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 wykorzystali 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 zostały również zmierzone w ich teście. Dlatego podczas analizy wydajności musimy wielokrotnie wykonać dany kod i następnie na podstawie tych danych obliczyć statystki.

Innym błędem, który widziałem było wierzenie 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 są zamknięte wszystkie niepotrzebne aplikacje oraz skompilować kod w trybie release. Na przykład nie potrzebujemy uruchomionego Visual Studio, aby wykonać testy wydajności.

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

BenchmarkDotNet

Jak widać powyżej, możemy popełnić sporo błędów podczas mierzenia wydajności kodu. 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 github) 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 również wyłapuje błędy, które wykonujemy podczas uruchamiania testów. Np. nie pozwoli uruchomić testu, gdy aplikacja jest skompilowana w trybie debug lub podpiętym debugger.

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 20 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 organizuje 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:

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ć 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 BenchamarkDotNet. 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 chce porównać między 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 w 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. Natomiast drugi to referencyjna metoda, która posłuży jako porównanie do pierwszego jak i każdego kolejnego testu.

Referencyjna implementacja jest bardzo prosta. Korzysta z switch 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:

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ę:

Uruchamianie testu

Uruchomienie testu jest bardzo proste, wystarczy tylko w klasie Program skorzystać z 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:

Mając tak przygotowaną aplikację konsolowa 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 wyniki:

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 to wartości referencyjnej.

Po wykonaniu testu możemy zobaczyć kilka ciekawych rzeczy w katalogu z aplikacją. Pojawił się nowy katalog BenchmarkDotNet.Artifacts, w którym są 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 ilości operacji wykonywanych w poszczególnych iteracjach. Biblioteka umożliwia pominięte tego etapu i określenie wartości ręcznie
  • IdleWarmup oraz IdleTarget – służy 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. ładowanie dll do pamięci itp. Czy też często procesory potrzebują chwili czasu, aby pracować z pełną częstotliwością
  • MainTarget – właściwe wykonywanie i pomiar czasu

Optymalizacja ToDisplayString

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

Poniżej widoczna jest ostateczne implementacja metody ToDisplayString, którą przygotowałem. W przykładzie na github 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 wygenerowania 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ść refleksji zapisuje 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:

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

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 jak 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 decyzje, że już nie będę szukał bardziej wydajnej implementacji. Zysk w moich aplikacja 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ą implementacje 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:

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 github). 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 to ich przejrzenia oraz spróbowanie swoich sił w wymyśleniu nowej implementacji.

Podsumowanie

Mierzenie wydajności kodu nie jest łatwy 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 to bliższego zapoznania się w biblioteką. W przyszłości będą nią wykorzystywał wielokrotnie na tym blogu. Również zobaczymy, co jeszcze ciekawego umożliwia BenchmarkDotNet.

 

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

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *