在stack上做small string或small vector优化比在heap上效率高吗?
题主的问题:
在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)的例子就不展开说了,现在已是多如牛毛 >_<