diff --git a/conversion/__init__.py b/conversion/__init__.py index 3976e03..25c7fdf 100644 --- a/conversion/__init__.py +++ b/conversion/__init__.py @@ -279,6 +279,8 @@ TEXT_MODEL_MAP: dict[str, str] = { "Qwen3_5TextModel": "qwen", "Qwen4ExpForCausalLM": "qwen4exp", "Qwen4ExpForConditionalGeneration": "qwen4exp", + # Youtu-LLM-A7B 的视觉塔是 Qwen3-VL ViT + merger(deepstack_visual_indexes 为空) + "YoutuVLMoEForConditionalGeneration": "youtu_vl_vision", "RND1": "qwen", "RWForCausalLM": "falcon", "RWKV6Qwen2ForCausalLM": "rwkv", @@ -320,12 +322,14 @@ TEXT_MODEL_MAP: dict[str, str] = { "XverseForCausalLM": "xverse", "YoutuForCausalLM": "deepseek", "YoutuVLForConditionalGeneration": "deepseek", + "YoutuVLMoEForConditionalGeneration": "youtu_moe", "modeling_grove_moe.GroveMoeForCausalLM": "grovemoe", "modeling_sarvam_moe.SarvamMoEForCausalLM": "bailingmoe", } MMPROJ_MODEL_MAP: dict[str, str] = { + "YoutuVLMoEForConditionalGeneration": "youtu_vl_vision", "AudioFlamingo3ForConditionalGeneration": "ultravox", "ClefModel": "clef", "CogVLMForCausalLM": "cogvlm", diff --git a/conversion/youtu_moe.py b/conversion/youtu_moe.py new file mode 100644 index 0000000..a00f9b5 --- /dev/null +++ b/conversion/youtu_moe.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Callable, Iterable, Iterator, TYPE_CHECKING + +import torch + +if TYPE_CHECKING: + from torch import Tensor + +from .base import ModelBase, TextModel, gguf, logger + + +@ModelBase.register("YoutuVLMoEForConditionalGeneration") +class YoutuMoEModel(TextModel): + model_arch = gguf.MODEL_ARCH.YOUTU_MOE + + _LAYER_RE = re.compile(r"^model\.layers\.(\d+)\.(.+)$") + _EXPERT_RE = re.compile(r"^mlp\.experts\.(\d+)\.(gate_proj|down_proj|up_proj)\.weight$") + _EXPERT_TYPES = { + "gate_proj": gguf.MODEL_TENSOR.FFN_GATE_EXP, + "down_proj": gguf.MODEL_TENSOR.FFN_DOWN_EXP, + "up_proj": gguf.MODEL_TENSOR.FFN_UP_EXP, + } + _CONFIG = { + "architectures": ["YoutuVLMoEForConditionalGeneration"], + "model_type": "youtu_vl_moe", + "hidden_size": 3072, "num_hidden_layers": 48, "vocab_size": 129280, + "intermediate_size": 12288, "max_position_embeddings": 262144, + "num_attention_heads": 32, "num_key_value_heads": 32, "head_dim": 128, + "rms_norm_eps": 1e-6, "rope_theta": 1600000.0, "rope_scaling": None, + "hidden_act": "silu", "attention_bias": False, "tie_word_embeddings": False, + "q_lora_rank": 1024, "kv_lora_rank": 512, + "qk_nope_head_dim": 128, "qk_rope_head_dim": 64, "v_head_dim": 128, + "multi_latent_attention": True, "attention_output_gate": True, + "mla_output_gate_type": "head_wise", "experimental_attention_variant": "kda", + "linear_attention_pattern": [1, 1, 1, 0] * 12, + "short_conv_kernel_size": 4, "use_full_rank_gate": True, + "kda_gate_lower_bound": -5.0, + "first_k_dense_replace": 1, "n_routed_experts": 256, "num_experts_per_tok": 8, + "n_shared_experts": 1, "moe_intermediate_size": 1024, + "moe_shared_experts_intermediate_size": 1024, + "enable_multimodal_shared_experts": False, + "moe_router_modality_aware_expert_bias": True, + "moe_router_token_level_modality": True, + "n_group": 1, "topk_group": 1, "norm_topk_prob": True, + "routed_scaling_factor": 2.83, "scoring_func": "sigmoid", + "topk_method": "quantile_balancing", "moe_latent_size": None, + "moe_latent_rmsnorm": False, + "bos_token_id": 0, "eos_token_id": 1, "pad_token_id": 0, + } + # 官方 config 的 mrope_section 是 [12,10,10](sum=32=qk_rope_head_dim/2)。 + # 但 ggml 的 IMROPE 用 sector = freq_index % sum(sections) 取 THW,且 + # freq 31(12+10+10 之外的最后一个)在官方实现里落回 T,在 ggml 里会落进 + # "extra" 槽位取 pos[3](文本 token 的 pos[3] 恒为 0,会算错)。 + # 令 sections[0]=11 使 sum=31:freq 31 → sector 0 → T,其余 freq 的 sector + # 不变,于是与官方逐频率一致,且不再依赖 pos[3]。见 modeling 的 + # YoutuVLRotaryEmbedding._apply_interleaved_mrope。 + _MROPE_SECTIONS = [11, 10, 10, 0] + + _PRE_REGEX = ( + r"\p{N}{1,3}", + "[一-龥぀-ゟ゠-ヿ]+", + r"""[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~][A-Za-z]+|[^""" + "\r\n" + + r"\p{L}\p{P}\p{S}]?[\p{L}\p{M}]+| ?[\p{P}\p{S}]+[" + "\r\n" + + r"]*|\s*[" + "\r\n" + r"]+|\s+(?!\S)|\s+", + ) + + def __init__(self, *args, **kwargs): + hparams = kwargs.get("hparams") + if hparams is None: + dir_model = args[0] if args else kwargs["dir_model"] + hparams = ModelBase.load_hparams(Path(dir_model), False) + kwargs["hparams"] = hparams + self._validate_config(hparams) + if kwargs.get("fuse_qkv") or kwargs.get("fuse_gate_up_exps"): + raise ValueError("Youtu MoE 必须保留独立 Q/K/V 和 gate/down/up 专家张量") + self._experts: dict[tuple[int, str], dict[int, Tensor]] = {} + super().__init__(*args, **kwargs) + + @classmethod + def _validate_config(cls, hparams: dict) -> None: + for key, expected in cls._CONFIG.items(): + if key not in hparams or hparams[key] != expected: + raise ValueError(f"不支持的 Youtu 配置 {key}: {hparams.get(key)!r},预期 {expected!r}") + rope = hparams.get("rope_parameters", {}) + if rope.get("rope_type") != "default" or rope.get("rope_theta") != 1600000.0: + raise ValueError("Youtu 文本转换仅支持默认 RoPE,theta=1600000") + if hparams.get("quantization_config") or "text_config" in hparams: + raise ValueError("仅支持官方未量化的 Youtu-LLM-A7B-Base 配置") + if any(hparams.get(k) for k in ("mla_use_nope", "attn_res_block_size", "routed_expert_hidden_size")): + raise ValueError("Youtu MoE 不支持 nope-only、跨层残差或 latent MoE") + + @classmethod + def _normalize_name(cls, name: str) -> str | None: + if name.startswith("model.visual."): + return None + if name.startswith("model.language_model."): + name = "model." + name.removeprefix("model.language_model.") + layer = cls._LAYER_RE.fullmatch(name) + if layer is not None and int(layer[1]) >= 48: + return None # 官方 MTP 层位于普通文本层之后。 + return name + + @classmethod + def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None: + # 不使用 TextModel 的宽泛多模态过滤;其它未知名称必须报错。 + return None if cls._normalize_name(item[0]) is None else item + + @classmethod + def _layer_specs(cls, bid: int) -> dict[str, tuple[gguf.MODEL_TENSOR | None, tuple[int, ...]]]: + t = gguf.MODEL_TENSOR + specs = { + "input_layernorm.weight": (t.ATTN_NORM, (3072,)), + "post_attention_layernorm.weight": (t.FFN_NORM, (3072,)), + } + if bid % 4 != 3: + specs.update({ + "self_attn.qkv_proj.weight": (None, (12288, 3072)), + "self_attn.conv1d.weight": (None, (12288, 1, 4)), + "self_attn.f_a_proj.weight": (t.SSM_F_A, (128, 3072)), + "self_attn.f_b_proj.weight": (t.SSM_F_B, (4096, 128)), + "self_attn.b_proj.weight": (t.SSM_BETA, (32, 3072)), + "self_attn.A_log": (t.SSM_A, (32,)), + "self_attn.dt_bias": (t.SSM_DT, (4096,)), + "self_attn.g_proj.weight": (t.SSM_G, (4096, 3072)), + "self_attn.out_norm.weight": (t.SSM_NORM, (128,)), + "self_attn.out_proj.weight": (t.ATTN_OUT, (3072, 4096)), + }) + else: + specs.update({ + "self_attn.q_a_proj.weight": (t.ATTN_Q_A, (1024, 3072)), + "self_attn.q_a_layernorm.weight": (t.ATTN_Q_A_NORM, (1024,)), + "self_attn.q_b_proj.weight": (t.ATTN_Q_B, (6144, 1024)), + "self_attn.kv_a_proj_with_mqa.weight": (t.ATTN_KV_A_MQA, (576, 3072)), + "self_attn.kv_a_layernorm.weight": (t.ATTN_KV_A_NORM, (512,)), + "self_attn.kv_b_proj.weight": (None, (8192, 512)), + "self_attn.g_proj.weight": (t.ATTN_GATE, (32, 3072)), + "self_attn.o_proj.weight": (t.ATTN_OUT, (3072, 4096)), + }) + if bid == 0: + specs.update({ + "mlp.gate_proj.weight": (t.FFN_GATE, (12288, 3072)), + "mlp.down_proj.weight": (t.FFN_DOWN, (3072, 12288)), + "mlp.up_proj.weight": (t.FFN_UP, (12288, 3072)), + }) + else: + specs.update({ + "mlp.gate.weight": (t.FFN_GATE_INP, (256, 3072)), + "mlp.gate.qb_beta": (t.FFN_EXP_PROBS_B, (2, 256)), + "mlp.shared_experts.gate_proj.weight": (t.FFN_GATE_SHEXP, (1024, 3072)), + "mlp.shared_experts.down_proj.weight": (t.FFN_DOWN_SHEXP, (3072, 1024)), + "mlp.shared_experts.up_proj.weight": (t.FFN_UP_SHEXP, (1024, 3072)), + }) + return specs + + @classmethod + def _tensor_spec(cls, name: str) -> tuple[gguf.MODEL_TENSOR | None, tuple[int, ...], int | None, str]: + globals_ = { + "model.embed_tokens.weight": (gguf.MODEL_TENSOR.TOKEN_EMBD, (129280, 3072)), + "model.norm.weight": (gguf.MODEL_TENSOR.OUTPUT_NORM, (3072,)), + "lm_head.weight": (gguf.MODEL_TENSOR.OUTPUT, (129280, 3072)), + } + if name in globals_: + tensor_id, shape = globals_[name] + return tensor_id, shape, None, name + layer = cls._LAYER_RE.fullmatch(name) + if layer is not None: + bid, part = int(layer[1]), layer[2] + if 0 <= bid < 48: + expert = cls._EXPERT_RE.fullmatch(part) + if expert is not None and bid > 0 and int(expert[1]) < 256: + shape = (3072, 1024) if expert[2] == "down_proj" else (1024, 3072) + return cls._EXPERT_TYPES[expert[2]], shape, bid, part + spec = cls._layer_specs(bid).get(part) + if spec is not None: + return spec[0], spec[1], bid, part + raise ValueError(f"无法映射 Youtu 张量 {name!r}") + + @classmethod + def _required_names(cls) -> set[str]: + names = {"model.embed_tokens.weight", "model.norm.weight", "lm_head.weight"} + for bid in range(48): + names.update(f"model.layers.{bid}.{part}" for part in cls._layer_specs(bid)) + if bid > 0: + names.update(f"model.layers.{bid}.mlp.experts.{eid}.{proj}.weight" + for eid in range(256) for proj in cls._EXPERT_TYPES) + return names + + def _validate_tensor_inventory(self) -> None: + actual = {n for raw in self.model_tensors if (n := self._normalize_name(raw)) is not None} + required = self._required_names() + missing, extra = sorted(required - actual), sorted(actual - required) + if missing or extra: + raise ValueError(f"Youtu 文本 checkpoint 不完整或存在未知张量:" + f"缺失 {len(missing)} 个 {missing[:3]};未知 {len(extra)} 个 {extra[:3]}") + + def get_tensors(self) -> Iterator[tuple[str, Tensor]]: + for raw_name, gen in self.model_tensors.items(): + name = self._normalize_name(raw_name) + if name is not None: + self._tensor_spec(name) # 在读取权重前拒绝未知名称。 + yield name, gen() + + def prepare_tensors(self): + self._validate_config(self.hparams) + self._validate_tensor_inventory() + super().prepare_tensors() + if self._experts: + raise ValueError(f"未完成的 Youtu 专家组:{sorted(self._experts)}") + + def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]: + name = self._normalize_name(name) + if name is None: + return + tensor_id, shape, layer_id, part = self._tensor_spec(name) + if tuple(data_torch.shape) != shape: + raise ValueError(f"Youtu 张量 {name} 形状 {tuple(data_torch.shape)},预期 {shape}") + if bid != layer_id: + raise ValueError(f"Youtu 张量 {name} 的层号 {bid} 与 {layer_id} 不一致") + + if part == "self_attn.qkv_proj.weight": + for i, key in enumerate((gguf.MODEL_TENSOR.ATTN_Q, gguf.MODEL_TENSOR.ATTN_K, gguf.MODEL_TENSOR.ATTN_V)): + yield self.format_tensor_name(key, bid), data_torch[i * 4096:(i + 1) * 4096] + return + if part == "self_attn.conv1d.weight": + for i, key in enumerate((gguf.MODEL_TENSOR.SSM_CONV1D_Q, gguf.MODEL_TENSOR.SSM_CONV1D_K, gguf.MODEL_TENSOR.SSM_CONV1D_V)): + conv = data_torch[i * 4096:(i + 1) * 4096].float().reshape(1, 4096, 1, 4) + yield self.format_tensor_name(key, bid), conv + return + if part == "self_attn.kv_b_proj.weight": + # 原始 32 个 MLA head,不是压缩 cache 的单 KV head。 + kv = data_torch.reshape(32, 256, 512) + yield self.format_tensor_name(gguf.MODEL_TENSOR.ATTN_K_B, bid), kv[:, :128].transpose(1, 2).contiguous() + yield self.format_tensor_name(gguf.MODEL_TENSOR.ATTN_V_B, bid), kv[:, 128:].contiguous() + return + + if part == "self_attn.q_b_proj.weight": + # 官方 MLA 先 cat(q[0::2], q[1::2]) 再 rotate_half,等价于相邻维成对旋转; + # ggml IMROPE 是前后半成对旋转。把每个 head 的 64 维 rope 段重排为 + # [偶数维..., 奇数维...],两种旋转逐元素等价(freq 索引不变)。 + q = data_torch.reshape(32, 128 + 64, -1) + pe = q[:, 128:] + data_torch = torch.cat( + [q[:, :128], pe[:, 0::2], pe[:, 1::2]], dim=1).reshape(32 * (128 + 64), -1) + elif part == "self_attn.kv_a_proj_with_mqa.weight": + # [kv_lora_rank + k_rope, hidden],尾部 64 维 k_pe 同样重排为 NeoX 布局。 + pe = data_torch[512:] + data_torch = torch.cat([data_torch[:512], pe[0::2], pe[1::2]], dim=0) + + expert = self._EXPERT_RE.fullmatch(part) + if expert is not None: + eid, proj = int(expert[1]), expert[2] + group = self._experts.setdefault((bid, proj), {}) + if eid in group: + raise ValueError(f"重复的 Youtu 专家 {name}") + group[eid] = data_torch + if len(group) == 256: + stacked = torch.stack([group[e] for e in range(256)], dim=0) + del self._experts[(bid, proj)] + yield self.format_tensor_name(self._EXPERT_TYPES[proj], bid), stacked + return + + suffix = ".weight" + if part == "self_attn.A_log": + data_torch, suffix = -torch.exp(data_torch.float()), "" + elif part == "self_attn.dt_bias": + data_torch, suffix = data_torch.float(), ".bias" + elif part == "mlp.gate.qb_beta": + # 官方 checkpoint 的 qb_beta 是 [2, n_expert]:第 0 行文本、第 1 行图像/视频。 + # modeling 的 YoutuVLTopkRouter._bias_for_tokens 逐 token 选行 + # (modality_id==MODALITY_VISION 取第 1 行)。两行都保留,runtime 用 + # ggml_get_rows 按 token 模态取行,所以这里只做 F32 与后缀处理。 + data_torch, suffix = data_torch.float(), ".bias" + if tensor_id is None: + raise ValueError(f"Youtu 张量 {name} 缺少转换规则") + new_name = self.format_tensor_name(tensor_id, bid, suffix) + if self.tensor_force_quant(name, new_name, bid, data_torch.ndim) == gguf.GGMLQuantizationType.F32: + data_torch = data_torch.float() + yield new_name, data_torch + + def tensor_force_quant(self, name: str, new_name: str, bid: int | None, n_dims: int) -> gguf.GGMLQuantizationType | bool: + if n_dims <= 1 or new_name.endswith("_norm.weight") or ".ssm_" in new_name or ( + self.match_model_tensor_name(new_name, gguf.MODEL_TENSOR.FFN_GATE_INP, bid) + or self.match_model_tensor_name(new_name, gguf.MODEL_TENSOR.FFN_EXP_PROBS_B, bid, ".bias") + ): + return gguf.GGMLQuantizationType.F32 + return False + + def set_gguf_parameters(self): + self._validate_config(self.hparams) + # 不调用 TextModel.set_gguf_parameters:它会写入原始 head_dim/KV head 和 MRoPE sections。 + w = self.gguf_writer + w.add_block_count(48) + w.add_context_length(262144) + w.add_embedding_length(3072) + w.add_feed_forward_length(12288) + w.add_vocab_size(129280) + w.add_head_count(32) + w.add_head_count_kv([0, 0, 0, 1] * 12) + w.add_layer_norm_rms_eps(1e-6) + w.add_rope_dimension_count(64) + w.add_rope_freq_base(1600000.0) + w.add_rope_scaling_type(gguf.RopeScalingType.NONE) + # runtime 使用 LLAMA_ROPE_TYPE_IMROPE(交错 M-RoPE),文本 token 的 + # t/h/w 位置相同,退化为普通 RoPE。 + w.add_rope_dimension_sections(self._MROPE_SECTIONS) + w.add_key_length(576) + w.add_value_length(512) + w.add_key_length_mla(192) + w.add_value_length_mla(128) + w.add_q_lora_rank(1024) + w.add_kv_lora_rank(512) + w.add_ssm_conv_kernel(4) + w.add_kda_head_dim(128) + w.add_kda_gate_lower_bound(-5.0) + w.add_leading_dense_block_count(1) + w.add_expert_count(256) + w.add_expert_used_count(8) + w.add_expert_shared_count(1) + w.add_expert_feed_forward_length(1024) + w.add_expert_shared_feed_forward_length(1024) + w.add_expert_weights_scale(2.83) + w.add_expert_weights_norm(True) + w.add_expert_gating_func(gguf.ExpertGatingFuncType.SIGMOID) + w.add_file_type(self.ftype) + + def get_vocab_base_pre(self, tokenizer) -> str: + expected = { + "type": "Sequence", + "pretokenizers": [ + {"type": "Split", "pattern": {"Regex": regex}, "behavior": "Isolated", "invert": False} + for regex in self._PRE_REGEX + ] + [{"type": "ByteLevel", "add_prefix_space": False, "trim_offsets": True, "use_regex": False}], + } + if tokenizer.get("model", {}).get("type") != "BPE" or tokenizer.get("pre_tokenizer") != expected: + raise ValueError("Youtu tokenizer 必须是使用真实 deepseek-v3 regex 的 ByteLevel BPE") + if tokenizer.get("normalizer") not in (None, {"type": "Sequence", "normalizers": []}): + raise ValueError("Youtu tokenizer 不应包含额外 normalizer") + return "deepseek-v3" + + def get_vocab_base(self) -> tuple[list[str], list[int], str]: + # 直接解析官方 JSON,绝不加载或执行仓库内的模型/tokenizer Python 代码。 + tokenizer = json.loads((self.dir_model / "tokenizer.json").read_text(encoding="utf-8")) + pre = self.get_vocab_base_pre(tokenizer) + vocab = dict(tokenizer["model"]["vocab"]) + added = {t["id"]: t for t in tokenizer["added_tokens"]} + for entry in added.values(): + vocab[entry["content"]] = entry["id"] + reverse = {idx: token for token, idx in vocab.items()} + if len(reverse) != len(vocab) or any(not isinstance(i, int) or i < 0 or i >= 129280 for i in reverse): + raise ValueError("Youtu tokenizer 词表存在重复或越界 ID") + if len(reverse) != 129280 - 465: + raise ValueError("官方 Youtu 词表应包含 128815 个 token 和 465 个 UNUSED 槽位") + if reverse.get(0) != "<|begin▁of▁sentence|>" or reverse.get(1) != "<|end▁of▁sentence|>": + raise ValueError("Youtu tokenizer BOS/EOS ID 不匹配") + tokens, types = [], [] + for idx in range(129280): + token = reverse.get(idx) + if token is None: + tokens.append(f"[PAD{idx}]") + types.append(gguf.TokenType.UNUSED) + continue + token_type = gguf.TokenType.NORMAL + if idx in added: + token_type = (gguf.TokenType.CONTROL if added[idx]["special"] or self.does_token_look_special(token) + else gguf.TokenType.USER_DEFINED) + if token_type == gguf.TokenType.USER_DEFINED: + token = token.replace("▁", " ") + tokens.append(token) + types.append(token_type) + return tokens, types, pre + + def set_vocab(self): + self._set_vocab_gpt2() + # SpecialVocab 已读取官方模板和 merges;显式固定文本续写的特殊 token 契约。 + self.gguf_writer.add_bos_token_id(0) + self.gguf_writer.add_eos_token_id(1) + self.gguf_writer.add_pad_token_id(0) + self.gguf_writer.add_add_bos_token(True) + self.gguf_writer.add_add_eos_token(False) + # 覆盖 _set_vocab_gpt2 写入的官方模板,见 _patched_chat_template 的说明。 + patched = self._patched_chat_template() + if patched is not None: + self.gguf_writer.add_chat_template(patched) + + def _patched_chat_template(self): + """把非思考分支的 `` 改成 ``。 + + 官方模板在 enable_thinking=false 时把结尾从 `` 换成 ``, + 对 llama.cpp 的模板差分分析器(common/chat-diff-analyzer.cpp 的 + compare_thinking_enabled)而言这是"替换式"差异:它只认"追加式"差异, + 尾部 64 字符锚点在另一侧永远找不到,于是 reasoning_mode 判定为 NONE。 + 改成 `` 后两种渲染互为前缀延伸,分析器可识别出 + reasoning_start=`` / reasoning_end=``(实测 -lv 5 输出 + `reasoning_mode: TAG_BASED`)。语义完全等价:两者都表示"空的思考块"。 + + 注意:仅靠本补丁**不足以**剥掉 content 里的 ``。llama.cpp 生成 + 解析器时用 `p.prefix(generation_prompt, reasoning_start)`,那是 + `literal(提示词中 之前的部分)`,而解析输入只有生成文本,前缀 + 匹配不上 → 整条 PEG 解析失败退回 content-only。因此输出侧仍需要 + strip_think.py 兜底。保留本补丁是为了让 reasoning 模式判定正确 + (模型若真的输出 …,可被归入 reasoning_content)。 + """ + path = self.dir_model / "chat_template.jinja" + if not path.is_file(): + logger.warning("未找到 chat_template.jinja,跳过模板补丁") + return None + text = path.read_text(encoding="utf-8") + pairs = [ + ("{{- '' if thinking_mode != 'thinking' else '' }}", + "{{- '' if thinking_mode != 'thinking' else '' }}"), + (" {%- else -%}\n {{- '' }}\n {%- endif -%}", + " {%- else -%}\n {{- '' }}\n {%- endif -%}"), + ] + for old, new in pairs: + if text.count(old) != 1: + logger.warning("chat_template.jinja 结构已变化(匹配 %d 处),跳过模板补丁", text.count(old)) + return None + text = text.replace(old, new) + logger.info("已应用 chat template 补丁:非思考分支输出 (推理标记可被差分分析器识别)") + return text diff --git a/conversion/youtu_vl_vision.py b/conversion/youtu_vl_vision.py new file mode 100644 index 0000000..00778eb --- /dev/null +++ b/conversion/youtu_vl_vision.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from .base import ModelBase, gguf +from .qwen3vl import Qwen3VLVisionModel + + +@ModelBase.register("YoutuVLMoEForConditionalGeneration") +@ModelBase.example("tencent/Youtu-LLM-A7B-Base") +class YoutuVLVisionModel(Qwen3VLVisionModel): + """Youtu-LLM-A7B 的视觉塔:Qwen3-VL ViT + merger,deepstack 为空。 + + 只导出视觉权重(mmproj)。文本主干由 conversion/youtu_moe.py 处理。 + 张量命名与 Qwen3-VL 一致(model.visual.*),因此直接复用其映射; + 差异只有 config 里没有 text_config,layernorm eps 回落到 1e-6。 + """ + + def set_gguf_parameters(self): + super().set_gguf_parameters() + + # 官方 projector 输出维度必须等于文本 hidden_size,否则 mtmd 注入的 embedding 无法对齐。 + out_hidden = self.hparams_vision.get("out_hidden_size") + text_hidden = self.global_config.get("hidden_size") + if out_hidden is not None and text_hidden is not None and int(out_hidden) != int(text_hidden): + raise ValueError( + f"视觉 out_hidden_size={out_hidden} 与文本 hidden_size={text_hidden} 不一致" + ) + + # deepstack 在本模型为空;clip 侧按 is_deepstack_layers 判断,显式写空列表以便排查。 + if not self.is_deepstack_layers: + self.gguf_writer.add_vision_is_deepstack_layers([]) diff --git a/gguf-py/gguf/constants.py b/gguf-py/gguf/constants.py index 1d67dc8..ece1eb3 100644 --- a/gguf-py/gguf/constants.py +++ b/gguf-py/gguf/constants.py @@ -733,6 +733,7 @@ class MODEL_ARCH(IntEnum): MAION_CODER = auto() KIMI_LINEAR = auto() KIMI_K3 = auto() + YOUTU_MOE = auto() TALKIE = auto() MELLUM = auto() NANBEIGE = auto() @@ -1592,6 +1593,7 @@ MODEL_ARCH_NAMES: dict[MODEL_ARCH, str] = { MODEL_ARCH.MAION_CODER: "maion-coder", MODEL_ARCH.KIMI_LINEAR: "kimi-linear", MODEL_ARCH.KIMI_K3: "kimi-k3", + MODEL_ARCH.YOUTU_MOE: "youtu-moe", MODEL_ARCH.TALKIE: "talkie", MODEL_ARCH.MELLUM: "mellum", MODEL_ARCH.NANBEIGE: "nanbeige", @@ -6231,6 +6233,50 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = { MODEL_TENSOR.FFN_ROUTED_UP, MODEL_TENSOR.FFN_ROUTED_NORM, ], + + MODEL_ARCH.YOUTU_MOE: [ + MODEL_TENSOR.TOKEN_EMBD, + MODEL_TENSOR.OUTPUT_NORM, + MODEL_TENSOR.OUTPUT, + MODEL_TENSOR.ATTN_NORM, + # MLA (full-attention layers) + MODEL_TENSOR.ATTN_Q, + MODEL_TENSOR.ATTN_K, + MODEL_TENSOR.ATTN_V, + MODEL_TENSOR.ATTN_OUT, + MODEL_TENSOR.ATTN_GATE, + MODEL_TENSOR.ATTN_Q_A, + MODEL_TENSOR.ATTN_Q_B, + MODEL_TENSOR.ATTN_KV_A_MQA, + MODEL_TENSOR.ATTN_K_B, + MODEL_TENSOR.ATTN_V_B, + MODEL_TENSOR.ATTN_Q_A_NORM, + MODEL_TENSOR.ATTN_KV_A_NORM, + # KDA (linear-attention layers) + MODEL_TENSOR.SSM_CONV1D_Q, + MODEL_TENSOR.SSM_CONV1D_K, + MODEL_TENSOR.SSM_CONV1D_V, + MODEL_TENSOR.SSM_F_A, + MODEL_TENSOR.SSM_F_B, + MODEL_TENSOR.SSM_BETA, + MODEL_TENSOR.SSM_A, + MODEL_TENSOR.SSM_G, + MODEL_TENSOR.SSM_DT, + MODEL_TENSOR.SSM_NORM, + # FFN + MODEL_TENSOR.FFN_NORM, + MODEL_TENSOR.FFN_GATE, + MODEL_TENSOR.FFN_DOWN, + MODEL_TENSOR.FFN_UP, + MODEL_TENSOR.FFN_GATE_INP, + MODEL_TENSOR.FFN_EXP_PROBS_B, + MODEL_TENSOR.FFN_GATE_EXP, + MODEL_TENSOR.FFN_DOWN_EXP, + MODEL_TENSOR.FFN_UP_EXP, + MODEL_TENSOR.FFN_GATE_SHEXP, + MODEL_TENSOR.FFN_DOWN_SHEXP, + MODEL_TENSOR.FFN_UP_SHEXP, + ], MODEL_ARCH.TALKIE: [ MODEL_TENSOR.TOKEN_EMBD, MODEL_TENSOR.OUTPUT, diff --git a/src/llama-arch.cpp b/src/llama-arch.cpp index 8a0d6b1..f8d706b 100644 --- a/src/llama-arch.cpp +++ b/src/llama-arch.cpp @@ -165,6 +165,7 @@ static const std::map LLM_ARCH_NAMES = { { LLM_ARCH_MAION_CODER, "maion-coder" }, { LLM_ARCH_KIMI_LINEAR, "kimi-linear" }, { LLM_ARCH_KIMI_K3, "kimi-k3" }, + { LLM_ARCH_YOUTU_MOE, "youtu-moe" }, { LLM_ARCH_GLM5_NEXT, "glm5-next" }, { LLM_ARCH_TALKIE, "talkie" }, { LLM_ARCH_MELLUM, "mellum" }, @@ -1289,6 +1290,7 @@ bool llm_arch_is_hybrid(const llm_arch & arch) { case LLM_ARCH_KIMI_LINEAR: case LLM_ARCH_BAILINGMOE3: case LLM_ARCH_KIMI_K3: + case LLM_ARCH_YOUTU_MOE: case LLM_ARCH_GLM5_NEXT: case LLM_ARCH_QWEN35: case LLM_ARCH_QWEN35MOE: @@ -1317,6 +1319,7 @@ bool llm_arch_is_diffusion(const llm_arch & arch) { bool llm_arch_supports_rs_rollback(const llm_arch & arch) { switch (arch) { case LLM_ARCH_KIMI_K3: + case LLM_ARCH_YOUTU_MOE: case LLM_ARCH_QWEN35: case LLM_ARCH_QWEN35MOE: case LLM_ARCH_QWEN4EXP: @@ -1387,6 +1390,7 @@ bool llm_arch_supports_sm_tensor(const llm_arch & arch) { case LLM_ARCH_KIMI_LINEAR: case LLM_ARCH_BAILINGMOE3: case LLM_ARCH_KIMI_K3: + case LLM_ARCH_YOUTU_MOE: case LLM_ARCH_GLM5_NEXT: case LLM_ARCH_QWEN3TTS: case LLM_ARCH_LIMITE: diff --git a/src/llama-arch.h b/src/llama-arch.h index 072d3bc..5a1c8bb 100644 --- a/src/llama-arch.h +++ b/src/llama-arch.h @@ -163,6 +163,7 @@ enum llm_arch { LLM_ARCH_MAION_CODER, LLM_ARCH_KIMI_LINEAR, LLM_ARCH_KIMI_K3, + LLM_ARCH_YOUTU_MOE, LLM_ARCH_GLM5_NEXT, LLM_ARCH_TALKIE, LLM_ARCH_MELLUM, diff --git a/src/llama-context.cpp b/src/llama-context.cpp index 4141e3c..404cb99 100644 --- a/src/llama-context.cpp +++ b/src/llama-context.cpp @@ -2528,7 +2528,7 @@ void llama_context::output_reorder() { uint32_t llama_context::graph_max_nodes(uint32_t n_tokens) const { uint32_t res; - if (model.arch == LLM_ARCH_KIMI_K3 || model.arch == LLM_ARCH_GLM5_NEXT) { + if (model.arch == LLM_ARCH_KIMI_K3 || model.arch == LLM_ARCH_YOUTU_MOE || model.arch == LLM_ARCH_GLM5_NEXT) { // the n_tokens*40 budget below is exhausted at ubatch 3840 res = std::max(n_tokens * 160, 64u * model.n_tensors()); } else if (model.arch == LLM_ARCH_HRM_TEXT) { diff --git a/src/llama-graph.cpp b/src/llama-graph.cpp index 7a94266..fb3ef5f 100644 --- a/src/llama-graph.cpp +++ b/src/llama-graph.cpp @@ -2354,7 +2354,7 @@ ggml_tensor * llm_graph_context::build_moe_ffn( ggml_tensor * weights_sum = ggml_sum_rows(ctx0, weights); // [1, n_tokens] cb(weights_sum, "ffn_moe_weights_sum", il); - if (arch == LLM_ARCH_DEEPSEEK41) { + if (arch == LLM_ARCH_DEEPSEEK41 || arch == LLM_ARCH_YOUTU_MOE) { // model.py Gate.forward: weights / (sum + 1e-20); the sum can fall below the F16 clamp below weights_sum = ggml_scale_bias(ctx0, weights_sum, 1.0f, 1e-20f); } else { @@ -2363,7 +2363,7 @@ ggml_tensor * llm_graph_context::build_moe_ffn( } cb(weights_sum, "ffn_moe_weights_sum_clamped", il); - weights = ggml_div(ctx0, weights, weights_sum); // [n_expert_used, n_tokens] + weights = ggml_div(ctx0, weights, ggml_cast(ctx0, weights_sum, GGML_TYPE_F32)); // [n_expert_used, n_tokens] cb(weights, "ffn_moe_weights_norm", il); weights = ggml_reshape_3d(ctx0, weights, 1, n_expert_used, n_tokens); diff --git a/src/llama-model.cpp b/src/llama-model.cpp index 83aed01..ea7c0a2 100644 --- a/src/llama-model.cpp +++ b/src/llama-model.cpp @@ -364,6 +364,8 @@ static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params return new llama_model_kimi_linear(params); case LLM_ARCH_KIMI_K3: return new llama_model_kimi_k3(params); + case LLM_ARCH_YOUTU_MOE: + return new llama_model_youtu_moe(params); case LLM_ARCH_GLM5_NEXT: return new llama_model_glm5_next(params); case LLM_ARCH_STEP35: @@ -3720,6 +3722,7 @@ llama_rope_type llama_model_rope_type(const llama_model * model) { case LLM_ARCH_CLEF: case LLM_ARCH_QWEN4EXP: case LLM_ARCH_QWEN3TTS: + case LLM_ARCH_YOUTU_MOE: return LLAMA_ROPE_TYPE_IMROPE; case LLM_ARCH_GLM4: diff --git a/src/models/models.h b/src/models/models.h index 9a8602b..d12a1d2 100644 --- a/src/models/models.h +++ b/src/models/models.h @@ -3018,6 +3018,32 @@ struct llama_model_kimi_k3 : public llama_model_base { std::unique_ptr build_arch_graph(const llm_graph_params & params) const override; }; +struct llama_model_youtu_moe : public llama_model_base { + llama_model_youtu_moe(const struct llama_model_params & params) : llama_model_base(params) {} + void load_arch_hparams(llama_model_loader & ml) override; + void load_arch_tensors(llama_model_loader & ml) override; + + struct graph : public llm_build_delta_net_base { + graph(const llama_model & model, const llm_graph_params & params); + + ggml_tensor * build_kda_layer(ggml_tensor * cur, const llama_layer & layer, + llm_graph_input_rs * inp_rs, + int64_t d_conv, int64_t head_dim, int64_t n_head_kda, + int64_t d_inner, int64_t n_seq_tokens, int64_t n_seqs, int il); + + ggml_tensor * build_mla_layer(ggml_tensor * cur, const llama_layer & layer, + llm_graph_input_attn_k * inp_attn_k, ggml_tensor * inp_pos, int il); + + ggml_tensor * build_qb_moe(ggml_tensor * cur, const llama_layer & layer, int il); + + // I32 [n_tokens]:0=文本,1=图像/视频(用于 qb_beta 逐 token 选行) + ggml_tensor * inp_modality = nullptr; + int rope_sections[4] = {0, 0, 0, 0}; + }; + + std::unique_ptr build_arch_graph(const llm_graph_params & params) const override; +}; + struct llama_model_glm5_next : public llama_model_base { llama_model_glm5_next(const struct llama_model_params & params) : llama_model_base(params) {} void load_arch_hparams(llama_model_loader & ml) override; diff --git a/src/models/youtu-moe.cpp b/src/models/youtu-moe.cpp new file mode 100644 index 0000000..41f0f46 --- /dev/null +++ b/src/models/youtu-moe.cpp @@ -0,0 +1,491 @@ +#include "models.h" + +#include +#include +#include + +#include "ggml-backend.h" +#include "llama-memory-recurrent.h" + +void llama_model_youtu_moe::load_arch_hparams(llama_model_loader & ml) { + ml.get_key(LLM_KV_ATTENTION_LAYERNORM_RMS_EPS, hparams.f_norm_rms_eps); + ml.get_key(LLM_KV_ATTENTION_KEY_LENGTH_MLA, hparams.n_embd_head_k_mla_impl); + ml.get_key(LLM_KV_ATTENTION_VALUE_LENGTH_MLA, hparams.n_embd_head_v_mla_impl); + ml.get_key(LLM_KV_ATTENTION_Q_LORA_RANK, hparams.n_lora_q); + ml.get_key(LLM_KV_ATTENTION_KV_LORA_RANK, hparams.n_lora_kv); + ml.get_key(LLM_KV_SSM_CONV_KERNEL, hparams.ssm_d_conv); + ml.get_key(LLM_KV_KDA_HEAD_DIM, hparams.n_embd_head_kda); + ml.get_key(LLM_KV_KDA_GATE_LOWER_BOUND, hparams.kda_gate_lower_bound); + // 交错 M-RoPE(IMROPE)的 THW 频率分配,文本 token 三个位置相同即退化为普通 RoPE。 + ml.get_key_or_arr(LLM_KV_ROPE_DIMENSION_SECTIONS, hparams.rope_sections, 4, true); + + // 缓存保存 [compressed_kv, rotated_k];V 复用 K 缓存的前 n_lora_kv 维。 + hparams.n_embd_head_k_full = hparams.n_lora_kv + hparams.n_rot(); + hparams.n_embd_head_v_full = hparams.n_lora_kv; + + for (uint32_t i = 0; i < hparams.n_layer(); ++i) { + hparams.is_recr_impl[i] = hparams.n_head_kv(i) == 0; + } + + ml.get_key_or_arr(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp_arr, hparams.n_layer_all); + ml.get_key(LLM_KV_EXPERT_SHARED_COUNT, hparams.n_expert_shared); + ml.get_key(LLM_KV_LEADING_DENSE_BLOCK_COUNT, hparams.n_layer_dense_lead); + ml.get_key(LLM_KV_EXPERT_WEIGHTS_SCALE, hparams.expert_weights_scale); + ml.get_key(LLM_KV_EXPERT_WEIGHTS_NORM, hparams.expert_weights_norm); + ml.get_key(LLM_KV_EXPERT_GATING_FUNC, hparams.expert_gating_func); + + if (hparams.kda_gate_lower_bound != -5.0f || + hparams.expert_gating_func != LLAMA_EXPERT_GATING_FUNC_TYPE_SIGMOID || + !hparams.expert_weights_norm) { + throw std::runtime_error("youtu-moe 需要 -5 KDA decay 和归一化 sigmoid 专家权重"); + } + + type = LLM_TYPE_UNKNOWN; +} + +void llama_model_youtu_moe::load_arch_tensors(llama_model_loader &) { + LLAMA_LOAD_LOCALS; + + const int64_t head_dim = hparams.n_embd_head_kda; + const int64_t d_conv = hparams.ssm_d_conv; + const int64_t d_inner = head_dim * n_head; + + const int64_t q_lora_rank = hparams.n_lora_q; + const int64_t kv_lora_rank = hparams.n_lora_kv; + const int64_t k_mla = hparams.n_embd_head_k_mla(); + const int64_t v_mla = hparams.n_embd_head_v_mla(); + const int64_t k_nope = k_mla - n_rot; + GGML_ASSERT(k_nope > 0 && n_rot > 0); + + tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0); + output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0); + output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, 0); + + for (int i = 0; i < n_layer; ++i) { + auto & layer = layers[i]; + + layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0); + layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0); + + if (hparams.is_recr(i)) { + // numpy [1, d_inner, 1, d_conv];量化后末尾单维可省略。 + layer.ssm_q_conv = create_tensor(tn(LLM_TENSOR_SSM_CONV1D_Q, "weight", i), {d_conv, 1, d_inner, 1}, 0); + layer.ssm_k_conv = create_tensor(tn(LLM_TENSOR_SSM_CONV1D_K, "weight", i), {d_conv, 1, d_inner, 1}, 0); + layer.ssm_v_conv = create_tensor(tn(LLM_TENSOR_SSM_CONV1D_V, "weight", i), {d_conv, 1, d_inner, 1}, 0); + + // converter 将 fused QKV 拆为常规 Q/K/V,卷积 helper 分别读取。 + layer.wq = create_tensor(tn(LLM_TENSOR_ATTN_Q, "weight", i), {n_embd, d_inner}, 0); + layer.wk = create_tensor(tn(LLM_TENSOR_ATTN_K, "weight", i), {n_embd, d_inner}, 0); + layer.wv = create_tensor(tn(LLM_TENSOR_ATTN_V, "weight", i), {n_embd, d_inner}, 0); + + layer.ssm_f_a = create_tensor(tn(LLM_TENSOR_SSM_F_A, "weight", i), {n_embd, head_dim}, 0); + layer.ssm_f_b = create_tensor(tn(LLM_TENSOR_SSM_F_B, "weight", i), {head_dim, d_inner}, 0); + layer.ssm_beta = create_tensor(tn(LLM_TENSOR_SSM_BETA, "weight", i), {n_embd, n_head}, 0); + // converter 已将 A_log 转为 -exp(A_log)。 + layer.ssm_a = create_tensor(tn(LLM_TENSOR_SSM_A_NOSCAN, i), {n_head}, 0); + layer.ssm_dt_b = create_tensor(tn(LLM_TENSOR_SSM_DT, "bias", i), {d_inner}, 0); + layer.ssm_g = create_tensor(tn(LLM_TENSOR_SSM_G, "weight", i), {n_embd, d_inner}, 0); + layer.ssm_o_norm = create_tensor(tn(LLM_TENSOR_SSM_NORM, "weight", i), {head_dim}, 0); + layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {d_inner, n_embd}, 0); + } else { + layer.attn_q_a_norm = create_tensor(tn(LLM_TENSOR_ATTN_Q_A_NORM, "weight", i), {q_lora_rank}, 0); + layer.attn_kv_a_norm = create_tensor(tn(LLM_TENSOR_ATTN_KV_A_NORM, "weight", i), {kv_lora_rank}, 0); + layer.wq_a = create_tensor(tn(LLM_TENSOR_ATTN_Q_A, "weight", i), {n_embd, q_lora_rank}, 0); + layer.wq_b = create_tensor(tn(LLM_TENSOR_ATTN_Q_B, "weight", i), {q_lora_rank, n_head * k_mla}, 0); + layer.wkv_a_mqa = create_tensor(tn(LLM_TENSOR_ATTN_KV_A_MQA, "weight", i), {n_embd, kv_lora_rank + n_rot}, 0); + + // 强制 split KV:K_B numpy [n_head, kv_lora_rank, k_nope],V_B [n_head, v_mla, kv_lora_rank]。 + layer.wk_b = create_tensor(tn(LLM_TENSOR_ATTN_K_B, "weight", i), {k_nope, kv_lora_rank, n_head}, 0); + layer.wv_b = create_tensor(tn(LLM_TENSOR_ATTN_V_B, "weight", i), {kv_lora_rank, v_mla, n_head}, 0); + + // 原始 head-wise gate,不扩展为 n_head * v_mla 个输出通道。 + layer.wqkv_gate = create_tensor(tn(LLM_TENSOR_ATTN_GATE, "weight", i), {n_embd, n_head}, 0); + layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_head * v_mla, n_embd}, 0); + } + + if (i < (int) hparams.n_layer_dense_lead) { + const int64_t n_ff_dense = hparams.n_ff(i); + layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff_dense}, 0); + layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), {n_ff_dense, n_embd}, 0); + layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff_dense}, 0); + } else { + const int64_t n_ff_exp = hparams.n_ff_exp(i); + const int64_t n_ff_shared = n_ff_exp * hparams.n_expert_shared; + + layer.ffn_gate_inp = create_tensor(tn(LLM_TENSOR_FFN_GATE_INP, "weight", i), {n_embd, n_expert}, 0); + // [2, n_expert]:第 0 行文本、第 1 行图像/视频,逐 token 选行。 + layer.ffn_exp_probs_b = create_tensor(tn(LLM_TENSOR_FFN_EXP_PROBS_B, "bias", i), {n_expert, 2}, 0); + if (layer.ffn_gate_inp->type != GGML_TYPE_F32) { + throw std::runtime_error("youtu-moe QB router 权重必须保留 FP32"); + } + + create_tensor_gate_up_exps(layer, i, n_embd, n_ff_exp, n_expert, 0); + layer.ffn_down_exps = create_tensor(tn(LLM_TENSOR_FFN_DOWN_EXPS, "weight", i), {n_ff_exp, n_embd, n_expert}, 0); + layer.ffn_gate_shexp = create_tensor(tn(LLM_TENSOR_FFN_GATE_SHEXP, "weight", i), {n_embd, n_ff_shared}, 0); + layer.ffn_down_shexp = create_tensor(tn(LLM_TENSOR_FFN_DOWN_SHEXP, "weight", i), {n_ff_shared, n_embd}, 0); + layer.ffn_up_shexp = create_tensor(tn(LLM_TENSOR_FFN_UP_SHEXP, "weight", i), {n_embd, n_ff_shared}, 0); + } + } +} + +std::unique_ptr llama_model_youtu_moe::build_arch_graph(const llm_graph_params & params) const { + if (params.gtype == LLM_GRAPH_TYPE_DECODER_MTP) { + throw std::runtime_error("youtu-moe 暂不支持 MTP graph"); + } + return std::make_unique(*this, params); +} + +// 逐 token 模态输入。官方 YoutuVLTopkRouter 对图像/视频 token 使用 qb_beta 第 1 行 +// (modality_id == MODALITY_VISION),其余(含解码阶段)用第 0 行。 +// mtmd 会把图像占位 token 换成 embedding 行,所以「该行是 embedding」等价于 +// input_ids == image_token_id / video_token_id;纯 token 批次恒为文本。 +class llm_graph_input_youtu_modality : public llm_graph_input_i { +public: + llm_graph_input_youtu_modality() = default; + virtual ~llm_graph_input_youtu_modality() = default; + + void set_input(const llama_ubatch * ubatch) override { + if (ids == nullptr) { + return; + } + const int64_t n_tokens = (int64_t) ubatch->n_tokens; + GGML_ASSERT(ids->ne[0] == n_tokens); + + std::vector data((size_t) n_tokens, 0); + if (ubatch->token == nullptr) { + // 纯 embedding 批次:整批都是图像/视频 token。 + std::fill(data.begin(), data.end(), 1); + } else if (ubatch->type != nullptr) { + // 混合批次:type[i] != 0 表示该行来自 embedding。 + for (int64_t i = 0; i < n_tokens; ++i) { + data[(size_t) i] = ubatch->type[i] != 0 ? 1 : 0; + } + } + ggml_backend_tensor_set(ids, data.data(), 0, data.size()*sizeof(int32_t)); + + // 诊断:YOUTU_DEBUG_MODALITY=1 时打印本批次的模态分布,用于确认 + // 图像 token 确实走了 qb_beta 第 1 行(视觉行)。 + if (const char * dbg = getenv("YOUTU_DEBUG_MODALITY")) { + if (dbg[0] == '1') { + const auto n_vis = std::count(data.begin(), data.end(), 1); + fprintf(stderr, "[youtu-modality] n_tokens=%lld vision=%lld text=%lld token=%s type=%s\n", + (long long) n_tokens, (long long) n_vis, (long long) (n_tokens - n_vis), + ubatch->token ? "set" : "null", ubatch->type ? "set" : "null"); + } + } + } + + bool can_reuse(const llm_graph_params & params) override { + return ids != nullptr && ids->ne[0] == (int64_t) params.ubatch.n_tokens; + } + + ggml_tensor * ids = nullptr; // I32 [n_tokens] +}; + +llama_model_youtu_moe::graph::graph(const llama_model & model, const llm_graph_params & params) : + llm_build_delta_net_base(params) { + if (!hparams.is_mla()) { + throw std::runtime_error("youtu-moe 只支持 split KV 的 absorbed MLA 缓存,不支持非 absorbed 模式"); + } + GGML_ASSERT(rope_type == GGML_ROPE_TYPE_IMROPE); + std::copy(std::begin(hparams.rope_sections), std::begin(hparams.rope_sections) + 4, rope_sections); + + ggml_tensor * inpL = build_inp_embd(model.tok_embd); + cb(inpL, "inp_embd", -1); + ggml_tensor * inp_pos = build_inp_pos(); + ggml_tensor * inp_out_ids = build_inp_out_ids(); + + { + auto inp = std::make_unique(); + auto & t = inp->ids; + t = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_tokens); + ggml_set_input(t); + cb(t, "inp_modality", -1); + res->add_input(std::move(inp)); + inp_modality = t; + } + + auto * inp_hybrid = build_inp_mem_hybrid_k(); + auto * inp_rs = inp_hybrid->get_recr(); + auto * inp_attn_k = inp_hybrid->get_attn(); + + const int64_t n_head_kda = hparams.n_head(); + const int64_t head_dim = hparams.n_embd_head_kda; + const int64_t d_conv = hparams.ssm_d_conv; + const int64_t d_inner = n_head_kda * head_dim; + const int64_t n_seqs = ubatch.n_seqs; + const int64_t n_seq_tokens = ubatch.n_seq_tokens; + + GGML_ASSERT(n_seqs != 0 && ubatch.equal_seqs()); + GGML_ASSERT(ubatch.n_tokens == n_seq_tokens * n_seqs); + + ggml_tensor * cur = inpL; + for (int il = 0; il < n_layer; ++il) { + const auto & layer = model.layers[il]; + ggml_tensor * inpSA = inpL; + + cur = build_norm(inpL, layer.attn_norm, nullptr, LLM_NORM_RMS, il); + cb(cur, "attn_norm", il); + ggml_build_forward_expand(gf, cur); + + if (hparams.is_recr(il)) { + cur = build_kda_layer(cur, layer, inp_rs, d_conv, head_dim, n_head_kda, + d_inner, n_seq_tokens, n_seqs, il); + } else { + cur = build_mla_layer(cur, layer, inp_attn_k, inp_pos, il); + } + + // 必须先完成所有 token 的卷积、递归状态和 KV 写入,再裁剪末层 FFN 输入。 + if (il == n_layer - 1 && inp_out_ids) { + cur = ggml_get_rows(ctx0, cur, inp_out_ids); + inpSA = ggml_get_rows(ctx0, inpSA, inp_out_ids); + // 模态 id 必须跟着裁到输出行,否则末层 MoE 的 per-token bias 对不上。 + inp_modality = ggml_reshape_1d(ctx0, ggml_get_rows(ctx0, + ggml_reshape_2d(ctx0, inp_modality, 1, n_tokens), inp_out_ids), n_outputs); + } + ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpSA); + cb(ffn_inp, "ffn_inp", il); + + cur = build_norm(ffn_inp, layer.ffn_norm, nullptr, LLM_NORM_RMS, il); + cb(cur, "ffn_norm", il); + if ((uint32_t) il < hparams.n_layer_dense_lead) { + cur = build_ffn(cur, + layer.ffn_up, nullptr, nullptr, + layer.ffn_gate, nullptr, nullptr, + layer.ffn_down, nullptr, nullptr, + nullptr, LLM_FFN_SILU, LLM_FFN_PAR, il); + cb(cur, "ffn_out", il); + } else { + cur = build_qb_moe(cur, layer, il); + } + + cur = ggml_add(ctx0, cur, ffn_inp); + cur = build_cvec(cur, il); + cb(cur, "l_out", il); + inpL = cur; + } + + cur = build_norm(inpL, model.output_norm, nullptr, LLM_NORM_RMS, -1); + cb(cur, "result_norm", -1); + res->t_embd = cur; + + cur = ggml_mul_mat(ctx0, model.output, cur); + cb(cur, "result_output", -1); + res->t_logits = cur; +} + +// 沿用 Kimi-K3 的卷积缓存布局及多槽 rollback 写回,仅独立复制 helper。 +static ggml_tensor * youtu_moe_conv1d(ggml_cgraph * gf, ggml_context * ctx0, + ggml_tensor * conv_states_all, ggml_tensor * conv_state_all, + int64_t qkv, ggml_tensor * x, ggml_tensor * proj_w, ggml_tensor * conv_w, + int64_t d_conv, int64_t head_dim, int64_t n_head, + int64_t n_seq_tokens, int64_t n_seqs, int64_t n_tokens, int64_t kv_head, + int64_t mem_size, int64_t K_rs) { + const int64_t d_inner = head_dim * n_head; + const int64_t conv_state_size = (d_conv - 1) * d_inner; + const int64_t n_embd_r_total = 3 * conv_state_size; + + ggml_tensor * conv_state_x = ggml_view_3d(ctx0, conv_state_all, d_conv - 1, d_inner, n_seqs, + (d_conv - 1) * ggml_element_size(conv_state_all), + n_embd_r_total * ggml_element_size(conv_state_all), + qkv * conv_state_size * ggml_element_size(conv_state_all)); + + ggml_tensor * x_proj = ggml_mul_mat(ctx0, proj_w, x); + ggml_tensor * x_3d = ggml_reshape_3d(ctx0, x_proj, d_inner, n_seq_tokens, n_seqs); + ggml_tensor * conv_x = ggml_concat(ctx0, conv_state_x, ggml_transpose(ctx0, x_3d), 0); + + // 最后 K_rs 个 token 必须共享 ubatch,与现有 recurrent rollback 契约一致。 + for (int64_t s = 0; s < K_rs; ++s) { + const int64_t s_idx = std::max(0, n_seq_tokens - s); + ggml_tensor * conv_x_s = ggml_view_3d(ctx0, conv_x, d_conv - 1, d_inner, n_seqs, + conv_x->nb[1], conv_x->nb[2], s_idx * conv_x->nb[0]); + ggml_build_forward_expand(gf, + ggml_cpy(ctx0, conv_x_s, + ggml_view_3d(ctx0, conv_states_all, d_conv - 1, d_inner, n_seqs, + (d_conv - 1) * ggml_element_size(conv_states_all), + n_embd_r_total * ggml_element_size(conv_states_all), + ((s * mem_size + kv_head) * n_embd_r_total + qkv * conv_state_size) * ggml_element_size(conv_states_all)))); + } + + ggml_tensor * conv_weight = ggml_reshape_2d(ctx0, conv_w, d_conv, d_inner); + ggml_tensor * Xcur = ggml_ssm_conv(ctx0, conv_x, conv_weight); + Xcur = ggml_reshape_2d(ctx0, Xcur, d_inner, n_tokens); + Xcur = ggml_silu(ctx0, Xcur); + + return ggml_reshape_4d(ctx0, Xcur, head_dim, n_head, n_seq_tokens, n_seqs); +} + +ggml_tensor * llama_model_youtu_moe::graph::build_kda_layer( + ggml_tensor * cur, const llama_layer & layer, llm_graph_input_rs * inp_rs, + int64_t d_conv, int64_t head_dim, int64_t n_head_kda, + int64_t d_inner, int64_t n_seq_tokens, int64_t n_seqs, int il) { + const auto * mctx_cur = inp_rs->mctx; + const auto kv_head = mctx_cur->get_head(); + + ggml_tensor * conv_states_all = mctx_cur->get_r_l(il); + ggml_tensor * conv_state_all = build_rs(inp_rs, conv_states_all, hparams.n_embd_r(), n_seqs); + const int64_t mem_size = mctx_cur->get_size(); + const int64_t K_rs = (int64_t) cparams.n_rs_seq + 1; + + ggml_tensor * Qcur = youtu_moe_conv1d(gf, ctx0, conv_states_all, conv_state_all, 0, cur, layer.wq, layer.ssm_q_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs); + ggml_tensor * Kcur = youtu_moe_conv1d(gf, ctx0, conv_states_all, conv_state_all, 1, cur, layer.wk, layer.ssm_k_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs); + ggml_tensor * Vcur = youtu_moe_conv1d(gf, ctx0, conv_states_all, conv_state_all, 2, cur, layer.wv, layer.ssm_v_conv, d_conv, head_dim, n_head_kda, n_seq_tokens, n_seqs, n_tokens, kv_head, mem_size, K_rs); + cb(Qcur, "kda_q_conv", il); + cb(Kcur, "kda_k_conv", il); + cb(Vcur, "kda_v_conv", il); + + ggml_tensor * f_a = ggml_mul_mat(ctx0, layer.ssm_f_a, cur); + ggml_tensor * g1 = ggml_mul_mat(ctx0, layer.ssm_f_b, f_a); + g1 = ggml_add(ctx0, g1, layer.ssm_dt_b); + g1 = ggml_reshape_3d(ctx0, g1, head_dim, n_head_kda, n_tokens); + ggml_tensor * A = ggml_reshape_3d(ctx0, layer.ssm_a, 1, n_head_kda, 1); + // A 为 -exp(A_log):负号消去后,decay = -5 * sigmoid(exp(A_log) * (... + dt))。 + g1 = ggml_mul(ctx0, g1, A); + g1 = ggml_sigmoid(ctx0, ggml_scale(ctx0, g1, -1.0f)); + g1 = ggml_scale(ctx0, g1, hparams.kda_gate_lower_bound); + cb(g1, "kda_g1", il); + g1 = ggml_reshape_4d(ctx0, g1, head_dim, n_head_kda, n_seq_tokens, n_seqs); + + ggml_tensor * beta = ggml_mul_mat(ctx0, layer.ssm_beta, cur); + beta = ggml_reshape_4d(ctx0, beta, 1, n_head_kda, n_seq_tokens, n_seqs); + beta = ggml_sigmoid(ctx0, beta); + cb(beta, "kda_beta", il); + + ggml_tensor * ssm_states_all = mctx_cur->get_s_l(il); + ggml_tensor * state = build_rs(inp_rs, ssm_states_all, hparams.n_embd_s(), n_seqs); + state = ggml_reshape_4d(ctx0, state, head_dim, head_dim, n_head_kda, n_seqs); + + Qcur = build_gdn_l2_norm(ctx0, Qcur, 1e-6f); + Kcur = build_gdn_l2_norm(ctx0, Kcur, 1e-6f); + ggml_tensor * output = build_recurrent_attn(inp_rs, ssm_states_all, Qcur, Kcur, Vcur, g1, beta, state, il); + output = ggml_cont(ctx0, output); + cb(output, "kda_scan_out", il); + + ggml_tensor * g2 = ggml_mul_mat(ctx0, layer.ssm_g, cur); + g2 = ggml_reshape_3d(ctx0, g2, head_dim, n_head_kda, n_tokens); + ggml_tensor * o = ggml_reshape_3d(ctx0, output, head_dim, n_head_kda, n_tokens); + ggml_tensor * normed = build_norm(o, layer.ssm_o_norm, nullptr, LLM_NORM_RMS, il); + cb(g2, "kda_g2", il); + cb(normed, "kda_normed", il); + ggml_tensor * gated = ggml_mul(ctx0, normed, ggml_sigmoid(ctx0, g2)); + gated = ggml_cont_2d(ctx0, gated, d_inner, n_tokens); + cur = ggml_mul_mat(ctx0, layer.wo, gated); + cb(cur, "kda_out", il); + + return cur; +} + +ggml_tensor * llama_model_youtu_moe::graph::build_mla_layer( + ggml_tensor * cur, const llama_layer & layer, + llm_graph_input_attn_k * inp_attn_k, ggml_tensor * inp_pos, int il) { + const int64_t k_mla = hparams.n_embd_head_k_mla(); + const int64_t v_mla = hparams.n_embd_head_v_mla(); + const int64_t k_rope = hparams.n_rot(); + const int64_t k_nope = k_mla - k_rope; + const int64_t kv_lora_rank = hparams.n_lora_kv; + const float kq_scale = 1.0f / sqrtf((float) k_mla); + + ggml_tensor * q = ggml_mul_mat(ctx0, layer.wq_a, cur); + q = build_norm(q, layer.attn_q_a_norm, nullptr, LLM_NORM_RMS, il); + q = ggml_mul_mat(ctx0, layer.wq_b, q); + q = ggml_reshape_3d(ctx0, q, k_mla, n_head, n_tokens); + cb(q, "mla_q", il); + + ggml_tensor * q_nope = ggml_view_3d(ctx0, q, k_nope, n_head, n_tokens, q->nb[1], q->nb[2], 0); + ggml_tensor * q_pe = ggml_view_3d(ctx0, q, k_rope, n_head, n_tokens, q->nb[1], q->nb[2], + ggml_row_size(q->type, k_nope)); + ggml_tensor * kv_cmpr_pe = ggml_mul_mat(ctx0, layer.wkv_a_mqa, cur); + ggml_tensor * kv_cmpr = ggml_view_2d(ctx0, kv_cmpr_pe, kv_lora_rank, n_tokens, + ggml_row_size(kv_cmpr_pe->type, kv_lora_rank + k_rope), 0); + ggml_tensor * k_pe = ggml_view_3d(ctx0, kv_cmpr_pe, k_rope, 1, n_tokens, + ggml_row_size(kv_cmpr_pe->type, kv_lora_rank + k_rope), + ggml_row_size(kv_cmpr_pe->type, kv_lora_rank + k_rope), + ggml_row_size(kv_cmpr_pe->type, kv_lora_rank)); + + // 官方 MLA 先 cat(q[0::2], q[1::2]) 再 rotate_half,即前后半成对旋转; + // converter 已把 q_pe/k_pe 权重重排为 NeoX(前后半) 布局,这里用 IMROPE + // (rotate_pairs(n_dims, n_dims/2))与官方逐元素一致。文本 token 的 t/h/w + // 位置相同,等价于普通 RoPE;图像 token 用 3D 位置做交错 M-RoPE。 + // 必须在 concat 前仅旋转 q_pe/k_pe,不能旋转 latent 维。 + q_pe = ggml_rope_multi(ctx0, q_pe, inp_pos, nullptr, n_rot, rope_sections, rope_type, n_ctx_orig, + freq_base, freq_scale, ext_factor, attn_factor, beta_fast, beta_slow); + k_pe = ggml_rope_multi(ctx0, k_pe, inp_pos, nullptr, n_rot, rope_sections, rope_type, n_ctx_orig, + freq_base, freq_scale, ext_factor, attn_factor, beta_fast, beta_slow); + cb(q_pe, "mla_q_pe_rope", il); + cb(k_pe, "mla_k_pe_rope", il); + + kv_cmpr = build_norm(kv_cmpr, layer.attn_kv_a_norm, nullptr, LLM_NORM_RMS, il); + cb(kv_cmpr, "mla_kv_cmpr_norm", il); + q_nope = ggml_permute(ctx0, q_nope, 0, 2, 1, 3); + ggml_tensor * q_absorbed = ggml_mul_mat(ctx0, layer.wk_b, q_nope); + q_absorbed = ggml_permute(ctx0, q_absorbed, 0, 2, 1, 3); + ggml_tensor * Q = ggml_concat(ctx0, q_absorbed, q_pe, 0); + ggml_tensor * V = ggml_reshape_3d(ctx0, kv_cmpr, kv_lora_rank, 1, n_tokens); + ggml_tensor * K = ggml_concat(ctx0, V, k_pe, 0); + cb(Q, "mla_Qcur", il); + cb(K, "mla_Kcur", il); + cb(V, "mla_Vcur", il); + + // build_attn_mha 同时提供 Flash/non-Flash absorbed 路径,并在返回前应用 V_B。 + ggml_tensor * out = build_attn(inp_attn_k, nullptr, nullptr, nullptr, + Q, K, V, nullptr, nullptr, layer.wv_b, kq_scale, il); + out = ggml_reshape_3d(ctx0, out, v_mla, n_head, n_tokens); + ggml_tensor * gate = ggml_mul_mat(ctx0, layer.wqkv_gate, cur); + gate = ggml_sigmoid(ctx0, gate); + gate = ggml_reshape_3d(ctx0, gate, 1, n_head, n_tokens); + cb(gate, "mla_gate", il); + out = ggml_mul(ctx0, out, gate); + cb(out, "mla_gated", il); + out = ggml_cont_2d(ctx0, out, v_mla * n_head, n_tokens); + out = ggml_mul_mat(ctx0, layer.wo, out); + cb(out, "mla_out", il); + + return out; +} + +ggml_tensor * llama_model_youtu_moe::graph::build_qb_moe(ggml_tensor * cur, const llama_layer & layer, int il) { + const int64_t n_tokens_ffn = cur->ne[1]; + const int64_t n_used = hparams.n_expert_used(il); + GGML_ASSERT(n_used > 0 && n_used < hparams.n_expert); + + ggml_tensor * logits = ggml_mul_mat(ctx0, layer.ffn_gate_inp, cur); + ggml_prec_set_acc(logits, GGML_PREC_F32); + cb(logits, "ffn_moe_logits", il); + + // 逐 token 选行:文本用第 0 行、图像/视频用第 1 行(官方 _bias_for_tokens)。 + ggml_tensor * bias = ggml_get_rows(ctx0, layer.ffn_exp_probs_b, inp_modality); + cb(bias, "ffn_moe_qb_bias", il); + // 选择用 logits - beta,权重仍用 sigmoid(logits)。 + ggml_tensor * selection = ggml_sub(ctx0, logits, bias); + cb(selection, "ffn_moe_qb_selection", il); + ggml_tensor * top_k_plus_one = ggml_argsort_top_k(ctx0, selection, n_used + 1); + cb(top_k_plus_one, "ffn_moe_qb_topk_plus_one", il); + // 与官方一样先取 k+1 再保留前 k;严格 tie 的顺序不保证跨 torch/backend 相同。 + ggml_tensor * chosen = ggml_cont(ctx0, ggml_view_2d(ctx0, top_k_plus_one, + n_used, n_tokens_ffn, top_k_plus_one->nb[1], 0)); + + // 通用 normalize 的 YOUTU_MOE 分支应为 weights / (sum + 1e-20),而非 F16 clamp。 + ggml_tensor * moe = build_moe_ffn(cur, + nullptr, + layer.ffn_up_exps, + layer.ffn_gate_exps, + layer.ffn_down_exps, + nullptr, + hparams.n_expert, n_used, + LLM_FFN_SILU, true, hparams.expert_weights_scale, + LLAMA_EXPERT_GATING_FUNC_TYPE_SIGMOID, il, + logits, layer.ffn_gate_up_exps, + layer.ffn_up_exps_s, layer.ffn_gate_exps_s, layer.ffn_down_exps_s, + chosen); + cb(moe, "ffn_moe_out", il); + + ggml_tensor * shared = build_ffn(cur, + layer.ffn_up_shexp, nullptr, nullptr, + layer.ffn_gate_shexp, nullptr, nullptr, + layer.ffn_down_shexp, nullptr, nullptr, + nullptr, LLM_FFN_SILU, LLM_FFN_PAR, il); + cb(shared, "ffn_shexp", il); + ggml_tensor * out = ggml_add(ctx0, moe, shared); + cb(out, "ffn_out", il); + return out; +}