Skip to content

dory: full-attention KV in pages of the core pool - #1284

Closed
jschmied wants to merge 5 commits into
ashhart:mainfrom
jschmied:dory-paged
Closed

jschmied wants to merge 5 commits into
ashhart:mainfrom
jschmied:dory-paged

Conversation

@jschmied

Copy link
Copy Markdown
Contributor

Follows #1233 (Dory, landed in #1256-#1261): the full-attention caches move into pages of the core KV pool. Two commits on main 2ff019107: b5eadd6a1 (the window_pages kernels, contract registrations and records) and 09852121d (Dory on pages, with tests).

What this changes

At 2 loops a Dory stream's 14 full-attention caches take 56 KiB a token (14 GiB at 256K; 24 GiB at 4 loops).
Only these page. The 42 sliding rings are a fixed 3,072 keys (504 MiB a stream), whatever the context, and their writes
wrap, so they stay one buffer a slot and count as the layout's fixed bytes.

  • A page holds 256 positions of every full cache (14 MiB at 2 loops). 256 is window_rows.cu's decode chunk, so a chunk and a
    prompt tile never straddle a page.
  • Loaded.layout with engine_paged (as GLM's): every stream, the first included, leases its pages from kv_pool.Pool at its
    prompt pass (prompt, reply, one verify window) and returns them when it ends. The plan sets one window of pages aside, so one
    stream can always reach the context. A request that cannot lease waits (LaneMemory); fits is the host's admission. --parallel
    counts a slot's rings and table row plus a page each; the prompt cache gets the rest.
  • Kernels: window_rows.cuh takes a Pg template switch; the head dim 256 decode partials, merge and prompt tiles are new kernels in
    a new image window_pages.cu (tf_wrows_part_256_pg, _merge_256_pg, _prompt_256_pg), and tf_wrows_put writes k/v rows into
    the pages. Only addresses change; no sum changes order. With the switch off, every kernel in window_rows.cu and
    contract_controls.cu has identical SASS on sm_80/86/89/120/121. WindowAttention.loadPages loads the image on demand (Dory only).
  • Prompt-cache states copy page runs in the contiguous layout, so a kept state moves between the two forms.
  • Paged is the default for tensorfold-native; TENSORFOLD_DORY_KV_PAGES=0 keeps every stream contiguous and allocated at open.
    tensorfold run stays contiguous.

Receipt

Environment: one DGX Spark (GB10, sm_121, CUDA 13.0, nvcc 13.0.88), Dory-1.0-NVFP4 (ModelOpt), greedy unless noted.

  • tf-cuda-test dory-pages (new), L = 1, 2, 4: a contiguous and a paged model over 4 slots on shuffled pages (prompts 255..3,100 rows,
    page edges at 255/256/257, 64 rounds of 1-16 rows): every row's fp32 logits bit-equal. Then the lane core over 1/3/8 streams,
    greedy, sampled and drafted: replies equal, pool idle.

  • On pages (DORY_PAGED=1), L = 1, 2, 4: dory-serve: eager == graphs, drafted == plain, concurrent == solo at 2/4/8 (with and without
    drafts). dory-states: resumed == fresh at 8,000, 8,192 and 32,001 into caches wiped to 0xFF, greedy and sampled.

  • Served (tensorfold-native, 2 loops, 24 GB budget, 128K window, --parallel 4): contiguous refuses ("needs 30.63 GiB ... leaves 21.02
    GiB: 2 streams fit"); paged starts (3 more taking 1,360 pages, 18.59 GiB, enough for one 131,072-token window). An 85k-token prompt
    with three of 15-19k concurrently, and four 50-58k prompts (which cannot all fit; they wait for pages), all equal their solo replies.
    Chat resume: 18,503 of 18,943 prompt tokens cached, reply equal to a fresh server's (0.40 s against 4.7 s).

  • Decode (dory-bench, 2 loops, no drafts, tok/s together, 3 ABAB rounds):

    streams contiguous paged
    1 44.1 / 43.5 / 43.5 43.8 / 43.7 / 43.7
    8 326.9 / 326.1 / 326.5 323.7 / 324.4 / 324.6

    Means 43.7 vs 43.7 and 326.5 vs 324.2 (-0.7 %); contiguous alone moves 0.5 %.

  • Prefill (dory-prefill, 2 loops, prompt time alone, best of three passes per run, 3 alternating runs, ms):

    rows contiguous paged
    512 94.8 / 95.8 / 96.2 95.4 / 95.6 / 96.1
    2,048 369.5 / 371.5 / 377.4 371.7 / 372.7 / 377.8
    8,192 1665.9 / 1687.1 / 1683.9 1664.6 / 1679.3 / 1688.5
    16,384 3779.1 / 3800.2 / 3820.3 3749.4 / 3769.2 / 3786.0

    Within +-0.3 % at every length (16k is 0.8 % faster paged), inside the contiguous arm's own spread; the extra spill (56-byte
    stack) costs nothing measurable.

  • Kernel contract: the four new launches pass gates 1-4 at pages of 256 and 1,024 positions (decode and prompt). tf_wrows_put places
    the caches in a shuffled arena bit for bit before the attention kernels read them. Two controls (two streams' tables swapped, a stale
    page id) go red at gate 3. Full run on this branch: 340 kernels, 338 pass, 91/91 controls red; the two failures are w4a8's
    planted-non-finite cases on main, and the 143 limit problems are the launches main keeps pending (none is a window_pages launch).
    Launch records are from nvcc 13.0.

  • Tests: zig build test -Dnvcc=/usr/local/cuda/bin/nvcc from the repo root exit 0; zig fmt --check clean; lean check 0 new.

Gotchas

  • Pages are leased whole at the prompt pass, not grown while decoding, and the prompt cache still copies rows (no page sharing).
  • At most 8 streams (a round's rows over 16-row windows), as before.
  • Records are nvcc 13.0 only; there is no nvcc 13.2 on this box.
  • The paged prompt kernel uses 255 registers with a 56-byte stack on sm_121 (the contiguous one 32 bytes); prefill is within noise
    (table above), so no split was made.

Checklist

  • Every token still goes through the lane rounds.
  • Drafted == plain, resumed == fresh, concurrent == solo, paged == contiguous (logits bit-equal).
  • No precision traded for speed; the bits do not change.
  • New kernels are registered with the kernel contract, with red controls.
  • Decode within noise of contiguous.
  • Platforms: CUDA on GB10 (sm_121), one rank; other SMs through recorded compiles; no Metal change.
  • Comments are one line; lean check 0 new.
  • No personal data, machine names, internal hosts or local paths. No AI attribution lines.

…nd the writer that places rows

window_rows.cuh's decode partials, merge and prompt tiles take a Pg (a stream's row of page ids,
elements a page, positions a page) as a template switch. With the switch off every kernel in
window_rows.cu and contract_controls.cu compiles to the same SASS on sm_80/86/89/120/121 as before;
the paged head dim 256 kernels are the new image window_pages.cu:

- tf_wrows_part_256_pg, tf_wrows_merge_256_pg, tf_wrows_prompt_256_pg: a decode chunk and a prompt
  tile lie in one page (pages hold whole 256-key chunks), so a page id is read once a chunk or tile.
- tf_wrows_put: k and v rows into the pages at their positions, a block a row, 16 bytes a thread.

WindowAttention.loadPages loads the image on demand (only Dory asks); Window.pg selects the paged
kernels, and the unpaged calls are unchanged.

All four are registered with the kernel contract: tf_wrows_put places the caches of each case in a
shuffled arena bit for bit, then the attention kernels read them through the table, each output
within float64's range, at pages of 256 and 1,024 positions. Two controls (two streams' tables
swapped, a stale page id) go red at gate 3. Launch records from nvcc 13.0's sm_80/86/89/120/121
images.
…am leasing, a window set aside for the first

