この記事では、Unity の新しい Data-Oriented Tech Stack(DOTS)を簡単にご紹介し、現状と今後の方向性について説明します。いずれ近いうちに DOTS に関するブログ記事をさらに投稿する予定です。
C++ についてお話しましょう。Unity は現在、C++ で記述されています。
上級ゲームプログラマーの悩みにはさまざまなものがありますが、その 1 つに、ターゲットのプロセッサーが理解でき、正常にゲームを実行できる(機械語の)命令を備えた実行ファイルを提供しなければならないという問題があります。
コードの中にパフォーマンスが求められる部分がある場合、最終的な命令をどのようなものにすべきかを私たちは知っています。必要なのは、合理的かつ容易にロジックを記述して、生成された命令が望みどおりのものかどうかを簡単に検証する方法です。
私たちが見るところ、C++ はこのような作業にあまり向いていません。ループをベクトル化したくても、非常に多くの問題が発生し、コンパイラーによってループがベクトル化されないことがあります。今はベクトル化されていても、何気なく加えた変更のせいで、明日にはベクトル化されなくなるかもしれません。すべての C/C++ コンパイラーに、確実にコードをベクトル化させることすら難しいのです。
そこで私たちは、「ほどよく無理のないマシンコードの生成方法」を独自に開発し、私たちにとって重要な項目をすべてチェックすることにしました。C++ の設計順序をもう少し都合が良いように変えようかとも考えましたが、それよりもそのエネルギーを使い、ゲーム開発者が抱える課題をしっかりと考慮しながらツールチェインの設計に力を注ぐほうが良いと考えました。
私たちにとって重要な項目は次のとおりです。
以上が重要だと考えていることです。次に、このマシンコードジェネレーターにどの入力言語を使うかを決める必要があります。次の選択肢があるとしましょう。
C# をパフォーマンスが最も強く求められる内部ループに使うのかと思う方もいるかもしれません。しかし、Unity にとって優れたメリットがいくつもある C# を選ぶのは、ごく自然なことです。
私は C# でコードを書くことがとても好きです。しかし、従来の C# はパフォーマンスの観点から見ると、それほど優れた言語ではありません。C# 言語チーム、標準ライブラリチーム、ランタイムチームは、ここ 2 年間で大きな進歩を遂げてきました。しかしそれでも、C# 言語を使うときは、メモリ内のどのようにデータを配置するかはまったく制御できません。それこそまさに、パフォーマンスの改善に必要なものです。
それに加えて、標準ライブラリは「ヒープ上のオブジェクト」と「他のオブジェクトへのポインター参照を持つオブジェクト」を中心としています。
そのため、パフォーマンスが求められるコードに取り組むときは、ほとんどの標準ライブラリを使うことを諦め(Linq、StringFormatter、List、Dictionary も使えません)、アロケーション(つまりクラスは使えず、構造体のみ使用可能)、リフレクション、ガベージコレクター、仮想呼び出しの使用を禁止しつつ、いくつかの新しいコンテナ(NativeArray など)の使用を認めれば良いでしょう。その他の C# の要素はまったく問題ありません。例については、Aras のブログで紹介されているパストレーサーのミニプロジェクトの例を参照してください。
このサブセットで、ホットループに必要なものをすべて無理なく処理することができます。C# のサブセットであるため、通常の C# として実行することも可能です。範囲外アクセス時にはエラーが発生するほか、C++ では想像もできないような親切なエラーメッセージ、優れたデバッガーサポート、高速なコンパイルの揃った環境が手に入ります。このサブセットはしばしばハイパフォーマンス C# または HPC# と呼ばれます。
私たちは Burst というコードジェネレーター/コンパイラーを開発しました。これは Unity 2018.1 からプレビューパッケージとして提供しています。課題はたくさんあるものの、現段階ではその出来に満足しています。
C++ よりも高速になる場合もありますが、C++ よりも低速になる場合もまだあります。低速になるのはパフォーマンス上のバグであると考えられますが、解決できる自信があります。
ただし、パフォーマンスを比較するだけでは十分ではありません。そのパフォーマンスを得るために何をする必要があるかということも重要です。たとえば、Unity の現在の C++ レンダラーから C++ のカリングコードを取り出し、Burst に移植したとします。パフォーマンスは同じですが、C++ のコードは C++ コンパイラーに実際にベクトル化させるために、膨大な処理を実行する必要があります。Burst のコードのサイズは約 4 分の 1 です。
正直なところ、「最もパフォーマンスが求められるコードを C# に移植するべきだ」と話しても、Unity 社内の関係者全員からすぐに賛成が得られることはないでしょう。多くのプログラマーは、C++ を使っているときのほうが「ハードウェアとの距離が近い」と感じています。しかし、それが正しいと言えるのも、もうあとわずかの間でしょう。C# を使えば、ソースのコンパイルからマシンコードの生成まで、プロセス全体を完全にコントロールできるほか、気に入らない点があれば、その点を調べて修正することができます。
私たちは、パフォーマンスが求められるすべてのコードを C++ から HPC# へとゆっくりとですが確実に移植を進めています。求めるパフォーマンスを得ることが簡単になったほか、バグのあるコードを書くことが少なくなり、作業もしやすくなりました。
下の画像は Burst Inspector のスクリーンショットです。Burst の各ホットループに対して生成されるアセンブリ命令を簡単に確認することができます。
Unity にはさまざまなユーザーがいます。ARM64 命令セットをすべて暗記している人もいれば、コンピューターサイエンスの博士号を取らなくても何かを生み出せればそれで満足という人もいます。
エンジンコードの実行に費やす時間(通常 90% 以上)が短くなるのは、どのユーザーにとってもメリットがあります。また、アセットストアのパッケージのランタイムコードの実行時間も短くなります。アセットストアのパッケージの作成者も HPC# を採用しているからです。
それに加えて、上級ユーザーにとっては HPC# で独自の高パフォーマンスのコードを書けるというメリットがあります。
C++ では、プロジェクトの部品ごとに最適化のレベルを変えるようコンパイラーに要求することは非常に困難です。最適化のレベルの指定は、ファイル単位の粒度が精一杯です。
Burst はプログラム内の 1 つのメソッドを入力(ホットループへのエントリポイント)として使うよう設計されています。Burst はその関数とその関数が呼び出すすべてのもの(既知であることが保証されているもの。仮想関数や関数ポインターは使用不可)をコンパイルします。
Burst はプログラムの比較的小さな部分に対してのみ使用されることもあり、やや極端なほどに最適化を行います。Burst はほぼすべての呼び出し位置をインライン化します。インライン化された形においては関数の引数に関する情報が増えるため、自動で削除されない if チェックは削除します。
C++ も C# も、スレッドセーフなコードを書くうえではあまり助けになってくれません。
複数のコアを搭載したコンシューマー向けゲームハードウェアが登場してから 10 年以上経過した今でも、複数のコアを効率的に使うプログラムを提供することは非常に困難です。
データの競合、非決定性、デッドロックは、いずれもマルチスレッドコードの提供を難しくしている課題です。私たちが求めているのは、「特定の関数とその関数が呼び出すすべてのものが、グローバル状態を読み書きしないか確認する」機能です。「プログラマー全員が守るべきガイドライン」ではなく、その規則に違反している場合にコンパイルエラーを出す機能です。Burst ならコンパイルエラーが発生します。
私たちは、Unity ユーザーに対しても社内でも、「ジョブ型」のコードを書くこと、つまり必要なすべてのデータ変換をジョブに分割することを勧めています。各ジョブが副作用のない「関数的」なものになります。Burst では、操作対象の読み取り専用のバッファと読み取り/書き込みバッファが明示的に指定されます。他のデータにアクセスしようとすると、コンパイルエラーが発生します。
ジョブスケジューラーによって、ジョブ実行中の読み取り専用バッファへの書き込みを確実に回避できます。また、ジョブ実行中の読み取り/書き込みバッファからの読み取りも確実に回避できます。
規則に違反するジョブをスケジュールすると、その度にランタイムエラーが発生します。不運にも競合状態が発生したときだけではありません。エラーメッセージでは、「バッファ A から読み取るジョブをスケジュールしようとしていますが、すでにバッファ A に書き込むジョブをスケジュールしました。そのため、このジョブをスケジュールするには、前のそのジョブを依存関係として指定する必要があります」という旨が説明されます。
このような安全機構を使えば、コミットする前に多くのバグを発見し、すべてのコアを効率的に使用できることに私たちは気づきました。デッドロックや競合状態を発生させるコードを書くのが不可能になります。実行するスレッド数や他のプロセスがスレッドに割り込む回数にかかわらず、決まった結果が得られることが保証されます。
このようなコンポーネントをすべて改造できるようになれば、それらを互いに認識させることが可能になります。たとえば、ベクトル化が失敗するケースでよく見られるのは、2 つのポインターが同じメモリを参照(エイリアシング)しないことをコンパイラーが保証できないケースです。私たちはコレクションライブラリを作成しました。そのため、2 つの NativeArray が同じメモリを参照することはありません。また、この知識を Burst で利用できるため、最適化を諦めずに済みます。2 つの配列ポインターが同じメモリを参照していないか、Burst を使って警戒できるからです。
同様に、私たちは Unity.Mathemetics 数学ライブラリも作成しました。Burst はこのライブラリとの相性が非常に良く、将来的には math.sin() などに対して精度を妥協する形の最適化を行うことも可能になる予定です。Burst にとって math.sin() は単なるコンパイル対象の C# メソッドではありません。Burst は sin() の三角関数的な特性だけでなく、x が小さな値の場合には sin(x) == x になること(Burst ならこれを証明可能)や、一定の精度を犠牲にすれば sin() をテイラー級数展開で置き換えられることを理解しています。プラットフォームおよびアーキテクチャ間で浮動小数点数の決定性を確保することも、Burst の今後のゴールの 1 つです。これについても実現可能であると私たちは考えています。
Unity のランタイムコードを HPC# で書くことで、エンジンとゲームが同じ言語で記述されることになります。Unity では HPC# に変換したランタイムシステムをソースコードとして配布する予定です。誰でもそのコードを使って学習、改良、カスタマイズを行うことができます。私たちが作成したものよりも優れたパーティクルシステム、物理演算システム、レンダラーを作成できるよう、ユーザーに平等な機会を提供する予定です。多くの方が開発に取り組んでくれると期待しています。社内の開発プロセスを Unity ユーザーの開発プロセスにさらに近づけることで、私たちはユーザーが直面している困難をもっと直接的に感じ取ることができます。また、2 つの別々のワークフローではなく、1 つのワークフローの改善に全力を集中させることも可能になるでしょう。
次の記事では、DOTS のまた別の要素である Entity Component System について取り上げる予定です。