LoginSignup
14
9

More than 5 years have passed since last update.

ILの暗黒の魔術 第3章「動的生成!そしてBurst挽歌へ」

Posted at

初投稿です(ガバガバ)。

私はヴァーレントゥーガが好きです。

ヴァーレントゥーガは、ななあし氏が制作した国取りタイプ戦略シミュレーションゲームです。敵軍の動きや地形を考慮しながら自軍の兵士をリアルタイムで動かす戦闘システムや、ファンタジー世界を舞台とした壮大なストーリーが数多くのプレイヤーから人気を得ています。
一方、ヴァーレントゥーガは、完成度の高い戦闘システムを引き継ぎながら、ユーザーが独自のシナリオを作り公開することが可能なゲーム制作ツールとして、作者のななあし氏が仕様を公開しています。
そこからは、多くの派生作品が生み出されてきました。その中には、ニコニコ自作ゲームフェス4でフリーゲーム夢現賞を受賞した「LostTechnology」や海外展開を準備中の「きのこ・たけのこ戦争IF」など、派生元であるヴァーレントゥーガに負けず劣らず高い評価を得ている作品も数多くあります。
高い完成度を誇る戦略シミュレーションゲームとして、また多くの派生作品を生み出したゲーム制作ツールとして、自作ゲームのコミュニティに偉大な貢献を残しており、まさにゲーム史における重要な存在であるといえます。
銃魔のレザネーションのヴァーレントゥーガ紹介ページより引用

ヴァーレントゥーガは2018年に開発が終了しています。
(今後新たな機能が追加されることは)ないです。

機能が安定したのを受け()、私は数年前からUnity C#でヴァーレントゥーガ互換ソフトであるWahrenを開発しています。
ヴァーレントゥーガは作者の漏らしたコメントから推察するにVisual C++でDirectX9を使用して作成されているはずです。
故にユーザー作成の独自シナリオを実現している方法はよほど頭のおかしい技術を使用していない限りインタプリタであることは明白です。
インタプリタが遅いのは日本書紀にも書いてあるぐらい自明の理です。
故に私は.NETの特長であるJITを活かすべく、ユーザーのオリジナルシナリオのソースコードを動的に構文解析し、ILを吐いてAssemblyを作成することでPure C#並の速度を出そうと試みています。

Unity 2018.1から新たに登場した機能であるBurst Compiler(記事執筆時点でバージョン0.2.4-preview.41)を使用することで高速な並列計算を実現することが可能です。
そこで、今回はBurst Compilerを利用する並列計算を行う構造体を動的に定義してみました。

第1節 設計

動的IL生成について私が今まで書いてきた記事はSystem.Reflection.Emit.DynamicMethodを利用してきました。
これはトリッキーなこと(privateメンバへのアクセス)を行うならば非常に重宝するのですが、メソッドしか作れず、構造体を作ることができません。

AssemblyBuilder&ModuleBuilder

今回はアセンブリから作っていきましょう。
アセンブリば大体DLLみたいなものと思っていただければ大丈夫です。

// using System.Reflection;
// using System.Reflection.Emit;

const string ModuleName = "Hoge";
const string FileName = ModuleName + ".dll";
AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(ModuleName), AssemblyBuilderAccess.RunAndSave);
ModuleBuilder moduleBulder = assemblyBuilder.DefineDynamicModule(ModuleName, FileName);

動的に定義する場合AssemblyBuilder.DefineDynamicAssembly静的メソッドを使用します。
この第一引数には何故かstring型ではなくAssemblyName型でアセンブリ名を与えます。
第二引数はRunあるいはRunAndSaveを選びます。RunAndSaveの方がデバッガビリティが高いのでよいでしょう。

アセンブリは必ず1つはモジュールを持ちます。
複数モジュールを持つことはできますが、MSBuildはその操作をサポートしていないので考慮せずともよいでしょう。
このModuleBuilder型のオブジェクトに対してクラスや構造体や列挙型をごりごり定義していくことで大規模IL動的生成を行うのが主流です。
static変数にでも持たせるのがいいのではないですかね。

TypeBuilder

今回はUnity.Jobs.IJobParallelForを実装したBurstJobな構造体を定義していきます。
TypeBuilderでメソッドやフィールド、プロパティにインナークラスなど諸々全てを定義し終えた後、Type TypeBuilder.CreateType()インスタンスメソッドを呼び出すことでTypeオブジェクトを得られます。
そしてその戻り値のTypeオブジェクトを引数に取るobject System.Activator.CreateInstance(Type t)静的メソッドによって動的に定義された構造体のインスタンス(がBOX化されたもの)が得られるのです。

