ContextReq commited on
Commit
b615ad6
·
verified ·
1 Parent(s): 0897af6

first main run setup ready to begin

Browse files
Files changed (6) hide show
  1. layer_0.py +133 -0
  2. layer_1.py +105 -0
  3. layer_11.py +29 -0
  4. layer_2.py +40 -0
  5. notio.py +75 -0
  6. train_seq.py +253 -0
layer_0.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 0: the binary input.
2
+
3
+ No parameters. Owns the u8 id streams (train/val) and turns them into
4
+ model-ready batches: x (B,T) and y (B,T) int64 tensors, where y is the
5
+ same stream shifted by one id (next-token target).
6
+
7
+ Independence: imports only numpy/torch. Knows nothing about layer 1+.
8
+ """
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+ import torch
15
+
16
+
17
+ @dataclass
18
+ class Layer0Config:
19
+ block_size: int = 1024 # T: context length handed to layer 1
20
+ batch_size: int = 32 # B
21
+ chunk_size: int = 65536 # shuffle granularity, in ids
22
+ seed: Optional[int] = 1337
23
+
24
+
25
+ class Layer0:
26
+ """Read-only view of the binary; chunk-shuffle sampling."""
27
+
28
+ def __init__(self, cfg, train_path="data/train.bin", val_path="data/val.bin"):
29
+ self.cfg = cfg
30
+ # memmaps: fork-inheritable later, no copies in this process now
31
+ self.train = np.memmap(train_path, dtype=np.uint8, mode="r")
32
+ self.val = np.memmap(val_path, dtype=np.uint8, mode="r")
33
+ self.n_train = int(self.train.size)
34
+ self.n_val = int(self.val.size)
35
+ self.n_chunks = self.n_train // cfg.chunk_size # tail partial chunk dropped
36
+ self.rng = np.random.default_rng(cfg.seed)
37
+
38
+ def sample_batch(self):
39
+ """One training batch: random chunk, random offset, x and y shifted by 1."""
40
+ B, T, C = self.cfg.batch_size, self.cfg.block_size, self.cfg.chunk_size
41
+ xs = np.empty((B, T + 1), dtype=np.uint8)
42
+ for b in range(B):
43
+ c = int(self.rng.integers(0, self.n_chunks))
44
+ o = int(self.rng.integers(0, C - T)) # keeps y inside the chunk
45
+ base = c * C + o
46
+ xs[b] = self.train[base: base + T + 1]
47
+ x = torch.tensor(xs[:, :T], dtype=torch.long)
48
+ y = torch.tensor(xs[:, 1:], dtype=torch.long)
49
+ return x, y
50
+
51
+ def val_block(self, i):
52
+ """The i-th contiguous block of the val stream (sequential, no shuffle)."""
53
+ T = self.cfg.block_size
54
+ base = i * T
55
+ assert base + T < self.n_val, f"val block {i} out of range"
56
+ seg = self.val[base: base + T + 1]
57
+ return (torch.tensor(seg[:T], dtype=torch.long),
58
+ torch.tensor(seg[1:], dtype=torch.long))
59
+
60
+ @property
61
+ def n_val_blocks(self):
62
+ # blocks i with base+T+1 <= n_val -> i <= (n_val - T - 1) // T
63
+ return (self.n_val - self.cfg.block_size - 1) // self.cfg.block_size + 1
64
+
65
+ @property
66
+ def steps_per_epoch(self):
67
+ return self.n_train // (self.cfg.batch_size * self.cfg.block_size)
68
+
69
+ # ---- stateful truncated BPTT mode: sequential windows over contiguous tapes ----
70
+
71
+ @property
72
+ def tape_len(self):
73
+ """Contiguous ids per row, truncated to whole windows."""
74
+ return (self.n_train // self.cfg.batch_size) // self.cfg.block_size * self.cfg.block_size
75
+
76
+ @property
77
+ def n_seq_windows(self):
78
+ return self.tape_len // self.cfg.block_size
79
+
80
+ def seq_windows(self):
81
+ """Yield (x, y) for each T-window, all B rows advancing in lockstep.
82
+
83
+ Row b walks tape b = train[b*tape_len : (b+1)*tape_len] in order.
84
+ Tapes are contiguous in the original stream, so the caller may carry
85
+ RNN state across windows and reset only at sweep end (the one real
86
+ discontinuity). Deterministic: no RNG.
87
+ """
88
+ B, T = self.cfg.batch_size, self.cfg.block_size
89
+ rows = np.arange(B, dtype=np.int64) * self.tape_len
90
+ cols = np.arange(T + 1, dtype=np.int64)
91
+ idx = rows[:, None] + cols[None, :] # (B, T+1) base indices, built once
92
+ for w in range(self.n_seq_windows):
93
+ off = (idx + w * T).ravel() # per-window offsets
94
+ xs = self.train[off].reshape(B, T + 1) # one C-level gather, no python loop
95
+ yield (torch.tensor(xs[:, :T], dtype=torch.long),
96
+ torch.tensor(xs[:, 1:], dtype=torch.long))
97
+
98
+
99
+ def _self_test():
100
+ PROJ = Path(__file__).resolve().parents[1]
101
+ import sys
102
+ sys.path.insert(0, str(PROJ / "scripts"))
103
+ import tokenizer as tk
104
+ tk.load_vocab()
105
+
106
+ cfg = Layer0Config(batch_size=8, block_size=256)
107
+ l0 = Layer0(cfg)
108
+ x, y = l0.sample_batch()
109
+
110
+ assert x.shape == (8, 256) and y.shape == (8, 256), "batch shape"
111
+ assert int(x.min()) >= 0 and int(x.max()) <= 108, "ids out of vocab"
112
+ assert torch.equal(y[:, :255], x[:, 1:]), "shift law violated"
113
+
114
+ v0, _ = l0.val_block(0)
115
+ assert torch.equal(v0, torch.tensor(l0.val[:256].astype(np.int64))), "val walk"
116
+
117
+ def render(row):
118
+ out = bytes(row.numpy().astype(np.uint8)).translate(tk.TABLE_DEC)
119
+ for s, t in tk.SENT_DEC:
120
+ out = out.replace(s, t)
121
+ return tk.display(out).decode()
122
+
123
+ print("x[0] (model input):")
124
+ print(render(x[0]))
125
+ print("y[0] (model target):")
126
+ print(render(y[0]))
127
+ print(f"n_train={l0.n_train:,} n_chunks={l0.n_chunks:,} "
128
+ f"steps/epoch={l0.steps_per_epoch:,} n_val_blocks={l0.n_val_blocks:,}")
129
+ print("layer0 self-test: PASS")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ _self_test()
layer_1.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 1: the embedding - id -> vector.
2
+
3
+ wte: token table (vocab_size, d_model) "what"
4
+ wpe: position table (block_size, d_model) "where"
5
+ x = wte(ids) + wpe(pos), then optional dropout.
6
+
7
+ Independence: imports only torch. Knows nothing about layer 0 (data)
8
+ or anything above it. Exposes tie_lm_head for the (later) output layer.
9
+ """
10
+ from dataclasses import dataclass
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+
16
+ @dataclass
17
+ class Layer1Config:
18
+ vocab_size: int = 109
19
+ block_size: int = 1024
20
+ d_model: int = 512
21
+ init_std: float = 0.02
22
+ dropout: float = 0.0
23
+
24
+
25
+ class Layer1(nn.Module):
26
+ def __init__(self, cfg: Layer1Config):
27
+ super().__init__()
28
+ self.cfg = cfg
29
+ self.wte = nn.Embedding(cfg.vocab_size, cfg.d_model)
30
+ self.wpe = nn.Embedding(cfg.block_size, cfg.d_model)
31
+ self.drop = nn.Dropout(cfg.dropout)
32
+ self.init_weights()
33
+
34
+ def init_weights(self):
35
+ nn.init.normal_(self.wte.weight, mean=0.0, std=self.cfg.init_std)
36
+ nn.init.normal_(self.wpe.weight, mean=0.0, std=self.cfg.init_std)
37
+
38
+ def forward(self, ids, pos_offset=0):
39
+ """ids: (B, T) long -> (B, T, d_model). pos_offset: position embedding
40
+ start, used by stateful generation so wpe advances with the stream."""
41
+ B, T = ids.shape
42
+ assert pos_offset + T <= self.cfg.block_size, \
43
+ f"pos_offset+T={pos_offset + T} exceeds block_size {self.cfg.block_size}"
44
+ pos = torch.arange(T, device=ids.device) + pos_offset
45
+ return self.drop(self.wte(ids) + self.wpe(pos))
46
+
47
+ def tie_lm_head(self, head):
48
+ """Share wte with the (later) output head: head.weight IS wte.weight."""
49
+ head.weight = self.wte.weight
50
+ return head
51
+
52
+ @property
53
+ def n_params(self):
54
+ return sum(p.numel() for p in self.parameters())
55
+
56
+
57
+ def _self_test():
58
+ torch.manual_seed(0)
59
+ cfg = Layer1Config()
60
+ l1 = Layer1(cfg)
61
+ B, T = 4, 16
62
+ ids = torch.randint(0, cfg.vocab_size, (B, T))
63
+ x = l1(ids)
64
+
65
+ assert x.shape == (B, T, cfg.d_model), "embed shape"
66
+
67
+ # same id everywhere -> the wte part is constant across positions
68
+ same = torch.full((1, T), 42, dtype=torch.long)
69
+ xs = l1(same)
70
+ for p in range(T - 1):
71
+ d = (xs[0, p] - l1.wpe.weight[p]) - (xs[0, p + 1] - l1.wpe.weight[p + 1])
72
+ assert d.abs().max().item() < 1e-6, "wte part varies with position"
73
+
74
+ # same position, different ids -> the wpe part is constant
75
+ for b in range(B - 1):
76
+ d = (x[b, 0] - l1.wte(ids[b, 0])) - (x[b + 1, 0] - l1.wte(ids[b + 1, 0]))
77
+ assert d.abs().max().item() < 1e-6, "wpe part varies with token"
78
+
79
+ # gradients flow through both tables
80
+ x.sum().backward()
81
+ assert l1.wte.weight.grad is not None and l1.wte.weight.grad.abs().sum().item() > 0
82
+ assert l1.wpe.weight.grad is not None and l1.wpe.weight.grad.abs().sum().item() > 0
83
+
84
+ # param count: 109*256 + 256*256
85
+ assert l1.n_params == cfg.vocab_size * cfg.d_model + cfg.block_size * cfg.d_model == 93440
86
+
87
+ # weight tying is a true share, not a copy
88
+ head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
89
+ l1.tie_lm_head(head)
90
+ assert head.weight is l1.wte.weight, "tie is not a shared tensor"
91
+
92
+ # block_size guard
93
+ try:
94
+ l1(torch.zeros((1, cfg.block_size + 1), dtype=torch.long))
95
+ raise SystemExit("expected assertion for T > block_size")
96
+ except AssertionError:
97
+ pass
98
+
99
+ print(f"wte {tuple(l1.wte.weight.shape)} wpe {tuple(l1.wpe.weight.shape)} "
100
+ f"params {l1.n_params:,}")
101
+ print("layer1 self-test: PASS")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ _self_test()
layer_11.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 11: the output head.
2
+
3
+ ln_f + lm_head over the vocab. Supports weight tying with layer 1's wte.
4
+ The 12-layer stack: layer 0 + layer 1 + 9x layer 2 + this.
5
+ """
6
+ from dataclasses import dataclass
7
+
8
+ import torch.nn as nn
9
+
10
+
11
+ @dataclass
12
+ class Layer11Config:
13
+ vocab_size: int = 109
14
+ d_model: int = 512
15
+
16
+
17
+ class Layer11(nn.Module):
18
+ def __init__(self, cfg: Layer11Config):
19
+ super().__init__()
20
+ self.cfg = cfg
21
+ self.ln_f = nn.LayerNorm(cfg.d_model)
22
+ self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
23
+
24
+ def tie_to(self, layer1):
25
+ """Share layer 1's wte: head.weight IS wte.weight (0 extra params)."""
26
+ self.lm_head.weight = layer1.wte.weight
27
+
28
+ def forward(self, x):
29
+ return self.lm_head(self.ln_f(x))
layer_2.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layer 2: one recurrent block (pre-LN + residual, LSTM or GRU).
2
+
3
+ The 12-layer stack instantiates this 9 times: layers 2..10.
4
+ Causal by construction - no mask needed; memory is O(T), not O(T^2).
5
+ Hidden state resets each forward (windowed training); stateful sampling
6
+ can pass an initial state later.
7
+
8
+ Independence: imports only torch. Knows nothing about other layers.
9
+ """
10
+ from dataclasses import dataclass
11
+
12
+ import torch.nn as nn
13
+
14
+
15
+ @dataclass
16
+ class Layer2Config:
17
+ d_model: int = 512
18
+ rnn_type: str = "gru" # "lstm" | "gru"
19
+ dropout: float = 0.0
20
+
21
+
22
+ class Layer2(nn.Module):
23
+ def __init__(self, cfg: Layer2Config):
24
+ super().__init__()
25
+ self.cfg = cfg
26
+ self.ln = nn.LayerNorm(cfg.d_model)
27
+ if cfg.rnn_type == "lstm":
28
+ self.rnn = nn.LSTM(cfg.d_model, cfg.d_model, batch_first=True)
29
+ elif cfg.rnn_type == "gru":
30
+ self.rnn = nn.GRU(cfg.d_model, cfg.d_model, batch_first=True)
31
+ else:
32
+ raise ValueError(f"unknown rnn_type: {cfg.rnn_type}")
33
+
34
+ def forward(self, x, state=None):
35
+ out, state = self.rnn(self.ln(x), state)
36
+ return x + out, state
37
+
38
+ @property
39
+ def n_params(self):
40
+ return sum(p.numel() for p in self.parameters())
notio.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The assembler: layer 0 + layer 1 + 9x layer 2 + layer 11 = 12 layers.
2
+
3
+ The only file that knows the whole stack. forward(ids) -> logits (B, T, vocab).
4
+ """
5
+ from dataclasses import dataclass, field
6
+
7
+ import torch.nn as nn
8
+
9
+ from .layer_0 import Layer0, Layer0Config
10
+ from .layer_1 import Layer1, Layer1Config
11
+ from .layer_2 import Layer2, Layer2Config
12
+ from .layer_11 import Layer11, Layer11Config
13
+
14
+
15
+ @dataclass
16
+ class NotioConfig:
17
+ layer0: Layer0Config = field(default_factory=Layer0Config)
18
+ layer1: Layer1Config = field(default_factory=Layer1Config)
19
+ block: Layer2Config = field(default_factory=Layer2Config)
20
+ head: Layer11Config = field(default_factory=Layer11Config)
21
+ n_blocks: int = 2 # layers 2..10; + head = layer 11 -> 12 layers total
22
+
23
+
24
+ class Notio(nn.Module):
25
+ def __init__(self, cfg: NotioConfig):
26
+ super().__init__()
27
+ self.cfg = cfg
28
+ # cross-layer contracts (the one place they can be checked)
29
+ assert cfg.layer1.block_size >= cfg.layer0.block_size, "layer 1 capacity must be >= layer 0 sequence length"
30
+ assert cfg.layer1.d_model == cfg.block.d_model == cfg.head.d_model, "d_model must match everywhere"
31
+ assert cfg.layer1.vocab_size == cfg.head.vocab_size, "vocab_size must match layer 1 and head"
32
+
33
+ self.layer0 = Layer0(cfg.layer0) # data (not an nn.Module)
34
+ self.layer1 = Layer1(cfg.layer1)
35
+ self.blocks = nn.ModuleList([Layer2(cfg.block) for _ in range(cfg.n_blocks)])
36
+ self.head = Layer11(cfg.head)
37
+ self.tie_head()
38
+ self.init_weights()
39
+
40
+ def init_weights(self):
41
+ for module in self.modules():
42
+ if isinstance(module, nn.Linear):
43
+ nn.init.normal_(module.weight, mean=0.0, std=self.cfg.layer1.init_std)
44
+ if module.bias is not None:
45
+ nn.init.zeros_(module.bias)
46
+ # residual-path projections get the GPT-2 depth scaling
47
+ for name, p in self.named_parameters():
48
+ if name.endswith("c_proj.weight"):
49
+ nn.init.normal_(p, mean=0.0,
50
+ std=self.cfg.layer1.init_std / (2 * self.cfg.n_blocks) ** 0.5)
51
+
52
+ def tie_head(self):
53
+ self.head.tie_to(self.layer1)
54
+
55
+ def forward(self, ids, states=None, pos_offset=0):
56
+ """ids: (B, T) long -> (logits (B, T, vocab), states list).
57
+
58
+ states: per-block recurrent state from the previous window, or None
59
+ for zeros. The caller decides when to detach/reset (truncated BPTT).
60
+ pos_offset: position embedding offset for stateful generation.
61
+ """
62
+ x = self.layer1(ids, pos_offset)
63
+ new_states = []
64
+ for i, block in enumerate(self.blocks):
65
+ st = None if states is None else states[i]
66
+ x, st = block(x, st)
67
+ new_states.append(st)
68
+ return self.head(x), new_states
69
+
70
+ @property
71
+ def n_params(self):
72
+ return sum(p.numel() for p in self.parameters())
73
+
74
+ def sample_batch(self):
75
+ return self.layer0.sample_batch()
train_seq.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Stateful truncated BPTT training loop for the RNN stack.
3
+
4
+ - layer_0.seq_windows(): B contiguous tapes walked in order
5
+ - RNN state carried across windows but detached at every boundary:
6
+ gradients are truncated to one window, while information flows forward
7
+ through the carried state (the k1 == k2 == T form of truncated BPTT)
8
+ - state reset to zeros only at sweep boundaries (the one real discontinuity)
9
+
10
+ Usage:
11
+ python3 src/train_seq.py # train (defaults: 1 epoch, cpu/cuda auto)
12
+ python3 src/train_seq.py --check # prove truncation + statefulness
13
+ python3 src/train_seq.py --epochs 3 --lr 3e-4 --save out.pt
14
+ """
15
+ import argparse
16
+ import math
17
+ import sys
18
+ import time
19
+ from itertools import islice
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import torch
24
+ import torch.nn.functional as F
25
+
26
+ PROJ = Path(__file__).resolve().parents[1]
27
+ sys.path.insert(0, str(PROJ))
28
+ sys.path.insert(0, str(PROJ / "scripts"))
29
+ import tokenizer as tk
30
+ from model import Notio, NotioConfig
31
+
32
+ tk.load_vocab() # also asserts vocab.txt == vocab.bin at train start
33
+
34
+
35
+ def detach_states(states):
36
+ """Detach every hidden/cell tensor: no gradient flows across windows.
37
+ Handles LSTM (h, c) tuples and GRU bare tensors."""
38
+ out = []
39
+ for st in states:
40
+ out.append(tuple(s.detach() for s in st) if isinstance(st, tuple) else st.detach())
41
+ return out
42
+
43
+
44
+ def ids_to_display(ids):
45
+ """id tensor -> human text via the runtime decoder (tags dropped/mapped)."""
46
+ out = bytes(ids.cpu().numpy().astype(np.uint8)).translate(tk.TABLE_DEC)
47
+ for sent, tok in tk.SENT_DEC:
48
+ out = out.replace(sent, tok)
49
+ return tk.display(out).decode()
50
+
51
+
52
+ @torch.no_grad()
53
+ def generate(m, device, max_tokens=256, temperature=0.9, top_k=0):
54
+ """Sample one story: <bos> prompt, carry RNN state, stop at <eos>."""
55
+ m.eval()
56
+ ids = torch.tensor([[1]], dtype=torch.long, device=device) # <bos>
57
+ states = None
58
+ pos = 0
59
+ for _ in range(max_tokens):
60
+ logits, states = m(ids[:, -1:], states, pos_offset=pos)
61
+ states = detach_states(states)
62
+ pos += 1
63
+ logits = logits[:, -1, :] / max(temperature, 1e-6)
64
+ if top_k > 0:
65
+ v, _ = torch.topk(logits, top_k)
66
+ logits = torch.where(logits < v[:, -1:],
67
+ torch.full_like(logits, -float("inf")), logits)
68
+ probs = F.softmax(logits, dim=-1)
69
+ nxt = torch.multinomial(probs, 1)
70
+ ids = torch.cat([ids, nxt], dim=1)
71
+ if nxt.item() == 2: # <eos>
72
+ break
73
+ m.train()
74
+ return ids
75
+
76
+
77
+ def sample_and_print(m, device, n=2):
78
+ for i in range(n):
79
+ ids = generate(m, device)
80
+ print(f"--- sample {i} ({ids.numel()} ids) ---")
81
+ print(ids_to_display(ids))
82
+
83
+
84
+ @torch.no_grad()
85
+ def val_loss(m, device, n_blocks=20):
86
+ """Mean next-token loss over the first n_blocks contiguous val blocks,
87
+ carrying state across them (mirrors training)."""
88
+ m.eval()
89
+ states = None
90
+ total = 0.0
91
+ n = min(n_blocks, m.layer0.n_val_blocks)
92
+ for i in range(n):
93
+ x, y = m.layer0.val_block(i)
94
+ x, y = x.unsqueeze(0).to(device), y.unsqueeze(0).to(device)
95
+ logits, states = m(x, states)
96
+ total += F.cross_entropy(logits.view(-1, m.cfg.head.vocab_size), y.view(-1)).item()
97
+ states = detach_states(states)
98
+ m.train()
99
+ return total / n
100
+
101
+
102
+ def lr_at(step, lr, warmup, total, min_lr):
103
+ """Linear warmup, then cosine decay to min_lr over [warmup, total)."""
104
+ if step < warmup:
105
+ return lr * step / max(warmup, 1)
106
+ if step >= total:
107
+ return min_lr
108
+ prog = (step - warmup) / max(total - warmup, 1)
109
+ return min_lr + 0.5 * (lr - min_lr) * (1 + math.cos(math.pi * prog))
110
+
111
+
112
+ def check():
113
+ torch.manual_seed(0)
114
+ m = Notio(NotioConfig())
115
+ m.train()
116
+ it = m.layer0.seq_windows()
117
+ x1, y1 = next(it)
118
+ x2, y2 = next(it)
119
+
120
+ # 1) statefulness: carried state changes the second window's output
121
+ _, st = m(x1)
122
+ logits_carry, _ = m(x2, st)
123
+ logits_zero, _ = m(x2)
124
+ assert not torch.equal(logits_carry, logits_zero), "state is not used"
125
+
126
+ # 2) truncation: detach cuts the graph back to window 1.
127
+ # (values still flow - that is the point of carrying state)
128
+ V = m.cfg.head.vocab_size
129
+ assert st[0][0].grad_fn is not None, "state should carry history to window 1"
130
+ st_d = detach_states(st)
131
+ assert st_d[0][0].grad_fn is None, "detached state should be a leaf"
132
+ logits_d, _ = m(x2, st_d)
133
+ assert torch.equal(logits_carry, logits_d), "detach changed the forward pass"
134
+
135
+ def pgrad(states_):
136
+ m.zero_grad()
137
+ logits, _ = m(x2, states_)
138
+ F.cross_entropy(logits.view(-1, V), y2.view(-1)).backward()
139
+ return sum(p.grad.detach().abs().sum() for p in m.parameters() if p.grad is not None)
140
+
141
+ gd = pgrad(st_d) # detached: graph stops at the carried state
142
+ gu = pgrad(st) # undetached: gradient also flows back through window 1
143
+ assert torch.equal(gd, gu) is False, "detach did not truncate the gradient path"
144
+
145
+ # 3) tape continuity: window w+1 continues window w inside a tape
146
+ assert torch.equal(x2[:, 0], y1[:, -1]), "tapes are not contiguous"
147
+
148
+ # 4) a few real optimizer steps with carried+detached state
149
+ opt = torch.optim.AdamW(m.parameters(), lr=1e-3)
150
+ states = None
151
+ it2 = m.layer0.seq_windows()
152
+ t0 = time.time()
153
+ for step in range(3):
154
+ x, y = next(it2)
155
+ logits, states = m(x, states)
156
+ loss = F.cross_entropy(logits.view(-1, m.cfg.head.vocab_size), y.view(-1))
157
+ opt.zero_grad()
158
+ loss.backward()
159
+ opt.step()
160
+ states = detach_states(states)
161
+ print(f"step {step}: loss {loss.item():.3f}")
162
+ toks = 3 * x.numel()
163
+ print(f"truncation: True | statefulness: True | tape continuity: True")
164
+ print(f"{toks / (time.time() - t0):,.0f} tokens/s (CPU, {m.n_params:,} params, "
165
+ f"T={m.cfg.layer0.block_size}, B={m.cfg.layer0.batch_size}, d={m.cfg.layer1.d_model}, "
166
+ f"n_blocks={m.cfg.n_blocks})")
167
+
168
+
169
+ def main(device, epochs, lr, log_every, save, max_steps, save_every,
170
+ warmup, min_lr, resume, val_blocks):
171
+ torch.backends.cudnn.benchmark = True
172
+ if resume:
173
+ ck = torch.load(resume, map_location=device, weights_only=False) # our own file, contains NotioConfig
174
+ m = Notio(ck["cfg"])
175
+ m.load_state_dict(ck["state_dict"])
176
+ m.to(device)
177
+ start = ck.get("step", 0)
178
+ print(f"resumed {resume} @ step {start} "
179
+ f"(loss {ck.get('loss', float('nan')):.3f}, "
180
+ f"val loss {ck.get('val_loss', float('nan')):.3f})")
181
+ else:
182
+ m = Notio(NotioConfig())
183
+ m.to(device)
184
+ start = 0
185
+ m.train()
186
+ opt = torch.optim.AdamW(m.parameters(), lr=lr)
187
+ total = max_steps if max_steps else epochs * m.layer0.n_seq_windows
188
+ states = None
189
+ step = start
190
+ t0 = time.time()
191
+ it = islice(m.layer0.seq_windows(), start, None) # continue the sweep position
192
+ for epoch in range(epochs if max_steps is None else 1):
193
+ src = islice(it, max_steps) if max_steps is not None else it
194
+ for x, y in src:
195
+ new_lr = lr_at(step, lr, warmup, total, min_lr)
196
+ for g in opt.param_groups:
197
+ g["lr"] = new_lr
198
+ x, y = x.to(device), y.to(device)
199
+ logits, states = m(x, states)
200
+ loss = F.cross_entropy(logits.view(-1, m.cfg.head.vocab_size), y.view(-1))
201
+ opt.zero_grad()
202
+ loss.backward()
203
+ torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
204
+ opt.step()
205
+ states = detach_states(states) # truncate gradients at window length T
206
+ step += 1
207
+ if step % log_every == 0:
208
+ tok = step * x.numel()
209
+ print(f"epoch {epoch} step {step}: loss {loss.item():.3f} | "
210
+ f"{tok / (time.time() - t0):,.0f} tok/s | lr {new_lr:.2e}")
211
+ if step % save_every == 0:
212
+ vl = val_loss(m, device, val_blocks)
213
+ print(f"val loss @ step {step}: {vl:.3f}")
214
+ sample_and_print(m, device)
215
+ if save:
216
+ torch.save({"step": step, "loss": loss.item(), "val_loss": vl,
217
+ "lr": new_lr, "window": step, "cfg": m.cfg,
218
+ "state_dict": m.state_dict()}, save)
219
+ print(f"checkpoint saved: {save} @ step {step}")
220
+ states = None # sweep ended: reset at the real discontinuity
221
+ if max_steps is not None:
222
+ break
223
+ it = m.layer0.seq_windows()
224
+ total_tok = (step - start) * m.cfg.layer0.batch_size * m.cfg.layer0.block_size
225
+ vl = val_loss(m, device, val_blocks)
226
+ print(f"done: {step} steps | {total_tok / (time.time() - t0):,.0f} tok/s on {device} "
227
+ f"| final loss {loss.item():.3f} | val loss {vl:.3f}")
228
+ if save:
229
+ torch.save({"step": step, "loss": loss.item(), "val_loss": vl, "lr": new_lr,
230
+ "window": step, "cfg": m.cfg, "state_dict": m.state_dict()}, save)
231
+ print(f"saved {save}")
232
+
233
+
234
+ if __name__ == "__main__":
235
+ ap = argparse.ArgumentParser()
236
+ ap.add_argument("--check", action="store_true", help="prove truncation + statefulness, then exit")
237
+ ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
238
+ ap.add_argument("--epochs", type=int, default=1)
239
+ ap.add_argument("--lr", type=float, default=4e-4)
240
+ ap.add_argument("--log-every", type=int, default=20)
241
+ ap.add_argument("--max-steps", type=int, default=None, help="benchmark: train this many steps then stop")
242
+ ap.add_argument("--save", default=None, help="checkpoint path (saved every --save-every steps + at end)")
243
+ ap.add_argument("--save-every", type=int, default=500)
244
+ ap.add_argument("--warmup", type=int, default=500)
245
+ ap.add_argument("--min-lr", type=float, default=4e-5)
246
+ ap.add_argument("--resume", default=None, help="checkpoint to resume from (continues sweep position)")
247
+ ap.add_argument("--val-blocks", type=int, default=20)
248
+ a = ap.parse_args()
249
+ if a.check:
250
+ check()
251
+ else:
252
+ main(a.device, a.epochs, a.lr, a.log_every, a.save, a.max_steps, a.save_every,
253
+ a.warmup, a.min_lr, a.resume, a.val_blocks)