Skip to content

lfm2: a cheaper DSpark hold (one concurrent encoder) - #1394

Open
BlivionIaG wants to merge 14 commits into
ashhart:mainfrom
BlivionIaG:series/dspark-hold
Open

BlivionIaG wants to merge 14 commits into
ashhart:mainfrom
BlivionIaG:series/dspark-hold

Conversation

@BlivionIaG

@BlivionIaG BlivionIaG commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Runs shared DSpark holds in one concurrent encoder, shares staged absorbs, measures shared verification increments, and reuses final lane probability/grammar buffers.

Rebuilt on main d33dcfc767ee093393c6bfa00b3949bd7b4d8e88. The rejected #1379/#1382 guard commits are excluded. No M5 was accessed for this restack. Fresh CI is running; maintainer re-review remains required.

Scope and review fixes

Includes the clean #1390–#1393 chain and #1381. All Zig/build source files are byte-identical to the previously tested integration f73bb5590 (public parents: prior #1394 head 98f5cc72b and main d33dcfc76); the rebase changes history and receipt placement, not arithmetic or policy. Current main’s unrelated contracts are retained.

Validation

Code f9de08d58; final receipt head 87d1fbbd0. 73/73 scoped build steps, 355 tests passed and three skipped; native/reference/contract tools compile. Lean has zero new findings. The new local M4 replay passes 11 kernels / all 17 controls red, 9/9 FP32-reference picks, zero-allocation steady rounds, cache/resume/fresh equality, cancellation/replacement at 6/16 slots (zero wrong/errors), 29 sampled cases and eight 8-bit equality cells. M4 scope and full results. It uses context 4096; no new long-context or performance qualification is claimed. The default build passes 239/239 steps. Prior same-source M5 integration report has 72 throughput cells, six retention batches and matching tokens. Earlier long-context/cancel/cache receipts retain their original source identifiers.

Remaining performance requirement

The existing M5 integration loses about 1–2% at four/eight streams while gaining about 7–9% at 16. This restack does not claim to fix or waive that tradeoff, and Ash has not approved an exception to the no-slowdown requirement. The accepted task-owner tradeoff is not presented as maintainer approval. No stream-count/context guard is introduced.

Part of #1374. The chain’s intermediate branches are now updated rather than leaving fixes only in the cumulative tip.

@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. This builds on #1391, so it inherits that review's blockers (runtime lossy BF16 conversion, the 4-bit buffer mismatch, dropped async failure flags, the contract gaps). Its concurrent encoder and shared stage flush also need device race, resume and replacement-equality checks.

@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Device checks on an M4 Pro at the stack (details in the body, head 2e5966f68): resumed equals fresh (hash dc53424643e3 with the prompt cache on and off); about 1,000 requests with random cancels and immediate replacements at 6 and 16 slots, 0 wrong replies and 0 errors against solo hashes; the same with Metal API validation on, no validation message. This is evidence, not a proof of ordering: the concurrent encoder puts a buffer barrier after every dependent step, and I describe it in the body.

@BlivionIaG
BlivionIaG marked this pull request as ready for review October 26, 2026 21:37
@jschmied

Copy link
Copy Markdown
Contributor

An M3 receipt for this head, since the body says no run covers M1, M2 or M3. M3 Pro (36 GB), macOS 26.7.1, Zig 0.17.0, head 2e5966f68:

  • zig build test test-lfm2 test-lfm2-dspark -Dcpu=apple_m1: 534 of 534 steps succeeded (45 tests skipped). zig fmt --check zig is clean.
  • tf-kernel-contract --only lfm2: 11 kernels pass gates 1-4, 0 fail; 17 controls red as planted, 0 missed; 0 limit problems. --only tile_qmm: 6 pass, 0 limit problems.
  • The full zig build kernel-contract can't finish on an M3, on main b30d5b9d3 too:
    • First it stops at UnrecordedRotaryCompiler.
    • With a fresh tf-rotary-sweep record, it then stops at "gemma4 31B rounded dense: tensor units required".
  • tf-lfm2-exact on LiquidAI/LFM2.5-24B-A2B-MLX-4bit @ 667408d4: 72 forward/cache checks passed, 0 failed, 0 skipped.
  • tf-lfm2-dspark MODEL DRAFTER 256 (drafter @ e052e5b1): 9 of 9 drafts equal the fp32 reference's picks, with the largest confidence difference 0.00106. Over 20 steady rounds: 0 host allocations, 0 resizes. A 16-stream hold takes 21.9 ms at depth 1 and 40.2 ms at depth 2.
  • Served with --drafter --parallel 2, greedy, 160 tokens, 4 prompts: drafted replies equal "draft": false byte for byte, also with two drafted streams at once.