IJobParalleFor
using Unity.Jobs.LowLevel.Unsafe;

namespace Unity.Jobs
{
    [JobProducerType(typeof(IJobParallelForExtensions.ParallelForJobStruct<>))]
    public interface IJobParallelFor
    {
        void Execute(int index);
    }
}

JobParallelForを実装した構造体を第一引数に指定して、Unity.Jobs.JobHandle Unity.Jobs.IJobParallelForExtensions.Schedule<T>(T job, int arrayLength, int innerLoopBatchCount, Unity.Jobs.JobHandle) where T : struct静的メソッドを呼ぶことでマルチスレッドで良い感じに並列計算が実行されるようになります。

よって、今回動的に生成するコードはC#で擬似コードを提示しますと次のような形である必要があります。

[Unity.Burst.BurstCompile]
public unsafe struct BURSTJOB : Unity.Jobs.IJobParallelFor
{
    [Unity.Jobs.LowLevel.Unsafe.NativeDisableUnsafePtrRestriction] public int* ptr;
    public void Execute(int index) => ptr[index] = index;
}

いや、まあこれだけだとまともに動作できないのですが。
問題点は2つあります。

  • BURSTJOB型に型変換できないのでint*型のフィールドptrに設定できない
  • IJobParallelForインターフェース型に型変換したとしてもUnity.Jobs.JobHandle Unity.Jobs.IJobParallelForExtensions.Schedule<T>(T job, int arrayLength, int innerLoopBatchCount, Unity.Jobs.JobHandle) where T : struct静的メソッドの型制約にひっかかる為並列計算できない

よってこの2つの問題を解決するために新たに次のインターフェースを用意し、BURSTJOB構造体にこれを実装しましょう。

using Unity.Jobs;
using Unity.Jobs.LowLevel.Unsafe;
using Unity.Burst;

private const int COUNT = 50000;

public unsafe interface IAccessor : IJobParallelFor
{
    int* GetPtr();
    void SetPtr(int* value);
    JobHandle Schedule();
}

[BurstCompile]
public unsafe struct BURSTJOB : IAccessor
{
    [NativeDisableUnsafePtrRestriction] public int* ptr;
    public void Execute(int index) => ptr[index] = index;
    public JobHandle Schedule() => IJobParallelForExtensions.Schedule<BURSTJOB>(this, COUNT, 1024, default(JobHandle));
    public void SetPtr(int* value) => ptr = value;
    public int* GetPtr() => ptr;
}

こうすればActivator.CreateInsatnaceでインスタンス化したオブジェクトをIAccessorに型変換することでポインタPtrを設定し、かつIJobParallelForExtensions.Scheduleを呼び出すことが可能となります。
具体的には以下のようにUpdateマジックメソッド内から呼びます。

private const int COUNT = 50000;
unsafe void Update()
{
    using (var array = new NativeArray<int>(COUNT, Allocator.TempJob))
    {
        accessor.SetPtr((int*)array.GetUnsafePtr());
        JobHandle handle = accessor.Schedule(); // マルチスレッド並列計算を発行する。
        handle.Complete(); // 同期的に完了を待機する。
    }
}

さあ、事前の設計と擬似コードは以上で十分でしょう。これから構造体を定義しましょう。

第2節 型を捏ねる

概形

BURSTJOB型を定義しましょう。この型は構造体で、IAccessorを実装しています。故に以下のように記述して概形を定義します。

Start()マジックメソッド内から抜粋(1)
TypeBuilder typeBuilder = moduleBulder.DefineType("BURSTJOB",
    TypeAttributes.Public | TypeAttributes.AnsiClass | TypeAttributes.Sealed | TypeAttributes.SequentialLayout,
    typeof(System.ValueType),
    new Type[] { typeof(IAccessor), typeof(IJobParallelFor) });
typeBuilder.SetCustomAttribute(new CustomAttributeBuilder(typeof(BurstCompileAttribute).GetConstructor(Type.EmptyTypes), Array.Empty<object>()));

