Skip to content

perf(linear_attention): halve the prefill chunk and split the scan over dv (-25% op, -1.07 s TTFT, 129 -> 49 MiB scratch) - #627

Draft
BoarQing wants to merge 6 commits into
mainfrom
perf/la-prefill-chunk16-dvsplit
Draft

perf(linear_attention): halve the prefill chunk and split the scan over dv (-25% op, -1.07 s TTFT, 129 -> 49 MiB scratch)#627
BoarQing wants to merge 6 commits into
mainfrom
perf/la-prefill-chunk16-dvsplit

Conversation

@BoarQing

@BoarQing BoarQing commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Halves the linear-attention prefill chunk (LA_CHUNK 32 -> 16, LA_WINDOW_CHUNKS 64 -> 32) and splits the prefill scan over the dv dimension (LA_SCAN_NSPLIT 4, 256-thread scan blocks). On an 18,778-token prompt this takes the linear-attention op down 25.2% and TTFT down 1,069 ms (-5.4%), and shrinks the prefill scratch arena from 129.3 MiB to 49.1 MiB.

Numerics are unchanged; this is an occupancy and blocking change only.

Related issue or design

Builds directly on #606. See "Notes for reviewers" for the branch ordering.

Why

#606 capped the prefill scratch but left the kernels' shape alone, and profiling that shape shows the limiter is LDS, not arithmetic.

At LA_CHUNK=32 pass 3 asks for 61,952 B of LDS. A CU has 64 KB, so exactly one block is resident, and with one block per CU a memory stall has nothing to hide behind. The scan has the opposite problem: it is serial in the chunk index and ran one block per (batch, kv head), which is 32 blocks for this model against 40 CUs, so the machine is not even filled once.

Both are addressed by changing how work is divided, not what work is done.

What

Chunk 32 -> 16. Every chunk-sized LDS tile halves; pass 3 drops to 29,824 B, which fits two blocks per CU. LA_WINDOW_CHUNKS follows 64 -> 32 so the window stays at 512 tokens, which is also where most of the arena reduction comes from. The extra launches this costs are not a real cost: holding LA_CHUNK at 16 and varying only the window, 32 chunks (37 windows, 49.1 MiB) beats 128 chunks (10 windows, 193.3 MiB) by 9.6%.

Scan split over dv. The scan's two inner products are WS[t][j] = sum_i W[t][i] S0[i][j] and S1[i][j] = a S0[i][j] + sum_s Ktilde[s][i] U[s][j]. Both touch only column j of S0, so the recurrence factors completely over dv and the columns can be split across blocks without changing the serial structure in the chunk index. At NSPLIT=4 the scan runs 128 blocks instead of 32, each owning dv/4 columns, and the narrower 256-thread block is what keeps them resident.

Two things worth knowing about the split:

  • Each split re-reads the dv-independent W tile. That duplicated read is the cost traded for the parallelism, and it only pays once LA_CHUNK=16 has shrunk the tile: applied on top of LA_CHUNK=32 the split is a 12.8% regression.
  • The 256-thread scan block is part of the split, not an independent knob. With the split it is worth 13 points; without it, it costs 17.

NSPLIT=1 restores the previous scan exactly (dvs == dv, j0 == 0). The launcher declines the config if dv % LA_SCAN_NSPLIT != 0, so the runtime falls back rather than computing a wrong answer on an unsupported shape.

Test plan

Correctness. A standalone harness calls the exported launcher on synthetic fp16 inputs and compares against an fp64 per-token reference. The existing gate only ran the reference at T <= 512, which never crosses a window boundary and therefore could not see any of this; since every change here alters the multi-window carry hand-off, the reference was run at the full 18,778 tokens, crossing 36 boundaries:

#606 this PR
cosine 0.999999960 0.999999960
mean abs err 0.25863 0.25863
relative L2, trailing half 2.749e-4 2.749e-4
NaN / Inf 0 0

Identical to every reported digit.

Cosine alone was not trusted as the gate: a deliberately corrupted build (inter-window carry scaled by 0.99) still passes cosine > 0.9999 at T=4096, because a single perturbed boundary barely moves a whole-tensor cosine. The trailing-half relative L2 moves 2.5x on that same corruption, so it is what the gate keys on.

Performance. All figures are paired interleaved runs, arm order rotated per round. This is necessary rather than fussy: the machine has at least two performance states, and the same DLL measured anywhere from 75 to 165 ms per call across this session. Absolute numbers are therefore not comparable across rounds and only within-round paired deltas are reported.

Notes for reviewers

Branch ordering. This targets main per CONTRIBUTING, but only the last commit is mine. The other five belong to #606 (feat/linattn-window-tiling) and its base feat/linattn-scratch-mem, which has no PR of its own. Review just 072ce177, or the compare against #606's head:

git diff origin/feat/linattn-window-tiling...perf/la-prefill-chunk16-dvsplit

That is 1 file, +67/-32. This should land after #606, or be rebased onto main once #606 does.

A third change was measured and rejected. Storing S0 transposed makes pass 3's WMMA B fragment one 32-byte vector load instead of 16 two-byte loads. The ISA confirms the mechanism exactly as predicted, global_load_u16 per WMMA falling from 20.7 to 4.7 and the inner loop from 78 instructions to 13, and it is still not worth taking: the transposing store has to read LDS down a column, and the scan loses more than pass 3 gains. The conclusion is that the B-fragment load was never the limiter. It is not in this PR and no dead code for it is left behind.

Coverage limits. NSPLIT=4 was verified at B=1, Hkv=32, where the scan was starved of blocks. On a shape where B*Hkv already exceeds the CU count the split has nothing to win and the duplicated W read may make it a small loss; it is worth a second look before this is relied on for large-batch prefill.

AI assistance. The profiling, the measurement harnesses, the implementation and this description were produced with Cursor (Claude Opus 4.5), and the commit carries the trailers. The claims above are all from local runs on the hardware listed below, and the correctness gate was validated against a known-bad build before being trusted. I have reviewed the change and own it.

Performance

Metric #606 This PR
Linear-attention op, isolated (median of 10 paired rounds) baseline -25.2%, 10/10 wins
TTFT (6 paired rounds) baseline -1,069 ms, -5.4%, 5/6 wins
Linear attention per forward, in model 3,443 ms 2,244 ms (-34.8%)
Pass 1 (local) baseline -54.3%
Pass 2 (scan) baseline -5.8%
Pass 3 (WMMA) baseline -42.2%
Prefill scratch arena 129.3 MiB 49.1 MiB
Pass 3 LDS per block 61,952 B 29,824 B
Scan blocks per launch 32 128

Hardware, workload, build configuration, and measurement method:

  • gfx1151, Radeon 8060S Graphics, 40 CUs, 64 KB LDS per CU
  • Qwen3-Next-80B-A3B, fp16, 30 layers, B=1, Hkv=32, dk=dv=128
  • 18,778-token prompt, chunk-parallel gated-delta prefill path
  • Release build, HIP/ROCm on Windows
  • Isolated op: standalone harness, DLL swapped between arms, arm order rotated each round, median and per-round paired delta over 10 rounds
  • TTFT: full model run per arm, arms alternated across 6 rounds, paired within round
  • Per-pass: hipEvent timing around each launch inside the window loop, first 60 forwards skipped

BoarQing and others added 6 commits July 29, 2026 23:53
Rewrite the gated_delta linear-attention prefill from a 587-deep serial
chunk recurrence (grid=B*Hkv=32 blocks, 25% occupancy, latency-bound) into a
three-pass chunk-parallel form that exploits the affine chunk-state map
S_out = L_c*S_in + b_c, with the dominant [Q;W]@s0 GEMM on the RDNA3
wave-matrix cores:

  Pass 1 (grid = B*Hkv*nchunks): S_in-independent locals
          Uloc=(I-trilT)^-1(beta.*v), W=(I-trilT)^-1(diag(beta.*A)K),
          Ktilde, a_last.
  Pass 2 (grid = B*Hkv, light serial scan): U = Uloc - W*S0,
          S1 = a_last*S0 + Ktilde^T U (fp16-rounded per chunk).
  Pass 3 (grid = B*Hkv*nchunks): O = scale*(A.*(Q*S0) + P_lower*U), with the
          stacked [Q;W]@s0 GEMM via __builtin_amdgcn_wmma_f32_16x16x16_f16_w32.

Numerically equivalent to the serial kernel (same fp32 math, same per-chunk
fp16 rounding); validated cosine=1.0000000 vs an fp64 per-token gated_delta
reference.

