char-fork/llama.cpp

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

Gabe Goodhartmodel : GraniteSWAForCausalLM / GraniteMoeSWAForCausalLM (#25505)7221e24f5

char/aliceai-t5-moe
37.6 KiB775 linesraw
1from __future__ import annotations
2
3import re
4from typing import Any, Callable, Iterable, TYPE_CHECKING
5
6import torch
7
8if TYPE_CHECKING:
9    from torch import Tensor
10
11from .base import MmprojModel, ModelBase, gguf, logger
12
13from .llama import LlamaModel
14from .mamba import Mamba2Model
15
16
17@ModelBase.register("GraniteForCausalLM")
18@ModelBase.example("ibm-granite/granite-3.3-2b-instruct")
19class GraniteModel(LlamaModel):
20    """Conversion for IBM's GraniteForCausalLM"""
21    model_arch = gguf.MODEL_ARCH.GRANITE
22
23    def set_gguf_parameters(self):
24        """Granite uses standard llama parameters with the following differences:
25
26        - No head_dim support
27        - New multiplier params:
28            - attention_scale
29            - embedding_scale
30            - residual_scale
31        - logits_scaling
32        """
33        if head_dim := self.hparams.pop("head_dim", None):
34            logger.warning("Ignoring head_dim (%s) from config for Granite", head_dim)
35        super().set_gguf_parameters()
36        # NOTE: Convert _multiplier params to _scale params for naming
37        #   consistency
38        if attention_scale := self.hparams.get("attention_multiplier"):
39            self.gguf_writer.add_attention_scale(attention_scale)
40            logger.info("gguf: (granite) attention_scale = %s", attention_scale)
41        if embedding_scale := self.hparams.get("embedding_multiplier"):
42            self.gguf_writer.add_embedding_scale(embedding_scale)
43            logger.info("gguf: (granite) embedding_scale = %s", embedding_scale)
44        if residual_scale := self.hparams.get("residual_multiplier"):
45            self.gguf_writer.add_residual_scale(residual_scale)
46            logger.info("gguf: (granite) residual_scale = %s", residual_scale)
47        if logits_scale := self.hparams.get("logits_scaling"):
48            self.gguf_writer.add_logit_scale(logits_scale)
49            logger.info("gguf: (granite) logits_scale = %s", logits_scale)
50
51        # If being used as the base for Granite4 Vision, add deepstack_layer_arr
52        if self.hparams.get("spatial_target_layers") or self.hparams.get("deepstack_layer_map"):
53            normalized_projector_map = Granite4VisionMmprojModel.get_normalized_projector_map(self.hparams)
54            deepstack_mapping_arr = [-1 for _ in range(self.block_count)] # Populate with -1 sentinels
55            for proj_idx, (_, llm_layer, _, _) in enumerate(normalized_projector_map):
56                # Skip the first projector which is handled as the base embedding
57                # stream like normal
58                if proj_idx == 0:
59                    continue
60                deepstack_mapping_arr[llm_layer] = proj_idx
61            self.gguf_writer.add_deepstack_mapping(deepstack_mapping_arr)
62
63    @classmethod
64    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
65        name, gen = item
66        # Skip multimodal tensors
67        if (
68            name.startswith(("encoder."))
69            or "image_" in name
70            or "layerwise_projectors" in name
71            or "spatial_projectors" in name
72        ):
73            return
74        return super().filter_tensors(item)
75
76
77@ModelBase.register("GraniteSWAForCausalLM")
78class GraniteSWAModel(GraniteModel):
79    """Conversion for IBM's GraniteSWAForCausalLM (interleaved sliding window attention)"""
80    model_arch = gguf.MODEL_ARCH.GRANITE_SWA
81
82    @classmethod
83    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
84        name, gen = item
85
86        if name.endswith("sinks"):
87            name += ".weight"
88
89        return super().filter_tensors((name, gen))
90
91    def set_gguf_parameters(self):
92        """GraniteSWA uses Granite parameters plus sliding window configuration."""
93        super().set_gguf_parameters()
94
95        # Add sliding_window from config
96        sliding_window = self.hparams.get("sliding_window", 128)
97        self.gguf_writer.add_sliding_window(sliding_window)
98        logger.info("gguf: (granite_swa) sliding_window = %s", sliding_window)
99
100        # Derive sliding_window_pattern from layer_types
101        if layer_types := self.hparams.get("layer_types"):
102            is_swa = [t == "sliding_attention" for t in layer_types]
103            self.gguf_writer.add_sliding_window_pattern(is_swa)
104            logger.info("gguf: (granite_swa) sliding_window_pattern = %d SWA layers / %d total",
105                        sum(is_swa), len(is_swa))
106        else:
107            # Fall back to period-based pattern: i % 4 != 0
108            # This matches the transformers default pattern
109            n_layers = self.block_count
110            is_swa = [i % 4 != 0 for i in range(n_layers)]
111            self.gguf_writer.add_sliding_window_pattern(is_swa)
112            logger.info("gguf: (granite_swa) sliding_window_pattern (inferred) = %d SWA layers / %d total",
113                        sum(is_swa), n_layers)
114
115        # Add rope_pattern from no_rope_layers
116        if no_rope_layers := self.hparams.get("no_rope_layers"):
117            # Convert 1/0 to bool (1 = use RoPE, 0 = NoPE)
118            rope_pattern = [bool(x) for x in no_rope_layers]
119            self.gguf_writer.add_rope_pattern(rope_pattern)
120            logger.info("gguf: (granite_swa) rope_pattern = %d RoPE layers / %d total",
121                        sum(rope_pattern), len(rope_pattern))
122
123
124@ModelBase.register("GraniteMoeSWAForCausalLM")
125class GraniteMoeSWAModel(GraniteSWAModel):
126    """Conversion for IBM's GraniteMoeSWAForCausalLM (unified dense + MoE with iSWA)"""
127    model_arch = gguf.MODEL_ARCH.GRANITE_SWA
128
129    def set_gguf_parameters(self):
130        super().set_gguf_parameters()
131        if shared_intermediate_size := self.hparams.get("shared_intermediate_size"):
132            self.gguf_writer.add_expert_shared_feed_forward_length(shared_intermediate_size)
133            logger.info("gguf: (granitemoewa) shared_intermediate_size = %s", shared_intermediate_size)
134
135    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
136        """Split merged MoE tensors (gate+up) following standard MoE pattern."""
137
138        # Handle expert FFN tensors (merged gate+up) - swash format: experts.gate_up_proj
139        # Kept fused since inference (build_moe_ffn) supports a single gate_up_exps
140        # tensor for the routed experts.
141        if name.endswith("block_sparse_moe.experts.gate_up_proj"):
142            ffn_dim = self.hparams["intermediate_size"]
143            assert data_torch.shape[-2] == 2 * ffn_dim, f"Merged FFN tensor size must be 2 * intermediate_size, got {data_torch.shape[-2]}"
144            yield from ModelBase.modify_tensors(self, data_torch, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_GATE_UP_EXP, bid), bid)
145            return
146
147        # Handle expert FFN down projection - swash format: experts.down_proj
148        if name.endswith("block_sparse_moe.experts.down_proj"):
149            yield from ModelBase.modify_tensors(self, data_torch, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_DOWN_EXP, bid), bid)
150            return
151
152        # Handle expert FFN tensors (merged gate+up) - standard granite format: input_linear.weight
153        # Kept fused since inference (build_moe_ffn) supports a single gate_up_exps
154        # tensor for the routed experts.
155        if name.endswith("block_sparse_moe.input_linear.weight"):
156            ffn_dim = self.hparams["intermediate_size"]
157            assert data_torch.shape[-2] == 2 * ffn_dim, "Merged FFN tensor size must be 2 * intermediate_size"
158            yield from ModelBase.modify_tensors(self, data_torch, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_GATE_UP_EXP, bid), bid)
159            return
160
161        # Handle shared expert FFN tensors (if present) - kept fused since
162        # inference (build_ffn) supports a single ffn_up_shexp tensor with
163        # LLM_FFN_SWIGLU for the shared expert.
164        if name.endswith("shared_mlp.input_linear.weight"):
165            ffn_dim = self.hparams.get("shared_intermediate_size", self.hparams["intermediate_size"])
166            assert data_torch.shape[-2] == 2 * ffn_dim, "Merged FFN tensor size must be 2 * shared_intermediate_size"
167            yield from ModelBase.modify_tensors(self, data_torch, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_UP_SHEXP, bid), bid)
168            return
169
170        # Handle shared expert output (if present)
171        if name.endswith("shared_mlp.output_linear.weight"):
172            yield from ModelBase.modify_tensors(self, data_torch, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_DOWN_SHEXP, bid), bid)
173            return
174
175        # Pass through to parent for all other tensors (including sinks)
176        yield from super().modify_tensors(data_torch, name, bid)
177
178
179@ModelBase.register("GraniteMoeForCausalLM", "GraniteMoeSharedForCausalLM")
180@ModelBase.example("ibm-granite/granite-3.1-3b-a800m-instruct")
181class GraniteMoeModel(GraniteModel):
182    """Conversion for IBM's GraniteMoeForCausalLM"""
183    model_arch = gguf.MODEL_ARCH.GRANITE_MOE
184
185    def set_gguf_parameters(self):
186        """GraniteMoeShared uses GraniteMoe parameters plus the following:
187        - shared_intermediate_size
188        """
189        super().set_gguf_parameters()
190        if shared_feed_forward_length := self.hparams.get("shared_intermediate_size"):
191            self.gguf_writer.add_expert_shared_feed_forward_length(shared_feed_forward_length)
192            logger.info("gguf: (granitemoeshared) shared_feed_forward_length = %s", shared_feed_forward_length)
193
194    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
195        """In modeling_granitemoe, the JetMoe implementation of parallel experts
196        is used. This essentially merges w1 and w3 into a single tensor with 2x
197        the hidden size that is then split during forward. To keep compatibility
198        with existing mixtral support, we pull them apart here.
199        """
200
201        if name.endswith("block_sparse_moe.input_linear.weight"):
202            ffn_dim = self.hparams["intermediate_size"]
203            assert data_torch.shape[-2] == 2 * ffn_dim, "Merged FFN tensor size must be 2 * intermediate_size"
204            gate, up = data_torch.split(ffn_dim, dim=-2)
205            yield from ModelBase.modify_tensors(self, gate, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_GATE_EXP, bid), bid)
206            yield from ModelBase.modify_tensors(self, up, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_UP_EXP, bid), bid)
207            return
208
209        has_experts = bool(self.hparams.get('num_local_experts'))
210
211        if name.endswith("shared_mlp.input_linear.weight"):
212            ffn_dim = self.hparams["shared_intermediate_size"]
213            assert data_torch.shape[-2] == 2 * ffn_dim, "Merged FFN tensor size must be 2 * shared_intermediate_size"
214            gate, up = data_torch.split(ffn_dim, dim=-2)
215            if has_experts:
216                yield from ModelBase.modify_tensors(self, gate,self.format_tensor_name(gguf.MODEL_TENSOR.FFN_GATE_SHEXP, bid), bid)
217                yield from ModelBase.modify_tensors(self, up, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_UP_SHEXP, bid), bid)
218                return
219            yield from ModelBase.modify_tensors(self, gate, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_GATE, bid), bid)
220            yield from ModelBase.modify_tensors(self, up, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_UP, bid), bid)
221            return
222
223        if not has_experts and name.endswith("shared_mlp.output_linear.weight"):
224            yield from ModelBase.modify_tensors(self, data_torch, self.format_tensor_name(gguf.MODEL_TENSOR.FFN_DOWN, bid), bid)
225            return
226
227        yield from super().modify_tensors(data_torch, name, bid)
228
229
230@ModelBase.register("GraniteSwitchForCausalLM")
231@ModelBase.example("ibm-granite/granite-switch-4.1-3b-preview")
232class GraniteSwitchModel(GraniteMoeModel):
233    """Dense, all-attention Granite with N per-token embedded LoRA adapters, stacked
234    over the adapter dim with a zero adapter at slot 0 (N = num_adapters + 1)."""
235    model_arch = gguf.MODEL_ARCH.GRANITE_SWITCH
236
237    # permute q/k per-slice below (NORM-rope layout), not via the parent's auto-permute
238    undo_permute = False
239
240    def __init__(self, *args, **kwargs):
241        super().__init__(*args, **kwargs)
242        # the weightless switch reserves one cache slot: one fewer block than num_hidden_layers
243        self.block_count = self.block_count - 1
244        self.tensor_map = gguf.get_tensor_name_map(self.model_arch, self.block_count)
245
246        self._n_adapters = int(self.hparams["num_adapters"])
247        self._max_lora_rank = int(self.hparams["max_lora_rank"])
248        self._n_slots = self._n_adapters + 1  # +1 for the zero slot at index 0
249
250        n_head = int(self.hparams["num_attention_heads"])
251        n_kv_head = int(self.hparams["num_key_value_heads"])
252        head_dim = (
253            self.hparams.get("projection_head_dim")
254            or self.hparams.get("head_dim")
255            or (self.hparams["hidden_size"] // n_head)
256        )
257        self._n_head = n_head
258        self._n_kv_head = n_kv_head
259        self._head_dim = int(head_dim)
260        self._q_size = n_head * self._head_dim
261        self._kv_size = n_kv_head * self._head_dim
262
263    def set_gguf_parameters(self):
264        super().set_gguf_parameters()
265
266        # dense: pin expert_used_count to 0 (config carries a leftover num_experts_per_tok)
267        if not self.hparams.get("num_local_experts"):
268            self.gguf_writer.add_expert_used_count(0)
269
270        self.gguf_writer.add_adapter_count(self._n_adapters)
271        self.gguf_writer.add_adapter_lora_rank(self._max_lora_rank)
272        self.gguf_writer.add_adapter_token_ids_activate(self.hparams["adapter_token_ids"])
273        self.gguf_writer.add_adapter_token_ids_substitute(self.hparams["adapter_substitute_token_ids"])
274        router_gain = float(self.hparams.get("control_token_gain", 15.0))
275        self.gguf_writer.add_adapter_router_gain(router_gain)
276        logger.info("gguf: (graniteswitch) num_adapters=%s max_lora_rank=%s n_slots=%s router_gain=%s", self._n_adapters, self._max_lora_rank, self._n_slots, router_gain)
277
278    def _lora_a(self, data: Tensor) -> Tensor:
279        # on-disk A: [n_adapters, 1, max_rank, in] -> [n_adapters+1, max_rank, in]
280        a = data.squeeze(1)
281        zero = torch.zeros_like(a[:1])
282        return torch.cat([zero, a], dim=0).contiguous()
283
284    def _lora_b(self, data: Tensor, permute_n_head: int | None = None) -> Tensor:
285        # on-disk B: [n_adapters, 1, out, max_rank] -> [n_adapters+1, out, max_rank]
286        b = data.squeeze(1)
287        if permute_n_head is not None:
288            # permute each adapter's B output rows to match the permuted q/k base
289            b = torch.stack([self.permute(b[i], permute_n_head, permute_n_head) for i in range(b.shape[0])], dim=0)
290        zero = torch.zeros_like(b[:1])
291        return torch.cat([zero, b], dim=0).contiguous()
292
293    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
294        T = gguf.MODEL_TENSOR
295
296        # skip the weightless switch + control-token buffers (rebuilt at load time)
297        bare = name.split(".")[-1]
298        if (
299            name.startswith("model.switch.") or name.startswith("switch.")
300            or bare in ("adapter_token_ids", "control_to_substitute_lut")
301        ):
302            return
303
304        if "self_attn.qkv_proj" in name:
305            if name.endswith("base_layer.weight"):
306                # fused [q|k|v] rows: permute q/k row-blocks for ggml's NORM-rope layout
307                q, k, v = data_torch.split([self._q_size, self._kv_size, self._kv_size], dim=0)
308                q = self.permute(q, self._n_head, self._n_head)
309                k = self.permute(k, self._n_kv_head, self._n_kv_head)
310                fused = torch.cat([q, k, v], dim=0)
311                yield (self.format_tensor_name(T.ATTN_QKV, bid), fused)
312                return
313            if "lora_A_slices." in name:
314                slot = int(name.rsplit(".", 1)[1])
315                key = {0: T.ATTN_Q, 1: T.ATTN_K, 2: T.ATTN_V}[slot]
316                yield (self.format_tensor_name(key, bid, suffix=".lora_a"), self._lora_a(data_torch))
317                return
318            if "lora_B_slices." in name:
319                slot = int(name.rsplit(".", 1)[1])
320                key, ph = {
321                    0: (T.ATTN_Q, self._n_head),
322                    1: (T.ATTN_K, self._n_kv_head),
323                    2: (T.ATTN_V, None),
324                }[slot]
325                yield (self.format_tensor_name(key, bid, suffix=".lora_b"), self._lora_b(data_torch, ph))
326                return
327            raise ValueError(f"Unexpected qkv_proj tensor: {name}")
328
329        if "self_attn.o_proj" in name:
330            if name.endswith("base_layer.weight"):
331                yield (self.format_tensor_name(T.ATTN_OUT, bid), data_torch)
332                return
333            if name.endswith("lora_A"):
334                yield (self.format_tensor_name(T.ATTN_OUT, bid, suffix=".lora_a"), self._lora_a(data_torch))
335                return
336            if name.endswith("lora_B"):
337                yield (self.format_tensor_name(T.ATTN_OUT, bid, suffix=".lora_b"), self._lora_b(data_torch))
338                return
339            raise ValueError(f"Unexpected o_proj tensor: {name}")
340
341        if "shared_mlp.input_linear" in name:
342            ffn = self.hparams["shared_intermediate_size"]
343            if name.endswith("base_layer.weight"):
344                gate, up = data_torch.split([ffn, ffn], dim=0)
345                yield (self.format_tensor_name(T.FFN_GATE, bid), gate)
346                yield (self.format_tensor_name(T.FFN_UP, bid), up)
347                return
348            if "lora_A_slices." in name:
349                slot = int(name.rsplit(".", 1)[1])
350                key = {0: T.FFN_GATE, 1: T.FFN_UP}[slot]
351                yield (self.format_tensor_name(key, bid, suffix=".lora_a"), self._lora_a(data_torch))
352                return
353            if "lora_B_slices." in name:
354                slot = int(name.rsplit(".", 1)[1])
355                key = {0: T.FFN_GATE, 1: T.FFN_UP}[slot]
356                yield (self.format_tensor_name(key, bid, suffix=".lora_b"), self._lora_b(data_torch))
357                return
358            raise ValueError(f"Unexpected shared_mlp.input_linear tensor: {name}")
359
360        if "shared_mlp.output_linear" in name:
361            if name.endswith("base_layer.weight"):
362                yield (self.format_tensor_name(T.FFN_DOWN, bid), data_torch)
363                return
364            if name.endswith("lora_A"):
365                yield (self.format_tensor_name(T.FFN_DOWN, bid, suffix=".lora_a"), self._lora_a(data_torch))
366                return
367            if name.endswith("lora_B"):
368                yield (self.format_tensor_name(T.FFN_DOWN, bid, suffix=".lora_b"), self._lora_b(data_torch))
369                return
370            raise ValueError(f"Unexpected shared_mlp.output_linear tensor: {name}")
371
372        if bid is not None and ".layers." in name and (
373            "input_layernorm" in name or "post_attention_layernorm" in name
374        ):
375            key = T.ATTN_NORM if "input_layernorm" in name else T.FFN_NORM
376            yield (self.format_tensor_name(key, bid), data_torch)
377            return
378
379        if name in ("model.embed_tokens.weight", "embed_tokens.weight"):
380            yield (self.format_tensor_name(T.TOKEN_EMBD), data_torch)
381            return
382        if name in ("model.norm.weight", "norm.weight"):
383            yield (self.format_tensor_name(T.OUTPUT_NORM), data_torch)
384            return
385        if name == "lm_head.weight":
386            return  # tied to token_embd
387
388        raise ValueError(f"graniteswitch: unhandled tensor {name!r} (bid={bid})")
389
390
391@ModelBase.register("GraniteMoeHybridForCausalLM", "BambaForCausalLM")
392@ModelBase.example("ibm-granite/granite-4.0-h-tiny", "ibm-ai-platform/Bamba-9B-v2")
393class GraniteHybridModel(Mamba2Model, GraniteMoeModel):
394    """GraniteHybrid is a hybrid SSM + Attention model that uses Mamba2 SSM
395    layers and optionally uses MoE w/ a shared expert"""
396    model_arch = gguf.MODEL_ARCH.GRANITE_HYBRID
397    undo_permute = True
398
399    def __init__(self, *args, **kwargs):
400
401        # Hybrid mamba models use a prefix for the mamba-specific params.
402        # TODO: Extend this if the prefix(es) need to be configurable
403        self.hparam_prefixes = ["mamba"]
404
405        super().__init__(*args, **kwargs)
406
407        # Lists of which layers use ssm vs attention
408        self._attn_layers = self.get_attn_layers()
409        self._ssm_layers = [
410            i for i in range(self.block_count)
411            if i not in self._attn_layers
412        ]
413
414        # There are some models in this family that are non-hybrid, but keep the
415        # same parent class by setting all layers to "attention." If this is the
416        # case, the model architecture needs to be updated to a standard
417        # "granite" or "granitemoe" model
418        if not self._ssm_layers:
419            has_experts = self.find_hparam(["num_experts_per_tok", "num_experts_per_token"], optional=True)
420            new_arch = (
421                gguf.MODEL_ARCH.GRANITE_MOE
422                if has_experts else
423                gguf.MODEL_ARCH.GRANITE
424            )
425            self.model_arch = new_arch
426            self.gguf_writer.arch = gguf.MODEL_ARCH_NAMES[new_arch]
427            self.gguf_writer.add_architecture()
428
429        # n_group and d_inner are used during reshape_tensors for mamba2
430        # NOTE: Explicitly include hparam prefix prefix for d_model to
431        #   disambiguate with top-level head_dim
432        # NOTE 2: If needed for future models, this can be isolated in a method
433        #   to separate the prefix setting and the keys used
434        self.d_model = self.find_hparam([f"{self.hparam_prefixes[0]}_head_dim", "hidden_size", "d_model"])
435        self.n_group = self.find_hparam(["n_groups", "num_groups"])
436        self.d_inner = self.find_hparam(["expand", "num_heads"]) * self.d_model
437
438    def get_attn_layers(self):
439        # Explicit list of layer type names
440        if layer_types := self.hparams.get("layer_types"):
441            return [
442                i for i, typ in enumerate(layer_types)
443                if typ == "attention"
444            ]
445
446        # Layer types indicated by index or period
447        attn_layers = self.hparams.get("attn_layer_indices", [])
448        if not attn_layers:
449            attn_period = self.hparams.get("attn_layer_period")
450            assert attn_period, "Didn't find attn_layer_indices or attn_layer_period"
451            attn_offset = self.hparams.get("attn_layer_offset")
452            assert attn_offset is not None, "No attention layer offset set with attn_layer_period"
453            attn_layers = [
454                i for i in range(self.block_count)
455                if i % attn_period == attn_offset
456            ]
457        return attn_layers
458
459    def find_hparam(self, keys: Iterable[str], *args, **kwargs) -> Any:
460        prefixed = []
461        for pfx in self.hparam_prefixes:
462            prefixed.extend(
463                "_".join([pfx, k])
464                for k in keys
465            )
466        keys = list(keys) + prefixed
467        return Mamba2Model.find_hparam(self, keys, *args, **kwargs)
468
469    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
470        if (
471            name.endswith("block_sparse_moe.input_linear.weight")
472            or "shared_mlp" in name
473        ):
474            yield from GraniteMoeModel.modify_tensors(self, data_torch, name, bid)
475            return
476
477        # Determine whether this is a mamba layer or an attention layer
478        if bid in self._ssm_layers:
479            yield from Mamba2Model.modify_tensors(self, data_torch, name, bid)
480            return
481        elif bid in self._attn_layers:
482            yield from GraniteMoeModel.modify_tensors(self, data_torch, name, bid)
483            return
484        yield from ModelBase.modify_tensors(self, data_torch, name, bid)
485
486    def set_gguf_parameters(self):
487        """This method merges params from both parents and some that are
488        specific to this model. The result is some duplication of how the params
489        get set. The following warnings are expected during conversion:
490
491        WARNING:Duplicated key name 'granitehybrid.attention.head_count_kv'
492        WARNING:Duplicated key name 'granitehybrid.context_length'
493        """
494        GraniteMoeModel.set_gguf_parameters(self)
495
496        ## Mamba mixer params ##
497        self.gguf_writer.add_ssm_conv_kernel(self.find_hparam(["conv_kernel", "d_conv"]))
498        self.gguf_writer.add_ssm_state_size(self.find_hparam(["state_size", "d_state", "state_dim", "ssm_state_size"]))
499        self.gguf_writer.add_ssm_group_count(self.n_group)
500        self.gguf_writer.add_ssm_inner_size(self.d_inner)
501        # NOTE: The mamba_dt_rank is _not_ the right field for how this is used
502        #   in llama.cpp
503        self.gguf_writer.add_ssm_time_step_rank(self.find_hparam(["n_heads", "num_heads"]))
504
505        ## Attention params ##
506        head_count_kv = self.find_hparam(["num_key_value_heads", "n_head_kv"])
507        head_count_kv_vec = [
508            head_count_kv if i in self._attn_layers else 0 for i in range(self.block_count)
509        ]
510        if rope_dim := self.hparams.get("attn_rotary_emb"):
511            self.gguf_writer.add_rope_dimension_count(rope_dim)
512        self.gguf_writer.add_head_count_kv(head_count_kv_vec)
513
514        ## If Bamba or non-hybrid, use rope, otherwise don't
515        use_rope = (
516            "BambaForCausalLM" in self.hparams["architectures"]
517            or not self._ssm_layers
518        )
519        self.gguf_writer.add_rope_scaling_finetuned(use_rope)
520        if not use_rope:
521            self.gguf_writer.add_context_length(2**20)
522
523        ## Validation ##
524        d_head = self.find_hparam(["d_head"], optional=True) or 64
525        assert self.hparams.get("hidden_act") in [None, "silu"], "Only SILU activation supported"
526        assert self.d_inner % d_head == 0, f"SSM inner size {self.d_inner} not a multiple of head dim {d_head}"
527
528    def set_vocab(self):
529        # For models with no ssm layers, don't pad for mamba2
530        self.hparams["pad_vocab_size_multiple"] = 8 if self._ssm_layers else 1
531        Mamba2Model.set_vocab(self)
532
533
534@ModelBase.register("GraniteSpeechForConditionalGeneration")
535@ModelBase.example("ibm-granite/granite-speech-3.3-2b", "ibm-granite/granite-4.0-1b-speech")
536class GraniteSpeechMmprojModel(MmprojModel):
537    has_vision_encoder = False
538    has_audio_encoder = True
539
540    _batch_norm_tensors: list[dict[str, Tensor]] | None = None
541
542    def get_audio_config(self) -> dict[str, Any] | None:
543        return self.global_config.get("encoder_config")
544
545    def set_gguf_parameters(self):
546        assert self.hparams_audio is not None
547        a = self.hparams_audio
548        a["hidden_size"] = a["hidden_dim"]
549        a["intermediate_size"] = a["hidden_dim"] * a["feedforward_mult"]
550        a["num_attention_heads"] = a["num_heads"]
551        a["num_hidden_layers"] = a["num_layers"]
552
553        super().set_gguf_parameters()
554
555        self.gguf_writer.add_clip_projector_type(gguf.VisionProjectorType.GRANITE_SPEECH)
556        self.gguf_writer.add_audio_num_mel_bins(a["input_dim"])
557        self.gguf_writer.add_audio_attention_layernorm_eps(1e-5)
558        self.gguf_writer.add_audio_chunk_size(a["context_size"])
559        self.gguf_writer.add_audio_conv_kernel_size(a["conv_kernel_size"])
560        self.gguf_writer.add_audio_max_pos_emb(a["max_pos_emb"])
561
562        p = self.global_config
563        self.gguf_writer.add_audio_projector_window_size(p["window_size"])
564        self.gguf_writer.add_audio_projector_downsample_rate(p["downsample_rate"])
565        self.gguf_writer.add_audio_projector_head_count(p["projector_config"]["num_attention_heads"])
566
567    def tensor_force_quant(self, name, new_name, bid, n_dims):
568        if "encoder" in name or "projector" in name:
569            if ".conv" in name and ".weight" in name:
570                return gguf.GGMLQuantizationType.F32
571        return super().tensor_force_quant(name, new_name, bid, n_dims)
572
573    @classmethod
574    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
575        name, gen = item
576        if "attention_dists" in name or "num_batches_tracked" in name:
577            return None
578        return super().filter_tensors(item)
579
580    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
581        # fold running_mean, running_var and eps into weight and bias for batch_norm
582        if "batch_norm" in name and "encoder.layers." in name:
583            if self._batch_norm_tensors is None:
584                self._batch_norm_tensors = [{} for _ in range(self.block_count)]
585            assert bid is not None
586            self._batch_norm_tensors[bid][name] = data_torch
587            if len(self._batch_norm_tensors[bid]) < 4:
588                return
589            prefix = f"encoder.layers.{bid}.conv.batch_norm"
590            weight = self._batch_norm_tensors[bid][f"{prefix}.weight"]
591            bias = self._batch_norm_tensors[bid][f"{prefix}.bias"]
592            running_mean = self._batch_norm_tensors[bid][f"{prefix}.running_mean"]
593            running_var = self._batch_norm_tensors[bid][f"{prefix}.running_var"]
594            eps = 1e-5
595            a = weight / torch.sqrt(running_var + eps)
596            b = bias - running_mean * a
597            yield from super().modify_tensors(a, f"encoder.layers.{bid}.conv.batch_norm.weight", bid)
598            yield from super().modify_tensors(b, f"encoder.layers.{bid}.conv.batch_norm.bias", bid)
599            return
600
601        if ".attn.to_kv.weight" in name:
602            k_weight, v_weight = data_torch.chunk(2, dim=0)
603            yield from super().modify_tensors(k_weight, name.replace("to_kv", "to_k"), bid)
604            yield from super().modify_tensors(v_weight, name.replace("to_kv", "to_v"), bid)
605            return
606
607        if ("up_conv" in name or "down_conv" in name) and name.endswith(".weight"):
608            if data_torch.ndim == 3 and data_torch.shape[2] == 1:
609                data_torch = data_torch.squeeze(2)
610
611        if "depth_conv" in name and name.endswith(".weight"):
612            if data_torch.ndim == 3 and data_torch.shape[1] == 1:
613                data_torch = data_torch.squeeze(1)
614
615        yield from super().modify_tensors(data_torch, name, bid)
616
617
618@ModelBase.register("GraniteSpeechPlusForConditionalGeneration")
619@ModelBase.example("ibm-granite/granite-speech-4.1-2b-plus")
620class GraniteSpeechPlusMmprojModel(GraniteSpeechMmprojModel):
621    """Conversion for GraniteSpeechPlus - extends GraniteSpeech with feature layer concatenation"""
622    has_vision_encoder = False
623    has_audio_encoder = True
624
625    def set_gguf_parameters(self):
626        assert self.hparams_audio is not None
627        super().set_gguf_parameters()
628
629        # Add feature_layer if present in encoder config
630        if feature_layers := self.hparams_audio.get("cat_hidden_layers"):
631            self.gguf_writer.add_audio_feature_layers(feature_layers)
632            logger.info(f"gguf: audio feature_layers = {feature_layers}")
633
634            # Validate projector dimension matches concatenated encoder output
635            hidden_dim = self.hparams_audio["hidden_dim"]
636            expected_dim = hidden_dim * (len(feature_layers) + 1)
637            projector_dim = self.global_config["projector_config"]["encoder_hidden_size"]
638
639            if projector_dim != expected_dim:
640                raise ValueError(
641                    f"Projector encoder_hidden_size ({projector_dim}) does not match "
642                    f"expected concatenated dimension ({expected_dim}). "
643                    f"Expected: hidden_dim ({hidden_dim}) * (len(feature_layers) + 1) = {expected_dim}"
644                )
645
646
647@ModelBase.register("Granite4VisionForConditionalGeneration")
648@ModelBase.example("ibm-granite/granite-4.0-3b-vision")
649class Granite4VisionMmprojModel(MmprojModel):
650    has_vision_encoder = True
651    has_audio_encoder = False
652
653    @staticmethod
654    def get_normalized_projector_map(global_config: dict) -> list[tuple[int, int, str, int]]:
655        """Normalize both deepstack and spatial projector maps to the form:
656        (vision_layer, llm_layer, <type>, type_index)
657
658        This is then used to populate the following mappings:
659        - vision_feature_layers (mmproj hparam): ordered list of all
660          vision_layer values where order corresponds with the order of the
661          stacked projector tensors
662          NOTE: Values may appear multiple times for spatial projectors
663        - tensor_prefix_map (mmproj tensors): mapping from tensor prefixes to
664          the index of the corresponding projector in the stacked tensors
665        - deepstack_layer_arr (llm hparam): per-text-layer array indicating
666          which input vision feature should be injected at that layer
667          (-1 if none)
668
669        Output: (vision_layer, llm_layer, <type>, type_index)
670        """
671        deepstack_map = global_config.get("deepstack_layer_map", [])  # [[vis_layer, llm_layer], ...]
672        spatial_layers = global_config.get("spatial_target_layers", [])  # [llm_layer, ...]
673        n_text_layers = global_config["text_config"]["num_hidden_layers"]
674        n_vision_layers = global_config["vision_config"]["num_hidden_layers"]
675        normalized_projector_map = []
676        if deepstack_map:
677            for deepstack_idx, (vision_layer, llm_layer) in enumerate(sorted(deepstack_map)):
678                if vision_layer < 0:
679                    vision_layer = n_vision_layers + vision_layer
680                if llm_layer < 0:
681                    llm_layer = n_text_layers + llm_layer
682                normalized_projector_map.append((vision_layer, llm_layer, "layerwise", deepstack_idx))
683        if spatial_layers:
684            spatial_vision_layer = global_config.get("spatial_vision_layer", -1)
685            if spatial_vision_layer < 0:
686                spatial_vision_layer = n_vision_layers + spatial_vision_layer
687            for spatial_idx, llm_layer in enumerate(spatial_layers):
688                normalized_projector_map.append((spatial_vision_layer, llm_layer, "spatial", spatial_idx))
689        return list(sorted(normalized_projector_map, key=(lambda entry: entry[1])))
690
691    def __init__(self, *args, **kwargs):
692        super().__init__(*args, **kwargs)
693        normalized_projector_map = self.get_normalized_projector_map(self.global_config)
694        self._n_proj = len(normalized_projector_map)
695
696        self._tensor_prefix_map = {
697            f"model.{proj_type}_projectors.{type_idx}": proj_idx
698            for proj_idx, (_, _, proj_type, type_idx) in enumerate(normalized_projector_map)
699        }
700        self._vision_feature_layers = [vision_layer for vision_layer, _, _, _ in normalized_projector_map]
701        self._spatial_offsets = [
702            type_idx if proj_type == "spatial" else -1
703            for _, _, proj_type, type_idx in normalized_projector_map
704        ]
705
706    def set_gguf_parameters(self):
707        assert self.hparams_vision is not None
708        super().set_gguf_parameters()
709
710        self.gguf_writer.add_clip_projector_type(gguf.VisionProjectorType.GRANITE4_VISION)
711
712        # SigLIP encoder hparams
713        self.gguf_writer.add_vision_attention_layernorm_eps(self.hparams.get("layer_norm_eps", 1e-6))
714        self.gguf_writer.add_vision_use_gelu(True)
715
716        # Preprocessor
717        self.gguf_writer.add_vision_preproc_image_size(self.hparams.get("image_size", 384))
718
719        # QFormer projector config
720        ds_rate = self.global_config["downsample_rate"]
721        ds_parts = ds_rate.split("/")
722        assert len(ds_parts) == 2, f"Invalid 'downsample_rate' value: {ds_rate}"
723        query_side, window_side = [int(p) for p in ds_parts]
724        self.gguf_writer.add_vision_projector_query_side(query_side)
725        self.gguf_writer.add_vision_projector_window_side(window_side)
726
727        # Set vision feature layers
728        self.gguf_writer.add_vision_feature_layers(self._vision_feature_layers)
729
730        # Set the spatial offests per projector
731        self.gguf_writer.add_vision_spatial_offsets(self._spatial_offsets)
732
733        # Add flattened image grind pinpoints (resolution candidates internally)
734        if pinpoints := self.global_config.get("image_grid_pinpoints"):
735            # Flatten with h, w -> w, h inversion
736            pinpoints = [val for h, w in pinpoints for val in (w, h)]
737            self.gguf_writer.add_vision_image_grid_pinpoints(pinpoints)
738
739    @classmethod
740    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
741        name, _ = item
742        if ("vision_model.head" in name or name.startswith("lm_head")):
743            return None
744        return super().filter_tensors(item)
745
746    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
747
748        # Detect projector tensors and bin them
749        projector_idx = None
750        for prefix, proj_idx in self._tensor_prefix_map.items():
751            if name.startswith(prefix):
752                projector_idx = proj_idx
753                break
754        if projector_idx is not None:
755            # If this projector tensor has a block id within the projector,
756            # alias the bid to projector_idx
757            #
758            # TODO: currently, none of the Granite 4 Vision models have
759            # projectors with multiple QFormer layers, so the `layer.{}` index
760            # is always 0. This allows us to simply map to a single `bid` that
761            # matches the projector index. If this changes, we'll need a
762            # convention that merges the two IDs.
763            id_matches = list(re.finditer(r"\.([0-9]+)\.", name))
764            all_ids = [int(m.group(1)) for m in id_matches]
765            assert len(all_ids) >= 1 and len(all_ids) <= 2, "Must have at least 1 and at most 2 ids in tensor names"
766            # If not layer id, just use the projector index
767            new_bid = projector_idx
768            if len(all_ids) == 1:
769                new_name = name[:id_matches[0].span(1)[0]] + str(new_bid) + name[id_matches[0].span(1)[1]:]
770            else: # len(all_ids) == 2
771                new_bid = projector_idx # + all_ids[1]
772                new_name = name[:id_matches[0].span(0)[0]] + name[id_matches[0].span(1)[1]:id_matches[1].span(1)[0]] + str(new_bid) + name[id_matches[1].span(1)[1]:]
773            yield from super().modify_tensors(data_torch, new_name, new_bid)
774            return
775        yield from super().modify_tensors(data_torch, name, bid)