@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Thank you @jschmied. I added your M3 Pro receipt to this body and updated #1377 to #1394 and #1374: M1 and M2 are now the chips with no run (their launch limits are recorded from offline compiles).

@BlivionIaG

Copy link
Copy Markdown
Contributor Author

An M3 receipt for this head, since the body says no run covers M1, M2 or M3. M3 Pro (36 GB), macOS 26.7.1, Zig 0.17.0, head 2e5966f68:

  • zig build test test-lfm2 test-lfm2-dspark -Dcpu=apple_m1: 534 of 534 steps succeeded (45 tests skipped). zig fmt --check zig is clean.
  • tf-kernel-contract --only lfm2: 11 kernels pass gates 1-4, 0 fail; 17 controls red as planted, 0 missed; 0 limit problems. --only tile_qmm: 6 pass, 0 limit problems.
  • The full zig build kernel-contract can't finish on an M3, on main b30d5b9d3 too:
    • First it stops at UnrecordedRotaryCompiler.
    • With a fresh tf-rotary-sweep record, it then stops at "gemma4 31B rounded dense: tensor units required".
  • tf-lfm2-exact on LiquidAI/LFM2.5-24B-A2B-MLX-4bit @ 667408d4: 72 forward/cache checks passed, 0 failed, 0 skipped.
  • tf-lfm2-dspark MODEL DRAFTER 256 (drafter @ e052e5b1): 9 of 9 drafts equal the fp32 reference's picks, with the largest confidence difference 0.00106. Over 20 steady rounds: 0 host allocations, 0 resizes. A 16-stream hold takes 21.9 ms at depth 1 and 40.2 ms at depth 2.
  • Served with --drafter --parallel 2, greedy, 160 tokens, 4 prompts: drafted replies equal "draft": false byte for byte, also with two drafted streams at once.

Thanks for the run, now we have proof it works

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG, and thanks to @jschmied for the M3 Pro receipt. The concurrent hold encoder puts a barrier after
every step that reads an earlier step's writes (we followed the absorb, the layers, the attention waves and the Markov
steps), encodeTwinFrom leaves encodeTwin and its callers as they were, and the cancel and replacement stress at 6
and 16 slots with Metal API validation on, plus resumed equal to fresh, is the device evidence our last review asked
for.

This PR's own changes look ready. It is blocked only by what it inherits from #1391 and #1392: the guards, the
Drafter.init errdefer order, the config bounds and the quantizer test. Once those are fixed and the stack is rebased
on current main without #1379 and #1382, we will run the device cells on the stack head: drafted against plain and
concurrent against solo at 1 to 16 streams, the long-context cells, and alternated speed rounds against main.

@Arminova

Copy link
Copy Markdown
Contributor

An M1 receipt for this head, for the load-time memory the body lists as not measured. M1 Pro (32 GB, MacBookPro18,1), macOS 27.0.1, Xcode 27.1 with Metal Toolchain 27A266a, Zig 0.17.0, head 2e5966f:

zig build native -Dcpu=apple_m1: built.
tensorfold-native serve on LiquidAI/LFM2.5-24B-A2B-MLX-4bit @ 667408d4, no drafter, --parallel 1 --context 4096, under /usr/bin/time -l: loaded in 112.9 s (cold, first start after the build). The server logged 13.6 GiB in use once loaded and 13.4 GiB wired between requests; context_fitted: false.
/memory once ready, before any request: lifetime_peak_physical_footprint_bytes 14,637,672,960 (13.63 GiB), footprint 14,608,919,088.
After one greedy request (23 prompt tokens, 256 reply tokens): the peak is unchanged at 14,637,672,960. time -l on exit gives the same peak memory footprint, 14,637,672,960.
That request: 51.4 tok/s decode; TTFT 19.2 s, the first request after the start.

@Arminova

Arminova commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

An M1 receipt for this head: the load-time memory the body lists as not measured, and a slow-prompt finding on M1 that main has too. M1 Pro (32 GB, MacBookPro18,1), macOS 27.0.1, Xcode 27.1 with Metal Toolchain 27A266a, Zig 0.17.0, zig build native -Dcpu=apple_m1, LiquidAI/LFM2.5-24B-A2B-MLX-4bit @ 667408d4, no drafter.

Load-time memory (head 2e5966f68, --parallel 1 --context 4096, under /usr/bin/time -l):

  • Loaded in 112.9 s, cold. The server logged 13.6 GiB in use once loaded and 13.4 GiB wired between requests; context_fitted: false.
  • /memory once ready, before any request: lifetime_peak_physical_footprint_bytes 14,637,672,960 (13.63 GiB), footprint 14,608,919,088.
  • After one greedy request (23 prompt tokens, 256 reply tokens) the peak is unchanged; time -l on exit gives the same peak memory footprint, 14,637,672,960.

Cold prompts are slow on M1, on this head and on main ee896951d alike. One stream, --context 4096, one warm-up, then raw /v1/completions with max_tokens 1 and a fresh nonce first (cached=0 in every server line); times are the server's ttft:

prompt tokens this head main ee896951d
16 15.97 s 15.38 s
19 17.02 s 16.72 s
114 35.92 s 40.62 s
117 39.93 s 41.19 s
462 111.03 s 109.23 s
448 108.23 s 108.34 s
930 202.82 s 212.11 s
916 191.93 s 193.18 s
fit 14.9 s + 0.200 s a token 15.0 s + 0.204 s a token
  • About 15 s before any prompt token, then about 5 prompt tokens a second. Decode is normal: 54.5 tok/s on this head and 44.1 on main for 128 tokens after a 15-token prompt.
  • The startup line gives LFM2 window ms by width: 1 17.7, 2 27.6, 4 45.7, 8 80.8, 16 143.5, about 110 rows a second at width 16, some 20 times what cold prompts get.
  • At --context 16384 on this head a 1,864-token chat prompt took 404.2 s; a following ~8k-token prompt did not finish, and the server ignored SIGINT and SIGTERM during it and needed SIGKILL (seen once).

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @Arminova for the M1 Pro receipt. The load peak (13.63 GiB without a drafter) is the number the body was missing. The slow cold prompts show on main too, so this stack didn't cause them. The long prompt that ignored SIGTERM looks like the same whole-prompt pass, which has no cancel point on main either. We'll look at it with #1377.

@BlivionIaG, nothing has been pushed to #1390-#1394 since our last comments, so where things stand is unchanged. This PR's own changes are fine. What still blocks the stack:

Once that's in, we'll run these on the stack head: drafted against plain and concurrent against solo at 1 to 16 streams, the long-context cells, and alternated speed rounds against main.

@BlivionIaG

Copy link
Copy Markdown
Contributor Author

@ashhart Following up on your last checklist: the cumulative fixes are now pushed here, at 98f5cc72b. This PR targets main directly; the older intermediate PR branches have not been rewritten.

  • Rebuilt on main dd0a9cc5e, excluding the rejected lanes: a shared round drafts only while drafting measures faster (Model.shared_guard) #1379/lanes: a lone stream holds drafts only while its drafted rounds measure faster #1382 guard commits. Removed the shared/lone guards and their recipe descriptions, and restored the unrelated native/metal.zig comment.
  • Fixed Drafter.init failure cleanup ordering, added allocation-failure coverage, validated rank/theta bounds, and widened the quantizer arithmetic so 64 * bits does not overflow in Debug. BF16 is the default, 8-bit is opt-in, and 4-bit is refused. Corrected the recipe and included load-peak evidence.
  • Source 099455490 has the M5 drafted/plain and concurrent/solo, sampled/resumed-cache, cancellation/replacement, 8-bit, and 65,536/130,816-token equality checks. LFM kernel contracts: 11 pass, all 17 negative controls red. Independent drafter reference: 9/9; 20 steady rounds with zero host allocations/resizes. The new M4 kernel/reference checks pass, but memory pressure prevented repeating the served qualification there; older-chip receipts remain explicitly historical.
  • The 128-cell, four-rotation M5 performance comparison shows 16-stream gains of 10.96% code / 8.86% prose over the earlier PR, with four-stream losses of 1.93% / 0.42%. The task owner accepted that tradeoff. Candidate plain four-stream prose is also 2.04% below main; we are not claiming gains everywhere.

Receipts, reproducer and limitations.

The final allocation-only follow-up removes duplicate probability and held-grammar buffers without changing arithmetic or decoding policy. Its shared-lane/native checks pass (341 tests, three skipped), and the separate 64-cell M5 before/after comparison completed with matching token hashes and throughput changes between -0.45% and +0.68%. That comparison is additional evidence, not a repeat of the full hardware suite on the final commit.

All nine CI checks are green on 98f5cc72b, and GitHub reports no merge conflicts. Could you re-review this cumulative head and run your maintainer device cells when available?

… holds follow the grants

A drafter that gives each held chain draft's chance (Drafter.VTable.chances) may set Facts.chances. Drafted.facts
then sets Model.draft_probabilities and Model.granted_depth, so the window allocator prices each stream's verify
rows by those chances, as it does a head's node chances. With Facts.confidence_floor it instead verifies the
drafts at least that likely (a fixed window when 0).

