Prise en charge des instructions spécifiques au matériel dans .NET Core (désormais non seulement SIMD)

Présentation


Il y a quelques années, nous avons décidé qu'il était temps de prendre en charge le code SIMD dans .NET . Nous avons introduit l'espace de noms System.Numerics avec les types Vector2 , Vector3 , Vector4 et Vector<T> . Ces types représentent une API à usage général pour créer, accéder et manipuler des instructions vectorielles dans la mesure du possible. Ils offrent également une compatibilité logicielle dans les cas où le matériel ne prend pas en charge les instructions appropriées. Cela a permis, avec un refactoring minimal, de vectoriser un certain nombre d'algorithmes. Quoi qu'il en soit, la généralité de cette approche rend son application difficile afin de tirer pleinement parti de toutes les instructions vectorielles disponibles, sur du matériel moderne. De plus, le matériel moderne fournit un certain nombre d'instructions spécialisées, non vectorielles, qui peuvent améliorer considérablement les performances. Dans cet article, je vais vous expliquer comment nous avons contourné ces limitations dans .NET Core 3.0.



Remarque: Il n'y a pas encore de terme établi pour la traduction Intrisics . À la fin de l'article, il y a un vote pour l'option de traduction. Si nous choisissons une bonne option, nous changerons l'article


Quelles sont les fonctions intégrées


Dans .NET Core 3.0, nous avons ajouté de nouvelles fonctionnalités appelées fonctions intégrées spécifiques au matériel (loin WF). Cette fonctionnalité donne accès à de nombreuses instructions matérielles spécifiques qui ne peuvent pas être simplement représentées par des mécanismes plus généraux. Ils diffèrent des instructions SIMD existantes en ce qu'ils n'ont pas un usage général (les nouveaux WF ne sont pas multiplateformes et leur architecture ne fournit pas de compatibilité logicielle). Au lieu de cela, ils fournissent directement des fonctionnalités spécifiques à la plateforme et au matériel pour les développeurs .NET. Les fonctions SIMD existantes, par exemple multiplateforme, offrent une compatibilité logicielle et sont légèrement abstraites du matériel sous-jacent. Cette abstraction peut être coûteuse, en plus, elle peut empêcher la divulgation de certaines fonctionnalités (lorsque, par exemple, la fonctionnalité n'existe pas ou est difficile à émuler sur toutes les plates-formes cibles).


Les nouvelles fonctions intégrées et les types pris en charge se trouvent sous l' System.Runtime.Intrinsics . Pour .NET Core 3.0, il existe actuellement un System.Runtime.Intrinsics.X86 . Nous travaillons sur la prise en charge des fonctions intégrées pour d'autres plates-formes telles que System.Runtime.Intrinsics.Arm .


Dans les espaces de noms spécifiques à la plate-forme, les WF sont regroupés en classes qui représentent des groupes d'instructions matérielles intégrées logiquement (souvent appelées architecture de jeu d'instructions (ISA)). Chaque classe fournit une propriété IsSupported indique si le matériel sur lequel le code s'exécute prend en charge cet ensemble d'instructions. En outre, chacune de ces classes contient un ensemble de méthodes mappées à un ensemble d'instructions correspondant. Parfois, il existe une sous-classe supplémentaire qui correspond à une partie du même jeu d'instructions, qui peut être limitée (prise en charge) par du matériel spécifique. Par exemple, la classe Lzcnt donne accès aux instructions pour compter les zéros non Lzcnt . Il possède une sous-classe appelée X64 , qui contient la forme de ces instructions utilisées uniquement sur les machines à architecture 64 bits.


