ニートが学ぶ非同期処理 NativeContainerとC#JobSystem

はいどーも非同期処理がだんだん楽しくなってきた無職です。
本日はUnityで使えるC#JobSystemと、それに関係のあるNativeContainerについて書いていきたいと思います。
参考にするスライドはこちらのものです。
https://www.slideshare.net/UnityTechnologiesJapan/cjobsystem-ecscpu

NativeContainerってなんやねん

カクつかずに動作することが重要なゲームにとって最も恐ろしいのはGCだと思います。
GCは一般的には喜ばしいこととされていますが、ことゲームに限っては「いつ実行されるのかわからない」ため、「いつ急激に重くなるかわからない」「重要な場面でユーザー操作を受け付けなくなってしまうかも」などの問題があります。
これを緩和するために作られたのがNativeContainerのようです。
このNativeContainerはいわゆるアンセーフなコードなため、メモリの確保と解放をプログラマの責任でおこなわなければなりません。
ぶっちゃけこれはGCのある言語しか扱ってこなかった(ちゅーかJavaScriptとC#だけですけどw)僕としては、かなりハードルが高いですw
いつ解放するかのベストプラクティスとか何も知りませんからね……。
ただNativeContainerはメモリリークがあるとエディタ上で警告を発してくれるっぽいので、過度に心配する必要はないのかもしれません。

NativeContainerをとりあえず使ってみる

なにはともあれ実践です。
適当に書いてみます。

NativeContainerを使うにはUnity.Collections名前空間をusingしておくと便利です。

以下の行でNativeContainerを使っています。

第一引数には配列の長さを、第二引数にはメモリの生存期間を指定します。
今回の生存期間は「解放するまで」です。
他にも「そのフレームのみ有効」とか「そのJob中のみ有効」とかあるみたいですよ。

あとはご覧の通り普通の配列と全く同じように使えています。
違いと言えば、最後にDisposeメソッドを呼んでメモリの解放をしていることでしょうか。
もしもこの解放を忘れた場合、メモリリークが発生します。
ですがエディタ上ではメモリリークが発生したとき、警告を表示してくれるようです。

例えば最後のDisposeメソッドをコメントアウトしてみてください。
エディタに戻ると警告を表示してくれているはずです。
これはありがたいですね。
ただ実機上ではそのままリークするらしいので気をつけたいところです。

C#JobSystemってなんやねん

ここからが非同期処理の本題です。
参考にしているスライドによると、C#JobSystemには以下の特徴があるらしいです。

・エンジン内部処理専用のWorkerThreadをC#スクリプト側にも公開
・C#スクリプト側では実行させたい内容をJobとして登録する
・登録されたJobはWorkerThreadにも処理が分担される

あとはUnityAPIのほとんど(例えばランダム生成用の関数とか)を呼び出すことができなくなるらしいですが、これはC#JobSystemに限らず、メインスレッド以外から処理を実行した場合は共通ですね。

また、C#JobSystemでは値型しか使えません。
すなわちclassの使用は不可です。
これは大切なことで覚えておきたいですね。

Jobの種類

というわけで何かさっそく書いてみたいのですが、Jobには大きくわけて3つの種類があるようです。
C#JobSystemと一口に言っても色々あるんですね。

具体的には

・IJob
・IJobParallelFor
・IJobParallelForTransform

があるようです。
なんかLINQで見たことのある名前なのですが、中身も似てる感じですかね。
以下で一つずつ試してみます。

IJobで何か書いてみる

IJobってなんやねんということですが、要はインターフェイスです。
void Execute()を実装しなければならないようです。
中身も適当に書いてみます。

注目してほしいのはクラスではなく構造体になっているところです。
この構造体を利用する側のクラスも書いてみます。

先ほど作成したHogeJobを使っていますね。
Scheduleメソッドなんていつ宣言したんだということですが、これは拡張メソッドとして定義されています。
JobHandleが何なのかはまだよくわかりませんw

さて色々と試したのですが、Completeを実行させたタイミングで「ほげええええ」と表示されました。
これはメインスレッドを待機させてるんでしょうかね。
ちょっとよくわかりませんでした。
気にせず次に進みます。

IJobParallelForを使ってみる

今度はIJobParalellForです。
これもインターフェイスですが、シグネチャが先ほどのIJobとちょっと違います。
具体的にはvoid Execute(int index)ですね。
とりあえずコードを書き進めていきます。

IJobParallelForインターフェイスを実装している構造体です。
仕様がいまいち把握できていないのですが、どうもこの構造体の中でNativeArray系の構造体を宣言し、それをExecuteの中で使うっぽいです。

で、この構造体を使う側のクラスはこんな感じです。

配列を渡している以外はIJobの例とほとんど同じですね。
で、パフォーマンスはどうなのかということですが、普通に100個の配列を処理した場合(つまりメインスレッドだけ)は20ミリ秒かかっていたのに対し、上記のC#JobSystemを利用した場合は4ミリ秒で終わりました。
かなり大雑把な計り方ですが(本当はプロファイラ使ったほうがいいっぽいです)、これは使用を検討してもよいパフォーマンスな気がします。

IJobParallelForTransformを使ってみる

最後はIJobParallelForTransformです。
名前からしてTransformを複数のプールスレッドで動かす感じですかね。
なんでわざわざこんなものが用意されているかと言えば、やはりメインスレッド以外では通常、Transformに対して何か処理を加えることができないからでしょう(たぶん)。
逆に言うとIJobParallelForTransformは例外的に別スレッドでもTransformを動かせる、ということですね(こっちも多分)。

さっそくコードを書いて色々と試したのですが……どうも上手く扱うことができませんでした。
一応コードを書きますが、あんまり参考にしない方がいいかも。

まずはJobから。

次にJobを動かす側を。

_targetsフィールドにはTransformを100個くらい適当に当てはめてください。
_textフィールドは単に実機で結果を見やすくするためのものです。
Updateメソッド内のUpdateJobがJobを使ったバージョンで、UpdatePositionがJobを使っていないバージョン……のつもりです(コメントアウトして試してみてください)。
一応はJobバージョンの方が圧倒的に早いですが、この比較方法が正しいのかどうかすら怪しいですw

知った限りの注意点としては、別スレッドなのでJobの中ではTime.deltaTimeが直接は使えないことでしょうか。
あとTransformAccessはTransformが持っている一部のメソッドを持っていません。
オブジェクトの回転がめっちゃ面倒そうでした(数学が得意な人なら問題ないかも)。

結果は

一応結果も書いておきます。
エディタ上でも実機(Android)でもJobを使ったバージョンの方が圧倒的に早いです。
ただエディタ上では一回目のScheduleが完了するまでの時間も、それ以降のScheduleが完了する時間もあまり速度の差は出ませんでした(理由は詳しく調べていませんが、そもそもJobはエディタ上だと比較的重いらしいです)。
ところがAndroid(Monoでビルド)の場合、一回目は通常のループと速度の差は認められませんでしたが、二回目以降は一瞬で完了しました。
また、IL2CPPでビルドした場合、一回目の実行からそれなりに早かったです(二回目以降に比べると格段に遅いですが、それでも通常のループよりは早い)。
正確な測定方法ではない(もとい正確に測定する方法がわからない)ですが、オブジェクトが多くなればなるほど、また計算が複雑になればなるほどJobとの速度差が出そうだなとは思いました。

終わりに

C#JobSystemの勉強を始める前は「スレッドについて学んだし、余裕っしょ!」とか思っていたのですが、C#JobSystemめちゃんこ難しいですw
もう少し調べないと実際のゲームで使うのは怖い気もしますが、さりとてあまり情報が出回っていない(特に日本語)ので、どうやって勉強しようかなあ……とは悩んでいます。

まあボチボチ進めていきます。
次からはまたいつもの非同期処理の勉強に戻ります。
ほなそんな感じでまた。

フォローする