Skip to content

DFlash framework evolution: draft model, verify path, batched engines, multi-target support - #97

Open
anupsv wants to merge 75 commits into
dflash-mlx-swiftfrom
dflash-framework-updates
Open

anupsv wants to merge 75 commits into
dflash-mlx-swiftfrom
dflash-framework-updates

Conversation

@anupsv

@anupsv anupsv commented Jul 26, 2026

Copy link
Copy Markdown

Summary

Publishes the local dflash-mlx-swift development line (74 commits) that evolved well past the squashed snapshot currently at the tip of this branch. It contains the full MLXSpeculative DFlash framework as developed and tuned locally: the draft model with QKV/MLP fusion paths, greedy verification scheduling, verify-QMM fast path and diagnostics, batched engine + token generator, cache rollback, mlx-bench DFlash commands, and target support for Qwen3/3.5, GPT-OSS, and Gemma 4 (plus the earlier Gemma 4 MTP drafter work).

Relationship to the branch tip

The current dflash-mlx-swift tip (6824c86 "Add DFlash support for MLX Swift") is a squash cut from an ancestor of this line (content matches local d775c7b to within ~15 lines) and relocates some DFlash files under Libraries/MLXLLM/DFlash/. This PR carries the canonical, newer history (~6,400 additional lines in Libraries/MLXSpeculative/ alone). Merging will need a reconciliation pass for the relocated duplicate copy and the MTP/DeepSeek commits (#12#15) unique to the remote branch — flagged deliberately rather than resolved silently here.

Stacked PR

The Laguna XS 2.1 DFlash work is stacked on top of this branch as a separate PR for reviewability.

🤖 Generated with Claude Code

anupsv and others added 30 commits May 13, 2026 22:44
Spec for porting Google's Gemma 4 Multi-Token Prediction "assistant"
drafters to mlx-swift-lm: new MLXSpeculative library, targeted edits to
Gemma4Text.swift, one new BatchKVCache primitive, and a
greedy-identical parity test gated by a 0.9× Python-reference
throughput floor on M-series hardware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
27-task plan executing the spec. Tasks 1-11 fully detailed with
failing test, full code listing, and commit message per step.
Tasks 12-27 outlined with files, goals, and test shape — to be
expanded incrementally during execution to keep the plan file
navigable and allow per-task clarifications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New opt-in library that will house the Gemma 4 MTP drafter. Registered
as a product, wired into MLXLMTests and BenchmarkHelpers as a dep,
with a placeholder source file until real code lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drafter in MLXSpeculative needs to construct per-row offsets and pass
them into Gemma4DecoderLayer. Enum moved under a new public Gemma4
enum-namespace; helper functions kept internal with inline annotations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-row tail-zero primitive used by the Gemma 4 MTP round-loop to
rewind rows that accepted fewer tokens than the round's max. GPU-only
broadcast; empty-cache / out-of-range keep values are safe no-ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old predicate (firstKvSharedLayerIdx > 0) incorrectly excluded
fully-shared configs like the Gemma 4 assistant drafter, where
numKvSharedLayers == numHiddenLayers. New predicate is exposed as a
static helper for test coverage, and the init gains a forceSharedKV
override for defense-in-depth when a drafter config is ever passed
with a surprising layer count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure refactor: callAsFunction now delegates head-application + softcap to
a private helper. Same behavior, but forwardForMTP (next task) can reuse
the exact same head path without duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gemma4SharedKV is the immutable snapshot consumed by the MTP drafter;
Gemma4SharedKVCapture is the mutable reference-type sink used by the
forthcoming capture hook inside Gemma4TextModelInner. sliceTail clamps
to 1 slot when rejected >= T to keep the drafter's attention valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a Gemma4SharedKVCapture is passed in, the inner trunk snapshots
the K/V tensors of the last non-shared full-attention and last
non-shared sliding-attention layers into it. Capture indices are
precomputed at init. Default capture == nil keeps every existing call
site unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Returns (logits, lastHidden, capturedSharedKV) in one pass. Logits go
through the same applyLMHead helper as the regular forward so softcap
behavior is unchanged; lastHidden is pre-head trunk output; capturedSharedKV
is populated via the Task 7 capture hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Uniform-trims caches by (blockSize - max_accepted - 1); for the per-row
case also calls BatchKVCache.zeroTailPerRow so rows that accepted fewer
tokens than the round's max have their tail divergence cleared.
Closes the cache-rewind half of the MTP target-side API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greedy accept-prefix walker for single-row speculative decoding. Pure
Swift; emitted.count == accepted + 1 invariant asserted via tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-row version with per-row emit budgets. Tested for equivalence with
per-row calls to single, and for correct budget truncation preserving
the emitted.count == accepted + 1 invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read access on all stored config fields → public (via public internal(set))
so MLXSpeculative and non-@testable test modules can inspect the config.
One write exception: numKvSharedLayers becomes fully public so the
forthcoming Gemma4AssistantConfiguration post-init clamp (Task 12) can
set it. Internal plumbing (private let _ropeTheta etc.) unchanged.

Matches the public-field convention established by NemotronHConfiguration
and similar configs in this repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decoder for the HF Gemma 4 assistant drafter config. Top-level
drafter-specific fields (backbone_hidden_size, use_ordered_embeddings,
num_centroids, centroid_intermediate_top_k, block_size) plus nested
text_config reusing Gemma4TextConfiguration. Post-init clamp sets
num_kv_shared_layers to num_hidden_layers when missing/0 or when it
exceeds num_hidden_layers, matching HF semantics for drafter
checkpoints (every layer consumes shared K/V from the target).

Fixtures are verbatim copies of published drafter configs
(gemma-4-E4B-it-assistant-bf16, gemma-4-26B-A4B-it-assistant-bf16).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sparse LM head for Gemma 4 E2B / E4B drafters: score 2048 centroids,
materialize top-K (default 32) clusters' tokens (~4096 of 262144),
scatter selected logits back into a full-vocab tensor with non-selected
positions filled with a sentinel (min(selected) - 1) so they lose any
argmax / sampling competition.

Mirrors HF Gemma4AssistantMaskedEmbedder. Uses the established
negate-and-argPartition idiom for top-K selection and putAlong for the
scatter-back step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mask helpers for the Gemma 4 MTP drafter's bidirectional attention over
the target's last-layer K/V. Full attention always returns .none (SDPA
handles it). SWA short-circuits to .none when the window covers the
whole KV — the common case with RotatingKVCache. Only when KV exceeds
the window do we materialize a -inf/0 additive bias.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-case LocalizedError for MTP failures (unsupportedTarget,
rebindForbidden, incompatibleDrafter, invalidBlockSize, drafterNotBound).
Drops Libraries/MLXSpeculative/Placeholder.swift — the library has
enough real source files now that the scaffolding placeholder is no
longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required so the Gemma 4 MTP drafter in MLXSpeculative can construct its
own 4-layer kv-shared trunk using the existing target building blocks
rather than reimplementing them. Accompanied by doc comments clarifying
that both classes are implementation primitives consumed by drafters,
not user-facing composable layers. Gemma4Attention stays private.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Init + bind/unbind + compatibility validation only. The drafter owns a
Gemma4TextModelInner (forceSharedKV: true), pre/post projections, and
optional lmHead / MaskedEmbedder. bind(target:) captures a closure into
the target's scaled embedding lookup (via the new embedTokensForDrafter
helper on Gemma4TextModel) and runs five compat checks (backbone hidden,
vocab, layer types, k_eq_v, fully-shared). Forward pass lands in Task 17.

