LoginSignup
24
23

More than 5 years have passed since last update.

ILの暗黒の魔術 第2章「Unity反撃!●された静的委譲!」

Posted at

初投稿です(大嘘)。

ufcppによるとマルチキャストが不要な静的メソッドに対してデリゲート的なことをしようとする場合デリゲートはオーバースペックなのですね。
ですがシングルキャストなデリゲートはC#ではサポートされていません。
メソッドから関数ポインタを取得するldftn命令を直接出力したり、関数ポインタをメソッドとして使用するcalli命令を直接出力したりできないからです。

C#でできないことだってできる。そう、ILならね。 という感じでILを使用してシングルキャスト静的デリゲートを扱えるようにしてみました。

第0節 Im IL nichts Neues/IL戦線異状なし

C#はRoslynコンパイラによってコンパイルされると中間言語(Intermediate Language)にコンパイルされてDLLやEXEファイルになります。
ILは実行環境でJITコンパイルされて機械語に翻訳されて実行されます。

C:\Windows\Microsoft.Net\Framework64\v4.0.30319\ilasm.exeをコマンドラインから使用して.ilファイルをコンパイルすると.dllファイルを作ることが可能です。
この時に.projファイルは不要です。

ILの書き方はLINQPad5ILSpyを使用して学ぶのが最適でしょう。
学び方は @neuecc さんの記事を参考にされると良いでしょう。
なお、calliとldftnは殆どIL中にも出てこない命令ですので上記ソフトで使用法を学習できませんでした。

第1節 Anders Got His Document/アンダァスは仕様書を読んだ

ILについてのECMAの仕様書を読んでldftnとcalliについての概略を把握しました。 calliについて検索すると「calling」というワードも引っかかるのが面倒でしたね。

calliについての仕様書の記述

Format Assembly Format Description
29 calli callsitedescr Call method indicated on the stack with arguments described by callsitedescr.

Stack Transition:
…, arg0, arg1 … argN, ftn → …, retVal (not always returned)

Description:
The calli instruction calls ftn (a pointer to a method entry point) with the arguments arg0 … argN.
The types of these arguments are described by the signature callsitedescr. (See Partition I for a description of the CIL calling sequence.) The calli instruction can be immediately preceded by a tail. prefix to specify that the current method state should be released before transferring control. If the call would transfer control to a method of higher trust than the originating method the stack frame will not be released; instead, the execution will continue silently as if the tail. prefix had not been supplied.
[A callee of “higher trust” is defined as one whose permission grant-set is a strict superset of the grant-set of the caller.]
The ftn argument must be a method pointer to a method that can be legitimately called with the arguments described by callsitedescr (a metadata token for a stand-alone signature). Such a pointer can be created using the ldftn or ldvirtftn instructions, or could have been passed in from native code.
The standalone signature specifies the number and type of parameters being passed, as well as the calling convention (See Partition II). The calling convention is not checked dynamically, so code that uses a calli instruction will not work correctly if the destination does not actually use the specified calling convention.
The arguments are placed on the stack in left-to-right order. That is, the first argument is computed and placed on the stack, then the second argument, and so on. The argument-building code sequence for an instance or virtual method shall push that instance reference (the this pointer, which shall not be null) first. [Note: for calls to methods on value types, the this pointer is a managed pointer, not an instance reference. §I.8.6.1.5. end note]
The arguments are passed as though by implicit starg (§III.3.61) instructions, see Implicit argument coercion §III.1.6.
calli pops the this pointer, if any, and the arguments off the evaluation stack before calling the method. If the method has a return value, it is pushed on the stack upon method completion. On the callee side, the arg0 parameter/this pointer is accessed as argument 0, arg1 as argument 1, and so on.

自家翻訳

