2009-08-18 9 views
52

Attualmente sto facendo alcune ottimizzazioni dell'ultima misura, principalmente per divertimento e apprendimento, e ho scoperto qualcosa che mi ha lasciato un paio di domande.Curiosità: Perché Expression <...> durante la compilazione viene eseguito più rapidamente di un metodo dinamico minimo?

lato, le questioni:

  1. Quando costruiscono un metodo in memoria attraverso l'uso di DynamicMethod, e utilizzare il debugger, non v'è alcun modo per me di passare nel codice assembly generato, quando vieweing il codice nella vista del disassemblatore? Il debugger sembra semplicemente scavalcare l'intero metodo per me
  2. Oppure, se ciò non è possibile, è possibile che io in qualche modo salvi il codice IL generato su disco come un assieme, in modo che possa ispezionarlo con Reflector?
  3. Perché la versione Expression<...> del mio metodo di aggiunta semplice (Int32 + Int32 => Int32) è più veloce di una versione minima di DynamicMethod?

Ecco un breve e completo programma che dimostra. Sul mio sistema, l'output è:

DynamicMethod: 887 ms 
Lambda: 1878 ms 
Method: 1969 ms 
Expression: 681 ms 

Mi aspettavo la lambda e chiamate di metodo ad avere valori più alti, ma la versione DynamicMethod è costantemente circa 30-50% più lento (variazioni probabilmente a causa di Windows e altri programmi). Qualcuno conosce la ragione?

Ecco il programma:

using System; 
using System.Linq.Expressions; 
using System.Reflection.Emit; 
using System.Diagnostics; 

namespace Sandbox 
{ 
    public class Program 
    { 
     public static void Main(String[] args) 
     { 
      DynamicMethod method = new DynamicMethod("TestMethod", 
       typeof(Int32), new Type[] { typeof(Int32), typeof(Int32) }); 
      var il = method.GetILGenerator(); 

      il.Emit(OpCodes.Ldarg_0); 
      il.Emit(OpCodes.Ldarg_1); 
      il.Emit(OpCodes.Add); 
      il.Emit(OpCodes.Ret); 

      Func<Int32, Int32, Int32> f1 = 
       (Func<Int32, Int32, Int32>)method.CreateDelegate(
        typeof(Func<Int32, Int32, Int32>)); 
      Func<Int32, Int32, Int32> f2 = (Int32 a, Int32 b) => a + b; 
      Func<Int32, Int32, Int32> f3 = Sum; 
      Expression<Func<Int32, Int32, Int32>> f4x = (a, b) => a + b; 
      Func<Int32, Int32, Int32> f4 = f4x.Compile(); 
      for (Int32 pass = 1; pass <= 2; pass++) 
      { 
       // Pass 1 just runs all the code without writing out anything 
       // to avoid JIT overhead influencing the results 
       Time(f1, "DynamicMethod", pass); 
       Time(f2, "Lambda", pass); 
       Time(f3, "Method", pass); 
       Time(f4, "Expression", pass); 
      } 
     } 

     private static void Time(Func<Int32, Int32, Int32> fn, 
      String name, Int32 pass) 
     { 
      Stopwatch sw = new Stopwatch(); 
      sw.Start(); 
      for (Int32 index = 0; index <= 100000000; index++) 
      { 
       Int32 result = fn(index, 1); 
      } 
      sw.Stop(); 
      if (pass == 2) 
       Debug.WriteLine(name + ": " + sw.ElapsedMilliseconds + " ms"); 
     } 

     private static Int32 Sum(Int32 a, Int32 b) 
     { 
      return a + b; 
     } 
    } 
} 
+1

Interessante domanda. Questo tipo di cose può essere risolto usando WinDebug e SOS. Ho pubblicato un passo alla volta di un'analoga analisi che ho fatto molte lune fa nel mio blog, http://blog.barrkel.com/2006/05/clr-tailcall-optimization-or-lack.html –

+0

Ho pensato che avrei dovuto eseguire il ping tu - ho scoperto come forzare JIT senza dover chiamare il metodo una volta. Utilizzare l'argomento del costruttore DynamicMethod 'restrictedSkipVisibility'. A seconda del contesto (sicurezza del codice), potrebbe non essere disponibile. –

+1

Davvero una buona domanda. Innanzitutto, per questo tipo di profilazione, userei una versione/Console - quindi il 'Debug.WriteLine' sembra fuori luogo; ma anche con 'Console.WriteLine' le mie statistiche sono simili: DynamicMethod: 630 ms Lambda: 561 ms Metodo: 553 ms Espressione: 360 ms Sto ancora cercando ... –

