mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-13 14:48:32 +09:00
604 lines
36 KiB
C++
604 lines
36 KiB
C++
// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.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>
|
|
|
|
#include <Config.h>
|
|
#if MOBILEGL_PIPE_PUSH
|
|
// kMGPipeSubsystem* - the runtime bitmask's named bits - and MGPipeHandle itself. Both are
|
|
// header-only constant/POD declarations, and both are push-only, so the pull build's include
|
|
// graph is unchanged (G1).
|
|
#include <MG_Pipe/MGPipe.h>
|
|
#include <MG_Pipe/MGPipeHandles.h>
|
|
#endif
|
|
|
|
#include <cstdlib>
|
|
|
|
// Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14), and the
|
|
// {slot, gen} mint the re-keyed sites are written against.
|
|
//
|
|
// Two switches decide which arm a re-keyed site runs, and they are NOT the same switch:
|
|
//
|
|
// MOBILEGL_PIPE_PUSH (compile) - is the pushed state there to be keyed on at all
|
|
// Features.PipePush (runtime bitmask) - is THIS subsystem migrated in THIS run
|
|
// MOBILEGL_PIPE_LEGACY_MEMOS (compile) - is the pre-handle arm compiled beside it
|
|
// Features.PipeLegacyMemos (runtime) - may the pre-handle arm be ENTERED in this run
|
|
//
|
|
// ARCHITECTURE.md 9.6's point: once a handle wave lands, a clear MOBILEGL_PIPE_PUSH bit is
|
|
// only a valid A/B while the legacy arm is still compiled, because with the bit clear the
|
|
// backend would otherwise still run the re-keyed code. So a clear bit selects the legacy
|
|
// arm, and a run that has explicitly disabled the legacy arm may not fall into it.
|
|
//
|
|
// D14 spends that last sentence at STARTUP, not per draw: "a Track-H subsystem whose bit is
|
|
// clear is a startup Fatal{PipeLegacyMemosDisabled}". Nothing in the draw path aborts, and
|
|
// nothing outside Track H consults the legacy-memo lever at all - see
|
|
// MagmaPipeValidateSubsystemConfiguration below for both halves of that rule.
|
|
//
|
|
// The whole header is inert in a pull build: MOBILEGL_PIPE_PUSH is 0 there, every helper
|
|
// below is behind it, and the pull build's translation units are byte-identical (G1).
|
|
namespace MobileGL::MG_Backend::DirectVulkan {
|
|
|
|
#if MOBILEGL_PIPE_PUSH
|
|
// Is `subsystemBit` (MG_Pipe/MGPipe.h's kMGPipeSubsystem*) migrated in this run?
|
|
inline Bool MagmaPipeSubsystemOn(Uint64 subsystemBit) {
|
|
return (MG_Config::Features.PipePush & subsystemBit) != 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// D14's startup gate
|
|
// ---------------------------------------------------------------------------------
|
|
//
|
|
// Called once from VulkanRenderer::Initialize(), i.e. only when Magma is the backend
|
|
// that is actually running. It answers exactly one question and it answers it before the
|
|
// first draw: is there an arm for Magma's Track-H subsystem in this configuration?
|
|
//
|
|
// Three deliberate boundaries, each of which the per-draw shape this replaces got wrong:
|
|
//
|
|
// * ONLY Magma's own Track-H bit is checked. Espryt's bit 5 is Espryt's business (a
|
|
// DirectVulkan run does not execute one line of DirectGLES' re-key), so
|
|
// MOBILEGL_PIPE_PUSH=0x20 must not kill a Magma run, and MOBILEGL_PIPE_PUSH=0x40 must
|
|
// not kill an Espryt one.
|
|
// * bit 0 (kMGPipeSubsystemRenderState) is NOT Track H and is NOT fatal. It is not a
|
|
// memo re-key at all: it decides where the pipeline memo's STATE KEY comes from, and
|
|
// a clear bit there simply means the client is not pushing render-state CSOs in this
|
|
// run, which GetOrCreatePipeline answers with its own state hash. D14 labels bits 5
|
|
// and 6 "Track H" and labels bit 0 nothing of the sort.
|
|
// * it is Fatal at STARTUP, once, not on a draw. A per-draw abort inside
|
|
// GetOrCreatePipeline turns a configuration mistake into a mid-frame crash and puts a
|
|
// branch nobody needs on the hottest path in the backend.
|
|
//
|
|
// [declared deviation from D14, review v2 minor 2] D14's runtime row reads "false: the
|
|
// legacy arm is never entered", and D14's compile-switch row names ComputePipelineStateHash
|
|
// as part of the pre-handle arm. Those two together would make MOBILEGL_PIPE_LEGACY_MEMOS=0
|
|
// with bit 0 CLEAR a contradiction: the pipeline memo has no CSO handle to key on, so it
|
|
// keys on a state hash, and in a build that compiles the pre-handle arm that hash IS
|
|
// ComputePipelineStateHash. Magma does not make that fatal - bit 0 is not Track H, and
|
|
// there is a correct answer (the state hash) where for bits 5/6 there is none - but it no
|
|
// longer does it SILENTLY: the combination is named once, at startup, right here.
|
|
inline void MagmaPipeValidateSubsystemConfiguration() {
|
|
if (!MG_Config::Features.PipeLegacyMemos &&
|
|
!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) {
|
|
MGLOG_W("MGPipe: MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState (bit 0 "
|
|
"of MOBILEGL_PIPE_PUSH) clear - Magma's pipeline memo has no CSO handle to key "
|
|
"on, so every draw whose pipeline-state version moved runs the pre-handle STATE "
|
|
"HASH instead. That is not a Track-H subsystem and not fatal, but it is not the "
|
|
"handle arm either: set bit 0 (MOBILEGL_PIPE_PUSH=0x%llx) if this run was meant "
|
|
"to measure it.",
|
|
static_cast<unsigned long long>(MG_Config::Features.PipePush |
|
|
MG_Pipe::kMGPipeSubsystemRenderState));
|
|
}
|
|
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
|
// The pre-handle arm is compiled AND the operator has not forbidden entering it, so a
|
|
// clear bit is an ordinary, valid A/B: the site takes the legacy arm.
|
|
if (MG_Config::Features.PipeLegacyMemos) return;
|
|
#endif
|
|
if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) return;
|
|
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
|
const char* const why = "this run has MOBILEGL_PIPE_LEGACY_MEMOS=0";
|
|
#else
|
|
const char* const why =
|
|
"this build has cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF, which compiles no such arm";
|
|
#endif
|
|
MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} Magma's Track-H subsystem "
|
|
"(kMGPipeSubsystemMagmaVertexInput, bit 6 of MOBILEGL_PIPE_PUSH) is clear, so the "
|
|
"vertex-input cache and the VAO draw memo want the pre-handle arm - but %s. Set "
|
|
"bit 6 (MOBILEGL_PIPE_PUSH=0x%llx, or the default 0x%llx), or allow the legacy arm.",
|
|
why,
|
|
static_cast<unsigned long long>(MG_Config::Features.PipePush |
|
|
MG_Pipe::kMGPipeSubsystemMagmaVertexInput),
|
|
static_cast<unsigned long long>(MG_Pipe::kMGPipeSubsystemsMigratedAtP2));
|
|
std::abort();
|
|
}
|
|
|
|
// "Does this Track-H site run the handle arm?" - the ONE question every re-keyed Track-H
|
|
// site asks, so that they cannot disagree with each other or with the startup gate.
|
|
inline Bool MagmaPipeTrackHArmIsHandles(Uint64 trackHBit) {
|
|
#if MOBILEGL_PIPE_LEGACY_MEMOS
|
|
return MagmaPipeSubsystemOn(trackHBit);
|
|
#else
|
|
// No pre-handle arm exists in this build, and MagmaPipeValidateSubsystemConfiguration
|
|
// has already made a clear bit a startup Fatal, so the handle arm is the only arm a
|
|
// running process can be on.
|
|
(void)trackHBit;
|
|
return true;
|
|
#endif
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Negative control C (P2 brief D18): MOBILEGL_PIPE_HANDLE_ABA_CONTROL
|
|
// ---------------------------------------------------------------------------------
|
|
//
|
|
// "Is the object-identity half of every vertex-input memo key deliberately defeated in
|
|
// this run?" - the ONE question the control's sites ask, for the same reason
|
|
// MagmaPipeTrackHArmIsHandles exists: three sites deciding separately could disagree,
|
|
// and a control that defeats two of three guards proves nothing.
|
|
//
|
|
// WHAT IT DEFEATS, AND WHY IT IS SPELLED AS "REPLACE THE IDENTITY WITH A CONSTANT"
|
|
// RATHER THAN "USE THE HEAP ADDRESS".
|
|
//
|
|
// D18 wrote the control as "hash attr.Buffer.get() instead of GetLifetimeId(), and skip
|
|
// the vaoLifetimeId compare", on the theory that a deleted object's replacement lands at
|
|
// the freed heap block and so reproduces the key. Measured, it does not: in
|
|
// HandleRecycleScenario the GL NAMES come back (glGen* hands the deleted name straight
|
|
// out) but the C++ heap blocks do not - a VertexArrayObject is 3920 bytes, too large for
|
|
// glibc's tcache, so its chunk goes to the unsorted bin and is split by the very next
|
|
// allocation the replacement path makes. Four create/delete cycles in one run produced
|
|
// four distinct addresses, ~1 MiB apart. With no address reuse there is nothing for
|
|
// "hash the address" to collide with: the replacement hashes differently, indexes a
|
|
// different memo slot, and inherits nothing - so the arm asserted stale pixels and saw
|
|
// fresh ones, which is a FAILING negative control that had stopped controlling anything.
|
|
//
|
|
// So the control no longer asks the allocator for the collision; it manufactures it. On
|
|
// both arms the object identity is replaced by a constant, which is the strongest form of
|
|
// "the allocator handed the block back" and is deterministic. That covers strictly more
|
|
// than D18's spelling, and in particular it reaches the arm P2 SHIPS: on the handle arm
|
|
// the constant defeats the OBJECT IDENTITY THAT SELECTS THE SLOT - the key the handle arm
|
|
// ships - so the replacement VAO is handed the dead one's memo entry and its content hash.
|
|
// Defeating only the retired lifetime-id/address guards would leave that key untested,
|
|
// which is exactly the vacuity this control exists to catch.
|
|
//
|
|
// WHAT IT DOES NOT COVER, AND WHY NO REPRODUCER OF THIS SHAPE CAN [fix-aba review v1,
|
|
// MAJOR 1]. It does NOT exercise the GENERATION half of {slot, gen}:
|
|
//
|
|
// * this mint has no death notification - nothing in MG_Backend/DirectVulkan consumes
|
|
// NotifyStateObjectDestroyed - so a slot returns to the free list only through
|
|
// OnFrameBoundary's age sweep (kSweepInterval 256, kRetireAgeBoundaries 1024, below);
|
|
// * HandleRecycleScenario issues five frame boundaries, so the free list is empty when
|
|
// the replacement VAO acquires and it gets a BRAND-NEW slot at Gen 1 (measured:
|
|
// redVao slot=2 gen=1, greenVao slot=3 gen=1). The knob-off FRESH verdict there is
|
|
// decided by the SLOT alone, and deleting the ++Gen below leaves all four arms green;
|
|
// * a genuine slot REUSE needs >= 1024 idle boundaries after the dead object's last
|
|
// draw, which necessarily puts the two draws in different frames - and the only memo
|
|
// that carries a GPU slice rather than a layout, ResolvedVertexBindings, declines
|
|
// across frames by design. The two requirements are mutually exclusive, so the
|
|
// generation is out of reach of any same-frame pixel reproducer for this memo.
|
|
//
|
|
// The generation is covered where it IS expressible, over this mint and the claim rule
|
|
// MagmaPipeClaimSlotMemos below: MG_Test/Pipe/MagmaPipeIdentityTest.cpp drives a real
|
|
// retire -> reuse and asserts that a memo stamped at {slot, gen=N} is not served at
|
|
// {slot, gen=N+1} with the knob off and IS served with it on. Deleting the ++Gen reds that
|
|
// suite; it is the only place in the tree where that deletion is caught.
|
|
//
|
|
// Everything the control does NOT defeat is as load-bearing as what it does. It never
|
|
// touches a guard that is not an IDENTITY guard: the resolved-bindings memo's frame
|
|
// serial, its slice-epoch compares and its host-map check all stay in force, so a green
|
|
// AbaControl arm still means "a replacement object was handed its dead predecessor's
|
|
// resolved vertex bindings because the identity halves of the keys were defeated", not
|
|
// "every safety net was switched off until something broke".
|
|
//
|
|
// Off by default (Config.h), set only by the HandleRecycle AbaControl ctest lanes, and
|
|
// #if MOBILEGL_PIPE_PUSH throughout, so no shipping pull build can even parse it.
|
|
// P4a (BRIEF-P4A.md D-I2, G8): WHICH KINDS THIS ANSWER COVERS, and it is not "all of them".
|
|
//
|
|
// P4a mints six more client-side kinds - Texture, Renderbuffer, Framebuffer, SamplerCso,
|
|
// SamplerViewCso and ShaderCso - and requires the ABA control to defeat "the identity half
|
|
// of P4a's memo keys as well", because a control that only defeats the guards a phase
|
|
// RETIRED says nothing about the key that phase SHIPS.
|
|
//
|
|
// On Magma there is no such key to defeat, and that is a fact about the roadmap rather than
|
|
// an omission here. MagmaPipeIdentityTables below mints exactly TWO kinds,
|
|
// VertexElementsCso and Buffer; a texture, a framebuffer, a sampler, a view and a program
|
|
// are all still reached from their frontend objects on this backend, and moving them onto
|
|
// handles is P7's work (ROADMAP.md:24 - "Magma anything"; P4a leaves MG_Backend/DirectVulkan
|
|
// untouched apart from this file). So the honest statement is per KIND, and it is spelled as
|
|
// code rather than as a comment so that a caller cannot read the blanket answer above and
|
|
// conclude the knob covers its kind:
|
|
//
|
|
// * for the two kinds this backend really keys on {slot, gen}, the knob defeats the
|
|
// identity exactly as it always has (MagmaPipeClaimSlotMemos);
|
|
// * for P4a's six there is nothing here to defeat, so the answer is FALSE - and
|
|
// MG_IntegrationTest's HandleRecycleScenario reads that through its own build probe and
|
|
// makes those cases' AbaControl arm assert the CORRECT pixels while SAYING that it is
|
|
// not controlling anything for that kind. It does not assert a corruption that no code
|
|
// on this tree can produce, which would be a permanently red always-on lane.
|
|
//
|
|
// WHAT MAKES IT TRUE LATER, in one sentence, so the next reader does not have to derive it:
|
|
// when a backend grows a Features.PipeHandleAbaControl consumer over its P4a object slot
|
|
// tables - one `if` in GetOrCreate / FindByHandle, the shape MagmaPipeClaimSlotMemos already
|
|
// has for vertex input - this function's per-kind answer becomes that consumer's, the
|
|
// integration probe finds the consumer, and the six cases flip to expecting the corruption.
|
|
inline Bool MagmaPipeAbaControlDefeatsIdentity() {
|
|
return MG_Config::Features.PipeHandleAbaControl;
|
|
}
|
|
|
|
// WHICH KINDS THIS BACKEND ACTUALLY KEYS ON {slot, gen}, and therefore which kinds the knob
|
|
// above has an identity to defeat at all. `kind` is MG_Pipe::MGPipeKind.
|
|
//
|
|
// EXHAUSTIVE, WITH NO `default:`, for MG_IntegrationTest/Harness/PipeSlotPeek.cpp's reason:
|
|
// a kind added to MGPipeKind without a decision here must be a -Wswitch warning in this
|
|
// file rather than a row that silently inherits somebody else's answer. Being wrong in the
|
|
// "covered" direction is the expensive one - a control asserting a corruption nobody can
|
|
// produce is a permanently red always-on lane - so an undecided kind must never read true,
|
|
// and with no `default:` there is no arm for it to read true from.
|
|
//
|
|
// constexpr AND PINNED BY static_assert BELOW, which is what stops it rotting the way a
|
|
// predicate with no caller does: MagmaPipeIdentityTables mints exactly two kinds, the
|
|
// asserts say so in both directions, and the file no longer compiles if the tables and this
|
|
// statement of them ever part company. (Review F-m5: the earlier form had no caller at all
|
|
// and could not make anything red or green.)
|
|
inline constexpr Bool MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind kind) {
|
|
switch (kind) {
|
|
// The two MagmaPipeIdentityTables really mints.
|
|
case MG_Pipe::MGPipeKind::VertexElementsCso:
|
|
case MG_Pipe::MGPipeKind::Buffer:
|
|
return true;
|
|
// P4a's six object classes: still reached from their frontend objects on this
|
|
// backend (Magma's object paths are P7, ROADMAP.md:24), so there is no key here for
|
|
// the knob to defeat.
|
|
case MG_Pipe::MGPipeKind::Texture:
|
|
case MG_Pipe::MGPipeKind::Renderbuffer:
|
|
case MG_Pipe::MGPipeKind::Framebuffer:
|
|
case MG_Pipe::MGPipeKind::SamplerCso:
|
|
case MG_Pipe::MGPipeKind::SamplerViewCso:
|
|
case MG_Pipe::MGPipeKind::ShaderCso:
|
|
// ...and everything else this backend does not mint a handle for.
|
|
case MG_Pipe::MGPipeKind::None:
|
|
case MG_Pipe::MGPipeKind::Xfb:
|
|
case MG_Pipe::MGPipeKind::RenderStateCso:
|
|
case MG_Pipe::MGPipeKind::Fence:
|
|
case MG_Pipe::MGPipeKind::Query:
|
|
case MG_Pipe::MGPipeKind::Context:
|
|
case MG_Pipe::MGPipeKind::KindCount:
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static_assert(MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::VertexElementsCso),
|
|
"MagmaPipeIdentityTables mints VertexElementsCso: the knob has an identity to "
|
|
"defeat for it");
|
|
static_assert(MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Buffer),
|
|
"MagmaPipeIdentityTables mints Buffer: the knob has an identity to defeat for it");
|
|
static_assert(!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Texture) &&
|
|
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Renderbuffer) &&
|
|
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Framebuffer) &&
|
|
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::SamplerCso) &&
|
|
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::SamplerViewCso) &&
|
|
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::ShaderCso),
|
|
"P4a's six object classes are not keyed on {slot, gen} on this backend, so "
|
|
"HandleRecycleScenario's six AbaControl arms must NOT expect a corruption here. "
|
|
"Wiring one of them is what flips this assert, this predicate and that arm - and "
|
|
"MG_IntegrationTest's two-symbol probe over MG_Backend/DirectVulkan is what "
|
|
"carries the answer into the lane");
|
|
|
|
// THERE IS DELIBERATELY NO PER-KIND WRAPPER HERE, and review F-v2-m3 is why. An earlier
|
|
// round carried `MagmaPipeAbaControlCoversKind(kind)` - the conjunction of the two
|
|
// statements above - and it had no caller anywhere in the tree: the knob's only two
|
|
// consumers (VulkanRenderer.cpp's VAO draw memo and VertexInputStateFactory.cpp's pipeline
|
|
// key) each hold ONE kind, VertexElementsCso, by construction, so the kind is not a
|
|
// variable at either site. A conjunction no build ever evaluates cannot be pinned the way
|
|
// the predicate above is pinned - it is not constexpr, because it reads MG_Config::Features,
|
|
// so no static_assert can reach it - which makes it exactly the rot F-m5 was raised about,
|
|
// one level up: an `&&` whose operands could be inverted or dropped with nothing to say so.
|
|
//
|
|
// The two pieces stand alone instead, and each is pinned by something that runs:
|
|
// MagmaPipeAbaControlKindIsRekeyedHere is constexpr and asserted in BOTH directions by the
|
|
// three static_asserts above, which compile in every Magma build; MagmaPipeAbaControlDefeats
|
|
// Identity is the knob, and its two consumers are what make it true or false. A call site
|
|
// that ever does hold a variable kind writes the `&&` there, where a build will run it.
|
|
|
|
// The single consumer-table entry every VAO collapses onto while the control is on. Slot
|
|
// 0 is a real, ordinary entry of both tables (MagmaPipeSlotIndex maps the first allocatable
|
|
// handle onto it), so nothing about the tables changes shape for the control's sake.
|
|
inline constexpr Uint32 kMagmaPipeAbaControlSlotIndex = 0;
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// The {slot, gen} mint
|
|
// ---------------------------------------------------------------------------------
|
|
//
|
|
// Maps a frontend object's never-reused lifetime id to a dense {slot, gen}. Three
|
|
// properties, and the third is the one review v2 got wrong:
|
|
//
|
|
// 1. exact identity - Gen moves whenever a slot changes owner, so a stale handle can
|
|
// never match a live object even if the allocator hands back the same heap address
|
|
// (the ABA HandleRecycleScenario reproduces);
|
|
// 2. dense slots - the slot IS an index, so a consumer's per-slot table needs no hash,
|
|
// no probe and no mix;
|
|
// 3. NO CAPACITY CLIFF. A live object's handle never changes while the object is being
|
|
// drawn, whatever the working set size.
|
|
//
|
|
// Property 3 is why this is not the fixed 2-way set-associative LRU the previous round
|
|
// shipped. That structure evicted a LIVE object once the working set passed its capacity,
|
|
// and every consumer memo keyed on the handle died with it: measured on a verbatim
|
|
// transcription, 54% of uses lost their handle at 2500 live VAOs against 2048 entries, and
|
|
// 20% at 1024 live VAOs once the lifetime ids are sparse (an app that creates and destroys
|
|
// VAOs, which is the Minecraft chunk shape this exists for). Two of the three memos it
|
|
// fed - the content-hash memo and the resolved-state memo - had NO capacity before this
|
|
// package: they were unbounded mutable fields on VertexArrayObject. Introducing eviction
|
|
// there turns one ComputeHash per VAO reconfiguration into one per DRAW, and, once the
|
|
// buffer table thrashes too, makes the vertex-input content hash a per-draw value that
|
|
// inserts a fresh heap-allocated BackendVertexInputState into an unbounded map on every
|
|
// draw. That is a worse leak than the one it was introduced to avoid.
|
|
//
|
|
// So: grow on demand, and reclaim by AGE instead of by capacity.
|
|
//
|
|
// * Acquire hits an UnorderedMap<lifetimeId, slotIndex>, in front of which sits a
|
|
// one-entry memo. Every re-keyed site in a draw asks about the SAME VAO, so the memo
|
|
// turns the five-or-six acquisitions a draw makes into one map probe plus five Uint64
|
|
// compares - less than the address multiply plus two-way probe the pre-handle arm ran.
|
|
// * OnFrameBoundary retires slots whose object has not been drawn for
|
|
// kRetireAgeBoundaries boundaries and returns them to a free list, so the table's
|
|
// footprint tracks the LIVE DRAWN working set, not objects ever created. That is the
|
|
// property MG_Impl/Pipe/SlotAllocator cannot have here: nothing in P2 can call its
|
|
// Free (the tracker emits no object-class state, BufferBackendOps::OnDestroy is handed
|
|
// a BackendBufferResource rather than the BufferObject, and VertexArrayObject has no
|
|
// death hook at all - adding one is D13's explicit-destroy work, which covers Espryt's
|
|
// six kinds, not VertexElementsCso), so an allocator here would grow by one SlotState
|
|
// plus one map node per object EVER created, for the life of the process, on a
|
|
// platform with an LMK. Age-based reclamation is the stand-in for the death
|
|
// notification, and it is exactly as ABA-proof, because reuse bumps Gen.
|
|
// * A retire costs at most one memo recompute if the object is drawn again - the same
|
|
// price a cache miss costs - and it is charged only to objects that went idle for
|
|
// ~1024 frames, never to a hot one.
|
|
//
|
|
// Memory: one map node plus one 24-byte Entry per live object, i.e. tens of bytes against
|
|
// the kilobyte a VertexArrayObject or a BufferObject already costs the frontend. There is
|
|
// no capacity to size off a device measurement because there is no capacity; what the
|
|
// device run in D.4.2 can still want is the number itself, so the high-water mark is
|
|
// logged at MGLOG_D on the allocate-a-new-slot branch (once per new object, never on a
|
|
// draw - ROADMAP.md:7).
|
|
//
|
|
// Single-threaded, like the rest of the renderer. Owned per VulkanRenderer (see
|
|
// MagmaPipeIdentityTables): a process-global would share one table, and one reclamation
|
|
// clock, across two live contexts.
|
|
class MagmaPipeIdentityTable {
|
|
public:
|
|
explicit MagmaPipeIdentityTable(const char* kindName) : m_kindName(kindName) {}
|
|
|
|
// Slots ever minted. A consumer table indexed by MagmaPipeSlotIndex() needs this many
|
|
// entries; MagmaPipeSlotTable below grows itself, so nobody has to ask.
|
|
Uint32 Count() const { return static_cast<Uint32>(m_entries.size()); }
|
|
// Objects currently holding a slot - the live working set this table tracks.
|
|
Uint32 LiveCount() const { return static_cast<Uint32>(m_index.size()); }
|
|
|
|
MG_Pipe::MGPipeHandle Acquire(Uint64 lifetimeId) {
|
|
// Unreachable: MG_State hands out lifetime ids from 1 precisely so that a
|
|
// zero-initialised memo slot cannot carry a live object's id. Guarded anyway so
|
|
// that a zero can never be minted into a slot and then indexed with.
|
|
if (lifetimeId == 0) return MG_Pipe::kMGPipeNullHandle;
|
|
// The one-entry front memo. Cleared by any retire, so it can never serve a slot
|
|
// that has been handed back to the free list.
|
|
if (lifetimeId == m_lastLifetimeId) {
|
|
m_entries[m_lastIndex].LastUse = m_boundary;
|
|
return m_lastHandle;
|
|
}
|
|
Uint32 index = 0;
|
|
const auto it = m_index.find(lifetimeId);
|
|
if (it != m_index.end()) {
|
|
index = it->second;
|
|
} else {
|
|
index = ClaimSlot();
|
|
m_entries[index].LifetimeId = lifetimeId;
|
|
m_index.emplace(lifetimeId, index);
|
|
}
|
|
Entry& entry = m_entries[index];
|
|
entry.LastUse = m_boundary;
|
|
m_lastLifetimeId = lifetimeId;
|
|
m_lastIndex = index;
|
|
m_lastHandle = MG_Pipe::MGPipeHandle{index + MG_Pipe::kMGPipeFirstAllocatableSlot,
|
|
entry.Gen};
|
|
return m_lastHandle;
|
|
}
|
|
|
|
// Ages the table and returns idle slots to the free list. Same shape and the same
|
|
// self-gating as VertexInputStateFactory::OnFrameBoundary, which is what the reclaimed
|
|
// slots' consumers use.
|
|
void OnFrameBoundary() {
|
|
++m_boundary;
|
|
if ((m_boundary % kSweepInterval) != 0) return;
|
|
SizeT retired = 0;
|
|
for (auto it = m_index.begin(); it != m_index.end();) {
|
|
Entry& entry = m_entries[it->second];
|
|
if ((m_boundary - entry.LastUse) > kRetireAgeBoundaries) {
|
|
entry.LifetimeId = 0;
|
|
m_freeSlots.push_back(it->second);
|
|
it = m_index.erase(it);
|
|
++retired;
|
|
} else {
|
|
++it;
|
|
}
|
|
}
|
|
if (retired != 0) {
|
|
// A retired slot's Gen has not moved yet - it moves when the slot is reused -
|
|
// so a front memo pointing at one would still hand out a handle the consumer
|
|
// tables would accept. Drop it.
|
|
m_lastLifetimeId = 0;
|
|
m_lastHandle = MG_Pipe::kMGPipeNullHandle;
|
|
MGLOG_D("MagmaPipeIdentityTable(%s): retired %zu idle slots, %u live of %u minted",
|
|
m_kindName, retired, LiveCount(), Count());
|
|
}
|
|
}
|
|
|
|
private:
|
|
// Sweep cadence and retirement age, deliberately the same numbers
|
|
// VertexInputStateFactory::OnFrameBoundary uses for the entries these slots key: a slot
|
|
// retired earlier than its cache entry would mint a new handle for an object whose
|
|
// entry is still live and still correct, which is a pure waste.
|
|
static constexpr Uint64 kSweepInterval = 256;
|
|
static constexpr Uint64 kRetireAgeBoundaries = 1024;
|
|
|
|
struct Entry {
|
|
Uint64 LifetimeId = 0;
|
|
Uint64 LastUse = 0;
|
|
// Moves ONLY on slot reuse, never on respecify: an object that keeps its slot keeps
|
|
// its generation, which is what makes a memo survive a reconfiguration.
|
|
Uint32 Gen = 0;
|
|
};
|
|
|
|
Uint32 ClaimSlot() {
|
|
while (!m_freeSlots.empty()) {
|
|
const Uint32 index = m_freeSlots.back();
|
|
m_freeSlots.pop_back();
|
|
// MGPipeHandles.h:52-58 defends the Gen wrap only in a debug allocator, and
|
|
// MOBILEGL_ASSERT is compiled out of every build P2 runs (Defines.h: asserts are
|
|
// live only at MOBILEGL_LOG_ACTIVE_LEVEL == DEBUG). So the wrap is handled on the
|
|
// RELEASE path instead of asserted: a slot that has been reused 2^32 times is
|
|
// permanently retired rather than wrapped, because a wrapped Gen would let a
|
|
// stale handle match a live object. It costs one slot.
|
|
if (m_entries[index].Gen == ~Uint32{0}) {
|
|
MGLOG_W("MagmaPipeIdentityTable(%s): slot %u reached generation 2^32-1 and is "
|
|
"retired for good; {slot, gen} stays unique",
|
|
m_kindName, index + MG_Pipe::kMGPipeFirstAllocatableSlot);
|
|
continue;
|
|
}
|
|
++m_entries[index].Gen;
|
|
return index;
|
|
}
|
|
const Uint32 index = static_cast<Uint32>(m_entries.size());
|
|
m_entries.push_back(Entry{});
|
|
m_entries[index].Gen = 1;
|
|
// The high-water mark, at powers of two from 1024 up: at most a handful of lines
|
|
// for a whole session, emitted from the allocate-a-NEW-slot branch, i.e. once per
|
|
// object this backend has ever seen and never on a draw (ROADMAP.md:7).
|
|
//
|
|
// [narrow, declared deviation from D20's "MGLOG_D for anything non-critical"] This
|
|
// one is I, not D, because D is compiled out of every build that ships and of every
|
|
// build P2 measures, and this line IS the measurement review v2's MAJOR 1 asks for:
|
|
// the live-object high-water mark of minecraft-1.21.4-in-world and
|
|
// ...-sodium-in-world, which nothing on desktop reaches and no gate here can see.
|
|
// The structure no longer has a capacity to size off it, so the number is evidence
|
|
// rather than a tuning input - but D.4.2 should still read it out of the device log,
|
|
// and it cannot read a line that was compiled away.
|
|
const SizeT minted = m_entries.size();
|
|
if (minted >= 1024 && (minted & (minted - 1)) == 0) {
|
|
MGLOG_I("MagmaPipeIdentityTable(%s): high-water %zu slots minted, %u live",
|
|
m_kindName, minted, LiveCount());
|
|
}
|
|
return index;
|
|
}
|
|
|
|
const char* m_kindName = "";
|
|
Uint64 m_boundary = 0;
|
|
Vector<Entry> m_entries;
|
|
Vector<Uint32> m_freeSlots;
|
|
UnorderedMap<Uint64, Uint32> m_index;
|
|
// One-entry front memo (see Acquire). m_lastLifetimeId == 0 means "empty": a live
|
|
// object's lifetime id is never 0.
|
|
Uint64 m_lastLifetimeId = 0;
|
|
Uint32 m_lastIndex = 0;
|
|
MG_Pipe::MGPipeHandle m_lastHandle = MG_Pipe::kMGPipeNullHandle;
|
|
};
|
|
|
|
// The two mints one renderer owns. Per renderer, NOT process-global: two live contexts (or
|
|
// a context recreation, which destroys and rebuilds the renderer) would otherwise share one
|
|
// table and one reclamation clock, and both consumer tables are per-instance already.
|
|
class MagmaPipeIdentityTables {
|
|
public:
|
|
// A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array
|
|
// resolves to, and the only kind in MGPipeKind that names vertex-input state.
|
|
MG_Pipe::MGPipeHandle HandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
|
|
return kind == MG_Pipe::MGPipeKind::Buffer ? m_buffers.Acquire(lifetimeId)
|
|
: m_vaos.Acquire(lifetimeId);
|
|
}
|
|
void OnFrameBoundary() {
|
|
m_vaos.OnFrameBoundary();
|
|
m_buffers.OnFrameBoundary();
|
|
}
|
|
const MagmaPipeIdentityTable& Vaos() const { return m_vaos; }
|
|
const MagmaPipeIdentityTable& Buffers() const { return m_buffers; }
|
|
|
|
private:
|
|
MagmaPipeIdentityTable m_vaos{"VertexElementsCso"};
|
|
MagmaPipeIdentityTable m_buffers{"Buffer"};
|
|
};
|
|
|
|
// The table entry a handle names. Every per-slot table Magma keeps is indexed by this.
|
|
//
|
|
// A null handle has no slot, and it is unreachable here: both lifetime-id sources start at
|
|
// 1 (VertexArrayObject.cpp, BufferObject.cpp), so Acquire's zero guard never fires. The
|
|
// ternary, not the assertion, is what has effect in a shipped build (Defines.h compiles
|
|
// MOBILEGL_ASSERT out at INFO), and slot 0 of a consumer table is a real entry that a null
|
|
// handle can never match, because MGPipeHandleIsNull is also what the consumers compare.
|
|
inline Uint32 MagmaPipeSlotIndex(const MG_Pipe::MGPipeHandle& handle) {
|
|
MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle),
|
|
"a null MGPipeHandle has no slot to index a per-slot table with");
|
|
return MG_Pipe::MGPipeHandleIsNull(handle)
|
|
? 0u
|
|
: handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot;
|
|
}
|
|
|
|
// A grow-on-demand per-slot table whose ENTRY ADDRESSES NEVER MOVE.
|
|
//
|
|
// D12.4 asks for a grow-on-demand Vector, and with an unbounded mint that is what a
|
|
// consumer needs - but a Vector that grows relocates its elements, and the draw path holds
|
|
// references into these entries across nested calls. Chunks of kChunkEntries are appended
|
|
// instead: the Vector of owning pointers reallocates, the chunks never do, so an entry
|
|
// reference is valid for the life of the table. That is the same guarantee the fixed table
|
|
// it replaces gave, without the fixed capacity.
|
|
template <typename T, Uint32 kChunkEntries = 256>
|
|
class MagmaPipeSlotTable {
|
|
public:
|
|
T& operator[](Uint32 index) {
|
|
const Uint32 chunk = index / kChunkEntries;
|
|
while (m_chunks.size() <= chunk) {
|
|
m_chunks.push_back(MakeUnique<Chunk>());
|
|
}
|
|
return m_chunks[chunk]->Entries[index % kChunkEntries];
|
|
}
|
|
SizeT Capacity() const { return m_chunks.size() * kChunkEntries; }
|
|
|
|
private:
|
|
struct Chunk {
|
|
T Entries[kChunkEntries] = {};
|
|
};
|
|
Vector<UniquePtr<Chunk>> m_chunks;
|
|
};
|
|
|
|
// The claim rule every per-slot memo table uses, in one place so that the rule and the
|
|
// negative control that defeats it cannot drift apart between consumers - and so that the
|
|
// unit suite which drives a REAL slot reuse (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) tests
|
|
// this code rather than a copy of it.
|
|
//
|
|
// The SLOT picks the entry; the WHOLE handle - Gen included - decides whether the entry is
|
|
// this object's. A slot the mint recycled for a different object comes back with a moved
|
|
// Gen, so the compare fails and the entry is cleared rather than inherited. That is the
|
|
// half HandleRecycleScenario cannot reach (see MagmaPipeAbaControlDefeatsIdentity).
|
|
//
|
|
// With negative control C on, every object collapses onto one entry and the entry is handed
|
|
// back UNCLEARED and UNCLAIMED - at once "the replacement reproduced its predecessor's
|
|
// slot" and "the slot was reused and Gen did not move".
|
|
//
|
|
// `Memos` needs a MG_Pipe::MGPipeHandle member named Owner and a default constructor that
|
|
// means "empty"; VertexInputStateFactory::VaoBackendMemos is the one production instance.
|
|
template <typename Memos, Uint32 kChunkEntries>
|
|
inline Memos& MagmaPipeClaimSlotMemos(MagmaPipeSlotTable<Memos, kChunkEntries>& table,
|
|
const MG_Pipe::MGPipeHandle& handle) {
|
|
if (MagmaPipeAbaControlDefeatsIdentity()) {
|
|
return table[kMagmaPipeAbaControlSlotIndex];
|
|
}
|
|
Memos& memos = table[MagmaPipeSlotIndex(handle)];
|
|
if (!(memos.Owner == handle)) {
|
|
memos = Memos{};
|
|
memos.Owner = handle;
|
|
}
|
|
return memos;
|
|
}
|
|
#endif // MOBILEGL_PIPE_PUSH
|
|
} // namespace MobileGL::MG_Backend::DirectVulkan
|