[Fix] (Espryt): adopt mesh-arena-sized stores into coherent persistent maps at definition, and stop the flush tiers from re-synchronizing them

This commit is contained in:
2026-08-28 04:19:11 -04:00
parent ff426da3a9
commit 3327784fd0
5 changed files with 107 additions and 16 deletions
+9
View File
@@ -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,
+1
View File
@@ -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");
+63 -16
View File
@@ -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<std::mutex> 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);
@@ -8,6 +8,8 @@
#include "BufferObject.h"
#include <Config.h>
#include <atomic>
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) {
@@ -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<BufferMappingAccessBit> appAccess);