Open6

Vector<T> と Unity Burst の対応表

サトー™ @sator_imagingサトー™ @sator_imaging

文責 @ Copilot Think Deeper

編注:64 ビット幅じゃなく 128 ビット幅(SSE2)を使う選択肢しかないっぽい?


いい質問ですね!SIMD命令セットにはさまざまな種類があり、すべてのSIMD命令が整数演算をサポートしているわけではありません。

例えば、SSE(Streaming SIMD Extensions)の初期バージョンは主に浮動小数点演算向けでしたが、SSE2以降では整数演算のサポートが強化されました。一方で、AVX(Advanced Vector Extensions)の初期バージョンは256ビットの浮動小数点演算に特化しており、整数演算のSIMD拡張が追加されたのはAVX2からです。

また、ARMのNEONやRISC-VのVector Extensionなど、異なるアーキテクチャのSIMD命令セットもそれぞれ異なる整数演算のサポートを持っています。特定の命令セットによっては、整数演算よりも浮動小数点演算に最適化されているものもあります。

詳しくはこちらで確認できます。どのアーキテクチャや用途について知りたいですか?例えば、特定のプログラムでの最適化についても話せますよ!

サトー™ @sator_imagingサトー™ @sator_imaging

Vector64.Shuffle<byte>

以下は、Unity Burst の SSE/SSSE3 インストリンシクスを用いて、Vector<byte>.shuffle と同等の機能(バイト単位の並び替え)を実現するコード例です。 このコードは、入力として 128 ビット(16 バイト)のベクトルとマスクを受け取り、マスクの各バイトの下位4ビットにより再配置するバイト位置を指定し、もしそのバイトの上位ビット(0x80)がセットされていれば該当出力を 0 にするという仕様を再現しています。 SSSE3 命令が利用可能な場合は Ssse3.shuffle_epi8 を呼び出し、サポートされていない場合はソフトウェア版のフォールバックを実装しています。

using Unity.Burst;
using Unity.Burst.Intrinsics;
using static Unity.Burst.Intrinsics.X86.Ssse3;
using System.Runtime.CompilerServices;

[BurstCompile]
public static unsafe class ShuffleUtil
{
    /// <summary>
    /// 入力ベクトル <paramref name="vector"/> をマスク <paramref name="mask"/> に従ってバイト単位でシャッフルします。
    /// マスクの各バイトの下位4ビット (0~15) は参照する元ベクトルのバイトインデックスを示し、
    /// 上位ビット (0x80) がセットされている場合、出力は 0 とします。
    /// SSSE3 がサポートされている環境ではハードウェア命令 (Ssse3.shuffle_epi8) を、
    /// それ以外の場合はフォールバック処理としてソフトウェア実装を用います。
    /// </summary>
    /// <param name="vector">シャッフルの元となる 128 ビット(16 バイト)のベクトル</param>
    /// <param name="mask">シャッフル・マスク(各バイトで再配置インデックスとゼロ出力制御を指定)</param>
    /// <returns>シャッフル結果の 128 ビットベクトル</returns>
    [BurstCompile]
    public static unsafe v128 ShuffleBytes(v128 vector, v128 mask)
    {
        // SSSE3 が使用可能な場合、ハードウェア命令で実行
        if (Ssse3.IsSsse3Supported)
        {
            return Ssse3.shuffle_epi8(vector, mask);
        }
        else
        {
            // フォールバック実装(ソフトウェアで 16 バイト分ループ処理)
            v128 result = default;
            byte* pVector = (byte*)&vector;
            byte* pMask = (byte*)&mask;
            byte* pResult = (byte*)&result;
            for (int i = 0; i < 16; i++)
            {
                byte control = pMask[i];
                // 上位ビット (0x80) がセットされていれば、結果は 0 を格納
                if ((control & 0x80) != 0)
                {
                    pResult[i] = 0;
                }
                else
                {
                    // 下位4ビットで 0~15 のインデックスを決定し、該当する元ベクトルのバイトをコピー
                    int index = control & 0x0F;
                    pResult[i] = pVector[index];
                }
            }
            return result;
        }
    }
}
サトー™ @sator_imagingサトー™ @sator_imaging

Vector64.ShiftRightLogical<byte>

