Az optimalizáció fejezetben szó volt az SMID utasításokról, amelyek lehetővé teszik, hogy egyszerre több adatot dolgozzunk fel egy utasítással. Az első ilyen utasításkészlet a Pentium I-ben bemutatkozott MMX volt, ami 128 bites belső regiszterekkel dolgozik. Ez az utasításkészlet multimédiára lett kitalálva és leginkább lebegőpontos számokkal boldogul el. Ezt az idő előrehaladtával újabb és újabb utasításkészletek követték és nem csak Intel oldalon. Az AMD is fejlesztett sajátot, illetve az ARM is rendelkezik saját megoldással.
Közös jellemzőjük ezeknek az utasításkészleteknek, hogy ma már nem csak lebegőpontos számokkal boldogulnak el, hanem nagyjából bármilyen típussal, egészen addig, amíg az befér az utasításkészlet által használt regiszterekbe. X64 oldalon jelenleg az AVX-2 utasításkészlet mind a két nagy gyártó által támogatott és ezzel egyszerre 256 bit adat dolgozható fel.
Az AVX-2 továbbfejlesztése az AVX-512, ami 512 bites belső regiszterekkel dolgozik és sajnos ez a kifejezés valójában egy esernyőnyi utasításkészletet jelöl, ezért szinte biztos, hogy egy AVX-512-t támogató processzor nem minden utasítást támogat. Relatíve új utasításkészletnek számít, amit az AMD processzorok 2022-ben kezdtek el támogatni. A jövőben azonban egyre nagyobb hangsúlyt fog kapni a támogatottsága.
De mit lehet kezdeni 256 bittel? Például betölthetünk 8db int számot és ezek összegét kiszámolhatjuk. Ennek leginkább akkor van értelme, ha mondjuk egy tömb elemeit összegezzük. Teoretikusan egy 64 000 elemű tömb összegének kiszámolása nagyon egyszerűsítve 64 000 db összeadást igényel hagyományos módon lekódolva, míg AVX-2 utasításokkal elvégezhető 9000 db összeadás segítségével, ami jelentős műveletszám csökkenés, ami végső soron a sebesség növekedéséhez vezet. De nem csak a matematikai műveletek gyorsítására lehet ezt alkalmazni, hanem például egy string.Contains() művelet is gyorsítható, mivel 256 bitbe 16db UTF-16 karakter belefér.
Ezen utasításkészletek alkalmazásának több hátráltatója is volt. Az első, hogy nem minden processzor támogat minden utasítást, ezért vagy többször kell megírni ugyanazt az algoritmust különböző módon, vagy egy olyan utasításkészletet kell használni, ami mindenhol jelen van, de nem a legújabb. A másik hátráltató tényező, hogy ezen utasítások igencsak bonyolultak és nagyon sokáig hatékonyan csak kézzel megírt assembly segítségével voltak használhatóak.
Az assembly használata pedig kizáró tényező volt a .NET kódok számra, mivel a .NET alkalmazások a CLR felett futnak, így nem tudtunk közvetlenül a gép számára kódot írni. Itt fontos a mult iidő, mivel .NET 6 óta lehetőség van intrinsics utasításokat is használnunk, illetve a System.Numerics.Vector 1 típus platformtól és processzortól függetlenül rendelkezik hardveres SMID utasítások támogatásával. Ennek előnye, hogy ha az algoritmusunkat fel tudjuk írni úgy, hogy vektorokat használjon és ezt a beépített típust alkalmazzuk, akkor jó eséllyel gyorsulni fog a működése.
A .NET 8 futtatókörnyezetének egyik legnagyobb újdonsága, hogy a háttérben a natív kód generálásakor, ha lehetősége van rá, akkor törekszik SMID utasításokat használni, így a tényleges kódunk módosítása nélkül élvezhetjük a gyorsabb működést.
Az alábbi példa a System.Numerics.Vector használatát mutatja be egy kereső algoritmus esetén:
using System;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
namespace VectorIntrisics
{
internal static class Program
{
//Vektorizált megoldás
private static unsafe bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
//Ha a hardver támogatja
if (Vector.IsHardwareAccelerated && haystack.Length >= Vector<byte>.Count)
{
//memória cím fixálása
//hogy miközben fut az algoritmus ne helyezze át a GC
//a tömb helyét, mert az bezavarhat
fixed (byte* haystackPtr = &MemoryMarshal.GetReference(haystack))
{
Vector<byte> target = new Vector<byte>(needle);
byte* current = haystackPtr;
byte* endMinusOneVector = haystackPtr + haystack.Length - Vector<byte>.Count;
do
{
//vektor keresés
if (Vector.EqualsAny(target, *(Vector<byte>*)current))
{
return true;
}
current += Vector<byte>.Count;
}
while (current < endMinusOneVector);
if (Vector.EqualsAny(target, *(Vector<byte>*)endMinusOneVector))
{
return true;
}
}
}
else
{
//normál megoldás, ha nincs hw támogatás
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
}
return false;
}
private static void Main(string[] args)
{
//adat generálása amiben keresünk
byte[] data = Enumerable.Repeat((byte)123, 999).Append((byte)42).ToArray();
//teljes tömbön keresünk
bool result = Contains(data, 42);
Console.WriteLine(result);
}
}
}
A fenti kódrészlet nem meglepő módon egy True értéket ad vissza. Az algoritmus lényege, hogy nem egyesével nézzük végig a keresett byte-ra a tömböt, hanem egyszerre 32 byte-on vagy 16 byte-on (utasításkészlettől függően) vizsgáljuk meg a tartalmazást.
Ha igazán bele szeretnénk mászni az SMID lehetőségekbe, akkor a System.Runtime.Intrinsics.X86 és System.Runtime.Intrinsics.Arm névterekben találunk osztályokat, amelyek statikus metódusai az SMID utasításokat reprezentálják és a futtatókörnyezet úgy, ahogy van hajtja őket végre. Az egyes SMID utasítások leírásai a https://learn.microsoft.com/en-us/dotnet/api/system.runtime.intrinsics.x86 és a https://learn.microsoft.com/en-us/dotnet/api/system.runtime.intrinsics.arm címen találhatóak, ami a .NET oldalt illeti. A tényleges hardveres utasításkészletek leírásai az Intel esetén az „Intel® 64 and IA-32 Architectures Software Developer’s Manual”2 című, több mint 5000 oldalas kiadványban vannak felsorolva. Hasonló dokumentum ARM processzorokhoz is létezik. 3
-
A vektor típusok mellett a
System.Numerics.Matrix4x4ésSystem.Numerics.Matrix3x2típusok is hardver gyorsítottak, ha mátrix műveletekkel dolgozunk.↩ -
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html↩
-
https://arm-software.github.io/acle/neon_intrinsics/advsimd.html↩