Dlaczego zmiana 0,1f na 0 spowalnia działanie 10-krotnie?

1527

Dlaczego ten fragment kodu

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

uruchomić ponad 10 razy szybciej niż następujący bit (identyczny, chyba że zaznaczono inaczej)?

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

podczas kompilacji z Visual Studio 2010 SP1. Poziom optymalizacji było -02z sse2włączona. Nie testowałem z innymi kompilatorami.

Dragarro
źródło
10
Jak zmierzyłeś różnicę? A jakich opcji użyłeś podczas kompilacji?
James Kanze
158
Dlaczego w tym przypadku kompilator nie upuszcza +/- 0?!?
Michael Dorgan
127
@ Zyx2000 Kompilator nie jest w pobliżu tak głupi. Demontaż trywialny przykład w pokazach LINQPad że wypluwa sam kod czy używasz 0, 0f, 0d, lub nawet (int)0w sytuacji, gdy doublejest to potrzebne.
millimoose
14
jaki jest poziom optymalizacji?
Otto Allmendinger

Odpowiedzi:

1615

Witamy w świecie zdominowanych zmiennoprzecinkowych ! Mogą siać spustoszenie w wydajności!

Liczby normalne (lub subnormalne) są rodzajem hackowania, aby uzyskać dodatkowe wartości bardzo zbliżone do zera z reprezentacji zmiennoprzecinkowej. Operacje na znormalizowanym zmiennoprzecinkowym mogą być dziesiątki do setek razy wolniejsze niż na znormalizowanym zmiennoprzecinkowym. Wynika to z faktu, że wiele procesorów nie radzi sobie z nimi bezpośrednio i musi je przechwycić i rozwiązać za pomocą mikrokodu.

Jeśli wydrukujesz liczby po 10 000 iteracjach, zobaczysz, że zbiegły się one w różne wartości w zależności od tego, 0czy 0.1są używane.

Oto kod testowy skompilowany na 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;
}

Wynik:

#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

Zwróć uwagę, że w drugim przebiegu liczby są bardzo bliskie zeru.

Numery zdormalizowane są na ogół rzadkie, dlatego większość procesorów nie próbuje ich obsługiwać skutecznie.


Aby zademonstrować, że ma to wszystko wspólnego z liczbami zdenormalizowanymi, jeśli wyzerujemy wartości normalne do zera , dodając to na początku kodu:

_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);

Wtedy wersja z 0nie jest już 10 razy wolniejsza i faktycznie staje się szybsza. (Wymaga to kompilacji kodu z włączoną obsługą SSE).

Oznacza to, że zamiast używać tych dziwnych, prawie zerowych wartości o mniejszej precyzji, po prostu zaokrąglamy do zera.

Czasy pracy: Core i7 920 @ 3,5 GHz:

//  Don't flush denormals to zero.
0.1f: 0.564067
0   : 26.7669

//  Flush denormals to zero.
0.1f: 0.587117
0   : 0.341406

Ostatecznie nie ma to nic wspólnego z liczbą całkowitą czy zmiennoprzecinkową. Symbol 0lub 0.1fjest konwertowany / przechowywany w rejestrze poza obiema pętlami. To nie ma wpływu na wydajność.

Tajemniczy
źródło
100
Nadal uważam za trochę dziwne, że kompilator „+ 0” nie jest całkowicie zoptymalizowany. Czy tak by się stało, gdyby wstawił „+ 0.0f”?
s73v3r
51
@ s73v3r To bardzo dobre pytanie. Teraz, gdy patrzę na zespół, nawet się + 0.0fnie optymalizuje. Gdybym musiał zgadywać, mogłoby + 0.0fto mieć skutki uboczne, gdyby okazało się, że y[i]jest to sygnał NaNlub coś w tym rodzaju ... Mógłbym się jednak mylić.
Tajemniczy
14
W wielu przypadkach podwójny nadal napotyka ten sam problem, tylko o różnej wielkości liczbowej. Flush-to-zero jest odpowiedni dla aplikacji audio (i innych, w których możesz sobie pozwolić na utratę 1e-38 tu i tam), ale uważam, że nie dotyczy to x87. Bez FTZ zwykłą poprawką w zastosowaniach audio jest wstrzykiwanie sygnału DC o niskiej amplitudzie (niesłyszalnego) lub fali prostokątnej do liczb jittera z dala od normalności.
Russell Borogove
16
@Isaac, ponieważ gdy y [i] jest znacznie mniejsze niż 0,1, dodanie powoduje utratę precyzji, ponieważ najbardziej znacząca cyfra w liczbie staje się wyższa.
Dan Is Fiddling By Firelight
167
@ s73v3r: Nie można zoptymalizować + 0.f, ponieważ zmiennoprzecinkowe ma ujemne 0, a wynikiem dodania + 0.f do -.0f jest + 0.f. Tak więc dodanie 0.f nie jest operacją tożsamości i nie można go zoptymalizować.
Eric Postpischil,
415

Użycie gcci zastosowanie różnicy do wygenerowanego zestawu daje tylko tę różnicę:

73c68,69
<   movss   LCPI1_0(%rip), %xmm1
---
>   movabsq $0, %rcx
>   cvtsi2ssq   %rcx, %xmm1
81d76
<   subss   %xmm1, %xmm0

Ten cvtsi2ssqjest rzeczywiście 10 razy wolniejszy.

Najwyraźniej floatwersja używa rejestru XMM załadowanego z pamięci, podczas gdy intwersja konwertuje intwartość rzeczywistą 0 na floatużycie cvtsi2ssqinstrukcji, co zajmuje dużo czasu. Przekazywanie -O3do gcc nie pomaga. (gcc wersja 4.2.1.)

(Używanie doublezamiast floatnie ma znaczenia, z wyjątkiem tego, że zmienia się cvtsi2ssqw a cvtsi2sdq.)

Aktualizacja

Niektóre dodatkowe testy pokazują, że niekoniecznie jest to cvtsi2ssqinstrukcja. Po wyeliminowaniu (użycie a int ai=0;float a=ai;i użycie azamiast 0) różnica prędkości pozostaje. Więc @Mysticial ma rację, denormalizowane zmiennoprzecinkowe robią różnicę. Można to zobaczyć, testując wartości pomiędzy 0i 0.1f. Punktem zwrotnym w powyższym kodzie jest w przybliżeniu 0.00000000000000000000000000000001moment, w którym pętle nagle trwają 10 razy dłużej.

Zaktualizuj << 1

Mała wizualizacja tego interesującego zjawiska:

  • Kolumna 1: liczba zmiennoprzecinkowa podzielona przez 2 dla każdej iteracji
  • Kolumna 2: binarna reprezentacja tego elementu pływającego
  • Kolumna 3: czas potrzebny na zsumowanie tego pływaka 1e7 razy

Wyraźnie widać, że wykładnik (ostatnie 9 bitów) zmienia się na najniższą wartość, gdy zaczyna się denormalizacja. W tym momencie proste dodawanie staje się 20 razy wolniejsze.

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

Równoważną dyskusję na temat ARM można znaleźć w pytaniu Przepełnienie stosu Denormalizowany zmiennoprzecinkowy w Objective-C? .

mvds
źródło
27
-Onie naprawiają tego, ale -ffast-mathtak. (Używam tego cały czas, IMO narożne przypadki, w których powoduje problemy z precyzją, nie powinny i tak
pojawiać
Nie ma konwersji na żadnym dodatnim poziomie optymalizacji z gcc-4.6.
Jed
@leftaroundabout: kompilacja pliku wykonywalnego (nie biblioteki) z -ffast-mathlinkami dodatkowego kodu startowego, który ustawia FTZ (kolor na zero) i DAZ (denormalne są zero) w MXCSR, więc procesor nigdy nie musi przyjmować wolnej pomocy mikrokodu dla normalnych.
Peter Cordes,
34

Wynika to ze znormalizowanego użycia zmiennoprzecinkowego. Jak się go pozbyć i stracić wydajność? Po przeszukaniu Internetu w poszukiwaniu sposobów zabijania liczb normalnych wydaje się, że nie ma jeszcze „najlepszego” sposobu na zrobienie tego. Znalazłem te trzy metody, które mogą najlepiej działać w różnych środowiskach:

  • Może nie działać w niektórych środowiskach GCC:

    // Requires #include <fenv.h>
    fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV);
  • Może nie działać w niektórych środowiskach 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)
  • Wydaje się działać zarówno w GCC, jak i 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);
  • Kompilator Intel ma opcje domyślnego wyłączania denormałów w nowoczesnych procesorach Intel. Więcej informacji tutaj

  • Przełączniki kompilatora. -ffast-math, -msselub -mfpmath=ssewyłączy denormale i przyspieszy kilka innych rzeczy, ale niestety zrobi też wiele innych przybliżeń, które mogą uszkodzić twój kod. Przetestuj dokładnie! Odpowiednikiem szybkiej matematyki dla kompilatora Visual Studio jest, /fp:fastale nie byłem w stanie potwierdzić, czy to również wyłącza denormale. 1

