Почему изменение от 0,1f до 0 снижает производительность в 10 раз?

1528

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

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. Уровень оптимизации был -02с sse2включенным. Я не тестировал с другими компиляторами.

Dragarro
источник
10
Как вы измерили разницу? А какие опции вы использовали при компиляции?
Джеймс Канзе
158
Почему в этом случае компилятор просто не удаляет +/- 0?!?
Майкл Дорган
127
@ Zyx2000 Компилятор не так уж и глуп. Дизассемблирование тривиального примера в LINQPad показывает , что он выплевывает один и тот же код , используете ли вы 0, 0f, 0dили даже (int)0в контексте , где doubleнеобходимо.
миллимус
14
какой уровень оптимизации?
Отто Альмендингер

Ответы:

1617

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

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

Если вы распечатаете числа после 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больше не будет в 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преобразуется / хранится в регистре снаружи обеих петель. Так что это не влияет на производительность.

Mysticial
источник
100
Я все еще нахожу немного странным, что компилятор по умолчанию не полностью оптимизировал "+ 0". Случилось бы это, если бы он поставил «+ 0.0f»?
s73v3r
51
@ s73v3r Это очень хороший вопрос. Теперь, когда я смотрю на сборку, даже + 0.0fне оптимизируется. Если бы мне пришлось угадывать, это могло + 0.0fбы иметь побочные эффекты, если бы y[i]это было сигналом NaNили чем-то ... Я могу ошибаться.
Мистик
14
Двойники все равно столкнутся с той же проблемой во многих случаях, но с другой числовой величиной. Сброс в ноль хорош для аудио приложений (и других, где вы можете позволить себе потерять 1e-38 здесь и там), но я считаю, что это не относится к x87. Без FTZ обычное решение для звуковых приложений состоит в том, чтобы вводить сигнал с очень низкой амплитудой (не слышимый) постоянного или прямоугольного сигнала в числа джиттера вдали от ненормальности.
Рассел Борогове
16
@Isaac, потому что, когда y [i] значительно меньше 0,1, добавление приводит к потере точности, поскольку наиболее значимая цифра в числе становится выше.
Дэн возится с огнем
167
@ s73v3r: + 0.f нельзя оптимизировать, поскольку с плавающей запятой имеет отрицательный 0, а результат добавления + 0.f к -.0f равен + 0.f. Таким образом, добавление 0.f не является операцией идентификации и не может быть оптимизировано.
Эрик Постпишил
415

Использование 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? ,

MVDS
источник
27
-OЭто не исправить, но -ffast-mathделает. (Я использую это все время, IMO в тех случаях, когда это вызывает проблемы с точностью, в любом случае не
должно появляться
С gcc-4.6 нет конверсии на любом положительном уровне оптимизации.
Джед
@leftaroundabout: компилирует исполняемый файл (не библиотеку) со -ffast-mathссылками некоторого дополнительного кода запуска, который устанавливает FTZ (сбрасывать на ноль) и DAZ (ненормированные равны нулю) в MXCSR, поэтому ЦП никогда не приходится принимать медленную помощь микрокода для денормализаций.
Питер Кордес
34

Это из-за денормализованного использования с плавающей точкой. Как избавиться от этого и от потери производительности? Поискав в Интернете способы уничтожения ненормальных чисел, кажется, что пока нет «лучшего» способа сделать это. Я нашел эти три метода, которые могут лучше всего работать в разных средах:

  • Может не работать в некоторых средах 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

инжир
источник
1
Это звучит как приличный ответ на другой, но связанный вопрос (Как я могу предотвратить численные вычисления, чтобы получить ненормальные результаты?) Однако он не отвечает на этот вопрос.
Бен Фойгт
Windows X64 пропускает настройку резкого снижения производительности при запуске .exe, а Windows 32-bit и linux - нет. В Linux gcc -ffast-math должен устанавливать резкое понижение (но я думаю, что не в Windows). Предполагается, что компиляторы Intel инициализируются в main (), чтобы эти различия в ОС не проходили, но я был укушен и должен был явно указать это в программе. Предполагается, что процессоры Intel, начинающиеся с Sandy Bridge, будут эффективно обрабатывать субнормалы, возникающие при сложении / вычитании (но не делении / умножении), поэтому есть смысл использовать постепенное понижение.
Tim18
1
Microsoft / fp: fast (не по умолчанию) не выполняет никаких агрессивных действий, присущих gcc -ffast-math или ICL (по умолчанию) / fp: fast. Это больше похоже на ICL / fp: source. Поэтому вы должны явно установить / fp: (и, в некоторых случаях, режим недостаточного заполнения), если хотите сравнить эти компиляторы.
Tim18
18

В 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])

[1] http://carlh.net/plugins/denormals.php

Герман Гарсия
источник
Также см fesetround()от fenv.h(определяются для C99) для другого, более переносимого способа округления ( linux.die.net/man/3/fesetround ) (но это будет влиять на все операции FP, а не только subnormals )
немецкий Garcia
Вы уверены, что вам нужно 1 << 15 и 1 << 11 для ЗСТ? Я видел только 1 << 15 цитируется в другом месте ...
рис
@fig: 1 << 11 - для маски переполнения. Более подробная информация здесь: softpixel.com/~cwright/programming/simd/sse.php
немецкий Гарсия
@GermanGarcia это не отвечает на вопрос ОП; вопрос был: «Почему этот бит кода работает в 10 раз быстрее, чем ...» - вы должны либо попытаться ответить на этот вопрос, прежде чем предоставлять этот обходной путь, либо указать это в комментарии.
9

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

Это не нулевая константа, 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;разве вы не думаете, что это не так?

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

remcycles
источник