了解 C# SIMD 输出 注意

问题描述

我有以下代码段,它对数组的所有元素求和(大小是硬编码的,为 32):

static unsafe int F(int* a) 
{
    Vector256<int> ymm0 = Avx2.LoadVector256(a + 0);
    Vector256<int> ymm1 = Avx2.LoadVector256(a + 8);
    Vector256<int> ymm2 = Avx2.LoadVector256(a + 16);
    Vector256<int> ymm3 = Avx2.LoadVector256(a + 24);

    ymm0 = Avx2.Add(ymm0,ymm1);
    ymm2 = Avx2.Add(ymm2,ymm3);

    ymm0 = Avx2.Add(ymm0,ymm2);

    const int s = 256 / 32;
    int*      t = stackalloc int[s];

    Avx2.Store(t,ymm0);

    int r = 0;
    for (int i = 0; i < s; ++i)
        r += t[i];

    return r;
}

这会生成以下 ASM

Program.F(Int32*)
    L0000: sub rsp,0x28
    L0004: vzeroupper                       ; Question #1
    L0007: vxorps xmm4,xmm4,xmm4
    L000b: vmovdqa [rsp],xmm4              ; Question #2
    L0010: vmovdqa [rsp+0x10],xmm4         ; Question #2
    L0016: xor eax,eax                     ; Question #3
    L0018: mov [rsp+0x20],rax
    L001d: mov rax,0x7d847bd1f9ce          ; Question #4
    L0027: mov [rsp+0x20],rax
    L002c: vmovdqu ymm0,[rcx]
    L0030: vmovdqu ymm1,[rcx+0x20]
    L0035: vmovdqu ymm2,[rcx+0x40]
    L003a: vmovdqu ymm3,[rcx+0x60]
    L003f: vpaddd ymm0,ymm0,ymm1
    L0043: vpaddd ymm2,ymm2,ymm3
    L0047: vpaddd ymm0,ymm2
    L004b: lea rax,[rsp]                   ; Question #5
    L004f: vmovdqu [rax],ymm0
    L0053: xor edx,edx                     ; Question #5
    L0055: xor ecx,ecx                     ; Question #5
    L0057: movsxd r8,ecx
    L005a: add edx,[rax+r8*4]
    L005e: inc ecx
    L0060: cmp ecx,8
    L0063: jl short L0057
    L0065: mov eax,edx
    L0067: mov rcx,0x7d847bd1f9ce          ; Question #4
    L0071: cmp [rsp+0x20],rcx
    L0076: je short L007d
    L0078: call 0x00007ffc9de2d430          ; Question #6
    L007d: nop
    L007e: vzeroupper
    L0081: add rsp,0x28
    L0085: ret

