char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

DevVexusconvert : write explicit recurrent_layers for Qwen3-Next / Qwen3.5 (#28208)9a7570587

char/aliceai-t5-moe
39.8 KiB884 linesraw
1from __future__ import annotations
2
3import json
4
5from typing import Any, Callable, Iterable, TYPE_CHECKING
6
7import numpy as np
8import torch
9
10if TYPE_CHECKING:
11    from torch import Tensor
12
13from .base import LazyTorchTensor, ModelBase, TextModel, gguf, logger
14
15
16@ModelBase.register("QWenLMHeadModel")
17@ModelBase.example("Qwen/Qwen-7B")
18class QwenModel(TextModel):
19    model_arch = gguf.MODEL_ARCH.QWEN
20
21    @staticmethod
22    def token_bytes_to_string(b):
23        from transformers.convert_slow_tokenizer import bytes_to_unicode
24        byte_encoder = bytes_to_unicode()
25        return ''.join([byte_encoder[ord(char)] for char in b.decode('latin-1')])
26
27    @staticmethod
28    def bpe(mergeable_ranks: dict[bytes, int], token: bytes, max_rank: int | None = None) -> list[bytes]:
29        parts = [bytes([b]) for b in token]
30        while True:
31            min_idx = None
32            min_rank = None
33            for i, pair in enumerate(zip(parts[:-1], parts[1:])):
34                rank = mergeable_ranks.get(pair[0] + pair[1])
35                if rank is not None and (min_rank is None or rank < min_rank):
36                    min_idx = i
37                    min_rank = rank
38            if min_rank is None or (max_rank is not None and min_rank >= max_rank):
39                break
40            assert min_idx is not None
41            parts = parts[:min_idx] + [parts[min_idx] + parts[min_idx + 1]] + parts[min_idx + 2:]
42        return parts
43
44    def set_vocab(self):
45        self._set_vocab_qwen()
46
47
48@ModelBase.register(
49    "Qwen2Model",
50    "Qwen2ForCausalLM",
51    "Qwen2AudioForConditionalGeneration",
52    "KORMoForCausalLM",
53    "AudioFlamingo3ForConditionalGeneration",
54    "DotsOCRForCausalLM",
55)
56@ModelBase.example("Qwen/Qwen2.5-7B-Instruct")
57class Qwen2Model(TextModel):
58    model_arch = gguf.MODEL_ARCH.QWEN2
59
60    def set_vocab(self):
61        try:
62            self._set_vocab_sentencepiece()
63        except FileNotFoundError:
64            self._set_vocab_gpt2()
65
66    def set_gguf_parameters(self):
67        super().set_gguf_parameters()
68        self._try_set_pooling_type()
69
70    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
71        if self.hf_arch == "Qwen2Model":
72            name = f"model.{name}"  # map to Qwen2ForCausalLM tensors
73        yield from super().modify_tensors(data_torch, name, bid)
74
75
76@ModelBase.register("Qwen2MoeForCausalLM")
77@ModelBase.example("Qwen/Qwen1.5-MoE-A2.7B")
78class Qwen2MoeModel(TextModel):
79    model_arch = gguf.MODEL_ARCH.QWEN2MOE
80
81    def set_gguf_parameters(self):
82        super().set_gguf_parameters()
83        if (moe_intermediate_size := self.hparams.get("moe_intermediate_size")) is not None:
84            self.gguf_writer.add_expert_feed_forward_length(moe_intermediate_size)
85            logger.info(f"gguf: expert feed forward length = {moe_intermediate_size}")
86        if (shared_expert_intermediate_size := self.hparams.get('shared_expert_intermediate_size')) is not None:
87            self.gguf_writer.add_expert_shared_feed_forward_length(shared_expert_intermediate_size)
88            logger.info(f"gguf: expert shared feed forward length = {shared_expert_intermediate_size}")
89
90    _experts: list[dict[str, Tensor]] | None = None
91
92    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
93        # handle aggregated expert tensors
94        # GGUF stores dimensions reversed from PyTorch, so:
95        # PyTorch (A,B,C) -> GGUF writes [C,B,A] -> GGML reads ne={C,B,A}
96        # Input shapes from HF: (n_expert, n_ff_exp, n_embd) or (n_expert, n_embd, n_ff_exp)
97        # Expected GGML ne: {n_embd, n_ff_exp, n_expert} for gate/up, {n_ff_exp, n_embd, n_expert} for down
98        if name.endswith("mlp.experts.down_proj") or name.endswith("mlp.experts.down_proj.weight"):
99            mapped = f"{name}.weight" if not name.endswith(".weight") else name
100            # HF: [n_expert, n_embd, n_ff] -> GGML: {n_ff, n_embd, n_expert}
101            yield from super().modify_tensors(data_torch, mapped, bid)
102            return
103
104        if name.endswith("mlp.experts.gate_up_proj") or name.endswith("mlp.experts.gate_up_proj.weight"):
105            if data_torch.ndim < 3 or data_torch.shape[-2] % 2 != 0:
106                raise ValueError(f"Unexpected gate_up_proj shape for {name}: {tuple(data_torch.shape)}")
107            # HF: [n_expert, 2*n_ff, n_embd] -> split on dim=-2
108            n_ff = data_torch.shape[-2] // 2
109            gate = data_torch[..., :n_ff, :].contiguous()
110            up = data_torch[..., n_ff:, :].contiguous()
111            # gate/up: [n_expert, n_ff, n_embd] -> GGML: {n_embd, n_ff, n_expert}
112            base_name = name.removesuffix(".weight").removesuffix(".gate_up_proj")
113            mapped_gate = f"{base_name}.gate_proj.weight"
114            mapped_up = f"{base_name}.up_proj.weight"
115            yield from super().modify_tensors(gate, mapped_gate, bid)
116            yield from super().modify_tensors(up, mapped_up, bid)
117            return
118
119        if name.find("experts") != -1:
120            n_experts = self.find_hparam(["num_local_experts", "num_experts"])
121            assert bid is not None
122
123            if self._experts is None:
124                self._experts = [{} for _ in range(self.block_count)]
125
126            self._experts[bid][name] = data_torch
127
128            if len(self._experts[bid]) >= n_experts * 3:
129                # merge the experts into a single 3d tensor
130                for w_name in ["down_proj", "gate_proj", "up_proj"]:
131                    datas: list[Tensor] = []
132
133                    for xid in range(n_experts):
134                        ename = f"model.layers.{bid}.mlp.experts.{xid}.{w_name}.weight"
135                        datas.append(self._experts[bid][ename])
136                        del self._experts[bid][ename]
137
138                    data_torch = torch.stack(datas, dim=0)
139
140                    merged_name = f"model.layers.{bid}.mlp.experts.{w_name}.weight"
141
142                    yield from super().modify_tensors(data_torch, merged_name, bid)
143                return
144            else:
145                return
146
147        yield from super().modify_tensors(data_torch, name, bid)
148
149    def prepare_tensors(self):
150        super().prepare_tensors()
151
152        if self._experts is not None:
153            # flatten `list[dict[str, Tensor]]` into `list[str]`
154            experts = [k for d in self._experts for k in d.keys()]
155            if len(experts) > 0:
156                raise ValueError(f"Unprocessed experts: {experts}")
157
158
159@ModelBase.register("Qwen3ForCausalLM", "Qwen3Model")
160@ModelBase.example("Qwen/Qwen3-8B")
161class Qwen3Model(Qwen2Model):
162    model_arch = gguf.MODEL_ARCH.QWEN3
163
164    # extra logic for rerank models
165    is_rerank: bool = False
166    is_tied_embeddings: bool = False
167    token_false_id: int | None = None
168    token_true_id: int | None = None
169
170    def __init__(self, *args, **kwargs):
171        super().__init__(*args, **kwargs)
172
173        # track for intern-s1-mini
174        hparams = ModelBase.load_hparams(self.dir_model, is_mistral_format=False)
175        self.origin_hf_arch = hparams.get('architectures', [None])[0]
176
177        if self._is_qwen3_reranker():
178            self._find_rerank_config()
179
180    def _is_qwen3_reranker(self) -> bool:
181        readme_path = self.dir_model / "README.md"
182        readme_text = ""
183        if readme_path.exists():
184            with readme_path.open("r", encoding="utf-8") as f:
185                readme_text = f.read()
186
187        name_hints = [
188            str(self.dir_model.name),
189            str(self.hparams.get("_name_or_path", "")),
190            str(self.hparams.get("model_type", "")),
191            str(self.origin_hf_arch or ""),
192        ]
193        name_hints = [hint.lower() for hint in name_hints if hint]
194
195        if "# qwen3-reranker" in readme_text.lower() or "# qwen3-vl-reranker" in readme_text.lower():
196            return True
197
198        if any("qwen3-reranker" in hint or "qwen3-vl-reranker" in hint for hint in name_hints):
199            return True
200
201        return "sequenceclassification" in (self.origin_hf_arch or "").lower()
202
203    def set_vocab(self):
204        # deal with intern-s1-mini
205        if self.origin_hf_arch == 'InternS1ForConditionalGeneration':
206            self._set_vocab_interns1()
207            return
208
209        super().set_vocab()
210
211    def _find_rerank_config(self):
212        from transformers import AutoTokenizer
213        tokenizer = AutoTokenizer.from_pretrained(self.dir_model)
214
215        self.is_rerank = True
216        self.is_tied_embeddings = self.hparams.get("tie_word_embeddings", False)
217        self.token_false_id = tokenizer.convert_tokens_to_ids("no")  # ty: ignore[unresolved-attribute, invalid-assignment]
218        self.token_true_id = tokenizer.convert_tokens_to_ids("yes")  # ty: ignore[unresolved-attribute, invalid-assignment]
219        self.sep_token_id = tokenizer.convert_tokens_to_ids("|")  # ty: ignore[unresolved-attribute]
220
221        assert self.token_false_id is not None and self.token_true_id is not None
222
223    def set_gguf_parameters(self):
224        super().set_gguf_parameters()
225        if self.is_rerank:
226            self.gguf_writer.add_pooling_type(gguf.PoolingType.RANK)
227            self.gguf_writer.add_classifier_output_labels(["yes", "no"])
228            self.gguf_writer.add_chat_template([{
229                "name": "rerank",
230                "template": "<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n"
231                            "<|im_start|>user\n<Instruct>: Given a web search query, retrieve relevant passages that answer the query\n<Query>: {query}\n<Document>: {document}<|im_end|>\n"
232                            "<|im_start|>assistant\n<think>\n\n</think>\n\n"
233            }])
234
235    def _get_cls_out_tensor(self, data_torch: Tensor) -> Tensor:
236        # extract "yes" and "no" tokens from the output lm_head tensor
237        false_row = data_torch[self.token_false_id]
238        true_row = data_torch[self.token_true_id]
239        return torch.stack([true_row, false_row], dim=0)
240
241    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
242        if self.is_rerank:
243            is_tied_head = self.is_tied_embeddings and "embed_tokens" in name
244            is_real_head = not self.is_tied_embeddings and "lm_head" in name
245            if is_tied_head or is_real_head:
246                cls_out_head = (
247                    gguf.TENSOR_NAMES[gguf.MODEL_TENSOR.CLS_OUT] + ".weight",
248                    self._get_cls_out_tensor(data_torch),
249                )
250                yield cls_out_head
251                if is_tied_head:
252                    yield from super().modify_tensors(data_torch, name, bid)
253                return
254
255        yield from super().modify_tensors(data_torch, name, bid)
256
257
258@ModelBase.register("Qwen3MoeForCausalLM")
259@ModelBase.example("Qwen/Qwen3-30B-A3B")
260class Qwen3MoeModel(Qwen2MoeModel):
261    model_arch = gguf.MODEL_ARCH.QWEN3MOE
262
263    def __init__(self, *args, **kwargs):
264        super().__init__(*args, **kwargs)
265        hparams = ModelBase.load_hparams(self.dir_model, False)
266        self.origin_hf_arch = hparams.get('architectures', [None])[0]
267
268    def set_vocab(self):
269        # deal with intern-s1
270        if self.origin_hf_arch == 'InternS1ForConditionalGeneration':
271            self._set_vocab_interns1()
272            return
273
274        super().set_vocab()
275
276
277class _QwenMtpMixin:
278    """Shared MTP wiring for Qwen3-Next and Qwen3.5/3.6 text variants. The HF
279    config carries the MTP block under `mtp_num_hidden_layers` (computed from
280    the checkpoint when absent, e.g. Qwen3-Next) and the tensors under
281    `mtp.*`; we extend block_count, emit the nextn metadata key, and remap
282    `mtp.*` to the standard layer-indexed nextn naming so the existing
283    tensor_map handles them."""
284
285    supports_mtp_export = True
286    hparams: dict[str, Any]
287    model_arch: gguf.MODEL_ARCH
288    gguf_writer: gguf.GGUFWriter
289    block_count: int
290    tensor_map: gguf.TensorNameMap
291    no_mtp: bool
292    mtp_only: bool
293    _original_block_count: int | None = None
294    opt_num_mtp_layers: int = 0
295
296    def __init__(self, *args, **kwargs):
297        super().__init__(*args, **kwargs)
298        self.block_count = self.hparams["num_hidden_layers"]
299        if not self.no_mtp:
300            n_mtp = self.hparams.get("mtp_num_hidden_layers", 0)
301            # Qwen-3-Next doesn't include `mtp_num_hidden_layers` in config.
302            if n_mtp == 0:
303                assert self.opt_num_mtp_layers != 0
304                n_mtp = self.opt_num_mtp_layers
305            self.block_count += n_mtp
306        self.tensor_map = gguf.get_tensor_name_map(self.model_arch, self.block_count)
307
308    def index_tensors(self, remote_hf_model_id: str | None = None) -> dict[str, Callable[[], Tensor]]:
309        hparams = {**self.hparams, **self.hparams.get("text_config", {})}
310        key = next((k for k in ["n_layers", "num_hidden_layers", "n_layer", "num_layers"] if k in hparams), None)
311        type(self)._original_block_count = hparams.get(key)
312        type(self).opt_num_mtp_layers = 0
313        return super().index_tensors(remote_hf_model_id=remote_hf_model_id)  # ty: ignore[unresolved-attribute]
314
315    @classmethod
316    def filter_tensors(cls, item):
317        assert cls._original_block_count is not None
318        # TODO: change TextModel to super()
319        if (titem := TextModel.filter_tensors(item)) is None:
320            return None
321        name, gen = titem
322        if name.startswith("model.mtp."):
323            name = name.replace("model.", "", 1)
324        if name.startswith("mtp."):
325            if cls.no_mtp:
326                return None
327            remapper = {
328                "fc":                    "eh_proj",
329                "pre_fc_norm_embedding": "enorm",
330                "pre_fc_norm_hidden":    "hnorm",
331                "norm":                  "shared_head.norm",
332            }
333            parts = name.split(".", 3)
334            if len(parts) == 4 and parts[1] == "layers" and parts[2].isdecimal():
335                mtp_idx = int(parts[2])
336                name = f"model.layers.{cls._original_block_count + mtp_idx}.{parts[3]}"
337                cls.opt_num_mtp_layers = max(cls.opt_num_mtp_layers, mtp_idx + 1)
338            elif len(parts) == 3 and parts[1] in remapper:
339                name = f"model.layers.{cls._original_block_count}.{remapper[parts[1]]}.{parts[2]}"
340        elif cls.mtp_only:
341            keep = name in (
342                "model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
343                "embed_tokens.weight", "norm.weight",
344            )
345            if not keep:
346                return None
347        return name, gen
348
349    def set_gguf_parameters(self):
350        super().set_gguf_parameters()  # ty: ignore[unresolved-attribute]
351        if self.no_mtp:
352            return
353        if (n := self.block_count - self.hparams["num_hidden_layers"]) > 0:
354            self.gguf_writer.add_nextn_predict_layers(n)
355
356    def prepare_metadata(self, vocab_only: bool):
357        from_dir = self.fname_out.is_dir()
358        super().prepare_metadata(vocab_only=vocab_only)  # ty: ignore[unresolved-attribute]
359
360        if not self.mtp_only or not from_dir:
361            return
362
363        output_type: str = self.ftype.name.partition("_")[2]  # pyright: ignore[reportAttributeAccessIssue] # ty: ignore[unresolved-attribute]
364        fname_default: str = gguf.naming_convention(
365            self.metadata.name, self.metadata.basename, self.metadata.finetune,                  # pyright: ignore[reportAttributeAccessIssue] # ty: ignore[unresolved-attribute]
366            self.metadata.version, size_label=None, output_type=output_type, model_type=None)    # pyright: ignore[reportAttributeAccessIssue] # ty: ignore[unresolved-attribute]
367        self.fname_out = self.fname_out.parent / f"mtp-{fname_default}.gguf"
368
369
370@ModelBase.register("Qwen3NextForCausalLM")
371@ModelBase.example("Qwen/Qwen3-Next-80B-A3B-Instruct")
372class Qwen3NextModel(_QwenMtpMixin, Qwen2MoeModel):
373    model_arch = gguf.MODEL_ARCH.QWEN3NEXT
374
375    def set_gguf_parameters(self):
376        super().set_gguf_parameters()
377        self.gguf_writer.add_ssm_conv_kernel(self.hparams["linear_conv_kernel_dim"])
378        self.gguf_writer.add_ssm_state_size(self.hparams["linear_key_head_dim"])
379        self.gguf_writer.add_ssm_group_count(self.hparams["linear_num_key_heads"])
380        self.gguf_writer.add_ssm_time_step_rank(self.hparams["linear_num_value_heads"])
381        self.gguf_writer.add_ssm_inner_size(self.hparams["linear_value_head_dim"] * self.hparams["linear_num_value_heads"])
382        if (layer_types := self.hparams.get("layer_types")) is not None:
383            n_layer = self.hparams["num_hidden_layers"]
384            if len(layer_types) != n_layer:
385                raise ValueError(f"layer_types has {len(layer_types)} entries, expected num_hidden_layers ({n_layer})")
386            recurrent = [t == "linear_attention" for t in layer_types]
387            recurrent += [False] * (self.block_count - n_layer)
388            self.gguf_writer.add_recurrent_layers(recurrent)
389        self.gguf_writer.add_full_attention_interval(self.hparams.get("full_attention_interval", 4))
390        if (rope_dim := self.hparams.get("head_dim")) is None:
391            rope_dim = self.hparams["hidden_size"] // self.hparams["num_attention_heads"]
392        self.gguf_writer.add_rope_dimension_count(int(rope_dim * self.rope_parameters.get("partial_rotary_factor", 0.25)))
393
394    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
395        if name.endswith(".A_log"):
396            data_torch = -torch.exp(data_torch)
397        elif name.endswith(".dt_bias"):
398            name = name.rpartition(".dt_bias")[0] + ".dt_proj.bias"
399        elif "conv1d" in name:
400            data_torch = data_torch.squeeze()
401        elif name.endswith("norm.weight") and not name.endswith("linear_attn.norm.weight"):
402            data_torch = data_torch + 1
403
404        if "in_proj_qkvz.weight" in name:
405            # original order:  [q, k, v, z] * head_count
406            # corrected order: [q * head_count, k * head_count, v * head_count, z * head_count]
407            head_k_dim = self.hparams["linear_key_head_dim"]
408            head_v_dim = self.hparams["linear_value_head_dim"]
409            num_v_heads = self.hparams["linear_num_value_heads"]
410            num_k_heads = self.hparams["linear_num_key_heads"]
411            hidden_size = self.hparams["hidden_size"]
412            split_arg_list_qkvz = [
413                head_k_dim, # q partition
414                head_k_dim, # k partition
415                (num_v_heads // num_k_heads * head_v_dim), # v partition
416                (num_v_heads // num_k_heads * head_v_dim), # z partition
417            ]
418            # view as (n_embd, head_count, [q+k+v+z])
419            data_torch = data_torch.permute(1, 0).contiguous()
420            data_torch = data_torch.view(-1, num_k_heads, sum(split_arg_list_qkvz))
421            # split into q, k, v, z
422            q, k, v, z = torch.split(data_torch, split_arg_list_qkvz, dim=-1)
423            # flatten dim + head_count
424            q = q.contiguous().view(hidden_size, -1)
425            k = k.contiguous().view(hidden_size, -1)
426            v = v.contiguous().view(hidden_size, -1)
427            z = z.contiguous().view(hidden_size, -1)
428            # stack back
429            qkv = torch.cat([q, k, v], dim=-1).permute(1, 0).contiguous()
430            z = z.permute(1, 0).contiguous()
431            yield (self.format_tensor_name(gguf.MODEL_TENSOR.ATTN_QKV,  bid, ".weight"), qkv)
432            yield (self.format_tensor_name(gguf.MODEL_TENSOR.ATTN_GATE, bid, ".weight"), z)
433        else:
434            yield from super().modify_tensors(data_torch, name, bid)
435
436
437@ModelBase.register("RND1")
438@ModelBase.example("radicalnumerics/RND1-Base-0910")
439class RND1Model(Qwen2MoeModel):
440    model_arch = gguf.MODEL_ARCH.RND1
441
442    def set_gguf_parameters(self):
443        super().set_gguf_parameters()
444
445        # RND1 specific parameters
446        # RND1 uses bidirectional attention
447        self.gguf_writer.add_causal_attention(False)
448
449        if (mask_token_id := self.hparams.get("mask_token_id")) is not None:
450            self.gguf_writer.add_mask_token_id(mask_token_id)
451
452
453class _LinearAttentionVReorderBase(Qwen3NextModel):
454    model_arch = gguf.MODEL_ARCH.QWEN3NEXT  # overridden by subclasses
455    """reorders V heads from grouped to tiled order for ggml broadcast
456
457    see https://github.com/ggml-org/llama.cpp/pull/19468#discussion_r2786394306
458
459    Linear attention may has num_k_heads < num_v_heads. The HF weights store
460    V heads grouped by K head: [G0_v0..v{r-1}, G1_v0..v{r-1}, ...].
461    ggml binary ops use tiled broadcast: [K0, K1, ..., K0, K1, ...].
462    We reorder V heads to tiled order so ggml_repeat can replace the expensive
463    interleaved repeat: [G0_v0, G1_v0, ..., G0_v1, G1_v1, ...].
464    """
465
466    @staticmethod
467    def _reorder_v_heads(tensor: Tensor, dim: int, num_k_heads: int, num_v_per_k: int, head_dim: int) -> Tensor:
468        """Reorder V heads from grouped (by K head) to tiled order along the given dimension."""
469        shape = list(tensor.shape)
470        if dim < 0:
471            dim += len(shape)
472        new_shape = shape[:dim] + [num_k_heads, num_v_per_k, head_dim] + shape[dim + 1:]
473        tensor = tensor.reshape(*new_shape)
474        perm = list(range(len(new_shape)))
475        perm[dim], perm[dim + 1] = perm[dim + 1], perm[dim]
476        return tensor.permute(*perm).contiguous().reshape(*shape)
477
478    def _transform_nvfp4_weight(self, name: str, weight: Tensor, scale: Tensor) -> tuple[Tensor, Tensor]:
479        if not name.endswith((
480            ".linear_attn.in_proj_qkv.weight",
481            ".linear_attn.in_proj_z.weight",
482            ".linear_attn.in_proj_a.weight",
483            ".linear_attn.in_proj_b.weight",
484            ".linear_attn.out_proj.weight",
485        )):
486            return weight, scale
487
488        num_k_heads = self.hparams["linear_num_key_heads"]
489        num_v_heads = self.hparams["linear_num_value_heads"]
490        head_k_dim = self.hparams["linear_key_head_dim"]
491        head_v_dim = self.hparams["linear_value_head_dim"]
492        num_v_per_k = num_v_heads // num_k_heads
493
494        def unpack_nibbles(qs: Tensor) -> Tensor:
495            lo = torch.bitwise_and(qs, 0x0F)
496            hi = torch.bitwise_right_shift(qs, 4)
497            return torch.stack((lo, hi), dim=-1).reshape(*qs.shape[:-1], qs.shape[-1] * 2)
498
499        def pack_nibbles(codes: Tensor) -> Tensor:
500            codes = codes.reshape(*codes.shape[:-1], codes.shape[-1] // 2, 2)
501            lo = torch.bitwise_and(codes[..., 0], 0x0F)
502            hi = torch.bitwise_left_shift(torch.bitwise_and(codes[..., 1], 0x0F), 4)
503            return torch.bitwise_or(lo, hi).contiguous()
504
505        def apply_col_perm(qs: Tensor, scales: Tensor, col_perm: Tensor) -> tuple[Tensor, Tensor]:
506            assert qs.ndim >= 2
507            assert scales.ndim >= 2
508
509            k = qs.shape[-1] * 2
510            assert col_perm.numel() == k
511            assert k % 16 == 0
512
513            group_cols = col_perm.reshape(-1, 16)
514            group_starts = group_cols[:, 0]
515            expected = group_starts.unsqueeze(1) + torch.arange(16, dtype=col_perm.dtype)
516            assert torch.equal(group_cols, expected)
517            assert torch.all(group_starts % 16 == 0)
518
519            group_perm = (group_starts // 16).to(dtype=torch.long)
520            expected_groups = torch.arange(scales.shape[-1], dtype=torch.long)
521            assert group_perm.numel() == scales.shape[-1]
522            assert torch.equal(torch.sort(group_perm).values, expected_groups)
523
524            codes = unpack_nibbles(qs)
525            codes = codes.index_select(-1, col_perm.to(device=qs.device, dtype=torch.long))
526            qs = pack_nibbles(codes)
527            scales = scales.index_select(-1, group_perm.to(device=scales.device))
528            return qs, scales
529
530        def reorder_rows(qs: Tensor, scales: Tensor, head_dim: int) -> tuple[Tensor, Tensor]:
531            row_perm = self._reorder_v_heads(
532                torch.arange(num_v_heads * head_dim, dtype=torch.long).unsqueeze(-1),
533                0, num_k_heads, num_v_per_k, head_dim,
534            ).squeeze(-1)
535            return (
536                qs.index_select(0, row_perm.to(device=qs.device)),
537                scales.index_select(0, row_perm.to(device=scales.device)),
538            )
539
540        if name.endswith(".linear_attn.in_proj_qkv.weight"):
541            q_dim = head_k_dim * num_k_heads
542            k_dim = head_k_dim * num_k_heads
543            q = weight[:q_dim]
544            k = weight[q_dim:q_dim + k_dim]
545            v = weight[q_dim + k_dim:]
546            q_scale = scale[:q_dim]
547            k_scale = scale[q_dim:q_dim + k_dim]
548            v_scale = scale[q_dim + k_dim:]
549            v, v_scale = reorder_rows(v, v_scale, head_v_dim)
550            return torch.cat([q, k, v], dim=0), torch.cat([q_scale, k_scale, v_scale], dim=0)
551
552        if name.endswith(".linear_attn.in_proj_z.weight"):
553            weight, scale = reorder_rows(weight, scale, head_v_dim)
554        elif name.endswith((".linear_attn.in_proj_a.weight", ".linear_attn.in_proj_b.weight")):
555            weight, scale = reorder_rows(weight, scale, 1)
556        elif name.endswith(".linear_attn.out_proj.weight"):
557            col_perm = self._reorder_v_heads(
558                torch.arange(num_v_heads * head_v_dim, dtype=torch.long).unsqueeze(0),
559                1, num_k_heads, num_v_per_k, head_v_dim,
560            ).squeeze(0)
561            weight, scale = apply_col_perm(weight, scale, col_perm)
562
563        return weight, scale
564
565    def _repack_nvfp4(self, name: str, weight: Tensor, scale: Tensor, scale2: Tensor, input_scale: Tensor):
566        weight, scale = self._transform_nvfp4_weight(name, weight, scale)
567        super()._repack_nvfp4(name, weight, scale, scale2, input_scale)
568
569    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
570        num_k_heads = self.hparams.get("linear_num_key_heads", 0)
571        num_v_heads = self.hparams.get("linear_num_value_heads", 0)
572
573        if num_k_heads > 0 and num_v_heads > 0 and num_k_heads != num_v_heads and "linear_attn." in name:
574            head_k_dim = self.hparams["linear_key_head_dim"]
575            head_v_dim = self.hparams["linear_value_head_dim"]
576            num_v_per_k = num_v_heads // num_k_heads
577
578            if ".in_proj_qkv." in name:
579                # QKV weight: reorder only the V rows
580                q_dim = head_k_dim * num_k_heads
581                k_dim = head_k_dim * num_k_heads
582                q = data_torch[:q_dim]
583                k = data_torch[q_dim:q_dim + k_dim]
584                v = data_torch[q_dim + k_dim:]
585                v = self._reorder_v_heads(v, 0, num_k_heads, num_v_per_k, head_v_dim)
586                data_torch = torch.cat([q, k, v], dim=0)
587
588            elif ".in_proj_z." in name:
589                # Z gate weight: reorder rows (num_v_heads * head_v_dim)
590                data_torch = self._reorder_v_heads(data_torch, 0, num_k_heads, num_v_per_k, head_v_dim)
591
592            elif ".in_proj_b." in name or ".in_proj_a." in name:
593                # Beta/Alpha weight: reorder rows (num_v_heads, head_dim=1)
594                data_torch = self._reorder_v_heads(data_torch, 0, num_k_heads, num_v_per_k, 1)
595
596            elif ".A_log" in name or ".dt_bias" in name or ".dt_proj" in name:
597                # A_log / dt_bias: 1D parameters with num_v_heads elements
598                if data_torch.ndim == 1:
599                    data_torch = self._reorder_v_heads(
600                        data_torch.unsqueeze(-1), 0, num_k_heads, num_v_per_k, 1
601                    ).squeeze(-1)
602                else:
603                    data_torch = self._reorder_v_heads(data_torch, -1, num_k_heads, num_v_per_k, 1)
604
605            elif ".conv1d" in name:
606                # Conv1d kernel: reorder only the V channel portion
607                data = data_torch.squeeze()
608                qk_channels = head_k_dim * num_k_heads * 2
609                qk_part = data[:qk_channels]
610                v_part = data[qk_channels:]
611                v_part = self._reorder_v_heads(v_part, 0, num_k_heads, num_v_per_k, head_v_dim)
612                data_torch = torch.cat([qk_part, v_part], dim=0)
613
614            elif ".out_proj." in name:
615                # Out projection weight: reorder columns (input dimension)
616                data_torch = self._reorder_v_heads(data_torch, 1, num_k_heads, num_v_per_k, head_v_dim)
617
618        yield from super().modify_tensors(data_torch, name, bid)
619
620
621class _Qwen35MRopeMixin:
622    # Qwen3.5 always applies interleaved MRoPE (see Qwen3_5RotaryEmbedding in transformers);
623    # the upstream default mrope_section is [11, 11, 10] and llama.cpp's QWEN35 / QWEN35MOE
624    # loaders treat qwen35.rope.dimension_sections as required, so make sure it is always
625    # written even when a particular checkpoint omits the field in `rope_parameters`.
626    _QWEN35_DEFAULT_MROPE_SECTION = [11, 11, 10, 0]
627
628    gguf_writer: gguf.GGUFWriter
629    rope_parameters: dict
630
631    def set_gguf_parameters(self):
632        super().set_gguf_parameters()  # ty: ignore[unresolved-attribute]
633        if "mrope_section" not in self.rope_parameters:
634            self.gguf_writer.add_rope_dimension_sections(self._QWEN35_DEFAULT_MROPE_SECTION)
635
636
637@ModelBase.register("Qwen3_5ForConditionalGeneration", "Qwen3_5ForCausalLM")
638@ModelBase.example("Qwen/Qwen3.5-9B")
639class Qwen3_5TextModel(_Qwen35MRopeMixin, _LinearAttentionVReorderBase):
640    model_arch = gguf.MODEL_ARCH.QWEN35
641
642
643@ModelBase.register("Qwen3_5MoeForConditionalGeneration", "Qwen3_5MoeForCausalLM")
644@ModelBase.example("Qwen/Qwen3.5-35B-A3B")
645class Qwen3_5MoeTextModel(_Qwen35MRopeMixin, _LinearAttentionVReorderBase):
646    model_arch = gguf.MODEL_ARCH.QWEN35MOE
647
648
649@ModelBase.register("DFlashDraftModel", "DFlash2DraftModel")
650@ModelBase.example("z-lab/Qwen3.5-9B-DFlash")
651class DFlashModel(Qwen3Model):
652    model_arch = gguf.MODEL_ARCH.DFLASH
653
654    def set_vocab(self):
655        if self.target_model_dir is None:
656            raise ValueError(
657                "DFlash draft model requires --target-model-dir to be specified. "
658                "Please provide the path to the target model directory containing the tokenizer."
659            )
660        logger.info(f"DFlash: Using tokenizer from target model: {self.target_model_dir}")
661        original_dir = self.dir_model
662        self.dir_model = self.target_model_dir
663
664        # Reuse the target model's own vocab handler (e.g. Gemma-4 needs its
665        # own tokenizer logic, not the Qwen default).
666        from . import get_model_class
667        with open(self.target_model_dir / "config.json", "r", encoding="utf-8") as f:
668            target_hparams = json.load(f)
669            target_arch = target_hparams["architectures"][0]
670        target_cls = get_model_class(target_arch)
671
672        if target_cls is not type(self):
673            if target_arch == "NemotronHForCausalLM":
674                setattr(self, "is_moe", "num_experts_per_tok" in target_hparams)
675            target_cls.set_vocab(self)  # ty: ignore[unresolved-attribute]
676        else:
677            super().set_vocab()
678
679        self.dir_model = original_dir
680
681        mask_token_id = self.hparams.get("dflash_config", {}).get("mask_token_id")
682        if mask_token_id is not None:
683            self.gguf_writer.add_mask_token_id(mask_token_id)
684
685    def set_gguf_parameters(self):
686        super().set_gguf_parameters()
687
688        dflash_config = self.hparams.get("dflash_config", {})
689        block_size = dflash_config.get("block_size", self.hparams.get("block_size", 16))
690        self.gguf_writer.add_block_size(block_size)
691
692        if "conv_kernel_size" in dflash_config:
693            self.gguf_writer.add_conv_kernel_size(int(dflash_config["conv_kernel_size"]))
694            self.gguf_writer.add_conv_group_size(int(dflash_config["conv_group_size"]))
695            self.gguf_writer.add_selector_rank(int(dflash_config["selector_rank"]))
696            self.gguf_writer.add_selector_top_k(int(dflash_config["selector_top_k"]))
697
698        output_multiplier = dflash_config.get(
699            "output_multiplier", self.hparams.get("output_multiplier")
700        )
701        if output_multiplier is not None:
702            self.gguf_writer.add_logit_scale(float(output_multiplier))
703        softcap = dflash_config.get(
704            "final_logit_softcapping", self.hparams.get("final_logit_softcapping")
705        )
706        if softcap is not None and float(softcap) > 0:
707            self.gguf_writer.add_final_logit_softcapping(float(softcap))
708        embedding_scale = dflash_config.get(
709            "input_embedding_scale", self.hparams.get("input_embedding_scale")
710        )
711        if embedding_scale is not None:
712            self.gguf_writer.add_embedding_scale(float(embedding_scale))
713
714        target_layer_ids = dflash_config.get("target_layer_ids", [])
715        if target_layer_ids:
716            extract_layer_ids = [i + 1 for i in target_layer_ids]
717            self.gguf_writer.add_target_layers(extract_layer_ids)
718
719        use_sliding_window = self.hparams.get("use_sliding_window", False) or dflash_config.get("use_swa", False)
720        sliding_window = dflash_config.get("swa_window_size") or self.hparams.get("sliding_window")
721        layer_types = self.hparams.get("layer_types")
722        if use_sliding_window and sliding_window and layer_types:
723            is_swa = [lt == "sliding_attention" for lt in layer_types]
724            self.gguf_writer.add_sliding_window(sliding_window)
725            self.gguf_writer.add_sliding_window_pattern(is_swa)
726
727        causal = self.hparams.get("is_causal")
728        if causal is None:
729            causal = dflash_config.get("causal")
730        if causal is not None:
731            self.gguf_writer.add_causal_attention(bool(causal))
732
733        # M-RoPE target: the draft ropes on the temporal dim only, so write
734        # degenerate sections [n_rot/2, 0, 0, 0]
735        if self._target_uses_mrope():
736            head_dim = self.hparams.get("head_dim") or self.hparams["hidden_size"] // self.hparams["num_attention_heads"]
737            self.gguf_writer.add_rope_dimension_sections([head_dim // 2, 0, 0, 0])
738
739    def _target_uses_mrope(self) -> bool:
740        if self.target_model_dir is None:
741            return False
742        with open(self.target_model_dir / "config.json", "r", encoding="utf-8") as f:
743            cfg = json.load(f)
744        cfg = cfg.get("text_config", cfg)
745        rope = cfg.get("rope_parameters") or cfg.get("rope_scaling") or {}
746        return "mrope_section" in rope
747
748    @classmethod
749    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
750        name, gen = item
751        if not name.startswith("model."):
752            name = "model." + name
753        if "sink" in name and not name.endswith(".weight"):
754            name += ".weight"
755        return super().filter_tensors((name, gen))
756
757    _ROPE_PERMUTE_SUFFIXES = (
758        "self_attn.q_proj.weight",
759        "self_attn.k_proj.weight",
760        "self_attn.q_norm.weight",
761        "self_attn.k_norm.weight",
762    )
763
764    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
765        if name == "model.embed_tokens.weight" and not self.hparams.get("has_embed_tokens", True):
766            return
767
768        # interleaved-rope checkpoints (rope_is_neox_style = false) -> NeoX layout: per head, even dims first then odd
769        if not self.hparams.get("rope_is_neox_style", True) and name.endswith(self._ROPE_PERMUTE_SUFFIXES):
770            head_dim = self.hparams["head_dim"]
771            shape = data_torch.shape
772            data_torch = data_torch.reshape(-1, head_dim // 2, 2, *shape[1:]).transpose(1, 2).reshape(shape)
773
774        if name in (
775            "model.candidate_selector.predecessor_codebook",
776            "model.candidate_selector.successor_codebook",
777        ):
778            name += ".weight"
779
780        yield from super().modify_tensors(data_torch, name, bid)
781
782
783@ModelBase.register(
784    "Qwen3DSparkModel",
785    "DSparkDraftModel",
786    "DSparkSpeculator",
787    "Lfm2DSparkDraftModel",
788    "LingDSparkModel",
789)
790@ModelBase.example("satgeze/Qwen3.6-27B-DSpark")
791class DSparkModel(DFlashModel):
792    # DSpark = DFlash + a semi-autoregressive Markov head.
793    model_arch = gguf.MODEL_ARCH.DFLASH
794
795    def __init__(self, dir_model, *args, **kwargs):
796        hparams = kwargs.pop("hparams", None)
797        if hparams is None:
798            hparams = ModelBase.load_hparams(dir_model, False)
799
800        # EAGLE3-style exports use the 1+N bonus-anchor block, DFlash-lineage exports sample from the anchor
801        self._sample_from_anchor = hparams.get(
802            "sample_from_anchor",
803            "transformer_layer_config" not in hparams and "aux_hidden_state_layer_ids" not in hparams)
804        if "transformer_layer_config" in hparams:
805            hparams = {**hparams, **hparams["transformer_layer_config"]}
806
807        super().__init__(dir_model, *args, hparams=hparams, **kwargs)
808
809        # normalize both schemas to DFlash's nested dflash_config
810        if "aux_hidden_state_layer_ids" in self.hparams:
811            self.hparams.setdefault("dflash_config", {
812                "mask_token_id": self.hparams.get("mask_token_id"),
813                "target_layer_ids": [i - 1 for i in self.hparams["aux_hidden_state_layer_ids"]],
814            })
815        else:
816            self.hparams.setdefault("dflash_config", {
817                k: self.hparams[k] for k in ("target_layer_ids", "mask_token_id") if k in self.hparams
818            })
819
820        if (markov_head_type := self.hparams.get("markov_head_type", "vanilla")) != "vanilla":
821            raise ValueError(f"unsupported markov_head_type {markov_head_type!r} (only 'vanilla' is supported)")
822
823        n_vocab = self.hparams["vocab_size"]
824        self._n_vocab_draft = self.hparams.get("draft_vocab_size") or n_vocab
825        if self._n_vocab_draft > n_vocab:
826            raise ValueError(f"draft_vocab_size {self._n_vocab_draft} exceeds vocab_size {n_vocab}")
827        self._d2t: Tensor | None = None
828
829    def set_gguf_parameters(self):
830        super().set_gguf_parameters()
831        self.gguf_writer.add_sample_from_anchor(self._sample_from_anchor)
832
833        # confidence head is optional: vanilla-markov exports ship without it
834        has_conf = any("confidence_head.proj" in name for name in self.model_tensors)
835        self.gguf_writer.add_has_confidence_head(has_conf)
836
837    @classmethod
838    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
839        if item[0] == "t2d":  # not used at runtime
840            return None
841        return super().filter_tensors(item)
842
843    _ROPE_PERMUTE_SUFFIXES = (
844        "self_attn.q_proj.weight",
845        "self_attn.k_proj.weight",
846        "self_attn.q_norm.weight",
847        "self_attn.k_norm.weight",
848    )
849
850    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
851        if name == "model.d2t":
852            self._d2t = data_torch
853            return
854
855        if self._n_vocab_draft == self.hparams["vocab_size"] and name.endswith("lm_head.weight"):
856            return
857
858        # interleaved-rope checkpoints (rope_is_neox_style = false) -> NeoX layout: per head, even dims first then odd
859        if not self.hparams.get("rope_is_neox_style", True) and name.endswith(self._ROPE_PERMUTE_SUFFIXES):
860            head_dim = self.hparams["head_dim"]
861            shape = data_torch.shape
862            data_torch = data_torch.reshape(-1, head_dim // 2, 2, *shape[1:]).transpose(1, 2).reshape(shape)
863
864        yield from super().modify_tensors(data_torch, name, bid)
865
866    def prepare_tensors(self):
867        super().prepare_tensors()
868
869        n_vocab = self.hparams["vocab_size"]
870        if self._n_vocab_draft < n_vocab and self._d2t is None:
871            raise ValueError(f"draft_vocab_size {self._n_vocab_draft} < vocab_size {n_vocab} but no d2t table found")
872
873        # write d2t as absolute target token ids
874        if self._d2t is not None:
875            data = LazyTorchTensor.to_eager(self._d2t).to(torch.int64).cpu().numpy().reshape(-1)
876            if data.size != self._n_vocab_draft:
877                raise ValueError(f"d2t size {data.size} does not match draft_vocab_size {self._n_vocab_draft}")
878            data = data + np.arange(data.size, dtype=np.int64)
879            if np.any((data < 0) | (data >= n_vocab)):
880                raise ValueError(f"d2t target ids out of range for target vocab size {n_vocab}")
881            if np.unique(data).size != data.size:
882                raise ValueError("d2t contains duplicate target ids")
883            logger.info(f"{'d2t,':<30} --> I64, shape = {{{data.size}}}")
884            self.gguf_writer.add_tensor("d2t", data, raw_dtype=gguf.GGMLQuantizationType.I64)