¿Por qué cambiar 0.1f a 0 ralentiza el rendimiento en 10x?

1528

¿Por qué este bit 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; // <--
    }
}

ejecuta 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 compilar con Visual Studio 2010 SP1. El nivel de optimización fue -02con sse2activado. No he probado con otros compiladores.

Dragarro
fuente
10
¿Cómo midiste la diferencia? ¿Y qué opciones usaste al compilar?
James Kanze
158
¿Por qué el compilador no deja caer el +/- 0 en este caso?
Michael Dorgan
127
@ Zyx2000 El compilador no está tan cerca de ese estúpido. Desmontaje de un ejemplo trivial en espectáculos LINQPad que escupe el mismo código si se utiliza 0, 0f, 0d, o incluso (int)0en un contexto donde una doublees necesaria.
millimoose
14
¿Cuál es el nivel de optimización?
Otto Allmendinger

Respuestas:

1617

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

Los números denormales (o subnormales) son una especie de truco para obtener algunos valores adicionales muy cercanos a cero de la representación de coma flotante. Las operaciones en punto flotante desnormalizado pueden ser 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 usando microcódigo.

Si imprime los números después de 10,000 iteraciones, verá que han convergido a diferentes valores dependiendo de si se usa 0o no 0.1.

Aquí está el código de prueba compilado 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 volcamos los denormals a cero agregando esto al comienzo del código:

_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);

Entonces la versión con 0ya 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 extraños valores de precisión casi inferior a cero, simplemente 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 entero o un punto flotante. El 0o 0.1fse convierte / almacena en un registro fuera de ambos bucles. Entonces eso no tiene ningún efecto en el rendimiento.

Místico
fuente
100
Todavía me resulta un poco extraño que el compilador no optimice completamente el "+ 0". ¿Habría sucedido esto si hubiera puesto "+ 0.0f"?
s73v3r
51
@ s73v3r Esa es una muy buena pregunta. Ahora que miro el ensamblaje, ni siquiera + 0.0fse optimiza. Si tuviera que adivinar, podría ser que + 0.0ftendría efectos secundarios si se y[i]tratara de una señalización NaNo algo ... Sin embargo, podría estar equivocado.
Mysticial
14
Los dobles aún se encontrarán con el mismo problema en muchos casos, solo que con una magnitud numérica diferente. Flush-to-zero está bien para aplicaciones de audio (y otras en las que puede permitirse perder 1e-38 aquí y allá), pero creo que no se aplica a x87. Sin FTZ, la solución habitual para las aplicaciones de audio es inyectar una señal de CC o de onda cuadrada de amplitud muy baja (no audible) para alentar los números de fluctuación de la desnormalidad.
Russell Borogove
16
@Isaac porque cuando y [i] es significativamente menor que 0.1 agregarlo resulta en una pérdida de precisión porque el dígito más significativo en el número se vuelve más alto.
Dan Is Fiddling By Firelight
167
@ s73v3r: El + 0.f no se puede optimizar porque el punto flotante tiene un 0 negativo, y el resultado de agregar + 0.f a -.0f es + 0.f. Por lo tanto, agregar 0.f no es una operación de identidad y no se puede optimizar.
Eric Postpischil
415

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

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

El cvtsi2ssqque es 10 veces más lento de hecho.

Aparentemente, la floatversión usa un registro XMM cargado desde la memoria, mientras que la intversión convierte un intvalor real 0 para floatusar la cvtsi2ssqinstrucción, lo que lleva mucho tiempo. Pasar -O3a gcc no ayuda. (gcc versión 4.2.1.)

(Usar en doublelugar de floatno importa, excepto que cambia el cvtsi2ssqa cvtsi2sdq.)

Actualizar

Algunas pruebas adicionales muestran que no es necesariamente la cvtsi2ssqinstrucción. Una vez eliminado (usando ay int ai=0;float a=ai;usando en alugar de 0), la diferencia de velocidad permanece. Entonces @Mysticial tiene razón, los flotadores desnormalizados marcan la diferencia. Esto se puede ver probando valores entre 0y 0.1f. El punto de inflexión en el código anterior es aproximadamente en 0.00000000000000000000000000000001, cuando los bucles de repente tardan 10 veces más.

Actualización << 1

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

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

Puede ver claramente el exponente (los últimos 9 bits) cambiar a su valor más bajo, cuando se establece la desnormalización. En ese punto, la suma 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

Se puede encontrar una discusión equivalente sobre ARM en la pregunta de desbordamiento de pila ¿ Punto flotante desnormalizado en Objective-C? .

mvds
fuente
27
-Os no lo arregla, pero lo -ffast-mathhace. (Yo uso todo el tiempo, la OMI los casos de esquina donde causa problemas de precisión no debe aparecer en un programa bien diseñado de todos modos.)
leftaroundabout
No hay conversión en ningún nivel de optimización positivo con gcc-4.6.
Jed
@leftaroundabout: compilando un ejecutable (no una biblioteca) con -ffast-mathenlaces con un código de inicio adicional que establece FTZ (vaciado a cero) y DAZ (denormal son cero) en el MXCSR, por lo que la CPU nunca tiene que tomar una ayuda lenta de microcódigo para denormals.
Peter Cordes
34

Se debe al uso de punto flotante desnormalizado. ¿Cómo deshacerse de él y de la penalización de rendimiento? Después de haber buscado en Internet formas de matar los números normales, parece que todavía no hay una "mejor" forma de hacerlo. He encontrado estos tres métodos que pueden funcionar mejor en diferentes entornos:

  • Podría no funcionar en algunos entornos de CCG:

    // Requires #include <fenv.h>
    fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV);
  • Podría no funcionar en algunos entornos de 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)
  • Parece funcionar tanto en GCC como en 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);
  • El compilador Intel tiene opciones para deshabilitar denormals por defecto en las CPU Intel modernas. Más detalles aquí

  • El compilador cambia. -ffast-math, -msseo -mfpmath=ssedeshabilitará los valores normales y acelerará algunas otras cosas, pero desafortunadamente también hará muchas otras aproximaciones que podrían romper su código. Prueba con cuidado! El equivalente de las matemáticas rápidas para el compilador de Visual Studio es, /fp:fastpero no he podido confirmar si esto también deshabilita los valores normales. 1

higo
fuente
1
Esto suena como una respuesta decente a una pregunta diferente pero relacionada (¿Cómo puedo evitar que los cálculos numéricos produzcan resultados denormales?) Sin embargo, no responde a esta pregunta.
Ben Voigt
Windows X64 pasa una configuración de subdesbordamiento abrupto cuando inicia .exe, mientras que Windows de 32 bits y Linux no. En Linux, gcc -ffast-math debería establecer un subflujo abrupto (pero creo que no en Windows). Se supone que los compiladores Intel se inicializan en main () para que estas diferencias de sistema operativo no pasen, pero he sido mordido y necesito configurarlo explícitamente en el programa. Se supone que las CPU Intel que comienzan con Sandy Bridge manejan subnormales que surgen en sumar / restar (pero no dividir / multiplicar) de manera eficiente, por lo que existe un caso para usar un flujo descendente gradual.
tim18
1
Microsoft / fp: rápido (no predeterminado) no hace ninguna de las cosas agresivas inherentes a gcc -ffast-math o ICL (predeterminado) / fp: rápido. Es más como ICL / fp: fuente. Por lo tanto, debe establecer / fp: (y, en algunos casos, el modo de flujo insuficiente) explícitamente si desea comparar estos compiladores.
tim18
18

En gcc puede habilitar FTZ y DAZ con esto:

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

también use modificadores gcc: -msse -mfpmath = sse

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

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

German Garcia
fuente
También ver fesetround()a partir de fenv.h(definido para C99) para otro, de forma más portátil del redondeo ( linux.die.net/man/3/fesetround ) (pero esto afectaría a todas las operaciones de PF, no sólo a los subnormales )
Germán García
¿Estás seguro de que necesitas 1 << 15 y 1 << 11 para FTZ? Solo he visto 1 << 15 citado en otro lugar ...
fig
@fig: 1 << 11 es para la máscara de flujo inferior. Más información aquí: softpixel.com/~cwright/programming/simd/sse.php
German Garcia
@GermanGarcia esto no responde la pregunta de los OP; la pregunta era "¿Por qué este bit de código se ejecuta 10 veces más rápido que ..."? Debería intentar responder eso antes de proporcionar esta solución o proporcionarlo en un comentario.
9

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

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

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

¿Por qué se pierde la precisión adicional cuando agrega 0.1f? Porque los números de coma flotante solo tienen tantos dígitos significativos. Digamos que tiene suficiente almacenamiento para tres dígitos significativos, entonces 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 0.10001.

En resumen, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f;no es el no-op que crees que es.

Mystical también dijo esto : el contenido de los flotadores es importante, no solo el código de ensamblaje.

remcycles
fuente