2023年8月11日金曜日

DirectX12 Multithread対応の続き2

マルチスレッド対応をしたけど、Releaseビルドで動かすと何故かカクカクの動きになる。
Debugビルドでは問題なく動いているように見えたけど、なにか問題のある書き方をしてるんだろう。

PIXでフレームの時間を見ると400us程度で終わっている。
この時間で1フレームが終わっているなら、2500fps出る計算になるが、実際に出るのは200fps程度で、60fsp固定している場合でも頻繁に30台~50台になり安定していない。


マルチスレッド処理の見直し1


メインスレッドと、コマンドリスト追加用のスレッド間の同期を必要以上に行っているので、シングルスレッドよりも非効率になってしまっていた。

メインスレッドで1つのコマンドをキューに追加したら裏で同時に動いてもらいたかったので、キューに追加するたびに通知して並列に動かしていたが、これがあまり良くないみたいだ。

ある一定量をキューに追加して、コマンドリストにはその単位でまとめて追加してもらうように修正。コマンドリスト追加用のスレッドの同期は、起こされたときに自動的にロックされる仕組みをそのまま利用して処理を行い、全て終わったらそのまま寝る。余計なLock/Unlockを行わないようにした。

この改善により、Releaseビルドでもまともに動くようになり、fpsも700ぐらい出るようになった。


マルチスレッド処理の見直し2


上記の見直しで、実はちゃんとしたダブルバッファリングが出来ていないのではないかという疑惑が浮かんだ。

一番最初の知識は、マイクロソフトのD3D12Fullscreenというサンプルだった。
サンプルでは、いくつかのリソースをフレーム数分用意してバックバッファインデックスによって使うリソースを切り替えて使っていくという方法。これを真似ればダブルバッファリングの仕組みになっていると思っていたが、罠が潜んでいた。
ライブラリ開発当初からの疑問で、定数バッファなどはフレーム数分用意しているのに、中間レンダーターゲットは1つしか用意していない。
あるフレームで、中間レンダーターゲットに書き込んでその結果をレンダーターゲットに書き込む。次のフレームの描画はこの中間レンダーターゲットの利用が済んでいないとできない。

中間レンダーターゲットもバッファ数分用意しないといけないのではないか?
マイクロソフトのサンプルが1つで処理しているから不要なのか?
実際の動作は1つでも動いている様に見える。
1年以上この疑問を持ちながら開発してきたけど、ついに確信を持ってバッファ数分必要だということがわかった。

1つで良かったのはシングルスレッドで処理しているからだ。マルチスレッドで、前のフレームリソースをGPU処理している最中に次のフレームのコマンドリストに同じ中間レンダーターゲットを追加しようとすればデバッグレイヤーで怒られる。今まではキッチリ中間レンダーターゲットの利用が終わるまで待って次のフレームの処理を行っていたんだと思う。

この確信により、さらなる改善案はExecuteCommandListsの呼び出しも別スレッドにすることだった。

見直し1まではコマンドリスト追加を別スレッドにやってもらって、ExecuteCommandLists呼び出しはメインスレッドで行っていたが、各追加スレッドが終わるのを待って、ExecuteCommandListsを呼び出すのに足止めを食らってしまう。実際には待たずに次のコマンドリスト追加を行いたい。

というわけで、ExecuteCommandListsを呼び出す部分も別スレッドにした。
これに伴い、書き換えが必要な各リソースを完全にフレーム数分用意する必要が出てきた。
リソースはその仕組を用意していたので、必要なリソースに関してはフレーム毎に用意するように修正した。

2つ目の改善で、FPSは1200を超えるようになった。


この改造をするに当たり、一旦壊して再構築を2回行ったのでめちゃくちゃ時間がかかった。だけど、かなり良い結果になって満足。



ハマりポイント


見直し1について


まず、マルチスレッド化するときにコマンドクラスと、コマンドラインクラスを作った。
コマンドクラスをメインスレッドで用意して、キューに追加してコマンドラインクラスに通知。
コマンドラインクラスが起きて、キューがある場合コマンドラインに追加してまた寝るを繰り返す。
コマンドラインを数ライン準備できたらExecuteCommandListsを呼び出すためにメインスレッドで各コマンドラインクラスのスレッドの終了を確認する際、キューの中身が空になっているのを確認しつつ、更にそのスレッドが寝たことを確認するために別のMutexを用意して確認していた。

