Skip to content
Merged
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
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

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