Skip to content

fix: prefill the trunk, not the assistant, in dual-model MTP - #46

Merged
solderzzc merged 1 commit into
mainfrom
fix/dual-model-mtp-long-prompt-prefill
Aug 10, 2026
Merged

fix: prefill the trunk, not the assistant, in dual-model MTP#46
solderzzc merged 1 commit into
mainfrom
fix/dual-model-mtp-long-prompt-prefill

Conversation

@solderzzc

Copy link
Copy Markdown
Member

The bug

A Gemma 4 assistant checkpoint is entirely KV-shared layers — it ships no K/V projections and can only run with sharedKV handed in from the trunk's cache, which is what callMTP does.

But the dual-model MTP path installs the assistant as the iterator's model, so MTPTokenIterator.prepare() ran its prefill through the assistant's plain callAsFunction, standalone:

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

Why nothing caught it

prepare() only forwards a chunk when the prompt exceeds prefillStepSize (512). Below that it returns .tokens without touching the model at all — so every short-prompt test passed and the failure only appeared on a cold server given a long prompt. I found it running a ~9k-token benchmark against SharpAI/SwiftLM#137.

The fix

Delegate callAsFunction to mainModelRef when set. Prefill belongs to the trunk in any case: it is the trunk's cache being primed and the trunk's logits the iterator samples the first token from. Generation and verification already route through callMTP, which runs the trunk internally — this was the one path that didn't.

Also clamps the KVCacheSimple shared-KV slice to the key buffer, matching what the rotating branch already does (offset counts positions seen and can outrun the allocation).

Verification

gemma-4-26b-a4b-it-4bit + gemma-4-26B-A4B-it-assistant-bf16, cold server, ~9k-token prompt:

before after
~9k-token prompt (cold) hard abort 120 tok @ 19.1 tok/s, coherent
short prompt ok ok ('Tokyo')
streaming ok ok (15 SSE frames)

Also worth recording, from the same runs: with the crash fixed, dual-model MTP is faster than no-MTP at long context (18.5 vs 17.4 tok/s, +6%) — the inverse of the ~14% loss measured at 150 tokens in ml-explore#137.

Blocks SharpAI/SwiftLM#137, which needs the submodule pointer bumped to this.

🤖 Generated with Claude Code

A Gemma 4 assistant checkpoint is all KV-shared layers — it ships no K/V
projections of its own and can only run with sharedKV handed in from the
trunk's cache, which is what callMTP does. But the dual-model path installs
the assistant as the iterator's `model`, so MTPTokenIterator.prepare() ran
its prefill through the assistant's plain callAsFunction, standalone.

That only aborts when prepare() actually forwards a chunk, i.e. when the
prompt exceeds prefillStepSize (512). Shorter prompts return .tokens without
touching the model, which is why every test so far passed and a ~9k-token
prompt died with:

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

Delegate callAsFunction to mainModelRef when it is set. Prefill belongs to
the trunk regardless: it is the trunk's cache being primed and the trunk's
logits the iterator samples the first token from.

Verified on gemma-4-26b-a4b-it-4bit + gemma-4-26B-A4B-it-assistant-bf16 with
a cold server and a ~9k-token prompt: previously a hard abort, now 120 tok
@ 19.1 tok/s with coherent output; short prompts and streaming unchanged.

