[perf] Single-lane serial stream compaction stalls 63/64 threads — use subgroup prefix-sum (ui-fused.comp.glsl) #124

Closed
opened 2026-06-17 21:18:54 +02:00 by jorijnvdgraaf · 0 comments

Found by an adversarially-verified performance audit (confirmed real after a skeptic pass).

Location: shaders/ui-fused.comp.glsl:79-87 (same pattern inlined in ui-quads/circles/images/text)
Severity: medium · Effort: moderate · Category: gpu

Problem

uiCompactChunk runs the survivor scan entirely on gl_LocalInvocationIndex == 0 between two barriers — ~64 serial iterations with 63/64 lanes idle, 4× per chunk in the fused kernel. It must produce a stable in-order exclusive prefix of the survivors (s_order[] in buffer order) so the per-pixel inner loop still sees items in draw order.

Fix — subgroup exclusive prefix-sum (primary)

Enable GL_KHR_shader_subgroup_arithmetic (+ _ballot) and replace the lane-0 scan with subgroupExclusiveAdd(s_keep[k]) to compute each survivor's output slot in s_order[] at register speed. This beats the shared-memory fallback on both barrier count and shared-memory traffic.

Safe to enable here:

  • WebGPU is unaffected — it runs separate embedded WGSL shaders (additional/dom-webgpu.js); these GLSL files compile to SPIR-V (ui-fused.comp.spv) for the Vulkan path only.
  • The feature requirement is effectively free: the device baseline is apiVersion = 1.4 with a hard requirement on VK_EXT_descriptor_heap, and every GPU that clears that bar supports Vulkan 1.1 compute subgroup ballot+arithmetic. No realistic device is excluded, so no correctness fallback is required.

Implementation caveats (why this stays "moderate"):

  1. The 64-thread workgroup is not one subgroup: 2 subgroups on the 32-wide NVIDIA descriptor_heap target, up to 8 on 8-wide Intel. So it's per-subgroup subgroupExclusiveAdd → write each subgroup's total to a shared slot → tiny scan over the ≤8 partial totals → add each subgroup's base as a carry. (Could instead pin width via VK_EXT_subgroup_size_control + requiredSubgroupSize, but the general carry avoids that machinery.)
  2. Must remain order-preserving. Do not use atomicAdd(s_count, 1) per survivor — atomics yield arbitrary order and would break draw order. Exclusive prefix-add preserves it; atomics don't.

Magnitude

The win is the compaction bubble only — SSBO traffic and the surrounding barriers are unchanged, and the per-pixel survivor loop (SDF / texture() fetches) dominates frame cost. This tightens a per-chunk serial bubble; it does not transform frame time. Medium severity is correct.

Found by an adversarially-verified performance audit (confirmed real after a skeptic pass). **Location:** `shaders/ui-fused.comp.glsl:79-87` (same pattern inlined in ui-quads/circles/images/text) **Severity:** medium · **Effort:** moderate · **Category:** gpu ### Problem `uiCompactChunk` runs the survivor scan entirely on `gl_LocalInvocationIndex == 0` between two barriers — ~64 serial iterations with 63/64 lanes idle, 4× per chunk in the fused kernel. It must produce a **stable in-order** exclusive prefix of the survivors (`s_order[]` in buffer order) so the per-pixel inner loop still sees items in draw order. ### Fix — subgroup exclusive prefix-sum (primary) Enable `GL_KHR_shader_subgroup_arithmetic` (+ `_ballot`) and replace the lane-0 scan with `subgroupExclusiveAdd(s_keep[k])` to compute each survivor's output slot in `s_order[]` at register speed. This beats the shared-memory fallback on both barrier count and shared-memory traffic. **Safe to enable here:** - WebGPU is unaffected — it runs separate embedded WGSL shaders (`additional/dom-webgpu.js`); these GLSL files compile to SPIR-V (`ui-fused.comp.spv`) for the Vulkan path only. - The feature requirement is effectively free: the device baseline is `apiVersion = 1.4` with a hard requirement on `VK_EXT_descriptor_heap`, and every GPU that clears that bar supports Vulkan 1.1 compute subgroup ballot+arithmetic. No realistic device is excluded, so no correctness fallback is required. **Implementation caveats (why this stays "moderate"):** 1. The 64-thread workgroup is **not** one subgroup: 2 subgroups on the 32-wide NVIDIA `descriptor_heap` target, up to 8 on 8-wide Intel. So it's per-subgroup `subgroupExclusiveAdd` → write each subgroup's total to a shared slot → tiny scan over the ≤8 partial totals → add each subgroup's base as a carry. (Could instead pin width via `VK_EXT_subgroup_size_control` + `requiredSubgroupSize`, but the general carry avoids that machinery.) 2. Must remain order-preserving. Do **not** use `atomicAdd(s_count, 1)` per survivor — atomics yield arbitrary order and would break draw order. Exclusive prefix-add preserves it; atomics don't. ### Magnitude The win is the compaction bubble only — SSBO traffic and the surrounding barriers are unchanged, and the per-pixel survivor loop (SDF / `texture()` fetches) dominates frame cost. This tightens a per-chunk serial bubble; it does not transform frame time. Medium severity is correct.
jorijnvdgraaf changed title from [perf] Single-lane serial stream compaction stalls 63/64 threads (ui-fused.comp.glsl) to [perf] Single-lane serial stream compaction stalls 63/64 threads — use subgroup prefix-sum (ui-fused.comp.glsl) 2026-06-17 21:40:08 +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#124
No description provided.