[perf][MEDIUM] Add DispatchFused (additive) to collapse consecutive UI passes to one load/store #47

Closed
opened 2026-06-16 16:51:31 +02:00 by jorijnvdgraaf · 0 comments

Subsystem: UI compute pipeline + GLSL shaders
Location: shaders/ui-quads.comp.glsl:18-50 (same in circles/images/text); new fused kernel + UIRenderer::DispatchFused
Impact: MEDIUM · Effort: Large
Related: #46 (tile culling — co-design), #48 (barrier scoping — non-breaking partial win)

Problem

Each of the four UI shaders does one imageLoad + one imageStore of the swapchain storage image. A frame that runs N consecutive standard passes loads+stores every covered pixel N times (2N image transactions/pixel) plus N-1 inter-pass VkMemoryBarriers that drain compute and flush the whole image. For the common quads+text frame that is 4 image transactions/pixel + 1 full barrier where 2 transactions + 0 barriers would do.

Why the original "fuse all four passes into one dispatch" was rejected

It removed the generic ui.Dispatch(customShader, ...) interleave point and the per-pass read-modify-write semantics that a real downstream consumer (3DForts) depends on. Verified: Forts3D-MainMenu.cpp:744-906 interleaves custom compute (blockRevealShader, circleWipeShader, textFadeShader) between standard passes, into outImage, with load-bearing ordering ("drawn AFTER the dark-gray wipe shader so it sits on top"); Forts3D-Bloom.cpp:560 composites into OutImageSlot() the same way. Both consume the frozen 48-byte UIDispatchHeader, FillHeader, OutImageSlot, FontAtlas*Slot. A blind single-pass merge breaks all of this.

Proposed fix — additive DispatchFused

Add one new entry point alongside the existing per-element Dispatch* (which stay, unchanged):

DispatchFused(cmd,
  {quadsSlot,  quadsCount},
  {circlesSlot, circlesCount},
  {imagesSlot, imagesCount},
  {textSlot, textCount,  fontSlots},
  clipRectPx[...])   // per-category clip if needed

backed by one uber-kernel that loads dst once, composites quads→circles→images→text in canonical order in a register, and stores once — eliminating the redundant load/store and the inter-pass barriers for that fused run.

The interleave capability is preserved explicitly via DispatchFused → ui.Dispatch(custom) → DispatchFused. No hidden flush state; the app declares its fusible batches. 3DForts MainMenu becomes: DispatchFused(early) → custom reveal/wipe → DispatchFused(late).

This is additive and does NOT touch the frozen contract: per-element Dispatch* and the 48-byte UIDispatchHeader are untouched. DispatchFused gets its own new push-constant layout (4 sub-headers + font slots).

Cost model (why one uber-kernel, NOT per-combination kernels)

  • An absent category is a zero-trip, push-constant-uniform loop (~3 instructions, no divergence, no memory traffic) — using DispatchFused for only quads+text is not wasted; the unused circle/image loops are ~free.
  • The win materializes at >=2 fused types (saves 1 load + 1 store + 1 barrier per extra type). A single-type screen is marginally worse than the dedicated DispatchQuads (dead checks) — so keep the per-element calls for single-type / fine-grained use.
  • Do NOT specialize per combination (DispatchFusedQuadText, etc. = 15 kernels for 2^4-1 subsets). The categories composite sequentially, so per-loop temporaries don't overlap and the compiler reuses registers across phases — the uber-kernel's VGPR high-water mark is ~max(per-category), not sum. Specialization recovers <1% (dead checks + binary size) at the cost of 15 pipelines + API bloat + maintenance. Not worth it.

Caveats to measure / watch

  1. Occupancy / register pressure of the uber-kernel — the one thing to profile before committing. Unlikely to bite on a bandwidth-bound shader (stalled on image/SSBO memory, not VGPRs), but verify.
  2. Escalation path IF profiling shows it is VGPR/occupancy-limited: keep one shader source, add VkSpecializationInfo booleans (HAS_QUADS/...) and lazily create pipelines keyed by the present-mask, so only the ~3-4 combinations that actually occur compile. NOT hand-written variants. Measure-first only.
  3. Per-category clip rectsDispatchFused carries one clip per category in its header; slightly larger PC.
  4. WebGPU imagesDispatchImages binds one texture per dispatch on WebGPU (Forts3D-OptionsMenu.cpp:574). Vulkan bindless reads texture slots from item data trivially; the WebGPU images path may need to fall back to per-element or be handled specially. Vulkan-first.
  5. Co-design with #46 — tile culling also restructures the inner item loop; design the fused kernel and per-tile binning together, not stacked.

Irreducible limit

Fusion across a custom ui.Dispatch() is impossible (a dispatch boundary is a memory round-trip — registers don't survive it). MainMenu's interleaved passes still pay a store/load + barrier around each custom dispatch. That is physics, not API design, and is no longer something this issue tries to remove.

**Subsystem:** UI compute pipeline + GLSL shaders **Location:** `shaders/ui-quads.comp.glsl:18-50` (same in circles/images/text); new fused kernel + `UIRenderer::DispatchFused` **Impact:** MEDIUM · **Effort:** Large **Related:** #46 (tile culling — co-design), #48 (barrier scoping — non-breaking partial win) ### Problem Each of the four UI shaders does one `imageLoad` + one `imageStore` of the swapchain storage image. A frame that runs N consecutive standard passes loads+stores every covered pixel N times (2N image transactions/pixel) plus N-1 inter-pass `VkMemoryBarrier`s that drain compute and flush the whole image. For the common quads+text frame that is 4 image transactions/pixel + 1 full barrier where 2 transactions + 0 barriers would do. ### Why the original "fuse all four passes into one dispatch" was rejected It removed the generic `ui.Dispatch(customShader, ...)` interleave point and the per-pass read-modify-write semantics that a real downstream consumer (3DForts) depends on. Verified: `Forts3D-MainMenu.cpp:744-906` interleaves custom compute (`blockRevealShader`, `circleWipeShader`, `textFadeShader`) *between* standard passes, into `outImage`, with load-bearing ordering ("drawn AFTER the dark-gray wipe shader so it sits on top"); `Forts3D-Bloom.cpp:560` composites into `OutImageSlot()` the same way. Both consume the frozen 48-byte `UIDispatchHeader`, `FillHeader`, `OutImageSlot`, `FontAtlas*Slot`. A blind single-pass merge breaks all of this. ### Proposed fix — additive `DispatchFused` Add **one** new entry point alongside the existing per-element `Dispatch*` (which stay, unchanged): ``` DispatchFused(cmd, {quadsSlot, quadsCount}, {circlesSlot, circlesCount}, {imagesSlot, imagesCount}, {textSlot, textCount, fontSlots}, clipRectPx[...]) // per-category clip if needed ``` backed by **one uber-kernel** that loads `dst` once, composites quads→circles→images→text in canonical order in a register, and stores once — eliminating the redundant load/store and the inter-pass barriers for that fused run. The interleave capability is preserved **explicitly** via `DispatchFused → ui.Dispatch(custom) → DispatchFused`. No hidden flush state; the app declares its fusible batches. 3DForts MainMenu becomes: `DispatchFused(early)` → custom reveal/wipe → `DispatchFused(late)`. **This is additive and does NOT touch the frozen contract:** per-element `Dispatch*` and the 48-byte `UIDispatchHeader` are untouched. `DispatchFused` gets its own new push-constant layout (4 sub-headers + font slots). ### Cost model (why one uber-kernel, NOT per-combination kernels) - An absent category is a **zero-trip, push-constant-uniform loop** (~3 instructions, no divergence, no memory traffic) — using `DispatchFused` for only quads+text is *not* wasted; the unused circle/image loops are ~free. - The win materializes at **>=2 fused types** (saves 1 load + 1 store + 1 barrier per extra type). A single-type screen is marginally worse than the dedicated `DispatchQuads` (dead checks) — so keep the per-element calls for single-type / fine-grained use. - **Do NOT specialize per combination** (`DispatchFusedQuadText`, etc. = 15 kernels for 2^4-1 subsets). The categories composite *sequentially*, so per-loop temporaries don't overlap and the compiler reuses registers across phases — the uber-kernel's VGPR high-water mark is ~max(per-category), not sum. Specialization recovers <1% (dead checks + binary size) at the cost of 15 pipelines + API bloat + maintenance. Not worth it. ### Caveats to measure / watch 1. **Occupancy / register pressure** of the uber-kernel — the one thing to profile before committing. Unlikely to bite on a bandwidth-bound shader (stalled on image/SSBO memory, not VGPRs), but verify. 2. **Escalation path IF profiling shows it is VGPR/occupancy-limited:** keep one shader source, add `VkSpecializationInfo` booleans (`HAS_QUADS`/...) and **lazily** create pipelines keyed by the present-mask, so only the ~3-4 combinations that actually occur compile. NOT hand-written variants. Measure-first only. 3. **Per-category clip rects** — `DispatchFused` carries one clip per category in its header; slightly larger PC. 4. **WebGPU images** — `DispatchImages` binds one texture per dispatch on WebGPU (`Forts3D-OptionsMenu.cpp:574`). Vulkan bindless reads texture slots from item data trivially; the WebGPU images path may need to fall back to per-element or be handled specially. Vulkan-first. 5. **Co-design with #46** — tile culling also restructures the inner item loop; design the fused kernel and per-tile binning together, not stacked. ### Irreducible limit Fusion *across* a custom `ui.Dispatch()` is impossible (a dispatch boundary is a memory round-trip — registers don't survive it). MainMenu's interleaved passes still pay a store/load + barrier around each custom dispatch. That is physics, not API design, and is no longer something this issue tries to remove.
jorijnvdgraaf changed title from [perf][MEDIUM] Redundant swapchain imageLoad/imageStore per UI dispatch to [perf][MEDIUM] Add DispatchFused (additive) to collapse consecutive UI passes to one load/store 2026-06-16 18:46:34 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics#47
No description provided.