"""M9 — Multi-Token Prediction (MTP). GOAL: more tokens-to-loss. From ONE trunk forward we predict k future tokens via k output heads, so each forward yields more learning signal. This does NOT reduce per-layer FLOPs — the trunk is unchanged; we only bolt extra (cheap) output heads onto the final hidden state. External basis: Meta 2024 "Better & Faster Large Language Models via Multi-token Prediction" (gains at no training-time overhead; up to ~3x inference via self-speculative decoding). Design: * head j (j = 0 .. n_pred-1) predicts the token at position t+1+j from the final trunk hidden state at position t. j=0 is the ORDINARY next-token head. * loss = mean over heads of cross-entropy at the correspondingly-shifted targets. Given the standard next-token targets `y` (y[i] = idx[i+1]), head j's target at position i is idx[i+1+j] = y[i+j]. Positions whose target falls past the end of the sequence are MASKED (ignore_index = -100), never wrapped. * eval reports the j=0 (primary) head's val-loss, which is exactly the usual next-token loss. Heads are kept cheap: the primary head is the model's existing tied head (weight == token embedding); the j>=1 auxiliary heads are small Linears (optionally tied to the same embedding). Pure PyTorch, CPU/CUDA. """ from __future__ import annotations import torch import torch.nn as nn import torch.nn.functional as F __all__ = ["MultiTokenHead", "shift_targets", "mtp_loss"] IGNORE_INDEX = -100 def shift_targets(targets: torch.Tensor, j: int, ignore_index: int = IGNORE_INDEX) -> torch.Tensor: """Targets for head j, given the standard next-token targets `targets` (shape (b, t), targets[:, i] == idx[:, i+1]). Head j predicts idx[i+1+j] from position i, i.e. targets[:, i+j]. We left-shift `targets` by j and pad the j positions that run off the end with `ignore_index` (masked, NOT wrapped). j == 0 returns `targets` unchanged. """ if j < 0: raise ValueError(f"head index j must be >= 0, got {j}") b, t = targets.shape if j == 0: return targets if j >= t: # every position's target is past the end -> all masked return torch.full_like(targets, ignore_index) shifted = targets[:, j:] # (b, t-j) valid targets pad = torch.full((b, j), ignore_index, dtype=targets.dtype, device=targets.device) return torch.cat([shifted, pad], dim=1) # (b, t), last j masked def mtp_loss(logits_list, targets: torch.Tensor, ignore_index: int = IGNORE_INDEX): """Mean over heads of the per-head cross-entropy at shifted, boundary-masked targets. logits_list[j] has shape (b, t, vocab). Returns (mean_loss, per_head_losses) where per_head_losses[0] is the primary next-token loss. With a single head this reduces EXACTLY to F.cross_entropy(logits, targets) (no position is masked when n_pred == 1, since head 0 uses targets verbatim). """ per_head = [] for j, logits in enumerate(logits_list): tgt = shift_targets(targets, j, ignore_index) loss_j = F.cross_entropy( logits.reshape(-1, logits.size(-1)), tgt.reshape(-1), ignore_index=ignore_index, ) per_head.append(loss_j) mean_loss = torch.stack(per_head).mean() return mean_loss, per_head class MultiTokenHead(nn.Module): """n_pred output heads predicting tokens t+1 .. t+n_pred from one final hidden state. head 0 is the primary next-token head; if `primary_head` is supplied (e.g. the GPT's existing tied head) it is reused so the n_pred==1 case is byte-for-byte the ordinary model. The j>=1 heads are cheap Linears; with `tie_embedding` they share `embedding.weight` and add no params. """ def __init__( self, d_model: int, vocab_size: int, n_pred: int = 1, primary_head: nn.Module | None = None, embedding: nn.Embedding | None = None, tie_embedding: bool = False, bias: bool = False, ) -> None: super().__init__() if n_pred < 1: raise ValueError(f"n_pred must be >= 1, got {n_pred}") self.n_pred = n_pred self.d_model = d_model self.vocab_size = vocab_size # primary (j=0) head if primary_head is not None: self.primary = primary_head else: self.primary = nn.Linear(d_model, vocab_size, bias=bias) if tie_embedding and embedding is not None: self.primary.weight = embedding.weight # auxiliary (j>=1) heads self.aux = nn.ModuleList() for _ in range(n_pred - 1): lin = nn.Linear(d_model, vocab_size, bias=bias) if tie_embedding and embedding is not None: lin.weight = embedding.weight self.aux.append(lin) def heads(self): return [self.primary, *self.aux] def forward(self, h: torch.Tensor): """h: (b, t, d_model). Returns a list of n_pred logits tensors, each (b, t, vocab).""" return [head(h) for head in self.heads()] def loss(self, h: torch.Tensor, targets: torch.Tensor, ignore_index: int = IGNORE_INDEX): """Returns (mean_loss, per_head_losses). per_head_losses[0] is the primary loss.""" return mtp_loss(self.forward(h), targets, ignore_index) class MTPGPT(nn.Module): """Thin wrapper that runs a GPT-like trunk once and applies a MultiTokenHead. The wrapped `trunk` must expose a `.trunk_hidden(idx) -> (b, t, d)` method returning the final (post-lnf) hidden state, OR a `.forward_features(idx)`. This keeps the trunk forward at full FLOPs (no reduction) and adds only the cheap heads. """ def __init__(self, trunk: nn.Module, d_model: int, vocab_size: int, n_pred: int = 1, primary_head: nn.Module | None = None, embedding: nn.Embedding | None = None, tie_embedding: bool = False) -> None: super().__init__() self.trunk = trunk self.mtp = MultiTokenHead(d_model, vocab_size, n_pred, primary_head, embedding, tie_embedding) def _features(self, idx: torch.Tensor) -> torch.Tensor: if hasattr(self.trunk, "trunk_hidden"): return self.trunk.trunk_hidden(idx) if hasattr(self.trunk, "forward_features"): return self.trunk.forward_features(idx) raise AttributeError("trunk must expose trunk_hidden() or forward_features()") def forward(self, idx: torch.Tensor, targets: torch.Tensor | None = None): h = self._features(idx) logits_list = self.mtp(h) loss = None per_head = None if targets is not None: loss, per_head = mtp_loss(logits_list, targets) # primary logits first for downstream next-token use return logits_list, loss, per_head