calli命令はarg0からargNの与えられた引数を用いてftn(メソッドエントリポイントを指す関数ポインタ)の位置にあるメソッドを実行する。
引数の型はシグネチャcallsitedescrによって示される。(CILのcalling sequenceの説明については第一編を見よ。)
このcalli命令によって使用される関数ポインタはldftnあるいはldvirtftn命令で得られるか、あるいはネイティブコードから受け渡される。
Standaloneシグネチャは渡される引数の個数と型とともに呼び出し規約(第二編を見よ)を詳らかにする。呼び出し規約は動的(実行時)には検証されないため、呼び出し先の関数の呼び出し規約が記述されたそれでない場合、正しく動作しない。
引数は左から右順でスタック上に積まれる。これは、第一引数が先に計算されてスタックに積まれ、次に第二引数という風に続くということである。インスタンスメソッドあるいは仮想メソッドのための引数にバインドされるコード列ならばインスタンスへの参照(this参照のことであり、nullを許容しない)を第零引数として置く。*注:値型のインスタンスメソッドの場合はthisはマネージドポインタであり、インスタンスへの参照ではない。
引数は暗黙的に使用されるstarg命令によってメソッドに渡される。
calliは関数ポインタをpopし、もし引数があるのであれば、関数を呼び出す前に評価スタックから引数をpopする。もしメソッドが戻り値を返すのであればメソッド完了時に戻り値がスタック上にpushされる。呼び出される側において、第零引数はargument 0、第一引数はargument 1という風にして使用可能である。

callsitedescrをどう書けばいいのかわかりませんね。

ldftnについての仕様書の記述

Format Assembly Format Description
FE 06 ldftn method Push a pointer to a method referenced by method, on the stack.

Stack Transition:
… → …, ftn

Description:
The ldftn instruction pushes a method pointer (§II.14.5) to the native code implementing the method described by method (a metadata token, either a methoddef or methodref (see Partition II)), or to some other implementation-specific description of method (see Note) onto the stack).
The value pushed can be called using the calli instruction if it references a managed method (or a stub that transitions from managed to unmanaged code). It may also be used to construct a delegate, stored in a variable, etc.
The CLI resolves the method pointer according to the rules specified in §I.12.4.1.3 (Computed destinations), except that the destination is computed with respect to the class specified by method.
The value returned points to native code (see Note) using the calling convention specified by method. Thus a method pointer can be passed to unmanaged native code (e.g., as a callback routine).
Note that the address computed by this instruction can be to a thunk produced specially for this purpose (for example, to re-enter the CIL interpreter when a native version of the method isn’t available).
[Note: There are many options for implementing this instruction. Conceptually, this instruction places on the virtual machine’s evaluation stack a representation of the address of the method specified. In terms of native code this can be an address (as specified), a data structure that contains the address, or any value that can be used to compute the address, depending on the architecture of the underlying machine, the native calling conventions, and the implementation technology of the VES (JIT, interpreter, threaded code, etc.). end note]

自家翻訳

ldftn命令はmethoddefあるいはmethodref(第二編を見よ)なメタデータトークンによって記述された、メソッド実行部分のネイティブコードへの関数ポインタをpushする。
スタック上にpushされた値は、もしその値がマネージドコードを参照(あるいはマネージド領域からアンマネージ領域へ移行するためのスタブ)ならばcalli命令によって使用されうる。
この値は変数に格納されたり、あるいはデリゲートを作成したり、その他なにかに使用される可能性もあるかもしれない(低い)。
CLIはメソッドで指定されたクラスに関して計算されたdestinationを除いて、第一編十二章四節一項三款に詳述された規則に従い関数ポインタを解決する。
戻り値の値はメソッドに指定された呼び出し規約を使用するネイティブコードを指す。これ故に関数ポインタはアンマネージドネイティブコードに渡されうる。(例:コールバックルーチン)
注意すべきことに、この命令によって算出されたアドレスはこの目的のために特別に用意されたthunkでありうる。たとえばネイティブ版のメソッドをCILインタプリタに再代入することは不可能である。
*注:この命令の実装方法は多岐にわたる。概念的には、この命令は仮想マシンの評価スタックに特定メソッドのアドレスを表すものをpushする。ネイティブコードならばそれはアドレスや、アドレスを含む構造体、あるいはアドレスを算出しうるなんらかの値であり、具体的には基底に存在する現実の計算機の構造、ネイティブの呼び出し規約、VESの実装に依存する。

