diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 5e96abcf..642a9bdc 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -138,6 +138,12 @@ namespace MobileGL::MG_Config { // freed; Adreno maps it into the process in 16 KiB chunks, so one ~1M-draw loading frame // exhausts vm.max_map_count. See VulkanRenderer::SplitOversizedRecording. Uint32 MagmaMaxDrawsPerCommandBuffer = 16384; + // MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES: how many consecutive frames (strictly, BeginFrame + // epochs) a frame slot's descriptor pools must stay under a quarter full before Magma + // hands the slot's whole set cache back and lets it re-grow. 0 = never trim. A loading + // frame otherwise keeps its burst of sets (rd12: ~15k, where steady frames use a few dozen) + // for the life of the process. See UniformManager::TrimFrameDescriptorPools. + Uint32 MagmaDescriptorTrimFrames = 120; // MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER: avoid mipmap min filters in samplers, // resolves certain rendering bugs on ANGLE + llvmpipe. Bool EsprytAvoidSamplerMipmapMinFilter = false; diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 8e60e82a..3b8ba099 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -179,6 +179,7 @@ namespace MobileGL::MG_ConfigLoader { features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64); features.MagmaMaxDrawsPerCommandBuffer = QueryEnvUint32("MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER", 16384, 0, 1u << 24); + features.MagmaDescriptorTrimFrames = QueryEnvUint32("MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES", 120, 0, 1u << 20); features.EsprytAvoidSamplerMipmapMinFilter = QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER"); features.EsprytAvoidExplicitLodBias = QueryEnvFlag("MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS"); diff --git a/MobileGL/MG_Backend/DirectVulkan/DescriptorPoolCensus.h b/MobileGL/MG_Backend/DirectVulkan/DescriptorPoolCensus.h new file mode 100644 index 00000000..f3ca3020 --- /dev/null +++ b/MobileGL/MG_Backend/DirectVulkan/DescriptorPoolCensus.h @@ -0,0 +1,29 @@ +// MobileGL - MobileGL/MG_Backend/DirectVulkan/DescriptorPoolCensus.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once + +#include + +namespace MobileGL::MG_Backend::DirectVulkan { + // A count of Magma's per-frame descriptor pools and the sets they hold (UniformManager), for + // tests. Dependency-free on purpose: the integration scenarios include it next to the system GL + // headers. They link the static library on desktop; the shipping shared library is built with + // hidden visibility, so on Android nothing outside the library can call GetDescriptorPoolCensus. + struct DescriptorPoolCensus { + bool available = false; // false when no Magma renderer is live (e.g. the DirectGLES backend) + uint32_t frameSlots = 0; + uint32_t pools = 0; // descriptor pools across every frame slot + uint32_t maxPoolsInOneSlot = 0; + uint64_t capacitySets = 0; // sum of maxSets over those pools + uint64_t cachedSets = 0; // sets allocated from them and held in the per-layout caches + uint64_t slotTrims = 0; // whole-slot trims since the renderer came up + }; + + DescriptorPoolCensus GetDescriptorPoolCensus(); +} // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index cae6e388..0e439b31 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -1446,4 +1446,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { Optional GetRequestedSwapInterval() { return g_requestedSwapInterval; } + + DescriptorPoolCensus GetDescriptorPoolCensus() { + return pVulkanRenderer ? pVulkanRenderer->GetDescriptorPoolCensus() : DescriptorPoolCensus{}; + } } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index 1496774d..8a417540 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -303,6 +303,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_samplerResolveMemoHighWater = 0; m_setsPerFrame = setsPerFrame; m_peakDescriptorSetsObserved = 0; + m_trimQuietEpochs = MG_Config::Features.MagmaDescriptorTrimFrames; + // The renderer records its first frame into slot 0 before any BeginFrame runs. + m_epochSlot = 0; + m_epochOpen = true; + m_descriptorSlotTrims = 0; m_textureManager = textureManager; m_samplerManager = samplerManager; @@ -312,6 +317,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { frame.activeDescriptorPoolIndex = 0; frame.allocatedSetsThisFrame = 0; frame.peakAllocatedSetsThisFrame = 0; + frame.capacitySets = m_setsPerFrame; + frame.quietEpochs = 0; frame.descriptorPools.clear(); VkDescriptorPool initialPool = VK_NULL_HANDLE; @@ -361,8 +368,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { frame.activeDescriptorPoolIndex = 0; frame.allocatedSetsThisFrame = 0; frame.peakAllocatedSetsThisFrame = 0; + frame.capacitySets = 0; + frame.quietEpochs = 0; } m_frames.clear(); + m_epochSlot = 0; + m_epochOpen = false; m_bufferManager = nullptr; m_programFactory = nullptr; @@ -396,6 +407,20 @@ namespace MobileGL::MG_Backend::DirectVulkan { "UniformDescriptorBinder: new descriptor set peak observed=%u (base setsPerFrame=%u, frame=%u, pools=%zu)", m_peakDescriptorSetsObserved, m_setsPerFrame, frameIndex, frame.descriptorPools.size()); } + // The epoch that just ended is the one of the slot begun last - this slot again on the + // drain path, the previous frame's slot on the Present path. Its peak is still unreset. + if (m_epochOpen && m_epochSlot < m_frames.size()) { + NoteEndedEpochForTrim(m_frames[m_epochSlot].peakAllocatedSetsThisFrame); + } + m_epochSlot = frameIndex; + m_epochOpen = true; + // No pending command buffer references this slot's sets here (see TrimFrameDescriptorPools + // for why that holds at both of BeginFrame's call sites), so a quiet grown slot can be + // handed back whole. + if (m_trimQuietEpochs != 0 && frame.descriptorPools.size() > 1 && + frame.capacitySets >= kDescriptorTrimMinCapacity && frame.quietEpochs >= m_trimQuietEpochs) { + TrimFrameDescriptorPools(frame, frameIndex); + } frame.activeDescriptorPoolIndex = 0; frame.allocatedSetsThisFrame = 0; frame.peakAllocatedSetsThisFrame = 0; @@ -404,11 +429,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } // The frame's descriptor sets are recycled above, so last frame's reuse targets // are gone: start the per-draw descriptor-reuse cache fresh this frame. - for (auto& entry : m_descriptorReuseMemo) { - entry.valid = false; - } - m_fastRebindMemo.valid = false; - m_lastBindValid = false; + InvalidateDescriptorSetMemos(); // Re-fingerprint the bound sampler set fresh this frame so any GL object address // reuse cannot outlive a single frame (see SamplerResolveMemo). Only the entries a // resolve has actually written can be valid, so the high-water mark bounds the @@ -422,6 +443,68 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + void UniformManager::InvalidateDescriptorSetMemos() { + // Every VkDescriptorSet handle held outside the per-layout caches - in this backend they all + // live in this class. A trim can hand a destroyed set's handle value to a fresh allocation, + // so the bind-dedup shadow must drop too, not only the two reuse memos. + for (auto& entry : m_descriptorReuseMemo) { + entry.valid = false; + } + m_fastRebindMemo.valid = false; + m_lastBindValid = false; + } + + void UniformManager::NoteEndedEpochForTrim(Uint32 epochPeakSets) { + for (auto& slot : m_frames) { + if (static_cast(epochPeakSets) * kDescriptorTrimHeadroom > slot.capacitySets) { + slot.quietEpochs = 0; + } else if (slot.quietEpochs < std::numeric_limits::max()) { + ++slot.quietEpochs; + } + } + } + + void UniformManager::TrimFrameDescriptorPools(FrameResources& frame, Uint32 frameIndex) { + const SizeT poolsBefore = frame.descriptorPools.size(); + const Uint64 capacityBefore = frame.capacitySets; + // Holders first: the per-layout cache and every memo that may name one of these sets. + frame.descriptorSetCacheByLayout.clear(); + InvalidateDescriptorSetMemos(); + for (SizeT i = 1; i < frame.descriptorPools.size(); ++i) { + if (frame.descriptorPools[i].handle != VK_NULL_HANDLE) { + vkDestroyDescriptorPool(m_device, frame.descriptorPools[i].handle, nullptr); + } + } + frame.descriptorPools.resize(1); + auto& base = frame.descriptorPools[0]; + // Frees every set allocated from it; the spec defines no failure for vkResetDescriptorPool. + vkResetDescriptorPool(m_device, base.handle, 0); + base.allocatedSets = 0; + frame.activeDescriptorPoolIndex = 0; + frame.capacitySets = base.maxSets; + frame.quietEpochs = 0; + ++m_descriptorSlotTrims; + MGLOG_D("UniformDescriptorBinder: frame %u trimmed its descriptor pools (%zu pools / %llu sets -> 1 / %u)", + frameIndex, poolsBefore, static_cast(capacityBefore), base.maxSets); + } + + DescriptorPoolCensus UniformManager::GetDescriptorPoolCensus() const { + DescriptorPoolCensus census; + census.available = true; + census.frameSlots = static_cast(m_frames.size()); + for (const auto& frame : m_frames) { + const auto poolCount = static_cast(frame.descriptorPools.size()); + census.pools += poolCount; + census.maxPoolsInOneSlot = std::max(census.maxPoolsInOneSlot, poolCount); + census.capacitySets += frame.capacitySets; + for (const auto& cacheEntryPair : frame.descriptorSetCacheByLayout) { + census.cachedSets += cacheEntryPair.second.sets.size(); + } + } + census.slotTrims = m_descriptorSlotTrims; + return census; + } + void UniformManager::OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout) { SizeT purgedSets = 0; for (auto& frame : m_frames) { @@ -2174,6 +2257,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { frame.descriptorPools.push_back({grownPool, grownMaxSets, 0, updateAfterBind}); frame.activeDescriptorPoolIndex = static_cast(frame.descriptorPools.size() - 1); + frame.capacitySets += grownMaxSets; + // Growing is the opposite of quiet: a trim must wait a full quiet run from here. + frame.quietEpochs = 0; MGLOG_D("UniformDescriptorBinder: frame %u descriptor pool exhausted, grew a %u-set pool (updateAfterBind=%d), " "poolCount=%zu", frameIndex, grownMaxSets, updateAfterBind ? 1 : 0, frame.descriptorPools.size()); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index d6951c57..99dc160a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -12,6 +12,7 @@ #include "VkBufferManager.h" #include "VkSamplerManager.h" #include "VkTextureManager.h" +#include "../DescriptorPoolCensus.h" #include "../VkIncludes.h" #include @@ -63,9 +64,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // 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. + // references its sets. This is the only PER-LAYOUT eviction path for the + // caches - a live layout's entry must never be purged on its own (its sets would be + // unreachable pool slots), so there is deliberately no age-based sweep here. The + // other path, TrimFrameDescriptorPools, drops a slot's whole cache together with + // the pools behind it. void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout); // One record per visited CombinedImageSampler DESCRIPTOR (post fallback substitution, // in binding order, and within a binding in array-element order): the resolved texture @@ -155,6 +158,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { // smallest one, and doubling IT made every grown pool the same size. static Uint32 NextDescriptorPoolMaxSets(const Vector& pools, Bool updateAfterBind, Uint32 initialMaxSets); + // Trim policy (see TrimFrameDescriptorPools). A slot is "quiet" in an epoch whose peak set + // count times kDescriptorTrimHeadroom still fits its capacity; a slot that has been quiet for + // MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES consecutive epochs, and holds at least + // kDescriptorTrimMinCapacity sets, is trimmed. The headroom is what keeps the trim from + // thrashing: geometric growth leaves a re-grown slot at least a third full (its pools sum to + // under twice the largest, and the largest is under twice the need that added it), so the + // epoch that grew a slot is never quiet for it. The floor keeps a slot sized for ordinary + // frames from being trimmed at all - the trim exists to give back a burst. + static constexpr Uint32 kDescriptorTrimHeadroom = 4; + static constexpr Uint32 kDescriptorTrimMinCapacity = 1024; + // For tests (DescriptorPoolCensus.h): pools, capacity, cached sets and trims over every slot. + DescriptorPoolCensus GetDescriptorPoolCensus() const; private: @@ -171,6 +186,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 cursor = 0; }; + // descriptorSetCacheByLayout is the per-frame set cache: BeginFrame rewinds every cursor, so + // a slot re-writes the sets it allocated in earlier frames instead of allocating new ones, and + // the cache only ever grows - except through TrimFrameDescriptorPools, which gives a slot's + // whole burst back at once (and OnDescriptorSetLayoutDestroyed, one dead layout at a time). struct FrameResources { Vector descriptorPools; UnorderedMap descriptorSetCacheByLayout; @@ -178,6 +197,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 activeDescriptorPoolIndex = 0; Uint32 allocatedSetsThisFrame = 0; Uint32 peakAllocatedSetsThisFrame = 0; + // Sum of maxSets over descriptorPools, kept in step by Initialize, Grow and Trim. + Uint64 capacitySets = 0; + // Consecutive epochs (see NoteEndedEpochForTrim) that were quiet for THIS slot's capacity. + Uint32 quietEpochs = 0; }; static Bool ResolveSamplerTexture(const MG_State::GLState::ProgramObject& program, @@ -303,6 +326,28 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Vector& dynamicOffsets); Bool CreateDescriptorPool(Uint32 maxSets, Bool updateAfterBind, VkDescriptorPool& outPool) const; Bool GrowFrameDescriptorPool(FrameResources& frame, Uint32 frameIndex, Bool updateAfterBind); + // An epoch is the stretch between two BeginFrame calls: one frame, or the part of one between + // two drains (VulkanRenderer::TryDrainFrameTransients). Called by BeginFrame with the epoch + // that just ended, it updates every slot's quiet count - a burst in any slot counts against + // all of them, so bursts that land in the other slots still hold a slot's trim off. + void NoteEndedEpochForTrim(Uint32 epochPeakSets); + // Gives a slot's whole descriptor burst back: clears its per-layout cache, destroys every + // grown pool and resets the base pool, so the slot re-grows geometrically from setsPerFrame. + // Whole-slot rather than per-set: vkFreeDescriptorSets fragments a pool and can never empty + // a grown one. Called only from BeginFrame, which runs only when no pending command buffer + // references the slot's sets: + // - Present path (VulkanRenderer::Present, after WaitAndAcquireNextImage): the slot's + // imageInFlightFence has been waited. That fence rides the slot's LAST submission - the + // Present submit - and every mid-frame submission of the slot (FlushPendingCommands, + // SplitOversizedRecording, readbacks) went to the same queue earlier, so the fence's + // signal covers them (vkQueueSubmit's fence scope includes all earlier submissions). + // - Drain path (VulkanRenderer::TryDrainFrameTransients): every submission has been + // observed complete and no recording is open. + // Sets are per slot: a slot's sets are only ever bound into recordings made while it is the + // current slot, so trimming one slot never touches another slot's in-flight work. + void TrimFrameDescriptorPools(FrameResources& frame, Uint32 frameIndex); + // Drops every handle this class holds to a descriptor set outside the per-layout caches. + void InvalidateDescriptorSetMemos(); VkResult AllocateDescriptorSetsFromActivePool( Uint32 frameIndex, const ProgramFactory::VkProgramObject& programObj, VkDescriptorSet& outDescriptorSet); VkResult AcquireDescriptorSet(Uint32 frameIndex, @@ -320,6 +365,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 m_maxBindings = 0; Uint32 m_setsPerFrame = 0; Uint32 m_peakDescriptorSetsObserved = 0; + // MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES, latched at Initialize (0 = never trim). + Uint32 m_trimQuietEpochs = 0; + // The slot whose epoch is open (the last one BeginFrame was called for); valid once + // m_epochOpen is set. Its peak is the ended epoch's peak at the next BeginFrame. + Uint32 m_epochSlot = 0; + Bool m_epochOpen = false; + Uint64 m_descriptorSlotTrims = 0; VkTextureManager* m_textureManager = nullptr; VkSamplerManager* m_samplerManager = nullptr; mutable SharedPtr m_fallbackTexture2D; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 90980415..5db812fc 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -285,6 +285,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { // signal as soon as the GPU actually finishes, which MC 1.21.5's // fence-paced ring buffers rely on to recycle their space. Uint64 GetSyncPointSubmitIndex() const; + // For tests: the per-frame descriptor pools (see DescriptorPoolCensus.h). + DescriptorPoolCensus GetDescriptorPoolCensus() const { + return m_uniformManager ? m_uniformManager->GetDescriptorPoolCensus() : DescriptorPoolCensus{}; + } // Non-blocking: polls outstanding submission fences and reports // whether every submission up to `submitIndex` has completed. Bool IsSubmitIndexComplete(Uint64 submitIndex); diff --git a/README.md b/README.md index ac6c21e5..1e865575 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ MobileGL supports runtime configuration via environment variables. | `MOBILEGL_MAGMA_R11G11B10F_FALLBACK` | Use Magma's R11G11B10F format fallback. | `0`, `1` | `0` | | `MOBILEGL_MAGMA_FRAMESINFLIGHT` | Set Magma frames in flight. | Integer `1`–`64` | `3` | | `MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER` | Draws and dispatches Magma records into one command buffer before it submits the buffer mid-frame and continues on a fresh one. `0` never splits. | Integer `0`–`16777216` | `16384` | +| `MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES` | Frames a frame slot's descriptor pools must stay under a quarter full before Magma frees the slot's cached descriptor sets and grown pools. `0` never trims. | Integer `0`–`1048576` | `120` | | `MOBILEGL_ESPRYT_AVOID_SAMPLER_MIPMAP_MIN_FILTER` | Avoid sampler mipmap minification filters. | `0`, `1` | `0` | | `MOBILEGL_COHERENT_AS_FLUSH` | Treat persistent `GL_MAP_FLUSH_EXPLICIT_BIT` maps as coherent (app-compat for engines like Flywheel that never flush them). | `0`, `1` | `0` | | `MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION` | Always emulate depth/stencil `glReadPixels`/`glGetTexImage` by shader sampling on Espryt, instead of using the driver's own depth/stencil readback where it has one. | `0`, `1` | `0` |