Prova a catturare accelerando il mio codice?


1505

Ho scritto del codice per testare l'impatto di try-catch, ma vedendo alcuni risultati sorprendenti.

static void Main(string[] args)
{
    Thread.CurrentThread.Priority = ThreadPriority.Highest;
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;

    long start = 0, stop = 0, elapsed = 0;
    double avg = 0.0;

    long temp = Fibo(1);

    for (int i = 1; i < 100000000; i++)
    {
        start = Stopwatch.GetTimestamp();
        temp = Fibo(100);
        stop = Stopwatch.GetTimestamp();

        elapsed = stop - start;
        avg = avg + ((double)elapsed - avg) / i;
    }

    Console.WriteLine("Elapsed: " + avg);
    Console.ReadKey();
}

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    for (int i = 1; i < n; i++)
    {
        n1 = n2;
        n2 = fibo;
        fibo = n1 + n2;
    }

    return fibo;
}

Sul mio computer, questo stampa costantemente un valore di circa 0,96 ..

Quando avvolgo il ciclo for all'interno di Fibo () con un blocco try-catch come questo:

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    try
    {
        for (int i = 1; i < n; i++)
        {
            n1 = n2;
            n2 = fibo;
            fibo = n1 + n2;
        }
    }
    catch {}

    return fibo;
}

Ora stampa costantemente 0,69 ... - in realtà funziona più velocemente! Ma perché?

Nota: l'ho compilato usando la configurazione Release e ho eseguito direttamente il file EXE (fuori da Visual Studio).

EDIT: L' eccellente analisi di Jon Skeet mostra che try-catch sta in qualche modo facendo sì che il CLR x86 utilizzi i registri della CPU in un modo più favorevole in questo caso specifico (e penso che dobbiamo ancora capire il perché). Ho confermato che Jon ha scoperto che x64 CLR non ha questa differenza e che era più veloce di x86 CLR. Ho anche provato usando i inttipi all'interno del metodo Fibo invece dei longtipi, e quindi il CLR x86 è stato altrettanto veloce del CLR x64.


AGGIORNAMENTO: sembra che questo problema sia stato risolto da Roslyn. Stessa macchina, stessa versione CLR - il problema rimane come sopra quando compilato con VS 2013, ma il problema scompare quando compilato con VS 2015.


111
@Lloyd cerca di ottenere una risposta alla sua domanda "in realtà corre più veloce! Ma perché?"
Andreas Niedermair,

137
Quindi, ora "Swallowing Exceptions" è passato da una cattiva pratica a una buona ottimizzazione delle prestazioni: P
Luciano,

2
È in un contesto aritmetico non controllato o controllato?
Casuale 832,

7
@ taras.roshko: Anche se non desidero fare un disservizio a Eric, questa non è in realtà una domanda C # - è una domanda del compilatore JIT. L'ultima difficoltà sta nel capire perché il JIT x86 non utilizza tanti registri senza il tentativo / catch come con il blocco try / catch.
Jon Skeet,

63
Dolce, quindi se annidiamo questi tentativi di cattura possiamo andare ancora più veloci, giusto?
Chuck Pinkert,

Risposte:


1053

Uno degli ingegneri di Roslyn specializzato nella comprensione dell'ottimizzazione dell'utilizzo dello stack ha dato un'occhiata a questo e mi ha riferito che sembra esserci un problema nell'interazione tra il modo in cui il compilatore C # genera archivi variabili locali e il modo in cui il compilatore JIT registra programmazione nel corrispondente codice x86. Il risultato è una generazione di codice non ottimale sui carichi e sui negozi dei locali.

Per qualche motivo non chiaro a tutti noi, il percorso problematico di generazione del codice viene evitato quando JITter sa che il blocco si trova in una regione protetta da tentativo.

Questo è piuttosto strano. Seguiremo il team di JITter e vedremo se è possibile inserire un bug in modo che possano risolvere questo problema.

Inoltre, stiamo lavorando su miglioramenti per Roslyn agli algoritmi dei compilatori C # e VB per determinare quando i locali possono essere resi "effimeri", ovvero semplicemente spinti e spuntati nello stack, anziché assegnare una posizione specifica nello stack per la durata dell'attivazione. Riteniamo che JITter sarà in grado di fare un lavoro migliore nell'allocazione dei registri e quant'altro se gli diamo migliori suggerimenti su quando i locali possono essere "morti" in precedenza.

