Jak zmierzyć wydajność kodu .NET? BenchmarkDotNet

Wydajność kodu?

Wielokrotnie zastanawiam się, czy mój kod jest wydajny. Czy nie da się czegoś zrobić, aby aplikacja działała szybciej. Przy szybkości działania nie można założyć, że coś jest wydajne, bez zmierzenia tego. Jak zobaczysz pod koniec wpisu, może się okazać, że operacja, która wydaje się szybka, wcale taka nie jest. Jak zmierzyć wydajność kodu .NET?

Możemy zrobić to na wiele sposobów. Niektórzy używają DateTime.Now, inni DateTime.UtcNow czy też klasy StopWatch. W każdym w tych przypadków musimy napisać kod, który umożliwi zmierzenie wydajności. Niestety często w tym miejscu popełniamy błędy, które mogą zaważyć na otrzymanym wyniku.

Błędy podczas mierzenia wydajności

Kiedyś widziałem test wydajności Entity Framework przeprowadzony przez jedną firmę. Była to aplikacja konsolowa, który wykonywała jedno proste zapytanie na bazie. Do zmierzenia szybkości wykorzystano klasę StopWatch. Entity Framework nie przeszedł tego testu pozytywnie.

Z czasem programiści z tamtej firmy dowiedzieli się, że Entity Framework podczas pierwszego zapytania wykonuje kilka dodatkowych zapytań na bazie (np. na potrzeby mechanizmu migracji), które również zostały zmierzone w ich teście. Dlatego podczas analizy wydajności musimy wielokrotnie wykonać dany kod i następnie na podstawie tych danych obliczyć statystyki.

Innym błędem, który widziałem, było mierzenie wydajności aplikacji skompilowanej w trybie debug z podłączonym debuggerem z Visual Studio. Takie podejście również nie jest efektywne i wpływa na wynikach testu.

Najlepiej wykonywać testy w systemie, w którym zamknięte są wszystkie niepotrzebne aplikacje, oraz skompilować kod w trybie release. Dla przykładu – nie potrzebujemy uruchomionego Visual Studio, aby wykonać testy wydajności.

Podobnie ma się sytuacja z maszynami wirtualnymi. Lepiej wykonywać testy w maszynie fizycznej, aby również nie mieć dodatkowego narzutu.

BenchmarkDotNet

Jak widać powyżej, podczas mierzenia wydajności kodu możemy popełnić sporo błędów. Dodatkowo konieczność pisania sporej ilości dodatkowego kodu, aby efektywnie zmierzyć szybkość kodu, powoduje, że nie jest to takie proste i szybkie. Dlatego warto skorzystać z czegoś gotowego, co rozwiązuje większość z problemów, a do tego umożliwia wykonanie testu w szybki sposób.

BenchmarkDotNet (strona na githubie) jest jedną z takich bibliotek. Twórcy projektu położyli duży nacisk na to, aby zdjąć z barków programisty całą brudną robotę związaną z przygotowaniem oraz uruchomieniem mierzenia wydajności.

Między innymi w locie tworzą dedykowane projekty dla poszczególnych metod, aby w odizolowaniu zmierzyć ich wydajność. Biblioteka wyłapuje również błędy, które wykonujemy podczas uruchamiania testów, np. nie pozwoli uruchomić testu, gdy aplikacja jest skompilowana w trybie debug lub z podpiętym debuggerem.

Działanie biblioteki najlepiej zobaczyć na realnym przykładzie.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

Mierzenie wydajności metody ToDisplayString

W jednym z wcześniejszych wpisów pokazałem, jak organizuję w swoich aplikacjach wyświetlanie ładnych napisów dla typów wyliczeniowych. Wspomniałem tam, że implementacja metody ToDisplayString nie jest zbyt wydajna. Wszystko przez użytą tam refleksję. Dla przypomnienia – implementacja wyglądała tak:

public static string ToDisplayString(this Enum value)
{
if (value == null)
{
throw new ArgumentNullException("value");
}
string description = value.ToString();
EnumDescriptionAttribute[] attributes = (EnumDescriptionAttribute[])value.GetType().GetCustomAttributes(typeof(EnumDescriptionAttribute), false);
if (attributes != null && attributes.Length > 0)
{
Type resourceType = attributes[0].ResourceType;
if (resourceType != null)
{
var property = resourceType.GetProperty(description);
if (property != null)
{
var propertyValue = property.GetValue(null);
if (propertyValue != null)
{
description = propertyValue.ToString();
}
}
}
}
return description;
}

A co to znaczy, że nie jest zbyt wydajna? Gdy mówimy o wydajności, to takie sformułowania nie są precyzyjne. Należy przede wszystkim zmierzyć czas działania takiego kodu oraz porównać go do czegoś innego, co jest wydajne. Do tego właśnie najlepiej użyć BenchmarkDotNet.

Nowy projekt dla testów

Aby skorzystać z biblioteki BenchmarkDotNet, w pierwszej kolejności tworzymy nowy projekt aplikacji konsolowej. Następnie za pomocą nugeta instalujemy pakiet BenchmarkDotNet. Mając już tak przygotowany projekt, możemy dodać testy.

Na ogół dla każdego testu tworzę nowy katalog. W nim wrzucam klasę z testami oraz dodatkowe klasy z różnymi implementacjami, które chcę porównać ze sobą. Strukturę widać poniżej na zrzucie ekranu z Visual Studio.

enum localization benchmarks solution explorer

W dużych projektach, gdy tych testów jest więcej, staram się, aby struktura katalogów była taka sama, jak we właściwym projekcie – aby mieć porządek.

Dwa pierwsze testy

W pierwszej kolejności do projektu dodałem dwa testy. Pierwszy to implementacja metody ToDisplayString z wcześniejszego wpisu. Drugi to natomiast referencyjna metoda, która posłuży jako porównanie do pierwszego i każdego kolejnego testu.

Referencyjna implementacja jest bardzo prosta. Korzysta ze switcha i dla każdej wartości z enuma zwraca odpowiedni napis z pliku resource. Implementacja nie jest tak ogólna, jak metoda ToDisplayString, ale to nie jest w tym momencie istotne.

Obie metody znajdują się w klasie EnumExtensions w projekcie z testami. Dzięki temu mogę dowolnie zmieniać implementację metody ToDisplayString z właściwego projektu i wracać do testów. Obie testowe metody wyglądają tak:

public static class EnumExtensions
{
public static string ToDisplayStringResources(this OrderStatus value)
{
switch (value)
{
case OrderStatus.Created:
return OrderStatusResources.Created;
case OrderStatus.Completed:
return OrderStatusResources.Completed;
case OrderStatus.Canceled:
return OrderStatusResources.Canceled;
default:
throw new ArgumentOutOfRangeException(nameof(value), value, null);
}
}
public static string ToDisplayStringReflection(this Enum value)
{
if (value == null)
{
throw new ArgumentNullException("value");
}
string description = value.ToString();
EnumDescriptionAttribute[] attributes = (EnumDescriptionAttribute[])value.GetType().GetCustomAttributes(typeof(EnumDescriptionAttribute), false);
if (attributes != null && attributes.Length > 0)
{
Type resourceType = attributes[0].ResourceType;
if (resourceType != null)
{
var property = resourceType.GetProperty(description);
if (property != null)
{
var propertyValue = property.GetValue(null);
if (propertyValue != null)
{
description = propertyValue.ToString();
}
}
}
}
return description;
}
}

Same testy, które będziemy uruchamiać, znajdują się w klasie ToDisplayStringBenchmarks. Klasa zawiera właściwość OrderStatus, która posłuży do wygenerowania ładnego napisu, oraz dwie metody, które będą uruchamiane przez BenchmarkDotNet. Metodę należy udekorować atrybutem Benchmark, aby została przetestowana przez bibliotekę:

public class ToDisplayStringBenchmarks
{
protected OrderStatus OrderStatus { get; set; }
public ToDisplayStringBenchmarks()
{
OrderStatus = OrderStatus.Completed;
}
[Benchmark]
public string Resources()
{
return OrderStatus.ToDisplayStringResources();
}
[Benchmark]
public string Reflection()
{
return OrderStatus.ToDisplayStringReflection();
}
}

Uruchamianie testu

Uruchomienie testu jest bardzo proste – wystarczy tylko w klasie Program skorzystać ze statycznej metody Run klasy BenchmarkRunner i w parametrze generycznym określić, z jakiej klasy mają być uruchomione testy (metody z atrybutem Benchmark). W przykładzie jest to:

class Program
{
static void Main(string[] args)
{
BenchmarkRunner.Run<ToDisplayStringBenchmarks>();
}
}
view raw Program.cs hosted with ❤ by GitHub

Mając tak przygotowaną aplikację konsolową, wystarczy ją uruchomić, aby wykonać testy. Warto zauważyć, że BenchmarkDotNet pilnuje tego, że uruchamiany aplikację skompilowaną w trybie release. Gdy spróbujemy uruchomić wersję debug, dostaniemy odpowiedni komunikat:

benchmarkdotnet debug mode

Analiza wyników

Aplikacja na konsoli wyświetla informacje o aktualnych krokach testów. Na końcu dostajemy podsumowanie dla poszczególnych testów. Uruchomienie testów na moim komputerze dało następujące wyniki:

BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
Frequency=3897226 Hz, Resolution=256.5928 ns, Timer=TSC
[Host] : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0
DefaultJob : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0
```
| Method | Mean | Error | StdDev |
|----------- |------------:|-----------:|-----------:|
| Resources | 99.83 ns | 0.6599 ns | 0.5850 ns |
| Reflection | 4,572.03 ns | 30.9951 ns | 28.9928 ns |
view raw Result.txt hosted with ❤ by GitHub

Jak widać, implementacja bazująca na refleksji jest około 45 razy wolniejsza niż referencyjna implementacja (patrzymy na kolumnę Mean w podsumowaniu). Jest to dużo. Spróbujemy to zoptymalizować i porównać ponownie z wartością referencyjną.

Po wykonaniu testu możemy zobaczyć w katalogu z aplikacją kilka ciekawych rzeczy. Pojawił się nowy katalog BenchmarkDotNet.Artifacts, w którym znajdują się informacje o wykonanych testach. Jest przede wszystkim log (ToDisplayStringBenchmarks.log) z informacjami o przebiegu testów (to samo, co jest wyświetlane na konsoli) oraz raporty w różnych postaciach. BenchmarkDotNet umożliwia na przykład wygenerowanie wykresów pokazujących wyniki testów.

Etapy wykonywania testu

Analizując log BenchmarkDotNet, możemy zauważyć kilka różnych etapów, które służą do jak najlepszego zmierzenia czasu testowanych operacji. Poszczególne etapy to:

  • Pilot – służy od określenia liczby operacji wykonywanych w poszczególnych iteracjach. Biblioteka umożliwia pominięcie tego etapu i określenie wartości ręcznie.
  • IdleWarmup oraz IdleTarget – służą do zmierzenia narzutu biblioteki, który zostanie później uwzględniony podczas wyniku.
  • MainWarmup – służy do „rozgrzania” testowanej metody – chodzi o to, aby już podczas właściwego pomiaru nie uwzględniać wszystkich elementów, które wykonują się raz podczas wykonywania metody, np. ładowania dll do pamięci itp. Często też procesory potrzebują chwili czasu, aby pracować z pełną częstotliwością.
  • MainTarget – właściwe wykonywanie i pomiar czasu.

Optymalizacja ToDisplayString

Wiedząc, że wcześniej zaproponowana implementacja nie jest zbyt wydajna, mogę zaproponować coś bardziej efektywnego. Oczywiście posłużę się BenchmarkDotNet w celu sprawdzenia, ile udało się zyskać w stosunku do wersji opartej o refleksję oraz jak daleko jesteśmy od referencyjnej metody.

Poniżej widoczna jest ostateczna implementacja metody ToDisplayString, którą przygotowałem. W przykładzie na githubie znajdują się inne możliwe implementacje, które testowałem. Każda z nich jest mniej efektywna niż ostateczna. Zachęcam do pobrania kodu i sprawdzenia ich.

W ostatecznej implementacji dość mocno ograniczam ilość używanej refleksji. Klasa wygenerowana przez Visual Studio dla pliku resource wykorzystuje pod spodem instancje klasy ResourceManager, za pomocą której pobiera wartość. Dlatego w ostatecznej implementacji w pierwszej kolejności pobieram instancję ResourceManagera i później ręcznie korzystam z metody GetString, aby pobrać wartość na podstawie nazwy enuma.

Aby uniknąć ilości refleksji, zapisuję tak pobrany ResourceManager do lokalnego słownika, z którego pobranie wartości jest szybsze niż kolejne użycie refleksji.

Sama implementacja wygląda tak:

private static IDictionary<Type, ResourceManager> _resourceManagers = new ConcurrentDictionary<Type, ResourceManager>();
public static string ToDisplayStringUsingResourceManagerAndCache(this Enum value, CultureInfo culture = null)
{
string description = value.ToString();
ResourceManager resourceManager = null;
if (_resourceManagers.TryGetValue(value.GetType(), out resourceManager) == false)
{
EnumDescriptionAttribute[] attributes = (EnumDescriptionAttribute[])value.GetType().GetCustomAttributes(typeof(EnumDescriptionAttribute), false);
if (attributes.Any())
{
Type resourceType = attributes[0].ResourceType;
if (resourceType != null)
{
var resourceManagerProperty = resourceType.GetProperty("ResourceManager");
if (resourceManagerProperty != null)
{
resourceManager = resourceManagerProperty.GetValue(null) as ResourceManager;
_resourceManagers.Add(value.GetType(), resourceManager);
}
}
}
}
if (resourceManager != null)
{
return resourceManager.GetString(description, culture ?? Thread.CurrentThread.CurrentUICulture);
}
return description;
}

Po dodaniu nowego testu do klasy ToDisplayStringBenchmarks możemy wykonać jeszcze raz test i sprawdzić, o ile szybsza jest nowa implementacja:

[Benchmark]
public string UsingResourceManagerAndCache()
{
return OrderStatus.ToDisplayStringUsingResourceManagerAndCache();
}
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
Frequency=3897226 Hz, Resolution=256.5928 ns, Timer=TSC
[Host] : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0
DefaultJob : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0
Method | Mean | Error | StdDev |
----------------------------- |-----------:|----------:|----------:|
Resources | 100.7 ns | 1.211 ns | 1.133 ns |
Reflection | 4,609.6 ns | 54.148 ns | 50.650 ns |
UsingResourceManagerAndCache | 755.6 ns | 6.128 ns | 5.732 ns |
view raw Result2.txt hosted with ❤ by GitHub

Widzimy, że ta implementacja jest około 7–8 razy wolniejsza niż implementacja referencyjna, a z drugiej strony sporo szybsza niż wersja bazująca w całości na refleksji.

Można powiedzieć, że dalej jesteśmy daleko od wartości referencyjnej. Ale z drugiej strony, gdy spojrzymy na same wartości, to zauważymy, że są one bardzo małe w stosunku do, na przykład, czasu potrzebnego na wykonanie zapytania na bazie. Dlatego w tym momencie podjąłem decyzję, że już nie będę szukał bardziej wydajnej implementacji. Zysk w moich aplikacjach nie byłby odczuwalny dla użytkownika w stosunku do poświęconego czasu. Istnieje wiele innych rzeczy, które mogę zrobić, aby aplikacje działały lepiej.

Warto o tym pamiętać.

Problem ostatecznej implementacji

Analizując ostateczną implementację, można zauważyć (i przy okazji się tym zdziwić – tak było u mnie), że najbardziej kosztowną operacją jest wywołanie metody ToString na wartości enuma. Aby to pokazać, przygotowałem jeszcze jeden test, który mierzy czas wywołania tej metody:

[Benchmark]
public string EnumToString()
{
return OrderStatus.ToString();
}
Method | Mean | Error | StdDev |
----------------------------- |-----------:|-----------:|-----------:|
Resources | 100.6 ns | 0.9496 ns | 0.8883 ns |
Reflection | 4,599.0 ns | 16.3676 ns | 13.6677 ns |
UsingResourceManagerAndCache | 755.7 ns | 2.6123 ns | 2.3158 ns |
EnumToString | 604.1 ns | 1.4914 ns | 1.2454 ns |
view raw Result3.txt hosted with ❤ by GitHub

Jak widać, większość czasu z ostatecznej implementacji zajmuje właśnie ta jedna linijka. Jak wspomniałem wcześniej, można by spróbować ją jakoś wyeliminować, ale myślę, że dość mocno rozbudowałoby to implementację, czego w tym momencie nie chcę robić.

Przykład

Na potrzeby tego wpisu rozbudowałem przykład z lokalizacji enumów (repozytorium na githubie). Aby uruchomić projekt z testami (EnumLocalization.Benchmarks), należy skompilować solution w trybie release, wyłączyć wszystkie niepotrzebne aplikacje w systemie oraz uruchomić aplikację EnumLocalization.Benchmarks.exe z katalogu bin/release projektu.

Przykład zawiera kilka innych możliwych implementacji. Zachęcam do ich przejrzenia oraz spróbowania swoich sił w wymyśleniu nowej implementacji.

Podsumowanie

Mierzenie wydajności kodu nie jest łatwym tematem. Szczególnie że można popełnić sporo błędów, które mogą spowodować wyciągnięcie błędnych wniosków. Dlatego warto korzystać z gotowych narzędzi, takich jak BenchmarkDotNet, które ułatwiają ten proces i eliminują podstawowe problemy.

Gorąco zachęcam do bliższego zapoznania się z biblioteką. W przyszłości będą ją wykorzystywał wielokrotnie na tym blogu. Zobaczymy również, co jeszcze ciekawego umożliwia BenchmarkDotNet.

 

1 thought on “Jak zmierzyć wydajność kodu .NET? BenchmarkDotNet

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.