| """Looped-MoE modeling code (port of the seedvar `loop-lm` architecture). |
| |
| Provenance |
| ---------- |
| Ported from ``modeling_loop_lm.py`` as published with |
| ``ml-ryanlee/seedvar-looped-moe-1e18-d704-seed42..47`` (arXiv 2605.09165, |
| *Sparse Layers are Critical to Scaling Looped Language Models*). That file is the |
| architecture specification: the published checkpoints are its training product, |
| and the diagnostics pipeline is already validated against it. |
| |
| This is a **port, not a copy**. The numerics are kept faithful (see "Faithful to |
| the original" below) because the baseline's whole job is to reproduce the |
| published architecture; the deviations are all in service of three requirements |
| from HANDOVER §4.3 that the original file does not meet: |
| |
| 1. **Semantic parameters have no defaults.** The original ``LoopLMConfig`` |
| defaults ``d_model=1024``, ``num_experts=8`` and so on. Defaults are how a |
| silently-wrong run happens: a typo'd key name falls back to a plausible |
| number and the run looks fine. Every shape/semantic field here is required |
| and a missing one raises. |
| 2. **Every loop step is hookable, by explicit index.** The loop counter is |
| threaded down to each block, so a consumer never recovers the loop axis by |
| reshaping a flattened layer axis (which silently yields transposed |
| semantics). |
| 3. **Router logits are exposed with their semantics labelled.** Both the |
| pre-softmax logits and the post-softmax probabilities are handed out, tagged |
| via ``src.model.loop_trace.ROUTER_LOGITS_ARE_PRESOFTMAX``. |
| |
| Scope |
| ----- |
| Only the ``looped-moe`` variant is implemented. The original file carries four |
| variants (base / looped / moe / looped-moe); all four architectures this project |
| trains -- baseline and DVF-a/b/c -- are looped-moe, differing only in the (L, R, E) |
| triple. Porting the unused three would be dead code (project rule: no entities |
| beyond necessity). They remain available in the original file if ever needed. |
| |
| Shape parameters, and what the experiment varies |
| ------------------------------------------------ |
| ==================== ====== ========================================= |
| config field symbol meaning |
| ==================== ====== ========================================= |
| num_layers_in_stack L physical layers in the shared stack |
| num_stacks R times the stack is called (loop count) |
| num_experts E experts per MoE layer |
| num_active k experts activated per token |
| ==================== ====== ========================================= |
| |
| The DVF ("dual vector foil") series holds L*R = 16 and E*L = 64 fixed and only |
| moves capacity around: baseline (8,2,8), DVF-a (4,4,16), DVF-b (2,8,32), |
| DVF-c (1,16,64). |
| |
| Faithful to the original (do not "fix" these -- they are muP, not bugs) |
| ---------------------------------------------------------------------- |
| * ``RMSNorm`` has **no** gain parameter. |
| * Attention is scaled by ``1/d_k``, not ``1/sqrt(d_k)``. |
| * Softmax upcasts to float32. |
| * Expert FFN width is ``d_ff // num_active`` -- divided by k, *not* by E. This is |
| what makes E*L=64 hold total expert parameters constant across the DVF series. |
| * Initialisation is muP: ``std = std_base / sqrt(width_ratio)`` with |
| ``std_base = sqrt(2/(fan_in_base + fan_out_base))`` against a d_base=128 proxy. |
| |
| Deliberate deviation |
| -------------------- |
| ``RotaryPositionalEmbedding`` builds its rotation table with vectorised torch ops |
| instead of the original's ``max_seq_len * d_k/2`` nested Python loop, which costs |
| minutes at seq_len 4096. ``tests/test_rope_equivalence.py`` pins the vectorised |
| table against a literal transcription of the original loop. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| from typing import Any, Optional |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from einops import einsum, rearrange, reduce, repeat |
| from torch import Tensor |
| from torch.nn.functional import grouped_mm, silu |
| from transformers import PretrainedConfig, PreTrainedModel |
| from transformers.generation import GenerationMixin |
| from transformers.modeling_outputs import CausalLMOutputWithPast |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| from src.model.loop_trace import ACTIVE_TRACE_SINK, TraceMeta, TracePoint, TraceSink |
| except ImportError: |
| try: |
| from .loop_trace import ACTIVE_TRACE_SINK, TraceMeta, TracePoint, TraceSink |
| except ImportError: |
| from loop_trace import ACTIVE_TRACE_SINK, TraceMeta, TracePoint, TraceSink |
|
|
| __all__ = ["LoopMoEConfig", "LoopMoEForCausalLM", "LoopedMoETransformer"] |
|
|
| |
| |
| HEAD_TAIL_NUM_EXPERTS = 8 |
| """Experts in a head/tail layer, fixed across every ablation configuration. |
| |
| Recipe 2026-09-12 section 2.1: head and tail are "identical in all configurations" |
| (1 layer, 8 experts, top-2). It is deliberately independent of the loop block's E -- |
| S4 gives the loop 64 experts per layer and its head still has 8 -- so the loop block's |
| count must not be reused here. |
| """ |
|
|
| BASE_D_MODEL = 128 |
| BASE_D_FF = 384 |
|
|
|
|
| def softmax(logits: Tensor, dim: int) -> Tensor: |
| """Max-shifted softmax in float32 (verbatim semantics from the original).""" |
| logits = logits.float() |
| max_values = torch.max(logits, dim=dim, keepdim=True).values |
| shifted = logits - max_values |
| shifted_exps = torch.exp(shifted) |
| shifted_exp_sums = torch.sum(shifted_exps, dim=dim, keepdim=True) |
| return shifted_exps / shifted_exp_sums |
|
|
|
|
| class Linear(nn.Module): |
| """Bias-free linear layer with muP initialisation.""" |
|
|
| def __init__(self, in_features, out_features, width_ratio, std_base, device=None, dtype=None): |
| super().__init__() |
| |
| self.weight = nn.Parameter(torch.empty(out_features, in_features, dtype=dtype, device=device)) |
| |
| |
| |
| |
| self._init_std = std_base / math.sqrt(width_ratio) |
| self.reset_parameters() |
|
|
| def reset_parameters(self) -> None: |
| std = self._init_std |
| nn.init.trunc_normal_(self.weight, mean=0.0, std=std, a=-3 * std, b=3 * std) |
|
|
| def forward(self, x: Tensor) -> Tensor: |
| return einsum(self.weight, x, "d_out d_in, ... d_in -> ... d_out") |
|
|
|
|
| class Embedding(nn.Module): |
| def __init__(self, num_embeddings, embedding_dim, device=None, dtype=None): |
| super().__init__() |
| self.weight = nn.Parameter(torch.empty(num_embeddings, embedding_dim, dtype=dtype, device=device)) |
| self.reset_parameters() |
|
|
| def reset_parameters(self) -> None: |
| nn.init.trunc_normal_(self.weight, mean=0.0, std=1.0, a=-3, b=3) |
|
|
| def forward(self, token_ids: Tensor) -> Tensor: |
| return self.weight[token_ids] |
|
|
|
|
| class RMSNorm(nn.Module): |
| """RMS norm **without** a gain parameter (muP convention).""" |
|
|
| def __init__(self, d_model: int, eps: float = 1e-5, device=None, dtype=None): |
| super().__init__() |
| self.d_model = d_model |
| self.eps = eps |
|
|
| def forward(self, x: Tensor) -> Tensor: |
| in_dtype = x.dtype |
| x = x.to(torch.float32) |
| mean_squared_sum = (1 / self.d_model) * einsum(x, x, "... seq d, ... seq d -> ... seq") |
| rms = torch.sqrt(mean_squared_sum + self.eps) |
| rms_norm = einsum(x, 1 / rms, "... seq d, ... seq -> ... seq d") |
| return rms_norm.to(in_dtype) |
|
|
|
|
| class PositionwiseFeedforward(nn.Module): |
| """SwiGLU: W2(SiLU(W1 x) * W3 x).""" |
|
|
| def __init__(self, d_model: int, d_ff: int, width_ratio: float, device=None, dtype=None): |
| super().__init__() |
| w_std_base = math.sqrt(2 / (BASE_D_MODEL + BASE_D_FF)) |
| self.w1 = Linear(d_model, d_ff, width_ratio, w_std_base, device=device, dtype=dtype) |
| self.w2 = Linear(d_ff, d_model, width_ratio, w_std_base, device=device, dtype=dtype) |
| self.w3 = Linear(d_model, d_ff, width_ratio, w_std_base, device=device, dtype=dtype) |
|
|
| def forward(self, x: Tensor) -> Tensor: |
| return self.w2(silu(self.w1(x)) * self.w3(x)) |
|
|
|
|
| class RotaryPositionalEmbedding(nn.Module): |
| """RoPE with a precomputed [seq, d_k/2, 2, 2] rotation table. |
| |
| Vectorised rebuild of the original's nested Python loop; see the module |
| docstring and ``tests/test_rope_equivalence.py``. |
| """ |
|
|
| def __init__(self, theta: float, d_k: int, max_seq_len: int, device=None, dtype=None): |
| super().__init__() |
| |
| |
| |
| |
| self._rope_theta, self._rope_d_k = theta, d_k |
| self._rope_max_seq_len, self._rope_dtype = max_seq_len, dtype |
| rotations = self._build_table(theta, d_k, max_seq_len, device, dtype) |
| self.register_buffer("rotations", rotations, persistent=True) |
|
|
| @staticmethod |
| def _build_table(theta: float, d_k: int, max_seq_len: int, device, dtype) -> Tensor: |
| """[seq, d_k/2, 2, 2] rotation table. |
| |
| Angles are built in float64 and only then cast down. At seq_len 4096 the |
| largest angle is ~4096 rad, where float32 spacing is ~2.4e-4; computing |
| cos/sin at float32 there loses ~4 decimal digits. The original does this |
| implicitly (Python floats are float64), so float64 here is both more |
| accurate and what keeps the table equal to the reference. |
| """ |
| positions = torch.arange(max_seq_len, device=device, dtype=torch.float64) |
| pair_idx = torch.arange(d_k // 2, device=device, dtype=torch.float64) |
| inv_freq = theta ** (2 * pair_idx / d_k) |
| angles = positions[:, None] / inv_freq[None, :] |
| cos, sin = torch.cos(angles), torch.sin(angles) |
| |
| table = torch.stack( |
| [torch.stack([cos, -sin], dim=-1), torch.stack([sin, cos], dim=-1)], dim=-2 |
| ) |
| return table.to(dtype if dtype is not None else torch.float32) |
|
|
| @torch.no_grad() |
| def reset_parameters(self) -> None: |
| """Regenerate the rotation table in place (buffers survive nothing).""" |
| self.rotations.copy_( |
| self._build_table( |
| self._rope_theta, self._rope_d_k, self._rope_max_seq_len, |
| self.rotations.device, self.rotations.dtype, |
| ) |
| ) |
|
|
| def forward(self, x: Tensor, token_positions: Tensor) -> Tensor: |
| rot = self.rotations[token_positions].to(dtype=x.dtype) |
| x_pairs = rearrange(x, "... seq_dim (feature_dim i) -> ... seq_dim feature_dim i", i=2) |
| y_pairs = einsum( |
| rot, |
| x_pairs, |
| "... seq_dim feature_dim i j, ... seq_dim feature_dim j -> ... seq_dim feature_dim i", |
| ) |
| return rearrange(y_pairs, "... seq_dim feature_dim i -> ... seq_dim (feature_dim i)") |
|
|
|
|
| class MultiheadSelfAttention(nn.Module): |
| """Causal MHSA with RoPE. muP: attention logits scaled by 1/d_k.""" |
|
|
| def __init__(self, d_model: int, num_heads: int, max_seq_len: int, theta: float, |
| width_ratio: float, device=None, dtype=None): |
| super().__init__() |
| if d_model % num_heads != 0: |
| raise ValueError(f"d_model ({d_model}) must be divisible by num_heads ({num_heads})") |
| self.d_model = d_model |
| self.num_heads = num_heads |
|
|
| attn_std_base = math.sqrt(2 / (BASE_D_MODEL + BASE_D_MODEL)) |
| self.q_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype) |
| self.k_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype) |
| self.v_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype) |
| self.output_proj = Linear(d_model, d_model, width_ratio, attn_std_base, device=device, dtype=dtype) |
| self.rope = RotaryPositionalEmbedding(theta, d_model // num_heads, max_seq_len, device, dtype) |
|
|
| def forward(self, x: Tensor, token_positions: Optional[Tensor] = None) -> Tensor: |
| d_k = self.d_model // self.num_heads |
| q_heads = rearrange(self.q_proj(x), "... seq (heads d_k) -> ... heads seq d_k", d_k=d_k) |
| k_heads = rearrange(self.k_proj(x), "... seq (heads d_k) -> ... heads seq d_k", d_k=d_k) |
| v_heads = rearrange(self.v_proj(x), "... seq (heads d_v) -> ... heads seq d_v", d_v=d_k) |
|
|
| if token_positions is None: |
| token_positions = rearrange(torch.arange(x.shape[-2], device=x.device), "seq -> 1 seq") |
| q_heads = self.rope(q_heads, token_positions) |
| k_heads = self.rope(k_heads, token_positions) |
|
|
| mha_heads = F.scaled_dot_product_attention( |
| q_heads, k_heads, v_heads, is_causal=True, scale=1.0 / d_k |
| ) |
| return self.output_proj(rearrange(mha_heads, "... heads seq d_v -> ... seq (heads d_v)")) |
|
|
|
|
| class Router(nn.Module): |
| """Top-k softmax router. Returns pre-softmax logits *and* probabilities. |
| |
| The two are returned side by side, and the caller labels which is which via |
| ``ROUTER_LOGITS_ARE_PRESOFTMAX``. There is no jitter noise and no temperature; |
| routing is deterministic given the input (matching the original). |
| """ |
|
|
| def __init__(self, d_model: int, num_experts: int, num_active: int, width_ratio: float, |
| device=None, dtype=None): |
| super().__init__() |
| std_base = math.sqrt(2 / (BASE_D_MODEL + num_experts)) |
| self.gate = Linear(d_model, num_experts, width_ratio, std_base, device=device, dtype=dtype) |
| self.num_active = num_active |
|
|
| def forward(self, x: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]: |
| logits = self.gate(x) |
| probs = softmax(logits, dim=-1) |
| top_scores, top_experts = torch.topk(probs, k=self.num_active, dim=-1) |
| |
| top_scores = top_scores / torch.sum(top_scores, dim=-1, keepdim=True) |
| return logits, probs, top_scores, top_experts |
|
|
|
|
| class GroupedMoEPrenormBlock(nn.Module): |
| """Pre-norm block whose FFN is a grouped top-k MoE. |
| |
| Layout: x -> +attn(ln1(x)) -> +moe(ln2(.)). Aux losses are returned rather |
| than stashed on the module, so nothing has to be reset between loop steps. |
| """ |
|
|
| @staticmethod |
| def _init_expert_weights(num_experts, in_features, out_features, width_ratio, std_base, |
| device, dtype) -> nn.Parameter: |
| w = torch.empty(num_experts, in_features, out_features, device=device, dtype=dtype) |
| std_scaled = std_base / math.sqrt(width_ratio) |
| nn.init.trunc_normal_(w, mean=0.0, std=std_scaled, a=-3 * std_scaled, b=3 * std_scaled) |
| return nn.Parameter(w) |
|
|
| @torch.no_grad() |
| def reset_parameters(self) -> None: |
| """Re-init the grouped expert weights (see Linear.reset_parameters).""" |
| std = self._expert_init_std |
| for w in (self.experts_w1, self.experts_w2, self.experts_w3): |
| nn.init.trunc_normal_(w, mean=0.0, std=std, a=-3 * std, b=3 * std) |
|
|
| def __init__(self, d_model: int, num_heads: int, d_ff: int, num_experts: int, num_active: int, |
| max_seq_len: int, theta: float, width_ratio: float, device=None, dtype=None): |
| super().__init__() |
| self.ln1 = RMSNorm(d_model, device=device, dtype=dtype) |
| self.attn = MultiheadSelfAttention(d_model, num_heads, max_seq_len, theta, width_ratio, device, dtype) |
| self.ln2 = RMSNorm(d_model, device=device, dtype=dtype) |
| self.router = Router(d_model, num_experts, num_active, width_ratio, device=device, dtype=dtype) |
|
|
| self.num_experts = num_experts |
| self.num_active = num_active |
|
|
| |
| |
| d_ff_expert = d_ff // num_active |
| w_std_base = math.sqrt(2 / (BASE_D_MODEL + BASE_D_FF)) |
| self._expert_init_std = w_std_base / math.sqrt(width_ratio) |
| self.experts_w1 = self._init_expert_weights(num_experts, d_model, d_ff_expert, width_ratio, w_std_base, device, dtype) |
| self.experts_w2 = self._init_expert_weights(num_experts, d_ff_expert, d_model, width_ratio, w_std_base, device, dtype) |
| self.experts_w3 = self._init_expert_weights(num_experts, d_model, d_ff_expert, width_ratio, w_std_base, device, dtype) |
|
|
| def forward( |
| self, |
| x: Tensor, |
| token_positions: Optional[Tensor] = None, |
| *, |
| loop_step: Optional[int] = None, |
| layer_idx: Optional[int] = None, |
| block: Optional[str] = None, |
| unrolled_pos: Optional[int] = None, |
| trace_sink: Optional[TraceSink] = None, |
| ) -> tuple[Tensor, Tensor, Tensor]: |
| batch, seq, dim = x.shape |
| total_tokens = batch * seq |
|
|
| norm1_out = self.ln1(x) |
| attn_out = self.attn(norm1_out, token_positions) |
| assert x.shape == attn_out.shape |
| resid1_out = attn_out + x |
|
|
| norm2_out = self.ln2(resid1_out) |
| logits, probs, top_scores, top_experts = self.router(norm2_out) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| top_scores = top_scores.to(x.dtype) |
|
|
| |
| x_flat = rearrange(norm2_out, "b s d -> (b s) d") |
| flat_expert_ids = rearrange(top_experts, "b s k -> (b s k)") |
| flat_scores = rearrange(top_scores, "b s k -> (b s k)") |
| flat_positions = torch.arange(total_tokens, device=x.device) |
| flat_token_ids = repeat(flat_positions, "n -> (n k)", k=self.num_active) |
|
|
| sort_indices = flat_expert_ids.argsort(stable=True) |
| sorted_expert_ids = flat_expert_ids[sort_indices] |
| sorted_token_ids = flat_token_ids[sort_indices] |
| sorted_scores = flat_scores[sort_indices] |
| sorted_x = x_flat[sorted_token_ids] |
|
|
| counts = torch.bincount(sorted_expert_ids, minlength=self.num_experts) |
| offs = counts.cumsum(0).to(torch.int32) |
|
|
| h1 = grouped_mm(sorted_x, self.experts_w1, offs=offs) |
| h3 = grouped_mm(sorted_x, self.experts_w3, offs=offs) |
| gated = silu(h1) * h3 |
| expert_out = grouped_mm(gated, self.experts_w2, offs=offs) |
|
|
| expert_out = einsum(expert_out, sorted_scores, "n d, n -> n d") |
| output_flat = torch.zeros(total_tokens, dim, device=x.device, dtype=expert_out.dtype) |
| output_flat.index_add_(0, sorted_token_ids, expert_out) |
| experts_out = rearrange(output_flat, "(b s) d -> b s d", b=batch, s=seq) |
|
|
| |
| |
| |
| |
| |
| fi = counts.float() / (total_tokens * self.num_active) |
| pi = reduce(probs, "b s e -> e", "mean") |
| lb = self.num_experts * einsum(fi, pi, "e, e ->") |
|
|
| logsumexp = torch.logsumexp(logits.float(), dim=-1) |
| lz = reduce(logsumexp**2, "... -> ", "mean") |
|
|
| assert experts_out.shape == resid1_out.shape |
| final_out = resid1_out + experts_out |
|
|
| |
| |
| |
| |
| |
| |
| sinks = [s for s in (ACTIVE_TRACE_SINK.sink, trace_sink) if s is not None] |
| if sinks: |
| if loop_step is None or layer_idx is None or block is None or unrolled_pos is None: |
| raise ValueError( |
| "trace_sink was provided but loop_step/layer_idx/block/unrolled_pos " |
| "were not. Both axes must be explicit counters, never inferred." |
| ) |
| if block not in ("head", "loop", "tail"): |
| raise ValueError(f"block must be head/loop/tail, got {block!r}") |
| |
| |
| key = unrolled_pos |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| point = TracePoint( |
| block=block, |
| unrolled_pos=unrolled_pos, |
| |
| |
| |
| num_experts=int(logits.shape[-1]), |
| top_k=int(top_experts.shape[-1]), |
| loop_step=loop_step, |
| layer_idx=layer_idx, |
| router_logits=logits.detach(), |
| router_probs=probs.detach(), |
| topk_idx=top_experts.detach(), |
| topk_weights=top_scores.detach(), |
| residual=final_out.detach(), |
| ) |
| for sink in sinks: |
| sink.setdefault(key, point) |
|
|
| return final_out, lb, lz |
|
|
|
|
| class LoopedStack(nn.Module): |
| """The stack of L MoE blocks that gets called R times.""" |
|
|
| def __init__(self, context_length: int, d_model: int, num_layers_in_stack: int, num_heads: int, |
| d_ff: int, rope_theta: float, width_ratio: float, num_experts: int, |
| num_active: int, device=None, dtype=None): |
| super().__init__() |
| self.layers = nn.ModuleList( |
| [ |
| GroupedMoEPrenormBlock( |
| d_model, num_heads, d_ff, num_experts, num_active, |
| context_length, rope_theta, width_ratio, device, dtype, |
| ) |
| for _ in range(num_layers_in_stack) |
| ] |
| ) |
|
|
| def forward( |
| self, |
| x: Tensor, |
| *, |
| loop_step: int, |
| unrolled_pos_start: int, |
| trace_sink: Optional[TraceSink] = None, |
| ) -> tuple[Tensor, Tensor, Tensor]: |
| """`unrolled_pos_start` is the depth coordinate this call's first layer occupies. |
| |
| Passed in rather than recomputed from `loop_step`, so the caller owns the depth |
| axis in one place: the stack does not need to know how many layers ran before it. |
| """ |
| lb_total = x.new_zeros(()) |
| lz_total = x.new_zeros(()) |
| for layer_idx, layer in enumerate(self.layers): |
| x, lb, lz = layer( |
| x, loop_step=loop_step, layer_idx=layer_idx, block="loop", |
| unrolled_pos=unrolled_pos_start + layer_idx, trace_sink=trace_sink, |
| ) |
| lb_total = lb_total + lb |
| lz_total = lz_total + lz |
| return x, lb_total, lz_total |
|
|
|
|
| class LoopedMoETransformer(nn.Module): |
| """Looped MoE transformer: one shared stack applied ``num_stacks`` times. |
| |
| The loop is an explicit Python ``for``; ``loop_step`` is the loop variable and |
| is threaded all the way down to each block. Nothing downstream ever has to |
| recover it from tensor shapes. |
| """ |
|
|
| def __init__(self, vocab_size: int, context_length: int, d_model: int, num_layers_in_stack: int, |
| num_stacks: int, num_heads: int, d_ff: int, num_experts: int, num_active: int, |
| rope_theta: float, width_ratio: float, num_head_layers: int = 0, |
| num_tail_layers: int = 0, head_tail_num_experts: int = HEAD_TAIL_NUM_EXPERTS, |
| device=None, dtype=None): |
| super().__init__() |
| self.num_stacks = num_stacks |
| self.num_layers_in_stack = num_layers_in_stack |
| self.total_layers = num_stacks * num_layers_in_stack |
| self.num_head_layers = num_head_layers |
| self.num_tail_layers = num_tail_layers |
| |
| |
| |
| |
| |
| self.unrolled_depth = num_head_layers + self.total_layers + num_tail_layers |
|
|
| self.token_embeddings = Embedding(vocab_size, d_model, device=device, dtype=dtype) |
| |
| |
| |
| make_outer = lambda: GroupedMoEPrenormBlock( |
| d_model, num_heads, d_ff, head_tail_num_experts, num_active, |
| context_length, rope_theta, width_ratio, device, dtype, |
| ) |
| self.head_layers = nn.ModuleList([make_outer() for _ in range(num_head_layers)]) |
| self.tail_layers = nn.ModuleList([make_outer() for _ in range(num_tail_layers)]) |
| self.stack = LoopedStack( |
| context_length, d_model, num_layers_in_stack, num_heads, d_ff, rope_theta, |
| width_ratio, num_experts, num_active, device=device, dtype=dtype, |
| ) |
| self.ln_final = RMSNorm(d_model, device=device, dtype=dtype) |
| std_base_lm_head = math.sqrt(2 / (BASE_D_MODEL + vocab_size)) |
| self.lm_head = Linear(d_model, vocab_size, width_ratio, std_base_lm_head, device=device, dtype=dtype) |
|
|
| @classmethod |
| def from_config(cls, config: "LoopMoEConfig", *, device=None, dtype=None) -> "LoopedMoETransformer": |
| """THE way to build this model from a config. Both call sites use it. |
| |
| There were two: the HF wrapper and `pretrain/train_spec.py`, each with its own |
| hand-written keyword list. When head/tail layers were added, the training path's |
| list was not updated, so it silently built a model with no head or tail while its |
| config said otherwise -- it trained, the loss looked plausible, and every artifact |
| recorded the config's depth rather than the depth that ran. Nothing could raise, |
| because a shorter model is a perfectly valid model. |
| |
| A single entry point makes that class of drift impossible rather than merely |
| tested-for: a field added to the config is read here once, and both paths get it. |
| """ |
| return cls( |
| vocab_size=config.vocab_size, |
| context_length=config.context_length, |
| d_model=config.d_model, |
| num_layers_in_stack=config.num_layers_in_stack, |
| num_stacks=config.num_stacks, |
| num_heads=config.num_heads, |
| d_ff=config.d_ff, |
| num_experts=config.num_experts, |
| num_active=config.num_active, |
| rope_theta=config.rope_theta, |
| width_ratio=config.width_ratio, |
| num_head_layers=config.num_head_layers, |
| num_tail_layers=config.num_tail_layers, |
| device=device, |
| dtype=dtype, |
| ) |
|
|
| def forward( |
| self, |
| x: Tensor, |
| *, |
| trace_sink: Optional[TraceSink] = None, |
| ) -> tuple[Tensor, Tensor, Tensor]: |
| lb_total = None |
| lz_total = None |
|
|
| x = self.token_embeddings(x) |
|
|
| def run_outer(layers, block: str, pos: int, x, lb_total, lz_total): |
| for i, layer in enumerate(layers): |
| x, lb, lz = layer( |
| x, loop_step=0, layer_idx=0, block=block, unrolled_pos=pos + i, |
| trace_sink=trace_sink, |
| ) |
| lb_total = lb if lb_total is None else lb_total + lb |
| lz_total = lz if lz_total is None else lz_total + lz |
| return x, lb_total, lz_total |
|
|
| |
| |
| |
| x, lb_total, lz_total = run_outer(self.head_layers, "head", 0, x, lb_total, lz_total) |
| pos = self.num_head_layers |
| for loop_step in range(self.num_stacks): |
| x, lb, lz = self.stack( |
| x, loop_step=loop_step, unrolled_pos_start=pos, trace_sink=trace_sink, |
| ) |
| pos += self.num_layers_in_stack |
| lb_total = lb if lb_total is None else lb_total + lb |
| lz_total = lz if lz_total is None else lz_total + lz |
| x, lb_total, lz_total = run_outer(self.tail_layers, "tail", pos, x, lb_total, lz_total) |
|
|
| x = self.lm_head(self.ln_final(x)) |
|
|
| |
| |
| |
| |
| return x, lb_total / self.unrolled_depth, lz_total / self.unrolled_depth |
|
|
|
|
| def _require(kwargs: dict[str, Any], name: str) -> Any: |
| """Fetch a required config field or raise. |
| |
| Project rule (HANDOVER §4.3 / §5.6): semantic parameters get no defaults. |
| A default is a silent-wrong-answer generator -- a mistyped or dropped key |
| becomes a plausible number instead of an error. |
| """ |
| if name not in kwargs or kwargs[name] is None: |
| raise ValueError( |
| f"LoopMoEConfig: required field {name!r} is missing. Semantic " |
| "parameters have no defaults in this project; state it explicitly." |
| ) |
| return kwargs.pop(name) |
|
|
|
|
| class LoopMoEConfig(PretrainedConfig): |
| """Config for the looped-MoE architecture. **Every field is required.** |
| |
| Compatible with ``save_pretrained``/``from_pretrained``: a config.json written |
| by this class round-trips, and one is rejected loudly if a field is absent. |
| """ |
|
|
| model_type = "loop-moe" |
|
|
| |
| |
| |
| |
| has_no_defaults_at_init = True |
|
|
| def __init__(self, **kwargs: Any): |
| |
| |
| self.vocab_size = _require(kwargs, "vocab_size") |
| self.context_length = _require(kwargs, "context_length") |
| self.d_model = _require(kwargs, "d_model") |
| self.num_heads = _require(kwargs, "num_heads") |
| self.d_ff = _require(kwargs, "d_ff") |
| self.rope_theta = _require(kwargs, "rope_theta") |
| self.width_ratio = _require(kwargs, "width_ratio") |
| self.num_layers_in_stack = _require(kwargs, "num_layers_in_stack") |
| self.num_stacks = _require(kwargs, "num_stacks") |
| self.num_experts = _require(kwargs, "num_experts") |
| self.num_active = _require(kwargs, "num_active") |
| self.lb_loss_factor = _require(kwargs, "lb_loss_factor") |
| |
| |
| |
| |
| |
| |
| self.num_head_layers = int(kwargs.pop("num_head_layers", 0)) |
| self.num_tail_layers = int(kwargs.pop("num_tail_layers", 0)) |
| self.lz_loss_factor = _require(kwargs, "lz_loss_factor") |
|
|
| |
| |
| |
| |
| |
| |
| |
| kwargs.pop("tie_word_embeddings", None) |
| self.tie_word_embeddings = False |
|
|
| self._validate() |
|
|
| |
| |
| self.num_layers = self.num_stacks * self.num_layers_in_stack |
| self.unrolled_depth = self.num_head_layers + self.num_layers + self.num_tail_layers |
|
|
| |
| |
| |
| |
| |
| |
| |
| kwargs.pop("max_length", None) |
|
|
| super().__init__(**kwargs) |
|
|
| def _validate(self) -> None: |
| """Reject out-of-domain values loudly rather than failing deep in a kernel.""" |
| positive = ( |
| "vocab_size", "context_length", "d_model", "num_heads", "d_ff", |
| "num_layers_in_stack", "num_stacks", "num_experts", "num_active", |
| ) |
| for name in positive: |
| value = getattr(self, name) |
| if not isinstance(value, int) or value < 1: |
| raise ValueError(f"LoopMoEConfig.{name} must be a positive int, got {value!r}") |
| if self.d_model % self.num_heads != 0: |
| raise ValueError( |
| f"d_model ({self.d_model}) must be divisible by num_heads ({self.num_heads})" |
| ) |
| if self.num_active > self.num_experts: |
| raise ValueError( |
| f"num_active ({self.num_active}) cannot exceed num_experts ({self.num_experts})" |
| ) |
| if self.d_ff % self.num_active != 0: |
| raise ValueError( |
| f"d_ff ({self.d_ff}) must be divisible by num_active ({self.num_active}); " |
| "expert width is d_ff // num_active and truncation would silently " |
| "change the parameter count." |
| ) |
| for name in ("num_head_layers", "num_tail_layers"): |
| value = getattr(self, name) |
| if not isinstance(value, int) or value < 0: |
| raise ValueError(f"LoopMoEConfig.{name} must be a non-negative int, got {value!r}") |
| if (self.d_model // self.num_heads) % 2 != 0: |
| raise ValueError( |
| f"head dim ({self.d_model // self.num_heads}) must be even for RoPE" |
| ) |
|
|
| @property |
| def trace_meta(self) -> TraceMeta: |
| """Shape/provenance block handed to the metrics collector.""" |
| return TraceMeta( |
| num_stacks=self.num_stacks, |
| num_layers_in_stack=self.num_layers_in_stack, |
| num_experts=self.num_experts, |
| num_active=self.num_active, |
| ) |
|
|
|
|
| class LoopMoEForCausalLM(PreTrainedModel, GenerationMixin): |
| """HF-compatible causal LM wrapper. |
| |
| Kept HF-shaped on purpose: HANDOVER §4.7 makes "the diagnostics pipeline |
| ingests our checkpoints unchanged" an acceptance criterion, and that pipeline |
| loads models through ``from_pretrained``. |
| """ |
|
|
| config_class = LoopMoEConfig |
|
|
| def __init__(self, config: LoopMoEConfig): |
| super().__init__(config) |
| self.model = LoopedMoETransformer.from_config(config) |
| self.post_init() |
|
|
| def get_input_embeddings(self): |
| return self.model.token_embeddings |
|
|
| def set_input_embeddings(self, value): |
| self.model.token_embeddings = value |
|
|
| def forward( |
| self, |
| input_ids: torch.LongTensor, |
| attention_mask: Optional[Tensor] = None, |
| labels: Optional[torch.LongTensor] = None, |
| trace_sink: Optional[TraceSink] = None, |
| **kwargs: Any, |
| ) -> CausalLMOutputWithPast: |
| """Forward pass. |
| |
| Returns a ``CausalLMOutputWithPast`` whose ``loss`` is the *total* loss |
| (CE + weighted aux). The unweighted components are attached as |
| ``task_loss`` / ``lb_loss`` / ``z_loss`` so the training loop can log the |
| breakdown without recomputing anything. |
| |
| **Label contract (documented here 2026-08-21, `ABCI_ERR_20260821_0405_ |
| gate1_label_shift_root_cause.md`)**: ``labels`` must already be |
| next-token-shifted by the caller -- ``labels[..., t] == input_ids[..., t+1]``, |
| with the last position set to ``-100`` (no target exists after it). This |
| method does **not** shift internally; it passes ``labels`` to |
| ``F.cross_entropy`` exactly as given. Before this date the only written |
| record of this contract was a comment in |
| ``src/model/loop_trace.py`` ("the dataloader supplies pre-shifted |
| labels during training, so the shift is explicit here") -- not here, at |
| the definition itself. That gap let four separate call sites |
| (the pretraining dataloader path aside, which was correct) independently |
| get this wrong the same way, rather than it being four unrelated |
| mistakes. Any caller not shifting first -- e.g. ``model(input_ids=ids, |
| labels=ids)`` -- silently trains/evaluates on the trivial |
| copy-the-current-token target instead of next-token prediction. |
| """ |
| logits, lb, lz = self.model(input_ids, trace_sink=trace_sink) |
|
|
| loss = task_loss = None |
| if labels is not None: |
| task_loss = F.cross_entropy(logits.view(-1, logits.size(-1)), labels.view(-1)) |
| loss = ( |
| task_loss |
| + self.config.lb_loss_factor * lb |
| + self.config.lz_loss_factor * lz |
| ) |
|
|
| out = CausalLMOutputWithPast(loss=loss, logits=logits) |
| |
| |
| out.task_loss = task_loss |
| out.lb_loss = lb |
| out.z_loss = lz |
| return out |
|
|
| def prepare_inputs_for_generation(self, input_ids, **kwargs): |
| return {"input_ids": input_ids} |
|
|