[Fix] (Magma): a frame slot whose descriptor pools stay under a quarter full for 120 frames is handed back whole - its set cache cleared, grown pools destroyed, base pool reset and every memo holding a set handle dropped - so rd12's loading burst no longer lives for the whole process (live sets 15,042 -> 39, pools 65 -> 6); MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES (0 = never) and a test-visible pool census

This commit is contained in:
2026-09-29 10:34:16 -04:00
parent c2de6576ad
commit 53fa16c2c2
8 changed files with 191 additions and 8 deletions
+6
View File
@@ -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;
+1
View File
@@ -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");
@@ -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 <cstdint>
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
@@ -1446,4 +1446,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Optional<Int> GetRequestedSwapInterval() {
return g_requestedSwapInterval;
}
DescriptorPoolCensus GetDescriptorPoolCensus() {
return pVulkanRenderer ? pVulkanRenderer->GetDescriptorPoolCensus() : DescriptorPoolCensus{};
}
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -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<Uint64>(epochPeakSets) * kDescriptorTrimHeadroom > slot.capacitySets) {
slot.quietEpochs = 0;
} else if (slot.quietEpochs < std::numeric_limits<Uint32>::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<unsigned long long>(capacityBefore), base.maxSets);
}
DescriptorPoolCensus UniformManager::GetDescriptorPoolCensus() const {
DescriptorPoolCensus census;
census.available = true;
census.frameSlots = static_cast<uint32_t>(m_frames.size());
for (const auto& frame : m_frames) {
const auto poolCount = static_cast<uint32_t>(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<Uint32>(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());
@@ -12,6 +12,7 @@
#include "VkBufferManager.h"
#include "VkSamplerManager.h"
#include "VkTextureManager.h"
#include "../DescriptorPoolCensus.h"
#include "../VkIncludes.h"
#include <Includes.h>
@@ -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<DescriptorPoolBucket>& 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<DescriptorPoolBucket> descriptorPools;
UnorderedMap<VkDescriptorSetLayout, DescriptorSetCacheEntry> 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<Uint32>& 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<MG_State::GLState::ITextureObject> m_fallbackTexture2D;
@@ -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);
+1
View File
@@ -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` |