c++ 2除法浮点幂的优化

owfi6suc  于 2023-06-07  发布在  其他
关注(0)|答案(2)|浏览(428)

假设我想把unsigned int除以2或4或8,等等。AFAIK编译器用移位替换这种除法。
但是,我可以期望它不是将float除以128,而是从它的指数部分减去7吗?
确保使用指数减法而不是浮点除法的最佳实践是什么?

lrpiutwd

lrpiutwd1#

如果你要乘以或除以一个常数,一个中等质量的编译器应该优化它。在许多平台上,硬件乘法指令可能是最佳的。
对于乘以(或除以)2的幂,std::ldexp(x, p)x乘以2p,其中pint(如果p取反,则除以)。我不希望在大多数平台上比简单乘法有太大的好处,因为手动(软件)指数操作必须包括上溢和下溢检查,因此在大多数情况下,所得的指令序列不太可能比硬件乘法有所改进。

ffdz8vbo

ffdz8vbo2#

TL;DR

使用带有/运算符的默认除法

长应答

我写了一个浮点2次幂乘法/除法的半工作按位实现(不考虑NaN,Infinity和0,正如Peter Cordes指出的那样),但发现它的性能仍然比2次幂除法的原生/略差,尽管比非2次幂除法好。
这表明GCC对2的乘方进行了某种优化,我们可以通过查看它在Godbolt上生成的x86-64程序集来证实这一点。
对于2的幂,1/2.0f = 0.5f可以精确地表示而不会引入任何舍入误差,因此n/2.0f完全等价于n * 0.5f。GCC知道即使没有-ffast-math也可以安全地进行优化。Binary floating-pointmantissa * 2^exp使用以2为底的指数,因此2的幂值(包括像0.5 = 2^-1这样的负幂)可以精确地用1.0尾数表示。

#include "stdio.h"

union float_int{
    unsigned int i;
    float f;
};

float float_pow2(float n, int pow){
    union float_int u;
    u.f = n;
    unsigned int exp = ((u.i>>23)&0xff) + pow;
    u.i = (u.i&0b10000000011111111111111111111111) | (exp<<23);
    return u.f;
}

int main(){
    float n = 3.14;
    float result = 0;
    for(int i = 0; i < 1000000000; i++){
        // Uncomment one of the four
        // result += n/2.01f;
        // result += n/2.0f;
        // result += n/2;
        result += float_pow2(n,-1);
        
        // Prevent value re-use
        // n == 100003.14 by the time the loop ends, and the result will be in float range
        n += 0.01f;
    }
    printf("%f\n", result);
}

性能

代码使用GCC -O3编译。如果没有优化,编译器将不会内联float_pow2,并且将在每个语句之后存储/重新加载,因此自定义函数的性能将更差,因为它是用多个语句完成的。

内置除法性能,除数为2.01f(x86 divss

real    0m1.907s
user    0m1.896s
sys 0m0.000s

内置2.0f除数除法性能(x86 mulss

real    0m0.798s
user    0m0.791s
sys 0m0.004s

内置除数为2的除法性能(x86 mulss

real    0m0.798s
user    0m0.794s
sys 0m0.004s

自定义除法性能(float_pow2)

(GCC将数据复制到整数寄存器并返回,而不是使用SSE 2向量整数数学指令。)

real    0m0.968s
user    0m0.967s
sys 0m0.000s

关于准确度,在进行的10次测试中,最后一次测试的标准偏差为0.018秒。其他人似乎落在类似的范围内的一致性。
2.0f2.0的性能几乎相同,根据godbolt.org,它们确实被编译成了同一个程序集。
对基准测试实际测量的内容进行性能分析(本节由@Peter Cordes撰写):
基准测试测量float加法的延迟,或者循环体的总吞吐量成本(如果更高的话)。(例如,如果编译器无法优化除法到乘法,请参阅Floating point division vs floating point multiplication)。
或者使用float_pow2,在Intel CPU上会很复杂:https://uica.uops.info/对GCC的asm循环的Skylake和Ice Lake的预测(从Godbolt复制/粘贴)非常接近测量结果:对于float_pow2循环,每次迭代约4.9个循环,而对于4.0c用于/= 2(aka *= 0.5f)循环。4.9c / 4c = 1.21的性能比非常接近0.968s/0.798s = 1.21
它的分析表明,它不像divss那样是一个吞吐量瓶颈(如果执行单元不必等待输入准备就绪,它可以在每次迭代2.25个周期内运行那么多的工作)。理论上,仅两个addss依赖链就有4个周期长。(Skylake具有2个/时钟FP add/穆尔吞吐量,具有4个周期延迟。
但是在某些周期中,当addss准备执行时,它必须等待一个周期,因为不同的uop是oldest one ready for that execution port。(可能是因为movd eax, xmm0addss xmm0, xmm2都在等待相同的XMM 0输入,即上一次迭代中addss xmm0, xmm2的结果。这是n += 0.01f。在这些addss xmm0, xmm2 uop中,它们被调度到端口0,在那里它们遇到了这种资源冲突,这延迟了关键路径依赖链上的进程,n += 0.01f
因此,我们在这里真正测量的是由float_pow2的额外工作所产生的资源冲突,这些额外工作会干扰两个FP添加延迟瓶颈。如果加法运算在输入准备就绪后没有立即运行,则无法弥补损失的时间。这是因为它是延迟瓶颈,而不是吞吐量瓶颈。使用多个n1 += 0.02f/n2 += 0.02f等展开可以避免这种情况,但编译器不能在没有-ffast-math的情况下这样做,因为它会引入不同的舍入误差。
mulss仅是一条指令,理论上可能会产生相同的瓶颈,但在这种情况下,uop调度往往会奏效,因此它不会从关键路径窃取周期。
顺便说一句,由乘法(或其他一些操作)连接的两个加法链的依赖模式与Latency bounds and throughput bounds for processors for operations that must occur in sequence相同

相关问题