第2節 Wikiapocalypse Now/ウィキペディアの黙示録

calliの使用例を見ないと書きようがないとわかった私は試しにwikipedia英語版を開いてみました。
使用例がありました(完全勝利した淫夢くんUC)。
サンプルコードについては上記リンク先を参照してみてください。

結論から書きますが上記サンプルは私を激しく混乱させるだけに終わりました。

IL_001d:   calli      unmanaged stdcall void modopt([mscorlib]System.Runtime.CompilerServices.CallConvStdcall)(native int)

このコード片のIL_0001d:はラベルです。ハイ、C#ではswitch文のcaseで使用されるラベルです。あるいはgoto文の行き先を意味するラベルです。
LINQPadでC#をILに変換し、それをさらにILSpyで完全版を確認すると必ず全IL命令のある行にIL_00xx:みたいな表記をされているのでCOBOLとかのように必須だと思い込んでいました。
別になくてもいいのです。
別になくてもいいのです(強調)。

さらに上記サンプルはC++/CLIでネイティブコードを表現するためのものでしてね……

第3節 Lightning Strike Document re-Launch/雷撃仕様書再出動

やはり基本となるべきものは仕様書です。基本に立ち返ってサンプルを探しましょう。
197ページからのサンプルが完全に正しかったです。
なぜ、最初見落としていたのかですって?
無視していました。IL_00xx:のようなラベルがないのでこれは実際には動かない擬似コードだろうと思いこんでいました。
LINQPad5のILデコンパイルで表示されるILは厳密には擬似コードなので、そう思い込んでしまいました。

.assembly Test { }
.assembly extern mscorlib { }
.method public static int32 AddOne(int32 Input)
{
 .maxstack 5
 ldarg Input
 ldc.i4.1
 add
 ret
}
.method public static int32 Negate(int32 Input)
{
 .maxstack 5
 ldarg Input
 neg
 ret
}
.class value sealed public MakeDecision extends [mscorlib]System.ValueType
{
 .field static bool Oscillate
 .method public static method int32 *(int32) Decide()
 {
   ldsfld bool valuetype MakeDecision::Oscillate
   dup
   not
   stsfld bool valuetype MakeDecision::Oscillate
   brfalse NegateIt
   ldftn int32 AddOne(int32)
   ret
  NegateIt:
   ldftn int32 Negate(int32)
   ret
 }
}
.method public static void Start()
{
 .maxstack 2
 .entrypoint
 ldc.i4.1
 call method int32 *(int32) valuetype MakeDecision::Decide()
 calli int32(int32)
 call void [mscorlib]System.Console::WriteLine(int32)
 ldc.i4.1
 call method int32 *(int32) valuetype MakeDecision::Decide()
 calli int32(int32)
 call void [mscorlib]System.Console::WriteLine(int32)
 ldc.i4.1
 call method int32 *(int32) valuetype MakeDecision::Decide()
 calli int32(int32)
 call void [mscorlib]System.Console::WriteLine(int32)
 ret
}

ILを全て書く場合、そのDLLが所属するアセンブリ名をファイルの先頭に.assembly アセンブリ名と書きます。
そしてこのILが参照する全てのDLLというかアセンブリの名前を.assembly extern 参照アセンブリ名として記述します。
mscorlibに基本的なAPIが集約されています。

AddOne, Negate解説

AddOneとNegateはC#で表記すると以下のようになります。萌ポイントとしてはneg命令によって正負反転が一命令で実現されていることですね。

public static int AddOne(int Input) => Input + 1;
public static int Negate(int Input) => -Input;

MakeDecision構造体解説

MakeDecisionは構造体です。.class value sealedと extends [mscorlib]System.ValueTypeによってそう指定されています。
.field static bool Oscillateはprivateなbool型のstaticフィールドの定義です。
C#では同一文で複数のフィールドを定義できますが、ILでは1文につき1フィールド定義します。
MakeDecision.DecideについてC#で擬似コードを書いてみましょう。

