Подтвердить что ты не робот

Эффективное деление с плавающей запятой с постоянными целыми делителями

Недавний question, позволяют ли компиляторы заменить смену с плавающей запятой с умножением с плавающей запятой, вдохновили меня задать этот вопрос.

В соответствии с строгим требованием, чтобы результаты после преобразования кода были побито идентичны действительной операции деления, тривиально видеть, что для двоичной арифметики IEEE-754 это возможно для делителей, которые являются степенью двух. До тех пор, пока дивизора является представимым, умножая на обратный дивизор, доставляя результаты, идентичные делению. Например, умножение на 0.5 может заменить деление на 2.0.

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

Николас Брисебарре, Жан-Мишель Мюллер и Саурабх Кумар Райна. Ускорение правильно округленного деления с плавающей запятой, когда делитель известен заранее. IEEE Transactions on Computers, Vol. 53, № 8, август 2004 г., стр. 1069-1072.

Метод, предложенный авторами статьи, предкомпирует обратный дивизор y как нормализованную пару головы-хвоста z h: z l следующим образом: z h= 1/y, z l= fma (-y, z h, 1)/y. Позднее деление q = x/y затем вычисляется как q = fma (z h, x, z l * x). В документе приводятся различные условия, которые должен удовлетворять дивизор y для выполнения этого алгоритма. Как легко заметить, этот алгоритм имеет проблемы с бесконечностями и ноль, когда знаки головы и хвоста различаются. Что еще более важно, это не даст правильных результатов для очень малых по величине дивидендов x, потому что вычисление хвоста частного, z l * x, страдает от underflow.

В документе также делается ссылка на альтернативный алгоритм деления на основе FMA, впервые предложенный Питером Маркстайном, когда он был в IBM. Соответствующая ссылка:

Р. W. Markstein. Вычисление элементарных функций на процессоре IBM RISC System/6000. IBM Journal of Research and Development, Vol. 34, № 1, январь 1990 г., стр. 111-119

В алгоритме Маркштейна сначала вычисляется обратный rc, из которого формируется начальное частное q = x * rc. Затем остальная часть деления точно вычисляется с помощью FMA как r = fma (-y, q, x), а улучшенный, более точный коэффициент, наконец, вычисляется как q = fma (r, rc, q).

У этого алгоритма также есть проблемы для x, которые являются нулями или бесконечностями (легко обрабатываются с соответствующим условным исполнением), но исчерпывающее тестирование с использованием данных с одной точностью float IEEE-754 показывает, что оно обеспечивает правильное отношение по всем возможным дивидендам x для многих делителей y, среди этих многих маленьких целых чисел. Этот код C реализует его:

/* 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 компилятор nvcc CUDA 7.5 генерирует следующий машинный код для GPU класса Kepler:

    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, показанную ниже, которая проходит через целые делители в порядке возрастания и для каждого из них исчерпывающе проверяет приведенную выше кодовую последовательность на правильное деление. Он печатает список делителей, прошедших этот исчерпывающий тест. Частичный вывод выглядит следующим образом:

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,

Чтобы включить алгоритм замены в компилятор в качестве оптимизации, белый список делителей, к которым безопасно применяется приведенное выше преобразование кода, является непрактичным. Вывод программы до сих пор (со скоростью около одного результата в минуту) указывает на то, что быстрый код работает правильно во всех возможных кодировках x для тех делителей y, которые являются нечетными целыми числами или являются степенями двух. Анекдотические доказательства, а не доказательство, конечно.

Какой набор математических условий может определить a-priori, безопасно ли преобразование деления на указанную выше последовательность кода? Ответы могут предполагать, что все операции с плавающей запятой выполняются в режиме округления по умолчанию "от раунда до ближайшего или даже".

#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;
}
4b9b3361

Ответ 1

Позвольте мне перезапустить в третий раз. Мы пытаемся ускорить

    q = x / y

где y - целочисленная константа, а q, x и y - все IEEE 754-2008 binary32 значения с плавающей запятой. Ниже fmaf(a,b,c) указывает объединенное умножение-сложение a * b + c с использованием значений binary32.

Наивный алгоритм основан на предварительно рассчитанном обратном выражении,

    C = 1.0f / y

так, чтобы во время выполнения было достаточно (намного быстрее) умножения:

    q = x * C

Ускорение Брисбарре-Мюллера-Райна использует две предварительно вычисленные константы,

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

чтобы во время выполнения хватило одного умножения и одного сложного умножения-сложения:

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

Алгоритм Маркштейна объединяет наивный подход с двумя слитными множителями, которые дают правильный результат, если наивный подход дает результат в пределах 1 единицы в наименее значимом месте, путем предварительного вычисления

    C1 = 1.0f / y
    C2 = -y

так что деление может быть аппроксимировано с использованием

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

Наивный подход работает для всех степеней двух y, но в остальном он довольно плохой. Например, для делителей 7, 14, 15, 28 и 30 он дает неверный результат для более чем половины всех возможных x.

Подход Брисбарре-Мюллера-Райна аналогичным образом терпит неудачу для почти всех не степеней двух y, но гораздо меньше x дают неверный результат (менее половины процента всех возможных x, варьируется в зависимости от y).

В статье Брисбарре-Мюллера-Райна показано, что максимальная ошибка в наивном подходе составляет ± 1,5 ULP.

Подход Маркштейна дает правильные результаты для степеней двух y, а также для нечетного целого числа y. (Я не нашел неисправный нечетный целочисленный делитель для подхода Маркштейна.)


Для подхода Маркштейна я проанализировал делители 1 - 19700 (здесь необработанные данные).

Составляя график количества случаев отказов (делитель по горизонтальной оси, количество значений x, где подход Марккштейна не удается для указанного делителя), мы можем увидеть простую картину:

Markstein failure cases
(источник: nominal-animal.net)

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

Если мы изменим ось x на обратный бит (двоичные цифры в обратном порядке, то есть 0b11101101 → 0b10110111, данные) делителей, мы получим очень четкую схему: Markstein failure cases, bit reverse divisor
(источник: nominal-animal.net)

Если мы проведем прямую линию через центр наборов точек, мы получим кривую 4194304/x. (Помните, что график учитывает только половину возможных поплавков, поэтому при рассмотрении всех возможных поплавков удвойте его.) 8388608/x и 2097152/x полностью заполняют шаблон ошибок.

Таким образом, если мы используем rev(y) для вычисления обратного бита делителя y, то 8388608/rev(y) является хорошим приближением первого порядка числа случаев (из всех возможных значений с плавающей запятой), в которых подход Маркстейна дает неверный результат для четный делитель без степеней двух y. (Или 16777216/rev(x) для верхнего предела.)

Добавлено 2016-02-28: Я нашел аппроксимацию для числа случаев ошибок с использованием подхода Маркштейна, учитывая любой целочисленный (двоичный 32) делитель. Вот как псевдокод:

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

Это дает правильную оценку погрешности с точностью до ± 1 для случаев неудачи Маркштейна, которые я проверял (но я еще не проверил адекватно проверенные делители больше 8388608). Окончательное деление должно быть таким, чтобы в нем не было ложных нулей, но я не могу этого гарантировать (пока). При этом не учитываются очень большие делители (скажем, 0x1p100, или 1e + 30, и больше по величине), которые имеют проблемы с понижением - в любом случае, я бы определенно исключил такие делители из ускорения.

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


Схема неудач для подхода Маркштейна является регулярной и очень интересной. Подход работает для всех степеней двух делителей и всех нечетных целочисленных делителей.

Для делителей больше 16777216 я постоянно вижу те же ошибки, что и для делителя, который делится на наименьшую степень двух, чтобы получить значение меньше 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. (Внутри каждой пары мантисса одинакова, и изменяется только сила двух).

Предполагая, что мой тестовый стенд не является ошибочным, это означает, что подход Маркстейна также работает с делителями больше 16777216 по величине (но меньше, скажем, 1e + 30), если делитель таков, что при делении на наименьшую степень двух, что дает коэффициент менее 16777216 по величине, и коэффициент является нечетным.

Ответ 2

В этом вопросе задается способ определения значений константы Y, которые делают возможным преобразование x / Y в более дешевое вычисление с использованием FMA для всех возможных значений x. Другим подходом является использование статического анализа для определения чрезмерного приближения значений x, что может привести к обычному необоснованному преобразованию в знании того, что значения, для которых преобразованный код отличается от исходного деления, не происходят.

Используя представления наборов значений с плавающей запятой, которые хорошо адаптированы к задачам вычислений с плавающей запятой, даже анализ вперед, начиная с начала функции, может дать полезную информацию. Например:

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.
    • если succ (M) присутствует в U, то эта пара значений вносит succ (M) - M в положительные значения результата.
    • if -succ (M) присутствует в V, то эта пара значений вносит отрицательное значение M - succ (M) в отрицательные значения результата.
    • если pred (M) присутствует в U, то эта пара значений вносит отрицательное значение pred (M) - M в отрицательные значения результата.
    • если -pred (M) присутствует в V, то эта пара значений вносит значение M - pred (M) в положительные значения результата.
  • Выполняем ту же работу, если 0 может быть результатом добавления отрицательного значения U и положительного значения V.

Подтверждение: вышеупомянутое рассматривает идеи из "Улучшения сложения плавающей запятой и ограничений вычитания", Бруно Марре и Клода Мишеля


Пример: компиляция функции f ниже:

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 et al. в соответствии с их алгоритмом?
  • Есть ли у 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 может быть только одним из двух подписанных нулей

Ответ 3

Результат деления с плавающей запятой:

  • знаковый флаг
  • значимое
  • показатель степени
  • набор флагов (переполнение, нижний поток, неточный и т.д. - см. fenv())

Получение первых 3 штук правильно (но неправильный набор флагов). Без каких-либо дополнительных знаний (например, какие части из них действительно имеют значение, возможные значения дивиденда и т.д.), Я бы предположил, что замена деления на константу с умножением на константу (и/или запутанный беспорядок FMA) почти никогда не безопасно.

Кроме того; для современных процессоров я также не предполагал, что замена деления на 2 FMA всегда является улучшением. Например, если узким местом является выборка/декодирование команды, то эта "оптимизация" ухудшит производительность. В другом примере, если последующие инструкции не зависят от результата (процессор может выполнять множество других инструкций параллельно в ожидании результата), версия FMA может вводить несколько киосков с зависимостями и ухудшать производительность. Для третьего примера, если используются все регистры, версия FMA (требующая дополнительных "живых" переменных) может увеличить "пролитие" и ухудшить производительность.

Обратите внимание, что (во многих, но не во всех случаях) деление или умножение на константу, кратное 2, может быть выполнено только с добавлением (в частности, добавлением числа сдвига к экспоненте).

Ответ 4

Я люблю @Pascal, но в оптимизации часто лучше иметь простое и понятное подмножество преобразований, а не идеальное решение.

Все текущие и общие исторические форматы с плавающей запятой имели одну общую черту: двоичную мантиссу.

Следовательно, все дроби были рациональными числами вида:

x/2 n

Это контрастирует с константами в программе (и всеми возможными фракциями base-10), которые являются рациональными числами формы:

x/(2 n * 5 m)

Итак, одна оптимизация просто проверит входные и обратные для m == 0, так как эти числа представлены точно в формате FP, а операции с ними должны приводить числа, которые точны в формате.

Итак, например, в пределах (десятичный 2-значный) диапазон от .01 до 0.99 можно было бы оптимизировать разделение или умножение на следующие числа:

.25 .50 .75

И все остальное не будет. (Я думаю, сначала проверьте его, lol.)