public TypeBuilder DefineType(string name, TypeAttributes attr, Type parent, Type[] interfaces);
第一引数で型の名前を、第二引数で型の概形を、第三引数で基底型を、第四引数で実装するインターフェース(継承した基底インターフェースを含む)全てを指定することでTypeBuilderオブジェクトを戻します。
名前はそのまま"BURSTJOB"。
型の概形は一般的な構造体のそれを指定しています。読者の皆さんが構造体を動的に定義するならばこのTypeAttributesをそのままコピペして使うといいでしょう。
IAccessorインターフェースはIJobParallelForインターフェースを継承しているため、ModuleBuilder.DefineTypeメソッドの引数に継承元であるIJobParallelFor型を表すTypeオブジェクトを渡しています。
.NETにおける構造体は全てSystem.ValueType型から派生しているため基底型に指定します。
そしてTypeBuilder.SetCustomAttrbuteインスタンスメソッドを呼ぶことでこの型に対してつけられた属性を表現します。

フィールド

次にフィールドを定義してあげましょう。

Start()マジックメソッド内から抜粋(2)
Type intPtrType = typeof(int).MakePointerType();
FieldBuilder ptr = typeBuilder.DefineField("ptr",
    intPtrType,
    FieldAttributes.Public);
field0.SetCustomAttribute(new CustomAttributeBuilder(typeof(NativeDisableUnsafePtrRestrictionAttribute).GetConstructor(Type.EmptyTypes), Array.Empty<object>()));

int*を表すTypeオブジェクトを結構いろいろな箇所で必要としているのですが、typeof(int*)という表記法はC#において認められていません。
そのため、Type.MakePointerTypeインスタンスメソッドを使用してint型を表すTypeオブジェクトからint*を表すTypeオブジェクトを作成します。ちなみに参照ref intを表すTypeオブジェクトを作るならばMakeByRefTypeメソッドを使います。
このフィールドには[NativeDisableUnsafePtrRestriction]属性を付与しなくてはならないので、BURSTJOB構造体に対して行ったように属性を付与しましょう。

メソッド

BURSTJOB構造体に定義されたメソッドは全てIAccessorとIJobParallelForインターフェースで定義されたメソッドです。
故にこの記事ではオーバーライドしか扱いません。

では、一番単純なint* GetPtrメソッドをまずは見てみましょう。

Start()マジックメソッド内から抜粋(3)
// using static System.Reflection.Emmit.OpCodes;

const MethodAttributes Attributes = MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final | MethodAttributes.NewSlot | MethodAttributes.HideBySig;

MethodBuilder getPtr = typeBuilder.DefineMethod("GetPtr", Attributes, intPtrType, Type.EmptyTypes);
{
    ILGenerator il = getPtr.GetILGenerator();
    il.Emit(Ldarg_0);
    il.Emit(Ldfld, ptr);
    il.Emit(Ret);
}

MethodBuilder DefineMethod(string name, MethodAttributes attributes, Type returnType, Type[] parameterTypes);
第一引数はメソッドの名前で、第二引数にメソッドの性質を指定します。
今回はpublicで仮想メソッドをオーバーライドし、構造体なのでsealed(final)でこれ以上VTableを検索しない、と指定しています。(HideBySigとNewSlot?こまけえこたあいいんだよ!)
第三引数に戻り値の型を定義し、第四引数に引数の型を定義します。引数がないメソッドならばType.EmptyTypesを渡してあげましょう。(System.Array.Empty()でもよさげですが、EmptyTypesはstatic readonlyフィールドなので微々たる差ですが高速に動作します。)

そして、ILGenerator MethodBuilder.GetILGenerator()でとうとうILをガリガリ書いていきましょう!
ILGenerator.Emitメソッドの第一引数にOpCodesオブジェクトを渡し、第二引数以後にその補足を足す、という形でILを動的に出力します。
OpCodes毎に補足が不要だったり、補足の型が千差万別なのにEmitメソッドばかりなのは実際バグの温床で使いにくいです。ここの辺りの辛さを大分緩和してくれるライブラリにSigilというものがあります。興味のある方は学んでみても良いと思います。

IL部分でやっていることはメソッドの第一引数からフィールドptrの値をスタックに積んで戻り値としているだけです。

次にvoid Execute(int index) => ptr[index] = index;を見てみましょう。
これはポインタ演算が絡むのでやや複雑性が高いですが、慣れればそんなでもありません。
戻り値がないvoidなメソッドの戻り値の型はtypeof(void)です。
IL部分の解説はコード中のコメントを読んでください。

