ASTRAI-labs/pluto-nano-0.5-base
021
1"""2ASTRAI Pluto — native architecture for the Pluto family.3 4A standalone decoder-only Transformer with:5 * RMSNorm + RoPE (no learned positional embeddings)6 * Causal SDPA attention (multi-head, optional GQA)7 * Top-K Mixture-of-Experts (SwiGLU experts), no required shared expert8 * Multi-Token Prediction heads (training-only)9 * Tied input/output embedding10 * Router auxiliary loss (load balance) + z-loss11 12Not derived from any HuggingFace base model — fresh implementation in plain13PyTorch. Save/load uses a `pluto_config.json` + a safetensors weights file.14 15Naming: `PlutoModel` / `PlutoForCausalLM`. The `_meta` dict on the config holds16size hyper-params; routing / aux-loss config is on its own dataclass.17"""18from __future__ import annotations19 20import json21import math22import os23from dataclasses import asdict, dataclass, field24from pathlib import Path25from typing import Optional26 27import torch28import torch.nn as nn29import torch.nn.functional as F30 31 32# ─── Config ─────────────────────────────────────────────────────────────33 34@dataclass35class PlutoConfig:36 # Architecture (multilingual Nano — d=384, layers=16, GQA, 32k vocab)37 vocab_size: int = 3276838 hidden_size: int = 38439 intermediate_size_expert: int = 153640 intermediate_size_shared: int = 0 # 0 = no shared expert41 n_layers: int = 1642 n_heads: int = 643 n_kv_heads: int = 2 # GQA: 6→2 → ~50 % attn-param saving44 n_experts: int = 35 # 5 langs × 7 experts each45 top_k: int = 1 # max sparsity → ~50 M active inference46 n_languages: int = 5 # en, pt, es, zh, hi47 max_position_embeddings: int = 409648 rope_theta: float = 1_000_000.049 rms_norm_eps: float = 1e-650 tie_word_embeddings: bool = True51 52 # MTP — training-only aux heads53 mtp_depth: int = 254 mtp_loss_weight: float = 0.1555 56 # Routing aux losses57 router_aux_loss_coef: float = 0.0158 router_z_loss_coef: float = 0.00159 60 # Bookkeeping61 model_type: str = "astrai_pluto"62 pad_token_id: int | None = None63 bos_token_id: int | None = None64 eos_token_id: int | None = None65 66 # Tokenizer config (saved for convenience)67 tokenizer_name: str | None = None68 69 def to_dict(self) -> dict:70 return asdict(self)71 72 @classmethod73 def from_dict(cls, d: dict) -> "PlutoConfig":74 # ignore extra keys silently for forward-compat75 known = {f.name for f in cls.__dataclass_fields__.values()}76 return cls(**{k: v for k, v in d.items() if k in known})77 78 def save(self, output_dir: str | Path) -> None:79 os.makedirs(output_dir, exist_ok=True)80 with open(Path(output_dir) / "pluto_config.json", "w") as f:81 json.dump(self.to_dict(), f, indent=2)82 83 @classmethod84 def load(cls, model_dir: str | Path) -> "PlutoConfig":85 with open(Path(model_dir) / "pluto_config.json") as f:86 return cls.from_dict(json.load(f))87 88 89# ─── Layers ─────────────────────────────────────────────────────────────90 91class RMSNorm(nn.Module):92 def __init__(self, dim: int, eps: float = 1e-6):93 super().__init__()94 self.weight = nn.Parameter(torch.ones(dim))95 self.eps = eps96 97 def forward(self, x: torch.Tensor) -> torch.Tensor:98 # Compute in fp32 for numerical stability, return in input dtype99 out = x.float()100 norm = out.pow(2).mean(-1, keepdim=True).add(self.eps).rsqrt()101 return (out * norm).to(x.dtype) * self.weight102 103 104def _rope_freqs(dim: int, base: float, device, dtype=torch.float32) -> torch.Tensor:105 inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2, device=device, dtype=dtype) / dim))106 return inv_freq107 108 109def _rope_cache(seq_len: int, dim: int, base: float, device) -> tuple[torch.Tensor, torch.Tensor]:110 inv_freq = _rope_freqs(dim, base, device)111 t = torch.arange(seq_len, device=device, dtype=torch.float32)112 freqs = torch.outer(t, inv_freq)113 cos = freqs.cos()114 sin = freqs.sin()115 return cos, sin116 117 118def _apply_rope(q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor):119 # q, k: [B, H, T, Dh]; cos, sin: [T, Dh/2]120 def rotate(x: torch.Tensor) -> torch.Tensor:121 x1, x2 = x[..., ::2], x[..., 1::2]122 rot = torch.stack((-x2 * sin + x1 * cos, x1 * sin + x2 * cos), dim=-1)123 return rot.flatten(-2)124 return rotate(q), rotate(k)125 126 127class PlutoAttention(nn.Module):128 """Causal SDPA attention with optional GQA + RoPE."""129 def __init__(self, cfg: PlutoConfig):130 super().__init__()131 assert cfg.hidden_size % cfg.n_heads == 0132 self.cfg = cfg133 self.head_dim = cfg.hidden_size // cfg.n_heads134 self.q_proj = nn.Linear(cfg.hidden_size, cfg.n_heads * self.head_dim, bias=False)135 self.k_proj = nn.Linear(cfg.hidden_size, cfg.n_kv_heads * self.head_dim, bias=False)136 self.v_proj = nn.Linear(cfg.hidden_size, cfg.n_kv_heads * self.head_dim, bias=False)137 self.o_proj = nn.Linear(cfg.hidden_size, cfg.hidden_size, bias=False)138 139 def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:140 B, T, D = x.shape141 H = self.cfg.n_heads142 Hk = self.cfg.n_kv_heads143 Dh = self.head_dim144 145 q = self.q_proj(x).view(B, T, H, Dh).transpose(1, 2) # [B, H, T, Dh]146 k = self.k_proj(x).view(B, T, Hk, Dh).transpose(1, 2) # [B, Hk, T, Dh]147 v = self.v_proj(x).view(B, T, Hk, Dh).transpose(1, 2)148 q, k = _apply_rope(q, k, cos[:T].to(q.dtype), sin[:T].to(q.dtype))149 # GQA: expand kv if Hk < H150 if Hk != H:151 repeats = H // Hk152 k = k.repeat_interleave(repeats, dim=1)153 v = v.repeat_interleave(repeats, dim=1)154 y = F.scaled_dot_product_attention(q, k, v, is_causal=True)155 y = y.transpose(1, 2).contiguous().view(B, T, D)156 return self.o_proj(y)157 158 159class SwiGLU(nn.Module):160 def __init__(self, dim: int, hidden: int):161 super().__init__()162 self.w_gate = nn.Linear(dim, hidden, bias=False)163 self.w_up = nn.Linear(dim, hidden, bias=False)164 self.w_down = nn.Linear(hidden, dim, bias=False)165 166 def forward(self, x: torch.Tensor) -> torch.Tensor:167 return self.w_down(F.silu(self.w_gate(x)) * self.w_up(x))168 169 170class PlutoMoE(nn.Module):171 """Top-K MoE using grouped matmul (torch._grouped_mm).172 173 Expert weights are kept as 3 stacked tensors of shape [E, D, H] (gate, up)174 and [E, H, D] (down) so the whole layer is 3 grouped GEMMs per forward.175 176 Currently specialised for top_k == 1 (sort once, no aggregation). Top-K>1177 falls back to the per-expert loop.178 179 Optional shared expert (always active) if intermediate_size_shared > 0.180 """181 def __init__(self, cfg: PlutoConfig):182 super().__init__()183 self.cfg = cfg184 E, D, H = cfg.n_experts, cfg.hidden_size, cfg.intermediate_size_expert185 self.router = nn.Linear(D, E, bias=False)186 # SwiGLU expert weights stacked along the expert dim.187 # `_grouped_mm(A, B, offs)` expects B in [E, K, N] for A in [M, K]188 # → output [M, N]. So we store:189 # W_gate: [E, D, H] → x @ W_gate → [M, H]190 # W_up: [E, D, H]191 # W_down: [E, H, D]192 self.W_gate = nn.Parameter(torch.empty(E, D, H))193 self.W_up = nn.Parameter(torch.empty(E, D, H))194 self.W_down = nn.Parameter(torch.empty(E, H, D))195 # Init: Kaiming-like, scaled down so initial residual is well-behaved.196 std_in = 1.0 / math.sqrt(D)197 std_h = 1.0 / math.sqrt(H)198 nn.init.normal_(self.W_gate, std=std_in)199 nn.init.normal_(self.W_up, std=std_in)200 nn.init.normal_(self.W_down, std=std_h)201 self.shared = (SwiGLU(D, cfg.intermediate_size_shared)202 if cfg.intermediate_size_shared > 0 else None)203 204 @staticmethod205 def _offsets_from_counts(counts: torch.Tensor) -> torch.Tensor:206 # Convert [E] counts → end-offset tensor [E] of int32.207 # `torch._grouped_mm` consumes end-offsets (exclusive cumsum).208 return counts.cumsum(0).to(torch.int32)209 210 def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, dict]:211 B, T, D = x.shape212 E = self.cfg.n_experts213 x_flat = x.reshape(B * T, D)214 logits = self.router(x_flat) # [B*T, E]215 216 if self.cfg.top_k == 1:217 # Sort tokens by expert id → contiguous expert ranges → grouped GEMM218 top_idx = logits.argmax(dim=-1) # [B*T]219 sort_idx = top_idx.argsort(stable=True)220 x_sorted = x_flat[sort_idx] # [B*T, D]221 222 counts = torch.bincount(top_idx, minlength=E) # [E]223 offsets = self._offsets_from_counts(counts) # [E] end-offsets224 225 # Grouped SwiGLU: each token uses ONE expert.226 gate = torch._grouped_mm(x_sorted, self.W_gate, offsets) # [B*T, H]227 up = torch._grouped_mm(x_sorted, self.W_up, offsets) # [B*T, H]228 hidden = F.silu(gate) * up229 out_sorted = torch._grouped_mm(hidden, self.W_down, offsets) # [B*T, D]230 231 # Un-sort232 inverse = torch.empty_like(sort_idx)233 inverse[sort_idx] = torch.arange(sort_idx.size(0), device=x.device)234 out = out_sorted[inverse]235 else:236 # Top-K>1 fallback: slower loop. Kept for completeness.237 topk_vals, topk_idx = logits.topk(self.cfg.top_k, dim=-1)238 topk_w = F.softmax(topk_vals, dim=-1)239 out = torch.zeros_like(x_flat)240 for k in range(self.cfg.top_k):241 ids = topk_idx[..., k]242 w = topk_w[..., k].unsqueeze(-1)243 # Per-K grouped GEMM244 sort_idx = ids.argsort(stable=True)245 x_sorted = x_flat[sort_idx]246 counts = torch.bincount(ids, minlength=E)247 offsets = self._offsets_from_counts(counts)248 gate = torch._grouped_mm(x_sorted, self.W_gate, offsets)249 up = torch._grouped_mm(x_sorted, self.W_up, offsets)250 hidden = F.silu(gate) * up251 out_sorted = torch._grouped_mm(hidden, self.W_down, offsets)252 inverse = torch.empty_like(sort_idx)253 inverse[sort_idx] = torch.arange(sort_idx.size(0), device=x.device)254 out = out + out_sorted[inverse] * w255 top_idx = topk_idx[..., 0] # for aux-loss bookkeeping below256 257 if self.shared is not None:258 out = out + self.shared(x_flat)259 out = out.reshape(B, T, D)260 261 # Auxiliary losses (Switch Transformer load-balance + ST-MoE z-loss)262 aux: dict = {}263 if self.training:264 probs = F.softmax(logits.float(), dim=-1)265 expert_freq = probs.mean(dim=0) # [E]266 counts_norm = (counts.float() / counts.float().sum().clamp_min(1.0))267 aux["aux_load"] = (expert_freq * counts_norm).sum() * self.cfg.n_experts268 aux["aux_z"] = (logits.float().logsumexp(-1) ** 2).mean()269 return out, aux270 271 272class PlutoBlock(nn.Module):273 def __init__(self, cfg: PlutoConfig):274 super().__init__()275 self.ln1 = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)276 self.attn = PlutoAttention(cfg)277 self.ln2 = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)278 self.moe = PlutoMoE(cfg)279 280 def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> tuple[torch.Tensor, dict]:281 x = x + self.attn(self.ln1(x), cos, sin)282 y, aux = self.moe(self.ln2(x))283 x = x + y284 return x, aux285 286 287# ─── Models ─────────────────────────────────────────────────────────────288 289class PlutoModel(nn.Module):290 """Decoder backbone: token embed → N blocks → final RMSNorm."""291 def __init__(self, cfg: PlutoConfig):292 super().__init__()293 self.cfg = cfg294 self.embed_tokens = nn.Embedding(cfg.vocab_size, cfg.hidden_size)295 self.blocks = nn.ModuleList([PlutoBlock(cfg) for _ in range(cfg.n_layers)])296 self.final_norm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)297 self.register_buffer("_rope_initialised", torch.tensor(False), persistent=False)298 self._rope_cos = None299 self._rope_sin = None300 301 def _ensure_rope(self, seq_len: int, device, dtype):302 head_dim = self.cfg.hidden_size // self.cfg.n_heads303 if (self._rope_cos is None or self._rope_cos.size(0) < seq_len304 or self._rope_cos.device != device):305 cos, sin = _rope_cache(self.cfg.max_position_embeddings, head_dim,306 self.cfg.rope_theta, device)307 self._rope_cos = cos.to(dtype)308 self._rope_sin = sin.to(dtype)309 310 def forward(self, input_ids: torch.Tensor) -> tuple[torch.Tensor, list[dict]]:311 B, T = input_ids.shape312 h = self.embed_tokens(input_ids)313 self._ensure_rope(T, h.device, h.dtype)314 aux_list = []315 for blk in self.blocks:316 h, aux = blk(h, self._rope_cos, self._rope_sin)317 aux_list.append(aux)318 h = self.final_norm(h)319 return h, aux_list320 321 322class PlutoForCausalLM(nn.Module):323 """LM head + optional MTP heads. Returns full loss in `forward`."""324 def __init__(self, cfg: PlutoConfig):325 super().__init__()326 self.cfg = cfg327 self.model = PlutoModel(cfg)328 self.lm_head = nn.Linear(cfg.hidden_size, cfg.vocab_size, bias=False)329 if cfg.tie_word_embeddings:330 self.lm_head.weight = self.model.embed_tokens.weight331 # MTP — training-only auxiliary heads that predict tokens further ahead.332 self.mtp_heads = nn.ModuleList([333 nn.Linear(cfg.hidden_size, cfg.vocab_size, bias=False)334 for _ in range(cfg.mtp_depth)335 ])336 337 def forward(self, input_ids: torch.Tensor, labels: torch.Tensor | None = None,338 attention_mask: torch.Tensor | None = None,339 ) -> dict:340 # We only honour `labels` from the training harness (HF API).341 if labels is None:342 labels = input_ids343 h, aux_list = self.model(input_ids)344 logits = self.lm_head(h)345 out = {"logits": logits}346 347 # Main next-token loss. Trainer is expected to pass `input_ids = ids[:-1]`348 # and `labels = ids[1:]` so they already align (no internal shift).349 if labels is not None and labels.size(1) == logits.size(1):350 ce = F.cross_entropy(351 logits.float().view(-1, logits.size(-1)),352 labels.view(-1),353 ignore_index=-100,354 )355 loss = ce356 # MTP auxiliary losses: head d predicts the token d positions ahead.357 # Skip entirely when mtp_loss_weight == 0 to save the per-head matmul358 # against the full vocab — that head alone is ~15-20 % of step time.359 if self.cfg.mtp_depth > 0 and self.cfg.mtp_loss_weight > 0:360 mtp_total = 0.0361 for d, head in enumerate(self.mtp_heads, start=1):362 if labels.size(1) <= d: continue363 logits_d = head(h)[:, :-d, :].contiguous()364 labels_d = labels[:, d:].contiguous()365 mtp_total = mtp_total + F.cross_entropy(366 logits_d.float().view(-1, logits_d.size(-1)),367 labels_d.view(-1),368 ignore_index=-100,369 )370 loss = loss + self.cfg.mtp_loss_weight * (mtp_total / max(self.cfg.mtp_depth, 1))371 # Router aux losses (averaged over layers)372 if aux_list and "aux_load" in aux_list[0]:373 aux_load = torch.stack([a["aux_load"] for a in aux_list]).mean()374 aux_z = torch.stack([a["aux_z"] for a in aux_list]).mean()375 loss = (loss + self.cfg.router_aux_loss_coef * aux_load376 + self.cfg.router_z_loss_coef * aux_z)377 out["loss"] = loss378 return out379 380 381# ─── Save / load ────────────────────────────────────────────────────────382 383def save_pluto(model: PlutoForCausalLM, output_dir: str | Path) -> None:384 model.cfg.save(output_dir)385 from safetensors.torch import save_model386 # `save_model` handles tied weights (embed↔lm_head) by deduplicating them.387 # We must NOT permanently move the model to CPU — restore device after save.388 devices = {p.device for p in model.parameters()}389 device = next(iter(devices)) if len(devices) == 1 else None390 model_cpu = model.cpu()391 save_model(model_cpu, str(Path(output_dir) / "model.safetensors"))392 if device is not None and device.type != "cpu":393 model.to(device)394 395 396def load_pluto(model_dir: str | Path, dtype=torch.bfloat16, map_location="cpu") -> PlutoForCausalLM:397 cfg = PlutoConfig.load(model_dir)398 model = PlutoForCausalLM(cfg).to(dtype)399 from safetensors.torch import load_file400 state = load_file(str(Path(model_dir) / "model.safetensors"), device=str(map_location))401 model.load_state_dict(state, strict=False)402 return model403 404 405# ─── Param accounting ──────────────────────────────────────────────────406 407def count_params(model: nn.Module) -> int:408 return sum(p.numel() for p in model.parameters())409 410 411def estimate_active_params(cfg: PlutoConfig) -> dict:412 """At-inference active params (MTP heads NOT counted, since they are training-only)."""413 head_dim = cfg.hidden_size // cfg.n_heads414 attn_per_layer = (415 cfg.hidden_size * cfg.n_heads * head_dim # q_proj416 + cfg.hidden_size * cfg.n_kv_heads * head_dim # k_proj417 + cfg.hidden_size * cfg.n_kv_heads * head_dim # v_proj418 + cfg.hidden_size * cfg.hidden_size # o_proj419 )420 expert_size = 3 * cfg.hidden_size * cfg.intermediate_size_expert # SwiGLU421 shared_size = (3 * cfg.hidden_size * cfg.intermediate_size_shared422 if cfg.intermediate_size_shared > 0 else 0)423 active_per_layer = attn_per_layer + cfg.top_k * expert_size + shared_size424 active_total = active_per_layer * cfg.n_layers425 # lm_head is also "active" (full matmul against vocab)426 active_total += cfg.vocab_size * cfg.hidden_size427 428 total_experts = expert_size * cfg.n_experts * cfg.n_layers429 total_shared = shared_size * cfg.n_layers430 total_attn = attn_per_layer * cfg.n_layers431 emb_params = cfg.vocab_size * cfg.hidden_size432 lm_head_params = 0 if cfg.tie_word_embeddings else cfg.vocab_size * cfg.hidden_size433 mtp_params = cfg.mtp_depth * cfg.vocab_size * cfg.hidden_size434 total_params = (total_experts + total_shared + total_attn + emb_params435 + lm_head_params + mtp_params436 + 2 * cfg.n_layers * cfg.hidden_size # RMSNorm weights437 + cfg.hidden_size)438 return {439 "total_params": total_params,440 "active_inference_params": active_total,441 "expert_total_params": total_experts,442 "attn_total_params": total_attn,443 "embedding_params": emb_params,444 "lm_head_params": lm_head_params,445 "mtp_head_params": mtp_params,446 }447 448 449if __name__ == "__main__":450 cfg = PlutoConfig()451 stats = estimate_active_params(cfg)452 for k, v in stats.items():453 print(f" {k:<28} {v/1e6:>8.2f} M")454 print(f" active/total ratio {stats['active_inference_params']/stats['total_params']*100:>5.2f} %")455 456 m = PlutoForCausalLM(cfg)457 n_real = count_params(m)458 print(f"\n real (actual) total {n_real/1e6:>8.2f} M")459 x = torch.randint(0, cfg.vocab_size, (2, 32))460 out = m(x, labels=x)461 print(f" fwd OK logits {tuple(out['logits'].shape)} loss={out['loss'].item():.4f}")462 