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

Ihre Aufmerksamkeit wird auf einen kleinen Überblick über die Möglichkeiten der Vektorisierung von Algorithmen in .NET Framework und .NETCORE gelenkt. Der Zweck des Artikels ist es, diese Techniken denjenigen vorzustellen, die sie überhaupt nicht kannten, und zu zeigen, dass .NET nicht weit hinter den "echten, kompilierten" Sprachen für den Muttersprachler zurückbleibt
Entwicklung.


Ich fange gerade erst an, Vektorisierungstechniken zu lernen. Wenn mich also jemand aus der Community auf eine explizite Neigung hinweist oder verbesserte Versionen der unten beschriebenen Algorithmen vorschlägt, bin ich sehr glücklich.


Ein bisschen Geschichte


In .NET erschien SIMD erstmals 2015 mit der Veröffentlichung von .NET Framework 4.6. Dann wurden die Typen Matrix3x2, Matrix4x4, Ebene, Quaternion, Vektor2, Vektor3 und Vektor4 hinzugefügt, die die Erstellung vektorisierter Berechnungen ermöglichten. Später wurde der Typ Vector <T> hinzugefügt, der mehr Möglichkeiten zur Vektorisierung von Algorithmen bot. Aber viele Programmierer waren immer noch unglücklich, weil Die oben genannten Typen schränkten den Gedankenfluss des Programmierers ein und erlaubten nicht die volle Leistung der SIMD-Anweisungen moderner Prozessoren. Bereits heute wurde in der .NET Core 3.0-Vorschau der System.Runtime.Intrinsics-Namespace angezeigt, der viel mehr Freiheit bei der Auswahl von Anweisungen bietet. Um die besten Geschwindigkeitsergebnisse zu erzielen, müssen Sie RyuJit verwenden und entweder unter x64 erstellen oder Prefer 32-bit deaktivieren und unter AnyCPU erstellen. Alle Benchmarks, die ich auf einem Computer mit einem Intel Core i7-6700-Prozessor mit 3,40 GHz (Skylake) ausgeführt habe.


Fassen Sie die Elemente des Arrays zusammen


Ich habe mich entschlossen, mit dem klassischen Problem zu beginnen, das bei der Vektorisierung oft zuerst geschrieben wird. Dies ist die Aufgabe, die Summe der Elemente des Arrays zu ermitteln. Wir werden vier Implementierungen dieser Aufgabe schreiben und die Elemente des Array-Arrays zusammenfassen:


Am offensichtlichsten


public int Naive() { int result = 0; foreach (int i in Array) { result += i; } return result; } 

Verwenden von LINQ


 public long LINQ() => Array.Aggregate<int, long>(0, (current, i) => current + i); 

Verwenden von Vektoren aus 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; } 

Verwenden von Code aus dem System.Runtime.Intrinsics-Bereich:


 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 einen Benchmark für diese 4 Methoden auf meinem Computer gestartet und folgendes Ergebnis erhalten:


MethodeItemsCountMedian
Naiv1075,12 ns
LINQ101 186,85 ns
Vektoren1060,09 ns
Intrinsics10255,40 ns
Naiv100360,56 ns
LINQ1002 719,24 ns
Vektoren10060,09 ns
Intrinsics100345,54 ns
Naiv10001 847,88 ns
LINQ100012 033,78 ns
Vektoren1000240,38 ns
Intrinsics1000630,98 ns
Naiv10.00018 403,72 ns
LINQ10.000102 489,96 ns
Vektoren10.0007 316,42 ns
Intrinsics10.0003 365,25 ns
Naiv100.000176 630,67 ns
LINQ100.000975 998,24 ns
Vektoren100.00078 828,03 ns
Intrinsics100.00041 269,41 ns

Es ist ersichtlich, dass die Lösungen mit Vektoren und Intrinsics sehr viel schneller sind als die offensichtliche Lösung und mit LINQ. Jetzt müssen wir herausfinden, was bei diesen beiden Methoden passiert.