Figa
źródło
1
To brzmi jak przyzwoita odpowiedź na inne, ale powiązane pytanie (Jak mogę zapobiec wytwarzaniu odbiegających od normy wyników obliczeń numerycznych?). To jednak nie odpowiada na to pytanie.
Ben Voigt
Windows X64 przekazuje ustawienie nagłego niedopełnienia po uruchomieniu .exe, podczas gdy Windows 32-bit i Linux nie. W Linuksie gcc -ffast-math powinien ustawić nagły niedopełnienie (ale myślę, że nie w Windows). Kompilatory Intela powinny się inicjować w main (), aby te różnice w systemie operacyjnym nie przeszły, ale zostałem ugryziony i muszę to ustawić wyraźnie w programie. Procesory Intel zaczynające się od Sandy Bridge powinny efektywnie radzić sobie z subnormalami powstającymi w dodawaniu / odejmowaniu (ale nie dzieleniu / mnożeniu), więc istnieje potrzeba stosowania stopniowego niedomiaru.
tim18
1
Microsoft / fp: fast (nie jest domyślny) nie wykonuje żadnej agresywnej rzeczy związanej z gcc -ffast-math lub ICL (domyślnie) / fp: fast. To bardziej przypomina ICL / fp: source. Musisz więc ustawić / fp: (aw niektórych przypadkach tryb niedomiaru), jeśli chcesz porównać te kompilatory.
tim18
18

W gcc możesz włączyć FTZ i DAZ za pomocą:

#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);
}

użyj także przełączników gcc: -msse -mfpmath = sse

(odpowiadające kredyty Carlowi Hetheringtonowi [1])

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

Niemiecki Garcia
źródło
Patrz także fesetround()z fenv.h(zdefiniowany dla C99) na inny, bardziej przenośny sposób zaokrąglania ( linux.die.net/man/3/fesetround ) (ale to wpłynie na wszystkie operacje PF, nie tylko subnormals )
niemiecki Garcia
Czy na pewno potrzebujesz 1 << 15 i 1 << 11 dla FTZ? Widziałem tylko 1 << 15 cytowany gdzie indziej ...
rys.
@fig: 1 << 11 dotyczy maski dolnego przepływu. Więcej informacji tutaj: softpixel.com/~cwright/programming/simd/sse.php
niemiecki Garcia
@GermanGarcia to nie odpowiada na pytanie PO; pytanie brzmiało: „Dlaczego ten fragment kodu działa 10 razy szybciej niż ...” - powinieneś spróbować odpowiedzieć na to pytanie przed udostępnieniem tego obejścia lub podać to w komentarzu.
9

Komentarz Dana Neely'a powinien zostać rozszerzony na odpowiedź:

To nie stała stała 0.0fjest zdenormalizowana lub powoduje spowolnienie, to wartości zbliżają się do zera przy każdej iteracji pętli. Gdy zbliżają się coraz bardziej do zera, potrzebują większej precyzji do reprezentacji i stają się denormalizowane. To są y[i]wartości. (Zbliżają się do zera, ponieważ x[i]/z[i]dla wszystkich jest mniejsza niż 1,0 i).

Zasadniczą różnicą między wolnymi i szybkimi wersjami kodu jest instrukcja y[i] = y[i] + 0.1f;. Gdy tylko ta linia zostanie wykonana po każdej iteracji pętli, dodatkowa precyzja w liczbach zmiennoprzecinkowych zostanie utracona, a denormalizacja potrzebna do przedstawienia tej precyzji nie jest już potrzebna. Następnie operacje zmiennoprzecinkowe y[i]pozostają szybkie, ponieważ nie są zdenormalizowane.

Dlaczego dodajesz dodatkową precyzję 0.1f? Ponieważ liczby zmiennoprzecinkowe mają tylko tyle cyfr znaczących. Załóżmy, że masz wystarczająco dużo pamięci masowej dla trzech cyfr znaczących, potem 0.00001 = 1e-5, i 0.00001 + 0.1 = 0.1, przynajmniej dla tego przykładu formacie pływaka, ponieważ nie ma miejsca do przechowywania najmniej znaczący bit w 0.10001.

Krótko mówiąc, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f;nie jest to żadna operacja, o której możesz pomyśleć.

Mystical również to powiedział : zawartość pływaków ma znaczenie, a nie tylko kod asemblera.

motocykle
źródło