В прошлой статье речь шла о том, как можно сложить массив из чисел типа double наиболее точно, то есть получить такую сумму, как если бы мы выполняли вычисления в рамках арифметики с бесконечной точностью, а затем один раз округлили бы результат. Был показан алгоритм, который эквивалентен применению типа данных double-double, в котором сложение происходит сразу в двух переменных: основная сумма и хвостик-погрешность. Опытные читатели сразу догадались, что сложение хвостиков-погрешностей также допускает по отношению к себе рекурсивное применение того же алгоритма, что приводит не к удвоенной, а к утроенной точности, и вообще, можно организовать каскад сложений произвольного размера, получая любую наперёд заданную точность расчётов, поэтому фактически в прошлой статье была показана предпосылка к так называемой «дробной длинной арифметике». Опытный программист без труда разберётся как её реализовать, ну а я обещал дать аналогичные фундаментальные основы для скалярного произведения и вычисления полинома в точке. Поскольку все базовые вводные слова уже были сказаны в двух предшествующих статьях, в этой будет меньше «воды» и «лишних», по мнению опытных математиков, сведений. Прошу под кат.
Как обычно, видео-презентация полностью дублирует содержание статьи, и создана для удобства тех посетителей Хабра, кому голос с иллюстрациями воспринять проще чем текст без таковых.
О сложении мы знаем уже достаточно: с помощью функции two_sum можно сложить N чисел наиболее точно, дело осталось за малым: научиться перемножать два числа типа double так, чтобы не терять отбрасываемый при округлении «хвостик», сохраняя его в отдельной переменной. Если в случае сложения сделать это можно очень просто при любом раскладе, лишь бы промежуточная сумма не переполнялась (кто не читал: «Сложение двух чисел с плавающей запятой без потери точности»), то в случае умножения есть дополнительное ограничение.
Обозначим через минимальную экспоненту вашей системы чисел с плавающей запятой, в рамках которой выполняется умножение (для float: ?126, для double: ?1022), буквой обозначим количество битов мантиссы с учётом скрытой единицы (24 для float и 53 для double). Пусть и — экспоненты наших исходных чисел и , тогда требуется, чтобы было выполнено условие:
Если данное условие не выполняется, то могут возникнуть случаи, когда нельзя представить в виде суммы , где и . Указанное неравенство является сложным способом сказать простую фразу: «хвостик» не должен быть денормализованным. И это вполне понятно: когда мы перемножаем два числа, получаем удвоенное количество битов, которые гарантированно влезают в две переменные того же типа данных. Если же одна из этих переменных попадает в денормализованный диапазон, происходит битовый сдвиг мантиссы с потерей младших битов. И кроме переполнения это единственная ситуация, при которой произведение двух чисел нельзя точно представить в форме суммы двух чисел того же типа.
Так ли существенно это ограничение? Для float нужно чтобы сумма экспонент оказалась не меньше ?126+24?1=?103, то есть, грубо говоря, если каждый из множителей больше 2?51, то всё будет хорошо. Спросите себя: часто ли вы применяете числа такого порядка? Если да, то можете выполнить масштабирование и перейти к числам побольше. Можете применить double, и работать с числами больше 2?485. Но даже если плюнуть на всё и применить алгоритм в неподходящих условиях, в простых прикладных задачах он всё равно отработает правильно чуть чаще чем всегда.
Если указанное условие выполнено, то с помощью инструкции FMA (Fused Multiply-Add) точное умножение двух чисел выполняется следующим алгоритмом:
Результатом работы функции будет пара чисел , такая, что является ближайшим к числом, а — хвостиком-погрешностью, причём в точности. Разумеется, по ходу расчётов не должно произойти переполнения, за этим придётся следить отдельно: нужно оставаться в рамках условия .
Если операция FMA для вас недоступна, то ситуация сильно усложняется. Желаемое произведение можно получить путём разбиения исходных переменных и на две части примерно посередине так, чтобы старшая часть операндов (ah и bh) содержала половину старших битов, а младшая (al и bl) — половину младших. Далее нужные операции выполняются именно с этими половинками, в общей сложности алгоритм потребует 17 операций сложения и умножения. Данный трюк в чём-то аналогичен известному алгоритмическому трюку для вычисления произведения двух целых чисел с сохранением результата в удвоенном типе данных (кто не знал, читайте Г. Уоррен «Алгоритмические трюки для программистов», в издании 2014 года это раздел 8.2). Нижеследующий алгоритм написан мною по мотивам книги Jean-Michel Muller, “Handbook of floating-point arithmetic”, также как и остальная часть этой статьи.
Данный алгоритм для двоичных типов данных из Стандарта IEEE-754 работает всегда, для десятичных типов он будет работать только если количество цифр мантиссы чётно, то для есть для decimal32 он может давать ошибочный результат. Если же вы изобретаете свой тип данных с плавающей запятой, читайте полное условие теоремы 4.8 в упомянутой выше книге в разделе 4.4.2.2 “Dekker’s multiplication algorithm”. Там же, но разделом ранее разъясняется как подбирать магическую константу для правильного разбиения в функции split. В двоичной системе она подбирается равной 2s+1, при этом младшая часть lo будет содержать s битов, а старшая — все остальные.
Итак, теперь мы умеем умножать два числа точно. Стряпаем сумму произведений по образу и подобию предыдущей статьи. Данный алгоритм назван именами трёх авторов Ogita—Rump—Oishi:
Понятно, что two_prod нужно заменить на two_prod_s, если вам недоступна FMA.
Опытный программист видит здесь в строке с комментарием «собираем хвостики» возможность каскадного применения функции two_sum, что даёт увеличение точности, но я оставлю это опытному программисту на его усмотрение.
Помимо этой версии скалярного произведения есть ещё две наивные функции, разъяснение которых в тексте статьи будет моветоном.
Тестирование выполнялось точно также как в статье про сумму N чисел. Напомню только, что верхнее число в ячейке — средняя погрешность в ULP на 100 тестах, а нижнее — максимальная погрешность на них же. Размер каждого теста N=106. В первом столбце указано выбранное распределение случайных чисел.
Ожидаемо, что алгоритм с применением double-double сработал безошибочно, и так будет происходить на всех типичных бытовых задачах. Таблица не требует особых комментариев кроме одного: наивный алгоритм с FMA может давать менее точный результат, чем ещё более наивный алгоритм с двумя округлениями на каждом шаге. При этом ДА, я выполнил дизассемблирование и убедился, что в этом наивном методе компилятор не всунул FMA в качестве оптимизации. Вообще говоря, он не имеет на это права без моего разрешения, но всё же осторожность не помешает.
Мы умеем точно умножать два числа и складывать целые массивы, а значит нам под силу применить это знание для любых алгоритмов, которые основаны на этих двух операциях. Например, в качестве бонуса рассмотрим вычисление значения полинома в заданной точке.
Когда мы применяем схему Горнера, то по сути имеем похожий по структуре цикл, в теле которого также одно умножение и сложение, поэтому никаких дополнительных пояснений к построению алгоритма не требуется, просто пишем код. Предполагается, что коэффициенты полинома записаны от старшего к младшему, а степень полинома на единицу меньше количества элементов в массиве A.
Поскольку значение полинома на случайных тестах очень быстро возрастает и создаёт трудности для точного расчёта (эталонное значение ответа, как вы знаете, я вычисляю через дроби), для тестов взято ограничение N=100 элементов в массиве. Комментарии снова излишни, кроме одного: здесь применение FMA для наивного расчёта становится оправданным — точность заметно повышается.
Считаю, что базовые представления об увеличении точности при использовании арифметики с плавающей запятой описаны в полной мере, если принять во внимание научно-популярный характер изложения и бытовую сферу применения описанных знаний. Напомню, что у меня не было цели погружаться именно в глубоко-научную сферу, для этого существуют монографии. Поэтому данную серию я завершаю, но что будет дальше — сказать не могу, пожалуйста, ждите.
Одна из лучших наград автору — успешное применение описанных им знаний на практике для всеобщей пользы. Благодарю за внимание!
[1] Разделы 4.4.2.2 и 5.4-5.5 книги Jean-Michel Muller, “Handbook of floating-point arithmetic”, 2018.
[2] Раздел 8.2 книги (по поводу умножения целых чисел) Генри С. Уоррен мл. «Алгоритмические трюки для программистов», 2014.
Как обычно, видео-презентация полностью дублирует содержание статьи, и создана для удобства тех посетителей Хабра, кому голос с иллюстрациями воспринять проще чем текст без таковых.
Умножение двух чисел без потери точности (ну… почти)
О сложении мы знаем уже достаточно: с помощью функции two_sum можно сложить N чисел наиболее точно, дело осталось за малым: научиться перемножать два числа типа double так, чтобы не терять отбрасываемый при округлении «хвостик», сохраняя его в отдельной переменной. Если в случае сложения сделать это можно очень просто при любом раскладе, лишь бы промежуточная сумма не переполнялась (кто не читал: «Сложение двух чисел с плавающей запятой без потери точности»), то в случае умножения есть дополнительное ограничение.
Обозначим через минимальную экспоненту вашей системы чисел с плавающей запятой, в рамках которой выполняется умножение (для float: ?126, для double: ?1022), буквой обозначим количество битов мантиссы с учётом скрытой единицы (24 для float и 53 для double). Пусть и — экспоненты наших исходных чисел и , тогда требуется, чтобы было выполнено условие:
Если данное условие не выполняется, то могут возникнуть случаи, когда нельзя представить в виде суммы , где и . Указанное неравенство является сложным способом сказать простую фразу: «хвостик» не должен быть денормализованным. И это вполне понятно: когда мы перемножаем два числа, получаем удвоенное количество битов, которые гарантированно влезают в две переменные того же типа данных. Если же одна из этих переменных попадает в денормализованный диапазон, происходит битовый сдвиг мантиссы с потерей младших битов. И кроме переполнения это единственная ситуация, при которой произведение двух чисел нельзя точно представить в форме суммы двух чисел того же типа.
Так ли существенно это ограничение? Для float нужно чтобы сумма экспонент оказалась не меньше ?126+24?1=?103, то есть, грубо говоря, если каждый из множителей больше 2?51, то всё будет хорошо. Спросите себя: часто ли вы применяете числа такого порядка? Если да, то можете выполнить масштабирование и перейти к числам побольше. Можете применить double, и работать с числами больше 2?485. Но даже если плюнуть на всё и применить алгоритм в неподходящих условиях, в простых прикладных задачах он всё равно отработает правильно чуть чаще чем всегда.
Если указанное условие выполнено, то с помощью инструкции FMA (Fused Multiply-Add) точное умножение двух чисел выполняется следующим алгоритмом:
double __fastcall two_prod (double &t, double a, double b) {
double p = a*b;
t = fma (a, b, -p); // t = a*b-p, не забудьте подключить <cmath>
return p;
}
Результатом работы функции будет пара чисел , такая, что является ближайшим к числом, а — хвостиком-погрешностью, причём в точности. Разумеется, по ходу расчётов не должно произойти переполнения, за этим придётся следить отдельно: нужно оставаться в рамках условия .
Если операция FMA для вас недоступна, то ситуация сильно усложняется. Желаемое произведение можно получить путём разбиения исходных переменных и на две части примерно посередине так, чтобы старшая часть операндов (ah и bh) содержала половину старших битов, а младшая (al и bl) — половину младших. Далее нужные операции выполняются именно с этими половинками, в общей сложности алгоритм потребует 17 операций сложения и умножения. Данный трюк в чём-то аналогичен известному алгоритмическому трюку для вычисления произведения двух целых чисел с сохранением результата в удвоенном типе данных (кто не знал, читайте Г. Уоррен «Алгоритмические трюки для программистов», в издании 2014 года это раздел 8.2). Нижеследующий алгоритм написан мною по мотивам книги Jean-Michel Muller, “Handbook of floating-point arithmetic”, также как и остальная часть этой статьи.
// Разбиение x на старшую и младшую часть
void __fastcall split (double &hi, double &lo, double x) {
double gamma = 0x8000001*x; // gamma=(2**27+1)*x; 27 = ceil(53/2), где 53 - размер мантиссы double (со скрытым битом).
double delta = x - gamma;
hi = gamma + delta;
lo = x - hi;
}
// Произведение с разбиением, результат идентичен функции two_prod
double __fastcall two_prod_s (double &t, double a, double b) {
double ah, al, bh, bl, p;
split (ah, al, a);
split (bh, bl, b);
p = a*b;
t = (ah*bh-p) + ah*bl + al*bh + al*bl;
return p;
}
Данный алгоритм для двоичных типов данных из Стандарта IEEE-754 работает всегда, для десятичных типов он будет работать только если количество цифр мантиссы чётно, то для есть для decimal32 он может давать ошибочный результат. Если же вы изобретаете свой тип данных с плавающей запятой, читайте полное условие теоремы 4.8 в упомянутой выше книге в разделе 4.4.2.2 “Dekker’s multiplication algorithm”. Там же, но разделом ранее разъясняется как подбирать магическую константу для правильного разбиения в функции split. В двоичной системе она подбирается равной 2s+1, при этом младшая часть lo будет содержать s битов, а старшая — все остальные.
Вычисление скалярного произведения
Итак, теперь мы умеем умножать два числа точно. Стряпаем сумму произведений по образу и подобию предыдущей статьи. Данный алгоритм назван именами трёх авторов Ogita—Rump—Oishi:
// Функция точного сложения двух чисел из предыдущей статьи
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;
}
using dvect_t = vector<double>;
double __fastcall ogita (const dvect_t &X, const dvect_t &Y) {
double s=0.0, c=0.0, p, pi, t;
for (size_t i=0; i<X.size(); ++i) {
p = two_prod (pi, X[i], Y[i]); // p = X[i]*Y[i], но в pi остался «хвостик».
s = two_sum (t, s, p); // s += p, но в t остался «хвостик».
c += pi+t; // Собираем «хвостики»
}
return s+c; // Общая сумма и «хвостик»
}
Понятно, что two_prod нужно заменить на two_prod_s, если вам недоступна FMA.
Опытный программист видит здесь в строке с комментарием «собираем хвостики» возможность каскадного применения функции two_sum, что даёт увеличение точности, но я оставлю это опытному программисту на его усмотрение.
Помимо этой версии скалярного произведения есть ещё две наивные функции, разъяснение которых в тексте статьи будет моветоном.
double __fastcall naive (const dvect_t &X, const dvect_t &Y) {
double s=0.0;
for (size_t i=0; i<X.size(); ++i)
s += X[i]*Y[i];
return s;
}
double __fastcall naive_fma (const dvect_t &X, const dvect_t &Y) {
double s=0.0;
for (size_t i=0; i<X.size(); ++i)
s = fma (X[i], Y[i], s);
return s;
}
Тестирование выполнялось точно также как в статье про сумму N чисел. Напомню только, что верхнее число в ячейке — средняя погрешность в ULP на 100 тестах, а нижнее — максимальная погрешность на них же. Размер каждого теста N=106. В первом столбце указано выбранное распределение случайных чисел.
Naive | Naive FMA | Ogita—Rump—Oishi | |
---|---|---|---|
U[1, 2) | 95.31 275 |
95.36 276 |
0.00 0 |
±U[1, 2) | 441.61 6878 |
432.99 7489 |
0.00 0 |
U[1e-10, 1e10) | 2478.61 2740 |
2478.55 2739 |
0.00 0 |
±U[1e-10, 1e10) | 392.50 11893 |
394.86 12126 |
0.00 0 |
exp[2] | 183.70 513 |
183.65 512 |
0.00 0 |
±exp[2] | 329.21 5962 |
347.31 7251 |
0.00 0 |
N(0, 1) | 1554.07 109700 |
1408.30 95896 |
0.00 0 |
Ожидаемо, что алгоритм с применением double-double сработал безошибочно, и так будет происходить на всех типичных бытовых задачах. Таблица не требует особых комментариев кроме одного: наивный алгоритм с FMA может давать менее точный результат, чем ещё более наивный алгоритм с двумя округлениями на каждом шаге. При этом ДА, я выполнил дизассемблирование и убедился, что в этом наивном методе компилятор не всунул FMA в качестве оптимизации. Вообще говоря, он не имеет на это права без моего разрешения, но всё же осторожность не помешает.
Вычисление значения полинома
Мы умеем точно умножать два числа и складывать целые массивы, а значит нам под силу применить это знание для любых алгоритмов, которые основаны на этих двух операциях. Например, в качестве бонуса рассмотрим вычисление значения полинома в заданной точке.
Когда мы применяем схему Горнера, то по сути имеем похожий по структуре цикл, в теле которого также одно умножение и сложение, поэтому никаких дополнительных пояснений к построению алгоритма не требуется, просто пишем код. Предполагается, что коэффициенты полинома записаны от старшего к младшему, а степень полинома на единицу меньше количества элементов в массиве A.
double __fastcall naive (const dvect_t &A, double x) {
double s=0.0;
for (auto a: A) {
s *= x;
s += a;
}
return s;
}
double __fastcall naive_fma (const dvect_t &A, double x) {
double s=0.0;
for (auto a: A)
s = fma (s, x, a);
return s;
}
// Алгоритм по именам трёх авторов: Graillat–Langlois–Louvet
double __fastcall graillat (const dvect_t &A, double x) {
double s=0.0, c=0.0, p, pi, t;
for (auto a: A) {
// Полином из основных значений
p = two_prod (pi, s, x);
s = two_sum (t, p, a);
// Полином из «хвостиков»
c *= x;
c += pi+t; // Можете применить здесь FMA, но не вижу особого смысла
}
return s+c; // Фактически, здесь сумма значений двух полиномов.
}
Поскольку значение полинома на случайных тестах очень быстро возрастает и создаёт трудности для точного расчёта (эталонное значение ответа, как вы знаете, я вычисляю через дроби), для тестов взято ограничение N=100 элементов в массиве. Комментарии снова излишни, кроме одного: здесь применение FMA для наивного расчёта становится оправданным — точность заметно повышается.
Naive | Naive FMA | Graillat—Langlois—Louvet | |
---|---|---|---|
U[1, 2) | 3.15 11 |
2.67 9 |
0.00 0 |
±U[1, 2) | 3.96 29 |
3.03 27 |
0.00 0 |
U[1/10, 10) | 1.86 11 |
1.47 7 |
0.00 0 |
±U[1/10, 10) | 2.20 61 |
1.29 10 |
0.00 0 |
exp[2] | 0.59 5 |
0.46 4 |
0.00 0 |
±exp[2] | 0.90 30 |
0.71 6 |
0.00 0 |
N(0, 1) | 0.90 30 |
0.71 6 |
0.00 0 |
Считаю, что базовые представления об увеличении точности при использовании арифметики с плавающей запятой описаны в полной мере, если принять во внимание научно-популярный характер изложения и бытовую сферу применения описанных знаний. Напомню, что у меня не было цели погружаться именно в глубоко-научную сферу, для этого существуют монографии. Поэтому данную серию я завершаю, но что будет дальше — сказать не могу, пожалуйста, ждите.
Одна из лучших наград автору — успешное применение описанных им знаний на практике для всеобщей пользы. Благодарю за внимание!
Список источников
[1] Разделы 4.4.2.2 и 5.4-5.5 книги Jean-Michel Muller, “Handbook of floating-point arithmetic”, 2018.
[2] Раздел 8.2 книги (по поводу умножения целых чисел) Генри С. Уоррен мл. «Алгоритмические трюки для программистов», 2014.
abcdsash
с интересом прочитал, но по коду чувства какие то смешанные…
не люблю почему то записи вида
c += pi+t
в коде. Явная запись намерений программиста мне всегда казалась более читабельной. Не вижу смысла экономии символов в ущерб ясности.
Понимаю, что для большинства эта запись понятна, но все же я поклонник ясного кода вообще, а не для большинства. Да, повторю, многим этот код ясен не менее, чем его более развернутая запись и тем не менее — не люблю такое в коде. Или старая школа или что… не знаю, скорей всего предпочтение ясности перед соглашениями ))))
ПС: все сказанное — ИМХО и не умаляет интересности самого материала
ArtemKaravaev Автор
Благодарю за замечание, но прошу принять во внимание то обстоятельство, что у разных коллективов существует разная традиция и своя собственная внутренняя система посланий друг другу через структуру кода. Большинство моих читателей переписывают код по своим канонам, понимая это.
abcdsash
да это не замечание, не поймите меня правильно )))
Это просто некое ворчание, что-ли… даже брюзжание )))
Вы — автор кода, вы и пишете так, как считаете нужным.
В своем коде я такими конструкциями предпочитаю не оперировать, вот и ворчу )))
мне проще написать:
a = a + delta
и это я считаю более янсым, чем подобные сокращенные варианты записи… тем более, если подсчитать символы, то «перерасход» — копеечный, а ясность — выше
Но это ИМХО
ArtemKaravaev Автор
Лет 20 назад один из преподавателей привёл нам пример, когда запись вроде вашей выглядит нелепо:
Это просто к слову.
Но вообще я за то, чтобы комментарии к статье были бы только по теме статьи. Считаю, что нужно ценить время и внимание читателей, не привлекая их к себе отстранёнными от темы соображениями. Поговорить можно в ЛС, можно с друзьями, но не с десятками тысяч людей (именно столько откроют статью), которые не для этого сюда приходят. Это моё субъективное мнение :)
Afterk
Выходит вы пишите
вместо ?maaGames
Я свидетель префиксного инкремента и мне больно смотреть на оба примера :)
maxzhurkin
maaGames
edo1h
edo1h
хм, а что тут неявного? «увеличиваем
c
наpi+t
»yevgen12
Не могу похвастаться тем, что все понял, но интересно.
С научно-популярным характером согласиться можно. А вот насчет бытовой сферы применения… Это в какой именно части бытовой сферы обычно применяют?ArtemKaravaev Автор
Там где нужно на тяп-ляп собрать код, который вычисляет некоторое выражение через сложение и умножение наиболее точно, то есть когда точность обычного double не устраивает: задачи математического анализа и дифференциальных уравнений при их простейшем применении к каких-то нехитрых ситуациях в быту. Ну, скажем, по-быстрому интеграл взять, не особенно заморачиваясь, но чтобы точность была высокой.
В научных расчётах, где есть высокая ответственность, описанные в статье знания именно в таком виде применить нельзя, потому что нужно понимать их математическую основу, чтобы отвечать за каждую операцию, а я эту основу не давал.
yevgen12
Теперь понял.
Бытовые применения интеграла мне близки. Если там ключи завалились в труднодоступное место) Но это уже, конечно, за пределами темы.А если серьезно… Наверное, очень научная должна быть наука, чтобы обычных вычислений с double не хватало. Это же, как я понимаю, точность каких-то исходных данных должна быть сопоставима с ошибкой ограниченности размера double. Это что-то вроде 10^(-14) или 10^(-15). Не говорю, что вопрос не актуальный, просто интересно, откуда такими интегралами ключи достают.
ArtemKaravaev Автор
Здесь всё дело в том, что у нас с вами нет точного определения что такое научная и бытовая сферы. В моём миропонимании бытовое — это всё что делается просто для удовлетворения минимального набора потребностей в той или иной сфере, возможно, на скорую руку, по-быстрому, на коленках, назовите как угодно.
Например, идёт научная работа, требуется решить сложную задачу, непонятно как подступиться. Пробуем с разных сторон подобраться, просто выполняя тяп-ляп расчёты для оценки трудозатрат и перспективности того или иного метода, который будем применять. То есть удовлетворяем набор минимальных потребностей: получить хоть какие-то сведения о задаче, чтобы можно было приступить уже от бытового к научному решению.
А точность может понадобиться наибольшая в том случае, когда полученный результат затем подвергается какой-то обработке, после которой погрешность возрастает ещё больше, затем ещё — и так много миллионов раз. Когда речь идёт об устойчивости какого-то решения, ошибка даже в 2-3 последних битах (7 ULP) в исходном числе может после миллиона последующих операций перевести систему из состояния сходится к состоянию расходится.
Короче говоря, для меня быт — это не только ключи доставать, это ещё и нудная тягомотина, которая предшествует реальному научному поиску. Вся эта тягомотина — это быт учёного и занимает массу времени.
Ну ещё добавлю, что на олимпиадах по программированию эти знания тоже могут быть нужны, там не всегда требуется знать всю математическую подоплёку, достаточно просто интуитивно понимать алгоритм и уметь его правильно применять. Для таких людей, кто выступает на соревнованиях, эти знания могут быть полезны даже очень.
Osnovjansky
Лет 20 почти не пользовался математикой, но попробую привести пару возможных примеров.
Если хочется по-исследовать какую-то модель, заданную дифференциальными/интегральными уравнениями и «погулять» по ней по различным траекториям, то для траекторий, проходящих близко от точек бифуркации, нехватка точности может радикально исказить результат. А процессы, происходящие в районе резонансов могут быть интересны и в акустике и в радиоэлектронике и в механизмах.
Или «бытовой» пример. Сейчас играю в игрушку Kerbal Space Program с модом Interstellar Extended.
Космическая баллистика в игре упрощенная — по Кеплеру — аппарат считается находящимся в сфере влияния одного небесного тела и летает по эллипсу/параболе/гиперболе. Для обычных двигателей можно считать, что они работают только в точке маневра, и там всё достаточно просто. Но. В этом дополнении есть двигатели микро-тяги (солнечный парус, ионные двигатели). И, для определения, когда и на сколько месяцев я должен включить эти двигатели, чтобы попасть, например, с Кербина (Земли) на Джул (Юпитер) — будет много «крючёчков». Причем для обеспечения торможения гравитационным маневром об какой-нибудь из спутников, с довольно высокой точностью нужно вовремя попасть в определенное место.
В этой игре баллистика упрощенная — решение можно частично искать аналитически и точности double, скорее всего, хватит. А вот если бы я подобное попытался сделать в Orbiter-е, то, как и в реальном мире, мне пришлось бы «пошагово» считать траекторию аппарата, на движение которого влияет добрая половина планет Солнечной системы, с накоплением ошибок либо в результате слишком крупного шага, либо в результате количества шагов.
К слову, когда читал об игре, встретился любопытный термин «Межпланетная транспортная сеть»(Википедия).
Refridgerator
yevgen12
Osnovjansky в общем об этом же сказал.
ArtemKaravaev Автор
Да и я почти тоже самое написал:
yevgen12
С формальной стороны да, почти то же самое или совсем то же самое.
С позиций того, кто сталкивался с подобными вещами в обозримом прошлом, все объяснения примерно об одном. Но этот человек не стал бы задавать детские вопросы)Но с другой стороны… Наверное, с педагогической, хотя более правильно будет сказать «с моей личной», Ваше объяснение более «общее»:
Да, Refridgerator написал примерно про то же самое, но со своим примером. Ну и его сообщение уже читалось как более понятное, поскольку выше уже сказали про шаги интегрирования (баланс между ошибкой от мелкого шага и крупного, Osnovjansky), а это, наверное самый очевидный пример, который здесь возможен.
byman
После серии хороших уроков, а также после портирования на свой процессор пакета для вычисления с quad-double точностью, уже совсем иначе читаешь следующий урок :)
Может кто встречал в сети какие-то отчеты по быстродействию различных процессоров на тестах с quad-double точностью? Было бы интересно посмотреть.
MichaelBorisov
Спасибо, автор! Отличный материал.
Хочу заметить по поводу этого:
Предлагаю применять для тестов полиномы Чебышева. Сколь угодно высокой степени, их значения на заданном промежутке не выходят за заданные пределы.
Кстати, для вычисления полиномов Чебышева, кажется, есть специальные, оптимизированные методы. Может быть, к ним тоже можно применить описываемые вами трюки?
Далее, интерес представляет задача вычисления интерполяционного полинома (полином Лагранжа). Там тоже вычисления подвержены ошибкам округления, а в некоторых задачах нужно вычислять интерполяционные полиномы высокой степени (порядка 10000). Есть специальные трюки для повышения точности (барицентрическая формула), но и они упираются в пределы точности double. Может быть, описываемыми вами методами можно и здесь что-то улучшить?
Задача перемножения полиномов (свёртка). Уже при степени полиномов порядка десятков, «обычные» алгоритмы теряют точность безнадёжно. Я использовал трюки с FFT, помогают. Но, может, можно сделать ещё лучше.
ArtemKaravaev Автор
Спасибо за предложение и за оценку статьи. Можно предложить много специфических тестов, когда полином не будет сильно возрастать, например, мне ближе преобразование Фурье на окружности единичного радиуса (как вы понимаете, для быстрого умножения длинных чисел). Но я решил сделать всё совсем случайным, а нужную специфику пусть читатель тестирует сам.
Описываемые алгоритмы можно применить везде, где есть необходимость перемножать пару чисел и складывать цепочки чисел любого размера. Но даже если где-то есть деление, оно может быть заменено серией сложений и умножений. Так что можно всё, было бы желание и необходимость.
Однако практика показывает, что если есть необходимость решить конкретную задачу, то для неё создают конкретный алгоритм, а я описал общие методы, они могут быть неэффективными в каком-то частном случае. Например, в некоторых задачах проще взять программную реализация float128 и получить более подходящий код и более точное решение.
fofyo
Посит стандарт обновился, его выложили в маил лист. Теперь es=2 для всех битностей. В итоге 16-битный посит имеет quare аккумулятор, который сможет вместить ±10^305 диапазон — примерно такой диапазон у типа double. Что вы думаете об этом?
ArtemKaravaev Автор
Исследования нашего научного цента показали полную непригодность позитов для решения реальных задач математического анализа и дифференциальных уравнений. Хотя сама идея красивая. Больше ничего сказать не могу, мне эта тема с тех пор неинтересна. На Хабре по теме позитов есть информация от других авторов.
makaedgar
Спасибо что делитесь своим опытом)
Может быть подскажете что-нибудь подобное для разложения Холецкого? Работаю с матрицами ковариации 100*100, и точности double не хватает. Приходится пользоваться длинной арифметикой, что сильно замедляет код
ArtemKaravaev Автор
Пожалуйста.
Любой алгоритм, основанный на перемножении двух чисел и сложении любого количества чисел может быть реализован с применением указанных в статье знаний. Но в разложении Холецкого также приходится выполнять деление и извлечение корня. Обе эти операции можно заменить серией сложений и умножений. Так что по сути подсказывать нечего.
Если нужен конкретный ответ, то мои консультации платные, обращайтесь в ЛС. Но я вас уверяю, дешевле будет вам попробовать сделать самостоятельно, это нетрудно.
MichaelBorisov
А вы пробовали применять другие методы разложения для ваших матриц? Часто бывает, что более эффективные методы (напр. метод Левинсона) для матриц специального вида имеют худшую численную устойчивость, чем более общие, хотя и более медленные, методы.
В случае Холецкого, у вас всё равно вычислительная сложность N^3. Попробуйте LU-разложение, SVD или хотя бы метод Гаусса.
web_sv
Трюки симпатичные, но наверное стоит указать что есть стандартные реализации этого подхода (long double, double-double, float128 в зависимости от реализации) https://en.m.wikipedia.org/wiki/Quadruple-precision_floating-point_format#Double-double_arithmetic
ArtemKaravaev Автор
Стандартных реализаций нет, есть чьи-то реализации давно известных приёмов. И моя задача всего лишь в их популяризации. При этом long double к данной теме не относится из-за невозможности применить его так же просто как остальные типы. Более того, сам по себе double-double или float128 не позволяет создать целый каскад сложений с повышением точности до любых значений, а описанные в статьях приёмы — позволяют.
schulzr
Еще раз спасибо за статью. Я как-то пропустил в свое время существование таких несложных, но полезных, трюков.
Скажите, у Вы не пробовали точное скалярное произведение в методе сопряженных градиентов? Дают ли такие алгоритмы улучшение сходимости в практических задачах?
ArtemKaravaev Автор
Поправлю: не точное, а наиболее точное, это разные вещи. Да и то, всегда можно подобрать тест, когда даже эти трюки спасать не будут.
Нет, не приходилось. Но могу сразу сказать, что увеличение точности обычно даёт улучшение сходимости, если в задаче нет какой-то особой специфики.
schulzr
Придется попробовать :) Странно, но в кодах тех открытых библиотек линейной алгебры, в которых я копался не было попыток использовать «наиболее точное» произведение в итерационных методах. Хотя известно, что это один из основных источников проблем.
byman
Трюки действительно интересные, но очень затратные в плане тактов процессора. Без FMA очень проблематично использовать two_prod (); На своем процессоре я влез только в 17 тактов. А если все это еще делать через вызов-возврат функции, экономя код, то уже задумаешься. С two_sum () тоже 8 тактов (с учетом пузырей). Так что если в итоге все потом посчитать, то уже и другие алгоритмы могут быть привлекательными.