IL2CPPはUnity 5世代で初導入されオプションとして提供されて、それでも最初はWindowsのみサポートされ、その後、iOSでサポートされ、Unity 2017辺りでAndroidもサポートされるようになったと記憶しています。
そんなIL2CPPですがc/c++やAssemblerをパフォーマンスという観点からも、ゲームプログラマとして本気で歩んできた側からしたとき確信でもあり疑いもありました。
「本当に速いのか?」
この疑問がずっと脳裏にありました。今でもあります。
JITがそもそも、というところは一旦置いておいて、それでもC#などスクリプトによる開発効率は非常に高いので、UnityからのIL2CPPは非常に応援し続ける分野です。
今回はそのIL2CPPで「Null判定」「配列のOut of Range」と、制作するアルゴリズムと関係ないコード単体の高速化として向き合う3つのコードについて見ていきます。
本当は 「キャスト」 についても記載したいのですが、少し長くなりそうなので別の機会に。
ここで得られるもの
- c/c++を知らない人がC#が実際に何を実行しているのか見える。
- C#が開発効率を如何に上げてくれているかがわかる。
- c/c++が好きな人はジレンマが強くなる。かも。
検証環境
Unity | 2021.3.16f1 |
Api Compatibility Level | .NET Standard 2.1 |
Script Call Optimization | Fast but no Exceptions |
Target Platform | iOS |
上記の環境でBuildしてひたすらXcodeからビルドされたc++コードを確認していきます
本日のレシピ
- 簡単なC#コードのc++をトレースしてみる
- それでも最適化したいなら
簡単なC#コードのc++をトレースしてみる
以下の確認用 TestIL2CPP
クラスを作成しました。
namespace Sample
{
class TestIL2CPP
{
private class EmptyClass {}
static int OutOfRange(int[] array)
{
return array[0];
}
static string Null(object obj)
{
return obj.ToString();
}
}
}
- OutOfRangeメソッド : 配列の範囲チェックがc++ではどうなるか見るためのメソッドです。
- Nullメソッド : Null判定がc++ではどうなるか見るためのメソッドです。
-
Castメソッド : Cast処理がc++ではどうなるか見るためのメソッドです。
- C#のキャストは
()
によるものとas
を利用するものの2つしかなく、IL2CPPによる差はあまりないので、今回は()
によるキャストのみ採用しています。
- C#のキャストは
Nullチェック
c/c++ではnullアクセスが発生したときの動作は「未定義の動作(undefined behaviour)」であり、具体的な動作は言語仕様として定められてないため予測できません。
一般的に、nullポインタに対しアクセスするとプログラムはクラッシュする可能性があります。これはメモリ保護違反やセグメンテーションフォルトなどのエラーが発生するためです。クラッシュが発生しない場合でも、不正なデータの読み書きが行われる可能性があり、予期せぬ結果やバグに繋がる原因となることがあります。
前置きはここまでにし、用意した TestIL2CPP.Null
メソッドをIL2CPPによって生成したc++コードを見ていきましょう。
// System.String Sample.TestIL2CPP::Null(System.Object)
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR String_t* TestIL2CPP_Null_mBD990A27696644A18347475488F1081A3EA11C8C (RuntimeObject* ___0_obj, const RuntimeMethod* method)
{
{
// return obj.ToString();
RuntimeObject* L_0 = ___0_obj;
NullCheck(L_0);
String_t* L_1;
L_1 = VirtualFuncInvoker0< String_t* >::Invoke(3 /* System.String System.Object::ToString() */, L_0);
return L_1;
}
}
前途したNullアクセスですが、C#ではNullオブジェクトにアクセスすると NullReferenceException 例外をスローします。実際にプログラムが、この例外を投げるにはオブジェクトがnullかを判定する必要があり、IL2CPPでは NullCheck(L_0);
の箇所でそれが行われています。
NullCheck
関数を見てみましょう。
inline void NullCheck(void* this_ptr)
{
if (this_ptr != NULL)
return;
il2cpp_codegen_raise_null_reference_exception();
}
予想通りの結果でしたか?単純に if
でnullを判定し、実際にnullであれば例外を投げるだけのシンプルなinline関数で実装されているようです。
では実際に当初の目的である「本当に速いのか?」の観点でトレースするとどうでしょうか。
C#では return obj.ToString();
と
- オブジェクトにアクセスする
- 文字列に変換する
- 文字列を返す
の3ステップを実行しているだけですが、実際にはオブジェクトにアクセスしようとするたびにif
が発生しオーバーヘッドが発生し4ステップの構成となっています。これはゲーム開発のような1フレーム内にパフォーマンス稼がないとならない場面では場所では大きすぎる問題です。
配列の範囲チェック
次に配列アクセスしている OutOfRange
メソッドのc++を見ていきましょう。
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR int32_t TestIL2CPP_OutOfRange_mA47FBDDAD789C2CA6D1D797430026023944D9D60 (Int32U5BU5D_t19C97395396A72ECAF310612F0760F165060314C* ___0_array, const RuntimeMethod* method)
{
{
// return array[0];
Int32U5BU5D_t19C97395396A72ECAF310612F0760F165060314C* L_0 = ___0_array;
NullCheck(L_0);
int32_t L_1 = 0;
int32_t L_2 = (L_0)->GetAt(static_cast<il2cpp_array_size_t>(L_1));
return L_2;
}
}
array
はオブジェクトなので、Nullメソッドと同様 NullCheck
関数によって if
のオーバーヘッドが発生していることがわかります。
次に実際に配列へアクセスしてそうな GetAt
メソッドの呼び出しを見てみましょう。
inline int32_t GetAt(il2cpp_array_size_t index) const
{
IL2CPP_ARRAY_BOUNDS_CHECK(index, (uint32_t)(this)->max_length);
return m_Items[index];
}
この GetAt
メソッドというのは int
配列が構造化されたものになっています。
IL2CPPでは配列系の大元で IL2CppArray
というエイリアスの RuntimeArray が用意されています。
この派生で
struct Int32U5BU5D_t19C97395396A72ECAF310612F0760F165060314C : public RuntimeArray
{
...
inline int32_t GetAt(il2cpp_array_size_t index) const
{
IL2CPP_ARRAY_BOUNDS_CHECK(index, (uint32_t)(this)->max_length);
return m_Items[index];
}
...
}
といった具合に配列も、その方の構造体として作成されている事がわかります。
逸れましたが、次に IL2CPP_ARRAY_BOUNDS_CHECK
を見る必要がありそうです
// Performance optimization as detailed here: http://blogs.msdn.com/b/clrcodegeneration/archive/2009/08/13/array-bounds-check-elimination-in-the-clr.aspx
// Since array size is a signed int32_t, a single unsigned check can be performed to determine if index is less than array size.
// Negative indices will map to a unsigned number greater than or equal to 2^31 which is larger than allowed for a valid array.
#define IL2CPP_ARRAY_BOUNDS_CHECK(index, length) \
do { \
if (((uint32_t)(index)) >= ((uint32_t)length)) il2cpp_codegen_raise_index_out_of_range_exception(); \
} while (0)
マクロで実装されているようで、なにやら色々ややこしそうですが簡単なので一つずつ見てみましょう。
- まずコメントにも記載のとおり
if
によるオーバーヘッドは一つしかないようです。 -
do {} while (0)
についてはc/c++で常套手段としてよく使われるマクロを単一の文として扱うためのトリックで、大抵のコンパイラで省いてくれるのでスルーしましょう。
という事から、ここでのオーバーヘッドはif
の一つになります。ですがNullCheck
のときと同様パフォーマンスが必要な場面では配列が多様されているような箇所が大概です。そのような場面ではたった一つのif
でもかなり高価です。
それでも最適化したいなら
でも安心してください、Unityにはこういった問題に対する回避策があります。
まず Unityのドキュメント に記載の通り Il2CppSetOptionAttribute.cs
を探し出し、Assets以下の任意のディレクトリに配置します。
そして Il2CppSetOptionAttribute
を使えるようになるので該当メソッドに提供していくだけです。
Nullチェックを除外する
以下がNullチェックを除外するオプションを付与したメソッドの実装になります。
メソッド名を変更してはいますが、実装は先程の Null
メソッドと同一です。
[Il2CppSetOption(Option.NullChecks, false)]
static string OmitNullCheck(object obj)
{
return obj.ToString();
}
さっそくc++コードを見てみましょう。
// System.String Sample.TestOptimizeIL2CPP::OmitNullCheck(System.Object)
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR String_t* TestOptimizeIL2CPP_OmitNullCheck_mCDA77106B6A304884E5052F8BB7D6FCA25679058 (RuntimeObject* ___0_obj, const RuntimeMethod* method)
{
{
// return obj.ToString();
RuntimeObject* L_0 = ___0_obj;
String_t* L_1;
L_1 = VirtualFuncInvoker0< String_t* >::Invoke(3 /* System.String System.Object::ToString() */, L_0);
return L_1;
}
}
しっかりと NullCheck
関数が除外されていることがわかり、それ以外は同一の実装となっています。ということはNullアクセスが無いと断言できる場合にはNullチェックを外すことが可能ということになり高速化を量ことができます。
その代わり前途した通り、NullReferenceExceptionの例外は投げられず、アプリケーションはクラッシュが発生することになります。
配列の範囲チェックを除外する
配列の範囲チェックを外すには Il2CppSetOptionAttribute
で Option.ArrayBoundsChecks
を false
で提供するだけです。
[Il2CppSetOption(Option.ArrayBoundsChecks, false)]
static int OmitOutOfRange(int[] array)
{
return array[0];
}
c++コードは以下になります。
// System.Int32 Sample.TestOptimizeIL2CPP::OmitOutOfRange(System.Int32[])
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR int32_t TestOptimizeIL2CPP_OmitOutOfRange_m5B0E545250A3C281C0AA3F459C3ED94B00454340 (Int32U5BU5D_t19C97395396A72ECAF310612F0760F165060314C* ___0_array, const RuntimeMethod* method)
{
{
// return array[0];
Int32U5BU5D_t19C97395396A72ECAF310612F0760F165060314C* L_0 = ___0_array;
NullCheck(L_0);
int32_t L_1 = 0;
int32_t L_2 = (L_0)->GetAtUnchecked(static_cast<il2cpp_array_size_t>(L_1));
return L_2;
}
}
GetAt
メソッドに変わり GetAtUnchecked
メソッドになっています。実装は如何になります。
inline int32_t GetAtUnchecked(il2cpp_array_size_t index) const
{
return m_Items[index];
}
綺麗に IL2CPP_ARRAY_BOUNDS_CHECK
がなくなり参照だけとなっています。 NullCheck
同様ですが、範囲外のアクセスが発生した場合、不正な値を参照することになるため予期せぬ結果や、最終的にはクラッシュに陥る可能性もでてきます。
ですが、範囲外のアクセスが発生しないと確定している場所においては有効な手段です。
まとめ
C#はプログラミングの敷居を下げてくれ、c/c++から入った人も高レベル言語として着手しやすい環境です。
c/c++をプロフェッショナルに扱ってきた人たちからすると、IL2CPPするまでもなく「きっとこんな実装かな?」とこのレベルのことは予想できている事だったりしそうですが、そういった観点があるからこそ Il2CppSetOptionAttribute
といったレベルの最適化までは視野に入れず、そういったものはパフォーマンスに多大な影響のでやすいグラフィックスパイプラインや物理エンジン周りはUnityや他ライブラリに任せ楽しくゲーム側を作り続けたい気持ちです。
そうでないとC#を使っているメリットから離れてしまっているように感じるからです。
そもそもIL2CPPのときに全てのNullチェックや範囲チェックを外すコンパイルオプションが提供されていれば、それを使いたい気持ちでもありますが...あるのかな?