MethodBuilder execute = typeBuilder.DefineMethod("Execute", Attributes, typeof(void), new Type[] { typeof(int) });
{
    ILGenerator il = execute.GetILGenerator();
    il.Emit(Ldarg_0); // thisポインタをロード
    il.Emit(Ldfld, ptr); // thisポインタの指す先からint*型のフィールドptrをロード(スタック上にプッシュ)
    il.Emit(Ldarg_1); // メソッドの第一引数indexをロード
    il.Emit(Conv_I); // indexをポインタであるIntPtr型に型変換あるいは解釈の変更
    il.Emit(Ldc_I4_4); // 4を掛ける
    il.Emit(Mul);
    il.Emit(Add); // ptrの指すアドレスに4 * indexを足す。
    il.Emit(Ldarg_1); // 第一引数のindexという値をスタックにプッシュ
    il.Emit(Stind_I4); // ポインタの指す先に値を代入する。
    il.Emit(Ret); // スタックは空なので何も返さない。
}

次にJobHandle Scheudle()メソッドを見てみましょう。

MethodBuilder schedule = typeBuilder.DefineMethod("Schedule", Attributes, typeof(JobHandle), Type.EmptyTypes);
{
    ILGenerator il = schedule.GetILGenerator();
    LocalBuilder local = il.DeclareLocal(typeof(JobHandle));
    il.Emit(Ldarg_0); // thisポインタをロード
    il.Emit(Ldobj, typeBuilder); // thisポインタの指す先にある実体をスタック上にロード IJobParallelForExtensions.Schedule<T>(T, int, int, JobHandle)の第一引数
    il.Emit(Ldc_I4, COUNT); // 第二引数
    il.Emit(Ldc_I4, 1024); // 第三引数
    il.Emit(Ldloca_S, 0); // ローカル変数のアドレスをスタック上にロード
    il.Emit(Initobj, typeof(JobHandle)); // ポインタの指す先にJobHandle型の構造体をnewする。構造体なので当然default(JobHandle)
    il.Emit(Ldloc_0); // ローカル変数の値をスタック上にロード
    il.EmitCall(Call, typeof(IJobParallelForExtensions).GetMethod(nameof(IJobParallelForExtensions.Schedule)).MakeGenericMethod(typeBuilder), Type.EmptyTypes); // 関数呼び出し
    il.Emit(Ret); // スタックにはJobHandleのみが残っているのでこれを返り値とする
}

LocalBuilderは初めて出てきましたね。
ILGenerator.DeclareLocalでローカル変数を宣言します。
しかし、ローカル変数に名前がありませんね。実は.NETではローカル変数は無名の存在で、インデックスナンバーで参照される存在なのです。

たとえデフォルト値であったとしても構造体を作成するためには構造体を作成する先のアドレスを取得する必要があります。
今回はローカル変数をわざわざ定義し、ローカル変数のアドレスに対して構造体の初期化を行いました。
そして初期化後にローカル変数をロードしています。かなり回りくどく感じますがshoganai。
そして4つの引数(BURSTJOB, int, int, JobHandle)が揃ったらIJobParallelForExtensions.Schedule<BURSTJOB>を呼びます。
この時、Scheduleがジェネリックメソッドですので型パラメーターを与えるためにMakeGenericMethodメソッドを使いましょう。
これは、MethodInfo MethodInfo.MakeGenericMethod(params Type[] parameters)というメソッドシグネチャです。TypeBuilder型はType型を先祖に持つので問題なく渡せます。

SetPtrは簡単なので解説しません。

第3節 実行結果

こうして全てのメソッドを実装して動的な構造体を定義し終えました。
さあ、実行してみましょう!

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

Burst CompilerがMono.Cecil.AssemblyResolutionExceptionを吐いて死にました。
どうやら動的に定義されたBurstJobをコンパイルすることができないようです。

本当に、本当に残念でなりません。

typeBuilder.SetCustomAttribute(new CustomAttributeBuilder(typeof(BurstCompileAttribute).GetConstructor(Type.EmptyTypes), Array.Empty<object>()));

をコメントアウトするとBurstコンパイルされずにちゃんとマルチスレッドで動作しました。
実に惜しい話ですね。

感想

:angel:

私の野心的試みは必ず失敗するという呪いでもあるのでしょうか?

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

宣伝

ヴァーレントゥーガ互換のUnityゲームを現在作っています。
お手伝いいただける方はぜひご連絡ください。

付属

全体のコード
using System;
using System.Diagnostics;
using UnityEngine;
using Unity.Burst;
using Unity.Jobs;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using System.Reflection;
using System.Reflection.Emit;

using static System.Reflection.Emit.OpCodes;

