From c540fb88ee726229562de26dafc80aa778f93ef6 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Mon, 27 Jul 2026 21:45:21 -0400 Subject: [PATCH] [Fix] (DirectVulkan): drain frame transients on present-less paths - readback waits, suspended presentation, blocking sync waits and flush completion polls now run Present's per-frame drains (deferred buffer/texture releases, transient arena rewind, descriptor cursors, retired command buffers, conversion caches) whenever every submission is provably complete, so offscreen/minimized workloads stay bounded; never blocks, frames-in-flight overlap untouched --- .../DirectVulkan/Renderer/FrameContext.cpp | 36 ++++++++- .../DirectVulkan/Renderer/FrameContext.h | 30 +++++-- .../DirectVulkan/Renderer/VkBufferManager.cpp | 9 +++ .../DirectVulkan/Renderer/VkBufferManager.h | 5 ++ .../Renderer/VkTextureManager.cpp | 7 ++ .../DirectVulkan/Renderer/VkTextureManager.h | 4 + .../DirectVulkan/Renderer/VulkanRenderer.cpp | 78 +++++++++++++++++-- .../DirectVulkan/Renderer/VulkanRenderer.h | 9 +++ 8 files changed, 164 insertions(+), 14 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp index 1f72dbcb..c37a6f07 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.cpp @@ -264,7 +264,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (result != VK_SUCCESS) { return result; } - frame.retiredCommandBuffers.push_back(frame.commandBuffer); + // lastSubmitIndex was just written by the renderer for the submission + // that carried this command buffer. + frame.retiredCommandBuffers.push_back({frame.commandBuffer, frame.lastSubmitIndex}); frame.commandBuffer = replacement; return VK_SUCCESS; } @@ -274,12 +276,40 @@ namespace MobileGL::MG_Backend::DirectVulkan { return; } if (m_device != VK_NULL_HANDLE && m_commandPool != VK_NULL_HANDLE) { - vkFreeCommandBuffers(m_device, m_commandPool, static_cast(frame.retiredCommandBuffers.size()), - frame.retiredCommandBuffers.data()); + for (const auto& retired : frame.retiredCommandBuffers) { + vkFreeCommandBuffers(m_device, m_commandPool, 1, &retired.commandBuffer); + } } frame.retiredCommandBuffers.clear(); } + void FrameContext::FreeRetiredCommandBuffersCompletedUpTo(Uint64 completedSubmitIndex) { + if (m_device == VK_NULL_HANDLE || m_commandPool == VK_NULL_HANDLE) { + return; + } + for (auto& frame : m_frames) { + // Retired buffers are appended in submit order, so the completed + // ones form a prefix. + SizeT completedCount = 0; + while (completedCount < frame.retiredCommandBuffers.size() && + frame.retiredCommandBuffers[completedCount].submitIndex <= completedSubmitIndex) { + vkFreeCommandBuffers(m_device, m_commandPool, 1, + &frame.retiredCommandBuffers[completedCount].commandBuffer); + ++completedCount; + } + if (completedCount > 0) { + frame.retiredCommandBuffers.erase(frame.retiredCommandBuffers.begin(), + frame.retiredCommandBuffers.begin() + completedCount); + } + } + } + + void FrameContext::FreeAllRetiredCommandBuffers() { + for (auto& frame : m_frames) { + FreeRetiredCommandBuffers(frame); + } + } + void FrameContext::AssertValidFrameIndex(Uint32 frameIndex) const { MOBILEGL_ASSERT(frameIndex < m_frames.size(), "FrameContext index out of range"); } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.h index 14dd144b..af83e379 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/FrameContext.h @@ -40,6 +40,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkPresentInfoKHR presentInfo{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR}; }; + // A command buffer submitted mid-frame (FlushPendingCommands), tagged + // with the submit-tracker index it was submitted under so it can be + // freed as soon as that submission is observed complete - without + // waiting for the slot's fence to be waited again (present-less flush + // loops never wait it). + struct RetiredCommandBuffer { + VkCommandBuffer commandBuffer = VK_NULL_HANDLE; + Uint64 submitIndex = 0; + }; + struct FrameData { VkCommandBuffer commandBuffer = VK_NULL_HANDLE; VkSemaphore imageAvailableSemaphore = VK_NULL_HANDLE; @@ -47,10 +57,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool isCommandRecording = false; Bool hasCommandBufferRecorded = false; Bool imageAvailableSemaphoreConsumed = false; - // Command buffers submitted mid-frame (FlushPendingCommands) whose - // execution is only known complete once this slot's fence has been - // waited again; freed at that point. - Vector retiredCommandBuffers; + // Command buffers submitted mid-frame (FlushPendingCommands), + // appended in submit order; freed once their submission is known + // complete (fence wait or completion poll). + Vector retiredCommandBuffers; // Submit-tracker index of this slot's most recent queue submission // (written by the renderer at submit time). Uint64 lastSubmitIndex = 0; @@ -79,9 +89,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Parks the current (already ended and submitted) command buffer on the // slot's retired list and installs a freshly allocated one, so recording // can restart while the submitted buffer is still executing. Retired - // buffers are freed after the slot's fence is next waited. + // buffers are freed after the slot's fence is next waited, or as soon + // as their submission is observed complete. VkResult RetireCurrentCommandBuffer(); + // Frees every retired command buffer whose tagged submission index is + // known complete. Driven by the renderer's submit tracker on completion + // events (fence waits and non-blocking polls), so present-less flush + // loops reclaim their buffers without any extra wait. + void FreeRetiredCommandBuffersCompletedUpTo(Uint64 completedSubmitIndex); + // Frees every slot's retired command buffers. Only valid when the + // caller has proven every queue submission complete. + void FreeAllRetiredCommandBuffers(); + Uint32 GetCurrentFrameIndex() const; Uint32 GetFrameCount() const; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp index be105242..9d2396a2 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp @@ -141,6 +141,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_transientUploadArena.BeginFrame(frameIndex); } + void VkBufferManager::CollectAllDeferredReleases() { + for (Uint32 frameIndex = 0; frameIndex < m_deferredBufferReleases.size(); ++frameIndex) { + CollectDeferredReleases(frameIndex); + } + for (Uint32 frameIndex = 0; frameIndex < m_transientUploadArena.GetFrameCount(); ++frameIndex) { + m_transientUploadArena.CollectDeferredReleases(frameIndex); + } + } + void VkBufferManager::NotifyDeviceIdle() { // Everything submitted so far has completed. Work recorded for the // current frame has not been submitted yet, so the current serial diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h index 77b2f855..aec65f53 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.h @@ -77,6 +77,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Recreate all per-frame transient arenas Bool RecreateTransientArenas(Uint32 frameCount); void BeginFrame(Uint32 frameIndex); + // Drains every frame slot's deferred buffer/resource releases (and the + // transient arena's parked superseded blocks). Only valid when the + // caller has proven every queue submission complete; used by the + // present-less frame-boundary drain. + void CollectAllDeferredReleases(); // All previously submitted GPU work has completed (vkDeviceWaitIdle). void NotifyDeviceIdle(); // A frame slot's submission fence has been waited: every serial up to diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 8cb2dad9..a21dbccd 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -629,6 +629,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { CollectDeferredReleases(frameIndex); } + void VkTextureManager::CollectAllDeferredReleases() { + const SizeT frameCount = std::min(m_deferredReleases.size(), m_deferredViewReleases.size()); + for (SizeT frameIndex = 0; frameIndex < frameCount; ++frameIndex) { + CollectDeferredReleases(static_cast(frameIndex)); + } + } + void VkTextureManager::EraseTrackedTexture(const TextureIdentity& identity) { auto resourceIt = m_textureResources.find(identity); if (resourceIt != m_textureResources.end()) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h index 79ce1c54..6bbef3fd 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.h @@ -267,6 +267,10 @@ public: Bool Initialize(const InitInfo& initInfo); void Shutdown(); void BeginFrame(Uint32 frameIndex); + // Drains every frame slot's deferred image/view releases. Only valid when + // the caller has proven every queue submission complete; used by the + // present-less frame-boundary drain. + void CollectAllDeferredReleases(); TextureResource* SyncTextureAndGetDescriptor( MG_State::GLState::ITextureObject& texture); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 21285259..b55a96e7 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -6204,12 +6204,12 @@ void main() { frame.hasCommandBufferRecorded = false; frame.isCommandRecording = false; - // The wait proved every descriptor set this slot has in flight idle; - // rewind the reuse cursors so present-less readback loops stay - // bounded (Present is the only other rewind point). - if (m_uniformManager) { - m_uniformManager->BeginFrame(m_frameContext.GetCurrentFrameIndex()); - } + // The wait proved every submission complete, so the full frame-boundary + // drain applies: descriptor cursors, transient arenas, deferred + // texture/buffer releases, retired command buffers and the converted + // vertex-stream cache all rewind here, keeping present-less readback + // loops bounded (Present is the only other drain point). + TryDrainFrameTransients(); return true; } @@ -7123,6 +7123,9 @@ void main() { } m_bufferManager.NotifyDeviceIdle(); OnSubmitsCompletedUpTo(m_submitCounter); + // The queue was just drained; take the free frame-boundary drain when + // nothing is recorded (present-less timer-query loops). No-op otherwise. + TryDrainFrameTransients(); return true; } @@ -7191,6 +7194,54 @@ void main() { vkDestroyFence(m_device, record.fence, nullptr); } } + // Mid-frame-flushed command buffers whose submission just completed can + // be freed now; present-less flush loops have no other reclaim point. + m_frameContext.FreeRetiredCommandBuffersCompletedUpTo(m_completedSubmitCounter); + } + + Bool VulkanRenderer::TryDrainFrameTransients() { + if (m_device == VK_NULL_HANDLE || m_frameContext.GetFrameCount() == 0) { + return false; + } + if (m_completedSubmitCounter != m_submitCounter) { + RefreshCompletedSubmits(); + if (m_completedSubmitCounter != m_submitCounter) { + return false; + } + } + if (HasPendingRecordedWork()) { + return false; + } + + // Every submission is complete and nothing recorded references the + // per-frame transients, so the drains Present's tail performs are safe + // here too. Raise the buffer manager's completed floor first: the + // serial inference (frameSerial - frameCount) is only justified by + // Present's slot-fence cadence, and the extra BeginFrame below would + // otherwise inflate it past reality. + m_bufferManager.NotifyDeviceIdle(); + + const Uint32 frameIndex = m_frameContext.GetCurrentFrameIndex(); + m_frameContext.FreeAllRetiredCommandBuffers(); + for (Uint32 slot = 0; slot < m_deferredDepthMipmapCleanup.size(); ++slot) { + CollectDeferredDepthMipmapCleanup(slot); + } + if (m_textureManager) { + m_textureManager->CollectAllDeferredReleases(); + m_textureManager->BeginFrame(frameIndex); + } + m_bufferManager.CollectAllDeferredReleases(); + m_bufferManager.BeginFrame(frameIndex); + // The cached conversion slices point into the transient arena the + // BeginFrame above just rewound; drop them together. + m_convertedVertexStreams.clear(); + if (m_uniformManager) { + m_uniformManager->BeginFrame(frameIndex); + } + if (m_renderPassManager) { + m_renderPassManager->OnPresent(); + } + return true; } VkFence VulkanRenderer::AcquirePooledSubmitFence() { @@ -7253,6 +7304,10 @@ void main() { if (m_device == VK_NULL_HANDLE || m_graphicsQueue == VK_NULL_HANDLE || m_frameContext.GetFrameCount() == 0) { return false; } + // Non-blocking completion poll: gives flush-only workloads (no sync + // objects, no present) a point where finished submissions retire their + // pooled fences and mid-frame command buffers. + RefreshCompletedSubmits(); auto& frame = m_frameContext.GetCurrent(); if (!frame.isCommandRecording && !frame.hasCommandBufferRecorded) { return false; @@ -7328,6 +7383,10 @@ void main() { const VkResult result = vkWaitForFences(m_device, 1, &record.fence, VK_TRUE, timeoutNs); if (result == VK_SUCCESS) { OnSubmitsCompletedUpTo(record.submitIndex); + // The wait already stalled the pipeline; if it happens to + // have drained everything (present-less fence loops), take + // the free frame-boundary drain. No-op otherwise. + TryDrainFrameTransients(); return true; } if (result != VK_TIMEOUT) { @@ -7417,6 +7476,13 @@ void main() { suspendedFrame.isCommandRecording = false; suspendedFrame.hasCommandBufferRecorded = false; m_lastPipelineValid = false; + // The dropped recording is never submitted, so once the fence + // poll shows the pre-suspension submissions complete the frame + // transients (descriptor sets, transient arenas, deferred + // releases, conversion caches) can rewind; without this a + // minimized-window app accumulates them for the whole + // suspension. + TryDrainFrameTransients(); MGLOG_D("Present skipped: no usable swapchain (zero-area window)"); return; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 6a92abe0..b1c8cc1b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -345,6 +345,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkFence AcquirePooledSubmitFence(); void DestroySubmitFencePool(); Bool HasPendingRecordedWork() const; + // Frame-boundary housekeeping for paths that never reach Present's + // tail (present-less readback loops, suspended presentation, blocking + // sync waits): runs the same per-frame drains Present performs, but + // only when every queue submission has been observed complete AND no + // recorded-but-unsubmitted commands exist - i.e. when CPU-GPU overlap + // is provably already zero. Never blocks (non-blocking fence poll + // only), so the presenting path's frames-in-flight pipelining is + // untouched. Returns true when the drain ran. + Bool TryDrainFrameTransients(); Vector m_inFlightSubmits; Vector m_freeSubmitFences;