Как уточнить деление с плавающей запятой на графических процессорах с поддержкой FMA?

При написании вычислительного кода для графических процессоров с использованием API-интерфейсов, в которых вычислительные шейдеры транслируются через SPIR-V (в частности, Vulkan), я гарантирую, что ошибка ULP при делении с плавающей запятой будет не более 3. Другая базовая арифметика (сложение, умножение) - правильно округленный.

Как эффективно реализовать правильно округленное деление в этих условиях? Предположим, что FMA доступна и правильно округлена.

Что происходит с денормальными значениями, будет зависеть от используемого оборудования. Vulkan API позволяет запрашивать, может ли устройство сохранять денормальные значения и может ли оно сбрасывать их до нуля (поэтому графический процессор, который не полностью поддерживает денормальные значения, будет иметь canPreserve: false, canFlush: true). Итак, давайте дополнительно предположим, что графический процессор может создавать и обрабатывать ненормальные значения, не сбрасывая их до нуля (в противном случае попытки получить правильно округленный результат, который является субнормальным, кажется бесполезным).


person amonakov    schedule 24.12.2020    source источник
comment
Это нетривиально. В качестве отправной точки вы можете посмотреть этот ответ.   -  person njuffa    schedule 25.12.2020
comment
Основная идея состоит в том, чтобы получить предварительное частное, достаточно близкое к математическому результату (1 ulp или меньше), а затем использовать остаток для определения правильно округленного результата. Полное решение, использующее эмуляцию с помощью целочисленной арифметики, можно найти в этом ответе.   -  person njuffa    schedule 25.12.2020
comment
Пожалуйста, поясните в вопросе: поддерживает ли Vulkan денормальные значения (субнормальные значения в терминологии IEEE-754) или обрабатывает субнормальные входные данные как ноль и предписывает реакцию сброса до нуля для субнормальных результатов?   -  person njuffa    schedule 25.12.2020
comment
@njuffa Я добавил абзац, Vulkan поддерживает денормализацию без обязательной промывки, разработчики оборудования могут выбирать, поддерживать ли денормализацию, программисты приложений могут запрашивать, что делает оборудование.   -  person amonakov    schedule 25.12.2020
comment
Я надеялся, что сработает что-нибудь простое, например, следующее: about_inv = 1 / y; приблизительно_квото = х * приблизительно_инв; cr_quo = fma (fma (-approx_quo, y, x), приблизительно_inv, приблизительно_quo)   -  person amonakov    schedule 25.12.2020
comment
... но на самом деле это не работает, и можно создать контрпримеры за несколько секунд, попробовав случайные входные данные. Хотя это кажется нелогичным.   -  person amonakov    schedule 25.12.2020
comment
Если вы хотите получить результат деления с правильным округлением в режиме округления до ближайшего или четного, вы просматриваете около 15 инструкций для быстрого пути, примерно половина из которых будет fma или fmul, при условии обратная величина точно округлена (ошибка менее 1 мультика). Вам необходимо уточнить обратное, а также предварительное частное, чтобы получить правильный результат.   -  person njuffa    schedule 25.12.2020


Ответы (1)


Современные алгоритмы деления на основе FMA обычно в значительной степени зависят от работы Питера Маркштейна, который много работал над этой проблемой. Каноническая ссылка:

Питер Маркштейн, IA-64 и элементарные функции: скорость и точность, Prentice-Hall 2000.

Приведенный ниже код C также основан на работе Маркштейна. В основном он состоит из быстрого пути, который обрабатывает общие случаи как можно быстрее, и медленного пути, который обрабатывает все надоедливые особые случаи.

Используемые концепции довольно просты, но они требуют тщательного математического анализа, чтобы гарантировать правильно округленный результат. Первый шаг - вычислить точную величину, обратную делителю. Это требует точного вычисления ошибки аппроксимации, для которой FMA является лучшим инструментом. Уточнение обратной величины из (аппаратного) приближения обычно принимает форму одной итерации Ньютона-Рафсона с квадратичной сходимостью или одной итерации Галлея с кубической сходимостью, обе из которых идеально отображаются в FMA.

Умножение обратной величины делителя на делимое дает нам приближение к частному. Это также уточняется на основе вычисления остатка на основе FMA. На этом этапе обычно работают с версией дивиденда, масштабированной до единицы, чтобы защитить от переполнения и потери значимости при промежуточных вычислениях и обеспечить максимально широкий диапазон операндов в быстром пути алгоритма деления. Это также означает, что в конце необходимо выполнить последний шаг масштабирования для корректировки величины частного.

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

Считайте приведенный ниже код алгоритмическим эскизом. Включенный тест уровня дыма, основанный на случайных тестовых векторах, далек от строгой тестовой среды. Условия для включения медленного пути не были показаны ни как можно более тесными, ни как необходимыми, ни как наиболее эффективными. Уточнение обратной использует один шаг Ньютона-Рафсона, но этого может быть недостаточно для данного обратного приближения графического процессора, и может потребоваться замена итерации Галлея за счет дополнительной FMA. Наконец, я пропустил построение всего медленного пути, поскольку это превысило бы ограничения ответа Stackoverflow, как с точки зрения проектных усилий, так и количества текстового описания.

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>

float uint32_as_float (uint32_t a)
{
    float r;
    memcpy (&r, &a, sizeof r);
    return r;
}

uint32_t float_as_uint32 (float a)
{
    uint32_t r;
    memcpy (&r, &a, sizeof r);
    return r;
}

float rcp_approx (float a)
{
    return 1.0f / a; // fake it for now
}

float fp32_div_slowpath (float dividend, float divisor)
{
    return dividend / divisor; // fake it for now
}

float fp32_div (float dividend, float divisor)
{
    const uint32_t FP32_MANT_MASK = 0x007fffffu;
    const uint32_t FP32_ONE = 0x3f800000u;
    const uint32_t FP32_SIGN_MASK = 0x80000000u;
    const uint32_t FP32_SIGN_EXPO_MASK = 0xff800000u;
    const uint32_t FP32_HI_LIMIT = 0x7e800000u; // 0x1.0p+126
    const uint32_t FP32_LO_LIMIT = 0x00800001u; // 0x1.0p-126 + ulp

    // fast path
    float recip_approx = rcp_approx (divisor); 
    float recip_err = fmaf (recip_approx, -divisor, 1.0f);
    float dividend_mant = uint32_as_float ((float_as_uint32 (dividend) & FP32_MANT_MASK) | FP32_ONE);
    float dividend_scale = uint32_as_float (float_as_uint32 (dividend) & FP32_SIGN_EXPO_MASK);
    float refined_recip = fmaf (recip_approx, recip_err, recip_approx);
    float quotient_mant = refined_recip * dividend_mant;
    float quotient_residual = fmaf (quotient_mant, -divisor, dividend_mant);
    float refined_quotient_mant = fmaf (refined_recip, quotient_residual, quotient_mant);
    float refined_quotient_residual = fmaf (refined_quotient_mant, -divisor, dividend_mant);
    float final_quotient_mant = fmaf (refined_recip, refined_quotient_residual, refined_quotient_mant);
    float final_quotient = final_quotient_mant * dividend_scale;

    // check if we need to apply the slow path and invoke it if that is the case
    uint32_t id = float_as_uint32 (divisor) & ~FP32_SIGN_MASK;
    uint32_t iq = float_as_uint32 (final_quotient) & ~FP32_SIGN_MASK;
    if ((id > FP32_HI_LIMIT) || ((iq - FP32_LO_LIMIT) > (FP32_HI_LIMIT - FP32_LO_LIMIT))) {
        final_quotient = fp32_div_slowpath (dividend, divisor);
    }

    return final_quotient;
}