risposta

53

Il metodo creato tramite DynamicMethod passa attraverso due thunk, mentre il metodo creato tramite Expression<> non passa attraverso qualsiasi.

Ecco come funziona. Ecco la sequenza di chiamata per invocare fn(0, 1) nel metodo Time (I hard-coded gli argomenti a 0 e 1 per la facilità di debugging):

00cc032c 6a01   push 1   // 1 argument 
00cc032e 8bcf   mov  ecx,edi 
00cc0330 33d2   xor  edx,edx  // 0 argument 
00cc0332 8b410c   mov  eax,dword ptr [ecx+0Ch] 
00cc0335 8b4904   mov  ecx,dword ptr [ecx+4] 
00cc0338 ffd0   call eax // 1 arg on stack, two in edx, ecx 

Per la prima invocazione ho studiato, DynamicMethod, la linea call eax sale come so:

00cc0338 ffd0   call eax {003c2084} 
0:000> !u 003c2084 
Unmanaged code 
003c2084 51    push ecx 
003c2085 8bca   mov  ecx,edx 
003c2087 8b542408  mov  edx,dword ptr [esp+8] 
003c208b 8b442404  mov  eax,dword ptr [esp+4] 
003c208f 89442408  mov  dword ptr [esp+8],eax 
003c2093 58    pop  eax 
003c2094 83c404   add  esp,4 
003c2097 83c010   add  eax,10h 
003c209a ff20   jmp  dword ptr [eax] 

Sembra che stia facendo un po 'di swizzling dello stack per riorganizzare gli argomenti. Suppongo che sia dovuto alla differenza tra i delegati che usano l'argomento implicito 'questo' e quelli che non lo fanno.

che saltano alla fine si risolve in questo modo:

003c209a ff20   jmp  dword ptr [eax]  ds:0023:012f7edc=0098c098 
0098c098 e963403500  jmp  00ce0100 

Il resto del codice a 0098c098 si presenta come un tonfo JIT, la cui partenza ma ho riscritto con un jmp dopo il JIT. E 'solo dopo questo salto che si arriva a codice vero e proprio:

0:000> !u eip 
Normal JIT generated code 
DynamicClass.TestMethod(Int32, Int32) 
Begin 00ce0100, size 5 
>>> 00ce0100 03ca   add  ecx,edx 
00ce0102 8bc1   mov  eax,ecx 
00ce0104 c3    ret 

La sequenza di invocazione per il metodo creato tramite Expression<> è diverso - manca il codice pila swizzling. Qui si tratta, dal primo salto via eax:

00cc0338 ffd0   call eax {00ce00a8} 

0:000> !u eip 
Normal JIT generated code 
DynamicClass.lambda_method(System.Runtime.CompilerServices.ExecutionScope, Int32, Int32) 
Begin 00ce00a8, size b 
>>> 00ce00a8 8b442404  mov  eax,dword ptr [esp+4] 
00ce00ac 03d0   add  edx,eax 
00ce00ae 8bc2   mov  eax,edx 
00ce00b0 c20400   ret  4 

Ora, come ha fatto le cose si fanno in questo modo?

  1. Pila swizzling non era necessario (il primo argomento implicito dal delegato viene effettivamente utilizzato, cioè non come un delegato legato ad un metodo statico)
  2. JIT deve essere stato costretto dalla logica compilazione LINQ modo che il delegato ha tenuto l'indirizzo di destinazione reale piuttosto che falso.

Non so come il LINQ abbia forzato la JIT, ma so come forzare una JIT da solo, richiamando la funzione almeno una volta. AGGIORNAMENTO: Ho trovato un altro modo per forzare un JIT: utilizzare l'argomento restrictedSkipVisibility per il costruttore e passare true. Quindi, ecco il codice modificato che elimina pila swizzling utilizzando il 'questo' parametro implicito, e utilizza il costruttore alternativo per la precompilazione in modo che l'indirizzo legato è il vero indirizzo, piuttosto che il thunk:

using System; 
using System.Linq.Expressions; 
using System.Reflection.Emit; 
using System.Diagnostics; 