public static Func<int, int> Decide()
{
  if(Oscillate)
  {
    Oscillate != Oscillate;
    return AddOne;
  }
  else
  {
    Oscillate != Oscillate;
    return Negate;
  }
}

初出命令の解説

  • dup
    • スタックの一番上にある値を複製してpush
  • ldsfld/stsfld
    • 静的フィールドをスタック上にloadあるいはスタック上から格納(store)
  • brfalse
    • スタックの一番上の値をpopして評価し、falseならばラベル位置へ処理をgoto

最適化されたILコードを見ると結構な頻度でローカル変数が消滅したりします。具体的には一つのインスタンスを連続で使い倒す場合ですね。この場合必要数だけ最初にdupしまくることでローカル変数を省いています。IL書くようになるとdupを上手に使いたくなります。

ちなみにC#擬似コードで戻り値の型をFunc<int, int>と書いていますが、実際に戻っているのは関数ポインタです。

Start解説

疑似C#コードは以下の通りです。

public static void Start()
{
   System.Console.WriteLine(MakeDecision.Decide()(1)); // -1
   System.Console.WriteLine(MakeDecision.Decide()(1)); //  2
   System.Console.WriteLine(MakeDecision.Decide()(1)); // -1
}

試しにilasm.exeで動かせば動きました。
いやぁ、ラベルって各行に必須なものではなかったのですね(遠い目)。

第4節 Happy Hanukkah, Mr. Albahari/総称型のハッピーハヌカ

前節でcalliでCLIのマネージコードを呼び出す際の書式がわかりました。
int32(int32)のように戻り値の型(引数の型, ...)ですね。

使い方がわかったとしてもライブラリにするためには総称型・ジェネリクスでないと使い勝手が悪いですよね。
ジェネリクスに対応させましょう。
ここでLINQPad5とILSpyに以下のコードをIL化してもらいます。

public static class Z
{
    public static T A(this T obj0) => obj0;
}
.class public auto ansi abstract sealed beforefieldinit Z extends [mscorlib]System.Object
{
    .method public hidebysig static !!T A<T>(!!T obj0) cil managed
    {
        .custom instance void [mscorlib]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = (01 00 00 00)
        .maxstack 1
        ldarg.0
        ret
    }
}

このILコードで注目していただきたいのは、メソッドシグネチャを定義している部分です。
!!T A<T>(!!T obj0)と書いてありますよね。<>の間にジェネリクスの型パラメータの名前を書きます。
そしてその型パラメータを他のコードで使用する場合、!!というprefixをつけます。
簡単ですね!

ところで、C#のwhere以下に記述される型制約も<>の間に記述されることになります。
この例としてはC# 7.3で導入された最高にクールなunmanaged制約がわかりやすいでしょう。
T A<T>() where T : unmanagedは
!!T A<valuetype .ctor (class [mscorlib]System.ValueType modreq([mscorlib]System.Runtime.InteropServices.UnmanagedType)) T>() cil managed
となります。
modereqとは呼び出し側が無視してはならない条件という意味合いですので、必須の型制約なのですね。

また、C#の拡張メソッドをILで記述するにはどうすればよいでしょうか?
メソッドの最初の行とその静的クラスの最初の行に.custom instance void [mscorlib]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = (01 00 00 00)と記述するのです。
そうすることでそのクラスとメソッドが拡張クラス・メソッドであると.NETに伝えられます。

ref引数及びref戻り値

型の後ろに&とつけるだけで参照扱いされます。!!T&のような感じです。

参照型と値型に対してそれぞれフィールド取得などで命令に細かい差異が生じますが、今回の記事ではその辺の知識は不要なので説明しません。
学びたいのであればECMAの仕様書に当たると良いですよ。

第5節 The Bridge on The River Ldftn/関数ポインタにかける橋

calliによって関数ポインタを駆動するための知識は前節までで得られました。

では、どうやって関数ポインタを得ましょうか?
ldftnでは引数に関数の情報を取ります。これはコード中にべた書きする他ありません。
ですが、ライブラリのユーザーはILではなく、C#を使う想定です。