// Fixes via: Greg Rose, KISS: A Bit Too Simple. http://eprint.iacr.org/2011/007
static unsigned int z=362436069,w=521288629,jsr=362436069,jcong=123456789;
#define znew (z=36969*(z&0xffff)+(z>>16))
#define wnew (w=18000*(w&0xffff)+(w>>16))
#define MWC  ((znew<<16)+wnew)
#define SHR3 (jsr^=(jsr<<13),jsr^=(jsr>>17),jsr^=(jsr<<5)) /* 2^32-1 */
#define CONG (jcong=69069*jcong+13579)                     /* 2^32 */
#define KISS ((MWC^CONG)+SHR3)

int main (void)
{
    uint64_t count = 0;
    float divisor, dividend, res, ref;

    do {
        if (!(count & 0xffffffULL)) printf ("\rcount: %llu", count);

        dividend = uint32_as_float (KISS);
        divisor  = uint32_as_float (KISS);
        res = fp32_div (dividend, divisor);
        ref = dividend / divisor;
        if (float_as_uint32 (res) != float_as_uint32 (ref)) {
            printf ("error @ dividend=% 16.6a divisor=% 16.6a (% 15.8e, % 15.8e): res=% 16.6a ref=% 16.6a (% 15.8e, % 15.8e)\n", 
                    dividend, divisor, dividend, divisor, res, ref, res, ref);
            return EXIT_FAILURE;
        }

        count++;
    } while (count);
    return EXIT_SUCCESS;
}

Некоторые дополнительные примечания после повторного ознакомления с проблемой. Предпочтительно, если инструкция обратного приближения оборудования обрабатывает субнормальные значения как ноль. Это позволяет исключить проверку величины делителя при вызове кода медленного пути, так как любой делитель, превышающий 2 126, даст результат, обратный нулю и частному бесконечности, тем самым инициируя частное проверить.

Что касается проверки частного, может быть принят любой результат быстрого пути, представляющий нормальный результат, за исключением наименьшего нормального значения, которое может представлять неправильное округление от субнормального результата. Другими словами, частные, абсолютное значение которых находится в [0x1.000002p-126, 0x1.fffffep + 127], не должны запускать повторное вычисление с помощью кода медленного пути.

Нет необходимости в шаге частичного уточнения, пока используется достаточно точная обратная величина. Исчерпывающий тест, который охватывает все дивиденды в [1,2] и все делители в [1,2] и, таким образом, все возможные комбинации шаблонов значащей (мантиссы), возможен с современным оборудованием, требующим 2 46 тестов случаи. Даже со скалярным кодом, работающим на одном ядре ЦП, он завершился менее чем за четыре дня без каких-либо сообщений об ошибках.

На практике, где это возможно, нужно заставить компилятор встроить fp32_div(), а fp_div_slowpath() в вызываемую подпрограмму.

Ниже я прототипировал основанную на AVX версию деления с одинарной точностью, используя упрощения, описанные выше. Он прошел все мои тесты. Поскольку точность аппаратного обратного преобразования на основе AVX невысока, требуется итерация Галлея для уточнения обратного значения до полной одинарной точности, обеспечивая результаты с максимальной ошибкой, близкой к 0,5 ulp.

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include "immintrin.h"

#define MODE_RANDOM           (1)
#define MODE_MANT_EXHAUSTIVE  (2)
#define TEST_MODE             (MODE_RANDOM)

float uint32_as_float (uint32_t a) { float r; memcpy (&r, &a, sizeof r); return r; }
uint32_t float_as_uint32 (float a) { uint32_t r; memcpy (&r, &a, sizeof r); return r; }
float fp32_div_slowpath (float dividend, float divisor) { return dividend / divisor; }

/* 12-bit approximation. Subnormal arguments and results are treated as zero */
float rcp_approx_avx (float divisor)
{
    float r;
    __m256 t = _mm256_set1_ps (divisor);
    t = _mm256_rcp_ps (t);
    memcpy (&r, &t, sizeof r);
    return r;
}

