Search Unity

Unity の Burst コンパイラー は、C# コードを高度に最適化された機械語に変換します。1年前の Burst コンパイラーの最初の安定版リリース以来、私たちはコンパイラの品質・ユーザー体験・堅牢性の向上に取り組んできました。新しいメジャーバージョンアップである Burst 1.3 のリリースを受けて、この機会にパフォーマンスに焦点を当てた重要な機能であるエイリアス対応の強化についてご紹介したいと思います。

新しいコンパイラー組込み関数である Unity.Burst.CompilerServices.Aliasing.ExpectAliased と Unity.Burst.CompilerServices.Aliasing.ExpectNotAliased により、自分が書いたコードをコンパイラーがどのように把握しているかを確認することができるようになりました。これらの新しい機能と [Unity.Burst.NoAlias] 属性の強化を組み合わせることで、パフォーマンスを追求する上で新たな力を提供することができます。

はじめに

このブログ記事では、エイリアスの概念と [NoAlias] 属性を使用して構造体の中身がどのようにエイリアスになっている可能性があるかを指定する方法、コンパイラーがあなたのコードをどのように理解しているかを確認にするために、エイリアスのためのコンパイラーの新しい組込み関数を使用する方法を説明します。

エイリアスとは

エイリアスとは、データへの2つのポインタがたまたま同じメモリ上の位置を指していることを言います。

このコードはパフォーマンスに影響する、エイリアスによる古典的な問題を含んでいます。なんのヒントもなければ、コンパイラーは変数 a と変数 b がエイリアスになっているか分からないため、次のような無駄のある機械語を生成してしまいます。

この機械語は、

  • 変数 b に 13 を格納し、
  • 変数 a に 42 を格納し、
  • 変数 b から値を再度読み込んで、戻り値とします。

コンパイラーには変数 a と変数 b が同じメモリに存在するかどうかが分からないため、変数 b を再読み込みする必要が生じます。 同じメモリに存在する場合、変数 b の値は 42 になりますが、 同じメモリに存在しない場合は変数 b の値は 13 になることになります。

もっと複雑な例

以下のシンプルなジョブのコードを見てみましょう。

このジョブは、単にあるバッファから別のバッファにコピーをするだけのものです。Input と Output がエイリアスになっていない場合、つまりそれらを格納しているメモリの領域が重なっていない場合、このジョブの動作は以下のようになります。

コンパイラーがこれらのふたつのバッファがエイリアスになっていないと認識している場合、Burst での上記のコード例のように、コンパイラーはコードをベクトル化して、要素ひとつひとつではなく、複数の要素をいっぺんにコピーできるようにできます。

仮に Input と Output がエイリアスになっていた場合、何が起こるかを見てみましょう。実際は、Unity が備える保護機構がこのようなよくあるケースをキャッチし、コードに問題があることをユーザーにお知らせします。しかし、その保護機構を無効にしたと仮定しましょう。

上記の図のように、メモリの位置が一部重なっているため、Input の最小の要素の値 a は Output の全体にコピーされることになります。この状態でコンパイラーがベクトル化を行った場合、つまり誤ってエイリアスになっていないと認識した場合、どうなってしまうでしょうか?

なんということでしょう、Output の内容はさきほどとはまったく違ったものになってしまいます。

エイリアスは、Burst コンパイラーがコードを最適化する力を制限してしまいます。これは特にベクトル化に悪影響を及ぼします。ループ内で使用されている変数がエイリアスになっているとコンパイラーが考えた場合、おそらくループを安全にベクトル化することはできないでしょう。Burst 1.3.0 以降では、エイリアスへの新しい対応方法によって、パフォーマンスを大幅に向上できるようになりました。

[NoAlias] 属性の紹介

Burst 1.3.0では、[NoAlias] 属性は4種類の箇所に記述できるように拡張されました。

  • 関数の引数では、その引数が他の引数や「this」ポインターとエイリアスになっていないことを示します。
  • 構造体のフィールドにおいては、そのフィールドが構造体の他のフィールドとエイリアスになっていないことを示します。
  • 構造体そのものにおいては、その構造体自身のアドレスがその構造体の中に現れないことを示します。
  • 関数の戻り値では、返されるポインターが同じ関数から返される他のポインターとエイリアスにならないことを示します。

構造体のフィールドおよび関数の引数においてその型が構造体の場合、 「X とエイリアスになっていない」というのは、その構造体のどのフィールドに現れるポインターであっても(間接的にでも) X とエイリアスになっていないと保証することになります。

また [NoAlias] 属性を持つ引数は、ジョブの構造体などの「this」ポインターとエイリアスしないことを保証することにもなります。その構造体は、Entities.ForEach() を使った場合、ラムダ式によってキャプチャーされるすべての変数も含んでいます。

それでは、それぞれの使用例を順番に見ていきましょう。

関数の引数における NoAlias 属性

さきほどの Foo 関数の例に [NoAlias] 属性を追加すると、どうなるか見てみましょう。

出力される機械語は以下のように変わります。

変数 b からの読み込みは、定数 13 を直接戻り値とする形に変わっていることにお気づきでしょうか。

構造体のフィールドにおける NoAlias 属性

同じ例を、代わりに構造体に適用してみましょう。

上記のコードから、以下のような機械語が生成されます。

この機械語は、

  • 変数 b の先頭データのアドレスを rax レジスターに読み込み、
  • そこに 42 を格納し(1109917696 は 16 進数に直すと 0x42280000 であり、これは単精度浮動小数点数の 42.0f を表します)、
  • 変数 a の先頭データのアドレスを rcx レジスターに読み込み、
  • そこに 13 を格納し、
  • 変数 b の先頭データを再読み込みし、それを戻り値にするために整数に変換します。

ここでふたつの NativeArray が同じメモリに存在しないことをユーザーが分かっているなら、[NoAlias] 属性を使うことができます。

変数 a と b の両方に [NoAlias] 属性を指定することで、構造体内では互いにエイリアスになっていないことがコンパイラーに伝わり、生成される機械語は以下のように変わります。

コンパイラーは整数の定数である 42 をそのまま戻り値にできるようになりました。

構造体における NoAlias 属性

一般的に言ってだいたいの構造体では、その構造体へのポインタが構造体自身の中に現れないという前提を持つことができます。そうではない古典的な例を見てみましょう。

これは稀な例ではありますが、連結リストは構造体から構造体自体へのポインタがありうるデータ構造になっています。

構造体の [NoAlias] が役立つ具体的な例を見てみましょう。

生成される機械語は以下のようになります。

この機械語は、

  • rax レジスターに変数 p をロードし、
  • 42 を 変数 p の示すメモリーに格納し、
  • rax レジスターに再び変数 p を読み込み!
  • ecx レジスターに変数 i を読み込み、
  • 変数 p が示すアドレスを基に、変数 i が示すインデックスの要素を戻り値にします。

変数 p が 2 回読み込まれていることに注目してください。その理由は、コンパイラーが変数 p が構造体 bar 自体のアドレスを指している可能性を考慮しているからです。コンパイラーは、念のために構造体 bar から変数 p を再読み込みしなければなりません。 なんという無駄でしょう。

ここで [NoAlias] 属性の登場です。

生成される機械語は以下のようになります。

コンパイラーに変数 p は構造体 bar へのポインターでは有り得ないことを教えたので、変数 p の読み込みは一度で済むようになりました。

関数の戻り値における NoAlias 属性

関数の中には、毎回違ったポインターしか返さないものもあります。例えば、malloc はそのような関数のひとつです。このような場合、[return:NoAlias] 属性はコンパイラーを使うことができます。

スタックメモリーからメモリーを確保するバンプアロケーターを使った例を見てみましょう。

生成される機械語は以下のようになります。

かなりの量になってしまいましたが、この機械語は、

  • rdi レジスターに変数 ptr1 が格納されていて、
  • rax レジスターには変数 ptr2 が格納されていて、
  • 42 を変数 ptr1 の示すアドレスに格納し、
  • 13 を変数 ptr2 の示すアドレスに格納し、
  • 変数 ptr1 の示すアドレスから再度読み込みして関数の戻り値にします。

それでは、[return: NoAlias] 属性を追加してみましょう。

生成される機械語は以下のようになります。

コンパイラーは変数 ptr1 の示すアドレスから再読み込みするのではなく、単に 42 を引数の戻り値とするように変わりました。

[return: NoAlias] 属性は、上記のバンプアロケーターの例や malloc のような、毎回違ったポインターを生成することを100%保証できる関数でのみ使用してください。また、コンパイラーは性能を考慮して積極的に関数をインライン化するので、上記のような小さな関数は呼び出し元にインライン化されてしまい、[return: NoAlias] 属性を使わなかった場合と同じ結果を生成する可能性が高いことにも注意してください(それを避けるために、この例ではインライン化を強制的に行わないように指定しています)。

エイリアスを防ぐための関数の複製

Burst は関数の引数同士のエイリアスの状況を把握している関数呼び出しにおいて、エイリアスの状況を推測し、呼び出された関数に伝搬させることで、最適化を押し進めることができます。例を見てみましょう。

これは、Bar 関数の中で、コンパイラーが変数 a と b がエイリアスになっているか分からないためです。Burst 以外のコンパイラーにおいても結果は同様でしょう。

しかし、Burst はもっとスマートに、変数 a と b がエイリアスにならないことが分かっている場所では Bar の複製を作成し、元の呼び出しをその複製への呼び出しに置き換えます。その結果、出力される機械語は以下のようになるのです。

見ての通り、変数 a からの無駄な読み込みが最適化されました。

エイリアスのチェック

エイリアスはコンパイラーの最適化の鍵となります。そのため、我々はエイリアスに対応するための組み込み関数を追加しました。

  • Unity.Burst.CompilerServices.Aliasing.ExpectAliased は、ふたつのポインタがエイリアスに なっている ことを期待します。そうでない場合は、コンパイルエラーが発生します。
  • Unity.Burst.CompilerServices.Aliasing.ExpectNotAliasedは、ふたつのポインタがエイリアスに なっていない ことを期待します。そうでない場合は、コンパイルエラーが発生します。

以下は使用例です。

これらの組み込み関数により、コンパイラーが知っているエイリアスの状況が想定通りかどうか確認することができます。これらはコンパイル時のチェックなので、引数に副作用がない場合、実行時のコストは発生しません。これらは特に、パフォーマンスに重要なコードで、後からの変更がエイリアスの状況をもとにした最適化を壊さないようにしたい時に便利です。Burst では、コンパイラー全体の制御によって、コードが意図した通りに最適化されていることかどうか確認できるようにコンパイラーから詳しい情報を得ることができるのです。

ジョブシステムにおけるエイリアス

Unity の Job System には、エイリアスについていくつかの前提条件が組み込まれています。そのルールは以下の通りです。

  1. [JobProducerType] 属性が指定された構造体(例: IJob, IJobParallelFor など)において、その構造体内の [NativeContainer] 属性がついた構造体であるフィールド(例: NativeArray, NativeSlice など) は同じく [NativeContainer] 属性がついた構造体である他のフィールドとエイリアスになってない。
  2. ただし [NativeDisableConternaterSafetyRestriction] 属性を持つフィールドに対しては、上記ルールは 例外となる。その場合、フィールドが構造体の他のフィールドとエイリアスになっている可能性があることを Job System に明示的に伝えていることになる。
  3. [NativeContainer] 属性が指定された構造体は、その構造体自体の中に this ポインターを保持してはいけない。

厳密な定義は上記のものですが、コードを見た方がわかりやすいでしょう。

順を追ってみてみましょう。

  • 変数 a と変数 b はどちらも [JobProducerType] 属性が指定された構造体に含まれる NativeContainer なので、エイリアスになる可能性はありません。
  • しかし、変数 c は [NativeDisableContainerSafetyRestriction] 属性が指定されているので、変数 a や変数 b とエイリアスになっている可能性があることになります。
  • また、変数 a、b、c はそれぞれ自分自身へのポインターを保持することはありません(この例では、NativeArray 自体へのポインターが、NativeArray が持つ配列の内容に含まれることはありません)。

これらの組み込まれたルールによって、Burst はほとんどのコードに対して非常に優れた最適化を実行することができます。

一般的な使用例

多くのユーザーは以下の BasicJob のようなコードを書くでしょう。

このコードは、3つの配列を読み込み、その結果を足し合わせ、4つ目の配列に格納しています。こういったコードはコンパイラーに最適です。ベクトル化されたコードを生成できるので、今日の携帯電話やデスクトップコンピュータに搭載されている強力なCPUを最大限に活用することができます。

上記のジョブを Burst インスペクターで見てみましょう。

コードがベクトル化されていることがわかります。コンパイラーがベクトル化を行えるのは、上で説明したように、Unity の Job System がジョブの構造体に含まれる各フィールドがお互いにエイリアスになっていないというルールがあるからです。

しかし実際には、以下のコードのようにユーザーがデータ構造を構築していて、Burst がそれらの構造体においてエイリアスがどのような状況になっているかの情報を持っていない場合があります。

上記の例では、BasicJob にあったメンバー変数を別の構造体 Data にラップし、その構造体をジョブの構造体の唯一のメンバーとして格納しています。それでは、また Burst インスペクターを見てみましょう。

Burst はこの例をベクトル化することはできていますが、ループの開始時に使用されるすべてのポインターが重複していないことをチェックするというコストを払っています。

これは、Job System が持つルールが、構造体の直接のメンバーについてのみ Burst に保証を与えるものであり、それらから派生したものについては保証しないからです。つまり、Burst は、変数 a、b、c、および o の示す先がエイリアスになっていると仮定しなければなりません。 これは「これらのポインターのどれが実際に同じになっているのか?」という複雑で時間を要する処理を意味します。では、どうやってこれを解決すればよいのでしょうか?そう、ここで [NoAlias] 属性を使います!

上記の WithAliasingInformationJob ジョブでは、Data 構造体のフィールドに新しく [NoAlias] 属性が指定されています。これらの [NoAlias] 属性は、Burst に以下のことを伝えています。

  • 変数 a、b、c、および o は、[NoAlias] 属性を持つ Data 構造体内の他のフィールドとエイリアスになる可能性はありません。
  • つまり、各変数は構造体内の他の変数とエイリアスになることはありません。 すべての変数に [NoAlias] 属性が指定されているからです。

それでは、再び Burst インスペクターを見てみましょう。

 

この変更により、時間がかかる実行寺のポインターのチェックがすべてなくなり、ベクトル化されたループを実行することができるようになりました。

さらに新しい Unity.Burst.CompilerServices.Aliasing 組み込み関数を使用することで、将来的に誤ってコードを変更してエイリアスの状況に影響を与えることがないようにできます。例えば、以下のようになります。

これらのチェックは コンパイルエラーを起こしません。つまり、すでに見たように、Burst は [NoAlias] 属性によって、このケースを検出して最適化するのに十分な情報を持っているということです。

さて、このブログでは説明を簡潔にするために少々不自然な例となっていますが、このようなエイリアスに対するヒントは、あなたのコードに非常に現実的なパフォーマンス向上をもたらすことができます。私たちが常に推奨しているように、コードの変更を行うたびに Burst インスペクターを使用することで、より最適化された方向に向かって歩み続けることができます。

結論

Burst 1.3.0 のリリースでは、コードのパフォーマンスを最大限に引き出すためのツールを提供しています。拡張・強化された [NoAlias] 属性のサポートにより、データ構造がどのように動作するかを完全に制御することができます。また、新しいコンパイラーの組込み関数により、コンパイラーがどのようにコードを理解しているかを知ることができるようになりました。

Burstをまだ使い始めておらず、新しい Data-Oriented Technology Stack(DOTS)に関する当社の取り組みについて詳しく知りたい方は、当社の DOTS のページをご覧ください。今後、より多くの学習リソースや開発チームによる講演へのリンクが追加されてゆきます。

私たちはフィードバックを歓迎します。ぜひフォーラムに参加して、あなたのコードの高速化に Burst がどのように寄与できるかお教えください。

16 replies on “Burst によるエイリアス対応の強化”

This is like having your whole car covered in bird’s cr*p, and freaking out because you saw ant and you didn’t want it to dirty your car. Can the ant make your car more dirty? Yeah….
will it make any real difference? F*** no….
99% of unity api is C# manager layer to CPP layer call and is almost as slow as reflection(very, VERY slow compared to any normal direct method call), and they are optimising some memory aliasing for burst that will be used in 0.01% cases by 0.1% users.
Thats typical unity for ya rotfl.

Most of the engine features are being rewritten to make use of burst, and many of the old APIs are getting updated with support for the Job System. You can definitely make use of those optimizations today. In the title I’m working on, Burst is a live saver. Btw, if you’re having problems with extern call performance, you’re definitely doing something wrong – you can relatively easily refactor your code to mostly avoid those.

Are there any cleaner way of writing these “ExpectNotAliased” checks? As the code is in your example, it sort of feels like you have unit tests in your production code…

Also, some sort of tool that would highlight which jobs the compiler expects to have Aliasing in would be super useful, instead of having to read machine code to figure out which jobs can be optimized.

very interesting. It’s great that youprovide this power and also the detailed explanation. The doc in the burst manual was already really good but it is great that you take the time to write these in depth blogs.

I showed burst to a friend last night who is not a game developer and he was like. Can i generate code with this and call in a normal .NET core app? :D

Burst and all DOTS stack is fantastic. Never had so much pleasure while coding stuff. Good job.
P.S. quaterniond or ability to multiply quaternion with double3 would be great too.

Sorry guys but you are creating an engine which is harder than to learn unreal\c++ while lots of missing and incomplete features with worse graphics except the tech demos you created inhouse

I would strongly have to disagree here, we are using DOTS and it has reduced complexity by order of magnitudes in comparison to C++. I’m really surprised at the comment.

Nonsense, have you actually used it? Its many times easier to learn than c++, and very different. Your comment makes it sound like you have only briefly glanced over DOTS related things rather than used them yourself.

DOTS is not only much simpler to use than UE4 c++, but it’s also easier to make *robust/scalable* code than in Monobehaviour. DOTS is code architecture heaven and makes you save a tremendous amount of time

It really feels like most of the people who actually tried DOTS in practice love it, and most of the people who’ve never tried it think it’s too scary and complicated

Haha, it’s funny that you use monospaced font in your text output with assembly code, but still use a non monospaced code in console output and test runner, is that a very hard to do? This is are a rhetorical question, since I ll made my test runner write result in monospaced font, and this is awesome, but console does not supplied with package manager.

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です