[Перевод] Оптимизация математических вычислений и опция -ffast-math в GCC 11
В этом материале речь пойдёт об оптимизациях, которые включает опция -ffast-math
при компиляции кода, написанного на C или C++, с использованием GCC 11 для x86_64 Linux (при применении других языков, операционных систем, процессоров могут использоваться немного другие оптимизации).
Опция -ffast-math
Большинство оптимизаций, включаемых с помощью опции -ffast-math
, можно включать и отключать в индивидуальном порядке. Эта опция включает их все. Вот их список:
- -ffinite-math-only
- -fno-signed-zeros
- -fno-trapping-math
- -fassociative-math
- -fno-math-errno
- -freciprocal-math
- -funsafe-math-optimizations
- -fcx-limited-range
При компиляции кода, написанного на стандартном C (то есть — при применении опции -std=c99
или чего-то в этом роде, что приводит к использованию стандартного C вместо диалекта «GNU C», используемого по умолчанию) опция -ffast-math
включает ещё и оптимизацию -ffp-contract=fast
, что позволяет компилятору комбинировать операции умножения и сложения с применением инструкции FMA
(пример на godbolt.org). Эта оптимизация не влияет на C++ и GNU C, так как для них оптимизация -ffp-contract=fast
применяется по умолчанию.
Опция -ffast-math
, кроме того, включает оптимизации -fno-signaling-nans
, -fno-rounding-math
и -fexcess-precision=fast
, которые включены по умолчанию при компиляции C- или C++-кода для x86_64 Linux, поэтому тут я об этих оптимизациях не рассказываю.
У линковки программ с применением gcc
с опцией -ffast-math
есть и ещё одно последствие: благодаря этому процессор воспринимает субнормальные числа как 0.0.
Восприятие субнормальных чисел как 0.0
В формате описания чисел с плавающей точкой имеется специальное представление для значений, близких к 0.0. Появление этих «субнормальных» чисел (их ещё называют «денормализованными») в некоторых случаях приводит к очень большим дополнительным затратам вычислительных ресурсов, так как процессор обрабатывает субнормальные результаты с использованием микрокода для обработки исключений.
Агнер Фог пишет, что, при использовании процессорной микроархитектуры Broadwell, если в результате выполнения операции над нормальными числами получается субнормальное число, на выполнение операции требуется примерно 124 лишних штрафных тактовых цикла.
У x86_64-процессоров есть возможность рассматривать субнормальные числа, как 0.0 и сбрасывать эти числа в значение 0.0, что позволяет устранять ситуации, ведущие к снижению производительности. Включить это можно так:
#define MXCSR_DAZ (1<<6) /* Включить режим приведения денормализованных чисел к нулю */
#define MXCSR_FTZ (1<<15) /* Включить режим сброса значений в ноль */
unsigned int mxcsr = __builtin_ia32_stmxcsr();
mxcsr |= MXCSR_DAZ | MXCSR_FTZ;
__builtin_ia32_ldmxcsr(mxcsr);
Линковка программы с применением gcc
с опцией -ffast-math
приводит к отключению использования субнормальных чисел в этой программе через добавление вышеприведённого кода в глобальный конструктор, который выполняется до функции main
.
Опции -ffinite-math-only и -fno-signed-zeros
Проведению многих оптимизаций препятствуют особенности таких значений с плавающей точкой, как NaN
, Inf
и -0.0
. Например:
x+0.0
нельзя свести кx
, так как это даст неверный результат в том случае, когдаx
равно-0.0
.x-x
нельзя свести к0.0
, так как это даст неверный результат в том случае, когдаx
равноNaN
илиInf
.x*0.0
нельзя свести к0.0
, так как это даст неверный результат в том случае, когдаx
равноNaN
илиInf
.- Компилятор не может трансформировать такую конструкцию:
if (x > y) {
do_something();
} else {
do_something_else();
}
в такую:
if (x <= y) {
do_something_else();
} else {
do_something();
}
Дело в том, что такая трансформация некорректна в том случае, если x
или y
равняются NaN
. (Это преобразование можно счесть удачной оптимизацией при упрощении управления потоком выполнения программы и для обеспечения того, чтобы при обработке общего случая не использовались ветвления).
Опции -ffinite-math-only
и -fno-signed-zeros
сообщают компилятору о том, что в программе нет вычислений, в результате выполнения которых получаются значения NaN
, Inf
или -0.0
. Поэтому компилятор может применить к коду вышеописанные оптимизации.
Обратите внимание на то, что, если эти флаги установлены, программа может вести себя странно (например — не определять true
— или false
-части инструкции if
) в тех случаях, когда вычисления приводят к появлению значений Inf
, NaN
или -0.0
.
Опция -fno-trapping-math
Имеется возможность включить перехват исключений, имеющих отношение к вычислениям с плавающей точкой, используя функцию feenableexcept
GNU libc для генерирования сигнала SIGFPE
. Делается это в тех случаях, когда при выполнении некоей инструкции для работы с числами с плавающей точкой происходит переполнение, исчезновение значащих разрядов, появление NaN
, и в прочих подобных ситуациях. Например, нижеприведённая функция compute
выполняет вычисления, которые приводят к переполнению с появлением Inf
, что, при включённом флаге FE_OVERFLOW
, приводит к завершению программы с сигналом SIGFPE
.
// Это компилируется так: "gcc example.c -D_GNU_SOURCE -O2 -lm"
#include
#include
void compute(void) {
float f = 2.0;
for (int i = 0; i < 7; ++i) {
f = f * f;
printf("%d: f = %f\n", i, f);
}
}
int main(void) {
compute();
printf("\nWith overflow exceptions:\n");
feenableexcept(FE_OVERFLOW);
compute();
return 0;
}
Это значит, что компилятор не может запланировать спекулятивное выполнение инструкций, выполняющих операции с числами с плавающей точкой, так как подобные инструкции могут после этого сгенерировать сигнал в случаях, в которых исходная программа этого не сделает. Например, при вычислении x/y
в следующем цикле используются константы, но компилятор не может вынести эту операцию за пределы цикла, так как это может привести к тому, что функция выполнит операцию x/y
для случаев, когда все элементы arr
будут больше, чем 0.0, что, в результате, приведёт к сбою в случаях, когда в исходной программе сбоев не будет (пример).
double arr[1024];
void foo(int n, double x, double y) {
for (int i = 0; i < n; ++i) {
if (arr[i] > 0.0)
arr[i] = x / y;
}
}
Ещё один интересный особый случай связан с операциями, в которых используются атомарные числа с плавающей точкой. Стандарт C требует, чтобы исключения, связанные с такими операциями, отбрасывались бы при выполнении комбинированного присваивания (смотрите раздел «Compound assignment» в стандарте C). В результате, если не включена опция -fno-trapping-math
, компилятору необходимо добавлять в готовую к выполнению программу дополнительный код (пример).
Передача компилятору опции -fno-trapping-math
сообщает ему о том, что программа не допускает возникновения исключений при выполнении вычислений с плавающей точкой, что позволяет компилятору выполнить эти оптимизации.
Опция -fassociative-math
Опция -fassociative-math
позволяет выполнять реассоциацию операндов в последовательностях операций с плавающей точкой (а также — выполнять ещё несколько более общих оптимизаций, связанных с переупорядочением выражений). Большинство таких оптимизаций нуждается также в опциях -fno-trapping-math
, -ffinite-math-only
и -fno-signed-zeros
.
Вот некоторые примеры оптимизаций, возможных благодаря -fassociative-math
:
Реассоциация выражений особенно полезна для векторизации вычислений. Рассмотрим, скажем, следующий цикл (пример):
float a[1024];
float foo(void) {
float sum = 0.0f;
for (int i = 0; i < 1024; ++i) {
sum += a[i];
}
return sum;
}
Все операции прибавления значений к sum
выполняются последовательно, в результате в обычных условиях эти вычисления векторизовать нельзя. Но опция -fassociative-math
позволяет компилятору изменить порядок операций:
sum = (a[0] + a[4] + ... + a[1020]) + (a[1] + a[5] + ... + a[1021]) + (a[2] + a[6] + ... + a[1022]) + (a[3] + a[7] + ... + a[1023]);
В результате цикл можно скомпилировать так, как если бы он изначально выглядел бы следующим образом:
float a[1024];
float foo(void) {
float sum0 = sum1 = sum2 = sum3 = 0.0f;
for (int i = 0; i < 1024; i += 4) {
sum0 += a[i ];
sum1 += a[i + 1];
sum2 += a[i + 2];
sum3 += a[i + 3];
}
return sum0 + sum1 + sum2 + sum3;
}
А такая конструкция легко поддаётся векторизации.
Опция -fno-math-errno
Математические функции C могут устанавливать errno
в том случае, если вызываются с некорректными аргументами. Этот возможный побочный эффект вычислений означает, что компилятор должен вызывать функции libc
вместо использования инструкций, которые могут напрямую вычислить результат.
Компилятор может, в некоторых случаях, смягчить эту проблему, не вызывая функцию тогда, когда ему известно, что операция будет выполнена успешно (пример):
double foo(double x) {
return sqrt(x);
}
Эта конструкция компилируется в исполняемый код с использованием инструкции sqrtsd
в том случае, если входное значение находится в допустимом диапазоне, а sqrt
вызывается лишь для входных значений, вычисления над которыми приводят к появлению NaN
:
foo:
pxor xmm1, xmm1
ucomisd xmm1, xmm0
ja .L10
sqrtsd xmm0, xmm0
ret
.L10:
jmp sqrt
Этот ход позволяет избавиться от большей части дополнительной нагрузки на систему (так как операции сравнения и ветвления, при их правильном предсказании, по сути, никакой нагрузки не создают), но дополнительная ветвь выполнения кода и вызов функции усложняют жизнь другим оптимизациям (таким, как векторизация вычислений).
Опция -fno-math-errno
заставляет GCC оптимизировать все математические функции так, как будто они не устанавливают errno
(то есть — компилятору не нужно вызывать функции libc
в том случае, если процессорная архитектура, для которой компилируется код, обладает подходящими инструкциями).
▍Нематематические функции
Одним неожиданным эффектом применения опции -fno-math-errno
является тот факт, что она заставляет GCC считать, что функции libc
, ответственные за выделение памяти (вроде malloc
и strdup
) не устанавливают errno
. Это можно видеть в следующей функции, где использование -fno-math-errno
приводит к тому, что GCC убирает вызовы perror
(пример):
void *foo(size_t size) {
errno = 0;
void *p = malloc(size);
if (p == NULL) {
if (errno)
perror("error");
exit(1);
}
return p;
}
По этому поводу было сделано сообщение об ошибке GCC 88576.
Опция -freciprocal-math
Опция -freciprocal-math
позволяет компилятору вычислять выражения вида x/y
как x*(1/y)
. Это полезно для тех случаев, когда приходится иметь дело с такими конструкциями (пример):
float length = sqrtf(x*x + y*y + z*z);
x = x / length;
y = y / length;
z = z / length;
Благодаря этой опции компилятор может обработать код так, как если бы он был написан следующим образом:
float t = 1.0f / sqrtf(x*x + y*y + z*z);
x = x * t;
y = y * t;
z = z * t;
Данная оптимизация приводит к генерированию большего количества инструкций, но итоговый код, в целом, оказывается лучше, так как умножение быстрее деления и может выполняться на большем количестве портов в CPU.
Опция -funsafe-math-optimizations
Опция -funsafe-math-optimizations
включает различные «математически корректные» оптимизации, в результате применения которых могут изменяться результаты вычислений, что вызвано особенностями устройства чисел с плавающей точкой. Вот несколько примеров:
Обратите внимание на то, что для применения многих из этих оптимизаций нужны дополнительные флаги, например — такие, как -ffinite-math-only
и -fno-math-errno
.
Опция -funsafe-math-optimizations
, кроме того, включает следующие оптимизации:
- -fno-signed-zeros
- -fno-trapping-math
- -fassociative-math
- -freciprocal-math
Включает она (для стандартного C) и -ffp-contract=fast
, а так же — восприятие субнормальных чисел как 0.0 (так же, как был описано в разделе о -ffast-math
).
Опция -fcx-limited-range
Математические формулы для умножения и деления комплексных чисел выглядят так:
a+ ibc+id=ac-bd+i (bc+ad)
a+ibc+id= ac+bdc2+d2+ibc-adc2+d2
Но это не очень хорошо подходит для значений с плавающей точкой.
Одна из проблем заключается в том, что в ходе вычислений значения с переполнением могут превратиться в NaN
, а не в Inf
, в результате реализация соответствующих алгоритмов должна это учитывать. Умножение, в итоге, выражается кодом, напоминающим следующий:
double complex
mul(double a, double b, double c, double d) {
double ac, bd, ad, bc, x, y;
double complex res;
ac = a * c;
bd = b * d;
ad = a * d;
bc = b * c;
x = ac - bd;
y = ad + bc;
if (isnan(x) && isnan(y)) {
/* Восстановить бесконечности, вычисленные как NaN + iNaN. */
_Bool recalc = 0;
if (isinf(a) || isinf(b)) {
/* z - бесконечность. "Упаковать" бесконечность и сделать значения NaN
* в другом показателе равными 0. */
a = copysign(isinf(a) ? 1.0 : 0.0, a);
b = copysign(isinf(b) ? 1.0 : 0.0, b);
if (isnan(c)) c = copysign(0.0, c);
if (isnan(d)) d = copysign(0.0, d);
recalc = 1;
}
if (isinf(c) || isinf(d)) {
/* w - бесконечность. "Упаковать" бесконечность и сделать значения NaN
* в другом показателе равными 0. */
c = copysign(isinf(c) ? 1.0 : 0.0, c);
d = copysign(isinf(d) ? 1.0 : 0.0, d);
if (isnan(a)) a = copysign(0.0, a);
if (isnan(b)) b = copysign(0.0, b);
recalc = 1;
}
if (!recalc
&& (isinf(ac) || isinf(bd)
|| isinf(ad) || isinf(bc))) {
/* Восстановить бесконечности из переполнения путём установки
* значений NaN в 0. */
if (isnan(a)) a = copysign(0.0, a);
if (isnan(b)) b = copysign(0.0, b);
if (isnan(c)) c = copysign(0.0, c);
if (isnan(d)) d = copysign(0.0, d);
recalc = 1;
}
if (recalc) {
x = INFINITY * (a * c - b * d);
y = INFINITY * (a * d + b * c);
}
}
__real__ res = x;
__imag__ res = y;
return res;
}
Тут имеется и ещё одна проблема, которая заключается в том, что вычисления могут вызвать переполнение даже в том случае, если результат операции находится в допустимом диапазоне. Это вызывает больше всего неприятностей при делении, поэтому реализация этой операции нуждается в использовании ещё большего количества дополнительного кода (дополнительные подробности ищите в стандарте C).
Применение опции -fcx-limited_range
приводит к тому, что компилятор использует обычные математические формулы для умножения и деления комплексных чисел.
Пользуетесь ли вы оптимизациями математических вычислений в своих С/С++-проектах?