float fp32_div (float dividend, float divisor)
{
    const uint32_t FP32_MANT_MASK = 0x007fffffu;
    const uint32_t FP32_ONE = 0x3f800000u;
    const uint32_t FP32_SIGN_MASK = 0x80000000u;
    const uint32_t FP32_SIGN_EXPO_MASK = 0xff800000u;
    const uint32_t FP32_QUOTIENT_HI_LIMIT = 0x7f7fffffu; // 0x1.0p+128 - ulp
    const uint32_t FP32_QUOTIENT_LO_LIMIT = 0x00800001u; // 0x1.0p-126 + ulp

    // fast path
    float recip_approx = rcp_approx_avx (divisor);
    float recip_err = fmaf (recip_approx, -divisor, 1.0f);
    float recip_err2 = fmaf (recip_err, recip_err, recip_err);
    float refined_recip = fmaf (recip_approx, recip_err2, recip_approx);
    float dividend_mant = uint32_as_float ((float_as_uint32 (dividend) & FP32_MANT_MASK) | FP32_ONE);
    float dividend_scale = uint32_as_float (float_as_uint32 (dividend) & FP32_SIGN_EXPO_MASK);
    float refined_quotient_mant = refined_recip * dividend_mant;
    float refined_quotient_residual = fmaf (refined_quotient_mant, -divisor, dividend_mant);
    float final_quotient_mant = fmaf (refined_recip, refined_quotient_residual, refined_quotient_mant);
    float final_quotient = final_quotient_mant * dividend_scale;

    // check if we need to apply the slow path and invoke it if that is the case
    uint32_t iq = float_as_uint32 (final_quotient) & ~FP32_SIGN_MASK;
    if ((iq - FP32_QUOTIENT_LO_LIMIT) > (FP32_QUOTIENT_HI_LIMIT - FP32_QUOTIENT_LO_LIMIT)) {
        final_quotient = fp32_div_slowpath (dividend, divisor);
    }
    return final_quotient;
}

// Fixes via: Greg Rose, KISS: A Bit Too Simple. http://eprint.iacr.org/2011/007
static unsigned int z=362436069,w=521288629,jsr=362436069,jcong=123456789;
#define znew (z=36969*(z&0xffff)+(z>>16))
#define wnew (w=18000*(w&0xffff)+(w>>16))
#define MWC  ((znew<<16)+wnew)
#define SHR3 (jsr^=(jsr<<13),jsr^=(jsr>>17),jsr^=(jsr<<5)) /* 2^32-1 */
#define CONG (jcong=69069*jcong+13579)                     /* 2^32 */
#define KISS ((MWC^CONG)+SHR3)

int main (void)
{
    uint64_t count = 0;
    float divisor, dividend, res, ref;

#if TEST_MODE == MODE_RANDOM
    do {
        if (!(count & 0xffffffULL)) printf ("\rcount: %llu", count);

        dividend = uint32_as_float (KISS);
        divisor  = uint32_as_float (KISS);
        res = fp32_div (dividend, divisor);
        ref = dividend / divisor;
        if (float_as_uint32 (res) != float_as_uint32 (ref)) {
            printf ("error @ dividend=% 16.6a divisor=% 16.6a (% 15.8e, % 15.8e): res=% 16.6a ref=% 16.6a (% 15.8e, % 15.8e)\n", 
                    dividend, divisor,  dividend, divisor, res, ref, res, ref);
            return EXIT_FAILURE;
        }

        count++;
    } while (count);
#else
    for (dividend = 1.0f; dividend <= 2.0f; dividend = uint32_as_float (float_as_uint32 (dividend) + 1)) {
        printf ("\rdividend=%08x", float_as_uint32 (dividend));
        for (divisor = 1.0f; divisor <= 2.0f; divisor = uint32_as_float (float_as_uint32 (divisor) + 1)) {
            res = fp32_div (dividend, divisor);
            ref = dividend / divisor;
            if (float_as_uint32 (res) != float_as_uint32 (ref)) {
                printf ("error @ dividend=% 16.6a divisor=% 16.6a (% 15.8e, % 15.8e): res=% 16.6a ref=% 16.6a (% 15.8e, % 15.8e)\n", 
                        dividend, divisor,  dividend, divisor, res, ref, res, ref);
                return EXIT_FAILURE;
            }
        }
    }
#endif

    return EXIT_SUCCESS;
}

Соответствующий код CUDA (протестированный) для графического процессора NVIDIA выглядит следующим образом:

__noinline__ __device__ float fp32_div_slowpath (float dividend, float divisor) 
{ 
    return dividend / divisor; 
}

/* Subnormal arguments and results are treated as zero */
__forceinline__ __device__ float rcp_approx_gpu (float divisor)
{
    float r;
    asm ("rcp.approx.ftz.f32 %0,%1;\n\t" : "=f"(r) : "f"(divisor));
    return r;
}

__forceinline__ __device__ float fp32_div (float dividend, float divisor)
{
    const uint32_t FP32_MANT_MASK = 0x007fffffu;
    const uint32_t FP32_ONE = 0x3f800000u;
    const uint32_t FP32_SIGN_MASK = 0x80000000u;
    const uint32_t FP32_SIGN_EXPO_MASK = 0xff800000u;
    const uint32_t FP32_QUOTIENT_HI_LIMIT = 0x7f7fffffu; // 0x1.0p+128 - ulp
    const uint32_t FP32_QUOTIENT_LO_LIMIT = 0x00800001u; // 0x1.0p-126 + ulp

    // fast path
    float recip_approx = rcp_approx_gpu (divisor);
    float recip_err = fmaf (recip_approx, -divisor, 1.0f);
    float refined_recip = fmaf (recip_approx, recip_err, recip_approx);
    float dividend_mant = __int_as_float ((__float_as_int (dividend) & FP32_MANT_MASK) | FP32_ONE);
    float dividend_scale = __int_as_float (__float_as_int (dividend) & FP32_SIGN_EXPO_MASK);
    float refined_quotient_mant = refined_recip * dividend_mant;
    float refined_quotient_residual = fmaf (refined_quotient_mant, -divisor, dividend_mant);
    float final_quotient_mant = fmaf (refined_recip, refined_quotient_residual, refined_quotient_mant);
    float final_quotient = final_quotient_mant * dividend_scale;

    // check if we need to apply the slow path and invoke it if that is the case
    uint32_t iq = __float_as_int (final_quotient) & ~FP32_SIGN_MASK;
    if ((iq - FP32_QUOTIENT_LO_LIMIT) > (FP32_QUOTIENT_HI_LIMIT - FP32_QUOTIENT_LO_LIMIT)) {
        final_quotient = fp32_div_slowpath (dividend, divisor);
    }
    return final_quotient;
}
person njuffa    schedule 25.12.2020
comment
Хорошая работа. Незначительное: В count & 0xffffffULL LL не требуется в константе. OTOH, %llu", count может стать проблемой в будущем, если unsigned long long шире, чем uint64_t. - person chux - Reinstate Monica; 25.12.2020
comment
Спасибо за код и ссылки, это улучшило мое понимание. При поиске сопутствующих материалов я обнаружил Официальное подтверждение алгоритмов деления IA-64 слайды, в которых упоминается, как в контексте исходных алгоритмов Маркштейна можно сократить задержку в двух FMA, используя не уточненную обратную величину для первого частного приближения. Может быть применимо и здесь. - person amonakov; 25.12.2020
comment
@amonakov Джон Харрисон - эксперт в доказательстве правильности численных алгоритмов, а я всего лишь (на пенсии) инженер-программист со степенью CS. У меня нет математических знаний, чтобы оценить достоверность таких модификаций для обратной реализации конкретного графического процессора. Обратите внимание, что Маркштейн опубликовал несколько вариантов (в своей книге и некоторых [официальных] статьях) для различных ограничений по задержке и пропускной способности. Вообще говоря, графические процессоры - это процессоры, рассчитанные на высокую пропускную способность, и задержка обычно не входит в число важных вопросов. - person njuffa; 25.12.2020
comment
Понятно, просто подумал, что было бы полезно упомянуть ссылку для дальнейшего использования. Я очень признателен за ваш вклад здесь и на форуме Nvidia. - person amonakov; 25.12.2020
comment
@amonakov Спасибо за добрые слова. Да, ссылка полезна, поэтому я проголосовал за комментарий :-) - person njuffa; 25.12.2020