Testowanie porównawcze małych próbek kodu w C #, czy można ulepszyć tę implementację?


104

Dość często w SO zdaję sobie sprawę, że porównuję małe fragmenty kodu, aby zobaczyć, która implementacja jest najszybsza.

Dość często widzę komentarze, że kod benchmarkingu nie bierze pod uwagę jittingu ani garbage collectora.

Mam następującą prostą funkcję benchmarkingu, którą powoli ewoluowałem:

  static void Profile(string description, int iterations, Action func) {
        // warm up 
        func();
        // clean up
        GC.Collect();

        var watch = new Stopwatch();
        watch.Start();
        for (int i = 0; i < iterations; i++) {
            func();
        }
        watch.Stop();
        Console.Write(description);
        Console.WriteLine(" Time Elapsed {0} ms", watch.ElapsedMilliseconds);
    }

Stosowanie:

Profile("a descriptions", how_many_iterations_to_run, () =>
{
   // ... code being profiled
});

Czy ta implementacja ma jakieś wady? Czy wystarczy pokazać, że implementacja X jest szybsza niż implementacja Y przez iteracje Z? Czy możesz wymyślić jakiś sposób, w jaki mógłbyś to poprawić?

EDYCJA Jest całkiem jasne, że preferowane jest podejście oparte na czasie (w przeciwieństwie do iteracji), czy ktoś ma jakieś implementacje, w których sprawdzanie czasu nie wpływa na wydajność?


Zobacz także BenchmarkDotNet .
Ben Hutchison

Odpowiedzi:


95

Oto zmodyfikowana funkcja: zgodnie z zaleceniami społeczności, nie krępuj się zmienić tego, jest to wiki społeczności.

static double Profile(string description, int iterations, Action func) {
    //Run at highest priority to minimize fluctuations caused by other processes/threads
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
    Thread.CurrentThread.Priority = ThreadPriority.Highest;

    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
    return watch.Elapsed.TotalMilliseconds;
}

Upewnij się, że kompilujesz w wersji z włączonymi optymalizacjami i uruchamiasz testy poza programem Visual Studio . Ta ostatnia część jest ważna, ponieważ JIT wykonuje optymalizacje z dołączonym debugerem, nawet w trybie wydania.


Możesz chcieć rozwinąć pętlę kilka razy, na przykład 10, aby zminimalizować narzut pętli.
Mike Dunlavey

2
Właśnie zaktualizowałem, aby używać Stopwatch.StartNew. Nie jest to zmiana funkcjonalna, ale zapisuje jedną linię kodu.
LukeH

1
@Luke, wielka zmiana (chciałbym dać jej +1). @Mike nie jestem pewien, podejrzewam, że narzut wirtualnego połączenia będzie znacznie wyższy niż porównanie i przypisanie, więc różnica w wydajności będzie znikoma
Sam Saffron

Proponuję, abyś przekazał liczbę iteracji do akcji i utworzył tam pętlę (być może - nawet rozwiniętą). W przypadku pomiaru stosunkowo krótkich operacji jest to jedyna opcja. Wolałbym widzieć odwrotną metrykę - np. Liczbę przejść / sek.
Alex Yakunin

2
Co myślisz o wyświetlaniu średniego czasu. Coś takiego: Console.WriteLine ("Średni czas, który upłynął {0} ms", watch.ElapsedMilliseconds / iterations);
rudimenter

22

Finalizacja niekoniecznie musi zostać zakończona przed GC.Collectzwrotem. Finalizacja jest umieszczana w kolejce, a następnie uruchamiana w osobnym wątku. Ten wątek może być nadal aktywny podczas testów, wpływając na wyniki.

Jeśli chcesz się upewnić, że finalizacja została zakończona przed rozpoczęciem testów, możesz zadzwonić GC.WaitForPendingFinalizers, co będzie blokować do czasu wyczyszczenia kolejki finalizacji:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

10
Dlaczego GC.Collect()jeszcze raz?
colinfang

7
@colinfang Ponieważ obiekty, które są „finalizowane”, nie są poddawane GC przez finalizator. A więc po drugie, Collectaby upewnić się, że „sfinalizowane” obiekty również zostaną zebrane.
MAV,

15

Jeśli chcesz wykluczyć interakcje GC z równania, możesz zechcieć wywołać rozgrzewkę po wywołaniu GC.Collect, a nie przed. W ten sposób wiesz, że .NET będzie już mieć wystarczającą ilość pamięci przydzielonej z systemu operacyjnego dla zestawu roboczego funkcji.

Pamiętaj, że dla każdej iteracji wykonujesz wywołanie metody niewymienionej, więc upewnij się, że porównujesz rzeczy, które testujesz, z pustą treścią. Musisz także zaakceptować fakt, że możesz niezawodnie mierzyć czas tylko rzeczy, które są kilka razy dłuższe niż wywołanie metody.

Ponadto, w zależności od tego, jakiego rodzaju rzeczy profilujesz, możesz chcieć uruchomić czas w oparciu o określony czas, a nie przez określoną liczbę iteracji - może to prowadzić do łatwiejszych do porównania liczb bez konieczność posiadania bardzo krótkiego okresu dla najlepszej implementacji i / lub bardzo długiego dla najgorszego.


1
dobre strony, czy miałbyś na myśli wdrożenie oparte na czasie?
Sam Saffron

6

W ogóle unikałbym minięcia delegata:

  1. Wywołanie delegata to ~ wywołanie metody wirtualnej. Niedrogie: ~ 25% najmniejszej alokacji pamięci w .NET. Jeśli interesują Cię szczegóły, zobacz np. Ten link .
  2. Anonimowi delegaci mogą prowadzić do użycia zamknięć, których nawet nie zauważysz. Ponownie, dostęp do pól zamknięcia jest zauważalny niż np. Dostęp do zmiennej na stosie.

Przykładowy kod prowadzący do użycia zamknięcia:

public void Test()
{
  int someNumber = 1;
  Profiler.Profile("Closure access", 1000000, 
    () => someNumber + someNumber);
}

Jeśli nie wiesz o domknięciach, przyjrzyj się tej metodzie w .NET Reflector.


Ciekawe uwagi, ale jak utworzyłbyś metodę Profile () wielokrotnego użytku, jeśli nie zdasz pełnomocnika? Czy istnieją inne sposoby przekazania dowolnego kodu do metody?
Ash

1
Używamy "using (new Measurement (...)) {... zmierzony kod ...}". Otrzymujemy więc obiekt Measurement implementujący IDisposable zamiast przekazywania delegata. Zobacz code.google.com/p/dataobjectsdotnet/source/browse/Xtensive.Core/ ...
Alex Yakunin

Nie spowoduje to żadnych problemów z zamknięciami.
Alex Yakunin

3
@AlexYakunin: Twój link wygląda na uszkodzony. Czy mógłbyś dołączyć kod klasy Pomiar w swojej odpowiedzi? Podejrzewam, że bez względu na to, jak go zaimplementujesz, nie będziesz w stanie uruchomić kodu do wielokrotnego profilowania za pomocą tego podejścia IDisposable. Jednak jest to rzeczywiście bardzo przydatne w sytuacjach, w których chcesz zmierzyć, jak działają różne części złożonej (splecionej) aplikacji, o ile będziesz pamiętać, że pomiary mogą być niedokładne i niespójne, gdy są wykonywane w różnym czasie. W większości swoich projektów stosuję to samo podejście.
ShdNx

1
Wymóg kilkukrotnego przeprowadzania testu wydajności jest naprawdę ważny (rozgrzewka + wielokrotne pomiary), więc przeszedłem na podejście również z delegatem. Ponadto, jeśli nie używasz domknięć, wywołanie delegata jest szybsze niż wywołanie metody interfejsu w przypadku IDisposable.
Alex Yakunin

6

Myślę, że najtrudniejszym problemem do przezwyciężenia za pomocą metod analizy porównawczej, takich jak ta, jest uwzględnienie przypadków skrajnych i nieoczekiwanych. Na przykład - „Jak działają dwa fragmenty kodu przy dużym obciążeniu procesora / wykorzystaniu sieci / wyrzucaniu dysków / itp.”. Doskonale nadają się do podstawowych sprawdzeń logicznych, aby sprawdzić, czy określony algorytm działa znacznie szybciej niż inny. Aby jednak poprawnie przetestować wydajność większości kodu, należałoby utworzyć test, który mierzy wąskie gardła tego konkretnego kodu.

