LayerNorm [4096,768] is about 10% behind Triton's kernel
LayerNorm is the one operation where a tuned baseline still beats the Rust kernel. This issue carries the measurements, how the gap was closed from 2.3x to 1.10x, what Triton's generated code does differently, and the hypotheses that remain untested. Measurements belong in comments here; the code lives in examples/oxide/src/main.rs.
Where it stands
Same-session Nsight Systems captures, 100 iterations, quiet GPU, [4096, 768] FP32. Each row is one build; Triton is re-measured in the same session as the control.
| Rust build |
Rust kernel us |
Triton kernel us, same session |
| mage-001 (block per row, shared tree reduction, 3 scalar passes) |
18.64 |
8.19 (mage-001 capture) |
| one warp per row (#42) |
11.03 |
8.06 |
| two warps per row (#44) |
10.05 |
7.99 |
one block per row, 96 threads, 1/sqrt_rn |
8.99 |
8.09 |
one block per row, 128 threads, 1/sqrt_rn |
9.04 |
8.10 |
one block per row, 128 threads, rsqrt_approx (#52) |
8.85 |
8.07 |
The time around the call (mean of 300 warmed CUDA-event samples, three rotating rounds) moved 19.47 -> 15.43 -> 12.91 -> 11.71 us, against Triton's 20.62 us in the mage-003 capture: the Rust kernel is ahead on the span and behind on kernel time.
What Triton's kernel does
norm_kernel from examples/oxide/triton_target.py compiled offline for sm_89 with the harness configuration (grid = 4096, BLOCK = 1024, 4 warps) through ASTSource + GPUTarget, then censused in PTX and with cuobjdump:
| Property |
Triton norm_kernel |
our kernel before the rewrite |
| Grid x block |
4096 x 128, one block per row |
1024 x 256, two warps per row |
Registers (cuobjdump) |
31 |
39 |
| Shared memory |
16 bytes |
64 bytes |
| Loads |
24 scalar ld.global.b32 — no vectorisation |
12 quad loads |
| Stores |
8 scalar |
quad |
Passes over x |
one, row slice in registers |
two |
| Barriers |
3 |
1 |
Reading: Triton does not win by vectorising (its accesses are scalar). It wins on occupancy and on a single pass — a lean thread state (31 registers, essentially no shared memory) with one block per row, so the eight elements each thread owns stay in registers from load to store. #52 adopted that shape: grid = rows, 128 threads, a quad per thread plus a masked tail slot, sums from the held values, four warps reduced by shuffles into eight floats of shared, one barrier, and rsqrt_approx_f32 instead of a division with a precise square root.
What is not explained
The residual ~10% is not attributable with the tooling on this host. Registers now match Triton's profile (31-40 depending on how they are counted), the structure is the same single pass, and instruction counts are lower than Triton's, not higher. Nsight Compute counters are unavailable here (ERR_NVGPUCTRPERM), so occupancy, cache behaviour and scheduling cannot be separated.
Hypotheses worth testing, in order of cost
- Measure the gap at other shapes. Run
[8192, 768], [1024, 2048] and [4096, 4096] with Triton and Rust in one session. A gap that stays proportional is structural; one that appears only at width 768 suggests a tail or slot-masking artifact of the 2-slot layout (128 threads cover 256 quads while 768 elements need only 192).
- Cache hints on the read-only loads. The toolchain exposes
cuda_device::ptx load intrinsics; ld.global.nc (read-only, non-coherent) is the natural hint for x, gamma and beta. Compare quad loads with and without it.
- Two rows per block with 128 threads. Halves the number of blocks and the number of cross-warp exchanges per element, and may improve L2 locality for a 4096-row tensor.
- Grid-stride over rows. Fewer blocks (for example 512 x 128 threads, eight rows each) to amortise block launch and index setup; the mage-002 and mage-003 captures show the kernel is short enough that launch and tail effects are a plausible few percent.
- Check the codegen. Whether
cuda-oxide passes ptxas the same optimisation and register budget as Triton's pipeline is not established; a build with an explicit register cap or different ptxas options would settle whether the residual is compiler scheduling.
Protocol for whoever picks this up
- One measurement at a time on the shared GPU (see the coordination issue).
- Same-session Triton control in every comparison; a cross-session comparison is not evidence at this size.
- Report kernel time from Nsight Systems and the event span separately, and name which is which.
- Variants that measure worse get reported here with their metric, not silently dropped, and are not committed.
Rejected so far (measured, not kept)
| Variant |
Result |
Why |
| register-held row with the two-warp split (12 elements per lane) |
13.88 us event span against 12.67 |
the extra registers cost more occupancy than the saved read |
| 96 threads per row |
8.99 us kernel |
slightly worse than 128 with rsqrt_approx |
| 192 threads per row, one quad each |
9.11 us kernel |
more threads did not help; 31 registers but no gain |
| row staged in shared memory for a single global pass |
14.3 us event span |
the shared round trip costs what the read cost |
| four warps per row with an uneven span split |
13.3-13.7 us event span |
half the lanes idle in the tail iteration |
LayerNorm [4096,768] is about 10% behind Triton's kernel
LayerNorm is the one operation where a tuned baseline still beats the Rust kernel. This issue carries the measurements, how the gap was closed from 2.3x to 1.10x, what Triton's generated code does differently, and the hypotheses that remain untested. Measurements belong in comments here; the code lives in
examples/oxide/src/main.rs.Where it stands
Same-session Nsight Systems captures, 100 iterations, quiet GPU,
[4096, 768]FP32. Each row is one build; Triton is re-measured in the same session as the control.1/sqrt_rn1/sqrt_rnrsqrt_approx(#52)The time around the call (mean of 300 warmed CUDA-event samples, three rotating rounds) moved 19.47 -> 15.43 -> 12.91 -> 11.71 us, against Triton's 20.62 us in the mage-003 capture: the Rust kernel is ahead on the span and behind on kernel time.
What Triton's kernel does
norm_kernelfromexamples/oxide/triton_target.pycompiled offline forsm_89with the harness configuration (grid = 4096,BLOCK= 1024, 4 warps) throughASTSource+GPUTarget, then censused in PTX and withcuobjdump:norm_kernelcuobjdump)ld.global.b32— no vectorisationxReading: Triton does not win by vectorising (its accesses are scalar). It wins on occupancy and on a single pass — a lean thread state (31 registers, essentially no shared memory) with one block per row, so the eight elements each thread owns stay in registers from load to store. #52 adopted that shape: grid = rows, 128 threads, a quad per thread plus a masked tail slot, sums from the held values, four warps reduced by shuffles into eight floats of shared, one barrier, and
rsqrt_approx_f32instead of a division with a precise square root.What is not explained
The residual ~10% is not attributable with the tooling on this host. Registers now match Triton's profile (31-40 depending on how they are counted), the structure is the same single pass, and instruction counts are lower than Triton's, not higher. Nsight Compute counters are unavailable here (
ERR_NVGPUCTRPERM), so occupancy, cache behaviour and scheduling cannot be separated.Hypotheses worth testing, in order of cost
[8192, 768],[1024, 2048]and[4096, 4096]with Triton and Rust in one session. A gap that stays proportional is structural; one that appears only at width 768 suggests a tail or slot-masking artifact of the 2-slot layout (128 threads cover 256 quads while 768 elements need only 192).cuda_device::ptxload intrinsics;ld.global.nc(read-only, non-coherent) is the natural hint forx,gammaandbeta. Compare quad loads with and without it.cuda-oxidepassesptxasthe same optimisation and register budget as Triton's pipeline is not established; a build with an explicit register cap or differentptxasoptions would settle whether the residual is compiler scheduling.Protocol for whoever picks this up
Rejected so far (measured, not kept)
rsqrt_approx