【问题标题】:How is std::vector faster than a plain array?std::vector 如何比普通数组快?
【发布时间】:2014-11-29 03:16:56
【问题描述】:

我在对循环缓冲区进行基准测试时偶然发现了这一点。谁能解释一下 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

【问题讨论】:

  • 可能只是分配与缓存中的其他数据对齐的方式。附言你想要resize 而不是reserve。
  • @MarkRansom 谢谢,更新了代码。结果仍然成立。
  • GCC 4.8 带来了更大的不同。我看到向量为 0.6s,数组为 1.8s。优化级别很重要,-O3 为向量获得 0.9 秒。
  • 有一个半对的答案,刚刚被删除,但观察得很好。数组元素位于 container 结构中,而向量元素位于其他位置并通过指针访问。这对优化也有一些影响。您可以通过将数组更改为 uint_pair* 并在构造函数中分配它来缩小差距。在我的测试中,它的性能翻了一番,但它仍然比向量慢一点。
  • 用另一个编译器试过,反汇编显示问题。问题是数组与循环变量存储在同一类内存中,编译器对别名假设保守,并将循环变量视为易失性。向量不是问题,它存储在堆而不是堆栈上。使用 uint_pair* 数据而不是 uint_pair 数据[16] 消除了差异。

标签: c++ performance c++11 vector stl


【解决方案1】:

以下是消除差异的方法。而不是你的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 样式数组相同。

【讨论】:

  • 有趣的是,就性能而言,std::array 更像是使用 C 样式数组,而不是使用 std::vector。
  • @5gon12eder 正确,它只是围绕 C 样式数组的类似 STL 的包装器。我也试过了,在这种情况下,它的行为就像 C 样式的数组。
  • 在我的机器上,我观察到有些不同的结果。 std::vector 的循环总是有 5 条指令。该数组需要 7 个与 OP 的代码,但只需要 4 个与您的代码,因此它比std::vector 更快(也由计时结果支持)。 std::array 总是生成与 C 样式数组相同的汇编代码。 [x86_64 GNU/Linux 上的 GCC 4.9.1 20140903(预发行版)]
【解决方案2】:

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

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

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

编辑:

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

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

标准库的实现者正在为你竭尽全力:)

【讨论】:

  • 您声称问题在于内存对齐,但您没有证明这一点。您使用了与我类似的 add 函数,我证明仅此更改即可消除性能差异。所以对齐变化根本没有影响(换句话说,编译器已经处理好了)。
  • 你是对的,内置在结构中的数组和通过指针访问的数组是有区别的。但这并不能解释整个性能差异(请参阅我对原始问题的评论)。我还想为您的缓存声明提供一些证据。数据总计少于 20 个整数。所有方法都应该在缓存中。
  • 我们一定会得到不同的结果。使用您的“set”或我更改的“add”,堆分配的 c 数组和 std::vector 之间的性能差异 not 相等 - 仍然有 ~0.04s 的减速c数组。使用正确对齐的堆分配可以完全消除这种差异,顺便说一句,这是编译器不会为您做的事情。因此,修改后的“添加”以及对齐的堆分配都是必要的。
【解决方案3】:

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

向量的汇编代码

.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

【讨论】:

  • 我不相信移动会开始。首先uint_pair 声明了一个构造函数,因此它没有默认的移动构造函数。第二:add函数中operator=的参数是一个左值。第三:即使定义了一个 move ctor,两个未签名的成员仍然需要被复制。
猜你喜欢
  • 2011-09-16
  • 2012-04-18
  • 2021-05-26
  • 2011-05-08
  • 1970-01-01
  • 2017-05-10
  • 1970-01-01
  • 2014-10-24
  • 2018-04-21
相关资源
最近更新 更多