diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp index 63bf8365..e11183f8 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp @@ -8,6 +8,7 @@ #include "PipelineFactory.h" +#include namespace MobileGL::MG_Backend::DirectVulkan { static const char* PrimitiveTopologyToString(VkPrimitiveTopology topology) { @@ -297,10 +298,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { return evicted; } - Uint32 PipelineFactory::EvictByRenderPass(VkRenderPass renderPass) { + Uint32 PipelineFactory::EvictByRenderPasses(const Vector& renderPasses) { + if (renderPasses.empty() || m_cache.empty()) { + return 0; + } + // Sorted-batch membership test keeps a mass eviction (shader-pack switch, + // dimension exit) at one O(cache * log batch) scan instead of one full scan + // per dying pass. + Vector sortedPasses = renderPasses; + std::sort(sortedPasses.begin(), sortedPasses.end()); Uint32 evicted = 0; for (auto it = m_cache.begin(); it != m_cache.end();) { - if (it->second.renderPass == renderPass) { + if (std::binary_search(sortedPasses.begin(), sortedPasses.end(), it->second.renderPass)) { if (it->second.pipeline != VK_NULL_HANDLE) { vkDestroyPipeline(m_device, it->second.pipeline, nullptr); } @@ -311,8 +320,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } if (evicted > 0) { - MGLOG_D("PipelineFactory::EvictByRenderPass: evicted %u pipelines for destroyed render pass", - evicted); + MGLOG_D("PipelineFactory::EvictByRenderPasses: evicted %u pipelines for %zu destroyed render passes", + evicted, sortedPasses.size()); } return evicted; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h index 59e1a3be..292c98fa 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h @@ -70,15 +70,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { // VkRenderPassManager::OnPresent's sweep. Returns the number of pipelines // destroyed so the caller can drop any memoized VkPipeline handle. Uint32 OnFrameBoundary(); - // Destroys every cached pipeline hashed on `renderPass`. Only safe when the - // caller guarantees GPU idleness for them - the render-pass manager calls this - // (via the renderer) for passes its own >1024-boundary-idle sweep just evicted, - // and a pipeline hashed on that handle is only ever bound by draws that also - // hit the render-pass entry. Also closes the handle-recycling hazard: a - // recycled VkRenderPass value must never serve a stale pipeline. Returns the - // number destroyed (callers invalidate memos when non-zero); linear scan is - // fine, evictions are rare. - Uint32 EvictByRenderPass(VkRenderPass renderPass); + // Destroys every cached pipeline hashed on one of `renderPasses`. Only safe + // when the caller guarantees GPU idleness for them - the render-pass manager + // calls this (via the renderer) for passes its own >1024-boundary-idle sweep + // just evicted, and a pipeline hashed on those handles is only ever bound by + // draws that also hit the render-pass entries. Also closes the handle-recycling + // hazard: a recycled VkRenderPass value must never serve a stale pipeline. + // Batched: one cache scan regardless of how many passes died in the sweep. + // Returns the number destroyed (callers invalidate memos when non-zero). + Uint32 EvictByRenderPasses(const Vector& renderPasses); // Destroys every cached pipeline built from the program with content hash // `programHash`. Called from the ProgramFactory eviction path, which proves the // same >1024-boundary idleness (the program's pipelines are only bound by draws diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index ed32b6ca..2c068bdb 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -171,7 +171,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_programFactory = nullptr; m_device = VK_NULL_HANDLE; m_minDynamicOffsetAlignment = 1; - m_frameCounter = 0; m_frameCount = 0; m_maxBindings = 0; m_setsPerFrame = 0; @@ -216,49 +215,33 @@ namespace MobileGL::MG_Backend::DirectVulkan { SizeT purgedSets = 0; for (auto& frame : m_frames) { const auto it = frame.descriptorSetCacheByLayout.find(descriptorSetLayout); - if (it != frame.descriptorSetCacheByLayout.end()) { - purgedSets += it->second.sets.size(); - frame.descriptorSetCacheByLayout.erase(it); + if (it == frame.descriptorSetCacheByLayout.end()) { + continue; } + // Free the sets back to their pools and credit the bucket accounting, so + // program churn recycles pool capacity instead of abandoning the slots. + // GPU-safe: the layout only dies after >1024 idle frame boundaries, so no + // in-flight command buffer references these sets. + for (const auto& cached : it->second.sets) { + if (cached.set == VK_NULL_HANDLE) { + continue; + } + vkFreeDescriptorSets(m_device, cached.pool, 1, &cached.set); + const auto bucket = std::find_if( + frame.descriptorPools.begin(), frame.descriptorPools.end(), + [&cached](const DescriptorPoolBucket& candidate) { return candidate.handle == cached.pool; }); + if (bucket != frame.descriptorPools.end() && bucket->allocatedSets > 0) { + --bucket->allocatedSets; + } + } + purgedSets += it->second.sets.size(); + frame.descriptorSetCacheByLayout.erase(it); } if (purgedSets > 0) { // The per-draw reuse memo folds the layout handle into its signature; drop // it so a recycled handle value cannot revive a purged set mid-frame. m_hasLastDescriptor = false; - MGLOG_D("UniformDescriptorBinder: purged %zu descriptor sets for destroyed layout", purgedSets); - } - } - - void UniformManager::OnFrameBoundary() { - ++m_frameCounter; - - // Sweep cadence and retire age mirror VkRenderPassManager::OnPresent. Only the - // CPU-side tracking is reclaimed here: the sets' pool slots stay occupied until - // Shutdown destroys the pools (no FREE_DESCRIPTOR_SET_BIT, no mid-life pool - // reset), exactly as they would had the entry been kept. What the sweep buys is - // that an idle layout's tracking vectors stop accumulating, and that a - // destroyed-then-recycled layout handle finds no stale entry to hit. - constexpr Uint64 kSweepInterval = 256; - constexpr Uint64 kRetireAgeFrames = 1024; - if ((m_frameCounter % kSweepInterval) != 0) { - return; - } - - SizeT purgedSets = 0; - for (auto& frame : m_frames) { - for (auto it = frame.descriptorSetCacheByLayout.begin(); - it != frame.descriptorSetCacheByLayout.end();) { - if (m_frameCounter - it->second.lastUsedFrame > kRetireAgeFrames) { - purgedSets += it->second.sets.size(); - it = frame.descriptorSetCacheByLayout.erase(it); - } else { - ++it; - } - } - } - if (purgedSets > 0) { - m_hasLastDescriptor = false; - MGLOG_D("UniformDescriptorBinder::OnFrameBoundary: dropped %zu idle descriptor sets", purgedSets); + MGLOG_D("UniformDescriptorBinder: freed %zu descriptor sets for destroyed layout", purgedSets); } } @@ -962,6 +945,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + // FREE_DESCRIPTOR_SET_BIT lets a destroyed layout's cached sets be freed back + // (OnDescriptorSetLayoutDestroyed) so program churn recycles pool capacity. + // The cost is on set allocation only, which happens when a layout's per-frame + // cache grows - never on the per-draw reuse path. + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = maxSets; poolInfo.poolSizeCount = static_cast(std::size(poolSizes)); poolInfo.pPoolSizes = poolSizes; @@ -1040,12 +1028,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkDescriptorSet& outDescriptorSet) { auto& frame = m_frames[frameIndex]; auto& cache = frame.descriptorSetCacheByLayout[programObj.descriptorSetLayout]; - // Every layout used in a frame is acquired at least once (the per-draw - // descriptor-reuse memo starts each frame invalidated and folds the layout - // into its signature), so an actively-used entry is stamped every frame. - cache.lastUsedFrame = m_frameCounter; if (cache.cursor < cache.sets.size()) { - outDescriptorSet = cache.sets[cache.cursor++]; + outDescriptorSet = cache.sets[cache.cursor++].set; } else { VkResult allocResult = AllocateDescriptorSetsFromActivePool(frameIndex, programObj, outDescriptorSet); if (allocResult == VK_ERROR_OUT_OF_POOL_MEMORY || allocResult == VK_ERROR_FRAGMENTED_POOL) { @@ -1059,7 +1043,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { return allocResult; } - cache.sets.push_back(outDescriptorSet); + // The successful allocation came from the bucket the alloc helper left + // active; record it so a layout-destroyed purge can free the set back. + cache.sets.push_back({outDescriptorSet, frame.descriptorPools[frame.activeDescriptorPoolIndex].handle}); ++cache.cursor; MGLOG_D("UniformDescriptorBinder: cached descriptor set count for frame=%u grew to %zu", frameIndex, cache.sets.size()); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index bc92f56f..82187acf 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -41,16 +41,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { void BeginFrame(Uint32 frameIndex); // A ProgramFactory eviction just destroyed this layout: purge every frame // slot's cached descriptor sets for it, so a recycled handle value can never - // stale-hit sets written for the dead layout's bindings. The sets themselves - // are only dropped, never freed - the frame pools are created without - // FREE_DESCRIPTOR_SET_BIT and are never reset mid-life, so their pool slots - // stay occupied until Shutdown destroys the pools wholesale. + // stale-hit sets written for the dead layout's bindings. The sets are + // vkFreeDescriptorSets'd back to their pools (created with + // FREE_DESCRIPTOR_SET_BIT) and the pool accounting is credited, so program + // churn recycles pool capacity instead of abandoning it. GPU-safe: the layout + // only dies after >1024 idle frame boundaries, so no in-flight command buffer + // references its sets. This is the only eviction path for the per-layout + // caches - a live layout's entry must never be purged (its sets would be + // unreachable pool slots), so there is deliberately no age-based sweep here. void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout); - // Frame boundary hook: ages the per-layout descriptor-set caches and drops - // long-unused entries (mirroring VkRenderPassManager::OnPresent's sweep), so a - // shader-churn session's peak layout population does not pin its tracking - // vectors forever. Same pool-slot caveat as above. - void OnFrameBoundary(); Bool CollectSampledTextures(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Vector& outTextures); @@ -77,12 +76,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 allocatedSets = 0; }; + // A cached descriptor set together with the pool it was allocated from, so a + // layout-destroyed purge can vkFreeDescriptorSets it back and credit the + // owning bucket's accounting. + struct CachedDescriptorSet { + VkDescriptorSet set = VK_NULL_HANDLE; + VkDescriptorPool pool = VK_NULL_HANDLE; + }; + struct DescriptorSetCacheEntry { - Vector sets; + Vector sets; Uint32 cursor = 0; - // Frame-boundary counter value of the last AcquireDescriptorSet hit; drives - // cache eviction (see OnFrameBoundary). - Uint64 lastUsedFrame = 0; }; struct FrameResources { @@ -146,9 +150,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { Vector m_frames; VkDeviceSize m_minDynamicOffsetAlignment = 1; - // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for - // descriptor-set cache aging. - Uint64 m_frameCounter = 0; Uint32 m_frameCount = 0; Uint32 m_maxBindings = 0; Uint32 m_setsPerFrame = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp index 71432706..1a97ff3d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp @@ -207,6 +207,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource.Destroy(m_device, m_allocator); } m_renderbufferResources.clear(); + CollectDeferredRenderbufferReleases(/*destroyAll=*/true); // caller guarantees device idle m_pendingRenderbufferClears.clear(); RenderPassEntry::s_textureResourcesScratch.clear(); s_activeRenderPass = {}; @@ -214,19 +215,55 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_rpFastValid = false; } + Uint64 VkRenderPassManager::RetireAgeFrames() const { + // MaxFramesInFlight + 2 covers the frame ring plus one boundary for the + // recording-to-submit gap and one because OnPresent runs ahead of Present's + // fence wait; the floor of 8 keeps a margin over the default ring of 3 while + // still releasing multi-MB attachment memory promptly (the render-pass cache's + // 1024-frame retirement would pin it for no additional safety). + return std::max(8, static_cast(m_config.MaxFramesInFlight) + 2); + } + + void VkRenderPassManager::DeferRenderbufferBackingRelease(RenderbufferResource& resource) { + // The superseded backing may still be referenced by in-flight command buffers + // (glRenderbufferStorage can respecify a renderbuffer drawn this very frame), + // so it is parked and destroyed only after RetireAgeFrames() boundaries. + if (resource.image == VK_NULL_HANDLE && resource.view == VK_NULL_HANDLE) { + return; + } + m_deferredRenderbufferReleases.push_back({resource.image, resource.allocation, resource.view, m_frameCounter}); + resource.image = VK_NULL_HANDLE; + resource.allocation = nullptr; + resource.view = VK_NULL_HANDLE; + } + + void VkRenderPassManager::CollectDeferredRenderbufferReleases(Bool destroyAll) { + if (m_deferredRenderbufferReleases.empty()) { + return; + } + const Uint64 retireAgeFrames = RetireAgeFrames(); + std::erase_if(m_deferredRenderbufferReleases, [&](DeferredRenderbufferRelease& release) { + if (!destroyAll && m_frameCounter - release.deferredAtFrame < retireAgeFrames) { + return false; + } + if (release.view != VK_NULL_HANDLE) { + vkDestroyImageView(m_device, release.view, nullptr); + } + if (release.image != VK_NULL_HANDLE) { + vmaDestroyImage(m_allocator, release.image, release.allocation); + } + return true; + }); + } + void VkRenderPassManager::CollectRenderbufferGarbage() { // Two-phase reclamation: a dead renderbuffer's VkImage may still be referenced by // command buffers submitted up to frames-in-flight frames ago (it was legally // attached and drawn right up to its deletion), so the first observation of an // expired weak reference only stamps the current frame counter; Destroy runs once // enough frame boundaries have passed that the stamping frame's submission fence - // has provably been waited. MaxFramesInFlight + 2 covers the frame ring plus one - // boundary for the recording-to-submit gap and one because OnPresent runs ahead - // of Present's fence wait; the floor of 8 keeps a margin over the default ring of - // 3 while still releasing multi-MB attachment memory promptly (the render-pass - // cache's 1024-frame retirement would pin it for no additional safety). - const Uint64 retireAgeFrames = - std::max(8, static_cast(m_config.MaxFramesInFlight) + 2); + // has provably been waited (see RetireAgeFrames). + const Uint64 retireAgeFrames = RetireAgeFrames(); for (auto it = m_renderbufferResources.begin(); it != m_renderbufferResources.end();) { auto& resource = it->second; const auto liveRenderbuffer = resource.renderbuffer.lock(); @@ -294,6 +331,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { return &resource; } + // Respecify: park the old backing for aged destruction instead of destroying + // inline - it may still be referenced by in-flight command buffers. + DeferRenderbufferBackingRelease(resource); resource.Destroy(m_device, m_allocator); resource.renderbuffer = renderbuffer; @@ -1205,6 +1245,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // coupling it to renderbuffer *use* (the GetOrCreateRenderbufferResource call // site never runs again once an app stops using renderbuffers). CollectRenderbufferGarbage(); + CollectDeferredRenderbufferReleases(/*destroyAll=*/false); // Sweep occasionally; evict entries whose last use is far past every // in-flight frame so their VkRenderPass/VkFramebuffer can be destroyed @@ -1215,6 +1256,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { return; } + // Collect the dying handles and notify once after the loop: pipelines hashed + // on them share the entries' >kRetireAgeFrames idleness (they are only bound + // by draws that hit those entries), so the observer may destroy them + // immediately - and a single batched notification costs one pipeline-cache + // scan instead of one per evicted pass. + Vector destroyedRenderPasses; const Uint64 activeHash = s_hasActiveRenderPass ? s_activeRenderPass.hash : 0; for (auto it = m_renderPasses.begin(); it != m_renderPasses.end();) { const Bool isActive = s_hasActiveRenderPass && it->first == activeHash; @@ -1222,17 +1269,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (m_rpFastValid && m_rpFastRenderPassHash == it->first) { m_rpFastValid = false; } - // Notify while the handle is still alive: pipelines hashed on it share - // the entry's >kRetireAgeFrames idleness (they are only bound by draws - // that hit this entry), so the observer may destroy them immediately. - if (m_evictionObserver != nullptr) { - m_evictionObserver->OnRenderPassDestroyed(it->second.renderPass); - } + destroyedRenderPasses.push_back(it->second.renderPass); it = m_renderPasses.erase(it); } else { ++it; } } + if (!destroyedRenderPasses.empty() && m_evictionObserver != nullptr) { + m_evictionObserver->OnRenderPassesDestroyed(destroyedRenderPasses); + } } Bool VkRenderPassManager::BeginRenderPass(VkCommandBuffer commandBuffer, RenderPassEntry& renderPassEntry) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h index cdddd28a..dfd5f132 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h @@ -158,16 +158,20 @@ namespace MobileGL::MG_Backend::DirectVulkan { public: using HashType = Uint64; - // Notified from the OnPresent sweep for each aged-out entry, BEFORE the entry - // destructor destroys its VkRenderPass: pipelines are hashed on the raw handle, - // and once destroyed the value may be recycled for an incompatible pass, so - // dependent caches must purge everything keyed on it in the same step. The - // wholesale paths (Shutdown/RecreateSwapchain) do not notify - their callers - // already drop every pipeline outright. + // Notified once per OnPresent sweep with every aged-out entry's VkRenderPass + // value: pipelines are hashed on the raw handle, and once destroyed the value + // may be recycled for an incompatible pass, so dependent caches must purge + // everything keyed on them before any new pass can be created (the sweep and + // the notification run back-to-back with no creation in between; observers + // compare the values, never dereference them). Batched so a mass-idle cohort + // (shader-pack switch, dimension exit) costs the observer one pipeline-cache + // scan, not one per dying pass. The wholesale paths + // (Shutdown/RecreateSwapchain) do not notify - their callers already drop + // every pipeline outright. class IEvictionObserver { public: virtual ~IEvictionObserver() = default; - virtual void OnRenderPassDestroyed(VkRenderPass renderPass) = 0; + virtual void OnRenderPassesDestroyed(const Vector& renderPasses) = 0; }; VkRenderPassManager(VkDevice device, @@ -266,12 +270,28 @@ namespace MobileGL::MG_Backend::DirectVulkan { ClearAttachmentPayload payload{}; }; + // A superseded renderbuffer backing (glRenderbufferStorage respecify) parked + // until enough frame boundaries have passed that no in-flight command buffer + // can still reference it; destroyed in OnPresent (see RetireAgeFrames). + struct DeferredRenderbufferRelease { + VkImage image = VK_NULL_HANDLE; + VmaAllocation allocation = nullptr; + VkImageView view = VK_NULL_HANDLE; + Uint64 deferredAtFrame = 0; + }; + UnorderedMap m_renderbufferResources; UnorderedMap m_pendingRenderbufferClears; + Vector m_deferredRenderbufferReleases; Bool HasPendingRenderbufferClear( const MG_State::GLState::FramebufferAttachmentObject& attachment) const; void CollectRenderbufferGarbage(); + // Frame-boundary margin after which a resource last referenced by a retired + // GL object (or superseded backing) is provably past every in-flight frame. + Uint64 RetireAgeFrames() const; + void DeferRenderbufferBackingRelease(RenderbufferResource& resource); + void CollectDeferredRenderbufferReleases(Bool destroyAll); static inline XXH64_state_t* m_hashState = XXH64_createState(); static inline ActiveRenderPassInfo s_activeRenderPass{}; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 268ba038..63154d02 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -7228,11 +7228,10 @@ void main() { } // 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. + // per-frame transients. Pure-reclaim work runs on every drain: it only + // releases memory that is provably dead, never invalidates anything a + // later draw would have to rebuild. Raise the buffer manager's + // completed floor first so busy-tracking reflects the proven idleness. m_bufferManager.NotifyDeviceIdle(); const Uint32 frameIndex = m_frameContext.GetCurrentFrameIndex(); @@ -7242,23 +7241,38 @@ void main() { } if (m_textureManager) { m_textureManager->CollectAllDeferredReleases(); - m_textureManager->BeginFrame(frameIndex); } m_bufferManager.CollectAllDeferredReleases(); + // Descriptor cursors rewind on every drain (the pre-drain readback path + // already did exactly this), keeping fence/readback loops' set usage bounded. + if (m_uniformManager) { + m_uniformManager->BeginFrame(frameIndex); + } + + // Frame-boundary-equivalent work - transient arena rewind (which invalidates + // the conversion cache) and the cache-aging clocks - is gated to every 8th + // drain since the last Present: a presenting app's mid-frame readbacks/waits + // must neither force re-conversion/re-upload churn for the rest of the frame + // nor multiply the aging rate (which would shrink the 1024-boundary retire + // window and thrash periodically-used pipelines/programs), while present-less + // loops still rewind the arena and age their caches every 8 iterations - + // bounded by 8 iterations' transient usage. + ++m_drainsSinceLastPresent; + if ((m_drainsSinceLastPresent % 8) != 0) { + return true; + } + if (m_textureManager) { + m_textureManager->BeginFrame(frameIndex); + } 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(); } - // Present-less loops cross frame boundaries here, so the content-addressed - // caches age on the same cadence as Present's tail. The pipeline memo can - // survive across these boundaries (no per-frame reset on this path), so it - // must drop whenever the sweep destroys anything. + // The pipeline memo can survive across these boundaries (no per-frame reset + // on this path), so it must drop whenever the sweep destroys anything. if (m_programFactory) { m_programFactory->OnFrameBoundary(); } @@ -7266,9 +7280,6 @@ void main() { m_lastPipelineValid = false; m_lastPipelineResult = VK_NULL_HANDLE; } - if (m_uniformManager) { - m_uniformManager->OnFrameBoundary(); - } if (m_vertexInputStateFactory) { m_vertexInputStateFactory->OnFrameBoundary(); } @@ -7366,6 +7377,14 @@ void main() { return false; } + // Command-buffer boundary: the pipeline memo must not survive it, or a + // pipeline bound only through memo hits is never re-stamped in the factory + // cache and the aging sweep could destroy it while the flushed submission + // still references it. Mirrors the drops at the readback and Present + // boundaries; costs one full pipeline lookup on the next draw. + m_lastPipelineValid = false; + m_lastPipelineResult = VK_NULL_HANDLE; + // The submitted command buffer may still be executing; recording must // restart on a fresh one. If none can be allocated, fall back to // draining this submission so reusing the buffer stays legal. @@ -7530,16 +7549,20 @@ void main() { MOBILEGL_ASSERT(m_imageIndexAcquired < m_swapchainObject.GetImageCount(), "Present, acquired image index out of range"); m_renderPassManager->OnPresent(); + // A real presented frame is the canonical aging cadence; mid-frame drains + // count against this and only age when presents stop coming. + m_drainsSinceLastPresent = 0; // Age the content-addressed caches on the same frame-boundary cadence. Each // keeps its own internal 256-sweep gate, so the per-frame cost is one counter // increment and compare per cache; entries used by this frame's still- - // unsubmitted recording were stamped this boundary and can never age out. + // unsubmitted recording were stamped this boundary (every command-buffer + // boundary drops the pipeline memo, so the first draw of each recording + // performs a real, stamping lookup) and can never age out. m_programFactory->OnFrameBoundary(); if (m_pipelineFactory->OnFrameBoundary() > 0) { m_lastPipelineValid = false; // an aged-out pipeline may still be memoized m_lastPipelineResult = VK_NULL_HANDLE; } - m_uniformManager->OnFrameBoundary(); m_vertexInputStateFactory->OnFrameBoundary(); m_samplerManager->OnFrameBoundary(); auto& frame = m_frameContext.GetCurrent(); @@ -8574,15 +8597,15 @@ void main() { m_computePipelines.clear(); } - void VulkanRenderer::OnRenderPassDestroyed(VkRenderPass renderPass) { + void VulkanRenderer::OnRenderPassesDestroyed(const Vector& renderPasses) { if (m_pipelineFactory == nullptr) { return; } // The render-pass sweep's >1024-boundary idle guarantee covers these pipelines - // too (they are only bound by draws that hit the dying entry), so the factory + // too (they are only bound by draws that hit the dying entries), so the factory // destroys them immediately. The memo must drop as well: it can hand out a // cached handle without touching the factory. - if (m_pipelineFactory->EvictByRenderPass(renderPass) > 0) { + if (m_pipelineFactory->EvictByRenderPasses(renderPasses) > 0) { m_lastPipelineValid = false; m_lastPipelineResult = VK_NULL_HANDLE; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index bdf7c5a5..34f7be82 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -134,11 +134,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // recording, before any render pass. void OnFrameCommandRecordingBegan(VkCommandBuffer commandBuffer) override; - // VkRenderPassManager::IEvictionObserver: the render-pass aging sweep is - // destroying a VkRenderPass; evict every graphics pipeline hashed on the + // VkRenderPassManager::IEvictionObserver: the render-pass aging sweep just + // destroyed these VkRenderPasses; evict every graphics pipeline hashed on a // dying handle (they share its >1024-boundary idleness, so immediate // destruction is safe) and drop the last-pipeline memo if any went. - void OnRenderPassDestroyed(VkRenderPass renderPass) override; + void OnRenderPassesDestroyed(const Vector& renderPasses) override; // ProgramFactory::IEvictionObserver: an aged-out program entry was // destroyed; evict its compute pipeline and graphics pipelines (same @@ -376,6 +376,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { Vector m_freeSubmitFences; Uint64 m_submitCounter = 0; Uint64 m_completedSubmitCounter = 0; + // Drains since the last Present, gating the drain's frame-boundary-equivalent + // work (arena rewind + cache aging): a presenting app's mid-frame + // readbacks/waits must neither churn the transient caches nor accelerate the + // aging clocks, while present-less loops still cross a boundary every few + // iterations. Reset in Present. + Uint32 m_drainsSinceLastPresent = 0; NativeWindowType m_window = 0; void* m_platformDisplay = nullptr;