Skip to content

feat(speculative): Muse Glimmer DFlash assistant drafter on the round loop - #1762

Merged
inureyes merged 3 commits into
mainfrom
feature/issue-1343-muse-glimmer-assistant-drafter
Sep 10, 2026
Merged

inureyes merged 3 commits into
mainfrom
feature/issue-1343-muse-glimmer-assistant-drafter

Conversation

@inureyes

@inureyes inureyes commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

Adds the Muse Glimmer assistant drafter (meta-models/Muse-Glimmer-30B-assistant, model_type: muse_glimmer_assistant) to the existing DFlash round loop, with the Muse target adapter and the server dispatch, so mlxcel-server -m <Muse-Glimmer-30B> --model-draft <assistant> serves text-only requests speculatively at B = 1.

What changed

  • mlxcel-core drafter::dflash::muse: config (flat; num_target_layers and vocab_size are Options checked against the bound target, because the published config.json carries neither), five sliding-attention layers with per-head q/k norms under a bidirectional sliding mask over absolute positions (|q - k| <= 2048), encoder.fc plus encoder.output_norm_enc, and a Drafter adapter that binds the target's raw embedding table and untied lm_head. The context cache is a temporal window rather than a RotatingKVCache: a wrapped ring returns physical slot order and the mask needs absolute key positions. muse_glimmer_assistant resolves to DrafterKind::Dflash; its markers join the standalone-model rejection.
  • Muse target (src/models/muse_glimmer_speculative.rs): residual-stream capture before the final norm, trim rollback over the mixed caches, sliding caches armed with clamp(block * 8, 32, 128) rows of speculative buffer floored at the block size, the block-versus-chain exactness probe as the gate. MuseGlimmerTextWrapper and the VLM wrapper hand out embed_tokens_module (raw table, not the embed_norm lookup) and lm_head_module (no softcap). prefill_forward_with_capture_layers keeps the trait default on purpose: a Muse verify output carries no rollback state (the trim reads cache offsets), so there is nothing prompt-sized to skip.
  • Round loop: DFlashGenerator reads Drafter::configured_block_size / prefer_requested_block_size and, for a drafter that declares a depth below the requested width without insisting on it, runs the MTP loop's BlockThroughputController between the two. The Muse assistant starts at 4 rows under the checkpoint's 16-row ceiling (measurement below). Qwen 3.5 DFlash (no depth) and DSpark (prefers the requested width) keep a fixed width; pinned by dflash_round_loop_starts_at_the_configured_depth.
  • Server (the PR feat(speculative): LFM2 / LFM2.5 DSpark drafter on the DFlash loop #1751 review item): DFlashTargetModel::supports_batched() replaces the hardcoded LFM2 arm in the batched variant gate, required_drafter_family() replaces the DSpark boolean and refuses a mismatched pairing by name for both LFM2 and Muse, and the module doc no longer describes per-family policy living outside the trait. run_dflash_burst's three arms and model_variant_label cover MuseGlimmerVLM. The Muse startup guard admits exactly the assistant drafter (--draft-kind dflash or unset), still rejecting adapters, KV modes, TP/PP and draft flags without a drafter.
  • Docs: docs/supported-models.md (DFlash row for Muse, family paragraph, the "not a speculative target" sentence replaced), docs/speculative-acceptance.md, and the README speculative bullet.

Pre-merge review round (commit 796604f1)

Three findings were fixed in this PR; the rest are listed below as reported and not fixed.

  • HIGH, fixed. The exactness gate probed a width the loop does not run at. The verify width became adaptive in this change, so a Muse run warms up at the drafter's declared depth of four rows and widens to the requested ceiling only when a measurement window pays for it, while the burst gate called exactness_allows(block_size) with the ceiling alone. The forward width selects which quantized-matmul kernel MLX dispatches, so a pass at the ceiling is not evidence about the depth: docs/benchmarks.md records the same comparison reading 20.6 percent disagreement at width 8 and 0.0 percent at width 32. probed_verify_widths now names the widths a run can settle on and dflash_exactness_allows_every_width requires all of them. On this host the probe declines at width 4 as well (162404 of 404096 logit bytes differ, against 187123 at width 16), so the gate had been reporting a verdict for a width the loop would have left after the warm-up. Pinned by probed_widths_cover_the_warm_up_depth_and_the_ceiling.
  • HIGH, fixed. The speculative buffer could be narrower than the block it buffers. speculative_buffer_size was the Gemma 4 rule verbatim, clamp(block * 8, 32, 128), and the buffer is the slack past max_size that lets a verify block be appended across the ring boundary and rewound. --draft-block-size is not bounded above anywhere on the way in, so at 129 rows and above the cap handed the block less slack than it needed: the append overwrites still-visible window rows, RotatingKVCache::trim rewinds the offsets without restoring them, and the sequence continues from a window with holes in it with no error anywhere. The rule now floors at the block size, a no-op at every width a real run reaches. Pinned by speculative_buffer_never_falls_below_the_block_it_buffers. The rollback also names a short rewind through tracing::error! rather than only a debug_assert, and a verify that captured no residual stream for a drafter slot now says so instead of handing over a zero slab that reads as a residual stream.
  • MEDIUM, fixed. Config dimensions were unbounded and the encoder projected rows it was about to discard. MuseAssistantConfig::validate now bounds every dimension it hands MLX as an i32, so a malformed config.json naming one past the wrap point fails as a load message rather than reaching MLX as a negative extent, which crosses the cxx bridge as an abort (config_refuses_dimensions_that_would_wrap_an_i32). And the drafter's forward drops the rows past the sliding window before the encoder runs rather than after: encoder.fc and output_norm_enc are both per-row so the context is unchanged, but at -c 16384 the incoming residual slab is 1.1 GB in f16 and seven eighths of it was being cast and projected only to be sliced away.

Reported and not fixed:

  • MEDIUM. --draft-block-size has no upper bound. resolve_draft_block_size returns an override verbatim ("no further validation here; concrete generators enforce their own minimums"), which is family-wide and predates this change. The buffer floor above removes the silent-corruption consequence for Muse, but an absurd width still allocates an absurd verify block. A shared clamp belongs in resolve_draft_block_size or the server startup validation and would touch every DFlash and MTP family.
  • MEDIUM. The burst's decline line names the requested ceiling (block_size=16) while the probe verdict immediately above it names the width that actually failed (block_size=4). The burst message is shared by all DFlash families and it points the reader at the verdict line, so it is left alone rather than made family-specific.
  • MEDIUM. MuseAssistantContextCache::append rebuilds the whole window every round (concatenate, slice, contiguous, then two ffi::copy), so a steady-state round copies roughly 2 x 2048 x 8 x 128 x 2 bytes per drafter layer. A ring with a rotate-on-read, or RotatingKVCache taught to report absolute key positions, would drop it. It is a fraction of one target verify forward and does not move the measurements below.
  • LOW. MuseAssistantDrafter::bind does not re-run validate_target_compat the way Glm4MoeLiteMtpDrafter::bind does. Every current caller (run_dflash_on_target, the batched arm, speculative_bench, the scheduler hook) validates first, and putting it in bind would repeat a full-vocabulary probe matmul on every burst.
  • LOW. MuseAssistantModel::forward guards its cache-count invariant with debug_assert_eq! and zip, which truncates silently in release. The only constructor of that vector is make_caches.
  • LOW. The mtp_exactness_gate probe key is (block_size, hidden_size, num_hidden_layers), which cannot tell two Muse checkpoints of the same shape apart within one process. Pre-existing and shared with the MTP and DSpark arms.

Real-checkpoint validation (M5 Max, GPU generation 17; models/muse-glimmer-30b-4bit with models/muse-glimmer-30b-assistant-bf16, port 19343)

Re-run at 796604f1 against a mlxcel-server rebuilt after the rebase onto e3ca8afc and after the review fixes; no source file is newer than the binary. Driver: validate_muse.py starts target/release/mlxcel-server -m models/muse-glimmer-30b-4bit --port 19343 -c 16384 --parallel 1 [--model-draft models/muse-glimmer-30b-assistant-bf16], sends POST /v1/chat/completions with temperature: 0, max_tokens: 256, logprobs: true, and compares logprobs.content[].token between arms. Three text prompts: a 66-token tides prompt, a 1968-token log prompt whose 256-token answer crosses the 2048-token sliding window during decode, and a 2660-token log prompt whose caches wrap during the prefill; plus one image_url request.

  1. Shipped default (no env override): the probe declines on this host at the warm-up width (MTP declined: verify block position 0 differs from the single-token chain in 162404 of 404096 logit bytes. Disabling qmv_wide did not make it exact either. block_size=4), the burst logs DFlash speculative dispatch declined for seq ...: the block-versus-chain exactness probe did not pass at block_size=16 once per request and serves classically. All three prompts: IDENTICAL, 256 of 256 tokens against the same server without --model-draft. This is the documented generation-15-and-newer condition, the same as LFM2 DSpark on this host.
  2. MLXCEL_MTP_ALLOW_INEXACT=1: cross-window-decode IDENTICAL (256 tokens, 47 rounds, 209 of 213 proposals accepted, mean accepted 4.45, 60.5 tok/s decode against 22.5 tok/s classic end to end); wrapped-in-prefill IDENTICAL (256 tokens, 47 rounds, 209 of 214 accepted, mean 4.45, 60.6 tok/s against 20.1); tides DIVERGES at token 59 (Earth's rotation against centrifugal force, 109 rounds, 146 of 375 accepted, mean accepted 1.34, 38.4 tok/s decode against 31.6 classic end to end). The divergence is the byte-identity the probe declined, observed after overriding it, which is why the override is not the default.
  3. Image request under --model-draft: one WARN ... DFlash speculative dispatch declined for seq seq-4: multimodal VLM request detected ... line, served classically, HTTP 200.
  4. timings on every speculative response carry draft_kind: dflash, draft_rounds, draft_n, draft_n_accepted; mean accepted length is above 1 on every prompt (1.34 to 4.45) and every speculative arm decodes faster than the classic arm of the same prompt.
  5. Verify-width measurement that set the 4-row start (an earlier sweep on the same pairing, all under the override, three natural prompts: tides, a code request, a history question): fixed 16 rows 13.0 / 18.1 / 12.4 tok/s (1.87 / 3.40 / 1.80 accepted per round) against 18.1 tok/s classic; 8 rows 24.9 / 30.5 / 21.7; 4 rows 33.6 / 41.3 / 31.6; 2 rows 30.0 / 33.1 / 31.7 (first proposal accepted 74 to 82 percent of rounds). The 2660-token log prompt accepts 14 of 15 at 16 rows, which is what the comparator widens for; on the natural prompts its 16-row challenge windows measured 0.012 to 0.024 tokens per ms against 0.030 to 0.037 at 4 rows and were not adopted. Two alternatives were measured and rejected: capturing the residual stream one layer earlier (HF hidden_states[i] indexing) changed nothing (1.83 / 3.47 / 1.50), and feeding the drafter the embed_norm-wrapped embedding halved acceptance (1.28 / 1.35 / 0.90).

Not in this PR

  • Batched (B > 1) Muse windows decline through supports_batched(); multimodal requests decline; draft_window_size is accepted and unused (the issue's out-of-scope list).
  • No initial_block_size on DFlashGenerator::new and no dflash_round_loop_initial_block_size_four test: the drafter-declared depth plus the throughput comparator covers the first-round narrowing the issue asked for, and is what the measurement supports.
  • The registry drafters entry for LFM2 was already absent before this change and is left as is.

Checks

cargo fmt --all -- --check; cargo clippy --lib --tests and cargo clippy -p mlxcel-core --lib --tests with -D warnings, both under --features metal,accelerate. Tests under --profile test-fast --features metal,accelerate: -p mlxcel-core drafter::dflash (86 passed) and the root crate filtered on muse_glimmer (109), models::muse_glimmer (21), server::batch::speculative_burst (67), server::startup (80), models::detection (63), cli::speculative_args (22), server::batch::dflash_target (4). The --workspace gate was not run locally because the machine is shared; the narrow scopes plus CI cover it.

Closes #1343

@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:low Low priority area:models Model architectures, weights, loading, metadata area:docs User and developer documentation area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:inference Generation, sampling, decoding (incl. speculative, DRY) modelsize:medium 4-bit checkpoint 10-50GB; comfortable on a 128GB dev box modeltype:vlm Vision-language model arch:dense Dense transformer decoder status:review Under review labels Sep 10, 2026
… loop

Muse Glimmer ships a DFlash-style block drafter (`meta-models/Muse-Glimmer-30B-assistant`, `model_type: muse_glimmer_assistant`) that mlxcel could not run: the server refused every `--draft-*` flag on a Muse target and the existing DFlash drafter is the Qwen-shaped one. This adds the assistant model, the Muse target adapter and the dispatch wiring so `--model-draft <assistant>` runs the DFlash round loop on text-only Muse Glimmer requests at B = 1.

- `drafter::dflash::muse`: flat config (no `num_target_layers` / `vocab_size`, both checked against the bound target), five sliding-attention layers with per-head q/k norms under a bidirectional sliding mask over absolute positions, a temporal context window instead of a `RotatingKVCache` (a wrapped ring returns physical slot order), and a bind to the target's raw embedding table and untied `lm_head`.
- Muse target: residual-stream capture before the final norm, trim rollback over the mixed caches with the sliding ones armed with a speculative buffer, and the block-versus-chain exactness probe as the gate.
- Round loop: a drafter declaring a configured depth below the requested width runs the MTP throughput comparator between the two. Muse starts at 4 rows under the 16-row ceiling: on an M5 Max a fixed 16 measured 13 to 18 tok/s against 18 classic on natural text, 4 rows 34 to 41 tok/s, and 16 rows 94 tok/s on repetitive text.
- Server: `DFlashTargetModel::supports_batched()` and `required_drafter_family()` replace the hardcoded LFM2 arms; the Muse startup guard admits exactly the assistant drafter.

Closes #1343
@inureyes
inureyes force-pushed the feature/issue-1343-muse-glimmer-assistant-drafter branch from a2388bc to 1d78342 Compare September 10, 2026 14:18
Pre-merge review follow-up on #1343, in three parts.

The exactness gate probed only the requested ceiling. Since this change the DFlash round loop no longer holds one width for a Muse run: it warms up at the drafter's declared depth of four rows and widens to the ceiling only when a measurement window pays for it, so the width the burst actually starts at was never measured. The forward width selects which quantized-matmul kernel MLX dispatches, so a pass at the ceiling is not evidence about the depth; `docs/benchmarks.md` records the same comparison reading 20.6 percent disagreement at width 8 and 0.0 percent at width 32. `probed_verify_widths` names the widths a run can settle on and `dflash_exactness_allows_every_width` requires all of them, which is what the two Muse `DFlashTargetModel::exactness_allows` arms now call. Measured on the M5 Max pairing: the probe declines at width 4 as well (162404 of 404096 logit bytes differ, against 187123 at width 16), so the gate was previously reporting a verdict for a width the loop would have left immediately.

The speculative buffer could be narrower than the block it buffers. `speculative_buffer_size` was the Gemma 4 rule verbatim, `clamp(block * 8, 32, 128)`, and the buffer is the slack past `max_size` that lets a verify block be appended across the ring boundary and then rewound. `--draft-block-size` is not bounded above anywhere on the way in, so at 129 rows and above the cap handed the block less slack than it needed: the append overwrites window rows that are still visible, and `RotatingKVCache::trim` rewinds the offsets without restoring them, leaving the sequence continuing from a window with holes in it and no error anywhere. The rule now floors at the block size, which is a no-op at every width a real run reaches. The rollback also names a short rewind through `tracing::error!` rather than only a `debug_assert`, and a verify that captured no residual stream for a drafter slot says so instead of handing over a zero slab.

Two smaller ones. The drafter's `forward` drops the rows past the sliding window before the encoder runs rather than after: `encoder.fc` and `output_norm_enc` are both per-row, so the context is the same, but at `-c 16384` the incoming residual slab is 1.1 GB in f16 and seven eighths of it was being cast and projected only to be sliced away. And `MuseAssistantConfig::validate` now bounds every dimension it hands MLX as an `i32`, so a malformed `config.json` naming one past the wrap point fails as a load message instead of reaching MLX as a negative extent, which crosses the cxx bridge as an abort.

Docs: the supported-models DFlash row and `speculative-acceptance.md` record the multi-width gate and the buffer floor, and the README speculative bullet now names the Muse Glimmer path beside the MTP and DSpark ones.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 10, 2026
Bilingual report for the Muse Glimmer DFlash assistant drafter: what the published checkpoint carries and what it omits, why the drafter's bidirectional absolute-position mask rules out a rotating cache for its own context, the measurement that made the verify width adaptive, the server contract change that PR #1751 deferred to this family, and the pre-merge review round with the three findings fixed and the six left as reported.
@inureyes
inureyes merged commit fb12866 into main Sep 10, 2026
13 checks passed
@inureyes
inureyes deleted the feature/issue-1343-muse-glimmer-assistant-drafter branch September 10, 2026 14:59
inureyes added a commit that referenced this pull request Sep 10, 2026
Main's DFlash server path moved to the `DFlashTargetModel` trait in `src/server/batch/dflash_target.rs` (#1751, #1762) with a per-family exactness gate and drafter-family pairing check, and the drafter refusal message grew a family list (#1770). This merge drops the branch's own `DFlashBurstTarget` trait in favor of main's design and integrates Laguna into it: `LagunaWrapper` implements `DFlashTargetModel` (every prompt row as the first hidden, `block_size` rows of speculative slack on the rotating caches, no batched burst, a `Laguna` drafter family that the target requires and the Laguna drafter self-reports through `Drafter::is_laguna_dflash`), `LagunaVerifyOutput` implements `DFlashVerifyOutput`, and the burst's variant gate, `drive!` match and batched gate gain Laguna arms.

`LagunaModel::dflash_exactness_allows` runs the same measured block-versus-chain probe LFM2 and Muse Glimmer run, memoized through `mtp_exactness_gate` with the `MLXCEL_MTP_ALLOW_INEXACT` override; both the server burst and the offline `mlxcel generate --draft-kind dflash` arm fail closed on it. The DFlash probe recognizes `DFlashLagunaForCausalLM` next to the DSpark marker, `load_drafter` checks the Muse assistant, then Laguna, then the default, and the standalone-model refusal names the Laguna drafter.

Refs #1351
inureyes added a commit that referenced this pull request Sep 11, 2026
## Summary

Adds the Poolside Laguna DFlash drafter (`poolside/Laguna-*-DFlash`) as `mlxcel_core::drafter::laguna_dflash`, implements `SpeculativeTarget` on the Laguna target, routes `model_type: laguna` drafters through `load_drafter`, integrates Laguna into the server's `DFlashTargetModel` design (#1751, #1762) as its own drafter family, and wires the pairing into both `mlxcel-server` and offline `mlxcel generate --draft-kind dflash` (the offline DFlash arm did not exist for any target before; it is Laguna-only here). Like the LFM2 and Muse Glimmer arms, the pairing runs behind the measured block-versus-chain exactness gate (`LagunaModel::dflash_exactness_allows`, `MLXCEL_MTP_ALLOW_INEXACT` override) on both the server and the offline arm.

## Where the landed code and the issue text disagree

- The forward follows vLLM's `laguna_dflash.py` (vllm-project/vllm#46853), which is what the published checkpoint is served with: every drafter layer passes the projected context through its own `input_layernorm` before the K/V projection (the issue has `ctx_qkv = qkv_proj(ctx_i)`), and the sliding window is a per-query limit over `[context | block]` rather than a fixed 511-row context view. With the checkpoint's norm weights all exactly 1.0 the first difference is numerically a no-op today; the second only matters past 512 context tokens.
- The drafter's initial context is every captured prompt row (vLLM's DFlash proposer; `FirstHiddenRows::EveryPromptRow`), not the last row the Qwen 3.5 port uses.
- `src/server/speculative_dispatch.rs` needed no Laguna entry: dispatch is by drafter kind; the target arm lives in `dflash_target.rs` and `speculative_burst.rs`.
- The sliding target caches are armed with `enable_speculative_buffer(block_size)` so rollback is a tail trim; the drafter's context cache is a temporal buffer capped at `window - 1`, not a ring.

## Measurements (GB10 sm_121, NVFP4 target, post-rebase on main `0c21aed2` with #1795, `MLXCEL_MTP_ALLOW_INEXACT=1`)

**Exactness probe, re-run after #1795:** still declines this host at block 16 (107246 of 200704 logit bytes differ at the first verify position) and at block 8 (107466); `qmv_wide` off does not change it. Neither #1795 fix applies to this pairing: the Laguna loader already kept the drafter in bf16 (it now goes through the shared `apply_drafter_load_dtype_policy`), and Laguna has no gated-delta layers. Without the override the offline arm exits with the verdict and the server burst declines to classic decode, so the feature ships inert here by default.

**Throughput A/B, same binary, feature off (no `--draft-model`) versus on per block size.** Raw code prompts, no chat template, 200 tokens, GPU lock held for the whole sweep (GPU exclusive, 0 percent at start); the host was not otherwise idle: another session's `cargo build` / `rustc` ran at full CPU throughout (load 2.6 to 3.9), which is the same for both arms. Decode tok/s as the CLI reports it; n runs, min to max; per-round device time is the synchronized verify (drafter plus target) and the drafter's host-side graph build is listed separately.

| Configuration | n | tok/s mean (min to max) | Mean accepted | Device ms/round | Drafter build ms/round | vs off |
|---|---|---|---|---|---|---|
| code 0 (`retry_with_backoff`), off | 5 | 31.18 (30.41 to 31.98) | | | | |
| code 0, block 2 | 3 | 16.19 (16.12 to 16.25) | 0.84 | 80 | 30 | 0.52x |
| code 0, block 3 | 3 | 21.61 (21.52 to 21.77) | 1.52 | 83 | 30 | 0.69x |
| code 0, block 4 | 3 | 27.05 (26.61 to 27.53) | 2.21 | 85 | 31 | 0.87x |
| code 0, block 5 | 3 | 28.33 (26.21 to 29.51) | 2.55 | 89 | 34 | 0.91x |
| code 0, block 6 | 3 | 30.46 (30.05 to 31.26) | 2.90 | 92 | 33 | 0.98x |
| code 0, block 7 | 3 | 30.24 (30.12 to 30.47) | 3.06 | 97 | 34 | 0.97x |
| code 0, block 8 | 3 | 31.26 (31.07 to 31.36) | 3.33 | 101 | 34 | 1.00x |
| code 0, block 9 | 3 | 30.58 (30.37 to 30.75) | 3.33 | 106 | 33 | 0.98x |
| code 0, block 10 | 3 | 30.02 (29.68 to 30.19) | 3.42 | 109 | 35 | 0.96x |
| code 0, block 12 | 3 | 28.94 (28.66 to 29.41) | 3.52 | 116 | 37 | 0.93x |
| code 0, block 16 (default) | 3 | 27.41 (26.90 to 27.90) | 3.55 | 127 | 35 | 0.88x |
| code 1 (`lru_get`), off | 3 | 31.80 (31.50 to 32.38) | | | | |
| code 1, block 4 | 3 | 26.10 (25.74 to 26.47) | 2.08 | 83 | 31 | 0.82x |
| code 1, block 6 | 3 | 30.42 (30.11 to 30.64) | 2.92 | 91 | 34 | 0.96x |
| code 1, block 8 | 3 | 29.95 (29.60 to 30.56) | 3.17 | 102 | 34 | 0.94x |
| code 1, block 10 | 3 | 30.16 (29.88 to 30.58) | 3.44 | 109 | 35 | 0.95x |

**No configuration is a net win on this host, before or after #1795.** The best width is block 8 at 1.00x on code 0 (31.26 versus 31.18, its whole range inside the off arm's 30.41 to 31.98) and 0.94x on code 1; the default block 16 is 0.88x. The pre-rebase sweep on an idle host gave the same picture (block 8 at 0.97x, block 16 at 0.86x). There is no sub-8 cliff on this NVFP4 path (5, 6, 7 sit on a smooth curve) and no crossover past 8. The default stays at 16 (`DEFAULT_DFLASH_BLOCK_SIZE`); if a served width is ever retuned for Laguna, block 8 is the least-bad on GB10 and that change belongs to #1797, not this PR.

**Why the ceiling is where it is.** The device time of a verify round is a fixed 77 ms plus about 3.1 ms per row (80 ms at 2 rows, 127 ms at 16) against a 31 to 32 ms classic token, 2.5x a single-token step even at 2 rows, plus about 30 to 35 ms of host-side drafter graph construction per round, one classic token's worth. The classic step is graph-replayed while the multi-row verify runs eagerly and launch-bound; that is a runtime property of the CUDA backend on this host, unchanged by #1795 for this pairing, and out of scope for a drafter port. Even perfect acceptance at block 8 would reach about 17 ms per token; at the measured 3.3 to 3.6 accepted, 0.88x to 1.00x. Thinking-channel prompts (1.1 to 1.8 accepted) are about 0.4x.

Every greedy-id divergence from classic decode was checked with the block-versus-chain probe in `laguna_real_checkpoint_probe`: the chain arm's top-2 margin is 0.0 or 0.125 (one bf16 ulp) at each, so it is the `M >= 2` versus `M = 1` quantized-kernel property the exactness gate measures, not cache or mask logic; the tiny f32 model is token-exact with forced accept lengths 0, 1, 2 and full across the window wrap. The drafter's per-position accuracy along the reference path is 0.88, 1.00, 0.75, 0.62 for d_0 to d_3 on code and 0.88, 0.50, 0.25 inside `<think>`. Server path: same counters as offline (`rounds=60 proposed=835 accepted=67` on the issue's chat prompt).

## Acceptance criteria

- Drafter directory loads through `load_drafter` and is refused as a standalone model: verified (detection test plus real runs).
- Greedy speculative output token-identical to non-speculative: **not met on this host**, unchanged after #1795: the probe declines by default (107246 of 200704 bytes differ at block 16, 107466 at block 8); with the override, identical on 1 of 6 prompts and differing at one or two bf16 tie positions per 128 tokens on the other 5. Documented in `docs/supported-models.md` and the report.
- Rollback trims full and sliding target caches and the drafter context caches: verified by `greedy_invariant_with_forced_rejections` (oracle drafter forcing accept lengths 0, 1, 2, full over a window of 6) and `greedy_invariant_with_real_drafter`.
- Mean accepted length above 3 on the code prompt: 1.12 on the issue's chat prompt (the model answers inside `<think>`); 3.55 at block 16 and 3.33 at block 8 on the raw code body (n=3, post-rebase).
- Higher decode throughput: **not met on this host** at any block size 2 to 16, before or after #1795 (best 1.00x at block 8, n=3, range inside the off arm's; 0.88x at the default block 16), for the launch-bound reason above; the feature is gated off here by the exactness probe regardless.
- `docs/supported-models.md` and the detection test: done.
- `cargo test --workspace --profile test-fast --features metal,accelerate`: not runnable on this Linux/CUDA host; unrun. Ran instead on the merged tree: core `drafter::laguna_dflash`, `drafter::dflash`, `drafter::tests` (123 passed); binary `models::laguna_dflash_tests`, `models::detection_tests`, `models::laguna_tests`, `server::batch::dflash_target`, `server::speculative_dispatch_tests` (27 passed, 1 ignored real-checkpoint probe).
- `cargo clippy --profile test-fast --features cuda --lib --bins --tests -- -D warnings` (both crates) and `cargo fmt --all -- --check`: clean.

## Changes during review

- Merged `origin/main` (nine PRs, including the `DFlashTargetModel` server design from #1751 and #1762 and the drafter-refusal wording from #1770); the branch's own burst trait was dropped in favor of main's and Laguna became a fourth drafter family with the shared measured exactness gate. Merged again at `0c21aed2` to pick up #1795; the Laguna loader now uses the shared `apply_drafter_load_dtype_policy`.
- `from_weights` validates the `qkv_proj`, `o_proj`, `g_proj` and `fc` row counts against the config so a mismatched checkpoint is a load error rather than an MLX reshape throw inside the first draft, and the per-round `copy` of the captured hidden input was removed.
- Implementation review: the offline arm now refuses a non-Laguna DFlash drafter by name (the pairing gate the server already ran) and refuses sampling requests (the round loop is greedy-only); the drafter prefers its one trained block width so a wider `--draft-block-size` cannot engage the adaptive width controller at an unprobed width; context rows older than the window are dropped before `aux_hidden_norms` and `fc` instead of after; `concatenate_many` replaces the pairwise fold; the rotating caches take the Muse Glimmer slack rule and warn instead of panicking; a short rollback trim logs an error in release builds; `load_drafter` routes on the same Laguna predicate detection uses; `attention_bias: true` is rejected and `intermediate_size` is checked at load; `MLXCEL_PRINT_TOKEN_IDS` keeps its single stderr contract with a prompt-ids line added; two tests pin context-offset continuity and the fused q/k/v row order.
- Security and performance review: `greedy_only` is true so the server burst declines sampling requests to classic decode instead of silently returning greedy output; `dflash_config.block_size` (2..=512) and the sliding windows (2..=2^20) are bounded at config load because the drafter's `config.json` is untrusted and sizes allocations and `i32` mask arithmetic; the sanitizer checks split q/k/v shapes before fusing and refuses prefixed-plus-unprefixed duplicates; projection input widths and norm lengths are checked at load; the context K/V comes from a row-sliced K/V-only projection on plain checkpoints, the context cache exposes its buffer instead of returning a copy, and `combine_hidden` builds one `concatenate_many` node; the offline arm refuses a token bias. The regression check after each fix commit reproduced the same 128 ids and `accepted=98` on the code prompt.

Closes #1351
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch:dense Dense transformer decoder area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:docs User and developer documentation area:inference Generation, sampling, decoding (incl. speculative, DRY) area:models Model architectures, weights, loading, metadata modelsize:medium 4-bit checkpoint 10-50GB; comfortable on a 128GB dev box modeltype:vlm Vision-language model priority:low Low priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

1 participant