Skip to content

flashnext cuda: the opt-in --tp 2 decode graph takes the dense split and keeps its graph across drafted verifies - #2982

Open
swbiggart wants to merge 21 commits into
ashhart:mainfrom
swbiggart:flashnext-tp2-graph-split
Open

swbiggart wants to merge 21 commits into
ashhart:mainfrom
swbiggart:flashnext-tp2-graph-split

Conversation

@swbiggart

@swbiggart swbiggart commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

With the opt-in TF_FLASHNEXT_DECODE_GRAPH=1 at --tp 2, start-up refused the default dense split (DecodeGraphWithDenseSplit), and every drafted verify or keep cleared the graph, so a drafted stream beside a plain one forced a recapture each verify. Now the graph captures the split's ops with the round, and a key holding each lane's buffers replaces those clears. Tokens are unchanged; unset, so is the served path.

Checks on this head

  • Head commit: 6d6bc8e65 on main 4cfd95573; zig fmt --check build.zig zig and git diff --check pass on 6d6bc8e65. Receipts on 2f4cdfe7e, 1bfb3e0f8 (RTX) and bee6b7caf (GB10) stand for 6d6bc8e65: rebasing onto eab305db0 changed their code only by main's flashnext: refuse a malformed TF_FLASHNEXT_* setting by name before device work #2585, which refuses malformed TF_FLASHNEXT_* settings at start-up; the later rebases onto 4f8317dac, 000b534a2 and 3885dceb2 moved CHANGELOG.md lines and took the parents' final heads, whose only code change is three test lines that set round-graph-capture's release hook to null. Two hosts now keep the blocking-collectives parent's nonblocking communicator, so the GB10 solo cells were re-measured at bee6b7caf; earlier GB10 rows are labelled. The restack onto 4cfd95573 takes both parents' heads after flashnext-rank: the failure modes start once both communicators are up, and timeout bounds only the collective #2976 and flashnext cuda: at --tp 2 a prompt pass reduce-scatters its routed experts' slots by rows and gathers their sums #2977 landed, including flashnext cuda: at --tp 2 on one host, collectives run on a blocking split of the nonblocking communicator #2978's one-host agreement; the merge is resolved as before, and these eight commits are unchanged apart from pin rows and CHANGELOG.md lines.
  • Lean checker, python3 tools/zig/lean_check.py, on the base and on this head: 341 and 341 (base 4cfd95573), 0 new. pr_check --base upstream/main on this head: 0 errors, 3 advisory warnings (no --pr; the blocking-collectives parent's RFC 5737 192.0.2.x test addresses).
  • Tests run, with the command and pass, fail and skip counts:
    • zig build test-cuda-host: exit 0 on this head (Linux x86_64, CUDA 13.3.1 image); on 2f4cdfe7e (--summary all, fresh cache), 6193/6232 passed, 39 skipped. New tests: the split round's capture; the key across settles and seams; mixed streams; the pocket refusal.
    • Fail before, four plants on 2f4cdfe7e (plants.txt): without key.buffers[i], 2 failed; without the pocket guard, 1; clear resetting the counters, 3; the split test's fake pass without its gathers, 1.
    • zig build test test-golden -Dcpu=apple_m1 on d532b9f05, this stack on main eab305db0 before the parents' final heads: 7606/7680 passed, 73 skipped, 1 failed: dashboard_test, which main eab305db0 also fails (same Mac: 13041/13152, 110 skipped).
    • zig build on 2f4cdfe7e: pass. zig build test-gradient-serving on this head: pass.
    • Device test tf-cuda-test flashnext-model-rank MODEL RANK PORT ids.txt rounds MASTER on 2 x GB10 (fb8fdf3d8 builds, blocking split across the hosts), one rank a host (MASTER is rank 0's), graph on; each rank asserts its counts and every graphed round equals an eager row byte for byte:
      • before the split change (f415e5f2c): FAIL round graphs captured and replayed { 0, 0 }, want { 6, 47 };
      • before the key change (93baf0599): FAIL plain rounds between verifies captured 4 more;
      • with both (071c02ed2): PASS 63 rounds equal eager rows byte for byte (round graph true, dense split 1), both ranks; graph off also passes.
      • 2 x RTX PRO 6000 Blackwell Max-Q (PCIe Gen4) at 1bfb3e0f8, no MASTER: the same PASS.
  • CI: runs on this head.
  • Not run, and why:
    • RTX device test with graph off, TF_FLASHNEXT_SHARD_HEAD=1, leave, or its fail-before: not run; graph off and the fail-before ran on GB10.
    • GB10 device test, mixed and plain-pair cells on the head's two-host path: not re-run; the earlier GB10 runs used the blocking split. The bee6b7caf solo cells ran that path graph on; RTX ran the rest on the same graph code.
    • Churn cell: arrivals and drops still clear the graph.
    • The graph's device memory: not read from /metrics.
    • A capture failing on one rank only: no control; as on main, both ranks fail the round.
    • A resumed prompt with the graph on: not served.
    • --tp 1, Metal, HIP: unreachable (needs a collective).

Receipt

  • Main against branch: cells and numbers, or why they can't be run: main refuses the graph with the split, so each pair runs graph on against graph off (main plus the parents). tp2_bench.py (median of 3 greedy 256-token requests) and gs_mixed.py/rc_mixed.py over tools/bench_concurrent.py's stream client; cell_sum.py recomputes (https://gist.github.com/swbiggart/659bf94526ad1920cacde6737ca27356).
    • 2 x GB10 (200G RoCE) at bee6b7caf, one start each, graph off then on:

      tok/s graph off graph on on vs off
      code plain 23.02 24.41 +6.0%
      chat plain 22.95 24.31 +5.9%
      code drafted 50.89 51.05 +0.3%
      chat drafted 37.63 37.77 +0.4%

      Both ranks: 5 captures, 1,024 replays.

    • 2 x GB10, earlier build (fb8fdf3d8, blocking split across the hosts), six starts ABCCBA, means of two: new 3385b84e2, old 70bb77b92 (split without the key), off: new with the graph off.

      tok/s off old, on new, on new vs old
      mixed: code drafted 32.81 30.91 33.25 +7.6%
      mixed: chat plain 15.82 15.80 16.41 +3.9%

      After the mixed cell, both ranks: new 17 captures, 3,072 replays; old 257, 2,608.

    • 2 x RTX PRO 6000 Blackwell Max-Q (PCIe Gen4, 300 W), 1bfb3e0f8, one start each:

      tok/s graph off graph on on vs off
      code plain 73.31 74.19 +1.2%
      chat plain 73.23 74.10 +1.2%
      code drafted 209.64 210.85 +0.6%
      chat drafted 149.79 150.27 +0.3%
      two plain at once, sum 132.05 134.12 +1.6%
      mixed: code drafted, chat plain 121.80, 54.32 121.80, 54.59 0.0%, +0.5%
      four at once (two drafted, two plain), sum 256.49 255.61 -0.3%

      Both ranks: 21 captures, 4,096 replays after the mixed cells.

  • Boxes it reaches: Flash Next CUDA at --tp 2 with the graph on, plain one-row rounds. Unset, nothing changes (the removed clears acted on an empty cache).
  • New switch? No; TF_FLASHNEXT_DECODE_GRAPH exists. MASTER is a test argument.
  • Environment, with the TensorFold commit, Zig and backend toolchain, machine and GPU, checkpoint and revision: head 6d6bc8e65; 2 x GB10 (sm_121), 2 x RTX PRO 6000 (sm_120), host tests on an Apple M5 Max; Zig 0.17.0, CUDA 13.3.1, NCCL 2.32.3; drivers 580.178.04 and 580.159.03 (GB10), 595.99.02 (RTX); nvidia/Qwen3.8-Flash-Next-NVFP4 at fc694b54fb0174e0913e6adf86691ef85a4ead47.
  • Paths and platforms the change reaches, which I ran and which I could not: sm_121 and sm_120, not other SMs. Draft.settle swaps the state buffers whose addresses a capture bakes in, so each lane's buffer digest is in the key; launch refuses an unkeyed pocket.
  • Exactness, from tools/bench_concurrent.py --alone --serial or token_sha against "draft": false: token_sha 4e759dbd859c (code) and 764da77fbd3f (chat) in every cell, graph on and off. RTX, graph on, --levels 1,4 --alone --serial: alone 3/3 and 12/12, serial equal, 0 failed.
  • Main against this branch, same machine and checkpoint, posted before asking for a run on our machines (or why you can't): as above.
  • Decode speed before and after, from tools/bench_openai.py: the tables, same stream client.
  • Prompt speed before and after: n/a; the graph runs only on one-row decode rounds.
  • Licence of any new weights or drafter, or "n/a": n/a.

Checklist

  • The title and description match the head commit, including the receipt and the stack.

  • New tests fail before the change, pass after it, and skip cleanly without their dependency.

  • Comments and docstrings are one line. No measurements or history in the source.

  • No personal data, machine names, internal hosts or local paths in the diff or its commits. No AI attribution lines.

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off.

  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. Open: no resumed prompt was served with the graph on.

  • No precision traded for speed. If the bits change, the description says which sums change and why.

  • Decode and prompt processing are no slower than main at one stream and the widest admitted; with --drafter, no slower than without it, greedy and sampled. Open: on RTX, four streams at once are 0.3% lower with the graph on.

@BlivionIaG

Copy link
Copy Markdown
Contributor

Coordination for #2980 and this PR before publishing the EXL3 stack update (#1441–#1446, #1522): my unpublished correction adds an EXL3 host-planning admission check to cuda_prompt.zig. A pooled-expert run actually failed with CUDA 900/901 because its host synchronization was reached during whole-pass capture. The first three EXL3 stages now have a same-line conflict with the pocket guard.

Both exclusions must survive: learned pockets remain eager as your PR requires, and an EXL3 pool requiring host planning cannot enter whole-pass capture. I am not changing your staging, abort/release handling, split or buffer keys. Flagging this before push to coordinate the shared predicate; no device-pass or publication-clearance claim for my correction yet.

@swbiggart

Copy link
Copy Markdown
Contributor Author

Thanks for raising this before you push. Agreed: both exclusions should hold. A folder with learned pockets stays eager, and an EXL3 pool that needs host planning stays out of whole-pass capture.

Both of these PRs edit that line. #2980 adds e.pockets.mask() == 0. This PR removes e.layers[0].dense_cut == null, because the graph now takes the dense split. With your check, the condition would read:

if (e.collective != null and round_graph.enabled() and !has_media and e.pockets.mask() == 0 and <your host-planning check>) {

Until this PR lands, and e.layers[0].dense_cut == null is also part of it.

Whoever lands second can keep the other's terms when resolving the conflict. If yours lands first, I'll carry your check through the rebases of #2980 and this PR.

@ashhart

ashhart commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @swbiggart. We reviewed this PR's own commits (22e7b3baf..26fe518e3) and they read right:

  • The key now holds each lane's buffer digest (window_graph.pieces) and its cap (zig/src/families/flashnext/cuda_round_graph.zig:29-30). A settle swaps a lane's states, so the key moves with it. The three clears this drops from verify, verifyMany and rollback in cuda_mtp_ops.zig aren't needed any more. Scratch and score growth still clear the graph in reserveScores and reserve (cuda_prompt.zig:354, :373).
  • The pocket refusal in launch backs up the caller's e.pockets.mask() == 0. Your host tests catch all four planted failures, and your device test compares every graphed round with an eager row byte for byte on both pairs. CI is green on 26fe518e3.
  • Keep (a) and (b) in one PR. There's no need to split them.
  • RTX's four-stream -0.3% is within run-to-run noise for one start each, and the graph is opt-in, so we won't hold the PR for it.
  • Your plan with @BlivionIaG for the cuda_prompt.zig:458 condition works for us. Whichever PR lands second keeps every term.

Three things before it lands:

  1. A resumed prompt with the graph on. It's the one exactness item your checklist leaves open. On either pair, with TF_FLASHNEXT_DECODE_GRAPH=1, please serve a conversation's second turn that resumes from its kept prompt. Post its token_sha next to the same turn on a fresh server and with the graph off.
  2. The CHANGELOG line says twice that a drafted and a plain stream no longer recapture the graph on every verify. Something like this would do: "Flash Next on CUDA at --tp 2: the opt-in decode graph (TF_FLASHNEXT_DECODE_GRAPH=1) now runs with the dense split, its default, where start-up refused it. A drafted window's verify and keep no longer clear the graph, so a drafted and a plain stream at once stop recapturing it on every verify. On two GB10s, plain decode goes from 23.02 and 22.95 to 24.41 and 24.31 tok/s (code and chat) and drafted decode is level. Every token is unchanged, and the graph stays opt-in (flashnext cuda: the opt-in --tp 2 decode graph takes the dense split and keeps its graph across drafted verifies #2982)." When both land, we'll drop "with TF_FLASHNEXT_SPLIT_DENSE=0" from flashnext cuda: the opt-in --tp 2 decode graph (TF_FLASHNEXT_DECODE_GRAPH=1) serves again instead of failing its first plain round #2980's line, since they ship in the same release.
  3. A rebase of the stack onto today's main. Main 3885dceb2b re-pinned fourteen serving sources this morning (486b0be1c2) and changed cuda_launch.zig (aaf01da54d). Now this PR, flashnext cuda: the opt-in --tp 2 decode graph (TF_FLASHNEXT_DECODE_GRAPH=1) serves again instead of failing its first plain round #2980 and flashnext cuda: at --tp 2 on one host, collectives run on a blocking split of the nonblocking communicator #2978 each conflict with main in zig/tests/fixtures/gradient-serving.tsv, as well as in CHANGELOG.md. Please re-pin from the merged files so test-gradient-serving passes on the new heads. The CHANGELOG conflict is ours to settle at landing. If you'd rather we did the re-pin and the stack's rebase, say so and we'll do it, keeping your authorship.

This carries #2980 and #2978, so it lands right after them.

  • Synthena (Ash's Agent)

@swbiggart
swbiggart force-pushed the flashnext-tp2-graph-split branch from 26fe518 to fbd535f Compare November 12, 2026 14:04
@swbiggart

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

  1. Resumed prompt with the graph on: I'll serve a conversation's second turn on two RTX PRO 6000s with TF_FLASHNEXT_DECODE_GRAPH=1 and the prompt cache on (drafts off, since --tp 2 with drafts needs --prompt-cache-gib 0). I'll post its token_sha beside the same turn on a fresh server and with the graph off.
  2. CHANGELOG: done, in your wording.
  3. Rebase: the stack (flashnext cuda: at --tp 2 on one host, collectives run on a blocking split of the nonblocking communicator #2978, flashnext cuda: the opt-in --tp 2 decode graph (TF_FLASHNEXT_DECODE_GRAPH=1) serves again instead of failing its first plain round #2980 and this PR) and the other branches main's re-pin touched are rebased onto 3885dceb2, with pins re-pinned from the merged files; test-gradient-serving passes on the new heads.

@swbiggart

Copy link
Copy Markdown
Contributor Author

Resumed prompt with the graph on: two RTX PRO 6000s at this head (fbd535faf), --tp 2 --prompt-cache-gib 8 --no-drafts, greedy, thinking off. The conversation has two turns: turn 2 is turn 1, its reply and a follow-up question (251 prompt tokens, 256 generated).

Server Turn 2 cached_tokens Turn 2 token_sha
Graph on, turn 2 resumed after turn 1 43 c75fded3eb47
Graph on, fresh server, turn 2 only 0 c75fded3eb47
Graph off, turn 2 resumed after turn 1 43 c75fded3eb47

Turn 1 hashes to 66ed4bf53b4a on both servers that ran it. With the graph on, the engine logs its capture on the first plain round (decode round graphs: 1 captured); the counter next prints at the 1,024th replay, beyond these 416 rounds.

@ashhart

ashhart commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Thanks @swbiggart. The resumed turn hashes to c75fded3eb47 on all three servers: graph on and resumed (43 cached tokens), graph on and fresh, and graph off and resumed. That closes the last exactness item. The CHANGELOG line reads as agreed.

Nothing else is owed on this PR's own commits. What's left is the landing order:

  1. flashnext-rank: the failure modes start once both communicators are up, and timeout bounds only the collective #2976 lands first, then flashnext cuda: at --tp 2 a prompt pass reduce-scatters its routed experts' slots by rows and gathers their sums #2977.
  2. flashnext cuda: the opt-in --tp 2 decode graph (TF_FLASHNEXT_DECODE_GRAPH=1) serves again instead of failing its first plain round #2980 and flashnext cuda: at --tp 2 on one host, collectives run on a blocking split of the nonblocking communicator #2978 rebase onto flashnext cuda: at --tp 2 a prompt pass reduce-scatters its routed experts' slots by rows and gathers their sums #2977, as you planned on each of them.
  3. This PR then needs one more restack. It carries flashnext cuda: at --tp 2 on one host, collectives run on a blocking split of the nonblocking communicator #2978 at 1b9fc3c1d through the merge d45db9ddc, so it doesn't have the one-host agreement (c7907837d) yet. Please move it onto the new heads of flashnext cuda: the opt-in --tp 2 decode graph (TF_FLASHNEXT_DECODE_GRAPH=1) serves again instead of failing its first plain round #2980 and flashnext cuda: at --tp 2 on one host, collectives run on a blocking split of the nonblocking communicator #2978, and re-pin zig/tests/fixtures/gradient-serving.tsv from the merged files.

Against today's main only CHANGELOG.md conflicts, and that one is ours to settle.

#2977 changes the prompt pass's collectives under this graph. So once this is restacked, we'll run flashnext-model-rank with the graph on and off on two GB10s (nvidia/Qwen3.8-Flash-Next-NVFP4, one rank a host) at the final head before landing. If #2837 lands first, that run covers the buffers it shares with this graph too. When this and #2980 both land, we'll drop "with TF_FLASHNEXT_SPLIT_DENSE=0" from #2980's line, as we said.

  • Synthena (Ash's Agent)

…ed and that a 4 s stall stays bounded

The sum mode issues one raw all-reduce that must return ncclSuccess, as on a blocking communicator, and reports
init time and device memory. The timeout mode's peer outlives rank 0's 3 s bound, so a collective that waits on the
peer inside NCCL fails it. An optional MASTER address lets the two ranks run on two hosts.
…mmunicator

Every collective on a nonblocking communicator ran its launch on a thread of NCCL's and returned in progress, so
the engine polled it with a 50 us sleep, about 105 us a collective and 192 a decode pass. The parent stays nonblocking
and bounds start-up under the control link and a deadline as before; a blocking communicator split from it, sharing
its connections, carries every collective and returns once the kernel is enqueued. NCCL_RUNTIME_CONNECT=0, set when
unset, makes NCCL connect inside the parent's init and the split's job, which the parent's poll bounds, so no
collective waits on its peer for a connection. Abort ends the parent, which stops a split in flight, then the split.
…ECT and the measured decode

The two-rank recipe says how start-up stays bounded and what NCCL_RUNTIME_CONNECT=1 would give up; the CUDA recipe's
transport report names Flash Next with GLM; the changelog and the ledger's tensor-parallel row carry the measurements.
…communicator; one host keeps the blocking split
…e capture

TF_FLASHNEXT_DECODE_GRAPH=1 at --tp 2 failed every first plain round
with UnownedCaptureSource: Cache.run set only `prepared`, so Pass.chunk
uploaded the round's token ids through the copy-source owner inside the
capture, which refuses any host source there. The round now queues its
ids, positions, lane table and n-gram rows on the stream with
stageWindow, as the one-GPU shared windows do, then captures or replays
kernels and collectives alone; an unstaged pass is refused before any
capture. A replay stages every round's inputs afresh, the lane table
included.
A graph that captured NCCL collectives holds persistent plans, and
ncclCommAbort waits without a bound until every such graph is destroyed
(commDestroySync polls localPersistentRefs). With the round graph held
by the engine, a peer lost during a replay, a capture or between rounds
would hang the surviving rank in the abort instead of returning
RankDisconnected or RankTimeout. The cache that captures a
communicator's collectives now registers a release that the abort runs
first: it ends an open capture, frees its graph and destroys the
executable, so NCCL's abort can drain and return.
… eager

A round of taught and untaught streams scales its pocket rows through a
buffer rowScales allocates anew every round, which the round graph's key
does not cover, so a replay would read the freed one. Like the window
graphs, the round graph now leaves pockets out: with any pocket loaded
both ranks run their rounds eagerly (the mask is the checkpoint's, so
the ranks agree).
…and a lost peer

Each of 52 two-rank rounds (one lane, two lanes at different positions,
the other owner order, the 512-key seam) must equal an eager one-row
forward of a twin stream byte for byte; with TF_FLASHNEXT_DECODE_GRAPH=1
every round must take the graph and the one-lane rounds must replay the
first one's executable. `leave` decodes until the peer is stopped from
outside: after four rounds it must hold the graph exactly when it is
on, and the leader must then stop with RankDisconnected or RankTimeout
rather than wait (the run's own timeout bounds a hang).
The context is 1024 so the rounds cross 512 keys.
cuda_round_graph.zig stages its round before capture, cuda_collective.zig runs the round graph's release before an abort, and cuda_prompt.zig leaves rounds with pockets eager; all three reach only the opt-in TF_FLASHNEXT_DECODE_GRAPH path, so test-gradient-serving follows the new hashes.
It also puts the pull request number on the notes of the three rows re-pinned in gradient-serving.tsv.
…graph-split

The abort releases the round graph before it aborts the parent and the
split it made, cuda_collective.zig is re-pinned for both changes, and the
round graph host test's fake communicator has no parent.
Every op the split adds to a round (the head and column gathers, copyCols and the FP32 matmul, the split shared expert) is ordered on the compute stream and reads scratch the round graph's key already names, so the graph captures it like the sum. Drop the load refusal and the round's dense_cut gate.
A clear keeps the counts; every 64th capture logs them, as the window graphs do. Host tests cover a split-shaped round's gathers inside the capture and a clear between rounds recapturing each one.
… drafted window's verify and keep leave it held

A settle swaps a sequence's DeltaNet state buffers with its window's
spares, and a captured round bakes those addresses in. The round graph's
key now holds each lane's buffer digest (the one the window graphs key on)
and capacity, so a settled lane is a new key, and verify, verifyMany and
rollback no longer clear the graph. launch refuses a pass that carries a
pocket before it captures anything.
@swbiggart
swbiggart force-pushed the flashnext-tp2-graph-split branch from fbd535f to 6d6bc8e Compare November 13, 2026 17:30
@swbiggart

Copy link
Copy Markdown
Contributor Author

Restacked on the new heads, as you asked: #2980 0ddafbc80 and #2978 b4a758b2f, both on main 4cfd95573, through the merge 42032c865, so it now has the one-host agreement. The merge is resolved as before: the abort releases the round graph, then aborts the parent and the split; both usage lines are kept; the round graph test rig has no parent. The eight commits, 42032c865..6d6bc8e65, are unchanged apart from pin rows (re-pinned from the merged files) and CHANGELOG.md lines. On 6d6bc8e65, zig build test-cuda-host (Linux x86_64) and test-gradient-serving pass, and lean is 341, 0 new.

@ashhart

ashhart commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thanks @swbiggart. The resumed turn's matching hashes already close that exactness ask. The CHANGELOG wording is fixed, and your restack names both updated parents, including the one-host agreement.

The one thing we still need to finish reviewing the combined code is a single net diff of the resolved stack at 6d6bc8e65 against main, including the abort and serving pin merge resolutions. That lets us verify the integration described in your reply; the separate commit patches omit those resolutions. The final GB10 graph run remains ours to schedule.

  • Synthena (Ash's Agent)

@swbiggart

Copy link
Copy Markdown
Contributor Author

The resolved stack at 6d6bc8e65 against its main base 4cfd95573, as one diff, and the merge's own resolutions: https://gist.github.com/swbiggart/73c5d55daaa0d202c8bdf5663c459839

@ashhart

ashhart commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thanks @swbiggart. The net diff and remerge-diff link answer the artifact request, including graph release before both communicator aborts and the combined serving pins. No further author change requested. The final resolved-code review and the two-GB10 graph-on/off check remain on our side before landing.

  • 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