Unity は最新の .NET 技術を Unity ユーザーに使っていただけるよう、今なお懸命に取り組んでいます。この取り組みを率いるチームメンバーの 1 人として、さらなる進展を皆さんにお伝えできることを嬉しく思います。取り組みでは、既存の Unity コードを .NET CoreCLR JIT ランタイムで動作させることを目標の 1 つとしています。このランタイムは、高性能、高度、かつ効率的なガベージコレクター(GC)を備えています。
このブログ記事では、CoreCLR GC と Unity エンジンのネイティブコードを緊密に連携させるために行った最近の変更について説明します。まず概要を説明してから、技術的な詳細について述べます。
C# 言語で行われるメモリ割り当ては、ガベージコレクターによって管理されます。メモリの割り当てが必要な場合、そのメモリを割り当てるコードは、そのメモリが使用されなくなった場合は、常にそのメモリを無視できます。そのメモリは、他のコードが使用できるように後で GC によってリサイクルされます。
Unity は現在、保守的で非移動型の GC である Boehm GC を使用しています。Boehm GC は、すべてのスレッドスタック(マネージドコードとネイティブコードを含む)をスキャンして、回収するマネージドオブジェクトを探します。マネージドオブジェクトを割り当てた後は、メモリ内でそのオブジェクトの場所を変えることはありません。
NET では、厳密な移動型 GC である CoreCLR GC が使用されます。CoreCLR GC は、割り当てられたオブジェクトをマネージドコード内でのみ追跡し、パフォーマンスを向上させるためにメモリ内でオブジェクトを移動します。この仕組みにより、CoreCLR GC ははるかに少ないオーバーヘッドで動作し、ゲームのパフォーマンスを向上させることができます。
どちらの GC も機能は優れていますが、それらを使用するコードには異なる要件が課せられます。Unity エンジンとエディターのコードは Boehm GC の要件に基づいて開発されているため、CoreCLR GC を使用するには、Unity コードに多くの変更を加える必要があります。Unity が記述したカスタムマーシャリングツール(バインディングジェネレーターとプロキシジェネレーター)も変更が必要なコードの 1 つです。
マネージドコードとは、角を曲がった所にコーヒーショップがあり、その通りの先に食料品店があるような街に建つ家と考えることができます。この家を「マネージドコードの地」と呼びましょう。開発者にとって、ここは素晴らしい住み家です。しかし、時にはここから離れ、C++ コードの本来の生息地である「ネイティブコードの荒野」に行きたいと思うこともあります。
2 つの場所を移動するときは、マーシャリング鉄道がスーツケースの持ち込みを許可しているので、マネージドメモリをいくらか持ち込むことができます。「荒野」でお土産を買って家に持ち帰りたい場合もあるでしょう。
使用しなくなったメモリは、どこにあろうと GC が忠実に追跡してリサイクルしてくれるので助かります。しかし、GC にはやるべきことがたくさんあります。これらのスレッドとコールスタックはあっという間に膨れ上がります。後でネイティブコードの荒野に何度も足を運ぶと、GC はほとんどの時間をあなたを追いかけ回して過ごすことになります。
Unity エンジンを CoreCLR に移行する作業の大半を占めるのは、エンジンコードと GC を緊密に連携させる作業です。
GC とマーシャリング鉄道は、マネージドメモリをネイティブコードの荒野に越境させないことで合意しました。この合意によって GC の作業は大幅に減るため、効率性が向上します。CoreCLR GC はこのモードで動作します。つまり、存在するオブジェクトを正確に把握し、マネージドコードのみを処理します。これにより、メモリ内でオブジェクトを移動させて効率性を向上させることも可能です。
楽しい図や絵文字はかわいいですが、マネージドコードとネイティブコードを何千回も行き来しながら 10 年以上かけて進化してきた本番コードベースに実際に実装する必要があります。
システム設計の観点から考えると、境界を見つける必要があります。Unity には、以下の 2 つの重要な内部的境界があります。
これらのツールはどちらも、上述の 2 つの場所の間でメモリを移動する鉄道としての役割を果たす C++ および IL コードを生成します。Unity の開発者は過去 1 年間、GC が割り当てたオブジェクトが境界を越えてリークすることがないように、またリークした場合は有用な診断を提供するように、これら 2 つのコードジェネレーターを修正してきました。マネージドとネイティブの境界自体を越えようとするコードも見つかっており、そのコードを代わりにこれらのコードジェネレーターの 1 つに移行しています。
もちろんこのようなことが発生するのは、Unity の他の何百人もの開発者がエンジンコードを積極的に変更して新機能やバグ修正をユーザーに提供しているからです。私たちは飛行中のロケットを改造しようとしているのです。私たちがどのようにこの移行を段階的に行うことができたかをより詳しく理解するために、このマネージドとネイティブの境界の一側面である System.Object を深く掘り下げてみましょう。
.NET で GC によって割り当てられたすべてのメモリは、System.Object という型のオブジェクトと結合されているはずです。これはすべての .NET 型の基底クラスであるため、ネイティブコードに越境するメモリにとって重要な場合が多くあります。Unity エンジンの C++ コードは、System.Object の代わりとなる ScriptingObjectPtr 抽象クラスを使用しています。
typedef MonoObject* ScriptingBackendNativeObjectPtr;
class ScriptingObjectPtr {
public:
ScriptingObjectPtr(ScriptingBackendNativeObjectPtr target) : m_Target(target) {}
protected:
ScriptingBackendNativeObjectPtr m_Target;
};
このようにして、マネージドメモリはネイティブコードにたどり着きます。ScriptingBackendNativeObjectPtr は、GC が割り当てたメモリに対するポインターです。Unity の現在の GC は、ネイティブコードのすべてのコールスタックを走査し、ScriptingObjectPtr の可能性があるメモリを慎重に探します。GC が割り当てたメモリに対するポインターでなくなるように、これらのインスタンスを変更できれば、GC の負担が軽減され、最終的に CoreCLR GC がさらに高速になります。
ScriptingObjectPtr の表現を 1 つに限定するのではなく、次の 3 つの可能な表現のいずれかを使用する必要があります。
GC 割り当てポインターは、GC アンセーフな使用をすべて取り除くための一時的なステップです。この表現では、ScriptingObjectPtr が現在と同様に機能し続けることができます。すべての Unity コードが CoreCLR GC に対して安全になった時点で、このユースケースを削除することが目的です。
マネージドスタック参照は、マネージドからネイティブに値が渡された場合に、マネージドオブジェクトへの間接的な参照を表す効率的な方法です。GC 割り当てポインター変数のアドレスは、(GC 割り当てポインター自体ではなく)ネイティブコードに渡されます。ローカルアドレス自体は GC によって移動されず、マネージドオブジェクトはマネージドコード内のコールスタックで存続するため、これは GC セーフです。このアプローチは、CoreCLR ランタイム内で使用されている類似の手法からヒントを得ています。
GCHandle は、マネージドオブジェクトへの強力な間接的参照として機能し、オブジェクトが GC によって回収されないことを保証します。もし荒野で休暇を過ごしている間にマネージドコードの地にメモリを残してしまった場合、戻ってくるまでそのメモリを保持したいとあなたが考えていることを GC はわかっています。これはマネージドスタック参照のケースと似ていますが、明示的な生存期間管理が必要です。さらに、GCHandle の構築と破棄のために、追加のオーバーヘッドが発生します。どうしても必要な場合にのみこの表現を使用するということを、このオーバーヘッドは表しています。
この表現は、ScriptingBackendNativeObjectPtr の代わりとなる新しい型の ScriptingReferenceWrapper を使用して実装されます。
struct ScriptingReferenceWrapper
{
// Various constructors elided for brevity
void* GetGCUnsafePtr() const;
static ScriptingReferenceWrapper FromRawPtr(void* ptr);
private:
// Assumption: all pointers are 8 byte aligned.
// This leaves 2 bits for tracking.
// One bit is already in use by GCHandle
// Bits
// 0 - reserved for GC Handles.
// 1 - 0 - object reference
// - 1 - gc handle
// 2 - 0 - this is a managed object pointer
// - 1 - this is a GCHandle or object reference
// 0b00 - object pointer
// 0b01 - object reference
// 0b1_ - gc handle; lowest bit is implementation specific
bool IsPointer() const {
return (((uintptr_t)value) & 0b11) == 0b00; }
bool IsRef() const {
return (((uintptr_t)value) & 0b11) == 0b01; }
bool IsHandle() const {
return (((uintptr_t)value) & 0b10) == 0b10; }
uintptr_t value;
};
ここでは、多くのコンストラクターや割り当て演算子を省略しました。これらは、内部リソースの適切な生存期間管理を遵守するために使用されます。
この型のサイズに注意してください。1 つの uintptr_t 値のみで構成され、ポインターと同じサイズです。つまり、ScriptingReferenceWrapper は ScriptingBackendNativeObjectPtr と同じサイズです。ScriptingObjectPtr が違いを把握しているため、これでコードを使用せずに 1 対 1 で置換できます。
ここで重要なのは、コードのコメントで言及されている 4 バイトのアラインメント要件です。C# 言語で行われるメモリ割り当ては、ガベージコレクターによって管理されます。この要件が守られていれば、その値の 2 ビットを再利用して、3 つの表現のどれが使用されているかを示すことができます。Unity コードを移行している間は、GetGCUnsafePtr と FromRawPtr のメソッドにより、GC 割り当てポインター表現に一時的な相互運用が提供されます。
理想的な環境では、ScriptingObjectPtr 抽象クラスは不要であり、ネイティブコードはマネージドメモリにアクセスしません。しかし、アクセスできることが有益な場合もあります。そのため、GC の安全を確保する作業をエンジンで完了して、マネージドスタック参照と GCHandle の各ケースを維持し、GC 割り当てポインターのケースを完全に削除するつもりです。
ここで、GC とコード生成の間の合意が役割を果たします。3 つのサブシステムがすべて、ScriptingObjectPtr の可能な表現を理解できるようになったので、私たちのチームはエンジンコードでの使用を段階的に置き換えています。不要な場所では ScriptingObjectPtr を削除し、必要な場所では最も効率的な表現を使用することができます。それぞれの使用がエンドツーエンドで変更されている限り、異なるすべての表現は共存でき、ロケットは飛び続けることができます。
完全に GC セーフなエンジンを使用することで、CoreCLR GC を有効にして、CoreCLR GC がマネージドコードの地でリサイクルするメモリを探すだけで済むようにできます。つまり、CoreCLR GC の作業量は大幅に削減され、コードを実行する各フレームの時間は長くなります。
Unity の CoreCLR への移行について詳しくは、フォーラムを参照するか、Unity の製品ロードマップを詳しく説明する Unite 2023 にアクセスしてください。X で私(@petersonjm1)と直接とつながることもできます。また、現在連載中の Tech from the Trenches シリーズの他の Unity 開発者による新しい技術ブログもぜひご覧ください。
Is this article helpful for you?
Thank you for your feedback!