SIMD並列化ライブラリSmartVectorDotNet開発の知見まとめ(4) C#と.Netの最適化
これまではほとんどの計算機において共通して利用できる知見を語ってきましたが、本稿ではC#およびその実行環境である.Net固有の特性について述べます。
注
現在の.Netでは実行環境としてJIT/AOTの両方がサポートされていますが、簡便化のため本稿ではIL→native assmのコンパイルをJITコンパイルと表現します。
AOTにおいても中間表現としてILを経由するため最終的なnative assmは同等のものが生成されると考えて問題ないと思われます。
ジェネリクスの実行時最適化
参考:
.Netのジェネリクスは、JITコンパイル時に型固有のコードを生成する最適化が導入されています。
特に型引数が値型の場合は非常に強力な最適化が働き、書き方を工夫すれば非ジェネリックと完全に等価なnative assmを生成させることができます。
具体的には、型の特殊化において
- 分岐は
if(typeof(T) == typeof(byte))
のようにtypeof
で行う - 型変換は
System.Runtime.CompilerServices.Unsafe.As<TFrom, TTo>()
で行う
のルールに従うことで最適化を効かせることができます。
internal static TTo Reinterpret<TFrom, TTo>(in TFrom x)
where TFrom : unmanaged
where TTo : unmanaged
=> Unsafe.As<TFrom, TTo>(ref Unsafe.AsRef(in x));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T UnaryPlus<T>(in T x)
where T : unmanaged
{
if(typeof(T) == typeof(byte )) return Reinterpret<sbyte , T>((sbyte )(+Reinterpret<T, sbyte >(x)));
if(typeof(T) == typeof(ushort)) return Reinterpret<short , T>((short )(+Reinterpret<T, short >(x)));
if(typeof(T) == typeof(uint )) return Reinterpret<int , T>((int )(+Reinterpret<T, int >(x)));
if(typeof(T) == typeof(ulong )) return Reinterpret<long , T>((long )(+Reinterpret<T, long >(x)));
if(typeof(T) == typeof(nuint )) return Reinterpret<nint , T>((nint )(+Reinterpret<T, nint >(x)));
if(typeof(T) == typeof(sbyte )) return Reinterpret<sbyte , T>((sbyte )(+Reinterpret<T, sbyte >(x)));
if(typeof(T) == typeof(short )) return Reinterpret<short , T>((short )(+Reinterpret<T, short >(x)));
if(typeof(T) == typeof(int )) return Reinterpret<int , T>((int )(+Reinterpret<T, int >(x)));
if(typeof(T) == typeof(long )) return Reinterpret<long , T>((long )(+Reinterpret<T, long >(x)));
if(typeof(T) == typeof(nint )) return Reinterpret<nint , T>((nint )(+Reinterpret<T, nint >(x)));
if(typeof(T) == typeof(float )) return Reinterpret<float , T>((float )(+Reinterpret<T, float >(x)));
if(typeof(T) == typeof(double)) return Reinterpret<double, T>((double)(+Reinterpret<T, double>(x)));
throw new NotSupportedException();
}
静的Strategy
値型ジェネリック最適化の更なる応用例として、strategyパターンの高速化があります。
例として次のようなコードを考えます。
public interface IFooStrategy
{
public void DoSomething(int x);
}
public class Hoge
{
private readonly int[] _array;
public void DoForEverything(IFooStrategy foo)
{
foreach(var x in _array)
{
foo.DoSomething(x);
}
}
}
strategyパターンの典型的な使用例ですが、インターフェースメソッドDoSomething
へのアクセスがループの中で行われていることに着目してください。
このメソッドアクセスはILではcallvirt
命令にコンパイルされますが、callvirt
命令は原則としてvtable経由でのアクセスにJITコンパイルされます。
ループ中で何度も参照を利用すると、たとえキャッシュに展開されるとしても余計なオーバーヘッドになることは想像に難くないでしょう。
そこで、strategy具象型を型引数で受け取るように書き換えて見ます。
public class Hoge
{
private readonly int[] _array;
public void DoForEverything<TFooStrategy>(TFooStrategy foo)
where TFooStrategy : IFooStrategy
{
foreach(var x in _array)
{
foo.DoSomething(x);
}
}
}
TFooStrategy
の型が値型であるならばこのコードにはJIT最適化が適用されます。
値型はvtableを持たずメソッドコールが一意に決定できるため、callvirt
命令であってもJIT
後にはメソッド呼び出しに参照が挟まらなくなり、場合によってはインライン化も有効になります。
さらに、この最適化はDoSomething
が明示的に実装されていたとしても機能します。
一方で、TFooStrategy
に参照型が渡された場合はこの最適化が効きません。
利用側で気を付ける運用も不可能ではありませんが、無用なミスを防ぐためにstruct制約を付けておきましょう。
public void DoForEverything<TFooStrategy>(TFooStrategy foo)
where TFooStrategy : struct, IFooStrategy
さて、型引数がstruct制約を持つようになるともう一つメリットがあります。
C#においては、値型であればdefault(TFooStrategy)
でその型の実体を高効率に生成することができます。
default
を使うとフィールドが意図通りに初期化されないことがありますが、strategy
であればふつう状態を持たないためこれが問題になるケースは稀でしょう。
そのため、このメソッドは引数を受け取らなくても問題なく機能します。[1]
public void DoForEverything<TFooStrategy>()
where TFooStrategy : struct, IFooStrategy
{
var foo = default(TFooStrategy);
foreach(var x in _array)
{
foo.DoSomething(x);
}
}
-
この配慮はインターフェースの静的メソッドが導入されたC#8.0/.Net Core3.0以降では不要です。ただし、SmartVectorDotNetでは最低サポートバージョンをnetstandard2.0に設定しているため互換性を鑑みてこの実装を利用しています。 ↩︎
Discussion