Download model.py from GeorgeCodesSometimes/sprout-instruct: direct link, hf CLI and curl.
- Browser
- Download file 16.8 kB
-
https://huggingface.co/GeorgeCodesSometimes/sprout-instruct/resolve/main/model.py
- Command line
-
hf download hf://GeorgeCodesSometimes/sprout-instruct/model.py
-
curl -L -o model.py https://huggingface.co/GeorgeCodesSometimes/sprout-instruct/resolve/main/model.py
16.8 kB
| """Llama-style decoder-only transformer, 29.4M params: RoPE, pre-norm RMSNorm, | |
| SwiGLU, no biases, tied embeddings. Running this file checks the pieces and | |
| then overfits one batch.""" | |
| import argparse | |
| import math | |
| import time | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| VOCAB_SIZE = 4096 | |
| N_LAYER = 8 | |
| N_HEAD = 8 | |
| DIM = 512 | |
| HEAD_DIM = DIM // N_HEAD | |
| CTX = 512 | |
| THETA = 10000.0 | |
| INIT_STD = 0.02 | |
| def hidden(dim): | |
| """4*dim cut by 2/3 for SwiGLU's third matrix, rounded up to 256.""" | |
| return 256 * math.ceil(8 * dim / 3 / 256) | |
| HIDDEN = hidden(DIM) # 1536 | |
| def rope_tables(head_dim, ctx, device=None): | |
| """cos/sin of the RoPE angles at every position, both (ctx, head_dim).""" | |
| # float64 because the angle at the last position is a few hundred radians, | |
| # where float32 spacing already swamps the relative positions we want. | |
| freqs = THETA ** (-torch.arange(0, head_dim, 2, device=device).double() / head_dim) | |
| angles = torch.outer(torch.arange(ctx, device=device).double(), freqs) | |
| # Duplicating rather than interleaving pairs channel i with i + head_dim/2, | |
| # so the rotation is one concat and two products. | |
| angles = torch.cat([angles, angles], dim=-1) | |
| return angles.cos().float(), angles.sin().float() | |
| def rope(x, cos, sin): | |
| """Rotate x, (B, heads, T, head_dim), by its position angles.""" | |
| half = x.shape[-1] // 2 | |
| rotated = torch.cat([-x[..., half:], x[..., :half]], dim=-1) | |
| return x * cos + rotated * sin | |
| class RMSNorm(nn.Module): | |
| def __init__(self, dim, eps=1e-5): | |
| super().__init__() | |
| self.eps = eps | |
| self.weight = nn.Parameter(torch.ones(dim)) | |
| def forward(self, x): | |
| # In fp32: a sum of 512 bf16 squares loses enough precision to shift | |
| # the scale of every activation. | |
| dtype = x.dtype | |
| x = x.float() | |
| x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) | |
| return self.weight * x.to(dtype) | |
| class Attention(nn.Module): | |
| def __init__(self, dim, n_head): | |
| super().__init__() | |
| self.n_head = n_head | |
| self.wq = nn.Linear(dim, dim, bias=False) | |
| self.wk = nn.Linear(dim, dim, bias=False) | |
| self.wv = nn.Linear(dim, dim, bias=False) | |
| self.wo = nn.Linear(dim, dim, bias=False) | |
| def forward(self, x, cos, sin, cache=None, use_cache=False): | |
| B, T, C = x.shape | |
| q = self.wq(x).view(B, T, self.n_head, -1).transpose(1, 2) | |
| k = self.wk(x).view(B, T, self.n_head, -1).transpose(1, 2) | |
| v = self.wv(x).view(B, T, self.n_head, -1).transpose(1, 2) | |
| q, k = rope(q, cos, sin), rope(k, cos, sin) # position enters via q, k only | |
| past = 0 if cache is None else cache[0].shape[2] | |
| if cache is not None: | |
| k = torch.cat([cache[0], k], dim=2) | |
| v = torch.cat([cache[1], v], dim=2) | |
| if past and T > 1: | |
| # Cached chunks need a causal mask offset by the prefix length. | |
| mask = torch.arange(k.shape[2], device=x.device)[None, :] <= ( | |
| past + torch.arange(T, device=x.device)[:, None]) | |
| y = F.scaled_dot_product_attention(q, k, v, attn_mask=mask) | |
| else: | |
| # A single new query can see the entire cache. is_causal=True | |
| # would align it with the FIRST key rather than the last one. | |
| y = F.scaled_dot_product_attention(q, k, v, is_causal=past == 0) | |
| return self.wo(y.transpose(1, 2).reshape(B, T, C)), (k, v) if use_cache else None | |
| class MLP(nn.Module): | |
| def __init__(self, dim): | |
| super().__init__() | |
| self.w1 = nn.Linear(dim, hidden(dim), bias=False) | |
| self.w2 = nn.Linear(hidden(dim), dim, bias=False) | |
| self.w3 = nn.Linear(dim, hidden(dim), bias=False) | |
| def forward(self, x): | |
| return self.w2(F.silu(self.w1(x)) * self.w3(x)) | |
| class Block(nn.Module): | |
| def __init__(self, dim, n_head): | |
| super().__init__() | |
| self.attn_norm = RMSNorm(dim) | |
| self.attn = Attention(dim, n_head) | |
| self.mlp_norm = RMSNorm(dim) | |
| self.mlp = MLP(dim) | |
| def forward(self, x, cos, sin, cache=None, use_cache=False): | |
| attention, cache = self.attn(self.attn_norm(x), cos, sin, cache, use_cache) | |
| x = x + attention | |
| return x + self.mlp(self.mlp_norm(x)), cache | |
| class Sprout(nn.Module): | |
| def __init__(self, n_layer=N_LAYER, dim=DIM, n_head=N_HEAD, ctx=CTX, vocab=VOCAB_SIZE, | |
| pos="rope"): | |
| super().__init__() | |
| assert pos in ("rope", "learned"), pos | |
| self.ctx = ctx | |
| self.tok_emb = nn.Embedding(vocab, dim) | |
| # GPT-2's learned absolute positions, the alternative to RoPE. | |
| self.pos_emb = nn.Embedding(ctx, dim) if pos == "learned" else None | |
| self.blocks = nn.ModuleList(Block(dim, n_head) for _ in range(n_layer)) | |
| self.norm = RMSNorm(dim) | |
| self.head = nn.Linear(dim, vocab, bias=False) | |
| self.head.weight = self.tok_emb.weight # tie before init: one tensor only | |
| self.apply(init_weights) | |
| # The projections writing into the residual stream start smaller, so | |
| # its variance grows like a sum of 2*n_layer contributions. | |
| for block in self.blocks: | |
| for w in (block.attn.wo.weight, block.mlp.w2.weight): | |
| nn.init.normal_(w, std=INIT_STD / math.sqrt(2 * n_layer)) | |
| cos, sin = rope_tables(dim // n_head, ctx) | |
| if self.pos_emb is not None: | |
| cos, sin = torch.ones_like(cos), torch.zeros_like(sin) # rotation by 0 | |
| self.register_buffer("cos", cos, persistent=False) | |
| self.register_buffer("sin", sin, persistent=False) | |
| def _hidden(self, idx, cache=None, use_cache=False): | |
| T = idx.shape[1] | |
| if cache is not None: | |
| assert use_cache and len(cache) == len(self.blocks) | |
| offset = 0 if cache is None else cache[0][0].shape[2] | |
| assert 0 < T and offset + T <= self.ctx, "input exceeds the model context" | |
| if cache is not None: | |
| for k, v in cache: | |
| assert k.shape == v.shape == (idx.shape[0], self.blocks[0].attn.n_head, | |
| offset, self.cos.shape[1]) | |
| assert k.device == v.device == idx.device | |
| cos, sin = self.cos[offset:offset + T], self.sin[offset:offset + T] | |
| x = self.tok_emb(idx) | |
| if self.pos_emb is not None: | |
| x = x + self.pos_emb(torch.arange(offset, offset + T, device=idx.device)) | |
| updated = [] | |
| for i, block in enumerate(self.blocks): | |
| x, entry = block(x, cos, sin, None if cache is None else cache[i], use_cache) | |
| if use_cache: | |
| updated.append(entry) | |
| return self.norm(x), tuple(updated) | |
| def forward(self, idx, targets=None): | |
| logits = self.head(self._hidden(idx)[0]) | |
| if targets is None: | |
| return logits, None | |
| return logits, F.cross_entropy(logits.flatten(0, 1), targets.flatten()) | |
| def forward_cached(self, idx, cache=None): | |
| """Return chunk logits and per-layer (K, V), each (B, heads, T, head_dim).""" | |
| assert not self.training, "KV caching requires model.eval()" | |
| x, cache = self._hidden(idx, cache, use_cache=True) | |
| return self.head(x), cache | |
| def generate_tokens(self, idx, n, temperature=1.0, stop=None, top_k=None, use_cache=True): | |
| """Yield (B, 1) token tensors; cache lives only in this iterator.""" | |
| assert not self.training, "generation requires model.eval()" | |
| assert idx.ndim == 2 and idx.shape[0] > 0 and idx.shape[1] > 0 | |
| assert n >= 0 and temperature >= 0, (n, temperature) | |
| assert top_k is None or 0 < top_k <= self.head.out_features, top_k | |
| assert stop is None or 0 <= stop < self.head.out_features, stop | |
| cache = None | |
| finished = torch.zeros(idx.shape[0], dtype=torch.bool, device=idx.device) | |
| for _ in range(n): | |
| if use_cache: | |
| if cache is None or cache[0][0].shape[2] == self.ctx: | |
| # Sliding the window changes every retained hidden state. | |
| # Rebuild it at position zero to match uncached generation. | |
| logits, cache = self.forward_cached(idx[:, -self.ctx:]) | |
| else: | |
| logits, cache = self.forward_cached(idx[:, -1:], cache) | |
| logits = logits[:, -1] | |
| else: | |
| logits = self(idx[:, -self.ctx:])[0][:, -1] | |
| if temperature == 0: | |
| nxt = logits.argmax(-1, keepdim=True) | |
| else: | |
| logits = logits / temperature | |
| if top_k is not None: | |
| cutoff = logits.topk(top_k, dim=-1).values[:, -1:] | |
| logits = logits.masked_fill(logits < cutoff, float("-inf")) | |
| nxt = torch.multinomial(logits.softmax(-1), 1) | |
| if stop is not None: | |
| nxt = nxt.masked_fill(finished[:, None], stop) | |
| finished |= nxt[:, 0] == stop | |
| idx = torch.cat([idx, nxt], dim=1) | |
| yield nxt | |
| if stop is not None and finished.all(): | |
| break | |
| def generate(self, idx, n, temperature=1.0, stop=None, top_k=None, use_cache=True): | |
| """Append up to n tokens; temperature=0 selects greedy decoding.""" | |
| tokens = list(self.generate_tokens(idx, n, temperature, stop, top_k, use_cache)) | |
| return torch.cat([idx, *tokens], dim=1) if tokens else idx | |
| def init_weights(module): | |
| if isinstance(module, (nn.Linear, nn.Embedding)): | |
| nn.init.normal_(module.weight, std=INIT_STD) | |
| def check_cache(device): | |
| """Check cached chunks, sliding windows, streaming, and batched stopping.""" | |
| torch.manual_seed(1337) | |
| for pos in ("rope", "learned"): | |
| model = Sprout(n_layer=2, dim=32, n_head=4, ctx=64, vocab=256, pos=pos).to(device).eval() | |
| idx = torch.randint(256, (2, 13), device=device) | |
| ref = model(idx)[0] | |
| first, cache = model.forward_cached(idx[:, :5]) | |
| single, cache = model.forward_cached(idx[:, 5:6], cache) | |
| last, cache = model.forward_cached(idx[:, 6:], cache) | |
| logits = torch.cat([first, single, last], dim=1) | |
| error = (logits - ref).abs().max().item() | |
| assert torch.allclose(logits, ref, atol=1e-6, rtol=1e-5), error | |
| assert all(k.shape == v.shape == (2, 4, 13, 8) for k, v in cache) | |
| assert all(not k.requires_grad and not v.requires_grad for k, v in cache) | |
| cached = model.generate(idx, 224, temperature=0) | |
| plain = model.generate(idx, 224, temperature=0, use_cache=False) | |
| assert torch.equal(cached, plain), (cached != plain).nonzero() | |
| # A prefix longer than the context must use exactly the final window. | |
| assert torch.equal(model.generate(cached, 3, temperature=0), | |
| model.generate(cached, 3, temperature=0, use_cache=False)) | |
| assert torch.equal(model.generate(idx, 0), idx) | |
| tokens = list(model.generate_tokens(idx, 224, temperature=0)) | |
| assert torch.equal(torch.cat([idx, *tokens], dim=1), cached) | |
| stop = int(cached[0, idx.shape[1]]) | |
| stopped = model.generate(idx, 32, temperature=0, stop=stop) | |
| assert (stopped[0, idx.shape[1]:] == stop).all() | |
| assert torch.equal(stopped, model.generate(idx, 32, temperature=0, stop=stop, | |
| use_cache=False)) | |
| if stopped.shape[1] < idx.shape[1] + 32: | |
| assert (stopped[:, -1] == stop).all() | |
| print(f"{pos} on {device}: 224 greedy tokens match exactly across context rollover; " | |
| f"chunk error {error:.2e}; streaming and batched stopping pass") | |
| def main(device): | |
| check_cache(device) | |
| torch.manual_seed(1337) | |
| model = Sprout() | |
| # Params, against a hand count of the same architecture. | |
| expected = ( | |
| VOCAB_SIZE * DIM | |
| + N_LAYER * (4 * DIM * DIM + 3 * DIM * HIDDEN + 2 * DIM) | |
| + DIM | |
| ) | |
| actual = sum(p.numel() for p in model.parameters()) | |
| print(f"params: {actual:,} (hand count {expected:,})") | |
| assert actual == expected, (actual, expected) | |
| assert model.head.weight is model.tok_emb.weight | |
| with torch.random.fork_rng(devices=[]): # leave the seeded checks below as they were | |
| learned = sum(p.numel() for p in Sprout(pos="learned").parameters()) | |
| print(f"params with learned positions: {learned:,} (+ctx*dim = {CTX * DIM:,})") | |
| assert learned == expected + CTX * DIM | |
| # RMSNorm against the definition written longhand. | |
| x = torch.randn(4, 7, DIM) | |
| norm = RMSNorm(DIM) | |
| with torch.no_grad(): | |
| norm.weight.normal_() | |
| ref = norm.weight * x / torch.sqrt((x * x).sum(-1, keepdim=True) / DIM + norm.eps) | |
| print(f"rmsnorm vs reference: {(norm(x) - ref).abs().max():.2e}") | |
| assert torch.allclose(norm(x), ref, atol=1e-6) | |
| # RoPE at position 0 is a no-op, and q.k depends only on the gap m - n. | |
| cos, sin = (t.double() for t in rope_tables(HEAD_DIM, CTX)) | |
| q = torch.randn(2, 1, 1, HEAD_DIM, dtype=torch.float64) | |
| k = torch.randn(2, 1, 1, HEAD_DIM, dtype=torch.float64) | |
| print(f"rope at position 0: {(rope(q, cos[0], sin[0]) - q).abs().max():.2e}") | |
| assert torch.allclose(rope(q, cos[0], sin[0]), q, atol=1e-6) | |
| def dot(m, n): | |
| return (rope(q, cos[m], sin[m]) * rope(k, cos[n], sin[n])).sum(-1) | |
| worst = max( | |
| (dot(m, n) - dot(m + d, n + d)).abs().max().item() | |
| for m, n, d in [(0, 0, 7), (3, 11, 100), (17, 5, 300), (100, 200, 211)] | |
| ) | |
| print(f"rope relative shift, worst gap: {worst:.2e}") | |
| assert worst < 1e-6 | |
| # Flash attention against the masked softmax it replaces. | |
| q, k, v = (torch.randn(2, N_HEAD, 64, HEAD_DIM) for _ in range(3)) | |
| scores = q @ k.transpose(-2, -1) / math.sqrt(HEAD_DIM) | |
| causal = torch.ones(64, 64, dtype=torch.bool).tril() | |
| manual = scores.masked_fill(~causal, float("-inf")).softmax(-1) @ v | |
| flash = F.scaled_dot_product_attention(q, k, v, is_causal=True) | |
| print(f"manual attention vs sdpa: {(manual - flash).abs().max():.2e}") | |
| assert torch.allclose(manual, flash, atol=1e-5) | |
| # A model that has learned nothing pays about ln(V) per token. | |
| val = np.memmap("data/val.bin", dtype=np.uint16, mode="r") | |
| real = torch.from_numpy(val[: 8 * (CTX + 1)].astype(np.int64)).view(8, CTX + 1) | |
| model.eval() | |
| with torch.no_grad(): | |
| for name, b in [("random", torch.randint(VOCAB_SIZE, (8, CTX + 1))), ("real", real)]: | |
| logits, loss = model(b[:, :-1], b[:, 1:]) | |
| s = logits.std().item() | |
| print(f"init loss ({name}): {loss:.4f}, logit std {s:.3f}, " | |
| f"ln(V) + std^2/2 = {math.log(VOCAB_SIZE) + s ** 2 / 2:.4f}") | |
| # Causality: change one token and nothing before it moves, bitwise. | |
| idx = torch.randint(VOCAB_SIZE, (1, 64)) | |
| t = 40 | |
| with torch.no_grad(): | |
| before = model(idx)[0] | |
| idx[0, t] = (idx[0, t] + 1) % VOCAB_SIZE | |
| after = model(idx)[0] | |
| print(f"causality: same up to t={t}: {torch.equal(before[:, :t], after[:, :t])}, " | |
| f"changed at t: {(before[:, t] - after[:, t]).abs().max():.3f}") | |
| assert torch.equal(before[:, :t], after[:, :t]) | |
| assert not torch.equal(before[:, t], after[:, t]) | |
| # Overfit one batch, the only check that runs backward. 32x128 rather than | |
| # the full context, which needs ~9GB of activations. | |
| model = Sprout().to(device) | |
| model.train() | |
| seq = 128 | |
| batch = torch.from_numpy(val[: 32 * (seq + 1)].astype(np.int64)).view(32, seq + 1) | |
| x, y = batch[:, :-1].to(device), batch[:, 1:].to(device) | |
| opt = torch.optim.AdamW(model.parameters(), lr=3e-4, betas=(0.9, 0.95)) | |
| print(f"overfitting 32x{seq} tokens on {device}") | |
| start = time.time() | |
| for step in range(301): | |
| loss = model(x, y)[1] | |
| opt.zero_grad(set_to_none=True) | |
| loss.backward() | |
| # Once the batch is memorized the gradients are tiny and Adam's step | |
| # blows up; without clipping the loss spikes back out. | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) | |
| opt.step() | |
| if step % 50 == 0: | |
| print(f" step {step:3d} loss {loss.item():.4f} {time.time() - start:.0f}s") | |
| assert loss.item() < 0.1, loss.item() | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--check-cache", action="store_true") | |
| parser.add_argument("--device", choices=("cpu", "mps", "cuda"), default=( | |
| "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")) | |
| args = parser.parse_args() | |
| if args.check_cache: | |
| check_cache(args.device) | |
| else: | |
| main(args.device) | |