以下は、16バイト(128ビット)ベクトル内の各バイトに対して、論理右シフト(zero-fill right shift)を実施するサンプルコードです。 Unity Burst の SSE2 インストリンシクスを使用して、まず 8ビット要素を16ビットにゼロ拡張し、16ビット単位でシフトした後、再び8ビットにパックする実装例になります。SSSE3 等の拡張ではなく、SSE2 レベルの命令のみを使用しているため、幅広い環境で動作します。 ※ SSE2 がサポートされない環境では、ソフトウェア実装によるフォールバックを行っています。

using Unity.Burst;
using Unity.Burst.Intrinsics;
using static Unity.Burst.Intrinsics.X86.Sse2;
using System.Runtime.CompilerServices;

[BurstCompile]
public static unsafe class ShiftUtil
{
    /// <summary>
    /// 128ビット(16バイト)のベクトル内の各バイトに対して、指定されたシフト分だけ論理的(ゼロ拡張)に右シフトを行います。
    /// SSE2が使用可能な場合は、下記の手順で処理します。
    ///   1. Sse2.UnpackLow / UnpackHigh により、8ビット要素をそれぞれ16ビット(ushort)にゼロ拡張。
    ///   2. Sse2.ShiftRightLogical で16ビット毎にシフト。
    ///   3. Sse2.Packus_epi16 で16ビット要素を8ビットにパック。
    /// シフト量が8以上の場合はすべて0となります。
    /// </summary>
    /// <param name="vector">処理対象となる128ビットベクトル(v128型)</param>
    /// <param name="shiftAmount">右シフトするビット数</param>
    /// <returns>各バイトが右シフトされた結果の128ビットベクトル</returns>
    [BurstCompile]
    public static v128 ShiftRightLogical(v128 vector, int shiftAmount)
    {
        if (Sse2.IsSse2Supported)
        {
            // 0拡張用のゼロベクトルを用意(16バイトすべて0)
            v128 zero = SetZeroVector128();

            // 8バイトずつ取り出し、16ビットにゼロ拡張する
            v128 lowWords = UnpackLow(vector, zero);   // 1〜8バイト目が 16ビットに拡張
            v128 highWords = UnpackHigh(vector, zero); // 9〜16バイト目が 16ビットに拡張

            // 16ビット単位で論理右シフトを実施
            lowWords = ShiftRightLogical(lowWords, shiftAmount);
            highWords = ShiftRightLogical(highWords, shiftAmount);

            // Packus_epi16 でシフト結果を再び8ビットにパック(各要素は0~255の範囲になる)
            v128 result = Packus_epi16(lowWords, highWords);
            return result;
        }
        else
        {
            // SSE2が使用できない場合、ソフトウェアで1バイトずつ処理します
            v128 result = default;
            byte* pVector = (byte*)&vector;
            byte* pResult = (byte*)&result;
            for (int i = 0; i < 16; i++)
            {
                pResult[i] = (byte)(pVector[i] >> shiftAmount);
            }
            return result;
        }
    }
}
サトー™ @sator_imagingサトー™ @sator_imaging

Vector64.ShiftRightArithmetic<byte>

以下は、16バイト(128ビット)に格納された各要素が 符号付き 8 ビット整数 (sbyte) として解釈されるベクトルに対して、算術的右シフト(Arithmetic Shift Right)を行う例です。 .NET の Vector<SByte>.ShiftRightArithmetic と同等の動作をするように、まず各要素を 16 ビットに符号拡張し、SSE2 の命令 srai_epi16 で各 16 ビット要素を算術右シフト、最後に pack して 8 ビットに戻す実装例になります。 ※ このコードは Unity Burst 内で SSE2 が有効な環境を前提としていますが、フォールバックとしてループによる実装も用意しています。

using Unity.Burst;
using Unity.Burst.Intrinsics;
using static Unity.Burst.Intrinsics.X86.Sse2;
using System.Runtime.CompilerServices;

