From 3327784fd04ed06da4e652589106aa2c9c33b54e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 28 Aug 2026 03:48:15 -0400 Subject: [PATCH] [Fix] (Espryt): adopt mesh-arena-sized stores into coherent persistent maps at definition, and stop the flush tiers from re-synchronizing them --- MobileGL/Config.h | 9 +++ MobileGL/ConfigLoader.cpp | 1 + MobileGL/MG_Backend/DirectGLES/Managers.cpp | 79 +++++++++++++++---- .../GLState/BufferState/BufferObject.cpp | 30 +++++++ .../GLState/BufferState/BufferObject.h | 4 + 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/MobileGL/Config.h b/MobileGL/Config.h index af01d4c0..f76c4263 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -184,6 +184,15 @@ namespace MobileGL::MG_Config { // on Mali both the immediate glBufferSubData and a staged copy into a busy // mutable store ghost the whole destination on the CPU. Bool EsprytDisableInvalidateFlush = false; + // MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION: keep mesh-arena-sized buffer stores + // (>= 16MiB) on the CPU-shadow model instead of backing them with the backend's + // persistently+coherently mapped storage at definition time (negative control / + // escape hatch). Frontend-scoped: it engages only where the active backend + // provides AcquirePersistentMap. With adoption on, an app SubData into a busy + // 128MB arena is a plain memcpy into GPU-visible memory; every driver-mediated + // route for the same write stalls the thread or ghost-copies the whole arena on + // this class of Mali driver, and the arena stops costing its size again in RAM. + Bool DisableLargeBufferAdoption = false; // MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION: make DirectGLES skip the native ES // depth/stencil reads and always go through the shader-sampling emulation. Core GL // ES has no depth or stencil readback, but some drivers accept it anyway (Mesa does, diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 42c2764b..87bcb038 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -187,6 +187,7 @@ namespace MobileGL::MG_ConfigLoader { features.EsprytDisableUnpackRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UNPACK_RING"); features.EsprytDisableUploadRing = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING"); features.EsprytDisableInvalidateFlush = QueryEnvFlag("MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH"); + features.DisableLargeBufferAdoption = QueryEnvFlag("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION"); features.EsprytForceDepthStencilReadbackEmulation = QueryEnvFlag("MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION"); features.RelaxedSemantics = QueryEnvFlag("MOBILEGL_RELAXED_SEMANTICS"); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index c5cc7121..f8b906cb 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -828,6 +828,12 @@ namespace MobileGL::MG_Backend::DirectGLES { return RingAvailable(g_uploadRing); } + // A partial range below this goes through the staging ring instead of a + // range-invalidating map: the map's page-substitution fast path needs a + // sizeable (page-coverable) range to engage, and below it the driver + // falls back to waiting out the WAR hazard on the CPU. + constexpr SizeT kInvalidateRangeMinBytes = 128u * 1024u; + // Push every queued range of `resource` from the shadow into the backend // store, without ever letting a driver resolve the WAR hazard against // in-flight frames at the WHOLE BUFFER's expense. Three tiers: @@ -879,11 +885,27 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT start = std::min(range.start, end); const SizeT size = end - start; if (size == 0) continue; - if (mapUsable) { + // The invalidating map's fast path is SHAPE-dependent on this Mali + // driver: a whole-buffer invalidation renames the store outright, + // and a large range gets fresh pages - but a small unaligned range + // of a busy store makes the map WAIT (osup_sync_object_wait, ~9% + // of a Minecraft 26.3 replay). So: whole buffer -> orphan-map; + // large range -> range-invalidating map; small range -> the staged + // ring copy, whose worst case (a whole-destination ghost) is only + // ever the small destination itself. + // + // The map covers EXACTLY the queued range: only those bytes are the + // shadow's to rewrite. Widening to page bounds looked free and was + // not - the widened bytes clobbered GPU-written data (an SSBO + // counter beside the app's SubData) with the stale shadow. + const Bool wholeBuffer = start == 0 && end == limit && limit == resource.storageSize; + if (mapUsable && (wholeBuffer || size >= kInvalidateRangeMinBytes)) { BindBufferId(TempBufferTarget, resource.id); + const GLbitfield access = + GL_MAP_WRITE_BIT | + (wholeBuffer ? GL_MAP_INVALIDATE_BUFFER_BIT : GL_MAP_INVALIDATE_RANGE_BIT); void* dst = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)start, - (GLsizeiptr)size, - GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT); + (GLsizeiptr)size, access); if (dst) { Memcpy(dst, bufferObject.MappedData() + start, size); g_GLESFuncs.glUnmapBuffer(TempBufferTarget); @@ -1055,17 +1077,22 @@ namespace MobileGL::MG_Backend::DirectGLES { resource->pendingRanges.Add({offset, offset + size}); return; } + // An adopted zero-copy persistent store already HAS the bytes (the + // frontend wrote them through the coherent mapping); a driver upload + // here would be a self-copy that re-synchronizes what coherent mapping + // made free. + if (resource->persistentMapped && resource->persistentPtr) { + resource->syncedChangeSerial = bufferObject.GetChangeSerial(); + return; + } // An immediate glBufferSubData resolves the WAR hazard against frames // still referencing this store on the CPU on some drivers - Mali parks // the thread in osup_sync_object_wait until every referencing job // retires, which serialized Minecraft 26.3's per-frame UBO/chunk-mesh // update streams into ~1 fps. Queue the range instead (the shadow // already holds the bytes) and let draw-time sync push the merged - // ranges through the staging ring. The zero-copy persistent store - // keeps the legacy immediate upload: draw-time sync never flushes - // ranges for it, and its mapping publishes writes by itself. - if ((resource->persistentMapped && resource->persistentPtr) || - MG_Config::Features.EsprytDisableUploadRing) { + // ranges through the staging ring. + if (MG_Config::Features.EsprytDisableUploadRing) { UploadRangeNow(*resource, bufferObject, offset, offset + size); resource->syncedChangeSerial = bufferObject.GetChangeSerial(); return; @@ -1087,13 +1114,23 @@ namespace MobileGL::MG_Backend::DirectGLES { return; } - // Same WAR-hazard rule as Ops_SubData: an immediate upload (mapped or - // glBufferSubData) can park the thread on Mali until the frames still - // referencing this store retire. Queue the range for the staged-copy - // flush at draw-time sync; only the zero-copy persistent store and the - // negative-control kill switch keep the immediate paths below. - if (!(resource->persistentMapped && resource->persistentPtr) && - !MG_Config::Features.EsprytDisableUploadRing) { + // An adopted zero-copy persistent store already HAS the bytes: the + // frontend shadow IS the coherent mapping the app (or UploadSubData) + // wrote into, so publishing is free. The self-copy that used to run + // here mapped a buffer this backend keeps persistently mapped (an + // INVALID_OPERATION whose fallback was a WAR-stalling + // glBufferSubData). + if (resource->persistentMapped && resource->persistentPtr) { + resource->syncedChangeSerial = bufferObject.GetChangeSerial(); + return; + } + + // Same WAR-hazard rule as Ops_SubData: an immediate synchronized upload + // (mapped or glBufferSubData) can park the thread on Mali until the + // frames still referencing this store retire. Queue the range for the + // staged flush at draw-time sync; the negative-control kill switch + // keeps the immediate paths below. + if (!MG_Config::Features.EsprytDisableUploadRing) { const std::lock_guard lock(resource->pendingMutex); resource->pendingRanges.Add(range); return; @@ -2628,7 +2665,17 @@ namespace MobileGL::MG_Backend::DirectGLES { } } } - if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures) { + // TEMP-EXP (leak texture deletes): /sdcard/MG/exp_leak_texture_deletes. + // Discriminator for the mali-mem-purge hiccup theory: never hand the + // driver a texture free, so the purge daemon has nothing to reclaim. + static const Bool s_expLeakTextureDeletes = [] { + FILE* f = std::fopen("/sdcard/MG/exp_leak_texture_deletes", "rb"); + if (!f) return false; + std::fclose(f); + return true; + }(); + if (m_contextGeneration == g_backendContextGeneration && g_GLESFuncs.glDeleteTextures && + !s_expLeakTextureDeletes) { g_GLESFuncs.glDeleteTextures(1, &m_backendTextureId); if (m_bufferImageSplitViewId != 0) { g_GLESFuncs.glDeleteTextures(1, &m_bufferImageSplitViewId); diff --git a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp index cd4c5b51..5c0fd40c 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp +++ b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp @@ -8,6 +8,8 @@ #include "BufferObject.h" +#include + #include namespace MobileGL::MG_State::GLState { @@ -126,6 +128,7 @@ namespace MobileGL::MG_State::GLState { // distinguishes the two cases, and it is cleared just above. m_storageFlags = GL_DYNAMIC_STORAGE_BIT | GL_MAP_READ_BIT | GL_MAP_WRITE_BIT; NotifyRespecify(); + TryAdoptLargeStorage(); } void BufferObject::Resize(SizeT size) { @@ -144,6 +147,33 @@ namespace MobileGL::MG_State::GLState { m_isImmutableStorage = true; m_storageFlags = storageFlags; NotifyRespecify(); + TryAdoptLargeStorage(); + } + + // Back a LARGE store with the backend's persistently+coherently mapped GPU + // storage the moment it is (re)defined, without waiting for the app to map it. + // Minecraft 26.3 streams chunk meshes into 128MB vertex arenas with plain + // glNamedBufferSubData - the one write API that carries no synchronization + // hint - and on Mali every route that hands the driver a write into a busy + // MUTABLE store either parks the calling thread (glBufferSubData, and + // glMapBufferRange even with GL_MAP_UNSYNCHRONIZED_BIT) or ghost-copies the + // whole destination on a driver worker (staged glCopyBufferSubData, and a + // range-invalidating map: ~167ms per touched arena, the recurring in-world + // hiccup). An adopted coherent map is the one shape with NO per-write driver + // call at all: every SubData lands as a plain memcpy into GPU-visible memory, + // and the shadow copy is dropped (a 128MB arena stops costing 128MB of RAM). + // Only attempted for stores the size of mesh arenas: small buffers keep the + // shadow model whose draw-time flush already prices them correctly. + void BufferObject::TryAdoptLargeStorage() { + constexpr SizeT kLargeBufferAdoptBytes = 16u * 1024u * 1024u; + if (MG_Config::Features.DisableLargeBufferAdoption) return; + if (m_size < kLargeBufferAdoptBytes) return; + if (m_resource.IsGpuResident()) return; + if (m_isMapped) return; + if (g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) return; + if (void* base = g_bufferBackendOps->AcquirePersistentMap(*this)) { + m_resource.AdoptPersistentMap(base); + } } void BufferObject::UploadData(DataPtr data, SizeT atOffset) { diff --git a/MobileGL/MG_State/GLState/BufferState/BufferObject.h b/MobileGL/MG_State/GLState/BufferState/BufferObject.h index 311c5698..756aedab 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferObject.h +++ b/MobileGL/MG_State/GLState/BufferState/BufferObject.h @@ -211,6 +211,10 @@ namespace MobileGL { // Sizes the store for a (re)definition, renewing an adopted GPU-resident // mapping across it. See the definition for why the renewal is not optional. void RedefineStorage(SizeT size); + // Backend-initiated coherent adoption for mesh-arena-sized stores; see the + // definition for the driver behavior that makes every other write route to + // a busy large mutable store a frame-scale stall. + void TryAdoptLargeStorage(); void NotifyRespecify(); void NotifySubData(SizeT offset, SizeT size); void NotifyFlushMappedRange(Range1D range, Flags appAccess);