[英]C++ performance std::array vs std::vector
晚上好。
我知道C风格的数组或std :: array并不比矢量快。 我一直使用矢量(我使用它们很好)。 但是,我有一些情况,使用std :: array比使用std :: vector更好,我不知道为什么(用clang 7.0和gcc 8.2测试)。
让我分享一个简单的代码:
#include <vector>
#include <array>
// some size constant
const size_t N = 100;
// some vectors and arrays
using vec = std::vector<double>;
using arr = std::array<double,3>;
// arrays are constructed faster here due to known size, but it is irrelevant
const vec v1 {1.0,-1.0,1.0};
const vec v2 {1.0,2.0,1.0};
const arr a1 {1.0,-1.0,1.0};
const arr a2 {1.0,2.0,1.0};
// vector to store combinations of vectors or arrays
std::vector<double> glob(N,0.0);
到现在为止还挺好。 初始化变量的上述代码不包含在基准测试中。 现在,让我们编写一个函数来组合v1
和v2
或a1
和a2
元素( double
):
// some combination
auto comb(const double m, const double f)
{
return m + f;
}
基准功能:
void assemble_vec()
{
for (size_t i=0; i<N-2; ++i)
{
glob[i] += comb(v1[0],v2[0]);
glob[i+1] += comb(v1[1],v2[1]);
glob[i+2] += comb(v1[2],v2[2]);
}
}
void assemble_arr()
{
for (size_t i=0; i<N-2; ++i)
{
glob[i] += comb(a1[0],a2[0]);
glob[i+1] += comb(a1[1],a2[1]);
glob[i+2] += comb(a1[2],a2[2]);
}
}
我用clang 7.0和gcc 8.2试过这个。 在这两种情况下,阵列版本的速度几乎是矢量版本的两倍。
有谁知道为什么? 谢谢!
数组必然比向量慢的基本假设是不正确的。 因为向量要求将其数据存储在已分配的内存中(默认分配器使用动态内存),所以需要使用的值必须存储在堆内存中,并在执行此程序时重复访问。 相反,数组使用的值可以完全优化,并在程序的程序集中直接引用。
下面是GCC在打开优化后吐出为assemble_vec
和assemble_arr
函数的assemble_vec
集:
[-snip-]
//==============
//Vector Version
//==============
assemble_vec():
mov rax, QWORD PTR glob[rip]
mov rcx, QWORD PTR v2[rip]
mov rdx, QWORD PTR v1[rip]
movsd xmm1, QWORD PTR [rax+8]
movsd xmm0, QWORD PTR [rax]
lea rsi, [rax+784]
.L23:
movsd xmm2, QWORD PTR [rcx]
addsd xmm2, QWORD PTR [rdx]
add rax, 8
addsd xmm0, xmm2
movsd QWORD PTR [rax-8], xmm0
movsd xmm0, QWORD PTR [rcx+8]
addsd xmm0, QWORD PTR [rdx+8]
addsd xmm0, xmm1
movsd QWORD PTR [rax], xmm0
movsd xmm1, QWORD PTR [rcx+16]
addsd xmm1, QWORD PTR [rdx+16]
addsd xmm1, QWORD PTR [rax+8]
movsd QWORD PTR [rax+8], xmm1
cmp rax, rsi
jne .L23
ret
//=============
//Array Version
//=============
assemble_arr():
mov rax, QWORD PTR glob[rip]
movsd xmm2, QWORD PTR .LC1[rip]
movsd xmm3, QWORD PTR .LC2[rip]
movsd xmm1, QWORD PTR [rax+8]
movsd xmm0, QWORD PTR [rax]
lea rdx, [rax+784]
.L26:
addsd xmm1, xmm3
addsd xmm0, xmm2
add rax, 8
movsd QWORD PTR [rax-8], xmm0
movapd xmm0, xmm1
movsd QWORD PTR [rax], xmm1
movsd xmm1, QWORD PTR [rax+8]
addsd xmm1, xmm2
movsd QWORD PTR [rax+8], xmm1
cmp rax, rdx
jne .L26
ret
[-snip-]
这些代码部分之间存在一些差异,但关键区别在于分别在.L23
和.L26
标签之后,对于矢量版本,与阵列版本相比,数字通过效率较低的操作码加在一起。正在使用(更多)SSE指令。 与阵列版本相比,矢量版本还涉及更多的内存查找。 这些因素相互结合将导致代码对std::array
版本的代码执行速度比std::vector
版本快。
C ++别名规则不允许编译器证明glob[i] += stuff
不会修改const vec v1 {1.0,-1.0,1.0};
其中一个元素const vec v1 {1.0,-1.0,1.0};
或v2
。
std::vector
上的const
意味着可以假设“控制块”指针在构造之后不被修改,但是内存仍然是动态分配的,所有编译器都知道它在静态存储中实际上有一个const double *
。
std::vector
实现中没有任何内容允许编译器排除指向该存储的其他一些non-const
指针。 例如, glob
的控制块中的double *data
。
C ++没有为库实现者提供一种方法,为编译器提供不同std::vector
s的存储不重叠的信息。 他们不能使用__restrict
(甚至在支持该扩展的编译器上),因为这可能会破坏带有vector元素地址的程序。 有关restrict
请参阅C99文档 。
但是使用const arr a1 {1.0,-1.0,1.0};
和a2
,双打本身可以进入只读静态存储,编译器知道这一点。 因此它可以评估comb(a1[0],a2[0]);
等等在编译时 。 在@Xirema的答案中,您可以看到asm输出加载常量.LC1
和.LC2
。 (只有两个常数,因为这两个a1[0]+a2[0]
和a1[2]+a2[2]
是1.0+1.0
。循环体使用xmm2
作为源操作数为addsd
两次,另一恒定一次)。
不,再次因为潜在的混叠。 它不知道存储到glob[i+0..3]
中的存储不会修改v1[0..2]
的内容,因此每次通过循环后它都会从v1和v2重新加载到glob
。
(但它不必重新加载vector<>
控制块指针,因为基于类型的严格别名规则让它假设存储double
不会修改double*
。)
编译器可以检查glob.data() + 0 .. N-3
没有与v1/v1.data() + 0 .. 2
任何一个重叠,并为该情况制作了不同版本的循环,将三个comb()
结果提升出循环。
这是一些有用的优化,一些编译器在自动矢量化时会做,如果它们不能证明缺少别名 ; 在你的情况下,gcc不会检查重叠,这显然是一个错过的优化,因为它会使函数运行得更快。 但问题是编译器是否可以合理地猜测在运行时检查重叠是否值得发出asm,并且具有相同循环的2个不同版本。 通过配置文件引导优化,它会知道循环很热(运行多次迭代),并且值得花费额外的时间。 但如果没有这个,编译器可能不希望冒太多风险。
ICC19(英特尔的编译器)实际上确实在这里做了类似的事情,但它很奇怪:如果你看一下assemble_vec
的开头( 在Godbolt编译器浏览器上 ),它从glob
加载数据指针,然后加上8并再次减去指针,产生一个常数8
。 然后它在运行时分支8 > 784
(未采用)然后-8 < 784
(采用)。 看起来这应该是重叠检查,但它可能使用相同的指针两次而不是v1和v2? ( 784 = 8*100 - 16 = sizeof(double)*N - 16
)
无论如何,它最终运行了..B2.19
循环,它提升了所有3个comb()
计算,有趣的是一次循环2次迭代,4个标量加载并存储到glob[i+0..4]
,并且6 addsd
(标量双)添加指令。
在函数体的其他地方,有一个矢量化版本,它使用3x addpd
(打包双addpd
),只存储/重新加载部分重叠的128位向量。 这将导致存储转发停顿,但是无序执行可能能够隐藏它。 它非常奇怪,它在运行时分支计算,每次都会产生相同的结果,并且从不使用该循环。 闻起来像个臭虫。
如果glob[]
是一个静态数组 ,你仍然遇到了问题。 因为编译器无法知道v1/v2.data()
没有指向那个静态数组。
我想如果你通过double *__restrict g = &glob[0];
访问它double *__restrict g = &glob[0];
,根本不存在问题。 这将保证编译器g[i] += ...
不会影响您通过其他指针访问的任何值,如v1[0]
。
实际上,这不能为gcc,clang或ICC -O3
提升comb()
。 但它确实适用于MSVC。 (我已经读过MSVC没有进行基于类型的严格别名优化,但是它没有在循环中重新加载glob.data()
所以它以某种方式弄清楚存储一个double不会修改指针。但MSVC会与其他C ++实现不同,定义*(int*)my_float
的行为以进行类型惩罚。)
为了测试, 我把它放在Godbolt上
//__attribute__((noinline))
void assemble_vec()
{
double *__restrict g = &glob[0]; // Helps MSVC, but not gcc/clang/ICC
// std::vector<double> &g = glob; // actually hurts ICC it seems?
// #define g glob // so use this as the alternative to __restrict
for (size_t i=0; i<N-2; ++i)
{
g[i] += comb(v1[0],v2[0]);
g[i+1] += comb(v1[1],v2[1]);
g[i+2] += comb(v1[2],v2[2]);
}
}
我们从循环外的MSVC得到这个
movsd xmm2, QWORD PTR [rcx] # v2[0]
movsd xmm3, QWORD PTR [rcx+8]
movsd xmm4, QWORD PTR [rcx+16]
addsd xmm2, QWORD PTR [rax] # += v1[0]
addsd xmm3, QWORD PTR [rax+8]
addsd xmm4, QWORD PTR [rax+16]
mov eax, 98 ; 00000062H
然后我们得到一个有效的循环。
所以这是对gcc / clang / ICC的错过优化。
我认为关键是你使用的存储空间太小(六个双倍),这使得编译器在std::array
情况下,通过在寄存器中放置值来完全消除RAM存储。 如果更优化,编译器可以将堆栈变量存储到寄存器。 这减少了一半的内存访问(只写入glob
仍然存在)。 在std::vector
的情况下,由于使用了动态内存,编译器无法执行这样的优化。 尝试为a1, a2, v1, v2
使用明显更大的尺寸
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.