Gran diferencia de rendimiento (26 veces más rápido) al compilar para 32 y 64 bits

80

Yo estaba tratando de medir la diferencia de utilizar una fory foreachcuando se accede a las listas de los tipos de valor y tipos de referencia.

Usé la siguiente clase para hacer el perfil.

public static class Benchmarker
{
    public static void Profile(string description, int iterations, Action func)
    {
        Console.Write(description);

        // Warm up
        func();

        Stopwatch watch = new Stopwatch();

        // Clean up
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        watch.Start();
        for (int i = 0; i < iterations; i++)
        {
            func();
        }
        watch.Stop();

        Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations);
    }
}

Usé doublepara mi tipo de valor. Y creé esta 'clase falsa' para probar tipos de referencia:

class DoubleWrapper
{
    public double Value { get; set; }

    public DoubleWrapper(double value)
    {
        Value = value;
    }
}

Finalmente ejecuté este código y comparé las diferencias de tiempo.

static void Main(string[] args)
{
    int size = 1000000;
    int iterationCount = 100;

    var valueList = new List<double>(size);
    for (int i = 0; i < size; i++) 
        valueList.Add(i);

    var refList = new List<DoubleWrapper>(size);
    for (int i = 0; i < size; i++) 
        refList.Add(new DoubleWrapper(i));

    double dummy;

    Benchmarker.Profile("valueList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < valueList.Count; i++)
        {
             unchecked
             {
                 var temp = valueList[i];
                 result *= temp;
                 result += temp;
                 result /= temp;
                 result -= temp;
             }
        }
        dummy = result;
    });

    Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in valueList)
        {
            var temp = v;
            result *= temp;
            result += temp;
            result /= temp;
            result -= temp;
        }
        dummy = result;
    });

    Benchmarker.Profile("refList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < refList.Count; i++)
        {
            unchecked
            {
                var temp = refList[i].Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }
        dummy = result;
    });

    Benchmarker.Profile("refList foreach: ", iterationCount, () =>
    {
        double result = 0;
        foreach (var v in refList)
        {
            unchecked
            {
                var temp = v.Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }

        dummy = result;
    });

    SafeExit();
}

Seleccioné Releasey Any CPUopciones, ejecuté el programa y obtuve los siguientes tiempos:

valueList for:  average time: 483,967938 ms
valueList foreach:  average time: 477,873079 ms
refList for:  average time: 490,524197 ms
refList foreach:  average time: 485,659557 ms
Done!

Luego seleccioné las opciones Release y x64, ejecuté el programa y obtuve los siguientes tiempos:

valueList for:  average time: 16,720209 ms
valueList foreach:  average time: 15,953483 ms
refList for:  average time: 19,381077 ms
refList foreach:  average time: 18,636781 ms
Done!

¿Por qué la versión x64 bits es mucho más rápida? Esperaba alguna diferencia, pero no algo tan grande.

No tengo acceso a otras computadoras. ¿Podría ejecutar esto en sus máquinas y decirme los resultados? Estoy usando Visual Studio 2015 y tengo un Intel Core i7 930.

Este es el SafeExit()método, para que pueda compilarlo / ejecutarlo usted mismo:

private static void SafeExit()
{
    Console.WriteLine("Done!");
    Console.ReadLine();
    System.Environment.Exit(1);
}

Según lo solicitado, usando en double?lugar de miDoubleWrapper :

Cualquier CPU

valueList for:  average time: 482,98116 ms
valueList foreach:  average time: 478,837701 ms
refList for:  average time: 491,075915 ms
refList foreach:  average time: 483,206072 ms
Done!

x64

valueList for:  average time: 16,393947 ms
valueList foreach:  average time: 15,87007 ms
refList for:  average time: 18,267736 ms
refList foreach:  average time: 16,496038 ms
Done!

Por último, pero no menos importante: la creación de un x86perfil me da casi los mismos resultados de usoAny CPU .

Trauer
fuente
14
"Cualquier CPU"! = "32Bits"! Si se compila "Cualquier CPU", su aplicación debería ejecutarse como un proceso de 64 bits en su sistema de 64 bits. También eliminaría el código que se mete con el GC. En realidad, no ayuda.
Thorsten Dittmar
9
@ThorstenDittmar las llamadas de GC son anteriores a la medición, en lugar de en el código medido. Eso es lo suficientemente razonable para reducir el grado en que la suerte de la sincronización de GC puede afectar dicha medición. Además, hay "favor de 32 bits" frente a "favor de 64 bits" como un factor entre las compilaciones.
Jon Hanna
1
@ThorstenDittmar Pero ejecuto la versión de lanzamiento (fuera de Visual Studio) y el Administrador de tareas dice que es una aplicación de 32 bits (cuando se compila en cualquier CPU). También. Como dijo Jon Hanna, la llamada a GC es útil.
Trauer
2
¿Qué versión de tiempo de ejecución estás usando? El nuevo RyuJIT en 4.6 es mucho más rápido, pero incluso para versiones anteriores, el compilador x64 y JITer eran más nuevos y avanzados que las versiones x32. Pueden realizar optimizaciones mucho más agresivas que las versiones x86.
Panagiotis Kanavos
2
Me gustaría señalar que el tipo involucrado parece no tener ningún efecto; cambie doublea float, longo inty obtendrá resultados similares.
Jon Hanna

