diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index c59e5d52..b277e170 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -37,6 +38,12 @@ namespace MobileGL { MGLOG_I("MobileGL closing..."); } glslang::FinalizeProcess(); + // GL syncs die with their contexts, and every context is gone by the + // time full teardown runs: drain the live-sync registry while the + // backend function table can still release the backend handles (and + // before a re-initialized library could pair them with the wrong + // backend's DeleteSync). + MG_Impl::GLImpl::DestroyAllSyncObjects(); MG_Backend::pActiveBackendObject.reset(); MG_State::pGLContext.reset(); MG_State::pEGLContext.reset(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index 3c880454..6e865632 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -32,6 +32,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra))); XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor))); + // The buffer's heap address is an identity component of the key: a freed + // buffer's reused address can alias an old cache entry, but only under a + // byte-identical attribute layout - and the entry payload is a pure function + // of the hashed inputs, with the draw path re-resolving bindingBufferKeys + // against the live VAO attribute pointers, so an aliased hit returns exactly + // what a rebuild would. Address drift only grows the map; the OnFrameBoundary + // aging sweep bounds that. const SizeT bufferKey = reinterpret_cast(attr.Buffer.get()); XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey))); } @@ -58,6 +65,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const MG_State::GLState::VertexArrayObject& vao, HashType hash) { auto it = m_cache.find(hash); if (it != m_cache.end()) { + it->second.lastUsedFrameBoundary = m_frameBoundaryCounter; return it->second; } @@ -166,6 +174,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { auto& entry = m_cache[hash]; entry.hash = hash; + entry.lastUsedFrameBoundary = m_frameBoundaryCounter; entry.bindings = builder.GetBindings(); entry.attributes = builder.GetAttributes(); entry.bindingBufferKeys = std::move(bindingBufferKeys); @@ -180,6 +189,30 @@ namespace MobileGL::MG_Backend::DirectVulkan { return entry; } + void VertexInputStateFactory::OnFrameBoundary() { + ++m_frameBoundaryCounter; + + // Sweep occasionally; evict entries whose last hit is far in the past. + // Erasure happens only here, never mid-frame: the draw path holds a + // reference into the current entry across its setup, and unordered_map + // erase would invalidate it. Entries are CPU-side only, so no GPU-idle + // proof is needed; an evicted entry that is used again is simply rebuilt + // from the VAO state (same hash, same content). + constexpr Uint64 kSweepInterval = 256; + constexpr Uint64 kRetireAgeBoundaries = 1024; + if ((m_frameBoundaryCounter % kSweepInterval) != 0) { + return; + } + + for (auto it = m_cache.begin(); it != m_cache.end();) { + if (m_frameBoundaryCounter - it->second.lastUsedFrameBoundary > kRetireAgeBoundaries) { + it = m_cache.erase(it); + } else { + ++it; + } + } + } + VkFormat VertexInputStateFactory::ToVkVertexFormat(DataType type, Int size, Bool normalized, Bool isInteger, Bool isBgra) { if (isBgra) { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 3f82180e..07525351 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -27,6 +27,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { struct BackendVertexInputState { HashType hash = 0; + // Frame boundary of the last cache hit; entries idle past the + // OnFrameBoundary retirement age are evicted (CPU heap only). + Uint64 lastUsedFrameBoundary = 0; Vector bindings; Vector attributes; Vector bindingBufferKeys; @@ -55,6 +58,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { const BackendVertexInputState& GetOrCreateVertexInputState( const MG_State::GLState::VertexArrayObject& vao, HashType hash); const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao); + // Frame boundary hook: ages the cache and evicts entries not hit for many + // frames. The key mixes buffer heap addresses, so buffer/VAO churn keeps + // minting fresh keys; without eviction the map grows for the whole session. + // Entries hold no Vulkan handles (pipeline creation copies the descriptions) + // and the draw path's entry reference never spans a frame boundary, so + // eviction here needs no GPU-idle proof. Self-gated: one counter bump and + // compare except on sweep boundaries. + void OnFrameBoundary(); static SizeT GetComponentSize(DataType type); // Tightly-packed byte size of one vertex element for this attribute: componentSize * size for // normal types, and 4 (one packed word) for the 2_10_10_10 types and GL_BGRA. Returns 0 for @@ -70,6 +81,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { const VulkanRendererConfig& m_config; VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE; UnorderedMap m_cache; + // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. + Uint64 m_frameBoundaryCounter = 0; static inline XXH64_state_t* m_hashState = XXH64_createState(); }; } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.cpp index 241267fd..b0eabcc1 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.cpp @@ -89,6 +89,33 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_device = VK_NULL_HANDLE; m_config = nullptr; + m_frameBoundaryCounter = 0; + } + + void VkSamplerManager::OnFrameBoundary() { + ++m_frameBoundaryCounter; + + // Sweep occasionally; destroy samplers whose last use is far past every + // in-flight frame. Destroy and erase must stay atomic, or Shutdown would + // double-free the handle; an evicted key that recurs simply re-creates + // its sampler on the next miss. + constexpr Uint64 kSweepInterval = 256; + constexpr Uint64 kRetireAgeBoundaries = 1024; + if ((m_frameBoundaryCounter % kSweepInterval) != 0) { + return; + } + + for (auto it = m_samplers.begin(); it != m_samplers.end();) { + auto& entry = it->second; + if (m_frameBoundaryCounter - entry.lastUsedFrameBoundary > kRetireAgeBoundaries) { + if (m_device != VK_NULL_HANDLE && entry.handle != VK_NULL_HANDLE) { + vkDestroySampler(m_device, entry.handle, nullptr); + } + it = m_samplers.erase(it); + } else { + ++it; + } + } } Uint64 VkSamplerManager::BuildSamplerKey(const MG_State::GLState::SamplerObject& sampler, @@ -137,6 +164,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Uint64 key = BuildSamplerKey(sampler, texture, forceNearestFiltering); auto it = m_samplers.find(key); if (it != m_samplers.end()) { + it->second.lastUsedFrameBoundary = m_frameBoundaryCounter; return it->second.handle; } @@ -169,6 +197,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { entry.handle = vkSampler; entry.externalIndex = sampler.GetExternalIndex(); entry.version = sampler.GetVersion(); + entry.lastUsedFrameBoundary = m_frameBoundaryCounter; m_samplers[key] = entry; return vkSampler; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.h index 0e72f60a..5ec83059 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkSamplerManager.h @@ -36,12 +36,27 @@ public: VkSampler GetOrCreateSampler(const MG_State::GLState::SamplerObject& sampler, const MG_State::GLState::ITextureObject& texture, Bool forceNearestFiltering = false); + // Frame boundary hook: ages the sampler cache and destroys samplers not used + // for many frames. The key hashes continuous float state (lodBias, LOD clamps, + // anisotropy), so an app animating those would otherwise mint an unbounded + // stream of never-destroyed VkSamplers and eventually exhaust the device's + // maxSamplerAllocationCount. A sampler idle for over a thousand frame + // boundaries cannot be referenced by any in-flight command buffer (frames in + // flight are single digits), and every descriptor set the GPU consumes is + // written that same frame with live handles (the per-binding resolve memo and + // descriptor-set reuse are both frame-reset), so destruction here needs no + // fence wait. Self-gated: one counter bump and compare except on sweep + // boundaries. + void OnFrameBoundary(); private: struct SamplerCacheEntry { VkSampler handle = VK_NULL_HANDLE; Uint externalIndex = 0; Uint16 version = 0; + // Frame boundary of the last cache hit; entries idle past the + // OnFrameBoundary retirement age have their VkSampler destroyed. + Uint64 lastUsedFrameBoundary = 0; }; Uint64 BuildSamplerKey(const MG_State::GLState::SamplerObject& sampler, @@ -67,6 +82,8 @@ private: Bool m_samplerAnisotropySupported = false; Float m_maxSamplerAnisotropy = 1.0f; UnorderedMap m_samplers; + // Monotonic frame-boundary counter (bumped in OnFrameBoundary) for cache aging. + Uint64 m_frameBoundaryCounter = 0; static inline XXH64_state_t* m_hashState = XXH64_createState(); }; } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 4aa79c28..268ba038 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -7269,6 +7269,12 @@ void main() { if (m_uniformManager) { m_uniformManager->OnFrameBoundary(); } + if (m_vertexInputStateFactory) { + m_vertexInputStateFactory->OnFrameBoundary(); + } + if (m_samplerManager) { + m_samplerManager->OnFrameBoundary(); + } return true; } @@ -7534,6 +7540,8 @@ void main() { m_lastPipelineResult = VK_NULL_HANDLE; } m_uniformManager->OnFrameBoundary(); + m_vertexInputStateFactory->OnFrameBoundary(); + m_samplerManager->OnFrameBoundary(); auto& frame = m_frameContext.GetCurrent(); auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); if (activeRenderPass) diff --git a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp index d9b8620d..951f3fe7 100644 --- a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp +++ b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp @@ -133,4 +133,33 @@ namespace MobileGL::MG_Impl::GLImpl { values[0] = value; } } + + void DestroyAllSyncObjects() { + // Detach the registry under the lock, release outside it. Entries the app + // already deleted were erased by DeleteSync, so nothing here double-frees; + // a DeleteSync racing this sweep finds an empty registry and returns. A + // thread still blocked inside ClientWaitSync/GetSynciv during teardown + // holds a raw SyncObject* these deletes invalidate - the same undefined + // race an app-driven DeleteSync already has. + UnorderedMap orphans; + { + const std::lock_guard lock(g_syncObjectsMutex); + orphans.swap(g_liveSyncObjects); + } + if (orphans.empty()) { + return; + } + // Both backends' DeleteSync only free the heap wrapper once their GL + // context/renderer is gone (generation/current-thread guards), so this is + // safe after the backend has released its EGL resources - but not after + // the function table itself is cleared. + const auto backendDeleteSync = MG_Backend::gBackendFunctionsTable.GL.DeleteSync; + for (const auto& [_, syncObject] : orphans) { + if (backendDeleteSync && syncObject->backendHandle) { + backendDeleteSync(syncObject->backendHandle); + } + delete syncObject; + } + MGLOG_D("DestroyAllSyncObjects: reclaimed %zu sync object(s) the app left undeleted", orphans.size()); + } } // namespace MobileGL::MG_Impl::GLImpl diff --git a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.h b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.h index d9a0dc87..07aea742 100644 --- a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.h +++ b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.h @@ -16,4 +16,12 @@ namespace MobileGL::MG_Impl::GLImpl { void WaitSync(GLsync sync, GLbitfield flags, GLuint64 timeout); void DeleteSync(GLsync sync); void GetSynciv(GLsync sync, GLenum pname, GLsizei bufSize, GLsizei* length, GLint* values); + // Destroys every still-registered sync object exactly as DeleteSync would. + // GL requires syncs to die with their context; called only from full library + // teardown (DestroyImpl), where no context survives on any thread, so the + // process-global registry can be drained wholesale. Must run while the + // backend function table is still populated: each backend handle has to be + // released by the backend that created it, never by a later re-initialized + // one. + void DestroyAllSyncObjects(); } // namespace MobileGL::MG_Impl::GLImpl