Unity を検索

機能ハイライト:Unity 2021.2 における IL2CPP ランタイムのパフォーマンス改善

2022年2月17日 カテゴリ: テクノロジー | 7 分 で読めます
Runtime Performance banner
Runtime Performance banner
取り上げているトピック
シェア

C# の表現力と安全性を活かして Unity プロジェクトのコードを書くことで、ターゲットとするプラットフォームで最大限のランタイムパフォーマンスを発揮できることをご存知でしたか?そのため、Unity の .NET Tech グループは、皆さんが書くスクリプトを支える基礎的な技術スタックの更新に熱心に取り組んでいます。

Unity 2021 では、IL2CPP スクリプティングバックエンドを使うときにスピードアップを図れるよう、いくつかの改善を行いました。ここでは、コードを驚くほど速くするために導入された主な変更点を詳しくご紹介します。

デリゲートの呼び出し

デリゲート呼び出しの仕組みは C# の強みですが、CreateDelegate API はかなり複雑です。デリゲートにはオープンのものとクローズのものがあり、仮想呼び出しまたはインターフェイス呼び出しによってインスタンスまたは静的メソッドを呼び出すことができます。これらのすべてのバリエーションは、ランタイムが正しいメソッドが正しい呼び出しによって呼ばれることを保証するためのいくつかのチェックを完了しなければならないことを示しています。

Unity 2021.2 では、IL2CPP はコンパイル時に正しい呼び出し型を確認するために必要なほぼすべてのものを事前に計算します。つまり、オープンなデリゲートでは 2 つの間接呼び出し命令が必要なのに対し、クローズなデリゲートでは 1 つの間接呼び出し命令しか必要ありません。これは、.NET Framework や .NET Core で採用されているデリゲートの呼び出し方法と同じです。

これのおかげで、デリゲートの呼び出しは以前よりも高速になり、いくつかのターゲットとなるベンチマークでははるかに高速となる結果となりました。

Delegate Invocation Performance

不必要なボックス化のチェック

C# でのボックス化は、値型を System.Object 型のオブジェクトに変換する処理です。これは、マネージヒープ上のスペースの割り当てを伴うため、必ずしも迅速な処理ではありません。しかし、Unity 2021.2 では、IL2CPP のランタイムを高速化するために、いくつかのボックス化を削除して、この操作からより高いパフォーマンスを引き出しています。

実行時の nullable 型のボックス化は特定の方法で行う必要があり、ボックス化の操作ごとに与えられた型が nullable であるかどうかを判断しなければなりません。実行時に不要なチェックを省くことで、特に nullable やジェネリックを使うケースでのボックス化のパフォーマンスが向上しました。

Boxing performance graph

ジェネリック仮想メソッドの呼び出し

ジェネリック仮想メソッドは、C# に用意された表現力の高い機能ですが、効率的に実装することは困難です。直接メソッド呼び出しとは異なり、コンパイラーはビルド時にジェネリック仮想メソッドの呼び出しターゲットに関する情報をほとんど持たないため、実行時にターゲットを見つける必要があります。

Unity 2021.2 では、以下の例のように、ジェネリック仮想メソッドやインターフェースメソッドの呼び出しのパフォーマンスを 2 倍にしました。

interface Interface { T GetValue<T>(); } class Base : Interface { public virtual T GetValue<T>() { return default(T); } } class Derived : Base { public override T GetValue<T>() { return default(T); } } private Base obj = new Derived(); private Interface iface = obj; public void CallToVirtualGenericMemberFunction() { obj.GetValue<int>(); } public void CallToGenericInterfaceMemberFunction() { iface.GetValue<int>(); }
Generic Virtual Method Performance

Enum.HasFlag

[Flags] 属性を持つ C# の列挙型を便利に使って、可能性のあるさまざまなオプションの組み合わせを表現しましょう。[Flags] の付いた列挙型で指定された値をチェックするコードでは、しばしば Enum.HasFlag メソッドが使われます。Unity 2021.2 では、IL2CPP によってこのメソッドの呼び出しが強化され、100 倍以上の速度で実行されるようになりました。

たとえば、こんなベンチマークがあります。

public void CallToEnumHasFlag() { _enum.HasFlag(_flag); }

この生成されたコードから...

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void EnumHasFlag_CallToEnumHasFlag_m5819FE655D569D7AF856B879164E6416EFEFC30E (EnumHasFlag_t72757859AA4C348BBEE0A64FDBB747AFCFE326C2 * __this, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var); s_Il2CppMethodInitialized = true; }{ int32_t L_0 = __this->____enum_0; int32_t L_1 = L_0; RuntimeObject * L_2 = Box(MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var, &L_1); int32_t L_3 = __this->____flag_1; int32_t L_4 = L_3; RuntimeObject * L_5 = Box(MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var, &)L_4); NullCheck((Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E2 *)L_2); bool L_6;L_6 = Enum_HasFlag_m15293B523AA7BA15272699C7304E908106AD7F7B((Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E *)L_2, (Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E2 *)L_5, NULL) ; return; }}

...このコードが生成されます。

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void EnumHasFlag_CallToEnumHasFlag_m5819FE655D569D7AF856B879164E6416EFEFC30E (EnumHasFlag_t72757859AA4C348BBEE0A64FDBB747AFCFE326C2 * __this,const RuntimeMethod* method) { { int32_t L_0 = __this->____enum_0;int32_t L_1 = L_0; int32_t L_2 = __this->____flag_1; int32_t L_3 = L_2; bool L_4 = il2cpp_codegen_enum_has_flag(L_1, L_3) ; return; }}

不必要なボックス化や null チェックが削除されたことで、C++ コンパイラがコードを完全に最適化できるようになりました。この場合、マイクロベンチマークは 162 ナノ秒から 1.18 ナノ秒になりました。

Enum.HasFlag Performance graph

制約された呼び出し

制約された呼び出しも C# の便利な機能です。これにより開発者は、仮想メソッドの呼び出しなど、通常は高価と考えられている操作をはるかに低いコストで行うことができます。しかし、どうすればよいのでしょうか。 

この仕組みでは基本的に、特定の呼び出しが行われる方法についてランタイムに「ヒント」が提供されます。IL2CPP では、これらのヒントをより多く集め、コストの高い呼び出しをコストの低い直接呼び出しに変換します。  

そこで、皆さんのコードに次のような値型があるとします。

private struct SimpleValueType { }

また、System.Object の Equals 仮想メソッドを任意の型で呼び出すことができるジェネリックメソッドも用意されています。

private static void Equals<T>(T t) { t.Equals(null); }

すると、このベンチマークが...

public void CallToEqualsValueType() { Equals(_simpleValueType); }

...10 倍の速さになります。

これは、IL2CPP が、ここでは仮想呼び出しを行う必要がないと認識し、代わりに直接メソッド呼び出しを行うためです。ご覧のように、制約された呼び出しのベンチマークの多くで、大幅な改善が見られます。

Constrained Call performance graph

今すぐお試しください

他のパフォーマンス分析と同様に、スクリプトコードをより深く理解するために、プロファイルを行うことをお勧めします。ここでの改善は、すべてターゲットとするベンチマークに現れていますが、当然ながら、パフォーマンスの特性はプロジェクトごとに大きく異なります。 皆さんの体験をぜひお聞かせください。Unity フォーラムに参加して、プロジェクトのパフォーマンス分析、改善、開発中に直面した課題などを共有してください。

2022年2月17日 カテゴリ: テクノロジー | 7 分 で読めます
取り上げているトピック