UE4:マルチスレッド処理

今回は GameThread の処理が重たいものの他のコアは結構空いているという状況があって、 処理を別コアに分散する方法を調査した。UDNで質問した所、UE4 Wiki のリンクと USkeletalMeshComponent、UAnimInstance の FTaskGraphInterface::Get() 辺りを見てくれ、 との返答を頂いたので、その辺りを調査した結果をここに残す

UE4 Wiki の内容を調査

次の UE4 Wiki を参照。https://wiki.unrealengine.com/Multi-Threading:_Task_Graph_System

記載されているソースを簡略化

素数を5000個計算するプログラムが例として書かれていたが、 クラス名が長いのと、英語コメントなので、読みやすくするためにソース簡略化してみた

まず、タスク側の処理

namespace MultiThreadTest
{
    //  UObjectsへのマルチスレッドリンク、このリンクを介してUObjects / AActorsを作成、変更、破壊をしないで!
    AGamePlayerController* ThePC;

    //  結果を入れる変数
    TArray<uint32>      PrimeNumbers;

    //  タスクグラフ終了完了イベントt
    FGraphEventArray    CompletionEvents;

    //  全てのタスクが終了したかをチェック。
    bool TasksAreComplete()
    {
        for (int32 Index = 0; Index < CompletionEvents.Num(); Index++)
        {
            if (!CompletionEvents[Index]->IsComplete())
            {
                return false;
            }
        }
        return true;
    }

    //  素数計算コード
    int32 FindNextPrimeNumber()
    {
        //  重要ではないので中身は省略
        //  PrimeNumbers 配列の最後の数+1から総当たりで素数チェック。
        //  計算結果が出た後、PrimeNumbers に他のスレッドから同じ結果入れられていたら、再度計算。
        //      :
        return TestPrime;
    }


    //  タスクスレッドの用意
    class FTestTask
    {
      public:
        //  コンストラクタ
        FTestTask()
        {
        }

        //  名前を返す関数
        static const TCHAR* GetTaskName()
        {
            return TEXT("FTestTask");
        }

        //  StatId を返す関数
        FORCEINLINE static TStatId GetStatId()
        {
            RETURN_QUICK_DECLARE_CYCLE_STAT(FTestTask, STATGROUP_TaskGraphTasks);
        }

        // スレッドタイプを返す
        static ENamedThreads::Type GetDesiredThread()
        {
            return ENamedThreads::AnyThread;
        }

        // 継続モードを返す
        static ESubsequentsMode::Type GetSubsequentsMode()
        {
            return ESubsequentsMode::TrackSubsequents;
        }

        //  実行されるタスク
        void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
        {
            //  TArrayに結果を追加している。スレッドセーフではないので問題が発生する可能性があり
            PrimeNumbers.Add(FindNextPrimeNumber());
            //  HUDに表示
            ThePC->ClientMessage(FString("A thread completed! ~ ") + FString::FromInt(PrimeNumbers.Last()));
        }
    };

    //  Multi-Task Initiation Point
    void FindPrimes(const uint32 TotalToFind)
    {
        PrimeNumbers.Empty();
        PrimeNumbers.Add(2);
        PrimeNumbers.Add(3);

        //  1素数=1タスク。TotalToFind個のタスクを作成し、TotalToFind個の終了イベントを作成
        for(uint32 b = 0; b < TotalToFind; b++)
        {
            //  プロパティを追加する際は ConstructAndDispatchWhenReady() 内に記入
            CompletionEvents.Add(TGraphTask<FTestTask>::CreateTask(NULL, ENamedThreads::GameThread).ConstructAndDispatchWhenReady());
        }
    }
}

こちらは、呼び出し側の APlayerController の処理

// タイマーで定期的に呼び出す処理
void AGamePlayerController::CheckAllThreadsDone()
{
    if(MultiThreadTest::TasksAreComplete() )
    {
        // タイマー呼び出しクリア
        GetWorldTimerManager().ClearTimer(this, &AGamePlayerController::CheckAllThreadsDone);

        // 結果表示(省略)
        //      :
    }
}

// タスク開始処理。関数は終了を待たないですぐ抜ける。
void AGamePlayerController::StartThreadTest()
{
    MultiThreadTest::ThePC = this;
    MultiThreadTest::FindPrimes(50000);     // 指定した数のタスクを生成する

    //  1秒単位で関数を呼び出して終了判定。
    GetWorldTimerManager().SetTimer(this, &AGamePlayerController::CheckAllThreadsDone, 1, true);
}

ソースの大まかな内容

ソースを見て、処理の大まかな内容をまとめてみた

  • APlayerController継承(AGamePlayerController) から呼び出す形になっている。HUD表示を使うため。
    • HUDが必要なければ AActorで良いと思われる。
  • 1素数 = 1タスクで分散処理。計算する個数のタスクを作成し、計算する個数の終了イベントを作成する。
  • タイマーイベントを使って、一定間隔で終了イベントをチェック。
  • 素数の結果は TArrayに保存。各タスクで Add() している。

タスク化する際の手順

ソース内のタスク化に関連する部分をピックアップ。

  • タスククラスの用意。UE4ソース内でタスククラスを見ると最低限用意するメソッドは以下の5つ。ソース例にある GetTaskName() は必要ない。
    • コンストラクタ。必要ならここでタスク呼び出し元からプロパティを渡す。
    • DoTask()メソッド。タスク処理本体。
    • GetStatId()メソッド。パフォーマンス計測用のStatIDを返す。
    • GetDesiredThread()メソッド。スレッド動作モードを指定。特に何もなければ ENamedThreads::AnyThread を返す、で良い。
    • GetSubsequentsMode()メソッド。スレッドに継続があるかどうかを指定。
      • 継続タスクがあれば ESubsequentsMode::TrackSubsequents
      • 無ければ ESubsequentsMode::FireAndForget を返す。
class FTestTask
{
    FTestTask() { /* プロパティを渡す場合に処理が入る */ }
    void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent) {  /* タスク処理が入る */ }
        :
}
  • タスク終了イベントの配列を用意
    例では、namespace 内のグローバル変数だが、制御する AActor 内に置くのが良いと思われる。
FGraphEventArray        CompletionEvents;
  • タスクの生成と、タスク終了イベント を追加
CompletionEvents.Add( TaskTGraphTask< FTestTask >::CreateTask().ConstructAndDispatchWhenReady() )
  • タイマー処理で終了をチェックする関数を呼び出す
GetWorldTimerManager().SetTimer(this, &AGamePlayerController::CheckAllThreadsDone, 1, true);
  • 終了したらタイマーを解除。
GetWorldTimerManager().ClearTimer(this, &AGamePlayerController::CheckAllThreadsDone);

その他、Wikiに書いてあること

  • 各タスクスレッドの DoTask() 内で UObjectを作成、削除、変更しないでください (4.8または-Rama以降)
  • DoTask() 内でタイマーを呼び出さないでください
  • デバッグ用のライン/ポイント描画しようとすると、クラッシュする可能性があります (4.6.1以降)
  • 上記のような事をしなければ、メインスレッド上のアクターに対して段階的な進捗状況を送ることができます。
  • 実際にDoTask() からHUDクラスの関数をプレーヤーコントローラポインタを使い呼び出しています!
  • また、AsyncWork.hには、FAsyncTaskやFAutoDeleteAsyncTaskなどのタスク用の素晴らしいテンプレートがあります
  • 本当にシンプルな関数なら ParallelFor を使う方が良い。
    • Runtime/Core/Public/Async/ParallelFor.h

USkeletalMeshComponent 内の調査

FTaskGraphInterface::Get() でUE4ソースを検索した所、以下の処理を見つかった。スケルタルメッシュのクロスの処理を並列化している箇所らしい。

void USkeletalMeshComponent::HandleExistingParallelClothSimulation()
{
    if(IsValidRef(ParallelClothTask))
    {
        // There's a simulation in flight
        check(IsInGameThread());
/*1*/   FTaskGraphInterface::Get().WaitUntilTaskCompletes(ParallelClothTask, ENamedThreads::GameThread);
/*2*/   CompleteParallelClothSimulation();
    }
}
  1. ParallelClothTask 処理の終了待ち FTaskGraphInterface::WaitUntilTaskCompletes | Unreal Engine

  2. クロスシミュレーションの終了処理
    ParallelClothTask を破棄して、シミュレーション結果を戻す処理をしている。

つまり、HandleExistingParallelClothSimulation() 関数は、クロスシミュレーションが走っていたらクロスシミュレーションが終わるまで待って終了処理を行う、というもの。

この関数を呼び出しているのは以下の関数。名前から見ても破棄処理関連の関数なので、この関数は通常の終了待ち処理では使われていない模様。

USkeletalMeshComponent::OnUnregister()
USkeletalMeshComponent::RemoveAllClothingActors()
USkeletalMeshComponent::ReleaseAllClothingResources()

なので、タスク生成の方から通常の終了待ち処理を調査する。

タスク生成は ParallelClothTask で検索するとすぐに見つかる。

void USkeletalMeshComponent::UpdateClothStateAndSimulate(float DeltaTime, FTickFunction& ThisTickFunction)
{
            :
    if(ClothingSimulation)
    {
/*1*/   ParallelClothTask = TGraphTask<FParallelClothTask>::CreateTask(nullptr, ENamedThreads::GameThread).ConstructAndDispatchWhenReady(*this, DeltaTime);

/*2*/   FGraphEventArray Prerequisites;
/*2*/   Prerequisites.Add(ParallelClothTask);
/*2*/   FGraphEventRef ClothCompletionEvent = TGraphTask<FParallelClothCompletionTask>::CreateTask(&Prerequisites, ENamedThreads::GameThread).ConstructAndDispatchWhenReady(this);

/*3*/   ThisTickFunction.GetCompletionHandle()->SetGatherThreadForDontCompleteUntil(ENamedThreads::GameThread);
/*4*/   ThisTickFunction.GetCompletionHandle()->DontCompleteUntil(ClothCompletionEvent);
    }
            :
}
  1. タスクを生成処理。UE4 Wiki の内容と同じようにタスクを作成して、そのタスク終了イベントを ParallelClothTask に渡している。
  2. これは少し不可解な処理。先ほど生成した終了イベントを使って、更に終了イベントを作成している。 複数タスクの終了待ちをするならまだ理解できるが、この場合はどう見ても単体タスクの終了。 1つで良くない?シミュレーション処理と終了処理をそれぞれ重いので別タスク化しているのか?
    あと、Prerequisites のスコープはこの制御ブロック内になっているが、 TWeakPtr で渡しているので消えない模様。これもわかりにくい。
  3. 調べたけどよくわからず。GatherThread って何を指している?
  4. ClothCompletionEvent 終了まで、TickFuncion の終了を待つ。

それぞれのタスクの処理を見てみる。

それぞれのタスククラス FParallelClothTask, FParallelClothCompletionTask の DoTask() 関数を見てみる。

  • FParallelClothTask
SkeletalMeshComponent.ClothingSimulation->Simulate(SkeletalMeshComponent.ClothingSimulationContext);

SkeletalMeshComponent のクロスシミュレーション処理を呼んでるだけ。

  • FParallelClothCompletionTask
MeshComp->CompleteParallelClothSimulation();  

SkeletalMeshComponent の終了処理関数を呼んでるだけ。 最初に出てきた HandleExistingParallelClothSimulation() でも同じ関数が呼び出されている。

特に他の処理は入っていない。

SkeletalMeshComponent の分散処理まとめ

  • タスクの生成、タスクの終了イベント生成は UE4 Wiki で書かれている手順と変わりはなかった。
  • タスクの生成は2つ。「クロスシミュレーション処理」→「 タスクイベントの破棄+クロスシミュレーション後の計算取得」処理。
  • TickFuncion に対して、2つ目のタスク終了イベントを待たせる様に指定。
  • 2つ目のタスク終了イベント変数は、ブロックスコープにて生成したオブジェクトを
    TWeakPtr にてタスクに渡していて、ポインタを破棄すると消える。