From 4b3fd11462ac17a123bf027a2baded2791ac5f3e Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Thu, 6 Aug 2026 11:21:26 -0400 Subject: [PATCH] [Perf] (MG_Backend): key DirectVulkan's pipeline memo on state values, not a version that never repeats Two per-draw churn costs, one cause each. A blend toggle switched pipelines through a memo keyed on a monotonic pipeline-state version - which never repeats, so flipping GL_BLEND off and back on produced a "new" key both times, forced the full SetupDraw and rebuilt the whole pipeline payload for a pipeline the cache already held. The memo now keys on a value hash of the pipeline-relevant fixed-function state, recomputed only when the state version moved, and the consecutive-draw fast path re-resolves just the pipeline through it when nothing but render state changed. Blaze3D brackets every batch with exactly this toggle; mc_state_toggle drops 36% (6629 -> 4230 ns/op, 4.8x native to 3.7x). The sampler-churn cost had the same shape as the Espryt side fixed separately: glBindSampler bumps the frontend texture-bind generation even when it re-binds the sampler the unit already holds, so the per-draw fast path died every draw. The fast path now proves each binding's descriptor inputs unchanged - texture and sampler lifetime ids, parameter and content sums, the sampling-resolution generation, image epochs and exact layouts - and reuses the binding's cached VkDescriptorImageInfo instead of re-running the resolve chain. mc_sampler_churn drops 30% (1597 -> 1125), and the proof machinery pays for itself on the uniform-range case too (-17%). mc_tex_param stays where it is on this backend deliberately: profiling shows its remaining cost is frontend validation with zero backend work, unreachable from Renderer/. All nine cases measured on both backends, interleaved A/B, no case worse than noise. Unit tests 421/421. --- .../DirectVulkan/Renderer/UniformManager.cpp | 121 +++++++++-- .../DirectVulkan/Renderer/UniformManager.h | 50 ++++- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 201 ++++++++++++++++-- .../DirectVulkan/Renderer/VulkanRenderer.h | 49 ++++- 4 files changed, 378 insertions(+), 43 deletions(-) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index 8f68dead..827c244f 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -212,6 +212,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // reuse cannot outlive a single frame (see SamplerResolveMemo). for (auto& memo : m_samplerResolveMemo) { memo.valid = false; + memo.infoValid = false; } } @@ -254,9 +255,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool UniformManager::ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Uint32 binding, VkDescriptorImageInfo& outImageInfo) const { + Uint32 binding, VkDescriptorImageInfo& outImageInfo, + Bool trustUnchangedHint) const { MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveSamplerDescriptor: texture manager is null"); MOBILEGL_ASSERT(m_samplerManager != nullptr, "ResolveSamplerDescriptor: sampler manager is null"); + // The caller proved every input of this binding's resolution unchanged since the + // last full resolve (which also filled the cache), so the whole chain below - + // texture/sampler resolution, completeness probe, sync, layout handling, sampler + // and view lookups - would recompute the identical descriptor. + if (trustUnchangedHint && binding < m_samplerResolveMemo.size() && + m_samplerResolveMemo[binding].infoValid) { + outImageInfo = m_samplerResolveMemo[binding].info; + return true; + } MOBILEGL_ASSERT(binding < programObj.samplerNameByBinding.size(), "ResolveSamplerDescriptor: sampler binding %u name lookup out of range", binding); // Raw-pointer resolve to skip the SharedPtr atomic refcount churn: the bound texture stays @@ -430,7 +441,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { .imageView = sampledImageView, .imageLayout = resource->layout, }; - return outImageInfo.sampler != VK_NULL_HANDLE; + if (outImageInfo.sampler == VK_NULL_HANDLE) { + return false; + } + if (binding < m_samplerResolveMemo.size()) { + m_samplerResolveMemo[binding].info = outImageInfo; + m_samplerResolveMemo[binding].infoValid = true; + } + return true; } Bool UniformManager::ResolveSamplerDescriptorOverride( @@ -808,10 +826,55 @@ namespace MobileGL::MG_Backend::DirectVulkan { return m_fallbackTexture2D; } + Bool UniformManager::ResolveSampledBinding(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, + Uint32 binding, + MG_State::GLState::ITextureObject*& outTexture, + const MG_State::GLState::SamplerObject*& outSampler) const { + // Open-coded ResolveSamplerTextureRaw so the unit is resolved once for both the + // texture and the sampler override - this runs per binding per full-path draw, + // and program-alternating draw streams take the full path on every draw. + MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSampledBinding: GL context is null"); + MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), + "ResolveSampledBinding: sampler location binding %u out of range", binding); + MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), + "ResolveSampledBinding: sampler target binding %u out of range", binding); + const Int location = programObj.samplerUniformLocationByBinding[binding]; + const Int unit = ResolveSamplerUnitIndex(program, location, binding); + auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; + MG_State::GLState::ITextureObject* texture = + textureUnit.GetBindingSlot(preferredTarget).GetBoundObject().get(); + // Undefined default texture (name 0, no image) resolves as "unbound", exactly + // like ResolveSamplerTextureRaw reports it. + if (MG_State::GLState::IsUndefinedDefaultTexture(texture)) { + texture = nullptr; + } + if (texture == nullptr) { + // ResolveSamplerDescriptor will substitute the fallback texture for this binding; + // include it in the sampled set so the pre-render-pass sync/transition pass covers + // its first use instead of leaving that work to happen inside an active pass. + if (preferredTarget != TextureTarget::Texture2D && + preferredTarget != TextureTarget::TextureRectangle) { + return false; + } + texture = GetFallbackTexture(preferredTarget).get(); + } + const auto& samplerOverride = textureUnit.GetSamplerObject(); + outTexture = texture; + outSampler = samplerOverride ? samplerOverride.get() + : (texture != nullptr ? texture->GetSamplerObject().get() : nullptr); + return true; + } + Bool UniformManager::CollectSampledTextures(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Vector& outTextures) { + Vector& outTextures, + Vector* outBindingRecords) { outTextures.clear(); + if (outBindingRecords != nullptr) { + outBindingRecords->clear(); + } const Uint32 bindingCount = std::min(m_maxBindings, static_cast(programObj.bindingKinds.size())); @@ -820,17 +883,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { continue; } - MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding); - if (!texture) { - // ResolveSamplerDescriptor will substitute the fallback texture for this binding; - // include it in the sampled set so the pre-render-pass sync/transition pass covers - // its first use instead of leaving that work to happen inside an active pass. - const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; - if (preferredTarget != TextureTarget::Texture2D && - preferredTarget != TextureTarget::TextureRectangle) { - continue; - } - texture = GetFallbackTexture(preferredTarget).get(); + MG_State::GLState::ITextureObject* texture = nullptr; + const MG_State::GLState::SamplerObject* sampler = nullptr; + if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) { + continue; + } + if (outBindingRecords != nullptr) { + outBindingRecords->push_back({texture != nullptr ? texture->GetLifetimeId() : 0, + sampler != nullptr ? sampler->GetLifetimeId() : 0}); } auto found = std::find(outTextures.begin(), outTextures.end(), texture); @@ -841,6 +901,33 @@ namespace MobileGL::MG_Backend::DirectVulkan { return true; } + Bool UniformManager::SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, + const Vector& previousRecords) const { + const Uint32 bindingCount = + std::min(m_maxBindings, static_cast(programObj.bindingKinds.size())); + SizeT recordIndex = 0; + for (Uint32 binding = 0; binding < bindingCount; ++binding) { + if (programObj.bindingKinds[binding] != ProgramFactory::DescriptorBindingKind::CombinedImageSampler) { + continue; + } + MG_State::GLState::ITextureObject* texture = nullptr; + const MG_State::GLState::SamplerObject* sampler = nullptr; + if (!ResolveSampledBinding(program, programObj, binding, texture, sampler)) { + continue; + } + if (recordIndex >= previousRecords.size()) { + return false; + } + const SampledBindingRecord& record = previousRecords[recordIndex++]; + if (record.textureLifetimeId != (texture != nullptr ? texture->GetLifetimeId() : 0) || + record.samplerLifetimeId != (sampler != nullptr ? sampler->GetLifetimeId() : 0)) { + return false; + } + } + return recordIndex == previousRecords.size(); + } + Bool UniformManager::CollectStorageImageTextures( const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, @@ -1160,7 +1247,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { const ProgramFactory::VkProgramObject& programObj, Uint32 frameIndex, VkPipelineBindPoint bindPoint, - const SamplerBindingOverride* samplerBindingOverride) { + const SamplerBindingOverride* samplerBindingOverride, + Bool samplerDescriptorsUnchangedHint) { auto& frame = m_frames[frameIndex]; if (frame.descriptorPools.empty()) { MGLOG_E("UniformDescriptorBinder::BindProgramUniformBuffers failed: frame descriptor pools are invalid"); @@ -1341,7 +1429,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { samplerBindingOverride->sampler != nullptr) { hasImage = ResolveSamplerDescriptorOverride(*samplerBindingOverride, imageInfo); } else { - hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, imageInfo); + hasImage = ResolveSamplerDescriptor(commandBuffer, program, programObj, binding, imageInfo, + samplerDescriptorsUnchangedHint); } if (!hasImage) { MGLOG_E( diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h index e049ab56..d11b444f 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.h @@ -53,18 +53,42 @@ namespace MobileGL::MG_Backend::DirectVulkan { // caches - a live layout's entry must never be purged (its sets would be // unreachable pool slots), so there is deliberately no age-based sweep here. void OnDescriptorSetLayoutDestroyed(VkDescriptorSetLayout descriptorSetLayout); + // One record per visited CombinedImageSampler binding (post fallback substitution, + // in binding order): the resolved texture and effective sampler, as never-reused + // lifetime ids so a freed-and-reallocated object at the same heap address can only + // MISS a comparison, never false-hit it (same ABA rule as SamplerResolveMemo). + struct SampledBindingRecord { + Uint64 textureLifetimeId = 0; + Uint64 samplerLifetimeId = 0; + }; Bool CollectSampledTextures(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, - Vector& outTextures); + Vector& outTextures, + Vector* outBindingRecords = nullptr); + // Shadow-compare for the SetupDraw fast path: re-runs the CollectSampledTextures + // walk and reports whether every visited binding still resolves to the recorded + // (texture, effective sampler) pair. A texture bind generation bump alone (e.g. a + // redundant glBindSampler, which always bumps it) does not prove the sampled set + // moved; this walk does, without rebuilding the set or falling off the fast path. + Bool SampledBindingsUnchanged(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, + const Vector& previousRecords) const; Bool CollectStorageImageTextures(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Vector& outTextures) const; + // samplerDescriptorsUnchangedHint: the caller (SetupDraw fast path) proved that + // every input of every combined-image-sampler resolution is unchanged since the + // previous draw's resolve - same (texture, sampler) per binding, texture params + // sum, sampling-resolution generation (sampler params + texture shape), image + // epochs AND per-resource layout values - so the per-binding cached + // VkDescriptorImageInfo may be reused without re-running the resolve chain. Bool BindProgramUniformBuffers(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 frameIndex, VkPipelineBindPoint bindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, - const SamplerBindingOverride* samplerBindingOverride = nullptr); + const SamplerBindingOverride* samplerBindingOverride = nullptr, + Bool samplerDescriptorsUnchangedHint = false); // Pure format-policy helper kept public for host regression tests. Formatted storage // images use their shader qualifier; transformed float images use glBindImageTexture's @@ -114,6 +138,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { static Bool ResolveSamplerTexture(const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, SharedPtr& outTexture); + // Shared per-binding resolution for CollectSampledTextures and + // SampledBindingsUnchanged, so membership and comparison can never diverge: + // texture after the fallback substitution (may still be null when no fallback + // exists), effective sampler = unit override else the texture's own sampler. + // False = the binding is skipped (unbound with a non-2D fallback target). + Bool ResolveSampledBinding(const MG_State::GLState::ProgramObject& program, + const ProgramFactory::VkProgramObject& programObj, Uint32 binding, + MG_State::GLState::ITextureObject*& outTexture, + const MG_State::GLState::SamplerObject*& outSampler) const; // Raw-pointer variant for the per-draw sampled-texture walk (CollectSampledTextures): // the bound texture stays alive through the draw via GL binding state, so callers that // only need the pointer skip the SharedPtr copy's atomic refcount churn. @@ -121,9 +154,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding); SharedPtr GetFallbackTexture(TextureTarget target) const; + // trustUnchangedHint: reuse this binding's cached VkDescriptorImageInfo outright + // (see BindProgramUniformBuffers' samplerDescriptorsUnchangedHint for the proof + // obligations the caller carries). Bool ResolveSamplerDescriptor(VkCommandBuffer commandBuffer, const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, - VkDescriptorImageInfo& outImageInfo) const; + VkDescriptorImageInfo& outImageInfo, + Bool trustUnchangedHint = false) const; Bool ResolveSamplerDescriptorOverride(const SamplerBindingOverride& samplerBindingOverride, VkDescriptorImageInfo& outImageInfo) const; Bool ResolveTexelBufferDescriptor(const MG_State::GLState::ProgramObject& program, @@ -253,6 +290,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { SamplerNumericDomain viewFormatDomain = SamplerNumericDomain::Unknown; VkFormat viewFormat = VK_FORMAT_UNDEFINED; Bool viewFormatValid = false; + // Whole resolved descriptor from this binding's last full resolve. Reused + // ONLY under ResolveSamplerDescriptor's trustUnchangedHint, whose caller + // proves every resolve input unchanged; cleared with the per-frame reset + // (the cached VkSampler outlives a frame only via a fresh resolve, which + // also re-stamps it against VkSamplerManager's frame-boundary sweep). + VkDescriptorImageInfo info{}; + Bool infoValid = false; }; mutable Vector m_samplerResolveMemo; }; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index ab30b842..b86e626f 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -4164,6 +4164,77 @@ void main() { return true; } + // Boost-style hash combine. The inputs are tiny enum ordinals and bit masks, so + // full avalanche is unnecessary; the combine only has to keep distinct state + // vectors apart under the memo's otherwise-exact key. + static inline Uint64 CombinePipelineStateWord(Uint64 hash, Uint64 word) { + return hash ^ (word + 0x9E3779B97F4A7C15ull + (hash << 6) + (hash >> 2)); + } + + // Value hash over every fixed-function GL state the pipeline payload reads that + // the memo key's other fields (mode, program hash, vertex-input hash, render-pass + // hash, transform flags) do not already pin down. Enumerated against the payload + // build in GetOrCreatePipeline - any new GL-state read there must be added here: + // - capability bits: CullFace, DepthTest, PolygonOffsetFill (mode gating rides + // the memo's mode key), RasterizerDiscard, ColorLogicOp, StencilTest, + // PrimitiveRestart(+FixedIndex), plus the depth write mask + // - patch vertices, polygon mode, cull face mode, depth func, logic op + // - front/back stencil ops + compare funcs (ref/mask are dynamic state) + // - per draw buffer up to the render pass's colour span: indexed blend enable, + // blend factors/equations, indexed colour write mask (broadcast from index 0 + // when the device lacks independentBlend - the same read the payload does) + // FBO-derived payload inputs (attachment presence/formats/draw-buffer gating) are + // pinned by the render-pass hash key, exactly as the version-keyed memo relied on. + Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount) const { + auto& ctx = *MG_State::pGLContext; + Uint64 capabilityBits = 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::CullFace) ? 1ull << 0 : 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::DepthTest) ? 1ull << 1 : 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::PolygonOffsetFill) ? 1ull << 2 : 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::RasterizerDiscard) ? 1ull << 3 : 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::ColorLogicOp) ? 1ull << 4 : 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::StencilTest) ? 1ull << 5 : 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) ? 1ull << 6 : 0; + capabilityBits |= ctx.IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex) ? 1ull << 7 : 0; + capabilityBits |= ctx.GetDepthMask() ? 1ull << 8 : 0; + Uint64 hash = CombinePipelineStateWord(0x243F6A8885A308D3ull, capabilityBits); + hash = CombinePipelineStateWord(hash, static_cast(ctx.GetPatchVertices())); + hash = CombinePipelineStateWord(hash, static_cast(ctx.GetPolygonModeFront())); + hash = CombinePipelineStateWord(hash, static_cast(ctx.GetCullFaceMode())); + hash = CombinePipelineStateWord(hash, static_cast(ctx.GetDepthFunc())); + hash = CombinePipelineStateWord(hash, static_cast(ctx.GetLogicOp())); + for (const StencilFace face : {StencilFace::Front, StencilFace::Back}) { + const StencilFaceState& stencil = ctx.GetStencilState(face); + hash = CombinePipelineStateWord(hash, + static_cast(stencil.FailOp) | + (static_cast(stencil.PassDepthPassOp) << 16) | + (static_cast(stencil.PassDepthFailOp) << 32) | + (static_cast(stencil.Func) << 48)); + } + for (Uint32 i = 0; i < colorAttachmentCount; ++i) { + BlendFactor srcRGB = BlendFactor::One; + BlendFactor dstRGB = BlendFactor::Zero; + BlendFactor srcAlpha = BlendFactor::One; + BlendFactor dstAlpha = BlendFactor::Zero; + BlendEquation colorEquation = BlendEquation::Add; + BlendEquation alphaEquation = BlendEquation::Add; + ctx.GetBlendFuncIndexed(i, srcRGB, dstRGB, srcAlpha, dstAlpha); + ctx.GetBlendEquationIndexed(i, colorEquation, alphaEquation); + const BoolVec4 mask = ctx.GetColorMaskIndexed(m_independentBlendFeatureEnabled ? i : 0); + Uint64 attachmentWord = ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i) ? 1ull : 0; + attachmentWord |= (mask.r() ? 1ull << 1 : 0) | (mask.g() ? 1ull << 2 : 0) | + (mask.b() ? 1ull << 3 : 0) | (mask.a() ? 1ull << 4 : 0); + attachmentWord |= static_cast(srcRGB) << 8; + attachmentWord |= static_cast(dstRGB) << 16; + attachmentWord |= static_cast(srcAlpha) << 24; + attachmentWord |= static_cast(dstAlpha) << 32; + attachmentWord |= static_cast(colorEquation) << 40; + attachmentWord |= static_cast(alphaEquation) << 48; + hash = CombinePipelineStateWord(hash, attachmentWord); + } + return hash; + } + // A program that runs a geometry shader AND captures transform feedback. Both halves are // link-time properties, so this is safe to fold into a pipeline keyed on the program hash. static Bool ProgramCapturesXfbFromGeometryStage(const MG_State::GLState::ProgramObject& program) { @@ -4192,7 +4263,7 @@ void main() { // PipelineCreatePayload field: draw mode (topology + polygon-fill depth-bias gate), program // 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). + // driven blend & write-mask gating), and the pipeline-state value hash (all fixed-function state). // Reset per-frame and on pipeline destruction so a memoized handle can never dangle. // 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 @@ -4203,14 +4274,25 @@ void main() { const Uint64 renderPassHash = renderPassEntry.hash; // The pipeline-relevant subset only: glViewport / glScissor / glBlendColor / glStencilMask // and friends are dynamic state or not pipeline state at all, and keying the memo on the - // all-state counter made any of them evict a perfectly good VkPipeline. + // all-state counter made any of them evict a perfectly good VkPipeline. The memo compares + // the VALUE hash of that subset, never the version itself: the version is monotonic, so + // per-draw state flips (GL_BLEND toggles) would otherwise miss entries the memo holds. + // The version only guards recomputing the hash - unchanged version, unchanged bytes. const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); + if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || + m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount) { + m_pipelineStateHash = ComputePipelineStateHash(renderPassEntry.colorAttachmentCount); + m_pipelineStateHashVersion = renderStateVersion; + m_pipelineStateHashColorCount = renderPassEntry.colorAttachmentCount; + m_pipelineStateHashValid = true; + } + const Uint64 pipelineStateHash = m_pipelineStateHash; 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.pipelineStateHash == pipelineStateHash && entry.transformFlags == transformFlags) { return entry.pipeline; } @@ -4704,7 +4786,7 @@ void main() { entry.programHash = programObj.hash; entry.vertexInputHash = vertexLayoutHash; entry.renderPassHash = renderPassHash; - entry.renderStateVersion = renderStateVersion; + entry.pipelineStateHash = pipelineStateHash; entry.transformFlags = transformFlags; entry.pipeline = pipeline; m_pipelineMemoNext = (m_pipelineMemoNext + 1) % kPipelineMemoSize; @@ -4814,7 +4896,7 @@ void main() { Bool VulkanRenderer::TrySetupDrawFastPath(FrameContext::FrameData& frame, GLenum mode, Flags aspects, const DrawCmdParam& drawParams, const IndexBufferView* pIndexBufferView) { - const SetupDrawSnapshot& snap = m_setupDrawSnapshot; + SetupDrawSnapshot& snap = m_setupDrawSnapshot; if (!snap.valid || !frame.isCommandRecording) { return false; } @@ -4844,9 +4926,25 @@ void main() { drawFbo->GetObjectVersion() != snap.fboVersion) { return false; } - if (MG_State::pGLContext->GetPipelineStateVersion() != snap.renderStateVersion || - MG_State::pGLContext->GetTextureBindGeneration() != snap.bindGeneration) { - return false; + // The two monotonic counters get a shadow-compare rescue instead of an + // unconditional decline: both bump on state writes whose VALUE often lands + // back on what the snapshot already describes (a GL_BLEND toggle between + // two draws, a redundant glBindSampler), and declining here sends every + // such draw through the full SetupDraw. + const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); + const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Bool renderStateMoved = renderStateVersion != snap.renderStateVersion; + const Bool bindsMoved = bindGeneration != snap.bindGeneration; + if (renderStateMoved) { + // Only the pipeline depends on the moved state - except the render-pass + // flavor input (depth/stencil participation); a flip of that must take + // the full path's pass selection. + const Bool drawUsesDepthStencil = + MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest) || + MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::StencilTest); + if (drawUsesDepthStencil != snap.drawUsesDepthStencil) { + return false; + } } if (GetShaderTransformFlags(m_swapchainObject.GetPreTransform()).GetRaw() != snap.baseTransformFlags) { return false; @@ -4856,6 +4954,12 @@ void main() { m_renderPassManager->GetRenderbufferImageEpoch() != snap.renderbufferImageEpoch) { return false; } + const auto& programObj = m_programFactory->GetOrCreateProgram( + program, ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags)); + if (bindsMoved && + !m_uniformManager->SampledBindingsUnchanged(program, programObj, m_sampledBindingRecordsScratch)) { + return false; + } // Same sampled set as the snapshotting draw (program/bind keys above); // verify content and params are untouched and every layout is still @@ -4869,6 +4973,16 @@ void main() { } Uint64 contentSum = 0; Uint64 paramsSum = 0; + // The descriptor-reuse hint (see BindProgramUniformBuffers) additionally needs + // every sampled resource still in the exact layout the cached descriptors hold. + // A layout that moved to a different-but-sampleable one only clears the hint + // (this draw re-resolves and re-caches) - the fast path itself stays valid. + // bindsMoved does not clear the hint: reaching this point with a moved bind + // generation means SampledBindingsUnchanged proved the per-binding (texture, + // sampler) pairs identical, and the sums/generation checks below cover every + // remaining descriptor input. + const Bool layoutSnapshotUsable = m_sampledLayoutSnapshots.size() == sampledTextures.size(); + Bool samplerDescriptorsUnchanged = layoutSnapshotUsable; for (SizeT i = 0; i < sampledTextures.size(); ++i) { const auto* sampledTexture = sampledTextures[i]; if (sampledTexture == nullptr) { @@ -4878,30 +4992,64 @@ void main() { if (resource == nullptr || !IsValidSampledImageLayout(resource->layout)) { return false; } + if (layoutSnapshotUsable && m_sampledLayoutSnapshots[i] != resource->layout) { + m_sampledLayoutSnapshots[i] = resource->layout; + samplerDescriptorsUnchanged = false; + } contentSum += sampledTexture->GetContentVersion(); paramsSum += sampledTexture->GetTextureParamsVersion(); } if (contentSum != snap.sampledContentSum || paramsSum != snap.sampledParamsSum) { return false; } + const Uint64 samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + if (samplingResolutionGeneration != snap.samplingResolutionGeneration) { + snap.samplingResolutionGeneration = samplingResolutionGeneration; + samplerDescriptorsUnchanged = false; + } for (SizeT i = 0; i < sampledTextures.size(); ++i) { if (sampledTextures[i] != nullptr && sampledResources[i] != nullptr) { m_textureManager->StampResourceRecordingUse(*sampledResources[i]); } } - // Everything the full path would re-resolve is provably unchanged; run + // Everything the full path would re-resolve is provably unchanged - or, for + // a moved pipeline-state version, reduces to re-resolving just the pipeline + // through the value-keyed memo against the still-active render pass. Run // only the per-draw tail. + VkPipeline pipeline = snap.pipeline; + if (renderStateMoved) { + // Same lookup the full path would do; every input (FBO + version, image + // index, depth/stencil participation, image epochs, no pending clears) + // was verified unchanged above, so this is a pure cache hit on the same + // entry the snapshot's pipeline was built against. + const RenderPassEntry& renderPassEntry = m_renderPassManager->GetOrCreateRenderPass( + *drawFbo, m_imageIndexAcquired, snap.drawUsesDepthStencil); + if (!activeRenderPass->CompatibleWith(renderPassEntry)) { + return false; + } + pipeline = GetOrCreatePipeline(mode, program, programObj, + ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags), + vao, renderPassEntry); + if (pipeline == VK_NULL_HANDLE) { + return false; + } + } + // Every decline is behind us: the snapshot again describes the current + // counters, so the next draw's compare is two integer loads. + snap.renderStateVersion = renderStateVersion; + snap.bindGeneration = bindGeneration; + snap.pipeline = pipeline; if (!g_dynamicStateShadow.graphicsPipelineValid || - g_dynamicStateShadow.graphicsPipeline != snap.pipeline) { - vkCmdBindPipeline(frame.commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, snap.pipeline); + g_dynamicStateShadow.graphicsPipeline != pipeline) { + vkCmdBindPipeline(frame.commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); g_dynamicStateShadow.graphicsPipelineValid = true; - g_dynamicStateShadow.graphicsPipeline = snap.pipeline; + g_dynamicStateShadow.graphicsPipeline = pipeline; } - const auto& programObj = m_programFactory->GetOrCreateProgram( - program, ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags)); if (!m_uniformManager->BindProgramUniformBuffers(frame.commandBuffer, program, programObj, - m_frameContext.GetCurrentFrameIndex())) { + m_frameContext.GetCurrentFrameIndex(), + VK_PIPELINE_BIND_POINT_GRAPHICS, nullptr, + samplerDescriptorsUnchanged)) { return false; } if (!UploadAndBindVertexBuffers(frame.commandBuffer, vao, programObj, drawParams, pIndexBufferView)) { @@ -5043,8 +5191,8 @@ void main() { m_lastSampledSetTransformFlags == transformFlags && m_lastSampledSetBindGeneration == bindGeneration; if (!sampledSetUnchanged) { - const Bool hasSampledTextures = - m_uniformManager->CollectSampledTextures(program, programObj, sampledTextures); + const Bool hasSampledTextures = m_uniformManager->CollectSampledTextures( + program, programObj, sampledTextures, &m_sampledBindingRecordsScratch); MOBILEGL_ASSERT(hasSampledTextures, "%s: CollectSampledTextures failed", __func__); m_lastSampledSetValid = true; m_lastSampledSetProgramLifetimeId = programLifetimeId; @@ -5317,14 +5465,25 @@ void main() { snap.textureEraseEpoch = m_textureManager->GetResourceEraseEpoch(); snap.textureImageEpoch = m_textureManager->GetTextureImageEpoch(); snap.renderbufferImageEpoch = m_renderPassManager->GetRenderbufferImageEpoch(); + snap.drawUsesDepthStencil = drawUsesDepthStencil; snap.renderPassExtent = renderPassEntry->extent; snap.pipeline = pipeline; + snap.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); Uint64 snapContentSum = 0; Uint64 snapParamsSum = 0; - for (const auto* sampledTexture : sampledTextures) { - if (sampledTexture != nullptr) { - snapContentSum += sampledTexture->GetContentVersion(); - snapParamsSum += sampledTexture->GetTextureParamsVersion(); + // Record each resource's layout VALUE for the descriptor-reuse hint; + // transitions above updated the resources in place, so this reads the + // layouts the descriptors just resolved against. + m_sampledLayoutSnapshots.assign(sampledTextures.size(), VK_IMAGE_LAYOUT_UNDEFINED); + for (SizeT i = 0; i < sampledTextures.size(); ++i) { + const auto* sampledTexture = sampledTextures[i]; + if (sampledTexture == nullptr) { + continue; + } + snapContentSum += sampledTexture->GetContentVersion(); + snapParamsSum += sampledTexture->GetTextureParamsVersion(); + if (sampledResources[i] != nullptr) { + m_sampledLayoutSnapshots[i] = sampledResources[i]->layout; } } snap.sampledContentSum = snapContentSum; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index 5887db7a..ef6ab28a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -531,7 +531,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Per-pipeline provoking-vertex mode. capturesXfbFromGeometryStage must be a LINK-TIME // property of the program, never the dynamic "is transform feedback active" flag: the // 8-entry m_pipelineMemo and the SetupDrawSnapshot fast path key on programObj.hash and - // GetRenderStateParametersVersion(), neither of which moves when glBeginTransformFeedback is + // the pipeline-state value hash, neither of which moves when glBeginTransformFeedback is // called, so a dynamic input here would hand back a stale VkPipeline. VkProvokingVertexModeEXT SelectProvokingVertexMode(VkPrimitiveTopology topology, Bool capturesXfbFromGeometryStage) const; @@ -623,7 +623,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 programHash = 0; Uint64 vertexInputHash = 0; Uint64 renderPassHash = 0; - Uint renderStateVersion = 0; + // VALUE hash of the pipeline-relevant fixed-function state (see + // ComputePipelineStateHash), not the monotonic pipeline-state version: + // the version never repeats, so a per-draw GL_BLEND toggle would miss + // all entries forever even though the state alternates between two + // values the memo already holds. + Uint64 pipelineStateHash = 0; ProgramFactory::CompileOptionFlags transformFlags = {}; VkPipeline pipeline = VK_NULL_HANDLE; }; @@ -631,11 +636,26 @@ namespace MobileGL::MG_Backend::DirectVulkan { PipelineMemoEntry m_pipelineMemo[kPipelineMemoSize]; Uint32 m_pipelineMemoCount = 0; Uint32 m_pipelineMemoNext = 0; + // Hash of every fixed-function GL state the pipeline payload reads that the + // memo key's other fields (mode / program / vertex input / render pass / + // transform flags) do not already pin down. Equal hash under an equal rest + // of key => byte-identical PipelineCreatePayload. Cached per pipeline-state + // version: the version is monotonic and bumps on every pipeline-state + // change, so an unchanged (version, colorAttachmentCount) proves the state + // bytes are unchanged and the hash can be reused without re-reading them. + Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount) const; + Uint m_pipelineStateHashVersion = 0; + Uint32 m_pipelineStateHashColorCount = 0; + Uint64 m_pipelineStateHash = 0; + Bool m_pipelineStateHashValid = false; // Drops every memoized pipeline handle. Required at command-buffer - // boundaries and whenever any pipeline may have been destroyed. + // boundaries and whenever any pipeline may have been destroyed. Also drops + // the cached pipeline-state hash: the same boundaries can retire the GL + // context whose monotonic version the cache is keyed on. void InvalidatePipelineMemo() { m_pipelineMemoCount = 0; m_pipelineMemoNext = 0; + m_pipelineStateHashValid = false; } UnorderedMap m_computePipelines; UniquePtr m_programFactory; @@ -707,6 +727,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 renderbufferImageEpoch = 0; Uint64 sampledContentSum = 0; Uint64 sampledParamsSum = 0; + // Guards the sampler-descriptor reuse hint: bumped by any sampler-object + // parameter or texture shape change (see GetSamplingResolutionGeneration), + // none of which the sums above cover. + Uint64 samplingResolutionGeneration = 0; + // Render-pass flavor input (DepthTest || StencilTest at snapshot time). + // A pipeline-state change that leaves this equal cannot change which + // render pass GetOrCreateRenderPass would pick, so the fast path may + // re-resolve just the pipeline against the active pass; a change that + // flips it must fall back to the full path's pass selection. + Bool drawUsesDepthStencil = false; IntVec2 renderPassExtent = {0, 0}; VkPipeline pipeline = VK_NULL_HANDLE; }; @@ -715,11 +745,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Per-draw scratch buffers (clear keeps capacity) — these paths run for every // draw call and must not allocate. Vector m_sampledTexturesScratch; + // Per-binding (texture, effective sampler) lifetime-id records from the same + // CollectSampledTextures walk that filled m_sampledTexturesScratch. The fast + // path shadow-compares against them (SampledBindingsUnchanged) when the + // texture bind generation moved, so a redundant glBindSampler/glBindTexture + // storm that resolves to the same bindings keeps the fast path. + Vector m_sampledBindingRecordsScratch; // Parallel to m_sampledTexturesScratch, refilled by every SetupDraw's // first sampled-texture loop: the resolved backend resources, so the // post-transition loop can skip re-resolving textures whose layout is // already sampleable. Vector m_sampledResourcesScratch; + // Layout VALUE of each sampled resource when the snapshot (and so the cached + // sampler descriptors) was built, parallel to m_sampledResourcesScratch. The + // fast path's validity check only proves the layout is still sampleable; the + // descriptor-reuse hint additionally needs it to be the SAME sampleable + // layout (a mid-frame compute dispatch can move a sampled texture from + // READ_ONLY_OPTIMAL to GENERAL, both valid, different descriptor). + Vector m_sampledLayoutSnapshots; Vector m_storageImageTexturesScratch; Vector m_vertexBuffersScratch; Vector m_vertexOffsetsScratch;