Skip to content

Commit 62108fd

Browse files
authored
Merge branch 'ggml-org:master' into master
2 parents 045cdeb + 0d9ceae commit 62108fd

27 files changed

Lines changed: 1986 additions & 81 deletions

‎common/chat.cpp‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2325,6 +2325,179 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha
23252325
return data;
23262326
}
23272327

2328+
// Kimi K3 - XTML tagged format, built by open_tag/close_tag macros:
2329+
// open_tag(t, attrs) = <|open|>t k="v"...<|sep|> close_tag(t) = <|close|>t<|sep|>
2330+
// assistant := [think] [response] [tools] close_tag(message) <|end_of_msg|>
2331+
// the generation prompt already opens the think (or response) section, so the
2332+
// section opener is optional here - same as Kimi K2 Thinking
2333+
static common_chat_params common_chat_params_init_kimi_k3(const common_chat_template & tmpl,
2334+
const autoparser::generation_params & inputs) {
2335+
common_chat_params data;
2336+
2337+
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
2338+
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
2339+
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
2340+
data.supports_thinking = true;
2341+
2342+
const std::string SEP = "<|sep|>";
2343+
const std::string MSG_START = "<|open|>message role=\"assistant\"<|sep|>";
2344+
const std::string THINK_START = "<|open|>think<|sep|>";
2345+
const std::string THINK_END = "<|close|>think<|sep|>";
2346+
const std::string RESP_START = "<|open|>response<|sep|>";
2347+
const std::string RESP_END = "<|close|>response<|sep|>";
2348+
const std::string TOOLS_START = "<|open|>tools<|sep|>";
2349+
const std::string TOOLS_END = "<|close|>tools<|sep|>";
2350+
const std::string CALL_START = "<|open|>call tool=\"";
2351+
const std::string CALL_END = "<|close|>call<|sep|>";
2352+
const std::string ARG_START = "<|open|>argument key=\"";
2353+
const std::string ARG_END = "<|close|>argument<|sep|>";
2354+
const std::string MSG_END = "<|close|>message<|sep|>";
2355+
const std::string EOM_TOKEN = "<|end_of_msg|>";
2356+
2357+
// only the markers are special tokens. tag names ("think", "response", ...) are
2358+
// normal tokens and must not be preserved, or prose with those words is broken
2359+
data.preserved_tokens = {
2360+
"<|open|>",
2361+
"<|close|>",
2362+
"<|sep|>",
2363+
"<|end_of_msg|>",
2364+
};
2365+
2366+
data.thinking_start_tag = THINK_START;
2367+
data.thinking_end_tags = { THINK_END };
2368+
2369+
// per-role message-start delimiters. user/assistant messages only have the role
2370+
// attribute, so the full opener is used. system and tool messages have more
2371+
// attributes, so those delimiters stop after the closing quote of the role
2372+
data.message_delimiters = {
2373+
{ COMMON_CHAT_ROLE_ASSISTANT, "<|open|>message role=\"assistant\"<|sep|>" },
2374+
{ COMMON_CHAT_ROLE_USER, "<|open|>message role=\"user\"<|sep|>" },
2375+
{ COMMON_CHAT_ROLE_TOOL, "<|open|>message role=\"tool\"" },
2376+
{ COMMON_CHAT_ROLE_SYSTEM, "<|open|>message role=\"system\"" },
2377+
};
2378+
2379+
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
2380+
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
2381+
auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE;
2382+
2383+
if (inputs.has_continuation()) {
2384+
const auto & msg = inputs.continue_msg;
2385+
2386+
data.generation_prompt = MSG_START + THINK_START + msg.reasoning_content;
2387+
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
2388+
data.generation_prompt += THINK_END + RESP_START + msg.render_content();
2389+
}
2390+
2391+
data.prompt += data.generation_prompt;
2392+
}
2393+
2394+
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
2395+
auto end = p.end();
2396+
2397+
auto start = p.optional(p.literal(MSG_START));
2398+
2399+
// the think section is always consumed, even with reasoning extraction off:
2400+
// the generation prompt ends with open_tag('think'), so it is always present.
2401+
// reasoning stops at its own closer, or at the response opener if the model
2402+
// skips the closer
2403+
auto think_body = extract_reasoning ? p.reasoning(p.until_one_of({ THINK_END, RESP_START })) :
2404+
p.content(p.until_one_of({ THINK_END, RESP_START }));
2405+
2406+
auto reasoning = p.optional(p.optional(p.literal(THINK_START)) + think_body +
2407+
p.optional(p.literal(THINK_END)));
2408+
2409+
// content runs to the response closer, or to the next section if truncated
2410+
auto response = p.optional(p.literal(RESP_START)) +
2411+
p.content(p.until_one_of({ RESP_END, TOOLS_START, MSG_END })) +
2412+
p.optional(p.literal(RESP_END));
2413+
2414+
// the EOG token after the message closer reaches the parser as text,
2415+
// so it must be consumed or the parse stays incomplete
2416+
auto trailer = p.optional(p.literal(MSG_END)) + p.optional(p.literal(EOM_TOKEN));
2417+
2418+
if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
2419+
return start + reasoning + response + trailer + end;
2420+
}
2421+
2422+
auto tool_choices = p.choice();
2423+
foreach_function(inputs.tools, [&](const json & tool) {
2424+
const auto & function = tool.at("function");
2425+
std::string name = function.at("name");
2426+
const json schema = function.contains("parameters") ? function.at("parameters") : json::object();
2427+
2428+
// arguments come one tag per key, with the JSON type in a type="..."
2429+
// attribute. the type is taken from the tool schema instead, as it tells
2430+
// us if the value is JSON or a literal string
2431+
auto args = p.eps();
2432+
if (schema.contains("properties") && !schema.at("properties").empty()) {
2433+
auto arg_choices = p.choice();
2434+
for (const auto & prop : schema.at("properties").items()) {
2435+
const std::string & key = prop.key();
2436+
2437+
std::string type = "string";
2438+
if (prop.value().is_object() && prop.value().contains("type") &&
2439+
prop.value().at("type").is_string()) {
2440+
type = prop.value().at("type").get<std::string>();
2441+
}
2442+
2443+
auto value = type == "string" ? p.tool_arg_string_value(p.until(ARG_END)) :
2444+
p.tool_arg_value(p.until(ARG_END));
2445+
2446+
// skip the trailing type="..." attribute: anything up to <|sep|>
2447+
arg_choices |= p.rule("kimi-k3-arg-" + name + "-" + key,
2448+
p.tool_arg(p.tool_arg_open(p.literal(ARG_START)) +
2449+
p.tool_arg_name(p.literal(key)) + p.literal("\"") +
2450+
p.until(SEP) + p.literal(SEP) + value +
2451+
p.tool_arg_close(p.literal(ARG_END))));
2452+
}
2453+
args = p.zero_or_more(arg_choices);
2454+
}
2455+
2456+
// skip the trailing index="N" attribute the same way
2457+
auto call = p.tool(p.tool_open(p.literal(CALL_START) + p.tool_name(p.literal(name)) + p.literal("\"") +
2458+
p.until(SEP) + p.literal(SEP)) +
2459+
p.tool_args(args) + p.tool_close(p.literal(CALL_END)));
2460+
2461+
tool_choices |= p.rule("kimi-k3-tool-" + name, call);
2462+
});
2463+
2464+
// all calls go inside one tools section, then the message is closed. the
2465+
// message closer is part of the trigger rule, or else the lazy grammar
2466+
// rejects it once tool calls have started
2467+
auto tools_section =
2468+
p.trigger_rule("kimi-k3-tool-call", p.literal(TOOLS_START) + p.one_or_more(tool_choices) +
2469+
p.literal(TOOLS_END) + p.optional(p.literal(MSG_END)) +
2470+
p.optional(p.literal(EOM_TOKEN)));
2471+
2472+
auto tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED ? tools_section :
2473+
p.optional(tools_section);
2474+
2475+
return start + reasoning + response + tools + trailer + end;
2476+
});
2477+
2478+
data.parser = parser.save();
2479+
2480+
if (include_grammar) {
2481+
data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED;
2482+
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
2483+
foreach_function(inputs.tools, [&](const json & tool) {
2484+
const auto & function = tool.at("function");
2485+
if (function.contains("parameters")) {
2486+
auto schema = function.at("parameters");
2487+
builder.resolve_refs(schema);
2488+
}
2489+
});
2490+
parser.build_grammar(builder, data.grammar_lazy);
2491+
});
2492+
2493+
data.grammar_triggers = {
2494+
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, TOOLS_START },
2495+
};
2496+
}
2497+
2498+
return data;
2499+
}
2500+
23282501
// Cohere2 MoE (a.k.a. "North Code") parser.
23292502
//
23302503
// The assistant turn is fully marker-wrapped:
@@ -3293,6 +3466,13 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
32933466
return common_chat_params_init_kimi_k2(tmpl, params);
32943467
}
32953468

