Skip to content

lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels; stacked on #1377) - #1492

Draft
jschmied wants to merge 15 commits into
ashhart:mainfrom
jschmied:lfm2-cuda-plain
Draft

jschmied wants to merge 15 commits into
ashhart:mainfrom
jschmied:lfm2-cuda-plain

Conversation

@jschmied

@jschmied jschmied commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1488. Stacked on #1377 (BlivionIaG's LFM2 stack, head 70f74ca34): the 10 commits below 7d7d8f49e are #1377's, and this PR adds only the last four. Draft until #1377 lands, then I'll rebase onto main.

What

LFM2.5-24B-A2B (lfm2_moe) served natively on CUDA from LiquidAI/LFM2.5-24B-A2B-MLX-4bit: plain serving with --parallel N and TF's built-in drafts. No DSpark drafter yet. The engine reuses the shared CUDA pieces wherever they fit.

  • Reused as they are (13): the affine embedding rows, the 4/6/8-bit affine linears (prompt GEMM and GEMV), addRmsTail norms, the SwiGLU act with group sums, qk_rows (q/k norm + RoPE), the page KV write, the Affine4 experts with the grouped plan, the MoE combine, and tree_round argmax / sampling.
  • New instances of shared templates (2): prefill_attention's prompt kernel at head dim 64, and the paged shared-window attention at head dim 64. Its body moves from Nemotron's file into a neutral paged_attn_segs.cuh. Head 64 exports tf_paged_attn_chunk_segs_64 / tf_paged_attn_merge_segs_64, and Nemotron's head-128 symbols keep their names and SASS.
  • New kernels (3), under the kernel contract: the short conv with its rolling state, the conv-state commit, and the router. The conv and its commit live in a neutral liquid_conv.cuh, templated on the tap count (tf_liquid_conv_3 / tf_liquid_commit_3), so LFM3's 7-tap conv (Add native Metal support for LFM3-1.7B and LFM3-VL-2B MLX 4/6/8-bit checkpoints #1491) can reuse them; only the router stays in lfm2_moe.cu. The move is SASS-identical by function body on all five SMs, and served replies are byte-equal before and after. The router has fp32 weights, expert bias, normalized top-4 and eps 1e-6.

Commits:

  • 7d7d8f49e cuda: the paged shared-window attention at head dim 64
  • d70fc9821 lfm2_moe: CUDA glue kernels under the contract
  • c6645063a lfm2_moe: serve LFM2.5-24B-A2B on CUDA
  • 580d8f24b cuda: Liquid's short conv and its commit in liquid_conv.cuh, by tap count

22 files, +2,262 / −142 against #1377.

Verification (GB10, sm_121, nvcc 13.0)

  • SASS identity: every existing function is unchanged on sm_80/86/89/120/121; 15 functions are added.
  • Kernel contract: the 4 new kernels pass gates 1–4, and all 3 planted controls go red.
    • It found a real bug, now fixed: a row whose router scores were all NaN picked expert 0 twice.
    • Launch records: 30 of 30 pass on nvcc 13.0 and 13.2 (one 13.2 override).
  • Served, real weights, --parallel 8, greedy:
    • drafted == plain on 8/8 prompts, including a partial acceptance;
    • concurrent == solo on 8/8, drafted and plain;
    • token hashes are identical across a restart.
  • float64 reference (CPU, the MLX weights dequantized exactly, every op in float64): 61 of 64 greedy tokens agree.
    • Each of the 3 misses is the reference's second choice, with logit margins 0.009, 0.069 and 0.36.
    • A second reference rounds to bf16 at every point where the engine stores bf16, with the router, decode attention and the decode down output in fp32, as in the engine. Two variants of it serve as noise probes: every row on the decode path, and float32 arithmetic. At 2 of the 3 positions all three pick the engine's token. At the third (margin 0.36), both probes pick it, and the main variant keeps the float64 token by 0.30.
    • The rounding-only variants themselves disagree at 2 of 64 positions, so at these margins the misses are rounding on near-ties. I haven't compared against the engine's own logits: the CUDA backend doesn't return them yet.
  • lean_check: 0 new problems against LFM2: shared-round decode #1377's head. zig build test passes.

