"""GemmaHyperExpert -- the per-token GENERATED low-rank student that replaces a Gemma-4 decoder layer's whole feed-forward/MoE BLOCK. Trained LAYER-LOCALLY by feature distillation against the teacher's cached block I/O (X = block input = pre_feedforward_layernorm input ; Y = block output = X+D), with NO access to the teacher's weights at train time -- only the cached (X, Y) activations. This is the Gemma adaptation of the StarCoder testbed's HyperExpert (../hyper_expert.py), with two deliberate changes: 1. RESIDUAL parameterization. The teacher block is a residual: Y = X + D. So the student models the DELTA on top of the carried residual: Yhat = X + base(X) + V @ gelu(U @ X) and is trained to minimize relMSE(Yhat, Y). Modeling D (not Y outright) is the easy/stable target and gives an exact identity warm start. 2. FROM-SCRATCH init (no src_mlp). We have no teacher MLP weights to subset, so base is a learned width-b FFN. We ZERO the two output paths (b_proj and g_v) so at init base(X)=0 and the generated correction=0 => Yhat = X exactly (the block is the identity residual). Init relMSE is then ||D||^2/||Y||^2, a finite, meaningful starting point that training drives down. enc, g_u and b_fc get small normal init; b_proj and g_v start at zero and pick up gradient immediately (b_proj from grad*b_fc_act, g_v from grad*h), recovering in a few steps -- the validated warm-start trick from the testbed. Architecture (hidden d=2816 for Gemma's text decoder), the validated winning shape "large hypernetwork, small generated rank": encoder : z = gelu(Enc(x)), Enc: Linear(d -> c) generator : U = (G_U z).view(r, d), G_U: Linear(c -> r*d, no bias) V = (G_V z).view(d, r), G_V: Linear(c -> d*r, no bias) base : base(x) = b_proj(gelu(b_fc(x))), widths d->b->d output : Yhat = x + base(x) + V @ gelu(U @ x) Per-layer params ~ d*c (enc) + 2*c*r*d (generators) + 2*d*b (base). With d=2816, c=9728, r=4, b=512 this is ~250M/layer => ~7.5B over 30 layers (teacher/3.5). This is the PER-TOKEN core (chunk=1 in the testbed). The cached activations are stored flattened ([n_tokens, d], sequence/boundary structure not retained), so the chunked / sentence-boundary DECODE-SPEED variants are an assembly-time concern and are intentionally omitted here -- per-token distillation is the capability being trained. gelu uses the tanh approximation to match the testbed exactly. """ import torch, torch.nn as nn, torch.nn.functional as F def expert_params(d, c, r, b): """Exact parameter count of one GemmaHyperExpert (for sizing to a target B).""" enc = d * c + c g_u = c * (r * d) g_v = c * (d * r) b_fc = d * b + b b_proj = b * d + d return enc + g_u + g_v + b_fc + b_proj class GemmaHyperExpert(nn.Module): def __init__(self, d, c, r, b, dtype=torch.bfloat16): super().__init__() self.d, self.c, self.r, self.b = d, c, r, b self.dtype = dtype self.enc = nn.Linear(d, c) # x -> z self.g_u = nn.Linear(c, r * d, bias=False) # z -> U (r,d) self.g_v = nn.Linear(c, d * r, bias=False) # z -> V (d,r) self.b_fc = nn.Linear(d, b) # base up self.b_proj = nn.Linear(b, d) # base down nn.init.normal_(self.enc.weight, std=0.02); nn.init.zeros_(self.enc.bias) nn.init.normal_(self.g_u.weight, std=0.02) nn.init.zeros_(self.g_v.weight) # generated correction = 0 at init nn.init.normal_(self.b_fc.weight, std=0.02); nn.init.zeros_(self.b_fc.bias) nn.init.zeros_(self.b_proj.weight); nn.init.zeros_(self.b_proj.bias) # base = 0 at init self.to(dtype) def base(self, x): return self.b_proj(F.gelu(self.b_fc(x), approximate="tanh")) def forward(self, x): """x: [..., d] block input X. Returns Yhat = X + base(X) + lowrank(X).""" shape = x.shape x2 = x.reshape(-1, self.d).to(self.dtype) # [N, d] N = x2.shape[0] z = F.gelu(self.enc(x2), approximate="tanh") # [N, c] U = self.g_u(z).view(N, self.r, self.d) # [N, r, d] V = self.g_v(z).view(N, self.d, self.r) # [N, d, r] h = F.gelu(torch.bmm(U, x2.unsqueeze(-1)).squeeze(-1), approximate="tanh") # [N, r] res = torch.bmm(V, h.unsqueeze(-1)).squeeze(-1) # [N, d] y = x2 + self.base(x2) + res # residual block output return y.view(shape).to(x.dtype) def footprint(self): return sum(p.numel() for p in self.parameters()) if __name__ == "__main__": # quick sizing table + forward/shape/warm-start sanity on CPU d = 2816 for (c, r, b) in [(512, 4, 512), (9728, 4, 512), (9856, 4, 640), (9984, 4, 512)]: p = expert_params(d, c, r, b) print(f"c={c} r={r} b={b}: {p/1e6:.1f}M/layer -> {p*30/1e9:.2f}B over 30 layers") torch.manual_seed(0) e = GemmaHyperExpert(d, 512, 4, 512, dtype=torch.float32) x = torch.randn(3, 7, d) y = e(x) assert y.shape == x.shape # warm start: Yhat == X exactly at init (base=0, g_v=0) print("warm-start max|Yhat-X| =", (y - x).abs().max().item(), "(should be ~0)") print("footprint check:", e.footprint(), "==", expert_params(d, 512, 4, 512))