Эффективное вычисление 2 ** 64/делителя через быстродействующую обратную точку с плавающей запятой

В настоящее время я изучаю способы использования быстрой одноточечной функции с плавающей запятой различных современных процессоров для вычисления начального приближения для 64-разрядного беззнакового целочисленного деления на основе итераций Newton-Raphson с фиксированной точкой. Это требует вычисления 2 64/divisor, насколько это возможно, где начальное приближение должно быть меньше или равно математическому результату на основе требований следующих итераций с фиксированной точкой. Это означает, что это вычисление должно обеспечить недооценку. В настоящее время у меня есть следующий код, который хорошо работает, основываясь на обширном тестировании:

#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 binary32 в int32 и наоборот, через intrinsics float_as_int() и int_as_float(), мы можем комбинировать использование nextafterf() и масштабирования следующим образом:

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

Предполагая, что эти подходы возможны на данной платформе, это оставляет нас с преобразованиями между float и uint64_t в качестве основных препятствий. Большинство платформ не предоставляют команду, которая выполняет преобразование с uint64_t в float со статическим режимом округления (здесь: в направлении положительной бесконечности = вверх), а некоторые не предлагают никаких команд для преобразования между uint64_t и плавающей точечные типы, что делает это узким местом производительности.

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;
}

Я рассмотрел различные методы разделения и бит-скругления для обработки конверсий (например, округление на целочисленной стороне, затем используйте обычное преобразование в float, которое использует круглый режим IEEE 754 с округлением до ближайшего -или-четное), но накладные расходы, которые это создает, делают это вычисление быстрым обратным возвратом с плавающей запятой с точки зрения производительности. Как бы то ни было, похоже, что мне лучше было бы начать начальное приближение, используя классический LUT с интерполяцией или полиномиальную аппроксимацию с фиксированной точкой, и последуйте за ними с помощью 32-битного шага Newton-Raphson с фиксированной точкой.

Есть ли способы повысить эффективность моего текущего подхода? Интересны портативные и полупортативные способы, связанные с встроенными функциями для конкретных платформ (в частности, для x86 и ARM в качестве доминирующих в настоящее время архитектур ЦП). Компиляция для x86_64 с использованием компилятора Intel при очень высокой оптимизации (/O3 /QxCORE-AVX2 /Qprec-div-) вычисление начального приближения занимает больше инструкций, чем итерация, которая занимает около 20 инструкций. Ниже приведен полный код деления для ссылки, показывающий приближение в контексте.

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;
}

Ответы

Ответ 1

Это решение объединяет две идеи:

  • Вы можете преобразовать в плавающую точку, просто переинтерпретируя биты как с плавающей запятой и вычитая константу, если число находится в определенном диапазоне. Поэтому добавьте константу, переинтерпретируйте, а затем вычтите эту константу. Это даст усеченный результат (который поэтому всегда меньше или равен желаемому значению).
  • Вы можете аппроксимировать взаимность, отрицая как экспонента, так и мантиссу. Это может быть достигнуто путем интерпретации битов как int.

Вариант 1 здесь работает только в определенном диапазоне, поэтому мы проверяем диапазон и корректируем используемые константы. Это работает в 64 битах, потому что желаемый поплавок имеет только 23 бита точности.

Результат этого кода будет двойным, но преобразование в float тривиально и может быть выполнено на битах или напрямую, в зависимости от аппаратного обеспечения.

После этого вы захотите выполнить итерацию Newton-Raphson.

Большая часть этого кода просто преобразуется в магические числа.

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 7 дает несколько инструкций (и ветку), но, разумеется, не размножается и не делит вообще. Если броски между int и double быстрые, это должно выполняться довольно быстро.

Я подозреваю, что float (всего 23 бита точности) потребует более 1 или 2 итераций Newton-Raphson, чтобы получить нужную вам точность, но я не выполнил математику...