Skip to content

qwen CUDA: paged prompt chunks on the tensor-core prompt tiles, bit-equal to contiguous - #1418

Closed
jschmied wants to merge 2 commits into
ashhart:mainfrom
jschmied:paged-prompt-attn
Closed

jschmied wants to merge 2 commits into
ashhart:mainfrom
jschmied:paged-prompt-attn

Conversation

@jschmied

Copy link
Copy Markdown
Contributor

What

With TF_QWEN_KV_PAGES on a stream (every stream under TF_QWEN_KV_PAGES_ALL or TF_QWEN_KV_SHARE), a Qwen3.5
prompt chunk currently goes through the scalar paged decode partials plus their merge. That costs about 2x TTFT
(the 27B measurement on #1357's original head a1804ab: cold 8k 7.3 s contiguous vs 15.7 s paged; warm turn 0.75 vs 2.2 s). This PR adds paged twins of the
contiguous prompt kernels and routes one-part paged prompt chunks to them.

  • hpattn_paged_kernel / hpattn8_paged_kernel<256, 8, 2, 2> (prefill_attention.cu): hpattn_kernel /
    hpattn8_kernel with CAP = ptok plus a page table. A qwen3_5 page stores each key head's ptok rows
    consecutively (qwen35.cuh kv_row), which matches a contiguous cache of cap = ptok. Only the global address
    each 16-byte chunk is staged from changes: PT[row / CAP] * PS, with the page id looked up once per 32-key
    staging item and a per-row lookup only where an item straddles a page. The kernel folds the same 64-key tiles in
    the same order, so paged == contiguous, bit for bit.
  • prefill_attention_rows.cuh: the head-major address takes the page term. For the existing kernels PT is the
    constexpr null, so the term folds away. Every existing function's SASS is unchanged on
    sm_80/86/89/120/121; 0 changed functions across all 97 fatbins (sm_121); 2 added.
  • qwen3_5: cuda_prompt.run launches the paged twin when the stream's Seg has a table. cuda_forward takes the
    prompt branch for a one-part paged prompt chunk ahead of the scalar paged partials. Windows of several parts
    (verify) keep the paged partials.

No family changes its bytes. A paged Qwen stream now produces exactly the contiguous stream's prefill outputs,
where today it does not.

Kernel contract

zig/tests/cuda/contract_prompt_pages.zig:

  • registers hpattn_kernel / hpattn8_kernel at the 27B's 24/4 heads against float64 (attention_ref bound,
    gates 1-4);
  • registers the two paged twins, at pages of 256 and 48 positions shuffled in an arena with spare junk pages, each
    replaces its contiguous kernel bit for bit at 14 shapes, including 4096-row chunks and resumes at 2048/4096;
  • plants a red control: a table whose first page id names a later page.

Launch records are in prompt_pages_records.zig (nvcc 13.0, plus 13.2 overrides).

  • Filtered run: 9 kernels pass gates 1-4, and the control is red.
  • Full run: 346 kernels pass gates 1-4, 0 fail, 95/95 controls red.
    • The 52 limit problems are all pre-existing qwen35 records, the same count as on main.
    • This PR's four launches pass live limits.

Gates (server, greedy, first-row logits hash of every prefill + reply text)

arm vs prefill logits texts c1/2/4/8 == solo
27B bf16 contiguous (this branch) main equal equal yes
27B bf16 PAGES_ALL (256) main contiguous equal (all) 1 of 17 differs, decode-only yes
27B bf16 PAGE_TEST=48 main contiguous equal (all) same 1 differs yes
27B bf16 PAGES_ALL, --no-drafts / no prefix cache main contiguous equal (all) same 1 differs yes
27B bf16 SHARE=8 (on the KV-share stack) main contiguous equal (all) same 1 differs yes
27B fp8 KV PAGES_ALL main fp8 equal equal yes
35B-A3B PAGES_ALL main contiguous equal (all) 2 of 17 differ, decode-only yes
  • Coverage: turn-2 resumes at 4096/8192, a 20k needle, and a cold turn 2 without the cache (== cached).
  • drafted == plain, resumed == fresh.
  • The reply-text differences come from decode on pages, which still uses the scalar paged decode partials. The
    control shows this predates the PR: main with PAGES_ALL on the same short prompts gives the same divergent text.
    On main, paged prefill logits also differ for those prompts; this PR makes them equal.

