关于C#:具有常数整数除数的高效浮点除法

Efficient floating-point division with constant integer divisors

最近的一个问题,编译器是否可以用浮点乘法替换浮点除法,启发我问这个问题。

在严格要求下,代码转换后的结果应与实际的除法运算完全一致。对于二进制的ieee-754算法来说,这对于二次幂的除数来说是可能的,这一点是微不足道的。只要对方除数的倒数乘以除数的倒数可表示除数的结果与除数相同。例如,用0.5进行乘法可以用2.0代替除法。

然后,我们会想知道其他除数这样的替换是如何工作的,假设我们允许任何简短的指令序列来替换除数,但运行速度要快得多,同时提供完全相同的结果。特别是除了纯乘法之外,还允许融合乘法加法运算。在评论中,我指出了以下相关文件:

尼古拉斯·布里斯巴雷、让·米歇尔·穆勒和索拉巴·库马尔·雷纳。当除数提前已知时,加速正确舍入浮点除法。《IEEE计算机汇刊》,第53卷,第8期,2004年8月,第1069-1072页。

论文作者所倡导的技术将除数y的倒数预计算为标准化的头尾对zh:zlas follows:zh=1/y,zl=fma(-y,zh,1)/y。稍后,除数q=x/y随后计算为q=fma(zh,x,zlx)。本文推导了除数y必须满足的各种条件,才能使该算法工作。正如人们所观察到的,当头部和尾部的符号不同时,该算法存在无穷大和零的问题。更重要的是,由于计算商尾,zl*x,它将无法为非常小的股息x提供正确的结果。

本文还简要介绍了一种基于fma的划分算法,该算法是由PeterMarkstein在IBM时首创的。相关参考是:

马克斯坦。IBM RISC System/6000处理器上基本函数的计算。IBM研究与开发杂志,第34卷,第1期,1990年1月,第111-119页

在Markstein的算法中,首先计算一个倒数rc,从中形成一个初始商q=x*rc。然后,用fma作为r=fma(-y,q,x)精确地计算除法的剩余部分,最后用q=fma(r,rc,q)计算改进的、更精确的商。

该算法还存在0或无穷大的x问题(通过适当的条件执行很容易解决),但使用IEEE-754单精度float数据进行的详尽测试表明,它在所有可能的除数x中,为这些小整数中的许多除数y提供正确的商。此C代码实现它:

1
2
3
4
5
6
7
8
9
/* precompute reciprocal */
rc = 1.0f / y;

/* compute quotient q=x/y */
q = x * rc;
if ((x != 0) && (!isinf(x))) {
    r = fmaf (-y, q, x);
    q = fmaf (r, rc, q);
}

在大多数处理器体系结构中,这应该转换为无分支的指令序列,使用预测、条件移动或选择类型指令。举一个具体的例子:对于3.0f的除法,CUDA 7.5的nvcc编译器为开普勒类GPU生成以下机器代码:

1
2
3
4
5
6
7
8
    LDG.E R5, [R2];                        // load x
    FSETP.NEU.AND P0, PT, |R5|, +INF , PT; // pred0 = fabsf(x) != INF
    FMUL32I R2, R5, 0.3333333432674408;    // q = x * (1.0f/3.0f)
    FSETP.NEU.AND P0, PT, R5, RZ, P0;      // pred0 = (x != 0.0f) && (fabsf(x) != INF)
    FMA R5, R2, -3, R5;                    // r = fmaf (q, -3.0f, x);
    MOV R4, R2                             // q
@P0 FFMA R4, R5, c[0x2][0x0], R2;          // if (pred0) q = fmaf (r, (1.0f/3.0f), q)
    ST.E [R6], R4;                         // store q

在我的实验中,我编写了下面显示的微小的C测试程序,该程序按递增的顺序逐步通过整数除数,并针对每个整数除数详尽地测试上述代码序列与正确的除法。它打印了通过这个详尽测试的除数列表。部分输出如下:

1
PASS: 1, 2, 3, 4, 5, 7, 8, 9, 11, 13, 15, 16, 17, 19, 21, 23, 25, 27, 29, 31, 32, 33, 35, 37, 39, 41, 43, 45, 47, 49, 51, 53, 55, 57, 59, 61, 63, 64, 65, 67, 69,

为了将替换算法作为一种优化合并到编译器中,上述代码转换可以安全地应用到的除数白名单是不切实际的。到目前为止,该程序的输出(大约每分钟一个结果)表明,对于那些奇数整数或二次幂的除数y,快速代码在x的所有可能编码中都能正确工作。当然是轶事证据,而不是证据。

什么样的数学条件可以决定a-先验,将除法转换成上述代码序列是否安全?答案可以假设所有浮点运算都是在默认的舍入模式"舍入到最近或偶数"下执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <stdlib.h>
#include <stdio.h>
#include <math.h>

int main (void)
{
    float r, q, x, y, rc;
    volatile union {
        float f;
        unsigned int i;
    } arg, res, ref;
    int err;

    y = 1.0f;
    printf ("PASS:");
    while (1) {
        /* precompute reciprocal */
        rc = 1.0f / y;

        arg.i = 0x80000000;
        err = 0;
        do {
            /* do the division, fast */
            x = arg.f;
            q = x * rc;
            if ((x != 0) && (!isinf(x))) {
                r = fmaf (-y, q, x);
                q = fmaf (r, rc, q);
            }
            res.f = q;
            /* compute the reference, slowly */
            ref.f = x / y;

            if (res.i != ref.i) {
                err = 1;
                break;
            }
            arg.i--;
        } while (arg.i != 0x80000000);

        if (!err) printf ("%g,", y);
        y += 1.0f;
    }
    return EXIT_SUCCESS;
}


这个问题要求找到一种方法来确定常数Y的值,这样就可以安全地将x / Y转换为使用fma对x的所有可能值进行更便宜的计算。另一种方法是使用静态分析来确定x可以取的值的过度近似,这样,一般不健全的转换就可以应用于转换代码不同于原始除法的值不会发生的知识中。好的。

使用一组很好地适应浮点计算问题的浮点值的表示法,甚至从函数开始的前向分析也可以产生有用的信息。例如:好的。

1
2
3
4
5
float f(float z) {
  float x = 1.0f + z;
  float r = x / Y;
  return r;
}

假设默认的四舍五入到最近的模式(*),在上述函数中,x只能是NaN(如果输入是NaN),+0.0F,或大于2-24的数值,但不能是-0.0F或任何小于2-24的数值。这证明了将常数Y的许多值转换为问题中所示的两种形式之一是正确的。好的。

(*)假设没有这些假设,许多优化是不可能的,并且C编译器已经做出了这些假设,除非程序显式地使用#pragma STDC FENV_ACCESS ON。好的。

预测上述x的信息的前向静态分析可以基于一组浮点值的表示,表达式可以作为一个元组:好的。

  • 一组可能的NaN值的表示(由于NaN的行为未指定,因此选择仅使用布尔值,其中true表示可以存在一些NaN,false表示不存在NaN)。
  • 四个布尔标志分别指示存在+inf、-inf、+0.0、-0.0、
  • 负有限浮点值的包含区间,以及
  • 正有限浮点值的包含区间。

为了遵循这种方法,静态分析器必须理解C程序中可能发生的所有浮点操作。举例来说,在分析的代码中,用于处理+的值u和v之间的相加可以实现为:好的。

  • 如果其中一个操作数中存在NaN,或者操作数可以是相反符号的无穷大,则结果中存在NaN。
  • 如果0不能是u值和v值相加的结果,请使用标准间隔算法。结果的上界为u中的最大值和v中的最大值的四舍五入到最近的加法,因此这些界应使用四舍五入到最近的方法计算。
  • 如果0是u的正值和v的负值相加的结果,那么让m是u中最小的正值,这样-m在v中出现。
    • 如果suc(m)存在于u中,那么这对值将suc(m)-m贡献给结果的正值。
    • 如果-suc(m)存在于v中,则这对值将负值m-suc(m)贡献给结果的负值。
    • 如果pred(m)存在于u中,则这对值将负值pred(m)-m贡献给结果的负值。
    • 如果v中存在-pred(m),则这对值将m-pred(m)的值贡献给结果的正值。
  • 如果0是u的负值和v的正值相加的结果,则执行相同的操作。

承认:以上借鉴了布鲁诺?马尔(Bruno Marre)和克劳德?米歇尔(Claude Michel)提出的"改进浮点加减约束"的观点。好的。

示例:以下函数f的编译:好的。

1
2
3
4
5
6
7
8
float f(float z, float t) {
  float x = 1.0f + z;
  if (x + t == 0.0f) {
    float r = x / 6.0f;
    return r;
  }
  return 0.0f;
}

