Unity界最速DIコンテナVContainer が速い理由の解説

拙作の Unity用DIライブラリ、VContainer の v0.9.0 では、ILコードをコンパイル時に生成することによるメタプログラミングの高速化が足されました。

この機能をつかうと、Zenjectのデフォルトとの比較でディタ上では50倍くらい、IL2CPPでは20倍〜くらい速い結果になっています。
また、グラフのとおり VContainerはコード生成なしで比較してもZenjectより数段パフォーマンスが良いです。

個人的には、UnityでわざわざDIコンテナを導入することの肝は、MonoBehaviour ではなくピュアなC#で作ったクラスの方をエントリポイントにできる枠組みの提供にあると思ってますが、 VContainerは、その辺をオーバヘッドの少ない形で提供できていると思うので、興味ある方は試してみて欲しい。

github.com

今日は 最適化について考えたことを、Zenjectと比較しつつ振り返ってみよーと思います。

アロケーション抑制

C# のコードの最適化でまず最初に考える基本的なことはヒープアロケーションを減らすことです。

一般論として、現代的な環境で動くアプリケーションを書くということは、すごく生産性の高いランタイムの上の、巨大なライブラリの上の、さらに巨大なライブラリの上の、リッチなライブラリの上の、その上に自分のコードが乗っかっることになるので、その表現力の高いコードの一行一行のパフォーマンスが等価ではありません。

そのため、だいたいどんな環境でも、その環境のおける遅い行為が何なのか? を把握して、設計やコーディング慣習レベルで遅さを弾いておくことが速いコードを書くためのまずは土台になってきます。 遅いパーツが表面から深部に至るまで多用されたものを後から直すことは困難か不可能か死人がでます。

「早すぎる最適化は悪」という言説はそのとおりで、意味のない最適化によって何かが失われることは悪なんですけど、 コストをほぼ無視できる文と、1,000倍遅い文が混在していることが最初からわかりきっているのに、「まずはパフォーマンスを意識せず書いて後から計測してボトルネックを叩き潰そう」だけで挑むのはナンセンスです。

アプリケーションを書くときは、 ネットワークI/O だとか画面に新しい絵を出現させるだとか、CPUとは別のハードとのやりとりが発生する処理が出てくるわけですが、普通はみんなそういう処理は別次元の遅さであることを認識してますよね。C#ワールドにスコープを絞って考えても同じような考え方が適用できます。C# の最初からわかっている別次元に遅いパーツはGCです。

Unityでつくるゲームのように、メインループを止めるとユーザの体験が劣化するアプリにおいて、 GCの回収フェイズで全マネージドスレッドが止まる Stop the World があること自体がけっこうハンデだと思うんですが、それを置いておいても、実はミクロなチューニングをしてみると GC.Alloc = GCが管理するメモリを確保する処理 の方も、 最初から省く作業をする価値がある程度には 十分に遅いです。

.NET Core などの環境では、Unityよりも寿命の短いメモリの回収の性能が良い仕組みになってると思いますが、最近は .NET Core の標準ライブラリも アロケーションを削減することに神経をとがらせている節があるので、UnityだけでなくC#一般の話だと考えて特に問題ないと思います。

VContainer のコア部分は 、余計なGCゴミが発生しないように気を遣っていて、その結果、何も考えずに書いた最初の計測の時点でZenjectより数倍は速い結果が出てました。

Zenject の真実

Zenject は、ベンチマークが公開されていたりするので、割とパフォーマンスに気を遣っているイメージがあるのですが、 実はアロケーション抑制はあまり徹底されていません。コードを覗いてみると、「プロファイラを仕込みまくって後から遅いところを直そうぜ」といった気持ちを読み取ることができます。

