Strano aumento delle prestazioni in un semplice benchmark


97

Ieri ho trovato un articolo di Christoph Nahr intitolato ".NET Struct Performance" che confrontava diversi linguaggi (C ++, C #, Java, JavaScript) per un metodo che aggiunge strutture a due punti ( doubletuple).

Come si è scoperto, la versione C ++ impiega circa 1000 ms per essere eseguita (iterazioni 1e9), mentre C # non può scendere sotto i ~ 3000 ms sulla stessa macchina (e funziona anche peggio in x64).

Per testarlo da solo, ho preso il codice C # (e semplificato leggermente per chiamare solo il metodo in cui i parametri vengono passati per valore) e l'ho eseguito su una macchina i7-3610QM (boost di 3.1Ghz per single core), 8 GB di RAM, Win8. 1, utilizzando .NET 4.5.2, RELEASE build a 32 bit (x86 WoW64 poiché il mio sistema operativo è a 64 bit). Questa è la versione semplificata:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Con Pointdefinito semplicemente:

public struct Point 
{
    private readonly double _x, _y;

    public Point(double x, double y) { _x = x; _y = y; }

    public double X { get { return _x; } }

    public double Y { get { return _y; } }
}

L'esecuzione produce risultati simili a quelli dell'articolo:

Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms

Prima strana osservazione

Poiché il metodo dovrebbe essere inline, mi chiedevo come si sarebbe comportato il codice se avessi rimosso completamente le strutture e semplicemente inline l'intera cosa insieme:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    public static void Main()
    {
        // not using structs at all here
        double ax = 1, ay = 1, bx = 1, by = 1;

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
        {
            ax = ax + by;
            ay = ay + bx;
        }
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            ax, ay, sw.ElapsedMilliseconds);
    }
}

E ha ottenuto praticamente lo stesso risultato (in realtà l'1% più lento dopo diversi tentativi), il che significa che JIT-ter sembra fare un buon lavoro ottimizzando tutte le chiamate di funzione:

Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms

Significa anche che il benchmark non sembra misurare alcuna structprestazione e in realtà sembra misurare solo l' doublearitmetica di base (dopo che tutto il resto è stato ottimizzato).

Le cose strane

Adesso arriva la parte strana. Se aggiungo semplicemente un altro cronometro fuori dal ciclo (sì, l'ho ristretto a questo passaggio folle dopo diversi tentativi), il codice viene eseguito tre volte più velocemente :

public static void Main()
{
    var outerSw = Stopwatch.StartNew();     // <-- added

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    outerSw.Stop();                         // <-- added
}

Result: x=1000000001 y=1000000001, Time elapsed: 961 ms

È ridicolo! E non Stopwatchè che mi stia dando risultati sbagliati perché posso vedere chiaramente che finisce dopo un solo secondo.

Qualcuno può dirmi cosa potrebbe succedere qui?

(Aggiornare)

Ecco due metodi nello stesso programma, che mostra che il motivo non è JITting:

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Test1();
        Test2();

        Console.WriteLine();

        Test1();
        Test2();
    }

    private static void Test1()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    private static void Test2()
    {
        var swOuter = Stopwatch.StartNew();

        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);

        swOuter.Stop();
    }
}

Produzione:

Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms

Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms

Ecco un pastebin. È necessario eseguirlo come una versione a 32 bit su .NET 4.x (ci sono un paio di controlli nel codice per garantire questo).

(Aggiorna 4)

Seguendo i commenti di @usr sulla risposta di @Hans, ho controllato lo smontaggio ottimizzato per entrambi i metodi, e sono piuttosto diversi:

Test1 a sinistra, Test2 a destra

Questo sembra dimostrare che la differenza potrebbe essere dovuta al fatto che il compilatore si comporta in modo strano nel primo caso, piuttosto che al doppio allineamento dei campi?

Inoltre, se aggiungo due variabili (offset totale di 8 byte), ottengo comunque lo stesso aumento di velocità e non sembra più correlato alla menzione dell'allineamento del campo di Hans Passant:

// this is still fast?
private static void Test3()
{
    var magical_speed_booster_1 = "whatever";
    var magical_speed_booster_2 = "whatever";

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    GC.KeepAlive(magical_speed_booster_1);
    GC.KeepAlive(magical_speed_booster_2);
}

1
Oltre alla cosa JIT, dipende anche dalle ottimizzazioni del compilatore, l'ultimo Ryujit fa più ottimizzazioni e ha persino introdotto un supporto limitato per le istruzioni SIMD.
Felix K.

3
Jon Skeet ha riscontrato un problema di prestazioni con i campi di sola lettura nelle strutture: Micro-ottimizzazione: la sorprendente inefficienza dei campi di sola lettura . Prova a rendere i campi privati ​​non di sola lettura.
dbc

2
@dbc: ho fatto un test con solo doublevariabili locali , no structs, quindi ho escluso le inefficienze di struttura / chiamata del metodo.
Groo

3
Sembra che accada solo a 32 bit, con RyuJIT, ottengo 1600 ms entrambe le volte.
leppie

2
Ho esaminato lo smontaggio di entrambi i metodi. Non c'è niente di interessante da vedere. Test1 genera codice inefficiente senza motivo apparente. Bug JIT o in base alla progettazione. In Test1, JIT carica e memorizza i doppi per ogni iterazione nello stack. Questo potrebbe essere per garantire la precisione esatta perché l'unità float x86 utilizza una precisione interna a 80 bit. Ho scoperto che qualsiasi chiamata di funzione non inline nella parte superiore della funzione la fa andare di nuovo veloce.
usr

Risposte:


10

L'aggiornamento 4 spiega il problema: nel primo caso, JIT mantiene i valori calcolati ( a, b) sullo stack; nel secondo caso, JIT lo tiene nei registri.

In effetti, Test1funziona lentamente a causa del Stopwatch. Ho scritto il seguente benchmark minimo basato su BenchmarkDotNet :

[BenchmarkTask(platform: BenchmarkPlatform.X86)]
public class Jit_RegistersVsStack
{
    private const int IterationCount = 100001;

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithoutStopwatch()
    {
        double a = 1, b = 1;
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}", a);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithStopwatch()
    {
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // fadd        qword ptr [ebp-14h]
            // fstp        qword ptr [ebp-14h]
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithTwoStopwatches()
    {
        var outerSw = new Stopwatch();
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }
}

I risultati sul mio computer:

BenchmarkDotNet=v0.7.7.0
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel(R) Core(TM) i7-4702MQ CPU @ 2.20GHz, ProcessorCount=8
HostCLR=MS.NET 4.0.30319.42000, Arch=64-bit  [RyuJIT]
Type=Jit_RegistersVsStack  Mode=Throughput  Platform=X86  Jit=HostJit  .NET=HostFramework

             Method |   AvrTime |    StdDev |       op/s |
------------------- |---------- |---------- |----------- |
   WithoutStopwatch | 1.0333 ns | 0.0028 ns | 967,773.78 |
      WithStopwatch | 3.4453 ns | 0.0492 ns | 290,247.33 |
 WithTwoStopwatches | 1.0435 ns | 0.0341 ns | 958,302.81 |

Come possiamo vedere:

  • WithoutStopwatchfunziona velocemente (perché a = a + busa i registri)
  • WithStopwatchfunziona lentamente (perché a = a + busa lo stack)
  • WithTwoStopwatchesfunziona di nuovo velocemente (perché a = a + busa i registri)

Il comportamento di JIT-x86 dipende da una grande quantità di condizioni diverse. Per qualche ragione, il primo cronometro forza JIT-x86 a utilizzare lo stack e il secondo cronometro gli consente di utilizzare nuovamente i registri.


Questo non spiega davvero la causa. Se controlli i miei test, sembrerebbe che il test che ha un ulteriore Stopwatchfunziona effettivamente più velocemente . Ma se si scambia l'ordine in cui vengono richiamati nel Mainmetodo, l'altro metodo viene ottimizzato.
Groo

75

C'è un modo molto semplice per ottenere sempre la versione "veloce" del tuo programma. Progetto> Proprietà> scheda Costruisci, deseleziona l'opzione "Preferisci 32 bit", assicurati che la selezione di destinazione della piattaforma sia AnyCPU.

Davvero non preferisci 32 bit, purtroppo è sempre attivato per impostazione predefinita per i progetti C #. Storicamente, il set di strumenti di Visual Studio ha funzionato molto meglio con i processi a 32 bit, un vecchio problema che Microsoft ha risolto. È ora di rimuovere quell'opzione, VS2015 in particolare ha indirizzato gli ultimi veri blocchi stradali al codice a 64 bit con un nuovissimo jitter x64 e supporto universale per Modifica + Continua.

Basta chiacchiere, quello che hai scoperto è l'importanza dell'allineamento per le variabili. Il processore ci tiene moltissimo. Se una variabile è disallineata in memoria, il processore deve fare del lavoro extra per mescolare i byte per ottenerli nell'ordine corretto. Ci sono due distinti problemi di disallineamento, uno è dove i byte sono ancora all'interno di una singola linea di cache L1, che costa un ciclo extra per spostarli nella giusta posizione. E quello più cattivo, quello che hai trovato, dove parte dei byte sono in una riga della cache e parte in un'altra. Ciò richiede due accessi alla memoria separati e incollarli insieme. Tre volte più lento.

I tipi doublee longsono quelli che creano problemi in un processo a 32 bit. Hanno una dimensione di 64 bit. E può essere così disallineato di 4, il CLR può garantire solo un allineamento a 32 bit. Non è un problema in un processo a 64 bit, è garantito che tutte le variabili siano allineate a 8. È anche il motivo sottostante per cui il linguaggio C # non può promettere che siano atomiche . E perché le matrici di double vengono allocate nel Large Object Heap quando hanno più di 1000 elementi. Il LOH fornisce una garanzia di allineamento di 8. E spiega perché l'aggiunta di una variabile locale ha risolto il problema, un riferimento a un oggetto è di 4 byte, quindi ha spostato la doppia variabile di 4, ottenendo ora l'allineamento. Per errore.

Un compilatore C o C ++ a 32 bit fa un lavoro extra per garantire che double non possa essere disallineato. Non esattamente un problema semplice da risolvere, lo stack può essere disallineato quando viene inserita una funzione, dato che l'unica garanzia è che sia allineata a 4. Il prologo di una tale funzione deve fare del lavoro extra per allinearlo a 8. Lo stesso trucco non funziona in un programma gestito, il garbage collector si preoccupa molto di dove si trova esattamente una variabile locale nella memoria. Necessario in modo che possa scoprire che un oggetto nell'heap del GC è ancora referenziato. Non può gestire correttamente una tale variabile che viene spostata di 4 perché lo stack era disallineato quando è stato inserito il metodo.

Questo è anche il problema di fondo con i tremoli di .NET che non supportano facilmente le istruzioni SIMD. Hanno requisiti di allineamento molto più elevati, del tipo che nemmeno il processore può risolvere da solo. SSE2 richiede un allineamento di 16, AVX richiede un allineamento di 32. Impossibile ottenerlo nel codice gestito.

Ultimo ma non meno importante, si noti anche che questo rende le prestazioni di un programma C # che viene eseguito in modalità a 32 bit molto imprevedibile. Quando si accede a un double o long archiviato come campo in un oggetto, perf può cambiare drasticamente quando il garbage collector compatta l'heap. Che muove gli oggetti nella memoria, un tale campo può ora improvvisamente diventare disallineato. Molto casuale, ovviamente, può essere un vero grattacapo :)

Bene, nessuna soluzione semplice, ma un codice a 64 bit è il futuro. Rimuovere la forzatura del jitter finché Microsoft non cambierà il modello di progetto. Forse la prossima versione quando si sentiranno più sicuri di Ryujit.


1
Non sono sicuro di come l'allineamento giochi in questo quando le doppie variabili potrebbero essere (e sono in Test2) registrate. Test1 utilizza lo stack, Test2 no.
usr

2
Questa domanda sta cambiando troppo velocemente perché io possa tenerne traccia. Devi stare attento al test stesso che influisce sul risultato del test. È necessario inserire [MethodImpl (MethodImplOptions.NoInlining)] sui metodi di prova per confrontare le mele con le arance. Ora vedrai che l'ottimizzatore può mantenere le variabili sullo stack FPU in entrambi i casi.
Hans Passant

4
Omg, è vero. Perché l'allineamento del metodo ha un impatto sulle istruzioni generate ?! Non dovrebbe esserci alcuna differenza per il corpo del loop. Tutto dovrebbe essere nei registri. Il prologo di allineamento dovrebbe essere irrilevante. Sembra ancora un bug JIT.
usr

3
Devo rivedere in modo significativo la risposta, peccato. Ci arrivo domani.
Hans Passant

2
@HansPassant hai intenzione di esaminare le fonti JIT? Sarebbe divertente. A questo punto tutto quello che so è che si tratta di un bug JIT casuale.
usr

5

Ridotto un po '(sembra influenzare solo il runtime CLR 4.0 a 32 bit).

Notare che il posizionamento del var f = Stopwatch.Frequency;fa la differenza.

Lento (2700 ms):

static void Test1()
{
  Point a = new Point(1, 1), b = new Point(1, 1);
  var f = Stopwatch.Frequency;

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

Veloce (800 ms):

static void Test1()
{
  var f = Stopwatch.Frequency;
  Point a = new Point(1, 1), b = new Point(1, 1);

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

Modificare il codice senza toccare Stopwatchcambia anche drasticamente la velocità. Cambiare la firma del metodo in Test1(bool warmup)e aggiungere un condizionale Consolenell'output: if (!warmup) { Console.WriteLine(...); }ha anche lo stesso effetto (ci siamo imbattuti in questo durante la costruzione dei miei test per riprodurre il problema).
Tra il

@InBetween: ho visto, c'è qualcosa di strano. Inoltre accade solo su struct.
leppie

4

Sembra che ci sia qualche bug nel Jitter perché il comportamento è ancora più strano. Considera il codice seguente:

public static void Main()
{
    Test1(true);
    Test1(false);
    Console.ReadLine();
}

public static void Test1(bool warmup)
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    if (!warmup)
    {
        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Questo verrà eseguito in 900ms, come il caso esterno del cronometro. Tuttavia, se rimuoviamo la if (!warmup)condizione, verrà eseguita in 3000ms. Ciò che è ancora più strano è che il codice seguente verrà eseguito anche in 900ms:

public static void Test1()
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
        0, 0, sw.ElapsedMilliseconds);
}

Nota ho rimosso a.Xe a.Yriferimenti Consoledall'output.

Non ho idea di cosa stia succedendo, ma questo mi Stopwatchsembra abbastanza buggato e non è correlato ad avere un esterno o meno, il problema sembra un po 'più generalizzato.


Quando rimuovi le chiamate a a.Xe a.Y, il compilatore è probabilmente libero di ottimizzare praticamente tutto all'interno del ciclo, perché i risultati dell'operazione sono inutilizzati.
Groo

@Groo: sì, sembra ragionevole ma non se si tiene conto dell'altro strano comportamento che stiamo vedendo. Rimuoverlo a.Xe a.Ynon farlo andare più velocemente di quando includi la if (!warmup)condizione o gli OP outerSw, il che implica che non ottimizza nulla, sta solo eliminando qualsiasi bug che faccia funzionare il codice a una velocità subottimale ( 3000ms invece di 900ms).
Tra il

2
Oh, ok, ho pensato che il miglioramento della velocità è accaduto quando warmupera vero, ma in quel caso la linea non è ancora stampato, in modo che il caso in cui non vengono stampati in realtà i riferimenti a. Tuttavia, mi piace assicurarmi di fare sempre riferimento ai risultati dei calcoli da qualche parte verso la fine del metodo, ogni volta che eseguo il benchmarking delle cose.
Groo
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.