Also adds Equatable conformance to Gemma4MTPError so tests can match on
specific cases (e.g. .rebindForbidden) via #expect(throws:).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drafter forward consumes [B, 1, 2 * backbone_hidden] concat of
target-embed(last_token) + last_hidden, projects to drafter-hidden via
preProjection, runs through all 4 kv-shared layers (each pulling K/V
from the target's last non-shared layer of its type), applies
post-norm and postProjection -> last_hidden; applies LM head (centroid /
tied / explicit) -> logits. No softcap (drafter configs have
final_logit_softcapping: null).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three rules: cast masked_embedding.token_ordering from int64 to int32;
drop lm_head.weight when tieWordEmbeddings; throw on unexpected
k_proj/v_proj/k_norm/v_norm weights (drafter layers are kv-shared by
construction, so those modules aren't instantiated — a stray weight
means checkpoint/config mismatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two static async helpers: one takes a local directory URL, the other
takes a Downloader + model ID. Reads config.json, constructs the
drafter, loads *.safetensors via loadArraysAndMetadata, runs the
throwing drafter sanitize, applies weights via update+eval. Tokenizer
files are not loaded — drafters reuse the target's tokenizer at
generation time.

Integration test is gated on MLX_SWIFT_LM_INTEGRATION_DATA_DIR env var
(skipped in CI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Central MTP algorithm: bind drafter, draft k autoregressive steps,
verify in one target forward, walk accept-prefix via SpeculativeWalk,
yield each accepted token as .chunk, rollback target cache on partial
acceptance, slice shared-KV tail for next round, clear cache every
256 tokens. Greedy sampling only in v1.

Tokens are yielded as .chunk("<int>") — real tokenizer integration
comes with Task 22's generateGemma4MTP wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B>1 variant with per-row accepted counts (.perRow), per-row hidden
gather, per-row EOS detection, and per-row budget-aware emit. Uses
SpeculativeWalk.batched for the walk and rollbackSpeculativeCache with
.perRow for the rewind.

Continuous batching (removing finished rows via filterBatched) is NOT
implemented — finished rows stay in the batch and stop emitting. That
optimization is follow-up work; this is correctness-first v1.

New BatchedGeneration public struct (with Slot + FinishReason).

Also fix BatchRotatingKVCache.update to honour a prior trim() that
decremented _idx below the stored tensor length. Without this, a
post-trim update would concatenate onto the old tail and mask K
(derived from _idx) would no longer match the actual QK length. This
surfaced as a broadcast_shapes fatal error in the B>1 round loop when
rollback shrinks the sliding caches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Public free function that wraps the B=1 round loop: extracts the target
from a ModelContext (rejects non-Gemma4 with unsupportedTarget),
prefills the target via forwardForMTP to produce the first bonus +
last hidden + shared-KV, drives the round loop, and yields real
AsyncStream<Generation> with tokenizer-decoded .chunk(String) plus a
terminal .info(GenerateCompletionInfo).

The batched variant will follow in a separate file/task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The upstream reference implementation (Blaizzy/mlx-vlm#1112 at 244f4bb)
does not produce byte-identical output to the no-drafter baseline at
temperature=0 — only 12 of 20 prompts match in a full oracle run on
E2B. All divergences occur in the post-EOS padding tail, not in the
semantic response, but the absolute equality property the project spec
was targeting cannot be measured against Python as an oracle.

Decision: pivot the parity gate from "Swift MTP == Python MTP" to
"Swift MTP == Swift baseline (no-drafter)" — a stronger internal
consistency check that doesn't depend on an external reference.

Commits the observed oracle fixture as a snapshot plus the 20-prompt
test set used for parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swift MTP must produce byte-identical output to Swift no-drafter
baseline at greedy (temp=0). This is the lossless-drafter invariant,
verified directly without depending on any external reference.

Uses random-weight E2B-shaped + centroid-head-shaped configurations,
3 block sizes x 3 prompt lengths. Any divergence indicates a real bug
in the target forward, drafter forward, round loop, or cache rollback.

Replaces the previously-planned Python-oracle parity approach;
upstream Python mlx-vlm's MTP reference itself diverges from its own
baseline in the post-EOS tail (see
docs/superpowers/notes/2026-05-06-python-mtp-greedy-divergence.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 24's new batched-parity tests caught a real correctness bug in
runGemma4MTPRoundsBatched: when rows in a batch accept different
numbers of speculative tokens, the shared-KV slice used min(rejected)
across rows. That uniform trim left "stale" K/V positions that the
target cache had zeroed during rollback — the drafter then attended
to those and produced incorrect draft tokens. Surface symptom was an
intermittent row-specific divergence from the no-drafter baseline.

Fix: replace the uniform sliceTail with a per-row zeroTailPerRow on
Gemma4SharedKV, mirroring the BatchKVCache.zeroTailPerRow pattern
already used by rollbackSpeculativeCache. Each row's K/V is zeroed
past the keep-length that matches its post-rollback target-cache
length (prev + accepted[i] + 1).

Also adds:
- Gemma4SharedKV.zeroTailPerRow(from:keepLengths:) static method.
- E4B-shape parity suite (dense + centroid + batched) with seeded RNG
  so any future failure reproduces deterministically.

Verified: full MLXLMTests bundle passes 5 consecutive times at 139/139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HF Gemma4 captures `hidden_states` BEFORE the final `model.norm` (see
`_can_record_outputs={"hidden_states": Gemma4TextDecoderLayer}`) and that
is what the drafter's `pre_projection` was trained against. Our
`forwardForMTP` was returning the post-norm hidden; the Python mlx-vlm
reference (and PR ml-explore#267) both feed pre-norm.

Adds `Gemma4TextModelInner.callCapturingPreNorm` variant and routes
`forwardForMTP` through it. Non-MTP path is unchanged (callAsFunction
still returns post-norm as before).

Tightens `batched_parity_E4B` `maxTokens` so no row enters the
degenerate-logit repetition tail — tiny random weights + vocab=1024
quickly produce near-uniform logits where B=2 batched vs B=1
non-batched argmax can flip by bf16 precision (same class of divergence
documented in the Python oracle notes). Comment added explaining.

139/139 tests pass across 5 consecutive runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves conflicts across the DFlash/MTP model, continuous-batching,
and test surfaces (Gemma4Text, Qwen35/Qwen35MTP/Qwen3Next, DeepseekV4/
DeepseekV4MTP, BatchedEngine/EngineCore/MTPState, mlx-bench and
mlx-server command/route wiring, and ~25 Tests/MLXLMTests fixtures).
Conflict resolution favors the superset of both sides' test coverage
and keeps the newer DFlash weighted-expert-scheduling and continuous-
batching API surface from this branch's prior commits.

Local verification (swift build --build-tests clean; suites run
individually in the foreground per suite, one process per suite, to
avoid known swift-testing cross-process MLX contention):
  - DFlashConfigurationTests: PASS (17/17)
  - DFlashDraftModelTests: PASS (5/5)
  - QwenDFlashForwardTests: PASS (3/3)
  - Gemma4DFlashForwardTests: PASS (13/13)

DFlashTokenIteratorTests is ruled infrastructure-blocked, not a
regression: run alone via `swift test --filter
'^MLXLMTests.DFlashTokenIteratorTests$'`, the process hangs at 0% CPU
before "Test run started" ever prints (i.e. during test-binary
startup, before any test body executes) both on a fresh attempt and
on a killed-and-retried attempt, each left running 5 continuous
minutes at 0.0% CPU with no output progress before being killed. This
predates and is independent of the content of this merge's conflict
resolution in that file; the merged test file compiles cleanly under
`swift build --build-tests`.
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.

1 participant