c++ - Почему изменение 0.1f to 0 замедляет производительность на 10x?




performance visual-studio-2010 compilation floating-point (5)

Комментарий Дан Нили следует расширить в ответ:

Это не нулевая константа 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; y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; это не то, что вы можете себе представить.

Мистик также сказал об этом : содержание поплавков имеет значение, а не только код сборки.

Почему этот бит кода,

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. (Я не тестировал другие компиляторы.)


Использование 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; и используя вместо 0 ) разница в скорости остается. Итак, @Mysticial прав, денормализованные поплавки имеют значение. Это можно увидеть путем тестирования значений от 0 до 0.1f . Точка поворота в приведенном выше коде приблизительно равна 0.00000000000000000000000000000001 , когда петли внезапно проходят в 10 раз.

Обновление << 1

Небольшая визуализация этого интересного явления:

  • Столбец 1: поплавок, разделенный на 2 для каждой итерации
  • Столбец 2: двоичное представление этого поплавка
  • Столбец 3: время, затраченное на суммирование этого поплавка 1 раз в 7 раз

Вы можете ясно видеть, что показатель экспоненты (последние 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? ,


Добро пожаловать в мир денормализованных плавающих точек ! Они могут нанести ущерб производительности!

Денормальные (или субнормальные) числа являются своего рода хаком, чтобы получить некоторые дополнительные значения, очень близкие к нулю из представления с плавающей запятой. Операции с денормализованной плавающей точкой могут быть в десятки и сотни раз медленнее, чем при нормализованной плавающей запятой. Это связано с тем, что многие процессоры не могут обрабатывать их напрямую и должны ловить их и разрешать с помощью микрокода.

Если вы распечатаете цифры после 10 000 итераций, вы увидите, что они сходились к разным значениям в зависимости от того, используется ли 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 больше не будет 10x медленнее и на самом деле становится быстрее. (Это требует, чтобы код был скомпилирован с включенным 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:

    // 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


Помимо локального / глобального времени хранения переменных, опкод-предсказание делает функцию быстрее.

Как объясняют другие ответы, функция использует код операции STORE_FAST в цикле. Вот байт-код для цикла функции:

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator
         16 STORE_FAST               0 (x)       # set local variable
         19 JUMP_ABSOLUTE           13           # back to FOR_ITER

Обычно, когда программа запускается, Python выполняет каждый код операции один за другим, отслеживая стеки и предварительно формируя другие проверки в кадре стека после выполнения каждого кода операции. Прогнозирование кода означает, что в некоторых случаях Python может перейти непосредственно к следующему коду операции, тем самым избегая некоторых из этих накладных расходов.

В этом случае каждый раз, когда Python видит FOR_ITER (верх цикла), он «предсказывает», что STORE_FAST является следующим STORE_FAST операции, который он должен выполнить. Затем Python заглядывает в следующий код операции и, если предсказание верное, оно переходит прямо к STORE_FAST . Это приводит к сжатию двух кодов операций в один код операции.

С другой стороны, код операции STORE_NAME используется в цикле на глобальном уровне. Python делает * not * делает подобные прогнозы, когда видит этот код операции. Вместо этого он должен вернуться к вершине цикла оценки, который имеет очевидные последствия для скорости, с которой выполняется цикл.

Чтобы дать дополнительную техническую информацию об этой оптимизации, вот цитата из файла ceval.c («движок» виртуальной машины Python):

Некоторые коды операций, как правило, попадают в пары, что позволяет прогнозировать второй код при первом запуске. Например, GET_ITER часто сопровождается FOR_ITER . За FOR_ITER часто следуют STORE_FAST или UNPACK_SEQUENCE .

Проверка прогноза требует одного высокоскоростного теста переменной регистра с константой. Если спаривание было хорошим, то собственное внутреннее предсказание ветвления процессора имеет высокую вероятность успеха, что приводит к почти нулевому переходу на следующий код операции. Успешное предсказание экономит поездку через eval-loop, включая две непредсказуемые ветки, тест HAS_ARG и коммутационный футляр. В сочетании с предсказанием внутренней ветви процессора успешный PREDICT приводит к тому, что два PREDICT работают так, как если бы они были одним новым кодом операции с объединенными телами.

Мы можем видеть в исходном коде код операции FOR_ITER точно, где сделано предсказание для STORE_FAST :

case FOR_ITER:                         // the FOR_ITER opcode case
    v = TOP();
    x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
    if (x != NULL) {                     
        PUSH(x);                       // put x on top of the stack
        PREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!
        PREDICT(UNPACK_SEQUENCE);      // this and everything below is skipped
        continue;
    }
    // error-checking and more code for when the iterator ends normally                                     

Функция PREDICT расширяется до if (*next_instr == op) goto PRED_##op т.е. мы просто переходим к началу прогнозируемого кода операции. В этом случае мы прыгаем сюда:

PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
    v = POP();                     // pop x back off the stack
    SETLOCAL(oparg, v);            // set it as the new local variable
    goto fast_next_opcode;

Локальная переменная теперь установлена, и следующий код операции готов к выполнению. Python продолжается через iterable, пока он не достигнет конца, делая успешное предсказание каждый раз.

На вики-странице Python есть больше информации о том, как работает виртуальная машина CPython.







c++ performance visual-studio-2010 compilation floating-point