Certaines de ces classes sont naturellement de nature hiérarchique. Par exemple, si Lzcnt.X64.IsSupported renvoie true, alors Lzcnt.IsSupported doit également renvoyer true, car il s'agit d'une sous-classe explicite. Ou, par exemple, si Sse2.IsSupported renvoie true, alors Sse.IsSupported doit retourner true, car Sse2 hérite explicitement de Sse . Cependant, il convient de noter que la similitude des noms de classe n'est pas un indicateur de leur appartenance à la même hiérarchie d'héritage. Par exemple, Bmi2 pas hérité de Bmi1 , donc les valeurs renvoyées par IsSupported pour ces deux ensembles d'instructions seront différentes. Le principe fondamental du développement de ces classes était la présentation explicite des spécifications ISA. SSE2 nécessite la prise en charge de SSE1, de sorte que les classes qui les représentent sont liées par héritage. Dans le même temps, BMI2 ne nécessite pas de prise en charge pour BMI1, nous n'avons donc pas utilisé d'héritage. Ce qui suit est un exemple de l'API ci-dessus.


 namespace System.Runtime.Intrinsics.X86 { public abstract class Sse { public static bool IsSupported { get; } public static Vector128<float> Add(Vector128<float> left, Vector128<float> right); // Additional APIs public abstract class X64 { public static bool IsSupported { get; } public static long ConvertToInt64(Vector128<float> value); // Additional APIs } } public abstract class Sse2 : Sse { public static new bool IsSupported { get; } public static Vector128<byte> Add(Vector128<byte> left, Vector128<byte> right); // Additional APIs public new abstract class X64 : Sse.X64 { public static bool IsSupported { get; } public static long ConvertToInt64(Vector128<double> value); // Additional APIs } } } 

Vous pouvez en voir plus dans le code source sur les liens suivants source.dot.net ou dotnet / coreclr sur GitHub


IsSupported vérifications IsSupported traitées par le compilateur JIT en tant que constantes d'exécution (lorsque l'optimisation est activée), vous n'avez donc pas besoin de compilation croisée pour prendre en charge plusieurs ISA, plates-formes ou architectures. Au lieu de cela, il vous suffit d'écrire le code à l'aide d'expressions if , à la suite de quoi les branches de code inutilisées (c'est-à-dire les branches qui ne sont pas accessibles en raison de la valeur de la variable dans l'instruction conditionnelle) seront ignorées lors de la génération du code natif.


Il est important que la vérification du IsSupported correspondant précède l'utilisation des commandes matérielles intégrées. S'il n'y a pas une telle vérification, le code qui utilise des commandes spécifiques à la plate-forme s'exécutant sur des plates-formes / architectures où ces commandes ne sont pas prises en charge lèvera une exception d'exécution PlatformNotSupportedException .


Quels avantages offrent-ils?


Bien sûr, les fonctions intégrées spécifiques au matériel ne sont pas pour tout le monde, mais elles peuvent être utilisées pour améliorer les performances des opérations chargées de calculs. CoreFX et ML.NET utilisent ces méthodes pour accélérer des opérations telles que la copie en mémoire, la recherche de l'index d'un élément dans un tableau ou une chaîne, le redimensionnement d'une image ou le travail avec des vecteurs / matrices / tenseurs. La vectorisation manuelle d'un code qui s'est avéré être un goulot d'étranglement peut également être plus simple qu'il n'y paraît. La vectorisation du code, en effet, consiste à effectuer plusieurs opérations à la fois, en général, à l'aide d'instructions SIMD (un flux d'instructions, plusieurs flux de données).


Avant de décider de vectoriser du code, vous devez effectuer un profilage pour vous assurer que ce code fait vraiment partie du «point chaud» (et, par conséquent, votre optimisation augmentera considérablement les performances). Il est également important d'effectuer un profilage à chaque étape de la vectorisation, car la vectorisation de tout le code n'entraîne pas une productivité accrue.


Vectorisation d'un algorithme simple


Pour illustrer l'utilisation des fonctions intégrées, nous prenons l'algorithme pour additionner tous les éléments d'un tableau ou d'une plage. Ce type de code est un candidat idéal pour la vectorisation, car à chaque itération, la même opération triviale est effectuée.


Un exemple d'implémentation d'un tel algorithme peut ressembler à ceci:


 public int Sum(ReadOnlySpan<int> source) { int result = 0; for (int i = 0; i < source.Length; i++) { result += source[i]; } return result; } 

Ce code est assez simple et direct, mais en même temps assez lent pour les données d'entrée volumineuses, comme ne fait qu'une seule opération triviale par itération.


 BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362 AMD Ryzen 7 1800X, 1 CPU, 16 logical and 8 physical cores .NET Core SDK=3.0.100-preview9-013775 [Host] : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT [AttachedDebugger] DefaultJob : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT 

La méthodeCompterMoyenneErreurStddev
Somme12,477 ns0,0192 ns0,0179 ns
Somme22,164 ns0,0265 ns0,0235 ns
Somme43,224 ns0,0302 ns0,0267 ns
Somme84.347 ns0,0665 ns0,0622 ns
Somme168.444 ns0,2042 ns0,3734 ns
Somme3213,963 ns0,2182 ns0,2041 ns
Somme6450,374 ns0,2995 ns0,2620 ns
Somme12860.139 ns0,3890 ns0,3639 ns
Somme256106,416 ns0,6404 ns0,5990 ns
Somme512291.450 ns3,5148 ns3,2878 ns
Somme1024574.243 ns9.5851 ns8.4970 ns
Somme20481 137,819 ns5,9363 ns5,5529 ns
Somme40962 228,341 ns22.8882 ns21.4097 ns
Somme81922 973,040 ns14.2863 ns12,6644 ns
Somme163845 883,504 ns15,9619 ns14.9308 ns
Somme3276811 699,237 ns104,0970 ns97,3724 ns

Augmentez la productivité grâce aux cycles de déploiement


Les processeurs modernes ont diverses options pour améliorer les performances du code. Pour les applications monothread, une de ces options consiste à effectuer plusieurs opérations primitives en un seul cycle de processeur.


La plupart des processeurs modernes peuvent effectuer quatre opérations d'addition en un seul cycle d'horloge (dans des conditions optimales), ce qui permet, avec la «mise en page» correcte du code, d'améliorer parfois les performances, même dans une implémentation à un seul thread.


Bien que JIT puisse effectuer le déroulement de boucle seul, JIT est prudent dans ce type de décision, en raison de la taille du code généré. Par conséquent, il peut être avantageux de déployer une boucle, en code, manuellement.


Vous pouvez développer la boucle dans le code ci-dessus comme suit:


 public unsafe int SumUnrolled(ReadOnlySpan<int> source) { int result = 0; int i = 0; int lastBlockIndex = source.Length - (source.Length % 4); // Pin source so we can elide the bounds checks fixed (int* pSource = source) { while (i < lastBlockIndex) { result += pSource[i + 0]; result += pSource[i + 1]; result += pSource[i + 2]; result += pSource[i + 3]; i += 4; } while (i < source.Length) { result += pSource[i]; i += 1; } } return result; } 

Ce code est un peu plus compliqué, mais il fait un meilleur usage des fonctionnalités matérielles.


Pour les très petites boucles, ce code s'exécute un peu plus lentement. Mais cette tendance est déjà en train de changer pour les données d'entrée de huit éléments, après quoi la vitesse d'exécution commence à augmenter (le temps d'exécution du code optimisé, pour 32 mille éléments, est 26% inférieur à celui de la version originale). Il convient de noter qu'une telle optimisation n'augmente pas toujours la productivité. Par exemple, lorsque vous travaillez avec des collections avec des éléments de type float version "déployée" de l'algorithme a presque la même vitesse que celle d'origine. Par conséquent, il est très important d'effectuer un profilage.


La méthodeCompterMoyenneErreurStddev
Sumunrolled12.922 ns0,0651 ns0,0609 ns
Sumunrolled23,576 ns0,0116 ns0,0109 ns
Sumunrolled43,708 ns0,0157 ns0,0139 ns
Sumunrolled84.832 ns0,0486 ns0,0454 ns
Sumunrolled167.490 ns0,1131 ns0,1058 ns
Sumunrolled3211,277 ns0,0910 ns0,0851 ns
Sumunrolled6419,761 ns0.2016 ns0,1888 ns
Sumunrolled12836,639 ns0,3043 ns0,2847 ns
Sumunrolled25677,969 ns0,8409 ns0,7866 ns
Sumunrolled512146,357 ns1,3209 ns1,2356 ns
Sumunrolled1024287,354 ns0,9223 ns0,8627 ns
Sumunrolled2048566,405 ns4,0155 ns3,5596 ns
Sumunrolled40961 131,016 ns7.3601 ns6,5246 ns
Sumunrolled81922 259,836 ns8.6539 ns8.0949 ns
Sumunrolled163844 501,295 ns6,4186 ns6.0040 ns
Sumunrolled327688 979,690 ns19,5265 ns18.2651 ns


Augmentez la productivité grâce à la vectorisation de boucle


Quoi qu'il en soit, mais nous pouvons encore optimiser légèrement ce code. Les instructions SIMD sont une autre option fournie par les processeurs modernes pour améliorer les performances. À l'aide d'une seule instruction, ils vous permettent d'effectuer plusieurs opérations en un seul cycle d'horloge. Cela peut être mieux que le dépliage de boucle directe, car, en fait, la même chose se fait, mais avec une plus petite quantité de code généré.


Pour clarifier, chaque opération d'ajout, dans un cycle déployé, prend 4 octets. Ainsi, nous avons besoin de 16 octets pour 4 opérations d'addition sous forme développée. Dans le même temps, l'instruction d'addition SIMD effectue également 4 opérations d'addition, mais ne prend que 4 octets. Cela signifie que nous avons moins d'instructions pour le CPU. De plus, dans le cas d'une instruction SIMD, le CPU peut faire des hypothèses et effectuer des optimisations, mais cela dépasse le cadre de cet article. Ce qui est encore mieux, c'est que les processeurs modernes peuvent exécuter plus d'une instruction SIMD à la fois, c'est-à-dire que, dans certains cas, vous pouvez appliquer une stratégie mixte, en même temps effectuer un balayage de cycle partiel et une vectorisation.


En général, vous devez commencer par regarder la classe polyvalente Vector<T> pour vos tâches. Comme les nouveaux WF , il intégrera des instructions SIMD, mais en même temps, étant donné la polyvalence de cette classe, il peut réduire le nombre de codage «manuel».


Le code pourrait ressembler à ceci:


 public int SumVectorT(ReadOnlySpan<int> source) { int result = 0; Vector<int> vresult = Vector<int>.Zero; int i = 0; int lastBlockIndex = source.Length - (source.Length % Vector<int>.Count); while (i < lastBlockIndex) { vresult += new Vector<int>(source.Slice(i)); i += Vector<int>.Count; } for (int n = 0; n < Vector<int>.Count; n++) { result += vresult[n]; } while (i < source.Length) { result += source[i]; i += 1; } return result; } 

Ce code fonctionne plus rapidement, mais nous sommes obligés de faire référence à chaque élément séparément lors du calcul du montant final. En outre, le Vector<T> n'a pas de taille définie avec précision et peut varier en fonction de l'équipement sur lequel le code s'exécute. Les fonctions intégrées spécifiques au matériel fournissent des fonctionnalités supplémentaires qui peuvent légèrement améliorer ce code et le rendre un peu plus rapide (au prix d'une complexité de code supplémentaire et d'exigences de maintenance).


La méthodeCompterMoyenneErreurStddev
SumVectorT14,517 ns0,0752 ns0,0703 ns
SumVectorT24.853 ns0,0609 ns0,0570 ns
SumVectorT45,047 ns0,0909 ns0,0850 ns
SumVectorT85.671 ns0,0251 ns0,0223 ns
SumVectorT166,579 ns0,0330 ns0,0276 ns
SumVectorT3210,460 ns0,0241 ns0,0226 ns
SumVectorT6417.148 ns0,0407 ns0,0381 ns
SumVectorT12823,239 ns0,0853 ns0,0756 ns
SumVectorT25662,146 ns0,8319 ns0,7778 ns
SumVectorT512114,863 ns0,4175 ns0,3906 ns
SumVectorT1024172,129 ns1,8673 ns1,7467 ns
SumVectorT2048429,722 ns1,0461 ns0,9786 ns
SumVectorT4096654,209 ns3,6215 ns3.0241 ns
SumVectorT81921 675,046 ns14,5231 ns13,5849 ns
SumVectorT163842 514,778 ns5.3369 ns4,9921 ns
SumVectorT327686 689,829 ns13.9947 ns13.0906 ns


REMARQUE Pour cet article, j'ai forcé la taille Vector<T> égale à 16 octets en utilisant le paramètre de configuration interne ( COMPlus_SIMD16ByteOnly=1 ). Ce réglage a normalisé les résultats lors de la comparaison de SumVectorT avec SumVectorizedSse , et nous a permis de garder le code simple. En particulier, il a évité d'écrire un saut conditionnel if (Avx2.IsSupported) { } . Ce code est presque identique au code pour Sse2 , mais traite avec Vector256<T> (32 octets) et traite encore plus d'éléments en une seule itération de la boucle.


Ainsi, en utilisant les nouvelles fonctions intégrées , le code peut être réécrit comme suit:


 public int SumVectorized(ReadOnlySpan<int> source) { if (Sse2.IsSupported) { return SumVectorizedSse2(source); } else { return SumVectorT(source); } } public unsafe int SumVectorizedSse2(ReadOnlySpan<int> source) { int result; fixed (int* pSource = source) { Vector128<int> vresult = Vector128<int>.Zero; int i = 0; int lastBlockIndex = source.Length - (source.Length % 4); while (i < lastBlockIndex) { vresult = Sse2.Add(vresult, Sse2.LoadVector128(pSource + i)); i += 4; } if (Ssse3.IsSupported) { vresult = Ssse3.HorizontalAdd(vresult, vresult); vresult = Ssse3.HorizontalAdd(vresult, vresult); } else { vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0x4E)); vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0xB1)); } result = vresult.ToScalar(); while (i < source.Length) { result += pSource[i]; i += 1; } } return result; } 

