Pipe: per-program global-constants key and frontend-written spans replace the client shadow diff.

This commit is contained in:
2026-10-09 18:26:43 -04:00
parent e9201891f5
commit e70925d35b
6 changed files with 205 additions and 43 deletions
@@ -1572,7 +1572,8 @@ namespace MobileGL::MG_Impl::GLImpl {
// version.
if (std::memcmp(pUBO + offset + byteOffsetInsideUniform, value, writeSize) == 0) return;
Memcpy(pUBO + offset + byteOffsetInsideUniform, value, writeSize);
programObject.MarkUBOContentDirty();
programObject.MarkUBOContentDirtyRange(static_cast<Uint>(offset + byteOffsetInsideUniform),
static_cast<Uint>(writeSize));
} else {
const auto& ttype = programObject.GetUniformTypeFacts(location);
if (!ttype.isTexture && !ttype.isImage) return;
+52 -40
View File
@@ -210,34 +210,33 @@ namespace MobileGL::MG_Pipe {
Uint64 bytes = 0;
const MGPipeHandle cso = AcquireShaderCso(*program, bytes);
// (ShaderCso, Version) IS the key, so the latch is the key: an unchanged pair means
// the server already holds these bytes and re-sending them would move the record's
// serial for nothing.
if (cso == m_constantsCso && version == m_constantsVersion) return bytes;
// THE KEY IS PER HANDLE, because the block is: the applier keeps one image per shader
// CSO record, so a program drawn again after another one ran in between still holds
// what it was last sent. An unchanged (ShaderCso, Version) pair means the server
// already holds these bytes and re-sending them would move the record's serial for
// nothing. The latch lives beside the record latch, so a recycled slot or a death
// retires it with that one; a re-issued create and Reset() retire it explicitly.
Latch* latch = MGPipeHandleIsNull(cso) ? nullptr : &LatchFor(cso);
const Bool held = latch != nullptr && latch->ConstantsLive && latch->ConstantsEpoch == m_constantsEpoch;
if (held && latch->ConstantsVersion == version) return bytes;
// Taken on every send, span or whole, so the range always describes the bytes
// written since the image the server holds for this program.
const auto dirty = program->TakeUBODirtyRange();
m_lastConstants = MGPGlobalConstants{};
m_lastConstants.ShaderCso = cso;
m_lastConstants.Version = version;
// P14: the server holds this program's previous image while the latch names it (every
// path that could drop or replace the applier's block clears the latch), so only the
// span that moved goes out - when it is small enough to be worth it.
m_constantsCso = cso;
m_constantsVersion = version;
// P14: the server holds this program's previous image while its latch is held (every
// path that could drop or replace the applier's block retires the latch), so only the
// span the frontend wrote since then goes out - when it is small enough to be worth it.
const Uint8* image = static_cast<const Uint8*>(program->GetUBOData());
if (cso == m_constantsCso && m_constantsShadow.size() == size && size <= 0xffffu) {
// Word-wise from both ends, then byte-wise inside the first differing word.
const Uint8* shadow = m_constantsShadow.data();
const auto word = [](const Uint8* at) {
Uint64 value;
std::memcpy(&value, at, sizeof(value));
return value;
};
Uint32 first = 0;
while (first + 8 <= size && word(image + first) == word(shadow + first)) first += 8;
while (first < size && image[first] == shadow[first]) ++first;
Uint32 last = size;
while (last >= first + 8 && word(image + last - 8) == word(shadow + last - 8)) last -= 8;
while (last > first && image[last - 1] == shadow[last - 1]) --last;
// Nothing moved but the version: one byte carries the new version.
if (first == size) {
if (held && latch->ConstantsSize == size && size <= 0xffffu) {
Uint32 first = std::min<Uint32>(dirty.Begin, size);
Uint32 last = std::min<Uint32>(dirty.End, size);
// Nothing written but the version moved: one byte carries the new version.
if (first >= last) {
first = 0;
last = 1;
}
@@ -248,8 +247,7 @@ namespace MobileGL::MG_Pipe {
m_lastConstants.Blob.Offset = reinterpret_cast<Uint64>(image + first);
m_lastConstants.Blob.Size = 0;
MGPipeRouteSetGlobalConstants(m_lastConstants, image + first, static_cast<Uint64>(length));
std::memcpy(m_constantsShadow.data() + first, image + first, length);
m_constantsVersion = version;
latch->ConstantsVersion = version;
++m_constantSets;
++m_constantSpans;
if (MG_Util::PipeStats::Enabled()) {
@@ -263,17 +261,19 @@ namespace MobileGL::MG_Pipe {
// pointer. Offset carries the staging address for diagnostics only; nothing reads
// it as a length.
m_lastConstants.Blob.Seg = kMGHostSpanSegNone;
m_lastConstants.Blob.Offset = reinterpret_cast<Uint64>(program->GetUBOData());
m_lastConstants.Blob.Offset = reinterpret_cast<Uint64>(image);
m_lastConstants.Blob.Size = 0;
// `size` is GetUBOSize(), and it is passed because the record declares 0 - the
// monolith convention (the bytes ride beside the record) that CONTRACT-P5 table 1
// rule A cannot keep under split. It is the row's largest and least bounded blob,
// per program per frame, so it is also the one R-10's max-record counter watches.
MGPipeRouteSetGlobalConstants(m_lastConstants, program->GetUBOData(),
static_cast<Uint64>(size));
m_constantsCso = cso;
m_constantsVersion = version;
m_constantsShadow.assign(image, image + size);
MGPipeRouteSetGlobalConstants(m_lastConstants, image, static_cast<Uint64>(size));
if (latch != nullptr) {
latch->ConstantsLive = true;
latch->ConstantsEpoch = m_constantsEpoch;
latch->ConstantsVersion = version;
latch->ConstantsSize = size;
}
++m_constantSets;
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::CsoBlobBytes, size);
@@ -508,7 +508,8 @@ namespace MobileGL::MG_Pipe {
// not happen to move the content version, of which a recycled slot is one.
// Invalidated rather than re-emitted here, because this function has no business
// deciding when the constants go out: the next EmitGlobalConstants sees an
// unlatched key and sends them.
// unlatched key and sends them whole.
latch.ConstantsLive = false;
if (m_constantsCso == handle) {
m_constantsCso = kMGPipeNullHandle;
m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
@@ -606,12 +607,13 @@ namespace MobileGL::MG_Pipe {
// The RECORD half stays, and that is the rule rather than an oversight: the applier
// keeps its shader-CSO records across a make-current because a program lives in a share
// group, and re-publishing one would move its Serial for nothing. The global-constants
// key goes with the working state because its record's bytes are per (Cso, Version) and
// a fresh server has not been told them.
// keys go with the working state because its record's bytes are per (Cso, Version) and
// a fresh server has not been told them: every per-handle key retires at once by epoch.
void Reset() {
m_boundCso = kMGPipeNullHandle;
m_drawCso = kMGPipeNullHandle;
m_dispatchCso = kMGPipeNullHandle;
++m_constantsEpoch;
m_constantsCso = kMGPipeNullHandle;
m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
// The composite memo's freshness goes with them - and only its freshness. Its
@@ -629,9 +631,10 @@ namespace MobileGL::MG_Pipe {
// ---- what a unit case reads ----
const MGPProgramDesc& LastProgramDesc() const { return m_lastDesc; }
const MGPGlobalConstants& LastGlobalConstants() const { return m_lastConstants; }
// THE (Cso, Version) KEY set_global_constants is suppressed against. Exposed so a case
// can pin that a re-issued create_shader_state invalidates it - the applier clears the
// block on the re-issue (wire W6), so a latch that survived it would never re-send.
// THE LAST (Cso, Version) set_global_constants sent. Exposed so a case can pin that a
// re-issued create_shader_state invalidates it - the applier clears the block on the
// re-issue (wire W6), so a latch that survived it would never re-send. The per-handle
// key the suppression reads retires at the same three places.
MGPipeHandle GlobalConstantsCso() const { return m_constantsCso; }
Uint32 GlobalConstantsVersion() const { return m_constantsVersion; }
// D-J3's counted refusal: programs whose linked snapshot carried more modules than
@@ -645,6 +648,7 @@ namespace MobileGL::MG_Pipe {
Uint64 DrawProgramSetCount() const { return m_drawSets; }
Uint64 DispatchProgramSetCount() const { return m_dispatchSets; }
Uint64 GlobalConstantsSetCount() const { return m_constantSets; }
Uint64 GlobalConstantsSpanCount() const { return m_constantSpans; }
// P5e (pg). The record a case reads back, and the two counters D-J3's rule asks for:
// how many set_program_bindings went out, and how many programs were REFUSED because
// one of their three sets is larger than the record can name. A refusal leaves the
@@ -691,6 +695,13 @@ namespace MobileGL::MG_Pipe {
Uint32 BindingsGen = 0;
Uint32 BindingsBackendStateVersion = 0;
Uint32 BindingsBlockBindingVersion = 0;
// set_global_constants' key, here for the bindings' reason: what the applier's record
// at this handle holds, as (Version, Size), valid only while ConstantsLive and the
// epoch is the emitter's current one.
Bool ConstantsLive = false;
Uint32 ConstantsVersion = 0;
Uint32 ConstantsSize = 0;
Uint64 ConstantsEpoch = 0;
};
// TWO TABLES, NOT A WIDER ONE, and it is the allocator's own reason repeated where it
@@ -745,11 +756,12 @@ namespace MobileGL::MG_Pipe {
MGPipeHandle m_boundCso = kMGPipeNullHandle;
MGPipeHandle m_drawCso = kMGPipeNullHandle;
MGPipeHandle m_dispatchCso = kMGPipeNullHandle;
// The last (Cso, Version) set_global_constants sent, for the unit cases; the suppression
// itself reads the per-handle Latch keys.
MGPipeHandle m_constantsCso = kMGPipeNullHandle;
Uint32 m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
// The image last sent for m_constantsCso (whole or patched by spans): what the server
// holds while the latch names it.
Vector<Uint8> m_constantsShadow;
// Moved by Reset(): a Latch's constants key holds only at the epoch it was taken in.
Uint64 m_constantsEpoch = 1;
Uint64 m_constantSpans = 0;
Uint64 m_creates = 0;
+1 -1
View File
@@ -611,7 +611,7 @@ namespace MobileGL::MG_State {
continue;
}
Memcpy(destinationUbo + destinationOffset, sourceUbo + sourceOffset, span);
destination.MarkUBOContentDirty();
destination.MarkUBOContentDirtyRange(destinationOffset, static_cast<Uint>(span));
}
}
}
@@ -744,8 +744,31 @@ namespace MobileGL::MG_State::GLState {
// can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the
// backends' "never uploaded" sentinel, so skip over it on wrap.
Uint32 GetUBOContentVersion() const { return m_uboContentVersion; }
// A write whose extent is not known marks the whole shadow.
void MarkUBOContentDirty() const {
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0;
m_uboDirtyBegin = 0;
m_uboDirtyEnd = ~Uint32{0};
}
void MarkUBOContentDirtyRange(Uint offset, Uint size) const {
if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0;
m_uboDirtyBegin = std::min<Uint32>(m_uboDirtyBegin, offset);
m_uboDirtyEnd = std::max<Uint32>(m_uboDirtyEnd, offset + size);
}
// The byte range of the shadow written since the last take, cleared by taking it. The
// pipe's program emitter is its one taker: it sends this range as a span to a server
// that already holds the image as of its previous take. End is NOT clamped (a whole-
// shadow mark reads ~0u), and Begin >= End means nothing was written. A new object
// starts as wholly written.
struct UBODirtyRange {
Uint32 Begin;
Uint32 End;
};
UBODirtyRange TakeUBODirtyRange() const {
const UBODirtyRange range{m_uboDirtyBegin, m_uboDirtyEnd};
m_uboDirtyBegin = ~Uint32{0};
m_uboDirtyEnd = 0;
return range;
}
// ---- the reserved gl_NumSamples stand-in (ShaderTranspiler::NUM_SAMPLES_UNIFORM_NAME) ----
@@ -771,7 +794,7 @@ namespace MobileGL::MG_State::GLState {
Memcpy(&current, slot, sizeof(Int));
if (current == samples) return true;
Memcpy(slot, &samples, sizeof(Int));
MarkUBOContentDirty();
MarkUBOContentDirtyRange(offset, static_cast<Uint>(sizeof(Int)));
return true;
}
// ---- glUniform* inside the phase-A -> phase-B window ----
@@ -1440,6 +1463,8 @@ namespace MobileGL::MG_State::GLState {
mutable SizeT m_backendHashMemoNextSlot = 0;
mutable Uint32 m_backendHashMemoVersion = ~0u;
mutable Uint32 m_uboContentVersion = 0;
mutable Uint32 m_uboDirtyBegin = 0;
mutable Uint32 m_uboDirtyEnd = ~Uint32{0};
mutable Uint32 m_linkVersion = 0;
// ---- Link OUTPUT ----
+110
View File
@@ -735,6 +735,116 @@ void main() { gl_Position = vec4(0.0); EmitVertex(); }
}
EXPECT_GT(Emitter().GlobalConstantsSetCount(), setsBefore);
}
// A default uniform block wide enough that one vec4 is less than half of it, so a write to
// one uniform crosses as a span rather than as the whole block.
const char* kVsWide = R"(#version 430 core
uniform vec4 u_a;
uniform vec4 u_b;
uniform vec4 u_c;
uniform vec4 u_d;
void main() { gl_Position = u_a + u_b + u_c + u_d; }
)";
GLuint MakeWideProgram() {
const GLuint program = GL::CreateProgram();
GL::AttachShader(program, MakeShader(GL_VERTEX_SHADER, kVsWide));
GL::AttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFs));
GL::LinkProgram(program);
GLint linked = GL_FALSE;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "the wide program did not link";
return program;
}
// Binds `name` and drives both program emissions to their fixed point (the first
// EmitGlobalConstants reads the version before GetUBOSize() joins phase B, which bumps it).
void DrawWith(GLuint name) {
GL::UseProgram(name);
Emitter().EmitShaderState(Ctx());
for (int i = 0; i < 8 && Emitter().EmitGlobalConstants(Ctx()) > 0u; ++i) {
}
}
// THE set_global_constants KEY IS PER HANDLE, because the applier keeps one block per shader
// CSO record: a program drawn again after another one ran in between, with no uniform of its
// own written, still holds on the server what it was last sent. A single last-sent key re-sent
// the whole block on every such switch back.
TEST(ProgramEmit, ASwitchBackToAnUnchangedProgramSendsNoGlobalConstants) {
EmitterScope scope;
const GLuint a = MakeWideProgram();
const GLuint b = MakeWideProgram();
DrawWith(a);
DrawWith(b);
const Uint64 setsBefore = Emitter().GlobalConstantsSetCount();
ASSERT_GT(setsBefore, 0u) << "both programs declare uniforms, so blocks must have gone out";
GL::UseProgram(a);
Emitter().EmitShaderState(Ctx());
EXPECT_EQ(Emitter().EmitGlobalConstants(Ctx()), 0u);
EXPECT_EQ(Emitter().GlobalConstantsSetCount(), setsBefore)
<< "the server already holds program a's block at this version";
}
// The span is the range the frontend WROTE since the image the server holds for that program,
// and it survives a switch to another program and back.
TEST(ProgramEmit, AUniformWriteAfterASwitchCrossesAsTheSpanItWrote) {
EmitterScope scope;
const GLuint a = MakeWideProgram();
const GLuint b = MakeWideProgram();
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(a);
ASSERT_TRUE(program);
DrawWith(a);
DrawWith(b);
GL::UseProgram(a);
const GLint location = GL::GetUniformLocation(a, "u_c");
ASSERT_GE(location, 0);
GL::Uniform4f(location, 1.0f, 2.0f, 3.0f, 4.0f);
const Uint offset = program->GetUniformOffset(static_cast<Uint>(location));
ASSERT_NE(offset, ProgramObject::kInvalidUniformOffset);
ASSERT_GE(program->GetUBOSize(), 32u) << "the block must be wide enough for a span";
const Uint64 spansBefore = Emitter().GlobalConstantsSpanCount();
Emitter().EmitShaderState(Ctx());
EXPECT_GT(Emitter().EmitGlobalConstants(Ctx()), 0u);
EXPECT_EQ(Emitter().GlobalConstantsSpanCount(), spansBefore + 1u);
EXPECT_EQ(Emitter().LastGlobalConstants().Range, (static_cast<Uint32>(offset) << 16) | 16u)
<< "the record must carry exactly the vec4 that was written";
EXPECT_EQ(Emitter().LastGlobalConstants().Blob.Offset,
reinterpret_cast<Uint64>(static_cast<const Uint8*>(program->GetUBOData()) + offset));
EXPECT_EQ(Emitter().LastGlobalConstants().Version, program->GetUBOContentVersion());
}
// A write whose extent the frontend does not know marks the whole block, and the next send is
// then the whole block rather than a span.
TEST(ProgramEmit, AnUnrangedContentMarkSendsTheWholeBlock) {
EmitterScope scope;
const GLuint a = MakeWideProgram();
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(a);
ASSERT_TRUE(program);
DrawWith(a);
program->MarkUBOContentDirty();
EXPECT_GT(Emitter().EmitGlobalConstants(Ctx()), 0u);
EXPECT_EQ(Emitter().LastGlobalConstants().Range, 0u);
EXPECT_EQ(Emitter().LastGlobalConstants().Version, program->GetUBOContentVersion());
}
// Reset() is a fresh server: every program's key retires at once, so the next send of an
// UNCHANGED program is its whole block.
TEST(ProgramEmit, AResetRetiresEveryProgramsGlobalConstantsKey) {
EmitterScope scope;
const GLuint a = MakeWideProgram();
DrawWith(a);
const Uint64 setsBefore = Emitter().GlobalConstantsSetCount();
Emitter().Reset();
Emitter().EmitShaderState(Ctx());
EXPECT_GT(Emitter().EmitGlobalConstants(Ctx()), 0u);
EXPECT_EQ(Emitter().GlobalConstantsSetCount(), setsBefore + 1u);
EXPECT_EQ(Emitter().LastGlobalConstants().Range, 0u);
}
// FINAL REVIEW C-2: the death helper forwards to this emitter before the slot is freed, so
// a dead program's handle no longer reads as published in the record memo between the death
// and the recycle (the memo's own Gen test covers only the recycle).
@@ -124,6 +124,20 @@ The plan and its measurements are in [PLAN-P15.md](PLAN-P15.md); the audit is in
the next lead is the GPU side (render-stage trace of Espryt vs MobileGlues passes), not driver calls.
4. Small CPU batch: ValidateForVerb residue, Tracker::Update (0.018, mostly inherent),
EmitGlobalConstants (0.026). Expected ≤0.03 ms in total.
- DONE on host, branch `cpubatch` (2026-10-09). **Device measurement pending.** Line-level
profile of fbm2 h-r1..r3 (both backends, p15h syms) and m2ab e-DirectVulkan r1..r3 (LTO).
- EmitGlobalConstants, cut: the (ShaderCso, Version) key is per handle (the applier keeps one
block per record), so switching back to a program whose uniforms did not move sends nothing,
and a moved one sends a span against its own held image instead of the whole block. The span
is the range the frontend wrote (ProgramObject::MarkUBOContentDirtyRange / TakeUBODirtyRange),
so the client shadow and its two-ended diff scan are gone. The scan plus shadow copies were
~0.0065 ms/frame; with the skipped switch-back sends, expect ~0.007-0.010 ms/frame.
- Tracker::Update: inherent, skipped. ~30 ns per verb over ~25 frontend counters; the only real
cut is the epoch skip (PLAN stage 1), which is not a small item.
- ValidateForVerb residue: skipped. The largest named piece (~0.008 ms/frame) is the
MOBILEGL_PIPE_POISON stamp walk, armed by MOBILEGL_BUILD_DISAGGREGATED. The bench libraries
are the plugin build (split ON); the FCL-embedded build (split OFF, INFO) compiles it out.
The rest (prologue, singleton guards, gates) is spread below 0.002 each.
5. API 29 build plus its A/B (`wip/api29-switch`).
6. Smaller follow-ups:
- DONE 2026-10-09: `bench_session.sh stop` now runs `pin_clocks.sh restore` first (verified over a