Perché la modifica da 0.1f a 0 rallenta le prestazioni di 10 volte?

Perché questo bit di codice,

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

eseguito più di 10 volte più veloce del seguente bit (identico tranne dove indicato)?

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

durante la compilazione con Visual Studio 2010 SP1. (Non ho testato con altri compilatori.)

Benvenuti nel mondo del punto di virgola mobile denormalizzato ! Possono devastare le prestazioni !!!

I numeri Denormal (o subnormali) sono una specie di trucco per ottenere valori extra molto vicini allo zero rispetto alla rappresentazione in virgola mobile. Le operazioni su un punto fluttuante denormalizzato possono essere da decine a centinaia di volte più lente rispetto a un punto fluttuante normalizzato. Questo perché molti processori non possono gestirli direttamente e devono intercettarli e risolverli usando il microcodice.

Se si stampano i numeri dopo 10.000 iterazioni, si vedrà che sono stati convertiti in valori diversi a seconda che venga utilizzato 0 o 0.1 .

Ecco il codice di prova compilato su 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; } 

Produzione:

 #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 

Nota come nella seconda analisi i numeri sono molto vicini allo zero.

I numeri denormalizzati sono generalmente rari e quindi la maggior parte dei processori non tenta di gestirli in modo efficiente.


Per dimostrare che tutto ciò ha a che fare con numeri denormalizzati, se svuotiamo i denormali a zero aggiungendo questo all'inizio del codice:

 _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); 

Quindi la versione con 0 non è più 10 volte più lenta e in realtà diventa più veloce. (Ciò richiede che il codice venga compilato con SSE abilitato).

Ciò significa che piuttosto che utilizzare questi valori quasi zero di precisione inferiore bizzarri, abbiamo invece solo intorno a zero.

Tempi: 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 

Alla fine, questo non ha davvero nulla a che fare con un intero o un punto a virgola mobile. Lo 0 o lo 0.1f viene convertito / memorizzato in un registro al di fuori di entrambi i loop. In modo che non ha alcun effetto sulle prestazioni.

L’utilizzo di gcc e l’applicazione di una diff all’assembly generato produce solo questa differenza:

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

Il cvtsi2ssq è in effetti 10 volte più lento.

Apparentemente, la versione float utilizza un registro XMM caricato dalla memoria, mentre la versione int converte un valore int reale 0 in float usando l'istruzione cvtsi2ssq , impiegando molto tempo. Passare da -O3 a gcc non aiuta. (gcc versione 4.2.1.)

(Usare double invece di float non ha importanza, tranne che cambia cvtsi2ssq in un cvtsi2sdq .)

Aggiornare

Alcuni test aggiuntivi mostrano che non è necessariamente l'istruzione cvtsi2ssq . Una volta eliminato (usando un int ai=0;float a=ai; e usando a invece di 0 ), la differenza di velocità rimane. Quindi @Mysticial ha ragione, i float denormalizzati fanno la differenza. Questo può essere visto testando valori tra 0 e 0.1f . Il punto di svolta nel codice sopra riportato è all'incirca a 0.00000000000000000000000000000001 , quando i loop impiegano improvvisamente 10 volte di più.

Aggiornamento << 1

Una piccola visualizzazione di questo fenomeno interessante:

  • Colonna 1: un float, diviso per 2 per ogni iterazione
  • Colonna 2: la rappresentazione binaria di questo float
  • Colonna 3: il tempo impiegato per sumre questo float 1e7 volte

È ansible vedere chiaramente l'esponente (gli ultimi 9 bit) passare al valore più basso, quando si triggers la denormalizzazione. A quel punto, l'aggiunta semplice diventa 20 volte più lenta.

 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 

Una discussione equivalente su ARM può essere trovata nella domanda Stack Overflow Punto di virgola mobile denormalizzato in Objective-C? .

È dovuto all’utilizzo in virgola mobile denormalizzato. Come sbarazzarsi di entrambi e della penalità delle prestazioni? Dopo aver perlustrato Internet per modi di uccidere numeri denormali, sembra che non ci sia ancora un modo “migliore” per farlo. Ho trovato questi tre metodi che possono funzionare meglio in ambienti diversi:

  • Potrebbe non funzionare in alcuni ambienti GCC:

     // Requires #include  fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV); 
  • Potrebbe non funzionare in alcuni ambienti di Visual Studio: 1

     // Requires #include  _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) 
  • Sembra funzionare sia in GCC che in Visual Studio:

     // Requires #include  // Requires #include  _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); 
  • Il compilatore Intel ha opzioni per disabilitare i denormali di default sulle moderne CPU Intel. Maggiori dettagli qui

  • Interruttori del compilatore. -ffast-math , -msse o -mfpmath=sse disabiliterà i denormals e renderà qualche altra cosa più veloce, ma sfortunatamente fa anche molte altre approssimazioni che potrebbero infrangere il tuo codice. Prova con attenzione! L'equivalente di fast-math per il compilatore di Visual Studio è /fp:fast ma non sono stato in grado di confermare se questo disabilita anche i denormali. 1

In gcc puoi abilitare FTZ e DAZ con questo:

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

utilizzare anche le opzioni di gcc: -msse -mfpmath = sse

(crediti corrispondenti a Carl Hetherington [1])

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

Il commento di Dan Neely dovrebbe essere ampliato in una risposta:

Non è la costante zero 0.0f che viene denormalizzata o causa un rallentamento, sono i valori che si avvicinano a zero ogni iterazione del ciclo. Man mano che si avvicinano sempre più allo zero, hanno bisogno di maggiore precisione per rappresentare e diventano denormalizzati. Questi sono y[i] valori di y[i] . (Si avvicinano allo zero perché x[i]/z[i] è inferiore a 1.0 per tutti i .)

La differenza cruciale tra le versioni lente e veloci del codice è l’affermazione y[i] = y[i] + 0.1f; . Non appena questa linea viene eseguita ogni iterazione del ciclo, la precisione extra nel float viene persa e la denormalizzazione necessaria per rappresentare che la precisione non è più necessaria. Successivamente, le operazioni in virgola mobile su y[i] rimangono veloci perché non vengono denormalizzate.

Perché la precisione extra viene persa quando si aggiunge 0.1f ? Perché i numeri in virgola mobile hanno solo tante cifre significative. Supponiamo di avere memoria sufficiente per tre cifre significative, quindi 0.00001 = 1e-5 e 0.00001 + 0.1 = 0.1 , almeno per questo esempio di formato float, perché non ha spazio per memorizzare il bit meno significativo in 0.10001 .

In breve, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; non è il non-op si potrebbe pensare che sia.

Anche Mystical ha detto questo : il contenuto dei float è importante, non solo il codice assembly.