P1yansh commited on
Commit
0fcfdf0
·
0 Parent(s):

Initial commit: GLM-5.2 Baby from scratch with WSD schedule

Browse files
.gitignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+
7
+ # Environments
8
+ .env
9
+ .venv
10
+ env/
11
+ venv/
12
+ ENV/
13
+ env.bak/
14
+ venv.bak/
15
+
16
+ # Project specific ignores
17
+ # Ignore the large dataset binaries
18
+ data/*.bin
19
+ data_phase3/*.bin
20
+
21
+ # Ignore checkpoints
22
+ out_glm5/*.pt
23
+ *.pt
24
+
25
+ # IDE / Editor
26
+ .vscode/
27
+ .idea/
28
+ *.swp
29
+ *.swo
30
+
31
+ # VSCode workspace / agents
32
+ .agents/
README.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🍼 GLM-5.2 Baby (120M) - From Scratch
2
+
3
+ This repository contains a from-scratch implementation and pretraining script for a "baby" version (~120M parameters) of **GLM-5.2** (GLM MoE DSA). It is heavily inspired by Andrej Karpathy's `nanoGPT` and aims to be highly educational.
4
+
5
+ The model is small enough to train on a single consumer laptop GPU (e.g., RTX 4050 6GB VRAM) but includes all the cutting-edge architectural innovations of modern frontier models.
6
+
7
+ ## ✨ Architectural Features Implemented
8
+
9
+ This isn't just a standard Transformer. It implements three major innovations from recent frontier models (like DeepSeek-V3 and GLM-5):
10
+
11
+ 1. **MLA (Multi-Latent Attention):** Compresses the attention mechanism using LoRA-style projections to drastically save VRAM during training and inference.
12
+ 2. **DSA (DeepSeek Sparse Attention):** Selects only the most relevant tokens to attend to via a learned indexer, rather than attending to the entire context uniformly.
13
+ 3. **MoE (Mixture of Experts):** Uses a fine-grained sigmoid-routed mixture of experts alongside a shared expert, activating only a subset of parameters per token.
14
+
15
+ ## 🚀 Training Features
16
+
17
+ The training loop (`train_glm5.py`) is highly optimized for limited hardware (6GB VRAM) while maximizing throughput (~4,900 tokens/sec on an RTX 4050):
18
+ - **Mixed Precision:** Uses `bfloat16` and TF32 Tensor Cores.
19
+ - **Gradient Checkpointing:** Recomputes forward passes during backprop to save ~40% VRAM.
20
+ - **Gradient Accumulation:** Achieves large effective batch sizes on a single GPU.
21
+ - **WSD (Warmup-Stable-Decay) Learning Rate Schedule:**
22
+ Supports multi-phase training by holding the learning rate at peak for a "stable" exploration phase before initiating a steep cosine decay. (Controlled via `--stable_iters`).
23
+
24
+ ## 📚 Educational Guide
25
+
26
+ If you are new to LLM pretraining, learning rates, loss curves, and scaling laws, check out the included beginner guide:
27
+ 👉 **[LLM Training Guide for Beginners](llm_training_guide.md)**
28
+
29
+ ## 🛠️ Usage
30
+
31
+ ### Installation
32
+ ```bash
33
+ pip install -r requirements.txt
34
+ ```
35
+
36
+ ### Training
37
+ To train the model on a single GPU with the WSD schedule (holding LR stable for 217,000 steps):
38
+
39
+ ```bash
40
+ python train_glm5.py \
41
+ --data_dir ./data \
42
+ --batch_size 6 \
43
+ --gradient_accumulation_steps 3 \
44
+ --max_iters 110000 \
45
+ --lr_decay_iters 260000 \
46
+ --warmup_iters 1500 \
47
+ --stable_iters 217000 \
48
+ --eval_interval 2000 \
49
+ --eval_iters 200 \
50
+ --log_interval 100
51
+ ```
52
+
53
+ ### Generation / Sampling
54
+ To sample text from your best trained checkpoint:
55
+
56
+ ```bash
57
+ python train_glm5.py --eval_only --ckpt out_glm5/ckpt_best.pt --prompt "The future of AI is"
58
+ ```
59
+
60
+ ## ⚖️ License
61
+ MIT License
data/meta.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tokenizer": "gpt2",
3
+ "vocab_size": 50257,
4
+ "eot_token": 50256,
5
+ "dtype": "uint16",
6
+ "train_tokens": 1979887628,
7
+ "val_tokens": 19913769,
8
+ "total_tokens": 1999801397,
9
+ "sources": [
10
+ "fineweb_edu",
11
+ "wikipedia",
12
+ "gutenberg",
13
+ "starcoder"
14
+ ],
15
+ "val_ratio": 0.01
16
+ }
dataprep_phase3.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pretraining Data Pipeline -- Phase 3 (0.4B Tokens)
3
+ ===================================================
4
+
5
+ Streams 400 Million tokens of domain-diverse text:
6
+ - Wikipedia (wikimedia/wikipedia) -- 200M tokens (50%)
7
+ - Gutenberg (emozilla/pg19) -- 120M tokens (30%)
8
+ - Python Code (bigcode/the-stack) -- 80M tokens (20%)
9
+
10
+ Outputs to `./data_phase3` to avoid touching active training in `./data`.
11
+
12
+ Usage:
13
+ python dataprep_phase3.py
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import time
20
+
21
+ import numpy as np
22
+ import tiktoken
23
+ from tqdm import tqdm
24
+
25
+ DEFAULT_OUTPUT_DIR = "./data_phase3"
26
+ DEFAULT_TOKENIZER = "gpt2"
27
+ DEFAULT_TOTAL_TOKENS = 400_000_000 # 0.4B tokens
28
+ DEFAULT_VAL_RATIO = 0.01
29
+ DEFAULT_WRITE_CHUNK = 1_000_000
30
+
31
+ SOURCE_ALLOCATIONS = {
32
+ "wikipedia": 0.50, # 200M tokens
33
+ "gutenberg": 0.30, # 120M tokens
34
+ "starcoder": 0.20, # 80M tokens
35
+ }
36
+
37
+ GPT2_EOT = 50256
38
+
39
+
40
+ def iter_wikipedia():
41
+ from datasets import load_dataset
42
+ ds = load_dataset("wikimedia/wikipedia", "20231101.en", split="train", streaming=True)
43
+ for example in ds:
44
+ text = example.get("text", "")
45
+ if text:
46
+ yield text
47
+
48
+
49
+ def iter_gutenberg():
50
+ from datasets import load_dataset
51
+ ds = load_dataset("emozilla/pg19", split="train", streaming=True)
52
+ for example in ds:
53
+ text = example.get("text", "")
54
+ if text:
55
+ yield text
56
+
57
+
58
+ def iter_starcoder():
59
+ from datasets import load_dataset
60
+ try:
61
+ ds = load_dataset("bigcode/the-stack-smol", data_dir="data/python", split="train", streaming=True)
62
+ for example in ds:
63
+ text = example.get("content", "")
64
+ if text:
65
+ yield text
66
+ except Exception:
67
+ ds = load_dataset("codeparrot/codeparrot-clean", split="train", streaming=True)
68
+ for example in ds:
69
+ text = example.get("content", "")
70
+ if text:
71
+ yield text
72
+
73
+
74
+ SOURCE_ITERATORS = {
75
+ "wikipedia": iter_wikipedia,
76
+ "gutenberg": iter_gutenberg,
77
+ "starcoder": iter_starcoder,
78
+ }
79
+
80
+
81
+ def tokenize_and_write(source_name, text_iterator, target_tokens, encoder, eot_token, train_file, val_file, val_ratio, write_chunk, dtype):
82
+ train_buffer, val_buffer = [], []
83
+ train_total, val_total = 0, 0
84
+ doc_count = 0
85
+ rng = np.random.default_rng(seed=42 + hash(source_name) % 10000)
86
+
87
+ pbar = tqdm(
88
+ total=target_tokens,
89
+ unit="tok",
90
+ unit_scale=True,
91
+ desc=f" {source_name}",
92
+ bar_format=" {desc}: {percentage:3.0f}% |{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]",
93
+ )
94
+
95
+ for text in text_iterator:
96
+ tokens = encoder.encode_ordinary(text)
97
+ tokens.append(eot_token)
98
+
99
+ is_val = rng.random() < val_ratio
100
+
101
+ if is_val:
102
+ val_buffer.extend(tokens)
103
+ val_total += len(tokens)
104
+ if len(val_buffer) >= write_chunk:
105
+ val_file.write(np.array(val_buffer, dtype=dtype).tobytes())
106
+ val_buffer = []
107
+ else:
108
+ train_buffer.extend(tokens)
109
+ train_total += len(tokens)
110
+ if len(train_buffer) >= write_chunk:
111
+ train_file.write(np.array(train_buffer, dtype=dtype).tobytes())
112
+ train_buffer = []
113
+
114
+ doc_count += 1
115
+ pbar.update(len(tokens))
116
+
117
+ if train_total + val_total >= target_tokens:
118
+ break
119
+
120
+ if train_buffer:
121
+ train_file.write(np.array(train_buffer, dtype=dtype).tobytes())
122
+ if val_buffer:
123
+ val_file.write(np.array(val_buffer, dtype=dtype).tobytes())
124
+
125
+ pbar.close()
126
+ print(f" {source_name}: {doc_count:,} docs | train {train_total:,} + val {val_total:,} tokens")
127
+ return train_total, val_total
128
+
129
+
130
+ def main():
131
+ parser = argparse.ArgumentParser(description="Pretraining Data Pipeline -- Phase 3")
132
+ parser.add_argument("--output_dir", type=str, default=DEFAULT_OUTPUT_DIR)
133
+ parser.add_argument("--total_tokens", type=int, default=DEFAULT_TOTAL_TOKENS)
134
+ args = parser.parse_args()
135
+
136
+ os.makedirs(args.output_dir, exist_ok=True)
137
+ train_path = os.path.join(args.output_dir, "train.bin")
138
+ val_path = os.path.join(args.output_dir, "val.bin")
139
+ meta_path = os.path.join(args.output_dir, "meta.json")
140
+
141
+ encoder = tiktoken.get_encoding(DEFAULT_TOKENIZER)
142
+ vocab_size = encoder.n_vocab
143
+ dtype = np.uint16 if vocab_size <= 65535 else np.uint32
144
+
145
+ source_targets = {k: int(args.total_tokens * v) for k, v in SOURCE_ALLOCATIONS.items()}
146
+
147
+ print(f"\n{'='*70}")
148
+ print(f" Phase 3 Data Pipeline (0.4B Tokens Target)")
149
+ print(f"{'='*70}")
150
+ print(f" Output Dir: {os.path.abspath(args.output_dir)}")
151
+ for name, target in source_targets.items():
152
+ print(f" {name:15s}: {target:,} tokens")
153
+ print(f"{'='*70}\n")
154
+
155
+ total_train, total_val = 0, 0
156
+ t0 = time.time()
157
+
158
+ with open(train_path, "wb") as train_file, open(val_path, "wb") as val_file:
159
+ for source_name, target in source_targets.items():
160
+ print(f"\n [{source_name}] Streaming {target:,} tokens...")
161
+ try:
162
+ tr, va = tokenize_and_write(
163
+ source_name, SOURCE_ITERATORS[source_name](), target,
164
+ encoder, GPT2_EOT, train_file, val_file, DEFAULT_VAL_RATIO,
165
+ DEFAULT_WRITE_CHUNK, dtype
166
+ )
167
+ total_train += tr
168
+ total_val += va
169
+ except Exception as e:
170
+ print(f" [WARN] Error on {source_name}: {e}")
171
+
172
+ meta = {
173
+ "tokenizer": DEFAULT_TOKENIZER,
174
+ "vocab_size": vocab_size,
175
+ "dtype": "uint16" if dtype == np.uint16 else "uint32",
176
+ "train_tokens": total_train,
177
+ "val_tokens": total_val,
178
+ "total_tokens": total_train + total_val,
179
+ "sources": list(source_targets.keys()),
180
+ }
181
+ with open(meta_path, "w") as f:
182
+ json.dump(meta, f, indent=2)
183
+
184
+ print(f"\n [DONE] Phase 3 data ready at {args.output_dir}! Total: {total_train + total_val:,} tokens")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
dataprep_pretrain.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pretraining Data Pipeline for Baby GLM-5.2
3
+ ============================================
4
+
5
+ Streams, tokenizes, and writes 3.3B tokens to binary files for pretraining.
6
+
7
+ Data Sources:
8
+ 1. FineWeb-Edu -- 2.0B tokens (high-quality web text)
9
+ 2. Wikipedia EN -- 0.7B tokens (encyclopedic knowledge)
10
+ 3. Project Gutenberg -- 0.4B tokens (literary text)
11
+ 4. StarCoder Python -- 0.2B tokens (code, optional)
12
+
13
+ Output:
14
+ data/train.bin -- ~3.267B tokens, binary uint16 memmap
15
+ data/val.bin -- ~0.033B tokens, binary uint16 memmap
16
+ data/meta.json -- tokenizer info, vocab size, token counts
17
+
18
+ Usage:
19
+ python dataprep_pretrain.py # Full 3.3B token run
20
+ python dataprep_pretrain.py --total_tokens 10000000 # Quick 10M test
21
+ python dataprep_pretrain.py --no_code # Skip code data
22
+
23
+ Estimated runtime: 2-4 hours on CPU with good internet (full run)
24
+ Estimated disk: ~7 GB for output .bin files
25
+ """
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import time
31
+
32
+ import numpy as np
33
+ import tiktoken
34
+ from tqdm import tqdm
35
+
36
+ # =============================================================================
37
+ # Configuration
38
+ # =============================================================================
39
+
40
+ DEFAULT_OUTPUT_DIR = "./data"
41
+ DEFAULT_TOKENIZER = "gpt2"
42
+ DEFAULT_TOTAL_TOKENS = 3_300_000_000 # 3.3B tokens (Chinchilla+ for 120M params)
43
+ DEFAULT_VAL_RATIO = 0.01 # 1% validation split
44
+ DEFAULT_WRITE_CHUNK = 1_000_000 # Flush to disk every 1M tokens
45
+
46
+ # Data source allocation (fraction of total tokens)
47
+ SOURCE_ALLOCATIONS = {
48
+ "fineweb_edu": 0.606, # ~2.0B tokens
49
+ "wikipedia": 0.212, # ~0.7B tokens
50
+ "gutenberg": 0.121, # ~0.4B tokens
51
+ "starcoder": 0.061, # ~0.2B tokens (optional)
52
+ }
53
+
54
+ # End-of-text token for GPT-2 tokenizer
55
+ GPT2_EOT = 50256
56
+
57
+
58
+ # =============================================================================
59
+ # Dataset Iterators
60
+ # =============================================================================
61
+ # Each iterator yields raw text strings from a HuggingFace dataset stream.
62
+ # Streaming means we never hold the full dataset in memory.
63
+ # =============================================================================
64
+
65
+
66
+ def iter_fineweb_edu():
67
+ """Stream high-quality educational web text from FineWeb-Edu."""
68
+ from datasets import load_dataset
69
+ ds = load_dataset(
70
+ "HuggingFaceFW/fineweb-edu",
71
+ name="sample-10BT",
72
+ split="train",
73
+ streaming=True,
74
+ )
75
+ for example in ds:
76
+ text = example.get("text", "")
77
+ if text:
78
+ yield text
79
+
80
+
81
+ def iter_wikipedia():
82
+ """Stream English Wikipedia articles (script-free wikimedia version)."""
83
+ from datasets import load_dataset
84
+ ds = load_dataset(
85
+ "wikimedia/wikipedia",
86
+ "20231101.en",
87
+ split="train",
88
+ streaming=True,
89
+ )
90
+ for example in ds:
91
+ text = example.get("text", "")
92
+ if text:
93
+ yield text
94
+
95
+
96
+ def iter_gutenberg():
97
+ """Stream public domain books from Project Gutenberg (PG-19 subset)."""
98
+ from datasets import load_dataset
99
+ ds = load_dataset(
100
+ "emozilla/pg19",
101
+ split="train",
102
+ streaming=True,
103
+ )
104
+ for example in ds:
105
+ # pg19 uses "text" for the full book text, with "short_book_title" as metadata
106
+ text = example.get("text", "")
107
+ if text:
108
+ yield text
109
+
110
+
111
+ def iter_starcoder():
112
+ """
113
+ Stream Python code from The Stack v2 (non-gated subset).
114
+
115
+ If you want the full StarCoder dataset instead, first authenticate:
116
+ huggingface-cli login
117
+ Then change 'bigcode/the-stack-v2-train-smol-ids' below to 'bigcode/starcoderdata'.
118
+ """
119
+ from datasets import load_dataset
120
+ try:
121
+ # Try the non-gated smol subset first
122
+ ds = load_dataset(
123
+ "bigcode/the-stack-smol",
124
+ data_dir="data/python",
125
+ split="train",
126
+ streaming=True,
127
+ )
128
+ for example in ds:
129
+ text = example.get("content", "")
130
+ if text:
131
+ yield text
132
+ except Exception:
133
+ # Fallback: use codeparrot's cleaned Python dataset
134
+ ds = load_dataset(
135
+ "codeparrot/codeparrot-clean",
136
+ split="train",
137
+ streaming=True,
138
+ )
139
+ for example in ds:
140
+ text = example.get("content", "")
141
+ if text:
142
+ yield text
143
+
144
+
145
+ # Map source names to their iterators
146
+ SOURCE_ITERATORS = {
147
+ "fineweb_edu": iter_fineweb_edu,
148
+ "wikipedia": iter_wikipedia,
149
+ "gutenberg": iter_gutenberg,
150
+ "starcoder": iter_starcoder,
151
+ }
152
+
153
+
154
+ # =============================================================================
155
+ # Tokenization & Writing
156
+ # =============================================================================
157
+
158
+
159
+ def tokenize_and_write(
160
+ source_name,
161
+ text_iterator,
162
+ target_tokens,
163
+ encoder,
164
+ eot_token,
165
+ train_file,
166
+ val_file,
167
+ val_ratio,
168
+ write_chunk,
169
+ dtype,
170
+ ):
171
+ """
172
+ Tokenize text from an iterator and write tokens to train/val binary files.
173
+
174
+ Each document is separated by an EOT token. Documents are randomly assigned
175
+ to val split with probability val_ratio.
176
+
177
+ Args:
178
+ source_name: Name of the data source (for logging)
179
+ text_iterator: Iterator yielding text strings
180
+ target_tokens: Number of tokens to collect from this source
181
+ encoder: tiktoken encoder
182
+ eot_token: End-of-text token ID
183
+ train_file: Open file handle for train.bin
184
+ val_file: Open file handle for val.bin
185
+ val_ratio: Fraction of documents for validation
186
+ write_chunk: Buffer size before flushing to disk
187
+ dtype: numpy dtype for token storage (uint16 or uint32)
188
+
189
+ Returns:
190
+ (train_tokens_written, val_tokens_written)
191
+ """
192
+ train_buffer = []
193
+ val_buffer = []
194
+ train_total = 0
195
+ val_total = 0
196
+ doc_count = 0
197
+
198
+ rng = np.random.default_rng(seed=42 + hash(source_name) % 10000)
199
+
200
+ pbar = tqdm(
201
+ total=target_tokens,
202
+ unit="tok",
203
+ unit_scale=True,
204
+ desc=f" {source_name}",
205
+ bar_format=" {desc}: {percentage:3.0f}% |{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]",
206
+ )
207
+
208
+ for text in text_iterator:
209
+ # Tokenize the document
210
+ tokens = encoder.encode_ordinary(text)
211
+ tokens.append(eot_token) # Separate documents with EOT
212
+
213
+ # Randomly assign entire documents to train or val
214
+ is_val = rng.random() < val_ratio
215
+
216
+ if is_val:
217
+ val_buffer.extend(tokens)
218
+ val_total += len(tokens)
219
+ # Flush val buffer
220
+ if len(val_buffer) >= write_chunk:
221
+ val_file.write(np.array(val_buffer, dtype=dtype).tobytes())
222
+ val_buffer = []
223
+ else:
224
+ train_buffer.extend(tokens)
225
+ train_total += len(tokens)
226
+ # Flush train buffer
227
+ if len(train_buffer) >= write_chunk:
228
+ train_file.write(np.array(train_buffer, dtype=dtype).tobytes())
229
+ train_buffer = []
230
+
231
+ doc_count += 1
232
+ pbar.update(len(tokens))
233
+
234
+ # Check if we've reached the target
235
+ if train_total + val_total >= target_tokens:
236
+ break
237
+
238
+ # Flush remaining buffers
239
+ if train_buffer:
240
+ train_file.write(np.array(train_buffer, dtype=dtype).tobytes())
241
+ if val_buffer:
242
+ val_file.write(np.array(val_buffer, dtype=dtype).tobytes())
243
+
244
+ pbar.close()
245
+ print(f" {source_name}: {doc_count:,} docs | "
246
+ f"train {train_total:,} + val {val_total:,} = {train_total + val_total:,} tokens")
247
+
248
+ return train_total, val_total
249
+
250
+
251
+ # =============================================================================
252
+ # Main Pipeline
253
+ # =============================================================================
254
+
255
+
256
+ def main():
257
+ parser = argparse.ArgumentParser(
258
+ description="Pretraining Data Pipeline for Baby GLM-5.2",
259
+ formatter_class=argparse.RawDescriptionHelpFormatter,
260
+ )
261
+ parser.add_argument("--output_dir", type=str, default=DEFAULT_OUTPUT_DIR,
262
+ help="Output directory for .bin and .json files")
263
+ parser.add_argument("--total_tokens", type=int, default=DEFAULT_TOTAL_TOKENS,
264
+ help="Total tokens to collect across all sources")
265
+ parser.add_argument("--val_ratio", type=float, default=DEFAULT_VAL_RATIO,
266
+ help="Fraction of documents for validation split")
267
+ parser.add_argument("--write_chunk", type=int, default=DEFAULT_WRITE_CHUNK,
268
+ help="Buffer size (tokens) before flushing to disk")
269
+ parser.add_argument("--no_code", action="store_true",
270
+ help="Exclude code data (StarCoder)")
271
+ args = parser.parse_args()
272
+
273
+ # --- Setup ---
274
+ os.makedirs(args.output_dir, exist_ok=True)
275
+ train_path = os.path.join(args.output_dir, "train.bin")
276
+ val_path = os.path.join(args.output_dir, "val.bin")
277
+ meta_path = os.path.join(args.output_dir, "meta.json")
278
+
279
+ # --- Tokenizer ---
280
+ encoder = tiktoken.get_encoding(DEFAULT_TOKENIZER)
281
+ vocab_size = encoder.n_vocab # 50257 for GPT-2
282
+ eot_token = GPT2_EOT
283
+
284
+ # Determine dtype: uint16 if vocab fits, uint32 otherwise
285
+ if vocab_size <= 65535:
286
+ dtype = np.uint16
287
+ dtype_str = "uint16"
288
+ else:
289
+ dtype = np.uint32
290
+ dtype_str = "uint32"
291
+
292
+ # --- Compute per-source token targets ---
293
+ include_code = not args.no_code
294
+ active_sources = {k: v for k, v in SOURCE_ALLOCATIONS.items()
295
+ if k != "starcoder" or include_code}
296
+
297
+ # Renormalize allocations if code is excluded
298
+ total_alloc = sum(active_sources.values())
299
+ source_targets = {
300
+ k: int(args.total_tokens * v / total_alloc)
301
+ for k, v in active_sources.items()
302
+ }
303
+
304
+ # --- Print Plan ---
305
+ print(f"\n{'='*70}")
306
+ print(f" Pretraining Data Pipeline for Baby GLM-5.2")
307
+ print(f"{'='*70}")
308
+ print(f" Tokenizer: {DEFAULT_TOKENIZER} (vocab_size={vocab_size})")
309
+ print(f" Token dtype: {dtype_str}")
310
+ print(f" Total target: {args.total_tokens:,} tokens")
311
+ print(f" Val ratio: {args.val_ratio:.1%}")
312
+ print(f" Output dir: {os.path.abspath(args.output_dir)}")
313
+ print(f" Code data: {'Yes' if include_code else 'No'}")
314
+ print(f"\n Source Allocation:")
315
+ for name, target in source_targets.items():
316
+ print(f" {name:20s} {target:>14,} tokens ({target/args.total_tokens:.1%})")
317
+ print(f"{'='*70}\n")
318
+
319
+ # --- Process Each Source ---
320
+ total_train = 0
321
+ total_val = 0
322
+ t0 = time.time()
323
+
324
+ with open(train_path, "wb") as train_file, open(val_path, "wb") as val_file:
325
+ for source_name, target in source_targets.items():
326
+ print(f"\n [{source_name}] Streaming {target:,} tokens...")
327
+ iterator_fn = SOURCE_ITERATORS[source_name]
328
+
329
+ try:
330
+ train_written, val_written = tokenize_and_write(
331
+ source_name=source_name,
332
+ text_iterator=iterator_fn(),
333
+ target_tokens=target,
334
+ encoder=encoder,
335
+ eot_token=eot_token,
336
+ train_file=train_file,
337
+ val_file=val_file,
338
+ val_ratio=args.val_ratio,
339
+ write_chunk=args.write_chunk,
340
+ dtype=dtype,
341
+ )
342
+ total_train += train_written
343
+ total_val += val_written
344
+ except Exception as e:
345
+ print(f" [WARN] Error streaming {source_name}: {e}")
346
+ print(f" Skipping this source and continuing...")
347
+ continue
348
+
349
+ elapsed = time.time() - t0
350
+
351
+ # --- Write Metadata ---
352
+ meta = {
353
+ "tokenizer": DEFAULT_TOKENIZER,
354
+ "vocab_size": vocab_size,
355
+ "eot_token": eot_token,
356
+ "dtype": dtype_str,
357
+ "train_tokens": total_train,
358
+ "val_tokens": total_val,
359
+ "total_tokens": total_train + total_val,
360
+ "sources": list(source_targets.keys()),
361
+ "val_ratio": args.val_ratio,
362
+ }
363
+ with open(meta_path, "w") as f:
364
+ json.dump(meta, f, indent=2)
365
+
366
+ # --- Summary ---
367
+ train_size_gb = os.path.getsize(train_path) / 1e9
368
+ val_size_gb = os.path.getsize(val_path) / 1e9
369
+
370
+ print(f"\n{'='*70}")
371
+ print(f" [DONE] Data preparation complete!")
372
+ print(f"{'='*70}")
373
+ print(f" Time elapsed: {elapsed/3600:.1f} hours ({elapsed:.0f}s)")
374
+ print(f" Train tokens: {total_train:,}")
375
+ print(f" Val tokens: {total_val:,}")
376
+ print(f" Total tokens: {total_train + total_val:,}")
377
+ print(f" train.bin: {train_size_gb:.2f} GB")
378
+ print(f" val.bin: {val_size_gb:.2f} GB")
379
+ print(f" meta.json: {meta_path}")
380
+ print(f"\n Next step: python train_glm5.py --data_dir {args.output_dir}")
381
+ print(f"{'='*70}\n")
382
+
383
+
384
+ if __name__ == "__main__":
385
+ main()
llm_training_guide.md ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🧠 The Beginner's Guide to Understanding Your LLM Training Run
2
+
3
+ *Tailored for your GLM-5.2 Baby 120M pretraining on RTX 4050*
4
+
5
+ ---
6
+
7
+ ## Part 1: What Do All These Numbers Mean?
8
+
9
+ When you see a log line like this:
10
+
11
+ ```
12
+ step 52000 | train 4.1230 | val 4.1071 | lr 5.51e-04
13
+ ```
14
+
15
+ Here's what each piece tells you:
16
+
17
+ ### 📉 Loss (train & val)
18
+
19
+ **Loss** = how wrong the model is. Lower = better.
20
+
21
+ Your model is trying to predict the next word (token) in a sequence. The loss measures how surprised the model is by the correct answer. Think of it like a quiz score — but inverted: **4.10 is better than 4.30**.
22
+
23
+ | Term | What it means | Your value |
24
+ |------|--------------|------------|
25
+ | **Training loss** | Error on data the model is actively learning from | `4.1230` |
26
+ | **Validation loss** | Error on data the model has **never seen** (the real test) | `4.1071` |
27
+
28
+ > [!IMPORTANT]
29
+ > **Always trust validation loss over training loss.** Training loss can go down just because the model memorizes data. Validation loss tells you if it's actually *learning patterns*.
30
+
31
+ #### What's "good" for a 120M model?
32
+
33
+ - **Loss > 5.0** → The model is basically guessing randomly
34
+ - **Loss ~4.0–4.5** → It's learning basic grammar and common patterns ← **You are here**
35
+ - **Loss ~3.5–4.0** → It understands sentence structure well
36
+ - **Loss ~3.0–3.5** → Coherent paragraphs, factual fragments
37
+ - **Loss < 3.0** → Very strong for this model size (hard to achieve with 120M params)
38
+
39
+ ### 📐 Learning Rate (lr)
40
+
41
+ ```
42
+ lr 5.51e-04 → this means 0.000551
43
+ ```
44
+
45
+ **Learning rate** = how big of a step the model takes when adjusting its weights after each batch.
46
+
47
+ - **Too high** → Model overshoots and loss spikes or goes to NaN (crashes)
48
+ - **Too low** → Model barely changes and training takes forever
49
+ - **Just right** → Steady decrease in loss
50
+
51
+ Your training uses a **cosine schedule with warmup**. Here's what that looks like:
52
+
53
+ ```
54
+ Learning Rate over time:
55
+
56
+ 6e-4 | /‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
57
+ | / \
58
+ | / \
59
+ | / \
60
+ 6e-5 |/ \___
61
+ +----+--------+--------+--------+--------+
62
+ 0 1.5k 65k 130k 195k 260k
63
+ warmup steps →
64
+
65
+ Phase: [WARM] [——— COSINE DECAY ———————] [MIN]
66
+ ```
67
+
68
+ **What this means for YOUR run:**
69
+ - Steps 0–1,500: LR ramped up from 0 → `6e-4` (warmup)
70
+ - Steps 1,500–260,000: LR slowly decays from `6e-4` → `6e-5` following a cosine curve
71
+ - At step 52,000 you're at `5.51e-04` — still very close to the peak!
72
+ - **The big improvements come later** when the LR drops significantly (after ~130k steps)
73
+
74
+ ### ⚡ Tokens per Second (tok/s)
75
+
76
+ ```
77
+ 4,259 tok/s
78
+ ```
79
+
80
+ This is your training speed — how many tokens (≈words) the model processes per second. Higher = faster training. Your RTX 4050 is pushing ~4,000-5,000 tok/s, which is solid for a laptop GPU.
81
+
82
+ ### 💾 VRAM
83
+
84
+ ```
85
+ VRAM 4.78GB
86
+ ```
87
+
88
+ How much GPU memory you're using. Your GPU has 6GB total, so 4.78GB means you have a ~1.2GB safety buffer. If this ever hits 6GB → Out of Memory crash (OOM).
89
+
90
+ ---
91
+
92
+ ## Part 2: How to Tell if Training is Going Well
93
+
94
+ ### ✅ Signs of Healthy Training
95
+
96
+ 1. **Val loss is trending down over thousands of steps** (not every single eval, but the overall trend)
97
+ 2. **Train loss and val loss are close together** (no big gap)
98
+ 3. **No NaN or Inf in loss** (that would mean the training exploded)
99
+ 4. **No sudden loss spikes** that don't recover
100
+
101
+ ### ⚠️ Warning Signs
102
+
103
+ | Symptom | What it means | What to do |
104
+ |---------|--------------|------------|
105
+ | Val loss goes **up** while train loss goes **down** | **Overfitting** — memorizing instead of learning | Stop training, use more data, or add regularization |
106
+ | Val loss **flat for 20,000+ steps** | **Plateau** — model may be stuck | Often resolves when LR decays; be patient |
107
+ | Loss suddenly shoots to **100+** or **NaN** | **Training instability** | Reduce learning rate, check data for corruption |
108
+ | Train loss **very noisy** (swings of ±1.0) | **Batch size too small** | Increase `gradient_accumulation_steps` |
109
+
110
+ ### 📊 Your Run's Health Check
111
+
112
+ ```
113
+ Val Loss Trajectory:
114
+ Step 20k: 4.31 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░
115
+ Step 24k: 4.27 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░
116
+ Step 28k: 4.22 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░
117
+ Step 30k: 4.19 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░
118
+ Step 36k: 4.16 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░
119
+ Step 40k: 4.12 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░ ← plateau started here
120
+ Step 50k: 4.18 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░��░ ← bouncing around
121
+ Step 52k: 4.11 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░ ← NEW BEST! Plateau broken 🎉
122
+ ```
123
+
124
+ **Verdict: Your training is healthy.** The 10k-step plateau (40k–50k) was normal — it just broke through at step 52k with a new best of `4.1071`.
125
+
126
+ ---
127
+
128
+ ## Part 3: Key Concepts You Should Know
129
+
130
+ ### 🎯 Chinchilla Scaling Laws (The "20:1 Rule")
131
+
132
+ DeepMind discovered in 2022 that for optimal training, you should use roughly **20 tokens of data per parameter**.
133
+
134
+ **Your model:**
135
+ - Parameters: ~120M
136
+ - Chinchilla-optimal data: 120M × 20 = **2.4B tokens**
137
+ - Your planned data: **2.4B tokens** ✅ (2.0B Phase 2 + 0.4B Phase 3)
138
+
139
+ You're right on target! This means your model should be trained to near its full potential by the time you finish.
140
+
141
+ ### 🔄 Gradient Accumulation (Why `batch_size 6 × grad_accum 3`)
142
+
143
+ Your GPU can only fit 6 sequences in VRAM at once. But training works better with larger "effective" batches (more stable gradients). **Gradient accumulation** is a trick:
144
+
145
+ ```
146
+ Step 1: Process 6 sequences → compute gradients (DON'T update weights yet)
147
+ Step 2: Process 6 more sequences → add gradients to step 1's
148
+ Step 3: Process 6 more sequences → add gradients again
149
+ → NOW update weights using all 18 sequences' worth of gradients!
150
+ ```
151
+
152
+ Effective batch = `6 × 3 = 18 sequences × 512 tokens = 9,216 tokens per weight update`
153
+
154
+ ### 🧊 Why Mixed Precision (bfloat16) Matters
155
+
156
+ Normally, numbers in the model use 32 bits (float32). **bfloat16** uses only 16 bits:
157
+ - **Pro:** Uses ~half the VRAM, runs ~2× faster on Tensor Cores
158
+ - **Con:** Slightly less precise math
159
+ - **Net result:** Massive win. This is why you can fit a 120M model in 6GB
160
+
161
+ ### 🏔️ Gradient Checkpointing
162
+
163
+ Normally, the GPU stores ALL intermediate calculations during the forward pass (to use during backpropagation). With gradient checkpointing:
164
+ - GPU **throws away** intermediate results to save VRAM
165
+ - During backpropagation, it **recomputes** them on the fly
166
+ - **Trade:** ~30% slower training, but ~40% less VRAM
167
+
168
+ This is enabled in your run and is essential for fitting in 6GB.
169
+
170
+ ---
171
+
172
+ ## Part 4: Your Cosine Schedule — Why Patience Pays Off
173
+
174
+ Here's something critical to understand about your specific run:
175
+
176
+ ```
177
+ Your LR decay spans 260,000 steps, but this run goes to 110,000.
178
+
179
+ At step 52,000:
180
+ - You're 20% through the cosine schedule
181
+ - LR has only dropped from 6.00e-4 → 5.51e-4 (an 8% decrease)
182
+ - The model is still taking BIG learning steps
183
+
184
+ At step 110,000 (end of this run):
185
+ - You'll be 42% through the cosine schedule
186
+ - LR will be around ~4.4e-4
187
+ - Model will be learning at a moderate pace
188
+
189
+ The BIGGEST gains happen in Phase 3 (steps 110k→260k):
190
+ - LR drops dramatically from 4.4e-4 → 6e-5
191
+ - This is where the model "settles in" and polishes its knowledge
192
+ - Think of it as: early training = rough sketch, late training = fine details
193
+ ```
194
+
195
+ > [!TIP]
196
+ > **This is why you saw a plateau.** The LR is still high enough that the model is "bouncing around" in the loss landscape. As LR decreases, these oscillations will shrink and the loss will drop more smoothly.
197
+
198
+ ---
199
+
200
+ ## Part 5: 📚 Must-Read Resources (Ordered by Difficulty)
201
+
202
+ ### 🟢 Beginner — Start Here
203
+
204
+ | # | Resource | What You'll Learn | Link |
205
+ |---|----------|-------------------|------|
206
+ | 1 | **Karpathy: "Let's build GPT: from scratch"** (YouTube, ~2hrs) | How transformers work, attention, the training loop — all coded live | [YouTube](https://www.youtube.com/watch?v=kCc8FmEb1nY) |
207
+ | 2 | **Karpathy: "A Recipe for Training Neural Networks"** (Blog) | THE guide for debugging training. When loss is weird, read this. | [karpathy.github.io](https://karpathy.github.io/2019/04/25/recipe/) |
208
+ | 3 | **3Blue1Brown: "But what is a neural network?"** (YouTube) | Visual intuition for how neural nets learn | [YouTube](https://www.youtube.com/watch?v=aircAruvnKk) |
209
+
210
+ ### 🟡 Intermediate — After You've Done the Above
211
+
212
+ | # | Resource | What You'll Learn | Link |
213
+ |---|----------|-------------------|------|
214
+ | 4 | **Karpathy: "Let's reproduce GPT-2 (124M)"** (YouTube, ~4hrs) | EXACTLY what you're doing — pretraining a GPT from scratch, optimized | [YouTube](https://www.youtube.com/watch?v=l8pRSuU81PU) |
215
+ | 5 | **Sebastian Raschka: "Build a Large Language Model (From Scratch)"** (Book + GitHub) | Full pipeline: tokenization → training → finetuning, with code | [GitHub](https://github.com/rasbt/LLMs-from-scratch) |
216
+ | 6 | **Chinchilla Paper (Summary)** | Why 20 tokens/param matters, scaling laws | Search: "Chinchilla scaling laws explained" |
217
+
218
+ ### 🔴 Advanced — When You Want to Go Deeper
219
+
220
+ | # | Resource | What You'll Learn | Link |
221
+ |---|----------|-------------------|------|
222
+ | 7 | **EleutherAI: LM Evaluation Harness** | How to properly benchmark your model beyond just val loss | [GitHub](https://github.com/EleutherAI/lm-evaluation-harness) |
223
+ | 8 | **DeepSeek-V3 Technical Report** | The MLA + MoE architecture your model is based on | [arXiv](https://arxiv.org/abs/2412.19437) |
224
+ | 9 | **Sebastian Raschka: "Ahead of AI" newsletter** | Weekly updates on LLM research and training techniques | [Substack](https://magazine.sebastianraschka.com/) |
225
+
226
+ ---
227
+
228
+ ## Part 6: Quick Glossary
229
+
230
+ | Term | Plain English |
231
+ |------|--------------|
232
+ | **Token** | A piece of a word. "training" → ["train", "ing"]. Your model sees tokens, not words. |
233
+ | **Epoch** | One full pass through all training data. You won't complete 1 full epoch in this run. |
234
+ | **Perplexity** | `e^loss` — another way to express loss. Your val loss 4.11 = perplexity ~61. Means "the model is choosing between ~61 equally likely next tokens." |
235
+ | **Overfitting** | Model memorizes training data instead of learning general patterns. Val loss goes up while train loss keeps going down. |
236
+ | **Underfitting** | Model hasn't learned enough yet. Both losses are still high. |
237
+ | **Cosine decay** | LR schedule that follows a cosine curve from high → low. Industry standard. |
238
+ | **AdamW** | The optimizer (the algorithm that updates weights). It's what everyone uses for LLMs. |
239
+ | **Gradient clipping** | Caps the size of gradient updates to prevent explosions. Your clip = 1.0. |
240
+ | **MoE** | Mixture of Experts — only some "expert" sub-networks activate per token, making the model bigger without proportionally more compute. |
241
+ | **MLA** | Multi-Latent Attention — compresses the attention mechanism using LoRA-style projections to save VRAM. |
242
+ | **DSA** | DeepSeek Sparse Attention — selects only the most relevant tokens to attend to, instead of attending to everything. |
243
+
244
+ ---
245
+
246
+ > [!NOTE]
247
+ > **The single most important resource for you right now is Karpathy's ["Let's reproduce GPT-2 (124M)"](https://www.youtube.com/watch?v=l8pRSuU81PU) video.** It covers the exact same workflow you're doing — pretraining a ~124M parameter model from scratch on a single GPU with the same optimizer, LR schedule, and training loop design. Your GLM-5.2 script is heavily inspired by nanoGPT, so it will feel very familiar.
248
+
249
+ ---
250
+
251
+ *Your training is going well. Val loss 4.1071 at step 52k is solid progress. Keep it running!* 🚀
out_glm5/data/input.txt ADDED
The diff for this file is too large to render. See raw diff
 
out_glm5/data/val.npy ADDED
Binary file (67.7 kB). View file
 
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Requirements for "Let's Reproduce GLM-5.2"
2
+ # Install with: pip install -r requirements.txt
3
+
4
+ # Core
5
+ torch>=2.1.0
6
+ numpy>=1.24.0
7
+
8
+ # Tokenization (GPT-2 BPE tokenizer)
9
+ tiktoken>=0.5.0
10
+
11
+ # Data Preparation (used by dataprep_pretrain.py)
12
+ datasets>=2.14.0
13
+ transformers>=4.30.0
14
+
15
+ # Progress bars
16
+ tqdm>=4.65.0
train_glm5.py ADDED
@@ -0,0 +1,1380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Let's Reproduce GLM-5.2 (GLM MoE DSA) — From Scratch!
3
+ =======================================================
4
+
5
+ A baby version of GLM-5.2 (zhipu-ai / zai-org / GLM-5), trained from scratch.
6
+
7
+ GLM-5.2 combines three cutting-edge innovations:
8
+ 1. MLA (Multi-Latent Attention) — LoRA-compressed Q and KV projections
9
+ 2. DSA (DeepSeek Sparse Attention) — top-k token selection via a learned indexer
10
+ 3. MoE (Mixture of Experts) — sigmoid-routed fine-grained experts + shared expert
11
+
12
+ This script implements ALL of these from scratch in a single file,
13
+ scaled down to ~120M parameters for training on a single consumer GPU.
14
+
15
+ Architecture Reference:
16
+ HuggingFace transformers — models/glm_moe_dsa/modeling_glm_moe_dsa.py
17
+
18
+ Paper References:
19
+ - DeepSeek-V3 (MLA + MoE): https://arxiv.org/abs/2412.19437
20
+ - DeepSeek Sparse Attention: https://arxiv.org/abs/2603.12201
21
+ - GLM: https://github.com/THUDM/GLM
22
+
23
+ Inspired by Andrej Karpathy's "Let's reproduce GPT-2" and nanoGPT.
24
+
25
+ Usage:
26
+ python train_glm5.py # Train with defaults (RTX 4050 friendly)
27
+ python train_glm5.py --batch_size 2 # Smaller batch for less VRAM
28
+ python train_glm5.py --compile # Use torch.compile (faster, needs warmup)
29
+ python train_glm5.py --eval_only --ckpt out/ckpt.pt # Generate from a checkpoint
30
+ python train_glm5.py --no_gradient_checkpointing # Disable grad checkpointing (needs more VRAM)
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import math
36
+ import os
37
+ import time
38
+ from dataclasses import dataclass
39
+
40
+ import numpy as np
41
+ import tiktoken
42
+ import torch
43
+ import torch.nn as nn
44
+ import torch.nn.functional as F
45
+ import torch.utils.checkpoint
46
+
47
+ # =============================================================================
48
+ # Section 1: Model Configuration
49
+ # =============================================================================
50
+ # The full GLM-5.2 has 78 layers, 6144 hidden, 256 experts — far too large.
51
+ # We scale everything down to ~120M total params while preserving EVERY
52
+ # architectural innovation. Think of this as "baby GLM-5.2".
53
+ # =============================================================================
54
+
55
+
56
+ @dataclass
57
+ class GLM5Config:
58
+ """
59
+ Configuration for baby GLM-5.2.
60
+ Default values give a ~159M total param model (~82M non-embedding),
61
+ trainable on a single RTX 4050 (6GB VRAM) with gradient checkpointing + bf16.
62
+
63
+ The full GLM-5.2 values are shown in comments for reference.
64
+ """
65
+
66
+ # --- Vocabulary & Embedding ---
67
+ vocab_size: int = 50304 # GPT-2 tokenizer (50257) padded to nearest 128 [full: 154880]
68
+
69
+ # --- Core Dimensions ---
70
+ hidden_size: int = 768 # Model width (d_model) [full: 6144]
71
+ num_hidden_layers: int = 12 # Total decoder layers [full: 78]
72
+
73
+ # --- Multi-Latent Attention (MLA) ---
74
+ num_attention_heads: int = 12 # Number of query heads [full: 64]
75
+ q_lora_rank: int = 384 # Query LoRA bottleneck [full: 2048]
76
+ kv_lora_rank: int = 128 # Key/Value LoRA bottleneck [full: 512]
77
+ qk_nope_head_dim: int = 32 # Non-rotary Q/K head dim [full: 192]
78
+ qk_rope_head_dim: int = 32 # Rotary Q/K head dim [full: 64]
79
+ v_head_dim: int = 64 # Value head dim [full: 256]
80
+
81
+ # --- Dense MLP ---
82
+ intermediate_size: int = 2048 # Dense FFN intermediate dim [full: 12288]
83
+ hidden_act: str = "silu" # Activation function
84
+
85
+ # --- Mixture of Experts (MoE) ---
86
+ first_k_dense_replace: int = 3 # First K layers use dense MLP [full: 3]
87
+ moe_intermediate_size: int = 256 # Per-expert FFN intermediate dim [full: 2048]
88
+ n_routed_experts: int = 8 # Number of routed experts [full: 256]
89
+ num_experts_per_tok: int = 2 # Top-k experts per token [full: 8]
90
+ n_shared_experts: int = 1 # Always-active shared experts [full: 1]
91
+ n_group: int = 1 # Expert groups for routing [full: 1]
92
+ topk_group: int = 1 # Top groups selected [full: 1]
93
+ routed_scaling_factor: float = 2.5 # Expert weight scaling [full: 2.5]
94
+ norm_topk_prob: bool = True # Normalize routing probabilities
95
+
96
+ # --- DeepSeek Sparse Attention (DSA) ---
97
+ index_topk: int = 256 # Top tokens selected by DSA indexer [full: 2048]
98
+ index_head_dim: int = 64 # Head dim in DSA indexer [full: 128]
99
+ index_n_heads: int = 12 # Heads in DSA indexer [full: 32]
100
+
101
+ # --- Positional Encoding ---
102
+ max_position_embeddings: int = 4096 # Max context length [full: 202752]
103
+ rope_theta: float = 10000.0 # RoPE base frequency
104
+
105
+ # --- Regularization & Precision ---
106
+ rms_norm_eps: float = 1e-5 # RMSNorm epsilon
107
+ attention_dropout: float = 0.0 # Attention dropout
108
+
109
+ # --- Initialization ---
110
+ initializer_range: float = 0.02 # Std dev for weight init
111
+
112
+ # --- Weight Tying ---
113
+ # if False, Total model param = 158.8 M params. If True param cout changes to around 120 M
114
+ tie_word_embeddings: bool = True # Tie embed + lm_head [full: False]
115
+
116
+ def __post_init__(self):
117
+ self.qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
118
+ # Per-layer MLP type: first K layers = dense, rest = MoE (sparse)
119
+ n_dense = min(self.first_k_dense_replace, self.num_hidden_layers)
120
+ self.mlp_layer_types = ["dense"] * n_dense + ["sparse"] * (self.num_hidden_layers - n_dense)
121
+ # DSA indexer pattern: alternating "full" (run indexer) / "shared" (reuse previous)
122
+ # Full GLM-5.2 uses a freq/offset schedule; we simplify to alternating.
123
+ self.indexer_types = [
124
+ "full" if i % 2 == 0 else "shared" for i in range(self.num_hidden_layers)
125
+ ]
126
+
127
+
128
+ # =============================================================================
129
+ # Section 2: Architecture Components
130
+ # =============================================================================
131
+ # We build each component bottom-up, with detailed comments explaining
132
+ # WHY each design choice was made in GLM-5.2.
133
+ # =============================================================================
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # 2a: RMSNorm — Root Mean Square Layer Normalization
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ class RMSNorm(nn.Module):
141
+ """
142
+ RMSNorm (Root Mean Square Layer Normalization).
143
+
144
+ Unlike LayerNorm, RMSNorm does NOT center activations (no mean subtraction).
145
+ This is cheaper and works just as well for LLMs.
146
+
147
+ Formula: output = x / sqrt(mean(x²) + eps) * weight
148
+ """
149
+
150
+ def __init__(self, hidden_size, eps=1e-6):
151
+ super().__init__()
152
+ self.weight = nn.Parameter(torch.ones(hidden_size))
153
+ self.eps = eps
154
+
155
+ def forward(self, x):
156
+ input_dtype = x.dtype
157
+ x = x.float() # Always compute in float32 for numerical stability
158
+ variance = x.pow(2).mean(-1, keepdim=True)
159
+ x = x * torch.rsqrt(variance + self.eps)
160
+ return self.weight * x.to(input_dtype)
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # 2b: Rotary Position Embedding (RoPE) — Interleaved variant
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ class RotaryEmbedding(nn.Module):
169
+ """
170
+ Standard Rotary Position Embedding (RoPE).
171
+
172
+ Computes cos/sin tables for position encoding. The actual rotation is
173
+ applied by apply_rotary_pos_emb_interleave() — see below.
174
+ """
175
+
176
+ def __init__(self, dim, max_position_embeddings=4096, base=10000.0, device=None):
177
+ super().__init__()
178
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float, device=device) / dim))
179
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
180
+
181
+ @torch.no_grad()
182
+ def forward(self, x, position_ids):
183
+ """
184
+ Args:
185
+ x: [B, T, D] — only used for dtype/device reference
186
+ position_ids: [B, T]
187
+ Returns:
188
+ cos, sin: each [B, T, dim]
189
+ """
190
+ inv_freq = self.inv_freq[None, :, None].expand(position_ids.shape[0], -1, 1)
191
+ pos = position_ids[:, None, :].float()
192
+ freqs = (inv_freq.float() @ pos.float()).transpose(1, 2) # [B, T, dim/2]
193
+ emb = torch.cat((freqs, freqs), dim=-1) # [B, T, dim]
194
+ return emb.cos().to(x.dtype), emb.sin().to(x.dtype)
195
+
196
+
197
+ def apply_rotary_pos_emb_interleave(q, k, cos, sin, unsqueeze_dim=1):
198
+ """
199
+ Apply INTERLEAVED Rotary Position Embedding.
200
+
201
+ GLM-5.2 (and DeepSeek) uses interleaved RoPE pairs: (x0,x1), (x2,x3), ...
202
+ Each pair is rotated by a single frequency.
203
+
204
+ This is DIFFERENT from standard LLaMA-style RoPE which splits the
205
+ first/second half of the head dimension. The interleaved version avoids
206
+ memory-shuffling copies from 'rotate_half'.
207
+
208
+ ┌──────────────────────────────────────────────────────────────────┐
209
+ │ Standard RoPE: [x0..x_d/2 | x_d/2..x_d] → rotate halves │
210
+ │ Interleaved RoPE: [x0,x1 | x2,x3 | ...] → rotate pairs │
211
+ └──────────────────────────────────────────────────────────────────┘
212
+ """
213
+ # cos/sin come as cat(freqs, freqs) → take the first half
214
+ cos = cos[..., : cos.shape[-1] // 2].unsqueeze(unsqueeze_dim)
215
+ sin = sin[..., : sin.shape[-1] // 2].unsqueeze(unsqueeze_dim)
216
+
217
+ # Split into even and odd indexed elements (the interleaved pairs)
218
+ q1, q2 = q[..., 0::2], q[..., 1::2]
219
+ k1, k2 = k[..., 0::2], k[..., 1::2]
220
+
221
+ # Apply 2D rotation to each (even, odd) pair
222
+ q_embed = torch.cat([q1 * cos - q2 * sin, q2 * cos + q1 * sin], dim=-1)
223
+ k_embed = torch.cat([k1 * cos - k2 * sin, k2 * cos + k1 * sin], dim=-1)
224
+ return q_embed, k_embed
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # 2c: Gated MLP (SwiGLU)
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ class GatedMLP(nn.Module):
233
+ """
234
+ SwiGLU MLP: down_proj( SiLU(gate_proj(x)) ⊙ up_proj(x) )
235
+
236
+ The gating mechanism (SiLU on gate, element-wise multiply with up)
237
+ consistently outperforms vanilla ReLU/GELU FFNs in modern LLMs.
238
+ Used in LLaMA, DeepSeek, GLM, Gemma, Qwen, and many others.
239
+ """
240
+
241
+ def __init__(self, hidden_size, intermediate_size):
242
+ super().__init__()
243
+ self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
244
+ self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
245
+ self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=False)
246
+
247
+ def forward(self, x):
248
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # 2d: Top-K Expert Router (Sigmoid-based, DeepSeek-style)
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ class TopKRouter(nn.Module):
257
+ """
258
+ Sigmoid-based top-k expert router with bias correction.
259
+
260
+ Key differences from the traditional softmax MoE router:
261
+ ┌──────────────────────────────────────────────────────────────────┐
262
+ │ 1. SIGMOID scoring (not softmax) — experts scored independently │
263
+ │ 2. Correction bias — loaded from checkpoint, helps balance load │
264
+ │ 3. Group routing — select top groups, then experts within them │
265
+ │ 4. Normalize + scale — weights normalized then scaled by 2.5x │
266
+ └──────────────────────────────────────────────────────────────────┘
267
+
268
+ The sigmoid approach prevents "expert collapse" where softmax routing
269
+ causes only a few experts to receive all the tokens.
270
+ """
271
+
272
+ def __init__(self, config):
273
+ super().__init__()
274
+ self.top_k = config.num_experts_per_tok
275
+ self.num_experts = config.n_routed_experts
276
+ self.hidden_dim = config.hidden_size
277
+ self.routed_scaling_factor = config.routed_scaling_factor
278
+ self.n_group = config.n_group
279
+ self.topk_group = config.topk_group
280
+ self.norm_topk_prob = config.norm_topk_prob
281
+
282
+ # Router weight: one logit per expert
283
+ self.weight = nn.Parameter(torch.empty(self.num_experts, self.hidden_dim))
284
+ # Correction bias: pretrained load-balancing signal (zeros for training from scratch)
285
+ self.register_buffer("e_score_correction_bias", torch.zeros(self.num_experts))
286
+
287
+ def forward(self, x):
288
+ x_flat = x.view(-1, self.hidden_dim)
289
+
290
+ # Step 1: Sigmoid scoring (NOT softmax!)
291
+ # Each expert gets an independent 0-1 probability
292
+ router_logits = F.linear(x_flat.float(), self.weight.float())
293
+ scores = router_logits.sigmoid()
294
+ scores_for_choice = scores + self.e_score_correction_bias
295
+
296
+ # Step 2: Group-based routing
297
+ # With n_group=1 (our config), this is standard top-k.
298
+ # With n_group>1 (full GLM-5.2), first select best groups, then pick
299
+ # top experts only from those groups — prevents cross-group interference.
300
+ group_scores = (
301
+ scores_for_choice.view(-1, self.n_group, self.num_experts // self.n_group)
302
+ .topk(2, dim=-1)[0]
303
+ .sum(dim=-1)
304
+ )
305
+ group_idx = torch.topk(group_scores, k=self.topk_group, dim=-1, sorted=False)[1]
306
+ group_mask = torch.zeros_like(group_scores)
307
+ group_mask.scatter_(1, group_idx, 1)
308
+ score_mask = (
309
+ group_mask.unsqueeze(-1)
310
+ .expand(-1, self.n_group, self.num_experts // self.n_group)
311
+ .reshape(-1, self.num_experts)
312
+ )
313
+ scores_for_choice = scores_for_choice.masked_fill(~score_mask.bool(), float("-inf"))
314
+
315
+ # Step 3: Select top-k experts per token
316
+ topk_indices = torch.topk(scores_for_choice, k=self.top_k, dim=-1, sorted=False)[1]
317
+ topk_weights = scores.gather(1, topk_indices)
318
+
319
+ # Step 4: Normalize probabilities and scale
320
+ if self.norm_topk_prob:
321
+ topk_weights = topk_weights / (topk_weights.sum(dim=-1, keepdim=True) + 1e-20)
322
+ topk_weights = topk_weights * self.routed_scaling_factor
323
+
324
+ return topk_weights, topk_indices
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # 2e: MoE Expert Collection (Batched 3D Tensors)
329
+ # ---------------------------------------------------------------------------
330
+
331
+
332
+ class MoEExperts(nn.Module):
333
+ """
334
+ Collection of expert MLPs stored as batched 3D parameter tensors.
335
+
336
+ Instead of N separate nn.Linear modules, we store ALL expert weights
337
+ in single tensors. This enables efficient batched dispatch.
338
+
339
+ gate_up_proj: [num_experts, 2*intermediate, hidden]
340
+ down_proj: [num_experts, hidden, intermediate]
341
+
342
+ Each expert computes: SiLU(gate(x)) * up(x) → down → output
343
+
344
+ NOTE: This is the naive loop implementation. Production systems (DeepSeek,
345
+ GLM-5.2) use custom CUDA kernels for grouped GEMM — orders of magnitude faster.
346
+ """
347
+
348
+ def __init__(self, config):
349
+ super().__init__()
350
+ self.num_experts = config.n_routed_experts
351
+ self.hidden_dim = config.hidden_size
352
+ self.intermediate_dim = config.moe_intermediate_size
353
+
354
+ # Fused gate+up projection: [E, 2*I, D]
355
+ self.gate_up_proj = nn.Parameter(
356
+ torch.empty(self.num_experts, 2 * self.intermediate_dim, self.hidden_dim)
357
+ )
358
+ # Down projection: [E, D, I]
359
+ self.down_proj = nn.Parameter(
360
+ torch.empty(self.num_experts, self.hidden_dim, self.intermediate_dim)
361
+ )
362
+
363
+ def forward(self, x, topk_indices, topk_weights):
364
+ """
365
+ Route tokens to their selected experts and accumulate outputs.
366
+
367
+ Args:
368
+ x: [num_tokens, hidden_dim]
369
+ topk_indices: [num_tokens, top_k] — which experts each token uses
370
+ topk_weights: [num_tokens, top_k] — routing weights
371
+ """
372
+ final = torch.zeros_like(x)
373
+
374
+ # Build per-expert assignment mask
375
+ with torch.no_grad():
376
+ expert_mask = F.one_hot(topk_indices, self.num_experts) # [tokens, top_k, E]
377
+ expert_mask = expert_mask.permute(2, 1, 0) # [E, top_k, tokens]
378
+ expert_hit = (expert_mask.sum(dim=(-1, -2)) > 0).nonzero()
379
+
380
+ # Process each active expert
381
+ for idx in expert_hit:
382
+ e = idx[0]
383
+ top_k_pos, token_idx = torch.where(expert_mask[e])
384
+ current = x[token_idx]
385
+
386
+ # SwiGLU: SiLU(gate) * up → down
387
+ gate, up = F.linear(current, self.gate_up_proj[e]).chunk(2, dim=-1)
388
+ hidden = F.silu(gate) * up
389
+ out = F.linear(hidden, self.down_proj[e])
390
+
391
+ # Weight by routing probability and accumulate
392
+ out = out * topk_weights[token_idx, top_k_pos, None]
393
+ final.index_add_(0, token_idx, out.to(final.dtype))
394
+
395
+ return final
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # 2f: Full MoE Block (Router + Routed Experts + Shared Expert)
400
+ # ---------------------------------------------------------------------------
401
+
402
+
403
+ class MoEBlock(nn.Module):
404
+ """
405
+ Full Mixture-of-Experts block.
406
+
407
+ Output = Routed_Experts(x) + Shared_Expert(x)
408
+
409
+ The shared expert ALWAYS processes all tokens — it provides a stable
410
+ "backbone" of computation. The routed experts add specialized capacity
411
+ for different types of tokens/patterns.
412
+ """
413
+
414
+ def __init__(self, config):
415
+ super().__init__()
416
+ self.gate = TopKRouter(config)
417
+ self.experts = MoEExperts(config)
418
+ # Shared expert: always-on, processes every token unconditionally
419
+ self.shared_experts = GatedMLP(
420
+ config.hidden_size,
421
+ config.moe_intermediate_size * config.n_shared_experts,
422
+ )
423
+
424
+ def forward(self, x):
425
+ residual = x
426
+ orig_shape = x.shape
427
+ topk_weights, topk_indices = self.gate(x)
428
+ x = x.view(-1, x.shape[-1])
429
+ x = self.experts(x, topk_indices, topk_weights).view(*orig_shape)
430
+ x = x + self.shared_experts(residual)
431
+ return x
432
+
433
+
434
+ # ---------------------------------------------------------------------------
435
+ # 2g: DeepSeek Sparse Attention (DSA) Indexer
436
+ # ---------------------------------------------------------------------------
437
+
438
+
439
+ class DSAIndexer(nn.Module):
440
+ """
441
+ DeepSeek Sparse Attention (DSA) Indexer.
442
+
443
+ THE key innovation of DSA: instead of attending to ALL past tokens (O(n²)),
444
+ the indexer selects only the top-k most relevant tokens per query position.
445
+ This makes long-context attention tractable (O(n·k) where k << n).
446
+
447
+ Architecture:
448
+ ┌─────────────────────────────────────────────────────────────────────┐
449
+ │ 1. Separate Q/K projections (NOT shared with main MLA attention) │
450
+ │ 2. Multi-head dot-product scoring with ReLU (not softmax!) │
451
+ │ 3. Learned per-head importance weights for aggregation │
452
+ │ 4. Returns top-k token indices for the main attention to use │
453
+ └─────────────────────────────────────────────────────────────────────┘
454
+
455
+ NOTE: The @torch.no_grad() decorator matches the official implementation.
456
+ The indexer doesn't backpropagate gradients — in production GLM-5.2, it's
457
+ pre-trained separately. For training from scratch, the random-but-causal
458
+ token selection acts as attention regularization. The model learns to be
459
+ robust to approximate attention through its main MLA weights.
460
+ """
461
+
462
+ def __init__(self, config, layer_idx):
463
+ super().__init__()
464
+ self.hidden_size = config.hidden_size
465
+ self.n_heads = config.index_n_heads
466
+ self.head_dim = config.index_head_dim
467
+ self.qk_rope_head_dim = config.qk_rope_head_dim
468
+ self.index_topk = config.index_topk
469
+ self.q_lora_rank = config.q_lora_rank
470
+
471
+ # The indexer has its OWN projections — completely separate from main attention!
472
+ self.wq_b = nn.Linear(self.q_lora_rank, self.n_heads * self.head_dim, bias=False)
473
+ self.wk = nn.Linear(self.hidden_size, self.head_dim, bias=False)
474
+ self.k_norm = nn.LayerNorm(self.head_dim, eps=1e-6)
475
+ # Learned per-head importance: "how much should we trust each head's score?"
476
+ self.weights_proj = nn.Linear(self.hidden_size, self.n_heads, bias=False)
477
+ self.softmax_scale = self.head_dim**-0.5
478
+
479
+ @torch.no_grad()
480
+ def forward(self, hidden_states, q_resid, cos, sin, position_ids):
481
+ """
482
+ Select top-k most relevant tokens for each query position.
483
+
484
+ Args:
485
+ hidden_states: [B, S, hidden_size] — input to this layer
486
+ q_resid: [B, S, q_lora_rank] — query residual from MLA's q_a_layernorm
487
+ cos, sin: position embeddings
488
+ position_ids: [B, S]
489
+
490
+ Returns:
491
+ topk_indices: [B, S, topk] — indices of selected tokens (int32)
492
+ """
493
+ B, S, _ = hidden_states.shape
494
+
495
+ # --- Query: project from q_resid (shared with main attention's LoRA output) ---
496
+ q = self.wq_b(q_resid).view(B, S, self.n_heads, self.head_dim)
497
+ q_rot, q_pass = q.split([self.qk_rope_head_dim, self.head_dim - self.qk_rope_head_dim], dim=-1)
498
+
499
+ # --- Key: project from hidden states (fresh, independent projection) ---
500
+ k = self.k_norm(self.wk(hidden_states)).unsqueeze(2) # [B, S, 1, head_dim]
501
+ k_rot, k_pass = k.split([self.qk_rope_head_dim, self.head_dim - self.qk_rope_head_dim], dim=-1)
502
+
503
+ # --- Apply interleaved RoPE to both Q and K ---
504
+ q_rot, k_rot = apply_rotary_pos_emb_interleave(q_rot, k_rot, cos, sin, unsqueeze_dim=2)
505
+ q = torch.cat([q_rot, q_pass], dim=-1) # [B, S, n_heads, head_dim]
506
+ k = torch.cat([k_rot, k_pass], dim=-1).squeeze(2) # [B, S, head_dim]
507
+
508
+ # --- Multi-head relevance scoring ---
509
+ # Each head independently scores every (query, key) pair
510
+ # q: [B, S, n_heads, D] @ k^T: [B, 1, D, S] → [B, S, n_heads, S]
511
+ scores = (
512
+ torch.matmul(q.float(), k.transpose(-1, -2).float().unsqueeze(1)) * self.softmax_scale
513
+ )
514
+ scores = F.relu(scores) # ReLU! Not softmax. This creates naturally sparse scores.
515
+
516
+ # --- Weighted head aggregation ---
517
+ # Learn which heads' opinions matter more, then combine
518
+ weights = self.weights_proj(hidden_states.to(self.weights_proj.weight.dtype)).float()
519
+ weights = weights * (self.n_heads**-0.5)
520
+ # [B, S, 1, n_heads] @ [B, S, n_heads, S] → [B, S, 1, S] → squeeze → [B, S, S]
521
+ index_scores = torch.matmul(weights.unsqueeze(-2), scores).squeeze(-2)
522
+
523
+ # --- Enforce causality: can't select future tokens! ---
524
+ key_positions = torch.arange(S, device=hidden_states.device)
525
+ causal = key_positions[None, None, :] > position_ids[:, :, None]
526
+ index_scores = index_scores.masked_fill(causal, float("-inf"))
527
+
528
+ # --- Select top-k most relevant tokens ---
529
+ topk = min(self.index_topk, S)
530
+ return index_scores.topk(topk, dim=-1).indices.to(torch.int32)
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # 2h: Multi-Latent Attention (MLA) + DSA Integration
535
+ # ---------------------------------------------------------------------------
536
+
537
+
538
+ class MultiLatentAttention(nn.Module):
539
+ """
540
+ Multi-Latent Attention (MLA) with DeepSeek Sparse Attention (DSA).
541
+
542
+ MLA compresses queries and key-values through LoRA-style bottlenecks.
543
+ This dramatically reduces KV-cache size during inference.
544
+
545
+ ┌─────────────────────────────────────────────────────────────────────────┐
546
+ │ Query path: │
547
+ │ x → q_a_proj (compress) → RMSNorm → q_b_proj (expand per-head) │
548
+ │ → split into [q_nope, q_rope] → apply RoPE to q_rope │
549
+ │ │
550
+ │ KV path: │
551
+ │ x → kv_a_proj (compress to [kv_latent + k_rope]) │
552
+ │ → kv_latent → RMSNorm → kv_b_proj (expand per-head) │
553
+ │ → split into [k_nope, value] │
554
+ │ → k_rope gets RoPE and is broadcast to all heads │
555
+ │ │
556
+ │ Then: q = [q_nope, q_rope], k = [k_nope, k_rope] │
557
+ │ Standard scaled dot-product attention with DSA sparse masking │
558
+ └─────────────────────────────────────────────────────────────────────────┘
559
+
560
+ Cross-layer DSA sharing:
561
+ - "full" layers run the indexer to compute fresh top-k indices
562
+ - "shared" layers reuse the previous full layer's indices (saves compute)
563
+ """
564
+
565
+ def __init__(self, config, layer_idx):
566
+ super().__init__()
567
+ self.config = config
568
+ self.layer_idx = layer_idx
569
+ self.num_heads = config.num_attention_heads
570
+ self.q_lora_rank = config.q_lora_rank
571
+ self.kv_lora_rank = config.kv_lora_rank
572
+ self.qk_nope_head_dim = config.qk_nope_head_dim
573
+ self.qk_rope_head_dim = config.qk_rope_head_dim
574
+ self.qk_head_dim = config.qk_head_dim # nope + rope
575
+ self.v_head_dim = config.v_head_dim
576
+
577
+ # === Query LoRA compression ===
578
+ # hidden → compress → normalize → expand to per-head queries
579
+ self.q_a_proj = nn.Linear(config.hidden_size, config.q_lora_rank, bias=False)
580
+ self.q_a_layernorm = RMSNorm(config.q_lora_rank, eps=config.rms_norm_eps)
581
+ self.q_b_proj = nn.Linear(config.q_lora_rank, self.num_heads * self.qk_head_dim, bias=False)
582
+
583
+ # === KV LoRA compression ===
584
+ # hidden → compress to [kv_latent, k_rope_shared]
585
+ self.kv_a_proj_with_mqa = nn.Linear(
586
+ config.hidden_size,
587
+ self.kv_lora_rank + self.qk_rope_head_dim,
588
+ bias=False,
589
+ )
590
+ self.kv_a_layernorm = RMSNorm(self.kv_lora_rank, eps=config.rms_norm_eps)
591
+ # kv_latent → expand to per-head [k_nope, value]
592
+ self.kv_b_proj = nn.Linear(
593
+ self.kv_lora_rank,
594
+ self.num_heads * (self.qk_nope_head_dim + self.v_head_dim),
595
+ bias=False,
596
+ )
597
+
598
+ # === Output projection ===
599
+ self.o_proj = nn.Linear(self.num_heads * self.v_head_dim, config.hidden_size, bias=False)
600
+
601
+ # === Attention scaling ===
602
+ self.scaling = self.qk_head_dim ** (-0.5)
603
+
604
+ # === DSA: indexer or shared ===
605
+ self.skip_topk = config.indexer_types[layer_idx] == "shared"
606
+ self.indexer = None if self.skip_topk else DSAIndexer(config, layer_idx)
607
+
608
+ def forward(self, x, cos, sin, position_ids, prev_topk_indices=None):
609
+ B, T, _ = x.shape
610
+
611
+ # ============ Query Path ============
612
+ # x → compress(768→384) → RMSNorm → expand(384→12*64=768)
613
+ q_resid = self.q_a_layernorm(self.q_a_proj(x)) # [B, T, q_lora_rank=384]
614
+ q = self.q_b_proj(q_resid) # [B, T, num_heads * qk_head_dim]
615
+ q = q.view(B, T, self.num_heads, self.qk_head_dim).transpose(1, 2) # [B, H, T, qk_head_dim]
616
+ q_nope, q_rope = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
617
+
618
+ # ============ KV Path ============
619
+ # x → compress(768→160) → split [kv_latent(128), k_rope(32)]
620
+ compressed_kv = self.kv_a_proj_with_mqa(x)
621
+ k_compressed, k_rope = compressed_kv.split(
622
+ [self.kv_lora_rank, self.qk_rope_head_dim], dim=-1
623
+ )
624
+ # kv_latent → RMSNorm → expand(128→12*96=1152) → split [k_nope(32), v(64)]
625
+ kv = self.kv_b_proj(self.kv_a_layernorm(k_compressed))
626
+ kv = kv.view(B, T, self.num_heads, self.qk_nope_head_dim + self.v_head_dim).transpose(1, 2)
627
+ k_nope, v = kv.split([self.qk_nope_head_dim, self.v_head_dim], dim=-1)
628
+
629
+ # ============ Apply Interleaved RoPE ============
630
+ # k_rope is shared across heads (MQA-style for the rope component)
631
+ k_rope = k_rope.view(B, 1, T, self.qk_rope_head_dim)
632
+ q_rope, k_rope = apply_rotary_pos_emb_interleave(q_rope, k_rope, cos, sin)
633
+ k_rope = k_rope.expand(B, self.num_heads, T, -1) # broadcast to all heads
634
+
635
+ # Concatenate nope + rope components for final Q and K
636
+ q = torch.cat([q_nope, q_rope], dim=-1) # [B, H, T, qk_head_dim=64]
637
+ k = torch.cat([k_nope, k_rope], dim=-1) # [B, H, T, qk_head_dim=64]
638
+
639
+ # ============ DSA: Sparse Token Selection ============
640
+ if self.indexer is not None:
641
+ # "Full" layer: run indexer to get fresh top-k indices
642
+ topk_indices = self.indexer(x, q_resid, cos, sin, position_ids)
643
+ else:
644
+ # "Shared" layer: reuse previous full layer's indices
645
+ assert prev_topk_indices is not None, (
646
+ f"Layer {self.layer_idx} is 'shared' DSA but got no previous top-k indices!"
647
+ )
648
+ topk_indices = prev_topk_indices
649
+
650
+ # ============ Attention Computation ============
651
+ attn_weights = torch.matmul(q, k.transpose(-1, -2)) * self.scaling # [B, H, T, T]
652
+
653
+ # Causal mask: prevent attending to future positions
654
+ causal_mask = torch.triu(
655
+ torch.full((T, T), torch.finfo(q.dtype).min, device=x.device, dtype=q.dtype),
656
+ diagonal=1,
657
+ )
658
+ attn_weights = attn_weights + causal_mask[None, None, :, :]
659
+
660
+ # DSA sparse mask: ONLY attend to the indexer's top-k selected tokens
661
+ # index_mask[b, t, t'] = True → position t' is NOT selected → mask it out
662
+ index_mask = torch.ones(B, T, T, device=x.device, dtype=torch.bool)
663
+ index_mask.scatter_(-1, topk_indices.long(), False) # Set selected positions to False (unmasked)
664
+ attn_weights = attn_weights.masked_fill(
665
+ index_mask.unsqueeze(1), # [B, 1, T, T] — broadcast across heads
666
+ torch.finfo(q.dtype).min,
667
+ )
668
+
669
+ # Softmax + weighted sum of values
670
+ attn_weights = F.softmax(attn_weights, dim=-1, dtype=torch.float32).to(q.dtype)
671
+ attn_output = torch.matmul(attn_weights, v) # [B, H, T, v_head_dim]
672
+
673
+ # Reshape and project output
674
+ attn_output = attn_output.transpose(1, 2).reshape(B, T, self.num_heads * self.v_head_dim)
675
+ attn_output = self.o_proj(attn_output)
676
+
677
+ return attn_output, topk_indices
678
+
679
+
680
+ # ---------------------------------------------------------------------------
681
+ # 2i: Transformer Decoder Layer
682
+ # ---------------------------------------------------------------------------
683
+
684
+
685
+ class DecoderLayer(nn.Module):
686
+ """
687
+ Pre-norm Transformer decoder layer.
688
+
689
+ Structure:
690
+ x → LayerNorm → MLA Attention → +residual → LayerNorm → MLP/MoE → +residual
691
+
692
+ Layers 0..first_k_dense_replace use dense SwiGLU MLP.
693
+ Remaining layers use Mixture-of-Experts (MoE).
694
+ """
695
+
696
+ def __init__(self, config, layer_idx):
697
+ super().__init__()
698
+ self.self_attn = MultiLatentAttention(config, layer_idx)
699
+
700
+ # Choose MLP type based on layer position
701
+ if config.mlp_layer_types[layer_idx] == "sparse":
702
+ self.mlp = MoEBlock(config)
703
+ else:
704
+ self.mlp = GatedMLP(config.hidden_size, config.intermediate_size)
705
+
706
+ self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
707
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
708
+
709
+ def forward(self, x, cos, sin, position_ids, prev_topk_indices=None):
710
+ # Pre-norm → Attention → Residual
711
+ residual = x
712
+ x = self.input_layernorm(x)
713
+ attn_out, topk_indices = self.self_attn(x, cos, sin, position_ids, prev_topk_indices)
714
+ x = residual + attn_out
715
+
716
+ # Pre-norm → MLP/MoE → Residual
717
+ residual = x
718
+ x = self.post_attention_layernorm(x)
719
+ x = residual + self.mlp(x)
720
+
721
+ return x, topk_indices
722
+
723
+
724
+ # ---------------------------------------------------------------------------
725
+ # 2j: Full Model (Base + CausalLM head)
726
+ # ---------------------------------------------------------------------------
727
+
728
+
729
+ class GLM5Model(nn.Module):
730
+ """GLM-5.2 base model: token embeddings → N decoder layers → final RMSNorm."""
731
+
732
+ def __init__(self, config):
733
+ super().__init__()
734
+ self.config = config
735
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
736
+ self.layers = nn.ModuleList(
737
+ [DecoderLayer(config, i) for i in range(config.num_hidden_layers)]
738
+ )
739
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
740
+ self.rotary_emb = RotaryEmbedding(
741
+ config.qk_rope_head_dim,
742
+ max_position_embeddings=config.max_position_embeddings,
743
+ base=config.rope_theta,
744
+ )
745
+ self.gradient_checkpointing = False
746
+
747
+ def forward(self, input_ids):
748
+ B, T = input_ids.shape
749
+ assert T <= self.config.max_position_embeddings, (
750
+ f"Sequence length {T} > max_position_embeddings {self.config.max_position_embeddings}"
751
+ )
752
+
753
+ x = self.embed_tokens(input_ids)
754
+
755
+ # Compute position embeddings once (shared across all layers)
756
+ position_ids = torch.arange(T, device=input_ids.device).unsqueeze(0).expand(B, -1)
757
+ cos, sin = self.rotary_emb(x, position_ids)
758
+
759
+ # Forward through decoder layers
760
+ # Each layer returns (hidden_states, topk_indices)
761
+ # topk_indices propagate from "full" DSA layers to "shared" layers
762
+ topk_indices = None
763
+ for layer in self.layers:
764
+ if self.gradient_checkpointing and self.training:
765
+ # gradient_checkpointing.checkpoint does not support None inputs.
766
+ # Pass a sentinel zero-tensor when topk_indices is None (layer 0 full-indexer layers),
767
+ # and detect it inside with a flag. Simpler: just skip checkpointing for the very
768
+ # first "full" layer (layer 0) which has no prev indices to receive.
769
+ if topk_indices is None:
770
+ # Layer 0 is always a "full" DSA layer — run normally, then checkpoint the rest.
771
+ x, topk_indices = layer(x, cos, sin, position_ids, None)
772
+ else:
773
+ x, topk_indices = torch.utils.checkpoint.checkpoint(
774
+ layer, x, cos, sin, position_ids, topk_indices,
775
+ use_reentrant=False,
776
+ )
777
+ else:
778
+ x, topk_indices = layer(x, cos, sin, position_ids, topk_indices)
779
+
780
+ return self.norm(x)
781
+
782
+
783
+ class GLM5ForCausalLM(nn.Module):
784
+ """
785
+ GLM-5.2 for Causal Language Modeling.
786
+ = GLM5Model (base) + Linear lm_head (vocab projection).
787
+ """
788
+
789
+ def __init__(self, config):
790
+ super().__init__()
791
+ self.config = config
792
+ self.model = GLM5Model(config)
793
+ self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
794
+
795
+ if config.tie_word_embeddings:
796
+ self.lm_head.weight = self.model.embed_tokens.weight
797
+
798
+ # Initialize all weights
799
+ self.apply(self._init_weights)
800
+
801
+ def _init_weights(self, module):
802
+ """Initialize weights following GLM-5.2 conventions."""
803
+ std = self.config.initializer_range
804
+ if isinstance(module, nn.Linear):
805
+ nn.init.normal_(module.weight, mean=0.0, std=std)
806
+ if module.bias is not None:
807
+ nn.init.zeros_(module.bias)
808
+ elif isinstance(module, nn.Embedding):
809
+ nn.init.normal_(module.weight, mean=0.0, std=std)
810
+ elif isinstance(module, RMSNorm):
811
+ nn.init.ones_(module.weight)
812
+ elif isinstance(module, MoEExperts):
813
+ nn.init.normal_(module.gate_up_proj, mean=0.0, std=std)
814
+ nn.init.normal_(module.down_proj, mean=0.0, std=std)
815
+ elif isinstance(module, TopKRouter):
816
+ nn.init.normal_(module.weight, mean=0.0, std=std)
817
+
818
+ def forward(self, input_ids, targets=None):
819
+ """
820
+ Args:
821
+ input_ids: [B, T] token indices
822
+ targets: [B, T] target token indices (shifted by 1 in get_batch)
823
+
824
+ Returns:
825
+ logits: [B, T, vocab_size]
826
+ loss: scalar if targets provided, else None
827
+ """
828
+ hidden_states = self.model(input_ids)
829
+ logits = self.lm_head(hidden_states)
830
+
831
+ loss = None
832
+ if targets is not None:
833
+ loss = F.cross_entropy(
834
+ logits.view(-1, logits.size(-1)),
835
+ targets.view(-1),
836
+ ignore_index=-1,
837
+ )
838
+ return logits, loss
839
+
840
+ @torch.no_grad()
841
+ def generate(self, idx, max_new_tokens, temperature=0.8, top_k=200):
842
+ """
843
+ Simple autoregressive generation with temperature + top-k sampling.
844
+
845
+ No KV-cache for simplicity — recomputes the full context each step.
846
+ This is slower but simpler, and matches Karpathy's nanoGPT style.
847
+ """
848
+ self.eval()
849
+ for _ in range(max_new_tokens):
850
+ # Crop to max context length if needed
851
+ idx_cond = (
852
+ idx
853
+ if idx.size(1) <= self.config.max_position_embeddings
854
+ else idx[:, -self.config.max_position_embeddings :]
855
+ )
856
+ logits, _ = self(idx_cond)
857
+ logits = logits[:, -1, :] / temperature
858
+
859
+ if top_k is not None:
860
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
861
+ logits[logits < v[:, [-1]]] = float("-inf")
862
+
863
+ probs = F.softmax(logits, dim=-1)
864
+ idx_next = torch.multinomial(probs, num_samples=1)
865
+ idx = torch.cat((idx, idx_next), dim=1)
866
+ return idx
867
+
868
+ def param_count(self):
869
+ """Return a detailed parameter count breakdown."""
870
+ total = sum(p.numel() for p in self.parameters())
871
+ embed_params = self.model.embed_tokens.weight.numel()
872
+ head_params = self.lm_head.weight.numel() if not self.config.tie_word_embeddings else 0
873
+ non_embed = total - embed_params - head_params
874
+
875
+ # MoE active params per token
876
+ moe_layers = sum(1 for t in self.config.mlp_layer_types if t == "sparse")
877
+ if moe_layers > 0:
878
+ expert_params_per_layer = (
879
+ 2 * self.config.moe_intermediate_size * self.config.hidden_size
880
+ + self.config.hidden_size * self.config.moe_intermediate_size
881
+ )
882
+ total_expert_params = expert_params_per_layer * self.config.n_routed_experts * moe_layers
883
+ active_expert_params = expert_params_per_layer * self.config.num_experts_per_tok * moe_layers
884
+ active_ratio = self.config.num_experts_per_tok / self.config.n_routed_experts
885
+ else:
886
+ total_expert_params = 0
887
+ active_expert_params = 0
888
+ active_ratio = 1.0
889
+
890
+ active_params = total - total_expert_params + active_expert_params
891
+ return {
892
+ "total": total,
893
+ "non_embedding": non_embed,
894
+ "active_per_token": active_params,
895
+ "moe_active_ratio": active_ratio,
896
+ }
897
+
898
+
899
+ # =============================================================================
900
+ # Section 3: Data Loading
901
+ # =============================================================================
902
+ # Loads pre-tokenized binary data produced by dataprep_pretrain.py.
903
+ # Data format: train.bin / val.bin (uint16 memmap) + meta.json.
904
+ #
905
+ # Run dataprep_pretrain.py first to prepare the data:
906
+ # python dataprep_pretrain.py # Full 3.3B tokens
907
+ # python dataprep_pretrain.py --total_tokens 10000000 # Quick 10M test
908
+ # =============================================================================
909
+
910
+
911
+ def load_pretrain_data(data_dir):
912
+ """
913
+ Load pre-tokenized binary data from data_dir.
914
+
915
+ Expects:
916
+ data_dir/train.bin -- binary token file (uint16 or uint32)
917
+ data_dir/val.bin -- binary token file (uint16 or uint32)
918
+ data_dir/meta.json -- metadata (vocab_size, dtype, token counts)
919
+
920
+ Returns:
921
+ train_data: np.memmap of training tokens
922
+ val_data: np.memmap of validation tokens
923
+
924
+ Raises:
925
+ FileNotFoundError if data files are missing.
926
+ """
927
+ train_path = os.path.join(data_dir, "train.bin")
928
+ val_path = os.path.join(data_dir, "val.bin")
929
+ meta_path = os.path.join(data_dir, "meta.json")
930
+
931
+ # --- Validate files exist ---
932
+ for path, name in [(train_path, "train.bin"), (val_path, "val.bin"), (meta_path, "meta.json")]:
933
+ if not os.path.exists(path):
934
+ raise FileNotFoundError(
935
+ f" [ERR] {name} not found at: {path}\n"
936
+ f" Run dataprep_pretrain.py first to prepare the data:\n"
937
+ f" python dataprep_pretrain.py\n"
938
+ f" Or for a quick test:\n"
939
+ f" python dataprep_pretrain.py --total_tokens 10000000"
940
+ )
941
+
942
+ # --- Load metadata ---
943
+ with open(meta_path, "r") as f:
944
+ meta = json.load(f)
945
+
946
+ dtype_str = meta.get("dtype", "uint16")
947
+ dtype = np.uint16 if dtype_str == "uint16" else np.uint32
948
+ train_tokens = meta.get("train_tokens", 0)
949
+ val_tokens = meta.get("val_tokens", 0)
950
+
951
+ print(f" [Data]")
952
+ print(f" Tokenizer: {meta.get('tokenizer', 'unknown')}")
953
+ print(f" Vocab size: {meta.get('vocab_size', 'unknown')}")
954
+ print(f" Dtype: {dtype_str}")
955
+ print(f" Train: {train_tokens:,} tokens")
956
+ print(f" Val: {val_tokens:,} tokens")
957
+ print(f" Total: {train_tokens + val_tokens:,} tokens")
958
+ print(f" Sources: {', '.join(meta.get('sources', []))}")
959
+
960
+ # --- Memory-map the binary files ---
961
+ # memmap reads directly from disk without loading into RAM.
962
+ # This is critical for 3.3B tokens (~7GB) on a 6GB VRAM machine.
963
+ train_data = np.memmap(train_path, dtype=dtype, mode="r")
964
+ val_data = np.memmap(val_path, dtype=dtype, mode="r")
965
+
966
+ return train_data, val_data
967
+
968
+
969
+ def get_batch(split, train_data, val_data, block_size, batch_size, device):
970
+ """
971
+ Sample a random batch of token sequences from memmap data.
972
+
973
+ Uses .copy() on numpy slices to avoid torch tensor issues with
974
+ non-writable memmap arrays.
975
+ """
976
+ data = train_data if split == "train" else val_data
977
+ ix = torch.randint(len(data) - block_size - 1, (batch_size,))
978
+ x = torch.stack([torch.from_numpy(data[i : i + block_size].astype(np.int64).copy()) for i in ix])
979
+ y = torch.stack([torch.from_numpy(data[i + 1 : i + 1 + block_size].astype(np.int64).copy()) for i in ix])
980
+ return x.to(device), y.to(device)
981
+
982
+
983
+ # =============================================================================
984
+ # Section 4: Training
985
+ # =============================================================================
986
+ # AdamW optimizer with cosine LR schedule, gradient accumulation,
987
+ # mixed precision, gradient checkpointing, periodic eval + checkpointing.
988
+ # =============================================================================
989
+
990
+
991
+ def get_lr(it, warmup_iters, lr_decay_iters, learning_rate, min_lr, stable_iters=0):
992
+ """WSD (Warmup-Stable-Decay) learning rate schedule.
993
+
994
+ Phases:
995
+ 1. Warmup: steps [0, warmup_iters) — linear ramp 0 → learning_rate
996
+ 2. Stable: steps [warmup_iters, stable_iters) — constant at learning_rate
997
+ 3. Decay: steps [stable_iters, lr_decay_iters] — cosine decay → min_lr
998
+
999
+ If stable_iters <= warmup_iters (default: 0), this reduces to the standard
1000
+ cosine schedule with warmup (backward-compatible).
1001
+ """
1002
+ # Phase 1: Linear warmup
1003
+ if it < warmup_iters:
1004
+ return learning_rate * (it + 1) / warmup_iters
1005
+ # Phase 3 ended: hold at min_lr
1006
+ if it > lr_decay_iters:
1007
+ return min_lr
1008
+ # Phase 2: Stable (constant LR) — only if stable_iters is set
1009
+ if stable_iters > warmup_iters and it < stable_iters:
1010
+ return learning_rate
1011
+ # Phase 3: Cosine decay from learning_rate → min_lr
1012
+ decay_start = max(stable_iters, warmup_iters)
1013
+ decay_ratio = (it - decay_start) / (lr_decay_iters - decay_start)
1014
+ coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
1015
+ return min_lr + coeff * (learning_rate - min_lr)
1016
+
1017
+
1018
+ @torch.no_grad()
1019
+ def estimate_loss(model, train_data, val_data, eval_iters, block_size, batch_size, device, ctx):
1020
+ """Estimate loss on train and val splits (averaged over eval_iters batches)."""
1021
+ model.eval()
1022
+ out = {}
1023
+ for split in ["train", "val"]:
1024
+ losses = []
1025
+ for _ in range(eval_iters):
1026
+ X, Y = get_batch(split, train_data, val_data, block_size, batch_size, device)
1027
+ with ctx:
1028
+ _, loss = model(X, Y)
1029
+ losses.append(loss.item())
1030
+ out[split] = np.mean(losses)
1031
+ model.train()
1032
+ return out
1033
+
1034
+
1035
+ def train(args):
1036
+ """Main training function."""
1037
+ # --- Device Setup ---
1038
+ device = args.device
1039
+ if device == "auto":
1040
+ device = "cuda" if torch.cuda.is_available() else "cpu"
1041
+
1042
+ print(f"\n{'='*70}")
1043
+ print(f" >> Let's Reproduce GLM-5.2 (GLM MoE DSA) -- From Scratch!")
1044
+ print(f"{'='*70}")
1045
+ print(f" Device: {device}")
1046
+ if device == "cuda":
1047
+ torch.set_float32_matmul_precision("high")
1048
+ print(f" GPU: {torch.cuda.get_device_name()}")
1049
+ print(f" VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
1050
+ print(f" [OK] TF32 Tensor Cores enabled")
1051
+
1052
+ # --- Data ---
1053
+ train_data, val_data = load_pretrain_data(args.data_dir)
1054
+
1055
+ # --- Model ---
1056
+ config = GLM5Config()
1057
+ model = GLM5ForCausalLM(config)
1058
+ counts = model.param_count()
1059
+
1060
+ print(f"\n [Model Architecture]")
1061
+ print(f" Hidden size: {config.hidden_size}")
1062
+ print(f" Layers: {config.num_hidden_layers} "
1063
+ f"({config.first_k_dense_replace} dense + "
1064
+ f"{config.num_hidden_layers - config.first_k_dense_replace} MoE)")
1065
+ print(f" Attention heads: {config.num_attention_heads}")
1066
+ print(f" Q LoRA rank: {config.q_lora_rank} -> qk_head_dim: {config.qk_head_dim} "
1067
+ f"(nope:{config.qk_nope_head_dim} + rope:{config.qk_rope_head_dim})")
1068
+ print(f" KV LoRA rank: {config.kv_lora_rank} -> v_head_dim: {config.v_head_dim}")
1069
+ print(f" Experts: {config.n_routed_experts} routed "
1070
+ f"(top-{config.num_experts_per_tok}) + {config.n_shared_experts} shared")
1071
+ print(f" DSA index_topk: {config.index_topk}")
1072
+ print(f" Indexer pattern: {''.join('F' if t == 'full' else 'S' for t in config.indexer_types)}")
1073
+ print(f"\n [Parameters]")
1074
+ print(f" Total: {counts['total']:>12,}")
1075
+ print(f" Non-embedding: {counts['non_embedding']:>12,}")
1076
+ print(f" Active per token: {counts['active_per_token']:>12,} "
1077
+ f"({counts['moe_active_ratio']:.0%} of experts active)")
1078
+ print(f" VRAM (est. train): ~{counts['total'] * 16 / 1e9:.1f} GB "
1079
+ f"(weights + optimizer + gradients)")
1080
+
1081
+ model = model.to(device)
1082
+
1083
+ # --- Gradient Checkpointing ---
1084
+ if args.gradient_checkpointing:
1085
+ model.model.gradient_checkpointing = True
1086
+ print(f"\n [OK] Gradient checkpointing: ON (saves ~40% VRAM, ~30% slower)")
1087
+
1088
+ # --- Mixed Precision ---
1089
+ if device == "cuda" and torch.cuda.is_bf16_supported():
1090
+ dtype = torch.bfloat16
1091
+ ctx = torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16)
1092
+ print(f" [OK] Mixed precision: bfloat16")
1093
+ elif device == "cuda":
1094
+ dtype = torch.float16
1095
+ ctx = torch.amp.autocast(device_type="cuda", dtype=torch.float16)
1096
+ print(f" [OK] Mixed precision: float16")
1097
+ else:
1098
+ dtype = torch.float32
1099
+ ctx = torch.amp.autocast(device_type="cpu", enabled=False)
1100
+ print(f" [WARN] No mixed precision (CPU mode)")
1101
+
1102
+ # GradScaler only needed for float16 (bfloat16 doesn't need scaling)
1103
+ # On CPU, GradScaler must be disabled entirely (no CUDA streams available)
1104
+ scaler = torch.amp.GradScaler("cuda", enabled=(dtype == torch.float16 and device == "cuda"))
1105
+
1106
+ # --- Dry Run: verify forward pass and check VRAM ---
1107
+ print(f"\n Verifying forward pass...")
1108
+ try:
1109
+ with torch.no_grad():
1110
+ dummy = torch.randint(0, config.vocab_size, (1, args.block_size), device=device)
1111
+ with ctx:
1112
+ _, test_loss = model(dummy, dummy)
1113
+ print(f" [OK] Forward pass OK (dummy loss={test_loss.item():.4f})")
1114
+ if device == "cuda":
1115
+ print(f" [OK] VRAM after forward: {torch.cuda.max_memory_allocated() / 1e9:.2f} GB")
1116
+ torch.cuda.reset_peak_memory_stats()
1117
+ except torch.cuda.OutOfMemoryError:
1118
+ print(f" [ERR] OOM during forward pass! Try reducing --batch_size or --block_size")
1119
+ return
1120
+
1121
+ # --- torch.compile ---
1122
+ raw_model = model # Keep a reference to the un-compiled model for saving
1123
+ if args.compile and device == "cuda":
1124
+ print(f" [OK] Compiling model with torch.compile (first step will be slow)...")
1125
+ model = torch.compile(model)
1126
+
1127
+ # --- Optimizer ---
1128
+ # Separate weight decay: only for 2D+ params (weight matrices), not biases/norms
1129
+ decay_params = []
1130
+ no_decay_params = []
1131
+ for name, param in raw_model.named_parameters():
1132
+ if not param.requires_grad:
1133
+ continue
1134
+ if param.dim() >= 2:
1135
+ decay_params.append(param)
1136
+ else:
1137
+ no_decay_params.append(param)
1138
+
1139
+ optimizer = torch.optim.AdamW(
1140
+ [
1141
+ {"params": decay_params, "weight_decay": args.weight_decay},
1142
+ {"params": no_decay_params, "weight_decay": 0.0},
1143
+ ],
1144
+ lr=args.learning_rate,
1145
+ betas=(args.beta1, args.beta2),
1146
+ fused=(device == "cuda"),
1147
+ )
1148
+
1149
+ tokens_per_step = args.batch_size * args.block_size * args.gradient_accumulation_steps
1150
+ print(f"\n [Training Configuration]")
1151
+ print(f" Batch size: {args.batch_size} x {args.gradient_accumulation_steps} "
1152
+ f"grad accum = {args.batch_size * args.gradient_accumulation_steps} effective")
1153
+ print(f" Sequence length: {args.block_size}")
1154
+ print(f" Tokens per step: {tokens_per_step:,}")
1155
+ print(f" Max iterations: {args.max_iters:,}")
1156
+ print(f" Total tokens: ~{tokens_per_step * args.max_iters:,}")
1157
+ print(f" Learning rate: {args.learning_rate} -> {args.min_lr} (cosine)")
1158
+ print(f" Warmup: {args.warmup_iters} steps")
1159
+ print(f"{'='*70}\n")
1160
+
1161
+ # --- Resume from Checkpoint (if requested or if ckpt.pt exists) ---
1162
+ start_iter = 0
1163
+ best_val_loss = float("inf")
1164
+ ckpt_path = os.path.join(args.out_dir, "ckpt.pt")
1165
+
1166
+ if args.resume and os.path.exists(ckpt_path):
1167
+ print(f" [RESUME] Loading checkpoint from {ckpt_path}...")
1168
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
1169
+ raw_model.load_state_dict(ckpt["model"])
1170
+ if "optimizer" in ckpt:
1171
+ optimizer.load_state_dict(ckpt["optimizer"])
1172
+ start_iter = ckpt.get("iter_num", 0) + 1
1173
+ best_val_loss = ckpt.get("best_val_loss", float("inf"))
1174
+ print(f" [RESUME] Resuming from step {start_iter} (best val loss: {best_val_loss:.4f})")
1175
+
1176
+ # --- Training Loop ---
1177
+ os.makedirs(args.out_dir, exist_ok=True)
1178
+ t0 = time.time()
1179
+ tokens_processed = 0
1180
+
1181
+ for iter_num in range(start_iter, args.max_iters):
1182
+ # Update learning rate (WSD schedule: warmup → stable → cosine decay)
1183
+ lr = get_lr(iter_num, args.warmup_iters, args.lr_decay_iters, args.learning_rate, args.min_lr, args.stable_iters)
1184
+ for param_group in optimizer.param_groups:
1185
+ param_group["lr"] = lr
1186
+
1187
+ # --- Periodic Evaluation ---
1188
+ if iter_num % args.eval_interval == 0:
1189
+ losses = estimate_loss(
1190
+ model, train_data, val_data,
1191
+ args.eval_iters, args.block_size, args.batch_size, device, ctx,
1192
+ )
1193
+ print(
1194
+ f" step {iter_num:>5d} | "
1195
+ f"train {losses['train']:.4f} | val {losses['val']:.4f} | "
1196
+ f"lr {lr:.2e}"
1197
+ )
1198
+
1199
+ # Save latest checkpoint at every eval interval so progress is never lost
1200
+ ckpt = {
1201
+ "model": raw_model.state_dict(),
1202
+ "optimizer": optimizer.state_dict(),
1203
+ "config": config,
1204
+ "iter_num": iter_num,
1205
+ "best_val_loss": best_val_loss,
1206
+ }
1207
+ torch.save(ckpt, ckpt_path)
1208
+ print(f" [SAVED] latest checkpoint to {ckpt_path} (step {iter_num})")
1209
+
1210
+ # Save separate best checkpoint when val loss improves
1211
+ if losses["val"] < best_val_loss:
1212
+ best_val_loss = losses["val"]
1213
+ ckpt["best_val_loss"] = best_val_loss
1214
+ best_ckpt_path = os.path.join(args.out_dir, "ckpt_best.pt")
1215
+ torch.save(ckpt, best_ckpt_path)
1216
+ print(f" [SAVED] BEST checkpoint to {best_ckpt_path} (val_loss={best_val_loss:.4f})")
1217
+
1218
+ # --- Gradient Accumulation Loop ---
1219
+ optimizer.zero_grad(set_to_none=True)
1220
+ for micro_step in range(args.gradient_accumulation_steps):
1221
+ X, Y = get_batch("train", train_data, val_data, args.block_size, args.batch_size, device)
1222
+ with ctx:
1223
+ _, loss = model(X, Y)
1224
+ loss = loss / args.gradient_accumulation_steps
1225
+ scaler.scale(loss).backward()
1226
+ tokens_processed += X.numel()
1227
+
1228
+ # Gradient clipping
1229
+ if args.grad_clip > 0:
1230
+ scaler.unscale_(optimizer)
1231
+ torch.nn.utils.clip_grad_norm_(raw_model.parameters(), args.grad_clip)
1232
+
1233
+ scaler.step(optimizer)
1234
+ scaler.update()
1235
+
1236
+ # --- Logging ---
1237
+ if iter_num > 0 and iter_num % args.log_interval == 0:
1238
+ dt = time.time() - t0
1239
+ tps = tokens_processed / dt if dt > 0 else 0
1240
+ lossf = loss.item() * args.gradient_accumulation_steps
1241
+ vram = ""
1242
+ if device == "cuda":
1243
+ vram = f" | VRAM {torch.cuda.max_memory_allocated() / 1e9:.2f}GB"
1244
+ print(f" step {iter_num:>5d} | loss {lossf:.4f} | lr {lr:.2e} | {tps:,.0f} tok/s{vram}")
1245
+ t0 = time.time()
1246
+ tokens_processed = 0
1247
+
1248
+ print(f"\n{'='*70}")
1249
+ print(f" [DONE] Training complete! Best val loss: {best_val_loss:.4f}")
1250
+ print(f" Checkpoint saved to: {os.path.join(args.out_dir, 'ckpt.pt')}")
1251
+ print(f"{'='*70}")
1252
+
1253
+
1254
+ # =============================================================================
1255
+ # Section 5: Text Generation / Sampling
1256
+ # =============================================================================
1257
+
1258
+
1259
+ def sample(args):
1260
+ """Generate text from a trained checkpoint."""
1261
+ device = args.device
1262
+ if device == "auto":
1263
+ device = "cuda" if torch.cuda.is_available() else "cpu"
1264
+
1265
+ # Load checkpoint
1266
+ ckpt_path = args.ckpt or os.path.join(args.out_dir, "ckpt.pt")
1267
+ if not os.path.exists(ckpt_path):
1268
+ print(f" [ERR] Checkpoint not found at {ckpt_path}")
1269
+ print(f" Train first with: python train_glm5.py")
1270
+ return
1271
+
1272
+ print(f" Loading checkpoint from {ckpt_path}...")
1273
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
1274
+ config = ckpt["config"]
1275
+
1276
+ model = GLM5ForCausalLM(config)
1277
+ model.load_state_dict(ckpt["model"])
1278
+ model = model.to(device)
1279
+ model.eval()
1280
+
1281
+ # Encode prompt
1282
+ enc = tiktoken.get_encoding("gpt2")
1283
+ prompt = args.prompt or "\n"
1284
+ tokens = enc.encode(prompt)
1285
+ idx = torch.tensor([tokens], dtype=torch.long, device=device)
1286
+
1287
+ print(f"\n Prompt: {prompt!r}")
1288
+ print(f" {'-'*60}")
1289
+
1290
+ # Generate — use bfloat16 on CUDA, float32 on CPU (autocast doesn't support bf16 on CPU)
1291
+ if device == "cuda" and torch.cuda.is_bf16_supported():
1292
+ gen_ctx = torch.amp.autocast(device_type="cuda", dtype=torch.bfloat16)
1293
+ elif device == "cuda":
1294
+ gen_ctx = torch.amp.autocast(device_type="cuda", dtype=torch.float16)
1295
+ else:
1296
+ import contextlib
1297
+ gen_ctx = contextlib.nullcontext()
1298
+ with gen_ctx:
1299
+ output = model.generate(
1300
+ idx,
1301
+ max_new_tokens=args.max_new_tokens,
1302
+ temperature=args.temperature,
1303
+ top_k=args.top_k,
1304
+ )
1305
+
1306
+ generated = enc.decode(output[0].tolist())
1307
+ print(generated)
1308
+ print(f" {'-'*60}\n")
1309
+
1310
+
1311
+ # =============================================================================
1312
+ # Section 6: Entry Point
1313
+ # =============================================================================
1314
+
1315
+
1316
+ def main():
1317
+ parser = argparse.ArgumentParser(
1318
+ description="Let's Reproduce GLM-5.2 (GLM MoE DSA) -- From Scratch!",
1319
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1320
+ )
1321
+
1322
+ # --- Mode ---
1323
+ parser.add_argument("--eval_only", action="store_true", help="Generate text only (no training)")
1324
+ parser.add_argument("--resume", action="store_true", help="Resume training from existing out_dir/ckpt.pt")
1325
+ parser.add_argument("--ckpt", type=str, default=None, help="Checkpoint path for generation")
1326
+
1327
+ # --- Training ---
1328
+ parser.add_argument("--out_dir", type=str, default="out_glm5", help="Output directory for checkpoints")
1329
+ parser.add_argument("--data_dir", type=str, default="./data", help="Data directory containing train.bin, val.bin, meta.json")
1330
+ parser.add_argument("--max_iters", type=int, default=800000, help="Training iterations (800K for 3.3B tokens)")
1331
+ parser.add_argument("--batch_size", type=int, default=4, help="Micro batch size per step")
1332
+ parser.add_argument("--block_size", type=int, default=512, help="Context/sequence length")
1333
+ parser.add_argument("--gradient_accumulation_steps", type=int, default=4, help="Gradient accumulation steps")
1334
+ parser.add_argument("--learning_rate", type=float, default=6e-4, help="Peak learning rate")
1335
+ parser.add_argument("--min_lr", type=float, default=6e-5, help="Minimum learning rate (end of cosine)")
1336
+ parser.add_argument("--warmup_iters", type=int, default=2000, help="LR warmup iterations")
1337
+ parser.add_argument("--lr_decay_iters", type=int, default=800000, help="Cosine decay length (match max_iters)")
1338
+ parser.add_argument("--stable_iters", type=int, default=0, help="WSD: keep LR at peak until this step, then cosine decay (0=standard cosine)")
1339
+ parser.add_argument("--weight_decay", type=float, default=0.1, help="Weight decay")
1340
+ parser.add_argument("--beta1", type=float, default=0.9, help="AdamW beta1")
1341
+ parser.add_argument("--beta2", type=float, default=0.95, help="AdamW beta2")
1342
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="Gradient clipping (0=disable)")
1343
+
1344
+ # --- Efficiency ---
1345
+ parser.add_argument(
1346
+ "--gradient_checkpointing", action="store_true", default=True,
1347
+ help="Enable gradient checkpointing (default: on, saves VRAM)",
1348
+ )
1349
+ parser.add_argument(
1350
+ "--no_gradient_checkpointing", action="store_false", dest="gradient_checkpointing",
1351
+ help="Disable gradient checkpointing",
1352
+ )
1353
+ parser.add_argument("--compile", action="store_true", help="Use torch.compile (faster, needs warmup)")
1354
+ parser.add_argument("--device", type=str, default="auto", help="Device: auto, cuda, cpu")
1355
+
1356
+ # --- Evaluation ---
1357
+ parser.add_argument("--eval_interval", type=int, default=2000, help="Evaluate every N steps")
1358
+ parser.add_argument("--eval_iters", type=int, default=50, help="Batches per evaluation")
1359
+ parser.add_argument("--log_interval", type=int, default=100, help="Log loss every N steps")
1360
+
1361
+ # --- Generation ---
1362
+ parser.add_argument("--prompt", type=str, default=None, help="Prompt for text generation")
1363
+ parser.add_argument("--max_new_tokens", type=int, default=500, help="Max tokens to generate")
1364
+ parser.add_argument("--temperature", type=float, default=0.8, help="Sampling temperature")
1365
+ parser.add_argument("--top_k", type=int, default=200, help="Top-k sampling")
1366
+
1367
+ args = parser.parse_args()
1368
+
1369
+ if args.eval_only:
1370
+ sample(args)
1371
+ else:
1372
+ train(args)
1373
+ # Generate a sample after training completes
1374
+ print("\n >> Generating sample text from the trained model...\n")
1375
+ args.prompt = args.prompt or "First Citizen:\n"
1376
+ sample(args)
1377
+
1378
+
1379
+ if __name__ == "__main__":
1380
+ main()