Un petit aperçu de SIMD en .NET / C #

Nous vous proposons un petit aperçu des capacités de vectorisation d'algorithmes dans le .NET Framework et .NETCORE. Le but de l'article est de présenter ces techniques à ceux qui ne les connaissaient pas du tout et de montrer que .NET n'est pas loin derrière les "vrais, compilés" langages pour le natif
développement.


Je commence tout juste à apprendre les techniques de vectorisation, donc si quelqu'un de la communauté me pointe vers un dévers explicite ou suggère des versions améliorées des algorithmes décrits ci-dessous, je serai extrêmement heureux.


Un peu d'histoire


Dans .NET, SIMD est apparu pour la première fois en 2015 avec la sortie du .NET Framework 4.6. Ensuite, les types Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 et Vector4 ont été ajoutés, ce qui a permis la construction de calculs vectorisés. Plus tard, le type Vector <T> a été ajouté, ce qui a fourni plus d'opportunités pour la vectorisation d'algorithmes. Mais de nombreux programmeurs étaient toujours mécontents car les types ci-dessus limitaient le flux de pensées du programmeur et ne permettaient pas d'utiliser toute la puissance des instructions SIMD des processeurs modernes. Déjà à notre époque, dans l'aperçu .NET Core 3.0, l'espace de noms System.Runtime.Intrinsics est apparu, ce qui offre une plus grande liberté dans le choix des instructions. Pour obtenir les meilleurs résultats de vitesse, vous devez utiliser RyuJit et soit construire sur x64, soit désactiver Prefer 32 bits et construire sur AnyCPU. Toutes les références que j'ai exécutées sur un ordinateur avec un processeur Intel Core i7-6700 à 3,40 GHz (Skylake).


Résumer les éléments du tableau


J'ai décidé de commencer par le problème classique, qui est souvent écrit en premier quand il s'agit de vectorisation. C'est la tâche de trouver la somme des éléments du tableau. Nous écrirons quatre implémentations de cette tâche, nous résumerons les éléments du tableau Array:


Le plus évident


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

Utilisation de LINQ


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

Utilisation de vecteurs de 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; } 

À l'aide du code de l'espace System.Runtime.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; } 

J'ai lancé un benchmark sur ces 4 méthodes sur mon ordinateur et j'ai obtenu ce résultat:


La méthodeItemsCountMédiane
Naïf1075,12 ns
LINQ101 186,85 ns
Vecteurs1060,09 ns
Intrinsèque10255,40 ns
Naïf100360,56 ns
LINQ1002 719,24 ns
Vecteurs10060,09 ns
Intrinsèque100345,54 ns
Naïf10001 847,88 ns
LINQ100012 033,78 ns
Vecteurs1000240,38 ns
Intrinsèque1000630,98 ns
Naïf10 00018 403,72 ns
LINQ10 000102 489,96 ns
Vecteurs10 0007 316,42 ns
Intrinsèque10 0003 365,25 ns
Naïf100 000176 630,67 ns
LINQ100 000975 998,24 ns
Vecteurs100 00078 828,03 ns
Intrinsèque100 00041 269,41 ns

On peut voir que les solutions avec vecteurs et intrinsèque sont beaucoup plus rapides que la solution évidente et avec LINQ. Maintenant, nous devons comprendre ce qui se passe dans ces deux méthodes.


Considérez la méthode Vectors plus en détail:


Vecteurs
 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; - c'est le nombre de 4 octets que nous pouvons mettre dans un vecteur. Si l'accélération matérielle est utilisée, cette valeur indique le nombre de nombres à 4 octets pouvant être placés dans un registre SIMD. En fait, il montre combien d'éléments de ce type vous pouvez effectuer des opérations en parallèle;
  • accVector - un vecteur dans lequel le résultat de la fonction s'accumulera;
    var v = nouveau vecteur <int> (tableau, i); - les données sont chargées dans un nouveau vecteur v, à partir du tableau, à partir de l'index i. Les données vectorSize seront chargées exactement.
  • accVector = Vector.Add (accVector, v); - deux vecteurs sont ajoutés.
    Par exemple, dans le tableau 8, les nombres sont stockés: {0, 1, 2, 3, 4, 5, 6, 7} et vectorSize == 4, puis:
    Dans la première itération de la boucle accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3}, après addition dans accVector ce sera: {0, 0, 0, 0} + {0, 1, 2 , 3} = {0, 1, 2, 3}.
    Dans la deuxième itération, v = {4, 5, 6, 7} et après addition, accVector = {0, 1, 2, 3} + {4, 5, 6, 7} = {4, 6, 8, 10}.
  • Il ne reste plus qu'à obtenir en quelque sorte la somme de tous les éléments du vecteur, pour cela nous pouvons appliquer la multiplication scalaire par un vecteur rempli d'unités: int result = Vector.Dot (accVector, Vector <int> .One);
    Il s'avère alors: {4, 6, 8, 10} {1, 1, 1, 1} = 4 1 + 6 1 + 8 1 + 10 * 1 = 28.
  • En fin de compte, si nécessaire, des nombres sont ajoutés qui ne correspondent pas au dernier vecteur.

