Unity を検索

文字列マーシャリングを CoreCLR ガベージコレクターに対して安全にする

2023年2月14日 カテゴリ: Engine & platform | 10 分 で読めます
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
シェア

Is this article helpful for you?

Thank you for your feedback!

前回のブログで紹介したように、私はチームと共に最新の .NET 技術を皆さんにお使いいただくための取り組みを行っています。この取り組みは、既存の Unity のコードを、マイクロソフトの .NET CoreCLR JIT ランタイムで動作させることを目標の 1 つとしています。このランタイムは、より高度かつ効率的なガベージコレクター(GC)を備えています。

このブログでは、私のチームが最近施した、マネージドとネイティブの境界を越えて GC セーフな方法で文字列データのマーシャリングを可能にする変更について理解するためのガイドを提供しようと思います。なぜこの変更を施したのかの背景については、以前書いた AnimationEvent のマーシャリングについてのブログで少し説明しています。

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

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

例えば、C# の String 型を考えてみましょう。単なる文字の配列ですから、かなりシンプルなもののはずです。しかしそうはいきません。文字列のマーシャリングには、目に見えない、そして興味深い複雑なポイントがたくさんあります。

Unity の tooltip プロパティの実装では、ネイティブコードを呼び出して適切なデータを取得または設定します。

public static string tooltip { get { return Internal_GetTooltip();} set { Internal_SetTooltip(value); } }

そして、Internal_GetTooltipInternal_SetTooltip が C++ コードで次のように宣言されています。

ScriptingStringPtr Internal_GetTooltip(); void Internal_SetTooltip(const core::string& value);

ここで、ScriptingStringPtr は文字列オブジェクトへのポインターで、これは .NET のガベージコレクターで管理されているメモリです。core::string は Unity の C++ における文字列の内部表現です(std::string に類似)。すでにいくつかの問題点を発見していれば、これはボーナスポイントです。

私の文字列とあなたの文字列は一緒じゃないかもしれない

奇妙に聞こえるかもしれませんが、現代のプログラミング言語にはさまざまな文字列表現があります。C# では、文字列は、文字列の文字数を格納する 32 ビット整数と、その後に続く 2 バイトの UTF16 エンコード文字列の配列で表現されます。Unity の C++ の core::string は、数字文字にマシン定義サイズの整数を使用しますが、それらの文字は 1 バイトの UTF8 エンコードされた値の配列に格納されます。

このようにさまざまな表現があることは、文字列が Blittable ではないことを意味するので、このマネージドとネイティブの境界を越えてデータを行き来させるためには、何らかのマーシャリングが必要になるのです。このマーシャリングには、適切なサイズのデータバッファの割り当て、文字情報の変換が含まれ、そうすることで可能性のあるすべてのロケールの値が上手く扱われるように取り計らいます。

通常、C# 開発者は、組み込みの p/invoke マーシャリングを使用して、こうした詳細を処理するように .NET ランタイムに依頼することができます。しかし、Unity にはカスタムマーシャリングツールがあり、私たちはこれをバインディングジェネレーターと呼んでいます。

バインドを実現する繋ぎ手

このカスタムマーシャリングツールを使用するために、Unity のマネージドモードからネイティブモードへの関数呼び出しでは、CoreCLR の Unity のフォークで .NET ランタイムの特別な機能である内部呼び出し(略称 icall)が採用されています。一般的な用途にはお勧めしませんが、icall は戻り値や関数の引数をマーシャリングせずに関数ポインターを呼び出します。icall のネイティブ側は、関数呼び出しのマネージド側からのすべての引数のレイアウトと位置について十分な情報を持っている必要があります。icall は本質的に安全ではありませんが、デフォルトの p/invoke マーシャリングを使用した場合よりも、より高いパフォーマンスを引き出すことができます。

バインディングジェネレーターは、Unity C# のコードを解析し、icall として実装されている extern メソッドを探します。各 icall ごとに、マネージドアセンブリに直接生成される .NET中間言語(IL)コードと、後で Unity のビルドプロセスでコンパイルされるファイルに生成される C++ コードの両方が生成されます。Unity のコードベースには約 1 万種類の icall が存在するため、バインディングジェネレーターはこのプロセスを自動化し、最適化のためのフックを提供する重要なツールとなっています。

ツールチップを改善しよう

もし、上記のツールチップの定義の問題を見逃していたとしても、心配しないでください。実は些細な問題なのです。まず、GC が管理するメモリへの生ポインター(ScriptingStringPtr)は、オブジェクトを移動させる(Moving)CoreCLR GC では上手く動作しません。次に、Internal_SetTooltip は UTF8 文字列を受け付けますが、C# は UTF16 文字列を使用します。そこで変換を回避して、パフォーマンスを向上させることはできるでしょうか。やってみましょう。

無駄な作業を省く

core::string は、Unity のネイティブコードで文字列を表現する唯一の方法ではないことがわかりました。Unity には UTF16String という型もあり、これは文字数を示す 32 ビット整数と、2 バイトの UTF16 エンコード値の配列です(C# の文字列表現と同じです)。

上記の C# の公開 API でツールチップを設定した場合、ネイティブの実装は以下のようになります。

static void Internal_SetTooltip(const core::string& value) { UTF16String str(value.c_str()); GUIState &cState = GetGUIState(); cState.m_OnGUIState.SetMouseTooltip(str); cState.m_OnGUIState.SetKeyTooltip(str); }

内部的には、core::string のその UTF8 文字列は、UTF16 表現にコピーバックされます。その代わりに、UTF16String を直接受け入れるようにメソッドのシグネチャを変更します。

void Internal_SetTooltip(const UTF16String& str);

バインディングジェネレーターは、C# の文字列を ReadOnlySpan として扱い、GC が呼び出し中に移動しないようにそのメモリを一時的に固定する Microsoft Intermediate Language(MSIL)コードをいくつか作成します。これは、バインディングジェネレーターにすでに組み込まれている、Span 型のマーシャリングに対する既存のサポートを利用するものです。最後に、生成された C++ コードによって、その SpanUTF16String として使用されるようになります。

ここで 1 つ重要かつ特殊なケースについて処理する必要があります。すなわち、Span が空かそうでないかという判定です。しかし、文字列には null である場合と、非 null だが空である可能性があります。そこで、特別な ManagedSpanWrapper 型を作成して、null の Span を処理するようにしました。生成された MSIL コードは、C# では次のようになります。

private unsafe static void Internal_SetTooltip(string value) { ManagedSpanWrapper managedSpanWrapper; if (!StringMarshaller.TryMarshalEmptyOrNullString(value, ref managedSpanWrapper)) { fixed (char* begin = value.AsSpan()){ managedSpanWrapper = new ManagedSpanWrapper(begin, value.Length); Internal_SetTooltip_Injected(in managedSpanWrapper); } } else { Internal_SetTooltip_Injected(in managedSpanWrapper); } }

GC に任せる

以前、CoreCLR GC は、ネイティブコードにいくつかの制約を与える代償として、優れたパフォーマンスを提供すると述べました。具体的には、ネイティブコードで GC が管理するメモリに直接アクセスすることができなくなったのです。C# では文字列は GC が管理するので、Internal_GetTooltip から生ポインターを返すことはできません。ここまでで、Unity は内部的にすでにツールチップの値を UTF16 文字列として表現していることを確認しました。これは C# の文字列表現とよく一致しているので、シグネチャを次のように変更します。

UTF16String Internal_GetTooltip();

これで、バインディングジェネレーターは、GC を一時停止したりメモリを固定したりする特別なコードを必要とせずに、GC に C# 側で文字列メモリを管理させ、完全な制御をさせることができます。

やってみよう

これは、Unity のコードベースで頻繁に行われる文字列パラメーターのマーシャリングの 1 つのフレーバーに過ぎません。文字列だけでなく、バインディングジェネレーターはマネージドコードからネイティブコードに渡すことができるすべての型を処理する必要があります。しかし、大規模な Unity のコードベース全体に小さなチームの作業を活用するための素晴らしいツールともなりえます。

このように徹底的に変更することで、Unity が CoreCLR GC に対して安全になると同時に、パフォーマンスの改善も図ることができます。

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

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

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

文字列マーシャリングと CoreCLR に関連するヒントをもっと見たい方は、フォーラムをご覧ください。あるいは、TwitterMastodon で直接話しかけていただいてもかまいません。また、現在連載中の Tech from the Trenches シリーズの他の Unity 開発者による新しい技術ブログもぜひご覧ください

2023年2月14日 カテゴリ: Engine & platform | 10 分 で読めます

Is this article helpful for you?

Thank you for your feedback!

関連する投稿