【问题标题】:Signed or unsigned loop counter有符号或无符号循环计数器
【发布时间】:2019-03-22 14:35:15
【问题描述】:

我对这个简单示例中使用有符号和无符号循环计数器的区别感到非常惊讶:

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

..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

【问题讨论】:

  • 编译器似乎无法推断出截断到 32 位实际上是没有必要的。它如何与size_t 一起使用?
  • 这是一个错过的优化,因为它可以证明i 不会换行但会失败。但是有符号溢出是 UB,它可以将 32 位循环计数器提升到 64 位,而无需每次都重做符号扩展,就像它每次在这里重做零扩展一样。不是每次,而是每 8 次。
  • @zch 你是对的,size_t 产生与signed int 相同的代码

标签: assembly optimization compiler-optimization icc unsigned-integer


【解决方案1】:

这是 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, 64cmp 和 64 位操作数大小(RAX 而不是 EAX)


我将您的代码制作成 MCVE,以便使用其他编译器进行测试。 __assume_aligned 是 ICC-only,所以我使用了 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 代码将解压成单独的负载并在问题/重命名阶段添加 uops(或者在添加到 IDQ 之前,我不确定)。所以它不使用指针增量来节省前端带宽、为更大的乱序窗口消耗更少的 ROB 空间以及对超线程更友好是相当愚蠢的。


脚注 1:

(即使不是这样,像volatileatomic 访问这样没有副作用的无限循环也是未定义的行为,所以即使i &lt;= n 带有运行时变量n,编译器将被允许​​假设循环不是无限的,因此i 没有换行。Is while(1); undefined behavior in C?)

实际上 gcc 和 clang 并没有利用这一点,而是创建一个实际上可能是无限的循环,并且不要因为可能的怪异而自动矢量化。所以避免使用运行时变量ni &lt;= n,尤其是对于无符号比较。请改用i &lt; n

如果展开,i += 2 可以产生类似的效果。

所以在源代码中做结束指针和指针增量通常是好的,因为这通常是 asm 的最佳选择。

【讨论】:

    猜你喜欢
    • 2012-10-06
    • 1970-01-01
    • 2016-02-03
    • 2012-08-10
    • 1970-01-01
    • 2012-10-19
    • 2021-10-04
    • 2015-02-24
    • 1970-01-01
    相关资源
    最近更新 更多