В предыдущих сериях…
Прошлая статья рассказала о двух способах сложения двух двоичных чисел с плавающей запятой без потери точности. Чтобы добиться этого, мы представили сумму c=a+b в виде двух чисел (s,t)=a+b, причём таких, что s — наиболее близкое к a+b точно-представимое число, а t=(a+b)-s — это отсекаемая в результате округления часть, составляющая точную погрешность. У читателей был вопрос: а можно ли достаточно точно сложить массив чисел типа double? Оказывается, можно! Но только, вероятно, не всегда и не абсолютно… и не алгоритмом Кэхэна, который тогда вспоминали в комментариях. За подробностями прошу под кат, где мы и найдём приложение тому, о чём я рассказал в прошлый раз.

Вступление
Кому не терпится получить результат, листайте сразу вниз к разделу «Таблицы» — этот раздел можно понять без чтения теории и многословной методики тестирования. А здесь начинается описание проблемы.
Традиционно, полное содержание статьи также доступно в форме презентации с закадровым голосом:
Дело в том, что абсолютно точно сложить N чисел типа double (binary64 в IEEE-754) с сохранением суммы в типе double не получится в силу самой специфики формата с плавающей запятой. Если мы возьмём арифметику с плавающей запятой с бесконечной точностью (или даже воспользуемся рациональными числами), то при достаточном объёме памяти получим абсолютно точную сумму S массива чисел X[N]. Но эту абсолютно точную сумму нужно будет всё равно округлить к ближайшему числу типа double. Назовём эту округлённую сумму S’. Если вспомнить функцию RN(x) из предыдущей статьи, которая выполняет округление произвольного вещественного x «к ближайшему чётному», то можно сказать, что S’=RN(S), поэтому |S-S’| ≤ 0.5ulp (т. е. ошибка составляет не более половины значения младшего бита). Таким образом, об абсолютной точности мы говорить не можем, но мы будем называть число S’ — «наиболее точной суммой» (из возможных). Поэтому переформулируем наш вопрос иначе: можно ли сложить числа X[N] так, чтобы получить наиболее точную сумму S’, не пользуясь длинной арифметикой, а оставаясь лишь в рамках операций сложения с double?
Да! На случайном наборе чисел этого, вероятно, можно добиться. Я покажу один алгоритм, который мне не удалось «завалить», хотя в теории, наверное, можно придумать какие-то особенные тесты, чтобы он ошибся хотя бы на 1ulp и выдал бы неправильно хотя бы последний бит результата. Всего мы рассмотрим три алгоритма и сравним их между собой через абсолютно точную сумму S, округлённую до наиболее точной суммы S’.
Нам понадобятся два алгоритма из предыдущей статьи, и сейчас мы должны дать им названия. Я буду пользоваться синтаксисом C++, уверен, это не вызовет трудностей с переводом у любого читателя.
Первый алгоритм для двух чисел
Напомню, эти алгоритмы возвращают два числа: наиболее близкую к реальной сумму s=RN(a+b) и точную погрешность t=(a+b)-s. Данный алгоритм работает правильно только при условии |a|≥|b|, если сумма RN(a+b)≠∞.
s = RN (a+b);
z = RN (s-a);
t = RN (b-z);
return (s, t);
Я запрограммировал его на C++ вот так:
double __fastcall fast_two_sum (double &t, double a, double b) {
double s = a+b;
t = b-(s-a);
return s;
}Не забудьте, что при компилировании подобного кода нельзя применять флаги агрессивной оптимизации вычислений с плавающей запятой, потому что переставлять порядок вычислений здесь категорически нельзя. Также мне неизвестно, будут ли работать подобные конструкции на GPU, так к��к раньше (по крайней мере, на заре их применения) они могли обрабатывать числа с плавающей запятой даже близко не по Стандарту. О текущем состоянии дел мне неизвестно, прошу знающих читателей подсказать в комментариях.
Второй алгоритм для двух чисел
Этот алгоритм работает при любом соотношении между
s = RN (a+b);
b' = RN (s-a);
a' = RN (s-b');
d_b = RN (b-b');
d_a = RN (a-a');
t = RN (d_a+d_b);
return (s, t)
В моём исполнении на C++ выглядит так:
double __fastcall two_sum (double &t, double a, double b) {
double s = a+b;
double bs = s-a;
double as = s-bs;
t = (b-bs) + (a-as);
return s;
}
Теперь рассмотрим какие бывают варианты сложения нескольких чисел из массива. Сразу предупреждаю, что их очень много, но я взял два универсальных и наиболее интересных из книги [1], которые действительно хорошо себя показывают. На остальные алгоритмы, если вам интересно, авторы упомянутой книги дают ряд ссылок.
Обзор алгоритмов сложения
Задача. Задан массив X[N] типа double, требуется отыскать сумму всех чисел в этом массиве, сумма имеет тот же тип double.
Ниже по тексту будут представлены мои варианты реализации алгоритмов с описанием (где оно требуется). Все реализации будут опираться на псевдоним для массива чисел типа double:
using dvect_t = vector<double>;Алгоритм 0. Абсолютно точная и наиболее точная сумма
Этот алгоритм нужен только для проверки остальных, его практический смысл лично для меня почти полностью отсутствует. Чтобы посчитать сумму чисел точно, нужна арифметика с бесконечной точностью. Это может быть длинная арифметика с плавающей запятой, а может быть длинная арифметика рациональных чисел. Я выбрал последнюю. Взял библиотеку MPIR (можно было взять и GMP) и тип данных mpq_t. Написанный в скрытом тексте код выдаёт наиболее точную сумму S’, получаемую из абсолютно точной суммы S путём правильного округления. Пропустите скрытый текст, если вы не знакомы с внутренним устройством формата double, это не помешает вам понять остальной материал.
Код алгоритма 0 и пояснения
Если нет цели получить максимальную скорость вычислений, то код может быть написан очень прямолинейно, однако далее возникает проблема. Функция mpq_get_d из библиотеки MPIR округляет число вниз, просто отрезая «лишнее» (а другой функции округления там нет). Нам нужно округлить по правилу «к ближайшему чётному», а для этого приходится совершать «танцы с бубном» над битовым представлением чисел типа double. Поэтому в коде ниже есть UB, будьте внимательны.
using u64 = unsigned long long;
#define DAU(x) (*(u64*)(&x)) // Это UB, друзья! Перепишите как вам правильнее.
// Наиболее близкая сумма.
// Суммирование через рациональные дроби с бесконечной точностью.
// Внимание, здесь танцы с UB! Проверьте, прежде чем запускать.
double __fastcall sum_exact (const dvect_t &X) {
mpq_t s, a, b;
mpq_inits (s, a, b, NULL);
// Сначала нужно всё сложить
mpq_set_d (s, 0.0); // Общая сумма дробей s = 0
for (auto x: X) {
mpq_set_d (a, x); // Переводим 'x' к дроби 'a'
mpq_add (s, s, a); // Складываем s += a.
}
// Теперь получаем точно-округлённый результат.
double res = mpq_get_d (s); // Здесь округление к нулю! Это ещё не точный результат!
// Достаём "бубен"...
// Нужно на время убрать знак "минус"
bool is_negative = res < 0.0;
res = abs(res);
mpq_abs (s, s);
u64 res64 = DAU(res); // Получить битовое представление числа типа double.
u64 res64_next = res64+1; // Получить следующее за 'res' число в битовой форме
double res_next;
DAU(res_next) = res64_next; // 'res_next' - это число, следующее за 'res'
// Начинаем "танец"...
mpq_set_d (a, res); // a - точное дробное представление res
mpq_set_d (b, res_next); // b - точное дробное представление res_next
// В этот момент наша сумма s где-то внутри отрезка [a, b]=[res, res_next].
mpq_sub (a, s, a); // a - расстояние от res до s
mpq_sub (b, b, s); // b - расстояние от s до res_next
// Что больше: расстояние от res до s или от s до res_next?
if (mpq_cmp (a, b) > 0)
res = res_next; // Если наша s ближе к правой границе отрезка.
if ((res64&1) && mpq_cmp (a, b) == 0)
res = res_next; // Если s по центру, но младший бит левой границы нечётный.
mpq_clears (s, a, b, NULL);
return is_negative ? -res : res;
}
Алгоритм 1. Наивное суммирование
Очевидный алгоритм и, пожалуй, наиболее быстрый и просторный для оптимизаций компилятора выглядит вот так:
double __fastcall sum_naive (const dvect_t &X) {
double s = 0.0;
for (auto x: X) s += x;
return s;
}
Здесь нечего пояснять.
Алгоритм 2. Kahan
double __fastcall sum_kahan (const dvect_t &X) {
double s=0.0, c=0.0;
for (auto x: X) {
double y = x + c;
s = fast_two_sum (c, s, y);
}
return s;
}
Схема работы алгоритма основана на первом методе сложения двух чисел без потери точности из предыдущей статьи. «Хвостик»
Внимательный читатель вспомнит, что алгоритм s=fast_two_sum(c, s, y) работает корректно только когда |s|≥|y|, что не обязательно будет именно так для беспорядочной последовательности чисел из исходного массива X. Тем не менее, компенсационные способности данного алгоритма перекрывают ошибочное срабатывание для случаев |s|<|y|, по какой причине на практике этот алгоритм чаще всего работает лучше наивного суммирования. «Завалить» его именно на случайных тестах мне не удалось, хотя подобрать специальную последовательность вполне можно (см. последнюю таблицу в статье). Также в единственной книге, на которую я ссылаюсь, приводится пример:
X[0] = 18014398509481984.0; // 2**54
X[1] = 18014398509481982.0; // 2**54-2
X[2] = -9007199254740991.0; // -(2**53-1)
X[3] = -9007199254740991.0;
X[4] = -9007199254740991.0;
X[5] = -9007199254740991.0;
Правильный ответ 2. Алгоритм Кэхэна вернёт 3. Наивное суммирование вернёт 1. Согласитесь, это чудовищная ошибка в 50% (или 251ulp). А вот следующий алгоритм выдаст в этом случае правильный ответ.
Алгоритм 3. Rump–Ogita–Oishi
Разумный вопрос: а что если вместо первого алгоритма сложения двух чисел из предыдущей статьи взя��ь второй, который безразличен к порядку этих чисел? Этот подход применяется в алгоритме Rump–Ogita–Oishi и даёт такой код:
double __fastcall sum_rump (const dvect_t &X) {
double s=0.0, c=0.0, e;
for (double x: X) {
s = two_sum (e, s, x);
c += e;
}
return s+c;
}
Однако здесь авторы алгоритма решили складывать «хвостики»
Тестирование
Замечание о методике и способе вывода погрешности
Я прошу прощения за многословное описание методики тестирования и преподнесения результата, но если его не сделать, то найдётся очень много скептиков, которые скажут, что я что-то не то протестировал и получил не те данные, какие они получили у себя. А этим объяснением я снимаю с себя всякую ответственность за несовпадения, которые обязательно будут.
Читатель понимает, что величина N может быть произвольной, так же как произвольными могут быть числа в массиве. Поэтому совершенно ясно, что любой обзор работы алгоритмов на практике будет субъективным и неполным. Какое распределение чисел X[i] выбрать? Какими выбрать верхнюю и нижнюю границы суммируемых чисел? Сколько чисел взять для получения репрезентативного результата? Сколько случайных тестов нужно для вычисления адекватной средней погрешности?
Видите в какой трудной ситуации находится обзорщик? Стоит что-нибудь сделать неправильно, читатель найдёт к чему придраться «со своей колокольни». Поэтому я заранее предупреждаю, что мой обзор будет обусловлен исключительно моими предпочтениями выбора тестов. Они описаны рядом с таблицами ниже. Если вам нужны другие результаты (а результаты можно получить любые, всё зависит от степени предвзятости экспериментатора), вы можете создать другие тесты :)
Второе предупреждение: как подавать результат? В каких единицах его будет удобнее всего принять читателям, не нуждающимся в детальном научном исследовании? Ну, допустим, я дам вам абсолютную погрешность, которая вычисляется по формуле
где
Однако эта разность точного и приближённого ответа не будет информативной, потому что сильно зависит от порядка результата (порядок этот, как вы помните, может быть от -324 до +308), вам говорит о чём-нибудь число 1.23456e+224 в качестве погрешности? Вряд ли. Гораздо более информативной будет относительная погрешность:
Но и она будет представлять из себя числа, порядки которых будут очень маленькими и трудными для восприятия через ощущения человека. Смотрите сами: относительная погрешность 1.23456e-10%. Удобно?
Тогда принимают решение показывать погрешность через ulp. Напомню, что ulp(x) — это, грубо говоря, ценность последнего бита числа x. Например, для чисел x типа double на интервале [1,2) величина ulp(x)=2-52. На интервале [2,4) ulp(x)=2-51 и так далее, вырастает вдвое при каждом увеличении экспоненты числа. Такой способ описания погрешности удобен тем, что показывает относительную погрешность в единицах измерения, равных ценности одного последнего бита результата. То есть зная эту погрешность, вы можете быстро понять, условно выражаясь, сколько битов результат вы «запороли» в ходе расчётов. 1ulp — потеряли 1 бит, 2-3ulp — 2 бита, 4-7ulp — 3 бита и т. д. Здесь логарифмическая зависимость.
Чтобы получить погрешность в ulp, нужно посчитать выражение
На моём любимом языке UB++ функция ulp(x) для double вычисляется вот так:
// Получить значение смещённой экспоненты для 'x' типа double
// Внимание, здесь UB! Перепишите код, если он вам не подходит
#define GET_EXP(x) (((*(unsigned long long*)(&x))&0x7FFFFFFFFFFFFFFFULL)>>52)
// Получить значение ULP для числа x.
double get_ulp (double x) { return ldexp (1.0, GET_EXP(x)-1075); }
Таблицы
Каждый алгоритм прогонялся на 100 тестах. Один тест — это массив X[N] из чисел типа double. При этом получалось два вида погрешностей: средняя по всем 100 тестам и максимальная по ним же. Оба эти числа указаны в каждой ячейке таблицы: сверху — средняя, снизу — максимальная погрешность. Измерение погрешности ведётся в ulp относительно наиболее точной суммы, полученной абсолютно точным алгоритмом.
Помимо массива X[N], через алгоритм также одновременно прогонялись два других массива, состоящие из этих же чисел. Один из них упорядочен по возрастанию абсолютного значения, а второй — по убыванию.
Первый набор тестов: равномерно распределённые случайные числа на интервале [1,2) в количестве N=1000. Пояснения к символам «~» «↑» и «↓» даётся ниже.
| Порядок | Naive | Kahan | Rump–Ogita–Oishi |
|---|---|---|---|
| ~ | 4.86 21 |
0 0 |
0 0 |
| ↑ | 4.97 14 |
0 0 |
0 0 |
| ↓ | 4.50 19 |
0 0 |
0 0 |
Сейчас я поясню как читать такую таблицу. Символ «~» говорит, что числа в массиве расположены хаотично. Символ «↑» — упорядочены по возрастанию. Символ «↓» — по убыванию. Верхнее число в ячейке — средняя погрешность на 100 тестах, нижнее — максимальная на них же. Как понять, например, число 19 в таблице? Оно значит, что если упорядочить 100 разных массивов по убыванию и на каждом запустить алгоритм, то максимальная погрешность на этих тестах составит 19ulp, то есть, грубо говоря, 4-5 битов «потеряли». В десятичных цифрах это будет 1-2 цифры. Если учесть, что число типа double держит почти 16 десятичных цифр, то потерю 2-х цифр в бытовых задачах можно считать несущественной. При этом в среднем на этих 100 тестах погрешность составила 4,5ulp, то есть почти одну десятичную цифру.
Теперь случайным образом назначим знак «минус» всем нашим случайным числам из интервала [1,2), чтобы положительных чисел и отрицательных стало поровну (в вероятностном смысле). Ещё к нашим символам в первом столбце таблицы мы добавляем «±» с очевидным смыслом.
| Порядок | Naive | Kahan | Rump–Ogita–Oishi |
|---|---|---|---|
| ±~ | 158.96 7936 |
0 0 |
0 0 |
| ±↑ | 86.35 2560 |
0 0 |
0 0 |
| ±↓ | 175.70 11776 |
0 0 |
0 0 |
Вот это да! Разрешили числам быть отрицательными и тут же у наивного алгоритма, что называется, почва ушла из под ног. Погрешность в 11776 ulp эквивалентна потере 4-5 десятичных цифр (13-14 битов из 52 битов дробной части мантиссы). Остальные алгоритмы по-прежнему выдают наиболее точную сумму.
Теперь увеличим объём чисел, установим N=106. Мы ожидаем рост погрешности. Ожидания, в целом, оправдываются.
| Порядок | Naive | Kahan | Rump–Ogita–Oishi |
|---|---|---|---|
| ~ | 143.00 562 |
0 0 |
0 0 |
| ↑ | 126.60 473 |
0 0 |
0 0 |
| ↓ | 161.91 482 |
0 0 |
0 0 |
| ±~ | 2015.41 38889 |
0 0 |
0 0 |
| ±↑ | 1520.84 33965 |
0 0 |
0 0 |
| ±↓ | 1672.76 36513 |
0 0 |
0 0 |
Перейдём к более показательному интервалу [1e-10, 1e+10) и возьмём сразу N=106 чисел, только генерация чисел выполняется не равномерно по интервалу, а равномерно по множеству всех возможных чисел типа double на этом интервале. На целочисленном интервале [0x3ddb7cdfd9d7bdbb, 0x4202a05f20000000) (это битовая запись чисел 1e-10 и 1e+10 в формате double) выбирается целое число
| Порядок | Naive | Kahan | Rump–Ogita–Oishi |
|---|---|---|---|
| ~ | 4277.17 4662 |
0 0 |
0 0 |
| ↑ | 29.17 111 |
0 0 |
0 0 |
| ↓ | 7508.68 7915 |
0 0 |
0 0 |
| ±~ | 475.68 8221 |
1.09 21 |
0 0 |
| ±↑ | 65.39 861 |
0.01 1 |
0 0 |
| ±↓ | 270.34 1736 |
0 0 |
0 0 |
Как видим, ситуация стала хуже, потому что числа очень разные и ясно, что их сумма будет давать гораздо большую погрешность. Здесь даже алгоритм Кэхэна начал ошибаться. А последний алгоритм всё ещё держится.
Далее у нас экспоненциальное распределение с параметром
| Порядок | Naive | Kahan | Rump–Ogita–Oishi |
|---|---|---|---|
| ~ | 4.84 19 |
0.01 1 |
0 0 |
| ↑ | 2.81 11 |
0.01 1 |
0 0 |
| ↓ | 6.53 26 |
0 0 |
0 0 |
| ±~ | 33.83 1650 |
1.26 23 |
0 0 |
| ±↑ | 12.76 422 |
0.31 6 |
0 0 |
| ±↓ | 18.54 548 |
0 0 |
0 0 |
Напоследок посмотрим на нормальное распределение
| Порядок | Naive | Kahan | Rump–Ogita–Oishi |
|---|---|---|---|
| ~ | 17.77 483 |
1.46 61 |
0 0 |
| ↑ | 10.92 243 |
0.34 19 |
0 0 |
| ↓ | 19.71 734 |
0 0 |
0 0 |
Мы видим, что если есть отрицательные числа, то наивный алгоритм начинает складывать совсем плохо. Возникает вопрос: что если складывать отдельно отрицательные числа, отдельно положительные, а затем сложить обе суммы? Ничего не будет кроме того, что станет хуже:
| Порядок | Всё подряд | Отдельно + и – |
|---|---|---|
| ±~ | 78.65 2336 |
2121.61 50048 |
| ±↑ | 76.27 2496 |
2465.55 66432 |
| ±↓ | 52.74 480 |
2863.61 99200 |
Напоследок я обещал показать в каком случае алгоритм Кэхэна может выдать ответ хуже, чем прямолинейное сложение. Возьмём тест, в котором X[i]=(double)cos(i), i=0..106-1. Убедитесь сами (здесь лишь один тест, поэтому не указана средняя погрешность, только максимальная):
| Порядок | Naive | Kahan | Rump–Ogita–Oishi |
|---|---|---|---|
| ~ | 210 | 522 | 0 |
| ↑ | 410 | 0 | 0 |
| ↓ | 42 | 0 | 0 |
Такая вот картина, друзья. Я провёл намного больше тестов, пока работал над статьёй, и сделал для себя следующие выводы.
- Если нужно испортить сумму, получаемую наивным алгоритмом, то можно складывать отдельно положительные и отдельно отрицательные числа. Иногда, но не всегда, сортировка чисел по убыванию модуля также «ломает» наивный алгоритм, лучше сортировать по возрастанию, но и это в ряде случаев даёт более плохой результат, чем в случае хаотичного порядка.
- Если нужно получить ожидаемо хороший результат, следует взять алгоритм Rump–Ogita–Oishi, я не нашёл случайных тестов, при которых он выдавал бы не наиболее точную сумму. Недостаток алгоритма в том, что работать он будет медленнее наивного сложения. Ускорить такой алгоритм, вероятно, можно, пользуясь битовым представлением числа с плавающей запятой и кое-какими трюками, но у нас такой задачи на этот обзор не было.
- Алгоритм Кэхэна значительно лучше наивного суммирования и гораздо проще алгоритма Rump–Ogita–Oishi, поэтому наиболее целесообразен на практике, где точность наивного метода вам не подходит. Просто убедитесь заранее, что ваши числа не обладают какими-то экстраординарными свойствами, которые «завалят» данный алгоритм.
- Наивное суммирование в общем-то не такое страшное, как могло показаться. Ну, подумаешь, потеряли мы 5 десятичных цифр (если N не более миллиона). Много? Если их у вас 16, то вроде бы не страшно, а если 7 (для float), то вроде бы… хотя, друзья, неужели кто-то будет складывать миллион чисел в типе float? Вы серьёзно? Это допустимо только если вам нужна одна правильная цифра, либо если массив обладает какой-то особой спецификой, и то я бы не рискнул.
Исходный код программы тестирования я не даю по следующим причинам.
- Это творческий процесс, я создал свою субъективную схему тестирования и не считаю это научно-достоверным знанием, а потому не выношу на суд общественности. Коды алгоритмов я дал выше, если вам очень нужно, то не составит труда написать своё сравнение алгоритмов и получить свои данные.
- Мой код не автоматизирован, чтобы его запускать для получения разных тестов, нужно ковыряться в программе и менять какие-то параметры, это просто неприлично — давать такой код. А написание полноценной системы тестирования в задачи публикации не входило.
Прошу отнестись с пониманием.
Другие приложения
У алгоритмов точного сложения двух чисел из предыдущей статьи есть и другие приложения, которые я надеюсь рассмотреть в последующих обзорах. Это вычисление скалярного произведения и значения полинома в точке. Хотя, возможно, читатель уже сам видит, как применить туда эти алгоритмы. Проблема возникает только с корректным промежуточным умножением двух чисел. И я бы не сказал, что это прямо-таки простая тема. Моя мотивация писать продолжение зависит от вашего интереса.
Благодарю за внимание и творческих вам успехов!
Продолжение: Статья о скалярном произведении.
Список источников
[1] Раздел 5.3 книги Jean-Michel Muller, “Handbook of floating-point arithmetic”, 2018.
