为什么 GCC 和 Clang 不优化乘以 2^n 的浮点数到指数的整数 PADDD,即使使用 -ffast-math?

Why don't GCC and Clang optimize multiplication by 2^n with a float to integer PADDD of the exponent, even with -ffast-math?

考虑到这个功能,

float mulHalf(float x) {
    return x * 0.5f;
}

以下函数产生与 normal input/output.

相同的结果
float mulHalf_opt(float x) {
    __m128i e = _mm_set1_epi32(-1 << 23);
    __asm__ ("paddd\t%0, %1" : "+x"(x) : "xm"(e));
    return x;
}

这是 -O3 -ffast-math 的汇编输出。

mulHalf:
        mulss   xmm0, DWORD PTR .LC0[rip]
        ret

mulHalf_opt:
        paddd   xmm0, XMMWORD PTR .LC1[rip]
        ret

-ffast-math 启用 -ffinite-math-only,它“假设参数和结果不是 NaN 或 +-Infs”[1].

因此 mulHalf 的编译输出可能会更好地使用 paddd-ffast-math on 如果这样做会在 -ffast-math.[=32= 的容差下产生更快的代码]

我从 Intel Intrinsics Guide.

得到了以下 tables
(MULSS)
Architecture    Latency Throughput (CPI)
Skylake         4       0.5
Broadwell       3       0.5
Haswell         5       0.5
Ivy Bridge      5       1

(PADDD)
Architecture    Latency Throughput (CPI)
Skylake         1       0.33
Broadwell       1       0.5
Haswell         1       0.5
Ivy Bridge      1       0.5

显然,paddd 是一条更快的指令。然后我想也许是因为整数和浮点单元之间的旁路延迟。

This answer 显示来自 Agner Fog 的 table。

Processor                       Bypass delay, clock cycles 
  Intel Core 2 and earlier        1 
  Intel Nehalem                   2 
  Intel Sandy Bridge and later    0-1 
  Intel Atom                      0 
  AMD                             2 
  VIA Nano                        2-3 

看到这一点,paddd 似乎仍然是赢家,尤其是在 Sandy Bridge 之后的 CPU 上,但是为最近的 CPU 指定 -march 只是将 mulss 更改为 vmulss , 它有一个类似的 latency/throughput.

为什么即使使用 -ffast-math,GCC 和 Clang 也不将 2^n 的浮点数乘法优化为 paddd

0.0f的输入失败,-ffast-math不排除。 (尽管从技术上讲这是次正规的特例,恰好也有零尾数。)

整数减法会换行到 all-ones 指数字段,并翻转符号位,所以你会得到 0.0f * 0.5f 产生 -Inf,这是不可接受的。

@chtz 指出可以使用 psubusw 修复 +0.0f 的情况,但对于 -0.0f -> +Inf 仍然失败。所以不幸的是,即使 -ffast-math 允许零的“错误”符号,这也不可用。但是即使 fast-math.

对于无穷大和 NaN 也是完全错误的也是不可取的

除此之外,是的,我认为这会奏效,并且在 Nehalem 以外的 CPU 上,在旁路延迟与 ALU 延迟之间为自己付出代价,即使在其他 FP 指令之间使用也是如此。

0.0 行为是个阻碍。除此之外,与其他输入的 FP 乘法相比,下溢行为更不理想,例如即使设置了 FTZ(输出清零)也会产生次正常值。使用 DAZ 集(非正规化为零)读取它的代码仍然可以正确处理它,但是 FP bit-pattern 对于具有最小归一化指数(编码为 1)和 non-zero尾数。例如将标准化数字乘以 0.5f.

,您可以获得 0x00000001 的 bit-pattern

即使不是 0.0f showstopper,这种怪异现象也可能超出 GCC 愿意加诸于人们的范围。因此,即使对于 GCC 可以证明 non-zero 的情况,我也不期望它,除非它也可以证明远离 FLT_MIN。这可能非常罕见,不值得寻找。

当你知道它是安全的时候你当然可以手动完成它,尽管使用 SIMD 内在函数更方便。 我希望标量 type-punning 的 asm 相当糟糕,当您只需要低标量 FP 元素时,可能是整数 sub 附近的 2x movd,而不是将其保存在 paddd 的 XMM 中。

Godbolt for several attempts,包括直接的内在函数,clang 会像我们希望的那样编译成 memory-source paddd。 Clang 的 shuffle 优化器看到上面的元素是“死的”(_mm_cvtss_f32 只读取底部的元素),并且能够将它们视为“不关心”。

// clang compiles this fully efficiently
// others waste an instruction or more on _mm_set_ss to zero the upper XMM elements
float mulHalf_opt_intrinsics(float x) {
    __m128i e = _mm_set1_epi32(-1u << 23);
    __m128 vx = _mm_set_ss(x);
    vx = _mm_castsi128_ps( _mm_add_epi32(_mm_castps_si128(vx), e) );
    return _mm_cvtss_f32(vx);
}

还有一个普通的标量版本。我还没有测试它是否可以 auto-vectorize,但可以想象它可以这样做。否则,GCC 和 clang 都会执行 movd/add/movd(或 sub)将值反弹到 GP-integer 寄存器。

float mulHalf_opt_memcpy_scalar(float x) {
    uint32_t xi;
    memcpy(&xi, &x, sizeof(x));
    xi += -1u << 23;
    memcpy(&x, &xi, sizeof(x));
    return x;
}