Nota
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare ad accedere o modificare le directory.
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare a modificare le directory.
SIMD (singola istruzione, più dati) è il supporto hardware per l'applicazione di un'operazione a più parti di dati in parallelo con una singola istruzione. Il codice vettorializzato elabora diversi valori per iterazione anziché uno, che può aumentare notevolmente la velocità effettiva per il tipo di lavoro numerico, scientifico, grafico, elaborazione del testo e parallelismo dei dati in cui la stessa operazione si ripete su un buffer. Il rovescio della medaglia è una maggiore complessità, quindi conviene soprattutto quando l'input è sufficientemente grande e il vantaggio è confermato da misurazioni.
.NET offre diversi tipi di supporto SIMD. Scegli quello che corrisponde al controllo necessario e alla complessità che sei disposto ad assumere.
Tipi di supporto SIMD in .NET
| API (Interfaccia di Programmazione delle Applicazioni) | Namespace | Quando usarlo |
|---|---|---|
| Tipi di vettore e matrice a scopo fisso | System.Numerics | Grafica e matematica geometrica con vettori di elementi da 2 a 4, matrici, quaternioni e piani. |
Vector<T> |
System.Numerics | Vettorizzazione portabile a larghezza variabile quando non è necessario il controllo per piattaforma. |
Vector64<T>, Vector128<T>, Vector256<T>Vector512<T> |
System.Runtime.Intrinsics | Vettorizzazione multipiattaforma e a larghezza fissa con controllo con granularità fine. Questo è il punto di partenza consigliato per i nuovi algoritmi vettorializzati. |
| Funzioni intrinseche hardware | System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm, System.Runtime.Intrinsics.Wasm | Istruzioni specifiche del processore che le API di livello superiore non espongono, per l'ultimo bit di prestazioni in un percorso critico. |
TensorPrimitives |
System.Numerics.Tensors | Operazioni matematiche vettorializzate predefinite su span. Esegue la vettorializzazione per te. |
Dove queste API si sovrappongono, si riferiscono attraverso livelli di astrazione. I tipi vettoriali generici sono i tipi di interscambio fondamentali che gli altri livelli si scambiano tra loro, quindi tecnicamente costituiscono il livello più basso: il tipo a larghezza variabile Vector<T>, che si espande fino a qualsiasi ampiezza supportata dall'hardware in uso, e i tipi a larghezza fissa da Vector64<T> a Vector512<T>. Gli intrinseci hardware specifici della piattaforma in System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm e System.Runtime.Intrinsics.Wasm operano su tali tipi, ciascuno dei quali corrisponde direttamente a una singola istruzione del processore. Le operazioni multipiattaforma esposte sui tipi generici si collocano a un livello superiore rispetto alle primitive intrinseche specifiche della piattaforma, venendo poi mappate su di esse per ciascuna piattaforma di destinazione. Più in alto sono ancora le API gestite che operano su interi buffer, metodi vettorializzati su Span<T> e string, TensorPrimitivesche si basano sui livelli sottostanti, in modo da ottenere l'accelerazione SIMD senza scriverne a mano uno. I System.Numerics tipi a forma definita sono tipi di utilità specifici del dominio per la grafica e la geometria, anziché far parte di questo stack di interscambio.
Il resto di questo articolo illustra queste API dal livello più alto al più basso, quindi illustra i test, il benchmarking e le procedure consigliate.
Tipi vettoriali e matriciali di System.Numerics
Il namespace System.Numerics include tipi accelerati tramite SIMD con dimensioni fisse:
- Vector2, Vector3e Vector4 rappresentano vettori di 2, 3 e 4 Single valori.
- Matrix3x2 e Matrix4x4 rappresentano matrici di Single valori 3x2 e 4x4.
- Plane rappresenta un piano nello spazio tridimensionale.
- Quaternion rappresenta un vettore utilizzato per codificare rotazioni tridimensionali.
Questi tipi si adattano naturalmente alla grafica e alla geometria, e il runtime accelera le relative operazioni con istruzioni SIMD quando l'hardware le supporta. Nell'esempio seguente vengono aggiunti due vettori:
Vector2 v1 = Vector2.Create(0.1f, 0.2f);
Vector2 v2 = Vector2.Create(1.1f, 2.2f);
Vector2 sum = v1 + v2;
Espongono anche le comuni operazioni di algebra vettoriale che ci si aspetta, come il prodotto scalare, la distanza e la limitazione:
float dot = Vector2.Dot(v1, v2);
float distance = Vector2.Distance(v1, v2);
Vector2 clamped = Vector2.Clamp(v1, Vector2.Zero, Vector2.One);
I tipi di matrice supportano la matematica della matrice, ad esempio trasposizione e moltiplicazione:
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);
Vettore<T>
Vector<T> rappresenta un vettore a larghezza variabile di un tipo numerico primitivo. La lunghezza è fissa per la durata del processo, ma il valore di Vector<T>.Count dipende dalla CPU che esegue il codice. Il compilatore Just-In-Time (JIT) considera Count come costante, quindi i cicli scritti su di esso ottimizzano bene.
Vector<T> offre la vettorializzazione portabile senza codice per piattaforma, al costo di non conoscere la larghezza del vettore in fase di compilazione. Nell'esempio seguente viene calcolato l'aggiunta a livello di elemento di due matrici:
// 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;
}
Annotazioni
Questo esempio è illustrativo. Raramente è necessario scrivere un ciclo come questo manualmente, perché TensorPrimitives fornisce già matematica accelerata basata su span. Questo componente aggiuntivo a livello di elemento è Adde vengono fornite anche riduzioni come Sum . Queste operazioni sono accelerate dall'hardware per i tipi di elemento supportati Vector<T> (Vector<T>.IsSupported).
Verificare la presenza di accelerazione hardware
I tipi accelerati tramite SIMD funzionano anche su configurazioni hardware o JIT che non supportano SIMD, perché ripiegano su implementazioni software non accelerate. Per stabilire se l'accelerazione è effettivamente disponibile, controllare la proprietà pertinente IsHardwareAccelerated :
-
Vector.IsHardwareAccelerated indica se
Vector<T>le operazioni vengono accelerate. -
Vector128.IsHardwareAcceleratede gli equivalenti, segnalano l'accelerazione
Vector64/Vector256/Vector512per ogni larghezza fissa.
Queste proprietà vengono trasformate in costanti dal JIT, quindi i rami che non vengono percorsi vengono eliminati e verificarle non comporta alcun costo in fase di esecuzione. Non memorizzare nella cache i valori; leggerli direttamente dove sono necessari. Lo stesso vale per le Count proprietà ( ad esempio , Vector128<T>.Count), che sono anche costanti JIT-time.
La maggior parte delle operazioni su una larghezza supportata dall'accelerazione beneficia a sua volta dell'accelerazione, ma ciò non è garantito per tutte le operazioni. Ad esempio, la divisione in virgola mobile potrebbe essere accelerata, mentre la divisione tra interi non lo è. Quando Vector256 è accelerato, di solito lo è anche Vector128, ma non è garantito, quindi verifica ogni larghezza che utilizzi.
Tip
Se un'operazione di cui hai bisogno non è accelerata su una piattaforma che ti interessa, oppure se desideri una nuova API multipiattaforma, segnala il problema in dotnet/runtime. Lo stesso vale anche per i miglioramenti al codegen.
Non tutti i tipi di elemento sono validi per ogni vettore.
Vector128<T> e gli elementi correlati supportano attualmente i tipi numerici primitivi (byte, sbyte, short, ushort, int, uint, long, ulong, float, double, nint e nuint), e questo insieme potrebbe ampliarsi fino a includere altri tipi in futuro. Usare Vector128<T>.IsSupported per determinare se un dato T è valido, particolarmente utile dal codice generico.
I tipi non supportati, ad esempio char e bool, possono comunque essere vettorizzati reinterpretando il buffer come tipo supportato della stessa dimensione. Usa Cast per reinterpretare uno span, ad esempio da char a ushort, oppure il metodo As<TFrom, TTo> del vettore per reinterpretare un vettore di cui si è già in possesso. La reinterpretazione modifica solo il tipo, non i bit sottostanti, quindi è responsabilità dell'utente mantenere i dati ben formati: deve bool rimanere 0 o 1e deve char rimanere un'unità di codice UTF-16 valida. Se un'operazione vettorializzata potrebbe produrre un valore non compreso nell'intervallo, è necessario normalizzare il risultato prima di scriverlo di nuovo.
Vettorizzazione multipiattaforma con Vector128
Vector128<T> è il denominatore comune in ogni piattaforma che supporta la vettorializzazione, quindi è il posto migliore per iniziare. Contiene un vettore a 128 bit: 16 byte, 8 short, 4 ints/floats o 2 longs/double.
------------------------------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> è due volte più largo, e Vector512<T> due volte di nuovo. Non tutti gli hardware supportano le larghezze maggiori, quindi gli esempi che seguono usano Vector128 per la portabilità.
Ogni larghezza ha un tipo generico (Vector128<T>) per i dati e una classe statica non generica (Vector128) che contiene la maggior parte delle operazioni, inclusi metodi factory statici come Create e Load. Operatori come +, & e << sono il modo più idiomatico per esprimere operazioni aritmetiche e sui bit; sono da preferire ai metodi equivalenti con nome per evitare errori dovuti alla precedenza degli operatori e migliorare la leggibilità. Per gli algoritmi che dipendono dall'ordine dei byte, usa IsLittleEndian per la diramazione, che il JIT riconduce anch'esso a una costante.
Annotazioni
In x86/x64 le Vector256<T> operazioni vengono generalmente considerate come due "corsie" indipendenti a 128 bit. Per la maggior parte delle operazioni element-wise ciò è trasparente, ma le operazioni che attraversano le lane (come gli shuffle o le operazioni a coppie/orizzontali) potrebbero comportarsi diversamente o avere un costo maggiore rispetto all'equivalente Vector128. Verificare con i benchmark prima di presupporre che un vettore più ampio sia più veloce.
Le operazioni di attraversamento della corsia non si ampliano gratuitamente
Le operazioni per elemento non dipendono dalla larghezza: v1 + v2 produce lo stesso risultato per ciascun elemento, sia che v1 e v2 siano Vector128<T> o Vector256<T>: l'allargamento elabora solo una corsia di dati in più per istruzione.
Add on v = [a, b, c, d] e w = [e, f, g, h] combina sempre gli elementi dello stesso indice:
v: [ a | b | c | d ]
w: [ e | f | g | h ]
+ + + +
r: [a+e|b+f|c+g|d+h]
Le operazioni tra corsie non sono così facilmente scalabili, perché gli elementi che vengono combinati dipendono dalla larghezza del vettore. Una riduzione a coppie combina elementi adiacenti invece di quelli con lo stesso indice, quindi l’ampliamento cambia quali elementi finiscono per essere abbinati tra loro:
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)
È esattamente ciò che fa una riduzione orizzontale: somma gli elementi di un vettore in due passaggi di somme a coppie. Su x86/x64, Vector128<float> (4 elementi) ci si arriva con due chiamate a 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();
}
Applica lo stesso schema a due chiamate a Vector256<float> (8 elementi) e sembra corretto, ma non lo è.
HorizontalAdd non opera nell'intero vettore a 256 bit; ripete il modello pairwise in modo indipendente all'interno di ogni corsia a 128 bit. Due passaggi producono la somma degli elementi della corsia inferiore (elementi 0-3), replicata su tutta la corsia inferiore, e la somma degli elementi della corsia superiore (elementi 4-7), replicata su tutta la corsia superiore, non la somma di tutti e otto gli elementi:
// 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();
}
Per ottenere il totale corretto, attraversa esplicitamente il confine tra lane: leggi la somma parziale di ciascuna lane con GetLower/GetUpper e sommale; GetLower e GetUpper dividono un vettore nella sua prima e seconda metà:
------------------------------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();
}
Questo passo aggiuntivo è il costo reale delle corsie di attraversamento. Un algoritmo che attraversa le lane non si amplia automaticamente come invece fa uno elemento per elemento: misura prima di presumere che il vettore più ampio sia vincente.
Operazioni comuni
Vector128 e i suoi componenti correlati più estesi offrono un'ampia API. Non è necessario memorizzarlo, conoscere le categorie e cercare i dettagli quando sono necessari. Ogni operazione ha un fallback software per le piattaforme che non possono accelerarla. La tabella seguente illustra essenzialmente l'intera superficie.
| Categoria | Funzionamento | API rappresentative |
|---|---|---|
| Constants | Vettori costanti predefiniti |
Zero, One, NegativeOne, AllBitsSet, Indices, SignSequence, E, Pi, Tau, Epsilon, NaN, PositiveInfinity, NegativeInfinity, NegativeZero |
| Creazione | Trasmettere una sequenza scalare, impostare elementi o generare una sequenza |
Create, CreateScalar, CreateScalarUnsafe, Create(ReadOnlySpan<T>)CreateSequence, CreateGeometricSequence, , CreateHarmonicSequenceCreateAlternatingSequence |
| Caricare e archiviare | Spostare i dati tra memoria e vettore |
Load, LoadUnsafe, LoadAligned, LoadAlignedNonTemporal, Store, StoreUnsafe, StoreAligned, StoreAlignedNonTemporal, CopyTo, TryCopyTo |
| Aritmetica | Matematica e riduzioni degli elementi |
Add (x + y), Subtract (x - y), Multiply (x * y), Divide (x / y), Negate (-x), AddSaturate, SubtractSaturate, Abs, Sqrt, FusedMultiplyAdd, Dot, Sum |
| Operazioni di bit | Logica bit per bit e spostamenti |
BitwiseAnd (x & y), BitwiseOr (x \| y), Xor (x ^ y), AndNot (x & ~y)OnesComplement (~x), ShiftLeft (x << n), , ShiftRightArithmetic (x >> n)ShiftRightLogical (x >>> n) |
| Min, max e limitazione | Limitazione per elemento del minimo, del massimo e dell'intervallo |
Min, Max, Clamp, MinMagnitude, MaxMagnitudeMinNumber, MaxNumber, , MinMagnitudeNumberMaxMagnitudeNumber |
| Arrotondamento | Arrotondare ogni elemento a un valore integrale |
Ceiling, Floor, RoundTruncate |
| Funzioni matematiche | Funzioni di supporto per segno, interpolazione, angolo e funzioni trascendentali |
CopySign, Lerp, DegreesToRadians, RadiansToDegrees, Hypot, Sin, Cos, SinCos, Asin, Exp, Log, Log2 |
| Comparison | Confronta ogni elemento; il risultato è una maschera vettoriale, non quella che dà l'operatore bool |
Equals, GreaterThan, GreaterThanOrEqual, LessThanLessThanOrEqual |
| Classification | Predicati per elemento sulla riga numerica, ognuno dei quali restituisce una maschera vettoriale |
IsNaN, IsFinite, IsInfinity, IsPositiveInfinity, IsNegativeInfinity, IsInteger, IsEvenInteger, IsOddInteger, IsNegative, IsPositive, IsNormal, IsSubnormal, IsZero |
| Riduzioni di confronto | Ridurre un confronto per elemento a un singolo bool |
EqualsAll (x == y), EqualsAny, GreaterThanAll, GreaterThanAny, GreaterThanOrEqualAll, GreaterThanOrEqualAny, LessThanAll, LessThanAny, LessThanOrEqualAll, LessThanOrEqualAny |
| Predicati sull'intero vettore | Ridurre un vettore a un bool: verificare se tutti, almeno uno o nessun elemento è uguale a un valore oppure se (nelle forme WhereAllBitsSet) tutti i bit sono impostati. Preferite questi anziché convertire una maschera in un indice |
All, Any, None, AllWhereAllBitsSet, AnyWhereAllBitsSetNoneWhereAllBitsSet |
| Search | Contare o individuare gli elementi in base al valore oppure (i WhereAllBitsSet moduli) impostano corsie in una maschera |
Count, IndexOf, LastIndexOf, CountWhereAllBitsSet, IndexOfWhereAllBitsSetLastIndexOfWhereAllBitsSet |
| Maschera da indicizzare | Trasformare una maschera di confronto in una maschera di bit scalare e analizzarla |
ExtractMostSignificantBits con TrailingZeroCount o LeadingZeroCount |
| Selection | Combinare due vettori secondo una maschera, bit per bit |
ConditionalSelect(x, y, z), equivalente a (y & x) \| (z & ~x) |
| Conversion | Modificare il tipo numerico, calcolando nuovi valori (ad esempio, int in float) |
ConvertToInt32, ConvertToInt64, ConvertToUInt32, ConvertToUInt64, ConvertToSingleConvertToDouble |
| Allargamento e restringimento | Suddividere gli elementi in un tipo più ampio o comprimerli in uno più stretto |
Widen, WidenLower, WidenUpper, NarrowNarrowWithSaturation |
| Reinterpretazione | Reinterpretare i bit come un altro tipo di elemento senza modificarli |
As<TFrom, TTo>, AsByte, AsInt32, AsSingle, e le altre As* forme di elemento |
| Interoperabilità con System.Numerics | Reinterpretare tra Vector128<T> e i tipi numerici a forma fissa |
AsVector, AsVector2, AsVector3, AsVector4AsPlane, AsQuaternion, , AsVector128AsVector128Unsafe |
| Reorder | Ridisporre gli elementi per indice o interleavere due vettori |
Shuffle, Reverse, Zip, ZipLower, ZipUpper, Unzip, UnzipEven, UnzipOdd, ConcatLowerLower, ConcatLowerUpper, ConcatUpperLower, ConcatUpperUpper |
| Accesso alla corsia | Visualizzare o sostituire i singoli elementi e le relative metà, oppure ridimensionare il vettore |
GetElement, WithElement, ToScalar, GetLowerGetUpper, WithLower, , WithUpperToVector256 |
Tip
Diverse operazioni presentano varianti Estimate e Native; ad esempio, MultiplyAddEstimate, ClampNative, MinNative, MaxNative, ShuffleNative e ConvertToInt32Native. Corrispondono a un'istruzione hardware più veloce che sacrifica un po' di precisione o rinuncia a una garanzia IEEE per i casi limite (come la gestione dei NaN), quindi usateli solo quando un benchmark mostra che la forma esatta è il collo di bottiglia e una semantica meno rigorosa è accettabile.
Annotazioni
Vector256.Shuffle considera il suo input come un singolo vettore a 256 bit, mentre Avx2.Shuffle, specifico della piattaforma, funziona come due blocchi indipendenti a 128 bit. L'API multipiattaforma è la scelta più portabile, ma è opportuno verificare il comportamento richiesto quando si esegue il porting di intrinseci scritti manualmente.
Strutturare il percorso del codice
Un metodo vettorializzato in genere si dirama in un percorso per ciascuna larghezza del vettore, più un fallback scalare per input di dimensioni ridotte e per hardware non accelerato. Per usare il vettore più grande supportato dall'hardware, verificare prima il vettore di ampiezza maggiore e poi procedere via via a quelli più piccoli:
// 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 condizione esterna di ciascuna larghezza combina Vector128.IsHardwareAccelerated (una costante in fase JIT che indica se la piattaforma accelera tale larghezza) con Vector128<T>.IsSupported (se il tipo di elemento T è valido per tale larghezza). All'interno di un blocco supportato, confrontare la lunghezza di input con Count per scegliere tra il percorso vettorializzato e un fallback di input di piccole dimensioni. Il metodo è generico rispetto a T, e i blocchi Vector256 e Vector512, identici al blocco Vector128 ma che usano il tipo più ampio, sono mostrati come commentati, per brevità.
Esistono due meccanismi di riserva separati. Un buffer troppo piccolo per anche il vettore più stretto, ma su hardware accelerato, passa a SumVectorSmall, una tabella di salto esplicita switch che gestisce ogni possibile lunghezza del vettore secondario senza un ciclo:
// 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) è anche una costante in fase JIT, quindi SumVectorSmall effettua il dispatch in base alla larghezza dell'elemento verso una tabella dimensionata per un numero di elementi pari a quello del vettore più largo, lo stesso approccio utilizzato da TensorPrimitives. Quando il nuovo modello di sicurezza della memoria è abilitato, sizeof(T) le espressioni su un parametro di tipo con il unmanaged vincolo sono consentite nel codice sicuro. Viene visualizzata solo la tabella a 4 byte; le tabelle a 1 byte, 2 byte e 8 byte condividono la forma. I casi più grandi gestiscono il residuo con un Vector256 o Vector128 usando due letture sovrapposte — una dall'inizio, una dalla fine — così la gestione del residuo più ampio per i percorsi Vector512/Vector256 omessi si trova direttamente nella tabella di salto. I due carichi si sovrappongono ogni volta che la lunghezza non è un multiplo esatto della larghezza, quindi la coda viene mascherata all'identità aggiuntiva con ConditionalSelect prima che venga sommata. Questa maschera è necessaria solo perché l'addizione non è idempotente; un'operazione idempotente, ad esempio una ricerca, potrebbe piegare direttamente la coda sovrapposta. Un buffer sull'hardware senza vettorizzazione passa a SumScalar, un normale ciclo scalare.
Scorri i dati di input e gestisci il restante
Per elaborare un buffer più grande di un singolo vettore, iterare sul buffer un vettore per volta, quindi gestire gli elementi rimanenti che non sono sufficienti a riempire un vettore completo. Il modo più affidabile per gestire la coda consiste nel rielaborare gli elementi corrispondenti all’ultimo vettore completo, sovrapponendosi ad alcuni elementi già elaborati dal ciclo, evitando così un epilogo scalare separato. L'eventuale correzione di tale sovrapposizione dipende dall'operazione.
Un'operazione non idempotente, come una somma, contarebbe due volte gli elementi sovrapposti, quindi bisogna mascherarli impostandoli all'elemento identità dell'operazione prima di includerli nella riduzione. Usare questa opzione quando ogni elemento deve contribuire esattamente una volta:
// 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);
}
Questa versione protegge il ciclo srotolato con un if, così i payload di piccole dimensioni saltano completamente i quattro accumulatori e passano direttamente alla parte restante. Quando sono presenti dati sufficienti, un do/while accumula quattro vettori per iterazione in archivi indipendenti, che consente alla pipeline del processore di aggiungere le aggiunte e le combina in modo abbinato. Una switch tabella di salto si piega quindi nei rimanenti vettori completi da zero a tre e, in case 0, la coda del vettore secondario: riutilizza un vettore completo precaricato dalla fine del buffer, sovrapponendo gli elementi già elaborati e maschera che si sovrappongono all'identità aggiuntiva con ConditionalSelect in modo che la coda rimanga vettorializzata invece di cadere in un ciclo scalare. Come in precedenza, Vector128.Create legge gli elementi Vector128<T>.Count dallo span. Il JIT omette il controllo dei limiti dello span per i modelli di accesso tipici, quindi Create è una scelta predefinita valida anche in un loop critico; LoadUnsafe (descritto di seguito) è l'alternativa di livello inferiore quando si scorre il buffer tramite riferimento gestito. Per input molto grandi, un'implementazione completa allineerebbe anche il buffer e userebbe letture e scritture non temporali per evitare di espellere dalla cache linee utili; entrambi gli aspetti sono omessi qui e trattati in modo completo in TensorPrimitives.
Un'operazione idempotente , ad esempio la ricerca di un valore, può rielaborare la sovrapposizione in modo innocuo, in modo da piegare l'ultimo vettore direttamente senza maschera:
// 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 gestione errata del resto è una fonte comune di bug. Un ciclo che legge oltre la fine del buffer produce risultati non deterministici e può arrestarsi in modo anomalo. La suite di test del runtime usa un helper BoundedMemory che colloca una pagina inaccessibile immediatamente dopo il buffer, così qualsiasi lettura oltre i limiti provoca un'eccezione AccessViolationException durante i test. Coprire sempre la logica per la gestione degli elementi rimanenti, compresi i buffer la cui lunghezza non è un multiplo della larghezza del vettore.
Caricare e archiviare vettori in modo sicuro
Per la maggior parte del codice Vector128.Create(span) e CopyTo sono il modo più semplice per spostare i dati tra un intervallo e un vettore e jit li mantiene efficienti. Quando sono necessarie operazioni di caricamento e archiviazione di livello inferiore, ad esempio per scorrere un buffer tramite riferimento gestito, è preferibile usare i sovraccarichi LoadUnsafe e StoreUnsafe che accettano un riferimento gestito e un offset di elementi nuint. A differenza degli overload basati sul Load/Store puntatore, non richiedono l'aggiunta del buffer e, a differenza dell'aritmetica dei riferimenti non elaborati, non è necessario avanzare manualmente un oggetto ref. È facile commettere errori con entrambe le alternative, introducendo vulnerabilità nel garbage collector o violazioni di accesso.
Per evitare che i buffer vuoti generino eccezioni, ottenere il riferimento iniziale da GetReference (o GetArrayDataReference per gli array) anziché da ref span[0].
Importante
L'aritmetica degli offset usa interi senza segno nuint. Controllare sempre la lunghezza del buffer prima di calcolare un offset, buffer.Length - Vector128<int>.Countad esempio . Se il buffer è minore di un vettore, tale sottrazione passa a un valore enorme e il ciclo legge memoria non valida.
Intrinseci hardware specifici della piattaforma
Quando una specifica istruzione del processore ti offre un vantaggio che le API portabili non espongono, ricorri alle funzioni intrinseche hardware in System.Runtime.Intrinsics.X86, System.Runtime.Intrinsics.Arm e System.Runtime.Intrinsics.Wasm. Ogni classe intrinseca ha una IsSupported proprietà (anche una costante JIT) in modo da poter proteggere il percorso specializzato ed eseguire il fallback al codice portabile altrove:
// 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;
}
}
Il metodo precedente mostra come accendere i percorsi di codice per architettura quando li si vuole, ma è intenzionalmente un semplice esempio: non è effettivamente necessario qui. L'espressione portabile (vector & mask) == Vector128<byte>.Zero viene già tradotta nell'istruzione ottimale su ogni piattaforma (ad esempio, ptest su x86/x64), quindi svolge la stessa funzione delle diramazioni scritte manualmente, ma senza la complessità aggiuntiva. Ricorri alle intrinsics esplicite solo quando un'istruzione specifica offre prestazioni misurabilmente migliori di quanto generato dalle API portabili.
Gli intrinsic hardware richiedono un'implementazione separata per ogni set di istruzioni, quindi vanno considerati un'ottimizzazione per i percorsi critici individuati tramite misurazioni, anziché una scelta predefinita. Le Vector128/Vector256 API vengono già tradotte in istruzioni efficienti su ogni piattaforma e, in pratica, il codice sofisticato ottimizzato per singola istruzione non sempre offre risultati migliori. Conferma la differenza con un benchmark prima di impegnarti nella manutenzione aggiuntiva.
Matematica di livello superiore con TensorPrimitives
Se sono necessari calcoli matematici vettorializzati su intervalli e non si vogliono scrivere manualmente i cicli, TensorPrimitives fornisce un ampio set di operazioni numeriche, ovvero aritmetiche, esponenziali e riduzioni per elemento, ad esempio la somiglianza del prodotto e del coseno, che sono già vettorializzate internamente. È disponibile nel pacchetto 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);
Per i carichi di lavoro numerici e di intelligenza artificiale, TensorPrimitives spesso offre la maggior parte dei vantaggi del SIMD scritto a mano senza alcuna complessità.
Testare tutti i percorsi di codice
Poiché un metodo vettorializzato ha diversi percorsi di codice, i test devono coprire ognuno di essi: il Vector256 percorso, il Vector128 percorso e il percorso scalare, ognuno con input sufficientemente grandi e troppo piccoli per trarre vantaggio. È possibile variare le dimensioni di input nei test, ma non è possibile attivare o disattivare l'accelerazione hardware a livello di test. Al contrario, controllarlo con le variabili di ambiente prima dell'avvio del processo:
- Impostare
DOTNET_EnableAVX2=0in modo che Vector256.IsHardwareAccelerated restituiscafalse. - Impostare
DOTNET_EnableHWIntrinsic=0su disabilita completamente le intrinseche, in modo cheVector128,Vector64eVector<T>riportino tutti l'assenza di accelerazione.
Per eseguire ogni percorso in un singolo computer, eseguire il gruppo di test una volta senza override, una volta con DOTNET_EnableAVX2=0e una volta con DOTNET_EnableHWIntrinsic=0. L'alternativa consiste nell'eseguire test su una varietà di configurazioni hardware sufficiente a coprirle.
Knob di configurazione del set di istruzioni
Oltre a questi due, il runtime riconosce un knob per raggruppamento logico di set di istruzioni, ognuno preceduto da DOTNET_. Un singolo knob può coprire diversi setEnableAVX2 di istruzioni correlati, ad esempio gate AVX2 insieme a BMI1, BMI2, F16C, FMA, LZCNT e MOVBE. Impostando una manopola su 0 si disabilitano l'intero gruppo e tutti i livelli sovrapposti. Impostandolo su 1 (il valore predefinito per la maggior parte dei casi) si abilita il gruppo, ma l'hardware deve comunque supportarlo davvero: l'attivazione di un'opzione non supportata dalla CPU in uso viene ignorata, quindi puoi solo restringere ciò che viene utilizzato, senza mai poter forzare l'attivazione di un'istruzione non supportata e compromettere il sistema.
DOTNET_EnableHWIntrinsic=0 è la soluzione drastica: disattiva tutto fino al livello più basso, quindi Vector128, Vector64 e Vector<T> segnalano tutti l'assenza di accelerazione e il codice passa al percorso software.
Importante
Si tratta di strumenti di diagnostica, destinati principalmente a test e convalida, ovvero l'esercizio di ogni percorso di codice, la riproduzione di un problema specifico dell'hardware o la conferma di un fallback. Non sono progettati per uso generale o di produzione e non sono un contratto di stabilità. L'insieme seguente è quello riconosciuto da .NET 11; le versioni precedenti esponevano un insieme diverso — in particolare, le impostazioni di base e quelle di AVX-512 sono state riconfigurate — quindi verifica i nomi in base alla versione del runtime di destinazione.
Questi strumenti hanno anche limiti su ciò che raggiungono. Poiché controllano le decisioni JIT, non influiscono sul codice già compilato in anticipo tramite ReadyToRun o Native AOT e non influiscono necessariamente sulle routine interne usate dal runtime e dalle librerie principali. Considerali come un modo per indirizzare il tuo codice compilato tramite JIT, non come un interruttore globale per disattivare un set di istruzioni.
L'opzione di base e i limiti di larghezza si applicano a ogni architettura:
Manopola (prefisso DOTNET_) |
Impostazione predefinita | Effetto |
|---|---|---|
EnableHWIntrinsic |
1 |
Commutatore master per tutti gli intrinseci hardware; 0 forza il percorso software completo. |
MaxVectorTBitWidth |
impostazione predefinita del sistema |
Vector<T> La larghezza massima è espressa in bit; un valore inferiore a 128 indica l'impostazione predefinita del sistema. |
PreferredVectorBitWidth |
impostazione predefinita del sistema | Delimita il vettore a larghezza fissa massima che segnala IsHardwareAccelerated, in bit; un valore inferiore a 128 indica l'impostazione predefinita del sistema. |
L'impostazione predefinita del sistema per MaxVectorTBitWidth può essere più stretta rispetto all'hardware completamente supportato, quindi Vector<T> non aumenta automaticamente fino al vettore più ampio disponibile. Ad esempio, Vector512<T>.IsHardwareAccelerated può essere true mentre Vector<T> rimane a 256 bit; impostare DOTNET_MaxVectorTBitWidth=512 per acconsentire esplicitamente Vector<T> alla larghezza più ampia.
PreferredVectorBitWidth limita la larghezza massima del vettore riportata da IsHardwareAccelerated. Riducendolo al di sotto del valore supportato dall'hardware, le larghezze maggiori vengono disattivate: su una macchina che supporta vettori a 512 bit, DOTNET_PreferredVectorBitWidth=256 fa sì che Vector512<T>.IsHardwareAccelerated riporti false. Si tratta di una manopola generale, ma solo x86/x64 offre larghezze superiori a 128 oggi, quindi è l'unico posto in cui ha un effetto osservabile.
Ogni raggruppamento logico di set di istruzioni x86/x64 ha anche un proprio commutatore:
Manopola (DOTNET_ prefisso) |
Impostazione predefinita | Gates |
|---|---|---|
EnableAVX |
1 |
AVX e dipendenti |
EnableAVX2 |
1 |
AVX2, BMI1, BMI2, F16C, FMA, LZCNT, MOVBE e dipendenti |
EnableAVX512 |
1 |
AVX-512 F+BW+CD+DQ+VL e relative dipendenze |
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 (registri estesi per utilizzo generico) |
EnableAES |
1 |
AES, PCLMULQDQ |
EnableAVX512VP2INTERSECT |
1 |
AVX-512 VP2INTERSECT |
EnableAVXIFMA |
1 |
AVX-IFMA |
EnableAVXVNNI |
1 |
AVX-VNNI |
EnableAVXVNNIINT |
1 |
VEX AVX-VNNI-INT8 e AVX-VNNI-INT16 |
EnableGFNI |
1 |
GFNI |
EnableSHA |
1 |
SHA |
EnableVAES |
1 |
VAES, VPCLMULQDQ |
EnableWAITPKG |
1 |
WAITPKG |
EnableX86Serialize |
1 |
SERIALIZZARE X86 |
In Arm64 ogni raggruppamento logico di set di istruzioni ha un proprio commutatore:
Manopola (DOTNET_ prefisso) |
Impostazione predefinita | Gates |
|---|---|---|
EnableArm64Aes |
1 |
AES |
EnableArm64Atomics |
1 |
operazioni atomiche LSE (Large System Extensions) |
EnableArm64Crc32 |
1 |
CRC32 |
EnableArm64Dczva |
1 |
DC ZVA azzeramento della cache |
EnableArm64Dp |
1 |
Punto prodotto |
EnableArm64Rdm |
1 |
Arrotondamento, raddoppio, moltiplicazione e accumulo (RDM) |
EnableArm64Sha1 |
1 |
SHA1 |
EnableArm64Sha256 |
1 |
SHA256 |
EnableArm64Rcpc |
1 |
Ordinamento coerente con il rilascio e con il processore (RCpc) |
EnableArm64Rcpc2 |
1 |
RCpc2 |
EnableArm64Cssc |
0 |
Compressione di sequenza breve comune (CSSC) |
EnableArm64Sve |
1 |
Estensione vettoriale scalabile (SVE) |
EnableArm64Sve2 |
1 |
SVE2 |
EnableArm64Sha3 |
1 |
SHA3 |
EnableArm64Sm4 |
1 |
SM4 |
EnableArm64SveAes |
1 |
SVE AES |
EnableArm64SveSha3 |
1 |
SVE SHA3 |
EnableArm64SveSm4 |
1 |
SVE SM4 |
Un'opzione impostata su 0 per impostazione predefinita (ad esempio, EnableAVX10v2 o EnableArm64Cssc) abilita un set di istruzioni che è ancora in fase di introduzione, quindi resta disabilitato finché non si sceglie esplicitamente di attivarlo.
Benchmark per confermare la vittoria
La vettorizzazione aggiunge complessità, quindi verifica che ne valga la pena prima di mantenerla. Usare BenchmarkDotNet e usare le stesse variabili di ambiente mostrate in precedenza per confrontare le implementazioni scalari, Vector128e Vector256 in un'unica esecuzione. Lo strumento di diagnostica della disassembly di BenchmarkDotNet può anche produrre il codice assembly generato, il che è prezioso per la messa a punto di codice ad alte prestazioni.
Alcune cose da tenere a mente:
- Gli input più grandi ne beneficiano di più. Per i buffer di piccole dimensioni, il codice vettorializzato può essere più lento rispetto al codice scalare a causa del sovraccarico di configurazione. Esegui il benchmark delle dimensioni di input che i chiamanti usano effettivamente.
- Gli incrementi di velocità sono raramente perfetti. Un vettore a 256 bit che opera su elementi a 32 bit non sarà necessariamente 8 volte più veloce; il throughput della memoria, l'allineamento e la latenza delle istruzioni influiscono tutti.
- L'allineamento della memoria influisce sulla stabilità. L'allineamento dell'allocazione randomizzata aggiunge rumore tra un'esecuzione e l'altra. È possibile allocare memoria allineata a AlignedAlloc per ottenere risultati stabili o abilitare la randomizzazione della memoria di BenchmarkDotNet per osservare la distribuzione completa.
Procedure consigliate
- Per prima cosa, contattare le API di livello superiore esistenti.
Span<T>,string, LINQ,TensorPrimitivese i tipi tensoriali accelerano già molte operazioni comuni per te: non implementare manualmente ciò che è già ottimizzato e testato. - Inizia con
Vector128<T>; è accelerato sulla più ampia gamma di hardware e non hai bisogno diVector256<T>per ottenere un'implementazione corretta e portabile. Aggiungere larghezze più ampie e intrinseci hardware solo per i percorsi ad accesso frequente misurati. - Controllare
IsHardwareAcceleratedeCountdirettamente invece di memorizzarli nella cache. Il jit li trasforma in costanti. - Gestire sempre il resto del ciclo e tenere conto dei buffer di origine e di destinazione sovrapposti durante l'archiviazione.
- Scrivete prima i test per i casi limite, quindi una soluzione scalare, poi esprimete tale logica scalare con le API vettoriali.
- Testa ogni percorso di esecuzione del codice (incluse le violazioni di accesso) e valuta le prestazioni con dimensioni di input realistiche prima di decidere se accettare la complessità aggiuntiva.