Skip to content

llama: let an MTP draft borrow the target's embeddings and lm head - #142

Open
danielhanchen wants to merge 1 commit into
base/upstream-662a0b012from
mtp/borrow-target-tensors
Open

llama: let an MTP draft borrow the target's embeddings and lm head#142
danielhanchen wants to merge 1 commit into
base/upstream-662a0b012from
mtp/borrow-target-tensors

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

What this does

A NextN/MTP draft exported with --mtp carries token_embd, output_norm and lm_head so that it can be loaded as a standalone model. For every sidecar published so far those three tensors are most of the file.

Taking ggml-org's own, ggml-org/Qwen3.8-27B-GGUF mtp-Qwen3.8-27B-Q4_0.gguf, arch qwen35, 18 tensors:

size share
file total 1.565 GiB
token_embd + output (both [5120, 248320] q4_0) + output_norm 1.332 GiB 85.1%
the MTP block itself, blk.64.*, 15 tensors 0.223 GiB 14.2%

Their BF16 (5.538 GiB) and Q8_0 (2.947 GiB) sidecars sit at the same ratio.

This adds an opt-in --mtp-shared-embd that leaves those tensors out and marks the file with nextn_shared_target_tensors. The loader resolves the missing names against the already loaded target model instead.

The graph side needs no change at all. Twelve archs already fall back to model.tok_embd / model.output for their nextn blocks (qwen3next.cpp:660,815 and the same in deepseek32, deepseek4, glm4-moe, glm-dsa, nemotron-h-moe, mimo2, cohere2moe, hy-v3, qwen35, qwen35moe, step35). Only the loader half was missing.

Result

Stripping the three tensors from ggml-org's sidecar with scripts/mtp_strip_shared.py and running it against the unmodified Qwen3.8-27B-Q4_K_M.gguf target:

full sidecar stripped
size 1.565 GiB 0.233 GiB (6.7x smaller)
acceptance 64.444% 64.444%
drafted / accepted 135 / 87 135 / 87
generated text md5 2873913f642b14fb6c6ebc46aa64a47e identical

llama-speculative-simple, greedy, fixed seed, one B200.

Existing sidecars are not affected

The borrow is gated on the new metadata key, so a file published before this change cannot reach the new code path. There are four independent checks, in this order, and a normal model load exits on the first:

  1. the tensor is not token_embd / output / output_norm
  2. the file does not set nextn_shared_target_tensors
  3. the file ships the tensor itself
  4. no target model was supplied

Verified against the two published sidecars I could find, on the same binary, before and after:

pair acceptance / drafted / accepted / n_predict generated text
unsloth/gemma-4-E2B-it-GGUF mtp-gemma-4-E2B-it-Q8_0 46.061% / 165 / 76 / 131, unchanged c9af508b9a1d596f5bb29bc2eb8592d6, unchanged
ggml-org/Qwen3.8-27B-GGUF mtp-Qwen3.8-27B-Q4_0 64.444% / 135 / 87 / 132, unchanged 2873913f642b14fb6c6ebc46aa64a47e, unchanged

Only decode speed moves, and in both directions, which is run to run noise.

The gemma-4 file is the stricter of the two and is worth describing, because it is what shaped the design. It is arch gemma4-assistant, a self contained 4 block draft running at hidden 256 against the target's 1536, bridging with nextn.pre_projection [3072, 256] and nextn.post_projection [256, 1536]. It has its own token_embd at [256, 262144] and no output.weight, because gemma4-assistant.cpp:37-38 ties the head to its own embeddings unconditionally. A draft may legitimately run at a different width from its target, so "the tensor is missing" is not a safe trigger on its own.

That also decides where the hook sits. It runs before check_tensor_dims, which is both before the throw for a required tensor and before the TENSOR_NOT_REQUIRED to TENSOR_DUPLICATED tie fallback. Without that, an arch whose lm head falls back to token_embd would alias the borrowed embedding rather than borrow the target's real output.weight, which is silently wrong for any model with untied embeddings.

Errors

Loading such a file on its own:

borrow_shared_tensor: this model is a draft head without its own 'token_embd.weight';
load it as a draft of its target model, not on its own

