diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index fa67dea8..de97b20a 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -542,6 +543,171 @@ namespace MobileGL::MG_Pipe { } } + // ================================================================================ + // P3a: the resource family's emission (brief D-A, D-B, D-C, D-D) + // ================================================================================ + // + // Declared in MG_Pipe/PipeMutation.h and defined here for the layering reason that + // header states: the emission sites are BufferObject's dispatchers, which are MG_State's, + // and MG_State may see a declaration but never MG_Impl/Pipe/ResourceTracker.h. + // + // Every one of these is called from a site that has ALREADY asked + // MGPipeResourceSubsystemEnabled(), except the mint and the destroy - the handle is + // client state and its lifetime is the frontend object's, not the subsystem's. + namespace { + using MG_State::GLState::BufferObject; + + MGPHandleOnly BufferHandleOnly(MGPipeHandle handle) { + MGPHandleOnly only{}; + only.Handle = handle; + only.Kind = static_cast(MGPipeKind::Buffer); + return only; + } + } // namespace + + Bool MGPipeResourceSubsystemEnabled() { + return (MG_Config::Features.PipePush & kMGPipeSubsystemResources) != 0 && + MGPipeGetResourceOps() != nullptr; + } + + Bool MGPipeResourceOpsHaveSubDataResident() { + const MGPipeResourceOps* ops = MGPipeGetResourceOps(); + return ops != nullptr && ops->SubDataResident != nullptr; + } + + void MGPipeMintResourceHandle(BufferObject& buffer) { + // UNCONDITIONAL in a push build, deliberately: set_vertex_buffers names a buffer by + // handle whether or not the resource family is switched on, so gating the mint on + // the resource subsystem bit would make the vertex-input subsystem emit null handles + // in exactly the A/B arm that exists to isolate the two. It costs one free-list pop + // and one map insert per buffer object and emits nothing. + MGPipeInstallClientResourceCallbacks(); + MGPipeResourceTrackerInstance().Acquire(buffer); + } + + void MGPipeEmitResourceCreate(BufferObject& buffer) { + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + const MGPipeHandle handle = tracker.Acquire(buffer); + Uint16 bindMask = tracker.BindMask(handle); + if (auto* ctx = LiveContext()) bindMask = tracker.RefreshBindMask(*ctx, buffer, handle); + // storageDefined = false: the constructor has no store yet, storage is defined lazily + // by the first respecify, and the backend's ensure path already tolerates a resource + // that has none. + const MGPResourceDesc desc = MGPipeBuildResourceDesc(buffer, handle, bindMask, false); + tracker.NoteDesc(desc, true); + MGPipeApplyResourceCreate(desc); + } + + void MGPipeEmitResourceRespecify(BufferObject& buffer) { + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + const MGPipeHandle handle = tracker.Acquire(buffer); + Uint16 bindMask = tracker.BindMask(handle); + if (auto* ctx = LiveContext()) bindMask = tracker.RefreshBindMask(*ctx, buffer, handle); + const MGPResourceDesc desc = MGPipeBuildResourceDesc(buffer, handle, bindMask, true); + tracker.NoteDesc(desc, false); + // initialBytes is the client's own shadow base - zero copy, and null is a real answer + // for the orphaning idiom (a NULL-data respecify leaves the store undefined and the + // backend must not upload the stale bytes). + const void* initialBytes = desc.HasDefinedContent != 0 ? buffer.MappedData() : nullptr; + // kNeedsAck rides on the CALL and MGPipeResourceRespecifyNeedsAck(desc) decides per + // record: only an immutable store (a glBufferStorage*) is a real synchronous + // allocation and only it is allowed one. In monolith the acknowledgement is + // ((void)0), because the applier is one function call away and has already run by + // the time this returns; the transport wires the doorbell to that same predicate. + MGPipeApplyResourceRespecify(desc, initialBytes); + } + + void MGPipeEmitResourceSubData(BufferObject& buffer, SizeT offset, SizeT size) { + const MGPipeHandle handle = MGPipeResourceTrackerInstance().Acquire(buffer); + const Uint8* base = buffer.MappedData(); + const Bool encodable = + MGPipeForEachSubDataRecordRange(offset, size, [&](Uint64 at, Uint64 length) { + MGPSubData record{}; + MGPipeBuildSubDataRecord(handle, at, length, record); + MGPipeApplyResourceSubData(record, base + at); + }); + if (!encodable) { + MGLOG_E_ONCE("MGPipe: resource_subdata range [%llu, +%llu) on buffer %u cannot be encoded - " + "one record's destination box caps the offset at 2^31-1", + static_cast(offset), static_cast(size), + buffer.GetExternalIndex()); + } + } + + void MGPipeEmitBufferSubDataResident(BufferObject& buffer, SizeT offset, const void* bytes, SizeT size) { + const MGPipeHandle handle = MGPipeResourceTrackerInstance().Acquire(buffer); + const auto* base = static_cast(bytes); + const Bool encodable = + MGPipeForEachSubDataRecordRange(offset, size, [&](Uint64 at, Uint64 length) { + MGPSubData record{}; + MGPipeBuildSubDataRecord(handle, at, length, record); + // The application's STAGING store, valid for the duration of the call only. + MGPipeApplyBufferSubDataResident(record, base + (at - offset)); + }); + if (!encodable) { + MGLOG_E_ONCE("MGPipe: buffer_subdata_resident range [%llu, +%llu) on buffer %u cannot be encoded", + static_cast(offset), static_cast(size), + buffer.GetExternalIndex()); + } + } + + void MGPipeEmitResourceFlushRange(BufferObject& buffer, SizeT offset, SizeT size, Uint32 accessFlags) { + const MGPipeHandle handle = MGPipeResourceTrackerInstance().Acquire(buffer); + MGPFlushRange record{}; + record.Res = handle; + record.Offset = offset; + record.Size = size; + // The application's REAL flags, not a normalised subset: the backend's kill-switch + // arm reads INVALIDATE_RANGE / INVALIDATE_BUFFER / UNSYNCHRONIZED per call to choose + // between a map+memcpy+unmap and an upload, so merging them here would change which. + record.AccessFlags = accessFlags; + MGPipeApplyResourceFlushRange(record, buffer.MappedData() + offset); + } + + void MGPipeEmitResourceReadback(BufferObject& buffer) { + const MGPipeHandle handle = MGPipeResourceTrackerInstance().Acquire(buffer); + MGPReadback record{}; + record.Res = handle; + // Whole-buffer by contract (BufferObject.h: the op pulls the backend's current + // contents for the WHOLE buffer into the shadow). + record.Offset = 0; + record.Size = buffer.GetSize(); + // The answer travels back through MGPipeClientOnBufferWriteback, and the server's + // epoch bump happens AFTER that writeback, never before. + MGPipeApplyResourceReadback(record); + } + + void* MGPipeEmitMapPersistent(BufferObject& buffer) { + const MGPipeHandle handle = MGPipeResourceTrackerInstance().Acquire(buffer); + MGPipeResourceTrackerInstance().NoteMapPersistent(); + // THE map-persistent-roundtrips SITE, and it counts every EMISSION - mint OR + // DECLINE - because every one of them needs an answer from the resource owner. A + // counter defined as "round trips actually taken" is 0 by construction in monolith + // and could never go red for the reason it exists; this one is the same number in + // both modes and is "one per storage definition" exactly as the design requires. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::MapPersistentRoundtrips, 1); + } + return MGPipeApplyMapPersistent(BufferHandleOnly(handle), buffer.GetSize(), buffer.MappedData()); + } + + void MGPipeEmitResourceDestroyAndFree(BufferObject& buffer) { + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + const MGPipeHandle handle = tracker.Find(buffer); + if (MGPipeHandleIsNull(handle)) return; + if (MGPipeResourceSubsystemEnabled()) { + tracker.NoteDestroy(); + MGPipeApplyResourceDestroy(BufferHandleOnly(handle)); + } + // THE ORDER IS FIXED (D-L): the applier clears the record and the backend drops its + // twin while the handle still resolves, and only then does the slot go back. Free + // erases the lifetimeId -> slot mapping, so a notice resolved twice finds nothing the + // second time - and the Gen bump happens on the NEXT handout of the slot, not here, + // so a double free cannot skip a generation. + tracker.Retire(handle); + MGPipeSlots().Free(MGPipeKind::Buffer, handle); + } + void MGPipeSetPoisonOmission(const char* verb, const char* field) { if (verb == nullptr || field == nullptr) { g_omission = PoisonOmission{}; @@ -711,14 +877,28 @@ namespace MobileGL::MG_Pipe { // row to Coverage.def can never silently drop a field on the floor before the call // that carries it exists. // - // P3a's two are DELIBERATELY ABSENT at the contract commit: the three emitters below - // are stubs that emit nothing and the applier's fourteen entry points are stubs that - // apply nothing, so wiring either bit here would retire a pull for a call that does - // not happen yet. The commit that gives the emitters their bodies adds them. + // P3a's two were DELIBERATELY ABSENT at the contract commit, because the emitters + // were stubs; each is added by the commit that gives its own emitters their bodies. + // + // NEITHER OF THEM RETIRES A PULL, and saying so is the point of adding them + // deliberately rather than by reflex: + // + // kMGPipeSubsystemResources names NO emitted field at all. SubsystemForEmitter + // above can never return it, because the resource family is dispatched at the GL + // call that causes it rather than filled into a PipeInputs field - there is no + // Coverage.def emitted row for it and there cannot be one. It is here so the + // constant states what this build emits for, which is what an operator reading + // a MOBILEGL_PIPE_PUSH value has to be able to trust. + // + // kMGPipeSubsystemVertexInput names exactly one emitted field, GetBoundVertexArray + // through bind_vertex_elements - and EmittedCallSuppliesTheWholeField below says + // false for it, with the reason. So this bit switches the EMISSION on and + // changes nothing about the fill loop. constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState | kMGPipeSubsystemPixelPack | kMGPipeSubsystemPatchState | - kMGPipeSubsystemVertexAttribDefaults; + kMGPipeSubsystemVertexAttribDefaults | + kMGPipeSubsystemResources; // A field an emitted call supplies COMPLETELY, so the residual fill may stop pulling // it. Two rows of Coverage.def's emitted list do not qualify and each has its reason diff --git a/MobileGL/MG_Impl/Pipe/ResourceTracker.h b/MobileGL/MG_Impl/Pipe/ResourceTracker.h new file mode 100644 index 00000000..ab05c717 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/ResourceTracker.h @@ -0,0 +1,492 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/ResourceTracker.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 CLIENT side of P3a's resource family (brief D-A, D-B, D-C, D-D). +// +// WHERE IT RUNS, and it is the ONE exception to push-at-validate (ARCHITECTURE.md 5.1): +// the seven BufferBackendOps hooks already dispatch at the GL call that causes them, so +// their pipe calls are emitted from the same BufferObject dispatchers - not from +// MGPipeValidateForVerb. Nothing about buffers moves to validate time in P3a. +// +// WHAT LIVES HERE +// * the sticky BindMask, one constexpr BufferTarget -> bit table with a static_assert +// that it covers every enumerator, so a new target cannot be silently unmapped; +// * the lifetimeId -> {slot, gen} mint (through MGPipeSlots(), the one allocator) and +// the slot -> BufferObject* INVERSE the reverse channel resolves a writeback through; +// * the nine MGPipeEmitResource* bodies, declared in MG_Pipe/PipeMutation.h so that +// MG_State sees a declaration and never this file (the same layering PipeMutation.h +// already has for MGP_NOTE_MUTATION: declare in MG_Pipe, define in MG_Impl); +// * the MGPSubData range splitter, because one record's box caps the destination at a +// 2^31-1 offset and a 2^32-1 size; +// * the map-persistent-roundtrips counting site. +// +// HEADER-ONLY, for the ownership reason Tracker.h states in full: the root CMakeLists.txt +// that would name a new .cpp belongs to the contract package and is frozen behind the tag. +// MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library. +// +// NO TIMER, and no per-call record copy on a HOT path. The two observables a unit case +// needs - the last emitted descriptor and the per-call counts - are written only by +// resource_create and resource_respecify, which run once per glBufferData rather than per +// upload; resource_subdata, the hot one, is observed through the pure builders below +// instead (MGPipeBuildSubDataRecord / MGPipeForEachSubDataRecordRange), which is also what +// lets a test drive the splitter at both of its bounds without a 4 GiB buffer. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace MobileGL::MG_Pipe { + + // --------------------------------------------------------------------------------- + // D-A3: BindMask + // --------------------------------------------------------------------------------- + + // MGPResourceDesc::BindMask's twelve bits, in the order MGPipeTypes.h names them: + // VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET| + // DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. + // + // They are spelled HERE rather than in MGPipeTypes.h because that header is the contract + // package's and the mask has, so far, exactly one producer: this file. The integrator + // moves them beside the field when a second producer appears (P4a's texture family). + enum MGPipeBindBit : Uint16 { + kMGPipeBindNone = 0, + kMGPipeBindVertex = 1u << 0, + kMGPipeBindIndex = 1u << 1, + kMGPipeBindConstant = 1u << 2, + kMGPipeBindShaderBuffer = 1u << 3, + kMGPipeBindIndirect = 1u << 4, + kMGPipeBindSampler = 1u << 5, + kMGPipeBindShaderImage = 1u << 6, + kMGPipeBindRenderTarget = 1u << 7, + kMGPipeBindDepthStencil = 1u << 8, + kMGPipeBindStreamOutput = 1u << 9, + kMGPipeBindAtomic = 1u << 10, + // THE D-B7 SWITCH. With kCapNeedsHostIndexBytes set the server mirrors this + // resource's bytes so it can rewrite restart indices and flatten multi-draws + // (ARCHITECTURE.md 10.3). Getting it wrong is invisible in monolith and silently + // disables both under split, which is why it is set from the same table as every + // other bit rather than from a special case at the emission site. + kMGPipeBindElementArray = 1u << 11, + }; + + // A sentinel the table below returns for an enumerator it does not name. It is NOT a + // legal mask value: every enumerator must be listed, including the ones that map to no + // bit at all, so that ADDING a BufferTarget is a build break here rather than a bit + // that silently stops being published. + inline constexpr Uint32 kMGPipeBindUnmapped = 0x10000u; + + // The one table. No `default:` arm on purpose - that is what makes the static_assert + // below able to see an unnamed enumerator. + constexpr Uint32 MGPipeBindMaskForBufferTarget(BufferTarget target) { + switch (target) { + case BufferTarget::Vertex: + return kMGPipeBindVertex; + // GL_ELEMENT_ARRAY_BUFFER is the VAO's element slot: the same bind is both "this + // resource is an index buffer" and "the server may need its bytes on its own side". + case BufferTarget::Index: + return kMGPipeBindIndex | kMGPipeBindElementArray; + case BufferTarget::Uniform: + return kMGPipeBindConstant; + case BufferTarget::ShaderStorage: + return kMGPipeBindShaderBuffer; + case BufferTarget::DispatchIndirect: + case BufferTarget::DrawIndirect: + case BufferTarget::Parameter: + return kMGPipeBindIndirect; + // A texture buffer's backing store is SAMPLED through the texture that names it. + case BufferTarget::Texture: + return kMGPipeBindSampler; + case BufferTarget::TransformFeedback: + return kMGPipeBindStreamOutput; + case BufferTarget::AtomicCounter: + return kMGPipeBindAtomic; + // TRANSFER AND QUERY TARGETS, which the bind mask deliberately does not name: none + // of them is a pipeline binding, none of them makes the server keep anything, and + // a bit set for them would only widen what a split server mirrors. Listed rather + // than defaulted, so the completeness assert still sees them. + case BufferTarget::CopyRead: + case BufferTarget::CopyWrite: + case BufferTarget::PixelPack: + case BufferTarget::PixelUnpack: + case BufferTarget::Query: + return kMGPipeBindNone; + case BufferTarget::BufferTargetCount: + case BufferTarget::Unknown: + return kMGPipeBindNone; + } + return kMGPipeBindUnmapped; + } + + constexpr Bool MGPipeEveryBufferTargetIsMapped() { + for (SizeT i = 0; i < static_cast(BufferTarget::BufferTargetCount); ++i) { + if (MGPipeBindMaskForBufferTarget(static_cast(i)) == kMGPipeBindUnmapped) { + return false; + } + } + return true; + } + static_assert(MGPipeEveryBufferTargetIsMapped(), + "a BufferTarget enumerator has no MGPResourceDesc::BindMask row: add it to " + "MGPipeBindMaskForBufferTarget, including a deliberate kMGPipeBindNone, or the " + "resource it is bound to stops publishing that binding (D-A3, P8 expectation 1)"); + static_assert(MGPipeBindMaskForBufferTarget(BufferTarget::Index) & kMGPipeBindElementArray, + "the ELEMENT_ARRAY bit is the index host mirror's switch (ARCHITECTURE.md 10.3)"); + + // --------------------------------------------------------------------------------- + // The discriminators MGPResourceDesc / MGPSubData carry for a BUFFER + // --------------------------------------------------------------------------------- + // + // MGPipeTypes.h documents Target as "Buffer | Tex1D..TexCubeArray | Renderbuffer | + // TexBuffer" and StorageKind as "== TextureStorageType", but P3a is buffer-only and the + // contract package minted no enum for the first list. Buffer is its leading member and + // is therefore 0, which is also what a zero-initialised record already says; the second + // is the frontend enum, named rather than open-coded. + inline constexpr Uint16 kMGPipeResourceTargetBuffer = 0; + inline constexpr Uint8 kMGPipeResourceStorageKindBuffer = + static_cast(MobileGL::TextureStorageType::Buffer); + + // --------------------------------------------------------------------------------- + // D-A2: the payload builders. Pure, so a unit case can assert field by field. + // --------------------------------------------------------------------------------- + + // The descriptor for `buffer`. `storageDefined` is false for the create that the + // constructor emits - storage is defined lazily by the first respecify and a backend + // tolerates a resource that has none - and true for every respecify. + inline MGPResourceDesc MGPipeBuildResourceDesc(const MG_State::GLState::BufferObject& buffer, + MGPipeHandle handle, Uint16 bindMask, + Bool storageDefined) { + MGPResourceDesc desc{}; + desc.Resource = handle; + desc.Target = static_cast(kMGPipeResourceTargetBuffer); + desc.StorageKind = kMGPipeResourceStorageKindBuffer; + desc.BindMask = bindMask; + if (storageDefined) { + desc.Width = static_cast(buffer.GetSize()); + desc.Usage = static_cast(buffer.GetUsage()); + desc.StorageFlags = static_cast(buffer.GetStorageFlags()); + desc.Immutable = buffer.IsImmutableStorage() ? 1 : 0; + desc.HasDefinedContent = buffer.HasDefinedContent() ? 1 : 0; + } + // Diagnostics only: a GL name is never an identity, never a memo key and never part + // of a content hash (ARCHITECTURE.md 4.2.1). + desc.GlNameForDiag = static_cast(buffer.GetExternalIndex()); + return desc; + } + + // The buffer half of MGPSubData: the destination range rides in the box's first + // coordinate and first extent, and MGPipeSetSubDataBufferRange is the ONLY spelling of + // that convention. Returns false, with the record untouched, when the range does not fit + // one record - which is where MGPipeForEachSubDataRecordRange comes in. + inline Bool MGPipeBuildSubDataRecord(MGPipeHandle res, Uint64 offset, Uint64 size, MGPSubData& out) { + out = MGPSubData{}; + out.Res = res; + out.Target = kMGPipeResourceTargetBuffer; + out.SourceIsVerbatimLevelShadow = 1; // the bytes ARE the client's shadow, unmodified + if (!MGPipeSetSubDataBufferRange(out, offset, size)) return false; + out.Blob.Seg = kMGHostSpanSegNone; + out.Blob.Size = size; + return true; + } + + // ONE record caps at a 2^31-1 offset and a 2^32-1 size (MGPipeTypes.h), so a range + // beyond either has to be split. The pieces are CONTIGUOUS and in ascending order: + // splitting a content write into overlapping or reordered pieces would change what the + // backend's queue-and-drain sees, and the Mali WAR-stall fix depends on the queue being + // exactly the writes the application made. + // + // The OFFSET bound cannot be split away - every piece of a range that starts past + // 2^31-1 starts past it too - so the walk returns false for such a range and emits + // nothing rather than emitting a record whose box the applier's bounds gate would + // refuse. That needs a >2 GiB buffer, which nothing in the corpus has; the answer is + // still stated rather than assumed, because the alternative is a silent truncation. + template + inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece) { + constexpr Uint64 kMaxOffset = 0x7FFFFFFFull; + constexpr Uint64 kMaxSize = 0xFFFFFFFFull; + if (offset > kMaxOffset) return false; + if (size == 0) return true; + // A single piece may run to the end of the buffer; only its SIZE is split. + Uint64 at = offset; + Uint64 left = size; + while (left > 0) { + if (at > kMaxOffset) return false; + const Uint64 chunk = left > kMaxSize ? kMaxSize : left; + piece(at, chunk); + at += chunk; + left -= chunk; + } + return true; + } + + // --------------------------------------------------------------------------------- + // The tracker: handles, the inverse, the sticky mask, the reverse channel + // --------------------------------------------------------------------------------- + + class MGPipeResourceTracker { + public: + using BufferObject = MG_State::GLState::BufferObject; + using GLContext = MG_State::GLState::GLContext; + + // The handle for `buffer`, minted on first use. Minting is NOT gated on a backend + // having registered MGPipeResourceOps: the handle is CLIENT state and + // set_vertex_buffers names it whether or not the resource family is switched on, so + // gating it would make the vertex-input subsystem emit null handles whenever the + // resource subsystem is off. Only the CALLS are gated (D-A1). + MGPipeHandle Acquire(BufferObject& buffer) { + const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Buffer, buffer.GetLifetimeId()); + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1); + m_bySlot[slot].Object = &buffer; + m_bySlot[slot].Gen = handle.Gen; + return handle; + } + + // The handle a buffer already has, or the null handle. Never mints - the emission + // path calls Acquire, the query paths call this. + MGPipeHandle Find(const BufferObject& buffer) const { + return MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, buffer.GetLifetimeId()); + } + + // D-D's inverse, and a RAW pointer is exact here: the entry exists only between the + // create the constructor emits and the destroy the destructor emits, and a readback + // is only ever issued for a live, bound buffer. A WeakPtr would be wrong - the + // object does not own itself through a SharedPtr at those two moments. The Gen + // compare is what refuses a stale handle rather than resolving it to whatever now + // occupies the slot. + BufferObject* Resolve(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_bySlot.size()) return nullptr; + const Entry& entry = m_bySlot[slot]; + if (entry.Object == nullptr || entry.Gen != handle.Gen) return nullptr; + if (MGPipeSlots().GenOfSlot(MGPipeKind::Buffer, handle.Slot) != handle.Gen) return nullptr; + return entry.Object; + } + + // Drops the inverse entry and the sticky mask. The CALLER frees the slot afterwards, + // in that order (D-L): MGPipeSlotAllocator::Free erases the lifetimeId -> slot + // mapping, so anything that has to resolve the handle must do it first. + void Retire(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) return; + m_bySlot[slot] = Entry{}; + } + + // The sticky everBoundAs mask. Sticky exactly as MGPResourceDesc::ImageBindableHint's + // everImageBound is: ORed, never cleared, so a buffer that was an element array once + // keeps saying so. + Uint16 BindMask(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + return slot < m_bySlot.size() ? m_bySlot[slot].BindMask : Uint16{0}; + } + + void NoteBoundAs(MGPipeHandle handle, BufferTarget target) { + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) return; + m_bySlot[slot].BindMask |= static_cast(MGPipeBindMaskForBufferTarget(target)); + } + + // Accumulates into the sticky mask every target `buffer` is bound to RIGHT NOW, and + // returns the accumulated value. + // + // [DEVIATION, recorded in client-v1.md] D-A3 asks for the OR at every glBindBuffer / + // glBindBufferBase / glBindBufferRange / VAO element-slot bind. Those entry points + // are MG_Impl/GLImpl/Buffer/GL_Buffer.cpp's, which C.5 assigns to no package and + // C.1 does not list for this one, so the mask is accumulated by SAMPLING the + // frontend's live binding state instead - here, at every resource emission, which is + // the only place its value is read. It is still STICKY (the union over every sample + // this buffer has ever been part of), and it is exact for the GL idiom the bit + // matters for: bind, then define or update the store. What it cannot see is a bind + // that happens after the buffer's LAST storage or content operation and is never + // followed by another - the fix is one line in BindBuffer_State, and it is handed to + // the integrator rather than taken here. + // + // The scan is skipped unless a binding-slot version moved since the last one, which + // is one Uint16 load per global target and none per binding point. + Uint16 RefreshBindMask(GLContext& ctx, const BufferObject& buffer, MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) return 0; + Entry& entry = m_bySlot[slot]; + const Uint64 epoch = BindEpoch(ctx); + if (epoch == m_bindEpoch && entry.BindMaskEpoch == epoch) return entry.BindMask; + m_bindEpoch = epoch; + entry.BindMaskEpoch = epoch; + Uint16 mask = entry.BindMask; + for (const auto target : MG_State::GLState::GlobalBufferTargets) { + if (ctx.GetBufferBindingSlot(target).GetBoundObject().get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(target)); + } + } + for (const auto target : MG_State::GLState::BufferBindPointTargets) { + const SizeT touched = ctx.GetTouchedBufferBindingPointCount(target); + for (SizeT i = 0; i < touched; ++i) { + if (ctx.GetBufferBindingPoint(target, static_cast(i)).GetBoundObject().get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(target)); + break; + } + } + } + // The index slot is the BOUND VAO's, not BufferState's, so it is not in + // GlobalBufferTargets and GetBufferBindingSlot(Index) asserts without a VAO. + if (const auto& vao = ctx.GetBoundVertexArray()) { + if (vao->GetIndexBufferBindingSlot().GetBoundObject().get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(BufferTarget::Index)); + } + for (int i = 0; i < MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; ++i) { + if (vao->GetAttribute(static_cast(i)).Buffer.get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(BufferTarget::Vertex)); + break; + } + } + } + entry.BindMask = mask; + return mask; + } + + // ---- the two observables a unit case reads (see the header comment) ---- + const MGPResourceDesc& LastDesc() const { return m_lastDesc; } + Uint64 CreateCount() const { return m_creates; } + Uint64 RespecifyCount() const { return m_respecifies; } + Uint64 DestroyCount() const { return m_destroys; } + Uint64 MapPersistentCount() const { return m_mapPersistents; } + + void NoteDesc(const MGPResourceDesc& desc, Bool isCreate) { + m_lastDesc = desc; + if (isCreate) { + ++m_creates; + } else { + ++m_respecifies; + } + } + void NoteDestroy() { ++m_destroys; } + void NoteMapPersistent() { ++m_mapPersistents; } + + // A unit fixture's per-case reset. Never called by the library: a context change + // does not invalidate a handle, because the handle is the CLIENT's identity for a + // frontend object that outlives it. + void ResetForTest() { + m_bySlot.clear(); + m_bindEpoch = 0; + m_lastDesc = MGPResourceDesc{}; + m_creates = m_respecifies = m_destroys = m_mapPersistents = 0; + } + + private: + struct Entry { + BufferObject* Object = nullptr; + Uint32 Gen = 0; + Uint16 BindMask = 0; + Uint64 BindMaskEpoch = 0; + }; + + // "Has any buffer binding moved since the last scan": the sum of the binding-slot + // versions, which BindingSlot bumps only on a real change. A collision costs one + // skipped rescan of ONE buffer's mask, and the mask is re-scanned at the next + // emission whose epoch differs, so it can delay a bit by one storage op and never + // drop one - the same over-fire-is-free / under-fire-is-fatal direction every + // shutter in Tracker.h takes. + static Uint64 BindEpoch(GLContext& ctx) { + Uint64 epoch = 1; + for (const auto target : MG_State::GLState::GlobalBufferTargets) { + epoch += ctx.GetBufferBindingSlot(target).GetVersion(); + epoch *= 3; + } + if (const auto& vao = ctx.GetBoundVertexArray()) { + epoch += vao->GetIndexBufferBindingSlot().GetVersion(); + epoch = MGPipeMixShutterValue(epoch, vao->GetLifetimeId()); + epoch = MGPipeMixShutterValue(epoch, vao->GetConfigVersion()); + } + return epoch; + } + + // The same mix Tracker.h's composite shutters use. Spelled here rather than + // included so this header does not depend on the tracker. + static constexpr Uint64 MGPipeMixShutterValue(Uint64 accumulator, Uint64 value) { + accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2); + return accumulator; + } + + Vector m_bySlot; + Uint64 m_bindEpoch = 0; + MGPResourceDesc m_lastDesc{}; + Uint64 m_creates = 0; + Uint64 m_respecifies = 0; + Uint64 m_destroys = 0; + Uint64 m_mapPersistents = 0; + }; + + // The monolith's one resource tracker, beside the state tracker, the CSO cache and the + // set-hash suppressor. + inline MGPipeResourceTracker& MGPipeResourceTrackerInstance() { + static MGPipeResourceTracker tracker; + return tracker; + } + + // --------------------------------------------------------------------------------- + // D-D: the client's half of the reverse channel + // --------------------------------------------------------------------------------- + + // The backend produced the bytes of a readback and hands them back through the channel. + // The client resolves the handle to its own object and writes the shadow; the epoch bump + // stays SERVER-side and happens AFTER this returns, never before (ARCHITECTURE.md 7.4: + // the reverse channel needs the same ordering guarantee as the forward one). + inline void MGPipeClientOnBufferWriteback(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes) { + auto* buffer = MGPipeResourceTrackerInstance().Resolve(res); + if (buffer == nullptr) { + MGLOG_E_ONCE("MGPipe: OnBufferWriteback for a handle {%u,%u} that resolves to no buffer", + res.Slot, res.Gen); + return; + } + if (bytes.Seg != kMGHostSpanSegNone) { + MGLOG_E_ONCE("MGPipe: OnBufferWriteback carried a transport segment (%u); P3a is monolith only", + bytes.Seg); + return; + } + // Monolith: Seg is kMGHostSpanSegNone and Offset IS the address of the backend's + // mapped bytes (MGPipeTypes.h says so in as many words). Under a transport the + // segment resolves first, and that is the phase's edit, not this one's. + buffer->WritebackFromBackend( + DataPtr{reinterpret_cast(static_cast(bytes.Offset)), + static_cast(bytes.Size)}, + static_cast(offset)); + } + + // A draw or dispatch wrote these ranges. ARCHITECTURE.md 7.1 calls this a NARROWING + // channel - the client builds a conservative pending set at its own emission points and + // the callback only ever removes from it - so P3a's implementation marks exactly what + // the three Espryt MarkGpuWritten sites mark today and the observable behaviour is + // unchanged. The narrowing itself is P8/P9's. + inline void MGPipeClientOnGpuWritten(MGPipeHandle res, Uint rangeCount, const MGPRange* ranges) { + (void)rangeCount; + (void)ranges; + if (auto* buffer = MGPipeResourceTrackerInstance().Resolve(res)) buffer->MarkGpuWritten(); + } + + // Installed once, and never over an entry a backend already claimed: these two are the + // CLIENT's implementations of a backend -> frontend callback, so the backend installs + // the rest of the table and these two answer for it. + inline void MGPipeInstallClientResourceCallbacks() { + if (gMGPipeCallbacks.OnBufferWriteback == nullptr) { + gMGPipeCallbacks.OnBufferWriteback = &MGPipeClientOnBufferWriteback; + } + if (gMGPipeCallbacks.OnGpuWritten == nullptr) { + gMGPipeCallbacks.OnGpuWritten = &MGPipeClientOnGpuWritten; + } + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Pipe/PipeMutation.h b/MobileGL/MG_Pipe/PipeMutation.h index 7fd438a7..c09cf932 100644 --- a/MobileGL/MG_Pipe/PipeMutation.h +++ b/MobileGL/MG_Pipe/PipeMutation.h @@ -75,6 +75,59 @@ namespace MobileGL::MG_Pipe { // MG_Impl/Pipe/PipeFill.cpp. A no-op unless a context is live. void MGPipeNoteAggregate(MGPipeAggregate aggregate); + + // ---- P3a: the resource family's emission points (brief D-A1) ---- + // + // The seven BufferBackendOps hooks already dispatch at the GL call that causes them + // (ARCHITECTURE.md 5.1 names them as the ONE exception to push-at-validate), so their + // pipe calls are emitted from the same BufferObject dispatchers rather than from the + // validate point. That puts the emission inside MG_State, which is why these are + // DECLARED here beside the two notices and DEFINED in MG_Impl/Pipe/PipeFill.cpp: this + // header is the one MG_State already includes for exactly this, and the closure gate + // (check_include_closure.py's mutation-header probe) keeps it a declaration - reaching + // MG_Impl/Pipe/ResourceTracker.h from BufferObject.cpp would pull the client's tracker + // into the state machine that calls it. + // + // The forward declaration is the whole coupling: none of these needs the definition of + // BufferObject, and this header must not gain it. +} // namespace MobileGL::MG_Pipe + +namespace MobileGL::MG_State::GLState { + class BufferObject; +} + +namespace MobileGL::MG_Pipe { + // (Features.PipePush & kMGPipeSubsystemResources) != 0 && MGPipeGetResourceOps() != nullptr. + // + // BOTH HALVES MATTER. The bit is the operator's per-subsystem A/B; the table is "has a + // backend taken this family over at all". Until one has, every dispatch below falls + // through to the BufferBackendOps table it replaces and the tree behaves exactly as it + // did - which is what lets the client half land on its own. + Bool MGPipeResourceSubsystemEnabled(); + // The nullable member, asked the way the frontend asks g_bufferBackendOps->ResidentSubData + // today: one backend deliberately does not implement it and the caller has a different + // path when it is absent (BufferObject::FillSubData). + Bool MGPipeResourceOpsHaveSubDataResident(); + + // Minted from the constructor and released from the destructor, both unconditionally in + // a push build: a handle is CLIENT state and set_vertex_buffers names it whether or not + // the resource family is switched on. The CALLS are what the predicate above gates. + void MGPipeMintResourceHandle(MG_State::GLState::BufferObject& buffer); + // In this order, and it is not negotiable (D-L): the destroy resolves the handle, and + // MGPipeSlotAllocator::Free erases the lifetimeId -> slot mapping it resolves through. + void MGPipeEmitResourceDestroyAndFree(MG_State::GLState::BufferObject& buffer); + + void MGPipeEmitResourceCreate(MG_State::GLState::BufferObject& buffer); + void MGPipeEmitResourceRespecify(MG_State::GLState::BufferObject& buffer); + void MGPipeEmitResourceSubData(MG_State::GLState::BufferObject& buffer, SizeT offset, SizeT size); + void MGPipeEmitBufferSubDataResident(MG_State::GLState::BufferObject& buffer, SizeT offset, + const void* bytes, SizeT size); + void MGPipeEmitResourceFlushRange(MG_State::GLState::BufferObject& buffer, SizeT offset, SizeT size, + Uint32 accessFlags); + void MGPipeEmitResourceReadback(MG_State::GLState::BufferObject& buffer); + // Returns the coherent host pointer the resource owner donated, or null for a DECLINE - + // which is a real answer. Every call, mint or decline, is one map-persistent roundtrip. + void* MGPipeEmitMapPersistent(MG_State::GLState::BufferObject& buffer); } // namespace MobileGL::MG_Pipe #define MGP_NOTE_MUTATION(Field) \ ::MobileGL::MG_Pipe::MGPipeNoteFrontendMutation(::MobileGL::MG_Pipe::MGPipeInputField::Field) diff --git a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp index 7109f242..5c2a5ff2 100644 --- a/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp +++ b/MobileGL/MG_State/GLState/BufferState/BufferObject.cpp @@ -34,9 +34,28 @@ namespace MobileGL::MG_State::GLState { BufferObject::BufferObject(Uint externalIndex) : m_externalIndex(externalIndex), m_size(0), m_usage(BufferUsage::StaticDraw), m_isMapped(false), - m_mappingAccess(BufferMappingAccessBit::Null), m_mappedRange({0, 0}), m_ownsStagingData{} {} + m_mappingAccess(BufferMappingAccessBit::Null), m_mappedRange({0, 0}), m_ownsStagingData{} { +#if MOBILEGL_PIPE_PUSH + // P3a D-A2: a resource EXISTS before anything can name it, so resource_create is + // emitted from the constructor and carries no storage - the store is defined lazily + // by the first respecify and every backend already tolerates a resource with none. + // The handle itself is minted whatever the subsystem bitmask says, because + // set_vertex_buffers names this buffer by handle out of a different subsystem. + MG_Pipe::MGPipeMintResourceHandle(*this); + if (MG_Pipe::MGPipeResourceSubsystemEnabled()) MG_Pipe::MGPipeEmitResourceCreate(*this); +#endif + } BufferObject::~BufferObject() { +#if MOBILEGL_PIPE_PUSH + // P3a D-L: the buffer's death crosses as resource_destroy, which is the catalogue + // call for it - no seventh NotifyStateObjectDestroyed raiser is added, because that + // header exists for kinds that have no such call. The emit-then-free ORDER is fixed + // inside the helper and is not negotiable. + const Bool pushedResources = MG_Pipe::MGPipeResourceSubsystemEnabled(); + MG_Pipe::MGPipeEmitResourceDestroyAndFree(*this); + if (pushedResources) return; +#endif if (m_resource.Backend() && g_bufferBackendOps && g_bufferBackendOps->OnDestroy) { g_bufferBackendOps->OnDestroy(m_resource.ReleaseBackend()); } @@ -45,6 +64,12 @@ namespace MobileGL::MG_State::GLState { void BufferObject::NotifyRespecify() { ++m_changeSerial; MGP_NOTE_AGGREGATE(BufferChange); +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::MGPipeResourceSubsystemEnabled()) { + MG_Pipe::MGPipeEmitResourceRespecify(*this); + return; + } +#endif if (g_bufferBackendOps && g_bufferBackendOps->Respecify) { g_bufferBackendOps->Respecify(*this); } @@ -55,6 +80,12 @@ namespace MobileGL::MG_State::GLState { MGP_NOTE_AGGREGATE(BufferChange); if (size == 0) return; m_hasDefinedContent = true; +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::MGPipeResourceSubsystemEnabled()) { + MG_Pipe::MGPipeEmitResourceSubData(*this, offset, size); + return; + } +#endif if (g_bufferBackendOps && g_bufferBackendOps->SubData) { g_bufferBackendOps->SubData(*this, offset, size); } @@ -65,6 +96,16 @@ namespace MobileGL::MG_State::GLState { MGP_NOTE_AGGREGATE(BufferChange); if (range.start >= range.end) return; m_hasDefinedContent = true; +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::MGPipeResourceSubsystemEnabled()) { + static_assert(sizeof(appAccess.GetRaw()) <= sizeof(Uint32), + "MGPFlushRange::AccessFlags is a Uint32 and carries the application's " + "real Flags, unnormalised"); + MG_Pipe::MGPipeEmitResourceFlushRange(*this, range.start, range.end - range.start, + static_cast(appAccess.GetRaw())); + return; + } +#endif if (g_bufferBackendOps && g_bufferBackendOps->FlushMappedRange) { g_bufferBackendOps->FlushMappedRange(*this, range, appAccess); } @@ -189,6 +230,12 @@ namespace MobileGL::MG_State::GLState { if (m_size < kLargeBufferAdoptBytes) return; if (m_resource.IsGpuResident()) return; if (m_isMapped) return; +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::MGPipeResourceSubsystemEnabled()) { + if (void* base = MG_Pipe::MGPipeEmitMapPersistent(*this)) m_resource.AdoptPersistentMap(base); + return; + } +#endif if (g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) return; if (void* base = g_bufferBackendOps->AcquirePersistentMap(*this)) { m_resource.AdoptPersistentMap(base); @@ -321,6 +368,17 @@ namespace MobileGL::MG_State::GLState { // Cleared unconditionally: without a readback op the shadow can never catch up, // and retrying on every subsequent read would only repeat the same no-op. m_gpuWritePending = false; +#if MOBILEGL_PIPE_PUSH + if (m_size != 0 && MG_Pipe::MGPipeResourceSubsystemEnabled()) { + // The answer comes back through the reverse channel's OnBufferWriteback, which + // resolves this handle to this object and writes the shadow before the server + // bumps its mutation epoch. In monolith the whole sequence is synchronous inside + // the applier, so the caller sees the reconciled shadow on return exactly as it + // does today. + MG_Pipe::MGPipeEmitResourceReadback(*this); + return; + } +#endif if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->ReadbackFromGpu == nullptr) { return; } @@ -369,6 +427,30 @@ namespace MobileGL::MG_State::GLState { // NotifyContentWrite on a resident store only bumps the serial: the backend has no // separate copy to sync, so no transfer op runs. void BufferObject::LandBytesIntoResidentStore(SizeT offset, DataPtr bytes) { +#if MOBILEGL_PIPE_PUSH + // buffer_subdata_resident stays NULLABLE and stays asymmetric: one backend + // deliberately does not implement it, and the frontend checks the pipe table exactly + // as it checks the op table it replaces, so a backend without it keeps the legacy + // ordered in-place host write below. + if (bytes.size > 0 && MG_Pipe::MGPipeResourceSubsystemEnabled() && + MG_Pipe::MGPipeResourceOpsHaveSubDataResident()) { + MG_Pipe::MGPipeEmitBufferSubDataResident(*this, offset, bytes.data, bytes.size); + m_hasDefinedContent = true; + ++m_changeSerial; + MGP_NOTE_AGGREGATE(BufferChange); + m_gpuWritePending = true; + return; + } + // No resident op: the write lands in place below, after retiring the GPU writes this + // store is known to be waiting on - which is the same answer, and the same code, a + // backend with a null ResidentSubData gets today. + if (MG_Pipe::MGPipeResourceSubsystemEnabled()) { + SyncGpuWrites(); + Memcpy(m_resource.Bytes() + offset, bytes.data, bytes.size); + NotifyContentWrite(offset, bytes.size); + return; + } +#endif if (bytes.size > 0 && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) { g_bufferBackendOps->ResidentSubData(*this, offset, bytes); m_hasDefinedContent = true; @@ -402,7 +484,16 @@ namespace MobileGL::MG_State::GLState { // op the landing would memcpy the expansion into the mapping the loop below // fills in place anyway, so a whole-arena clear would allocate a whole arena // for nothing. - if (m_resource.IsGpuResident() && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) { + if (m_resource.IsGpuResident() && +#if MOBILEGL_PIPE_PUSH + // The same question, asked of whichever table owns the family in this build. + (MG_Pipe::MGPipeResourceSubsystemEnabled() + ? MG_Pipe::MGPipeResourceOpsHaveSubDataResident() + : (g_bufferBackendOps && g_bufferBackendOps->ResidentSubData)) +#else + g_bufferBackendOps && g_bufferBackendOps->ResidentSubData +#endif + ) { Vector expanded(size); if (pattern.size == 1) { Memset(expanded.data(), *static_cast(pattern.data), size); @@ -504,6 +595,14 @@ namespace MobileGL::MG_State::GLState { if (m_isMapped) { return false; } +#if MOBILEGL_PIPE_PUSH + if (m_size != 0 && MG_Pipe::MGPipeResourceSubsystemEnabled()) { + void* pushedBase = MG_Pipe::MGPipeEmitMapPersistent(*this); + if (pushedBase == nullptr) return false; + m_resource.AdoptPersistentMap(pushedBase); + return true; + } +#endif if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) { return false; } @@ -548,6 +647,16 @@ namespace MobileGL::MG_State::GLState { // returning; AdoptPersistentMap then releases the shadow. Falls back to the // shadow when the backend declines (returns null). Only attempted once - the // storage is immutable and outlives unmap/remap. +#if MOBILEGL_PIPE_PUSH + if (!m_resource.IsGpuResident() && (access & BufferMappingAccessBit::Write) && + !(access & BufferMappingAccessBit::FlushExplicit) && + MG_Pipe::MGPipeResourceSubsystemEnabled()) { + if (void* pushedBase = MG_Pipe::MGPipeEmitMapPersistent(*this)) { + m_resource.AdoptPersistentMap(pushedBase); + } + return m_resource.Bytes() + range.start; + } +#endif if (!m_resource.IsGpuResident() && (access & BufferMappingAccessBit::Write) && !(access & BufferMappingAccessBit::FlushExplicit) && g_bufferBackendOps && g_bufferBackendOps->AcquirePersistentMap) {