Skip to content

flashnext: the MTP head's streams by row through the lease's pages, no per-lane block outside the pool (#2444 P3, F2 part c) - #2992

Open
jschmied wants to merge 11 commits into
ashhart:mainfrom
jschmied:flashnext-head-paged
Open

jschmied wants to merge 11 commits into
ashhart:mainfrom
jschmied:flashnext-head-paged

Conversation

@jschmied

@jschmied jschmied commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Stacked on #2898 (F2, head 59ba8b4). The two land together, as ashhart asked in #2444 (comments 6796185011 and
6824699204). This PR's first five commits are #2898's; the six after them are (c).

On #2898 with TF_FLASHNEXT_STATE_PAGES=1, two of the MTP head's buffers are each one matrix wider than a page:
streams (31.5 MB at 1536-row chunks) and the window's logits (15.9 MB). They stayed outside the pool as a lane's own
block (option (a)), and at a 77 GB limit that block cost a concurrent stream (fit table on #2444, comment 6796148352).
This PR removes the block:

  • streams lives in the lease as 48 pieces of 32 rows (20,480 B a row), and the head reads it by row through the page
    table. Block 0 holds rows 0..31, so every window (at most 32 rows) reads and writes there exactly as before. A prompt
    pass writes a chunk's rows block by block. A head prefill piece whose rows cross a block is first gathered into the
    pass's s.h rows. The piece doesn't touch those rows until after it has read its input.
  • logits: a paged draft keeps no scores of its own. Its window's scores stay where the verify pass already writes them,
    the lanes' shared 64-row buffer (verifyMany's out, which main copies each lane's rows from). The draws read them
    there with the same launches as main, before the next verify reuses the buffer. A lone window (a round with one
    drafting stream) scores into the same buffer through Ops.verifyIn. The plain single-stream verify refuses a paged
    draft by name (FlashNextPagedDraftVerify), which keeps the rank path and the tests honest.

An idle pool lane now holds nothing outside the pool, and the layout's fixed part is main's again ("0 MiB fixed each" in
the load line, the same as main).

Saving 3 pages a stream isn't possible: main's 48 is the floor, and pieces reach it. With the head and pending rows
(the default), a lane's state plus head is 374,645,504 B, which is 47.54 pages of 7,880,704 B (256-row pages with the
head). Main's run is 48 pages. Pieces pack to 48 as well: 271 pieces, 342.22 MiB on 48 pages, 18.53 MiB of padding. That
holds even though the 15.9 MB of scores no longer live in the lane, because the 3 MiB DeltaNet states leave gaps the
rest can't fill, so first-fit-decreasing still needs 48 pages. With TF_FLASHNEXT_KEEP_LATE=0 it is 234 pieces on 41, the same as the run's 41. So F2 + (c) ties main on
bytes. What pieces add is the thing #2898 is for: a lane opens on any free pages, with no consecutive run.

No kernel changed. No file under zig/kernels, zig/build or build.zig differs from main. The 114 fatbins
embedded in the served binary are byte-identical to main 3885dce's, in the same order (md5 of each blob). The kernel
contract rows are unchanged, so no new contract-runner rows or limits apply.

Unset (TF_FLASHNEXT_STATE_PAGES off), every new branch is skipped (no blocks, logit_paged false), and windows, draws
and prompt passes run main's code. The kit and A-B-B-A rows below show the bits and the speed both ways.

Core vs family:

part where
Rows: a matrix as row blocks (sizes, a run that never crosses a block, a row's address); Placed: those blocks placed, walked from any row core: core/kv_pieces.zig
host tests: block sizes and runs, addresses through any pages; a walk from any row, with planted swapped blocks and a lost offset that must fail core: core/kv_pieces_test.zig
Hooks.stream_rows (a Placed); per-block stream copies at the pass's write-back family: cuda_batch_state.zig, cuda_batch_head.zig, cuda_prompt.zig, cuda_prompt_windows.zig
Draft.pieceSizes adds the blocks, initPieces takes their addresses, streamAt, window_scores; the lane's wide block is gone family: cuda_mtp_draft.zig, cuda_kv_lanes.zig
prefill gather, prompt_last, verifyIn; draws read window_scores; plain verify on a paged draft refused by name family: cuda_mtp_ops.zig, cuda_mtp_draw.zig, cuda_lanes_verify.zig

Code sharing, checked first: no Qwen, GLM or core path reads a buffer wider than a page through pages. Their pool lanes
hold only whole-layer state. So Rows/Placed go in core next to the packer, and Q3 (or a Qwen head, if one comes) can
use them as is. Metal has no Flash Next pool.

Checks on this head

Served gates (GB10, Qwen3.8-Flash-Next-FP4, --context 16384 --parallel 4)

Conformance kit (tools/conformance.py, flashnext-cuda profile with the cancel/evict blocks), page cache dropped before
every server, run after the rebase: main 3885dce (md5 82a75428) and the rebased head e89fb3c1e (a4381ec5), in the
same job. Cells: pages off; TF_FLASHNEXT_KV_PAGES=256; and 256 + TF_FLASHNEXT_STATE_PAGES=1 (the branch only). All 7 gates pass in every
cell, main's included (35 of 35): drafted == plain (448 of 724 drafts accepted), repeat == first, concurrent == solo at
1/2/4/8 (drafted and plain, greedy and seeded), resumed == fresh, refusals, cancel (census back to idle), evict. Every kit
row's sha equals main's in the same paging: 149 rows, 137 keys, 0 differ, for off, 256, and 256 with pieces.
The final head 25108af (b9788991) reran the three branch cells: 21/21 gates, 0 of 137 keys differ from main's rows.

Load line, pieces cell: "state, in the pool as pieces: 271 pieces, 342.22 MiB on 48 pages a lane, 18.53 MiB padding
(5.1 %)" and "0 MiB fixed each". Main's is "state run, in the pool: 357.39 MiB on 48 pages", also with "0 MiB fixed
each".

Fit (ashhart's landing cell): admitted streams by memory limit

TENSORFOLD_CUDA_MEMORY_LIMIT_GB=L, --parallel auto (8 asked), --context 16384, 256-row pages, --prompt-cache-gib 0.
Main 33ada77 against this PR's pre-rebase head adae273 (md5 376329c7) with TF_FLASHNEXT_STATE_PAGES=1. It stands for
this head: the rebase changed no layout, packer or ledger code, and the load line above is the same (271 pieces on 48
pages, 0 MiB fixed each). A 16k-window stream needs 65 pages plus its state (48
both ways).

 L GB | lanes main/pieces | pool pages main/pieces | 16k streams main/pieces | 4k (17 pages) streams main/pieces
   76 | 7/7 | 337/337 | 3/3 | 6/6
 76.5 | 8/8 | 405/405 | 4/4 | 7/7
   77 | 8/8 | 473/473 | 5/5 | 8/8
 77.5 | 8/8 | 541/541 | 5/5 | 8/8
   78 | 8/8 | 610/610 | 6/6 | 8/8
   79 | 8/8 | 746/746 | 7/7 | 8/8

Main and pieces admit the same streams at every limit, and the pool has the same pages. #2898 alone lost a stream at 77
GB (16k streams 5 vs 4) and one at 76/76.5/77 GB with 4k prompts. That loss is gone.

Speed: A-B-B-A, page cache dropped before every server

Rerun after the rebase: main 3885dce (82a75428) against the rebased head e89fb3c1e (a4381ec5). c1 = 3 x 256 tokens
solo, c4 = 3 rounds of 4 x 256, TTFT = two 8.8k-token prompts. Two jobs: one ran A-B-B-A x2 at both pagings, the second
another A-B-B-A x2 at pages 256. Pooled: 4 rounds at 256, 2 off. Token shas are identical across every arm of each
paging, and solo == c4 in every arm.

== pages 256: main vs pieces; 4 rounds; token shas identical across 16 arms: True; solo==c4 every arm: True
  c1 tok/s         main [41.233, 41.4, 41.467, 41.3, 41.5, 41.467, 41.433, 41.333]  pieces [41.1, 41.4, 41.4, 41.533, 41.367, 41.467, 41.333, 41.567]  mean +0.01%  rounds -0.16% / +0.20% / -0.16% / +0.16%  (2 of 4 rounds worse; spread main 41.233-41.500, pieces 41.100-41.567)
  c4 tok/s         main [81.445, 81.788, 81.558, 81.788, 81.852, 81.425, 81.691, 80.818]  pieces [80.601, 81.74, 81.639, 81.499, 81.527, 81.51, 81.602, 81.899]  mean -0.05%  rounds -0.55% / -0.13% / -0.15% / +0.61%  (3 of 4 rounds worse; spread main 80.818-81.852, pieces 80.601-81.899)
  ttft 8.8k s (1)  main [4.564, 4.554, 4.552, 4.551, 4.616, 4.575, 4.541, 4.563]  pieces [4.54, 4.544, 4.551, 4.59, 4.545, 4.555, 4.532, 4.576]  mean -0.23%  rounds -0.39% / +0.41% / -0.99% / +0.04%  (2 of 4 rounds worse; spread main 4.541-4.616, pieces 4.532-4.590)
  ttft 8.8k s (2)  main [4.553, 4.563, 4.531, 4.54, 4.527, 4.557, 4.528, 4.712]  pieces [4.568, 4.514, 4.559, 4.549, 4.527, 4.55, 4.518, 4.556]  mean -0.47%  rounds -0.38% / +0.41% / -0.08% / -1.80%  (1 of 4 rounds worse; spread main 4.527-4.712, pieces 4.514-4.568)
== pages off: main vs branch; 2 rounds; token shas identical across 8 arms: True; solo==c4 every arm: True
  c1 tok/s         main [41.4, 41.667, 41.3, 41.5]  branch [41.367, 41.633, 41.567, 41.467]  mean +0.10%  rounds -0.08% / +0.28%  (1 of 2 rounds worse; spread main 41.300-41.667, branch 41.367-41.633)
  c4 tok/s         main [81.764, 81.722, 81.611, 81.777]  branch [81.653, 81.712, 81.858, 81.782]  mean +0.04%  rounds -0.07% / +0.15%  (1 of 2 rounds worse; spread main 81.611-81.777, branch 81.653-81.858)
  ttft 8.8k s (1)  main [4.576, 4.573, 4.563, 4.574]  branch [4.563, 4.566, 4.559, 4.557]  mean -0.23%  rounds -0.23% / -0.23%  (0 of 2 rounds worse; spread main 4.563-4.576, branch 4.557-4.566)
  ttft 8.8k s (2)  main [4.554, 4.563, 4.557, 4.545]  branch [4.544, 4.55, 4.548, 4.529]  mean -0.26%  rounds -0.25% / -0.27%  (0 of 2 rounds worse; spread main 4.545-4.563, branch 4.529-4.550)

Pooled at pages 256: c1 +0.01 %, c4 -0.05 %, TTFT -0.23 % / -0.47 %. Off: c1 +0.10 %, c4 +0.04 %, TTFT -0.23 % / -0.26 %.
No cell at 256 keeps one sign across the rounds. The c4 rounds go -0.55 / -0.13 / -0.15 / +0.61 %; the low arm is 80.6 in
one pieces arm and 80.8 in one main arm, the same dip that hit either arm before the rebase (adae273: pooled c1 -0.05 %,
c4 -0.07 %).

An earlier head that copied each score row to its own piece lost 0.69 % at c=4 in both rounds; this head does not copy.

Proposed CHANGELOG line (for ashhart to place): "Flash Next: with TF_FLASHNEXT_STATE_PAGES, the MTP head's streams live in
the stream's pages too and its window scores stay in the shared verify buffer, so a pool lane holds nothing outside the
pool (#2444)."

@BlivionIaG

Copy link
Copy Markdown
Contributor

Coordination before publishing the EXL3 stack update (#1441–#1446, #1522): its new, unpublished capture-admission correction touches cuda_prompt_windows.zig and cuda_window_graph.zig, also touched by this stack. A real pooled-expert run failed with CUDA 900/901 after host planning / synchronization entered whole-pass capture. My guard excludes that case before capture; resident experts and covered active device plans retain eligibility.

This is separate from your paged head streams and shared verify scores; I am not implementing or replacing those. The current textual conflict is in cuda_prompt_windows.zig. Both your row/page handling and the host-planning admission check need to survive integration. Flagging this before push to agree on the shared call-site shape/order. The corrected EXL3 device validation is still pending, not claimed complete.

@jschmied

Copy link
Copy Markdown
Contributor Author

@BlivionIaG thanks for flagging it first. This stack's edits in those two files are one line each:

  • cuda_prompt_windows.zig:14, forwardGraph: one more refusal before anything is staged or captured. A pass with paged rows (hooks.stream_rows.bases.len > 0) returns false and runs uncaptured.
  • cuda_window_graph.zig:189, RollbackKey.init: two more hash inputs, d.saved_tail and d.saved_nrow, so a paged rollback can't reuse a graph keyed to other rows.

So both are pure admission and key changes, and they compose with your guard. Your host-planning check is one more early return false before window_graph.stageWindow, and either order works, because every check returns before staging or capture. Whichever stack lands second keeps the other's condition, in the same line or as an if right after it. If your correction also changes RollbackKey, please keep the two fields. I'll do the same on our side if we land second.

@ashhart

ashhart commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied, this is the cell we asked for on #2444: main and F2 + (c) admit the same streams at every limit from 76 to 79 GB, the kit rows equal main's in all three cells, and at 256 pages the pooled A-B-B-A means sit within 0.25% of main, with no cell keeping one sign across rounds. Thanks as well for the correction on the 3 pages. The pieces pack to 48 pages, the same as main's run, so nobody who turns this on loses a stream, and a lane can now open on any free pages instead of a run. That's enough for us. As we said on #2444, 8-head groups only come in if a measured cell shows the whole-layer pad (18.53 MiB a lane) costing a stream.

I read the head through, and it holds up:

  • Placed.next never crosses a block.
  • copyStreams and the prompt chunks carry from.
  • streamAt/prompt_last match the old offsets in block 0.
  • The window graph refuses a paged pass before staging.
  • The lone branch in cuda_lanes_verify.zig (n == 1 or cap <= 1) always holds a group of one.
  • Each group's draws run before the next group's verify reuses the lanes' buffer, so window_scores can't be overwritten while it's read.
  • Outside verify, verifyMany and draws, nothing on the head or on main touches a draft's logits.

@BlivionIaG's guard and these two lines compose as you described.

What's left before this and #2898 go in together:

  1. Please rebase flashnext: pool lanes keep their states and draft as pieces on whole pages, no run (#2444 P3, F2) #2898 and this PR onto today's main and push both. The conflict is in zig/src/families/flashnext/cuda_mtp_draw.zig's draws (main's starts at line 113). Main now waits once and checks every verify row's best values, and it refuses a non-finite selection by its row (c6a3377, 80e0fd7). A paged draft needs your scores in main's h.best, candidates and row download, with checkBest/badVerify kept as main has them. Your local copy on 93a2e93 should have most of this. Main has moved since, and dfd9ac5 touches the same file and the environment table, so it needs one more rebase.
  2. That resolution changes the draws a paged draft takes. Please rerun the kit's three cells (pages off, 256, and 256 with TF_FLASHNEXT_STATE_PAGES=1) on the rebased head, and post the row-sha comparison with main as before. The fit table and A-B-B-A rows can stand unless the resolution changes more than the score pointer.
  3. Please take flashnext: pool lanes keep their states and draft as pieces on whole pages, no run (#2444 P3, F2) #2898 out of draft once it's rebased, so CI runs on both heads. We'll review flashnext: pool lanes keep their states and draft as pieces on whole pages, no run (#2444 P3, F2) #2898 on its own thread then, as we said we would.
  4. Optional, your call: TF_FLASHNEXT_STATE_PAGES is a .cuda row (zig/src/platform/environment.zig:50 on flashnext: pool lanes keep their states and draft as pieces on whole pages, no run (#2444 P3, F2) #2898), so a malformed value stops every CUDA start, not only Flash Next's. Main now has a flashnext scope that both Flash Next load paths check first. Moving the row there would keep it with the other 29. The count test in environment_flashnext_test.zig and the "29 settings" line in docs/environment.md would move to 30.

On overlap: kv_pool.zig and kv_pool_state_test.zig are byte-equal to #2939's, so whichever of the two lands second just drops those hunks.

  • Synthena (Ash's Agent)

@jschmied
jschmied force-pushed the flashnext-head-paged branch from adae273 to 25108af Compare November 12, 2026 17:14
@jschmied

jschmied commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor Author

@ashhart thanks for the read-through. All four are done; heads are #2898 d432157 and this PR 25108af, both on main 3885dce.

  1. Rebased. In cuda_mtp_draw.zig draws, main's code stays as it is: one wait, checkBest over every verify row, badVerify on a non-finite row, pending, and dfd9ac5's on(). A paged draft changes the score pointer only: scores (window_scores on pieces, logits otherwise) goes into main's h.best, candidates and row download. git diff origin/main -- cuda_mtp_draw.zig is those three lines plus the const scores line. Every other file has the same added and removed lines as adae273.
  2. Kit rerun after the rebase. The three cells (pages off, 256, and 256 with TF_FLASHNEXT_STATE_PAGES=1) and main's two ran in one job against main 3885dce. All 7 gates pass in every cell (35/35). Row shas against main: 149 rows, 137 keys, 0 differ in each of the three cells. The final head 25108af reran the three cells: 21/21, 0 of 137 keys differ. Because the resolution only moved the pointer, the fit table stands; nothing in the layout, packer or ledger moved, and the load line is the same (271 pieces on 48 pages, 0 MiB fixed). I reran the A-B-B-A anyway: pooled over 4 rounds at 256, c1 +0.01 %, c4 -0.05 %, TTFT -0.23 % / -0.47 %; off, c1 +0.10 %, c4 +0.04 %. No cell at 256 holds one sign, and tokens are identical in every arm.
  3. flashnext: pool lanes keep their states and draft as pieces on whole pages, no run (#2444 P3, F2) #2898 is out of draft now.
  4. Taken: TF_FLASHNEXT_STATE_PAGES is now in the flashnext scope (flashnext: pool lanes keep their states and draft as pieces on whole pages, no run (#2444 P3, F2) #2898's d432157). The count test reads 30 settings (21 switches) and docs/environment.md says 30. The refusal test now checks it fires under .flashnext and not under .cuda.

Host test-kv-pool test-cuda-host 392/392 on each head, x86 720/720 + 16/16, lean 0 new on both. The 114 fatbins are byte-identical to main's. The PR body has the rows.

kv_pieces packs a family's state pieces first-fit decreasing on whole pages
(each piece inside one page, 256-byte aligned) and checks a map for overlap.
Lease.state holds those pages beside the token table; takeState takes them
from the promise and finish gives them back, so no consecutive run is needed.
Layout.pieces counts them wherever a run counted (need, plan, holds), and
Layout.outside names the buffers wider than a page a stream keeps in its fixed
part. The load report shows the pieces, their pages and pad, and that fixed part.
…aft as pieces

Under TF_FLASHNEXT_KV_PAGES, TF_FLASHNEXT_STATE_PAGES=1 maps each lane's
DeltaNet states, conv tails, n-gram tail and its MTP head's spares, saved rows,
small buffers and pending-row log onto pieces of whole pages from its lease
(no claimRun). The head's streams and window scores, wider than a page, are a
block of the lane's own outside the pool, charged in its fixed part. The saved
n-gram tail and rows get their own pointers, which the rollback key hashes.
Unset, the run and every buffer stay as before.
… 0 or 1

Read through environment.value, so the CUDA backend refuses any other text by
name at startup, before a model loads; words, 2 and padded text are refused.
…allest page that holds it

TF_FLASHNEXT_STATE_PAGES's PieceOverPage said pages need 128 rows or more, but the floor is lower and depends on
the head: the head's attention layer widens a page, so 104 rows hold a 3 MiB DeltaNet state with the head and 112
without. The load now logs the page's bytes, the largest piece and the smallest TF_FLASHNEXT_KV_PAGES that holds it
(smallestRows, from the same piece sizes the packer places), and the refusal points at that line, as Qwen's does.
…ith the other Flash Next settings

As a .cuda row a malformed value stopped every CUDA start, not only Flash Next's. In main's flashnext scope it is
checked where the other 29 are, before either Flash Next load path does device or checkpoint work; the count test
and docs/environment.md move to 30 settings, 21 switches.
@ashhart

ashhart commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. f0fe4b7 is 25108af restacked on main 6c5cef8. The only difference is #2939's TooMany text in zig/src/native/cuda_kv_pool.zig ("state pages") and its test. In cuda_mtp_draw.zig, draws keeps main's one wait, checkBest, badVerify and pending, and changes only where the scores are read (:116-117, :127, :137). CI is green on this head and on #2898's 59ba8b4, and the kit, fit and A-B-B-A rows carry over.

Every ask is met, so we'll land #2898 and this PR together, #2898 first. #2939 carries the same core commit, so whichever of them lands second drops it. These are the changelog lines we'll use:

  • Flash Next on CUDA: TF_FLASHNEXT_STATE_PAGES=1 (off by default; needs TF_FLASHNEXT_KV_PAGES) keeps a pool lane's states and MTP buffers as pieces on any whole pages of its lease, so a lane opens on any free pages instead of a consecutive run; output tokens are unchanged (#2898, for #2444). Thanks to @jschmied.
  • Flash Next on CUDA: with TF_FLASHNEXT_STATE_PAGES=1, the MTP head's streams also sit in the lane's pages and its window scores stay in the shared verify buffer, so a pool lane holds nothing outside the pool, at 48 pages a stream as before (#2992, for #2444). Thanks to @jschmied.

Thanks also to @BlivionIaG for flagging the cuda_prompt_windows.zig overlap early. Your host-planning guard and this stack's one-line checks in cuda_prompt_windows.zig and cuda_window_graph.zig compose as @jschmied described, so whichever lands second keeps both.

  • Synthena (Ash's Agent)

This branch has not been deployed

No deployments
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