From 267211e8b4e257ca11a57255e690f857ac6e0e71 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 7 Oct 2026 10:07:42 -0400 Subject: [PATCH] [Perf] (Magma): wire draws stop rehashing program SPIR-V and stop allocating per draw (rd12 monolith 45.7 -> 56.7 fps). --- .../Renderer/MagmaProgramSource.h | 22 ++++++-- .../Renderer/VertexInputStateFactory.cpp | 3 +- .../Renderer/VertexInputStateFactory.h | 28 ++++++++++ .../DirectVulkan/Renderer/VulkanRenderer.h | 28 ++++++++++ .../DirectVulkan/Renderer/WireDraw.inc | 55 +++++++++++++------ MobileGL/MG_Pipe/PipeApply.h | 16 ++++++ 6 files changed, 130 insertions(+), 22 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaProgramSource.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaProgramSource.h index 79d94ac3..b70d43bf 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaProgramSource.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaProgramSource.h @@ -47,15 +47,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { const auto& stages = m_record->Archive->LinkedStages; return std::find(stages.begin(), stages.end(), static_cast(stage)) != stages.end(); } + // The view has no lifetime of its own, so the memo lives in the server record it views + // (MGPipeShaderCsoRecord::BackendHashMemo), never in the client's program object: an entry + // answers only while the record's Serial and BindingsSerial are the ones it was taken at. Bool GetBackendHashMemo(Uint flags, Uint64& hash) const { - // A view has no lifetime in which a memo can safely persist. The server - // factory's content cache still owns compiled programs; hash from current - // archive + binding tails rather than borrowing the client's mutable memo. - (void)flags; - (void)hash; + for (const auto& entry : m_record->BackendHashMemo) { + if (entry.Valid && entry.Key == flags && entry.Serial == m_record->Serial && + entry.BindingsSerial == m_record->BindingsSerial) { + hash = entry.Hash; + return true; + } + } return false; } void SetBackendHashMemo(Uint flags, Uint64 hash) const { + auto& memo = m_record->BackendHashMemo; + auto& entry = memo[m_record->BackendHashMemoNext++ % memo.size()]; + entry.Serial = m_record->Serial; + entry.BindingsSerial = m_record->BindingsSerial; + entry.Hash = hash; + entry.Key = flags; + entry.Valid = true; } Uint64 GetBackendStateVersion() const { return m_record->Serial; } Uint64 GetBlockBindingVersion() const { return m_record->BindingsSerial; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index 76efdb29..3320d6da 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -20,7 +20,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool VertexInputStateFactory::BuildWireVertexInput(const MG_Pipe::MGPipeVertexElementsRecord& elements, const MG_Pipe::MGPipeApplierState& state, Uint32 activeMask, BackendVertexInputState& out) const { - out = BackendVertexInputState{}; + // The caller's state is a per-draw scratch (WireDraw.inc): reset it without freeing. + out.ResetKeepingCapacity(); for (Uint32 location = 0; location < elements.AttributeCount; ++location) { const auto& attr = elements.Attributes[location]; if (!attr.Enabled || !(activeMask & (1u << location))) continue; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 311dced2..87ce495d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -74,6 +74,34 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkPipelineVertexInputStateCreateInfo state{ VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO }; + + // Back to a default-constructed state while keeping the vectors' storage, so a + // per-draw scratch rebuilds without touching the heap. Every member is reset by the + // default assignment; only the vectors' buffers are carried across it. + void ResetKeepingCapacity() { + const auto keep = [](auto& vector) { + auto storage = Move(vector); + storage.clear(); + return storage; + }; + auto keptBindings = keep(bindings); + auto keptAttributes = keep(attributes); + auto keptBufferKeys = keep(bindingBufferKeys); + auto keptBaseOffsets = keep(bindingBaseOffsets); + auto keptLocations = keep(bindingAttributeLocations); + auto keptClientMemory = keep(bindingUsesClientMemory); + auto keptConversions = keep(bindingConversions); + auto keptDivisors = keep(bindingDivisors); + *this = BackendVertexInputState{}; + bindings = Move(keptBindings); + attributes = Move(keptAttributes); + bindingBufferKeys = Move(keptBufferKeys); + bindingBaseOffsets = Move(keptBaseOffsets); + bindingAttributeLocations = Move(keptLocations); + bindingUsesClientMemory = Move(keptClientMemory); + bindingConversions = Move(keptConversions); + bindingDivisors = Move(keptDivisors); + } }; VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice): diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 809ab604..86fe84ac 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -785,6 +785,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 swapchainImageIndex = 0; Bool isDefault = false, framebufferSrgb = false; Bool operator==(const WireDrawPassKey&) const = default; + // Default values, vectors emptied but their storage kept (the per-draw scratch). + void ResetKeepingCapacity() { + auto keptAttachments = Move(attachments); + auto keptColorReferences = Move(colorReferences); + *this = WireDrawPassKey{}; + keptAttachments.clear(); + keptColorReferences.clear(); + attachments = Move(keptAttachments); + colorReferences = Move(keptColorReferences); + } }; struct WireDrawPassCacheEntry { WireDrawPassKey key; @@ -799,6 +809,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { Vector> m_wireDrawPassCaches; WireDrawPassKey m_wireDrawPassKey; WireRenderPassCompatibilityTable m_wireRenderPassCompatibility; + // The interned compatibility id of m_wireDrawPass's attachments, or 0 when not known yet. + // A draw that continues the pass (its WireDrawPassKey compares equal, which covers every + // input of the compatibility key) reuses it instead of building and hashing the key again. + Uint64 m_wireDrawPassCompatibilityId = 0; + // P14: SetupWireDraw's per-draw containers, cleared and refilled every draw instead of + // allocated and freed (the heap was ~10% of a Minecraft frame on the wire arm). A nested + // SetupWireDraw - nothing does that today - would use a local set (WireDraw.inc). + struct WireDrawScratch { + VertexInputStateFactory::BackendVertexInputState input; + Vector vertexSlices; + Vector attachments; + Vector viewInfos; + Vector colors; + Vector sameLayoutAttachments; + WireDrawPassKey passKey; + WireRenderPassCompatibilityKey compatibilityKey; + }; + WireDrawScratch m_wireDrawScratch; struct WireRetiredObjects { Uint64 submitIndex = 0; UniquePtr drawPass; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc index 72f7ebad..da7741de 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc @@ -104,6 +104,7 @@ VkRenderPassManager::EndRenderPass(frame.commandBuffer); } m_wireDrawPass.reset(); + m_wireDrawPassCompatibilityId = 0; for (const auto view : m_wireDrawViews) vkDestroyImageView(m_device, view, nullptr); m_wireDrawViews.clear(); m_wireDrawPassKey = {}; @@ -117,6 +118,7 @@ } void VulkanRenderer::RetireWireDrawPass() { + m_wireDrawPassCompatibilityId = 0; if (!m_wireDrawPass) return; auto& frame = m_frameContext.GetCurrent(); if (frame.isCommandRecording && VkRenderPassManager::GetActiveRenderPass()) @@ -201,6 +203,11 @@ Bool VulkanRenderer::SetupWireDraw(FrameContext::FrameData& frame, GLenum mode, Flags aspects, const DrawCmdParam& drawParams, const IndexBufferView* indices) { auto& state = MG_Pipe::MGPipeApplier(); + // The per-draw containers (VulkanRenderer.h's WireDrawScratch). Nothing re-enters this + // function from inside it today; if something ever does, it gets its own set rather + // than clearing the outer draw's. + WireDrawScratch nestedScratch; + WireDrawScratch& scratch = m_wirePreparationDepth == 0 ? m_wireDrawScratch : nestedScratch; const auto program = WireProgramSource(state.BoundShaderCso); if (!program.GetLinkStatus() || !program.GetSpirvStatus()) { MGL_WIRE_DECLINE_AT(ProgramLinkOrSpirv, "program is not linked or carries no SPIR-V"); @@ -265,7 +272,7 @@ if (vaoHandle.Slot >= state.VertexElementsCsos.size()) MagmaWireFatal("vertex-elements-record"); const auto& vao = state.VertexElementsCsos[vaoHandle.Slot]; if (!vao.Live || vao.Gen != vaoHandle.Gen) MagmaWireFatal("vertex-elements-generation"); - VertexInputStateFactory::BackendVertexInputState input{}; + auto& input = scratch.input; // P7 wave 2 package C, CONTRACT-P7 ยง3.2: THE `vertex-layout` ROW IS RETIRED AND SPLIT. // // A false is now ONE reason and only one - `buffer-window`, an enabled attribute the @@ -301,7 +308,8 @@ WireDeclineTally::Count(WireDeclineSite::VertexFormatUnsupported); return false; } - Vector vertexSlices(input.bindings.size()); + auto& vertexSlices = scratch.vertexSlices; + vertexSlices.assign(input.bindings.size(), BufferSlice{}); for (SizeT binding = 0; binding < input.bindings.size(); ++binding) { const Uint32 location = input.bindingAttributeLocations[binding]; const auto& attr = vao.Attributes[location]; @@ -474,15 +482,19 @@ return false; } - Vector attachments; - Vector viewInfos; - Vector colors; + auto& attachments = scratch.attachments; + auto& viewInfos = scratch.viewInfos; + auto& colors = scratch.colors; + attachments.clear(); + viewInfos.clear(); + colors.clear(); VkAttachmentReference depth{VK_ATTACHMENT_UNUSED, VK_IMAGE_LAYOUT_GENERAL}; VkExtent2D extent{~0u, ~0u}; Uint32 layers = ~0u; VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT; Uint64 passHash = 0; - WireDrawPassKey passKey{}; + auto& passKey = scratch.passKey; + passKey.ResetKeepingCapacity(); passKey.isDefault = fbo->IsDefault; passKey.framebufferSrgb = MG_Pipe::gPipeInputs.IsCapabilityEnabled(CapabilityInput::FramebufferSrgb); if (fbo->IsDefault) { @@ -492,7 +504,8 @@ // attachments below resolve the same index, so this key has to be taken after it. passKey.swapchainImageIndex = DefaultFramebufferWriteIndex(true); } - Vector sameLayoutAttachments; + auto& sameLayoutAttachments = scratch.sameLayoutAttachments; + sameLayoutAttachments.clear(); const auto add = [&](const MG_Pipe::MGPSurface& surface, VkImageAspectFlags aspect) -> Uint32 { WireImage image = ResolveWireImage(*fbo, surface, aspect, /*isWriteTarget=*/true); if (!image.image) return VK_ATTACHMENT_UNUSED; @@ -580,9 +593,13 @@ if (!stillOpen) for (auto& image : sameLayoutAttachments) TransitionWireImage(image, VK_IMAGE_LAYOUT_GENERAL); } - if (!continuing) RetireWireDrawPass(); + if (!continuing) { + RetireWireDrawPass(); + m_wireDrawPassCompatibilityId = 0; + } if (!continuing && !AcquireCachedWireDrawPass(passKey)) { - m_wireDrawPassKey = Move(passKey); + // A copy, not a move: passKey is the scratch's, and a new pass is the rare case. + m_wireDrawPassKey = passKey; Vector views; views.reserve(viewInfos.size()); for (const auto& info : viewInfos) { @@ -609,13 +626,19 @@ created.hash = created.compatibilityHash = XXH64(colors.data(), colors.size() * sizeof(colors[0]), passHash); } auto& pass = *m_wireDrawPass; - WireRenderPassCompatibilityKey pipelinePassKey{}; - for (const auto& attachment : attachments) - pipelinePassKey.attachmentFormatsAndSamples.push_back( - (static_cast(attachment.format) << 32) | static_cast(attachment.samples)); - for (const auto& color : colors) pipelinePassKey.colorReferences.push_back(color.attachment); - pipelinePassKey.depthReference = depth.attachment; - const Uint64 compatibilityId = m_wireRenderPassCompatibility.Intern(pipelinePassKey); + // Interned once per pass: a continued pass has the same key, so the same compatibility. + if (m_wireDrawPassCompatibilityId == 0) { + auto& pipelinePassKey = scratch.compatibilityKey; + pipelinePassKey.attachmentFormatsAndSamples.clear(); + pipelinePassKey.colorReferences.clear(); + for (const auto& attachment : attachments) + pipelinePassKey.attachmentFormatsAndSamples.push_back( + (static_cast(attachment.format) << 32) | static_cast(attachment.samples)); + for (const auto& color : colors) pipelinePassKey.colorReferences.push_back(color.attachment); + pipelinePassKey.depthReference = depth.attachment; + m_wireDrawPassCompatibilityId = m_wireRenderPassCompatibility.Intern(pipelinePassKey); + } + const Uint64 compatibilityId = m_wireDrawPassCompatibilityId; const auto pipeline = GetOrCreatePipelineWithInput(mode, program, programObj, flags, input, pass, ResolvePrimitiveRestartEnable(aspects, indices), compatibilityId); if (pipeline == VK_NULL_HANDLE) { diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h index 04013810..06d5b749 100644 --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -475,6 +475,22 @@ namespace MobileGL::MG_Pipe { // so it obeys VertexBuffersSerial's rule: ADVANCED, never returned to a value it has // already handed out. Uint64 BindingsSerial = 0; + + // P14: the backend program factory's content hash of THIS record's program, one entry per + // compile variant (the factory's own memo key). The hash reads the archive's SPIR-V, the + // block-binding tail and the variant, so an entry is valid exactly while Serial (a relink) + // and BindingsSerial (set_program_bindings) still hold the values it was taken at. It + // lives in the record so it dies with it: a recycled slot starts from a fresh record and + // no other record can ever answer from it. Nothing in MG_Pipe reads it. + struct BackendHashMemoEntry { + Uint64 Serial = 0; + Uint64 BindingsSerial = 0; + Uint64 Hash = 0; + Uint32 Key = 0; + Bool Valid = false; + }; + mutable Array BackendHashMemo{}; + mutable Uint32 BackendHashMemoNext = 0; }; // The vertex-elements CSO as the applier holds it: the unpacked blob, both views, plus