public class TestManager : MonoBehaviour
{
    public unsafe interface IAccessor : IJobParallelFor
    {
        int* GetPtr();
        void SetPtr(int* value);
        JobHandle Schedule();
    }

    private const int COUNT = 50000;
    IAccessor accessor;

    void Start()
    {
        const string ModuleName = "Hoge";
        const string FileName = ModuleName + ".dll";
        AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(ModuleName), AssemblyBuilderAccess.RunAndSave);
        var moduleBulder = assemblyBuilder.DefineDynamicModule(ModuleName, FileName);
        var typeBuilder = moduleBulder.DefineType("BURSTJOB",
            TypeAttributes.Public | TypeAttributes.AnsiClass | TypeAttributes.Sealed | TypeAttributes.SequentialLayout,
            typeof(System.ValueType),
            new Type[] { typeof(IAccessor), typeof(IJobParallelFor) });
        typeBuilder.SetCustomAttribute(new CustomAttributeBuilder(typeof(BurstCompileAttribute).GetConstructor(Type.EmptyTypes), Array.Empty<object>()));
        var intPtrType = typeof(int).MakePointerType();
        var ptr = typeBuilder.DefineField("ptr",
            intPtrType,
            FieldAttributes.Public);
        ptr.SetCustomAttribute(new CustomAttributeBuilder(typeof(NativeDisableUnsafePtrRestrictionAttribute).GetConstructor(Type.EmptyTypes), Array.Empty<object>()));
        const MethodAttributes Attributes = MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final | MethodAttributes.NewSlot | MethodAttributes.HideBySig;
        var schedule = typeBuilder.DefineMethod("Schedule", Attributes, typeof(JobHandle), Type.EmptyTypes);
        {
            var il = schedule.GetILGenerator();
            var local = il.DeclareLocal(typeof(JobHandle));
            il.Emit(Ldarg_0);
            il.Emit(Ldobj, typeBuilder);
            il.Emit(Ldc_I4, COUNT);
            il.Emit(Ldc_I4, 1024);
            il.Emit(Ldloca_S, 0);
            il.Emit(Initobj, typeof(JobHandle));
            il.Emit(Ldloc_0);
            il.EmitCall(Call, typeof(IJobParallelForExtensions).GetMethod(nameof(IJobParallelForExtensions.Schedule)).MakeGenericMethod(typeBuilder), Type.EmptyTypes);
            il.Emit(Ret);
        }
        var execute = typeBuilder.DefineMethod("Execute", Attributes, typeof(void), new Type[] { typeof(int) });
        {
            var il = execute.GetILGenerator();
            il.Emit(Ldarg_0);
            il.Emit(Ldfld, ptr);
            il.Emit(Ldarg_1);
            il.Emit(Conv_I);
            il.Emit(Ldc_I4_4);
            il.Emit(Mul);
            il.Emit(Add);
            il.Emit(Ldarg_1);
            il.Emit(Stind_I4);
            il.Emit(Ret);
        }
        var setPtr = typeBuilder.DefineMethod("SetPtr", Attributes+, typeof(void), new[] { intPtrType });
        {
            var il = setPtr.GetILGenerator();
            il.Emit(Ldarg_0);
            il.Emit(Ldarg_1);
            il.Emit(Stfld, ptr);
            il.Emit(Ret);
        }
        var getPtr = typeBuilder.DefineMethod("GetPtr", Attributes, intPtrType, Type.EmptyTypes);
        {
            var il = getPtr.GetILGenerator();
            il.Emit(Ldarg_0);
            il.Emit(Ldfld, ptr);
            il.Emit(Ret);
        }
        accessor = (IAccessor)Activator.CreateInstance(typeBuilder.CreateType());
        assemblyBuilder.Save(FileName);
        Verify(assemblyBuilder);
    }

    unsafe void Update()
    {
        using (var array = new NativeArray<int>(COUNT, Allocator.TempJob))
        {
            accessor.SetPtr((int*)array.GetUnsafePtr());
            accessor.Schedule().Complete();
        }
    }

    static void Verify(params AssemblyBuilder[] builders)
    {
        var path = @"C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.2 Tools\x64\PEVerify.exe";

        foreach (var targetDll in builders)
        {
            var psi = new ProcessStartInfo(path, targetDll.GetName().Name + ".dll")
            {
                CreateNoWindow = true,
                WindowStyle = ProcessWindowStyle.Hidden,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false
            };

            var p = Process.Start(psi);
            var data = p.StandardOutput.ReadToEnd();
            Console.WriteLine(data);
        }
    }
}
14
9
2

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
14
9