悩んでいた所ちょうど良さそうな.NETのAPIが見つかりました。

System.Runtime.InteropServices.Marshal.GetFunctionPointerForDelegate(System.Delegate)

そのまんまな名前ですね。

私は喜び勇んでこのAPIを使用して得た関数ポインタをcalliに渡してみました。

「ガッシ!ボカッ!」
.NET Coreは死んだ。スイーツ(笑)

.NET Coreの新たなクラッシュ方法を私は学びました。
開発サイクルを高速に回すためにまず.NET Coreで動作をテストしています。Unityだとコンパイルと実行でそこそこ時間がかかりますからね。

IL部・DynamicMethodの裏技

前の章で解説したテクニックを使用します。
具体的には.NET 4.xのmscorlibをILSpyで覗いてみればわかるのですが、System.Delegateクラスのprivateフィールドに2つIntPtr型があるのです。
この2つ(_methodPtrと_methodPtrAux)のうちのどちらかがldftnで得られるメソッド本体を指す関数ポインタであると容易に想定されます。なぜ2つIntPtr型を用意しているのかよくわかりませんが。

これはC#で書ける内容ですので以下のように書いて、それをLINQPad5→ILSpyのコンボでIL化してilasm.exeでDLL化しました。

using System;
using System.Reflection;
using System.Reflection.Emit;
using System.Collections.Generic;

public static class StaticDelegateHelper
{
    public static readonly Func<Delegate, IntPtr> GetFuncPtr;
    static StaticDelegateHelper()
    {
        DynamicMethod method = new DynamicMethod("GetFuncPtr", MethodAttributes.Public | MethodAttributes.Static, CallingConventions.Standard, typeof(IntPtr), new Type[] { typeof(Delegate) }, typeof(Delegate), true);
        ILGenerator ilgen = method.GetILGenerator();
        ilgen.Emit(OpCodes.Ldarg_0); // 第0引数について
        ilgen.Emit(OpCodes.Ldfld, typeof(Delegate).GetField("_methodPtr", BindingFlags.NonPublic | BindingFlags.Instance));
        ilgen.Emit(OpCodes.Ret); // 戻り値とせよ
        GetFuncPtr = (Func<Delegate, IntPtr>)method.CreateDelegate(typeof(Func<Delegate, IntPtr>));
    }
}

これをIL化したコードを一応見てみましょう。

