diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a29344c..c354766f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -571,6 +571,11 @@ if (MOBILEGL_BUILD_DISAGGREGATED) MobileGL/MG_Remote/Client/ClientSession.cpp MobileGL/MG_Remote/Client/EmitTables.cpp MobileGL/MG_Remote/Client/CapsMirror.cpp + # P5 b1's two: the conservative GPU-write set the client must build because all six + # MarkGpuWritten producers are on the server's side of the line, and the + # block-granularity persistent-map push that tier T2 makes mandatory. + MobileGL/MG_Remote/Client/GpuWritePending.cpp + MobileGL/MG_Remote/Client/PersistentMapTracker.cpp MobileGL/MG_Remote/Server/ServerSession.cpp MobileGL/MG_Remote/Server/PipeApplier.cpp MobileGL/MG_Remote/Server/ServerLoop.cpp diff --git a/MobileGL/MG_Remote/Client/GpuWritePending.cpp b/MobileGL/MG_Remote/Client/GpuWritePending.cpp new file mode 100644 index 00000000..dd9b55d7 --- /dev/null +++ b/MobileGL/MG_Remote/Client/GpuWritePending.cpp @@ -0,0 +1,182 @@ +// MobileGL - MobileGL/MG_Remote/Client/GpuWritePending.cpp +// 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 + +#include "GpuWritePending.h" + +#include "ClientSession.h" + +#include +#include +#include +#include +#include + +namespace MobileGL::MG_Remote::Client { + + using MG_State::GLState::BufferObject; + using MobileGL::BufferTarget; + using MG_State::GLState::ImageTextureBinding; + + namespace { + // Per-row tallies. Diagnostics, and the only thing a unit case can assert on: an + // over-approximating set has no observable difference when a row fires too OFTEN, so + // "did this row fire at all, for this buffer" has to be readable directly. + Array(GpuWriteProducer::Count)> g_producerMarks{}; + + // The transform-feedback rows, shared by the draw walk (row 3) and by + // glEndTransformFeedback (row 5). Both mark the SAME set - the capture targets of the + // capture program - and the split exists only so the two can be counted apart. + void MarkTransformFeedbackTargets(GpuWriteProducer producer) { + auto& context = MG_State::pGLContext; + if (!context) return; + if (!context->IsTransformFeedbackActive()) return; + const auto& program = context->GetTransformFeedbackProgram(); + if (program == nullptr) return; + const SizeT declared = program->GetTransformFeedbackBufferCount(); + const SizeT count = + declared < MG_State::GLState::GLContext::MAX_TRANSFORM_FEEDBACK_BUFFERS + ? declared + : static_cast(MG_State::GLState::GLContext::MAX_TRANSFORM_FEEDBACK_BUFFERS); + for (SizeT i = 0; i < count; ++i) { + const auto& point = + context->GetBufferBindingPoint(BufferTarget::TransformFeedback, static_cast(i)); + MarkBufferForProducer(point.GetBoundObject(), producer); + } + } + + void MarkShaderStorageBindings() { + auto& context = MG_State::pGLContext; + if (!context) return; + // The TOUCHED count, exactly as the backend twin uses it + // (DirectGLES.cpp:559-560): the binding-point array is 84 entries wide and + // walking all of them on every draw is what the high-water mark exists to avoid. + const SizeT points = context->GetTouchedBufferBindingPointCount(BufferTarget::ShaderStorage); + for (SizeT i = 0; i < points; ++i) { + const auto& point = context->GetBufferBindingPoint(BufferTarget::ShaderStorage, static_cast(i)); + MarkBufferForProducer(point.GetBoundObject(), GpuWriteProducer::ShaderStorageBinding); + } + } + + void MarkAtomicCounterBindings() { + auto& context = MG_State::pGLContext; + if (!context) return; + // WIDER THAN ITS BACKEND TWIN, ON PURPOSE AND IN THE SAFE DIRECTION. + // SyncAtomicCounterBuffers (DirectGLES.cpp:578-583) walks the GL bindings the + // TRANSPILED program declared, which is a subset of what is bound; the client has + // the bindings but not that per-program list at this point, so it marks every + // touched atomic-counter point. Over-approximating costs a readback the narrowing + // channel then removes. Under-approximating reads a stale counter and says + // nothing, which is the failure every atomic-counter conformance case is. + const SizeT points = context->GetTouchedBufferBindingPointCount(BufferTarget::AtomicCounter); + for (SizeT i = 0; i < points; ++i) { + const auto& point = context->GetBufferBindingPoint(BufferTarget::AtomicCounter, static_cast(i)); + MarkBufferForProducer(point.GetBoundObject(), GpuWriteProducer::AtomicCounterBinding); + } + } + + void MarkWritableImageBufferTextures() { + auto& context = MG_State::pGLContext; + if (!context) return; + // The backend keeps a bitset of writable image-buffer units + // (DirectGLES.cpp:2350-2352, maintained from its own SyncImageTextureBinding) and + // the client has no equivalent, so it sweeps. The sweep is bounded by the array, + // not by a device limit read: MaxImageUnits would be a backend read, and a stale + // or absent backend object would silently shorten the walk - which is the one + // direction this set may not fail in. The loop body is a null test on a + // contiguous array until a unit is actually bound. P5 records cost and does not + // gate on it (2026-09-08 rule); an image-unit high-water mark on GLContext is the + // obvious narrowing and belongs with P8's binding-walk migration. + for (Int unit = 0; unit < MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS; ++unit) { + const auto& binding = context->GetImageTextureBinding(unit); + if (!ImageUnitIsAWritableBufferTexture(binding)) continue; + auto* textureBuffer = static_cast(binding.Texture.get()); + MarkBufferForProducer(textureBuffer->GetBufferBindingSlot().GetBoundObject(), + GpuWriteProducer::WritableImageBufferTexture); + } + } + } // namespace + + Bool GpuWriteSetIsClientSide() { + return MG_Config::Transport != MG_Config::TransportMode::Monolith; + } + + Bool ImageUnitIsAWritableBufferTexture(const ImageTextureBinding& binding) { + // Verbatim from IsWritableImageBufferTexture (DirectGLES.cpp:2354-2357). All three + // terms are client state; none of them is a driver question. + return binding.Texture != nullptr && binding.Access != GL_READ_ONLY && + binding.Texture->GetStorageType() == TextureStorageType::Buffer; + } + + void MarkBufferForProducer(const SharedPtr& buffer, GpuWriteProducer producer) { + if (!GpuWriteSetIsClientSide()) return; + if (buffer == nullptr) return; + if (producer >= GpuWriteProducer::Count) return; + buffer->MarkGpuWritten(); + ++g_producerMarks[static_cast(producer)]; + } + + void MarkGpuWritesForDraw() { + if (!GpuWriteSetIsClientSide()) return; + MarkShaderStorageBindings(); + MarkAtomicCounterBindings(); + MarkWritableImageBufferTextures(); + MarkTransformFeedbackTargets(GpuWriteProducer::TransformFeedbackCapture); + } + + void MarkGpuWritesForDispatch() { + if (!GpuWriteSetIsClientSide()) return; + MarkShaderStorageBindings(); + MarkAtomicCounterBindings(); + MarkWritableImageBufferTextures(); + } + + void MarkReadPixelsPackBuffer() { + if (!GpuWriteSetIsClientSide()) return; + auto& context = MG_State::pGLContext; + if (!context) return; + MarkBufferForProducer(context->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(), + GpuWriteProducer::ReadPixelsPackBuffer); + } + + void MarkEndTransformFeedbackCaptureTargets() { + if (!GpuWriteSetIsClientSide()) return; + MarkTransformFeedbackTargets(GpuWriteProducer::EndTransformFeedbackCapture); + } + + Uint64 ProducerMarkCount(GpuWriteProducer producer) { + if (producer >= GpuWriteProducer::Count) return 0; + return g_producerMarks[static_cast(producer)]; + } + + void ResetProducerMarkCountsForTest() { + g_producerMarks.fill(0); + } + + Bool BufferWritebackIsReachable(const BufferObject& buffer) { + if (buffer.GetSize() == 0) return false; +#if MOBILEGL_PIPE_PUSH + return MG_Pipe::MGPipeResourceSubsystemEnabled(); +#else + return false; +#endif + } + + void AwaitBufferWriteback(BufferObject& buffer) { + (void)buffer; + // THE WAIT IS THE BARRIER'S WAIT (R-3). The reply-slot id IS the record's seq, so + // "appliedSeq reached my readback" and "my answer is back" are one condition, and + // ClientSession::EmitAndWait has already paid for it by the time the emitter returns. + // With no session - a build-split lane running monolith, and every unit case - the + // emission WAS the application, synchronously, so the writeback has already landed + // and there is nothing to wait for. Spelling that as "return" rather than as a loop + // is deliberate: a loop here would be a hang in exactly that configuration, which is + // the configuration every gate lane runs. + if (ClientSession::Active() == nullptr) return; + } + +} // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/GpuWritePending.h b/MobileGL/MG_Remote/Client/GpuWritePending.h new file mode 100644 index 00000000..73f4cbf5 --- /dev/null +++ b/MobileGL/MG_Remote/Client/GpuWritePending.h @@ -0,0 +1,145 @@ +// MobileGL - MobileGL/MG_Remote/Client/GpuWritePending.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 + +// THE CLIENT-SIDE CONSERVATIVE GPU-WRITE SET (ARCHITECTURE.md:575 names this component; +// CONTRACT-P5.md section 3, table 2's first set). Owner: package b1. +// +// WHY IT HAS TO MOVE SIDES. `BufferObject::SyncGpuWrites` (BufferObject.cpp:369) runs +// synchronously, on the application's thread, the moment the application calls glMapBuffer or +// glGetBufferSubData. It has to answer "did the GPU write this buffer since I last read it?" +// - and today all six producers of that answer are BACKEND-side (DirectGLES.cpp:570, :618, +// :2603, UniformManager.cpp:1075, :1231, VulkanRenderer.cpp:11618), i.e. on the server's side +// of a split. Asking the server is a round trip the design forbids in steady state, so the +// client must build the set itself, from state it already owns: the SSBO and atomic-counter +// binding points, the image units whose Access is not GL_READ_ONLY, and the transform-feedback +// capture targets. +// +// CONSERVATIVE MEANS OVER-APPROXIMATE, AND THAT IS THE WHOLE DESIGN. The reverse channel's +// OnGpuWritten is a NARROWING channel (ResourceTracker.h:577-581): the client marks +// everything a shader COULD have written and the server only ever removes entries. So a row +// here that fires too often costs a readback; a row that fires too rarely reads a stale shadow +// and is silent. Every row below therefore mirrors its backend twin exactly, including the two +// places the twin is deliberately NARROW - a GL_READ_ONLY image binding is left alone, and the +// image walk runs from draw preparation rather than from glBindImageTexture's eager sync. +// +// NO NARROWING IN P5. ResourceTracker.h:587-592's `rangeCount == 1` assertion STAYS. Zero +// ranges will mean "a fully narrowed set - nothing is dirty" at P8/P9, and a package that +// reads ResourceTracker.h:577-581 alone will think it owns that already. It does not. +// +// THE TWO NEW PRODUCERS. Rows 4 and 5 have no backend twin: they are behaviour P5 ADDS +// (ARCHITECTURE.md:508), and both are strictly better than what monolith does. +// * glReadPixels into a pack PBO becomes fire-and-forget plus a client-side mark. Monolith +// maps the PBO and copies it into the shadow inside the call (DirectGLES.cpp:10983-10993), +// which is an unconditional stall on every glReadPixels whether or not anyone reads the +// shadow; marking instead defers the cost to the first read that actually wants it. +// * glEndTransformFeedback drops its unbounded ClientWaitSync (GL_Drawing.cpp:1367, timeout +// ~0ull) and marks the capture targets, for the same reason: the wait exists only so that +// a later MapBuffer sees real results, which is precisely what the flag is for. +// +// GATED ON THE TRANSPORT, like everything else in this package: on the monolith path the six +// backend sites still run and a second marker would be new behaviour (D-J), and rows 4 and 5 +// would remove a stall monolith is entitled to keep. + +#pragma once +#include + +#include + +namespace MobileGL::MG_State::GLState { + class BufferObject; + struct ImageTextureBinding; +} // namespace MobileGL::MG_State::GLState + +namespace MobileGL::MG_Remote::Client { + + // ONE ROW PER PRODUCER, and the enum is the inventory: six that mirror a backend + // MarkGpuWritten site one-for-one, two that P5 adds. Each has exactly one unit case, and + // the per-row counters below are what those cases assert on - a row that stops firing is + // otherwise invisible, because an over-approximating set fails SILENTLY in the direction + // that matters. + enum class GpuWriteProducer : Uint8 { + // DirectGLES.cpp:570 (MarkShaderStorageBuffersGpuWritten) and + // UniformManager.cpp:1231 (ResolveStorageBufferDescriptor): every SSBO binding point, + // unconditional, once the points are bound and the draw or dispatch is going out. + ShaderStorageBinding = 0, + // DirectGLES.cpp:618 (SyncAtomicCounterBuffers): every bound atomic counter. The + // point of a counter is that the shader increments it and every conformance case + // reads the increment back with glMapBufferRange or glGetBufferSubData. + AtomicCounterBinding, + // DirectGLES.cpp:2603 (MarkWritableImageBufferTexturesGpuWritten) and + // UniformManager.cpp:1075 (ResolveStorageTexelBufferDescriptor): a buffer texture on + // an image unit, ONLY when Access != GL_READ_ONLY. Marking a read-only binding would + // make the next map wait on - and then re-read - a dispatch that could not have + // changed a byte of it. + WritableImageBufferTexture, + // VulkanRenderer.cpp:11618 (BeginXfbCaptureForDraw): the capture targets, because + // "the capture is a GPU write like any shader's". + TransformFeedbackCapture, + // P5, new: glReadPixels into a bound GL_PIXEL_PACK_BUFFER. + ReadPixelsPackBuffer, + // P5, new: glEndTransformFeedback, in place of the unbounded fence wait. + EndTransformFeedbackCapture, + Count + }; + + // Transport != Monolith. False means every entry point below is a no-op and the six + // backend sites are still the only producers, which is exactly today's behaviour. + Bool GpuWriteSetIsClientSide(); + + // ---- the walks ------------------------------------------------------------------- + // + // Called from the client's own draw / dispatch emission point, BEFORE the verb record + // goes out, for the same ordering reason the persistent-map push has: the set must + // describe the work the record is about to start. + + // Rows 0, 1, 2 and 3. + void MarkGpuWritesForDraw(); + // Rows 0, 1 and 2. A dispatch has no transform feedback. + void MarkGpuWritesForDispatch(); + + // ---- the two new producers ------------------------------------------------------- + + // Row 4. Marks whatever is bound to GL_PIXEL_PACK_BUFFER, or nothing when the read goes + // to client memory - which is the case the backend's map-and-copy never had to consider, + // because it only ran when a PBO was bound in the first place. + void MarkReadPixelsPackBuffer(); + // Row 5. Must be called while the capture state is still ACTIVE: GLContext's + // EndTransformFeedback clears the live bindings, so a mark taken after it marks nothing. + void MarkEndTransformFeedbackCaptureTargets(); + + // ---- the row predicates, exposed so a unit case can drive one row at a time ------- + + // Row 2's discriminator, verbatim from DirectGLES.cpp:2354-2357. It is a function rather + // than three inline conditions at the call site because it is the one row whose backend + // twin is narrow on purpose, and a client that re-derived it slightly wider would mark + // read-only image bindings with nothing able to see that it had. + Bool ImageUnitIsAWritableBufferTexture(const MG_State::GLState::ImageTextureBinding& binding); + + // The one place a row actually marks. Null and duplicate marks are absorbed here so the + // walks stay readable, and the per-row counter moves only when a buffer really was + // marked. + void MarkBufferForProducer(const SharedPtr& buffer, + GpuWriteProducer producer); + + Uint64 ProducerMarkCount(GpuWriteProducer producer); + void ResetProducerMarkCountsForTest(); + + // ---- SyncGpuWrites' third state (CONTRACT-P5.md section 3) ----------------------- + + // Blocks until this buffer's OnBufferWriteback has landed. With no session - a build-split + // lane running monolith, and every unit case - the emission was synchronous and the answer + // is already in, so this returns at once; that is why it is a call rather than a loop the + // caller writes, because the loop would be a hang in exactly that configuration. + void AwaitBufferWriteback(MG_State::GLState::BufferObject& buffer); + + // Is there a readback route at all? A buffer with no size, or one whose backend registered + // no resource ops, can never catch up, and SyncGpuWrites must clear rather than block for + // ever. It is the ONE case monolith's unconditional clear covers that a writeback cannot. + Bool BufferWritebackIsReachable(const MG_State::GLState::BufferObject& buffer); + +} // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/PersistentMapTracker.cpp b/MobileGL/MG_Remote/Client/PersistentMapTracker.cpp new file mode 100644 index 00000000..90c48f1b --- /dev/null +++ b/MobileGL/MG_Remote/Client/PersistentMapTracker.cpp @@ -0,0 +1,132 @@ +// MobileGL - MobileGL/MG_Remote/Client/PersistentMapTracker.cpp +// 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 + +#include "PersistentMapTracker.h" + +#include +#include + +#include + +namespace MobileGL::MG_Remote::Client { + + using MG_State::GLState::BufferObject; + using MobileGL::BufferMappingAccessBit; + + PersistentMapTracker& PersistentMapTracker::Instance() { + // Leaked on purpose, once, like every other role-local singleton (ID-8): a buffer's + // destructor runs from exit handlers after this TU's globals would already be gone, + // and it calls Forget(). + static PersistentMapTracker* instance = new PersistentMapTracker{}; + return *instance; + } + + Uint64 PersistentMapTracker::BlockBytes() { + return static_cast(MG_Config::Ipc.PersistentBlockKb) * 1024ull; + } + + Bool PersistentMapTracker::PushIsArmed() { + return MG_Config::Transport != MG_Config::TransportMode::Monolith; + } + + // SyncPersistentMappedRange's early-out chain (BufferObject.cpp:341-353), in its order, + // read as a membership test. Every line here has a line there; if one of them moves, the + // unit case that drives both against each other is what says so. + Bool PersistentMapTracker::IsLivePersistentMap(const BufferObject& buffer) { + if (!buffer.IsMapped()) return false; + // GPU-resident: the application already wrote into coherent GPU memory and there is + // nothing to ship. At tier T2 this arm is unreachable - MapPersistent declines - but + // the predicate must still read the chain, not the tier: a build that reaches T0/T1 + // later must see this row answer for itself. + if (buffer.IsBackendPersistentMapped()) return false; + const auto access = buffer.GetMappingAccess(); + if (!(access & BufferMappingAccessBit::Persistent)) return false; + if (!(access & BufferMappingAccessBit::Write)) return false; + // FLUSH_EXPLICIT: the application promises to announce its own writes with + // glFlushMappedBufferRange, which already crosses as resource_flush_range. Pushing + // here as well would ship the same bytes twice and take the upload-shape decision + // away from the side that pays for it. + if (access & BufferMappingAccessBit::FlushExplicit) return false; + const auto range = buffer.GetMappedRange(); + if (range.start >= range.end) return false; + return true; + } + + void PersistentMapTracker::NoteMapStateChanged(BufferObject& buffer) { + const Uint64 key = buffer.GetLifetimeId(); + if (IsLivePersistentMap(buffer)) { + m_livePersistentMaps[key] = &buffer; + return; + } + m_livePersistentMaps.erase(key); + } + + void PersistentMapTracker::Forget(const BufferObject& buffer) { + m_livePersistentMaps.erase(buffer.GetLifetimeId()); + } + + void PersistentMapTracker::PushBlocksFor(BufferObject& buffer) { + if (!PushIsArmed()) return; + // Re-checked rather than trusted. The set is maintained at five events and a sixth + // one arriving without a NoteMapStateChanged would otherwise push a buffer whose + // shadow has been released - an adopted store's Bytes() is the GPU map, and reading + // it as if it were the shadow is how a "conservative" push turns into a fault. + if (!IsLivePersistentMap(buffer)) { + Forget(buffer); + return; + } + const Uint64 blockBytes = BlockBytes(); + // 0 IS THE NEGATIVE CONTROL, NOT "unlimited" (E3(a)). Pushing one whole-span block + // here would make the control green for the wrong reason - it has to disable the + // push, so that PersistentCoherentMapScenario draws the last uploaded bytes and goes + // red exactly the way an unpushed map does. + if (blockBytes == 0) return; + + const auto range = buffer.GetMappedRange(); + const Uint64 begin = static_cast(range.start); + const Uint64 end = static_cast(range.end); + for (Uint64 at = begin; at < end; at += blockBytes) { + const Uint64 length = (end - at) < blockBytes ? (end - at) : blockBytes; + buffer.PushMappedSpanBlock(static_cast(at), static_cast(length)); + ++m_blocksPushed; + m_bytesPushed += length; + } + } + + void PersistentMapTracker::PushAllMembers() { + if (!PushIsArmed()) return; + if (m_livePersistentMaps.empty()) return; + // Copied out first: PushBlocksFor can erase its own entry (a member that stopped + // being one), and ska::flat_hash_map invalidates on erase. + Vector members; + members.reserve(m_livePersistentMaps.size()); + for (const auto& entry : m_livePersistentMaps) members.push_back(entry.second); + for (BufferObject* buffer : members) { + if (buffer != nullptr) PushBlocksFor(*buffer); + } + } + + void PushPersistentMapsBeforeVerb() { + PersistentMapTracker::Instance().PushAllMembers(); + } + + Bool AdoptTierIsEmulate() { + const Uint32 tier = MG_Config::Ipc.AdoptTier; + if (tier == 2) return true; + // A NAMED refusal, not a silent fall back to T2. T0 (a real cross-process shared + // mapping) and T1 (a server-side staging map) are P11's, and the reason the knob + // parses them today is that the negative control needs a spelling before the thing + // it controls exists. Falling back would make `MOBILEGL_IPC_ADOPT_TIER=0` look like + // a working T0 run and silently produce pmap bytes it must not produce. + MGLOG_F("MGPipe: MOBILEGL_IPC_ADOPT_TIER=%u names an adoption tier P11 implements and P5 " + "does not; P5 runs at T2 (emulate) only.", + static_cast(tier)); + std::abort(); + } + +} // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Client/PersistentMapTracker.h b/MobileGL/MG_Remote/Client/PersistentMapTracker.h new file mode 100644 index 00000000..c8326a73 --- /dev/null +++ b/MobileGL/MG_Remote/Client/PersistentMapTracker.h @@ -0,0 +1,139 @@ +// MobileGL - MobileGL/MG_Remote/Client/PersistentMapTracker.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 + +// THE BLOCK-GRANULARITY PERSISTENT-MAP PUSH (P5 R-6, CONTRACT-P5.md section 3, table 2's +// second set). Owner: package b1. +// +// WHY THIS EXISTS AT ALL. A coherent persistent map is the one buffer shape with no per-write +// API call: the application memcpys through the pointer and neither a serial, an epoch nor a +// record moves. In monolith that is free, because the pointer IS the backend's GPU storage - +// MEASUREMENTS.md:87 prices the adoption at p99 163 -> 21 ms and ~400 MB saved, and +// ARCHITECTURE.md:481 requires it to hold unmoved for the whole monolith track. Across a +// process boundary the adopted address is meaningless, so P5 runs at tier T2 (emulate): the +// client keeps the shadow, MGPipeApplyMapPersistent declines, and the bytes the application +// wrote with no call have to be SHIPPED. `persistent-map-push` (PipeStats `pmap`) is exactly +// those bytes, and it is structurally zero while adoption survives - which is why forcing T2 +// and wiring the counter are one deliverable and not two. +// +// THE SET. `m_livePersistentMaps` is SyncPersistentMappedRange's own early-out chain +// (BufferObject.cpp:341-353) read as a membership test - mapped, NOT GPU-resident, Persistent, +// Write, NOT FlushExplicit, non-empty mapped range - and nothing else. Reading it as a +// predicate rather than re-deriving one is deliberate: the day that chain grows a sixth +// early-out, a re-derived predicate silently keeps pushing a buffer the monolith path stopped +// pushing, and the two arms diverge with no test able to say so. IsLivePersistentMap() is the +// single spelling; BufferObject::SyncPersistentMappedRange is held to it by a unit case. +// +// THE GRANULARITY. MOBILEGL_IPC_PERSISTENT_BLOCK_KB (default 64) blocks, keyed +// {MGPipeHandle, blockIndex} - the key is implicit, because a block ships as an ORDINARY +// resource_subdata record whose destination range is [blockIndex * blockBytes, + blockBytes). +// NO NEW RECORD KIND: the existing record is already chunked by +// MGPipeForEachSubDataRecordRange and already acceptance-gated, and a second way to say +// "these bytes go there" is a second way to get it wrong. A block size of 0 is E3(a)'s +// NEGATIVE CONTROL and means "push nothing" - not "one unlimited block" - so +// PersistentCoherentMapScenario must go red under it. +// +// PHASE 1 IS CONSERVATIVE, AND SAYS SO. The whole mapped span is pushed, by block, at every +// validate point; no dirty bits, no memcmp. Phase 2 (MOBILEGL_IPC_SHADOW_SHM, P6+) makes it +// precise, and ARCHITECTURE.md:499 grants it the right to be pulled forward if Phase 1 is +// unacceptable on the Create/Flywheel fixtures - the one place in the plan where a +// measurement may reorder phases. +// +// ORDERING IS THE CORRECTNESS PROPERTY, NOT THE GRANULARITY. In monolith the push is a memcpy +// on the same thread as the draw that follows it, so the bytes the application wrote before +// the draw are the bytes the draw sees. Under split both travel SEG_CMD in order, so the ring +// preserves it - PROVIDED the push is emitted AT the validate point and not lazily. A push +// deferred past its own draw record is the C-1 regression re-committed at the transport layer. + +#pragma once +#include + +#include + +namespace MobileGL::MG_State::GLState { + class BufferObject; +} + +namespace MobileGL::MG_Remote::Client { + + class PersistentMapTracker { + public: + // Client-role singleton (table 3: the MG_Impl/MG_Remote/Client singletons are + // client-exclusive). Leaks at exit for ID-8's reason, once per role-local singleton. + static PersistentMapTracker& Instance(); + + // MOBILEGL_IPC_PERSISTENT_BLOCK_KB * 1024. Zero means the push is OFF (E3(a)). + static Uint64 BlockBytes(); + // Transport != Monolith. The whole module is inert on the monolith path: an extra + // resource_subdata record there would be new behaviour, which D-J forbids. + static Bool PushIsArmed(); + + // SyncPersistentMappedRange's early-out chain as a predicate. THE only spelling. + static Bool IsLivePersistentMap(const MG_State::GLState::BufferObject& buffer); + + // Membership maintenance, both idempotent and both safe to call on a buffer that is + // not a member. Called from BufferObject on every event that can move the predicate: + // map, unmap, respecify, adoption, destruction. + void NoteMapStateChanged(MG_State::GLState::BufferObject& buffer); + void Forget(const MG_State::GLState::BufferObject& buffer); + + // One member's whole mapped span, by block. Re-checks the predicate first, so a + // member that stopped being one (an adoption, an unmap that did not route through + // NoteMapStateChanged) is dropped rather than pushed. + void PushBlocksFor(MG_State::GLState::BufferObject& buffer); + + // THE VALIDATE-POINT HOOK. Every member, before the verb record is emitted. + void PushAllMembers(); + + SizeT MemberCount() const { return m_livePersistentMaps.size(); } + Uint64 BlocksPushed() const { return m_blocksPushed; } + Uint64 BytesPushed() const { return m_bytesPushed; } + // Unit tests only: the counters are diagnostics, the set is not reset by it. + void ResetCountersForTest() { + m_blocksPushed = 0; + m_bytesPushed = 0; + } + void ClearForTest() { + m_livePersistentMaps.clear(); + ResetCountersForTest(); + } + + private: + // Keyed on BufferObject::GetLifetimeId(), which is globally unique and never reused - + // never the GL name (LIFO-recycled by glGenBuffers) and never the heap address + // (recycled by the allocator). The raw pointer is safe because every removal path is + // explicit: ~BufferObject and ReleaseMemory both call Forget/NoteMapStateChanged, and + // PushBlocksFor re-checks the predicate before it dereferences anything it kept. + UnorderedMap m_livePersistentMaps; + Uint64 m_blocksPushed = 0; + Uint64 m_bytesPushed = 0; + }; + + // What the client's emit table calls immediately BEFORE emitting any verb that can read a + // buffer (draw, dispatch, readback, blit, present). It is a free function rather than a + // method so the emit table does not have to name the singleton, and so the one-line call + // reads as what it is: "publish everything the application wrote with no call". + // + // In P5 the 21 SyncPersistentMappedRange sites (CONTRACT-P5.md section 3: 9 Espryt + 12 + // Magma, MEASUREMENTS.md:111's 20 being one low) still stand where they are and route + // into PushBlocksFor through BufferObject::SyncPersistentMappedRange, so the push already + // happens at every point monolith pushes at. They retire into THIS call at P8, when the + // draw-path binding walks move to the client. + void PushPersistentMapsBeforeVerb(); + + // R-6's tier gate, and the ONE spelling of it. True for MOBILEGL_IPC_ADOPT_TIER=2, the + // only tier P5 implements; 0 (a real cross-process shared mapping) and 1 (a server-side + // staging map) parse - so the negative control has a name before the thing it controls + // exists - and are a NAMED refusal here rather than a silent fall back to T2. It is asked + // by MGPipeApplyMapPersistent, which is where the decline is decided, so the client's + // three adoption call sites keep their existing "null means declined" branch and the + // map-persistent-roundtrips counter keeps counting ATTEMPTS in both arms (E3(c) asserts + // mpr is equal between the monolith and the split arm, which is only true if the decline + // happens after the count, on the applier's side of the emission). + Bool AdoptTierIsEmulate(); + +} // namespace MobileGL::MG_Remote::Client