在stack上做small string或small vector优化比在heap上效率高吗?

我看到glibc++的string和llvm的vector的small string或small vector是用的stack空间, 可是直接在hea…
关注者
134
被浏览
23,530
登录后你可以
不限量看优质回答私信答主深度交流精彩内容一键收藏

题主的问题:

在stack上做small string或small vector优化比在heap上效率高吗?
我看到glibc++的string和llvm的vector的small string或small vector是用的stack空间,

这是个很有误导性的提问方式——重点并非stack vs heap,而是数据是内联在对象中还是位于对象外的别的地方。

在stack上做small string或small vector优化比在heap上效率高吗? - 匿名用户的回答

已经写得不错,我只是想突出一下问题真正的重点。

其实指针、对象与数据的关系,从存储(内存布局)的角度看,有很多种情况。下面简单说几种。

1. 数据在对象外

典型的例子有naïve方式实现的vector,像这样:

template <typename T>
class vector {
  T* data_;
  int size_;
  int capacity_;
};

此时vector对象与真正的数据之间的关系是这样的:

  vector<T>
[ data_     ] -> [ ... 真正的数据 ... ]
[ size_     ]
[ capacity_ ]

如果有这样的代码使用vector:

int foo() {
  vector<int> v;
  v.push_back(42);
  return v.size();
}

那么这个vector<int>实例确实会被分配在栈上(或者被进一步优化,这里不讨论)。

而如果有另外的代码使用vector:

struct Foo {
  vector<int> v;
};

int bar() {
  Foo* foo = new Foo();
  foo->v.push_back(42);
  // ...
}

那么这个vector<int>就会作为Foo实例的一部分被分配在堆上。

——这不是重点,重点是vector真正的数据并不在vector对象里,而在别的地方。访问其真正的对象需要一层间接。

下面就开始玩花样了。在进入花样之前请让我先贴出

@Tim Shen

大大在评论区里的提醒:

用vector做small buffer举例稍有不妥,因为“move vector不invalidate iterator”似乎有人提出,很多实现也是已然如此。参见 c++ - Am I guaranteed that pointers to std::vector elements are valid after the vector is moved?

嗯所以下面的(2)和(3)都不适用于STL的std::vector<T>喔。请想像这是某个平行世界的别的“Vector<T>”类型 >_<

2. 数据在对象内

对可变长度类型,例如string、vector之类的,类型相同的对象里真正装的数据的长度可能不同的,在特殊情况下可以优化成把数据直接装在对象内部。

还是用vector为例子。假如我调整一下它的布局,变成这样:

template <typename T>
class vector {
  int size_;
  int capacity_;
  T* data_;
};

(注意现在 size_ 字段放在最前面了)

那么我就可以拿size_字段作为一个标记。假定这里是在一个32位平台上,int与指针类型都是32位的话,那么就可以知道 capacity_ 与 data_ 字段加起来有8字节,而我们可以在数据内容不大于8字节时,把这俩字段的空间“偷”来存实际数据。假如我们在这样的vector<int>里存了2个元素,那么它的内存布局其实是这样:

 vector<int>
[ size_ = 2 ]
[ data[0]   ] // capacity_
[ data[1]   ] // data_

这里假定我们实现了逻辑,判断当 size_ <= 2 时采用上述“内联”布局,并且知道 capacity_ 逻辑上肯定等于2(因为对象内的空间只够装这么多)于是把它的空间偷出来用,data_ 字段由于数据被内联所以也不需要了可以被偷出来用。

3. 数据在指针里

啥?这是什么意思?

还是拿vector举例。假如我们所处的环境不流行直接用vector的值,而是喜欢用指针——vector<T>*——的话,即便我们做了(2)的小对象优化,数据还是离我们有一步之遥:

 vector<T>*      vector<T>
[ a ptr    ] -> [ ...     ]

那我们在这种前提下能不能再用“偷空间”的思路来把数据挪到离我们更近的地方呢?答案是肯定的。

假设我们在32位平台上,一个指针有4个字节,而通常对象分配空间时底层的内存分配器(例如malloc())会对分配做对齐,例如说malloc()总是得到8字节对齐的指针——这样这个指针的低3位就总是0。

那么我们就可以进一步偷取指针内的空间,对vector<int8_t>玩这样的花招:(下面用点伪代码…)

bool vector<int8_t>::is_tagged() {
  return ((intptr_t) this) & 0x7 != 0;
}

int vector<int8_t>::size() {
  return is_tagged() ? (((intptr_t) this) & 0x7) - 1
                     : size_;
}

int8_t vector<int8_t>::at(int index) {
  if (is_tagged()) {
    assert(index < 3, "oob");
    intptr_t data = (intptr_t) this;
    int8_t* base = (int8_t*) &data;
    return base[index + 1]; // assuming little-endian
  } else {
    return data_[index];
  }
}

(用伪代码只是为了码字快点写得方便,懒得处理边角情况。好孩子请不要照抄…)

这样,在处理 size <= 3 的vector<int8_t>时,数据就可以直接藏在指针里了。

本来一个指针会是这样:(在内存中)

      vector<int8_t>*
[ byte0 byte1 byte2 byte3 ]

假定是在小端(little-endian)平台上,那么这块内存表达的逻辑值是:

byte3 << 24 | byte2 << 16 | byte1 << 8 | byte0

也就是内存中靠前的表示的是逻辑数据的低位字节。

一个普通的vector<int8_t>指针是这样的:(二进制)

      vector<int8_t>*
[ xxxxx000 byte1 byte2 byte3 ]

也就是说最低3位为0。

而指针里一个藏了数据的指针则是这样的:

      vector<int8_t>*
[ xxxxx010 data[0] byte2 byte3 ]

这里最低3位不是0,而是2,我们就把2 - 1 = 1当作size,并且把原本指针的一部分的byte1偷出来当作数据存储空间用。

重要注意:在这种情况下,数据直接就藏在指针里了,所以其实根本连真正的对象都不需要。

一般用指针指向对象在内存中是这样:

[ ptr ] -> [ data ]

而上面这种情况下,数据直接藏在伪装的指针里,于是就变成了:

[ fake ptr (inlined data) ]

看,这个伪“指针”没有指向实际的对象了。

这种在指针中藏数据的做法,在动态语言的实现中相当常见,有个名字叫做“tagged pointer”——带标记的指针。

这种技巧玩得很溜的有例如30年前就存在的众多Smalltalk实现们——它们的SmallInteger就是玩的这招。

而这个思路经由Smalltalk -> Self -> Strongtalk -> V8的路线也传到了V8这个JavaScript引擎上,它的Smi以及其它一些特殊类型就是这样实现的。

其它用了tagged pointer(或其升级版的NaN-boxing)的例子就不展开说了,现在已是多如牛毛 >_<