Skip to content

LayerNorm [4096,768] is about 10% behind Triton's kernel #54

Description

@superposition

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

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    gapA measured gap against a baseline, with evidence attachedworkstream:kernelsRust kernel optimisation track (main agent) — evidence in PRs, stage records in field notes

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions