概要
UnrealEngine の非同期処理についてのメモ書きです。
更新履歴
日付 | 内容 |
---|---|
2020/01/23 | スレッドの使用コアについての追記 |
2023/05/09 | コア指定の修正追記 |
環境
Windows10
Visual Studio 2017
UnrealEngine 4.22
参考
以下を参考にさせて頂きました、ありがとうございます。
UE4/C++ で lambda-expression なタスクをお手軽に非同期処理に投げる方法
C++ 標準の promise / future / thread に対応する UE4 標準の TPromise / TFuture / FRunnableThread の使い方
[UE4] 非同期処理を実装する
Multi-Threading: How to Create Threads in UE4
『FINAL FANTASY VII REMAKE』におけるプロファイリングと最適化事例
Multithreading in Unreal Engine 5
FFunctionGraphTask::CreateAndDispatchWhenReady()
ラムダ式で非同期に処理するお手軽な方法のようです。
"Engine\Source\Runtime\Core\Public\Async\TaskGraphInterfaces.h"
以下コード例
UCLASS()
class TEST_API AMyPlayer : public APawn
{
GENERATED_BODY()
// イベント
FGraphEventRef GraphEventRef;
};
void AMyPlayer::Update()
{
// 更新処理
}
void AMyPlayer::Tick(float DeltaTime)
{
// 非同期にする処理
TFunction< void() > _TaskFunc = [this]{
this->Update();
};
// スレッド名(バックグラウンドの通常優先度)
ENamedThreads::Type _Thread = ENamedThreads::AnyBackgroundThreadNormalTask;
// 未処理or処理済なら再度更新
if(!GraphEventRef || GraphEventRef->IsComplete()){
// 更新処理(タスクグラフは不要なので第3引き数はnullptr)
GraphEventRef = FFunctionGraphTask::CreateAndDispatchWhenReady( _TaskFunc, TStatId(), nullptr, _Thread );
}
}
スレッド名は ENamedThreads::GameThread とするとゲームスレッドになります。
優先順位や処理の重さなどで、使い分ける必要があります。
AnyHiPriThreadNormalTask = AnyThread | HighThreadPriority | NormalTaskPriority,
AnyHiPriThreadHiPriTask = AnyThread | HighThreadPriority | HighTaskPriority,
AnyNormalThreadNormalTask = AnyThread | NormalThreadPriority | NormalTaskPriority,
AnyNormalThreadHiPriTask = AnyThread | NormalThreadPriority | HighTaskPriority,
AnyBackgroundThreadNormalTask = AnyThread | BackgroundThreadPriority | NormalTaskPriority,
AnyBackgroundHiPriTask = AnyThread | BackgroundThreadPriority | HighTaskPriority,
処理の前提条件となる FGraphEventRef を第3引き数に渡すとタスクグラフが生成され、処理順序の制御ができます。
FAsyncTask / FAutoDeleteAsyncTask
タスクを定義するクラスが必要です。
エンジンのソース
"Engine\Source\Runtime\Core\Public\Async\AsyncWork.h"
を参考に以下コード例
// タスクを定義するクラス
class FMyAsyncTask : public FNonAbandonableTask
{
friend class FAsyncTask<FMyAsyncTask>;
public:
// コンストラクタ
FMyAsyncTask(TFunction<void()> InWork)
: Work(InWork)
{
}
// ワーク実行
void DoWork()
{
// コンストラクタで指定された関数を実行
Work();
}
FORCEINLINE TStatId GetStatId() const
{
RETURN_QUICK_DECLARE_CYCLE_STAT(FMyAsyncTask, STATGROUP_ThreadPoolAsyncTasks);
}
private:
TFunction<void()> Work;
};
// 実行をするアクター
UCLASS()
class TEST_API AMyPlayer : public APawn
{
GENERATED_BODY()
// タスク
TFunction< void() > MyTaskFunc;
// タスク定義クラス
FAsyncTask<FMyAsyncTask>* MyTask;
};
BeginPlay()で用意、Endplay()で後始末。
Tick()で終了確認と再度実行をしています。
void AMyPlayer::BeginPlay()
{
// 実行するタスク
MyTaskFunc = [this]{
this->Update();
};
// 生成
MyTask = new FAsyncTask<FMyAsyncTask>(MyTaskFunc);
}
void AMyPlayer::Tick(float DeltaTime)
{
// 終了確認&実行
if(MyTask && MyTask->IsDone()){
// バッググラウンド実行
MyTask->StartBackgroundTask();
// 同期処理で実行の場合は以下の様になる
//MyTask->StartSynchronousTask();
}
}
void AMyPlayer::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// 後始末
if(MyTask){
MyTask->EnsureCompletion();
delete MyTask;
MyTask = nullptr;
}
}
FAutoDeleteAsyncTask を使う場合もほぼ一緒です。(後始末が不要です)
排他処理について
マルチスレッドでの割り込み禁止にはクリティカルセクションを使い排他処理をすることによって実装可能です。
以下コード例。
UCLASS()
class TEST_API AMyPlayer : public APawn
{
GENERATED_BODY()
...
// クリティカルセクション
FCriticalSection Mutex;
};
割り込み禁止の前後でLock/Unlockするだけです。
#include "CriticalSection.h"
void AMyPlayer::Update()
{
// 割り込み禁止
Mutex.Lock();
// 別スレッドでの割り込み禁止中なので、ここの処理はスレッドセーフ
// 割り込み禁止解除
Mutex.Unlock();
}
他にスレッドセーフなカウンタ FThreadSafeCounter やブール値 FThreadSafeBool などがあり、こちらを使っても実現可能です。
スレッドで使用するコアの指定について
使用するスレッドによってマスクパターンを渡しUE側(プラットフォーム側)で自動で割り振られているようです。
"Engine\Source\Runtime\Core\Public\GenericPlatform\GenericPlatformAffinity.h"
class FGenericPlatformAffinity
{
public:
// メインスレッドが使用するCPUコアを指定するマスク
static const CORE_API uint64 GetMainGameMask()
{
return 0xFFFFFFFFFFFFFFFF;
}
...// 以下略
};
以下ゲームプラットフォームの指定コアマスク。
class FPS4Affinity : public FGenericPlatformAffinity
{
public:
// by default let all threads run whenever they can be scheduled. Games can override as necessary based on
// game specific measurements.
static const CORE_API uint64 GetMainGameMask()
{
// Keep the game thread on module 0 to avoid cache penalties
return MAKEAFFINITYMASK4(0,1,2,3);
}
...// 以下略
};
複数コアでスレッドが実行された場合、CPUキャッシュヒット率に影響がでることがあるようです。
「FINAL FANTASY VII REMAKE」での指定例
Thread | 優先度 | コア指定 |
---|---|---|
GameThread | TPri_Normal | 0 |
RenderingThread | TPri_Normal | 1 |
RHIThread | TPri_Normal | 2 |
TaskGraphThread | TPri_SlightlyBelowNormal | 0,1,2,3,4,5,6 |
PoolThread | TPri_Lowest | 3,4,5,6 |
AsyncLoadingThread | TPri_BelowNormal | 2,3,4,5,6 |
これを見ると、GameThreadやRenderingThreadでの処理をブロックしないように優先度とコア指定をしている感じでしょうか。
並列実行のfor文
ParallelFor
並列実行のfor文で、C#の Parallel.For に少し似ています。
ループで回す処理をラムダ式で渡して処理します。非同期なので計算順序が決まっている処理の場合、シングルスレッド指定にする必要があります。
inline void ParallelFor(int32 Num, TFunctionRef<void(int32)> Body, bool bForceSingleThread = false);
"Engine\Source\Runtime\Core\Public\Async\ParallelFor.h"
を参考に以下コード例
#include "ParallelFor.h"
int32 MaxEntries = 500;
ParallelFor( MaxEntries, [](int32 CurrIdx)
{
float Sum = 0.0f;
for ( int32 _Lp = 0; _Lp < 1000 * 10; _Lp++ )
{
Sum += FMath::Sqrt( (float)_Lp );
}
UE_LOG(LogTemp, Log, TEXT("%d : Sum=%f"), CurrIdx, Sum);
} );
以下、結果です。
LogTemp: 0 : Sum=666616.687500
LogTemp: 13 : Sum=666616.687500
LogTemp: 1 : Sum=666616.687500
LogTemp: 2 : Sum=666616.687500
LogTemp: 3 : Sum=666616.687500
LogTemp: 65 : Sum=666616.687500
LogTemp: 4 : Sum=666616.687500
LogTemp: 5 : Sum=666616.687500
LogTemp: 26 : Sum=666616.687500
LogTemp: 6 : Sum=666616.687500
LogTemp: 66 : Sum=666616.687500
... 以下略
並列処理なので、順序は不定です。
ParallelFor の第3引き数を true にするとシングルスレッドで実行されるため、順序は維持されます。
ParallelForWithPreWork
inline void ParallelForWithPreWork(int32 Num, TFunctionRef<void(int32)> Body, TFunctionRef<void()> CurrentThreadWorkToDoBeforeHelping, bool bForceSingleThread = false);
ParallelForWithPreWork は第3引き数に並列for処理の前に完了させたい処理を指定できるようです。
まとめ
ゲームスレッドの処理負荷削減にも非同期処理は使える可能性があるかと思います。
ですが、バグると面倒なことになるので排他処理などを十分考えていく必要があります。
async / await が欲しいです。