MarkChenX commited on
Commit
1c50c27
·
verified ·
1 Parent(s): d089c8b

Upload 24 files

Browse files
nanochat/__init__.py ADDED
File without changes
nanochat/checkpoint_manager.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utilities for saving and loading model/optim/state checkpoints.
3
+ """
4
+ import os
5
+ import re
6
+ import json
7
+ import shutil
8
+ import logging
9
+ import torch
10
+ from filelock import FileLock
11
+
12
+ from nanochat.common import get_base_dir
13
+ from nanochat.gpt import GPT, GPTConfig
14
+ from nanochat.tokenizer import get_tokenizer
15
+ from nanochat.common import setup_default_logging
16
+
17
+ # Set up logging
18
+ setup_default_logging()
19
+ logger = logging.getLogger(__name__)
20
+ def log0(message):
21
+ if int(os.environ.get('RANK', 0)) == 0:
22
+ logger.info(message)
23
+
24
+ def _patch_missing_config_keys(model_config_kwargs):
25
+ """Add default values for new config keys missing in old checkpoints."""
26
+ # Old models were trained with full context (no sliding window)
27
+ if "window_pattern" not in model_config_kwargs:
28
+ model_config_kwargs["window_pattern"] = "L"
29
+ log0(f"Patching missing window_pattern in model config to 'L'")
30
+ # Checkpoints predating the quantum feed-forward network contain c_fc/c_proj
31
+ # tensors and must continue to instantiate the original dense MLP.
32
+ if "mlp_type" not in model_config_kwargs:
33
+ model_config_kwargs["mlp_type"] = "classical"
34
+ log0("Patching missing mlp_type in model config to 'classical'")
35
+ # Checkpoints predating the LFM2-style hybrid backbone were all-attention with
36
+ # legacy RoPE base and value embeddings enabled. These defaults reproduce them.
37
+ if "mixer_pattern" not in model_config_kwargs:
38
+ model_config_kwargs["mixer_pattern"] = "A"
39
+ log0("Patching missing mixer_pattern in model config to 'A'")
40
+ if "conv_kernel" not in model_config_kwargs:
41
+ model_config_kwargs["conv_kernel"] = 3
42
+ if "rope_theta" not in model_config_kwargs:
43
+ model_config_kwargs["rope_theta"] = 100000.0
44
+ log0("Patching missing rope_theta in model config to 100000.0")
45
+ if "ffn_pattern" not in model_config_kwargs:
46
+ model_config_kwargs["ffn_pattern"] = ""
47
+ if "use_value_embeddings" not in model_config_kwargs:
48
+ model_config_kwargs["use_value_embeddings"] = True
49
+
50
+ def _patch_missing_keys(model_data, model_config):
51
+ """Add default values for new parameters that may be missing in old checkpoints."""
52
+ n_layer = model_config.n_layer
53
+ # resid_lambdas defaults to 1.0 (identity scaling)
54
+ if "resid_lambdas" not in model_data:
55
+ model_data["resid_lambdas"] = torch.ones(n_layer)
56
+ log0(f"Patching missing resid_lambdas in model data to 1.0")
57
+ # x0_lambdas defaults to 0.0 (disabled)
58
+ if "x0_lambdas" not in model_data:
59
+ model_data["x0_lambdas"] = torch.zeros(n_layer)
60
+ log0(f"Patching missing x0_lambdas in model data to 0.0")
61
+
62
+ def save_checkpoint(checkpoint_dir, step, model_data, optimizer_data, meta_data, rank=0):
63
+ if rank == 0:
64
+ os.makedirs(checkpoint_dir, exist_ok=True)
65
+ # Save the model state parameters
66
+ model_path = os.path.join(checkpoint_dir, f"model_{step:06d}.pt")
67
+ torch.save(model_data, model_path)
68
+ logger.info(f"Saved model parameters to: {model_path}")
69
+ # Save the metadata dict as json
70
+ meta_path = os.path.join(checkpoint_dir, f"meta_{step:06d}.json")
71
+ with open(meta_path, "w", encoding="utf-8") as f:
72
+ json.dump(meta_data, f, indent=2)
73
+ logger.info(f"Saved metadata to: {meta_path}")
74
+ # Note that optimizer state is sharded across ranks, so each rank must save its own.
75
+ if optimizer_data is not None:
76
+ os.makedirs(checkpoint_dir, exist_ok=True)
77
+ optimizer_path = os.path.join(checkpoint_dir, f"optim_{step:06d}_rank{rank:d}.pt")
78
+ torch.save(optimizer_data, optimizer_path)
79
+ logger.info(f"Saved optimizer state to: {optimizer_path}")
80
+
81
+ def load_checkpoint(checkpoint_dir, step, device, load_optimizer=False, rank=0):
82
+ # Load the model state
83
+ model_path = os.path.join(checkpoint_dir, f"model_{step:06d}.pt")
84
+ model_data = torch.load(model_path, map_location=device)
85
+ # Load the optimizer state if requested
86
+ optimizer_data = None
87
+ if load_optimizer:
88
+ optimizer_path = os.path.join(checkpoint_dir, f"optim_{step:06d}_rank{rank:d}.pt")
89
+ optimizer_data = torch.load(optimizer_path, map_location=device)
90
+ # Load the metadata
91
+ meta_path = os.path.join(checkpoint_dir, f"meta_{step:06d}.json")
92
+ with open(meta_path, "r", encoding="utf-8") as f:
93
+ meta_data = json.load(f)
94
+ return model_data, optimizer_data, meta_data
95
+
96
+
97
+ def build_model(checkpoint_dir, step, device, phase):
98
+ """
99
+ A bunch of repetitive code to build a model from a given checkpoint.
100
+ Returns:
101
+ - base model - uncompiled, not wrapped in DDP
102
+ - tokenizer
103
+ - meta data saved during base model training
104
+ """
105
+ assert phase in ["train", "eval"], f"Invalid phase: {phase}"
106
+ model_data, optimizer_data, meta_data = load_checkpoint(checkpoint_dir, step, device, load_optimizer=False)
107
+ if device.type in {"cpu", "mps"}:
108
+ # Convert bfloat16 tensors to float for CPU inference
109
+ model_data = {
110
+ k: v.float() if v.dtype == torch.bfloat16 else v
111
+ for k, v in model_data.items()
112
+ }
113
+ # Hack: fix torch compile issue, which prepends all keys with _orig_mod.
114
+ model_data = {k.removeprefix("_orig_mod."): v for k, v in model_data.items()}
115
+ model_config_kwargs = meta_data["model_config"]
116
+ _patch_missing_config_keys(model_config_kwargs)
117
+ log0(f"Building model with config: {model_config_kwargs}")
118
+ model_config = GPTConfig(**model_config_kwargs)
119
+ _patch_missing_keys(model_data, model_config)
120
+ with torch.device("meta"):
121
+ model = GPT(model_config)
122
+ # Load the model state
123
+ model.to_empty(device=device)
124
+ model.init_weights() # note: this is dumb, but we need to init the rotary embeddings. TODO: fix model re-init
125
+ model.load_state_dict(model_data, strict=True, assign=True)
126
+ # Put the model in the right training phase / mode
127
+ if phase == "eval":
128
+ model.eval()
129
+ else:
130
+ model.train()
131
+ # Load the Tokenizer
132
+ tokenizer = get_tokenizer()
133
+ # Sanity check: compatibility between model and tokenizer
134
+ assert tokenizer.get_vocab_size() == model_config_kwargs["vocab_size"], f"Tokenizer vocab size {tokenizer.get_vocab_size()} does not match model config vocab size {model_config_kwargs['vocab_size']}"
135
+ return model, tokenizer, meta_data
136
+
137
+
138
+ def find_largest_model(checkpoints_dir):
139
+ # attempt to guess the model tag: take the biggest model available
140
+ model_tags = [f for f in os.listdir(checkpoints_dir) if os.path.isdir(os.path.join(checkpoints_dir, f))]
141
+ if not model_tags:
142
+ raise FileNotFoundError(f"No checkpoints found in {checkpoints_dir}")
143
+ # 1) normally all model tags are of the form d<number>, try that first:
144
+ candidates = []
145
+ for model_tag in model_tags:
146
+ match = re.match(r"d(\d+)", model_tag)
147
+ if match:
148
+ model_depth = int(match.group(1))
149
+ candidates.append((model_depth, model_tag))
150
+ if candidates:
151
+ candidates.sort(key=lambda x: x[0], reverse=True)
152
+ return candidates[0][1]
153
+ # 2) if that failed, take the most recently updated model:
154
+ model_tags.sort(key=lambda x: os.path.getmtime(os.path.join(checkpoints_dir, x)), reverse=True)
155
+ return model_tags[0]
156
+
157
+
158
+ def find_last_step(checkpoint_dir):
159
+ # Look into checkpoint_dir and find model_<step>.pt with the highest step
160
+ checkpoint_files = [f for f in os.listdir(checkpoint_dir) if re.search(r'model_(\d+)\.pt$', f)]
161
+ if not checkpoint_files:
162
+ raise FileNotFoundError(f"No checkpoints found in {checkpoint_dir}")
163
+ last_step = max(int(f.split("_")[-1].split(".")[0]) for f in checkpoint_files)
164
+ return last_step
165
+
166
+ # -----------------------------------------------------------------------------
167
+ # convenience functions that take into account nanochat's directory structure
168
+
169
+ def load_model_from_dir(checkpoints_dir, device, phase, model_tag=None, step=None):
170
+ if model_tag is None:
171
+ # guess the model tag by defaulting to the largest model
172
+ model_tag = find_largest_model(checkpoints_dir)
173
+ log0(f"No model tag provided, guessing model tag: {model_tag}")
174
+ checkpoint_dir = os.path.join(checkpoints_dir, model_tag)
175
+ if step is None:
176
+ # guess the step by defaulting to the last step
177
+ step = find_last_step(checkpoint_dir)
178
+ assert step is not None, f"No checkpoints found in {checkpoint_dir}"
179
+ # build the model
180
+ log0(f"Loading model from {checkpoint_dir} with step {step}")
181
+ model, tokenizer, meta_data = build_model(checkpoint_dir, step, device, phase)
182
+ return model, tokenizer, meta_data
183
+
184
+ def load_model(source, *args, **kwargs):
185
+ model_dir = {
186
+ "base": "base_checkpoints",
187
+ "sft": "chatsft_checkpoints",
188
+ "rl": "chatrl_checkpoints",
189
+ }[source]
190
+ base_dir = get_base_dir()
191
+ checkpoints_dir = os.path.join(base_dir, model_dir)
192
+ return load_model_from_dir(checkpoints_dir, *args, **kwargs)
193
+
194
+ TOKENIZER_FILES = ("tokenizer.pkl", "token_bytes.pt")
195
+
196
+ def download_hub_checkpoint(repo_id, revision=None, local_dir=None, token=None):
197
+ """
198
+ Download a nanochat-format checkpoint repo from the HuggingFace Hub and return the
199
+ local directory, ready to hand to build_model()/find_last_step().
200
+
201
+ The repo is expected to contain model_<step>.pt / meta_<step>.json (and optionally
202
+ optim_<step>_rank<r>.pt) at its root, i.e. exactly what save_checkpoint() writes.
203
+ If it also ships the training tokenizer, we install it into the local tokenizer dir
204
+ when there isn't one already -- a Hub snapshot without its matching tokenizer would
205
+ otherwise trip the vocab-size assert in build_model() with a confusing message.
206
+ """
207
+ try:
208
+ from huggingface_hub import snapshot_download
209
+ except ImportError as e:
210
+ raise ImportError(
211
+ "huggingface_hub is required to download checkpoints from the Hub. "
212
+ "Install it with: uv sync --extra gpu --extra distill"
213
+ ) from e
214
+ if local_dir is None:
215
+ local_dir = os.path.join("models", repo_id.split("/")[-1])
216
+ # Under torchrun only one rank should download; the others block and then reuse it.
217
+ os.makedirs(os.path.dirname(local_dir) or ".", exist_ok=True)
218
+ with FileLock(local_dir + ".lock"):
219
+ log0(f"Downloading checkpoint {repo_id} to {local_dir}")
220
+ snapshot_download(repo_id=repo_id, revision=revision, local_dir=local_dir, token=token)
221
+ # Sanity check that this actually looks like a nanochat checkpoint directory.
222
+ if not any(re.search(r"model_(\d+)\.pt$", f) for f in os.listdir(local_dir)):
223
+ raise FileNotFoundError(
224
+ f"{repo_id} downloaded to {local_dir} but contains no model_<step>.pt file. "
225
+ "This does not look like a nanochat checkpoint repo (as written by save_checkpoint)."
226
+ )
227
+ _install_hub_tokenizer(local_dir, repo_id)
228
+ return local_dir
229
+
230
+ def _install_hub_tokenizer(local_dir, repo_id):
231
+ """Copy a tokenizer shipped alongside Hub weights into the local tokenizer dir, if absent."""
232
+ tokenizer_dir = os.path.join(get_base_dir(), "tokenizer")
233
+ if os.path.exists(os.path.join(tokenizer_dir, "tokenizer.pkl")):
234
+ return # a local tokenizer already exists, never clobber it
235
+ # the tokenizer may sit at the repo root or in a tokenizer/ subdirectory
236
+ for candidate in (local_dir, os.path.join(local_dir, "tokenizer")):
237
+ if os.path.exists(os.path.join(candidate, "tokenizer.pkl")):
238
+ os.makedirs(tokenizer_dir, exist_ok=True)
239
+ for filename in TOKENIZER_FILES:
240
+ src = os.path.join(candidate, filename)
241
+ if os.path.exists(src):
242
+ shutil.copy2(src, os.path.join(tokenizer_dir, filename))
243
+ log0(f"Installed tokenizer from {candidate} into {tokenizer_dir}")
244
+ return
245
+ raise FileNotFoundError(
246
+ f"{repo_id} does not ship a tokenizer.pkl and none exists at {tokenizer_dir}. "
247
+ "The model cannot be loaded without the tokenizer it was trained with: either "
248
+ "train/copy one there (see scripts/tok_train.py), or pass --tokenizer-dir pointing "
249
+ "at a directory containing tokenizer.pkl and token_bytes.pt."
250
+ )
251
+
252
+ def install_tokenizer_dir(tokenizer_dir):
253
+ """Copy an explicitly provided tokenizer into the local tokenizer dir (--tokenizer-dir)."""
254
+ dest = os.path.join(get_base_dir(), "tokenizer")
255
+ src_pickle = os.path.join(tokenizer_dir, "tokenizer.pkl")
256
+ if not os.path.exists(src_pickle):
257
+ raise FileNotFoundError(f"No tokenizer.pkl found in {tokenizer_dir}")
258
+ os.makedirs(dest, exist_ok=True)
259
+ for filename in TOKENIZER_FILES:
260
+ src = os.path.join(tokenizer_dir, filename)
261
+ if os.path.exists(src):
262
+ shutil.copy2(src, os.path.join(dest, filename))
263
+ log0(f"Installed tokenizer from {tokenizer_dir} into {dest}")
264
+
265
+ def load_optimizer_state(source, device, rank, model_tag=None, step=None):
266
+ """Load just the optimizer shard for a given rank, without re-loading the model."""
267
+ model_dir = {
268
+ "base": "base_checkpoints",
269
+ "sft": "chatsft_checkpoints",
270
+ "rl": "chatrl_checkpoints",
271
+ }[source]
272
+ base_dir = get_base_dir()
273
+ checkpoints_dir = os.path.join(base_dir, model_dir)
274
+ if model_tag is None:
275
+ model_tag = find_largest_model(checkpoints_dir)
276
+ checkpoint_dir = os.path.join(checkpoints_dir, model_tag)
277
+ if step is None:
278
+ step = find_last_step(checkpoint_dir)
279
+ optimizer_path = os.path.join(checkpoint_dir, f"optim_{step:06d}_rank{rank:d}.pt")
280
+ if not os.path.exists(optimizer_path):
281
+ log0(f"Optimizer checkpoint not found: {optimizer_path}")
282
+ return None
283
+ log0(f"Loading optimizer state from {optimizer_path}")
284
+ optimizer_data = torch.load(optimizer_path, map_location=device)
285
+ return optimizer_data
nanochat/common.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Common utilities for nanochat.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import logging
8
+ import urllib.request
9
+ import torch
10
+ import torch.distributed as dist
11
+ from filelock import FileLock
12
+
13
+ # The dtype used for compute (matmuls, activations). Master weights stay fp32 for optimizer precision.
14
+ # Linear layers cast their weights to this dtype in forward, replacing torch.amp.autocast.
15
+ # Override with NANOCHAT_DTYPE env var: "bfloat16", "float16", "float32"
16
+ _DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
17
+ def _detect_compute_dtype():
18
+ env = os.environ.get("NANOCHAT_DTYPE")
19
+ if env is not None:
20
+ return _DTYPE_MAP[env], f"set via NANOCHAT_DTYPE={env}"
21
+ if torch.cuda.is_available():
22
+ # bf16 requires SM 80+ (Ampere: A100, A10, etc.)
23
+ # Older GPUs like V100 (SM 70) and T4 (SM 75) only have fp16 tensor cores
24
+ capability = torch.cuda.get_device_capability()
25
+ if capability >= (8, 0):
26
+ return torch.bfloat16, f"auto-detected: CUDA SM {capability[0]}{capability[1]} (bf16 supported)"
27
+ # fp16 training requires GradScaler (not yet implemented), so fall back to fp32.
28
+ # Users can still force fp16 via NANOCHAT_DTYPE=float16 if they know what they're doing.
29
+ return torch.float32, f"auto-detected: CUDA SM {capability[0]}{capability[1]} (pre-Ampere, bf16 not supported, using fp32)"
30
+ # Note: MPS on recent macOS also handles bf16 fine, opt in via NANOCHAT_DTYPE=bfloat16
31
+ return torch.float32, "auto-detected: no CUDA (CPU/MPS)"
32
+ COMPUTE_DTYPE, COMPUTE_DTYPE_REASON = _detect_compute_dtype()
33
+
34
+ class ColoredFormatter(logging.Formatter):
35
+ """Custom formatter that adds colors to log messages."""
36
+ # ANSI color codes
37
+ COLORS = {
38
+ 'DEBUG': '\033[36m', # Cyan
39
+ 'INFO': '\033[32m', # Green
40
+ 'WARNING': '\033[33m', # Yellow
41
+ 'ERROR': '\033[31m', # Red
42
+ 'CRITICAL': '\033[35m', # Magenta
43
+ }
44
+ RESET = '\033[0m'
45
+ BOLD = '\033[1m'
46
+ def format(self, record):
47
+ # Add color to the level name
48
+ levelname = record.levelname
49
+ if levelname in self.COLORS:
50
+ record.levelname = f"{self.COLORS[levelname]}{self.BOLD}{levelname}{self.RESET}"
51
+ # Format the message
52
+ message = super().format(record)
53
+ # Add color to specific parts of the message
54
+ if levelname == 'INFO':
55
+ # Highlight numbers and percentages
56
+ message = re.sub(r'(\d+\.?\d*\s*(?:GB|MB|%|docs))', rf'{self.BOLD}\1{self.RESET}', message)
57
+ message = re.sub(r'(Shard \d+)', rf'{self.COLORS["INFO"]}{self.BOLD}\1{self.RESET}', message)
58
+ return message
59
+
60
+ def setup_default_logging():
61
+ handler = logging.StreamHandler()
62
+ handler.setFormatter(ColoredFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
63
+ logging.basicConfig(
64
+ level=logging.INFO,
65
+ handlers=[handler]
66
+ )
67
+
68
+ setup_default_logging()
69
+ logger = logging.getLogger(__name__)
70
+
71
+ def get_base_dir():
72
+ # co-locate nanochat intermediates with other cached data in ~/.cache (by default)
73
+ if os.environ.get("NANOCHAT_BASE_DIR"):
74
+ nanochat_dir = os.environ.get("NANOCHAT_BASE_DIR")
75
+ else:
76
+ home_dir = os.path.expanduser("~")
77
+ cache_dir = os.path.join(home_dir, ".cache")
78
+ nanochat_dir = os.path.join(cache_dir, "nanochat")
79
+ os.makedirs(nanochat_dir, exist_ok=True)
80
+ return nanochat_dir
81
+
82
+ def download_file_with_lock(url, filename, postprocess_fn=None):
83
+ """
84
+ Downloads a file from a URL to a local path in the base directory.
85
+ Uses a lock file to prevent concurrent downloads among multiple ranks.
86
+ """
87
+ base_dir = get_base_dir()
88
+ file_path = os.path.join(base_dir, filename)
89
+ lock_path = file_path + ".lock"
90
+
91
+ if os.path.exists(file_path):
92
+ return file_path
93
+
94
+ with FileLock(lock_path):
95
+ # Only a single rank can acquire this lock
96
+ # All other ranks block until it is released
97
+
98
+ # Recheck after acquiring lock
99
+ if os.path.exists(file_path):
100
+ return file_path
101
+
102
+ # Download the content as bytes
103
+ print(f"Downloading {url}...")
104
+ with urllib.request.urlopen(url) as response:
105
+ content = response.read() # bytes
106
+
107
+ # Write to local file
108
+ with open(file_path, 'wb') as f:
109
+ f.write(content)
110
+ print(f"Downloaded to {file_path}")
111
+
112
+ # Run the postprocess function if provided
113
+ if postprocess_fn is not None:
114
+ postprocess_fn(file_path)
115
+
116
+ return file_path
117
+
118
+ def print0(s="",**kwargs):
119
+ ddp_rank = int(os.environ.get('RANK', 0))
120
+ if ddp_rank == 0:
121
+ print(s, **kwargs)
122
+
123
+ def print_banner():
124
+ # Cool DOS Rebel font ASCII banner made with https://manytools.org/hacker-tools/ascii-banner/
125
+ banner = """
126
+ █████ █████
127
+ ░░███ ░░███
128
+ ████████ ██████ ████████ ██████ ██████ ░███████ ██████ ███████
129
+ ░░███░░███ ░░░░░███ ░░███░░███ ███░░███ ███░░███ ░███░░███ ░░░░░███░░░███░
130
+ ░███ ░███ ███████ ░███ ░███ ░███ ░███░███ ░░░ ░███ ░███ ███████ ░███
131
+ ░███ ░███ ███░░███ ░███ ░███ ░███ ░███░███ ███ ░███ ░███ ███░░███ ░███ ███
132
+ ████ █████░░████████ ████ █████░░██████ ░░██████ ████ █████░░███████ ░░█████
133
+ ░░░░ ░░░░░ ░░░░░░░░ ░░░░ ░░░░░ ░░░░░░ ░░░░░░ ░░░░ ░░░░░ ░░░░░░░░ ░░░░░
134
+ """
135
+ print0(banner)
136
+
137
+ def is_ddp_requested() -> bool:
138
+ """
139
+ True if launched by torchrun (env present), even before init.
140
+ Used to decide whether we *should* initialize a PG.
141
+ """
142
+ return all(k in os.environ for k in ("RANK", "LOCAL_RANK", "WORLD_SIZE"))
143
+
144
+ def is_ddp_initialized() -> bool:
145
+ """
146
+ True if torch.distributed is available and the process group is initialized.
147
+ Used at cleanup to avoid destroying a non-existent PG.
148
+ """
149
+ return dist.is_available() and dist.is_initialized()
150
+
151
+ def get_dist_info():
152
+ if is_ddp_requested():
153
+ # We rely on torchrun's env to decide if we SHOULD init.
154
+ # (Initialization itself happens in compute init.)
155
+ assert all(var in os.environ for var in ['RANK', 'LOCAL_RANK', 'WORLD_SIZE'])
156
+ ddp_rank = int(os.environ['RANK'])
157
+ ddp_local_rank = int(os.environ['LOCAL_RANK'])
158
+ ddp_world_size = int(os.environ['WORLD_SIZE'])
159
+ return True, ddp_rank, ddp_local_rank, ddp_world_size
160
+ else:
161
+ return False, 0, 0, 1
162
+
163
+ def autodetect_device_type():
164
+ # prefer to use CUDA if available, otherwise use MPS, otherwise fallback on CPU
165
+ if torch.cuda.is_available():
166
+ device_type = "cuda"
167
+ elif torch.backends.mps.is_available():
168
+ device_type = "mps"
169
+ else:
170
+ device_type = "cpu"
171
+ print0(f"Autodetected device type: {device_type}")
172
+ return device_type
173
+
174
+ def compute_init(device_type="cuda"): # cuda|cpu|mps
175
+ """Basic initialization that we keep doing over and over, so make common."""
176
+
177
+ assert device_type in ["cuda", "mps", "cpu"], "Invalid device type atm"
178
+ if device_type == "cuda":
179
+ assert torch.cuda.is_available(), "Your PyTorch installation is not configured for CUDA but device_type is 'cuda'"
180
+ if device_type == "mps":
181
+ assert torch.backends.mps.is_available(), "Your PyTorch installation is not configured for MPS but device_type is 'mps'"
182
+
183
+ # Reproducibility
184
+ # Note that we set the global seeds here, but most of the code uses explicit rng objects.
185
+ # The only place where global rng might be used is nn.Module initialization of the model weights.
186
+ torch.manual_seed(42)
187
+ if device_type == "cuda":
188
+ torch.cuda.manual_seed(42)
189
+ # skipping full reproducibility for now, possibly investigate slowdown later
190
+ # torch.use_deterministic_algorithms(True)
191
+
192
+ # Precision
193
+ if device_type == "cuda":
194
+ torch.set_float32_matmul_precision("high") # uses tf32 instead of fp32 for matmuls, see https://docs.pytorch.org/docs/stable/generated/torch.set_float32_matmul_precision.html
195
+
196
+ # Distributed setup: Distributed Data Parallel (DDP), optional, and requires CUDA
197
+ is_ddp_requested, ddp_rank, ddp_local_rank, ddp_world_size = get_dist_info()
198
+ if is_ddp_requested and device_type == "cuda":
199
+ device = torch.device("cuda", ddp_local_rank)
200
+ torch.cuda.set_device(device) # make "cuda" default to this device
201
+ dist.init_process_group(backend="nccl", device_id=device)
202
+ dist.barrier()
203
+ else:
204
+ device = torch.device(device_type) # mps|cpu
205
+
206
+ if ddp_rank == 0:
207
+ logger.info(f"Distributed world size: {ddp_world_size}")
208
+
209
+ return is_ddp_requested, ddp_rank, ddp_local_rank, ddp_world_size, device
210
+
211
+ def compute_cleanup():
212
+ """Companion function to compute_init, to clean things up before script exit"""
213
+ if is_ddp_initialized():
214
+ dist.destroy_process_group()
215
+
216
+ class DummyWandb:
217
+ """Useful if we wish to not use wandb but have all the same signatures"""
218
+ def __init__(self):
219
+ pass
220
+ def log(self, *args, **kwargs):
221
+ pass
222
+ def finish(self):
223
+ pass
224
+
225
+ # hardcoded BF16 peak flops for various GPUs
226
+ # inspired by torchtitan: https://github.com/pytorch/torchtitan/blob/main/torchtitan/tools/utils.py
227
+ # and PR: https://github.com/karpathy/nanochat/pull/147
228
+ def get_peak_flops(device_name: str) -> float:
229
+ name = device_name.lower()
230
+
231
+ # Table order matters: more specific patterns first.
232
+ _PEAK_FLOPS_TABLE = (
233
+ # NVIDIA Blackwell
234
+ (["gb200"], 2.5e15),
235
+ (["grace blackwell"], 2.5e15),
236
+ (["b200"], 2.25e15),
237
+ (["b100"], 1.8e15),
238
+ # NVIDIA Hopper
239
+ (["h200", "nvl"], 836e12),
240
+ (["h200", "pcie"], 836e12),
241
+ (["h200"], 989e12),
242
+ (["h100", "nvl"], 835e12),
243
+ (["h100", "pcie"], 756e12),
244
+ (["h100"], 989e12),
245
+ (["h800", "nvl"], 989e12),
246
+ (["h800"], 756e12),
247
+ # NVIDIA Ampere data center
248
+ (["a100"], 312e12),
249
+ (["a800"], 312e12),
250
+ (["a40"], 149.7e12),
251
+ (["a30"], 165e12),
252
+ # NVIDIA Ada data center
253
+ (["l40s"], 362e12),
254
+ (["l40-s"], 362e12),
255
+ (["l40 s"], 362e12),
256
+ (["l4"], 121e12),
257
+ # AMD CDNA accelerators
258
+ (["mi355"], 2.5e15),
259
+ (["mi325"], 1.3074e15),
260
+ (["mi300x"], 1.3074e15),
261
+ (["mi300a"], 980.6e12),
262
+ (["mi250x"], 383e12),
263
+ (["mi250"], 362.1e12),
264
+ # Consumer RTX
265
+ (["5090"], 209.5e12),
266
+ (["4090"], 165.2e12),
267
+ # 7,424 CUDA cores * 1.59 GHz max boost * 4 dense BF16 tensor FLOPs.
268
+ # Laptop boost varies with configured TGP, so actual peak may be lower.
269
+ (["3080 ti", "laptop"], 47.2e12),
270
+ (["3090"], 71e12),
271
+ )
272
+ for patterns, flops in _PEAK_FLOPS_TABLE:
273
+ if all(p in name for p in patterns):
274
+ return flops
275
+ if "data center gpu max 1550" in name:
276
+ # Ponte Vecchio (PVC) - dynamic based on compute units
277
+ max_comp_units = torch.xpu.get_device_properties("xpu").max_compute_units
278
+ return 512 * max_comp_units * 1300 * 10**6
279
+
280
+ # Unknown GPU - return inf so MFU shows as 0% rather than a wrong guess
281
+ logger.warning(f"Peak flops undefined for: {device_name}, MFU will show as 0%")
282
+ return float('inf')
283
+
284
+ def get_peak_bandwidth(device_name: str) -> float:
285
+ """Peak HBM/GDDR memory bandwidth in bytes/sec. The decode phase of inference
286
+ is memory-bandwidth-bound, so this is the roofline for tokens/sec (see MBU)."""
287
+ name = device_name.lower()
288
+
289
+ # Table order matters: more specific patterns first.
290
+ _PEAK_BANDWIDTH_TABLE = (
291
+ # NVIDIA Blackwell (HBM3e)
292
+ (["gb200"], 8.0e12),
293
+ (["grace blackwell"], 8.0e12),
294
+ (["b200"], 8.0e12),
295
+ (["b100"], 8.0e12),
296
+ # NVIDIA Hopper
297
+ (["h200"], 4.8e12),
298
+ (["h100", "nvl"], 3.9e12),
299
+ (["h100", "pcie"], 2.0e12),
300
+ (["h100"], 3.35e12), # SXM
301
+ (["h800", "pcie"], 2.0e12),
302
+ (["h800"], 3.35e12), # SXM
303
+ # NVIDIA Ampere data center (A100 80GB; the 40GB variant is 1.6e12)
304
+ (["a100"], 2.0e12),
305
+ (["a800"], 2.0e12),
306
+ (["a40"], 696e9),
307
+ (["a30"], 933e9),
308
+ # NVIDIA Ada data center
309
+ (["l40s"], 864e9),
310
+ (["l40-s"], 864e9),
311
+ (["l40 s"], 864e9),
312
+ (["l4"], 300e9),
313
+ # AMD CDNA accelerators
314
+ (["mi355"], 8.0e12),
315
+ (["mi325"], 6.0e12),
316
+ (["mi300x"], 5.3e12),
317
+ (["mi300a"], 5.3e12),
318
+ (["mi250x"], 3.28e12),
319
+ (["mi250"], 3.28e12),
320
+ # Consumer RTX
321
+ (["5090"], 1.79e12),
322
+ (["4090"], 1.01e12),
323
+ (["3090"], 936e9),
324
+ )
325
+ for patterns, bandwidth in _PEAK_BANDWIDTH_TABLE:
326
+ if all(p in name for p in patterns):
327
+ return bandwidth
328
+
329
+ # Unknown GPU - return inf so MBU shows as 0% rather than a wrong guess
330
+ logger.warning(f"Peak bandwidth undefined for: {device_name}, MBU will show as 0%")
331
+ return float('inf')
nanochat/core_eval.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Functions for evaluating the CORE metric, as described in the DCLM paper.
3
+ https://arxiv.org/abs/2406.11794
4
+
5
+ TODOs:
6
+ - All tasks ~match except for squad. We get 31% reference is 37%. Figure out why.
7
+ """
8
+ import random
9
+
10
+ from jinja2 import Template
11
+ import torch
12
+ import torch.distributed as dist
13
+
14
+ # -----------------------------------------------------------------------------
15
+ # Prompt rendering utilities
16
+
17
+ def render_prompts_mc(item, continuation_delimiter, fewshot_examples=None):
18
+ """Render complete prompts for a multiple choice question"""
19
+ template_str = """
20
+ {%- for example in fewshot_examples -%}
21
+ {{ example.query }}{{ continuation_delimiter }}{{ example.choices[example.gold] }}
22
+
23
+ {% endfor -%}
24
+ {{ item.query }}{{ continuation_delimiter }}{{ choice }}""".strip()
25
+ template = Template(template_str)
26
+ fewshot_examples = fewshot_examples or []
27
+ context = {
28
+ 'fewshot_examples': fewshot_examples,
29
+ 'continuation_delimiter': continuation_delimiter,
30
+ 'item': item
31
+ }
32
+ prompts = [template.render(choice=choice, **context) for choice in item['choices']]
33
+ return prompts
34
+
35
+
36
+ def render_prompts_schema(item, continuation_delimiter, fewshot_examples=None):
37
+ """Render complete prompts for a schema question"""
38
+ template_str = """
39
+ {%- for example in fewshot_examples -%}
40
+ {{ example.context_options[example.gold] }}{{ continuation_delimiter }}{{ example.continuation }}
41
+
42
+ {% endfor -%}
43
+ {{ context }}{{ continuation_delimiter }}{{ item.continuation }}""".strip()
44
+ template = Template(template_str)
45
+ fewshot_examples = fewshot_examples or []
46
+ context = {
47
+ 'fewshot_examples': fewshot_examples,
48
+ 'continuation_delimiter': continuation_delimiter,
49
+ 'item': item
50
+ }
51
+ prompts = [template.render(context=context_option, **context)
52
+ for context_option in item['context_options']]
53
+ return prompts
54
+
55
+
56
+ def render_prompts_lm(item, continuation_delimiter, fewshot_examples=None):
57
+ """
58
+ Render complete prompt for a language modeling task.
59
+ Notice that we manually trim the context in the template,
60
+ which in some datasets seems to have trailing whitespace (which we don't want).
61
+ """
62
+ template_str = """
63
+ {%- for example in fewshot_examples -%}
64
+ {{ example.context | trim }}{{ continuation_delimiter }}{{ example.continuation }}
65
+
66
+ {% endfor -%}
67
+ {{ item.context | trim }}{{ continuation_delimiter }}{% if include_continuation %}{{ item.continuation }}{% endif %}""".strip()
68
+ template = Template(template_str)
69
+ fewshot_examples = fewshot_examples or []
70
+ context = {
71
+ 'fewshot_examples': fewshot_examples,
72
+ 'continuation_delimiter': continuation_delimiter,
73
+ 'item': item
74
+ }
75
+ # Return two prompts: without and with the continuation
76
+ prompt_without = template.render(include_continuation=False, **context)
77
+ prompt_with = template.render(include_continuation=True, **context)
78
+ # Due to the way the data seems to be stored, I think I need to strip in the case of LM here.
79
+ # Otherwise we may get trailing whitespaces in prompt_without (which get absorbed into the next
80
+ # token in prompt_with), meaning we don't get a nice and clean prefix in the token space
81
+ # to detect the final continuation. Tokenizers...
82
+ prompt_without = prompt_without.strip()
83
+ return [prompt_without, prompt_with]
84
+
85
+
86
+ def find_common_length(token_sequences, direction='left'):
87
+ """
88
+ Find the length of the common prefix or suffix across token sequences
89
+ - direction: 'left' for prefix, 'right' for suffix
90
+ """
91
+ min_len = min(len(seq) for seq in token_sequences)
92
+ indices = {
93
+ 'left': range(min_len),
94
+ 'right': range(-1, -min_len-1, -1)
95
+ }[direction]
96
+ # Find the first position where the token sequences differ
97
+ for i, idx in enumerate(indices):
98
+ token = token_sequences[0][idx]
99
+ if not all(seq[idx] == token for seq in token_sequences):
100
+ return i
101
+ return min_len
102
+
103
+
104
+ def stack_sequences(tokens, pad_token_id):
105
+ """Stack up a list of token sequences, pad to longest on the right"""
106
+ bsz, seq_len = len(tokens), max(len(x) for x in tokens)
107
+ input_ids = torch.full((bsz, seq_len), pad_token_id, dtype=torch.long)
108
+ for i, x in enumerate(tokens):
109
+ input_ids[i, :len(x)] = torch.tensor(x, dtype=torch.long)
110
+ return input_ids
111
+
112
+
113
+ def batch_sequences_mc(tokenizer, prompts):
114
+ # In multiple choice, contexts are the same but the continuation is different (common prefix)
115
+ tokens = tokenizer(prompts, prepend=tokenizer.get_bos_token_id())
116
+ # figure out the start and end of each continuation
117
+ answer_start_idx = find_common_length(tokens, direction='left')
118
+ start_indices = [answer_start_idx] * len(prompts)
119
+ end_indices = [len(x) for x in tokens]
120
+ return tokens, start_indices, end_indices
121
+
122
+
123
+ def batch_sequences_schema(tokenizer, prompts):
124
+ # In schema tasks, contexts vary but continuation is the same (common suffix)
125
+ tokens = tokenizer(prompts, prepend=tokenizer.get_bos_token_id())
126
+ # figure out the start and end of each context
127
+ suffix_length = find_common_length(tokens, direction='right')
128
+ end_indices = [len(x) for x in tokens]
129
+ start_indices = [ei - suffix_length for ei in end_indices]
130
+ return tokens, start_indices, end_indices
131
+
132
+
133
+ def batch_sequences_lm(tokenizer, prompts):
134
+ # In LM tasks, we have two prompts: without and with continuation
135
+ tokens = tokenizer(prompts, prepend=tokenizer.get_bos_token_id())
136
+ tokens_without, tokens_with = tokens
137
+ start_idx, end_idx = len(tokens_without), len(tokens_with)
138
+ assert start_idx < end_idx, "prompt without is supposed to be a prefix of prompt with"
139
+ assert tokens_without == tokens_with[:start_idx], "prompt without is supposed to be a prefix of prompt with"
140
+ # we only need the with continuation prompt in the LM task, i.e. batch size of 1
141
+ return [tokens_with], [start_idx], [end_idx]
142
+
143
+
144
+ @torch.no_grad()
145
+ def forward_model(model, input_ids):
146
+ """
147
+ Take BxT tensor of token ids, return BxT tensor of losses and argmax predictions.
148
+ The last column of losses is set to nan because we don't have autoregressive targets there.
149
+ """
150
+ batch_size, seq_len = input_ids.size()
151
+ outputs = model(input_ids)
152
+ # Roll the tensor to the left by one position to get the (autoregressive) target ids
153
+ target_ids = torch.roll(input_ids, shifts=-1, dims=1)
154
+ # Calculate cross entropy at all positions
155
+ losses = torch.nn.functional.cross_entropy(
156
+ outputs.view(batch_size * seq_len, -1),
157
+ target_ids.view(batch_size * seq_len),
158
+ reduction='none'
159
+ ).view(batch_size, seq_len)
160
+ # Set the last column to be nan because there is no autoregressive loss there
161
+ losses[:, -1] = float('nan')
162
+ # Get the argmax predictions at each position
163
+ predictions = outputs.argmax(dim=-1)
164
+ return losses, predictions
165
+
166
+
167
+ @torch.no_grad()
168
+ def evaluate_example(idx, model, tokenizer, data, device, task_meta):
169
+ """Evaluate a single example, return True if correct, False otherwise"""
170
+ item = data[idx]
171
+ task_type = task_meta['task_type']
172
+ num_fewshot = task_meta['num_fewshot']
173
+ continuation_delimiter = task_meta['continuation_delimiter']
174
+
175
+ # Sample few-shot examples (excluding current item)
176
+ fewshot_examples = []
177
+ if num_fewshot > 0:
178
+ rng = random.Random(1234 + idx)
179
+ available_indices = [i for i in range(len(data)) if i != idx]
180
+ fewshot_indices = rng.sample(available_indices, num_fewshot)
181
+ fewshot_examples = [data[i] for i in fewshot_indices]
182
+
183
+ # Render prompts and batch sequences based on task type
184
+ if task_type == 'multiple_choice':
185
+ prompts = render_prompts_mc(item, continuation_delimiter, fewshot_examples)
186
+ tokens, start_idxs, end_idxs = batch_sequences_mc(tokenizer, prompts)
187
+ elif task_type == 'schema':
188
+ prompts = render_prompts_schema(item, continuation_delimiter, fewshot_examples)
189
+ tokens, start_idxs, end_idxs = batch_sequences_schema(tokenizer, prompts)
190
+ elif task_type == 'language_modeling':
191
+ prompts = render_prompts_lm(item, continuation_delimiter, fewshot_examples)
192
+ tokens, start_idxs, end_idxs = batch_sequences_lm(tokenizer, prompts)
193
+ else:
194
+ raise ValueError(f"Unsupported task type: {task_type}")
195
+
196
+ # Some models can't forward sequences beyond a certain length (e.g. GPT-2)
197
+ # In these cases, we have to truncate sequences to max length and adjust the indices
198
+ if hasattr(model, 'max_seq_len') and model.max_seq_len is not None:
199
+ max_tokens = model.max_seq_len
200
+ new_tokens, new_start_idxs, new_end_idxs = [], [], []
201
+ for t, s, e in zip(tokens, start_idxs, end_idxs):
202
+ if len(t) > max_tokens:
203
+ num_to_crop = len(t) - max_tokens
204
+ new_tokens.append(t[-max_tokens:]) # take the last max_tokens tokens
205
+ new_start_idxs.append(s - num_to_crop) # shift the indices down
206
+ new_end_idxs.append(e - num_to_crop)
207
+ assert s - num_to_crop >= 0, "this should never happen right?"
208
+ assert e - num_to_crop >= 0, "this should never happen right?"
209
+ else:
210
+ new_tokens.append(t) # keep unchanged
211
+ new_start_idxs.append(s)
212
+ new_end_idxs.append(e)
213
+ tokens, start_idxs, end_idxs = new_tokens, new_start_idxs, new_end_idxs
214
+
215
+ # Stack up all the sequences into a batch
216
+ pad_token_id = tokenizer.get_bos_token_id() # use BOS as pad token is ok
217
+ input_ids = stack_sequences(tokens, pad_token_id)
218
+ input_ids = input_ids.to(device)
219
+
220
+ # Forward the model, get the autoregressive loss and argmax prediction at each token
221
+ losses, predictions = forward_model(model, input_ids)
222
+
223
+ # See if the losses/predictions come out correctly
224
+ if task_type == 'language_modeling':
225
+ # language modeling task is currently always batch size 1
226
+ si = start_idxs[0]
227
+ ei = end_idxs[0]
228
+ # predictions[i] predict input_ids[i+1] autoregressively
229
+ predicted_tokens = predictions[0, si-1:ei-1]
230
+ actual_tokens = input_ids[0, si:ei]
231
+ is_correct = torch.all(predicted_tokens == actual_tokens).item()
232
+ elif task_type in ['multiple_choice', 'schema']:
233
+ # For MC/schema: find the option with lowest average loss
234
+ mean_losses = [losses[i, si-1:ei-1].mean().item()
235
+ for i, (si, ei) in enumerate(zip(start_idxs, end_idxs))]
236
+ pred_idx = mean_losses.index(min(mean_losses))
237
+ is_correct = pred_idx == item['gold']
238
+ else:
239
+ raise ValueError(f"Unsupported task type: {task_type}")
240
+
241
+ return is_correct
242
+
243
+
244
+ def evaluate_task(model, tokenizer, data, device, task_meta):
245
+ """
246
+ This function is responsible for evaluating one task across many examples.
247
+ It also handles dispatch to all processes if the script is run with torchrun.
248
+ """
249
+ rank = dist.get_rank() if dist.is_initialized() else 0
250
+ world_size = dist.get_world_size() if dist.is_initialized() else 1
251
+ correct = torch.zeros(len(data), dtype=torch.float32, device=device)
252
+ # stride the examples to each rank
253
+ for idx in range(rank, len(data), world_size):
254
+ is_correct = evaluate_example(idx, model, tokenizer, data, device, task_meta)
255
+ correct[idx] = float(is_correct)
256
+ # sync results across all the processes if running distributed
257
+ if world_size > 1:
258
+ dist.barrier()
259
+ dist.all_reduce(correct, op=dist.ReduceOp.SUM)
260
+ # compute the mean
261
+ mean_correct = correct.mean().item()
262
+ return mean_correct
nanochat/dataloader.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Distributed dataloaders for pretraining.
3
+
4
+ BOS-aligned bestfit:
5
+ - Every row starts with BOS token
6
+ - Documents packed using best-fit algorithm to minimize cropping
7
+ - When no document fits remaining space, crops a document to fill exactly
8
+ - 100% utilization (no padding), ~35% tokens cropped at T=2048
9
+
10
+ Compared to the original tokenizing_distributed_data_loader:
11
+ BOS-aligned loses ~35% of tokens to cropping, but ensures that
12
+ there are fewer "confusing" tokens in the train/val batches as every token can
13
+ now attend back to the BOS token and sees the full context of the document.
14
+
15
+ Fallback to the original if you have very limited data AND long documents:
16
+ https://github.com/karpathy/nanochat/blob/3c3a3d7/nanochat/dataloader.py#L78-L117
17
+ """
18
+
19
+ import torch
20
+ import pyarrow.parquet as pq
21
+
22
+ from nanochat.common import get_dist_info
23
+ from nanochat.dataset import list_parquet_files
24
+
25
+ def _document_batches(split, resume_state_dict, tokenizer_batch_size):
26
+ """
27
+ Infinite iterator over document batches (list of text strings) from parquet files.
28
+
29
+ Handles DDP sharding and approximate resume. Each yield is (text_batch, (pq_idx, rg_idx, epoch))
30
+ where text_batch is a list of document strings, indices track position for resumption,
31
+ and epoch counts how many times we've cycled through the dataset (starts at 1).
32
+ """
33
+ ddp, ddp_rank, ddp_local_rank, ddp_world_size = get_dist_info()
34
+
35
+ warn_on_legacy = ddp_rank == 0 and split == "train" # rank 0 on train split will warn on legacy
36
+ parquet_paths = list_parquet_files(warn_on_legacy=warn_on_legacy)
37
+ assert len(parquet_paths) != 0, "No dataset parquet files found, did you run dataset.py?"
38
+ parquet_paths = parquet_paths[:-1] if split == "train" else parquet_paths[-1:]
39
+
40
+ resume_pq_idx = resume_state_dict["pq_idx"] if resume_state_dict is not None else 0
41
+ resume_rg_idx = resume_state_dict["rg_idx"] if resume_state_dict is not None else None
42
+ resume_epoch = resume_state_dict.get("epoch", 1) if resume_state_dict is not None else 1
43
+ first_pass = True
44
+ pq_idx = resume_pq_idx
45
+ epoch = resume_epoch
46
+
47
+ while True: # iterate infinitely (multi-epoch)
48
+ pq_idx = resume_pq_idx if first_pass else 0
49
+ while pq_idx < len(parquet_paths):
50
+ filepath = parquet_paths[pq_idx]
51
+ pf = pq.ParquetFile(filepath)
52
+ # Start from resume point if resuming on same file, otherwise from DDP rank
53
+ if first_pass and (resume_rg_idx is not None) and (pq_idx == resume_pq_idx):
54
+ base_idx = resume_rg_idx // ddp_world_size
55
+ base_idx += 1 # advance by 1 so we don't repeat data after resuming
56
+ rg_idx = base_idx * ddp_world_size + ddp_rank
57
+ if rg_idx >= pf.num_row_groups:
58
+ pq_idx += 1
59
+ continue
60
+ resume_rg_idx = None # only do this once
61
+ else:
62
+ rg_idx = ddp_rank
63
+ while rg_idx < pf.num_row_groups:
64
+ rg = pf.read_row_group(rg_idx)
65
+ batch = rg.column('text').to_pylist()
66
+ for i in range(0, len(batch), tokenizer_batch_size):
67
+ yield batch[i:i+tokenizer_batch_size], (pq_idx, rg_idx, epoch)
68
+ rg_idx += ddp_world_size
69
+ pq_idx += 1
70
+ first_pass = False
71
+ epoch += 1
72
+
73
+
74
+ def tokenizing_distributed_data_loader_with_state_bos_bestfit(
75
+ tokenizer, B, T, split,
76
+ tokenizer_threads=4, tokenizer_batch_size=128,
77
+ device="cuda", resume_state_dict=None,
78
+ buffer_size=1000
79
+ ):
80
+ """
81
+ BOS-aligned dataloader with Best-Fit Cropping.
82
+
83
+ Reduces token waste compared to simple greedy cropping by searching a buffer
84
+ for documents that fit well, while maintaining 100% utilization (no padding).
85
+
86
+ Algorithm for each row:
87
+ 1. From buffered docs, pick the LARGEST doc that fits entirely
88
+ 2. Repeat until no doc fits
89
+ 3. When nothing fits, crop a doc to fill remaining space exactly
90
+
91
+ Key properties:
92
+ - Every row starts with BOS
93
+ - 100% utilization (no padding, every token is trained on)
94
+ - Approximately 35% of all tokens are discarded due to cropping
95
+ """
96
+ assert split in ["train", "val"], "split must be 'train' or 'val'"
97
+
98
+ row_capacity = T + 1
99
+ batches = _document_batches(split, resume_state_dict, tokenizer_batch_size)
100
+ bos_token = tokenizer.get_bos_token_id()
101
+ doc_buffer = []
102
+ pq_idx, rg_idx, epoch = 0, 0, 1
103
+
104
+ def refill_buffer():
105
+ nonlocal pq_idx, rg_idx, epoch
106
+ doc_batch, (pq_idx, rg_idx, epoch) = next(batches)
107
+ token_lists = tokenizer.encode(doc_batch, prepend=bos_token, num_threads=tokenizer_threads)
108
+ for tokens in token_lists:
109
+ doc_buffer.append(tokens)
110
+
111
+ # Pre-allocate buffers once: layout is [inputs (B*T) | targets (B*T)]
112
+ # This gives us contiguous views and a single HtoD transfer
113
+ use_cuda = device == "cuda"
114
+ row_buffer = torch.empty((B, row_capacity), dtype=torch.long) # for building rows without creating Python lists
115
+ cpu_buffer = torch.empty(2 * B * T, dtype=torch.long, pin_memory=use_cuda) # staging area (CPU)
116
+ gpu_buffer = torch.empty(2 * B * T, dtype=torch.long, device=device) # on-device buffer
117
+ cpu_inputs = cpu_buffer[:B * T].view(B, T) # a few views into these buffers just for convenience
118
+ cpu_targets = cpu_buffer[B * T:].view(B, T)
119
+ inputs = gpu_buffer[:B * T].view(B, T)
120
+ targets = gpu_buffer[B * T:].view(B, T)
121
+
122
+ while True:
123
+ for row_idx in range(B):
124
+ pos = 0
125
+ while pos < row_capacity:
126
+ # Ensure buffer has documents
127
+ while len(doc_buffer) < buffer_size:
128
+ refill_buffer()
129
+
130
+ remaining = row_capacity - pos
131
+
132
+ # Find largest doc that fits entirely
133
+ best_idx = -1
134
+ best_len = 0
135
+ for i, doc in enumerate(doc_buffer):
136
+ doc_len = len(doc)
137
+ if doc_len <= remaining and doc_len > best_len:
138
+ best_idx = i
139
+ best_len = doc_len
140
+
141
+ if best_idx >= 0:
142
+ doc = doc_buffer.pop(best_idx)
143
+ doc_len = len(doc)
144
+ row_buffer[row_idx, pos:pos + doc_len] = torch.tensor(doc, dtype=torch.long)
145
+ pos += doc_len
146
+ else:
147
+ # No doc fits - crop shortest in buffer to fill remaining and minimize waste
148
+ shortest_idx = min(range(len(doc_buffer)), key=lambda i: len(doc_buffer[i]))
149
+ doc = doc_buffer.pop(shortest_idx)
150
+ row_buffer[row_idx, pos:pos + remaining] = torch.tensor(doc[:remaining], dtype=torch.long)
151
+ pos += remaining
152
+
153
+ # Copy to pinned CPU buffer, then single HtoD transfer
154
+ cpu_inputs.copy_(row_buffer[:, :-1])
155
+ cpu_targets.copy_(row_buffer[:, 1:])
156
+
157
+ state_dict = {"pq_idx": pq_idx, "rg_idx": rg_idx, "epoch": epoch}
158
+
159
+ # Single HtoD copy into persistent GPU buffer and yield
160
+ gpu_buffer.copy_(cpu_buffer, non_blocking=use_cuda)
161
+ yield inputs, targets, state_dict
162
+
163
+ def tokenizing_distributed_data_loader_bos_bestfit(*args, **kwargs):
164
+ """Helper that omits state_dict from yields."""
165
+ for inputs, targets, state_dict in tokenizing_distributed_data_loader_with_state_bos_bestfit(*args, **kwargs):
166
+ yield inputs, targets
nanochat/dataset.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The base/pretraining dataset is a set of parquet files.
3
+ This file contains utilities for:
4
+ - iterating over the parquet files and yielding documents from it
5
+ - download the files on demand if they are not on disk
6
+
7
+ For details of how the dataset was prepared, see `repackage_data_reference.py`.
8
+ """
9
+
10
+ import os
11
+ import argparse
12
+ import time
13
+ import requests
14
+ import pyarrow.parquet as pq
15
+ from multiprocessing import Pool
16
+
17
+ from nanochat.common import get_base_dir
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # The specifics of the current pretraining dataset
21
+
22
+ # The URL on the internet where the data is hosted and downloaded from on demand
23
+ BASE_URL = "https://huggingface.co/datasets/karpathy/climbmix-400b-shuffle/resolve/main"
24
+ MAX_SHARD = 6542 # the last datashard is shard_06542.parquet
25
+ index_to_filename = lambda index: f"shard_{index:05d}.parquet" # format of the filenames
26
+ base_dir = get_base_dir()
27
+ DATA_DIR = os.path.join(base_dir, "base_data_climbmix")
28
+
29
+ # -----------------------------------------------------------------------------
30
+ # These functions are useful utilities to other modules, can/should be imported
31
+
32
+ def list_parquet_files(data_dir=None, warn_on_legacy=False):
33
+ """ Looks into a data dir and returns full paths to all parquet files. """
34
+ data_dir = DATA_DIR if data_dir is None else data_dir
35
+
36
+ # Legacy-supporting code due to the upgrade from FinewebEdu-100B to ClimbMix-400B
37
+ # This code will eventually be deleted.
38
+ if not os.path.exists(data_dir):
39
+ if warn_on_legacy:
40
+ print()
41
+ print("=" * 80)
42
+ print(" WARNING: DATASET UPGRADE REQUIRED")
43
+ print("=" * 80)
44
+ print()
45
+ print(f" Could not find: {data_dir}")
46
+ print()
47
+ print(" nanochat recently switched from FinewebEdu-100B to ClimbMix-400B.")
48
+ print(" Everyone who does `git pull` as of March 4, 2026 is expected to see this message.")
49
+ print(" To upgrade to the new ClimbMix-400B dataset, run these two commands:")
50
+ print()
51
+ print(" python -m nanochat.dataset -n 170 # download ~170 shards, enough for GPT-2, adjust as desired")
52
+ print(" python -m scripts.tok_train # re-train tokenizer on new ClimbMix data")
53
+ print()
54
+ print(" For now, falling back to your old FinewebEdu-100B dataset...")
55
+ print("=" * 80)
56
+ print()
57
+ # attempt a fallback to the legacy data directory
58
+ data_dir = os.path.join(base_dir, "base_data")
59
+
60
+ parquet_files = sorted([
61
+ f for f in os.listdir(data_dir)
62
+ if f.endswith('.parquet') and not f.endswith('.tmp')
63
+ ])
64
+ parquet_paths = [os.path.join(data_dir, f) for f in parquet_files]
65
+ return parquet_paths
66
+
67
+ def parquets_iter_batched(split, start=0, step=1):
68
+ """
69
+ Iterate through the dataset, in batches of underlying row_groups for efficiency.
70
+ - split can be "train" or "val". the last parquet file will be val.
71
+ - start/step are useful for skipping rows in DDP. e.g. start=rank, step=world_size
72
+ """
73
+ assert split in ["train", "val"], "split must be 'train' or 'val'"
74
+ parquet_paths = list_parquet_files()
75
+ parquet_paths = parquet_paths[:-1] if split == "train" else parquet_paths[-1:]
76
+ for filepath in parquet_paths:
77
+ pf = pq.ParquetFile(filepath)
78
+ for rg_idx in range(start, pf.num_row_groups, step):
79
+ rg = pf.read_row_group(rg_idx)
80
+ texts = rg.column('text').to_pylist()
81
+ yield texts
82
+
83
+ # -----------------------------------------------------------------------------
84
+ def download_single_file(index):
85
+ """ Downloads a single file index, with some backoff """
86
+
87
+ # Construct the local filepath for this file and skip if it already exists
88
+ filename = index_to_filename(index)
89
+ filepath = os.path.join(DATA_DIR, filename)
90
+ if os.path.exists(filepath):
91
+ print(f"Skipping {filepath} (already exists)")
92
+ return True
93
+
94
+ # Construct the remote URL for this file
95
+ url = f"{BASE_URL}/{filename}"
96
+ print(f"Downloading {filename}...")
97
+
98
+ # Download with retries
99
+ max_attempts = 5
100
+ for attempt in range(1, max_attempts + 1):
101
+ try:
102
+ response = requests.get(url, stream=True, timeout=30)
103
+ response.raise_for_status()
104
+ # Write to temporary file first
105
+ temp_path = filepath + f".tmp"
106
+ with open(temp_path, 'wb') as f:
107
+ for chunk in response.iter_content(chunk_size=1024 * 1024): # 1MB chunks
108
+ if chunk:
109
+ f.write(chunk)
110
+ # Move temp file to final location
111
+ os.rename(temp_path, filepath)
112
+ print(f"Successfully downloaded {filename}")
113
+ return True
114
+
115
+ except (requests.RequestException, IOError) as e:
116
+ print(f"Attempt {attempt}/{max_attempts} failed for {filename}: {e}")
117
+ # Clean up any partial files
118
+ for path in [filepath + f".tmp", filepath]:
119
+ if os.path.exists(path):
120
+ try:
121
+ os.remove(path)
122
+ except:
123
+ pass
124
+ # Try a few times with exponential backoff: 2^attempt seconds
125
+ if attempt < max_attempts:
126
+ wait_time = 2 ** attempt
127
+ print(f"Waiting {wait_time} seconds before retry...")
128
+ time.sleep(wait_time)
129
+ else:
130
+ print(f"Failed to download {filename} after {max_attempts} attempts")
131
+ return False
132
+
133
+ return False
134
+
135
+
136
+ if __name__ == "__main__":
137
+ parser = argparse.ArgumentParser(description="Download pretraining dataset shards")
138
+ parser.add_argument("-n", "--num-files", type=int, default=-1, help="Number of train shards to download (default: -1), -1 = disable")
139
+ parser.add_argument("-w", "--num-workers", type=int, default=4, help="Number of parallel download workers (default: 4)")
140
+ args = parser.parse_args()
141
+
142
+ # Prepare the output directory
143
+ os.makedirs(DATA_DIR, exist_ok=True)
144
+
145
+ # The way this works is that the user specifies the number of train shards to download via the -n flag.
146
+ # In addition to that, the validation shard is *always* downloaded and is pinned to be the last shard.
147
+ num_train_shards = MAX_SHARD if args.num_files == -1 else min(args.num_files, MAX_SHARD)
148
+ ids_to_download = list(range(num_train_shards))
149
+ ids_to_download.append(MAX_SHARD) # always download the validation shard
150
+
151
+ # Download the shards
152
+ print(f"Downloading {len(ids_to_download)} shards using {args.num_workers} workers...")
153
+ print(f"Target directory: {DATA_DIR}")
154
+ print()
155
+ with Pool(processes=args.num_workers) as pool:
156
+ results = pool.map(download_single_file, ids_to_download)
157
+
158
+ # Report results
159
+ successful = sum(1 for success in results if success)
160
+ print(f"Done! Downloaded: {successful}/{len(ids_to_download)} shards to {DATA_DIR}")
nanochat/distill/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """
2
+ Cross-Architecture Weight Translation (CAWT): distills a large teacher transformer
3
+ (Model A, e.g. Gemma-12B) into the LFM2-Quantum student architecture (Model B) by
4
+ training a converter network (Model C) that maps A's parameters into an
5
+ initialization for B, then fine-tuning B directly. See scripts/distill_train.py.
6
+ """
nanochat/distill/converter.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Converter Model C (CAWT Sec 2.2-2.3): translates Model A's parameters into an
3
+ initialization for Model B's weight tensors via cross-attention, so B "inherits"
4
+ A's weights instead of being trained on A's outputs alone.
5
+
6
+ Design, matching the algorithm description:
7
+ - Source weight tokens (Sec 2.1) are embedded with a trainable value projection
8
+ plus role/layer/chunk structural embeddings -- see weight_tokens.WeightCorpus for
9
+ the (frozen) chunking step this consumes.
10
+ - Each target tensor is produced by a bank of learned query tokens (one per output
11
+ chunk) that cross-attend over a *relevance window* of source tokens (Sec 2.2): the
12
+ top-k source layers under a learned soft layer-correspondence matrix M (Sec 2.3),
13
+ weighted into the attention scores as an additive log-bias so gradient descent can
14
+ still refine which of the top-k layers matters most. Role compatibility is not
15
+ hand-coded (Sec 2.4): query and source tokens both carry role embeddings, so the
16
+ cross-attention itself learns which teacher roles are relevant to which student
17
+ roles (e.g. a quantum FFN's theta angles have no analytic teacher counterpart, so
18
+ this is left entirely to learned attention + Phase 2 fine-tuning).
19
+ - This bounds cost to O(|theta_B| * k) source tokens attended per target tensor,
20
+ not O(|theta_A| * |theta_B|), which is what makes a 12B-parameter teacher feasible.
21
+ """
22
+
23
+ import math
24
+ from dataclasses import dataclass, field
25
+ from typing import Dict, List, Optional, Tuple
26
+
27
+ import torch
28
+ import torch.nn as nn
29
+ import torch.nn.functional as F
30
+
31
+ from nanochat.gpt import norm # parameter-free RMSNorm, reused for style consistency
32
+ from .roles import NUM_ROLES, ROLE_TO_ID
33
+ from .weight_tokens import WeightCorpus
34
+
35
+
36
+ def sinusoidal_features(frac: torch.Tensor, dim: int) -> torch.Tensor:
37
+ """Continuous positional features for a scalar in [0, 1] (layer depth fraction or
38
+ intra-tensor chunk fraction). Used instead of a per-absolute-position embedding
39
+ table so the converter's size does not depend on the teacher's or student's depth
40
+ (needed for the Phase 3 amortization check: reusing C on a different teacher)."""
41
+ half = dim // 2
42
+ freqs = torch.exp(torch.linspace(0, math.log(1000.0), half, device=frac.device, dtype=torch.float32))
43
+ angles = frac.float().unsqueeze(-1) * freqs
44
+ feats = torch.cat([torch.sin(angles), torch.cos(angles)], dim=-1)
45
+ if feats.shape[-1] < dim:
46
+ feats = F.pad(feats, (0, dim - feats.shape[-1]))
47
+ return feats
48
+
49
+
50
+ def gaussian_bump_layer_map(student_layers: int, teacher_layers: int, tau: float) -> torch.Tensor:
51
+ """Pre-softmax init for the layer-correspondence matrix M (Sec 2.3): student layer j
52
+ starts out attending most to the teacher layer at the same relative depth."""
53
+ j = torch.arange(student_layers).float().unsqueeze(1) / max(student_layers - 1, 1)
54
+ i = torch.arange(teacher_layers).float().unsqueeze(0) / max(teacher_layers - 1, 1)
55
+ return -((i - j) ** 2) / max(tau, 1e-6)
56
+
57
+
58
+ @dataclass
59
+ class ConverterConfig:
60
+ chunk_dim: int = 1024
61
+ d_model: int = 512
62
+ n_heads: int = 8
63
+ n_decoder_blocks: int = 4
64
+ svd_rank: int = 64
65
+ top_k_source_layers: int = 4
66
+ layer_map_tau: float = 0.5
67
+ translate_quantum_theta: bool = True
68
+ reg_target_std: float = 0.02
69
+
70
+ def __post_init__(self):
71
+ assert self.d_model % self.n_heads == 0, "d_model must be divisible by n_heads"
72
+
73
+
74
+ class _DecoderBlock(nn.Module):
75
+ """Self-attention among a tensor's own queries (keeps chunks of one output
76
+ coherent with each other) + cross-attention into the source weight-token window
77
+ + a small MLP. Pre-norm, no biases, no learnable norm scale -- mirrors the style of
78
+ nanochat/gpt.py's transformer blocks."""
79
+
80
+ def __init__(self, d_model: int, n_heads: int):
81
+ super().__init__()
82
+ self.n_heads = n_heads
83
+ self.self_q = nn.Linear(d_model, d_model, bias=False)
84
+ self.self_k = nn.Linear(d_model, d_model, bias=False)
85
+ self.self_v = nn.Linear(d_model, d_model, bias=False)
86
+ self.self_o = nn.Linear(d_model, d_model, bias=False)
87
+ self.cross_q = nn.Linear(d_model, d_model, bias=False)
88
+ self.cross_k = nn.Linear(d_model, d_model, bias=False)
89
+ self.cross_v = nn.Linear(d_model, d_model, bias=False)
90
+ self.cross_o = nn.Linear(d_model, d_model, bias=False)
91
+ self.mlp_fc = nn.Linear(d_model, 4 * d_model, bias=False)
92
+ self.mlp_proj = nn.Linear(4 * d_model, d_model, bias=False)
93
+
94
+ def _mha(self, q_in, kv_in, q_lin, k_lin, v_lin, o_lin, attn_bias=None):
95
+ Tq, Tk = q_in.shape[0], kv_in.shape[0]
96
+ H = self.n_heads
97
+ d = q_in.shape[-1]
98
+ hd = d // H
99
+ q = q_lin(q_in).view(Tq, H, hd).permute(1, 0, 2).unsqueeze(0) # (1, H, Tq, hd)
100
+ k = k_lin(kv_in).view(Tk, H, hd).permute(1, 0, 2).unsqueeze(0)
101
+ v = v_lin(kv_in).view(Tk, H, hd).permute(1, 0, 2).unsqueeze(0)
102
+ mask = None
103
+ if attn_bias is not None:
104
+ mask = attn_bias.view(1, 1, Tq, Tk).expand(1, H, Tq, Tk)
105
+ out = F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
106
+ out = out.squeeze(0).permute(1, 0, 2).reshape(Tq, d)
107
+ return o_lin(out)
108
+
109
+ def forward(self, queries, source=None, source_bias=None):
110
+ x = queries
111
+ x = x + self._mha(norm(x), norm(x), self.self_q, self.self_k, self.self_v, self.self_o)
112
+ if source is not None:
113
+ x = x + self._mha(norm(x), norm(source), self.cross_q, self.cross_k, self.cross_v, self.cross_o, attn_bias=source_bias)
114
+ x = x + self.mlp_proj(F.gelu(self.mlp_fc(norm(x))))
115
+ return x
116
+
117
+
118
+ class ConverterC(nn.Module):
119
+ """
120
+ Phi: the trainable parameters of C. Deliberately small relative to either A or B
121
+ (Sec 5 budget: 50-300M) -- its size is independent of the student's depth/width
122
+ because queries and source tokens are built from role/position embeddings rather
123
+ than per-tensor learned banks.
124
+ """
125
+
126
+ def __init__(self, cfg: ConverterConfig, teacher_num_layers: int, student_num_layers: int):
127
+ super().__init__()
128
+ self.cfg = cfg
129
+ self.teacher_num_layers = teacher_num_layers
130
+ self.student_num_layers = student_num_layers
131
+ d = cfg.d_model
132
+
133
+ self.role_embed = nn.Embedding(NUM_ROLES, d)
134
+ self.value_proj = nn.Linear(cfg.chunk_dim, d)
135
+ self.src_layer_mlp = nn.Linear(d, d)
136
+ self.src_chunk_mlp = nn.Linear(d, d)
137
+ self.query_layer_mlp = nn.Linear(d, d)
138
+ self.query_chunk_mlp = nn.Linear(d, d)
139
+ self.blocks = nn.ModuleList(_DecoderBlock(d, cfg.n_heads) for _ in range(cfg.n_decoder_blocks))
140
+ self.out_head = nn.Linear(d, cfg.chunk_dim)
141
+
142
+ init_logits = gaussian_bump_layer_map(student_num_layers, teacher_num_layers, cfg.layer_map_tau)
143
+ self.layer_map_logits = nn.Parameter(init_logits) # (L_B, L_A), pre-softmax
144
+
145
+ def layer_map(self) -> torch.Tensor:
146
+ """M in the algorithm description: row-softmax, (L_B, L_A)."""
147
+ return torch.softmax(self.layer_map_logits, dim=-1)
148
+
149
+ def encode_layer_tokens(self, corpus: WeightCorpus, layer_idx: int) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor]]:
150
+ """Embeds one teacher layer's raw chunks into converter-space tokens. Done
151
+ lazily (per training step, only for layers actually selected by the top-k
152
+ relevance window) rather than once upfront, so value_proj / role_embed /
153
+ src_layer_mlp / src_chunk_mlp all receive gradients from the training loss."""
154
+ entries = corpus.layers.get(layer_idx, [])
155
+ if not entries:
156
+ return None, None
157
+ device = self.value_proj.weight.device
158
+ dtype = self.value_proj.weight.dtype
159
+ tokens, roles = [], []
160
+ for role_id, chunks in entries:
161
+ chunks = chunks.to(device=device, dtype=dtype)
162
+ n = chunks.shape[0]
163
+ vals = self.value_proj(chunks)
164
+ role_e = self.role_embed(torch.full((n,), role_id, device=device, dtype=torch.long))
165
+ layer_frac = torch.full((n,), layer_idx / max(self.teacher_num_layers - 1, 1), device=device)
166
+ chunk_frac = torch.arange(n, device=device).float() / max(n - 1, 1)
167
+ tok = (
168
+ vals
169
+ + role_e
170
+ + self.src_layer_mlp(sinusoidal_features(layer_frac, self.cfg.d_model).to(dtype))
171
+ + self.src_chunk_mlp(sinusoidal_features(chunk_frac, self.cfg.d_model).to(dtype))
172
+ )
173
+ tokens.append(tok)
174
+ roles.append(torch.full((n,), role_id, device=device, dtype=torch.long))
175
+ return torch.cat(tokens, dim=0), torch.cat(roles, dim=0)
176
+
177
+ def build_queries(self, role_id: int, layer_idx: int, num_chunks: int, device, dtype) -> torch.Tensor:
178
+ role_e = self.role_embed(torch.full((num_chunks,), role_id, device=device, dtype=torch.long))
179
+ layer_frac = torch.full((num_chunks,), layer_idx / max(self.student_num_layers - 1, 1), device=device)
180
+ chunk_frac = torch.arange(num_chunks, device=device).float() / max(num_chunks - 1, 1)
181
+ return (
182
+ role_e
183
+ + self.query_layer_mlp(sinusoidal_features(layer_frac, self.cfg.d_model).to(dtype))
184
+ + self.query_chunk_mlp(sinusoidal_features(chunk_frac, self.cfg.d_model).to(dtype))
185
+ )
186
+
187
+ def generate_tensor(self, corpus: WeightCorpus, target, source_cache: Dict[int, Tuple]) -> torch.Tensor:
188
+ """target: a student_targets.TargetSpec (key, shape, role, layer_idx)."""
189
+ numel = 1
190
+ for s in target.shape:
191
+ numel *= s
192
+ num_chunks = -(-numel // self.cfg.chunk_dim)
193
+ device = self.value_proj.weight.device
194
+ dtype = self.value_proj.weight.dtype
195
+
196
+ queries = self.build_queries(ROLE_TO_ID[target.role], target.layer_idx, num_chunks, device, dtype)
197
+
198
+ row = self.layer_map()[target.layer_idx] # (L_A,)
199
+ k = min(self.cfg.top_k_source_layers, self.teacher_num_layers)
200
+ topk_w, topk_idx = torch.topk(row, k)
201
+
202
+ src_tokens, src_bias = [], []
203
+ for w, li in zip(topk_w, topk_idx.tolist()):
204
+ if li not in source_cache:
205
+ source_cache[li] = self.encode_layer_tokens(corpus, li)
206
+ toks, _ = source_cache[li]
207
+ if toks is None:
208
+ continue
209
+ src_tokens.append(toks)
210
+ src_bias.append(torch.log(w + 1e-8).expand(toks.shape[0]))
211
+
212
+ x = queries
213
+ if src_tokens:
214
+ source = torch.cat(src_tokens, dim=0)
215
+ bias = torch.cat(src_bias, dim=0).unsqueeze(0).expand(num_chunks, -1).to(dtype)
216
+ else:
217
+ source, bias = None, None
218
+ for block in self.blocks:
219
+ x = block(x, source, bias)
220
+
221
+ out = self.out_head(x) # (num_chunks, chunk_dim)
222
+ flat = out.reshape(-1)[:numel]
223
+ return flat.view(target.shape).float()
224
+
225
+ def generate_state_dict(self, corpus: WeightCorpus, targets) -> Dict[str, torch.Tensor]:
226
+ """One call = one weight generation (theta_B <- C_phi(theta_A)), Sec 4 Phase 1/2."""
227
+ source_cache: Dict[int, Tuple] = {}
228
+ return {t.key: self.generate_tensor(corpus, t, source_cache) for t in targets}
nanochat/distill/kd_corpus.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Builds/caches the teacher-generated distillation corpus and serves aligned
3
+ (student-tokenized, teacher-tokenized) examples of it. This corpus backs both:
4
+ - L_KL's tokenizer-mismatch fallback: sequence-level KD, i.e. plain LM loss on
5
+ Model A's own generations (Sec 3).
6
+ - L_hidden: pooled hidden-state alignment needs A's and B's hidden states for
7
+ "the same" text; running each model's own tokenizer over one shared string is the
8
+ simplest way to get that without a shared vocabulary.
9
+ Examples are kept unbatched (list of single-sequence tensors) rather than padded into
10
+ a batch, since the two tokenizers produce different lengths for the same text and
11
+ padding would corrupt the mean-pooled hidden-state alignment in losses.py.
12
+ """
13
+
14
+ import json
15
+ import os
16
+
17
+ import torch
18
+
19
+
20
+ def build_or_load(teacher, prompts, cache_path: str = None, max_new_tokens: int = 256, temperature: float = 0.8):
21
+ if cache_path and os.path.exists(cache_path):
22
+ with open(cache_path) as f:
23
+ return json.load(f)
24
+ texts = teacher.generate_completions(prompts, max_new_tokens=max_new_tokens, temperature=temperature)
25
+ records = [{"prompt": p, "text": t} for p, t in zip(prompts, texts)]
26
+ if cache_path:
27
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
28
+ with open(cache_path, "w") as f:
29
+ json.dump(records, f)
30
+ return records
31
+
32
+
33
+ class KDBatcher:
34
+ """Cycles through the cached (prompt, completion) records, tokenizing each with
35
+ both tokenizers on demand."""
36
+
37
+ def __init__(self, records, student_tokenizer, teacher_hf_tokenizer, student_seq_len: int, teacher_seq_len: int, device):
38
+ assert records, "KD corpus is empty; build it first with kd_corpus.build_or_load"
39
+ self.records = records
40
+ self.student_tokenizer = student_tokenizer
41
+ self.teacher_hf_tokenizer = teacher_hf_tokenizer
42
+ self.student_seq_len = student_seq_len
43
+ self.teacher_seq_len = teacher_seq_len
44
+ self.device = device
45
+ self._idx = 0
46
+
47
+ def next_examples(self, n: int):
48
+ """Returns a list of up to n (x, y, teacher_input_ids) tuples, each shaped
49
+ (1, T) -- one unpadded sequence per example."""
50
+ examples = []
51
+ attempts = 0
52
+ while len(examples) < n and attempts < 4 * n:
53
+ attempts += 1
54
+ rec = self.records[self._idx % len(self.records)]
55
+ self._idx += 1
56
+ text = rec["prompt"] + rec["text"]
57
+ s_ids = self.student_tokenizer.encode(text, prepend="<|bos|>")[: self.student_seq_len + 1]
58
+ if len(s_ids) < 2:
59
+ continue
60
+ t_ids = self.teacher_hf_tokenizer(text, truncation=True, max_length=self.teacher_seq_len, return_tensors="pt").input_ids[0]
61
+ if t_ids.numel() < 1:
62
+ continue
63
+ x = torch.tensor(s_ids[:-1], dtype=torch.long, device=self.device).unsqueeze(0)
64
+ y = torch.tensor(s_ids[1:], dtype=torch.long, device=self.device).unsqueeze(0)
65
+ t = t_ids.to(self.device).unsqueeze(0)
66
+ examples.append((x, y, t))
67
+ return examples
nanochat/distill/losses.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Phase 1 training losses (CAWT Sec 3) beyond the plain LM cross-entropy that
3
+ GPT.forward already computes (used directly for L_task and, on the teacher-generated
4
+ corpus, for the L_KL tokenizer-mismatch fallback -- see scripts/distill_train.py).
5
+
6
+ This module implements the two losses that don't already exist elsewhere:
7
+ - L_hidden: alignment between student and teacher hidden states, reusing the
8
+ converter's layer map M as the correspondence weighting (Sec 3: "hidden alignment
9
+ and weight alignment share correspondence, a useful inductive bias").
10
+ - L_reg: magnitude regularization on generated weights, so Phase 1's B doesn't
11
+ initialize with exploding attention logits (Sec 3).
12
+ """
13
+
14
+ from typing import List
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+
20
+
21
+ def pool_hidden_states(hidden: torch.Tensor, num_pools: int) -> torch.Tensor:
22
+ """Mean-pools a (T, D) sequence into (num_pools, D) chunks along the sequence
23
+ axis. This is the tokenizer-agnostic stand-in for exact per-token alignment: A and
24
+ B tokenize the same string into different-length sequences, so there is no shared
25
+ position index to match token-for-token. Pooling to a fixed number of
26
+ position-normalized chunks gives a coarse but tokenizer-independent alignment."""
27
+ T, D = hidden.shape
28
+ if T < num_pools:
29
+ hidden = F.pad(hidden, (0, 0, 0, num_pools - T))
30
+ pooled = F.adaptive_avg_pool1d(hidden.float().t().unsqueeze(0), num_pools)
31
+ return pooled.squeeze(0).t() # (num_pools, D)
32
+
33
+
34
+ def hidden_alignment_loss(
35
+ student_hidden_by_layer: List[torch.Tensor],
36
+ teacher_hidden_by_layer: List[torch.Tensor],
37
+ layer_map: torch.Tensor,
38
+ projections: nn.ModuleList,
39
+ num_pools: int = 8,
40
+ ) -> torch.Tensor:
41
+ """
42
+ student_hidden_by_layer: list[L_B] of (1, T_B, D_B) -- one entry per student block,
43
+ e.g. captured via forward hooks on model.transformer.h[j] (see distill_train.py).
44
+ teacher_hidden_by_layer: list[L_A] of (1, T_A, D_A).
45
+ layer_map: (L_B, L_A) row-softmax correspondence, i.e. converter.layer_map().
46
+ projections: nn.ModuleList of L_B Linear(D_B -> D_A) modules (P_j in the algorithm).
47
+ """
48
+ device = layer_map.device
49
+ teacher_pooled = torch.stack([pool_hidden_states(h[0], num_pools) for h in teacher_hidden_by_layer]).to(device) # (L_A, P, D_A)
50
+
51
+ total = None
52
+ for j, h_b in enumerate(student_hidden_by_layer):
53
+ pooled_b = pool_hidden_states(h_b[0], num_pools).to(device) # (P, D_B)
54
+ projected = projections[j](pooled_b) # (P, D_A)
55
+ target = torch.einsum("i,ipd->pd", layer_map[j], teacher_pooled) # soft-mixed teacher target
56
+ term = F.mse_loss(projected, target)
57
+ total = term if total is None else total + term
58
+ return total / max(len(student_hidden_by_layer), 1)
59
+
60
+
61
+ def weight_magnitude_reg(generated_state_dict: dict, target_std: float = 0.02) -> torch.Tensor:
62
+ """Penalizes generated matmul weights whose std drifts *above* the std nanochat's
63
+ own from-scratch initializer would use for a matrix of that role (Sec 3 L_reg:
64
+ 'avoid exploding attention logits at init'). Shrinking toward zero is not
65
+ penalized -- an under-confident init is safe and gets corrected by Phase 2
66
+ fine-tuning; an over-scaled one can blow up the forward pass before that ever
67
+ happens."""
68
+ terms = []
69
+ for tensor in generated_state_dict.values():
70
+ if tensor.ndim < 2:
71
+ continue
72
+ std = tensor.float().std()
73
+ terms.append(torch.clamp(std - target_std, min=0.0) ** 2)
74
+ if not terms:
75
+ return torch.zeros((), device=next(iter(generated_state_dict.values())).device)
76
+ return torch.stack(terms).mean()
nanochat/distill/roles.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Canonical weight-tensor role vocabulary shared between teacher (Model A) and student
3
+ (Model B) parameters (CAWT Sec 2.1). Cross-attention in the converter learns role
4
+ *compatibility* through these embeddings rather than a hand-written per-pair mapping
5
+ table -- e.g. nothing here hard-codes "SwiGLU up_proj feeds quantum theta", the
6
+ converter discovers whatever correspondence minimizes the training loss.
7
+ """
8
+
9
+ ROLES = [
10
+ "attn_q",
11
+ "attn_k",
12
+ "attn_v",
13
+ "attn_o",
14
+ "ffn_gate",
15
+ "ffn_up",
16
+ "ffn_down",
17
+ "conv_in",
18
+ "conv_out",
19
+ "conv_depthwise",
20
+ "quantum_theta",
21
+ ]
22
+
23
+ ROLE_TO_ID = {name: i for i, name in enumerate(ROLES)}
24
+ NUM_ROLES = len(ROLES)
nanochat/distill/shuffle.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Diagnostic ablation (CAWT Sec 5 & 6): shuffle the *identity* of weight tokens fed to
3
+ the converter while keeping their structural metadata (layer/role/position) intact.
4
+ If C is actually reading theta_A, shuffling should crater downstream quality. If
5
+ performance survives the shuffle, C has collapsed into a hypernetwork that memorizes
6
+ theta_B purely from the training loss and ignores theta_A entirely -- i.e. the
7
+ "translation" is fake. Run this (scripts/distill_shuffle_ablation.py) before investing
8
+ in a full-scale run.
9
+ """
10
+
11
+ import copy
12
+
13
+ import torch
14
+
15
+ from .weight_tokens import WeightCorpus
16
+
17
+
18
+ def shuffle_weight_corpus(corpus: WeightCorpus, seed: int = 0) -> WeightCorpus:
19
+ """Returns a deep copy of `corpus` with every chunk's *values* permuted globally
20
+ across the whole teacher, independent of which (layer, role) they originally
21
+ belonged to. Shapes and per-entry chunk counts are preserved exactly, so this is a
22
+ drop-in replacement for the real corpus in ConverterC.generate_state_dict."""
23
+ rng = torch.Generator().manual_seed(seed)
24
+ shuffled = copy.deepcopy(corpus)
25
+
26
+ all_chunks = [chunks for entries in shuffled.layers.values() for _, chunks in entries]
27
+ if not all_chunks:
28
+ return shuffled
29
+ flat = torch.cat([c.reshape(-1) for c in all_chunks])
30
+ perm = torch.randperm(flat.numel(), generator=rng)
31
+ flat = flat[perm]
32
+
33
+ offset = 0
34
+ for entries in shuffled.layers.values():
35
+ for idx, (role_id, chunks) in enumerate(entries):
36
+ n = chunks.numel()
37
+ entries[idx] = (role_id, flat[offset : offset + n].view_as(chunks))
38
+ offset += n
39
+ return shuffled
nanochat/distill/student_targets.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Enumerates Model B's (the LFM2-Quantum student's) translatable weight tensors given a
3
+ GPTConfig, and merges converter-generated tensors with a natively-initialized
4
+ reference for everything the converter does not translate.
5
+
6
+ Translated (matmul weights participating in the forward pass -- attention Q/K/V/O,
7
+ short-conv in/depthwise/out, and FFN up/gate/down or the quantum circuit's theta
8
+ angles): these are exactly the tensors that can plausibly carry transferred knowledge.
9
+
10
+ Left at nanochat's own from-scratch init (GPT.init_weights): resid_lambdas,
11
+ x0_lambdas, smear_gate/smear_lambda, backout_lambda, ve_gate, value_embeds, and the
12
+ quantum FFN's input/output scale+bias. None of these have a teacher analog -- the
13
+ quantum calibration params in particular are deliberately near-identity/zero at init
14
+ (see QuantumMLP.reset_parameters) to preserve the model's zero-residual training
15
+ stability property, so CAWT leaves them alone rather than learning to override an
16
+ analytic choice (Sec 2.4: "do the linear algebra, don't make C learn it").
17
+ Embeddings (wte, lm_head) are not enumerated here at all -- Sec 2.5 handles those
18
+ through vocab_align.py instead of the chunked cross-attention pathway.
19
+ """
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Iterator, List, Tuple
23
+
24
+ import torch
25
+
26
+ from nanochat.gpt import GPT
27
+
28
+
29
+ @dataclass
30
+ class TargetSpec:
31
+ key: str
32
+ shape: Tuple[int, ...]
33
+ role: str
34
+ layer_idx: int
35
+
36
+
37
+ def iter_translatable_targets(config, translate_quantum_theta: bool = True) -> Iterator[TargetSpec]:
38
+ head_dim = config.n_embd // config.n_head
39
+ mixers = config.mixer_types()
40
+ ffns = config.ffn_types()
41
+ for i in range(config.n_layer):
42
+ if mixers[i] == "A":
43
+ yield TargetSpec(f"transformer.h.{i}.attn.c_q.weight", (config.n_head * head_dim, config.n_embd), "attn_q", i)
44
+ yield TargetSpec(f"transformer.h.{i}.attn.c_k.weight", (config.n_kv_head * head_dim, config.n_embd), "attn_k", i)
45
+ yield TargetSpec(f"transformer.h.{i}.attn.c_v.weight", (config.n_kv_head * head_dim, config.n_embd), "attn_v", i)
46
+ yield TargetSpec(f"transformer.h.{i}.attn.c_proj.weight", (config.n_embd, config.n_embd), "attn_o", i)
47
+ else:
48
+ yield TargetSpec(f"transformer.h.{i}.conv.in_proj.weight", (3 * config.n_embd, config.n_embd), "conv_in", i)
49
+ yield TargetSpec(f"transformer.h.{i}.conv.conv.weight", (config.n_embd, 1, config.conv_kernel), "conv_depthwise", i)
50
+ yield TargetSpec(f"transformer.h.{i}.conv.out_proj.weight", (config.n_embd, config.n_embd), "conv_out", i)
51
+
52
+ ffn_type = ffns[i]
53
+ if ffn_type == "Q":
54
+ if translate_quantum_theta:
55
+ num_qubits = config.quantum_num_qubits
56
+ num_registers = -(-config.n_embd // num_qubits) # ceil div, mirrors QuantumMLP
57
+ yield TargetSpec(f"transformer.h.{i}.mlp.theta", (config.quantum_depth, num_registers, num_qubits), "quantum_theta", i)
58
+ elif ffn_type == "S":
59
+ hidden = int(config.n_embd * 8 / 3)
60
+ hidden = ((hidden + 127) // 128) * 128 # mirrors ClassicalSwiGLU
61
+ yield TargetSpec(f"transformer.h.{i}.mlp.w1.weight", (hidden, config.n_embd), "ffn_gate", i)
62
+ yield TargetSpec(f"transformer.h.{i}.mlp.w3.weight", (hidden, config.n_embd), "ffn_up", i)
63
+ yield TargetSpec(f"transformer.h.{i}.mlp.w2.weight", (config.n_embd, hidden), "ffn_down", i)
64
+ elif ffn_type == "C":
65
+ yield TargetSpec(f"transformer.h.{i}.mlp.c_fc.weight", (4 * config.n_embd, config.n_embd), "ffn_up", i)
66
+ yield TargetSpec(f"transformer.h.{i}.mlp.c_proj.weight", (config.n_embd, 4 * config.n_embd), "ffn_down", i)
67
+ else:
68
+ raise ValueError(f"Unknown ffn type {ffn_type!r}")
69
+
70
+
71
+ def reference_state_dict(config, device="cpu") -> dict:
72
+ """A natively-initialized student (GPT.init_weights), used to source every
73
+ parameter the converter does not translate."""
74
+ with torch.device("meta"):
75
+ model = GPT(config)
76
+ model.to_empty(device=device)
77
+ model.init_weights()
78
+ return {k: v.detach().clone() for k, v in model.state_dict().items()}
79
+
80
+
81
+ def merge_generated(config, generated: dict, reference: dict = None, translate_quantum_theta: bool = True) -> dict:
82
+ """Combine converter-generated tensors with the native-init reference for every
83
+ other key, producing a complete state_dict ready for GPT.load_state_dict."""
84
+ ref = reference if reference is not None else reference_state_dict(config)
85
+ merged = dict(ref)
86
+ for spec in iter_translatable_targets(config, translate_quantum_theta):
87
+ if spec.key in generated:
88
+ merged[spec.key] = generated[spec.key].to(ref[spec.key].dtype)
89
+ return merged
nanochat/distill/teacher.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Frozen teacher (Model A) wrapper around a HuggingFace causal LM -- Gemma-12B by
3
+ default, but anything with a Llama-family decoder stack works (Gemma, Llama, Mistral,
4
+ Qwen, ...), including natively multimodal checkpoints like google/gemma-3-12b-pt whose
5
+ text config/decoder layers are nested under a `text_config`/`language_model` submodule
6
+ rather than sitting at the top level -- see `_text_config` and `_find_decoder_layers`.
7
+
8
+ `transformers` is only imported inside this module (lazily, inside __init__), so the
9
+ rest of nanochat/distill and its tests do not require it to be installed -- it is an
10
+ optional extra (`uv sync --extra distill`) since most of nanochat's core workflow
11
+ never touches a HuggingFace model.
12
+ """
13
+
14
+ import os
15
+ import time
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+
20
+ from nanochat.common import print0
21
+ from .roles import ROLE_TO_ID
22
+ from .weight_tokens import WeightCorpus
23
+
24
+ # Candidate attribute paths to a Llama-family decoder layer stack, tried in order.
25
+ # Composite/multimodal checkpoints (e.g. Gemma3's *-pt/-it 4B/12B/27B sizes, which are
26
+ # vision+text) nest the text decoder under a `language_model` submodule; plain
27
+ # text-only checkpoints (Llama, Mistral, Gemma3 1B, ...) have it directly under `model`.
28
+ _DECODER_LAYER_PATHS = [
29
+ "model.layers",
30
+ "model.language_model.layers",
31
+ "language_model.model.layers",
32
+ "language_model.layers",
33
+ "model.model.layers",
34
+ "model.text_model.layers",
35
+ "transformer.h",
36
+ ]
37
+
38
+
39
+ def _text_config(config):
40
+ """Resolves the language-model sub-config for composite/multimodal HF configs
41
+ (e.g. Gemma3Config wraps a Gemma3TextConfig under `.text_config` and exposes it
42
+ via the standard `get_text_config()` accessor); falls back to `config` itself for
43
+ plain text-only models where there is nothing to unwrap."""
44
+ if hasattr(config, "get_text_config"):
45
+ return config.get_text_config()
46
+ return getattr(config, "text_config", config)
47
+
48
+
49
+ def _find_decoder_layers(model, expected_num_layers: int) -> nn.ModuleList:
50
+ """Locates the `expected_num_layers`-long ModuleList of decoder layers, trying
51
+ known attribute paths first and falling back to a generic search over every
52
+ ModuleList in the model for one of the right length whose elements look like
53
+ decoder layers (have `self_attn` + `mlp` submodules) -- guards against silently
54
+ grabbing an unrelated stack (e.g. a vision tower) of a different length."""
55
+ for path in _DECODER_LAYER_PATHS:
56
+ obj = model
57
+ for part in path.split("."):
58
+ obj = getattr(obj, part, None)
59
+ if obj is None:
60
+ break
61
+ if obj is not None and hasattr(obj, "__len__") and len(obj) == expected_num_layers:
62
+ return obj
63
+ for _, module in model.named_modules():
64
+ if isinstance(module, nn.ModuleList) and len(module) == expected_num_layers:
65
+ first = module[0]
66
+ if hasattr(first, "self_attn") and hasattr(first, "mlp"):
67
+ return module
68
+ raise ValueError(
69
+ f"Could not locate the teacher's {expected_num_layers}-layer decoder stack "
70
+ f"(tried {_DECODER_LAYER_PATHS} and a generic module search). This teacher's "
71
+ f"architecture may nest its language-model layers differently than the "
72
+ f"Llama/Gemma family -- extend _DECODER_LAYER_PATHS in nanochat/distill/teacher.py."
73
+ )
74
+
75
+
76
+ class Teacher:
77
+ """Model A. Always frozen (requires_grad_(False) on every parameter) -- CAWT never
78
+ updates the teacher, only reads its weights and activations."""
79
+
80
+ def __init__(
81
+ self,
82
+ model_name: str,
83
+ device_map="auto",
84
+ dtype=torch.bfloat16,
85
+ load_in_4bit: bool = False,
86
+ trust_remote_code: bool = False,
87
+ ):
88
+ from transformers import AutoModelForCausalLM, AutoTokenizer
89
+ from huggingface_hub.utils import enable_progress_bars
90
+
91
+ # HF/hub progress bars (download + "Loading checkpoint shards") are on by
92
+ # default; only force them on if the user hasn't explicitly opted out via
93
+ # HF_HUB_DISABLE_PROGRESS_BARS (calling enable_progress_bars() when that's set
94
+ # just raises a UserWarning and does nothing).
95
+ if os.environ.get("HF_HUB_DISABLE_PROGRESS_BARS") != "1":
96
+ enable_progress_bars()
97
+
98
+ kwargs = dict(device_map=device_map, trust_remote_code=trust_remote_code)
99
+ if load_in_4bit:
100
+ from transformers import BitsAndBytesConfig
101
+
102
+ kwargs["quantization_config"] = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=dtype)
103
+ else:
104
+ kwargs["dtype"] = dtype # `torch_dtype` is the pre-5.x name; fall back to it below
105
+
106
+ print0(f"Loading teacher weights for {model_name} (this can take a while for a multi-GB checkpoint)...")
107
+ t0 = time.time()
108
+ try:
109
+ self.model = AutoModelForCausalLM.from_pretrained(model_name, **kwargs)
110
+ except TypeError:
111
+ if "dtype" in kwargs:
112
+ kwargs["torch_dtype"] = kwargs.pop("dtype")
113
+ self.model = AutoModelForCausalLM.from_pretrained(model_name, **kwargs)
114
+ else:
115
+ raise
116
+ self.model.eval()
117
+ for p in self.model.parameters():
118
+ p.requires_grad_(False)
119
+ print0(f"Teacher weights loaded in {time.time() - t0:.1f}s")
120
+
121
+ print0(f"Loading teacher tokenizer for {model_name}...")
122
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=trust_remote_code)
123
+ self.model_name = model_name
124
+ self.config = _text_config(self.model.config) # unwraps composite/multimodal configs (e.g. Gemma3)
125
+ self.num_layers = self.config.num_hidden_layers
126
+ self.hidden_size = self.config.hidden_size
127
+ self.layers = _find_decoder_layers(self.model, self.num_layers)
128
+
129
+ def get_device(self):
130
+ return next(self.model.parameters()).device
131
+
132
+ @staticmethod
133
+ def _dense_weight(module: nn.Module) -> torch.Tensor:
134
+ """Returns `module.weight` as a normal, correctly-shaped (out, in) float
135
+ tensor. Under --teacher-load-in-4bit, transformers replaces every nn.Linear
136
+ with bitsandbytes' Linear4bit, whose `.weight` is a `Params4bit` -- a *packed*
137
+ buffer whose physical shape does not match the logical (out, in) matrix.
138
+ Treating it as a plain tensor (e.g. multiplying it against a norm's scale
139
+ vector) doesn't raise a shape error; broadcasting silently expands it into an
140
+ enormous, nonsensical tensor instead. Detect and dequantize it first."""
141
+ weight = module.weight
142
+ quant_state = getattr(weight, "quant_state", None)
143
+ if quant_state is not None:
144
+ # Params4bit.dequantize() (the convenience method) returns the packed
145
+ # flat shape rather than the logical (out, in) matrix on this bitsandbytes
146
+ # version -- use the lower-level functional API, which reshapes correctly.
147
+ from bitsandbytes.functional import dequantize_4bit
148
+
149
+ return dequantize_4bit(weight.data, quant_state).float()
150
+ return weight.data.float()
151
+
152
+ @staticmethod
153
+ def _fold_input_norm(linear_weight: torch.Tensor, norm_weight: torch.Tensor, gemma_style: bool = True) -> torch.Tensor:
154
+ """Absorbs an RMSNorm gamma into the Linear that CONSUMES its output:
155
+ y = norm(x) * gamma @ W^T == x_normed @ (W * gamma)^T (broadcast over the
156
+ Linear's input dim, i.e. column-scale). Gemma's RMSNorm uses a (1 + weight)
157
+ scale (its weight is init'd to 0 = identity); set gemma_style=False for a
158
+ plain-gamma RMSNorm (e.g. Llama)."""
159
+ scale = (1.0 + norm_weight.data.float()) if gemma_style else norm_weight.data.float()
160
+ return linear_weight.data.float() * scale.unsqueeze(0)
161
+
162
+ @staticmethod
163
+ def _fold_output_norm(linear_weight: torch.Tensor, norm_weight: torch.Tensor, gemma_style: bool = True) -> torch.Tensor:
164
+ """Absorbs an RMSNorm gamma applied to a Linear's OUTPUT (Gemma2/3's
165
+ 'sandwich norm': post_attention_layernorm/post_feedforward_layernorm scale the
166
+ attention/MLP output before the residual add, not the next layer's input) into
167
+ that Linear's weight, row-scaling instead of column-scaling."""
168
+ scale = (1.0 + norm_weight.data.float()) if gemma_style else norm_weight.data.float()
169
+ return linear_weight.data.float() * scale.unsqueeze(1)
170
+
171
+ @torch.no_grad()
172
+ def extract_weight_corpus(self, chunk_dim: int, svd_rank: int, cache_path: str = None, gemma_style_norm: bool = True) -> WeightCorpus:
173
+ """Phase 0.1 (norm folding) + Phase 0.2 (low-rank compression, via
174
+ WeightCorpus.add) applied to every attention/FFN matrix in the teacher.
175
+
176
+ Handles both the plain Llama-style single-norm-per-sublayer layout (one norm
177
+ feeding attention, one feeding the MLP) and Gemma2/3's 4-norm "sandwich" layout
178
+ (input_layernorm -> attn -> post_attention_layernorm -> +residual;
179
+ pre_feedforward_layernorm -> mlp -> post_feedforward_layernorm -> +residual):
180
+ the RMSNorm that *feeds* a Linear is folded into its input (column-scale); a
181
+ norm applied to a sublayer's *output* before the residual add (sandwich-only)
182
+ is folded into that sublayer's last Linear's output instead (row-scale).
183
+ """
184
+ if cache_path and os.path.exists(cache_path):
185
+ print0(f"Loading cached weight corpus from {cache_path}")
186
+ return torch.load(cache_path, weights_only=False)
187
+
188
+ from tqdm.auto import tqdm
189
+
190
+ corpus = WeightCorpus(chunk_dim, self.num_layers)
191
+ for i, layer in enumerate(tqdm(self.layers, desc="Extracting teacher weight corpus (norm-fold + low-rank SVD)")):
192
+ input_ln_w = layer.input_layernorm.weight
193
+ post_attn_ln = getattr(layer, "post_attention_layernorm", None)
194
+ pre_ffn_ln = getattr(layer, "pre_feedforward_layernorm", None)
195
+ post_ffn_ln = getattr(layer, "post_feedforward_layernorm", None)
196
+ sandwich = pre_ffn_ln is not None # Gemma2/3-style 4-norm layout
197
+ if not sandwich and post_attn_ln is None:
198
+ raise ValueError(
199
+ f"Layer {i} has neither pre_feedforward_layernorm nor "
200
+ f"post_attention_layernorm; cannot determine the MLP's input norm "
201
+ f"for this architecture."
202
+ )
203
+
204
+ q = self._fold_input_norm(self._dense_weight(layer.self_attn.q_proj), input_ln_w, gemma_style_norm)
205
+ k = self._fold_input_norm(self._dense_weight(layer.self_attn.k_proj), input_ln_w, gemma_style_norm)
206
+ v = self._fold_input_norm(self._dense_weight(layer.self_attn.v_proj), input_ln_w, gemma_style_norm)
207
+ o = self._dense_weight(layer.self_attn.o_proj)
208
+ if sandwich and post_attn_ln is not None:
209
+ o = self._fold_output_norm(o, post_attn_ln.weight, gemma_style_norm)
210
+ corpus.add(i, ROLE_TO_ID["attn_q"], q, rank=svd_rank)
211
+ corpus.add(i, ROLE_TO_ID["attn_k"], k, rank=svd_rank)
212
+ corpus.add(i, ROLE_TO_ID["attn_v"], v, rank=svd_rank)
213
+ corpus.add(i, ROLE_TO_ID["attn_o"], o, rank=svd_rank)
214
+
215
+ # Non-sandwich (Llama/Gemma1-style) models feed the MLP from the same
216
+ # single norm that also (post-)follows attention (post_attention_layernorm).
217
+ ffn_input_ln_w = pre_ffn_ln.weight if sandwich else post_attn_ln.weight
218
+ gate = self._fold_input_norm(self._dense_weight(layer.mlp.gate_proj), ffn_input_ln_w, gemma_style_norm)
219
+ up = self._fold_input_norm(self._dense_weight(layer.mlp.up_proj), ffn_input_ln_w, gemma_style_norm)
220
+ down = self._dense_weight(layer.mlp.down_proj)
221
+ if sandwich and post_ffn_ln is not None:
222
+ down = self._fold_output_norm(down, post_ffn_ln.weight, gemma_style_norm)
223
+ corpus.add(i, ROLE_TO_ID["ffn_gate"], gate, rank=svd_rank)
224
+ corpus.add(i, ROLE_TO_ID["ffn_up"], up, rank=svd_rank)
225
+ corpus.add(i, ROLE_TO_ID["ffn_down"], down, rank=svd_rank)
226
+
227
+ if cache_path:
228
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
229
+ torch.save(corpus, cache_path)
230
+ return corpus
231
+
232
+ def get_embedding_matrix(self) -> torch.Tensor:
233
+ return self.model.get_input_embeddings().weight.detach()
234
+
235
+ def get_unembedding_matrix(self) -> torch.Tensor:
236
+ head = self.model.get_output_embeddings()
237
+ return head.weight.detach() if head is not None else self.get_embedding_matrix()
238
+
239
+ @torch.no_grad()
240
+ def forward_hidden(self, input_ids: torch.Tensor, attention_mask: torch.Tensor = None):
241
+ """Returns (hidden_states, logits). hidden_states is a tuple of L_A tensors
242
+ (B, T, d_A), one per transformer layer -- the embedding-layer output
243
+ (hidden_states[0] in HF's convention) is dropped since it has no student-side
244
+ counterpart in the per-layer correspondence."""
245
+ input_ids = input_ids.to(self.get_device())
246
+ if attention_mask is not None:
247
+ attention_mask = attention_mask.to(self.get_device())
248
+ out = self.model(input_ids=input_ids, attention_mask=attention_mask, output_hidden_states=True, use_cache=False)
249
+ return out.hidden_states[1:], out.logits
250
+
251
+ @torch.no_grad()
252
+ def generate_completions(self, prompts, max_new_tokens: int = 256, temperature: float = 0.8):
253
+ """Builds the sequence-level KD corpus (Sec 3 L_KL fallback: 'train on A's
254
+ generations', used because A and B use different tokenizers so per-token KL
255
+ has no shared position index to align on)."""
256
+ from tqdm.auto import tqdm
257
+
258
+ texts = []
259
+ device = self.get_device()
260
+ for prompt in tqdm(prompts, desc="Generating teacher completions (KD corpus)"):
261
+ enc = self.tokenizer(prompt, return_tensors="pt").to(device)
262
+ gen = self.model.generate(
263
+ **enc,
264
+ max_new_tokens=max_new_tokens,
265
+ do_sample=temperature > 0,
266
+ temperature=max(temperature, 1e-4),
267
+ pad_token_id=self.tokenizer.pad_token_id or self.tokenizer.eos_token_id,
268
+ )
269
+ texts.append(self.tokenizer.decode(gen[0], skip_special_tokens=True))
270
+ return texts
nanochat/distill/vocab_align.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding/tokenizer alignment (CAWT Sec 2.5). Model A (Gemma) and Model B (the
3
+ nanochat rustbpe/tiktoken tokenizer) have different vocabularies, so embeddings are
4
+ handled explicitly rather than through the chunked cross-attention pathway used for
5
+ matmul weights:
6
+
7
+ 1. Shared-token overlap: for a student token id whose surface string is a *single*
8
+ token under the teacher's tokenizer too, its teacher correspondence is that one
9
+ token id.
10
+ 2. FOCUS/WECHSEL-style fallback: for student-only tokens, the correspondence is
11
+ whatever (possibly multi-token) sequence the teacher's tokenizer produces for the
12
+ same string; the student embedding is initialized from the mean of those teacher
13
+ embeddings.
14
+ 3. Token *correspondence* (which teacher token ids map to which student token) is a
15
+ one-time, tokenizer-only computation with no learnable parameters -- see
16
+ `build_token_correspondence`. Turning that correspondence into an actual student
17
+ embedding requires a d_A -> d_B projection (`EmbeddingAligner`), which stays
18
+ trainable through Phase 1 as part of phi; Phase 2 bakes the result into a real
19
+ nn.Embedding and fine-tunes it freely.
20
+
21
+ Splitting it this way keeps the expensive part (looping over the whole vocab, calling
22
+ the HF tokenizer per token) a Phase-0-only cost: every Phase 1 step just re-applies the
23
+ small trainable projection to an already-gathered (vocab_size, teacher_dim) constant.
24
+ """
25
+
26
+ from typing import List, Tuple
27
+
28
+ import torch
29
+ import torch.nn as nn
30
+
31
+
32
+ class EmbeddingAligner(nn.Module):
33
+ """Learned d_A -> d_B projection applied to teacher embedding vectors."""
34
+
35
+ def __init__(self, teacher_dim: int, student_dim: int):
36
+ super().__init__()
37
+ self.proj = nn.Linear(teacher_dim, student_dim, bias=False)
38
+ nn.init.orthogonal_(self.proj.weight)
39
+
40
+ def forward(self, teacher_vectors: torch.Tensor) -> torch.Tensor:
41
+ return self.proj(teacher_vectors.float())
42
+
43
+
44
+ def _is_special(piece: str) -> bool:
45
+ return not piece or piece.startswith("<|")
46
+
47
+
48
+ def build_token_correspondence(student_tokenizer, teacher_hf_tokenizer, max_vocab: int = None) -> Tuple[List[List[int]], torch.Tensor]:
49
+ """One-time (Phase 0) pass over the student vocabulary. Returns:
50
+ - mapping: list of length vocab_size, mapping[sid] = list of teacher token ids
51
+ whose mean embedding should seed student token sid (empty if no correspondence
52
+ was found, e.g. for a special token).
53
+ - filled: bool tensor, filled[sid] = mapping[sid] is non-empty.
54
+ """
55
+ from tqdm.auto import tqdm
56
+
57
+ vocab_size = student_tokenizer.get_vocab_size()
58
+ n = vocab_size if max_vocab is None else min(vocab_size, max_vocab)
59
+ mapping: List[List[int]] = [[] for _ in range(vocab_size)]
60
+ filled = torch.zeros(vocab_size, dtype=torch.bool)
61
+ for sid in tqdm(range(n), desc="Building student<->teacher token correspondence"):
62
+ try:
63
+ piece = student_tokenizer.id_to_token(sid)
64
+ except Exception:
65
+ continue
66
+ if _is_special(piece):
67
+ continue
68
+ try:
69
+ teacher_ids = teacher_hf_tokenizer.encode(piece, add_special_tokens=False)
70
+ except Exception:
71
+ teacher_ids = []
72
+ if teacher_ids:
73
+ mapping[sid] = teacher_ids
74
+ filled[sid] = True
75
+ return mapping, filled
76
+
77
+
78
+ @torch.no_grad()
79
+ def gather_teacher_vectors(mapping: List[List[int]], teacher_embed_matrix: torch.Tensor, device="cpu") -> torch.Tensor:
80
+ """Applies a token correspondence to one teacher embedding matrix (input or
81
+ output embeddings -- call this twice, once per matrix, on the same `mapping`).
82
+ Rows with an empty correspondence are left as zero; the caller is expected to mask
83
+ them out with `filled` and fall back to the student's own native init there."""
84
+ d = teacher_embed_matrix.shape[-1]
85
+ out = torch.zeros(len(mapping), d)
86
+ teacher_embed_matrix = teacher_embed_matrix.to(device)
87
+ for sid, ids in enumerate(mapping):
88
+ if not ids:
89
+ continue
90
+ idx = torch.tensor(ids, device=device)
91
+ out[sid] = teacher_embed_matrix[idx].mean(dim=0).float().cpu()
92
+ return out
93
+
94
+
95
+ def apply_alignment(aligner: EmbeddingAligner, teacher_vectors: torch.Tensor, filled: torch.Tensor, native_init: torch.Tensor) -> torch.Tensor:
96
+ """Per-training-step application (differentiable w.r.t. `aligner`): projects the
97
+ precomputed teacher vectors and splices them into the rows that have a
98
+ correspondence, keeping the student's own native init everywhere else."""
99
+ projected = aligner(teacher_vectors.to(native_init.device))
100
+ mask = filled.to(native_init.device).unsqueeze(-1)
101
+ return torch.where(mask, projected.to(native_init.dtype), native_init)
nanochat/distill/weight_tokens.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chunked weight tokenization (CAWT Sec 2.1) and low-rank compression (Phase 0.2).
3
+
4
+ A full dense mapping from a 12B teacher to a much smaller student is intractable, so
5
+ every source tensor is flattened and sliced into fixed-size "weight token" chunks
6
+ instead of being consumed whole. 2D matrices are additionally factored to a low rank
7
+ via randomized SVD before chunking -- this is what keeps the token budget (and
8
+ therefore the converter's attention cost) tractable for a teacher the size of
9
+ Gemma-12B: a (4096, 4096) matrix at rank 64 is ~31x fewer elements than the dense
10
+ tensor.
11
+ """
12
+
13
+ import torch
14
+ import torch.nn.functional as F
15
+
16
+
17
+ def lowrank_factors(weight: torch.Tensor, rank: int):
18
+ """Randomized low-rank SVD: weight (out, in) ~= U_r @ V_r, U_r (out, r), V_r (r, in).
19
+ Singular values are folded into U_r so V_r's rows are directly comparable across
20
+ tensors (unit-ish scale), which keeps the weight-token embedder simpler."""
21
+ assert weight.ndim == 2, f"lowrank_factors expects a 2D matrix, got shape {tuple(weight.shape)}"
22
+ r = max(1, min(rank, min(weight.shape) - 1 if min(weight.shape) > 1 else 1))
23
+ w = weight.float()
24
+ q = min(r + 10, min(w.shape))
25
+ U, S, V = torch.svd_lowrank(w, q=q, niter=4)
26
+ U_r = U[:, :r] * S[:r].unsqueeze(0) # (out, r)
27
+ V_r = V[:, :r].t() # (r, in), since svd_lowrank gives w ~= U @ diag(S) @ V.T
28
+ return U_r.contiguous(), V_r.contiguous()
29
+
30
+
31
+ def chunk_tensor(flat: torch.Tensor, chunk_dim: int) -> torch.Tensor:
32
+ """Pad a 1D tensor to a multiple of chunk_dim and reshape to (num_chunks, chunk_dim)."""
33
+ assert flat.ndim == 1
34
+ n = flat.numel()
35
+ num_chunks = -(-n // chunk_dim) # ceil div
36
+ pad = num_chunks * chunk_dim - n
37
+ if pad > 0:
38
+ flat = F.pad(flat, (0, pad))
39
+ return flat.view(num_chunks, chunk_dim)
40
+
41
+
42
+ class WeightCorpus:
43
+ """
44
+ Frozen source weight tokens extracted from a teacher, grouped by (0-indexed)
45
+ layer. Chunks are stored on CPU in fp16 -- this is a passive, non-trainable cache;
46
+ the converter's value/role/position embeddings (which ARE trainable) are applied
47
+ lazily when a layer's tokens are needed during a training step, so gradients still
48
+ reach the embedder even though the raw chunk values themselves are frozen constants
49
+ (Model A's parameters are never updated by CAWT).
50
+ """
51
+
52
+ def __init__(self, chunk_dim: int, num_layers: int):
53
+ self.chunk_dim = chunk_dim
54
+ self.num_layers = num_layers
55
+ # layer_idx -> list[(role_id, chunks (n_i, chunk_dim) fp16 CPU tensor)]
56
+ self.layers = {i: [] for i in range(num_layers)}
57
+
58
+ def add(self, layer_idx: int, role_id: int, tensor: torch.Tensor, rank: int = None):
59
+ """Add one teacher tensor under (layer_idx, role_id). 2D tensors are
60
+ low-rank-compressed first (Phase 0.2) when `rank` is given; everything else
61
+ (1D vectors, small 3D depthwise-conv kernels, etc.) is stored densely."""
62
+ tensor = tensor.detach()
63
+ if tensor.ndim == 2 and rank is not None and rank < min(tensor.shape):
64
+ U, V = lowrank_factors(tensor, rank)
65
+ self._add_flat(layer_idx, role_id, U.reshape(-1))
66
+ self._add_flat(layer_idx, role_id, V.reshape(-1))
67
+ else:
68
+ self._add_flat(layer_idx, role_id, tensor.float().reshape(-1))
69
+
70
+ def _add_flat(self, layer_idx, role_id, flat):
71
+ chunks = chunk_tensor(flat, self.chunk_dim).to(torch.float16).cpu()
72
+ self.layers[layer_idx].append((role_id, chunks))
73
+
74
+ def num_tokens(self) -> int:
75
+ return sum(c.shape[0] for entries in self.layers.values() for _, c in entries)
76
+
77
+ def num_source_layers_with_data(self) -> int:
78
+ return sum(1 for entries in self.layers.values() if entries)
nanochat/engine.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Engine for efficient inference of our models.
3
+
4
+ Everything works around token sequences:
5
+ - The user can send token sequences to the engine
6
+ - The engine returns the next token
7
+
8
+ Notes:
9
+ - The engine knows nothing about tokenization, it's purely token id sequences.
10
+
11
+ The whole thing is made as efficient as possible.
12
+ """
13
+
14
+ import torch
15
+ import torch.nn.functional as F
16
+ import signal
17
+ import warnings
18
+ from contextlib import contextmanager
19
+ from collections import deque
20
+ from nanochat.common import compute_init, autodetect_device_type, COMPUTE_DTYPE
21
+ from nanochat.checkpoint_manager import load_model
22
+
23
+ # -----------------------------------------------------------------------------
24
+ # Calculator tool helpers
25
+ @contextmanager
26
+ def timeout(duration, formula):
27
+ def timeout_handler(signum, frame):
28
+ raise Exception(f"'{formula}': timed out after {duration} seconds")
29
+
30
+ signal.signal(signal.SIGALRM, timeout_handler)
31
+ signal.alarm(duration)
32
+ yield
33
+ signal.alarm(0)
34
+
35
+ def eval_with_timeout(formula, max_time=3):
36
+ try:
37
+ with timeout(max_time, formula):
38
+ with warnings.catch_warnings():
39
+ warnings.simplefilter("ignore", SyntaxWarning)
40
+ return eval(formula, {"__builtins__": {}}, {})
41
+ except Exception as e:
42
+ signal.alarm(0)
43
+ # print(f"Warning: Failed to eval {formula}, exception: {e}") # it's ok ignore wrong calculator usage
44
+ return None
45
+
46
+ def use_calculator(expr):
47
+ """
48
+ Evaluate a Python expression safely.
49
+ Supports both math expressions and string operations like .count()
50
+ """
51
+ # Remove commas from numbers
52
+ expr = expr.replace(",", "")
53
+
54
+ # Check if it's a pure math expression (old behavior)
55
+ if all([x in "0123456789*+-/.() " for x in expr]):
56
+ if "**" in expr: # disallow power operator
57
+ return None
58
+ return eval_with_timeout(expr)
59
+
60
+ # Check if it's a string operation we support
61
+ # Allow: strings (single/double quotes), .count(), letters, numbers, spaces, parens
62
+ allowed_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\"()._ "
63
+ if not all([x in allowed_chars for x in expr]):
64
+ return None
65
+
66
+ # Disallow dangerous patterns
67
+ dangerous_patterns = ['__', 'import', 'exec', 'eval', 'compile', 'open', 'file',
68
+ 'input', 'raw_input', 'globals', 'locals', 'vars', 'dir',
69
+ 'getattr', 'setattr', 'delattr', 'hasattr']
70
+ expr_lower = expr.lower()
71
+ if any(pattern in expr_lower for pattern in dangerous_patterns):
72
+ return None
73
+
74
+ # Only allow .count() method for now (can expand later)
75
+ if '.count(' not in expr:
76
+ return None
77
+
78
+ # Evaluate with timeout
79
+ return eval_with_timeout(expr)
80
+
81
+ # -----------------------------------------------------------------------------
82
+ class KVCache:
83
+ """
84
+ KV Cache designed for Flash Attention 3's flash_attn_with_kvcache API.
85
+
86
+ Key differences from FA2-style cache:
87
+ - Tensors are (B, T, H, D) not (B, H, T, D)
88
+ - FA3 updates the cache in-place during flash_attn_with_kvcache
89
+ - Position tracked per batch element via cache_seqlens tensor
90
+ """
91
+
92
+ def __init__(self, batch_size, num_heads, seq_len, head_dim, num_layers, device, dtype):
93
+ self.batch_size = batch_size
94
+ self.max_seq_len = seq_len
95
+ self.n_layers = num_layers
96
+ self.n_heads = num_heads
97
+ self.head_dim = head_dim
98
+ # Pre-allocate cache tensors: (n_layers, B, T, H, D)
99
+ self.k_cache = torch.zeros(num_layers, batch_size, seq_len, num_heads, head_dim, device=device, dtype=dtype)
100
+ self.v_cache = torch.zeros(num_layers, batch_size, seq_len, num_heads, head_dim, device=device, dtype=dtype)
101
+ # Current sequence length per batch element (FA3 needs int32)
102
+ self.cache_seqlens = torch.zeros(batch_size, dtype=torch.int32, device=device)
103
+ # Previous token's normalized embedding for smear (set by model forward pass)
104
+ self.prev_embedding = None
105
+ # Rolling short-conv state per conv layer: {layer_idx: (B, C, kernel-1)}.
106
+ # Empty for all-attention models; populated by ShortConvBlock during decode.
107
+ self.conv_state = {}
108
+
109
+ def reset(self):
110
+ """Reset cache to empty state."""
111
+ self.cache_seqlens.zero_()
112
+ self.prev_embedding = None
113
+ self.conv_state = {}
114
+
115
+ def get_conv_state(self, layer_idx):
116
+ """Return the cached short-conv history for a layer, or None if unset."""
117
+ return self.conv_state.get(layer_idx)
118
+
119
+ def set_conv_state(self, layer_idx, state):
120
+ """Store the short-conv history (last kernel-1 gated inputs) for a layer."""
121
+ self.conv_state[layer_idx] = state
122
+
123
+ def get_pos(self):
124
+ """Get current position (assumes all batch elements at same position)."""
125
+ return self.cache_seqlens[0].item()
126
+
127
+ def get_layer_cache(self, layer_idx):
128
+ """Return (k_cache, v_cache) views for a specific layer."""
129
+ return self.k_cache[layer_idx], self.v_cache[layer_idx]
130
+
131
+ def advance(self, num_tokens):
132
+ """Advance the cache position by num_tokens."""
133
+ self.cache_seqlens += num_tokens
134
+
135
+ def prefill(self, other):
136
+ """
137
+ Copy cached KV from another cache into this one.
138
+ Used when we do batch=1 prefill and then want to generate multiple samples in parallel.
139
+ """
140
+ assert self.get_pos() == 0, "Cannot prefill a non-empty KV cache"
141
+ assert self.n_layers == other.n_layers and self.n_heads == other.n_heads and self.head_dim == other.head_dim
142
+ assert self.max_seq_len >= other.max_seq_len
143
+ other_pos = other.get_pos()
144
+ self.k_cache[:, :, :other_pos, :, :] = other.k_cache[:, :, :other_pos, :, :]
145
+ self.v_cache[:, :, :other_pos, :, :] = other.v_cache[:, :, :other_pos, :, :]
146
+ self.cache_seqlens.fill_(other_pos)
147
+ # Copy smear state: expand batch=1 prev_embedding to num_samples
148
+ if other.prev_embedding is not None:
149
+ self.prev_embedding = other.prev_embedding.expand(self.batch_size, -1, -1).clone()
150
+ # Copy short-conv state, expanding batch=1 prefill history to num_samples
151
+ for layer_idx, state in other.conv_state.items():
152
+ self.conv_state[layer_idx] = state.expand(self.batch_size, -1, -1).clone()
153
+
154
+ # -----------------------------------------------------------------------------
155
+ @torch.inference_mode()
156
+ def sample_next_token(logits, rng, temperature=1.0, top_k=None):
157
+ """Sample a single next token from given logits of shape (B, vocab_size). Returns (B, 1)."""
158
+ assert temperature >= 0.0, "temperature must be non-negative"
159
+ if temperature == 0.0:
160
+ return torch.argmax(logits, dim=-1, keepdim=True)
161
+ if top_k is not None and top_k > 0:
162
+ k = min(top_k, logits.size(-1))
163
+ vals, idx = torch.topk(logits, k, dim=-1)
164
+ vals = vals / temperature
165
+ probs = F.softmax(vals, dim=-1)
166
+ choice = torch.multinomial(probs, num_samples=1, generator=rng)
167
+ return idx.gather(1, choice)
168
+ else:
169
+ logits = logits / temperature
170
+ probs = F.softmax(logits, dim=-1)
171
+ return torch.multinomial(probs, num_samples=1, generator=rng)
172
+
173
+ # -----------------------------------------------------------------------------
174
+
175
+ class RowState:
176
+ # Per-row state tracking during generation
177
+ def __init__(self, current_tokens=None):
178
+ self.current_tokens = current_tokens or [] # Current token sequence for this row
179
+ self.forced_tokens = deque() # Queue of tokens to force inject
180
+ self.in_python_block = False # Whether we are inside a python block
181
+ self.python_expr_tokens = [] # Tokens of the current python expression
182
+ self.completed = False # Whether this row has completed generation
183
+
184
+ class Engine:
185
+
186
+ def __init__(self, model, tokenizer):
187
+ self.model = model
188
+ self.tokenizer = tokenizer # needed for tool use
189
+
190
+ @torch.inference_mode()
191
+ def generate(self, tokens, num_samples=1, max_tokens=None, temperature=1.0, top_k=None, seed=42):
192
+ """Same as generate, but does single prefill and then clones the KV cache."""
193
+ assert isinstance(tokens, list) and isinstance(tokens[0], int), "expecting list of ints"
194
+ device = self.model.get_device()
195
+ # Allocate the KV cache in the compute dtype so it matches what the forward pass emits
196
+ dtype = COMPUTE_DTYPE
197
+ rng = torch.Generator(device=device)
198
+ rng.manual_seed(seed)
199
+
200
+ # Get the special tokens we need to coordinate the tool use state machine
201
+ get_special = lambda s: self.tokenizer.encode_special(s)
202
+ python_start = get_special("<|python_start|>")
203
+ python_end = get_special("<|python_end|>")
204
+ output_start = get_special("<|output_start|>")
205
+ output_end = get_special("<|output_end|>")
206
+ assistant_end = get_special("<|assistant_end|>") # if sampled, ends row
207
+ bos = self.tokenizer.get_bos_token_id() # if sampled, ends row
208
+
209
+ # 1) Run a batch 1 prefill of the prompt tokens
210
+ m = self.model.config
211
+ kv_model_kwargs = {"num_heads": m.n_kv_head, "head_dim": m.n_embd // m.n_head, "num_layers": m.n_layer}
212
+ kv_cache_prefill = KVCache(
213
+ batch_size=1,
214
+ seq_len=len(tokens),
215
+ device=device,
216
+ dtype=dtype,
217
+ **kv_model_kwargs,
218
+ )
219
+ ids = torch.tensor([tokens], dtype=torch.long, device=device)
220
+ logits = self.model.forward(ids, kv_cache=kv_cache_prefill)
221
+ logits = logits[:, -1, :].expand(num_samples, -1) # (num_samples, vocab_size)
222
+
223
+ # 2) Replicate the KV cache for each sample/row
224
+ kv_length_hint = (len(tokens) + max_tokens) if max_tokens is not None else self.model.config.sequence_len
225
+ kv_cache_decode = KVCache(
226
+ batch_size=num_samples,
227
+ seq_len=kv_length_hint,
228
+ device=device,
229
+ dtype=dtype,
230
+ **kv_model_kwargs,
231
+ )
232
+ kv_cache_decode.prefill(kv_cache_prefill)
233
+ del kv_cache_prefill # no need to keep this memory around
234
+
235
+ # 3) Initialize states for each sample
236
+ row_states = [RowState(tokens.copy()) for _ in range(num_samples)]
237
+
238
+ # 4) Main generation loop
239
+ num_generated = 0
240
+ while True:
241
+ # Stop condition: we've reached max tokens
242
+ if max_tokens is not None and num_generated >= max_tokens:
243
+ break
244
+ # Stop condition: all rows are completed
245
+ if all(state.completed for state in row_states):
246
+ break
247
+
248
+ # Sample the next token for each row
249
+ next_ids = sample_next_token(logits, rng, temperature, top_k) # (B, 1)
250
+ sampled_tokens = next_ids[:, 0].tolist()
251
+
252
+ # Process each row: choose the next token, update state, optional tool use
253
+ token_column = [] # contains the next token id along each row
254
+ token_masks = [] # contains the mask (was it sampled (1) or forced (0)?) along each row
255
+ for i, state in enumerate(row_states):
256
+ # Select the next token in this row
257
+ is_forced = len(state.forced_tokens) > 0 # are there tokens waiting to be forced in deque?
258
+ token_masks.append(0 if is_forced else 1) # mask is 0 if forced, 1 if sampled
259
+ next_token = state.forced_tokens.popleft() if is_forced else sampled_tokens[i]
260
+ token_column.append(next_token)
261
+ # Update the state of this row to include the next token
262
+ state.current_tokens.append(next_token)
263
+ # On <|assistant_end|> or <|bos|>, mark the row as completed
264
+ if next_token == assistant_end or next_token == bos:
265
+ state.completed = True
266
+ # Handle tool logic
267
+ if next_token == python_start:
268
+ state.in_python_block = True
269
+ state.python_expr_tokens = []
270
+ elif next_token == python_end and state.in_python_block:
271
+ state.in_python_block = False
272
+ if state.python_expr_tokens:
273
+ expr = self.tokenizer.decode(state.python_expr_tokens)
274
+ result = use_calculator(expr)
275
+ if result is not None:
276
+ result_tokens = self.tokenizer.encode(str(result))
277
+ state.forced_tokens.append(output_start)
278
+ state.forced_tokens.extend(result_tokens)
279
+ state.forced_tokens.append(output_end)
280
+ state.python_expr_tokens = []
281
+ elif state.in_python_block:
282
+ state.python_expr_tokens.append(next_token)
283
+
284
+ # Yield the token column
285
+ yield token_column, token_masks
286
+ num_generated += 1
287
+
288
+ # Prepare logits for next iteration
289
+ ids = torch.tensor(token_column, dtype=torch.long, device=device).unsqueeze(1)
290
+ logits = self.model.forward(ids, kv_cache=kv_cache_decode)[:, -1, :] # (B, vocab_size)
291
+
292
+ def generate_batch(self, tokens, num_samples=1, **kwargs):
293
+ """
294
+ Non-streaming batch generation that just returns the final token sequences.
295
+ Returns a list of token sequences (list of lists of ints).
296
+ Terminal tokens (assistant_end, bos) are not included in the results.
297
+ """
298
+ assistant_end = self.tokenizer.encode_special("<|assistant_end|>")
299
+ bos = self.tokenizer.get_bos_token_id()
300
+ results = [tokens.copy() for _ in range(num_samples)]
301
+ masks = [[0] * len(tokens) for _ in range(num_samples)]
302
+ completed = [False] * num_samples
303
+ for token_column, token_masks in self.generate(tokens, num_samples, **kwargs):
304
+ for i, (token, mask) in enumerate(zip(token_column, token_masks)):
305
+ if not completed[i]:
306
+ if token == assistant_end or token == bos:
307
+ completed[i] = True
308
+ else:
309
+ results[i].append(token)
310
+ masks[i].append(mask)
311
+ # Stop if all rows are completed
312
+ if all(completed):
313
+ break
314
+ return results, masks
315
+
316
+
317
+ if __name__ == "__main__":
318
+ """
319
+ Quick inline test to make sure that the naive/slow model.generate function
320
+ is equivalent to the faster Engine.generate function here.
321
+ """
322
+ import time
323
+ # init compute
324
+ device_type = autodetect_device_type()
325
+ ddp, ddp_rank, ddp_local_rank, ddp_world_size, device = compute_init(device_type)
326
+ # load the model and tokenizer
327
+ model, tokenizer, meta = load_model("base", device, phase="eval")
328
+ bos_token_id = tokenizer.get_bos_token_id()
329
+ # common hyperparameters
330
+ kwargs = dict(max_tokens=64, temperature=0.0)
331
+ # set the starting prompt
332
+ prompt_tokens = tokenizer.encode("The chemical formula of water is", prepend=bos_token_id)
333
+ # generate the reference sequence using the model.generate() function
334
+ generated_tokens = []
335
+ torch.cuda.synchronize()
336
+ t0 = time.time()
337
+ stream = model.generate(prompt_tokens, **kwargs)
338
+ for token in stream:
339
+ generated_tokens.append(token)
340
+ chunk = tokenizer.decode([token])
341
+ print(chunk, end="", flush=True)
342
+ print()
343
+ torch.cuda.synchronize()
344
+ t1 = time.time()
345
+ print(f"Reference time: {t1 - t0:.2f}s")
346
+ reference_ids = generated_tokens
347
+ # generate tokens with Engine
348
+ generated_tokens = []
349
+ engine = Engine(model, tokenizer)
350
+ stream = engine.generate(prompt_tokens, num_samples=1, **kwargs) # note: runs in fp32
351
+ torch.cuda.synchronize()
352
+ t0 = time.time()
353
+ for token_column, token_masks in stream:
354
+ token = token_column[0] # only print out the first row
355
+ generated_tokens.append(token)
356
+ chunk = tokenizer.decode([token])
357
+ print(chunk, end="", flush=True)
358
+ print()
359
+ torch.cuda.synchronize()
360
+ t1 = time.time()
361
+ print(f"Engine time: {t1 - t0:.2f}s")
362
+ # compare the two sequences
363
+ for i in range(len(reference_ids)):
364
+ if reference_ids[i] != generated_tokens[i]:
365
+ print(f"Mismatch at {i}: {reference_ids[i]} != {generated_tokens[i]}")
366
+ break
367
+ print(f"Match: {reference_ids == generated_tokens}")
nanochat/execution.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sandboxed execution utilities for running Python code that comes out of an LLM.
3
+ Inspired by the OpenAI HumanEval code:
4
+ https://github.com/openai/human-eval/blob/master/human_eval/execution.py
5
+
6
+ The code runs in a fresh Python subprocess. What is covered:
7
+ - Each execution runs in its own process (killed hard by the parent on timeout)
8
+ - A fresh interpreter: no access to the parent process memory, and a scrubbed environment
9
+ - Memory limits are enforced via rlimits (256MB by default)
10
+ - stdout and stderr are captured, stdin is disabled
11
+ - Code runs in a temporary directory that is deleted afterwards
12
+ - Destructive functions are disabled (examples: os.system, os.kill, shutil.rmtree, subprocess.Popen)
13
+
14
+ What is not covered:
15
+ - Not a true security sandbox
16
+ - Network access is not blocked (e.g. sockets could be opened)
17
+ - Python's dynamic features (e.g. ctypes) could bypass restrictions
18
+ - No kernel-level isolation (no seccomp, no containers, no virtualization)
19
+
20
+ Overall this sandbox is good for evaluation of generated code and protects against
21
+ accidental destructive behavior, but it is not safe against malicious adversarial code.
22
+ """
23
+
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from dataclasses import dataclass
28
+ from typing import Optional
29
+
30
+ # -----------------------------------------------------------------------------
31
+
32
+ @dataclass
33
+ class ExecutionResult:
34
+ """Result of executing Python code in a sandbox."""
35
+ success: bool
36
+ stdout: str
37
+ stderr: str
38
+ error: Optional[str] = None
39
+ timeout: bool = False
40
+ memory_exceeded: bool = False
41
+
42
+
43
+ # The guard runs in the subprocess before the untrusted code. It applies the
44
+ # resource limits and disables destructive functions to protect against
45
+ # accidents (a fork bomb, deleting files, killing other processes, ...).
46
+ # It is trivially bypassable by adversarial code, see docstring above.
47
+ GUARD = r"""
48
+ import faulthandler, builtins, os, shutil, subprocess, sys
49
+ maximum_memory_bytes = {maximum_memory_bytes}
50
+ if maximum_memory_bytes is not None and sys.platform != "darwin":
51
+ # (the resource limit calls seem to fail on macOS, skip them there)
52
+ import resource
53
+ resource.setrlimit(resource.RLIMIT_AS, (maximum_memory_bytes, maximum_memory_bytes))
54
+ resource.setrlimit(resource.RLIMIT_DATA, (maximum_memory_bytes, maximum_memory_bytes))
55
+ resource.setrlimit(resource.RLIMIT_STACK, (maximum_memory_bytes, maximum_memory_bytes))
56
+ faulthandler.disable()
57
+ builtins.exit = None
58
+ builtins.quit = None
59
+ builtins.help = None
60
+ os.environ["OMP_NUM_THREADS"] = "1"
61
+ for name in ("kill", "system", "putenv", "remove", "removedirs", "rmdir", "fchdir",
62
+ "setuid", "fork", "forkpty", "killpg", "rename", "renames", "truncate",
63
+ "replace", "unlink", "fchmod", "fchown", "chmod", "chown", "chroot",
64
+ "lchflags", "lchmod", "lchown", "getcwd", "chdir"):
65
+ setattr(os, name, None)
66
+ for name in ("rmtree", "move", "chown"):
67
+ setattr(shutil, name, None)
68
+ subprocess.Popen = None
69
+ for name in ("ipdb", "joblib", "resource", "psutil", "tkinter"):
70
+ sys.modules[name] = None
71
+ """
72
+
73
+
74
+ def execute_code(
75
+ code: str,
76
+ timeout: float = 5.0, # 5 seconds default
77
+ maximum_memory_bytes: Optional[int] = 256 * 1024 * 1024, # 256MB default
78
+ ) -> ExecutionResult:
79
+ """
80
+ Execute Python code in a sandboxed environment.
81
+
82
+ Args:
83
+ code: Python code to execute as a string
84
+ timeout: Maximum execution time in seconds (default: 5.0)
85
+ maximum_memory_bytes: Memory limit in bytes (default: 256MB, None to disable)
86
+
87
+ Returns:
88
+ ExecutionResult with success status, stdout/stderr, and error information
89
+
90
+ Example:
91
+ >>> result = execute_code("print('hello world')")
92
+ >>> result.success
93
+ True
94
+ >>> result.stdout
95
+ 'hello world\\n'
96
+ """
97
+ # the guard runs first, then the untrusted code (with fresh globals, as a repr'd literal)
98
+ guard = GUARD.format(maximum_memory_bytes=maximum_memory_bytes)
99
+ program = guard + f"\nexec(compile({code!r}, '<llm>', 'exec'), {{'__name__': '__main__'}})\n"
100
+
101
+ with tempfile.TemporaryDirectory() as tmpdir:
102
+ try:
103
+ process = subprocess.run(
104
+ [sys.executable, "-c", program],
105
+ cwd=tmpdir, # writes land in the tempdir, deleted afterwards
106
+ env={"PATH": "/usr/bin:/bin"}, # scrub the environment
107
+ stdin=subprocess.DEVNULL,
108
+ capture_output=True,
109
+ text=True,
110
+ timeout=timeout,
111
+ )
112
+ except subprocess.TimeoutExpired:
113
+ # subprocess.run kills the child process on timeout
114
+ return ExecutionResult(
115
+ success=False,
116
+ stdout="",
117
+ stderr="",
118
+ error="Execution timed out (process killed)",
119
+ timeout=True,
120
+ )
121
+
122
+ success = process.returncode == 0
123
+ stderr = process.stderr
124
+ # the last line of the traceback identifies the exception, e.g. "TypeError: ..."
125
+ error = None if success else (stderr.strip().splitlines() or ["Execution failed"])[-1]
126
+ memory_exceeded = "MemoryError" in stderr
127
+ result = ExecutionResult(
128
+ success=success,
129
+ stdout=process.stdout,
130
+ stderr=stderr,
131
+ error=error,
132
+ memory_exceeded=memory_exceeded,
133
+ )
134
+ return result
nanochat/flash_attention.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unified Flash Attention interface with automatic FA3/SDPA switching.
3
+
4
+ Exports `flash_attn` module that matches the FA3 API exactly, but falls back
5
+ to PyTorch SDPA on incompatible CUDA GPUs, MPS, and CPU.
6
+
7
+ Usage (drop-in replacement for FA3):
8
+ from nanochat.flash_attention import flash_attn
9
+
10
+ # Training (no KV cache)
11
+ y = flash_attn.flash_attn_func(q, k, v, causal=True, window_size=window_size)
12
+
13
+ # Inference (with KV cache)
14
+ y = flash_attn.flash_attn_with_kvcache(q, k_cache, v_cache, k=k, v=v, ...)
15
+ """
16
+ import torch
17
+ import torch.nn.functional as F
18
+
19
+
20
+ # =============================================================================
21
+ # Detection: Try to load FA3 on CUDA GPUs
22
+ # =============================================================================
23
+ def _load_flash_attention_3():
24
+ """Try to load Flash Attention 3."""
25
+ if not torch.cuda.is_available():
26
+ return None
27
+ try:
28
+ major, _ = torch.cuda.get_device_capability()
29
+ # FA3 kernels are currently compiled for Hopper (sm90), Ada (sm89) and Ampere (sm80/sm86)
30
+ # Blackwell (sm100) needs SDPA fallback until FA3 is recompiled or FA4 is released
31
+ import os
32
+ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
33
+ from kernels import get_kernel, has_kernel
34
+ # The varunneal kernel obtains better results for H100/Hopper
35
+ if major == 9:
36
+ hf_kernel = "varunneal/flash-attention-3"
37
+ return get_kernel(hf_kernel).flash_attn_interface
38
+ else:
39
+ hf_kernel = "kernels-community/flash-attn3"
40
+ if has_kernel(hf_kernel):
41
+ return get_kernel(hf_kernel).flash_attn_interface
42
+ else:
43
+ return None
44
+
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ _fa3 = _load_flash_attention_3()
50
+ HAS_FA3 = _fa3 is not None
51
+
52
+ # Override for testing: set to 'fa3', 'sdpa', or None (auto)
53
+ _override_impl = None
54
+
55
+
56
+ def _resolve_use_fa3():
57
+ """Decide once whether to use FA3, based on availability, override, and dtype."""
58
+ if _override_impl == 'fa3':
59
+ assert HAS_FA3, "Cannot override to FA3: not available on this hardware"
60
+ return True
61
+ if _override_impl == 'sdpa':
62
+ return False
63
+ if HAS_FA3:
64
+ # FA3 Hopper kernels only support bf16 and fp8; fp16/fp32 must use SDPA fallback
65
+ from nanochat.common import COMPUTE_DTYPE
66
+ if COMPUTE_DTYPE == torch.bfloat16:
67
+ return True
68
+ return False
69
+ return False
70
+
71
+ USE_FA3 = _resolve_use_fa3()
72
+
73
+
74
+ # =============================================================================
75
+ # SDPA helpers
76
+ # =============================================================================
77
+ def _sdpa_attention(q, k, v, window_size, enable_gqa):
78
+ """
79
+ SDPA attention with sliding window support.
80
+ q, k, v are (B, H, T, D) format.
81
+ """
82
+ Tq = q.size(2)
83
+ Tk = k.size(2)
84
+ window = window_size[0]
85
+
86
+ # Full context, same length
87
+ if (window < 0 or window >= Tq) and Tq == Tk:
88
+ return F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=enable_gqa)
89
+
90
+ # Single token generation
91
+ if Tq == 1:
92
+ if window >= 0 and window < Tk:
93
+ # window is "left" tokens we need to include (window + 1) keys total
94
+ start = max(0, Tk - (window + 1))
95
+ k = k[:, :, start:, :]
96
+ v = v[:, :, start:, :]
97
+ return F.scaled_dot_product_attention(q, k, v, is_causal=False, enable_gqa=enable_gqa)
98
+
99
+ # Need explicit mask for sliding window/chunk inference
100
+ device = q.device
101
+ # For chunk inference (Tq != Tk), is_causal is not aligned to cache position => build an explicit bool mask
102
+ row_idx = (Tk - Tq) + torch.arange(Tq, device=device).unsqueeze(1)
103
+ col_idx = torch.arange(Tk, device=device).unsqueeze(0)
104
+ mask = col_idx <= row_idx
105
+
106
+ # sliding window (left)
107
+ if window >= 0 and window < Tk:
108
+ mask = mask & ((row_idx - col_idx) <= window)
109
+
110
+ return F.scaled_dot_product_attention(q, k, v, attn_mask=mask, enable_gqa=enable_gqa)
111
+
112
+ # =============================================================================
113
+ # Public API: Same interface as FA3
114
+ # =============================================================================
115
+ def flash_attn_func(q, k, v, causal=False, window_size=(-1, -1)):
116
+ """
117
+ Flash Attention for training (no KV cache).
118
+
119
+ Args:
120
+ q, k, v: Tensors of shape (B, T, H, D)
121
+ causal: Whether to use causal masking
122
+ window_size: (left, right) sliding window. -1 means unlimited.
123
+
124
+ Returns:
125
+ Output tensor of shape (B, T, H, D)
126
+ """
127
+ if USE_FA3:
128
+ return _fa3.flash_attn_func(q, k, v, causal=causal, window_size=window_size)
129
+
130
+ # SDPA fallback: transpose (B, T, H, D) -> (B, H, T, D)
131
+ q = q.transpose(1, 2)
132
+ k = k.transpose(1, 2)
133
+ v = v.transpose(1, 2)
134
+ enable_gqa = q.size(1) != k.size(1)
135
+ y = _sdpa_attention(q, k, v, window_size, enable_gqa)
136
+ return y.transpose(1, 2) # back to (B, T, H, D)
137
+
138
+
139
+ def flash_attn_with_kvcache(q, k_cache, v_cache, k=None, v=None, cache_seqlens=None,
140
+ causal=False, window_size=(-1, -1)):
141
+ """
142
+ Flash Attention with KV cache for inference.
143
+
144
+ FA3 updates k_cache/v_cache in-place. Our SDPA fallback does the same.
145
+
146
+ Args:
147
+ q: Queries, shape (B, T_new, H, D)
148
+ k_cache, v_cache: Pre-allocated cache tensors, shape (B, T_max, H_kv, D)
149
+ k, v: New keys/values to insert, shape (B, T_new, H_kv, D)
150
+ cache_seqlens: Current position in cache, shape (B,) int32
151
+ causal: Whether to use causal masking
152
+ window_size: (left, right) sliding window. -1 means unlimited.
153
+
154
+ Returns:
155
+ Output tensor of shape (B, T_new, H, D)
156
+ """
157
+ if USE_FA3:
158
+ return _fa3.flash_attn_with_kvcache(
159
+ q, k_cache, v_cache, k=k, v=v, cache_seqlens=cache_seqlens,
160
+ causal=causal, window_size=window_size
161
+ )
162
+
163
+ # SDPA fallback: manually manage KV cache
164
+ B, T_new, H, D = q.shape
165
+ pos = cache_seqlens[0].item() # assume uniform position across batch
166
+
167
+ # Insert new k, v into cache (in-place, matching FA3 behavior)
168
+ if k is not None and v is not None:
169
+ k_cache[:, pos:pos+T_new, :, :] = k
170
+ v_cache[:, pos:pos+T_new, :, :] = v
171
+
172
+ # Get full cache up to current position + new tokens
173
+ end_pos = pos + T_new
174
+ k_full = k_cache[:, :end_pos, :, :]
175
+ v_full = v_cache[:, :end_pos, :, :]
176
+
177
+ # Transpose to SDPA layout: (B, T, H, D) -> (B, H, T, D)
178
+ q_sdpa = q.transpose(1, 2)
179
+ k_sdpa = k_full.transpose(1, 2)
180
+ v_sdpa = v_full.transpose(1, 2)
181
+
182
+ enable_gqa = q_sdpa.size(1) != k_sdpa.size(1)
183
+ y_sdpa = _sdpa_attention(q_sdpa, k_sdpa, v_sdpa, window_size, enable_gqa)
184
+
185
+ return y_sdpa.transpose(1, 2) # back to (B, T, H, D)
186
+
187
+
188
+ # =============================================================================
189
+ # Export: flash_attn module interface (drop-in replacement for FA3)
190
+ # =============================================================================
191
+ from types import SimpleNamespace
192
+ flash_attn = SimpleNamespace(
193
+ flash_attn_func=flash_attn_func,
194
+ flash_attn_with_kvcache=flash_attn_with_kvcache,
195
+ )
nanochat/fp8.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal FP8 training for nanochat — tensorwise dynamic scaling only.
2
+
3
+ Drop-in replacement for torchao's Float8Linear (~2000 lines) with ~150 lines.
4
+ We only need the "tensorwise" recipe (one scalar scale per tensor), not the full
5
+ generality of torchao (rowwise scaling, FSDP float8 all-gather, DTensor, tensor
6
+ subclass dispatch tables, etc.)
7
+
8
+ How FP8 training works
9
+ ======================
10
+ A standard Linear layer does one matmul in forward and two in backward:
11
+ forward: output = input @ weight.T
12
+ backward: grad_input = grad_output @ weight
13
+ grad_weight= grad_output.T @ input
14
+
15
+ FP8 training wraps each of these three matmuls with:
16
+ 1. Compute scale = FP8_MAX / max(|tensor|) for each operand
17
+ 2. Quantize: fp8_tensor = clamp(tensor * scale, -FP8_MAX, FP8_MAX).to(fp8)
18
+ 3. Matmul via torch._scaled_mm (cuBLAS FP8 kernel, ~2x faster than bf16)
19
+ 4. Dequantize: _scaled_mm handles this internally using the inverse scales
20
+
21
+ The key insight: torch._scaled_mm and the float8 dtypes are PyTorch built-ins.
22
+ torchao is just orchestration around these primitives. We can call them directly.
23
+
24
+ FP8 dtype choice
25
+ ================
26
+ There are two FP8 formats. We use both, following the standard convention:
27
+ - float8_e4m3fn: 4-bit exponent, 3-bit mantissa, range [-448, 448]
28
+ Higher precision (more mantissa bits), used for input and weight.
29
+ - float8_e5m2: 5-bit exponent, 2-bit mantissa, range [-57344, 57344]
30
+ Wider range (more exponent bits), used for gradients which can be large.
31
+
32
+ torch._scaled_mm layout requirements
33
+ =====================================
34
+ The cuBLAS FP8 kernel requires specific memory layouts:
35
+ - First argument (A): must be row-major (contiguous)
36
+ - Second argument (B): must be column-major (B.t().contiguous().t())
37
+ If B is obtained by transposing a contiguous tensor (e.g. weight.t()), it is
38
+ already column-major — no copy needed. Otherwise we use _to_col_major().
39
+
40
+ How this differs from torchao's approach
41
+ ========================================
42
+ torchao uses a "tensor subclass" architecture: Float8TrainingTensor is a subclass
43
+ of torch.Tensor that bundles FP8 data + scale + metadata. It implements
44
+ __torch_dispatch__ with a dispatch table that intercepts every aten op (mm, t,
45
+ reshape, clone, ...) and handles it in FP8-aware fashion. When you call
46
+ output = input @ weight.T
47
+ the @ operator dispatches to aten.mm, which gets intercepted and routed to
48
+ torch._scaled_mm behind the scenes. This is ~2000 lines of code because you need
49
+ a handler for every tensor operation that might touch an FP8 tensor.
50
+
51
+ We take a simpler approach: a single autograd.Function (_Float8Matmul) that takes
52
+ full-precision inputs, quantizes to FP8 internally, calls _scaled_mm, and returns
53
+ full-precision outputs. Marked @allow_in_graph so torch.compile treats it as one
54
+ opaque node rather than trying to trace inside.
55
+
56
+ The trade-off is in how torch.compile sees the two approaches:
57
+ - torchao: compile decomposes the tensor subclass (via __tensor_flatten__) and
58
+ sees every individual op (amax, scale, cast, _scaled_mm) as separate graph
59
+ nodes. Inductor can fuse these with surrounding operations (e.g. fuse the
60
+ amax computation with the preceding layer's activation function).
61
+ - ours: compile sees a single opaque call. It can optimize everything around
62
+ the FP8 linear (attention, norms, etc.) but cannot fuse across the boundary.
63
+
64
+ Both call the exact same cuBLAS _scaled_mm kernel — the GPU matmul is identical.
65
+ The difference is only in the "glue" ops (amax, scale, cast) which are tiny
66
+ compared to the matmul. In practice this means our version is slightly faster
67
+ (less compilation overhead, no tensor subclass dispatch cost) but can produce
68
+ subtly different floating-point rounding paths under torch.compile, since Inductor
69
+ generates a different graph. Numerics are bitwise identical in eager mode.
70
+ """
71
+
72
+ import torch
73
+ import torch.nn as nn
74
+
75
+ from nanochat.common import COMPUTE_DTYPE
76
+
77
+ # Avoid division by zero when computing scale from an all-zeros tensor
78
+ EPS = 1e-12
79
+
80
+
81
+ @torch.no_grad()
82
+ def _to_fp8(x, fp8_dtype):
83
+ """Dynamically quantize a tensor to FP8 using tensorwise scaling.
84
+
85
+ "Tensorwise" means one scalar scale for the entire tensor (as opposed to
86
+ "rowwise" which computes a separate scale per row). Tensorwise is faster
87
+ because cuBLAS handles the scaling; rowwise needs the CUTLASS kernel.
88
+
89
+ Returns (fp8_data, inverse_scale) for use with torch._scaled_mm.
90
+ """
91
+ fp8_max = torch.finfo(fp8_dtype).max
92
+ # Compute the max absolute value across the entire tensor
93
+ amax = x.float().abs().max()
94
+ # Scale maps [0, amax] -> [0, fp8_max]. Use float64 for the division to
95
+ # ensure consistent numerics between torch.compile and eager mode.
96
+ # (torchao does the same upcast — without it, compile/eager can diverge)
97
+ scale = fp8_max / amax.double().clamp(min=EPS)
98
+ scale = scale.float()
99
+ # Quantize: scale into FP8 range, saturate (clamp prevents overflow when
100
+ # casting — PyTorch's default is to wrap, not saturate), then cast to FP8
101
+ x_scaled = x.float() * scale
102
+ x_clamped = x_scaled.clamp(-fp8_max, fp8_max)
103
+ x_fp8 = x_clamped.to(fp8_dtype)
104
+ # _scaled_mm expects the *inverse* of our scale (it multiplies by this to
105
+ # convert FP8 values back to the original range during the matmul)
106
+ inv_scale = scale.reciprocal()
107
+ return x_fp8, inv_scale
108
+
109
+
110
+ def _to_col_major(x):
111
+ """Rearrange a 2D tensor's memory to column-major layout.
112
+
113
+ torch._scaled_mm requires its second operand in column-major layout.
114
+ The trick: transpose -> contiguous (forces a copy in transposed order)
115
+ -> transpose back. The result has the same logical shape but column-major
116
+ strides, e.g. a [M, N] tensor gets strides (1, M) instead of (N, 1).
117
+ """
118
+ return x.t().contiguous().t()
119
+
120
+
121
+ # allow_in_graph tells torch.compile to treat this as an opaque operation —
122
+ # dynamo won't try to decompose it into smaller ops. See the module docstring
123
+ # for how this differs from torchao's tensor subclass approach.
124
+ @torch._dynamo.allow_in_graph
125
+ class _Float8Matmul(torch.autograd.Function):
126
+ """Custom autograd for the three FP8 GEMMs of a Linear layer.
127
+
128
+ The forward quantizes input and weight to FP8 and saves
129
+ the quantized tensors + scales for backward.
130
+ """
131
+
132
+ @staticmethod
133
+ def forward(ctx, input_2d, weight):
134
+ # Quantize both operands to e4m3 (higher precision format)
135
+ input_fp8, input_inv = _to_fp8(input_2d, torch.float8_e4m3fn)
136
+ weight_fp8, weight_inv = _to_fp8(weight, torch.float8_e4m3fn)
137
+ ctx.save_for_backward(input_fp8, input_inv, weight_fp8, weight_inv)
138
+
139
+ # output = input @ weight.T
140
+ # input_fp8 is [B, K] contiguous = row-major (good for first arg)
141
+ # weight_fp8 is [N, K] contiguous, so weight_fp8.t() is [K, N] with
142
+ # strides (1, K) = column-major (good for second arg, no copy needed!)
143
+ output = torch._scaled_mm(
144
+ input_fp8,
145
+ weight_fp8.t(),
146
+ scale_a=input_inv,
147
+ scale_b=weight_inv,
148
+ out_dtype=input_2d.dtype,
149
+ # use_fast_accum=True accumulates the dot products in lower precision.
150
+ # Slightly less accurate but measurably faster. Standard practice for
151
+ # the forward pass; we use False in backward for more precise gradients.
152
+ use_fast_accum=True,
153
+ )
154
+ return output
155
+
156
+ @staticmethod
157
+ def backward(ctx, grad_output):
158
+ in_fp8, in_inv, w_fp8, w_inv = ctx.saved_tensors
159
+
160
+ # === GEMM 1: grad_input = grad_output @ weight ===
161
+ # Shapes: [B, N] @ [N, K] -> [B, K]
162
+ # Gradients use e5m2 (wider range), weights use e4m3 (higher precision)
163
+ go_fp8, go_inv = _to_fp8(grad_output, torch.float8_e5m2)
164
+ # go_fp8 is [B, N] contiguous = row-major, good for first arg
165
+ # w_fp8 is [N, K] contiguous = row-major, need column-major for second arg
166
+ w_col = _to_col_major(w_fp8)
167
+ grad_input = torch._scaled_mm(
168
+ go_fp8,
169
+ w_col,
170
+ scale_a=go_inv,
171
+ scale_b=w_inv,
172
+ out_dtype=grad_output.dtype,
173
+ use_fast_accum=False,
174
+ )
175
+
176
+ # === GEMM 2: grad_weight = grad_output.T @ input ===
177
+ # Shapes: [N, B] @ [B, K] -> [N, K]
178
+ # go_fp8 is [B, N] contiguous, we need go.T = [N, B] as first arg.
179
+ # Transposing gives column-major, but first arg needs row-major,
180
+ # so we must call .contiguous() to physically rearrange the memory.
181
+ go_T = go_fp8.t().contiguous() # [N, B] row-major
182
+ in_col = _to_col_major(in_fp8) # [B, K] column-major
183
+ grad_weight = torch._scaled_mm(
184
+ go_T,
185
+ in_col,
186
+ scale_a=go_inv,
187
+ scale_b=in_inv,
188
+ out_dtype=grad_output.dtype,
189
+ use_fast_accum=False,
190
+ )
191
+
192
+ return grad_input, grad_weight
193
+
194
+
195
+ class Float8Linear(nn.Linear):
196
+ """Drop-in nn.Linear replacement that does FP8 compute.
197
+
198
+ Weights and biases remain in their original precision (e.g. fp32/bf16).
199
+ Only the matmul is performed in FP8 via the _Float8Matmul autograd function.
200
+ """
201
+
202
+ def forward(self, input):
203
+ # Cast input to COMPUTE_DTYPE (typically bf16) since _scaled_mm expects
204
+ # reduced precision input, and we no longer rely on autocast to do this.
205
+ input = input.to(COMPUTE_DTYPE)
206
+ # _scaled_mm only works on 2D tensors, so flatten batch dimensions
207
+ orig_shape = input.shape
208
+ input_2d = input.reshape(-1, orig_shape[-1])
209
+ output = _Float8Matmul.apply(input_2d, self.weight)
210
+ output = output.reshape(*orig_shape[:-1], output.shape[-1])
211
+ if self.bias is not None:
212
+ output = output + self.bias.to(output.dtype)
213
+ return output
214
+
215
+ @classmethod
216
+ def from_float(cls, mod):
217
+ """Create Float8Linear from nn.Linear, sharing the same weight and bias.
218
+
219
+ Uses meta device to avoid allocating a temporary weight tensor — we
220
+ create the module shell on meta (shapes/dtypes only, no memory), then
221
+ point .weight and .bias to the original module's parameters.
222
+ """
223
+ with torch.device("meta"):
224
+ new_mod = cls(mod.in_features, mod.out_features, bias=False)
225
+ new_mod.weight = mod.weight
226
+ new_mod.bias = mod.bias
227
+ return new_mod
228
+
229
+
230
+ class Float8LinearConfig:
231
+ """Minimal config matching torchao's API. Only tensorwise recipe is supported."""
232
+
233
+ @staticmethod
234
+ def from_recipe_name(recipe_name):
235
+ if recipe_name != "tensorwise":
236
+ raise ValueError(
237
+ f"Only 'tensorwise' recipe is supported, got '{recipe_name}'. "
238
+ f"Rowwise/axiswise recipes require the full torchao library."
239
+ )
240
+ return Float8LinearConfig()
241
+
242
+
243
+ def convert_to_float8_training(module, *, config=None, module_filter_fn=None):
244
+ """Replace nn.Linear layers with Float8Linear throughout a module.
245
+
246
+ Walks the module tree in post-order (children before parents) and swaps
247
+ each nn.Linear that passes the optional filter. The new Float8Linear shares
248
+ the original weight and bias tensors — no copies, no extra memory.
249
+
250
+ Args:
251
+ module: Root module to convert.
252
+ config: Float8LinearConfig (accepted for API compat, only tensorwise supported).
253
+ module_filter_fn: Optional filter(module, fqn) -> bool. Only matching Linears
254
+ are converted. Common use: skip layers with dims not divisible by 16
255
+ (hardware requirement for FP8 matmuls on H100).
256
+ """
257
+ def _convert(mod, prefix=""):
258
+ for name, child in mod.named_children():
259
+ fqn = f"{prefix}.{name}" if prefix else name
260
+ _convert(child, fqn)
261
+ if isinstance(child, nn.Linear) and not isinstance(child, Float8Linear):
262
+ if module_filter_fn is None or module_filter_fn(child, fqn):
263
+ setattr(mod, name, Float8Linear.from_float(child))
264
+
265
+ _convert(module)
266
+ return module
nanochat/gpt.py ADDED
@@ -0,0 +1,916 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPT model (rewrite, a lot simpler)
3
+ Notable features:
4
+ - rotary embeddings (and no positional embeddings)
5
+ - QK norm
6
+ - untied weights for token embedding and lm_head
7
+ - PyTorch-native variational quantum feed-forward network
8
+ - norm after token embedding
9
+ - no learnable params in rmsnorm
10
+ - no bias in linear layers
11
+ - Group-Query Attention (GQA) support for more efficient inference
12
+ - Flash Attention 3 integration
13
+ """
14
+
15
+ from functools import partial
16
+ from dataclasses import dataclass
17
+
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+ from torch.utils.checkpoint import checkpoint
22
+
23
+ from nanochat.common import get_dist_info, print0, COMPUTE_DTYPE
24
+ from nanochat.optim import MuonAdamW
25
+
26
+ # Our custom Flash Attention module that automatically uses FA3 when compatible and SDPA fallback otherwise
27
+ from nanochat.flash_attention import flash_attn
28
+
29
+ @dataclass
30
+ class GPTConfig:
31
+ sequence_len: int = 2048
32
+ vocab_size: int = 32768
33
+ n_layer: int = 12
34
+ n_head: int = 6 # number of query heads
35
+ n_kv_head: int = 6 # number of key/value heads (GQA)
36
+ n_embd: int = 768
37
+ # Feed-forward network. "quantum" is a PyTorch-native exact simulator;
38
+ # "classical" retains the old ReLU² MLP for loading legacy checkpoints.
39
+ mlp_type: str = "quantum"
40
+ quantum_num_qubits: int = 4
41
+ quantum_depth: int = 2
42
+ # Sliding window attention pattern string, tiled across layers. Final layer always L.
43
+ # Characters: L=long (full context), S=short (quarter context)
44
+ # Examples: "L"=all full context, "SL"=alternating, "SSL"=two short then one long
45
+ window_pattern: str = "SSSL"
46
+ # LFM2-style hybrid backbone. mixer_pattern selects the per-layer token mixer,
47
+ # tiled across layers: 'A'=grouped-query attention, 'C'=gated short convolution.
48
+ # Default "A" reproduces the original all-attention model (back-compat).
49
+ mixer_pattern: str = "A"
50
+ conv_kernel: int = 3 # depthwise short-conv kernel size for 'C' layers
51
+ rope_theta: float = 100000.0 # RoPE base frequency (LFM2 uses 1e6)
52
+ # Per-layer feed-forward selection, tiled across layers: 'Q'=quantum circuit,
53
+ # 'C'=classical ReLU² MLP, 'S'=classical SwiGLU. Empty ⇒ derive from mlp_type.
54
+ ffn_pattern: str = ""
55
+ use_value_embeddings: bool = True # ResFormer value embeddings (attention layers only)
56
+
57
+ def mixer_types(self):
58
+ return _tile_pattern(self.mixer_pattern, self.n_layer, "AC", "mixer_pattern")
59
+
60
+ def ffn_types(self):
61
+ pattern = self.ffn_pattern
62
+ if not pattern:
63
+ pattern = {"quantum": "Q", "classical": "C"}[self.mlp_type]
64
+ return _tile_pattern(pattern, self.n_layer, "QCS", "ffn_pattern")
65
+
66
+
67
+ def _tile_pattern(pattern, n_layer, valid_chars, name):
68
+ """Tile/validate a pattern string to exactly n_layer characters."""
69
+ pattern = pattern.upper()
70
+ assert pattern, f"{name} must be non-empty"
71
+ assert all(c in valid_chars for c in pattern), \
72
+ f"Invalid {name}={pattern!r}; use only characters from {valid_chars!r}"
73
+ return [pattern[i % len(pattern)] for i in range(n_layer)]
74
+
75
+
76
+ def _layer_has_ve(config, layer_idx):
77
+ """Value embeddings apply only to attention layers, when enabled, following
78
+ the alternating (last-layer-included) ResFormer schedule."""
79
+ if not getattr(config, "use_value_embeddings", True):
80
+ return False
81
+ if config.mixer_types()[layer_idx] != "A":
82
+ return False
83
+ return has_ve(layer_idx, config.n_layer)
84
+
85
+
86
+ def norm(x):
87
+ return F.rms_norm(x, (x.size(-1),)) # note that this will run in bf16, seems ok
88
+
89
+ class Linear(nn.Linear):
90
+ """nn.Linear that casts weights to match input dtype in forward.
91
+ Replaces autocast: master weights stay fp32 for optimizer precision,
92
+ but matmuls run in the activation dtype (typically bf16 from embeddings)."""
93
+ def forward(self, x):
94
+ return F.linear(x, self.weight.to(dtype=x.dtype))
95
+
96
+
97
+ def has_ve(layer_idx, n_layer):
98
+ """Returns True if GPT layer should have Value Embedding (alternating, last layer always included)."""
99
+ return layer_idx % 2 == (n_layer - 1) % 2
100
+
101
+ def apply_rotary_emb(x, cos, sin):
102
+ # note: this rotates by -theta, the transpose of the textbook convention. Functionally
103
+ # equivalent (only the relative q/k rotation matters), kept for checkpoint compatibility.
104
+ assert x.ndim == 4 # multihead attention
105
+ d = x.shape[3] // 2
106
+ x1, x2 = x[..., :d], x[..., d:] # split up last dim into two halves
107
+ y1 = x1 * cos + x2 * sin # rotate pairs of dims
108
+ y2 = x1 * (-sin) + x2 * cos
109
+ return torch.cat([y1, y2], 3)
110
+
111
+ class CausalSelfAttention(nn.Module):
112
+ def __init__(self, config, layer_idx, has_value_embed=None):
113
+ super().__init__()
114
+ self.layer_idx = layer_idx
115
+ self.n_head = config.n_head
116
+ self.n_kv_head = config.n_kv_head
117
+ self.n_embd = config.n_embd
118
+ self.head_dim = self.n_embd // self.n_head
119
+ assert self.n_embd % self.n_head == 0
120
+ assert self.n_kv_head <= self.n_head and self.n_head % self.n_kv_head == 0
121
+ self.c_q = Linear(self.n_embd, self.n_head * self.head_dim, bias=False)
122
+ self.c_k = Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
123
+ self.c_v = Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
124
+ self.c_proj = Linear(self.n_embd, self.n_embd, bias=False)
125
+ self.ve_gate_channels = 12
126
+ # has_value_embed defaults to the original alternating schedule when constructed
127
+ # directly (e.g. in tests); GPT passes the mixer-aware value from _layer_has_ve.
128
+ if has_value_embed is None:
129
+ has_value_embed = has_ve(layer_idx, config.n_layer)
130
+ self.ve_gate = Linear(self.ve_gate_channels, self.n_kv_head, bias=False) if has_value_embed else None
131
+
132
+ def forward(self, x, ve, cos_sin, window_size, kv_cache):
133
+ B, T, C = x.size()
134
+
135
+ # Project the input to get queries, keys, and values
136
+ # Shape: (B, T, H, D) - FA3's native layout, no transpose needed!
137
+ q = self.c_q(x).view(B, T, self.n_head, self.head_dim)
138
+ k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim)
139
+ v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim)
140
+
141
+ # Value residual (ResFormer): mix in value embedding with input-dependent gate per head
142
+ if ve is not None:
143
+ ve = ve.view(B, T, self.n_kv_head, self.head_dim)
144
+ gate = 3 * torch.sigmoid(self.ve_gate(x[..., :self.ve_gate_channels])) # (B, T, n_kv_head), range (0, 3)
145
+ v = v + gate.unsqueeze(-1) * ve
146
+
147
+ # Apply Rotary Embeddings to queries and keys to get relative positional encoding
148
+ cos, sin = cos_sin
149
+ q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin)
150
+ q, k = norm(q), norm(k) # QK norm
151
+ q = q * 1.2 # sharper attention (split scale between Q and K), TODO think through better
152
+ k = k * 1.2
153
+
154
+ # Flash Attention (FA3 or SDPA fallback)
155
+ # window_size is (left, right) tuple: (N, 0) for causal, (-1, 0) for full context
156
+ if kv_cache is None:
157
+ # Training: causal attention with optional sliding window
158
+ y = flash_attn.flash_attn_func(q, k, v, causal=True, window_size=window_size)
159
+ else:
160
+ # Inference: use flash_attn_with_kvcache which handles cache management
161
+ k_cache, v_cache = kv_cache.get_layer_cache(self.layer_idx)
162
+ y = flash_attn.flash_attn_with_kvcache(
163
+ q, k_cache, v_cache,
164
+ k=k, v=v,
165
+ cache_seqlens=kv_cache.cache_seqlens,
166
+ causal=True,
167
+ window_size=window_size,
168
+ )
169
+ # Advance position after last layer processes
170
+ if self.layer_idx == kv_cache.n_layers - 1:
171
+ kv_cache.advance(T)
172
+
173
+ # Re-assemble the heads and project back to residual stream
174
+ y = y.contiguous().view(B, T, -1)
175
+ y = self.c_proj(y)
176
+ return y
177
+
178
+
179
+ class ClassicalMLP(nn.Module):
180
+ """The original dense ReLU² MLP, retained for legacy checkpoints."""
181
+ def __init__(self, config):
182
+ super().__init__()
183
+ self.c_fc = Linear(config.n_embd, 4 * config.n_embd, bias=False)
184
+ self.c_proj = Linear(4 * config.n_embd, config.n_embd, bias=False)
185
+
186
+ def forward(self, x):
187
+ x = self.c_fc(x)
188
+ x = F.relu(x).square()
189
+ x = self.c_proj(x)
190
+ return x
191
+
192
+
193
+ class QuantumMLP(nn.Module):
194
+ """
195
+ A differentiable variational quantum circuit implemented with PyTorch.
196
+
197
+ Embedding channels are split into independent small quantum registers. Each
198
+ register angle-encodes its inputs with RY gates, applies trainable RY gates
199
+ and a ring of CNOT entanglers, then returns Pauli-Z expectation values. The
200
+ simulator is exact and uses ordinary PyTorch tensor operations, so gradients,
201
+ device placement, state dicts, and torch.compile work without PennyLane/Qiskit.
202
+
203
+ This is a simulator: memory is O(2**num_qubits) per register. Keeping
204
+ num_qubits small (the default is 4) makes it practical inside a language model.
205
+ """
206
+ def __init__(self, config):
207
+ super().__init__()
208
+ self.n_embd = config.n_embd
209
+ self.num_qubits = config.quantum_num_qubits
210
+ self.depth = config.quantum_depth
211
+ if not 2 <= self.num_qubits <= 8:
212
+ raise ValueError("quantum_num_qubits must be between 2 and 8")
213
+ if self.depth < 1:
214
+ raise ValueError("quantum_depth must be at least 1")
215
+
216
+ self.num_registers = (self.n_embd + self.num_qubits - 1) // self.num_qubits
217
+ self.padded_embd = self.num_registers * self.num_qubits
218
+ parameter_shape = (self.depth, self.num_registers, self.num_qubits)
219
+ self.theta = nn.Parameter(torch.empty(parameter_shape))
220
+ self.input_scale = nn.Parameter(torch.empty(self.num_registers, self.num_qubits))
221
+ self.input_bias = nn.Parameter(torch.empty(self.num_registers, self.num_qubits))
222
+ self.output_scale = nn.Parameter(torch.empty(self.num_registers, self.num_qubits))
223
+ self.output_bias = nn.Parameter(torch.empty(self.num_registers, self.num_qubits))
224
+
225
+ cnot_permutations, z_signs = self._make_quantum_buffers(self.theta.device)
226
+ self.register_buffer("cnot_permutations", cnot_permutations, persistent=False)
227
+ self.register_buffer("z_signs", z_signs, persistent=False)
228
+ self.reset_parameters()
229
+
230
+ def _make_quantum_buffers(self, device):
231
+ """Build basis permutations for CNOTs and Pauli-Z measurement signs."""
232
+ num_states = 1 << self.num_qubits
233
+ basis = torch.arange(num_states, device=device, dtype=torch.long)
234
+ permutations = []
235
+ for control in range(self.num_qubits):
236
+ target = (control + 1) % self.num_qubits
237
+ control_is_one = ((basis >> control) & 1).bool()
238
+ permutations.append(torch.where(control_is_one, basis ^ (1 << target), basis))
239
+ cnot_permutations = torch.stack(permutations)
240
+
241
+ signs = []
242
+ for qubit in range(self.num_qubits):
243
+ signs.append(1.0 - 2.0 * ((basis >> qubit) & 1).float())
244
+ return cnot_permutations, torch.stack(signs)
245
+
246
+ @torch.no_grad()
247
+ def reset_parameters(self):
248
+ # Near-identity input encoding with small trainable variational angles.
249
+ nn.init.normal_(self.theta, mean=0.0, std=0.02)
250
+ nn.init.ones_(self.input_scale)
251
+ nn.init.zeros_(self.input_bias)
252
+ # Zero output preserves the transformer's residual-path initialization.
253
+ nn.init.zeros_(self.output_scale)
254
+ nn.init.zeros_(self.output_bias)
255
+ # __init__ may run under a meta-device context. GPT.init_weights calls
256
+ # this again after to_empty(), at which point real buffers are required.
257
+ if not self.theta.is_meta:
258
+ cnot_permutations, z_signs = self._make_quantum_buffers(self.theta.device)
259
+ self.cnot_permutations = cnot_permutations
260
+ self.z_signs = z_signs
261
+
262
+ @staticmethod
263
+ def _apply_ry(state, angle, qubit):
264
+ """Apply a batched RY(angle) gate to one qubit of a real statevector."""
265
+ low_dim = 1 << qubit
266
+ high_dim = state.size(-1) // (2 * low_dim)
267
+ paired = state.reshape(*state.shape[:-1], high_dim, 2, low_dim)
268
+ amplitude_zero, amplitude_one = paired.unbind(dim=-2)
269
+ half_angle = angle.unsqueeze(-1).unsqueeze(-1) * 0.5
270
+ cosine, sine = torch.cos(half_angle), torch.sin(half_angle)
271
+ rotated_zero = cosine * amplitude_zero - sine * amplitude_one
272
+ rotated_one = sine * amplitude_zero + cosine * amplitude_one
273
+ return torch.stack((rotated_zero, rotated_one), dim=-2).flatten(-3)
274
+
275
+ def forward(self, x):
276
+ input_dtype = x.dtype
277
+ # Trigonometric statevector evolution is kept in fp32 for stability.
278
+ angles = x.float()
279
+ if self.padded_embd != self.n_embd:
280
+ angles = F.pad(angles, (0, self.padded_embd - self.n_embd))
281
+ angles = angles.view(*x.shape[:-1], self.num_registers, self.num_qubits)
282
+ angles = angles * self.input_scale + self.input_bias
283
+
284
+ state = angles.new_zeros(*angles.shape[:-1], 1 << self.num_qubits)
285
+ state[..., 0] = 1.0 # |00...0>
286
+
287
+ # Data encoding.
288
+ for qubit in range(self.num_qubits):
289
+ state = self._apply_ry(state, angles[..., qubit], qubit)
290
+
291
+ # Hardware-efficient variational layers: rotations followed by a CNOT ring.
292
+ parameter_prefix = (1,) * (angles.ndim - 2)
293
+ for layer in range(self.depth):
294
+ for qubit in range(self.num_qubits):
295
+ angle = self.theta[layer, :, qubit].view(*parameter_prefix, self.num_registers)
296
+ state = self._apply_ry(state, angle, qubit)
297
+ for permutation in self.cnot_permutations:
298
+ state = state.index_select(-1, permutation)
299
+
300
+ probabilities = state.square()
301
+ expectations = torch.einsum("...s,qs->...q", probabilities, self.z_signs)
302
+ output = expectations * self.output_scale + self.output_bias
303
+ output = output.flatten(-2)[..., :self.n_embd]
304
+ return output.to(input_dtype)
305
+
306
+
307
+ class ClassicalSwiGLU(nn.Module):
308
+ """SwiGLU feed-forward (as used by LFM2). Kept available as a classical
309
+ alternative to the quantum FFN; not used by the default quantum preset."""
310
+ def __init__(self, config):
311
+ super().__init__()
312
+ # ~8/3·d_model rounded to a multiple of 128 keeps the SwiGLU param budget
313
+ # comparable to a 4·d ReLU MLP (two input projections instead of one).
314
+ hidden = int(config.n_embd * 8 / 3)
315
+ hidden = ((hidden + 127) // 128) * 128
316
+ self.w1 = Linear(config.n_embd, hidden, bias=False) # gate
317
+ self.w3 = Linear(config.n_embd, hidden, bias=False) # up
318
+ self.w2 = Linear(hidden, config.n_embd, bias=False) # down
319
+
320
+ def forward(self, x):
321
+ return self.w2(F.silu(self.w1(x)) * self.w3(x))
322
+
323
+
324
+ class ShortConvBlock(nn.Module):
325
+ """LFM2-style double-gated short-range convolution token mixer.
326
+
327
+ A single input projection produces three gates (B, C, x). The value x is
328
+ gated by B, passed through a causal depthwise short convolution, gated by C,
329
+ then projected out: y = out_proj( C * depthwise_conv( B * x ) ).
330
+ Complexity is O(n) in sequence length (vs O(n²) attention). Causality is
331
+ enforced by left-padding the convolution by kernel-1 so position t never
332
+ reads position t+1. During KV-cache decoding the last kernel-1 gated inputs
333
+ are cached so incremental single-token steps match the full-sequence result.
334
+ """
335
+ def __init__(self, config, layer_idx):
336
+ super().__init__()
337
+ self.layer_idx = layer_idx
338
+ self.n_embd = config.n_embd
339
+ self.kernel = config.conv_kernel
340
+ self.in_proj = Linear(config.n_embd, 3 * config.n_embd, bias=False)
341
+ self.conv = nn.Conv1d(config.n_embd, config.n_embd, kernel_size=self.kernel,
342
+ groups=config.n_embd, bias=False)
343
+ self.out_proj = Linear(config.n_embd, config.n_embd, bias=False)
344
+
345
+ def forward(self, x, kv_cache=None):
346
+ B, T, C = x.size()
347
+ b_gate, c_gate, value = self.in_proj(x).chunk(3, dim=-1)
348
+ conv_in = (b_gate * value).transpose(1, 2) # (B, C, T)
349
+ pad = self.kernel - 1
350
+ prev = None if kv_cache is None else kv_cache.get_conv_state(self.layer_idx)
351
+ if prev is None:
352
+ padded = F.pad(conv_in, (pad, 0)) # causal left pad
353
+ else:
354
+ padded = torch.cat([prev.to(conv_in.dtype), conv_in], dim=-1)
355
+ y = F.conv1d(padded, self.conv.weight.to(dtype=padded.dtype), groups=self.n_embd) # valid conv over (pad + T) -> length T
356
+ if kv_cache is not None and pad > 0:
357
+ kv_cache.set_conv_state(self.layer_idx, padded[:, :, -pad:].detach())
358
+ y = y.transpose(1, 2) # (B, T, C)
359
+ y = c_gate * y
360
+ return self.out_proj(y)
361
+
362
+
363
+ def _make_ffn(config, layer_idx):
364
+ ffn_type = config.ffn_types()[layer_idx]
365
+ if ffn_type == "Q":
366
+ return QuantumMLP(config)
367
+ elif ffn_type == "C":
368
+ return ClassicalMLP(config)
369
+ elif ffn_type == "S":
370
+ return ClassicalSwiGLU(config)
371
+ raise ValueError(f"Unknown ffn type {ffn_type!r}; expected 'Q', 'C' or 'S'")
372
+
373
+
374
+ class Block(nn.Module):
375
+ def __init__(self, config, layer_idx):
376
+ super().__init__()
377
+ # Token mixer: attention or short convolution. Submodule is named `attn`
378
+ # for attention (preserving legacy checkpoint keys) and `conv` for
379
+ # convolution; exactly one is non-None.
380
+ mixer_type = config.mixer_types()[layer_idx]
381
+ if mixer_type == "A":
382
+ self.attn = CausalSelfAttention(config, layer_idx, has_value_embed=_layer_has_ve(config, layer_idx))
383
+ self.conv = None
384
+ else:
385
+ self.attn = None
386
+ self.conv = ShortConvBlock(config, layer_idx)
387
+ self.mlp = _make_ffn(config, layer_idx)
388
+
389
+ def forward(self, x, ve, cos_sin, window_size, kv_cache):
390
+ if self.attn is not None:
391
+ x = x + self.attn(norm(x), ve, cos_sin, window_size, kv_cache)
392
+ else:
393
+ x = x + self.conv(norm(x), kv_cache)
394
+ x = x + self.mlp(norm(x))
395
+ return x
396
+
397
+
398
+ class GPT(nn.Module):
399
+ def __init__(self, config, pad_vocab_size_to=64):
400
+ """
401
+ NOTE a major footgun: this __init__ function runs in meta device context (!!)
402
+ Therefore, any calculations inside here are shapes and dtypes only, no actual data.
403
+ => We actually initialize all data (parameters, buffers, etc.) in init_weights() instead.
404
+ """
405
+ super().__init__()
406
+ self.config = config
407
+ self.gradient_checkpointing = False
408
+ # Compute per-layer window sizes for sliding window attention
409
+ # window_size is (left, right) tuple: (-1, 0) for full context, (N, 0) for sliding window
410
+ self.window_sizes = self._compute_window_sizes(config)
411
+ # Pad vocab for efficiency (DDP, tensor cores). This is just an optimization - outputs are cropped in forward().
412
+ # https://huggingface.co/docs/transformers/main_classes/model#transformers.PreTrainedModel.resize_token_embeddings
413
+ padded_vocab_size = ((config.vocab_size + pad_vocab_size_to - 1) // pad_vocab_size_to) * pad_vocab_size_to
414
+ if padded_vocab_size != config.vocab_size:
415
+ print0(f"Padding vocab_size from {config.vocab_size} to {padded_vocab_size} for efficiency")
416
+ self.transformer = nn.ModuleDict({
417
+ "wte": nn.Embedding(padded_vocab_size, config.n_embd),
418
+ "h": nn.ModuleList([Block(config, layer_idx) for layer_idx in range(config.n_layer)]),
419
+ })
420
+ self.lm_head = Linear(config.n_embd, padded_vocab_size, bias=False)
421
+ # Per-layer learnable scalars (inspired by modded-nanogpt)
422
+ # resid_lambdas: scales the residual stream at each layer (init 1.0 = neutral)
423
+ # x0_lambdas: blends initial embedding back in at each layer (init 0.0 = disabled)
424
+ # Separate parameters so they can have different optimizer treatment
425
+ self.resid_lambdas = nn.Parameter(torch.ones(config.n_layer)) # fake init, real init in init_weights()
426
+ self.x0_lambdas = nn.Parameter(torch.zeros(config.n_layer)) # fake init, real init in init_weights()
427
+ # Smear: mix previous token's embedding into current token (cheap bigram-like info)
428
+ self.smear_gate = Linear(24, 1, bias=False)
429
+ self.smear_lambda = nn.Parameter(torch.zeros(1))
430
+ # Backout: subtract cached mid-layer residual before final norm to remove low-level features
431
+ self.backout_lambda = nn.Parameter(0.2 * torch.ones(1))
432
+ # Value embeddings (ResFormer-style): alternating layers, last layer always included
433
+ head_dim = config.n_embd // config.n_head
434
+ kv_dim = config.n_kv_head * head_dim
435
+ self.value_embeds = nn.ModuleDict({str(i): nn.Embedding(padded_vocab_size, kv_dim) for i in range(config.n_layer) if _layer_has_ve(config, i)})
436
+ # To support meta device initialization, we init the rotary embeddings here, but it's just "fake" meta tensors only.
437
+ # As for rotary_seq_len, these rotary embeddings are pretty small/cheap in memory,
438
+ # so let's just over-compute them by 10X, but assert fail if we ever reach that amount.
439
+ # In the future we can dynamically grow the cache, for now it's fine.
440
+ self.rotary_seq_len = config.sequence_len * 10 # 10X over-compute should be enough, TODO make nicer?
441
+ head_dim = config.n_embd // config.n_head
442
+ cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
443
+ self.register_buffer("cos", cos, persistent=False) # persistent=False means it's not saved to the checkpoint
444
+ self.register_buffer("sin", sin, persistent=False)
445
+
446
+ @torch.no_grad()
447
+ def init_weights(self):
448
+ """
449
+ Initialize the full model in this one function for maximum clarity.
450
+
451
+ wte (embedding): normal, std=1.0
452
+ lm_head: normal, std=0.001
453
+ for each block:
454
+ attn.c_q: uniform, std=1/sqrt(n_embd)
455
+ attn.c_k: uniform, std=1/sqrt(n_embd)
456
+ attn.c_v: uniform, std=1/sqrt(n_embd)
457
+ attn.c_proj: zeros
458
+ mlp.c_fc: uniform, std=1/sqrt(n_embd)
459
+ mlp.c_proj: zeros
460
+ """
461
+
462
+ # Embedding and unembedding
463
+ torch.nn.init.normal_(self.transformer.wte.weight, mean=0.0, std=0.8)
464
+ torch.nn.init.normal_(self.lm_head.weight, mean=0.0, std=0.001)
465
+
466
+ # Transformer blocks: uniform init with bound = sqrt(3) * std (same standard deviation as normal)
467
+ n_embd = self.config.n_embd
468
+ s = 3**0.5 * n_embd**-0.5 # sqrt(3) multiplier makes sure Uniform achieves the same std as Normal
469
+ for block in self.transformer.h:
470
+ # Token mixer
471
+ if block.attn is not None:
472
+ torch.nn.init.uniform_(block.attn.c_q.weight, -s, s) # weights use Uniform to avoid outliers
473
+ torch.nn.init.uniform_(block.attn.c_k.weight, -s, s)
474
+ torch.nn.init.uniform_(block.attn.c_v.weight, -s, s)
475
+ torch.nn.init.zeros_(block.attn.c_proj.weight) # projections are zero
476
+ else: # ShortConvBlock
477
+ torch.nn.init.uniform_(block.conv.in_proj.weight, -s, s)
478
+ torch.nn.init.uniform_(block.conv.conv.weight, -s, s) # depthwise (C,1,k)
479
+ torch.nn.init.zeros_(block.conv.out_proj.weight) # zero-residual init like c_proj
480
+ # Feed-forward network
481
+ if isinstance(block.mlp, QuantumMLP):
482
+ block.mlp.reset_parameters()
483
+ elif isinstance(block.mlp, ClassicalSwiGLU):
484
+ torch.nn.init.uniform_(block.mlp.w1.weight, -s * 0.4, s * 0.4)
485
+ torch.nn.init.uniform_(block.mlp.w3.weight, -s * 0.4, s * 0.4)
486
+ torch.nn.init.zeros_(block.mlp.w2.weight) # zero-residual init
487
+ else: # ClassicalMLP (ReLU²)
488
+ torch.nn.init.uniform_(block.mlp.c_fc.weight, -s * 0.4, s * 0.4) # 0.4x init scale for c_fc
489
+ torch.nn.init.zeros_(block.mlp.c_proj.weight)
490
+
491
+ # Per-layer scalars
492
+ # Per-layer resid init: stronger residual at early layers, weaker at deep layers
493
+ n_layer = self.config.n_layer
494
+ for i in range(n_layer):
495
+ self.resid_lambdas.data[i] = 1.15 - (0.10 * i / max(n_layer - 1, 1))
496
+ # Decaying x0 init: earlier layers get more input embedding blending
497
+ for i in range(n_layer):
498
+ self.x0_lambdas.data[i] = 0.20 - (0.15 * i / max(n_layer - 1, 1))
499
+
500
+ # Smear/backout scalars and smear gate must be explicitly initialized
501
+ torch.nn.init.zeros_(self.smear_lambda)
502
+ torch.nn.init.constant_(self.backout_lambda, 0.2)
503
+ torch.nn.init.uniform_(self.smear_gate.weight, 0.0, 0.02)
504
+
505
+ # Value embeddings (init like c_v: uniform with same std)
506
+ for ve in self.value_embeds.values():
507
+ torch.nn.init.uniform_(ve.weight, -s, s)
508
+
509
+ # Gate weights init with small positive values so gates start slightly above neutral
510
+ for block in self.transformer.h:
511
+ if block.attn is not None and block.attn.ve_gate is not None:
512
+ torch.nn.init.uniform_(block.attn.ve_gate.weight, 0.0, 0.02)
513
+
514
+ # Rotary embeddings
515
+ head_dim = self.config.n_embd // self.config.n_head
516
+ cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
517
+ self.cos, self.sin = cos, sin
518
+
519
+ # Cast embeddings to COMPUTE_DTYPE: optimizer can tolerate reduced-precision
520
+ # embeddings and it saves memory. Exception: fp16 requires fp32 embeddings
521
+ # because GradScaler cannot unscale fp16 gradients.
522
+ if COMPUTE_DTYPE != torch.float16:
523
+ self.transformer.wte.to(dtype=COMPUTE_DTYPE)
524
+ for ve in self.value_embeds.values():
525
+ ve.to(dtype=COMPUTE_DTYPE)
526
+
527
+ def _precompute_rotary_embeddings(self, seq_len, head_dim, base=None, device=None):
528
+ # Base (theta) defaults to the model config (LFM2 uses 1e6; legacy 1e5).
529
+ if base is None:
530
+ base = self.config.rope_theta
531
+ # autodetect the device from model embeddings
532
+ if device is None:
533
+ device = self.transformer.wte.weight.device
534
+ # stride the channels
535
+ channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
536
+ inv_freq = 1.0 / (base ** (channel_range / head_dim))
537
+ # stride the time steps
538
+ t = torch.arange(seq_len, dtype=torch.float32, device=device)
539
+ # calculate the rotation frequencies at each (time, channel) pair
540
+ freqs = torch.outer(t, inv_freq)
541
+ cos, sin = freqs.cos(), freqs.sin()
542
+ cos, sin = cos.to(COMPUTE_DTYPE), sin.to(COMPUTE_DTYPE)
543
+ cos, sin = cos[None, :, None, :], sin[None, :, None, :] # add batch and head dims for later broadcasting
544
+ return cos, sin
545
+
546
+ def _compute_window_sizes(self, config):
547
+ """
548
+ Compute per-layer window sizes for sliding window attention.
549
+
550
+ Returns list of (left, right) tuples for FA3's window_size parameter:
551
+ - left: how many tokens before current position to attend to (-1 = unlimited)
552
+ - right: how many tokens after current position to attend to (0 for causal)
553
+
554
+ Pattern string is tiled across layers. Final layer always gets L (full context).
555
+ Characters: L=long (full context), S=short (quarter context)
556
+ """
557
+ pattern = config.window_pattern.upper()
558
+ assert all(c in "SL" for c in pattern), f"Invalid window_pattern: {pattern}. Use only S and L."
559
+ # Map characters to window sizes
560
+ long_window = config.sequence_len
561
+ short_window = -(-long_window // 4 // 128) * 128 # ceil to FA3 tile size (2048 -> 768)
562
+ char_to_window = {
563
+ "L": (long_window, 0),
564
+ "S": (short_window, 0),
565
+ }
566
+ # Tile pattern across layers
567
+ window_sizes = []
568
+ for layer_idx in range(config.n_layer):
569
+ char = pattern[layer_idx % len(pattern)]
570
+ window_sizes.append(char_to_window[char])
571
+ # Final layer always gets full context
572
+ window_sizes[-1] = (long_window, 0)
573
+ return window_sizes
574
+
575
+ def get_device(self):
576
+ return self.transformer.wte.weight.device
577
+
578
+ def estimate_flops(self):
579
+ """
580
+ Return the estimated FLOPs per token for the model (forward + backward).
581
+ Each matmul weight parameter contributes 2 FLOPs (multiply *, accumulate +) in forward, and 2X that in backward => 2+4=6.
582
+ Cleanest explanation of this: https://medium.com/@dzmitrybahdanau/the-flops-calculus-of-language-model-training-3b19c1f025e4
583
+ On top of that, 12 * h * q * effective_seq_len accounts for key @ query matmul flops inside attention.
584
+ With sliding windows, effective_seq_len varies per layer (capped by window size).
585
+ Ref: https://arxiv.org/abs/2204.02311 (PaLM paper).
586
+ This is ~1% off from the exact formulas of Chinchilla paper, the difference is:
587
+ - Chinchilla counts the embedding layer as flops (? weird, it's just a lookup => we ignore)
588
+ - Chinchilla counts exp/sum/divide in attention softmax as flops (a little sus and very tiny => we ignore)
589
+ """
590
+ h, q, t = self.config.n_head, self.config.n_embd // self.config.n_head, self.config.sequence_len
591
+ # Sum attention FLOPs over attention layers only (conv layers have no attention),
592
+ # accounting for sliding window.
593
+ attn_flops = 0
594
+ for i, block in enumerate(self.transformer.h):
595
+ if block.attn is None:
596
+ continue
597
+ window = self.window_sizes[i][0] # (left, right) tuple, we use left
598
+ effective_seq = t if window < 0 else min(window, t)
599
+ attn_flops += 12 * h * q * effective_seq
600
+ num_flops_per_token = (
601
+ 6 * self.num_matmul_params()
602
+ + 3 * self.quantum_forward_flops_per_token()
603
+ + 3 * self.conv_forward_flops_per_token()
604
+ + attn_flops
605
+ )
606
+ return num_flops_per_token
607
+
608
+ def num_matmul_params(self):
609
+ """
610
+ The number of parameters that participate in matmuls with the token stream,
611
+ i.e. contribute 2 FLOPs/param to the forward pass. Counted structurally: every
612
+ matmul in this model goes through the Linear class, while non-matmul params
613
+ (embeddings = lookups, per-layer scalars) are nn.Embedding or raw Parameters.
614
+ """
615
+ matmul_params = sum(m.weight.numel() for m in self.modules() if isinstance(m, Linear))
616
+ return matmul_params
617
+
618
+ def quantum_forward_flops_per_token(self):
619
+ """
620
+ Approximate arithmetic cost of the simulated quantum circuits.
621
+
622
+ RY gates and Z measurements each scale with register state size. CNOT
623
+ gates are basis permutations, so they contribute memory traffic but no
624
+ floating-point arithmetic here.
625
+ """
626
+ total = 0
627
+ for module in self.modules():
628
+ if isinstance(module, QuantumMLP):
629
+ states = 1 << module.num_qubits
630
+ rotations_and_measurement = module.depth + 2
631
+ total += (
632
+ 3
633
+ * module.num_registers
634
+ * module.num_qubits
635
+ * states
636
+ * rotations_and_measurement
637
+ )
638
+ return total
639
+
640
+ def conv_forward_flops_per_token(self):
641
+ """Arithmetic cost of the depthwise short convolutions (per token). The
642
+ in/out projections are Linear and already counted in num_matmul_params."""
643
+ total = 0
644
+ for block in self.transformer.h:
645
+ if block.conv is not None:
646
+ total += 2 * self.config.n_embd * block.conv.kernel
647
+ return total
648
+
649
+ def estimate_decode_flops(self, context_len):
650
+ """
651
+ Forward FLOPs to decode one token at a given context length during inference:
652
+ 2 FLOPs per matmul param, plus attention over min(context, window) per attention layer.
653
+ """
654
+ h = self.config.n_head
655
+ q = self.config.n_embd // self.config.n_head
656
+ attn_flops = 0
657
+ for i, block in enumerate(self.transformer.h):
658
+ if block.attn is None:
659
+ continue
660
+ window = self.window_sizes[i][0]
661
+ attn_flops += 4 * h * q * min(context_len, window)
662
+ decode_flops = (
663
+ 2 * self.num_matmul_params()
664
+ + self.quantum_forward_flops_per_token()
665
+ + self.conv_forward_flops_per_token()
666
+ + attn_flops
667
+ )
668
+ return decode_flops
669
+
670
+ def estimate_prefill_flops(self, num_tokens):
671
+ """Forward FLOPs to prefill a prompt: causal, so token t attends to min(t, window)."""
672
+ h = self.config.n_head
673
+ q = self.config.n_embd // self.config.n_head
674
+ attn_flops = 0
675
+ for i, block in enumerate(self.transformer.h):
676
+ if block.attn is None:
677
+ continue
678
+ window = self.window_sizes[i][0]
679
+ w = min(window, num_tokens)
680
+ attended_tokens = w * (w + 1) // 2 + (num_tokens - w) * w # ramp up to w, then flat
681
+ attn_flops += 4 * h * q * attended_tokens
682
+ prefill_flops = (
683
+ (2 * self.num_matmul_params()
684
+ + self.quantum_forward_flops_per_token()
685
+ + self.conv_forward_flops_per_token())
686
+ * num_tokens
687
+ + attn_flops
688
+ )
689
+ return prefill_flops
690
+
691
+ def kv_bytes_per_token(self):
692
+ """Bytes to *store* one token of KV cache during inference, per row (all layers)."""
693
+ head_dim = self.config.n_embd // self.config.n_head
694
+ kv_dtype_bytes = COMPUTE_DTYPE.itemsize # the KV cache is kept in the compute dtype
695
+ return self.config.n_layer * 2 * self.config.n_kv_head * head_dim * kv_dtype_bytes
696
+
697
+ def kv_read_bytes(self, context_len):
698
+ """Bytes of KV cache *read* by one decode step at a given context length, per row.
699
+ Sliding window layers only attend to (and read) the last `window` tokens."""
700
+ head_dim = self.config.n_embd // self.config.n_head
701
+ kv_dtype_bytes = COMPUTE_DTYPE.itemsize
702
+ total = 0
703
+ for window, _ in self.window_sizes:
704
+ total += 2 * self.config.n_kv_head * head_dim * kv_dtype_bytes * min(context_len, window)
705
+ return total
706
+
707
+ def num_scaling_params(self):
708
+ """
709
+ Return detailed parameter counts for scaling law analysis.
710
+ Different papers use different conventions:
711
+ - Kaplan et al. excluded embedding parameters
712
+ - Chinchilla included all parameters
713
+ Ref: https://arxiv.org/abs/2203.15556 (Chinchilla paper)
714
+ Ref: https://arxiv.org/abs/2001.08361 (Kaplan et al. original scaling laws paper)
715
+
716
+ Returns a dict with counts for each parameter group, so downstream analysis
717
+ can experiment with which combination gives the cleanest scaling laws.
718
+ """
719
+ # Count each group separately (mirrors the grouping in setup_optimizers)
720
+ wte = sum(p.numel() for p in self.transformer.wte.parameters())
721
+ value_embeds = sum(p.numel() for p in self.value_embeds.parameters())
722
+ lm_head = sum(p.numel() for p in self.lm_head.parameters())
723
+ transformer_matrices = sum(p.numel() for p in self.transformer.h.parameters())
724
+ scalars = self.resid_lambdas.numel() + self.x0_lambdas.numel() + self.smear_gate.weight.numel() + self.smear_lambda.numel() + self.backout_lambda.numel()
725
+ total = wte + value_embeds + lm_head + transformer_matrices + scalars
726
+ assert total == sum(p.numel() for p in self.parameters()), "Parameter count mismatch"
727
+ return {
728
+ 'wte': wte,
729
+ 'value_embeds': value_embeds,
730
+ 'lm_head': lm_head,
731
+ 'transformer_matrices': transformer_matrices,
732
+ 'scalars': scalars,
733
+ 'total': total,
734
+ }
735
+
736
+ def setup_optimizer(self, unembedding_lr=0.004, embedding_lr=0.2, matrix_lr=0.02, weight_decay=0.0, scalar_lr=0.5, muon_group_size=-1, circuit_lr=None):
737
+ model_dim = self.config.n_embd
738
+
739
+ # Separate out all parameters into groups
740
+ transformer_params = list(self.transformer.h.parameters())
741
+ circuit_params = [
742
+ p
743
+ for module in self.transformer.h.modules()
744
+ if isinstance(module, QuantumMLP)
745
+ for p in module.parameters()
746
+ ]
747
+ circuit_param_ids = {id(p) for p in circuit_params}
748
+ non_circuit = [p for p in transformer_params if id(p) not in circuit_param_ids]
749
+ # Muon only handles 2D matrices. The depthwise short-conv weight is 3D
750
+ # (channels, 1, kernel), so it joins the quantum-circuit params in AdamW.
751
+ matrix_params = [p for p in non_circuit if p.ndim == 2]
752
+ conv_params = [p for p in non_circuit if p.ndim != 2]
753
+ assert all(p.ndim == 2 for p in matrix_params), "Muon only supports matrix parameters"
754
+ # The quantum circuit's parameters are rotation angles, not linear weights: they
755
+ # are periodic and much more LR-sensitive, so circuit_lr optionally puts them in
756
+ # their own AdamW group. Leaving it None keeps the single combined group, which
757
+ # matters because splitting changes the param_group layout and therefore breaks
758
+ # warm-starting the optimizer from a checkpoint saved without the split.
759
+ if circuit_lr is None:
760
+ adamw_extra_params = circuit_params + conv_params # non-matrix transformer params
761
+ else:
762
+ adamw_extra_params = conv_params
763
+ value_embeds_params = list(self.value_embeds.parameters())
764
+ embedding_params = list(self.transformer.wte.parameters())
765
+ lm_head_params = list(self.lm_head.parameters())
766
+ resid_params = [self.resid_lambdas]
767
+ x0_params = [self.x0_lambdas]
768
+ smear_params = [self.smear_gate.weight, self.smear_lambda, self.backout_lambda]
769
+ split_circuit_params = [] if circuit_lr is None else circuit_params # counted separately below when split out
770
+ assert len(list(self.parameters())) == len(matrix_params) + len(adamw_extra_params) + len(split_circuit_params) + len(embedding_params) + len(lm_head_params) + len(value_embeds_params) + len(resid_params) + len(x0_params) + len(smear_params)
771
+
772
+ # Scale the LR for the AdamW parameters by ∝1/√dmodel (tuned for 768 dim model)
773
+ dmodel_lr_scale = (model_dim / 768) ** -0.5
774
+ print0(f"Scaling the LR for the AdamW parameters ∝1/√({model_dim}/768) = {dmodel_lr_scale:.6f}")
775
+
776
+ # Build param_groups with all required fields explicit
777
+ param_groups = [
778
+ # AdamW groups (embeddings, lm_head, scalars)
779
+ dict(kind='adamw', params=lm_head_params, lr=unembedding_lr * dmodel_lr_scale, betas=(0.8, 0.96), eps=1e-10, weight_decay=0.01),
780
+ dict(kind='adamw', params=embedding_params, lr=embedding_lr * dmodel_lr_scale, betas=(0.8, 0.995), eps=1e-10, weight_decay=0.001),
781
+ dict(kind='adamw', params=value_embeds_params, lr=embedding_lr * dmodel_lr_scale * 0.5, betas=(0.8, 0.995), eps=1e-10, weight_decay=0.01),
782
+ dict(kind='adamw', params=resid_params, lr=scalar_lr * 0.01, betas=(0.8, 0.95), eps=1e-10, weight_decay=0.05),
783
+ dict(kind='adamw', params=x0_params, lr=scalar_lr, betas=(0.96, 0.95), eps=1e-10, weight_decay=0.0), # higher beta1 for x0
784
+ dict(kind='adamw', params=smear_params, lr=0.2, betas=(0.8, 0.95), eps=1e-10, weight_decay=0.0),
785
+ ]
786
+ if adamw_extra_params:
787
+ param_groups.append(dict(
788
+ kind='adamw', params=adamw_extra_params, lr=matrix_lr,
789
+ betas=(0.9, 0.99), eps=1e-8, weight_decay=weight_decay,
790
+ ))
791
+ if circuit_lr is not None and circuit_params:
792
+ print0(f"Quantum circuit parameters in their own AdamW group at lr={circuit_lr}")
793
+ param_groups.append(dict(
794
+ kind='adamw', params=circuit_params, lr=circuit_lr,
795
+ betas=(0.9, 0.99), eps=1e-8, weight_decay=0.0,
796
+ ))
797
+ # Muon groups (matrix params, grouped by shape for stacking)
798
+ for shape in sorted({p.shape for p in matrix_params}):
799
+ shape_params = [p for p in matrix_params if p.shape == shape]
800
+ group_size = len(shape_params) if muon_group_size <= 0 else muon_group_size
801
+ for start in range(0, len(shape_params), group_size):
802
+ group_params = shape_params[start:start + group_size]
803
+ param_groups.append(dict(
804
+ kind='muon', params=group_params, lr=matrix_lr,
805
+ momentum=0.95, ns_steps=5, beta2=0.9, weight_decay=weight_decay,
806
+ ))
807
+
808
+ optimizer = MuonAdamW(param_groups)
809
+ for group in optimizer.param_groups:
810
+ group["initial_lr"] = group["lr"]
811
+ return optimizer
812
+
813
+ def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
814
+ B, T = idx.size()
815
+
816
+ # Grab the rotary embeddings for the current sequence length (they are of shape (1, seq_len, 1, head_dim/2))
817
+ assert T <= self.cos.size(1), f"Sequence length grew beyond the rotary embeddings cache: {T} > {self.cos.size(1)}"
818
+ assert idx.device == self.cos.device, f"Rotary embeddings and idx are on different devices: {idx.device} != {self.cos.device}"
819
+ assert self.cos.dtype == COMPUTE_DTYPE, f"Rotary embeddings must be in {COMPUTE_DTYPE}, got {self.cos.dtype}"
820
+ # if kv cache exists, we need to offset the rotary embeddings to the current position in the cache
821
+ T0 = 0 if kv_cache is None else kv_cache.get_pos()
822
+ cos_sin = self.cos[:, T0:T0+T], self.sin[:, T0:T0+T] # truncate cache to current sequence length
823
+
824
+ # Embed the tokens
825
+ x = self.transformer.wte(idx) # embed current token
826
+ x = x.to(COMPUTE_DTYPE) # ensure activations are in compute dtype (no-op usually, but active for fp16 code path)
827
+ x = norm(x)
828
+
829
+ # Smear: mix previous token's embedding into current position (cheap bigram info)
830
+ if kv_cache is None:
831
+ # Training / naive generate: full sequence available, use fast slice
832
+ assert T > 1, "Training forward pass should have T > 1"
833
+ gate = self.smear_lambda.to(x.dtype) * torch.sigmoid(self.smear_gate(x[:, 1:, :24]))
834
+ x = torch.cat([x[:, :1], x[:, 1:] + gate * x[:, :-1]], dim=1)
835
+ else:
836
+ # KV cache inference: read prev embedding from cache, store current for next step
837
+ x_pre_smear = kv_cache.prev_embedding
838
+ kv_cache.prev_embedding = x[:, -1:, :]
839
+ if T > 1:
840
+ # Prefill: apply smear to positions 1+, same as training
841
+ gate = self.smear_lambda.to(x.dtype) * torch.sigmoid(self.smear_gate(x[:, 1:, :24]))
842
+ x = torch.cat([x[:, :1], x[:, 1:] + gate * x[:, :-1]], dim=1)
843
+ elif x_pre_smear is not None:
844
+ # Decode: single token, use cached prev embedding
845
+ gate = self.smear_lambda.to(x.dtype) * torch.sigmoid(self.smear_gate(x[:, :, :24]))
846
+ x = x + gate * x_pre_smear
847
+
848
+ # Forward the trunk of the Transformer
849
+ x0 = x # save initial normalized embedding for x0 residual
850
+ n_layer = self.config.n_layer
851
+ backout_layer = n_layer // 2 # cache at halfway point
852
+ x_backout = None
853
+ for i, block in enumerate(self.transformer.h):
854
+ x = self.resid_lambdas[i] * x + self.x0_lambdas[i] * x0
855
+ ve = self.value_embeds[str(i)](idx).to(x.dtype) if str(i) in self.value_embeds else None
856
+ if self.gradient_checkpointing and self.training and kv_cache is None:
857
+ # Recompute the block during backward instead of retaining its
858
+ # large attention and quantum-statevector activations.
859
+ def block_forward(block_input, value_embedding, block=block, window_size=self.window_sizes[i]):
860
+ return block(block_input, value_embedding, cos_sin, window_size, None)
861
+ x = checkpoint(block_forward, x, ve, use_reentrant=False)
862
+ else:
863
+ x = block(x, ve, cos_sin, self.window_sizes[i], kv_cache)
864
+ if i == backout_layer:
865
+ x_backout = x
866
+ # Subtract mid-layer residual to remove low-level features before logit projection
867
+ if x_backout is not None:
868
+ x = x - self.backout_lambda.to(x.dtype) * x_backout
869
+ x = norm(x)
870
+
871
+ # Forward the lm_head (compute logits)
872
+ softcap = 15 # smoothly cap the logits to the range [-softcap, softcap]
873
+ logits = self.lm_head(x) # (B, T, padded_vocab_size) <- very big tensor, large amount of memory
874
+ logits = logits[..., :self.config.vocab_size] # slice to remove padding
875
+ logits = logits.float() # switch to fp32 for logit softcap and loss computation
876
+ logits = softcap * torch.tanh(logits / softcap) # squash the logits
877
+
878
+ if targets is not None:
879
+ # training: given the targets, compute and return the loss
880
+ # TODO experiment with chunked cross-entropy?
881
+ loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1, reduction=loss_reduction)
882
+ return loss
883
+ else:
884
+ # inference: just return the logits directly
885
+ return logits
886
+
887
+ @torch.inference_mode()
888
+ def generate(self, tokens, max_tokens, temperature=1.0, top_k=None, seed=42):
889
+ """
890
+ Naive autoregressive streaming inference.
891
+ To make it super simple, let's assume:
892
+ - batch size is 1
893
+ - ids and the yielded tokens are simple Python lists and ints
894
+ """
895
+ assert isinstance(tokens, list)
896
+ device = self.get_device()
897
+ rng = None
898
+ if temperature > 0:
899
+ rng = torch.Generator(device=device)
900
+ rng.manual_seed(seed)
901
+ ids = torch.tensor([tokens], dtype=torch.long, device=device) # add batch dim
902
+ for _ in range(max_tokens):
903
+ logits = self.forward(ids) # (B, T, vocab_size)
904
+ logits = logits[:, -1, :] # (B, vocab_size)
905
+ if top_k is not None and top_k > 0:
906
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
907
+ logits[logits < v[:, [-1]]] = -float('Inf')
908
+ if temperature > 0:
909
+ logits = logits / temperature
910
+ probs = F.softmax(logits, dim=-1)
911
+ next_ids = torch.multinomial(probs, num_samples=1, generator=rng)
912
+ else:
913
+ next_ids = torch.argmax(logits, dim=-1, keepdim=True)
914
+ ids = torch.cat((ids, next_ids), dim=1)
915
+ token = next_ids.item()
916
+ yield token
nanochat/loss_eval.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A number of functions that help with evaluating a base model.
3
+ """
4
+ import math
5
+ import torch
6
+ import torch.distributed as dist
7
+
8
+ @torch.no_grad()
9
+ def evaluate_bpb(model, batches, steps, token_bytes):
10
+ """
11
+ Instead of the naive 'mean loss', this function returns the bits per byte (bpb),
12
+ which is a tokenization vocab size-independent metric, meaning you are still comparing
13
+ apples:apples if you change the vocab size. The way this works is that instead of just
14
+ calculating the average loss as usual, you calculate the sum loss, and independently
15
+ also the sum bytes (of all the target tokens), and divide. This normalizes the loss by
16
+ the number of bytes that the target tokens represent.
17
+
18
+ The added complexity is so that:
19
+ 1) All "normal" tokens are normalized by the length of the token in bytes
20
+ 2) No special tokens (e.g. <|bos|>) are included in the metric - they are masked out.
21
+ 3) No actively masked tokens (using ignore_index of e.g. -1) are included in the metric.
22
+
23
+ In addition to evaluate_loss, we need the token_bytes tensor:
24
+ It is a 1D tensor of shape (vocab_size,), indicating the number of bytes for
25
+ each token id, or 0 if the token is to not be counted (e.g. special tokens).
26
+ """
27
+ # record the losses
28
+ total_nats = torch.tensor(0.0, dtype=torch.float32, device=model.get_device())
29
+ total_bytes = torch.tensor(0, dtype=torch.int64, device=model.get_device())
30
+ batch_iter = iter(batches)
31
+ for _ in range(steps):
32
+ x, y = next(batch_iter)
33
+ loss2d = model(x, y, loss_reduction='none') # (B, T)
34
+ loss2d = loss2d.view(-1) # flatten
35
+ y = y.view(-1) # flatten
36
+ if (y.int() < 0).any(): # mps does not currently have kernel for < 0 for int64, only int32
37
+ # slightly more complex code path if some target tokens are ignore_index (e.g. -1)
38
+ # any target token < 0 is to be ignored: do NOT index token_bytes with negatives
39
+ valid = y >= 0
40
+ y_safe = torch.where(valid, y, torch.zeros_like(y))
41
+ # map valid targets to their byte length; ignored targets contribute 0 bytes
42
+ num_bytes2d = torch.where(
43
+ valid,
44
+ token_bytes[y_safe],
45
+ torch.zeros_like(y, dtype=token_bytes.dtype)
46
+ )
47
+ total_nats += (loss2d * (num_bytes2d > 0)).sum()
48
+ total_bytes += num_bytes2d.sum()
49
+ else:
50
+ # fast path: no ignored targets, safe to index directly
51
+ num_bytes2d = token_bytes[y]
52
+ total_nats += (loss2d * (num_bytes2d > 0)).sum()
53
+ total_bytes += num_bytes2d.sum()
54
+ # sum reduce across all ranks
55
+ world_size = dist.get_world_size() if dist.is_initialized() else 1
56
+ if world_size > 1:
57
+ dist.all_reduce(total_nats, op=dist.ReduceOp.SUM)
58
+ dist.all_reduce(total_bytes, op=dist.ReduceOp.SUM)
59
+ # move both to cpu, calculate bpb and return
60
+ total_nats = total_nats.item()
61
+ total_bytes = total_bytes.item()
62
+ if total_bytes == 0:
63
+ return float('inf')
64
+ bpb = total_nats / (math.log(2) * total_bytes)
65
+ return bpb
nanochat/optim.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A nice and efficient mixed AdamW/Muon Combined Optimizer.
3
+ Usually the embeddings and scalars go into AdamW, and the matrix parameters go into Muon.
4
+ The same class handles both single GPU and distributed training: when there is no
5
+ multi-rank process group, the communication ops are simply skipped and every rank
6
+ (i.e. the only rank) owns all of the parameters.
7
+
8
+ Adapted from: https://github.com/KellerJordan/modded-nanogpt
9
+ Further contributions from @karpathy and @chrisjmccormick.
10
+ """
11
+
12
+ import torch
13
+ import torch.distributed as dist
14
+ from torch import Tensor
15
+ from nanochat.common import COMPUTE_DTYPE
16
+
17
+ # -----------------------------------------------------------------------------
18
+ """
19
+ Good old AdamW optimizer, fused kernel.
20
+ https://arxiv.org/abs/1711.05101
21
+ """
22
+
23
+ @torch.compile(dynamic=False, fullgraph=True)
24
+ def adamw_step_fused(
25
+ p: Tensor, # (32768, 768) - parameter tensor
26
+ grad: Tensor, # (32768, 768) - gradient, same shape as p
27
+ exp_avg: Tensor, # (32768, 768) - first moment, same shape as p
28
+ exp_avg_sq: Tensor, # (32768, 768) - second moment, same shape as p
29
+ step_t: Tensor, # () - 0-D CPU tensor, step count
30
+ lr_t: Tensor, # () - 0-D CPU tensor, learning rate
31
+ beta1_t: Tensor, # () - 0-D CPU tensor, beta1
32
+ beta2_t: Tensor, # () - 0-D CPU tensor, beta2
33
+ eps_t: Tensor, # () - 0-D CPU tensor, epsilon
34
+ wd_t: Tensor, # () - 0-D CPU tensor, weight decay
35
+ ) -> None:
36
+ """
37
+ Fused AdamW step: weight_decay -> momentum_update -> bias_correction -> param_update
38
+ All in one compiled graph to eliminate Python overhead between ops.
39
+ The 0-D CPU tensors avoid recompilation when hyperparameter values change.
40
+ """
41
+ # Some params (wte, value_embeds) are stored in bf16, so do the math in fp32 and
42
+ # cast back at the end. MPS errors on mixed-dtype ops (CUDA promotes them), and
43
+ # scalar arithmetic like 1 - beta2 loses all precision in bf16. compile fuses the casts.
44
+ p32 = p.float()
45
+ exp_avg32 = exp_avg.float()
46
+ exp_avg_sq32 = exp_avg_sq.float()
47
+ grad32 = grad.float()
48
+ # Weight decay (decoupled, applied before the update)
49
+ p32.mul_(1 - lr_t * wd_t)
50
+ # Update running averages (lerp_ is cleaner and fuses well)
51
+ exp_avg32.lerp_(grad32, 1 - beta1_t)
52
+ exp_avg_sq32.lerp_(grad32.square(), 1 - beta2_t)
53
+ # Bias corrections
54
+ bias1 = 1 - beta1_t ** step_t
55
+ bias2 = 1 - beta2_t ** step_t
56
+ # Compute update and apply
57
+ denom = (exp_avg_sq32 / bias2).sqrt() + eps_t
58
+ step_size = lr_t / bias1
59
+ p32.add_(exp_avg32 / denom, alpha=-step_size)
60
+ # Write back (no-ops in the common case where everything is already fp32)
61
+ p.copy_(p32)
62
+ exp_avg.copy_(exp_avg32)
63
+ exp_avg_sq.copy_(exp_avg_sq32)
64
+
65
+ # -----------------------------------------------------------------------------
66
+ """
67
+ Muon optimizer adapted and simplified from modded-nanogpt.
68
+ https://github.com/KellerJordan/modded-nanogpt
69
+
70
+ Background:
71
+ Newton-Schulz iteration to compute the zeroth power / orthogonalization of G. We opt to use a
72
+ quintic iteration whose coefficients are selected to maximize the slope at zero. For the purpose
73
+ of minimizing steps, it turns out to be empirically effective to keep increasing the slope at
74
+ zero even beyond the point where the iteration no longer converges all the way to one everywhere
75
+ on the interval. This iteration therefore does not produce UV^T but rather something like US'V^T
76
+ where S' is diagonal with S_{ii}' ~ Uniform(0.5, 1.5), which turns out not to hurt model
77
+ performance at all relative to UV^T, where USV^T = G is the SVD.
78
+
79
+ Here, an alternative to Newton-Schulz iteration with potentially better convergence properties:
80
+ Polar Express Sign Method for orthogonalization.
81
+ https://arxiv.org/pdf/2505.16932
82
+ by Noah Amsel, David Persson, Christopher Musco, Robert M. Gower.
83
+
84
+ NorMuon variance reduction: per-neuron/column adaptive learning rate that normalizes
85
+ update scales after orthogonalization (Muon's output has non-uniform scales across neurons).
86
+ https://arxiv.org/pdf/2510.05491
87
+
88
+ Two more (very) slight and optional improvements:
89
+ 1) MuonEq row equilibration: rescale each row to the mean row norm so the spectrum
90
+ entering orthogonalization is better conditioned (https://arxiv.org/abs/2603.28254)
91
+ 2) Muon+ renormalization: snap the Frobenius norm to sqrt(min(m, n)), the norm of an exactly
92
+ semi-orthogonal matrix, correcting for under-convergence of the polar iteration (https://arxiv.org/abs/2602.21545)
93
+
94
+ Some of the changes in nanochat implementation:
95
+ - Uses a simpler, more general approach to parameter grouping and stacking
96
+ - Uses a single fused kernel for the momentum -> polar_express -> variance_reduction -> update step
97
+ - Makes no assumptions about model architecture (e.g. that attention weights are fused into QKVO format)
98
+ """
99
+
100
+ # Coefficients for Polar Express (computed for num_iters=5, safety_factor=2e-2, cushion=2)
101
+ # From https://arxiv.org/pdf/2505.16932
102
+ polar_express_coeffs = [
103
+ (8.156554524902461, -22.48329292557795, 15.878769915207462),
104
+ (4.042929935166739, -2.808917465908714, 0.5000178451051316),
105
+ (3.8916678022926607, -2.772484153217685, 0.5060648178503393),
106
+ (3.285753657755655, -2.3681294933425376, 0.46449024233003106),
107
+ (2.3465413258596377, -1.7097828382687081, 0.42323551169305323),
108
+ ]
109
+
110
+
111
+ @torch.compile(dynamic=False, fullgraph=True)
112
+ def muon_step_fused(
113
+ stacked_grads: Tensor, # (12, 768, 3072) - stacked gradients
114
+ stacked_params: Tensor, # (12, 768, 3072) - stacked parameters
115
+ momentum_buffer: Tensor, # (12, 768, 3072) - first moment buffer
116
+ second_momentum_buffer: Tensor, # (12, 768, 1) or (12, 1, 3072) - factored second moment
117
+ momentum_t: Tensor, # () - 0-D CPU tensor, momentum coefficient
118
+ lr_t: Tensor, # () - 0-D CPU tensor, learning rate
119
+ wd_t: Tensor, # () - 0-D CPU tensor, weight decay
120
+ beta2_t: Tensor, # () - 0-D CPU tensor, beta2 for second moment
121
+ ns_steps: int, # 5 - number of Newton-Schulz/Polar Express iterations
122
+ red_dim: int, # -1 or -2 - reduction dimension for variance
123
+ ) -> None:
124
+ """
125
+ Fused Muon step: momentum -> polar_express -> variance_reduction -> cautious_update
126
+ All in one compiled graph to eliminate Python overhead between ops.
127
+ Some of the constants are 0-D CPU tensors to avoid recompilation when values change.
128
+ """
129
+
130
+ # Nesterov momentum
131
+ momentum = momentum_t.to(stacked_grads.dtype)
132
+ momentum_buffer.lerp_(stacked_grads, 1 - momentum)
133
+ g = stacked_grads.lerp_(momentum_buffer, momentum)
134
+
135
+ # Cast to bf16 for speed when available; skip cast otherwise (fp16 is unstable here due to limited exponent range)
136
+ X = g.bfloat16() if COMPUTE_DTYPE == torch.bfloat16 else g
137
+
138
+ # MuonEq row equilibration: rescale each row to the mean row norm so the spectrum entering orthogonalization is better conditioned
139
+ target = X.float().norm(dim=(-2, -1), keepdim=True) / (X.size(-2) ** 0.5)
140
+ row_norm = X.float().norm(dim=-1, keepdim=True).clamp_min(1e-6)
141
+ X = X * (target / row_norm).to(X.dtype)
142
+
143
+ # Polar Express orthogonalization: replace each update with the nearest orthogonal matrix
144
+ X = X / (X.norm(dim=(-2, -1), keepdim=True) * 1.01 + 1e-6)
145
+ if g.size(-2) > g.size(-1): # Tall matrix
146
+ for a, b, c in polar_express_coeffs[:ns_steps]:
147
+ A = X.mT @ X
148
+ B = b * A + c * (A @ A)
149
+ X = a * X + X @ B
150
+ else: # Wide matrix (original math)
151
+ for a, b, c in polar_express_coeffs[:ns_steps]:
152
+ A = X @ X.mT
153
+ B = b * A + c * (A @ A)
154
+ X = a * X + B @ X
155
+ # Cast back to the param dtype (MPS errors on the mixed-dtype ops below when X is bf16)
156
+ g = X.to(stacked_params.dtype)
157
+
158
+ # Muon+ renormalization: snap Frobenius norm to sqrt(min(m, n))
159
+ target_norm = min(g.size(-2), g.size(-1)) ** 0.5
160
+ current_norm = g.float().norm(dim=(-2, -1), keepdim=True).clamp_min(1e-6)
161
+ g = g * (target_norm / current_norm).to(g.dtype)
162
+
163
+ # Variance reduction
164
+ beta2 = beta2_t.to(g.dtype)
165
+ v_mean = g.float().square().mean(dim=red_dim, keepdim=True)
166
+ red_dim_size = g.size(red_dim)
167
+ v_norm_sq = v_mean.sum(dim=(-2, -1), keepdim=True) * red_dim_size
168
+ v_norm = v_norm_sq.sqrt()
169
+ second_momentum_buffer.lerp_(v_mean.to(dtype=second_momentum_buffer.dtype), 1 - beta2)
170
+ step_size = second_momentum_buffer.clamp_min(1e-10).rsqrt()
171
+ scaled_sq_sum = (v_mean * red_dim_size) * step_size.float().square()
172
+ v_norm_new = scaled_sq_sum.sum(dim=(-2, -1), keepdim=True).sqrt()
173
+ final_scale = step_size * (v_norm / v_norm_new.clamp_min(1e-10))
174
+ g = g * final_scale.to(g.dtype)
175
+
176
+ # Cautious weight decay + parameter update
177
+ lr = lr_t.to(g.dtype)
178
+ wd = wd_t.to(g.dtype)
179
+ mask = (g * stacked_params) >= 0
180
+ if torch.compiler.is_compiling():
181
+ # Inductor fuses this expression into a single kernel.
182
+ stacked_params.sub_(lr * g + lr * wd * stacked_params * mask)
183
+ else:
184
+ # Without torch.compile the expression above materializes a full-size
185
+ # temporary update tensor. On large models that can cost multiple GiB. Apply
186
+ # decay before the gradient update so both operations are allocation-free
187
+ # while preserving p - lr*g - lr*wd*p*mask.
188
+ stacked_params.addcmul_(stacked_params, mask, value=-(lr * wd))
189
+ stacked_params.add_(g, alpha=-lr)
190
+
191
+ # -----------------------------------------------------------------------------
192
+
193
+ class MuonAdamW(torch.optim.Optimizer):
194
+ """
195
+ Combined optimizer: Muon for 2D matrix params, AdamW for others.
196
+
197
+ AdamW - Fused AdamW optimizer step.
198
+
199
+ Muon - MomentUm Orthogonalized by Newton-schulz
200
+ https://kellerjordan.github.io/posts/muon/
201
+
202
+ Muon internally runs standard SGD-momentum, and then performs an orthogonalization post-
203
+ processing step, in which each 2D parameter's update is replaced with the nearest orthogonal
204
+ matrix. To efficiently orthogonalize each update, we use a Newton-Schulz iteration, which has
205
+ the advantage that it can be stably run in bfloat16 on the GPU.
206
+
207
+ Some warnings:
208
+ - The Muon optimizer should not be used for the embedding layer, the final fully connected layer,
209
+ or any {0,1}-D parameters; those should all be optimized by a standard method (e.g., AdamW).
210
+ - To use it with 4D convolutional filters, it works well to just flatten their last 3 dimensions.
211
+
212
+ The same class covers single GPU and distributed training. In the distributed setting
213
+ (a multi-rank process group is initialized), gradients are synchronized here in the
214
+ optimizer (nanochat does not use DDP) and optimizer states are sharded across ranks
215
+ (ZeRO-2 style). On a single rank, all communication is skipped and the rank owns all
216
+ parameters, so the sharded code paths degenerate to plain full-tensor updates.
217
+
218
+ Design Goals:
219
+ - Overlap communication with computation (async ops)
220
+ - Minimize memory by sharding optimizer states across ranks (ZeRO-2 style)
221
+ - Batch small tensors into single comm ops where possible
222
+
223
+ Communication Pattern (3-phase async):
224
+ We use a 3-phase structure to maximize overlap between communication and compute:
225
+
226
+ Phase 1: Launch all async reduce ops
227
+ - Kick off all reduce_scatter/all_reduce operations
228
+ - Don't wait - let them run in background while we continue
229
+
230
+ Phase 2: Wait for reduces, compute updates, launch gathers
231
+ - For each group: wait for its reduce, compute the update, launch gather
232
+ - By processing groups in order, earlier gathers run while later computes happen
233
+
234
+ Phase 3: Wait for gathers, copy back
235
+ - Wait for all gathers to complete
236
+ - Copy updated params back to original tensors (Muon only)
237
+
238
+ AdamW Communication (ZeRO-2 style):
239
+ - Small params (<1024 elements): all_reduce gradients, update full param on each rank.
240
+ Optimizer state is replicated but these params are tiny (scalars, biases).
241
+ - Large params: reduce_scatter gradients so each rank gets 1/N of the grad, update
242
+ only that slice, then all_gather the updated slices. Optimizer state (exp_avg,
243
+ exp_avg_sq) is sharded - each rank only stores state for its slice.
244
+ Requires param.shape[0] divisible by world_size.
245
+
246
+ Muon Communication (stacked + chunked):
247
+ - All params in a Muon group must have the same shape (caller's responsibility).
248
+ - Stack all K params into a single (K, *shape) tensor for efficient comm.
249
+ - Divide K params across N ranks: each rank "owns" ceil(K/N) params.
250
+ - reduce_scatter the stacked grads so each rank gets its chunk.
251
+ - Each rank computes Muon update only for params it owns.
252
+ - all_gather the updated params back to all ranks.
253
+ - Optimizer state (momentum_buffer, second_momentum_buffer) is sharded by chunk.
254
+ - Padding: if K doesn't divide evenly, we zero-pad to (ceil(K/N) * N) for comm,
255
+ then ignore the padding when copying back.
256
+
257
+ Buffer Reuse:
258
+ - For Muon, we allocate stacked_grads for reduce_scatter input, then reuse the
259
+ same buffer as the output for all_gather (stacked_params). This saves memory
260
+ since we don't need both buffers simultaneously.
261
+
262
+ Arguments:
263
+ param_groups: List of dicts, each containing:
264
+ - 'params': List of parameters
265
+ - 'kind': 'adamw' or 'muon'
266
+ - For AdamW groups: 'lr', 'betas', 'eps', 'weight_decay'
267
+ - For Muon groups: 'lr', 'momentum', 'ns_steps', 'beta2', 'weight_decay'
268
+ """
269
+ def __init__(self, param_groups: list[dict]):
270
+ super().__init__(param_groups, defaults={})
271
+ # 0-D CPU tensors to avoid torch.compile recompilation when values change
272
+ self._adamw_step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
273
+ self._adamw_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
274
+ self._adamw_beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
275
+ self._adamw_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
276
+ self._adamw_eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
277
+ self._adamw_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
278
+ self._muon_momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
279
+ self._muon_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
280
+ self._muon_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
281
+ self._muon_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
282
+
283
+ def _reduce_adamw(self, group: dict, world_size: int) -> dict:
284
+ """Launch async reduce ops for AdamW group. Returns info dict with per-param infos."""
285
+ param_infos = {}
286
+ for p in group['params']:
287
+ grad = p.grad
288
+ if world_size == 1:
289
+ # Single rank: no communication, update the full param in place
290
+ param_infos[p] = dict(future=None, grad_slice=grad, is_small=True)
291
+ elif p.numel() < 1024:
292
+ # Small params: all_reduce (no scatter/gather needed)
293
+ future = dist.all_reduce(grad, op=dist.ReduceOp.AVG, async_op=True).get_future()
294
+ param_infos[p] = dict(future=future, grad_slice=grad, is_small=True)
295
+ else:
296
+ # Large params: reduce_scatter
297
+ assert grad.shape[0] % world_size == 0, f"AdamW reduce_scatter requires shape[0] ({grad.shape[0]}) divisible by world_size ({world_size})"
298
+ rank_size = grad.shape[0] // world_size
299
+ grad_slice = torch.empty_like(grad[:rank_size])
300
+ future = dist.reduce_scatter_tensor(grad_slice, grad, op=dist.ReduceOp.AVG, async_op=True).get_future()
301
+ param_infos[p] = dict(future=future, grad_slice=grad_slice, is_small=False)
302
+ return dict(param_infos=param_infos)
303
+
304
+ def _reduce_muon(self, group: dict, world_size: int) -> dict:
305
+ """Launch async reduce op for Muon group. Returns info dict."""
306
+ params = group['params']
307
+ if world_size == 1:
308
+ # Single rank: this rank owns all params, the stacked grads are the "chunk"
309
+ grad_chunk = torch.stack([p.grad for p in params])
310
+ return dict(future=None, grad_chunk=grad_chunk, stacked_grads=None, chunk_size=len(params))
311
+ chunk_size = (len(params) + world_size - 1) // world_size
312
+ padded_num_params = chunk_size * world_size
313
+ p = params[0]
314
+ shape, device, dtype = p.shape, p.device, p.dtype
315
+
316
+ # Stack grads and zero-pad to padded_num_params
317
+ grad_stack = torch.stack([p.grad for p in params])
318
+ stacked_grads = torch.empty(padded_num_params, *shape, dtype=dtype, device=device)
319
+ stacked_grads[:len(params)].copy_(grad_stack)
320
+ if len(params) < padded_num_params:
321
+ stacked_grads[len(params):].zero_()
322
+
323
+ # Reduce_scatter to get this rank's chunk
324
+ grad_chunk = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
325
+ future = dist.reduce_scatter_tensor(grad_chunk, stacked_grads, op=dist.ReduceOp.AVG, async_op=True).get_future()
326
+
327
+ return dict(future=future, grad_chunk=grad_chunk, stacked_grads=stacked_grads, chunk_size=chunk_size)
328
+
329
+ def _compute_adamw(self, group: dict, info: dict, gather_list: list, rank: int, world_size: int) -> None:
330
+ """Wait for reduce, compute AdamW updates, launch gathers for large params."""
331
+ param_infos = info['param_infos']
332
+ for p in group['params']:
333
+ pinfo = param_infos[p]
334
+ if pinfo['future'] is not None:
335
+ pinfo['future'].wait()
336
+ grad_slice = pinfo['grad_slice']
337
+ state = self.state[p]
338
+
339
+ # For small params, operate on full param; for large, operate on slice
340
+ if pinfo['is_small']:
341
+ p_slice = p
342
+ else:
343
+ rank_size = p.shape[0] // world_size
344
+ p_slice = p[rank * rank_size:(rank + 1) * rank_size]
345
+
346
+ # State init
347
+ if not state:
348
+ state['step'] = 0
349
+ state['exp_avg'] = torch.zeros_like(p_slice)
350
+ state['exp_avg_sq'] = torch.zeros_like(p_slice)
351
+ state['step'] += 1
352
+
353
+ # Fill 0-D tensors and run fused kernel
354
+ self._adamw_step_t.fill_(state['step'])
355
+ self._adamw_lr_t.fill_(group['lr'])
356
+ self._adamw_beta1_t.fill_(group['betas'][0])
357
+ self._adamw_beta2_t.fill_(group['betas'][1])
358
+ self._adamw_eps_t.fill_(group['eps'])
359
+ self._adamw_wd_t.fill_(group['weight_decay'])
360
+ adamw_step_fused(
361
+ p_slice, grad_slice, state['exp_avg'], state['exp_avg_sq'],
362
+ self._adamw_step_t, self._adamw_lr_t, self._adamw_beta1_t,
363
+ self._adamw_beta2_t, self._adamw_eps_t, self._adamw_wd_t,
364
+ )
365
+
366
+ # Large params need all_gather
367
+ if not pinfo['is_small']:
368
+ future = dist.all_gather_into_tensor(p, p_slice, async_op=True).get_future()
369
+ gather_list.append(dict(future=future, params=None))
370
+
371
+ def _compute_muon(self, group: dict, info: dict, gather_list: list, rank: int) -> None:
372
+ """Wait for reduce, compute Muon updates, launch gather."""
373
+ if info['future'] is not None:
374
+ info['future'].wait()
375
+ params = group['params']
376
+ chunk_size = info['chunk_size']
377
+ grad_chunk = info['grad_chunk']
378
+ p = params[0]
379
+ shape, device, dtype = p.shape, p.device, p.dtype
380
+
381
+ # How many params does this rank own?
382
+ start_idx = rank * chunk_size
383
+ num_owned = min(chunk_size, max(0, len(params) - start_idx))
384
+
385
+ # Get or create group-level state
386
+ state = self.state[p]
387
+ if "momentum_buffer" not in state:
388
+ state["momentum_buffer"] = torch.zeros(chunk_size, *shape, dtype=dtype, device=device)
389
+ if "second_momentum_buffer" not in state:
390
+ state_shape = (chunk_size, shape[-2], 1) if shape[-2] >= shape[-1] else (chunk_size, 1, shape[-1])
391
+ state["second_momentum_buffer"] = torch.zeros(state_shape, dtype=dtype, device=device)
392
+ red_dim = -1 if shape[-2] >= shape[-1] else -2
393
+
394
+ stacked_owned = None
395
+ if num_owned > 0:
396
+ owned_params = [params[start_idx + i] for i in range(num_owned)]
397
+ stacked_owned = torch.stack(owned_params)
398
+
399
+ # Fill 0-D tensors and run fused kernel
400
+ self._muon_momentum_t.fill_(group["momentum"])
401
+ self._muon_beta2_t.fill_(group["beta2"])
402
+ self._muon_lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1])**0.5)
403
+ self._muon_wd_t.fill_(group["weight_decay"])
404
+ muon_step_fused(
405
+ grad_chunk[:num_owned], stacked_owned,
406
+ state["momentum_buffer"][:num_owned], state["second_momentum_buffer"][:num_owned],
407
+ self._muon_momentum_t, self._muon_lr_t, self._muon_wd_t, self._muon_beta2_t,
408
+ group["ns_steps"], red_dim,
409
+ )
410
+
411
+ if info['stacked_grads'] is None:
412
+ # Single rank: copy back immediately so each temporary parameter
413
+ # stack can be released before the next Muon group is processed.
414
+ torch._foreach_copy_(params, list(stacked_owned.unbind(0)))
415
+ info["grad_chunk"] = None
416
+ return
417
+
418
+ # Build the input buffer for all_gather
419
+ updated_params = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
420
+ if num_owned > 0:
421
+ updated_params[:num_owned].copy_(stacked_owned)
422
+ if num_owned < chunk_size:
423
+ updated_params[num_owned:].zero_()
424
+
425
+ # Reuse stacked_grads buffer for all_gather output
426
+ stacked_params = info["stacked_grads"]
427
+ future = dist.all_gather_into_tensor(stacked_params, updated_params, async_op=True).get_future()
428
+ gather_list.append(dict(future=future, stacked_params=stacked_params, params=params))
429
+ info["grad_chunk"] = None
430
+
431
+ def _finish_gathers(self, gather_list: list) -> None:
432
+ """Wait for all gathers and copy Muon params back."""
433
+ for info in gather_list:
434
+ if info["future"] is not None:
435
+ info["future"].wait()
436
+ if info["params"] is not None:
437
+ # Muon: copy from stacked buffer back to individual params
438
+ torch._foreach_copy_(info["params"], list(info["stacked_params"][:len(info["params"])].unbind(0)))
439
+
440
+ @torch.no_grad()
441
+ def step(self):
442
+ # On a single rank (no multi-rank process group), all communication is skipped
443
+ if dist.is_available() and dist.is_initialized():
444
+ rank = dist.get_rank()
445
+ world_size = dist.get_world_size()
446
+ else:
447
+ rank = 0
448
+ world_size = 1
449
+
450
+ # Phase 1: launch all async reduce ops
451
+ reduce_infos: list[dict] = []
452
+ for group in self.param_groups:
453
+ if group['kind'] == 'adamw':
454
+ reduce_infos.append(self._reduce_adamw(group, world_size))
455
+ elif group['kind'] == 'muon':
456
+ reduce_infos.append(self._reduce_muon(group, world_size))
457
+ else:
458
+ raise ValueError(f"Unknown optimizer kind: {group['kind']}")
459
+
460
+ # Phase 2: wait for reduces, compute updates, launch gathers
461
+ gather_list: list[dict] = []
462
+ for group, info in zip(self.param_groups, reduce_infos):
463
+ if group['kind'] == 'adamw':
464
+ self._compute_adamw(group, info, gather_list, rank, world_size)
465
+ elif group['kind'] == 'muon':
466
+ self._compute_muon(group, info, gather_list, rank)
467
+ else:
468
+ raise ValueError(f"Unknown optimizer kind: {group['kind']}")
469
+
470
+ # Phase 3: wait for gathers, copy back
471
+ self._finish_gathers(gather_list)
nanochat/tokenizer.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BPE Tokenizer in the style of GPT-4: train with rustbpe, inference with tiktoken.
3
+ """
4
+
5
+ import os
6
+ import copy
7
+ from functools import lru_cache
8
+
9
+ SPECIAL_TOKENS = [
10
+ # every document begins with the Beginning of Sequence (BOS) token that delimits documents
11
+ "<|bos|>",
12
+ # tokens below are only used during finetuning to render Conversations into token ids
13
+ "<|user_start|>", # user messages
14
+ "<|user_end|>",
15
+ "<|assistant_start|>", # assistant messages
16
+ "<|assistant_end|>",
17
+ "<|python_start|>", # assistant invokes python REPL tool
18
+ "<|python_end|>",
19
+ "<|output_start|>", # python REPL outputs back to assistant
20
+ "<|output_end|>",
21
+ ]
22
+
23
+ # NOTE: this split pattern deviates from GPT-4 in that we use \p{N}{1,2} instead of \p{N}{1,3}
24
+ # I did this because I didn't want to "waste" too many tokens on numbers for smaller vocab sizes.
25
+ # I verified that 2 is the sweet spot for vocab size of 32K. 1 is a bit worse, 3 was worse still.
26
+ SPLIT_PATTERN = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,2}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"""
27
+
28
+ # -----------------------------------------------------------------------------
29
+ # Tokenizer based on rustbpe + tiktoken combo
30
+ import pickle
31
+ import rustbpe
32
+ import tiktoken
33
+
34
+ class RustBPETokenizer:
35
+ """Light wrapper around tiktoken (for efficient inference) but train with rustbpe"""
36
+
37
+ def __init__(self, enc, bos_token):
38
+ self.enc = enc
39
+ self.bos_token_id = self.encode_special(bos_token)
40
+
41
+ @classmethod
42
+ def train_from_iterator(cls, text_iterator, vocab_size):
43
+ # 1) train using rustbpe
44
+ tokenizer = rustbpe.Tokenizer()
45
+ # the special tokens are inserted later in __init__, we don't train them here
46
+ vocab_size_no_special = vocab_size - len(SPECIAL_TOKENS)
47
+ assert vocab_size_no_special >= 256, f"vocab_size_no_special must be at least 256, got {vocab_size_no_special}"
48
+ tokenizer.train_from_iterator(text_iterator, vocab_size_no_special, pattern=SPLIT_PATTERN)
49
+ # 2) construct the associated tiktoken encoding for inference
50
+ pattern = tokenizer.get_pattern()
51
+ mergeable_ranks_list = tokenizer.get_mergeable_ranks()
52
+ mergeable_ranks = {bytes(k): v for k, v in mergeable_ranks_list}
53
+ tokens_offset = len(mergeable_ranks)
54
+ special_tokens = {name: tokens_offset + i for i, name in enumerate(SPECIAL_TOKENS)}
55
+ enc = tiktoken.Encoding(
56
+ name="rustbpe",
57
+ pat_str=pattern,
58
+ mergeable_ranks=mergeable_ranks, # dict[bytes, int] (token bytes -> merge priority rank)
59
+ special_tokens=special_tokens, # dict[str, int] (special token name -> token id)
60
+ )
61
+ return cls(enc, "<|bos|>")
62
+
63
+ @classmethod
64
+ def from_directory(cls, tokenizer_dir):
65
+ pickle_path = os.path.join(tokenizer_dir, "tokenizer.pkl")
66
+ with open(pickle_path, "rb") as f:
67
+ enc = pickle.load(f)
68
+ return cls(enc, "<|bos|>")
69
+
70
+ @classmethod
71
+ def from_pretrained(cls, tiktoken_name):
72
+ # https://github.com/openai/tiktoken/blob/eedc8563/tiktoken_ext/openai_public.py
73
+ enc = tiktoken.get_encoding(tiktoken_name)
74
+ # tiktoken calls the special document delimiter token "<|endoftext|>"
75
+ # yes this is confusing because this token is almost always PREPENDED to the beginning of the document
76
+ # it most often is used to signal the start of a new sequence to the LLM during inference etc.
77
+ # so in nanoChat we always use "<|bos|>" short for "beginning of sequence", but historically it is often called "<|endoftext|>".
78
+ return cls(enc, "<|endoftext|>")
79
+
80
+ def get_vocab_size(self):
81
+ return self.enc.n_vocab
82
+
83
+ def get_special_tokens(self):
84
+ return self.enc.special_tokens_set
85
+
86
+ def id_to_token(self, id):
87
+ return self.enc.decode([id])
88
+
89
+ @lru_cache(maxsize=32)
90
+ def encode_special(self, text):
91
+ return self.enc.encode_single_token(text)
92
+
93
+ def get_bos_token_id(self):
94
+ return self.bos_token_id
95
+
96
+ def encode(self, text, prepend=None, append=None, num_threads=8):
97
+ # text can be either a string or a list of strings
98
+
99
+ if prepend is not None:
100
+ prepend_id = prepend if isinstance(prepend, int) else self.encode_special(prepend)
101
+ if append is not None:
102
+ append_id = append if isinstance(append, int) else self.encode_special(append)
103
+
104
+ if isinstance(text, str):
105
+ ids = self.enc.encode_ordinary(text)
106
+ if prepend is not None:
107
+ ids.insert(0, prepend_id) # TODO: slightly inefficient here? :( hmm
108
+ if append is not None:
109
+ ids.append(append_id)
110
+ elif isinstance(text, list):
111
+ ids = self.enc.encode_ordinary_batch(text, num_threads=num_threads)
112
+ if prepend is not None:
113
+ for ids_row in ids:
114
+ ids_row.insert(0, prepend_id) # TODO: same
115
+ if append is not None:
116
+ for ids_row in ids:
117
+ ids_row.append(append_id)
118
+ else:
119
+ raise ValueError(f"Invalid input type: {type(text)}")
120
+
121
+ return ids
122
+
123
+ def __call__(self, *args, **kwargs):
124
+ return self.encode(*args, **kwargs)
125
+
126
+ def decode(self, ids):
127
+ return self.enc.decode(ids)
128
+
129
+ def decode_single_token_bytes(self, token_id):
130
+ return self.enc.decode_single_token_bytes(token_id)
131
+
132
+ def save(self, tokenizer_dir):
133
+ # save the encoding object to disk
134
+ os.makedirs(tokenizer_dir, exist_ok=True)
135
+ pickle_path = os.path.join(tokenizer_dir, "tokenizer.pkl")
136
+ with open(pickle_path, "wb") as f:
137
+ pickle.dump(self.enc, f)
138
+ print(f"Saved tokenizer encoding to {pickle_path}")
139
+
140
+ def render_conversation(self, conversation, max_tokens=2048):
141
+ """
142
+ Tokenize a single Chat conversation (which we call a "doc" or "document" here).
143
+ Returns:
144
+ - ids: list[int] is a list of token ids of this rendered conversation
145
+ - mask: list[int] of same length, mask = 1 for tokens that the Assistant is expected to train on.
146
+ """
147
+ # ids, masks that we will return and a helper function to help build them up.
148
+ ids, mask = [], []
149
+ def add_tokens(token_ids, mask_val):
150
+ if isinstance(token_ids, int):
151
+ token_ids = [token_ids]
152
+ ids.extend(token_ids)
153
+ mask.extend([mask_val] * len(token_ids))
154
+
155
+ # sometimes the first message is a system message...
156
+ # => just merge it with the second (user) message
157
+ if conversation["messages"][0]["role"] == "system":
158
+ # some conversation surgery is necessary here for now...
159
+ conversation = copy.deepcopy(conversation) # avoid mutating the original
160
+ messages = conversation["messages"]
161
+ assert messages[1]["role"] == "user", "System message must be followed by a user message"
162
+ messages[1]["content"] = messages[0]["content"] + "\n\n" + messages[1]["content"]
163
+ messages = messages[1:]
164
+ else:
165
+ messages = conversation["messages"]
166
+ assert len(messages) >= 1, f"Conversation has less than 1 message: {messages}"
167
+
168
+ # fetch all the special tokens we need
169
+ bos = self.get_bos_token_id()
170
+ user_start, user_end = self.encode_special("<|user_start|>"), self.encode_special("<|user_end|>")
171
+ assistant_start, assistant_end = self.encode_special("<|assistant_start|>"), self.encode_special("<|assistant_end|>")
172
+ python_start, python_end = self.encode_special("<|python_start|>"), self.encode_special("<|python_end|>")
173
+ output_start, output_end = self.encode_special("<|output_start|>"), self.encode_special("<|output_end|>")
174
+
175
+ # now we can tokenize the conversation
176
+ add_tokens(bos, 0)
177
+ for i, message in enumerate(messages):
178
+
179
+ # some sanity checking here around assumptions, to prevent footguns
180
+ must_be_from = "user" if i % 2 == 0 else "assistant"
181
+ assert message["role"] == must_be_from, f"Message {i} is from {message['role']} but should be from {must_be_from}"
182
+
183
+ # content can be either a simple string or a list of parts (e.g. containing tool calls)
184
+ content = message["content"]
185
+
186
+ if message["role"] == "user":
187
+ assert isinstance(content, str), "User messages are simply expected to be strings"
188
+ value_ids = self.encode(content)
189
+ add_tokens(user_start, 0)
190
+ add_tokens(value_ids, 0)
191
+ add_tokens(user_end, 0)
192
+ elif message["role"] == "assistant":
193
+ add_tokens(assistant_start, 0)
194
+ if isinstance(content, str):
195
+ # simple string => simply add the tokens
196
+ value_ids = self.encode(content)
197
+ add_tokens(value_ids, 1)
198
+ elif isinstance(content, list):
199
+ for part in content:
200
+ value_ids = self.encode(part["text"])
201
+ if part["type"] == "text":
202
+ # string part => simply add the tokens
203
+ add_tokens(value_ids, 1)
204
+ elif part["type"] == "python":
205
+ # python tool call => add the tokens inside <|python_start|> and <|python_end|>
206
+ add_tokens(python_start, 1)
207
+ add_tokens(value_ids, 1)
208
+ add_tokens(python_end, 1)
209
+ elif part["type"] == "python_output":
210
+ # python output => add the tokens inside <|output_start|> and <|output_end|>
211
+ # none of these tokens are supervised because the tokens come from Python at test time
212
+ add_tokens(output_start, 0)
213
+ add_tokens(value_ids, 0)
214
+ add_tokens(output_end, 0)
215
+ else:
216
+ raise ValueError(f"Unknown part type: {part['type']}")
217
+ else:
218
+ raise ValueError(f"Unknown content type: {type(content)}")
219
+ add_tokens(assistant_end, 1)
220
+
221
+ # truncate to max_tokens tokens MAX (helps prevent OOMs)
222
+ ids = ids[:max_tokens]
223
+ mask = mask[:max_tokens]
224
+ return ids, mask
225
+
226
+ def visualize_tokenization(self, ids, mask, with_token_id=False):
227
+ """Small helper function useful in debugging: visualize the tokenization of render_conversation"""
228
+ RED = '\033[91m'
229
+ GREEN = '\033[92m'
230
+ RESET = '\033[0m'
231
+ GRAY = '\033[90m'
232
+ tokens = []
233
+ for i, (token_id, mask_val) in enumerate(zip(ids, mask)):
234
+ token_str = self.decode([token_id])
235
+ color = GREEN if mask_val == 1 else RED
236
+ tokens.append(f"{color}{token_str}{RESET}")
237
+ if with_token_id:
238
+ tokens.append(f"{GRAY}({token_id}){RESET}")
239
+ return '|'.join(tokens)
240
+
241
+ def render_for_completion(self, conversation):
242
+ """
243
+ Used during Reinforcement Learning. In that setting, we want to
244
+ render the conversation priming the Assistant for a completion.
245
+ Unlike the Chat SFT case, we don't need to return the mask.
246
+ """
247
+ # We have some surgery to do: we need to pop the last message (of the Assistant)
248
+ conversation = copy.deepcopy(conversation) # avoid mutating the original
249
+ messages = conversation["messages"]
250
+ assert messages[-1]["role"] == "assistant", "Last message must be from the Assistant"
251
+ messages.pop() # remove the last message (of the Assistant) inplace
252
+
253
+ # Now tokenize the conversation
254
+ ids, mask = self.render_conversation(conversation)
255
+
256
+ # Finally, to prime the Assistant for a completion, append the Assistant start token
257
+ assistant_start = self.encode_special("<|assistant_start|>")
258
+ ids.append(assistant_start)
259
+ return ids
260
+
261
+ # -----------------------------------------------------------------------------
262
+ # nanochat-specific convenience functions
263
+
264
+ def get_tokenizer():
265
+ from nanochat.common import get_base_dir
266
+ base_dir = get_base_dir()
267
+ tokenizer_dir = os.path.join(base_dir, "tokenizer")
268
+ return RustBPETokenizer.from_directory(tokenizer_dir)
269
+
270
+ def get_token_bytes(device="cpu"):
271
+ import torch
272
+ from nanochat.common import get_base_dir
273
+ base_dir = get_base_dir()
274
+ tokenizer_dir = os.path.join(base_dir, "tokenizer")
275
+ token_bytes_path = os.path.join(tokenizer_dir, "token_bytes.pt")
276
+ assert os.path.exists(token_bytes_path), f"Token bytes not found at {token_bytes_path}? It gets written by tok_train.py"
277
+ with open(token_bytes_path, "rb") as f:
278
+ token_bytes = torch.load(f, map_location=device)
279
+ return token_bytes