c# - remarks - Rendimiento de tuplas estructurales
remarks c# documentation (1)
¿Está ejecutando ambos ejemplos en la misma arquitectura. Obtengo ~ 1.4sec en x64 para los códigos F # y C # y ~ 0.6sec en x86 para F # y ~ 0.3sec en x86 para C #.
Como se dice cuando se descompilan los ensamblajes, el código se ve muy similar, pero aparecen algunas diferencias al examinar el código IL:
F # - let min (struct(a1, b1)) (struct(a2, b2)) ...
.maxstack 5
.locals init (
[0] int32 b1,
[1] int32 a1,
[2] int32 b2,
[3] int32 a2
)
IL_0000: ldarga.s _arg2
IL_0002: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0007: stloc.0
IL_0008: ldarga.s _arg2
IL_000a: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_000f: stloc.1
IL_0010: ldarga.s _arg1
IL_0012: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0017: stloc.2
IL_0018: ldarga.s _arg1
IL_001a: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_001f: stloc.3
IL_0020: nop
IL_0021: ldloc.1
IL_0022: ldloc.3
IL_0023: call int32 Program::min@8(int32, int32)
IL_0028: ldloc.0
IL_0029: ldloc.2
IL_002a: call int32 Program::min@8(int32, int32)
IL_002f: newobj instance void valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::.ctor(!0, !1)
IL_0034: ret
C # - MinPair
.maxstack 3
.locals init (
[0] int32 b,
[1] int32 b2,
[2] int32 a2
)
IL_0000: ldarg.0
IL_0001: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0006: stloc.0
IL_0007: ldarg.0
IL_0008: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_000d: ldarg.1
IL_000e: ldfld !1 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item2
IL_0013: stloc.1
IL_0014: ldarg.1
IL_0015: ldfld !0 valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::Item1
IL_001a: stloc.2
IL_001b: ldloc.2
IL_001c: call int32 PerfItCs.Program::MinInt(int32, int32)
IL_0021: ldloc.0
IL_0022: ldloc.1
IL_0023: call int32 PerfItCs.Program::MinInt(int32, int32)
IL_0028: newobj instance void valuetype [System.ValueTuple]System.ValueTuple`2<int32, int32>::.ctor(!0, !1)
IL_002d: ret
La diferencia aquí es que el compilador de C # evita introducir algunas variables locales presionando los resultados intermedios en la pila. Como las variables locales se asignan en la pila de todas formas, es difícil ver por qué esto debería llevar a un código más eficiente.
Las otras funciones son muy similares.
Desmontando el x86 se obtiene esto:
F # - el bucle
; F#
; struct (i, i)
01690a7e 8bce mov ecx,esi
01690a80 8bd6 mov edx,esi
; Loads x (pair) onto stack
01690a82 8d45f0 lea eax,[ebp-10h]
01690a85 83ec08 sub esp,8
01690a88 f30f7e00 movq xmm0,mmword ptr [eax]
01690a8c 660fd60424 movq mmword ptr [esp],xmm0
; Push new tuple on stack
01690a91 52 push edx
01690a92 51 push ecx
; Loads pointer to x into ecx (result will be written here)
01690a93 8d4df0 lea ecx,[ebp-10h]
; Call min
01690a96 ff15744dfe00 call dword ptr ds:[0FE4D74h]
; Increase i
01690a9c 46 inc esi
01690a9d 81fe01e1f505 cmp esi,offset FSharp_Core_ni+0x6be101 (05f5e101)
; Reached the end?
01690aa3 7cd9 jl 01690a7e
C # - el bucle
; C#
; Loads x (pair) into ecx, eax
02c2057b 8d55ec lea edx,[ebp-14h]
02c2057e 8b0a mov ecx,dword ptr [edx]
02c20580 8b4204 mov eax,dword ptr [edx+4]
; new System.ValueTuple<int, int>(i, i)
02c20583 8bfe mov edi,esi
02c20585 8bd6 mov edx,esi
; Push x on stack
02c20587 50 push eax
02c20588 51 push ecx
; Push new tuple on stack
02c20589 52 push edx
02c2058a 57 push edi
; Loads pointer to x into ecx (result will be written here)
02c2058b 8d4dec lea ecx,[ebp-14h]
; Call MinPair
02c2058e ff15104d2401 call dword ptr ds:[1244D10h]
; Increase i
02c20594 46 inc esi
; Reached the end?
02c20595 81fe00e1f505 cmp esi,5F5E100h
02c2059b 7ede jle 02c2057b
Es difícil entender por qué el código F # debería funcionar significativamente peor aquí. El código parece aproximadamente equivalente a la excepción de cómo se carga x
en la pila. Hasta que a alguien se le movq
una buena explicación de por qué voy a especular que es porque movq
tiene peor latencia que push
y como todas las instrucciones manipulan la pila, la CPU no puede reordenar las instrucciones para mitigar la latencia de movq
.
Por qué la fluctuación de fase eligió movq
para el código F # y no para el código C # que actualmente no conozco.
Para x64, el rendimiento parece empeorar debido a una mayor sobrecarga en los preludios de método y más estancamiento debido al aliasing. Esto es principalmente especulación de mi parte, pero es difícil ver en el código de ensamblaje lo que, excepto el bloqueo, podría reducir el rendimiento de x64 en un factor 4x.
Al marcar min
como inline, tanto x64 como x86 se ejecutan en ~ 0.15 seg. No es sorprendente, ya que eso elimina toda la sobrecarga de los preludios de métodos y un montón de lectura y escritura en la pila.
Marcar los métodos de F # para la alineación agresiva (con [MethodImpl (MethodImplOptions.AggressiveInlining)]
no funciona ya que el compilador de F # elimina todos los atributos, lo que significa que el jitter nunca lo ve, pero marcar los métodos de C # para la alineación agresiva hace que el código de C # se ejecute en ~ 0.15 seg.
Así que al final, el jitter x86 eligió, por alguna razón, jit el código de manera diferente a pesar de que el código IL es muy similar. Posiblemente los atributos en los métodos afectan el jitter ya que son un poco diferentes.
El jitter x64 probablemente podría hacer un mejor trabajo al empujar los parámetros en la pila de una manera más eficiente. Supongo que usar push
como el jitter x86 es preferible a mov
como la semántica de push
es más restringido pero eso es solo una especulación de mi parte.
En casos como este, cuando los métodos son baratos, marcarlos como en línea puede ser bueno.
Para ser honesto, no estoy seguro de que esto ayude a OP pero espero que haya sido algo interesante.
PD. Ejecuto el código en .NET 4.6.2 en un i5 3570K
El siguiente programa F # define una función que devuelve el menor de dos pares de ints representados como tuplas de estructura y se necesitan 1.4s para ejecutarse:
let [<EntryPoint>] main _ =
let min a b : int = if a < b then a else b
let min (struct(a1, b1)) (struct(a2, b2)) = struct(min a1 a2, min b1 b2)
let mutable x = struct(0, 0)
for i in 1..100000000 do
x <- min x (struct(i, i))
0
Si descompilo el CIL a C # obtengo este código:
public static int MinInt(int a, int b)
{
if (a < b)
{
return a;
}
return b;
}
public static System.ValueTuple<int, int> MinPair(System.ValueTuple<int, int> _arg2, System.ValueTuple<int, int> _arg1)
{
int b = _arg2.Item2;
int a = _arg2.Item1;
int b2 = _arg1.Item2;
int a2 = _arg1.Item1;
return new System.ValueTuple<int, int>(MinInt(a, a2), MinInt(b, b2));
}
public static void Main(string[] args)
{
System.ValueTuple<int, int> x = new System.ValueTuple<int, int>(0, 0);
for (int i = 1; i <= 100000000; i++)
{
x = MinPair(x, new System.ValueTuple<int, int>(i, i));
}
}
Recompilando eso con el compilador de C # toma solo 0.3s, que es 4 veces más rápido que el F # original.
No puedo ver por qué un programa es mucho más rápido que el otro. Incluso he descompilado ambas versiones en CIL y no puedo ver ninguna razón obvia. Al llamar a la función C # Min
desde F # se obtiene el mismo (pobre) rendimiento. El CIL del bucle interno de la persona que llama es literalmente idéntico.
¿Alguien puede explicar esta diferencia sustancial de rendimiento?