Unity Burst コンパイラーは、C# コードを高度に最適化されたマシンコードに変換します。@dreamingimlatios をはじめ、フォーラムの素晴らしいユーザーからよく寄せられる質問の 1 つに、Burst コード内の関数のパラメーターに関する物があります。開発者はそれらを使うべきなのでしょうか、また、使うならどこで使うべきなのでしょうか。この記事では、それらについて詳しく説明しようと思います。
C# 7.2 では in パラメーター修飾子が、呼び出された関数がデータを変更することが許されていない場合に、関数に何らかのデータを参照渡しで渡す方法として導入されています。
int Foo(in int a, ref int b) { a = 42; // これはコンパイラーエラーになる。 b = a; // b が参照渡しされているのでこれは OK return a; }
in パラメーターは開発者とコンパイラーの間で、どのようにデータが使われ、また変更されるかについての契約を強制することができる、非常に便利な言語の概念です。in パラメーター修飾子を使うと、呼び出された関数がデータの変更を許されていないケースでも、参照渡しで引数を渡せるようになります。in パラメーター修飾子は out パラメーター修飾子(関数によってパラメーターが変更されなければならない場所で使う)や ref パラメーター修飾子(パラメーターの値が変更される可能性がある場所で使う)と 3 つで対になっています。
以下のシンプルなジョブのコードを見てみましょう。
上記のコードは 2 つの部分に分割できます。
ここで、Burst インスペクターを開くと、上記のコードを受け取ってコンパイラーが実際に何を生成するのかを確認することができます。
赤枠で強調したアセンブリに注意してください。これは関数が必要とするスタックのバイト数を示しています。続く部分は Execute メソッドそのものです。次に Execute メソッド自体を見てみましょう。
上の図で赤枠で強調した部分に注意してください。この部分は、レジスター rax に入っているメモリアドレスと rsp のスタックの間で何度もコピーを行っています。なぜこのようなことをしているのでしょうか。素晴らしき ABI(Application Binary Interface)の世界へようこそ。はるか昔、コンピューターが現代のほとんどの家よりも大きかった頃、コンピューターを使う頭のいい人たちが、2 人が互いのコードを一緒に使えるようなプログラムを書く場合、プログラムを書くためのルールについて合意できていなければならないことに気が付きました。関数を使用して呼び出し元から呼び出し先にデータを渡す場合、コンパイラーが関数のパラメーターがどこにあるかについて了解していて、呼び出し元がどこにデータを置くか、および呼び出し先がどこからデータを受け取るかを分かるようにしておく必要があります。ある関数から別の関数へのデータの受け渡しを行う際には、呼び出し元と呼び出し先の両方が理解しなければならないルールが存在し、それによって正しいデータを正しい場所に置き、意味のあるものにすることができます。この場合のルールは呼び出し規約と呼ばれ、奇妙なものから素晴らしいものまで、さまざまなものがあります。OSごとに規約は異なるのが普通で、中には複数の規約を持つOSもありますが、重要なのは呼び出し元と呼び出し先の双方が同じルールに従うことで、予想外の振る舞いをしないようにすることです。ほとんどの呼び出し規約では、単純なデータ(プリミティブ型や小さな構造体)はレジスター内で、値渡しで渡されます。それがデータを渡す最も効率的な手段だからです。しかし、16 バイト以上の大きな構造体は、一般的に間接的に渡さなければなりません。上で示した単純なジョブをもう一度見て、ABI に準拠するためにコンパイラーがコードに行うべきことがわかるように修正したコードは以下のようになります。
こうすることで、コンパイラーは次のように処理を行いました。
もう一度 Burst インスペクターへの出力を見てみましょう。
これがコンパイラーが生成したコピーがマッピングされるアセンブリです。大量のデータをコピーしています。今度は同じ例で in パラメーターを使ったものを見てみましょう。
この新しいジョブのスタック割り当てサイズをもう一度見てみましょう。
スタックサイズが先ほどの 192 バイトから 32 バイトに縮小していることがわかります。次に「DoSomething」関数の呼び出しを見てみましょう。
先ほどまで、「InDataA」と「InDataB」のコピーを作らなければならないために存在した読み込みとデータ格納がなくなったことがわかります。これはコンパイラーにそれが必要ないことを伝えたからです。いいですね!ここで in パラメーターを使うと、コンパイラーにコード生成時により良い仕事をする方法を教えることができます。また、パフォーマンスに大きく影響する「DoSomething」メソッドが内側のループの内部にある場合、このコードから非常に多くの命令を削減することができたことになります。
C# の in パラメーターには少し奇妙なところが 1 つあります。in パラメーターを使うときは、ref を使うときとは違い、コールサイト引数を明示的にマークする必要がないのです。
この舞台裏で起きているのは、コンパイラーがローカル変数を挿入し、42 をそこに格納し、in を使ってそれを渡すということです。以下のようになります。
ですので、in を関数に追加しても、回避しようとしたコピーは依然として行われてしまいます。NativeArray を使っていると、これが発生します。NativeArray のインデクサーが T を参照ではなく、値で返すためです。NativeArray の中のデータがぶら下がっている参照によって破壊されないようにして、メモリ違反が起きないようにこうしています。
これを考えるために、私たちのジョブのバリアントを追加してみましょう。
新しいジョブでは、以下の変更が加えられています。
ここで、Burst インスペクターに表示されるアセンブリを見てみましょう。
ハイライトされた部分では、先ほど in パラメーターを使って回避しようとしていた読み込まれるデータと保存されるデータが返され、また、ループの反復のたびにこれをやらなければならなくなりました。あらら。どうすればこのコピーを避けることができるでしょうか。UnsafeUtility によって提供されるヘルパー関数を使って、次のようにします。
上の例では、新しいヘルパーメソッド「GetElementAsRef」が追加されました。これは単純にネイティブの配列とインデックスを取り、「UnsafeUtility.ArrayElementAsRef」ヘルパーを使って要素に値ではなく参照を返します。このコードは、NativeArray を削除して割り当てを保証しているメモリが解放されたとき、配列の要素を参照すると「死んだ」メモリや再利用された可能性があるメモリから読み込みを行ってしまうことがあるため、安全ではありません。ただし、これを考慮に入れれば、in を使ってネイティブの配列への参照を明示的に DoSomething メソッドに渡すことができるようになります。ここで、Burst インスペクターをもう一度見てみましょう。
データのコピーを取るためのロード命令やストア命令がなくなって、効率的でパフォーマンスの高いコードに戻っていることがわかります。
C# の開発元が in パラメーターのアナウンスを行ったとき、同時にその使用のパフォーマンス上の特徴について解説したブログ記事を書いています。この記事に、「readonly でない構造体を in パラメーターとして渡すべきではないということです」という一文があります。
readonly でない構造体を in パラメーターとして渡さないようにというこのアドバイスが出された理由は、その構造体のインスタンスメソッドを呼んだとき、インスタンスメソッドが状態を変えたときに備えて、コンパイラーに in パラメーターのコピーを生成させることになる恐れがあるためです。これの例を見てみましょう。
上の例では「SomeStruct」を in パラメーターとして「SomeMethod」に渡そうとしているために、構造体のインスタンスメソッドを呼ぼうとしています。C# コンパイラーはこれを検知し、「SomeMethod」の「s」の防衛的なコピーを生成します。
これはコンパイラーが生成した IL です。ここで in パラメーターのコピーを行うために、ldobj と stloc.0 が実行されていることがわかります。
ほとんどすべての場合で、インスタンスのメソッドが構造体の状態を変えない限り、Burst はこれを推定し、防衛的なコピーを削除することができます。
上記のコードでは、インスタンスメソッドが in パラメーターのデータを変更しなかったので、Burst が完全にコピーを削除していることがわかります。ですので、C# コードに関する一般的なアドバイスとして、in パラメーターは readonly の構造体の中だけで使うこと、そして Burst を使っている HPC# の中では、in パラメーターデータの中に格納しない限りはうまくいくということです。
in パラメーターは、開発者とコンパイラーとの間で、最適なパフォーマンスを得るための契約を提供する、非常に強力かつ有用な言語の構成要素です。このブログ記事でも紹介したように:
Burst をまだ使い始めておらず、新しい Data-Oriented Technology Stack(DOTS)に関する当社の取り組みについて詳しく知りたい方は、当社の DOTS のページをご覧ください。今後、より多くの学習リソースや開発チームによる講演へのリンクが追加されてゆきます。
私たちはフィードバックを歓迎します。ぜひフォーラムに参加して、あなたのコードの高速化に Burst がどのように寄与できるかお教えください。