Ce code, encore une fois, est un peu plus compliqué, mais il est beaucoup plus rapide pour tout le monde, sauf les plus petits ensembles d'entrée. Pour 32 000 éléments, ce code s'exécute 75% plus rapidement que le cycle étendu et 81% plus rapidement que le code source de l'exemple.


Vous avez remarqué que nous avons rédigé des chèques IsSupported . Le premier vérifie si le matériel actuel prend en charge l'ensemble requis de fonctions intégrées , sinon, l'optimisation est effectuée par une combinaison de balayage et de Vector<T> . Cette dernière option sera sélectionnée pour les plates-formes comme ARM / ARM64 qui ne prennent pas en charge le jeu d'instructions requis, ou si le jeu a été désactivé pour la plate-forme. Le deuxième test IsSupported , dans la méthode SumVectorizedSse2 , est utilisé pour une optimisation supplémentaire si le matériel prend en charge le Ssse3 instructions Ssse3 .


Sinon, la majeure partie de la logique est essentiellement la même que pour la boucle étendue. Vector128<T> est un type de 128 bits contenant des éléments Vector128<T>.Count . Dans ce cas, uint , qui est lui-même 32 bits, peut avoir 4 éléments (128/32), c'est ainsi que nous avons lancé la boucle.


La méthodeCompterMoyenneErreurStddev
Sumvectorisé14,555 ns0,0192 ns0,0179 ns
Sumvectorisé24,848 ns0,0147 ns0,0137 ns
Sumvectorisé45.381 ns0,0210 ns0,0186 ns
Sumvectorisé84,838 ns0,0209 ns0,0186 ns
Sumvectorisé165.107 ns0,0175 ns0,0146 ns
Sumvectorisé325.646 ns0,0230 ns0,0204 ns
Sumvectorisé646,763 ns0,0338 ns0,0316 ns
Sumvectorisé1289.308 ns0,1041 ns0,0870 ns
Sumvectorisé25615,634 ns0,0927 ns0,0821 ns
Sumvectorisé51234.706 ns0,2851 ns0,2381 ns
Sumvectorisé102468.110 ns0,4016 ns0,3756 ns
Sumvectorisé2048136,533 ns1,3104 ns1,2257 ns
Sumvectorisé4096277,930 ns0,5913 ns0,5531 ns
Sumvectorisé8192554,720 ns3,5133 ns3,2864 ns
Sumvectorisé163841 110,730 ns3,3043 ns3.0909 ns
Sumvectorisé327682 200,996 ns21.0538 ns19,6938 ns