[BurstCompile]
public static unsafe class ShiftArithmeticUtil
{
    /// <summary>
    /// 128ビット(16バイト)ベクトル内の各要素(sbyte)が与えられたシフト量分だけ算術右シフトされた結果を返します。
    /// すなわち、各要素が符号拡張され、負の場合はシフトで上位に 1 が埋め込まれます。
    /// 
    /// ※ 注意: この実装は、元の型を byte ではなく sbyte として解釈して算術シフトを行います。
    /// </summary>
    /// <param name="vector">
    /// シフト対象となる 128ビットベクトル(各要素は 8 ビット、符号付きと解釈)。
    /// </param>
    /// <param name="shiftAmount">
    /// シフトするビット数(0~7 の範囲)。
    /// </param>
    /// <returns>
    /// 各要素が算術右シフトされた結果の 128ビットベクトル。
    /// </returns>
    [BurstCompile]
    public static v128 ShiftRightArithmetic(v128 vector, int shiftAmount)
    {
        if (Sse2.IsSse2Supported)
        {
            // 0ベクトルを用意
            v128 zero = SetZeroVector128();
            // 各8ビット要素について、0と比較してマスクを生成
            // → 要素が負の場合は 0xFF、非負の場合は 0x00 となる
            v128 mask = cmpgt_epi8(zero, vector);

            // SSE2では8ビットの算術シフト命令が存在しないため、
            // まず各8ビット要素を16ビットに符号拡張します。
            // 下位8バイトを16ビットに拡張
            v128 low = UnpackLow(vector, mask);
            // 上位8バイトを16ビットに拡張
            v128 high = UnpackHigh(vector, mask);

            // 16ビット単位の算術右シフトを実施
            low = srai_epi16(low, (byte)shiftAmount);
            high = srai_epi16(high, (byte)shiftAmount);

            // 16ビットになっている各要素を pack して、再び8ビットごとの sbyte に戻す
            v128 result = packss_epi16(low, high);
            return result;
        }
        else
        {
            // SSE2が利用できない場合、フォールバックとして1バイトずつ算術シフトを実施
            v128 result = default;
            byte* pSrc = (byte*)&vector;
            byte* pDst = (byte*)&result;
            for (int i = 0; i < 16; i++)
            {
                // sbyte として読み出し、算術右シフト(<< は符号拡張付き)
                sbyte val = (sbyte)pSrc[i];
                sbyte shifted = (sbyte)(val >> shiftAmount);
                pDst[i] = (byte)shifted;
            }
            return result;
        }
    }
}
サトー™ @sator_imagingサトー™ @sator_imaging

Vector64.ConditionalSelect<byte>

以下は、64ビット(8バイト)分の条件選択を行う実装例です。 このコードは、各バイトごとにマスクの該当バイトが 0xFF (=条件成立)ならば trueCase のバイトを、そうでなければ falseCase のバイトを選択する、つまり

result = (mask & trueCase) | ((~mask) & falseCase)

という処理を実現しています。 なお、Unity Burst の SIMD インストリンシクスでは基本的に 128 ビット(v128)が用いられるため、ここでは v128 型を使いつつ、下位 64 ビットのみ有効な形として実装しています。 SSE2 が利用可能な環境では、Sse2.And/Sse2.AndNot/Sse2.Or を使用し、利用不可の場合はフォールバックとしてポインタによるループ処理を行います。

using Unity.Burst;
using Unity.Burst.Intrinsics;
using static Unity.Burst.Intrinsics.X86.Sse2;
using System.Runtime.CompilerServices;

[BurstCompile]
public static unsafe class ConditionalSelectUtil
{
    /// <summary>
    /// 64ビット(8バイト)単位の条件選択を行います。
    /// 
    /// 各バイトに対して、mask の該当バイトが 0xFF なら trueCase の対応バイトを、
    /// そうでなければ falseCase の対応バイトを選択します。
    /// この実装では、v128 型を用い、下位 64 ビットのみを有効なデータとしています。
    /// </summary>
    /// <param name="mask">
    /// 条件マスク。各バイトが 0xFF のとき、そのバイトは条件成立(true)とみなされます。
    /// </param>
    /// <param name="trueCase">
    /// 条件成立の場合に選択されるベクトル(各バイト)。
    /// </param>
    /// <param name="falseCase">
    /// 条件不成立の場合に選択されるベクトル(各バイト)。
    /// </param>
    /// <returns>
    /// 条件選択結果の 64 ビット(下位8バイト)を有する v128 型のベクトル。
    /// </returns>
    [BurstCompile]
    public static v128 ConditionalSelect64(v128 mask, v128 trueCase, v128 falseCase)
    {
        if (Sse2.IsSse2Supported)
        {
            // 条件選択演算: (mask & trueCase) | ((~mask) & falseCase)
            v128 selectedTrue = And(mask, trueCase);
            v128 selectedFalse = AndNot(mask, falseCase); // AndNot: (~mask) & falseCase
            v128 combined = Or(selectedTrue, selectedFalse);

            // v128 は128ビットですが、ここでは下位64ビットのみが意味を持つとしたいので、
            // 下位64ビットが1(つまり有効)、上位64ビットをクリアするマスクを用いて結果を整形します。
            v128 lowerMask = SetLow64(ulong.MaxValue);
            return And(combined, lowerMask);
        }
        else
        {
            // SSE2非対応の場合は、フォールバック実装:8バイトずつ処理します
            v128 result = default;
            byte* pMask   = (byte*)&mask;
            byte* pTrue   = (byte*)&trueCase;
            byte* pFalse  = (byte*)&falseCase;
            byte* pResult = (byte*)&result;
            for (int i = 0; i < 8; i++)   // 下位8バイト分だけ処理
            {
                pResult[i] = (pMask[i] == 0xFF) ? pTrue[i] : pFalse[i];
            }
            // 上位8バイトはゼロクリア
            for (int i = 8; i < 16; i++)
            {
                pResult[i] = 0;
            }
            return result;
        }
    }

