Skip to content

Fix MLXVLM Gemma4 loader to honor num_kv_shared_layers (E-series targets)#384

Merged
davidkoski merged 3 commits into
ml-explore:mainfrom
GoodOlClint:pr/gemma4-vlm-kv-sharing
Jul 15, 2026
Merged

Fix MLXVLM Gemma4 loader to honor num_kv_shared_layers (E-series targets)#384
davidkoski merged 3 commits into
ml-explore:mainfrom
GoodOlClint:pr/gemma4-vlm-kv-sharing

Conversation

@GoodOlClint

Copy link
Copy Markdown
Contributor

Proposed changes

Fixes loading of the Gemma 4 E-series targets (E2B/E4B), which currently fail
with keyNotFound … layers.{15,24}.self_attn.k_proj.weight. This is independent
of MTP — plain loading (speculative=false) fails identically.

Root cause. The E-series checkpoints use num_kv_shared_layers > 0: the last
N decoder layers reuse an earlier layer's K/V and ship no k_proj/v_proj. They
carry vision_config, so they load through VLMModelFactory
MLXVLM/Models/Gemma4.swift. That backbone builds every decoder layer with the
default kvSharedOnly: false, and Gemma4TextAttention.init gated K/V projection
construction on kvSharedOnly alone — even though it already computes
isKVSharedLayer. So the shared-tail layers declared a k_proj the checkpoint
doesn't have, and loadWeights threw. The text-only backbone
(MLXLLM/Models/Gemma4Text.swift) already gates on the shared-layer predicate;
this was drift between the two copies.

Fix. Gate K/V projection construction on !kvSharedOnly && !isKVSharedLayer,
matching the text backbone. The runtime already passes sharedKV to these layers
and sanitize already drops redundant K/V weights for PTQ checkpoints, so both
QAT (omit) and PTQ (ship+drop) layouts load. Dense / 26B-A4B MoE
(num_kv_shared_layers = 0) are unaffected.

Relates to #282 (Gemma 4 family loader gaps). Complements the E-series MTP
centroid-embedder PR — that fixes the drafter; this fixes the target it pairs with.

Checklist

  • I have read the CONTRIBUTING document
  • I have run pre-commit run --all-files to format my code
  • I have added tests that prove my fix is effective
    • testGemma4TextSharedLayersDeclareNoKProj (unit, no checkpoint): a 4-layer
      model with 2 KV-shared layers declares k_proj only on the KV-owning layers.
    • Gemma4EseriesLoadIntegrationTests (cache-gated): the real E2B/E4B 4bit
      targets load through VLMModelFactory and generate. Verified locally — both
      load and produce output (the path that threw pre-fix).
  • Documentation updates — n/a

@GoodOlClint
GoodOlClint marked this pull request as ready for review June 30, 2026 13:42
@mandrael

mandrael commented Jul 1, 2026

Copy link
Copy Markdown

Independently hit this exact bug and can confirm the fix.

