Ein kleiner Überblick über SIMD in .NET / C #

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:


MethodeItemsCountMittelwertFehlerStddevVerhältnis
Naiv103,531 ns0,0336 ns0,0314 ns1,00
LINQ1076,925 ns0,4166 ns0,3897 ns21.79
Vektoren102,750 ns0,0210 ns0,0196 ns0,78
Intrinsics106,513 ns0,0623 ns0,0582 ns1,84
Naiv10047,982 ns0,3975 ns0,3524 ns1,00
LINQ100590,414 ns3,8808 ns3,4402 ns12.31
Vektoren10010,122 ns0,0747 ns0,0699 ns0,21
Intrinsics10014.277 ns0,0566 ns0,0529 ns0,30
Naiv1000569.910 ns2,8297 ns2,6469 ns1,00
LINQ10005.658,570 ns31,7465 ns29.6957 ns9.93
Vektoren100079,598 ns0,3498 ns0,3272 ns0,14
Intrinsics100066,970 ns0,3937 ns0,3682 ns0,12
Naiv10.0005.637,571 ns37,5050 ns29,2814 ns1,00
LINQ10.00056.498.987 ns294.8776 ns275,8287 ns10.02
Vektoren10.000772.900 ns2,6802 ns2,5070 ns0,14
Intrinsics10.000579,152 ns2,8371 ns2,6538 ns0,10
Naiv100.00056.352,865 ns230,7916 ns215,8826 ns1,00
LINQ100.000562,610,571 ns3,775.7631 ns3,152.9332 ns9.98
Vektoren100.0008,389.647 ns165,9590 ns227,1666 ns0,15
Intrinsics100.0007,261.334 ns89,6468 ns69.9903 ns0,13

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:


  • vectorSize wird durch eine Konstante angegeben. Dies liegt daran, dass diese Methode explizit Avx2-Anweisungen verwendet, die mit 256-Bit-Registern arbeiten. Eine echte Anwendung sollte eine Überprüfung enthalten, ob ein aktueller Prozessor Avx2 unterstützt. Wenn nicht, sollte ein anderer Code aufgerufen werden. Es sieht so aus:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero; accVector wird als 256-Bit-Vektor deklariert, der mit Nullen gefüllt ist.
  • fest (int * ptr = Array) - Der Zeiger auf das Array wird in ptr platziert.
  • Als nächstes folgen die gleichen Operationen wie in Vektoren: Laden von Daten in einen Vektor und Hinzufügen von zwei Vektoren.
  • Die Summierung von Vektorelementen erfolgt nach folgender Methode:
    • Erstellen Sie ein Array auf dem Stapel: var temp = stackalloc int [vectorSize];
    • Laden Sie einen Vektor in dieses Array: Avx2.Store (temp, accVector);
    • Summenarray-Elemente während des Zyklus.
  • Als nächstes werden die Elemente zusammengefasst, die nicht zum letzten Vektor passen.

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:


MethodeItemsCountMittelwertFehlerStddevVerhältnis
Naiv10.0007.033,8 ns50,636 ns47,365 ns1,00
LINQ10.00064.841,4 ns289,157 ns270,478 ns9.22
Vektoren10.000504,0 ns2,406 ns2,251 ns0,07
Memcmp10.000368,1 ns2,637 ns2,466 ns0,05
Intrinsics10.000283,6 ns1,135 ns1,061 ns0,04
Naiv100.00085.214,4 ns903,868 ns845,478 ns1,00
LINQ100.000702.279,4 ns2,846.609 ns2,662.720 ns8.24
Vektoren100.0005,179,2 ns45,337 ns42,409 ns0,06
Memcmp100.0004,510,5 ns24,292 ns22.723 ns0,05
Intrinsics100.0002.957,0 ns11.452 ns10,712 ns0,03
Naiv1.000.000844.006,1 ns3,552.478 ns3,322.990 ns1,00
LINQ1.000.0006.483.079,3 ns42.641,040 ns39.886,455 ns7.68
Vektoren1.000.00054.180,1 ns357,258 ns334.180 ns0,06
Memcmp1.000.00049.480,1 ns515,675 ns457,133 ns0,06
Intrinsics1.000.00036.633,9 ns680,525 ns636,564 ns0,04

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:


MethodeItemsCountMittelwertFehlerStddevVerhältnis
Naiv108,844 ns0,0772 ns0,0603 ns1,00
LINQ1087,456 ns0,9496 ns0,8883 ns9,89
Vektoren103,140 ns0,0406 ns0,0380 ns0,36
Intrinsics1013.813 ns0,0825 ns0,0772 ns1,56
Naiv100107,310 ns0,6975 ns0,6183 ns1,00
LINQ100626,285 ns5,7677 ns5,3951 ns5.83
Vektoren10011.844 ns0,2113 ns0,1873 ns0,11
Intrinsics10019.616 ns0,1018 ns0,0903 ns0,18
Naiv10001.032,466 ns6,3799 ns5,6556 ns1,00
LINQ10006,266.605 ns42,6585 ns39,9028 ns6.07
Vektoren100083,417 ns0,5393 ns0,4780 ns0,08
Intrinsics100088,358 ns0,4921 ns0,4603 ns0,09
Naiv10.0009.942,503 ns47,9732 ns40.0598 ns1,00
LINQ10.00062.305.598 ns643,8775 ns502,6972 ns6.27
Vektoren10.000914,967 ns7,2959 ns6,8246 ns0,09
Intrinsics10.000931.698 ns6,3444 ns5,9346 ns0,09
Naiv100.00094.834,804 ns793,8585 ns703,7349 ns1,00
LINQ100.000626.620.968 ns4,696.9221 ns4.393,5038 ns6.61
Vektoren100.0009.000,827 ns179,5351 ns192.1005 ns0,09
Intrinsics100.0008,690,771 ns101,7078 ns95,1376 ns0,09
Naiv1.000.000959,302,249 ns4,268.2488 ns3,783.6914 ns1,00
LINQ1.000.0006,218,681.888 ns31.321,9277 ns29.298.5506 ns6.48
Vektoren1.000.00099.778,488 ns1,975.6001 ns4,252.6877 ns0,10
Intrinsics1.000.00096.449,350 ns1,171.8067 ns978,5116 ns0,10

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.

Source: https://habr.com/ru/post/de467689/


All Articles