3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

IL2CPPを今更どれだけ高速化されるのか見てみる

Last updated at Posted at 2023-05-12

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による差はあまりないので、今回は () によるキャストのみ採用しています。

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();

  1. オブジェクトにアクセスする
  2. 文字列に変換する
  3. 文字列を返す
    の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)

マクロで実装されているようで、なにやら色々ややこしそうですが簡単なので一つずつ見てみましょう。

  1. まずコメントにも記載のとおり if によるオーバーヘッドは一つしかないようです。
  2. 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の例外は投げられず、アプリケーションはクラッシュが発生することになります。

配列の範囲チェックを除外する

配列の範囲チェックを外すには Il2CppSetOptionAttributeOption.ArrayBoundsChecksfalse で提供するだけです。

[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チェックや範囲チェックを外すコンパイルオプションが提供されていれば、それを使いたい気持ちでもありますが...あるのかな?

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?