CORETECH ENGINEER BLOG

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

ILPostProcessor 入門 第2回目「ILPostProcessorの基本構造」

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

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

ILについて説明していく前に、今回はILPostProcessorを実装するための下地を整えます。

実行モデル

本格的に ILPostProcessor を実装していくにあたって注意が必要なのは、ILPostProcessor が Unity とは独立したプロセスで実行されるという点です。これは、ILPostProcessor が UnityEngineUnityEditor などのエンジン機能にアクセスできないことを意味します。

これを確かめるため、ILPostProcessorで次のようなコードを書いてみましょう。

using System;
using System.Collections.Generic;
using Unity.CompilationPipeline.Common.Diagnostics;
using Unity.CompilationPipeline.Common.ILPostProcessing;

public class EnvironmentCheck : 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);

        return new ILPostProcessResult(null, new List<DiagnosticMessage>()
        {
            new()
            {
                DiagnosticType = DiagnosticType.Warning,
                MessageData =
                    $"{compiledAssembly.Name} / PID: {System.Diagnostics.Process.GetCurrentProcess().Id}, TID: {Environment.CurrentManagedThreadId}, version: {Environment.Version}, framework desc: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}, CommandLine: {Environment.CommandLine}"
            }
        });
    }
}

詳しくは後述しますが、ILPostProcessor では DiagnosticMessage のリストを返すことでログ出力を行うことができます。

結果は次のようになります(Unity 2022.3.19f1 で実行)。

EnvironmentCheck: (0,0): warning UnityEngine.UI / PID: 14158, TID: 93, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Unity.InternalAPIEngineBridge.001 / PID: 14158, TID: 91, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Unity.2D.Animation.Runtime / PID: 14158, TID: 86, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Unity.2D.Common.Runtime / PID: 14158, TID: 19, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Unity.VisualScripting.State / PID: 14158, TID: 25, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Unity.VisualScripting.Core / PID: 14158, TID: 15, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Assembly-CSharp / PID: 14158, TID: 17, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Unity.2D.IK.Runtime / PID: 14158, TID: 97, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
EnvironmentCheck: (0,0): warning Unity.VisualScripting.Flow / PID: 14158, TID: 10, version: 6.0.11, framework desc: .NET 6.0.11, CommandLine: /Applications/Unity/Hub/Editor/2022.3.19f1/Unity.app/Contents/Tools/ilpp/Unity.ILPP.Runner/Unity.ILPP.Runner.dll /tmp/ilpp.sock-cd439d36cb37927fb295cd23773ebbd2
...

これを見ると、コンパイル対象の各アセンブリに対して同一の PID: 14158、そして異なるスレッドが割り振られているのがわかります。また、実行環境は .NET 6 で、コマンドライン引数を見ると Unity のインストールディレクトリに含まれる Unity.ILPP.Runner というDLLを実行していることがわかります。

Unity 本体とは異なるプロセスで、なんなら Mono ではなく .NET 6 が実行されています。Unity はコンパイルパイプライン内でこのプロセスと通信を行い、ILPostProcessor を適用しているのです。

また、このプロセスは各アセンブリを異なるスレッドで並列に処理しています。つまり、異なるアセンブリの処理で同じデータにアクセスするようなコードを記述する場合、スレッドセーフに気を使う必要があるということです。

特筆すべき点として、コンパイルを複数回走らせてもこのプロセスは基本的に生存し続けます(ILPostProcessor自体のコードを編集しない限り)。staticな場所に置いたデータ等は次のコンパイルでも残り続けることがあるので、キャッシュ処理などを行う場合は注意が必要です。

⚠️ Unity 2021 以下では少し状況が異なり、アセンブリごとに異なるプロセスで処理されます。

ログ出力の整備

上記の実行モデルの話を見ると、ILPostProcessor で UnityEngineUnityEditor にアクセスできない理由がわかると思います。

UnityEngine にアクセスできないということは、Debug.Log() が使用できないということです。ILPostProcessor の実装を始めるなら、まずは Debug.Log() 代替となるログ出力手段を用意するのがおすすめです。前項で少し紹介しましたが、ILPostProcessor では DiagnosticMessage のリストを返すことでログを Console ウィンドウに出すことができます。

ただ、これだけではプリントデバッグするのに不便です。Debug.Log() と同様に、static なメソッドでログ出力できるようにしておきます。

using System;
using System.Collections.Generic;
using Unity.CompilationPipeline.Common.Diagnostics;

public class Diagnostics
{
    [ThreadStatic] private static List<DiagnosticMessage> _messages;

    public static void Log(string message)
    {
        _messages ??= new();
        _messages.Add(new DiagnosticMessage()
        {
            DiagnosticType = DiagnosticType.Warning,
            MessageData = message
        });
    }

    public static void LogError(string message)
    {
        _messages ??= new();
        _messages.Add(new DiagnosticMessage()
        {
            DiagnosticType = DiagnosticType.Error,
            MessageData = message
        });
    }

    public static DiagnosticMessage[] Flush()
    {
        _messages ??= new();
        var res = _messages.ToArray();
        _messages.Clear();
        return res;
    }
}

基本的には、static な List<DiagnosticMessage> としてメッセージを蓄積しておき、あとで配列として取得できるようにするものです。