結果的に処理がスムーズに流れず、シングルスレッドで動かすより遅くなっていた。

解決策は同期を最小限にすること。
Mutexのロックして、条件変数のWaitに渡したら手動では一切ロック操作は行わない。
起こされて寝るまではロックしっぱなしで処理を行う。
メインで追加したコマンドをコマンドラインに追加する処理を完全に並列にしたかったが、それを諦めてある程度の単位でそれを行う事により、スムーズに動くようになった。

ExecuteCommandListsを呼び出すためにメインスレッド側の確認は、Mutexの同期をしてキューの数を数えるだけで良くなった。
前は仮に0だとしても、キューから抜いた後まだ動いている可能性があったため寝た確認まで必要だったけど、ロックしっぱなしにすることによりロックが取れたということは処理済み、もしくはまだ起きていないということになる。

ここで少し話が逸れるが、標準ライブラリの条件変数の反応が良くない気がする。
キューに追加し終わって通知を行ってを何ラインか繰り返して、いざ実行する段になって各スレッドの状況を確認する際、ロックを取りに行って固まればちょうど実行中で取れた段階で処理完了が確認出来る。メインスレッド側もうまく待てる。これがロックを取りに行ってロックが取れた段階でキューを確認するとまだ入っているラインがいくつかある場合がある。通知をして余ってるCPUがあれば即座にスレッドが動作するものと思っていたけど、全然起きない。このループを2000回くらいやってやっと処理が終わるということもあった。
ロックが取れてキューが残っている場合は改めて通知をする様にしても変わらず反応が鈍い。昔のCPUはコア数が少なかったから通知したら自分自身のコアがそのまま通知先のスレッドの処理に切り替わるなどして、反応がよかったのか?
Mutexをロックしてから通知するのが一番効率がよいはずだったけど、今のCPUだとそのあたりの定石も変わってしまったのかもしれない。


見直し2について


コマンドキューもクラス化して、ExecuteCommandListsを別スレッドに実行してもらうことにより、コマンドラインの完了をメインスレッドで待たなくて良くする対応を行ったが、ライブラリの根幹部分を変更する必要があるため、すべてが動かなくなる。

自分のコーディングスタイルが少し修正して動かして確認する方法なので、この状況は非常にストレスだった。途中手がつけられず放置した期間もあるけど、この期間が長くなると取り返しがつかなくなるので、無理矢理にでも手を動かした。

マルチスレッドで、シングルスレッドよりも動きの予想が立ちづらい状況で一切動かさず組み上げるのはかなり困難だ。
とりあえず組み終わって、コンパイルが通っても最初は全く動かなかった。
呼び出し方もわかっているAPIで、今まで動いていたにもかかわらずマルチスレッドにしたらエラーが出まくっている。ログを入れまくり、実際の呼び出し順番を把握していった結果、なんとか原因を突き止めた。

原因はフレームを切り替えた(Present)後に、アプリケーション側のフレーム切り替え処理を行っていることだった。
フレーム切り替え処理は2つのことを行っており、1つは前フレームのリソースの開放、もう1つは、今フレームのGPU要求処理(ExecuteCommandLists)の完了を待つ事。
GPU要求処理に関しては、フレーム切り替え前に完了している必要があるため、場所を切り替え前に移した。

他にも細かい修正をいくつか行った末にやっと元の表示が出来るまで復活した。

と思ったけど、パフォーマンスを見るためにFPSを表示しようとしたところ、表示されない。何回か表示/非表示を繰り返すと一瞬表示されて消える現象が確認できた。表示してないわけではないけどすぐに消えてしまうようだ。

この原因はDirect2Dの処理は3D処理の後に行っているが、こちらはマルチスレッド化対象外になっている。
フレーム切り替え直前で3DのGPU要求完了を待っている為、2D処理が被ってしまいおかしくなっているようだ。
上記のフレーム切り替え処理を更に前に移して、2D処理前に完了させるようにしたところ正常に表示されるようになった。

ただ、2Dの処理はシングルスレッドなので、大量のコマンドを送信する場合はボトルネックになってくる。今後こちらも別途マルチスレッド化するか、3D側で文字表示出来るようにしてしまうかを考える必要がある。




0 件のコメント:

コメントを投稿