Repro: Loading mlx-community/gemma-4-E4B-it-qat-4bit through VLMModelFactory fails with UpdateError.keyNotFound at language_model.model.layers.24.self_attn.k_proj.weight. E4B has num_hidden_layers=42 / num_kv_shared_layers=18, so layers 24–41 are KV-shared and the checkpoint ships no k_proj/v_proj/k_norm for them, yet Gemma4TextAttention.init builds those modules for every layer. The PTQ ...-4bit build fails the same way one step earlier, at layers.24.self_attn.k_norm.weight: sanitize() (merged #330) correctly drops the redundant tensors, which leaves the wrongly-declared modules unfilled. So #330 alone doesn't cover this — the module construction still needs the gate this PR adds.

Confirmation: With this PR applied, gemma-4-E4B-it-qat-4bit loads and generates coherent output through VLMModelFactory. No regression for non-E-series configs: isKVSharedLayer is false whenever num_kv_shared_layers == 0, so those models build every layer exactly as before.

As a bonus this also unblocks the E-series gemma4_assistant MTP drafter end-to-end (paired with #383): with both applied, the E4B target + assistant runs speculative decoding losslessly (byte-identical greedy output) at ~2× tokens/s on an M-series Mac.

Thanks for the fix!

@davidkoski davidkoski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good but it is failing swift-format -- please rerun that. Thank you!

GoodOlClint added a commit to GoodOlClint/mlx-swift-lm that referenced this pull request Jul 2, 2026
…Tests

CI's lint job builds swift-format from releases/latest (603.0.0), whose
OrderedImports rule places attributed imports (@_spi/@testable) after the
plain imports in a separate group — differing from 602.x, which the repo's
`language: system` pre-commit hook picks up locally. This left
MTPSpeculativeTokenIteratorTests.swift (unrelated to this PR's change) drifted
on main, tripping the whole-repo `pre-commit run --all` lint on every PR.
Reformat it to satisfy CI's 603.

Requested by @davidkoski on ml-explore#384.
@GoodOlClint
GoodOlClint force-pushed the pr/gemma4-vlm-kv-sharing branch from 122f671 to 785f963 Compare July 2, 2026 13:41
@GoodOlClint

Copy link
Copy Markdown
Contributor Author

Done — rerun and pushed (785f963).

The failing file was actually an unrelated one: Tests/MLXLMTests/MTPSpeculativeTokenIteratorTests.swift, not the KV-sharing change. Root cause is a swift-format version skew: the CI lint job builds swift-format from releases/latest (currently 603.0.0), whose OrderedImports rule groups attributed imports (@_spi/@testable) into a trailing block, whereas the repo's pre-commit hook is language: system and picks up whatever's on the contributor's PATH (603 vs my local 602.x disagree on exactly this file). So it had drifted on main and pre-commit run --all (whole-repo) tripped it on this PR. Reformatted to 603's ordering here.

Heads-up for the maintainers: because CI tracks releases/latest while pre-commit is unpinned, every swift-format release that changes a rule can red-flag otherwise-untouched PRs until someone reformats. Pinning the CI swift-format tag to match a pinned pre-commit rev would stop the recurring churn — happy to send that as a separate PR if useful.

GoodOlClint added a commit to GoodOlClint/mlx-swift-lm that referenced this pull request Jul 2, 2026
…Tests

Same pre-existing drift as fixed on ml-explore#384: CI's lint builds swift-format from
releases/latest (603.0.0), whose OrderedImports groups attributed imports
(@_spi/@testable) into a trailing block — differing from the 602.x that the
repo's `language: system` pre-commit hook picks up locally. The unrelated file
MTPSpeculativeTokenIteratorTests.swift had drifted on main, tripping the
whole-repo `pre-commit run --all` lint on this PR too. Reformat to satisfy 603.
@GoodOlClint
GoodOlClint requested a review from davidkoski July 5, 2026 22:03
beshkenadze added a commit to beshkenadze/mlx-swift-lm that referenced this pull request Jul 6, 2026
Cherry-pick of the loader fix from ml-explore#384 onto
bd4b743 (our pinned base). Gates the K/V-projection build on
isKVSharedLayer so the module tree matches E-series checkpoints whose
shared tail ships no k_proj (E2B layer 15 / E4B layer 24) — fixes
keyNotFound: layers.24.self_attn.k_proj.weight.
@davidkoski
davidkoski dismissed their stale review July 6, 2026 22:26

swift-format has been run

@davidkoski
davidkoski removed their request for review July 6, 2026 22:26
@davidkoski

Copy link
Copy Markdown
Collaborator

Ugh, trying to get CI to rerun on this.

@GoodOlClint

GoodOlClint commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Claude thinks it sees the issue with the CI, PR incoming.
#404 should hopefully unblock CI, looks like it's been failing across the board for a week.

GoodOlClint and others added 3 commits July 9, 2026 13:13
The VLM Gemma4 text backbone built every decoder layer with the default
kvSharedOnly=false, so Gemma4TextAttention.init constructed k_proj/v_proj/
k_norm/v_norm on the KV-shared tail layers too — even though it already
computed isKVSharedLayer and the checkpoints ship no such weights there.
Loading any KV-sharing Gemma 4 checkpoint (the E2B/E4B "effective" targets,
which route through VLMModelFactory because they carry vision_config) then
failed `keyNotFound layers.{15,24}.self_attn.k_proj.weight` — independent of
MTP (speculative=false fails identically). Dense/MoE (num_kv_shared_layers=0)
were unaffected.

Gate the K/V projection construction on `!isKVSharedLayer` as well, matching
the text-only backbone (MLXLLM/Models/Gemma4Text.swift). The runtime already
passes sharedKV to these layers and sanitize already drops redundant K/V
weights for PTQ checkpoints, so both QAT (omit) and PTQ (ship+drop) layouts
load. The bug is upstream drift: MLXVLM duplicates the LM backbone because it
cannot import MLXLLM, and the fix landed only in the text copy.

Validated by testGemma4TextSharedLayersDeclareNoKProj (no checkpoint): a
4-layer model with 2 KV-shared layers declares k_proj only on layers 0,1.

Relates to ml-explore#282 (Gemma 4 family loader gaps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-checkpoint regression for the num_kv_shared_layers loader fix: loads the
E2B/E4B 4bit targets through VLMModelFactory (the path that threw keyNotFound
on the KV-shared tail layers pre-fix) and runs a short greedy generate, which
also exercises the runtime shared-KV forward. Cache-gated per model id, so it
runs where the weights are present and disables (not fails) otherwise.

Verified locally: both E2B and E4B targets load and generate non-empty output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@GoodOlClint
GoodOlClint force-pushed the pr/gemma4-vlm-kv-sharing branch from 2d4c149 to 79a2b58 Compare July 9, 2026 18:14

@davidkoski davidkoski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good, thank you!

@davidkoski
davidkoski merged commit 68947cc into ml-explore:main Jul 15, 2026
2 checks passed
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.

3 participants