Skip to content

feat: make --mtp work for model families that ship MTP heads separately - #137

Merged
solderzzc merged 4 commits into
mainfrom
claude/mtp-assistant-wiring
Aug 10, 2026
Merged

feat: make --mtp work for model families that ship MTP heads separately#137
solderzzc merged 4 commits into
mainfrom
claude/mtp-assistant-wiring

Conversation

@solderzzc

Copy link
Copy Markdown
Member

Implements the wiring discussed on #109, in the shape that avoids a second generation branch.

The gap

--mtp is currently silently a no-op for Gemma 4. The gate at Server.swift:1629/:1640 is context.model is any MTPLanguageModel, and only Qwen35Model, Qwen35TextModel and DeepseekV4Model conform — those carry MTP heads inside the checkpoint (mtp.* weights).

Gemma 4 doesn't. Google ships the heads as a separate assistant checkpoint, which is why DualModelMTP exists — MTPLanguageModel plus a mainModelRef back-pointer — and Gemma4AssistantModel conforms to it. But nothing in Sources/ ever set that reference except Sources/Gemma4MTPBench/main.swift:173, and Gemma4MTPBench is not a target in Package.swift, so it cannot build. The whole path was unreachable.

The change

--mtp-assistant-model <path-or-id> loads the assistant, injects mainModelRef, and routes through the existing generateMTP call. Instead of a second branch, mtpContext() decides which context generateMTP runs against:

  • in-checkpoint MTP → the main context, exactly as today
  • separate assistant → a derived context whose model is the assistant, while tokenizer, processor, configuration — and the KV cache passed alongside — remain the trunk's

That mirrors the reference usage in Gemma4MTPBench (assistant as model, main model's cache) and keeps one code path, so the prompt cache is untouched.

An explicit flag rather than #109's id table: that table maps gemma-4-e4b-it → the E2B assistant and gemma-4-31b-it → the 26B one, which look like transcription slips, and a wrong guess silently drafts from the wrong model.

Measured — and it is not a speedup yet

Correctness is fine: output prefixes are identical to baseline. Throughput is worse on both pairs available here:

trunk + assistant baseline with MTP
gemma-4-e2b-it-4bit + E2B assistant 136.8 tok/s 117.2
gemma-4-26b-a4b-it-4bit + 26B assistant 74.1 tok/s 63.6

And flat across depth on the 26B pair: --num-mtp-tokens 1 / 2 / 3 → 63.3 / 64.1 / 63.6 tok/s.

Invariance to draft depth is the interesting part — it points at a fixed per-round cost rather than per-drafted-token cost, which is exactly what the unlanded maxSharedKV=16 cap in #109 targets (O(T) → O(16) cross-attention in runMTPHead). Both assistants also ship bf16 against 4-bit trunks, so every drafted token costs more than the trunk token it replaces.

What this is and isn't

It makes the flag mean something instead of silently doing nothing, and gives the perf work in #109 something to be measured against. It is not a speedup on its own, and I would not enable MTP by default on these numbers.

With no --mtp-assistant-model, behaviour is byte-identical to before — the fallback is main.model is any MTPLanguageModel ? main : nil, the original condition.

259 tests pass.

Refs #109.

🤖 Generated with Claude Code

--mtp has been silently a no-op for Gemma 4. The gate is `context.model is any
MTPLanguageModel`, and only Qwen35Model, Qwen35TextModel and DeepseekV4Model
conform — those carry their MTP heads inside the main checkpoint. Gemma 4 does
not: Google ships the heads as a separate assistant checkpoint, and
Gemma4AssistantModel conforms to DualModelMTP (MTPLanguageModel plus a
back-reference to the trunk it drafts for). Nothing in Sources/ ever set that
reference except Gemma4MTPBench, which is not a target in Package.swift and so
cannot build — leaving the whole path unreachable.

--mtp-assistant-model loads the assistant, injects mainModelRef, and routes
through the existing generateMTP call. Rather than adding a second generation
branch, mtpContext() picks which context generateMTP should run against: the
main context for in-checkpoint MTP, or a derived context whose model is the
assistant while tokenizer, processor and configuration — and the KV cache
passed alongside — stay the trunk's. That mirrors the reference usage in
Gemma4MTPBench and keeps one code path, so the prompt cache is unaffected.

An explicit flag rather than an id table: the table in #109 maps gemma-4-e4b-it
to the E2B assistant and gemma-4-31b-it to the 26B one, which look like slips,
and a wrong guess here silently drafts from the wrong model.

Measured, and the result is not favourable yet. Output is correct — identical
prefixes to baseline — but throughput is worse on both pairs available here:

  gemma-4-e2b-it-4bit  + E2B assistant:  136.8 → 117.2 tok/s
  gemma-4-26b-a4b-4bit + 26B assistant:   74.1 →  63.6 tok/s

and flat across --num-mtp-tokens 1/2/3 (63.3 / 64.1 / 63.6 on the 26B pair).
Invariance to draft depth points at a fixed per-round cost rather than draft
token cost, which is what the unlanded maxSharedKV=16 cap in #109 targets. Both
assistants also ship bf16 against 4-bit trunks, so each drafted token costs
more than the trunk token it replaces.

So this makes the flag mean something and gives the perf work something to be
measured against; it is not a speedup on its own. MTP stays opt-in and off by
default, and with no --mtp-assistant-model the behaviour is byte-identical to
before.

259 tests pass.

Refs #109.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@solderzzc

Copy link
Copy Markdown
Member Author

Long-context testing of this branch turned up a crash in the feature it adds, plus a correction to the performance picture I posted earlier.

Crash (blocks merge). On a cold server, any prompt over prefillStepSize (512 tokens) aborts:

Fatal error: Layer 0 is a KV-shared layer but received no sharedKV

MTPTokenIterator.prepare() prefills through context.model, and this PR makes that the assistant. A Gemma 4 assistant is entirely KV-shared layers and cannot run standalone — only callMTP supplies it sharedKV. Below 512 tokens prepare() returns .tokens without a forward pass, which is exactly why every test I ran on this branch passed.

Fixed in SharpAI/mlx-swift-lm#46 (delegate the assistant's callAsFunction to the trunk). This PR needs its submodule pointer bumped to that before merging.

Performance, corrected. My earlier ~14% loss was measured only at 150 tokens. At ~9k tokens the sign flips:

context no-MTP dual-model MTP
150 tok 74.1 tok/s 63.6 tok/s (−14%)
~9k tok 17.4 tok/s 18.5 tok/s (+6%)

Which is the expected shape: the fixed per-round cost of speculation is a big fraction of a cheap short-context step and a small fraction of an expensive long-context one. So this feature helps where it matters and hurts on trivial prompts — worth saying in the docs rather than treating the short-prompt number as the headline.

On the maxSharedKV window from #109: I ported it and it makes no measurable difference — 62.6 vs 63.6 tok/s at 150 tokens, 18.5 vs 18.6 at ~9k. The claimed 2–4× was at 40K–100K context, which I can't reach on the 26B pair within a reasonable run. Not landing it on unreproduced numbers; the port is parked on perf/mtp-shared-kv-window if anyone wants to take it to that scale.

solderzzc and others added 3 commits August 9, 2026 13:17
Points at SharpAI/mlx-swift-lm#46, which makes the Gemma 4 assistant's
callAsFunction delegate to the trunk. Without it this PR's feature aborts on
any prompt over prefillStepSize (512 tokens) with

    Fatal error: Layer 0 is a KV-shared layer but received no sharedKV

because MTPTokenIterator.prepare() prefills through context.model, which this
PR makes the assistant — and an assistant checkpoint is entirely KV-shared
layers that cannot run without sharedKV from the trunk.

To be re-pointed at main once #46 lands, since the squash rewrites the SHA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every prompt in this repo's test suite is under 80 characters, so prepare()
always returned prompt tokens without forwarding them and chunked prefill was
never run. That gap is how a dual-model MTP crash on any real-sized prompt
reached a green CI (SharpAI/mlx-swift-lm#46) — the failure needed only a
prompt past prefillStepSize to appear, and nothing in CI supplied one.

Adds one ~2700-token request to the contract suite. An empty response is
treated as a failure, not an error case: a crash in prefill drops the
connection rather than returning an error body, which is precisely the
signature being watched for.

This covers the ordinary generate path only. CI runs no --mtp job, so the
speculative variant of the same code path remains uncovered (#128).

Verified locally: server logs prompt=2697t for the new case, suite 10 passed
0 failed 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SharpAI/mlx-swift-lm#46 landed as squash commit 6a2c179, which replaces the
branch SHA the previous bump pointed at. The tree is byte-identical to the
interim pointer, so the CI already run against this PR still applies — only
the commit identity changes, from a now-deleted branch to main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@solderzzc
solderzzc merged commit 675cf20 into main Aug 10, 2026
13 checks passed
@solderzzc
solderzzc deleted the claude/mtp-assistant-wiring branch August 10, 2026 18:49
solderzzc added a commit that referenced this pull request Aug 16, 2026
#109 bundled a sliding-window KV cap, a Gemma4-specific MTP-assistant
auto-resolution path, and this benchmark tooling into one PR. The first two
are not part of this extraction:

- the KV cap was independently ported and evaluated earlier this session —
  no measurable benefit at 150 tokens or ~9k context, parked
- the auto-resolution wiring (Gemma4MTPRegistry, mtpAsstRef reusing the
  draft-model path) duplicates what #137 already shipped more generally as
  the explicit --mtp-assistant-model flag; rebasing it forward would
  reintroduce a second, narrower implementation of a feature that already
  exists on main

This PR is only the tooling, and it rebases clean because it is genuinely
orthogonal: mtp_bench.py drives the server with --mtp --num-mtp-tokens N
--turbo-kv, the single-checkpoint MTP path, not the Gemma4 dual-model
wiring in conflict. Verified none of these three files reference
Gemma4MTPRegistry, mtp-assistant-model, or anything else from the withheld
part of #109.

run_benchmark.sh's Test 13 previously shelled out to `swift run Gemma4MTPBench`,
a product that no longer exists in Package.swift — that path was already
broken on main before this PR. It now drives mtp_bench.py against the regular
SwiftLM binary instead.

README's benchmark numbers (Gemma4 26B, 4-bit and 8-bit) are carried over from
the original PR's measurements, not reproduced in this extraction — a 40K/100K
context benchmark run is multi-hour. The 8-bit table matches data already used
as reference in mlx-swift-lm#46 and the #137 comment thread earlier this
session, so it is not new to this repo's history, just newly landing in the
README.

Verified: run_benchmark.sh syntax checked, mtp_bench.py compiles and its
--help output parses correctly; confirmed --mtp, --num-mtp-tokens and
--turbo-kv all already exist on main independent of --mtp-assistant-model.

Refs #109

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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