[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) {
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<Uint32>(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");
}
@@ -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<VkCommandBuffer> retiredCommandBuffers;
// Command buffers submitted mid-frame (FlushPendingCommands),
// appended in submit order; freed once their submission is known
// complete (fence wait or completion poll).
Vector<RetiredCommandBuffer> 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;
@@ -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
@@ -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
@@ -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<Uint32>(frameIndex));
}
}
void VkTextureManager::EraseTrackedTexture(const TextureIdentity& identity) {
auto resourceIt = m_textureResources.find(identity);
if (resourceIt != m_textureResources.end()) {
@@ -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);
@@ -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;
}
@@ -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<SubmitRecord> m_inFlightSubmits;
Vector<VkFence> m_freeSubmitFences;