diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index 8020d113..4723c769 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -469,6 +469,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // treat them as signaled/available with zero results from here on. BumpRendererGeneration(); pVulkanRenderer.reset(); + // The reflection cache is file-scope, not renderer-owned; without this the + // deleted programs' reflection strings survive full context teardown. + ClearProgramResourceCaches(); BackendObject::ReleaseEGLResources(); } @@ -478,6 +481,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // treat them as signaled/available with zero results from here on. BumpRendererGeneration(); pVulkanRenderer.reset(); + // The reflection cache is file-scope, not renderer-owned; without this the + // deleted programs' reflection strings survive full context teardown. + ClearProgramResourceCaches(); } const RendererInfo& BackendObject_DirectVulkan::GetRendererInfo() const { diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index f9abfddb..ac69853b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -61,6 +61,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { }; struct ProgramResourceCache { + // Lifetime id of the program the cached reflection belongs to. GL names are + // recycled (IndexGenerator hands freed indices straight back), and a + // recreated program's backendStateVersion restarts at the same small values, + // so the version alone can collide; the never-reused lifetime id makes the + // slot's ownership unambiguous. + Uint64 programLifetimeId = 0; Uint32 backendStateVersion = 0; Vector storageBlocks; Vector bufferVariables; @@ -82,6 +88,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 baseInstance = 0; }; + // Keyed by GL program name so the freed-name reuse in IndexGenerator bounds the + // map at the peak-simultaneous-program high-water mark; each slot's ownership is + // checked against the program's lifetime id before it is served (see + // GetProgramResourceCache). Cleared wholesale at EGL teardown via + // ClearProgramResourceCaches. UnorderedMap g_programResourceCaches; void ClearReadPixelsOutput(GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { @@ -142,13 +153,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { ProgramResourceCache& GetProgramResourceCache(const MG_State::GLState::ProgramObject& program) { auto& cache = g_programResourceCaches[program.GetExternalIndex()]; + const Uint64 programLifetimeId = program.GetLifetimeId(); const Uint32 backendStateVersion = program.GetBackendStateVersion(); - if (cache.backendStateVersion == backendStateVersion && + // The lifetime id must match too: a new program that reuses a deleted + // program's name and happens to land on the same backendStateVersion (both + // count from zero) would otherwise be served the dead program's reflection. + if (cache.programLifetimeId == programLifetimeId && + cache.backendStateVersion == backendStateVersion && (!cache.storageBlocks.empty() || !cache.bufferVariables.empty())) { return cache; } cache = {}; + cache.programLifetimeId = programLifetimeId; cache.backendStateVersion = backendStateVersion; Vector modules; @@ -366,6 +383,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } // namespace + void ClearProgramResourceCaches() { + // Called from EGL teardown while the backend's m_eglStateMutex is held; GL + // calls are serialized in this codebase (contexts migrate threads but never + // run concurrently), so no other thread can be inside the unsynchronized map. + // Live programs in another context self-heal: their entry rebuilds from the + // retained generated SPIR-V on the next resource query. + g_programResourceCaches.clear(); + } + GLuint GetShaderStorageBlockIndex(const MG_State::GLState::ProgramObject& program, const String& name) { auto& cache = GetProgramResourceCache(program); const auto it = std::find_if(cache.storageBlocks.begin(), cache.storageBlocks.end(), diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h index 8e268e0d..cff26991 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h @@ -23,6 +23,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 GetRendererGeneration(); void BumpRendererGeneration(); + // Drops every cached program-resource reflection entry (CPU-side strings/vectors + // only, no Vulkan handles). Called at EGL teardown next to the renderer reset; + // safe because GL calls are serialized in this codebase, and any still-live + // program rebuilds its entry from the retained generated SPIR-V on demand. + void ClearProgramResourceCaches(); + void ClearBufferfi(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil); void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value); void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp index 83da4946..63bf8365 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp @@ -243,23 +243,100 @@ namespace MobileGL::MG_Backend::DirectVulkan { const HashType hash = ComputeHash(payload); auto it = m_cache.find(hash); if (it != m_cache.end()) { - return it->second; + it->second.lastUsedFrame = m_frameCounter; + return it->second.pipeline; } VkPipeline pipeline = CreatePipeline(payload); - m_cache.emplace(hash, pipeline); + m_cache.emplace(hash, PipelineCacheEntry{pipeline, payload.programHash, payload.renderPass, + m_frameCounter}); return pipeline; } void PipelineFactory::DestroyAll() { for (auto& pair : m_cache) { - if (pair.second != VK_NULL_HANDLE) { - vkDestroyPipeline(m_device, pair.second, nullptr); + if (pair.second.pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(m_device, pair.second.pipeline, nullptr); } } m_cache.clear(); } + Uint32 PipelineFactory::OnFrameBoundary() { + ++m_frameCounter; + + // Sweep cadence and retire age mirror VkRenderPassManager::OnPresent: an entry + // idle for more than kRetireAgeFrames frame boundaries cannot be referenced by + // any in-flight command buffer (frames-in-flight <= MOBILEGL_MAGMA_FRAMESINFLIGHT), + // so immediate vkDestroyPipeline is safe. The caller must drop its "last + // pipeline" memo when this returns non-zero: the memo can return a cached + // handle without touching this cache, so an evicted pipeline may still be + // memoized (present-less flush loops never reset the memo per frame). + constexpr Uint64 kSweepInterval = 256; + constexpr Uint64 kRetireAgeFrames = 1024; + if ((m_frameCounter % kSweepInterval) != 0) { + return 0; + } + + Uint32 evicted = 0; + for (auto it = m_cache.begin(); it != m_cache.end();) { + if (m_frameCounter - it->second.lastUsedFrame > kRetireAgeFrames) { + if (it->second.pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(m_device, it->second.pipeline, nullptr); + } + it = m_cache.erase(it); + ++evicted; + } else { + ++it; + } + } + if (evicted > 0) { + MGLOG_D("PipelineFactory::OnFrameBoundary: evicted %u idle pipelines (%zu remain)", evicted, + m_cache.size()); + } + return evicted; + } + + Uint32 PipelineFactory::EvictByRenderPass(VkRenderPass renderPass) { + Uint32 evicted = 0; + for (auto it = m_cache.begin(); it != m_cache.end();) { + if (it->second.renderPass == renderPass) { + if (it->second.pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(m_device, it->second.pipeline, nullptr); + } + it = m_cache.erase(it); + ++evicted; + } else { + ++it; + } + } + if (evicted > 0) { + MGLOG_D("PipelineFactory::EvictByRenderPass: evicted %u pipelines for destroyed render pass", + evicted); + } + return evicted; + } + + Uint32 PipelineFactory::EvictByProgramHash(HashType programHash) { + Uint32 evicted = 0; + for (auto it = m_cache.begin(); it != m_cache.end();) { + if (it->second.programHash == programHash) { + if (it->second.pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(m_device, it->second.pipeline, nullptr); + } + it = m_cache.erase(it); + ++evicted; + } else { + ++it; + } + } + if (evicted > 0) { + MGLOG_D("PipelineFactory::EvictByProgramHash: evicted %u pipelines for program hash 0x%llx", + evicted, static_cast(programHash)); + } + return evicted; + } + VkPipeline PipelineFactory::CreatePipeline(const PipelineCreatePayload& payload) const { MOBILEGL_ASSERT(payload.stages != nullptr && !payload.stages->empty(), "PipelineFactory: stages are empty"); MOBILEGL_ASSERT(payload.vertexInputState != nullptr, "PipelineFactory: vertexInputState is null"); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h index 9f101d7f..59e1a3be 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.h @@ -65,6 +65,26 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkPipeline GetOrCreatePipeline(const PipelineCreatePayload& payload); void DestroyAll(); + // Frame boundary hook: ages the pipeline cache and destroys long-unused entries + // (their command buffers retired many frames ago), mirroring + // 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 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 + // that stamp its factory entry). Returns the number destroyed. + Uint32 EvictByProgramHash(HashType programHash); + // Driver quirk: suppress depth writes on accumulation-blended pipelines. Multi-pass // depth-equality rendering (a blended prepass writes depth that later passes re-test // with an equality-inclusive compare on the re-rasterized geometry) requires @@ -87,12 +107,26 @@ namespace MobileGL::MG_Backend::DirectVulkan { static Bool ShouldSuppressDepthWrite(const PipelineCreatePayload& payload); private: + struct PipelineCacheEntry { + VkPipeline pipeline = VK_NULL_HANDLE; + // The hashed inputs the eviction paths key on: programHash ties the entry to + // its ProgramFactory entry, renderPass records the exact handle the hash + // folded in (the hash is one-way, so targeted eviction needs them verbatim). + HashType programHash = 0; + VkRenderPass renderPass = VK_NULL_HANDLE; + // Frame-boundary counter value of the last GetOrCreatePipeline hit; drives + // cache eviction (see OnFrameBoundary). + Uint64 lastUsedFrame = 0; + }; + VkPipeline CreatePipeline(const PipelineCreatePayload& payload) const; VkDevice m_device = VK_NULL_HANDLE; const VulkanRendererConfig& m_config; VkPipelineCache m_pipelineCache = VK_NULL_HANDLE; - UnorderedMap m_cache; + UnorderedMap m_cache; + // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. + Uint64 m_frameCounter = 0; static inline XXH64_state_t* m_hashState = XXH64_createState(); static inline Bool s_suppressBlendedDepthWrite = false; }; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index fb758973..b57de618 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -1950,11 +1950,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { } auto it = m_cache.find(hash); if (it != m_cache.end()) { + // Every draw/dispatch funnels through this lookup (the renderer memos only + // skip re-hashing, never the factory lookup), so an actively-used entry is + // stamped at least once per frame boundary and can never be aged out while + // any in-flight command buffer still references it. + it->second.lastUsedFrame = m_frameCounter; return it->second; } auto& entry = m_cache[hash]; entry.hash = hash; + entry.lastUsedFrame = m_frameCounter; auto& shaders = program.GetAttachedShaders(); auto& spirv = program.GetGeneratedSpirv(); Vector> moduleSpirvs(spirv.size()); @@ -2068,4 +2074,42 @@ namespace MobileGL::MG_Backend::DirectVulkan { return entry; } + + void ProgramFactory::OnFrameBoundary() { + ++m_frameCounter; + + // Sweep cadence and retire age mirror VkRenderPassManager::OnPresent: an entry + // idle for more than kRetireAgeFrames frame boundaries cannot be referenced by + // any in-flight command buffer (frames-in-flight <= MOBILEGL_MAGMA_FRAMESINFLIGHT), + // so its shader modules and layouts are destroyed immediately - no deferred- + // destroy machinery needed. Eviction is content-based, never tied to + // glDeleteProgram: the cache is content-hash-shared across GL programs, so a + // delete-driven erase could free an entry another live program still resolves. + // An evicted entry self-heals - the frontend program keeps its generated + // SPIR-V, so the next GetOrCreateProgram rebuilds it (this also covers the + // renderer's internal blit/depth-mipmap programs). + constexpr Uint64 kSweepInterval = 256; + constexpr Uint64 kRetireAgeFrames = 1024; + if ((m_frameCounter % kSweepInterval) != 0) { + return; + } + + for (auto it = m_cache.begin(); it != m_cache.end();) { + if (m_frameCounter - it->second.lastUsedFrame > kRetireAgeFrames) { + const HashType hash = it->first; + const VkDescriptorSetLayout descriptorSetLayout = it->second.descriptorSetLayout; + MGLOG_D("ProgramFactory::OnFrameBoundary: evicting idle program entry hash=0x%llx", + static_cast(hash)); + // erase runs ~VkProgramObject (modules/layouts destroyed); notify after + // so an observer never observes a half-destroyed entry through a lookup. + // Observers only need the handle values to purge their keyed caches. + it = m_cache.erase(it); + if (m_evictionObserver != nullptr) { + m_evictionObserver->OnProgramEvicted(hash, descriptorSetLayout); + } + } else { + ++it; + } + } + } } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index 4d3d49b1..7b4cb290 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -88,6 +88,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // gl_FragDepth); shader-computed depth is immune to the cross-pipeline // position-invariance quirk (see PipelineFactory::ShouldSuppressDepthWrite). Bool fragmentReplacesDepth = false; + // Frame-boundary counter value of the last GetOrCreateProgram hit; drives + // cache eviction (see OnFrameBoundary). + Uint64 lastUsedFrame = 0; static inline VkDevice s_device = VK_NULL_HANDLE; @@ -124,6 +127,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { producerOutputComponentCount = other.producerOutputComponentCount; fragmentInputComponentCount = other.fragmentInputComponentCount; fragmentReplacesDepth = other.fragmentReplacesDepth; + lastUsedFrame = other.lastUsedFrame; other.hash = 0; other.descriptorSetLayout = VK_NULL_HANDLE; other.pipelineLayout = VK_NULL_HANDLE; @@ -135,6 +139,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.producerOutputComponentCount = 0; other.fragmentInputComponentCount = 0; other.fragmentReplacesDepth = false; + other.lastUsedFrame = 0; } VkProgramObject& operator=(VkProgramObject&& other) noexcept { if (this == &other) { @@ -170,6 +175,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { producerOutputComponentCount = other.producerOutputComponentCount; fragmentInputComponentCount = other.fragmentInputComponentCount; fragmentReplacesDepth = other.fragmentReplacesDepth; + lastUsedFrame = other.lastUsedFrame; other.hash = 0; other.descriptorSetLayout = VK_NULL_HANDLE; other.pipelineLayout = VK_NULL_HANDLE; @@ -181,6 +187,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.producerOutputComponentCount = 0; other.fragmentInputComponentCount = 0; other.fragmentReplacesDepth = false; + other.lastUsedFrame = 0; return *this; } @@ -210,6 +217,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { } }; + // Notified when the OnFrameBoundary sweep destroys an aged-out cache entry, + // carrying the entry's content hash and the VkDescriptorSetLayout it owned. + // Dependent caches (compute pipelines, PipelineFactory entries, UniformManager's + // per-layout descriptor sets) must purge in the same step: after vkDestroy the + // layout handle value may be recycled for an unrelated layout, and the program + // hash may be re-inserted by a later rebuild of the same content. + class IEvictionObserver { + public: + virtual ~IEvictionObserver() = default; + virtual void OnProgramEvicted(HashType programHash, VkDescriptorSetLayout descriptorSetLayout) = 0; + }; + explicit ProgramFactory(VkDevice device, const VulkanRendererConfig& config, Uint32 maxBindings = 16, Bool shaderDrawParametersEnabled = false, Bool unformattedFloatStorageImagesEnabled = false) @@ -225,6 +244,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { const VkProgramObject& GetOrCreateProgram( const MG_State::GLState::ProgramObject& program, CompileOptionFlags flags); + // Observer may be null (no notifications). Not owned. + void SetEvictionObserver(IEvictionObserver* observer) { m_evictionObserver = observer; } + // Frame boundary hook: ages the program cache and evicts long-unused entries + // (their command buffers retired many frames ago), mirroring + // VkRenderPassManager::OnPresent's sweep. + void OnFrameBoundary(); + static VkShaderStageFlagBits ToVkStage(ShaderStage stage); static VkFormat ConvertSpirvImageFormatToVkFormat(SpvImageFormat format); static SamplerNumericDomain UniformTypeToSamplerNumericDomain(GLenum glType); @@ -266,6 +292,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { // shaderStorageImageReadWithoutFormat and shaderStorageImageWriteWithoutFormat. Bool m_unformattedFloatStorageImagesEnabled = false; mutable ProgramLookupCache m_lastLookup; + // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. + Uint64 m_frameCounter = 0; + IEvictionObserver* m_evictionObserver = nullptr; static inline XXH64_state_t* m_hashState = XXH64_createState(); }; } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index 50838e99..ed32b6ca 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -171,6 +171,7 @@ 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; @@ -211,6 +212,56 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + void UniformManager::OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout) { + 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 (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); + } + } + Bool UniformManager::ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, @@ -989,6 +1040,10 @@ 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++]; } else { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index 6f495f00..bc92f56f 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -39,6 +39,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { void Shutdown(); 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. + 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); @@ -68,6 +80,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { struct DescriptorSetCacheEntry { Vector sets; Uint32 cursor = 0; + // Frame-boundary counter value of the last AcquireDescriptorSet hit; drives + // cache eviction (see OnFrameBoundary). + Uint64 lastUsedFrame = 0; }; struct FrameResources { @@ -131,6 +146,9 @@ 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 fcf09ddc..fcc49b02 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp @@ -1194,6 +1194,12 @@ 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); + } it = m_renderPasses.erase(it); } else { ++it; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h index 71731435..71f751a0 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.h @@ -157,11 +157,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { class VkRenderPassManager { 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. + class IEvictionObserver { + public: + virtual ~IEvictionObserver() = default; + virtual void OnRenderPassDestroyed(VkRenderPass renderPass) = 0; + }; + VkRenderPassManager(VkDevice device, VkPhysicalDevice physicalDevice, VmaAllocator allocator, const VulkanRendererConfig& config, VkClearManager& clearManager, VkTextureManager& textureManager, SwapchainObject& swapchainObject); ~VkRenderPassManager(); + // Observer may be null (no notifications). Not owned. + void SetEvictionObserver(IEvictionObserver* observer) { m_evictionObserver = observer; } + Bool Initialize(); void Shutdown(); @@ -192,6 +208,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { UnorderedMap m_renderPasses; // Monotonic frame counter (bumped in OnPresent) for render-pass cache aging. Uint64 m_frameCounter = 0; + IEvictionObserver* m_evictionObserver = nullptr; // Bumped whenever a renderbuffer VkImage is (re)created; together with the texture // manager's image epoch this invalidates the render-pass fast path on any attachment diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index b55a96e7..4aa79c28 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -2529,6 +2529,11 @@ void main() { m_shaderDrawParametersFeatureEnabled, m_unformattedFloatStorageImagesEnabled); MOBILEGL_ASSERT(m_programFactory != nullptr, "ProgramFactory creation failed."); + // Aging evictions (render passes and program entries) must purge the dependent + // pipeline / compute-pipeline / descriptor-set caches in the same step; both + // sweeps only run from the frame-boundary seams, long after initialization. + m_renderPassManager->SetEvictionObserver(this); + m_programFactory->SetEvictionObserver(this); m_samplerManager = MakeUnique(); MOBILEGL_ASSERT(m_samplerManager != nullptr, "VkSamplerManager creation failed."); @@ -2589,6 +2594,15 @@ void main() { DestroyDeferredDepthMipmapCleanup(); DestroyComputePipelines(); + // No sweep runs during teardown, but the observers point at this renderer + // and the factories die at different times below; disconnect them first. + if (m_renderPassManager) { + m_renderPassManager->SetEvictionObserver(nullptr); + } + if (m_programFactory) { + m_programFactory->SetEvictionObserver(nullptr); + } + m_pipelineFactory.reset(); ShutdownBlitResources(); ShutdownDepthMipmapResources(); @@ -7241,6 +7255,20 @@ void main() { 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. + if (m_programFactory) { + m_programFactory->OnFrameBoundary(); + } + if (m_pipelineFactory && m_pipelineFactory->OnFrameBoundary() > 0) { + m_lastPipelineValid = false; + m_lastPipelineResult = VK_NULL_HANDLE; + } + if (m_uniformManager) { + m_uniformManager->OnFrameBoundary(); + } return true; } @@ -7496,6 +7524,16 @@ void main() { MOBILEGL_ASSERT(m_imageIndexAcquired < m_swapchainObject.GetImageCount(), "Present, acquired image index out of range"); m_renderPassManager->OnPresent(); + // 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. + 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(); auto& frame = m_frameContext.GetCurrent(); auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); if (activeRenderPass) @@ -8528,6 +8566,43 @@ void main() { m_computePipelines.clear(); } + void VulkanRenderer::OnRenderPassDestroyed(VkRenderPass renderPass) { + 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 + // 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) { + m_lastPipelineValid = false; + m_lastPipelineResult = VK_NULL_HANDLE; + } + } + + void VulkanRenderer::OnProgramEvicted(ProgramFactory::HashType programHash, + VkDescriptorSetLayout descriptorSetLayout) { + // Same >1024-boundary idleness as the program entry: its compute pipeline is + // only dispatched, and its graphics pipelines only bound, through paths that + // stamp the entry, so immediate destruction is GPU-safe. (The graphics memo + // never holds compute pipelines; it only needs invalidating for the factory + // eviction below.) + const auto computeIt = m_computePipelines.find(programHash); + if (computeIt != m_computePipelines.end()) { + if (computeIt->second != VK_NULL_HANDLE && m_device != VK_NULL_HANDLE) { + vkDestroyPipeline(m_device, computeIt->second, nullptr); + } + m_computePipelines.erase(computeIt); + } + if (m_pipelineFactory != nullptr && m_pipelineFactory->EvictByProgramHash(programHash) > 0) { + m_lastPipelineValid = false; + m_lastPipelineResult = VK_NULL_HANDLE; + } + if (m_uniformManager != nullptr) { + m_uniformManager->OnDescriptorSetLayoutDestroyed(descriptorSetLayout); + } + } + VkPipeline VulkanRenderer::GetOrCreateComputePipeline(const ProgramFactory::VkProgramObject& programObj) { const auto it = m_computePipelines.find(programObj.hash); if (it != m_computePipelines.end()) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index b1c8cc1b..bdf7c5a5 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -114,7 +114,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { } }; - class VulkanRenderer : public IBufferCopyCommandProvider, public FrameContext::IRecordingObserver { + class VulkanRenderer : public IBufferCopyCommandProvider, + public FrameContext::IRecordingObserver, + public VkRenderPassManager::IEvictionObserver, + public ProgramFactory::IEvictionObserver { public: VulkanRenderer(NativeWindowType window, const VulkanRendererConfig& cfg = {}); ~VulkanRenderer(); @@ -131,6 +134,20 @@ 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 + // 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; + + // ProgramFactory::IEvictionObserver: an aged-out program entry was + // destroyed; evict its compute pipeline and graphics pipelines (same + // idleness guarantee - they are only bound through draws/dispatches that + // stamp the program entry) and purge the descriptor-set cache entries + // keyed by its now-recyclable VkDescriptorSetLayout handle. + void OnProgramEvicted(ProgramFactory::HashType programHash, + VkDescriptorSetLayout descriptorSetLayout) override; + Bool SetupDraw(FrameContext::FrameData& frame, GLenum mode, Flags aspects, const DrawCmdParam& drawParams, const IndexBufferView* pIndexBufferView = nullptr);