晚上好。
我知道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);
到目前为止,太好了。上面的初始化变量的代码未包含在基准测试中。现在,让我们编写一个函数来组合double
和v1
或v2
和a1
的元素(a2
):
// 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进行此操作。在这两种情况下,数组版本的运行速度几乎都是矢量版本的两倍。
有人知道为什么吗?谢谢!
答案 0 :(得分:6)
您对数组必须比矢量慢的基本假设是不正确的。由于向量要求将其数据存储在分配的内存中(默认分配器使用动态内存),因此必须使用需要使用的值存储在堆内存中,并在执行该程序期间重复访问这些值。相反,数组使用的值可以完全优化,并且可以直接在程序的汇编中直接引用。
启用优化后,下面是assemble_vec
和assemble_arr
函数的GCC汇编程序:
[-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
版本的代码执行得更快。
答案 1 :(得分:3)
C ++别名规则不允许编译器证明glob[i] += stuff
没有修改const vec v1 {1.0,-1.0,1.0};
或v2
的元素之一。
const
上的 std::vector
意味着可以假定“控制块”指针在构造后不会被修改,但是内存仍然是动态分配的,编译器知道它实际上具有静态存储中的const double *
。
std::vector
实现中的任何内容都不能使编译器排除指向该存储的某些 other non-const
指针。例如,double *data
控制块中的glob
。
C ++没有为库实现者提供一种向编译器提供不同std::vector
的存储不重叠的信息。他们不能使用{{1 }}(即使在支持该扩展的编译器上也是如此),因为这可能会破坏使用矢量元素地址的程序。参见the C99 documentation for restrict
。
但是使用__restrict
和const arr a1 {1.0,-1.0,1.0};
,双打本身可以进入只读静态存储,并且编译器知道这一点。 因此,它可以在编译时评估a2
等,例如 。在@Xirema的答案中,您可以看到asm输出加载常量comb(a1[0],a2[0]);
和.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<>
不会修改{{1} }。
编译器可以检查double
是否与double*
中的任何一个都不重叠,并且针对该情况制作了不同版本的循环,将三个{ {1}}的结果不在循环内。
这是一个有用的优化,某些编译器在无法证明没有别名的情况下会在自动矢量化时进行;在您的情况下,gcc不会检查重叠部分显然是错过的优化,因为它会使函数运行得更快。但是问题是,编译器是否可以合理地猜测值得散发在运行时检查是否有重叠的asm,并且具有同一循环的2个不同版本。通过配置文件引导的优化,它将知道循环很热(运行许多迭代),并且值得花费额外的时间。但是如果没有这些,编译器可能不想冒过多地膨胀代码的风险。
实际上, ICC19(英特尔的编译器)确实在执行 这样的操作,但这很奇怪:如果您查看glob.data() + 0 .. N-3
(on the Godbolt compiler explorer)的开头,它将加载v1/v1.data() + 0 .. 2
中的数据指针,然后加8并再次减去该指针,产生一个常量comb()
。然后,它在运行时在assemble_vec
(未采用)上分支,然后在glob
(采用)上分支。看起来这应该是重叠检查,但是它可能两次使用相同的指针而不是v1和v2? (8
)
无论如何,它最终运行了8 > 784
循环,该循环提升了所有3个-8 < 784
的计算,有趣的是,一次循环执行了2次迭代,并加载了4个标量,并将其存储到784 = 8*100 - 16 = sizeof(double)*N - 16
中,和6 ..B2.19
(标量双精度)添加指令。
在函数体的其他地方,有一个向量化的版本,它使用3x comb()
(压缩后的双倍),仅存储/重新加载部分重叠的128位向量。这将导致存储转发停顿,但是乱序执行可能能够将其隐藏。真的很奇怪,它在运行时分支到一个计算上,每次都会产生相同的结果,并且从不使用该循环。像臭虫一样闻起来。
如果glob[i+0..4]
是静态数组,您仍然会遇到问题。因为编译器无法知道addsd
没有指向该静态数组。
我认为,如果您通过 addpd
访问它,那根本不会有问题。这将向编译器保证glob[]
不会影响您通过其他指针(例如v1/v2.data()
)访问的任何值。
在实践中,不是不能为gcc,clang或ICC double *__restrict g = &glob[0];
提升g[i] += ...
。但这对MSVC是确实。 (我已经读过MSVC不会进行基于类型的严格别名优化,但是它不会在循环内重新加载v1[0]
,因此它已经以某种方式弄清楚了存储双精度不会修改指针。但是MSVC确实做到了。定义comb()
的行为以进行类型处理,这与其他C ++实现不同。)
要进行测试,请I put this on Godbolt
-O3
我们从循环外的MSVC中获得了
glob.data()
然后,我们得到一个看起来高效的循环。
因此,这是gcc / clang / ICC的优化遗漏。
答案 2 :(得分:1)
我认为关键是您使用的存储空间太小(六倍),这使编译器在std::array
情况下可以通过将值放置在寄存器中来完全消除RAM中的存储。如果更优化,编译器可以将堆栈变量存储到寄存器中。这样可以将内存访问量减少一半(仅保留写入glob
)。在std::vector
的情况下,编译器无法执行这种优化,因为使用了动态内存。尝试为a1, a2, v1, v2