Nadal powiedziałbym, że testowanie małych bloków kodu często ma niewielki zwrot z inwestycji i może zachęcać do używania zbyt złożonego kodu zamiast prostego kodu, który można konserwować. Pisanie przejrzystego kodu, który inni programiści lub ja po sześciu miesiącach możemy szybko zrozumieć, przyniesie więcej korzyści w zakresie wydajności niż wysoce zoptymalizowany kod.


1
znaczący to jeden z tych terminów, który jest naprawdę załadowany. czasami wdrożenie, które jest o 20% szybsze, jest znaczące, czasami musi być 100 razy szybsze, aby było znaczące. Zgadzam się z przejrzystością, patrz: stackoverflow.com/questions/1018407/…
Sam Saffron

W tym przypadku znaczące nie wszystko jest załadowane. Porównujesz jedną lub więcej współbieżnych implementacji i jeśli różnica w wydajności tych dwóch implementacji nie jest statystycznie istotna, nie warto angażować się w bardziej złożoną metodę.
Paul Alexander

5

Wzywałbym func()kilka razy na rozgrzewkę, a nie tylko jedną.


1
Celem było zapewnienie wykonania kompilacji jit. Jakie korzyści daje wywołanie funkcji func wiele razy przed pomiarem?
Sam Saffron

3
Dać JIT szansę na poprawę pierwszych wyników.
Alexey Romanov

1
.NET JIT nie poprawia swoich wyników w czasie (tak jak Java). Konwertuje metodę z IL na Assembly tylko raz, przy pierwszym wywołaniu.
Matt Warren,

4

Sugestie dotyczące ulepszeń

  1. Wykrywanie, czy środowisko wykonawcze jest dobre do testów porównawczych (na przykład wykrywanie, czy debugger jest dołączony lub czy optymalizacja jit jest wyłączona, co spowodowałoby nieprawidłowe pomiary).

  2. Niezależne pomiary części kodu (aby dokładnie zobaczyć, gdzie znajduje się wąskie gardło).

  3. Porównanie różnych wersji / komponentów / fragmentów kodu (w pierwszym zdaniu mówisz „... testujemy małe fragmenty kodu, aby zobaczyć, która implementacja jest najszybsza.”).

Odnośnie nr 1:

  • Aby wykryć, czy debugger jest dołączony, przeczytaj właściwość System.Diagnostics.Debugger.IsAttached(pamiętaj, aby obsłużyć również przypadek, w którym debugger nie jest początkowo dołączony, ale jest dołączany po pewnym czasie).

  • Aby wykryć, czy optymalizacja jit jest wyłączona, przeczytaj właściwość DebuggableAttribute.IsJITOptimizerDisabledodpowiednich zestawów:

    private bool IsJitOptimizerDisabled(Assembly assembly)
    {
        return assembly.GetCustomAttributes(typeof (DebuggableAttribute), false)
            .Select(customAttribute => (DebuggableAttribute) customAttribute)
            .Any(attribute => attribute.IsJITOptimizerDisabled);
    }

Odnośnie nr 2:

Można to zrobić na wiele sposobów. Jednym ze sposobów jest zezwolenie na dostarczenie kilku delegatów, a następnie indywidualny pomiar tych delegatów.

Odnośnie # 3:

Można to również zrobić na wiele sposobów, a różne przypadki użycia wymagałyby bardzo różnych rozwiązań. Jeśli test porównawczy jest wywoływany ręcznie, zapis do konsoli może być w porządku. Jeśli jednak test porównawczy jest wykonywany automatycznie przez system kompilacji, zapisywanie do konsoli prawdopodobnie nie jest takie dobre.

Jednym ze sposobów jest zwrócenie wyniku testu porównawczego jako obiektu o jednoznacznie określonym typie, który można łatwo wykorzystać w różnych kontekstach.


Etimo.Benchmarks

Innym podejściem jest użycie istniejącego komponentu do wykonania testów porównawczych. Właściwie w mojej firmie zdecydowaliśmy się udostępnić nasze narzędzie testowe do domeny publicznej. W swej istocie zarządza kolektorem śmieci, jitterem, rozgrzewkami itp., Tak jak sugerują niektóre inne odpowiedzi. Ma również trzy funkcje, które zasugerowałem powyżej. Zarządza kilkoma zagadnieniami omawianymi na blogu Erica Lipperta .

To jest przykładowy wynik, w którym porównywane są dwa komponenty, a wyniki są zapisywane w konsoli. W tym przypadku dwa porównywane składniki nazywane są „KeyedCollection” i „MultiplyIndexedKeyedCollection”:

Etimo.Benchmarks - przykładowe dane wyjściowe konsoli

Istnieje pakiet NuGet , przykładowy pakiet NuGet, a kod źródłowy jest dostępny w witrynie GitHub . Jest też wpis na blogu .

Jeśli się spieszysz, sugeruję pobranie przykładowego pakietu i po prostu zmodyfikowanie przykładowych delegatów w razie potrzeby. Jeśli się nie spieszysz, dobrym pomysłem może być przeczytanie posta na blogu, aby zrozumieć szczegóły.


1

Musisz także przeprowadzić „rozgrzewkę” przed rzeczywistym pomiarem, aby wykluczyć czas, jaki kompilator JIT poświęca na jowanie kodu.


jest wykonywana przed pomiarem
Sam Saffron

1

W zależności od kodu, który jest testowany, i platformy, na której działa, może być konieczne uwzględnienie wpływu wyrównania kodu na wydajność . Aby to zrobić, prawdopodobnie wymagałoby to zewnętrznego opakowania, które uruchamiało test wiele razy (w oddzielnych domenach aplikacji lub procesach?), Czasami najpierw wywołując „kod uzupełniający”, aby wymusić kompilację JIT, aby kod był testowane w celu dostosowania w inny sposób. Pełny wynik testu dałby najlepsze i najgorsze czasy dla różnych dopasowań kodu.


1

Jeśli próbujesz wyeliminować wpływ Garbage Collection z testu porównawczego, czy warto to ustawić GCSettings.LatencyMode?

Jeśli nie, a chcesz, aby wpływ utworzonych śmieci funcbył częścią testu porównawczego, to czy nie powinieneś również wymuszać zbierania danych pod koniec testu (wewnątrz licznika czasu)?


0

Podstawowym problemem związanym z twoim pytaniem jest założenie, że pojedynczy pomiar może odpowiedzieć na wszystkie twoje pytania. Aby uzyskać skuteczny obraz sytuacji, musisz wykonywać pomiary wiele razy, zwłaszcza w języku zbierania śmieci, takim jak C #.

Inna odpowiedź daje dobry sposób pomiaru podstawowej wydajności.

static void Profile(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Jednak ten pojedynczy pomiar nie uwzględnia wyrzucania elementów bezużytecznych. Odpowiedni profil dodatkowo uwzględnia najgorszy przypadek wydajności wyrzucania elementów bezużytecznych rozłożonych na wiele wywołań (ta liczba jest trochę bezużyteczna, ponieważ maszyna wirtualna może się zakończyć bez zbierania pozostałych śmieci, ale nadal jest przydatna do porównywania dwóch różnych implementacji func).

static void ProfileGarbageMany(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Można też chcieć zmierzyć wydajność czyszczenia pamięci w najgorszym przypadku dla metody, która jest wywoływana tylko raz.

static void ProfileGarbage(string description, int iterations, Action func) {
    // warm up 
    func();

    var watch = new Stopwatch(); 

    // clean up
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    watch.Start();
    for (int i = 0; i < iterations; i++) {
        func();

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
    watch.Stop();
    Console.Write(description);
    Console.WriteLine(" Time Elapsed {0} ms", watch.Elapsed.TotalMilliseconds);
}

Ale ważniejsze niż zalecanie jakichkolwiek konkretnych możliwych dodatkowych pomiarów do profilowania jest idea, że ​​należy mierzyć wiele różnych statystyk, a nie tylko jeden rodzaj statystyki.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.