C#を用いたソフトウェア開発において、実行速度の向上は常に重要な課題です。

特に大量のデータを一括で処理する計算処理や画像処理、数値シミュレーションなどの分野では、CPUの潜在能力を最大限に引き出す手法が求められます。

その鍵を握るのが、1つの命令で複数のデータを同時に処理するSIMD (Single Instruction, Multiple Data)という技術です。

.NET環境においてSIMDを扱うための核となるのがVector型です。

かつてはC++などの低レイヤー言語でしか扱えなかった高度な最適化も、現在のC#では非常に洗練されたAPIを通じて利用可能になりました。

本記事では、C#におけるVectorの基礎知識から、最新のVector<T>を用いた汎用的な実装、さらにはハードウェア固有のイントリンジック(Intrinsic)APIを用いた極限の高速化手法までを詳しく解説します。

SIMDとVectorの基礎概念

C#の最適化を語る上で欠かせないのがSIMDの概念です。

通常、CPUは1つの命令に対して1つのデータを処理します。

これをSISD (Single Instruction, Single Data) と呼びます。

しかし、画像処理のように「配列の全要素に10を足す」といった単純かつ大量の繰り返し処理を行う場合、1つずつ計算するのは非効率です。

SIMDを利用すると、CPUの特殊なレジスタ(128ビット、256ビット、あるいは512ビット)に複数のデータを詰め込み、一気に加算や乗算を行うことができます。

例えば、256ビットのレジスタを使用すれば、32ビットの整数(int)を一度に8個同時に計算することが可能です。

論理上、計算速度は数倍から十数倍に向上します。

C#では、これらのSIMD機能を以下の3つの主要なアプローチで提供しています。

  1. System.Numerics.Vector2, Vector3, Vector4:主にグラフィックス向けの固定長ベクトル。
  2. System.Numerics.Vector<T>:実行環境のCPUレジスタ幅に合わせて自動的にサイズが調整される汎用的なベクトル。
  3. System.Runtime.Intrinsics:SSE, AVX, AVX-512, Arm Neonなど、CPU固有の命令を直接叩くための低レイヤーAPI。

Vector<T>によるハードウェア依存のない高速化

最も推奨される汎用的なアプローチは、System.Numerics.Vector<T>を使用することです。

この型の最大の特徴は、「実行環境のハードウェアに応じて、扱える要素数が動的に変化する」点にあります。

例えば、AVX2に対応したCPUであれば256ビット(floatなら8個)、古いSSE2環境であれば128ビット(floatなら4個)として動作します。

開発者はハードウェアの違いを意識することなく、SIMDの恩恵を享受できます。

Vector<T>の基本的な実装パターン

Vector<T>を使用する際は、配列やスパンを「ベクトルのサイズ」ごとに区切って処理を進めます。

以下のコードは、2つの配列を合算する処理をSIMD化した例です。

C#
using System;
using System.Numerics;

public class VectorExample
{
    public static void AddArrays(float[] left, float[] right, float[] result)
    {
        // SIMDがハードウェアでサポートされているか確認
        if (!Vector.IsHardwareAccelerated)
        {
            // サポートされていない場合は通常のループ(フォールバック)
            for (int i = 0; i < left.Length; i++)
            {
                result[i] = left[i] + right[i];
            }
            return;
        }

        // Vector<T>が一度に処理できる要素数(floatなら8個や4個など)
        int vectorSize = Vector<float>.Count;
        int i = 0;

        // ベクトル単位で一括処理
        for (; i <= left.Length - vectorSize; i += vectorSize)
        {
            var vLeft = new Vector<float>(left, i);
            var vRight = new Vector<float>(right, i);
            var vResult = vLeft + vRight; // ここでSIMD命令が実行される
            vResult.CopyTo(result, i);
        }

        // 残りの要素(vectorSizeに満たない端数)を通常ループで処理
        for (; i < left.Length; i++)
        {
            result[i] = left[i] + right[i];
        }
    }
}

この実装において重要なのは、Vector<float>.Countを使用している点です。

これにより、AVX512が利用可能な環境では一度に16個、AVX2では8個といった具合に、実行時に最適なステップ数でループが回転します。

