From 5a3e9f08572173495862e371b40d0ad5f10e35b8 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Tue, 8 Sep 2026 12:56:40 -0400 Subject: [PATCH] [Feat] (Pipe): publish a program's per-stage SPIR-V and reflection archive as a shader CSO and its default uniform block as global constants --- MobileGL/MG_Impl/Pipe/ProgramEmit.h | 307 +++++++++++++++++- .../GLState/ProgramState/ProgramObject.cpp | 17 +- .../GLState/ProgramState/ProgramObject.h | 17 + 3 files changed, 334 insertions(+), 7 deletions(-) diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h index 0a5926af..8497c29f 100644 --- a/MobileGL/MG_Impl/Pipe/ProgramEmit.h +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -36,38 +36,333 @@ // 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 #include +#include #include #include +#include +#include namespace MobileGL::MG_Pipe { // 0 until the emitters below have bodies; see FramebufferEmit.h's note. inline constexpr Uint64 kMGPipeWiredProgramSubsystem = 0; - // STUB AT THE CONTRACT COMMIT: emits nothing, returns 0 payload bytes. + // D-H6. ~0u is the BACKENDS' "never uploaded" sentinel for a global-constants version, and + // ProgramObject::MarkUBOContentDirty skips it on the wrap for exactly that reason. The + // client must never put it on the wire either: a server that received it would read its own + // record as "nothing has ever been uploaded here" and re-upload for ever. + inline constexpr Uint32 kMGPipeGlobalConstantsNeverUploaded = ~Uint32{0}; + + inline constexpr Bool MGPipeGlobalConstantsVersionIsEmittable(Uint32 version) { + return version != kMGPipeGlobalConstantsNeverUploaded; + } + + // A program's identity for the wire, out of the SNAPSHOT the last link consumed and never + // out of the live attach list: glAttachShader and glCompileShader take effect only at the + // NEXT link and neither moves m_linkVersion, so a stage mask built from GetAttachedShaders + // would describe a program that does not exist yet. GetLinkedShaderStages() is also what + // indexes GetGeneratedSpirv(), so the two halves of this descriptor are guaranteed to agree + // by construction rather than by care. + inline Uint32 MGPipeStageMaskOf(const MG_State::GLState::ProgramObject& program) { + Uint32 mask = 0; + for (const ShaderStage stage : program.GetLinkedShaderStages()) { + if (stage == ShaderStage::Unknown) continue; + mask |= Uint32{1} << static_cast(stage); + } + return mask; + } + class MGPipeProgramEmitter { public: using GLContext = MG_State::GLState::GLContext; + using ProgramObject = MG_State::GLState::ProgramObject; // create_shader_state (re-issued on the SAME handle whenever the link version moves - // Gen moves only on slot reuse), then bind_shader_state and set_draw_program / // set_dispatch_program. Two program calls because the frontend has two joins and two // PipeInputs slots. + // + // BOTH JOINS HAPPEN HERE and both are the verb's own: GetProgramForDraw flattens a + // bound pipeline into its composite and GetProgramForDispatch answers the compute + // question, and with a plain glUseProgram they are the same object, so the ordinary + // frame pays one join it was going to pay anyway. Uint64 EmitShaderState(GLContext& ctx) { - (void)ctx; - return 0; + Uint64 bytes = 0; + const auto& drawProgram = ctx.GetProgramForDraw(); + const auto& dispatchProgram = ctx.GetProgramForDispatch(); + + const MGPipeHandle drawCso = + drawProgram ? AcquireShaderCso(*drawProgram, bytes) : kMGPipeNullHandle; + const MGPipeHandle dispatchCso = + dispatchProgram ? (dispatchProgram == drawProgram ? drawCso + : AcquireShaderCso(*dispatchProgram, bytes)) + : kMGPipeNullHandle; + + // THE BOUND CSO IS THE DRAW ONE WHEN THERE IS ONE. bind_shader_state names what + // glUseProgram selected, and when a program pipeline is bound instead that is the + // composite; a compute-only pipeline has no draw program at all, and then the + // dispatch program is the only thing bound. A null handle is legal here and means + // exactly "nothing bound". + const MGPipeHandle boundCso = !MGPipeHandleIsNull(drawCso) ? drawCso : dispatchCso; + if (boundCso != m_boundCso) { + MGPipeApplyBindShaderState(HandleOnly(boundCso)); + m_boundCso = boundCso; + ++m_binds; + bytes += sizeof(MGPHandleOnly); + } + if (drawCso != m_drawCso) { + MGPipeApplySetDrawProgram(HandleOnly(drawCso)); + m_drawCso = drawCso; + ++m_drawSets; + bytes += sizeof(MGPHandleOnly); + } + if (dispatchCso != m_dispatchCso) { + MGPipeApplySetDispatchProgram(HandleOnly(dispatchCso)); + m_dispatchCso = dispatchCso; + ++m_dispatchSets; + bytes += sizeof(MGPHandleOnly); + } + return bytes; } // set_global_constants: the DEFAULT UNIFORM BLOCK only, keyed (ShaderCso, Version) and // at most once per program per frame. Version is GetUBOContentVersion() and must never // be ~0u, which is the backends' "never uploaded" sentinel - the wrap skips it. + // + // NAMED uniform blocks are NOT this call's: set_shader_buffers(Uniform) is a later + // phase's and BindCurrentProgramWithResources' named-UBO block is untouched. What + // travels here is globalUboScratch, the link phase's CPU array, which has no GL name + // and no BufferObject behind it. Uint64 EmitGlobalConstants(GLContext& ctx) { - (void)ctx; - return 0; + const auto& program = ctx.GetProgramForDraw(); + if (!program) return 0; + const Uint32 version = program->GetUBOContentVersion(); + // THE SENTINEL IS NEVER EMITTED. A server that received ~0u would read its own + // record as "never uploaded" and re-upload every frame for ever. + if (!MGPipeGlobalConstantsVersionIsEmittable(version)) return 0; + const Uint size = program->GetUBOSize(); + if (size == 0) return 0; + + 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; + + m_lastConstants = MGPGlobalConstants{}; + m_lastConstants.ShaderCso = cso; + m_lastConstants.Version = version; + // THE ONE BLOB RULE: Size 0 means "this record does not declare its blob" - which + // is what a monolith emission is - and the bytes ride beside it as a companion + // 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.Size = 0; + MGPipeApplySetGlobalConstants(m_lastConstants, program->GetUBOData()); + m_constantsCso = cso; + m_constantsVersion = version; + ++m_constantSets; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::CsoBlobBytes, size); + } + return bytes + sizeof(MGPGlobalConstants) + size; } - void Reset() {} + // D-H4's re-issue rule, and it is the CreateVertexElements shape one for one: the + // record goes out again on the SAME handle whenever the link version moves, which is + // legal because MGPipeHandle::Gen increments only on slot reuse and never on a + // respecify. A program that relinks is the same GL object and the server's twin table + // must not be asked to mint a second one. + MGPipeHandle AcquireShaderCso(const ProgramObject& program, Uint64& payloadBytes) { + const MGPipeHandle handle = AcquireShaderCsoHandle(program); + if (MGPipeHandleIsNull(handle)) return handle; + Latch& latch = LatchFor(handle); + + const Uint32 linkVersion = program.GetLinkVersion(); + if (latch.RecordLive && latch.RecordGen == handle.Gen && latch.LinkVersion == linkVersion) { + return handle; + } + + const auto& link = program.GetLinkReflection(); + const auto& spirv = program.GetSpirvReflection(); + + m_lastDesc = MGPProgramDesc{}; + m_lastDesc.Cso = handle; + m_lastDesc.StageMask = MGPipeStageMaskOf(program); + m_lastDesc.GlobalUboSize = static_cast(program.GetUBOSize()); + m_lastDesc.ReservedNumSamplesOffset = static_cast(spirv.reservedNumSamplesOffset); + m_lastDesc.SpirvStatus = spirv.spirvStatus ? 1 : 0; + m_lastDesc.NativeFloat64 = spirv.nativeFloat64 ? 1 : 0; + m_lastDesc.PointSizeDemoted = spirv.pointSizeDemoted ? 1 : 0; + m_lastDesc.EnableSpirvValidation = spirv.enableSpirvValidation ? 1 : 0; + + // ONE BLOB REF PER MODULE, IN THE LINKED-SHADER-SNAPSHOT'S ORDER, which is the + // order GetGeneratedSpirv() is indexed in - so Spirv[i] and StageMask agree because + // they came out of the same snapshot. Every one of them declares Size 0 (the one + // Blob rule); Offset carries the module's staging address so a reader can see which + // slots are occupied without the record pretending to declare a length it does not + // own. + const SizeT moduleCount = spirv.generatedSpirv.size(); + MOBILEGL_ASSERT(moduleCount <= 6, + "MGPProgramDesc::Spirv[] carries six modules and this program linked %u", + static_cast(moduleCount)); + for (SizeT i = 0; i < moduleCount && i < 6; ++i) { + m_lastDesc.Spirv[i].Seg = kMGHostSpanSegNone; + m_lastDesc.Spirv[i].Offset = reinterpret_cast(spirv.generatedSpirv[i].data()); + m_lastDesc.Spirv[i].Size = 0; + } + m_lastDesc.Reflection.Seg = kMGHostSpanSegNone; + m_lastDesc.Reflection.Offset = reinterpret_cast(&link); + m_lastDesc.Reflection.Size = 0; + + MGPipeApplyCreateShaderState(m_lastDesc, &link, &spirv); + ++m_creates; + payloadBytes += sizeof(MGPProgramDesc); + + latch.RecordLive = true; + latch.RecordGen = handle.Gen; + latch.LinkVersion = linkVersion; + return handle; + } + + // C-1's question for this kind - see MGPipeSamplerEmitter::RecordIsPublished for the + // full reason. A ShaderCso slot can exist with no record behind it, because a backend + // twin table mints one through MGPipeSlots().Acquire whether or not the program + // subsystem ever asked this client to emit a create. + // + // THE COMPOSITE BAND IS INDEXED SEPARATELY, for the allocator's own reason: the band + // base is 983040, so a slot-indexed vector would allocate ~983k latches for one program + // pipeline. Both spaces stay dense against their own high-water mark. + Bool RecordIsPublished(MGPipeHandle handle) const { + if (MGPipeHandleIsNull(handle)) return false; + const Vector& table = TableOf(handle); + const SizeT slot = SlotIndexOf(handle); + if (slot >= table.size()) return false; + const Latch& latch = table[slot]; + return latch.RecordLive && latch.RecordGen == handle.Gen; + } + + void NoteRecordDestroyed(MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return; + Vector& table = TableOf(handle); + const SizeT slot = SlotIndexOf(handle); + if (slot < table.size() && table[slot].RecordGen == handle.Gen) { + table[slot] = Latch{}; + } + if (m_boundCso == handle) m_boundCso = kMGPipeNullHandle; + if (m_drawCso == handle) m_drawCso = kMGPipeNullHandle; + if (m_dispatchCso == handle) m_dispatchCso = kMGPipeNullHandle; + if (m_constantsCso == handle) { + m_constantsCso = kMGPipeNullHandle; + m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded; + } + } + + // The validate point's FreshlyPrimed arm. MGPipeApplierReset clears DrawProgram, + // DispatchProgram and BoundShaderCso - all three are per-context WORKING STATE - so + // the three mirrors here go with them, or the first emission after a make-current + // would be suppressed as unchanged and the server would draw with the previous + // context's program bound. + // + // 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. + void Reset() { + m_boundCso = kMGPipeNullHandle; + m_drawCso = kMGPipeNullHandle; + m_dispatchCso = kMGPipeNullHandle; + m_constantsCso = kMGPipeNullHandle; + m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded; + } + + void ResetCounters() { + m_creates = m_binds = m_drawSets = m_dispatchSets = m_constantSets = 0; + } + + // ---- what a unit case reads ---- + const MGPProgramDesc& LastProgramDesc() const { return m_lastDesc; } + const MGPGlobalConstants& LastGlobalConstants() const { return m_lastConstants; } + MGPipeHandle BoundCso() const { return m_boundCso; } + MGPipeHandle DrawCso() const { return m_drawCso; } + MGPipeHandle DispatchCso() const { return m_dispatchCso; } + Uint64 CreateCount() const { return m_creates; } + Uint64 BindCount() const { return m_binds; } + Uint64 DrawProgramSetCount() const { return m_drawSets; } + Uint64 DispatchProgramSetCount() const { return m_dispatchSets; } + Uint64 GlobalConstantsSetCount() const { return m_constantSets; } + + private: + // THE ONE PLACE THE BAND CAN ENTER. An ordinary program's slot comes from the ordinary + // allocator door keyed on its lifetime id. CompositeResolver.h widens this to send a + // pipeline composite through MGPipeSlotAllocator::AllocateComposite instead, and + // nothing else about the emission changes - the server never learns a composite is a + // composite. + MGPipeHandle AcquireShaderCsoHandle(const ProgramObject& program) { + const Uint64 lifetimeId = program.GetLifetimeId(); + const MGPipeHandle existing = MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId); + if (!MGPipeHandleIsNull(existing)) return existing; + return MGPipeSlots().AllocateFor(MGPipeKind::ShaderCso, lifetimeId); + } + + struct Latch { + Bool RecordLive = false; + Uint32 RecordGen = 0; + Uint32 LinkVersion = 0; + }; + + // TWO TABLES, NOT A WIDER ONE, and it is the allocator's own reason repeated where it + // bites a second time: the composite band starts at slot 983040, so folding a composite + // into the ordinary slot-indexed vector would allocate ~983k latches - and grow them + // again on every future push_back - for a single program pipeline. Both spaces stay + // dense against their own high-water mark, which is exactly what the allocator does one + // level down. + Vector& TableOf(MGPipeHandle handle) { + return MGPipeIsCompositeShaderSlot(handle.Slot) ? m_compositeLatch : m_latch; + } + const Vector& TableOf(MGPipeHandle handle) const { + return MGPipeIsCompositeShaderSlot(handle.Slot) ? m_compositeLatch : m_latch; + } + static SizeT SlotIndexOf(MGPipeHandle handle) { + return MGPipeIsCompositeShaderSlot(handle.Slot) + ? static_cast(handle.Slot - kMGPipeShaderCsoCompositeSlotBase) + : static_cast(handle.Slot); + } + Latch& LatchFor(MGPipeHandle handle) { + Vector& table = TableOf(handle); + const SizeT slot = SlotIndexOf(handle); + if (slot >= table.size()) table.resize(slot + 1); + return table[slot]; + } + + static MGPHandleOnly HandleOnly(MGPipeHandle handle) { + MGPHandleOnly only{}; + only.Handle = handle; + only.Kind = static_cast(MGPipeKind::ShaderCso); + return only; + } + + MGPProgramDesc m_lastDesc{}; + MGPGlobalConstants m_lastConstants{}; + + Vector m_latch; + Vector m_compositeLatch; + MGPipeHandle m_boundCso = kMGPipeNullHandle; + MGPipeHandle m_drawCso = kMGPipeNullHandle; + MGPipeHandle m_dispatchCso = kMGPipeNullHandle; + MGPipeHandle m_constantsCso = kMGPipeNullHandle; + Uint32 m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded; + + Uint64 m_creates = 0; + Uint64 m_binds = 0; + Uint64 m_drawSets = 0; + Uint64 m_dispatchSets = 0; + Uint64 m_constantSets = 0; }; inline MGPipeProgramEmitter& MGPipeProgramEmitterInstance() { diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index f2ace10c..8464786d 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include const char* kDefaultFragmentShaderSource = R"(#version 460 core @@ -40,7 +41,21 @@ namespace MobileGL::MG_State::GLState { // object no longer exists to be passed, and because the lifetime id is what the client // slot allocator resolves the handle from. No-op unless a backend registered the ops // (a pull build declares none at all). - NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::ShaderCso, m_lifetimeId); + // + // P4a D-I1: the notice is no longer raised directly - it is step 2 of the ONE + // client-side death helper for this kind, which emits delete_shader_state first, + // raises the notice second and frees the slot last. The client mints the ShaderCso, so + // the client is where its death has to be spoken from: a backend death-ops table is a + // redundant, idempotent SECOND path, and under a backend that installs none it was + // previously the ONLY one, which is how a slot leaks for the life of the process. + // + // AN ORDINARY PROGRAM AND A PIPELINE COMPOSITE TAKE THIS SAME LINE. A composite is an + // ordinary ProgramObject with its own lifetime id, its slot merely comes out of the + // reserved band, and its OTHER release path - the pipeline cache dropping it when the + // draw-program signature moves - goes through the same helper. Whichever runs second is + // a proven no-op, because the slot allocator refuses a slot that is not live at that + // generation. + MG_Pipe::MGPipeEmitShaderCsoDestroyAndFree(m_lifetimeId); #endif } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index cee4f358..7546e5aa 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -1111,6 +1111,23 @@ namespace MobileGL::MG_State::GLState { return Artifacts(); } +#if MOBILEGL_PIPE_PUSH + // The phase-B twin of GetLinkReflection, and it exists for exactly one caller: + // create_shader_state publishes the WHOLE SpirvArtifacts beside the whole + // LinkArtifacts, because the archive the far side deserialises into is those two + // structs and nothing else. The granular getters above (GetGeneratedSpirv, + // GetSpirvStatus, UsesNativeFloat64, PointSizeDemoted, GetSpirvValidationEnabled, + // GetUBOSize) cover what the GL layer needs and deliberately do not reach + // reservedNumSamplesOffset or uniformOffsets, which the descriptor does need. + // + // PUSH-ONLY, so a pull build's symbol set and this class's layout are byte-for-byte + // what they were: an inline const-ref accessor adds nothing to a pull build that is + // never compiled into one. It joins both phases, like every other Spirv() reader. + const SpirvArtifacts& GetSpirvReflection() const { + return Spirv(); + } +#endif + static Bool IsValidUniformLocation(const LinkArtifacts& artifacts, Int location) { if (location < 0 || location > static_cast(artifacts.maxUniformLocation)) return false; if (static_cast(location) >= artifacts.uniformIndexInTProgram.size()) return false;