pipenetwork's picture
Add files using upload-large-folder tool
affbc49 verified
Raw
History Blame
37.7 kB
# MLX port of moonshotai/Kimi-K3 (2.78T-total / 104B-active multimodal MoE).
#
# Text tower only — the vision tower rides along in the checkpoint and is handled
# separately (see scripts/convert.py, which passes it through untouched).
#
# Derived from mlx-lm's `kimi_linear.py` (Kimi-Linear-48B-A3B), which already
# covers KDA + gated MLA + DeepSeek-style sparse MoE. K3 adds five things that
# do not exist anywhere in MLX; each is marked [K3] below:
#
# [K3-1] SiTU-GLU activation beta=4.0, linear_beta=25.0
# [K3-2] Attention Residuals (AttnRes) rank-1 [1,H] score projections, block 12
# [K3-3] Stable LatentMoE routed experts live in a 3584-d latent
# [K3-4] q-LoRA + output-gated MLA q_a/q_b rank 1536, sigmoid g_proj
# [K3-5] per-channel KDA decay A_log is [head_dim], not [num_heads]
#
# NOTE on [K3-5]: the reference `modeling_kimi_linear.py` allocates
# `A_log = Parameter(log(empty(num_heads)))` — i.e. [96] — but every checkpoint
# shard ships A_log as [128] == head_dim, while b_proj is [96, H] and dt_bias is
# [12288] == 96*128. fla's kernel broadcasts A_log against g of shape
# (B,T,96,128), so a length-128 vector can only align with the trailing head-dim
# axis. The decay rate is therefore per-channel and shared across heads. The
# reference init code is simply stale w.r.t. the released weights; the shapes are
# authoritative. tests/test_kimi_k3.py asserts this against the real shard.
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from .base import (
BaseModelArgs,
create_attention_mask,
create_ssm_mask,
scaled_dot_product_attention,
)
from .cache import ArraysCache, KVCache
from .gated_delta import gated_delta_kernel, gated_delta_ops
from .mla import MultiLinear
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
vocab_size: int
hidden_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
intermediate_size: int
rms_norm_eps: float
linear_attn_config: Dict[str, Any]
num_experts: int
moe_intermediate_size: int
kv_lora_rank: int
q_lora_rank: Optional[int] = None
tie_word_embeddings: bool = False
qk_nope_head_dim: Optional[int] = None
qk_rope_head_dim: Optional[int] = None
v_head_dim: Optional[int] = None
mla_use_nope: bool = True
mla_use_output_gate: bool = False
num_experts_per_token: int = 1
num_shared_experts: int = 0
moe_router_activation_func: str = "sigmoid"
moe_renormalize: bool = True
routed_scaling_factor: float = 1.0
first_k_dense_replace: int = 0
moe_layer_freq: int = 1
num_expert_group: int = 1
topk_group: int = 1
max_position_embeddings: int = 1048576
# [K3-1]
hidden_act: str = "situ"
activation_situ_beta: float = 1.0
activation_situ_linear_beta: Optional[float] = None
# [K3-2]
attn_res_block_size: Optional[int] = None
# [K3-3]
routed_expert_hidden_size: Optional[int] = None
latent_moe_use_norm: bool = False
# REAP-pruned checkpoints keep a different number of experts per layer
# (0 for dense layers). None => every MoE layer has `num_experts`.
expert_counts: Optional[List[int]] = None
# Graded REAP: per layer, how many of that layer's experts sit in the
# high-precision bank. 0 (or None) => a single uniform-width bank.
expert_bank_split: Optional[List[int]] = None
# multimodal wrapper fields (ignored by the text tower)
vision_config: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, params: Dict[str, Any]) -> "ModelArgs":
# K3's config.json nests the language model under `text_config`.
if "text_config" in params:
merged = dict(params["text_config"])
merged["model_type"] = params.get("model_type", "kimi_k3")
merged["vision_config"] = params.get("vision_config", {})
for k in ("bos_token_id", "eos_token_id", "pad_token_id"):
merged.setdefault(k, params.get(k))
params = merged
return super().from_dict(params)
# ---------------------------------------------------------------- [K3-1] SiTU
class SituGLU(nn.Module):
"""beta*tanh(gate/beta)*sigmoid(gate) * linear_beta*tanh(up/linear_beta).
Matches SituAndMul in the reference, including the float32 upcast — the
tanh saturation points (beta=4, linear_beta=25) are far enough out that
bf16 rounding visibly changes the result.
"""
def __init__(self, beta: float = 1.0, linear_beta: Optional[float] = None):
super().__init__()
self.beta = beta
self.linear_beta = linear_beta
def __call__(self, x_up: mx.array, x_gate: mx.array) -> mx.array:
dtype = x_up.dtype
gate = x_gate.astype(mx.float32)
up = x_up.astype(mx.float32)
out = self.beta * mx.tanh(gate / self.beta) * mx.sigmoid(gate)
if self.linear_beta is not None:
up = self.linear_beta * mx.tanh(up / self.linear_beta)
return (out * up).astype(dtype)
def _situ_from_args(args: ModelArgs) -> SituGLU:
return SituGLU(args.activation_situ_beta or 1.0, args.activation_situ_linear_beta)
class KimiMLP(nn.Module):
def __init__(
self,
args: ModelArgs,
hidden_size: Optional[int] = None,
intermediate_size: Optional[int] = None,
):
super().__init__()
h = hidden_size or args.hidden_size
i = intermediate_size or args.intermediate_size
self.gate_proj = nn.Linear(h, i, bias=False)
self.up_proj = nn.Linear(h, i, bias=False)
self.down_proj = nn.Linear(i, h, bias=False)
self.act = _situ_from_args(args)
def __call__(self, x: mx.array) -> mx.array:
return self.down_proj(self.act(self.up_proj(x), self.gate_proj(x)))
# ------------------------------------------------------------- [K3-2] AttnRes
def _apply_attn_res(
prefix_sum: mx.array,
block_residual: mx.array,
proj_weight: mx.array,
norm_weight: mx.array,
eps: float,
) -> mx.array:
"""Softmax mixture over [block residuals..., prefix_sum].
prefix_sum: (N, H)
block_residual: (N, K, H) -> returns (N, H)
Each candidate is RMS-normed, scored against a single learned direction
(norm.weight * proj.weight, both length H), and the candidates are combined
by a softmax over K+1. All in float32, as in the reference.
"""
v = mx.concatenate([block_residual, mx.expand_dims(prefix_sum, 1)], axis=1)
vf = v.astype(mx.float32)
variance = mx.mean(vf * vf, axis=-1, keepdims=True)
k = vf * mx.rsqrt(variance + eps)
score_weight = norm_weight.astype(mx.float32) * proj_weight.reshape(-1).astype(
mx.float32
)
scores = mx.sum(k * score_weight, axis=-1)
probs = mx.expand_dims(mx.softmax(scores, axis=-1), 1)
return (probs @ vf).squeeze(1).astype(v.dtype)
class AttnResProj(nn.Module):
"""Holds the rank-1 [1, H] score projection and its paired RMSNorm gain.
Kept as plain arrays rather than nn.Linear/nn.RMSNorm so the quantizer skips
them — they are 7168 floats each and feed a softmax, so quantizing them is
all downside.
"""
def __init__(self, hidden_size: int):
super().__init__()
self.proj_weight = mx.zeros((1, hidden_size))
self.norm_weight = mx.ones((hidden_size,))
def __call__(
self, prefix_sum: mx.array, block_residual: mx.array, eps: float
) -> mx.array:
return _apply_attn_res(
prefix_sum, block_residual, self.proj_weight, self.norm_weight, eps
)
# ------------------------------------------------------ [K3-4] q-LoRA gated MLA
class KimiMLAAttention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.num_heads = args.num_attention_heads
self.qk_nope_head_dim = args.qk_nope_head_dim or 128
self.qk_rope_head_dim = args.qk_rope_head_dim or 0
self.q_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
self.v_head_dim = args.v_head_dim or 128
self.kv_lora_rank = args.kv_lora_rank
self.q_lora_rank = args.q_lora_rank
self.scale = self.q_head_dim**-0.5
h = args.hidden_size
if self.q_lora_rank is not None:
self.q_a_proj = nn.Linear(h, self.q_lora_rank, bias=False)
self.q_a_layernorm = nn.RMSNorm(self.q_lora_rank, eps=args.rms_norm_eps)
self.q_b_proj = nn.Linear(
self.q_lora_rank, self.num_heads * self.q_head_dim, bias=False
)
else:
self.q_proj = nn.Linear(h, self.num_heads * self.q_head_dim, bias=False)
self.kv_a_proj_with_mqa = nn.Linear(
h, self.kv_lora_rank + self.qk_rope_head_dim, bias=False
)
self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank, eps=args.rms_norm_eps)
# kv_b_proj is absorbed into these two at load time (see sanitize), so the
# KV cache holds the 512-d latent instead of 96 full heads. At 1M context
# that is the difference between ~110 GB and ~1.2 GB of cache per layer.
self.embed_q = MultiLinear(self.qk_nope_head_dim, self.kv_lora_rank, self.num_heads)
self.unembed_out = MultiLinear(self.kv_lora_rank, self.v_head_dim, self.num_heads)
self.o_proj = nn.Linear(self.num_heads * self.v_head_dim, h, bias=False)
self.use_output_gate = args.mla_use_output_gate
if self.use_output_gate:
self.g_proj = nn.Linear(h, self.num_heads * self.v_head_dim, bias=False)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[KVCache] = None,
) -> mx.array:
B, L, _ = x.shape
if self.q_lora_rank is not None:
q = self.q_b_proj(self.q_a_layernorm(self.q_a_proj(x)))
else:
q = self.q_proj(x)
q = q.reshape(B, L, self.num_heads, self.q_head_dim).transpose(0, 2, 1, 3)
q_nope, q_pe = mx.split(q, [self.qk_nope_head_dim], axis=-1)
compressed_kv = self.kv_a_proj_with_mqa(x)
compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1)
k_pe = k_pe.reshape(B, L, 1, self.qk_rope_head_dim).transpose(0, 2, 1, 3)
kv_latent = mx.expand_dims(self.kv_a_layernorm(compressed_kv), axis=1)
if cache is not None:
kv_latent, k_pe = cache.update_and_fetch(kv_latent, k_pe)
# NoPE: q_pe/k_pe carry no rotary embedding at all (the reference asserts
# use_nope and leaves rotary_emb=None). They are a plain extra 64 dims,
# folded in as an additive score term alongside the causal mask.
pe_scores = (q_pe * self.scale) @ k_pe.swapaxes(-1, -2)
if mask is not None:
pe_scores = mx.where(
mask, pe_scores, mx.array(mx.finfo(pe_scores.dtype).min, pe_scores.dtype)
)
if L == 1:
q_nope = self.embed_q(q_nope)
k = v = kv_latent
else:
k = self.embed_q(kv_latent, transpose=False)
v = self.unembed_out(kv_latent)
out = scaled_dot_product_attention(
q_nope, k, v, cache=cache, scale=self.scale, mask=pe_scores
)
if L == 1:
out = self.unembed_out(out)
out = out.transpose(0, 2, 1, 3).reshape(B, L, -1)
if self.use_output_gate:
out = out * mx.sigmoid(self.g_proj(x))
return self.o_proj(out)
# ------------------------------------------------------------- [K3-5] KDA
class ShortConv1d(nn.Module):
def __init__(self, channels: int, kernel_size: int):
super().__init__()
self.kernel_size = kernel_size
self.conv = nn.Conv1d(
in_channels=channels,
out_channels=channels,
kernel_size=kernel_size,
groups=channels,
padding=0,
bias=False,
)
def __call__(self, x, state, mask=None, lengths=None):
if mask is not None and state is not None:
x = x * mx.expand_dims(mask, -1) if mask.ndim == 2 else x
conv_input = mx.concatenate([state, x], axis=1)
new_state = conv_input[:, -(self.kernel_size - 1) :, :]
return nn.silu(self.conv(conv_input)), new_state
class KimiDeltaAttention(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
cfg = args.linear_attn_config
self.layer_idx = layer_idx
self.num_heads = cfg["num_heads"]
self.head_dim = cfg["head_dim"]
self.conv_kernel = cfg.get("short_conv_kernel_size", 4)
self.gate_lower_bound = cfg.get("gate_lower_bound", None)
self.use_full_rank_gate = cfg.get("use_full_rank_gate", False)
self.projection_dim = self.num_heads * self.head_dim
h = args.hidden_size
self.scale = float(self.head_dim) ** -0.5
self.q_proj = nn.Linear(h, self.projection_dim, bias=False)
self.k_proj = nn.Linear(h, self.projection_dim, bias=False)
self.v_proj = nn.Linear(h, self.projection_dim, bias=False)
self.q_conv = ShortConv1d(self.projection_dim, self.conv_kernel)
self.k_conv = ShortConv1d(self.projection_dim, self.conv_kernel)
self.v_conv = ShortConv1d(self.projection_dim, self.conv_kernel)
self.f_a_proj = nn.Linear(h, self.head_dim, bias=False)
self.f_b_proj = nn.Linear(self.head_dim, self.projection_dim, bias=False)
self.b_proj = nn.Linear(h, self.num_heads, bias=False)
# [K3] full-rank output gate (use_full_rank_gate=True) replaces the
# low-rank g_a/g_b pair used by Kimi-Linear-48B.
if self.use_full_rank_gate:
self.g_proj = nn.Linear(h, self.projection_dim, bias=False)
else:
self.g_a_proj = nn.Linear(h, self.head_dim, bias=False)
self.g_b_proj = nn.Linear(self.head_dim, self.projection_dim, bias=False)
# [K3-5] per-channel, shared across heads -> broadcast as (1, head_dim).
self.A_log = mx.zeros((self.head_dim,))
self.dt_bias = mx.zeros((self.projection_dim,))
self.o_norm = nn.RMSNorm(self.head_dim, eps=args.rms_norm_eps)
self.o_proj = nn.Linear(self.projection_dim, h, bias=False)
def _compute_g(self, a: mx.array) -> mx.array:
# log-decay, then the safe-gate clamp (fla's `lower_bound`), then exp.
log_g = -mx.exp(self.A_log.reshape(1, self.head_dim).astype(mx.float32)) * nn.softplus(
a + self.dt_bias.reshape(self.num_heads, self.head_dim)
)
if self.gate_lower_bound is not None:
log_g = mx.maximum(log_g, self.gate_lower_bound)
return mx.exp(log_g)
def __call__(self, x, mask=None, cache=None):
B, T, _ = x.shape
dtype = x.dtype
if cache is not None:
q_state, k_state, v_state, ssm_state = cache
lengths = cache.lengths
else:
q_state = k_state = v_state = ssm_state = None
lengths = None
if q_state is None:
s = mx.zeros((B, self.conv_kernel - 1, self.projection_dim), dtype=dtype)
q_state = k_state = v_state = s
q_conv, q_state = self.q_conv(self.q_proj(x), q_state, mask, lengths)
k_conv, k_state = self.k_conv(self.k_proj(x), k_state, mask, lengths)
v_conv, v_state = self.v_conv(self.v_proj(x), v_state, mask, lengths)
if cache is not None:
cache[0], cache[1], cache[2] = q_state, k_state, v_state
q = q_conv.reshape(B, T, self.num_heads, self.head_dim)
k = k_conv.reshape(B, T, self.num_heads, self.head_dim)
v = v_conv.reshape(B, T, self.num_heads, self.head_dim)
# use_qk_l2norm_in_kernel=True in the reference
q = (self.scale**2) * mx.fast.rms_norm(q, None, 1e-6)
k = self.scale * mx.fast.rms_norm(k, None, 1e-6)
a_logits = self.f_b_proj(self.f_a_proj(x)).reshape(
B, T, self.num_heads, self.head_dim
)
beta = mx.sigmoid(self.b_proj(x).reshape(B, T, self.num_heads))
g = self._compute_g(a_logits)
if ssm_state is None:
ssm_state = mx.zeros(
(B, self.num_heads, self.head_dim, self.head_dim), dtype=mx.float32
)
use_kernel = mx.default_device() == mx.gpu and mx.metal.is_available()
fn = gated_delta_kernel if use_kernel else gated_delta_ops
out, ssm_state = fn(q, k, v, g, beta, ssm_state, mask)
if cache is not None:
cache[3] = ssm_state
cache.advance(T)
if self.use_full_rank_gate:
gate = self.g_proj(x)
else:
gate = self.g_b_proj(self.g_a_proj(x))
gate = gate.reshape(B, T, self.num_heads, self.head_dim)
out = (
self.o_norm(out.reshape(B, T, self.num_heads, self.head_dim))
* mx.sigmoid(gate)
).reshape(B, T, -1)
return self.o_proj(out)
# --------------------------------------------------------- [K3-3] LatentMoE
def _group_expert_select(
scores: mx.array,
bias: mx.array,
top_k: int,
num_expert_group: int,
topk_group: int,
routed_scaling_factor: float,
renormalize: bool,
activation: str,
):
if activation == "sigmoid":
scores = mx.sigmoid(scores.astype(mx.float32))
else:
scores = mx.softmax(scores.astype(mx.float32), axis=-1)
orig = scores
scores = scores + bias
if num_expert_group > 1 and num_expert_group > topk_group:
k = scores.shape[-1] // num_expert_group
scores = mx.unflatten(scores, -1, (num_expert_group, k))
group_scores = mx.topk(scores, 2, axis=-1).sum(axis=-1, keepdims=True)
gate = mx.argpartition(-group_scores, kth=topk_group - 1, axis=-2)
gate = mx.put_along_axis(
mx.ones_like(group_scores),
gate[..., :topk_group, :],
mx.zeros((1,), scores.dtype),
axis=-2,
)
scores = mx.flatten(scores - gate * mx.finfo(scores.dtype).max, -2, -1)
inds = mx.stop_gradient(mx.argpartition(-scores, kth=top_k - 1, axis=-1)[..., :top_k])
weights = mx.take_along_axis(orig, inds, axis=-1)
if top_k > 1 and renormalize:
weights = weights / (mx.sum(weights, axis=-1, keepdims=True) + 1e-20)
weights = weights * routed_scaling_factor
return inds, weights.astype(orig.dtype)
class TwoBankSwitchGLU(nn.Module):
"""SwitchGLU whose experts are split into two banks at different bit widths.
Experts [0, n_hi) live in `bank_hi`, [n_hi, n_hi+n_lo) in `bank_lo`. This
exists because MLX pins one bit width per expert tensor: QuantizedSwitchLinear
stores scalar bits/group_size/mode and gather_qmm takes them as scalars. Two
modules means two widths, which is what graded REAP needs -- most-salient
experts at mxfp4 (free on K3, it is the source encoding), the rest lower.
The naive way to do this costs 2x expert compute: run both banks over every
routed pair and select. Instead we exploit the sort SwitchGLU already
performs. After sorting (token, slot) pairs by expert index, a bank owning a
contiguous index range owns a contiguous *slice* of the sorted pairs, so
each bank does exactly its own share of the work and no more.
Cost: the split point is data-dependent, so it must be read back to the
host to slice on. Measured at K3 REAP dims (3584->3072, 240 experts,
top-16), that sync is essentially the *entire* overhead -- the second
dispatch is nearly free:
prefill (256 tok) 1.05x vs single-bank
decode (1 tok) 1.16x
Decode routes only top_k pairs, too few to amortise two kernel launches
plus a device sync, so below PARTITION_ON_HOST pairs we pull the indices
down in one transfer and partition with numpy instead. That alone took
decode from 1.44x to 1.16x.
"""
# Below this many (token, slot) pairs, partition on the host.
PARTITION_ON_HOST = 1024
def __init__(self, input_dims, hidden_dims, n_hi, n_lo, activation):
super().__init__()
if n_hi <= 0 or n_lo <= 0:
raise ValueError(f"both banks must be non-empty, got {n_hi}/{n_lo}")
self.n_hi, self.n_lo = n_hi, n_lo
self.bank_hi = SwitchGLU(input_dims, hidden_dims, n_hi, activation=activation)
self.bank_lo = SwitchGLU(input_dims, hidden_dims, n_lo, activation=activation)
@property
def num_experts(self):
return self.n_hi + self.n_lo
@staticmethod
def _run(bank, xp, idx):
"""One bank over an already-sorted slice of pairs."""
up = bank.up_proj(xp, idx, sorted_indices=True)
gate = bank.gate_proj(xp, idx, sorted_indices=True)
return bank.down_proj(bank.activation(up, gate), idx, sorted_indices=True)
def __call__(self, x: mx.array, indices: mx.array) -> mx.array:
lead = indices.shape[:-1]
K = indices.shape[-1]
flat = indices.reshape(-1)
total = flat.size
if total <= self.PARTITION_ON_HOST:
# one host transfer beats argsort-kernel + sum-kernel + sync
host = np.array(flat.tolist(), dtype=np.int32)
order = mx.array(np.argsort(host, kind="stable"))
split = int((host < self.n_hi).sum())
else:
order = mx.argsort(flat)
split = int(mx.sum(flat < self.n_hi))
inv_order = mx.argsort(order)
sorted_idx = flat[order]
# one row per (token, slot) pair, in expert order
pairs = mx.expand_dims(x, (-2, -3)).flatten(0, -3)[order // K]
if split == 0:
out = self._run(self.bank_lo, pairs, sorted_idx - self.n_hi)
elif split == total:
out = self._run(self.bank_hi, pairs, sorted_idx)
else:
out = mx.concatenate(
[
self._run(self.bank_hi, pairs[:split], sorted_idx[:split]),
self._run(self.bank_lo, pairs[split:], sorted_idx[split:] - self.n_hi),
],
axis=0,
)
return mx.unflatten(out[inv_order], 0, (*lead, K)).squeeze(-2)
class KimiSparseMoE(nn.Module):
"""LatentMoE: 896 experts run in a 3584-d latent, not the 7168-d residual.
hidden -> down_proj -> [top-16 of 896 experts] -> norm -> up_proj -> hidden
plus 2 shared experts applied to the *undown-projected* input.
"""
def __init__(
self,
args: ModelArgs,
num_experts: Optional[int] = None,
bank_split: int = 0,
):
super().__init__()
self.args = args
h = args.hidden_size
self.num_experts = num_experts if num_experts is not None else args.num_experts
self.bank_split = bank_split
self.use_latent = args.routed_expert_hidden_size is not None
self.moe_hidden = args.routed_expert_hidden_size if self.use_latent else h
self.use_norm = args.latent_moe_use_norm
self.gate = nn.Linear(h, self.num_experts, bias=False)
self.e_score_correction_bias = mx.zeros((self.num_experts,), dtype=mx.float32)
if bank_split:
self.switch_mlp = TwoBankSwitchGLU(
self.moe_hidden,
args.moe_intermediate_size,
bank_split,
self.num_experts - bank_split,
activation=_situ_from_args(args),
)
else:
self.switch_mlp = SwitchGLU(
self.moe_hidden,
args.moe_intermediate_size,
self.num_experts,
activation=_situ_from_args(args),
)
if self.use_latent:
self.routed_expert_down_proj = nn.Linear(h, self.moe_hidden, bias=False)
self.routed_expert_up_proj = nn.Linear(self.moe_hidden, h, bias=False)
if self.use_norm:
self.routed_expert_norm = nn.RMSNorm(
self.moe_hidden, eps=args.rms_norm_eps
)
if args.num_shared_experts:
self.shared_experts = KimiMLP(
args, intermediate_size=args.moe_intermediate_size * args.num_shared_experts
)
else:
self.shared_experts = None
# Optional REAP saliency tap (scripts/reap_calibrate.py). Left as None in
# normal use and costs nothing. It exists so the calibration harness reads
# gates and per-expert outputs from *this* forward pass rather than a
# reimplementation that could drift from it. MLX keeps non-array
# attributes out of parameters()/children(), so this never touches
# quantization, saving or loading.
self.expert_stats_hook = None
def __call__(self, x: mx.array) -> mx.array:
identity = x
inds, weights = _group_expert_select(
self.gate(x),
self.e_score_correction_bias,
min(self.args.num_experts_per_token, self.num_experts),
self.args.num_expert_group,
self.args.topk_group,
self.args.routed_scaling_factor,
self.args.moe_renormalize,
self.args.moe_router_activation_func,
)
y = self.routed_expert_down_proj(x) if self.use_latent else x
y = self.switch_mlp(y, inds)
if self.expert_stats_hook is not None:
# y here is the per-expert output BEFORE gate weighting, shape
# (..., top_k, moe_hidden) -- exactly what REAP saliency needs.
self.expert_stats_hook(inds, weights, y)
y = (y * weights[..., None]).sum(axis=-2)
if self.use_latent:
if self.use_norm:
y = self.routed_expert_norm(y)
y = self.routed_expert_up_proj(y)
if self.shared_experts is not None:
y = y + self.shared_experts(identity)
return y
# ------------------------------------------------- checkpoint key remapping
#
# Single source of truth, shared by Model.sanitize (whole-checkpoint, used when
# mlx-lm loads a converted repo) and scripts/convert.py (per-layer, used when
# streaming the 1.56 TB source). Keeping one implementation is the point: the
# streaming converter never instantiates a Model, so a second copy of these
# rules would be free to drift out of sync and silently mis-place weights.
def is_kda_layer(args: ModelArgs, idx: int) -> bool:
# config lists kda_layers/full_attn_layers 1-BASED: config n -> layer n-1.
return (idx + 1) in args.linear_attn_config["kda_layers"]
def is_moe_layer(args: ModelArgs, idx: int) -> bool:
return (
args.num_experts > 0
and idx >= args.first_k_dense_replace
and idx % args.moe_layer_freq == 0
)
def experts_in_layer(args: ModelArgs, idx: int) -> int:
"""Expert count for one layer — varies after REAP pruning."""
if args.expert_counts:
return int(args.expert_counts[idx])
return args.num_experts
def bank_split_in_layer(args: ModelArgs, idx: int) -> int:
"""How many of a layer's experts sit in the high-precision bank (0 = one bank)."""
if not args.expert_bank_split:
return 0
return int(args.expert_bank_split[idx])
def remap_checkpoint(
weights: Dict[str, mx.array],
args: ModelArgs,
layer_indices: Optional[List[int]] = None,
stack_experts: bool = True,
) -> Dict[str, mx.array]:
"""Map Kimi-K3 checkpoint keys onto this module tree.
`layer_indices` restricts the layer loop (the converter passes the single
layer it is holding). `stack_experts=False` leaves per-expert tensors alone
so the converter can quantize each expert before stacking, which keeps peak
memory at ~6 GB per layer instead of ~59 GB.
"""
out: Dict[str, mx.array] = {}
for k, v in weights.items():
# K3 wraps the text tower as `language_model.*`; the vision tower and
# projector are handled outside mlx-lm.
if k.startswith("vision_tower.") or k.startswith("mm_projector."):
continue
out[k[len("language_model.") :] if k.startswith("language_model.") else k] = v
weights = out
if args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
if layer_indices is None:
layer_indices = range(args.num_hidden_layers)
for i in layer_indices:
p = f"model.layers.{i}"
# [K3-2] AttnRes: fold `*_res_proj` / `*_res_norm` into the AttnResProj
# containers so the quantizer leaves them alone.
for src in ("self_attention_res", "mlp_res"):
for suffix, field_name in (("proj", "proj_weight"), ("norm", "norm_weight")):
key = f"{p}.{src}_{suffix}.weight"
if key in weights:
weights[f"{p}.{src}.{field_name}"] = weights.pop(key)
if is_moe_layer(args, i):
src = f"{p}.block_sparse_moe"
if stack_experts:
for w, dst in (("w1", "gate_proj"), ("w3", "up_proj"), ("w2", "down_proj")):
if f"{src}.experts.0.{w}.weight" in weights:
weights[f"{src}.switch_mlp.{dst}.weight"] = mx.stack(
[
weights.pop(f"{src}.experts.{e}.{w}.weight")
for e in range(experts_in_layer(args, i))
]
)
bias_key = f"{src}.gate.e_score_correction_bias"
if bias_key in weights:
weights[f"{src}.e_score_correction_bias"] = weights.pop(bias_key)
attn = f"{p}.self_attn"
if is_kda_layer(args, i):
for src, dst in (("q_conv1d", "q_conv"), ("k_conv1d", "k_conv"), ("v_conv1d", "v_conv")):
key = f"{attn}.{src}.weight"
if key in weights:
w = weights.pop(key)
if w.ndim == 3:
w = w.moveaxis(2, 1)
weights[f"{attn}.{dst}.conv.weight"] = w
dt = f"{attn}.dt_bias"
if dt in weights and weights[dt].ndim > 1:
weights[dt] = weights[dt].reshape(-1)
else:
# [K3-4] absorb kv_b_proj -> embed_q / unembed_out so the KV cache
# stores the 512-d latent rather than 96 materialised heads.
kv_b = f"{attn}.kv_b_proj.weight"
if kv_b in weights:
qk_nope = args.qk_nope_head_dim or 128
v_head = args.v_head_dim or 128
v = weights.pop(kv_b).reshape(args.num_attention_heads, qk_nope + v_head, -1)
weights[f"{attn}.embed_q.weight"] = mx.contiguous(
v[:, :qk_nope, :].swapaxes(-1, -2)
)
weights[f"{attn}.unembed_out.weight"] = mx.contiguous(v[:, qk_nope:, :])
for suffix, field_name in (("proj", "proj_weight"), ("norm", "norm_weight")):
key = f"model.output_attn_res_{suffix}.weight"
if key in weights:
weights[f"model.output_attn_res.{field_name}"] = weights.pop(key)
return weights
# ------------------------------------------------------------------- layers
class KimiDecoderLayer(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
self.args = args
self.layer_idx = layer_idx
# config lists full_attn_layers/kda_layers 1-BASED: config n -> layer n-1.
kda_layers = args.linear_attn_config["kda_layers"]
self.is_linear = (layer_idx + 1) in kda_layers
if self.is_linear:
self.self_attn = KimiDeltaAttention(args, layer_idx)
else:
self.self_attn = KimiMLAAttention(args)
is_moe = (
args.num_experts > 0
and layer_idx >= args.first_k_dense_replace
and layer_idx % args.moe_layer_freq == 0
)
if is_moe:
self.block_sparse_moe = KimiSparseMoE(
args,
experts_in_layer(args, layer_idx),
bank_split_in_layer(args, layer_idx),
)
else:
self.mlp = KimiMLP(args)
self.is_moe = is_moe
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
)
self.use_attn_res = args.attn_res_block_size is not None
if self.use_attn_res:
self.attn_res_block_size = args.attn_res_block_size
self.self_attention_res = AttnResProj(args.hidden_size)
self.mlp_res = AttnResProj(args.hidden_size)
def _ffn(self, x: mx.array) -> mx.array:
return self.block_sparse_moe(x) if self.is_moe else self.mlp(x)
def __call__(self, x, mask=None, cache=None, block_residual=None):
if not self.use_attn_res:
h = x + self.self_attn(self.input_layernorm(x), mask, cache)
return h + self._ffn(self.post_attention_layernorm(h)), block_residual
B, T, H = x.shape
eps = self.args.rms_norm_eps
prefix_sum = x
if block_residual is not None and block_residual.shape[1] > 0:
x = self.self_attention_res(
prefix_sum.reshape(-1, H), block_residual, eps
).reshape(B, T, H)
pushed = self.layer_idx % self.attn_res_block_size == 0
if pushed:
block_residual = mx.concatenate(
[block_residual, mx.expand_dims(prefix_sum.reshape(-1, H), 1)], axis=1
)
y = self.self_attn(self.input_layernorm(x), mask, cache)
prefix_sum = y if pushed else prefix_sum + y
x = self.mlp_res(prefix_sum.reshape(-1, H), block_residual, eps).reshape(B, T, H)
z = self._ffn(self.post_attention_layernorm(x))
return prefix_sum + z, block_residual
class KimiK3Model(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 = [KimiDecoderLayer(args, i) for i in range(args.num_hidden_layers)]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.use_attn_res = args.attn_res_block_size is not None
if self.use_attn_res:
self.output_attn_res = AttnResProj(args.hidden_size)
kda_layers = args.linear_attn_config["kda_layers"]
self.ssm_idx = kda_layers[0] - 1
self.attn_idx = next(
i for i in range(len(self.layers)) if (i + 1) not in kda_layers
)
def __call__(
self,
inputs: mx.array,
cache: Optional[List[Any]] = None,
inputs_embeds: Optional[mx.array] = None,
) -> mx.array:
# inputs_embeds lets the multimodal wrapper splice image features in
# place of the <|media_pad|> placeholder before the stack runs.
h = self.embed_tokens(inputs) if inputs_embeds is None else inputs_embeds
if cache is None:
cache = [None] * len(self.layers)
ssm_mask = create_ssm_mask(h, cache[self.ssm_idx])
attn_mask = create_attention_mask(h, cache[self.attn_idx], return_array=True)
B, T, H = h.shape
block_residual = mx.zeros((B * T, 0, H), dtype=h.dtype) if self.use_attn_res else None
for layer, layer_cache in zip(self.layers, cache):
mask = ssm_mask if layer.is_linear else attn_mask
h, block_residual = layer(h, mask, layer_cache, block_residual)
if self.use_attn_res:
h = self.output_attn_res(
h.reshape(-1, H), block_residual, self.args.rms_norm_eps
).reshape(B, T, H)
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 = KimiK3Model(args)
self.lm_head = (
None
if args.tie_word_embeddings
else nn.Linear(args.hidden_size, args.vocab_size, bias=False)
)
def __call__(
self,
inputs: mx.array,
cache: Optional[List[Any]] = None,
inputs_embeds: Optional[mx.array] = None,
) -> mx.array:
out = self.model(inputs, cache, inputs_embeds=inputs_embeds)
if self.lm_head is None:
return self.model.embed_tokens.as_linear(out)
return self.lm_head(out)
@property
def layers(self):
return self.model.layers
def make_cache(self):
return [
ArraysCache(size=4) if layer.is_linear else KVCache()
for layer in self.layers
]
def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]:
return remap_checkpoint(weights, self.args)
@property
def cast_predicate(self):
def predicate(path: str):
if "e_score_correction_bias" in path:
return False
if path.endswith("A_log") or path.endswith("dt_bias"):
return False
return True
return predicate
@property
def quant_predicate(self):
def predicate(path, _):
# router gate drives top-16-of-896 selection; 8 bits is cheap insurance
if path.endswith("block_sparse_moe.gate"):
return {"group_size": 64, "bits": 8}
return True
return predicate