[UE4] Unreal InsightsでCPUのボトルネックを調べて修正する


検証Ver:4.25.0

 CPUの負荷が高くてアプリが重いけど良く分からん時のための、原因特定から修正までの流れの一例です。
 (※:この手順ではUnreal Insithtsを利用します)

参照:
[公式ドキュメント]Unreal Insights
UE4.25 Update - Unreal Insights -

1. 負荷の傾向を知る

 アプリケーションの動作が重い時には、まずその負荷がCPUにあるのかGPUなのか、大まかなあたりをつけます。Test構成でパッケージを作成してからアプリケーションを起動してコンソールコマンド stat unitを実行します。
※:Developmentビルドでも計測できますが、最適化の観点からTestビルドの方が望ましいです

 60fpsの場合は1フレームあたり16.6ms以内で動作しますが、下記の図ではCPU(Game)、GPUともに16.6msを越えているため何らかの負荷がある状態です。この図を見る限りはキャラクター1体がいるだけなので一見して負荷がなさそうに見えます。しかしながら、 stat unitの結果は16.6msを越えているので、シーン全体を通してみるとどこかに負荷が隠れているような状況です。今回はCPUに注目して見てみましょう。

2. Unreal Insightsでキャプチャを取る

始めにUnreal Insightsを起動します(パス: \Engine\Binaries\Win64\UnrealInsights.exe)。それから以下の起動引数を付与してアプリケーションを起動します。

CPUトレース時の引数指定
 -trace=cpu,frame -statnamedevents 

 今回はCPUに限定して取得するので上記のような指定をしています。CPUとフレーム全体をキャプチャしたいので-traceで指定し、-statnamedeventsを指定することによってCPUの詳細なイベントを出力するようにします。ある程度取得したらアプリケーションを終了して先程キャプチャしたトレースファイルを開きます。するとGame Threadで以下のように1フレームあたり約19ms程度かかっていることが確認できます。


namedeventsの違い

-statnamedeventsを付与しない場合は簡易的なCPUイベントしか見えないため解析に必要な情報が見れないことがあります。付与した場合は詳細なイベントを見ることができますが、statsのイベントキャプチャ自体にコストが追加される点や、トレースファイルが大きくなる点についてご留意ください。以下はGame Threadの様子をあり/なしで比較したものですが、同じ2フレーム分の表示であったとしても出力されたイベント量に違いがあることが分かります。

・-statnamedevents あり

・-statnamedevents なし

3. Unreal Insightsで解析する

 この積み上げグラフをどう見るかは以下の動画が非常に分かりやすいのでこちらもご覧ください。
「パフォーマンスの計測 再入門 〜Unity 2020版〜(5月28日号) - Unityステーション」32:42 頃から

1フレーム中の処理は、左から右へ順番に実行されて、上から順番に関数が呼ばれる流れとなっています。バーの長さが処理時間になるので、最終的にこのバーの長さが短くできるとこの処理は短い時間で実行されることになります。今はFEngineLoop(19ms)と1フレームあたり19ms掛かっているので、60fpsを目指す場合は16.6ms以内で収めるようにする必要があります。そのためにもこのグラフをどうにかして短くする必要があります。

 このグラフで言えば赤枠の箇所がグラフのおよそ半分を占めているので、この部分がボトルネックとなっている部分です。ここを改善できれば処理時間を大きく短縮することができるようになるので、ここの詳細をマウスホイールをスクロールして拡大して見てみましょう。

 上記の赤枠を拡大したものがこちらです。何やらCharacterMovementComponentのTickComponentが多数存在しています。画面上ではキャラクターが1体しか映っていないのに、なぜかキャラクターの移動を制御するCharacterMovementComponentが多数あるようです。こんな時はシーンに立ち戻ってキャラクターがいないか確認しましょう。

 Editorでシーンを確認していると、プレイヤーからZ=10000と見えないところに256体もキャラクターがいました。どうやらこの人たちはカメラには映っていなかったものの、シーン全体のCPUコストに追加投入されていたようです。この人たちはシーンに殆ど映らないのでどうにかしてコストを消してしまいましょう。

