🏎️

.NET 10.0 時代の最適化戦略

に公開

マイクロベンチマークが多いのでその点ご理解の上よろしくお願いします。

Unity については

  • 逆をやれば最適化になる
  • 「これはやらなくて良い」は Unity ではやった方が良いということ

です。

はじめに

ベンチマークに使う構造体はコチラ。

  • readonly ナシ(最適化されづらい)
  • インターフェイス型を返す GetEnumerator のみ
    • ※ 配列の GetEnumerator もインターフェイスを返すが最終的な型は構造体
public struct MyStruct<T> : IEnumerable<T>
{
    public T[] buffer;
    public MyStruct(int size) => buffer = new T[size];
    public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)buffer).GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

※ Unity の場合は明示的実装を活用して具象型を返すメソッドを定義し、

  • public <構造体の具象型> GetEnumerator()
  • IEnumerator IEnumerable.GetEnumerator()
  • IEnumerator<T> IEnumerable<T>.GetEnumerator()

構造体そのものが使われるチャンスを増やすというハック(おまじない要素強め)がある。

その1)インターフェイスを使って良い

ベンチマークに使うコードはコチラ。

ポイント

  • メソッドのパラメーターとしてインターフェイスを渡す
  • GetEnumerator が構造体をインターフェイス型として返す
  • どちらもボックス化(=ヒープアロケーション)を引き起こす
[Benchmark] public int ByValue() {
    var foo = new MyStruct<int>(10);
    return Run(foo as IEnumerable<int>);
}

[Benchmark] public int ByRef() {
    var foo = new MyStruct<int>(10);
    return RunRef(foo);
}

int Run(IEnumerable<int> values) {
    int count = 0;
    foreach (var x in values) count++;
    return count;
}

int RunRef(in IEnumerable<int> values) {
    int count = 0;
    foreach (var x in values) count++;
    return count;
}

結果

まずは .NET 9.0 から。

速度が変わらずどちらもアロケーションが発生しています。

続いて .NET 10.0

速度が10倍でアロケーションも消滅しています!

何故なのか

ByValue のベンチマークのコードはわざわざインターフェイスに明示的にキャストしていますが、最終的には構造体なので不要なアロケーションが無くなります。

また、インターフェイス越しのメソッド呼び出しも脱仮想化されて直接呼出しになり、結果として実行速度が向上します。

ByValue(再掲)

[Benchmark] public int ByValue() {
    var foo = new MyStruct<int>(10);
    return Run(foo as IEnumerable<int>);
}

int Run(IEnumerable<int> values) {
    int count = 0;
    foreach (var x in values) count++;
    return count;
}

一方の ByRef のベンチマークコード(再掲)は、

[Benchmark] public int ByRef() {
    var foo = new MyStruct<int>(10);
    return RunRef(foo);
}

int RunRef(in IEnumerable<int> values) {
    int count = 0;
    foreach (var x in values) count++;
    return count;
}

メソッドの引数に in を付けています。これは

  • 構造体が大きい場合コピーコストがかかるから参照渡しする

を再現していると思ってください。実際はインターフェイスではなく具象型を引数に取るべきです。

べきです、という事で具象型を in で取るベンチマークも取りました。

[Benchmark] public int ByRefConcrete() {
    var foo = new MyStruct<int>(10);
    return RunRefConcrete(foo);
}

int RunRefConcrete(in MyStruct<int> values) {
    int count = 0;
    foreach (var x in values) count++;
    return count;
}

👇 それがコチラ(.NET 10.0)

ByValue に並びました。この手法は特に Unity(古い .NET 環境)で効きます。そして最新の .NET 10.0 では

  • 😎「そういうヤツはこっちでやっておきますね」

です。.NET 10 以降がターゲットなら普通にコードを書けば良いです。素晴らしい!

その2)構造体にこだわらない(クラスで良い)

MyStruct<T> は構造体でしたが、これを単純に class MyStruct<T> に変えてベンチマークを取ってみます。

ByRefConcrente のパフォーマンスが悪くなりました。

クラスは(おそらく)脱仮想化の恩恵を受けていますが、in モデファイヤーによるパフォーマンスの劣化が認められます。構造体では起きなかったアロケーションも起きています。

一方で普通にインターフェイスを受け取っている ByValue 方は良い結果を残しています。アロケーションも無し。この結果から導き出される結論は同じで「普通にコードを書けば良い」です。

--

ヒープ確保が無くなっている理由としては以下のような感じでしょうか。

https://zenn.dev/sator_imaging/scraps/d3fb68f6ec201a

クラスを強制的にヒープに確保するというのは Java / C# 最大の発明(物事が単純になった/問題もあるけどね)ですが、最新の環境ではインライン化や何やらを経て、最終的に小さなスコープで完結するクラスはスタックに置かれるとか、大体そんな感じの最適化が行われるようになっています。

Unity は CoreCLR への移行でどうなる?

まず、IL2CPP を使わない方が速いという状況については考えません。これは iOS / Android が AOT コンパイルされていないアプリを受け付けないからです。

--

👇 IL コードを見てみると、、、

box がありますね。

ふわっとしたアレコレ

仮に Unity の CoreCLR への移行で .NET 10.0 環境になったとして、素直に IL から C++ コードへ変換した場合、アロケーション回避や場合によっては脱仮想化の恩恵が受けられない可能性があります。

IL2CPP の場合は AggressiveInlining を付けないと絶対にインライン化されないと言った、C# とはまた別のノウハウや知識が必要だと思うので、この辺り今の段階から Unity 社の開発に耳打ちしておいた方が良いのかもしれないですね。(念押しで)

普通に考えれば C++ コンパイラーがヒープ確保しなくて済むなら勝手にそうする、って最適化をやってくれそうですけど、IL2CPP が最適化しやすいコードを吐くかどうかが分からない。

それ以前に IL2CPP 続投なのかも不明ですが。

おわりに

Unity では依然として構造体(サイズが大きい場合は in 渡し)が最適化に効きます。

以上です。お疲れ様でした。

Discussion