At --loops 2 and 256K a stream's 14 full-attention caches take 14 GiB; the 42 sliding rings are a
fixed 3,072 keys (504 MiB) whatever the context, so only the full caches page. A page holds 256
positions of every full cache (K then V each), 14 MiB at 2 loops, 24 MiB at 4; the sliding rings
stay one buffer a slot, because they do not grow and their writes wrap.

- Loaded.layout with engine_paged, as GLM's: the first stream's rows are pages too, and the plan
  sets one window of them aside, so one stream can always reach the context. Every stream leases
  its pages from kv_pool.Pool at its prompt pass (prompt, reply and one verify window) and gives
  them back when it ends; a request that cannot lease waits (LaneMemory), and F.fits is the host's
  admission. --parallel counts a slot's rings and table row and a page each; the prompt cache gets
  the rest (kept states are copies of varying size: snap_bytes 0).
- One table row a slot, so a decode round mixes streams in one launch. Model.attach makes the arena,
  the tables and the rings of the slots the plan names; forward refuses rows past a slot's pages.
  Positions are written by tf_wrows_put and read through the table by the paged window kernels; the
  bits do not change.
- Prompt-cache states copy page runs in the contiguous layout, so a kept state moves between the
  two forms.
- TENSORFOLD_DORY_KV_PAGES=0 keeps every stream's caches contiguous and allocated at open (what
  `tensorfold run` and the checks' contiguous side use).

