Slothwolf commited on
Commit
54beca3
·
verified ·
1 Parent(s): 3ee19e4

Delete nanochat

Browse files
nanochat/__init__.py DELETED
File without changes
nanochat/checkpoint_manager.py DELETED
@@ -1,194 +0,0 @@
1
- """
2
- Utilities for saving and loading model/optim/state checkpoints.
3
- """
4
- import os
5
- import re
6
- import glob
7
- import json
8
- import logging
9
- import torch
10
-
11
- from nanochat.common import get_base_dir
12
- from nanochat.gpt import GPT, GPTConfig
13
- from nanochat.tokenizer import get_tokenizer
14
- from nanochat.common import setup_default_logging
15
-
16
- # Set up logging
17
- setup_default_logging()
18
- logger = logging.getLogger(__name__)
19
- def log0(message):
20
- if int(os.environ.get('RANK', 0)) == 0:
21
- logger.info(message)
22
-
23
- def _patch_missing_config_keys(model_config_kwargs):
24
- """Add default values for new config keys missing in old checkpoints."""
25
- # Old models were trained with full context (no sliding window)
26
- if "window_pattern" not in model_config_kwargs:
27
- model_config_kwargs["window_pattern"] = "L"
28
- log0(f"Patching missing window_pattern in model config to 'L'")
29
-
30
- def _patch_missing_keys(model_data, model_config):
31
- """Add default values for new parameters that may be missing in old checkpoints."""
32
- n_layer = model_config.n_layer
33
- # resid_lambdas defaults to 1.0 (identity scaling)
34
- if "resid_lambdas" not in model_data:
35
- model_data["resid_lambdas"] = torch.ones(n_layer)
36
- log0(f"Patching missing resid_lambdas in model data to 1.0")
37
- # x0_lambdas defaults to 0.0 (disabled)
38
- if "x0_lambdas" not in model_data:
39
- model_data["x0_lambdas"] = torch.zeros(n_layer)
40
- log0(f"Patching missing x0_lambdas in model data to 0.0")
41
-
42
- def save_checkpoint(checkpoint_dir, step, model_data, optimizer_data, meta_data, rank=0):
43
- if rank == 0:
44
- os.makedirs(checkpoint_dir, exist_ok=True)
45
- # Save the model state parameters
46
- model_path = os.path.join(checkpoint_dir, f"model_{step:06d}.pt")
47
- torch.save(model_data, model_path)
48
- logger.info(f"Saved model parameters to: {model_path}")
49
- # Save the metadata dict as json
50
- meta_path = os.path.join(checkpoint_dir, f"meta_{step:06d}.json")
51
- with open(meta_path, "w", encoding="utf-8") as f:
52
- json.dump(meta_data, f, indent=2)
53
- logger.info(f"Saved metadata to: {meta_path}")
54
- # Note that optimizer state is sharded across ranks, so each rank must save its own.
55
- if optimizer_data is not None:
56
- os.makedirs(checkpoint_dir, exist_ok=True)
57
- optimizer_path = os.path.join(checkpoint_dir, f"optim_{step:06d}_rank{rank:d}.pt")
58
- torch.save(optimizer_data, optimizer_path)
59
- logger.info(f"Saved optimizer state to: {optimizer_path}")
60
-
61
- def load_checkpoint(checkpoint_dir, step, device, load_optimizer=False, rank=0):
62
- # Load the model state
63
- model_path = os.path.join(checkpoint_dir, f"model_{step:06d}.pt")
64
- model_data = torch.load(model_path, map_location=device)
65
- # Load the optimizer state if requested
66
- optimizer_data = None
67
- if load_optimizer:
68
- optimizer_path = os.path.join(checkpoint_dir, f"optim_{step:06d}_rank{rank:d}.pt")
69
- optimizer_data = torch.load(optimizer_path, map_location=device)
70
- # Load the metadata
71
- meta_path = os.path.join(checkpoint_dir, f"meta_{step:06d}.json")
72
- with open(meta_path, "r", encoding="utf-8") as f:
73
- meta_data = json.load(f)
74
- return model_data, optimizer_data, meta_data
75
-
76
-
77
- def build_model(checkpoint_dir, step, device, phase):
78
- """
79
- A bunch of repetitive code to build a model from a given checkpoint.
80
- Returns:
81
- - base model - uncompiled, not wrapped in DDP
82
- - tokenizer
83
- - meta data saved during base model training
84
- """
85
- assert phase in ["train", "eval"], f"Invalid phase: {phase}"
86
- model_data, optimizer_data, meta_data = load_checkpoint(checkpoint_dir, step, device, load_optimizer=False)
87
- if device.type in {"cpu", "mps"}:
88
- # Convert bfloat16 tensors to float for CPU inference
89
- model_data = {
90
- k: v.float() if v.dtype == torch.bfloat16 else v
91
- for k, v in model_data.items()
92
- }
93
- # Hack: fix torch compile issue, which prepends all keys with _orig_mod.
94
- model_data = {k.removeprefix("_orig_mod."): v for k, v in model_data.items()}
95
- model_config_kwargs = meta_data["model_config"]
96
- _patch_missing_config_keys(model_config_kwargs)
97
- log0(f"Building model with config: {model_config_kwargs}")
98
- model_config = GPTConfig(**model_config_kwargs)
99
- _patch_missing_keys(model_data, model_config)
100
- with torch.device("meta"):
101
- model = GPT(model_config)
102
- # Load the model state
103
- model.to_empty(device=device)
104
- model.init_weights() # note: this is dumb, but we need to init the rotary embeddings. TODO: fix model re-init
105
- model.load_state_dict(model_data, strict=True, assign=True)
106
- # Put the model in the right training phase / mode
107
- if phase == "eval":
108
- model.eval()
109
- else:
110
- model.train()
111
- # Load the Tokenizer
112
- tokenizer = get_tokenizer()
113
- # Sanity check: compatibility between model and tokenizer
114
- 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']}"
115
- return model, tokenizer, meta_data
116
-
117
-
118
- def find_largest_model(checkpoints_dir):
119
- # attempt to guess the model tag: take the biggest model available
120
- model_tags = [f for f in os.listdir(checkpoints_dir) if os.path.isdir(os.path.join(checkpoints_dir, f))]
121
- if not model_tags:
122
- raise FileNotFoundError(f"No checkpoints found in {checkpoints_dir}")
123
- # 1) normally all model tags are of the form d<number>, try that first:
124
- candidates = []
125
- for model_tag in model_tags:
126
- match = re.match(r"d(\d+)", model_tag)
127
- if match:
128
- model_depth = int(match.group(1))
129
- candidates.append((model_depth, model_tag))
130
- if candidates:
131
- candidates.sort(key=lambda x: x[0], reverse=True)
132
- return candidates[0][1]
133
- # 2) if that failed, take the most recently updated model:
134
- model_tags.sort(key=lambda x: os.path.getmtime(os.path.join(checkpoints_dir, x)), reverse=True)
135
- return model_tags[0]
136
-
137
-
138
- def find_last_step(checkpoint_dir):
139
- # Look into checkpoint_dir and find model_<step>.pt with the highest step
140
- checkpoint_files = glob.glob(os.path.join(checkpoint_dir, "model_*.pt"))
141
- if not checkpoint_files:
142
- raise FileNotFoundError(f"No checkpoints found in {checkpoint_dir}")
143
- last_step = int(max(os.path.basename(f).split("_")[-1].split(".")[0] for f in checkpoint_files))
144
- return last_step
145
-
146
- # -----------------------------------------------------------------------------
147
- # convenience functions that take into account nanochat's directory structure
148
-
149
- def load_model_from_dir(checkpoints_dir, device, phase, model_tag=None, step=None):
150
- if model_tag is None:
151
- # guess the model tag by defaulting to the largest model
152
- model_tag = find_largest_model(checkpoints_dir)
153
- log0(f"No model tag provided, guessing model tag: {model_tag}")
154
- checkpoint_dir = os.path.join(checkpoints_dir, model_tag)
155
- if step is None:
156
- # guess the step by defaulting to the last step
157
- step = find_last_step(checkpoint_dir)
158
- assert step is not None, f"No checkpoints found in {checkpoint_dir}"
159
- # build the model
160
- log0(f"Loading model from {checkpoint_dir} with step {step}")
161
- model, tokenizer, meta_data = build_model(checkpoint_dir, step, device, phase)
162
- return model, tokenizer, meta_data
163
-
164
- def load_model(source, *args, **kwargs):
165
- model_dir = {
166
- "base": "base_checkpoints",
167
- "sft": "chatsft_checkpoints",
168
- "rl": "chatrl_checkpoints",
169
- }[source]
170
- base_dir = get_base_dir()
171
- checkpoints_dir = os.path.join(base_dir, model_dir)
172
- return load_model_from_dir(checkpoints_dir, *args, **kwargs)
173
-
174
- def load_optimizer_state(source, device, rank, model_tag=None, step=None):
175
- """Load just the optimizer shard for a given rank, without re-loading the model."""
176
- model_dir = {
177
- "base": "base_checkpoints",
178
- "sft": "chatsft_checkpoints",
179
- "rl": "chatrl_checkpoints",
180
- }[source]
181
- base_dir = get_base_dir()
182
- checkpoints_dir = os.path.join(base_dir, model_dir)
183
- if model_tag is None:
184
- model_tag = find_largest_model(checkpoints_dir)
185
- checkpoint_dir = os.path.join(checkpoints_dir, model_tag)
186
- if step is None:
187
- step = find_last_step(checkpoint_dir)
188
- optimizer_path = os.path.join(checkpoint_dir, f"optim_{step:06d}_rank{rank:d}.pt")
189
- if not os.path.exists(optimizer_path):
190
- log0(f"Optimizer checkpoint not found: {optimizer_path}")
191
- return None
192
- log0(f"Loading optimizer state from {optimizer_path}")
193
- optimizer_data = torch.load(optimizer_path, map_location=device)
194
- return optimizer_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/common.py DELETED
@@ -1,278 +0,0 @@
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
- return torch.float32, "auto-detected: no CUDA (CPU/MPS)"
31
- COMPUTE_DTYPE, COMPUTE_DTYPE_REASON = _detect_compute_dtype()
32
-
33
- class ColoredFormatter(logging.Formatter):
34
- """Custom formatter that adds colors to log messages."""
35
- # ANSI color codes
36
- COLORS = {
37
- 'DEBUG': '\033[36m', # Cyan
38
- 'INFO': '\033[32m', # Green
39
- 'WARNING': '\033[33m', # Yellow
40
- 'ERROR': '\033[31m', # Red
41
- 'CRITICAL': '\033[35m', # Magenta
42
- }
43
- RESET = '\033[0m'
44
- BOLD = '\033[1m'
45
- def format(self, record):
46
- # Add color to the level name
47
- levelname = record.levelname
48
- if levelname in self.COLORS:
49
- record.levelname = f"{self.COLORS[levelname]}{self.BOLD}{levelname}{self.RESET}"
50
- # Format the message
51
- message = super().format(record)
52
- # Add color to specific parts of the message
53
- if levelname == 'INFO':
54
- # Highlight numbers and percentages
55
- message = re.sub(r'(\d+\.?\d*\s*(?:GB|MB|%|docs))', rf'{self.BOLD}\1{self.RESET}', message)
56
- message = re.sub(r'(Shard \d+)', rf'{self.COLORS["INFO"]}{self.BOLD}\1{self.RESET}', message)
57
- return message
58
-
59
- def setup_default_logging():
60
- handler = logging.StreamHandler()
61
- handler.setFormatter(ColoredFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
62
- logging.basicConfig(
63
- level=logging.INFO,
64
- handlers=[handler]
65
- )
66
-
67
- setup_default_logging()
68
- logger = logging.getLogger(__name__)
69
-
70
- def get_base_dir():
71
- # co-locate nanochat intermediates with other cached data in ~/.cache (by default)
72
- if os.environ.get("NANOCHAT_BASE_DIR"):
73
- nanochat_dir = os.environ.get("NANOCHAT_BASE_DIR")
74
- else:
75
- home_dir = os.path.expanduser("~")
76
- cache_dir = os.path.join(home_dir, ".cache")
77
- nanochat_dir = os.path.join(cache_dir, "nanochat")
78
- os.makedirs(nanochat_dir, exist_ok=True)
79
- return nanochat_dir
80
-
81
- def download_file_with_lock(url, filename, postprocess_fn=None):
82
- """
83
- Downloads a file from a URL to a local path in the base directory.
84
- Uses a lock file to prevent concurrent downloads among multiple ranks.
85
- """
86
- base_dir = get_base_dir()
87
- file_path = os.path.join(base_dir, filename)
88
- lock_path = file_path + ".lock"
89
-
90
- if os.path.exists(file_path):
91
- return file_path
92
-
93
- with FileLock(lock_path):
94
- # Only a single rank can acquire this lock
95
- # All other ranks block until it is released
96
-
97
- # Recheck after acquiring lock
98
- if os.path.exists(file_path):
99
- return file_path
100
-
101
- # Download the content as bytes
102
- print(f"Downloading {url}...")
103
- with urllib.request.urlopen(url) as response:
104
- content = response.read() # bytes
105
-
106
- # Write to local file
107
- with open(file_path, 'wb') as f:
108
- f.write(content)
109
- print(f"Downloaded to {file_path}")
110
-
111
- # Run the postprocess function if provided
112
- if postprocess_fn is not None:
113
- postprocess_fn(file_path)
114
-
115
- return file_path
116
-
117
- def print0(s="",**kwargs):
118
- ddp_rank = int(os.environ.get('RANK', 0))
119
- if ddp_rank == 0:
120
- print(s, **kwargs)
121
-
122
- def print_banner():
123
- # Cool DOS Rebel font ASCII banner made with https://manytools.org/hacker-tools/ascii-banner/
124
- banner = """
125
- ███ ███ █████ █████
126
- ░░░ ░░░ ░░███ ░░███
127
- █████████████ ████ ████████ ████ ██████ ░███████ ██████ ███████
128
- ░░███░░███░░███ ░░███ ░░███░░███ ░░███ ███░░███ ░███░░███ ░░░░░███ ░░░███░
129
- ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░░░ ░███ ░███ ███████ ░███
130
- ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ███░░███ ░███ ███
131
- █████░███ █████ █████ ████ █████ █████░░██████ ████ █████░░████████ ░░█████
132
- ░░░░░ ░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░ ░░░░░░ ░░░░ ░░░░░ ░░░░░░░░ ░░░░░
133
- """
134
- print0(banner)
135
-
136
- def is_ddp_requested() -> bool:
137
- """
138
- True if launched by torchrun (env present), even before init.
139
- Used to decide whether we *should* initialize a PG.
140
- """
141
- return all(k in os.environ for k in ("RANK", "LOCAL_RANK", "WORLD_SIZE"))
142
-
143
- def is_ddp_initialized() -> bool:
144
- """
145
- True if torch.distributed is available and the process group is initialized.
146
- Used at cleanup to avoid destroying a non-existent PG.
147
- """
148
- return dist.is_available() and dist.is_initialized()
149
-
150
- def get_dist_info():
151
- if is_ddp_requested():
152
- # We rely on torchrun's env to decide if we SHOULD init.
153
- # (Initialization itself happens in compute init.)
154
- assert all(var in os.environ for var in ['RANK', 'LOCAL_RANK', 'WORLD_SIZE'])
155
- ddp_rank = int(os.environ['RANK'])
156
- ddp_local_rank = int(os.environ['LOCAL_RANK'])
157
- ddp_world_size = int(os.environ['WORLD_SIZE'])
158
- return True, ddp_rank, ddp_local_rank, ddp_world_size
159
- else:
160
- return False, 0, 0, 1
161
-
162
- def autodetect_device_type():
163
- # prefer to use CUDA if available, otherwise use MPS, otherwise fallback on CPU
164
- if torch.cuda.is_available():
165
- device_type = "cuda"
166
- elif torch.backends.mps.is_available():
167
- device_type = "mps"
168
- else:
169
- device_type = "cpu"
170
- print0(f"Autodetected device type: {device_type}")
171
- return device_type
172
-
173
- def compute_init(device_type="cuda"): # cuda|cpu|mps
174
- """Basic initialization that we keep doing over and over, so make common."""
175
-
176
- assert device_type in ["cuda", "mps", "cpu"], "Invalid device type atm"
177
- if device_type == "cuda":
178
- assert torch.cuda.is_available(), "Your PyTorch installation is not configured for CUDA but device_type is 'cuda'"
179
- if device_type == "mps":
180
- assert torch.backends.mps.is_available(), "Your PyTorch installation is not configured for MPS but device_type is 'mps'"
181
-
182
- # Reproducibility
183
- # Note that we set the global seeds here, but most of the code uses explicit rng objects.
184
- # The only place where global rng might be used is nn.Module initialization of the model weights.
185
- torch.manual_seed(42)
186
- if device_type == "cuda":
187
- torch.cuda.manual_seed(42)
188
- # skipping full reproducibility for now, possibly investigate slowdown later
189
- # torch.use_deterministic_algorithms(True)
190
-
191
- # Precision
192
- if device_type == "cuda":
193
- 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
194
-
195
- # Distributed setup: Distributed Data Parallel (DDP), optional, and requires CUDA
196
- is_ddp_requested, ddp_rank, ddp_local_rank, ddp_world_size = get_dist_info()
197
- if is_ddp_requested and device_type == "cuda":
198
- device = torch.device("cuda", ddp_local_rank)
199
- torch.cuda.set_device(device) # make "cuda" default to this device
200
- dist.init_process_group(backend="nccl", device_id=device)
201
- dist.barrier()
202
- else:
203
- device = torch.device(device_type) # mps|cpu
204
-
205
- if ddp_rank == 0:
206
- logger.info(f"Distributed world size: {ddp_world_size}")
207
-
208
- return is_ddp_requested, ddp_rank, ddp_local_rank, ddp_world_size, device
209
-
210
- def compute_cleanup():
211
- """Companion function to compute_init, to clean things up before script exit"""
212
- if is_ddp_initialized():
213
- dist.destroy_process_group()
214
-
215
- class DummyWandb:
216
- """Useful if we wish to not use wandb but have all the same signatures"""
217
- def __init__(self):
218
- pass
219
- def log(self, *args, **kwargs):
220
- pass
221
- def finish(self):
222
- pass
223
-
224
- # hardcoded BF16 peak flops for various GPUs
225
- # inspired by torchtitan: https://github.com/pytorch/torchtitan/blob/main/torchtitan/tools/utils.py
226
- # and PR: https://github.com/karpathy/nanochat/pull/147
227
- def get_peak_flops(device_name: str) -> float:
228
- name = device_name.lower()
229
-
230
- # Table order matters: more specific patterns first.
231
- _PEAK_FLOPS_TABLE = (
232
- # NVIDIA Blackwell
233
- (["gb200"], 2.5e15),
234
- (["grace blackwell"], 2.5e15),
235
- (["b200"], 2.25e15),
236
- (["b100"], 1.8e15),
237
- # NVIDIA Hopper
238
- (["h200", "nvl"], 836e12),
239
- (["h200", "pcie"], 836e12),
240
- (["h200"], 989e12),
241
- (["h100", "nvl"], 835e12),
242
- (["h100", "pcie"], 756e12),
243
- (["h100"], 989e12),
244
- (["h800", "nvl"], 989e12),
245
- (["h800"], 756e12),
246
- # NVIDIA Ampere data center
247
- (["a100"], 312e12),
248
- (["a800"], 312e12),
249
- (["a40"], 149.7e12),
250
- (["a30"], 165e12),
251
- # NVIDIA Ada data center
252
- (["l40s"], 362e12),
253
- (["l40-s"], 362e12),
254
- (["l40 s"], 362e12),
255
- (["l4"], 121e12),
256
- # AMD CDNA accelerators
257
- (["mi355"], 2.5e15),
258
- (["mi325"], 1.3074e15),
259
- (["mi300x"], 1.3074e15),
260
- (["mi300a"], 980.6e12),
261
- (["mi250x"], 383e12),
262
- (["mi250"], 362.1e12),
263
- # Consumer RTX
264
- (["5090"], 209.5e12),
265
- (["4090"], 165.2e12),
266
- (["3090"], 71e12),
267
- )
268
- for patterns, flops in _PEAK_FLOPS_TABLE:
269
- if all(p in name for p in patterns):
270
- return flops
271
- if "data center gpu max 1550" in name:
272
- # Ponte Vecchio (PVC) - dynamic based on compute units
273
- max_comp_units = torch.xpu.get_device_properties("xpu").max_compute_units
274
- return 512 * max_comp_units * 1300 * 10**6
275
-
276
- # Unknown GPU - return inf so MFU shows as 0% rather than a wrong guess
277
- logger.warning(f"Peak flops undefined for: {device_name}, MFU will show as 0%")
278
- return float('inf')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/engine.py DELETED
@@ -1,351 +0,0 @@
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
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
-
104
- def reset(self):
105
- """Reset cache to empty state."""
106
- self.cache_seqlens.zero_()
107
-
108
- def get_pos(self):
109
- """Get current position (assumes all batch elements at same position)."""
110
- return self.cache_seqlens[0].item()
111
-
112
- def get_layer_cache(self, layer_idx):
113
- """Return (k_cache, v_cache) views for a specific layer."""
114
- return self.k_cache[layer_idx], self.v_cache[layer_idx]
115
-
116
- def advance(self, num_tokens):
117
- """Advance the cache position by num_tokens."""
118
- self.cache_seqlens += num_tokens
119
-
120
- def prefill(self, other):
121
- """
122
- Copy cached KV from another cache into this one.
123
- Used when we do batch=1 prefill and then want to generate multiple samples in parallel.
124
- """
125
- assert self.get_pos() == 0, "Cannot prefill a non-empty KV cache"
126
- assert self.n_layers == other.n_layers and self.n_heads == other.n_heads and self.head_dim == other.head_dim
127
- assert self.max_seq_len >= other.max_seq_len
128
- other_pos = other.get_pos()
129
- self.k_cache[:, :, :other_pos, :, :] = other.k_cache[:, :, :other_pos, :, :]
130
- self.v_cache[:, :, :other_pos, :, :] = other.v_cache[:, :, :other_pos, :, :]
131
- self.cache_seqlens.fill_(other_pos)
132
-
133
- # -----------------------------------------------------------------------------
134
- @torch.inference_mode()
135
- def sample_next_token(logits, rng, temperature=1.0, top_k=None):
136
- """Sample a single next token from given logits of shape (B, vocab_size). Returns (B, 1)."""
137
- assert temperature >= 0.0, "temperature must be non-negative"
138
- if temperature == 0.0:
139
- return torch.argmax(logits, dim=-1, keepdim=True)
140
- if top_k is not None and top_k > 0:
141
- k = min(top_k, logits.size(-1))
142
- vals, idx = torch.topk(logits, k, dim=-1)
143
- vals = vals / temperature
144
- probs = F.softmax(vals, dim=-1)
145
- choice = torch.multinomial(probs, num_samples=1, generator=rng)
146
- return idx.gather(1, choice)
147
- else:
148
- logits = logits / temperature
149
- probs = F.softmax(logits, dim=-1)
150
- return torch.multinomial(probs, num_samples=1, generator=rng)
151
-
152
- # -----------------------------------------------------------------------------
153
-
154
- class RowState:
155
- # Per-row state tracking during generation
156
- def __init__(self, current_tokens=None):
157
- self.current_tokens = current_tokens or [] # Current token sequence for this row
158
- self.forced_tokens = deque() # Queue of tokens to force inject
159
- self.in_python_block = False # Whether we are inside a python block
160
- self.python_expr_tokens = [] # Tokens of the current python expression
161
- self.completed = False # Whether this row has completed generation
162
-
163
- class Engine:
164
-
165
- def __init__(self, model, tokenizer):
166
- self.model = model
167
- self.tokenizer = tokenizer # needed for tool use
168
-
169
- @torch.inference_mode()
170
- def generate(self, tokens, num_samples=1, max_tokens=None, temperature=1.0, top_k=None, seed=42):
171
- """Same as generate, but does single prefill and then clones the KV cache."""
172
- assert isinstance(tokens, list) and isinstance(tokens[0], int), "expecting list of ints"
173
- device = self.model.get_device()
174
- # NOTE: setting the dtype here and in this way is an ugly hack.
175
- # Currently the repo assumes that cuda -> bfloat16 and everything else -> float32.
176
- # We need to know the dtype here to call __init__ on KVCache and pre-allocate its tensors.
177
- # As a quick hack, we're making generate() function inherit and know about this repo-wise assumption.
178
- # I think there has to be a bigger refactor to deal with device/dtype tracking across the codebase.
179
- # In particular, the KVCache should allocate its tensors lazily
180
- dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
181
- rng = torch.Generator(device=device)
182
- rng.manual_seed(seed)
183
-
184
- # Get the special tokens we need to coordinate the tool use state machine
185
- get_special = lambda s: self.tokenizer.encode_special(s)
186
- python_start = get_special("<|python_start|>")
187
- python_end = get_special("<|python_end|>")
188
- output_start = get_special("<|output_start|>")
189
- output_end = get_special("<|output_end|>")
190
- assistant_end = get_special("<|assistant_end|>") # if sampled, ends row
191
- bos = self.tokenizer.get_bos_token_id() # if sampled, ends row
192
-
193
- # 1) Run a batch 1 prefill of the prompt tokens
194
- m = self.model.config
195
- kv_model_kwargs = {"num_heads": m.n_kv_head, "head_dim": m.n_embd // m.n_head, "num_layers": m.n_layer}
196
- kv_cache_prefill = KVCache(
197
- batch_size=1,
198
- seq_len=len(tokens),
199
- device=device,
200
- dtype=dtype,
201
- **kv_model_kwargs,
202
- )
203
- ids = torch.tensor([tokens], dtype=torch.long, device=device)
204
- logits = self.model.forward(ids, kv_cache=kv_cache_prefill)
205
- logits = logits[:, -1, :].expand(num_samples, -1) # (num_samples, vocab_size)
206
-
207
- # 2) Replicate the KV cache for each sample/row
208
- kv_length_hint = (len(tokens) + max_tokens) if max_tokens is not None else self.model.config.sequence_len
209
- kv_cache_decode = KVCache(
210
- batch_size=num_samples,
211
- seq_len=kv_length_hint,
212
- device=device,
213
- dtype=dtype,
214
- **kv_model_kwargs,
215
- )
216
- kv_cache_decode.prefill(kv_cache_prefill)
217
- del kv_cache_prefill # no need to keep this memory around
218
-
219
- # 3) Initialize states for each sample
220
- row_states = [RowState(tokens.copy()) for _ in range(num_samples)]
221
-
222
- # 4) Main generation loop
223
- num_generated = 0
224
- while True:
225
- # Stop condition: we've reached max tokens
226
- if max_tokens is not None and num_generated >= max_tokens:
227
- break
228
- # Stop condition: all rows are completed
229
- if all(state.completed for state in row_states):
230
- break
231
-
232
- # Sample the next token for each row
233
- next_ids = sample_next_token(logits, rng, temperature, top_k) # (B, 1)
234
- sampled_tokens = next_ids[:, 0].tolist()
235
-
236
- # Process each row: choose the next token, update state, optional tool use
237
- token_column = [] # contains the next token id along each row
238
- token_masks = [] # contains the mask (was it sampled (1) or forced (0)?) along each row
239
- for i, state in enumerate(row_states):
240
- # Select the next token in this row
241
- is_forced = len(state.forced_tokens) > 0 # are there tokens waiting to be forced in deque?
242
- token_masks.append(0 if is_forced else 1) # mask is 0 if forced, 1 if sampled
243
- next_token = state.forced_tokens.popleft() if is_forced else sampled_tokens[i]
244
- token_column.append(next_token)
245
- # Update the state of this row to include the next token
246
- state.current_tokens.append(next_token)
247
- # On <|assistant_end|> or <|bos|>, mark the row as completed
248
- if next_token == assistant_end or next_token == bos:
249
- state.completed = True
250
- # Handle tool logic
251
- if next_token == python_start:
252
- state.in_python_block = True
253
- state.python_expr_tokens = []
254
- elif next_token == python_end and state.in_python_block:
255
- state.in_python_block = False
256
- if state.python_expr_tokens:
257
- expr = self.tokenizer.decode(state.python_expr_tokens)
258
- result = use_calculator(expr)
259
- if result is not None:
260
- result_tokens = self.tokenizer.encode(str(result))
261
- state.forced_tokens.append(output_start)
262
- state.forced_tokens.extend(result_tokens)
263
- state.forced_tokens.append(output_end)
264
- state.python_expr_tokens = []
265
- elif state.in_python_block:
266
- state.python_expr_tokens.append(next_token)
267
-
268
- # Yield the token column
269
- yield token_column, token_masks
270
- num_generated += 1
271
-
272
- # Prepare logits for next iteration
273
- ids = torch.tensor(token_column, dtype=torch.long, device=device).unsqueeze(1)
274
- logits = self.model.forward(ids, kv_cache=kv_cache_decode)[:, -1, :] # (B, vocab_size)
275
-
276
- def generate_batch(self, tokens, num_samples=1, **kwargs):
277
- """
278
- Non-streaming batch generation that just returns the final token sequences.
279
- Returns a list of token sequences (list of lists of ints).
280
- Terminal tokens (assistant_end, bos) are not included in the results.
281
- """
282
- assistant_end = self.tokenizer.encode_special("<|assistant_end|>")
283
- bos = self.tokenizer.get_bos_token_id()
284
- results = [tokens.copy() for _ in range(num_samples)]
285
- masks = [[0] * len(tokens) for _ in range(num_samples)]
286
- completed = [False] * num_samples
287
- for token_column, token_masks in self.generate(tokens, num_samples, **kwargs):
288
- for i, (token, mask) in enumerate(zip(token_column, token_masks)):
289
- if not completed[i]:
290
- if token == assistant_end or token == bos:
291
- completed[i] = True
292
- else:
293
- results[i].append(token)
294
- masks[i].append(mask)
295
- # Stop if all rows are completed
296
- if all(completed):
297
- break
298
- return results, masks
299
-
300
-
301
- if __name__ == "__main__":
302
- """
303
- Quick inline test to make sure that the naive/slow model.generate function
304
- is equivalent to the faster Engine.generate function here.
305
- """
306
- import time
307
- # init compute
308
- device_type = autodetect_device_type()
309
- ddp, ddp_rank, ddp_local_rank, ddp_world_size, device = compute_init(device_type)
310
- # load the model and tokenizer
311
- model, tokenizer, meta = load_model("base", device, phase="eval")
312
- bos_token_id = tokenizer.get_bos_token_id()
313
- # common hyperparameters
314
- kwargs = dict(max_tokens=64, temperature=0.0)
315
- # set the starting prompt
316
- prompt_tokens = tokenizer.encode("The chemical formula of water is", prepend=bos_token_id)
317
- # generate the reference sequence using the model.generate() function
318
- generated_tokens = []
319
- torch.cuda.synchronize()
320
- t0 = time.time()
321
- stream = model.generate(prompt_tokens, **kwargs)
322
- for token in stream:
323
- generated_tokens.append(token)
324
- chunk = tokenizer.decode([token])
325
- print(chunk, end="", flush=True)
326
- print()
327
- torch.cuda.synchronize()
328
- t1 = time.time()
329
- print(f"Reference time: {t1 - t0:.2f}s")
330
- reference_ids = generated_tokens
331
- # generate tokens with Engine
332
- generated_tokens = []
333
- engine = Engine(model, tokenizer)
334
- stream = engine.generate(prompt_tokens, num_samples=1, **kwargs) # note: runs in fp32
335
- torch.cuda.synchronize()
336
- t0 = time.time()
337
- for token_column, token_masks in stream:
338
- token = token_column[0] # only print out the first row
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"Engine time: {t1 - t0:.2f}s")
346
- # compare the two sequences
347
- for i in range(len(reference_ids)):
348
- if reference_ids[i] != generated_tokens[i]:
349
- print(f"Mismatch at {i}: {reference_ids[i]} != {generated_tokens[i]}")
350
- break
351
- print(f"Match: {reference_ids == generated_tokens}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/flash_attention.py DELETED
@@ -1,192 +0,0 @@
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 non-Hopper GPUs (including Blackwell), 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 Hopper+ GPUs
22
- # =============================================================================
23
- def _load_flash_attention_3():
24
- """Try to load Flash Attention 3 (requires Hopper GPU, sm90)."""
25
- if not torch.cuda.is_available():
26
- return None
27
- try:
28
- major, _ = torch.cuda.get_device_capability()
29
- # FA3 kernels are compiled for Hopper (sm90) only
30
- # Ada (sm89), Blackwell (sm100) need SDPA fallback until FA3 is recompiled
31
- if major != 9:
32
- return None
33
- import os
34
- os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
35
- from kernels import get_kernel
36
- return get_kernel('varunneal/flash-attention-3').flash_attn_interface
37
- except Exception:
38
- return None
39
-
40
-
41
- _fa3 = _load_flash_attention_3()
42
- HAS_FA3 = _fa3 is not None
43
-
44
- # Override for testing: set to 'fa3', 'sdpa', or None (auto)
45
- _override_impl = None
46
-
47
-
48
- def _resolve_use_fa3():
49
- """Decide once whether to use FA3, based on availability, override, and dtype."""
50
- if _override_impl == 'fa3':
51
- assert HAS_FA3, "Cannot override to FA3: not available on this hardware"
52
- return True
53
- if _override_impl == 'sdpa':
54
- return False
55
- if HAS_FA3:
56
- # FA3 Hopper kernels only support bf16 and fp8; fp16/fp32 must use SDPA fallback
57
- from nanochat.common import COMPUTE_DTYPE
58
- if COMPUTE_DTYPE == torch.bfloat16:
59
- return True
60
- return False
61
- return False
62
-
63
- USE_FA3 = _resolve_use_fa3()
64
-
65
-
66
- # =============================================================================
67
- # SDPA helpers
68
- # =============================================================================
69
- def _sdpa_attention(q, k, v, window_size, enable_gqa):
70
- """
71
- SDPA attention with sliding window support.
72
- q, k, v are (B, H, T, D) format.
73
- """
74
- Tq = q.size(2)
75
- Tk = k.size(2)
76
- window = window_size[0]
77
-
78
- if k.dtype != q.dtype:
79
- k = k.to(q.dtype)
80
- if v.dtype != q.dtype:
81
- v = v.to(q.dtype)
82
-
83
- # Full context, same length
84
- if (window < 0 or window >= Tq) and Tq == Tk:
85
- return F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=enable_gqa)
86
-
87
- # Single token generation
88
- if Tq == 1:
89
- if window >= 0 and window < Tk:
90
- # window is "left" tokens we need to include (window + 1) keys total
91
- start = max(0, Tk - (window + 1))
92
- k = k[:, :, start:, :]
93
- v = v[:, :, start:, :]
94
- return F.scaled_dot_product_attention(q, k, v, is_causal=False, enable_gqa=enable_gqa)
95
-
96
- # Need explicit mask for sliding window/chunk inference
97
- device = q.device
98
- # For chunk inference (Tq != Tk), is_causal is not aligned to cache position => build an explicit bool mask
99
- row_idx = (Tk - Tq) + torch.arange(Tq, device=device).unsqueeze(1)
100
- col_idx = torch.arange(Tk, device=device).unsqueeze(0)
101
- mask = col_idx <= row_idx
102
-
103
- # sliding window (left)
104
- if window >= 0 and window < Tk:
105
- mask = mask & ((row_idx - col_idx) <= window)
106
-
107
- return F.scaled_dot_product_attention(q, k, v, attn_mask=mask, enable_gqa=enable_gqa)
108
-
109
- # =============================================================================
110
- # Public API: Same interface as FA3
111
- # =============================================================================
112
- def flash_attn_func(q, k, v, causal=False, window_size=(-1, -1)):
113
- """
114
- Flash Attention for training (no KV cache).
115
-
116
- Args:
117
- q, k, v: Tensors of shape (B, T, H, D)
118
- causal: Whether to use causal masking
119
- window_size: (left, right) sliding window. -1 means unlimited.
120
-
121
- Returns:
122
- Output tensor of shape (B, T, H, D)
123
- """
124
- if USE_FA3:
125
- return _fa3.flash_attn_func(q, k, v, causal=causal, window_size=window_size)
126
-
127
- # SDPA fallback: transpose (B, T, H, D) -> (B, H, T, D)
128
- q = q.transpose(1, 2)
129
- k = k.transpose(1, 2)
130
- v = v.transpose(1, 2)
131
- enable_gqa = q.size(1) != k.size(1)
132
- y = _sdpa_attention(q, k, v, window_size, enable_gqa)
133
- return y.transpose(1, 2) # back to (B, T, H, D)
134
-
135
-
136
- def flash_attn_with_kvcache(q, k_cache, v_cache, k=None, v=None, cache_seqlens=None,
137
- causal=False, window_size=(-1, -1)):
138
- """
139
- Flash Attention with KV cache for inference.
140
-
141
- FA3 updates k_cache/v_cache in-place. Our SDPA fallback does the same.
142
-
143
- Args:
144
- q: Queries, shape (B, T_new, H, D)
145
- k_cache, v_cache: Pre-allocated cache tensors, shape (B, T_max, H_kv, D)
146
- k, v: New keys/values to insert, shape (B, T_new, H_kv, D)
147
- cache_seqlens: Current position in cache, shape (B,) int32
148
- causal: Whether to use causal masking
149
- window_size: (left, right) sliding window. -1 means unlimited.
150
-
151
- Returns:
152
- Output tensor of shape (B, T_new, H, D)
153
- """
154
- if USE_FA3:
155
- return _fa3.flash_attn_with_kvcache(
156
- q, k_cache, v_cache, k=k, v=v, cache_seqlens=cache_seqlens,
157
- causal=causal, window_size=window_size
158
- )
159
-
160
- # SDPA fallback: manually manage KV cache
161
- B, T_new, H, D = q.shape
162
- pos = cache_seqlens[0].item() # assume uniform position across batch
163
-
164
- # Insert new k, v into cache (in-place, matching FA3 behavior)
165
- if k is not None and v is not None:
166
- k_cache[:, pos:pos+T_new, :, :] = k
167
- v_cache[:, pos:pos+T_new, :, :] = v
168
-
169
- # Get full cache up to current position + new tokens
170
- end_pos = pos + T_new
171
- k_full = k_cache[:, :end_pos, :, :]
172
- v_full = v_cache[:, :end_pos, :, :]
173
-
174
- # Transpose to SDPA layout: (B, T, H, D) -> (B, H, T, D)
175
- q_sdpa = q.transpose(1, 2)
176
- k_sdpa = k_full.transpose(1, 2)
177
- v_sdpa = v_full.transpose(1, 2)
178
-
179
- enable_gqa = q_sdpa.size(1) != k_sdpa.size(1)
180
- y_sdpa = _sdpa_attention(q_sdpa, k_sdpa, v_sdpa, window_size, enable_gqa)
181
-
182
- return y_sdpa.transpose(1, 2) # back to (B, T, H, D)
183
-
184
-
185
- # =============================================================================
186
- # Export: flash_attn module interface (drop-in replacement for FA3)
187
- # =============================================================================
188
- from types import SimpleNamespace
189
- flash_attn = SimpleNamespace(
190
- flash_attn_func=flash_attn_func,
191
- flash_attn_with_kvcache=flash_attn_with_kvcache,
192
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/gpt.py DELETED
@@ -1,463 +0,0 @@
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
- - relu^2 activation in MLP
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
-
22
- from nanochat.common import get_dist_info, print0, COMPUTE_DTYPE
23
- from nanochat.optim import MuonAdamW, DistMuonAdamW
24
-
25
- # Our custom Flash Attention module that automatically uses FA3 on Hopper+ and SDPA fallback elsewhere
26
- from nanochat.flash_attention import flash_attn
27
-
28
- @dataclass
29
- class GPTConfig:
30
- sequence_len: int = 2048
31
- vocab_size: int = 32768
32
- n_layer: int = 12
33
- n_head: int = 6 # number of query heads
34
- n_kv_head: int = 6 # number of key/value heads (GQA)
35
- n_embd: int = 768
36
- # Sliding window attention pattern string, tiled across layers. Final layer always L.
37
- # Characters: L=long (full context), S=short (half context)
38
- # Examples: "L"=all full context, "SL"=alternating, "SSL"=two short then one long
39
- window_pattern: str = "SSSL"
40
-
41
-
42
- def norm(x):
43
- return F.rms_norm(x, (x.size(-1),)) # note that this will run in bf16, seems ok
44
-
45
- class Linear(nn.Linear):
46
- """nn.Linear that casts weights to match input dtype in forward.
47
- Replaces autocast: master weights stay fp32 for optimizer precision,
48
- but matmuls run in the activation dtype (typically bf16 from embeddings)."""
49
- def forward(self, x):
50
- return F.linear(x, self.weight.to(dtype=x.dtype))
51
-
52
-
53
- def has_ve(layer_idx, n_layer):
54
- """Returns True if GPT layer should have Value Embedding (alternating, last layer always included)."""
55
- return layer_idx % 2 == (n_layer - 1) % 2
56
-
57
- def apply_rotary_emb(x, cos, sin):
58
- assert x.ndim == 4 # multihead attention
59
- d = x.shape[3] // 2
60
- x1, x2 = x[..., :d], x[..., d:] # split up last dim into two halves
61
- y1 = x1 * cos + x2 * sin # rotate pairs of dims
62
- y2 = x1 * (-sin) + x2 * cos
63
- return torch.cat([y1, y2], 3)
64
-
65
- class CausalSelfAttention(nn.Module):
66
- def __init__(self, config, layer_idx):
67
- super().__init__()
68
- self.layer_idx = layer_idx
69
- self.n_head = config.n_head
70
- self.n_kv_head = config.n_kv_head
71
- self.n_embd = config.n_embd
72
- self.head_dim = self.n_embd // self.n_head
73
- assert self.n_embd % self.n_head == 0
74
- assert self.n_kv_head <= self.n_head and self.n_head % self.n_kv_head == 0
75
- self.c_q = Linear(self.n_embd, self.n_head * self.head_dim, bias=False)
76
- self.c_k = Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
77
- self.c_v = Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
78
- self.c_proj = Linear(self.n_embd, self.n_embd, bias=False)
79
- self.ve_gate_channels = 32
80
- self.ve_gate = Linear(self.ve_gate_channels, self.n_kv_head, bias=False) if has_ve(layer_idx, config.n_layer) else None
81
-
82
- def forward(self, x, ve, cos_sin, window_size, kv_cache):
83
- B, T, C = x.size()
84
-
85
- # Project the input to get queries, keys, and values
86
- # Shape: (B, T, H, D) - FA3's native layout, no transpose needed!
87
- q = self.c_q(x).view(B, T, self.n_head, self.head_dim)
88
- k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim)
89
- v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim)
90
-
91
- # Value residual (ResFormer): mix in value embedding with input-dependent gate per head
92
- if ve is not None:
93
- ve = ve.view(B, T, self.n_kv_head, self.head_dim)
94
- gate = 2 * torch.sigmoid(self.ve_gate(x[..., :self.ve_gate_channels])) # (B, T, n_kv_head), range (0, 2)
95
- v = v + gate.unsqueeze(-1) * ve
96
-
97
- # Apply Rotary Embeddings to queries and keys to get relative positional encoding
98
- cos, sin = cos_sin
99
- q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin)
100
- q, k = norm(q), norm(k) # QK norm
101
-
102
- # Flash Attention (FA3 on Hopper+, PyTorch SDPA fallback elsewhere)
103
- # window_size is (left, right) tuple: (N, 0) for causal, (-1, 0) for full context
104
- if kv_cache is None:
105
- # Training: causal attention with optional sliding window
106
- y = flash_attn.flash_attn_func(q, k, v, causal=True, window_size=window_size)
107
- else:
108
- # Inference: use flash_attn_with_kvcache which handles cache management
109
- k_cache, v_cache = kv_cache.get_layer_cache(self.layer_idx)
110
- y = flash_attn.flash_attn_with_kvcache(
111
- q, k_cache, v_cache,
112
- k=k, v=v,
113
- cache_seqlens=kv_cache.cache_seqlens,
114
- causal=True,
115
- window_size=window_size,
116
- )
117
- # Advance position after last layer processes
118
- if self.layer_idx == kv_cache.n_layers - 1:
119
- kv_cache.advance(T)
120
-
121
- # Re-assemble the heads and project back to residual stream
122
- y = y.contiguous().view(B, T, -1)
123
- y = self.c_proj(y)
124
- return y
125
-
126
-
127
- class MLP(nn.Module):
128
- def __init__(self, config):
129
- super().__init__()
130
- self.c_fc = Linear(config.n_embd, 4 * config.n_embd, bias=False)
131
- self.c_proj = Linear(4 * config.n_embd, config.n_embd, bias=False)
132
-
133
- def forward(self, x):
134
- x = self.c_fc(x)
135
- x = F.relu(x).square()
136
- x = self.c_proj(x)
137
- return x
138
-
139
-
140
- class Block(nn.Module):
141
- def __init__(self, config, layer_idx):
142
- super().__init__()
143
- self.attn = CausalSelfAttention(config, layer_idx)
144
- self.mlp = MLP(config)
145
-
146
- def forward(self, x, ve, cos_sin, window_size, kv_cache):
147
- x = x + self.attn(norm(x), ve, cos_sin, window_size, kv_cache)
148
- x = x + self.mlp(norm(x))
149
- return x
150
-
151
-
152
- class GPT(nn.Module):
153
- def __init__(self, config, pad_vocab_size_to=64):
154
- """
155
- NOTE a major footgun: this __init__ function runs in meta device context (!!)
156
- Therefore, any calculations inside here are shapes and dtypes only, no actual data.
157
- => We actually initialize all data (parameters, buffers, etc.) in init_weights() instead.
158
- """
159
- super().__init__()
160
- self.config = config
161
- # Compute per-layer window sizes for sliding window attention
162
- # window_size is (left, right) tuple: (-1, 0) for full context, (N, 0) for sliding window
163
- self.window_sizes = self._compute_window_sizes(config)
164
- # Pad vocab for efficiency (DDP, tensor cores). This is just an optimization - outputs are cropped in forward().
165
- # https://huggingface.co/docs/transformers/main_classes/model#transformers.PreTrainedModel.resize_token_embeddings
166
- padded_vocab_size = ((config.vocab_size + pad_vocab_size_to - 1) // pad_vocab_size_to) * pad_vocab_size_to
167
- if padded_vocab_size != config.vocab_size:
168
- print0(f"Padding vocab_size from {config.vocab_size} to {padded_vocab_size} for efficiency")
169
- self.transformer = nn.ModuleDict({
170
- "wte": nn.Embedding(padded_vocab_size, config.n_embd),
171
- "h": nn.ModuleList([Block(config, layer_idx) for layer_idx in range(config.n_layer)]),
172
- })
173
- self.lm_head = Linear(config.n_embd, padded_vocab_size, bias=False)
174
- # Per-layer learnable scalars (inspired by modded-nanogpt)
175
- # resid_lambdas: scales the residual stream at each layer (init 1.0 = neutral)
176
- # x0_lambdas: blends initial embedding back in at each layer (init 0.0 = disabled)
177
- # Separate parameters so they can have different optimizer treatment
178
- self.resid_lambdas = nn.Parameter(torch.ones(config.n_layer)) # fake init, real init in init_weights()
179
- self.x0_lambdas = nn.Parameter(torch.zeros(config.n_layer)) # fake init, real init in init_weights()
180
- # Value embeddings (ResFormer-style): alternating layers, last layer always included
181
- head_dim = config.n_embd // config.n_head
182
- kv_dim = config.n_kv_head * head_dim
183
- self.value_embeds = nn.ModuleDict({str(i): nn.Embedding(padded_vocab_size, kv_dim) for i in range(config.n_layer) if has_ve(i, config.n_layer)})
184
- # To support meta device initialization, we init the rotary embeddings here, but it's just "fake" meta tensors only.
185
- # As for rotary_seq_len, these rotary embeddings are pretty small/cheap in memory,
186
- # so let's just over-compute them by 10X, but assert fail if we ever reach that amount.
187
- # In the future we can dynamically grow the cache, for now it's fine.
188
- self.rotary_seq_len = config.sequence_len * 10 # 10X over-compute should be enough, TODO make nicer?
189
- head_dim = config.n_embd // config.n_head
190
- cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
191
- self.register_buffer("cos", cos, persistent=False) # persistent=False means it's not saved to the checkpoint
192
- self.register_buffer("sin", sin, persistent=False)
193
-
194
- @torch.no_grad()
195
- def init_weights(self):
196
- """
197
- Initialize the full model in this one function for maximum clarity.
198
-
199
- wte (embedding): normal, std=1.0
200
- lm_head: normal, std=0.001
201
- for each block:
202
- attn.c_q: uniform, std=1/sqrt(n_embd)
203
- attn.c_k: uniform, std=1/sqrt(n_embd)
204
- attn.c_v: uniform, std=1/sqrt(n_embd)
205
- attn.c_proj: zeros
206
- mlp.c_fc: uniform, std=1/sqrt(n_embd)
207
- mlp.c_proj: zeros
208
- """
209
-
210
- # Embedding and unembedding
211
- torch.nn.init.normal_(self.transformer.wte.weight, mean=0.0, std=1.0)
212
- torch.nn.init.normal_(self.lm_head.weight, mean=0.0, std=0.001)
213
-
214
- # Transformer blocks: uniform init with bound = sqrt(3) * std (same standard deviation as normal)
215
- n_embd = self.config.n_embd
216
- s = 3**0.5 * n_embd**-0.5 # sqrt(3) multiplier makes sure Uniform achieves the same std as Normal
217
- for block in self.transformer.h:
218
- torch.nn.init.uniform_(block.attn.c_q.weight, -s, s) # weights use Uniform to avoid outliers
219
- torch.nn.init.uniform_(block.attn.c_k.weight, -s, s)
220
- torch.nn.init.uniform_(block.attn.c_v.weight, -s, s)
221
- torch.nn.init.zeros_(block.attn.c_proj.weight) # projections are zero
222
- torch.nn.init.uniform_(block.mlp.c_fc.weight, -s, s)
223
- torch.nn.init.zeros_(block.mlp.c_proj.weight)
224
-
225
- # Per-layer scalars
226
- self.resid_lambdas.fill_(1.0) # 1.0 => typical residual connections at init
227
- self.x0_lambdas.fill_(0.1) # 0.1 => small initial weight for skip connection to input embedding
228
-
229
- # Value embeddings (init like c_v: uniform with same std)
230
- for ve in self.value_embeds.values():
231
- torch.nn.init.uniform_(ve.weight, -s, s)
232
-
233
- # Gate weights init to zero so gates start at sigmoid(0) = 0.5, scaled by 2 -> 1.0 (neutral)
234
- for block in self.transformer.h:
235
- if block.attn.ve_gate is not None:
236
- torch.nn.init.zeros_(block.attn.ve_gate.weight)
237
-
238
- # Rotary embeddings
239
- head_dim = self.config.n_embd // self.config.n_head
240
- cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
241
- self.cos, self.sin = cos, sin
242
-
243
- # Cast embeddings to COMPUTE_DTYPE: optimizer can tolerate reduced-precision
244
- # embeddings and it saves memory. Exception: fp16 requires fp32 embeddings
245
- # because GradScaler cannot unscale fp16 gradients.
246
- if COMPUTE_DTYPE != torch.float16:
247
- self.transformer.wte.to(dtype=COMPUTE_DTYPE)
248
- for ve in self.value_embeds.values():
249
- ve.to(dtype=COMPUTE_DTYPE)
250
-
251
- def _precompute_rotary_embeddings(self, seq_len, head_dim, base=10000, device=None):
252
- # TODO: bump base theta more? e.g. 100K is more common more recently
253
- # autodetect the device from model embeddings
254
- if device is None:
255
- device = self.transformer.wte.weight.device
256
- # stride the channels
257
- channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
258
- inv_freq = 1.0 / (base ** (channel_range / head_dim))
259
- # stride the time steps
260
- t = torch.arange(seq_len, dtype=torch.float32, device=device)
261
- # calculate the rotation frequencies at each (time, channel) pair
262
- freqs = torch.outer(t, inv_freq)
263
- cos, sin = freqs.cos(), freqs.sin()
264
- cos, sin = cos.to(COMPUTE_DTYPE), sin.to(COMPUTE_DTYPE)
265
- cos, sin = cos[None, :, None, :], sin[None, :, None, :] # add batch and head dims for later broadcasting
266
- return cos, sin
267
-
268
- def _compute_window_sizes(self, config):
269
- """
270
- Compute per-layer window sizes for sliding window attention.
271
-
272
- Returns list of (left, right) tuples for FA3's window_size parameter:
273
- - left: how many tokens before current position to attend to (-1 = unlimited)
274
- - right: how many tokens after current position to attend to (0 for causal)
275
-
276
- Pattern string is tiled across layers. Final layer always gets L (full context).
277
- Characters: L=long (full context), S=short (half context)
278
- """
279
- pattern = config.window_pattern.upper()
280
- assert all(c in "SL" for c in pattern), f"Invalid window_pattern: {pattern}. Use only S and L."
281
- # Map characters to window sizes
282
- long_window = config.sequence_len
283
- short_window = long_window // 2
284
- char_to_window = {
285
- "L": (long_window, 0),
286
- "S": (short_window, 0),
287
- }
288
- # Tile pattern across layers
289
- window_sizes = []
290
- for layer_idx in range(config.n_layer):
291
- char = pattern[layer_idx % len(pattern)]
292
- window_sizes.append(char_to_window[char])
293
- # Final layer always gets full context
294
- window_sizes[-1] = (long_window, 0)
295
- return window_sizes
296
-
297
- def get_device(self):
298
- return self.transformer.wte.weight.device
299
-
300
- def estimate_flops(self):
301
- """
302
- Return the estimated FLOPs per token for the model (forward + backward).
303
- Each matmul weight parameter contributes 2 FLOPs (multiply *, accumulate +) in forward, and 2X that in backward => 2+4=6.
304
- Cleanest explanation of this: https://medium.com/@dzmitrybahdanau/the-flops-calculus-of-language-model-training-3b19c1f025e4
305
- On top of that, 12 * h * q * effective_seq_len accounts for key @ query matmul flops inside attention.
306
- With sliding windows, effective_seq_len varies per layer (capped by window size).
307
- Ref: https://arxiv.org/abs/2204.02311 (PaLM paper).
308
- This is ~1% off from the exact formulas of Chinchilla paper, the difference is:
309
- - Chinchilla counts the embedding layer as flops (? weird, it's just a lookup => we ignore)
310
- - Chinchilla counts exp/sum/divide in attention softmax as flops (a little sus and very tiny => we ignore)
311
- """
312
- nparams = sum(p.numel() for p in self.parameters())
313
- # Exclude non-matmul params: embeddings and per-layer scalars
314
- value_embeds_numel = sum(ve.weight.numel() for ve in self.value_embeds.values())
315
- nparams_exclude = (self.transformer.wte.weight.numel() + value_embeds_numel +
316
- self.resid_lambdas.numel() + self.x0_lambdas.numel())
317
- h, q, t = self.config.n_head, self.config.n_embd // self.config.n_head, self.config.sequence_len
318
- # Sum attention FLOPs per layer, accounting for sliding window
319
- attn_flops = 0
320
- for window_size in self.window_sizes:
321
- window = window_size[0] # (left, right) tuple, we use left
322
- effective_seq = t if window < 0 else min(window, t)
323
- attn_flops += 12 * h * q * effective_seq
324
- num_flops_per_token = 6 * (nparams - nparams_exclude) + attn_flops
325
- return num_flops_per_token
326
-
327
- def num_scaling_params(self):
328
- """
329
- Return detailed parameter counts for scaling law analysis.
330
- Different papers use different conventions:
331
- - Kaplan et al. excluded embedding parameters
332
- - Chinchilla included all parameters
333
- Ref: https://arxiv.org/abs/2203.15556 (Chinchilla paper)
334
- Ref: https://arxiv.org/abs/2001.08361 (Kaplan et al. original scaling laws paper)
335
-
336
- Returns a dict with counts for each parameter group, so downstream analysis
337
- can experiment with which combination gives the cleanest scaling laws.
338
- """
339
- # Count each group separately (mirrors the grouping in setup_optimizers)
340
- wte = sum(p.numel() for p in self.transformer.wte.parameters())
341
- value_embeds = sum(p.numel() for p in self.value_embeds.parameters())
342
- lm_head = sum(p.numel() for p in self.lm_head.parameters())
343
- transformer_matrices = sum(p.numel() for p in self.transformer.h.parameters())
344
- scalars = self.resid_lambdas.numel() + self.x0_lambdas.numel()
345
- total = wte + value_embeds + lm_head + transformer_matrices + scalars
346
- assert total == sum(p.numel() for p in self.parameters()), "Parameter count mismatch"
347
- return {
348
- 'wte': wte,
349
- 'value_embeds': value_embeds,
350
- 'lm_head': lm_head,
351
- 'transformer_matrices': transformer_matrices,
352
- 'scalars': scalars,
353
- 'total': total,
354
- }
355
-
356
- def setup_optimizer(self, unembedding_lr=0.004, embedding_lr=0.2, matrix_lr=0.02, weight_decay=0.0, adam_betas=(0.8, 0.95), scalar_lr=0.5):
357
- model_dim = self.config.n_embd
358
- ddp, rank, local_rank, world_size = get_dist_info()
359
-
360
- # Separate out all parameters into groups
361
- matrix_params = list(self.transformer.h.parameters())
362
- value_embeds_params = list(self.value_embeds.parameters())
363
- embedding_params = list(self.transformer.wte.parameters())
364
- lm_head_params = list(self.lm_head.parameters())
365
- resid_params = [self.resid_lambdas]
366
- x0_params = [self.x0_lambdas]
367
- assert len(list(self.parameters())) == len(matrix_params) + len(embedding_params) + len(lm_head_params) + len(value_embeds_params) + len(resid_params) + len(x0_params)
368
-
369
- # Scale the LR for the AdamW parameters by ∝1/√dmodel (tuned for 768 dim model)
370
- dmodel_lr_scale = (model_dim / 768) ** -0.5
371
- print0(f"Scaling the LR for the AdamW parameters ∝1/√({model_dim}/768) = {dmodel_lr_scale:.6f}")
372
-
373
- # Build param_groups with all required fields explicit
374
- param_groups = [
375
- # AdamW groups (embeddings, lm_head, scalars)
376
- dict(kind='adamw', params=lm_head_params, lr=unembedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
377
- dict(kind='adamw', params=embedding_params, lr=embedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
378
- dict(kind='adamw', params=value_embeds_params, lr=embedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
379
- dict(kind='adamw', params=resid_params, lr=scalar_lr * 0.01, betas=adam_betas, eps=1e-10, weight_decay=0.0),
380
- 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
381
- ]
382
- # Muon groups (matrix params, grouped by shape for stacking)
383
- for shape in sorted({p.shape for p in matrix_params}):
384
- group_params = [p for p in matrix_params if p.shape == shape]
385
- param_groups.append(dict(
386
- kind='muon', params=group_params, lr=matrix_lr,
387
- momentum=0.95, ns_steps=5, beta2=0.95, weight_decay=weight_decay,
388
- ))
389
-
390
- Factory = DistMuonAdamW if ddp else MuonAdamW
391
- optimizer = Factory(param_groups)
392
- for group in optimizer.param_groups:
393
- group["initial_lr"] = group["lr"]
394
- return optimizer
395
-
396
- def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
397
- B, T = idx.size()
398
-
399
- # Grab the rotary embeddings for the current sequence length (they are of shape (1, seq_len, 1, head_dim/2))
400
- assert T <= self.cos.size(1), f"Sequence length grew beyond the rotary embeddings cache: {T} > {self.cos.size(1)}"
401
- assert idx.device == self.cos.device, f"Rotary embeddings and idx are on different devices: {idx.device} != {self.cos.device}"
402
- assert self.cos.dtype == COMPUTE_DTYPE, f"Rotary embeddings must be in {COMPUTE_DTYPE}, got {self.cos.dtype}"
403
- # if kv cache exists, we need to offset the rotary embeddings to the current position in the cache
404
- T0 = 0 if kv_cache is None else kv_cache.get_pos()
405
- cos_sin = self.cos[:, T0:T0+T], self.sin[:, T0:T0+T] # truncate cache to current sequence length
406
-
407
- # Forward the trunk of the Transformer
408
- x = self.transformer.wte(idx) # embed current token
409
- x = x.to(COMPUTE_DTYPE) # ensure activations are in compute dtype (no-op usually, but active for fp16 code path)
410
- x = norm(x)
411
- x0 = x # save initial normalized embedding for x0 residual
412
- for i, block in enumerate(self.transformer.h):
413
- x = self.resid_lambdas[i] * x + self.x0_lambdas[i] * x0
414
- ve = self.value_embeds[str(i)](idx).to(x.dtype) if str(i) in self.value_embeds else None
415
- x = block(x, ve, cos_sin, self.window_sizes[i], kv_cache)
416
- x = norm(x)
417
-
418
- # Forward the lm_head (compute logits)
419
- softcap = 20 # smoothly cap the logits to the range [-softcap, softcap]
420
- logits = self.lm_head(x) # (B, T, padded_vocab_size) <- very big tensor, large amount of memory
421
- logits = logits[..., :self.config.vocab_size] # slice to remove padding
422
- logits = logits.float() # switch to fp32 for logit softcap and loss computation
423
- logits = softcap * torch.tanh(logits / softcap) # squash the logits
424
-
425
- if targets is not None:
426
- # training: given the targets, compute and return the loss
427
- # TODO experiment with chunked cross-entropy?
428
- loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1, reduction=loss_reduction)
429
- return loss
430
- else:
431
- # inference: just return the logits directly
432
- return logits
433
-
434
- @torch.inference_mode()
435
- def generate(self, tokens, max_tokens, temperature=1.0, top_k=None, seed=42):
436
- """
437
- Naive autoregressive streaming inference.
438
- To make it super simple, let's assume:
439
- - batch size is 1
440
- - ids and the yielded tokens are simple Python lists and ints
441
- """
442
- assert isinstance(tokens, list)
443
- device = self.get_device()
444
- rng = None
445
- if temperature > 0:
446
- rng = torch.Generator(device=device)
447
- rng.manual_seed(seed)
448
- ids = torch.tensor([tokens], dtype=torch.long, device=device) # add batch dim
449
- for _ in range(max_tokens):
450
- logits = self.forward(ids) # (B, T, vocab_size)
451
- logits = logits[:, -1, :] # (B, vocab_size)
452
- if top_k is not None and top_k > 0:
453
- v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
454
- logits[logits < v[:, [-1]]] = -float('Inf')
455
- if temperature > 0:
456
- logits = logits / temperature
457
- probs = F.softmax(logits, dim=-1)
458
- next_ids = torch.multinomial(probs, num_samples=1, generator=rng)
459
- else:
460
- next_ids = torch.argmax(logits, dim=-1, keepdim=True)
461
- ids = torch.cat((ids, next_ids), dim=1)
462
- token = next_ids.item()
463
- yield token
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/optim.py DELETED
@@ -1,533 +0,0 @@
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
- Two versions are provided (MuonAdamW, DistMuonAdamW), for single GPU and distributed.
5
-
6
- Addapted from: https://github.com/KellerJordan/modded-nanogpt
7
- Further contributions from @karpathy and @chrisjmccormick.
8
- """
9
-
10
- import torch
11
- import torch.distributed as dist
12
- from torch import Tensor
13
-
14
- # -----------------------------------------------------------------------------
15
- """
16
- Good old AdamW optimizer, fused kernel.
17
- https://arxiv.org/abs/1711.05101
18
- """
19
-
20
- @torch.compile(dynamic=False, fullgraph=True)
21
- def adamw_step_fused(
22
- p: Tensor, # (32768, 768) - parameter tensor
23
- grad: Tensor, # (32768, 768) - gradient, same shape as p
24
- exp_avg: Tensor, # (32768, 768) - first moment, same shape as p
25
- exp_avg_sq: Tensor, # (32768, 768) - second moment, same shape as p
26
- step_t: Tensor, # () - 0-D CPU tensor, step count
27
- lr_t: Tensor, # () - 0-D CPU tensor, learning rate
28
- beta1_t: Tensor, # () - 0-D CPU tensor, beta1
29
- beta2_t: Tensor, # () - 0-D CPU tensor, beta2
30
- eps_t: Tensor, # () - 0-D CPU tensor, epsilon
31
- wd_t: Tensor, # () - 0-D CPU tensor, weight decay
32
- ) -> None:
33
- """
34
- Fused AdamW step: weight_decay -> momentum_update -> bias_correction -> param_update
35
- All in one compiled graph to eliminate Python overhead between ops.
36
- The 0-D CPU tensors avoid recompilation when hyperparameter values change.
37
- """
38
- # Weight decay (decoupled, applied before the update)
39
- p.mul_(1 - lr_t * wd_t)
40
- # Update running averages (lerp_ is cleaner and fuses well)
41
- exp_avg.lerp_(grad, 1 - beta1_t)
42
- exp_avg_sq.lerp_(grad.square(), 1 - beta2_t)
43
- # Bias corrections
44
- bias1 = 1 - beta1_t ** step_t
45
- bias2 = 1 - beta2_t ** step_t
46
- # Compute update and apply
47
- denom = (exp_avg_sq / bias2).sqrt() + eps_t
48
- step_size = lr_t / bias1
49
- p.add_(exp_avg / denom, alpha=-step_size)
50
-
51
- # -----------------------------------------------------------------------------
52
- """
53
- Muon optimizer adapted and simplified from modded-nanogpt.
54
- https://github.com/KellerJordan/modded-nanogpt
55
-
56
- Background:
57
- Newton-Schulz iteration to compute the zeroth power / orthogonalization of G. We opt to use a
58
- quintic iteration whose coefficients are selected to maximize the slope at zero. For the purpose
59
- of minimizing steps, it turns out to be empirically effective to keep increasing the slope at
60
- zero even beyond the point where the iteration no longer converges all the way to one everywhere
61
- on the interval. This iteration therefore does not produce UV^T but rather something like US'V^T
62
- where S' is diagonal with S_{ii}' ~ Uniform(0.5, 1.5), which turns out not to hurt model
63
- performance at all relative to UV^T, where USV^T = G is the SVD.
64
-
65
- Here, an alternative to Newton-Schulz iteration with potentially better convergence properties:
66
- Polar Express Sign Method for orthogonalization.
67
- https://arxiv.org/pdf/2505.16932
68
- by Noah Amsel, David Persson, Christopher Musco, Robert M. Gower.
69
-
70
- NorMuon variance reduction: per-neuron/column adaptive learning rate that normalizes
71
- update scales after orthogonalization (Muon's output has non-uniform scales across neurons).
72
- https://arxiv.org/pdf/2510.05491
73
-
74
- Some of the changes in nanochat implementation:
75
- - Uses a simpler, more general approach to parameter grouping and stacking
76
- - Uses a single fused kernel for the momentum -> polar_express -> variance_reduction -> update step
77
- - Makes no assumptions about model architecture (e.g. that attention weights are fused into QKVO format)
78
- """
79
-
80
- # Coefficients for Polar Express (computed for num_iters=5, safety_factor=2e-2, cushion=2)
81
- # From https://arxiv.org/pdf/2505.16932
82
- polar_express_coeffs = [
83
- (8.156554524902461, -22.48329292557795, 15.878769915207462),
84
- (4.042929935166739, -2.808917465908714, 0.5000178451051316),
85
- (3.8916678022926607, -2.772484153217685, 0.5060648178503393),
86
- (3.285753657755655, -2.3681294933425376, 0.46449024233003106),
87
- (2.3465413258596377, -1.7097828382687081, 0.42323551169305323),
88
- ]
89
-
90
- @torch.compile(dynamic=False, fullgraph=True)
91
- def muon_step_fused(
92
- stacked_grads: Tensor, # (12, 768, 3072) - stacked gradients
93
- stacked_params: Tensor, # (12, 768, 3072) - stacked parameters
94
- momentum_buffer: Tensor, # (12, 768, 3072) - first moment buffer
95
- second_momentum_buffer: Tensor, # (12, 768, 1) or (12, 1, 3072) - factored second moment
96
- momentum_t: Tensor, # () - 0-D CPU tensor, momentum coefficient
97
- lr_t: Tensor, # () - 0-D CPU tensor, learning rate
98
- wd_t: Tensor, # () - 0-D CPU tensor, weight decay
99
- beta2_t: Tensor, # () - 0-D CPU tensor, beta2 for second moment
100
- ns_steps: int, # 5 - number of Newton-Schulz/Polar Express iterations
101
- red_dim: int, # -1 or -2 - reduction dimension for variance
102
- ) -> None:
103
- """
104
- Fused Muon step: momentum -> polar_express -> variance_reduction -> cautious_update
105
- All in one compiled graph to eliminate Python overhead between ops.
106
- Some of the constants are 0-D CPU tensors to avoid recompilation when values change.
107
- """
108
-
109
- # Nesterov momentum
110
- momentum = momentum_t.to(stacked_grads.dtype)
111
- momentum_buffer.lerp_(stacked_grads, 1 - momentum)
112
- g = stacked_grads.lerp_(momentum_buffer, momentum)
113
-
114
- # Polar express
115
- X = g.bfloat16()
116
- X = X / (X.norm(dim=(-2, -1), keepdim=True) * 1.02 + 1e-6)
117
- if g.size(-2) > g.size(-1): # Tall matrix
118
- for a, b, c in polar_express_coeffs[:ns_steps]:
119
- A = X.mT @ X
120
- B = b * A + c * (A @ A)
121
- X = a * X + X @ B
122
- else: # Wide matrix (original math)
123
- for a, b, c in polar_express_coeffs[:ns_steps]:
124
- A = X @ X.mT
125
- B = b * A + c * (A @ A)
126
- X = a * X + B @ X
127
- g = X
128
-
129
- # Variance reduction
130
- beta2 = beta2_t.to(g.dtype)
131
- v_mean = g.float().square().mean(dim=red_dim, keepdim=True)
132
- red_dim_size = g.size(red_dim)
133
- v_norm_sq = v_mean.sum(dim=(-2, -1), keepdim=True) * red_dim_size
134
- v_norm = v_norm_sq.sqrt()
135
- second_momentum_buffer.lerp_(v_mean.to(dtype=second_momentum_buffer.dtype), 1 - beta2)
136
- step_size = second_momentum_buffer.clamp_min(1e-10).rsqrt()
137
- scaled_sq_sum = (v_mean * red_dim_size) * step_size.float().square()
138
- v_norm_new = scaled_sq_sum.sum(dim=(-2, -1), keepdim=True).sqrt()
139
- final_scale = step_size * (v_norm / v_norm_new.clamp_min(1e-10))
140
- g = g * final_scale.to(g.dtype)
141
-
142
- # Cautious weight decay + parameter update
143
- lr = lr_t.to(g.dtype)
144
- wd = wd_t.to(g.dtype)
145
- mask = (g * stacked_params) >= 0
146
- stacked_params.sub_(lr * g + lr * wd * stacked_params * mask)
147
-
148
- # -----------------------------------------------------------------------------
149
- # Single GPU version of the MuonAdamW optimizer.
150
- # Used mostly for reference, debugging and testing.
151
-
152
- class MuonAdamW(torch.optim.Optimizer):
153
- """
154
- Combined optimizer: Muon for 2D matrix params, AdamW for others, single GPU version.
155
-
156
- AdamW - Fused AdamW optimizer step.
157
-
158
- Muon - MomentUm Orthogonalized by Newton-schulz
159
- https://kellerjordan.github.io/posts/muon/
160
-
161
- Muon internally runs standard SGD-momentum, and then performs an orthogonalization post-
162
- processing step, in which each 2D parameter's update is replaced with the nearest orthogonal
163
- matrix. To efficiently orthogonalize each update, we use a Newton-Schulz iteration, which has
164
- the advantage that it can be stably run in bfloat16 on the GPU.
165
-
166
- Some warnings:
167
- - The Muon optimizer should not be used for the embedding layer, the final fully connected layer,
168
- or any {0,1}-D parameters; those should all be optimized by a standard method (e.g., AdamW).
169
- - To use it with 4D convolutional filters, it works well to just flatten their last 3 dimensions.
170
-
171
- Arguments:
172
- param_groups: List of dicts, each containing:
173
- - 'params': List of parameters
174
- - 'kind': 'adamw' or 'muon'
175
- - For AdamW groups: 'lr', 'betas', 'eps', 'weight_decay'
176
- - For Muon groups: 'lr', 'momentum', 'ns_steps', 'beta2', 'weight_decay'
177
- """
178
- def __init__(self, param_groups: list[dict]):
179
- super().__init__(param_groups, defaults={})
180
- # 0-D CPU tensors to avoid torch.compile recompilation when values change
181
- # AdamW tensors
182
- self._adamw_step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
183
- self._adamw_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
184
- self._adamw_beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
185
- self._adamw_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
186
- self._adamw_eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
187
- self._adamw_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
188
- # Muon tensors
189
- self._muon_momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
190
- self._muon_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
191
- self._muon_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
192
- self._muon_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
193
-
194
- def _step_adamw(self, group: dict) -> None:
195
- """
196
- AdamW update for each param in the group individually.
197
- Lazy init the state, fill in all 0-D tensors, call the fused kernel.
198
- """
199
- for p in group['params']:
200
- if p.grad is None:
201
- continue
202
- grad = p.grad
203
- state = self.state[p]
204
-
205
- # State init
206
- if not state:
207
- state['step'] = 0
208
- state['exp_avg'] = torch.zeros_like(p)
209
- state['exp_avg_sq'] = torch.zeros_like(p)
210
- exp_avg = state['exp_avg']
211
- exp_avg_sq = state['exp_avg_sq']
212
- state['step'] += 1
213
-
214
- # Fill 0-D tensors with current values
215
- self._adamw_step_t.fill_(state['step'])
216
- self._adamw_lr_t.fill_(group['lr'])
217
- self._adamw_beta1_t.fill_(group['betas'][0])
218
- self._adamw_beta2_t.fill_(group['betas'][1])
219
- self._adamw_eps_t.fill_(group['eps'])
220
- self._adamw_wd_t.fill_(group['weight_decay'])
221
-
222
- # Fused update: weight_decay -> momentum -> bias_correction -> param_update
223
- adamw_step_fused(
224
- p, grad, exp_avg, exp_avg_sq,
225
- self._adamw_step_t, self._adamw_lr_t, self._adamw_beta1_t,
226
- self._adamw_beta2_t, self._adamw_eps_t, self._adamw_wd_t,
227
- )
228
-
229
- def _step_muon(self, group: dict) -> None:
230
- """
231
- Muon update for all params in the group (stacked for efficiency).
232
- Lazy init the state, fill in all 0-D tensors, call the fused kernel.
233
- """
234
- params: list[Tensor] = group['params']
235
- if not params:
236
- return
237
-
238
- # Get or create group-level buffers (stored in first param's state for convenience)
239
- p = params[0]
240
- state = self.state[p]
241
- num_params = len(params)
242
- shape, device, dtype = p.shape, p.device, p.dtype
243
-
244
- # Momentum for every individual parameter
245
- if "momentum_buffer" not in state:
246
- state["momentum_buffer"] = torch.zeros(num_params, *shape, dtype=dtype, device=device)
247
- momentum_buffer = state["momentum_buffer"]
248
-
249
- # Second momentum buffer is factored, either per-row or per-column
250
- if "second_momentum_buffer" not in state:
251
- state_shape = (num_params, shape[-2], 1) if shape[-2] >= shape[-1] else (num_params, 1, shape[-1])
252
- state["second_momentum_buffer"] = torch.zeros(state_shape, dtype=dtype, device=device)
253
- second_momentum_buffer = state["second_momentum_buffer"]
254
- red_dim = -1 if shape[-2] >= shape[-1] else -2
255
-
256
- # Stack grads and params (NOTE: this assumes all params have the same shape)
257
- stacked_grads = torch.stack([p.grad for p in params])
258
- stacked_params = torch.stack(params)
259
-
260
- # Fill all the 0-D tensors with current values
261
- self._muon_momentum_t.fill_(group["momentum"])
262
- self._muon_beta2_t.fill_(group["beta2"] if group["beta2"] is not None else 0.0)
263
- self._muon_lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1])**0.5)
264
- self._muon_wd_t.fill_(group["weight_decay"])
265
-
266
- # Single fused kernel: momentum -> polar_express -> variance_reduction -> update
267
- muon_step_fused(
268
- stacked_grads,
269
- stacked_params,
270
- momentum_buffer,
271
- second_momentum_buffer,
272
- self._muon_momentum_t,
273
- self._muon_lr_t,
274
- self._muon_wd_t,
275
- self._muon_beta2_t,
276
- group["ns_steps"],
277
- red_dim,
278
- )
279
-
280
- # Copy back to original params
281
- torch._foreach_copy_(params, list(stacked_params.unbind(0)))
282
-
283
- @torch.no_grad()
284
- def step(self):
285
- for group in self.param_groups:
286
- if group['kind'] == 'adamw':
287
- self._step_adamw(group)
288
- elif group['kind'] == 'muon':
289
- self._step_muon(group)
290
- else:
291
- raise ValueError(f"Unknown optimizer kind: {group['kind']}")
292
-
293
- # -----------------------------------------------------------------------------
294
- # Distributed version of the MuonAdamW optimizer.
295
- # Used for training on multiple GPUs.
296
-
297
- class DistMuonAdamW(torch.optim.Optimizer):
298
- """
299
- Combined distributed optimizer: Muon for 2D matrix params, AdamW for others.
300
-
301
- See MuonAdamW for the algorithmic details of each optimizer. This class adds
302
- distributed communication to enable multi-GPU training without PyTorch DDP.
303
-
304
- Design Goals:
305
- - Overlap communication with computation (async ops)
306
- - Minimize memory by sharding optimizer states across ranks (ZeRO-2 style)
307
- - Batch small tensors into single comm ops where possible
308
-
309
- Communication Pattern (3-phase async):
310
- We use a 3-phase structure to maximize overlap between communication and compute:
311
-
312
- Phase 1: Launch all async reduce ops
313
- - Kick off all reduce_scatter/all_reduce operations
314
- - Don't wait - let them run in background while we continue
315
-
316
- Phase 2: Wait for reduces, compute updates, launch gathers
317
- - For each group: wait for its reduce, compute the update, launch gather
318
- - By processing groups in order, earlier gathers run while later computes happen
319
-
320
- Phase 3: Wait for gathers, copy back
321
- - Wait for all gathers to complete
322
- - Copy updated params back to original tensors (Muon only)
323
-
324
- AdamW Communication (ZeRO-2 style):
325
- - Small params (<1024 elements): all_reduce gradients, update full param on each rank.
326
- Optimizer state is replicated but these params are tiny (scalars, biases).
327
- - Large params: reduce_scatter gradients so each rank gets 1/N of the grad, update
328
- only that slice, then all_gather the updated slices. Optimizer state (exp_avg,
329
- exp_avg_sq) is sharded - each rank only stores state for its slice.
330
- Requires param.shape[0] divisible by world_size.
331
-
332
- Muon Communication (stacked + chunked):
333
- - All params in a Muon group must have the same shape (caller's responsibility).
334
- - Stack all K params into a single (K, *shape) tensor for efficient comm.
335
- - Divide K params across N ranks: each rank "owns" ceil(K/N) params.
336
- - reduce_scatter the stacked grads so each rank gets its chunk.
337
- - Each rank computes Muon update only for params it owns.
338
- - all_gather the updated params back to all ranks.
339
- - Optimizer state (momentum_buffer, second_momentum_buffer) is sharded by chunk.
340
- - Padding: if K doesn't divide evenly, we zero-pad to (ceil(K/N) * N) for comm,
341
- then ignore the padding when copying back.
342
-
343
- Buffer Reuse:
344
- - For Muon, we allocate stacked_grads for reduce_scatter input, then reuse the
345
- same buffer as the output for all_gather (stacked_params). This saves memory
346
- since we don't need both buffers simultaneously.
347
-
348
- Arguments:
349
- param_groups: List of dicts, each containing:
350
- - 'params': List of parameters
351
- - 'kind': 'adamw' or 'muon'
352
- - For AdamW groups: 'lr', 'betas', 'eps', 'weight_decay'
353
- - For Muon groups: 'lr', 'momentum', 'ns_steps', 'beta2', 'weight_decay'
354
- """
355
- def __init__(self, param_groups: list[dict]):
356
- super().__init__(param_groups, defaults={})
357
- # 0-D CPU tensors to avoid torch.compile recompilation when values change
358
- self._adamw_step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
359
- self._adamw_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
360
- self._adamw_beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
361
- self._adamw_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
362
- self._adamw_eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
363
- self._adamw_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
364
- self._muon_momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
365
- self._muon_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
366
- self._muon_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
367
- self._muon_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
368
-
369
- def _reduce_adamw(self, group: dict, world_size: int) -> dict:
370
- """Launch async reduce ops for AdamW group. Returns info dict with per-param infos."""
371
- param_infos = {}
372
- for p in group['params']:
373
- grad = p.grad
374
- if p.numel() < 1024:
375
- # Small params: all_reduce (no scatter/gather needed)
376
- future = dist.all_reduce(grad, op=dist.ReduceOp.AVG, async_op=True).get_future()
377
- param_infos[p] = dict(future=future, grad_slice=grad, is_small=True)
378
- else:
379
- # Large params: reduce_scatter
380
- assert grad.shape[0] % world_size == 0, f"AdamW reduce_scatter requires shape[0] ({grad.shape[0]}) divisible by world_size ({world_size})"
381
- rank_size = grad.shape[0] // world_size
382
- grad_slice = torch.empty_like(grad[:rank_size])
383
- future = dist.reduce_scatter_tensor(grad_slice, grad, op=dist.ReduceOp.AVG, async_op=True).get_future()
384
- param_infos[p] = dict(future=future, grad_slice=grad_slice, is_small=False)
385
- return dict(param_infos=param_infos)
386
-
387
- def _reduce_muon(self, group: dict, world_size: int) -> dict:
388
- """Launch async reduce op for Muon group. Returns info dict."""
389
- params = group['params']
390
- chunk_size = (len(params) + world_size - 1) // world_size
391
- padded_num_params = chunk_size * world_size
392
- p = params[0]
393
- shape, device, dtype = p.shape, p.device, p.dtype
394
-
395
- # Stack grads and zero-pad to padded_num_params
396
- grad_stack = torch.stack([p.grad for p in params])
397
- stacked_grads = torch.empty(padded_num_params, *shape, dtype=dtype, device=device)
398
- stacked_grads[:len(params)].copy_(grad_stack)
399
- if len(params) < padded_num_params:
400
- stacked_grads[len(params):].zero_()
401
-
402
- # Reduce_scatter to get this rank's chunk
403
- grad_chunk = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
404
- future = dist.reduce_scatter_tensor(grad_chunk, stacked_grads, op=dist.ReduceOp.AVG, async_op=True).get_future()
405
-
406
- return dict(future=future, grad_chunk=grad_chunk, stacked_grads=stacked_grads, chunk_size=chunk_size)
407
-
408
- def _compute_adamw(self, group: dict, info: dict, gather_list: list, rank: int, world_size: int) -> None:
409
- """Wait for reduce, compute AdamW updates, launch gathers for large params."""
410
- param_infos = info['param_infos']
411
- for p in group['params']:
412
- pinfo = param_infos[p]
413
- pinfo['future'].wait()
414
- grad_slice = pinfo['grad_slice']
415
- state = self.state[p]
416
-
417
- # For small params, operate on full param; for large, operate on slice
418
- if pinfo['is_small']:
419
- p_slice = p
420
- else:
421
- rank_size = p.shape[0] // world_size
422
- p_slice = p[rank * rank_size:(rank + 1) * rank_size]
423
-
424
- # State init
425
- if not state:
426
- state['step'] = 0
427
- state['exp_avg'] = torch.zeros_like(p_slice)
428
- state['exp_avg_sq'] = torch.zeros_like(p_slice)
429
- state['step'] += 1
430
-
431
- # Fill 0-D tensors and run fused kernel
432
- self._adamw_step_t.fill_(state['step'])
433
- self._adamw_lr_t.fill_(group['lr'])
434
- self._adamw_beta1_t.fill_(group['betas'][0])
435
- self._adamw_beta2_t.fill_(group['betas'][1])
436
- self._adamw_eps_t.fill_(group['eps'])
437
- self._adamw_wd_t.fill_(group['weight_decay'])
438
- adamw_step_fused(
439
- p_slice, grad_slice, state['exp_avg'], state['exp_avg_sq'],
440
- self._adamw_step_t, self._adamw_lr_t, self._adamw_beta1_t,
441
- self._adamw_beta2_t, self._adamw_eps_t, self._adamw_wd_t,
442
- )
443
-
444
- # Large params need all_gather
445
- if not pinfo['is_small']:
446
- future = dist.all_gather_into_tensor(p, p_slice, async_op=True).get_future()
447
- gather_list.append(dict(future=future, params=None))
448
-
449
- def _compute_muon(self, group: dict, info: dict, gather_list: list, rank: int) -> None:
450
- """Wait for reduce, compute Muon updates, launch gather."""
451
- info['future'].wait()
452
- params = group['params']
453
- chunk_size = info['chunk_size']
454
- grad_chunk = info['grad_chunk']
455
- p = params[0]
456
- shape, device, dtype = p.shape, p.device, p.dtype
457
-
458
- # How many params does this rank own?
459
- start_idx = rank * chunk_size
460
- num_owned = min(chunk_size, max(0, len(params) - start_idx))
461
-
462
- # Get or create group-level state
463
- state = self.state[p]
464
- if "momentum_buffer" not in state:
465
- state["momentum_buffer"] = torch.zeros(chunk_size, *shape, dtype=dtype, device=device)
466
- if "second_momentum_buffer" not in state:
467
- state_shape = (chunk_size, shape[-2], 1) if shape[-2] >= shape[-1] else (chunk_size, 1, shape[-1])
468
- state["second_momentum_buffer"] = torch.zeros(state_shape, dtype=dtype, device=device)
469
- red_dim = -1 if shape[-2] >= shape[-1] else -2
470
-
471
- # Build output buffer for all_gather
472
- updated_params = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
473
-
474
- if num_owned > 0:
475
- owned_params = [params[start_idx + i] for i in range(num_owned)]
476
- stacked_owned = torch.stack(owned_params)
477
-
478
- # Fill 0-D tensors and run fused kernel
479
- self._muon_momentum_t.fill_(group["momentum"])
480
- self._muon_beta2_t.fill_(group["beta2"])
481
- self._muon_lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1])**0.5)
482
- self._muon_wd_t.fill_(group["weight_decay"])
483
- muon_step_fused(
484
- grad_chunk[:num_owned], stacked_owned,
485
- state["momentum_buffer"][:num_owned], state["second_momentum_buffer"][:num_owned],
486
- self._muon_momentum_t, self._muon_lr_t, self._muon_wd_t, self._muon_beta2_t,
487
- group["ns_steps"], red_dim,
488
- )
489
- updated_params[:num_owned].copy_(stacked_owned)
490
-
491
- if num_owned < chunk_size:
492
- updated_params[num_owned:].zero_()
493
-
494
- # Reuse stacked_grads buffer for all_gather output
495
- stacked_params = info["stacked_grads"]
496
- future = dist.all_gather_into_tensor(stacked_params, updated_params, async_op=True).get_future()
497
- gather_list.append(dict(future=future, stacked_params=stacked_params, params=params))
498
-
499
- def _finish_gathers(self, gather_list: list) -> None:
500
- """Wait for all gathers and copy Muon params back."""
501
- for info in gather_list:
502
- info["future"].wait()
503
- if info["params"] is not None:
504
- # Muon: copy from stacked buffer back to individual params
505
- torch._foreach_copy_(info["params"], list(info["stacked_params"][:len(info["params"])].unbind(0)))
506
-
507
- @torch.no_grad()
508
- def step(self):
509
- rank = dist.get_rank()
510
- world_size = dist.get_world_size()
511
-
512
- # Phase 1: launch all async reduce ops
513
- reduce_infos: list[dict] = []
514
- for group in self.param_groups:
515
- if group['kind'] == 'adamw':
516
- reduce_infos.append(self._reduce_adamw(group, world_size))
517
- elif group['kind'] == 'muon':
518
- reduce_infos.append(self._reduce_muon(group, world_size))
519
- else:
520
- raise ValueError(f"Unknown optimizer kind: {group['kind']}")
521
-
522
- # Phase 2: wait for reduces, compute updates, launch gathers
523
- gather_list: list[dict] = []
524
- for group, info in zip(self.param_groups, reduce_infos):
525
- if group['kind'] == 'adamw':
526
- self._compute_adamw(group, info, gather_list, rank, world_size)
527
- elif group['kind'] == 'muon':
528
- self._compute_muon(group, info, gather_list, rank)
529
- else:
530
- raise ValueError(f"Unknown optimizer kind: {group['kind']}")
531
-
532
- # Phase 3: wait for gathers, copy back
533
- self._finish_gathers(gather_list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/tokenizer.py DELETED
@@ -1,406 +0,0 @@
1
- """
2
- BPE Tokenizer in the style of GPT-4.
3
-
4
- Two implementations are available:
5
- 1) HuggingFace Tokenizer that can do both training and inference but is really confusing
6
- 2) Our own RustBPE Tokenizer for training and tiktoken for efficient inference
7
- """
8
-
9
- import os
10
- import copy
11
- from functools import lru_cache
12
-
13
- SPECIAL_TOKENS = [
14
- # every document begins with the Beginning of Sequence (BOS) token that delimits documents
15
- "<|bos|>",
16
- # tokens below are only used during finetuning to render Conversations into token ids
17
- "<|user_start|>", # user messages
18
- "<|user_end|>",
19
- "<|assistant_start|>", # assistant messages
20
- "<|assistant_end|>",
21
- "<|python_start|>", # assistant invokes python REPL tool
22
- "<|python_end|>",
23
- "<|output_start|>", # python REPL outputs back to assistant
24
- "<|output_end|>",
25
- ]
26
-
27
- # NOTE: this split pattern deviates from GPT-4 in that we use \p{N}{1,2} instead of \p{N}{1,3}
28
- # I did this because I didn't want to "waste" too many tokens on numbers for smaller vocab sizes.
29
- # I verified that 2 is the sweet spot for vocab size of 32K. 1 is a bit worse, 3 was worse still.
30
- 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+"""
31
-
32
- # -----------------------------------------------------------------------------
33
- # Generic GPT-4-style tokenizer based on HuggingFace Tokenizer
34
- from tokenizers import Tokenizer as HFTokenizer
35
- from tokenizers import pre_tokenizers, decoders, Regex
36
- from tokenizers.models import BPE
37
- from tokenizers.trainers import BpeTrainer
38
-
39
- class HuggingFaceTokenizer:
40
- """Light wrapper around HuggingFace Tokenizer for some utilities"""
41
-
42
- def __init__(self, tokenizer):
43
- self.tokenizer = tokenizer
44
-
45
- @classmethod
46
- def from_pretrained(cls, hf_path):
47
- # init from a HuggingFace pretrained tokenizer (e.g. "gpt2")
48
- tokenizer = HFTokenizer.from_pretrained(hf_path)
49
- return cls(tokenizer)
50
-
51
- @classmethod
52
- def from_directory(cls, tokenizer_dir):
53
- # init from a local directory on disk (e.g. "out/tokenizer")
54
- tokenizer_path = os.path.join(tokenizer_dir, "tokenizer.json")
55
- tokenizer = HFTokenizer.from_file(tokenizer_path)
56
- return cls(tokenizer)
57
-
58
- @classmethod
59
- def train_from_iterator(cls, text_iterator, vocab_size):
60
- # train from an iterator of text
61
- # Configure the HuggingFace Tokenizer
62
- tokenizer = HFTokenizer(BPE(
63
- byte_fallback=True, # needed!
64
- unk_token=None,
65
- fuse_unk=False,
66
- ))
67
- # Normalizer: None
68
- tokenizer.normalizer = None
69
- # Pre-tokenizer: GPT-4 style
70
- # the regex pattern used by GPT-4 to split text into groups before BPE
71
- # NOTE: The pattern was changed from \p{N}{1,3} to \p{N}{1,2} because I suspect it is harmful to
72
- # very small models and smaller vocab sizes, because it is a little bit wasteful in the token space.
73
- # (but I haven't validated this! TODO)
74
- gpt4_split_regex = Regex(SPLIT_PATTERN) # huggingface demands that you wrap it in Regex!!
75
- tokenizer.pre_tokenizer = pre_tokenizers.Sequence([
76
- pre_tokenizers.Split(pattern=gpt4_split_regex, behavior="isolated", invert=False),
77
- pre_tokenizers.ByteLevel(add_prefix_space=False, use_regex=False)
78
- ])
79
- # Decoder: ByteLevel (it pairs together with the ByteLevel pre-tokenizer)
80
- tokenizer.decoder = decoders.ByteLevel()
81
- # Post-processor: None
82
- tokenizer.post_processor = None
83
- # Trainer: BPE
84
- trainer = BpeTrainer(
85
- vocab_size=vocab_size,
86
- show_progress=True,
87
- min_frequency=0, # no minimum frequency
88
- initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
89
- special_tokens=SPECIAL_TOKENS,
90
- )
91
- # Kick off the training
92
- tokenizer.train_from_iterator(text_iterator, trainer)
93
- return cls(tokenizer)
94
-
95
- def get_vocab_size(self):
96
- return self.tokenizer.get_vocab_size()
97
-
98
- def get_special_tokens(self):
99
- special_tokens_map = self.tokenizer.get_added_tokens_decoder()
100
- special_tokens = [w.content for w in special_tokens_map.values()]
101
- return special_tokens
102
-
103
- def id_to_token(self, id):
104
- return self.tokenizer.id_to_token(id)
105
-
106
- def _encode_one(self, text, prepend=None, append=None, num_threads=None):
107
- # encode a single string
108
- # prepend/append can be either a string of a special token or a token id directly.
109
- # num_threads is ignored (only used by the nanochat Tokenizer for parallel encoding)
110
- assert isinstance(text, str)
111
- ids = []
112
- if prepend is not None:
113
- prepend_id = prepend if isinstance(prepend, int) else self.encode_special(prepend)
114
- ids.append(prepend_id)
115
- ids.extend(self.tokenizer.encode(text, add_special_tokens=False).ids)
116
- if append is not None:
117
- append_id = append if isinstance(append, int) else self.encode_special(append)
118
- ids.append(append_id)
119
- return ids
120
-
121
- def encode_special(self, text):
122
- # encode a single special token via exact match
123
- return self.tokenizer.token_to_id(text)
124
-
125
- def get_bos_token_id(self):
126
- # Different HuggingFace models use different BOS tokens and there is little consistency
127
- # 1) attempt to find a <|bos|> token
128
- bos = self.encode_special("<|bos|>")
129
- # 2) if that fails, attempt to find a <|endoftext|> token (e.g. GPT-2 models)
130
- if bos is None:
131
- bos = self.encode_special("<|endoftext|>")
132
- # 3) if these fail, it's better to crash than to silently return None
133
- assert bos is not None, "Failed to find BOS token in tokenizer"
134
- return bos
135
-
136
- def encode(self, text, *args, **kwargs):
137
- if isinstance(text, str):
138
- return self._encode_one(text, *args, **kwargs)
139
- elif isinstance(text, list):
140
- return [self._encode_one(t, *args, **kwargs) for t in text]
141
- else:
142
- raise ValueError(f"Invalid input type: {type(text)}")
143
-
144
- def __call__(self, *args, **kwargs):
145
- return self.encode(*args, **kwargs)
146
-
147
- def decode(self, ids):
148
- return self.tokenizer.decode(ids, skip_special_tokens=False)
149
-
150
- def save(self, tokenizer_dir):
151
- # save the tokenizer to disk
152
- os.makedirs(tokenizer_dir, exist_ok=True)
153
- tokenizer_path = os.path.join(tokenizer_dir, "tokenizer.json")
154
- self.tokenizer.save(tokenizer_path)
155
- print(f"Saved tokenizer to {tokenizer_path}")
156
-
157
- # -----------------------------------------------------------------------------
158
- # Tokenizer based on rustbpe + tiktoken combo
159
- import pickle
160
- import rustbpe
161
- import tiktoken
162
-
163
- class RustBPETokenizer:
164
- """Light wrapper around tiktoken (for efficient inference) but train with rustbpe"""
165
-
166
- def __init__(self, enc, bos_token):
167
- self.enc = enc
168
- self.bos_token_id = self.encode_special(bos_token)
169
-
170
- @classmethod
171
- def train_from_iterator(cls, text_iterator, vocab_size):
172
- # 1) train using rustbpe
173
- tokenizer = rustbpe.Tokenizer()
174
- # the special tokens are inserted later in __init__, we don't train them here
175
- vocab_size_no_special = vocab_size - len(SPECIAL_TOKENS)
176
- assert vocab_size_no_special >= 256, f"vocab_size_no_special must be at least 256, got {vocab_size_no_special}"
177
- tokenizer.train_from_iterator(text_iterator, vocab_size_no_special, pattern=SPLIT_PATTERN)
178
- # 2) construct the associated tiktoken encoding for inference
179
- pattern = tokenizer.get_pattern()
180
- mergeable_ranks_list = tokenizer.get_mergeable_ranks()
181
- mergeable_ranks = {bytes(k): v for k, v in mergeable_ranks_list}
182
- tokens_offset = len(mergeable_ranks)
183
- special_tokens = {name: tokens_offset + i for i, name in enumerate(SPECIAL_TOKENS)}
184
- enc = tiktoken.Encoding(
185
- name="rustbpe",
186
- pat_str=pattern,
187
- mergeable_ranks=mergeable_ranks, # dict[bytes, int] (token bytes -> merge priority rank)
188
- special_tokens=special_tokens, # dict[str, int] (special token name -> token id)
189
- )
190
- return cls(enc, "<|bos|>")
191
-
192
- @classmethod
193
- def from_directory(cls, tokenizer_dir):
194
- pickle_path = os.path.join(tokenizer_dir, "tokenizer.pkl")
195
- with open(pickle_path, "rb") as f:
196
- enc = pickle.load(f)
197
- return cls(enc, "<|bos|>")
198
-
199
- @classmethod
200
- def from_pretrained(cls, tiktoken_name):
201
- # https://github.com/openai/tiktoken/blob/eedc8563/tiktoken_ext/openai_public.py
202
- enc = tiktoken.get_encoding(tiktoken_name)
203
- # tiktoken calls the special document delimiter token "<|endoftext|>"
204
- # yes this is confusing because this token is almost always PREPENDED to the beginning of the document
205
- # it most often is used to signal the start of a new sequence to the LLM during inference etc.
206
- # so in nanoChat we always use "<|bos|>" short for "beginning of sequence", but historically it is often called "<|endoftext|>".
207
- return cls(enc, "<|endoftext|>")
208
-
209
- def get_vocab_size(self):
210
- return self.enc.n_vocab
211
-
212
- def get_special_tokens(self):
213
- return self.enc.special_tokens_set
214
-
215
- def id_to_token(self, id):
216
- return self.enc.decode([id])
217
-
218
- @lru_cache(maxsize=32)
219
- def encode_special(self, text):
220
- return self.enc.encode_single_token(text)
221
-
222
- def get_bos_token_id(self):
223
- return self.bos_token_id
224
-
225
- def encode(self, text, prepend=None, append=None, num_threads=8):
226
- # text can be either a string or a list of strings
227
-
228
- if prepend is not None:
229
- prepend_id = prepend if isinstance(prepend, int) else self.encode_special(prepend)
230
- if append is not None:
231
- append_id = append if isinstance(append, int) else self.encode_special(append)
232
-
233
- if isinstance(text, str):
234
- ids = self.enc.encode_ordinary(text)
235
- if prepend is not None:
236
- ids.insert(0, prepend_id) # TODO: slightly inefficient here? :( hmm
237
- if append is not None:
238
- ids.append(append_id)
239
- elif isinstance(text, list):
240
- ids = self.enc.encode_ordinary_batch(text, num_threads=num_threads)
241
- if prepend is not None:
242
- for ids_row in ids:
243
- ids_row.insert(0, prepend_id) # TODO: same
244
- if append is not None:
245
- for ids_row in ids:
246
- ids_row.append(append_id)
247
- else:
248
- raise ValueError(f"Invalid input type: {type(text)}")
249
-
250
- return ids
251
-
252
- def __call__(self, *args, **kwargs):
253
- return self.encode(*args, **kwargs)
254
-
255
- def decode(self, ids):
256
- return self.enc.decode(ids)
257
-
258
- def save(self, tokenizer_dir):
259
- # save the encoding object to disk
260
- os.makedirs(tokenizer_dir, exist_ok=True)
261
- pickle_path = os.path.join(tokenizer_dir, "tokenizer.pkl")
262
- with open(pickle_path, "wb") as f:
263
- pickle.dump(self.enc, f)
264
- print(f"Saved tokenizer encoding to {pickle_path}")
265
-
266
- def render_conversation(self, conversation, max_tokens=2048):
267
- """
268
- Tokenize a single Chat conversation (which we call a "doc" or "document" here).
269
- Returns:
270
- - ids: list[int] is a list of token ids of this rendered conversation
271
- - mask: list[int] of same length, mask = 1 for tokens that the Assistant is expected to train on.
272
- """
273
- # ids, masks that we will return and a helper function to help build them up.
274
- ids, mask = [], []
275
- def add_tokens(token_ids, mask_val):
276
- if isinstance(token_ids, int):
277
- token_ids = [token_ids]
278
- ids.extend(token_ids)
279
- mask.extend([mask_val] * len(token_ids))
280
-
281
- # sometimes the first message is a system message...
282
- # => just merge it with the second (user) message
283
- if conversation["messages"][0]["role"] == "system":
284
- # some conversation surgery is necessary here for now...
285
- conversation = copy.deepcopy(conversation) # avoid mutating the original
286
- messages = conversation["messages"]
287
- assert messages[1]["role"] == "user", "System message must be followed by a user message"
288
- messages[1]["content"] = messages[0]["content"] + "\n\n" + messages[1]["content"]
289
- messages = messages[1:]
290
- else:
291
- messages = conversation["messages"]
292
- assert len(messages) >= 1, f"Conversation has less than 1 message: {messages}"
293
-
294
- # fetch all the special tokens we need
295
- bos = self.get_bos_token_id()
296
- user_start, user_end = self.encode_special("<|user_start|>"), self.encode_special("<|user_end|>")
297
- assistant_start, assistant_end = self.encode_special("<|assistant_start|>"), self.encode_special("<|assistant_end|>")
298
- python_start, python_end = self.encode_special("<|python_start|>"), self.encode_special("<|python_end|>")
299
- output_start, output_end = self.encode_special("<|output_start|>"), self.encode_special("<|output_end|>")
300
-
301
- # now we can tokenize the conversation
302
- add_tokens(bos, 0)
303
- for i, message in enumerate(messages):
304
-
305
- # some sanity checking here around assumptions, to prevent footguns
306
- must_be_from = "user" if i % 2 == 0 else "assistant"
307
- assert message["role"] == must_be_from, f"Message {i} is from {message['role']} but should be from {must_be_from}"
308
-
309
- # content can be either a simple string or a list of parts (e.g. containing tool calls)
310
- content = message["content"]
311
-
312
- if message["role"] == "user":
313
- assert isinstance(content, str), "User messages are simply expected to be strings"
314
- value_ids = self.encode(content)
315
- add_tokens(user_start, 0)
316
- add_tokens(value_ids, 0)
317
- add_tokens(user_end, 0)
318
- elif message["role"] == "assistant":
319
- add_tokens(assistant_start, 0)
320
- if isinstance(content, str):
321
- # simple string => simply add the tokens
322
- value_ids = self.encode(content)
323
- add_tokens(value_ids, 1)
324
- elif isinstance(content, list):
325
- for part in content:
326
- value_ids = self.encode(part["text"])
327
- if part["type"] == "text":
328
- # string part => simply add the tokens
329
- add_tokens(value_ids, 1)
330
- elif part["type"] == "python":
331
- # python tool call => add the tokens inside <|python_start|> and <|python_end|>
332
- add_tokens(python_start, 1)
333
- add_tokens(value_ids, 1)
334
- add_tokens(python_end, 1)
335
- elif part["type"] == "python_output":
336
- # python output => add the tokens inside <|output_start|> and <|output_end|>
337
- # none of these tokens are supervised because the tokens come from Python at test time
338
- add_tokens(output_start, 0)
339
- add_tokens(value_ids, 0)
340
- add_tokens(output_end, 0)
341
- else:
342
- raise ValueError(f"Unknown part type: {part['type']}")
343
- else:
344
- raise ValueError(f"Unknown content type: {type(content)}")
345
- add_tokens(assistant_end, 1)
346
-
347
- # truncate to max_tokens tokens MAX (helps prevent OOMs)
348
- ids = ids[:max_tokens]
349
- mask = mask[:max_tokens]
350
- return ids, mask
351
-
352
- def visualize_tokenization(self, ids, mask, with_token_id=False):
353
- """Small helper function useful in debugging: visualize the tokenization of render_conversation"""
354
- RED = '\033[91m'
355
- GREEN = '\033[92m'
356
- RESET = '\033[0m'
357
- GRAY = '\033[90m'
358
- tokens = []
359
- for i, (token_id, mask_val) in enumerate(zip(ids, mask)):
360
- token_str = self.decode([token_id])
361
- color = GREEN if mask_val == 1 else RED
362
- tokens.append(f"{color}{token_str}{RESET}")
363
- if with_token_id:
364
- tokens.append(f"{GRAY}({token_id}){RESET}")
365
- return '|'.join(tokens)
366
-
367
- def render_for_completion(self, conversation):
368
- """
369
- Used during Reinforcement Learning. In that setting, we want to
370
- render the conversation priming the Assistant for a completion.
371
- Unlike the Chat SFT case, we don't need to return the mask.
372
- """
373
- # We have some surgery to do: we need to pop the last message (of the Assistant)
374
- conversation = copy.deepcopy(conversation) # avoid mutating the original
375
- messages = conversation["messages"]
376
- assert messages[-1]["role"] == "assistant", "Last message must be from the Assistant"
377
- messages.pop() # remove the last message (of the Assistant) inplace
378
-
379
- # Now tokenize the conversation
380
- ids, mask = self.render_conversation(conversation)
381
-
382
- # Finally, to prime the Assistant for a completion, append the Assistant start token
383
- assistant_start = self.encode_special("<|assistant_start|>")
384
- ids.append(assistant_start)
385
- return ids
386
-
387
- # -----------------------------------------------------------------------------
388
- # nanochat-specific convenience functions
389
-
390
- def get_tokenizer():
391
- from nanochat.common import get_base_dir
392
- base_dir = get_base_dir()
393
- tokenizer_dir = os.path.join(base_dir, "tokenizer")
394
- # return HuggingFaceTokenizer.from_directory(tokenizer_dir)
395
- return RustBPETokenizer.from_directory(tokenizer_dir)
396
-
397
- def get_token_bytes(device="cpu"):
398
- import torch
399
- from nanochat.common import get_base_dir
400
- base_dir = get_base_dir()
401
- tokenizer_dir = os.path.join(base_dir, "tokenizer")
402
- token_bytes_path = os.path.join(tokenizer_dir, "token_bytes.pt")
403
- assert os.path.exists(token_bytes_path), f"Token bytes not found at {token_bytes_path}? It gets written by tok_train.py"
404
- with open(token_bytes_path, "rb") as f:
405
- token_bytes = torch.load(f, map_location=device)
406
- return token_bytes