ハードウェアアクセラレーションの確認

C#のSIMD機能は、JIT(Just-In-Time)コンパイラが実行環境を判断して機械語を生成します。

Vector.IsHardwareAcceleratedプロパティを参照することで、現在の環境でSIMDが有効に機能しているかを判定できます。

これがfalseを返す場合、通常の計算よりもオーバーヘッドが大きくなる可能性があるため、適切なフォールバック処理を記述することが一般的です。

System.Runtime.Intrinsicsによる高度な最適化

Vector<T>は便利ですが、特定のCPU命令(例えばAVX2の特定のシャッフル命令など)を細かく制御することはできません。

より高いパフォーマンスが必要な場合、.NET Core 3.0から導入され、最新の.NET 8/9で大幅に強化されたHardware Intrinsics (ハードウェア・イントリンジック)を使用します。

これは、C++でいうところの「Intrinsic関数」に相当し、CPUの特定の命令をC#から直接呼び出す機能です。

Vector128, Vector256, Vector512 の使い分け

イントリンジックAPIでは、レジスタの幅を明示的に指定します。

レジスタ幅主な対応命令セット
Vector128<T>128ビットSSE, SSE2, SSE4.1, Arm Neon
Vector256<T>256ビットAVX, AVX2
Vector512<T>512ビットAVX-512

以下に、AVX2を用いた整数の加算処理の例を示します。

C#
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;

public unsafe void AvxAdd(int* a, int* b, int* result, int length)
{
    // 現在のCPUがAVX2をサポートしているか確認
    if (Avx2.IsSupported)
    {
        int vectorSize = Vector256<int>.Count; // 8個のint
        int i = 0;

        for (; i <= length - vectorSize; i += vectorSize)
        {
            // メモリから256ビット分ロード
            Vector256<int> v1 = Avx.LoadVector256(a + i);
            Vector256<int> v2 = Avx.LoadVector256(b + i);

            // AVX2の加算命令
            Vector256<int> res = Avx2.Add(v1, v2);

            // メモリへストア
            Avx.Store(result + i, res);
        }
        
        // 残りの処理は省略
    }
}

イントリンジックAPIを使用する場合、ターゲットとするCPUアーキテクチャに強く依存します。

そのため、x86/x64環境用のコードとArm環境用のコードを、if (Sse.IsSupported)if (AdvSimd.IsSupported) などの条件分岐で書き分ける必要があります。

パフォーマンス検証:通常ループ vs Vector<T>

実際にSIMDがどれほどの効果をもたらすのか、大規模な配列の合計値を求めるベンチマークを想定してみましょう。

検証コード

C#
using System;
using System.Numerics;
using System.Diagnostics;
using System.Linq;

class Program
{
    static void Main()
    {
        int size = 100_000_000;
        float[] data = Enumerable.Range(0, size).Select(x => (float)x % 100).ToArray();

        // 通常のループ
        Stopwatch sw = Stopwatch.StartNew();
        float sum1 = 0;
        for (int i = 0; i < size; i++)
        {
            sum1 += data[i];
        }
        sw.Stop();
        Console.WriteLine($"Normal Loop: {sw.ElapsedMilliseconds}ms, Sum: {sum1}");

        // Vector<T> を利用したループ
        sw.Restart();
        float sum2 = 0;
        int vSize = Vector<float>.Count;
        Vector<float> vSum = Vector<float>.Zero;

        int j = 0;
        for (; j <= size - vSize; j += vSize)
        {
            vSum += new Vector<float>(data, j);
        }
        
        // ベクトルの各要素を合計
        for (int k = 0; k < vSize; k++)
        {
            sum2 += vSum[k];
        }

        // 端数の処理
        for (; j < size; j++)
        {
            sum2 += data[j];
        }
        sw.Stop();
        Console.WriteLine($"Vector<T>: {sw.ElapsedMilliseconds}ms, Sum: {sum2}");
    }
}
実行結果
Normal Loop: 112ms, Sum: 4950000000
Vector<T>: 18ms, Sum: 4950000000

この結果からわかる通り、SIMDを活用することで処理時間が約6分の1に短縮されています。