Pointing it at the wrong target:

borrow_shared_tensor: draft and target disagree on 'token_embd.weight':
target has   1536, 262144,      1,      1, draft wants   5120, 248320

Shapes are compared across all four dimensions, because the draft uses the tensor directly rather than only needing a compatible vocabulary.

Scope

126 insertions, 13 deletions, 17 files. git diff --stat is empty for both src/models/ and ggml/.

Borrowed tensors are deliberately not counted in n_created, which done_getting_tensors requires to equal the tensor count in the file; they are not in the file and are neither allocated nor freed by the draft.

--mtp-shared-embd is opt in and is rejected unless --mtp is also given, so files produced by --mtp today are unchanged and keep loading on any build. It applies to the shared _QwenMtpMixin plus the parallel keep lists in glm.py, dots3.py, bailingmoe3.py and command_r.py.

Tests

  • test-llama-archs -a qwen35: OK, 8.39e-08 CUDA, 0.00e+00 CPU, roundtrip OK
  • test-llama-archs -a glm4moe: OK, 8.52e-08 CUDA, 0.00e+00 CPU, roundtrip OK
  • both published sidecars, before and after, as above
  • the stripped sidecar, as above
  • both error paths, as above

Notes

Based on base/upstream-6fe749801, a pushed copy of ggml-org 6fe749801, because this fork's master does not carry the NextN converter machinery this builds on.

The borrow log line only appears under -v, the same as lazy read enabled. A user cannot see the mechanism engage at default verbosity.

scripts/mtp_strip_shared.py, used to produce the stripped sidecar from a published one, is in the workspace rather than in this diff. It is a test fixture, not part of the change.

