Field note · llama.cpp

GGML_ASSERT(layer.nextn.hc_head_norm) — two MTP pull requests disagree about one tensor name

A published Qwen3.8-Flash-Next MTP head loads against PR #27739 and aborts against PR #27836 with "MTP block missing nextn.hc_head_norm". The head is not damaged and no tensor is missing: the same three hyper-connection tensors are exported as top-level output_hc_norm/down/up by one PR and expected as blk.N.nextn.hc_head_norm/down/up by the other, because a standalone head file has no trunk to collide with and a grafted head does. The published merge script filters to blk.* and silently drops all three. Rename them instead of dropping them.

Hardware
NVIDIA DGX Spark · GB10 (SM121) · aarch64 · 128 GB unified
Software
llama.cpp master + PR #27836 (draft) · unsloth/Qwen3.8-Flash-Next-GGUF UD-IQ4_XS · MTP head from a third-party Q4_K_M export built for PR #27739

The abort

Grafting a published MTP head into the target GGUF and starting the server against PR #27836:

GGML_ASSERT(layer.nextn.hc_head_norm && "MTP block missing nextn.hc_head_norm") failed

Nothing is missing. The three tensors the assert is looking for are in the file you downloaded, with the right shapes and the right bytes. They are under a different name.

Why two names are both correct

Two competing pull requests implement multi-token prediction for this architecture, and the published head was exported for the earlier one.

PR #27739 (the head as published)PR #27836 (what the server expects)
head's mixeroutput_hc_norm / _down / _upblk.N.nextn.hc_head_norm / _down / _up
whyan MTP-only file has no trunk, so the head's mixer becomes the file's mixerthe head lives inside a full model, which already owns the trunk's output_hc_*

Same three tensors, same bytes, different name — and each name is right in its own container. In a standalone head file there is no trunk to collide with, so the top-level name is the natural one. Inside a full model that name is already taken by the trunk.

Why the published merge script makes it worse

merge-mtp-shard.py as shipped with the head keeps only tensors matching blk.*. The three hyper-connection tensors are top-level, so they match nothing, and the script drops them without warning. The merged file looks complete — correct shard count, correct tensor count for everything else — and dies at load.

The fix is to rename rather than filter:

HC_RENAME = {
    'output_hc_norm': f'blk.{mtp_block}.nextn.hc_head_norm',
    'output_hc_down': f'blk.{mtp_block}.nextn.hc_head_down',
    'output_hc_up':   f'blk.{mtp_block}.nextn.hc_head_up',
}
name = HC_RENAME.get(name, name)

While rewriting the merge, two more tensors are worth deliberately leaving behind. PR #27836 marks nextn.embed_tokens and nextn.shared_head_head as TENSOR_NOT_REQUIRED and falls back to the target model's own. Dropping them saves roughly 1.5 GB and makes the draft read the exact weights the target verifies against, which is what you want from a drafter in the first place.

The rest of the graft

The head has to live inside the target file, not beside it. With --spec-type draft-mtp and no -md, llama-server never loads a second model — it opens a second context over the same weights (cparams.ctx_type = LLAMA_CONTEXT_TYPE_MTP), because the head is blk.48.* of the target rather than a separate drafter. So the merge rewrites shard 1's metadata (block_count 48→49, nextn_predict_layers, compress_ratios, split.count, split.tensors.count 1224→1256), hard-links the untouched shards and appends the head as a new final shard: about 1.9 GB of new bytes, no re-download of the 90 GB.

One trap inside the trap: hard-linking a Hugging Face cache entry duplicates a symlink, not a file. Snapshot entries are symlinks into ../../../blobs/<hash>, and os.link() on a symlink copies the symlink with its relative target intact, which resolves to nothing from the new directory. The symptom is two 79-byte "shards" and a server that dies in 0.4 seconds. Call os.path.realpath() before linking.

Standing caveat

PR #27836 is still a draft. If a nextn tensor is renamed before it merges, a grafted shard goes stale and the server aborts at load in exactly this way again. Worth a check that watches the PR's tensor names rather than one that watches for this assert — the assert is the messenger, and it has been correct both times.

Measured payoff once it loads: 27.75 → 43.30 tok/s on code (91.6% acceptance) and 28.28 → 33.47 on prose (86.5%). The full write-up is in I grafted a speculative decoding head into a 90 GB model file; the background on why no published GGUF carries the head is in the MTP head is real, and it is in none of the GGUFs.

Status
workaround
First seen
August 28, 2026