Backend,  C#

Mierzenie wydajności kodu w C# – BenchmarkDotNet

Jak się dowiedzieć, czy nasz kod działa wolno i przydałoby się go przyśpieszyć? Często opieramy się na naszej wiedzy, doświadczeniu oraz intuicji. Innym razem dostajemy uwagi od naszego zespołu podczas code review. Albo po prostu widzimy, że aplikacja się wlecze, jak czas pracy w poniedziałek. Nie zawsze jednak uda nam się to dostrzec, bo my ludzie, mamy spowolnioną percepcję, która nie nadąża za obliczeniami komputera. Łatwo zauważymy różnicę sekund, ale z mniejszymi wartościami, już sobie nie radzimy. Z pomocą może przyjść BenchmarkDotNet.

Bo dobrze by było móc zobaczyć wyniki prędkości kodu. Tak by mieć liczby przed oczami i móc porównać czasy. Moglibyśmy wtedy sprawdzić ilokrotnie dany kod wykonujący tą samą czynność jest szybszy i czy warto go zastąpić. Możemy skorzystać z biblioteki, która nam to wszystko pokaże.

Czym jest BenchmarkDotNet?

Biblioteka ta pozwala w łatwy sposób wyświetlić pomiary wydajności kodu. Posługuje się wieloma metrykami, które można dostosowywać. W podstawowej wersji wyniki widoczne są w konsoli, jednak można je eksportować do innych postaci. Z biblioteki korzysta wiele ważnych narzędzi, takich jak: .NET Runtime oraz .NET Performance. Z tego powodu można założyć, że jest wiarygodna.

Najprościej rzecz ujmując, oznacza się metody, które chce się zbadać i po uruchomieniu aplikacji widzi się ich pomiary. W przykładzie pokaże Wam jak wykorzystać tę bibliotekę dodając do kodu tylko 2 dodatkowe linijki.

Przykład

W przykładzie porównamy różne sposoby na wyciąganie elementu z tablicy. Jeżeli element istnieje to go zwrócimy, a jak nie, to zwrócimy nulla. Operacje będą robiły dokładnie to samo, tylko na inny sposób. Zmierzą się:

  1. Metoda LINQ FirstOrDefault.
  2. Dwie metody LINQ – najpierw Any, żeby dowiedzieć się czy dany element jest w tablicy, a następnie First.
  3. Tradycyjne podejście z ręcznym wykorzystaniem pętli for.

Coś mi mówi, że druga forma będzie najwolniejsza, ponieważ przeszuka element w tablicy dwukrotnie. Tradycyjna pętla powinna wygrać, bo to czysta logika i operacje. Zapewne FirstOrDefault robi to samo, ale jest otoczką pod ten mechanizm i może na tym tracić.

Przygotowanie projektu

Utworzymy teraz aplikacje konsolową. Po tym od razu dodamy bibliotekę BenchmarkDotNet, która wykona całą robotę z mierzeniem wydajności. Instalujemy ją za pomocą Nugeta:

BenchmarkDotNet in nuget

albo wpisując komendę w konsoli:

Install-Package BenchmarkDotNet

Kod do porównania

Zaimplementuję teraz całość kodu, który będziemy chcieli zbadać. Metody do wyciągania elementu znajdą się w klasie, która dodatkowo przyda się, dlatego że wskazujemy ją podczas uruchomienia benchmarku. Wypełnienie tablicy będzie w konstruktorze, żeby nie zakłócać pomiarów wydajności metod.

Do zmiennej n jawnie przypiszę element, który będę chciał wyciągnąć podczas testów benchmarka. Sama biblioteka posiada obsługę parametrów, ale żeby nie przeciągać tego wpisu, to o parametrach zrobię oddzielny artykuł.

public class GetElementService
{
    int[] array;
    public int n;
    public GetElementService()
    {
        array = [ 54, 763, 1, 43, 423, 2, 77, 342, 542, 78, 21, 65, 23, 3, 6, 32, 2, 21, 
            4312, 6532, 34, 1, 34, 1, 6, 3, 3, 5, 3, 2, 5, 6, 2, 123, 43, 543, 231, 543, 21, 65, 2134, 4 ];
    }
}

Czas zaimplementować nasze kandydatki na miss wydajności, czyli metody, które będziemy porównywać. Wszystkie one znajdą się w tej samej klasie, którą zdefiniowałem powyżej. Skupmy się na implementacji metody z FirstOrDefault:

public int? GetElementByFirstOrDefault()
{
    return array.FirstOrDefault(x => x == n);
}

Teraz sprawdzimy, czy element istnieje w tablicy używając metody Any, a później wyciągniemy element metodą First:

public int? GetElementByFirstAndAny()
{
    if (array.Any(x => x == n))
    {
        return array.First(x => x == n);
    }
    return null;
}

No i na sam koniec podstawowa szkoła tańca z kodem, stara pętla for, która zwróci element jeżeli go znajdzie:

public int? GetElementByFor()
{
    for (int i = 0; i < array.Length; i++)
    {
        if (array[i] == n)
        {
            return n;
        }
    }
    return null;
}

Wykorzystanie biblioteki

To co zrobiliśmy do tej pory, to nic innego jak implementacja potrzebnych funkcji do systemu. Nie ma jeszcze nic, co przetestowało by wydajność. Zróbmy to teraz, używając jedynie dwóch różnych linijek kodu. W pierwszej kolejności do każdej metody, dodajemy atrybut [Benchmark]. Robimy tak samo we wszystkich metodach:

[Benchmark]
public int? GetElementByFirstAndAny()
{
    if (array.Any(x => x == n))
    {
        return array.First(x => x == n);
    }
    return null;
}

Następnie musimy jeszcze dopisać linię kodu, która uruchomi benchmarka. Służy do tego metoda BenchmarkRunner.Run<T>(), która w generycznym parametrze dostaje klasę ze zdefiniowanymi benchmarkami. Wywołamy ją w metodzie Main.

internal class Program
{
    static void Main(string[] args)
    {
        BenchmarkRunner.Run<GetElementService>();
    }
}

Na sam koniec trzeba jeszcze zmienić tryb uruchomienia aplikacji, ponieważ wymaga tego biblioteka. Nie może być to tryb debug, bo zawiera w sobie niepotrzebne elementy, które mogą spowolnić działanie aplikacji, a w badaniu wydajności tego nie chcemy. Przełączmy się na relase. Tutaj jeszcze całość napisanego kodu:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace BenchmarkDotNetForBlog;

internal class Program
{
    static void Main(string[] args)
    {
        BenchmarkRunner.Run<GetElementService>();
    }
}

public class GetElementService
{
    int[] array;
    int n = 500;
    public GetElementService()
    {
        array = [ 54, 763, 1, 43, 423, 2, 77, 342, 542, 78, 21, 65, 23, 3, 6, 32, 2, 21, 
            4312, 6532, 34, 1, 34, 1, 6, 3, 3, 5, 3, 2, 5, 6, 2, 123, 43, 543, 231, 543, 21, 65, 2134, 4 ];
    }

    [Benchmark]
    public int? GetElementByFirstOrDefault()
    {
        return array.FirstOrDefault(x => x == n);
    }

    [Benchmark]
    public int? GetElementByFirstAndAny()
    {
        if (array.Any(x => x == n))
        {
            return array.First(x => x == n);
        }
        return null;
    }

    [Benchmark]
    public int? GetElementByFor()
    {
        for (int i = 0; i < array.Length; i++)
        {
            if (array[i] == n)
            {
                return n;
            }
        }
        return null;
    }
}

Pomiary wydajności

Czas zobaczyć, co biblioteka nam wypluje. Po uruchomieniu aplikacji na konsoli dostaniemy pomiary. Widzimy, z jaką prędkością działają metody, dzięki czemu możemy zdecydować, którą chcemy użyć, gdy różnica jest znacząca. Na samo wygenerowanie benchmarku musimy poczekać, bo nie jest to operacja błyskawiczna. No dobra, poświęciłem się dla Ciebie i ze stoperem w ręku wyszła minuta i 25 sekund. Po czym zobaczyłem, że czas pracy benchamarka również jest wyświetlony na konsoli, więc było to poświęcenie niepotrzebne.

Tutaj wyniki dla poszukiwania elementu o wartości 21, który jest 11 elementem w tablicy:

Można teraz porównać i przemyśleć czy takie wyniki mają sens. Dwa razy większa różnica czasu przy dwóch metodach LINQ jest sensowna. Program będzie musiał przejść przez elementy tablicy dwukrotnie niż w przypadku tylko jednej metody FirstOrDefault, co oznacza o połowę więcej wykonanych operacji. Spodziewałem się, że pętla for będzie najszybsza w tym rankingu, jednak nie wiedziałem jak bardzo i spodziewałem się mniejszej różnicy.

