Skip to content

lfm2: draft with LiquidAI's DSpark drafter (--drafter) - #1391

Open
BlivionIaG wants to merge 39 commits into
ashhart:mainfrom
BlivionIaG:series/dspark-drafter
Open

BlivionIaG wants to merge 39 commits into
ashhart:mainfrom
BlivionIaG:series/dspark-drafter

Conversation

@BlivionIaG

@BlivionIaG BlivionIaG commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Branch series/dspark-drafter, head 35e9255c0. Sits on the taps PR (#1390) and on #1378 and #1379. Part of #1374.

What this changes

tensorfold serve takes --drafter DIR for lfm2_moe checkpoints with LiquidAI's DSpark drafter,
LiquidAI/LFM2.5-24B-A2B-DSpark at e052e5b1dd103c38cdd4f8ddc068d7dea4586f55. Its weights are under the LFM Open
License v1.0 and are not shipped; the recipe links the licence and says to read it before use. The lane core verifies
every draft, so a drafted reply equals its "draft": false reply.

  • The drafter is family code (families/lfm2_moe/dspark*.zig). It runs the released bf16 matrices as they are, on bf16 kernels (lfm2_bf16_rows, lfm2_bf16_mma, lfm2_markov_scan_bf16). --drafter-bits 8 is an explicit option that quantizes them to 8-bit codes at load and runs them on the family's kernels and core/tile_qmm; it is named in --help and the startup log. 4 is refused on LFM2 and 8 on other families.
  • New kernels, registered with the kernel contract with gate 5 over every Apple chip we serve: lfm2_markov_scan and
    lfm2_markov_pick (metal lfm2 markov step), lfm2_attn_block and lfm2_attn_block_merge (the target's attention
    kernels with a FULL template flag; the target's instantiations keep their causal count), lfm2_draft_ids.
  • A hold that fits one pass is not waited on: the next verify waits on its event on the GPU and copies its drafts
    (Drafted.held_on_target, from series/lanes-held). Shared rounds take the shared-round guard (series/lanes-guard).
  • The drafter lane's bytes join the per-stream bytes metal_admit plans with; a refused open frees what it loaded.
  • The held path refuses a NaN, Inf or invalid pick with DraftNonfinite, as the synchronous path does; planted tests for both paths fail without the fix. A hidden_act other than silu is refused by name. The fp32 reference reads the target embedding bits from the checkpoint and its host matmul joins its workers on every path.
  • No environment switch: LFM2_DRAFT_TRACE and LFM2_DSPARK_SYNC of the source history are gone.

Receipts at this head

M5 Ultra (Mac17,15, macOS 27.0 build 26A428), stack head 2e5966f68, three alternated rounds, a fresh server per cell, no other model process, --parallel 16. bf16 is the default, 8-bit is --drafter-bits 8.

cell no drafter bf16 8-bit
1 stream, code (tok/s, median of 9) 289.8 355.9 (+22.8%) 370.2 (+27.8%)
1 stream, chat 267.9 313.1 (+16.9%) 324.3 (+21.1%)
38 short prompts, drafted vs "draft": false on one server 262.6 364.5 389.7
4 streams, distinct prompts (aggregate) 564.3 568.6 (+0.7%) 570.0 (+1.0%)
8 streams 734.6 735.0 (+0.0%) 737.1 (+0.3%)
16 streams 924.1 913.1 (-1.2%) 918.0 (-0.7%)
cold time to first token, 2,048 tokens (s, median of 3) 0.149 0.156 0.152
cold time to first token, 65,536 tokens (s, median of 3) 7.60 7.84 7.69
peak footprint, warm (GiB, --context 131072) 13.90 14.67 14.41
peak footprint after one 65,536-token request (GiB) 15.14 16.52 16.26
  • Exactness: exact.py 8 of 8 equal in all three rounds for bf16 and for 8-bit; all 38 short-prompt replies token-equal drafted against "draft": false; bench_concurrent --alone --serial equal in every cell, 0 failed. Drafter weights 625 MiB (bf16) and 361 MiB (8-bit); accepted tokens per round 2.827 and 2.859.
  • Drafts against the fp32 host reference (tf-lfm2-dspark, 2,048 and 16,384 context, M5): 9 of 9 equal in both modes; at 8,192, 32,768, 65,536 and 130,816 context (bf16): 9 of 9, first-row logit cosine 0.99998, 0.99997, 0.99992, 0.99995.
  • Kernel contract and tf-lfm2-exact at the DSpark stack head 2e5966f68 (it holds this PR's kernels), M5 Ultra (Mac17,15, macOS 27.0 build 26A428): --only lfm2 SUMMARY 11 kernels: 11 pass gates 1-4, 0 fail, 0 known findings; 17 controls: 17 red as planted, 0 missed; 0 limit problems. --only tile_qmm 6 pass, 0 fail. tf-lfm2-exact (real weights, LFM2.5-24B-A2B-MLX-4bit 667408d4): 72 forward/cache checks passed, 0 failed, 0 skipped.
  • zig build test test-lfm2 test-lfm2-dspark at the stack head (M4 Pro, main 8c36359d5 merged in): 534 of 534 steps passed; zig fmt --check clean; tools/zig/lean_check.py 428 problems, the same as main.
  • 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.
  • Failures the tests catch: with the Markov bias dropped from the score (lfm2_dspark.metal line 35), the family test
    a Markov step picks the argmax of bf16(base + bf16(W2 . W1[prev])) fails and the contract says FAIL shapes metal lfm2 markov step: at 1x128000x256: the pick's score is below the row's best by more than its bound.
  • The multi-stream rows compare separate servers (the client cannot send "draft": false per stream there); the one-stream rows compare on one server.
  • M4 Pro DSpark decode: not measured on a quiet machine. The drafter's correctness checks ran on the M4 Pro (drafted equals "draft": false, resume, cancel, stress; see lfm2: a cheaper DSpark hold (one concurrent encoder) #1394), and one indicative round on a busy machine gave one-stream code 143.9 tok/s with the drafter against 140.6 plain and chat 121.2 against 121.1; it is not a receipt.

Not measured

  • M1 and M2 Macs (see above); load-time memory.
  • CUDA and HIP: this family has no CUDA or HIP path. Linux CI is run by the PR checks.

Related PRs

Sits on #1390, #1378 and #1379. The scheduled PR (#1392) sits on this PR.

With Drafted.held_on_target set, Drafted.verify gives the target its windows with Window.held as the round loop
made them, so a target that reads held drafts from the drafter on the device skips the host readback. A window
of a stream with fillers still gets host tokens. The flag is off by default; without it verify runs as before
and the new check is not evaluated.

drafted_held_test.zig checks the three cases: held windows pass through, a stream with fillers gets its pending
token as host tokens, and with the flag off the drafter's drafts are read back as host tokens.
engine.zig is 592 lines and the shared-round guard needs room in it. pipelinedRound and landInflight move
unchanged to pipelined.zig as round and land; Engine.familyRound and Result become public for them. No
behaviour change.
…el.shared_guard)

A drafter whose hold is a pass of its own costs a shared round more than the depth rule's head steps price:
the rule treats the rest of a round's time as overhead that plain rounds pay too. Facts.shared_guard (and
Model.shared_guard for a backend) lets the engine compare the two modes directly.

shared_guard.zig keeps, for each stream count, committed tokens over the round's wall time in plain stretches
(no budgets, so no holds) and in drafted stretches. It starts plain, measures plain rounds for 16 rounds and
drafted ones for 4, runs the faster mode for 512 rounds and doubles that, up to 64 times, while the same mode
wins again; then it measures the other mode again. Drafted rounds must beat plain ones by 5%. A mode's first
round still carries the other mode's drafts or holds and is not measured. An entry is allocated once a stream
count; later rounds take no allocation.

A plain stretch changes only how many drafts a window carries, never what the target draws, so replies stay
the same tokens. Without the flag the engine never consults the guard.

Tests: the guard's choice and re-measures, ever rarer re-measures of a losing mode, its skipped first rounds,
no allocation after a stream count's first round, and replies equal to one-token decode with the guard on and
off.
… stream joining one holds no drafts

With the shared-round guard on, a plain round still verified the lane core's context and Recall copies, which
are drafts too, priced from a prior; and a stream joining other streams held its first drafts even while the
guard ran plain rounds at that stream count, a hold pass for each join.

The engine now marks a shared round plain when the guard runs that stream count plain, and copyProposal
proposes nothing for it, so the guard's plain mode is the drafts-off round. Open.firstDepth gives no drafts to
a stream that joins live streams while the guard runs their count plus one plain. Both are behind
Model.shared_guard; without it plain_round stays false and firstDepth is unchanged.

Tests: a guarded plain round proposes no copies; a stream joining plain-guarded rounds holds no first drafts
and, without the flag, holds them as before.
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.
LiquidAI/LFM2.5-24B-A2B-DSpark is LiquidAI's speculative-decoding
drafter for LFM2.5-24B-A2B. `tensorfold serve` now takes it with
--drafter DIR for lfm2_moe checkpoints. The lane core verifies every
draft, so a drafted reply equals its "draft": false reply.

The drafter reads the target's rows after layers 2, 10, 18, 26 and 34
(the taps). fc and hidden_norm turn each row into the context keys and
values of five Qwen3 layers. A block of the pending token and mask
tokens runs the layers with non-causal attention, then the target's
head. The Markov head adds W2 W1[previous token] to each row's logits
and takes the argmax, one row after the other. A block is as long as
the deepest chain its pass holds, at most the nine it was trained on.

The drafter runs on the family's own kernels and core/tile_qmm (on GPUs
with tensor units, core/affine_mm for absorb passes past the twin's
rows): its bf16 linears are quantized to 8-bit MLX affine codes at
load, and the q and k rows are reordered so that the target's
half-split RoPE kernel gives the drafter's interleaved RoPE. New
kernels:

- lfm2_markov_scan and lfm2_markov_pick (lfm2_dspark.metal): a Markov
  step's W1 gather, W2 dot over 8-bit codes, bias add and argmax, ties
  to the lowest token, a NaN score flagged;
- lfm2_attn_block and lfm2_attn_block_merge: the target's attention and
  merge kernels with a FULL template flag, every block row seeing the
  keys [0, len + 1). The target's own instantiations keep their causal
  count.

Both register with the kernel contract (metal_lfm2_dspark.zig,
metal_lfm2_attn.zig), gate 5 against every Apple chip we serve and
this GPU.

Rows reach the drafter in passes: a prompt chunk in one pass of up to
2,048 rows; a round's verified rows are staged on the host (128 a lane)
and absorbed by the next hold, so a round without holds does no
drafter GPU work. A hold pass takes as many streams as their blocks
fit in 32 rows. The drafter sets Facts.shared_guard, so shared rounds
draft only while drafted rounds measure faster than plain ones at that
stream count; a lone stream keeps the depth rule's own plain guard.

The drafter keeps its context with each kept prompt state, so a
drafting stream resumes a prompt as before. Its lane's bytes join the
streams the load limit admits. --drafter-bits 0 is refused for this
drafter, and a refused open frees the model it loaded.

Tests: test-lfm2-dspark checks the config's admission and refusals,
the quantizer, the RoPE reordering through lfm2_qk, the Markov step
and the block attention on the GPU. tf-lfm2-dspark runs the real
drafter against an fp32 host reference of the released checkpoint and
times its passes.
… the GPU

