Unity を検索

Burst における in パラメーター

2020年11月25日 カテゴリ: Engine & platform | 11 分 で読めます
取り上げているトピック
シェア

Is this article helpful for you?

Thank you for your feedback!

Unity Burst コンパイラーは、C# コードを高度に最適化されたマシンコードに変換します。@dreamingimlatios をはじめ、フォーラムの素晴らしいユーザーからよく寄せられる質問の 1 つに、Burst コード内の関数のパラメーターに関する物があります。開発者はそれらを使うべきなのでしょうか、また、使うならどこで使うべきなのでしょうか。この記事では、それらについて詳しく説明しようと思います。

in パラメーターとは何か

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 つで対になっています。

間接引数と ABI

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

上記のコードは 2 つの部分に分割できます。

  •  値渡しされる 2 つの構造体を受け取る DoSomething メソッドを呼ぶ。
  •  関数の呼び出しによりデータに対して何らかの操作が行われ、その結果が返される(このデモでは、操作は重要な意味をもたない)。
  •  DoSomething メソッドの上に [MethodImpl(MethodImplOptions.NoInlining)] を置いていることに注意してください。これは 2 つの理由からそうしています。
    • Burst インスペクターを使用することで、結果として得られるアセンブリの特定のメソッドをピンポイントで特定することができるようになる。
    • DoSomething メソッドが非常に大きな関数であるために Burst がインライン化を行わなかった場合に何が起こるかをシミュレーションできるようになる。 

ここで、Burst インスペクターを開くと、上記のコードを受け取ってコンパイラーが実際に何を生成するのかを確認することができます。

赤枠で強調したアセンブリに注意してください。これは関数が必要とするスタックのバイト数を示しています。続く部分は Execute メソッドそのものです。次に Execute メソッド自体を見てみましょう。

上の図で赤枠で強調した部分に注意してください。この部分は、レジスター rax に入っているメモリアドレスと rsp のスタックの間で何度もコピーを行っています。なぜこのようなことをしているのでしょうか。素晴らしき ABI(Application Binary Interface)の世界へようこそ。はるか昔、コンピューターが現代のほとんどの家よりも大きかった頃、コンピューターを使う頭のいい人たちが、2 人が互いのコードを一緒に使えるようなプログラムを書く場合、プログラムを書くためのルールについて合意できていなければならないことに気が付きました。関数を使用して呼び出し元から呼び出し先にデータを渡す場合、コンパイラーが関数のパラメーターがどこにあるかについて了解していて、呼び出し元がどこにデータを置くか、および呼び出し先がどこからデータを受け取るかを分かるようにしておく必要があります。ある関数から別の関数へのデータの受け渡しを行う際には、呼び出し元と呼び出し先の両方が理解しなければならないルールが存在し、それによって正しいデータを正しい場所に置き、意味のあるものにすることができます。この場合のルールは呼び出し規約と呼ばれ、奇妙なものから素晴らしいものまで、さまざまなものがあります。OSごとに規約は異なるのが普通で、中には複数の規約を持つOSもありますが、重要なのは呼び出し元と呼び出し先の双方が同じルールに従うことで、予想外の振る舞いをしないようにすることです。ほとんどの呼び出し規約では、単純なデータ(プリミティブ型や小さな構造体)はレジスター内で、値渡しで渡されます。それがデータを渡す最も効率的な手段だからです。しかし、16 バイト以上の大きな構造体は、一般的に間接的に渡さなければなりません。上で示した単純なジョブをもう一度見て、ABI に準拠するためにコンパイラーがコードに行うべきことがわかるように修正したコードは以下のようになります。

こうすることで、コンパイラーは次のように処理を行いました。

  • DoSomething 関数への引数 ab を、参照渡しで渡すように変更した。
  • Execute メソッドに 2 つの新しいローカル変数 InDataACopyInDataBCopy を追加した。
  • Execute メソッドでは、InDataAInDataB からこの 2 つの変数にデータのコピーを取る必要がある。
  • そのため、これらのローカル変数を参照渡しで渡して DoSomething 関数を呼び出す。

もう一度 Burst インスペクターへの出力を見てみましょう。

これがコンパイラーが生成したコピーがマッピングされるアセンブリです。大量のデータをコピーしています。今度は同じ例で in パラメーターを使ったものを見てみましょう。

この新しいジョブのスタック割り当てサイズをもう一度見てみましょう。

スタックサイズが先ほどの 192 バイトから 32 バイトに縮小していることがわかります。次に「DoSomething」関数の呼び出しを見てみましょう。

先ほどまで、「InDataA」と「InDataB」のコピーを作らなければならないために存在した読み込みとデータ格納がなくなったことがわかります。これはコンパイラーにそれが必要ないことを伝えたからです。いいですね!ここで in パラメーターを使うと、コンパイラーにコード生成時により良い仕事をする方法を教えることができます。また、パフォーマンスに大きく影響する「DoSomething」メソッドが内側のループの内部にある場合、このコードから非常に多くの命令を削減することができたことになります。

NativeArray に関する注意書き

C# の in パラメーターには少し奇妙なところが 1 つあります。in パラメーターを使うときは、ref を使うときとは違い、コールサイト引数を明示的にマークする必要がないのです。

この舞台裏で起きているのは、コンパイラーがローカル変数を挿入し、42 をそこに格納し、in を使ってそれを渡すということです。以下のようになります。

ですので、in を関数に追加しても、回避しようとしたコピーは依然として行われてしまいます。NativeArray を使っていると、これが発生します。NativeArray のインデクサーが T を参照ではなく、値で返すためです。NativeArray の中のデータがぶら下がっている参照によって破壊されないようにして、メモリ違反が起きないようにこうしています。

これを考えるために、私たちのジョブのバリアントを追加してみましょう。

新しいジョブでは、以下の変更が加えられています。

  • IJob が IJobParallelFor に変更された。
  • ジョブが単一の要素ではなく、データの配列をまたいで実行されるようになった。
  • NativeArray インデクサーが参照ではなく値を返すため、「DoSomething」コールサイトに明示的な「in」がなくなった。

ここで、Burst インスペクターに表示されるアセンブリを見てみましょう。

ハイライトされた部分では、先ほど in パラメーターを使って回避しようとしていた読み込まれるデータと保存されるデータが返され、また、ループの反復のたびにこれをやらなければならなくなりました。あらら。どうすればこのコピーを避けることができるでしょうか。UnsafeUtility によって提供されるヘルパー関数を使って、次のようにします。

上の例では、新しいヘルパーメソッド「GetElementAsRef」が追加されました。これは単純にネイティブの配列とインデックスを取り、「UnsafeUtility.ArrayElementAsRef」ヘルパーを使って要素に値ではなく参照を返します。このコードは、NativeArray を削除して割り当てを保証しているメモリが解放されたとき、配列の要素を参照すると「死んだ」メモリや再利用された可能性があるメモリから読み込みを行ってしまうことがあるため、安全ではありません。ただし、これを考慮に入れれば、in を使ってネイティブの配列への参照を明示的に DoSomething メソッドに渡すことができるようになります。ここで、Burst インスペクターをもう一度見てみましょう。

データのコピーを取るためのロード命令やストア命令がなくなって、効率的でパフォーマンスの高いコードに戻っていることがわかります。

C# における防衛的なコピー

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 パラメーターは、開発者とコンパイラーとの間で、最適なパフォーマンスを得るための契約を提供する、非常に強力かつ有用な言語の構成要素です。このブログ記事でも紹介したように:

  • 大きな構造体を値渡しで受け取るインライン化されていない関数がある場合、構造体を in パラメーターにすることで、パフォーマンスを向上させることができる。
  • 関数を呼び出す箇所では、in を使ってコピーを発生させずに渡すことができるデータがあることに注意しなければなりません。呼び出し箇所で明示的に in 修飾子を使うことで、コンピューターにそのことを指示することができます。
  • この記事で紹介した Burst インスペクターを使うことで、コードに対する深い洞察を得ることができるので、ぜひ活用すること。

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

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

2020年11月25日 カテゴリ: Engine & platform | 11 分 で読めます

Is this article helpful for you?

Thank you for your feedback!

取り上げているトピック