首页 > 解决方案 > AVX 的乘法向量化比 SSE 慢

问题描述

我有一段代码在竞争激烈的锁下运行,所以它需要尽可能快。代码非常简单——它是一组数据的基本乘加,如下所示:

for( int i = 0; i < size; i++ )
{
    c[i] += (double)a[i] * (double)b[i];
}

在启用 SSE 支持的 -O3 下,代码正在按照我的预期进行矢量化。但是,打开 AVX 代码生成后,我得到了大约 10-15% 的减速而不是加速,我不知道为什么。

这是基准代码:

#include <chrono>
#include <cstdio>
#include <cstdlib>

int main()
{
    int size = 1 << 20;

    float *a = new float[size];
    float *b = new float[size];
    double *c = new double[size];

    for (int i = 0; i < size; i++)
    {
        a[i] = rand();
        b[i] = rand();
        c[i] = rand();
    }

    for (int j = 0; j < 10; j++)
    {
        auto begin = std::chrono::high_resolution_clock::now();

        for( int i = 0; i < size; i++ )
        {
            c[i] += (double)a[i] * (double)b[i];
        }

        auto end = std::chrono::high_resolution_clock::now();
        auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count();

        printf("%lluus\n", duration);
    }
}

这是 SSE 下生成的程序集:

0x100007340 <+144>:  cvtps2pd (%r13,%rbx,4), %xmm0
0x100007346 <+150>:  cvtps2pd 0x8(%r13,%rbx,4), %xmm1
0x10000734c <+156>:  cvtps2pd (%r15,%rbx,4), %xmm2
0x100007351 <+161>:  mulpd  %xmm0, %xmm2
0x100007355 <+165>:  cvtps2pd 0x8(%r15,%rbx,4), %xmm0
0x10000735b <+171>:  mulpd  %xmm1, %xmm0
0x10000735f <+175>:  movupd (%r14,%rbx,8), %xmm1
0x100007365 <+181>:  addpd  %xmm2, %xmm1
0x100007369 <+185>:  movupd 0x10(%r14,%rbx,8), %xmm2
0x100007370 <+192>:  addpd  %xmm0, %xmm2
0x100007374 <+196>:  movupd %xmm1, (%r14,%rbx,8)
0x10000737a <+202>:  movupd %xmm2, 0x10(%r14,%rbx,8)
0x100007381 <+209>:  addq   $0x4, %rbx
0x100007385 <+213>:  cmpq   $0x100000, %rbx           ; imm = 0x100000 
0x10000738c <+220>:  jne    0x100007340               ; <+144> at main.cpp:26:20

运行 SSE 基准测试的结果:

1411us
1246us
1243us
1267us
1242us
1237us
1246us
1242us
1250us
1229us

启用了 AVX 的生成程序集:

0x1000070b0 <+144>:  vcvtps2pd (%r13,%rbx,4), %ymm0
0x1000070b7 <+151>:  vcvtps2pd 0x10(%r13,%rbx,4), %ymm1
0x1000070be <+158>:  vcvtps2pd 0x20(%r13,%rbx,4), %ymm2
0x1000070c5 <+165>:  vcvtps2pd 0x30(%r13,%rbx,4), %ymm3
0x1000070cc <+172>:  vcvtps2pd (%r15,%rbx,4), %ymm4
0x1000070d2 <+178>:  vmulpd %ymm4, %ymm0, %ymm0
0x1000070d6 <+182>:  vcvtps2pd 0x10(%r15,%rbx,4), %ymm4
0x1000070dd <+189>:  vmulpd %ymm4, %ymm1, %ymm1
0x1000070e1 <+193>:  vcvtps2pd 0x20(%r15,%rbx,4), %ymm4
0x1000070e8 <+200>:  vcvtps2pd 0x30(%r15,%rbx,4), %ymm5
0x1000070ef <+207>:  vmulpd %ymm4, %ymm2, %ymm2
0x1000070f3 <+211>:  vmulpd %ymm5, %ymm3, %ymm3
0x1000070f7 <+215>:  vaddpd (%r14,%rbx,8), %ymm0, %ymm0
0x1000070fd <+221>:  vaddpd 0x20(%r14,%rbx,8), %ymm1, %ymm1
0x100007104 <+228>:  vaddpd 0x40(%r14,%rbx,8), %ymm2, %ymm2
0x10000710b <+235>:  vaddpd 0x60(%r14,%rbx,8), %ymm3, %ymm3
0x100007112 <+242>:  vmovupd %ymm0, (%r14,%rbx,8)
0x100007118 <+248>:  vmovupd %ymm1, 0x20(%r14,%rbx,8)
0x10000711f <+255>:  vmovupd %ymm2, 0x40(%r14,%rbx,8)
0x100007126 <+262>:  vmovupd %ymm3, 0x60(%r14,%rbx,8)
0x10000712d <+269>:  addq   $0x10, %rbx
0x100007131 <+273>:  cmpq   $0x100000, %rbx           ; imm = 0x100000 
0x100007138 <+280>:  jne    0x1000070b0               ; <+144> at main.cpp:26:20

