问题 std :: vector如何比普通数组更快?


在对循环缓冲区进行基准测试时,我偶然发现了这一点。任何人都可以解释一下std :: vector如何在这个实例中胜过普通数组?

#include <iostream>
#include <vector>

struct uint_pair {
    unsigned int a, b;
    uint_pair (unsigned int x = 0, unsigned int y = 0) : a(x), b(y) {}
};

struct container {
    unsigned int pos;

#ifdef USE_VECTOR
    std::vector<uint_pair> data;
    container() : pos(0) { data.resize(16); }
#else
    uint_pair data[16];
    container() : pos(0) {}
#endif

    void add(uint_pair val) {
        data[++pos % 16] = val;
    }
};

int main() {
    container c;
    for (unsigned int i = 0; i < 1000000000; i++) c.add(uint_pair{i, i});
    std::cout << c.data[0].a << " " << c.data[0].b << std::endl;
}

这些是我使用GCC得到的结果(与Clang类似):

g++ -o bench -std=c++0x -Os main.cpp -D'USE_VECTOR'
real    0m8.757s
user    0m8.750s
sys     0m0.002s

g++ -o bench -std=c++0x -Os main.cpp
real    0m9.215s
user    0m9.209s
sys     0m0.002s

12827
2017-10-04 04:13


起源

可能只是分配与缓存中的其他数据对齐的方式。附:你要 resize 不 reserve。 - Mark Ransom
@MarkRansom谢谢,更新了代码。结果仍然有效。 - amarcus
GCC 4.8带来了更大的差异。我看到矢量为0.6s,阵列为1.8s。优化级别很重要,-O3的矢量为0.9s。 - Adam
我唯一能想到的是,不知何故,对于向量,它将缓冲区地址保存在寄存器中,但不知何故不能为原始数组做到这一点。查看机器代码。尝试直接指向数组而不是数组。 - Cheers and hth. - Alf
有一个半正确的答案刚刚删除,但有一个很好的观察。数组元素在你的 containerstruct,而vector元素在其他地方并通过指针访问。这对优化也有一些影响。您可以通过将阵列更改为a来缩小差距 uint_pair* 并在构造函数中分配它。在我的测试中,它的性能翻倍,但它仍然比矢量慢一点。 - Adam


答案:


这是你如何消除差异。而不是你的 add,使用这样的函数:

void set(unsigned int x, unsigned int y) {
    ++pos;
    data[pos % 16].a = x;
    data[pos % 16].b = y;
}

像这样叫:

for (unsigned int i = 0; i < 1000000000; i++) c.set(i, i);

这与您的完全相同,但它避免了语义上创建临时对象。看起来当你使用向量时,编译器能够更好地优化临时。

$ g++-4.8 -o bench -std=c++11 -Os main.cpp -DUSE_VECTOR
$ time ./bench 
999999999 999999999

real    0m0.635s
user    0m0.630s
sys 0m0.002s

$ g++-4.8 -o bench -std=c++11 -Os main.cpp
$ time ./bench 
999999999 999999999

real    0m0.644s
user    0m0.639s
sys 0m0.002s

在我的机器上 set 和 add 方法产生与载体相同的性能。只有阵列显示出差异。为了进一步提高优化,如果使用-O0进行编译,那么数组方法会稍快一些(但是比使用-Os慢10倍以上)。

这没有解释 为什么 编译器对这两者的处理方式不同。毕竟,矢量是由数组支持的。还有,一个 std::array 行为与您的C风格数组完全相同。


9
2017-10-04 04:46



有趣的是,表现明智, std::array 更像是使用C风格的数组而不是使用 std::vector。 - 5gon12eder
@ 5gon12eder正确,它只是一个围绕C风格数组的类似STL的包装器。我也试过了,在这种情况下它的行为就像C风格的数组一样。 - Adam
在我的机器上,我观察到一些不同的结果。的循环 std::vector 总是有5条说明。该阵列需要7个OP代码,但只有4个与你的相同,所以它甚至更快(也由时序结果支持)比 std::vector。 std::array 始终生成与C风格数组相同的汇编代码。 [x86_64 GNU / Linux上的GCC 4.9.1 20140903(预发布)] - 5gon12eder


一个问题是在结构中放置“pos”成员。

对于c-array,请记住它是连续存储在与“pos”成员相邻的内存中。当数据被推入c-数组时,必须发出额外的指令以偏移到通过“pos”成员的结构中。但是,写入向量不会产生这样的限制,因为它的内存位于其他位置。

要挤出更多性能,请确保最热门的数据位于缓存行的前面。

编辑:

为了使c数组的执行速度与向量一样快,必须在64位机器上的8字节边界上分配c数组。所以类似于:

uint_pair* data;
unsigned int pos;

container() : pos(0) {
    std::size_t bufSize = sizeof(uint_pair) * 17;
    void* p = new char[bufSize];
    p = std::align(8, sizeof(uint_pair), p, bufSize);
    data = reinterpret_cast<uint_pair*>(p);
}

稍加修改的添加功能:

void add(unsigned int x, unsigned int y) {
    auto& ref = data[pos++ % 16];
    ref.a = x;
    ref.b = y;
}

c阵列现在时代:

real    0m0.735s
user    0m0.730s
sys     0m0.002s

和std :: vector:

real    0m0.743s
user    0m0.736s
sys     0m0.004s

标准库实现者正在为您提供全程服务:)


2
2017-10-04 04:51



您的主张是问题是内存对齐,但您没有显示。你使用了类似的 add 我做过的功能,我表明只有这种改变才能消除性能差异。因此,对齐更改根本没有任何影响(换句话说,编译器已经处理过了)。 - Adam
你是对的,结构中内置的数组和通过指针访问的数组之间存在差异。但这并不能解释整个性能差异(请参阅我对原始问题的评论)。我还想看看你的缓存声明的一些证据。数据总计不到20个整数。所有方法都应该在缓存中。 - Adam
我们必须得到不同的结果。使用你的“set”或我改变的“add”,堆分配的c-array和std :: vector之间的性能差异是 不 相等 - c-array上仍有约0.04秒的减速。使用正确对齐的堆分配可以完全消除这种差异,顺便说一句,这是编译器不会为您做的事情。因此,修改后的“添加”以及对齐的堆分配都是必需的。 - d3coy


由于operator =(rvalue reference),C ++ 11编译器似乎为vector生成了更好的代码。 首先,在C ++ 03编译器中,普通数组比矢量快两倍。 其次,如果使用Adam建议的void set(unsigned int x,unsigned int y),则tehre没有区别。

向量的汇编代码

.L49:
leal    (%rdi,%rax), %esi
andl    $15, %esi
leaq    (%rdx,%rsi,8), %rsi
movl    %eax, (%rsi)
movl    %eax, 4(%rsi)
incq    %rax
cmpq    $1000000000, %rax
jne .L49

对于普通数组

.L3:
movl    12(%rsp), %edx
incl    %edx
movl    %edx, 12(%rsp)
andl    $15, %edx
leaq    12(%rsp,%rdx,8), %rdx
movl    %eax, 4(%rdx)
movl    %eax, 8(%rdx)
incl    %eax
cmpl    $1000000000, %eax
jne .L3

0
2017-10-08 06:46



我不相信一举一动。首先 uint_pair 声明一个构造函数,因此它没有默认的移动构造函数。第二:参数 operator= 在里面 add 函数是左值。第三:即使定义移动ctor,仍然必须复制两个未签名的成员。 - DarioP