Respuestas:

87

Puedo reproducir esto en 4.5.2. No hay RyuJIT aquí. Tanto los desmontajes x86 como los x64 parecen razonables. Las comprobaciones de rango y demás son las mismas. La misma estructura básica. No se desenrolla el bucle.

x86 usa un conjunto diferente de instrucciones flotantes. El rendimiento de estas instrucciones parece ser comparable con las instrucciones x64 excepto por la división :

  1. Las instrucciones flotantes x87 de 32 bits utilizan una precisión de 10 bytes internamente.
  2. La división de precisión extendida es muy lenta.

La operación de división hace que la versión de 32 bits sea extremadamente lenta. Eliminar el comentario de la división iguala el rendimiento en gran medida (32 bits por debajo de 430 ms a 3,25 ms).

Peter Cordes señala que las latencias de instrucción de las dos unidades de coma flotante no son tan diferentes. Quizás algunos de los resultados intermedios sean números desnormalizados o NaN. Estos pueden desencadenar un camino lento en una de las unidades. O tal vez los valores diverjan entre las dos implementaciones debido a una precisión flotante de 10 bytes frente a 8 bytes.

Peter Cordes también señala que todos los resultados intermedios son NaN ... Eliminar este problema (de valueList.Add(i + 1)modo que ningún divisor sea cero) en su mayoría iguala los resultados. Aparentemente, al código de 32 bits no le gustan los operandos NaN en absoluto. Vamos a imprimir algunos valores intermedios: if (i % 1000 == 0) Console.WriteLine(result);. Esto confirma que los datos ahora son cuerdos.

Al realizar una evaluación comparativa, debe comparar una carga de trabajo realista. ¡¿Pero quién hubiera pensado que una división inocente puede estropear su punto de referencia ?!

Intente simplemente sumar los números para obtener un mejor punto de referencia.

La división y el módulo son siempre muy lentos. Si modifica el BCLDictionary código para que simplemente no use el operador de módulo para calcular el índice de cubeta, el rendimiento medible mejora. Así de lenta es la división.

Aquí está el código de 32 bits:

ingrese la descripción de la imagen aquí

Código de 64 bits (misma estructura, división rápida):

ingrese la descripción de la imagen aquí

Esto no está vectorizado a pesar de que se utilizan instrucciones SSE.

usr
fuente
11
"¿Quién hubiera pensado que una división inocente puede estropear su punto de referencia?" Lo hice, de inmediato tan pronto como vi una división en el bucle interno, esp. como parte de la cadena de dependencia. La división solo es inocente cuando es una división entera por una potencia de 2. De agner.org/optimize insn tables: Nehalem fdivtiene una latencia de 7-27 ciclos (y el mismo rendimiento recíproco). divsdes de 7 a 22 ciclos. addsdcon latencia 3c, rendimiento 1 / c. La división es la única unidad de ejecución no canalizada en las CPU Intel / AMD. C # JIT no está vectorizando el bucle para x86-64 (con divPd).
Peter Cordes
1
Además, ¿es normal que 32b C # no use matemáticas SSE? ¿No es posible usar las funciones de la máquina actual como parte del punto de JIT? Entonces, en Haswell y versiones posteriores, podría vectorizar automáticamente bucles enteros con 256b AVX2, en lugar de solo SSE. Para obtener la vectorización de bucles FP, supongo que tendrías que escribirlos con cosas como 4 acumuladores en paralelo, ya que las matemáticas FP no son asociativas. Pero de todos modos, usar SSE en modo de 32 bits es más rápido, porque tiene menos instrucciones para hacer el mismo trabajo escalar cuando no tiene que hacer malabarismos con la pila FP x87.
Peter Cordes
4
De todos modos, div es muy lento, pero 10B x87 fdiv no es mucho más lento que 8B SSE2, por lo que esto no explica la diferencia entre x86 y x86-64. Lo que podría explicarlo son las excepciones de FPU o ralentizaciones con desnormales / infinitos. La palabra de control x87 FPU está separada del registro de control de excepción / redondeo SSE ( MXCSR). El manejo diferente de desnormales NaNos podría explicar el factor de 26 perf diff. C # puede establecer denormals-are-zero en MXCSR.
Peter Cordes
2
@Trauer y usr: Me acabo de dar cuenta de que valueList[i] = i, a partir de i=0, la primera iteración de bucle lo hace 0.0 / 0.0. Por lo tanto, cada operación en todo su punto de referencia se realiza con NaNs. ¡Esa división parece cada vez menos inocente! No soy un experto en rendimiento con NaNs, o la diferencia entre x87 y SSE para esto, pero creo que esto explica la diferencia de rendimiento de 26x. Apuesto a que sus resultados estarán mucho más cerca entre 32 y 64 bits si inicializa valueList[i] = i+1.
Peter Cordes
1
En cuanto a flush-to-zero, no me gusta demasiado con el doble de 64 bits, pero cuando se usan juntos 80 bits extendidos y 64 bits dobles, situaciones en las que un valor de 80 bits podría desbordar y luego escalar lo suficiente producir un valor que se pueda representar como 64 bits doublesería bastante raro. Uno de los principales patrones de uso del tipo de 80 bits era permitir que se sumen varios números sin tener que redondear los resultados hasta el final. Bajo ese patrón, los desbordamientos simplemente no son un problema.
supercat
31

valueList[i] = i, a partir de i=0, por lo que lo hace la primera iteración del ciclo 0.0 / 0.0. De modo que todas las operaciones de todo su banco de pruebas se realizan conNaN s.

Como @usr mostró en la salida de desmontaje , la versión de 32 bits usaba punto flotante x87, mientras que 64 bits usaba punto flotante SSE.

No soy un experto en rendimiento con NaNs, o la diferencia entre x87 y SSE para esto, pero creo que esto explica la diferencia de rendimiento de 26x. Apuesto a que sus resultados estarán mucho más cerca entre 32 y 64 bits si inicializa valueList[i] = i+1. (actualización: usr confirmó que esto hizo que el rendimiento de 32 y 64 bits fuera bastante cercano).

La división es muy lenta en comparación con otras operaciones. Vea mis comentarios sobre la respuesta de @usr. También vea http://agner.org/optimize/ para conocer toneladas de cosas geniales sobre hardware y optimización de asm y C / C ++, algunas de ellas relevantes para C #. Tiene tablas de instrucciones de latencia y rendimiento para la mayoría de las instrucciones para todas las CPU x86 recientes.

Sin embargo, 10B x87 fdivno es mucho más lento que la doble precisión 8B de SSE2 divsd, para valores normales. IDK sobre diferencias de rendimiento con NaN, infinitos o desnormales.

Sin embargo, tienen diferentes controles para lo que sucede con los NaN y otras excepciones de FPU. La palabra de control x87 FPU está separada del registro de control de excepciones / redondeo SSE (MXCSR). Si x87 obtiene una excepción de CPU para cada división, pero SSE no, eso explica fácilmente el factor de 26. O tal vez solo hay una diferencia de rendimiento tan grande cuando se manejan NaN. El hardware no está optimizado para funcionar NaNdespués NaN.

IDK si los controles SSE para evitar ralentizaciones con desnormals entrarán en juego aquí, ya que creo resultque será NaNtodo el tiempo. IDK si C # establece la marca denormals-are-zero en el MXCSR, o la marca flush-to-zero-flag (que escribe ceros en primer lugar, en lugar de tratar las denormales como cero cuando se lee).

Encontré un artículo de Intel sobre los controles de punto flotante SSE, que lo contrastaba con la palabra de control x87 FPU. Sin embargo, no tiene mucho que decir NaN. Termina con esto:

Conclusión

Para evitar problemas de serialización y rendimiento debido a números desnormales y de subdesbordamiento, use las instrucciones SSE y SSE2 para configurar los modos Flush-to-Zero y Denormals-Are-Zero dentro del hardware para permitir el mayor rendimiento para aplicaciones de punto flotante.

IDK si esto ayuda a alguno con la división por cero.

for vs foreach

Puede ser interesante probar un cuerpo de bucle con un rendimiento limitado, en lugar de ser una sola cadena de dependencia llevada por un bucle. Como está, todo el trabajo depende de los resultados anteriores; no hay nada que la CPU pueda hacer en paralelo (aparte de los límites, verifique la siguiente carga de la matriz mientras se ejecuta la cadena mul / div).

Es posible que vea más diferencias entre los métodos si el "trabajo real" ocupaba más recursos de ejecución de la CPU. Además, en Intel anterior a Sandybridge, hay una gran diferencia entre un ajuste de bucle en el búfer de bucle de 28uop o no. Obtienes cuellos de botella de decodificación de instrucciones si no, especialmente. cuando la duración promedio de la instrucción es mayor (lo que sucede con SSE). Las instrucciones que decodifican a más de un uop también limitarán el rendimiento del decodificador, a menos que tengan un patrón que sea bueno para los decodificadores (por ejemplo, 2-1-1). Por lo tanto, un bucle con más instrucciones de sobrecarga de bucle puede marcar la diferencia entre un bucle que se ajusta en el caché uop de 28 entradas o no, lo cual es un gran problema en Nehalem y, a veces, útil en Sandybridge y versiones posteriores.

Peter Cordes
fuente
Nunca he tenido un caso en el que observe alguna diferencia de rendimiento en función de si los NaN estaban en mi flujo de datos, pero la presencia de números desnormalizados puede marcar una gran diferencia en el rendimiento. No parece ser el caso en este ejemplo, pero es algo a tener en cuenta.
Jason R
@JasonR: ¿Es eso solo porque NaNlos correos electrónicos son realmente raros en la práctica? Dejé todo el material sobre desnormals y el enlace al material de Intel, principalmente para beneficio de los lectores, no porque pensara que realmente tendría mucho efecto en este caso específico.
Peter Cordes
En la mayoría de las aplicaciones son raras. Sin embargo, al desarrollar un nuevo software que utiliza punto flotante, no es raro que los errores de implementación produzcan flujos de NaN en lugar de los resultados deseados. Esto se me ha ocurrido muchas veces y no recuerdo ningún impacto notable en el rendimiento cuando aparecen los NaN. He observado lo contrario si hago algo que provoca la aparición de desnormales; que normalmente resulta en una caída del rendimiento que se percibe inmediatamente. Tenga en cuenta que estos se basan solo en mi experiencia anecdótica; puede haber una caída en el rendimiento con los NaN que simplemente no he notado.
Jason R
@JasonR: IDK, tal vez los NaN no sean mucho más lentos con SSE. Claramente, son un gran problema para x87. La semántica SSE FP fue diseñada por Intel en los días de PII / PIII. Esas CPU tienen la misma maquinaria fuera de servicio debajo del capó que los diseños actuales, por lo que presumiblemente tenían en mente un alto rendimiento para P6 al diseñar SSE. (Sí, Skylake se basa en la microarquitectura P6. Algunas cosas han cambiado, pero aún decodifica a uops y los programa en puertos de ejecución con un búfer de reorden). La semántica x87 se diseñó para un chip coprocesador externo opcional para una CPU escalar en orden.
Peter Cordes
@PeterCordes Llamar a Skylake como un chip basado en P6 es demasiado. 1) La FPU fue (casi) totalmente rediseñada durante la era de Sandy Bridge, por lo que la antigua FPU P6 básicamente ha llegado tan lejos como hoy; 2) la decodificación de x86 a uop tuvo una modificación crítica durante la era Core2: mientras que los diseños anteriores decodificaban la instrucción de computación y memoria como uops separados, el chip Core2 + tiene uops que consisten en una instrucción de computación y un operador de memoria. Esto llevó a un rendimiento y una eficiencia energética mucho mayores, a costa de un diseño más complejo y una frecuencia de pico potencialmente más baja.
shodanshok
1

Tenemos la observación de que el 99,9% de todas las operaciones de punto flotante involucrarán NaN, lo cual es al menos muy inusual (encontrado por Peter Cordes primero). Tenemos otro experimento de usr, que encontró que eliminar las instrucciones de división hace que la diferencia de tiempo desaparezca casi por completo.

Sin embargo, el hecho es que los NaN solo se generan porque la primera división calcula 0.0 / 0.0 que da el NaN inicial. Si no se realizan las divisiones, el resultado siempre será 0.0, y siempre calcularemos 0.0 * temp -> 0.0, 0.0 + temp -> temp, temp - temp = 0.0. Entonces, eliminar la división no solo eliminó las divisiones, sino que también eliminó las NaN. Esperaría que los NaN sean en realidad el problema, y ​​que una implementación maneje los NaN muy lentamente, mientras que la otra no tiene el problema.

Valdría la pena comenzar el ciclo en i = 1 y medir nuevamente. Las cuatro operaciones dan como resultado * temp, + temp, / temp, - temp efectivamente suma (1 - temp) para que no tengamos números inusuales (0, infinito, NaN) para la mayoría de las operaciones.