tf-cuda-test dory-pages runs one model contiguous and one paged over four slots on shuffled
pages (prompts to 3,100 rows, page edges at 255/256/257, 64 rounds of 1 to 16 rows) and then the
lane core over 1/3/8 streams, greedy, sampled and drafted: logits bit-equal, replies equal, the pool
idle. DORY_PAGED=1 runs dory-serve and dory-states on pages.
@ashhart

ashhart commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. We ran this on a GB10 with the Dory 4B NVFP4 checkpoint against main, in alternating base/new runs.

Exact, with one small cost.

  • Tokens: identical (0 of 12 rows differ). Chat with thinking off and on, and the 16k needle, qualify on both builds.
  • Decode: the 1/2/4/8-stream sweep is within 0.4%.
  • 2k prefill: consistently a little slower. In a three-and-three alternating rerun, the new runs read 5,319 / 5,301 / 5,284 tok/s against base 5,390 / 5,338 / 5,331, about −0.6% to −1%. 8k is flat (+0.2%).

Our rule is that a merge doesn't regress anything, however small. Could the full-attention rows keep main's contiguous path wherever paging isn't needed (the first stream's window, or any stream whose rows fit its set-aside window), and go through the page table only past that? That's how the Qwen paging slice ended up flat. Once 2k is level with main we'll land it straight away.

- Synthema, Ash's AI Assistant.

…of a step while one is free

findAligned finds the highest such run; claimRun's body moves to claimAt, unchanged. A lease that
already holds pages, or finds no free aligned run, falls back to cover, so nothing waits on it.
…s path, the table only past that

Review of ashhart#1284: 2k prefill read -0.6 to -1 % against main. The arena is now cache-major (each full
cache's K over every page, then V), so page ids p..p+n-1 are rows p*256.. of one buffer, and a lease
taken as one run from a multiple of pagesOf(context) is exactly main's slot w of a contiguous cache.
Streams lease that way while a run is free (kv_pool.coverRun), the first stream always.

- A batch whose every stream sits in such a window runs main's kernels on the window index as the
  slot: the fused row writes, tf_wrows_prompt_256 and the contiguous decode, no tf_wrows_put, its own
  graphs (flat_graphs). Any stream on scattered pages sends the whole batch through the table, which
  is valid for the windowed streams too: one launch set, no split, graphs keyed by rows as before.
- Prompt-cache copies coalesce consecutive page ids into one copy.

dory-pages maps slots 0, 1 on windows and 2, 3 on shuffled pages and adds rounds of a lone stream and
a windowed pair; the lane-core replies run scattered and windowed. DORY_SCATTER=0 runs dory-serve and
dory-states on windows.
@Maxrio9

Maxrio9 commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

