Skip to content
Open
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: 2 additions & 0 deletions common/speculative.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,8 @@ 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());

mparams.model_shared = model_tgt;

llama_model * model_dft = llama_model_load_from_file(params.model.path.c_str(), mparams);
if (model_dft == NULL) {
LOG_ERR("%s: failed to load draft model, '%s'\n", __func__, model_path.c_str());
Expand Down
4 changes: 2 additions & 2 deletions conversion/bailingmoe3.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Ca

if is_mtp and cls.no_mtp:
return None
if cls.mtp_only and not is_mtp and name not in (
if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in (
"model.word_embeddings.weight", "model.norm.weight", "lm_head.weight",
):
)):
return None

return super().filter_tensors((name, gen))
Expand Down
5 changes: 5 additions & 0 deletions conversion/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class ModelBase:
supports_mtp_export: bool = False
mtp_only: bool = False
no_mtp: bool = False
mtp_shared_embd: bool = False

def __init__(self, dir_model: Path, ftype: gguf.LlamaFileType, fname_out: Path, *, is_big_endian: bool = False,
use_temp_file: bool = False, eager: bool = False,
Expand Down Expand Up @@ -1032,6 +1033,10 @@ def set_type(self):

def prepare_metadata(self, vocab_only: bool):

# tells the loader they are missing on purpose
if self.mtp_only and self.mtp_shared_embd:
self.gguf_writer.add_nextn_shared_target_tensors(True)

total_params, shared_params, expert_params, expert_count = self.gguf_writer.get_total_parameter_count()

self.metadata = gguf.Metadata.load(self.metadata_override, self.dir_model_card, self.model_name, total_params)
Expand Down
4 changes: 2 additions & 2 deletions conversion/command_r.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ def filter_tensors(cls, item):
is_mtp = (m := re.match(r"model\.layers\.(\d+)\.", name)) is not None and int(m.group(1)) >= cls._n_main_layers
if is_mtp and cls.no_mtp:
return None
if cls.mtp_only and not is_mtp and name not in (
if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in (
"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
):
)):
return None

return name, gen
Expand Down
4 changes: 2 additions & 2 deletions conversion/dots3.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Ca
# --no-mtp: drop the NextN/MTP block; --mtp: keep only that block plus the shared embeddings/norm/lm_head
if is_mtp and cls.no_mtp:
return None
if cls.mtp_only and not is_mtp and name not in (
if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in (
"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
):
)):
return None

return name, gen
Expand Down
12 changes: 6 additions & 6 deletions conversion/glm.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Ca

if is_mtp and cls.no_mtp:
return None
if cls.mtp_only and not is_mtp and name not in (
if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in (
"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
):
)):
return None

return name, gen
Expand Down Expand Up @@ -292,9 +292,9 @@ def filter_tensors(cls, item):
is_mtp = match is not None and int(match.group(1)) >= cls._n_main_layers
if is_mtp and cls.no_mtp:
return None
if cls.mtp_only and not is_mtp and name not in (
if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in (
"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
):
)):
return None

return name, gen
Expand Down Expand Up @@ -352,9 +352,9 @@ def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Ca
return None
# --mtp: keep ONLY NextN-block tensors plus the shared embeddings/
# norm/lm_head (so the resulting GGUF carries just the draft head).
if cls.mtp_only and not is_mtp and name not in (
if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in (
"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
):
)):
return None