Also clamps the KVCacheSimple shared-KV slice to the key buffer, matching
the rotating branch — `offset` counts positions seen and can outrun it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solderzzc added a commit to SharpAI/SwiftLM that referenced this pull request Aug 9, 2026
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>
solderzzc added a commit to SharpAI/SwiftLM that referenced this pull request Aug 9, 2026
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>
@solderzzc
solderzzc merged commit 6a2c179 into main Aug 10, 2026
6 checks passed
@solderzzc
solderzzc deleted the fix/dual-model-mtp-long-prompt-prefill branch August 10, 2026 03:34
solderzzc added a commit to SharpAI/SwiftLM that referenced this pull request Aug 10, 2026
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 added a commit to SharpAI/SwiftLM that referenced this pull request Aug 10, 2026
…ly (#137)

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

--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>

* fix: bump mlx-swift-lm to pick up the dual-model MTP prefill fix

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>

* test: exercise chunked prefill with a prompt over the 512-token boundary

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>

* chore: re-point mlx-swift-lm at the merged prefill fix on main

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>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
solderzzc added a commit that referenced this pull request Aug 11, 2026
The MTP unit tests built their assistant from a config with
num_kv_shared_layers: 0, which no shipped assistant checkpoint uses — real
ones are entirely KV-shared and carry no K/V projections at all. So the
existing tests exercised a model that cannot occur in practice, and dual-model
MTP aborting on any chunk-prefilled prompt (#46) passed straight through them.

Adds a matching config (every layer shared) and two tests:

- a plain forward must reach the trunk, asserted by comparing logits against
  the trunk directly rather than merely surviving the call
- MTPTokenIterator with prefillStepSize squeezed to 2, which forces the
  chunked prefill path with a 5-token prompt

The second is the regression proper. prepare() only forwards a chunk when the
prompt exceeds prefillStepSize; below it the model is never called, which is
why a 512-token threshold hid the bug behind every short test prompt. Lowering
the threshold reproduces it without a multi-gigabyte model pair — the smallest
real one is 7.1 GB, more than GitHub's 10 GB per-repo cache budget can host
alongside the existing modalities.

Verified red-green: with the delegation in Gemma4AssistantModel.callAsFunction
removed, both abort with the production error

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

Note that a regression here surfaces as a crashed test run rather than a failed
assertion, since the guard is a fatalError. Full suite: 98 tests, 16 suites,
all passing.

Co-authored-by: Aegis AI Assistant <simba@aegis-ai.dev>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
solderzzc added a commit that referenced this pull request Aug 12, 2026
…#48)

* test: cover the all-KV-shared assistant and its chunked prefill

The MTP unit tests built their assistant from a config with
num_kv_shared_layers: 0, which no shipped assistant checkpoint uses — real
ones are entirely KV-shared and carry no K/V projections at all. So the
existing tests exercised a model that cannot occur in practice, and dual-model
MTP aborting on any chunk-prefilled prompt (#46) passed straight through them.

Adds a matching config (every layer shared) and two tests:

- a plain forward must reach the trunk, asserted by comparing logits against
  the trunk directly rather than merely surviving the call
- MTPTokenIterator with prefillStepSize squeezed to 2, which forces the
  chunked prefill path with a 5-token prompt

The second is the regression proper. prepare() only forwards a chunk when the
prompt exceeds prefillStepSize; below it the model is never called, which is
why a 512-token threshold hid the bug behind every short test prompt. Lowering
the threshold reproduces it without a multi-gigabyte model pair — the smallest
real one is 7.1 GB, more than GitHub's 10 GB per-repo cache budget can host
alongside the existing modalities.

Verified red-green: with the delegation in Gemma4AssistantModel.callAsFunction
removed, both abort with the production error

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

Note that a regression here surfaces as a crashed test run rather than a failed
assertion, since the guard is a fatalError. Full suite: 98 tests, 16 suites,
all passing.

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

* feat: load glm_moe_dsa / deepseek_v3_2 with dense attention (stage 1)

GLM-5.2 uses `glm_moe_dsa`, which in mlx-lm is 54 lines whose model class is
a bare `class Model(DSV32Model)` — it is DeepSeek V3.2, not its own
architecture. V3.2 in turn is V3 plus DeepSeek Sparse Attention: a lightning
indexer scores cached keys and attention is restricted to the top
`index_topk`. The V3 half of that already exists in this repo, so both model
types map to one new Swift model built on the V3 stack.

The indexer is not implemented here. That is not an approximation below
`index_topk`, because the reference returns no selection at all until the
cache is longer than it:

    if k.shape[2] <= self.index_topk:
        return None

GLM-5.2 ships index_topk 2048, so output is exact for the first 2048
positions and diverges past them — dense instead of top-2048 sparse, a
long-context quality question rather than a failure. Implementing the indexer
is stage 2 and also closes the DeepseekV4Compressor/Indexer TODO.

Config decoding differs from V3 in one way worth noting: GLM-5.2 carries no
top-level rope_theta, nesting it in `rope_parameters` (mlx-lm unpacks this in
__post_init__). Both layouts are accepted. `rope_type: "default"` is
deliberately not forwarded as rope_scaling — a non-nil dict there sends the V3
stack looking for yarn fields the config does not have.

Also generalises a trap in DeepseekV3.sanitize, which dropped
`model.layers.61` as a string literal. That number is just numHiddenLayers —
V3 has 61 layers and its MTP block sits at index 61 — but GLM-5.2 has 78, so
the literal deleted a real layer while keeping the actual MTP block. It now
filters on `layerIdx >= numHiddenLayers`, identical for V3 and correct for any
depth. Covered both ways: a 78-layer model keeps layer 61 and drops 78, a
61-layer model still drops 61. Verified red-green — under the old literal the
GLM case fails on both counts.

Tests are tiny random-init configs in the style of the Gemma 4 suite; they
establish shape, flow and weight handling, not numerics against a reference.
Nothing here is validated against real weights: the smallest glm_moe_dsa
checkpoint is 308 GB. Full suite: 104 tests, 17 suites, passing.

Refs SharpAI/SwiftLM#111

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

---------

Co-authored-by: Aegis AI Assistant <simba@aegis-ai.dev>
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