1.6K Views
July 13, 18
スライド概要
2018/7/13に開催されたGTMF2018TOKYOの講演資料です。
講師:山村 達彦(ユニティ・テクノロジーズ・ジャパン合同会社)
リアルタイム3Dコンテンツを制作・運用するための世界的にリードするプラットフォームである「Unity」の日本国内における販売、サポート、コミュニティ活動、研究開発、教育支援を行っています。ゲーム開発者からアーティスト、建築家、自動車デザイナー、映画製作者など、さまざまなクリエイターがUnityを使い想像力を発揮しています。
ハードウェアの性能を活かす為の、 Unityの新しい3つの機能 ver 1.1 山村 達彦 ユニティ・テクノロジーズ・ジャパ ン
Unityの目指しているもの ゲーム開発の民主化 難しい問題の解決 今回得に解決するもの 成功を支援
スクリプト、基礎から考え直す GameObject MonoBehaviour (簡単、様々な用途で使える。だが遅い) (パフォーマンスのために 難しい事をする必要がある) ECS C# Job System Burstコンパイラ (よりHWに寄り添う)
Performance By Default “ デフォルトで高性能なコードを ”
3つの機能 ・Entity Component System (ECS) ・C# Job System ・Burst Compiler 今回はコレの話
オブジェクトを制御する Entity Component System :存在 :システム :部品
コンポーネントは状態と処理を持つ GameObjectはコンポーネントのコンテナ GameObject Transform Component Data Data Data GameObject • ゲーム世界内での物体 • 複数のComponentを持つ Component • 単体で動作 • フィールドを持つ Logic • ロジックを持つ Logic • 他のComponentを操作する (Transform等)
GameObject / Componentの場合 Data Data Data ハンドルの入力処理 Data 曲がる動作の処理 Data 加速する動作 ハンドル タイヤ エンジン 動作はオブジェクトごとに独立 動作を決める為に他のオブジェクトへの参照が必要
Monobehaviourは他オブジェクトへの参照を持つ GameObject GameManager Transform n GameFlow Control Move Gun 参照関係が複雑になると セットアップがややこしくなる
GameObject / Componentの場合 ドルの入力処理 がる動作の処理 加速する動作 ハンドル タイヤ エンジン 神 依存関係が面倒な場合、Singletonが活躍 Super Special Awesome ultra Game Manager
“ それで、ECSではどうなってんの? ”
ECSではオブジェクト毎にロジックを持たせず、 ComponentSystemが一括制御する Component A Data Data Data Component A Data Data Data Component A Data Data 処理 Data Component A 処理 処理 ComponentSystem 毎フレーム呼ばれるイベントを持つ
ComponentSystemへのオブジェクト登録は自動 GameObject Transform Component Group Transform[] GameObject Transform Component ComponentSystem 全体から効率的に自動検索。 GameObject Transform Component GameObject Systemは誰がオブジェクトを 生成しているか知らなくても良い
取得するオブジェクト一覧は、 要求するComponentでフィルタリングする Group GameObject Transform[] Transform Move Move[] ComponentSystem GameObject Transform Moveが足りない Move GameObject Player Transform 必要なコンポーネントが 揃った状態で取得できる
Small Demo Object キャラクター操作する System 入力を受け付ける System Rigidbody スピード GameObjectEntity システムが他のシステムを 参照しつつ、オブジェクトを 操作する例
ComponentSystemを継承 Demo
Demo Systemが要求する Componentを登録
[Inject]でオブジェクトへの参照を注入 Demo
Demo OnUpdateにオブジェクトを操作する処理を記述
複数のシステムでEntityの動きを作る コントロール 入力 座標 入力 システム 移動 システム 当たり判定 システム 死亡判定 形状 耐久力 流れ作業のようにComponentDataを操作して、動きを作る
ECS上では、保有するComponentの組み合わせで動作が変わる タイヤ タイヤ タイヤ タイヤ 人力 人力 エンジン エンジン 荷物 荷物 人力で 移動 荷物を 運ぶ エンジンで 移動
Groupが要求できる組み合わせ Group 要求できるもの Entityに登録した を含む • Component 及び を含まない • ComponentData • SharedComponentData Entity / GameObject • … なにこれ ComponentSystem
Entityは超軽量なGameObject ComponentDataはロジックを持たないコンポーネント Entity Position Wheel Engine • ゲーム世界でのモノ • 複数のComponentDataを持つ ComponentData • Entityに含まれる • 値を持つ • 参照型を持たない • ロジックは持たない
EntityもGameObjectも役割は同じ「モノ」という概念 GameObject Transform Component Data Data Logic Logic Entity Position Wheel Engine
定義の違い MonoBehaviour public class Engine : MonoBehaviour { public float power; } ComponentData 構造体 IComponentDataを継承 public struct Engine : IComponentData { public float power; } 値のみ持つ 何もデータを持たない事もある
生成の違い MonoBehaviour var obj = new GameObject(“obj”, typeof(Engine)); ComponentData EntityManagerで生成 var entity_manager = World.Active.GetOrCreateManager<EntityManager>(); var entity = entity_manager.CreateEntity(typeof(Engine));
Systemが要求する時の違い MonoBehaviourを要求 struct Group{ GameObjectArray objs; ComponentArray<Monobehaviour> comps; } ComponentDataを要求 struct Group{ ComponentDataArrayや EntityArrayで取得 EntityArray entities; ComponentArray<ComponentData> comps; }
Q “ GameObject&Componentから EntityとComponentDataに変えて 何かメリットでもあるの? ” 先程のGameObjectとの組み合わせる場合はECSハイブリットと呼ぶ
“ A ECSが本気モードになる ” ハードのお勉強
メモリキャッシュ・CPUコア 1アクセスの間に 200サイクルも 処理ができる CPU CPU メモリのアクセス速度と CPUの性能は大きな差がある メモリからデータをロード するのは時間がかかる メモリ
メモリキャッシュ・CPUコア CPU 2〜3 サイクル 20〜30 サイクル CPU キャッシュを活用して L1 キャッシュ L1 キャッシュ L2キャッシュ ギャップを緩和 キャッシュから使えば 100倍早くなるかも メモリ
CPU キャッシュライン 一回でロードするのは64バイト 1byteしかデータを使用しない 64 byte L1 キャッシュ 場合でも64byte単位でロード 64 byte L2キャッシュ いかに無駄なくデータを 配置できるかがポイント 64 byte メモリ
「クラシック」の問題(1) GameObject 1 Data Rigidbody GameObject 3 Data Transform Data Data Renderer Data GameObject 2 Data Data Data Data Monobehavour Data Data Transform Data Transform Data Data Data Data Monobehavour Rigidbody データが散在していてキャッシュが使いにくい 必要なデータを集めるのに何度もロードが必要 Data Data
「クラシック」の問題(2) MoveScript 太字:必要なデータ 細字:不要なデータ Transform float Speed; Vector3 up; Transform transform Vector3 right; … Vector3 forward; Vector3 position; Vector3 localPosition; Quatrainion rotation; Quatrainion localRotation; … 余計なデータが たくさん含まれる 余計なデータで キャッシュがすぐ埋まる
ECSのメモリレイアウト(見た目) Entity 0 Entity 1 Entity 2 Entity 3 Positoin Positoin Positoin Positoin Rotation Rotation Rotation Rotation Speed Speed Speed Speed EntityはComponentDataを持つコンテナのように見えるが…
ECSのメモリレイアウト(実際に近いイメージ) 0 1 2 3 pos Array Positoin Positoin Positoin Positoin … rot Array Rotation Rotation Rotation Rotation … spd Array Speed Speed Speed Speed … Entity2 Entityは唯のID、実態は構造体の配列 ※実際にはもっと複雑で様々な最適化を含む
ECSのシステムがアクセスするデータ Pos Array Positoin Po Rot Array Rotation Ro Spd Array Speed 使用するデータのみ アクセスする 余計なデータで キャッシュがすぐ埋まらない Sp
プリフェッチ 連続するデータは先読み予測が効く POS キャッシュライン POS POS 読んだ POS POS POS POS 読んだ POS POS POS POS POS 多分次はココやろ 先に読んどいたる
メモリはNative Containerで確保 •アンマネージドなメモリを提供 = GCは発生しない •利便性を損なわず効率アップ •IL2CPPではインデクサが1命令 (get/setのオーバーヘッドなし) NativeArray NativeArraySOA •AOSやSOAのなどに対応
NativeArray 生成 var a = new NativeArray<MyStruct>(32, Allocator.Persistent); 解放 a.Dispose(); Allocator.Persistent 永続的に使用可能 Allocator.Temp 同じフレームで解放しないとエラー Allocator.TempJob 4フレーム以内に解放しないとエラー ジョブ終了時に自動開放させるオプション有
オブジェクトの増減が低コスト POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS POS 要素を削除したときは POS POS POS POS 最後の要素で塗り直すだけ POS ガベージコレクションは発生しない
ちょっと未来話 手続き的な手法を踏まずにオブジェクトをロード GameObject 1 Data Data Data Data Data Data Data Data Data Renderer Data Data Data Data Data Entity Data Entity Entity Entity Data Data File Data Data Data Data Entity Entity Entity Data Data Data Data Data Entity Entity レイアウトが確定しているので、 ロードしたデータをそのまま メモリに展開
ECSまとめ 新しいオブジェクトを制御する機構 コンポーネント志向に近い形のオブジェクト制御 メモリレイアウトがCPUに優しい データ志向のレイアウト キャッシュ、フェッチを活用してアクセスを効率化 ECSで組めばHWを知らなくてもよしなにしてくれる
安全・簡単にマルチコアを活用 “ C# Job System ”
最近のコアは増加傾向にある 1コア辺りのクロック数 コアの数 1989 2018 iOS/Android モバイルですら6〜8コア積んでる時代 ※イメージ図
一つのコアで複数の処理を実行 index[0] index[1] index[2] index[3] index[4] index[5] Work Work Work Work Work Work Core 0 Core 1 ZZZzzz 一つのコアが作業している間、他のコアは仕事が無い
複数のコアで複数の処理を実行 index[0] index[1] index[2] index[3] index[4] index[5] Work Work Work Work Work Work Core 0 Core 1 複数のコアで分担して作業すれば早くおわる だからもっとコアを活用しよう!と言いたいが、
とはいえ、マルチスレッドは難しい 粒度 競合 同期 大きな区分で仕事を任せた方が色々と早いが、分担が難しい 使用中のデータを、誰かが書き換えて結果がおかしくなる 同僚の仕事が終わらないと自分の仕事を始められない デッドロック 互いに完了待ちしてしまい、仕事が停止する バグるとデバッグしんどいです
C# Job Systemでしんどくないマルチコア対応 ルールに従えば簡単・安心にマルチコア活用 並列処理で処理が完了するまでの時間を短縮 ECSとも簡単に連携できる Burstを通すと、もっと高速化
高速なC#を組む為のルール High Performance C# Class Type無し Boxing無し GC Allocation無し Exception無し (C# Job Systemの制約) 大丈夫! まだ (一応) C# All Basic Types Struct Enum Generic Properties Safe Sandbox
メインスレッド以外もガンガン使うの楽しい
C# Job Systemの2つのAPI IJob 処理 IJobParallelFor Main Main Worker 0 Worker 0 Worker 1 Worker 1 Worker 2 Worker 2 別のコアで処理 メインスレッドはジョブ発行するマン 複数のコアで一括処理 処理が追いつかなければメインスレッドも使って一括処理
例えばシューティングの 当たり判定
C# Job Systemの基本的な流れ(1/3) NativeArray <float3> 座標データとか pos[0] pos[1] pos[2] pos[3] pos[4] pos[5] NativeArray <int> 座標的に接触したか res[0] NativeArray <int> 結局、接触したのか ans[0] res[1] res[2] res[3] res[4] res[5] 3つのバッファーを用意する Jobの処理は全て確保したバッファーに対して行う
C# Job Systemの基本的な流れ(2/3) NativeArray <float3> 計算に利用するデータ pos[0] pos[1] pos[2] pos[3] pos[4] pos[5] Core 1 で処理 NativeArray <int> 結果を格納する res[0] res[1] Core 2 で処理 res[2] res[3] Core 3 で処理 res[4] 複数のコアで担当する要素を処理し 結果をバッファに格納する res[5]
C# Job Systemの基本的な流れ(3/3) NativeArray <int> 結果を格納する res[0] res[1] res[2] res[3] res[4] res[5] Core 1 で処理 NativeArray <int> 最終的な結果 ans[0] ココを見て接触したか判定 結果をまとめて最終的な判定を行う
やってみる並列処理(1) pos[0] pos[1] pos[2] pos[3] pos[4] pos[5] Core 1 で処理 res[0] res[1] Core 2 で処理 res[2] res[3] Core 1 で処理 ans[0] Core 3 で処理 res[4] IJobParallelFor res[5] IJob 弾が特定の場所に接触したかを 全てのコアを使って並列処理
IJobParallelForを定義(1/3) struct 弾の判定ジョブ : IJobParallelFor { 継承 public void Execute(int id) { } } IJobParallelForを継承した構造体を定義
IJobParallelForを定義(2/3) struct 弾の判定ジョブ : IJobParallelFor { public NativeArray<Vector3> pos; public NativeArray<int> res; public void Execute(int id) { 入出力のバッファ } } Jobが使用するバッファを定義 C# Job Systemは 入出力を明確にする
IJobParallelForを定義(3/3)
struct 弾の判定ジョブ : IJobParallelFor {
public NativeArray<Vector3>pos;
public NativeArray<int> res;
public void Execute(int id) {
idは供給される
Jobが実行する処理
res[id] = (Vector3.Distance(
Vector3.zero, pos[id]) < 1) ? 0 : 1;
}
}
Jobの処理をExecuteに記述
struct 弾の判定 : IJobParallelFor {
public NativeArray<Vector3>pos;
public NativeArray<int> res;
public void Execute(int id) {
pos[id] = (Vector3.Distance(
IJobParallelForを使う(1/4)
Vector3.zero, res[id]) < 1) ? 0 : 1;
}
生成
void Update() {
var ajob = new 弾の判定ジョブ(){} ;
}
Jobを生成
}
struct 弾の判定 : IJobParallelFor {
public NativeArray<Vector3>pos;
public NativeArray<int> res;
public void Execute(int id) {
pos[id] = (Vector3.Distance(
IJobParallelForを使う(2/4)
Vector3.zero, res[id]) < 1) ? 0 : 1;
}
}
データ参照
void Update() {
var ajob = new 弾の判定ジョブ(){
pos = positions,
res = results1
}
}
Jobにバッファを登録
struct 弾の判定 : IJobParallelFor {
public NativeArray<Vector3>pos;
public NativeArray<int> res;
public void Execute(int id) {
pos[id] = (Vector3.Distance(
IJobParallelForを使う(3/4)
Vector3.zero, res[id]) < 1) ? 0 : 1;
}
}
void Update() {
var ajob = new 弾の判定ジョブ(){
pos = positions,
1ジョブでまとめて
実行する回数
res = results1
}
var handle = ajob.Schedule(positions.Length, 8);
実行指令
}
毎フレームScheduleを呼ぶ
struct 弾の判定 : IJobParallelFor {
public NativeArray<Vector3>pos;
public NativeArray<int> res;
public void Execute(int id) {
pos[id] = (Vector3.Distance(
IJobParallelForを使う(4/4)
Vector3.zero, res[id]) < 1) ? 0 : 1;
}
}
void Update() {
var ajob = new 弾の判定ジョブ(){
pos = positions,
res = results1
}
var handle = ajob.Schedule(positions.Length, 8);
JobHandle.ScheduleBatchedJobs();
即時ジョブ起動
handle.Complete();
}
完了まで待つ
以下は必要に応じて
動かしてみる
メインスレッドの処理 (ジョブの発行) ワーカースレッドの処理 (当たり判定の計算)
やってみる並列処理(2) pos[0] pos[1] pos[2] pos[3] pos[4] pos[5] Core 1 で処理 res[0] res[1] Core 2 で処理 res[2] res[3] Core 1 で処理 ans[0] Core 3 で処理 res[4] IJobParallelFor res[5] IJob
IJob struct 結果を受け取るジョブ : IJob { 継承 public void Execute() { } }
IJob struct 結果を受け取るジョブ : IJob { public NativeArray<int> res; public NativeArray<int> ans; public void Execute() { } } 入出力
IJob
struct 結果を受け取るジョブ : IJob {
public NativeArray<int> res;
public NativeArray<int> ans;
public void Execute() {
var length = res.Length;
for(var i=0; i<length; i++){
ジョブの実行内容
if( res[i] == 1){}
}
}
}
IJob
struct 結果を受け取るジョブ : IJob {
public NativeArray<int> res;
public NativeArray<int> ans;
public void Execute() {
var length = res.Length;
for(var i=0; i<length; i++){
if( res[i] == 1){ ans[0] = 1; return ;}
}
ans[0] = 0;
}
}
結果を格納
IJob
struct BJob : IJob {
public NativeArray<int> res;
public NativeArray<int> result;
public void Execute() {
for (var i = 0; i < positions.Length; ++i) {
var pos = positions[i];
読込専用なら兎も角、読み書きするジョブが複数同時に同じ要素を操作するとヤバイ
if( pos.x > -0.5f && pos.x < 0.5f){
result[0] = 1; return;
}
}
データの読取専用
データの書込専用
}
[ReadOnly]や[WriteOnly]属性をつける
}
要素への参照なので危険がつきまとう
IJob
struct 結果を受け取るジョブ : IJob {
public [ReadOnly] NativeArray<int> res;
public [WriteOnly] NativeArray<int> ans;
public void Execute() {
var length = res.Length;
for(var i=0; i<length; i++){
if( res[i] == 1){ans[0] = 1; return ;}
}
ans[0] = 0;
}
}
読取専用・書込専用の
属性を付ける
問題が起こりそうならエラーを出してくれる InvalidOperationException: The previously scheduled job Job1:AJob writes to the NativeArray AJob.positions. You are trying to schedule a new job Job1:BJob, which writes to the same NativeArray (via BJob.positions). 要約:複数のジョブが同じNativeArrayに同時に書き込むかも
問題が起こりそうならエラーを出してくれる InvalidOperationException: The native container has been declared as [WriteOnly] in the job, but you are reading from it. 要約:書込専用のNativeArrayを読み込んじゃあかん ありがたや
ジョブの依存関係 void Update() { var ajob = new 弾の判定ジョブ() { …}; var bjob = new 結果を受け取るジョブ() { … }; var handle = ajob.Schedule(positions.Length, 8); handle = bjob.Schedule(handle); bJobはaJobが } 完了したら実行 Scheduleの引数にJobHandleを入れる
動かしてみる
メインスレッド 集計処理
ECSとのC# Job Systemの連携(1) Injectの代わりにジェネリックでComponentDataを要求 public struct RigidbodyPositionJob : IJobProcessComponentData<Position, RigidbodyPosition> { } IJobProcessComponentDataを使う [Inject]無しにComponentData注入、若干効率的
ECSとのC# Job Systemの連携(2) public struct RigidbodyPositionJob : IJobProcessComponentData<Position, RigidbodyPosition> { public void Execute(ref Position pos, ref RigidbodyPosition rot) { // 処理 } } [ReadOnly][WriteOnly]属性で、 競合しないように、ジョブの実行順を 自動調整してくれる機能付 IJobProcessComponentDataを使う [Inject]無しにComponentData注入、若干効率的
.NETのThreadと比較して、どう違うの? .NET 4.xやC#7.2対応で 使えるようになった UnityのWorker Thread上で動作 コンテキストスイッチの増加を防ぐ オーバーヘッドが少ない ※IL2CPPでビルドした場合 使うための制約がキツイ ハイパフォーマンスなC#記述を強制 複数フレームを跨ぐ処理に向かない そもそもコアを使い切る為のもの ケースバイケースで 使い分け推奨
結局、マルチスレッドの難しい点は解決出来たか? • ジョブのまとめて実行する数を指定できる →粒度をある程度コントロールする ・複数のジョブが同じ参照先に書き込み可能だとエラー →競合の発生をエディターで防ぐ
結局、マルチスレッドの難しい点は解決出来たか? ・ジョブはメインスレッドでのみCompleteできる →デッドロックを起こさない ・ジョブはメインスレッドでのみScheduleできる →Completeとの整合性を重視
“ 危険なのは自由すぎるマルチスレッド ”
厳しい条件下で最適化するコンパイラ “ Burst ”
option 第三の選択肢 2 使い勝手を維持しつつ option IL2CPP 単純にC++へ変換 1 Mono IL option 3 厳しい条件下で最適化 Burstコンパイラ
厳しい前提条件の上で最適化 High Performance C#前提 Class Type無し 制約を設ける事で 限界まで最適化 Boxing無し GC Allocation無し Exception無し 連続したメモリレイアウト前提 データの入出力がハッキリしてる前提 ECSやC# JobSystemで 組めばOK
使い方
[Unity.Burst.BurstCompile]
struct 弾の判定 : IJobParallelFor {
public NativeArray<Vector3>pos;
public NativeArray<int> res;
public void Execute(int id) {
pos[id] = (Vector3.Distance(
Vector3.zero, res[id]) < 1) ? 0 : 1;
}
}
IJob系のインターフェースに
BurstCompile属性をつける
Burst OFF だいたい 7ms (IL2CPPで4倍くらい高速化) Burst ON だいたい 0.2ms
様々な最適化 CPU拡張命令を積極的に使う プロセッサ向けの最適化をUnityで行う avx2, sse4, neon, arrch 最適化しやすいMathライブラリも提供 計算の精度を下げる(オプション) 厳密な結果が必要ない箇所に使うオプション その他、様々な最適化
Burstでコンパイルした後のアセンブリは確認出来る 最大効率でないなら、 レポートすれば修正が入るかも
PackageManagerで提供 新しいプロセッサが追加された際、 Unityエンジンを自体をアップデート せず対応が可能 ECSパッケージには最初から含む
まとめ “ ECSでメモリレイアウト制御 (難しい) をよしなにしてくれる ” C# Job Systemでマルチコア対応 (難しい) をよしなにしてくれる BurstでCPU演算向け最適化 (難しい) をよしなにしてくれる
まとめ “ 頑張って最適化せずとも 最初から最大効率で動作するコードになる ”
オブジェクト管理 “ 処理時間 消費電力 空いた余剰スペックで ” もっと多くの要素をゲームにつぎ込めるように Thank You