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( 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.