问题描述
我有以下代码段,它对数组的所有元素求和(大小是硬编码的,为 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
有助于提高性能。
L0007
到 L0018
行将局部变量使用的存储空间清零。
0x7d847bd1f9ce
值似乎与检测堆栈溢出有关。它设置一个检查值,当函数完成时,它会查看该值是否已更改。如果有,它会调用一个诊断函数。
函数体从 L002c
开始。首先它初始化您的本地 ymm
变量,然后进行添加。
lea
处的 L004b
是 t
的分配。下一条指令 (L004f
) 是 Avx2.Store(t,ymm0);
语句。
L0053
到 L0063
是 for 循环。 rax
已经拥有 t
的值,ecx
拥有 i
,而 edx
拥有 r
。
从 L0065
到最后,我们有 return 语句和函数结语。 Epilog 检查堆栈是否已被破坏,进行一些清理,然后返回给调用者。