namespace Sandbox 
{ 
    public class Program 
    { 
     public static void Main(String[] args) 
     { 
      DynamicMethod method = new DynamicMethod("TestMethod", 
       typeof(Int32), new Type[] { typeof(object), typeof(Int32), 
       typeof(Int32) }, true); 
      var il = method.GetILGenerator(); 

      il.Emit(OpCodes.Ldarg_1); 
      il.Emit(OpCodes.Ldarg_2); 
      il.Emit(OpCodes.Add); 
      il.Emit(OpCodes.Ret); 

      Func<Int32, Int32, Int32> f1 = 
       (Func<Int32, Int32, Int32>)method.CreateDelegate(
        typeof(Func<Int32, Int32, Int32>), null); 
      Func<Int32, Int32, Int32> f2 = (Int32 a, Int32 b) => a + b; 
      Func<Int32, Int32, Int32> f3 = Sum; 
      Expression<Func<Int32, Int32, Int32>> f4x = (a, b) => a + b; 
      Func<Int32, Int32, Int32> f4 = f4x.Compile(); 
      for (Int32 pass = 1; pass <= 2; pass++) 
      { 
       // Pass 1 just runs all the code without writing out anything 
       // to avoid JIT overhead influencing the results 
       Time(f1, "DynamicMethod", pass); 
       Time(f2, "Lambda", pass); 
       Time(f3, "Method", pass); 
       Time(f4, "Expression", pass); 
      } 
     } 

     private static void Time(Func<Int32, Int32, Int32> fn, 
      String name, Int32 pass) 
     { 
      Stopwatch sw = new Stopwatch(); 
      sw.Start(); 
      for (Int32 index = 0; index <= 100000000; index++) 
      { 
       Int32 result = fn(index, 1); 
      } 
      sw.Stop(); 
      if (pass == 2) 
       Console.WriteLine(name + ": " + sw.ElapsedMilliseconds + " ms"); 
     } 

     private static Int32 Sum(Int32 a, Int32 b) 
     { 
      return a + b; 
     } 
    } 
} 

Ecco i tempi di esecuzione sul mio sistema:

DynamicMethod: 312 ms 
Lambda: 417 ms 
Method: 417 ms 
Expression: 312 ms 

aggiornato per aggiungere:

ho provato a eseguire il codice sul mio nuovo sistema, che è un core i7 920 con Windows 7 x64 con .NET 4 beta 2 installato (m scoree.dll ver. 4.0.30902), ei risultati sono, beh, variabili.

csc 3.5, /platform:x86, runtime v2.0.50727 (via .config) 

Run #1 
DynamicMethod: 214 ms 
Lambda: 571 ms 
Method: 570 ms 
Expression: 249 ms 

Run #2 
DynamicMethod: 463 ms 
Lambda: 392 ms 
Method: 392 ms 
Expression: 463 ms 

Run #3 
DynamicMethod: 463 ms 
Lambda: 570 ms 
Method: 570 ms 
Expression: 463 ms 

forse questo è Intel SpeedStep che influenzano i risultati, o forse Turbo Boost. In ogni caso, è molto fastidioso.

csc 3.5, /platform:x64, runtime v2.0.50727 (via .config) 
DynamicMethod: 428 ms 
Lambda: 392 ms 
Method: 392 ms 
Expression: 428 ms 

csc 3.5, /platform:x64, runtime v4 
DynamicMethod: 428 ms 
Lambda: 356 ms 
Method: 356 ms 
Expression: 428 ms 

csc 4, /platform:x64, runtime v4 
DynamicMethod: 428 ms 
Lambda: 356 ms 
Method: 356 ms 
Expression: 428 ms 

csc 4, /platform:x86, runtime v4 
DynamicMethod: 463 ms 
Lambda: 570 ms 
Method: 570 ms 
Expression: 463 ms 

csc 3.5, /platform:x86, runtime v4 
DynamicMethod: 214 ms 
Lambda: 570 ms 
Method: 571 ms 
Expression: 249 ms 

Molti di questi risultati saranno incidenti di tempi, qualunque essa sia che causa lo speedups casuali nello scenario v2.0 C# 3.5/runtime. Dovrò riavviare per vedere se SpeedStep o Turbo Boost è responsabile di questi effetti.

+0

Quindi questo significa che devo aggiungere un modo per invocare il mio metodo in modo sicuro, solo per ottenere quel miglioramento delle prestazioni? Posso certamente farlo. –

+1

Quello che voglio dire è ... i metodi che sto creando non sommeranno effettivamente due numeri, ma saranno responsabili della costruzione e della risoluzione dei servizi in un'implementazione IoC. In questo caso, non voglio veramente il metodo completo per eseguire e costruire un servizio, solo per ottenere un minore incremento delle prestazioni. Visto che alcuni servizi verranno utilizzati * molto *, e il servizio effettivo è minimo e leggero, sto mettendo un po 'di sforzo anche nell'effettivo codice di risoluzione. Inoltre, è un divertente progetto di apprendimento per reflection.emit. Apprezzo davvero il lavoro che hai inserito nella tua risposta! –

+4

Un'analisi affascinante e approfondita. Grazie –