# MLX port of Kimi-K3's vision tower — a 3D (video-capable) MoonViT variant plus # the patchmergerv2 projector. # # 0.49 B params, 0.02% of the model, and the only reason K3 is multimodal. # # Closest existing MLX code is mlx-vlm's `kimi_vl/vision.py` (MoonViT for # Kimi-VL). This shares its complex64 2D-rope approach but differs in six ways, # each marked [K3-V*] below: # # [K3-V1] 3D grids (t,h,w): a 1D sin-cos *time* embedding is added on top of # the learnable 2D grid, and rope frequencies repeat per frame. # [K3-V2] bilinear position-embedding interpolation (Kimi-VL used bicubic); # mlx-vlm ships bicubic/nearest kernels only, so it is written here. # [K3-V3] qkv_hidden_size 1536 != hidden 1024 — attention runs wider than the # residual stream, so wo is [1024, 1536] and is NOT square. # [K3-V4] RMSNorm, not LayerNorm. # [K3-V5] no biases anywhere (attn_bias / linear_bias / patch_embed_proj_bias # are all false). # [K3-V6] sd2_tpool merge: mean-pool over frames, then 2x2 spatial merge, then # a 2-layer MLP projector with *exact* (erf) GELU and a post-RMSNorm. # # The ViT blocks use tanh-approximate GELU (`PytorchGELUTanh` in the reference) # while the projector uses exact `nn.GELU()`. They are different functions and # the reference deliberately uses both; do not unify them. import math from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple import mlx.core as mx import mlx.nn as nn # RMSNorm eps for the ViT blocks. The reference constructs `nn.RMSNorm(dim)` # with no eps, so PyTorch falls back to finfo(dtype).eps — 1.19e-7 in fp32 but # 7.8e-3 in bf16, which are materially different normalizers. We use the fp32 # value, matching an upcast implementation; `projector_ln_eps` (1e-5) is given # explicitly by the config and is used only for the projector's post_norm. VIT_NORM_EPS = 1.1920929e-07 @dataclass class VisionConfig: vt_hidden_size: int = 1024 vt_intermediate_size: int = 4096 vt_num_hidden_layers: int = 27 vt_num_attention_heads: int = 12 qkv_hidden_size: int = 1536 patch_size: int = 14 num_channels: int = 3 init_pos_emb_height: int = 64 init_pos_emb_width: int = 64 init_pos_emb_time: int = 4 pos_emb_type: str = "divided_fixed" pos_emb_interpolation_mode: str = "bilinear" merge_kernel_size: Tuple[int, int] = (2, 2) merge_type: str = "sd2_tpool" norm_type: str = "rmsnorm" mlp_type: str = "mlp2" mm_projector_type: str = "patchmergerv2" mm_hidden_size: int = 1024 text_hidden_size: int = 7168 projector_hidden_act: str = "gelu" projector_ln_eps: float = 1e-5 attn_bias: bool = False linear_bias: bool = False patch_embed_proj_bias: bool = False rope_theta_base: float = 10000.0 rope_max_size: int = 512 @classmethod def from_dict(cls, d: Dict[str, Any]) -> "VisionConfig": known = {f for f in cls.__dataclass_fields__} out = {k: v for k, v in d.items() if k in known} if "merge_kernel_size" in out: out["merge_kernel_size"] = tuple(out["merge_kernel_size"]) return cls(**out) @property def head_dim(self) -> int: return self.qkv_hidden_size // self.vt_num_attention_heads # ------------------------------------------------------- [K3-V2] bilinear def bilinear_interpolate(x: mx.array, out_h: int, out_w: int) -> mx.array: """Resize (H, W, C) -> (out_h, out_w, C), matching torch's bilinear `F.interpolate(..., align_corners=False)`. torch maps destination index d to source coordinate `max(0, scale * (d + 0.5) - 0.5)` and lerps between the two neighbours, clamping the upper index at the edge. Getting the half-pixel offset wrong shifts every position embedding by half a patch, which is the kind of error that degrades quality without ever looking obviously broken. """ in_h, in_w = x.shape[0], x.shape[1] if (in_h, in_w) == (out_h, out_w): return x def axis_index(in_n: int, out_n: int): scale = in_n / out_n src = mx.maximum( (mx.arange(out_n, dtype=mx.float32) + 0.5) * scale - 0.5, mx.array(0.0, mx.float32), ) i0 = mx.floor(src) w1 = src - i0 i0 = i0.astype(mx.int32) i1 = mx.minimum(i0 + 1, mx.array(in_n - 1, mx.int32)) return i0, i1, w1 h0, h1, wh = axis_index(in_h, out_h) w0, w1i, ww = axis_index(in_w, out_w) xf = x.astype(mx.float32) top = mx.take(xf, h0, axis=0) # (out_h, in_w, C) bot = mx.take(xf, h1, axis=0) wh = wh.reshape(out_h, 1, 1) rows = top * (1.0 - wh) + bot * wh left = mx.take(rows, w0, axis=1) # (out_h, out_w, C) right = mx.take(rows, w1i, axis=1) ww = ww.reshape(1, out_w, 1) return left * (1.0 - ww) + right * ww def sincos_1d(dim: int, length: int) -> mx.array: """(length, dim) sin-cos table; matches get_1d_sincos_pos_embed.""" omega = mx.arange(dim // 2, dtype=mx.float32) / (dim / 2.0) omega = 1.0 / (10000.0**omega) out = mx.outer(mx.arange(length, dtype=mx.float32), omega) return mx.concatenate([mx.sin(out), mx.cos(out)], axis=1) # ------------------------------------------------ [K3-V1] 3D position embed class Learnable2DInterpPosEmbDivided(nn.Module): """Learnable 2D grid, bilinearly resized per image, plus a sin-cos time term.""" def __init__(self, cfg: VisionConfig): super().__init__() self.cfg = cfg # reference does nn.init.normal_(weight); a zero init would make an # unloaded tower emit exactly zero, which hides a failed weight load self.weight = mx.random.normal( (cfg.init_pos_emb_height, cfg.init_pos_emb_width, cfg.vt_hidden_size) ) self._time = sincos_1d(cfg.vt_hidden_size, cfg.init_pos_emb_time) def __call__(self, x: mx.array, grid_thws: List[Tuple[int, int, int]]) -> mx.array: embs = [] for t, h, w in grid_thws: if (h, w) == self.weight.shape[:2]: emb2d = self.weight.reshape(-1, self.weight.shape[-1]) else: emb2d = bilinear_interpolate(self.weight, h, w).reshape( -1, self.weight.shape[-1] ) if t == 1: emb = emb2d else: emb = mx.expand_dims(emb2d, 0) + mx.expand_dims(self._time[:t], 1) emb = emb.reshape(-1, emb.shape[-1]) embs.append(emb) return x + mx.concatenate(embs, axis=0).astype(x.dtype) class MoonVision3dPatchEmbed(nn.Module): """Patch projection. The reference is a Conv2d whose kernel equals the patch size applied to pre-extracted (L, 3, 14, 14) patches, which is exactly a linear map over the flattened patch — done as a matmul here to sidestep MLX's NHWC conv weight layout entirely.""" def __init__(self, cfg: VisionConfig): super().__init__() self.cfg = cfg fan_in = cfg.num_channels * cfg.patch_size * cfg.patch_size # torch Conv2d default: uniform(-1/sqrt(fan_in), 1/sqrt(fan_in)) bound = 1.0 / math.sqrt(fan_in) self.proj_weight = mx.random.uniform( low=-bound, high=bound, shape=(cfg.vt_hidden_size, fan_in) ) self.pos_emb = Learnable2DInterpPosEmbDivided(cfg) def __call__(self, patches: mx.array, grid_thws) -> mx.array: x = patches.reshape(patches.shape[0], -1).astype(self.proj_weight.dtype) x = x @ self.proj_weight.T return self.pos_emb(x, grid_thws) # --------------------------------------------------------- [K3-V1] 2D rope class Rope2DPosEmbRepeated(nn.Module): """freqs_cis[h, w] interleaves x (width) and y (height) frequencies: entry 2i is cis(w * theta^(-4i/dim)), entry 2i+1 is cis(h * ...). Repeated t times so every frame shares the same spatial rope.""" def __init__(self, dim: int, max_h: int, max_w: int, theta_base: float = 10000.0): super().__init__() assert dim % 4 == 0, "rope dim must be divisible by 4" self.dim, self.max_h, self.max_w, self.theta_base = dim, max_h, max_w, theta_base self._cache: Optional[mx.array] = None def _full(self) -> mx.array: if self._cache is None: n = self.max_h * self.max_w flat = mx.arange(0, n, dtype=mx.float32) x_pos = flat % self.max_w y_pos = mx.floor(flat / self.max_w) dim_range = mx.arange(0, self.dim, 4)[: self.dim // 4].astype(mx.float32) freqs = 1.0 / (self.theta_base ** (dim_range / self.dim)) xf, yf = mx.outer(x_pos, freqs), mx.outer(y_pos, freqs) x_cis = mx.cos(xf) + 1j * mx.sin(xf) y_cis = mx.cos(yf) + 1j * mx.sin(yf) self._cache = mx.stack([x_cis, y_cis], axis=-1).reshape( self.max_h, self.max_w, -1 ) return self._cache def __call__(self, grid_thws: List[Tuple[int, int, int]]) -> mx.array: full = self._full() parts = [] for t, h, w in grid_thws: assert 1 <= h <= self.max_h and 1 <= w <= self.max_w, (h, w) base = full[:h, :w].reshape(-1, self.dim // 2) parts.append(mx.tile(base, (t, 1)) if t > 1 else base) return mx.concatenate(parts, axis=0) def apply_rope(q: mx.array, k: mx.array, freqs_cis: mx.array): """q, k: (L, heads, head_dim); freqs_cis: (L, head_dim/2) complex64.""" f = mx.expand_dims(freqs_cis, axis=-2) def rot(t): pair = t.astype(mx.float32).reshape(*t.shape[:-1], -1, 2) c = (pair[..., 0] + 1j * pair[..., 1]) * f out = mx.stack([mx.real(c), mx.imag(c)], axis=-1) return out.reshape(*t.shape).astype(t.dtype) return rot(q), rot(k) # ----------------------------------------------------------- encoder class MLP2(nn.Module): def __init__(self, dims: List[int], bias: bool = False): super().__init__() self.fc0 = nn.Linear(dims[0], dims[1], bias=bias) self.fc1 = nn.Linear(dims[1], dims[2], bias=bias) def __call__(self, x: mx.array) -> mx.array: # PytorchGELUTanh -- the tanh approximation, not exact erf GELU. return self.fc1(nn.gelu_approx(self.fc0(x))) class MoonViTEncoderLayer(nn.Module): def __init__(self, cfg: VisionConfig): super().__init__() self.cfg = cfg h, qkv = cfg.vt_hidden_size, cfg.qkv_hidden_size self.num_heads = cfg.vt_num_attention_heads self.head_dim = cfg.head_dim self.scale = self.head_dim**-0.5 self.norm0 = nn.RMSNorm(h, eps=VIT_NORM_EPS) self.norm1 = nn.RMSNorm(h, eps=VIT_NORM_EPS) self.wqkv = nn.Linear(h, qkv * 3, bias=cfg.attn_bias) self.wo = nn.Linear(qkv, h, bias=cfg.attn_bias) # [K3-V3] not square self.mlp = MLP2([h, cfg.vt_intermediate_size, h], bias=cfg.linear_bias) def _attend(self, x, freqs_cis, spans): L = x.shape[0] qkv = self.wqkv(x).reshape(L, 3, self.num_heads, self.head_dim) q, k, v = qkv[:, 0], qkv[:, 1], qkv[:, 2] q, k = apply_rope(q, k, freqs_cis) # Images are packed into one sequence but must not attend across each # other. Looping the (few) images costs sum(L_i^2) instead of the # (sum L_i)^2 an explicit block-diagonal mask would. outs = [] for s, e in spans: qi = q[s:e].transpose(1, 0, 2)[None] ki = k[s:e].transpose(1, 0, 2)[None] vi = v[s:e].transpose(1, 0, 2)[None] o = mx.fast.scaled_dot_product_attention(qi, ki, vi, scale=self.scale) outs.append(o[0].transpose(1, 0, 2).reshape(e - s, -1)) return self.wo(mx.concatenate(outs, axis=0)) def __call__(self, x, freqs_cis, spans): x = x + self._attend(self.norm0(x), freqs_cis, spans) return x + self.mlp(self.norm1(x)) class MoonViT3dEncoder(nn.Module): def __init__(self, cfg: VisionConfig): super().__init__() self.rope_2d = Rope2DPosEmbRepeated( cfg.head_dim, cfg.rope_max_size, cfg.rope_max_size, cfg.rope_theta_base ) self.blocks = [MoonViTEncoderLayer(cfg) for _ in range(cfg.vt_num_hidden_layers)] self.final_layernorm = nn.RMSNorm(cfg.vt_hidden_size, eps=VIT_NORM_EPS) def __call__(self, x: mx.array, grid_thws) -> mx.array: freqs_cis = self.rope_2d(grid_thws) spans, pos = [], 0 for t, h, w in grid_thws: spans.append((pos, pos + t * h * w)) pos += t * h * w for blk in self.blocks: x = blk(x, freqs_cis, spans) return self.final_layernorm(x) # ---------------------------------------------------- [K3-V6] merge + project def tpool_patch_merger(x: mx.array, grid_thws, merge_kernel_size=(2, 2)): """-> list of (h/2 * w/2, 4, C): mean over frames, then 2x2 spatial merge.""" d = x.shape[-1] kh, kw = merge_kernel_size out, pos = [], 0 for t, h, w in grid_thws: seq = x[pos : pos + t * h * w] pos += t * h * w nh, nw = h // kh, w // kw s = seq.reshape(t, nh, kh, nw, kw, d).transpose(0, 1, 3, 2, 4, 5) s = mx.mean(s, axis=0) # temporal pooling out.append(s.reshape(nh * nw, kh * kw, d)) return out class PatchMergerMLPV2(nn.Module): def __init__(self, cfg: VisionConfig): super().__init__() inner = cfg.mm_hidden_size * cfg.merge_kernel_size[0] * cfg.merge_kernel_size[1] self.inner = inner # nn.Sequential(Linear, GELU, Linear) in the reference -> proj.0 / proj.2 self.proj_0 = nn.Linear(inner, inner, bias=False) self.proj_2 = nn.Linear(inner, cfg.text_hidden_size, bias=False) self.post_norm = nn.RMSNorm(cfg.text_hidden_size, eps=cfg.projector_ln_eps) def __call__(self, feats: List[mx.array]) -> List[mx.array]: lengths = [f.shape[0] for f in feats] x = mx.concatenate([f.reshape(f.shape[0], -1) for f in feats], axis=0) # exact erf GELU here -- deliberately different from the ViT's tanh GELU x = self.post_norm(self.proj_2(nn.gelu(self.proj_0(x)))) out, pos = [], 0 for n in lengths: out.append(x[pos : pos + n]) pos += n return out class VisionModel(nn.Module): """vision_tower + mm_projector: pixels -> text-space embeddings.""" def __init__(self, cfg: VisionConfig): super().__init__() self.cfg = cfg if cfg.pos_emb_type != "divided_fixed": raise NotImplementedError(cfg.pos_emb_type) if cfg.merge_type != "sd2_tpool": raise NotImplementedError(cfg.merge_type) if cfg.mm_projector_type != "patchmergerv2": raise NotImplementedError(cfg.mm_projector_type) if cfg.norm_type != "rmsnorm": raise NotImplementedError(cfg.norm_type) self.patch_embed = MoonVision3dPatchEmbed(cfg) self.encoder = MoonViT3dEncoder(cfg) self.mm_projector = PatchMergerMLPV2(cfg) def __call__(self, pixel_values: mx.array, grid_thws) -> List[mx.array]: grid_thws = [tuple(int(v) for v in g) for g in grid_thws] x = self.patch_embed(pixel_values, grid_thws) x = self.encoder(x, grid_thws) merged = tpool_patch_merger(x, grid_thws, self.cfg.merge_kernel_size) return self.mm_projector(merged) def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]: out: Dict[str, mx.array] = {} for k, v in weights.items(): if k.startswith("vision_tower."): k = k[len("vision_tower.") :] if k == "patch_embed.proj.weight": # Conv2d (out, in, kH, kW) -> flat linear (out, in*kH*kW). # torch sums over (c, i, j) in that order, so a plain # reshape is the correct flattening. out["patch_embed.proj_weight"] = v.reshape(v.shape[0], -1) elif k == "patch_embed.pos_emb.weight": out["patch_embed.pos_emb.weight"] = v else: out[k] = v elif k.startswith("mm_projector."): k = k[len("mm_projector.") :] if k == "proj.0.weight": out["mm_projector.proj_0.weight"] = v elif k == "proj.2.weight": out["mm_projector.proj_2.weight"] = v else: out["mm_projector." + k] = v return out