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

Voici un rapide aperçu des capacités de vectorisation d'algorithmes dans .NET Framework et .NET Core. Cet article est destiné à ceux qui ne connaissent rien à ces techniques. Je montrerai également que .NET n'est pas en retard sur les langages "réels compilés" pour le développement natif.


Je commence tout juste à apprendre les techniques de vectorisation. Donc, j'apprécierai si les membres de la communauté trouvent des erreurs claires ou suggèrent des améliorations aux algorithmes décrits.


Un peu d'histoire


SIMD est apparu dans .NET Framework 4.6 en 2015. C'est à ce moment que les types Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 et Vector4 ont été ajoutés. Ils ont permis des calculs vectorisés. Vient ensuite le type Vector <T> qui donne plus d'occasions de vectoriser des algorithmes. Cependant, de nombreux programmeurs n'étaient toujours pas satisfaits car ces types restreignaient les flux d'idées des codeurs et ne permettaient pas d'utiliser la pleine capacité des instructions SIMD dans les processeurs modernes. Maintenant, dans l'aperçu .NET Core 3.0, nous avons l'espace de noms System.Runtime.Intrinsics qui donne beaucoup de liberté dans le choix des instructions. Pour tirer le meilleur parti de la vitesse, vous devez utiliser RyuJit et recourir à un assemblage x64 ou désactiver la préférence 32 bits et choisir l'assemblage AnyCPU. J'ai exécuté tous les tests de performances sur un processeur Intel Core i7-6700 3,40 GHz (Skylake).


Sommation des éléments du tableau


J'ai décidé de commencer par une tâche classique qui vient généralement en premier si la vectorisation est impliquée. Il s'agit de trouver la somme des éléments du tableau. Écrivons quatre implémentations de cette tâche pour additionner les éléments de Array.


L'implémentation la plus évidente:


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

Implémentation basée sur LINQ:


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

L'implémentation basée sur des 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'implémentation basée sur le code de l'espace de noms 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 comparé ces 4 méthodes sur mon ordinateur et j'ai obtenu les résultats suivants:


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

Il est clair que les solutions avec vecteurs et intrinsèques sont beaucoup plus rapides que les solutions évidentes et basées sur LINQ. Maintenant, nous devons comprendre ce qui se passe dans ces deux méthodes.


Examinons de plus près la méthode des vecteurs:


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; - la quantité de nombres à 4 octets que nous pouvons placer dans un vecteur. Si l'accélération matérielle est utilisée, cette valeur montre combien de nombres à 4 octets nous pouvons mettre dans un registre SIMD. En fait, il montre combien d'éléments de ce type peuvent être traités simultanément;
  • accVector est un vecteur qui accumule le résultat de la fonction;
  • var v = nouveau vecteur <int> (tableau, i); - les données du tableau sont chargées dans un nouveau vecteur v, à partir de l'index i. Le vectorSize des données sera chargé exactement;
  • accVector = Vector.Add (accVector, v); - deux vecteurs sont additionnés.
    Par exemple, il y a 8 nombres dans Array: {0, 1, 2, 3, 4, 5, 6, 7} et vectorSize == 4.
    Ensuite, pendant le premier cycle d'itération, accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3} et après l'addition, accVector tiendra: {0, 0, 0, 0} + {0, 1 , 2, 3} = {0, 1, 2, 3}.
    Lors de 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 nous suffit maintenant d'obtenir la somme de tous les éléments vectoriels. Pour ce faire, nous pouvons utiliser la multiplication scalaire par un vecteur rempli de uns: int result = Vector.Dot (accVector, Vector <int> .One);
    On obtient alors: {4, 6, 8, 10} * {1, 1, 1, 1} = 4 * 1 + 6 * 1 + 8 * 1 + 10 * 1 = 28.
  • Si nécessaire, les nombres qui ne correspondent pas au dernier vecteur seront additionnés à la fin.

Examinons le code 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; } 

Nous pouvons voir que c'est comme des vecteurs à une exception près:


  • vectorSize est spécifié par une constante. En effet, cette méthode utilise explicitement des instructions Avx2 qui fonctionnent avec des registres 256 bits. Une application réelle devrait comprendre si un processeur actuel prend en charge Avx2. Sinon, un autre code doit être appelé. 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) - le pointeur vers le tableau est placé dans ptr.
  • Viennent ensuite les mêmes opérations que dans les vecteurs: chargement des données dans un vecteur et ajout de deux vecteurs.
  • La sommation des éléments vectoriels utilise la méthode suivante:
    • créer un tableau sur la pile: var temp = stackalloc int [vectorSize];
    • charger un vecteur dans ce tableau: Avx2.Store (temp, accVector);
    • additionner les éléments du tableau pendant le cycle.
  • Ensuite, les éléments qui ne correspondent pas au dernier vecteur sont résumés.

