Skip to content

glm: GLM-5.3-Flash (NVIDIA's NVFP4 checkpoint) as a CUDA family at TP over hosts - #804

Open
Bizuayeu wants to merge 13 commits into
ashhart:mainfrom
Bizuayeu:pr/glm53-flash-cuda
Open

Bizuayeu wants to merge 13 commits into
ashhart:mainfrom
Bizuayeu:pr/glm53-flash-cuda

Conversation

@Bizuayeu

@Bizuayeu Bizuayeu commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

The family #593 tracks: GLM-5.3-Flash on CUDA, tensor parallel over two GB10s (and three, below), on the shared NVFP4
layer (qlinear, experts.Grouped) and hand-written .cu for the rest. It serves through tensorfold-native serve
(rank 0 serves, the other ranks follow its steps), drafts (the checkpoint's MTP layer) and concurrency go through the
lanes core, and kept prompt states through the family's snap_* hooks.

This branch is 1.0.10 with the pieces split out first (#742, #759 and #761 are in 1.0.9, #800 and #805 in 1.0.10);
until the rest land, its first commits are theirs, and the review is the last five:

Next to the Metal GLM

One family package: config.zig is shared (this adds head_unit, shared_inter, norm_topk and the parse checks
the NVFP4 config needs), both backends sit behind the lane core's backend table, kept prompt states behind
api.prompt_cache.Snapshots, and the tokenizer, chat template and tool parsing stay above the family. The CUDA host
files are cuda_*.zig beside the Metal ones; they share no code today, for reasons in the code:

  • weights: Metal reads MLX q4g64 and re-lays tensors into its kernels' tiles while loading (weights_fix.zig); CUDA
    reads ModelOpt NVFP4 as stored with bf16 attention projections, cut per rank (cuda_weights.zig, cuda_shard.zig)
  • state: Metal holds per-layer mtl.Buffer slices with the position on the host; CUDA one device arena with the
    position on the device, which its kernels read
  • forward: Metal encodes ops on mtl.ComputeEncoder; CUDA launches kernels and NCCL exchanges on a stream
  • more than one machine: the same shape, rank 0 records each step and the others replay it (mirror.zig,
    cuda_follow.zig), over an MCDMA control window on Metal and ncclBroadcast on CUDA
  • page cache: macOS msync/mincore against Linux madvise/fadvise (core: a checkpoint's pages out of the page cache once a loader is done with them #766)

What could move into shared code in follow-ups, by value: one backend-neutral tensor plan both loaders read (checkpoint
name, dtype and shape, the axis cut, each rank's span; the names and cut axes already agree); a state-size table both
state.zig and cuda_state.zig take (the arithmetic is the same); the step tape apart from its transport; the
token-handle ring this family's lanes share with Nemotron's CUDA lanes, into the lane core. Splitting a Config across
machines differs too (Metal rewrites the Config to one Mac's share; CUDA keeps it whole and cuts with Share, which
allows uneven shares); taking Share on Metal would be the larger change, so it is a suggestion here.

Several requests in one forward

  • prefill_step: a prompt goes one 2,048-row chunk at a time and the other streams' rounds run between chunks.
  • hidden_rows: up to 8 streams' verify windows (32 rows) share one forward; row-wise work (projections, experts,
    exchanges, head) runs over all rows, each stream's KDA chain and DSA attention on its own sequence.
  • glm-shared-check holds every stream against a twin that runs each round alone: logits, tokens, drafts and state
    equal byte for byte at TP=2 on the full model.
  • Neither depends on the weight format: only the loader and the kernels behind qlinear / experts.Grouped do.

Speed

  • The port began on captured Triton cubins; every kernel is now hand-written. Against the Triton set on the same two
    GB10s in one sitting (median of 2): prompt 8,192 tokens 5.86 -> 5.26 s and 32,768 tokens 22.64 -> 20.54 s, with the
    same 49 greedy ids after each prompt; greedy decode without drafts 17.10 to 17.15 -> 17.10 to 17.20 tok/s (three chat
    prompts of about 2k tokens).
What we tried for speed during the port, kept and dropped

Measured while porting, each against the build before it in the same sitting: with Nsight Compute on one GB10 (the
five-layer forward check with both TP=2 ranks on it, over a 32k-token prompt where noted), with Nsight Systems on
rank 0 of the two GB10s (a decode step), or end to end on the two. Every kept change leaves each output's bits as
they were.

Kept

change aim measured
DSA index row selection laid out as Triton's: a row per 4-warp block, 8 consecutive pools a thread more rows an SM 21.0 → 14.3 ms over a 32k prompt (Triton 15.1), Compute
hyper-connection partial dots: 4 prompt rows a block, sharing the weight loads fewer weight reads 191.6 → 137.7 ms over a 32k prompt (Triton 177), Compute
hyper-connection finish: warp 0's Sinkhorn steps tightened a shorter decode step 7.30 → 6.23 ms over 234 calls, Compute; below the end-to-end resolution
decode BF16 matmuls: each shape takes Triton's _bmm setup (BLOCK_N 32 or 64, 1 to 3 cp.async stages, 4 warps) and issues the next stage's copy before waiting on this one Triton's decode matmul time a step's matmuls 39.31 → 37.56 ms (Triton 37.43), Systems; greedy decode from 4% to 0.6% behind Triton
greedy draws: each rank's top-1 on the device, then one all-gather no host pass over the logits (155 KB down, a scan, up, a wait) +1.2% tok/s
rank 0's step header: written through a kernel argument, no wait after its broadcast less idle GPU between steps +0.2% tok/s
a prompt chunk's exchanges in 3 row pieces (was 4) more of the exchanges behind compute 8k and 32k prompts 1.2% shorter
NVFP4 expert prompt kernel: the row-tile count a template constant (#805) the branch out of the mma chain at 2,048 rows swiglu 29.7% and down 15.4% shorter, Compute; 8,192 tokens 5.61 → 5.29 s, 32,768 21.86 → 20.71 s

Dropped

change aim measured, and why dropped
NVFP4 expert decode kernel: four k32 load groups in flight (was two) hide load latency 1 row 5.9% shorter, but 3 rows 7.9% and 40 rows 6.7% longer (gate/up, 2,048 wide), Compute; drafted windows and shared rounds are the rows a served decode has
CUDA graphs for the verify forward, captured per slot fewer launches level without drafts, 0.9 to 1.5% slower with them; the GPU idles 1.4% of a decode step
slice sums fused into the sliced decode matmul's last block fewer launches and reads 0.7% less in Compute, but a step 60.78 → 61.08 ms in Systems: the last block adds the slices alone at the end
TMA loads in the prompt tiles less address arithmetic 5.8% shorter in a cache-cold bench; split tiles with several stages returned wrong bytes beside another stream's device copy (not reproduced in the bench); TMA in the unsplit tile only: 32k 22.60 against 22.62 s
a drafted window's greedy rows exchanged at once (one all-gather, one wait) fewer waits within the run-to-run spread
a prompt chunk's exchanges in 8 pieces; 2 NCCL channels more overlap 32k 4% longer; no change
a tile for the 2,048 x 8,192 x 1,536 prompt matmul (2 slices) Triton's 746 us there best 821 against 841 us, cache-cold bench; 32 calls in a 32k prompt, at most 3 ms (0.08%)
swiglu with 8 values a thread fewer instructions it changes the order of a 64-value sum
decode exchanges overlapped with compute; independent branches on a second stream hide the exchanges not tried: the exchanges are 3% of a step, latency-bound, and the next op reads them; the GPU is 98.6% busy and bound by reads

Looked at and left as is: upstream's NVFP4 lane matmul (qmmf, 1.1% of a decode step, Systems) and the sampled draw
(a sampled step 0.1% longer than a greedy one). Neither Nsight Compute (fixed clocks, caches flushed) nor a cache-cold
bench ranked the decode matmul setups as the served decode did, so decode changes were judged on the two GB10s.

Receipt

  • Environment: this branch at 6380c8da on 1.0.10 (6c5e5192); the rows below are from one sitting on two NVIDIA GB10
    (driver 580.178.04, CUDA 13.3, NCCL 2.30.7), Linux 7.0.0-1019-nvidia with kho=off, over RoCE with NCCL;
    nvidia/GLM-5.3-Flash-NVFP4 at 423acf37; BF16 KV; --context 40960 --parallel 2 for the prompt and decode rows,
    --parallel 4 for the concurrent ones.

  • Exactness:

    • tools/bench_concurrent.py --alone --serial (levels 1 and 4, both prompts, 256 tokens, temperature 0, 3
      repetitions): all 30 replies equal their requests alone, and the alone runs equal "draft": false.

    • resumed = fresh: a chat conversation whose later turns resume the state kept at the history end (cached 2,048 of
      about 3,100 tokens) gives the same replies as a server without the cache, greedy, with drafts and without, and
      sampled (turns at 3,094 to 3,113 tokens).

    • In the engine at TP=2: glm-shared-check (108 rows of 5 streams over 12 rounds equal their lone twins byte for
      byte); glm-lanes-concurrent and glm-lanes-cover (cancels, discards mid-decode and after a rejected draft, eos
      at 0, 10, 33 and 101 tokens, max_new 1 to 5, a pass resumed at 2,048 and 4,096 against the fresh pass, 512 tokens
      with drafts) equal their plain greedy reference; rank 1 follows to the end.

    • Admission over the ranks (TP=2): with rank 1 alone under TENSORFOLD_MEMORY_RESERVE_GIB=21,
      serve --context 40960 --parallel 8 stops before serving: rank 0 refuses the eight streams, naming rank 1.

    • Float64 references for every projection whose reduction order is the kernels' own (tf-cuda-test glm-ref, seed
      593), the worst column's error over its tolerance:

      check worst
      BF16 matmul 300 rows x [512, 4096], K in 8 slices, bf16 / fp32 out 0.550 / 2.1e-4
      the same x [1280, 1536], 4 slices 0.798 / 7.0e-4
      the same x [1024, 128], 1 slice 0.982 / 1.5e-2
      router 300 rows x [256, 4096], 8 slices 1.7e-4
      top-8 of 256 weights (2^-16 relative) 1.8e-2, no near-tie swaps
      latent attention, 70 rows over 1,430 keys, 32 heads 0.181
      latent sparse attention, 70 rows, up to 1,100 of 6,000 keys 0.474
      hyper-connection dots, 70 rows x 16 K blocks 2.1e-3
      DSA index scores, 96 rows, 1,536 pools 2.1e-3; top-512 equals the host's rule
      KDA chain, 70 / 3 rows x 32 heads 0.438 / 0.369

      Every check also holds its kernels' outputs bit-equal across row counts and forms (prompt rows against decode
      windows, fused against three-kernel KDA). The bf16 ratios near 1 are the output rounding itself (2^-8 |y|).

  • Decode speed (tools/bench_openai.py, 256 tokens, temperature 0, 3 repetitions, median tok/s), with vLLM
    0.28.1rc1.dev580 (nightly 385dce36) on the same checkpoint and pair, with the profile we serve it with (FP8 KV,
    MTP k=3, no CUDA graphs, one sequence), in the same sitting:

    fibonacci-raw gpu-chat-no-think
    this PR, drafts 33.48 27.88
    this PR, --no-drafts 17.61 17.61
    vLLM, MTP k=3 33.93 24.97
    vLLM, no MTP 14.16 14.18

    Four concurrent streams (bench_concurrent.py, level 4, the chat and code prompts): 52.8 and 68.0 tok/s in all,
    against 27.9 and 33.4 alone.

  • Prompt speed (tools/prefill_cold.py as of 394d566c^, which the template names and 1.0.0 removed; its run is
    standard library only; lengths 2k to 32k, as the server holds 40,960, three prompts each with a unique first line,
    a rest after each long one outside the timed span), median TTFT in seconds:

    tokens 2,048 4,096 8,192 16,384 32,768
    this PR 1.305 3.063 5.872 11.460 22.745
    vLLM 1.465 3.058 6.649 13.871 28.138

    A decode beside a 32k prompt waits at most 1.68 s between tokens (the prompt in 2,048-row chunks).

  • Memory: the server's /memory peak 97.3 GiB of device memory on rank 0 (--context 40960 --parallel 2, through
    the 32k prompts); each host's memory in use above its idle baseline at most 100.5 GiB (this PR) and 111.7 GiB
    (vLLM, gpu_memory_utilization 0.80), sampled every second.

  • Tests run (empty cache): zig build test-cuda-host 511/511, zig build test 617/622 (5 skipped, none failed),
    tf-cuda-test quant-ref 4 and glm-ref 11 passed, the lanes checks on a synthetic two-layer checkpoint that
    tf-cuda-test glm-synth writes (cover 40, concurrent 38, shared 1) and on the model cut to five layers (cover 36,
    concurrent 38); at TP=3 on three GB10s (uneven shares: 22/21/21 heads, 704/704/640 expert rows) glm-lanes-concurrent
    and glm-shared-check; python3 tools/zig/lean_check.py finds nothing new against 1.0.10.

  • Not run, and why:

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. KV stays bf16; the sums whose order is the kernels' own are the ones glm-ref
    bounds against float64.
  • Prompt processing is no slower than the last release. (No release serves this checkpoint on CUDA, so against
    vLLM above; the shared kernels it carries leave prompt processing as it was, as their PRs say.)
  • The description says which platforms I ran, Metal M1 to M5 and CUDA, and which I could not.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. (A new family: its
    tests have no code to run before it. Its host tests run in zig build test-cuda-host, which does no GPU work,
    and read checked-in fixtures, not the checkpoint, so they have nothing to skip; the GPU checks are
    tf-cuda-test commands.)
  • 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.

@Bizuayeu

Bizuayeu commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Updated the description: the Speed section now gives the comparison with the Triton set run on this head (53309a72,
the same two GB10s in one sitting, median of 2) in place of the 1.0.8 numbers, which it matches within 1%; the receipt
tables drop this PR's (1.0.8) rows, which this head's rows replace; and the two checklist items left open are ticked
with their reasons.

@ashhart

ashhart commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Hello @Bizuayeu,

Thank you for the GLM-5.3-Flash CUDA family. On this head it calls packGateUp, decodeF32, promptF32, the expert skip, and Checkpoint.release. The prompt pass restores a kept prefix. The caches stay bf16. The change sits on v1.0.9, and the trial merge onto that base is clean. This pull request is not in 1.0.10. We have not run it.

Your body reports a serve on two GB10s at 53309a72, your head, which is not in 1.0.10: drafted replies equal to "draft": false, a resumed chat equal to a server without the cache, and the lane checks equal to the plain greedy reference. Concurrent streams are reported at 52.7 and 68.0 tok/s against 27.6 and 33.2 alone. Rows marked 1.0.8 are from 078e3230, your head on that sitting, not in 1.0.10. The Triton comparison says a run on this head will follow. Please say which speed tables are 53309a72 and which are still that 1.0.8 sitting.

Tensor parallel over hosts is what the code does. TF_GLM_RANK and TF_GLM_WORLD place the rank (cuda_native.zig:46). World above 1 requires NCCL_COMM_ID (cuda_native.zig:75). Rank 0 serves and the others follow (cuda_native.zig:1). A step goes out with ncclBroadcast (cuda_follow.zig:62). The ranks' partial rows meet with ncclAllGather, or with ncclSend and ncclRecv when a prompt piece is split (cuda_forward.zig:314, :330). config.parse keeps the whole config (cuda_engine.zig:97) and Share.of cuts it (:102, cuda_shard.zig:30). A prompt moves in 2,048-row chunks (cuda_state.zig:18). The shared forward holds at most 8 streams and 32 rows, and each window is at most 8 rows (cuda_engine.zig:30, :31, cuda_state.zig:20). The unit test on the default config expects a TP=3 split of 22, 21 and 21 heads and 704, 704 and 640 expert rows (cuda_shard.zig:56).

You report the server at TP=3 was not run, because a ring of /32 addresses gives the other ranks no bootstrap interface, and that the engine checks at TP=3 passed on 1.0.8. The comment at cuda_native.zig:123 says rank 0's budget is checked and the other ranks are not. The 97.3 GiB figure is rank 0. What refuses a follower before it loads?

The body lists #805 among the commits under review. d108e2a74 is your head and is not in 1.0.10. It has the same aim and a different patch from #805's head 4ed879dc3, which is in 1.0.10. Your 8,192-token and 32,768-token times are the GLM family at TP=2. Please confirm d108e2a74 should stay in this branch.

Four things have to change before this is ready.

  1. Drop b9420637c (your head, not in 1.0.10). raiseNofile (zig/src/server/listener.zig:15) does the job raiseOpenFileLimit already does (zig/src/server/serve.zig:50, called at start). That has been in serve.zig since 1.0.0. server: the soft open-file limit is raised to the hard one when the server listens #760 closes for the same reason.

  2. Engine.init has to call modelopt.admit (zig/src/core/modelopt.zig:26). Today it only calls config.parse (zig/src/families/glm/cuda_engine.zig:97), so a quant_algo other than NVFP4 is never refused on the serve path. admit returns error.UnsupportedQuant (modelopt.zig:35). The test expects MIXED_PRECISION to fail (modelopt.zig:100).

  3. RankScatter.run (zig/src/cuda/rank_scatter.zig:41) has no caller on the serve path. The forward already exchanges its own partials (cuda_forward.zig:327). Take daed17ba0 out (your head, not in 1.0.10), or make that forward call run and drop the other exchange. tf-cuda-test rank-scatter does not let it land alone.

  4. Your body says every kernel is hand-written and the captured cubins are gone. Please confirm the PTX in zig/kernels/cuda/glm_triton.cuh is that source. It is 60 lines (sqrt.approx, div.full, add.f32, mul.f32, ex2.approx, a block sum). Nothing vendored or captured lands. open already drops the captured-kernel argument (cuda_native.zig:73), which is the shape we want. The new files have no cubin and no SPIR-V.

Lines past 120 columns, the longest at glm_kda.cu:386, can be wrapped at landing.

Once those four are done, the gate is this family serving the NVFP4 checkpoint on a CUDA GPU. More than one GPU needs a second card and NCCL. The receipts are a drafted reply equal to "draft": false, a resumed prompt equal to a fresh one, and concurrent streams equal to their solo runs.

Thank you again.

…ecodeF32 and promptF32)

A tensor-parallel family sums its ranks' partial rows before rounding, so it needs a projection's fp32 sums as
the kernel holds them. qmmf.cu and prompt16.cu already take F32 as a template argument; this instantiates FP4's
fp32 tiles (and reduce_kernel<true> for the split path), loads them beside the bf16 ones, and adds
Lane.matmulOut, Prompt.matmulOut and qlinear's decodeF32 and promptF32. Other formats refuse fp32 out with
EpilogueNotBuilt, as the experts do. matmul, decode and prompt keep their signatures and bf16 bits.
…n block

The GLM-5.3-Flash CUDA port reads nvidia/GLM-5.3-Flash-NVFP4 as stored. admit() checks
config.json's quantization_config (top level, else text_config's) for quant_method modelopt,
quant_algo NVFP4 and 4-bit float weights in groups of 16, and refuses anything else with a
reason the caller logs (the Why of nemotron/config.zig), so a test reads the reason instead
of failing on an error log. MIXED_PRECISION is refused for now.