4. 問題に対する修正を適用する

 CPUにはケースによって様々な問題点と対処法が存在します。幾つかの最適化例は色んなところで紹介されているのでそちらを参考に修正したいところですが、今回はキャラクター256体が「移動する必要がなかった」ということで、親クラスをCharacterではなくSkeletalMeshActorに入れ替えるという方法を取ったとしましょう。この修正はこのAcotorのParentClassを変更するだけです。比較したものが以下になりますが、変更前にあったCharacterMovementComponentのTick処理が無くなっていることが分かります。

変更前:ParentClass=Character

変更後:ParentClass=SkeletalMeshActor

 結果として約19.0ms→約9.0msと不要な移動処理を削除するだけでこのように早くなりました。
これは極端な例ですが、ボトルネック箇所を特定してから適切な修正を行う事で最適化をはかりましょう。

5. まっとうに修正するとすれば

 上記の修正は親クラスを変更するという実におおざっぱで実際にはありえない変更なわけですが、処理負荷の効果が見えやすいので例として挙げました。これを正確に最適化をするとすれば、グラフが占有する処理の1つをピックアップしてそれらを地道に潰していく必要があります。

 例えば、以下のCharacterMovementComponentのTickから発生するキャラクターの床判定処理が負荷になっているとします。

 これを潰したい場合、バーに記載されている項目をコード内で検索します。例としてChar FindFloorを検索します。検索すると以下のようにStatsの項目(stat character)であることが分かります。

CharacterMovementComponent.cpp
DECLARE_CYCLE_STAT(TEXT("Char FindFloor"), STAT_CharFindFloor, STATGROUP_Character);

statsの項目の1つであるということは、このマクロが埋め込まれた関数に実際の処理があるのでそこに飛びます。ユニークなタグとして割り当てられるSTAT_CharFindFloorで再度検索してみましょう。以下のコードUCharacterMovementComponent::FindFloorの処理を行っていることが分かります。

CharacterMovementComponent.cpp
void UCharacterMovementComponent::FindFloor(const FVector& CapsuleLocation, FFindFloorResult& OutFloorResult, bool bCanUseCachedLocation, const FHitResult* DownwardSweepResult) const
{
    SCOPE_CYCLE_COUNTER(STAT_CharFindFloor);

    // No collision, no floor...
    if (!HasValidData() || !UpdatedComponent->IsQueryCollisionEnabled())
    {
        OutFloorResult.Clear();
        return;
    }
    //...
}

 これでChar FindFloorが計測している関数が分かりましたので、あとは最適化を施すだけです。といってもやることはできるだけ処理時間を短くしたいだけなので、以下の事を考えながら負荷軽減できるかを検討しましょう。

・そもそも関数を呼ばれないようにする
 これは計測した関数そのものを実行させないようにすることによる最適化が狙いです。処理そのものが不要な場合は適用可能です。処理自体がバッサリカットされるので一番効果的ですが、コンテンツを成立させる上で削除できないケースもあります。

・できるだけ早く関数を終了する
 これは計測した関数そのものは実行するものの、処理を短縮することが狙いです。処理をする必要が無い場合は、前提条件などを先にチェックすることによって早めに処理を終了させます。Engineに手を入れたくない場合などではEngineクラスを意図的にオーバーライドして、Engine側のクラスに処理させる前に終了させてしまうというのも1つのやり方です。

・できるだけ処理数を減らす
 これは計測した関数そのものは実行するものの、処理数を減らすことが狙いです。重い処理などを実行させないようにすることでその関数自体の処理数を減らすことで最適化を行います。Blueprintに公開されているフラグなどはその1つであり、例えばOverlapイベントを発生させるためのフラグ(bGenerateOverlapEvents)はその1つで、これをオフにすることでオブジェクトの移動が発生しても、オーバーラップを検出したかどうかの処理がスキップされることによって負荷が軽減されます。

このようなことを検討事項として考えながらバーを減らしていくとうまく最適化に繋がるかと思います。