File size: 16,802 Bytes
20a371d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 | """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())
@torch.no_grad()
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
@torch.no_grad()
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
@torch.no_grad()
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)
@torch.no_grad()
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)
|