From e70925d35b798a8473ebd57aca96930e7bea09c0 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 9 Oct 2026 18:03:51 -0400 Subject: [PATCH] Pipe: per-program global-constants key and frontend-written spans replace the client shadow diff. --- .../MG_Impl/GLImpl/Program/GL_Program.cpp | 3 +- MobileGL/MG_Impl/Pipe/ProgramEmit.h | 92 ++++++++------- MobileGL/MG_State/GLState/Core.cpp | 2 +- .../GLState/ProgramState/ProgramObject.h | 27 ++++- MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp | 110 ++++++++++++++++++ docs/Disaggregated/notes/perf-p15/HANDOFF.md | 14 +++ 6 files changed, 205 insertions(+), 43 deletions(-) diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp index 32ccd1bd..da6ca51d 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp @@ -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(offset + byteOffsetInsideUniform), + static_cast(writeSize)); } else { const auto& ttype = programObject.GetUniformTypeFacts(location); if (!ttype.isTexture && !ttype.isImage) return; diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h index ef277f20..d6f91363 100644 --- a/MobileGL/MG_Impl/Pipe/ProgramEmit.h +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -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(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(dirty.Begin, size); + Uint32 last = std::min(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(image + first); m_lastConstants.Blob.Size = 0; MGPipeRouteSetGlobalConstants(m_lastConstants, image + first, static_cast(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(program->GetUBOData()); + m_lastConstants.Blob.Offset = reinterpret_cast(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(size)); - m_constantsCso = cso; - m_constantsVersion = version; - m_constantsShadow.assign(image, image + size); + MGPipeRouteSetGlobalConstants(m_lastConstants, image, static_cast(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 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; diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index f1b7c1e1..868fd568 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -611,7 +611,7 @@ namespace MobileGL::MG_State { continue; } Memcpy(destinationUbo + destinationOffset, sourceUbo + sourceOffset, span); - destination.MarkUBOContentDirty(); + destination.MarkUBOContentDirtyRange(destinationOffset, static_cast(span)); } } } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 63445e95..5fe20972 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -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(m_uboDirtyBegin, offset); + m_uboDirtyEnd = std::max(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(¤t, slot, sizeof(Int)); if (current == samples) return true; Memcpy(slot, &samples, sizeof(Int)); - MarkUBOContentDirty(); + MarkUBOContentDirtyRange(offset, static_cast(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 ---- diff --git a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp index cb8db765..2dc67915 100644 --- a/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp +++ b/MobileGL/MG_Test/Pipe/ProgramEmitTest.cpp @@ -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& 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(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(offset) << 16) | 16u) + << "the record must carry exactly the vec4 that was written"; + EXPECT_EQ(Emitter().LastGlobalConstants().Blob.Offset, + reinterpret_cast(static_cast(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& 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). diff --git a/docs/Disaggregated/notes/perf-p15/HANDOFF.md b/docs/Disaggregated/notes/perf-p15/HANDOFF.md index 412199ac..06848882 100644 --- a/docs/Disaggregated/notes/perf-p15/HANDOFF.md +++ b/docs/Disaggregated/notes/perf-p15/HANDOFF.md @@ -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