Eigen::VectorXd::operator += 似乎比循环 std::vector 慢 ~69%

Eigen::VectorXd::operator += seems ~69% slower than looping through a std::vector

下面的代码(需要 google 基准测试)填充两个向量并将它们相加,将结果存储在第一个向量中。对于我使用 Eigen::VectorXdstd::vector 进行性能比较的矢量类型:

#include <Eigen/Core>
#include <benchmark/benchmark.h>
#include <vector>

auto constexpr N = 1024u;

template <typename TVector>
TVector generate(unsigned min) {
    TVector v(N);
    for (unsigned i = 0; i < N; ++i)
        v[i] = static_cast<double>(min + i);
    return v;
}

auto ev1 = generate<Eigen::VectorXd>(0);
auto ev2 = generate<Eigen::VectorXd>(N);
auto sv1 = generate<std::vector<double>>(0);
auto sv2 = generate<std::vector<double>>(N);

void add_vectors(Eigen::VectorXd& v1, Eigen::VectorXd const& v2) {
    v1 += v2;
}

void add_vectors(std::vector<double>& v1, std::vector<double> const& v2) {
    for (unsigned i = 0; i < N; ++i)
        v1[i] += v2[i];
}

static void eigen(benchmark::State& state) {
    for (auto _ : state) {
        add_vectors(ev1, ev2);
        benchmark::DoNotOptimize(ev1);
    }
}

static void standard(benchmark::State& state) {
    for (auto _ : state) {
        add_vectors(sv1, sv2);
        benchmark::DoNotOptimize(sv1);
    }
}

BENCHMARK(standard);
BENCHMARK(eigen);

我 运行 它在 Intel Xeon E-2286M @2.40Ghz 上,使用 Eigen 3.3.9、MSVC 16.11.2 以及(除其他外)这些相关的编译器开关 /GL/Gy/O2/D "NDEBUG"/Oi/arch:AVX。典型的输出如下所示:

Run on (16 X 2400 MHz CPU s)
CPU Caches:
  L1 Data 32K (x8)
  L1 Instruction 32K (x8)
  L2 Unified 262K (x8)
  L3 Unified 16777K (x1)
--------------------------------------------------
Benchmark           Time           CPU Iterations
--------------------------------------------------
standard           99 ns        100 ns    7466667
eigen             169 ns        169 ns    4072727

这似乎表明在 std::vector 上运行比在 Eigen::VectorXd 上运行快 ~69%。在反汇编中,紧密循环如下所示:

// For Eigen::VectorXd
00007FF672221A11  vmovupd     ymm0,ymmword ptr [rcx+rax*8]  
00007FF672221A16  vaddpd      ymm1,ymm0,ymmword ptr [r8+rax*8]  
00007FF672221A1C  vmovupd     ymmword ptr [r8+rax*8],ymm1  
00007FF672221A22  add         rax,4  
00007FF672221A26  cmp         rax,rdx  
00007FF672221A29  jge         eigen+0C7h (07FF672221A37h)  
00007FF672221A2B  mov         rcx,qword ptr [rsp+48h]  
00007FF672221A30  mov         r8,qword ptr [rsp+58h]  
00007FF672221A35  jmp         eigen+0A1h (07FF672221A11h)  

// For std::vector
00007FF672221B40  vmovups     ymm1,ymmword ptr [rax+rdx-20h]  
00007FF672221B46  vaddpd      ymm1,ymm1,ymmword ptr [rax+rcx-20h]  
00007FF672221B4C  vmovups     ymmword ptr [rax+rcx-20h],ymm1  
00007FF672221B52  vmovups     ymm1,ymmword ptr [rax+rdx]  
00007FF672221B57  vaddpd      ymm1,ymm1,ymmword ptr [rax+rcx]  
00007FF672221B5C  vmovups     ymmword ptr [rax+rcx],ymm1  
00007FF672221B61  lea         rax,[rax+40h]  
00007FF672221B65  sub         r8,1  
00007FF672221B69  jne         standard+0C0h (07FF672221B40h)  

