[Perf] (MG_Backend): merge DirectVulkan's contiguous sub-draws, remember four programs

61% of mc_sodium_multidraw's steady-state CPU sat inside the driver
encoding one vkCmdDrawIndexed per sub-draw. MultiDrawElements now
collapses contiguous runs: merge only when the topology is a list
(POINTS/LINES/TRIANGLES), the accumulated count sits on a primitive
boundary, primitive restart is off, baseVertex/instanceCount/
firstInstance are identical and firstIndex is adjacent, with a
count-overflow guard - the bench's 132x32 sub-draws become 132x1.
Dangling-index discard semantics for list topologies are what the GL
spec already mandates per draw. No new Vulkan feature, so no DriverPost
gate; VK_EXT_multi_draw stays a gated follow-up.

The draw fast path's single SetupDraw snapshot died on every program
ping-pong (use_program's A/B pattern sent every other draw down the
full path, CollectSampledTextures alone 6.2% self). A 4-entry
program-keyed snapshot table (MRU by program lifetime id, per-entry
sampled-set copies, per-entry invalidation on decline or full-path
start, all entries still cleared at command-buffer boundary, pipeline
age-out and swapchain recreate) keeps all cycling programs hot.

Load-gated 6-round order-alternating A/B, sha1-fingerprinted pair:
sodium_multidraw -41.3%, use_program -23.9%, pass_switch -12.7%,
tex_param -3.8%, vanilla -2.1%; the one flag (tex_stream +5.4%)
reversed to -0.5% across 10 isolated alternating pairs. Espryt
untouched and unmoved. Unit tests 421/421.
This commit is contained in:
BZLZHH
2026-08-06 20:17:19 -04:00
parent cdea275227
commit 72ee7c439c
2 changed files with 182 additions and 35 deletions
@@ -5127,10 +5127,34 @@ void main() {
Bool VulkanRenderer::TrySetupDrawFastPath(FrameContext::FrameData& frame, GLenum mode,
Flags<DrawSetupAspect> aspects, const DrawCmdParam& drawParams,
const IndexBufferView* pIndexBufferView) {
SetupDrawSnapshot& snap = m_setupDrawSnapshot;
if (!snap.valid || !frame.isCommandRecording) {
if (!frame.isCommandRecording) {
return false;
}
// Entry select: by the draw program's lifetime id, MRU first (the id pins
// the entry; every other fact is re-guarded below, so probing a stale
// entry can only decline, never serve stale state).
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
const Uint64 programLifetimeId = program.GetLifetimeId();
SetupDrawSnapshot* snapPtr = nullptr;
{
SetupDrawSnapshot& mru = m_setupDrawSnapshots[m_setupDrawSnapshotMru];
if (mru.valid && mru.programLifetimeId == programLifetimeId) {
snapPtr = &mru;
} else {
for (Uint32 i = 0; i < kSetupDrawSnapshotCount; ++i) {
SetupDrawSnapshot& candidate = m_setupDrawSnapshots[i];
if (candidate.valid && candidate.programLifetimeId == programLifetimeId) {
snapPtr = &candidate;
m_setupDrawSnapshotMru = i;
break;
}
}
}
}
if (snapPtr == nullptr) {
return false;
}
SetupDrawSnapshot& snap = *snapPtr;
if (snap.aspects != aspects.GetRaw() || snap.mode != mode) {
return false;
}
@@ -5142,9 +5166,7 @@ void main() {
snap.imageIndex != m_imageIndexAcquired) {
return false;
}
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
if (program.GetLifetimeId() != snap.programLifetimeId ||
program.GetBackendStateVersion() != snap.programVersion) {
if (program.GetBackendStateVersion() != snap.programVersion) {
return false;
}
// A changed VAO does NOT decline: the VAO only feeds the pipeline's vertex
@@ -5282,7 +5304,7 @@ void main() {
}
}
if (bindsMoved &&
!m_uniformManager->SampledBindingsUnchanged(program, programObj, m_sampledBindingRecordsScratch)) {
!m_uniformManager->SampledBindingsUnchanged(program, programObj, snap.sampledBindingRecords)) {
return false;
}
@@ -5291,8 +5313,11 @@ void main() {
// sampleable, then stamp recording use exactly as the full path would.
// A feedback case (sampled texture written by the active pass) fails the
// layout check and falls back to the full path's end-pass handling.
const auto& sampledTextures = m_sampledTexturesScratch;
const auto& sampledResources = m_sampledResourcesScratch;
// The ENTRY's copies, not the scratch vectors: with more than one entry
// the scratch holds only the last full-path draw's set, which may belong
// to a different program.
const auto& sampledTextures = snap.sampledTextures;
const auto& sampledResources = snap.sampledResources;
if (sampledResources.size() != sampledTextures.size()) {
return false;
}
@@ -5306,7 +5331,7 @@ void main() {
// 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();
const Bool layoutSnapshotUsable = snap.sampledLayouts.size() == sampledTextures.size();
Bool samplerDescriptorsUnchanged = layoutSnapshotUsable;
for (SizeT i = 0; i < sampledTextures.size(); ++i) {
const auto* sampledTexture = sampledTextures[i];
@@ -5317,8 +5342,8 @@ void main() {
if (resource == nullptr || !IsValidSampledImageLayout(resource->layout)) {
return false;
}
if (layoutSnapshotUsable && m_sampledLayoutSnapshots[i] != resource->layout) {
m_sampledLayoutSnapshots[i] = resource->layout;
if (layoutSnapshotUsable && snap.sampledLayouts[i] != resource->layout) {
snap.sampledLayouts[i] = resource->layout;
samplerDescriptorsUnchanged = false;
}
contentSum += sampledTexture->GetContentVersion();
@@ -5437,17 +5462,51 @@ void main() {
if (TrySetupDrawFastPath(frame, mode, aspects, drawParams, pIndexBufferView)) {
return true;
}
// The fast path declined: whatever it saw may be stale. The full path
// below re-resolves everything and refreshes the snapshot on success.
m_setupDrawSnapshot.valid = false;
const auto& drawFbo =
MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
if (drawFbo != nullptr && IsUnsupportedFramebufferForDirectVulkan(*drawFbo)) {
// Nothing was mutated: other entries' per-probe guards (FBO identity +
// version among them) stay authoritative, so none need invalidating.
RecordUnsupportedFramebufferError(__func__);
return false;
}
const auto& vao = *MG_State::pGLContext->GetBoundVertexArray();
const auto& program = *MG_State::pGLContext->GetProgramForDraw();
// The fast path declined (or had no entry for this program): whatever THIS
// program's entry saw may be stale, and the full path below mutates state as
// it goes, so the entry must not stay matchable if that path fails mid-way.
// Select it now - the program's own entry when one exists, else an invalid
// slot, else a round-robin victim - and invalidate it until the successful
// refill at the end. Other programs' entries keep their validity: every fact
// they carry is re-guarded per probe (live pass hash, epochs, versions,
// sums), so a full path run in between can only make them decline.
SetupDrawSnapshot* fillSnap = nullptr;
{
Uint32 fillIndex = kSetupDrawSnapshotCount;
const Uint64 fillProgramLifetimeId = program.GetLifetimeId();
for (Uint32 i = 0; i < kSetupDrawSnapshotCount; ++i) {
if (m_setupDrawSnapshots[i].valid &&
m_setupDrawSnapshots[i].programLifetimeId == fillProgramLifetimeId) {
fillIndex = i;
break;
}
}
if (fillIndex == kSetupDrawSnapshotCount) {
for (Uint32 i = 0; i < kSetupDrawSnapshotCount; ++i) {
if (!m_setupDrawSnapshots[i].valid) {
fillIndex = i;
break;
}
}
}
if (fillIndex == kSetupDrawSnapshotCount) {
fillIndex = m_setupDrawSnapshotVictim;
m_setupDrawSnapshotVictim = (m_setupDrawSnapshotVictim + 1) % kSetupDrawSnapshotCount;
}
fillSnap = &m_setupDrawSnapshots[fillIndex];
fillSnap->valid = false;
m_setupDrawSnapshotMru = fillIndex;
}
ProgramFactory::CompileOptionFlags transformFlags =
ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw());
// Captured draws take the xfb-decorated program variant.
@@ -5773,7 +5832,7 @@ void main() {
// Snapshot the fully resolved configuration for the consecutive-draw
// fast path (see TrySetupDrawFastPath).
{
auto& snap = m_setupDrawSnapshot;
auto& snap = *fillSnap;
const auto* nowActiveRenderPass = VkRenderPassManager::GetActiveRenderPass();
if (nowActiveRenderPass != nullptr && !programObj.hasStorageImages) {
snap.valid = true;
@@ -5815,10 +5874,15 @@ void main() {
snap.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();
Uint64 snapContentSum = 0;
Uint64 snapParamsSum = 0;
// Per-entry copies of this draw's sampled set (the scratch vectors
// will be overwritten by the next full-path draw of ANY program).
// 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);
snap.sampledTextures = sampledTextures;
snap.sampledResources = sampledResources;
snap.sampledBindingRecords = m_sampledBindingRecordsScratch;
snap.sampledLayouts.assign(sampledTextures.size(), VK_IMAGE_LAYOUT_UNDEFINED);
for (SizeT i = 0; i < sampledTextures.size(); ++i) {
const auto* sampledTexture = sampledTextures[i];
if (sampledTexture == nullptr) {
@@ -5827,7 +5891,7 @@ void main() {
snapContentSum += sampledTexture->GetContentVersion();
snapParamsSum += sampledTexture->GetTextureParamsVersion();
if (sampledResources[i] != nullptr) {
m_sampledLayoutSnapshots[i] = sampledResources[i]->layout;
snap.sampledLayouts[i] = sampledResources[i]->layout;
}
}
snap.sampledContentSum = snapContentSum;
@@ -9439,13 +9503,75 @@ void main() {
VkCommandBuffer& commandBuffer = frame.commandBuffer;
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
vkCmdDrawIndexed(commandBuffer,
payload.pParams[idraw].indexCount,
payload.pParams[idraw].instanceCount,
payload.pParams[idraw].firstIndex,
payload.pParams[idraw].vertexOffset,
payload.pParams[idraw].firstInstance);
// Collapse contiguous sub-draw runs into one vkCmdDrawIndexed. Per-sub-draw
// command emission in the driver dominates a Sodium-shaped multi-draw
// (steady-state profile: >60% of the case inside the Vulkan driver's
// vkCmdDrawIndexed encoding for 132x32 sub-draws/frame), and a chunk
// renderer's sub-draws are runs of adjacent index ranges over one buffer.
// Two draws are one iff they concatenate to an identical index stream:
// - a LIST topology (points/lines/triangles). Strips/fans/loops would
// weld primitives across the seam.
// - the accumulated count ends on a primitive boundary, otherwise GL
// discards the dangling indices at the sub-draw's end but the merged
// stream would assemble them with the next sub-draw's indices.
// - primitive restart is off: with restart on, a sentinel mid-stream
// resets assembly, so a partial primitive before the seam would
// otherwise be discarded per sub-draw (same dangling-index argument).
// - identical baseVertex/instancing and firstIndex adjacency, so the
// merged range fetches exactly the two sub-draws' indices in order.
Uint32 mergeGranularity = 0;
switch (payload.mode) {
case GL_POINTS: mergeGranularity = 1; break;
case GL_LINES: mergeGranularity = 2; break;
case GL_TRIANGLES: mergeGranularity = 3; break;
default: break;
}
if (mergeGranularity != 0) {
const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters();
if (rsp.PrimitiveRestartEnabled || rsp.PrimitiveRestartFixedIndexEnabled) {
mergeGranularity = 0;
}
}
if (mergeGranularity == 0) {
for (Uint32 idraw = 0; idraw < payload.drawCount; ++idraw) {
vkCmdDrawIndexed(commandBuffer,
payload.pParams[idraw].indexCount,
payload.pParams[idraw].instanceCount,
payload.pParams[idraw].firstIndex,
payload.pParams[idraw].vertexOffset,
payload.pParams[idraw].firstInstance);
}
return;
}
Uint32 idraw = 0;
while (idraw < payload.drawCount) {
const DrawIndexedCmdParam& head = payload.pParams[idraw];
++idraw;
if (head.indexCount == 0) {
continue; // draws nothing, contributes nothing to a run
}
Uint32 mergedIndexCount = head.indexCount;
if (head.instanceCount == 1) {
while (idraw < payload.drawCount) {
const DrawIndexedCmdParam& next = payload.pParams[idraw];
if (next.indexCount == 0) {
++idraw;
continue;
}
if (mergedIndexCount % mergeGranularity != 0 ||
next.instanceCount != 1 ||
next.vertexOffset != head.vertexOffset ||
next.firstInstance != head.firstInstance ||
next.firstIndex != head.firstIndex + mergedIndexCount ||
mergedIndexCount + next.indexCount < mergedIndexCount) {
break;
}
mergedIndexCount += next.indexCount;
++idraw;
}
}
vkCmdDrawIndexed(commandBuffer, mergedIndexCount, head.instanceCount, head.firstIndex,
head.vertexOffset, head.firstInstance);
}
}
@@ -10105,7 +10231,7 @@ void main() {
void VulkanRenderer::OnFrameCommandRecordingBegan(VkCommandBuffer commandBuffer) {
// Dynamic state does not survive a command-buffer boundary.
ResetDynamicStateShadow();
m_setupDrawSnapshot.valid = false;
InvalidateSetupDrawSnapshots();
if (m_uniformManager) {
m_uniformManager->OnCommandBufferBoundary();
}
@@ -10271,7 +10397,7 @@ void main() {
// 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_setupDrawSnapshot.valid = false;
InvalidateSetupDrawSnapshots();
}
m_vertexInputStateFactory->OnFrameBoundary();
m_samplerManager->OnFrameBoundary();
@@ -11653,7 +11779,7 @@ void main() {
}
InvalidatePipelineMemo(); // pipelines freed -> the memoized handle would dangle
g_dynamicStateShadow.graphicsPipelineValid = false;
m_setupDrawSnapshot.valid = false;
InvalidateSetupDrawSnapshots();
DestroyComputePipelines();
if (m_frameContext.GetFrameCount() > 0) {
m_frameContext.GetCurrent().isCommandRecording = false;
@@ -765,8 +765,36 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// it bypasses GetOrCreateProgram, or the idle sweep could evict a live entry.
const ProgramFactory::VkProgramObject* programObj = nullptr;
Uint64 programFactoryEpoch = 0;
// Per-entry copies of the snapshotting draw's sampled set (the scratch
// vectors below hold only the LAST full-path draw's set, which with more
// than one snapshot entry is not necessarily this entry's program).
// sampledTextures/sampledResources carry the same epoch-guarded pointer
// lifetime rules as the scratch originals: textureEraseEpoch (checked
// every probe) declines the entry before any erased resource pointer
// could be dereferenced. sampledLayouts is the layout VALUE each
// resource held when this entry's descriptors were built (the
// descriptor-reuse hint needs the SAME layout, not just a sampleable
// one), and sampledBindingRecords feeds SampledBindingsUnchanged when
// the bind generation moved.
Vector<MG_State::GLState::ITextureObject*> sampledTextures;
Vector<VkTextureManager::TextureResource*> sampledResources;
Vector<VkImageLayout> sampledLayouts;
Vector<UniformManager::SampledBindingRecord> sampledBindingRecords;
};
SetupDrawSnapshot m_setupDrawSnapshot;
// Program-keyed snapshot entries: program ping-pong (Sodium switches programs
// mid-frame every few draws) would otherwise evict the single snapshot on
// every switch and send every draw through the full path. Entries are found
// by programLifetimeId (MRU-first probe); every other guard stays per-probe,
// so a stale entry declines itself exactly like the old single snapshot did.
static constexpr Uint32 kSetupDrawSnapshotCount = 4;
SetupDrawSnapshot m_setupDrawSnapshots[kSetupDrawSnapshotCount];
Uint32 m_setupDrawSnapshotMru = 0; // last entry that hit or was filled
Uint32 m_setupDrawSnapshotVictim = 0; // round-robin fill cursor when all entries are live
void InvalidateSetupDrawSnapshots() {
for (auto& snapshot : m_setupDrawSnapshots) {
snapshot.valid = false;
}
}
// Per-draw scratch buffers (clear keeps capacity) — these paths run for every
// draw call and must not allocate.
@@ -782,13 +810,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// 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;