The new paged prompt cases include k = 0 and are appended after the existing cases.
The inherited position expression evaluates modulo zero for that input; on x86_64, the full run crashes at an existing zero-prefix prompt before reaching the new paged cases (see #1316).
A CPU first-row probe returned zero on ARM in ReleaseFast, while Debug diagnosed division by zero on both architectures; that silent fast-mode result does not validate the calculation.
The prompt check still uses p0 + r, and the prompt launch does not read the decode pos array.
The independent fixture guard is in #1318 and merges cleanly with this branch; once it lands, or if you'd rather carry the same guard here, the new zero-prefix cases can be exercised.
Device reproduction was on current main. This PR head's device behavior remains unqualified.

…s a later page

At 256 rows and no prefix the table holds one page, so ids[1] read past it: a Debug panic, undefined in
ReleaseFast. With one page the control is planted from the next served shape on, where it stays red.
@jschmied

Copy link
Copy Markdown
Contributor Author

Thanks, done in three commits on top (no rebase): 9f00cc6, 9564058, 79ea7b2.

What changed. The arena is now cache-major (each full cache's K over every page, then V), so page ids p..p+n-1 are one contiguous run of rows. A stream leases its pages as one run starting at a multiple of pagesOf(context) whenever one is free (kv_pool.coverRun; the first stream always gets one), and such a stream is exactly main's slot w of a contiguous cache. When every stream in a batch sits in such a window, the step runs main's kernels with the window index as the slot: fused row writes, tf_wrows_prompt_256, the contiguous decode, no tf_wrows_put, its own graphs. If any stream is on scattered pages, the whole batch goes through the page table. The table is also valid for the windowed streams, so it stays one launch set: no split launches, and graphs are keyed by rows as before. I picked that for mixed batches because splitting would mean two launches per layer and twice the graphs, all to speed up a case that only shows up once the pool is fragmented.

Where the 2k cost was. nsys on the PR as it was, 2k prefill, L=2, per pass: paged attention 48.1 ms vs contiguous 47.95 ms. tf_wrows_put adds 0.56 ms, and the paged prompt kernel is 0.4 ms faster than the contiguous one on the full layers, so the GPU-side difference is +0.15 ms of 380 ms (0.04%). Served on my box, the PR showed no 2k loss against main (6+6 alternating: 4765 vs 4774 tok/s). The loss I could reproduce was c=8 decode, −0.6% (base 309.5–310.3, PR 304.9–309.1), from multi-stream rounds going through the table. The fast path removes both.

Served, alternating base/new, 3+3 (dory-nvfp4 L=2, --parallel 8, --context 32768; prefill = median tok/s of 8 cold prompts; decode = aggregate tok/s, 256 tokens, temp 0):

run 2k prefill 8k prefill c1 c2 c4 c8
main 1 4835 4686 45.4 88.7 170.3 310.1
new 1 4825 4692 45.6 89.2 170.8 310.6
main 2 4820 4666 45.5 89.2 170.8 310.9
new 2 4833 4648 45.4 88.7 171.3 311.4
main 3 4833 4665 45.8 89.6 171.8 311.5
new 3 4793 4658 45.6 89.3 170.8 310.2
mean Δ −0.26% −0.14% −0.07% −0.11% 0.00% −0.03%

Two of the three new 2k runs fall inside main's range (4820–4835). The third (4793) is low, and its 8 samples run 4751–4851. Main's own 2k runs on this box today span 4726–4835.

Gates (L=1/2/4, all pass):

  • dory-pages: paged == contiguous, logits bit-equal over 4 slots (2 on windows, 2 on shuffled pages, rounds of 4 mixed, 1 alone and a windowed pair, page edges 255/256/257). Lane-core replies equal at 1/3/8 streams, greedy/sampled/drafted, both scattered and windowed. The pool is idle at the end.
  • dory-serve (scattered and windowed): eager == graphs, drafted == plain, concurrent == solo at 2/4/8.
  • dory-states (scattered and windowed): resumed == fresh at 8000/8192/32001 into wiped caches.
  • 4×128K in 24 GB: starts, and 4 of 4 concurrent replies equal solo.
  • Kernel contract: no .cu changes in these commits; the window_rows/window_pages/contract_controls fatbins and the window_rows SASS match the PR's.
  • zig build test -Dnvcc=… from the repo root exits 0; lean shows 0 new problems.
  • Merged locally with main (incl. 3cb8cb5 and 553c8bb; the only conflict is the usage text in tests/cuda/main.zig, keep both sides), window_rows/window_pages contract (25 kernels, 11 controls): ReleaseFast 25 pass, 11 red; Debug 25 pass, 11 red. The paged prompt cases run every served shape, k = 0 among them. On the merge: dory-pages L=2, dory-serve L=2 (windows) and 4×128K all pass, and test-kv-pool passes.
  • One more commit, 79ea7b2: in Debug the stale-page control indexed ids[1] on the one-page k = 0 shape (in ReleaseFast it was undefined). It is now planted from the next shape on, and still goes red at 128x4096.

@Maxrio9 thanks, with #1316 fixed on main (3cb8cb5) the new zero-prefix cases now run; results are in the merge line above. One more Debug-only crash, on main and not in this PR: zig/tests/cuda/contract_window_rows.zig:249 (main ab864d4), in the setup for the all-keys-alike sums check, the @memcpy loop starts at row 0 and copies that row onto itself. Debug panics at the first window_rows decode case, so in Debug the full contract never reaches the paged cases. Starting the loop at 1 fixes it; I only applied that on the local merge.

ashhart pushed a commit that referenced this pull request Oct 26, 2026
…s path, the table only past that

Review of #1284: 2k prefill read -0.6 to -1 % against main. The arena is now cache-major (each full
cache's K over every page, then V), so page ids p..p+n-1 are rows p*256.. of one buffer, and a lease
taken as one run from a multiple of pagesOf(context) is exactly main's slot w of a contiguous cache.
Streams lease that way while a run is free (kv_pool.coverRun), the first stream always.

- A batch whose every stream sits in such a window runs main's kernels on the window index as the
  slot: the fused row writes, tf_wrows_prompt_256 and the contiguous decode, no tf_wrows_put, its own
  graphs (flat_graphs). Any stream on scattered pages sends the whole batch through the table, which
  is valid for the windowed streams too: one launch set, no split, graphs keyed by rows as before.
- Prompt-cache copies coalesce consecutive page ids into one copy.

dory-pages maps slots 0, 1 on windows and 2, 3 on shuffled pages and adds rounds of a lone stream and
a windowed pair; the lane-core replies run scattered and windowed. DORY_SCATTER=0 runs dory-serve and
dory-states on windows.
ashhart added a commit that referenced this pull request Oct 26, 2026
…tract_tests.zig stays at 600 lines (#1284)

Main grew contract_tests.zig to 600 lines after #1284's base; its added loadPages line made it 601, a new lean finding on the rebase. contract_window.zig now loads window_attention.cu, window_rows.cu and window_pages.cu in one call. No behaviour change.
ashhart added a commit that referenced this pull request Oct 26, 2026
… caches stay the default (#1284)

Paging changed Dory's default open, memory plan and admission; new behaviour is off by default until device checks qualify it. Unset or 0 keeps main's contiguous caches; any other value takes the pool's pages. Mirrors the device tests' DORY_PAGED switch.
@ashhart

ashhart commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Landed on main, thanks @jschmied, with two fix-ups on top: paging is opt-in with TENSORFOLD_DORY_KV_PAGES=1 (the PR turned it on by default, which changes every Dory deployment's plan and admission), and the contract runner's window loader takes window_pages.cu so contract_tests.zig stays within 600 lines. Device checks on a GB10 with Dory 1.0 4B NVFP4: the default path against main gives identical tokens with prefill and the 1/2/4/8 sweep flat in an alternating recheck, cuobjdump -sass of window_rows.fatbin is identical, and paging on against off gives identical tokens. Open follow-ups from review: derive max_slots from the round geometry instead of 8, refuse attach when context != per_slot * 256, and only load window_pages when paging is on.

ashhart added a commit that referenced this pull request Oct 26, 2026
@ashhart ashhart closed this Oct 26, 2026
ashhart pushed a commit that referenced this pull request Oct 27, 2026
…differently, five-SM RECORD lines

tf-cuda-test kernel-contract-limits on nvcc 13.3's fatbins for sm_80/86/89/120/121 found 539 stale records on
31a864f (543 with #1284's window_pages launches): 206 launches whose register counts (and a few static shared sizes)
differ from the 13.2 or 13.0 records they were checked against. The 156 ordinary ones go into a 13.3 set
(nvcc_133_records.zig), the 50 pending ones into a 13.3 receipts file the pending lookup reads
(measurements/pending_133.zig), as qwen_lanes_133.zig's decode receipts do; the tests that pinned those launches as
unmeasured under 13.3 now expect the receipts.

The tool's RECORD line always carries five entries: an SM a launch does not apply to keeps the record the compiler is
checked against, so the line pastes as an Override (it held one or two entries for cluster and GB10-only launches).
ashhart pushed a commit to jschmied/TensorFold that referenced this pull request Oct 30, 2026
…refuses a context off the page size, window_pages only with paging (ashhart#1284)
ashhart pushed a commit that referenced this pull request Oct 30, 2026
…refuses a context off the page size, window_pages only with paging (#1284)
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.

3 participants