CORETECH ENGINEER BLOG

株式会社サイバーエージェント SGEコア技術本部 技術ブログ

ILPostProcessor 入門 第1回目「はじめに + 最小実装」

この記事は ILPostProcessor 入門シリーズの第1回です。

  • 第1回 (ここ)
  • 第2回
  • 第3回
  • 第4回
  • 第5回

はじめに

こんにちは。

サイバーエージェントゲームエンターテイメント事業部・SGEコア技術本部(コアテク)の瀬戸です。

このシリーズでは、Unityの ILPostProcessor という API を使った開発テクニックについて、何記事かに分けて紹介していこうと思います。

ILPostProcessor はあまり馴染みがない機能かもしれませんが、有効に活用することで開発の効率化にとても役立ちます。今回の記事では、まず ILPostProcessor で何ができるのかを紹介しつつ、最小実装を動かすところまでやってみます。

IL

IL (Intermediate Language) は、.NET における中間言語です。別の表記では CIL (Common Intermediate Language) 、MSIL と書いたりもしますが、最も一般的な呼称は IL です。

Unity では、C# で書いたスクリプトは必ずこの IL を経由してからネイティブコードに変換されます。

IL ポストプロセッシング

今回とりあげる IL ポストプロセッシングは、C# からコンパイルされたILを編集する手法のことをいいます。

このシリーズではIL ポストプロセッシングの基本から、実用的な IL ポストプロセッシングのコードをガリガリ書いていくのに必要な知識まで、幅広く紹介していきます。

IL ポストプロセッシング は IL Weaving と呼ばれることもあります。weave は「織る」とか「編む」みたいな意味の単語です。覚えておくと情報収集に役立ちます。

IL ポストプロセッシングでできること

IL ポストプロセッシングで何ができるのかを理解するには、似たようなことを実現できる他の手法と比較するのがわかりやすいです。

機能的には、IL ポストプロセッシングと同じく事前コード生成技術にあたる Source Generator が最も近いです。Source Generator と比較して、ILポストプロセッシングが効果を発揮するのは次のようなシチュエーションです。

既存のコードの非破壊的な書き換え

IL ポストプロセッシングではコンパイル済みのDLLのみを編集するため、元の C# コードに影響を与えることなく処理を自由に書き換えることができます。Source Generator はコードの追加はできても編集はできないため、現状 IL ポストプロセッシングを行う最大のメリットはこの点だろうと思います。

📝 既存コードの書き換えについては C# 12 で Interceptor が試験的に導入され、Source Generator 等によって既存コードのメソッドの呼び出しを置き換えることができるようになっています。しかし、Unity はまだ C# 12 に対応していないため使用できません。

ランタイムパフォーマンスの向上

無駄なチェックやコピーを回避した最速のコードを追求することができます。

……とはいえ、近頃の C# はパフォーマンスを向上するための機能が充実していますし、一部C#では出力できない命令も System.Runtime.CompilerServices.Unsafe を使って書けたりしますので、パフォーマンスのためだけに IL を直に書くのはアンバランスな気がします。それに JIT や AOT でも様々な最適化がかかるので、意外とILレベルの最適化に効果がないこともあります。

低レイヤーへの理解

これは実質的なメリットではないですが、ILポストプロセッシングを学ぶと、普段書いている C# がどのようなILに変換されて動いているのかを深く理解することができます。例えば構造体の防衛的コピーとか、デリゲートのキャッシュなどに関する知識は、パフォーマンス的に正しいC#コードを書くことに役立ちます。


一方、Source Generatorと比較して、IL ポストプロセッシングには明らかな弱点が一つあります。それはコードが複雑になりやすいことです。

IL 編集は強力な一方、型安全やメモリ安全の仕組みは緩く、適当に書くと危険なコードを生成してしまう恐れがあり、実装には慎重さが求められます。また、IL は人間よりも機械が読むための言語であるため、IL を編集するコードは難解になりやすいです。

もしあなたが既に何か解決したい課題を持っていて、その解決法として IL ポストプロセッシングに期待を寄せているのであれば、まずは IL ポストプロセッシングを使わずに解決できないかを十分に検討しましょう。

Unity における ILポストプロセッシングの活用例

では、実際にどんな場面に IL ポストプロセッシングが活用されているのか、Unity における実例を見てみましょう。

Burst の Direct Call

