Yo estaba tratando de medir la diferencia de utilizar una for
y foreach
cuando 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é double
para 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é Release
y Any CPU
opciones, 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 x86
perfil me da casi los mismos resultados de usoAny CPU
.
fuente
double
afloat
,long
oint
y obtendrá resultados similares.Respuestas:
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 :
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 BCL
Dictionary
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:
Código de 64 bits (misma estructura, división rápida):
Esto no está vectorizado a pesar de que se utilizan instrucciones SSE.
fuente
fdiv
tiene una latencia de 7-27 ciclos (y el mismo rendimiento recíproco).divsd
es de 7 a 22 ciclos.addsd
con 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 (condivPd
).MXCSR
). El manejo diferente de desnormalesNaN
os podría explicar el factor de 26 perf diff. C # puede establecer denormals-are-zero en MXCSR.valueList[i] = i
, a partir dei=0
, la primera iteración de bucle lo hace0.0 / 0.0
. Por lo tanto, cada operación en todo su punto de referencia se realiza conNaN
s. ¡Esa división parece cada vez menos inocente! No soy un experto en rendimiento conNaN
s, 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 inicializavalueList[i] = i+1
.double
serí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.valueList[i] = i
, a partir dei=0
, por lo que lo hace la primera iteración del ciclo0.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
NaN
s, 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 inicializavalueList[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
fdiv
no es mucho más lento que la doble precisión 8B de SSE2divsd
, 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
NaN
despuésNaN
.IDK si los controles SSE para evitar ralentizaciones con desnormals entrarán en juego aquí, ya que creo
result
que seráNaN
todo 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: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.
fuente
NaN
los 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.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.
fuente
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!
fuente