[Refactor] (P13 W6c): Magma's frontend arm goes - the frontend program source, sampled-set walk, placeholder textures, VAO draw memos, identity tables and fast path - leaving the record arm.

This commit is contained in:
2026-10-06 17:55:44 -04:00
parent 887547c707
commit dc1e95b4c3
18 changed files with 116 additions and 7074 deletions
@@ -18,382 +18,6 @@
#include <utility>
namespace MobileGL::MG_Backend::DirectVulkan {
VertexInputStateFactory::HashType VertexInputStateFactory::ComputeHash(
const MG_State::GLState::VertexArrayObject& vao) const {
XXHASH_VERIFY(XXH64_reset(m_hashState, m_config.CacheVersion));
for (Int i = 0; i < MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; ++i) {
const auto& attr = vao.GetAttribute(i);
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Enabled, sizeof(attr.Enabled)));
if (!attr.Enabled) {
continue;
}
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Size, sizeof(attr.Size)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Type, sizeof(attr.Type)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Normalized, sizeof(attr.Normalized)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Stride, sizeof(attr.Stride)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Offset, sizeof(attr.Offset)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsInteger, sizeof(attr.IsInteger)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsLong, sizeof(attr.IsLong)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.IsBgra, sizeof(attr.IsBgra)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attr.Divisor, sizeof(attr.Divisor)));
// The bound buffer's IDENTITY is a component of the key, and it has to be the
// buffer's never-reused lifetime id - NOT its heap address, which this used to
// hash. An address is recycled by the allocator, so a deleted-and-recreated
// buffer reproduces it; combined with a byte-identical attribute layout that
// reproduces the WHOLE content hash, and the hash is what
// TryBindResolvedVertexBindings accepts as proof that a memoised binding still
// reads the buffer it was resolved from. It did not: a destroyed buffer's GPU
// slice was bound for its successor's draw, which is how a transform-feedback
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
// test's positions) instead of its own.
// Zero for client memory (no buffer), which is a distinct identity of its own.
//
// P2 D12.4 / ARCHITECTURE.md 9.5: under the handle arm the identity is the
// buffer's {slot, gen} rather than its lifetime id - "lifetimeId -> gen mixed
// into every server-side content hash". The two are equally ABA-proof (the
// allocator maps one onto the other and bumps Gen only on slot REUSE); what
// changes is that the key is now the identity the SERVER will be handed once
// buffers travel as handles, instead of a number only the client can mint.
Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
if (attr.Buffer) {
// The SAME arm question the other four re-keyed sites ask, through the same
// helper: a site that decided for itself could silently key on the pre-handle
// identity while its neighbours keyed on the handle.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
const MG_Pipe::MGPipeHandle handle =
m_identity->HandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId());
bufferKey = static_cast<Uint64>(handle.Slot) | (static_cast<Uint64>(handle.Gen) << 32);
}
if (MagmaPipeAbaControlDefeatsIdentity()) {
// Negative control C (P2 brief D18), on WHICHEVER arm this run is on - the
// pre-handle lifetime id and the handle's {slot, gen} are the same guard
// wearing two hats, and a control that defeated only the retired one would
// say nothing about the key P2 ships.
//
// The identity is replaced by a constant rather than by the raw
// BufferObject*, because the address is not recycled in practice and so
// never collides (see MagmaPipeAbaControlDefeatsIdentity). Zero is what a
// key with NO buffer identity in it looks like - the exact defect this
// hash was fixed for: "the hash is what TryBindResolvedVertexBindings
// accepts as proof that a memoised binding still reads the buffer it was
// resolved from", and with the identity gone it accepts a binding resolved
// from a different buffer. HandleRecycleScenario.AbaControl then draws a
// replacement VAO and gets its dead predecessor's vertex data.
bufferKey = 0;
}
}
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
}
return XXH64_digest(m_hashState);
}
VertexInputStateFactory::VaoBackendMemos& VertexInputStateFactory::MemosFor(
const MG_State::GLState::VertexArrayObject& vao) const {
const MG_Pipe::MGPipeHandle handle =
m_identity->HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId());
// One entry per mintable slot, grown on demand: the mint has no capacity, so neither
// does this, and no two live VAOs can share an entry however large the working set is.
// There is no probe in front of it because the mint itself is one - a one-entry memo
// hit for every acquisition after this draw's first, and a hash probe otherwise.
//
// The claim rule - the slot picks the entry, the whole handle (Gen included) decides
// whose it is - and negative control C's defeat of it are MagmaPipeArms.h's
// MagmaPipeClaimSlotMemos, so that the unit suite which drives a REAL slot reuse
// (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) exercises this code and not a copy of it.
// What the control defeats HERE is the identity that SELECTS the entry: every VAO
// collapses onto one, handed back uncleared, so the replacement inherits the dead
// VAO's content hash and its resolved-entry pointer. The GENERATION half is the unit
// suite's business, for the reason MagmaPipeAbaControlDefeatsIdentity spells out.
return MagmaPipeClaimSlotMemos(m_vaoMemos, handle);
}
Bool VertexInputStateFactory::TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao,
Uint64& outHash) const {
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
const VaoBackendMemos& memos = MemosFor(vao);
if (memos.HashConfigVersion != vao.GetConfigVersion()) return false;
outHash = memos.Hash;
return true;
}
return false;
}
VertexInputStateFactory::HashType VertexInputStateFactory::GetOrComputeHash(
const MG_State::GLState::VertexArrayObject& vao) const {
HashType hash = 0;
// P2 D12.5: the same memo, on the backend's side of the boundary.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
VaoBackendMemos& memos = MemosFor(vao);
if (memos.HashConfigVersion == vao.GetConfigVersion()) {
return memos.Hash;
}
hash = ComputeHash(vao);
memos.Hash = hash;
memos.HashConfigVersion = vao.GetConfigVersion();
return hash;
}
return hash;
}
const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState(
const MG_State::GLState::VertexArrayObject& vao) {
// P2 D12.5: the same per-draw fast path, but the resolved-entry pointer lives in this
// factory's slot-indexed table instead of on the frontend VAO. The eviction epoch
// survives the move and is still what stops a stale pointer being dereferenced: the
// POINTEE is a cache entry this factory can erase at a frame boundary, and moving the
// memo does not change that.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
VaoBackendMemos& memos = MemosFor(vao);
if (memos.StateConfigVersion == vao.GetConfigVersion() && memos.State != nullptr &&
memos.StateEpoch == m_evictionEpoch) {
const auto* memoEntry = static_cast<const BackendVertexInputState*>(memos.State);
memoEntry->lastUsedFrameBoundary = m_frameBoundaryCounter;
return *memoEntry;
}
const BackendVertexInputState& resolved =
GetOrCreateVertexInputState(vao, GetOrComputeHash(vao));
// MemosFor is re-taken rather than kept live across GetOrCreateVertexInputState:
// the reference is not worth holding across a call that can resize the table.
VaoBackendMemos& stamp = MemosFor(vao);
stamp.State = &resolved;
stamp.StateEpoch = m_evictionEpoch;
stamp.StateConfigVersion = vao.GetConfigVersion();
// The AUX memo is deliberately NOT stamped here: its two words already live in
// VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and its getter has no
// live reader anywhere, so the handle arm retires it rather than moving it.
return resolved;
}
// Unreachable: with no legacy arm compiled MagmaPipeTrackHArmIsHandles is a compile-
// time true, so the handle arm above always returns. Written out rather than left to
// fall off the end so the function still has a return on every path a compiler sees.
return GetOrCreateVertexInputState(vao, GetOrComputeHash(vao));
}
const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState(
const MG_State::GLState::VertexArrayObject& vao, HashType hash) {
auto it = m_cache.find(hash);
if (it != m_cache.end()) {
it->second->lastUsedFrameBoundary = m_frameBoundaryCounter;
return *it->second;
}
VertexInputStateBuilder builder;
Vector<SizeT> bindingBufferKeys;
Vector<SizeT> bindingBaseOffsets;
Vector<Uint32> bindingAttributeLocations;
Vector<Bool> bindingUsesClientMemory;
Vector<VertexStreamConversion> bindingConversions;
Vector<VkVertexInputBindingDivisorDescriptionEXT> bindingDivisors;
Uint32 unsupportedAttribMask = 0;
for (Uint32 location = 0; location < MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; ++location) {
const auto& attr = vao.GetAttribute(location);
if (!attr.Enabled) {
continue;
}
VkFormat sourceVkFormat =
ToVkVertexFormat(attr.Type, attr.Size, attr.Normalized, attr.IsInteger, attr.IsBgra, attr.IsLong);
VertexStreamConversion conversion = VertexStreamConversion::None;
// Gated on the SAME flag ToVkVertexFormat gates its 64-bit path on, and that is
// load-bearing rather than belt-and-braces: the narrowing is only correct because the
// shader's `dvec` input is a `vec` by the time the pipeline is built, and what
// guarantees that is the flag being clear. It is clear on every backend today, and a
// program with a 64-bit float vertex input is demoted WHOLE for the same reason even
// where the device has native fp64 (ProgramSpirvTask::GenerateSpirv). With the flag
// set, a dvec3/dvec4 would be declined by ToVkVertexFormat AND left 64-bit in the
// module, so a float32 stream would be fed to a Float64 input.
const Bool narrowFloat64Arrays =
#if MOBILEGL_BUILD_DISAGGREGATED
// P5c (hd, CONTRACT-P5C §3.7): with an active transport the answer is the
// SERVER's own backend's - the client caps mirror is client memory (rule E).
MG_Config::Transport != MG_Config::TransportMode::Monolith
? (MG_Remote::Server::ServerLoopInstance().Backend() == nullptr ||
!MG_Remote::Server::ServerLoopInstance().Backend()
->GetDynamicParameters()
.SupportsFloat64VertexAttributes)
:
#endif
MG_Backend::pActiveBackendObject == nullptr ||
!MG_Backend::pActiveBackendObject->GetDynamicParameters().SupportsFloat64VertexAttributes;
if (sourceVkFormat == VK_FORMAT_UNDEFINED && attr.Type == DataType::Float64 && narrowFloat64Arrays) {
// No native 64-bit fetch here (see ToVkVertexFormat's Float64 case), but the
// source bytes are ordinary IEEE-754 doubles and DemoteFloat64Pass has already
// narrowed every dvec input to a vec, so the array is narrowed to match rather
// than dropped. Mirrors what DirectGLES does for the same state.
const VkFormat narrowedFormat = ToFloat32VertexFormat(attr.Size);
if (narrowedFormat != VK_FORMAT_UNDEFINED && SupportsVertexBufferFormat(narrowedFormat)) {
sourceVkFormat = narrowedFormat;
conversion = VertexStreamConversion::Float64ToFloat32;
MGLOG_W_ONCE("Vertex attribute location=%u is a 64-bit (GL_DOUBLE) array; fetching it at "
"float32 precision through format=%d (size=%d long=%s)",
location, static_cast<Int>(narrowedFormat), attr.Size, attr.IsLong ? "true" : "false");
}
}
if (sourceVkFormat == VK_FORMAT_UNDEFINED) {
MGLOG_E_ONCE("Unsupported vertex attribute layout (location=%u, type=%s, size=%d): the array is "
"enabled but cannot be mapped to a VkFormat",
location, MG_Util::ConvertDataTypeToString(attr.Type).c_str(), attr.Size);
unsupportedAttribMask |= (1u << location);
continue;
}
VkFormat vkFormat = sourceVkFormat;
if (conversion == VertexStreamConversion::None && !SupportsVertexBufferFormat(vkFormat)) {
if (IsScaledIntegerVertexFormat(vkFormat)) {
const VkFormat fallbackFormat = ToFloat32VertexFormat(attr.Size);
if (fallbackFormat != VK_FORMAT_UNDEFINED && SupportsVertexBufferFormat(fallbackFormat)) {
vkFormat = fallbackFormat;
conversion = VertexStreamConversion::ScaledIntegerToFloat32;
MGLOG_W_ONCE("Vertex attribute location=%u format=%d lacks "
"VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT; using float32 stream format=%d "
"(type=%s size=%d normalized=%s integer=%s)",
location, static_cast<Int>(sourceVkFormat), static_cast<Int>(vkFormat),
MG_Util::ConvertDataTypeToString(attr.Type).c_str(), attr.Size,
attr.Normalized ? "true" : "false", attr.IsInteger ? "true" : "false");
}
}
if (conversion == VertexStreamConversion::None) {
MGLOG_E_ONCE("Unsupported Vulkan vertex format (location=%u, format=%d, type=%s, size=%d): "
"VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT is unavailable and no semantic fallback exists",
location, static_cast<Int>(sourceVkFormat),
MG_Util::ConvertDataTypeToString(attr.Type).c_str(), attr.Size);
unsupportedAttribMask |= (1u << location);
continue;
}
}
const SizeT attribByteSize = GetAttributeByteSize(attr.Type, attr.Size, attr.IsBgra);
if (attribByteSize == 0) {
MGLOG_E_ONCE("Vertex attribute with unknown component size (location=%u, type=%s): the array is "
"enabled but cannot be sized",
location, MG_Util::ConvertDataTypeToString(attr.Type).c_str());
unsupportedAttribMask |= (1u << location);
continue;
}
// Verbatim, zero included. The frontend already resolved a pointer call's
// "tightly packed" stride 0 into the element size (see VertexAttribute::Stride),
// so a zero here is the binding model's stride 0 - every vertex reads the same
// element - which is exactly what a zero VkVertexInputBindingDescription::stride
// means. Substituting the element size fetched a fresh element per vertex and ran
// off the end of the buffer (KHR-GL43.vertex_attrib_binding.basic-input-case7/8).
// Client-memory arrays cannot reach zero: they only exist on the pointer path.
const Uint32 sourceStride = static_cast<Uint32>(attr.Stride);
const Bool packedAttribute = attr.Type == DataType::Int2101010Rev ||
attr.Type == DataType::Uint2101010Rev;
const SizeT requiredAlignment = packedAttribute ? attribByteSize : GetComponentSize(attr.Type);
// For a client-memory array attr.Offset holds the raw client pointer, and the
// draw path re-uploads the data to a 16-aligned transient slice with attribute
// offset 0, so only the stride can violate Vulkan's fetch alignment there.
const Bool clientMemoryAttribute = attr.Buffer == nullptr;
if (conversion == VertexStreamConversion::None && requiredAlignment > 1 &&
((sourceStride % requiredAlignment) != 0 ||
(!clientMemoryAttribute && (attr.Offset % requiredAlignment) != 0))) {
// GL accepts arbitrary byte strides and offsets. Core Vulkan vertex fetches do not
// unless VK_EXT_legacy_vertex_attributes is available, so deinterleave this one
// attribute into a tightly packed transient stream without changing its format.
conversion = VertexStreamConversion::Repack;
MGLOG_W_ONCE("Vertex attribute location=%u uses Vulkan-incompatible alignment "
"(offset=%zu stride=%u required=%zu); using a tightly packed stream",
location, attr.Offset, sourceStride, requiredAlignment);
}
Uint32 stride = sourceStride;
// A converted stream is tightly packed, so its stride is the converted element
// size - unless the source stride is zero, which does not describe a packing at
// all but "never advance". That survives the conversion unchanged: the draw path
// converts exactly one element and every vertex reads it.
if (sourceStride != 0) {
if (conversion == VertexStreamConversion::Repack) {
stride = static_cast<Uint32>(attribByteSize);
} else if (conversion == VertexStreamConversion::ScaledIntegerToFloat32 ||
conversion == VertexStreamConversion::Float64ToFloat32) {
stride = static_cast<Uint32>(attr.Size * static_cast<Int>(sizeof(Float)));
}
}
const VkVertexInputRate inputRate =
(attr.Divisor == 0) ? VK_VERTEX_INPUT_RATE_VERTEX : VK_VERTEX_INPUT_RATE_INSTANCE;
const SizeT bufferKey = reinterpret_cast<SizeT>(attr.Buffer.get());
const Uint32 binding = static_cast<Uint32>(bindingBufferKeys.size());
bindingBufferKeys.push_back(bufferKey);
bindingBaseOffsets.push_back(attr.Buffer ? attr.Offset : 0);
bindingAttributeLocations.push_back(location);
bindingUsesClientMemory.push_back(attr.Buffer == nullptr);
bindingConversions.push_back(conversion);
builder.AddBinding(binding, stride, inputRate);
builder.AddAttribute(location, binding, vkFormat, 0);
// Divisor 1 is what VK_VERTEX_INPUT_RATE_INSTANCE already means; only anything
// else needs the extension to say it.
if (inputRate == VK_VERTEX_INPUT_RATE_INSTANCE && attr.Divisor != 1) {
bindingDivisors.push_back({binding, static_cast<Uint32>(attr.Divisor)});
}
}
const auto& state = builder.Build();
auto& slot = m_cache[hash];
if (!slot) {
slot = MakeUnique<BackendVertexInputState>();
}
BackendVertexInputState& entry = *slot;
entry.hash = hash;
entry.lastUsedFrameBoundary = m_frameBoundaryCounter;
entry.bindingDivisors = Move(bindingDivisors);
entry.bindings = builder.GetBindings();
entry.attributes = builder.GetAttributes();
// See the layoutHash declaration: hash only the resolved layout, never
// buffer identities, so identical layouts across VAOs/buffers agree.
XXHASH_VERIFY(XXH64_reset(m_hashState, 0));
for (const auto& binding : entry.bindings) {
XXHASH_VERIFY(XXH64_update(m_hashState, &binding.binding, sizeof(binding.binding)));
XXHASH_VERIFY(XXH64_update(m_hashState, &binding.stride, sizeof(binding.stride)));
XXHASH_VERIFY(XXH64_update(m_hashState, &binding.inputRate, sizeof(binding.inputRate)));
}
for (const auto& attribute : entry.attributes) {
XXHASH_VERIFY(XXH64_update(m_hashState, &attribute.location, sizeof(attribute.location)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attribute.binding, sizeof(attribute.binding)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attribute.format, sizeof(attribute.format)));
XXHASH_VERIFY(XXH64_update(m_hashState, &attribute.offset, sizeof(attribute.offset)));
}
for (const auto& divisor : entry.bindingDivisors) {
XXHASH_VERIFY(XXH64_update(m_hashState, &divisor.binding, sizeof(divisor.binding)));
XXHASH_VERIFY(XXH64_update(m_hashState, &divisor.divisor, sizeof(divisor.divisor)));
}
XXHASH_VERIFY(XXH64_update(m_hashState, &unsupportedAttribMask, sizeof(unsupportedAttribMask)));
entry.layoutHash = XXH64_digest(m_hashState);
entry.attributeLocationMask = 0;
for (const auto& attribute : entry.attributes) {
if (attribute.location < 32u) {
entry.attributeLocationMask |= (1u << attribute.location);
}
}
entry.bindingBufferKeys = std::move(bindingBufferKeys);
entry.bindingBaseOffsets = std::move(bindingBaseOffsets);
entry.bindingAttributeLocations = std::move(bindingAttributeLocations);
entry.bindingUsesClientMemory = std::move(bindingUsesClientMemory);
entry.bindingConversions = std::move(bindingConversions);
entry.unsupportedAttribMask = unsupportedAttribMask;
entry.state = state;
entry.state.pVertexBindingDescriptions = entry.bindings.empty() ? nullptr : entry.bindings.data();
entry.state.pVertexAttributeDescriptions = entry.attributes.empty() ? nullptr : entry.attributes.data();
if (!entry.bindingDivisors.empty()) {
entry.divisorState.vertexBindingDivisorCount = static_cast<Uint32>(entry.bindingDivisors.size());
entry.divisorState.pVertexBindingDivisors = entry.bindingDivisors.data();
entry.state.pNext = &entry.divisorState;
} else {
entry.state.pNext = nullptr;
}
return entry;
}
Bool VertexInputStateFactory::BuildWireVertexInput(const MG_Pipe::MGPipeVertexElementsRecord& elements,
const MG_Pipe::MGPipeApplierState& state, Uint32 activeMask, BackendVertexInputState& out) const {
out = BackendVertexInputState{};
@@ -532,37 +156,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return true;
}
void VertexInputStateFactory::OnFrameBoundary() {
++m_frameBoundaryCounter;
// Sweep occasionally; evict entries whose last hit is far in the past.
// Erasure happens only here, never mid-frame: the draw path holds a
// reference into the current entry across its setup, and unordered_map
// erase would invalidate it. Entries are CPU-side only, so no GPU-idle
// proof is needed; an evicted entry that is used again is simply rebuilt
// from the VAO state (same hash, same content).
constexpr Uint64 kSweepInterval = 256;
constexpr Uint64 kRetireAgeBoundaries = 1024;
if ((m_frameBoundaryCounter % kSweepInterval) != 0) {
return;
}
for (auto it = m_cache.begin(); it != m_cache.end();) {
if (m_frameBoundaryCounter - it->second->lastUsedFrameBoundary > kRetireAgeBoundaries) {
it = m_cache.erase(it);
// Invalidate every VAO's state-pointer memo: the erased node's
// address may be reused by a future insert. Advance through the
// process-wide source so the value stays unique across factory
// instances (see the member comment). With no legacy arm the memos
// live in this factory and die with it, so a per-instance bump is
// enough - P2 D12.5.
++m_evictionEpoch;
} else {
++it;
}
}
}
VkFormat VertexInputStateFactory::ToVkVertexFormat(DataType type, Int size, Bool normalized, Bool isInteger,
Bool isBgra, Bool isLong) {
if (isBgra) {