Measured (Qwen3.6-35B, gfx1151, seq 18778): kernel 604.8 ms -> 110.0 ms
(5.5x); RGP peak-clock occupancy 25% -> 25-100%, wavefronts 256 -> 150272;
E2E 16k prefill TTFT 33476 ms -> 17580 ms (1.90x). No regression at shorter
sequences (in128 1.51x, in2048 1.77x E2E).

Co-authored-by: Cursor <cursoragent@cursor.com>
… path

Promote the optimized path to the only implementation and drop the bring-up
scaffolding now that it is validated:

  - Delete the original serial chunked kernel (linear_attention_prefill_-
    chunked_kernel) and the scalar Pass-3 variant (la_pf_pass3_out); they are
    superseded by the chunk-parallel + WMMA path.
  - Remove all debug/tuning env gates: HIPDNN_EP_LA_PARALLEL, _WMMA, _PROF,
    _SCANBLK, _P3BLK, and the per-pass hipEvent timing instrumentation.
  - The launcher now always dispatches the three-pass parallel path; on any
    launch/alloc failure it returns nonzero so the runtime falls back to the
    per-token loop (unchanged higher-level safety net). WMMA needs dk,dv
    multiples of 16, so decline otherwise (-> per-token loop).

Behavior for the supported gated_delta config is unchanged: cosine=1.0000000
vs the fp64 reference, kernel 109.6 ms at seq 18778 (5.5x vs the removed
serial kernel).

Note: HIPDNN_EP_LA_PARALLEL still exists for the separate decode-path G-split
(la_par_G) and is untouched here.

Co-authored-by: Cursor <cursoragent@cursor.com>
…buffer

