Conversation
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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Publishes the local
dflash-mlx-swiftdevelopment line (74 commits) that evolved well past the squashed snapshot currently at the tip of this branch. It contains the fullMLXSpeculativeDFlash 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-swifttip (6824c86"Add DFlash support for MLX Swift") is a squash cut from an ancestor of this line (content matches locald775c7bto within ~15 lines) and relocates some DFlash files underLibraries/MLXLLM/DFlash/. This PR carries the canonical, newer history (~6,400 additional lines inLibraries/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