运行 AVX 基准测试的结果:

1532us
1404us
1480us
1464us
1410us
1383us
1333us
1362us
1494us
1526us

请注意,使用两倍于 SSE 的指令生成的 AVX 代码并不重要——我已经尝试手动展开较小的展开(以匹配 SSE),但 AVX 仍然较慢。

对于上下文,我使用的是 macOS 11 和 Xcode 12,以及带有 Intel Xeon CPU E5-1650 v2 @ 3.50GHz 的 Mac Pro 6.1(垃圾箱)。

标签: c++performanceoptimizationsseavx

解决方案


更新:对齐并没有太大/根本没有帮助。可能还有另一个瓶颈,例如在打包的浮点->双转换中?此外,vcvtps2pd (%r13,%rbx,4), %ymm0只有一个 16 字节的内存源,所以只有存储是 32 字节的。我们没有任何 32 字节的拆分加载。(在仔细查看代码之前,我在下面写了答案。)


那是一个 IvyBridge CPU。您的数据是否按 32 对齐?如果不是,那么众所周知的事实是,缓存线在 32 字节加载或存储上的拆分对于那些旧的微架构来说是一个严重的瓶颈。那些早期的英特尔 AVX 支持 CPU 具有全宽 ALU,但它们运行 32 字节加载并作为 2 个单独的数据周期存储在来自同一个 uop 1的执行单元中,这使得高速缓存行拆分更加特殊(而且速度非常慢)案子。(https://www.realworldtech.com/sandy-bridge/7/)。与Haswell(和 Zen 2)及更高版本不同,后者具有 32 字节数据路径2

如此缓慢,以至于 GCC 的默认-mtune=generic代码生成甚至会拆分 256 位 AVX 加载和存储,这些加载和存储在编译时并不知道要对齐。(这是一种矫枉过正的做法,尤其是在较新的 CPU 上,和/或当数据实际对齐但编译器不知道时,或者当数据在常见情况下对齐时,但该函数偶尔仍需要工作未对齐的数组,让硬件处理该特殊情况,而不是在常见情况下运行更多指令以检查该特殊情况。)

但是您使用的是 clang,它在此处生成了一些不错的代码(展开 4 倍),可以在对齐数组或 Haswell 等较新的 CPU 上运行良好。不幸的是,它使用索引寻址模式,破坏了展开的大部分目的(尤其是对于 Intel Sandybridge / Ivy Bridge),因为负载和 ALU uop 将分开并分别通过前端。微融合和寻址模式。(Haswell 可以将其中一些微融合用于 SSE 案例,但不能用于 AVX,例如商店。)

您可以使用aligned_alloc,或者使用 C++17 对齐new来获得与 . 兼容的对齐分配delete

Plainnew可能会给您一个按 16 对齐但未对齐 32 的指针。我不了解 MacOS,但在 Linux glibc 的大型分配器分配器通常会在页面开头保留 16 个字节用于簿记,因此您通常获得距离对齐 16 字节大于 16 的大分配。


脚注 2:在加载端口中花费第二个周期的单微指令仍然只生成一次地址。这允许另一个 uop(例如存储地址 uop)在第二个数据周期发生时使用 AGU。所以它不会干扰完全流水线化的地址处理部分。

SnB / IvB 只有 2 个 AGU/load 端口,因此通常每个时钟最多可以执行 2 个内存操作,其中最多一个是存储。但是对于 32 字节的加载和存储,每 2 个数据周期只需要一个地址(并且存储数据已经是来自存储地址的另一个端口的单独 uop),这允许 SnB / IvB 实现每个时钟 2 + 1 加载+存储,持续,对于 32 字节加载和存储的特殊情况。(这使用了大部分前端带宽,因此这些负载通常需要微融合作为另一条指令的内存源操作数。)

另请参阅我对缓存如何如此快的回答?在电子学.SE。

脚注 1: Zen 1(和 Bulldozer 系列)将所有 32 字节操作解码为 2 个单独的微指令,因此没有特殊情况。一半的负载可以跨缓存线分割,这与来自xmm负载的 16 字节负载完全相同。


推荐阅读