可以注意到,两者都使用 vaddpd 一次加 4 double。但是,对于 std::vector,编译器展开循环以每次迭代执行 2 vaddpd,但对于 Eigen::VectorXd 却没有执行相同的操作。另一个潜在的重要区别是 std::vector 的循环对齐到 32 字节(地址以 0x40 = 64 = 2*32 结尾)。

FWIW:我添加了 /Qvec-report:2 并且编译器报告:

[...]\Core\AssignEvaluator.h(415) : info C5002: loop not vectorized due to reason '1305'

reason 1305表示“类型信息不足”。

我有根据的猜测是,Eigen 努力使用内在函数(这里 _mm256_add_pd)会适得其反,并且会混淆编译器。让编译器做它的事情(自动矢量化)似乎是一个更好的主意。我是否遗漏了什么或者这可以被视为 Eigen 错误(错过优化机会)?

TL;DR: 问题主要来自constant loop bound 而不是直接来自Eigen。实际上,在第一种情况下,Eigen 将向量的大小存储在向量属性中,而在第二种情况下,您明确使用常量 N.

聪明的编译器可以使用此信息更积极地展开循环,因为他们知道 N 相当大。展开带有小 N 的循环不是一个好主意,因为代码会更大并且必须由处理器读取。如果代码尚未加载到 L1 缓存中,则必须从其他缓存、RAM 甚至最坏情况下的存储设备中加载。增加的延迟通常大于执行具有小展开因子的顺序循环。这就是编译器并不总是展开循环的原因(至少展开因子不是很大)。

内联在这段代码中也起着重要的作用。事实上,如果函数是内联的,编译器可以 传播常量 并知道向量的大小,使其能够通过更积极地展开循环来进一步优化代码。但是,如果函数未内联,则编译器无法知道循环边界。聪明的编译器仍然可以生成条件算法来优化小循环和大循环,但这会使程序更大并引入小的开销。当代码可以向量化但循环边界未知或者编译时别名未知时(生成的变体数量很快就会很大,因此代码大小也会很大),像 ICC 和 Clang 这样的编译器确实会生成不同的代码替代方案。

请注意,内联函数可能还不够,因为常量传播可能会被处理运行时定义的变量或非内联函数调用的复杂条件所捕获。或者,持续传播的质量可能不足以满足目标示例的要求。

最后,别名 在编译器生成此代码中的 SIMD 指令(并可能更好地展开循环)的能力方面也发挥着关键作用。事实上,别名通常会阻止 SIMD 指令的使用,并且编译器检查别名并相应地生成快速实现并不总是那么容易。

检验假设

如果基于矢量的实现使用存储在矢量对象中的循环边界,则 MSVC 生成的代码在基准测试中未矢量化:常量未正确传播尽管函数内联。生成的代码应该慢得多。这是 generated code:

$LL24@standard:
        vmovsd  xmm0, QWORD PTR [r9+rcx*8]
        vaddsd  xmm1, xmm0, QWORD PTR [r8+rcx*8]
        vmovsd  QWORD PTR [r8+rcx*8], xmm1
        mov     rax, QWORD PTR std::vector<double,std::allocator<double> > sv1+8
        inc     edx
        sub     rax, QWORD PTR std::vector<double,std::allocator<double> > sv1
        sar     rax, 3
        mov     ecx, edx
        cmp     rcx, rax
        jb      SHORT $LL24@standard

如果基于 Eigen 的实现使用常量循环边界,则 MSVC 生成的代码很好地矢量化并在基准测试中正确展开:编译时常量有助于编译器生成展开 2 次的循环。它通过混合 SSE 和 AVX 指令来做到这一点,这是非常令人惊讶的(这一点将在下面讨论)。生成的代码应该比原始 Eigen 实现快得多。但是,由于意外使用了 SSE 指令,它可能不如初始矢量实现快。这是 generated code:

$LL24@eigen:
        vmovupd xmm1, XMMWORD PTR [rdx+rcx-16]
        vaddpd  xmm1, xmm1, XMMWORD PTR [rcx-16]
        vmovupd xmm2, XMMWORD PTR [rcx+rdx]
        vmovupd XMMWORD PTR [rcx-16], xmm1
        vaddpd  xmm1, xmm2, XMMWORD PTR [rcx]
        vmovupd XMMWORD PTR [rcx], xmm1
        vmovups ymm1, YMMWORD PTR [rdx+rcx+16]
        vaddpd  ymm1, ymm1, YMMWORD PTR [rcx+16]
        vmovups YMMWORD PTR [rcx+16], ymm1
        lea     rcx, QWORD PTR [rcx+64]
        sub     rax, 1
        jne     SHORT $LL24@eigen

补充说明

值得注意的是,为非内联版本生成的代码使用了非常低效的标量代码(通常是由于 N 是未知的并且指针别名预计是可能的)。

在你的情况下,在这样的循环中混合 SSE 和 AVX 指令显然是一个 次优策略 并且可能是一个 编译器 issue/bug。实际上,生成代码的执行速度肯定会受到像您这样的 Intel 处理器上的存储指令的限制。您的处理器每个周期可以执行 1 个存储指令,每个周期可以执行 2 个加载指令,并且每个周期可以计算 2 个矢量化加法。它每个周期最多可以执行 6 条微指令(来自 5 条独立指令和可能的 4 条缓存的附加指令)。因此,生成的混合 SSE 和 AVX 的代码每次迭代至少需要 3 个周期。同时,基于向量的原始实现可以在仅 2 个周期内执行 4 个加载、2 个存储、2 个加法和 3 个其他指令,如 lea/sub/branch(由于实际的微指令端口等复杂的硬件,实际上可能是 3 个)调度,微指令缓存)。但是,请注意,编译器参数并未指定为您的特定处理器架构(即 Intel Coffee Lake)优化代码。不过,我非常怀疑混合 SSE 和 AVX 代码是否会导致 AMD 处理器(或任何主流 x86 处理器)的性能显着提升。或者,我可能是因为 MSVC 未能完全检测到在这种情况下没有别名。

为了消除阻止代码向量化和循环展开的大多数混叠问题,可以使用 OpenMP SIMD 指令(例如 #pragma omp simd)。 MSVC 使用标志 /openmp:experimental 实验性地支持这一点。这是结果代码:

void add_vectors(Eigen::VectorXd& v1, Eigen::VectorXd const& v2) {
    #pragma omp simd
    for (unsigned i = 0; i < N; ++i)
        v1[i] += v2[i];
}

MSVC出人意料地生成了一个只有SSE指令的汇编代码,但是如果你启用了AVX2,那么它生成的代码还算不错:

$LL26@eigen:
        mov     rcx, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev1
        lea     rdx, QWORD PTR [rdx+128]
        mov     rax, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev2
        vmovupd ymm0, YMMWORD PTR [rdx+rcx-192]
        vaddpd  ymm0, ymm0, YMMWORD PTR [rdx+rax-192]
        vmovupd YMMWORD PTR [rdx+rcx-192], ymm0
        mov     rcx, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev1
        mov     rax, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev2
        vmovupd ymm0, YMMWORD PTR [rdx+rcx-160]
        vaddpd  ymm0, ymm0, YMMWORD PTR [rdx+rax-160]
        vmovupd YMMWORD PTR [rdx+rcx-160], ymm0
        mov     rcx, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev1
        mov     rax, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev2
        vmovupd ymm0, YMMWORD PTR [rdx+rcx-128]
        vaddpd  ymm0, ymm0, YMMWORD PTR [rdx+rax-128]
        vmovupd YMMWORD PTR [rdx+rcx-128], ymm0
        mov     rcx, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev1
        mov     rax, QWORD PTR Eigen::Matrix<double,-1,1,0,-1,1> ev2
        vmovupd ymm0, YMMWORD PTR [rdx+rcx-96]
        vaddpd  ymm0, ymm0, YMMWORD PTR [rdx+rax-96]
        vmovupd YMMWORD PTR [rdx+rcx-96], ymm0
        sub     r8, 1
        jne     $LL26@eigen

由于意想不到的无用 mov 指令,此代码仍不完美。

或者,可以使用 fixed-size Eigen vectors 以获得更好的性能。

最后,请注意其他编译器(如 Clang、ICC 和 GCC)在此基准测试中的表现非常不同。