El único problema podría ser que la división siempre da un resultado entero, y algunas implementaciones de división tienen atajos cuando el resultado correcto no usa muchos bits. Por ejemplo, dividir 310.0 / 31.0 da 10.0 como los primeros cuatro bits con un resto de 0.0, y algunas implementaciones pueden dejar de evaluar los 50 bits restantes, mientras que otras no. Si hay una diferencia significativa, entonces comenzar el ciclo con resultado = 1.0 / 3.0 marcaría la diferencia.

gnasher729
fuente
-2

Puede haber varias razones por las que esto se está ejecutando más rápido en 64 bits en su máquina. La razón por la que le pregunté qué CPU estaba usando fue porque cuando aparecieron por primera vez las CPU de 64 bits, AMD e Intel tenían diferentes mecanismos para manejar el código de 64 bits.

Arquitectura del procesador:

La arquitectura de la CPU de Intel era puramente de 64 bits. Para ejecutar código de 32 bits, las instrucciones de 32 bits debían convertirse (dentro de la CPU) a instrucciones de 64 bits antes de su ejecución.

La arquitectura de la CPU de AMD debía construir 64 bits sobre su arquitectura de 32 bits; es decir, era esencialmente una arquitectura de 32 bits con extensiones de 64 bits; no había proceso de conversión de código.

Obviamente, esto fue hace unos años, así que no tengo idea de si la tecnología ha cambiado o cómo ha cambiado, pero en esencia, esperaría que el código de 64 bits funcione mejor en una máquina de 64 bits, ya que la CPU puede funcionar con el doble de bits por instrucción.

.NET JIT

Se argumenta que .NET (y otros lenguajes administrados como Java) son capaces de superar a lenguajes como C ++ debido a la forma en que el compilador JIT puede optimizar su código de acuerdo con la arquitectura de su procesador. A este respecto, es posible que descubra que el compilador JIT está utilizando algo en la arquitectura de 64 bits que posiblemente no estaba disponible o requería una solución alternativa cuando se ejecutaba en 32 bits.

Nota:

En lugar de usar DoubleWrapper, ¿ha considerado usar una Nullable<double>sintaxis abreviada: double?- Me interesaría ver si eso tiene algún impacto en sus pruebas.

Nota 2: Algunas personas parecen estar combinando mis comentarios sobre la arquitectura de 64 bits con IA-64. Solo para aclarar, en mi respuesta, 64 bits se refiere a x86-64 y 32 bits se refiere a x86-32. ¡Nada aquí hace referencia a IA-64!

Matthew Layton
fuente
4
Bien, entonces, ¿por qué es 26 veces más rápido? No puedo encontrar esto en la respuesta.
usr
2
Supongo que son las diferencias de nerviosismo, pero no más que adivinar.
Jon Hanna
2
@seriesOne: Creo que MSalters está tratando de decir que está mezclando IA-64 con x86-64. (Intel también usa IA-32e para x86-64, en sus manuales). Las CPU de escritorio de todos son x86-64. El Itanic se hundió hace unos años, y creo que se usó principalmente en servidores, no en estaciones de trabajo. Core2 (la primera CPU de la familia P6 que admite el modo largo x86-64) en realidad tiene algunas limitaciones en el modo de 64 bits. Por ejemplo, la macro-fusión uop solo funciona en modo de 32 bits. Intel y AMD hicieron lo mismo: ampliaron sus diseños de 32 bits a 64 bits.
Peter Cordes
1
@PeterCordes, ¿dónde mencioné IA-64? Soy consciente de que las CPU Itanium tenían un diseño y un conjunto de instrucciones completamente diferentes; primeros modelos etiquetados como EPIC o Computación de instrucción explícitamente paralela. Creo que MSalters está combinando 64 bits e IA-64. Mi respuesta es válida para la arquitectura x86-64: no había nada allí que hiciera referencia a la familia de CPU Itanium
Matthew Layton
2
@ series0ne: Ok, entonces su párrafo acerca de que las CPUs Intel son "puramente de 64 bits" es una completa tontería. Supuse que estabas pensando en IA-64 porque entonces no estarías completamente equivocado. Nunca hubo un paso de traducción adicional para ejecutar código de 32 bits. Los decodificadores x86-> uop solo tienen dos modos similares: x86 y x86-64. Intel construyó el P4 de 64 bits sobre el P4. El Core2 de 64 bits vino con muchas otras mejoras arquitectónicas sobre Core y Pentium M, pero cosas como la macro fusión que solo funciona en modo de 32 bits muestran que 64 bits estaba atornillado. (bastante temprano en el proceso de diseño, pero aún así.)
Peter Cordes