Betrachten Sie die Vektoren-Methode 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; - So viele 4-Byte-Zahlen können wir in einen Vektor einfügen. Wenn die Hardwarebeschleunigung verwendet wird, zeigt dieser Wert an, wie viele 4-Byte-Nummern in einem SIMD-Register abgelegt werden können. Tatsächlich wird angezeigt, wie viele Elemente dieses Typs Sie parallel ausführen können.
  • accVector - ein Vektor, in dem sich das Ergebnis der Funktion ansammelt;
    var v = neuer Vektor <int> (Array, i); - Daten werden ausgehend vom Index i aus dem Array in einen neuen Vektor v geladen. Es werden genau vectorSize-Daten geladen.
  • accVector = Vector.Add (accVector, v); - Es werden zwei Vektoren hinzugefügt.
    Zum Beispiel werden 8 Zahlen in Array gespeichert: {0, 1, 2, 3, 4, 5, 6, 7} und vectorSize == 4, dann:
    In der ersten Iteration der Schleife accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3} ist es nach der Addition in accVector: {0, 0, 0, 0} + {0, 1, 2 , 3} = {0, 1, 2, 3}.
    In 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}.
  • Es bleibt nur irgendwie die Summe aller Elemente des Vektors zu erhalten, dafür können wir die Skalarmultiplikation mit einem mit Einheiten gefüllten Vektor anwenden: int result = Vector.Dot (accVector, Vector <int> .One);
    Dann stellt sich heraus: {4, 6, 8, 10} {1, 1, 1, 1} = 4 1 + 6 1 + 8 1 + 10 * 1 = 28.
  • Am Ende werden bei Bedarf Zahlen addiert, die nicht in den letzten Vektor passen.

Wenn Sie sich den Intrinsics-Methodencode ansehen:


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; } 

Sie können sehen, dass es Vektoren mit wenigen Ausnahmen sehr ähnlich ist:


  • vectorSize ist durch Konstante gegeben. Dies liegt daran, dass Avx2-Befehle, die mit 256-Bit-Registern arbeiten, in dieser Methode explizit verwendet werden. In einer realen Anwendung sollte überprüft werden, ob der aktuelle Avx2-Prozessor Anweisungen unterstützt, und wenn nicht, einen anderen Code aufrufen. Es sieht ungefähr 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) - In ptr wird ein Zeiger auf ein Array eingegeben.
  • Dann die gleichen Operationen wie in Vektoren: Laden von Daten in einen Vektor und Hinzufügen von zwei Vektoren.
  • Um die Elemente des Vektors zusammenzufassen, wurde die folgende Methode angewendet:
    • Auf dem Stapel wird ein Array erstellt: var temp = stackalloc int [vectorSize];
    • Der Vektor wird in dieses Array geladen: Avx2.Store (temp, accVector);
    • In einer Schleife werden die Elemente des Arrays summiert.
  • Dann werden die Elemente des Arrays addiert, die nicht im letzten Vektor platziert sind

Vergleichen Sie zwei Arrays


Es ist notwendig, zwei Arrays von Bytes zu vergleichen. Eigentlich ist dies das Problem, aufgrund dessen ich angefangen habe, SIMD in .NET zu lernen. Wieder werden wir verschiedene Methoden für den Benchmark schreiben, wir werden 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; } 

Lösung über LINQ:


 public bool LINQ() => ArrayA.SequenceEqual(ArrayB); 

Lösung über 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; 

Verwenden von Vektoren aus 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; } 

Verwenden von Intrinsics:


 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; } } 

Das Ergebnis des Benchmarks auf meinem Computer:


MethodeItemsCountMedian
Naiv10.00066 719,1 ns
LINQ10.00071 211,1 ns
Vektoren10.0003 695,8 ns
Memcmp10.000600,9 ns
Intrinsics10.0001 607,5 ns
Naiv100.000588 633,7 ns
LINQ100.000651 191,3 ns
Vektoren100.00034 659,1 ns
Memcmp100.0005 513,6 ns
Intrinsics100.00012.078,9 ns
Naiv1.000.0005 637 293,1 ns
LINQ1.000.0006 622 666,0 ns
Vektoren1.000.000777 974,2 ns
Memcmp1.000.000361 704,5 ns
Intrinsics1.000.000434 252,7 ns

Der gesamte Code für diese Methoden ist meines Erachtens verständlich, mit Ausnahme von zwei Zeilen in Intrinsics:


 var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } 

Im ersten Schritt werden zwei Vektoren auf Gleichheit verglichen und das Ergebnis in dem Vektor areEqual gespeichert, in dem alle Bits in einem Element an einer bestimmten Position auf 1 gesetzt werden, wenn die entsprechenden Elemente in va und vb gleich sind. Es stellt sich heraus, dass, wenn die Vektoren aus den Bytes va und vb vollständig gleich sind, in areEquals alle Elemente gleich 255 (11111111b) sein sollten. Weil Avx2.CompareEqual ist ein Wrapper über _mm256_cmpeq_epi8. Auf der Intel-Website können Sie den Pseudocode dieser Operation sehen:
Die MoveMask-Methode aus einem Vektor erstellt eine 32-Bit-Zahl. Die Bitwerte sind die hohen Bits jedes der 32 Einzelbyte-Elemente des Vektors. Pseudocode finden Sie hier .


Wenn also einige Bytes in va und vb nicht übereinstimmen, sind in areEqual die entsprechenden Bytes 0, daher sind die höchstwertigen Bits dieser Bytes ebenfalls 0, was bedeutet, dass die entsprechenden Bits in der Avx2.MoveMask-Antwort ebenfalls 0 und der Vergleich sind mit equalsMask funktioniert nicht.


