¿Por qué cambiar 0.1f a 0 reduce el rendimiento en 10 veces?

¿Por qué este pedazo de código?

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

correr más de 10 veces más rápido que el siguiente bit (idéntico excepto donde se indique)

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

al comstackr con Visual Studio 2010 SP1. (No he probado con otros comstackdores).

¡Bienvenido al mundo del punto flotante desnormalizado ! ¡Pueden causar esgulps en el rendimiento!

Los números denormales (o subnormales) son como un truco para obtener algunos valores extra muy cerca de cero de la representación de coma flotante. Las operaciones en punto flotante desnormalizado pueden ser de decenas a cientos de veces más lentas que en punto flotante normalizado. Esto se debe a que muchos procesadores no pueden manejarlos directamente y deben atraparlos y resolverlos mediante microcódigo.

Si imprime los números después de 10.000 iteraciones, verá que han convergido a diferentes valores según se use 0 o 0.1 .

Aquí está el código de prueba comstackdo en 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; } 

Salida:

 #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 

Observe cómo en la segunda ejecución los números están muy cerca de cero.

Los números desnormalizados son generalmente raros y, por lo tanto, la mayoría de los procesadores no intentan manejarlos de manera eficiente.


Para demostrar que esto tiene todo que ver con los números desnormalizados, si nivelamos los denormales a cero agregando esto al comienzo del código:

 _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); 

Entonces la versión con 0 ya no es 10 veces más lenta y en realidad se vuelve más rápida. (Esto requiere que el código se compile con SSE habilitado).

Esto significa que en lugar de utilizar estos valores extraños de precisión más baja casi cero, en su lugar, redondeamos a cero.

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

Al final, esto realmente no tiene nada que ver con si es un número entero o coma flotante. El 0 o 0.1f se convierte / almacena en un registro fuera de ambos bucles. Entonces eso no tiene efecto en el rendimiento.

El uso de gcc y la aplicación de un diff al ensamblaje generado solo arroja esta diferencia:

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

El cvtsi2ssq es 10 veces más lento.

Aparentemente, la versión float usa un registro XMM cargado desde la memoria, mientras que la versión int convierte un valor real int 0 para float utilizando la instrucción cvtsi2ssq , lo que lleva mucho tiempo. Pasar -O3 a gcc no ayuda. (gcc versión 4.2.1.)

(Usar double lugar de float no importa, excepto que cambia el cvtsi2ssq en cvtsi2sdq ).

Actualizar

Algunas pruebas adicionales muestran que no es necesariamente la instrucción cvtsi2ssq . Una vez eliminada (usando un int ai=0;float a=ai; y usando a vez de 0 ), la diferencia de velocidad permanece. Entonces @Mysticial tiene razón, las carrozas desnormalizadas hacen la diferencia. Esto se puede ver probando valores entre 0 y 0.1f . El punto de inflexión en el código anterior es aproximadamente de 0.00000000000000000000000000000001 , cuando los bucles repentinamente toman 10 veces más tiempo.

Actualización < < 1

Una pequeña visualización de este interesante fenómeno:

  • Columna 1: un flotador, dividido por 2 por cada iteración
  • Columna 2: la representación binaria de este flotador
  • Columna 3: el tiempo necesario para sumr este flotador 1e7 veces

Puede ver claramente que el exponente (los últimos 9 bits) cambia a su valor más bajo, cuando se establece la desnormalización. En ese punto, la adición simple se vuelve 20 veces más 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 discusión equivalente sobre ARM se puede encontrar en Stak Overflow question Denormalized floating point en Objective-C? .

Se debe al uso desnormalizado de coma flotante. ¿Cómo deshacerse de ambos y la penalización de rendimiento? Después de haber buscado en Internet formas de matar números denormales, parece que todavía no hay una “mejor” forma de hacerlo. Encontré estos tres métodos que pueden funcionar mejor en diferentes entornos:

  • Puede que no funcione en algunos entornos GCC:

     // Requires #include  fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV); 
  • Puede que no funcione en algunos entornos de 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) 
  • Parece funcionar tanto en GCC como en Visual Studio:

     // Requires #include  // Requires #include  _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); 
  • El comstackdor de Intel tiene opciones para deshabilitar denormal de forma predeterminada en las CPU Intel modernas. Más detalles aquí

  • El comstackdor cambia -ffast-math , -msse o -mfpmath=sse desactivará denormals y hará otras cosas más rápido, pero desafortunadamente también hará muchas otras aproximaciones que podrían romper tu código. ¡Prueba cuidadosamente! El equivalente de la matemática rápida para el comstackdor de Visual Studio es /fp:fast pero no he podido confirmar si esto también desactiva los denormales. 1

En gcc puedes habilitar FTZ y DAZ con esto:

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

también use los conmutadores gcc: -msse -mfpmath = sse

(créditos correspondientes a Carl Hetherington [1])

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

El comentario de Dan Neely debería expandirse a una respuesta:

No es la constante cero 0.0f que se desnormaliza o causa una desaceleración, sino que son los valores los que se acercan a cero en cada iteración del ciclo. A medida que se acercan cada vez más a cero, necesitan más precisión para representar y se desnormalizan. Estos son los valores y[i] . (Se acercan a cero porque x[i]/z[i] es menor que 1.0 para todo i ).

La diferencia crucial entre las versiones lenta y rápida del código es la afirmación y[i] = y[i] + 0.1f; . Tan pronto como se ejecuta esta línea en cada iteración del ciclo, se pierde la precisión adicional en el flotante y ya no se necesita la desnormalización necesaria para representar esa precisión. Luego, las operaciones de coma flotante en y[i] permanecen rápidas porque no están desnormalizadas.

¿Por qué se pierde la precisión extra cuando se agrega 0.1f ? Debido a que los números de punto flotante solo tienen tantos dígitos significativos. Supongamos que tiene suficiente almacenamiento para tres dígitos significativos, luego 0.00001 = 1e-5 y 0.00001 + 0.1 = 0.1 , al menos para este formato flotante de ejemplo, porque no tiene espacio para almacenar el bit menos significativo en 0.10001 .

En resumen, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; no es el no-op que podrías pensar que es.

Mystical también dijo esto : el contenido de las carrozas importa, no solo el código ensamblador.