Grazie per averlo segnalato alla nostra attenzione e ci scusiamo per il comportamento strano.


8
Mi sono sempre chiesto perché il compilatore C # generi così tanti locali estranei. Ad esempio, le nuove espressioni di inizializzazione di array generano sempre un locale, ma non è mai necessario generare un locale. Se consente a JITter di produrre codice misurabile in modo più performante, forse il compilatore C # dovrebbe essere un po 'più attento a generare locali non necessari ...
Timwi,

33
@Timwi: assolutamente. Nel codice non ottimizzato il compilatore produce locali non necessari con grande abbandono perché facilitano il debug. Nel codice ottimizzato, se possibile, i temporali non necessari devono essere rimossi. Sfortunatamente abbiamo avuto molti bug nel corso degli anni in cui abbiamo ottimizzato accidentalmente l'ottimizzatore di eliminazione temporanea. L'ingegnere di cui sopra sta rielaborando completamente da zero tutto questo codice per Roslyn e, di conseguenza, dovremmo migliorare notevolmente il comportamento ottimizzato nel generatore di codice Roslyn.
Eric Lippert,

24
C'è mai stato qualche movimento su questo tema?
Robert Harvey,

10
Sembra che Roslyn l'abbia corretto.
Eren Ersönmez,

56
Hai perso l'occasione di chiamarlo "bug JITter".
mbomb007,

734

Beh, il modo in cui cronometri le cose mi sembra piuttosto brutto. Sarebbe molto più sensato temporizzare l'intero ciclo:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

In questo modo non sei in balia di piccoli tempi, aritmetica in virgola mobile e errore accumulato.

Dopo aver apportato tale modifica, vedere se la versione "non catch" è ancora più lenta della versione "catch".

EDIT: Okay, l'ho provato da solo - e sto vedendo lo stesso risultato. Molto strano. Mi chiedevo se il tentativo / cattura disabilitasse un cattivo allineamento, ma l'utilizzo [MethodImpl(MethodImplOptions.NoInlining)]invece non ha aiutato ...

Fondamentalmente dovrai guardare il codice JITted ottimizzato sotto cordbg, sospetto ...

EDIT: qualche altra informazione:

  • Mettere il try / catch intorno alla n++;linea migliora ancora le prestazioni, ma non tanto quanto metterlo attorno all'intero blocco
  • Se si rileva un'eccezione specifica ( ArgumentExceptionnei miei test) è ancora veloce
  • Se si stampa l'eccezione nel blocco catch è ancora veloce
  • Se ripeti l'eccezione nel blocco catch è di nuovo lento
  • Se usi un blocco finally invece di un blocco catch, è di nuovo lento
  • Se usi un blocco finally e un blocco catch, è veloce

Strano...

EDIT: Okay, abbiamo lo smontaggio ...

Questo sta usando il compilatore C # 2 e .NET 2 (32-bit) CLR, disassemblando con mdbg (poiché non ho cordbg sulla mia macchina). Vedo ancora gli stessi effetti sulle prestazioni, anche sotto il debugger. La versione veloce utilizza un tryblocco attorno a tutto tra le dichiarazioni delle variabili e l'istruzione return, con solo un catch{}gestore. Ovviamente la versione lenta è la stessa tranne senza il try / catch. Il codice chiamante (ovvero Main) è lo stesso in entrambi i casi e ha la stessa rappresentazione di assembly (quindi non è un problema in linea).

Codice smontato per la versione veloce:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

Codice smontato per versione lenta:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

In ogni caso, *mostra dove il debugger è entrato in un semplice "step-into".

EDIT: Okay, ora ho controllato il codice e penso di poter vedere come funziona ogni versione ... e credo che la versione più lenta sia più lenta perché utilizza meno registri e più spazio nello stack. Per piccoli valori nè forse più veloce, ma quando il ciclo occupa gran parte del tempo, è più lento.

