📝

IL2CPPでもnew T()を高速化したい

2023/12/26に公開

本記事はUnity Advent Calendar 2023 シリーズ3 21日目の記事です。遅れてすみません、、
急ぎ足で端折っててるので、分かりにくいかもしれません。
こちらのライブラリの部分解説になります。
https://github.com/Akeit0/UniReflection

IL2CPPの制限

まずUnity IL2CPPはAOT(Ahead of time)であり、動的コード生成はできません、
すでにNativeなコードを動かすのでILをJITコンパイルすることもできません。
(Interpreterを実装すれば実行は可能)
ということでリフレクションをExpressionTreeやDynamicMethodでの高速化はできません。
しかし、一部は高速化可能です。

MethodInfoは簡単

MethodInfoはDelegate.CreateDelegateを用いてDelegateに変換可能で、得られたDelegateはIL2CPPでも高速に実行できます。

ConstructorInfoは簡単には行かない

しかし、ConstructorInfoDelegate.CreateDelegateできません。コンストラクタはインスタンスメソッドであり、new ()とは異なり、アロケートされたインスタンスのコンストラクトのみを行うため、扱いづらいせいなのかDelegate化関数が提供されていません。

アロケートされただけでコンストラクタの呼ばれていないオブジェクトは
FormatterServices.GetUninitializedObject(Type)で得られるのであとは
コンストラクタのデリゲートさえあれば、new T()と同等なので、惜しい、、。

でも実はConstructorInfoをDelegate化する方法はあります。
internalなメソッドとUnsafeクラスです。

mscorlibとCoreCLRで異なるので、環境依存にはなりますが、現時点(2023/12/26)でのUnityが用いているmscorlibではDelegate.CreateDelegate_internalという型チェックを行わないextern関数が存在します。そしてMethodInfoConstructorInfoもデリゲート化時に用いられるRuntimeMethodHandleの配置が同じです。

ということで手順を説明します。

  1. internal static extern Delegate CreateDelegate_internal(Type type,object target,MethodInfo info, bool throwOnBindFailure)
    をリフレクションで取得する。
  2. Delegate.CreateDelegateでデリゲート化Func<Type,object,MethodInfo,bool,Delegate>
  3. Unsafe.AsConstructorInfoMethodInfoとしてコンパイラをだます。
  4. Delegateの型はターゲットの型が参照型の場合はvoid Action(T instance, /*引数*/)、値型の場合はvoid Action(ref T instance, /*引数*/)となるようにする。
  5. 先ほどのデリゲートを呼び出す。createDelegate_internal(delegateType, null, construnctorInfo, true)

以上の手順でConstructorInfoをデリゲート化できました。
new T(args)の代わりとする場合

//参照型
Func<TArg,T> create=(arg)=>{
	var instance=(T)FormatterServices.GetUninitializedObject(type);
	constructorDelegate(instance,arg);
	return instance;
}
//値型
Func<TArg,T> create=(arg)=>{
	var instance=default(T);
	constructorDelegate(ref instance,arg);
	return instance;
}

IL2CPPで高速化

これでめでたしめでたし、、、、でもいいのですが、CreateDelegate_internalにリフレクションで呼び出すとかはやっぱり嫌ですし、オーバーヘッドももっと減らしたいです。

ということでさらにLow Levelに行きます。
FormatterServices.GetUninitializedObjectは内部では、internal externなActivationServicesAllocateUninitializedClassInstanceを呼び出しており、そこから先はRuntime依存です。
il2cppではTypeからil2cpp_class_from_type()IL2CPPClass*に変換し、Class::New(IL2CPPClass*)を行っています。

実は通常のnew でもClass::New(IL2CPPClass*)を用いており、これを使えれば、オーバーヘッドはなくなります。
でもその関数はC++の関数でアクセスできないと思われるでしょう?

実はできるのです。
NativePlugin開発でよく見る[DllImport("__Internal")]
これでil2cppのapiにアクセスできるのです!!!

[DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)]
public static extern Il2CppClass* il2cpp_class_from_type(Il2CppType* type);

[DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)]
public static extern Il2CppObject* il2cpp_object_new(Il2CppClass* klass);

リフレクションに必要な関数は大体揃っていて、私のライブラリのUniReflection.IL2CPP.Il2cppApiでで見ることができます。GCやコマンドライン、icallによるextern methodsを検索、追加できるなど特殊なこともできます。
ということで、アロケーションは最大限高速化できました。

次はConstructorですね。
先ほどちらっと言いましたが、ConstructorInfoにはRuntimeMethodHandleが入っており、
そこでGetFunctionPointerできると思った人はなかなかですね。さらにIL2CPPではこの関数がNotSupportedなことを知っていたら、もっとすごい。実はIL2CPPに限りRuntimeMethodHandleが関数ポインタそのものなことを知っていたらもっともっとすごい。

関数ポインタならCreateDelegateいらないし、パフォーマンスはDelegateよりちょっと速いくらいです。

ということで、最終的に出来上がったものを見ましょう。

var isValueType = targetType.IsValueType;
var klass = targetType.GetClassHandle();
Func<TArg,T> createInstance;
if (isValueType)
{
    var p = (delegate*<ref TInstance, TArg, void>)constructor.MethodHandle.Value;
    createInstance = (t0) =>
    {
        var t = default(TInstance);
        p(ref t, t0);
        return t;
    };
}
else
{
  //Il2CppObjectHandleはil2cppでのobjectの表現
    var p = (delegate*<Il2CppObjectHandle, TArg, void>)constructor.MethodHandle.Value;
    createInstance = (t0) =>
    {
        var o = Il2CppApi.il2cpp_object_new(klass);
        p(o, t0);
        return Unsafe.As<Il2CppObjectHandle, TInstance>(ref o);
    };
}

最後にもう一度パフォーマンスを見てみましょう。

ばっちりですね!!

最後に

SourceGeneratorによりリフレクション高速化の需要はそんなにはないかもしれませんが、ライブラリとして公開しでいます。

汎用SerializerやDIには使えるかも?
https://github.com/Akeit0/UniReflection

Discussion