A hold that fits in one pass signals a GPU event and returns without a
wait. The next verify waits on that event on the GPU, and a new kernel,
lfm2_draft_ids, copies each window's drafts from the drafter's picks
into the verify's ids. The host reads the picks only after the verify,
or when a grammar, an absorb, a keep or a release needs them.
Multi-pass holds stay synchronous.

The LFM2 backend takes held windows (Window.held) from a HeldSource the
drafter gives, and Drafted.held_on_target passes windows without
fillers to it as they are.

lfm2_draft_ids registers with the kernel contract (metal lfm2 draft
ids): each stream's first pick, or 0 for a token past the vocabulary,
copied exactly; gate 5 against every Apple chip we serve and this GPU.
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.
The recipe says where the drafter comes from
(LiquidAI/LFM2.5-24B-A2B-DSpark at its revision), that its weights are
under the LFM Open License v1.0 and not shipped with TensorFold, how to
serve with --drafter, what the drafter runs and which kernels and
checks cover it. The README row and the recipe index name the drafts.
@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.

@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, a lot of careful work in this stack. We mapped all 31 commits to their PRs and reviewed each on current main. For the DSpark drafter (this PR and its descendants):

  1. Loading changes the released BF16 weights. dspark_weights.zig quantizes each dense matrix to 4/8-bit codes at startup (8-bit by default), and #1393 still discards the original. Target verification protects final tokens, but the engine doesn't create lossy copies of a checkpoint at serving time. Please run the released BF16 matrices, or refuse the format. Also honour each advertised --drafter-bits mode or refuse it by name (the native adapter accepts the setting and then uses 8 regardless).
  2. 4-bit mode reads past its buffer. With 4 bits the loader packs markov_w2 at that width, but lfm2_markov_scan (lfm2_dspark.metal) reads a byte per coefficient and the launcher passes no bit width. At 128000x256 the code buffer is 16,384,000 bytes and the shader can address byte 32,767,999 (found statically, not run on a GPU). Please refuse that mode before load or implement it correctly.
  3. The held (asynchronous) path drops draft failure flags: the synchronous path refuses a NaN pick or invalid ID (DraftNonfinite), but readPicks after an unwaited hold ignores Pick.nonfinite and maps an invalid ID to 0, as does the GPU-held copy. Native serving enables that path, so please carry the flag through the fence and add planted NaN/Inf/invalid-ID controls for both paths.
  4. Contract proof: the new operators report 0 planted controls and no recorded compiler/launch limits (the limit declarations use current-device checks with offline = false). Please add the gates, red controls, served shapes and records.
  5. Config admission doesn't check hidden_act before the fixed SiLU path, and an explicit scheduled verify without a confidence head silently becomes depth mode; both should refuse by name.
  6. The FP32 reference hard-codes a 6-bit target embedding and its host matmul joins workers only after all spawns succeed.
  7. The device receipts are mostly on older bases; we'll need alternating A/B on current main per landing head (the stack's own notes report a 1.8-2.7% four-stream drop on Qwen3.8-27B + DFlash2 from the shared-lane changes, which needs explaining, not dismissing).

