Qt 3D の CPU 使用量の改善

この記事は The Qt BlogCPU usage improvements in Qt 3D を翻訳したものです。
執筆: Svenn-Arne Dragly, 2017年11月16日

前回の LTS である Qt 5.6 以降、様々な改善が Qt 3D に対して行われてきました。KDAB と The Qt Company のエンジニアが Qt 5.9 LTS に向けて様々な新機能の開発を行い、そのうちの多くが KDAB の Sean Harmer による What's new in Qt 3D with Qt 5.9 にてカバーされています。さらなる新機能(例えば Vulkan 対応)が計画どおり進んでいますが、直近のリリースではパフォーマンスと安定性にも注力しています。パフォーマンスは実際に Qt 5.6 と比べると非常に改善していて、複雑なシーンや巨大なフレームグラフを持つシーンではこの改善による効果が顕著です。

多くのビューポートを持つシーンは、個々のビューポートがそれぞれのリーフノードに対応するため巨大なフレームグラフになりがちです。Qt 3D のフレームグラフの概要やその利点をご存じない方は、kdab.com のPaul Lemire のブログ記事 をご覧ください。下のスクリーンショットは内部のベンチマークの1つで、28個のビューポートを持つ比較的シンプル(でカラフル)なものです。

Qt 3D benchmark with many viewports

このベンチマークでの CPU の使用量は Qt 5.6.2 から Qt 5.9.2 にかけて大幅に削減されました。KDAB と協力をしながら、さらなる改善を Qt 5.11 で行う予定になっています。

viewport-benchmark-results

多くのパフォーマンスの改善は Qt 3D Studio のランタイムを Qt 3D ベースに変えるという理由で行われてきました。この新しいランタイム自体は来年の話になりますが、それにむけた Qt 3D に対するパフォーマンスの改善は Qt 5.9.x LTS の上ですでにはじまっています。内部で開発中の Qt3D Studio のサンプルでのベンチマーク結果も公開します。

3dstudio-benchmark-results

パフォーマンスの改善は Qt3D の様々な部分で行われてきており、glTF2 のような効率的なファイル形式のサポートもなされています。この記事では今回の CPU の使用量の削減についての詳細をいくつか紹介しようとおもいます。またメモリの使用量に関する別の記事も予定しています。

ジョブの依存関係解決の改善

パフォーマンスの改善の一つが Qt 3D のジョブの依存関係の解決です。Qt 3D では個々のフレームでの作業を並列実行が可能な小さなジョブの単位に分割します。ジョブは Qt 3D のバックエンド/フロントエンドアーキテクチャの重要な一部で、メインスレッドで動作するフロントエンドとそれ以外のスレッドで動作するバックエンドの分離を可能にします。描画や入力のハンドリング、アニメーションの実行などが該当します。詳細は Qt 3D Overview のドキュメント を参照してください。

バックエンドは様々なアスペクトからのジョブをスレッドプールを使って実行します。この際に、個々のジョブは依存関係を定義することが可能となっていて、実行時にこれが解決されている必要があります。ジョブが1つのフレームから別のフレームに変わることはよくあるため、依存関係の解決は効率よく行われる必要があります。ジョブの数が少ない場合にはシンプルですが、巨大なフレームグラフを持つ複雑なシーンでは多大な時間がかかる処理になります。

Callgrind でサンプルプログラムのプロファイルを行ったところ、このジョブの依存関係解決のある一部がパフォーマンスのボトルネックになっていることが分かりました。特に、すべての依存のための巨大な QVector はジョブが終了して、それに対応する依存情報がリストから取り除かれる度にリサイズの処理が行われていました。

まずはその QVector をやめて、2つのリストにジョブを保持するようにしました。1つはどのジョブが依存しているのかのリストで、もう一つは誰がそれに依存しているかのリストです。

class AspectTaskRunnable {
// ... other definitions
QVector m_dependencies;
QVector m_dependers;
};

これにより、ジョブが終了した際には m_dependers を確認し、依存しているジョブの m_dependencies から自分自身を消す処理が行われます。その後リストが空であればそのジョブが実行可能という仕組みです。

しかし、これによって多くの小さな QVector が逐次リサイズされるという状態になりました。巨大な QVector のリサイズよりはマシですが、まだまだ改善の余地がありました。

最終的に気づいたことは、ジョブの実行時には依存関係は変わらないため、誰にジョブに依存しているのかと、どのジョブが誰に依存しているのかの両方を確認する必要がないということでした。双方のジョブにおいて、どのジョブがそれに依存しているかが分かっていることと、依存されているジョブの数が分かればそれで十分ということです。

class AspectTaskRunnable {
// ... other definitions
int m_dependencyCount = 0;
QVector<AspectTaskRunnable*> m_dependers;
};

この場合、ジョブが完了する度に、依存しているジョブのリストを走査し、依存カウンターの値を一つ減らします。(読みやすいように単純化した)コードは以下のようになりました。