Zenject の設計上、内部的にヒープアロケーションがけっこう発生してるところを挙げてみると、

  • コンテナ内に保持されるキーに対応する値が内部的にはコレクションになっている。(多くのケースで型は重複して登録されないにも関わらず)
  • Resolve する時には必ずキーになる独自の型のクラスが必要。つまり Resolve を呼ぶ度にアロケーションが発生する可能性がある。 (ただしプーリングはしてる
  • 後からオブジェクトプーリングを入れようとしているが、コンテナと寿命が同等なオブジェクトについては再利用できる確率がかなり低いので単にオーバヘッドになってる恐れがある

などが気になるところです。

VContainerの方は現状だと、オブジェクトプーリングしているのはリフレクション時のメソッド呼び出しに必要な object[] だけです。 これはすぐに捨てることができるし、配列とはいえ要素数は限られるため、汎用なArrayPoolではなく事前に十分な数を確保しておく専用のやつを用意していて、ゴミがでないようになっています。

こういった無駄の削減を Zenject に対して今から施すには、かなり設計を変えていく必要がありそうです。身も蓋もないですが VContainer のGCゴミが少ない理由はテクニックというよりも「1から書いたから」というのが大きいと思ってます。

その他 ヒープアロケーションを削減する細かい術

他、アロケーション抑制の瑣末な行いを紹介してみます。

  • スレッドごとに一個あれば十分なバッファは、オブジェクトプールじゃなくて [ThreadStatic] で使いまわす
  • ラムダ式
    • これは基本ですが、ラムダ式が外側のスコープの変数をキャプチャする場合、ラムダ式が評価される度にスコープを表現するオブジェクトがヒープにとられるので、キャッシュするか 外側のキャプチャを消す
  • コレクションが必要な箇所があっても、本当に必要になるまでオブジェクトをつくらない。
    • たとえば、コンテナに対して 重複した型の登録が発生した時だけ、登録内容を Composite パターンな型でラップする仕組みにしてる

実行の最適化

VContainer の実行時間のボトルネックは、メタプログラミング部分 と 上記の ヒープアロケーション絡みの割合が大きかったんですが、他に効果があったと思われる実行速度の最適化をいくつか紹介してみます。

コンテナが不変

VContainer では、一度ビルドしたコンテナは不変で、後から要素を追加することができません。

まず、これによって 内部構造を極端に単純になります。コードのサイズも減ります。

設計上、コンテナが 読み取り専用であることは、パフォーマンス面でもメリットがあります。

  • 不変なコンテナに対する読み書きのスレッド競合を気にする必要がない
  • コンテナをビルドした時点で、必要な値が確定するため、必要のないところにコレクションやヒープが必要なオブジェクトをつくる必要がない。
  • readonly struct や 不変なコレクションをを使用できる
    • C# では 配列が最速
    • readonly struct は diffensive copy を抑制できるケースで速い

標準コレクションの差し替え

型に対応したオブジェクトを引いてくる実装は、 neuecc さんの 汎用DIコンテナ実装の neuecc/MicroResolver をけっこう参考にさせてもらってます。

特に、 C#でTypeをキーにしたDictionaryのパフォーマンス比較と最速コードの実装 - Grani Engineering Blog この辺を参考に、ここで紹介されている FixedTypeKeyHashTable はそのまま使ってたりします。

記事で詳しく紹介されていますが、これはスリムな読み取り専用のハッシュテーブルの実装で、でロックフリーです。(読み取り専用だから) 実装をみると、初期化時点で要素数が確定するため値は単純な配列に入っていて、インデックスを GetHashCode から求める + ハッシュ値の衝突はネストした配列の次の要素に入れていくといった、必要な機能以外に全く余計なものがなく全体はわずか100行程度におさまっています。 パフォーマンス面では 配列は特別な存在で、 IL レベルで読み書き命令があったりするので、参照頻度の多い コレクションの実装は配列ベースのごく単純なものに置き換えると効果がでる場面があります。

また、上に書いたとおり、読み取り専用にする代わりに機能を削ってスリムにしたハッシュテーブルを VContainerにはめることができるのは、VContainer のコンテナがイミュータブルなためです。

コア部分がUnityEngineに依存してない

VContainer のコア部分は、完全に UnityEngine に依存しない形になっています。

そのため、Resolveのパフォーマンスに関わるとろでは、 Unity 特有の型 を 特別扱いする分岐などがなく、ダイナミックに実行時型を取ったりする処理もありません。

( ただ、現状、 Unity依存の API が一部 interface への 拡張メソッドとして実装されていたり、Unity依存のAPI はnamespace を分からていたり、といったことになっていて、ユーザビリティ的には若干失敗なのでもうちょい なんか直すかも

遅さを前払いする

VContainer の遅い処理はほとんど 「コンテナをビルドする」フェイズで発生するつくりになってます。

コンテナのビルドが遅い代わりに、コンテナをつくった後は、すべての操作で限りなくオーバヘッドが小さく、またGCゴミがでません。 たとえば、デフォルトではメタプログラミングをリフレクションによって行っていますが、リフレクションによる型収集はコンテナをビルドする時以外は発生しません。(その前にも後にも型情報を収集しない)

遅さを一括して前払いで支払っている結果、同じ型のResolveの回数の増加に対して 遅くなっていく割合が小さく、一般的な用途では有利に働くと思います。( グラフでは、Zenject は 単純なテストケースと複雑なテストケースの差がありますが、VContainerはそこの差が小さい

また、コンテナのビルドをバックグラウンドスレッドに逃がしてやることで、DIによるメインスレッドのブロック時間の大半を消す、という使い方も想定してます。(README参照)

静的ディスパッチ

以前の経験 から、IL2CPP 環境では、仮想メソッド呼び出しのための C++ のコードが型ごとに生成されたり、実行ステップが増えたりするようすを観測しているため、 internal なクラスについては 大部分に seald 修飾子をつけていたり、あと、パフォーマンスだけが理由ではないですが継承を最小限に留めています。

VContainer.dll の IL を見ると、 内部の頻繁に使われるメソッドの大半が .callvirt じゃなくて .call 呼び出しになってるのが確認できます。一般的に、メソッド呼び出しは、動的より静的ディスパッチのほうがオーバヘッドが小さい他、コンパイラもより積極的な最適化をするチャンスがあるため高速です。

IL生成によるメタプログラミングの高速化

DIコンテナはコンストラクタやメソッド定義をするだけで自動的にオブジェクトが注入される機能(auto wiring)がチャームポイントですが、これはユーザのクラス定義を情報源にするため、メタプログラミング的な手法が必要です。
メタプログラミングは、DIを実装する上で一番大きくてわかりやすいボトルネックです。

class ClassA
{
    // この定義情報を元に……
    public ClassA(Service1 service1) { /* ... */ }
}

// これが呼び出されたら
container.Resolve<ClassA>();

// こういうことしたい
new ClassA(container.Resolve<ServiceA>());

C#メタプログラミングをする方法は色々ありますが、一般にもっとも高速で素朴な方法は、実行前に Inject メソッドの コード (C#, IL 問わず)生成してしまうことです。 しかし、DI の用途を考えると、対象になるのはライブラリのユーザが書いた型なので、このソースコードを勝手に書き換えちゃうことができません。 ( public なメソッドへの Inject だけを考えて良いのであれば、別のクラスを生成するという手はある

C# の汎用的な DIの実装では、実行時に Expression Tree や IL Generator などの機能を使って 動的に実装を生成する方法がとられています。

ただし、Unity の IL2CPP 環境では これらは動作しません。VContainer や Zenject では、デフォルトでは 実行時にリフレクションを使って Inject対象のメソッド情報を参照し、それをダイナミックに呼び出します。

// イメージ図

// リフレクションで定義情報をなめて
var constructorInfo = typeof(ClassA).DeclaredConstructors.First(...);

// リフレクション経由でメソッド呼び出しじゃ
constructorInfo.Invoke(new object { serviceA });

しかしこれは メタプログラミングをやる方法としては当然のように一番遅いです。型情報の収集だけでなく リフレクション経由のメソッド呼び出しの遅さも予想以上です。

Unityの中で生活しながらもこのボトルネックをどうにかするため、VContainer (や Zenjec)は、ソースコードには存在しないコード生成を、実行時ではなくコンパイル時に行う、というテクニックを使用します。

ちなみに IL コードというのは、C#コンパイラが吐く中間コードのことです。(java とかではバイトコードとも)
IL コード生成で高速化、と聞くと、クラッシュバンディクーみたいに「人間用のコードだと最適化が不十分だからおれがアセンブリを手で書いた方が速い」というアプローチのようにも聞こえますが、そうではなく、今回のようにダイナミックすぎる処理を事前に焼き込んでおく、という用途でしばしば使われます。

※新しいC# では、コンパイル後のパイプラインに C# を生成できる機能が入るらしいので、完全にこのような用途であればそっち使った方が良い、ていうケースがけっこう出てきそう

IL は値をスタックで操作したり、高水準な分岐とかはなかったりしますが、CLR で定義されているオブジェクトとかメソッドとかの概念があったりするため、以外と高水準な言語だなと思います。仕様の文書は、意外と人間用のわかりやすい説明が多めなので 概念を把握した後はたぶんこれを見てみると良いと思います。 Standard ECMA-335

VContainer が生成するコード

さて、Zenject の 「Reflection Baking」は、その名のとおり リフレクションでの型収集の結果を ILに書き込んでおくものです。
そのため、IL生成を有効にしている場合でも、リフレクション経由の呼び出しと同じI/Fのメソッドを生成します。

たとえば コンパイル時に生成するコードのイメージ図は以下です。

// こういうクラスに対して Inject したい
class ClassA
{
    public ClassA(Service1 service1, Service2 service2) { /* ... */ }
}
// リフレクションの場合
constructorInfo.Invoke(new object { serviceA });
// Zenject がILで生成するメソッド(のイメージ)  
ClassA CreateInstance(object[] args) => new ClassA((Service1)args[0], (Service2)args[1]);

一方、VContainer は、リフレクション か コード生成か といったメタプログラミング部分を広く抽象化しているため、Resolveの基本的なところを全体的に ILで書いています。
そのため、Zenjectと比較すると、リフレクションと同様の object[] はいたるところで完全に不要になっていて、引数についてのループなども 事前にインライン化しています。

// VContainer がIL生成するメソッド(のイメージ)
ClassA CreateInstance(IObjectResolver resolver) => new ClassA(resolver.Resolve<Service1>(), resolver.Resolve<Service2>())

( ちなみに、 MicroResolver の説明をみると、Resolve も消して new を焼き込んでいるのでさらにオーバーヘッドが小さい)

Unity の ILPostProcessor

Unity で コンパイル時にILを編集する方法で現在、簡単なのは ILPostProcessor を使うことだと思います。
Unity のECS は、Entity の操作を書くと自動的に それが注入される謎の 仕組みがありますが、これはコンパイル時IL編集によって実装されてる、というのを スーギ・ノウコ自治区さんの記事 で知りました。

ECS が 掘ってくれたおかげか、 Unity のpreviewパッケージにはさりげなく Mono.Cecil が含まれていたり、ILPostProcessor というコンパイル後のILをあれこれする口が用意されています。

大きな問題は、 ILPostProcessor の方がまだ ユーザに公開されていない内部用の機能で、そのままでは使えないことです。使うか使わないべきかかけっこう迷ったんですが、 現在は、フォーラムにあった方法の 、Unity.CompilationPipeline.Common.dll をプロジェクト側にコピーするというかなり行儀の悪いハックをして無理矢理使ってます。 最終的には、ECS が Unity における コンパイル時IL生成の草原に 先に足跡をつくってくれているので、コードをそのまま参考にするのが安全かな- と思って ILPostProcessor に乗ってみました。はやく 公開APIになってくれ