[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.
This commit is contained in:
BZLZHH
2026-08-06 11:21:26 -04:00
parent 9be5d95440
commit 4b3fd11462
4 changed files with 378 additions and 43 deletions
@@ -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<MG_State::GLState::ITextureObject*>& outTextures) {
Vector<MG_State::GLState::ITextureObject*>& outTextures,
Vector<SampledBindingRecord>* outBindingRecords) {
outTextures.clear();
if (outBindingRecords != nullptr) {
outBindingRecords->clear();
}
const Uint32 bindingCount =
std::min<Uint32>(m_maxBindings, static_cast<Uint32>(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<SampledBindingRecord>& previousRecords) const {
const Uint32 bindingCount =
std::min<Uint32>(m_maxBindings, static_cast<Uint32>(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(
@@ -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<MG_State::GLState::ITextureObject*>& outTextures);
Vector<MG_State::GLState::ITextureObject*>& outTextures,
Vector<SampledBindingRecord>* 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<SampledBindingRecord>& previousRecords) const;
Bool CollectStorageImageTextures(const MG_State::GLState::ProgramObject& program,
const ProgramFactory::VkProgramObject& programObj,
Vector<MG_State::GLState::ITextureObject*>& 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<MG_State::GLState::ITextureObject>& 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<MG_State::GLState::ITextureObject> 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<SamplerResolveMemo> m_samplerResolveMemo;
};
@@ -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<Uint64>(ctx.GetPatchVertices()));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(ctx.GetPolygonModeFront()));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(ctx.GetCullFaceMode()));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(ctx.GetDepthFunc()));
hash = CombinePipelineStateWord(hash, static_cast<Uint64>(ctx.GetLogicOp()));
for (const StencilFace face : {StencilFace::Front, StencilFace::Back}) {
const StencilFaceState& stencil = ctx.GetStencilState(face);
hash = CombinePipelineStateWord(hash,
static_cast<Uint64>(stencil.FailOp) |
(static_cast<Uint64>(stencil.PassDepthPassOp) << 16) |
(static_cast<Uint64>(stencil.PassDepthFailOp) << 32) |
(static_cast<Uint64>(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<Uint64>(srcRGB) << 8;
attachmentWord |= static_cast<Uint64>(dstRGB) << 16;
attachmentWord |= static_cast<Uint64>(srcAlpha) << 24;
attachmentWord |= static_cast<Uint64>(dstAlpha) << 32;
attachmentWord |= static_cast<Uint64>(colorEquation) << 40;
attachmentWord |= static_cast<Uint64>(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<DrawSetupAspect> 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;
@@ -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<ProgramFactory::HashType, VkPipeline> m_computePipelines;
UniquePtr<ProgramFactory> 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<MG_State::GLState::ITextureObject*> 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<UniformManager::SampledBindingRecord> 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<VkTextureManager::TextureResource*> 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<VkImageLayout> m_sampledLayoutSnapshots;
Vector<MG_State::GLState::ITextureObject*> m_storageImageTexturesScratch;
Vector<VkBuffer> m_vertexBuffersScratch;
Vector<VkDeviceSize> m_vertexOffsetsScratch;