From ba3c05f71da85cd0439e84ba42b0478b24d5db16 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 15 Oct 2026 23:27:12 +0200 Subject: [PATCH] model: add Muse Spark Open (text, vision and audio) (#30616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * model: add Muse Spark Open (text, vision and audio) Muse Spark Open is a latent space MoE: the residual stream stays at 8192 while the 256 experts live in 4096, with a projection down and up around them, top-8 sigmoid routing with a correction bias. Attention interleaves three sliding layers at a 2048 window with rope for one global layer without rope, with a sigmoid output gate and a weightless QK-norm. Residuals are norm preserving, their gate is folded at conversion time. Vision reuses the Muse Glimmer tower. Audio is a causal convolutional pyramid with no attention and no spectrogram, four stages folding 16, 6, 5 and 4 samples into the channel dim, one embedding per 80 ms of 24 kHz. The quantizer keeps the latent projections and the attention gate at Q8_0, a little over 1% of the weights, since the whole residual stream passes through them and the low-bit types turned the output into a repeating loop. The model saver writes the post norm epsilon, and the arch is registered in test-llama-archs. Co-authored-by: Xuan Son Nguyen Co-authored-by: Pedro Cuenca * Muse Spark: drop the redundant forward expand * Muse Spark: drop the redundant forward expand and tighten the comments * Update src/llama-quant.cpp Co-authored-by: Sigbjørn Skjæret * Update src/llama-graph.cpp Co-authored-by: Sigbjørn Skjæret * Update conversion/muse_spark.py Co-authored-by: Sigbjørn Skjæret * Update conversion/muse_spark.py Co-authored-by: Sigbjørn Skjæret * Update conversion/muse_spark.py Co-authored-by: Sigbjørn Skjæret * Update conversion/muse_spark.py Co-authored-by: Sigbjørn Skjæret --------- Co-authored-by: Xuan Son Nguyen Co-authored-by: Pedro Cuenca Co-authored-by: Sigbjørn Skjæret --- common/chat.cpp | 6 + common/parsers/muse-spark.cpp | 146 +++++++++++++++ common/parsers/parsers.h | 1 + common/parsers/sources.cmake | 1 + conversion/__init__.py | 2 + conversion/base.py | 3 + conversion/muse_glimmer.py | 2 +- conversion/muse_spark.py | 221 +++++++++++++++++++++++ convert_hf_to_gguf_update.py | 2 + gguf-py/gguf/constants.py | 53 ++++++ gguf-py/gguf/gguf_writer.py | 12 ++ gguf-py/gguf/tensor_mapping.py | 11 ++ models/templates/muse-spark.jinja | 228 +++++++++++++++++++++++ src/llama-arch.cpp | 14 ++ src/llama-arch.h | 8 + src/llama-graph.cpp | 19 +- src/llama-graph.h | 8 +- src/llama-hparams.h | 3 + src/llama-model-saver.cpp | 1 + src/llama-model.cpp | 4 + src/llama-model.h | 11 ++ src/llama-quant.cpp | 21 +++ src/llama-vocab.cpp | 1 + src/models/models.h | 13 ++ src/models/muse-spark.cpp | 238 +++++++++++++++++++++++++ tests/test-chat.cpp | 44 +++++ tests/test-llama-archs.cpp | 14 +- tools/mtmd/CMakeLists.txt | 1 + tools/mtmd/clip-impl.h | 7 + tools/mtmd/clip-model.h | 26 +++ tools/mtmd/clip.cpp | 75 +++++++- tools/mtmd/models/models.h | 5 + tools/mtmd/models/muse-spark-audio.cpp | 66 +++++++ tools/mtmd/mtmd-audio.cpp | 20 +++ tools/mtmd/mtmd-audio.h | 6 + tools/mtmd/mtmd.cpp | 6 + 36 files changed, 1290 insertions(+), 9 deletions(-) create mode 100644 common/parsers/muse-spark.cpp create mode 100644 conversion/muse_spark.py create mode 100644 models/templates/muse-spark.jinja create mode 100644 src/models/muse-spark.cpp create mode 100644 tools/mtmd/models/muse-spark-audio.cpp diff --git a/common/chat.cpp b/common/chat.cpp index 485671308..118072b6b 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -1110,6 +1110,12 @@ static common_chat_params_init_fn common_chat_template_detect_params_init(const return common_chat_params_init_gpt_oss; } + // Muse Spark shares the Muse recipient protocol and adds a commentary recipient. + if (src.find("to=commentary") != std::string::npos && src.find("<|eom|>") != std::string::npos) { + LOG_DBG("Using specialized template: Muse Spark\n"); + return common_chat_params_init_muse_spark; + } + // Muse Glimmer format using " to=" recipients and <|eom|>/<|eot|> message terminators. if (src.find("") != std::string::npos && src.find("<|eom|>") != std::string::npos) { LOG_DBG("Using specialized template: Muse Glimmer\n"); diff --git a/common/parsers/muse-spark.cpp b/common/parsers/muse-spark.cpp new file mode 100644 index 000000000..577090aa5 --- /dev/null +++ b/common/parsers/muse-spark.cpp @@ -0,0 +1,146 @@ +#include "parsers.h" + +// An assistant turn is rendered as one or more messages, each +// "<|start|>assistant to=<|message|>{content}{END}" where END is +// <|eom|> (more messages follow) or <|eot|> (end of turn): +// - chain-of-thought: to=self, terminated by <|eom|> +// - user facing aside: to=commentary, terminated by <|eom|> +// - final answer: to=user, terminated by <|eot|> +// A commentary message carries content the user sees while the assistant keeps +// working, typically before or between tool calls. +// The generation prompt is just "<|start|>assistant"; the model emits its own +// " to=...<|message|>". +common_chat_params common_chat_params_init_muse_spark(const common_chat_template & tmpl, + const autoparser::generation_params & inputs) { + common_chat_params data; + + data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs); + data.generation_prompt = "<|start|>assistant"; + data.format = COMMON_CHAT_FORMAT_PEG_NATIVE; + data.supports_thinking = true; + + data.preserved_tokens = { + "<|start|>", "<|message|>", "<|eom|>", "<|eot|>", + // ATEM tool-call markup emitted on " to=" turns. + "", "", + "", "", + }; + + data.message_delimiters = { + { COMMON_CHAT_ROLE_ASSISTANT, "<|start|>assistant" }, + { COMMON_CHAT_ROLE_USER, "<|start|>user" }, + { COMMON_CHAT_ROLE_SYSTEM, "<|start|>system" }, + { COMMON_CHAT_ROLE_TOOL, "<|start|>tool" }, + }; + + if (inputs.has_continuation()) { + const auto & msg = inputs.continue_msg; + + data.generation_prompt = "<|start|>assistant to=self<|message|>" + msg.reasoning_content; + if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) { + data.generation_prompt += "<|eom|><|start|>assistant to=user<|message|>" + msg.render_content(); + } + + data.prompt.append(data.generation_prompt); + } + + auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE; + + auto has_tools = inputs.tools.is_array() && !inputs.tools.empty(); + // Constrained grammar whenever tools are offered. + auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE; + + data.parser = build_chat_peg_parser([&](common_chat_peg_builder & p) { + auto start = p.rule("start", p.literal("<|start|>assistant")); + + if (!extract_reasoning && !include_grammar) { + return start + p.content(p.rest()); + } + + if (extract_reasoning) { + p.rule("analysis", p.literal(" to=self<|message|>") + p.reasoning(p.until("<|eom|>")) + p.literal("<|eom|>")); + } else { + p.rule("analysis", p.literal(" to=self<|message|>") + p.content(p.until("<|eom|>")) + p.literal("<|eom|>")); + } + auto analysis = p.ref("analysis"); + + // Commentary is user facing content emitted mid turn, so it reads as + // content and always ends on <|eom|>. + auto preamble = p.rule("preamble", p.literal(" to=commentary<|message|>") + + p.content(p.until("<|eom|>")) + p.literal("<|eom|>")); + + auto aside = p.rule("aside", analysis | preamble); + + auto recipient = p.optional(p.literal(" to=user")); + auto final_msg = p.rule("final", recipient + p.literal("<|message|>") + + p.content(p.until_one_of({ "<|eot|>", "<|eom|>" }))); + + if (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE) { + auto string_value = p.ac( + p.tool_arg_string_value(p.until("")) + p.tool_arg_close(p.literal("")), + ""); + + auto tool_choice = p.choice(); + foreach_function(inputs.tools, [&](size_t tool_index, const json & tool) { + const auto & function = tool.at("function"); + const std::string name = function.at("name"); + + std::vector arg_rules; + foreach_parameter(function, [&](size_t param_index, const common_chat_schema_property & prop, const common_chat_schema_document_ptr & doc) { + auto value_parser = p.eps(); + if (prop.schema->may_be_string()) { + value_parser = string_value; + } else { + value_parser = p.tool_arg_json_value( + p.schema(p.json(), "tool-" + std::to_string(tool_index) + "-arg-" + std::to_string(param_index) + "-schema", doc, *prop.schema)) + + p.tool_arg_close(p.literal("")); + } + + arg_rules.push_back(p.tool_arg( + p.tool_arg_open(p.literal("")) + + value_parser)); + }); + + auto args = p.eps(); + if (!arg_rules.empty()) { + args = p.zero_or_more(p.choice(arg_rules) + p.space()); + } + + auto tool_parser = p.tool( + p.tool_open(p.literal(" to=") + p.until("<|message|>") + + p.literal("<|message|>") + p.space() + + p.literal("") + p.space()) + << p.tool_args(args) + << p.tool_close(p.literal("") + p.space() + p.literal(""))); + + tool_choice |= p.rule("tool-" + std::to_string(tool_index), tool_parser); + }); + + auto tool_calls = inputs.parallel_tool_calls + ? p.trigger_rule("tool-call", tool_choice + p.zero_or_more(p.literal("<|eom|>") + start + tool_choice)) + : p.trigger_rule("tool-call", tool_choice); + + + if (inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED) { + return p.zero_or_more(start + aside) + start + tool_calls; + } + auto trailing_calls = p.optional(p.literal("<|eom|>") + start + tool_calls); + return p.zero_or_more(start + aside) + start + (tool_calls | (final_msg + trailing_calls)); + } + + return p.zero_or_more(start + aside) + start + final_msg; + }); + + if (include_grammar) { + data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED; + data.grammar = build_grammar([&](const common_grammar_builder & builder) { + data.parser.build_grammar(builder, data.grammar_lazy); + }); + data.grammar_triggers = { + { COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN, + "<\\|start\\|>assistant( to=(?!self<\\|message\\|>)(?!commentary<\\|message\\|>)(?!user<\\|message\\|>)[^<]*?<\\|message\\|>)" }, + }; + } + + return data; +} diff --git a/common/parsers/parsers.h b/common/parsers/parsers.h index 8f0a97ce3..fac69c096 100644 --- a/common/parsers/parsers.h +++ b/common/parsers/parsers.h @@ -79,6 +79,7 @@ common_chat_params common_chat_params_init_minimax_m3(const common_chat_template common_chat_params common_chat_params_init_ministral_3(const common_chat_template & tmpl, const autoparser::generation_params & inputs); common_chat_params common_chat_params_init_muse_glimmer(const common_chat_template & tmpl, const autoparser::generation_params & inputs); +common_chat_params common_chat_params_init_muse_spark(const common_chat_template & tmpl, const autoparser::generation_params & inputs); common_chat_params common_chat_params_init_qwen3_coder(const common_chat_template & tmpl, const autoparser::generation_params & inputs); diff --git a/common/parsers/sources.cmake b/common/parsers/sources.cmake index 152397074..5ee65b188 100644 --- a/common/parsers/sources.cmake +++ b/common/parsers/sources.cmake @@ -19,6 +19,7 @@ set(LLAMA_CHAT_PARSERS_SOURCES ${CMAKE_CURRENT_LIST_DIR}/minimax-m3.cpp ${CMAKE_CURRENT_LIST_DIR}/ministral3.cpp ${CMAKE_CURRENT_LIST_DIR}/muse-glimmer.cpp + ${CMAKE_CURRENT_LIST_DIR}/muse-spark.cpp ${CMAKE_CURRENT_LIST_DIR}/qwen3-coder.cpp ${CMAKE_CURRENT_LIST_DIR}/translate-gemma.cpp ) diff --git a/conversion/__init__.py b/conversion/__init__.py index 8ce4cb733..6c3914fbd 100644 --- a/conversion/__init__.py +++ b/conversion/__init__.py @@ -220,6 +220,7 @@ TEXT_MODEL_MAP: dict[str, str] = { "OlmoeForCausalLM": "olmo", "MuseGlimmerAssistantModel": "muse_glimmer", "MuseGlimmerForConditionalGeneration": "muse_glimmer", + "MuseSparkForConditionalGeneration": "muse_spark", "OpenELMForCausalLM": "openelm", "OpenJevModel": "qwen", "OrionForCausalLM": "orion", @@ -356,6 +357,7 @@ MMPROJ_MODEL_MAP: dict[str, str] = { "NemotronH_Omni_Reasoning_V3": "nemotron", "NemotronH_Nano_VL_V2": "nemotron", "MuseGlimmerForConditionalGeneration": "muse_glimmer", + "MuseSparkForConditionalGeneration": "muse_spark", "PaddleOCRVisionModel": "ernie", "Phi4ForCausalLMV": "phi", "Qwen2AudioForConditionalGeneration": "ultravox", diff --git a/conversion/base.py b/conversion/base.py index a5a80f27d..e3d265157 100644 --- a/conversion/base.py +++ b/conversion/base.py @@ -1869,6 +1869,9 @@ class TextModel(ModelBase): if chkhsh == "66b8d4e19ab16c3bfd89bce5d785fb7e0155e8648708a1f42077cb9fe002c273": # ref: https://huggingface.co/alvarobartt/grok-2-tokenizer res = "grok-2" + if chkhsh == "f5c965e4198da6e7525072d87d1ecf4c51d981fd486c7c4ec728ef9cec7e03fc": + # ref: https://huggingface.co/meta-models/Muse-Spark-Open-816B-A42B + res = "gpt-4o" if chkhsh == "b3d1dd861f1d4c5c0d2569ce36baf3f90fe8a102db3de50dd71ff860d91be3df": # ref: https://huggingface.co/aari1995/German_Semantic_V3 res = "jina-v2-de" diff --git a/conversion/muse_glimmer.py b/conversion/muse_glimmer.py index c86b33227..9b63d3dba 100644 --- a/conversion/muse_glimmer.py +++ b/conversion/muse_glimmer.py @@ -94,7 +94,7 @@ class MuseGlimmerVisionModel(MmprojModel): assert self.hparams_vision is not None c = self.hparams_vision # enriched vision_config from get_vision_config() - self.gguf_writer.add_clip_projector_type(gguf.VisionProjectorType.MUSE_GLIMMER) + self.gguf_writer.add_clip_vision_projector_type(gguf.VisionProjectorType.MUSE_GLIMMER) self.gguf_writer.add_vision_attention_layernorm_eps(float(c["layer_norm_eps"])) self.gguf_writer.add_vision_spatial_merge_size(int(c["merge_size"])) diff --git a/conversion/muse_spark.py b/conversion/muse_spark.py new file mode 100644 index 000000000..150f22f95 --- /dev/null +++ b/conversion/muse_spark.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from typing import Any, Iterable, TYPE_CHECKING + +import torch + +if TYPE_CHECKING: + from torch import Tensor + +from .base import ModelBase, MmprojModel, TextModel, gguf +from .muse_glimmer import MuseGlimmerVisionModel + + +# The reference clamps the squared stream coefficient with this floor. +RESIDUAL_GATE_EPS = 1e-3 + +# Block norms hold a delta around one; the output norm holds an absolute gain. +OFFSET_NORM_SUFFIXES = ( + "input_layernorm.weight", + "pre_feedforward_layernorm.weight", + "post_feedforward_layernorm.weight", + "pre_expert_norm.weight", + "post_expert_norm.weight", +) + + +def _unpermute_for_rope(tensor: "Tensor", n_heads: int) -> "Tensor": + """Invert transformers' `_permute_for_rope`: HF stores Q/K in rotate_half layout, + llama.cpp consumes the interleaved (NORM) layout. Vision Q/K carry a bias too.""" + if tensor.ndim == 1: + dim1, = tensor.shape + return tensor.view(n_heads, 2, dim1 // n_heads // 2).transpose(1, 2).reshape(dim1) + dim1, dim2 = tensor.shape + return tensor.view(n_heads, 2, dim1 // n_heads // 2, dim2).transpose(1, 2).reshape(dim1, dim2) + + +@ModelBase.register("MuseSparkForConditionalGeneration") +@ModelBase.example("meta-models/Muse-Spark-Open-816B-A42B") +class MuseSparkModel(TextModel): + model_arch = gguf.MODEL_ARCH.MUSE_SPARK + + def set_vocab(self): + self._set_vocab_gpt2() + + from transformers import AutoTokenizer + tok = AutoTokenizer.from_pretrained(self.dir_model) + eot_id = tok.convert_tokens_to_ids("<|eot|>") # ty: ignore[unresolved-attribute] + if isinstance(eot_id, int) and eot_id >= 0: + self.gguf_writer.add_eot_token_id(eot_id) + + def set_gguf_parameters(self): + super().set_gguf_parameters() + hparams = self.hparams + + # MetaP resolves to two scalars + # The output multiplier lands here and the attention scale rides on the synthesized QK-norm weights + self.gguf_writer.add_logit_scale(hparams["output_multiplier"]) + self.gguf_writer.add_final_logit_softcapping(hparams["final_logit_softcapping"]) + + self.gguf_writer.add_post_norm_eps(hparams["post_norm_eps"]) + + # Only the sliding layers carry rope, so the graph takes it from the pattern + sliding = [t == "sliding_attention" for t in hparams["layer_types"]] + theta = hparams["rope_parameters"]["rope_theta"] + assert sliding == [bool(t) for t in hparams["layer_rope_theta"]] + assert all(t in (0, theta) for t in hparams["layer_rope_theta"]) + assert all(t == "sparse" for t in hparams["mlp_layer_types"]) + + self.gguf_writer.add_sliding_window(hparams["sliding_window"]) + self.gguf_writer.add_rope_freq_base_swa(theta) + self.gguf_writer.add_sliding_window_pattern(sliding) + + # Experts live in a latent space narrower than the residual stream. + self.gguf_writer.add_moe_latent_size(hparams["moe_hidden_size"]) + self.gguf_writer.add_expert_feed_forward_length(hparams["moe_intermediate_size"]) + self.gguf_writer.add_expert_weights_scale(hparams["routed_scaling_factor"]) + self.gguf_writer.add_expert_weights_norm(True) + # the config carries no score function, the reference hardcodes sigmoid + self.gguf_writer.add_expert_gating_func(gguf.ExpertGatingFuncType.SIGMOID) + + def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]: + if name.endswith(OFFSET_NORM_SUFFIXES): + data_torch = data_torch + 1.0 + + if name.endswith(".self_attn.q_proj.weight"): + assert bid is not None + n_head = self.hparams["num_attention_heads"] + head_dim = self.hparams["head_dim"] + + yield self.map_tensor_name(name), _unpermute_for_rope(data_torch, n_head) + + # Synthesize QK-norm to carry the MetaP scale so the graph keeps the plain 1 / sqrt(head_dim) + ones = torch.ones(head_dim, dtype=torch.float32) + q_norm = ones * self.hparams["qk_scale_factor"] + yield self.format_tensor_name(gguf.MODEL_TENSOR.ATTN_Q_NORM, bid), q_norm + yield self.format_tensor_name(gguf.MODEL_TENSOR.ATTN_K_NORM, bid), ones + return + + if name.endswith(".self_attn.k_proj.weight"): + n_head_kv = self.hparams["num_key_value_heads"] + yield self.map_tensor_name(name), _unpermute_for_rope(data_torch, n_head_kv) + return + + # gate_up_proj fuses both projections over the output dim, gate first. + if name.endswith(".mlp.experts.gate_up_proj"): + assert bid is not None + n_ff = data_torch.shape[1] // 2 + yield self.format_tensor_name(gguf.MODEL_TENSOR.FFN_GATE_EXP, bid), data_torch[:, :n_ff, :] + yield self.format_tensor_name(gguf.MODEL_TENSOR.FFN_UP_EXP, bid), data_torch[:, n_ff:, :] + return + + if name.endswith(".mlp.experts.down_proj"): + assert bid is not None + yield self.format_tensor_name(gguf.MODEL_TENSOR.FFN_DOWN_EXP, bid), data_torch + return + + # The router scales its logits by the MetaP output multiplier before the sigmoid + # Fold it into the weight to keep a plain matmul + if name.endswith(".mlp.gate.weight"): + data_torch = data_torch.float() * self.hparams["output_multiplier"] + yield from super().modify_tensors(data_torch, name, bid) + return + + # Norm preserving residual gate: alpha scales the stream, beta the branch, with alpha^2 + beta^2 = 1 + # Both follow from the weight, so resolve them here + if name.endswith("_residual_gate.gate"): + assert bid is not None + attn = "post_attention" in name + + w = data_torch.float() / self.hparams["residual_gate_temperature"] + beta = torch.sigmoid(w) + alpha_sq = torch.sigmoid(-w) * (1.0 + beta) + alpha = torch.sqrt(torch.clamp(alpha_sq, min=RESIDUAL_GATE_EPS)) + + t_alpha = gguf.MODEL_TENSOR.ATTN_RESID_ALPHA if attn else gguf.MODEL_TENSOR.FFN_RESID_ALPHA + t_beta = gguf.MODEL_TENSOR.ATTN_RESID_BETA if attn else gguf.MODEL_TENSOR.FFN_RESID_BETA + yield self.format_tensor_name(t_alpha, bid), alpha + yield self.format_tensor_name(t_beta, bid), beta + return + + yield from super().modify_tensors(data_torch, name, bid) + + +@ModelBase.register("MuseSparkForConditionalGeneration") +@ModelBase.example("meta-models/Muse-Spark-Open-816B-A42B") +class MuseSparkVisionModel(MuseGlimmerVisionModel): + has_vision_encoder = True # same vision encoder as Muse Glimmer + has_audio_encoder = True + + def get_audio_config(self) -> dict[str, Any]: + c = self.global_config["audio_config"] + # the base class wants one width and one layer count: use the last stage width and the total number of conv blocks + return { + **c, + "hidden_size": c["hidden_sizes"][-1], + "intermediate_size": int(c["hidden_sizes"][-1] * c["mlp_ratio"]), + "num_attention_heads": 1, + "num_hidden_layers": sum(c["depths"]), + } + + def set_gguf_parameters(self): + super().set_gguf_parameters() + + assert self.hparams_audio + a = self.hparams_audio + + self.gguf_writer.add_clip_audio_projector_type(gguf.VisionProjectorType.MUSE_SPARK_AUDIO) + # the input is the raw waveform, one channel takes the place of the mel bins + self.gguf_writer.add_audio_num_mel_bins(1) + self.gguf_writer.add_audio_attention_layernorm_eps(float(a["rms_norm_eps"])) + self.gguf_writer.add_audio_conv_kernel_size(int(a["conv_kernel_size"])) + self.gguf_writer.add_audio_hidden_sizes([int(x) for x in a["hidden_sizes"]]) + self.gguf_writer.add_audio_patch_sizes([int(x) for x in a["patch_sizes"]]) + self.gguf_writer.add_audio_depths([int(x) for x in a["depths"]]) + + @classmethod + def filter_tensors(cls, item): + name, gen = item + if name.startswith("model.audio_tower."): + return MmprojModel.filter_tensors((name, gen)) + return super().filter_tensors((name, gen)) + + _AUDIO_HEAD = { + "model.audio_tower.norm": gguf.MODEL_TENSOR.A_PRE_NORM, + "model.audio_tower.proj": gguf.MODEL_TENSOR.A_MMPROJ_FC, + "model.audio_tower.post_proj_norm": gguf.MODEL_TENSOR.A_POST_NORM, + } + + def _audio_tensor(self, data_torch, name, suffix): + assert self.hparams_audio + + stem = name[: -len(suffix) - 1] + + if stem in self._AUDIO_HEAD: + return self.format_tensor_name(self._AUDIO_HEAD[stem], suffix="." + suffix), data_torch + + parts = stem.split(".") + stage = int(parts[3]) + + if parts[4] == "patch_embedder": + key = (gguf.MODEL_TENSOR.A_ENC_PATCH_PROJ if parts[5] == "proj" + else gguf.MODEL_TENSOR.A_ENC_PATCH_NORM) + return self.format_tensor_name(key, bid=stage, suffix="." + suffix), data_torch + + # conv blocks are numbered per stage upstream and flat here + depths = [int(d) for d in self.hparams_audio["depths"]] + bid = sum(depths[:stage]) + int(parts[5]) + tail = ".".join(parts[6:]) + key = { + "conv": gguf.MODEL_TENSOR.A_ENC_CONV1D, + "norm": gguf.MODEL_TENSOR.A_ENC_CONV1D_NORM, + "mlp.fc1": gguf.MODEL_TENSOR.A_ENC_FFN_UP, + "mlp.fc2": gguf.MODEL_TENSOR.A_ENC_FFN_DOWN, + }[tail] + return self.format_tensor_name(key, bid=bid, suffix="." + suffix), data_torch + + def modify_tensors(self, data_torch, name, bid): + if name.startswith("model.audio_tower."): + yield self._audio_tensor(data_torch, name, name.rpartition(".")[2]) + return + yield from super().modify_tensors(data_torch, name, bid) diff --git a/convert_hf_to_gguf_update.py b/convert_hf_to_gguf_update.py index 0afbcc7e9..d49fb8f3f 100755 --- a/convert_hf_to_gguf_update.py +++ b/convert_hf_to_gguf_update.py @@ -189,6 +189,8 @@ pre_computed_hashes = [ {"name": "qwen2", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/Qwen/Qwen3-Embedding-0.6B", "chkhsh": "d4540891389ea895b53b399da6ac824becc30f2fba0e9ddbb98f92e55ca0e97c"}, {"name": "qwen35", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/openbmb/MiniCPM-V-4_6", "chkhsh": "1444df51289cfa8063b96f0e62b1125440111bc79a52003ea14b6eac7016fd5f"}, {"name": "grok-2", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/alvarobartt/grok-2-tokenizer", "chkhsh": "66b8d4e19ab16c3bfd89bce5d785fb7e0155e8648708a1f42077cb9fe002c273"}, + # muse-spark uses the l4_202k tokenizer, whose pre-tokenizer regex is gpt-4o's + {"name": "gpt-4o", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/meta-models/Muse-Spark-Open-816B-A42B", "chkhsh": "f5c965e4198da6e7525072d87d1ecf4c51d981fd486c7c4ec728ef9cec7e03fc"}, # jina-v2-de variants {"name": "jina-v2-de", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/aari1995/German_Semantic_V3", "chkhsh": "b3d1dd861f1d4c5c0d2569ce36baf3f90fe8a102db3de50dd71ff860d91be3df"}, {"name": "gpt-2", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/evilfreelancer/ruGPT3XL", "chkhsh": "0fe1cf6eda062318a1af7270f3331a85c539a01778ff948e24388e949c5282f4"}, diff --git a/gguf-py/gguf/constants.py b/gguf-py/gguf/constants.py index a39d046fe..a3a9c478b 100644 --- a/gguf-py/gguf/constants.py +++ b/gguf-py/gguf/constants.py @@ -132,6 +132,7 @@ class Keys: EXPERTS_PER_GROUP = "{arch}.experts_per_group" MOE_EVERY_N_LAYERS = "{arch}.moe_every_n_layers" MOE_LATENT_SIZE = "{arch}.moe_latent_size" + POST_NORM_EPS = "{arch}.post_norm_epsilon" NEXTN_PREDICT_LAYERS = "{arch}.nextn_predict_layers" NUM_DEEPSTACK_LAYERS = "{arch}.n_deepstack_layers" DEEPSTACK_MAPPING = "{arch}.deepstack_mapping" @@ -466,6 +467,9 @@ class Keys: SUBSAMPLING_FACTOR = "clip.audio.subsampling_factor" CHUNK_SIZE = "clip.audio.chunk_size" CONV_KERNEL_SIZE = "clip.audio.conv_kernel_size" + HIDDEN_SIZES = "clip.audio.hidden_sizes" # muse-spark, per stage + PATCH_SIZES = "clip.audio.patch_sizes" # muse-spark, per stage + DEPTHS = "clip.audio.depths" # muse-spark, per stage MAX_POS_EMB = "clip.audio.max_pos_emb" FEATURE_LAYERS = "clip.audio.feature_layer" # Granite Speech Plus RVQ_NUM_QUANTIZERS = "clip.audio.rvq.num_quantizers" @@ -611,6 +615,7 @@ class MODEL_ARCH(IntEnum): OLMO2 = auto() OLMOE = auto() MUSE_GLIMMER = auto() + MUSE_SPARK = auto() OPENELM = auto() ARCTIC = auto() DEEPSEEK = auto() @@ -743,6 +748,8 @@ class MODEL_TENSOR(IntEnum): ATTN_ROT_EMBD = auto() ATTN_SINKS = auto() ATTN_GATE = auto() + ATTN_RESID_ALPHA = auto() + ATTN_RESID_BETA = auto() FFN_GATE_INP = auto() FFN_GATE_INP_SHEXP = auto() FFN_NORM = auto() @@ -771,6 +778,10 @@ class MODEL_TENSOR(IntEnum): FFN_GATE_TID2EID = auto() MOE_LATENT_DOWN = auto() # nemotron 3 super MOE_LATENT_UP = auto() # nemotron 3 super + FFN_RESID_ALPHA = auto() + FFN_RESID_BETA = auto() + FFN_PRE_EXP_NORM = auto() + FFN_POST_EXP_NORM = auto() ATTN_Q_NORM = auto() ATTN_K_NORM = auto() LAYER_OUT_NORM = auto() @@ -1121,6 +1132,8 @@ class MODEL_TENSOR(IntEnum): A_ENC_EMBD_TO_LOGITS = auto() # lfm2 A_ENC_INP_PROJ = auto() # gemma4 A_ENC_CONV1D = auto() + A_ENC_PATCH_PROJ = auto() # muse-spark + A_ENC_PATCH_NORM = auto() # muse-spark A_ENC_CONV1D_NORM = auto() # gemma3n A_ENC_CONV2D = auto() A_ENC_CONV_OUT = auto() @@ -1415,6 +1428,7 @@ MODEL_ARCH_NAMES: dict[MODEL_ARCH, str] = { MODEL_ARCH.OLMO2: "olmo2", MODEL_ARCH.OLMOE: "olmoe", MODEL_ARCH.MUSE_GLIMMER: "muse-glimmer", + MODEL_ARCH.MUSE_SPARK: "muse-spark", MODEL_ARCH.OPENELM: "openelm", MODEL_ARCH.ARCTIC: "arctic", MODEL_ARCH.DEEPSEEK: "deepseek", @@ -1544,6 +1558,8 @@ TENSOR_NAMES: dict[MODEL_TENSOR, str] = { MODEL_TENSOR.ATTN_ROT_EMBD: "blk.{bid}.attn_rot_embd", MODEL_TENSOR.ATTN_SINKS: "blk.{bid}.attn_sinks", MODEL_TENSOR.ATTN_GATE: "blk.{bid}.attn_gate", + MODEL_TENSOR.ATTN_RESID_ALPHA: "blk.{bid}.attn_resid_alpha", + MODEL_TENSOR.ATTN_RESID_BETA: "blk.{bid}.attn_resid_beta", MODEL_TENSOR.ATTN_Q_NORM: "blk.{bid}.attn_q_norm", MODEL_TENSOR.ATTN_K_NORM: "blk.{bid}.attn_k_norm", MODEL_TENSOR.ATTN_OUT_NORM: "blk.{bid}.attn_output_norm", @@ -1576,6 +1592,10 @@ TENSOR_NAMES: dict[MODEL_TENSOR, str] = { MODEL_TENSOR.FFN_GATE_TID2EID: "blk.{bid}.ffn_gate_tid2eid", MODEL_TENSOR.MOE_LATENT_DOWN: "blk.{bid}.ffn_latent_down", # nemotron 3 super MODEL_TENSOR.MOE_LATENT_UP: "blk.{bid}.ffn_latent_up", # nemotron 3 super + MODEL_TENSOR.FFN_RESID_ALPHA: "blk.{bid}.ffn_resid_alpha", + MODEL_TENSOR.FFN_RESID_BETA: "blk.{bid}.ffn_resid_beta", + MODEL_TENSOR.FFN_PRE_EXP_NORM: "blk.{bid}.ffn_pre_exp_norm", + MODEL_TENSOR.FFN_POST_EXP_NORM: "blk.{bid}.ffn_post_exp_norm", MODEL_TENSOR.LAYER_OUT_NORM: "blk.{bid}.layer_output_norm", MODEL_TENSOR.LAYER_OUT_SCALE: "blk.{bid}.layer_output_scale", MODEL_TENSOR.PER_LAYER_TOKEN_EMBD: "per_layer_token_embd", # gemma3n @@ -1921,6 +1941,8 @@ TENSOR_NAMES: dict[MODEL_TENSOR, str] = { MODEL_TENSOR.A_ENC_EMBD_TO_LOGITS: "a.embd_to_logits", MODEL_TENSOR.A_ENC_INP_PROJ: "a.input_projection", MODEL_TENSOR.A_ENC_CONV1D: "a.conv1d.{bid}", + MODEL_TENSOR.A_ENC_PATCH_PROJ: "a.patch.{bid}", + MODEL_TENSOR.A_ENC_PATCH_NORM: "a.patch.{bid}.norm", MODEL_TENSOR.A_ENC_CONV2D: "a.conv2d.{bid}", MODEL_TENSOR.A_ENC_CONV_OUT: "a.conv_out", MODEL_TENSOR.A_ENC_CONV1D_NORM: "a.conv1d.{bid}.norm", @@ -2273,6 +2295,8 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = { MODEL_TENSOR.A_ENC_EMBD_TO_LOGITS, MODEL_TENSOR.A_ENC_INP_PROJ, MODEL_TENSOR.A_ENC_CONV1D, + MODEL_TENSOR.A_ENC_PATCH_PROJ, + MODEL_TENSOR.A_ENC_PATCH_NORM, MODEL_TENSOR.A_ENC_CONV2D, MODEL_TENSOR.A_ENC_CONV_OUT, MODEL_TENSOR.A_ENC_CONV1D_NORM, @@ -3979,6 +4003,34 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = { MODEL_TENSOR.FFN_PRE_NORM, MODEL_TENSOR.FFN_POST_NORM, ], + MODEL_ARCH.MUSE_SPARK: [ + MODEL_TENSOR.TOKEN_EMBD, + MODEL_TENSOR.OUTPUT, + MODEL_TENSOR.OUTPUT_NORM, + MODEL_TENSOR.ATTN_NORM, + MODEL_TENSOR.ATTN_Q, + MODEL_TENSOR.ATTN_Q_NORM, + MODEL_TENSOR.ATTN_K, + MODEL_TENSOR.ATTN_K_NORM, + MODEL_TENSOR.ATTN_V, + MODEL_TENSOR.ATTN_OUT, + MODEL_TENSOR.ATTN_GATE, + MODEL_TENSOR.ATTN_RESID_ALPHA, + MODEL_TENSOR.ATTN_RESID_BETA, + MODEL_TENSOR.FFN_PRE_NORM, + MODEL_TENSOR.FFN_POST_NORM, + MODEL_TENSOR.FFN_RESID_ALPHA, + MODEL_TENSOR.FFN_RESID_BETA, + MODEL_TENSOR.FFN_GATE_INP, + MODEL_TENSOR.FFN_EXP_PROBS_B, + MODEL_TENSOR.MOE_LATENT_DOWN, + MODEL_TENSOR.FFN_PRE_EXP_NORM, + MODEL_TENSOR.FFN_GATE_EXP, + MODEL_TENSOR.FFN_UP_EXP, + MODEL_TENSOR.FFN_DOWN_EXP, + MODEL_TENSOR.FFN_POST_EXP_NORM, + MODEL_TENSOR.MOE_LATENT_UP, + ], MODEL_ARCH.OPENELM: [ MODEL_TENSOR.TOKEN_EMBD, MODEL_TENSOR.OUTPUT_NORM, @@ -6283,6 +6335,7 @@ class VisionProjectorType: COHERE2V = "cohere2v" D1OMNI_V = "d1omni_v" # lfm2 vision, without separator tokens D1OMNI_A = "d1omni_a" # lfm2a audio, with a residual block after the projector + MUSE_SPARK_AUDIO = "muse-spark-audio" # Items here are (block size, type size) diff --git a/gguf-py/gguf/gguf_writer.py b/gguf-py/gguf/gguf_writer.py index cc2bd8e7b..a1b1f1263 100644 --- a/gguf-py/gguf/gguf_writer.py +++ b/gguf-py/gguf/gguf_writer.py @@ -924,6 +924,9 @@ class GGUFWriter: def add_moe_latent_size(self, value: int) -> None: self.add_uint32(Keys.LLM.MOE_LATENT_SIZE.format(arch=self.arch), value) + def add_post_norm_eps(self, value: float) -> None: + self.add_float32(Keys.LLM.POST_NORM_EPS.format(arch=self.arch), value) + def add_nextn_predict_layers(self, count: int) -> None: self.add_uint32(Keys.LLM.NEXTN_PREDICT_LAYERS.format(arch=self.arch), count) @@ -1591,6 +1594,15 @@ class GGUFWriter: def add_audio_wa_pattern_mode(self, modes: Sequence[int]) -> None: self.add_array(Keys.ClipAudio.WA_PATTERN_MODE, modes) + def add_audio_hidden_sizes(self, values: Sequence[int]) -> None: + self.add_array(Keys.ClipAudio.HIDDEN_SIZES, values) + + def add_audio_patch_sizes(self, values: Sequence[int]) -> None: + self.add_array(Keys.ClipAudio.PATCH_SIZES, values) + + def add_audio_depths(self, values: Sequence[int]) -> None: + self.add_array(Keys.ClipAudio.DEPTHS, values) + def add_audio_window_size(self, value: int) -> None: self.add_uint32(Keys.ClipAudio.WINDOW_SIZE, value) diff --git a/gguf-py/gguf/tensor_mapping.py b/gguf-py/gguf/tensor_mapping.py index 63301cc11..ecea46463 100644 --- a/gguf-py/gguf/tensor_mapping.py +++ b/gguf-py/gguf/tensor_mapping.py @@ -530,6 +530,7 @@ class TensorNameMap: MODEL_TENSOR.FFN_EXP_PROBS_B: ( "model.layers.{bid}.mlp.gate.e_score_correction", # deepseek-v3 dots1 + "model.layers.{bid}.mlp.gate.e_score_correction_bias", # muse-spark "model.layers.{bid}.mlp.moe_statics.e_score_correction", # ernie4.5-moe "model.layers.{bid}.mlp.gate.expert_bias", # bailingmoe2 "model.layers.{bid}.mlp.expert_bias", # afmoe @@ -674,10 +675,20 @@ class TensorNameMap: MODEL_TENSOR.MOE_LATENT_DOWN: ( "backbone.layers.{bid}.mixer.fc1_latent_proj", # nemotron 3 super + "model.layers.{bid}.mlp.pre_expert_proj", # muse-spark ), MODEL_TENSOR.MOE_LATENT_UP: ( "backbone.layers.{bid}.mixer.fc2_latent_proj", # nemotron 3 super + "model.layers.{bid}.mlp.post_expert_proj", # muse-spark + ), + + MODEL_TENSOR.FFN_PRE_EXP_NORM: ( + "model.layers.{bid}.mlp.pre_expert_norm", # muse-spark + ), + + MODEL_TENSOR.FFN_POST_EXP_NORM: ( + "model.layers.{bid}.mlp.experts.post_expert_norm", # muse-spark ), # Feed-forward down diff --git a/models/templates/muse-spark.jinja b/models/templates/muse-spark.jinja new file mode 100644 index 000000000..8fc6c8df2 --- /dev/null +++ b/models/templates/muse-spark.jinja @@ -0,0 +1,228 @@ +{%- set tools = tools | default(none) -%} +{%- set add_generation_prompt = add_generation_prompt | default(false) -%} +{%- set reasoning_effort = reasoning_effort | default(none) -%} +{%- set disable_reasoning = disable_reasoning | default(false) -%} +{%- set cutoff_date = cutoff_date | default('2026-01-04') -%} +{%- set current_date = current_date | default(strftime_now('%Y-%m-%d') if strftime_now is defined else none) -%} +{%- set reasoning_effort_map = {'minimal': 1, 'low': 16, 'medium': 64, 'high': 256} -%} + +{#- Neutralise control sequences in caller-supplied text so it cannot be + tokenized as structure. "<|" is broken because every special token has the + form "<|...|>"; the bare media placeholders are broken because the + multimodal processor rewrites them positionally and a literal one in text + would desynchronise placeholders from the attached media. -#} +{%- macro esc(value) -%} + {{- value | string + | replace('<|', '< |') + | replace('', '< image>') + | replace('