Skip to content

perf(kernel): split triton decode attention by context length and sm count - #729

Merged
jason-fxz merged 2 commits into
mainfrom
perf/triton-attn-kv-splits
Oct 17, 2026
Merged

jason-fxz merged 2 commits into
mainfrom
perf/triton-attn-kv-splits

Conversation

@jason-fxz

@jason-fxz jason-fxz commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Decode attention reads the whole KV cache for one query per request, so it is bound by memory bandwidth and needs enough parallel programs to keep every SM busy. The Triton kernel gets that parallelism by splitting each request's keys into chunks that run in parallel and merging them afterwards (split-K), but it always used 8 chunks. Layers with few KV heads then ran on a small part of the GPU (16 programs on a 132-SM H100 for Gemma 4's full-attention layers at batch 1), and decode slowed down sharply with context: Gemma 4 26B-A4B decodes at 204 tok/s at 1k but 77 tok/s at 128k.

This PR sizes the chunks from the request length and the GPU. Batch-1 decode at 128k goes from 77 to 174 tok/s on Gemma 4, 90 to 213 on gpt-oss-20b and 67 to 196 on Qwen3.6-35B-A3B with --attention-backend triton, which puts triton within 2% of flashinfer and FA3 on Qwen3.6 from 1k to 192k. At 1k all three stay within 1.5% of main.

Design

The kernel is flash decoding: stage 1 cuts each request's keys into chunks and computes, in parallel, a partial attention output and its log-sum-exp for each chunk; stage 2 merges the chunks. This PR changes how many chunks a request gets, taking the smaller of two counts:

  • What fills the GPU: about four programs per SM across the batch and the head blocks, between 8 and 64 chunks. A batch-1 request can take 64 chunks; a large batch keeps main's 8.
  • What the length can use: every chunk writes a partial result that stage 2 reads back, so a chunk covers at least 128 keys. Requests of up to 1024 keys keep main's layout.

To work under CUDA graphs, which fix the grid at capture, the host only sizes the grid for the most chunks a request can take at that batch size. Each program then reads its request's current length from the graph's input buffers, computes the request's chunk count and chunk length, and exits if its chunk is past the end; stage 2 runs the same formula to merge only those chunks. One captured graph therefore serves every context length, and short requests in a batch stay coarse. For Qwen3.6's full-attention layers at batch 1 on H100, a 32k request now takes 64 chunks of 512 keys instead of 8 chunks of 4096.

