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

// a draft head can leave out the embeddings and lm head and use the target's
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
6 changes: 6 additions & 0 deletions conversion/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ class ModelBase:
supports_mtp_export: bool = False
mtp_only: bool = False
no_mtp: bool = False
# with mtp_only, leave the shared embeddings and lm head to the target model
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 +1034,10 @@ def set_type(self):

def prepare_metadata(self, vocab_only: bool):

# tells the loader the shared embeddings and lm head 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
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
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 +289 to +293

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
27 changes: 21 additions & 6 deletions ggml/src/ggml-cuda/common.cuh
Original file line number Diff line number Diff line change
Expand Up @@ -1425,13 +1425,18 @@ 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;
// Map from graph key to cuda_graph - allows multiple graphs per context when the
// computation is split across CPU/GPU (e.g., with --n-cpu-moe), and when the same
// split is called with different tensor shapes (e.g. a speculative verify batch)
std::unordered_map<uint64_t, std::unique_ptr<ggml_cuda_graph>> cuda_graphs;

// a cuda graph instance is only valid for the shapes it captured, so a caller that
// alternates shapes needs one instance per shape to stay on the graph path
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 +1451,19 @@ 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;
// a workload with many distinct shapes must not grow this without bound
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
38 changes: 30 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,36 @@ 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];
// the key identifies both the split (its first node) and the shapes it was called with.
// a captured cuda graph hard-codes the shapes, so a caller that alternates shapes - a
// speculative verify batch, for example - needs a separate instance per shape. with a
// single key per split, every shape change resets the warmup and no graph is ever used.
//
// this stays O(1) on purpose: walking every node undoes the point of a cuda graph, which is
// to not touch per-node data on the hot path. the first and last node carry the batch
// dimension, which is what changes when a verify batch changes size. a shape this does not
// separate just shares an entry and re-captures, exactly as before, so it can only help.
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 +2650,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 +4041,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 +4260,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 +4283,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 +4366,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
Loading
Loading