[Feat] (MG_State, BufferObject): under split cut the mapped span into blocks, publish the live-host-writes edge, and let the writeback rather than the request clear a GPU write

This commit is contained in:
2026-09-11 14:31:46 -04:00
parent 851a0b6f76
commit feca3994d1
2 changed files with 229 additions and 1 deletions
@@ -12,6 +12,14 @@
#include <atomic>
#include <MG_Pipe/PipeMutation.h>
#if MOBILEGL_BUILD_DISAGGREGATED
// The client role's persistent-map tracker. MG_State reaching into MG_Remote/Client is the
// layering ARCHITECTURE.md:575 names - the CLIENT role IS MG_State plus MG_Impl - and the
// edge exists only in a build that has the transport at all.
#include <MG_Remote/Client/GpuWritePending.h>
#include <MG_Remote/Client/PersistentMapTracker.h>
#include <MG_Util/Metrics/PipeStats.h>
#endif
namespace MobileGL::MG_State::GLState {
namespace {
@@ -47,6 +55,13 @@ namespace MobileGL::MG_State::GLState {
}
BufferObject::~BufferObject() {
#if MOBILEGL_BUILD_DISAGGREGATED
// Unconditional, not behind PushIsArmed(): the transport mode cannot change, but the
// tracker is a leaked singleton whose entries are raw pointers, and an entry that
// outlives its object is the one failure this set must not have. Forget is a no-op
// for a buffer that was never a member.
MG_Remote::Client::PersistentMapTracker::Instance().Forget(*this);
#endif
#if MOBILEGL_PIPE_PUSH
// P3a D-L: the buffer's death crosses as resource_destroy, which is the catalogue
// call for it - no seventh NotifyStateObjectDestroyed raiser is added, because that
@@ -166,6 +181,12 @@ namespace MobileGL::MG_State::GLState {
}
m_size = size;
m_resource.ResizeShadow(size);
#if MOBILEGL_BUILD_DISAGGREGATED
// The store this buffer's membership was about no longer exists, and ResizeShadow is
// reserve+resize - a grow past the reserve reallocates - so a pushed block's source
// base has moved too. Both are the same event to the tracker: re-read the predicate.
NotePersistentMapStateChanged();
#endif
}
void BufferObject::Respecify(SizeT size, const void* data) {
@@ -303,6 +324,12 @@ namespace MobileGL::MG_State::GLState {
m_mappedRange = {0, 0};
m_stagingBias = 0;
m_ownsStagingData = false;
#if MOBILEGL_BUILD_DISAGGREGATED
// AFTER the reset, so the predicate reads the post-unmap state, and after the landing
// above, so the last bytes of a write map are already on the wire when the record
// that says "no live writer" goes out behind them.
NotePersistentMapStateChanged();
#endif
}
void BufferObject::FlushMemoryRange(SizeT offset, SizeT length) {
@@ -339,6 +366,22 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::SyncPersistentMappedRange() {
#if MOBILEGL_BUILD_DISAGGREGATED
// SPLIT: the same span, cut into MOBILEGL_IPC_PERSISTENT_BLOCK_KB blocks, and the
// membership test is this function's own early-out chain read by the tracker rather
// than re-derived there. The 21 sites that call this (CONTRACT-P5.md section 3: 9
// Espryt + 12 Magma) therefore keep pushing at exactly the points monolith pushes
// at, which is what makes the split arm comparable to the monolith one at all; they
// retire into MG_Remote::Client::PushPersistentMapsBeforeVerb at P8.
//
// A block size of 0 disables the push (E3(a)'s negative control) and this is where
// that is felt: the bytes the application wrote through the pointer never leave, the
// frame draws the last uploaded ones, and PersistentCoherentMapScenario goes red.
if (MG_Remote::Client::PersistentMapTracker::PushIsArmed()) {
MG_Remote::Client::PersistentMapTracker::Instance().PushBlocksFor(*this);
return;
}
#endif
if (!m_isMapped) return;
// GPU-resident: the app already wrote directly into coherent GPU memory. This is
// the whole point of the persistent-map path - the per-draw whole-buffer re-upload
@@ -352,6 +395,76 @@ namespace MobileGL::MG_State::GLState {
NotifySubData(m_mappedRange.start, m_mappedRange.end - m_mappedRange.start);
}
#if MOBILEGL_BUILD_DISAGGREGATED
void BufferObject::PushMappedSpanBlock(SizeT offset, SizeT size) {
// NotifySubData and not MGPipeEmitResourceSubData directly: the serial bump, the
// defined-content promotion and the legacy-ops fallback are what the monolith span
// push does, and a second route to the same record is a second thing to keep in step.
if (size == 0) return;
NotifySubData(offset, size);
if (MG_Util::PipeStats::Enabled()) {
// THE persistent-map-push SITE. It is the bytes an application wrote through a
// map with no API call and that a split build therefore has to ship - the bytes
// PipeStats.cpp's inventory says stage-buffer explicitly does NOT cover, so the
// two classes do not double-count: stage-buffer is what a BACKEND stages out of
// its own shadow, this is what the CLIENT ships because it has no shadow on the
// far side.
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::PersistentMapPush,
static_cast<Uint64>(size));
}
}
void BufferObject::NotePersistentMapStateChanged() {
if (!MG_Remote::Client::PersistentMapTracker::PushIsArmed()) return;
MG_Remote::Client::PersistentMapTracker::Instance().NoteMapStateChanged(*this);
// THE LIVE-HOST-WRITES BIT (ARCHITECTURE.md 12, CONTRACT-P5.md table 1 row 19's
// neighbour). A live WRITE map - persistent or not - mutates the shadow with no call,
// no serial and no epoch, which is exactly why IsBufferDrawCleanByHandle had to ask
// the frontend object whether it was mapped. Under a spawn there is no object on that
// side, so the fact has to cross; it rides MGPResourceDesc's last pad byte as a
// METADATA field (ID-18 M4), so a respecify that moves only it allocates nothing,
// acks nothing and clears no pending upload.
//
// PUBLISHED ON THE EDGE, not per map: the record is a state announcement, and
// re-announcing an unchanged state on every map of a streaming buffer would be new
// traffic for no new information.
const Bool live = m_isMapped && (m_mappingAccess & BufferMappingAccessBit::Write) &&
!m_resource.IsGpuResident();
if (live == m_publishedLiveHostWrites) return;
m_publishedLiveHostWrites = live;
if (live) {
// RISING EDGE COSTS NOTHING. The bit rides every content record this buffer
// emits, and a live write map's first record is the first block push - or, for a
// non-persistent map, the landing at unmap. Emitting one here would be a record
// whose only content is a bit the very next record carries anyway.
return;
}
// FALLING EDGE NEEDS A CARRIER, because after an unmap there may be no further
// content record for the lifetime of the buffer, and a record stuck at "a host writer
// is live" reads draw-DIRTY forever - the mirror image of the C-1 regression, costing
// a re-upload per draw instead of losing bytes. So one block goes out with the bit
// already cleared above.
//
// ONE BLOCK AND NOT THE SPAN: the bytes of the span have already shipped on this same
// path - ReleaseMemory lands the staged writes and then calls NotifyFlushMappedRange
// for every non-resident write map that is not FLUSH_EXPLICIT - so this record exists
// to carry the STATE, and the bytes it carries are real, current and the cheapest
// honest ones there are. A zero-length record would have been the alternative and it
// is illegal by contract rule A.
if (m_size == 0) return;
const Uint64 blockBytes = MG_Remote::Client::PersistentMapTracker::BlockBytes();
const SizeT length = blockBytes == 0 || blockBytes >= static_cast<Uint64>(m_size)
? m_size
: static_cast<SizeT>(blockBytes);
NotifySubData(0, length);
}
Bool BufferObject::HasLiveHostWritesForWire() const {
return m_publishedLiveHostWrites;
}
#endif
void BufferObject::WritebackFromBackend(DataPtr data, SizeT atOffset) {
MOBILEGL_ASSERT(atOffset + data.size <= m_size,
"WritebackFromBackend out of bounds: atOffset (%zu) + data.size (%zu) > m_size (%zu)", atOffset,
@@ -359,6 +472,19 @@ namespace MobileGL::MG_State::GLState {
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
#if MOBILEGL_BUILD_DISAGGREGATED
// THE THIRD STATE'S ONLY EXIT. In a split build SyncGpuWrites does NOT clear the
// flag before emitting, because between the emission and the answer the shadow is
// stale and the object has no way to say so; the answer landing here is what makes it
// current, so the answer is what clears. Gated: in monolith SyncGpuWrites has already
// cleared by the time the applier calls back, and a second clear here would also
// retire a GPU write announced by something other than a readback - a ReadPixels into
// a pack PBO writes back through this same function.
if (MG_Config::Transport != MG_Config::TransportMode::Monolith &&
atOffset == 0 && data.size >= m_size) {
m_gpuWritePending = false;
}
#endif
}
void BufferObject::MarkGpuWritten() {
@@ -368,6 +494,44 @@ namespace MobileGL::MG_State::GLState {
void BufferObject::SyncGpuWrites() {
if (!m_gpuWritePending) return;
#if MOBILEGL_BUILD_DISAGGREGATED
// THE THIRD STATE (CONTRACT-P5.md section 3, ARCHITECTURE.md:509). This function has
// only ever been able to say "pending" or "not pending", and clearing first is safe
// in monolith because the readback is synchronous INSIDE the applier: the caller sees
// the reconciled shadow on return. Under a transport there is a third state - the
// readback was emitted and the answer has not arrived - which the flag cannot express,
// and clearing optimistically leaves the shadow silently stale for the object's life.
//
// So: emit, then BLOCK until OnBufferWriteback lands, and let the writeback do the
// clearing. ARCHITECTURE.md:509 lists "the first CPU read of a GPU-write-pending
// buffer" among the UNAVOIDABLE blocking points, because monolith already glFinish()es
// here; under R-1's verb barrier the block is nearly free, since the client is already
// waiting for appliedSeq to reach the record it just emitted.
//
// NO NARROWING (ResourceTracker.h:587-592's rangeCount == 1 assertion stays): this
// phase only gives the client a conservative set, and a zero-range announcement stays
// illegal until P8/P9 make it mean "fully narrowed - nothing is dirty".
if (MG_Config::Transport != MG_Config::TransportMode::Monolith) {
#if MOBILEGL_PIPE_PUSH
if (m_size != 0 && MG_Pipe::MGPipeResourceSubsystemEnabled()) {
MG_Pipe::MGPipeEmitResourceReadback(*this);
// The wait is the barrier's wait: the reply slot id IS the record's seq, so
// "my answer is back" and "appliedSeq reached me" are one condition. With no
// session (a build-split lane running monolith, and every unit case) the
// emission was synchronous and the writeback has already cleared the flag.
MG_Remote::Client::AwaitBufferWriteback(*this);
}
#endif
// A buffer with no readback route - no size, or a backend that registered no
// resource ops - can never catch up, and retrying on every subsequent read would
// only repeat the same no-op. That is the ONE case the monolith clear covers that
// the writeback cannot, so it is spelled out here rather than inherited.
if (m_gpuWritePending && !MG_Remote::Client::BufferWritebackIsReachable(*this)) {
m_gpuWritePending = false;
}
return;
}
#endif
// Cleared unconditionally: without a readback op the shadow can never catch up,
// and retrying on every subsequent read would only repeat the same no-op.
m_gpuWritePending = false;
@@ -564,6 +728,12 @@ namespace MobileGL::MG_State::GLState {
m_mappingAccess = (read ? BufferMappingAccessBit::Read : BufferMappingAccessBit::Null) |
(write ? BufferMappingAccessBit::Write : BufferMappingAccessBit::Null);
m_mappedRange = {0, m_size};
#if MOBILEGL_BUILD_DISAGGREGATED
// glMapBuffer never takes the Persistent bit, so this buffer can never join the
// push set - but a WRITE map still mutates the shadow with no call, which is the
// half of the live-host-writes bit that is not about the push at all.
NotePersistentMapStateChanged();
#endif
if (m_mappingAccess & BufferMappingAccessBit::Write) {
// glMapBuffer maps from offset 0, so no bias: the allocation's own
@@ -641,6 +811,14 @@ namespace MobileGL::MG_State::GLState {
m_isMapped = true;
m_mappingAccess = access;
m_mappedRange = range;
#if MOBILEGL_BUILD_DISAGGREGATED
// BEFORE the adoption attempt below, deliberately. Under R-6 the acquisition always
// declines, so the predicate this publishes is already final; if a later phase ever
// mints one, the adoption path notes the change itself and this entry is withdrawn
// there rather than never having been made - which is the order that keeps the set
// conservative under both answers.
NotePersistentMapStateChanged();
#endif
if (access & BufferMappingAccessBit::Persistent) {
m_ownsStagingData = false;
@@ -187,6 +187,48 @@ namespace MobileGL {
// from every path that reads the shadow on the app's behalf.
void SyncGpuWrites();
#if MOBILEGL_BUILD_DISAGGREGATED
// ---- P5 b1: the two things a split build has to do that a monolith does not ---
//
// Everything here is behind the build option AND behind
// `MG_Config::Transport != Monolith` at the call site, because an extra
// resource_subdata record or an extra respecify on the monolith path is new
// behaviour and D-J forbids it. The pull build compiles none of it, which is also
// how G1 holds over a file this central.
// ONE MOBILEGL_IPC_PERSISTENT_BLOCK_KB BLOCK of the mapped span, emitted through
// the private NotifySubData - the same serial, the same defined-content flag, the
// same record - so the split arm and the monolith arm differ in HOW the span is
// cut and in nothing else. Called only by
// MG_Remote::Client::PersistentMapTracker, which owns the cutting.
void PushMappedSpanBlock(SizeT offset, SizeT size);
// Called on every event that can move the tracker's membership predicate: map,
// unmap, respecify, adoption, destruction. It is one call rather than an
// insert/erase pair on purpose - the predicate is read from this object, so a
// caller that had to decide which of the two to call could decide differently
// from IsLivePersistentMap and the set would drift from the thing it models.
//
// It also PUBLISHES the live-host-writes bit when it changes: the server must
// know that a write map is live, because such a map mutates the shadow with no
// call, no serial and no epoch, and the applier's IsBufferDrawCleanByHandle can
// no longer ask this object (there is no object on that side of a spawn).
void NotePersistentMapStateChanged();
// What MGPipeBuildResourceDesc writes into MGPResourceDesc::HasLiveHostWrites.
// The published value, not the live predicate: the two are the same by the time
// any descriptor is built, and reading the published one is what makes a
// descriptor and the record that announced it agree by construction.
Bool HasLiveHostWritesForWire() const;
// Is a GPU write still unreconciled? Under split this is the THIRD STATE made
// readable: SyncGpuWrites no longer clears optimistically, so between the readback
// emission and OnBufferWriteback landing this stays true, and nothing else in the
// object can express that. Split-only so the pull build's layout and inlining do
// not move (G1).
Bool HasOutstandingGpuWrite() const { return m_gpuWritePending; }
#endif
Bool IsMapped() const;
Bool IsImmutableStorage() const;
SizeT GetSize() const;
@@ -264,8 +306,16 @@ namespace MobileGL {
Uint64 m_changeSerial = 0;
// See HasDefinedContent().
Bool m_hasDefinedContent = true;
// Set by MarkGpuWritten, cleared by SyncGpuWrites once the shadow is refreshed.
// Set by MarkGpuWritten, cleared by SyncGpuWrites once the shadow is refreshed -
// and, in a split build, cleared by WritebackFromBackend instead, because there
// the answer arrives later than the request. See SyncGpuWrites' definition.
Bool m_gpuWritePending = false;
#if MOBILEGL_BUILD_DISAGGREGATED
// The last value of MGPResourceDesc::HasLiveHostWrites this object published.
// Behind the option so the pull build's layout - and therefore every inlined
// constructor and accessor in it - does not move (G1).
Bool m_publishedLiveHostWrites = false;
#endif
Range1D m_mappedRange;
// The write-map staging store. MapAlignedData because the application is handed a
// pointer into it, and biased by m_stagingBias because ARB_map_buffer_alignment