c# - rapido - mejorar el rendimiento de visual studio 2017
Gran diferencia de rendimiento(26 veces más rápido) al compilar para 32 y 64 bits (4)
Estaba tratando de medir la diferencia de usar un
for
y un
foreach
al acceder a listas de tipos de valores 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é
double
para mi tipo de valor.
Y creé esta ''clase falsa'' para probar los 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é las opciones
Release
y
Any CPU
, ejecuté el programa y obtuve los siguientes horarios:
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 horarios:
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 bit es mucho más rápida? Esperaba alguna diferencia, pero no algo tan grande.
No tengo acceso a otras computadoras. ¿Podría por favor ejecutar esto en sus máquinas y decirme los resultados? Estoy usando Visual Studio 2015 y tengo un Intel Core i7 930.
Aquí está el método
SafeExit()
, para que pueda compilar / ejecutar usted mismo:
private static void SafeExit()
{
Console.WriteLine("Done!");
Console.ReadLine();
System.Environment.Exit(1);
}
Según lo solicitado, utilizando
double?
en lugar de mi
DoubleWrapper
:
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: crear un perfil
x86
me da casi los mismos resultados de usar
Any CPU
.
Puede haber varias razones por las cuales esto se ejecuta más rápido en 64 bits en su máquina. La razón por la que le pregunté qué CPU estaba usando era porque cuando las CPU de 64 bits aparecieron por primera vez, 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 el código de 32 bits, las instrucciones de 32 bits debían convertirse (dentro de la CPU) en instrucciones de 64 bits antes de la ejecución.
La arquitectura de CPU de AMD fue construir 64 bits justo encima de 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 / cómo ha cambiado la tecnología, pero esencialmente, esperaría que el código de 64 bits funcione mejor en una máquina de 64 bits ya que la CPU puede trabajar con el doble de la cantidad de bits por instrucción.
.NET JIT
Se argumenta que .NET (y otros lenguajes administrados como Java) son capaces de superar a los 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 el compilador JIT esté utilizando algo en la arquitectura de 64 bits que posiblemente no estaba disponible o requirió una solución alternativa cuando se ejecutó en 32 bits.
Nota:
En lugar de usar DoubleWrapper, ¿ha considerado usar
Nullable<double>
o 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. ¡Aquí nada hace referencia a IA-64!
Puedo reproducir esto en 4.5.2. No RyuJIT aquí. Los desmontajes x86 y x64 parecen razonables. Las comprobaciones de rango, etc., 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 :
- Las instrucciones flotantes x87 de 32 bits utilizan una precisión de 10 bytes internamente.
- La división de precisión extendida es súper lenta.
La operación de división hace que la versión de 32 bits sea extremadamente lenta. Descomentar 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 son números desnormalizados o NaN. Estos pueden desencadenar un camino lento en una de las unidades. O tal vez los valores divergen entre las dos implementaciones debido a la precisión de flotación de 10 bytes frente a 8 bytes.
Peter Cordes
también señala que
todos
los resultados intermedios son NaN
... Al eliminar este problema (
valueList.Add(i + 1)
para que ningún divisor sea cero), la mayoría de los resultados se igualan.
Aparentemente, al código de 32 bits no le gustan los operandos de NaN.
Imprimamos algunos valores intermedios:
if (i % 1000 == 0) Console.WriteLine(result);
.
Esto confirma que los datos ahora son sanos.
Al realizar una evaluación comparativa, debe comparar una carga de trabajo realista. ¿Pero quién hubiera pensado que una división inocente puede arruinar 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 código del
Dictionary
BCL para simplemente no usar el operador de módulo para calcular el rendimiento del índice de depósito, se pueden mejorar las mejoras mensurables.
Así de lenta es la división.
Aquí está el código de 32 bits:
Código de 64 bits (misma estructura, división rápida):
Esto no está vectorizado a pesar de las instrucciones SSE que se utilizan.
Tenemos la observación de que el 99.9% de todas las operaciones de punto flotante involucrarán NaN, lo cual es al menos altamente inusual (encontrado por Peter Cordes primero). Tenemos otro experimento por usr, que descubrió que eliminar las instrucciones de división hace que la diferencia horaria 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ó los NaN. Esperaría que los NaN sean realmente 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 resultan * temp, + temp, / temp, - temp efectivamente suman (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 otros no. Si hay una diferencia significativa, entonces comenzar el ciclo con resultado = 1.0 / 3.0 marcaría la diferencia.
valueList[i] = i
, comenzando desde
i=0
, por lo que la primera iteración del bucle es
0.0 / 0.0
.
Por lo tanto, cada operación en todo su punto de referencia se realiza con
NaN
s.
Como mostró @usr en la salida de desensamblaje , 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
NaN
s, o la diferencia entre x87 y SSE para esto, pero creo que esto explica la diferencia de rendimiento 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. Consulte también agner.org/optimize para obtener toneladas de excelentes cosas sobre hardware y la 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
fdiv
no es mucho más lento que el
divsd
doble precisión 8B de SSE2, para valores normales.
IDK sobre las diferencias de rendimiento con NaN, infinitos o denormales.
Sin embargo, tienen diferentes controles para lo que sucede con NaN y otras excepciones de FPU.
La
palabra de control x87 FPU
es independiente del registro de control de excepción / 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 NaNs.
El hardware
no
está optimizado para moverse a través de
NaN
después de
NaN
.
IDK si el SSE controla para evitar ralentizaciones con denormales entrará en juego aquí, ya que creo que el
result
será
NaN
todo el tiempo.
IDK si C # establece el indicador denormals-are-zero en el MXCSR, o el indicador flush-to-zero (que escribe ceros en primer lugar, en lugar de tratar los denormals como cero cuando se lee de nuevo).
Encontré un
artículo de Intel
sobre controles de coma flotante SSE, que lo contrasta con la palabra de control x87 FPU.
Sin embargo, no tiene mucho que decir sobre
NaN
.
Termina con esto:
Conclusión
Para evitar problemas de serialización y rendimiento debido a los números denormales y de subflujo, 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 con la división por cero.
para vs foreach
Puede ser interesante probar un cuerpo de bucle que tiene un rendimiento limitado, en lugar de ser una sola cadena de dependencia transportada por un bucle. Tal como están las cosas, todo el trabajo depende de 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 está ejecutando la cadena mul / div).
Es posible que vea más diferencia entre los métodos si el "trabajo real" ocupara más recursos de ejecución de las CPU. Además, en Intel anterior a Sandybridge, hay una gran diferencia entre un ajuste de bucle en el búfer de bucle 28uop o no. Obtiene cuellos de botella de decodificación de instrucciones si no, esp. cuando la duración promedio de la instrucción es mayor (lo que sucede con SSE). Las instrucciones que decodifican a más de una uop también limitarán el rendimiento del decodificador, a menos que vengan en un patrón que sea agradable 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 ajuste de bucle en la caché uop de 28 entradas o no, lo cual es un gran problema en Nehalem y, a veces, es útil en Sandybridge y más adelante.