DFlash speculative decoding for Qwen3.6/3.5 (Rust + MLX) - #204
DFlash speculative decoding for Qwen3.6/3.5 (Rust + MLX)#204dusterbloom wants to merge 15 commits into
Conversation
… (P1-P2) P1: dflash.rs config-driven drafter (loads Modal bf16 weights, verified). P2: GDN tape surface (GdnLayerTape, forward_with_tape/_stateless, replay_from_tape, tape FFI kernels) + CLM tap methods (forward_with_taps/_tape, replay_tape_rollback, embed_token_ids, forward_all_logits_from_hidden, project_logits) reconciled onto origin/main; bit-exact replay (max_diff 5.96e-8), rollback ~1.25ms/GDN-layer. Engine loop (P3/P4) pending; tape/tap methods are dead-code until wired. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tch (P3)
P2 splice had landed the CLM tap methods inside a #[cfg(test)] mod (excluded from
the non-test build, so dead). Move them into impl Qwen3NextCausalLM. Add the P3
AnyModel dispatch (forward_with_taps/_tape, embed_token_ids, forward_all_logits_from_hidden)
and AnyCache::{as_hybrid,as_hybrid_mut}, reconciled to main (_=> wildcard arms cover the
Gemma3/Gemma4/BonsaiQ1 variants the branch predates). higgs-models compiles (lib + tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…py-adaptive block Engine loop (P4): - SimpleEngine::generate_dflash_inner — block-diffusion draft → tape-recording verify → accept_prefix → GDN tape-replay rollback, dispatched from generate_inner when a drafter is loaded and no constraints/pixels. - load_with_dflash(Option<&Path>) resolves the drafter from the arg or HIGGS_DFLASH_PATH; load() delegates with None. - AnyModel::replay_tape_rollback dispatch + model_loader::load_dflash_drafter adapter; explicit non-Qwen3Next match arms (clippy wildcard_enum) and doc backticks; deleted the dead per-layer replay_from_tape. Verify bit-exactness (the real bug behind DFlash diverging from AR greedy): - The tape (verify) GDN kernel ran the SSM recurrence in bf16 (static_cast<InT> between timesteps + bf16 state_out) while AR decode keeps f32 → argmax flips on close calls across 30 GDN layers. Tape kernel now keeps f32 state (reinterpret + no inter-step cast + f32 state_out), matching the plain kernel. - The replay kernel fused state*g + k*delta (one FMA) vs the forward's two-step update; split to match exactly so rollback is bit-exact (replay-vs-forward 5.96e-8 → 0.00). New regression test test_gdn_tape_forward_matches_plain_forward_bf16. Entropy-adaptive block size: - Block size adapts by EMA-smoothed utilization (accepted/block = entropy proxy): hold block_max on sustained high utilization (low-entropy → biggest win, byte-exact), shrink toward block_min on sustained low utilization (high-entropy), big blocks on uncertain transitions keep verify off near-ties. Env: HIGGS_DFLASH_ADAPTIVE, HIGGS_DFLASH_MIN_BLOCK, HIGGS_DFLASH_BLOCK_SIZE. Results (release, Qwen3.6-35B-A3B-4bit + modal-labs drafter, DFlash/MTP decode): - code (low-entropy, thinking off): 1.32x MTP, byte-exact, accept_len 13.8 - prose (thinking on): 0.80x MTP, byte-exact (vs fixed-16 0.53x) Ignored integration test dflash_matches_ar_greedy_and_reports_accept_len gates byte-identical output + reports accept_len/tok-s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Floors DFlash to plain AR when speculation is actually slower than AR for the current text — no per-model knob, no entropy threshold. Measures T_ar (wall per AR token) live, and each spec round computes the realized speedup ratio = n_accepted * T_ar / step_wall; an EMA of that ratio < 1.0 floors to AR (re-probing spec every 32 AR steps with hysteresis). MoE's fast AR raises the break-even bar differently per model, which a fixed entropy/p1 threshold can't see — the realized wall-clock ratio can. Flooring also keeps the S>1 verify off high-entropy near-ties, so AR regions stay byte-exact. Characterization motivating it (Mac/MLX; accept curve matches RTX3090/CUDA): 35B-A3B (vs MTP): code accept 13.8 → 1.09x ✓ ; math 9.6 → 0.66x ; prose 1.9 → 0.73x 9B dense (vs AR): math accept 12.5 → 2.51x ✓ ; code 4.0 → 1.12x ; prose 1.9 → 0.70x Accept does not predict the winner (9B drafts trivial math well but code poorly, reverse of 35B); and 9B prose ran DFlash *slower than plain AR* (0.70x) — exactly the regression the gate catches: gate lifts 9B prose 0.70x → 0.89x toward AR parity. Also makes the test prompt configurable via HIGGS_TEST_PROMPT. Gate on by default; disable with HIGGS_DFLASH_GATE=0. 287 engine tests pass; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an optional per-model `draft_model` path to ModelConfig, threaded through load_engines → Engine::load_simple → SimpleEngine::load_with_dflash, so DFlash speculative decoding can be enabled from the config file (overrides the HIGGS_DFLASH_PATH env fallback). Doctor validates the path exists and rejects draft_model + batch=true (DFlash is simple-engine only). All ModelConfig literal sites updated. Release build + 474/99 higgs tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tually floors The realized-speedup gate seeded t_ar from the cold first-decode-after-prefill (~187ms vs ~25ms warm on Metal), so its ratio was permanently >1 and it never floored -- a dead no-op. On prose it speculated nearly every token (104 spec rounds / 200 tok, accept_len 1.9) at 0.45-0.76x AR. Seed t_ar from warm steady-state decodes (discard the kernel-cold first sample): t_ar 187->25ms, the gate now floors (spec_rounds 104->3) and prose parity climbs to ~0.98x. Also in this change: - tap-less floor decode: floored AR steps call `forward` instead of `forward_with_taps` except on the spec-handoff step. Byte-exact (both share `forward_raw_hidden`: identical layer loop, mask, KV/GDN cache mutation, and `project_logits`) and proven free by the new micro-bench -- the per-step taps are lazy clones dropped unevaluated (-0.5%, within noise). The original task premise (taps cause the prose gap) was refuted by measurement. - exponential backoff on the AR re-probe cadence (32->512): a sustained losing region amortizes the probe toward 0%; a winning probe resets to base. Now engages (probe_every 32->128 on prose). - log spec_rounds/probe_every on the length-limit completion + a gated GATE trace (HIGGS_DFLASH_TRACE) for t_ar/step_wall/ratio. - new #[ignore] micro-bench dflash_floor_tapless_vs_taps_per_step_cost. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an #[ignore] diagnostic that feeds the AR-correct tokens through the real verify path (forward_with_taps_tape) and compares per-position argmax against sequential S=1 AR greedy, reporting the top-2 logit gap at each flip. Finding on Qwen3.6-35B-A3B-4bit + Modal drafter (prose prompt): 3/48 positions flip, first at pos 28, with top-2 gaps of exactly 0.125/0.125/0.625 — multiples of 1/8, i.e. bf16 logit granularity. The divergence is inherent S>1 batched verify drift (batched full-attention + bf16 lm_head) vs S=1 sequential AR, flipping argmax only where the model is undecided to within a few bf16 ULPs. accept_prefix and the commit are correct; the output is self-consistent greedy w.r.t. the verify forward. Not a logic bug — both tokens are ~equiprobable at those ties. The GDN tape already makes the SSM layers bit-exact; FA + bf16 output precision are the residual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The realized-speedup gate judged the cold spec warm-up: the verify/drafter/ lm_head Metal kernels are kernel-cold (step_wall ~2x inflated, 236->121ms) and the drafter KV cache starts empty (accept climbs ~3 -> ~6 as it fills), while t_ar is already warm from the calibration fix. So ratio = n_acc*t_ar/step_wall read artificially low, ratio_ema decayed below 1.0 within ~3 rounds, and the gate floored a workload that wins 1.53x warm -- then death-spiralled (floored -> spec re-cools -> re-probe also looks bad -> stays floored). Fix, symmetric to the existing t_ar cold-sample discard: grant each spec (re-)entry a SPEC_WARMUP=3 grace where the gate stays in spec, does not update ratio_ema, and does not floor; and re-seed ratio_ema=2.0 on entry so a re-probe can't inherit a stale sub-1.0 EMA and re-floor in one round. Validated on Qwen3.5-9B + Modal drafter (code prompt): gate-ON spec_rounds 4->28, accept_len 2.25->4.75, ratio_ema holds 1.4-1.8 (no floor) -- recovering the ~1.5x that pure spec (HIGGS_DFLASH_GATE=0) delivers. 35B prose (accept ~2.0) still floors correctly when t_ar is fresh; confirmed genuine loser at 0.79x gate-off, so flooring it is the right call. Known limitation (separate, pre-existing): t_ar is calibrated once and not refreshed during a long spec streak, so under large thermal drift it can go stale (measured 25ms cool vs 101ms throttled) and skew the floor decision both ways. Periodic t_ar re-calibration is the follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…drift t_ar (warm AR per-token time, the gate's denominator reference) was measured only during AR steps, so a long spec-winning streak never refreshed it. But AR speed drifts with thermal — measured 25ms cool vs 101ms throttled on the same model — so a stale t_ar made `ratio = n_acc*t_ar/step_wall` skew and the gate could mis-decide whether spec still beats AR for the current thermal state. Every T_AR_RECAL_EVERY (48) judged winning rounds, force ONE AR step to refresh t_ar at the current thermal state. It reuses the AR-floor machinery via a `recal` window of 1; unlike a probe cooldown it does NOT re-arm the spec warm-up grace or reset ratio_ema (a single AR step barely cools the drafter, and we want to keep the winning EMA). need_taps is true on that step so current_taps stays fresh for the next draft. ~1 AR token per ~240 generated (<0.5% overhead). Validated on Qwen3.5-9B (600-token code prompt): recal fires at round ~48 (t_ar refreshed to 54.7ms), spec_rounds 84 / accept_len 4.81 — the winning streak holds with no spurious floor or death spiral. Byte-exact greedy unchanged. Also: HIGGS_TEST_MAX_TOKENS makes the dflash test's generation length configurable (needed to exercise streaks past the recal threshold). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Modal/Z-Lab drafters are trained at 40k seq len with sliding-window attention (config: layer_types = [sliding x5, full x1], sliding_window 4096), but higgs ignored layer_types/sliding_window — all layers ran full non-causal attention with UNBOUNDED context KV. <=4096 that's a faithful no-op; >4096 the sliding layers over-attended (off-distribution) and per-round cost grew O(ctx). Port it faithfully (reference: mlx_vlm .../qwen3_dflash/dflash.py): - DFlashConfig: parse layer_types + sliding_window (serde-default None → current all-full/no-window behavior preserved when absent). - Per-layer is_sliding from layer_idx, threaded through Decoder/Attention/new. - DFlashAttention::forward evicts the oldest context to the last (sliding_window - 1) entries on sliding layers, after the cache concat and before both the cache store and the attention — so the resident prefix is bounded and on-distribution. - RoPE-under-eviction: derive cache_offset as the MAX cache length across layers (the non-evicting full-attention layer carries the true absolute position); the new context K is roped at that absolute offset and cached entries keep their baked positions, so eviction only drops the oldest. No loop threading needed, and it equals the old first-layer offset exactly when nothing evicts. Validated: - New weight-free unit test sliding_layer_cache_caps_while_full_layer_grows: sliding cache caps at window-1, full-attn cache accumulates all context. - Short context regression (9B, gate off): accept_len 6.53 / 1.50x — byte identical to pre-change (eviction is a no-op <4096, max==first offset). - Long context (9B, ~6.5k-token prompt forcing eviction on round 1, gate off): no crash/OOM with the bounded cache, accept_len 16.0 (full block) — the windowed drafter stays on-distribution at depth. Implemented by codex against a fixed spec; reviewed, built, and validated here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two #[ignore] diagnostics served their purpose and their conclusions are captured in commit messages + code comments, so they're now just scaffolding: - dflash_floor_tapless_vs_taps_per_step_cost: proved per-step taps are free (~0%, lazy clones) — settled, recorded in the probe-backoff comment. - dflash_verify_vs_ar_argmax_divergence: localized the bf16 near-tie flips — recorded in the prior diagnostic commit. Also drop the two confirmation-only HIGGS_DFLASH_TRACE lines (warm-up discarded, t_ar recal) now that both mechanisms are validated; the main GATE trace (ratio/ratio_ema/floor + t_ar) stays — that's the decision-relevant one for future gate tuning. Kept: sliding_layer_cache_caps_while_full_layer_grows (eviction guard) and dflash_matches_ar_greedy (byte-exactness guard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…easured entropy) Adds the characterization harness from the spec (built by codex against a fixed spec, reviewed/verified here): - SimpleEngine::last_dflash_accepts (Mutex<Vec<u32>>): per-round accepted counts, cleared at generate_dflash_inner start, pushed after accept_prefix. Non-invasive (no GenerationOutput change). pub accessor for tests. - #[ignore] dflash_entropy_sweep: 12-prompt taxonomy spanning the entropy spectrum; per prompt an AR pass measuring top-50 Shannon entropy (bits) + top-1 prob, then a gate-off block-16 DFlash pass reporting accept_len mean + p10/p50/p90 + byte-exactness; markdown table sorted by entropy; plus context-length (512/4k/16k) and block-size (4/8/16) mini-sweeps. accept_len is thermal-robust, so the accept-vs-entropy characterization is reliable on a throttling laptop; tok/s is left to paired spot-checks. Harness only — the real-weight sweep run is a separate (multi-hour) job. Spec: .planning/measurements/dflash-entropy-sweep-plan.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`trim_drafter_cache` and `rollback_kv_only` (both pub fn in dflash.rs) had zero callers anywhere in the repo — superseded by `crop_drafter_cache` (the one the draft-verify loop uses) and the tape-replay rollback path. Remove them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "DFlash Speculative Decoding" section: accept_len-vs-entropy tables for Qwen3.6-35B-A3B-4bit (MoE) and Qwen3.5-9B-MLX-4bit (dense) with their modal-labs drafters, the three acceptance regimes, the context-depth and block-size mini-sweeps, and the analytic speedup. Leads with thermal-independent metrics (accept_len / entropy / byte_exact); notes tok/s is thermally confounded. Reproduce via the `dflash_entropy_sweep` #[ignore] harness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds end-to-end DFlash block-diffusion speculative decoding. A new ChangesDFlash Speculative Decoding
Sequence Diagram(s)sequenceDiagram
participant Config as ModelConfig
participant Main as main.rs load_engines
participant State as Engine::load_simple
participant Simple as SimpleEngine::load_with_dflash
participant Loader as model_loader::load_dflash_drafter
participant Models as higgs_models::dflash
Main->>State: load_simple(dir, ..., draft_model: Option<&Path>)
State->>Simple: load_with_dflash(dir, ..., dflash_path)
Simple->>Loader: load_dflash_drafter(path)
Loader->>Models: load_dflash_drafter(model_path)
Models-->>Loader: DFlashDrafter
Loader-->>Simple: DFlashDrafter
Simple->>Simple: extract tap_layers, block_size, mask_token_id
Simple-->>State: SimpleEngine { dflash: Some(DFlashState) }
State-->>Main: Engine::Simple(engine)
sequenceDiagram
participant Client as generate_inner
participant DFlash as generate_dflash_inner
participant Target as AnyModel forward_with_taps_tape
participant Drafter as DFlashDrafter::forward
participant Accept as accept_prefix
Client->>DFlash: dispatch (dflash loaded, greedy, unconstrained)
DFlash->>Target: forward_with_taps (prefill)
Target-->>DFlash: logits + tap hidden states
DFlash->>DFlash: sample first token
loop Speculative round
DFlash->>Drafter: forward(noise block, taps, drafter_cache)
Drafter-->>DFlash: draft proposals
DFlash->>Target: forward_with_taps_tape (verify)
Target-->>DFlash: verify logits + GDN tape
DFlash->>Accept: accept_prefix(draft, verify_argmax)
Accept-->>DFlash: accepted prefix + bonus token
alt Partial accept
DFlash->>Target: replay_tape_rollback(tape, n_accepted)
DFlash->>Drafter: crop_drafter_cache(keep_len)
end
DFlash->>DFlash: adapt block_size, gate on speedup
end
DFlash-->>Client: GenerationOutput
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/higgs-engine/src/simple.rs`:
- Around line 1452-1458: The generate_dflash_inner method is not receiving or
handling the enable_thinking parameter that is used in the normal generation
path to enforce thinking-budget behavior. Add the enable_thinking parameter to
the generate_dflash_inner function signature and implement the same
thinking-budget logic that enforces the 256-token limit before forcing a closing
</think> tag, mirroring the behavior in the normal generation loop. This ensures
that thinking requests on the DFlash path respect the same token budgets as the
standard path instead of continuing to reason until max_tokens is reached.
- Around line 1789-1797: Before appending tokens from the `accepted` vector in
the loop, cap the number of tokens to commit based on the remaining budget
(max_tokens minus current token count). Additionally, scan the `accepted` block
for the first EOS token and truncate the accepted list at that point before
pushing tokens to the `tokens` vector. After truncating `accepted`, recompute
`n_accepted` as the length of the truncated `accepted` list rather than the
original block size, ensuring that rollback and tap tracking remain aligned with
the actual tokens that were emitted.
- Around line 3595-3605: The SimpleEngine::load_with_dflash method reads the
HIGGS_DFLASH_PATH environment variable as a fallback even when drafter_path is
None, which means the run(false) test case could accidentally load DFlash if
that env var is set, resulting in false test passes. Either save and clear the
HIGGS_DFLASH_PATH environment variable before calling load_with_dflash in the
run(false) case and restore it afterward, or modify the loader to accept an
explicit parameter that disables the env var fallback completely. This ensures
the "AR" side of the test is truly tested without DFlash interference.
- Around line 1112-1117: The DFlash dispatch condition at the if statement
checking self.dflash.is_some() is too permissive and allows requests that DFlash
cannot properly handle. Add additional guards to the condition to ensure DFlash
is only used for plain greedy decoding without logprobs: check that logprobs are
not requested and that the SamplingParams in the params argument has only
default values (no temperature, no top-k, top-p, or penalty settings applied).
Only proceed to generate_dflash_inner when these additional conditions are
satisfied, otherwise fall back to the normal generation path.
In `@crates/higgs-models/src/dflash.rs`:
- Around line 1-11: The module documentation comment at the top of the file
incorrectly states "8 decoder layers" and "tapped from 5 target model layers",
but the actual configuration verified in the
loads_modal_drafter_against_real_weights function shows num_hidden_layers equals
6 and num_taps equals 8 with tap IDs [1,6,11,16,22,27,32,37]. Update the module
doc comment to accurately reflect these actual values (6 decoder layers and 8
taps from specific layers) and verify the reference documentation points to the
correct source that matches the test configuration being used.
In `@crates/higgs/src/doctor.rs`:
- Around line 287-304: The current validation of model.draft_model only checks
if the path exists using Path::exists(), which is insufficient because it allows
regular files or incomplete directories to pass. Enhance this validation to
ensure draft_model is a valid drafter directory by checking that it is a
directory (not a file) and contains the required drafter model files such as
config.json and weights. Replace the simple Path::exists() check with more
robust validation logic that verifies the directory structure matches what
DFlash expects when loading the drafter, catching misconfiguration before server
startup.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 329806a9-9f09-4a62-bd9f-fcd60fac01ff
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (12)
crates/higgs-engine/Cargo.tomlcrates/higgs-engine/src/model_loader.rscrates/higgs-engine/src/simple.rscrates/higgs-models/src/dflash.rscrates/higgs-models/src/lib.rscrates/higgs-models/src/qwen3_next.rscrates/higgs/src/config.rscrates/higgs/src/doctor.rscrates/higgs/src/main.rscrates/higgs/src/state.rscrates/higgs/src/tui/mod.rsdocs/benchmarking.md
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/higgs/src/doctor.rs (1)
338-357: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winValidate the
HIGGS_DFLASH_PATHfallback too.
SimpleEngine::load_with_dflashenables DFlash fromHIGGS_DFLASH_PATHwhendraft_modelis unset, but doctor only validates the explicit config field. A bad env drafter can still pass doctor and fail at startup for simple-engine models.🔧 Proposed fix
if let Some(ref drafter) = model.draft_model { if let Err(err) = validate_dflash_drafter_dir(std::path::Path::new(drafter)) { fail( &format!( "model {label} draft_model is not a valid DFlash drafter directory ({drafter}): {err}" ), result, ); continue; } if model.batch { fail( &format!( "model {label} sets draft_model but DFlash is simple-engine only (batch=true)" ), result, ); continue; } + } else if !model.batch { + if let Some(drafter) = std::env::var_os("HIGGS_DFLASH_PATH") + .filter(|value| !value.as_os_str().is_empty()) + { + let drafter_path = std::path::Path::new(&drafter); + if let Err(err) = validate_dflash_drafter_dir(drafter_path) { + fail( + &format!( + "model {label} HIGGS_DFLASH_PATH is not a valid DFlash drafter directory ({}): {err}", + drafter_path.display() + ), + result, + ); + continue; + } + } }As per coding guidelines,
crates/higgs/src/**/*.rs: “When adding or changing config fields, updatecrates/higgs/src/doctor.rsto validate the new field. The doctor should catch misconfiguration before the server starts.”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/higgs/src/doctor.rs` around lines 338 - 357, The doctor validation in the draft_model check block only validates the explicit `draft_model` configuration field, but SimpleEngine also uses the `HIGGS_DFLASH_PATH` environment variable as a fallback when draft_model is unset. Add validation for this environment variable by checking if it exists when draft_model is None, then validate the path using the same validate_dflash_drafter_dir function. Also ensure that if the HIGGS_DFLASH_PATH env var is set and batch=true for a model, it fails with an appropriate error message similar to the existing batch mode check for draft_model.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/higgs/src/doctor.rs`:
- Around line 338-357: The doctor validation in the draft_model check block only
validates the explicit `draft_model` configuration field, but SimpleEngine also
uses the `HIGGS_DFLASH_PATH` environment variable as a fallback when draft_model
is unset. Add validation for this environment variable by checking if it exists
when draft_model is None, then validate the path using the same
validate_dflash_drafter_dir function. Also ensure that if the HIGGS_DFLASH_PATH
env var is set and batch=true for a model, it fails with an appropriate error
message similar to the existing batch mode check for draft_model.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 24495f19-79f2-4a3f-9ddf-c92336d21d98
📒 Files selected for processing (3)
crates/higgs-engine/src/simple.rscrates/higgs-models/src/dflash.rscrates/higgs/src/doctor.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- crates/higgs-engine/src/simple.rs
- crates/higgs-models/src/dflash.rs
|
Split stack created to reduce review load:
The final split branch |
|
Closing as superseded by the split PR stack, starting with #205. |
What
First Rust + MLX implementation of DFlash speculative decoding (tap-fed drafter → batched target verify) for Qwen3.6-35B-A3B (MoE) and Qwen3.5-9B (dense). On its strong workloads it matches the acceptance of the community Python-MLX DFlash ports.
Pipeline: hidden states tapped from a handful of target layers feed a small DFlash drafter that proposes a block of tokens; the target verifies the block in one batched pass; the accepted prefix is committed and rejected positions are rolled back. Qwen3Next GDN (linear-attention) state is made bit-exact across the multi-token verify via a tape-record/replay kernel.
Key pieces:
crates/higgs-models/src/dflash.rs— drafter module (loadsmodal-labsDFlash weights),accept_prefix, drafter-cache crop.crates/higgs-models/src/qwen3_next.rs—forward_with_taps/forward_with_taps_tape/replay_tape_rollback+ GDN tape kernels.crates/higgs-engine/src/simple.rs— draft-verify loop, entropy-adaptive block size, and an auto-calibrated realized-speedup gate that floors DFlash→plain-AR when speculation is actually slower than AR for the current text/thermal state (keeping AR regions byte-exact), plus a drafter sliding-window port for long context.crates/higgs/src/config.rs+doctor.rs—draft_modelconfig field (simple engine only; doctor validates).Results
See
docs/benchmarking.md→ "DFlash Speculative Decoding". Acceptance is governed by the target's output entropy, not the task label. Three regimes, consistent across both models:accept_len14–15.4, byte-exact.accept_len/ entropy /byte_exactare thermal-independent and carry the characterization; absolute tok/s is thermally confounded on a laptop and is not quoted as a headline.Reproduce (Apple Silicon + MLX required)
Models (public on HF): target
mlx-community/Qwen3.6-35B-A3B-4bit(ormlx-community/Qwen3.5-9B-MLX-4bit) + draftermodal-labs/Qwen3.6-35B-A3B-DFlash(ormodal-labs/Qwen3.5-9B-DFlash).Byte-exactness vs plain AR greedy:
dflash_matches_ar_greedy_and_reports_accept_len(same env vars).Notes for review
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Configuration
draft_modelto enable DFlash; also supports discovery viaHIGGS_DFLASH_PATHand block sizing viaHIGGS_DFLASH_BLOCK_SIZE.Validation
Documentation
Tests