Почему изменение от 0,1f до 0 снижает производительность в 10 раз?
Почему этот кусок кода,
const float x[16] = { 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8,
1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
1.923, 2.034, 2.145, 2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
y[i] = x[i];
}
for (int j = 0; j < 9000000; j++)
{
for (int i = 0; i < 16; i++)
{
y[i] *= x[i];
y[i] /= z[i];
y[i] = y[i] + 0.1f; // <--
y[i] = y[i] - 0.1f; // <--
}
}
работать более чем в 10 раз быстрее, чем следующий бит (идентично, если не указано иное)?
const float x[16] = { 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8,
1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
1.923, 2.034, 2.145, 2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
y[i] = x[i];
}
for (int j = 0; j < 9000000; j++)
{
for (int i = 0; i < 16; i++)
{
y[i] *= x[i];
y[i] /= z[i];
y[i] = y[i] + 0; // <--
y[i] = y[i] - 0; // <--
}
}
при компиляции с Visual Studio 2010 SP1. (Я не тестировал с другими компиляторами.)
7 ответов
Добро пожаловать в мир денормализованных чисел с плавающей точкой! Они могут нанести ущерб производительности!!!
Денормальные (или субнормальные) числа являются своего рода хаком, чтобы получить некоторые дополнительные значения, очень близкие к нулю, из представления с плавающей запятой. Операции с денормализованной плавающей точкой могут быть в десятки и сотни раз медленнее, чем с нормализованной плавающей точкой. Это потому, что многие процессоры не могут обрабатывать их напрямую и должны перехватывать и разрешать их с помощью микрокода.
Если вы распечатаете числа после 10000 итераций, вы увидите, что они сходятся к различным значениям в зависимости от того, 0
или же 0.1
используется.
Вот тестовый код, скомпилированный на x64:
int main() {
double start = omp_get_wtime();
const float x[16]={1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,2.0,2.1,2.2,2.3,2.4,2.5,2.6};
const float z[16]={1.123,1.234,1.345,156.467,1.578,1.689,1.790,1.812,1.923,2.034,2.145,2.256,2.367,2.478,2.589,2.690};
float y[16];
for(int i=0;i<16;i++)
{
y[i]=x[i];
}
for(int j=0;j<9000000;j++)
{
for(int i=0;i<16;i++)
{
y[i]*=x[i];
y[i]/=z[i];
#ifdef FLOATING
y[i]=y[i]+0.1f;
y[i]=y[i]-0.1f;
#else
y[i]=y[i]+0;
y[i]=y[i]-0;
#endif
if (j > 10000)
cout << y[i] << " ";
}
if (j > 10000)
cout << endl;
}
double end = omp_get_wtime();
cout << end - start << endl;
system("pause");
return 0;
}
Выход:
#define FLOATING
1.78814e-007 1.3411e-007 1.04308e-007 0 7.45058e-008 6.70552e-008 6.70552e-008 5.58794e-007 3.05474e-007 2.16067e-007 1.71363e-007 1.49012e-007 1.2666e-007 1.11759e-007 1.04308e-007 1.04308e-007
1.78814e-007 1.3411e-007 1.04308e-007 0 7.45058e-008 6.70552e-008 6.70552e-008 5.58794e-007 3.05474e-007 2.16067e-007 1.71363e-007 1.49012e-007 1.2666e-007 1.11759e-007 1.04308e-007 1.04308e-007
//#define FLOATING
6.30584e-044 3.92364e-044 3.08286e-044 0 1.82169e-044 1.54143e-044 2.10195e-044 2.46842e-029 7.56701e-044 4.06377e-044 3.92364e-044 3.22299e-044 3.08286e-044 2.66247e-044 2.66247e-044 2.24208e-044
6.30584e-044 3.92364e-044 3.08286e-044 0 1.82169e-044 1.54143e-044 2.10195e-044 2.45208e-029 7.56701e-044 4.06377e-044 3.92364e-044 3.22299e-044 3.08286e-044 2.66247e-044 2.66247e-044 2.24208e-044
Обратите внимание, что во втором запуске числа очень близки к нулю.
Денормализованные числа, как правило, встречаются редко, и поэтому большинство процессоров не пытаются эффективно с ними справиться.
Чтобы продемонстрировать, что это имеет отношение к денормализованным числам, если мы сбрасываем денормалы в ноль, добавляя это в начало кода:
_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);
Тогда версия с 0
больше не в 10 раз медленнее и фактически становится быстрее. (Для этого необходимо, чтобы код был скомпилирован с включенным SSE.)
Это означает, что вместо того, чтобы использовать эти странные почти нулевые значения с более низкой точностью, мы вместо этого просто округляем до нуля.
Время: Core i7 920 @ 3,5 ГГц:
// Don't flush denormals to zero.
0.1f: 0.564067
0 : 26.7669
// Flush denormals to zero.
0.1f: 0.587117
0 : 0.341406
В конце концов, это действительно не имеет ничего общего с целым числом или с плавающей точкой. 0
или же 0.1f
конвертируется / сохраняется в регистр за пределами обоих циклов. Так что это не влияет на производительность.
С помощью gcc
и применение diff к сгенерированной сборке дает только эту разницу:
73c68,69
< movss LCPI1_0(%rip), %xmm1
---
> movabsq $0, %rcx
> cvtsi2ssq %rcx, %xmm1
81d76
< subss %xmm1, %xmm0
cvtsi2ssq
один в 10 раз медленнее.
По-видимому, float
версия использует регистр XMM, загруженный из памяти, а int
версия превращает настоящую int
значение от 0 до float
с использованием cvtsi2ssq
инструкция, занимающая много времени. Переходя -O3
GCC не помогает. (версия gcc 4.2.1.)
(С помощью double
вместо float
не имеет значения, за исключением того, что это меняет cvtsi2ssq
в cvtsi2sdq
.)
Обновить
Некоторые дополнительные тесты показывают, что это не обязательно cvtsi2ssq
инструкция. После устранения (используя int ai=0;float a=ai;
и используя a
вместо 0
) разница в скорости сохраняется. Так что @Mysticial прав, денормализованные числа имеют значение. Это можно увидеть путем тестирования значений между 0
а также 0.1f
, Поворотный момент в приведенном выше коде примерно в 0.00000000000000000000000000000001
, когда петли вдруг занимает в 10 раз больше времени.
Обновление<<1
Небольшая визуализация этого интересного явления:
- Колонка 1: число с плавающей точкой, деленное на 2 для каждой итерации
- Колонка 2: двоичное представление этого числа
- Колонка 3: время, необходимое для суммирования этого числа 1e7 раз
Вы можете ясно видеть, как показатель степени (последние 9 бит) меняется на самое низкое значение, когда начинается денормализация. В этот момент простое добавление становится в 20 раз медленнее.
0.000000000000000000000000000000000100000004670110: 10111100001101110010000011100000 45 ms
0.000000000000000000000000000000000050000002335055: 10111100001101110010000101100000 43 ms
0.000000000000000000000000000000000025000001167528: 10111100001101110010000001100000 43 ms
0.000000000000000000000000000000000012500000583764: 10111100001101110010000110100000 42 ms
0.000000000000000000000000000000000006250000291882: 10111100001101110010000010100000 48 ms
0.000000000000000000000000000000000003125000145941: 10111100001101110010000100100000 43 ms
0.000000000000000000000000000000000001562500072970: 10111100001101110010000000100000 42 ms
0.000000000000000000000000000000000000781250036485: 10111100001101110010000111000000 42 ms
0.000000000000000000000000000000000000390625018243: 10111100001101110010000011000000 42 ms
0.000000000000000000000000000000000000195312509121: 10111100001101110010000101000000 43 ms
0.000000000000000000000000000000000000097656254561: 10111100001101110010000001000000 42 ms
0.000000000000000000000000000000000000048828127280: 10111100001101110010000110000000 44 ms
0.000000000000000000000000000000000000024414063640: 10111100001101110010000010000000 42 ms
0.000000000000000000000000000000000000012207031820: 10111100001101110010000100000000 42 ms
0.000000000000000000000000000000000000006103515209: 01111000011011100100001000000000 789 ms
0.000000000000000000000000000000000000003051757605: 11110000110111001000010000000000 788 ms
0.000000000000000000000000000000000000001525879503: 00010001101110010000100000000000 788 ms
0.000000000000000000000000000000000000000762939751: 00100011011100100001000000000000 795 ms
0.000000000000000000000000000000000000000381469876: 01000110111001000010000000000000 896 ms
0.000000000000000000000000000000000000000190734938: 10001101110010000100000000000000 813 ms
0.000000000000000000000000000000000000000095366768: 00011011100100001000000000000000 798 ms
0.000000000000000000000000000000000000000047683384: 00110111001000010000000000000000 791 ms
0.000000000000000000000000000000000000000023841692: 01101110010000100000000000000000 802 ms
0.000000000000000000000000000000000000000011920846: 11011100100001000000000000000000 809 ms
0.000000000000000000000000000000000000000005961124: 01111001000010000000000000000000 795 ms
0.000000000000000000000000000000000000000002980562: 11110010000100000000000000000000 835 ms
0.000000000000000000000000000000000000000001490982: 00010100001000000000000000000000 864 ms
0.000000000000000000000000000000000000000000745491: 00101000010000000000000000000000 915 ms
0.000000000000000000000000000000000000000000372745: 01010000100000000000000000000000 918 ms
0.000000000000000000000000000000000000000000186373: 10100001000000000000000000000000 881 ms
0.000000000000000000000000000000000000000000092486: 01000010000000000000000000000000 857 ms
0.000000000000000000000000000000000000000000046243: 10000100000000000000000000000000 861 ms
0.000000000000000000000000000000000000000000022421: 00001000000000000000000000000000 855 ms
0.000000000000000000000000000000000000000000011210: 00010000000000000000000000000000 887 ms
0.000000000000000000000000000000000000000000005605: 00100000000000000000000000000000 799 ms
0.000000000000000000000000000000000000000000002803: 01000000000000000000000000000000 828 ms
0.000000000000000000000000000000000000000000001401: 10000000000000000000000000000000 815 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 44 ms
Эквивалентное обсуждение ARM можно найти в вопросе переполнения стека Денормализованная плавающая точка в Objective-C?,
Это связано с денормализованным использованием с плавающей точкой. Как избавиться от этого и от потери производительности? Поискав в Интернете способы уничтожения ненормальных чисел, кажется, что пока нет "лучшего" способа сделать это. Я нашел эти три метода, которые могут лучше всего работать в разных средах:
Может не работать в некоторых средах GCC:
// Requires #include <fenv.h> fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV);
Может не работать в некоторых средах Visual Studio: 1
// Requires #include <xmmintrin.h> _mm_setcsr( _mm_getcsr() | (1<<15) | (1<<6) ); // Does both FTZ and DAZ bits. You can also use just hex value 0x8040 to do both. // You might also want to use the underflow mask (1<<11)
Появляется для работы в GCC и Visual Studio:
// Requires #include <xmmintrin.h> // Requires #include <pmmintrin.h> _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON);
Компилятор Intel имеет опции для отключения денормальных значений по умолчанию на современных процессорах Intel. Подробнее здесь
Переключатели компилятора.
-ffast-math
,-msse
или же-mfpmath=sse
отключит ненормативную лексику и сделает несколько других вещей быстрее, но, к сожалению, также сделает много других приближений, которые могут нарушить ваш код. Проверьте внимательно! Эквивалентом быстрой математики для компилятора Visual Studio является/fp:fast
но я не смог подтвердить, отключает ли это также денормали. 1
Комментарий Дана Нили должен быть расширен в ответ:
Это не нулевая константа 0.0f
который денормализован или вызывает замедление, это значения, которые приближаются к нулю на каждой итерации цикла. По мере того, как они приближаются к нулю, им нужно больше точности для представления, и они становятся денормализованными. Эти y[i]
ценности. (Они приближаются к нулю, потому что x[i]/z[i]
меньше 1,0 для всех i
.)
Принципиальная разница между медленной и быстрой версиями кода заключается в утверждении y[i] = y[i] + 0.1f;
, Как только эта строка выполняется при каждой итерации цикла, дополнительная точность в плавающей запятой теряется, и денормализация, необходимая для представления этой точности, больше не нужна. После этого операции с плавающей запятой на y[i]
остаются быстрыми, потому что они не денормализованы.
Почему лишняя точность теряется при добавлении 0.1f
? Потому что числа с плавающей запятой имеют только столько значащих цифр. Скажем, у вас достаточно места для хранения трех значащих цифр, а затем 0.00001 = 1e-5
, а также 0.00001 + 0.1 = 0.1
по крайней мере для этого примера формата с плавающей запятой, потому что в нем нет места для хранения младшего значащего бита 0.10001
,
Короче, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f;
разве вы не думаете, что это не так?
Мистик сказал и это: имеет значение содержание float, а не только код сборки.
В gcc вы можете включить FTZ и DAZ с помощью этого:
#include <xmmintrin.h>
#define FTZ 1
#define DAZ 1
void enableFtzDaz()
{
int mxcsr = _mm_getcsr ();
if (FTZ) {
mxcsr |= (1<<15) | (1<<11);
}
if (DAZ) {
mxcsr |= (1<<6);
}
_mm_setcsr (mxcsr);
}
также используйте ключи gcc: -msse -mfpmath = sse
(соответствующие кредиты Карлу Хетерингтону [1])
Обновление на 2023 год, на Ryzen 3990x, gcc 10.2, опция компиляции-O3 -mavx2 -march=native
, разница между 2 версией
0.0f: 0.218s
0.1f: 0.127s
Так что он все еще медленнее, но не в 10 раз медленнее.
Процессоры только немного медленнее для денормализованных чисел в течение длительного времени. Моему процессору Zen2 требуется пять тактов для вычислений с денормализованными входами и денормализованными выходами и четыре такта с нормализованным числом.
Это небольшой бенчмарк, написанный с помощью Visual C++, чтобы показать небольшой эффект снижения производительности денормализованных чисел:
#include <iostream>
#include <cstdint>
#include <chrono>
using namespace std;
using namespace chrono;
uint64_t denScale( uint64_t rounds, bool den );
int main()
{
auto bench = []( bool den ) -> double
{
constexpr uint64_t ROUNDS = 25'000'000;
auto start = high_resolution_clock::now();
int64_t nScale = denScale( ROUNDS, den );
return (double)duration_cast<nanoseconds>( high_resolution_clock::now() - start ).count() / nScale;
};
double
tDen = bench( true ),
tNorm = bench( false ),
rel = tDen / tNorm - 1;
cout << tDen << endl;
cout << tNorm << endl;
cout << trunc( 100 * 10 * rel + 0.5 ) / 10 << "%" << endl;
}
Это часть сборки MASM.
PUBLIC ?denScale@@YA_K_K_N@Z
CONST SEGMENT
DEN DQ 00008000000000000h
ONE DQ 03FF0000000000000h
P5 DQ 03fe0000000000000h
CONST ENDS
_TEXT SEGMENT
?denScale@@YA_K_K_N@Z PROC
xor rax, rax
test rcx, rcx
jz byeBye
mov r8, ONE
mov r9, DEN
test dl, dl
cmovnz r8, r9
movq xmm1, P5
mov rax, rcx
loopThis:
movq xmm0, r8
REPT 52
mulsd xmm0, xmm1
ENDM
sub rcx, 1
jae loopThis
mov rdx, 52
mul rdx
byeBye:
ret
?denScale@@YA_K_K_N@Z ENDP
_TEXT ENDS
END
Было бы неплохо увидеть некоторые результаты в комментариях.