    /// <summary>
    /// 下位64ビットに指定の ulong 値がセットされた v128 型を生成します。
    /// SSE2 が利用可能な場合は cvtsi64x_si128 を使い、上位64ビットは 0 となるようにしています。
    /// </summary>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static v128 SetLow64(ulong value)
    {
        // SSE2 環境なら cvtsi64x_si128 を使用
        return cvtsi64x_si128(unchecked((long)value));
        // ※ cvtsi64x_si128 は、下位64ビットに value をセットし、上位は 0 として返します。
    }
}
サトー™ @sator_imagingサトー™ @sator_imaging

Vector64.Add<byte>

以下は、64ビット(8バイト)分のベクトル同士の要素ごとの加算(ラッピング加算)を行い、.NET の Vector64.Add<byte> と同等の動作を実現するコード例です。 この例では、SSE2 命令(paddb 相当の処理)を使用して高速に処理しますが、SSE2 未対応の場合はループによるフォールバック実装も用意しています。なお、Unity Burst で扱う v128 型を用いており、下位64ビットのみを実際のデータ領域として扱っています。

using Unity.Burst;
using Unity.Burst.Intrinsics;
using static Unity.Burst.Intrinsics.X86.Sse2;
using System.Runtime.CompilerServices;

[BurstCompile]
public static unsafe class Vector64AddUtil
{
    /// <summary>
    /// 64ビット(8バイト)分のベクトルについて、各バイトの加算(モジュロ256 のラッピング加算)を行います。
    /// つまり、各要素に対して下記の処理を実施します:
    ///      result[i] = left[i] + right[i]
    /// 入力および出力は、v128 型のうち下位64ビット(8バイト)が有効な領域となります。
    /// </summary>
    /// <param name="left">左側のベクトル(下位8バイトが有効)</param>
    /// <param name="right">右側のベクトル(下位8バイトが有効)</param>
    /// <returns>各バイトごとの加算結果を保持するベクトル(下位8バイトが有効)</returns>
    [BurstCompile]
    public static v128 Add64(v128 left, v128 right)
    {
        if (Sse2.IsSse2Supported)
        {
            // SSE2 の paddb 命令に相当する Sse2.Add を利用して全16バイト分の加算を行いますが、
            // 本来の Vector64.Add<byte> は下位64ビット(8バイト)のみが有効なため、後続でマスク処理を行います。
            v128 sum = Sse2.Add(left, right);
            // 下位64ビット(8バイト)のみを抽出するためのマスクを作成
            v128 lowMask = SetLow64(ulong.MaxValue);
            return Sse2.And(sum, lowMask);
        }
        else
        {
            // SSE2 未対応の場合は、1バイトずつループ処理して加算(ラッピング加算)を実施
            v128 result = default;
            byte* pLeft = (byte*)&left;
            byte* pRight = (byte*)&right;
            byte* pResult = (byte*)&result;
            for (int i = 0; i < 8; i++)  // 下位8バイトのみ
            {
                pResult[i] = (byte)(pLeft[i] + pRight[i]); // C# の byte 型での加算は自動的に modulo 256 のラッピングとなる
            }
            // 上位8バイトをゼロクリア
            for (int i = 8; i < 16; i++)
            {
                pResult[i] = 0;
            }
            return result;
        }
    }

    /// <summary>
    /// 下位64ビットに指定の ulong 値をセットした v128 型を生成します。
    /// SSE2 命令 cvtsi64x_si128 を利用して、下位64ビットに value をセットし、上位は 0 とします。
    /// </summary>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static v128 SetLow64(ulong value)
    {
        return cvtsi64x_si128((long)value);
    }
}