// Copyright Epic Games, Inc. All Rights Reserved. #include "ImageWriteQueue.h" #include "HAL/IConsoleManager.h" #include "HAL/ThreadSafeBool.h" #include "HAL/FileManager.h" #include "Misc/Paths.h" #include "Misc/ScopeLock.h" #include "Misc/FileHelper.h" #include "Async/Async.h" #include "Modules/ModuleManager.h" #include "IImageWrapperModule.h" #include "Misc/QueuedThreadPoolWrapper.h" DEFINE_LOG_CATEGORY(LogImageWriteQueue); static TAutoConsoleVariable CVarImageWriteQueueMaxConcurrency( TEXT("ImageWriteQueue.MaxConcurrency"), -1, TEXT("The maximum number of async image writes allowable at any given time.") TEXT("Default is to use the number of cores available."), ECVF_Default); static TAutoConsoleVariable CVarImageWriteQueueMaxQueueSize( TEXT("ImageWriteQueue.MaxQueueSize"), -1, TEXT("The maximum number of queued image write tasks allowable before the queue will block when adding more.") TEXT("Default is to use 4 times the number of cores available or 16 when multithreading is disabled on the command line."), ECVF_Default); /** * Struct defining particular 'fence' within the queue */ struct FImageWriteFence { FImageWriteFence(uint32 InID, uint32 InCount, TPromise&& InCompleted, TFunction InOnCompleted) : ID(InID) , Count(InCount) , Completed(MoveTemp(InCompleted)) , OnCompleted(MoveTemp(InOnCompleted)) { } FImageWriteFence(FImageWriteFence&&) = default; FImageWriteFence(const FImageWriteFence&) = delete; FImageWriteFence& operator=(FImageWriteFence&&) = default; FImageWriteFence& operator=(const FImageWriteFence&) = delete; /** A unique identifier for this fence, any tasks enqueued before this fence will have an ID <= this fence's ID */ uint32 ID; /** The number of tasks currently dispatched with an ID <= this fence */ uint32 Count; /** A promise to fulfil when this fence has been reached */ TPromise Completed; /** A callback to call on the game thread when this fence has been reached */ TFunction OnCompleted; }; /** Private implementation of the write queue */ class FImageWriteQueue : public IImageWriteQueue { public: FImageWriteQueue(); ~FImageWriteQueue(); public: /* ~ Begin IImageWriteQueue interface */ virtual TFuture Enqueue(TUniquePtr&& InTask, bool bBlockIfAtCapacity = true) override; virtual TFuture CreateFence(const TFunction& InOnFenceReached = TFunction()) override; virtual int32 GetNumPendingTasks() const override; /* ~ End IImageWriteQueue interface */ public: /** * (thread-safe) Called from a task when it has been completed. * * @param FenceID The fence ID that the task was created under */ void OnTaskCompleted(uint32 FenceID); /** * (thread-safe) Called from the module when this queue should start shutting down. * Prevents any susequent tasks from being enqueued */ void BeginShutdown(); private: /** * Called when any cvar in the engine is changed. Causes a recreation of the thread pool if necessary. */ void OnCVarsChanged(); /** * Ensure that the thread pool is set up with the correct number of pooled threads */ void RecreateThreadPool(); /** * (thread-safe) Decrement the number of tasks pending for any fence ID that is >= the fence specified * * @param FenceID The fence ID to decrement */ void DecrementFence(uint32 FenceID); private: /** Atomic count of currently pending (and in progress) tasks */ TAtomic NumPendingTasks; /** Atomic cache of the maximum number of allowable queued (and in progress) tasks */ TAtomic MaxQueueSize; /** Auto-reset event that is signalled every time a task completes */ FEvent* OnTaskCompletedEvent; /* ~~~ Begin ThreadPoolMutex protection ~~~*/ FCriticalSection ThreadPoolMutex; /** True when ThreadPool is an allocated thread pool that must be deleted on shutdown */ bool bOwnedThreadPool; /** Thread pool to queue tasks within - pool size set to the max concurrency cvar */ FQueuedThreadPool* ThreadPool; /* ~~~ End ThreadPoolMutex protection ~~~*/ /* ~~~ Begin FenceMutex protection ~~~*/ FCriticalSection FenceMutex; /** Array of fences that are still waiting to be reached */ TArray PendingFences; /** Serial ID of the next fence that should be returned. Starts at 0, increments each time a fence is created. */ uint32 CurrentFenceID; /** Incrementing count of the number of tasks that have been enqueued since the last fence was created. */ uint32 CurrentFenceCount; /* ~~~ End FenceMutex protection ~~~*/ /** Delegate handle for a consolve variable sink */ FConsoleVariableSinkHandle CVarSinkHandle; /** Set when we are pending shutdown and no new tasks should be added */ FThreadSafeBool bPendingShutdown; }; /** Implementation of the queued work that just writes a task */ class FQueuedImageWrite : public IQueuedWork { public: FQueuedImageWrite(uint32 InFenceID, FImageWriteQueue* InOwner, TUniquePtr&& InTask, TPromise&& InPromise) : FenceID(InFenceID) , Owner(InOwner) , Task(MoveTemp(InTask)) , Promise(MoveTemp(InPromise)) {} /** Perform the work on the current thread, and delete this object when done */ void RunTaskOnCurrentThread() { // Perform any compression, conversion and pixel processing, then write the image to disk bool bSuccess = Task->RunTask(); Promise.SetValue(bSuccess); // Inform the owning queue that a task was completed with this task's fence ID Owner->OnTaskCompleted(FenceID); delete this; } private: /** Called on a pooled thread when this work is to be performed */ virtual void DoThreadedWork() override { RunTaskOnCurrentThread(); } virtual void Abandon() override { Promise.SetValue(false); // Inform the owning queue that a task was completed with this task's fence ID Owner->OnTaskCompleted(FenceID); delete this; } private: /** The fence ID context that this task was dispatched within */ uint32 FenceID; /** The owning queue that dispatched this task */ FImageWriteQueue* Owner; /** The task itself */ TUniquePtr Task; /** A promise to fulfil when this task has been performed or abandoned */ TPromise Promise; }; FImageWriteQueue::FImageWriteQueue() : NumPendingTasks(0) , bOwnedThreadPool(false) , ThreadPool(nullptr) , CurrentFenceID(0) , CurrentFenceCount(0) , bPendingShutdown(false) { // Ensure that the image wrapper module is loaded - required for GImageWrappers FModuleManager::Get().LoadModuleChecked("ImageWrapper"); // Allocate the task completion event bool bManualResetEvent = false; OnTaskCompletedEvent = FPlatformProcess::GetSynchEventFromPool(bManualResetEvent); // Create the cvar sink and set up the thread pool CVarSinkHandle = IConsoleManager::Get().RegisterConsoleVariableSink_Handle(FConsoleCommandDelegate::CreateRaw(this, &FImageWriteQueue::OnCVarsChanged)); OnCVarsChanged(); } FImageWriteQueue::~FImageWriteQueue() { check(bPendingShutdown && NumPendingTasks == 0); FPlatformProcess::ReturnSynchEventToPool(OnTaskCompletedEvent); IConsoleManager::Get().UnregisterConsoleVariableSink_Handle(CVarSinkHandle); if (bOwnedThreadPool) { ThreadPool->Destroy(); delete ThreadPool; } } void FImageWriteQueue::OnCVarsChanged() { RecreateThreadPool(); const int32 ConfiguredMaxQueueSize = CVarImageWriteQueueMaxQueueSize.GetValueOnAnyThread(); MaxQueueSize = ConfiguredMaxQueueSize == -1 ? (ThreadPool ? ThreadPool->GetNumThreads() * 4 : 16) : ConfiguredMaxQueueSize; } void FImageWriteQueue::RecreateThreadPool() { if (!FPlatformProcess::SupportsMultithreading()) { return; } // Prevent any other tasks being dispatched FScopeLock ScopeLock(&ThreadPoolMutex); #if UE_IWQ_USE_GIOTHREADPOOL // To avoid spawning extra threads use global IO thread on mobile const int32 MaxConcurrency = GIOThreadPool->GetNumThreads(); #else const int32 ConfiguredMaxConcurrency = CVarImageWriteQueueMaxConcurrency.GetValueOnAnyThread(); const int32 MaxConcurrency = ConfiguredMaxConcurrency == -1 ? FPlatformMisc::NumberOfCores() : ConfiguredMaxConcurrency; #endif if (ThreadPool && MaxConcurrency != ThreadPool->GetNumThreads()) { CreateFence().Wait(); if (bOwnedThreadPool) { ThreadPool->Destroy(); delete ThreadPool; ThreadPool = nullptr; } else { check(ThreadPool == GIOThreadPool); ThreadPool = nullptr; } } if (!ThreadPool) { if (MaxConcurrency == GIOThreadPool->GetNumThreads()) { // Use the global IO thread pool if possible bOwnedThreadPool = false; ThreadPool = GIOThreadPool; } else if (GThreadPool && GThreadPool->GetNumThreads() >= MaxConcurrency) { // Use a simple wrapper to limit concurrency and reuse threads we already have bOwnedThreadPool = true; ThreadPool = new FQueuedThreadPoolWrapper(GThreadPool, MaxConcurrency); } else { // Create a new thread pool as a last resort bOwnedThreadPool = true; ThreadPool = FQueuedThreadPool::Allocate(); verify(ThreadPool->Create(MaxConcurrency, 5 * 1024)); } } } void FImageWriteQueue::DecrementFence(uint32 FenceID) { FScopeLock FenceLock(&FenceMutex); // If this fence ID is the current fence context, there cannot be any fences dependent upon this task if (FenceID == CurrentFenceID) { --CurrentFenceCount; return; } int32 LastCompletedFenceIndex = -1; // Iterate the pending fences in order, // decrement the fence count for this ID and // gather the last consecutive completed fence index (with a count of 0) for (int32 Index = 0; Index < PendingFences.Num(); ++Index) { FImageWriteFence& Fence = PendingFences[Index]; // If the current fence depends upon the ID supplied, and has outstanding tasks, we can't have reached any fence beyond it if (Fence.ID > FenceID && Fence.Count > 0) { break; } // If this is the supplied fence ID, decrement its count if (Fence.ID == FenceID) { --Fence.Count; } // If the previous fence has been reached, and so has this, increment the last completed fence index if (Index == LastCompletedFenceIndex + 1 && Fence.Count == 0) { ++LastCompletedFenceIndex; } } // If there is any chain of consecutive fences that have been reached, complete them all now if (LastCompletedFenceIndex >= 0) { for (int32 Index = 0; Index <= LastCompletedFenceIndex; ++Index) { FImageWriteFence& Fence = PendingFences[Index]; check(Fence.Count == 0); Fence.Completed.SetValue(); if (Fence.OnCompleted) { AsyncTask(ENamedThreads::GameThread, [LocalOnCompleted = MoveTemp(Fence.OnCompleted)] { LocalOnCompleted(); }); } } PendingFences.RemoveAt(0, LastCompletedFenceIndex+1, EAllowShrinking::No); } } void FImageWriteQueue::OnTaskCompleted(uint32 FenceID) { DecrementFence(FenceID); --NumPendingTasks; OnTaskCompletedEvent->Trigger(); } void FImageWriteQueue::BeginShutdown() { bPendingShutdown = true; CreateFence().Wait(); } int32 FImageWriteQueue::GetNumPendingTasks() const { return NumPendingTasks; } TFuture FImageWriteQueue::CreateFence(const TFunction& InOnFenceReached) { TPromise Promise; TFuture Future = Promise.GetFuture(); FScopeLock FenceLock(&FenceMutex); if (PendingFences.Num() == 0 && CurrentFenceCount == 0) { // The queue is completely empty, return immediately Promise.SetValue(); if (InOnFenceReached) { AsyncTask(ENamedThreads::GameThread, [InOnFenceReached] { InOnFenceReached(); }); } } else { // Move the promise into the write fence PendingFences.Emplace(CurrentFenceID, CurrentFenceCount, MoveTemp(Promise), CopyTemp(InOnFenceReached)); // Reset the current fence context ++CurrentFenceID; CurrentFenceCount = 0; } return Future; } TFuture FImageWriteQueue::Enqueue(TUniquePtr&& InTask, bool bBlockIfAtCapacity) { if (!ensureMsgf(!bPendingShutdown, TEXT("Cannot issue a new image write command while the queue is shutting down."))) { return TFuture(); } // Block if the queue is at capacity if (bBlockIfAtCapacity) { while (NumPendingTasks >= MaxQueueSize) { TRACE_CPUPROFILER_EVENT_SCOPE(FImageWriteQueue::EnqueueWait) OnTaskCompletedEvent->Wait(); } } else if (NumPendingTasks >= MaxQueueSize) { UE_LOG(LogImageWriteQueue, Warning, TEXT("Cannot issue a new image write command because the Queue is at max capacity.")); return TFuture(); } TPromise Promise; TFuture Future = Promise.GetFuture(); // Get the fence metrics for this task uint32 ThisTaskFenceID; { FScopeLock FenceLock(&FenceMutex); ThisTaskFenceID = CurrentFenceID; ++CurrentFenceCount; } FQueuedImageWrite* NewTask = new FQueuedImageWrite(ThisTaskFenceID, this, MoveTemp(InTask), MoveTemp(Promise)); // The thread pool will be nullptr where the platform does not support multi-threading, // If so, dispatch and execute the task immediately if (!ThreadPool) { // RunTaskOnCurrentThread deletes itself NewTask->RunTaskOnCurrentThread(); // NewTask is now invalid } else { // Dispatch the queued work - must operate under a lock since the thread pool can change at runtime in response to CVar changes FScopeLock ThreadPoolLock(&ThreadPoolMutex); ThreadPool->AddQueuedWork(NewTask); } ++NumPendingTasks; return Future; } class FImageWriteQueueModule : public IImageWriteQueueModule { virtual void StartupModule() override { Queue = MakeUnique(); } virtual void PreUnloadCallback() override { Queue->BeginShutdown(); } virtual void ShutdownModule() override { Queue->BeginShutdown(); Queue.Reset(); } virtual IImageWriteQueue& GetWriteQueue() override { return *Queue; } TUniquePtr Queue; }; IMPLEMENT_MODULE(FImageWriteQueueModule, ImageWriteQueue)