Signed or unsigned loop counter
在这个简单的示例中,使用有符号循环计数器和无符号循环计数器之间的差异让我非常惊讶:
1 2 3 4 5 6 7 8 9 | double const* a; __assume_aligned(a, 64); double s = 0.0; //for ( unsigned int i = 0; i < 1024*1024; i++ ) for ( int i = 0; i < 1024*1024; i++ ) { s += a[i]; } |
在签名的情况下,ICC 19.0.0生成(我显示了循环的展开部分):
1 2 3 4 5 6 7 8 9 10 11 12 | ..B1.2: vaddpd zmm7, zmm7, ZMMWORD PTR [rdi+rax*8] vaddpd zmm6, zmm6, ZMMWORD PTR [64+rdi+rax*8] vaddpd zmm5, zmm5, ZMMWORD PTR [128+rdi+rax*8] vaddpd zmm4, zmm4, ZMMWORD PTR [192+rdi+rax*8] vaddpd zmm3, zmm3, ZMMWORD PTR [256+rdi+rax*8] vaddpd zmm2, zmm2, ZMMWORD PTR [320+rdi+rax*8] vaddpd zmm1, zmm1, ZMMWORD PTR [384+rdi+rax*8] vaddpd zmm0, zmm0, ZMMWORD PTR [448+rdi+rax*8] add rax, 64 cmp rax, 1048576 jb ..B1.2 # Prob 99% |
在无符号情况下,ICC使用额外的寄存器来寻址内存,对应的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | ..B1.2: lea edx, DWORD PTR [8+rax] vaddpd zmm6, zmm6, ZMMWORD PTR [rdi+rdx*8] lea ecx, DWORD PTR [16+rax] vaddpd zmm5, zmm5, ZMMWORD PTR [rdi+rcx*8] vaddpd zmm7, zmm7, ZMMWORD PTR [rdi+rax*8] lea esi, DWORD PTR [24+rax] vaddpd zmm4, zmm4, ZMMWORD PTR [rdi+rsi*8] lea r8d, DWORD PTR [32+rax] vaddpd zmm3, zmm3, ZMMWORD PTR [rdi+r8*8] lea r9d, DWORD PTR [40+rax] vaddpd zmm2, zmm2, ZMMWORD PTR [rdi+r9*8] lea r10d, DWORD PTR [48+rax] vaddpd zmm1, zmm1, ZMMWORD PTR [rdi+r10*8] lea r11d, DWORD PTR [56+rax] add eax, 64 vaddpd zmm0, zmm0, ZMMWORD PTR [rdi+r11*8] cmp eax, 1048576 jb ..B1.2 # Prob 99% |
对我来说,令人惊讶的是它没有产生相同的代码(给定编译时循环计数)。这是一个编译器优化问题吗?
编译选项:
这是一个愚蠢的错过优化由国际商会。它并不特定于AVX512;它仍然在默认/通用架构设置中发生。
gcc和clang可以证明
回想一下,在C和C++中,未签名的环绕是很好定义的,但是签名溢出是未定义的行为。这意味着有符号变量可以提升到指针宽度,并且编译器不必每次将其用作数组索引时都重新将符号扩展到指针宽度。(
签名溢出UB是ICC能够为签名计数器进行适当优化的原因,即使它不能使用范围信息。另请参见http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html(关于未定义的行为)。注意,它使用的是64位操作数大小(rax而不是eax)的
我把你的代码制作成一个mcve来测试其他编译器。
1 2 3 4 5 6 7 8 9 10 | #define COUNTER_TYPE unsigned double sum(const double *a) { a = __builtin_assume_aligned(a, 64); double s = 0.0; for ( COUNTER_TYPE i = 0; i < 1024*1024; i++ ) s += a[i]; return s; } |
Clang像这样编译函数(Godbolt编译器资源管理器):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | # clang 7.0 -O3 sum: # @sum xorpd xmm0, xmm0 xor eax, eax xorpd xmm1, xmm1 .LBB0_1: # =>This Inner Loop Header: Depth=1 addpd xmm0, xmmword ptr [rdi + 8*rax] addpd xmm1, xmmword ptr [rdi + 8*rax + 16] addpd xmm0, xmmword ptr [rdi + 8*rax + 32] addpd xmm1, xmmword ptr [rdi + 8*rax + 48] addpd xmm0, xmmword ptr [rdi + 8*rax + 64] addpd xmm1, xmmword ptr [rdi + 8*rax + 80] addpd xmm0, xmmword ptr [rdi + 8*rax + 96] addpd xmm1, xmmword ptr [rdi + 8*rax + 112] add rax, 16 # 64-bit loop counter cmp rax, 1048576 jne .LBB0_1 addpd xmm1, xmm0 movapd xmm0, xmm1 # horizontal sum movhlps xmm0, xmm1 # xmm0 = xmm1[1],xmm0[1] addpd xmm0, xmm1 ret |
我没有启用AVX,这不会改变循环结构。注意clang只使用2个向量累加器,所以如果数据在L1D缓存中是热的,那么它会在fp上造成瓶颈,从而增加最新CPU上的延迟。Skylake可以一次保持8个
奇怪的是,clang没有使用指针增量,如果它无论如何都要添加/cmp的话。它只需要在循环前面多加几条指令,并简化寻址模式,允许即使在SandyBridge上也能进行负载的微融合。(但它不是AVX,所以哈斯韦尔和后来可以保持负载微保险丝。微融合和寻址模式)。gcc做到了这一点,但根本没有展开,这是gcc的默认配置,没有配置引导优化。
不管怎样,ICC的AVX512代码将在"问题/重命名"阶段(或者在添加到IDQ之前,我不确定)取消分层加载并添加UOP。因此,它不使用指针增量来节省前端带宽,为更大的无序窗口消耗更少的rob空间,并且更易于进行超线程处理,这是非常愚蠢的。
脚注1:
(即使不是这样,一个没有副作用的无限循环,如
在实践中,GCC和Clang并没有利用这一点,而是创建一个实际上是无限的循环,并且不会因为这种可能的怪异而自动向量化。因此,避免使用运行时变量
如果展开,
因此,在源代码中进行端点指针和指针增量通常是很好的,因为这通常是ASM的最佳选择。