关于汇编:有符号或无符号循环计数器

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使用额外的寄存器来寻址内存,对应的LEAs:

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%

对我来说,令人惊讶的是它没有产生相同的代码(给定编译时循环计数)。这是一个编译器优化问题吗?

编译选项:-O3 -march=skylake-avx512 -mtune=skylake-avx512 -qopt-zmm-usage=high


这是一个愚蠢的错过优化由国际商会。它并不特定于AVX512;它仍然在默认/通用架构设置中发生。

lea ecx, DWORD PTR [16+rax]计算i+16作为展开的一部分,截断为32位(32位操作数大小),零扩展为64位(写入32位寄存器时在x86-64中隐式)。这显式实现了类型宽度处无符号环绕的语义。

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(关于未定义的行为)。注意,它使用的是64位操作数大小(rax而不是eax)的add rax, 64cmp

我把你的代码制作成一个mcve来测试其他编译器。__assume_aligned只是ICC,所以我用了gnu c __builtin_assume_aligned

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个addpd(每时钟2个吞吐量,4个周期延迟)。因此,对于数据在二级缓存(尤其是L1D缓存)中很热的情况,ICC做得更好。

奇怪的是,clang没有使用指针增量,如果它无论如何都要添加/cmp的话。它只需要在循环前面多加几条指令,并简化寻址模式,允许即使在SandyBridge上也能进行负载的微融合。(但它不是AVX,所以哈斯韦尔和后来可以保持负载微保险丝。微融合和寻址模式)。gcc做到了这一点,但根本没有展开,这是gcc的默认配置,没有配置引导优化。

不管怎样,ICC的AVX512代码将在"问题/重命名"阶段(或者在添加到IDQ之前,我不确定)取消分层加载并添加UOP。因此,它不使用指针增量来节省前端带宽,为更大的无序窗口消耗更少的rob空间,并且更易于进行超线程处理,这是非常愚蠢的。

脚注1:

(即使不是这样,一个没有副作用的无限循环,如volatileatomic访问,也是未定义的行为,因此即使i <= n带有运行时变量n,编译器也可以假定循环不是无限的,因此i没有包装。是while(1);C中的未定义行为?

在实践中,GCC和Clang并没有利用这一点,而是创建一个实际上是无限的循环,并且不会因为这种可能的怪异而自动向量化。因此,避免使用运行时变量ni <= n,尤其是对于无符号比较。用i < n代替。

如果展开,i += 2也会有类似的效果。

因此,在源代码中进行端点指针和指针增量通常是很好的,因为这通常是ASM的最佳选择。