Speed (GB10)

streams decode tok/s (total)
1 87
4 194
8 303

Cold TTFT: 0.32 s at 2.8k prompt tokens, 1.32 s at 11.2k.

Not in this PR

Questions

  1. paged_attn_segs.cuh is still compiled into the nemotron_attention image. I'd move it and liquid_conv.cuh under zig/kernels/cuda/common/ once cuda: shared device primitives in zig/kernels/cuda/common (#1409, part 1: SASS unchanged) #1440 lands. Does that suit you, or would you rather each get its own image?
  2. ATC shows one conflict with Nemotron: two members a pass at 8 rows a simdgroup for wide M1 windows #1466, in registry.zig (records), which is easy to resolve whichever lands second.

BlivionIaG and others added 13 commits October 26, 2026 10:58
The host drew every verify row by scanning its 128,000 bf16 logits, after
the forward and with the GPU idle. lfm2_argmax picks each head row's first
largest logit on the GPU and flags a row with a nonfinite logit. verifyFn
takes the pick for greedy rows without a mask; sampled, masked and flagged
rows draw on the host as before.

The kernel is registered with the contract ("metal lfm2 argmax": the host
draw's pick, ties to the lower id, nonfinite rows flagged), and
tf-lfm2-exact checks every window's picks against the host draw.
…ctions

lfm2_conv, lfm2_qk, lfm2_attn_mma and lfm2_attn_merge each call an inline
body (lfm2_conv_row, lfm2_qk_row, lfm2_attn_rows, lfm2_merge_row) with the
same arguments, so another kernel can run a row's arithmetic unchanged.
The kernels' bits do not change.
The family's tests (config, draws, state) ran only inside `zig build test`.
test-lfm2 runs the engine's test binary filtered to lfm2_moe, so a change
to the family can run them without the whole suite.
…ll its streams

A recorded call of several streams launched each stream's own short conv
(30 layers) and its qk, attention and merge (10 layers): at 16 one-row
streams 960 launches a forward for little work each. lfm2_segments.metal
runs each of them once a layer over every row, from a table of the
streams' cache addresses, row offsets and key counts (segments.zig); each
row's arithmetic is the per-stream kernel's, called through the same
inline bodies. A stream of more than 8 rows folds a chunk's sub-spans in
its 8-row tiles, as its own 16-row tiles do. Prompt calls and one-stream
windows keep their launches.

Every cache buffer the table reaches is declared to the encoder: a new
stream's caches join the server's residency set only at its next flush,
so a kernel that reaches them through the table alone may read them
before they are resident. The scratch's attention partials now hold each
stream's apart (partialCount: a 32-row scratch's grow fourfold; the
served scratch of 2,048 rows already holds them).

The contract registers "metal lfm2 stream attention" and "metal lfm2
stream conv" as bit-equal replacements of the per-stream kernels, with
float64 attention and a host replay of the conv steps. test-lfm2 checks
the table, the row map, the tiles and the declared buffers, and the
partials' count at 1, 32 and 2,048 rows.
… more