Si vous regardez le code de la méthode intrinsèque:


Intrinsèque
 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; } 

Vous pouvez voir qu'il est très similaire aux vecteurs à quelques exceptions près:


  • vectorSize est donné par constante. En effet, les instructions Avx2 qui fonctionnent sur des registres 256 bits sont explicitement utilisées dans cette méthode. Dans une application réelle, il devrait y avoir une vérification pour voir si le processeur Avx2 actuel prend en charge les instructions et, sinon, appeler un autre code. Cela ressemble à ceci:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero; accVector est déclaré comme un vecteur 256 bits rempli de zéros.
  • fixed (int * ptr = Array) - un pointeur vers un tableau est entré dans ptr.
  • Puis les mêmes opérations que dans Vectors: charger des données dans un vecteur et ajouter deux vecteurs.
  • Pour résumer les éléments du vecteur, la méthode suivante a été appliquée:
    • un tableau est créé sur la pile: var temp = stackalloc int [vectorSize];
    • le vecteur est chargé dans ce tableau: Avx2.Store (temp, accVector);
    • dans une boucle, les éléments du tableau sont additionnés.
  • puis les éléments du tableau qui ne sont pas placés dans le dernier vecteur sont additionnés

Comparez deux tableaux


Il est nécessaire de comparer deux tableaux d'octets. En fait, c'est le problème à cause duquel j'ai commencé à apprendre SIMD dans .NET. Encore une fois, nous écrirons plusieurs méthodes pour le benchmark, nous comparerons deux tableaux: ArrayA et ArrayB:


La solution la plus évidente:


 public bool Naive() { for (int i = 0; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } 

Solution via LINQ:


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

Solution via la fonction MemCmp:


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

Utilisation de vecteurs de 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; } 

Utilisation de l'intrinsèque:


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

Le résultat du benchmark sur mon ordinateur:


La méthodeItemsCountMédiane
Naïf10 00066 719,1 ns
LINQ10 00071 211,1 ns
Vecteurs10 0003 695,8 ns
Memcmp10 000600,9 ns
Intrinsèque10 0001 607,5 ns
Naïf100 000588 633,7 ns
LINQ100 000651 191,3 ns
Vecteurs100 00034 659,1 ns
Memcmp100 0005 513,6 ns
Intrinsèque100 00012078,9 ns
Naïf1 000 0005 637 293,1 ns
LINQ1 000 0006 622 666,0 ns
Vecteurs1 000 000777 974,2 ns
Memcmp1 000 000361 704,5 ns
Intrinsèque1 000 000434 252,7 ns

Tout le code de ces méthodes, je pense, est compréhensible, à l'exception de deux lignes en Intrinsics:


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

Dans le premier, deux vecteurs sont comparés pour l'égalité et le résultat est stocké dans le vecteur areEqual, dans lequel tous les bits sont mis à 1 dans un élément à une position spécifique si les éléments correspondants dans va et vb sont égaux. Il s'avère que si les vecteurs des octets va et vb sont complètement égaux, alors dans areEquals tous les éléments doivent être égaux à 255 (11111111b). Parce que Avx2.CompareEqual est un wrapper sur _mm256_cmpeq_epi8, puis sur le site Intel vous pouvez voir le pseudo-code de cette opération:
La méthode MoveMask à partir d'un vecteur fait un nombre 32 bits. Les valeurs binaires sont les bits hauts de chacun des 32 éléments à un octet du vecteur. Le pseudocode peut être trouvé ici .


Ainsi, si certains octets dans va et vb ne correspondent pas, alors dans areEqual les octets correspondants seront 0, donc les bits les plus significatifs de ces octets seront également 0, ce qui signifie que les bits correspondants dans la réponse Avx2.MoveMask seront également 0 et la comparaison avec equalsMask ne fonctionnera pas.


Analysons un petit exemple, en supposant que la longueur du vecteur est de 8 octets (pour l'écrire, c'était moins):


  • Soit va = {100, 10, 20, 30, 100, 40, 50, 100} et vb = {100, 20, 10, 30, 100, 40, 80, 90};
  • Alors areEqual sera égal à {255, 0, 0, 255, 255, 255, 0, 0};
  • La méthode MoveMask renverra 10011100b, qui devra être comparée avec le masque 11111111b, car Ces masques étant inégaux, il s'avère que les vecteurs va et vb ne sont pas égaux.

Compter le nombre de fois qu'un élément apparaît dans la collection


Parfois, il est nécessaire de calculer le nombre de fois qu'un élément particulier est trouvé dans une collection, par exemple, des entiers, cet algorithme peut également être accéléré. Écrivons quelques méthodes de comparaison, nous chercherons l'élément Item dans le tableau Array.


Le plus évident:


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

en utilisant LINQ:


 public int LINQ() => Array.Count(i => i == Item); 

en utilisant des vecteurs de 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; } 

Utilisation de l'intrinsèque:


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

Le résultat du benchmark sur mon ordinateur:


La méthodeItemsCountMédiane
Naïf10002 824,41 ns
LINQ100012 138,95 ns
Vecteurs1000961,50 ns
Intrinsèque1000691,08 ns
Naïf10 00027 072,25 ns
LINQ10 000113 967,87 ns
Vecteurs10 0007 571,82 ns
Intrinsèque10 0004 296,71 ns
Naïf100 000361 028,46 ns
LINQ100 0001.091.994,28 ns
Vecteurs100 00082 839,29 ns
Intrinsèque100 00040 307,91 ns
Naïf1 000 0001 634 175,46 ns
LINQ1 000 0006 194 257,38 ns
Vecteurs1 000 000583 901,29 ns
Intrinsèque1 000 000413 520,38 ns

Les méthodes Vectors et Intrinsics sont complètement identiques en logique, les différences ne concernent que la mise en œuvre d'opérations spécifiques. L'idée dans son ensemble est:


  • un vecteur de masque est créé dans lequel le nombre requis est stocké dans chaque élément;
  • La partie du tableau est chargée dans le vecteur v et comparée au masque, alors tous les bits seront définis en éléments égaux dans areEqual, car areEqual est un vecteur d'entiers, alors si vous définissez tous les bits d'un élément, nous obtenons -1 dans cet élément ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
  • le vecteur areEqual est soustrait de accVector, puis accVector sera la somme du nombre de fois que l'élément item s'est produit dans tous les vecteurs v pour chaque position (moins min donne un plus).

Tout le code de l'article peut être trouvé sur GitHub


Conclusion


Je n'ai examiné qu'une très petite partie des possibilités offertes par .NET pour la vectorisation des calculs. Pour une liste complète et à jour des éléments intrinsèques disponibles dans .NETCORE sous x86, vous pouvez vous référer au code source . Il est pratique que dans les fichiers C # du résumé de chaque intrinsèque, il y ait son propre nom du monde de C, ce qui simplifie la compréhension de l'objectif de cet intrinsèque et la traduction des algorithmes C ++ / C existants en .NET. La documentation System.Numerics.Vector est disponible sur msdn .


À mon avis, .NET a un gros avantage sur C ++, car La compilation JIT a déjà lieu sur la machine client, puis le compilateur peut optimiser le code pour un processeur client spécifique, offrant des performances maximales. Dans le même temps, un programmeur pour écrire du code rapide peut rester dans le cadre d'un langage et d'une technologie.


UPD (15/09/2019):


Il y avait un montant dans les repères

Dans les benchmarks, j'ai utilisé IterationSetup, qui, en fin de compte, peut affecter considérablement les performances des benchmarks qui fonctionnent en moins de 100 ms. Si vous le refaitez sur GlobalSetup, les résultats seront comme ceci.


Somme des éléments du tableau:


La méthodeItemsCountMoyenneErreurStddevRatio
Naïf103,531 ns0,0336 ns0,0314 ns1,00
LINQ1076,925 ns0,4166 ns0,3889 ns21,79
Vecteurs102,750 ns0,0210 ns0,0196 ns0,78
Intrinsèque106,513 ns0,0623 ns0,0582 ns1,84
Naïf10047,982 ns0,3975 ns0,3524 ns1,00
LINQ100590.414 ns3,8808 ns3,4402 ns12,31
Vecteurs10010.122 ns0,0747 ns0,0699 ns0,21
Intrinsèque10014,277 ns0,0566 ns0,0529 ns0,30
Naïf1000569,910 ns2,8297 ns2,6469 ns1,00
LINQ10005,658.570 ns31,7465 ns29,6957 ns9,93
Vecteurs100079,598 ns0,3498 ns0,3272 ns0,14
Intrinsèque100066,970 ns0,3937 ns0,3668 ns0,12
Naïf10 0005,637.571 ns37.5050 ns29.2814 ns1,00
LINQ10 00056 498,987 ns294.8776 ns275,8287 ns10.02
Vecteurs10 000772.900 ns2,6802 ns2,5070 ns0,14
Intrinsèque10 000579,152 ns2,8371 ns2,6538 ns0,10
Naïf100 00056 352,865 ns230,7916 ns215,8826 ns1,00
LINQ100 000562 610,571 ns3,775.7631 ns3,152.9332 ns9,98
Vecteurs100 0008.389,647 ns165,9590 ns227.1666 ns0,15
Intrinsèque100 0007,261,334 ns89,6468 ns69,9903 ns0,13

Comparaison de deux tableaux:


La méthodeItemsCountMoyenneErreurStddevRatio
Naïf10 0007 033,8 ns50,636 ns47,365 ns1,00
LINQ10 00064 841,4 ns289,157 ns270,478 ns9.22
Vecteurs10 000504,0 ns2,406 ns2,251 ns0,07
Memcmp10 000368,1 ns2,637 ns2,466 ns0,05
Intrinsèque10 000283,6 ns1.135 ns1,061 ns0,04
Naïf100 00085,214.4 ns903,868 ns845,478 ns1,00
LINQ100 000702 279,4 ns2,846.609 ns2 662,720 ns8.24
Vecteurs100 0005 179,2 ns45,337 ns42.409 ns0,06
Memcmp100 0004,510,5 ns24,292 ns22,723 ns0,05
Intrinsèque100 0002 957,0 ns11,452 ns10,712 ns0,03
Naïf1 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
Vecteurs1 000 00054,180.1 ns357,258 ns334.180 ns0,06
Memcmp1 000 00049,480.1 ns515,675 ns457,133 ns0,06
Intrinsèque1 000 00036 633,9 ns680,525 ns636,564 ns0,04

Le nombre d'occurrences d'un élément dans un tableau:


La méthodeItemsCountMoyenneErreurStddevRatio
Naïf108,844 ns0,0772 ns0,0603 ns1,00
LINQ1087,456 ns0,9496 ns0,8888 ns9,89
Vecteurs103.140 ns0,0406 ns0,0380 ns0,36
Intrinsèque1013,813 ns0,0825 ns0,0772 ns1,56
Naïf100107,310 ns0,6975 ns0,6183 ns1,00
LINQ100626,285 ns5.7677 ns5.3951 ns5.83
Vecteurs10011,844 ns0,2113 ns0,1873 ns0,11
Intrinsèque10019,616 ns0,1018 ns0,0903 ns0,18
Naïf10001.032.466 ns6.3799 ns5.6556 ns1,00
LINQ10006,266.605 ns42,6585 ns39.9028 ns6.07
Vecteurs100083,417 ns0,5393 ns0,4780 ns0,08
Intrinsèque100088,358 ns0,4921 ns0,4603 ns0,09
Naïf10 0009 942,503 ns47,9732 ns40.0598 ns1,00
LINQ10 00062 305,598 ns643,8775 ns502,6972 ns6.27
Vecteurs10 000914,967 ns7.2959 ns6,8246 ns0,09
Intrinsèque10 000931,698 ns6.3444 ns5,9346 ns0,09
Naïf100 00094 834,804 ns793,8585 ns703,7349 ns1,00
LINQ100 000626 620,968 ns4 696,9221 ns4,393,5038 ns6,61
Vecteurs100 0009,000.827 ns179,5351 ns192.1005 ns0,09
Intrinsèque100 0008 690,771 ns101,7078 ns95.1376 ns0,09
Naïf1 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
Vecteurs1 000 00099,778.488 ns1,975.6001 ns4252,6877 ns0,10
Intrinsèque1 000 00096 449 350 ns1,171.8067 ns978.5116 ns0,10

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


All Articles