With granted_depth a stream's next hold is as deep as its last grant plus a slack: one more draft when the grant
took every held draft, none when it stopped short of them (Stream.grant_short), and after a grant of none a probe
of draft_slack drafts at the depth rule's probe interval. Facts.slack sets one slack for all three. A shared
round's granted depths are cut so the drafting streams' blocks fit one hold pass of batch_rows. When the
shared-round guard starts a drafted stretch, its first round grants every stream one draft, so the stretch
measures drafted rounds rather than plain ones left by grants of none.

All of it is behind Facts.chances and Model.granted_depth, off by default; windows.allocate records
grant_short only for granted depths.

Tests: chances price the chain's rows and drafted replies equal one-token decode alone and at 1, 4 and 8 shared
streams, with the scheduled verify ragged across streams and verifying fewer rows than a fixed window; the facts
reach the lane config; granted depths, their slacks and the one-pass cut; holds as wide as the grants.
…py, and holds kept only while they pay

The depth rule asks one head draft for a stream with a copy ahead, and a shared round's granted holds were priced
only by their verify rows. A drafter whose hold runs as a pass of its own (Facts.copy_first) pays that pass for
the one draft, and the copy round then drops it; Model.copy_first now asks it for none.

A drafter may also give its hold pass's timed cost by blocks and depth (Facts.hold_costs, priced by
hold_price.zig). With granted depths a shared round then keeps granted holds, most expected drafts a row first,
only while each raises the next round's expected tokens a ms over its verify rows and the pass; a probe after a
grant of none keeps its hold. A stream's last grant records the drafts it expected to keep (Stream.grant_gain).

Both are off by default (copy_first false, hold_costs empty).

Tests: with copy_first no hold is asked for a copy round and replies equal one-token decode at 1 and 3 streams;
a hold's cost between and past its timed passes; granted holds kept while they pay, none for a dear pass, and a
probe's hold kept.
Bound the reused ordering buffer by batch_rows, and cover reuse with an
allocator that refuses further allocations. Interpolate between the measured
depths surrounding the requested depth, including nearest-end extrapolation.
Query a stateful copy proposer once for a granted-depth budget decision.
The lane core can put an external drafter in front of a target. The
drafter reads the target's hidden rows after some layers (its taps).
LFM2-MoE had no taps, so it could draft only with the core's own heads.

prepare_features names the layers. From then on, each forward copies
each row's output of those layers into the scratch (lfm2_tap, a plain
copy of bf16 values). features() gives a stream's rows of the latest
verify or one-token round. prefill_features hands each prompt chunk's
rows to the drafter before the chunk's end is kept for reuse. The
prompt's last row is kept beside the stream's caches for its first
draft.

A drafting stream resumes only a kept state that also holds its
drafter's context (Snap.draft). Without a drafter nothing changes: no
taps are kept and every forward runs as before.

lfm2_tap registers with the kernel contract (metal_lfm2_tap.zig): the
third of five taps' columns at 1, 10, 32 and 2,048 rows must hold the
layer's bits and the other columns what the call found. Gate 5 checks
its launch against every Apple chip we serve and this GPU.
test-lfm2-dspark checks the same copy through the family's own kernels.
@BlivionIaG

Copy link
Copy Markdown
Contributor Author

@ashhart The intermediate branches are now rebuilt too, rather than leaving fixes only at the cumulative tip. All are on current main d33dcfc76, without the rejected #1379/#1382 ancestry:

No M5 was accessed for this restack. New M4 Pro / 48 GiB checks of the final integration:

  • Selected LFM2 contracts: 11 kernels pass, all 17 controls red, zero limit problems. Independent reference: 9/9 picks, worst confidence difference 0.00106; 20 steady rounds, zero host allocations/resizes.
  • Context 4096, parallel capacity 16: cached/resumed equals fresh, with 3517 reused tokens. Under Metal API validation, stress at 6 slots/60 s gives 120 completed +79 cancelled, and 16 slots/90 s gives 250 completed +132 cancelled; zero wrong replies and zero errors. All 29 sampled cases equal plain/solo/concurrent, and eight 8-bit cells at 1/4/8/16 streams across code/prose equal their plain and solo oracles. All five servers shut down gracefully, with no memory-pressure admission warning.
  • Each rebuilt head passes its scoped checks and global formatting; zero new lean findings. Final scoped checks: 355 passed / three skipped, 73/73 steps. Default build: 239/239 steps.

Reproduction, raw hashes/results, actual load peaks and limitations. CI is running on the new heads; these local results do not stand in for pending CI or your review.

The previously measured 1–2% slowdown at four/eight streams is still open against your performance requirement. Restacking does not fix or waive it, and the task owner’s accepted tradeoff is not presented as your approval. No new performance comparison or 65k/130k long-context run was made in this M4 replay; earlier receipts keep their original source scope. Could you re-review the propagated correctness/base fixes, with that remaining performance item kept explicit?

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