问题

  • 为什么我们在开头需要 VZEROUPPER。没有它不是很好吗?
  • VMOVDQA 开始时做什么。或者更确切地说,他们为什么在那里?
  • EAX 寄存器清零?为什么?可能与下一行 MOV [RSP+0x20],RAX 有关,但仍然无法理解。
  • 这个神秘的值 (0x7d847bd1f9ce) 有什么作用?
  • 中间还有几行我不明白为什么需要它们(请参阅代码中的“问题 #5”注释)。
  • 我假设这一行 (L0078: call 0x00007ffc9de2d430) 会引发异常。我的代码中是否存在可以引发异常的函数或其他内容

我知道有很多问题,但我无法将它们分开,因为我认为它们彼此相关。清晰明了:我只是想了解此处生成ASM。我不是这方面的专业人士。

注意

  • 如果您想知道 GCC (O2) 生成了什么,结果如下:
int32_t
f(int32_t *a) {
        __m256i ymm0;
        __m256i ymm1;
        __m256i ymm2;
        __m256i ymm3;

        ymm0 = _mm256_load_si256((__m256i*)(a + 0));
        ymm1 = _mm256_load_si256((__m256i*)(a + 8));
        ymm2 = _mm256_load_si256((__m256i*)(a + 16));
        ymm3 = _mm256_load_si256((__m256i*)(a + 24));
           
        ymm0 = _mm256_add_epi32(ymm0,ymm1);
        ymm2 = _mm256_add_epi32(ymm2,ymm3);

        ymm0 = _mm256_add_epi32(ymm0,ymm2);

        int32_t t[8];
        _mm256_store_si256((__m256i*)t,ymm0);

        int32_t r;
        r = 0;
        for (int i = 0; i < 8; ++i)
                r += t[i];

        return r;
}

以及生成ASM

f:
  push rbp
  xor r8d,r8d
  mov rbp,rsp
  and rsp,-32
  lea rax,[rsp-32]
  mov rdx,rsp
  vmovdqa ymm1,YMMWORD PTR [rdi+96]
  vpaddd ymm0,ymm1,YMMWORD PTR [rdi+64]
  vpaddd ymm0,YMMWORD PTR [rdi+32]
  vpaddd ymm0,YMMWORD PTR [rdi]
  vmovdqa YMMWORD PTR [rsp-32],ymm0
.L2:
  add r8d,DWORD PTR [rax]
  add rax,4
  cmp rax,rdx
  jne .L2
  mov eax,r8d
  vzeroupper
  leave
  ret

我认为它在这里优化了(可能很重)我的代码,但无论如何。

解决方法

为什么我们一开始需要 VZEROUPPER。没有它不是很好吗?

开头中插入 vzeroupper 可能是库/某些其他已知忘记清理其上部(以保护 SSE 代码)的第三方代码的解决方法。但是您没有使用 SSE 代码,您只有 AVX 代码,所以是的,一开始不需要

您的代码使用 VEX 编码指令(v 前缀),这意味着它不会遇到“错误依赖”(转换惩罚)问题 (Why is this SSE code 6 times slower without VZEROUPPER on Skylake?)。最重要的是,您立即使用 ymm 向量(进入 Dirty Upper State),这意味着电源管理/频率缩放的任何推理也不适用于此处(Dynamically determining where a rogue AVX-512 instruction is executing - 提到被遗忘的 {{1 }} 导致整个应用的频率降低)。

VMOVDQA 一开始是做什么的。或者更确切地说,他们为什么在那里?

vzeroupper

为什么要将要完全覆盖的内存清零?我的猜测是编译器没有完全计算循环的写覆盖率,所以它不知道你会完全覆盖它。所以它会归零以防万一。

清零 EAX 寄存器?为什么?可能和下一行MOV [RSP+0x20],RAX有关,但还是看不懂。

L0007: vxorps xmm4,xmm4,xmm4
L000b: vmovdqa [rsp],xmm4              ; Question #2
L0010: vmovdqa [rsp+0x10],xmm4         ; Question #2

因此它在地址 L0016: xor eax,eax ; Question #3 L0018: mov [rsp+0x20],rax L001d: mov rax,0x7d847bd1f9ce ; Question #4 L0027: mov [rsp+0x20],rax 处写入 64 位零,然后用堆栈金丝雀覆盖相同的内存区域。为什么需要先在那里写一个零?我不知道,看起来像是错过了优化。

这个神秘的值 (0x7d847bd1f9ce) 有什么作用? 我假设这一行 (L0078: call 0x00007ffc9de2d430) 抛出异常。我的代码中是否有函数或其他东西可以抛出异常?

如前所述,它是检测缓冲区溢出的堆栈金丝雀。

“使用 stackalloc 会自动启用公共语言运行时 (CLR) 中的缓冲区溢出检测功能。如果检测到缓冲区溢出,则会尽快终止进程,以最大程度地减少执行恶意代码的机会”-引用来自https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc

它在堆栈缓冲区的末尾写入一个它知道的值。然后执行您拥有的循环。然后它检查值是否改变(如果改变了,意味着你的循环写出越界)。请注意,这是一个巨大的堆栈金丝雀。不知道为什么他们必须使用 64 位。除非有充分的理由让它成为 64 位,否则我会认为这是一个错过的优化。它的代码大小和 uop-cache 很大,并且导致编译器发出更多指令(必须始终使用 rsp+0x20,不能使用 64 位常量作为任何其他指令的立即操作数,例如 { {1}} 或存储 mov)。

另外,关于金丝雀检查代码的说明

cmp

Fall-through 路径应该是最有可能采用的路径。在这种情况下,失败路径是“抛出异常”,这不应该是正常的。这可能是另一个错过的优化。它可能影响性能的方式是 - 如果此代码不在分支历史记录中,那么它将遭受分支未命中。如果预测正确,那就没问题了。间接影响 - 采取的分支在分支预测器历史中占据空间。如果这个分支从未被采用 - 会更便宜。

中间也有几行我不明白为什么需要它们(请参阅代码中的“问题 #5”注释)。

mov
此处不需要

L0071: cmp [rsp+0x20],rcx L0076: je short L007d L0078: call 0x00007ffc9de2d430 ; Question #6 L007d: nop 。我的猜测是它与编译器如何进行寄存器分配/堆栈管理有关,所以它只是编译器的一个怪癖(L004b: lea rax,[rsp] ; Question #5 L004f: vmovdqu [rax],ymm0 L0053: xor edx,edx ; Question #5 L0055: xor ecx,ecx ; Question #5 不能像普通寄存器一样分配,它总是用作堆栈指针,所以它有特殊对待)。

归零 LEA - 它用作最终结果的累加器。归零 rsp - 在随后的循环中用作计数器。


关于最后的横向总和。

通常,当从同一位置存储和读取但偏移/大小不同时 - 需要检查存储转发规则,以使您的目标 CPU 不会受到惩罚(您可以在 https://www.agner.org/optimize/#manuals 处找到这些规则, Intel 和 AMD 的指南中也列出了这些规则)。如果您的目标是现代 CPU(Skylake/Zen),那么在您的情况下您不应该遭受存储转发停滞,但仍然有更快的方法来水平总结向量。 (它的好处是避免错过与堆栈缓冲区相关的优化)。

查看这篇关于水平求和向量的好方法的好文章:https://stackoverflow.com/a/35270026/899255 您还可以查看编译器是如何做到的:https://godbolt.org/z/q74abrqzh(GCC at -O3)。

,

vzeroupper 有助于提高性能。

L0007L0018 行将局部变量使用的存储空间清零。

0x7d847bd1f9ce 值似乎与检测堆栈溢出有关。它设置一个检查值,当函数完成时,它会查看该值是否已更改。如果有,它会调用一个诊断函数。

函数体从 L002c 开始。首先它初始化您的本地 ymm 变量,然后进行添加。

lea 处的 L004bt 的分配。下一条指令 (L004f) 是 Avx2.Store(t,ymm0); 语句。

L0053L0063 是 for 循环。 rax 已经拥有 t 的值,ecx 拥有 i,而 edx 拥有 r

L0065 到最后,我们有 return 语句和函数结语。 Epilog 检查堆栈是否已被破坏,进行一些清理,然后返回给调用者。