Wydajność funkcji jest ważną miarą w każdej aplikacji. Zanim zaczniemy optymalizować kod aplikacji w pierwszej kolejności należy zidentyfikować fragment kodu, który działa nie optymalnie, tu z pomocą przyjdą profilery. Udało się znaleźliśmy fragment kodu, wprowadziliśmy poprawkę i jak najszybciej można porównać wydajność poprawki w odniesieniu do wersji pierwotnej? Pierwszym naszym odruchem jest wykorzystanie klasy StopWatch z przestrzeni nazw System.Diagnostics. Klasa jest „stoperem” udostępniająca metody służące do dokładnego pomiaru czasu wykonania danego fragmentu kodu. Inną opcją udostępniającą większe możliwości i przyspieszającą proces testowania jest biblioteka BenchmarkDotNet.
Testy z BenchmarkDotNet
Dziś wykorzystam VS Code, zatem zacznijmy od stworzenia projektu i dodania paczki BenchmarkDotNet z NuGet z poziomu .NET Core CLI.
dotnet new console -n MyBenchmarks cd MyBenchmarks dotnet add package BenchmarkDotNet dotnet restore
W ramach testu zostaną porównane czasy wykonania dwóch metod, których zadaniem jest inicjalizacja dwuwymiarowej tablicy wartościami -9999 (nodata). Metody, których czasy wykonania chcemy porównać oznaczamy atrybutem Benchmark.
using BenchmarkDotNet.Attributes; namespace MyBenchmarks { public class Initializer { private const int NODATA = -9999; private const int N = 5000; private const int M = 5000; [Benchmark] public int[,] InitializeArrayWithFastLoop() { var array2D = new int[N,M]; for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { array2D[i, j] = NODATA; } } return array2D; } [Benchmark] public int[,] InitializeArrayWithSlowLoop() { var array2D = new int[N,M]; for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { array2D[j, i] = NODATA; } } return array2D; } } }
Po zaimplementowaniu naszej klasy, należy uruchomić benchmark. Benchmark możemy uruchomić na kilka sposobów: Types, Url, Source lub BenchmarkSwitcher. W analizowanym przykładzie uruchomię za pomocą Types.
using System; using BenchmarkDotNet.Running; namespace MyBenchmarks { class Program { static void Main(string[] args) { var summary = BenchmarkRunner.Run(typeof(Initializer)); } } }
Aplikacje uruchamiamy dla konfiguracji Release w celu uzyskania wyników zgodnych z rzeczywistością. W przypadku uruchomienia aplikacji w konfiguracji Debug otrzymamy ostrzeżenie. Przed wykonaniem testów, najlepiej zamknąć wszystkie aplikacje, także IDE i uruchomić aplikacje przez interpreter poleceń np. PowerShell.
dotnet run --configuration Release
Wyniki analizy
Benchmark zależy od maszyny, na każdej stacji roboczej wyniki mogą być inne. Po wykonaniu analizy porównawczej, otrzymamy wyniki w formie poniższej tabeli. Rezultaty analizy także zapisywane są w katalogu .\BenchmarkDotNet.Artifacts\results w naszym projekcie. Znajdziemy tam np. plik .csv, .md, .html z wynikami analizy (domyślne formaty).

Testowane metody różniły się sposobem przejścia po macierzy. Najlepszy wyniki uzyskano dla przypadku przejścia wierszami, który był wydajniejszy od przejścia kolumnami. Elementy tablicy ułożone są w ciągłym obszarze pamięci wierszami. Czytając kolumnami za każdym razem odwołujemy się do innego cache line (zazwyczaj 64 bajty), co nie jest w tym przypadku wydajne. Czytając wierszami wykorzystujemy cały cache line.
Fluent config
BenchmarkDotNet udostępnia możliwość własnej konfiguracji testów po przez implementacje klasy dziedziczącej po ManualConfig lub użycie fluent interface. W configu możemy ustawić następujące elementy:
- Jobs – opisują środowisko testu (Platform, Runtime, Jit, GcMode), oraz sposób testowania metody (RunStrategy , LaunchCount, WarmupCount, TargetCount, IterationTime, UnrollFactor, InvocationCount). BenchmarkDotNet posiada algorytm automatycznego wybierania wartości parametrów testowania metody, który dopasowuje odpowiednią ilość wywołań metod;
- Columns – określają jakie kolumny znajdą się w tabeli podsumowania testu;
- Exporters – określają jakie formaty plików z wynikami będą znajdowały się w katalogu .\BenchmarkDotNet.Artifacts\results;
- Loggers – umożliwiają logowanie wyników testów np. na console, do pliku;
- Diagnosers – umożliwiają dodanie danych diagnostycznych do wyniku testu;
- Analyzers – analizują benchmark i mogą tworzyć ostrzeżenia np. o ustawionym trybie debug;
- Validators – weryfikują benchmark przed wykonaniem;
- Filters – umożliwiają filtrowanie testów;
- OrderProvider – umożliwia dostosowanie wyświetlania kolejności wyników w tabeli podsumowującej test.
Poniżej przykład konfiguracji z użyciem fluent interface.
var summary = BenchmarkRunner.Run<Initializer>( ManualConfig .Create(new ManualConfig()) .With(Job.Core .With(Platform.X64) .With(Jit.RyuJit)) .With(CsvExporter.Default) .With(HtmlExporter.Default) .With(MarkdownExporter.Default) .With(JsonExporter.Default) .With(XmlExporter.Default) .With(TargetMethodColumn.Method) .With(StatisticColumn.Mean) .With(StatisticColumn.Median) .With(StatisticColumn.Min) .With(StatisticColumn.Max) .With(RankColumn.Arabic) .With(ConsoleLogger.Default) .With(MemoryDiagnoser.Default) .With(EnvironmentAnalyser.Default));
Podsumowanie
BenchmarkDotNet umożliwia w prosty sposób wykonanie testów wydajności metod poprzez dodanie kilku atrybutów. Biblioteka ma duże możliwości konfiguracji, o których więcej można doczytaj w dokumentacji.