しゅみぷろ読者になるしゅみぷろプログラミングとかこの広告は、90日以上更新していないブログに表示しています。Systemはじめにやる気がある。全部Uniteのせい。System)とJobSystemについて色々触ってみたので、基礎部分のメモを残しておきます。サンプルのプロジェクトは以下から見れます。github.comどの程度自分の解釈が正しいのかちょっと怪しい部分もあるので、今後も色々と検証も行っていきます。その段階で間違いとか新たな発見とかがあったらその都度記事を更新します。あと、今回の内容は非同期処理について多少知見があると理解が早いです。なんか4年前くらいに書いた資料が発掘されたので、興味がある方は見てみてください。後ろの方(スライド92枚目あたり)に非同期処理についての何かが書いてあります。ECSとJobSystemを使うメリットそもそも、ECSやJobSystemはどんな時に役立ってくれるのでしょうか。これからECSとJobSystemそれぞれ、または両方利用する場合のについてメリットについて見ていきます。今関わってるプロダクトやこれから関わるプロダクトでECSやJobSystemの恩恵が殆ど感じられないようなケースもあるかもしれません。これらのメリットがあまり享受できなそうだと感じたら、無理に使う必要はありません。ECSとJobSystemを使いこなす事が出来れば、今までのUnityでは実現が難しかったことが出来るようになります。具体的には「大量のGameObjectをシーンに配置してそれぞれを動かしたり何かしら処理をさせたりする」みたいな事が高速に処理出来るようになります。加えて、メモリ効率も格段に良くなります。既存プロダクトの最適化でも役に立つケースがあるかもしれませんし、今後より便利になっていく機能だと思うので、今のうちに概要だけでも知っておくのが良さそうです。それぞれのComponentはどうでしょうか?これらのリソースがアプリケーションでどのくらいのメモリを確保するかは、実行している環境によって変わってきます。これらを詳細にチェックするには実際の環境での実行時にメモリをプロファイルする必要がありますが、Unityエディタ上でもProfilerウィンドウから参考数値程度のものであれば確認することが出来ます(エディタで表示されるメモリ使用量は一般的に実機で実行しているときよりも大きくなります)。ParticleSystemを持つGameObjectを大量生成した場合、エディタ上ではこうなります。500個くらい生成してProfilerを確認した結果です。ParticleSystem結構重たいなっていうのはちょっとした発見。Unityではオブジェクトにコンポーネントとしてデータをもたせることが出来ますが、このデータの多くは冗長で、実際には使われないようなものが多く含まれます。データ中心設計)のため、このようなムダなメモリの確保を限りなく無くすことが出来ます。データはハードウェアにとって扱いやすいよう連続的にメモリに配置され、キャッシュ効率の良いメモリアクセスが実現できます。ECSは「大量のデータを効率よく処理する」ことに長けています。作らず、必要なデータだけを定義して処理することができないか」を考えましょう。一般に、マルチスレッドでコードを書く際に注意しなければならないのが、データへの同時アクセスです。これは意図しないタイミングでの値の読み書きにより、期待した動作と異なるような結果を招いたり、あるいはデッドロックを発生させたりします。これを回避するために多数の同期用データや処理を足すと、コードが煩雑になるだけでなく、あるThreadが他のThreadの完了待ちで長時間待機状態になってしまう...というような事も少なくありません。結果、パフォーマンスがあまり改善しなかったり、デバッグが困難であったりと、様々な問題を抱えています。JobSystemはUnityEngineと上手い具合にやり取りするマルチスレッドコードを簡単に書くことができるように設計された機能であり、UnityEngineが内部で利用するThreadを効率よく使いまわすことが出来ます。扱えるデータに制約を加えることで、MainThreadの共有リソースへの同時アクセスが発生しにくいような仕組みになっており、加えてデバッグも容易になっていたりします。複数のCPUコアを効率よく使うことで、アプリケーションの様々なロジックを高速に完了させることが出来、適切な箇所に適用することでかなり大きな恩恵が得られます。ECSは並列処理ととても相性の良いアーキテクチャです。ECSで扱うデータは、シンプルな値がメモリ上に連続して並んでいるような格好になります。このデータのそれぞれに並列で何か処理を行いたい場合「入力されたデータを処理してその結果を出力する関数」をマルチスレッドで走らせれば良いという、とても単純な設計を行えば良くなります。まとめざっくり纏めると、以下のようなメリットがあります。処理の並列化を行いたい場合といった感じでしょうか。JobSystemJobSystemについて基本的なところメモしておきます。以下のサンプルを参考にさせていただきました。github.comJobSystemとは何かJobSystemはUnityEngineと上手い具合にやり取りするマルチスレッドコードを簡単に書くことができるように設計された機能です。JobSystemの重要な機能は、ユーザーの書いたコードとUnityEngineが内部的に使用しているネイティブ実装のJobSystemを統合することです。これは、ユーザーが書いたコードとUnityEngineがJobSystem用に生成されたWorkerThreadを使う事を意味します。JobSystem用に生成されたWorkerThreadを共有することによって、ユーザーがThreadの生成や管理を行う必要がなくなり、更に「CPUの論理コア数より多くのスレッドが生成されるのを防ぐ」ことが出来ます。これは、コンテキストスイッチの発生を防ぐことに繋がります。「処理実行中のスレッドの状態を保存して中断し、別途必要になった処理を行うために別のスレッドに切り替えて作業を行い、その後で最初のスレッドを再構成して処理を続ける」プロセスです。コンテキストスイッチはリソースを大量に消費するため、可能な限り避けることが重要になります。因みにUnityEngineの生成するWorkerThreadはコンテキストスイッチを避けるために、論理CPUコアと同等のThread数になるよう生成されます(MainThreadと合わせて論理コア数と同数になるっぽいです)。WorkerThreadで実行可能なJobと呼ばれるものを作成します。Jobは小さく、1つの特定のタスクを実行するように作る必要があります。Jobは継続タスク(async/await)のような従属性をサポートします。AとBというJobがあり、BはAの終了後に実行させないといけないといったようなケースをサポートしています。操作する必要があるデータのコピーを作成します。(マネージコードとネイティブコードの間で渡されたときに変換を必要としないデータ型)のみで、JobSystemではこれに該当する型にしかアクセス出来ないという制約があります。マネージコードからネイティブコードへのデータの受け渡し方法ですが、JobSystemはScheduleメソッドを呼び出すことでBlittable型を内部的にmemcpy関数でコピーし、受け渡します。コピーされたデータはネイティブメモリに格納され、Jobに定義されたExecuteメソッドがこの値を使ってWorkerThread上で処理を実行します。当然ですが、このコピーされた値をWorkerThreadで変更したとしても、MainThread上の値には何の影響もありません。WorkerThreadで処理した後の値を受け取るにはどうしたらいいでしょうか。NativeContainer属性が付加されたコンテナを使うことで実現可能です。NativeContainer属性の付加されているNativeArrayというコンテナを使うと、Jobはそのデータに対してコピーを行わず、メインスレッド上の共有データにアクセスすることができます。この共有データを使った処理が複数のJobで行われて競合した場合、例外が送出されます。Unityが用意してくれているNativeContainer属性の付加されている型はNativeArrayNativeListNativeHashMapNativeQueueなど、様々なものがあります。これが全てではありません。サンプルコードをちょこっと弄ったものです。velocityとdeltaTimeからpositionを計算するJobを作成し、実行しています(計算後にログを出力するだけで、ゲーム的な動きがあるサンプルではありません)。Jobを作る際、Jobでアクセスされる全てのデータをJob内に宣言します。宣言が可能なのはNativeContainer及びBlittable型のみです。読み取り専用という付加情報を与えることで複数のJobが並列にデータにアクセスできるようになります。デフォルトでは、コンテナは読み書きが可能です(つまり、MainThreadで結果を取り出すことができます)。Jobには一般的にフレームの概念がないため、deltaTimeをJobにコピーする必要があります。WorkerThreadで独立して処理が実行されます。Jobが実行するコードです。MainThreadとWorkerThreadでデータを安全に共有することができます。使い終えたらDisposeする必要があります。Jobの初期化処理です。Jobをスケジューリングし、後でJobの完了を待つことができるJobHandleを返します。Schedule実行後、すぐにCompleteを呼び出すことはお勧めできません。並列処理の恩恵を受けることがほぼできなくなるためです。}}Jobを使うにはIJobインターフェースを実装した構造体を定義します。Job側では読み取りにしか利用しないデータの場合にはReadOnly属性で修飾してあげましょう。(デフォルトでは読み書き両方できるデータになります)。IJobを定義した構造体を作ると、Unityが用意した以下のように定義されているSchedule拡張メソッドが利用可能になる仕組みです。IJob;このScheduleを呼び出すことで、JobがJobSystemの管理するQueue(JobQueue)にEnqueueされます。Scheduleの戻り値はJobHandleです。JobはScheduleメソッドのパラメーターにJobHandleを指定して、特定のJob実行後に継続して他のJobを実行させるようにできます。JobHandle.CombineDependenciesを使うことで、複数のJobHandleを纏めることができます。この纏めた結果のJobHandleをScheduleのパラメーターとして利用する場合、纏めた全てのJobの完了後にScheduleされたJobが実行されます。Scheduleをしても、実際にはJobは実行されません。Jobを開始するには、バッチ(仕事を一定数ごとに分割したもの)をflushする必要があります。これはCompleteを呼び出すと行われます。Schedule後直ぐにCompleteを呼び出すと、MainThreadがその段階でJobの完了待ちをしてしまうため、並列化の恩恵が薄れてしまいます。Jobがすぐに完了待ちを行うようなコードになっている場合、この勿体無い待ち時間をなんとか潰すために、MainThreadでもJobが実行されます(これの判断はJobSystemが勝手にしてくれます)。これを解決するには、Schedule後にJobHandle.ScheduleBatchedJobsメソッドを呼び出します。このメソッドを呼び出すことでバッチがflushされ、Jobが開始されます。あとはサンプルのように、Jobの結果を待つタイミングでCompleteを呼び出します。上記はサンプルコードのJobHandle.ScheduleBatchedJobsをコメントアウトして実行したときの結果です。無駄な待ち時間が発生するため、MainThreadでJobが実行されています。半々くらいの頻度でWorkerThreadで処理を行ってくれようとするんですが、結局MainThreadでは待ちが発生するので、どちらにせよ並列化の恩恵は受けられません。Scheduleしたら、後はCompleteやJobHandle.CompleteAllでJobの完了を待ちます。サンプルコードで使っているNativeArrayはNativeContainer属性が付加されたコンテナです。NativeContainer属性はジョブデバッグシステムが競合状態を確実に検出可能であることや、Thread間での安全な値の受け渡しが可能であるということを示すための属性です。(この属性を付加された構造体の実装がちゃんとしていれば)MainThreadから安全に値にアクセスできます。NativeArrayは固定長の配列で、生成時には配列のサイズとメモリの割り当て、解放について指定するAllocatorを渡します。MainThreadから安全に値にアクセスできると書きましたが、競合した場合は「例外が出てくれる。ログにも出力される」的な意味で安全と言っています。独自でNativeContainer属性を付加したコンテナを自作しても、実装がちゃんとしてなければクラッシュする可能性があります。これに関してはNativeContainerのドキュメントで詳細が見れます。AllocatorについてAllocator.Tempメモリ確保が最も高速です。これは1フレーム以下のアロケーションでの使用を目的としており、スレッドセーフではありません。この方法でメモリを確保した場合、関数呼び出しから戻る前にDisposeメソッドを呼び出す必要があります。Allocator.TempJobAllocator.Tempよりメモリ確保が低速ですが、Allocator.Persistentより高速です。これは、4フレーム以下の寿命を持つ場合に利用出来、スレッドセーフです。ほとんどの短期ジョブはこのNativeContainer割り当てタイプを使用します。4フレーム経過前にDisposeしないとメモリリークします。Allocator.Persistentメモリ確保が最も遅いですが、アプリケーションの存続期間中存続します。mallocへの直接呼び出しのラッパーです。長いJobがこのNativeContainer割り当てタイプを使用することがあります。使い終わったらDisposeします。これを動かしてProfilerで確認すると、VelocityJobのExecuteがJobSystemの管理するWorkerThreadで動いているのが確認できます。並列化Job先ほどのサンプルでは、MainThreadから切り離したい処理をJobとして登録し、JobSystem上のWorkerThreadで実行されるようにしました。次はこのJobを並列化してしまいます。先ほどのサンプルでは、Execute内でfor文を使って、positionを計算していました。データだけが異なる同じ演算を繰り返している部分なので、このExecuteの中も並列で計算してもらう方がよさそうです。これを実現するために、IJobParallelForが用意されています。IJobの代わりにこちらを実装するようにします。並列実行のJobをスケジュールします。最初のパラメータは、各反復が何回実行されるかです。2番目のパラメータは、内部でのループ分割数(バッチ数)です。}}上記はUnityマニュアルのサンプルコードです。サンプルの変更点はIJobの代わりにIJobParallelForを実装Executeが引数付きになり、インデックスが渡ってくるのでそれを使うように実装を変更(今回は配列のインデックスとして利用)ScheduleExecuteメソッド実行回数(今回は配列の長さ分)、内部でのループ分割数(バッチ数)を指定するように変更たったこれだけです。IJobParallelForでは指定されたバッチ数以上のチャンクに作業を分割し、適切な数のJobをスケジュールします。複数のJobが複数のWorkerThreadで実行されるようになるため、サンプルのようなケースではとても有効です。バッチ数に1に近い値がくるほど、スレッド間での作業の分散がより均一になります。作業が細かく分割されすぎるとオーバーヘッドがあるので、バッチ数を増やす方がよい場合もあります。1から始まり、パフォーマンスの向上が得られる間バッチ数を動的に増やすことは有効な戦略です。こちらもProfilerで確認すると、JobSystemのWorkerThreadで処理を行っていることが確認できます。先程のIJobを実装したJobのProfilerと比較すると、IJobParallelForが複数のWorkerThreadを使って処理を行っていることがわかると思います。JobSystemを使う上での注意JobSystemを利用する上で、以下のことに気をつける必要があります。7.0相当の機能が使えるようになったら改善される見込みっぽい常にCompleteを呼ぶことCompleteを呼ぶことで、WorkerThreadに委譲していたNativeContainerの所有権がMainThreadに返されるCompleteはJobデバッガの状態も更新する(更新されないままだと、デバッガはメモリリークしたと判断し、警告を出す)ECS続いてECSについて。みんな大好きテラシュールさんがめちゃくちゃわかりやすく、参考にさせていただきました。テラシュールブログECSの概念的な事とかは長くなるので書かないことにします。先ほどの参考ページや、以下を参照してください。WikipediaECSを利用するサンプルコードと、それの解説のみ書いていくことにします。上記で紹介したテラシュールさんでも言われていますが、本家ECSとUnityのECSだと用語が微妙に紛らわしいです。本記事内では本家ECSの用語を使っていきます。Data何かしらの情報EntityDataの集まりSystemEntityに対して行う処理GroupSystemが要求するDataの纏まりといった意味合いで解釈してください。独自のDataを定義する場合、IComponentDataかISharedComponentDataを実装します。IComponentDataは、座標情報などのEntityごとに異なるデータに適しています。ISharedComponentDataは、多くのEntityに共通するものがある場合に適しています。Group(Systemに渡されるEntityの纏まり。要求されるデータの配列のようなもの)を定義。IComponentDataかISharedComponentDataを実装したDataがSystemに要求されるデータになります。Lengthには要求するDataを持つEntityの数が格納されます。Thread間でデータを共有できます。SharedComponentDataArrayはReadOnlyを指定しないとエラーになります。値の代入行為が不適切であるからです。SharedComponentDataArrayは、Systemで計算に使う値を格納する用途で使います。GroupがEntityの持つ型と一致する場合に処理を実行するSystemを作ることができる。ECSを利用するサンプルクラス。JobSystemを利用していないため、MainThreadで動く。このサンプルでは、大量のMeshの移動と回転を行い、描画する。描画するようなSystemを作るべきですが、サンプルコードが冗長になるためここに描画処理を書いてあります。GetAllEntitiesで取得したNativeArrayは明示的に破棄する。フレームをまたいで生存しているとメモリリークするので注意。持つEntityを生成し、SystemがそのEntityに対して「位置・回転情報の更新」を行うサンプルです。使ってDrawMeshでMeshを描画しています(特定のDataを持つEntityの場合に描画を行うSystemを作って任せるようにしたほうがいいと思いますが、サンプルコードが長くなるので今回は割愛)。実行するとこんな感じになります。コードの解説ですが、ECSの考え方が掴めていればあとはUnityでそれをどう実装していけばいいのかを把握するだけでほぼ終わりです。多少気を付けなければならない部分もあるので、踏み抜かないよう注意していきましょう。Dataの定義サンプルのSpeedDataの定義がこれにあたります。}}Dataを定義する場合はIComponentDataISharedComponentDataのどちらかを実装した構造体を定義します。IComponentDataは、座標情報などのEntityごとに異なるDataの実装に適しています。Entityごとに異なる値になるので、Systemで読み書きが可能です。ISharedComponentDataは、多くのEntityに共通するものがある場合に適しています。値が同じものは各Entityで共通のDataインスタンスが利用されるようになります。Groupの定義サンプルのSampleGroupがGroupの定義です。Length;}IComponentDataISharedComponentDataのどちらかが実装されたDataのコンテナを格納する構造体を定義します。コンテナにはComponentDataArraySharedComponentDataArrayが利用できます。SharedComponentDataArrayはEntity間で共有される値で、Systemが勝手に変更を行うことはできません。コンテナの定義の際に[ReadOnly]属性を付加し忘れるとエラーが出ます。因みにComponentDataArrayはNativeContainerです。SharedComponentDataArrayはNativeContainerではありません。ComponentDataArrayがNativeContainerであることはJobSystemを利用する際に結構役立つ知識なので覚えておきましょう。Systemの定義SampleSystemがSystemの定義部分です。}}ComponentSystemを実装し、Systemが要求するEntityのコンテナ実装(つまりGroup)に対して[Inject]属性を付加します。OnUpdateはComponentSystemの抽象メソッドで、これがSystemの行う処理になります。後述しますが、JobSystemで並列に処理を実行するSystemを定義したい場合JobComponentSystemを実装します。Entityの生成まずEntityを管理するManagerのインスタンスを生成します。World.Active.GetOrCreateManager<EntityManager>();次に、EntityがもつDataタイプを先ほどのManagerに渡してEntityを作ってもらいます。entityManager.CreateEntity(archetype);生成されたEntityに対してDataを登録すれば終わりです。Entityは生成・登録を行なったManagerによって管理されます。SpeedData(10));ECSで処理の並列化ECSの利点は色々あるのですが、そのうちの1つが「データと処理が完全に分離される」点です。この特徴は、Systemで行われる処理を非同期にするのにとても都合が良いです。ECSは非同期処理を行うJobSystemと相性が良く、大量のデータを扱う場合には大きなパフォーマンス向上に繋がる可能性があります。以下の画像は、先ほどのサンプルを実行し、Profilerで確認したものです。Systemの処理が全てMainThreadで実行されているため、WorkerThreadが遊んでいます(使ってないので当然ですが)。これはちょっと勿体無いですね。以降では、Systemの処理をWorkerThreadで動かすようなサンプルを見ていきます。ECSECSとJobSystemを使う例を見ていきます。先ほどのECSのサンプルコードを改造してSystemの処理をJob化し、WorkerThreadで動作するようにしています。移動と回転処理を行うJobを定義。対象とするJobを定義することができる。JobComponentSystemは抽象クラス。JobSystemを利用するサンプルクラス。}}1つ目のECSサンプルと比較して、変更箇所は以下の通りです。IJobParallelForを実装したMoveRotateJobというJobが定義されたこのJobには、1つ目のサンプルでSystemのOnUpdateが実行していた処理が移植されているJobComponentSystemを実装したSystemが定義されたSystemは、1つ目のサンプルではComponentSystemが実装されていたJobComponentSystemのOnUpdateは引数および戻り値がJobHandleになるOnUpdateの引数inputDepsは、SystemにDataへの依存性注入を行うJobをScheduleして返されたJobHandleなので、inputDepsを任意のJobのScheduleに渡すことで、依存性注入後にJobの実行を行うことができる戻り値はScheduleしたJobが返すJobHandleECSのみのサンプルとあまり大きな違いはなく、Systemの処理をJobSystemで行わせるようにしただけです。難しくは無いと思います。因みに、パフォーマンスはこんな感じです。このサンプルだとMainThreadがUpdateの最後に記述した描画のための処理で圧迫されてしまっているのがちょっと気になりますが、とりあえず気にしないでください。今重要なのは複数のWorkerThreadでJobが実行できているということです。MainThreadのみで実行していた時に比べると、「Update開始からSystemの処理が終わるまでの時間」が大体17[ms]から10[ms]まで短縮されました。両サンプルの計測時には、Spaceキーを押している間生成を行うのではなく、一度に10000回Entityを生成し、3秒後の結果を表示しています。因みにJobにComputeJobOptimization属性を付加してBurstコンパイラに最適化してもらうようにすると、もっと速くなります(今はEditor上でのみBurstコンパイラの恩恵が受けられると聞いたような聞かないような...どうでしたっけ...)。以下はJobにComputeJobOptimization属性を付加して同じように測定した結果です。WorkerThreadの処理が速くなったことが確認できます。今まで載せてきたサンプルコードではあえて、それぞれの定義をバラバラにしていました。(先ほどのECS+JobSystemのサンプルをちょっと手直しして描画もSystemが行うよう変更してあります)。}}ダメと言われてもやってみたちょっと話を戻して、先程のMainThreadでの描画処理の負荷が高かったサンプルについて、もうちょっとなんとかならないか考えます。サンプルだと、MainThreadの処理中にWorkerThreadが遊んでムズムズしますね。0);}entities.Dispose();この部分です。全てのEntityを取得して、それぞれのEntityからDataを取得し描画する部分です。この部分を少しでもなんとかしたい...。Graphics.DrawMeshはMainThread以外から呼ぶとUnityが怒ります。素直に並列化ってわけにはいきません。そこで試しに、全てのEntityからDataを取得する部分のみをWorkerThreadで動かすようにJobを作ってみました。静的データへのアクセス。通常時に実行はできたが、Burstコンパイラで最適化をかけたら実行時エラーが出た。}}こんな感じになるんですが、JobのExecuteに注目してください。試しにJobからWorld.Active.GetOrCreateManager<EntityManager>();を呼び出してみました。「JobSystemを使う上での注意」で書いた、やっちゃいけない奴です。参考にしないでください。今の段階では普通に動きます。Profilerで確認したところ、目論見通り多少マシな感じになりました。exceptionが発生します。将来的にも使えなくなりそうなので、くれぐれも参考にしないで下さい。さいごにまだまだ理解不足な部分も多いですが、楽しかったのでもっと深く色々触ってみようと思います。カメラのレンズに雨が付着した感を出してみました。)はてなブログをはじめよう!es_programさんは、はてなブログを使っています。ブログを報告する引用をストックしましたストック一覧を見る閉じる引用するにはまずログインしてくださいログイン閉じる引用をストックできませんでした。再度お試しください閉じる限定公開記事のため引用できません。