Снижение прочности при делении с плавающей запятой вручную
В одном из наших последних заданий по компьютерным наукам в этом семестре мы должны применить снижение прочности к некоторым фрагментам кода. Большинство из них были прямолинейными, особенно с изучением вывода компилятора. Но одну из них я не смогу решить даже с помощью компилятора.
Наши профи дали нам следующую подсказку:
Подсказка: узнайте, как числа с плавающей запятой одинарной точности IEEE 754 представлены в памяти.
Вот фрагмент кода: (a имеет типdouble*
)
for (int i = 0; i < N; ++i) {
a[i] += i / 5.3;
}
Сначала я попытался изучить вывод компилятора для этого, снятого на godbolt. Я попытался скомпилировать его без какой-либо оптимизации: (примечание: я скопировал только соответствующую часть цикла for)
mov eax, DWORD PTR [rbp-4]
cdqe
lea rdx, [0+rax*8]
mov rax, QWORD PTR [rbp-16]
add rax, rdx
movsd xmm1, QWORD PTR [rax]
cvtsi2sd xmm0, DWORD PTR [rbp-4] //division relevant
movsd xmm2, QWORD PTR .LC0[rip] //division relevant
divsd xmm0, xmm2 //division relevant
mov eax, DWORD PTR [rbp-4]
cdqe
lea rdx, [0+rax*8]
mov rax, QWORD PTR [rbp-16]
add rax, rdx
addsd xmm0, xmm1
movsd QWORD PTR [rax], xmm0
и с-O3
:
.L2:
pshufd xmm0, xmm2, 238 //division relevant
cvtdq2pd xmm1, xmm2 //division relevant
movupd xmm6, XMMWORD PTR [rax]
add rax, 32
cvtdq2pd xmm0, xmm0 //division relevant
divpd xmm1, xmm3 //division relevant
movupd xmm5, XMMWORD PTR [rax-16]
paddd xmm2, xmm4
divpd xmm0, xmm3 //division relevant
addpd xmm1, xmm6
movups XMMWORD PTR [rax-32], xmm1
addpd xmm0, xmm5
movups XMMWORD PTR [rax-16], xmm0
cmp rax, rbp
jne .L2
Я прокомментировал часть деления ассемблерного кода. Но этот вывод не помогает мне понять, как применить уменьшение прочности фрагмента. (Возможно, слишком много оптимизаций, чтобы полностью понять вывод)
Во-вторых, я попытался понять битовое представление части с плавающей запятой.5.3
. Который:
0 |10000001|01010011001100110011010
Sign|Exponent|Mantissa
Но и это мне не помогает.
2 ответа
ПРЕДОСТЕРЕЖЕНИЕ: через несколько дней я понял, что этот ответ неверен, поскольку он игнорирует последствия потери значимости (до субнормального или до нуля) при вычислении . В этом случае умножение результата на степень двойки является «точным», но не дает такого результата, как деление большего целого числа на
Оставшаяся трудность состоит в том, чтобы переупорядочить итерации таким образом, чтобы каждый индекс между
Один из способов добиться этого состоит в том, чтобы перебирать все нечетные числа, меньшие , и после вычисления
if (N > 0) {
a[0] += 0.0; // this is needed for strict IEEE 754 compliance lol
for (int o = 1; o < N; o += 2) {
double d = o / 5.3;
int i = o;
do {
a[i] += d;
i += i;
d += d;
} while (i < N);
}
}
Примечание: здесь не используется предоставленная подсказка «Запросить, как числа с плавающей запятой одинарной точности IEEE 754 представлены в памяти». Я думаю, что довольно хорошо знаю, как числа с плавающей запятой одинарной точности представлены в памяти, но я не понимаю, насколько это актуально, тем более что в коде нет значений одинарной точности или вычислений, которые нужно оптимизировать. Я думаю, что в способе выражения проблемы есть ошибка, но все же вышеизложенное технически является частичным ответом на поставленный вопрос.
Я также проигнорировал проблемы переполнения для значений
В качестве дополнительного примечания приведенное выше преобразование заменяет только одно деление из двух. Он делает это, делая код не векторизуемым (а также менее удобным для кэширования). В вашем вопросе,
Если мы примем определение Википедии , что
сокращение прочности — это оптимизация компилятора, при которой дорогостоящие операции заменяются эквивалентными, но менее затратными операциями.
тогда мы можем применить здесь снижение силы, преобразовав дорогостоящее деление с плавающей запятой в умножение с плавающей запятой плюс два умножения-сложения с плавающей запятой (FMA). При условии, что
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <math.h>
int main (void)
{
const double rcp_5p3 = 1.0 / 5.3; // 0x1.826a439f656f2p-3
int i = INT_MAX;
do {
double ref = i / 5.3;
double res = fma (fma (-5.3, i * rcp_5p3, i), rcp_5p3, i * rcp_5p3);
if (res != ref) {
printf ("error: i=%2d res=%23.13a ref=%23.13a\n", i, res, ref);
return EXIT_FAILURE;
}
i--;
} while (i >= 0);
return EXIT_SUCCESS;
}
Большинство современных экземпляров распространенных архитектур процессоров, таких как x86-64 и ARM64, имеют аппаратную поддержку FMA, поэтому ее можно напрямую сопоставить с соответствующей аппаратной инструкцией. Это должно быть подтверждено просмотром разборки сгенерированного двоичного файла. Там, где аппаратная поддержка FMA отсутствует, преобразование, очевидно, не должно применяться, так как программные реализации
Основная идея здесь заключается в том, что математически деление эквивалентно умножению на обратное. Однако это не обязательно верно для арифметики с плавающей запятой конечной точности . Приведенный выше код пытается повысить вероятность вычисления с точностью до бита, определяя ошибку в наивном подходе с помощью FMA и при необходимости применяя исправление. Справочную информацию, включая ссылки на литературу, см. в предыдущем вопросе.
Насколько мне известно, еще не существует общего математически проверенного алгоритма для определения того, для каких делителей в паре с какими делимыми приведенное выше преобразование является безопасным (то есть дает результаты с точностью до бита), поэтому строго необходима исчерпывающая проверка. чтобы показать, что преобразование верно.
В комментариях user139746 указывает, что существует альтернативный алгоритм, потенциально снижающий силу деления с плавающей запятой с помощью постоянного делителя времени компиляции, путем предварительного вычисления обратной величины делителя с большей точностью, чем исходная, и, в частности, как двойной двойной. Для получения дополнительной информации см. Н. Бризбарр и Ж.-М. Мюллер, «Правильно округленное умножение на константу произвольной точности», IEEE Transactions on Computers, 57(2): 162-174, февраль 2008 г., в котором также содержится руководство по определению того, безопасно ли это преобразование для какой-либо конкретной константы. Поскольку данный случай прост, я снова использовал исчерпывающий тест, чтобы показать, что он безопасен. Там, где это применимо, это уменьшит деление до одного FMA плюс умножение:
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <mathimf.h>
int main (void)
{
const double rcp_5p3_hi = 1.8867924528301888e-1; // 0x1.826a439f656f2p-3
const double rcp_5p3_lo = -7.2921377017921457e-18;// -0x1.0d084b1883f6e0p-57
int i = INT_MAX;
do {
double ref = i / 5.3;
double res = fma (i, rcp_5p3_hi, i * rcp_5p3_lo);
if (res != ref) {
printf ("i=%2d res=%23.13a ref=%23.13a\n", i, res, ref);
return EXIT_FAILURE;
}
i--;
} while (i >= 0);
return EXIT_SUCCESS;
}