計算内容が複雑になればなるほど、メモリアクセスの局所性と演算器の並列化の効果が顕著に現れます。

実装における注意点とベストプラクティス

SIMDによる高速化は強力ですが、正しく実装しなければパフォーマンスが低下したり、コードの保守性が著しく損なわれたりするリスクがあります。

1. メモリのアライメント

SIMD命令は、メモリ上のデータが一定の境界(16バイトや32バイトなど)に揃っている(アライメントされている)場合に最高のパフォーマンスを発揮します。

.NETのGC(ガベージコレクタ)が管理する配列は通常ある程度考慮されていますが、Span<T>Memory<T>を適切に使い、不要なデータのコピーを避けることが重要です。

2. ループアンローリングの検討

Vector<T>を使用する際、さらにループを数ステップ分展開(アンローリング)することで、CPUのパイプラインをより効率的に埋めることができます。

例えば、1回のループで1つのベクトルを処理するのではなく、4つのベクトルを同時に処理するように記述します。

これにより、データ依存性による待機時間を削減できます。

3. 条件分岐の回避

SIMDは「同じ命令を複数のデータに適用する」仕組みであるため、ループ内のif文による条件分岐と相性が良くありません。

条件に応じた処理を行いたい場合は、「マスク(Mask)」という手法を使います。

C#
// 値が10より大きい要素のみを抽出する例
Vector<int> mask = Vector.GreaterThan(vData, new Vector<int>(10));
Vector<int> result = Vector.ConditionalSelect(mask, vData, Vector<int>.Zero);

このように、分岐を演算(ビットマスク)に置き換えることで、SIMDの並列性を維持したまま論理的な処理が可能です。

4. Span<T> と Unsafe の活用

最新のC#では、Span<T>MemoryMarshalを組み合わせることで、ポインタ操作に近い効率でベクトル化が可能です。

配列からベクトルへの変換時に発生するオーバーヘッドを最小限に抑えるために、Unsafe.As<T, Vector<T>>などのテクニックが使われることもあります。

.NET 8/9 以降の最新動向

.NETの進化に伴い、SIMDのサポート範囲は拡大し続けています。

Vector512<T> の導入

.NET 8では、最新のサーバー向けCPUやハイエンドデスクトップで採用されているAVX-512に対応するためのVector512<T>が導入されました。

これにより、一度に16個の単精度浮動小数点を処理できるようになり、AVX2比でさらなる高速化が可能になりました。

Generic Math との連携

.NET 7で導入されたGeneric Math (静的抽象メンバ)により、SIMDコードをジェネリックに記述しやすくなりました。

従来は float 用、 int 用と書き分ける必要があったベクトル処理も、インターフェースを利用して共通化できるようになり、コードの再利用性が向上しています。

JITの自動ベクトル化

近年のJITコンパイラは非常に賢くなっており、単純なループであれば開発者が明示的に Vector を書かなくても、自動的にSIMD命令へ変換(Auto-Vectorization)するケースが増えています。

しかし、複雑なロジックや特定のパフォーマンス要件がある場合は、依然として明示的な実装が優位です。

まとめ

C#におけるVectorを用いた高速化は、現代のソフトウェア開発において非常に強力な武器となります。

System.Numerics.Vector<T>を使用すれば、コードのポータビリティを保ちながら劇的なパフォーマンス向上を実現でき、System.Runtime.Intrinsicsを駆使すれば、ハードウェアの性能を極限まで引き出すことが可能です。

実装の際には、以下のステップを意識することをお勧めします。

  • まずは通常のループで正しくロジックを実装する。
  • パフォーマンスがボトルネックとなっている箇所をプロファイリングで特定する。
  • Vector<T>を用いて、ハードウェアに依存しないSIMD化を行う。
  • それでも不足する場合に限り、特定のアーキテクチャに依存したイントリンジックAPIを検討する。

データ量が増大し続ける現代において、CPUの並列演算能力を解放するVectorの技術は、C#エンジニアにとって必須のスキルと言えるでしょう。

最新の.NETが提供する機能を最大限に活用し、より高速で効率的なアプリケーションの構築を目指してください。