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

Summary BenchmarkDotNet

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.