return name, gen
Expand Down
2 changes: 1 addition & 1 deletion conversion/qwen.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ def filter_tensors(cls, item):
elif len(parts) == 3 and parts[1] in remapper:
name = f"model.layers.{cls._original_block_count}.{remapper[parts[1]]}.{parts[2]}"
elif cls.mtp_only:
keep = name in (
keep = not cls.mtp_shared_embd and name in (
"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
"embed_tokens.weight", "norm.weight",
)
Expand Down
57 changes: 47 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,56 @@ 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

# _QwenMtpMixin renames mtp.layers.0.* to the trailing block index, so the head reuses the
# existing qwen4exp mappings; only the two pieces below differ.
_MTP_MIXER_PREFIX = "mtp.hyper_connection_mixer."

@classmethod
def filter_tensors(cls, item):
# unindexed in the checkpoint, 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]]:
# W_e@e + W_h@h == [W_e|W_h] @ concat(e, h), so fc_embedding and fc_hidden fuse into eh_proj
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 embedding ahead of the hidden state
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 +99,15 @@ 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)]
# read with length block_count; 0 selects dense, which is how the MTP blocks attend
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 has no PLE table 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
10 changes: 10 additions & 0 deletions convert_hf_to_gguf.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ def parse_args() -> argparse.Namespace:
"--no-nextn", "--no-mtp", dest="no_mtp", action="store_true",
help="Exclude NextN speculative draft tensors from the converted GGUF. Pair with --mtp or --dspark on a second run to publish target and draft as two files.",
)
parser.add_argument(
"--mtp-shared-embd", action="store_true",
help="With --mtp, leave the token embeddings, output norm and LM head out of the draft and take them from the target model at load time. Much smaller draft, but it needs a llama.cpp new enough to read it.",
)
parser.add_argument(
"--dspark", action="store_true",
help="Export only the DeepSeek-V4 DSpark draft tensors as a separate GGUF.",
Expand Down Expand Up @@ -278,6 +282,12 @@ def main() -> None:
if args.mtp:
model_class.mtp_only = True

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
Comment on lines +285 to +289

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 shared embeddings to exporters that implement them

When --mtp-shared-embd is used with supported MTP architectures such as DeepseekV32, HYV3, Step35, or Nemotron-H, this unconditionally enables the mode even though their filter_tensors implementations still retain the embedding, norm, and LM-head tensors (for example, conversion/deepseek.py:499-501 and conversion/hunyuan.py:428-430). The resulting sidecar is still marked with nextn_shared_target_tensors, but because the weights remain present the loader uses the draft-owned copies, so the option does not provide its advertised reduction in file size or loaded memory. Either update every supports_mtp_export implementation to honor this flag or reject the option for unsupported model classes.

Useful? React with 👍 / 👎.


model_instance = model_class(dir_model, output_type, fname_out,
is_big_endian=args.bigendian, use_temp_file=args.use_temp_file,
eager=args.no_lazy,
Expand Down
21 changes: 15 additions & 6 deletions ggml/src/ggml-cuda/common.cuh
Original file line number Diff line number Diff line change
Expand Up @@ -1425,13 +1425,13 @@ struct ggml_backend_cuda_context {
int curr_stream_no = 0;

#ifdef USE_CUDA_GRAPH
// Map from first_node_ptr to cuda_graph - allows multiple graphs per context
// when the computation is split across CPU/GPU (e.g., with --n-cpu-moe)
std::unordered_map<const void *, std::unique_ptr<ggml_cuda_graph>> cuda_graphs;
std::unordered_map<uint64_t, std::unique_ptr<ggml_cuda_graph>> cuda_graphs;

static const size_t max_cuda_graphs = 64;

int64_t last_graph_eviction_sweep = 0;

ggml_cuda_graph * cuda_graph(const void * first_node_ptr) {
ggml_cuda_graph * cuda_graph(uint64_t graph_key) {
const int64_t time_now = ggml_time_us();

// sweep every 5s, evicting cuda graphs unused for >=10s
Expand All @@ -1446,9 +1446,18 @@ struct ggml_backend_cuda_context {
}
}

auto it = cuda_graphs.find(first_node_ptr);
auto it = cuda_graphs.find(graph_key);
if (it == cuda_graphs.end()) {
it = cuda_graphs.emplace(first_node_ptr, std::make_unique<ggml_cuda_graph>()).first;
while (cuda_graphs.size() >= max_cuda_graphs) {
auto lru = cuda_graphs.begin();
for (auto c = cuda_graphs.begin(); c != cuda_graphs.end(); ++c) {
if (c->second->last_used_time < lru->second->last_used_time) {
lru = c;
}
}
cuda_graphs.erase(lru);
}
it = cuda_graphs.emplace(graph_key, std::make_unique<ggml_cuda_graph>()).first;
}
it->second->last_used_time = time_now;
return it->second.get();
Expand Down
32 changes: 24 additions & 8 deletions ggml/src/ggml-cuda/ggml-cuda.cu
Original file line number Diff line number Diff line change
Expand Up @@ -2582,14 +2582,30 @@ static bool ggml_cuda_graph_check_compability(ggml_cgraph * cgraph) {
return use_cuda_graph;
}

static const void * ggml_cuda_graph_get_key(ggml_cgraph * cgraph) {
return cgraph->nodes[0];
// a captured graph hard-codes its shapes, so with one key per split an alternating shape
// (a speculative verify batch) resets warmup forever. O(1) on purpose: walking nodes undoes the
// point of a cuda graph. A shape this fails to separate re-captures as before, so it cannot regress.
static uint64_t ggml_cuda_graph_get_key(ggml_cgraph * cgraph) {
uint64_t key = (uint64_t) (uintptr_t) cgraph->nodes[0];

auto mix = [&key](uint64_t v) {
key = (key ^ v) * 0x100000001b3ull;
};

mix(cgraph->n_nodes);

for (int d = 0; d < GGML_MAX_DIMS; d++) {
mix(cgraph->nodes[0]->ne[d]);
mix(cgraph->nodes[cgraph->n_nodes - 1]->ne[d]);
}

return key;
}

static bool ggml_cuda_graph_update_required(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph * cgraph) {
bool res = false;

const void * graph_key = ggml_cuda_graph_get_key(cgraph);
const uint64_t graph_key = ggml_cuda_graph_get_key(cgraph);
ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);

if (cgraph->uid != 0 &&
Expand Down Expand Up @@ -2628,7 +2644,7 @@ static bool ggml_cuda_graph_update_required(ggml_backend_cuda_context * cuda_ctx
return res;
}

static void ggml_cuda_graph_update_executable(ggml_backend_cuda_context * cuda_ctx, const void * graph_key) {
static void ggml_cuda_graph_update_executable(ggml_backend_cuda_context * cuda_ctx, uint64_t graph_key) {
ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);

#if CUDART_VERSION >= 12000
Expand Down Expand Up @@ -4019,7 +4035,7 @@ static int ggml_cuda_try_fuse(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph
return 0;
}

static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph * cgraph, const bool use_cuda_graph, const bool cuda_graph_update_required, const void * graph_key) {
static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph * cgraph, const bool use_cuda_graph, const bool cuda_graph_update_required, uint64_t graph_key) {
bool graph_evaluated_or_captured = false;

// flag used to determine whether it is an integrated_gpu
Expand Down Expand Up @@ -4238,7 +4254,7 @@ static void ggml_cuda_graph_evaluate_and_capture(ggml_backend_cuda_context * cud
}

#ifdef USE_CUDA_GRAPH
static bool ggml_cuda_graph_set_enabled(ggml_backend_cuda_context * cuda_ctx, const void * graph_key) {
static bool ggml_cuda_graph_set_enabled(ggml_backend_cuda_context * cuda_ctx, uint64_t graph_key) {
ggml_cuda_graph * graph = cuda_ctx->cuda_graph(graph_key);

if (graph->graph == nullptr) {
Expand All @@ -4261,7 +4277,7 @@ static enum ggml_status ggml_backend_cuda_graph_compute(ggml_backend_t backend,

bool use_cuda_graph = false;
bool cuda_graph_update_required = false;
const void * graph_key = nullptr;
uint64_t graph_key = 0;

#ifdef USE_CUDA_GRAPH
graph_key = ggml_cuda_graph_get_key(cgraph);
Expand Down Expand Up @@ -4344,7 +4360,7 @@ static void ggml_backend_cuda_graph_optimize(ggml_backend_t backend, ggml_cgraph
ggml_backend_cuda_context * cuda_ctx = (ggml_backend_cuda_context *) backend->context;

#ifdef USE_CUDA_GRAPH
const void * graph_key = ggml_cuda_graph_get_key(cgraph);
const uint64_t graph_key = ggml_cuda_graph_get_key(cgraph);
const bool use_cuda_graph = ggml_cuda_graph_set_enabled(cuda_ctx, graph_key);
#else
const bool use_cuda_graph = false;
Expand Down
16 changes: 16 additions & 0 deletions gguf-py/gguf/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class LLM:
MOE_EVERY_N_LAYERS = "{arch}.moe_every_n_layers"
MOE_LATENT_SIZE = "{arch}.moe_latent_size"
NEXTN_PREDICT_LAYERS = "{arch}.nextn_predict_layers"
NEXTN_SHARED_TARGET_TENSORS = "{arch}.nextn_shared_target_tensors"
NUM_DEEPSTACK_LAYERS = "{arch}.n_deepstack_layers"
DEEPSTACK_MAPPING = "{arch}.deepstack_mapping"
POOLING_TYPE = "{arch}.pooling_type"
Expand Down Expand Up @@ -1175,6 +1176,10 @@ 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, in place of an output norm
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,14 @@ class MODEL_TENSOR(IntEnum):
MODEL_TENSOR.PLE_NORM_QUERY,
MODEL_TENSOR.PLE_NORM_CONV,
MODEL_TENSOR.PLE_CONV1D,
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
3 changes: 3 additions & 0 deletions gguf-py/gguf/gguf_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,9 @@ def add_moe_latent_size(self, value: int) -> None:
def add_nextn_predict_layers(self, count: int) -> None:
self.add_uint32(Keys.LLM.NEXTN_PREDICT_LAYERS.format(arch=self.arch), count)

def add_nextn_shared_target_tensors(self, value: bool) -> None:
self.add_bool(Keys.LLM.NEXTN_SHARED_TARGET_TENSORS.format(arch=self.arch), value)

def add_swin_norm(self, value: bool) -> None:
self.add_bool(Keys.LLM.SWIN_NORM.format(arch=self.arch), value)

Expand Down
Loading