[Perf] (MG_Backend): stop paying for descriptor slots and mip barriers nobody asked for

Five independent bits of per-draw and per-operation waste in the DirectVulkan
backend, all removing work whose answer was already known.

The per-draw descriptor walk iterated all 256 slots of bindingKinds to find the
one to eight bindings a real GL program declares, because that vector is sized to
the binding cap rather than to the program. Reflection now records the bindings it
actually assigned, and the draw path iterates that. It is built at the end of
ReflectLayout, not where bindingKinds is sized - at that point the vector is only
zero-initialised and the kinds are assigned further down, so a list built there
would be empty. It has to stay ascending: Vulkan consumes pDynamicOffsets in
binding order and the writer pushes them in iteration order, so an unordered list
would silently mis-pair dynamic offsets with their uniform blocks.

Descriptor pools were sized maxSets * the 256-binding cap, declaring 81,920
descriptors per pool and 245,760 across the frames in flight, for sets that hold
what shader reflection found. Sized from eight now; an outlier program is absorbed
by the VK_ERROR_OUT_OF_POOL_MEMORY path that already exists, which works because
pool sizes are aggregate budgets rather than per-set limits.

TrackLiveResource swept the whole live-buffer vector on every insert once it
passed 256 entries, and when the buffers are all live the sweep removes nothing
and the vector grows by one - so creating N live buffers cost about N^2/2
expired() checks. It sweeps on a doubling watermark now, with the same
reclamation semantics.

GenerateMipmap transitioned each destination level individually inside its loop,
but every generated level starts in the same layout and the loop only moves a
level out of TRANSFER_DST after writing it, so the whole range can be prepared in
one barrier - 3(N-1)+1 barrier commands become 2(N-1)+2. Each level is still
transitioned to TRANSFER_SRC before it is read, so the dependency between
consecutive levels is unchanged.

WaitForFrameSerial drained the entire graphics queue, as its own comment admitted.
Every submission records the frame serial it was made under, so it now waits on
the first fence at or past the requested serial. The narrow path deliberately does
not call NotifyDeviceIdle(): that claims every submission has retired, which is
only true after a real drain, so it stays on the fallback.

Verified with an 8213-case A/B (textures, buffers, queries, mipmaps, uniforms and
the whole direct_state_access suite): the Espryt failure list is identical, the
Magma failure list differs by one case, and both crash sets are unchanged on
Magma. That one case, buffer_storage.map_persistent_draw, does not reproduce in
isolation - running the buffer_storage group alone gives byte-identical results on
both builds (the same three failures, not including it), and it reports
NotSupported when run on its own. It is the same ordering-dependent behaviour this
suite shows elsewhere, and the three Espryt crash-set differences are the known
copy_image cluster moving chunk position. Flagging rather than hiding it.

direct_state_access stays at Espryt 370/371 and Magma 371/371; unit tests 421/421.
This commit is contained in:
BZLZHH
2026-08-05 15:19:37 -04:00
parent f3d52faad4
commit d39a706d57
6 changed files with 79 additions and 18 deletions
@@ -8285,15 +8285,23 @@ void main() {
resource->aspect, baseMipLevel, 1);
MOBILEGL_ASSERT(srcReady, "%s: failed to transition base mip level to transfer source", __func__);
for (Uint32 level = baseMipLevel + 1; level < generateMipLevelCount; ++level) {
VkImageLayout dstMipLayout = originalLayout;
Bool dstReady = VkTextureManager::TransitionImageLayout(
frame.commandBuffer, resource->image, dstMipLayout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
// Every generated level starts from originalLayout and ends up TRANSFER_DST_OPTIMAL, and
// the loop below only ever moves a level OUT of that layout after it has been written - so
// the whole range can be prepared in one barrier instead of one per level. That turns a
// 12-level chain's 3(N-1)+1 barrier commands into 2(N-1)+2. Each level is still
// individually transitioned to TRANSFER_SRC before it is read, so the write-then-read
// dependency between consecutive levels is unchanged.
if (generateMipLevelCount > baseMipLevel + 1) {
VkImageLayout dstRangeLayout = originalLayout;
const Bool dstRangeReady = VkTextureManager::TransitionImageLayout(
frame.commandBuffer, resource->image, dstRangeLayout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
originalSrcStageMask, VK_PIPELINE_STAGE_TRANSFER_BIT,
originalSrcAccessMask, VK_ACCESS_TRANSFER_WRITE_BIT,
resource->aspect, level, 1);
MOBILEGL_ASSERT(dstReady, "%s: failed to transition mip level %u to transfer destination", __func__, level);
resource->aspect, baseMipLevel + 1, generateMipLevelCount - (baseMipLevel + 1));
MOBILEGL_ASSERT(dstRangeReady, "%s: failed to transition mip levels to transfer destination", __func__);
}
for (Uint32 level = baseMipLevel + 1; level < generateMipLevelCount; ++level) {
const IntVec3 srcTexelSize = ComputeMipTexelSize(storageBaseTexelSize, level - 1);
const IntVec3 dstTexelSize = ComputeMipTexelSize(storageBaseTexelSize, level);
@@ -9085,10 +9093,26 @@ void main() {
if (m_device == VK_NULL_HANDLE || m_graphicsQueue == VK_NULL_HANDLE) {
return true;
}
// The serial was submitted but has not been observed complete. Frame
// fences are only waited on when their slot is reused, so the simplest
// safe wait is to drain the graphics queue; this over-waits (bounded
// by the in-flight frame count) but never deadlocks.
// Every submission is recorded with the frame serial it was made under, so the wait can be
// narrowed to the first submission at or past the requested serial instead of draining the
// whole queue. OnSubmitsCompletedUpTo calls NotifyFrameSerialComplete for every record it
// retires, so the completed-serial floor still advances correctly after one fence wait.
for (const auto& record : m_inFlightSubmits) {
if (record.frameSerial < serial || record.fence == VK_NULL_HANDLE) {
continue;
}
if (vkWaitForFences(m_device, 1, &record.fence, VK_TRUE, UINT64_MAX) != VK_SUCCESS) {
break; // fall through to the drain below
}
OnSubmitsCompletedUpTo(record.submitIndex);
// Deliberately no NotifyDeviceIdle() here: that claims every submission has retired,
// which is only true after a real queue drain. Work past this record may still run.
TryDrainFrameTransients();
return true;
}
// No usable record - fall back to draining the graphics queue. This over-waits (bounded by
// the in-flight frame count) but never deadlocks.
const VkResult result = vkQueueWaitIdle(m_graphicsQueue);
if (result != VK_SUCCESS) {
MGLOG_E("WaitForFrameSerial: vkQueueWaitIdle returned %d", result);