2016-05-22 34 views
7

Attualmente sto ottimizzando una libreria di basso livello e ho trovato un caso contro-intuitivo. Il commit che ha causato questa domanda è here.C# Perché utilizzare il metodo di istanza come delegato alloca oggetti temporanei di GC0 ma il 10% più veloce di un delegato memorizzato nella cache

C'è un delegato

public delegate void FragmentHandler(UnsafeBuffer buffer, int offset, int length, Header header); 

e un metodo di istanza

public void OnFragment(IDirectBuffer buffer, int offset, int length, Header header) 
{ 
    _totalBytes.Set(_totalBytes.Get() + length); 
} 

In this line, se uso il metodo come delegato, il programma alloca molti GC0 per la temperatura delegato involucro, ma la performance è del 10% più veloce (ma non stabile).

var fragmentsRead = image.Poll(OnFragment, MessageCountLimit); 

Se invece cache il metodo in un delegato fuori del ciclo come questo:

FragmentHandler onFragmentHandler = OnFragment; 

allora il programma non alloca affatto, numeri sono molto stabile, ma molto più lento.

Ho guardato attraverso IL generato e sta facendo la stessa cosa, ma nel caso successivo newobj viene chiamato solo una volta e quindi variabile locale se caricato.

Con IL_0034 delegato cache:

IL_002d: ldarg.0 
IL_002e: ldftn instance void Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput/Subscriber::OnFragment(class [Adaptive.Agrona]Adaptive.Agrona.IDirectBuffer, int32, int32, class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.Header) 
IL_0034: newobj instance void [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler::.ctor(object, native int) 
IL_0039: stloc.3 
IL_003a: br.s IL_005a 
// loop start (head: IL_005a) 
    IL_003c: ldloc.0 
    IL_003d: ldloc.3 
    IL_003e: ldsfld int32 Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput::MessageCountLimit 
    IL_0043: callvirt instance int32 [Adaptive.Aeron]Adaptive.Aeron.Image::Poll(class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler, int32) 
    IL_0048: stloc.s fragmentsRead 

Con allocazioni temporanee IL_0037:

IL_002c: stloc.2 
IL_002d: br.s IL_0058 
// loop start (head: IL_0058) 
    IL_002f: ldloc.0 
    IL_0030: ldarg.0 
    IL_0031: ldftn instance void Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput/Subscriber::OnFragment(class [Adaptive.Agrona]Adaptive.Agrona.IDirectBuffer, int32, int32, class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.Header) 
    IL_0037: newobj instance void [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler::.ctor(object, native int) 
    IL_003c: ldsfld int32 Adaptive.Aeron.Samples.IpcThroughput.IpcThroughput::MessageCountLimit 
    IL_0041: callvirt instance int32 [Adaptive.Aeron]Adaptive.Aeron.Image::Poll(class [Adaptive.Aeron]Adaptive.Aeron.LogBuffer.FragmentHandler, int32) 
    IL_0046: stloc.s fragmentsRead 

Perché il codice con le allocazioni è più veloce qui? Cosa è necessario per evitare allocazioni ma mantenere le prestazioni?

(test su .NET 4.5.2/4.6.1, x64, di uscita, su due macchine diverse)

Aggiornamento

Qui è esempio standalone che si comporta come previsto: delegato cache esegue più di 2x più veloce con 4 sec contro 11 sec. Quindi la domanda è specifica per il progetto di riferimento - quali problemi sottili con il compilatore JIT o qualcos'altro potrebbero causare il risultato inaspettato?

using System; 
using System.Diagnostics; 

namespace TestCachedDelegate { 

    public delegate int TestDelegate(int first, int second); 

    public static class Program { 
     static void Main(string[] args) 
     { 
      var tc = new TestClass(); 
      tc.Run(); 
     } 

     public class TestClass { 

      public void Run() { 
       var sw = new Stopwatch(); 
       sw.Restart(); 
       for (int i = 0; i < 1000000000; i++) { 
        CallDelegate(Add, i, i); 
       } 
       sw.Stop(); 
       Console.WriteLine("Non-cached: " + sw.ElapsedMilliseconds); 
       sw.Restart(); 
       TestDelegate dlgCached = Add; 
       for (int i = 0; i < 1000000000; i++) { 
        CallDelegate(dlgCached, i, i); 
       } 
       sw.Stop(); 
       Console.WriteLine("Cached: " + sw.ElapsedMilliseconds); 
       Console.ReadLine(); 
      } 

      public int CallDelegate(TestDelegate dlg, int first, int second) { 
       return dlg(first, second); 
      } 

      public int Add(int first, int second) { 
       return first + second; 
      } 

     } 
    } 
} 
+2

vi suggerisco di postare un [MCVE] del problema che non è collegato con il vostro codice specifico del dominio, quindi questo può essere riprodotto su altre macchine. –

+1

Il commit è un po 'poco chiaro, introducendo una variabile con lo stesso nome del campo della classe ad eccezione del trattino basso principale e della vecchia versione che si riferisce al campo della classe. Sei sicuro di aver provato con la variabile locale, spero? – hvd

+0

@hvd Le variabili locali all'interno del ciclo si comportano allo stesso modo dell'utilizzo del metodo all'interno della funzione '.Poll()'. La variabile locale all'esterno del ciclo si comporta in modo simile a un campo. –

risposta

2

Così, dopo aver letto la domanda troppo in fretta e pensare la si stava chiedendo qualcos'altro che ho finalmente avuto il tempo di sedersi e giocare con il test Aeoron in questione.

ho provato un paio di cose, prima di tutto ho confrontato l'IL e Assembler prodotte e ha scoperto che non vi era praticamente alcuna differenza né il luogo in cui noi chiamiamo Poll() o nel luogo in cui il gestore è effettivamente chiamato.

In secondo luogo, ho provato a commentare il codice nel metodo Poll() per confermare che la versione memorizzata nella cache fosse effettivamente più veloce (cosa che ha fatto).

Ho tentato di esaminare i contatori della CPU (mancati Cache, istruzioni annullate e previsioni errate di ramo) nel profiler VS ma non ho visto alcuna differenza tra le due versioni oltre al fatto che il costruttore delegato era ovviamente chiamato più volte.

Questo mi ha fatto pensare a un caso simile che abbiamo corso attraversato nel porting Disruptor-net dove abbiamo avuto una prova che era in esecuzione più lento rispetto alla versione Java, ma eravamo sicuri che non stavamo facendo nulla di più costoso. Il motivo della "lentezza" del test era che eravamo effettivamente più veloci e quindi meno numerosi e quindi il nostro throughput era più basso.

Se si inserisce un Thread.SpinWait (5) appena prima della chiamata a Poll(), si vedrà la stessa o migliore prestazione della versione non memorizzata nella cache.

risposta originale alla domanda che ho pensato al momento "perché utilizza un metodo di istanza delegato è più lento di cache manualmente il delegato":

L'indizio è nella domanda. È un metodo di istanza e pertanto acquisisce implicitamente il membro this e il fatto che questo sia catturato significa che non può essere memorizzato nella cache. Dato che this non cambierà mai durante la vita del delegato memorizzato nella cache, dovrebbe essere memorizzato nella cache.

Se si espande il gruppo metodo su (first, second) => this.Add(first, second), l'acquisizione diventa più evidente.

Nota che la squadra Roslyn sta lavorando per risolvere questo: https://github.com/dotnet/roslyn/issues/5835

+0

Come questo risponde alla domanda !? –

+0

@ V.B. Non avevo letto correttamente la domanda, ho aggiornato la mia risposta. – Slugart

+0

Grazie! Molto interessante –