mhla commited on
Commit
9dfef6b
·
verified ·
1 Parent(s): e519966

Upload 1905-base-d34 checkpoint (step 3000)

Browse files
README.md ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - gpt
7
+ - pre-1900
8
+ - historical
9
+ - nanochat
10
+ ---
11
+
12
+ # GPT-1905 D34 Base
13
+
14
+ 3.29B parameter GPT-style language model trained on pre-1905 English text. Training in progress.
15
+
16
+ ## Model Details
17
+
18
+ - **Architecture:** Custom GPT with RoPE, QK-norm, ReLU², value embeddings (ResFormer), per-layer residual/skip scalars
19
+ - **Parameters:** 3.29B
20
+ - **Layers:** 34
21
+ - **Hidden dim:** 2176
22
+ - **Attention heads:** 17 (query) / 17 (kv)
23
+ - **Head dim:** 128
24
+ - **Context length:** 2048 tokens
25
+ - **Vocab size:** 32,768 (BPE, GPT-4 style split pattern)
26
+ - **Training:** Base pretraining on pre-1905 corpus, checkpoint at step 3000
27
+
28
+ ## Checkpoint Contents
29
+
30
+ ```
31
+ model_003000.pt # Model weights
32
+ meta_003000.json # Training config and metadata
33
+ optim_003000_rank*.pt # Optimizer state shards (if present, for resuming training)
34
+ tokenizer/ # BPE tokenizer (tiktoken format) + token byte counts
35
+ nanochat/ # Source code to load and run the model
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ import torch, json
42
+ from nanochat.gpt import GPT, GPTConfig
43
+ from nanochat.tokenizer import RustBPETokenizer
44
+
45
+ tokenizer = RustBPETokenizer.from_directory("tokenizer")
46
+
47
+ with open("meta_003000.json") as f:
48
+ meta = json.load(f)
49
+
50
+ config = GPTConfig(**meta["model_config"])
51
+
52
+ with torch.device("meta"):
53
+ model = GPT(config)
54
+ model.to_empty(device="cuda")
55
+ model.init_weights()
56
+
57
+ state_dict = torch.load("model_003000.pt", map_location="cuda")
58
+ state_dict = {k.removeprefix("_orig_mod."): v for k, v in state_dict.items()}
59
+ model.load_state_dict(state_dict, strict=True, assign=True)
60
+ model.eval()
61
+
62
+ bos = tokenizer.get_bos_token_id()
63
+ tokens = tokenizer.encode("It was a dark and stormy night", prepend=bos)
64
+ with torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16):
65
+ for token in model.generate(tokens, max_tokens=100, temperature=0.8):
66
+ print(tokenizer.decode([token]), end="", flush=True)
67
+ ```
68
+
69
+ ## Dependencies
70
+
71
+ ```
72
+ torch>=2.9
73
+ tiktoken
74
+ rustbpe
75
+ ```
meta_003000.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "step": 3000,
3
+ "val_bpb": 0.9569599338186712,
4
+ "model_config": {
5
+ "sequence_len": 2048,
6
+ "vocab_size": 32768,
7
+ "n_layer": 34,
8
+ "n_head": 17,
9
+ "n_kv_head": 17,
10
+ "n_embd": 2176,
11
+ "window_pattern": "L"
12
+ },
13
+ "user_config": {
14
+ "run": "pre1905_d34",
15
+ "device_type": "",
16
+ "fp8": true,
17
+ "fp8_recipe": "tensorwise",
18
+ "activation_checkpointing": false,
19
+ "depth": 34,
20
+ "aspect_ratio": 64,
21
+ "head_dim": 128,
22
+ "max_seq_len": 2048,
23
+ "window_pattern": "L",
24
+ "num_iterations": -1,
25
+ "target_flops": -1.0,
26
+ "target_param_data_ratio": 20.0,
27
+ "device_batch_size": 4,
28
+ "total_batch_size": -1,
29
+ "embedding_lr": 0.3,
30
+ "unembedding_lr": 0.004,
31
+ "weight_decay": 0.2,
32
+ "matrix_lr": 0.02,
33
+ "scalar_lr": 0.5,
34
+ "adam_beta1": 0.8,
35
+ "adam_beta2": 0.95,
36
+ "warmup_ratio": 0.0,
37
+ "warmdown_ratio": 0.5,
38
+ "final_lr_frac": 0.0,
39
+ "resume_from_step": -1,
40
+ "eval_every": 250,
41
+ "eval_tokens": 20971520,
42
+ "core_metric_every": 2000,
43
+ "core_metric_max_per_task": 500,
44
+ "sample_every": 2000,
45
+ "save_every": 3000,
46
+ "model_tag": null
47
+ },
48
+ "device_batch_size": 4,
49
+ "max_seq_len": 2048,
50
+ "dataloader_state_dict": {
51
+ "pq_idx": 95,
52
+ "rg_idx": 48,
53
+ "epoch": 1
54
+ },
55
+ "loop_state": {
56
+ "min_val_bpb": 0.9569599338186712,
57
+ "smooth_train_loss": 2.612821289491694,
58
+ "total_training_time": 24056.003203868866
59
+ }
60
+ }
model_003000.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f3753ed9c5f7d1b453375fee1ec65d5b57fadc0e517f349fa1fa0239a87fcf93
3
+ size 10579743323
nanochat/__init__.py ADDED
File without changes
nanochat/checkpoint_manager.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
nanochat/common.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Common utilities for nanochat.
3
+ """
4
+
5
+ import datetime
6
+ import os
7
+ import re
8
+ import logging
9
+ import urllib.request
10
+ import torch
11
+ import torch.distributed as dist
12
+ from filelock import FileLock
13
+
14
+ class ColoredFormatter(logging.Formatter):
15
+ """Custom formatter that adds colors to log messages."""
16
+ # ANSI color codes
17
+ COLORS = {
18
+ 'DEBUG': '\033[36m', # Cyan
19
+ 'INFO': '\033[32m', # Green
20
+ 'WARNING': '\033[33m', # Yellow
21
+ 'ERROR': '\033[31m', # Red
22
+ 'CRITICAL': '\033[35m', # Magenta
23
+ }
24
+ RESET = '\033[0m'
25
+ BOLD = '\033[1m'
26
+ def format(self, record):
27
+ # Add color to the level name
28
+ levelname = record.levelname
29
+ if levelname in self.COLORS:
30
+ record.levelname = f"{self.COLORS[levelname]}{self.BOLD}{levelname}{self.RESET}"
31
+ # Format the message
32
+ message = super().format(record)
33
+ # Add color to specific parts of the message
34
+ if levelname == 'INFO':
35
+ # Highlight numbers and percentages
36
+ message = re.sub(r'(\d+\.?\d*\s*(?:GB|MB|%|docs))', rf'{self.BOLD}\1{self.RESET}', message)
37
+ message = re.sub(r'(Shard \d+)', rf'{self.COLORS["INFO"]}{self.BOLD}\1{self.RESET}', message)
38
+ return message
39
+
40
+ def setup_default_logging():
41
+ handler = logging.StreamHandler()
42
+ handler.setFormatter(ColoredFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ handlers=[handler]
46
+ )
47
+
48
+ setup_default_logging()
49
+ logger = logging.getLogger(__name__)
50
+
51
+ def get_base_dir():
52
+ # co-locate nanochat intermediates with other cached data in ~/.cache (by default)
53
+ if os.environ.get("NANOCHAT_BASE_DIR"):
54
+ nanochat_dir = os.environ.get("NANOCHAT_BASE_DIR")
55
+ else:
56
+ home_dir = os.path.expanduser("~")
57
+ cache_dir = os.path.join(home_dir, ".cache")
58
+ nanochat_dir = os.path.join(cache_dir, "nanochat")
59
+ os.makedirs(nanochat_dir, exist_ok=True)
60
+ return nanochat_dir
61
+
62
+ def download_file_with_lock(url, filename, postprocess_fn=None):
63
+ """
64
+ Downloads a file from a URL to a local path in the base directory.
65
+ Uses a lock file to prevent concurrent downloads among multiple ranks.
66
+ """
67
+ base_dir = get_base_dir()
68
+ file_path = os.path.join(base_dir, filename)
69
+ lock_path = file_path + ".lock"
70
+
71
+ if os.path.exists(file_path):
72
+ return file_path
73
+
74
+ with FileLock(lock_path):
75
+ # Only a single rank can acquire this lock
76
+ # All other ranks block until it is released
77
+
78
+ # Recheck after acquiring lock
79
+ if os.path.exists(file_path):
80
+ return file_path
81
+
82
+ # Download the content as bytes
83
+ print(f"Downloading {url}...")
84
+ with urllib.request.urlopen(url) as response:
85
+ content = response.read() # bytes
86
+
87
+ # Write to local file
88
+ with open(file_path, 'wb') as f:
89
+ f.write(content)
90
+ print(f"Downloaded to {file_path}")
91
+
92
+ # Run the postprocess function if provided
93
+ if postprocess_fn is not None:
94
+ postprocess_fn(file_path)
95
+
96
+ return file_path
97
+
98
+ def print0(s="",**kwargs):
99
+ ddp_rank = int(os.environ.get('RANK', 0))
100
+ if ddp_rank == 0:
101
+ print(s, **kwargs)
102
+
103
+ def print_banner():
104
+ # Cool DOS Rebel font ASCII banner made with https://manytools.org/hacker-tools/ascii-banner/
105
+ banner = """
106
+ █████ █████
107
+ ░░███ ░░███
108
+ ████████ ██████ ████████ ██████ ██████ ░███████ ██████ ███████
109
+ ░░███░░███ ░░░░░███ ░░███░░███ ███░░███ ███░░███ ░███░░███ ░░░░░███░░░███░
110
+ ░███ ░███ ███████ ░███ ░███ ░███ ░███░███ ░░░ ░███ ░███ ███████ ░███
111
+ ░███ ░███ ███░░███ ░███ ░███ ░███ ░███░███ ███ ░███ ░███ ███░░███ ░███ ███
112
+ ████ █████░░████████ ████ █████░░██████ ░░██████ ████ █████░░███████ ░░█████
113
+ ░░░░ ░░░░░ ░░░░░░░░ ░░░░ ░░░░░ ░░░░░░ ░░░░░░ ░░░░ ░░░░░ ░░░░░░░░ ░░░░░
114
+ """
115
+ print0(banner)
116
+
117
+ def is_ddp_requested() -> bool:
118
+ """
119
+ True if launched by torchrun (env present), even before init.
120
+ Used to decide whether we *should* initialize a PG.
121
+ """
122
+ return all(k in os.environ for k in ("RANK", "LOCAL_RANK", "WORLD_SIZE"))
123
+
124
+ def is_ddp_initialized() -> bool:
125
+ """
126
+ True if torch.distributed is available and the process group is initialized.
127
+ Used at cleanup to avoid destroying a non-existent PG.
128
+ """
129
+ return dist.is_available() and dist.is_initialized()
130
+
131
+ def get_dist_info():
132
+ if is_ddp_requested():
133
+ # We rely on torchrun's env to decide if we SHOULD init.
134
+ # (Initialization itself happens in compute init.)
135
+ assert all(var in os.environ for var in ['RANK', 'LOCAL_RANK', 'WORLD_SIZE'])
136
+ ddp_rank = int(os.environ['RANK'])
137
+ ddp_local_rank = int(os.environ['LOCAL_RANK'])
138
+ ddp_world_size = int(os.environ['WORLD_SIZE'])
139
+ return True, ddp_rank, ddp_local_rank, ddp_world_size
140
+ else:
141
+ return False, 0, 0, 1
142
+
143
+ def autodetect_device_type():
144
+ # prefer to use CUDA if available, otherwise use MPS, otherwise fallback on CPU
145
+ if torch.cuda.is_available():
146
+ device_type = "cuda"
147
+ elif torch.backends.mps.is_available():
148
+ device_type = "mps"
149
+ else:
150
+ device_type = "cpu"
151
+ print0(f"Autodetected device type: {device_type}")
152
+ return device_type
153
+
154
+ def compute_init(device_type="cuda"): # cuda|cpu|mps
155
+ """Basic initialization that we keep doing over and over, so make common."""
156
+
157
+ assert device_type in ["cuda", "mps", "cpu"], "Invalid device type atm"
158
+ if device_type == "cuda":
159
+ assert torch.cuda.is_available(), "Your PyTorch installation is not configured for CUDA but device_type is 'cuda'"
160
+ if device_type == "mps":
161
+ assert torch.backends.mps.is_available(), "Your PyTorch installation is not configured for MPS but device_type is 'mps'"
162
+
163
+ # Reproducibility
164
+ # Note that we set the global seeds here, but most of the code uses explicit rng objects.
165
+ # The only place where global rng might be used is nn.Module initialization of the model weights.
166
+ torch.manual_seed(42)
167
+ if device_type == "cuda":
168
+ torch.cuda.manual_seed(42)
169
+ # skipping full reproducibility for now, possibly investigate slowdown later
170
+ # torch.use_deterministic_algorithms(True)
171
+
172
+ # Precision
173
+ if device_type == "cuda":
174
+ torch.backends.fp32_precision = "tf32" # uses tf32 instead of fp32 for matmuls
175
+
176
+ # Distributed setup: Distributed Data Parallel (DDP), optional, and requires CUDA
177
+ is_ddp_requested, ddp_rank, ddp_local_rank, ddp_world_size = get_dist_info()
178
+ if is_ddp_requested and device_type == "cuda":
179
+ device = torch.device("cuda", ddp_local_rank)
180
+ torch.cuda.set_device(device) # make "cuda" default to this device
181
+ dist.init_process_group(backend="nccl", device_id=device, timeout=datetime.timedelta(minutes=30))
182
+ dist.barrier()
183
+ else:
184
+ device = torch.device(device_type) # mps|cpu
185
+
186
+ if ddp_rank == 0:
187
+ logger.info(f"Distributed world size: {ddp_world_size}")
188
+
189
+ return is_ddp_requested, ddp_rank, ddp_local_rank, ddp_world_size, device
190
+
191
+ def compute_cleanup():
192
+ """Companion function to compute_init, to clean things up before script exit"""
193
+ if is_ddp_initialized():
194
+ dist.destroy_process_group()
195
+
196
+ class DummyWandb:
197
+ """Useful if we wish to not use wandb but have all the same signatures"""
198
+ def __init__(self):
199
+ pass
200
+ def log(self, *args, **kwargs):
201
+ pass
202
+ def finish(self):
203
+ pass
204
+
205
+ # hardcoded BF16 peak flops for various GPUs
206
+ # inspired by torchtitan: https://github.com/pytorch/torchtitan/blob/main/torchtitan/tools/utils.py
207
+ # and PR: https://github.com/karpathy/nanochat/pull/147
208
+ def get_peak_flops(device_name: str) -> float:
209
+ name = device_name.lower()
210
+
211
+ # Table order matters: more specific patterns first.
212
+ _PEAK_FLOPS_TABLE = (
213
+ # NVIDIA Blackwell
214
+ (["gb200"], 2.5e15),
215
+ (["grace blackwell"], 2.5e15),
216
+ (["b200"], 2.25e15),
217
+ (["b100"], 1.8e15),
218
+ # NVIDIA Hopper
219
+ (["h200", "nvl"], 836e12),
220
+ (["h200", "pcie"], 836e12),
221
+ (["h200"], 989e12),
222
+ (["h100", "nvl"], 835e12),
223
+ (["h100", "pcie"], 756e12),
224
+ (["h100"], 989e12),
225
+ (["h800", "nvl"], 989e12),
226
+ (["h800"], 756e12),
227
+ # NVIDIA Ampere data center
228
+ (["a100"], 312e12),
229
+ (["a800"], 312e12),
230
+ (["a40"], 149.7e12),
231
+ (["a30"], 165e12),
232
+ # NVIDIA Ada data center
233
+ (["l40s"], 362e12),
234
+ (["l40-s"], 362e12),
235
+ (["l40 s"], 362e12),
236
+ (["l4"], 121e12),
237
+ # AMD CDNA accelerators
238
+ (["mi355"], 2.5e15),
239
+ (["mi325"], 1.3074e15),
240
+ (["mi300x"], 1.3074e15),
241
+ (["mi300a"], 980.6e12),
242
+ (["mi250x"], 383e12),
243
+ (["mi250"], 362.1e12),
244
+ # Consumer RTX
245
+ (["5090"], 209.5e12),
246
+ (["4090"], 165.2e12),
247
+ (["3090"], 71e12),
248
+ )
249
+ for patterns, flops in _PEAK_FLOPS_TABLE:
250
+ if all(p in name for p in patterns):
251
+ return flops
252
+ if "data center gpu max 1550" in name:
253
+ # Ponte Vecchio (PVC) - dynamic based on compute units
254
+ max_comp_units = torch.xpu.get_device_properties("xpu").max_compute_units
255
+ return 512 * max_comp_units * 1300 * 10**6
256
+
257
+ # Unknown GPU - return inf so MFU shows as 0% rather than a wrong guess
258
+ logger.warning(f"Peak flops undefined for: {device_name}, MFU will show as 0%")
259
+ return float('inf')
nanochat/engine.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from contextlib import nullcontext
23
+
24
+ # -----------------------------------------------------------------------------
25
+ # Calculator tool helpers
26
+ @contextmanager
27
+ def timeout(duration, formula):
28
+ def timeout_handler(signum, frame):
29
+ raise Exception(f"'{formula}': timed out after {duration} seconds")
30
+
31
+ signal.signal(signal.SIGALRM, timeout_handler)
32
+ signal.alarm(duration)
33
+ yield
34
+ signal.alarm(0)
35
+
36
+ def eval_with_timeout(formula, max_time=3):
37
+ try:
38
+ with timeout(max_time, formula):
39
+ with warnings.catch_warnings():
40
+ warnings.simplefilter("ignore", SyntaxWarning)
41
+ return eval(formula, {"__builtins__": {}}, {})
42
+ except Exception as e:
43
+ signal.alarm(0)
44
+ # print(f"Warning: Failed to eval {formula}, exception: {e}") # it's ok ignore wrong calculator usage
45
+ return None
46
+
47
+ def use_calculator(expr):
48
+ """
49
+ Evaluate a Python expression safely.
50
+ Supports both math expressions and string operations like .count()
51
+ """
52
+ # Remove commas from numbers
53
+ expr = expr.replace(",", "")
54
+
55
+ # Check if it's a pure math expression (old behavior)
56
+ if all([x in "0123456789*+-/.() " for x in expr]):
57
+ if "**" in expr: # disallow power operator
58
+ return None
59
+ return eval_with_timeout(expr)
60
+
61
+ # Check if it's a string operation we support
62
+ # Allow: strings (single/double quotes), .count(), letters, numbers, spaces, parens
63
+ allowed_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\"()._ "
64
+ if not all([x in allowed_chars for x in expr]):
65
+ return None
66
+
67
+ # Disallow dangerous patterns
68
+ dangerous_patterns = ['__', 'import', 'exec', 'eval', 'compile', 'open', 'file',
69
+ 'input', 'raw_input', 'globals', 'locals', 'vars', 'dir',
70
+ 'getattr', 'setattr', 'delattr', 'hasattr']
71
+ expr_lower = expr.lower()
72
+ if any(pattern in expr_lower for pattern in dangerous_patterns):
73
+ return None
74
+
75
+ # Only allow .count() method for now (can expand later)
76
+ if '.count(' not in expr:
77
+ return None
78
+
79
+ # Evaluate with timeout
80
+ return eval_with_timeout(expr)
81
+
82
+ # -----------------------------------------------------------------------------
83
+ class KVCache:
84
+ """
85
+ KV Cache designed for Flash Attention 3's flash_attn_with_kvcache API.
86
+
87
+ Key differences from FA2-style cache:
88
+ - Tensors are (B, T, H, D) not (B, H, T, D)
89
+ - FA3 updates the cache in-place during flash_attn_with_kvcache
90
+ - Position tracked per batch element via cache_seqlens tensor
91
+ """
92
+
93
+ def __init__(self, batch_size, num_heads, seq_len, head_dim, num_layers, device, dtype):
94
+ self.batch_size = batch_size
95
+ self.max_seq_len = seq_len
96
+ self.n_layers = num_layers
97
+ self.n_heads = num_heads
98
+ self.head_dim = head_dim
99
+ # Pre-allocate cache tensors: (n_layers, B, T, H, D)
100
+ self.k_cache = torch.zeros(num_layers, batch_size, seq_len, num_heads, head_dim, device=device, dtype=dtype)
101
+ self.v_cache = torch.zeros(num_layers, batch_size, seq_len, num_heads, head_dim, device=device, dtype=dtype)
102
+ # Current sequence length per batch element (FA3 needs int32)
103
+ self.cache_seqlens = torch.zeros(batch_size, dtype=torch.int32, device=device)
104
+
105
+ def reset(self):
106
+ """Reset cache to empty state."""
107
+ self.cache_seqlens.zero_()
108
+
109
+ def get_pos(self):
110
+ """Get current position (assumes all batch elements at same position)."""
111
+ return self.cache_seqlens[0].item()
112
+
113
+ def get_layer_cache(self, layer_idx):
114
+ """Return (k_cache, v_cache) views for a specific layer."""
115
+ return self.k_cache[layer_idx], self.v_cache[layer_idx]
116
+
117
+ def advance(self, num_tokens):
118
+ """Advance the cache position by num_tokens."""
119
+ self.cache_seqlens += num_tokens
120
+
121
+ def prefill(self, other):
122
+ """
123
+ Copy cached KV from another cache into this one.
124
+ Used when we do batch=1 prefill and then want to generate multiple samples in parallel.
125
+ """
126
+ assert self.get_pos() == 0, "Cannot prefill a non-empty KV cache"
127
+ assert self.n_layers == other.n_layers and self.n_heads == other.n_heads and self.head_dim == other.head_dim
128
+ assert self.max_seq_len >= other.max_seq_len
129
+ other_pos = other.get_pos()
130
+ self.k_cache[:, :, :other_pos, :, :] = other.k_cache[:, :, :other_pos, :, :]
131
+ self.v_cache[:, :, :other_pos, :, :] = other.v_cache[:, :, :other_pos, :, :]
132
+ self.cache_seqlens.fill_(other_pos)
133
+
134
+ # -----------------------------------------------------------------------------
135
+ @torch.inference_mode()
136
+ def sample_next_token(logits, rng, temperature=1.0, top_k=None):
137
+ """Sample a single next token from given logits of shape (B, vocab_size). Returns (B, 1)."""
138
+ assert temperature >= 0.0, "temperature must be non-negative"
139
+ if temperature == 0.0:
140
+ return torch.argmax(logits, dim=-1, keepdim=True)
141
+ if top_k is not None and top_k > 0:
142
+ k = min(top_k, logits.size(-1))
143
+ vals, idx = torch.topk(logits, k, dim=-1)
144
+ vals = vals / temperature
145
+ probs = F.softmax(vals, dim=-1)
146
+ choice = torch.multinomial(probs, num_samples=1, generator=rng)
147
+ return idx.gather(1, choice)
148
+ else:
149
+ logits = logits / temperature
150
+ probs = F.softmax(logits, dim=-1)
151
+ return torch.multinomial(probs, num_samples=1, generator=rng)
152
+
153
+ # -----------------------------------------------------------------------------
154
+
155
+ class RowState:
156
+ # Per-row state tracking during generation
157
+ def __init__(self, current_tokens=None):
158
+ self.current_tokens = current_tokens or [] # Current token sequence for this row
159
+ self.forced_tokens = deque() # Queue of tokens to force inject
160
+ self.in_python_block = False # Whether we are inside a python block
161
+ self.python_expr_tokens = [] # Tokens of the current python expression
162
+ self.completed = False # Whether this row has completed generation
163
+
164
+ class Engine:
165
+
166
+ def __init__(self, model, tokenizer):
167
+ self.model = model
168
+ self.tokenizer = tokenizer # needed for tool use
169
+
170
+ @torch.inference_mode()
171
+ def generate(self, tokens, num_samples=1, max_tokens=None, temperature=1.0, top_k=None, seed=42):
172
+ """Same as generate, but does single prefill and then clones the KV cache."""
173
+ assert isinstance(tokens, list) and isinstance(tokens[0], int), "expecting list of ints"
174
+ device = self.model.get_device()
175
+ # NOTE: setting the dtype here and in this way is an ugly hack.
176
+ # Currently the repo assumes that cuda -> bfloat16 and everything else -> float32.
177
+ # We need to know the dtype here to call __init__ on KVCache and pre-allocate its tensors.
178
+ # As a quick hack, we're making generate() function inherit and know about this repo-wise assumption.
179
+ # I think there has to be a bigger refactor to deal with device/dtype tracking across the codebase.
180
+ # In particular, the KVCache should allocate its tensors lazily
181
+ dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
182
+ rng = torch.Generator(device=device)
183
+ rng.manual_seed(seed)
184
+
185
+ # Get the special tokens we need to coordinate the tool use state machine
186
+ get_special = lambda s: self.tokenizer.encode_special(s)
187
+ python_start = get_special("<|python_start|>")
188
+ python_end = get_special("<|python_end|>")
189
+ output_start = get_special("<|output_start|>")
190
+ output_end = get_special("<|output_end|>")
191
+ assistant_end = get_special("<|assistant_end|>") # if sampled, ends row
192
+ bos = self.tokenizer.get_bos_token_id() # if sampled, ends row
193
+
194
+ # 1) Run a batch 1 prefill of the prompt tokens
195
+ m = self.model.config
196
+ kv_model_kwargs = {"num_heads": m.n_kv_head, "head_dim": m.n_embd // m.n_head, "num_layers": m.n_layer}
197
+ kv_cache_prefill = KVCache(
198
+ batch_size=1,
199
+ seq_len=len(tokens),
200
+ device=device,
201
+ dtype=dtype,
202
+ **kv_model_kwargs,
203
+ )
204
+ ids = torch.tensor([tokens], dtype=torch.long, device=device)
205
+ logits = self.model.forward(ids, kv_cache=kv_cache_prefill)
206
+ logits = logits[:, -1, :].expand(num_samples, -1) # (num_samples, vocab_size)
207
+
208
+ # 2) Replicate the KV cache for each sample/row
209
+ kv_length_hint = (len(tokens) + max_tokens) if max_tokens is not None else self.model.config.sequence_len
210
+ kv_cache_decode = KVCache(
211
+ batch_size=num_samples,
212
+ seq_len=kv_length_hint,
213
+ device=device,
214
+ dtype=dtype,
215
+ **kv_model_kwargs,
216
+ )
217
+ kv_cache_decode.prefill(kv_cache_prefill)
218
+ del kv_cache_prefill # no need to keep this memory around
219
+
220
+ # 3) Initialize states for each sample
221
+ row_states = [RowState(tokens.copy()) for _ in range(num_samples)]
222
+
223
+ # 4) Main generation loop
224
+ num_generated = 0
225
+ while True:
226
+ # Stop condition: we've reached max tokens
227
+ if max_tokens is not None and num_generated >= max_tokens:
228
+ break
229
+ # Stop condition: all rows are completed
230
+ if all(state.completed for state in row_states):
231
+ break
232
+
233
+ # Sample the next token for each row
234
+ next_ids = sample_next_token(logits, rng, temperature, top_k) # (B, 1)
235
+ sampled_tokens = next_ids[:, 0].tolist()
236
+
237
+ # Process each row: choose the next token, update state, optional tool use
238
+ token_column = [] # contains the next token id along each row
239
+ token_masks = [] # contains the mask (was it sampled (1) or forced (0)?) along each row
240
+ for i, state in enumerate(row_states):
241
+ # Select the next token in this row
242
+ is_forced = len(state.forced_tokens) > 0 # are there tokens waiting to be forced in deque?
243
+ token_masks.append(0 if is_forced else 1) # mask is 0 if forced, 1 if sampled
244
+ next_token = state.forced_tokens.popleft() if is_forced else sampled_tokens[i]
245
+ token_column.append(next_token)
246
+ # Update the state of this row to include the next token
247
+ state.current_tokens.append(next_token)
248
+ # On <|assistant_end|> or <|bos|>, mark the row as completed
249
+ if next_token == assistant_end or next_token == bos:
250
+ state.completed = True
251
+ # Handle tool logic
252
+ if next_token == python_start:
253
+ state.in_python_block = True
254
+ state.python_expr_tokens = []
255
+ elif next_token == python_end and state.in_python_block:
256
+ state.in_python_block = False
257
+ if state.python_expr_tokens:
258
+ expr = self.tokenizer.decode(state.python_expr_tokens)
259
+ result = use_calculator(expr)
260
+ if result is not None:
261
+ result_tokens = self.tokenizer.encode(str(result))
262
+ state.forced_tokens.append(output_start)
263
+ state.forced_tokens.extend(result_tokens)
264
+ state.forced_tokens.append(output_end)
265
+ state.python_expr_tokens = []
266
+ elif state.in_python_block:
267
+ state.python_expr_tokens.append(next_token)
268
+
269
+ # Yield the token column
270
+ yield token_column, token_masks
271
+ num_generated += 1
272
+
273
+ # Prepare logits for next iteration
274
+ ids = torch.tensor(token_column, dtype=torch.long, device=device).unsqueeze(1)
275
+ logits = self.model.forward(ids, kv_cache=kv_cache_decode)[:, -1, :] # (B, vocab_size)
276
+
277
+ def generate_batch(self, tokens, num_samples=1, **kwargs):
278
+ """
279
+ Non-streaming batch generation that just returns the final token sequences.
280
+ Returns a list of token sequences (list of lists of ints).
281
+ Terminal tokens (assistant_end, bos) are not included in the results.
282
+ """
283
+ assistant_end = self.tokenizer.encode_special("<|assistant_end|>")
284
+ bos = self.tokenizer.get_bos_token_id()
285
+ results = [tokens.copy() for _ in range(num_samples)]
286
+ masks = [[0] * len(tokens) for _ in range(num_samples)]
287
+ completed = [False] * num_samples
288
+ for token_column, token_masks in self.generate(tokens, num_samples, **kwargs):
289
+ for i, (token, mask) in enumerate(zip(token_column, token_masks)):
290
+ if not completed[i]:
291
+ if token == assistant_end or token == bos:
292
+ completed[i] = True
293
+ else:
294
+ results[i].append(token)
295
+ masks[i].append(mask)
296
+ # Stop if all rows are completed
297
+ if all(completed):
298
+ break
299
+ return results, masks
300
+
301
+
302
+ if __name__ == "__main__":
303
+ """
304
+ Quick inline test to make sure that the naive/slow model.generate function
305
+ is equivalent to the faster Engine.generate function here.
306
+ """
307
+ import time
308
+ # init compute
309
+ device_type = autodetect_device_type()
310
+ ddp, ddp_rank, ddp_local_rank, ddp_world_size, device = compute_init(device_type)
311
+ autocast_ctx = torch.amp.autocast(device_type=device_type, dtype=torch.bfloat16) if device_type == "cuda" else nullcontext()
312
+
313
+ # load the model and tokenizer
314
+ model, tokenizer, meta = load_model("base", device, phase="eval")
315
+ bos_token_id = tokenizer.get_bos_token_id()
316
+ # common hyperparameters
317
+ kwargs = dict(max_tokens=64, temperature=0.0)
318
+ # set the starting prompt
319
+ prompt_tokens = tokenizer.encode("The chemical formula of water is", prepend=bos_token_id)
320
+ # generate the reference sequence using the model.generate() function
321
+ generated_tokens = []
322
+ torch.cuda.synchronize()
323
+ t0 = time.time()
324
+ stream = model.generate(prompt_tokens, **kwargs)
325
+ with autocast_ctx:
326
+ for token in stream:
327
+ generated_tokens.append(token)
328
+ chunk = tokenizer.decode([token])
329
+ print(chunk, end="", flush=True)
330
+ print()
331
+ torch.cuda.synchronize()
332
+ t1 = time.time()
333
+ print(f"Reference time: {t1 - t0:.2f}s")
334
+ reference_ids = generated_tokens
335
+ # generate tokens with Engine
336
+ generated_tokens = []
337
+ engine = Engine(model, tokenizer)
338
+ stream = engine.generate(prompt_tokens, num_samples=1, **kwargs) # note: runs in fp32
339
+ torch.cuda.synchronize()
340
+ t0 = time.time()
341
+ with autocast_ctx:
342
+ for token_column, token_masks in stream:
343
+ token = token_column[0] # only print out the first row
344
+ generated_tokens.append(token)
345
+ chunk = tokenizer.decode([token])
346
+ print(chunk, end="", flush=True)
347
+ print()
348
+ torch.cuda.synchronize()
349
+ t1 = time.time()
350
+ print(f"Engine time: {t1 - t0:.2f}s")
351
+ # compare the two sequences
352
+ for i in range(len(reference_ids)):
353
+ if reference_ids[i] != generated_tokens[i]:
354
+ print(f"Mismatch at {i}: {reference_ids[i]} != {generated_tokens[i]}")
355
+ break
356
+ print(f"Match: {reference_ids == generated_tokens}")
nanochat/flash_attention.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _use_fa3():
49
+ """Determine whether to use FA3 based on availability and override."""
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
+ return HAS_FA3 # auto
56
+
57
+
58
+ # =============================================================================
59
+ # SDPA helpers
60
+ # =============================================================================
61
+ def _sdpa_attention(q, k, v, window_size, enable_gqa):
62
+ """
63
+ SDPA attention with sliding window support.
64
+ q, k, v are (B, H, T, D) format.
65
+ """
66
+ Tq = q.size(2)
67
+ Tk = k.size(2)
68
+ window = window_size[0]
69
+
70
+ # Full context, same length
71
+ if (window < 0 or window >= Tq) and Tq == Tk:
72
+ return F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=enable_gqa)
73
+
74
+ # Single token generation
75
+ if Tq == 1:
76
+ if window >= 0 and window < Tk:
77
+ # window is "left" tokens we need to include (window + 1) keys total
78
+ start = max(0, Tk - (window + 1))
79
+ k = k[:, :, start:, :]
80
+ v = v[:, :, start:, :]
81
+ return F.scaled_dot_product_attention(q, k, v, is_causal=False, enable_gqa=enable_gqa)
82
+
83
+ # Need explicit mask for sliding window/chunk inference
84
+ device = q.device
85
+ # For chunk inference (Tq != Tk), is_causal is not aligned to cache position => build an explicit bool mask
86
+ row_idx = (Tk - Tq) + torch.arange(Tq, device=device).unsqueeze(1)
87
+ col_idx = torch.arange(Tk, device=device).unsqueeze(0)
88
+ mask = col_idx <= row_idx
89
+
90
+ # sliding window (left)
91
+ if window >= 0 and window < Tk:
92
+ mask = mask & ((row_idx - col_idx) <= window)
93
+
94
+ return F.scaled_dot_product_attention(q, k, v, attn_mask=mask, enable_gqa=enable_gqa)
95
+
96
+ # =============================================================================
97
+ # Public API: Same interface as FA3
98
+ # =============================================================================
99
+ def flash_attn_func(q, k, v, causal=False, window_size=(-1, -1)):
100
+ """
101
+ Flash Attention for training (no KV cache).
102
+
103
+ Args:
104
+ q, k, v: Tensors of shape (B, T, H, D)
105
+ causal: Whether to use causal masking
106
+ window_size: (left, right) sliding window. -1 means unlimited.
107
+
108
+ Returns:
109
+ Output tensor of shape (B, T, H, D)
110
+ """
111
+ if _use_fa3():
112
+ return _fa3.flash_attn_func(q, k, v, causal=causal, window_size=window_size)
113
+
114
+ # SDPA fallback: transpose (B, T, H, D) -> (B, H, T, D)
115
+ q = q.transpose(1, 2)
116
+ k = k.transpose(1, 2)
117
+ v = v.transpose(1, 2)
118
+ enable_gqa = q.size(1) != k.size(1)
119
+ y = _sdpa_attention(q, k, v, window_size, enable_gqa)
120
+ return y.transpose(1, 2) # back to (B, T, H, D)
121
+
122
+
123
+ def flash_attn_with_kvcache(q, k_cache, v_cache, k=None, v=None, cache_seqlens=None,
124
+ causal=False, window_size=(-1, -1)):
125
+ """
126
+ Flash Attention with KV cache for inference.
127
+
128
+ FA3 updates k_cache/v_cache in-place. Our SDPA fallback does the same.
129
+
130
+ Args:
131
+ q: Queries, shape (B, T_new, H, D)
132
+ k_cache, v_cache: Pre-allocated cache tensors, shape (B, T_max, H_kv, D)
133
+ k, v: New keys/values to insert, shape (B, T_new, H_kv, D)
134
+ cache_seqlens: Current position in cache, shape (B,) int32
135
+ causal: Whether to use causal masking
136
+ window_size: (left, right) sliding window. -1 means unlimited.
137
+
138
+ Returns:
139
+ Output tensor of shape (B, T_new, H, D)
140
+ """
141
+ if _use_fa3():
142
+ return _fa3.flash_attn_with_kvcache(
143
+ q, k_cache, v_cache, k=k, v=v, cache_seqlens=cache_seqlens,
144
+ causal=causal, window_size=window_size
145
+ )
146
+
147
+ # SDPA fallback: manually manage KV cache
148
+ B, T_new, H, D = q.shape
149
+ pos = cache_seqlens[0].item() # assume uniform position across batch
150
+
151
+ # Insert new k, v into cache (in-place, matching FA3 behavior)
152
+ if k is not None and v is not None:
153
+ k_cache[:, pos:pos+T_new, :, :] = k
154
+ v_cache[:, pos:pos+T_new, :, :] = v
155
+
156
+ # Get full cache up to current position + new tokens
157
+ end_pos = pos + T_new
158
+ k_full = k_cache[:, :end_pos, :, :]
159
+ v_full = v_cache[:, :end_pos, :, :]
160
+
161
+ # Transpose to SDPA layout: (B, T, H, D) -> (B, H, T, D)
162
+ q_sdpa = q.transpose(1, 2)
163
+ k_sdpa = k_full.transpose(1, 2)
164
+ v_sdpa = v_full.transpose(1, 2)
165
+
166
+ enable_gqa = q_sdpa.size(1) != k_sdpa.size(1)
167
+ y_sdpa = _sdpa_attention(q_sdpa, k_sdpa, v_sdpa, window_size, enable_gqa)
168
+
169
+ return y_sdpa.transpose(1, 2) # back to (B, T, H, D)
170
+
171
+
172
+ # =============================================================================
173
+ # Export: flash_attn module interface (drop-in replacement for FA3)
174
+ # =============================================================================
175
+ from types import SimpleNamespace
176
+ flash_attn = SimpleNamespace(
177
+ flash_attn_func=flash_attn_func,
178
+ flash_attn_with_kvcache=flash_attn_with_kvcache,
179
+ )
nanochat/fp8.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal FP8 training for nanochat — tensorwise dynamic scaling only.
2
+
3
+ Drop-in replacement for torchao's Float8Linear (~2000 lines) with ~150 lines.
4
+ We only need the "tensorwise" recipe (one scalar scale per tensor), not the full
5
+ generality of torchao (rowwise scaling, FSDP float8 all-gather, DTensor, tensor
6
+ subclass dispatch tables, etc.)
7
+
8
+ How FP8 training works
9
+ ======================
10
+ A standard Linear layer does one matmul in forward and two in backward:
11
+ forward: output = input @ weight.T
12
+ backward: grad_input = grad_output @ weight
13
+ grad_weight= grad_output.T @ input
14
+
15
+ FP8 training wraps each of these three matmuls with:
16
+ 1. Compute scale = FP8_MAX / max(|tensor|) for each operand
17
+ 2. Quantize: fp8_tensor = clamp(tensor * scale, -FP8_MAX, FP8_MAX).to(fp8)
18
+ 3. Matmul via torch._scaled_mm (cuBLAS FP8 kernel, ~2x faster than bf16)
19
+ 4. Dequantize: _scaled_mm handles this internally using the inverse scales
20
+
21
+ The key insight: torch._scaled_mm and the float8 dtypes are PyTorch built-ins.
22
+ torchao is just orchestration around these primitives. We can call them directly.
23
+
24
+ FP8 dtype choice
25
+ ================
26
+ There are two FP8 formats. We use both, following the standard convention:
27
+ - float8_e4m3fn: 4-bit exponent, 3-bit mantissa, range [-448, 448]
28
+ Higher precision (more mantissa bits), used for input and weight.
29
+ - float8_e5m2: 5-bit exponent, 2-bit mantissa, range [-57344, 57344]
30
+ Wider range (more exponent bits), used for gradients which can be large.
31
+
32
+ torch._scaled_mm layout requirements
33
+ =====================================
34
+ The cuBLAS FP8 kernel requires specific memory layouts:
35
+ - First argument (A): must be row-major (contiguous)
36
+ - Second argument (B): must be column-major (B.t().contiguous().t())
37
+ If B is obtained by transposing a contiguous tensor (e.g. weight.t()), it is
38
+ already column-major — no copy needed. Otherwise we use _to_col_major().
39
+
40
+ How this differs from torchao's approach
41
+ ========================================
42
+ torchao uses a "tensor subclass" architecture: Float8TrainingTensor is a subclass
43
+ of torch.Tensor that bundles FP8 data + scale + metadata. It implements
44
+ __torch_dispatch__ with a dispatch table that intercepts every aten op (mm, t,
45
+ reshape, clone, ...) and handles it in FP8-aware fashion. When you call
46
+ output = input @ weight.T
47
+ the @ operator dispatches to aten.mm, which gets intercepted and routed to
48
+ torch._scaled_mm behind the scenes. This is ~2000 lines of code because you need
49
+ a handler for every tensor operation that might touch an FP8 tensor.
50
+
51
+ We take a simpler approach: a single autograd.Function (_Float8Matmul) that takes
52
+ full-precision inputs, quantizes to FP8 internally, calls _scaled_mm, and returns
53
+ full-precision outputs. Marked @allow_in_graph so torch.compile treats it as one
54
+ opaque node rather than trying to trace inside.
55
+
56
+ The trade-off is in how torch.compile sees the two approaches:
57
+ - torchao: compile decomposes the tensor subclass (via __tensor_flatten__) and
58
+ sees every individual op (amax, scale, cast, _scaled_mm) as separate graph
59
+ nodes. Inductor can fuse these with surrounding operations (e.g. fuse the
60
+ amax computation with the preceding layer's activation function).
61
+ - ours: compile sees a single opaque call. It can optimize everything around
62
+ the FP8 linear (attention, norms, etc.) but cannot fuse across the boundary.
63
+
64
+ Both call the exact same cuBLAS _scaled_mm kernel — the GPU matmul is identical.
65
+ The difference is only in the "glue" ops (amax, scale, cast) which are tiny
66
+ compared to the matmul. In practice this means our version is slightly faster
67
+ (less compilation overhead, no tensor subclass dispatch cost) but can produce
68
+ subtly different floating-point rounding paths under torch.compile, since Inductor
69
+ generates a different graph. Numerics are bitwise identical in eager mode.
70
+ """
71
+
72
+ import torch
73
+ import torch.nn as nn
74
+
75
+ # Avoid division by zero when computing scale from an all-zeros tensor
76
+ EPS = 1e-12
77
+
78
+
79
+ @torch.no_grad()
80
+ def _to_fp8(x, fp8_dtype):
81
+ """Dynamically quantize a tensor to FP8 using tensorwise scaling.
82
+
83
+ "Tensorwise" means one scalar scale for the entire tensor (as opposed to
84
+ "rowwise" which computes a separate scale per row). Tensorwise is faster
85
+ because cuBLAS handles the scaling; rowwise needs the CUTLASS kernel.
86
+
87
+ Returns (fp8_data, inverse_scale) for use with torch._scaled_mm.
88
+ """
89
+ fp8_max = torch.finfo(fp8_dtype).max
90
+ # Compute the max absolute value across the entire tensor
91
+ amax = x.float().abs().max()
92
+ # Scale maps [0, amax] -> [0, fp8_max]. Use float64 for the division to
93
+ # ensure consistent numerics between torch.compile and eager mode.
94
+ # (torchao does the same upcast — without it, compile/eager can diverge)
95
+ scale = fp8_max / amax.double().clamp(min=EPS)
96
+ scale = scale.float()
97
+ # Quantize: scale into FP8 range, saturate (clamp prevents overflow when
98
+ # casting — PyTorch's default is to wrap, not saturate), then cast to FP8
99
+ x_scaled = x.float() * scale
100
+ x_clamped = x_scaled.clamp(-fp8_max, fp8_max)
101
+ x_fp8 = x_clamped.to(fp8_dtype)
102
+ # _scaled_mm expects the *inverse* of our scale (it multiplies by this to
103
+ # convert FP8 values back to the original range during the matmul)
104
+ inv_scale = scale.reciprocal()
105
+ return x_fp8, inv_scale
106
+
107
+
108
+ def _to_col_major(x):
109
+ """Rearrange a 2D tensor's memory to column-major layout.
110
+
111
+ torch._scaled_mm requires its second operand in column-major layout.
112
+ The trick: transpose -> contiguous (forces a copy in transposed order)
113
+ -> transpose back. The result has the same logical shape but column-major
114
+ strides, e.g. a [M, N] tensor gets strides (1, M) instead of (N, 1).
115
+ """
116
+ return x.t().contiguous().t()
117
+
118
+
119
+ # allow_in_graph tells torch.compile to treat this as an opaque operation —
120
+ # dynamo won't try to decompose it into smaller ops. See the module docstring
121
+ # for how this differs from torchao's tensor subclass approach.
122
+ @torch._dynamo.allow_in_graph
123
+ class _Float8Matmul(torch.autograd.Function):
124
+ """Custom autograd for the three FP8 GEMMs of a Linear layer.
125
+
126
+ The forward saves input and weight in their original precision for the
127
+ backward pass. Each GEMM independently re-quantizes its operands to FP8.
128
+ (We don't reuse the forward's FP8 tensors in backward — the backward might
129
+ want different precision, and saving FP8 would lose information.)
130
+ """
131
+
132
+ @staticmethod
133
+ def forward(ctx, input_2d, weight):
134
+ ctx.save_for_backward(input_2d, weight)
135
+
136
+ # Quantize both operands to e4m3 (higher precision format)
137
+ input_fp8, input_inv = _to_fp8(input_2d, torch.float8_e4m3fn)
138
+ weight_fp8, weight_inv = _to_fp8(weight, torch.float8_e4m3fn)
139
+
140
+ # output = input @ weight.T
141
+ # input_fp8 is [B, K] contiguous = row-major (good for first arg)
142
+ # weight_fp8 is [N, K] contiguous, so weight_fp8.t() is [K, N] with
143
+ # strides (1, K) = column-major (good for second arg, no copy needed!)
144
+ output = torch._scaled_mm(
145
+ input_fp8,
146
+ weight_fp8.t(),
147
+ scale_a=input_inv,
148
+ scale_b=weight_inv,
149
+ out_dtype=input_2d.dtype,
150
+ # use_fast_accum=True accumulates the dot products in lower precision.
151
+ # Slightly less accurate but measurably faster. Standard practice for
152
+ # the forward pass; we use False in backward for more precise gradients.
153
+ use_fast_accum=True,
154
+ )
155
+ return output
156
+
157
+ @staticmethod
158
+ def backward(ctx, grad_output):
159
+ input_2d, weight = ctx.saved_tensors
160
+
161
+ # === GEMM 1: grad_input = grad_output @ weight ===
162
+ # Shapes: [B, N] @ [N, K] -> [B, K]
163
+ # Gradients use e5m2 (wider range), weights use e4m3 (higher precision)
164
+ go_fp8, go_inv = _to_fp8(grad_output, torch.float8_e5m2)
165
+ w_fp8, w_inv = _to_fp8(weight, torch.float8_e4m3fn)
166
+ # go_fp8 is [B, N] contiguous = row-major, good for first arg
167
+ # w_fp8 is [N, K] contiguous = row-major, need column-major for second arg
168
+ w_col = _to_col_major(w_fp8)
169
+ grad_input = torch._scaled_mm(
170
+ go_fp8,
171
+ w_col,
172
+ scale_a=go_inv,
173
+ scale_b=w_inv,
174
+ out_dtype=grad_output.dtype,
175
+ use_fast_accum=False,
176
+ )
177
+
178
+ # === GEMM 2: grad_weight = grad_output.T @ input ===
179
+ # Shapes: [N, B] @ [B, K] -> [N, K]
180
+ go_fp8_2, go_inv_2 = _to_fp8(grad_output, torch.float8_e5m2)
181
+ in_fp8, in_inv = _to_fp8(input_2d, torch.float8_e4m3fn)
182
+ # go_fp8_2 is [B, N] contiguous, we need go.T = [N, B] as first arg.
183
+ # Transposing gives column-major, but first arg needs row-major,
184
+ # so we must call .contiguous() to physically rearrange the memory.
185
+ go_T = go_fp8_2.t().contiguous() # [N, B] row-major
186
+ in_col = _to_col_major(in_fp8) # [B, K] column-major
187
+ grad_weight = torch._scaled_mm(
188
+ go_T,
189
+ in_col,
190
+ scale_a=go_inv_2,
191
+ scale_b=in_inv,
192
+ out_dtype=grad_output.dtype,
193
+ use_fast_accum=False,
194
+ )
195
+
196
+ return grad_input, grad_weight
197
+
198
+
199
+ class Float8Linear(nn.Linear):
200
+ """Drop-in nn.Linear replacement that does FP8 compute.
201
+
202
+ Weights and biases remain in their original precision (e.g. fp32/bf16).
203
+ Only the matmul is performed in FP8 via the _Float8Matmul autograd function.
204
+ """
205
+
206
+ def forward(self, input):
207
+ # Replicate the autocast behavior of F.linear — when autocast is active,
208
+ # we need to manually cast input to the autocast dtype (e.g. bf16),
209
+ # since we bypass F.linear's built-in autocast handling.
210
+ if torch.is_autocast_enabled():
211
+ input = input.to(torch.get_autocast_gpu_dtype())
212
+ # _scaled_mm only works on 2D tensors, so flatten batch dimensions
213
+ orig_shape = input.shape
214
+ input_2d = input.reshape(-1, orig_shape[-1])
215
+ output = _Float8Matmul.apply(input_2d, self.weight)
216
+ output = output.reshape(*orig_shape[:-1], output.shape[-1])
217
+ if self.bias is not None:
218
+ output = output + self.bias.to(output.dtype)
219
+ return output
220
+
221
+ @classmethod
222
+ def from_float(cls, mod):
223
+ """Create Float8Linear from nn.Linear, sharing the same weight and bias.
224
+
225
+ Uses meta device to avoid allocating a temporary weight tensor — we
226
+ create the module shell on meta (shapes/dtypes only, no memory), then
227
+ point .weight and .bias to the original module's parameters.
228
+ """
229
+ with torch.device("meta"):
230
+ new_mod = cls(mod.in_features, mod.out_features, bias=False)
231
+ new_mod.weight = mod.weight
232
+ new_mod.bias = mod.bias
233
+ return new_mod
234
+
235
+
236
+ class Float8LinearConfig:
237
+ """Minimal config matching torchao's API. Only tensorwise recipe is supported."""
238
+
239
+ @staticmethod
240
+ def from_recipe_name(recipe_name):
241
+ if recipe_name != "tensorwise":
242
+ raise ValueError(
243
+ f"Only 'tensorwise' recipe is supported, got '{recipe_name}'. "
244
+ f"Rowwise/axiswise recipes require the full torchao library."
245
+ )
246
+ return Float8LinearConfig()
247
+
248
+
249
+ def convert_to_float8_training(module, *, config=None, module_filter_fn=None):
250
+ """Replace nn.Linear layers with Float8Linear throughout a module.
251
+
252
+ Walks the module tree in post-order (children before parents) and swaps
253
+ each nn.Linear that passes the optional filter. The new Float8Linear shares
254
+ the original weight and bias tensors — no copies, no extra memory.
255
+
256
+ Args:
257
+ module: Root module to convert.
258
+ config: Float8LinearConfig (accepted for API compat, only tensorwise supported).
259
+ module_filter_fn: Optional filter(module, fqn) -> bool. Only matching Linears
260
+ are converted. Common use: skip layers with dims not divisible by 16
261
+ (hardware requirement for FP8 matmuls on H100).
262
+ """
263
+ def _convert(mod, prefix=""):
264
+ for name, child in mod.named_children():
265
+ fqn = f"{prefix}.{name}" if prefix else name
266
+ _convert(child, fqn)
267
+ if isinstance(child, nn.Linear) and not isinstance(child, Float8Linear):
268
+ if module_filter_fn is None or module_filter_fn(child, fqn):
269
+ setattr(mod, name, Float8Linear.from_float(child))
270
+
271
+ _convert(module)
272
+ return module
nanochat/gpt.py ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from torch.utils.checkpoint import checkpoint as torch_checkpoint
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ from nanochat.common import get_dist_info, print0
24
+ from nanochat.optim import MuonAdamW, DistMuonAdamW
25
+
26
+ # Our custom Flash Attention module that automatically uses FA3 on Hopper+ and SDPA fallback elsewhere
27
+ from nanochat.flash_attention import flash_attn
28
+
29
+ @dataclass
30
+ class GPTConfig:
31
+ sequence_len: int = 2048
32
+ vocab_size: int = 32768
33
+ n_layer: int = 12
34
+ n_head: int = 6 # number of query heads
35
+ n_kv_head: int = 6 # number of key/value heads (GQA)
36
+ n_embd: int = 768
37
+ # Sliding window attention pattern string, tiled across layers. Final layer always L.
38
+ # Characters: L=long (full context), S=short (half context)
39
+ # Examples: "L"=all full context, "SL"=alternating, "SSL"=two short then one long
40
+ window_pattern: str = "SSSL"
41
+
42
+
43
+ def norm(x):
44
+ # Purely functional rmsnorm with no learnable params
45
+ return F.rms_norm(x, (x.size(-1),))
46
+
47
+
48
+ def has_ve(layer_idx, n_layer):
49
+ """Returns True if GPT layer should have Value Embedding (alternating, last layer always included)."""
50
+ return layer_idx % 2 == (n_layer - 1) % 2
51
+
52
+ def apply_rotary_emb(x, cos, sin):
53
+ assert x.ndim == 4 # multihead attention
54
+ d = x.shape[3] // 2
55
+ x1, x2 = x[..., :d], x[..., d:] # split up last dim into two halves
56
+ y1 = x1 * cos + x2 * sin # rotate pairs of dims
57
+ y2 = x1 * (-sin) + x2 * cos
58
+ return torch.cat([y1, y2], 3)
59
+
60
+ class CausalSelfAttention(nn.Module):
61
+ def __init__(self, config, layer_idx):
62
+ super().__init__()
63
+ self.layer_idx = layer_idx
64
+ self.n_head = config.n_head
65
+ self.n_kv_head = config.n_kv_head
66
+ self.n_embd = config.n_embd
67
+ self.head_dim = self.n_embd // self.n_head
68
+ assert self.n_embd % self.n_head == 0
69
+ assert self.n_kv_head <= self.n_head and self.n_head % self.n_kv_head == 0
70
+ self.c_q = nn.Linear(self.n_embd, self.n_head * self.head_dim, bias=False)
71
+ self.c_k = nn.Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
72
+ self.c_v = nn.Linear(self.n_embd, self.n_kv_head * self.head_dim, bias=False)
73
+ self.c_proj = nn.Linear(self.n_embd, self.n_embd, bias=False)
74
+ self.ve_gate_channels = 32
75
+ self.ve_gate = nn.Linear(self.ve_gate_channels, self.n_kv_head, bias=False) if has_ve(layer_idx, config.n_layer) else None
76
+
77
+ def forward(self, x, ve, cos_sin, window_size, kv_cache):
78
+ B, T, C = x.size()
79
+
80
+ # Project the input to get queries, keys, and values
81
+ # Shape: (B, T, H, D) - FA3's native layout, no transpose needed!
82
+ q = self.c_q(x).view(B, T, self.n_head, self.head_dim)
83
+ k = self.c_k(x).view(B, T, self.n_kv_head, self.head_dim)
84
+ v = self.c_v(x).view(B, T, self.n_kv_head, self.head_dim)
85
+
86
+ # Value residual (ResFormer): mix in value embedding with input-dependent gate per head
87
+ if ve is not None:
88
+ ve = ve.view(B, T, self.n_kv_head, self.head_dim)
89
+ gate = 2 * torch.sigmoid(self.ve_gate(x[..., :self.ve_gate_channels])) # (B, T, n_kv_head), range (0, 2)
90
+ v = v + gate.unsqueeze(-1) * ve
91
+
92
+ # Apply Rotary Embeddings to queries and keys to get relative positional encoding
93
+ cos, sin = cos_sin
94
+ q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin)
95
+ q, k = norm(q), norm(k) # QK norm
96
+
97
+ # Flash Attention (FA3 on Hopper+, PyTorch SDPA fallback elsewhere)
98
+ # window_size is (left, right) tuple: (N, 0) for causal, (-1, 0) for full context
99
+ if kv_cache is None:
100
+ # Training: causal attention with optional sliding window
101
+ y = flash_attn.flash_attn_func(q, k, v, causal=True, window_size=window_size)
102
+ else:
103
+ # Inference: use flash_attn_with_kvcache which handles cache management
104
+ k_cache, v_cache = kv_cache.get_layer_cache(self.layer_idx)
105
+ y = flash_attn.flash_attn_with_kvcache(
106
+ q, k_cache, v_cache,
107
+ k=k, v=v,
108
+ cache_seqlens=kv_cache.cache_seqlens,
109
+ causal=True,
110
+ window_size=window_size,
111
+ )
112
+ # Advance position after last layer processes
113
+ if self.layer_idx == kv_cache.n_layers - 1:
114
+ kv_cache.advance(T)
115
+
116
+ # Re-assemble the heads and project back to residual stream
117
+ y = y.contiguous().view(B, T, -1)
118
+ y = self.c_proj(y)
119
+ return y
120
+
121
+
122
+ class MLP(nn.Module):
123
+ def __init__(self, config):
124
+ super().__init__()
125
+ self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=False)
126
+ self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=False)
127
+
128
+ def forward(self, x):
129
+ x = self.c_fc(x)
130
+ x = F.relu(x).square()
131
+ x = self.c_proj(x)
132
+ return x
133
+
134
+
135
+ class Block(nn.Module):
136
+ def __init__(self, config, layer_idx):
137
+ super().__init__()
138
+ self.attn = CausalSelfAttention(config, layer_idx)
139
+ self.mlp = MLP(config)
140
+
141
+ def forward(self, x, ve, cos_sin, window_size, kv_cache):
142
+ x = x + self.attn(norm(x), ve, cos_sin, window_size, kv_cache)
143
+ x = x + self.mlp(norm(x))
144
+ return x
145
+
146
+
147
+ class GPT(nn.Module):
148
+ def __init__(self, config, pad_vocab_size_to=64):
149
+ """
150
+ NOTE a major footgun: this __init__ function runs in meta device context (!!)
151
+ Therefore, any calculations inside here are shapes and dtypes only, no actual data.
152
+ => We actually initialize all data (parameters, buffers, etc.) in init_weights() instead.
153
+ """
154
+ super().__init__()
155
+ self.config = config
156
+ # Compute per-layer window sizes for sliding window attention
157
+ # window_size is (left, right) tuple: (-1, 0) for full context, (N, 0) for sliding window
158
+ self.window_sizes = self._compute_window_sizes(config)
159
+ # Pad vocab for efficiency (DDP, tensor cores). This is just an optimization - outputs are cropped in forward().
160
+ # https://huggingface.co/docs/transformers/main_classes/model#transformers.PreTrainedModel.resize_token_embeddings
161
+ padded_vocab_size = ((config.vocab_size + pad_vocab_size_to - 1) // pad_vocab_size_to) * pad_vocab_size_to
162
+ if padded_vocab_size != config.vocab_size:
163
+ print0(f"Padding vocab_size from {config.vocab_size} to {padded_vocab_size} for efficiency")
164
+ self.transformer = nn.ModuleDict({
165
+ "wte": nn.Embedding(padded_vocab_size, config.n_embd),
166
+ "h": nn.ModuleList([Block(config, layer_idx) for layer_idx in range(config.n_layer)]),
167
+ })
168
+ self.lm_head = nn.Linear(config.n_embd, padded_vocab_size, bias=False)
169
+ # Per-layer learnable scalars (inspired by modded-nanogpt)
170
+ # resid_lambdas: scales the residual stream at each layer (init 1.0 = neutral)
171
+ # x0_lambdas: blends initial embedding back in at each layer (init 0.0 = disabled)
172
+ # Separate parameters so they can have different optimizer treatment
173
+ self.resid_lambdas = nn.Parameter(torch.ones(config.n_layer)) # fake init, real init in init_weights()
174
+ self.x0_lambdas = nn.Parameter(torch.zeros(config.n_layer)) # fake init, real init in init_weights()
175
+ # Value embeddings (ResFormer-style): alternating layers, last layer always included
176
+ head_dim = config.n_embd // config.n_head
177
+ kv_dim = config.n_kv_head * head_dim
178
+ 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)})
179
+ # To support meta device initialization, we init the rotary embeddings here, but it's just "fake" meta tensors only.
180
+ # As for rotary_seq_len, these rotary embeddings are pretty small/cheap in memory,
181
+ # so let's just over-compute them by 10X, but assert fail if we ever reach that amount.
182
+ # In the future we can dynamically grow the cache, for now it's fine.
183
+ self.rotary_seq_len = config.sequence_len * 10 # 10X over-compute should be enough, TODO make nicer?
184
+ head_dim = config.n_embd // config.n_head
185
+ cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
186
+ self.register_buffer("cos", cos, persistent=False) # persistent=False means it's not saved to the checkpoint
187
+ self.register_buffer("sin", sin, persistent=False)
188
+ self.use_checkpoint = False # set by training script to enable activation checkpointing
189
+
190
+ @torch.no_grad()
191
+ def init_weights(self):
192
+ """
193
+ Initialize the full model in this one function for maximum clarity.
194
+
195
+ wte (embedding): normal, std=1.0
196
+ lm_head: normal, std=0.001
197
+ for each block:
198
+ attn.c_q: uniform, std=1/sqrt(n_embd)
199
+ attn.c_k: uniform, std=1/sqrt(n_embd)
200
+ attn.c_v: uniform, std=1/sqrt(n_embd)
201
+ attn.c_proj: zeros
202
+ mlp.c_fc: uniform, std=1/sqrt(n_embd)
203
+ mlp.c_proj: zeros
204
+ """
205
+
206
+ # Embedding and unembedding
207
+ torch.nn.init.normal_(self.transformer.wte.weight, mean=0.0, std=1.0)
208
+ torch.nn.init.normal_(self.lm_head.weight, mean=0.0, std=0.001)
209
+
210
+ # Transformer blocks: uniform init with bound = sqrt(3) * std (same standard deviation as normal)
211
+ n_embd = self.config.n_embd
212
+ s = 3**0.5 * n_embd**-0.5 # sqrt(3) multiplier makes sure Uniform achieves the same std as Normal
213
+ for block in self.transformer.h:
214
+ torch.nn.init.uniform_(block.attn.c_q.weight, -s, s) # weights use Uniform to avoid outliers
215
+ torch.nn.init.uniform_(block.attn.c_k.weight, -s, s)
216
+ torch.nn.init.uniform_(block.attn.c_v.weight, -s, s)
217
+ torch.nn.init.zeros_(block.attn.c_proj.weight) # projections are zero
218
+ torch.nn.init.uniform_(block.mlp.c_fc.weight, -s, s)
219
+ torch.nn.init.zeros_(block.mlp.c_proj.weight)
220
+
221
+ # Per-layer scalars
222
+ self.resid_lambdas.fill_(1.0) # 1.0 => typical residual connections at init
223
+ self.x0_lambdas.fill_(0.1) # 0.1 => small initial weight for skip connection to input embedding
224
+
225
+ # Value embeddings (init like c_v: uniform with same std)
226
+ for ve in self.value_embeds.values():
227
+ torch.nn.init.uniform_(ve.weight, -s, s)
228
+
229
+ # Gate weights init to zero so gates start at sigmoid(0) = 0.5, scaled by 2 -> 1.0 (neutral)
230
+ for block in self.transformer.h:
231
+ if block.attn.ve_gate is not None:
232
+ torch.nn.init.zeros_(block.attn.ve_gate.weight)
233
+
234
+ # Rotary embeddings
235
+ head_dim = self.config.n_embd // self.config.n_head
236
+ cos, sin = self._precompute_rotary_embeddings(self.rotary_seq_len, head_dim)
237
+ self.cos, self.sin = cos, sin
238
+
239
+ # Cast embeddings to bf16: optimizer can tolerate it and it saves memory
240
+ if self.transformer.wte.weight.device.type == "cuda":
241
+ self.transformer.wte.to(dtype=torch.bfloat16)
242
+ for ve in self.value_embeds.values():
243
+ ve.to(dtype=torch.bfloat16)
244
+
245
+ def _precompute_rotary_embeddings(self, seq_len, head_dim, base=10000, device=None):
246
+ # TODO: bump base theta more? e.g. 100K is more common more recently
247
+ # autodetect the device from model embeddings
248
+ if device is None:
249
+ device = self.transformer.wte.weight.device
250
+ # stride the channels
251
+ channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
252
+ inv_freq = 1.0 / (base ** (channel_range / head_dim))
253
+ # stride the time steps
254
+ t = torch.arange(seq_len, dtype=torch.float32, device=device)
255
+ # calculate the rotation frequencies at each (time, channel) pair
256
+ freqs = torch.outer(t, inv_freq)
257
+ cos, sin = freqs.cos(), freqs.sin()
258
+ cos, sin = cos.bfloat16(), sin.bfloat16() # keep them in bfloat16
259
+ cos, sin = cos[None, :, None, :], sin[None, :, None, :] # add batch and head dims for later broadcasting
260
+ return cos, sin
261
+
262
+ def _compute_window_sizes(self, config):
263
+ """
264
+ Compute per-layer window sizes for sliding window attention.
265
+
266
+ Returns list of (left, right) tuples for FA3's window_size parameter:
267
+ - left: how many tokens before current position to attend to (-1 = unlimited)
268
+ - right: how many tokens after current position to attend to (0 for causal)
269
+
270
+ Pattern string is tiled across layers. Final layer always gets L (full context).
271
+ Characters: L=long (full context), S=short (half context)
272
+ """
273
+ pattern = config.window_pattern.upper()
274
+ assert all(c in "SL" for c in pattern), f"Invalid window_pattern: {pattern}. Use only S and L."
275
+ # Map characters to window sizes
276
+ long_window = config.sequence_len
277
+ short_window = long_window // 2
278
+ char_to_window = {
279
+ "L": (long_window, 0),
280
+ "S": (short_window, 0),
281
+ }
282
+ # Tile pattern across layers
283
+ window_sizes = []
284
+ for layer_idx in range(config.n_layer):
285
+ char = pattern[layer_idx % len(pattern)]
286
+ window_sizes.append(char_to_window[char])
287
+ # Final layer always gets full context
288
+ window_sizes[-1] = (long_window, 0)
289
+ return window_sizes
290
+
291
+ def get_device(self):
292
+ return self.transformer.wte.weight.device
293
+
294
+ def estimate_flops(self):
295
+ """
296
+ Return the estimated FLOPs per token for the model (forward + backward).
297
+ Each matmul weight parameter contributes 2 FLOPs (multiply *, accumulate +) in forward, and 2X that in backward => 2+4=6.
298
+ Cleanest explanation of this: https://medium.com/@dzmitrybahdanau/the-flops-calculus-of-language-model-training-3b19c1f025e4
299
+ On top of that, 12 * h * q * effective_seq_len accounts for key @ query matmul flops inside attention.
300
+ With sliding windows, effective_seq_len varies per layer (capped by window size).
301
+ Ref: https://arxiv.org/abs/2204.02311 (PaLM paper).
302
+ This is ~1% off from the exact formulas of Chinchilla paper, the difference is:
303
+ - Chinchilla counts the embedding layer as flops (? weird, it's just a lookup => we ignore)
304
+ - Chinchilla counts exp/sum/divide in attention softmax as flops (a little sus and very tiny => we ignore)
305
+ """
306
+ nparams = sum(p.numel() for p in self.parameters())
307
+ # Exclude non-matmul params: embeddings and per-layer scalars
308
+ value_embeds_numel = sum(ve.weight.numel() for ve in self.value_embeds.values())
309
+ nparams_exclude = (self.transformer.wte.weight.numel() + value_embeds_numel +
310
+ self.resid_lambdas.numel() + self.x0_lambdas.numel())
311
+ h, q, t = self.config.n_head, self.config.n_embd // self.config.n_head, self.config.sequence_len
312
+ # Sum attention FLOPs per layer, accounting for sliding window
313
+ attn_flops = 0
314
+ for window_size in self.window_sizes:
315
+ window = window_size[0] # (left, right) tuple, we use left
316
+ effective_seq = t if window < 0 else min(window, t)
317
+ attn_flops += 12 * h * q * effective_seq
318
+ num_flops_per_token = 6 * (nparams - nparams_exclude) + attn_flops
319
+ return num_flops_per_token
320
+
321
+ def num_scaling_params(self):
322
+ """
323
+ Return detailed parameter counts for scaling law analysis.
324
+ Different papers use different conventions:
325
+ - Kaplan et al. excluded embedding parameters
326
+ - Chinchilla included all parameters
327
+ Ref: https://arxiv.org/abs/2203.15556 (Chinchilla paper)
328
+ Ref: https://arxiv.org/abs/2001.08361 (Kaplan et al. original scaling laws paper)
329
+
330
+ Returns a dict with counts for each parameter group, so downstream analysis
331
+ can experiment with which combination gives the cleanest scaling laws.
332
+ """
333
+ # Count each group separately (mirrors the grouping in setup_optimizers)
334
+ wte = sum(p.numel() for p in self.transformer.wte.parameters())
335
+ value_embeds = sum(p.numel() for p in self.value_embeds.parameters())
336
+ lm_head = sum(p.numel() for p in self.lm_head.parameters())
337
+ transformer_matrices = sum(p.numel() for p in self.transformer.h.parameters())
338
+ scalars = self.resid_lambdas.numel() + self.x0_lambdas.numel()
339
+ total = wte + value_embeds + lm_head + transformer_matrices + scalars
340
+ assert total == sum(p.numel() for p in self.parameters()), "Parameter count mismatch"
341
+ return {
342
+ 'wte': wte,
343
+ 'value_embeds': value_embeds,
344
+ 'lm_head': lm_head,
345
+ 'transformer_matrices': transformer_matrices,
346
+ 'scalars': scalars,
347
+ 'total': total,
348
+ }
349
+
350
+ 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):
351
+ model_dim = self.config.n_embd
352
+ ddp, rank, local_rank, world_size = get_dist_info()
353
+
354
+ # Separate out all parameters into groups
355
+ matrix_params = list(self.transformer.h.parameters())
356
+ value_embeds_params = list(self.value_embeds.parameters())
357
+ embedding_params = list(self.transformer.wte.parameters())
358
+ lm_head_params = list(self.lm_head.parameters())
359
+ resid_params = [self.resid_lambdas]
360
+ x0_params = [self.x0_lambdas]
361
+ 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)
362
+
363
+ # Scale the LR for the AdamW parameters by ∝1/√dmodel (tuned for 768 dim model)
364
+ dmodel_lr_scale = (model_dim / 768) ** -0.5
365
+ print0(f"Scaling the LR for the AdamW parameters ∝1/√({model_dim}/768) = {dmodel_lr_scale:.6f}")
366
+
367
+ # Build param_groups with all required fields explicit
368
+ param_groups = [
369
+ # AdamW groups (embeddings, lm_head, scalars)
370
+ dict(kind='adamw', params=lm_head_params, lr=unembedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
371
+ dict(kind='adamw', params=embedding_params, lr=embedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
372
+ dict(kind='adamw', params=value_embeds_params, lr=embedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
373
+ dict(kind='adamw', params=resid_params, lr=scalar_lr * 0.01, betas=adam_betas, eps=1e-10, weight_decay=0.0),
374
+ 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
375
+ ]
376
+ # Muon groups (matrix params, grouped by shape for stacking)
377
+ for shape in sorted({p.shape for p in matrix_params}):
378
+ group_params = [p for p in matrix_params if p.shape == shape]
379
+ param_groups.append(dict(
380
+ kind='muon', params=group_params, lr=matrix_lr,
381
+ momentum=0.95, ns_steps=5, beta2=0.95, weight_decay=weight_decay,
382
+ ))
383
+
384
+ Factory = DistMuonAdamW if ddp else MuonAdamW
385
+ optimizer = Factory(param_groups)
386
+ for group in optimizer.param_groups:
387
+ group["initial_lr"] = group["lr"]
388
+ return optimizer
389
+
390
+ def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
391
+ B, T = idx.size()
392
+
393
+ # Grab the rotary embeddings for the current sequence length (they are of shape (1, seq_len, 1, head_dim/2))
394
+ assert T <= self.cos.size(1), f"Sequence length grew beyond the rotary embeddings cache: {T} > {self.cos.size(1)}"
395
+ assert idx.device == self.cos.device, f"Rotary embeddings and idx are on different devices: {idx.device} != {self.cos.device}"
396
+ assert self.cos.dtype == torch.bfloat16, "Rotary embeddings must be in bfloat16"
397
+ # if kv cache exists, we need to offset the rotary embeddings to the current position in the cache
398
+ T0 = 0 if kv_cache is None else kv_cache.get_pos()
399
+ cos_sin = self.cos[:, T0:T0+T], self.sin[:, T0:T0+T] # truncate cache to current sequence length
400
+
401
+ # Forward the trunk of the Transformer
402
+ x = self.transformer.wte(idx) # embed current token
403
+ x = norm(x)
404
+ x0 = x # save initial normalized embedding for x0 residual
405
+ for i, block in enumerate(self.transformer.h):
406
+ x = self.resid_lambdas[i] * x + self.x0_lambdas[i] * x0
407
+ ve = self.value_embeds[str(i)](idx) if str(i) in self.value_embeds else None
408
+ if self.use_checkpoint and kv_cache is None:
409
+ x = torch_checkpoint(block, x, ve, cos_sin, self.window_sizes[i], kv_cache, use_reentrant=False)
410
+ else:
411
+ x = block(x, ve, cos_sin, self.window_sizes[i], kv_cache)
412
+ x = norm(x)
413
+
414
+ # Forward the lm_head (compute logits)
415
+ softcap = 15 # smoothly cap the logits to the range [-softcap, softcap]
416
+ logits = self.lm_head(x) # (B, T, padded_vocab_size) <- very big tensor, large amount of memory
417
+ logits = logits[..., :self.config.vocab_size] # slice to remove padding
418
+ logits = logits.float() # switch to fp32 for logit softcap and loss computation
419
+ logits = softcap * torch.tanh(logits / softcap) # squash the logits
420
+
421
+ if targets is not None:
422
+ # training: given the targets, compute and return the loss
423
+ # TODO experiment with chunked cross-entropy?
424
+ loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1, reduction=loss_reduction)
425
+ return loss
426
+ else:
427
+ # inference: just return the logits directly
428
+ return logits
429
+
430
+ @torch.inference_mode()
431
+ def generate(self, tokens, max_tokens, temperature=1.0, top_k=None, seed=42):
432
+ """
433
+ Naive autoregressive streaming inference.
434
+ To make it super simple, let's assume:
435
+ - batch size is 1
436
+ - ids and the yielded tokens are simple Python lists and ints
437
+ """
438
+ assert isinstance(tokens, list)
439
+ device = self.get_device()
440
+ rng = None
441
+ if temperature > 0:
442
+ rng = torch.Generator(device=device)
443
+ rng.manual_seed(seed)
444
+ ids = torch.tensor([tokens], dtype=torch.long, device=device) # add batch dim
445
+ for _ in range(max_tokens):
446
+ logits = self.forward(ids) # (B, T, vocab_size)
447
+ logits = logits[:, -1, :] # (B, vocab_size)
448
+ if top_k is not None and top_k > 0:
449
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
450
+ logits[logits < v[:, [-1]]] = -float('Inf')
451
+ if temperature > 0:
452
+ logits = logits / temperature
453
+ probs = F.softmax(logits, dim=-1)
454
+ next_ids = torch.multinomial(probs, num_samples=1, generator=rng)
455
+ else:
456
+ next_ids = torch.argmax(logits, dim=-1, keepdim=True)
457
+ ids = torch.cat((ids, next_ids), dim=1)
458
+ token = next_ids.item()
459
+ yield token
nanochat/loss_eval.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A number of functions that help with evaluating a base model.
3
+ """
4
+ import math
5
+ import torch
6
+ import torch.distributed as dist
7
+
8
+ @torch.no_grad()
9
+ def evaluate_bpb(model, batches, steps, token_bytes):
10
+ """
11
+ Instead of the naive 'mean loss', this function returns the bits per byte (bpb),
12
+ which is a tokenization vocab size-independent metric, meaning you are still comparing
13
+ apples:apples if you change the vocab size. The way this works is that instead of just
14
+ calculating the average loss as usual, you calculate the sum loss, and independently
15
+ also the sum bytes (of all the target tokens), and divide. This normalizes the loss by
16
+ the number of bytes that the target tokens represent.
17
+
18
+ The added complexity is so that:
19
+ 1) All "normal" tokens are normalized by the length of the token in bytes
20
+ 2) No special tokens (e.g. <|bos|>) are included in the metric - they are masked out.
21
+ 3) No actively masked tokens (using ignore_index of e.g. -1) are included in the metric.
22
+
23
+ In addition to evaluate_loss, we need the token_bytes tensor:
24
+ It is a 1D tensor of shape (vocab_size,), indicating the number of bytes for
25
+ each token id, or 0 if the token is to not be counted (e.g. special tokens).
26
+ """
27
+ # record the losses
28
+ total_nats = torch.tensor(0.0, dtype=torch.float32, device=model.get_device())
29
+ total_bytes = torch.tensor(0, dtype=torch.int64, device=model.get_device())
30
+ batch_iter = iter(batches)
31
+ for _ in range(steps):
32
+ x, y = next(batch_iter)
33
+ loss2d = model(x, y, loss_reduction='none') # (B, T)
34
+ loss2d = loss2d.view(-1) # flatten
35
+ y = y.view(-1) # flatten
36
+ if (y.int() < 0).any(): # mps does not currently have kernel for < 0 for int64, only int32
37
+ # slightly more complex code path if some target tokens are ignore_index (e.g. -1)
38
+ # any target token < 0 is to be ignored: do NOT index token_bytes with negatives
39
+ valid = y >= 0
40
+ y_safe = torch.where(valid, y, torch.zeros_like(y))
41
+ # map valid targets to their byte length; ignored targets contribute 0 bytes
42
+ num_bytes2d = torch.where(
43
+ valid,
44
+ token_bytes[y_safe],
45
+ torch.zeros_like(y, dtype=token_bytes.dtype)
46
+ )
47
+ total_nats += (loss2d * (num_bytes2d > 0)).sum()
48
+ total_bytes += num_bytes2d.sum()
49
+ else:
50
+ # fast path: no ignored targets, safe to index directly
51
+ num_bytes2d = token_bytes[y]
52
+ total_nats += (loss2d * (num_bytes2d > 0)).sum()
53
+ total_bytes += num_bytes2d.sum()
54
+ # sum reduce across all ranks
55
+ world_size = dist.get_world_size() if dist.is_initialized() else 1
56
+ if world_size > 1:
57
+ dist.all_reduce(total_nats, op=dist.ReduceOp.SUM)
58
+ dist.all_reduce(total_bytes, op=dist.ReduceOp.SUM)
59
+ # move both to cpu, calculate bpb and return
60
+ total_nats = total_nats.item()
61
+ total_bytes = total_bytes.item()
62
+ if total_bytes == 0:
63
+ return float('inf')
64
+ bpb = total_nats / (math.log(2) * total_bytes)
65
+ return bpb
nanochat/optim.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
optim_003000_rank0.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9da20cef7197da4730d7e845d0fefe7e7503d281cb1f6ea4f61368a685eeaa59
3
+ size 981842613
optim_003000_rank1.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:72d35f41dea161ff3f2049325af165187195460879e792f135e2f2b80643d2e9
3
+ size 981842613
optim_003000_rank10.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ddb507adf2586670d828fb25f7b0d58e42b469394db3b32a3b0f1a8ce7174e36
3
+ size 981842669
optim_003000_rank11.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f4cb61b48f17f1ca8704f99321acbeed9aeb6692518cc3bdc91e16b634824e7
3
+ size 981842669
optim_003000_rank12.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ca212fe0b3aa9dccfed719b3aaacea9ffd19a0ca6303c2469af35512f856b1d0
3
+ size 981842669
optim_003000_rank13.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dbf4093ab2c0044e3e418a62ff2a6bdce099a829350cab3e250bbbe02f444018
3
+ size 981842669
optim_003000_rank14.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6f9d406b2d97431f72c21f01209c9267a87f02ea12ac600d1dedf0bb83a254ff
3
+ size 981842669
optim_003000_rank15.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:91ca20bc8776a8b309d203742eaa122b2f52ab211e40d4d558ceedfa06d882fb
3
+ size 981842669
optim_003000_rank2.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16fd6a8c8740a5254b2c81337cfe60d2c88f69e773e1f8897d21ded989a40559
3
+ size 981842613
optim_003000_rank3.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06fa7a5a9baedb31ada768f89b6be6891c9181ee9f1c7855afd51ca4c7c45dde
3
+ size 981842613
optim_003000_rank4.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:505b6b31504e5e8b23be24d9af9bd098e5696871791f2d562d89b5a0375864a2
3
+ size 981842613
optim_003000_rank5.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e858c38ed03af08c18d8236dab011caa99eece6ada6a313d9733c027ceef581a
3
+ size 981842613
optim_003000_rank6.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dd021e18e757990320915221905bb333e726504551e6be385e26b6a43f189ebe
3
+ size 981842613
optim_003000_rank7.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f5a4b24fbfbafa2f90c8c157ac17d70677b542bafdc83c587f049cb18899d255
3
+ size 981842613
optim_003000_rank8.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:72c94748992e42bcd248d65bdc7d1241d032cab34beb303ac411611048ff78fd
3
+ size 981842613
optim_003000_rank9.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:782cff0ed6db4c54bf75823a85187dbc1f128655e0c9f0a1b9b6d619b1be3ad5
3
+ size 981842613
tokenizer/token_bytes.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cb29b37ce0a5e433e23411f25b1e44f0ca0b2892c3b502a7ef8fc31b836f8633
3
+ size 132677
tokenizer/tokenizer.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5e7d5a9088a7eb1743132eaebf8c7d448f88dc62ba0be2dec3d5e2489ede648b
3
+ size 400932