diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h new file mode 100644 index 00000000..6a26b08d --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -0,0 +1,185 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/CsoCache.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The render-state CSO cache (ARCHITECTURE.md 4.5.2 / 5.3, P2 brief D7). +// +// THE LOOKUP, and the first step is the whole point: +// 1. m_pipelineStateVersion (widened) did not move -> reuse the last handle. ZERO hashing, +// zero probing, and nothing is emitted unless m_version also moved. That is the steady +// state of every frame, and it is why the tracker asks the cache at all only when the +// dirty walk says the pipeline version moved. +// 2. moved -> hash the 396 pipeline bytes, probe, and on a hit CONFIRM WITH A MEMCMP +// before reusing the handle. ARCHITECTURE.md 4.1 says content addressing on an +// xxHash; a bare 64-bit equality would let a collision alias two different render +// states onto one CSO, which is silent wrong pixels with no gate that can see it. +// Mesa's cso_cache memcmps for the same reason. The memcmp only ever runs on a +// pipeline-version change, i.e. never in the steady state. +// 3. miss -> mint a slot, emit create_render_state with every pipeline chunk, then bind. +// +// CAPACITY 64 (ROADMAP.md P2). 64 x (8 + 8 + 396 + 8) = about 26 KB per context. ROADMAP.md +// open question 4 says 64 is provisional and the counters retune it at P13; this ships 64 +// and publishes the mint / bind / evict counters that retune reads. +// +// THE NEGATIVE CONTROL. kMGPipeBehaviourNoCsoContentAddressing (bit 63 of the runtime +// MOBILEGL_PIPE_PUSH bitmask) turns off the PROBE and the handle reuse, not the records: +// every pipeline-version change then mints a fresh CSO, binds it and evicts, which is +// precisely "whole-block content addressing" and reproduces the regression +// RenderState.h records. It is what separates "push is slower" from "the CSO design is +// slower", and CsoContentAddressingScenario (package E) is the always-on ctest that stops +// the switch from rotting. +// +// Header-only for the same ownership reason as Tracker.h: the root CMakeLists.txt that +// would name a new .cpp is package A's and is frozen behind the p2/contract tag. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + + inline constexpr SizeT kMGPipeCsoCacheCapacity = 64; + + class MGPipeCsoCache { + public: + struct Counters { + Uint64 Mints = 0; // create_render_state emissions + Uint64 Binds = 0; // bind_render_state emissions, mint or reuse + Uint64 Hits = 0; // a probe that found a live entry and passed the memcmp + Uint64 Collisions = 0; // a hash hit the memcmp REJECTED - the reason it exists + Uint64 Evictions = 0; // LRU evictions, each one a delete_render_state + }; + + // The handle for `params`' pipeline subset. Mints and emits create_render_state on a + // miss; emits delete_render_state for whatever it evicts to make room. `payloadBytes` + // accumulates what went on the wire, for PipeStats::RecordDrawPayloadBytes. + MGPipeHandle Acquire(const RenderStateParameters& params, Uint64& payloadBytes) { + Array bytes; + MGPipeGatherPipelineBytes(params, bytes.data()); + + const Bool contentAddressed = + (MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0; + if (contentAddressed) { + const Uint64 hash = MGPipeHashPipelineBytes(bytes.data()); + for (SizeT i = 0; i < m_entries.size(); ++i) { + if (m_entries[i].Hash != hash) continue; + if (std::memcmp(m_entries[i].Bytes.data(), bytes.data(), bytes.size()) != 0) { + // A 64-bit collision between two DIFFERENT render states. Reusing the + // handle here would render one state with the other's pipeline, so the + // entry is dropped and the caller mints - correctness first, and the + // counter says how often it happened. + ++m_counters.Collisions; + Evict(i); + break; + } + m_entries[i].LastUsed = ++m_clock; + ++m_counters.Hits; + return m_entries[i].Cso; + } + return Mint(hash, bytes, payloadBytes); + } + // Content addressing OFF: never probe, always mint. The records still exist, so + // the arm differs from the default one in exactly one thing - whether a handle is + // reused - which is what makes it a control rather than a different design. + return Mint(0, bytes, payloadBytes); + } + + // Context teardown, a server reset, a unit test's fixture. Emits nothing: the applier + // is reset alongside, and a delete for a record that is about to be dropped anyway + // would be a wire message with no reader. + void Reset() { + for (auto& entry : m_entries) MGPipeSlots().Free(MGPipeKind::RenderStateCso, entry.Cso); + m_entries.clear(); + m_clock = 0; + } + + void ResetCounters() { m_counters = Counters{}; } + + SizeT Size() const { return m_entries.size(); } + const Counters& GetCounters() const { return m_counters; } + + private: + struct Entry { + Uint64 Hash = 0; + Uint64 LastUsed = 0; + MGPipeHandle Cso = kMGPipeNullHandle; + Array Bytes{}; + }; + + MGPipeHandle Mint(Uint64 hash, const Array& bytes, + Uint64& payloadBytes) { + if (m_entries.size() >= kMGPipeCsoCacheCapacity) { + SizeT victim = 0; + for (SizeT i = 1; i < m_entries.size(); ++i) { + if (m_entries[i].LastUsed < m_entries[victim].LastUsed) victim = i; + } + Evict(victim); + } + + const MGPipeHandle cso = MGPipeSlots().Allocate(MGPipeKind::RenderStateCso); + MGPRenderStateDesc desc{}; + desc.Cso = cso; + desc.BaseCso = kMGPipeNullHandle; + // A brand-new CSO names every pipeline chunk; the incremental form against a + // BaseCso is what the applier's assertion allows and P3 will use once a CSO is + // minted from a neighbour rather than from nothing. + desc.ChunkMask = kAllPipelineChunks; + desc.Blob.Size = kMGPipePipelineChunkBytes; + MGPipeApplyCreateRenderState(desc, bytes.data()); + payloadBytes += sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes; + + Entry entry; + entry.Hash = hash; + entry.LastUsed = ++m_clock; + entry.Cso = cso; + entry.Bytes = bytes; + m_entries.push_back(entry); + + ++m_counters.Mints; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoMints, 1); + } + return cso; + } + + void Evict(SizeT index) { + MGPHandleOnly handle{}; + handle.Handle = m_entries[index].Cso; + handle.Kind = static_cast(MGPipeKind::RenderStateCso); + MGPipeApplyDeleteRenderState(handle); + MGPipeSlots().Free(MGPipeKind::RenderStateCso, m_entries[index].Cso); + m_entries[index] = m_entries.back(); + m_entries.pop_back(); + ++m_counters.Evictions; + } + + static constexpr Uint32 kAllPipelineChunks = + static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + + Vector m_entries; + Uint64 m_clock = 0; + Counters m_counters; + }; + + // The monolith's one cache, held beside the tracker. A Vector scan rather than a hash + // map on purpose: 64 entries of Uint64 is a handful of cache lines, it is probed only + // when the pipeline version moved, and it keeps the eviction order in the same array as + // the content - a map would need a second structure to answer "which is oldest". + inline MGPipeCsoCache& MGPipeCsoCacheInstance() { + static MGPipeCsoCache cache; + return cache; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 89ae1e01..40b3845b 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -15,8 +15,11 @@ #include #include #include +#include #include #include +#include +#include #include #include @@ -34,6 +37,12 @@ namespace MobileGL::MG_Pipe { // accessor of the same name (P1 brief D4: no derivation logic is re-implemented // here, which is what keeps the copy semantically identical by construction). // A forwarded field has no storage and copies nothing. + // The two doors the P2 emission step needs into PipeInputs' storage. They exist + // only for ApplierDerivesRenderStateFields' one-shot probe below; nothing on the hot + // path writes through them. + static RenderStateParameters& RenderStateOf(PipeInputs& inputs) { return inputs.m_renderState; } + static Uint32& ClearStencilOf(PipeInputs& inputs) { return inputs.m_clearStencil; } + static void CopyField(PipeInputs& dst, GLContext& ctx, MGPipeInputField field) { using F = MGPipeInputField; using MG_State::GLState::BufferBindPointTargets; @@ -596,6 +605,150 @@ namespace MobileGL::MG_Pipe { MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount); } + + // ================================================================================ + // The emission step (P2 brief D1 step 3, D5, D6, D7) + // ================================================================================ + namespace { + // Which runtime MOBILEGL_PIPE_PUSH subsystem owns a field, through the call that now + // supplies it. Zero means "still pulled". + constexpr Uint64 SubsystemForEmitter(MGPipeFieldEmitter emitter) { + switch (emitter) { + case MGPipeFieldEmitter::BindRenderState: + case MGPipeFieldEmitter::CreateRenderState: + case MGPipeFieldEmitter::SetDynamicState: + return kMGPipeSubsystemRenderState; + case MGPipeFieldEmitter::SetPixelPackState: + return kMGPipeSubsystemPixelPack; + case MGPipeFieldEmitter::SetPatchState: + return kMGPipeSubsystemPatchState; + case MGPipeFieldEmitter::SetVertexAttribDefaults: + return kMGPipeSubsystemVertexAttribDefaults; + case MGPipeFieldEmitter::kNone: + break; + } + return 0; + } + + // Which of those subsystems THIS BUILD actually emits for. It grows one commit at a + // time, and a field whose emitter is not wired here keeps being pulled - so adding a + // row to Coverage.def can never silently drop a field on the floor before the call + // that carries it exists. + constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState; + + // The fields the applier writes DIRECTLY, out of the chunk bytes it scattered. Every + // other emitted field reaches PipeInputs only through + // MGPipeDeriveRenderStateFields, which is why the probe below exists. + constexpr Bool AppliedWithoutDerivation(MGPipeInputField field) { + switch (field) { + case MGPipeInputField::GetRenderStateParameters: + case MGPipeInputField::GetRenderStateParametersVersion: + case MGPipeInputField::GetPipelineStateVersion: + case MGPipeInputField::GetPixelStoreParameters: + case MGPipeInputField::GetPatchVertices: + case MGPipeInputField::GetPatchDefaultOuterLevel: + case MGPipeInputField::GetPatchDefaultInnerLevel: + case MGPipeInputField::GetCurrentVertexAttribute: + return true; + default: + return false; + } + } + + // DOES THIS TREE'S APPLIER ACTUALLY DERIVE? + // + // MGPipeDeriveRenderStateFields is package A's, and on the P2 contract tag it is a + // declared stub whose body lands in A's follow-on commit. A field that reaches + // PipeInputs only through that derivation must NOT be skipped by the residual fill + // while the derivation is a stub: skipping it would leave the mirror unwritten and + // the backend reading a default. + // + // Rather than hard-code which branch this is, the filler asks once: it puts a + // sentinel in a scratch block's working RenderStateParameters, clears the mirror the + // derivation is supposed to recompute, runs the derivation, and looks. The answer is + // latched for the process and costs one compare, once. + // + // It stays useful after A lands: if the derivation is ever deleted or gated off, the + // filler degrades to PULLING those fields instead of rendering a default, which is + // the safe direction. The verify lane and RenderStateSpansTest are what say the + // derivation is CORRECT; this only says it is THERE. + Bool ApplierDerivesRenderStateFields() { + static const Bool answer = [] { + static PipeInputs probe; + constexpr Uint32 kSentinel = 0x5a5a5a5au; + MGPipeFillAccess::RenderStateOf(probe).ClearStencil = kSentinel; + MGPipeFillAccess::ClearStencilOf(probe) = 0u; + MGPipeDeriveRenderStateFields(probe); + const Bool derives = MGPipeFillAccess::ClearStencilOf(probe) == kSentinel; + if (!derives) { + MGLOG_W_ONCE("MGPipe: MGPipeDeriveRenderStateFields does not derive on this " + "build - the render-state mirrors stay on the pull path"); + } + return derives; + }(); + return answer; + } + + constexpr Uint32 kAllDynamicChunks = + static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); + + // create/bind_render_state and set_dynamic_state. Returns the bytes that went on the + // wire, for the payload histogram. + Uint64 EmitRenderState(GLContext& ctx, Uint32 dirty, Bool freshlyPrimed) { + MGPipeTracker& tracker = MGPipeTrackerInstance(); + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + const auto version = static_cast(ctx.GetRenderStateParametersVersion()); + const auto pipelineVersion = static_cast(ctx.GetPipelineStateVersion()); + Uint64 payloadBytes = 0; + + if (freshlyPrimed) { + // A fresh context is a fresh server: the cache's handles name slots this + // client's allocator is about to hand out again, so both sides start over + // together rather than one of them remembering the other's objects. + MGPipeCsoCacheInstance().Reset(); + MGPipeApplierReset(); + } + + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) { + const MGPipeHandle cso = MGPipeCsoCacheInstance().Acquire(live, payloadBytes); + MGPBindRenderState bind{}; + bind.Cso = cso; + bind.Version = version; + bind.PipelineVersion = pipelineVersion; + MGPipeApplyBindRenderState(bind); + payloadBytes += sizeof(MGPBindRenderState); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoBinds, 1); + } + } + + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) { + // The chunk-level suppressor: only the dynamic chunks that differ from what + // the server has. A glViewport sends chunk D0 and nothing else; a + // glClearColor sends D2. An EMPTY mask still sends the 32-byte header, + // because the VERSION is what Magma's dynamic tail gates on and it moved. + const Uint32 chunkMask = + freshlyPrimed ? kAllDynamicChunks + : MGPipeDynamicChunksThatMoved(live, tracker.Staged()); + Array blob; + const SizeT blobBytes = MGPipeDynamicChunkBlobBytes(chunkMask); + MGPipeGatherDynamicChunks(live, chunkMask, blob.data()); + MGPDynamicState dyn{}; + dyn.ChunkMask = chunkMask; + dyn.Version = version; + dyn.Blob.Size = blobBytes; + MGPipeApplySetDynamicState(dyn, blob.data()); + payloadBytes += sizeof(MGPDynamicState) + blobBytes; + } + + if (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewRenderState))) { + tracker.Staged() = live; + } + return payloadBytes; + } + } // namespace + // ---- the validate point (P2 brief D1) ---- void MGPipeValidateForVerb(MGPipeVerb verb) { PipeInputs& inputs = gPipeInputs; @@ -628,10 +781,27 @@ namespace MobileGL::MG_Pipe { // The mask is computed, latched and counted here and nothing is emitted from it // yet: this commit is the safety net that says the walk is semantically free // before any field stops being pulled. The emission steps land on top of it. - const Uint32 dirty = MGPipeTrackerInstance().Update(*ctx, verbClass); - (void)dirty; + MGPipeTracker& tracker = MGPipeTrackerInstance(); + const Uint32 dirty = tracker.Update(*ctx, verbClass); - // ---- step 4: the residual fill ---- + // ---- step 3: emission ---- + const Uint64 pushMask = MG_Config::Features.PipePush; + Uint64 payloadBytes = 0; + if ((pushMask & kMGPipeSubsystemRenderState) != 0 && + (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewRenderState))) != 0) { + payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); + } + if (payloadBytes != 0 && MG_Util::PipeStats::Enabled()) { + // PipeStats::RecordDrawPayloadBytes has been implemented and unit-tested since + // P0 and called by nothing; this is its first emitter, and the 24-bucket + // histogram is what answers ROADMAP.md open question 4's chunk-granularity + // retune with data instead of a guess. + MG_Util::PipeStats::RecordDrawPayloadBytes(payloadBytes); + } + + // ---- step 4: the residual fill, for what an emitted call did NOT supply ---- + const Bool applierDerives = ApplierDerivesRenderStateFields(); for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { const auto field = static_cast(i); if (!MGPipeFieldMaskHas(mask, field)) continue; @@ -645,7 +815,17 @@ namespace MobileGL::MG_Pipe { #else if (kMGPipeInputFieldSticky[i]) continue; #endif - MGPipeFillAccess::CopyField(inputs, *ctx, field); + // A field a P2 call now supplies is not pulled again - that second pull is + // exactly the cost P2 exists to remove. THE STAMP IS UNCHANGED either way: a + // stamp says "this verb published this field", which is as true of an emitted + // field as of a copied one, and withholding it would abort every backend read of + // the very fields the migration just took over. + const MGPipeFieldEmitter emitter = kMGPipeFieldEmittedBy[i]; + const Uint64 subsystem = SubsystemForEmitter(emitter); + const Bool supplied = subsystem != 0 && (subsystem & kMGPipeWiredSubsystems) != 0 && + (pushMask & subsystem) != 0 && + (applierDerives || AppliedWithoutDerivation(field)); + if (!supplied) MGPipeFillAccess::CopyField(inputs, *ctx, field); #if MOBILEGL_PIPE_POISON // The value is copied either way; only the stamp is withheld for the omitted pair. if (!IsOmitted(verb, field)) filled.FilledGen[i] = filled.CurrentVerbSerial; diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h index a01e21a6..f2684a8e 100644 --- a/MobileGL/MG_Impl/Pipe/Tracker.h +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -186,6 +186,7 @@ namespace MobileGL::MG_Pipe { Reset(); m_context = &ctx; } + const Bool wasPrimed = m_primed; Uint64 now[kMGPipeDirtyCount]; const RenderStateParameters& render = ctx.GetRenderStateParameters(); @@ -285,6 +286,7 @@ namespace MobileGL::MG_Pipe { } m_primed = true; + m_freshlyPrimed = !wasPrimed; m_lastDirty = dirty; if (MG_Util::PipeStats::Enabled()) { @@ -308,9 +310,11 @@ namespace MobileGL::MG_Pipe { m_framebufferBind.Reset(); m_pack = PixelStoreParameters{}; m_patch = PatchTrio{}; + m_staged = RenderStateParameters{}; m_context = nullptr; m_lastDirty = 0; m_primed = false; + m_freshlyPrimed = false; } void ResetCounters() { @@ -337,6 +341,16 @@ namespace MobileGL::MG_Pipe { Uint32 LastDirty() const { return m_lastDirty; } Bool Primed() const { return m_primed; } + // True when the LAST Update was the first one after a Reset - a fresh context, or a + // server reset. The emission step reads it to send a COMPLETE state rather than an + // increment against a staging mirror that describes a context that is gone. + Bool FreshlyPrimed() const { return m_freshlyPrimed; } + + // "What the server has" (P2 brief D8). set_dynamic_state sends the dynamic chunks + // that differ from this, which is the chunk-level suppressor; a chunk that + // memcmp-matches is not sent at all. + RenderStateParameters& Staged() { return m_staged; } + const RenderStateParameters& Staged() const { return m_staged; } private: static constexpr SizeT Index(MGPipeDirty bit) { return static_cast(bit); } @@ -357,9 +371,12 @@ namespace MobileGL::MG_Pipe { PixelStoreParameters m_pack{}; PatchTrio m_patch{}; + RenderStateParameters m_staged{}; + const void* m_context = nullptr; Uint32 m_lastDirty = 0; Bool m_primed = false; + Bool m_freshlyPrimed = false; Uint64 m_fires[kMGPipeDirtyCount][kMGPipeVerbClassCount]{}; Uint64 m_walks[kMGPipeVerbClassCount]{};