.class nested public auto ansi abstract sealed StaticDelegateHelper extends [mscorlib]System.Object
{
  // Fields
  .field public static initonly class [mscorlib]System.Func`2<class [mscorlib]System.Delegate, native int> GetFuncPtr

  // Methods
  .method private hidebysig specialname rtspecialname static void .cctor () cil managed 
  {
    // Method begins at RVA 0x205c
    // Code size 139 (0x8b)
    .maxstack 8

    ldstr "GetFuncPtr"
    ldc.i4.s 22
    ldc.i4.1
    ldtoken [mscorlib]System.IntPtr
    call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)
    ldc.i4.1
    newarr [mscorlib]System.Type
    dup
    ldc.i4.0
    ldtoken [mscorlib]System.Delegate
    call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)
    stelem.ref
    ldtoken [mscorlib]System.Delegate
    call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)
    ldc.i4.1
    newobj instance void [mscorlib]System.Reflection.Emit.DynamicMethod::.ctor(string, valuetype [mscorlib]System.Reflection.MethodAttributes, valuetype [mscorlib]System.Reflection.CallingConventions, class [mscorlib]System.Type, class [mscorlib]System.Type[], class [mscorlib]System.Type, bool)
    dup
    callvirt instance class [mscorlib]System.Reflection.Emit.ILGenerator [mscorlib]System.Reflection.Emit.DynamicMethod::GetILGenerator()
    dup
    ldsfld valuetype [mscorlib]System.Reflection.Emit.OpCode [mscorlib]System.Reflection.Emit.OpCodes::Ldarg_0
    callvirt instance void [mscorlib]System.Reflection.Emit.ILGenerator::Emit(valuetype [mscorlib]System.Reflection.Emit.OpCode)
    dup
    ldsfld valuetype [mscorlib]System.Reflection.Emit.OpCode [mscorlib]System.Reflection.Emit.OpCodes::Ldfld
    ldtoken [mscorlib]System.Delegate
    call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)
    ldstr "_methodPtr"
    ldc.i4.s 36
    callvirt instance class [mscorlib]System.Reflection.FieldInfo [mscorlib]System.Type::GetField(string, valuetype [mscorlib]System.Reflection.BindingFlags)
    callvirt instance void [mscorlib]System.Reflection.Emit.ILGenerator::Emit(valuetype [mscorlib]System.Reflection.Emit.OpCode, class [mscorlib]System.Reflection.FieldInfo)
    ldsfld valuetype [mscorlib]System.Reflection.Emit.OpCode [mscorlib]System.Reflection.Emit.OpCodes::Ret
    callvirt instance void [mscorlib]System.Reflection.Emit.ILGenerator::Emit(valuetype [mscorlib]System.Reflection.Emit.OpCode)
    ldtoken class [mscorlib]System.Func`2<class [mscorlib]System.Delegate, native int>
    call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)
    callvirt instance class [mscorlib]System.Delegate [mscorlib]System.Reflection.MethodInfo::CreateDelegate(class [mscorlib]System.Type)
    castclass class [mscorlib]System.Func`2<class [mscorlib]System.Delegate, native int>
    stsfld class [mscorlib]System.Func`2<class [mscorlib]System.Delegate, native int> StaticDelegateHelper::GetFuncPtr
    ret
  } // end of method StaticDelegateHelper::.cctor
} // end of class StaticDelegateHelper

メソッドの中身について語ることは特にないです。
補足すべき点としては、readonlyはinitonlyと記述されます。
そして静的コンストラクタは.method private hidebysig specialname rtspecialname static void .cctor () cil managedと書かれます。

_methodPtrで試したところ、.NET Coreがクラッシュしました。
_methodPtrAuxで試したところ、動きました(完全勝利した淫夢くんUC)。

性能測定

元々シングルキャスト静的デリゲートはマルチキャストなデリゲートよりも高速に動作するはずと聞いたのでそれを利用としたのでした。
では、どれだけ高速化したのか、測定しましょう!

using System;
using StaticDelegate;
using System.Diagnostics;
static class Program
{
    static Guid CALC() => Guid.NewGuid();
    [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveOptimization)]
    static void Main(string[] args)
    {
        const int V = 50000000;
        Stopwatch watch = new Stopwatch();
        var funcDelegate = new Func<Guid>(CALC);
        IntPtr funcPtr = funcDelegate.GetFuncPtr();
        watch.Start();
        for (int i = 0; i < V; i++)
            funcPtr.Call<Guid>();
        watch.Stop();
        System.Console.WriteLine(watch.ElapsedMilliseconds);
        watch.Reset();
        watch.Start();
        for (int i = 0; i < V; i++)
            funcDelegate();
        watch.Stop();
        System.Console.WriteLine(watch.ElapsedMilliseconds);
        watch.Reset();
    }
}

上記コードは5千万回デリゲートを生成します。
その結果は次の通りです。

Static Delegate Old Delegate
6112ms 6138ms

圧倒的僅差ッ! 意味がナイッ!

インライン化

C#で高速化するためのお手軽な属性をご存知でしょうか?
それはSystem.Runtime.CompilerServices.MethodImplAttribute(MethodImpleOptions.AggressiveInlining)です。
これをつけると関数はわりとインライン化されやすくなります。
インライン化されると何が嬉しいのかですって? それはこちらの記事をご覧ください。

IL的には次のように書けば[System.Runtime.CompilerServices.MethodImplAttribute(MethodImpleOptions.AggressiveInlining)]と同じ働きになります。

.method public hidebysig static !!T Calc<T>(native int funcPtr) cil managed aggressiveinlining

cil managed の後にaggressiveinliningとつけるだけですね。実に簡単です。
さあ、これで再試行しましょう。リベンジです!

