[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

This commit is contained in:
2026-07-27 21:45:21 -04:00
parent 6ae3245a0d
commit c540fb88ee
8 changed files with 164 additions and 14 deletions
@@ -264,7 +264,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (result != VK_SUCCESS) { if (result != VK_SUCCESS) {
return result; 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; frame.commandBuffer = replacement;
return VK_SUCCESS; return VK_SUCCESS;
} }
@@ -274,12 +276,40 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return; return;
} }
if (m_device != VK_NULL_HANDLE && m_commandPool != VK_NULL_HANDLE) { if (m_device != VK_NULL_HANDLE && m_commandPool != VK_NULL_HANDLE) {
vkFreeCommandBuffers(m_device, m_commandPool, static_cast<Uint32>(frame.retiredCommandBuffers.size()), for (const auto& retired : frame.retiredCommandBuffers) {
frame.retiredCommandBuffers.data()); vkFreeCommandBuffers(m_device, m_commandPool, 1, &retired.commandBuffer);
}
} }
frame.retiredCommandBuffers.clear(); 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 { void FrameContext::AssertValidFrameIndex(Uint32 frameIndex) const {
MOBILEGL_ASSERT(frameIndex < m_frames.size(), "FrameContext index out of range"); MOBILEGL_ASSERT(frameIndex < m_frames.size(), "FrameContext index out of range");
} }
@@ -40,6 +40,16 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkPresentInfoKHR presentInfo{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR}; 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 { struct FrameData {
VkCommandBuffer commandBuffer = VK_NULL_HANDLE; VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
VkSemaphore imageAvailableSemaphore = VK_NULL_HANDLE; VkSemaphore imageAvailableSemaphore = VK_NULL_HANDLE;
@@ -47,10 +57,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool isCommandRecording = false; Bool isCommandRecording = false;
Bool hasCommandBufferRecorded = false; Bool hasCommandBufferRecorded = false;
Bool imageAvailableSemaphoreConsumed = false; Bool imageAvailableSemaphoreConsumed = false;
// Command buffers submitted mid-frame (FlushPendingCommands) whose // Command buffers submitted mid-frame (FlushPendingCommands),
// execution is only known complete once this slot's fence has been // appended in submit order; freed once their submission is known
// waited again; freed at that point. // complete (fence wait or completion poll).
Vector<VkCommandBuffer> retiredCommandBuffers; Vector<RetiredCommandBuffer> retiredCommandBuffers;
// Submit-tracker index of this slot's most recent queue submission // Submit-tracker index of this slot's most recent queue submission
// (written by the renderer at submit time). // (written by the renderer at submit time).
Uint64 lastSubmitIndex = 0; Uint64 lastSubmitIndex = 0;
@@ -79,9 +89,19 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Parks the current (already ended and submitted) command buffer on the // Parks the current (already ended and submitted) command buffer on the
// slot's retired list and installs a freshly allocated one, so recording // slot's retired list and installs a freshly allocated one, so recording
// can restart while the submitted buffer is still executing. Retired // 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(); 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 GetCurrentFrameIndex() const;
Uint32 GetFrameCount() const; Uint32 GetFrameCount() const;
@@ -141,6 +141,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
m_transientUploadArena.BeginFrame(frameIndex); 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() { void VkBufferManager::NotifyDeviceIdle() {
// Everything submitted so far has completed. Work recorded for the // Everything submitted so far has completed. Work recorded for the
// current frame has not been submitted yet, so the current serial // current frame has not been submitted yet, so the current serial
@@ -77,6 +77,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Recreate all per-frame transient arenas // Recreate all per-frame transient arenas
Bool RecreateTransientArenas(Uint32 frameCount); Bool RecreateTransientArenas(Uint32 frameCount);
void BeginFrame(Uint32 frameIndex); 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). // All previously submitted GPU work has completed (vkDeviceWaitIdle).
void NotifyDeviceIdle(); void NotifyDeviceIdle();
// A frame slot's submission fence has been waited: every serial up to // A frame slot's submission fence has been waited: every serial up to
@@ -629,6 +629,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
CollectDeferredReleases(frameIndex); 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<Uint32>(frameIndex));
}
}
void VkTextureManager::EraseTrackedTexture(const TextureIdentity& identity) { void VkTextureManager::EraseTrackedTexture(const TextureIdentity& identity) {
auto resourceIt = m_textureResources.find(identity); auto resourceIt = m_textureResources.find(identity);
if (resourceIt != m_textureResources.end()) { if (resourceIt != m_textureResources.end()) {
@@ -267,6 +267,10 @@ public:
Bool Initialize(const InitInfo& initInfo); Bool Initialize(const InitInfo& initInfo);
void Shutdown(); void Shutdown();
void BeginFrame(Uint32 frameIndex); 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( TextureResource* SyncTextureAndGetDescriptor(
MG_State::GLState::ITextureObject& texture); MG_State::GLState::ITextureObject& texture);
@@ -6204,12 +6204,12 @@ void main() {
frame.hasCommandBufferRecorded = false; frame.hasCommandBufferRecorded = false;
frame.isCommandRecording = false; frame.isCommandRecording = false;
// The wait proved every descriptor set this slot has in flight idle; // The wait proved every submission complete, so the full frame-boundary
// rewind the reuse cursors so present-less readback loops stay // drain applies: descriptor cursors, transient arenas, deferred
// bounded (Present is the only other rewind point). // texture/buffer releases, retired command buffers and the converted
if (m_uniformManager) { // vertex-stream cache all rewind here, keeping present-less readback
m_uniformManager->BeginFrame(m_frameContext.GetCurrentFrameIndex()); // loops bounded (Present is the only other drain point).
} TryDrainFrameTransients();
return true; return true;
} }
@@ -7123,6 +7123,9 @@ void main() {
} }
m_bufferManager.NotifyDeviceIdle(); m_bufferManager.NotifyDeviceIdle();
OnSubmitsCompletedUpTo(m_submitCounter); 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; return true;
} }
@@ -7191,6 +7194,54 @@ void main() {
vkDestroyFence(m_device, record.fence, nullptr); 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() { VkFence VulkanRenderer::AcquirePooledSubmitFence() {
@@ -7253,6 +7304,10 @@ void main() {
if (m_device == VK_NULL_HANDLE || m_graphicsQueue == VK_NULL_HANDLE || m_frameContext.GetFrameCount() == 0) { if (m_device == VK_NULL_HANDLE || m_graphicsQueue == VK_NULL_HANDLE || m_frameContext.GetFrameCount() == 0) {
return false; 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(); auto& frame = m_frameContext.GetCurrent();
if (!frame.isCommandRecording && !frame.hasCommandBufferRecorded) { if (!frame.isCommandRecording && !frame.hasCommandBufferRecorded) {
return false; return false;
@@ -7328,6 +7383,10 @@ void main() {
const VkResult result = vkWaitForFences(m_device, 1, &record.fence, VK_TRUE, timeoutNs); const VkResult result = vkWaitForFences(m_device, 1, &record.fence, VK_TRUE, timeoutNs);
if (result == VK_SUCCESS) { if (result == VK_SUCCESS) {
OnSubmitsCompletedUpTo(record.submitIndex); 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; return true;
} }
if (result != VK_TIMEOUT) { if (result != VK_TIMEOUT) {
@@ -7417,6 +7476,13 @@ void main() {
suspendedFrame.isCommandRecording = false; suspendedFrame.isCommandRecording = false;
suspendedFrame.hasCommandBufferRecorded = false; suspendedFrame.hasCommandBufferRecorded = false;
m_lastPipelineValid = 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)"); MGLOG_D("Present skipped: no usable swapchain (zero-area window)");
return; return;
} }
@@ -345,6 +345,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkFence AcquirePooledSubmitFence(); VkFence AcquirePooledSubmitFence();
void DestroySubmitFencePool(); void DestroySubmitFencePool();
Bool HasPendingRecordedWork() const; 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<SubmitRecord> m_inFlightSubmits; Vector<SubmitRecord> m_inFlightSubmits;
Vector<VkFence> m_freeSubmitFences; Vector<VkFence> m_freeSubmitFences;