问题中的方法拒绝将函数f中的除法转换为替代形式,因为6不是除法可以无条件转换的值之一。相反,我建议从函数的开始应用一个简单的值分析,在这种情况下,它确定x是一个有限的浮点,或者是+0.0f或者至少是2-24个数量级,并且使用这个信息来应用brisebarre等人的转换,相信x * C2的知识。不下溢。好的。

为了明确起见,我建议使用下面的算法来决定是否将除法转换为更简单的方法:好的。

  • Y是不是可以根据其算法使用Brisebarre等人的方法转换的值之一?
  • 方法中的c1和c2是否具有相同的符号,或者是否可以排除股息无穷大的可能性?
  • 方法中的c1和c2是否有相同的符号,或者x只能取0的两个表示中的一个?如果c1和c2有不同的符号,并且x只能是一个零的表示,请记住使用基于fma的计算的符号(**),使其在x为零时产生正确的零。
  • 股息的规模是否能够保证足够大,以排除x * C2资金下溢的可能性?
  • 如果四个问题的答案是"是",那么除法可以在编译函数的上下文中转换为乘法和fma。上述静态分析用于回答问题2、3。4。好的。

    (**)"摆弄符号"是指当需要使用-fma(-c1,x,(-c2)*x)代替fma(c1,x,c2*x),以便在x只能是两个有符号零中的一个时使结果正确出现。好的。好啊。


    让我第三次重新启动。我们正试图加速好的。

    1
        q = x / y

    其中y为整数常数,qxy均为ieee 754-2008二进制32浮点值。下面,fmaf(a,b,c)表示使用二进制32值的融合乘法加a * b + c。好的。

    简单的算法是通过预先计算的倒数,好的。

    1
        C = 1.0f / y

    所以在运行时(更快)的乘法就足够了:好的。

    1
        q = x * C

    Brisebarre-Muller Raina加速度使用两个预先计算的常量,好的。

    1
    2
        zh = 1.0f / y
        zl = -fmaf(zh, y, -1.0f) / y

    因此,在运行时,一个乘法和一个融合乘法加法就足够了:好的。

    1
        q = fmaf(x, zh, x * zl)

    markstein算法将naive方法与两个融合乘法结合起来,如果naive方法通过预先计算在最不重要的位置在1个单位内生成结果,则会得到正确的结果。好的。

    1
    2
        C1 = 1.0f / y
        C2 = -y

    这样除数就可以用好的。

    1
    2
    3
        t1 = x * C1
        t2 = fmaf(C1, t1, x)
        q  = fmaf(C2, t2, t1)

    这种幼稚的方法适用于两个y的所有权力,但除此之外,它是相当糟糕的。例如,对于除数7、14、15、28和30,它会在所有可能的x的一半以上产生错误的结果。好的。

    Brisebarre-Muller-Raina方法在几乎所有两个y的非功率方面都同样失败,但产生错误结果的x要少得多(不到所有可能x的一半,取决于y)。好的。

    Brisebarre-Muller-Raina的文章表明,这种简单方法的最大误差为±1.5 ulps。好的。

    对于两个y的幂和奇数y的幂,markstein方法得到正确的结果。(我没有为Markstein方法找到一个失败的奇数整除数。)好的。

    对于Markstein方法,我分析了除数1-19700(这里是原始数据)。好的。

    绘制失败案例的数量(横轴中的除数,该除数的markstein方法失败的x的值的数量),我们可以看到一个简单的模式发生:好的。

    Markstein失败案例http://www.nominal-animal.net/answers/markstein.png好的。

    注意,这些图的水平轴和垂直轴都是对数。奇数除数没有点,因为该方法为我测试过的所有奇数除数生成正确的结果。好的。

    如果我们将x轴更改为除数器的位反转(二进制数字的倒序,即0B111101101→0B110110111,数据),我们将得到一个非常清晰的模式:Markstein失败案例,位反向除数http://www.nominal-animal.net/answers/markstein-failures.png好的。

    如果我们通过点集的中心画一条直线,我们得到曲线4194304/x。(请记住,绘图只考虑一半可能的浮点,因此在考虑所有可能的浮点时,将其加倍。)8388608/x2097152/x完全包围了整个错误模式。好的。

    因此,如果我们使用rev(y)来计算除数y的比特倒数,那么8388608/rev(y)是一个很好的一阶近似数(在所有可能的浮点数中),其中markstein方法会对两个除数y的偶数非幂产生不正确的结果。(或者,16777216/rev(x)表示上限。)好的。

    添加了2016-02-28:我发现了一个使用markstein方法的错误案例数量的近似值,给定任何整数(binary32)除数。这里是伪代码:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    function markstein_failure_estimate(divisor):
        if (divisor is zero)
            return no estimate
        if (divisor is not an integer)
            return no estimate

        if (divisor is negative)
            negate divisor

        # Consider, for avoiding underflow cases,
        if (divisor is very large, say 1e+30 or larger)
            return no estimate - do as division

        while (divisor > 16777216)
            divisor = divisor / 2

        if (divisor is a power of two)
            return 0

        if (divisor is odd)
            return 0

        while (divisor is not odd)
            divisor = divisor / 2

        # Use return (1 + 83833608 / divisor) / 2
        # if only nonnegative finite float divisors are counted!
        return 1 + 8388608 / divisor

    在我测试过的Markstein失效案例中,这会产生一个正确的误差估计值,误差估计值在±1以内(但我还没有充分测试过大于8388608的除数)。最后的除法应该是这样的,它不会报告错误的零,但我不能保证(现在)。它没有考虑到非常大的除数(比如0x1p100,或者1e+30,以及更大的数量级),这些除数有下溢问题——无论如何,我肯定会将这些除数排除在加速之外。好的。

    在初步测试中,估计值似乎异常准确。我没有画出一个图来比较1到20000除数的估计值和实际误差,因为这些点在图中完全一致。(在这个范围内,估计值是精确的,或者太大。)从本质上讲,估计值精确地复制了这个答案中的第一个图。好的。

    Markstein方法的失败模式是规则的,非常有趣。该方法适用于两个除数的所有幂和所有奇数整数除数。好的。

    对于大于16777216的除数,我始终看到与除数相同的错误,除数除以2的最小幂得到小于16777216的值。例如,0x1.3cdfa4p+23和0x1.3cdfa4p+41、0x1.d8874p+23和0x1.d8874p+32、0x1.cf84f8p+23和0x1.cf84f8p+34、0x1.e4a7fp+23和0x1.e4a7fp+37。(每对中尾数相同,只有两个的力量不同。)好的。

    假设我的测试台没有出错,这意味着markstein方法也可以处理大于16777216的除数(但小于,例如,1e+30),如果除数是这样的,当除以最小的2次幂,得到小于16777216的商,并且商是奇数。好的。好啊。


    我喜欢@pascal的答案,但是在优化中,拥有一个简单且易于理解的转换子集,而不是一个完美的解决方案通常会更好。

    所有当前和常见的历史浮点格式都有一个共同点:二进制尾数。

    因此,所有分数都是形式的有理数:

    x/2n

    这与程序中的常量(以及所有可能的基10分数)形成对比,后者是形式为的有理数:

    x/(2n*5m)

    因此,一个优化将简单地测试m==0的输入和倒数,因为这些数字是以fp格式精确表示的,用它们进行的操作将产生格式中准确的数字。

    因此,例如,在.010.99的(十进制2位)范围内,除以或乘以以下数字将得到优化:

    1
    .25 .50 .75

    其他一切都不会。(我想,先测试一下,哈哈)


    浮点除法的结果是:

    • 标志旗
    • 意义重大
    • 一个指数
    • 一组标志(溢出、下溢、不精确等——见fenv())

    前3个片段正确(但标记集不正确)是不够的。如果不进一步了解(例如,结果的哪些部分实际上很重要,股息的可能值等),我会假设用常数代替除法,用常数乘以(和/或复杂的fma混乱)几乎是不安全的。

    此外,对于现代CPU,我也不认为用2个FMA替换一个分区总是一种改进。例如,如果瓶颈是指令获取/解码,那么这种"优化"会使性能变差。例如,如果后续指令不依赖于结果(CPU可以在等待结果的同时并行执行许多其他指令),则FMA版本可能会导致多个依赖暂停并使性能变差。对于第三个例子,如果使用了所有寄存器,那么fma版本(它需要额外的"活动"变量)可能会增加"溢出"并使性能变差。

    请注意(在许多情况下,但并非所有情况下)2的常数倍数的除法或乘法可以单独使用加法(特别是向指数添加移位计数)来完成。