Files
MobileGL/MobileGL/MG_Remote/Client/PersistentMapTracker.cpp
T
swung0x48 cb06538c06 [MG_Remote, MG_Pipe, MG_Impl, MG_Test, MG_IntegrationTest, Docs] (Disaggregated): inproc performance package - dirty-driven persistent-map push, batched barrier waits, spin-clock batching
VD12 Minecraft on the Redmi (FCL, DirectGLES): the split's in-world frame rate
13 -> 60-66 fps against monolith's 110-114, and persistent-map wire bytes
25 MB/frame -> ~0.23 MB/frame. The residual split-specific costs are named at
the end, not hidden: the R-1 serialization (WaitForApplied ~12%, P11's
versioned-inputs design) and a split-only mutex complex still under
investigation.

Persistent-map push, three arms picked per buffer at map time:
- mprotect dirty tracker: the mapped range's page-aligned interior is
  protected PROT_READ; the app's next write faults, the SIGSEGV handler marks
  the page in the slot's bitmap and unprotects, and the push ships one 64 KB
  block per block containing a faulted page. Ownership is three tests -
  SEGV_ACCERR only (a userspace raise and an unmapped page chain verbatim),
  inside a live tracked range, and not a refault on a still-set bit (a write
  we unprotected succeeds, so that is a non-write access and it crashes
  honestly). The alignment is INWARD: an outward-aligned edge page holds
  foreign bytes, and a signal-blocking worker thread faulting on one is a
  process kill - that was the ctest-only SmallRing segfault. Edge spans
  (at most two per map) are hashed per push and ship their containing block
  only on change (unconditional edges measured 112 MB/frame). Re-arm is one
  mprotect per contiguous run. The first push ships everything once.
- hash-suppress arm (MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS, default 1):
  per-block XXH3 against the last push's hashes; untracked buffers only.
- PushDrawConsumers: only the buffers the upcoming draw/dispatch can read
  (VAO attribs/element, indexed binding points, indirect/parameter slots,
  TexBuffer backing - the last also retires the per-frame-cadence cloud
  twitch), plus the fault-epoch skip: an unmoved epoch proves no interior
  byte changed, tracked edges are hashed in place, and any hash-arm member
  vetoes. The membership map caches the tracker slot pointer (a 64-slot
  rescan per buffer per draw was measured and removed).

MOBILEGL_IPC_BATCH_WAITS (default 1; MOBILEGL_PIPE_VERIFY forces 0): a
value-class record (kCtxState/kCtxCso/kCtxObject with no reply slot) is
published without waiting for its own apply; the barrier is taken at the next
pull-reading verb, the only place BARRIER-PULLED fields are read, so the R-1
fence posts are untouched and only the round trips between them change. Two
consequences are pinned rather than argued: kCtxObject verbs the tests read
synchronously get explicit fences (GenerateMipmapFieldsCross,
FramebufferDeathCrosses), and a frontend object whose last SharedPtr drops on
the apply thread - impossible before batching - now enqueues its
(kind, lifetimeId) on the deferred destroy queue and the GL thread replays
the DestroyAndFree helper at the verb hooks (CopyImageLayeredScenario aborted
Fatal{RoleViolation, MGPipeSlots} without it).

Doorbell::Wait's spin reads the clock once per 64 yields: on the workload
device a steady_clock read is a real syscall (no vDSO) and the per-iteration
reads were ~20% of the whole process at ~1000 waits/frame.

Gates: gen_pipe --check/--self-test green; unit 2188/2188;
integration-split 110/111, the one red being GenerateMipmapDepthPixels whose
DirectGLES and DirectVulkan MONOLITH siblings flake identically with every
code path of this package provably inert (PushIsArmed false, no ClientSession,
deferral's monolith early-out).
2026-09-18 01:58:59 -04:00

837 lines
47 KiB
C++

// 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 <MG_Pipe/MGPipeTypes.h>
#include <MG_State/GLState/BufferState/BufferObject.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/TextureState/TextureObjectBuffer.h>
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
#include <MG_Util/Debug/Log.h>
#include <MG_Remote/Server/ServerLoop.h>
#include <xxhash.h>
#include <atomic>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <ucontext.h>
#include <unistd.h>
namespace MobileGL::MG_Remote::Client {
using MG_State::GLState::BufferObject;
// ---------------------------------------------------------------------------
// THE MPROTECT DIRTY TRACKER. Scanning a persistent write map for changes is
// O(range) per verb no matter how cheap the hash is (measured: a ~10 MB arena
// hashed at every draw is the whole frame at view distance 12). The MMU
// already knows what changed: after each push the range is protected
// PROT_READ, the application's next write faults, the handler marks the page
// dirty and makes it writable again, and the next push ships only the 64 KB
// blocks that contain a faulted page. The per-frame cost collapses from
// "hash every block of every arena at every draw" to "one fault per touched
// page per push-cycle plus the block pushes that always existed".
//
// THE TABLE IS A FIXED ARRAY, NOT A MAP: the SIGSEGV handler runs on ANY
// thread (chunk builders write arenas off the render thread) while
// registration runs on the GL thread, so the handler scans a lock-free array
// of atomics; the GL thread publishes an entry by writing pageBits, then end,
// then base with release ordering, and retires one by clearing base first.
//
// CHAINING IS LOAD-BEARING: the JVM uses SIGSEGV for implicit null checks,
// so a fault outside every tracked range MUST reach the previous handler or
// the first NPE after this installs is a process kill. Ownership is three
// tests, applied in the handler below: kernel-raised permission fault
// (SEGV_ACCERR - never a userspace raise, never an unmapped page), inside a
// live tracked range, and not an immediate same-instruction refault (a write
// we unprotected succeeds, so an instant refault is a non-write access and
// must crash honestly). Everything failing any test is chained verbatim.
// ---------------------------------------------------------------------------
namespace {
constexpr SizeT kMaxTrackedMaps = 64;
constexpr SizeT kPageShift = 12;
constexpr SizeT kPageBytes = 1u << kPageShift;
// The bitmap is FIXED-CAPACITY and never reallocated: a handler mid-flight on
// another thread can hold the pointer it loaded before a retire, so a grow-and-free
// would be a use-after-free no memory ordering can close. 64 K pages = 256 MB per
// tracked map, 8 KB per slot, allocated lazily once and kept for the process.
constexpr SizeT kMaxTrackedPages = 65536;
constexpr SizeT kPageBitsWords = kMaxTrackedPages / 64;
// Slot states for base: 0 = free, 1 = being set up (handler must skip: end is 0),
// anything else = live. The claim token is the sentinel, NOT the real base, so the
// handler can never observe a half-published entry (base live, end still stale).
constexpr uintptr_t kSlotSettingUp = 1;
TrackedWriteMap g_trackedMaps[kMaxTrackedMaps];
struct sigaction g_prevSegvAction {};
std::atomic<Bool> g_segvInstalled{false};
// Bumped by the handler for every write fault it answers. PushDrawConsumers
// compares it against the GL thread's last-walk value: an unmoved epoch proves
// no interior byte of any tracked map changed, because interior dirtiness can
// only ever arrive through a fault.
std::atomic<Uint64> g_faultEpoch{0};
// THE OWNERSHIP TESTS. "Ours" is a NARROW claim, and every fault that fails any
// one of these three tests is chained to the previous owner untouched, so a
// genuinely broken memory access crashes exactly as it would without us:
//
// (1) KERNEL-RAISED PERMISSION FAULT ONLY: si_code must be SEGV_ACCERR. A fault
// on an unmapped page (SEGV_MAPERR) is a wild pointer, and anything raised
// from userspace (raise/kill/tgkill/sigqueue) carries si_code <= 0 - this
// tracker never raises SIGSEGV itself, so a user-raised one is always foreign.
// (2) INSIDE A LIVE TRACKED RANGE: a fault anywhere else is the application's.
// (3) NOT A REFAULT WITHOUT RE-ARM: a write to a page this handler just
// unprotected SUCCEEDS, so a fault on a page whose dirty bit is still set
// is either a sibling thread mid-answer (answered once more, idempotently)
// or a non-write access such as an execute of the data page (chained, so it
// crashes honestly instead of refaulting forever). The page's own bit is
// the discriminator - see RefaultOfANonWriteAccess.
uintptr_t FaultPcFrom(void* ucontext) {
#if defined(__aarch64__)
return static_cast<uintptr_t>(
reinterpret_cast<ucontext_t*>(ucontext)->uc_mcontext.pc);
#elif defined(__x86_64__)
return static_cast<uintptr_t>(
reinterpret_cast<ucontext_t*>(ucontext)->uc_mcontext.gregs[REG_RIP]);
#else
// No PC access on this arch: the refault test is skipped, ownership rests
// on the si_code and range tests alone.
return 0;
#endif
}
struct FaultRepeatSlot {
std::atomic<pid_t> tid{0};
uintptr_t pc = 0;
uintptr_t address = 0;
Uint32 setBitAnswers = 0;
};
FaultRepeatSlot g_faultRepeats[8];
// THE REFAULT TEST, and its exact discriminator: the page's own bit. The
// tracker keeps ONE invariant - bit set <=> page writable, bit clear <=>
// page read-only (the handler sets-then-unprotects, the push
// clears-then-rearms). So at a fault, the bit the fetch_or just observed
// decides what this access can be:
//
// bit was CLEAR - the page was re-armed since the last answer, so this
// is a fresh write. A same-(thread, instruction, address) refault is
// EXPECTED here (malloc hands the same block in a re-armed shared
// page to the same free() site a frame later); it is not suspicious.
// bit was SET - the page should already be writable, so a WRITE cannot
// be what faulted. One exception: a sibling thread is mid-answer on
// the same page right now (it set the bit and has not unprotected
// yet). That thread's write succeeds once either answer lands, so it
// never comes back; a non-write access (an execute of the data page)
// comes back every time. Answer the first set-bit fault at a
// (thread, pc, address), chain the second.
Bool RefaultOfANonWriteAccess(uintptr_t pc, uintptr_t address, Bool bitWasSet) {
const pid_t tid = static_cast<pid_t>(syscall(SYS_gettid));
FaultRepeatSlot* slot = nullptr;
for (auto& candidate : g_faultRepeats) {
if (candidate.tid.load(std::memory_order_acquire) == tid) {
slot = &candidate;
break;
}
}
if (slot == nullptr) {
for (auto& candidate : g_faultRepeats) {
pid_t empty = 0;
if (candidate.tid.compare_exchange_strong(empty, tid)) {
slot = &candidate;
break;
}
}
}
// Full: evict deterministically. Losing a record costs the refault test on
// that thread, never a wrong answer - the next fault re-records it.
if (slot == nullptr) slot = &g_faultRepeats[static_cast<SizeT>(tid) & 7];
if (!bitWasSet) {
slot->pc = pc;
slot->address = address;
slot->setBitAnswers = 0;
return false;
}
const Bool same = slot->pc == pc && slot->address == address;
const Uint32 answers = same ? slot->setBitAnswers : 0;
if (answers >= 1) return true;
slot->pc = pc;
slot->address = address;
slot->setBitAnswers = answers + 1;
return false;
}
void ChainToPreviousSegvHandler(int sig, siginfo_t* info, void* ucontext) {
// Chain with the disposition the previous owner actually installed - the two
// entry points share a union, so SA_SIGINFO decides which signature is live,
// and the original info/ucontext go through verbatim so the next handler can
// run its own si_code test. For SIG_DFL (and the meaningless-but-possible
// SIG_IGN) restore the default and return: the faulting instruction
// re-executes, faults again, and dies the way an untracked fault would have.
if ((g_prevSegvAction.sa_flags & SA_SIGINFO) != 0) {
if (g_prevSegvAction.sa_sigaction != nullptr) {
g_prevSegvAction.sa_sigaction(sig, info, ucontext);
return;
}
} else if (g_prevSegvAction.sa_handler != SIG_DFL &&
g_prevSegvAction.sa_handler != SIG_IGN &&
g_prevSegvAction.sa_handler != nullptr) {
g_prevSegvAction.sa_handler(sig);
return;
}
struct sigaction restore {};
restore.sa_handler = SIG_DFL;
sigemptyset(&restore.sa_mask);
sigaction(SIGSEGV, &restore, nullptr);
}
void PersistentWriteFaultHandler(int sig, siginfo_t* info, void* ucontext) {
if (info != nullptr && info->si_code == SEGV_ACCERR) {
const uintptr_t address = reinterpret_cast<uintptr_t>(info->si_addr);
// EVERY overlapping slot gets its bit, not just the first: two tracked
// shadows can share an edge page (separate heap allocations, one page
// granularity), and unprotecting after only the first mark would leave the
// second buffer's block permanently un-pushed - the page is writable now,
// so it never faults again.
Bool matched = false;
Bool bitWasSet = false;
for (SizeT i = 0; i < kMaxTrackedMaps; ++i) {
const uintptr_t base =
g_trackedMaps[i].base.load(std::memory_order_acquire);
if (base <= kSlotSettingUp || address < base) continue;
const uintptr_t end =
g_trackedMaps[i].end.load(std::memory_order_acquire);
if (address >= end) continue;
const SizeT pageIndex = (address - base) >> kPageShift;
std::atomic<Uint64>* bits = g_trackedMaps[i].pageBits;
if (bits != nullptr) {
const Uint64 mask = 1ull << (pageIndex & 63);
const Uint64 old =
bits[pageIndex >> 6].fetch_or(mask, std::memory_order_relaxed);
if (!matched) bitWasSet = (old & mask) != 0;
}
matched = true;
}
if (matched &&
!RefaultOfANonWriteAccess(FaultPcFrom(ucontext), address, bitWasSet)) {
// The epoch moves BEFORE the unprotect: a walk that starts the
// moment the page becomes writable must still see this fault.
g_faultEpoch.fetch_add(1, std::memory_order_relaxed);
// Un-arm is a raw syscall, deliberately not the libc wrapper's
// bookkeeping: the handler's whole job is to get out of the way.
mprotect(reinterpret_cast<void*>(address & ~(kPageBytes - 1)),
kPageBytes, PROT_READ | PROT_WRITE);
return;
}
}
ChainToPreviousSegvHandler(sig, info, ucontext);
}
void InstallWriteFaultHandler() {
Bool expected = false;
if (!g_segvInstalled.compare_exchange_strong(expected, true)) return;
struct sigaction action {};
action.sa_sigaction = &PersistentWriteFaultHandler;
sigemptyset(&action.sa_mask);
action.sa_flags = SA_SIGINFO;
if (sigaction(SIGSEGV, &action, &g_prevSegvAction) != 0) {
MGLOG_W("MGPipe: persistent-map mprotect tracking unavailable (sigaction "
"failed); persistent write maps fall back to the hash scan");
g_prevSegvAction = {};
g_segvInstalled.store(false);
}
}
void UntrackWriteMap(Uint64 lifetimeId);
Bool TrackWriteMap(Uint64 lifetimeId, const Uint8* shadow,
SizeT rangeBegin, SizeT rangeEnd) {
if (shadow == nullptr || rangeEnd <= rangeBegin) return false;
InstallWriteFaultHandler();
if (!g_segvInstalled.load()) return false;
// INWARD alignment, and the reason is a process-killer: with OUTWARD
// alignment the protected edge pages hold FOREIGN bytes (the shadow is a
// heap allocation; malloc metadata and other allocations share its first
// and last page). A foreign write to such a page is fine for a thread
// that runs our handler - it is marked and unprotected - but driver and
// runtime worker threads commonly block every signal, and a fault there
// with SIGSEGV blocked ends the process on the spot, undebuggable.
// Protecting only pages FULLY inside the mapped range means every byte of
// a protected page is the shadow's own, and no other allocation can ever
// be made to fault by us. The head/tail partial pages stay writable and
// their blocks are pushed unconditionally at every push (at most two).
const uintptr_t rawBegin = reinterpret_cast<uintptr_t>(shadow + rangeBegin);
const uintptr_t rawEnd = reinterpret_cast<uintptr_t>(shadow + rangeEnd);
const uintptr_t base = (rawBegin + kPageBytes - 1) &
~static_cast<uintptr_t>(kPageBytes - 1);
const uintptr_t end = rawEnd & ~static_cast<uintptr_t>(kPageBytes - 1);
if (end <= base) return false; // sub-page range: the hash arm has it
const SizeT pageCount = (end - base) >> kPageShift;
if (pageCount > kMaxTrackedPages) {
MGLOG_W("MGPipe: persistent write map of %zu pages exceeds the mprotect "
"tracker's %zu-page capacity; this buffer falls back to the hash scan",
pageCount, kMaxTrackedPages);
return false;
}
// A remap/re-register of the same buffer must not stack a second slot on the
// same lifetimeId: retire whatever this id had first. No-op when untracked.
UntrackWriteMap(lifetimeId);
for (SizeT i = 0; i < kMaxTrackedMaps; ++i) {
uintptr_t empty = 0;
if (!g_trackedMaps[i].base.compare_exchange_strong(empty, kSlotSettingUp)) {
continue;
}
// Claimed. From here until the final base store the slot reads as
// (base=1, end=0), which the handler skips on both tests.
if (g_trackedMaps[i].pageBits == nullptr) {
g_trackedMaps[i].pageBits = new std::atomic<Uint64>[kPageBitsWords];
}
g_trackedMaps[i].pageCount = pageCount;
g_trackedMaps[i].lifetimeId = lifetimeId;
g_trackedMaps[i].edgeHashHead = 0;
g_trackedMaps[i].edgeHashTail = 0;
g_trackedMaps[i].hasEdges = base != rawBegin || end != rawEnd;
// EVERY BIT SET, NOT ZEROED - the same "fresh state pushes everything
// once" rule the hash arm keeps: the server has never seen these bytes,
// so the first push must ship the whole interior, not just the pages the
// application happened to write first. The first push then clears and
// re-arms page by page, and the set-bit read the fault discriminator
// does on a pre-first-push fault is answered once and unprotected -
// harmless, and the invariant (bit set <=> writable) holds from the
// first push onward. The last word is masked: a set bit past pageCount
// would re-arm a page outside the interior - a foreign page, which is
// the process-killer the inward alignment exists to keep out.
const SizeT wordCount = (pageCount + 63) / 64;
for (SizeT w = 0; w < wordCount; ++w) {
const SizeT bitsInWord =
(w == wordCount - 1 && (pageCount & 63) != 0) ? (pageCount & 63) : 64;
g_trackedMaps[i].pageBits[w].store(
bitsInWord == 64 ? ~0ull : ((1ull << bitsInWord) - 1),
std::memory_order_relaxed);
}
if (mprotect(reinterpret_cast<void*>(base), end - base, PROT_READ) != 0) {
g_trackedMaps[i].base.store(0, std::memory_order_release);
return false;
}
// Publish order is end, then base: the handler reads base first, and a base
// it can observe is only ever one whose end is already visible.
g_trackedMaps[i].end.store(end, std::memory_order_release);
g_trackedMaps[i].base.store(base, std::memory_order_release);
return true;
}
MGLOG_W("MGPipe: the mprotect tracker is full (%zu live persistent write maps); "
"this buffer falls back to the hash scan",
kMaxTrackedMaps);
return false;
}
void UntrackWriteMap(Uint64 lifetimeId) {
for (SizeT i = 0; i < kMaxTrackedMaps; ++i) {
if (g_trackedMaps[i].lifetimeId != lifetimeId ||
g_trackedMaps[i].base.load(std::memory_order_acquire) == 0) {
continue;
}
const uintptr_t base = g_trackedMaps[i].base.load(std::memory_order_acquire);
const uintptr_t end = g_trackedMaps[i].end.load(std::memory_order_acquire);
// Retire first, then unprotect: a fault racing the retire falls through to
// the previous handler, which is the honest answer for a page we no longer
// own; a fault racing the unprotect is impossible, the page is writable.
// end goes to 0 with the retire so a later re-claim of this slot reads
// (base=1, end=0) for its whole setup, which the handler skips.
g_trackedMaps[i].base.store(0, std::memory_order_release);
g_trackedMaps[i].end.store(0, std::memory_order_release);
g_trackedMaps[i].lifetimeId = 0;
mprotect(reinterpret_cast<void*>(base), end - base, PROT_READ | PROT_WRITE);
return;
}
}
// Every live slot, restored writable and freed. Test teardown only: without it a
// cleared fixture would leave its shadow pages PROT_READ behind, and the next
// fixture writing the recycled heap would fault into ranges whose owner is gone.
void UntrackAllWriteMaps() {
for (SizeT i = 0; i < kMaxTrackedMaps; ++i) {
const uintptr_t base = g_trackedMaps[i].base.load(std::memory_order_acquire);
if (base <= kSlotSettingUp) continue;
const uintptr_t end = g_trackedMaps[i].end.load(std::memory_order_acquire);
g_trackedMaps[i].base.store(0, std::memory_order_release);
g_trackedMaps[i].end.store(0, std::memory_order_release);
g_trackedMaps[i].lifetimeId = 0;
mprotect(reinterpret_cast<void*>(base), end - base, PROT_READ | PROT_WRITE);
}
}
TrackedWriteMap* TrackedMapFor(Uint64 lifetimeId) {
for (SizeT i = 0; i < kMaxTrackedMaps; ++i) {
if (g_trackedMaps[i].lifetimeId == lifetimeId &&
g_trackedMaps[i].base.load(std::memory_order_acquire) > kSlotSettingUp) {
return &g_trackedMaps[i];
}
}
return nullptr;
}
} // namespace
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<Uint64>(MG_Config::Ipc.PersistentBlockKb) * 1024ull;
}
Bool PersistentMapTracker::PushIsArmed() {
return MG_Config::Transport != MG_Config::TransportMode::Monolith;
}
Bool PersistentMapTracker::OnServerRole() { return Server::ServerLoop::OnApplyThread(); }
// 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();
// Unaccount the OLD state first: a re-notified member keeps its membership but
// may move arms (a resize re-registers the tracker), and the epoch skip's veto
// set must follow the arm the member actually runs on. Erase is a no-op for a
// member that was never large-untracked.
m_untrackedMembers.erase(key);
if (IsLivePersistentMap(buffer)) {
const auto range = buffer.GetMappedRange();
// The mprotect arm registers beside the hash arm: tracked buffers are pushed
// from the fault bitmap, everything else keeps the content scan. A failed
// registration (handler unavailable, table full, unaligned zero range) quietly
// leaves the buffer on the hash path.
TrackWriteMap(key, buffer.MappedData(), static_cast<SizeT>(range.start),
static_cast<SizeT>(range.end));
TrackedWriteMap* slot = TrackedMapFor(key);
m_livePersistentMaps[key] = MemberEntry{&buffer, slot};
if (slot == nullptr) m_untrackedMembers[key] = 1;
return;
}
m_livePersistentMaps.erase(key);
UntrackWriteMap(key);
}
void PersistentMapTracker::Forget(const BufferObject& buffer) {
const Uint64 key = buffer.GetLifetimeId();
m_untrackedMembers.erase(key);
m_livePersistentMaps.erase(key);
m_blockHashes.erase(key);
UntrackWriteMap(key);
}
void PersistentMapTracker::ClearForTest() {
m_livePersistentMaps.clear();
m_blockHashes.clear();
m_untrackedMembers.clear();
UntrackAllWriteMaps();
m_lastFaultEpoch = 0;
ResetCountersForTest();
}
void PersistentMapTracker::PushBlocksFor(BufferObject& buffer) {
if (!PushIsArmed()) return;
if (OnServerRole()) {
MGLOG_F("MGPipe: Fatal{RoleViolation, \"PushBlocksFor\"} - the persistent-map "
"producer belongs to the client; the server consumes transported bytes");
std::abort();
}
PushBlocksForChecked(buffer);
}
void PersistentMapTracker::PushBlocksForChecked(BufferObject& buffer) {
// 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.
//
// AND IT SAYS SO, ONCE. Until now this was a silent `return`, so E3(a)'s red could
// only ever be the scenario's pixel assertion and the control had no way to tell "the
// push was disabled" apart from "the push was never armed, or never reached, or the
// knob never got here" (joint-v1.md §3: "There is no Fatal for block size zero";
// ID-65 assigns the line to x2). The control now requires BOTH: the pixel red AND
// this line in the entry's own private log. It is MGLOG_W and not a Fatal because 0
// is a legal configured value whose whole purpose is to keep running with the push
// off; aborting here would turn every E3(a) entry into a subprocess abort and take
// the pixel evidence with it.
if (blockBytes == 0) {
if (!m_blockZeroAnnounced) {
m_blockZeroAnnounced = true;
MGLOG_W("MGPipe: persistent-map push disabled - MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0 "
"is exit gate E3(a)'s NEGATIVE CONTROL, not 'unlimited': a live "
"persistent WRITE mapping's dirty blocks are NOT being pushed, so the "
"server draws whatever bytes last crossed by some other route. A lane "
"that stays green with this set is not getting its pixels from the push");
}
return;
}
const auto range = buffer.GetMappedRange();
const Uint64 begin = static_cast<Uint64>(range.start);
const Uint64 end = static_cast<Uint64>(range.end);
// THE MPROTECT ARM. A tracked buffer's push is driven by the fault bitmap: one
// 64 KB block per block that contains at least one faulted page, shipped with
// the whole machinery of every other arm (serial bump, HasDefinedContent,
// wire record), then the page is cleared and re-armed read-only. No scan, no
// hash, no per-draw cost for buffers the application did not touch. The two
// partial-page EDGE ranges beside the page-aligned interior (see TrackWriteMap's
// inward alignment) are never protected, so nothing tells us they changed -
// their blocks ride along on every push, at most two.
TrackedWriteMap* tracked = nullptr;
if (const auto it = m_livePersistentMaps.find(buffer.GetLifetimeId());
it != m_livePersistentMaps.end() && it->second.tracked != nullptr &&
it->second.tracked->lifetimeId == buffer.GetLifetimeId()) {
tracked = it->second.tracked;
}
if (tracked != nullptr) {
const uintptr_t trackedBase = tracked->base.load(std::memory_order_acquire);
const uintptr_t trackedEnd = tracked->end.load(std::memory_order_acquire);
const uintptr_t shadowBase = reinterpret_cast<uintptr_t>(buffer.MappedData());
if (trackedBase != 0 && trackedEnd > trackedBase && shadowBase != 0) {
const Uint64 interiorBegin =
static_cast<Uint64>(trackedBase - shadowBase); // >= begin
const Uint64 interiorEnd =
static_cast<Uint64>(trackedEnd - shadowBase); // <= end
// Pushes are made in ASCENDING offset order, and `pushedUpTo` trims each
// span against the one before it: edge spans, duplicate pages inside one
// 64 KB block and faulted blocks overlapping an edge all collapse to one
// wire record per covered interval. The edge spans cover their WHOLE
// containing block rather than just the edge bytes, so a head edge and
// the faulted pages of the same first block merge into the one block
// push the hash arm would have made - the wire shape stays block-shaped.
// And an edge span ships ONLY when its bytes changed since the last push
// (the struct comment says why unconditional was measured and rejected):
// no faults happen on unprotected bytes, so the hash is the only change
// detector there is for them.
Uint64 pushedUpTo = begin;
const auto pushSpan = [&](Uint64 spanBegin, Uint64 spanEnd) {
if (spanBegin < pushedUpTo) spanBegin = pushedUpTo;
if (spanBegin >= spanEnd) return;
buffer.PushMappedSpanBlock(static_cast<SizeT>(spanBegin),
static_cast<SizeT>(spanEnd - spanBegin));
++m_blocksPushed;
m_bytesPushed += spanEnd - spanBegin;
pushedUpTo = spanEnd;
};
if (interiorBegin > begin) {
const Uint64 edgeHash = XXH3_64bits(
reinterpret_cast<const Uint8*>(shadowBase) + begin,
static_cast<size_t>(interiorBegin - begin));
if (edgeHash != tracked->edgeHashHead) {
tracked->edgeHashHead = edgeHash;
const Uint64 headBlockEnd = (begin / blockBytes + 1) * blockBytes;
pushSpan(begin, headBlockEnd < end ? headBlockEnd : end);
}
}
for (SizeT wordIndex = 0; wordIndex < (tracked->pageCount + 63) / 64; ++wordIndex) {
Uint64 word = tracked->pageBits[wordIndex].load(std::memory_order_relaxed);
while (word != 0) {
const SizeT bit = static_cast<SizeT>(__builtin_ctzll(word));
word &= word - 1;
const SizeT pageIndex = (wordIndex << 6) + bit;
// Clear the bit FIRST, per the order the correctness argument
// needs, but batch the re-arm: bits are visited in ascending
// page order, so a contiguous run of faulted pages is one
// mprotect instead of one per page. The order still holds for
// every page of the run - clear, then re-arm, then read - and a
// write in the clear-to-re-arm window lands before the run's
// reads, so it is inside the shipped bytes.
tracked->pageBits[wordIndex].fetch_and(~(1ull << bit),
std::memory_order_relaxed);
SizeT runEnd = pageIndex + 1;
while (word != 0) {
const SizeT nextBit = static_cast<SizeT>(__builtin_ctzll(word));
if ((wordIndex << 6) + nextBit != runEnd) break;
word &= word - 1;
tracked->pageBits[wordIndex].fetch_and(~(1ull << nextBit),
std::memory_order_relaxed);
++runEnd;
}
mprotect(reinterpret_cast<void*>(trackedBase + (pageIndex << kPageShift)),
(runEnd - pageIndex) << kPageShift, PROT_READ);
for (SizeT runPage = pageIndex; runPage < runEnd; ++runPage) {
// The interior was page-aligned INSIDE the mapped range at
// registration, so a faulted page's offset in buffer space is
// never negative; its containing block can still start before
// `begin` (a block is 64 KB, a page is 4 KB), and the clamp is
// what keeps the push inside [begin, end).
const Uint64 pageOffset =
(trackedBase + (runPage << kPageShift)) - shadowBase;
const Uint64 blockFloor = (pageOffset / blockBytes) * blockBytes;
const Uint64 blockStart =
blockFloor < begin ? begin : blockFloor;
const Uint64 blockEnd =
(end - blockStart) < blockBytes ? end : blockStart + blockBytes;
pushSpan(blockStart, blockEnd);
}
}
}
if (interiorEnd < end) {
const Uint64 edgeHash = XXH3_64bits(
reinterpret_cast<const Uint8*>(shadowBase) + interiorEnd,
static_cast<size_t>(end - interiorEnd));
if (edgeHash != tracked->edgeHashTail) {
tracked->edgeHashTail = edgeHash;
const Uint64 tailBlockStart = ((end - 1) / blockBytes) * blockBytes;
pushSpan(tailBlockStart > begin ? tailBlockStart : begin, end);
}
}
return;
}
}
// THE DIRTY-BLOCK PUSH (MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS). The whole-range push
// below is what the numbers made untenable on a real workload: every verb shipped the
// entire mapped range (measured ~25 MB/frame in a streamed world - one record, one
// staging copy, one server-side adoption and one barrier wait per 64 KB block). The
// app writes a small fraction of the range per frame, so each block is hashed against
// the shadow the last push saw and only changed blocks cross. Semantics are the
// push's own: a changed byte still reaches the server before the next consuming verb,
// and a hash of 0 is "unknown" (XXH64 of zero bytes is not zero), so a fresh state
// pushes everything once. API writes that bypass the map (NotifySubData) show up as a
// one-frame redundant push of the touched block, then the cache catches up - correct
// in both directions.
BlockHashState* state = nullptr;
const Uint8* shadow = nullptr;
if (MG_Config::Ipc.PersistentHashSuppress != 0) {
shadow = buffer.MappedData();
if (shadow != nullptr) {
state = &m_blockHashes[buffer.GetLifetimeId()];
const Uint64 blockCount = (end - begin + blockBytes - 1) / blockBytes;
// A remap, a resize or a block-size change invalidates the layout the
// hashes were taken against: reset, which forces one full push - the
// conservative answer for a new layout.
if (state->begin != begin || state->end != end || state->blockBytes != blockBytes ||
state->hashes.size() != blockCount) {
*state = {};
state->begin = begin;
state->end = end;
state->blockBytes = blockBytes;
state->hashes.assign(static_cast<SizeT>(blockCount), 0);
}
}
}
SizeT blockIndex = 0;
for (Uint64 at = begin; at < end; at += blockBytes, ++blockIndex) {
const Uint64 length = (end - at) < blockBytes ? (end - at) : blockBytes;
if (state != nullptr) {
const Uint64 h = XXH3_64bits(shadow + at, static_cast<size_t>(length));
if (h == state->hashes[blockIndex]) continue;
state->hashes[blockIndex] = h;
}
buffer.PushMappedSpanBlock(static_cast<SizeT>(at), static_cast<SizeT>(length));
++m_blocksPushed;
m_bytesPushed += length;
}
}
void PersistentMapTracker::PushAllMembers() {
if (!PushIsArmed()) return;
if (OnServerRole()) {
MGLOG_F("MGPipe: Fatal{RoleViolation, \"PushAllMembers\"} - the persistent-map "
"producer belongs to the client; the server consumes transported bytes");
std::abort();
}
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<BufferObject*> members;
members.reserve(m_livePersistentMaps.size());
for (const auto& entry : m_livePersistentMaps) members.push_back(entry.second.buffer);
for (BufferObject* buffer : members) {
if (buffer != nullptr) PushBlocksForChecked(*buffer);
}
}
void PersistentMapTracker::PushDrawConsumers() {
if (!PushIsArmed()) return;
if (OnServerRole()) {
MGLOG_F("MGPipe: Fatal{RoleViolation, \"PushDrawConsumers\"} - the persistent-map "
"producer belongs to the client; the server consumes transported bytes");
std::abort();
}
if (m_livePersistentMaps.empty()) return;
// THE EPOCH SKIP. Interior dirtiness can only arrive as a write fault, and the
// handler bumps the fault epoch for every one it answers before it unprotects;
// an unmoved epoch therefore proves no interior byte of any tracked map changed
// since the last walk. The skip still serves a tracked member's partial-page
// edge spans right here, per member, with no binding state read at all - they
// are never protected, so their two spans are hashed per push. An untracked
// (hash-arm) member vetoes the skip outright: only the binding walk knows
// whether this draw consumes it, and scanning every untracked member on faith
// measured strictly worse than the walk (VD12, on device). With none present
// and the epoch unmoved, nearly every draw in steady flight costs a handful of
// 4 KB edge hashes.
const Uint64 epoch = g_faultEpoch.load(std::memory_order_acquire);
if (m_untrackedMembers.empty() && epoch == m_lastFaultEpoch) {
for (const auto& entry : m_livePersistentMaps) {
BufferObject* member = entry.second.buffer;
if (member == nullptr) continue;
// The cached slot pointer, validated against the slot's own lifetimeId:
// a slot retired since the cache was written fails the test, as does one
// re-claimed by a different buffer.
const TrackedWriteMap* tracked = entry.second.tracked;
if (tracked != nullptr && tracked->lifetimeId == member->GetLifetimeId() &&
tracked->hasEdges) {
PushBlocksForChecked(*member);
}
}
return;
}
m_lastFaultEpoch = epoch;
const auto& ctx = MG_State::pGLContext;
if (ctx == nullptr) return;
// The buffers the upcoming draw or dispatch can read, and only those. Every one is
// named by the frontend's own binding state, read on this (the GL) thread - VAO
// attribute and element buffers, the indexed binding points of every indexed
// target, and the indirect / parameter slots. Client-sourced attributes (no
// BufferObject) live on this side already and need no push.
Vector<BufferObject*> consumers;
// One entry per buffer, not per binding point: an arena bound to six attribute
// slots would otherwise get its predicate re-check, bitmap walk and edge hashes
// run six times per draw.
const auto consume = [&consumers](BufferObject* buffer) {
if (buffer == nullptr) return;
for (const BufferObject* existing : consumers) {
if (existing == buffer) return;
}
consumers.push_back(buffer);
};
if (const auto& vao = ctx->GetBoundVertexArray()) {
for (Uint i = 0; i < MG_Pipe::kMGPipeMaxVertexAttribs; ++i) {
const auto& attrib = vao->GetAttribute(i);
if (attrib.Enabled && attrib.Buffer != nullptr) {
consume(attrib.Buffer.get());
}
}
if (const auto& element = vao->GetIndexBufferBindingSlot().GetBoundObject()) {
consume(element.get());
}
}
static constexpr BufferTarget kIndexedTargets[] = {
BufferTarget::Uniform, BufferTarget::ShaderStorage, BufferTarget::AtomicCounter,
BufferTarget::TransformFeedback, BufferTarget::CopyRead, BufferTarget::CopyWrite,
BufferTarget::Query, BufferTarget::Texture};
for (const BufferTarget target : kIndexedTargets) {
const SizeT count = ctx->GetBufferBindingPointCount(target);
for (SizeT i = 0; i < count; ++i) {
if (const auto& bound = ctx->GetBufferBindingPoint(target, i).GetBoundObject()) {
consume(bound.get());
}
}
}
static constexpr BufferTarget kSlotTargets[] = {BufferTarget::DrawIndirect,
BufferTarget::DispatchIndirect,
BufferTarget::Parameter};
for (const BufferTarget target : kSlotTargets) {
if (const auto& bound = ctx->GetBufferBindingSlot(target).GetBoundObject()) {
consume(bound.get());
}
}
// BUFFER TEXTURES (the cloud renderer's shape, and the gap the binding-point walk
// cannot see): a texture attached to a buffer with glTexBuffer is read by the draw
// through a texture unit, never through a buffer binding point. Walking the touched
// units and following a buffer-backed texture to its backing buffer is the only way
// those bytes earn their push - without this arm a persistent-mapped TexBuffer is
// only refreshed by the read-only verbs' whole push (once per present), which is
// exactly the per-frame-cadence cloud twitch this comment is written against.
const Int maxUnit = ctx->GetMaxTouchedTextureUnit();
for (Int unit = 0; unit <= maxUnit; ++unit) {
const auto& texture =
ctx->GetTextureUnitObject(unit).GetBindingSlot(TextureTarget::TextureBuffer).GetBoundObject();
if (texture == nullptr || texture->GetStorageType() != TextureStorageType::Buffer) {
continue;
}
auto* bufferTexture = static_cast<MG_State::GLState::TextureObjectBuffer*>(texture.get());
if (const auto& backing = bufferTexture->GetBufferBindingSlot().GetBoundObject()) {
consume(backing.get());
}
}
if (consumers.empty()) return;
for (BufferObject* buffer : consumers) {
if (buffer != nullptr && m_livePersistentMaps.count(buffer->GetLifetimeId()) != 0) {
PushBlocksForChecked(*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.
// 0 and 1 are the two CONTRACT §5 promises - a real cross-process shared mapping and a
// server-side staging map - and they name P11. Anything else is not a tier at all, and
// saying "P11 implements it" of a 7 would be a lie the operator then repeats. Both die
// here rather than at parse, which is late: the abort lands at the first
// map_persistent, so a mis-set run gets through EGL bring-up and a frame of setup
// first. Moving it to the parse means a knob-validity rule in ConfigLoader, which is
// c0's file; filed for the integrator rather than taken here.
if (tier <= 1) {
MGLOG_F("MGPipe: MOBILEGL_IPC_ADOPT_TIER=%u names adoption tier T%u, which P11 implements "
"and P5 does not; P5 runs at T2 (emulate) only.",
static_cast<unsigned>(tier), static_cast<unsigned>(tier));
} else {
MGLOG_F("MGPipe: MOBILEGL_IPC_ADOPT_TIER=%u is not an adoption tier; the only values are 0 "
"and 1 (P11) and 2 (emulate, the P5 default).",
static_cast<unsigned>(tier));
}
std::abort();
}
} // namespace MobileGL::MG_Remote::Client