Burst は、C# コードを LLVM バックエンドを利用して最適化された高速なネイティブコードに事前コンパイルする仕組みです。ユーザーからは通常の C# コードを実行するのと同じ感覚でネイティブコードを呼び出すことができる Direct Call という機能があり、このネイティブコードへのバイパスに IL ポストプロセッシングが利用されています。

Photon Fusion の RPC

ネットワーキングフレームワークPhoton Fusion において、リモートクライアント上で処理を呼び出す RPC (Remote Procedure Call) は、通常の C# メソッドを呼び出すのと同じ感覚で行うことができるようになっており、ここにも IL ポストプロセッシングが利用されています。

……こうして見てみると、上記の2例は「通常の C# を書いている感覚で、通常とは異なる処理が透過的に呼び出される」という点で共通しています。フレームワークやライブラリ側がユーザーの書くコードの量や認知的な負荷を軽減する目的で IL ポストプロセッシングを利用していることが分かります。この辺りが IL ポストプロセッシングの強みといえるでしょう。

Unityのパッケージソースのミラーを公開している needle-mirror を検索してみると、どんなパッケージでILポストプロセッシングが利用されているか分かります。

Unity の ILPostProcessor API

Unityには ILPostProcessor というAPIがあり、コンパイルパイプラインにフックしてコンパイル済みの IL を編集することができます。IL (DLLファイル) は byte[] として渡されるため、これをパースして編集する必要があります。IL のパースや編集には Mono.Cecil という標準的なライブラリが Unity Package Manager 経由で配信されているためこちらを利用するのがストレートです。

この ILPostProcessor API ですが、実はまだ正式なサポートがなく、ドキュメントもありません。ただ、先述の通り Burst など公式のパッケージでもがっつり利用されており、細かい仕様変更の可能性はあるにしても、いきなり使えなくなることは考えにくいです。

📝 Unityはコンパイルパイプラインを現状の Assembly Definition ベースのものから MSBuild / csproj ベースのものに移行する方針を発表していて、その際には上記のような独自 API の代わりに、非 Unity の .NET 環境で一般的に利用されている IL Weaving フレームワークFody など)が利用できるようになることが示唆されています。 https://discussions.unity.com/t/il-post-processing/910090

最小実装

まずは簡単な ILPostProcessor を作成してみます。 今回はまず細かい説明は置いておいて、最低限動く状態のものを作って動作を確認していきたいと思います。

Unity 2022.3で作業します。

Unityのバージョンによって、ILPostProcessor の挙動に幾つか重要な変更が加えられているため、異なるバージョンを使う際は注意が必要です。詳しくは次回の記事で解説します。

Mono.Cecil

Package Manager から Mono.Cecil をインストールするには、Add package by name... から com.unity.nuget.mono-cecil を追加します。

アセンブリの準備

ILPostProcessor を実装するためには、専用のアセンブリを用意する必要があります。Assembly Definition File を作成し、Unity.FirstProcessor.CodeGen という名前をつけます。

アセンブリ名は Unity. で始まり、.CodeGen で終わる必要があります。これは ILPostProcessor 用のアセンブリを Unity に認識させるために必要です。

作成した Assembly Definition File の設定を行います。

  • Auto Referencedのチェックを外す
  • No Engine References, Override References のチェックを入れる
  • Assembly References に4つのアセンブリを追加
  • Platforms を Editor のみに設定

これでアセンブリの準備は完了です。

アセンブリの読み込みと書き込み

作成した asmdef 下に適当なスクリプトを作成します。

まずはアセンブリの読み込みと書き込みを実装します。これが基本的な形です。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
using Unity.CompilationPipeline.Common.ILPostProcessing;

public class FirstProcessor : ILPostProcessor
{
    public override ILPostProcessor GetInstance() => this;

    public override bool WillProcess(ICompiledAssembly compiledAssembly) => true;

