image

Недавно я вернулся к анализу погрешностей чисел с плавающей запятой, чтобы усовершенствовать некоторые детали в следующей редакции книги Physically Based Rendering. Числа с плавающей запятой — интересная область вычислений, полная сюрпризов (хороших и плохих), а также хитрых трюков, позволяющих избавиться от неприятных неожиданностей.

В процессе работы я наткнулся на этот пост на StackOverflow, из которого узнал об изящном алгоритме точного вычисления $a \times b-c \times d$.

Но прежде чем приступать к алгоритму, нужно понять, что же такого хитрого в выражении $a \times b-c \times d$? Возьмём $a=33962.035$, $b=-30438.8$, $c=41563.4$ и $d=-24871.969$. (Это реальные значения, которые получились у меня во время запуска pbrt.) При 32-битных значениях float получаем: $a \times b=-1.03376365 \times 10^9$ и $c \times d=-1.03376352 \times 10^9$. Выполняем вычитание, и получаем $-128$. Но если выполнить вычисления с двойной точностью, а в конце преобразовать их во float, то получится $-75.1656$. Что произошло?

Проблема в том, что значение каждого произведения может сильно выйти за нижнюю границу $-1 \times 10^9$, где расстояние между представимыми значениями с плавающей запятой очень велико — 64. То есть при округлении $a \times b$ и $c \times d$ по отдельности до ближайшего представимого float, они превращаются в числа, кратные 64. В свою очередь, их разность будет кратной 64, и не останется никакой надежды, что она станет к $-75.1656$ ближе, чем $-64$. В нашем случае результат оказался ещё дальше из-за того, как два произведения были округлены в $-1 \times 10^9$. Мы напрямую столкнёмся со старым добрым катастрофическим сокращением1.

Вот решение получше2:

inline float DifferenceOfProducts(float a, float b, float c, float d) {
    float cd = c * d;
    float err = std::fma(-c, d, cd);
    float dop = std::fma(a, b, -cd);
    return dop + err;
}

DifferenceOfProducts() вычисляет $a \times b-c \times d$ таким образом, что избегает катастрофического сокращения. Впервые эта методика была описана легендарным Уильямом Кэхэном в статье On the Cost of Floating-PointComputation Without Extra-Precise Arithmetic. Стоит заметить, что работы Кэхэна интересно читать в целом, в них есть множество комментариев о текущем состоянии мира плавающих запятых, а также математических и технических рассуждений. Вот один из его выводов:

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

Отдав должное его остроумию, вернёмся к DifferenceOfProducts(): в основе искусности этой функции лежит использование инструкций совмещённого умножения-сложения (fused multiply-add, FMA)3. С математической точки зрения FMA(a,b,c) является $a \times b+c$, поэтому поначалу кажется, что эта операция полезна только в качестве микрооптимизаци: одна инструкция вместо двух. Однако FMA
обладает особым свойством — она округляет только один раз.

В обычном $a \times b+c$ сначала вычисляется $a \times b$, а потом это значение, которое в общем случае нельзя представить в формате с плавающей запятой, округляется до ближайшего float. Затем к этому округлённому значению прибавляется $c$, и этот результат снова округляется до ближайшего float. FMA реализована таким образом, что округление выполняется только в конце — промежуточное значение $a \times b$ сохраняет достаточную точность, поэтому после прибавления к нему $c$ готовый результат будет являться ближайшей к истинному значению $a \times b+c$ величиной float.

Разобравшись с FMA, вернёмся к DifferenceOfProducts(). Снова покажу первые две её строки:

    float cd = c * d;
    float err = std::fma(-c, d, cd);

Первая вычисляет округлённое значение $c \times d$, а вторая… вычитает $c \times d$ из их произведения? Если не знать, как работают FMA, то можно подумать, что err всегда будет равна нулю. Но при работе с FMA вторая строка на самом деле извлекает величину погрешности округления в вычисленном значении $c \times d$ и сохраняет её в err. После этого на выходе всё получается очень просто:

    float dop = std::fma(a, b, -cd);
    return dop + err;

Вторая FMA вычисляет разность произведений при помощи FMA, выполняя округление только в самом конце. Следовательно, она устойчива к катастрофическому сокращению, но ей нужно работать с округлённым значением $c \times d$. Оператор return «патчит» эту проблему, прибавляя погрешность, выделенную во второй строке. В статье Jeannenrod et al. показано, что результат верен до 1,5 ulps (мер единичной точности), что великолепно: FMA и простые операции с плавающей запятой точны до 0,5 ulps, поэтому алгоритм практически идеален.

Используем новый молоток


Когда начинаешь искать способы применения DifferenceOfProducts(), она оказывается на удивление полезной. Вычисляете дискриминанта квадратного уравнения? Вызывайте DifferenceOfProducts(b, b, 4 * a, c)4. Вычисляете определитель матрицы 2x2? Алгоритм решит эту задачу. В реализации следующей версии pbrt я нашёл ему около 80 применений. Из всех них самой любимой является функция векторного произведения. Она всегда была источником проблем, из-за которого приходилось поднимать руки вверх и использовать в реализации числа double, чтобы избежать катастрофического сокращения:

inline Vector3f Cross(const Vector3f &v1, const Vector3f &v2) {
    double v1x = v1.x, v1y = v1.y, v1z = v1.z;
    double v2x = v2.x, v2y = v2.y, v2z = v2.z;
    return Vector3f(v1y * v2z - v1z * v2y,
                    v1z * v2x - v1x * v2z,
                    v1x * v2y - v1y * v2x);
}

А теперь мы можем продолжать работать с float и использовать DifferenceOfProducts().

inline Vector3f Cross(const Vector3f &v1, const Vector3f &v2) {
    return Vector3f(DifferenceOfProducts(v1.y, v2.z, v1.z, v2.y),
                    DifferenceOfProducts(v1.z, v2.x, v1.x, v2.z),
                    DifferenceOfProducts(v1.x, v2.y, v1.y, v2.x));
}

Тот хитрый пример из начала поста на самом деле является частью векторного произведения. На определённом этапе коду pbrt требуется вычислить векторное произведение векторов $(33962.035, 41563.4, 7706.415)$ и $(-24871.969, -30438.8, -5643.727)$. При вычислении с помощью float получался бы вектор $(1552, -1248, -128)$. (Общее правило: если при вычислениях с плавающей запятой, где задействованы большие числа, вы получаете не такие большие целочисленные значения, то это почти верный признак того, что произошло катастрофическое сокращение.)

При двойной точности векторное произведение равно $(1556.0276, -1257.5151, -75.1656)$. Мы видим, что при float $x$ выглядит нормально, $y$ уже довольно плох, а $z$ становится той катастрофой, которая стала мотивацией к поиску решения. А какие результаты мы получим с DifferenceOfProducts() и значениями float? $(1556.0276, -1257.5153, -75.1656)$. Значения $x$ и $z$ соответствуют двойной точности, а $y$ слегка смещён — отсюда и взялась лишняя ulp.

А что со скоростью? DifferenceOfProducts() выполняет две FMA, а также умножение и сложение. Наивный алгоритм можно реализовать с одной FMA и одним умножением, что, казалось бы, должно занять в два раза меньше времени. Но на практике оказывается, что после получения значений из регистров это неважно: в синтетическом бенчмарке, проведённом на моём ноутбуке, DifferenceOfProducts() всего в 1,09 раза затратнее наивного алгоритма. Работа с двойной точностью была медленнее в 2,98 раза.

Как только вы узнаёте о возможности катастрофического сокращения, всевозможные невинно выглядящие выражения в коде начинаю казаться подозрительными. Похоже, что DifferenceOfProducts() является хорошим лекарством против большей их части. Она проста в использовании, и у нас нет особых причин не применять её.

Примечания


  1. Катастрофическое сокращение не представляет проблемы при вычитании величин с разными знаками или при сложении величин с одинаковым знаком. Однако оно может стать проблемой при сложении значений с разными знаками. То есть суммы следует рассматривать с тем же подозрением, что и разности.
  2. В качестве упражнения для читателя я советую написать функцию SumOfProducts(), обеспечивающую защиту от катастрофического сокращения. Если вы хотите усложнить задачу, то объясните, почему в DifferenceOfProducts(), dop + err == dop, ведь знаки a*b и c*d различаются.
  3. Инструкция FMA доступна в GPU уже больше десятка лет, а в большинстве ЦП — не менее пяти лет. На ЦП может потребоваться добавить флаги компилятора для их непосредственной генерации при использовании std::fma(); в gcc и clang работает -march=native.
  4. В формате чисел с плавающей запятой IEEE умножение на степени двойки выполняется точно, поэтому 4 * a не вызывает никакой ошибки округления, если только не происходит переполнения.

Комментарии (7)


  1. mmMike
    14.11.2019 13:03
    +1

    В процессе работы я наткнулся на этот пост на StackOverflow, из которого узнал об изящном алгоритме точного вычисления .

    Читаю статью и вспоминаю курс прикладной математики и вычислительных методов (что то как то так называлось) на котором все это давали в институте. И лабы на которых эти методы проверялись. Давно… очень давно (С++ еще не было).


    Однако базовые знания. Почему они становятся "я узнал об изящном алгоритме точного вычисления".


    Насколько актуально использование float в вычисления (не хранении) в эпоху 64 бит и широких шин к памяти — отдельный вопрос.


    1. greg123
      14.11.2019 21:29
      +2

      «Насколько актуально использование float в вычисления (не хранении) в эпоху 64 бит и широких шин к памяти — отдельный вопрос.»
      Если говорить о GPU, то в геймерских картах производительность операций над числами с плавающей запятой одинарной точности намного выше производительности с числами двойной точности.
      В статье же речь идет о движке для рендеринга, где производительность явно не на последнем месте.


      1. mmMike
        15.11.2019 05:10

        Точно. Как то упустил этот момент.
        Спасибо что обратили внимание (под GPU не приходилось писать)


      1. paluke
        15.11.2019 06:58

        А на gpu есть fma?


        1. FragCounter
          15.11.2019 09:42

          В статье же написано, что уже давно как есть.

          Инструкция FMA доступна в GPU уже больше десятка лет, а в большинстве ЦП — не менее пяти лет. На ЦП может потребоваться добавить флаги компилятора для их непосредственной генерации при использовании std::fma(); в gcc и clang работает -march=native.


        1. Cerberuser
          15.11.2019 09:46

          Судя по сказанному в статье, в первую очередь именно там и есть:


          Инструкция FMA доступна в GPU уже больше десятка лет, а в большинстве ЦП — не менее пяти лет.

          upd: опоздал :)


  1. maxzhurkin
    16.11.2019 16:15

    TLDR: никак