C ++性能std :: array与std :: vector

时间:2019-02-05 20:52:50

标签: c++ performance benchmarking stdvector stdarray

晚上好。

我知道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);

到目前为止,太好了。上面的初始化变量的代码未包含在基准测试中。现在,让我们编写一个函数来组合doublev1v2a1的元素(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进行此操作。在这两种情况下,数组版本的运行速度几乎都是矢量版本的两倍。

有人知道为什么吗?谢谢!

3 个答案:

答案 0 :(得分:6)

GCC(可能还有Clang)正在优化数组,而不是向量

您对数组必须比矢量慢的基本假设是不正确的。由于向量要求将其数据存储在分配的内存中(默认分配器使用动态内存),因此必须使用需要使用的值存储在堆内存中,并在执行该程序期间重复访问这些值。相反,数组使用的值可以完全优化,并且可以直接在程序的汇编中直接引用。

启用优化后,下面是assemble_vecassemble_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


但是使用__restrictconst arr a1 {1.0,-1.0,1.0};,双打本身可以进入只读静态存储,并且编译器知道这一点。 因此,它可以在编译时评估a2等,例如 。在@Xirema的答案中,您可以看到asm输出加载常量comb(a1[0],a2[0]);.LC1。 (因为.LC2a1[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-3on 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

使用大得多的尺寸