SIMD (単一命令、複数データ) は、1 つの命令と並行して複数のデータに 1 つの操作を適用するためのハードウェア サポートです。 ベクター化されたコードでは、反復ごとに 1 つではなく複数の値が処理されます。これは、同じ操作がバッファー上で繰り返される数値、科学的、グラフィックス、テキスト処理、データ並列処理の種類のスループットを大幅に向上させることができます。 トレードオフは複雑さが増すので、入力が十分に大きく、測定値で勝ちが確認された場合に最もメリットがあります。
.NETには、いくつかの種類の SIMD サポートが用意されています。 必要なコントロールの量と、実行する必要がある複雑さの程度に一致するものを選択します。
.NETでの SIMD サポートの種類
| API | 名前空間 | いつ使用するか |
|---|---|---|
| 固定目的のベクター型とマトリックス型 | System.Numerics | 2 ~ 4 個の要素ベクトル、行列、四元数、平面を使用したグラフィックスとジオメトリの数学。 |
Vector<T> |
System.Numerics | プラットフォームごとの制御が不要な場合の、ポータブルで可変幅のベクター化。 |
Vector64<T>、Vector128<T>、Vector256<T>、Vector512<T> |
System.Runtime.Intrinsics | きめ細かな制御によるクロスプラットフォームの固定幅ベクター化。 これは、新しいベクター化されたアルゴリズムの推奨される開始点です。 |
| ハードウェア組み込み関数 | System.Runtime.Intrinsics.X86、System.Runtime.Intrinsics.Arm、System.Runtime.Intrinsics.Wasm | ホット パスでのパフォーマンスの最後のビットについて、上位レベルの API で公開されない特定のプロセッサ命令。 |
TensorPrimitives |
System.Numerics.Tensors | Span 上ですぐに使える、ベクトル化された数学演算。 自動でベクター化を行います。 |
これらの API が重複する場合は、抽象化のレイヤーを介して関連します。 ジェネリック ベクター型は、他のレイヤーが通過する基本的なインターチェンジ型であるため、技術的には最も低いレベルです。可変幅のVector<T>は、実行中のハードウェアがサポートする任意の幅に拡張され、Vector64<T>を通じて固定幅のVector512<T>になります。
System.Runtime.Intrinsics.X86、System.Runtime.Intrinsics.Arm、およびSystem.Runtime.Intrinsics.Wasmのプラットフォーム固有のハードウェア組み込み関数は、これらの型に対して動作し、それぞれが個々のプロセッサ命令に直接マッピングされます。 ジェネリック型で提供されるクロスプラットフォームの操作は、プラットフォーム固有の組み込み関数より一段上の抽象化レベルにあり、ターゲットごとにそれらへ変換されます。 より高い値は、バッファー全体 ( Span<T> と stringのベクター化されたメソッド、および TensorPrimitives) 全体で動作するマネージド API であり、以下のレイヤー上に構築されるため、手書きを行わずに SIMD アクセラレーションを取得できます。
System.Numerics固定図形型は、このインターチェンジ スタックの一部ではなく、グラフィックスとジオメトリのドメイン固有の便利な型です。
この記事の残りの部分では、これらの API を最高レベルから最低レベルまで処理し、テスト、ベンチマーク、ベスト プラクティスについて説明します。
System.Numerics ベクター型とマトリックス型
System.Numerics名前空間は、固定形状の SIMD アクセラレータ型を提供します。
- Vector2、 Vector3、および Vector4 は、2、3、4 の Single 値のベクトルを表します。
- Matrix3x2 Matrix4x4は、Single値の 3x2 および 4x4 行列を表します。
- Plane は、3 次元空間内の平面を表します。
- Quaternion は、3 次元回転のエンコードに使用されるベクトルを表します。
これらの型はグラフィックスとジオメトリに自然にマップされ、ランタイムは、ハードウェアがサポートする SIMD 命令を使用して操作を高速化します。 次の例では、2 つのベクトルを追加します。
Vector2 v1 = Vector2.Create(0.1f, 0.2f);
Vector2 v2 = Vector2.Create(1.1f, 2.2f);
Vector2 sum = v1 + v2;
また、ドット積、距離、クランプなど、予想される一般的なベクトル演算も公開されます。
float dot = Vector2.Dot(v1, v2);
float distance = Vector2.Distance(v1, v2);
Vector2 clamped = Vector2.Clamp(v1, Vector2.Zero, Vector2.One);
行列型は、転置や乗算などの行列の算術演算をサポートします。
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);
Vector<T>
Vector<T> は、プリミティブ数値型の可変幅ベクトルを表します。 その長さはプロセスの有効期間中に固定されますが、 Vector<T>.Count の値はコードを実行する CPU によって異なります。 Just-In-Time (JIT) コンパイラは Count を定数として扱うので、それに対して書き込まれたループは適切に最適化されます。
Vector<T> では、コンパイル時にベクターの幅が分からなくても、プラットフォームごとのコードなしで移植可能なベクター化が可能になります。 次の例では、2 つの配列の要素ごとの加算を計算します。
// 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;
}
注
この例は例示です。
TensorPrimitivesは既に高速スパンベースの数学を提供しているため、このようなループを手動で記述する必要はほとんどありません。 この要素ごとの追加は Addであり、 Sum などの削減も提供されます。 これらの操作は、 Vector<T> がサポートする要素の種類 (Vector<T>.IsSupported) に対してハードウェアによって高速化されます。
ハードウェア アクセラレーションを確認する
SIMD アクセラレータ型は、非高速ソフトウェア実装にフォールバックするため、SIMD をサポートしていないハードウェアまたは JIT 構成でも機能します。 アクセラレーションが実際に使用可能かどうかを分岐するには、関連する IsHardwareAccelerated プロパティを確認します。
-
Vector.IsHardwareAccelerated は、
Vector<T>操作が高速化されているかどうかを報告します。 -
Vector128.IsHardwareAcceleratedと同等の
Vector64/Vector256/Vector512、固定幅ごとに加速度を報告します。
これらのプロパティは JIT によって定数に変換されるため、実行しないブランチは削除され、チェックの実行時コストは発生しません。 値をキャッシュしないでください。必要な場所で直接読み取る。
Countプロパティ (Vector128<T>.Count など) にも同じことが当てはまります。これは JIT 時定数でもあります。
高速幅に対するほとんどの操作はそれ自体が高速化されますが、すべての操作で保証されるわけではありません。 たとえば、整数除算が行われていない場合は、浮動小数点除算が高速化される場合があります。
Vector256が高速化されると、通常はVector128も行われますが、保証はないため、使用する各幅を確認してください。
ヒント
必要な操作が、関心のあるプラットフォームで高速化されていない場合、または新しいクロスプラットフォーム API が必要な場合は、 dotnet/runtime に問題を提出してください。 同じことが、codegen の機能強化にも当てはまります。
すべての要素型がすべてのベクターに対して有効なわけではありません。
Vector128<T> およびその兄弟は、現在、プリミティブ数値型 (byte、 sbyte、 short、 ushort、 int、 uint、 long、 ulong、 float、 double、 nint、 nuint) をサポートしており、そのセットは将来他の型を含むように拡張される可能性があります。
Vector128<T>.IsSupportedを使用して、特定のTが有効かどうかを判断します。これは、ジェネリック コードで特に役立ちます。
charやboolなど、サポートされていない型は、バッファーを同じサイズのサポートされている型として再解釈することでベクター化できます。
Castを使用してスパン (たとえば、charにushort) を再解釈するか、ベクターのAs<TFrom, TTo>メソッドを使用して、既に保持しているベクターを再解釈します。 再解釈では、基になるビットではなく型のみが変更されるため、データを整形式に保つのはユーザーの責任です。 bool は 0 または 1のままにする必要があり、 char は有効な UTF-16 コード単位のままである必要があります。 ベクター化された操作で範囲外の値が生成される可能性がある場合は、書き戻す前に結果を正規化するように注意してください。
Vector128 を使用したクロスプラットフォーム ベクター化
Vector128<T> はベクター化をサポートするすべてのプラットフォームで共通の分母であるため、始めるのに最適な場所です。 128 ビット ベクトルを保持します。16 バイト、8 ショーツ、4 ints/floats、または 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> は幅が 2 倍あり、Vector512<T> はさらにその 2 倍です。 すべてのハードウェアで大きな幅がサポートされているわけではないため、次の例では移植性のために Vector128 を使用しています。
各幅には、データのジェネリック型 (Vector128<T>) と、Vector128やCreateなどの静的ファクトリ メソッドなど、ほとんどの操作を保持する非ジェネリック静的クラス (Load) があります。
+、&、<<などの演算子は、算術演算とビット演算を表現する慣用的な方法です。演算子の優先順位のバグを回避し、読みやすさを向上させるために、名前付きメソッドと同等の演算子よりも演算子を優先します。 バイトオーダーに依存するアルゴリズムの場合は、jit も定数にフォールドする IsLittleEndianで分岐します。
注
x86/x64 では、 Vector256<T> 操作は通常、2 つの独立した 128 ビット "レーン" として扱われます。 ほとんどの要素ごとの操作では透過的ですが、レーンをまたぐ操作 (シャッフルやペアワイズ/水平操作など) の動作が異なる場合や、同等の Vector128 よりもコストが高い場合があります。 より広いベクトルが高速であると想定する前に、ベンチマークで確認してください。
車線横断操作は無料で拡大しない
要素ごとの操作は幅を気にしません。 v1 + v2 は、 v1 と v2 が Vector128<T> か Vector256<T>かに関係なく、要素ごとの結果と同じであり、命令ごとにもう 1 レーン分のデータを処理するだけです。
Add
v = [a, b, c, d]とw = [e, f, g, h]では常に同じインデックス要素が結合されます。
v: [ a | b | c | d ]
w: [ e | f | g | h ]
+ + + +
r: [a+e|b+f|c+g|d+h]
どの要素が結合されるかはベクターの幅によって異なるため、レーン交差操作では単純にスケールアップされません。 ペアワイズ縮小では、同じインデックスの要素ではなく 隣接する 要素が結合されるため、拡大すると、ペアになった要素が変わります。
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)
まさにこれが水平リダクションです。ベクトルの要素を、2 回のペアワイズ加算で合計します。 x86/x64 では、 Vector128<float> (4 個の要素) を使用して、 HorizontalAddを 2 回呼び出します。
// 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();
}
同じ 2 回呼び出しのパターンを Vector256<float>(8 要素)にも当てはめると、正しく見えますが、実際にはそうではありません。
HorizontalAdd は、256 ビット ベクトル全体で動作しません。各 128 ビット レーン内でペアワイズ パターンを個別に繰り返します。 2 つのラウンドを使用すると、下位レーンの合計 (要素 0 から 3) が下部レーン全体でブロードキャストされ、上位レーンの合計 (要素 4 から 7) が上位レーン全体にブロードキャストされます。8 つの要素の合計ではありません。
// 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();
}
正しい合計を取得するには、レーン境界を明示的にブリッジします。各レーンの部分合計を GetLower/GetUpper で読み取り、それらを加算します。GetLower して、ベクトルを 1 番目と 2 番目の半分に分割 GetUpper します。
------------------------------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();
}
その余分なステップは、車線を横断する実際のコストです。 レーンをまたぐアルゴリズムは、要素単位のアルゴリズムのようにただでそのままベクトル幅を広げられるわけではない。ベクトル幅が広いほうが有利だと決めつける前に、まず計測すべきだ。
一般的な操作
Vector128 とその上位の関連製品は、広範な API を公開しています。 それを覚える必要はありません。カテゴリを知り、必要なときに詳細を調べます。 すべての操作には、高速化できないプラットフォーム用のソフトウェア フォールバックがあります。 次の表は、基本的に表面全体を表しています。
| Category | それが何をするか | 代表的な API |
|---|---|---|
| Constants | 定義済みの定数ベクトル |
Zero、One、NegativeOne、AllBitsSet、Indices、SignSequence、E、Pi、Tau、Epsilon、NaN、PositiveInfinity、NegativeInfinity、NegativeZero |
| 作成 | スカラーのブロードキャスト、要素の設定、またはシーケンスの生成 |
Create、 CreateScalar、 CreateScalarUnsafe、 Create(ReadOnlySpan<T>)、 CreateSequence、 CreateGeometricSequence、 CreateHarmonicSequence、 CreateAlternatingSequence |
| 読み込みと格納 | メモリとベクターの間でデータを移動する |
Load、LoadUnsafe、LoadAligned、LoadAlignedNonTemporal、Store、StoreUnsafe、StoreAligned、StoreAlignedNonTemporal、CopyTo、TryCopyTo |
| 算術 | 要素ごとの数学と削減 |
Add (x + y)、Subtract (x - y)、Multiply (x * y)、Divide (x / y)、Negate (-x)、AddSaturate、SubtractSaturate、Abs、Sqrt、FusedMultiplyAdd、Dot、Sum |
| ビット演算 | ビットごとのロジックとシフト |
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、 Clamp、 MinMagnitude、 MaxMagnitude、 MinNumber、 MaxNumber、 MinMagnitudeNumber、 MaxMagnitudeNumber |
| 丸め | 各要素を整数値に丸める |
Ceiling、Floor、Round、Truncate |
| 算術関数 | 符号、補間、角度、および超越関数のヘルパー |
CopySign、Lerp、DegreesToRadians、RadiansToDegrees、Hypot、Sin、Cos、SinCos、Asin、Exp、Log、Log2 |
| Comparison | 各要素を比較します。結果はベクトル マスクであり、演算子が与える bool ではありません |
Equals、GreaterThan、GreaterThanOrEqual、LessThan、LessThanOrEqual |
| Classification | 数値行に対する要素ごとの述語。それぞれがベクター マスクを返します |
IsNaN、 IsFinite、 IsInfinity、 IsPositiveInfinity、 IsNegativeInfinity、 IsInteger、 IsEvenInteger、 IsOddInteger、 IsNegative、 IsPositive、 IsNormal、 IsSubnormal、 IsZero |
| 比較回数の削減 | 要素ごとの比較を単一の比較に折りたたむ bool |
EqualsAll (x == y)、EqualsAny、GreaterThanAll、GreaterThanAny、GreaterThanOrEqualAll、GreaterThanOrEqualAny、LessThanAll、LessThanAny、LessThanOrEqualAll、LessThanOrEqualAny |
| ベクトル全体に対する述語 | ベクトルを bool に集約します。すべての要素がある値に等しいか、いずれかの要素がその値に等しいか、またはどの要素もその値に等しくないか、あるいは(WhereAllBitsSet 形式では)すべてのビットが設定されているかどうかを判定します。 マスクをインデックスに変換する代わりに、こちらを使用してください |
All、 Any、 None、 AllWhereAllBitsSet、 AnyWhereAllBitsSet、 NoneWhereAllBitsSet |
| Search | 値によって要素をカウントまたは特定する、あるいは(WhereAllBitsSet 形式では)マスク内のレーンを設定する |
Count、 IndexOf、 LastIndexOf、 CountWhereAllBitsSet、 IndexOfWhereAllBitsSet、 LastIndexOfWhereAllBitsSet |
| インデックスにマスクする | 比較マスクをスカラー ビットマスクに変換してスキャンする |
ExtractMostSignificantBits で TrailingZeroCount または LeadingZeroCount |
| [選択] | マスクに従って 2 つのベクトルをビット単位でブレンドする |
ConditionalSelect(x, y, z)に相当します。 (y & x) \| (z & ~x) |
| Conversion | 数値型を変更し、新しい値を計算する (たとえば、 int を float) |
ConvertToInt32、 ConvertToInt64、 ConvertToUInt32、 ConvertToUInt64、 ConvertToSingle、 ConvertToDouble |
| 拡大と縮小 | 要素をより広い型に分割するか、より狭い型にパックする |
Widen、WidenLower、WidenUpper、Narrow、NarrowWithSaturation |
| 再解釈 | ビットを変更せずに別の要素型として再解釈する |
As<TFrom, TTo>、 AsByte、 AsInt32、 AsSingle、およびその他の As* 要素フォーム |
| System.Numerics 相互運用機能 |
Vector128<T>と固定図形の数値型の間で再解釈する |
AsVector、 AsVector2、 AsVector3、 AsVector4、 AsPlane、 AsQuaternion、 AsVector128、 AsVector128Unsafe |
| Reorder | インデックスを使用して要素を再配置するか、2 つのベクトルをインターリーブする |
Shuffle、Reverse、Zip、ZipLower、ZipUpper、Unzip、UnzipEven、UnzipOdd、ConcatLowerLower、ConcatLowerUpper、ConcatUpperLower、ConcatUpperUpper |
| 車線へのアクセス | 個々の要素と半分を読み取るか置換するか、ベクターのサイズを変更します |
GetElement、 WithElement、 ToScalar、 GetLower、 GetUpper、 WithLower、 WithUpper、 ToVector256 |
ヒント
いくつかの操作には、Estimate、Native、MultiplyAddEstimate、ClampNative、MinNative、MaxNativeなど、ShuffleNativeとConvertToInt32Nativeバリアントがあります。 これらは、精度を多少犠牲にしたり、IEEE のエッジケース保証(NaN の扱いなど)を放棄したりする代わりに、より高速なハードウェア命令にマップされます。そのため、ベンチマークによって厳密版がボトルネックであることが示され、かつ緩和された意味論を許容できる場合にのみ使用してください。
注
Vector256.Shuffle は、その入力を単一の 256 ビット ベクトルとして扱います。プラットフォーム固有の Avx2.Shuffle は 2 つの独立した 128 ビット レーンとして動作します。 クロスプラットフォーム API は移植性の高い選択肢ですが、手動で記述された組み込み関数を移植するときに必要な動作を確認します。
コード パスを構成する
ベクター化されたメソッドは通常、ベクター幅ごとのパスに分岐し、さらに入力サイズが小さい場合やアクセラレーションに対応していないハードウェア向けにスカラー処理へのフォールバックも備えます。 ハードウェアでサポートされている最大のベクターを使用するには、最初に最も広いベクターを確認し、次の操作を行います。
// 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);
}
各幅の外側ガードは、 Vector128.IsHardwareAccelerated (プラットフォームがその幅を高速化するかどうかを示す JIT 時間定数) と Vector128<T>.IsSupported (要素の種類 T がその幅に対して有効かどうか) を組み合わせたものです。 サポートされているブロック内で、入力の長さを Count と比較して、ベクター化されたパスと小さな入力フォールバックのどちらかを選択します。 このメソッドは T に対してジェネリックであり、Vector256 ブロックと Vector512 ブロックは、より広い型を使用している点を除けば Vector128 ブロックと同一ですが、簡潔にするためコメントアウトして示されています。
2 つの異なるフォールバックがあります。 最も狭いベクトルであっても小さすぎるバッファーが、高速ハードウェアでは、ループなしで可能なサブベクトルの長さを処理する明示的なSumVectorSmallジャンプ テーブルであるswitchに移動します。
// 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) は JIT 時間定数でもあります。そのため、 SumVectorSmall は、最も広いベクターの要素に対応するサイズのテーブルに要素の幅をディスパッチします。これは、 TensorPrimitives 使用するのと同じアプローチです。 (新しいメモリ セーフ モデルが有効になっている場合、sizeof(T)制約を持つ型パラメーターの式を安全なコードでunmanagedできます)。4 バイトのテーブルのみが表示されます。1 バイト、2 バイト、および 8 バイトのテーブルは、その図形を共有します。 大きいケースでは、先頭から 1 つ、末尾から 1 つという 2 つの重なり合うロードを使って、残りを Vector256 または Vector128 で折りたたむため、Vector512/Vector256 のパスを省いた場合のより幅広い余り処理が、そのままジャンプテーブル内に組み込まれています。 長さが幅の正確な倍数でない場合は常に 2 つの荷重が重なっているので、合計する前に、尾部は ConditionalSelect を使用して加法 ID にマスクされます。 加算は冪等ではないため、そのマスクが必要になります。検索のような冪等な操作であれば、重複している末尾部分を直接畳み込めます。 ベクター化がまったくないハードウェア上のバッファーは、通常のスカラー ループである SumScalarに入ります。
入力をループして剰余を処理する
1 つのベクターより大きいバッファーを処理するには、一度にベクターをループしてから、完全なベクターを埋めない残りの要素を処理します。 その末尾部分を堅牢に処理する方法は、最後の完全なベクター1本分の要素を再処理し、ループがすでに処理した一部と重複させることです。これにより、別個のスカラー・エピローグを回避できます。 その重複を修正する必要があるかどうかは、操作によって異なります。
総和のような べき等でない 演算では、重複する要素が 2 回数えられてしまうため、畳み込む前にそれらをその演算の単位元になるようマスクしておきます。 これは、各要素が 1 回だけ貢献する必要がある場合に使用します。
// 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);
}
このバージョンでは、アンロールしたループを if でガードしているため、ペイロードが小さい場合は 4 つのアキュムレータを完全にスキップし、そのまま剰余処理へ進みます。 十分なデータがある場合、 do/while は反復ごとに 4 つのベクトルを独立したアキュムレータに蓄積します。これにより、プロセッサパイプラインで加算が可能になり、ペアごとに結合されます。 その後、switch ジャンプテーブルで残りの 0~3 個の完全なベクトルを処理し、case 0 ではサブベクターの末尾も処理します。ここでは、バッファー末尾からあらかじめ読み込んでおいた完全なベクトルを再利用し、すでに処理済みの要素と重なる部分を ConditionalSelect で加法単位元になるようマスクすることで、末尾はスカラーループにフォールバックせず、ベクトル化されたままになります。 前と同様に、 Vector128.Create はスパンから Vector128<T>.Count 要素を読み取ります。 JIT は、一般的なアクセス パターンのスパンの境界チェックを省略するため、 Create はホット ループでも正常な既定値です。 LoadUnsafe (次に説明します) は、マネージド参照でバッファーをウォークする場合の下位レベルの代替手段です。 非常に大きな入力に対しては、完全な実装では、バッファのアラインメントも行い、非テンポラルロードとストアを使用して、有用なキャッシュラインがキャッシュから追い出されるのを防ぎます。これらについては、ここではどちらも省略しており、TensorPrimitives で詳しく扱っています。
値の検索のような べき等 な演算では、重なり部分を再処理しても問題がないため、マスクを使わずに最後のベクトルをそのまま取り込みます。
// 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
残りを誤って処理することは、バグの一般的な原因です。 バッファーの末尾を越えて読み取るループは、非決定的な結果を生成し、クラッシュする可能性があります。 ランタイムのテスト スイートでは、バッファーの直後にアクセスなしのページを配置する BoundedMemory ヘルパーが使用されるため、範囲外の読み取りではテスト中に AccessViolationException がスローされます。 長さがベクトル幅の倍数ではないバッファーを含め、剰余ロジックを常にカバーします。
ベクターを安全に読み込んで格納する
ほとんどのコードでは、 Vector128.Create(span) と CopyTo はスパンとベクターの間でデータを移動する最も簡単な方法であり、JIT はそれらを効率的に保ちます。 低レベルの読み込みと格納が必要な場合 (たとえば、マネージド参照でバッファーをウォークする場合)、マネージド参照とLoadUnsafe要素オフセットを受け取るStoreUnsafeオーバーロードとnuintオーバーロードを優先します。 ポインターベースの Load/Store オーバーロードとは異なり、バッファーをピン留めする必要はありません。未加工の参照算術演算とは異なり、 refを手動で進める必要はありません。 どちらの方法も誤りやすく、ガベージコレクターのホールやアクセス違反を招きかねません。
空のバッファーがスローされないように、GetReferenceではなく、GetArrayDataReference (または配列のref span[0]) から開始参照を取得します。
Important
オフセット算術では、符号なし nuintが使用されます。
buffer.Length - Vector128<int>.Countなどのオフセットを計算する前に、バッファーの長さを常に確認してください。 バッファーが 1 つのベクトルより小さい場合、その減算は膨大な値にアンダーフローし、ループは無効なメモリを読み取ります。
プラットフォーム固有のハードウェア組み込み関数
特定のプロセッサ命令によって、移植可能な API では提供されていない優位性を得られる場合は、System.Runtime.Intrinsics.X86、System.Runtime.Intrinsics.Arm、System.Runtime.Intrinsics.Wasm のハードウェア組み込み関数を使用します。 各組み込みクラスには IsSupported プロパティ (JIT 定数も) があるため、特殊化されたパスを保護し、別の場所で移植可能なコードにフォールバックできます。
// 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;
}
}
上記のメソッドは、必要なときにアーキテクチャごとのコード パスを明るくする方法を示していますが、これは意図的に単純な例です。ここでは実際には必要ありません。 移植可能な式 (vector & mask) == Vector128<byte>.Zero は、各プラットフォームでの最適な命令 (たとえば、x86/x64 の ptest ) に既に低下しているため、複雑さなしで、手書きのブランチと同じ作業を行います。 特定の命令がポータブル API によって生成される内容を測定的に上回る場合にのみ、明示的な組み込み関数に到達します。
ハードウェア組み込み関数では、命令セットごとに個別の実装が必要であるため、既定ではなく、測定されたホット パスの最適化として扱います。
Vector128
/
Vector256 これらの API は、各プラットフォームで既に効率的な命令に変換されますし、実際には凝った命令単位のコードが常に有利になるとは限りません。 追加のメンテナンスにコミットする前に、ベンチマークとの違いを確認します。
TensorPrimitives を使用した上位レベルの数学
スパンに対してベクトル化された算術演算が必要で、ループを自分で記述したくない場合、 TensorPrimitives は、既に内部的にベクトル化された、要素ごとの算術演算、指数演算、ドット積や余弦類似度などの減少などの大規模な数値演算セットを提供します。 System.Numerics.Tensors NuGet パッケージで入手できます。
// 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);
AI ワークロードと数値ワークロードの場合、多くの場合、 TensorPrimitives は、複雑さなしに手書き SIMD の利点の大部分を提供します。
すべてのコード パスをテストする
ベクター化されたメソッドには複数のコード パスがあるため、テストでは、Vector256 パス、Vector128 パス、スカラー パスのそれぞれについて、効果が得られるほど十分に大きい入力と、効果が得られないほど小さい入力の両方をカバーする必要があります。 テストでは入力サイズを変更できますが、テスト レベルでハードウェア アクセラレーションを切り替えることはできません。 代わりに、プロセスを開始する前に環境変数で制御します。
-
Vector256.IsHardwareAccelerated が
falseを返すように、DOTNET_EnableAVX2=0を設定します。 - 組み込み関数を完全に無効にするように
DOTNET_EnableHWIntrinsic=0を設定すると、すべてのVector128、Vector64、およびVector<T>が高速化を報告しません。
1 台のコンピューターですべてのパスを実行するには、オーバーライドなしでテスト スイートを 1 回、 DOTNET_EnableAVX2=0で 1 回、 DOTNET_EnableHWIntrinsic=0で 1 回実行します。 代替手段は、それらをカバーするのに十分な多様なハードウェアで実行されています。
命令セット構成ノブ
これら 2 つを超えて、ランタイムは命令セットの論理グループごとにノブを認識し、各プレフィックスに DOTNET_ が付きます。 1 つのノブは、いくつかの関連する命令セット (EnableAVX2 たとえば、ゲート AVX2 と BMI1、BMI2、F16C、FMA、LZCNT、MOVBE など) をカバーできます。 ノブを 0 に設定すると、そのグループ全体と、その上に重ねられているすべてのものが無効になります。
1(ほとんどの場合の既定値)に設定するとそのグループを有効にできますが、ハードウェア側が実際にそれをサポートしている必要があります。現在の CPU が備えていない設定項目を有効にしても無視されるため、使用対象を絞り込むことしかできず、未対応の命令を無理に有効にして自分で壊してしまうことはありません。
DOTNET_EnableHWIntrinsic=0 は強硬策です。基盤レベルまですべてを無効にするため、Vector128、Vector64、Vector<T> はいずれもアクセラレーションなしと報告し、コードはソフトウェアパスにフォールバックします。
Important
これらは、主にテストと検証を目的とした診断ツールです。各コード パスの実行、ハードウェア固有の問題の再現、フォールバックの確認を行います。 これらは一般的または運用環境用に設計されておらず、安定性コントラクトではありません。 次のセットは、.NET 11 が認識します。以前のリリースでは、ベースラインと AVX-512 ノブが特に再構成された別のセットが公開されているため、ターゲットとするランタイム バージョンに対して名前を確認します。
これらのツールには、到達する範囲にも制限があります。 JIT の決定をゲートするため、ReadyToRun またはネイティブ AOT を通じて事前にコンパイルされたコードには影響しません。また、ランタイムとコア ライブラリが自身で使用する内部ルーチンには必ずしも影響しません。 命令セットのグローバルオフスイッチではなく、独自の JIT コンパイルコードを誘導する方法として扱います。
基本スイッチと幅キャップは、すべてのアーキテクチャに適用されます。
ノブ (プレフィックスDOTNET_ ) |
Default | Effect |
|---|---|---|
EnableHWIntrinsic |
1 |
すべてのハードウェア組み込み関数のマスター スイッチ。 0 は完全なソフトウェア パスを強制します。 |
MaxVectorTBitWidth |
システムの既定値 |
Vector<T> の最大幅をビット単位で制限します。128 未満の値を指定すると、システム既定値になります。 |
PreferredVectorBitWidth |
システムの既定値 |
IsHardwareAcceleratedを報告する最大固定幅ベクトルをビット単位で大文字にします。128 未満の値は、システムの既定値を意味します。 |
MaxVectorTBitWidthのシステムの既定値は、ハードウェアが完全にサポートするよりも狭くなる可能性があるため、Vector<T>は使用可能な最も広いベクターに自動的に拡張されません。 たとえば、Vector512<T>.IsHardwareAccelerated は true にできる一方で、Vector<T> は 256 ビットのままです。Vector<T> をより広い幅にするには、DOTNET_MaxVectorTBitWidth=512 を設定します。
PreferredVectorBitWidth は、IsHardwareAccelerated を報告する最大ベクター幅の上限を設定します。 ハードウェアがサポートする値より低く設定すると、より広い幅は無効になります。512 ビット ベクトルをサポートするマシンでは、DOTNET_PreferredVectorBitWidth=256 にすると Vector512<T>.IsHardwareAccelerated は false を返します。 それは一般的なノブですが、x86/x64だけが今日128以上の幅を提供するので、それが観測可能な効果を持っている唯一の場所です。
x86/x64 命令セットの各論理グループには、独自のスイッチもあります。
ノブ (プレフィックスDOTNET_ ) |
Default | Gates |
|---|---|---|
EnableAVX |
1 |
AVX と依存関係 |
EnableAVX2 |
1 |
AVX2、BMI1、BMI2、F16C、FMA、LZCNT、MOVBE、ならびにそれらに依存するもの |
EnableAVX512 |
1 |
AVX-512 F+BW+CD+DQ+VL および依存関係 |
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 (拡張汎用レジスタ) |
EnableAES |
1 |
AES、PCLMULQDQ |
EnableAVX512VP2INTERSECT |
1 |
AVX-512 VP2INTERSECT |
EnableAVXIFMA |
1 |
AVX-IFMA |
EnableAVXVNNI |
1 |
AVX-VNNI |
EnableAVXVNNIINT |
1 |
VEX AVX-VNNI-INT8 および AVX-VNNI-INT16 |
EnableGFNI |
1 |
GFNI |
EnableSHA |
1 |
SHA |
EnableVAES |
1 |
VAES、VPCLMULQDQ |
EnableWAITPKG |
1 |
WAITPKG |
EnableX86Serialize |
1 |
X86 SERIALIZE |
Arm64 では、命令セットの論理グループごとに独自のスイッチがあります。
ノブ (プレフィックスDOTNET_ ) |
Default | Gates |
|---|---|---|
EnableArm64Aes |
1 |
AES |
EnableArm64Atomics |
1 |
LSE (Large System Extensions) アトミック |
EnableArm64Crc32 |
1 |
CRC32 |
EnableArm64Dczva |
1 |
DC ZVA キャッシュのゼロ化 |
EnableArm64Dp |
1 |
ドット積 |
EnableArm64Rdm |
1 |
四捨五入乗算累積 (RDM) |
EnableArm64Sha1 |
1 |
SHA1 |
EnableArm64Sha256 |
1 |
SHA256 |
EnableArm64Rcpc |
1 |
リリース整合性、プロセッサ整合性順序付け (RCpc) |
EnableArm64Rcpc2 |
1 |
RCpc2 |
EnableArm64Cssc |
0 |
共通短シーケンス圧縮 (CSSC) |
EnableArm64Sve |
1 |
スケーラブル ベクター拡張機能 (SVE) |
EnableArm64Sve2 |
1 |
SVE2 |
EnableArm64Sha3 |
1 |
SHA3 |
EnableArm64Sm4 |
1 |
SM4 |
EnableArm64SveAes |
1 |
SVE AES |
EnableArm64SveSha3 |
1 |
SVE SHA3 |
EnableArm64SveSm4 |
1 |
SVE SM4 |
既定で 0 するノブ ( EnableAVX10v2 や EnableArm64Csscなど) は、まだオンラインになっている命令セットをゲートするため、オプトインするまでオフのままです。
勝利を確認するためのベンチマーク
ベクトル化は複雑さを増すため、そのまま採用し続ける前に、それに見合う効果があるかどうかを確認してください。
BenchmarkDotNet を使用し、前に示したのと同じ環境変数を使用して、スカラー、Vector128、およびVector256の実装を 1 回の実行で比較します。 BenchmarkDotNet の逆アセンブリ診断ツールは、生成されたアセンブリを出力することもできます。これは、高パフォーマンス コードをチューニングする際に非常に重要です。
留意すべきいくつかの点は次のとおりです。
- 入力が大きいほど、メリットが増えます。 小さなバッファーの場合、ベクター化されたコードは、セットアップのオーバーヘッドのためにスカラー コードよりも遅くなる可能性があります。 呼び出し元が実際に使用する入力サイズをベンチマークします。
- 高速化が完全であることはまれです。 32 ビット要素で動作する 256 ビット ベクターは、確実に 8 倍高速になりません。メモリ のスループット、アラインメント、命令の待ち時間を考慮します。
- メモリの配置は安定性に影響します。 ランダム割り当てアラインメントにより、実行間にノイズが追加されます。 安定した結果を得るために、アライメントされたメモリを AlignedAlloc に割り当てたり、BenchmarkDotNet のメモリランダム化を有効にして完全な分散を観察したりできます。
ベスト プラクティス
- まず、既存の上位レベルの API にアクセスします。
Span<T>、string、LINQ、TensorPrimitives、およびテンソル型は、一般的な多くの操作をすでに高速化してくれます。既に最適化・テスト済みのものを、わざわざ自前で実装しないでください。 -
Vector128<T>から始めます。最も広範なハードウェア セットで高速化されるため、適切で移植可能な実装を得るためにVector256<T>必要はありません。 計測で特定されたホットパスに対してのみ、より広いデータ幅とハードウェアイントリンシックを追加する。 -
IsHardwareAcceleratedとCountはキャッシュせずに直接確認してください。JIT がそれらを定数にします。 - 常にループ剰余を処理し、格納時に重複するソース バッファーと宛先バッファーを考慮します。
- 最初にエッジ ケース テストを記述し、次にスカラー ソリューションを記述し、そのスカラー ロジックをベクター API で表します。
- 追加された複雑さにコミットする前に、すべてのコード パス (アクセス違反を含む) をテストし、現実的な入力サイズをベンチマークします。
こちらも参照ください
.NET