Analysieren wir ein kleines Beispiel unter der Annahme, dass die Länge des Vektors 8 Bytes beträgt (zum Schreiben war es weniger):


  • Sei va = {100, 10, 20, 30, 100, 40, 50, 100} und vb = {100, 20, 10, 30, 100, 40, 80, 90};
  • Dann ist areEqual gleich {255, 0, 0, 255, 255, 255, 0, 0};
  • Die MoveMask-Methode gibt 10011100b zurück, was mit der Maske 11111111b verglichen werden muss, weil Da diese Masken ungleich sind, stellt sich heraus, dass die Vektoren va und vb nicht gleich sind.

Zählen Sie, wie oft ein Element in der Sammlung vorkommt


Manchmal muss berechnet werden, wie oft ein bestimmtes Element in einer Sammlung gefunden wird, z. B. Ints. Dieser Algorithmus kann auch beschleunigt werden. Lassen Sie uns einige Methoden zum Vergleich schreiben. Wir werden nach dem Item-Element im Array-Array suchen.


Das offensichtlichste:


 public int Naive() { int result = 0; foreach (int i in Array) { if (i == Item) { result++; } } return result; } 

mit 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 mask = Avx2.SetAllVector256(Item); //var mask = Avx2.SetVector256(Item, Item, Item, Item, Item, Item, Item, Item); 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; } 

Das Ergebnis des Benchmarks auf meinem Computer:


MethodeItemsCountMedian
Naiv10002 824,41 ns
LINQ100012 138,95 ns
Vektoren1000961,50 ns
Intrinsics1000691,08 ns
Naiv10.00027 072,25 ns
LINQ10.000113 967,87 ns
Vektoren10.0007 571,82 ns
Intrinsics10.0004,296,71 ns
Naiv100.000361 028,46 ns
LINQ100.0001.091.994,28 ns
Vektoren100.00082 839,29 ns
Intrinsics100.00040 307,91 ns
Naiv1.000.0001 634 175,46 ns
LINQ1.000.0006 194 257,38 ns
Vektoren1.000.000583 901,29 ns
Intrinsics1.000.000413 520,38 ns

Die Methoden Vectors und Intrinsics sind in der Logik völlig identisch, die Unterschiede bestehen nur in der Implementierung spezifischer Operationen. Die Idee als Ganzes ist:


  • Es wird ein Maskenvektor erstellt, in dem die erforderliche Anzahl in jedem Element gespeichert ist.
  • Der Teil des Arrays wird in den Vektor v geladen und mit der Maske verglichen, dann werden alle Bits in areEqual in gleiche Elemente gesetzt, weil areEqual ist ein Vektor aus Ints. Wenn Sie also alle Bits eines Elements setzen, erhalten wir -1 in diesem Element ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
  • Der Vektor areEqual wird von accVector subtrahiert, und dann ist der accVector die Summe, wie oft das Elementelement in allen Vektoren v für jede Position aufgetreten ist (minus min ergibt ein Plus).

Der gesamte Code aus dem Artikel ist auf GitHub zu finden


Fazit


Ich habe nur einen sehr kleinen Teil der Möglichkeiten untersucht, die .NET für die Vektorisierung von Berechnungen bietet. Eine vollständige und aktuelle Liste der in .NETCORE unter x86 verfügbaren Intrinsics finden Sie im Quellcode . Es ist praktisch, dass in den C # -Dateien in der Zusammenfassung jedes Intrinsics ein eigener Name aus der Welt von C enthalten ist, was das Verständnis des Zwecks dieses Intrinsics und die Übersetzung vorhandener C ++ / C-Algorithmen in .NET vereinfacht. Die Dokumentation zu System.Numerics.Vector finden Sie unter msdn .


Meiner Meinung nach hat .NET einen großen Vorteil gegenüber C ++, weil Die JIT-Kompilierung findet bereits auf dem Client-Computer statt. Der Compiler kann den Code für einen bestimmten Client-Prozessor optimieren und bietet so maximale Leistung. Gleichzeitig kann ein Programmierer zum Schreiben von schnellem Code im Rahmen einer Sprache und Technologie bleiben.


UPD (15.09.2019):


Es gab einen Pfosten in Benchmarks

In Benchmarks habe ich IterationSetup verwendet, was, wie sich herausstellte, die Leistung von Benchmarks, die in weniger als 100 ms funktionieren, stark beeinträchtigen kann. Wenn Sie es in GlobalSetup wiederholen, sehen die Ergebnisse folgendermaßen aus.


Summe der Array-Elemente:


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

Vergleich zweier Arrays:


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

Die Anzahl der Vorkommen eines Elements in einem Array:


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

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


All Articles