Forse il blocco try / catch forza il salvataggio e il ripristino di più registri, quindi JIT usa anche quelli per il loop ... il che migliora le prestazioni complessive. Non è chiaro se sia una decisione ragionevole per JIT non utilizzare altrettanti registri nel codice "normale".

EDIT: Ho appena provato questo sulla mia macchina x64. Il CLR x64 è molto più veloce (circa 3-4 volte più veloce) del CLR x86 su questo codice e in x64 il blocco try / catch non fa alcuna differenza evidente.


4
@GordonSimpson, ma nel caso in cui venga rilevata solo un'eccezione specifica, tutte le altre eccezioni non verrebbero rilevate, quindi sarebbe comunque necessario qualsiasi overhead coinvolto nella tua ipotesi di non-tentativo.
Jon Hanna,

45
Sembra una differenza nell'allocazione dei registri. La versione veloce riesce a utilizzare esi,ediper uno dei long anziché lo stack. Utilizza ebxcome contatore, dove utilizza la versione lenta esi.
Jeffrey Sax,

13
@JeffreySax: Non è solo che i registri sono utilizzati ma quanti. La versione lenta utilizza più spazio nello stack, toccando meno registri. Non ho idea del perché ...
Jon Skeet,

2
Come vengono gestiti i frame di eccezione CLR in termini di registri e stack? La creazione di uno potrebbe aver liberato un registro per usarlo in qualche modo?
Casuale 832

4
IIRC x64 ha più registri disponibili rispetto a x86. La velocità che hai visto sarebbe coerente con il tentativo / cattura forzando l'uso di un registro aggiuntivo in x86.
Dan è Fiddling By Firelight il

116

I disassemblaggi di Jon mostrano che la differenza tra le due versioni è che la versione veloce usa una coppia di registri ( esi,edi) per memorizzare una delle variabili locali dove la versione lenta non lo fa.

Il compilatore JIT fa ipotesi diverse riguardo all'uso del registro per il codice che contiene un blocco try-catch rispetto al codice che non lo fa. Ciò provoca diverse scelte di allocazione del registro. In questo caso, questo favorisce il codice con il blocco try-catch. Codice diverso può portare all'effetto opposto, quindi non lo considero una tecnica di accelerazione per scopi generici.

Alla fine, è molto difficile dire quale codice finirà per funzionare più velocemente. Qualcosa come l'allocazione dei registri e i fattori che la influenzano sono dettagli di implementazione di così basso livello che non vedo come una tecnica specifica possa produrre in modo affidabile codice più veloce.

Ad esempio, considerare i due metodi seguenti. Sono stati adattati da un esempio di vita reale:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

Uno è una versione generica dell'altro. Sostituire il tipo generico con StructArrayrenderebbe identici i metodi. Poiché StructArrayè un tipo di valore, ottiene la propria versione compilata del metodo generico. Tuttavia, il tempo di esecuzione effettivo è significativamente più lungo rispetto al metodo specializzato, ma solo per x86. Per x64, i tempi sono praticamente identici. In altri casi, ho osservato differenze anche per x64.


6
Detto questo ... puoi forzare diverse scelte di allocazione dei registri senza usare un Try / Catch? O come test per questa ipotesi o come tentativo generale di modificare la velocità?
WernerCD,

1
Esistono diversi motivi per cui questo caso specifico potrebbe essere diverso. Forse è il try-catch. Forse è il fatto che le variabili vengono riutilizzate in un ambito interno. Qualunque sia il motivo specifico, è un dettaglio di implementazione su cui non puoi contare per essere preservato anche se lo stesso codice esatto viene chiamato in un programma diverso.
Jeffrey Sax,

4
@WernerCD Direi che C e C ++ hanno una parola chiave per suggerire che (A) è ignorato da molti compilatori moderni e (B) è stato deciso di non inserire C #, suggerisce che questo non è qualcosa che noi ' Vedrò in un modo più diretto.
Jon Hanna,

2
@WernerCD - Solo se scrivi tu stesso l'assemblea
OrangeDog,

72

Sembra un caso di inline andato male. Su un core x86, il jitter ha i registri ebx, edx, esi ed edi disponibili per l'archiviazione generica di variabili locali. Il registro ECX diventa disponibile in un metodo statico, esso non ha bisogno di conservare questo . Il registro eax è spesso necessario per i calcoli. Ma questi sono registri a 32 bit, per le variabili di tipo long deve usare una coppia di registri. Quali sono edx: eax per i calcoli e edi: ebx per l'archiviazione.