void QThreadPooler::taskFinished(AspectTaskRunnable *job)
{
const auto &dependers = job->m_dependers;
for (auto &depender : dependers) {
depender->m_dependencyCount--;
if (depender->m_dependencyCount == 0) {
m_threadPool.start(depender);
}
}
}

この変更の実装によって、ジョブの依存解決の処理は CPU の使用量に大きな貢献を果たしました。では次のボトルネックに移りましょう。

QThreadPool のパフォーマンスの改善

ベンチマークの結果から Qt の別の場所にも改善が期待される箇所が見つかりました。例えば、Qt 3D はジョブの管理と異なるスレッドでの実行のために Qt Core に含まれる QThreadPoolを使っています。しかし、一つ前のケースと同様に、QThreadPool は QVetor をジョブの保持に使っていて、ジョブの完了の度にリサイズ処理が行われていました。ジョブの数が少ないケースでは大きな問題とはなり得ませんが、複雑な Qt 3D のシーンのような膨大なジョブではボトルネックとなり得ました。

ここでは QThreadPool では十分な大きさの「queue pages」を使う実装に変え、これらのページのポインタを QVector に保持するようにしました。個々のページではキューの中の最初と最後のジョブのインデックスを保持しています。

class QueuePage {
enum {
MaxPageSize = 256;
};

// ... helper functions, etc.

int m_firstIndex = 0;
int m_lastIndex = -1;
QRunnable *m_entries[MaxPageSize];
};

これにより、ジョブが完了した際に最初のインデックスを1増やすのと、ジョブを追加した際に最後のインデックスを1増やすという処理でよくなりました。ページ内に余裕がない場合には新しいページを生成します。これはシンプルで低レベルな実装ですが、その効果は絶大でした。

特定のジョブの結果のキャッシュ化

次に発見したのは、とても CPU を占有している特定のジョブが存在するということです。QMaterialParameterGathererJob のようなこれらのジョブは前のフレームとの結果が変わらないにも関わらず非常に多くの処理を毎フレーム行っていました。これは明らかにキャッシュの導入でパフォーマンスが改善する所です。それでは QMaterialParameterGathererJob の処理を見てみましょう。

Qt 3D では QRenderPass で定義されたすべてのパラメーターを QTechnique や QEffect、QMaterial のようなレンダーパスが使うものの上で上書きすることが可能です。個々のパラメータは最終的にはシェーダープログラムの uniform の定義となります。以下のコードはすべてのレベルで "color" プロパティを設定している例です。

Material {
parameters: [
Parameter { name: "color"; value: "red"}
]
effect: Effect {
parameters: [
Parameter { name: "color"; value: "blue"}
]
techniques: Technique {
// ... graphics API filter, filter keys, etc.

parameters: [
Parameter { name: "color"; value: "green"}
]
renderPasses: RenderPass {
parameters: [
Parameter { name: "color"; value: "purple"}
]
shaderProgram: ShaderProgram {
// vertex shader code, etc.

fragmentShaderCode: "
#version 130
uniform vec4 color;
out vec4 fragColor;
void main() {
fragColor = color;
}
"
}
}
}
}
}

最終的にシェーダープログラムで利用されるパラメーターの値を導くため、QMaterialparameterGathererJob はシーン内のすべてのマテリアルをチェックし、該当するエフェクトやテクニック、レンダーパスを探します。それから、QMaterial に対する QEffect や QTechnique、QRenderPass のパラメータの設定の優先度を確認し、最終的なパラメーターの値を決定します。

この場合は、最終的な値は QMaterial 自体に設定されているものが最優先となり、値は "red" となります。

多くのマテリアルが存在する巨大なシーンではすべてのパラメータの確認はとても時間のかかる処理となり、Qt 3D Studio のサンプルのいくつかではボトルネックとして確認できました。このため、QMaterialParameterGathererJob で確定したパラメーターの値をキャッシュすることにしましたが、値が毎フレーム変わるような状況ではキャッシュは意味がないという事もすぐに分かりました。これはよくあるケースで、例えばアニメーションをしている場合が該当します。このため、値自体ではなく、QParameter オブジェクトのポインターをキャッシュとして保持することにしました。値はキャッシュではないところに保持をし、必要な場合にのみ取得するようにしています。このキャッシュの導入により、マテリアルが追加された際のようなシーンに巨大な変更が起こった時のみジョブが実行されるようになり、多くのパラメータが存在するようなシーンでは非常に大きな改善となりました。

これ以外にも同じような改善を様々なところで行っています。規模の大きいサンプルでプロファイルを行い、特定のジョブのボトルネックを特定し、パフォーマンスを改善する方法を探したり、結果のキャッシュを導入したりということをしています。幸いな事に、Qt 3D のジョブベースのシステムは個々のジョブの最適化やキャッシュの導入が割と簡単にできるような作りになっているため、今後の Qt 3D のリリースでも更にパフォーマンスの改善を行える予定です。


Blog Topics:

Comments