Tutaj wyniki dla elementu, który nie znajduje się w tablicy:

Metody z użyciem LINQ wyrównały się w czasie. Skoro nie został wykryty żaden element to metoda nie wywołuje metody First, tylko od razu zwraca nulla. Nie ma sytuacji dwukrotnego przechodzenia przez kolekcje.

Pamiętaj, że to tylko przykłady, które mają zademonstrować bibliotekę BenchamarkDotNet. Z założenia mają być one proste i łatwe w zrozumieniu, a to już ty sam wykorzystasz bibliotekę do swoich własnych i cnych potrzeb.

Łamanie ustalonych zasad

Wcześniej napisałem, że metody należy oznaczyć atrybutem [Benchmark], a kod uruchomić w trybie release, a nie debug. Ale co, jeżeli nie zastosujemy się do tych zaleceń i zrobimy na przekór?

1. Nieustawienie atrybutu Benchmark spowoduje, ze dana metoda nie zmierzy swojej wydajności. Jeżeli w jednej z trzech metod usuniemy atrybut, to zobaczymy wyniki jedynie dla dwóch.

2. Brak jakiegokolwiek atrybutu Benchmark oznacza, że nie wykonają się żadne testy wydajności. No bo jak odnaleźć coś co nie istnieje. Na konsoli pojawi się nic, czyli tak:

3. Podczas uruchomienia aplikacji w trybie debug, biblioteka sama nam powie, że coś jest nie tak i na konsoli dostaniemy taki komunikat:

4. Biblioteka sama waliduje, czy metody napisane są poprawnie. Może być sytuacja, że sama implementacja będzie poprawna, ale ich wywołanie już nie. Np. metody będą miały parametr, którego nie obsłużymy. Na konsoli pojawi się stosowny komunikat:

Podsumowanie

Wiesz już w jaki sposób możesz łatwo sprawdzić i porównać wydajność swojego kodu. Ten wpis jest wprowadzeniem do korzystania z biblioteki BenchmarkDotNet i nie wyczerpuje tematu. Po więcej możesz udać się do ich dokumentacji lub zaczekać na kolejne materiały na moim blogu (tylko, żeby Cię starość w tym oczekiwaniu nie zastała).

Biblioteka fajnie pokazuje wydajność na prawdziwych liczbach, co może pozwolić wybrać własciwą implementacje. Kiedyś do takich rzeczy pisało się swoje timery, a teraz już nie trzeba. Można zastosować ją w celu optymalizacji kodu w rzeczywistych projektach lub po prostu zwyczajnej nauki by zaspokoić swoją ciekawość. Ma jednak swoje ograniczenia, trzeba stworzyć dogodne warunki do takich benchmarków i nie zawsze będzie właściwym narzędziem. Gdzie benchmark nie może tam profiler pomoże.

Jednak pamiętaj, że nie zawsze najszybsze rozwiązanie jest tym najlepszym. Nie wyobrażam sobie, by mając świadomość, że pętla for jest szybsza niż FirstOrDefault przestać korzystać z LINQ i za każdym razem implementować pętlę for od zera. Po to powstało LINQ, żeby z niego korzystać. Należy zachować umiar, zobaczyć jak duża jest różnica w wydajności, poznać zalety danego rozwiązania i ostatecznie dobrać go pod siebie. Jakby wydajność miała zawsze być najważniejszym czynnikiem, nikt nie programował by w językach wysokiego poziomu, takich jak C#, tylko dalej tłuklibyśmy kod w asemblerze. No, może tak byłoby nawet i lepiej 😀

Daj znać, czy używasz BenchmarkDotNet i co ciekawego pozwolił Ci odkryć.

Czego się dowiedziałeś?

  1. Wiesz jak sprawdzić wydajność swojego kodu, by mieć liczby przed oczami.
  2. Umiesz porównać wydajność takich samych implementacji danego rozwiązania.
  3. Wiesz do czego służy biblioteka BenchmarkDotNet.
  4. Umiesz konfigurować i korzystać z podstaw biblioteki BenchmarkDotNet.
  5. Wiesz, że nie zawsze wydajność jest lepsza od czytelności kodu.
  6. Ale również wiesz, że kod wydajny jest lepszy od niewydajnego. Trzeba każdy przypadek rozważyć osobno.
  7. Wiesz dlaczego benchmark do sprawdzenia wydajności nie korzysta z debug, a z release.
Social media & sharing icons powered by UltimatelySocial