通过快速浮点数倒数,高效计算2**64 / 除数

18

我目前正在探索使用现代处理器的快速单精度浮点数倒数能力来计算基于定点牛顿-拉夫逊迭代的64位无符号整数除法的起始近似值。它需要尽可能准确地计算264 / 除数,其中初始近似值必须小于或等于数学结果,基于以下定点迭代的要求。这意味着此计算需要提供低估。根据广泛测试获得以下代码可正常工作:

#include <stdint.h> // import uint64_t
#include <math.h> // import nextafterf()

uint64_t divisor, recip;
float r, s, t;

t = uint64_to_float_ru (divisor); // ensure t >= divisor
r = 1.0f / t;
s = 0x1.0p64f * nextafterf (r, 0.0f);
recip = (uint64_t)s; // underestimate of 2**64 / divisor 

虽然这段代码是可行的,但在大多数平台上并不是很快。一种明显的改进方法,需要一些机器特定的代码,就是用硬件提供的快速浮点倒数替换除法r = 1.0f / t。可以通过迭代来增强,以产生一个与数学结果相差不超过1 ulp的结果,在现有代码的情况下产生一个低估值。对于x86_64的示例实现如下:

#include <xmmintrin.h>
/* Compute 1.0f/a almost correctly rounded. Halley iteration with cubic convergence */
inline float fast_recip_f32 (float a)
{
    __m128 t;
    float e, r;
    t = _mm_set_ss (a);
    t = _mm_rcp_ss (t);
    _mm_store_ss (&r, t);
    e = fmaf (r, -a, 1.0f);
    e = fmaf (e, e, e);
    r = fmaf (e, r, r);
    return r;
}

nextafterf() 的实现通常不是针对性能进行优化的。在存在一种快速重新解释 IEEE 754 binary32int32,反之亦然的平台上,通过使用内置函数 float_as_int()int_as_float(),我们可以结合使用 nextafterf() 和缩放,具体如下:

s = int_as_float (float_as_int (r) + 0x1fffffff);

假设在特定平台上这些方法是可行的,那么在floatuint64_t之间的转换将成为主要障碍。大多数平台不提供执行从uint64_tfloat的转换的指令,并且一些平台不提供任何指令来转换uint64_t和浮点类型之间的转换,这成为了性能瓶颈。请注意保留HTML标签。
t = uint64_to_float_ru (divisor);
r = fast_recip_f32 (t);
s = int_as_float (float_as_int (r) + 0x1fffffff);
recip = (uint64_t)s; /* underestimate of 2**64 / divisor */

一种可移植但较慢的 uint64_to_float_ru 实现会使用动态更改 FPU 舍入模式:

#include <fenv.h>
#pragma STDC FENV_ACCESS ON

float uint64_to_float_ru (uint64_t a)
{
    float res;
    int curr_mode = fegetround ();
    fesetround (FE_UPWARD);
    res = (float)a;
    fesetround (curr_mode);
    return res;
}

我研究了各种分割和位操作的方法来处理转换(例如,在整数侧进行四舍五入,然后使用使用IEEE 754舍入模式"Round-to-Nearest-or-Even"的普通float转换),但是这会带来额外的开销,从性能角度考虑,通过快速浮点数倒数计算并不具有吸引力。目前看来,最好的方法是使用经典的LUT和插值生成起始近似值,或者使用定点多项式逼近方法,并配合32位定点牛顿迭代步骤。

是否有提高当前方法效率的方法? 可移植和半可移植的方式包括特定平台的intrinsics将会是感兴趣的(特别是对于x86和ARM作为当前主导的CPU架构)。在使用Intel编译器以非常高的优化级别(/O3 /QxCORE-AVX2 /Qprec-div-)编译为x86_64时,初始逼近值的计算所需指令比迭代所需指令多。以下是完整的除法代码,显示了上下文中的逼近值。

uint64_t udiv64 (uint64_t dividend, uint64_t divisor)
{
    uint64_t temp, quot, rem, recip, neg_divisor = 0ULL - divisor;
    float r, s, t;

    /* compute initial approximation for reciprocal; must be underestimate! */
    t = uint64_to_float_ru (divisor);
    r = 1.0f / t;
    s = 0x1.0p64f * nextafterf (r, 0.0f);
    recip = (uint64_t)s; /* underestimate of 2**64 / divisor */

    /* perform Halley iteration with cubic convergence to refine reciprocal */
    temp = neg_divisor * recip;
    temp = umul64hi (temp, temp) + temp;
    recip = umul64hi (recip, temp) + recip;

    /* compute preliminary quotient and remainder */
    quot = umul64hi (dividend, recip); 
    rem = dividend - divisor * quot;

    /* adjust quotient if too small; quotient off by 2 at most */
    if (rem >= divisor) quot += ((rem - divisor) >= divisor) ? 2 : 1;

    /* handle division by zero */
    if (divisor == 0ULL) quot = ~0ULL;

    return quot;
}

umul64hi() 通常会映射到平台特定的内置函数或一些内联汇编代码。在 x86_64 上,我目前使用以下实现:

