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

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

ejecutar 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 -02 con sse2 activado. No lo he probado con otros compiladores.

preguntado el 16 de febrero de 12 a las 11:02

¿Cómo mediste la diferencia? ¿Y qué opciones usaste cuando compilaste? -

¿Por qué el compilador no simplemente elimina +/- 0 en este caso?!? -

@ Zyx2000 El compilador no es ni de lejos tan estúpido. Desmontar un ejemplo trivial en LINQPad muestra que escupe el mismo código ya sea que use 0, 0f, 0d, o incluso (int)0 en un contexto donde un double es necesario. -

¿cuál es el nivel de optimización? -

5 Respuestas

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

Los números desnormales (o subnormales) son una especie de truco para obtener algunos valores adicionales muy cercanos a cero fuera de la representación de punto flotante. Las operaciones en coma flotante desnormalizada se pueden decenas a cientos de veces más lento que en el 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 0 or 0.1 se utiliza.

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 números desnormalizados, si vaciar las desnormales 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 lento y en realidad se vuelve más rápido. (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 inferior a casi 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 número entero o un punto flotante. El 0 or 0.1f se convierte / almacena en un registro fuera de ambos bucles. Entonces eso no tiene ningún efecto sobre el rendimiento.

Respondido 17 Feb 12, 01:02

Todavía me resulta un poco extraño que el "+ 0" no esté completamente optimizado por el compilador de forma predeterminada. ¿Habría sucedido esto si hubiera puesto "+ 0.0f"? - s73v3r

@ s73v3r Esa es una muy buena pregunta. Ahora que miro la asamblea, ni siquiera + 0.0f se optimiza. Si tuviera que adivinar, podría ser que + 0.0f tendría efectos secundarios si y[i] pasó a ser una señalización NaN o algo ... aunque podría estar equivocado. - Mística

Los dobles todavía se encontrarán con el mismo problema en muchos casos, solo que en 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 onda cuadrada o CC de muy baja amplitud (no audible) para alejar los números de la desnormalidad. - russell borogove

@Isaac porque cuando y [i] es significativamente menor que 0.1, la suma da como resultado una pérdida de precisión porque el dígito más significativo del número se vuelve más alto. - Dan está jugando a la luz del fuego

@ 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. Entonces, agregar 0.f no es una operación de identidad y no se puede optimizar. - Eric Pospischil

Usar gcc y la aplicación de una diferencia al ensamblaje generado produce solo esta diferencia:

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

El cvtsi2ssq uno es 10 veces más lento.

Al parecer, el float la versión usa un XMM registro cargado desde la memoria, mientras que el int la versión convierte un real int valor 0 a float usando el cvtsi2ssq instrucción, que toma mucho tiempo. Paso -O3 a gcc no ayuda. (gcc versión 4.2.1.)

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

Noticias

Algunas pruebas adicionales muestran que no es necesariamente el cvtsi2ssq instrucción. Una vez eliminado (usando un int ai=0;float a=ai; y utilizando a en lugar 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 0 y 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.

Actualizar << 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 que el exponente (los últimos 9 bits) cambia 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 Stack Overflow ¿Punto flotante desnormalizado en Objective-C?.

contestado el 23 de mayo de 17 a las 13:05

-Ono lo soluciono, pero -ffast-math lo hace. (Lo uso todo el tiempo, en mi opinión, los casos de esquina en los que causa problemas de precisión no deberían aparecer en un programa diseñado correctamente de todos modos). izquierda

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-math vincula un código de inicio adicional que establece FTZ (al ras a cero) y DAZ (los desnormales son cero) en el MXCSR, por lo que la CPU nunca tiene que tomar una asistencia de microcódigo lento para los desnormales. - pedro cordes

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

  • Puede que no funcione en algunos entornos de GCC:

    // Requires #include <fenv.h>
    fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV);
    
  • Puede que no funcione 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 los desnormales de forma predeterminada en las CPU Intel modernas. Más detalles aquí

  • Modificadores del compilador. -ffast-math, -msse or -mfpmath=sse deshabilitará los desnormales y hará algunas otras cosas más rápido, pero desafortunadamente también hará muchas otras aproximaciones que podrían romper su código. ¡Prueba con cuidado! El equivalente de matemáticas rápidas para el compilador de Visual Studio es /fp:fast pero no he podido confirmar si esto también deshabilita los desnormales.1

Respondido 23 Jul 14, 16:07

Esto suena como una respuesta decente a una pregunta diferente pero relacionada (¿Cómo puedo evitar que los cálculos numéricos produzcan resultados desnormales?) 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 lo hacen. En Linux, gcc -ffast-math debería establecer un subdesbordamiento abrupto (pero creo que no en Windows). Se supone que los compiladores de Intel se inicializan en main () para que estas diferencias de sistema operativo no pasen, pero me han mordido y necesito configurarlo explícitamente en el programa. Se supone que las CPU de Intel que comienzan con Sandy Bridge manejan los subnormales que surgen al sumar / restar (pero no dividir / multiplicar) de manera eficiente, por lo que existe un caso para usar el subdesbordamiento gradual. - tim18

Microsoft / fp: fast (no es un valor predeterminado) no hace ninguna de las cosas agresivas inherentes a gcc -ffast-math o ICL (predeterminado) / fp: fast. Es más como ICL / fp: source. Por lo tanto, debe establecer / fp: (y, en algunos casos, el modo de subdesbordamiento) explícitamente si desea comparar estos compiladores. - tim18

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 conmutadores gcc: -msse -mfpmath = sse

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

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

Respondido 02 Oct 12, 05:10

Ver también fesetround() en fenv.h (definido para C99) para otra forma más portátil de redondeo (linux.die.net/man/3/fesetround) (pero esto afectaría a todas las operaciones de FP, no solo a los subnormales) - Germán García

¿Está seguro de que necesita 1 << 15 y 1 << 11 para FTZ? Solo he visto 1 << 15 citado en otro lugar ... - higo

@fig: 1 << 11 es para la máscara de desbordamiento. Más info aquí: softpixel.com/~cwright/programming/simd/sse.php - Germán García

@GermanGarcia esto no responde a la pregunta de los PO; la pregunta era "¿Por qué este fragmento de código se ejecuta 10 veces más rápido que ...?". Debería intentar responder a eso antes de proporcionar esta solución alternativa o proporcionarlo en un comentario. - usuario719662

Comentario de Dan Neely debería ampliarse a una respuesta:

No es la constante cero 0.0f que se desnormaliza o causa una ralentización, 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 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 ya no se necesita la desnormalización necesaria para representar esa precisión. Posteriormente, las operaciones de coma flotante en y[i] permanecen rápidos porque no están desnormalizados.

¿Por qué se pierde la precisión adicional cuando agrega 0.1f? Porque los números de coma flotante solo tienen una cantidad limitada de dígitos significativos. Supongamos que tiene suficiente espacio de 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 breve, y[i]=y[i]+0.1f; y[i]=y[i]-0.1f; no es la operación no operativa que podría pensar que es.

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

EDITAR: Para poner un punto más fino sobre esto, no todas las operaciones de punto flotante toman la misma cantidad de tiempo para ejecutarse, incluso si el código de operación de la máquina es el mismo. Para algunos operandos / entradas, la misma instrucción tardará más en ejecutarse. Esto es especialmente cierto para los números desnormales.

Respondido el 09 de Septiembre de 20 a las 20:09

No es la respuesta que estás buscando? Examinar otras preguntas etiquetadas or haz tu propia pregunta.