…refused, and hidden_act must be silu

The unwaited hold read its picks with nonfinite ignored and a bad id as 0; the GPU copy into a verify window did
the same. Both now refuse as the synchronous path does (DraftNonfinite): readPicks errors, and lfm2_draft_ids sets
a flag the verify reads. Planted NaN, Inf and invalid-id tests cover the host and the kernel. The config refuses a
hidden_act other than silu by name.
… the checkpoint and joins its workers on every path
… 0, the default), no quantizing at load

The drafter's linears and Markov W2 stay bf16: new rows and tile kernels for the linears and a bf16 scan.
An explicit --drafter-bits other than 0 is refused by name. 8-bit codes stay behind Options.bits for tools.
…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.
…planted controls

Merges the shared-round contract records. lfm2_tap's launch is in lfm2_records.zig with the limits --offline
compiled, not this GPU's. Two controls edit the source: rows packed at the hidden size, and a column of another
tap overwritten. Each goes red at the shapes gate.
…d six planted controls

Merges the tap and shared-round contract records. The Markov scan and pick, the draft ids and the block
attention are in lfm2_records.zig with the limits --offline compiled, not this GPU's. lfm2_attn_block is its own
kernel now, with its 128 threads reserved in its source, as M1 and M2 cannot build it for another count. Controls
edit the source: a NaN score not flagged, every stream reading the first stream's previous token, a pick past the
vocabulary keeping its token, and a block row missing the last key. Each goes red at its gate.
…, the README, the recipe and the startup log

--drafter-bits takes 0, 4 and 8. LFM2's drafter runs bf16 as released when it is not given or 0, 8-bit codes quantized
at load on 8, and refuses 4. The other families refuse 8 by name.
…y gates 1-4, recorded M1/M2 limits and six planted controls

The few-rows and 64x64 tile kernels and the scan over a bf16 W2 pass gates 1 to 4. Six controls edit the source: a lane
skipping half of k, a group's last row, an untransposed W tile, a lost last column, a NaN score not flagged and every
stream reading the first stream's previous token. Each goes red at its gate. The tile kernel compiles for 832 threads on
M1 and M2.
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.
…ogged as untested with the drafter

The check is its own, after the engine's: it uses the same generationServed helper, with a test beside it. The drafter
kernels' records keep their M1 and M2 limits.
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 35e9255c0 (main merged in).

  1. Weights: with no flag, or --drafter-bits 0, the drafter runs the released bf16 matrices as they are. --drafter-bits 8 is an explicit option that quantizes them to 8-bit codes at load, named in --help and the startup log. 4 is refused on LFM2, 8 on other families. On the M5 Ultra, bf16 is 2.6% to 4.8% slower than 8-bit on one stream with about the same acceptance; numbers are in the body.
  2. 4-bit mode: refused by name before load.
  3. Held path: a NaN, Inf or invalid pick now fails with DraftNonfinite on both paths, with planted controls that fail without the fix.
  4. Contract: the drafter's kernels (bf16 ones included) have planted controls and recorded limits. M4 Pro --only lfm2: 11 kernels pass, 17 of 17 controls red as planted. M5 Ultra at the stack head 2e5966f68: --only lfm2 11 kernels pass gates 1 to 4, 17 of 17 controls red as planted, 0 limit problems.
  5. hidden_act other than silu is refused by name. There is no verify flag any more, so no scheduled-without-head case.
  6. The fp32 reference reads the target embedding bits from the checkpoint and joins its workers on every path.
  7. A/B on current main: in lanes: a lone stream holds drafts only while its drafted rounds measure faster #1382. Qwen3.8-27B with DFlash2 at 4 streams, M5, 6 rotated rounds, median of round medians: main 322.0 tok/s, a second build of the same main commit 318.8 (-1.0%), lane head 318.9 (-1.0%). Identical source differs as much as the lane head does, and the spread inside one cell is 304 to 333, so I did not reproduce the 1.8% to 2.7% gap; it follows the build or the session, not the code.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG. A lot of this is fixed, and fixed well:

  • by default the drafter runs the released bf16 matrices as stored (only the q and k rows and their norm gains are
    reordered for the half-split RoPE);
  • 4-bit is refused before load, so the markov_w2 overread cannot happen;
  • the held path carries failures: lfm2_draft_ids sets a flag that runHeld reads before any cache commits, and
    readPicks refuses a NaN or out-of-range pick as the waited path does;
  • a hidden_act other than silu is refused, and the fp32 reference reads the embedding width and joins its workers
    on every path;
  • every new kernel has a launch record and planted controls (11 kernels, 17 controls at the stack head).