inline uint64_t umul64hi (uint64_t a, uint64_t b)
{
    uint64_t res;
    __asm__ (
        "movq  %1, %%rax;\n\t"  // rax = a
        "mulq  %2;\n\t"         // rdx:rax = a * b
        "movq  %%rdx, %0;\n\t"  // res = (a * b)<63:32>
        : "=rm" (res)
        : "rm"(a), "rm"(b)
        : "%rax", "%rdx");
    return res;
}

@JohnZwinck 可能吧 :-) 通常需要调整编译器开关,但这会以不希望的方式对其他代码产生负面影响。Intrinsics 很好,它们经常可以抽象成一组“通用 intrinsics”,这些 intrinsics 与特定平台的 intrinsics 相似(请参见 GROMACS 的 SIMD 源代码作为一个实例)。无论如何,浮点倒数并不是我的问题,转换正在破坏我的方法(除了在 GPU 上)。 - njuffa
1
还有类似的问题在这里:https://dev59.com/55Pfa4cB1Zd3GeqPD4D8#35096198 - user3528438
你能限定你所操作的数字范围吗? - LogicG8
@LogicG8 我不确定你在这个问题上的方向。由于这是在64位无符号整数除法的背景下,传递给倒数计算的除数在[1,2 ** 64)范围内。对于现代处理器实现的浮点倒数近似指令来说,这不是问题。它似乎排除了在整数和浮点空间之间进行转换的众所周知的技巧,特别是“魔术数字加法”。 - njuffa
@njuffa 那正是我要去的地方。 - LogicG8
显示剩余12条评论
1个回答

2
这个解决方案结合了两个想法:
  • 只要数字在特定范围内,您可以通过将位重新解释为浮点数并减去一个常量来将其转换为浮点数。因此,添加一个常量,重新解释,然后再减去该常量。这将产生截断的结果(因此始终小于或等于所需值)。
  • 您可以通过将指数和尾数都取反来近似倒数。这可以通过将位解释为int来实现。

这里的选项1仅适用于一定范围内,因此我们检查范围并调整所使用的常量。这适用于64位,因为期望的浮点数仅具有23位精度。

此代码中的结果将为double,但转换为float很容易,并且可以根据硬件直接操作位或直接执行。

此后,您需要执行牛顿-拉弗森迭代。

此代码的许多部分仅转换为魔术数字。

double                                                       
u64tod_inv( uint64_t u64 ) {                                 
  __asm__( "#annot0" );                                      
  union {                                                    
    double f;                                                
    struct {                                                 
      unsigned long m:52; // careful here with endianess     
      unsigned long x:11;                                    
      unsigned long s:1;                                     
    } u64;                                                   
    uint64_t u64i;                                           
  } z,                                                       
        magic0 = { .u64 = { 0, (1<<10)-1 + 52, 0 } },        
        magic1 = { .u64 = { 0, (1<<10)-1 + (52+12), 0 } },   
        magic2 = { .u64 = { 0, 2046, 0 } };                  

  __asm__( "#annot1" );                                      
  if( u64 < (1UL << 52UL ) ) {                               
    z.u64i = u64 + magic0.u64i;                              
    z.f   -= magic0.f;                                       
  } else {                                                   
    z.u64i = ( u64 >> 12 ) + magic1.u64i;                    
    z.f   -= magic1.f;                                       
  }                                                          
  __asm__( "#annot2" );                                      

  z.u64i = magic2.u64i - z.u64i;                             

  return z.f;                                                
}                                                            

在Intel Core 7上编译这个程序将产生一些指令(和一个分支),但当然不会有任何乘法或除法。如果在int和double之间的转换很快,这应该运行得相当快。

我怀疑浮点数(只有23位精度)需要进行超过1或2次牛顿迭代才能获得所需的精度,但我没有做过相关计算......


你提到了uint64和浮点数之间的转换问题...这个处理了。它通过你链接的相同方法进行近似倒数。既然那些不是你要找的,而且你知道现有的近似倒数指令,我不确定你真正想要回答什么。 - tolkienfan
我知道通过重新解释和使用魔数(在注释中提到)进行转换,以及如何通过整数操作形成快速倒数。因此,我不确定这里是否有任何我尚未尝试过的东西。由于我现在有一些时间,我将仔细查看您的代码,并了解它如何插入我上面展示的完整除法序列中,以便更好地回答我的问题。如果您愿意,还可以澄清这个插件方面。 - njuffa
从我的实验中可以看出,u64tod_inv()是一个低精度的替代品,用于 t = uint64_to_float_ru (divisor); r = 1.0f / t;,相对误差为0.125,需要三个浮点数NR迭代才能得到单精度精度的结果。看起来这可能会起作用(初始recip是否保证了紧密的低估?),但由于它不使用快速硬件浮点倒数功能(根据问题标题),这不是我正在寻找的答案。 - njuffa
你是正确的 - 它是1./t的低精度替代品(除了它也进行转换)。重新阅读后,我发现你需要的是与我最初想象的相反方向的舍入。这段代码不会向下舍入,但可以通过乘法来修复(有一个严格的相对误差范围)。不过,看起来你并不真正需要一个严格的低估值,是吗? - tolkienfan
正如在问题的评论中所确定的那样,定点Halley迭代需要一个初始估计值,该值小于或等于数学结果。 - njuffa
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接