From 8 rows the dense twin took its MMA window kernel, a threadgroup for
16 outputs over every row. A 2,048-wide matrix then has 128 threadgroups
and runs slower than the scalar twin's 4-row passes; shared rounds of 8
to 32 rows call such matrices at every layer. The 6,144-wide short conv
input and the head keep the window. Both kernels give the tiles' bits
(the load check runs each matrix's twin at every width); the contract's
twin now also serves 16 rows of 6,144 from 2,048.
… receipt

README pointed at no native recipe for LFM2.5-24B-A2B. The page gives the
checkpoint, the serve command, the shared-round kernels and their tests,
and the measured M5 Ultra decode rates before and after them.
…mits and four planted controls

The five launches are in lfm2_records.zig, with the limits that --offline compiled, not this GPU's. lfm2_attn_segs
reserves its 128 threads in its source, as M1 and M2 cannot build it for another count. Four controls edit the
source: a tie to the highest id, a nonfinite logit not flagged, and every stream reading or writing the first
stream's conv state or kv cache. Each goes red at its gate.
M1 and M2 cap a pipeline's threads as its registers rise, and no run covers them. The server refuses them by name
at open. An M3 is served, with one log line that it is not yet tested with shared rounds.
The shared-round kernels carry recorded M1 and M2 launch limits in the kernel contract, and no run covers those chips.
The server no longer refuses them.
The shared-window chunk and merge bodies move to paged_attn_segs.cuh, included twice by nemotron_attention.cu:
Nemotron's head-128 kernels keep their names and SASS, and head 64 adds tf_paged_attn_chunk_segs_64 and
tf_paged_attn_merge_segs_64. prefill_attention.cu instantiates pattn_kernel<64, 8, 4, 8> over paged caches.
sassid on sm_80/86/89/120/121: 0 functions changed, 15 added.
lfm2_moe.cu holds what the shared operators do not: the short conv over each stream's state with every window
row's snapshot, the commit of an accepted row's conv state, and the router (fp32 weights, top-4 of sigmoid + bias,
ties to the lower id, a NaN ranked as -inf). Each has a planted control. Launch records cover these kernels and
the head-64 paged attention on nvcc 13.0, with one 13.2 override.
The MLX 4-bit checkpoint (embedding at 4, 6 or 8 bits) on the shared affine-4 projections and experts, with
prompt chunks of 1024 rows, one-row decode and copy-drafted windows over --parallel streams. The ten attention
layers keep their keys and values in 512-position pages, and each stream has its own conv state. There is no
drafter and no prompt cache yet.
…ount

The gated short conv over each stream's state and the conv-state commit move out of lfm2_moe.cuh into
liquid_conv.cuh, templated on the tap count, so LFM3 (7 taps) can take the same bodies as LFM2.5 (3).
lfm2_moe.cu keeps the router and instantiates the 3-tap pair as tf_liquid_conv_3 and tf_liquid_commit_3;
the controls become tf_ctl_liquid_conv_3 and tf_ctl_liquid_commit_3. cuobjdump -sass on sm_80/86/89/120/121,
nvcc 13.0 and 13.2: the moved bodies are identical under their new names, every other function unchanged.
@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied, this is a tidy port. It reuses the shared CUDA pieces wherever they fit, and the contract finding the all-NaN router row is a good catch.

#1377 has landed as rewritten commits, so this branch no longer merges with main: it conflicts in docs/kernel-contract.md, registry.zig and contract_limits.zig. Your own five commits cherry-pick onto main with one conflict, the image list at zig/tests/cuda/contract_limits.zig:28. With the first four on main, zig fmt, test-lfm2-moe-cuda-host, test-cuda-host and the Linux cross-compile pass.

Before this leaves draft:

  1. Please rebase onto main.
  2. The lean check has one new finding, the two-line comment at zig/kernels/cuda/nemotron_attention.cu:233. It is on the head too.
  3. The three new attention launches, tf_paged_attn_chunk_segs_64, tf_paged_attn_merge_segs_64 and pattn_kernel<64, 8, 4, 8>, have limit records (lfm2_moe_cuda_records.zig:12-14) but no gate. The decode pair is new code: the SEG_WIDE 0 branch of paged_attn_segs.cuh:78-88. Please gate them against contract_attn.zig's float64 reference. Add a planted control that goes red, for example values read from the neighbouring lane's dims.
  4. Please commit the float64 reference under tools/. Its receipt should list each top-1 miss with the prompt, the position, both tokens and both margins, and cover teacher-forced prompt rows as well as reply rows, as lfm3: serve LFM3-1.7B NVFP4 on CUDA, weight-only (stacked on #1492 and the LFM3 admission) #1508 does.
  5. Docs:
    • a CUDA section in docs/recipes/lfm2.5-24b-a2b.md, with the command, the revision, the GB10 receipt and the weights' licence;
    • the README row (README.md:176 says one Mac);
    • the new checks in docs/kernel-contract.md.

To your questions: moving both headers under common/ after #1440 suits us. The #1466 conflict goes to whichever lands second.

Then we'll run the kernel contract and its limits at 13.0 and 13.2, and compare Nemotron's head-128 machine code with main. Then we'll serve the 4-bit export on a GB10: drafted against plain, concurrent against solo at 1, 4 and 8 streams, and speed.

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