Előzőleg megnéztük, hogy hogyan tudjuk gyorsítani a programunk futását vektorok használatával és ennek kapcsán jogosan merül fel a kérdés, hogy valójában mennyit is gyorsítottunk a programunk működésén?
Itt jön képbe a teljesítménymérés, teljesítménytesztelés, ami nem a legegyszerűbb feladatok közé tartozik, ha konzisztens és megbízható eredményeket szeretnénk kapni, amiket egymáshoz lehet hasonlítani.
A nehézség egy része abból fakad, hogy az operációs rendszerünk a folyamatok között folyamatosan váltogat, így ha mérés közben mást is végzünk, akkor az valamekkora mértékben hatással van a mérés eredményére. Éppen ezért ha, a cél mindig reprodukálható mérések futtatása, akkor azt egy szűz, minden felhasználói háttérfolyamattól mentes operációs rendszeren érdemes végezni, ami nem virtualizált környezetben fut, mert maga a virtualizáció is egy befolyásoló tényező.
A második nehézség a .NET működéséből adódik, de a probléma nem .NET specifikus. Minden olyan nyelv, ami JIT fordítással készül, érintett a problémakörben, ami maga a JIT működéséből adódik és a következő: Nem mindegy, hogy egy adott metódus mennyi alkalommal van végrehajtva, mert belsőleg más optimalizáción, kód generáláson esik át, ezért egy mérésből nem lehet megbízható eredményt produkálni. Mindig lesz valamekkora szórás a futások között.
A harmadik probléma pedig maga az idő. A mai számítógépek gyorsak. Gyors alatt pedig nanoszekundum nagyságrendű utasítás végrehajtásról beszélünk, amit az operációs rendszer miatt nem is olyan egyszerű mérni, de nem lehetetlen.
Egy megbízható sebességmérő rendszer összerakása nem kis feladat, annak ellenére, hogy „csak” idő méréséről beszélünk. Éppen ezért ez tipikusan az a kód, amit nem érdemes magunknak lefejlesztünk. Szerencsére van megoldás, mégpedig a Benchmark.NET NuGet csomag segítségével.
A Benchmark.NET „A” nagybetűs .NET sebességmérő megoldás, amit a Microsoft is előszeretettel használ a keretrendszer sebességének és optimalizációinak mérésére. Nagyon nagy előnye, hogy a bonyolult feladatot nagyon leegyszerűsíti és legalább olyan egyszerűvé teszi a teljesítmény tesztek írását, mint ha unit teszteket írnánk. Használatát egy példán keresztül a legegyszerűbb bemutatni, ezért nézzük is meg, hogy hogyan tudjuk lemérni azt, hogy az előzőleg megírt vektorizált algoritmusunk mennyivel gyorsabb, mint a hagyományos ciklusos megoldás:
using BenchmarkDotNet.Attributes;
using System;
using System.Linq;
namespace Benchmark
{
[SimpleJob]
public class BenchmarkContains
{
private byte[] _data;
[GlobalSetup]
public void Setup()
{
_data = Enumerable.Repeat((byte)123, 999).Append((byte)42).ToArray();
}
[Benchmark(Baseline = true)]
[Arguments((byte)42)]
public bool Contains(byte value)
=> Contains(_data, value);
[Benchmark]
[Arguments((byte)42)]
public bool ContainsWithVector(byte value)
=> Implementation.ContainsVector(_data, value);
private bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
return false;
}
}
}
Az első dolgunk, hogy készítsünk egy konzol alkalmazást és telepítsük a BenchmarkDotNet NuGet csomagot. Ezt követően létre kell hoznunk egy osztályt, ami az elvégzendő méréseket tartalmazza majd. A méréseket tartalmazó osztályt a SimpleJob attribútummal annotálnunk kell. Ennek, ha nem adunk paramétert, akkor a programban beállított .NET verziót fogja használni, azonban lehetőségünk van több keretrendszeren is tesztelnünk, ha az attribútum konstruktorában megadunk egy RuntimeMoniker típusú keretrendszer azonosítót. Például, ha tesztelni szeretnénk, hogy egy algoritmus hogyan teljesít .NET Framework 4.8.1 és .NET 8 alatt, akkor a tesztünket így kell annotálnunk:
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net481)]
A GlobalSetup attribútummal megjelölt metódus egyszer, a tesztfuttatás elején fog végrehajtódni és az itt elhelyezett kód futási ideje nem lesz beleszámítva a végső eredménybe. A tényleges méréseket a Benchmark attribútummal kell annotálnunk. Ezeket paraméterezni is tudjuk az Arguments attribútum segítségével, így könnyen készíthetünk méréseket az algoritmus legjobb és legrosszabb esetének vizsgálatára.
Mivel itt most egy keresésről van szó 1000 elem esetén, ezért nemes egyszerűséggel csak a legrosszabb esetet mérjük, mivel a legjobb esetben az első elem a keresett, ami esetén nincs értelme két keresést összehasonlítani. A kódban a Implementation.ContainsVector metódus az előző fejezetben bemutatott vektorizált keresés implementációja, a Contains pedig a hagyományos lineáris keresés implementációja. Ezen mérés esetén a Baseline = true paraméter az attribútumban azt jelzi, hogy a mérések viszonyítási alapja ez lesz, vagyis majd a kimeneti eredményekben a sebességkülönbség viszonyszámának ez lesz az alapja.
A mérésünk megírása után a Main metódusunkban annyi dolgunk van, hogy a BenchmarkRunner.Run metódust meghívjuk a méréseket tartalmazó osztályunkra:
using BenchmarkDotNet.Running;
namespace Benchmark
{
internal sealed class Program
{
private static void Main(string[] args)
{
BenchmarkRunner.Run<BenchmarkContains>();
}
}
}
Ezt követően Release konfigurációban fordítva debugger nélkül elindítjuk a programunkat. A Release konfiguráció kiemelten fontos, mivel Debug módban a fordító nem igen optimalizál a hibakereshetőség miatt.
A program indulása után a futtató újrafordítja a kódot a megadott keretrendszerekre és számos alkalommal lefuttatja a mérést, mivel egy mérés nem mérés. Közben pedig feljegyzi a mért időket és végül az eredményeket prezentálja nekünk táblázatos formában, ami az én gépemen (.NET 8 RC1-es keretrendszer, AMD Ryzen 5 3600 CPU) ezt eredményezte:
| Method | value | Mean | Error | StdDev | Ratio |
|------------------- |------ |----------:|---------:|---------:|------:|
| Contains | 42 | 382.75 ns | 6.865 ns | 6.086 ns | 1.00 |
| ContainsWithVector | 42 | 18.35 ns | 0.242 ns | 0.227 ns | 0.05 |
A táblázatban látunk egy átlagos futási időt, egy hiba oszlopot és egy szórást, illetve a Baseline attribútum miatt egy viszonyszámot, ami a hagyományos algoritmusunk esetén 1.00 lett, a vektorizált esetén pedig 0.05, ami azt jelenti, hogy a korábban bemutatott vektorizált megoldás 20x gyorsabb.