From d20c2d01ae48f5feb0c7499ed76be9249163ad76 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 29 Sep 2026 09:10:19 -0400 Subject: [PATCH] [Fix] (Magma): a frame's recording is submitted every 16384 draws/dispatches and continues on a fresh command buffer, with at most frames-in-flight + 1 submissions outstanding - rd12's 981k-draw loading frame no longer grows one command buffer until Adreno's 16 KiB driver mappings exhaust vm.max_map_count (MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER, 0 = unbounded) --- MobileGL/Config.h | 6 +++ MobileGL/ConfigLoader.cpp | 2 + .../DirectVulkan/Renderer/VulkanRenderer.cpp | 53 +++++++++++++++++++ .../DirectVulkan/Renderer/VulkanRenderer.h | 13 +++++ README.md | 1 + 5 files changed, 75 insertions(+) diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 8e9962f0..5e96abcf 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -132,6 +132,12 @@ namespace MobileGL::MG_Config { Bool MagmaR11G11B10FFallback = false; // MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3. Uint32 MagmaFramesInFlight = 3; + // MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER: GL draws and dispatches Magma records into + // one command buffer before it submits it and continues on a fresh one. 0 = unbounded. + // Drivers back a command buffer with GPU memory that is only returned when the buffer is + // 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_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 47809384..8e60e82a 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -177,6 +177,8 @@ namespace MobileGL::MG_ConfigLoader { features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64"); features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK"); features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64); + features.MagmaMaxDrawsPerCommandBuffer = + QueryEnvUint32("MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER", 16384, 0, 1u << 24); 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/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index b8369408..aaa66be1 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -6381,6 +6381,8 @@ void main() { Bool VulkanRenderer::SetupDraw(FrameContext::FrameData& frame, GLenum mode, Flags aspects, const DrawCmdParam& drawParams, const IndexBufferView* pIndexBufferView) { + // First, before anything of this draw is recorded or resolved. + SplitOversizedRecording(); // Sync each sampled texture at most once across this whole draw: the layout // probe loop, the post-transition loop, and ResolveSamplerDescriptor would // otherwise each re-run the full SyncTexture path on the same textures. @@ -6981,6 +6983,7 @@ void main() { } void VulkanRenderer::DispatchCompute(GLuint numGroupsX, GLuint numGroupsY, GLuint numGroupsZ) { + SplitOversizedRecording(); m_textureManager->CollectGarbage(); auto& frame = m_frameContext.GetCurrent(); // The DISPATCH accessor: with a pipeline bound this is its compute stage program @@ -7034,6 +7037,7 @@ void main() { } void VulkanRenderer::DispatchComputeIndirect(GLintptr indirect) { + SplitOversizedRecording(); m_textureManager->CollectGarbage(); auto& frame = m_frameContext.GetCurrent(); // See DispatchCompute: the dispatch accessor, not the draw one. @@ -12669,6 +12673,54 @@ void main() { return FlushPendingCommands(); } + void VulkanRenderer::SplitOversizedRecording() { + const Uint32 maxDraws = MG_Config::Features.MagmaMaxDrawsPerCommandBuffer; + if (maxDraws == 0) { + return; + } + // Counted before the check, so a buffer carries at most maxDraws calls before the one + // that splits it. OnFrameCommandRecordingBegan resets the count on every new recording. + if (++m_drawsInRecording <= maxDraws || !HasPendingRecordedWork()) { + return; + } + // Nothing of the current call is recorded yet, so the split falls between two GL calls, + // exactly where a glFlush would put it: the render pass ends with STORE, the next draw + // re-begins it with LOAD, and queries and transform feedback are bracketed per draw. + if (!FlushPendingCommands()) { + return; + } + // Fresh command buffer: the same reset every other mid-frame flush site applies. + m_lastSampledSetValid = false; + ++m_oversizedRecordingSplits; + MGLOG_I_ONCE("DirectVulkan: a frame recorded more than %u draws into one command buffer; submitting it and " + "continuing on a fresh one (MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER, 0 = unbounded)", + maxDraws); + MGLOG_D("DirectVulkan: split an oversized recording (%llu so far, submit index %llu)", + static_cast(m_oversizedRecordingSplits), + static_cast(m_submitCounter)); + + // Splitting only bounds the driver's command memory if the submitted buffers are freed + // as fast as they are produced. A GPU that falls behind would park them all on the + // retired list, which is the same growth spread over more buffers - so hold the queue + // to frames-in-flight + 1 outstanding submissions, the steady state of a pipelined + // frame plus this split. Wait on the fence directly rather than through + // WaitForSubmitIndex: its opportunistic TryDrainFrameTransients may rewind the transient + // arena, and this runs at the start of a draw whose caller may already hold slices of it. + const SizeT maxOutstanding = static_cast(m_frameContext.GetFrameCount()) + 1; + while (m_inFlightSubmits.size() > maxOutstanding) { + const SubmitRecord oldest = m_inFlightSubmits.front(); + if (oldest.fence == VK_NULL_HANDLE) { + break; + } + const VkResult waitResult = vkWaitForFences(m_device, 1, &oldest.fence, VK_TRUE, UINT64_MAX); + if (waitResult != VK_SUCCESS) { + MGLOG_E_ONCE("SplitOversizedRecording: vkWaitForFences returned %d", waitResult); + break; + } + OnSubmitsCompletedUpTo(oldest.submitIndex); + } + } + Bool VulkanRenderer::WaitForSubmitIndex(Uint64 submitIndex, Uint64 timeoutNs, Bool flushIfPending) { if (IsSubmitIndexComplete(submitIndex)) { return true; @@ -12707,6 +12759,7 @@ void main() { } void VulkanRenderer::OnFrameCommandRecordingBegan(VkCommandBuffer commandBuffer) { + m_drawsInRecording = 0; // Dynamic state does not survive a command-buffer boundary. ResetDynamicStateShadow(); InvalidateSetupDrawSnapshots(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index cbcde43a..90980415 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -490,6 +490,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { // only), so the presenting path's frames-in-flight pipelining is // untouched. Returns true when the drain ran. Bool TryDrainFrameTransients(); + // Bounded recording: called at the top of every GL draw and dispatch, before anything + // of that call is recorded. Once the open frame command buffer holds + // MagmaMaxDrawsPerCommandBuffer draws/dispatches, submits it (FlushPendingCommands) so + // the call continues on a fresh one, then waits until at most frames-in-flight + 1 + // submissions are outstanding. A driver backs a command buffer with GPU memory it only + // returns when the buffer is freed (Adreno: one 16 KiB mapping per chunk), so a frame + // that is never split grows it without bound - rd12's loading frame records ~1M draws + // into one buffer and runs the process out of vm.max_map_count. + void SplitOversizedRecording(); Vector m_inFlightSubmits; Vector m_freeSubmitFences; @@ -501,6 +510,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { // aging clocks, while present-less loops still cross a boundary every few // iterations. Reset in Present. Uint32 m_drainsSinceLastPresent = 0; + // GL draws/dispatches recorded into the open frame command buffer; reset whenever a + // recording begins (OnFrameCommandRecordingBegan). See SplitOversizedRecording. + Uint32 m_drawsInRecording = 0; + Uint64 m_oversizedRecordingSplits = 0; NativeWindowType m_window = 0; void* m_platformDisplay = nullptr; diff --git a/README.md b/README.md index 7c38f274..ac6c21e5 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ MobileGL supports runtime configuration via environment variables. | `MOBILEGL_ADVERTISE_FP64` | Advertise `GL_ARB_gpu_shader_fp64`. GLSL `double`/`dvec`/`dmat` compile and run either way - they are narrowed to 32 bits - so this only changes whether an application is told it has 64-bit precision, which it does not. | `0`, `1` | `0` | | `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_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` |