「ガッシ!ボカッ!」
.NET Coreは同じ結果を返した。スイーツ(笑)

calliが含まれるメソッドはインライン化されないようです。

第6節 The Burst Compiler Condition/Burstコンパイラの条件

前節にて私はSystem.Delegateと同等のスペックの新たなシングルキャストデリゲートを開発しました。
性能向上が本当は欲しいのですが、無いものは仕方がありません。
では現状得られたこの静的デリゲート(実態はIntPtr型に対する拡張メソッド群)をどのように使えばDelegateと差別化を図れるでしょうか?

この時私に電流が走りました。
「静的クラス以外の参照型を一切使用できない環境下であるならば、この拡張クラスと静的デリゲートを使わざるを得ないのでは?」と。

そんな都合の良い環境は……存在します。
UnityのBurst Jobです。
詳しくはUnity社のチュートリアルなどを参照してほしいのですが、BurstとはUnityで超高速に並列計算を行うための特殊な制約を課された環境です。Burst CompilerはILをネイティブコードに変えますが、SIMDなどを活用して大胆な高速化を図るそうです。
そこでは参照型をnewすることはできず、静的フィールドを使用できません。静的メソッドと構造体のみ使用できます。

System.Delegateと私のStaticDelegateHelper(拡張メソッド群)の差別化にぴったりではありませんか!

私は喜び勇んでサンプルプログラムを書いて実行しました。

「ガッシ!ボカッ!」
Unityは死んだ。スイーツ(笑)

Unityの使用するmscorlibについて

Unityの使用するmscorlibは.NET Coreの使用するそれと異なっていました。
はい、UnityはMonoです。MonoのSystem.Delegateには_methodPtrAuxというIntPtr型privateフィールドは存在していませんでした。

私はILSpyでSystem.DelegateのprivateなIntPtr型フィールドを調べてみました。

  • method_ptr
  • invoke_impl
  • method
  • delegate_trampoline
  • extra_arg
  • method_code

6個全部試してみました! 全部Unityをクラッシュさせました!(全ギレ)

再びのDynamicMethod

次のようなコードを書かざるを得ませんでした。

static IntPtr GetFuncPtr(this System.Delegate function)
{
    DynamicMethod dm = new DynamicMethod("", typeof(IntPtr), Array.Empty<Type>());
    ILGenerator ilgen = dm.GetILGenerator();
    ilgen.Emit(OpCodes.Ldftn, function.GetMethodInfo());
    ilgen.Emit(OpCodes.Ret);
    return (dm.CreateDelegate(typeof(Func<IntPtr>)) as Func<IntPtr>)();
}

System.DelegateからGetMethodInfoでMethodInfoを得ます。
これをldftnの引数にすることで対象のメソッドの関数ポインタを得ています。
そしてDynamicMethodでメソッドを新規に作成、呼び出し、戻り値として関数ポインタを得ます。
無駄な回り道感と無駄アロケーション感が大きすぎて敗北感に苛まれますね。

最終節 Static Delegate's Longest Day/静的委譲のいちばん長い日

こうして私はUnityでもデリゲートから関数ポインタを得られるようになり、calliを使ってシングルキャスト静的デリゲートを使えるようになりました。
さあ、最後にBurstでこれを使えれば今までデリゲートが存在せず柔軟な機能制御ができなかったBurst界隈に革命が起きるはずです。

「ガッシ!ボカッ!」
Burst Compilerは死んだ。スイーツ(笑)

Burst Compilerはcalli命令を扱えませんでした。

これまでの私の苦労は一体?と絶望し、Unity TechnologiesにBurst Compilerがcalliを扱えるように改善してほしいと要望を出しました。
現在Burstがデリゲートを扱えないのは重大な機能的欠陥であるとも指摘しました。

Delegate in Burst

返信は他のUnityユーザーの方からきました。
Burstは最新バージョンからデリゲートを特別扱いすることで利用可能にしているそうです。

感想

:angel:

最後までお読みいただきありがとうございました。

24
23
4

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
24
23