Unity を検索

AnimationEvent を CoreCLR ガベージコレクターに対して安全にする

2022年10月25日 カテゴリ: テクノロジー | 11 分 で読めます
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
取り上げているトピック
シェア

最新の.NETテクノロジーを Unity ユーザーに使っていただけるように、私たちは懸命に取り組んでいます。その一環として、既存の Unity のコードを、マイクロソフトの .NET CoreCLR JIT ランタイムで動作させることにしました。このランタイムは、高いパフォーマンスと効率的なガベージコレクター(GC)を備えています。

このブログは、アップデートされた AnimationEvent を高度な GC と組み合わせて使えるように、私たちのチームが行った最近の変更の一部についてその理解を助けることを目的に書かれました。

ネイティブからマネージドへ(そしてその逆も)

Unity のエンジンコードは、C#(マネージド)コードと C++(ネイティブ)コードの両方で記述されています。マネージドコードからネイティブコードへの移行は厄介でコストがかかりますが、最高のパフォーマンスを持つソリューションを提供する素晴らしい手段でもあります。

例えば、Unity の AnimationClip オブジェクトの上にある AddEvent メソッドを見てみましょう。そのメソッドのコードはとてもシンプルです。

public void AddEvent(AnimationEvent evt)
{
    if (evt == null)
        throw new ArgumentNullException("evt");
    AddEventInternal(evt);
}

ここで、AddEventInternal はネイティブメソッドです。Unity エンジンは、通常の p/invoke マーシャリングを行わずに直接この呼び出しを行うため、AddEventInternal を実装する C++ コードは、マネージドヒープから割り当てられたメモリへのポインターを取得することになります。Unity はよく知られた保守的で Non-moving な Boehm ガベージコレクターを使っているので、ネイティブコードは安全にマネージドメモリに直接アクセスできます。

しかし、Unity が代わりに正確で Moving な CoreCLR GC を使い始めたら何が起きるのでしょうか。考えられる問題は 2 つあります。CoreCLR は以下のようなことを引き起こす可能性があります。

  1. マネージドメモリ内の AnimationEvent 型のレイアウトを変更する。
  2. AnimationEvent インスタンスがメモリ上で参照しているオブジェクトを、ネイティブコードが使おうとしている間に移動させる。

では、この 2 つの問題をどのように解決したのか、ご紹介しましょう。

blit にするか否か

CoreCLR ランタイムが AnimationEvent インスタンスのデータをネイティブコードで使用できるように、確実に安定した表現を与えてくれるようにしなければなりません。これは、blittable 表現と呼ばれています。つまり、C# でも C++ でも、メモリ上のビットはまったく同じになるということです。現在、C# では、AnimationEvent はこのように定義されています。

public sealed class AnimationEvent
{
    internal float m_Time;
    internal string m_FunctionName;
    internal string m_StringParameter;
    internal Object m_ObjectReferenceParameter;
    internal float m_FloatParameter;
    internal int m_IntParameter;

    internal int m_MessageOptions;
    internal AnimationEventSource m_Source;
    internal AnimationState m_StateSender;
    internal AnimatorStateInfo m_AnimatorStateInfo;
    internal AnimatorClipInfo m_AnimatorClipInfo;
}

CoreCLR のランタイムは、参照型であるすべてのフィールドをメモリ上の表現の先頭に移動させるので、GC コードはキャッシュの局所性を利用することができます。すると、CoreCLR の内部レイアウトはこのようになります。

public sealed class AnimationEvent
{
    internal string m_FunctionName;
    internal string m_StringParameter;
    internal Object m_ObjectReferenceParameter;
    internal AnimationState m_StateSender;
    internal float m_Time;
    internal float m_FloatParameter;
    internal int m_IntParameter;
    internal int m_MessageOptions;
    internal AnimationEventSource m_Source;
    internal AnimatorStateInfo m_AnimatorStateInfo;
    internal AnimatorClipInfo m_AnimatorClipInfo;
}

フィールドはすべて同じですが、順序が異なることに注意してください。AddEventInternal のコードは、両方の命令を処理する必要があります。また、CoreCLR は将来的にこれらのフィールドの配置を自由に変更できるため、Unity エンジンのネイティブコードも変更する必要があるかもしれません。

マネージドとネイティブの境界を越えて AnimationEvent のデータを渡すためだけに使用される新しい内部型を導入することで、これらの問題を回避することができます。

[StructLayout(LayoutKind.Sequential)]
internal struct AnimationEventBlittable
{
    internal float m_Time;
    internal IntPtr m_FunctionName;
    internal IntPtr m_StringParameter;
    internal IntPtr m_ObjectReferenceParameter;
    internal float m_FloatParameter;
    internal int m_IntParameter;

    internal int m_MessageOptions;
    internal AnimationEventSource m_Source;
    internal IntPtr m_StateSender;
    internal AnimatorStateInfo m_AnimatorStateInfo;
    internal AnimatorClipInfo m_AnimatorClipInfo;
}

この型のフィールドはどれも参照型ではないので、CoreCLR はそれらを並べ替えないことに注意してください。その名が示す通り、AnimationEvent のデータを blittable に表現したものです。

これを扱うことができるのか

m_FunctionName のような参照型のフィールドに何が起こったか分かったでしょうか。文字列ではなく、IntPtr になっています。

ここに 2 つ目の問題の解決策があります。通常の AnimationEvent の場合、m_FunctionName は GC で確保された移動可能な文字列へのポインタです。しかし、AnimationEventBlittable では、GCHandle であり、ネイティブコードからマネージドオブジェクトに安全にアクセスすることができます。

これで、AnimationEvent から AnimationEventBlittable に変換するメソッドが書けるようになりました。

internal static AnimationEventBlittable FromAnimationEvent(AnimationEvent animationEvent)
{
    var animationEventBlittable = new AnimationEventBlittable
    {
        m_Time = animationEvent.m_Time,
        m_FunctionName = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_FunctionName)),
        m_StringParameter = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_StringParameter)),
        m_ObjectReferenceParameter = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_ObjectReferenceParameter)),
        m_FloatParameter = animationEvent.m_FloatParameter,
        m_IntParameter = animationEvent.m_IntParameter,
        m_MessageOptions = animationEvent.m_MessageOptions,
        m_Source = animationEvent.m_Source,
        m_StateSender = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_StateSender)),
        m_AnimatorStateInfo = animationEvent.m_AnimatorStateInfo,
        m_AnimatorClipInfo = animationEvent.m_AnimatorClipInfo
    };
    return animationEventBlittable;
}

この時点で、安全な AddEvent メソッドは次のようになります。

public void AddEvent(AnimationEvent evt)
{
    if (evt == null)
        throw new ArgumentNullException("evt");
    var animationEventBlittable = AnimationEventBlittable.FromAnimationEvent(evt);
    AddEventInternal(animationEventBlittable);
    animationEventBlittable.Dispose();
}

最後に AddEventInternal に対して、これらの GCHandle をアンラップして、本物のマネージドオブジェクトデータに戻すために、いくつかの変更を必要とします。これらの変更により、CoreCLR の GC をフルに活用しつつ、メモリセーフを実現することができました。いいですね!

これで本当に速くなっているのか

いや実は、速くなってないんです。ここまでは、新たに実行する必要があるコードをすべてお見せしただけです。でも、ここからが開発の面白いところです。

私たち Unity のチームは、コードを以前と同じような速さで動くようにできるのでしょうか。先にオチを言ってしまうと、できました。そして、安全性を維持しながらパフォーマンスを戻すために、エンジニアは 3 つ重要なことをしました。

まず、AnimationEventBlittable はクラスではなく、構造体であることを思い出してください(このコードを最初に書いた時点ではクラスでした)。スタック上に割り当てられるため(GC 経由ではない)、低コストになります。Mono、Il2CPP、CoreCLR による FromAnimationEvent メソッドのコード生成は素晴らしいもので、チームはメソッド自体のオーバーヘッドを測定可能なレベルで見出せないほどです。

もちろん、FromAnimationEvent メソッドは、 GCHandle.Alloc メソッドを(4 回も)呼び出しているので、このメソッドは安価ではありません。すべての .NET 仮想マシン実装は、GCHandle を割り当て、内部データ構造を更新して割り当てた GCHandle を追跡するために、軽くはない作業を行う必要があります。これらの変更点をプロファイリングしているうちに、重要なことに気づきました。GCHandle は生存時間が長くないのです。各ハンドルはネイティブコードの実行中にのみ必要なので、簡単に再利用することができます。この実装では、少数の GCHandle をプールし、FromAnimationEvent を呼び出すたびにそれらを再利用しています。つまり、FromAnimationEvent が何度も呼び出されるような現実的なユースケースでは、これらの GCHandle を割り当てるコストはほぼゼロになるのです。

また、ネイティブコードではまだ示されていない隠れたコストがあります。C++ のコードで「GCHandle をアンラップする」必要があるという、先ほどの議論を思い出してください。さて、CoreCLR はこのプロセスを本当に 速くすることがわかりました。GCHandle のターゲットを得る(つまりアンラップする)ために、CoreCLR は単純なポインターの逆参照と同じコストを必要とする、ということです。

しかし、私たちのベンチマークでは、同じコードで Mono と IL2CPP を使用した場合、著しく遅くなりました...何が原因なのでしょうか。調べてみたところ、Mono と IL2CPP では、GCHandle のアンラップは実際にはかなり高価であることがわかりました。ありがたいことに、クリティカルパスではめったに起こらないと分かりましたが、今回の変更で、これは留意すべき要素として浮上しました。そのため、Mono と IL2CPP で、CoreCLR が使っているアルゴリズムと同じものを実装しています。

すべての変更点を整理した上で、AddEvent メソッドとその他の公開 API メソッドの両方を含む AnimationEvent の内部ベンチマークでは、以前のコードと新しいコードに違いは見られませんでした。素晴らしいですね!

パフォーマンスか安定性か?両方取りましょう

CoreCLR ランタイムと GC は、全体的なパフォーマンスの向上を約束するものです。マイクロソフトは .NET のためにこれらに多大な投資をしており、このような改良を Unity ユーザーに提供できることを本当に嬉しく思っています。

既存のコードの安全性と安定性を維持しながら、最新の .NET アプリケーションの性能をフルに発揮することが期待されます。チームは、この調査で学んだ技術を、Unity エンジンコード内で、他にマネージドとネイティブの境界を行き来する部分(多数あります)にも適用していく予定です。

アニメーションや CoreCLR に関連するヒントをもっと知りたい方は、フォーラムをご覧いただくか、Twitter で私(@petersonjm1)と直接コンタクトをとってください。現在連載中の Tech from the Trenches シリーズの他の Unity 開発者による新しい技術ブログもぜひご覧ください

2022年10月25日 カテゴリ: テクノロジー | 11 分 で読めます
取り上げているトピック
関連する投稿