What still needs to change:

  1. Drafting off by stream count. The drafter's facts set shared_guard = true, so served DSpark runs lanes: a shared round drafts only while drafting measures faster (Model.shared_guard) #1379's
    guard, which we are not taking (see lanes: a shared round drafts only while drafting measures faster (Model.shared_guard) #1379). Please remove it and let the lane core's pricing decide (lanes: an external drafter's chain chances price verify rows; holds follow the grants and pay for their pass #1381 with the
    confidence head, the depth rule without it). With the guard on, the stack head (bf16) is +17% to +23% on one
    stream, about even at 4 and 8 streams, and 1.2% slower at 16. Without the guard, --drafter must still be at
    least as fast as no drafter at every admitted width and context; we would also like drafting to pay at 4 to 16
    streams, so the hold's cost there is the thing to work on.
  2. The timed fix is missing on this head. series/dspark-drafter carries lanes: a shared round drafts only while drafting measures faster (Model.shared_guard) #1379's first three commits but not
    4fa02928a, so with Config.timed = false the guard still keeps adaptive state; our table-only check fails here
    (expected 0 guard entries, found 1) and passes on lfm2: the DSpark confidence head schedules the verify #1392 to lfm2: a cheaper DSpark hold (one concurrent encoder) #1394. Removing the guard settles this too.
  3. Drafter.init frees owned before walking it on an error. Errdefers run in reverse order, so
    d.owned.deinit(gpa) runs first and the for (d.owned.items) loop then reads freed memory. A failure in scratch()
    or warm() therefore walks freed memory (a minimal test of the same pattern segfaults under Debug) instead of
    refusing --drafter cleanly. One errdefer fixes it:
    errdefer { for (d.owned.items) |b| b.deinit(); d.owned.deinit(gpa); }. Please add a test that fails a scratch
    allocation or warm().
  4. Config admission. markov_rank: 512 is admitted, but lfm2_markov_scan stages W1's row in
    threadgroup float e[256], so any rank over 256 writes past it; rope_theta: 1e100 becomes an infinite f32 and
    passes theta > 0. Please add rank > 256 to the UnsupportedDraftShape check and !std.math.isFinite(theta) to
    BadDraftConfig, with both cases in the refusal test.
  5. Quantizer test. 64 * bits is computed in u8 at dspark_quant.zig:88 and overflows at 8 bits. The test passes
    only because zig build test builds the engine tests at -Doptimize=fast; under Debug it panics.
    64 * @as(usize, bits) / 8 fixes it.
  6. --drafter after a failed wide check. If the local wider-lanes check fails, Metal.init keeps one-row rounds
    and Drafted.facts then gives zero drafts, so the server loads the drafter and silently never drafts. Please refuse
    --drafter by name when the backend's width is 1.
  7. --drafter-bits 8 (LFM2.5-24B-A2B on Metal: drafts with LiquidAI's DSpark drafter (proposal and gate) #1374, Question 2). An opt-in 8-bit drafter is fine. It only changes what the drafter
    proposes; the target's verify keeps every token exact. That matches --drafter-bits for the other families' drafters
    on main. Please make sure drafted still equals plain with it, and keep bf16 the default.

Once these are in and the stack is rebased on current main without #1379 and #1382, we will run drafted against plain,
concurrent against solo and the speed cells on our Mac.

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