@danielhanchen
danielhanchen requested a review from CISC as a code owner August 28, 2026 20:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e71418da0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1110 to +1112
if (src == nullptr) {
throw std::runtime_error(format("%s: draft needs tensor '%s' from the target, which does not have it",
__func__, name.c_str()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle tied output weights when borrowing the LM head

When the target has tied embeddings and omits output.weight, its loader falls back to a duplicate named token_embd.weight; consequently this name-based lookup cannot find output.weight. A stripped draft still requests the optional output tensor before its own tie fallback, reaches this exception, and cannot load against an otherwise compatible tied target. Resolve the target model's effective output tensor, including its tied fallback, rather than requiring an entry with the requested GGUF name.

Useful? React with 👍 / 👎.

Comment thread convert_hf_to_gguf.py
Comment on lines +285 to +289
if args.mtp_shared_embd:
if not args.mtp:
logger.error("--mtp-shared-embd only applies together with --mtp")
sys.exit(1)
model_class.mtp_shared_embd = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict or implement shared stripping for every MTP exporter

This enables mtp_shared_embd for every architecture supporting --mtp, but several such exporters still unconditionally retain their shared tensors: DeepSeek V3.2/V4, HY V3, Nemotron-H, and Step3.5 have unchanged mtp_only keep lists that do not inspect this flag. For those inputs, --mtp-shared-embd succeeds and writes the shared-target metadata but produces essentially the ordinary large sidecar, contrary to the CLI promise. Either update each supported filter or reject this option for exporters that do not implement stripping.

Useful? React with 👍 / 👎.

@CISC

CISC commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

I don't understand, what do you need nextn_shared_target_tensors for? Just use target tensors if sidecar doesn't have them, I'm pretty sure this is done already in several models.

@danielhanchen

Copy link
Copy Markdown
Member Author

@CISC I'll make it auto detect

A NextN/MTP draft exported with --mtp carries the token embeddings, output
norm and lm head so it can be loaded as a standalone model. For every current
sidecar those three tensors are most of the file: ggml-org/Qwen3.8-27B-GGUF
mtp-Qwen3.8-27B-Q4_0.gguf is 1.565 GiB, of which 1.332 GiB (85%) is the copy,
against 0.223 GiB for the MTP block itself.

Add an opt-in --mtp-shared-embd that leaves them out. The loader resolves
those names against the already loaded target model instead. The graph side
needs no change: the nextn blocks of seventeen archs already fall back to
model.tok_embd and model.output.

No new metadata: a file missing token_embd is what marks a draft that shares
the target's tensors, and such a file does not load at all today, so no
existing sidecar can reach the new path. The missing token_embd is also what
gates borrowing output and output_norm, so an arch that ties its head to its
own token_embd when output.weight is absent (qwen3.5, qwen3-next) is not
affected. Shapes are checked against the target and a mismatch is refused, as
is loading such a file on its own.
@danielhanchen
danielhanchen force-pushed the mtp/borrow-target-tensors branch from 4e71418 to f36eda8 Compare August 31, 2026 10:53
@danielhanchen
danielhanchen requested a review from ngxson as a code owner August 31, 2026 10:53
@danielhanchen
danielhanchen changed the base branch from base/upstream-6fe749801 to base/upstream-662a0b012 August 31, 2026 10:54
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T11:00:46.212310Z f36eda8 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

// a draft head that shares the target's embeddings does not carry its own token_embd,
// output or output_norm; take them from the target model instead. returns null unless the
// file is missing token_embd, so a model that ships its own tensors is never affected

P1 Badge Condense the hard-wrapped comment

Rewrite this new three-line comment as one or two concise lines rather than splitting its sentences at a fixed width; the repository explicitly requires concise comments and prohibits splitting a sentence across lines.

AGENTS.md reference: AGENTS.md:L74-L81


{"-lzm", "--lazy-mode"}, "MODE",

P2 Badge Preserve the existing lazy-read CLI aliases

Keep --tensor-read-lazy and LLAMA_ARG_TENSOR_READ_LAZY as compatibility aliases when introducing the new names. The parser and generated help sections were checked, and the old spellings have been removed repo-wide even though the option's behavior is unchanged, so existing launch scripts and deployments now fail argument parsing or silently stop honoring their environment setting after upgrading.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

danielhanchen added a commit that referenced this pull request Aug 31, 2026
* unsloth: pin ggml-org#27941, #152 and #154

ggml-org#27941 at 02eb201, no longer a draft and MERGEABLE upstream. It fixes
four qwen4exp correctness defects and is the only account of the Flash-Next
reporter who is on Vulkan only, where the allocator variable is never set. The
previous pin PR #148 carried the stale 8161d11 and is closed.

#152 at 258345e and #154 at 31e432e both merge onto b10708 on their own.

#137 is already pinned at 4e1865e, which is still its head, so no change.

#142 and #144 are NOT pinned here: both predate an upstream refactor that folded
lazy_mode and model_shared into a lazy struct, so they conflict on src/llama.cpp
and src/llama-model-loader.cpp against b10708. b10708 has 'ml.lazy.mode =
params.lazy_mode' where those branches still write 'ml.lazy_mode' and
'ml.model_shared'. They need rebasing onto current upstream before they can be
pinned; pinning them now would fail the resolve.

* unsloth: repin ggml-org#27941 to 6b2b85c

The PR moved on at 10:44Z, after 02eb201 was pinned. Verified to merge onto the
current base tag: 6 files changed, 480 insertions, 138 deletions.

* unsloth: pin #144, rebased, and leave #142 out as contained in it

#144 rebased onto b10709 at 6fc8df1. Two of its nine commits were dropped
rather than carried: 'qwen4exp: fix QSA correctness defects and harden metadata
loading' and the comment tidy on top of it. That work is what ggml-org#27941
supersedes, and keeping both copies is what made the two pins conflict in
llama-kv-cells.h and llama-memory-hybrid-idx.cpp. Listed after ggml-org#27941 so the
upstream version of that work lands first.

#142 is deliberately NOT pinned: its single commit is byte-identical to #144's
c7bd6f2 apart from the lazy API spelling, so #144 already contains it and
pinning both would apply the same change twice.

Verified: b10709 + ggml-org#27941 + #144 merges clean, 24 files, +612/-94.

* unsloth: repin ggml-org#27754 to 949f7ef

The PR moved on at 10:39Z; 5796547 was pinned by #159 earlier today. Verified
to merge onto b10709: 44 files changed, 2673 insertions, 38 deletions.

Every entry in the set is now at its PR's current head.

---------

Co-authored-by: danielhanchen <elliegouldingstuff@gmail.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.

2 participants