了解 C# SIMD 输出
Understanding C# SIMD output
我有以下代码段,它对数组的所有元素求和(大小是硬编码的 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, 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
) 抛出异常。我的代码中是否有可以抛出异常的函数或其他东西?
我知道有很多问题,但我不能将它们分开,因为我认为它们是相互关联的。 CRYSTAL 清楚:我只是想在这里了解生成的 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, ymm0, YMMWORD PTR [rdi+32]
vpaddd ymm0, 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
可以提高性能。
L0007
到 L0018
行正在清零局部变量使用的存储 space。
0x7d847bd1f9ce
值似乎与检测堆栈溢出有关。它设置一个检查值,当函数完成时,它会查看该值是否已更改。如果有,它会调用诊断功能。
函数体从L002c
开始。首先它会初始化您的本地 ymm
变量,然后进行添加。
L004b
处的lea
是t
的分配。下一条指令 (L004f
) 是 Avx2.Store(t, ymm0);
语句。
L0053
到 L0063
是 for 循环。 rax
已经有了t
的值,ecx
持有i
,edx
持有r
.
从 L0065
到最后我们有 return 语句和函数结尾。 epilog 检查堆栈是否已被破坏,进行一些清理,然后 returns 给调用者。
Why do we need VZEROUPPER at the beginning. Wouldn't it be perfectly fine without it?
在 开始 中插入 vzeroupper
可能是 library/some 其他已知忘记清理其鞋面的第三方代码的解决方法(以保护 SSE 代码)。但是你没有使用 SSE 代码,你只有 AVX 代码,所以 是的,一开始不需要它。
您的代码使用 VEX 编码指令(v
前缀),这意味着它不会遇到“错误依赖”(转换惩罚)问题(). And on top of that you're using ymm
vectors immediately (entering Dirty Upper State), which means any reasoning for power management/frequency scaling is also not applying here (Dynamically determining where a rogue AVX-512 instruction is executing - 忘记提及 vzeroupper
导致整个应用程序的频率降低)。
What do the VMOVDQAs do in the beginning. Or rather why are they there?
L0007: vxorps xmm4, xmm4, xmm4
L000b: vmovdqa [rsp], xmm4 ; Question #2
L0010: vmovdqa [rsp+0x10], xmm4 ; Question #2
为什么要将您要完全覆盖的内存归零?我的猜测是编译器没有完全计算循环的写覆盖率,所以它不知道你会完全覆盖它。所以它归零以防万一。
Zeroing out the EAX register? Why? Probably related to next line MOV [RSP+0x20], RAX, but still can't understand.
L0016: xor eax, eax ; Question #3
L0018: mov [rsp+0x20], rax
L001d: mov rax, 0x7d847bd1f9ce ; Question #4
L0027: mov [rsp+0x20], rax
因此它在地址 rsp+0x20
处写入 64 位零,然后用堆栈金丝雀覆盖相同的内存区域。为什么它需要先在那里写一个零?我不知道,看起来像是错过了优化。
What does this mysterious value (0x7d847bd1f9ce) do?
I'm assuming this line (L0078: call 0x00007ffc9de2d430) throws an exception. Is there a function or something in my code that can throw an exception?
如前所述,它是检测缓冲区溢出的堆栈金丝雀。
"The use of stackalloc automatically enables buffer overrun detection features in the common language runtime (CLR). If a buffer overrun is detected, the process is terminated as quickly as possible to minimize the chance that malicious code is executed" - quote from https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc
它在堆栈缓冲区的末尾写入一个它知道的值。然后执行您拥有的循环。然后它检查值是否改变(如果改变了,意味着你的循环写出边界)。请注意,这是一个巨大的堆栈金丝雀。不知道为什么他们必须使用 64 位。除非有充分的理由让它成为 64 位,否则我会认为这是一个错过的优化。它的代码大小和 uop-cache 很大,它导致编译器发出更多指令(必须始终使用 mov
,不能使用 64 位常量作为任何其他指令的直接操作数,例如 cmp
或存储 mov
).
此外,关于金丝雀检查代码的说明
L0071: cmp [rsp+0x20], rcx
L0076: je short L007d
L0078: call 0x00007ffc9de2d430 ; Question #6
L007d: nop
直通路径应该是最有可能采用的路径。在这种情况下,失败路径是“抛出异常”,这不应该是正常的。这可能是另一个错过的优化。它可能影响性能的方式是——如果这段代码不在分支历史中,那么它将遭受分支未命中。如果预测正确,那就没问题了。间接影响 - 采取的分支在分支预测器历史记录中占据 space。如果这个分支从未被采用 - 会更便宜。
There are also lines in between which I can not understand why are they needed (see "Question #5" comments in the code).
L004b: lea rax, [rsp] ; Question #5
L004f: vmovdqu [rax], ymm0
L0053: xor edx, edx ; Question #5
L0055: xor ecx, ecx ; Question #5
这里不需要 LEA
。我的猜测是这与编译器如何管理寄存器 allocation/stack 有关,所以它只是编译器的一个怪癖(rsp
不能像普通寄存器那样分配,它总是用作堆栈指针,所以必须特殊对待)。
归零 edx
- 它用作最终结果的累加器。归零 ecx
- 在随后的循环中用作计数器。
关于末尾水平和
一般来说,当从相同位置存储和读取时,但不同 offset/size - 需要检查目标的存储转发规则 CPU 以免受到惩罚(您可以找到https://www.agner.org/optimize/#manuals 上的那些人,Intel 和 AMD 也在他们的指南中列出了规则)。如果您的目标是现代 CPUs (Skylake/Zen),那么在您的情况下您不应该遇到存储转发停顿,但仍然有更快的方法来水平求和向量。 (而且它有一个好处,就是避免错过与堆栈缓冲区相关的优化)。
查看这篇关于水平求和矢量的好方法的精彩文章:
您还可以查看编译器是如何做到的:https://godbolt.org/z/q74abrqzh (GCC at -O3).
@stepan 很好地解释了 RyuJIT 生成的代码,但我想我会解决为什么 GCC 代码如此不同以及为什么 RyuJIT 错过了这么多潜在优化的问题。
简短的回答是,作为 Just In Time,RyuJIT 用于优化的时间预算非常有限,因此它针对常用模式进行优化。在您的情况下,JIT 可能过于字面地理解您的代码,而 GCC 能够更好地捕捉您的意图。
堆栈金丝雀代码可以简单地通过删除 stackalloc
并使用 Vector256<T>
本地来消除。此外,堆栈值的循环缺少一些优化,比如你的 i
变量在每次迭代中被符号扩展。您方法的这个版本通过帮助 JIT 解决它知道如何优化的问题来解决这两个问题。
static unsafe int F(int* a)
{
Vector256<int> ymm0 = Avx.LoadVector256(a + 0);
Vector256<int> ymm1 = Avx.LoadVector256(a + 8);
Vector256<int> ymm2 = Avx.LoadVector256(a + 16);
Vector256<int> ymm3 = Avx.LoadVector256(a + 24);
ymm0 = Avx2.Add(ymm0, ymm1);
ymm2 = Avx2.Add(ymm2, ymm3);
ymm0 = Avx2.Add(ymm0, ymm2);
// This address-taken local will be forced to the stack
Vector256<int> ymm4 = ymm0;
int* t = (int*)&ymm4;
// RyuJIT unrolls loops of Vector<T>.Count,
// Vector128<T>.Count, and Vector256<T>.Count
int r = 0;
for (int i = 0; i < Vector256<int>.Count; ++i)
r += *(t + i);
return r;
}
编译为:
Program.F(Int32*)
L0000: sub rsp, 0x38
L0004: vzeroupper
L0007: vmovdqu ymm0, [rcx]
L000b: vmovdqu ymm1, [rcx+0x20]
L0010: vmovdqu ymm2, [rcx+0x40]
L0015: vmovdqu ymm3, [rcx+0x60]
L001a: vpaddd ymm2, ymm2, ymm3
L001e: vpaddd ymm0, ymm0, ymm1
L0022: vpaddd ymm0, ymm0, ymm2
L0026: vmovupd [rsp], ymm0 ; write to the stack with no zeroing/canary
L002b: lea rax, [rsp]
L002f: mov edx, [rax] ; auto-unrolled loop
L0031: add edx, [rax+4]
L0034: add edx, [rax+8]
L0037: add edx, [rax+0xc]
L003a: add edx, [rax+0x10]
L003d: add edx, [rax+0x14]
L0040: add edx, [rax+0x18]
L0043: add edx, [rax+0x1c]
L0046: mov eax, edx
L0048: vzeroupper
L004b: add rsp, 0x38
L004f: ret
注意stack zeroing,stack canary write,check,possible throw都没有了。并且循环是自动展开的,具有更优化的标量 load/add 代码。
除此之外,正如其他 comments/answers 所建议的那样,不需要溢出到堆栈和标量加法,因为您可以使用 SIMD 指令进行水平加法。 RyuJIT 不会像 GCC 那样为你做这件事,但如果你是明确的,你可以获得最佳的 SIMD ASM。
static unsafe int F(int* a)
{
Vector256<int> ymm0 = Avx.LoadVector256(a + 0);
Vector256<int> ymm1 = Avx.LoadVector256(a + 8);
// The load can be contained in the add if you use the load
// as an operand rather than declaring explicit locals
ymm0 = Avx2.Add(ymm0, Avx.LoadVector256(a + 16));
ymm1 = Avx2.Add(ymm1, Avx.LoadVector256(a + 24));
ymm0 = Avx2.Add(ymm0, ymm1);
// Add the upper 128-bit lane to the lower lane
Vector128<int> xmm0 = Sse2.Add(ymm0.GetLower(), ymm0.GetUpper());
// Add odd elements to even
xmm0 = Sse2.Add(xmm0, Sse2.Shuffle(xmm0, 0b_11_11_01_01));
// Add high half to low half
xmm0 = Sse2.Add(xmm0, Sse2.UnpackHigh(xmm0.AsInt64(), xmm0.AsInt64()).AsInt32());
// Extract low element
return xmm0.ToScalar();
}
编译为:
Program.F(Int32*)
L0000: vzeroupper
L0003: vmovdqu ymm0, [rcx]
L0007: vmovdqu ymm1, [rcx+0x20]
L000c: vpaddd ymm0, ymm0, [rcx+0x40]
L0011: vpaddd ymm1, ymm1, [rcx+0x60]
L0016: vpaddd ymm0, ymm0, ymm1
L001a: vextracti128 xmm1, ymm0, 1
L0020: vpaddd xmm0, xmm0, xmm1
L0024: vpshufd xmm1, xmm0, 0xf5
L0029: vpaddd xmm0, xmm0, xmm1
L002d: vpunpckhqdq xmm1, xmm0, xmm0
L0031: vpaddd xmm0, xmm0, xmm1
L0035: vmovd eax, xmm0
L0039: vzeroupper
L003c: ret
除了过于保守的 vzeroupper
s 之外,它与您从优化 C/C++ 编译器中获得的相同。
我有以下代码段,它对数组的所有元素求和(大小是硬编码的 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, 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
) 抛出异常。我的代码中是否有可以抛出异常的函数或其他东西?
我知道有很多问题,但我不能将它们分开,因为我认为它们是相互关联的。 CRYSTAL 清楚:我只是想在这里了解生成的 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, ymm0, YMMWORD PTR [rdi+32]
vpaddd ymm0, 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
可以提高性能。
L0007
到 L0018
行正在清零局部变量使用的存储 space。
0x7d847bd1f9ce
值似乎与检测堆栈溢出有关。它设置一个检查值,当函数完成时,它会查看该值是否已更改。如果有,它会调用诊断功能。
函数体从L002c
开始。首先它会初始化您的本地 ymm
变量,然后进行添加。
L004b
处的lea
是t
的分配。下一条指令 (L004f
) 是 Avx2.Store(t, ymm0);
语句。
L0053
到 L0063
是 for 循环。 rax
已经有了t
的值,ecx
持有i
,edx
持有r
.
从 L0065
到最后我们有 return 语句和函数结尾。 epilog 检查堆栈是否已被破坏,进行一些清理,然后 returns 给调用者。
Why do we need VZEROUPPER at the beginning. Wouldn't it be perfectly fine without it?
在 开始 中插入 vzeroupper
可能是 library/some 其他已知忘记清理其鞋面的第三方代码的解决方法(以保护 SSE 代码)。但是你没有使用 SSE 代码,你只有 AVX 代码,所以 是的,一开始不需要它。
您的代码使用 VEX 编码指令(v
前缀),这意味着它不会遇到“错误依赖”(转换惩罚)问题(ymm
vectors immediately (entering Dirty Upper State), which means any reasoning for power management/frequency scaling is also not applying here (Dynamically determining where a rogue AVX-512 instruction is executing - 忘记提及 vzeroupper
导致整个应用程序的频率降低)。
What do the VMOVDQAs do in the beginning. Or rather why are they there?
L0007: vxorps xmm4, xmm4, xmm4
L000b: vmovdqa [rsp], xmm4 ; Question #2
L0010: vmovdqa [rsp+0x10], xmm4 ; Question #2
为什么要将您要完全覆盖的内存归零?我的猜测是编译器没有完全计算循环的写覆盖率,所以它不知道你会完全覆盖它。所以它归零以防万一。
Zeroing out the EAX register? Why? Probably related to next line MOV [RSP+0x20], RAX, but still can't understand.
L0016: xor eax, eax ; Question #3
L0018: mov [rsp+0x20], rax
L001d: mov rax, 0x7d847bd1f9ce ; Question #4
L0027: mov [rsp+0x20], rax
因此它在地址 rsp+0x20
处写入 64 位零,然后用堆栈金丝雀覆盖相同的内存区域。为什么它需要先在那里写一个零?我不知道,看起来像是错过了优化。
What does this mysterious value (0x7d847bd1f9ce) do? I'm assuming this line (L0078: call 0x00007ffc9de2d430) throws an exception. Is there a function or something in my code that can throw an exception?
如前所述,它是检测缓冲区溢出的堆栈金丝雀。
"The use of stackalloc automatically enables buffer overrun detection features in the common language runtime (CLR). If a buffer overrun is detected, the process is terminated as quickly as possible to minimize the chance that malicious code is executed" - quote from https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc
它在堆栈缓冲区的末尾写入一个它知道的值。然后执行您拥有的循环。然后它检查值是否改变(如果改变了,意味着你的循环写出边界)。请注意,这是一个巨大的堆栈金丝雀。不知道为什么他们必须使用 64 位。除非有充分的理由让它成为 64 位,否则我会认为这是一个错过的优化。它的代码大小和 uop-cache 很大,它导致编译器发出更多指令(必须始终使用 mov
,不能使用 64 位常量作为任何其他指令的直接操作数,例如 cmp
或存储 mov
).
此外,关于金丝雀检查代码的说明
L0071: cmp [rsp+0x20], rcx
L0076: je short L007d
L0078: call 0x00007ffc9de2d430 ; Question #6
L007d: nop
直通路径应该是最有可能采用的路径。在这种情况下,失败路径是“抛出异常”,这不应该是正常的。这可能是另一个错过的优化。它可能影响性能的方式是——如果这段代码不在分支历史中,那么它将遭受分支未命中。如果预测正确,那就没问题了。间接影响 - 采取的分支在分支预测器历史记录中占据 space。如果这个分支从未被采用 - 会更便宜。
There are also lines in between which I can not understand why are they needed (see "Question #5" comments in the code).
L004b: lea rax, [rsp] ; Question #5
L004f: vmovdqu [rax], ymm0
L0053: xor edx, edx ; Question #5
L0055: xor ecx, ecx ; Question #5
这里不需要 LEA
。我的猜测是这与编译器如何管理寄存器 allocation/stack 有关,所以它只是编译器的一个怪癖(rsp
不能像普通寄存器那样分配,它总是用作堆栈指针,所以必须特殊对待)。
归零 edx
- 它用作最终结果的累加器。归零 ecx
- 在随后的循环中用作计数器。
关于末尾水平和
一般来说,当从相同位置存储和读取时,但不同 offset/size - 需要检查目标的存储转发规则 CPU 以免受到惩罚(您可以找到https://www.agner.org/optimize/#manuals 上的那些人,Intel 和 AMD 也在他们的指南中列出了规则)。如果您的目标是现代 CPUs (Skylake/Zen),那么在您的情况下您不应该遇到存储转发停顿,但仍然有更快的方法来水平求和向量。 (而且它有一个好处,就是避免错过与堆栈缓冲区相关的优化)。
查看这篇关于水平求和矢量的好方法的精彩文章: 您还可以查看编译器是如何做到的:https://godbolt.org/z/q74abrqzh (GCC at -O3).
@stepan 很好地解释了 RyuJIT 生成的代码,但我想我会解决为什么 GCC 代码如此不同以及为什么 RyuJIT 错过了这么多潜在优化的问题。
简短的回答是,作为 Just In Time,RyuJIT 用于优化的时间预算非常有限,因此它针对常用模式进行优化。在您的情况下,JIT 可能过于字面地理解您的代码,而 GCC 能够更好地捕捉您的意图。
堆栈金丝雀代码可以简单地通过删除 stackalloc
并使用 Vector256<T>
本地来消除。此外,堆栈值的循环缺少一些优化,比如你的 i
变量在每次迭代中被符号扩展。您方法的这个版本通过帮助 JIT 解决它知道如何优化的问题来解决这两个问题。
static unsafe int F(int* a)
{
Vector256<int> ymm0 = Avx.LoadVector256(a + 0);
Vector256<int> ymm1 = Avx.LoadVector256(a + 8);
Vector256<int> ymm2 = Avx.LoadVector256(a + 16);
Vector256<int> ymm3 = Avx.LoadVector256(a + 24);
ymm0 = Avx2.Add(ymm0, ymm1);
ymm2 = Avx2.Add(ymm2, ymm3);
ymm0 = Avx2.Add(ymm0, ymm2);
// This address-taken local will be forced to the stack
Vector256<int> ymm4 = ymm0;
int* t = (int*)&ymm4;
// RyuJIT unrolls loops of Vector<T>.Count,
// Vector128<T>.Count, and Vector256<T>.Count
int r = 0;
for (int i = 0; i < Vector256<int>.Count; ++i)
r += *(t + i);
return r;
}
编译为:
Program.F(Int32*)
L0000: sub rsp, 0x38
L0004: vzeroupper
L0007: vmovdqu ymm0, [rcx]
L000b: vmovdqu ymm1, [rcx+0x20]
L0010: vmovdqu ymm2, [rcx+0x40]
L0015: vmovdqu ymm3, [rcx+0x60]
L001a: vpaddd ymm2, ymm2, ymm3
L001e: vpaddd ymm0, ymm0, ymm1
L0022: vpaddd ymm0, ymm0, ymm2
L0026: vmovupd [rsp], ymm0 ; write to the stack with no zeroing/canary
L002b: lea rax, [rsp]
L002f: mov edx, [rax] ; auto-unrolled loop
L0031: add edx, [rax+4]
L0034: add edx, [rax+8]
L0037: add edx, [rax+0xc]
L003a: add edx, [rax+0x10]
L003d: add edx, [rax+0x14]
L0040: add edx, [rax+0x18]
L0043: add edx, [rax+0x1c]
L0046: mov eax, edx
L0048: vzeroupper
L004b: add rsp, 0x38
L004f: ret
注意stack zeroing,stack canary write,check,possible throw都没有了。并且循环是自动展开的,具有更优化的标量 load/add 代码。
除此之外,正如其他 comments/answers 所建议的那样,不需要溢出到堆栈和标量加法,因为您可以使用 SIMD 指令进行水平加法。 RyuJIT 不会像 GCC 那样为你做这件事,但如果你是明确的,你可以获得最佳的 SIMD ASM。
static unsafe int F(int* a)
{
Vector256<int> ymm0 = Avx.LoadVector256(a + 0);
Vector256<int> ymm1 = Avx.LoadVector256(a + 8);
// The load can be contained in the add if you use the load
// as an operand rather than declaring explicit locals
ymm0 = Avx2.Add(ymm0, Avx.LoadVector256(a + 16));
ymm1 = Avx2.Add(ymm1, Avx.LoadVector256(a + 24));
ymm0 = Avx2.Add(ymm0, ymm1);
// Add the upper 128-bit lane to the lower lane
Vector128<int> xmm0 = Sse2.Add(ymm0.GetLower(), ymm0.GetUpper());
// Add odd elements to even
xmm0 = Sse2.Add(xmm0, Sse2.Shuffle(xmm0, 0b_11_11_01_01));
// Add high half to low half
xmm0 = Sse2.Add(xmm0, Sse2.UnpackHigh(xmm0.AsInt64(), xmm0.AsInt64()).AsInt32());
// Extract low element
return xmm0.ToScalar();
}
编译为:
Program.F(Int32*)
L0000: vzeroupper
L0003: vmovdqu ymm0, [rcx]
L0007: vmovdqu ymm1, [rcx+0x20]
L000c: vpaddd ymm0, ymm0, [rcx+0x40]
L0011: vpaddd ymm1, ymm1, [rcx+0x60]
L0016: vpaddd ymm0, ymm0, ymm1
L001a: vextracti128 xmm1, ymm0, 1
L0020: vpaddd xmm0, xmm0, xmm1
L0024: vpshufd xmm1, xmm0, 0xf5
L0029: vpaddd xmm0, xmm0, xmm1
L002d: vpunpckhqdq xmm1, xmm0, xmm0
L0031: vpaddd xmm0, xmm0, xmm1
L0035: vmovd eax, xmm0
L0039: vzeroupper
L003c: ret
除了过于保守的 vzeroupper
s 之外,它与您从优化 C/C++ 编译器中获得的相同。