HyperPEER / testbed /hyper_expert.py
MikeyBeez's picture
Add HyperPEER pipeline, testbed code, results, docs, Gradio landing
e41a3a4 verified
Raw
History Blame Contribute Delete
19.2 kB
"""HyperExpert -- a per-token GENERATED low-rank expert, drop-in for `Bank`.
Same forward interface as Bank: takes the MLP input hidden state x and returns
the MLP output y of the same shape. Instead of a fixed E-column bank, a small
hypernetwork generates a per-token rank-r FFN from the token's own hidden state,
on top of a shared per-layer low-width base FFN.
For hidden dim d:
encoder : z = gelu(Enc(x)), Enc: Linear(d -> c), z in R^c
generator: U = (G_U z).reshape(r, d), G_U in R^{(r*d) x c}
V = (G_V z).reshape(d, r), G_V in R^{(d*r) x c}
base : a stored per-layer width-b FFN, base(x) = Wb2 gelu(Wb1 x + bb1) + bb2
output : y = base(x) + V @ gelu(U @ x)
Per-layer stored footprint ~ 2*d*r*c (generators) + 2*d*b (base) + d*c (encoder).
CHUNKED generation (`chunk=N`, the DECODE-SPEED lever). The generator is the
expensive per-token cost; with N>1 we fire it ONCE per N-token chunk and reuse
the generated (U,V) across the chunk, amortizing the generator ~N-fold. The
shared base FFN STILL RUNS PER TOKEN, so per-token processing is preserved --
only the generated low-rank correction V@gelu(U@x) is held constant within a
chunk. chunk=1 reduces exactly to the per-token path above (sanity contract).
CHUNK MODES (`chunk_mode`). "fixed" (default) = the original arange//N chunking.
"sentence" = boundary-aware chunking: a chunk never crosses a sentence/line
boundary ( . ! ? ; newline) and holds at most `chunk_cap` tokens; a long segment
is greedily split into <=cap sub-chunks (still coherent). Per-token chunk ids are
computed tokenizer-side by chunk_ids_from_tokens() and handed in via set_chunk_ids()
(prefill/train) or via set_stream_boundary() per step (streaming decode). Same rule
both regimes: one generated (U,V) per chunk, base FFN per token; differentiable.
Two forward regimes share one rule (one (U,V) per chunk, base per token):
* PREFILL / TRAIN (`_stream=False`, x is [B,S,d]): vectorized. Each chunk's
latent z is the FULL-CHUNK MEAN of that chunk's inputs. NOTE: full-chunk
mean peeks at within-chunk future tokens, so this is the *prefill-style*
pooling -- documented and fine for the matched-budget ppl comparison (the
held-out eval is a full forward). A strictly-causal variant is the prefix/
running mean; at a chunk boundary in a stream that prefix is just the first
token, which is exactly what the decode path below uses.
* DECODE / STREAM (`_stream=True`, called one token at a time by HF.generate):
regenerate (U,V) only when crossing a chunk boundary (global pos % N == 0),
else reuse the cached (U,V). This yields exactly ceil(tokens/N) generator
calls -> the real amortization measured by bench_chunk.py. Boundary pooling
is the first token of the chunk (the causal prefix available at that step).
The tiny prefill(mean) vs decode(first-token) pooling difference only shifts
generated-text content; the reported numbers are ppl (prefill) and tok/s
(decode), so neither is corrupted. Call reset_stream() before each stream.
Warm start: G_V is zero-initialised so the generated correction starts at exactly
0 -> at step 0 the expert IS a width-b bank (y == base(x), equal to Bank modulo
compute dtype; verified relRMSE 2.4e-3 in diag_warmstart.py). It then learns the
per-token low-rank correction on top of that bank. This is the real warm start:
the hypernet begins EXACTLY where the bank begins.
Base subset (init): EMPIRICALLY, init="random" (default) is the right choice.
The diag_warmstart.py sweep (30-layer simultaneous replacement, fixed held-out)
measured INIT held-out ppl:
bank_random_fp32 11874 bank_topnorm_fp32 42502
hyper_random_bf16 12490 hyper_topnorm_bf16 42493
i.e. the baseline Bank itself uses RANDOM init (train_compress2.py default) and
starts at ~11.9k -- NOT "a few hundred". Top-norm is ~3.5x WORSE here: across 30
stacked layers the dropped-neuron errors of a top-norm subset compound coherently.
So init="random" both matches the bank baseline and gives the lower INIT. (g_v=0
makes the hyper start ~equal to whichever bank you pick; random is the good bank.)
init="topnorm" is kept only for the bisection that established the above.
"""
import torch, torch.nn as nn, torch.nn.functional as F
# ---- SENTENCE/LINE-boundary chunking helpers --------------------------------
# A "chunk" never crosses a sentence/line boundary AND is at most `cap` tokens.
# Boundaries on the CODE testbed = newlines + sentence-enders ( . ! ? ; \n ).
# A long segment (run of tokens up to and including its boundary ender) is split
# into sub-chunks of <= cap tokens (greedy: start a new chunk at a boundary token
# OR when the open chunk reaches `cap`). These two functions are tokenizer-side
# (they map token ids -> per-token chunk ids); the HyperExpert itself consumes the
# resulting integer chunk-id tensor and never needs the tokenizer.
BOUNDARY_CHARS = ".!?;\n"
def boundary_token_mask(tok, chars=BOUNDARY_CHARS):
"""Bool tensor [vocab]: True if a token's decoded text contains a boundary char.
Such a token ENDS its segment (the next token must begin a new chunk)."""
size = len(tok)
mask = torch.zeros(size, dtype=torch.bool)
cset = set(chars)
for i in range(size):
txt = tok.decode([i])
if any(ch in cset for ch in txt):
mask[i] = True
return mask
def chunk_ids_from_tokens(ids, bmask, cap):
"""Per-token chunk ids [B,S] (long) under the boundary+cap rule.
Scan each row: token s starts a new chunk iff the PREVIOUS token was a
boundary (segment ended) OR the open chunk already holds `cap` tokens. So a
chunk is contiguous, <= cap tokens, and a boundary token is always its LAST
token -> chunks never cross a boundary. cids are constants (no grad)."""
B, S = ids.shape
bnd = bmask.to("cpu")[ids.to("cpu")].tolist() # [B][S] python bools
out = [[0] * S for _ in range(B)]
for b in range(B):
row, orow = bnd[b], out[b]
cur = 0; length = 0
for s in range(S):
if s == 0:
cur = 0; length = 1
elif row[s - 1] or length >= cap:
cur += 1; length = 1
else:
length += 1
orow[s] = cur
return torch.tensor(out, dtype=torch.long, device=ids.device)
class HyperExpert(nn.Module):
def __init__(self, src_mlp, c, r, b, dtype=torch.bfloat16, init="random", chunk=1,
chunk_mode="fixed", chunk_cap=20):
super().__init__()
d = src_mlp.c_fc.weight.shape[1] # in/out dim (3072)
self.d, self.c, self.r, self.b = d, c, r, b
self.chunk = max(1, int(chunk)) # tokens per generated expert (1=per-token)
# chunk_mode: "fixed" = arange//chunk (original); "sentence" = boundary+cap
# chunking driven by per-token chunk ids fed in from the tokenizer side.
self.chunk_mode = chunk_mode
self.chunk_cap = max(1, int(chunk_cap)) # max tokens/chunk in sentence mode (N)
self.dtype = dtype # param/compute dtype (bf16 keeps
# encoder: x -> z in R^c
self.enc = nn.Linear(d, c)
# generators: z -> flattened U (r*d) and V (d*r)
self.g_u = nn.Linear(c, r * d, bias=False)
self.g_v = nn.Linear(c, d * r, bias=False)
# shared per-layer width-b base FFN, warm-started from the SAME top-norm
# b-column subset of the original MLP that Bank(init="topnorm") uses, so
# base(x) == Bank(x) at init. (init="random" reproduces the old cold start.)
if init == "topnorm":
idx = src_mlp.c_proj.weight.data.float().norm(dim=0).topk(b).indices
else:
idx = torch.randperm(src_mlp.c_fc.weight.shape[0])[:b]
self.b_fc = nn.Linear(d, b)
self.b_proj = nn.Linear(b, d)
with torch.no_grad():
self.b_fc.weight.copy_(src_mlp.c_fc.weight.data[idx].float())
self.b_fc.bias.copy_(src_mlp.c_fc.bias.data[idx].float()
if src_mlp.c_fc.bias is not None else torch.zeros(b))
self.b_proj.weight.copy_(src_mlp.c_proj.weight.data[:, idx].float())
self.b_proj.bias.copy_(src_mlp.c_proj.bias.data.float()
if src_mlp.c_proj.bias is not None else torch.zeros(d))
# small encoder/G_U init; zero G_V so initial correction is 0 (y == base)
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)
# Store params in `dtype` (bf16 by default): the hypernetwork is ~800M
# params/expert; fp32 params+grads for all 30 experts would peak near the
# 16 GB GPU limit and OOM at the first backward. bf16 halves that footprint.
self.to(dtype)
self.last_in = None; self.last_out = None
# sentence-mode chunk ids for the CURRENT vectorized/prefill forward [B,S],
# set from outside (train/eval/prompt) via set_chunk_ids(); never grad.
self._chunk_ids = None
# streaming (decode) state -- see reset_stream(); only used when _stream=True
self._stream = False
self.reset_stream()
# sentence-mode: per-token chunk ids for the next vectorized forward (prefill).
def set_chunk_ids(self, cids):
self._chunk_ids = cids; return self
# sentence-mode streaming: boundary flag(s) for the incoming token(s) [B].
def set_stream_boundary(self, bnd):
self._stream_bnd = bnd; return self
# ---- streaming (autoregressive decode) controls --------------------------
def set_streaming(self, flag):
self._stream = bool(flag); return self
def reset_stream(self):
"""Reset the per-generation streaming state. Call before each decode."""
self._pos = 0 # global token position in the current stream
self._Uc = None # cached generated U for the active chunk
self._Vc = None # cached generated V for the active chunk
self._calls = 0 # generator firings so far (for amortization stats)
# sentence-mode streaming extras (batch=1 decode, as bench uses):
self._open_len = 0 # tokens accumulated in the current open chunk
self._pending = False # last token was a boundary -> next token opens a chunk
self._stream_bnd = None # boundary flag(s) for the incoming token [B]
self._prefill_calls = 0 # generator firings during the prompt prefill
def base(self, x):
return self.b_proj(F.gelu(self.b_fc(x), approximate="tanh"))
# ---- chunked correction paths -------------------------------------------
def _chunk_prefill(self, xs):
"""Vectorized full-chunk-mean correction. xs: [B,S,d] -> res: [B,S,d]."""
B, S, d = xs.shape; N = self.chunk
n_chunks = (S + N - 1) // N
cidx = torch.arange(S, device=xs.device) // N # [S] chunk id per token
pooled = xs.new_zeros(B, n_chunks, d)
pooled.index_add_(1, cidx, xs) # sum inputs per chunk
counts = torch.bincount(cidx, minlength=n_chunks).to(xs.dtype).clamp_min(1)
pooled = pooled / counts.view(1, n_chunks, 1) # full-chunk mean -> [B,nc,d]
z = F.gelu(self.enc(pooled), approximate="tanh") # [B,nc,c] ONE z per chunk
U = self.g_u(z).view(B, n_chunks, self.r, d) # [B,nc,r,d] ONE expert per chunk
V = self.g_v(z).view(B, n_chunks, d, self.r) # [B,nc,d,r]
Ut = U.index_select(1, cidx) # [B,S,r,d] broadcast to tokens
Vt = V.index_select(1, cidx) # [B,S,d,r]
h = F.gelu(torch.matmul(Ut, xs.unsqueeze(-1)).squeeze(-1), approximate="tanh") # [B,S,r]
return torch.matmul(Vt, h.unsqueeze(-1)).squeeze(-1) # [B,S,d]
def _gen(self, x1):
"""Fire the generator once from a single pooled vector x1 [B,d]; cache (U,V)."""
B = x1.shape[0]
z = F.gelu(self.enc(x1), approximate="tanh") # [B,c]
self._Uc = self.g_u(z).view(B, self.r, self.d) # [B,r,d]
self._Vc = self.g_v(z).view(B, self.d, self.r) # [B,d,r]
self._calls += 1
def _chunk_stream(self, xs):
"""Stateful decode correction. xs: [B,S,d] -> res: [B,S,d].
Regenerates (U,V) only at chunk boundaries; reuses the cache otherwise."""
B, S, d = xs.shape; N = self.chunk
if S > 1:
# one-shot prompt prefill: same full-chunk-mean correction, counted as
# ceil(S/N) generator firings; seed the cache from the trailing token so
# subsequent S==1 decode steps continue cleanly.
res = self._chunk_prefill(xs)
self._calls += (S + N - 1) // N
self._gen(xs[:, -1, :]); self._calls -= 1 # cache only, not an extra count
self._pos += S
return res
x1 = xs[:, 0, :] # [B,d] single decode token
if (self._pos % N == 0) or (self._Uc is None):
self._gen(x1) # boundary -> fire generator
h = F.gelu(torch.bmm(self._Uc, x1.unsqueeze(-1)).squeeze(-1), approximate="tanh") # [B,r]
res = torch.bmm(self._Vc, h.unsqueeze(-1)).squeeze(-1) # [B,d]
self._pos += 1
return res.unsqueeze(1) # [B,1,d]
# ---- SENTENCE-mode chunked correction paths -----------------------------
def _chunk_prefill_ids(self, xs, cids):
"""Vectorized chunk-mean correction with PER-ROW chunk ids (sentence mode).
xs: [B,S,d], cids: [B,S] long -> res: [B,S,d]. One generated (U,V) per
chunk (chunk mean of its inputs), gathered back to every token. Differentiable
w.r.t. xs (scatter_add/gather/matmul); cids are constants."""
B, S, d = xs.shape
NC = int(cids.max().item()) + 1
idx = cids.unsqueeze(-1).expand(B, S, d) # [B,S,d]
pooled = xs.new_zeros(B, NC, d).scatter_add_(1, idx, xs) # sum per chunk
counts = xs.new_zeros(B, NC).scatter_add_(1, cids, xs.new_ones(B, S))
pooled = pooled / counts.clamp_min(1).unsqueeze(-1) # chunk mean -> [B,NC,d]
z = F.gelu(self.enc(pooled), approximate="tanh") # [B,NC,c] ONE z per chunk
U = self.g_u(z).view(B, NC, self.r, d) # [B,NC,r,d]
V = self.g_v(z).view(B, NC, d, self.r) # [B,NC,d,r]
gu = cids.view(B, S, 1, 1).expand(B, S, self.r, d)
gv = cids.view(B, S, 1, 1).expand(B, S, d, self.r)
Ut = U.gather(1, gu) # [B,S,r,d] per-token expert
Vt = V.gather(1, gv) # [B,S,d,r]
h = F.gelu(torch.matmul(Ut, xs.unsqueeze(-1)).squeeze(-1), approximate="tanh") # [B,S,r]
return torch.matmul(Vt, h.unsqueeze(-1)).squeeze(-1) # [B,S,d]
def _chunk_stream_sentence(self, xs):
"""Stateful decode correction for sentence mode (batch=1). Regenerates (U,V)
when the previous token was a boundary OR the open chunk hit `chunk_cap`."""
B, S, d = xs.shape; cap = self.chunk_cap
if S > 1:
# prompt prefill: full chunk-mean correction over the prompt's own chunks.
cids = self._chunk_ids
res = self._chunk_prefill_ids(xs, cids)
nc = int(cids.max().item()) + 1
self._calls += nc; self._prefill_calls = nc
last = int(cids[0, -1].item())
self._open_len = int((cids[0] == last).sum().item()) # size of trailing chunk
self._gen(xs[:, -1, :]); self._calls -= 1 # seed cache, not a count
bnd = self._stream_bnd
self._pending = bool(bnd.any().item()) if bnd is not None else False
self._pos += S
return res
x1 = xs[:, 0, :] # [B,d] single decode token
start_new = (self._Uc is None) or self._pending or (self._open_len >= cap)
if start_new:
self._gen(x1); self._open_len = 1 # boundary/cap -> fire generator
else:
self._open_len += 1
bnd = self._stream_bnd
self._pending = bool(bnd.any().item()) if bnd is not None else False
h = F.gelu(torch.bmm(self._Uc, x1.unsqueeze(-1)).squeeze(-1), approximate="tanh") # [B,r]
res = torch.bmm(self._Vc, h.unsqueeze(-1)).squeeze(-1) # [B,d]
self._pos += 1
return res.unsqueeze(1) # [B,1,d]
def forward(self, x):
self.last_in = x
shape = x.shape
d = self.d
if self.chunk_mode == "sentence":
# ---- SENTENCE/LINE-boundary + capped path (one (U,V) per chunk) ----
if x.ndim == 3:
B, S = shape[0], shape[1]
else:
B, S = 1, x.reshape(-1, d).shape[0]
xs = x.reshape(B, S, d).to(self.dtype)
if self._stream:
res = self._chunk_stream_sentence(xs)
else:
cids = self._chunk_ids
assert cids is not None and tuple(cids.shape) == (B, S), \
"sentence mode prefill needs set_chunk_ids([B,S]) before forward"
res = self._chunk_prefill_ids(xs, cids)
y = self.base(xs) + res # base PER TOKEN + chunk corr.
out = y.view(shape).to(x.dtype)
self.last_out = out
return out
if self.chunk <= 1:
# ---- per-token path (original; --chunk 1 reproduces this exactly) ----
x2 = x.reshape(-1, 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, d) # [N, r, d]
V = self.g_v(z).view(N, 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 = self.base(x2) + res # [N, d]
if self._stream: self._calls += N # per-token: one firing/token
out = y.view(shape).to(x.dtype)
self.last_out = out
return out
# ---- chunked path (one generated expert per N tokens; base per token) ----
if x.ndim == 3:
B, S = shape[0], shape[1]
else:
B, S = 1, x.reshape(-1, d).shape[0]
xs = x.reshape(B, S, d).to(self.dtype) # [B, S, d]
res = self._chunk_stream(xs) if self._stream else self._chunk_prefill(xs)
y = self.base(xs) + res # base PER TOKEN + chunked corr.
out = y.view(shape).to(x.dtype)
self.last_out = out
return out
def footprint(self):
return sum(p.numel() for p in self.parameters())