見出し画像

DOTS BEST PRACTICES 翻訳

以下のページを翻訳しました。(翻訳したあとに気づきましたが日本語訳版ありました😇
以下を参考にされても良いです、このページは1ページにまとまっています)
https://learn.un_ity.com/course/dots-best-practices

概要

可能な限り効率的なCPU使用を必要とするゲーム(またはその他のリアルタイムシミュレーション)の制作において、UnityのData Oriented Technology Stack (DOTS)は必要なパフォーマンスを得るための優れた方法です。
しかし、DOTSをうまく使うためには、単にAPIドキュメントを入手して直接作業することはできません。DOTSを使用してプロジェクトを作成する前に、次のことを理解しておく必要があります。

  • データ指向設計の基本概念

  • Unityのデータ指向設計

  • このガイドのベストプラクティスのアドバイス

データ指向設計 (DOD) は、多くの開発者がそのキャリア全体を使って取り組んできたオブジェクト指向プログラミング (OOP) からの大きな変化です。
これは、DOTSを習得するのが難しいことを意味し、期待していたパフォーマンス上のメリットを得られなくなるような落とし穴がたくさんあります。このベスト・プラクティス・ガイドには、こうした落とし穴を回避するためのアドバイスが記載されています。

このガイドのセクション1と2では、DOTSアプリケーションの構築を始める前に理解しておくべき基本的な知識と、優れたパフォーマンスを得る上での最大の障害について説明します。後半のセクションでは、DOTSの性能をさらに引き出すためのテクニックが紹介されていますが、これらをうまく活用するには、基本的な知識を理解している必要があります。

Part 1: Understand data-oriented design(データ指向設計を理解する)

概要

DOTSベストプラクティスガイドのこのセクションでは、以下の内容を説明します。

  • データ指向設計がオブジェクト指向プログラミングとどのように違うのか

  • DODの主要なコンセプトを紹介した文書やビデオのリンク

  • UnityのDOTSパッケージのインストール方法

  • UnityにおけるDOTSの機能実装する際に覚えておくべき重要な原則

1.Understanding DOD

UnityのEntitiesパッケージ(Entity Component System、ECSとも呼ばれる)は、プロジェクトに組み込んで自動的に素晴らしいパフォーマンスが得られるようなAPIではありません。データ指向設計(DOD)は、多くの開発者がメイン(または唯一)のプログラミングパラダイム[注1]として使用しているオブジェクト指向プログラミング(OOP)とは根本的に異なるアプローチです。

オブジェクト指向プログラミングでは、コードをクラスに構造化して、現実世界に存在する可能性のあるものを表現します。クラスのインスタンスは、1つのオブジェクトを表します。一般に、オブジェクトのすべてのデータはprivateキーワードによって隠蔽されます。そのデータを操作するメソッドと、他のオブジェクトに似ているがある点で異なるオブジェクトを表現するための継承階層があります。 結果として、たくさんのオブジェクトがメモリ上に散らばることになります。人間にとっては直感的に理解できるOOPですが、現代のCPUでは効率的に処理することができません。

一方、データ指向設計では、データに注目します。開発者は、どのようなデータが必要なのかを考え、そのデータを処理するシステムの実行中にCPUが効率的にデータにアクセスできるような、メモリ内でのデータの最適な構造を検討します。DODは、継承によってカプセル化された単一のオブジェクトを表現するのではなく、コンポジションを使ってオブジェクトをコンポーネントに分解し、コンポーネントを配列にグループ化します。システムは配列を繰り返しアクセスして、プロジェクトのアルゴリズムに必要なデータ変換します。DODでの最も一般的なユースケースは、一度に1つのオブジェクトを考えるのではなく、一度に多くのコンポーネントを考慮することです。

DODをうまく使うためには、プログラミングの基本中の基本と思われるOOPコンセプトの多くを無視する必要があります。オブジェクト指向のカプセル化、データの隠蔽は忘れてください。継承、ポリモーフィズム[注2]も忘れてください。また、おおむね参照型についても忘れてください。これらの概念はあなたの助けにはなりません。

OOPとDODの違いを、例を挙げて見てみましょう。これは、 Beach Ball Simulator: Special Edition という架空のゲームのスクリーンショットです。プレイヤーはパワーアップを起動してすべてのグリーンボールを動かします。

Beach Ball Simulator: Special Edition
(https://learn.unity.com/tutorial/part-1-understand-data-oriented-design より引用)
(https://learn.unity.com/tutorial/part-1-understand-data-oriented-design より引用)

OOPでは、Sphereクラスの配列を反復処理して各クラスのColorをチェックし、緑のクラスのPositionを設定します。配列には連続したデータが詰め込まれていますが、Sphereクラスへの参照しか含まれていないため、実際のデータはメモリ上に散らばっている可能性があり、キャッシュミスの原因になります。DODでは、SphereをColorとPositionのコンポーネントに分解してバッファに格納することで、キャッシュミスを減らし、処理を高速化しています。

2.Learning resources

DODが初めての方は、UnityでDOTSのコードを書き始める前に、時間をかけて基本的な原理を学んでおくべきです。DODを前もって理解しておけば、完全に理解しないまま飛び込んで行き詰まるよりも、はるかに時間の節約になります。一般に公開されている素晴らしい資料がたくさんあります。

データ指向設計の入門書

UnityのDOD

Advanced reading

UnityのDODパッケージ

パッケージのドキュメントには、UnityでDODを使用するための重要な情報が記載されています。上のリンクほどDODの理論を詳しく説明しているわけではありませんが、APIがどのように動作するかを理解しておくと、データの設計や実装を行う際に、何が可能で何が推奨されるかを知ることができます。

なお、このベストプラクティス・ガイドでは、Entitiesパッケージのバージョン0.16以降を使用していることを前提としています。DOTSは現在も活発に開発が行われており、APIや性能特性が変更される可能性がありますので、各パッケージの最新版を使用することをお勧めします。

  • Collections パッケージは、DOTSのメモリ管理を支えるnative typesを定義しています。以下も参照してください。 NativeArrayNativeSlice

  • C# Job System とそれを拡張した Jobs パッケージにより、スケジュールされたマルチスレッドのコードを作成することができます。

  • Burst は、IL/.NETバイトコードをLLVMを用いて高度に最適化されたネイティブコードに変換するコンパイラです。

  • Entities パッケージ(Entity Component SystemまたはECSとも呼ばれる)は、コレクション、C#ジョブ、Burstの力を引き出すために、EntityとComponentの作成と管理を可能にします。

  • DOTS Hybrid Renderer パッケージは、ECSエンティティのレンダリングに必要なデータを収集し、そのデータをUnityの既存のレンダリングアーキテクチャに送信するシステムです。

さらに、DOTSパッケージの使用例として ECS Samples リポジトリについて理解しておくことも重要です。特に、 ECSSamples/Assets/BuildConfigurations には、様々なプラットフォーム用のBuildConfigファイルの例があります。ビルド設定は、DOTSプロジェクトをビルドする際の従来の「ビルド設定」ウィンドウに代わるものです。しかし、この記事を書いている時点では、この機能を提供する Platforms パッケージはまだ開発中なので、ECS Samplesリポジトリが唯一の使用方法となります。DOTSプロジェクトを構築するためには、ビルド設定をプロジェクトにコピーし、必要に応じて変更することをお勧めします。

3.Key Principles(主な原則)

以下の重要な原則は、DODにどのように取り組むかを理解するための基礎となるものです。

  • **コーディングする前に設計する。**すべてのコンピュータコードは、基本的にデータ変換の連続です。必要なデータは何か、実行時にCPUが効率的にアクセスできるようにデータをどのようにフォーマットし、グループ化する必要があるか、どのシステムがどのようにデータを変換すべきか、などを考えてみましょう。データ設計を正しく行えば、そのデータを変換するコードを書くのは非常に簡単なはずです。

  • **効率的なメモリキャッシュ使用のための設計。**データは、必要のない空白や変数のない連続したバッファに詰められていることを確認する必要があります。システムやジョブは、このデータを線形反復することで、CPUがデータをキャッシュライン[注3]に効率的かつ予測可能な方法で取り込むことができます。これがプロジェクトで発生していない場合は、データを再設計します。

  • Blittable データの設計。Blittableデータとは、ポインタや参照の追加処理を必要とせずに、ジョブシステムがジョブにコピーできるデータのことです。つまり、マネージドされた型がないということです。クラス、参照、理想的にはstringsもありません。可能な限り多くのDOTSコードをスケジューリングし、スケジューリングしたコードの可能な限り多くを並列化する必要があります。これを可能にするのがBlittableデータです。また、HPC#[注4]のコアでもあり、Burstコンパイラが効率的なコードを生成することを可能にしています。

  • **一般的なケースを想定して設計する。**すべてのデータ構造やコードを、常に完全に効率的にすることはできません。エッジケースに最適化しても時間の無駄ですし、最悪の場合、一般的なケースを一般化することで、本当に重要なところに非効率性を導入することになります。意思決定の際には、あるコードが1フレームあたり10万回実行されるのか、1フレームあたり1回なのか、数秒に1回なのか、それとも初期化時のみなのかを考慮してください。頻繁に行われる操作に焦点を当てます。

  • **反復を受け入れる。**OOPよりもDODの方が、反復型開発に適しています。データに関する初期の仮定は間違っているかもしれません。要件が変わるかもしれません。DODのコードは、OOPコードよりもモジュール化されている傾向があり、より効率的なアルゴリズムを見つけたら、システム全体やその上で動作するコンポーネントを削除したり置き換えたりすることができます。この繰り返しのプロセスを受け入れてください。開発スケジュールにそのための時間を確保してください。

Part 2: Data design

概要

DOTS ベストプラクティスガイドのこのセクションでは、以下の内容を説明します。

  • DOTSコードの最初の行を書く前に、最大限の効率を得るためにデータ構造を設計する方法を学ぶ

  • Blittable DataとManaged Typeについて、またデータをBlittableにすべき理由について学ぶ

  • 実行時にコードがどのようにデータにアクセスするかを考える

1.Design your data upfront(前もってデータを設計する)

データ指向設計の基本を理解したら、アプリケーションのデータとシステムを設計する準備が整いました。データ(コンポーネント)とそのデータを変換するコード(システムからスケジューリングされたジョブ)という観点から考えるようになったので、実際にどのようなデータが必要なのか、また、求める動作を実現するためにコードがデータに対してどのような変換を行う必要があるのかを把握することから始めるのがよいでしょう。

さらに、この変換によって、データに予想される読み書きのアクセスパターンがわかるはずです。これによって、データを効率的な処理のためにエンティティやコンポーネントに構造化する最善の方法が明らかになります。

Unite Copenhagen 2019 presentationの Options for Entity Interation は、データデザインの優れたイントロダクションであり、ユースケースの具体的詳細を考慮することで、最適なツールやデータ構造を選択することができることを示しています。

ここでは、Atari社の名作ゲーム「Breakout」のDOTS版のデータデザインの例を示した「Breakoutデータデザインワークシート」を紹介します。同様のワークシートを自分のアプリケーションにも使ってみましょう。これにより、アプリケーションの実装時に多くの時間を節約することができます。なぜなら、データデザインに関する多くの問題を早期に発見することができ、デザインによって、どのコンポーネントやシステムを実装する必要があるのかを迅速かつ容易に把握することができるからです。

ワークシートはこちら👉

(https://learn.unity.com/tutorial/part-2-data-design より引用)

人々は、データです。道はCPUキャッシュだ。データ設計とは、キャッシュを最も効率的に使用するためにデータをパッケージ化する方法を考えることです。

ここでは、データを設計する際の注意点をご紹介します。

2.Design data for efficient transformation(効率的な変換のためのデータ設計)

DODの目標はハイパフォーマンスです。データを操作するシステムが効率的にデータにアクセスできるように、データを配置する必要があります。パフォーマンスを考慮してデータを設計する際に最も重要な1つの原則は、CPUキャッシュミスを最小限に抑え、CPUキャッシュヒットを最大限にすることです。このガイドのPart 1の多くの資料で説明されているように、CPUがデータを処理する必要があるときに、そのデータがすでにキャッシュラインにあることを発見した場合、そのデータへのアクセスはCPUにとって非常に高速です。このシナリオはキャッシュヒットと呼ばれます。データがキャッシュラインにない場合は、キャッシュミスと呼ばれ、CPUがメインメモリからデータを取得するのに長い時間待たされることになります。

つまり、今まで使っていた高レベルの一般的なコンテナを忘れなければなりません。例えば、DictionaryやLinkedListなどは、膨大な量のランダムなメモリアクセスを必要とし、結果として多くのキャッシュミスが発生します。代わりに、シンプルで予測可能な線形データ構造である配列とリスト( NativeArrayNativeList )について考えてみましょう。

ECSでは、コンポーネントのデータを、チャンク内に格納された配列で保存します。コンポーネントのチャンク全体(またはいくつかのチャンク)を一度に処理することは、CPUのキャッシュヒットを最大化するため効率的です。逆に、Entityからコンポーネントデータにアクセスするのはランダムアクセスなので、DictionaryのLookupと同様に非効率的です。 Entity のリストを繰り返し処理してコンポーネントデータに一度にアクセスすることは、アンチパターンであり、可能な限り避けるべきです。線形なメモリ構成を適切に作用させるためには、コードは EntityQuery を通してコンポーネントを処理する必要があります。

多くの場合、きめ細かいEntityQueriesを実現するには、少数の大きなコンポーネントを構築するよりも、多くの小さなコンポーネントからエンティティを構築して、エンティティのアーキタイプをきめ細かく制御するのが最善の方法です。さらに、小さなコンポーネントは、CPUキャッシュをより効率的に使用します。

典型的なキャッシュラインはわずか64バイトであることを考えると、CPUがアプリケーションから要求されたデータで64バイトを埋める時間のかかる作業を行った後は、そのデータをできるだけ効率的に使用することは合理的です。

Breakoutの例でボール用のコンポーネントを設計する場合、OOPで「ボール」クラスを作成する場合のように、すべてのデータを1つのコンポーネントに収めたいと思うかもしれません。

public struct Ball : IComponentData  
{  
    public float2 Position;  
    public float2 Direction;  
    public float Speed;  
    public float2 Size;  
    // ... etc...  
}  

Ballコンポーネントを処理するすべてのシステムが、それぞれこのデータのすべての部分にアクセスする必要がある場合は問題ありません。しかし、システムによっては、このデータの異なる部分によって動作します。例えば、レンダリングシステムでは、Ballの「Position」と「Size」は必要ですが、「Direction」と「Speed」は必要ありません。しかし、これらのデータがすべて同じコンポーネントにまとめられていると、CPUは必要なときにすべてのデータをキャッシュにロードしてしまい、現在のデータ変換で使用されていないデータでキャッシュがいっぱいになり、システム全体の効率が低下してしまいます。

データ設計がすべてのデータを別々のコンポーネントに保持している場合、レンダリングシステムは、パックされたPositionコンポーネントにアクセスするキャッシュラインと、パックされたSizeコンポーネントにアクセスする別のキャッシュラインを持つことができ、それらのキャッシュのすべてのコンテンツに対して有用な作業を行います。

3.Understand what’s Blittable and Burstable(BlittableとBurstableの意味を理解する)

従来のOOP技術(GameObjectやMonoBehavioursなど)では、ユーザーコードの大部分がメインスレッドに限定されるため、最新のCPUが持つ潜在的な処理能力のほんの一部しか利用できませんでした。しかし、マルチスレッドのコードは一般的に、競合状態のような面倒な問題のデバッグに時間がかかるため記述に時間がかかります。

C# Job Systemでは、競合状態を起こす可能性のあるコードを効果的に禁止するセーフティーシステムが含まれているため、競合状態のないマルチスレッドコードを書くことが非常に容易になります。競合状態の可能性を効率的にチェックできるように、C# Job Systemでは、Blittableデータタイプで動作するジョブしか作成できないという制約を導入しています。Blittableデータとは、ディスクやメモリの別の場所から、1回のブロックコピー操作でメモリにコピーできるデータで、コピー後に壊れたデータ参照を修正する必要がありません。つまり、ポインタや参照を修正する必要がないため、クラスや参照、 マネージヒープ 上に存在するものは何もありません。データ設計する際には、int、float、boolなどの単純な型と、それらの型だけを含む構造体を使用するように設計する必要があります。それに合わせてデータ型を計画する必要があります。

また、ジョブを使うことにはもう一つ大きなメリットがあります。それは、Burstがジョブをコンパイルすることで、高度に最適化されたネイティブコードが得られることです。C# .NETのサブセットである High-Performance C# (HPC#) で記述したすべてのジョブに、Burstのコンパイラを適用することができます。HPC#と互換性のあるデータタイプ、キーワード、言語機能の詳細については、Burstユーザーガイドを参照してください。

可能な限り多くのジョブをBurstでコンパイルできるように、Burstableコードの構成をよく理解しておく必要があります。

4.Consider whether data is read-only(データがread-onlyであるかどうかを考慮する)

ワークシート上では、それぞれのデータ変換が入力と出力を指定しています。どの変換においても、入力として使用され、出力として変更されないデータは読み取り専用であり、システムを実装する際にはそのように宣言する必要があります。

読み取り専用のデータを宣言しておくと、ジョブスケジューラがそのデータを処理するジョブを安全に並列化することができます。これにより、ジョブスケジューラはスケジュールされたジョブをどのように配置するかの選択肢が増え、利用可能なCPUスレッドを最も効率的に利用できるようになります。データを読み取り専用と適切に宣言することは、リアクティブシステム(データが変更されたときにのみ更新されるシステム)を含むプロジェクトにおいても重要です。データを読み取り/書き込みとしてアクセスすると、データが実際には変更されていなくても、リアクティブシステムが実行されてしまいます。このような理由から、読み取り専用のデータ(一部の変換では)は、読み取り/書き込みのデータとは別のコンポーネントに分離する必要があります。

データデザインのワークシートで、すべての変換で読み取り専用のデータが示されている場合、そのデータはコンポーネントに入れるべきではありません。理想的には、 BlobAsset の中に入れるべきです。BlobAssetはアンマネージドメモリに保存される不変のデータ構造で、Blittableデータ、Blittableデータの構造体や配列を含むことができ、BlobString の形式で文字列を含むこともできます。BlobAssetはディスク上にバイナリ形式で格納されているため、ScriptableObjectなどのOOPアセットよりもはるかに高速にデシリアライズすることができます。また、チャンクに格納されていないため、チャンクの断片化にも寄与せず、変更可能なコンポーネント データの処理の邪魔にもなりません。

5.Consider the most common use-cases, and optimize for those(最も一般的な使用例を考慮し、それらに合わせて最適化する)

重要な原則の項で述べたように、アプリケーションのデータを効率的に処理できるように設計する方法を検討する際には、コードが各データにアクセスする必要がある頻度を考慮することが重要です。ワークシートでは、各データの数量と、データ変換の入力と出力に基づいて予想される読み書きの頻度を計算するスペースを記載しています。これにより、最も一般的なデータアクセスのパターンが何であるかを明確に把握することができます。

最も一般的な操作が最も効率的にアクセスできるようにデータを構造化します(例えば、1フレームに1回の操作よりも、1フレームに10万回の操作の効率を優先させるべきです)。このことは、1フレームに1回しか起こらない変換では、副作用として多くのランダムなメモリアクセスやキャッシュミスが発生することを意味しているのかもしれません。あるいは、一般的な変換をより効率的に行うためのデータ構造を更新するために、1秒に1回、追加のハウスキープ処置を行わなければならないということかもしれません。あるいは、エンティティのインスタンス化と初期化のために、重いデータ処理を行わなければならないかもしれません。これらはすべて、最も頻繁に行われる操作を可能な限り高速化するのに役立つものであれば問題ありません。一般的なケースを先に最適化しておけば、他のケースが許容できないほど遅くなっていないかどうかをプロファイラで確認し、後でそれを見直すことができます。

これが実際に意味するのは、 EntityQuery をうまく計画することです。理想的には、適切なEntityQueryを使用して、反復処理するすべてのエンティティに対して有用な作業を行うジョブを記述することができます。

6.Don’t use strings(stringsを使わない)

Job SystemとBurstでは、様々なサイズの整数型や浮動小数点型、boolなど、多数のプリミティブ型をサポートしています。charについては将来的にサポートする予定です。しかし、Job SystemとBurstはmanaged typesであるため、C#の文字列型をサポートしていません。さらに、文字列はほとんどの目的において非常に非効率的なデータ型です。一般的には、UIの表示、ロードやセーブのためのファイル名やパス、そしてデバッグのためにのみ有用です。UIとファイルI/Oは一般的にUnityのDOTSコードの管轄外であり、文字列リテラルを使ってコードをデバッグする必要があります(以下参照)。

その他の内部的な目的のためには、人間が読めるstring識別子をblittableでランタイムに適したフォーマットに変換して、処理を高速化する必要があります。使用目的に応じて、これは列挙型、単純な整数インデックス、または文字列から計算されたハッシュ値などが考えられます。Entities 0.16では、この目的のために32ビットまたは64ビットのハッシュを生成できる XXHash クラスを提供しています。

String exceptions

文字列を使用する必要がある場合は、DOTSに適したオプションがいくつかあります。ollectionsパッケージには、 FixedString32FixedString64 などのいくつかの型があります。注:Collectionsパッケージの0.11以前のバージョンでは、これらはそれぞれNativeString32およびNativeString64と呼ばれていました。

ECSコンポーネントやジョブでFixedStringsを使用することができますが、表現したい文字列の長さによっては、メモリを浪費してしまう可能性があります。BlobAsset内に文字列を格納したい場合は、 BlobString を使用してください。デバッグログの目的で、限られた形式の文字列処理を使用することができます( Burst user guide :言語サポートを参照)。

7.Runtime data is not the same as authoring data(ランタイムデータはオーサリングデータとは異なる)

エンティティ変換ワークフローがどのように動作するかを理解してください。データデザインの観点からの主なポイントは以下の通りです。

  • オーサリングデータは柔軟性のために最適化されている

  • 人間の理解しやすさと編集しやすさ

  • バージョン管理(マージ可能、重複しない)

  • チームワークの構築

  • ランタイムデータはパフォーマンスのために最適化される

  • キャッシュの効率性

  • 読み込み時間とストリーミング

  • 配布サイズ
    つまり、このベストプラクティスガイドでは、最適なランタイムデータ構造の作成と処理に焦点を当てていますが、このランタイムデータのオーサリングに使用するデータ形式や、オーサリング用のデータ形式からランタイムデータへの変換方法についても考慮する必要があります。警告 GameObjectConversionUtility など、ランタイムでGameObjectをEntityに変換するためのAPIはあるが、ランタイムコードでは絶対に使ってはいけないので、将来的には非推奨となる。なぜなら、ランタイムコードでは絶対に使ってはいけないからだ。これらはあまりにも遅い。そのため、ビルド時にUnity Editorでオーサリングデータをランタイムデータに変換し、ランタイム時にUnityがランタイムデータのみをロードして使用するようにします。

Part 3.1: Implementation fundamentals(実装の基礎知識)

概要

DOTSベストプラクティスガイドのこのセクションでは、以下の内容を説明します。

  • DOTSコードのパフォーマンス低下やエラーの原因をチェックする方法

  • 適切なread/write権限とメモリアロケータを持つデータを宣言する方法

  • データの保存先と保存方法について

1.Always profile, always read the console window(常にプロファイル、常にコンソールウィンドウを読む)

(https://learn.unity.com/tutorial/part-3-1-implementation-fundamentals より引用)

机の上の整理整頓と同じように、データの保存場所を管理し、目的に応じてデータをまとめておく必要があります。机の上の整理整頓では、紙(書き込める)とホッチキス(書き込めない)の両方を保存できるように 、データを読み取り専用にするかどうかを指定する必要があります。

Unity Editorでどのようにウィンドウを配置しても、DOTSで開発している場合は、UnityのCPUプロファイラ(できればタイムライン表示)とコンソールウィンドウを常に開いて表示しておくと便利です。コンソールウィンドウでは、Jobセーフティーシステムに違反するコードを書いていないか、ネイティブに割り当てられた一時的なメモリをリークしていないかをすぐに知ることができます。また、プロファイラを使えば、パフォーマンスに問題があるシステムを書いたかどうかを知ることができます。

コードを書いてデバッグするときは、セーフティーシステムをオンにして、Unityが潜在的な問題をConsoleウィンドウで報告できるようにします。これらのオプションはすべてパフォーマンスに影響を与えるので、デバッグが終わってエディタでコードのパフォーマンスを確保したいときは、これらのオプションをオフにしてください。

  • Jobs > Leak Detection > On:ネイティブコンテナのアロケーションに関連した潜在的なメモリリークについて警告します。また、特定のリークの原因を追跡するには、Jobs > Leak Detection > Full Stack Traces (Expensive) を有効にします。リーク検出は、小さなマネージド・アロケーションを作成して追跡することで機能するため、プロファイラで多くのGC Allocsが表示される場合は、このオプションを無効にして再度試してみてください。

  • Jobs > Burst > Safety Checks:ネイティブコンテナでの境界外のエラーや、データの依存関係の問題を警告します。

  • Jobs > JobsDebugger:ジョブのスケジューリングやコンフリクトに関連する問題を警告します。

  • Unity Editor Status Bar > Debug Mode :この設定により、エディターの実行中にデバッガーをアタッチすることができます。Unity 2020.1以降、このオプションは、Unity Editorウィンドウの右下にあるアイコンにあります。それ以前のバージョンのUnityでは、このオプションは Preferences > External Tools > Editor Attaching にあります。

(https://learn.unity.com/tutorial/part-3-1-implementation-fundamentals より引用)

2.Separate data from systems(データとシステムの分離)

データとそのデータを操作するメソッドを1つの構造体(またはクラス)にまとめるのがOOPの概念です。DODは、データの構造体とそのデータを操作するシステムが基本です。つまり、コンポーネントにはメソッドがなく、システムにはデータがないというのが原則です。

Beware methods on components(コンポーネントのメソッドに注意)

コンポーネント上のメソッドは必ずしも有害ではありませんが、これはDODのアンチパターン「物事を1つ1つこなしていく」アンチパターンです。コンポーネント上のメソッドは、そのコンポーネント内のデータを操作するように設計されていますが、DODのパフォーマンス上の利点の大部分は、コンポーネントのバッファ(またはEntityQuery)全体を一度に変換するメソッドを書くことにあります。 コンポーネントのデータを公開し、複数のコンポーネントを反復するループを書き、データに対して直接操作を行います。

コンポーネントにメソッドを入れた場合、2つの結果が得られます。どちらかです。

  • メソッドがインライン化されず、ループ内のすべてのコンポーネントで追加のメソッド呼び出しによるパフォーマンスへの影響を受けるか

  • メソッドがインライン化され、生成されるコードは、最初にデータ変換をシステムに直接書き込んだ場合と同じになります。メソッドを書いても何の得にもなりません。

Beware data in systems(システム内のデータに注意)

"システムにデータを入れない "というのは、実際にはもう少し微妙なルールです。コーディングスタイルの観点から言えば、システムは最終的にはデータ変換に過ぎないはずです。システムには、状態データをできるだけ少なく、理想的には全く保存しないようにします。しかし、現実的には、システムのメンバー変数にある種のデータを格納できる場合があります。このような場合、考慮すべき重要なルールは、コンポーネントのデータは唯一の真実の情報源であるべきだということです。

つまり、システムが処理効率の良い構造でデータをキャッシュしても、そのキャッシュされたデータをECSワールドの現在の状態に基づいて再作成する限り(1フレームごと、または最後にシステムを起動したときからコンポーネントデータが変更された可能性を検出したとき)、問題はないということです。これは、エンティティやコンポーネントは、それらに関心を持つシステムの外部から生成または破壊される可能性があるためです(例えば、サブシーンのストリーミングやアンロード、Live Linkでの変更など)。つまり、システムはエンティティの寿命を仮定することはできません。また、キャッシュされたデータは、そのデータを構築するために使用されたコンポーネントよりも長持ちする可能性があるため、保持すべきではありません。

リアクティブシステムで値をキャッシュすることは、状況によっては有効です。例えば、 Unity PhysicsRigidBody コンポーネントの存在に反応し、効率的な衝突検出のために空間ハッシュ構造を構築します。ただし、デザイン上、Unity Physicsエンジンにはフレームの一貫性がないため、空間ハッシュのデータは一時的なものであり、フレームごとに再構築されることに注意が必要です。ハイブリッドレンダラーは、レンダリングバッチのリストを維持するために、RenderMesh コンポーネントの作成または破棄に反応します。このリストはフレームからフレームへと持続しますが、ハイブリッドレンダラーのシステムはRenderMeshコンポーネントの作成または破壊に反応してそれを更新するので、システムはバッチリストを常にワールドの状態と同期させることを保証できます。

コンポーネントが作成または破棄されたときに検出する方法の詳細については、Entitiesパッケージのドキュメントページの System State Components を参照してください。

また、システムが内部機能に関連するものをキャッシュすることもよくあります。例えば、EntityQueriesや、EndSimulationEntityCommandBufferSystemなどのシステムへの参照を OnCreate() でキャッシュしておくと、後で素早くアクセスできるようになります。

Avoid static data for faster iteration times(静的なデータを避けて反復時間を短縮)

開発時の反復時間が遅くなる原因の一つに、プレイモードに入ったり出たりする際のドメインの再ロードがあります。これは、プレイモードの開始時に、ユニティエディタがスクリプトコンテキスト全体をアンロード、リロードして、スタティック変数の値をリセットし、新規で決定性のある状態を提供するプロセスです。ドメインの再ロードを無効にすることもできますが、GameObjectベースのOOPプロジェクトでよく見られるように、プロジェクトがスタティック変数を使用している場合、予期せぬ動作を引き起こす可能性があります(Unityブログポスト「 Enter Play Mode faster in Unity 2019.3 」参照)。しかし、UnityのCTOであるJoachim Ante氏のフォーラム投稿( #Protip: Super fast enter playmode times when working in DOTS )で説明されているように、DOTSではすべてが静的変数を避けるように設計されています。もしあなたのプロジェクトが静的データも避けているのであれば、ドメインの再ロードを無効にして、開発中の時間を節約することができます。

3.Declare read-only data as often as possible(読み取り専用のデータを可能な限り宣言する)

データ設計では、どのデータがどのシステムに対して読み取り専用であるかを伝える必要があります。システムを実装する際には、その読み取り専用の関係を明示的に正しく表現する必要があります。

Use blob assets for read-only config data(読み取り専用の設定データにblobアセットを使用)

100%読み取り専用のデータ(コンフィグ データなど)は、ソース データが何であれ、 BlobBuilder を使用して読み取って blob アセットにパックする必要があります。 さらに、BlobAssetSerializeExtensionsを使用して、ビルド時に直接バイナリ ファイルにシリアライズし、ランタイムに直接ロードおよびデシリアライズすることもできます。これにより、他のファイル形式からの時間のかかる読み込みと解析の操作を省くことができます。

Declare write access correctly in Entities.ForEach() and jobs(Entities.ForEach()とジョブで書き込みアクセスを正しく宣言する)

Entities.ForEach()のラムダ関数を定義する際には、コンポーネント・パラメータを in キーワードで読み取り専用として宣言してください。ラムダ内でコンポーネント・データを変更する必要がある場合は、それらのパラメータを ref として宣言します。in パラメータは ref パラメータの後に記述する必要があります ( SystemBase.Entities ドキュメントの「Defining the lambda function」を参照)。

Burstがinパラメータをどのように扱うかについての詳細は、Unityのブログポスト「 In parameters in Burst 」を参照してください。

Entities.ForEach()で特定のコンポーネントを持つエンティティのみを反復処理する必要があるが、ラムダ内でそのコンポーネントから実際にデータを読み取る必要がない場合は、ラムダのパラメータとしてコンポーネントを渡さないでください。その代わりに、定義に WithAll<T>() を追加します ( SystemBase.Entities ドキュメントの「Describing the entity query」セクションを参照してください)。

Use attributes to mark [ReadOnly] variables in jobs(ジョブ内の[ReadOnly]変数をマークするための属性の使用する)

ジョブ構造体(例: IJobChunk )でデータを宣言する際には、ジョブのExecute()メソッドで書き込まれない変数が[ReadOnly]としてマークされていることを確認してください。

Use read-only versions of ComponentDataFromEntity or BufferFromEntity where possible(ComponentDataFromEntityやBufferFromEntityの読み取り専用バージョンを可能な限り使用する)

時にはエンティティコンポーネントのセットを繰り返し処理する必要がある場合がありますが、Dictionary のように動作するジョブフレンドリーな構造を介してコンポーネントやダイナミック バッファにランダムにアクセスする機能を維持する必要があります。 SystemBase.GetComponentDataFromEntity() メソッドと SystemBase.GetBufferFromEntity() メソッドは、これをサポートしています。これらのメソッドは両方ともオプションのブール値を取り、コンポーネント/バッファデータを読み取るだけの場合は、true を渡す必要があることに注意してください。ジョブスケジューラは、この情報を使用して、スケジュールされたジョブの依存関係と実行順序を計算します。しかし、Entities.ForEach()では、このフラグを使用して、 ComponentDataFromEntityBufferFromEntity が、並列化されたコードで競合状態なしに安全に使用できるかどうかを判断していません。そのためには、定義に WithReadOnly() を追加する必要があります。

また、スケジュールされたEntities.ForEach()の中から SystemBase.GetComponent() / SetComponent() を呼び出した場合、いくつかのコード生成が行われます。コード生成プロセスでは、これらの呼び出しをComponentDataFromEntityに変換します。ComponentDataFromEntityは、GetComponent()のみを呼び出した場合は読み取り専用、SetComponent()も呼び出した場合は読み取り/書き込みとしてフラグが立てられます。

Putting it all together(まとめ)
もし、あなたのJobに対する条件が次のようなものだったとします。

  • コンポーネントBar、Baz、Quxを持つエンティティを操作する必要があります。

  • Barは読み取り/書き込み、Bazは読み取り専用として扱わなければならない。Quxへの読み書きは必要ない。

  • エンティティからFooコンポーネントとMyBufferDataバッファにランダム、非線形、読み取り専用でアクセスしなければなりません(エンティティごとに調べます)。

  • ジョブを並行して実行するようにスケジュールしたい。

コードは以下のようになります。

var fooFromEnt = GetComponentDataFromEntity<Foo>(true);  
var myBufferFromEnt= GetBufferFromEntity<MyBufferData>(true);  
  
Entities
    .WithAll<Qux>()
    .WithReadOnly(fooFromEnt)
    .WithReadOnly(myBufferFromEnt)
    .ForEach((ref Bar bar, in Baz baz) =>  
    {  
        // ...  
    }.ScheduleParallel();

4.Know your memory allocators(メモリアローケータを知る)

Choosing an allocator(アロケータの選択)

ネイティブコンテナを割り当てる際、Unityは Allocator の選択肢を用意しています。これらについては、 NativeContainer .マニュアルページのNativeContainer Allocatorsの項で詳しく説明しています。一般的には、割り当て時間が長ければ長いほど、メモリの割り当てと解放に時間がかかります。つまり、現在のフレームのための一時的なバッファだけが必要な場合は、Allocator.Tempを使います。ジョブでコンテナを使用したい場合は、Allocator.TempJobを使用してください。4フレーム以上継続して使用したいアロケーションには、Allocator.Persistentを使用します。

Allocatorのパフォーマンス特性をより詳しく調べるには、Jackson Dunstan氏の2019年のブログ記事「 Native Memory Allocators: More Than Just a Lifetime」をご覧ください。

Disposing allocated memory(割り当てられたメモリの破棄)

適切なアロケータを使ってネイティブコンテナを割り当てたら、次の問題はどのように解放するかです。ネイティブメモリは、C#のマネージドメモリのように自動的にスコープされたり、ガベージコレクションされたりしません。

Entitites.ForEach()やJob.WithCode()の定義に WithDisposeOnCompletion() を追加することで、ジョブが完了した時点でキャプチャした変数を破棄することができます。

ジョブ構造体を直接作成してスケジューリングしている場合(例えば、JobChunkやJobEntityBatchを使用している場合)、ジョブにNativeコンテナを渡して、DeallocateOnJobCompletion属性を使用して確実に解放することができます。

public class MyJobSystem : SystemBase  
{  
    private struct MyJob : IJob  
    {  
        [DeallocateOnJobCompletion] public NativeArray<int> SomeArray;   
  
        public void Execute()  
        {  
            // ... Process SomeArray...  
        }  
    }  
  
    protected override void OnUpdate()  
    {  
        var someArray = new NativeArray<int>( 10, Allocator.TempJob );  
        var job = new MyJob { SomeArray = someArray };  
        Dependency = job.Schedule(Dependency);  
    }  
}  

Part 3.2: Managing the data transformation pipeline(データ変換パイプラインの管理)

概要

DOTSベストプラクティスガイドのこのセクションでは、以下の内容を説明します。

  • 入力データをプロジェクトが必要とする出力データに変換するための効率的なパイプラインを構築するために、システムを構築し整理する方法

  • 構造変更によるパフォーマンスへの影響と、その影響を軽減する方法を学ぶ

1.Control system ordering(制御システムの発注)

(https://learn.unity.com/tutorial/part-3-2-managing-the-data-transformation-pipeline より引用)

私たちがオブジェクト指向の世界に接するとき、インプット(個々の自動車部品)を受け取り、アウトプット(完全に組み立てられた自動車)を作りますが、その間には、特定の変換タスクを特定の順序で実行するシステム(ロボット)からなる、明確に定義された生産パイプラインがあります。これらの変換を効率的に行うには、ボトルネックをできる限り取り除くことが必要です。

DOTSプロジェクトの各フレームは、データ変換のパイプラインに似ています。フレームは、時間Tにおけるシミュレーションの状態を表すデータの集合体(ユーザーの入力を含む)から始まり、そのデータを処理するために、システムがジョブを起動して、時間T+1におけるシミュレーションの状態という出力を生成します。このパイプラインを効果的に管理するには、どのシステムがどの順番でデータを処理するかをコントロールする必要があります。

ECSでは、更新ループ中の様々なポイントで更新を行うデフォルトの ComponentSystemGroups がいくつか用意されています。デフォルトでは、新しいシステムを作成すると、ECSはSimulationSystemGroupの中でそれを更新し、UnityはSimulationSystemGroupの更新のどの時点でシステムを更新するかを任意に(しかし決定論的に)決定します。詳細については、Entitiesパッケージのドキュメント「 System Update Order 」を参照してください。

このプロセスをよりコントロールするためには、独自のComponentSystemGroupを作成し、SimulationSystemGroupの中に入れ子にする必要があります。作成した各システムでは、UpdateInGroup属性を使用して、どのComponentSystemGroupの一部であるかを指定します。各システムは、UpdateBeforeUpdateAfter属性を使用して、他のシステムとの相対的なグループ内での順序付けを制御する必要があります。

ここでは、ゲーム内のキャラクターを制御するためのシステムを例に説明します。まず、フレームの初期段階でコードが入力を読み込んで処理します。次に、キャラクタのステートマシンがこれらの入力を使用して、シミュレーションにおけるキャラクタの状態を更新します。最後に、更新されたキャラクターの状態を反映して、キャラクターがアニメーションを更新します。これにより、入力がキャラクタの状態に影響を与え、アニメーションが1つのフレーム内で開始されるため、プレイヤーにとってレスポンスの良い動作となります。この例では、システムの更新順序を[UpdateAfter]で指定しています。

public class InputSystem : SystemBase  
{  
    // ...  
}  
  
[UpdateAfter(typeof(InputSystem))]  
public class StateMachineSystem : SystemBase  
{  
    // ...  
}  
  
[UpdateAfter(typeof(StateMachineSystem))]  
public class AnimationSystem : SystemBase  
{  
    // ...  
} 

2.Group sync points together, and schedule as many jobs as you can(同期ポイントをグループ化し、できる限り多くのjobをスケジューリングする)

最新のマルチコアCPUを最大限効率的に利用するためには、メインスレッドからできるだけ多くの作業を移す必要があります。DOTSではC# Job Systemを使ってこれを実現しています。

ジョブの実行をスケジュールするには、3つの方法があります。

  • ScheduleParallel()

  • Schedule()

  • Run()

ScheduleParallel()は、任意のジョブまたはEntities.ForEach()を実行し、利用可能なすべてのCPUコアに作業を分割することができます。ただし、アルゴリズムやデータ構造、メモリ・アクセス・パターンによっては、安全に並列化できない場合があります。

ScheduleParallel()を使用できない場合は、Schedule()を使用して作業をスケジュールします。この方法では、メインスレッドではなく、単一のスレッドで作業が実行されます(メインスレッドがジョブの依存関係の連鎖の完了を待ってブロックされている場合を除く)。ただし、マネージド・オブジェクトへのアクセスが必要な場合などは、この方法が使えない場合もあります。

最後の選択肢はRun()で、ScheduleParallel()やSchedule()を使用できない場合にのみ使用してください。Run()を使ってジョブを実行すると、メインスレッドがブロックされ、既にスケジューリングされている依存関係をジョブシステムが強制的に完了できるようになります。その後、Job Schedulerはメインスレッド上で同期してジョブを実行します。Job Schedulerがスケジュールされたジョブを強制的に完了させるフレーム内の時間は同期ポイントとして知られており、適切に管理しないとパフォーマンスを低下させる可能性があります。

同期ポイントの主な原因は、ECSのチャンクデータ内の構造変化です。ECSの構造的変化とは

  • チャンクデータ内のエンティティコンポーネントのレイアウトの変更

  • エンティティの作成または破棄

  • コンポーネントの追加または削除

  • 共有コンポーネントの値の変更

競合状態を避けるために構造変更はメインスレッドで行う必要があるため、EntityManagerを介して構造変更を実行しようとすると、ジョブシステムがRun()を使用したジョブの実行しか許可しないことがよくある理由です。ありがたいことに、 Entity Command Buffersを使ってこの問題を軽減することができます。Entity Command Buffersは、構造変更のリクエストをキューに入れ、フレームの後のあらかじめ指定された時点でEntityCommandBufferSystemによってまとめて実行されます。EndSimulationEntityCommandBufferSystemは、一般的に使用されるビルトインの同期ポイントです。

Use SystemGroups to group main thread work(SystemGroupsを使用してメインスレッドの作業をグループ化)

他のシステムでは、管理されたデータとのやりとりのために、メインスレッドでの作業が必要になることがあります。例えば、従来のOOPオブジェクトであるGameObjectやMonoBehavioursからデータを読み込んで、DOTSで処理やシミュレーションを行い、その結果をGameObjectの表現に戻したいというアプリケーションが多いのではないでしょうか。これらのメインスレッドシステムは、SimulationSystemGroupの途中で新たな同期ポイントが発生しないように、できるだけ少ない数のSystemGroupにまとめる必要があります。以下のコードでは、ComponentSystemGroupを宣言し、マネージドデータプロセスとblittableデータプロセスを分離するシステムを示しています。

using Unity.Entities;  
  
// A group that runs right at the very beginning of SimulationSystemGroup  
[UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)]  
[UpdateBefore(typeof(BeginSimulationEntityCommandBufferSystem))]  
public class PreSimulationSystemGroup : ComponentSystemGroup { }
  
// A group that runs right at the very end of SimulationSystemGroup  
[UpdateInGroup(typeof(SimulationSystemGroup), OrderLast = true)]  
[UpdateAfter(typeof(EndSimulationEntityCommandBufferSystem))]  
public class PostSimulationSystemGroup : ComponentSystemGroup { }
  
[UpdateInGroup(typeof(PreSimulationSystemGroup))]  
public class CopyManagedDataToECSSystem : SystemBase  
{  
    // Copy data from managed objects (such as MonoBehaviours) into components  
    protected override void OnUpdate() { }  
}  
  
[UpdateInGroup(typeof(SimulationSystemGroup))]  
public class ProcessDataSystem : SystemBase  
{  
    // Process the ECS simulation.  
    protected override void OnUpdate() { }  
}  
  
[UpdateInGroup(typeof(PostSimulationSystemGroup))]  
public class CopyECSToManagedDataSystem : SystemBase  
{  
    // Copy processed data from components back into managed objects  
    protected override void OnUpdate() { }  

次のセクションでは、このテクニックを使ったより具体的な例を見てみましょう。

3.Separate HPC# from C#(C#からHPC#を分離する)

先の「データ設計」の項で述べたように、できるだけ多くのデータを[BlittableおよびBurstable](https://learn.unity.com/tutorial/part-2-data-design?uv=2020.1&courseId=60132919edbc2a56f9d439c3 #60133c51edbc2a001e319749)にすることを目指すべきです。また、ハイパフォーマンスC# (HPC# )を書くためのその他の制限事項も守る必要があります。詳細については、Burstパッケージドキュメントの C#/.NET Language Support セクションを参照してください。

あなたのプロジェクトがMonoBehavioursを含むコードを使用している場合、参照型と標準C# も扱います。これらのC# セクションは、DOTSコードとはできる限り独立的に分離しておくことを目指してください。DOTSコードに参照型や標準C#が混在していると、Burstのコンパイルジョブやスケジュールジョブができません。

IComponentDataインターフェイスを持つクラスを宣言して、マネージドIComponentDataを生成することができます。これは、いくつかの限定された方法で標準ECSコンポーネントのように動作するコンポーネントです。マネージドコードからDOTSに移行する場合、移行作業を助けるための一時的な手段として、マネージドIComponentDataを使用すると便利です。この機能を使用していない場合は、プロジェクトのPlayer SettingsのScripting Definesに UNITY_DISABLE_MANAGED_COMPONENTS を追加して、誰もが誤ってIComponentDataをクラスとして宣言しないようにしてください。

それでは、例を見てみましょう。AnimAIStateというコンポーネントには、AIキャラクターの現在のアクティビティを示す列挙型の値と、キャラクターのアニメーションやレンダリングを行うGameObject上のAnimatorコンポーネントへの参照が含まれています。コンポーネントシステムは、複雑なステートマシンロジック(図示せず)をフレームごとに実行して、キャラクターがどのようなアクティビティを行うべきかを判断し、Animator Controllerの変数を更新して、アクティビティにマッチしたアニメーションを再生します。

public enum CharacterActivity { Idle, Run, Jump, Shoot };  
  
public class AnimAIState : IComponentData  
{  
    public Animator animator; // An Animator component on a GameObject  
    public CharacterActivity activity;  
};  
  
public class AnimAISystem : SystemBase  
{  
    protected override void OnUpdate()  
    {  
        Entities.WithoutBurst().ForEach((ref AnimAIState animAIState) =>  
        {  
            animAIState.activity = SomeComplexStateMachineLogic();  
            animAIState.animator.SetInteger("State", (int)animAIState.activity);  
        }).Run();  
    }  
}  

一見、わかりやすくていいですよね。しかし、このAnimatorには問題があります。管理されたオブジェクトへの参照であるため、コンポーネントはBlittableではなく、ジョブで使用するためには管理されたIComponentData(構造体ではなくクラス)として宣言する必要があります。これにより、このコンポーネントを処理するEntities.ForEach()は、メイン・スレッドで直ちにRun()することと、WithoutBurst()でコンパイルすることを宣言しなければなりません。

その結果、複雑なステートマシンのロジックは純粋なHPC#として記述されているにもかかわらず、Burstを利用することができず、ジョブシステムはメインスレッド以外で実行することができません。Animatorコンポーネントは管理されたオブジェクトであるため、その操作には常に時間がかかりますが、コンポーネントやシステムが分割されて別々に扱われるようにコードを再編成すれば、ステートマシンロジックのパフォーマンスを向上させることができます。

public class AnimatorRef : IComponentData { public Animator animator; }  
  
public struct AIState : IComponentData { public CharacterActivity activity; };  
  
[UpdateInGroup(typeof(SimulationSystemGroup))]  
public class AIStateSystem : SystemBase  
{  
    protected override void OnUpdate()  
    {
        Entities.ForEach((ref AIState animAIState) =>  
        {  
            animAIState.activity = SomeComplexStateMachineLogic();  
        }).ScheduleParallel();  
    }  
}  
  
[UpdateInGroup(typeof(PostSimulationSystemGroup))]  
public class AnimatorRefSystem : SystemBase  
{  
    protected override void OnUpdate()  
    {  
        Entities.WithoutBurst().ForEach((AnimatorRef animatorRef, in AIState aiState) =>  
        {  
            animatorRef.animator.SetInteger("State", (int)aiState.activity);  
        }).Run();  
    }  
}  

このコードでは、このガイドの前のセクションにあったPostSimulationSystemGroupを使用し、その一部としてAnimatorRefSystemが更新されるようにしていることに注意してください。これは、AnimatorRefSystemのジョブはメインスレッドで実行する必要がありますが、AIStateSystemでスケジュールされたジョブが完了した後にのみ実行する必要があるためです。現在のところ、スケジュールされたジョブと、そのジョブの後に実行しなければならないメインスレッドのジョブの間の依存関係を表現する最善の方法は、スケジュールされたジョブが完了したことを確認するために同期ポイントを使用することです。この例では、システムはEndSimulationEntityCommandBufferSystemを使用しています。

AIStateにAnimatorの参照がなくなったため、AIStateSystemのEntities.ForEach()ジョブは自由にBurstを使用し、利用可能なワーカースレッド間で作業を並列化するようにスケジュールすることができます。これにより、ステートマシンロジックの実行時間が大幅に短縮されるはずです。

4.Beware of structural changes(構造変化に注意)

構造変更を行うと、パフォーマンスに悪影響を与える同期ポイントが発生する場合があります。また、構造変更の際にECSが実行しなければならない他のCPUタスクがあり、これもパフォーマンスに影響を与える可能性があります。例えば、AとBという2つのコンポーネントを持つエンティティがあり、3つ目のコンポーネントであるCを追加したいとします。

var entity = EntityManager.CreateEntity(typeof(A), typeof(B));  
// ... Some time later...  
EntityManager.AddComponent<C>(entity); 

エンティティにコンポーネントを追加すると、ECS内で次のようなプロセスが開始されます。

  • アーキタイプABのエンティティにCを追加すると、アーキタイプABCのエンティティができあがります。そこでまず、ABCのEntityArchetypeがすでに存在するかどうかを確認します。

  • EntityArchetypeがまだ存在しない場合は、作成します

  • アーキタイプABCには、チャンクへのポインタのリストが含まれています。これらのチャンクの中に、新しいエンティティのためのスペースがあるかどうかを確認します。

  • 必要に応じて新しいチャンクを割り当てます

  • 元のエンティティからコンポーネント A と B を新しいチャンクに Memcpy() します。

  • コンポーネントCも同様に新しいチャンクに作成またはコピーします。

  • EntityManager を更新して、以前は AB チャンクのインデックスを指していたエンティティが、ABC チャンクの新しいインデックスを指すようにします。

  • swap_back() を使用して AB チャンクから元のエンティティを削除します。

  • 元のエンティティがチャンク内の唯一のエンティティだった場合、チャンクのメモリが空になったので解放します。

  • 元のエンティティがチャンク内の最後のエンティティでなかった場合、swap_backはこのチャンク内の他のエンティティの1つのインデックスを変更しただけです。そこで、EntityManagerを再度更新して、移動したばかりのエンティティが新しいインデックスにマッピングされるようにします。

  • 新しいチャンクが割り当てられた場合、またはチャンクが解放された場合、そのチャンクのアーキタイプを含むすべてのEntityQueryについて、チャンクのキャッシュされたリストをクリアします。EntityQueryは、次に実行されるときに参照するチャンクのリストを再計算します。

これらの手順はどれも単独では特に遅いものではありませんが、それらをすべて加え、さらに1つのフレームでアーキタイプを変更する何千ものエンティティを掛け合わせると、かなり大きなパフォーマンスの影響を受けることになります。この処理コストの一部は、ランタイムに宣言されたEntityArchetypesとEntityQueriesの数に関連してスケールアップすることに気付きます。

実際、構造変更によるパフォーマンスへの影響は、さまざまな要因に左右されます。以下は、さまざまな方法で100万個のエンティティにコンポーネントを追加する際のメインスレッドでの中央値の時間(ミリ秒)を示した表です。これらは、2.9GHzクアッドコアのIntel Core i7 CPUを搭載した2016年のMacBook Proで実施しました。CPUによってタイミングは異なりますが、これらの時間は、異なるアプローチによってパフォーマンス特性が大きく異なることを明確に示しています。

(https://learn.unity.com/tutorial/part-3-2-managing-the-data-transformation-pipeline から引用)

この表のデータを解釈することで、構造的な変更を導入するコードを書く際に考慮すべきいくつかのガイドラインを得ることができます。

Prefer using EntityQuery to make changes, rather than making them individually(EntityQueryを使用して変更を行う方が、変更を個別に行うよりも望ましい)

EntityQueryを使用して構造的な変更を行うと、NativeArray<Entity>を使用するよりもはるかに速くなります。これは、EntityManager (または EntityCommandBuffer) が Entity を個別に操作するのではなく、チャンク全体を操作できるように EntityQuery を使用しているためです。

EntityQueryが使えない場合は、個々のEntityに変更を加えるのではなく、NativeArray<Entity>を使うようにしましょう。NativeArray<Entity>を使用すると、Entities.ForEach()内で個別にEntityを変更するよりもはるかに速くなります。EntityManagerが更新する必要のあるすべてのEntityを知っている場合、一度に1つしか処理しない場合にはできない最適化を行うことができます。

Prefer to add/remove tag components rather than components with data(データ付きコンポーネントではなく、タグ付きコンポーネントの追加/削除が望ましい)

タグコンポーネントの追加は、標準コンポーネントの追加よりもはるかに高速です。タグコンポーネントは、実際のデータを含まない IComponentData です。タグを追加/削除すると、エンティティのアーキタイプが変更され、将来的に何らかのEntityQueryに含まれるかどうかに影響を与える可能性がありますが、実際にはコンポーネントデータ自体の変更や追加は行われません。 一度にチャンク全体にタグを追加したり削除したりする場合(例えば、EntityCommandBufferやEntityManagerのAdd/RemoveComponent()メソッドにEntityQueryを渡すなど)、チャンク内のデータフォーマットを実際に変更する必要はありません。つまり、ECSはチャンクを新しいアーキタイプに属するものとしてマークし、アーキタイプのチャンクリストを更新するだけで、チャンクデータ自体を実際に移動したりコピーしたりする必要はありません。

Prefer using an EntityCommandBuffer rather than EntityManager(EntityManagerではなく、EntityCommandBufferの使用、が望ましい)

上の表に示した結果を生成するために使用した簡単なベンチマークテストでは、EntityManagerとEntityCommandBufferの結果は同等でしたが、実際のプロジェクトではEntityManagerを使用する際のいくつかの問題が浮き彫りになるでしょう。

  • 構造変更は常にメインスレッドで行われるため、ジョブシステムではEntityManagerを使用するジョブのスケジューリングや並列化、メインスレッド以外の場所での実行はできません。逆に、EntityCommandBufferがEntityCommandBufferSystemでplayed backされるまでは構造変更が発生しないので、スケジュールされたジョブや並列化されたジョブからEntityCommandBufferにコマンドを追加することができます。

  • EntityManagerを使用して構造的な変更を行うと、常に同期ポイントが作成されますが、EntityCommandBufferは既存の同期ポイントの間に実行されます。

  • EntityCommandBufferがそのコマンドをplayed backすると、構造的な変更に関わるメンテナンス作業の一部(例えば、EntityArchetypesやEntityQueriesが指すチャンクの再計算)を、コマンドリストを空にした後に一度だけ実行することができます。EntityManagerは構造変更のたびにこの作業を行う必要があるため、全体的にCPUの作業量が多くなります。
    EntityManagerは、同じフレーム内で後から実行されるEntityQueryに影響を与えるために構造的な変更が必要な場合に便利ですが、システムアップデートの順序を制御することで、SystemGroupの途中で構造的な変更を行う必要性を回避できることがよくあります。そうすることで、構造的な変更とその変更に依存するEntityQueryが既存の同期ポイントによって分離されていることを保証することができます(「 Separate HPC# from C# 」セクションを参照)。

Consider banning structural changes if entity state changes are very frequent(エンティティの状態変化が非常に頻繁な場合、構造変更の禁止を検討する)

構造的な変更はパフォーマンスに影響を与えますが、それには理由があります。エンティティの状態が何らかの形で変化した場合、異なるシステムのセットでデータ変換を実行できるようにすることで、エンティティの動作を変更したい場合があります。通常、これを行う最もシンプルで効率的な方法は、エンティティのアーキタイプを変更して、異なるEntityQueriesのセットに含めることです。

しかし、非常に頻繁にエンティティの状態を変更する必要がある場合は、コンポーネントを追加または削除するのではなく、コンポーネントに何らかのフラグを設定することで表現することができます。表の最初の項目からわかるように、100万個のエンティティを反復して1つずつフラグを設定するコード(この例では、すべてのエンティティのBarコンポーネントにIsActiveというboolを設定する)は、それらのエンティティにタグコンポーネントを追加する最も効率的な方法の2倍以上の速さでした。Barコンポーネントがアクティブなエンティティを処理したい他のジョブでは、Barコンポーネントを持つエンティティを問い合わせ、IsActiveがtrueであるかどうかをチェックしてから処理することができます。

Entities.ForEach((Entity entity, ref Foo foo, in Bar bar) =>  
{  
    if (bar.IsActive)  
    {  
        // ... do something  
    }  
}).ScheduleParallel();

これにはデメリットもあります。

  • このEntities.ForEach()は、FooとBarの両方のコンポーネントを持つすべてのエンティティを処理することになり、それぞれがアクティブであるかどうかをチェックするのは内部のコード次第です。これは、対象となることがわかっているエンティティのみを処理するEntityQueryに比べて、処理能力の無駄遣いです。

  • また、if文自体のパフォーマンスへの影響だけでなく、CPUがアクセスする必要のないコンポーネントデータをキャッシュラインに読み込むことによる影響もあります。条件付きで破棄されるコンポーネントをキャッシュに入れることは、帯域幅の無駄遣いです。この無駄な帯域幅の影響は大きく、最悪の場合、アクティブなエンティティを個別に処理した場合に発生するキャッシュミスと同等かそれ以上になる可能性があります。

  • どのエンティティがアクティブになって処理され、どのエンティティが拒否されるかをコンパイル時に知ることができないため、BurstはEntities.ForEach()のコードを自動的にベクトル化することができず、SIMD最適化の適用が制限されます。

  • アクティブなBarコンポーネントを持つ可能性のあるすべてのエンティティは、現在、Barコンポーネントを持たなければなりません。Barに他のデータが含まれている場合、プロジェクトでは、ディスク(プレハブやサブシーン)やメモリ上に余分なストレージスペースが必要になります。また、データのロードによる追加コストや、大きなエンティティがより多くのチャンクを占めることによるチャンクの断片化も発生します。

Entitiesパッケージの将来のバージョンでは、オプションのEnabledフラグを、チャンクコンポーネントのビットフィールドとして実装する計画があります。これにより、フラグビットの格納スペース、CPUキャッシュの帯域幅、バーストコード生成のバランスが取れ、厄介なメンテナンス(構造変更に伴うビットフィールドの再配置など)も自動的に処理されるようになります。これが利用可能な場合、非常に頻繁に状態が変化するエンティティには最適な選択肢となるかもしれませんが、ほとんどの場合、タグ・コンポーネントの方がまだ望ましいでしょう。

5.Enable/disable systems to avoid structural changes(構造変更を避けるためのシステムの有効化/無効化)

あるシステムが、そのEntityQueryにマッチするすべてのエンティティを処理しないようにするのではなく、それらのエンティティのすべてから何らかのコンポーネントを取り除くようにしたい場合、そのシステム自体を無効にすることができます。これを行う最も良い方法は、システムの OnCreate() メソッドで RequireSingletonForUpdate() を呼び出すことです。指定したコンポーネントがワールドに存在する場合は、システムが更新されます。コンポーネントを削除すると、システムは更新を停止し、数千個のコンポーネントを追加/削除する必要はなく、1つのコンポーネントを追加/削除するだけで済みます。このコードでは、何千本ものヒマワリが昼夜のサイクルで太陽の方向を向いています。太陽の光の方向は、Singletonコンポーネントに格納されています。夜になったときや、プレイヤーが室内にいるときには、SunUpdateSystemがSunDirectionシングルトンを破壊して、FlowerSystemの実行を回避し、ひまわりが必要なときだけ回転を更新するようにします。

public struct SunDirection : IComponentData  
{  
    public float3 Value;  
}  
  
[AlwaysUpdateSystem]  
public class SunUpdateSystem : SystemBase  
{  
    bool SunIsUpdating()  
    {  
        // return true if it's daytime and we're outside and the sun has moved  
    }  
  
    float3 CalculateSunFacingDirection()  
    {  
        // return a normalized vector representing the direction of the sun's light  
    }  
      
    protected override void OnUpdate()  
    {  
        if (SunIsUpdating())  
        {  
            if (!HasSingleton<SunDirection>())  
            {  
                // Create a singleton to contain the sunlight facing direction  
                var sunEnt = EntityManager.CreateEntity(typeof(SunDirection));    
            }  
            SetSingleton(new SunDirection { value = CalculateSunFacingDirection() } );  
        }  
        else if (HasSingleton<SunDirection>())  
        {  
            // Sun isn't updating (it's night time or we're indoors).  
            // Destroy the singleton to stop the sunflower system updating  
            EntityManager.DestroyEntity(GetSingletonEntity<SunDirection>());  
        }  
    }  
}  
  
public struct Flower : IComponentData { }  
  
public class FlowerSystem : SystemBase  
{  
    protected override void OnCreate()  
    {  
        base.OnCreate();  
        RequireSingletonForUpdate<SunDirection>();  
    }  
  
    protected override void OnUpdate()  
    {  
        var faceDirection = -GetSingleton<SunDirection>().Value;  
        faceDirection.y = 0;  
        var lookRotation = quaternion.LookRotation(faceDirection, new float3(0, 1, 0));  
  
        Entities.WithAll<Flower>().ForEach((ref Rotation rotation) =>  
        {  
            rotation.Value = lookRotation;  
        }).ScheduleParallel();  
    }  
}

もう一つの方法は、ComponentSystemBaseの Enabledプロパティを使用して、システムをオンまたはオフにすることです。これは、Entity Debuggerでシステムの横にあるチェックボックスを駆動するのと同じプロパティで、これを使ってデバッグ用のシステムを手動で有効または無効にすることができます。このフラグのチェックは実行時に非常に高速ですが、これは主にデバッグ機能として意図されています。コンポーネントに格納されていないデータに依存しているため(「 Beware data in systems 」を参照)、EnabledプロパティはUnity Live Linkなどの機能との相性が保証されていません。この問題を解決する一つの方法は、フレームの開始時に実行され、シミュレーションの状態(コンポーネントデータで表される)を調べ、この情報に基づいてどのシステムを有効/無効にするかを決定する単一のシステムを用意することです。これにより、あるシステムがランタイムにEnabledプロパティを操作していても、コンポーネントデータが唯一の情報源となります。

6.Structural changes during entity creation(エンティティ作成時の構造変化)

もうひとつのよくある間違いは、実行時にコンポーネントを1つずつ追加してエンティティを構築することです。例えば、Foo、Bar、Bazという3つのコンポーネントを持つエンティティを作成しない方法を紹介します。

// BAD!  
var entity = EntityManager.CreateEntity();  
EntityManager.AddComponent<Foo>(entity);  
EntityManager.AddComponent<Bar>(entity);  
EntityManager.AddComponent<Baz>(entity);

EntityManager.AddComponent()へのこれらの各呼び出しは、新しいアーキタイプを作成し、エンティティを全く新しいチャンクに移動させます。このアーキタイプは、アプリケーションの残りのランタイムの間存在し、新しいEntityQueryが参照するEntityArchetypesを計算する必要があるたびに、必要な計算のコストに貢献します。

最終的に取得したいエンティティを記述するアーキタイプを作成し、そのアーキタイプから直接エンティティを作成する方がはるかに良いです。

// Cache this archetype if we intend to use it again later  
var newEntityArchetype = EntityManager.CreateArchetype(typeof(Foo), typeof(Bar), typeof(Baz));  
var entity = EntityManager.CreateEntity(newEntityArchetype);
 
// Better yet, if you want to create lots of identical entities at the same time  
var entities = new NativeArray<Entity>(10000, Allocator.Temp);  
EntityManager.CreateEntity(newEntityArchetype, entities);
 

Part 3.3: Minimizing cache misses(キャッシュミスの最小化)

概要

DOTSベストプラクティスガイドのこのセクションでは、以下の内容を説明します。

  • コードが小さなバッファではなく大きなバッファで動作するようにデータを配置することで、キャッシュミスを最小限に抑える方法

  • キャッシュ効率を最大化するためのDynamicBufferの宣言と管理方法

  • チャンクの断片化がパフォーマンスやメモリに与える影響と、それを回避する方法

1.Iterate on large sets(大規模なセットの反復処理)

これがキャッシュミスの正体です。お昼ご飯を作るには、簡単に手に入る場所、つまり冷蔵庫から食べ物を持ってくるのが一番早いですよね。しかし、冷蔵庫が空であれば、買い物に行かなければならず、昼食には時間がかかります。あなたのデータも同じです。

CPUのキャッシュにないデータをコードが要求すると、CPUはそのデータをメモリから取得して、キャッシュラインを埋める必要があります。これには時間がかかりますが、そのデータ(メモリ内で64バイトのキャッシュラインを埋めるのに十分なデータ)がキャッシュされているので、高速にアクセスできるというメリットがあります。さらに、この後のメモリアクセスパターンがCPUに予測可能であれば、CPUは必要と思われるデータをキャッシュに事前読込みすることができます。CPUの予測はやや単純で、メモリ上の連続した線形バッファを前後に反復している場合は予測できますが、それ以上の複雑なものはランダムアクセスとして扱われ、キャッシュミスが発生します。つまり、連続した線形バッファを反復して処理することは、可能な限り頻繁に行うべきことなのです。

プリフェッチを最大限に活用するためには、小さなバッファよりも大きなバッファでの作業を優先すべきです。階層的な反復のアイデアに基づいてデータを作成しないでください。データを再配置してリニアにアクセスできるようにしたり、処理のためにデータのコピーを作成する必要がある場合は、そうしてください。データを複製して、より速くアクセスできるようにすることを「 denormalization (非正規化)」といいます(その代わり、書き込み操作が遅くなったり、複雑になったりします) 。 ECSでの非正規化の例としては、Innogamesのブログ記事「 Entity Relationships in Unity DOTS/ECS 」をご覧ください。エンティティデータセットを状態で分岐させるのではなく、状態でソートやフィルタリングを行う。 つまり、条件付きテストを可能な限りループの外に置くことです。

デフォルトでは、ネイティブコンテナはネストを許可しません。例えば、2D または 3D の NativeArray を作成することはできません。同様に、Entities.ForEach()を別のEntities.ForEach()のラムダの中に入れ子にすることもできません。これは、ジョブセーフティーシステムが、メインスレッドからしかジョブを起動できないようにすることで、安全性を保証しているためです。したがって、アプリケーションで世界をグリッドセルやボクセルに分割する必要がある場合、x、y、z軸に沿ってボクセルの配列を入れ子にしたり、各軸を順に反復するループを入れ子にしたりしてはいけません。代わりに、セルデータを線形配列に格納し、インデックスとグリッド境界からセルの(x, y, z)座標を計算します。

2.Set DynamicBuffer capacity(DynamicBufferの容量設定)

Dynamic buffer components は、エンティティに配列のような機能を追加する便利な方法です。その名の通り、これらのバッファは動的にサイズを変更することができ、例えば Add() メソッドを使用してバッファに新しいアイテムを追加することができます。ただし、C# の List と同様に、DynamicBuffer はコンポーネントの内部配列を格納しており、バッファ内のアイテム数がバッファの容量に達した場合は、新しいより大きな配列を再割り当てしてスペースを確保する必要があります。

DynamicBufferが初期容量内であれば、あたかもエンティティが内部に配列を持つコンポーネントを含んでいるかのように、チャンクにインラインで格納されます。しかし、DynamicBufferが容量を超えると、ECSはチャンクの外にメモリを割り当て、DynamicBuffer全体を新たに割り当てられたメモリに移動させます。そのため、DynamicBufferの容量が12個の要素で、13個目の要素が追加された場合、様々な問題が発生します。

  • 新しい要素を追加すると、ECSは新しいバッファストレージを割り当て、既存のバッファ要素を新しいメモリ位置にコピーします。これには時間がかかります。

  • 今後、DynamicBufferにアクセスしようとすると、バッファデータがエンティティチャンクのインラインに存在しなくなるため、キャッシュミスが発生します。

  • この状況は、チャンクの断片化につながります。DynamicBufferが初期容量を超えて移動すると、チャンクには常に12要素の空のスペースがあり、コードはもはやアクセスしませんが、DynamicBufferが存在する限り存続します。

Setting internal buffer capacity(内部バッファ容量の設定)

すべての DynamicBuffer のデフォルトの容量は、 TypeManager.DefaultBufferCapacityNumerator を使用して計算されます。これはデフォルトでは 128 バイト、つまり (例えば) 32 個の整数です。DynamicBufferに含まれる要素の数が事前に分かっている場合は、バッファを宣言する際にInternalBufferCapacity属性を使用して宣言する必要があります。バッファがこの初期容量を超えて成長しない限り、再割り当ての必要はありません。

// My buffer can contain up to 42 elements inline in the chunk
// If I add any more then ECS will reallocate the buffer onto a heap  
[InternalBufferCapacity(42)]  
public struct MyBufferElement : IBufferElementData  
{  
    public int Value;  
}

Setting buffer capacity dynamically(バッファ容量を動的に設定する)

DynamicBuffer が必要とする容量をコンパイル時に合理的に予測することができない、または実用的でない場合があります。このような状況は、アイテムを 1 つずつ DynamicBuffer に追加するときに問題になることがあります。デフォルトでは、バッファは毎回 1 つの要素で成長するため、容量を増加させる Add() のたびに割り当てが発生することになります。

このような状況を避けるために、容量を動的に制御することができます。 容量を動的に制御して、このような状況を避けることができます。 DynamicBuffer.EnsureCapacity()を使用すると、新しい要素が追加されるたびに再割り当てしなくても、指定された容量を収容するのに十分な大きさのメモリ領域に、バッファを強制的に再割り当てすることができます。不要になった容量パディングのためにダイナミック・バッファが過剰なメモリを占有してしまった場合は、 DynamicBuffer.TrimExcess() を呼び出してサイズに合わせて縮小することができます。

3.Understand chunks(チャンクの理解)

Entitiesパッケージのマニュアルページの「 Components 」では、メモリ内のコンポーネントの構造と構成について説明しています。ここでは便利な復習をします。

  • すべてのEntities.ForEach()またはJobChunkはEntityQueryを使用して、どのエンティティとコンポーネントがデータ変換に関与しているかをフィルタリングします。

  • EntityQueryには、 EntityArchetypesのリストが含まれます。これらは、クエリにマッチするエンティティのグループを記述します。

  • EntityArchetypeには、 ArchetypeChunks(一般に「チャンク」と呼ばれる)のリストが含まれます。チャンクは、アンマネージドメモリの16KBのバッファです。

  • チャンクには、特定のアーキタイプにマッチする多数のエンティティのコンポーネントが含まれています。

エンティティは、特定のチャンクを指すEntityManager内の構造体へのインデックスであり、エンティティのコンポーネントデータが存在するチャンク内の特定のインデックスです。
また、Innogamesブログの記事「 Unity’s “Performance by Default” under the hood 」にも良い説明があります。

Chunk fragmentation(チャンクフラグメンテーション)

コンポーネントを16KBのチャンクに格納することで発生する問題に、チャンクの断片化があります。チャンクフラグメンテーションとは、簡単に言えば、アーキタイプチャンクが効率的に使用されていないということです。

ECSはチャンク内のコンポーネントが連続して格納されることを保証していますが、チャンクが一杯になっていない場合はメモリを浪費することになります。また、システムが次のエンティティを処理するために、あるチャンクから別のチャンクにジャンプしなければならないたびに、キャッシュミスが発生します。

極端な例を挙げると、10万個のエンティティがあり、それぞれが独自のアーキタイプを持っている場合、それぞれのコンポーネントデータは別のチャンクに保存されます。つまり、ECSは1.5GB以上のチャンクデータを割り当てますが、そのほとんどは空です。つまり、ECSは1.5GB以上のチャンクデータを割り当てますが、そのほとんどは空です。これは、一部のプラットフォームでメモリ不足によるクラッシュを発生させるのに十分すぎるほどです。さらに、アプリケーションがメモリ不足に陥らなかったとしても、多数のエンティティを操作するすべてのジョブは、それぞれのエンティティ間でキャッシュミスが発生します。

Shared components and chunk fragmentation(共有コンポーネントとチャンクフラグメンテーション)

チャンクの断片化の一般的な原因は、共有コンポーネント)の不適切な使用です。共有コンポーネントを使用すると、多数のエンティティを、頻繁には変更されない比較的少数のサブグループにまとめることができます。これにより、各エンティティのコンポーネントデータのコピーを保存するためのメモリ容量の増加がなくなり、特定の種類のデータ処理をより効率的に行うことができます。例えば、DOTS Hybrid Rendererの RenderMesh コンポーネントでは、レンダリング可能なエンティティを、参照するメッシュアセットに応じてグループ化しています。これにより、ハイブリッドレンダラーは、エンティティーのチャンク全体を1つのGPUインスタンスのドローコールを通して効率的にバッチ処理することができます。しかし、共有コンポーネントの値のセットはすべて異なるアーキタイプを表しているため、共有コンポーネントのデータに多くの共有可能な値がある場合、結果としてチャンクの断片化が発生します(例えば、Hybrid-renderableエンティティがそれぞれ異なるユニークなメッシュを参照する場合など)。

原則として、共有コンポーネントを使用するのは、以下の記述に当てはまる場合のみに限られます。

  • システムが個々のサブグループで動作することが有用である

  • サブグループの数が比較的少ない。

  • 標準コンポーネントではなく共有コンポーネントを使用することで節約できるメモリは、より多くのアーキタイプを作成することで失われるメモリよりも大きい。

Prefabs and chunk fragmentation(プレハブとチャンクフラグメンテーション)

チャンクの断片化のもう一つの原因はプレハブです。プレハブエンティティをインスタンス化することで、実行時に新しいエンティティを動的に作成することができますが、プレハブ自体は、プレハブからインスタンス化されたエンティティとは異なります。プレハブエンティティにはプレハブコンポーネントがあり、EntityQueriesが暗黙のうちにプレハブを無視する原因となっています。EntityManagerは、インスタンス化の際に新しいコピーからこのコンポーネントを取り除きます。つまり、ECSシステムは、インスタンス化されたエンティティを操作しますが、元のプレハブはそのままにしておきます。つまり、プレハブはインスタンス化するエンティティとは異なるアーキタイプを持ち、異なるアーキタイプを持つ複数のプレハブをロードすると、各プレハブが16KBのチャンクを占有します。

一般的な原因としては、異なるメッシュを参照するRenderMeshコンポーネントを持つプレハブが考えられます。このようなプレハブのメモリオーバーヘッドはあっという間に大きくなりますので、(例えば)プロシージャル生成のゲームで多くの種類のプレハブを使用している場合は、一度にメモリにロードされるプレハブの数を把握し、理解し、管理できるようにしてください。

Heavy entities and chunk fragmentation(ヘビーエンティティとチャンクフラグメンテーション)

1つのチャンクに収まるエンティティの数は、アーキタイプによって異なる場合があります。これは、チャンクは常に16Kですが、各エンティティのアーキタイプは、異なる量のデータを含む異なるコンポーネントのセットで表されるためです。ヘビーエンティティとは、多くのコンポーネントを持つもの、または多くのデータを含むコンポーネントを持つものです。このようなエンティティは、1つのチャンクに収まるものは多くありません。そのため、これらのエンティティの大規模なセットを繰り返し処理すると、アプリケーションが遭遇するチャンク境界の数が多くなり、それに伴ってキャッシュミスが発生します。

OOPとは異なり、ECSにはオブジェクトやアイテムが存在しないことを覚えておきましょう。(クラスは通常、シミュレーションの世界で特定のタイプのオブジェクトやアイテムを表します)。あるのは、様々な方法でグループ化されたコンポーネントだけです。EntityをOOPオブジェクトに類似したものと考えるのは便利ですが、実際にはEntityは、ある特定のコンポーネントの集まりにアクセスするためのデータ構造へのインデックスに過ぎません。つまり、(例えば)ゲーム内のキャラクターを単一のエンティティとして表現しなければならない特別な理由はないということです。

つまり、ヘビーエンティティの問題を解決するには、単純に、より多くの軽いエンティティに分解して、より効率的にチャンクに詰め込むことができるということです。例えば、キャラクターのコンポーネントを、どのSystemGroupが処理するかによって、異なるエンティティに分けたとします(例えば、AI、経路探索、物理、アニメーション、レンダリングなど)。それらのSystemGroupは、必要なデータのみを含むコンポーネントで満たされたチャンクを繰り返し処理することができます( 例えば、物理シミュレーションがアクセスするチャンクには、AIのステートマシンのデータが入っていないので、物理コンポーネントを増やす余地があります)。

Using the Entity Debugger(エンティティデバッガーの使用)

Entity Debuggerの使用特定のアーキタイプのチャンク使用率を理解するために、Entity Debuggerの読み方を知っておくと便利です。Entity Debuggerのパッケージドキュメントはまだ開発中ですが、UnityのEntityComponentSystemSamplesのGitHubリポジトリの古いリビジョンに追加のドキュメントがあります。

4.Not everything has to be an Entity(すべてがEntityである必要はない)

前述のように、エンティティの目的は、特定のコンポーネントグループを識別する方法を提供し、他の場所で参照できるようにすることです。作成したエンティティの多くは、結果として生じるEntityハンドルを保持する必要はありません。あなたのシステムは、単にコンポーネントを操作して、あなたが望む動作を与えるだけです。

実際、ECSとEntitiesパッケージはDOTSの完全なオプション部分です。EECSのEntityManagerは、本質的には、チャンクと呼ばれるメモリページ内のNativeContainerにBlittableデータ(コンポーネントの形で)をパックするのに役立つ、特殊なメモリマネージャです。EntityQueriesは、操作するチャンクを選択するための効率的な方法です。システムは、データを処理するジョブをスケジューリングされた並列方法で起動するための手段です。コンポーネントに整理されていないBlittableデータのNativeContainerを使用するようにコードベースの一部を構成し、SystemBaseを継承していないクラスからデータを操作するBurstコンパイルされたジョブをスケジュールすることは十分可能です。

構築したいシミュレーションの一部が、Blittableデータをきれいに詰め込んだ連続したバッファに適しているかもしれませんが、そのようなデータ構造は必ずしもエンティティやコンポーネントになるのに適しているとは限りません。Minecraftスタイルのボクセルゲームを作る場合、すべてのボクセルはエンティティになるべきでしょうか?おそらくそうではないでしょう。もしそうしたら、各ボクセルのEntityハンドルのサイズが、ボクセルデータそのものよりも多くのメモリを占めることになるかもしれません。 ECSを使用しない方が良いデータ構造の場合は、恐れずにECSの外に出てみてください。

Part 3.4: Getting the most out of Burst(バーストを使いこなす)

概要

DOTS Best Practicesガイドのこのセクションでは、以下のことを行います。

  • Unity.Mathematicsライブラリ、Burstエイリアシングヒント、SIMD最適化を効率的に使用して、Burstコンパイラが高速なコードを生成できるようにする方法

1.Use Unity.Mathematics(Unity.Mathematicsを使う)

DOTSコードで実行したい数学演算には、従来の Mathf APIではなく、 Unity.Mathematics パッケージを使用します。数学の型も同様で、 Vector3 ではなくを、 float3 Quaternion ではなくquaternion を、 Matrix4x4 ではなく float4x4 を使います。

なぜか?それは、Unity.Mathematicsのデータタイプが、Burstが実装するSIMD最適化の基礎となっているからです。 Unity.Mathematicsのデータ型は、Burstが実装するSIMD最適化の基礎となります。ジョブ化されていない、Burstでコンパイルされていないコードでは、2つの数学ライブラリの間にそれほど大きなパフォーマンスの差はないかもしれませんが、Burstが関与するようになると、Unity.Mathematicsと新しいデータタイプを含むコードは、古いOOPのUnityEngine.Mathfに比べてかなり高速になります。

Understand operators(演算子の理解)

Unity.Mathematics 型で定義されている算術演算子の多くは、OOP UnityEngine 型の演算子とは同じようには動作しないことに注意する必要があります。float3 や float4x4 などの SIMD 型では、ほとんどすべての算術演算子がコンポーネント単位で適用されますが、これは古い UnityEngine 型では必ずしもそうではありません。

このことは、特に行列型を扱うときに覚えておく必要があります。 Matrix4x4.operator * を使用して 2 つの Matrix4x4 を乗算すると、結果は標準的な行列の乗算となり、結果の行列の各要素は行と列のドット積となります。しかし, float4x4.operator *は成分和演算なので,異なる結果が得られます。標準的な行列の乗算を行う場合は、代わりに math.mul() を使用する必要があります。

Embed Random generators in components(コンポーネントへのランダムジェネレータの組み込み)

Unity.Mathematics にはRandom 構造体が含まれており、これを使用して疑似乱数を効率的に生成することができます。Random はSeedで初期化する必要があり、そのSeedはstate と呼ばれる UInt32 フィールドに格納されます。コードが Next() メソッドのいずれかを呼び出すたびに、Random は入力として state を使用していくつかの数学的演算を実行し、新しい乱数を生成します。このメソッドは、新しく生成された乱数を state に保存し、次に別の乱数が必要になったときに、異なる入力を確保します。

この乱数生成方法は非常に高速ですが、マルチスレッドのコードに組み込む場合には注意が必要です。メインスレッドにRandomのインスタンスを1つ作成し、乱数生成を必要とするジョブに渡したくなるかもしれません。しかし、これは避けるべきです。すべてのジョブインスタンスが必要とするデータは、安全なマルチスレッドコードを可能にするために、コピーされています。つまり、すべてのスレッドがオリジナルのメインスレッドRandom構造体のコピーを持ち、すべてのスレッドに同じ値(オリジナルの構造体のstateの値のコピー)がシードされます。これはとてもランダムとは言えません。さらに悪いことに、stateの値はどのジョブインスタンスからもメインスレッドインスタンスに反映されないため、メインスレッドでも乱数が要求されない限り、ジョブは常に同じ「乱数」を毎フレーム生成することになります。

これを回避する方法はいくつかあります。例えば、 CreateFromIndex() で初期化されたRandomインスタンスの配列を作成し、ジョブのスレッドやチャンクのインデックスを使って、その中から使用するものを選択することができます。しかし、良質な乱数を生成する最も簡単な方法は、単純にEntityごとにRandomのインスタンスを1つ用意し、コンポーネントに格納することです。ジョブはこのインスタンスを使用して、特定のエンティティのデータ変換に必要な任意の乱数を生成することができ、その状態は一意であり、そのコンポーネントが存在する限り持続します。

2.Use Burst aliasing hints(バーストエイリアシングヒントを使う)

エイリアシングとは、メモリ上の同じ場所を指す2つの参照やポインタの内容をコードが操作している状況を表す言葉です。簡単な例を挙げてみましょう。

int Foo(ref int a, ref int b)  
{  
    b = 13;  
    a = 42;  
    return b;  
}  

このメソッドは何を返しますか?a と b が両方ともメモリの異なる領域を参照している場合、b が参照している場所には 13 が含まれています。しかし、両方の参照先が同じメモリ領域を指している場合、その場所には最後に設定した値である42が含まれます。aとbが同じメモリを参照している場合、それらはお互いにエイリアスと呼ばれます。

コンパイラはコンパイル時にこれらの2つの参照がエイリアスされているかどうかを知りませんので、最適ではないがあらゆる状況で正しい結果を保証するアセンブリを作成しなければなりません。

mov     dword ptr [rdx], 13    // Store 13 into b  
mov     dword ptr [rcx], 42    // Store 42 into a  
mov     eax, dword ptr [rdx]   // Load the contents of b back into the register
ret                            // Return the contents of the register

このコードがBurstでコンパイルされたジョブの中で発生し、aとbが絶対にエイリアスにならないことがわかっている場合、NoAlias属性を使ってコンパイラに伝えることができ、コンパイラはより効率的なアセンブリを生成することができます。この例では、bの内容をレジスタにロードし直す必要がなくなり、代わりに13を返すだけで済みます。

Unityのブログポスト「 Enhanced Aliasing With Burst 」では、さまざまな状況でポインタや参照がエイリアスされない場合に、[NoAlias]を使ってBurstに伝えるさまざまな方法を紹介しています。

  • メソッドのパラメータ

  • メソッドの戻り値

  • 構造体

  • 構造体フィールド

  • ジョブ

また、 Unity.Burst.CompilerServices.Aliasing を使って、エイリアシングに関する仮定が正しいかどうかをコンパイル時にチェックする組込み関数をコードに追加する方法も紹介されています。

3.SIMD optimization(SIMD最適化)

1つの命令:刻む。複数のデータ:棒状のセロリ。時間をかけてデータを前処理すれば、少量の命令で多くの作業を行うことができます。

DODを理解し、データと変換を計画し、実装における一般的なパフォーマンスの落とし穴を避けながら、このベストプラクティスガイドの他のすべての項目に従ってきましたが、もう少しパフォーマンスを引き出す必要があるジョブが1つまたは2つ残っています。そのジョブは、メモリアクセスの面では十分に効率的ですが、大規模なデータセットに対して多くの数学的操作を行っている場合があります(カスタムカリングや、プロシージャルコンテンツ、ビジュアルエフェクト、カスタム物理シミュレーションのために大量のピクセルや頂点データをCPUで操作する場合など)。このような場合、SIMDを検討する必要があるかもしれません。

SIMDとはSingle Instruction, Multiple Data(単一命令、複数データ)の略で、Unity.Mathematicsの用語では、CPUにとって、float4の数学的演算を行うことが、1つのfloatに同じ演算を行うのと同じくらい速いという事実を利用する方法です。

Burstは多くの場合、コードを自動的にベクトル化することに長けているので、まず最初にすべきことは、 Burst Inspector で、ジョブに対して生成されたアセンブリを見ることです。アセンブリが読めると便利ですが、読めなくても、SIMD命令とスカラ命令を数えて、ジョブの複雑さを知ることができるはずです。x86アセンブリーでは、xxxpsという形式の命令(addps、mulpsなど)がたくさんあり、それらが互いに影響しあっている場合がありますが、これはベクトル化されたSIMD命令であり、良いことです。
たくさんのxxxss(例えば、adds、mulss)が見られる場合、それらはスカラー命令であり、それは良くありません。2つの命令タイプが混在している場合は、SIMDとスカラのコードが混在していることになり、これもよくありません。SIMD命令とスカラ命令の数を数えてみてください。あなたの目的は、Burstコンパイラができるだけ多くのスカラ命令を削除できるようにコードをリファクタリングすることです。

Unite Copenhagen 2019のプレゼンテーション「 Intrinsics: Low-level engine development with Burst 」は、コードの一部をSIMDに変換するための優れた入門であり、そうすることで期待できるメリットも紹介しています。ビデオの最後の方では、Andreas氏がBurstでSIMDに適したコードを書くための一般的なベストプラクティスのアドバイスを挙げています。

  • **Burst inspectorを使いこなす。**アセンブリの専門家でなくても、アセンブリを見ることで、自分のコードがどの程度最適なのかを知ることができますし、変更を始めたときの比較基準にもなります。

  • **分岐をなくす。**CPUは、条件付きコードを分岐させるよりも、直線的な命令群を実行する方が速いのです。

  • **入力データのバッチは広い方が望ましい。**DODは個々のものを処理するのではなく、多くのもののバッファーを処理することが重要です。

  • **Unity.Mathematicsを縦に使う。**3つのfloat4(xの値に1つ、yの値に1つ、zの値に1つ)を使うことが出来るのに、(x, y, z)ベクトルの表現に float3 を使用しないでください。SIMDを使えば1つのベクトルを処理するのと同じコード量で4つのベクトルを処理できます。

SIMDについては、Andreas Fredriksson氏の2015年GDCプレゼンテーション「 SIMD at Insomniac Games: How We Do the Shuffle .」をご覧ください。

4.SIMD optimization example: simple frustum culling(SIMD最適化の例:単純な視錐台カリング)

架空のゲーム「Beach Ball Simulator: Special Edition」でのSIMD最適化の例をご紹介します。

ボーナスラウンドで、10万個のビーチボールが放出されると、レンダリング性能が問題となり、チームはビーチボールの境界球で視錐台カリングを行うカスタムシステムを実装することにしました。 各ボールには,位置をfloat3で定義するTranslationコンポーネントと,境界球のサイズを定義するSphereRadiusコンポーネントがあります。SphereVisibleコンポーネントは、カリングシステムが球体の一部がカメラ視錐台の平面内にあるかどうかに応じて設定する必要があります。

public struct SphereRadius : IComponentData  
{  
    public float Value;  
}  
  
public struct SphereVisible : IComponentData  
{  
    public bool Value;  
}  

まず、カリングシステムは、6つのカメラプレーンを定義するデータをfloat4にパックします。このとき、(x,y,z)成分はプレーンの法線ベクトル、w成分はプレーンの原点からの距離です。

public class FrustumCullSystem : SystemBase  
{  
    private Camera _camera;  
    private Plane[] _planesOOP;  
    protected NativeArray<float4> _planes; // xyz = plane normal, w = distance  
  
    protected override void OnCreate()  
    {  
        base.OnCreate();  
        _planesOOP = new Plane[6];  
        _planes = new NativeArray<float4>(6, Allocator.Persistent);  
    }  
  
    protected override void OnDestroy()  
    {  
        base.OnDestroy();  
        _planes.Dispose();  
    }  
  
    protected override void OnUpdate()  
    {  
        UpdateFrustumPlanes();  
    }  
  
    private void UpdateFrustumPlanes()  
    {  
        if (_camera == null)  
            _camera = Camera.main;  
          
        GeometryUtility.CalculateFrustumPlanes(_camera, _planesOOP);  
          
        for (int i = 0; i < 6; ++i)  
        {  
            _planes[i] = new float4(_planesOOP[i].normal, _planesOOP[i].distance);  
        }  
    }  
} 

球体がカメラの視錐台平面内にあるかどうかをテストするには,平面の法線と球体の中心との間でドット積を行い,平面の距離と球体の半径を加えて,その結果がゼロより大きいかどうかを確認します.ここでは、それを実行する素朴な実装を紹介します。すべての球体を反復し、すべての平面をループして1つずつテストを実行します。

protected override void OnUpdate()  
{      
    UpdateFrustumPlanes();  
    var planes = _planes;  
      
    Entities.WithReadOnly(planes).ForEach((
        ref SphereVisible visibility, in Translation translation, in SphereRadius radius) =>  
    {  
        bool visible = true;  
        for (int planeID = 0; planeID < 6; ++planeID)  
        {  
            if (math.dot(planes[planeID].xyz, translation.Value) +  
                planes[planeID].w + radius.Value <= 0)  
            {  
                visible = false;  
                break;  
            }  
        }  
      
        visibility.Value = visible;
    }).ScheduleParallel();  
} 

一見、効率の良い仕事のように見えますが、ここにはパフォーマンスを向上させる余地があります。まず、平面を繰り返し処理するforループがあります。具体的には、球体が視錐台平面の外側にある場合にループを終了するbreakステートメントがあります。これはアセンブリコードに分岐を発生させますが、これは避けるべきことです。

以下は、平面ループと分岐を削除したバージョンです。

protected override void OnUpdate()  
{  
    UpdateFrustumPlanes();  
    var planes = _planes;  
      
    Entities.WithReadOnly(planes).ForEach((ref SphereVisible visibility, in Translation translation, in SphereRadius radius) =>  
    {  
        visibility.Value =  
            (math.dot(planes[0].xyz, translation.Value) + planes[0].w + radius.Value > 0) &&  
            (math.dot(planes[1].xyz, translation.Value) + planes[1].w + radius.Value > 0) &&  
            (math.dot(planes[2].xyz, translation.Value) + planes[2].w + radius.Value > 0) &&  
            (math.dot(planes[3].xyz, translation.Value) + planes[3].w + radius.Value > 0) &&  
            (math.dot(planes[4].xyz, translation.Value) + planes[4].w + radius.Value > 0) &&  
            (math.dot(planes[5].xyz, translation.Value) + planes[5].w + radius.Value > 0);            
    }).ScheduleParallel();  
}

このジョブは実際の計算をより多く行いますが、ブランチを削除したことによるパフォーマンスの向上により、このバージョンは最初のバージョンよりも実際に速く実行されます。しかし、データの垂直性についてはどうでしょうか?このジョブは1つのステートメントですべての視錐台プレーンをチェックしますが、チェックはすべてスカラーです。もし平面データの詰め方が違っていたら、SIMD命令は1つの平面をチェックするのと同じ数の数学演算で、4つの平面に対して球体をチェックすることができます。

ハイブリッドレンダラーには、このような視錐台プレーンのデータ表現が PlanePacket4 という形ですでに含まれており、データを正しくパックするための FrustumPlanes.BuildSOAPlanePackets() というメソッドもあります。これにより、視錐台チェックをかなり短縮することができます。

protected override void OnUpdate()  
{  
    if (_camera == null)  
        _camera = Camera.main;  
      
    GeometryUtility.CalculateFrustumPlanes(_camera, _planesOOP);  
    _nativePlanes.CopyFrom(_planesOOP);  
    var planePackets = FrustumPlanes.BuildSOAPlanePackets(_nativePlanes, Allocator.TempJob);  
  
    Entities.WithReadOnly(planePackets).ForEach((  
        ref SphereVisible visibility, in Translation translation, in SphereRadius radius) =>  
    {  
        var t = translation.Value;  
        var p0 = planePackets[0];  
        var p1 = planePackets[1];  
          
        bool4 masks =
            (p0.Xs * t.x + p0.Ys * t.y + p0.Zs * t.z + p0.Distances + radius.Value <= 0) |  
            (p1.Xs * t.x + p1.Ys * t.y + p1.Zs * t.z + p1.Distances + radius.Value <= 0);  
  
        visibility.Value = masks.Equals(new bool4(false));  
  
    }).WithDisposeOnCompletion(planePackets).ScheduleParallel();  
}  

これは、単一の位置ベクトル(t.x, t.y, t.z)と4つの平面法線ベクトル(例えば、(p0.Xs, p0.Ys, p0.Zs))との間の内積計算を同時に実行することになるため、math.dot()メソッドを明示的な乗算と加算の演算に置き換えます。このソリューションでは、球体を2つのPlanePacket4と照合する際に、6つの平面と照合するのに必要な数学的演算の33%で照合することができます。

さらに最適化することも可能です。6(平面の数)は4(SIMDレジスタで可能な同時演算の数)にうまく分割できないため、2番目の平面パケットには、2つの有用な平面と、常に肯定的な結果をもたらす2つのダミーの平面しか含まれていません。これでは処理能力が無駄になってしまいます。

そこで、より広い入力データのバッチを好むというコンセプトを採用し、視錐台平面をSIMDに適した4つのパケットにパックする代わりに、球体をパックするとしたらどうでしょうか?これは確かに可能ですし、このようなアプローチは、意味のあるデータをできるだけ多くの演算に配置するため、CPUをより有効に活用することができます。しかし、4つのTranslationコンポーネントとSphereRadiusコンポーネントのセットを、cullテストの前に垂直方向のSIMDフォーマットにパックし、その後にbool4の結果をSphereVisibilityコンポーネントにアンパックするために、コードはいくつかの余分な作業をしなければならない。実際、この変換を高速化するために、以下のコードでは、SphereVisibilityにはboolではなくintを含めるように規定しています。

public class FrustumCullSystem : SystemBase  
{  
    private EntityQuery _query;  
      
    protected override void OnCreate()  
    {  
        base.OnCreate();  
        _query = GetEntityQuery(  
            ComponentType.ReadOnly<Translation>(),  
            ComponentType.ReadOnly<SphereRadius>(),  
            ComponentType.ReadWrite<SphereVisible>());   
    }  
      
    protected override void OnUpdate()  
    {  
        UpdateFrustumPlanes();  
  
        Dependency = new SIMDCullingJob  
        {  
            TranslationTypeHandle = GetComponentTypeHandle<Translation>(true),  
            RadiusTypeHandle = GetComponentTypeHandle<SphereRadius>(true),  
            Planes = _planes,  
            VisibilityTypeHandle = GetComponentTypeHandle<SphereVisible>(),  
        }.Schedule(_query, Dependency);  
    }  
      
    [BurstCompile]  
    struct SIMDCullingJob : IJobChunk  
    {  
        [ReadOnly] public ComponentTypeHandle<Translation> TranslationTypeHandle;  
        [ReadOnly] public ComponentTypeHandle<SphereRadius> RadiusTypeHandle;  
        [ReadOnly] public NativeArray<float4> Planes;  
          
        public ComponentTypeHandle<SphereVisible> VisibilityTypeHandle;  
          
        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)  
        {  
            // Get arrays of the components in this chunk that we're interested in. 
            // Reinterpret the data as floats to make it easier to manipulate for packing  
            var chunkTranslations = 
                chunk.GetNativeArray(TranslationTypeHandle).Reinterpret<float>(12);  
            var chunkRadii = chunk.GetNativeArray(RadiusTypeHandle).Reinterpret<float>();  
            var chunkVis = chunk.GetNativeArray(VisibilityTypeHandle);  
              
            var p0 = Planes[0];  
            var p1 = Planes[1];  
            var p2 = Planes[2];  
            var p3 = Planes[3];  
            var p4 = Planes[4];  
            var p5 = Planes[5];  
  
            // Step through the sphere entities in the chunk in strides of 4  
            for (var i = 0; chunk.Count - i >= 4; i += 4) 
            {  
                // Load 4 float3 positions into 3 float4s
                // then "shuffle" them into vertical Xs, Ys and Zs  
                var floatIdx = 3 * i;  
                var a = chunkTranslations.ReinterpretLoad<float4>(floatIdx);    // x0 y0 z0 x1  
                var b = chunkTranslations.ReinterpretLoad<float4>(floatIdx + 4);// y1 z1 x2 y2  
                var c = chunkTranslations.ReinterpretLoad<float4>(floatIdx + 8);// z2 x3 y3 z3  
                var Xs = new float4(a.x, a.w, b.z, c.y);  
                var Ys = new float4(a.y, b.x, b.w, c.z);  
                var Zs = new float4(a.z, b.y, c.x, c.w);  
                  
                // Grab 4 radii in a single float4  
                var Radii = chunkRadii.ReinterpretLoad<float4>(i);  
                  
                bool4 mask =  
                    p0.x * Xs + p0.y * Ys + p0.z * Zs + p0.w + Radii > 0.0f &  
                    p1.x * Xs + p1.y * Ys + p1.z * Zs + p1.w + Radii > 0.0f &  
                    p2.x * Xs + p2.y * Ys + p2.z * Zs + p2.w + Radii > 0.0f &  
                    p3.x * Xs + p3.y * Ys + p3.z * Zs + p3.w + Radii > 0.0f &  
                    p4.x * Xs + p4.y * Ys + p4.z * Zs + p4.w + Radii > 0.0f &  
                    p5.x * Xs + p5.y * Ys + p5.z * Zs + p5.w + Radii > 0.0f;  
  
                // This is much faster than unpacking the bool4 into four bools.  
                chunkVis.ReinterpretStore(i, new int4(mask));  
            }  
  
            // In case the number of entities in this chunk isn't neatly divisible by 4,  
            // cull the last few spheres individually  
            for (var i = (chunk.Count >> 2) << 2; i < chunk.Count; ++i)  
            {  
                var translation = chunkTranslations.ReinterpretLoad<float3>(3*i);  
                var radius = chunkRadii[i];  
                  
                chunkVis[i] = new SphereVisible  
                {  
                    Value =   
                    (math.dot(p0.xyz, translation) + p0.w + radius > 0 &&  
                    math.dot(p1.xyz, translation) + p1.w + radius > 0 &&  
                    math.dot(p2.xyz, translation) + p2.w + radius > 0 &&  
                    math.dot(p3.xyz, translation) + p3.w + radius > 0 &&  
                    math.dot(p4.xyz, translation) + p4.w + radius > 0 &&  
                    math.dot(p5.xyz, translation) + p5.w + radius > 0) ? 1 : 0  
                };  
            }  
        }  
    }  
} 

このような最適化に取り組む際には、各変更の前後でプロファイリングを行い、変更内容が期待通りに機能していることを確認する必要があります。しかし、ドラマやサスペンスの観点から、この実験の結果は最後まで残しておきました。

SIMD数学の最適化の進捗状況を確認するには、Burst Inspectorを使ってアセンブル済みのコードを調べるのが効果的です。簡潔にするため、このガイドではBurstコードを省略していますが、下の表は、10万個の球体があるシーンで実行される数学的演算(乗算、加算、ビットごとの論理的なANDとOR)の数を手動で数えて得られた、各バージョンの複雑さの概算を示しています。ここでは、足し算、掛け算、スカラ、SIMDにかかわらず、すべての数学的演算が同じパフォーマンスに影響すると仮定しています。

下の表は、2016年モデルのMacBook Pro(2.9GHzクアッドコアのIntel Core i7)で記録したCPU時間の中央値と、これらの演算数を並べたものです。この表には、各システムにおいてすべてのワーカースレッドでメインジョブに費やされた時間の合計と、メインスレッドで測定された実際の「ウォールクロック」時間の両方が含まれています。一貫性を持たせるために、これらの各システムは最後にDependency.Complete()を含め、ジョブを直ちに終了させ、CPUコストがシステム自体の中で報告されるようにしました。

(https://learn.unity.com/tutorial/part-3-4-getting-the-most-out-of-burst より引用)

注目すべき結果です。

  • 一般的に、アルゴリズムのバージョンに含まれる数学的演算の数を数えることは、そのパフォーマンスを予測するのに適しています。

  • バージョン2は、バージョン1に比べて、少なくとも50万回以上の数学的演算を処理しているにもかかわらず、高速です。これは、break文を削除したことによるものと思われます。V1とV2の性能差がより顕著にならない主な理由は、V1でforループをアンロールする方法をBurstがすでに見つけ出していたからです。より複雑なジョブでは、これは保証できません。

  • バージョン4は、カリングジョブのバージョンの中では、全体的に実行するオペレーションの数が最も少ないため、最も高速なバージョンですが、V1やV2の4倍の速度ではありません。これは、コンポーネントからのデータをフレームごとにパックされたSIMDフォーマットにシャッフルするための余分な作業があるためです。もし、球体の位置と半径のデータをSIMDデータとして保存し、変換を完全に避けることができれば、V4はかなり速くなるでしょう。

訳者注
注1 プログラミングにおける考え方、やり方。例えば、関数型プログラミング 、オブジェクト指向プログラミングなどが挙げられる
注2 virtual、interfaceなど
注3 CPU がメインメモリからデータを読み出すときのキャッシュ上のデータの大きさの単位
注4 ハイパフォーマンスC#→ https://blogs.unity3d.com/2019/02/26/on-dots-c-c/

※Unity 及び関連の製品名はUnity Technologies、またはその子会社の商標です。


この記事が気に入ったらサポートをしてみませんか?