Che è ciò che si distingue nello smontaggio per la versione lenta, non vengono utilizzati né edi né ebx.

Quando il jitter non riesce a trovare abbastanza registri per memorizzare le variabili locali, deve generare il codice per caricarli e memorizzarli dal frame dello stack. Ciò rallenta il codice, impedisce un'ottimizzazione del processore denominata "rinomina del registro", un trucco di ottimizzazione del core del processore interno che utilizza più copie di un registro e consente l'esecuzione super-scalare. Ciò consente l'esecuzione simultanea di più istruzioni, anche quando utilizzano lo stesso registro. Non avere abbastanza registri è un problema comune sui core x86, risolto in x64 che ha 8 registri extra (da r9 a r15).

Il jitter farà del suo meglio per applicare un'altra ottimizzazione della generazione del codice, proverà a incorporare il metodo Fibo (). In altre parole, non effettuare una chiamata al metodo ma generare il codice per il metodo inline nel metodo Main (). Ottimizzazione piuttosto importante che, per uno, rende le proprietà di una classe C # gratuitamente, dando loro la perfezione di un campo. Evita il sovraccarico di chiamare il metodo e di impostare il suo stack frame, risparmiando un paio di nanosecondi.

Esistono diverse regole che determinano esattamente quando un metodo può essere incorporato. Non sono esattamente documentati ma sono stati menzionati nei post del blog. Una regola è che non accadrà quando il corpo del metodo è troppo grande. Ciò sconfigge il guadagno derivante dall'inline, genera troppo codice che non si adatta altrettanto bene alla cache delle istruzioni L1. Un'altra regola rigida che si applica qui è che un metodo non verrà incorporato quando contiene un'istruzione try / catch. Lo sfondo dietro quello è un dettaglio di implementazione delle eccezioni, che si basano sul supporto integrato di Windows per SEH (Structure Exception Handling) che è basato su stack-frame.

Un comportamento dell'algoritmo di allocazione del registro nel jitter può essere dedotto giocando con questo codice. Sembra essere consapevole di quando il jitter sta cercando di incorporare un metodo. Una regola sembra usare solo la coppia del registro edx: eax per il codice interno che ha variabili locali di tipo long. Ma non edi: ebx. Senza dubbio perché ciò sarebbe troppo dannoso per la generazione del codice per il metodo chiamante, sia edi che ebx sono importanti registri di archiviazione.

Quindi ottieni la versione veloce perché il jitter sa in anticipo che il corpo del metodo contiene istruzioni try / catch. Sa che non può mai essere integrato così facilmente usa edi: ebx per l'archiviazione per la variabile lunga. Hai ottenuto la versione lenta perché il jitter non sapeva in anticipo che l'allineamento non avrebbe funzionato. Lo ha scoperto solo dopo aver generato il codice per il corpo del metodo.

Il difetto è che non è tornato indietro e non ha rigenerato il codice per il metodo. Il che è comprensibile, dati i limiti di tempo in cui deve operare.

Questo rallentamento non si verifica su x64 perché per uno ha altri 8 registri. Per un altro perché può memorizzare un long in un solo registro (come rax). E il rallentamento non si verifica quando si utilizza int invece che a lungo perché il jitter ha molta più flessibilità nella selezione dei registri.


21

L'avrei inserito come commento, dal momento che non sono davvero sicuro che questo sia probabilmente il caso, ma come ricordo non è un'istruzione try / tranne che comporta una modifica del modo in cui il meccanismo di smaltimento dei rifiuti di il compilatore funziona, in quanto cancella le allocazioni di memoria degli oggetti in modo ricorsivo dallo stack. In questo caso potrebbe non esserci un oggetto da chiarire o il ciclo for potrebbe costituire una chiusura che il meccanismo di garbage collection riconosce sufficiente per imporre un diverso metodo di raccolta. Probabilmente no, ma ho pensato che valesse la pena menzionarlo perché non l'avevo visto discusso da nessun'altra parte.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.