Skip to content

lfm3: serve LFM3-1.7B NVFP4 on CUDA, weight-only (stacked on #1492 and the LFM3 admission) - #1508

Draft
jschmied wants to merge 20 commits into
ashhart:mainfrom
jschmied:lfm3-cuda-nvfp4
Draft

jschmied wants to merge 20 commits into
ashhart:mainfrom
jschmied:lfm3-cuda-nvfp4

Conversation

@jschmied

Copy link
Copy Markdown
Contributor

Part of #1491 (CUDA side). Stacked on #1492 (LFM2.5 on CUDA, itself on #1377) and on BlivionIaG's LFM3 admission 8192e5437 (cherry-picked here with his authorship). Only the last four commits are new. Draft until those land.

What

LFM3-1.7B text served natively on CUDA from LiquidAI/LFM3-1.7B-NVFP4 (ModelOpt NVFP4), weight-only: the FP4 weights are dequantized, and activations stay bf16. MLX affine and FP8 come next on the same family; VL after that.

  • Admission (b70b95a53): the ModelOpt NVFP4 scheme in Blivion's lfm3/config.zig / weights.zig, with the export's exact exclusion list: only the FFN (w1, w3, w2) is FP4; conv, attention, gate, embedding and head are bf16. The real header checks out at 502 tensors; the MLX 4-bit layouts are unchanged.
  • Kernels (b5ed2eb69): the 7-tap instances of lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels; stacked on #1377) #1492's liquid_conv.cuh, tf_liquid_conv_7 / tf_liquid_commit_7, under the kernel contract with planted controls and records for nvcc 13.0 and 13.2.
  • Prompt attention at head dim 128, 4 query heads per KV head (6040caa99): a new instance of prefill_attention's template.
  • Family (08b338c7c):
    • FFN: one FP4 lane weight for w1|w3 on TF's NVFP4 linears (prompt16 for prompt rows).
    • Projections: q|k|v|gate as one bf16 lane weight.
    • Head: a bf16 copy of the tied embedding.
    • Full-attention layers (6): pool pages, partial RoPE through qk_rows' frequency table, Nemotron's head-128 chunk/merge kernels for decode.
    • Sliding layers (5, window 256): a per-slot ring on the shared window-attention kernels.
    • Gate and norms: the per-head output gate is act_rows.headGate; the sandwich norms are the shared norms.
    • Serving: --parallel N and the prompt cache (conv states, the rings and the pages through the cut, copied).

New kernels: none beyond the 7-tap instances. 25 files, +2,197 / −35 on top of the admission commit.

Verification (GB10, sm_121, real weights)

  • float64 reference (CPU, NVFP4 dequantized exactly; it agrees 829/829 with the official modeling_lfm3.py run in float32):
    • Greedy agrees on 825 of 829 prompt positions and 127 of 128 reply tokens. The misses have float64 margins of 0.003–0.153.
    • With the engine's bf16 rounding points it is 827/829 and 126/128; the remaining misses are exact bf16 ties or 0.125 apart.
  • Served:
    • drafted == plain 8/8 and concurrent == solo 8/8;
    • prompts of 300–2,960 tokens crossing the window several times, 8/8;
    • prompt == one-row decode at 32/32 cut points, and resumed == fresh 32/32;
    • restart, fixed pages == paged, and evictions from a 0.1 GiB cache all match;
    • after every drain the pool reads 520/520 free.
  • Machine code: every existing function is unchanged on sm_80/86/89/120/121, under nvcc 13.0 and 13.2.
  • Contract: the 4 new kernels pass, and their 4 controls go red. Limit records pass, 10/10 per toolkit.
  • lean_check 0 new; zig build test passes.

Speed (GB10)

decode, 1 / 4 / 8 streams 112.8 / 420.0 / 767.4 tok/s
cold TTFT, 2k / 8k / 32k 0.150 / 0.615 / 2.84 s
warm turn, 10.25k tokens with 10.14k cached 0.049 s (cold 0.81 s)

Questions

  1. Default served context and pool size: the model allows 262,144; the default here is 32,768. What would you like?
  2. The per-head gate runs as its own launch after attention (headGate). Fusing it into the attention epilogue would save a pass over o. Would you rather have it now or later?
  3. The NVFP4/FP8 schemes in the shared admission: is extending Blivion's weights.zig the right place, so Metal refuses these exports by name?

BlivionIaG and others added 20 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.
…KV head

prefill_attention.cu instantiates pattn_kernel<128, 8, 4, 8>: Nemotron's <128, 8, 8, 8> needs eight query heads
a KV head, and LFM3's full-attention layers have four (16 over 4). Its launch record is measured on nvcc 13.0 and
13.2 from every SM's image (206 registers on sm_80/86/89, 231 on sm_120/121, no static shared memory).
sassid on sm_80/86/89/120/121, both toolkits: 0 functions changed, 5 added.
Prepare text and VL 4/6/8-bit checkpoints with revision-pinned header inspection and native metadata validation. Inference remains unregistered pending operator and model qualification.

Refs ashhart#1491
LiquidAI/LFM3-1.7B-NVFP4 stores only the FFN as NVFP4: w1, w3 and w2 as e2m1 codes in groups of 16 with e4m3
block scales, an fp32 tensor scale and a static input scale. Every mixer projection, the embedding and the tied
head are bf16, and the depthwise conv kernel is [channels, 1, taps].

- config.zig reads `quantization_config` with quant_method "modelopt" as Scheme.modelopt_nvfp4: quant_algo NVFP4,
  one Linear group of static FP4 g16 weights and inputs, and an ignore list that is exactly the head, the
  embedding and every layer's mixer. Anything else is UnsupportedLfm3Quantization. MLX configs parse as before
  (Scheme.mlx_affine), so a decoder that reads one scheme can refuse the other by name.
- weights.zig's schema takes that layout for the bare namespace.

tf-lfm3-check passes the published header (b30025bb): 502 text tensors, 1,829,689,408 bytes. The MLX-4bit header
still passes (594 tensors, 973,008,384 bytes). Host tests 26/26.
…tract

lfm3.cu instantiates liquid_conv<7,0> and liquid_commit<7,0>; contract_controls.cu adds their <7,1> controls.
contract_lfm2_moe.zig's Conv and Commit take the tap count; contract_lfm3.zig checks the served 7-tap shapes.
Records are identical on nvcc 13.0 and 13.2. contract_limits.zig now lists the lfm2_moe and lfm3 images.
Conv layers keep a 6-row state per slot; full attention runs on kv_pool pages with partial RoPE (32 of 64 pairs);
sliding attention runs on a 1280-row per-slot ring through the shared window kernels at window 256.
FFN w1|w3 is one FP4 lane weight (shared weight_scale_2), w2 FP4; q|k|v|gate, o, conv and the tied head are bf16.
Adds --parallel lanes, drafted rows, and a copy-based prompt cache (conv states, rings, pages [0, at)).
@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. A float64 reference that agrees with the official modelling code at every position is the evidence we want. Admitting only the export's exact ignore list keeps the scheme honest.

Before this leaves draft:

  1. It stacks on an old copy of the admission. cb2d77003 still adds the root build-lfm3.zig, which lfm3: add native Metal vision support above the text decoder #1502 has since folded into build.zig. Please rebase onto the admission as it lands, and onto main once lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels; stacked on #1377) #1492 has.
  2. zig/src/families/lfm3/cuda_backend.zig is lfm2_moe CUDA: attention pages from the shared KV pool, and the prompt cache (stacked on #1492) #1503's 426 lines with the names changed; cuda_snap.zig and cuda_native.zig are mostly the same as lfm2_moe CUDA: attention pages from the shared KV pool, and the prompt cache (stacked on #1492) #1503's too. Please share one lane backend, and the state walk, between the two Liquid families, generic over the model and the forward. liquid_conv.cuh already shares the conv that way.
  3. pattn_kernel<128, 8, 4, 8> has a limit record (lfm3_cuda_records.zig:11) but no gate. Please gate it against contract_attn.zig's float64 reference, with a planted control, as we've asked on lfm2_moe: serve LFM2.5-24B-A2B on CUDA (plain serve, shared kernels; stacked on #1377) #1492 for head 64.
  4. Each kept state allocates a device buffer and frees it behind a stream wait (cuda_snap.zig:61, :75-76), as in lfm2_moe CUDA: attention pages from the shared KV pool, and the prompt cache (stacked on #1492) #1503. The same fix applies.
  5. Please commit the reference, and list each miss with its tokens and both margins.
  6. Please label the mode weight-only: the export's static FP4 input scales are read but not applied (cuda_weights.zig:118).
  7. Please state the weights' licence.

To your questions:

  • Keep 32,768 as the default, as LFM2 does, and raise it once 64k and longer contexts are measured.
  • Fuse the head gate later, in its own PR, with an A/B.
  • Yes, one admission for both backends. Whichever of this and lfm3: add native Metal vision support above the text decoder #1502 lands second should make Metal refuse modelopt_nvfp4 by name, with a test.

Then we'll run the contract and its limits. We'll serve the NVFP4 export on a GB10: drafted against plain, concurrent against solo, prompts across the sliding window, and resumed against fresh.

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