私は、.NET Frameworkを使う事が多いのですが、.NET FrameworkからSIMDを使うにはSystem.Numerics.Vectorsクラスを使ってSIMD演算ができるのですが、C言語で使うSIMDとは全く別物で使っていませんでした。
ところが、.NET Core 3.0から使えるSystem.Runtime.Intrinsics.X86 名前空間は、ほぼC言語のSIMDと似ているので、.NET 5.0 も登場した事だし、少し触ってみました。
そもそもSIMDって何?という話は私も説明できる程は理解していませんが、8bitの型(byteなど)や16bitの型(shortなど)を128bitや256bitでまとめて演算する事で、高速に処理する事が可能となります。
まずは、C言語の説明になりますが、ここ↓を見る事をおススメします。
C#のSIMDで出来る事は、加算、減算、シフトやビットの入れ替えなどになりますが、ここ↓を参照下さい。
ushort型配列をビットシフトし、byte配列へ代入する例
ushort型(16bit)の配列に格納された10bitの値(0~1023)を2bitシフトして、8bit(byte型)の配列に代入する例を示します。
SIMDのプログラムでは、ポインタを使うので、プロジェクトのプロパティで、アンセーフコードの許可にチェックを入れます。
まずは、作成したコンソールアプリのサンプルをご覧ください。
using System;
using System.Runtime.Intrinsics.X86;
namespace ConsoleApp1
{
class Program
{
static unsafe void Main(string[] args)
{
// データの個数(16の倍数の個数)
int dataCount = 1024;
// ushort型(16bit)の配列
var usData = new ushort[dataCount];
// byte型(bit)の配列
var bData = new byte[dataCount];
// 評価用データの作成
for (int i = 0; i < dataCount; i++)
{
usData[i] = (ushort)i;
}
fixed (ushort* pusData = usData)
fixed (byte* pbData = bData)
{
for (int i = 0; i < dataCount; i += 16)
{
// データをポインタから読み込む
var vVal1 = Avx2.LoadVector128((short*)pusData + i);
var vVal2 = Avx2.LoadVector128((short*)pusData + i + 8);
// 2ビット右側へシフト
vVal1 = Avx2.ShiftRightLogical(vVal1, 2);
vVal2 = Avx2.ShiftRightLogical(vVal2, 2);
// パック
var vDst = Avx2.PackUnsignedSaturate(vVal1, vVal2);
// 結果をbyte型配列に書き込む
Avx2.Store(pbData + i, vDst);
}
}
// 結果出力
for (int i = 0; i < bData.Length; i++)
{
Console.Write($"{bData[i],3}, ");
}
}
}
}
今回は、AVX2を使っていますが、AVX2は第4世代Core(Haswell)以降のCPUであれば使用可能なので、今どきのPCであれば、ほとんど使用できると思います。
まず、LoadVector128メソッドで、ushort配列(16bit)のポインタを128bitのVetorと呼ばれる高速に読み書きできる領域へ読込しています。
16bitのデータを128bitへ読込しているので、この1回のメソッドで、128 / 16 = 8 の8個分のデータを読込しています。
ちなみに、ushortポインタ(ushort*)をshortポインタ(short*)へキャストしているのは、最後に出てくるPackUnsignedSaturateへ渡す引数にVector128<ushort>が無い(Vector128<short>はある)ため、short*へキャストしています。
var vVal1 = Avx2.LoadVector128((short*)pusData + i);
ShiftRightLogicalメソッドでは、右側へシフトしています。
下記の例は2ビット右側へシフトしています。(4で割るのと同じ)
vVal1 = Avx.ShiftRightLogical(vVal1, 2);
PackUnsignedSaturateメソッドでは、パックと呼ばれる処理になりますが、詳細は最初に示した参考文献を見て頂くのがいいかと思いますが、引数で渡された型の半分の下位ビットをつなぎ合わせた物を返します。
var vDst = Avx2.PackUnsignedSaturate(vVal1, vVal2);
今回の例では、最初の引数の8個の16bitの値のそれぞれ、下位8bitの部分を取得し、8個の8bitの値とし、第二引数も同様に8個の8bitの値を取得し、計16個の8bitの値(16 x 8 = 128bit)を返します。
StoreメソッドはVectorから配列を示しているポインタへ値を戻します。
Avx2.Store(pbData + i, vDst);
まとめ
現状では、.NETのSIMDの情報が少ないので、C言語のSIMDの情報を頼りにする事になるかと思いますが、今回、Vectorと言っていた部分をレジスタに読み替えれば、ある程度理解できると思います。
また、このVectorへ渡すメモリにアライメントという概念があり、LoadVector128と似たメソッドにLoadAlignedVector128というメソッドがあり、これを使うと処理が少し速くなるのですが、メモリのアドレス(番地)が128で割り切れる必要があり、通常、配列から取得したポインタのアドレスは、このアドレスが128では割り切れない番地になっているため、少し工夫しないと、そのままでは使用する事ができません。
← .NET 5 C# Windows Forms プログラム へ戻る
コメント