Comparaison de deux tableaux


Nous devons comparer deux tableaux d'octets. C'est exactement cette tâche qui m'a fait étudier SIMD en .NET. Encore une fois, écrivons plusieurs méthodes d'analyse comparative et comparons 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 basée sur LINQ:


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

La solution basée sur 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; 

La solution basée sur des 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; } 

Solution basée sur 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; } } 

Les résultats de l'exécution de la référence sur mon ordinateur:


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

Je suppose que le code de ces méthodes est clair, à l'exception de deux lignes en Intrinsics:


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

Dans la première ligne, deux vecteurs sont comparés pour l'égalité et le résultat est enregistré dans le vecteur areEqual dans lequel tous les bits de l'élément à une position particulière sont mis à 1 si les éléments correspondants dans va et vb sont égaux. Ainsi, il s'avère que si les vecteurs d'octets va et vb sont égaux, tous les éléments dans areEquals doivent être égaux à 255 (11111111b). Comme Avx2.CompareEqual est un wrapper sur _mm256_cmpeq_epi8, nous pouvons aller sur le site Web d'Intel et voir le pseudocode de cette opération:
La méthode MoveMask crée un nombre 32 bits à partir d'un vecteur. Les bits supérieurs de chaque 32 éléments d'un octet dans un vecteur sont les valeurs des bits dans le résultat MoveMask. Le pseudocode est disponible ici .


Ainsi, si certains octets dans va et vb ne correspondent pas, les octets correspondants dans areEqual seront 0. Par conséquent, les bits supérieurs de ces octets seront également 0. Cela signifie que les bits correspondants dans la réponse Avx2.MoveMask seront également 0 et que areEqual ne sera pas égal à equalsMask.


Regardons un exemple en supposant que la longueur du vecteur est de 8 octets (pour écrire moins):


  • Soit va = {100, 10, 20, 30, 100, 40, 50, 100} et vb = {100, 20, 10, 30, 100, 40, 80, 90}.
  • AreEquals sera alors {255, 0, 0, 255, 255, 255, 0, 0}.
  • La méthode MoveMask renverra 10011100b qui doit être comparé au masque 11111111b. Comme ces masques ne sont pas égaux, les vecteurs va et vb ne sont pas égaux également.

Compter les fois où un élément se produit dans une collection.


Parfois, vous devez compter les occurrences d'un élément particulier, par exemple des entiers, dans une collection. Nous pouvons également accélérer cet algorithme. Pour comparaison, écrivons plusieurs méthodes pour rechercher l'élément Item dans Array.


Le plus évident:


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

Utilisation de LINQ:


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

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

Les résultats de l'exécution de la référence sur mon ordinateur:


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

Les vecteurs et les méthodes intrinsèques coïncident complètement dans la logique mais diffèrent dans la mise en œuvre d'opérations particulières. L'idée est la suivante:


  • créer un vecteur de masque dans lequel un nombre requis est stocké dans chaque élément;
  • charger la partie d'un tableau dans le vecteur v et comparer cette partie avec un masque. Par conséquent, tous les bits seront définis sur des éléments égaux de areEqual. Comme areEqual est un tableau d'entiers, alors si nous définissons tous les bits d'un élément, nous obtiendrons -1 dans cet élément ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
  • soustraire le vecteur areEqual de accVector. Ensuite, accVector contiendra le nombre de fois où l'élément item s'est produit dans tous les vecteurs v pour chaque position (moins par moins est un plus).

Le code entier de l'article est sur GitHub .


Conclusion


Je n'ai décrit qu'une petite partie des capacités .NET pour la vectorisation de calcul. Pour voir la liste complète mise à jour de tous les intrinsèques disponibles dans .NET Core sous x86, tournez-vous vers le code source . Il est pratique que le résumé de chaque élément intrinsèque dans les fichiers C # contienne son nom dans le monde C. Cela permet soit de comprendre le but de cet intrinsèque et le transfert d'algorithmes C ++ / C existants vers .NET. La documentation System.Numerics.Vector est disponible sur msdn .


Je pense que .NET a un grand avantage sur C ++. Comme la compilation JIT se produit déjà sur une machine cliente, un compilateur peut optimiser le code pour un processeur client particulier, offrant des performances maximales. Dans le même temps, un programmeur peut rester dans une langue et les mêmes technologies pour écrire du code rapide.

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


All Articles