在这个简单的示例中,使用带符号和无符号循环计数器的区别使我感到非常惊讶:
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(我正在显示循环的展开部分):
..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使用了额外的寄存器来寻址内存,并带有相应的LEA
:
..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%
对于我来说,令人惊讶的是它没有产生相同的代码(给出编译时间循环计数)。是编译器优化问题吗?
编译选项:
-O3 -march=skylake-avx512 -mtune=skylake-avx512 -qopt-zmm-usage=high
答案 0 :(得分:2)
这是ICC错过的愚蠢优化。它不是特定于AVX512的。默认/通用拱门设置仍然会发生这种情况。
lea ecx, DWORD PTR [16+rax]
正在计算i+16
作为展开的一部分,被截断为32位(32位操作数大小),零扩展为64位(在x86-64中隐含)编写32位寄存器)。这样可以在类型宽度处显式实现无符号环绕的语义。
gcc和clang证明unsigned i
不会换行没有问题,因此它们可以将零扩展从32位无符号扩展为64位指针宽度,以用于寻址模式,因为循环上限是已知的 1 。
回想一下,在C和C ++中,无符号环绕是明确定义的,但是无符号溢出是未定义的行为。这意味着可以将带符号的变量提升为指针宽度,并且编译器不必在每次将其用作数组索引时都将符号扩展重做为指针宽度。 (a[i]
等效于*(a+i)
,并且将整数添加到指针的规则意味着对于寄存器的高位可能不匹配的较窄值,必须使用符号扩展。)
签名溢出的UB是ICC能够针对签名计数器正确进行优化的原因,即使它无法使用范围信息也是如此。另请参见http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html(关于未定义的行为)。请注意,它使用的是add rax, 64
和cmp
,其操作数大小为64位(使用RAX代替EAX)
我将您的代码制作为MCVE,以便与其他编译器进行测试。 __assume_aligned
仅适用于ICC,因此我使用了GNU C __builtin_assume_aligned
。
#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 compiler explorer):
# 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个addpd
的飞行状态(每个时钟吞吐量2个,延迟4个周期)。因此,对于某些数据在L2或L1d缓存中特别热的情况,ICC的工作要好得多。
奇怪的是,如果clang仍然要添加/ cmp,它没有使用指针增量。循环之前只需要几个额外的指令,并且将简化寻址模式,即使在Sandybridge上也允许负载的微融合。 (但不是AVX,因此Haswell和更高版本可以使负载保持微融合。Micro fusion and addressing modes)。 GCC会这样做,但根本不会展开,这是GCC的默认设置,没有配置文件引导的优化。
无论如何,ICC的AVX512代码将分层为单独的负载,并在issue / rename阶段添加uops(或者不确定,在添加到IDQ之前)。因此,它不使用指针增量来节省前端带宽,在较大的无序窗口中占用较少的ROB空间,并且对超线程更友好是很愚蠢的。
脚注1:
(即使不是,volatile
或atomic
访问这样的无副作用的无限循环也是未定义的行为,因此即使i <= n
具有运行时变量n
,将允许编译器假定循环不是无限的,因此i
没有包装。Is while(1); undefined behavior in C?)
在实践中,gcc和clang不利用这一点,而是创建一个实际上可能是无限的循环,并且由于这种可能的怪异而不会自动向量化。因此,请避免将i <= n
与运行时变量n
一起使用,尤其是对于无符号比较。请改用i < n
。
如果展开,i += 2
会产生类似的效果。
因此,在源代码中进行端点指针和指针增量通常是好的,因为这对于asm通常是最佳的。