【问题标题】:Multiply-add vectorization slower with AVX than with SSEAVX 的乘法向量化比 SSE 慢
【发布时间】:2021-03-25 14:44:28
【问题描述】:

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

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(垃圾箱)。

【问题讨论】:

  • 性能说什么?
  • 我没有使用 perf,因为我没有使用 Linux。

标签: c++ performance optimization sse avx


【解决方案1】:

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


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

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

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

您可以使用 aligned_alloc,或者使用 C++17 对齐的 new 执行某些操作,以获得与 delete 兼容的对齐分配。

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


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

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

另请参阅我在 electronics.SE 上How can cache be that fast? 的回答。

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

【讨论】:

  • @prazuber:我希望这会有所帮助,或者至少使它们相等(如果内存带宽遇到瓶颈)。尝试较小的总大小(适合 L3 缓存,甚至 L2),以创建 AVX 可以提供显着帮助的情况。虽然打包的 float->double 转换需要改组,但这也可能是一个瓶颈。
  • 有趣的是,将浮点数更改为双精度数使得 SSE 和 AVX 的速度几乎慢了一倍,两者之间没有任何改善。但是,减小数组大小实际上有帮助!每个阵列中只有 32768 个元素,使用 AVX 可提高约 10%,而使用 16384 个元素时,速度几乎提高了 20%。这是否意味着我必须进行手动预取才能获得这种额外的速度?
  • @prazuber:这意味着如果您正在做一些计算强度如此低的事情,那么您应该为适合 L2 缓存的问题大小缓存块。或者每次加载数据时做更多的工作。但是,是的,手动预取(或要求编译器发出预取指令)可能会在这个微基准测试中恢复大型数组情况的一些性能;硬件预取在 4k 页面边界上无法完美(或根本无法)工作,因此需求负载的 OoO 执行可能是页面边界实际发生的事情的一部分。
  • @prazuber:请注意,与同代四核“客户端”芯片相比,多核 Xeon CPU 的单线程内存带宽非常低:Why is Skylake so much better than Broadwell-E for single-threaded memory throughput?。只有大 Xeon 的总带宽才好,并且需要多个线程才能获得与桌面上的 1 个线程一样多的带宽,更不用说用更多的内存控制器来饱和更高的总带宽了。内存性能调优很难...What Every Prog.. Should Know About Mem.
  • @prazuber:哦,我才想起来,软件预取很可能会伤害你的 CPU。这是一个常春藤桥,所以不幸的是它有一个性能错误:prefetcht* 指令的吞吐量比它们应该的要差得多。 agner.org/optimize/blog/read.php?i=285&v=t。顺便说一句,如果您深入研究内存性能,您可能会看到提到 Ivy Bridge 的“下一页预取器”。 AFAIK 这只是一个 TLB 推测性预取(page-walk),而不是数据预取。 (连续的虚拟页面不一定是物理连续的,硬件预取只能访问物理地址)
【解决方案2】:

如果我假设访问的 16MiB 数据有效地刷新了 12MiB LLC,那么实际上所有流量都进出 DRAM。算上所有三个数组的读取和c的回写,最快的SSE时间对应20.48 GB/s的DRAM带宽,而最快的AVX时间对应18.88 GB/s的DRAM带宽。这些都与我在 Xeon E5-2680 v2 上测量的 ~19.5 GB/s 最佳情况下单线程带宽(具有相同的 R:W 比率)相似。

两个想法:

  1. 当工作集如此接近 LLC 大小时,通常很难对性能有满意的理解。造成这种情况的原因有很多,包括使用的物理地址与英特尔用于将地址映射到 LLC 切片的未记录哈希函数之间的交互,以及 https://blog.stuffedcow.net/2013/01/ivb-cache-replacement/ 中记录的相当奇怪的动态 LLC 行为。
  2. 对于内存带宽受限的代码,Sandy Bridge EP 和 Ivy Bridge EP 使用标量代码提供了比 128 位 SSE 矢量代码更好的持续内存带宽,而 128 位 SSE 矢量代码又比 256 位 AVX 矢量代码快。在我的观察中,效果通常小于您看到的差异 - 可能 3-5% 与您的约 13% 中位数差异,但在这些尺寸下可能更大?我不知道对这种性能异常有任何完全令人满意的解释,但我的解释是“更窄”的负载会导致 L1 HW 预取器更早激活,从而使 L2 HW 预取器更早启动。鉴于 4KiB 页面的传输时间小于初始加载的延迟,硬件预取器几乎总是处于早期启动阶段,预取时间的微小变化可能很重要....

【讨论】:

  • OP 可能在管道中有足够的 ALU 微指令,它对你在 2 中提到的异常原因产生影响。虽然我猜你在 STREAM 基准测试中看到了一些实际的数学,但是OP的编译器破坏了load+convert和store的微融合,中间有一个multiply uop。因此,它比优化良好的三元组更需要 ALU 工作,特别是考虑到馈送转换的半宽度负载。 ROB 和 RS 的更多 uops 意味着进入新页面的前瞻窗口更短。 IDK 为什么这会使差异变小,而不是变大。
  • TL:DR:正如您所说,在尝试根据其他测量或已知事实来预测/解释有关此性能的任何事情时,有许多令人困惑的因素。
猜你喜欢
  • 1970-01-01
  • 2017-06-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-05-15
  • 2021-12-15
相关资源
最近更新 更多