Reduce the chunk-parallel gated_delta prefill's device scratch and fix its
lifetime, on top of the chunk-parallel + WMMA prefill (#589).

- Move the prefill scratch out of a process-static, never-freed g_la_scratch
  into the per-session RuntimeState::la_scratch pool (grow-on-demand, freed in
  hipdnn_ep_state_cleanup), mirroring qmoe_scratch/conv_scratch. The kernel now
  takes a caller-owned scratch + size; the wrapper sizes it via the new
  hip_linear_attention_prefill_scratch_bytes(). Footprint is now bounded to the
  session and released on teardown instead of held for the whole process.

- Store only the per-token rlast scalar (C floats/chunk) instead of the full
  Ktilde = rlast*K tile (C*dk floats/chunk); the scan rebuilds Ktilde from the
  original key tensor. Removes the Kt_g buffer (~1/dk the size) at bit-identical
  values.

Both changes are numerically exact (same fp32 math / per-chunk fp16 rounding);
only op-order-invariant scratch and buffer sizing change. Validated end-to-end
on Qwen3.5-9B (gfx1151): chunk-parallel prefill path exercised (dk=dv=128),
coherent output, no regression.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Bound the chunk-parallel gated_delta prefill's device scratch to a fixed
window instead of the whole sequence, on top of the session-scoped scratch
change (#596) and the chunk-parallel + WMMA prefill (#589).

The per-(head,chunk) buffers (Uloc, W, rlast, a_last and the chunk-start
states) were sized by nchunks = seq_len/LA_CHUNK, so the arena grew without
bound with prompt length. The three passes now run over LA_WINDOW_CHUNKS =
64 chunks (2048 tokens) at a time, so the arena is sized per window and stops
scaling with the sequence (measured via the exported
hip_linear_attention_prefill_scratch_bytes, B=1, Hkv=32, dk=dv=128):

  seq  4096:  256.5 MiB -> 129.3 MiB  (1.98x)
  seq 13529:  847.7 MiB -> 129.3 MiB  (6.56x)
  seq 16384: 1026.1 MiB -> 129.3 MiB  (7.94x)
  seq 32768: 2052.1 MiB -> 129.3 MiB  (15.9x)

A window still launches B*Hkv*64 = 2048 blocks at Hkv=32, far more than the
40 CUs need to stay saturated, so the chunk-parallel decomposition keeps its
parallelism. Ordering needs no extra synchronisation: the three passes are
issued back-to-back on one stream, so pass 3 of a window has finished reading
Uloc/W/S0 before pass 1 of the next window overwrites them.

The scan is already sequential over chunks, so windowing only changes where
its running state lives between launches. Across windows it hands off through
a small fp16 carry buffer instead of re-reading `state`: the running state is
fp16 in LDS, so a round trip through a narrower T (bf16 keeps 8 mantissa bits
vs fp16's 10) would perturb it at every boundary. Single-window prefills are
bit-identical to before -- is_first == is_last, the carry is never read or
written, and it is not allocated at all.

Co-authored-by: Cursor <cursoragent@cursor.com>
…er dv

The windowed prefill from #606 is limited by LDS, not by arithmetic. Pass 3
asks for 61,952 B of LDS at LA_CHUNK=32, over half of a CU's 64 KB, so only one
block is resident per CU and a stall has nothing to hide behind.

Two changes address that.

LA_CHUNK 32 -> 16 halves every chunk-sized LDS tile. Pass 3 drops to 29,824 B,
which fits two blocks per CU. LA_WINDOW_CHUNKS follows 64 -> 32 to hold the
window at 512 tokens, which also shrinks the scratch arena. The extra launches
are not a real cost: holding LA_CHUNK at 16, a 32-chunk window beats a
128-chunk one by 9.6% despite issuing 3.7x as many.

The scan then becomes the tail. It is serial in the chunk index and ran one
block per (batch, kv head) -- 32 blocks for this model, fewer than the 40 CUs.
But both of its inner products touch only column j of S0, so the recurrence
factors completely over dv and splits across blocks. At NSPLIT=4 that is 128
blocks, and the narrower 256-thread block that a dv/4 slice wants is what keeps
them resident. Each split re-reads the dv-independent W tile; that duplication
is what is traded for the parallelism, and it only pays once LA_CHUNK=16 has
shrunk the tile. NSPLIT=1 restores the previous scan exactly.

Measured on gfx1151 (Radeon 8060S, 40 CUs), Qwen3-Next-80B-A3B fp16, 30 layers,
18,778-token prompt, paired interleaved runs against #606:

  isolated op       -25.2%   (10/10 paired wins)
  TTFT            -1,069 ms  (-5.4%, 5/6 paired rounds)
  scratch arena   129.3 -> 49.1 MiB
  per pass        pass1 -54.3%, scan -5.8%, pass3 -42.2%

Correctness is unchanged: an fp64 per-token reference at 18,778 tokens, which
crosses 36 window boundaries, gives cosine 0.999999960 and a trailing-half
relative L2 of 2.749e-4, matching #606 to every reported digit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Made-with: Cursor
Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Thanks for opening a PR!

This project follows LLVM's incremental-development and AI-tool-use
guidance. See CONTRIBUTING.md
for the project workflow.

Before requesting review, please check that:

  1. The change is focused. Substantial work links the relevant issue
    or design discussion.
  2. The PR documents relevant test results and updates affected
    documentation.
  3. If AI tools provided substantial assistance, the description
    explains what was assisted and how it was validated, and commit
    trailers identify the tool. The contributor has reviewed and
    understands the result.

Reviewers are assigned through
CODEOWNERS where ownership
is configured.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

L2 Accuracy Results (EP vs CPU)

Model Combined L2 Total Elems Skipped NaN/Inf
conv_test_hybrid 4.8668E-07 64 0
GroupQueryAttention_seq256 25.2366 2621440 0
MatMulNBits_o_seq128 259.906 368640 0
QMoE_seq128 34.957 368640 0

Threshold: 0.01 | Run: 3532 - Commit: 072ce17

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

MorphiZen EP Performance Results

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 7.51 10.34 363 3 1243
GroupQueryAttention_seq128 4424.80 1.77112 11 6 311
matmul_down_seq128 525.58 2.33 72 3 351

EPContext Export Performance

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 7.56 43.65 359 3 15590

EPContext Import Performance

Model QPS Session (s) 1st Infer (ms) CPU% Mem (MB)
full_model_seq128 7.53 9.22 359 3 15760

OGA Benchmark Results

Model Warmup Reps Prompt Len Gen Tokens TTFT (ms) TPS Peak Mem (GB) GPU Mem (GB)
gpt-oss-20b-webgpu-int4-rtn-block-32 1 5 128 128 170.0 78.8 1.33 13.54
Llama-3.1-8B-awq-g128-int4-asym-fp16-onnx-dml 1 5 128 128 333.1 43.6 1.22 6.43

OGA Wheel Smoke (Python benchmark_e2e.py)

Model TTFT (ms) TPS
Llama-3.1-8B-awq-g128-int4-asym-fp16-onnx-dml 193 42.5

Run: 3532 - Commit: 072ce17

@BoarQing

BoarQing commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant