2026-06-10. Method: tools/spirv-stats (rspirv opcode/CFG statistics), naga-CLI compile of
the hand twin (naga shaders-wgsl/render.wgsl hand-render.spv), three controlled
experiments added to the bench (matmul_unchecked, render_v2, qptr build). All numbers
RTX 5070 Ti, medians of 30, timestamp queries, wall-clock cross-checked.
rust-gpu's render_cs contains exactly 9 ExtInst ops (GLSL.std.450 — native
sin/cos/sqrt). Math functions lower to native GPU instructions. Not the cause.
Tracer-vs-tracer instruction counts are nearly equal: rust-gpu render_cs 466
instructions vs the hand module's 426 total. The transpiled WGSL text is 3× bigger,
but that's formatting, not operations. Not the cause.
matmul_element with get_unchecked: 1.763 → 0.696 ms (2.5×) on the passthrough arm.
The unchecked rust-gpu kernel also beats hand-WGSL (1.487 ms) by 2.1× — because the
WGSL arm carries wgpu/naga's own injected clamp-checks that WGSL authors cannot opt out of.
The original "15% matmul gap" was never rust-gpu-vs-WGSL; it was rust-gpu's
branch-style checks being slightly costlier than naga's clamp-style checks.
Single biggest perf lever found. Caveat: only meaningful on the native passthrough
path — see the naga-tax finding below.
Firestar99 (rust-gpu maintainer) ran this suite on AMD Strix Halo 8060S / RADV (Linux) in discussion #614: render rustgpu-spv 1.447 ms vs hand-WGSL 1.514 ms — parity. The 1.84× gap below is an NVIDIA-driver behavior, not an inherent rust-gpu cost: NVIDIA's compiler digests the Phi-heavy flattened form worse than RADV's. The structural analysis below stands as the mechanism, but its cost is driver-dependent. (His run also confirms the naga-arm overhead pattern, amplified on RADV: render via naga 4.24 ms vs 1.45 passthrough.) Methodology caveat from the same thread, conceded: these are single-dispatch latency measurements on an under-utilized GPU; a saturation/throughput variant is queued.
The structural difference is stark:
rust-gpu render_cs |
naga compile of hand twin | |
|---|---|---|
| functions | 1 (everything inlined) | 11 (calls preserved) |
| instructions | 466 | 426 total |
| blocks | 74 in one function | 84 across 11 small functions |
| Phi nodes | 40 | ~0 (memory-form locals) |
rust-gpu must inline every function taking a reference (logical SPIR-V forbids pointer arguments — the maintainer-talk constraint, visible in the artifact), producing one Phi-heavy flattened mega-function. naga emits small structured functions with load/store locals. NVIDIA's compiler evidently optimizes the latter better here.
Discriminating experiment attempted: render_v2 threads RNG state by value (removing
pointer-arg inlining pressure). Result: 1.085 ms ≈ baseline 1.098 ms — no change,
because rust-gpu inlines by policy regardless; the experiment cannot separate "inlining"
from "Phi-flattened form". Render itself has only 7 slice accesses, so bounds checks are
negligible here — the gap is genuinely codegen shape, but isolating which aspect
(mega-function vs Phi-form vs block ordering) needs a tool we don't have in-repo
(e.g. spirv-opt function-inlining on the hand module to equalize structure).
matmul_unchecked through the naga frontend arm: 1.668 ms — the 2.5× win evaporates
back to checked-speed. wgpu re-injects bounds checks when it consumes SPIR-V through naga
(its security model; ShaderRuntimeChecks defaults on). Consequences:
- On the web path (WebGPU) you cannot escape bounds checks, no matter the language.
- The collatz naga-arm penalty (0.186 → 0.347 ms) is the same effect: double-checking (rust-gpu's own checks + wgpu's).
- Fair-comparison rule for anyone replicating: passthrough-vs-WGSL comparisons must state check policy explicitly.
RUSTGPU_CODEGEN_ARGS="--no-infer-storage-classes --spirt-passes=qptr" builds and
verifies correct on all five workloads (module grows 15→26 KB, 615→1152 instructions),
timing unchanged (render 1.079 ms). As expected: qptr targets pointer semantics
(more of Rust compiling at all), not performance.
- Hot-loop indexing dominates: in rust-gpu today,
get_unchecked(or restructuring to avoid repeated slice indexing) is worth up to 2.5× on native. This is actionable for any rust-gpu user right now. - The tracer-class gap (~1.8×) is codegen shape, not math, not bloat, not checks. Plausibly addressable in SPIR-T (block layout / Phi reduction / selective outlining); this is the precise, reproducible finding worth handing upstream.
- Web-path numbers are check-burdened for everyone — rust-gpu's relative position on WebGPU is better than the native numbers suggest, since hand-WGSL pays the same tax.
naga shaders-wgsl/render.wgsl hand-render.spv
cargo run -p spirv-stats --release -- shaders/spv/gpu_shaders.spv render_cs
cargo run -p spirv-stats --release -- hand-render.spv
cargo run -p bench --release # includes matmul_unchecked + render_v2 experimentsEarlier we diffed at the SPIR-V level. This is the view the browser actually compiles —
naga shaders/spv/gpu_shaders.spv out.wgsl, the rust-gpu render path (function_8) vs the
hand render.wgsl:
| metric | rust-gpu render (naga-emitted) | hand render.wgsl |
|---|---|---|
| structure | 1 fully-inlined function, 400 lines | 11 structured functions, 189 lines |
loop {} / switch |
3 / 2 (CFG reconstructed as a state machine) | 0 / 1 |
if |
22 | 10 |
var (mutable) |
60, of which 162 phi-materialized assignments | 14 |
for |
0 (control flow is loop+break+switch) |
3 |
The flattened SPIR-V (one 74-block / 40-OpPhi function — logical addressing forbids passing
pointers, so rust-gpu inlines everything) survives transpilation as a single 400-line WGSL
function whose 40 OpPhis become 162 mutable phi-vars threaded through a reconstructed
loop { switch } state machine. The hand version is 11 small functions with natural control
flow and zero phi materialization. This is the concrete form of the 1.86× gap: NVIDIA's
register allocator / optimizer handles the structured form far better than the phi-soup
state machine. It is codegen shape, not bounds checks (checks-off moves it <1%, per
naga-tax.md) and it is driver-specific (RADV reaches parity — it digests the inlined
form fine; NVIDIA does not). The fix lives upstream in rust-gpu's function-call story
(qptr / physical pointers would let it stop inlining), not in our kernel.