[c#] Prova ad aumentare il mio codice?



Answers

Beh, il modo in cui stai cronometrando le cose mi sembra abbastanza brutto. Sarebbe molto più sensato solo il tempo dell'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 questa modifica, verifica se la versione "non catch" è ancora più lenta della versione "catch".

EDIT: Ok, l'ho provato da solo - e sto vedendo lo stesso risultato. Molto strano. Mi sono chiesto se il try / catch stava disabilitando qualche inlining, ma usare [MethodImpl(MethodImplOptions.NoInlining)] invece non ha aiutato ...

Fondamentalmente avrai bisogno di guardare il codice JITted ottimizzato sotto cordbg, sospetto ...

EDIT: alcuni altri bit di informazioni:

  • Mettere alla prova / catturare solo il n++; la linea migliora ancora le prestazioni, ma non tanto quanto metterla in giro per l'intero blocco
  • Se rilevi un'eccezione specifica ( ArgumentException nei miei test) è ancora veloce
  • Se si stampa l'eccezione nel blocco catch, è ancora veloce
  • Se si rilancia l'eccezione nel blocco catch, è di nuovo lento
  • Se usi un blocco finally invece di un catch catch, è di nuovo lento
  • Se usi un blocco finally e un blocco catch, è veloce

Strano...

EDIT: Ok, abbiamo lo smontaggio ...

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

Codice smontato per 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 il * mostra dove il debugger è entrato in un semplice "step-in".

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

Probabilmente il blocco try / catch forza la memorizzazione e il ripristino di più registri, quindi il JIT usa anche quelli per il ciclo ... il che migliora le prestazioni complessive. Non è chiaro se sia una decisione ragionevole per il JIT di non usare tanti 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 sotto x64 il blocco try / catch non fa una differenza evidente.

Question

Ho scritto un codice per testare l'impatto del try-catch, ma ho visto 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 costantemente stampa un valore intorno a 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à corre più veloce! Ma perché?

Nota: l'ho compilato utilizzando la configurazione Release e ho eseguito direttamente il file EXE (all'esterno di Visual Studio).

EDIT: l' eccellente analisi di Jon Skeet mostra che try-catch sta in qualche modo facendo sì che il CLR x86 usi i registri della CPU in un modo più favorevole in questo caso specifico (e penso che dobbiamo ancora capire perché). Ho confermato che Jon ha scoperto che il CLR x64 non ha questa differenza e che era più veloce del CLR x86. Ho anche provato a utilizzare i tipi int all'interno del metodo Fibo invece dei tipi long , quindi il CLR x86 era 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.




Questo sembra un caso di inlining andato male. Su un core x86, il jitter ha il registro ebx, edx, esi ed edi disponibile per l'archiviazione generale delle variabili locali. Il registro ecx diventa disponibile in un metodo statico, non deve memorizzarlo. Il registro eax spesso è necessario per i calcoli. Ma questi sono registri a 32 bit, per le variabili di tipo lungo deve usare una coppia di registri. Quali sono edx: eax per i calcoli ed edi: ebx per l'archiviazione.

Questo è ciò che spicca 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 codice per caricarlo e memorizzarlo dal frame dello stack. Questo rallenta il codice, impedisce l'ottimizzazione del processore denominata "rinomina registro", un trucco di ottimizzazione del core del processore interno che utilizza più copie di un registro e consente l'esecuzione super-scalare. Che consente di eseguire più istruzioni contemporaneamente, anche quando usano lo stesso registro. Non avere abbastanza registri è un problema comune sui core x86, indirizzato 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à ad allineare 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 esempio, rende le proprietà di una classe C # gratuitamente, dando loro la perfezione di un campo. Evita il sovraccarico di effettuare il richiamo del metodo e l'impostazione del suo stack frame, risparmiando un paio di nanosecondi.

Esistono diverse regole che determinano esattamente quando un metodo può essere sottolineato. Non sono esattamente documentati ma sono stati citati nei post del blog. Una regola è che non accadrà quando il corpo del metodo è troppo grande. Ciò sconfigge il guadagno dall'inlining, genera troppo codice che non si adatta anche alla cache delle istruzioni L1. Un'altra dura regola che si applica qui è che un metodo non sarà inarcato quando contiene un'istruzione try / catch. Il retroscena dietro a questo è un dettaglio di implementazione delle eccezioni, esse sono integrate nel 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 dal gioco con questo codice. Sembra essere a conoscenza di quando il jitter sta cercando di allineare un metodo. Una regola sembra usare che solo la coppia di registri edx: eax può essere utilizzata per il codice inline 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 di chiamata, sia edi che ebx sono importanti registri di archiviazione.

Quindi ottieni la versione veloce perché il jitter sa bene che il corpo del metodo contiene istruzioni try / catch. Sa che non può mai essere delineato, quindi usa prontamente edi: ebx per l'archiviazione per la variabile lunga. Hai la versione lenta perché il jitter non sapeva in anticipo che l'inlining non avrebbe funzionato. Si è scoperto solo dopo aver generato il codice per il corpo del metodo.

Il difetto quindi è che non è tornato indietro e generare nuovamente 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 8 registri in più. Per un altro perché può memorizzare un lungo in un solo registro (come rax). E il rallentamento non si verifica quando si utilizza int invece di long perché il jitter ha molta più flessibilità nei registri di prelievo.




Related