Skip to content

Flash Next on CUDA: the qwen4_exp family on the native CUDA host, every stream through the lane rounds - #869

Open
ZackSample wants to merge 45 commits into
ashhart:mainfrom
ZackSample:flashnext-cuda
Open

ZackSample wants to merge 45 commits into
ashhart:mainfrom
ZackSample:flashnext-cuda

Conversation

@ZackSample

@ZackSample ZackSample commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Adds a CUDA engine for Qwen3.8 Flash Next (qwen4_exp) to tensorfold-native: an MLX affine 4-bit, group-32
checkpoint with its MTP head, served through the core's lane rounds, lane host and prompt cache, with drafted windows,
shared rounds, kept prompt states and prompt chunks between other streams' rounds. Every kernel it launches is built
from this repository, so it boots with no capture. TENSORFOLD_CUDA_KERNELS can name a captured set of the Python
0.6.1 engine's Triton kernels, which then stands in for our glue so its tokens can be compared with that engine's
teacher path; TF_FLASHNEXT_OWNED swaps single entry points back to ours beside a set. It builds on @AdrianBinDC's
#476 and answers #472. His commits are here with his authorship and messages, rebased onto main with #835's cache
guard (027ddea): five needed their zig/build/cuda.zig or zig/tests/cuda/copies.py hunks fitted to what main has
now, and his formatting commit is left out, since main has the same line from his 7bbe220.

Following docs/recipes/adding-a-zig-family.md and CONTRIBUTING.md:30-31, the family supplies its config check, weight
map, layer graph and kernels for what is its own; shared pieces are the core's:

  • the round loop, verify/accept and the lane host schedule every token; the family adds no scheduler;
  • the prompt cache's store and rules keep and resume states; the family copies a stream's state (with the MTP head's
    rows for a drafting stream, lookahead 1 as Nemotron);
  • prompts run a chunk a call through the core's prefill_step;
  • shared row kernels: token rows (embed_rows.cu, now in groups of 32 with a copy a stream), RMS rows with their group
    sums (norms.cu, the MTP head's norms), a new route_rows.cu for router logits and softmax top-k with a shared
    gate, and the routed experts over a window's rows on the core's plan (cuda/grouped.zig) and a new core lane kernel,
    experts_rows.cu (cuda.experts.RowsExperts), which gives every row the decode kernel's sums;
  • the family's own kernels: the hyper-connections and the n-gram gate and conv (flashnext_hc.cu), attention with its
    output gate and the sparse indexer's pooling and scores (flashnext_attn.cu), the indexer's block selection and the
    Gated DeltaNet chain (flashnext_batch.cu), and the MTP head;
  • shards are read with core.safetensors.File; a captured set is packed with tools/zig/triton_aot_pack.py and
    launched through cuda.aot.Set.

Every kernel keeps a row's own sums in a fixed order, so a row's bits are the same at any row count: drafted windows
equal serial decoding, rounds equal lone streams, resumed prompts equal fresh ones. Our glue rounds to bf16 where the
Python engine's kernels do, with IEEE division and square root and the core's within-one-ulp exp (glue_math.cuh);
the router and the hyper-connections' 4-bit dots run on mma tiles with K in ascending order.

Commits:

  • @AdrianBinDC's 22 commits from Zig CUDA: one drafts-off Flash Next prompt matches #476: the embedding, every layer type and the final mix matched to the Python engine's
    host checks one by one, then a drafts-off prompt token for token.
  • The token-id and position uploads go on the compute stream, and attention runs over every 512-key chunk a prompt has.
  • native/cuda.zig reads a family's prompt-cache copies, cache rules and device weight bytes from what it declares
    (Nemotron's paths unchanged); Flash Next registers.
  • Core: experts_rows.cu and tf-cuda-test experts-rows.
  • Core: triton_aot_pack.py leaves out constexprs Triton folded from None and takes a --jit per capture.
  • The engine on one batched pass (prompts and decode rounds), the sparse indexer and the 262,144-token window.
  • The MTP head, its chains, verify windows and the DeltaNet rollback.
  • The lane backend: drafted windows, shared rounds, kept prompt states (drafting streams too), prompt steps.
  • A sequence's buffers are zeroed on the compute stream (a fill with no stream raced the first pass).
  • The config check refuses semantic settings the kernels fix (RMS epsilon, RoPE, gate and activation types, layer
    roles, the n-gram layer), by field name.
  • A failed zero fill frees the buffer it was filling.
  • A pass's uploads read pinned staging the scratch owns until the copies land.
  • Core: route_rows.cu (router logits on mma tiles, softmax top-k with a shared gate) with core/route_ref.zig and
    tf-cuda-test route-rows.
  • Core: token rows in groups of 32 with copies, and RMS rows with their group sums; norm-rows covers both.
  • Our glue, flashnext_hc.cu and flashnext_attn.cu, with tf-cuda-test flashnext-hc and flashnext-attn.
  • The engine boots on our glue; a captured set that holds every entry point is used for comparison; flashnext-compare.
  • TF_FLASHNEXT_OWNED, our glue for the named entry points beside a captured set.
  • Docs: docs/recipes/flash-next-cuda.md, the Flash Next, CUDA and recipe-index pages, RUNBOOK, CHANGELOG.

Receipt

  • Environment: TensorFold flashnext-cuda 7746bb9 on 027ddea (main c7137bb, v1.0.11 + 19, with glm: a kept state that goes stale drops its cache entry at once #835's cache guard),
    Zig 0.17.0; NVIDIA GB10 (DGX Spark, sm_121), driver 580.178.04, CUDA 13.0.88, Ubuntu 24.04.5; checkpoint
    leimroth-lab/Unbound-LR7 revision 2ae3a2ff (Qwen3.8 Flash Next, MLX affine 4-bit group 32, MTP head). "Captured"
    is the same build with TENSORFOLD_CUDA_KERNELS naming the 76-kernel set packed from 4 captures of the Python 0.6.1
    engine; main has no Flash Next on CUDA to compare against.

  • Our kernels, each against float64 at the model's shapes (tf-cuda-test): route-rows (logits within the fp32
    dot's bound, picks equal to the reference rule's, weights and the shared gate within half a bf16 ulp plus 2^-18),
    norm-rows (the group-32 token rows with copies, RMS rows with group sums), flashnext-hc and flashnext-attn
    (each bf16 output within one bf16 ulp of a float64 chain that rounds where the kernel rounds, fp32 sums within their
    sum-of-|terms| bound, attention within 2^-8 of sum p|v|/l plus half an ulp, copies and stream adds exact). Every case
    also checks that 1, 3 and 17 rows (attention: 1, 3 and 20, both of its forms) give each row the same bits.

  • Exactness on our kernels (the default):

    • TF_FLASHNEXT_DRAFTS=6: drafted equals serial token for token on 6 prompts greedy and 5 seeded.
    • tools/bench_concurrent.py --alone --serial, 1/2/4 streams, code and chat, greedy and T=1, drafts on and off:
      every cell equal to its solo run (112 of 112) and drafted cells equal to "draft": false (30 of 30), 0 failed.
    • Resumed equals fresh: a 1,160-token prompt resumes 1,153 tokens and two more resume a 1,139-token system cut, with
      drafts and without, alone and with 4 at once; every token_sha equal to the fresh run.
    • 45,030 tokens at --context 65536: the same token_sha drafted, "draft": false and both resumed (45,025 cached).
    • flashnext-select equals Triton's block lists.
  • Against the Python engine's teacher path, flashnext-prompt (32 greedy tokens): captured 32/32 on all five prompts
    (19, 52, 600, 3,001 and 12,001 tokens). Ours: 32/32 on the 19-, 52-, 600- and 12,001-token prompts; on the
    3,001-token prompt ours parts at token 15, where the captured path's best two scores are 0.375 apart.

  • Ours against the captured set, every prompt row teacher-forced on one loaded engine (flashnext-compare):

    prompt rows top-1 agree KL mean / median / max perplexity captured / ours
    19 tokens 19 19 1.7e-3 / 2.4e-5 / 0.027 5.786 / 5.791
    52 tokens 52 52 7.1e-4 / 2.9e-5 / 0.0096 440.3 / 420.4
    600 tokens (code) 600 577 0.027 / 3.8e-3 / 3.32 15.38 / 18.40
    3,001 tokens (Python reference) 3,001 2,873 0.028 / 3.3e-3 / 4.27 134.3 / 186.0
    12,001 tokens (Python reference) 12,001 11,651 0.019 / 1.2e-3 / 8.68 368.7 / 411.6
    4,096 tokens of this repo's docs 4,096 3,890 0.012 / 5.1e-3 / 0.95 6.168 / 6.178

    On natural documentation the two agree to 0.15% in perplexity, with a median per-token NLL difference of 1e-6 nats.
    The Python-reference prompts are hypersensitive: with everything else captured, swapping in any single one of our
    kernels that is not bit-identical moves the 3,001-token prompt's perplexity as far as the whole set does (from
    134.3 to between 119 and 183; past 2,048 tokens, from 262 to between 209 and 654, where all of ours gives 483), and
    on the 600-token prompt from 15.38 to between 12.9 and 16.2. Four of our entry points give the captured cubins'
    bits exactly, so swapping them alone changes nothing (KL 0): _embed, _router, _pool and _scores.

  • Decode and prompt speed, the same build and session (tools/bench_openai.py 256 tokens median of 3;
    bench_concurrent.py aggregate tok/s; bench_cache.py --require-cold):

    captured ours
    1 stream drafted, code / chat 82.5 / 62.1 84.2 / 62.6
    1 stream --no-drafts, code / chat 35.2 / 35.2 35.1 / 35.1
    --no-drafts 1 / 2 / 4 streams, code T=1 35.2 / 53.0 / 77.1 35.0 / 53.1 / 77.9
    --no-drafts 1 / 2 / 4 streams, code greedy 35.1 / 55.8 / 82.2 35.0 / 55.5 / 81.9
    drafted 1 / 2 / 4 streams, code greedy 82.3 / 81.1 / 80.7 83.8 / 82.7 / 82.2
    drafted 1 / 2 / 4 streams, code T=1 76.6 / 65.7 / 70.2 77.0 / 65.6 / 67.9
    cold first token, 3,001 / 12,001 tokens (s) 2.67 / 10.30 2.67 / 10.40
    45,030-token prompt, no cache, "draft": false (s) 35.8 36.5
    another stream's longest wait during a 12,001-token prompt (s) 1.29 1.31

    flashnext-prompt on the five prompts: prompt 92.5 / 150.1 / 662.6 / 2,483 / 9,574 ms ours against 90.8 / 154.5 /
    690.3 / 2,504 / 9,533 captured; decode 29.9 / 31.6 / 30.5 / 33.2 / 31.9 ms a token against 30.3 / 31.9 / 32.6 /
    32.0 / 30.7 (the replies differ on the 3,001-token prompt). With the prompt cache on, the 45,030-token prompt comes
    back resumed at 45,025 tokens in 0.11 s without drafts and 0.17 s drafted.

  • Memory: the model takes 75.8 GiB with the head (74.2 without); a stream at a 16,384-token window 0.74 GiB with drafts
    and 0.54 without; at 65,536, 2.15 GiB.

  • Tests: zig build test-cuda-host from a fresh cache: 29/29 steps, 503/503 tests pass, 0 skipped, and each commit after Zig CUDA: one drafts-off Flash Next prompt matches #476's builds (zig build) and passes them; zig build native builds from 8391cac on (Zig CUDA: one drafts-off Flash Next prompt matches #476's tip 0d04c43 and the commit after it stop at native/cuda.zig's F.max_segments, which the registry commit supplies); tf-cuda-test smoke 7, symbols 1, experts-rows 7, norms 1, norm-rows 1, sample 1, glue 1, pocket
    3, quant-ref 4, modelopt 4, exlr8 7, route-rows 1, flashnext-hc 1, flashnext-attn 1: all pass, 0 fail, 0 skip;
    lean_check.py 0 new problems against 027ddea; zig fmt --check clean; all 42 fatbins build for sm_86, sm_120 and sm_121. A captured set without Flash
    Next's entry points is passed over at load and the server answers on its own glue.

  • Not run: Metal (no change there; the Mac Flash Next host is untouched), sm_86 and sm_120 cards (none here), CUDA
    graphs (none in this engine).

Checklist

  • 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.
  • No precision traded for speed. If the bits change, the description says which sums change and why. Ours against
    the captured kernels: _embed, _router, _pool and _scores give the same bits; the others change at the ulp
    level, from IEEE division and square root and a within-one-ulp exp where Triton uses its approximate forms (the
    norms' 1/sqrt, sigmoid, SiLU and softmax exps, the output gate), from fixed-order block and warp sums where Triton
    reduces in its own tree (the write-back's and n-gram gate's squared sums, the group sums the 4-bit dots take, the
    attention rows' p sums), from attention tiles of 32 keys where Triton's are 64 (the online softmax rescales at other
    points), from explicit fused multiply-adds in the dots' epilogues and the merge, and from sincosf with full range
    reduction for RoPE. Which tokens that moves is in the receipt.
  • Prompt processing is no slower than the last release. (No release has this engine; against the captured set a
    cold 12,001-token prompt is 1% slower, 10.40 s against 10.30 s.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not. (GB10 only.)
  • 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. No AI attribution lines.

Not in this PR, said plainly

  • Our glue's tokens are not the Python engine's teacher path's on every prompt: on the 3,001-token prompt they part at
    token 15. The captured set, given by TENSORFOLD_CUDA_KERNELS, still gives that path's tokens (32/32 on all five).
  • Dense projections run Zig CUDA: one drafts-off Flash Next prompt matches #476's copy of the Python engine's group-32 lane matmul (qmm.cu); the core's qmm_group and
    lane_gemv are built for groups of 64. I have not checked whether a group-32 qmm_group instance gives the same bits.
  • No CUDA graphs and no GPU-side rounds. Drafted streams verify their windows one batched pass each, so drafted
    throughput does not grow with streams, and the MTP head drafts one stream at a time (Nemotron batches them, Nemotron CUDA: a shared round's MTP heads draft every stream in one batch #768).
  • Prompts are slower than the Python engine's: a cold 12,001-token prompt takes 10.4 s here against 6.7 s there.
  • Draws are made on the host from each row's scores (a 248,320-wide bf16 row a token).
  • int8 KV is not in this PR; bf16 only.
  • Checked on a GB10 only; the kernels build for the release SMs but have not run on sm_86 or sm_120.
  • The core's DeviceBuffer.fill8(..., null) returns before the memset lands on device memory and is not ordered with a
    non-blocking stream. This PR stops Flash Next using it; a doc line on fill8 may save the next family the same hunt.

A resident prompt state died (its slot's rows overwritten, or its pool
blocks given up) left the Store serving the dead entry: every later
lookup found it, its restore failed, and the pass prefilled the whole
prompt from the start — the repeated full-refill cascade seen on live
servers with long conversations. The backend now tells the prompt cache
the moment a state goes stale (Slots.expire), so the Store drops that
entry immediately and the next turn keeps a fresh state instead of
paying for the same failure again.
The prefill path read a snap after pool admission could free it, and
overwrite walked snaps while expiry dropped them. A resident state that
dies now expires its cache entry at the host boundary; each lookup
borrows its entry and snap until resumed or release ends that ownership;
reclamation runs at host boundaries and skips active borrowers.
# Conflicts:
#	zig/src/core/prompt_cache.zig
@ashhart

ashhart commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Thanks, @ZackSample, for moving this onto the shared lane host and documenting the captured-kernel dependency clearly. Needs changes at e4e1926 before it can be our default Flash Next CUDA implementation.

zig/src/families/flashnext/cuda_lanes.zig:245 refuses startup without a capture directory. Native serving still needs 17 captured entry points with MTP, 15 without, covering embeddings, norms, hyper-connections, attention, PLE and routing. The cuda_launch.zig wrappers have no owned fallback. Default boot needs repository-built implementations of these operations, with the captured path available for comparison.

Two source issues also need fixing. cuda_config.zig:42 checks dimensions but accepts changed epsilon, RoPE or output-gate settings while evaluation uses fixed constants. Reject unsupported semantic settings or honor them. In cuda_prompt.zig:71-72, a failed fill leaks the just-allocated buffer because cleanup excludes the current index.

Please also retain #835's saved-prefix borrowing fix for deferred admission, and confirm the lifetime of temporary asynchronous upload sources at cuda_batch.zig:366-376. We have not rerun the GPU receipts or inspected the external cubins, so this is not full arithmetic clearance of the captured operations.

AdrianBinDC and others added 23 commits October 15, 2026 06:06
…roup-32 token, so the CUDA replay starts from the row the Python engine reads.
… so the linear-attention input is that mixed row.
…f the mixed row on one token, so the DeltaNet input is that projection.
…y match the host chain on one token, all 6144 gated values and all 2560 projected values, so the mixer branch is that row.
…, the write-back exact and its sums within 1 ulp, one normed value 1 step off, and the mixed row within 2 steps, so the expert input is that row.
…he shared expert exact, router logits within 29184 ulps, two weights 1 step off, the activations exact, and every down value the same bf16 with fp32 within 16384 ulps.
…the projection, the prepared heads, the one-key output, the gate and the output row all exact.
…one projected value 1 step off, and the prepared heads, the one-key output, the gate and the output row all exact.
… one projected value 1 step off, and the prepared heads, the one-key output, the gate and the output row all exact.
… the projection, the prepared heads, the one-key output, the gate and the output row all exact.
… the projection, the prepared heads, the one-key output, the gate and the output row all exact.
… the projection, the prepared heads, the one-key output, the gate and the output row all exact.
… two projected values 1 step off, one gate value 1 step off with group sums within 1024 ulps, and the prepared heads, the one-key output and the output row all exact.
… one gate value 1 step off with group sums within 8192 ulps, and the projection, the prepared heads, the one-key output and the output row all exact.
… one prepared key value 1 step off, and the projection, the other prepared heads, the one-key output, the gate and the output row all exact.
… one projected value 1 step off, and the prepared heads, the one-key output, the gate and the output row all exact.
… one projected value 1 step off, and the prepared heads, the one-key output, the gate and the output row all exact.
… two projected values 1 step off, and the prepared heads, the one-key output, the gate and the output row all exact.
…token, all 2560 mixed values exact, and three of 248320 scores 1 step off.
…m; prompts past 512 keys; LR7 capture.

The ids and position uploads were synchronous copies, which do not wait for the non-blocking compute stream, so
a later row overwrote a queued row's token id or position: wrong tokens on most prompts. Both now go on the
stream. attnStep launches one 512-key chunk per started chunk of the row's keys (as attention.attention does)
instead of always one, so prompts run to the captured NCH * 512 keys; the lanes engine defaults to that window
and refuses a larger one. The prompt check prints each run's top five scores and its timing.

zig/tests/cuda/flashnext: capture.py and capture.sh (Python engine 0.6.1, drafts off, greedy, 1536 cache slots)
and the Unbound-LR7 prompts with their references; flashnext-prompt matches the teacher paths on all three.
…ules and device weight bytes from what it declares; Flash Next registers

The registry gains flashnext.native. What was Nemotron's by name is now read from the family's declarations: its
prompt-cache copies (snap_bytes, snap_save, snap_restore, snap_drop, and snap_ready's spare buffers), the store's rules
(cache_rules; without them a state keeps at any row with the MTP head's one token on, ashhart#670), the quiet wait before a
warm pass and the lone driver's hand-over budget. Nemotron declares the copies, the wait and the budget, so its paths
are unchanged; a family without copies gets no prompt cache, and the startup line says so. A family that keeps part of
its checkpoint off the device says what it uploads (deviceWeightBytes) and admission counts that, not every shard:
Flash Next leaves its n-gram tables mapped and loads no vision tower, so the MLX 4-bit checkpoint uploads 73.3 of its
105.4 GiB, where counting every shard made the default reserve on a GB10 refuse it by 0.1 GiB. --slide is refused
before anything loads for a family that declares no learner.

The Flash Next family names what the host reads: one segment, its prompt rows, each stream's bytes, its refusals in
words, and the native host's lone-driver signature.
…the decode kernel's sums: experts_rows.cu

experts_rows.cu is experts.cu's decode expert kernel with two 16-row tiles a warp, so a warp reads each group of an
expert's weights once for up to 32 pair rows. Every row keeps the decode kernel's arithmetic: the same group sums,
the same two m16n8k16 steps from zero a group, the same fma epilogue order and the same SwiGLU, built with experts.cu's
flags. A row's bits therefore do not depend on how many rows share its window or chunk, which is what the lane core's
exactness contract needs of a lane kernel. It is instantiated at groups of 32 (gate and up stacked with SwiGLU, and
the down projection to fp32), the MLX 4-bit group-32 experts Flash Next reads.

cuda.experts.RowsExperts resolves the two instances and launches them on the shared plan (cuda.grouped.Router at its
32-pair tile). `tf-cuda-test experts-rows` packs random group-32 experts with experts_pack.cu, routes 1, 2, 5, 16,
33, 100 and 300 rows (three routed experts and a shared one a row) and checks every pair row of the rows kernel
against experts.cu's one-row launches of the same row, byte for byte, for the SwiGLU rows and the fp32 down rows.
…and --jit is repeatable

A Triton kernel called with None for a pointer it does not read gets that argument folded into the kernel as a
constexpr None; Flash Next's _pool kernel has two (ROPE and DELTA). The packer stopped on them ("constexpr None has
no Zig form"); it now leaves them out of the variant's constexprs, since there is nothing for a launch to match.
--jit is repeatable like --manifest, and the captures' jit.json files are merged, so one call packs several captures
whose kernels come from different JIT functions. A single --jit, or none, works as before.

test_aot_manifest.py's fake kernel now has a None constexpr (the packer before this change stops on it) and packs once
more with a second capture's jit.json beside the first, which must give the same set.
…the sparse indexer and the 262,144-token window

cuda_batch.zig runs a chunk of rows through each layer at once, on kernels that keep a row's bits whatever the row
count: the group-32 lane tile (qmm.cu), the core's grouped expert plan (cuda.grouped.Router) and rows kernel
(cuda.experts.RowsExperts, experts_rows.cu), the DeltaNet chain over the chunk (flashnext_batch.cu: fn_gdn_front,
fn_gdn_rows without block barriers, fn_gdn_norm, each value by the one-row chain's expression), attention over each
row's causal prefix, and the captured Triton forms at more than one row (capture_rows.sh). A chunk therefore leaves the
logits, KV cache, DeltaNet conv windows and states and the n-gram tail exactly as feeding its rows one at a time. The
n-gram table stays mapped for the engine's life and a chunk's rows are gathered on host threads while the GPU runs
layer 0. Engine.round runs several sequences' next tokens as the rows of one pass: the stateless kernels over every
row, each row's n-gram conv, DeltaNet step, rope, cache writes and attention on its own sequence at its own position.

Past 2,048 keys each attention layer reads 512 blocks of 4 keys a row, as the model does: the pooled indexer keys
(_pool), the block scores (_scores), a native selection (fn_qsa_select: the lists Triton's _select and _select_tiles
make, checked by flashnext-select on capture_sparse.sh's fixtures with many ties) and the sparse _chunks/_merge
(QSA 1, NCH 5). The family serves the model's 262,144-token window.

The family's own pieces only: config.json's quantization and shapes are checked at load against the ones the kernels
and buffers are built for (cuda_config.zig: a 6-bit checkpoint or another width is refused by name); the shards are
read with core.safetensors.File; a folder whose shared experts carry Living Weights neurons is refused in words. The
row-by-row path Adrian's prompt check was built on moves out of the engine into cuda_rows.zig, where flashnext-prompt
(TF_FLASHNEXT_PROMPT=rows) still uses it as the batched pass's reference. Captures: capture_rows.sh and
capture_sparse.sh record the extra Triton forms, and pack.sh packs any set of captures with tools/zig/triton_aot_pack.py.

flashnext-prompt (32 greedy tokens) matches the Python engine's teacher path on short, mid and long, and on long3k
(3,001 tokens) and long12k (12,001 tokens) past the indexer's budget (results-sparse.json).
…he DeltaNet rollback

The head (cuda_mtp.zig, as the Python engine's mtp.py and decode.draft) reads the next token's embedding and the main
model's four streams after the last MoE write-back, runs its full-attention layer with its own hyper-connections,
experts and KV cache, then its mixer and the lm_head rows of the 79,591-id draft vocabulary. A chain keeps its first
draft and stops before a later one whose temperature-1 probability falls under the confidence (6 drafts and 0.60 by
default; TF_FLASHNEXT_DRAFTS and TF_FLASHNEXT_CONFIDENCE). The head runs over a prompt a chunk at a time beside the
main pass (Ops.prefillChunk), so it keeps only a chunk's streams, not the whole prompt's.

A verify window (the pending token and the drafts) is one batched pass whose rows keep the bits they have alone, each
row drawn at its own keyed position with the draw serial decoding makes. The window runs the DeltaNet chain out of
place: kept whole, its end states swap in; cut, the kept rows replay from the window's start states and the projection
rows the pass kept, the conv windows and the n-gram tail shift by the kept rows alone. capture_mtp.sh records the head's
own Triton forms. flashnext-prompt with TF_FLASHNEXT_DRAFTS decodes each prompt serially and drafted and compares them
token for token (TF_FLASHNEXT_SEED for keyed sampled draws). The server does not use the head yet.
… windows, shared rounds, kept prompt states and prompt steps

The lane backend (cuda_lanes.zig) plugs into the core's round loop, prompt cache and lane host; the family adds no
scheduler. Each stream owns its caches (the 36 DeltaNet conv windows and states, the 12 attention layers' KV,
indexer keys and pooled blocks, the n-gram tail and history), keyed by the core's stream.

- With the MTP head, a drafting stream's verify window is one batched pass; several streams' windows come in one verify
  call, and each window keeps its own n-gram rows so another stream's pass can reuse the scratch before it is kept.
- Streams without drafts (--no-drafts, or "draft": false) take their next token together in one decode round
  (Engine.round) of up to 32 rows: each stream's reply is the one it gets alone.
- prefill_step runs a prompt a chunk at a time (the engine's 1,536 rows, cut at the cache's marks), so the lane host
  runs the other streams' rounds between a long prompt's chunks instead of after the whole prompt.
- The prompt cache keeps a stream's state at the marks the store asks for (cuda_snap.zig: the DeltaNet states, the
  attention rows and complete pooled blocks, the n-gram tail, and for a drafting stream the MTP head's cache rows too),
  and a later prompt restores it and runs the rest; the batched pass gives the same bits however a prompt is cut. The
  head reads one token past a row, so states keep with the core's lookahead of 1, as Nemotron's do. A drafting stream
  resumes only from a state that holds the head's rows; one without them serves a stream without drafts.
- A stream's bytes are its caches and, with the head, its head cache and a chunk's streams; the pass's scratch is
  reserved once at load.
…cannot read them before the fill lands

A fill with no stream (DeviceBuffer.fill8(..., null), cuMemsetD8) runs on the legacy stream and, for device memory,
returns before the memset is done; the engine's passes run on a non-blocking stream, which the legacy stream does not
order against. A new sequence's DeltaNet conv windows and states, attention caches and n-gram tail, a drafting stream's
head cache, and the pass scratch's zero row were filled that way, so the first chunk of a prompt could read a buffer
before it was zeroed, for example the previous sequence's DeltaNet state. Every fill now goes on the compute stream (and
the scratch's inverse frequencies upload there too), ahead of the passes that read them, as the token-id upload does.

The drafted check showed it: it zeroes the DeltaNet states between a prompt's serial and drafted runs, and in one run of
six prompts the prose prompt's drafted decode left the serial tokens at token 9 (113 drafts where its other runs make
111). A variant that skips the reset, so the drafted run starts from a state that is not zero, gives a mismatch of the
same kind; with the fills ordered, the check's repeats are equal, under CPU load too. The same pattern was in the row
path the prompt check uses, which is fixed alike.
…the recipe index, RUNBOOK and CHANGELOG

docs/recipes/flash-next-cuda.md says what the CUDA engine serves and how: the checkpoint it takes and the ones it
refuses, the captured kernel set and how to capture and pack one, the checks (the prompt and drafted checks, the
block selection, the rows kernel, served exactness) and measurements on a GB10, with what it does not do yet. The Flash
Next, CUDA and zig-family pages point to it, and CHANGELOG has the Unreleased entries.
… epsilon, RoPE, gates, activations, layer roles

cuda_config.zig checked the shapes and the quantization but let other settings through while the kernels evaluate
fixed constants. semantics() now refuses, by field name, a checkpoint whose text config changes what the CUDA path
hard-codes: rms_norm_eps (taken as fp32 1e-6), RoPE (rope_theta 10,000,000 in rope_parameters or at the top level,
partial_rotary_factor 0.25, a type other than default, a scaling factor or rope_scaling), output_gate_type (sigmoid),
hidden_act (silu), attention_bias, attn_output_gate, norm_topk_prob, mamba_ssm_dtype (float32), tied embeddings,
dedicated MTP embeddings, indexer_budget and indexer_kv_heads, the n-gram layer (ple_layer_ids [2], ple_embed_dim,
ple_conv_kernel_size 4, ngram_size 3, which is the conv's dilation), the layer roles (three linear-attention layers,
then one full) and the MTP layer's (one full-attention layer at the same RoPE base). An absent field means the
Python engine's default, as its config reader takes it. The n-gram hash's own fields (heads_per_ngram, the vocabulary
base and divisor, split parts, seed) are read and honoured by the n-gram reader; mrope_section moves image rows only.
Tests change each setting in turn and expect its name back.
zeros() counted a buffer only after its fill, so when the fill failed the errdefer freed the buffers before it and
leaked the one just allocated. The loop is now made(): a buffer is counted as soon as it is allocated, before its
fill, so a failure frees every buffer made, the last one too. A test with a fake buffer fails the fill at the first,
second and fourth of four and checks none is left live.
…s until the copies land

Pass.stage() copied a chunk's token ids and attention positions, and chunk() the rows' n-gram embeddings and their
group sums, from allocator memory freed when the function returned, with cuMemcpyHtoDAsync on the compute stream.
For pageable memory CUDA documents that call only as "might be synchronous": the driver stages the bytes before it
returns on the drivers we run, but nothing promises it, and freed memory may be reused before a copy that was not
staged lands. The scratch now owns one pinned buffer for all four (ids, positions, n-gram rows and sums, at
16-byte-aligned offsets) and an event recorded after each pass's last upload; the next pass waits on the event
before it writes the buffer (the gather thread writes the n-gram rows into it directly). The row-by-row check path's
two uploads from stack values now wait for their copy before the frame goes.
…te_rows.cu)

tf_route_logits gives OUT[m, e] = x[m] . w[e] in fp32 for bf16 rows and router rows on mma tiles: one chain over K in
16-wide steps from zero, the inputs staged with cp.async and read back with ldmatrix. A row's sums never depend on the
row count or the tile, so the few-row form (up to 16 rows, three 256-wide stages in flight) and the prompt form (two
128-wide stages) give the same bits. tf_route_topk takes a row's top k of its first E logits (the lower id among
equal ones) with weights bf16(e^(l_k - l_0) / their sum, summed in pick order), and optionally a shared expert as slot
k at bf16(sigmoid(bf16(logit E))), the softmax routing Qwen-style MoE families use.

core/route_ref.zig is the float64 reference, and tf-cuda-test route-rows checks both kernels against it at 1, 3, 16,
17 and 40 rows (logits within the fp32 dot's bound, picks equal to the reference rule's on the kernel's own logits, a
row of ties too, weights and the shared gate within half a bf16 ulp plus 2^-18), and every row count's bits equal.
…ith their group sums

embed_rows.cu's affine 4-bit rows take the scale group (32 or 64) and a copy count (EmbedRows.affine4Groups; affine4
keeps groups of 64 and one copy, so Nemotron's launches are unchanged). norms.cu gains tf_rms_rows_sums (Norms.rmsSums):
bf16 rows read x_ld apart, y = bf16(x * rsqrt(mean(x^2) + eps) * w) with fp32 weights and IEEE division and square
root, then each group's sum of the stored y, for a group-quantized matmul's input. tf-cuda-test norm-rows checks both
against float64 at every row count (Flash Next's 2,560-wide embedding in 4 copies, its MTP head's 2,560- and 10,240-wide
norms) with the bits of every row count equal.
…attn.cu, against float64

Our kernels for every entry point the batched pass took from a captured Triton set, with the Python engine's formulas
and bf16 rounding points, IEEE division and square root and the core's within-one-ulp exp (glue_math.cuh), built
without contraction:
- flashnext_hc.cu: the hyper-connection write-back (stream sums alone, the MoE slots, K slices), the down projection
  with its normed rows (the tiled 4-bit dots on mma tiles, groups in order, per K slice), the slices' sum with the bf16
  SiLU and inject gates, the up projection with the stream mix, the n-gram gate and dilated conv, and the MTP head's
  stream adds;
- flashnext_attn.cu: the q, k and indexer heads' norm and RoPE with the cache writes, the output gate, the indexer's
  pooled blocks and block scores, and attention over 512-key chunks (q . k and p . v on mma tiles, an online softmax
  with fp32 sums, dense or through a sparse row's list) with the chunks merged in order.
Every kernel keeps a row's own sums in a fixed order, so a row's bits are the same at any row count; attention has a
form that reads the next tile ahead for a decode round's few rows, with the same sums. cuda_glue.zig launches them
(Glue), with the core's token rows, RMS rows and route rows for the embedding, the MTP head's norms and the router.

tf-cuda-test flashnext-hc and flashnext-attn check each kernel at the model's shapes against a float64 chain that rounds
where the kernel rounds: each bf16 output within one bf16 ulp (an intermediate rounding can tip either way), fp32 sums
within their sum-of-|terms| bound, attention within 2^-8 of sum p |v| / l plus half an ulp (p enters p . v as bf16),
copies and the stream adds exact, and the bits of 1, 3 and 17 rows (attention: 1, 3 and 20, both forms) equal.
…y entry point is used for comparison

The engine no longer needs TENSORFOLD_CUDA_KERNELS: every launch Tri made from a captured cubin now runs our kernels
(cuda_glue.zig) unless a set is loaded. A set given by TENSORFOLD_CUDA_KERNELS, or found beside the binary, replaces
them only when it holds all 15 entry points the pass launches (17 with the MTP head); one without them, Nemotron's
say, is passed over with a log line, and the startup line says "own glue kernels" (native/cuda.zig asks the family's
Loaded.own_glue; aot.Set.has names a set's functions). flashnext-prompt takes native in place of a set's folder.

flashnext-compare loads the engine once and runs a prompt on our glue and on a captured set: every prompt row's scores
(top-1 agreement, the largest score difference, KL, each path's perplexity on the prompt's own next tokens), then
each path's greedy reply and the token where they part.
…ue beside a captured set

With a captured set loaded, TF_FLASHNEXT_OWNED (all, or a list of Triton names such as _chunks,_merge) makes those
entry points run our kernels and the rest the set's, so a difference can be put down to one kernel. Glue reads it once
at load into a mask; without a set every entry is ours, as before. flashnext-compare takes it on its own side, so it
compares the captured set against the captured set with the named entries swapped.
…for comparison

The recipe, the RUNBOOK line, the CUDA and Flash Next pages and the CHANGELOG say the engine needs no capture: every
kernel is built from the repository, TENSORFOLD_CUDA_KERNELS names a captured set only to compare against the Python
engine's teacher path, and TF_FLASHNEXT_OWNED swaps single entry points. The recipe lists the new tf-cuda-test cases
and flashnext-compare, the semantic settings the config check refuses, measurements on our kernels beside the
captured set's, and where our tokens part from the teacher path. The family recipe's lane-kernel row names
route_rows.cu.
@ZackSample

Copy link
Copy Markdown
Contributor Author

Thanks, @ashhart. 7746bb9 is pushed: the branch is rebased onto 027ddea (main with #835's cache guard) and has
eight new commits on top of the reviewed ones, then a docs commit. Point by point:

1. Startup without a capture (cuda_lanes.zig:245), and owned implementations of every captured entry point.
Flash Next boots on kernels built in this repository; open no longer refuses without TENSORFOLD_CUDA_KERNELS
(zig/src/families/flashnext/cuda_lanes.zig open/comparison). Every one of the 17 entry points has an owned
implementation, and the cuda_launch.zig wrappers take it whenever no captured set is loaded (Tri.glue, cuda_launch.zig:66):

Captured entry Owned implementation Where
_embed core token rows, now groups of 32 and a copy a stream zig/kernels/cuda/embed_rows.cu, EmbedRows.affine4Groups
_rmsnorm (MTP) core RMS rows with their 32-group sums zig/kernels/cuda/norms.cu tf_rms_rows_sums, Norms.rmsSums
_router, _topk_rows new core routing rows: logits on mma tiles, softmax top-k with a shared gate zig/kernels/cuda/route_rows.cu, zig/src/cuda/route_rows.zig
_hc_writeback (modes 0, 2, 4), _qmm_hcdown, _hc_reduce_act, _qmm_upmix family hyper-connections zig/kernels/cuda/flashnext_hc.cu
_ple_gate, _ple_conv family n-gram gate and dilated conv flashnext_hc.cu
_add_streams (MTP) family stream adds flashnext_hc.cu
_attn_prep, _attn_gate, _chunks, _merge family attention (q/k/indexer norms and RoPE, output gate, 512-key chunks, merge) zig/kernels/cuda/flashnext_attn.cu
_pool, _scores the sparse indexer's pooled blocks and block scores (selection was already ours) flashnext_attn.cu

The captured path stays for comparison: a set given by TENSORFOLD_CUDA_KERNELS (or bundled beside the binary) is used
only when it holds all 15 entry points (17 with the head), and the startup line says which glue runs. A set without
them, Nemotron's for instance, is passed over rather than failing a launch mid-request. flashnext-prompt takes
native, and the new flashnext-compare runs both paths on one loaded engine.

Each new kernel has a tf-cuda-test case against float64 (route-rows, flashnext-hc, flashnext-attn, and
norm-rows for the core changes); each checks every output against a float64 chain that
rounds where the kernel rounds (bf16 outputs within one bf16 ulp, fp32 sums within their sum-of-|terms| bound,
attention within 2^-8 of sum p|v|/l plus half an ulp) and that 1, 3 and 17 rows (attention: 1, 3 and 20, both of its
forms) give each row the same bits.

Owned against captured, measured on the same build:

  • Python teacher path, 32 greedy tokens (flashnext-prompt): captured 32/32 on all five prompts. Ours 32/32 on the
    19-, 52-, 600- and 12,001-token prompts, and on the 3,001-token prompt ours parts at token 15, where the captured
    path's best two scores are 0.375 apart. The captured path's 32/32 stays available as the comparison.
  • Teacher-forced, every prompt row on one loaded engine (flashnext-compare): top-1 agreement 100% on the short
    prompts and 96-97% on the long ones; KL(captured || ours) mean 7e-4 to 0.028. Over 4,096 tokens of this repository's
    documentation the next-token perplexity is 6.168 captured against 6.178 ours (median per-token NLL difference
    1e-6 nats). On the Python-reference prompts (3,001 and 12,001 tokens) ours is higher (186 against 134, 412 against
    369). Those prompts are hypersensitive to any change of bits: with everything else captured, swapping in a single
    one of our kernels that is not bit-identical moves the 3,001-token prompt's perplexity from 134 to anywhere between
    119 and 183 (between 209 and 654 past 2,048 tokens, where all of ours gives 483), and the 600-token prompt's from
    15.38 to between 12.9 and 16.2, in both directions, as the whole set does.
  • Bit-identical to the captured cubins: _embed, _router, _pool and _scores (swapping them alone gives KL 0
    and the same perplexity to the last digit). The other sums change at the ulp level, and why: IEEE division and
    square root and a within-one-ulp exp where Triton uses its approximate forms (the norms' 1/sqrt, sigmoid, SiLU,
    softmax exps, the output gate); fixed-order block and warp sums where Triton reduces in its own tree (the
    write-back's and n-gram gate's squared sums, the group sums the 4-bit dots take, attention's p sums); attention
    tiles of 32 keys against Triton's 64, so the online softmax rescales at other points; explicit fused multiply-adds
    in the dot epilogues and the merge; sincosf with full range reduction for RoPE.
  • Speed, the same build and session: one stream drafted 84.2 / 62.6 tok/s code / chat against 82.5 / 62.1 captured;
    --no-drafts 35.1 / 35.1 against 35.2 / 35.2; 4 sampled streams without drafts 77.9 against 77.1; drafted 4 greedy
    streams 82.2 against 80.7; cold first token at 3,001 / 12,001 tokens 2.67 / 10.40 s against 2.67 / 10.30; a
    45,030-token prompt with no cache 36.5 s against 35.8.
  • TF_FLASHNEXT_OWNED=all or a list of Triton names (_chunks,_merge) runs those entry points on ours beside a
    loaded set, which is how the per-kernel numbers above were taken; it is read once at load and documented in the
    recipe.

2. cuda_config.zig:42 accepted changed semantic settings. semantics() (zig/src/families/flashnext/cuda_config.zig:105) now refuses,
by field name, every setting the evaluation fixes instead of honouring: rms_norm_eps (taken as fp32 1e-6), RoPE
(rope_theta 10,000,000 in rope_parameters or at the top level, partial_rotary_factor 0.25, a type other than
default, a factor, or rope_scaling), output_gate_type (sigmoid), hidden_act (silu), attention_bias,
attn_output_gate, norm_topk_prob, mamba_ssm_dtype, tied or dedicated MTP embeddings, indexer_budget and
indexer_kv_heads, the n-gram layer (ple_layer_ids [2], ple_embed_dim, ple_conv_kernel_size, ngram_size,
which is also the conv's dilation), the layer roles (layer_types: three linear-attention layers, then one full) and
the MTP layer's (mtp.rope_theta, mtp.num_hidden_layers, mtp.layer_types). An absent field means the Python
engine's default, as its config reader takes it. The n-gram hash's own fields (heads_per_ngram, the vocabulary base
and divisor, split parts, seed) are read and honoured by readSpec; mrope_section only moves image rows, and text
rows rotate every axis by the same position. Two new tests change each setting in turn and expect its name back
(cuda_config.zig:208, :239).

3. cuda_prompt.zig:71-72 leaked the buffer whose fill failed. zeros() counted a buffer only after its fill, so a failed fill freed the
buffers before it and leaked the one just allocated. The loop is now made() (cuda_prompt.zig:79-92): a buffer is
counted as soon as it is allocated (:88), before its fill, so a failure frees every buffer made, that one too. The
test at cuda_prompt.zig:396 fails the fill at the first, second and fourth of four buffers with a fake buffer type and
checks none is left live.

4. #835's saved-prefix borrowing for deferred admission. It is in the base: the branch sits on 027ddea, so the lane host's borrow
holds a looked-up entry from lookupRewind (zig/src/core/prompt_cache.zig:283) until reported() releases or
resumes it (zig/src/core/lane_host.zig:84-90), eviction skips borrowed entries (prompt_cache_policy.zig:8, :14),
and a requeued admission releases its lookup (lane_host.zig:448). Flash Next is a deferred-admission case: its
prompts go through prefill_step, so a job admitted to filling (lane_host.zig:357) restores only at its first
chunk, after other streams' chunks may have kept states. Its backend reads s.reuse.saved in exactly one place, the
lane's open on that first chunk (cuda_lanes.zig:208, from prefillStepFn at :342), while the borrow holds, and
never after reported() clears it. The served receipts below (resumed equal to fresh, with 4 streams at once, and the
45k prompt resumed) ran on this base.

5. The temporary upload sources at cuda_batch.zig:366-376. They could outlive their memory only if the driver did not stage them. stage() copied
the token ids and positions, and chunk() the n-gram rows and their sums, from allocator memory freed when the function
returned, through cuMemcpyHtoDAsync on the compute stream. CUDA documents a pageable source for that call only as
"might be synchronous": the driver we run stages the bytes before it returns (a check that queues such a copy behind a 0.1 s kernel and
overwrites the source as soon as the call returns found 0 of 20 copies, 16 bytes to 8 MiB, reading the new bytes), but
nothing promises it, so the code was
correct by the driver's behaviour, not by contract. It no longer depends on it: the pass's scratch owns one pinned
buffer for all four (cuda_batch_state.zig:203-244, 16-byte-aligned offsets) and an event recorded after each pass's
last upload (uploaded, cuda_batch.zig:377, :447); the next pass waits on that event before it writes the buffer
(ready, cuda_batch.zig:367, :430), and the host gather writes the n-gram rows straight into it. The row-by-row
check path had the same shape with stack values (cuda_rows.zig:113, cuda_prompt_step.zig:241); it now waits for
those two copies before the frame returns.

Receipts on 7746bb9. On our kernels: drafted equals serial on 6 prompts greedy and 5 seeded; every
bench_concurrent.py --alone --serial cell equal to its solo run (112/112) and drafted cells equal to "draft": false
(30/30); resumed equals fresh (1,153 and 1,139 tokens resumed, alone and 4 at once, drafts on and off); 45,030 tokens
at --context 65536 give one token_sha drafted, "draft": false and both resumed (45,025 cached);
flashnext-select equals Triton's lists; zig build test-cuda-host 29/29 steps and 503/503 tests from a fresh cache, each commit after #476's builds (zig build) and passes them; zig build native builds from 8391cac on (#476's tip 0d04c43 and the commit after it stop at native/cuda.zig's F.max_segments, which the registry commit supplies); tf-cuda-test smoke, symbols,
experts-rows, norms, norm-rows, sample, glue, pocket, quant-ref, modelopt, exlr8, route-rows, flashnext-hc and
flashnext-attn all pass; lean 0 new against 027ddea; zig fmt --check clean; all 42 fatbins build for sm_86, sm_120 and sm_121; a captured set without Flash
Next's entry points is passed over at load and the server answers on ours. The full tables are in the description.

We have not inspected the captured cubins either; with this change the default path no longer depends on them.

@ashhart

ashhart commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Thank you, @ZackSample, for replacing the capture dependency with owned implementations for all 17 native serving entries.

At 7746bb9, the epsilon, gate, theta and zero-fill cleanup findings are closed. Two paths still need attention. zig/src/families/flashnext/cuda_config.zig:114 accepts sliding_attention where full attention is required because it only distinguishes linear from non-linear. At zig/src/families/flashnext/cuda_batch.zig:376, an earlier upload can remain queued if the next upload or event fails, leaving an old fence protecting reused host memory.

Please send a follow-up with exact layer-label validation and a drain before reuse or free after partial-upload failure.

Posted by Ash's AI Assistant Synthena

…rnels run

The check compared each label only with linear_attention, so a full-attention layer labelled sliding_attention (or
any other word) was accepted and served as full attention. Every label now has to be linear_attention or
full_attention, as the layer's place in the stack says. The new test relabels full and linear layers in turn and
expects layer_types back; on the old check the full layer relabelled sliding_attention came back accepted.
…he staging is reused or freed

A pass queues its uploads from the scratch's pinned staging, then records an event behind them, and the next pass
waits on that event before it writes the staging again. If a later upload or the record failed, an earlier upload of
the same pass stayed queued while the event still marked the previous pass, so the next pass, or the scratch's free,
could change or release bytes a copy had yet to read. The staging now notes an upload queued after the last record
and, until a record covers it, waits by draining the stream instead of on the event. Scratch.free waits the same way
before it frees the staging. The new test runs a clean pass and then one whose second upload, or whose record,
fails, against a stand-in queue, and expects nothing queued at the next wait; waiting on the event alone, as before,
leaves one copy queued.
ZackSample added a commit to ZackSample/TensorFold that referenced this pull request Oct 16, 2026
…w-c-fnback (ashhart#869 and the backward kernels)

Flash Next's trainer needs both open PRs: the CUDA family (ashhart#869) and the learner in zig/src/living (ashhart#878). The two
touched zig/build/cuda.zig and native/cuda.zig: the build keeps both the flashnext and living modules, and the native
host keeps @hasDecl(F, "slide") because Flash Next does not declare one until the next commits.
@ZackSample

Copy link
Copy Markdown
Contributor Author

Thanks, @ashhart. 4b46157 is pushed with both, a commit each:

1. Exact layer labels (e7163c5, zig/src/families/flashnext/cuda_config.zig:112-115). Each layer_types entry must
now be the exact word for its place in the stack, linear_attention for the three linear layers of each four and
full_attention for the fourth, so sliding_attention, attention or a misspelling on any layer is refused by name.
The new test (cuda_config.zig:239) relabels a full layer sliding_attention, attention and Full_Attention, the
last layer sliding_attention, and a linear layer full_attention and sliding_attention, and expects
layer_types back each time; on 7746bb9's check the full layer relabelled sliding_attention came back accepted.

2. A drain after a partial upload (4b46157, cuda_batch_state.zig:144-190, :271-285, :392-398). The pass's
uploads now go through the scratch's staging (Staging(Uploads)), which notes an upload queued after the last
recorded event (open). The event is recorded after a pass's last upload as before (uploaded), and that clears
the note. Until it does, because a later upload or the record failed, ready() drains the stream instead of waiting
on the old event, so the next pass never writes the pinned bytes, and Scratch.free (which now waits the same way
first) never frees them, while a copy may still read them. Pass.upload and both upload sites
(cuda_batch.zig:154, :376, :446) go through it. The new test (cuda_batch_state.zig:447-497) runs a clean pass,
then one whose second upload fails, one whose record fails, and a clean one, against a stand-in queue that counts
copies still in flight, and expects none at the next wait. With ready() waiting on the event alone, as 7746bb9's
did, it fails with expected 0, found 1.

Receipts on 4b46157 (GB10, sm_121, driver 580.178.04, CUDA 13.0.88; Unbound-LR7 2ae3a2ff):

  • zig build test-cuda-host from a fresh cache: 29/29 steps, 505/505 tests (7746bb9: 503; the two new tests).
    e7163c5 alone: 504/504. Both commits build (zig build, zig build native).
  • Our kernels: flashnext-prompt short, mid, long and long12k 32/32 against teacher32; drafted equals serial on 4
    prompts (prose, code, short, long; 2.19 to 4.38 tokens a round); tf-cuda-test smoke, symbols, flashnext-hc,
    flashnext-attn, route-rows, norm-rows, experts-rows and glue all pass.
  • Served (tensorfold-native serve, --parallel 2 --no-thinking --temperature 0): two prompts give the same
    replies and token_sha drafted and with "draft": false (117 and 146 tokens), and both at once equal each alone.
  • zig fmt --check build.zig zig clean; python3 tools/zig/lean_check.py 527 problems against 527 (0 new).

#895 and #896 sit on this branch, so they are rebased onto 4b46157 and pushed (lw-c-fnback 0211f4d,
lw-d-fnlearn bad3a2a); their diffs are unchanged apart from #896's training pass calling uploaded() without the
stream, which the staging now holds. Every commit of both builds (zig build, native, test-cuda-host, and
test-living after the merge) and passes zig fmt; from a fresh cache test-cuda-host gives 508/508 at #895's head
and 549/549 at #896's, and lean_check 527 at both.

Heads-up: our two DGX Sparks have been running 24/7 for a week on this, so they're getting a break. Replies on these PRs may be slower for a few days.

@ashhart

ashhart commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Thank you, @ZackSample, we will carry the Flash Next CUDA tests and staging fixtures with your credit into the selected Flash Next CUDA base; the landing order and remaining checks are in our reply on #952.

Posted by Ash's AI Assistant Synthena

@Mirrdhyn

Copy link
Copy Markdown
Contributor

Ran #869 (4b46157) on a second GB10 (sm_121, CUDA 13.2, -Dsm=121) with an MLX affine 4-bit/g32 checkpoint with its MTP head:

  • tf-cuda-test flashnext-hc, flashnext-attn, route-rows, norm-rows, experts-rows: all PASS.
  • Our own French correctness probes: 8/8 direct and 8/8 thinking, same as the Python 0.6.4 engine on the same checkpoint; tools 6/7 (the miss is the explicit tool_choice: "required" refusal).
  • Greedy decode (bench_openai, 256 tokens): 86.3 tok/s code, 69.2 chat.
  • Cold prompts: 858 tok/s at 13.6k and 55k tokens, against 2,600 for Python 0.6.4 on the same hardware.

An nsys profile of a 12,689-token prompt shows the GPU busy 99% of the time. 35% of it goes to expert_rows (the decode kernel reused for prompt rows), 19.5% to the g32 lane qmm_kernel, and 23% to the HC glue.

If nobody is on it, I'd like to take the prompt path for g32: qmm_prefill and experts_prefill instantiated for group 32, as Nemotron uses them for g64, with the usual same-bits-at-any-chunking checks. I'll post receipts from GB10. Does that fit your plan for #952?

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.

5 participants