A batch of four requests of different lengths on the same layer (batch 4, so the grid has 64 chunk slots per request; # is a chunk that runs, . a program that exits at once):

1k   ########........................................................   8 chunks x 128 keys
4k   ################################................................  32 chunks x 128 keys
16k  ################################################################  64 chunks x 256 keys
32k  ################################################################  64 chunks x 512 keys

main (8 chunks for every request):
32k  ########                                                           8 chunks x 4096 keys

With few programs per SM, each program has to keep more memory requests in flight: the key loop pipelines 3 steps instead of 2, and when the grid has at most one program per SM, each step reads 64 keys instead of 32 (skipped where it would not fit in shared memory).

Stage 2 merges a tile of up to 64 chunks per step instead of one chunk per loop iteration, so its loads no longer wait on each other one chunk at a time. The tile size follows the launch's chunk cap.

The backend sizes its split-K scratch to match, and under CUDA graphs all batch sizes share one buffer. A new test covers long requests next to short ones and sliding windows that span several chunks.

Tested on

NVIDIA H100 80GB HBM3 (132 SMs), Intel Xeon Platinum 8480+, driver 580.95.05, CUDA 13.0, torch 2.14.1+cu130, Triton 3.8.0, flashinfer 0.6.18.post1. main is bc0e2fe, the base of this branch.

End-to-end results

Models: nvidia/Gemma-4-26B-A4B-NVFP4 and openai/gpt-oss-20b, which use Triton attention by default, and nvidia/Qwen3.6-35B-A3B-NVFP4 with --attention-backend triton.

python -m freetoken --model-path <model> --port 8156 --served-model-name default --text-model-only \
  --max-running-requests 1 --cuda-graph-max-bs 1 --max-seq-len-override <140000 | 131072 | 200000> \
  --cache-type naive --moe-strategy fused [--attention-backend triton]

The client sends one streamed /v1/chat/completions request at a time: a prompt of about 1k to 192k tokens (source code, cut with each model's tokenizer), 256 output tokens with ignore_eos, temperature 1.0, top_p 0.95, top_k 64. Decode tok/s is (output tokens - 1) / (last token time - first token time). Median of 3 runs. TTFT is the same on both sides: these prompts are prefilled by the extend kernel, which this PR does not touch.

These runs are from 855aedd, before stage 2 merged a tile of chunks per step. The RTX results comment has an end-to-end run of the final commit on an RTX PRO 6000.

Decode tok/s, batch 1, main -> this PR:

Context Gemma 4 26B-A4B gpt-oss-20b Qwen3.6-35B-A3B (triton)
1k 204.1 -> 207.1 284.4 -> 285.7 243.1 -> 246.5
8k 186.9 -> 202.4 252.6 -> 273.0 212.7 -> 238.0
32k 144.9 -> 195.6 183.9 -> 257.3 148.2 -> 227.3
64k 112.1 -> 187.7 134.7 -> 239.7 105.4 -> 215.7
128k 76.8 -> 173.8 89.6 -> 213.2 66.8 -> 195.7
192k - - 49.0 -> 180.7

Qwen3.6 against the other attention backends, decode tok/s, batch 1:

Context triton main triton this PR flashinfer FA3
1k 243.1 246.5 244.9 244.9
8k 212.7 238.0 241.4 240.5
32k 148.2 227.3 231.2 229.0
64k 105.4 215.7 217.5 215.7
128k 66.8 195.7 198.8 196.4
192k 49.0 180.7 183.2 180.8

Kernel results

decode_paged_attention alone, bf16, one CUDA graph replay after an L2 flush, median us (main -> this PR). Shapes are query heads / KV heads / head_dim.

Shape 1 x 300 1 x 1k 1 x 32k 1 x 128k 4 x 32k 16 x 8k 64 x 1k-16k
Qwen3.6 full, 16/2/256 13.4 -> 12.1 18.2 -> 14.2 298.8 -> 53.3 1131.7 -> 119.8 287.8 -> 122.0 114.2 -> 110.5 497.8 -> 464.6
Gemma 4 full, 16/2/512 15.6 -> 13.4 23.0 -> 17.4 426.2 -> 79.1 1648.2 -> 205.4 433.6 -> 216.4 206.6 -> 201.2 932.7 -> 887.4
Gemma 4 sliding, 16/8/256, window 1024 12.7 -> 12.1 17.2 -> 16.3 17.0 -> 16.3 - 28.7 -> 26.7 65.6 -> 65.7 203.3 -> 202.0
gpt-oss full, 64/8/64, sinks 11.6 -> 11.9 15.2 -> 15.1 185.1 -> 54.0 672.0 -> 124.1 189.6 -> 114.9 105.8 -> 107.8 479.8 -> 477.2
gpt-oss sliding, 64/8/64, window 128, sinks 9.8 -> 10.0 9.9 -> 10.1 9.8 -> 10.0 - 10.2 -> 10.6 12.8 -> 13.1 22.6 -> 22.8
32/8/128 11.9 -> 12.2 16.4 -> 16.5 209.5 -> 74.8 793.9 -> 201.0 236.4 -> 192.9 192.7 -> 193.3 864.7 -> 861.7
  • These tables are from 855aedd. Rerun on H100 with the final commit (the same shapes plus 1 x 8k and 8 x 8k, shuffled KV slots), every case takes 0.58x to 1.00x of 855aedd's time (geometric mean 0.92), the largest gains are at 1 x 8k, and no case is slower than main. The RTX results comment covers four RTX GPUs.
  • Against an fp32 reference, the relative error over those cases is at most 0.00226 on the final commit and 0.00234 on main. Stage 2 sums a tile as a tree, so the output is not bit-identical to main.

Qwen3.6 full-attention shape against the other backends, median us:

Batch x context triton main triton this PR flashinfer FA3
1 x 1k 18.0 14.1 16.2 18.0
1 x 8k 83.2 32.1 26.9 26.2
1 x 32k 300.2 53.7 50.8 50.0
1 x 128k 1123.0 120.4 117.0 118.9
1 x 192k 1659.5 161.5 158.8 164.1
4 x 32k 292.4 122.6 113.8 114.5
4 x 192k 1654.8 547.4 534.8 552.6

Other checks

  • PYTHONPATH=python pytest tests/kernels/test_triton_attention.py: 41 passed; with CUDA_VISIBLE_DEVICES=, 11 passed and 30 skipped. Same counts on the final commit, and pytest tests/attention passes (15).
  • Compile only (no run), for sm_80, 86, 89, 90, 100 and 120, each with its own shared memory limit: both stages compile for the six shapes above and a head_dim 256 case with sinks, at batch 1 and 32. Shared memory peaks at 140,800 bytes on sm_80/90/100 (64-key tile at head_dim 512) and 74,496 bytes on sm_86/89/120, where that case falls back to 32 keys. Stage 2 uses at most 2,048 bytes.
  • The tables in this body are H100 only; the RTX results comment covers four RTX GPUs. The split count adapts to each GPU's SM count, but the stage 1 tile rule and the split constants were picked on H100. ROCm was not run; where torch reports no opt-in shared memory size, the tile stays at 32 keys.

@jason-fxz jason-fxz added the perf label Oct 17, 2026
- stage 2 loads SPLIT_TILE splits per step and merges them with one
  online-softmax update instead of one split per iteration
- SPLIT_TILE is the launch's split cap rounded up to a power of two,
  at most 8192 fp32 elements and 64 splits; num_warps scales with it
@jason-fxz

Copy link
Copy Markdown
Collaborator Author

Pushed 2bd6c33: stage 2 now merges a tile of splits per step instead of one split per iteration.

Why

I measured this PR on four consumer and workstation cards: RTX PRO 6000 Blackwell Server (sm_120, 188 SMs), RTX 5090 (170), RTX 4090 (128) and RTX 3080 Ti (80). On all four, stage 1 stops getting faster at 16 to 32 splits.

Stage 2 runs one program per (request, query head), and that program loads one split per iteration. Each iteration waits for the previous load to return, so at batch 1 the merge costs about 0.21 us per split. With 64 splits it takes 13.5 us on the PRO 6000, which is half of stage 1's time at 1 x 8k.

flashinfer's merge kernel handles 188 chunks in 5 to 9 us, and its attention kernel takes about the same time as this PR's stage 1. So the gap to flashinfer was the merge alone.

Change

  • Stage 2 loads SPLIT_TILE LSEs and a [SPLIT_TILE, head_dim] tile of partial outputs at once. Inside a tile it takes the max and sums the weighted partials; across tiles it uses an online softmax.
  • SPLIT_TILE = min(next_pow2(max_kv_splits), max(4, 8192 // BLOCK_DV), 64) and num_warps = clamp(SPLIT_TILE * BLOCK_DV / 2048, 1, 8).
  • I swept tile in {4..64} x warps in {1..8} over 6 shapes x 7 batches on each card. This one rule is within 0.8 to 1.2% of the best config per point on every card, so no per-GPU dispatch is needed.
  • A fixed tile does not work: tile 16 or 32 loses up to 35% on sliding-window and large-batch launches, where each request has only 4 to 8 splits.

Kernel results

decode_paged_attention, bf16, one CUDA graph replay after an L2 flush. Times are CUPTI medians in us, over 3 rounds with rotated order and no other process on the GPU. Columns are 855aedd -> 2bd6c33:

Shape, batch x context RTX PRO 6000 RTX 5090 RTX 4090 RTX 3080 Ti
Qwen3.6 full 16/2/256, 1 x 1k 13.4 -> 12.8 10.3 -> 9.7 12.0 -> 11.4 15.6 -> 14.9
Qwen3.6 full, 1 x 8k 40.0 -> 29.7 26.9 -> 18.7 41.3 -> 33.4 44.5 -> 34.0
Qwen3.6 full, 1 x 32k 85.7 -> 75.7 59.1 -> 50.7 114.1 -> 106.6 103.0 -> 92.4
Qwen3.6 full, 1 x 128k 255.3 -> 245.0 184.2 -> 175.8 353.4 -> 346.0 337.8 -> 326.8
Qwen3.6 full, 4 x 32k 254.7 -> 244.3 187.2 -> 179.5 362.5 -> 352.8 336.9 -> 328.9
Gemma 4 full 16/2/512, 1 x 8k 57.3 -> 49.5 38.5 -> 31.9 65.3 -> 58.8 70.8 -> 58.0
Gemma 4 full, 8 x 8k 261.1 -> 253.8 197.6 -> 193.1 363.5 -> 360.6 337.7 -> 336.2
gpt-oss full 64/8/64 sinks, 1 x 8k 39.4 -> 27.8 26.3 -> 16.9 39.0 -> 31.2 36.1 -> 30.4
gpt-oss sliding 128 sinks, 64 x 1k-16k 32.9 -> 31.0 25.9 -> 23.1 42.4 -> 41.5 44.9 -> 38.2
All 68 points: geomean / worst, new / old 0.943 / 1.002 0.927 / 1.000 0.963 / 1.023 0.949 / 0.999

The worst case, 1.023, is the 32/8/128 shape at 1 x 300 on the 4090, which is 0.2 us slower.

Qwen3.6 full-attention shape against flashinfer, as the fi backend calls it (graph decode wrapper, page 1, fa2), us, fi / this commit:

Batch x context RTX PRO 6000 RTX 5090 RTX 4090 RTX 3080 Ti
1 x 1k 15.6 / 12.8 11.3 / 9.7 14.7 / 11.4 17.3 / 14.9
1 x 8k 33.7 / 29.7 21.9 / 18.7 37.5 / 33.4 48.1 / 34.0
1 x 32k 81.2 / 75.7 62.3 / 50.7 113.1 / 106.6 108.6 / 92.4
1 x 128k 253.2 / 245.0 188.1 / 175.8 356.4 / 346.0 340.9 / 326.8
4 x 32k 249.2 / 244.3 183.6 / 179.5 353.8 / 352.8 333.4 / 328.9
64 x 1k-16k 824.7 / 859.1 725.8 / 754.5 1283.2 / 1316.8 1490.6 / 1411.1

Before this commit, triton was 11 to 23% slower than flashinfer at 1 x 8k on the PRO 6000, 5090 and 4090. Now it is faster at batch 1 on all four cards and within 4% at batch 4 and above. The fa and trtllm backends do not run on these GPUs.

End-to-end

Qwen3.6-35B-A3B-NVFP4 with --attention-backend triton, on the RTX PRO 6000, using the same flags and client as the PR body. Decode tok/s at batch 1, median of 3 runs:

Context main 855aedd 2bd6c33 fi
1k 241.5 244.7 245.1 243.7
8k 209.6 234.2 239.6 237.4
32k 144.8 218.2 222.7 218.0
64k 105.0 203.8 208.0 204.0
128k 66.7 173.5 176.5 173.7
192k 48.7 150.9 153.3 150.6

Other checks

  • PYTHONPATH=python pytest tests/kernels/test_triton_attention.py passes (41) on sm_120.
  • Relative error against an fp32 reference is unchanged: at most 0.0023 on every card and shape, the same as 855aedd.
  • The PR body's sentence "Cases that keep main's split layout and key tile give bit-identical output" no longer holds. A tile is summed as a tree, while main merges one split at a time. Output is bit-identical to main only when a request has a single valid split.
  • Stage 2 builds compile for sm_80, sm_90, gfx942 and gfx1100 with no spills. ROCm was not run.

Setup: driver 595.91 (PRO 6000), 595.71 (5090, 3080 Ti) and 580.76 (4090); torch 2.14.1+cu130, Triton 3.8.0, flashinfer 0.6.18.post1.

Not in this commit

Two things still differ from the best config in the sweep, on all four cards:

  • At mid batch the split cap still over-splits. Gemma 4's full layers at 8 x 8k remain 3 to 8% slower than main, while 8 splits is best.
  • With stage 2 now cheap, short requests gain from splits shorter than 128 keys. At 1 x 1k, 32 splits of 32 keys is best: 9.7 -> 7.3 us on the 5090.

One rule fits both on all four cards: about 2 programs per SM, at most 32 splits, _MIN_SPLIT_LEN 32. It cuts the mean loss against the best config per point from 3.3% to 1.5%. I can send that as a follow-up.

@jason-fxz

Copy link
Copy Markdown
Collaborator Author

RTX 4060 Laptop (sm_89, 24 SMs)

Kernel only. Qwen3.6 full-attention shape 16/2/256, bf16, batch 1, us, one CUDA graph replay after an L2 flush, median.

Context main 2bd6c33 fi
1k 29.7 25.6 28.7
4k 79.9 59.4 66.6
16k 235.5 200.7 207.9
32k 428.0 380.9 386.0
128k 1454.1 1196.0 1200.1
256k 2825.2 2276.4 2286.6
  • The PR is 1.12x to 1.34x faster than main at batch 1, and 1.05x to 1.09x faster at batch 4.
  • It matches fi from 32k up, and at 128k and above both reach 94–99% of the measured read bandwidth (238 GB/s). At 16k and below it is 4–20% faster than fi.
  • The gain is smaller than on H100 because main's 16 programs already cover two thirds of the 24 SMs.

@jason-fxz
jason-fxz merged commit 11c1adb into main Oct 17, 2026
3 checks passed
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 17, 2026
Upstream 0bca88f..11c1adb: GGUF on vendored ggml (FlashML-org#690 and the per-arch
loaders), MoE slot pools (FlashML-org#689, FlashML-org#700), GGUF experts on the cpu/hybrid
executor (FlashML-org#704), the NVFP4 MoE prefill GEMM rewrite (FlashML-org#723), triton decode
split-k by context length (FlashML-org#729), graph upload at capture (FlashML-org#697),
transformers 5.18.

Resolution:
- GGUF: upstream's stack replaces ours (dense llama/qwen2/qwen3, qwen3moe,
  qwen35moe loaders, q4_0/gguf expert banks, our ggml kernels and their
  tests). Re-ported on top: rope-only --hf-overrides (YaRN) for the
  llama/qwen2/qwen3/qwen3moe GGUF parsers, and the scalar head_count_kv of
  Gemma-4 GGUFs (upstream PR FlashML-org#190). Gemma-4 E2B/E4B GGUF is not re-ported.
- offload cache: upstream's pooled cache, with our disk tier (one pool
  only), fill_slots preload per pool, sync-free prefill invalidation for a
  contiguous staging range, hit-D2D-by-default wording and the HIP graph
  per-bank copy.
- NVFP4 prefill: upstream's GEMM and per-GPU tuning supersede our sm_89
  tile config and JOIN_B.
- triton decode attention: upstream's split-k over our fp8/nvfp4 KV paths
  and HIP head padding; DFlash verify graphs are uploaded at capture too.
- embedding: upstream's embedding quant method, with --embed-device cpu in
  front and the tied head's kernel choice kept for unquantized tables.
- engine: DFlash, --kv-reserve-share, shrink-first rebuild, startup hybrid
  split and the WSL pin probe combined with the slot pools.

GPU-checked on the RTX 4090: full suite green; Qwen3.6-35B-A3B NVFP4
prefill ~13.2k / decode ~131 tok/s (baseline 13.6k / 131), Qwen3-30B-A3B
Q4_K_M GGUF 14.6k / 259 (was 8.3k / 225).

Assisted-by: Claude Opus 5.5
pretor pushed a commit to pretor/FreeToken that referenced this pull request Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant