push_back,令人惊讶的是,它有一点开销,因为它实际上并不知道你已经保留了足够的空间,所以它总是需要检查。因为这个检查可以改变循环迭代之间的控制流,push_back 排除了编译器的自动向量化。
考虑这两个函数,其中第一个使用push_back,而第二个就地修改副本(或移入值):
auto exp1(std::vector<double> const& xs) -> std::vector<double> {
auto ys = std::vector<double>{};
ys.reserve(xs.size());
for(auto x : xs){ ys.push_back(std::exp(x)); }
}
auto exp2(std::vector<double> xs) -> std::vector<double> {
for(auto & x : xs){ x = std::exp(x); }
return xs;
}
We'll look at the assembly output,如果在 GCC 9.1 中编译时使用
gcc -std=c++17 -O3 -march=skylake-avx512
这是exp1的内部循环(嵌入了相当多的额外代码,因为你已经reserved,所以这些代码永远不会被执行):
.L45:
add rbx, 8
vmovsd QWORD PTR [r14], xmm0
add r14, 8
cmp r12, rbx
je .L44
.L18:
vmovsd xmm0, QWORD PTR [rbx]
call exp
vmovsd QWORD PTR [rsp], xmm0
cmp rbp, r14
jne .L45
这是exp2的:
.L53:
vmovsd xmm0, QWORD PTR [rbx]
add rbx, 8
call exp
vmovsd QWORD PTR [rbx-8], xmm0
cmp rbp, rbx
jne .L53
实际上,它们基本相同,因为exp 很复杂,GCC 不知道如何自动对其进行向量化。但是,考虑在内循环中发生更简单的事情的情况:
auto sq1(std::vector<double> const& xs) -> std::vector<double> {
auto ys = std::vector<double>{};
ys.reserve(xs.size());
for(auto x : xs){ ys.push_back(x*x); }
}
auto sq2(std::vector<double> xs) -> std::vector<double> {
for(auto & x : xs){ x *= x; }
return xs;
}
这是sq1的内部循环:
.L89:
vmovsd QWORD PTR [rsi], xmm0
add rbx, 8
add rsi, 8
mov QWORD PTR [rsp+24], rsi
cmp rbp, rbx
je .L72
.L75:
vmovsd xmm0, QWORD PTR [rbx]
mov rsi, QWORD PTR [rsp+24]
vmulsd xmm0, xmm0, xmm0
vmovsd QWORD PTR [rsp+8], xmm0
cmp rsi, QWORD PTR [rsp+32]
jne .L89
这里是sq2。请注意,它使用 vmulpd 和 ymm 寄存器,并且一次跳转 32 个字节而不是一次 8 个字节。
.L11:
vmovupd ymm0, YMMWORD PTR [rdx]
add rdx, 32
vmulpd ymm0, ymm0, ymm0
vmovupd YMMWORD PTR [rdx-32], ymm0
cmp rdx, rcx
jne .L11
当然,这个内部循环 sn-p 有点误导:如果它的大小没有被 4 整除,它隐藏了大量用于处理 std::vector 其余部分的代码。不过,我的主要关键是,是的,你实际上可以做得比reserve + push_back 稍微好一点(当我第一次发现时,这让我很惊讶),如果我们不处理exp,情况会好得多特别是。