Skip to content

LFM2: shared-round decode - #1377

Open
BlivionIaG wants to merge 10 commits into
ashhart:mainfrom
BlivionIaG:lfm2/plain-decode
Open

BlivionIaG wants to merge 10 commits into
ashhart:mainfrom
BlivionIaG:lfm2/plain-decode

Conversation

@BlivionIaG

@BlivionIaG BlivionIaG commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Branch lfm2/plain-decode, head 70f74ca34, with main 8c36359d5 merged in. Commits by BlivionIaG, no trailers.

What this changes

On LFM2.5-24B-A2B (Metal), a round of several streams now runs each layer's short conv and attention once over all
its rows, not once a stream. Plain decode at 4 to 16 streams is faster on the M5 Ultra; every reply is token for token
the same.

  • lfm2: a verify's greedy rows take the GPU's pick of their logits (471193c). lfm2_argmax picks each head row's
    first largest logit and flags a row with a nonfinite logit. A greedy row without a mask takes that pick; sampled,
    masked and flagged rows draw on the host as before.
  • lfm2: the conv, qk, attention and merge kernels' bodies as inline functions (886d417). No change to the bits.
  • build: test-lfm2 runs the LFM2-MoE family's tests alone (793b68e).
  • lfm2: a shared round runs each layer's conv and attention once over all its streams (807aa4d). At 16 one-row
    streams a forward launched 960 small kernels for the convs and attention. lfm2_segments.metal runs each of them
    once a layer, from a table of the streams' cache addresses, row offsets and key counts (segments.zig). Each row
    calls the per-stream kernel's inline body. Prompt calls and one-stream windows keep their launches. Every cache
    buffer the table reaches is declared to the encoder (useResource): a new stream's caches join the residency set
    only at its next flush. The scratch keeps each stream's attention partials apart (state.partialCount).
  • tile_qmm: the twin's MMA window only for matrices of 4,096 outputs or more (520d617). Narrower matrices keep the
    scalar twin's passes. Both kernels give the tiles' bits, and the load check still runs every matrix's twin at every
    width. tile_qmm is used only by lfm2_moe (grep of zig/src and zig/tests).
  • docs: the LFM2.5-24B-A2B recipe, its shared rounds and their M5 Ultra receipt (9df2cd9). README said
    there was no native recipe for this model; this adds one.

No arithmetic changes: the new kernels are bit-equal replacements, and the twin's two kernels give the same bits.

  • kernel contract: LFM2's shared-round launches carry recorded M1/M2 limits and four planted controls (9f74d91). lfm2_attn_segs and lfm2_attn_block reserve 128 threads in their source. The merges of main are plain merge commits.

Receipts at this head

Decode, M5 Ultra (Mac17,15, macOS 27.0 build 26A428)

Main 8c36359d5 against this head, LFM2.5-24B-A2B-MLX-4bit 667408d4 without a drafter, serve --parallel 16 --prompt-cache-gib 0, a fresh server per build and round, three rounds in the order main/this, this/main, main/this, bench_concurrent.py --tokens 256 --reps 3 --temperatures 0 --levels 1,4,8,16, aggregate tok/s, median of 3 reps. No cell dropped; the sampler saw one server and one client at a time and no Zig build.

A different prompt on each stream (tools/bench_prompts.json):

streams main, rounds 1-3 this head, rounds 1-3 change of medians
1 258.6, 258.8, 259.7 266.6, 267.7, 266.7 +3.0%
4 483.6, 486.0, 486.7 567.6, 567.3, 566.7 +16.7%
8 551.6, 551.9, 551.7 734.9, 734.9, 734.0 +33.2%
16 660.8, 660.6, 660.8 922.6, 921.5, 921.3 +39.4%

The same prompt on every stream (the client's code and chat prompts), change of medians across rounds: code +4.4%, +17.6%, +34.9%, +31.0% and chat +2.1%, +16.5%, +36.3%, +31.9% at 1, 4, 8 and 16 streams. The per-round values are in the raw output (code at 16 streams: 633.4, 632.5, 633.4 against 828.2, 829.9, 830.7).

  • Exactness: the token_sha256 of every stream and rep is identical to main's on all 12 cells in every round. Alone: 87 equal, 0 unequal, 0 failed (distinct prompts) and 174 equal (code and chat) per build and round; serial 8 of 8 equal.
  • Cold prefill (server-side time to first token, deterministic token-id prompts, --prompt-cache-gib 0, 3 reps): 2,048 tokens 0.147 s on both builds; 32,768 tokens 3.038 s on main and 3.039 s here. Output hashes equal.
  • Peak footprint (/usr/bin/time -l, --parallel 16, warm after 1-stream and 4-stream runs): 14.93 GB on both builds, a difference under 1 MB.

Decode, M4 Pro

Quiet machine (load under 2, no Zig build, one server at a time), a different prompt on each stream, greedy 256 tokens, 3 alternated rounds, both builds merged with main 8c36359d5:

streams main this branch change
1 117.7, 117.6, 117.5 119.3, 119.1, 119.3 +1.4%
4 193.2, 193.2, 193.3 203.6, 203.5, 203.5 +5.3%

token_sha256 equal to main in every cell, concurrent equals alone 12 of 12, "draft": false equal 4 of 4. Nemotron-3.5-Lightning 4-bit, 3 clean rounds: 1 stream 120.4, 120.0, 120.6 on main and 119.9, 120.0, 120.6 here; 4 streams 153.0, 152.8, 152.5 and 152.4, 156.3, 152.5 (within 1.3%, ranges overlap, tokens equal). tile_qmm is used only by lfm2_moe. The M5 runs of other families (Qwen3.8-27B with and without DFlash2, Qwen3.5-2B, Nemotron) are on the lane-core head in #1382.

Kernel contract and exactness (M5 Ultra)

Built on the M4 with -Dcpu=apple_m1 -Dversion=1.1.4 -Dmetal-std=metal4.0. A full tf-kernel-contract run is not posted: it refuses on main and on this branch alike (UnrecordedRotaryCompiler; it needs a tf-rotary-sweep receipt on the machine).

main 8c36359d5, --only lfm2:      0 kernels (main registers no LFM2 kernel)
main 8c36359d5, --only tile_qmm:  SUMMARY 6 kernels: 6 pass, 0 fail, 0 limit problems
this head,      --only lfm2:      SUMMARY 3 kernels: 3 pass gates 1-4, 0 fail, 0 known findings; 4 controls: 4 red as planted, 0 missed; 0 limit problems
this head,      --only tile_qmm:  SUMMARY 6 kernels: 6 pass, 0 fail

The new kernels: "metal lfm2 argmax" (the host draw's pick, ties to the lower id, nonfinite rows flagged), "metal lfm2 stream attention" and "metal lfm2 stream conv" (bit-equal to the per-stream kernels they replace, with float64 attention and a host replay of the conv steps as their checks). The four planted controls: a tie to the highest id, a nonfinite logit not flagged, every stream reading the first stream's state, every stream writing the first stream's cache. Launch limits are in zig/src/core/contract/lfm2_records.zig, the M1 and M2 ones from --offline. tf-lfm2-exact (real weights): 72 forward/cache checks passed, 0 failed, 0 skipped, on main and on this head.

Failures the tests catch

  • The caches not declared to the encoder (the useResource loop removed): on the M5, 0 of 12, 0 of 24 and 0 of 48
    concurrent replies equal their solo runs at 4, 8 and 16 distinct streams, in both of 2 rounds; with it, 12 of 12,
    24 of 24 and 48 of 48. test-lfm2 fails on that variant ("a shared round's table reaches only cache buffers it
    declares") and passes on this branch.
  • partialCount with @min(rows, batch_rows) left untyped (it narrows to u6, and most * subs wraps): test-lfm2
    fails, "expected 262144, found 65536". Main's sizing, every stream's partials in the same rows: the same failure.
    Both pass on this branch.

Tests and checks (M4 Pro, this head)

  • zig build test test-lfm2: 531 of 531 steps passed. zig fmt --check on zig/src: clean. tools/zig/lean_check.py: 428 problems, the same as main.
  • Chips: M4 Pro and M5 Ultra are run, and an M3 Pro (receipt in lfm2: a cheaper DSpark hold (one concurrent encoder) #1394). M1 and M2 launch limits are recorded from offline compiles; no run covers M1 or M2. The test plan below is for anyone with one.
Test plan for another machine

What it changes: with several LFM2 streams, conv and attention run once per layer over all streams, and the greedy
pick runs on the GPU. A reply must not change. Speed with 4 or more streams should go up.

Chips: M4 and M5 are run, and an M3 Pro ran the DSpark stack head. No run covers M1 or M2. Their launch limits are recorded in the kernel contract from
offline compiles. A run on either is the most useful receipt.

Who: anyone with an Apple Silicon Mac (M1 to M5) and enough memory for the 4-bit checkpoint (about 15 GB of it
is used while serving).
Needs: macOS with Xcode and its Metal compiler, Zig 0.17.0, Python 3.12, the checkpoint
LiquidAI/LFM2.5-24B-A2B-MLX-4bit (revision 667408d494bde191c2cb9dd4f4549827816b9275).

Rules: one model process at a time. Close other heavy apps. Do not build while a speed cell runs.

1. Two builds

main is the before. pr is the after. Two folders, so you can switch without rebuilding.

git clone https://github.com/ashhart/TensorFold.git tf-main
git clone https://github.com/ashhart/TensorFold.git tf-pr && git -C tf-pr fetch origin pull/1377/head:pr1377 && git -C tf-pr checkout pr1377
for d in tf-main tf-pr; do (cd $d && zig build native tf-lfm2-exact tf-lfm2-bench tf-kernel-contract -Dcpu=apple_m1 -Dversion=1.1.4); done

Write down: git -C tf-main rev-parse HEAD, git -C tf-pr rev-parse HEAD, your chip, memory, sw_vers.

2. Kernel contract (does this chip run the new kernels?)

for d in tf-main tf-pr; do echo "== $d"; ./$d/zig-out/bin/tf-kernel-contract --only lfm2; ./$d/zig-out/bin/tf-kernel-contract --only tile_qmm; done 2>&1 | tee contract.txt

Send the SUMMARY lines. If a chip fails or refuses here, say so: the PR does not support it yet.

3. Exactness (replies equal the before)

MODEL=/path/to/LFM2.5-24B-A2B-MLX-4bit
for d in tf-main tf-pr; do echo "== $d"; ./$d/zig-out/bin/tf-lfm2-exact $MODEL; done 2>&1 | tee exact.txt

Both must end with 0 failed. The PR run has more checks (it also checks the GPU pick against the host).

4. Served replies and speed

Alternate the builds: main, pr, pr, main, main, pr (three rounds, A B, B A, A B), so heat and load hit both.
For each cell: start the server in one terminal, run the client in another, stop the server, and wait until
pgrep -fl tensorfold-native prints nothing. Replace BUILD with tf-main or tf-pr, and ROUNDN with r1,
r2, r3.

./BUILD/zig-out/native/bin/tensorfold-native serve $MODEL --parallel 16 --port 8080 --context 40960
python3 tf-pr/tools/bench_concurrent.py http://127.0.0.1:8080 local-model --tokens 256 --reps 3 --temperatures 0 \
  --levels 1,4,8,16 --alone --prompts tf-pr/tools/bench_prompts.json --label BUILD-ROUNDN --output out-BUILD-ROUNDN.json

(--prompts gives each stream a different prompt. A same-prompt run flatters the numbers.)

Check:

  • Replies: for the same prompt and level, every token_sha256 is the same in the main and pr files.
  • Each concurrent reply equals its --alone run. The client reports this.
  • Speed: aggregate tok/s at 1, 4, 8 and 16 streams, per round. Report every round, not an average.
    On an M5 Ultra the PR measured about +3% (1 stream) up to +40% (16 streams). On an M4 Pro: about −2% to +10%.

Throw a cell away if another model process or a Zig build ran during it. Say which.

5. Prompt speed (must not change)

In process, no server. Replace BUILD as above:

for r in 1 2 3; do for b in tf-main tf-pr; do echo "== $b"; ./$b/zig-out/bin/tf-lfm2-bench $MODEL 8192 2048 128; done; done 2>&1 | tee bench.txt

The prompt rate must be equal within noise.

6. Memory

For one main and one pr server run of section 4, start the server with /usr/bin/time -l in front. After Ctrl-C,
send the maximum resident set size and peak memory footprint lines. They must be equal within noise.

7. Send back

One message with: both commit hashes, chip, memory, macOS, the SUMMARY lines, the end of each tf-lfm2-exact
run, any hash that differs, the per-round tok/s table, the tf-lfm2-bench lines, the memory lines, and the cells
you threw away with the reason.

Not covered here: other model families (a separate A/B), CUDA and HIP.

Not measured

  • M1 and M2 Macs: no run. An M3 Pro ran the stack head (receipt in lfm2: a cheaper DSpark hold (one concurrent encoder) #1394).
  • 64k, 128k and the native window for cold prefill (2k and 32k measured).
  • Sampled decode at several streams (only greedy measured; sampled rows still draw on the host).
  • 6- and 8-bit checkpoints.
  • CUDA and HIP (this family has no CUDA or HIP path).

Related PRs

The DSpark drafter PRs (#1390 to #1394) sit on this PR. They add the same docs/recipes/lfm2.5-24b-a2b.md and touch forward.zig, state.zig, kernels.zig, lfm2.metal, metal_main.zig and metal_targets.zig; they rebase on this.

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.


Issue

CONTRIBUTING asks for an issue before a new family, backend or arithmetic change. This is none of them: the kernels
are bit-equal and no reduction order changes.

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.
@ashhart

ashhart commented Oct 26, 2026

Copy link
Copy Markdown
Owner

A small request, @BlivionIaG: this description links more than 20 issues and PRs, which is over Agent Traffic Control's limit, so ATC drops the PR from its board and collision checks (and marks every check incomplete for everyone). Could you trim the related-work list to the issues this PR actually fixes or depends on (20 at most)? Thanks.

@BlivionIaG
BlivionIaG marked this pull request as ready for review October 26, 2026 11:59

@ashhart ashhart left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @BlivionIaG. LFM2 shared-round decode is the right direction. Before device checks: the new operators need canonical planted controls and recorded launch/compiler limits in the kernel contract (the summaries show 0 controls, and limit declarations use current-device checks), and the shared tile dispatch it touches needs a cross-family A/B. Please don't skip M1-M3 launch records: qualify those chips or refuse them explicitly.

…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.
@BlivionIaG

BlivionIaG commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Pushed 70f74ca34 (main merged in).

  • Kernel contract: the shared-round operators now have four planted controls, all red as planted, and recorded launch limits. The M1 and M2 limits come from --offline compiles. The old per-test limit functions with offline = false are gone. lfm2_attn_segs and lfm2_attn_block reserve 128 threads in their source.
  • Chips: M4 Pro and M5 Ultra are run. An M3 Pro ran the stack head (@jschmied's receipt on lfm2: a cheaper DSpark hold (one concurrent encoder) #1394): 534 of 534 tests, 11 kernels pass the contract with 17 of 17 controls red as planted, tf-lfm2-exact 72 passed. M1 and M2 are not run; their limits are recorded. The PR body has a test plan for anyone with those chips.
  • Other families, M4 Pro: Nemotron-3.5-Lightning 4-bit within 1.3% of main at 1 and 4 streams, tokens equal (table in the body). The M5 runs on Qwen and Nemotron measured the lane-core head (see lanes: a lone stream holds drafts only while its drafted rounds measure faster #1382), not this branch.
  • Decode on this head, M5 Ultra, a different prompt on each stream, three rounds against main 8c36359d5: +3.0% at 1 stream, +16.7% at 4, +33.2% at 8, +39.4% at 16, every token_sha256 equal; cold time to first token and peak footprint unchanged. M4 Pro: +1.4% at 1 stream and +5.3% at 4. Tables are in the body.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG, this is careful work and the numbers are good.

Since our last review the five shared-round launches and the greedy pick have records in lfm2_records.zig (M1 and M2
from --offline), the per-probe limit checks that queried the device are gone, and the four planted controls go red.
That closes the contract point. Thanks also to @jschmied for the M3 Pro run.

We merged this head onto current main (b30d5b9d3). It merges cleanly, and zig fmt --check, the lean check (no new
findings) and zig build test pass on the merge. Reading it, the GPU pick matches the host draw (first largest, lowest
id on ties, a nonfinite row goes back to the host), run bounds a round to batch_rows so the segment table cannot
overflow, and the contract probe covers unequal streams and a history past one 4,096-key chunk.

Before landing we will run our own cells on one Mac against main: tf-kernel-contract --only lfm2 and --only tile_qmm, tf-lfm2-exact on the 4-bit export and on the 6- and 8-bit ones (the README lists all three and only the
4-bit was measured), served replies at 1, 4, 8 and 16 streams (token hashes against main, concurrent against alone,
greedy and sampled), and alternated speed rounds. If those hold, we will land it. No changes needed from you for now.

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.

2 participants