    public override ILPostProcessResult Process(ICompiledAssembly compiledAssembly)
    {
        if (!WillProcess(compiledAssembly)) return new ILPostProcessResult(null);

        var loader = new AssemblyResolver();
        
        var folders = new HashSet<string>();
        foreach (var reference in compiledAssembly.References)
            folders.Add(Path.Combine(Environment.CurrentDirectory, Path.GetDirectoryName(reference)));

        var folderList = folders.OrderBy(x => x);
        foreach (var folder in folderList) loader.AddSearchDirectory(folder);
        
        var readerParameters = new ReaderParameters
        {
            InMemory = true,
            AssemblyResolver = loader,
            ReadSymbols = true,
            ReadingMode = ReadingMode.Deferred
        };

        readerParameters.SymbolStream = new MemoryStream(compiledAssembly.InMemoryAssembly.PdbData);

        var assembly = AssemblyDefinition.ReadAssembly(new MemoryStream(compiledAssembly.InMemoryAssembly.PeData), readerParameters);
        
        ProcessAssembly(assembly);
        
        byte[] peData;
        byte[] pdbData;
        {
            var peStream = new MemoryStream();
            var pdbStream = new MemoryStream();
            var writeParameters = new WriterParameters
            {
                SymbolWriterProvider = new PortablePdbWriterProvider(),
                WriteSymbols = true,
                SymbolStream = pdbStream
            };

            assembly.Write(peStream, writeParameters);
            peStream.Flush();
            pdbStream.Flush();

            peData = peStream.ToArray();
            pdbData = pdbStream.ToArray();
        }

        return new ILPostProcessResult(new InMemoryAssembly(peData, pdbData));
    }

    private void ProcessAssembly(AssemblyDefinition assembly)
    {
        /*
         * ここでassemblyを編集
         */
    }
    
    class AssemblyResolver : BaseAssemblyResolver
    {
    }
}

アセンブリを編集する

読み込みと書き込みが実装できたので、実際にアセンブリを編集する部分を実装していきます。 せっかくなので、それなりに IL ポストプロセッシングを使う意義のあるものを作ってみます。

今回は例として、「属性をつけたメソッドを Destroy 後に呼ぶと例外をスローさせるやつ」を作ります。 最終的な使用イメージはこんな感じです。

using UnityEngine;

public class Hoge : MonoBehaviour
{
    // Destroy後に呼ぶと例外をスローする
    [ThrowWhenDestroyed]
    public void SomeMethod()
    {
        Debug.Log("SomeMethod");
    }
}

Source Generator やリフレクションではこのようなことは実現できないので、まさに IL ポストプロセッシング向きのテーマですね。

生成する IL を確認する

まずはどんな IL を生成すればいいのかを考えていきます。

IL を扱う際は、SharpLab というツールを使うのが鉄板です。SharpLab を使うと、入力した C# に対応する IL を見ることができます。

次のような C# コードを入力してどんな IL が出てくるか見てみます。

using System;

public class C : UnityEngine.Object
{
    private void M()
    {
        // この行を挿入
        if(!this) throw new InvalidOperationException("This method cannot be called after destroyed.");
        
        Console.WriteLine("AAA");
    }
}

namespace UnityEngine
{
    public class Object
    {
        public static implicit operator bool(Object exists)
        {
            // Unityの生存チェックはboolオペレータをオーバーライドしているので、それを模す
            return true;
        }
    }
}

C# コードを入力したら、右ペインにILが表示されます。IL のコードは C# のそれとだいぶ見た目が異なりますが、クラス自体の構造は C# とほとんど同じなので、注意深く読んでいけば概形を掴むのはそれほど難しくありません。

クラスCの定義はこんな感じに表記されます。

.class public auto ansi beforefieldinit C
    extends UnityEngine.Object
{
    /*
     * クラスCに含まれるメソッドなど
     */
}

この中からメソッド M() を探してみると、こんな感じです。

    .method private hidebysig 
        instance void M () cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 30 (0x1e)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call bool UnityEngine.Object::op_Implicit(class UnityEngine.Object)
        IL_0006: brtrue.s IL_0013

        IL_0008: ldstr "This method cannot be called after destroyed."
        IL_000d: newobj instance void [System.Runtime]System.InvalidOperationException::.ctor(string)
        IL_0012: throw

        IL_0013: ldstr "AAA"
        IL_0018: call void [System.Console]System.Console::WriteLine(string)
        IL_001d: ret
    } // end of method C::M

IL_0000 から IL_0012 が ILPostProcessor で挿入する必要がある IL 命令です。ひとまずコードの意味は深く考えずに、素直に ILPostProcessor に落とし込んでいきます。

