はじめに
ほとんどの方が気にされていないかと思われますが、UnityのScripting Backendに使用するIL2CPPのオプションに関する内容です。 Unityの公式マニュアルではこちらでひっそりと記載があります。 docs.unity3d.com
オプションの種類と役割
オプションにはNull Check、Array bounds check、Divide by zero checksの3種類が存在します。 役割としては、nullアクセスチェック、配列の範囲外チェック、0除算チェックをそれぞれ行い、条件に一致した場合は例外を発生させます。
オプションの設定方法
Player Settings等で指定をする訳ではなく、Script内でIl2CppOptions属性を使用して指定する必要があります。
各オプションを有効にする場合
[Il2CppSetOption(Option.NullChecks, true)] [Il2CppSetOption(Option.ArrayBoundsChecks,true)] [Il2CppSetOption(Option.DivideByZeroChecks, true)]
各オプションを無効にする場合
[Il2CppSetOption(Option.NullChecks, false)] [Il2CppSetOption(Option.ArrayBoundsChecks,false)] [Il2CppSetOption(Option.DivideByZeroChecks, false)]
この属性をClassやメソッドといったスコープ単位で指定します。 例えば、HogeというClassとしてはNullCheckを無効にするが、Funcというメソッド内だけではNullCheckを有効にするといった場合は下記のようになります。
[Il2CppSetOption(Option.NullChecks, false)] public class Hoge { [Il2CppSetOption(Option.NullChecks, false)] public void Func() { ... } }
Il2CppSetOptionAttribute
及びOption
はIL2CppSetOptionAttribute.cs内で定義されています。このファイルはUnity Editorがインストールされているディレクトリの直下にある IL2CPPディレクトリ内に存在します。このファイルをAssets以下へコピーして使用して下さい。
検証結果
Pixel3XLにて単純なNULL比較、配列アクセス、整数除算をそれぞれ100万回行った結果を記載します。 100万回実行するとかなりのインパクトです。実際に1フレーム中100万はないですが、数千~1万は無いとは言えないスケールの話しではあります。 試行回数の多いLoop処理を行う場合、スコープ内の処理が保証出来るのであれば、そのスコープ内の処理のみチェックを無効化するという作戦が良さそうです。
有効 | 無効 | |
---|---|---|
Null Checks | 4.196[ms] | 2.784[ms] |
Array Bounds Check | 3.276[ms] | 1.384[ms] |
Div Zero Check | 1.384[ms] | 0 |
・・・
ここまでは公式にも載っている情報を単にまとめただけ。 既に他のUnity関連の技術Blogでも紹介されている可能性は極めて高いです。 しかしながらこのBlogはもう少し突っ込んだというか少しマニアック向けのBlogなのでここからが本番です。
具体的に何をしているのか
今回0除算の評価に使用したテストコードは下記の通りです。
int DiveByZeroChecks() { int c = 0; for (var i = 0; i < counter; i++) { c = counter / (i + 1); } return c; }
これがIL2CPPによってC++のコードに変化された結果下記のようになります。 IL2CPPによって変換されたC++のコードは{Project}\Temp\StagingArea\Il2Cpp\il2cppOutput以下にBulk_Assembly-CSharp_*.cpp(*は0から始まる連続する数値)というファイル名で出力されています。 Null Checks有効時:
extern "C" IL2CPP_METHOD_ATTR int32_t CompileOptionTest_DiveByZeroChecks_mAC47EACB46C7CEA431286B0C2440507CFEF94654 (CompileOptionTest_tB04A85FBFCEDB97BE0FB46F60F0BF59147D48D04 * __this, const RuntimeMethod* method)extern "C" IL2CPP_METHOD_ATTR int32_t CompileOptionTest_DiveByZeroChecksTrue_mAC47EACB46C7CEA431286B0C2440507CFEF94654 (CompileOptionTest_tB04A85FBFCEDB97BE0FB46F60F0BF59147D48D04 * __this, const RuntimeMethod* method) { int32_t V_0 = 0; int32_t V_1 = 0; { V_0 = 0; V_1 = 0; goto IL_0015; } IL_0006: { int32_t L_0 = __this->get_counter_7(); int32_t L_1 = V_1; DivideByZeroCheck(((int32_t)il2cpp_codegen_add((int32_t)L_1, (int32_t)1))); V_0 = ((int32_t)((int32_t)L_0/(int32_t)((int32_t)il2cpp_codegen_add((int32_t)L_1, (int32_t)1)))); int32_t L_2 = V_1; V_1 = ((int32_t)il2cpp_codegen_add((int32_t)L_2, (int32_t)1)); } IL_0015: { int32_t L_3 = V_1; int32_t L_4 = __this->get_counter_7(); if ((((int32_t)L_3) < ((int32_t)L_4))) { goto IL_0006; } } { int32_t L_5 = V_0; return L_5; } }
Null Checks無効時:
extern "C" IL2CPP_METHOD_ATTR int32_t CompileOptionTest_DiveByZeroChecks_mAC47EACB46C7CEA431286B0C2440507CFEF94654 (CompileOptionTest_tB04A85FBFCEDB97BE0FB46F60F0BF59147D48D04 * __this, const RuntimeMethod* method)extern "C" IL2CPP_METHOD_ATTR int32_t CompileOptionTest_DiveByZeroChecksTrue_mAC47EACB46C7CEA431286B0C2440507CFEF94654 (CompileOptionTest_tB04A85FBFCEDB97BE0FB46F60F0BF59147D48D04 * __this, const RuntimeMethod* method) { int32_t V_0 = 0; int32_t V_1 = 0; { V_0 = 0; V_1 = 0; goto IL_0015; } IL_0006: { int32_t L_0 = __this->get_counter_7(); int32_t L_1 = V_1; V_0 = ((int32_t)((int32_t)L_0/(int32_t)((int32_t)il2cpp_codegen_add((int32_t)L_1, (int32_t)1)))); int32_t L_2 = V_1; V_1 = ((int32_t)il2cpp_codegen_add((int32_t)L_2, (int32_t)1)); } IL_0015: { int32_t L_3 = V_1; int32_t L_4 = __this->get_counter_7(); if ((((int32_t)L_3) < ((int32_t)L_4))) { goto IL_0006; } } { int32_t L_5 = V_0; return L_5; } }
違いはDivideByZeroCheck()
という関数があるかないかの違いのみです。
同様にNullCheck有効時はNullCheck()
、配列範囲外チェックの場合は配列の要素にアクセスする関数がGetAtUnchecked()/SetAtUnchecked()
とGetAt()/SetAt()
いうメソッドに分かれており、GetAt()/SetAt()
の内部ではIL2CPP_ARRAY_BOUNDS_CHECK()
というマクロが実行されていることが確認出来ます。
これらのマクロはどこで定義されているかというと、
{UnityEditor}\Data\il2cpp\libil2cpp\codegen\il2cpp-codegen.h内で下記のように定義されていました。
inline void DivideByZeroCheck(int64_t denominator) { if (denominator != 0) return; il2cpp_codegen_raise_divide_by_zero_exception(); } inline void NullCheck(void* this_ptr) { if (this_ptr != NULL) return; il2cpp_codegen_raise_null_reference_exception(); } // 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_exception (il2cpp_codegen_get_index_out_of_range_exception()); \ } while (0)
処理の内容としては、if分1個かまして条件にマッチしたら例外を飛ばすといった、最低限のコストではありますが、毎回実行されるとなるとそのコストがもったいないと感じる方も居るでしょう・・・。
デフォルトの挙動を変えたい
Unityのマニュアルによるとデフォルトの挙動としては、Null Check、Array bounds checkが有効、Divide by zero checksが無効となっています。 これはNullアクセスや配列範囲外に書き込み等された場合、その瞬間に止まらないと、後の原因不明の不具合に発展する為、Unityの親心としてこのような設定にしているのだと思いますが、合えて初期値はあえて無効にしたいという開発者の方もいると思います。
IL2CPPのオプションを確認する
先ずはIL2CPP.EXEに渡されるコマンドライン引数を確認します。 確認する為のTOOLとして、Microsoftから提供されているProcessMonitorというツールを使用します。 docs.microsoft.com
調査方法は下記の通りです。
※clangは今回は関係ないですが、まあおいおい。
- ProcessMonitorを起動したらFilterからキーワードを登録します。
- ProcessMonitorのキャプチャーを開始します。
- Unityからプロジェクトをビルドを行います。
- ビルド完了後キャプチャを停止します。 5.キャプチャー結果からProcessNameがil2cpp.exeの内容を検索します。
il2cpp.exeに渡したコマンドラインの引数が下記のようになっていることが確認出来ます。 既存アプリの動作をこんなに詳しく知ることが出来るToolに少しドキドキしてしまいますが、そこはMicrosoft純正、なんの問題もありません。
文字列の内容から察するに--emit-null-checksと --enable-array-bounds-check がNull Checkと配列範囲外チェックを有効にするオプションのようです。 0除算チェックに該当すると思われるオプションは見当たらない為、この二つのオプションを取り除けばデフォルトの挙動を無効化出来そうです。 残念ながらUnityEditorにはこちらを制御するオプションはありません。 アイデアとしては、il2cpp.exeという名前のアプリを作成して、本物はil2cpp.exe等へリネーム。 偽物のil2cpp.exeの動作は渡された引数から--emit-null-checksと --enable-array-bounds-check を取り除いてからil2cpp.exeに渡すというフックアプリを作成すればなんとかなりそうですが、雲行きが怪しくなってきたのでこれ以上深くは言及しません。 またチェックを無効化する単純な方法としてはil2cpp-codegen.hから該当関数の中身を空にしてしまう方法もありますが、これは無効化でありデフォルトの挙動を変えるではないので良い手とは言えなさそうです。
最後に
繰り返しになりますが、実際にnullアクセスが発生したらクラッシュするかは時の運、配列の範囲外への書き込みは謎のデータ破損に繋がります。 チェックを無効化したのを忘れて「原因不明のクラッシュが・・・・」とならないようにくれぐれもご注意下さい。