[Feat] (Pipe): resolve a program pipeline into one shader CSO out of the reserved composite band and release it exactly once

This commit is contained in:
2026-09-08 15:20:36 -04:00
parent 5a3e9f0857
commit bc2aad2176
2 changed files with 219 additions and 1 deletions
+194
View File
@@ -0,0 +1,194 @@
// MobileGL - MobileGL/MG_Impl/Pipe/CompositeResolver.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 <Includes.h>
// P4a's PROGRAM-PIPELINE COMPOSITE, on the client side.
//
// GLContext::GetProgramForDraw() already flattens a bound pipeline into one hidden composite
// ProgramObject entirely in the frontend - it joins every graphics stage, computes the
// pipeline's draw-program signature, looks it up in the pipeline's own cache and, on a miss,
// attaches each stage's LINKED SNAPSHOT into a fresh ProgramObject and links it. All of that
// is frontend work and none of it moves. What this file adds is the one thing the wire needs:
// the composite gets ONE handle, out of the ShaderCso reserved high band, and
// create_shader_state goes out for it exactly as for an ordinary program. THE SERVER NEVER
// LEARNS IT IS A COMPOSITE and needs no "resolved draw program" hook at all.
//
// WHY A BAND RATHER THAN A FLAG ON THE HANDLE: a flag would have to be carried, honoured and
// masked off by every consumer of a ShaderCso handle, on both sides; a reserved slot range is
// a property of the allocator instead, so "an ordinary program can never be handed a composite
// slot" is true by construction. MGPipeSlotAllocator::Allocate refuses the band outright and
// AllocateComposite is the only door in.
//
// WHAT THIS FILE IS ACTUALLY FOR: the composite's slot has TWO INDEPENDENT RELEASE PATHS and
// either order has to free it exactly once.
// * the pipeline cache drops the composite when the draw-program signature moves. In the
// frontend that overwrite drops the last SharedPtr, so the composite's own destructor
// usually runs first; the resolver still speaks the release, because "usually" is not a
// contract and a client that only reacted to destructors would leak a slot the moment the
// frontend started holding a second reference.
// * the composite ProgramObject's own ~ProgramObject, which is an ordinary program's death
// path and takes the same helper.
// Both go through MGPipeEmitShaderCsoDestroyAndFree, and whichever runs second is a PROVEN
// no-op: MGPipeSlotAllocator::Free refuses a slot that is not live at that generation and
// bumps no generation of its own, so a double release cannot skip a generation either.
//
// THE KEY IS ComputeDrawProgramSignature(), the per-graphics-stage {lifetimeId, GetLinkVersion()}
// array - and DELIBERATELY NOT GetBackendStateVersion(), which is what made the SSO
// conformance loop rebuild the composite (glslang + SPIR-V + spirv-opt) on every draw, because
// a glUniform1i to a sampler moves it.
//
// HEADER-ONLY, for the ownership reason Tracker.h states: a new .cpp would need the root
// CMakeLists.txt, which is the contract package's.
//
// IT IS INCLUDED BY ProgramEmit.h AND NOT THE OTHER WAY ROUND, deliberately: the composite is
// a special case of the program family's own emission, so the family header depends on this
// one and this one depends on nothing of the family's. The reverse arrangement would make the
// resolver reachable only from a translation unit that had already decided to use it, i.e.
// dead in the build that matters and live only in the tests.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_State/GLState/ProgramState/ProgramObject.h>
#include <MG_State/GLState/ProgramState/ProgramPipelineObject.h>
namespace MobileGL::MG_Pipe {
// IS THIS PROGRAM A PIPELINE COMPOSITE? A composite is the one ProgramObject in the system
// constructed with external index 0 (Core.cpp's MakeShared<ProgramObject>(0u)), and that is
// not an accident of implementation: it is deliberately not a named program, so it must not
// answer glIsProgram and must not consume a GL name, and glCreateProgram never returns 0.
//
// ASKED THIS WAY RATHER THAN CARRIED ON THE OBJECT because a Bool member on ProgramObject
// would resize the pull build's object and break G1 outright - the phase's admitted-resize
// set is empty - and a hook in Core.cpp would have to be maintained on a path that already
// states the invariant in its own comment.
inline Bool MGPipeProgramIsPipelineComposite(const MG_State::GLState::ProgramObject& program) {
return program.GetExternalIndex() == 0;
}
class MGPipeCompositeResolver {
public:
using ProgramObject = MG_State::GLState::ProgramObject;
using ProgramPipelineObject = MG_State::GLState::ProgramPipelineObject;
using DrawProgramSignature = ProgramPipelineObject::DrawProgramSignature;
struct Counters {
Uint64 Mints = 0; // signatures this resolver has seen minted
Uint64 Reuses = 0; // a signature that had not moved
Uint64 Releases = 0; // signature-move releases, i.e. the pipeline-cache path
};
// Told, at every emission, which composite the frontend handed out for which pipeline.
// Returns the handle the emitter should use, which is always the one already minted off
// the composite's own lifetime id - the resolver never mints a second identity for an
// object that has one.
//
// WHEN THE SIGNATURE MOVES the previous composite's slot is released here, through the
// one death helper and in its fixed order. That is the pipeline-cache release path; the
// composite's own destructor is the other one and the second of the two is the proven
// no-op.
MGPipeHandle Observe(const ProgramPipelineObject& pipeline, const ProgramObject& composite,
MGPipeHandle handle) {
const DrawProgramSignature signature = pipeline.ComputeDrawProgramSignature();
const Uint key = pipeline.GetExternalIndex();
Entry* entry = Find(key);
if (entry != nullptr) {
if (entry->Signature == signature && entry->Handle == handle) {
++m_counters.Reuses;
return handle;
}
ReleaseEntry(*entry);
} else {
m_entries.push_back(Entry{});
entry = &m_entries.back();
entry->PipelineName = key;
}
entry->Signature = signature;
entry->Handle = handle;
entry->CompositeLifetimeId = composite.GetLifetimeId();
entry->Live = true;
++m_counters.Mints;
return handle;
}
// The pipeline object itself is going away, or a test is tearing down. Speaks the same
// release for whatever it still holds.
void Forget(Uint pipelineName) {
for (SizeT i = 0; i < m_entries.size(); ++i) {
if (m_entries[i].PipelineName != pipelineName) continue;
ReleaseEntry(m_entries[i]);
m_entries[i] = m_entries.back();
m_entries.pop_back();
return;
}
}
// A make-current. The entries name composites that belong to the frontend objects of
// the context being left, and those objects outlive the switch, so the RECORDS are not
// released here - releasing them would emit a delete for a live program. Only the
// memo's freshness is dropped, which costs at most one re-observation.
void Reset() {
for (Entry& entry : m_entries) entry.Live = false;
}
void ResetCounters() { m_counters = Counters{}; }
MGPipeHandle HandleFor(Uint pipelineName) const {
for (const Entry& entry : m_entries) {
if (entry.PipelineName == pipelineName && entry.Live) return entry.Handle;
}
return kMGPipeNullHandle;
}
SizeT Size() const { return m_entries.size(); }
const Counters& GetCounters() const { return m_counters; }
private:
struct Entry {
// NO FRONTEND SharedPtr, and that is the exit-order rule rather than a style
// choice: a static that held one would put a frontend destructor on an exit
// handler's path into a torn-down pipe. A GL name, a signature of plain integers,
// a handle and a lifetime id are all this needs.
Uint PipelineName = 0;
DrawProgramSignature Signature{};
MGPipeHandle Handle = kMGPipeNullHandle;
Uint64 CompositeLifetimeId = 0;
Bool Live = false;
};
Entry* Find(Uint pipelineName) {
for (Entry& entry : m_entries) {
if (entry.PipelineName == pipelineName) return &entry;
}
return nullptr;
}
void ReleaseEntry(Entry& entry) {
if (!entry.Live || entry.CompositeLifetimeId == 0) return;
entry.Live = false;
MGPipeEmitShaderCsoDestroyAndFree(entry.CompositeLifetimeId);
entry.Handle = kMGPipeNullHandle;
entry.CompositeLifetimeId = 0;
++m_counters.Releases;
}
Vector<Entry> m_entries;
Counters m_counters;
};
inline MGPipeCompositeResolver& MGPipeCompositeResolverInstance() {
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason, and named in the phase's risk
// list beside the other three new client singletons: heap-constructed and intentionally
// leaked at exit, holding no frontend SharedPtr.
static MGPipeCompositeResolver* resolver = new MGPipeCompositeResolver();
return *resolver;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+25 -1
View File
@@ -36,6 +36,7 @@
// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see
// FramebufferEmit.h for why, in full.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/CompositeResolver.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeHostSpan.h>
@@ -95,6 +96,16 @@ namespace MobileGL::MG_Pipe {
const MGPipeHandle drawCso =
drawProgram ? AcquireShaderCso(*drawProgram, bytes) : kMGPipeNullHandle;
// THE COMPOSITE'S SECOND RELEASE PATH is spoken here, not in a destructor: when the
// bound pipeline's draw-program signature moves, the resolver releases the slot the
// previous composite held. Whichever of the two paths runs second - this one or the
// composite ProgramObject's own ~ProgramObject - is a proven no-op, because the slot
// allocator refuses a slot that is not live at that generation.
if (drawProgram && MGPipeProgramIsPipelineComposite(*drawProgram)) {
if (const auto& pipeline = ctx.GetBoundProgramPipeline()) {
MGPipeCompositeResolverInstance().Observe(*pipeline, *drawProgram, drawCso);
}
}
const MGPipeHandle dispatchCso =
dispatchProgram ? (dispatchProgram == drawProgram ? drawCso
: AcquireShaderCso(*dispatchProgram, bytes))
@@ -279,6 +290,10 @@ namespace MobileGL::MG_Pipe {
m_dispatchCso = kMGPipeNullHandle;
m_constantsCso = kMGPipeNullHandle;
m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
// The composite memo's freshness goes with them - and only its freshness. Its
// ENTRIES name composites whose frontend objects outlive the context switch, so
// releasing them here would emit a delete for a live program.
MGPipeCompositeResolverInstance().Reset();
}
void ResetCounters() {
@@ -307,7 +322,16 @@ namespace MobileGL::MG_Pipe {
const Uint64 lifetimeId = program.GetLifetimeId();
const MGPipeHandle existing = MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId);
if (!MGPipeHandleIsNull(existing)) return existing;
return MGPipeSlots().AllocateFor(MGPipeKind::ShaderCso, lifetimeId);
// A composite is minted off ITS OWN lifetime id, out of the reserved band, and is
// an ordinary ShaderCso handle in every other respect - the same kind, the same
// {slot, gen} rules, the same Free, the same death helper. Keying it on its own
// lifetime id rather than on the pipeline's signature is what makes ~ProgramObject
// able to release it at all, and it is why two pipelines that happen to have the
// same signature keep their own composite: sharing one handle between two frontend
// objects would let the first one's death free a slot the second still names.
return MGPipeProgramIsPipelineComposite(program)
? MGPipeSlots().AllocateComposite(lifetimeId)
: MGPipeSlots().AllocateFor(MGPipeKind::ShaderCso, lifetimeId);
}
struct Latch {