Skip to content

Commit 2969d6d

Browse files
satindergrewalcharlie12345pwilkin
authored
model: add Hy3 (hy_v3) support with MTP speculative decoding (ggml-org#25395)
* model: add Hy3 (hy_v3) architecture support Adds Tencent Hunyuan 3 (HF architecture HYV3ForCausalLM, GGUF arch hy_v3): a MoE decoder stack with per-head Q/K RMSNorm, a sigmoid router with expert selection bias, an always-active ungated shared expert, and leading dense block(s) (first_k_dense_replace). The base implementation is ported from charlie12345's fork (https://github.com/charlie12345/ROCmFPX, src/models/hyv3.cpp), adapted to current mainline APIs (hparams.n_layer(), build_qkv, build_moe_ffn with fused gate_up + scale tensors, output_s). Note: blk.N.exp_probs_b is stored without a .bias suffix for compatibility with existing hy_v3 GGUFs produced by that fork. Co-Authored-By: charlie12345 <charlie12345@users.noreply.github.com> Co-authored-by: Piotr Wilkin <ilintar@gmail.com> Assisted-by: Claude Fable 5
1 parent 6eddde0 commit 2969d6d

16 files changed

Lines changed: 882 additions & 4 deletions

common/chat-auto-parser-generator.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ common_peg_parser analyze_tools::build_func_parser(common_chat_peg_builder & p,
262262
bool matched_atomic = false;
263263
common_peg_parser func_parser = p.eps();
264264

265+
if (!function.args_separator.empty()) {
266+
open = open + p.space() + p.literal(function.args_separator);
267+
}
268+
265269
if (!function.name_suffix.empty()) {
266270
func_parser = open + call_id_section + p.space() + args;
267271
matched_atomic = true;

common/chat-auto-parser.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,10 @@ struct tool_format_analysis {
192192
};
193193

194194
struct tool_function_analysis {
195-
std::string name_prefix; // e.g., "<function=", "\"name\": \"", "functions."
196-
std::string name_suffix; // e.g., ">", "\"", ":0"
197-
std::string close; // e.g., "</function>", "" (for tag-based)
195+
std::string name_prefix; // e.g., "<function=", "\"name\": \"", "functions."
196+
std::string name_suffix; // e.g., ">", "\"", ":0"
197+
std::string args_separator; // e.g., "<tool_sep>" (marker between function name and arguments)
198+
std::string close; // e.g., "</function>", "" (for tag-based)
198199
};
199200

200201
struct tool_arguments_analysis {

common/chat-diff-analyzer.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ void autoparser::analyze_template(const common_chat_template & tmpl) {
259259
LOG_DBG("per_call_end: '%s'\n", tools.format.per_call_end.c_str());
260260
LOG_DBG("func_name_prefix: '%s'\n", tools.function.name_prefix.c_str());
261261
LOG_DBG("func_name_suffix: '%s'\n", tools.function.name_suffix.c_str());
262+
LOG_DBG("func_args_separator: '%s'\n", tools.function.args_separator.c_str());
262263
LOG_DBG("func_close: '%s'\n", tools.function.close.c_str());
263264
LOG_DBG("call_id_prefix: '%s'\n", tools.call_id.prefix.c_str());
264265
LOG_DBG("call_id_suffix: '%s'\n", tools.call_id.suffix.c_str());
@@ -302,6 +303,7 @@ void autoparser::collect_preserved_tokens() {
302303
add_token(tools.format.per_call_end);
303304
add_token(tools.function.name_prefix);
304305
add_token(tools.function.name_suffix);
306+
add_token(tools.function.args_separator);
305307
add_token(tools.function.close);
306308
add_token(tools.arguments.start);
307309
add_token(tools.arguments.end);
@@ -1051,6 +1053,23 @@ void analyze_tools::check_per_call_markers() {
10511053
format.section_start.clear();
10521054
format.section_end.clear();
10531055
}
1056+
1057+
if (!format.per_call_end.empty()) {
1058+
auto count_occurrences = [](const std::string & haystack, const std::string & needle) {
1059+
size_t count = 0;
1060+
for (size_t pos = haystack.find(needle); pos != std::string::npos;
1061+
pos = haystack.find(needle, pos + needle.size())) {
1062+
count++;
1063+
}
1064+
return count;
1065+
};
1066+
size_t calls_one = count_occurrences(one_vs_two->output_A, format.per_call_end);
1067+
size_t calls_two = count_occurrences(one_vs_two->output_B, format.per_call_end);
1068+
if (calls_one > 0 && calls_one == calls_two) {
1069+
format.section_end = format.per_call_end;
1070+
format.per_call_end.clear();
1071+
}
1072+
}
10541073
}
10551074

10561075
void analyze_tools::extract_function_markers() {
@@ -1132,6 +1151,17 @@ void analyze_tools::extract_function_markers() {
11321151
auto suf_result = suffix_parser.parse_and_extract(diff.suffix);
11331152
if (suf_result.result.success()) {
11341153
function.name_suffix += suf_result.tags["ext"];
1154+
1155+
auto arg_start = [&](common_peg_parser_builder &p) {
1156+
return p.marker() + p.space() + p.choice({ p.literal(ARG_FIRST), p.literal(ARG_SECOND) });
1157+
};
1158+
auto sep_parser = build_tagged_peg_parser([&](common_peg_parser_builder &p) {
1159+
return p.tag("sep", p.zero_or_more(p.negate(arg_start(p)) + p.any())) + arg_start(p);
1160+
});
1161+
auto sep_result = sep_parser.parse_and_extract(diff.suffix.substr(suf_result.tags["ext"].size()));
1162+
if (sep_result.result.success()) {
1163+
function.args_separator = trim_whitespace(sep_result.tags["sep"]);
1164+
}
11351165
}
11361166
}
11371167

common/jinja/value.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,11 +750,50 @@ const func_builtins & value_string_t::get_builtins() const {
750750
res->val_str.mark_input_based_on(args.get_pos(0)->val_str);
751751
return res;
752752
}},
753+
{"format", [](const func_args & args) -> value {
754+
value val_input = args.get_pos(0);
755+
if (!is_val<value_string>(val_input)) {
756+
throw raised_exception("format() first argument must be a string");
757+
}
758+
const jinja::string & fmt = val_input->as_string();
759+
const bool fmt_is_input = fmt.all_parts_are_input();
760+
761+
const std::string str = fmt.str();
762+
jinja::string result;
763+
std::string literal;
764+
auto flush_literal = [&]() {
765+
if (!literal.empty()) {
766+
result.parts.push_back({fmt_is_input, literal});
767+
literal.clear();
768+
}
769+
};
770+
771+
size_t arg_idx = 1; // positional args follow the format string
772+
for (size_t i = 0; i < str.size(); ++i) {
773+
if (str[i] != '{') {
774+
literal += str[i];
775+
continue;
776+
}
777+
if (i + 1 >= str.size() || str[i + 1] != '}') {
778+
throw not_implemented_exception("format() only supports simple '{}' placeholders");
779+
}
780+
++i;
781+
flush_literal();
782+
const jinja::string arg_str = args.get_pos(arg_idx++)->as_string();
783+
result.parts.insert(result.parts.end(), arg_str.parts.begin(), arg_str.parts.end());
784+
}
785+
flush_literal();
786+
return mk_val<value_string>(result);
787+
}},
753788
{"int", [](const func_args & args) -> value {
754789
value val_input = args.get_pos(0);
755790
value val_default = args.get_kwarg_or_pos("default", 1);
756791
value val_base = args.get_kwarg_or_pos("base", 2);
757792
const int base = val_base->is_undefined() ? 10 : val_base->as_int();
793+
if (base != 0 && (base < 2 || base > 36)) {
794+
// an out-of-range base makes std::stoi fail fast on the MSVC CRT instead of throwing
795+
throw raised_exception("int() base must be 0 or between 2 and 36");
796+
}
758797
if (is_val<value_string>(val_input) == false) {
759798
throw raised_exception("int() first argument must be a string");
760799
}

conversion/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
"HunYuanDenseV1ForCausalLM": "hunyuan",
107107
"HunYuanMoEV1ForCausalLM": "hunyuan",
108108
"HunYuanVLForConditionalGeneration": "hunyuan",
109+
"HYV3ForCausalLM": "hunyuan",
109110
"IQuestCoderForCausalLM": "llama",
110111
"InternLM2ForCausalLM": "internlm",
111112
"InternLM3ForCausalLM": "internlm",

conversion/hunyuan.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import re
45

56
from pathlib import Path
67
from typing import Callable, Iterable, TYPE_CHECKING
@@ -355,3 +356,105 @@ def set_gguf_parameters(self):
355356
self.gguf_writer.add_context_length(ctx_len)
356357

357358
self.gguf_writer.add_rope_dimension_sections(list(self.rope_parameters["xdrope_section"]))
359+
360+
361+
@ModelBase.register("HYV3ForCausalLM")
362+
class HYV3Model(TextModel):
363+
model_arch = gguf.MODEL_ARCH.HY_V3
364+
365+
# Trunk layer count, stashed before indexing so the classmethod
366+
# filter_tensors can identify the appended MTP block(s) (mirrors
367+
# Step35Model).
368+
_n_main_layers: int | None = None
369+
370+
def __init__(self, *args, **kwargs):
371+
super().__init__(*args, **kwargs)
372+
# NextN/MTP layers are appended past num_hidden_layers; extend the
373+
# tensor map so the MTP block's tensors resolve to blk.<n>.* names.
374+
n_nextn = int(self.hparams.get("num_nextn_predict_layers", 0))
375+
if n_nextn > 0 and not self.no_mtp:
376+
self.block_count += n_nextn
377+
self.tensor_map = gguf.get_tensor_name_map(self.model_arch, self.block_count)
378+
379+
def index_tensors(self, remote_hf_model_id: str | None = None):
380+
type(self)._n_main_layers = self.hparams["num_hidden_layers"]
381+
return super().index_tensors(remote_hf_model_id=remote_hf_model_id)
382+
383+
def set_vocab(self):
384+
self._set_vocab_gpt2()
385+
386+
def set_gguf_parameters(self):
387+
super().set_gguf_parameters()
388+
self.gguf_writer.add_expert_feed_forward_length(self.hparams["moe_intermediate_size"])
389+
self.gguf_writer.add_expert_shared_feed_forward_length(
390+
self.hparams["moe_intermediate_size"] * self.hparams.get("num_shared_experts", 1)
391+
)
392+
self.gguf_writer.add_expert_weights_norm(self.hparams.get("route_norm", True))
393+
self.gguf_writer.add_expert_weights_scale(float(self.hparams.get("router_scaling_factor", 1.0)))
394+
# sigmoid router with expert selection bias
395+
self.gguf_writer.add_expert_gating_func(gguf.ExpertGatingFuncType.SIGMOID)
396+
397+
n_nextn = int(self.hparams.get("num_nextn_predict_layers", 0))
398+
if n_nextn > 0 and not self.no_mtp:
399+
self.gguf_writer.add_nextn_predict_layers(n_nextn)
400+
401+
@classmethod
402+
def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
403+
if (titem := super().filter_tensors(item)) is None:
404+
return None
405+
name, gen = titem
406+
407+
# HY V3 appends the MTP block(s) past num_hidden_layers.
408+
assert cls._n_main_layers is not None
409+
is_mtp = (m := re.match(r"model\.layers\.(\d+)\.", name)) is not None and int(m.group(1)) >= cls._n_main_layers
410+
411+
# --no-mtp: drop the appended MTP block(s) entirely.
412+
if is_mtp and cls.no_mtp:
413+
return None
414+
# --mtp: keep ONLY MTP-block tensors plus the shared embeddings/norm/
415+
# lm_head (so the resulting GGUF carries just the draft head).
416+
if cls.mtp_only and not is_mtp and name not in (
417+
"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
418+
):
419+
return None
420+
421+
# The MTP block's trailing final_layernorm (applied after the decoder
422+
# block, before the shared LM head) maps to nextn.shared_head_norm.
423+
if is_mtp:
424+
name = name.replace(".final_layernorm.", ".shared_head.norm.")
425+
426+
return name, gen
427+
428+
_experts: list[dict[str, Tensor]] | None = None
429+
430+
def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
431+
# merge the per-expert tensors into stacked 3d tensors
432+
if name.startswith("model.layers.") and ".mlp.experts." in name:
433+
n_experts = self.find_hparam(["num_local_experts", "num_experts"])
434+
assert bid is not None
435+
436+
if self._experts is None:
437+
self._experts = [{} for _ in range(self.block_count)]
438+
439+
self._experts[bid][name] = data_torch
440+
441+
if len(self._experts[bid]) >= n_experts * 3:
442+
for w_name in ("down_proj", "gate_proj", "up_proj"):
443+
datas: list[Tensor] = []
444+
for xid in range(n_experts):
445+
ename = f"model.layers.{bid}.mlp.experts.{xid}.{w_name}.weight"
446+
datas.append(self._experts[bid][ename])
447+
del self._experts[bid][ename]
448+
449+
merged = torch.stack(datas, dim=0)
450+
yield from super().modify_tensors(merged, f"model.layers.{bid}.mlp.experts.{w_name}.weight", bid)
451+
return
452+
453+
yield from super().modify_tensors(data_torch, name, bid)
454+
455+
def prepare_tensors(self):
456+
super().prepare_tensors()
457+
if self._experts is not None:
458+
experts = [k for d in self._experts for k in d.keys()]
459+
if experts:
460+
raise ValueError(f"Unprocessed experts: {experts}")

gguf-py/gguf/constants.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ class MODEL_ARCH(IntEnum):
512512
HUNYUAN_MOE = auto()
513513
HUNYUAN_DENSE = auto()
514514
HUNYUAN_VL = auto()
515+
HY_V3 = auto()
515516
SMOLLM3 = auto()
516517
GPT_OSS = auto()
517518
LFM2 = auto()
@@ -1093,6 +1094,7 @@ class MODEL_TENSOR(IntEnum):
10931094
MODEL_ARCH.HUNYUAN_MOE: "hunyuan-moe",
10941095
MODEL_ARCH.HUNYUAN_DENSE: "hunyuan-dense",
10951096
MODEL_ARCH.HUNYUAN_VL: "hunyuan_vl",
1097+
MODEL_ARCH.HY_V3: "hy_v3",
10961098
MODEL_ARCH.SMOLLM3: "smollm3",
10971099
MODEL_ARCH.GPT_OSS: "gpt-oss",
10981100
MODEL_ARCH.LFM2: "lfm2",
@@ -3936,6 +3938,37 @@ class MODEL_TENSOR(IntEnum):
39363938
MODEL_TENSOR.FFN_DOWN,
39373939
MODEL_TENSOR.FFN_UP,
39383940
],
3941+
MODEL_ARCH.HY_V3: [
3942+
MODEL_TENSOR.TOKEN_EMBD,
3943+
MODEL_TENSOR.OUTPUT_NORM,
3944+
MODEL_TENSOR.OUTPUT,
3945+
MODEL_TENSOR.ATTN_NORM,
3946+
MODEL_TENSOR.ATTN_Q,
3947+
MODEL_TENSOR.ATTN_Q_NORM,
3948+
MODEL_TENSOR.ATTN_K,
3949+
MODEL_TENSOR.ATTN_K_NORM,
3950+
MODEL_TENSOR.ATTN_V,
3951+
MODEL_TENSOR.ATTN_OUT,
3952+
MODEL_TENSOR.FFN_NORM,
3953+
MODEL_TENSOR.FFN_GATE,
3954+
MODEL_TENSOR.FFN_DOWN,
3955+
MODEL_TENSOR.FFN_UP,
3956+
MODEL_TENSOR.FFN_GATE_INP,
3957+
MODEL_TENSOR.FFN_EXP_PROBS_B,
3958+
MODEL_TENSOR.FFN_GATE_EXP,
3959+
MODEL_TENSOR.FFN_DOWN_EXP,
3960+
MODEL_TENSOR.FFN_UP_EXP,
3961+
MODEL_TENSOR.FFN_GATE_SHEXP,
3962+
MODEL_TENSOR.FFN_DOWN_SHEXP,
3963+
MODEL_TENSOR.FFN_UP_SHEXP,
3964+
# NextN/MTP tensors (draft head)
3965+
MODEL_TENSOR.NEXTN_EH_PROJ,
3966+
MODEL_TENSOR.NEXTN_EMBED_TOKENS,
3967+
MODEL_TENSOR.NEXTN_ENORM,
3968+
MODEL_TENSOR.NEXTN_HNORM,
3969+
MODEL_TENSOR.NEXTN_SHARED_HEAD_HEAD,
3970+
MODEL_TENSOR.NEXTN_SHARED_HEAD_NORM,
3971+
],
39393972
MODEL_ARCH.SMOLLM3: [
39403973
MODEL_TENSOR.TOKEN_EMBD,
39413974
MODEL_TENSOR.OUTPUT_NORM,

0 commit comments

Comments
 (0)