From a12068df52df67fdf85b629bdb2ecd85417c0a79 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Thu, 30 Jul 2026 05:01:46 -0400 Subject: [PATCH] [Perf] (DirectVulkan): reuse pipelines across per-chunk buffers and skip redundant pipeline binds --- .../Renderer/VertexInputStateFactory.cpp | 16 ++++ .../Renderer/VertexInputStateFactory.h | 7 ++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 84 +++++++++++-------- .../DirectVulkan/Renderer/VulkanRenderer.h | 32 +++++-- 4 files changed, 98 insertions(+), 41 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index 6e865632..1da72b7b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -177,6 +177,22 @@ namespace MobileGL::MG_Backend::DirectVulkan { entry.lastUsedFrameBoundary = m_frameBoundaryCounter; 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))); + } + XXHASH_VERIFY(XXH64_update(m_hashState, &unsupportedAttribMask, sizeof(unsupportedAttribMask))); + entry.layoutHash = XXH64_digest(m_hashState); entry.bindingBufferKeys = std::move(bindingBufferKeys); entry.bindingBaseOffsets = std::move(bindingBaseOffsets); entry.bindingAttributeLocations = std::move(bindingAttributeLocations); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index 07525351..eb89d6d3 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -27,6 +27,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { struct BackendVertexInputState { HashType hash = 0; + // Hash of the resolved Vulkan vertex layout only (bindings, attributes, + // unsupported mask) - NO buffer identities. `hash` mixes buffer heap + // addresses so per-chunk VBOs mint a fresh identity per buffer; keying + // pipelines on that minted one VkPipeline per chunk section for an + // identical layout, defeating pipeline reuse and the per-draw memo. + // Pipelines depend only on the layout, so they key on this instead. + HashType layoutHash = 0; // Frame boundary of the last cache hit; entries idle past the // OnFrameBoundary retirement age are evicted (CPU heap only). Uint64 lastUsedFrameBoundary = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 6bb989f5..008c0234 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -226,6 +226,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // set (blit, depth-mipmap) binds - their static state makes the // corresponding dynamic values undefined per the spec. struct DynamicStateShadow { + // Last graphics pipeline bound on the frame command buffer. Pipeline + // binds are command-buffer state (they survive render-pass boundaries), + // so the same reset points that invalidate dynamic state - recording + // (re)begin and the aux blit pipelines' raw binds - are exactly the + // points where this becomes unknown. + Bool graphicsPipelineValid = false; + VkPipeline graphicsPipeline = VK_NULL_HANDLE; Bool viewportValid = false; VkViewport viewport{}; Bool scissorValid = false; @@ -3815,16 +3822,24 @@ void main() { // content hash (folds program identity + link version + transform flags + shader stages), // vertex-input hash (VAO layout), render-pass hash (render targets + the draw-buffer/format // driven blend & write-mask gating), and the render-state version (all fixed-function state). - // Reset per-frame and on pipeline destruction so m_lastPipelineResult can never dangle. + // Reset per-frame and on pipeline destruction so a memoized handle can never dangle. const Uint64 vertexInputHash = m_vertexInputStateFactory->GetOrComputeHash(vao); + // The identity hash mixes buffer heap addresses (per-chunk VBOs mint a new + // one per buffer); the memo and the pipeline payload key on the resolved + // LAYOUT hash instead, so draws over identical layouts share one pipeline. + auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao, vertexInputHash); + const Uint64 vertexLayoutHash = vis.layoutHash; const Uint64 renderPassHash = renderPassEntry.hash; const Uint renderStateVersion = MG_State::pGLContext->GetRenderStateParametersVersion(); - if (m_lastPipelineValid && m_lastPipelineResult != VK_NULL_HANDLE && m_lastPipelineMode == mode && - m_lastPipelineProgramHash == programObj.hash && m_lastPipelineVertexInputHash == vertexInputHash && - m_lastPipelineRenderPassHash == renderPassHash && - m_lastPipelineRenderStateVersion == renderStateVersion && - m_lastPipelineTransformFlags == transformFlags) { - return m_lastPipelineResult; + for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) { + const PipelineMemoEntry& entry = m_pipelineMemo[i]; + if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode && + entry.programHash == programObj.hash && entry.vertexInputHash == vertexLayoutHash && + entry.renderPassHash == renderPassHash && + entry.renderStateVersion == renderStateVersion && + entry.transformFlags == transformFlags) { + return entry.pipeline; + } } #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG @@ -3865,8 +3880,6 @@ void main() { } #endif - // vertexInputHash was computed above for the fast-path key; reuse it here. - auto& vis = m_vertexInputStateFactory->GetOrCreateVertexInputState(vao, vertexInputHash); const Uint32 vertexInputAttribMask = BuildVertexInputAttributeMask(vis.attributes); const Uint32 activeAttribMask = programObj.activeVertexInputLocationMask; const Uint32 missingAttribMask = activeAttribMask & ~vertexInputAttribMask; @@ -3977,7 +3990,7 @@ void main() { PipelineFactory::PipelineCreatePayload payload { .programHash = programObj.hash, - .vertexInputHash = vertexInputHash, + .vertexInputHash = vertexLayoutHash, .pipelineLayout = programObj.pipelineLayout, .renderPass = renderPassEntry.renderPass, .colorAttachmentCount = renderPassEntry.colorAttachmentCount, @@ -4284,14 +4297,16 @@ void main() { } VkPipeline pipeline = m_pipelineFactory->GetOrCreatePipeline(payload); if (pipeline != VK_NULL_HANDLE) { - m_lastPipelineValid = true; - m_lastPipelineMode = mode; - m_lastPipelineProgramHash = programObj.hash; - m_lastPipelineVertexInputHash = vertexInputHash; - m_lastPipelineRenderPassHash = renderPassHash; - m_lastPipelineRenderStateVersion = renderStateVersion; - m_lastPipelineTransformFlags = transformFlags; - m_lastPipelineResult = pipeline; + PipelineMemoEntry& entry = m_pipelineMemo[m_pipelineMemoNext]; + entry.mode = mode; + entry.programHash = programObj.hash; + entry.vertexInputHash = vertexLayoutHash; + entry.renderPassHash = renderPassHash; + entry.renderStateVersion = renderStateVersion; + entry.transformFlags = transformFlags; + entry.pipeline = pipeline; + m_pipelineMemoNext = (m_pipelineMemoNext + 1) % kPipelineMemoSize; + m_pipelineMemoCount = std::min(m_pipelineMemoCount + 1, kPipelineMemoSize); } return pipeline; } @@ -4629,7 +4644,11 @@ void main() { MOBILEGL_ASSERT(ok, "%s: BeginRenderPass failed", __func__); } - vkCmdBindPipeline(frame.commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + if (!g_dynamicStateShadow.graphicsPipelineValid || g_dynamicStateShadow.graphicsPipeline != pipeline) { + vkCmdBindPipeline(frame.commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + g_dynamicStateShadow.graphicsPipelineValid = true; + g_dynamicStateShadow.graphicsPipeline = pipeline; + } const Bool boundUniforms = m_uniformManager->BindProgramUniformBuffers( frame.commandBuffer, program, programObj, m_frameContext.GetCurrentFrameIndex()); @@ -6412,7 +6431,7 @@ void main() { if (frame.isCommandRecording) { m_frameContext.EndCommandRecording(); frame.hasCommandBufferRecorded = true; - m_lastPipelineValid = false; // command-buffer boundary: drop the pipeline memo + InvalidatePipelineMemo(); // command-buffer boundary: drop the pipeline memo } // The pre-pass stream must never be submitted later than the recording // it was paired with (frame commands recorded after a pre-pass move @@ -7499,8 +7518,7 @@ void main() { m_programFactory->OnFrameBoundary(); } if (m_pipelineFactory && m_pipelineFactory->OnFrameBoundary() > 0) { - m_lastPipelineValid = false; - m_lastPipelineResult = VK_NULL_HANDLE; + InvalidatePipelineMemo(); } if (m_vertexInputStateFactory) { m_vertexInputStateFactory->OnFrameBoundary(); @@ -7617,8 +7635,7 @@ void main() { // cache and the aging sweep could destroy it while the flushed submission // still references it. Mirrors the drops at the readback and Present // boundaries; costs one full pipeline lookup on the next draw. - m_lastPipelineValid = false; - m_lastPipelineResult = VK_NULL_HANDLE; + InvalidatePipelineMemo(); // The submitted command buffer may still be executing; recording must // restart on a fresh one. If none can be allocated, fall back to @@ -7771,7 +7788,7 @@ void main() { m_frameContext.AbandonPreCommandRecording(); suspendedFrame.isCommandRecording = false; suspendedFrame.hasCommandBufferRecorded = false; - m_lastPipelineValid = false; + InvalidatePipelineMemo(); // The dropped recording is never submitted, so once the fence // poll shows the pre-suspension submissions complete the frame // transients (descriptor sets, transient arenas, deferred @@ -7807,8 +7824,10 @@ void main() { // performs a real, stamping lookup) and can never age out. m_programFactory->OnFrameBoundary(); if (m_pipelineFactory->OnFrameBoundary() > 0) { - m_lastPipelineValid = false; // an aged-out pipeline may still be memoized - m_lastPipelineResult = VK_NULL_HANDLE; + InvalidatePipelineMemo(); // an aged-out pipeline may still be memoized + // A recreated pipeline could reuse a freed handle value and alias + // the bind-dedup shadow; force the next draw to re-bind. + g_dynamicStateShadow.graphicsPipelineValid = false; } m_vertexInputStateFactory->OnFrameBoundary(); m_samplerManager->OnFrameBoundary(); @@ -7831,7 +7850,7 @@ void main() { if (frame.isCommandRecording) { m_frameContext.EndCommandRecording(); frame.hasCommandBufferRecorded = true; - m_lastPipelineValid = false; // command-buffer boundary: drop the pipeline memo + InvalidatePipelineMemo(); // command-buffer boundary: drop the pipeline memo } m_frameContext.EndPreCommandRecordingIfOpen(); @@ -8834,7 +8853,8 @@ void main() { if (m_pipelineFactory) { m_pipelineFactory->DestroyAll(); } - m_lastPipelineValid = false; // pipelines freed -> the memoized handle would dangle + InvalidatePipelineMemo(); // pipelines freed -> the memoized handle would dangle + g_dynamicStateShadow.graphicsPipelineValid = false; DestroyComputePipelines(); if (m_frameContext.GetFrameCount() > 0) { m_frameContext.GetCurrent().isCommandRecording = false; @@ -8997,8 +9017,7 @@ void main() { // destroys them immediately. The memo must drop as well: it can hand out a // cached handle without touching the factory. if (m_pipelineFactory->EvictByRenderPasses(renderPasses) > 0) { - m_lastPipelineValid = false; - m_lastPipelineResult = VK_NULL_HANDLE; + InvalidatePipelineMemo(); } } @@ -9017,8 +9036,7 @@ void main() { m_computePipelines.erase(computeIt); } if (m_pipelineFactory != nullptr && m_pipelineFactory->EvictByProgramHash(programHash) > 0) { - m_lastPipelineValid = false; - m_lastPipelineResult = VK_NULL_HANDLE; + InvalidatePipelineMemo(); } if (m_uniformManager != nullptr) { m_uniformManager->OnDescriptorSetLayoutDestroyed(descriptorSetLayout); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 131a1ff3..08624824 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -455,14 +455,30 @@ namespace MobileGL::MG_Backend::DirectVulkan { // gather + synthetic vertex-input rebuild + payload hash + lookup) when the full pipeline // state is unchanged from the previous draw. The key provably covers every pipeline field. // Reset per-frame and on pipeline destruction so the cached handle can never dangle. - Bool m_lastPipelineValid = false; - GLenum m_lastPipelineMode = 0; - Uint64 m_lastPipelineProgramHash = 0; - Uint64 m_lastPipelineVertexInputHash = 0; - Uint64 m_lastPipelineRenderPassHash = 0; - Uint m_lastPipelineRenderStateVersion = 0; - ProgramFactory::CompileOptionFlags m_lastPipelineTransformFlags = {}; - VkPipeline m_lastPipelineResult = VK_NULL_HANDLE; + // Small N-way pipeline-resolution memo (round-robin replacement). A + // single-entry memo thrashed on draw sequences that alternate a few + // pipelines (GUI text/quad program ping-pong), paying the full + // payload-hash lookup per draw; eight entries cover such working sets + // while keeping the hit path a trivial linear scan. + struct PipelineMemoEntry { + GLenum mode = 0; + Uint64 programHash = 0; + Uint64 vertexInputHash = 0; + Uint64 renderPassHash = 0; + Uint renderStateVersion = 0; + ProgramFactory::CompileOptionFlags transformFlags = {}; + VkPipeline pipeline = VK_NULL_HANDLE; + }; + static constexpr Uint32 kPipelineMemoSize = 8; + PipelineMemoEntry m_pipelineMemo[kPipelineMemoSize]; + Uint32 m_pipelineMemoCount = 0; + Uint32 m_pipelineMemoNext = 0; + // Drops every memoized pipeline handle. Required at command-buffer + // boundaries and whenever any pipeline may have been destroyed. + void InvalidatePipelineMemo() { + m_pipelineMemoCount = 0; + m_pipelineMemoNext = 0; + } UnorderedMap m_computePipelines; UniquePtr m_programFactory; UniquePtr m_uniformManager;