Hier ein kurzer Überblick über die Vektorisierungsfunktionen von Algorithmen in .NET Framework und .NET Core. Dieser Artikel richtet sich an diejenigen, die nichts über diese Techniken wissen. Ich werde auch zeigen, dass .NET für die native Entwicklung nicht hinter "echt kompilierten" Sprachen zurückbleibt.
Ich fange gerade an, Vektorisierungstechniken zu lernen. Ich würde mich freuen, wenn Community-Mitglieder eindeutige Fehler finden oder Verbesserungen der beschriebenen Algorithmen vorschlagen.
Etwas Geschichte
SIMD wurde 2015 in .NET Framework 4.6 veröffentlicht. Dann wurden die Typen Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 und Vector4 hinzugefügt. Sie erlaubten vektorisierte Berechnungen. Als nächstes kam der Typ Vector <T>, der mehr Möglichkeiten zur Vektorisierung von Algorithmen bot. Viele Programmierer waren jedoch immer noch unzufrieden, da diese Typen die Ideenströme der Codierer einschränkten und nicht die volle Kapazität von SIMD-Anweisungen in modernen Prozessoren nutzen ließen. In der Vorschau von .NET Core 3.0 haben wir jetzt den System.Runtime.Intrinsics-Namespace, der viel Freiheit bei der Auswahl von Anweisungen bietet. Um die höchste Geschwindigkeit zu erzielen, müssen Sie RyuJit verwenden und entweder auf die x64-Assembly zurückgreifen oder ausschalten. Bevorzugen Sie 32-Bit und wählen Sie AnyCPU-Assembly. Ich habe alle Benchmarks auf einem Intel Core i7-6700 3,40 GHz (Skylake) CPU-Computer ausgeführt.
Array-Elemente summieren
Ich habe mich entschlossen, mit einer klassischen Aufgabe zu beginnen, die normalerweise an erster Stelle steht, wenn es um Vektorisierung geht. Es geht darum, die Summe der Array-Elemente zu finden. Schreiben wir vier Implementierungen dieser Aufgabe, um die Elemente von Array zu summieren.
Die naheliegendste Implementierung:
public int Naive() { int result = 0; foreach (int i in Array) { result += i; } return result; }
LINQ-basierte Implementierung:
public long LINQ() => Array.Aggregate<int, long>(0, (current, i) => current + i);
Die Implementierung basiert auf Vektoren von System.Numerics:
public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; }
Die Implementierung basiert auf Code aus dem System.Runtime.Intrinsics-Namespace:
public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; }
Ich habe diese 4 Methoden auf meinem Computer verglichen und die folgenden Ergebnisse erzielt:
Es ist klar, dass Lösungen mit Vektoren und Intrinsics viel schneller sind als die offensichtlichen und LINQ-basierten Lösungen. Jetzt müssen wir herausfinden, was in diesen beiden Methoden vor sich geht.
Betrachten wir die Vektorenmethode genauer:
Vektoren public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; }
- int vectorSize = Vector <int> .Count; - Die Anzahl der 4-Byte-Zahlen, die wir in einen Vektor einfügen können. Wenn die Hardwarebeschleunigung verwendet wird, zeigt dieser Wert an, wie viele 4-Byte-Nummern in ein SIMD-Register eingefügt werden können. Tatsächlich wird angezeigt, wie viele Elemente dieses Typs gleichzeitig verarbeitet werden können.
- accVector ist ein Vektor, der das Ergebnis der Funktion akkumuliert.
- var v = neuer Vektor <int> (Array, i); - Die Daten aus dem Array werden beginnend mit dem i-Index in einen neuen v-Vektor geladen. Die Vektorgröße der Daten wird genau geladen.
- accVector = Vector.Add (accVector, v); - Zwei Vektoren werden summiert.
Zum Beispiel enthält Array 8 Zahlen: {0, 1, 2, 3, 4, 5, 6, 7} und vectorSize == 4.
Während des ersten Zyklus iteriert accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3} und nach der Addition hält accVector: {0, 0, 0, 0} + {0, 1 , 2, 3} = {0, 1, 2, 3}.
Während der zweiten Iteration ist v = {4, 5, 6, 7} und nach Addition accVector = {0, 1, 2, 3} + {4, 5, 6, 7} = {4, 6, 8, 10}. - Jetzt müssen wir nur noch die Summe aller Vektorelemente erhalten. Dazu können wir die Skalarmultiplikation mit einem mit Einsen gefüllten Vektor verwenden: int result = Vector.Dot (accVector, Vector <int> .One);
Dann erhalten wir: {4, 6, 8, 10} * {1, 1, 1, 1} = 4 * 1 + 6 * 1 + 8 * 1 + 10 * 1 = 28. - Falls erforderlich, werden die Zahlen, die nicht zum letzten Vektor passen, am Ende summiert.
Schauen wir uns den Intrinsics-Code an:
Intrinsics public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; }
Wir können sehen, dass es mit einer Ausnahme wie Vektoren ist:
Zwei Arrays vergleichen
Wir müssen zwei Arrays von Bytes vergleichen. Genau diese Aufgabe hat mich dazu gebracht, SIMD in .NET zu studieren. Lassen Sie uns noch einmal verschiedene Methoden für das Benchmarking schreiben und zwei Arrays vergleichen: ArrayA und ArrayB.
Die naheliegendste Lösung:
public bool Naive() { for (int i = 0; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; }
LINQ-basierte Lösung:
public bool LINQ() => ArrayA.SequenceEqual(ArrayB);
Die Lösung basierend auf der MemCmp-Funktion:
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)] static extern int memcmp(byte[] b1, byte[] b2, long count); public bool MemCmp() => memcmp(ArrayA, ArrayB, ArrayA.Length) == 0;
Die Lösung basiert auf Vektoren von System.Numerics:
public bool Vectors() { int vectorSize = Vector<byte>.Count; int i = 0; for (; i <= ArrayA.Length - vectorSize; i += vectorSize) { var va = new Vector<byte>(ArrayA, i); var vb = new Vector<byte>(ArrayB, i); if (!Vector.EqualsAll(va, vb)) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; }
Intrinsics-basierte Lösung:
public unsafe bool Intrinsics() { int vectorSize = 256 / 8; int i = 0; const int equalsMask = unchecked((int) (0b1111_1111_1111_1111_1111_1111_1111_1111)); fixed (byte* ptrA = ArrayA) fixed (byte* ptrB = ArrayB) { for (; i <= ArrayA.Length - vectorSize; i += vectorSize) { var va = Avx2.LoadVector256(ptrA + i); var vb = Avx2.LoadVector256(ptrB + i); var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } }
Die Ergebnisse der Ausführung des Benchmarks auf meinem Computer:
Ich denke, der Code dieser Methoden ist klar, mit Ausnahme von zwei Zeilen in Intrinsics:
var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; }
In der ersten Zeile werden zwei Vektoren auf Gleichheit verglichen und das Ergebnis in einem gleichen Vektor gespeichert, in dem alle Bits im Element an einer bestimmten Position auf 1 gesetzt werden, wenn die entsprechenden Elemente in va und vb gleich sind. Es stellt sich also heraus, dass, wenn die Bytevektoren va und vb gleich sind, alle Elemente in areEquals gleich 255 (11111111b) sein sollten. Da Avx2.CompareEqual ein Wrapper über _mm256_cmpeq_epi8 ist, können wir auf der Intel-Website den Pseudocode dieser Operation anzeigen:
Die MoveMask-Methode erstellt aus einem Vektor eine 32-Bit-Zahl. Die oberen Bits von jeweils 32 Ein-Byte-Elementen in einem Vektor sind die Werte der Bits im MoveMask-Ergebnis. Der Pseudocode ist hier verfügbar.
Wenn also einige Bytes in va und vb nicht übereinstimmen, sind die entsprechenden Bytes in areEqual 0. Daher sind auch die oberen Bits dieser Bytes 0. Dies bedeutet, dass die entsprechenden Bits in der Avx2.MoveMask-Antwort ebenfalls 0 sind und areEqual nicht gleich equalsMask ist.
Schauen wir uns ein Beispiel an, bei dem angenommen wird, dass die Vektorlänge 8 Bytes beträgt (um weniger zu schreiben):
- Sei va = {100, 10, 20, 30, 100, 40, 50, 100} und vb = {100, 20, 10, 30, 100, 40, 80, 90}.
- Dann sind areEquals {255, 0, 0, 255, 255, 255, 0, 0}.
- Die MoveMask-Methode gibt 10011100b zurück, die mit der 11111111b-Maske verglichen werden soll. Da diese Masken nicht gleich sind, sind auch die Vektoren va und vb nicht gleich.
Zählen der Häufigkeit, mit der ein Element in einer Sammlung vorkommt.
Manchmal müssen Sie die Vorkommen eines bestimmten Elements, z. B. Ganzzahlen, in einer Sammlung zählen. Wir können diesen Algorithmus auch beschleunigen. Zum Vergleich schreiben wir verschiedene Methoden, um das Element Item in Array zu durchsuchen.
Das offensichtlichste:
public int Naive() { int result = 0; foreach (int i in Array) { if (i == Item) { result++; } } return result; }
Verwenden von LINQ:
public int LINQ() => Array.Count(i => i == Item);
Verwenden von Vektoren aus System.Numerics.Vectors:
public int Vectors() { var mask = new Vector<int>(Item); int vectorSize = Vector<int>.Count; var accResult = new Vector<int>(); int i; var array = Array; for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); var areEqual = Vector.Equals(v, mask); accResult = Vector.Subtract(accResult, areEqual); } int result = 0; for (; i < array.Length; i++) { if (array[i] == Item) { result++; } } result += Vector.Dot(accResult, Vector<int>.One); return result; }
Verwenden von Intrinsics:
public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var temp = stackalloc int[vectorSize]; for (int j = 0; j < vectorSize; j++) { temp[j] = Item; } var mask = Avx2.LoadVector256(temp); var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i <= array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); var areEqual = Avx2.CompareEqual(v, mask); accVector = Avx2.Subtract(accVector, areEqual); } } int result = 0; Avx2.Store(temp, accVector); for(int j = 0; j < vectorSize; j++) { result += temp[j]; } for(; i < array.Length; i++) { if (array[i] == Item) { result++; } } return result; }
Die Ergebnisse der Ausführung des Benchmarks auf meinem Computer:
Vektoren und Intrinsics-Methoden stimmen in der Logik vollständig überein, unterscheiden sich jedoch in der Implementierung bestimmter Operationen. Die Idee ist die folgende:
- Erstellen Sie einen Maskenvektor, in dem in jedem Element eine erforderliche Anzahl gespeichert ist.
- Laden Sie den Teil eines Arrays in den Vektor v und vergleichen Sie diesen Teil mit einer Maske. Infolgedessen werden alle Bits in gleiche Elemente von areEqual gesetzt. Da areEqual ein Array von ganzen Zahlen ist, erhalten wir, wenn wir alle Bits eines Elements setzen, -1 in diesem Element ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
- subtrahieren areEqual Vektor von accVector. Dann speichert accVector, wie oft das Elementelement in allen Vektoren für jede Position aufgetreten ist (Minus mal Minus ist ein Plus).
Der gesamte Code aus dem Artikel ist auf GitHub .
Fazit
Ich habe nur einen kleinen Teil der .NET-Funktionen für die Berechnungsvektorisierung beschrieben. Um die vollständige aktualisierte Liste aller in .NET Core unter x86 verfügbaren Intrinsics anzuzeigen, wenden Sie sich an den Quellcode . Es ist praktisch, dass die Zusammenfassung aller in C # -Dateien enthaltenen Elemente ihren Namen in der C-Welt enthält. Dies hilft entweder, den Zweck dieser Eigenschaft zu verstehen oder vorhandene C ++ / C-Algorithmen auf .NET zu übertragen. Die Dokumentation zu System.Numerics.Vector ist auf msdn verfügbar.
Ich denke, .NET hat einen großen Vorteil gegenüber C ++. Da die JIT-Kompilierung bereits auf einem Clientcomputer erfolgt, kann ein Compiler den Code für einen bestimmten Clientprozessor optimieren und so maximale Leistung erzielen. Gleichzeitig kann ein Programmierer in einer Sprache und denselben Technologien bleiben, um schnellen Code zu schreiben.