3469+
// Kimi K3 - the <|open|>/<|close|>/<|end_of_msg|> markers are unique to it
3470+
if (src.find("<|open|>") != std::string::npos && src.find("<|close|>") != std::string::npos &&
3471+
src.find("<|end_of_msg|>") != std::string::npos) {
3472+
LOG_DBG("Using specialized template: Kimi K3\n");
3473+
return common_chat_params_init_kimi_k3(tmpl, params);
3474+
}
3475+
32963476
// Cohere2 MoE / North Code - marker-wrapped format with <|START_TEXT|> content and
32973477
// <|START_ACTION|> JSON tool calls. <|START_TEXT|> is unique to this template (the older
32983478
// Command-R templates use <|START_RESPONSE|>).

‎conversion/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"JinaEmbeddingsV5Model": "bert",
126126
"KORMoForCausalLM": "qwen",
127127
"KimiK25ForConditionalGeneration": "deepseek",
128+
"KimiK3ForConditionalGeneration": "kimi_k3",
128129
"KimiLinearForCausalLM": "kimi_linear",
129130
"KimiLinearModel": "kimi_linear",
130131
"KimiVLForConditionalGeneration": "deepseek",

‎conversion/base.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,43 @@ def tensor_force_quant(self, name: str, new_name: str, bid: int | None, n_dims:
658658
def generate_extra_tensors(self) -> Iterable[tuple[str, Tensor]]:
659659
return ()
660660

661+
@staticmethod
662+
def repack_mxfp4_blocks(packed: Tensor, scale: Tensor) -> np.ndarray:
663+
"""
664+
Repack 4-bit MX weights into ggml `block_mxfp4`. Lossless - only moves bits.
665+
666+
Source (compressed-tensors "mxfp4-pack-quantized", also used by DeepSeek-V4):
667+
packed uint8 [rows, cols/2] element 2i in the low nibble, 2i+1 in the high one
668+
scale uint8 [rows, cols/32] one E8M0 biased exponent per 32-element group
669+
670+
Destination, per group: one scale byte then 16 code bytes, where byte j holds
671+
element j in the low nibble and element j+16 in the high one.
672+
673+
The 4-bit codes need no remapping: both sides index into ggml's kvalues_mxfp4
674+
order. ggml doubles the kvalues and halves the scale, so the value is the same.
675+
"""
676+
p = packed.contiguous().view(torch.uint8)
677+
s = scale.contiguous().view(torch.uint8)
678+
679+
rows, packed_cols = p.shape
680+
cols = packed_cols * 2
681+
if cols % 32 != 0:
682+
raise ValueError(f"MXFP4 source row has {cols} values, expected a multiple of 32")
683+
684+
n_blocks = cols // 32
685+
if tuple(s.shape) != (rows, n_blocks):
686+
raise ValueError(f"MXFP4 scale shape {tuple(s.shape)} does not match {(rows, n_blocks)}")
687+
688+
src = p.reshape(rows, n_blocks, 16)
689+
lo = src & 0x0F # elements 0, 2, 4, ...
690+
hi = (src >> 4) & 0x0F # elements 1, 3, 5, ...
691+
692+
vals = torch.stack((lo, hi), dim=-1).reshape(rows, n_blocks, 32)
693+
qs = vals[:, :, :16] | (vals[:, :, 16:] << 4)
694+
695+
raw = torch.cat((s.unsqueeze(-1), qs.to(torch.uint8)), dim=-1)
696+
return raw.reshape(rows, n_blocks * 17).cpu().numpy()
697+
661698
@staticmethod
662699
def _nvfp4_pack(weight: Tensor, scale: Tensor) -> tuple[np.ndarray, list[int]]:
663700
"""Repack NVFP4 ModelOpt tensors into ggml super-block layout.
@@ -2661,7 +2698,10 @@ def get_model_architecture(hparams: dict[str, Any], model_type: ModelType) -> st
26612698
# Step3-VL keeps text config under text_config but uses a custom top-level architecture.
26622699
# For text conversion we route to a dedicated text-only class.
26632700
# TODO: refactor this later to avoid adding exception here
2664-
if model_type == ModelType.TEXT and arch in ("StepVLForConditionalGeneration", "Sarashina2VisionForCausalLM", "Exaone4_5_ForConditionalGeneration", "Step3p7ForConditionalGeneration"):
2701+
# Kimi-K3's text_config reports "KimiLinearForCausalLM", which is the older
2702+
# Kimi-Linear-48B architecture and cannot load K3 (no attention residuals,
2703+
# latent MoE, situ, ...). Route on the top-level architecture instead.
2704+
if model_type == ModelType.TEXT and arch in ("StepVLForConditionalGeneration", "Sarashina2VisionForCausalLM", "Exaone4_5_ForConditionalGeneration", "Step3p7ForConditionalGeneration", "KimiK3ForConditionalGeneration"):
26652705
return arch
26662706

26672707
# if "architectures" is found in the sub-config, use that instead

‎conversion/deepseek.py‎

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -709,31 +709,6 @@ def dequant_fp8_weight(weight: Tensor, scale: Tensor) -> Tensor:
709709
for name in tensors_to_remove:
710710
del self.model_tensors[name]
711711

712-
@staticmethod
713-
def _pack_mxfp4_blocks(weight: Tensor, scale: Tensor) -> np.ndarray:
714-
packed = weight.contiguous().view(torch.uint8)
715-
scale_u8 = scale.contiguous().view(torch.uint8)
716-
717-
out_features, packed_cols = packed.shape
718-
logical_cols = packed_cols * 2
719-
if logical_cols % 32 != 0:
720-
raise ValueError(f"MXFP4 source row has {logical_cols} values, expected a multiple of 32")
721-
722-
n_blocks = logical_cols // 32
723-
if tuple(scale_u8.shape) != (out_features, n_blocks):
724-
raise ValueError(f"MXFP4 scale shape {tuple(scale_u8.shape)} does not match {(out_features, n_blocks)}")
725-
726-
src = packed.reshape(out_features, n_blocks, 16)
727-
low = src & 0x0F
728-
high = (src >> 4) & 0x0F
729-
730-
# The safetensors bytes store adjacent values as low/high nibbles.
731-
# ggml MXFP4 blocks store values 0..15 in low nibbles and 16..31 in high nibbles.
732-
vals = torch.stack((low, high), dim=-1).reshape(out_features, n_blocks, 32)
733-
qs = vals[:, :, :16] | (vals[:, :, 16:] << 4)
734-
raw = torch.cat((scale_u8.unsqueeze(-1), qs.to(torch.uint8)), dim=-1)
735-
return raw.reshape(out_features, n_blocks * 17).cpu().numpy()
736-
737712
def _write_mxfp4_expert_tensor(self, bid: int, proj: str, tensor_key: gguf.MODEL_TENSOR) -> list[str]:
738713
n_experts = self.hparams["n_routed_experts"]
739714
data: np.ndarray | None = None
@@ -747,7 +722,7 @@ def _write_mxfp4_expert_tensor(self, bid: int, proj: str, tensor_key: gguf.MODEL
747722

748723
weight = LazyTorchTensor.to_eager(self.model_tensors[weight_name]())
749724
scale = LazyTorchTensor.to_eager(self.model_tensors[scale_name]())
750-
packed = self._pack_mxfp4_blocks(weight, scale)
725+
packed = self.repack_mxfp4_blocks(weight, scale)
751726
if data is None:
752727
data = np.empty((n_experts, *packed.shape), dtype=packed.dtype)
753728
data[eid] = packed

0 commit comments

Comments
 (0)