Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion common/speculative.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2582,7 +2582,7 @@ common_speculative_init_result::common_speculative_init_result(
model_path = params.speculative.draft.mparams.path;
LOG_INF("%s: loading draft model '%s'\n", __func__, model_path.c_str());

llama_model * model_dft = llama_model_load_from_file(params.model.path.c_str(), mparams);
llama_model * model_dft = llama_model_load_from_file(model_path.c_str(), mparams);
if (model_dft == NULL) {
LOG_ERR("%s: failed to load draft model, '%s'\n", __func__, model_path.c_str());
return;
Expand Down
68 changes: 58 additions & 10 deletions conversion/qwen4exp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Iterable, cast
from typing import Callable, Iterable, cast

import torch
from torch import Tensor
Expand All @@ -21,20 +21,64 @@ class Qwen4ExpTextModel(_Qwen35MRopeMixin, _LinearAttentionVReorderBase):
Shares the Qwen3.5 gated delta net and interleaved mrope, and adds three things:
hyper-connections in place of every layer norm, QSA sparse attention on the full
attention layers, and PLE n-gram hash embeddings on a single layer.

The checkpoint also carries a NextN/MTP draft head under `mtp.*`, exported as a
trailing block; pass --no-nextn to leave it out.
"""

model_arch = gguf.MODEL_ARCH.QWEN4EXP

# the MTP block is a separate draft head; vLLM drops it too
supports_mtp_export = False
no_mtp = True

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# only the shard names, so the table itself is never held
self._ple_shards: dict[int, str] = {}
self._ple_row_dim: int | None = None

# The MTP head is one trunk-shaped block (dense attention + MoE, wrapped in
# hyper-connections) plus a combiner, so once _QwenMtpMixin renames
# `mtp.layers.0.*` to the trailing block index its tensors ride the existing
# qwen4exp mappings unchanged. Only the two head-level pieces below differ.

_MTP_MIXER_PREFIX = "mtp.hyper_connection_mixer."

@classmethod
def filter_tensors(cls, item):
# the head carries its own copy of the trunk's hc_head_* output mixer,
# which qwen4exp has in place of a final norm; it is unindexed in the
# checkpoint and per-block in the GGUF
name, gen = item
if name.startswith("model." + cls._MTP_MIXER_PREFIX):
name = name.replace("model.", "", 1)
if name.startswith(cls._MTP_MIXER_PREFIX):
if cls.no_mtp:
return None
assert cls._original_block_count is not None
return f"model.layers.{cls._original_block_count}.{name[len('mtp.'):]}", gen
return super().filter_tensors((name, gen))

def index_tensors(self, remote_hf_model_id: str | None = None) -> dict[str, Callable[[], Tensor]]:
# qwen4exp splits the combiner the shared NextN code calls eh_proj into
# fc_embedding and fc_hidden; W_e@e + W_h@h == [W_e|W_h] @ concat(e, h),
# so the two fuse back into the single expected matmul
tensors = super().index_tensors(remote_hf_model_id=remote_hf_model_id)

emb = tensors.pop("mtp.fc_embedding.weight", None)
hid = tensors.pop("mtp.fc_hidden.weight", None)
if emb is None and hid is None:
return tensors
if emb is None or hid is None:
raise ValueError(
"the qwen4exp MTP combiner needs both mtp.fc_embedding.weight and "
"mtp.fc_hidden.weight; pass --no-nextn to convert without the draft head"
)

assert self._original_block_count is not None
# fc_embedding first: the graph concatenates the token embedding ahead of
# the hidden state, so the fused weight has to be ordered to match
name = f"model.layers.{self._original_block_count}.eh_proj.weight"
tensors[name] = lambda: torch.cat([emb(), hid()], dim=1)
return tensors

def _read_hash_constants(self, suffix: str) -> list[int]:
"""Read an int64 PLE constant straight from the checkpoint.

Expand Down Expand Up @@ -63,14 +107,18 @@ def set_gguf_parameters(self):
self.gguf_writer.add_indexer_top_k(hp["indexer_budget"])
ratio = hp["indexer_compress_ratio"]
layer_types = hp["layer_types"]
self.gguf_writer.add_attention_compress_ratios(
[ratio if layer_types[i] == "full_attention" else 0 for i in range(n_layer)]
)
ratios = [ratio if layer_types[i] == "full_attention" else 0 for i in range(n_layer)]
# llama.cpp reads this array with length block_count, and the MTP blocks
# trailing the trunk attend densely, which is what a ratio of 0 selects
ratios += [0] * (self.block_count - n_layer)
self.gguf_writer.add_attention_compress_ratios(ratios)

# ple_layer_ids is 1-based in the HF config; empty means no n-gram table,
# so emit no PLE keys rather than optional ones
# so emit no PLE keys rather than optional ones.
# a draft-only export carries no trunk tensors, so it carries no PLE table
# to describe either
ple_layers = [i - 1 for i in hp["ple_layer_ids"]]
if not ple_layers:
if not ple_layers or self.mtp_only:
return
self.gguf_writer.add_ple_layers(ple_layers)
self.gguf_writer.add_ple_ngram_size(hp["ngram_size"])
Expand Down
17 changes: 17 additions & 0 deletions gguf-py/gguf/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,11 @@ class MODEL_TENSOR(IntEnum):
NEXTN_HNORM = auto()
NEXTN_SHARED_HEAD_HEAD = auto()
NEXTN_SHARED_HEAD_NORM = auto()
# qwen4exp: the MTP head's own hyper-connection mixer, which stands in for the
# output norm the trunk does not have
NEXTN_HC_HEAD_NORM = auto()
NEXTN_HC_HEAD_DOWN = auto()
NEXTN_HC_HEAD_UP = auto()
# eagle3
FC = auto() # feature fusion layer
D2T = auto() # draft to target vocabulary mapping
Expand Down Expand Up @@ -1952,6 +1957,9 @@ class MODEL_TENSOR(IntEnum):
MODEL_TENSOR.NEXTN_HNORM: "blk.{bid}.nextn.hnorm",
MODEL_TENSOR.NEXTN_SHARED_HEAD_HEAD: "blk.{bid}.nextn.shared_head_head",
MODEL_TENSOR.NEXTN_SHARED_HEAD_NORM: "blk.{bid}.nextn.shared_head_norm",
MODEL_TENSOR.NEXTN_HC_HEAD_NORM: "blk.{bid}.nextn.hc_head_norm",
MODEL_TENSOR.NEXTN_HC_HEAD_DOWN: "blk.{bid}.nextn.hc_head_down",
MODEL_TENSOR.NEXTN_HC_HEAD_UP: "blk.{bid}.nextn.hc_head_up",
MODEL_TENSOR.FC: "fc",
MODEL_TENSOR.DSPARK_MARKOV_W1: "markov_w1",
MODEL_TENSOR.DSPARK_MARKOV_W2: "markov_w2",
Expand Down Expand Up @@ -2914,6 +2922,15 @@ class MODEL_TENSOR(IntEnum):
MODEL_TENSOR.PLE_NORM_QUERY,
MODEL_TENSOR.PLE_NORM_CONV,
MODEL_TENSOR.PLE_CONV1D,
# NextN/MTP draft head
MODEL_TENSOR.NEXTN_EH_PROJ,
MODEL_TENSOR.NEXTN_EMBED_TOKENS,
MODEL_TENSOR.NEXTN_ENORM,
MODEL_TENSOR.NEXTN_HNORM,
MODEL_TENSOR.NEXTN_SHARED_HEAD_HEAD,
MODEL_TENSOR.NEXTN_HC_HEAD_NORM,
MODEL_TENSOR.NEXTN_HC_HEAD_DOWN,
MODEL_TENSOR.NEXTN_HC_HEAD_UP,
],
MODEL_ARCH.PLAMO: [
MODEL_TENSOR.TOKEN_EMBD,
Expand Down
10 changes: 10 additions & 0 deletions gguf-py/gguf/tensor_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -2742,6 +2742,16 @@ class TensorNameMap:
MODEL_TENSOR.HC_HEAD_UP: (
"model.hyper_connection_mixer.input_mix_weight_up",
),
# the MTP head carries its own copy of the head mixer above
MODEL_TENSOR.NEXTN_HC_HEAD_NORM: (
"model.layers.{bid}.hyper_connection_mixer.hc_norm",
),
MODEL_TENSOR.NEXTN_HC_HEAD_DOWN: (
"model.layers.{bid}.hyper_connection_mixer.input_mix_weight_down",
),
MODEL_TENSOR.NEXTN_HC_HEAD_UP: (
"model.layers.{bid}.hyper_connection_mixer.input_mix_weight_up",
),
MODEL_TENSOR.INDEXER_Q_NORM: (
"model.layers.{bid}.self_attn.indexer.q_layernorm",
),
Expand Down
6 changes: 6 additions & 0 deletions src/llama-arch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,9 @@ static const std::map<llm_tensor, const char *> LLM_TENSOR_NAMES = {
{ LLM_TENSOR_NEXTN_HNORM, "blk.%d.nextn.hnorm" },
{ LLM_TENSOR_NEXTN_SHARED_HEAD_HEAD, "blk.%d.nextn.shared_head_head" },
{ LLM_TENSOR_NEXTN_SHARED_HEAD_NORM, "blk.%d.nextn.shared_head_norm" },
{ LLM_TENSOR_NEXTN_HC_HEAD_NORM, "blk.%d.nextn.hc_head_norm" },
{ LLM_TENSOR_NEXTN_HC_HEAD_DOWN, "blk.%d.nextn.hc_head_down" },
{ LLM_TENSOR_NEXTN_HC_HEAD_UP, "blk.%d.nextn.hc_head_up" },
{ LLM_TENSOR_ATTN_SUB_NORM, "blk.%d.attn_sub_norm" },
{ LLM_TENSOR_FFN_SUB_NORM, "blk.%d.ffn_sub_norm" },
{ LLM_TENSOR_DEC_OUTPUT_NORM, "dec.output_norm" },
Expand Down Expand Up @@ -962,6 +965,9 @@ static const std::map<llm_tensor, llm_tensor_info> LLM_TENSOR_INFOS = {
{LLM_TENSOR_NEXTN_HNORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
{LLM_TENSOR_NEXTN_SHARED_HEAD_HEAD, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
{LLM_TENSOR_NEXTN_SHARED_HEAD_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
{LLM_TENSOR_NEXTN_HC_HEAD_NORM, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
{LLM_TENSOR_NEXTN_HC_HEAD_DOWN, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
{LLM_TENSOR_NEXTN_HC_HEAD_UP, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
// Nemotron 3 Super
// latent projections feed ggml_mul_mat, the buft probe must use MUL_MAT to keep them on GPU
{LLM_TENSOR_FFN_LATENT_DOWN, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
Expand Down
5 changes: 5 additions & 0 deletions src/llama-arch.h
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,11 @@ enum llm_tensor {
LLM_TENSOR_NEXTN_HNORM,
LLM_TENSOR_NEXTN_SHARED_HEAD_HEAD,
LLM_TENSOR_NEXTN_SHARED_HEAD_NORM,
// qwen4exp: the MTP head ends in its own hyper-connection mixer rather than a
// plain RMSNorm, mirroring the trunk's hc_head_* (which is its output norm)
LLM_TENSOR_NEXTN_HC_HEAD_NORM,
LLM_TENSOR_NEXTN_HC_HEAD_DOWN,
LLM_TENSOR_NEXTN_HC_HEAD_UP,
LLM_TENSOR_MASKED_EMBD_CENTROIDS,
LLM_TENSOR_MASKED_EMBD_ORDERING,
LLM_TENSOR_FC,
Expand Down
2 changes: 1 addition & 1 deletion src/llama-model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2434,7 +2434,7 @@ llama_memory_i * llama_model::create_memory(const llama_memory_params & params,
const bool mtp_on_hybrid_qwen =
params.ctx_type == LLAMA_CONTEXT_TYPE_MTP &&
(arch == LLM_ARCH_QWEN3NEXT || arch == LLM_ARCH_QWEN35 || arch == LLM_ARCH_QWEN35MOE ||
arch == LLM_ARCH_BAILINGMOE3);
arch == LLM_ARCH_BAILINGMOE3 || arch == LLM_ARCH_QWEN4EXP);

const bool mtp_on_hybrid_nemotron =
params.ctx_type == LLAMA_CONTEXT_TYPE_MTP && arch == LLM_ARCH_NEMOTRON_H_MOE;
Expand Down
6 changes: 6 additions & 0 deletions src/llama-model.h
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ struct llama_layer_nextn {
struct ggml_tensor * shared_head_head_s = nullptr;
struct ggml_tensor * shared_head_head_in_s = nullptr;
struct ggml_tensor * shared_head_norm = nullptr;

// qwen4exp: the MTP head's own final hyper-connection mixer, which stands in for both
// the stream collapse and the output norm (the trunk has no separate output_norm either)
struct ggml_tensor * hc_head_norm = nullptr;
struct ggml_tensor * hc_head_down = nullptr;
struct ggml_tensor * hc_head_up = nullptr;
};

struct llama_layer_switch_lora {
Expand Down
12 changes: 11 additions & 1 deletion src/models/models.h
Original file line number Diff line number Diff line change
Expand Up @@ -2285,7 +2285,12 @@ struct llama_model_qwen4exp : public llama_model_base {

struct graph : public llm_build_delta_net_base {
graph(const llama_model & model, const llm_graph_params & params);
private:
protected:
// tag-dispatched ctor for graph_mtp: binds the members without building the trunk
struct no_build_t {};
graph(const llama_model & model, const llm_graph_params & params, no_build_t) :
llm_build_delta_net_base(params), model(model) {}

// HC replaces every layer norm: residual is [n_embd, hc, n_tokens]
ggml_tensor * build_hc_mix(
ggml_tensor * x,
Expand Down Expand Up @@ -2377,6 +2382,11 @@ struct llama_model_qwen4exp : public llama_model_base {
const llama_model & model;
};

// LLM_GRAPH_TYPE_DECODER_MTP draft head: one HC-wrapped dense-attention + MoE block
struct graph_mtp : public graph {
graph_mtp(const llama_model & model, const llm_graph_params & params);
};

std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
};

Expand Down
Loading