# Copyright © 2025 Apple Inc. from dataclasses import dataclass from functools import partial from typing import Any, Dict, List, Optional import mlx.core as mx import mlx.nn as nn from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention from .cache import KVCache, RotatingKVCache from .rope_utils import initialize_rope from .switch_layers import SwitchGLU @dataclass class ModelArgs(BaseModelArgs): model_type: str = "gemma4_text" hidden_size: int = 1536 num_hidden_layers: int = 35 intermediate_size: int = 6144 num_attention_heads: int = 8 head_dim: int = 256 global_head_dim: int = 512 global_partial_rotary_factor: float = 0.25 rms_norm_eps: float = 1e-6 vocab_size: int = 262144 vocab_size_per_layer_input: int = 262144 num_key_value_heads: int = 1 num_global_key_value_heads: Optional[int] = None num_kv_shared_layers: int = 20 pad_token_id: int = 0 hidden_size_per_layer_input: int = 256 rope_traditional: bool = False partial_rotary_factor: float = 1.0 rope_parameters: Optional[Dict] = None sliding_window: int = 512 sliding_window_pattern: int = 5 max_position_embeddings: int = 131072 attention_k_eq_v: bool = False final_logit_softcapping: float = 30.0 use_double_wide_mlp: bool = True enable_moe_block: bool = False num_experts: Optional[int] = None top_k_experts: Optional[int] = None moe_intermediate_size: Optional[int] = None layer_types: Optional[List[str]] = None tie_word_embeddings: bool = True def __post_init__(self): if self.rope_parameters is None: self.rope_parameters = { "full_attention": { "partial_rotary_factor": 0.25, "rope_theta": 1000000.0, "rope_type": "proportional", }, "sliding_attention": { "partial_rotary_factor": 1.0, "rope_theta": 10000.0, "rope_type": "default", }, } if self.layer_types is None: pattern = ["sliding_attention"] * (self.sliding_window_pattern - 1) + [ "full_attention" ] self.layer_types = (pattern * (self.num_hidden_layers // len(pattern) + 1))[ : self.num_hidden_layers ] class RMSNormNoScale(nn.Module): """RMSNorm without learnable scale.""" def __init__(self, dim: int, eps: float = 1e-6): super().__init__() self.eps = eps def __call__(self, x: mx.array) -> mx.array: return mx.fast.rms_norm(x, None, self.eps) @partial(mx.compile, shapeless=True) def logit_softcap(softcap, x): return mx.tanh(x / softcap) * softcap @partial(mx.compile, shapeless=True) def _complete_square(x2, y2, xy): return x2 + mx.expand_dims(y2, -1) - 2 * xy @partial(mx.compile, shapeless=True) def geglu(gate, x): return nn.gelu_approx(gate) * x class MLP(nn.Module): def __init__(self, config: ModelArgs, layer_idx: int = 0): super().__init__() first_kv_shared_layer_idx = ( config.num_hidden_layers - config.num_kv_shared_layers ) is_kv_shared_layer = layer_idx >= first_kv_shared_layer_idx > 0 use_double_wide = config.use_double_wide_mlp and is_kv_shared_layer intermediate_size = config.intermediate_size * (2 if use_double_wide else 1) self.gate_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False) self.down_proj = nn.Linear(intermediate_size, config.hidden_size, bias=False) self.up_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False) def __call__(self, x: mx.array) -> mx.array: return self.down_proj(geglu(self.gate_proj(x), self.up_proj(x))) class Router(nn.Module): """Expert router: norm -> scale -> project -> top-k -> renormalize.""" def __init__(self, config: ModelArgs): super().__init__() self.config = config self.eps = config.rms_norm_eps self.proj = nn.Linear(config.hidden_size, config.num_experts, bias=False) self.scale = mx.ones((config.hidden_size,)) self.per_expert_scale = mx.ones((config.num_experts,)) self._root_size = config.hidden_size**-0.5 def __call__(self, x: mx.array): x = mx.fast.rms_norm(x, self.scale * self._root_size, self.eps) expert_scores = self.proj(x) top_k_indices = mx.argpartition( expert_scores, kth=-self.config.top_k_experts, axis=-1 ) top_k_indices = mx.stop_gradient( top_k_indices[..., -self.config.top_k_experts :] ) top_k_weights = mx.take_along_axis(expert_scores, top_k_indices, axis=-1) top_k_weights = mx.softmax(top_k_weights, axis=-1) top_k_weights = top_k_weights * self.per_expert_scale[top_k_indices] return top_k_indices, top_k_weights class GeGLU(nn.Module): """GELU-gated linear unit activation for SwitchGLU.""" def __call__(self, x, gate): return geglu(gate, x) class Experts(nn.Module): """Sparse MoE using SwitchGLU with gather_mm.""" def __init__(self, config: ModelArgs): super().__init__() self.switch_glu = SwitchGLU( input_dims=config.hidden_size, hidden_dims=config.moe_intermediate_size, num_experts=config.num_experts, activation=GeGLU(), bias=False, ) def __call__( self, x: mx.array, top_k_indices: mx.array, top_k_weights: mx.array ) -> mx.array: w = mx.expand_dims(top_k_weights, -1) y = self.switch_glu(x, top_k_indices) return (w * y).sum(-2) class Attention(nn.Module): def __init__(self, config: ModelArgs, layer_idx: int): super().__init__() self.config = config self.layer_idx = layer_idx self.layer_type = config.layer_types[layer_idx] self.is_sliding = self.layer_type == "sliding_attention" self.has_kv = layer_idx < config.num_hidden_layers - config.num_kv_shared_layers self.head_dim = ( config.global_head_dim if self.layer_type == "full_attention" and hasattr(config, "global_head_dim") and config.global_head_dim else config.head_dim ) dim = config.hidden_size self.n_heads = config.num_attention_heads # K-eq-V for full attention layers (26B/31B models) self.use_k_eq_v = config.attention_k_eq_v and not self.is_sliding if self.use_k_eq_v and config.num_global_key_value_heads is not None: self.n_kv_heads = config.num_global_key_value_heads else: self.n_kv_heads = config.num_key_value_heads self.scale = 1.0 self.q_proj = nn.Linear(dim, self.n_heads * self.head_dim, bias=False) if self.has_kv: self.k_proj = nn.Linear(dim, self.n_kv_heads * self.head_dim, bias=False) if not self.use_k_eq_v: self.v_proj = nn.Linear( dim, self.n_kv_heads * self.head_dim, bias=False ) self.o_proj = nn.Linear(self.n_heads * self.head_dim, dim, bias=False) self.q_norm = nn.RMSNorm(self.head_dim, eps=config.rms_norm_eps) if self.has_kv: self.k_norm = nn.RMSNorm(self.head_dim, eps=config.rms_norm_eps) self.v_norm = RMSNormNoScale(self.head_dim, eps=config.rms_norm_eps) # RoPE (with partial rotation support) layer_key = "sliding_attention" if self.is_sliding else "full_attention" rope_params = config.rope_parameters.get(layer_key, {}) rope_theta = rope_params.get("rope_theta", 10000.0) self.rope = initialize_rope( dims=self.head_dim, traditional=config.rope_traditional, base=rope_theta, scaling_config=rope_params, max_position_embeddings=config.max_position_embeddings, ) def __call__( self, x: mx.array, mask: Optional[mx.array] = None, cache: Optional[Any] = None, shared_kv: Optional[tuple] = None, offset: Optional[Any] = None, ) -> mx.array: B, L, _ = x.shape queries = self.q_proj(x).reshape(B, L, self.n_heads, self.head_dim) queries = self.q_norm(queries) if shared_kv is not None: keys, values = shared_kv elif not self.has_kv: raise ValueError( f"Layer {self.layer_idx} is a KV-shared layer but received no shared_kv" ) else: keys = self.k_proj(x).reshape(B, L, self.n_kv_heads, self.head_dim) values = keys if not self.use_k_eq_v: values = self.v_proj(x).reshape(B, L, self.n_kv_heads, self.head_dim) offset = mx.array(cache.offset) if cache is not None else 0 keys = self.k_norm(keys) keys = keys.transpose(0, 2, 1, 3) keys = self.rope(keys, offset=offset) values = self.v_norm(values) values = values.transpose(0, 2, 1, 3) queries = queries.transpose(0, 2, 1, 3) queries = self.rope(queries, offset=offset) if cache is not None: keys, values = cache.update_and_fetch(keys, values) output = scaled_dot_product_attention( queries, keys, values, cache=cache, scale=self.scale, mask=mask ) output = output.transpose(0, 2, 1, 3).reshape(B, L, -1) return self.o_proj(output), (keys, values), offset class DecoderLayer(nn.Module): def __init__(self, config: ModelArgs, layer_idx: int): super().__init__() self.config = config self.layer_idx = layer_idx self.layer_type = config.layer_types[layer_idx] self.self_attn = Attention(config, layer_idx) self.mlp = MLP(config, layer_idx) self.input_layernorm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps) self.post_attention_layernorm = nn.RMSNorm( config.hidden_size, eps=config.rms_norm_eps ) self.pre_feedforward_layernorm = nn.RMSNorm( config.hidden_size, eps=config.rms_norm_eps ) self.post_feedforward_layernorm = nn.RMSNorm( config.hidden_size, eps=config.rms_norm_eps ) # MoE (26B model) self.enable_moe = config.enable_moe_block if self.enable_moe: self.router = Router(config) self.experts = Experts(config) self.post_feedforward_layernorm_1 = nn.RMSNorm( config.hidden_size, eps=config.rms_norm_eps ) self.post_feedforward_layernorm_2 = nn.RMSNorm( config.hidden_size, eps=config.rms_norm_eps ) self.pre_feedforward_layernorm_2 = nn.RMSNorm( config.hidden_size, eps=config.rms_norm_eps ) # Per-layer input gating (2B/4B models) self.hidden_size_per_layer_input = config.hidden_size_per_layer_input if self.hidden_size_per_layer_input: self.per_layer_input_gate = nn.Linear( config.hidden_size, self.hidden_size_per_layer_input, bias=False ) self.per_layer_projection = nn.Linear( self.hidden_size_per_layer_input, config.hidden_size, bias=False ) self.post_per_layer_input_norm = nn.RMSNorm( config.hidden_size, eps=config.rms_norm_eps ) else: self.per_layer_input_gate = None self.per_layer_projection = None self.post_per_layer_input_norm = None # Layer scalar self.layer_scalar = mx.ones((1,)) def __call__( self, x: mx.array, mask: Optional[mx.array] = None, cache: Optional[Any] = None, per_layer_input: Optional[mx.array] = None, shared_kv: Optional[tuple] = None, offset: Optional[Any] = None, ) -> mx.array: residual = x h = self.input_layernorm(x) h, shared_kv, offset = self.self_attn( h, mask, cache, shared_kv=shared_kv, offset=offset ) h = self.post_attention_layernorm(h) h = residual + h residual = h if self.enable_moe: h1 = self.pre_feedforward_layernorm(h) h1 = self.mlp(h1) h1 = self.post_feedforward_layernorm_1(h1) top_k_indices, top_k_weights = self.router(h) h2 = self.pre_feedforward_layernorm_2(h) h2 = self.experts(h2, top_k_indices, top_k_weights) h2 = self.post_feedforward_layernorm_2(h2) h = h1 + h2 else: h = self.pre_feedforward_layernorm(h) h = self.mlp(h) h = self.post_feedforward_layernorm(h) h = residual + h # Per-layer input gating if ( self.per_layer_input_gate is not None and self.per_layer_projection is not None and self.post_per_layer_input_norm is not None and per_layer_input is not None ): residual = h gate = self.per_layer_input_gate(h) gate = nn.gelu_approx(gate) gate = mx.multiply(gate, per_layer_input) gate = self.per_layer_projection(gate) gate = self.post_per_layer_input_norm(gate) h = residual + gate if self.layer_scalar is not None: h = h * self.layer_scalar return h, shared_kv, offset class Gemma4TextModel(nn.Module): def __init__(self, config: ModelArgs): super().__init__() self.config = config self.vocab_size = config.vocab_size self.window_size = config.sliding_window self.sliding_window_pattern = config.sliding_window_pattern self.num_hidden_layers = config.num_hidden_layers self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size) self.embed_scale = config.hidden_size**0.5 self.layers = [ DecoderLayer(config, layer_idx=i) for i in range(config.num_hidden_layers) ] self.norm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps) # Per-layer input embeddings (2B/4B models) self.hidden_size_per_layer_input = config.hidden_size_per_layer_input if self.hidden_size_per_layer_input: self.embed_tokens_per_layer = nn.Embedding( config.vocab_size_per_layer_input, config.num_hidden_layers * config.hidden_size_per_layer_input, ) self.embed_tokens_per_layer_scale = config.hidden_size_per_layer_input**0.5 self.per_layer_input_scale = 2.0**-0.5 self.per_layer_projection_scale = config.hidden_size**-0.5 self.per_layer_model_projection = nn.Linear( config.hidden_size, config.num_hidden_layers * config.hidden_size_per_layer_input, bias=False, ) self.per_layer_projection_norm = nn.RMSNorm( config.hidden_size_per_layer_input, eps=config.rms_norm_eps ) else: self.embed_tokens_per_layer = None self.per_layer_input_scale = None self.per_layer_projection_scale = None self.per_layer_model_projection = None self.per_layer_projection_norm = None # Arrange for shared KVs self.previous_kvs = list(range(len(self.layers))) if config.num_kv_shared_layers > 0: N = len(self.layers) M = N - config.num_kv_shared_layers kvs_by_type = {} for i in range(M): kvs_by_type[self.layers[i].layer_type] = i for j in range(M, N): self.previous_kvs[j] = kvs_by_type[self.layers[j].layer_type] def _get_per_layer_inputs( self, input_ids: Optional[mx.array], input_embeddings: Optional[mx.array] = None, ) -> mx.array: if input_ids is None: if input_embeddings is None: raise RuntimeError( "input_embeddings must be provided when input_ids are omitted." ) # Split the sequence dimension if this still holds too much # memory. 260k vocab means the distance tensor would be ~1GB # per 2k tokens in bf16. # # If the embedding is quantized we have to dequantize it anyway to # perform the match test. norms_embedding = self.embed_tokens.weight.square().sum(-1) norms_input = input_embeddings.square().sum(-1) distance = _complete_square( norms_embedding, norms_input, self.embed_tokens.as_linear(input_embeddings), ) # Checks can be added if needed but they necessarily break the GPU # pipelining and force an eval. # # match_counts = (distance < eps).sum(-1) # input_ids = mx.stop_gradient(mx.argmin(distance, axis=-1)) result = self.embed_tokens_per_layer(input_ids) result = result * self.embed_tokens_per_layer_scale return mx.unflatten( result, -1, (self.config.num_hidden_layers, self.hidden_size_per_layer_input), ) def _project_per_layer_inputs( self, input_embeddings: mx.array, per_layer_inputs: Optional[mx.array] = None, ) -> mx.array: per_layer_projection = self.per_layer_model_projection(input_embeddings) per_layer_projection = per_layer_projection * self.per_layer_projection_scale per_layer_projection = mx.unflatten( per_layer_projection, -1, (self.config.num_hidden_layers, self.hidden_size_per_layer_input), ) per_layer_projection = self.per_layer_projection_norm(per_layer_projection) if per_layer_inputs is None: return per_layer_projection return (per_layer_projection + per_layer_inputs) * self.per_layer_input_scale def _make_masks(self, h, cache): mask = {} masks = [] for l, c in zip(self.layers, cache): if l.layer_type not in mask: if l.layer_type == "full_attention": mask["full_attention"] = create_attention_mask(h, c) elif l.layer_type == "sliding_attention": mask["sliding_attention"] = create_attention_mask( h, c, window_size=self.window_size ) masks.append(mask[l.layer_type]) return masks def __call__( self, inputs: mx.array = None, cache=None, input_embeddings: Optional[mx.array] = None, per_layer_inputs: Optional[mx.array] = None, ): # Make the initial hidden state if input_embeddings is None: input_embeddings = self.embed_tokens(inputs) h = input_embeddings h = h * self.embed_scale # Get the extra inputs per layer if we have per layer embeddings if self.hidden_size_per_layer_input: if per_layer_inputs is None: per_layer_inputs = self._get_per_layer_inputs(inputs, input_embeddings) per_layer_inputs = self._project_per_layer_inputs(h, per_layer_inputs) if per_layer_inputs is not None: per_layer_inputs = [ per_layer_inputs[:, :, i, :] for i, _ in enumerate(self.layers) ] else: per_layer_inputs = [None] * len(self.layers) # Make the kv cache list, be sure to append None for all the shared kv # layers if cache is None: cache = [None] * len(self.layers) else: cache = cache + [None] * (len(self.layers) - len(cache)) # Apply each layer. We save all intermediate kvs and offset and grab # the previous one for the shared kv layers. masks = self._make_masks(h, cache) intermediates = [(None, None)] * len(self.layers) for idx, (layer, c, mask, prev_idx, per_layer_input) in enumerate( zip( self.layers, cache, masks, self.previous_kvs, per_layer_inputs, ) ): kvs, offset = intermediates[prev_idx] h, kvs, offset = layer( h, mask, c, per_layer_input=per_layer_input, shared_kv=kvs, offset=offset, ) intermediates[idx] = (kvs, offset) return self.norm(h) class Model(nn.Module): def __init__(self, args: ModelArgs): super().__init__() self.args = args self.model_type = args.model_type self.model = Gemma4TextModel(args) self.final_logit_softcapping = args.final_logit_softcapping self.tie_word_embeddings = args.tie_word_embeddings if not self.tie_word_embeddings: self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False) def __call__( self, inputs: mx.array, cache=None, input_embeddings: Optional[mx.array] = None, per_layer_inputs: Optional[mx.array] = None, ): out = self.model( inputs, cache=cache, input_embeddings=input_embeddings, per_layer_inputs=per_layer_inputs, ) if self.tie_word_embeddings: out = self.model.embed_tokens.as_linear(out) else: out = self.lm_head(out) if self.final_logit_softcapping is not None: out = logit_softcap(self.final_logit_softcapping, out) return out def sanitize(self, weights): sanitized = {} first_kv_shared = self.args.num_hidden_layers - self.args.num_kv_shared_layers for k, v in weights.items(): if any( s in k for s in ( "self_attn.rotary_emb", "input_max", "input_min", "output_max", "output_min", ) ): continue # KV-shared layers reuse K/V from earlier layers — drop their projections if any( s in k for s in (".self_attn.k_proj", ".self_attn.v_proj", ".self_attn.k_norm") ): try: layer_idx = int(k.split("layers.")[1].split(".")[0]) if layer_idx >= first_kv_shared: continue except (IndexError, ValueError): pass if k.endswith(".experts.gate_up_proj"): base = k.removesuffix(".gate_up_proj") gate, up = map(mx.contiguous, mx.split(v, 2, axis=-2)) sanitized[f"{base}.switch_glu.gate_proj.weight"] = gate sanitized[f"{base}.switch_glu.up_proj.weight"] = up continue if k.endswith(".experts.down_proj"): base = k.removesuffix(".down_proj") sanitized[f"{base}.switch_glu.down_proj.weight"] = v continue sanitized[k] = v return sanitized @property def quant_predicate(self): def predicate(path, _): if path.endswith("router.proj"): return {"group_size": 64, "bits": 8} return True return predicate @property def layers(self): return self.model.layers @property def head_dim(self): return self.args.head_dim @property def n_kv_heads(self): return self.args.num_key_value_heads def make_cache(self): first_kv_shared = self.args.num_hidden_layers - self.args.num_kv_shared_layers caches = [] for i in range(first_kv_shared): if self.args.layer_types[i] == "full_attention": caches.append(KVCache()) else: caches.append( RotatingKVCache( max_size=self.args.sliding_window, keep=0, ) ) return caches