Remarque
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de vous connecter ou de modifier des répertoires.
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de modifier des répertoires.
SIMD (instruction unique, plusieurs données) est une prise en charge matérielle de l’application d’une opération à plusieurs éléments de données en parallèle avec une seule instruction. Le code vectorisé traite plusieurs valeurs par itération au lieu d’une seule, ce qui peut considérablement augmenter le débit pour les tâches numériques, scientifiques, graphiques, de traitement de texte et parallèles sur les données, où la même opération est répétée sur un tampon de données. La contrepartie, c’est une complexité accrue ; cela vaut donc surtout le coup lorsque les données d’entrée sont suffisamment volumineuses et que le gain est confirmé par des mesures.
.NET offre plusieurs types de prise en charge de SIMD. Choisissez celui qui correspond à la quantité de contrôle dont vous avez besoin et à quelle complexité vous êtes prêt à prendre.
Types de prise en charge de SIMD dans .NET
| API (Interface de Programmation d'Applications) | Namespace | Quand l′utiliser ? |
|---|---|---|
| Types de vecteurs et de matrices à usage fixe | System.Numerics | Graphiques et mathématiques géométriques avec des vecteurs d’élément 2 à 4, des matrices, des quaternions et des plans. |
Vector<T> |
System.Numerics | Vectorisation portable à largeur variable lorsque vous n’avez pas besoin d’un contrôle par plateforme. |
Vector64<T>, Vector128<T>, Vector256<T>, Vector512<T> |
System.Runtime.Intrinsics | Vectorisation multiplateforme à largeur fixe avec contrôle affiné. Il s’agit du point de départ recommandé pour les nouveaux algorithmes vectorisés. |
| Intrinsèques matérielles | System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm, System.Runtime.Intrinsics.Wasm | Instructions de processeur spécifiques que les API de niveau supérieur n’exposent pas, pour le dernier bit de performances sur un chemin d’accès chaud. |
TensorPrimitives |
System.Numerics.Tensors | Opérations mathématiques vectorisées prêtes à l’emploi sur des spans. Il effectue la vectorisation pour vous. |
Là où ces API se chevauchent, elles se rapportent par des couches d’abstraction. Les types de vecteurs génériques sont les types d’échange fondamentaux que les autres couches s’échangent ; ils constituent donc, techniquement, le niveau le plus bas : le type à largeur variable Vector<T>, dont la largeur s’adapte à celle prise en charge par le matériel sur lequel l’application s’exécute, ainsi que les types à largeur fixe, de Vector64<T> à Vector512<T>. Les fonctions intrinsèques matérielles spécifiques à la plateforme dans System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm et System.Runtime.Intrinsics.Wasm opèrent sur ces types, correspondant chacune directement à une instruction processeur distincte. Les opérations multiplateformes exposées sur les types génériques se trouvent une étape au-dessus des intrinsèques propres à la plateforme, en les réduisant pour chaque cible. Plus haut encore se trouvent les API managées qui opèrent sur des tampons entiers — les méthodes vectorisées de Span<T>, de string et de TensorPrimitives —, lesquelles s’appuient sur les couches inférieures, ce qui vous permet de bénéficier de l’accélération SIMD sans avoir à écrire quoi que ce soit à la main. Les types à forme fixe System.Numerics sont des types utilitaires spécifiques à un domaine, destinés à l’infographie et à la géométrie, et non des éléments de cette pile d’interchange.
Le reste de cet article fonctionne par le biais de ces API du niveau le plus élevé au plus bas, puis couvre les tests, l’évaluation et les meilleures pratiques.
Types de vecteurs et de matrices System.Numerics
L’espace System.Numerics de noms fournit des types accélérés par SIMD avec une forme fixe :
- Vector2, Vector3et Vector4 représentent des vecteurs de 2, 3 et 4 Single valeurs.
- Matrix3x2 et Matrix4x4 représentent des matrices 3x2 et 4x4 de Single valeurs.
- Plane représente un plan dans un espace tridimensionnel.
- Quaternion représente un vecteur utilisé pour encoder des rotations tridimensionnelles.
Ces types se prêtent naturellement au graphisme et à la géométrie, et l’environnement d’exécution accélère leurs opérations à l’aide d’instructions SIMD lorsque le matériel les prend en charge. L’exemple suivant ajoute deux vecteurs :
Vector2 v1 = Vector2.Create(0.1f, 0.2f);
Vector2 v2 = Vector2.Create(1.1f, 2.2f);
Vector2 sum = v1 + v2;
Ils offrent également les opérations vectorielles courantes auxquelles on s’attend, telles que le produit scalaire, la distance et l’écrêtage :
float dot = Vector2.Dot(v1, v2);
float distance = Vector2.Distance(v1, v2);
Vector2 clamped = Vector2.Clamp(v1, Vector2.Zero, Vector2.One);
Les types de matrices prennent en charge les opérations matricielles comme la transposition et la multiplication :
Matrix4x4 m1 = Matrix4x4.Create(
1.1f, 1.2f, 1.3f, 1.4f,
2.1f, 2.2f, 3.3f, 4.4f,
3.1f, 3.2f, 3.3f, 3.4f,
4.1f, 4.2f, 4.3f, 4.4f);
Matrix4x4 m2 = Matrix4x4.Transpose(m1);
Matrix4x4 product = Matrix4x4.Multiply(m1, m2);
Vecteur<T>
Vector<T> représente un vecteur de largeur variable d’un type numérique primitif. Sa longueur est fixe pour la durée de vie du processus, mais la valeur de dépend du Vector<T>.Count processeur qui exécute le code. Le compilateur Just-In-Time (JIT) traite Count comme une constante, de sorte que les boucles qui l’utilisent s’optimisent bien.
Vector<T> vous donne une vectorisation portable sans code par plateforme, au coût de ne pas connaître la largeur du vecteur au moment de la compilation. L’exemple suivant calcule l’ajout par élément de deux tableaux :
// Illustrative: element-wise add with Vector<T>. In practice, prefer the already-accelerated
// TensorPrimitives.Add, which is optimized for every Vector<T>.IsSupported element type.
public static double[] Add(double[] left, double[] right)
{
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(right);
ArgumentOutOfRangeException.ThrowIfNotEqual(right.Length, left.Length);
double[] result = new double[left.Length];
int i = 0;
// Vector<T>.Count is a JIT-time constant, so the compiler optimizes the loop bound.
int lastVectorStart = left.Length - Vector<double>.Count;
for (; i <= lastVectorStart; i += Vector<double>.Count)
{
Vector<double> v1 = Vector.Create(left.AsSpan(i));
Vector<double> v2 = Vector.Create(right.AsSpan(i));
(v1 + v2).CopyTo(result, i);
}
// Process any remaining elements that don't fill a full vector.
// Simplified for illustration: a scalar tail isn't optimal. A vectorized
// remainder that reprocesses the last full vector avoids the per-element loop.
for (; i < left.Length; i++)
{
result[i] = left[i] + right[i];
}
return result;
}
Remarque
Cet exemple illustre. Vous avez rarement besoin d’écrire manuellement une boucle comme celle-ci, car TensorPrimitives fournit déjà des opérations mathématiques accélérées basées sur des spans. Cette addition élément par élément est Add, et des opérations de réduction telles que Sum sont également proposées. Ces opérations sont accélérées matériellement pour les types d’éléments qui Vector<T> prennent en charge (Vector<T>.IsSupported).
Vérifier la présence de l’accélération matérielle
Les types accélérés par SIMD fonctionnent même sur des configurations matérielles ou JIT qui ne prennent pas en charge le SIMD, car ils s’appuient sur des implémentations logicielles non accélérées. Pour déterminer si l’accélération est réellement disponible, vérifiez la propriété appropriée IsHardwareAccelerated :
-
Vector.IsHardwareAccelerated indique si
Vector<T>les opérations sont accélérées. -
Vector128.IsHardwareAcceleratedet les équivalents, signalent l’accélération
Vector64/Vector256/Vector512pour chaque largeur fixe.
Ces propriétés sont transformées en constantes par le JIT, de sorte que les branches que vous ne prenez pas sont éliminées et il n’y a aucun coût d’exécution pour les vérifier. Ne ayez pas en cache les valeurs ; lisez-les directement là où vous en avez besoin. La même chose s’applique aux Count propriétés (par exemple, Vector128<T>.Count), qui sont également des constantes JIT-time.
La plupart des opérations sur une largeur accélérée sont elles-mêmes accélérées, mais ce n’est pas garanti pour toutes les opérations. Par exemple, la division en virgule flottante peut être accélérée, alors que la division entière ne l’est pas. Lorsque Vector256 est accéléré, Vector128 l’est généralement aussi, mais ce n’est pas garanti ; vérifiez donc chaque largeur que vous utilisez.
Tip
Si une opération dont vous avez besoin n’est pas accélérée sur une plateforme qui vous intéresse, ou si vous souhaitez créer une nouvelle API multiplateforme, créez un problème sur dotnet/runtime. La même chose s’applique aux améliorations de codegen.
Chaque type d’élément n’est pas valide pour chaque vecteur.
Vector128<T>et ses frères prennent en charge les types numériques primitifs (byte, sbyte, shortushort, int, uint, long, ulong, floatdouble, , nint, et ) aujourd’hui, et nuintcet ensemble peut croître pour inclure d’autres types à l’avenir. Permet Vector128<T>.IsSupported de déterminer si une donnée T est valide, ce qui est particulièrement utile à partir du code générique.
Les types qui ne sont pas pris en charge, tels que char et bool, peuvent toujours être vectorisés en réinterprétant la mémoire tampon comme un type pris en charge de la même taille. Utilisez Cast pour réinterpréter un span — par exemple, de char à ushort — ou la méthode As<TFrom, TTo> du vecteur pour réinterpréter un vecteur dont vous disposez déjà. La réinterprétation modifie uniquement le type, et non les bits sous-jacents. Il est donc de votre responsabilité de conserver les données bien formées : un bool doit rester 0 ou 1, et il char doit rester une unité de code UTF-16 valide. Si une opération vectorisée peut produire une valeur hors plage, veillez à normaliser le résultat avant de le réécrire.
Vectorisation multiplateforme avec Vector128
Vector128<T> est le dénominateur commun sur chaque plateforme qui prend en charge la vectorisation. Il est donc le meilleur endroit pour commencer. Il contient un vecteur 128 bits : 16 octets, 8 shorts, 4 ints/floats ou 2 longs/doubles.
------------------------------128-bits---------------------------
| 64 | 64 |
-----------------------------------------------------------------
| 32 | 32 | 32 | 32 |
-----------------------------------------------------------------
| 16 | 16 | 16 | 16 | 16 | 16 | 16 | 16 |
-----------------------------------------------------------------
| 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
-----------------------------------------------------------------
Vector256<T> est deux fois plus large, et Vector512<T> deux fois de plus. Le matériel ne prend pas toujours en charge les largeurs plus grandes ; les exemples qui suivent utilisent donc Vector128 à des fins de portabilité.
Chaque largeur a un type générique (Vector128<T>) pour les données et une classe statique non générique (Vector128) qui contient la plupart des opérations, y compris les méthodes de fabrique statique comme Create et Load. Les opérateurs tels que +, &et << sont la façon idiomatique d’exprimer des opérations arithmétiques et de bits ; préférez-les à l’équivalent de la méthode nommée pour éviter les bogues de précédence des opérateurs et améliorer la lisibilité. Pour les algorithmes qui dépendent de l’ordre des octets, utilisez IsLittleEndian comme condition, que le JIT réduit également à une constante.
Remarque
Sur x86/x64, Vector256<T> les opérations sont généralement traitées comme deux « voies » indépendantes de 128 bits. Pour la plupart des opérations élément par élément, cela est transparent, mais les opérations qui s’effectuent entre les voies (comme les permutations ou les opérations par paire/horizontales) peuvent se comporter différemment ou coûter plus cher que l’équivalent Vector128. Vérifiez avec les benchmarks avant de supposer qu’un vecteur plus large est plus rapide.
Les opérations de franchissement de voie ne s’élargissent pas d’elles-mêmes
Les opérations élément par élément ne dépendent pas de la largeur : v1 + v2 donne le même résultat pour chaque élément, que v1 et v2 soient Vector128<T> ou Vector256<T> — l’élargissement ne fait que traiter, à chaque instruction, une voie de données supplémentaire.
Add sur v = [a, b, c, d] et w = [e, f, g, h] combine toujours les éléments de même indice :
v: [ a | b | c | d ]
w: [ e | f | g | h ]
+ + + +
r: [a+e|b+f|c+g|d+h]
Les opérations inter-voies ne passent pas aussi simplement à une échelle supérieure, car les éléments à combiner dépendent de la largeur du vecteur. Une réduction par paires combine des éléments adjacents plutôt que des éléments de même indice ; l’élargissement modifie donc les éléments qui se retrouvent appariés ensemble :
v: [ a | b | c | d ]
\_____/ \_____/
round 1: [ a+b | c+d | a+b | c+d ]
\_________________/
round 2: [ S | S | S | S ] (S = a+b+c+d)
C’est exactement ce que fait une réduction horizontale : elle additionne les éléments d’un vecteur en deux étapes d’additions par paires. Sur x86/x64, Vector128<float> (4 éléments) permet d’y parvenir avec deux appels à HorizontalAdd :
// Sums all four elements with two rounds of pairwise horizontal adds.
// HorizontalAdd(v, v) on [a, b, c, d] gives [a+b, c+d, a+b, c+d]; a second round
// collapses that to the full sum in every element.
public static float SumVector128(Vector128<float> v)
{
Debug.Assert(Sse3.IsSupported);
Vector128<float> step1 = Sse3.HorizontalAdd(v, v);
Vector128<float> step2 = Sse3.HorizontalAdd(step1, step1);
return step2.ToScalar();
}
Appliquez le même schéma en deux appels à Vector256<float> (8 éléments) et cela semble correct, mais il ne l’est pas.
HorizontalAdd n’opère pas sur l’intégralité du vecteur de 256 bits ; il répète le motif par paires indépendamment dans chaque lane de 128 bits. Deux itérations vous donnent la somme du demi-registre inférieur (éléments 0 à 3) diffusée sur tout le demi-registre inférieur, et la somme du demi-registre supérieur (éléments 4 à 7) diffusée sur tout le demi-registre supérieur, et non la somme des huit éléments :
// The same two-round pattern on Vector256<float> looks like it should sum all eight
// elements, but Avx.HorizontalAdd repeats the pairwise pattern independently within
// each 128-bit lane. The result holds the lower lane's sum (elements 0-3) broadcast
// across the lower lane and the upper lane's sum (elements 4-7) broadcast across the
// upper lane -- ToScalar only returns the lower lane's partial sum, not the total.
public static float SumVector256Naive(Vector256<float> v)
{
Debug.Assert(Avx.IsSupported);
Vector256<float> step1 = Avx.HorizontalAdd(v, v);
Vector256<float> step2 = Avx.HorizontalAdd(step1, step1);
return step2.ToScalar();
}
Pour obtenir le total correct, franchissez explicitement la limite entre les voies : lisez la somme partielle de chaque voie avec GetLower/GetUpper et additionnez-les — GetLower et GetUpper scindent un vecteur en première et seconde moitié :
------------------------------128-bits---------------------------
| LOWER | UPPER |
-----------------------------------------------------------------
| 32 | 32 | 32 | 32 |
-----------------------------------------------------------------
| 16 | 16 | 16 | 16 | 16 | 16 | 16 | 16 |
-----------------------------------------------------------------
| 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
-----------------------------------------------------------------
// Getting the full sum needs an explicit step to cross the lane boundary: read each
// lane's partial sum out with GetLower/GetUpper and add them together.
public static float SumVector256(Vector256<float> v)
{
Debug.Assert(Avx.IsSupported);
Vector256<float> step1 = Avx.HorizontalAdd(v, v);
Vector256<float> step2 = Avx.HorizontalAdd(step1, step1);
Vector128<float> lower = step2.GetLower();
Vector128<float> upper = step2.GetUpper();
return lower.ToScalar() + upper.ToScalar();
}
Cette étape supplémentaire est le véritable coût du changement de voie. Un algorithme avec croisements entre voies ne s’élargit pas gratuitement comme un algorithme élément par élément — mesurez avant de supposer qu’un vecteur plus large l’emporte.
Opérations courantes
Vector128 et ses frères plus larges exposent une grande surface d’API. Vous n’avez pas besoin de le mémoriser : connaissez les catégories et recherchez les détails lorsque vous en avez besoin. Chaque opération dispose d’une solution de repli logicielle pour les plateformes qui ne peuvent pas l’accélérer. Le tableau suivant couvre essentiellement toute la surface.
| Catégorie | Qu’est-ce que cela fait ? | API représentatives |
|---|---|---|
| Constants | Vecteurs constants prédéfinis |
Zero, One, NegativeOne, AllBitsSet, Indices, SignSequence, E, Pi, Tau, Epsilon, NaN, PositiveInfinity, NegativeInfinity, NegativeZero |
| Création | Diffuser un scalaire, définir des éléments ou générer une séquence |
Create, CreateScalar, CreateScalarUnsafe, Create(ReadOnlySpan<T>), CreateSequence, CreateGeometricSequence, CreateHarmonicSequence, CreateAlternatingSequence |
| Charger et stocker | Déplacer des données entre la mémoire et un vecteur |
Load, LoadUnsafe, LoadAligned, LoadAlignedNonTemporal, Store, StoreUnsafe, StoreAligned, StoreAlignedNonTemporal, CopyTo, TryCopyTo |
| Arithmetic | Opérations mathématiques élément par élément et réductions |
Add (x + y), Subtract (x - y), Multiply (x * y), Divide (x / y), Negate (-x), AddSaturate, SubtractSaturate, Abs, Sqrt, FusedMultiplyAdd, Dot, Sum |
| Opérations de bits | Logique et décalages au niveau du bit |
BitwiseAnd (x & y), BitwiseOr (x \| y), Xor (x ^ y), AndNot (x & ~y), OnesComplement (~x), ShiftLeft (x << n), ShiftRightArithmetic (x >> n), ShiftRightLogical (x >>> n) |
| Minimum, maximum et limitation | Bornage minimum, maximum et par plage, par élément |
Min, Max, Clamp, MinMagnitude, MaxMagnitude, MinNumber, MaxNumber, MinMagnitudeNumber, MaxMagnitudeNumber |
| Arrondi | Arrondir chaque élément à une valeur intégrale |
Ceiling, Floor, Round, Truncate |
| Fonctions mathématiques | Signe, interpolation, angle et fonctions transcendantales utilitaires |
CopySign, Lerp, DegreesToRadians, RadiansToDegrees, Hypot, Sin, Cos, SinCos, Asin, Exp, Log, Log2 |
| Comparison | Comparez chaque élément ; le résultat est un masque vectoriel, et non celui qu’un opérateur bool produit |
Equals, , GreaterThanGreaterThanOrEqual, , LessThanLessThanOrEqual |
| Classification | Prédicats par élément sur la ligne numérique, chacun retournant un masque vectoriel |
IsNaN, IsFinite, IsInfinity, IsPositiveInfinity, IsNegativeInfinity, IsInteger, IsEvenInteger, IsOddInteger, IsNegative, IsPositive, IsNormal, IsSubnormal, IsZero |
| Réductions par comparaison | Ramener une comparaison par élément à un seul bool |
EqualsAll (x == y), EqualsAny, GreaterThanAll, GreaterThanAny, GreaterThanOrEqualAll, GreaterThanOrEqualAny, LessThanAll, LessThanAny, LessThanOrEqualAll, LessThanOrEqualAny |
| Prédicats à vecteur entier | Réduire un vecteur en un bool : déterminer si tous les éléments, au moins un élément ou aucun élément sont égaux à une valeur, ou si (dans les versions WhereAllBitsSet) tous les bits sont à 1. Préférer ces éléments à la conversion d’un masque en index |
All, , Any, NoneAllWhereAllBitsSet, , AnyWhereAllBitsSetNoneWhereAllBitsSet |
| Search | Compter ou localiser des éléments par valeur, ou (les WhereAllBitsSet formulaires) définir des voies dans un masque |
Count, , IndexOf, LastIndexOfCountWhereAllBitsSet, , IndexOfWhereAllBitsSetLastIndexOfWhereAllBitsSet |
| Masque pour l’indexation | Transformer un masque de comparaison en masque de bits scalaire et l’analyser |
ExtractMostSignificantBits avec TrailingZeroCount ou LeadingZeroCount |
| Sélection | Mélanger deux vecteurs en fonction d’un masque, bit par bit |
ConditionalSelect(x, y, z), équivalent à (y & x) \| (z & ~x) |
| Conversion | Modifier le type numérique, calcul de nouvelles valeurs (par exemple, int en float) |
ConvertToInt32, , ConvertToInt64, ConvertToUInt32ConvertToUInt64, , ConvertToSingleConvertToDouble |
| Élargissement et rétrécissement | Fractionner des éléments en un type plus large ou les empaqueter en un plus étroit |
Widen, , WidenLowerWidenUpper, , NarrowNarrowWithSaturation |
| Réinterprétation | Réinterpréter les bits comme un autre type d’élément sans les modifier |
As<TFrom, TTo>, AsByte, AsInt32, AsSingle, et les autres formes de l’élément As* |
| Interopérabilité System.Numerics | Réinterpréter entre Vector128<T> et les types numériques à forme fixe |
AsVector, AsVector2, AsVector3, AsVector4, AsPlane, AsQuaternion, AsVector128, AsVector128Unsafe |
| Réordonner | Réorganiser les éléments par index ou entrelacement de deux vecteurs |
Shuffle, Reverse, Zip, ZipLower, ZipUpper, Unzip, UnzipEven, UnzipOdd, ConcatLowerLower, ConcatLowerUpper, ConcatUpperLower, ConcatUpperUpper |
| Accès à la voie | Lire ou remplacer des éléments individuels et des moitiés, ou redimensionner le vecteur |
GetElement, WithElement, ToScalar, GetLower, GetUpper, WithLower, WithUpper, ToVector256 |
Tip
Plusieurs opérations existent en variantes Estimate et Native — par exemple, MultiplyAddEstimate, ClampNative, MinNative, MaxNative, ShuffleNative et ConvertToInt32Native. Ils correspondent à une instruction matérielle plus rapide qui sacrifie un peu de précision ou renonce à une garantie IEEE sur certains cas limites (comme la gestion des NaN) ; n’y recourez donc que lorsqu’un benchmark montre que la forme exacte constitue le goulot d’étranglement et qu’une sémantique plus souple est acceptable.
Remarque
Vector256.Shuffle traite son entrée comme un seul vecteur de 256 bits, tandis que Avx2.Shuffle, spécifique à la plateforme, fonctionne comme deux blocs indépendants de 128 bits. L’API multiplateforme est le choix plus portable, mais vérifiez le comportement dont vous avez besoin lors du portage des intrinsèques écrites manuellement.
Structurer le chemin du code
Une méthode vectorisée se décline généralement en un chemin de code pour chaque largeur de vecteur, plus un repli scalaire pour les entrées de petite taille et le matériel non accéléré. Pour utiliser le plus grand vecteur pris en charge par le matériel, vérifiez d’abord le vecteur le plus large et effectuez la descente en puissance :
// Sums a buffer, choosing the widest vector the hardware and element type support.
public static T Sum<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
// The widest-first order continues with the Vector512 and Vector256 paths, which belong
// here ahead of the Vector128 block below. They're identical to it aside from the wider
// type (for example, Vector512<T> with Vector512.Create and Vector512.Sum), so they're
// omitted for brevity:
//
// if (Vector512.IsHardwareAccelerated && Vector512<T>.IsSupported)
// {
// if (buffer.Length >= Vector512<T>.Count)
// {
// return SumVector512(buffer);
// }
// return SumVectorSmall(buffer);
// }
//
// if (Vector256.IsHardwareAccelerated && Vector256<T>.IsSupported)
// {
// if (buffer.Length >= Vector256<T>.Count)
// {
// return SumVector256(buffer);
// }
// return SumVectorSmall(buffer);
// }
if (Vector128.IsHardwareAccelerated && Vector128<T>.IsSupported)
{
if (buffer.Length >= Vector128<T>.Count)
{
return SumVector128(buffer);
}
return SumVectorSmall(buffer);
}
return SumScalar(buffer);
}
La protection externe de chaque largeur combine Vector128.IsHardwareAccelerated (constante JIT-time pour savoir si la plateforme accélère cette largeur) avec Vector128<T>.IsSupported (si le type T d’élément est valide pour cette largeur). Dans un bloc pris en charge, comparez la longueur d’entrée à Count afin de choisir entre le chemin vectorisé et une solution de repli pour les petites entrées. La méthode est générique par rapport à T, et les blocs Vector256 et Vector512 — identiques au bloc Vector128, mais utilisant le type plus large — sont présentés en commentaires par souci de concision.
Il existe deux solutions de repli distinctes. Une mémoire tampon trop petite pour même le vecteur le plus étroit, mais sur le matériel accéléré, passe à SumVectorSmall: une table de raccourcis explicite switch qui gère chaque longueur de sous-vecteur possible sans boucle :
// Sums a buffer smaller than the widest vector. The complete "optimal" shape dispatches on the
// element width so each width uses a switch jump table sized to the number of elements that fit
// in the widest vector (63 for byte, 31 for short, 15 for int/float, 7 for long/double).
private static T SumVectorSmall<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
// sizeof(T) is a JIT constant, so only the matching branch survives for a given T.
if (sizeof(T) == 4)
{
return SumVectorSmall4(buffer);
}
// The 1-, 2-, and 8-byte tables share the shape below, sized for their element width.
// They're omitted for brevity, so those widths fall back to a scalar loop here:
//
// if (sizeof(T) == 1) return SumVectorSmall1(buffer); // switch over lengths 0..63
// if (sizeof(T) == 2) return SumVectorSmall2(buffer); // switch over lengths 0..31
// if (sizeof(T) == 8) return SumVectorSmall8(buffer); // switch over lengths 0..7
return SumScalar(buffer);
}
private static T SumVectorSmall4<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(sizeof(T) == 4);
Debug.Assert(buffer.Length < Vector512<T>.Count);
T result = T.Zero;
// A 4-byte element gives Count == 4/8/16 for Vector128/256/512, so a remainder can be up to
// 15 elements. The larger cases fold the leftover with the widest vector that fits, using two
// overlapping loads (one from the start, one from the end) rather than recursing—the shape
// TensorPrimitives uses. The loads overlap for lengths that aren't an exact multiple of the
// width, so the tail is masked down to the additive identity before it's summed. That mask is
// only needed because addition is non-idempotent; an idempotent operation such as a search
// could fold the overlapping tail in directly.
switch (buffer.Length)
{
// One or two Vector256's worth of data.
case 15:
case 14:
case 13:
case 12:
case 11:
case 10:
case 9:
case 8:
{
Vector256<T> beg = Vector256.Create(buffer);
Vector256<T> end = Vector256.Create(buffer.Slice(buffer.Length - Vector256<T>.Count));
Vector256<T> msk = CreateRemainderMask256<T>(buffer.Length - Vector256<T>.Count);
end = Vector256.ConditionalSelect(msk, end, Vector256<T>.Zero);
result = Vector256.Sum(beg + end);
break;
}
// One or two Vector128's worth of data.
case 7:
case 6:
case 5:
case 4:
{
Vector128<T> beg = Vector128.Create(buffer);
Vector128<T> end = Vector128.Create(buffer.Slice(buffer.Length - Vector128<T>.Count));
Vector128<T> msk = CreateRemainderMask128<T>(buffer.Length - Vector128<T>.Count);
end = Vector128.ConditionalSelect(msk, end, Vector128<T>.Zero);
result = Vector128.Sum(beg + end);
break;
}
// Smaller than a single vector: each case falls through to the next, accumulating one
// element per label.
case 3:
{
result += buffer[2];
goto case 2;
}
case 2:
{
result += buffer[1];
goto case 1;
}
case 1:
{
result += buffer[0];
goto case 0;
}
case 0:
{
break;
}
}
return result;
}
// Builds a mask whose last `keepLast` lanes are all-bits-set and the rest zero, so an overlapping
// tail load can be folded in without double-counting the lanes the head already covered.
// TensorPrimitives uses an internal table-based helper. The mask is only a bit pattern keyed on
// lane width, so it's built with the same-width integer Indices ([0, 1, 2, ...]) and reinterpreted
// to T: integer comparisons are cheaper than floating-point ones, so a float/double table would
// still compare as int/long rather than in its own element type.
private static Vector256<T> CreateRemainderMask256<T>(int keepLast)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(sizeof(T) == 4);
Vector256<int> firstKept = Vector256.Create(Vector256<int>.Count - keepLast);
return Vector256.GreaterThanOrEqual(Vector256<int>.Indices, firstKept).As<int, T>();
}
private static Vector128<T> CreateRemainderMask128<T>(int keepLast)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(sizeof(T) == 4);
Vector128<int> firstKept = Vector128.Create(Vector128<int>.Count - keepLast);
return Vector128.GreaterThanOrEqual(Vector128<int>.Indices, firstKept).As<int, T>();
}
sizeof(T) est également une constante à la compilation JIT, donc SumVectorSmall sélectionne, en fonction de la largeur des éléments, une table dimensionnée pour le nombre d’éléments du vecteur le plus large, comme le fait TensorPrimitives. (Lorsque le nouveau modèle de sécurité de la mémoire est activé, les expressions sizeof(T) sur un paramètre de type avec la contrainte unmanaged sont autorisées dans du code sûr.) Seule la table de 4 octets est affichée ; les tables de 1, 2 et 8 octets ont la même structure. Dans ses cas les plus larges, il traite le reliquat avec un Vector256 ou un Vector128 à l’aide de deux chargements qui se chevauchent — l’un depuis le début, l’autre depuis la fin — de sorte que le traitement du reliquat plus large pour les chemins Vector512/Vector256 omis se trouve directement dans la table de saut. Les deux chargements se chevauchent chaque fois que la longueur n’est pas un multiple exact de la largeur, de sorte que la queue est masquée à l’identité additive avec ConditionalSelect avant qu’elle ne soit additionnée. Ce masque n’est nécessaire que parce que l’ajout n’est pas idempotent ; une opération idempotente telle qu’une recherche peut plier directement la queue qui se chevauche. Une mémoire tampon sur un matériel sans aucune vectorisation se rabat sur SumScalar, une simple boucle scalaire.
Parcourir les données d’entrée et traiter le reste
Pour traiter une mémoire tampon plus grande qu’un seul vecteur, parcourez-la un vecteur à la fois, puis traitez les éléments restants qui ne remplissent pas un vecteur complet. La façon robuste de gérer ce reliquat consiste à retraiter les éléments correspondant au dernier vecteur complet, en recouvrant ainsi certains éléments déjà traités par la boucle, ce qui évite un épilogue scalaire distinct. Si ce chevauchement a besoin de correction dépend de l’opération.
Une opération non-idempotente, telle qu’une somme, compterait les éléments qui se chevauchent en double ; ramenez-les donc à l’élément neutre de l’opération avant de les intégrer à la réduction. Utilisez cette option lorsque chaque élément doit contribuer exactement une fois :
// Sums a buffer with an unrolled vector loop plus a masked, jump-table remainder.
private static T SumVector128<T>(ReadOnlySpan<T> buffer)
where T : unmanaged, INumberBase<T>
{
Debug.Assert(Vector128.IsHardwareAccelerated && Vector128<T>.IsSupported);
Debug.Assert(buffer.Length >= Vector128<T>.Count);
// Preload the last full vector, overlapping the tail. Any sub-vector remainder is folded in
// from here (masked) by case 0 of the switch below, so the loop never falls out to a separate
// scalar tail—the same shape TensorPrimitives uses.
Vector128<T> end = Vector128.Create(buffer.Slice(buffer.Length - Vector128<T>.Count));
// A production implementation would also align the buffer to a vector boundary and, for
// very large inputs, use non-temporal loads/stores so the data doesn't evict useful
// cache lines. Both are omitted here; see TensorPrimitives for a complete treatment.
Vector128<T> sum = Vector128<T>.Zero;
// Only pay for the four independent accumulators when there's enough data to unroll;
// smaller payloads skip straight to the remainder below. Four vectors per iteration lets
// the accumulators pipeline; Vector128.Create reads the first Vector128<T>.Count elements.
if (buffer.Length >= Vector128<T>.Count * 4)
{
Vector128<T> sum0 = Vector128<T>.Zero;
Vector128<T> sum1 = Vector128<T>.Zero;
Vector128<T> sum2 = Vector128<T>.Zero;
Vector128<T> sum3 = Vector128<T>.Zero;
do
{
sum0 += Vector128.Create(buffer);
sum1 += Vector128.Create(buffer.Slice(Vector128<T>.Count));
sum2 += Vector128.Create(buffer.Slice(Vector128<T>.Count * 2));
sum3 += Vector128.Create(buffer.Slice(Vector128<T>.Count * 3));
buffer = buffer.Slice(Vector128<T>.Count * 4);
}
while (buffer.Length >= Vector128<T>.Count * 4);
// Combine pairwise so the two independent adds can pipeline.
sum = (sum0 + sum1) + (sum2 + sum3);
}
// Split the remainder into its full vectors and a sub-vector tail. The full vectors fall
// through the jump table; the tail lands in case 0, where the preloaded end is masked so only
// the trailing elements the full vectors didn't already cover are added.
(int blocks, int trailing) = Math.DivRem(buffer.Length, Vector128<T>.Count);
switch (blocks)
{
case 3:
{
sum += Vector128.Create(buffer.Slice(Vector128<T>.Count * 2));
goto case 2;
}
case 2:
{
sum += Vector128.Create(buffer.Slice(Vector128<T>.Count));
goto case 1;
}
case 1:
{
sum += Vector128.Create(buffer);
goto case 0;
}
case 0:
{
Vector128<T> msk = CreateRemainderMask128<T>(trailing);
sum += Vector128.ConditionalSelect(msk, end, Vector128<T>.Zero);
break;
}
}
// Horizontally add the lanes into a single scalar.
return Vector128.Sum(sum);
}
Cette version protège la boucle non roulée avec un , ifdonc de petites charges utiles ignorent entièrement les quatre accumulateurs et tombent directement au reste. Lorsqu’il y a suffisamment de données, un do/while accumule quatre vecteurs par itération dans des accumulateurs indépendants, ce qui permet au processeur d’enchaîner les additions dans le pipeline, puis les combine deux à deux. Une table de saut switch prend ensuite en compte les zéro à trois vecteurs complets restants et, dans case 0, la fin de sous-vecteur : elle réutilise un vecteur complet préchargé depuis la fin du tampon, chevauchant ainsi les éléments déjà traités, et ramène ce chevauchement à l’identité additive au moyen de ConditionalSelect afin que la fin reste vectorisée au lieu de repasser à une boucle scalaire. Comme précédemment, Vector128.Create lit les éléments Vector128<T>.Count à partir du span. Le JIT supprime la vérification des limites du span pour les schémas d’accès habituels ; Create constitue donc un bon choix par défaut, même dans une boucle critique. LoadUnsafe (abordé ci-après) est l’alternative de plus bas niveau lorsque vous parcourez le tampon au moyen d’une référence managée. Pour les entrées très volumineuses, une implémentation complète alignerait également la mémoire tampon et utiliserait des charges et des magasins non temporels pour éviter de supprimer des lignes de cache utiles, à la fois omises ici et couvertes entièrement par TensorPrimitives.
Une opération idempotente, telle que la recherche d’une valeur, peut retraiter sans risque la zone de chevauchement ; elle intègre donc directement le dernier vecteur, sans masque :
// Idempotent search that re-processes the final vector instead of a scalar loop.
public static bool Contains(ReadOnlySpan<int> buffer, int searched)
{
Debug.Assert(Vector128.IsHardwareAccelerated);
Vector128<int> values = Vector128.Create(searched);
ReadOnlySpan<int> remaining = buffer;
while (remaining.Length >= Vector128<int>.Count)
{
if (Vector128.EqualsAny(Vector128.Create(remaining), values))
{
return true;
}
remaining = remaining.Slice(Vector128<int>.Count);
}
if (remaining.IsEmpty)
{
return false;
}
// A partial vector remains. When the buffer holds at least one full vector,
// re-check the last one (overlapping the tail); otherwise scan the few elements directly.
if (buffer.Length >= Vector128<int>.Count)
{
Vector128<int> tail = Vector128.Create(buffer.Slice(buffer.Length - Vector128<int>.Count));
return Vector128.EqualsAny(tail, values);
}
foreach (int value in remaining)
{
if (value == searched)
{
return true;
}
}
return false;
}
Warning
La mauvaise gestion du reste est une source courante de bogues. Une boucle qui lit au-delà de la fin de la mémoire tampon produit des résultats non déterministes et peut se bloquer. La suite de tests du runtime utilise une fonction utilitaire BoundedMemory qui place une page inaccessible immédiatement après le tampon, de sorte que toute lecture hors limites déclenche un AccessViolationException lors des tests. Couvrez toujours la logique restante, y compris les mémoires tampons dont la longueur n’est pas un multiple de la largeur du vecteur.
Charger et stocker des vecteurs en toute sécurité
Pour la plupart du code, Vector128.Create(span) et CopyTo sont le moyen le plus simple de déplacer des données entre une étendue et un vecteur, et le JIT les conserve efficacement. Lorsque vous avez besoin d’opérations de chargement et de stockage de bas niveau — par exemple, pour parcourir un tampon à l’aide d’une référence managée — privilégiez les surcharges LoadUnsafe et StoreUnsafe qui prennent une référence managée et un décalage d’élément nuint. Contrairement aux surcharges basées sur des pointeurs Load/Store, elles ne nécessitent pas d’épingler le tampon et, contrairement à l’arithmétique brute sur les références, ne nécessitent pas non plus d’avancer manuellement un ref. Les deux alternatives peuvent facilement être mal utilisées, au risque d’entraîner des trous dans le ramasse-miettes ou des violations d’accès.
Pour que les mémoires tampons vides ne lèvent pas, obtenez la référence de départ à partir GetReference (ou GetArrayDataReference pour les tableaux) plutôt que ref span[0].
Important
L’arithmétique offset utilise unsigned nuint. Vérifiez toujours la longueur de la mémoire tampon avant de calculer un décalage tel que buffer.Length - Vector128<int>.Count. Si la mémoire tampon est inférieure à un vecteur, cette soustraction soustrait une valeur énorme et la boucle lit la mémoire non valide.
Intrinsèques matérielles spécifiques à la plateforme
Lorsqu’une instruction de processeur spécifique vous donne un avantage que les API portables n’exposent pas, tournez-vous vers les intrinsèques matérielles dans System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm et System.Runtime.Intrinsics.Wasm. Chaque classe intrinsèque a une IsSupported propriété (également une constante JIT) pour vous permettre de protéger le chemin spécialisé et de revenir au code portable ailleurs :
// Illustrates per-platform lightup. The portable '(vector & mask) == Zero' below
// already lowers optimally, so prefer it unless a specific instruction measurably wins.
public static bool AllBitsClear(Vector128<byte> vector, Vector128<byte> mask)
{
if (Sse41.IsSupported)
{
// x86/x64: a single ptest instruction.
return Sse41.TestZ(vector, mask);
}
else if (AdvSimd.Arm64.IsSupported)
{
// Arm64: AND, then reduce the maximum byte across every lane.
Vector128<byte> anded = AdvSimd.And(vector, mask);
return AdvSimd.Arm64.MaxAcross(anded).ToScalar() == 0;
}
else if (PackedSimd.IsSupported)
{
// WebAssembly: AND, then test whether any lane is non-zero.
return !PackedSimd.AnyTrue(PackedSimd.And(vector, mask));
}
else
{
// Portable fallback for any other platform.
return (vector & mask) == Vector128<byte>.Zero;
}
}
La méthode précédente montre comment éclairer les chemins de code par architecture lorsque vous le souhaitez, mais il s’agit délibérément d’un exemple simple : vous n’en avez pas réellement besoin ici. L’expression (vector & mask) == Vector128<byte>.Zero portable est déjà inférieure à l’instruction optimale sur chaque plateforme (par exemple, ptest sur x86/x64), de sorte qu’elle effectue le même travail que les branches écrites manuellement, sans la complexité. N’utilisez des intrinsics explicites que lorsqu’une instruction précise surpasse de manière mesurable ce que génèrent les API portables.
Les intrinsèques matérielles nécessitent une implémentation distincte par jeu d’instructions. Elles sont donc considérées comme une optimisation des chemins d’accès chaud mesurés plutôt qu’une valeur par défaut. Les Vector128/Vector256 API sont déjà inférieures à des instructions efficaces sur chaque plateforme et, dans la pratique, le code sophistiqué par instruction ne gagne pas toujours. Vérifiez la différence avec un benchmark avant de valider la maintenance supplémentaire.
Mathématiques de niveau supérieur avec TensorPrimitives
Si vous avez besoin de mathématiques vectorisées sur des étendues et que vous ne souhaitez pas écrire les boucles vous-même, TensorPrimitives fournit un grand ensemble d’opérations numériques ( arithmétiques, exponentielles et réductions à l’échelle de l’élément, telles que le produit dot et la similarité cosinus), qui sont déjà vectorisées en interne. Il est disponible dans le package NuGet System.Numerics.Tensors .
// Computes result = (left * right) + addend over the whole span, vectorized internally.
public static float[] MultiplyAdd(float[] left, float[] right, float[] addend)
{
float[] result = new float[left.Length];
TensorPrimitives.Multiply(left, right, result);
TensorPrimitives.Add(result, addend, result);
return result;
}
// Higher-level reductions are available too.
public static float CosineSimilarity(float[] left, float[] right) =>
TensorPrimitives.CosineSimilarity(left, right);
Pour les charges de travail IA et numériques, TensorPrimitives offre souvent la plupart des avantages du SIMD écrit manuellement sans aucune complexité.
Tester tous les chemins de code
Étant donné qu’une méthode vectorisée possède plusieurs chemins de code, les tests doivent couvrir chacun d’eux : le Vector256 chemin d’accès, le Vector128 chemin d’accès et le chemin scalaire, chacun avec des entrées suffisamment grandes et trop petites pour bénéficier. Vous pouvez varier la taille d’entrée dans les tests, mais vous ne pouvez pas activer l’accélération matérielle au niveau du test. Au lieu de cela, contrôlez-le avec des variables d’environnement avant le démarrage du processus :
- Définir
DOTNET_EnableAVX2=0pour que Vector256.IsHardwareAccelerated renvoiefalse. - Définissez
DOTNET_EnableHWIntrinsic=0afin de désactiver complètement les intrinsèques, de sorte queVector128,Vector64etVector<T>indiquent tous l’absence d’accélération.
Afin de tester tous les chemins d’exécution sur une même machine, exécutez la suite de tests une fois sans surcharge, une fois avec DOTNET_EnableAVX2=0, et une fois avec DOTNET_EnableHWIntrinsic=0. L’autre solution consiste à l’exécuter sur une gamme de matériels suffisamment variée pour les couvrir.
Boutons de configuration de l’ensemble d’instructions
Outre ces deux paramètres, l’environnement d’exécution reconnaît un paramètre pour chaque regroupement logique de jeux d’instructions, chacun étant préfixé par DOTNET_. Un bouton unique peut couvrir plusieurs ensembles d’instructions connexes ,EnableAVX2 par exemple, portes AVX2 avec BMI1, BMI2, F16C, FMA, LZCNT et MOVBE. Régler un bouton sur 0 désactive l’ensemble de son groupe et tous les éléments superposés au-dessus. Le définir sur 1 (la valeur par défaut pour la plupart des cas) autorise le groupe, mais le matériel doit tout de même réellement le prendre en charge — l’activation d’une option que le processeur actuel ne prend pas en charge est ignorée ; vous pouvez donc uniquement restreindre ce qui est utilisé, jamais forcer l’activation d’une instruction non prise en charge et faire planter le système.
DOTNET_EnableHWIntrinsic=0 est la solution de dernier recours : il désactive tout jusqu’au niveau le plus bas, de sorte que Vector128, Vector64 et Vector<T> indiquent tous qu’il n’y a aucune accélération et que le code bascule sur son chemin logiciel.
Important
Il s’agit d’outils de diagnostic, destinés principalement aux tests et à la validation — pour parcourir chaque chemin de code, reproduire un problème propre au matériel ou confirmer un mécanisme de repli. Ils ne sont pas conçus pour une utilisation générale ou de production, et ils ne sont pas un contrat de stabilité. L’ensemble suivant est ce que .NET 11 reconnaît ; les versions antérieures ont exposé un autre ensemble ( la ligne de base et les boutons AVX-512 en particulier ont été reconfigurés), confirmez donc les noms par rapport à la version du runtime que vous ciblez.
Ces outils ont également des limites sur ce qu’ils atteignent. Étant donné qu’ils contrôlent les décisions JIT, ils n’affectent pas le code déjà compilé à l’avance via ReadyToRun ou Native AOT, et ils n’affectent pas nécessairement les routines internes que les bibliothèques runtime et principales utilisent eux-mêmes. Traitez-les comme un moyen de diriger votre propre code compilé JIT, et non un commutateur de désactivation global pour un jeu d’instructions.
Le switch de base et les limites de largeur s’appliquent sur toutes les architectures :
Bouton (DOTNET_ préfixe) |
Default | Résultat |
|---|---|---|
EnableHWIntrinsic |
1 |
Commutateur maître pour toutes les intrinsèques matérielles ; 0 force la voie entièrement logicielle. |
MaxVectorTBitWidth |
système par défaut | Limite Vector<T> à une largeur maximale en bits ; une valeur inférieure à 128 signifie que la valeur par défaut du système est utilisée. |
PreferredVectorBitWidth |
système par défaut | Limite le vecteur de largeur fixe maximale qui signale IsHardwareAccelerated, en bits, une valeur inférieure à 128 signifie que la valeur par défaut du système. |
La valeur par défaut du système pour MaxVectorTBitWidth peut être plus limitée que ce que le matériel prend entièrement en charge ; Vector<T> ne s’étend donc pas automatiquement au vecteur le plus large disponible. Par exemple, Vector512<T>.IsHardwareAccelerated peut être true, tandis que Vector<T> reste sur 256 bits ; définissez DOTNET_MaxVectorTBitWidth=512 pour faire passer Vector<T> à la largeur supérieure.
PreferredVectorBitWidth limite la largeur de vecteur maximale qui signale IsHardwareAccelerated. Le fait de l’abaisser en dessous de ce que le matériel prend en charge désactive les largeurs supérieures : sur une machine qui prend en charge des vecteurs de 512 bits, DOTNET_PreferredVectorBitWidth=256 fait que Vector512<T>.IsHardwareAccelerated affiche false. Il s’agit d’un bouton général, mais seul x86/x64 offre des largeurs supérieures à 128 aujourd’hui, de sorte que c’est le seul endroit où il a un effet observable.
Chaque regroupement logique de jeux d’instructions x86/x64 a également son propre commutateur :
Bouton (à préfixe DOTNET_) |
Default | Gates |
|---|---|---|
EnableAVX |
1 |
AVX et dépendants |
EnableAVX2 |
1 |
AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE et dépendants |
EnableAVX512 |
1 |
AVX-512 F+BW+CD+DQ+VL et dépendants |
EnableAVX512BMM |
1 |
AVX-512 BMM |
EnableAVX512v2 |
1 |
AVX-512 IFMA+VBMI |
EnableAVX512v3 |
1 |
AVX-512 BITALG+VBMI2+VPOPCNTDQ+VNNI |
EnableAVX10v1 |
1 |
AVX10.1 |
EnableAVX10v2 |
0 |
AVX10.2 |
EnableAPX |
0 |
APX (registres à usage général étendus) |
EnableAES |
1 |
AES, PCLMULQDQ |
EnableAVX512VP2INTERSECT |
1 |
AVX-512 VP2INTERSECT |
EnableAVXIFMA |
1 |
AVX-IFMA |
EnableAVXVNNI |
1 |
AVX-VNNI |
EnableAVXVNNIINT |
1 |
VEX AVX-VNNI-INT8 et AVX-VNNI-INT16 |
EnableGFNI |
1 |
GFNI |
EnableSHA |
1 |
SHA |
EnableVAES |
1 |
VAES, VPCLMULQDQ |
EnableWAITPKG |
1 |
WAITPKG |
EnableX86Serialize |
1 |
X86 SERIALIZE |
Sur Arm64, chaque regroupement logique de jeux d’instructions a son propre commutateur :
Bouton (préfixe DOTNET_) |
Default | Gates |
|---|---|---|
EnableArm64Aes |
1 |
Norme de chiffrement avancée (AES) |
EnableArm64Atomics |
1 |
Extensions système volumineuses (LSE) atomiques |
EnableArm64Crc32 |
1 |
CRC32 |
EnableArm64Dczva |
1 |
DC ZVA mise à zéro du cache |
EnableArm64Dp |
1 |
Dot Product |
EnableArm64Rdm |
1 |
Multiplication-accumulation avec doublement et arrondi (RDM) |
EnableArm64Sha1 |
1 |
SHA1 |
EnableArm64Sha256 |
1 |
SHA256 |
EnableArm64Rcpc |
1 |
Ordonnancement cohérent à la libération et cohérent entre processeurs (RCpc) |
EnableArm64Rcpc2 |
1 |
RCpc2 |
EnableArm64Cssc |
0 |
Compression commune de courtes séquences (CSSC) |
EnableArm64Sve |
1 |
Extension de vecteur scalable (SVE) |
EnableArm64Sve2 |
1 |
SVE2 |
EnableArm64Sha3 |
1 |
SHA3 |
EnableArm64Sm4 |
1 |
SM4 |
EnableArm64SveAes |
1 |
SVE AES |
EnableArm64SveSha3 |
1 |
SVE SHA3 |
EnableArm64SveSm4 |
1 |
SVE SM4 |
Un paramètre réglé par défaut sur 0 (par exemple, EnableAVX10v2 ou EnableArm64Cssc) contrôle l’activation d’un jeu d’instructions encore en cours de déploiement, de sorte qu’il reste désactivé jusqu’à ce que vous choisissiez de l’activer.
Test de performance pour confirmer le gain
La vectorisation ajoute de la complexité, donc vérifiez qu’elle en vaut la peine avant de la garder. Utilisez BenchmarkDotNet et utilisez les mêmes variables d’environnement indiquées précédemment pour comparer les scalaires, Vector128et Vector256 les implémentations dans une seule exécution. L’outil de diagnostic de désassemblage de BenchmarkDotNet peut également produire le code assembleur généré, ce qui est inestimable lors de l’optimisation de code haute performance.
Quelques points à garder à l’esprit :
- Les entrées plus volumineuses bénéficient davantage. Pour les petites mémoires tampons, le code vectorisé peut être plus lent que le code scalaire en raison de la surcharge de configuration. Évaluez les tailles d’entrée que vos appelants utilisent réellement.
- Les accélérations sont rarement parfaites. Un vecteur de 256 bits fonctionnant sur des éléments de 32 bits ne sera pas nécessairement 8 fois plus rapide ; la bande passante mémoire, l’alignement et la latence des instructions entrent tous en jeu.
- L’alignement de la mémoire affecte la stabilité. L’alignement d’allocation aléatoire ajoute du bruit d’une exécution à l’autre. Vous pouvez allouer une mémoire alignée avec AlignedAlloc pour obtenir des résultats stables, ou activer la randomisation de la mémoire de BenchmarkDotNet pour observer la distribution complète.
Bonnes pratiques
- Commencez par atteindre les API de niveau supérieur existantes.
Span<T>,string, LINQ,TensorPrimitives, et les types tensoriels accélèrent déjà pour vous de nombreuses opérations courantes — n'implémentez pas vous-même ce qui est déjà optimisé et testé. - Commencez par
Vector128<T>; il bénéficie d’une accélération sur la plus large gamme de matériels, et vous n’avez pas besoin deVector256<T>pour obtenir une implémentation correcte et portable. N’ajoutez des largeurs supérieures et des intrinsèques matérielles que pour les chemins critiques mesurés. - Vérifiez directement
IsHardwareAcceleratedetCountau lieu de les mettre en cache ; le JIT les transforme en constantes. - Gérez toujours le reste de la boucle et comptez les mémoires tampons source et de destination qui se chevauchent lors du stockage.
- Écrivez d’abord des tests de cas de périphérie, puis une solution scalaire, puis exprimez cette logique scalaire avec les API vectorielles.
- Testez chaque chemin de code (y compris les violations d’accès) et testez les tailles d’entrée réalistes avant de valider la complexité ajoutée.