実装する

    private void ProcessAssembly(AssemblyDefinition assembly)
    {
        foreach (var module in assembly.Modules)
        {
            foreach (var type in module.GetTypes())
            {
                ProcessType(type);
            }
        }
    }

    private void ProcessType(TypeDefinition type)
    {
        // UnityEngine.Objectを継承しているかチェック
        var cursor = type;
        TypeReference unityEngineObject = null;
        while (cursor != null)
        {
            if (cursor.FullName == "UnityEngine.Object")
            {
                unityEngineObject = cursor;
                break;
            }

            cursor = cursor.BaseType?.Resolve();
        }

        if (unityEngineObject == null) return;

        foreach (var method in type.GetMethods())
        {
            ProcessMethod(method, unityEngineObject);
        }
    }

    private void ProcessMethod(MethodDefinition method, TypeReference unityEngineObject)
    {
        // 属性がついてるかチェック
        if (method.CustomAttributes.All(attr => attr.AttributeType.Name != "ThrowWhenDestroyedAttribute")) return;

        // 一部のメソッドはbodyがない(abstractやextern)のでスキップ
        if (method.Body == null) return;

        // 編集前にマクロを展開し、最後に戻す
        // 参考: <https://zenn.dev/ruccho/articles/a8bbb8f0a58225>
        method.Body.SimplifyMacros();

        var processor = method.Body.GetILProcessor();

        var firstInstruction = method.Body.Instructions.First();

        // IL_0000: ldarg.0
        processor.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Ldarg_0));

        // UnityEngine.Objectのカスタムオペレータを取得
        var unityEngineObjectBooleanOperator = method.Module.ImportReference(new MethodReference("op_Implicit",
            method.Module.TypeSystem.Boolean,
            unityEngineObject)
        {
            HasThis = false,
            Parameters = { new ParameterDefinition(unityEngineObject) }
        });

        // InvalidOperationExceptionのコンストラクタを取得
        var invalidOperationExceptionCtor = method.Module.ImportReference(
            new MethodReference(
                ".ctor",
                method.Module.TypeSystem.Void,
                new TypeReference("System", "InvalidOperationException", null, method.Module.TypeSystem.CoreLibrary,
                    false))
            {
                HasThis = true,
                Parameters = { new ParameterDefinition(method.Module.TypeSystem.String) }
            });

        // IL_0001: call bool UnityEngine.Object::op_Implicit(class UnityEngine.Object)
        processor.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Call, unityEngineObjectBooleanOperator));
        
        // IL_0006: brtrue.s IL_0013
        processor.InsertBefore(firstInstruction,
            Instruction.Create(OpCodes.Brtrue, firstInstruction)); // 分岐先に元々の先頭の命令を指定
        
        // IL_0008: ldstr "This method cannot be called after destroyed."
        processor.InsertBefore(firstInstruction,
            Instruction.Create(OpCodes.Ldstr, "This method cannot be called after destroyed."));

        // IL_000d: newobj instance void [System.Runtime]System.InvalidOperationException::.ctor(string)
        processor.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Newobj, invalidOperationExceptionCtor));

        // IL_0012: throw
        processor.InsertBefore(firstInstruction, Instruction.Create(OpCodes.Throw));

        method.Body.OptimizeMacros();
    }

ILPostProcessor の実装はこれで完了です。

試す

using System;
using System.Threading.Tasks;
using UnityEngine;

public class Example : MonoBehaviour
{
    private async void Start()
    {
        try
        {
            ThrowWhenDestroyedMethod();
            await Task.Delay(100);
            Destroy(gameObject);
            await Task.Delay(100);
            ThrowWhenDestroyedMethod();
        }
        catch (Exception ex)
        {
            Debug.LogException(ex);
        }
    }

    [ThrowWhenDestroyed]
    private void ThrowWhenDestroyedMethod()
    {
        Debug.Log("ThrowWhenDestroyedMethod");
    }
    
}

[AttributeUsage(AttributeTargets.Method)]
public class ThrowWhenDestroyedAttribute : Attribute
{
}

Playすると、一度目の ThrowWhenDestroyed() はそのまま呼ばれて、Destroy 後の2度目では例外がスローされています。


さて、今回は間の細かい説明は省いて、最低限動くものを作ることにフォーカスしましたが、詳しいことは次回以降の記事で説明していきます。

予定している記事の内容はこんな感じです。

  • 第2回:ILPostProcessor の基本構造
    • ILPostProcessor の基本的な構成要素や、どのような仕組みで実行されるのか、デバッグ用のログ出力実装などを行います。
  • 第3回:IL 編集の基本
    • 実際の IL 命令の書き方について説明します。
  • 第4回:型システム
    • IL上で特定の型を参照する方法や、.NET の型システムの全体像について説明します。
  • 第5回:テクニック
    • 本格的に ILPostProcessor を実装する上で役にたつツールや情報源を紹介します。

実用的な ILPostProcessor を書く上で必要な知識をたくさん紹介していくので、ぜひ最後まで読んでみてください!