Speed (TTFT s, fresh servers alternating, 2+2 for 27B and 3+3 for 35B; texts and logits identical across all arms and rounds)

model arm 2k 8k 32k warm turn (7.97k, 4096 cached)
27B main contiguous 1.86 / 1.63 6.98 / 6.52 29.38 / 27.93 3.26 / 3.23
27B PAGES_ALL (this PR) 1.60 / 1.60 6.46 / 6.36 27.79 / 27.49 3.17 / 3.16
27B SHARE=8 (stack) 1.59 / 1.61 6.38 / 6.50 27.37 / 27.44 3.17 / 3.17
35B-A3B main contiguous 0.389 / 0.393 / 0.385 1.45 / 1.47 / 1.42 6.42 / 6.62 / 6.80 0.704 / 0.727 / 0.698
35B-A3B PAGES_ALL (this PR) 0.395 / 0.388 / 0.388 1.44 / 1.43 / 1.44 6.61 / 6.56 / 6.62 0.714 / 0.714 / 0.717
35B-A3B SHARE=8 (stack) 0.390 / 0.388 / 0.390 1.42 / 1.41 / 1.42 6.45 / 6.41 / 6.46 0.713 / 0.709 / 0.713

Paged is within noise of contiguous at every length (the 27B main round 0 is the slower cold first start). For
scale: on the scalar paged partials, a1804ab's own 27B measurement (its prompt set) had cold 8k at 15.7 s paged
vs 7.3 s contiguous, and a warm turn at 2.2 vs 0.75 s.

Dependencies / notes

…he contiguous kernels' bits

hpattn_paged_kernel and hpattn8_paged_kernel<256, 8, 2, 2> read the qwen3_5 pool's pages (qwen35.cuh kv_row:
a page holds each key head's ptok rows in a run, as a cache of cap ptok does). They are hpattn_kernel and
hpattn8_kernel with CAP = ptok and a page term on each row's address: the same 64-key tiles in the same order,
only the global address a 16-byte chunk is staged from moves, so their outputs are the contiguous kernels' bytes.
prefill_attention_rows.cuh's head-major address takes PT[row / CAP] * PS (bytes for e4m3, whose fp32 scales sit
at PS / 4 floats a page), the page id read once a 32-key staging item and per row only where an item straddles
a page; with PT the constexpr null of the existing kernels it folds away: every existing function's SASS is
unchanged on sm_80/86/89/120/121, every other fatbin byte-identical.

Kernel contract: hpattn_kernel and hpattn8_kernel at the 27B's 24/4 heads against float64 (attention_ref's bound,
gates 1-4), and the paged twins at pages of 256 and 48 positions, shuffled in an arena with spare junk pages, each
replacing its contiguous kernel bit for bit at 14 shapes up to 4096-row chunks; control: a table whose first page
id is a later page's. Launch records for all four, with nvcc 13.2 overrides.
With TF_QWEN_KV_PAGES on a stream (every stream under TF_QWEN_KV_PAGES_ALL or TF_QWEN_KV_SHARE), a prompt chunk
went through the scalar paged decode partials and their merge, about twice the contiguous chunk's time. A one-part
prompt chunk now runs hpattn_paged_kernel (or hpattn8_paged_kernel for --kv-cache fp8) over its pages: the launch of
the contiguous chunk at cap = the page's positions, from page 0 of the arena, with the stream's table. Its bytes are
the contiguous chunk's, so a stream's outputs no longer depend on whether it holds pages. Windows of several parts
keep the paged partials.

Every prefill's first-row logits on pages (27B bf16 and fp8, 35B-A3B; pages of 256 and 48; with and without
drafts and the prefix cache) equal main's contiguous ones. TTFT on pages is within noise of contiguous at 2k, 8k,
32k and a warm turn (27B 32k 27.8/27.5 s vs 29.4/27.9 s; 35B 6.61/6.56/6.62 s vs 6.42/6.62/6.80 s).
@jschmied

Copy link
Copy Markdown
Contributor Author

Closing: 0843339 (#1373) gives paged prompt chunks the tensor-core tiles over the page table (and the decode tiles too), and contract_qwen_pages checks them against contiguous, which is what this PR did. The rest here (float64 gates for the twins, shuffled 48-position pages) is not worth a rebase on its own.

@jschmied jschmied closed this Oct 27, 2026
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