Which linears are NVFP4 is left to the tensors, not to the block's ignore list: the pinned
revision 423acf37 stores the MTP layer's experts in BF16 without listing layer 45 there
(da920bb0 added it). The fixture is that revision's config.json.
nvfp4.zig is the format the GLM-5.3-Flash CUDA port's W4A16 linears read: e2m1 codes two to
a byte (input 2j in byte j's low nibble) [n, k/2], an e4m3 scale per 16 inputs [n, k/16],
and weight_scale_2 as an fp32 scalar that multiplies. dims() checks the four tensors fit
together; Linear.read() maps them from a Checkpoint and marks input_scale as used, since
the W4A4 math's static input scale is not read by W4A16 but a leftover tensor would refuse
the checkpoint; row() gives a row as e2m1 x e4m3 x global in f32, the reference the GPU
kernels will be compared with. stored() says a module is NVFP4 by its weight_scale tensor.
The e2m1 and e4m3 values come from core.quant's nvfp4 and fp8 (ashhart#730), as f32.

The fixture is the whole header of nvidia/GLM-5.3-Flash-NVFP4 (147,661 names, dtypes and
shapes, gzip'd): every NVFP4 linear reads, 42 MoE layers x 288 experts x 3 plus the three
dense MLP layers' 3, all under .mlp., and the MTP layer's experts stay BF16.
…e with them

A GLM-5.3-Flash rank reads about half of the 183 GB checkpoint through the mapping, and its
column cuts touch whole tensors. On GB10's unified memory the page cache holds memory the
rank's device allocations need (our Python serving engine drops it with posix_fadvise for
the same reason). safetensors.File.release drops a file's pages from the mapping (madvise)
and the page cache (fadvise DONTNEED); Checkpoint.release does every file. The mapping stays
valid and refaults on a later read, which a test checks. The GLM-5.3-Flash CUDA family
(ashhart#548) calls it after each layer it loads.
…me from its own declarations

The CUDA host wired Nemotron's prompt-cache copies, rules and budget in by name. They now come from the family's
`snap_*` declarations through `cuda_snaps.zig`: a family without `snap_save` gets no prompt cache, `snap_ready` sets
aside its state buffers, and `snap_grid` makes the store keep states only on the family's chunk starts (a family
whose rows depend on where a prompt's chunks begin), and `warm_quiet` turns on the quiet-turn reply prefill (warm
turns and the store's `.waits`). Nemotron declares `warm_quiet` and no grid and keeps 1.0.9's rules: any row, the
head one token on, warm turns, the same budget.

Three more hooks for a family split over hosts: `weightShare` (the weights this rank holds, for the memory check),
`explains_open` (the family's own text for an open error) and the learner only for a family with `slide`.
Nemotron's open, cache, warm turns and --slide are unchanged; `cuda_snaps.zig`'s test covers the rules each way.
…skip), as the decode form does

A family that computes one expert itself (GLM-5.3-Flash stacks its BF16 shared expert as the last expert id) plans
every pair together and has the grouped kernels skip that id. The decode form already takes skip; Grouped.prompt
refused it with OptionNotBuilt for NVFP4. The prompt entries now take skip and return before staging any rows of
that expert's items. Nemotron passes -1, so its bits do not change.

quant-ref now checks it: with the shared expert skipped, its pairs' prompt rows keep what was there and every
other pair's row equals the run without skip, for fp8 and nvfp4.
packGateUp packed each matrix into a scratch half and copied the halves into their interleaved places, two
allocations and a copy per expert. A loader packs every routed expert of every layer this way (GLM at TP=2: 45 x
256 experts, 2.4 MB a half), and the fresh pages and the copies put about 20 s on the load. packOne's loop now takes
the matrix's place among the stacked parts, so packGateUp writes gate and up straight where they go, and it needs
no allocator. The words are the same: a host test checks packGateUp against packOne's blocks interleaved.
… over hosts

The family ashhart#593 tracks, on the shared NVFP4 layer (`qlinear`, `experts.Grouped`) and hand-written `.cu` for the
rest: KDA and MLA/DSA attention, the hyper-connection glue, MoE routing, the MTP draft head and sampling. Rank 0
serves through `tensorfold-native serve` and the other ranks follow its steps; each rank holds its share of every
axis (uneven where a width doesn't divide). A prompt piece with at least as many rows as ranks is split over them
(`TF_GLM_PREFILL_REDUCE=gather` all-gathers it instead): each rank sends the others their rows' partials and receives
its own over NCCL send and receive (`nccl.zig` binds `ncclSend` and `ncclRecv`), and the hyper-connection step adds
them in rank order. Drafts and concurrency go through the lanes core:
`prefill_step` takes a prompt a chunk at a time between the other streams' rounds, and `hidden_rows` lets several
streams' verify windows share one forward.
The prompt cache keeps states on the family's 2,048-row chunk starts (`snap_grid`).

Checks in `tf-cuda-test`: `glm-ref` holds every projection whose reduction order is the kernels' own against float64
on synthetic inputs; the lanes checks (cover, concurrent, shared) run at TP over hosts and, with `glm-synth`'s
two-layer checkpoint, on one GPU.
… EXL3, 64 otherwise)

An EXL3 matrix splits only at 128 columns, so at TP=4 the head's 154,880 rows go 303, 303, 302 and 302 blocks of 128
instead of 605 units of 64 each. `Config.head_unit` is 64 by default and 128 when `quantization_config.quant_method`
is `exl3`; `Share.of` cuts the vocabulary in it. NVFP4 keeps 64, so its shares at TP=2 and TP=3 are unchanged.

The patch BHCC2025 posted on ashhart#593, with its test of the TP=4 and TP=2 shares.
…e width (TP=3's last rank, 640)

At TP=3 the routed and shared experts' 2,048 rows split 704, 704 and 640. The block was gcd(512, w), as glue.py
picks it, and only 512 and 64 had kernels, so the last rank's 640 (gcd 128) stopped the forward with
NoSwigluBlock. The values are element-wise and each 64-column sum is one group whichever block runs it, so the
64-column kernel serves any width that 512 doesn't divide; TP=2's widths keep the 512-column kernel.
…it reads the config

The family read config.json with config.parse alone, so a quant_algo other than NVFP4 was never refused on the
serve path. Engine.init now runs modelopt.admit on the same text first: a refusal logs admit's reason with the
file's path and returns its error (NotModelOpt or UnsupportedQuant), which explain words for the server.

glm-synth's two-layer checkpoint writes NVFP4 linears and now says so in its config.json, with the block
nvidia/GLM-5.3-Flash-NVFP4 carries, so the lanes checks on it are admitted the same way.
…tes rank 0 admits

The server admitted GLM-5.3-Flash's streams and its prompt cache by rank 0's room alone: a follower never returns
from open, so a rank with less free memory than rank 0 (another job, the page cache) could run short after the
server said it was up. Now the native host passes the family a reading of its budget (Options.room: free memory
less the reserve, under TENSORFOLD_CUDA_MEMORY_LIMIT_GB, past what the load took), every rank reads it once its
weights are in, and the ranks all-gather the values before a follower starts following (cuda_ranks.gather, which
agree now uses too). Rank 0 returns the smallest of the others (Loaded.peer_room) and the host admits by it when it
is smaller than its own, naming that rank in a refusal. Rank 0's per-stream and per-state bytes are the largest of
the ranks, since the cut gives a remainder to the first ranks, so the room it admits by holds on every rank.

Before the load, a rank's weights were taken as the checkpoint over the world size; TP=3's uneven cut puts more
than that on rank 0 (22 of 64 KDA heads, 704 of 2,048 expert rows). weightShare now reads config.json and takes the
checkpoint's bytes times the rank's largest share of an axis (Share.part), an upper bound but for small replicated
tensors. Nemotron declares neither field and takes the host's path as before.
@Bizuayeu
Bizuayeu force-pushed the pr/glm53-flash-cuda branch from 53309a7 to 6380c8d Compare October 14, 2026 11:10
@Bizuayeu

Copy link
Copy Markdown
Contributor Author

@ashhart, thank you for the review. The branch is now on 1.0.10 at 6380c8da, and the description lists its commits.

Speed tables: every row is from 53309a72 on 1.0.9, except those marked (1.0.8), which are from 078e3230. Tonight I'm running 6380c8da on the same GB10s, now on kernel 7.0.0-1019 with kho=off and driver 580.178.04, and every table will come from that one sitting. I'll say here when the description has them.

On the follower: every rank is its own tensorfold-native serve, so the check at native/cuda.zig:338 (its share of the weights, from :320, against its own budget) refuses a follower before it loads. What went unchecked was after the load. A follower never returns from open, so rank 0 admitted the streams and the prompt cache by its own room alone, and the 97.3 GiB was rank 0's. 6380c8da closes that. The host passes the family a reading of its budget (Options.room). Every rank reads it once its weights are in, and the ranks all-gather the values before a follower starts following. Rank 0 then admits by the smallest and names that rank when it refuses. Rank 0's per-stream and per-state bytes are the largest, since the cut gives a remainder to the first ranks, so that room holds on every rank. With rank 1 alone under TENSORFOLD_MEMORY_RESERVE_GIB=21, serve --context 40960 --parallel 8 at TP=2 stops before serving, and rank 0 names rank 1. The pre-load figure is now the checkpoint times the rank's largest axis share, instead of the checkpoint over the world size. At TP=3, rank 0 holds 11/32 of the KDA heads and expert rows.

d108e2a74 is dropped. The branch sits on 1.0.10, which has #805, and the GLM family runs on that kernel.

  1. b9420637c is dropped.
  2. Engine.init runs modelopt.admit on config.json before config.parse (65e8f18f). glm-synth's two-layer checkpoint writes NVFP4 linears, so its config.json now carries the same ModelOpt block.
  3. daed17ba0 is dropped. The forward exchanges partials, not sums: postRows adds the ranks' partial rows in rank order inside the hyper-connection step, so a reduce-scatter that returns summed rows doesn't fit it. cuda: a reduce-scatter in rank order over NCCL send and receive #642 can land with a caller that wants summed rows. The family commit now binds ncclSend and ncclRecv itself, since its split prompt exchange uses them.
  4. Yes. glm_triton.cuh is hand-written: one inline function per PTX op that GLM's Triton kernels compile to (sqrt.approx, div.full, ex2.approx, a block sum). Nothing in the branch is vendored or captured, and no cubin or SPIR-V lands.

Long lines: I wrapped the CUDA lines past 120 columns in the files the branch adds, glm_kda.cu:386 among them, and lean_check.py finds nothing new against 1.0.10. The template instantiations it adds to qmmf.cu and prompt16.cu match the lines beside them. Its Zig lines past 120 are calls and signatures as zig fmt leaves them, like upstream's. Wrapping them would put one argument on each line and take cuda_forward.zig past 600 lines, so I left them.

The description changes with this push:

@ashhart

ashhart commented Oct 14, 2026

Copy link
Copy Markdown
Owner

@Bizuayeu, thank you for the rebase onto 1.0.10 and for closing the follower-room gap: rank 0 admitting by the smallest room and naming the rank that refuses is just right.

A note on where this sits. More GLM-on-CUDA work opened today: @justincasey's #838 and #839 (EXL3 at TP=2, with real speed work) and @BHCC2025's EXL3 projections in #806, #740 and #741. We plan to keep one GLM family on CUDA, and #804 is the one to build on, since it already has any number of ranks, the prompt cache and the server. The EXL3 path and #839's speed work would come in through your family. We are coordinating in #832 and #593.

Looking forward to tonight's tables from 6380c8da. We will gate it as soon as they are in.

@SvangenStudios

Copy link
Copy Markdown

@Bizuayeu thanks for the GLM-5.3-Flash CUDA family. I tested 6380c8da at TP=4 on four GB10s. The default split exchange failed during prefill on our switchless ring after NCCL initialisation; with TF_GLM_PREFILL_REDUCE=gather on every rank, the checks below passed.

Setup. 4× DGX Spark (GB10), switchless ring with neighbour links only; ranks 0↔2 and 1↔3 are not mutually reachable over the selected RoCE transport. Kernel 7.0.0-1019-nvidia with kho=off, driver 580.178.04, CUDA 13.0 and Zig 0.17.0. Checkpoint: nvidia/GLM-5.3-Flash-NVFP4 at 423acf37; I checked the SHA-256 of all 35 LFS files against the hub on all four nodes. Each rank ran in a container with NCCL 2.30.7 and a ring graph. After a cold reboot, all eight links measured 106–107 Gb/s with ib_write_bw.

Default split. All four ranks completed NCCL initialisation, then failed with ncclGroupEnd: unhandled system error. The debug run shows rank 2 attempting connections to rank 0, including an ibv_modify_qp timeout during INIT→RTR. Both split exchange loops in cuda_forward.zig call ncclSend/ncclRecv for every other rank. These calls require peer-to-peer reachability over the selected transport; selecting Ring does not relay them through neighbouring ranks.

With TF_GLM_PREFILL_REDUCE=gather:

  • glm-lanes-concurrent: 38 PASS, 0 FAIL on rank 0, covering greedy and sampled streams, staggered starts, chunked prompts and a discard. Surviving streams matched their solo runs for 128 output tokens. The log reports 313 forwards containing several streams' windows. Ranks 1–3 each logged that they followed rank 0 to the end. The three input prompts contain 2,066, 2,108 and 2,089 tokens.
  • glm-shared-check: rank 0 reported 108 compared rows across five streams and 12 rounds, with two to five streams per forward. Local logits, output tokens and drafts matched the lone twins byte for byte; local final states also matched. Ranks 1–3 each logged that they followed to the end.
  • Server checks: bench_concurrent.py --alone --serial, levels 1 and 4, code and chat, 256 output tokens, temperature 0, three repetitions. All 30 measured replies matched their solo token hashes, and the solo references matched "draft": false.
  • Separate bench_openai.py runs with drafts and --no-drafts matched both token hashes and output SHA-256 for both prompts.

These checks compare execution paths within TP=4; they do not establish bitwise equivalence with TP=2.

I also saw the rank agreement check reject different checkpoint folder names: all four ranks logged REFUSE naming the snapshot-folder mismatch. That checks the folder name; the LFS content check above was separate.

Performance with gather. The TP=2 figures below are from the PR description, on different hosts and in a separate session, so this is not a controlled scaling comparison.

bench_openai.py, 256 output tokens, temperature 0, median of 3, tok/s fibonacci-raw gpu-chat-no-think
TP=4, drafts 66.29 42.10
TP=4, --no-drafts 25.85 25.76
Reported TP=2, drafts 33.16 27.61
Reported TP=2, --no-drafts 17.46 17.45

At four concurrent streams (--parallel 4), aggregate decode throughput was 130.5 tok/s for code and 85.9 for chat, against 66.1 and 44.0 at level 1. The PR reports 68.0 and 52.7 at level 4.

Target prompt length; median TTFT (s) 2,048 4,096 8,192 16,384 32,768
TP=4, gather 1.288 2.946 5.606 11.025 21.956
Reported TP=2 1.343 3.151 6.032 11.761 23.379

The prefill prompts use prefill_cold.py as of 394d566c^, with its target lengths set to your table's and unique first lines. Server-reported lengths were 2,047–2,049, then 4,097, 8,193, 16,385 and 32,769 tokens. These runs used --context 40960 --parallel 2, without rests between long prompts. TTFT was about 4–7% lower than the reported TP=2 figures. gather exchanges every rank's FP32 partial rows and repeats the glue work on every rank; I have not profiled how much this accounts for the limited prefill improvement.

After the decode and prefill runs with drafts and --parallel 2, rank 0's /memory peak was 58.3 GiB; the server reported 48.58 GiB for its model. The PR reports a TP=2 peak of 97.3 GiB on rank 0.

The build summaries, with many steps cached, reported test-cuda-host 79/79 and test 243/248, with five skipped and none failed. I did not repeat these from an empty cache.

Not run: glm-lanes-cover, glm-ref, split-path tests beyond the failed ring runs, a switched fabric, contexts above 40,960, or TP=2 on these hosts.

A documentation or startup note naming TF_GLM_PREFILL_REDUCE=gather on every rank would make the existing workaround easier to find on switchless setups. A ring-aware split exchange could keep the distributed glue work by relaying the partial rows through neighbours, preserving their bytes and rank order. ncclReduceScatter(sum) is not a guaranteed bit-equivalent replacement, since the current glue kernel sums FP32 contributions sequentially in rank order.

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

@ashhart, thank you. The run of 6380c8da stopped overnight at the vLLM step, on an output folder our script had left from an earlier run. It resumed this morning, and the description gets every row from it in a few hours.

@SvangenStudios, thank you for running 6380c8da at TP=4 and for tracing the failure. You read it right: the split exchange sends each rank's fp32 partial rows straight to the rank that glues them, then the glued bf16 rows straight back, so it needs every pair of ranks to connect over the chosen transport. A ring whose ranks 0 and 2 cannot reach each other fails at the first prompt chunk. With the tables, the description will name TF_GLM_PREFILL_REDUCE=gather for such fabrics and point to your run.

After the gate, as separate PRs if that suits:

  1. A startup check that runs the split exchange once before serving and, when a pair of ranks cannot connect, refuses with that hint instead of failing in a prefill.
  2. A split for rings that relays the partial rows through neighbours unchanged, so the owner still adds them in rank order. We have no four-node ring here, so we would ask you to run it.
  3. The checkpoint check: it compares the snapshot folder's name, which stands for the revision in a hub cache but refuses identical copies kept under other names. It could compare what the files hold (config.json, the index and each shard's header) and keep the revision check for hub snapshot paths.

@SvangenStudios

Copy link
Copy Markdown

@Bizuayeu thanks, that plan sounds good. I'd be happy to test the neighbour-relay split on the four-node ring when it's ready, comparing it with gather on the same build using the checks and benchmarks above. I can also check that the startup check refuses to serve with the current split on this ring and points to TF_GLM_PREFILL_REDUCE=gather.

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

@ashhart, the description now has every row from 6380c8da on 1.0.10, in one sitting on the updated hosts; no 1.0.8 rows remain. Its TP=4 line under "Not run" now names TF_GLM_PREFILL_REDUCE=gather for fabrics where some pairs of ranks cannot connect, with @SvangenStudios's run.

@jschmied

Copy link
Copy Markdown
Contributor

@Bizuayeu, Ling-3.1-flash (#850) needs several pieces this PR already has, and we'd rather extend yours than write second copies. Nothing here asks for changes to this PR. We'd do the extensions as follow-up PRs once it lands, if you agree.

  • KDA (glm_kda.cu). It does the same steps Ling does: the q|k|v conv, the safe gate with its lower bound, the sigmoid beta, the q/k norms, the delta rule and the gated output norm, with 128/128 heads and 4 taps. Ling's separate q/k/v conv weights can be concatenated at load, so the kernel serves Ling as is. We also have a KDA kernel on jschmied:cuda-kda (results on Ling-3.1-flash on CUDA: a shared KDA kernel first, then the family at TP=1 and TP=2 (EXL3) #850). It runs prompts in 16-row chunks, 11 ms for 2048 rows at 64 x 128 on one GB10. TensorFold should end up with one KDA, not two, and we asked @ashhart on Ling-3.1-flash on CUDA: a shared KDA kernel first, then the family at TP=1 and TP=2 (EXL3) #850 which form he wants. If it's yours, we'd add the chunked prompt path to it, with a float64 check next to your bit-for-bit check.
  • MLA (glm_glue_attn.cuh, glm_glue_latent.cuh). Ling's MLA has 64 rope dims on top of the 512-wide latent, so keys are 576 wide. Its heads are 128 wide (nope and v), not 256. It also has a per-head sigmoid gate on the attention output. Your kernels fix LW = 512 and d = 256 and have no rope term. We'd make those parameters and add the rope part and the gate, leaving GLM's path unchanged.
  • Routing (glm_glue_moe.cuh top-k). Ling uses group-limited routing: 8 groups, keep the 4 whose top-2 sum is highest, then top-8 by sigmoid + bias. GLM has n_group 1, so this would be an added branch that GLM never takes. The SwiGLU clamp already fits Ling, with a per-layer limit on layers 64 to 71.

Would you take these as follow-ups on top of this PR, or would you rather keep the GLM kernels family-only and have us copy what Ling needs?

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

@jschmied, yes, please extend these rather than copy them; follow-ups once #804 lands suit us, as long as GLM's path keeps its bits.

  • MLA and routing. Making LW, d and the rope term parameters, and adding the head gate and the group-limited branch, sound right, with GLM passing today's values and never taking the new branches. Each follow-up should leave glm-ref, glm-shared-check and the lanes checks passing as they do now, and we can run GLM's two- and three-host checks on our hosts for any of them.
  • KDA. glm_kda.cu runs a prompt's rows through the delta rule one at a time, so its outputs and state are bit-equal to decode windows and to a prompt resumed at any row; that is what lets a resumed conversation here equal a fresh one. A chunked prompt path would change that: blocks formed from where a call starts sum a prompt resumed mid-block differently. Whether TensorFold takes such a path, and where, is for @ashhart to decide, together with which KDA stays (Ling-3.1-flash on CUDA: a shared KDA kernel first, then the family at TP=1 and TP=2 (EXL3) #850). GLM's prompts would stay on the row path.

Our own follow-ups after the gate (a startup check that every rank pair can reach each other for split, a neighbour-relay split for switchless rings, and comparing checkpoints by their contents) touch the exchange and the loader, not these kernels, so they shouldn't collide with yours.

@jschmied

Copy link
Copy Markdown
Contributor

@Bizuayeu, one thing we found while checking glm_kda.cu against Ling's layer 0, in case it's useful. For verify windows, your three-kernel path (prep, step, out) is faster than the fused chain from 2 rows up, and your tests already show the two give the same bits (cuda_kda.zig: "fused and three-kernel chains match bits"). Today wide_rows = 64 sends every window below 64 rows to the fused kernel.

Median ms per call, synthetic rows, one GB10 (a conversion was running on it, so absolute times are a little high; the ratio held across runs):

rows 32 heads, fused 32 heads, three-kernel 64 heads, fused 64 heads, three-kernel
2 0.032 0.029 0.062 0.052
4 0.038 0.030 0.075 0.054
8 0.052 0.033 0.101 0.060
16 0.079 0.044 0.154 0.076
32 0.129 0.061 0.256 0.115
63 0.231 0.104 0.455 0.192

32 heads is one rank of GLM-5.3-Flash at TP=2. Lowering wide_rows to 2 would leave the bits as they are and make drafted windows' KDA about 1.2x to 2x faster. A single decode row stays on the fused kernel (0.05 ms at 64 heads). This is your call; we haven't changed it on our side.

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

@jschmied, thanks for timing these. GLM already sends every decode window to the three-kernel chain, a single row and drafted windows alike: cuda_forward.zig passes wide as !b.prefill and f.kda_wide, and kda_wide is on by default (TF_GLM_KDA_DECODE_WIDE, as in the Python family, where it gave decode +0.6% with the same tokens). So wide_rows only picks the chain for prompt windows under 64 rows, a short prompt or the last piece of a chunked one, and GLM's decode wouldn't change with it.

For Ling, passing wide for its decode windows the way GLM's forward does gets the same. Lowering wide_rows in your follow-up is fine with us as well, since glm_ref_kda.zig checks the two chains' bits at 3 and 70 rows. #804's head stays as it is.

@Bizuayeu

Copy link
Copy Markdown
Contributor Author

@ashhart, pushed 2d48a0bd on top: #758's third commit, its test decoding with core.quant's reference instead of an f32 row of its own. Nothing served calls that row, so the description's rows from 6380c8da still hold; at the new head the build, zig fmt --check, the host tests, zig build test, glm-ref and the thin and synthetic lanes checks pass on one host.

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.

4 participants