dotnet/runtimeのコミット履歴とかにうっすら痕跡が見て取れるんですが、 去年の10月中旬頃、 「low level hackathon」とかいう Microsoft 社内イベントをやっていたみたいです。

今、C# 7.2とかの頃に Span<T> 構造体が追加されて以来の4年ぶりくらいの動きになりますが、 .NET ランタイムの低層に手を入れてパフォーマンス改善を図りたい流れになっているみたいです。

その後の様子を見るに、これは昨年10月の hackathon 時だけの短期的なブームというわけでもなくて、割かし .NET 7 目標にちゃんと動き出している雰囲気です。

ということで、今日の分のブログから数回はこの手の low level なパフォーマンス改善系の話をしていこうかと思います。

定数配列

今日は、以下のような、 全要素が定数の配列を書いたときの最適化の話になります。

ReadOnlySpan<int> data = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };

例えば以下のような2つのメソッドを比べてみましょう。

static int M1(int i)
{
    ReadOnlySpan<int> table = new[] { 1, 0, -1, 0 }; // 差はこの行だけ
    return table[i % 4];
}

static int M2(int i)
{
    ReadOnlySpan<sbyte> table = new sbyte[] { 1, 0, -1, 0 }; // 差はこの行だけ
    return table[i % 4];
}

4要素の定数テーブルを引いているだけのシンプルなコードです。 M1M2 の差はテーブルが intsbyte かという点だけになりますが、 (少なくとも .NET 6 / C# 10.0 では) この差だけでパフォーマンスが数倍違います。

ベンチマーク用コード

一番大きな差は、

  • int の方は普通に配列が new されている (newarr 命令が発行されてる)
  • sbyte の方は 生データが直接参照されて、new ReadOnlySpan(void*, int) が呼ばれている

という点になります。 その結果、配列のヒープ アロケーションが発生するかどうかでパフォーマンスに大きな差が出ます。 (int の方がだいぶ遅い。)

エンディアン

C# 10.0 な現状、この手の最適化は bytesbyte に対してしか掛からないという制限があります。

理由は主にエンディアンで、 一応、.NET ランタイムはビッグエンディアンにも対応しているので、 new[] { 1, 2, ... } と書いてバイト列としてデータを記録するとき、 0, 0, 0, 1, 0, 0, 0, 2, ... と並べるか、 1, 0, 0, 0, 2, 0, 0, 0, ... と並べるかという問題があります。

とはいえ、これは別に今までの「要素が全部定数の配列」でも同じ問題はあって、

  • DLL 中にデータが埋め込まれる場合、.NET はリトルエンディアン
  • 埋め込みデータから配列を作るときに RuntimeHelpers.InitializeArray メソッドを呼ぶ
  • InitializeArray の中で、ビッグエンディアン環境だったらエンディアンをひっくり返す処理が入っている

みたいな動作をしているみたいです。

CreateSpan

ならまあ、やるべきことは割かしわかりやすいわけでして。 埋め込みデータから直接 ReadOnlySpan を作る部分を InitializeArray と同様のヘルパー メソッドにして、 ビッグエンディアン環境だったらひっくり返す処理を挟めばいいということになります。

それがこちら:

Add non-intrinsic implementation for CreateSpan<T>.`

Roslyn (C# コンパイラー)側の対応:

RuntimeHelpers.CreateSpan optimization for stackalloc

dotnet/runtime 内で既存コードに対してこれを前提にした最適化を掛けたもの:

Experiment with Roslyn optimization for ROS<T> in assembly data section

これが正式に採用されれば、 最初に例に挙げた M1 メソッドと M2 メソッドのパフォーマンス差はもう少し縮まるはずです。