Conclusion


Les nouvelles fonctions intégrées vous permettent de tirer parti des fonctionnalités spécifiques au matériel de la machine sur laquelle vous exécutez le code. Il existe environ 1 500 API pour X86 et X64 réparties sur 15 ensembles, il y en a trop pour les décrire dans un seul article. En profilant le code pour identifier les goulots d'étranglement, vous pouvez déterminer la partie du code qui bénéficie de la vectorisation et observer une très bonne augmentation des performances. Il existe de nombreux scénarios où la vectorisation peut être appliquée et le déroulement de la boucle n'est que le début.


Quiconque veut voir plus d'exemples peut rechercher l'utilisation de fonctions intégrées dans le cadre (voir dotnet et aspnet ), ou dans d'autres articles de la communauté. Et bien que les WF actuels soient vastes, il reste encore beaucoup de fonctionnalités à introduire. Si vous avez la fonctionnalité que vous souhaitez introduire, n'hésitez pas à enregistrer votre demande d'API via dotnet / corefx sur GitHub . Le processus de révision de l'API est décrit ici et il existe un bon exemple de modèle de demande d'API spécifié à l'étape 1.


Remerciements spéciaux


Je tiens à exprimer une gratitude particulière aux membres de notre communauté Fei Peng (@fiigii) et Jacek Blaszczynski (@ 4creators) pour leur aide dans la mise en œuvre du WF , ainsi qu'à tous les membres de la communauté pour leurs précieux commentaires concernant le développement, la mise en œuvre et la facilité d'utilisation de cette fonctionnalité.




Postface à la traduction


J'aime observer le développement de la plateforme .NET, et en particulier le langage C #. Venant du monde du C ++ et ayant peu d'expérience en développement en Delphi et Java, j'étais très à l'aise pour écrire des programmes en C #. En 2006, ce langage de programmation (le langage lui-même) m'a paru plus concis et pratique que Java dans le monde de la gestion des ordures et du cross-platform. Par conséquent, mon choix s'est porté sur C #, et je ne l'ai pas regretté. La première étape de l'évolution d'une langue a été simplement son apparition. En 2006, C # a absorbé tout le meilleur qui était à l'époque dans les meilleurs langages et plateformes: C ++ / Java / Delphi. En 2010, F # est devenue publique. C'était une plate-forme expérimentale pour étudier le paradigme fonctionnel dans le but de l'introduire dans le monde de .NET. Le résultat des expériences a été la prochaine étape de l'évolution de C # - l'expansion de ses capacités vers le FP, grâce à l'introduction de fonctions anonymes, d'expressions lambda et, finalement, de LINQ. Cette extension du langage a fait de C # le langage le plus avancé, de mon point de vue, à usage général. L'étape évolutive suivante était liée à la prise en charge de la concurrence et de l'asynchronie. Tâche / Tâche <T>, tout le concept de TPL, le développement de LINQ - PLINQ, et, enfin, async / wait. , - , .NET C# — . Span<T> Memory<T>, ValueTask/ValueTask<T>, IAsyncDispose, ref readonly struct in, foreach, IO.Streams. GC . , — . , .NET C#, , . ( ) .

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


All Articles