一点注意が必要なのが、このリストには [ThreadStatic] 属性がつけられています。これは前項で説明した通り、ILPostProcessor はそれぞれのアセンブリが異なるスレッドで処理されるため、異なるアセンブリのメッセージが混ざらないように、スレッドごとに別の List インスタンスを使用するために使われています。

後は、蓄積されたメッセージを ILPostProcessor 側で Flush() して取得するようにしてあげればOKです。

using System;
using System.Linq;
using Unity.CompilationPipeline.Common.ILPostProcessing;

public class EnvironmentCheck : 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);

        Diagnostics.Log(
            $"{compiledAssembly.Name} / PID: {System.Diagnostics.Process.GetCurrentProcess().Id}, TID: {Environment.CurrentManagedThreadId}, version: {Environment.Version}, framework desc: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}, CommandLine: {Environment.CommandLine}");

        return new ILPostProcessResult(null, Diagnostics.Flush().ToList());
    }
}

これで、どこからでも Debug.Log の感覚でログを出せるようになりました。

アセンブリ解決

前回行った最小実装の中で、このようなコードを書きました。

  class AssemblyResolver : BaseAssemblyResolver
  {
  }

この AssemblyResolver は、Mono.Cecil での処理中に、参照アセンブリの実体を解決する際に必要なオブジェクトです。

そもそも、.NETはアセンブリ同士の参照関係をどのように管理しているんでしょうか?試しに、コンパイル済みのアセンブリildasm というツールでテキスト表現に変換してみると、次のように出力されます。

📝 ildasmの詳しい使い方は第5回で説明します。

.assembly extern netstandard
{
  .publickeytoken = (CC 7B 13 FF CD 2D DD 51 )                         // .{...-.Q
  .ver 2:1:0:0
}
.assembly extern UnityEngine.CoreModule
{
  .ver 0:0:0:0
}

これは外部アセンブリの参照を示した部分です。このアセンブリnetstandardUnityEngine.CoreModule の二つのアセンブリを参照しています。また、アセンブリ参照は publickeytokenversion によって指定されています。

重要なのは、アセンブリの実体(.dll)が存在するファイルパスはここでは指定されていないということです。Mono.Cecilアセンブリを正しく解析するためには、すべての参照アセンブリの実体が必要です。このアセンブリ参照を実体に解決するのが AssemblyResolver の役割です。

では、AssemblyResolver の使われ方を見てみましょう。前回の最小実装で、AssemblyResolver をこのように初期化しました。

        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
        };

        var assembly = AssemblyDefinition.ReadAssembly(new MemoryStream(compiledAssembly.InMemoryAssembly.PeData),
            readerParameters);

ILPostProcessor では、引数として渡される ICompiledAssemblyReferences プロパティから、参照アセンブリの場所を得ることができます。これを使ってアセンブリを検索するディレクトリを列挙し、AssemblyResolver.AddSearchDirectory()AssemblyResolver に登録します。

作成された AssemblyResolverReaderParameter にセットされ、その ReaderParameter を使ってアセンブリをロードしてるのがわかります。

アセンブリ定義のキャッシュ

さて、この AssemblyResolver の実装ですが、実は少し問題があります。次のコードを見てください。

        var stringClassReference = assembly.MainModule.TypeSystem.String;
        var stringClass1 = stringClassReference.Resolve();
        var stringClass2 = stringClassReference.Resolve();

        Diagnostics.Log((stringClass1 == stringClass2).ToString());

assembly.MainModule.TypeSystem.String を使うと、標準クラスライブラリにある System.String 型への参照(TypeReference)を取得することができ、TypeReference に対してResolve() を呼び出すと、そのクラスが存在するアセンブリ上の定義(TypeDefinition)を取得することができます。

📝 TypeReferenceTypeDefinition については、第4回で詳しく説明します。

上のコードのように、同じ TypeReference に対して Resolve() を呼び出すと、常に同じ TypeDefinitionインスタンスが取得されるべきです。しかし、このコードは False を出力してしまいます。

実は、TypeReference には参照先アセンブリ(モジュール)の情報が含まれており、TypeReference の解決時には、内部的に AssemblyResolverアセンブリ解決処理が呼ばれています。そして AssemblyResolver のデフォルト実装は毎回アセンブリをフォルダから検索してDLLファイルを読み込んでしまうので、メモリ上では別々のインスタンスとして存在してしまう問題があります。

これを解決するために、一度ロードしたアセンブリをキャッシュする仕組みを AssemblyResolver に導入します。

    class AssemblyResolver : BaseAssemblyResolver
    {
        private readonly Dictionary<string, AssemblyDefinition> _cachedAssemblies = new();

        protected override AssemblyDefinition SearchDirectory(AssemblyNameReference name,
            IEnumerable<string> directories, ReaderParameters parameters)
        {
            if (_cachedAssemblies.TryGetValue(name.Name, out var cached)) return cached;

            var resolved = base.SearchDirectory(name, directories, parameters);

            if (resolved != null)
            {
                _cachedAssemblies[name.Name] = resolved;
            }

            return resolved;
        }
    }

こうすることで、TypeReference は常に同じインスタンスに解決されるようになりました。

これで ILPostProcessor を実装していくための下地が整いました。次回からは実際に IL 命令を編集する方法について説明していきます。