Unity の Burst コンパイラー は、C# コードを高度に最適化された機械語に変換します。1年前の Burst コンパイラーの最初の安定版リリース以来、私たちはコンパイラの品質・ユーザー体験・堅牢性の向上に取り組んできました。新しいメジャーバージョンアップである Burst 1.3 のリリースを受けて、この機会にパフォーマンスに焦点を当てた重要な機能であるエイリアス対応の強化についてご紹介したいと思います。
新しいコンパイラー組込み関数である Unity.Burst.CompilerServices.Aliasing.ExpectAliased と Unity.Burst.CompilerServices.Aliasing.ExpectNotAliased により、自分が書いたコードをコンパイラーがどのように把握しているかを確認することができるようになりました。これらの新しい機能と [Unity.Burst.NoAlias] 属性の強化を組み合わせることで、パフォーマンスを追求する上で新たな力を提供することができます。
このブログ記事では、エイリアスの概念と [NoAlias] 属性を使用して構造体の中身がどのようにエイリアスになっている可能性があるかを指定する方法、コンパイラーがあなたのコードをどのように理解しているかを確認にするために、エイリアスのためのコンパイラーの新しい組込み関数を使用する方法を説明します。
エイリアスとは、データへの2つのポインタがたまたま同じメモリ上の位置を指していることを言います。
int Foo(ref int a, ref int b) { b = 13; a = 42; return b; }
このコードはパフォーマンスに影響する、エイリアスによる古典的な問題を含んでいます。なんのヒントもなければ、コンパイラーは変数 a と変数 b がエイリアスになっているか分からないため、次のような無駄のある機械語を生成してしまいます。
mov dword ptr [rdx], 13 mov dword ptr [rcx], 42 mov eax, dword ptr [rdx] ret
この機械語は、
コンパイラーには変数 a と変数 b が同じメモリに存在するかどうかが分からないため、変数 b を再読み込みする必要が生じます。 同じメモリに存在する場合、変数 b の値は 42 になりますが、 同じメモリに存在しない場合は変数 b の値は 13 になることになります。
以下のシンプルなジョブのコードを見てみましょう。
[BurstCompile] private struct CopyJob : IJob { [ReadOnly] public NativeArray<float> Input; [WriteOnly] public NativeArray<float> Output; public void Execute() { for (int i = 0; i < Input.Length; i++) { Output[i] = Input[i]; } } }
このジョブは、単にあるバッファから別のバッファにコピーをするだけのものです。Input と Output がエイリアスになっていない場合、つまりそれらを格納しているメモリの領域が重なっていない場合、このジョブの動作は以下のようになります。
コンパイラーがこれらのふたつのバッファがエイリアスになっていないと認識している場合、Burst での上記のコード例のように、コンパイラーはコードをベクトル化して、要素ひとつひとつではなく、複数の要素をいっぺんにコピーできるようにできます。
仮に Input と Output がエイリアスになっていた場合、何が起こるかを見てみましょう。実際は、Unity が備える保護機構がこのようなよくあるケースをキャッチし、コードに問題があることをユーザーにお知らせします。しかし、その保護機構を無効にしたと仮定しましょう。
上記の図のように、メモリの位置が一部重なっているため、Input の最小の要素の値 a は Output の全体にコピーされることになります。この状態でコンパイラーがベクトル化を行った場合、つまり誤ってエイリアスになっていないと認識した場合、どうなってしまうでしょうか?
なんということでしょう、Output の内容はさきほどとはまったく違ったものになってしまいます。
エイリアスは、Burst コンパイラーがコードを最適化する力を制限してしまいます。これは特にベクトル化に悪影響を及ぼします。ループ内で使用されている変数がエイリアスになっているとコンパイラーが考えた場合、おそらくループを安全にベクトル化することはできないでしょう。Burst 1.3.0 以降では、エイリアスへの新しい対応方法によって、パフォーマンスを大幅に向上できるようになりました。
Burst 1.3.0では、[NoAlias] 属性は4種類の箇所に記述できるように拡張されました。
構造体のフィールドおよび関数の引数においてその型が構造体の場合、 「X とエイリアスになっていない」というのは、その構造体のどのフィールドに現れるポインターであっても(間接的にでも) X とエイリアスになっていないと保証することになります。
また [NoAlias] 属性を持つ引数は、ジョブの構造体などの「this」ポインターとエイリアスしないことを保証することにもなります。その構造体は、Entities.ForEach() を使った場合、ラムダ式によってキャプチャーされるすべての変数も含んでいます。
それでは、それぞれの使用例を順番に見ていきましょう。
さきほどの Foo 関数の例に [NoAlias] 属性を追加すると、どうなるか見てみましょう。
int Foo([NoAlias] ref int a, ref int b) { b = 13; a = 42; return b; }
出力される機械語は以下のように変わります。
mov dword ptr [rdx], 13 mov dword ptr [rcx], 42 mov eax, 13 ret
変数 b からの読み込みは、定数 13 を直接戻り値とする形に変わっていることにお気づきでしょうか。
同じ例を、代わりに構造体に適用してみましょう。
struct Bar { public NativeArray<int> a; public NativeArray<float> b; } int Foo(ref Bar bar) { bar.b[0] = 42.0f; bar.a[0] = 13; return (int)bar.b[0]; }
上記のコードから、以下のような機械語が生成されます。
mov rax, qword ptr [rcx + 16] mov dword ptr [rax], 1109917696 mov rcx, qword ptr [rcx] mov dword ptr [rcx], 13 cvttss2si eax, dword ptr [rax] ret
この機械語は、
ここでふたつの NativeArray が同じメモリに存在しないことをユーザーが分かっているなら、[NoAlias] 属性を使うことができます。
struct Bar { [NoAlias] public NativeArray<int> a; [NoAlias] public NativeArray<float> b; } int Foo(ref Bar bar) { bar.b[0] = 42.0f; bar.a[0] = 13; return (int)bar.b[0]; }
変数 a と b の両方に [NoAlias] 属性を指定することで、構造体内では互いにエイリアスになっていないことがコンパイラーに伝わり、生成される機械語は以下のように変わります。
mov rax, qword ptr [rcx + 16] mov dword ptr [rax], 1109917696 mov rax, qword ptr [rcx] mov dword ptr [rax], 13 mov eax, 42 ret
コンパイラーは整数の定数である 42 をそのまま戻り値にできるようになりました。
一般的に言ってだいたいの構造体では、その構造体へのポインタが構造体自身の中に現れないという前提を持つことができます。そうではない古典的な例を見てみましょう。
unsafe struct CircularList { public CircularList* next; public CircularList() { // The 'empty' list just points to itself. next = this; } }
これは稀な例ではありますが、連結リストは構造体から構造体自体へのポインタがありうるデータ構造になっています。
構造体の [NoAlias] が役立つ具体的な例を見てみましょう。
unsafe struct Bar { public int i; public void* p; } float Foo(ref Bar bar) { *(int*)bar.p = 42; return ((float*)bar.p)[bar.i]; }
生成される機械語は以下のようになります。
mov rax, qword ptr [rcx + 8] mov dword ptr [rax], 42 mov rax, qword ptr [rcx + 8] mov ecx, dword ptr [rcx] movss xmm0, dword ptr [rax + 4*rcx] ret
この機械語は、
変数 p が 2 回読み込まれていることに注目してください。その理由は、コンパイラーが変数 p が構造体 bar 自体のアドレスを指している可能性を考慮しているからです。コンパイラーは、念のために構造体 bar から変数 p を再読み込みしなければなりません。 なんという無駄でしょう。
ここで [NoAlias] 属性の登場です。
[NoAlias] unsafe struct Bar { public int i; public void* p; } float Foo(ref Bar bar) { *(int*)bar.p = 42; return ((float*)bar.p)[bar.i]; }
生成される機械語は以下のようになります。
mov rax, qword ptr [rcx + 8] mov dword ptr [rax], 42 mov ecx, dword ptr [rcx] movss xmm0, dword ptr [rax + 4*rcx] ret
コンパイラーに変数 p は構造体 bar へのポインターでは有り得ないことを教えたので、変数 p の読み込みは一度で済むようになりました。
関数の中には、毎回違ったポインターしか返さないものもあります。例えば、malloc はそのような関数のひとつです。このような場合、[return:NoAlias] 属性はコンパイラーを使うことができます。
スタックメモリーからメモリーを確保するバンプアロケーターを使った例を見てみましょう。
// Only ever returns a unique address into the stackalloc'ed memory. // We've made this no-inline as the compiler will always try and inline // small functions like these, which would defeat the purpose of this // example! [MethodImpl(MethodImplOptions.NoInlining)] unsafe int* BumpAlloc(int* alloca) { int location = alloca[0]++; return alloca + location; } unsafe int Func() { int* alloca = stackalloc int[128]; // Store our size at the start of the alloca. alloca[0] = 1; int* ptr1 = BumpAlloc(alloca); int* ptr2 = BumpAlloc(alloca); *ptr1 = 42; *ptr2 = 13; return *ptr1; }
生成される機械語は以下のようになります。
push rsi push rdi push rbx sub rsp, 544 lea rcx, [rsp + 36] movabs rax, offset memset mov r8d, 508 xor edx, edx call rax mov dword ptr [rsp + 32], 1 movabs rbx, offset "BumpAlloc(int* alloca)" lea rsi, [rsp + 32] mov rcx, rsi call rbx mov rdi, rax mov rcx, rsi call rbx mov dword ptr [rdi], 42 mov dword ptr [rax], 13 mov eax, dword ptr [rdi] add rsp, 544 pop rbx pop rdi pop rsi ret
かなりの量になってしまいましたが、この機械語は、
それでは、[return: NoAlias] 属性を追加してみましょう。
// We've made this no-inline as the compiler will always try and inline // small functions like these, which would defeat the purpose of this // example! [MethodImpl(MethodImplOptions.NoInlining)] [return: NoAlias] unsafe int* BumpAlloc(int* alloca) { int location = alloca[0]++; return alloca + location; } unsafe int Func() { int* alloca = stackalloc int[128]; // Store our size at the start of the alloca. alloca[0] = 1; int* ptr1 = BumpAlloc(alloca); int* ptr2 = BumpAlloc(alloca); *ptr1 = 42; *ptr2 = 13; return *ptr1; }
生成される機械語は以下のようになります。
push rsi push rdi push rbx sub rsp, 544 lea rcx, [rsp + 36] movabs rax, offset memset mov r8d, 508 xor edx, edx call rax mov dword ptr [rsp + 32], 1 movabs rbx, offset "BumpAlloc(int* alloca)" lea rsi, [rsp + 32] mov rcx, rsi call rbx mov rdi, rax mov rcx, rsi call rbx mov dword ptr [rdi], 42 mov dword ptr [rax], 13 mov eax, 42 add rsp, 544 pop rbx pop rdi pop rsi ret
コンパイラーは変数 ptr1 の示すアドレスから再読み込みするのではなく、単に 42 を引数の戻り値とするように変わりました。
[return: NoAlias] 属性は、上記のバンプアロケーターの例や malloc のような、毎回違ったポインターを生成することを100%保証できる関数でのみ使用してください。また、コンパイラーは性能を考慮して積極的に関数をインライン化するので、上記のような小さな関数は呼び出し元にインライン化されてしまい、[return: NoAlias] 属性を使わなかった場合と同じ結果を生成する可能性が高いことにも注意してください(それを避けるために、この例ではインライン化を強制的に行わないように指定しています)。
Burst は関数の引数同士のエイリアスの状況を把握している関数呼び出しにおいて、エイリアスの状況を推測し、呼び出された関数に伝搬させることで、最適化を押し進めることができます。例を見てみましょう。
// We've made this no-inline as the compiler will always try and inline // small functions like these, which would defeat the purpose of this // example! [MethodImpl(MethodImplOptions.NoInlining)] int Bar(ref int a, ref int b) { a = 42; b = 13; return a; } int Foo() { var a = 53; var b = -2; return Bar(ref a, ref b); } Previously the code for Bar would be: mov dword ptr [rcx], 42 mov dword ptr [rdx], 13 mov eax, dword ptr [rcx] ret
これは、Bar 関数の中で、コンパイラーが変数 a と b がエイリアスになっているか分からないためです。Burst 以外のコンパイラーにおいても結果は同様でしょう。
しかし、Burst はもっとスマートに、変数 a と b がエイリアスにならないことが分かっている場所では Bar の複製を作成し、元の呼び出しをその複製への呼び出しに置き換えます。その結果、出力される機械語は以下のようになるのです。
mov dword ptr [rcx], 42 mov dword ptr [rdx], 13 mov eax, 42 ret
見ての通り、変数 a からの無駄な読み込みが最適化されました。
エイリアスはコンパイラーの最適化の鍵となります。そのため、我々はエイリアスに対応するための組み込み関数を追加しました。
以下は使用例です。
using static Unity.Burst.CompilerServices.Aliasing; [BurstCompile] private struct CopyJob : IJob { [ReadOnly] public NativeArray<float> Input; [WriteOnly] public NativeArray<float> Output; public unsafe void Execute() { // NativeContainer attributed structs (like NativeArray) cannot alias with each other in a job struct! ExpectNotAliased(Input.getUnsafePtr(), Output.getUnsafePtr()); // NativeContainer structs cannot appear in other NativeContainer structs. ExpectNotAliased(in Input, in Output); ExpectNotAliased(in Input, Input.getUnsafePtr()); ExpectNotAliased(in Input, Output.getUnsafePtr()); ExpectNotAliased(in Output, Input.getUnsafePtr()); ExpectNotAliased(in Output, Output.getUnsafePtr()); // But things definitely alias with themselves! ExpectAliased(in Input, in Input); ExpectAliased(Input.getUnsafePtr(), Input.getUnsafePtr()); ExpectAliased(in Output, in Output); ExpectAliased(Output.getUnsafePtr(), Output.getUnsafePtr()); } }
これらの組み込み関数により、コンパイラーが知っているエイリアスの状況が想定通りかどうか確認することができます。これらはコンパイル時のチェックなので、引数に副作用がない場合、実行時のコストは発生しません。これらは特に、パフォーマンスに重要なコードで、後からの変更がエイリアスの状況をもとにした最適化を壊さないようにしたい時に便利です。Burst では、コンパイラー全体の制御によって、コードが意図した通りに最適化されていることかどうか確認できるようにコンパイラーから詳しい情報を得ることができるのです。
Unity の Job System には、エイリアスについていくつかの前提条件が組み込まれています。そのルールは以下の通りです。
厳密な定義は上記のものですが、コードを見た方がわかりやすいでしょう。
[BurstCompile] private struct JobSystemAliasingJob : IJobParallelFor { public NativeArray<float> a; public NativeArray<float> b; [NativeDisableContainerSafetyRestriction] public NativeArray<float> c; public unsafe void Execute(int i) { // a & b do not alias because they are [NativeContainer]'s. ExpectNotAliased(a.GetUnsafePtr(), b.GetUnsafePtr()); // But since c has [NativeDisableContainerSafetyRestriction] it can alias them. ExpectAliased(b.GetUnsafePtr(), c.GetUnsafePtr()); ExpectAliased(a.GetUnsafePtr(), c.GetUnsafePtr()); // No [NativeContainer]'s this pointer can appear within itself. ExpectNotAliased(in a, a.GetUnsafePtr()); ExpectNotAliased(in b, b.GetUnsafePtr()); ExpectNotAliased(in c, c.GetUnsafePtr()); } }
順を追ってみてみましょう。
これらの組み込まれたルールによって、Burst はほとんどのコードに対して非常に優れた最適化を実行することができます。
多くのユーザーは以下の BasicJob のようなコードを書くでしょう。
[BurstCompile] private struct BasicJob : IJobParallelFor { public NativeArray<float> a; public NativeArray<float> b; public NativeArray<float> c; public NativeArray<float> o; public void Execute(int i) { o[i] = a[i] * b[i] + c[i]; } }
このコードは、3つの配列を読み込み、その結果を足し合わせ、4つ目の配列に格納しています。こういったコードはコンパイラーに最適です。ベクトル化されたコードを生成できるので、今日の携帯電話やデスクトップコンピュータに搭載されている強力なCPUを最大限に活用することができます。
上記のジョブを Burst インスペクターで見てみましょう。
コードがベクトル化されていることがわかります。コンパイラーがベクトル化を行えるのは、上で説明したように、Unity の Job System がジョブの構造体に含まれる各フィールドがお互いにエイリアスになっていないというルールがあるからです。
しかし実際には、以下のコードのようにユーザーがデータ構造を構築していて、Burst がそれらの構造体においてエイリアスがどのような状況になっているかの情報を持っていない場合があります。
[BurstCompile] private struct NotEnoughAliasingInformationJob : IJobParallelFor { public struct Data { public NativeArray<float> a; public NativeArray<float> b; public NativeArray<float> c; public NativeArray<float> o; } public Data d; public void Execute(int i) { d.o[i] = d.a[i] * d.b[i] + d.c[i]; } }
上記の例では、BasicJob にあったメンバー変数を別の構造体 Data にラップし、その構造体をジョブの構造体の唯一のメンバーとして格納しています。それでは、また Burst インスペクターを見てみましょう。
Burst はこの例をベクトル化することはできていますが、ループの開始時に使用されるすべてのポインターが重複していないことをチェックするというコストを払っています。
これは、Job System が持つルールが、構造体の直接のメンバーについてのみ Burst に保証を与えるものであり、それらから派生したものについては保証しないからです。つまり、Burst は、変数 a、b、c、および o の示す先がエイリアスになっていると仮定しなければなりません。 これは「これらのポインターのどれが実際に同じになっているのか?」という複雑で時間を要する処理を意味します。では、どうやってこれを解決すればよいのでしょうか?そう、ここで [NoAlias] 属性を使います!
[BurstCompile] private struct WithAliasingInformationJob : IJobParallelFor { public struct Data { [NoAlias] public NativeArray<float> a; [NoAlias] public NativeArray<float> b; [NoAlias] public NativeArray<float> c; [NoAlias] public NativeArray<float> o; } public Data d; public void Execute(int i) { d.o[i] = d.a[i] * d.b[i] + d.c[i]; } }
上記の WithAliasingInformationJob ジョブでは、Data 構造体のフィールドに新しく [NoAlias] 属性が指定されています。これらの [NoAlias] 属性は、Burst に以下のことを伝えています。
それでは、再び Burst インスペクターを見てみましょう。
この変更により、時間がかかる実行寺のポインターのチェックがすべてなくなり、ベクトル化されたループを実行することができるようになりました。
さらに新しい Unity.Burst.CompilerServices.Aliasing 組み込み関数を使用することで、将来的に誤ってコードを変更してエイリアスの状況に影響を与えることがないようにできます。例えば、以下のようになります。
[BurstCompile] private struct WithAliasingInformationAndIntrinsicsJob : IJobParallelFor { public struct Data { [NoAlias] public NativeArray<float> a; [NoAlias] public NativeArray<float> b; [NoAlias] public NativeArray<float> c; [NoAlias] public NativeArray<float> o; } public Data d; public unsafe void Execute(int i) { // Check a does not alias with the other three. ExpectNotAliased(d.a.GetUnsafePtr(), d.b.GetUnsafePtr()); ExpectNotAliased(d.a.GetUnsafePtr(), d.c.GetUnsafePtr()); ExpectNotAliased(d.a.GetUnsafePtr(), d.o.GetUnsafePtr()); // Check b does not alias with the other two (it has already been checked against a above). ExpectNotAliased(d.b.GetUnsafePtr(), d.c.GetUnsafePtr()); ExpectNotAliased(d.b.GetUnsafePtr(), d.o.GetUnsafePtr()); // Check that c and o do not alias (the other combinations have been checked above). ExpectNotAliased(d.c.GetUnsafePtr(), d.o.GetUnsafePtr()); d.o[i] = d.a[i] * d.b[i] + d.c[i]; } }
これらのチェックは コンパイルエラーを起こしません。つまり、すでに見たように、Burst は [NoAlias] 属性によって、このケースを検出して最適化するのに十分な情報を持っているということです。
さて、このブログでは説明を簡潔にするために少々不自然な例となっていますが、このようなエイリアスに対するヒントは、あなたのコードに非常に現実的なパフォーマンス向上をもたらすことができます。私たちが常に推奨しているように、コードの変更を行うたびに Burst インスペクターを使用することで、より最適化された方向に向かって歩み続けることができます。
Burst 1.3.0 のリリースでは、コードのパフォーマンスを最大限に引き出すためのツールを提供しています。拡張・強化された [NoAlias] 属性のサポートにより、データ構造がどのように動作するかを完全に制御することができます。また、新しいコンパイラーの組込み関数により、コンパイラーがどのようにコードを理解しているかを知ることができるようになりました。
Burstをまだ使い始めておらず、新しい Data-Oriented Technology Stack(DOTS)に関する当社の取り組みについて詳しく知りたい方は、当社の DOTS のページをご覧ください。今後、より多くの学習リソースや開発チームによる講演へのリンクが追加されてゆきます。
私たちはフィードバックを歓迎します。ぜひフォーラムに参加して、あなたのコードの高速化に Burst がどのように寄与できるかお教えください。