[Perf] (DirectVulkan): reuse pipelines across per-chunk buffers and skip redundant pipeline binds

This commit is contained in:
2026-07-30 05:01:46 -04:00
parent 0b344792cc
commit a12068df52
4 changed files with 98 additions and 41 deletions
@@ -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);
@@ -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;
@@ -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);
@@ -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<ProgramFactory::HashType, VkPipeline> m_computePipelines;
UniquePtr<ProgramFactory> m_programFactory;
UniquePtr<UniformManager> m_uniformManager;