Skip to content

CUDA input gradients for Living Weights on Flash Next: a Gated DeltaNet layer, 4-bit products, routed experts, gated attention (shared), and the hyper-connections and pocket (family) - #895

Open
ZackSample wants to merge 49 commits into
ashhart:mainfrom
ZackSample:lw-c-fnback

Conversation

@ZackSample

Copy link
Copy Markdown
Contributor

What this changes

These are the kernels Living Weights' trainer needs to run the loss's gradient back through Qwen3.8 Flash Next on CUDA.
There is no serving change: nothing on the serve path calls them, and no shipped file outside the kernel list, the
runtime's exports and tf-cuda-test is touched. The trainer that uses them is the next PR (lw-d-fnlearn).

Following #736 and the recipe's core/family split, what any family with the same layer could use sits in the core
(zig/kernels/cuda/*.cu with a zig/src/cuda/*.zig wrapper), and only what is Flash Next's own sits in
families/flashnext:

  1. Core: a Gated DeltaNet layer's input-gradient backward (gdn_train.cu, cuda.gdn_train). The forward for
    training is recomputed from the layer's kept projection rows (conv, q and k L2 norms, g and beta, the delta-rule
    chain with a state checkpoint every 4 rows), with the serving chain's operations in its order, so y has the serving
    bits. The backward walks each chunk back after replaying its states into an L2-sized work area. Per row, with ds
    the state's gradient carried: ds += dy_j q; dq = sum_j dy_j S_j; dd_j = ds_j . k; dv_j = beta dd_j;
    dbeta = sum_j dd_j (v_j - S'_j . k); dk = sum_j (delta_j ds_j - beta dd_j S'_j); ds' = ds - beta dd k^T;
    dg = sum ds' . S_prev; ds = g ds'. Then the gated RMS norm, the L2 norms, the gates, the causal conv and its SiLU,
    and the in and out projections. Gradients into a kept prefix's state and conv history are dropped, as Nemotron's
    Mamba backward drops them. Shapes are parameters (key and value heads, hidden), so a Qwen3.8-27B-shaped DeltaNet uses
    the same kernels.
  2. Core: 4-bit (MLX affine, group 32) products, routed SwiGLU experts and softmax top-k routing (train_q4.cu,
    cuda.train_q4). A linear's input gradient on tensor cores (tf_train_gemm's tiles, split K for a 248,320-row head),
    the experts' products over MLX stacks or over experts_pack.cu's packed table (the engine's own copy, so no second
    copy of the experts), each pair's SwiGLU, and the top-k softmax weights and the sigmoid shared gate back through the
    router. tf_train_sort_pairs now takes up to 1024 experts (512 and a shared one); below 256 it does what it did.
  3. Core: gated attention (train_attn.cu, cuda.train_attn), Qwen3-Next's form: each head's q then its output
    gate, RMS-normed q and k, rotate-half RoPE on the first 2 half dims. The prepared heads again, the output gate, the
    causal part to 2048 keys (with train.cu's tf_train_attn_kv), the RoPE and norms undone, and the three projections
    through cuda.train_q4.
  4. Family: the hyper-connections and the pocket (flashnext_train.cu, families/flashnext/cuda_fn_back.zig). The
    write-back h' = h + bf16(branch inject) and the read-out (mix, its activations, down and inject, the streams' RMS
    norms) undone; and Living Weights' pocket of SwiGLU neurons beside the shared expert, behind its gate: the forward in
    two kernels (tf_fn_pocket_in then _out, tiled forms past a size, nothing launched for a layer with no neuron in
    use; a port of nemotron cuda: skip empty pocket layers and spread the pocket over many blocks #787's two-kernel pocket to this family), its input gradient and its output vectors' gradient.
    flashnext.fn_back composes these with the core's on one stream; its MoE backward runs the core's experts, then the
    pocket (whose shared-gate share joins the gate's gradient), then the core's router.

Every comment is one line; the block comments the derivations used to be in are in this description instead.

Receipt

  • Environment: lw-c-fnback a5d183d on 7746bb9 (Flash Next on CUDA: the qwen4_exp family on the native CUDA host, every stream through the lane rounds #869), Zig 0.17.0; NVIDIA GB10 (DGX Spark, sm_121), driver
    580.178.04, CUDA 13.0.88, Ubuntu 24.04.5. Synthetic tensors at Flash Next's shapes; no checkpoint.
  • Each case compares the GPU's gradients with a float64 reference, and first checks that reference against central
    differences of a float64 forward. A gradient's error is its worst column (the largest difference in a column over that
    column's largest reference value) and its relative L2 error. The bf16 products' tolerance is 3% worst column and 1%
    L2; the fp32 chains' is 1e-3.
    • tf-cuda-test gdn-back (16 key heads, 48 value heads, hidden 2560, 4-bit group-32 projections, 64, 256 and 37 rows,
      the last after a prefix with a conv history and state): forward bf16 outputs within 1 ulp of float64's rounding
      (y: 25 of 227,328 values 1 ulp off), every chain gradient within 1.3e-6 worst column, dx within 1.5e-2 (4.7e-5
      with the GEMM's operands rounded as the GPU rounds them). PASS.
    • tf-cuda-test q4-back (the loss through a 248,320 x 2560 4-bit head, 64 and 256 rows): loss and target probability
      within 7.2e-8, the bf16 gradient rows within 2.6e-3 of the largest, dmixed worst column 6.6e-3, L2 2.6e-3;
      reference against central differences 8e-7. 7 checks, PASS.
    • tf-cuda-test moe-back (512 routed experts of width 640, the shared expert, top 10, 64 and 256 rows): dx worst
      column 2.7e-6, each slot's dL/dw 1.7e-6, the router logits' gradients 1.9e-6; the same layer over the packed table
      gives the MLX stacks' dx bit for bit; reference against central differences 1.2e-9. 11 checks, PASS.
    • tf-cuda-test attn-back (24 query heads, 2 kv heads, head dim 256, RoPE on 64 dims, 64 and 256 rows): dx worst
      column 1.3e-2, L2 3.1e-3; dpq, dpk, dv within 1.3e-2; the prepared heads within 2^-5 of the host's; reference
      against central differences 7.6e-9. 11 checks, PASS.
    • tf-cuda-test fn-hc-back (4 streams, low rank 320, a block's read-out with inject and the final mixer's, 64 and 256
      rows): dbranch and dinject within 1.5e-7, dh worst column 5.8e-3, L2 1.7e-3; references against central
      differences 6e-8. 13 checks, PASS.
    • tf-cuda-test fn-moe-back (moe-back with 16 pocket neurons): every gradient as above, the pocket's forward and its
      output vectors' gradients within tolerance, the packed table again bit for bit. 16 checks, PASS.
    • tf-cuda-test fn-pocket 20: the two pocket kernels against tf_fn_pocket_rows byte for byte in 444 cases (1 to
      2,048 rows across both tiling thresholds, 1 to 128 neurons, widths 2560 and 2600, fp32 and bf16 outputs, with and
      without the gate and the training row scales); controls that reverse the neurons or rotate the inputs change
      12,703 and 9,633 values, so the comparison sees order; 0 neurons launches nothing and keeps every bit, -0.0
      included. PASS.
    • fn-back runs all six gradient cases one after another.
  • Times on the GB10 (one layer, 256 rows): DeltaNet 6.8 ms (4.1 ms of it the forward and chain), MoE 26 ms (19.6 ms
    over the packed table), attention 3.3 ms, a hyper-connection read-out 1.2 ms, the head 16.7 ms.
  • Without the kernels (zig build with no -Dnvcc or -Dfatbins) each new command prints SKIP and passes.
  • Tests: zig build test-cuda-host from a fresh cache: 29/29 steps, 506/506 tests pass (Flash Next on CUDA: the qwen4_exp family on the native CUDA host, every stream through the lane rounds #869's head: 503); every
    commit builds with -Dnvcc and passes zig fmt --check build.zig zig; python3 tools/zig/lean_check.py gives 527
    problems against 527 on 7746bb9 (0 new); all 46 fatbins build for sm_86, sm_120 and sm_121 and check_fatbins
    passes.
  • Not run: Metal (no change there), sm_86 and sm_120 cards (none here).

Checklist

  • Every token still goes through the lane rounds. No serial path, and nothing that needs drafts off. (No serving
    change.)
  • Drafted output equals "draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (Not
    touched.)
  • No precision traded for speed. If the bits change, the description says which sums change and why. (No serving
    bits change. The backward runs in fp32 from bf16 operands and treats the forward's roundings as identities, as
    Nemotron's trainer does.)
  • Prompt processing is no slower than the last release. (Not touched.)
  • 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

  • No trainer, no --slide: they are the next PR, stacked on this one and on Living Weights as a core module: the learner, pocket and fits in zig/src/living, a family described as data (Nemotron only) #878.
  • The DeltaNet backward's two projections dequantize a 4-bit matrix to bf16 for each sequence before a dense GEMM;
    using cuda.train_q4's 4-bit product there instead is a speed-up I have not made.
  • train_q4 is group 32 only (Flash Next's); Nemotron's trainer keeps its own group-64 kernels.
  • Attention's backward takes at most 2048 keys a sequence (the trainer's sequences are 512 rows at most), so the block
    indexer's sparse attention has no backward.

Stack

Questions

  • Is the core/family split right? The DeltaNet, 4-bit product, experts and routing, and gated attention backward are in
    the core because Qwen3.8-27B (or any Qwen3-Next-shaped model) has the same layers; the hyper-connections and the
    pocket are Flash Next's own.
  • Should the experts' backward read only the packed table (the engine's copy) and drop the MLX-stack form? The stack
    form is what the float64 test feeds directly; the packed form is what the trainer uses.

heitke and others added 30 commits October 14, 2026 08:24
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
…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.
…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.
…t within float64's tolerance (tf-cuda-test gdn-back)
…erts with a shared one, and softmax top-k routing

train_q4.cu with cuda.train_q4: a linear's input gradient on tensor cores (split K for a head), the experts' products
over MLX stacks or experts_pack.cu's packed table, each pair's SwiGLU, and the top_k softmax and sigmoid shared gate
back through the router. tf_train_sort_pairs takes up to 1024 experts. tf-cuda-test q4-back and moe-back check them
against float64 at 64 and 256 rows, each reference against central differences; packed experts give the stacks' bits.
…d's q then its gate, RMS-normed q and k, partial RoPE)

train_attn.cu with cuda.train_attn: the prepared heads again, the output gate, causal attention to 2048 keys with
train.cu's key and value part, the heads' RoPE and norms undone, and the q, k and v projections' input gradients through
cuda.train_q4. tf-cuda-test attn-back checks every gradient against float64 at 64 and 256 rows (24 query heads, 2 kv
heads, head dim 256), the reference against central differences.
…nts, and fn_back over the core's layers

flashnext_train.cu: the write-back's and read-out's backward (mix, activations, down and inject, the streams' RMS norms),
and the pocket's SwiGLU neurons beside the shared expert behind its gate: the forward in two kernels (tf_fn_pocket_in, then
_out, tiled for many rows; a layer with no neuron launches nothing), its input gradient, and its output vectors'
gradient. flashnext.fn_back composes them with cuda.train_q4 and cuda.train_attn on one stream. tf-cuda-test fn-hc-back
and fn-moe-back check against float64 at 64 and 256 rows; fn-pocket checks the two kernels against tf_fn_pocket_rows
byte for byte over 444 cases; fn-back runs every input-gradient case the trainer uses.
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