"""MLX-LM architecture adapter for the looped Nanbeige 4.2 model family. This file intentionally supports the released Nanbeige4.2-3B configuration only: standard Llama-style decoder blocks whose physical layers are reused for multiple loops. Optional Nanbeige n-gram, hyper-connection, depth-attention, split-loop, and shared-KV features are rejected instead of being approximated silently. """ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Union import mlx.core as mx import mlx.nn as nn from mlx.nn.layers.distributed import shard_linear from mlx_lm.models.activations import swiglu from mlx_lm.models.base import ( BaseModelArgs, create_attention_mask, scaled_dot_product_attention, ) from mlx_lm.models.cache import KVCache from mlx_lm.models.rope_utils import initialize_rope @dataclass class ModelArgs(BaseModelArgs): model_type: str hidden_size: int num_hidden_layers: int intermediate_size: int num_attention_heads: int rms_norm_eps: float vocab_size: int num_loops: int = 1 head_dim: Optional[int] = None max_position_embeddings: Optional[int] = None num_key_value_heads: Optional[int] = None attention_bias: bool = False mlp_bias: bool = False rope_theta: float = 10000 rope_traditional: bool = False rope_scaling: Optional[Dict[str, Union[float, str]]] = None tie_word_embeddings: bool = False skip_loop_final_norm: bool = False loop_loss_weights: Optional[List[float]] = None emb_neighbor_num: Optional[int] = None emb_split_num: Optional[int] = None ngram_vocab_size_ratio: Optional[float] = None enable_hyper_connection: bool = False enable_double_loop_split: bool = False enable_depth_attention: bool = False loop_share_kv: bool = False def __post_init__(self) -> None: if self.num_key_value_heads is None: self.num_key_value_heads = self.num_attention_heads unsupported = { "n-gram embeddings": ( self.emb_neighbor_num is not None or self.emb_split_num is not None or self.ngram_vocab_size_ratio is not None ), "hyper-connections": self.enable_hyper_connection, "double-loop split": self.enable_double_loop_split, "depth attention": self.enable_depth_attention, "shared loop KV": self.loop_share_kv, } enabled = [name for name, value in unsupported.items() if value] if enabled: raise ValueError( "This Nanbeige MLX adapter does not support: " + ", ".join(enabled) ) if self.loop_loss_weights: self.num_loops = len(self.loop_loss_weights) + 1 if self.num_loops < 1: raise ValueError("num_loops must be at least 1") class Attention(nn.Module): def __init__(self, args: ModelArgs): super().__init__() dim = args.hidden_size self.n_heads = args.num_attention_heads self.n_kv_heads = args.num_key_value_heads self.head_dim = args.head_dim or dim // self.n_heads self.scale = self.head_dim**-0.5 self.q_proj = nn.Linear( dim, self.n_heads * self.head_dim, bias=args.attention_bias ) self.k_proj = nn.Linear( dim, self.n_kv_heads * self.head_dim, bias=args.attention_bias ) self.v_proj = nn.Linear( dim, self.n_kv_heads * self.head_dim, bias=args.attention_bias ) self.o_proj = nn.Linear( self.n_heads * self.head_dim, dim, bias=args.attention_bias ) self.rope = initialize_rope( self.head_dim, args.rope_theta, args.rope_traditional, args.rope_scaling, args.max_position_embeddings, ) def __call__( self, x: mx.array, mask: Optional[mx.array] = None, cache: Optional[Any] = None, ) -> mx.array: batch, length, _ = x.shape queries = self.q_proj(x) keys = self.k_proj(x) values = self.v_proj(x) queries = queries.reshape(batch, length, self.n_heads, self.head_dim).transpose( 0, 2, 1, 3 ) keys = keys.reshape(batch, length, self.n_kv_heads, self.head_dim).transpose( 0, 2, 1, 3 ) values = values.reshape( batch, length, self.n_kv_heads, self.head_dim ).transpose(0, 2, 1, 3) if cache is None: queries = self.rope(queries) keys = self.rope(keys) else: queries = self.rope(queries, offset=cache.offset) keys = self.rope(keys, offset=cache.offset) 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(batch, length, -1) return self.o_proj(output) class MLP(nn.Module): def __init__(self, args: ModelArgs): super().__init__() self.gate_proj = nn.Linear( args.hidden_size, args.intermediate_size, bias=args.mlp_bias ) self.up_proj = nn.Linear( args.hidden_size, args.intermediate_size, bias=args.mlp_bias ) self.down_proj = nn.Linear( args.intermediate_size, args.hidden_size, bias=args.mlp_bias ) def __call__(self, x: mx.array) -> mx.array: return self.down_proj(swiglu(self.gate_proj(x), self.up_proj(x))) class TransformerBlock(nn.Module): def __init__(self, args: ModelArgs): super().__init__() self.self_attn = Attention(args) self.mlp = MLP(args) self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps) self.post_attention_layernorm = nn.RMSNorm( args.hidden_size, eps=args.rms_norm_eps ) def __call__( self, x: mx.array, mask: Optional[mx.array] = None, cache: Optional[Any] = None, ) -> mx.array: h = x + self.self_attn(self.input_layernorm(x), mask, cache) return h + self.mlp(self.post_attention_layernorm(h)) class NanbeigeModel(nn.Module): def __init__(self, args: ModelArgs): super().__init__() self.args = args self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size) self.layers = [TransformerBlock(args) for _ in range(args.num_hidden_layers)] self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps) def __call__( self, inputs: mx.array, cache: Optional[List[Any]] = None, input_embeddings: Optional[mx.array] = None, ) -> mx.array: h = self.embed_tokens(inputs) if input_embeddings is None else input_embeddings total_executions = self.args.num_loops * len(self.layers) if cache is None: cache = [None] * total_executions elif len(cache) != total_executions: raise ValueError( f"Expected {total_executions} KV caches for the looped model, " f"got {len(cache)}." ) for loop_index in range(self.args.num_loops): cache_offset = loop_index * len(self.layers) mask = create_attention_mask(h, cache[cache_offset]) for layer_index, layer in enumerate(self.layers): h = layer(h, mask, cache[cache_offset + layer_index]) if not self.args.skip_loop_final_norm: h = self.norm(h) if self.args.skip_loop_final_norm: h = self.norm(h) return h class Model(nn.Module): def __init__(self, args: ModelArgs): super().__init__() self.args = args self.model_type = args.model_type self.model = NanbeigeModel(args) if not args.tie_word_embeddings: self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False) def __call__( self, inputs: mx.array, cache: Optional[List[Any]] = None, input_embeddings: Optional[mx.array] = None, ) -> mx.array: output = self.model(inputs, cache, input_embeddings) if self.args.tie_word_embeddings: return self.model.embed_tokens.as_linear(output) return self.lm_head(output) def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]: weights = { key: value for key, value in weights.items() if "self_attn.rotary_emb.inv_freq" not in key } if self.args.tie_word_embeddings: weights.pop("lm_head.weight", None) return weights def shard(self, group: Optional[mx.distributed.Group] = None) -> None: group = group or mx.distributed.init() shard_count = group.size() for layer in self.model.layers: layer.self_attn.q_proj = shard_linear( layer.self_attn.q_proj, "all-to-sharded", group=group ) layer.self_attn.k_proj = shard_linear( layer.self_attn.k_proj, "all-to-sharded", group=group ) layer.self_attn.v_proj = shard_linear( layer.self_attn.v_proj, "all-to-sharded", group=group ) layer.self_attn.o_proj = shard_linear( layer.self_attn.o_proj, "sharded-to-all", group=group ) layer.self_attn.n_heads //= shard_count layer.self_attn.n_kv_heads //= shard_count layer.mlp.gate_proj = shard_linear( layer.mlp.gate_proj, "all-to-sharded", group=group ) layer.mlp.up_proj = shard_linear( layer.mlp.up_proj, "all-to-sharded", group=group ) layer.mlp.down_proj = shard_linear( layer.mlp.down_proj, "sharded-to-all", group=group ) @property def layers(self): return self.model.layers def make_cache(self) -> List[KVCache]: return [ KVCache() for _ in range(self.args.num_loops * self.args.num_hidden_layers) ]