fzzhang commited on
Commit
58258b8
·
verified ·
1 Parent(s): 48a5376

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +274 -0
  2. RUNBOOK.md +694 -0
  3. gpu-sft/.gitignore +6 -0
  4. gpu-sft/EVAL_RESULTS.md +50 -0
  5. gpu-sft/README.md +47 -0
  6. gpu-sft/claude/compile_results_local.py +1220 -0
  7. gpu-sft/claude/evals.md +38 -0
  8. gpu-sft/claude/make_fixture_eval_tree.py +183 -0
  9. gpu-sft/experiments/qwen3_chat_template.py +108 -0
  10. gpu-sft/requirements-gpu.txt +19 -0
  11. gpu-sft/scripts/gpu_eval/bootstrap_gpu_eval.sh +194 -0
  12. gpu-sft/scripts/gpu_eval/gpu_eval_driver.py +996 -0
  13. gpu-sft/scripts/gpu_eval/gpu_eval_monitor.py +486 -0
  14. gpu-sft/scripts/gpu_eval/patch_evalchemy_gpu.py +274 -0
  15. gpu-sft/scripts/gpu_eval/run_all_evals.sh +60 -0
  16. gpu-sft/scripts/gpu_sft/accelerate_fsdp_qwen3.yaml +41 -0
  17. gpu-sft/scripts/gpu_sft/deepspeed_zero3_qwen3.json +40 -0
  18. gpu-sft/scripts/gpu_sft/launch_sft_8b.sh +108 -0
  19. gpu-sft/scripts/gpu_sft/prepare_sft_data.py +525 -0
  20. gpu-sft/scripts/gpu_sft/push_exports.py +98 -0
  21. gpu-sft/scripts/gpu_sft/run_sft.sh +131 -0
  22. gpu-sft/scripts/gpu_sft/train_sft_qwen3.py +820 -0
  23. scripts/combine_and_publish.py +171 -0
  24. scripts/launch_shards.sh +36 -0
  25. scripts/monitor.sh +44 -0
  26. scripts/upload_my_shards.py +66 -0
  27. sdg/__init__.py +1 -0
  28. sdg/__main__.py +4 -0
  29. sdg/config.py +94 -0
  30. sdg/configs/qwen3_0.6b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml +41 -0
  31. sdg/configs/qwen3_0.6b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml +42 -0
  32. sdg/configs/qwen3_0.6b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml +42 -0
  33. sdg/configs/qwen3_0.6b_rstarcoder_seedsft_instill_n8_valredundancy5_round1.yaml +42 -0
  34. sdg/configs/qwen3_1.7b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml +41 -0
  35. sdg/configs/qwen3_1.7b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml +42 -0
  36. sdg/configs/qwen3_1.7b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml +42 -0
  37. sdg/configs/qwen3_32b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml +41 -0
  38. sdg/configs/qwen3_32b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml +42 -0
  39. sdg/configs/qwen3_32b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml +42 -0
  40. sdg/configs/qwen3_32b_rstarcoder_seedsft_instill_n8_valredundancy5_round1.yaml +42 -0
  41. sdg/configs/qwen3_4b_mot_math_instill_n4_valredundancy3_round1.yaml +42 -0
  42. sdg/configs/qwen3_4b_nemotron_cascade2_science100k_instill_n8_valredundancy5_round1.yaml +42 -0
  43. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy1_round1.yaml +41 -0
  44. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy3_round1.yaml +41 -0
  45. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy5_round1.yaml +41 -0
  46. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy1_round1.yaml +41 -0
  47. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy3_round1.yaml +41 -0
  48. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy5_round1.yaml +41 -0
  49. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_no_filter_round1.yaml +41 -0
  50. sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_simple_valredundancy5_round1.yaml +41 -0
README.md ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SDG — Distributed Sharded Pipeline
2
+
3
+ Generate synthetic data with vLLM + uncertainty-quantification validation, distributed across multiple GPUs via SLURM sharding with a shared MongoDB inference cache.
4
+
5
+ ## Prerequisites
6
+
7
+ ```bash
8
+ pip install -r sdg/requirements.txt
9
+ ```
10
+
11
+ Install MongoDB (download binary):
12
+
13
+ ```bash
14
+ curl -O https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-8.0.4.tgz
15
+ tar xzf mongodb-linux-x86_64-ubuntu2204-8.0.4.tgz
16
+ cp mongodb-linux-x86_64-ubuntu2204-8.0.4/bin/mongod /u/nlp/anaconda/main/anaconda3/envs/tonyreasoningtraces/bin/
17
+ mongod --version
18
+ ```
19
+
20
+ ## Quick Start (Single GPU, No Sharding)
21
+
22
+ ```bash
23
+ python -m sdg.generate --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml --limit 100
24
+ ```
25
+
26
+ This requires `mongo_uri` set in the config YAML (see below).
27
+
28
+ ## Distributed Sharded Run
29
+
30
+ ### 1. Start MongoDB
31
+
32
+ Launch a MongoDB server on a cluster node (no GPU needed) with max TTL of 21 days:
33
+
34
+ ```bash
35
+ nlprun -a tonyreasoningtraces --time 21-0 -m john3 --exclude john17 -c 4 -g 0 --memory 64g \
36
+ -w /nlp/scr4/nlp/crfm/text2image/text2image-rlhf/reasoning/virtual-world-data \
37
+ --job-name sdg-mongodb "python -m sdg.launch_mongodb 2>&1 | tee mongo.log"
38
+
39
+ nlprun -a tonyreasoningtraces --time 21-0 -q jag -p high -m jagupard39 --exclude john17 -c 4 -g 0 --memory 64g \
40
+ -w /nlp/scr4/nlp/crfm/text2image/text2image-rlhf/reasoning/virtual-world-data \
41
+ --job-name sdg-serve "python -m sdg.launch_mongodb 2>&1 | tee mongo.log"
42
+ ```
43
+
44
+ Or locally:
45
+
46
+ ```bash
47
+ python -m sdg.launch_mongodb --port 27017 --dbpath /tmp/sdg_mongo
48
+ ```
49
+
50
+ It will print `mongo-uri: mongodb://<hostname>:27017` to stdout (and `mongo.log`).
51
+
52
+ ### 2. Set `mongo_uri` and `num_shards` in your config
53
+
54
+ Grab the hostname from `mongo.log`, then edit your YAML config (e.g. `sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml`):
55
+
56
+ ```yaml
57
+ mongo_uri: "mongodb://<hostname>:27017"
58
+ num_shards: 32
59
+ ```
60
+
61
+ ### 3. Launch shards
62
+
63
+ ```commandline
64
+ conda activate tonyreasoningtraces && cd /nlp/scr4/nlp/crfm/text2image/text2image-rlhf/reasoning/virtual-world-data
65
+ ```
66
+
67
+ Dry run first to verify commands:
68
+
69
+ ```bash
70
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml launch --dry-run
71
+ ```
72
+
73
+ Then launch for real:
74
+
75
+ ```bash
76
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml launch
77
+ ```
78
+
79
+ This reads `num_shards` from the config and submits that many nlprun jobs (1 GPU each). Each job processes `ceil(total_seeds / num_shards)` examples and writes to `output/{experiment_name}/shards/shard_NNN/`. You can override with `--num-shards N` on the CLI.
80
+
81
+ ### 4. Check status
82
+
83
+ ```bash
84
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml check
85
+ ```
86
+
87
+ Prints a table like:
88
+
89
+ ```
90
+ Shard Status Seeds Passed Failed
91
+ 000 completed 2930 2100 830
92
+ 001 running - - -
93
+ 002 failed - - -
94
+ ...
95
+ Overall: 20/32 completed, 8/32 running, 4/32 failed
96
+ ```
97
+
98
+ When all shards are completed, `check` automatically:
99
+ - Combines all shard `output.jsonl` files into `output/{experiment_name}/output.jsonl`
100
+ - Aggregates all `stats.json` into a combined `output/{experiment_name}/stats.json`
101
+ - Uploads to HuggingFace at `teetone/{experiment_name}`
102
+
103
+ ### 5. Rerun failed shards
104
+
105
+ ```bash
106
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml rerun
107
+ ```
108
+
109
+ This re-launches only `failed` shards (skips `completed`, `running`, and `not_started`).
110
+
111
+ ## Output Directory Structure
112
+
113
+ ```
114
+ output/{experiment_name}/
115
+ launch_meta.json # Shard metadata
116
+ config.yaml # Config copy
117
+ output.jsonl # Combined output (after all shards done)
118
+ stats.json # Aggregated stats (after all shards done)
119
+ logs/
120
+ shard_000.log
121
+ shard_001.log
122
+ ...
123
+ shards/
124
+ shard_000/
125
+ output.jsonl
126
+ stats.json
127
+ shard_001/
128
+ output.jsonl
129
+ stats.json
130
+ ...
131
+ ```
132
+
133
+ ## Example: OpenThoughts4 with 32 shards
134
+
135
+ ```bash
136
+ # 1. Start MongoDB on john (no GPU, 16 GB RAM)
137
+ nlprun -a tonyreasoningtraces -q john --exclude john17 -c 4 -g 0 --memory 64g \
138
+ -w /nlp/scr4/nlp/crfm/text2image/text2image-rlhf/reasoning/virtual-world-data \
139
+ --job-name sdg-mongodb "python -m sdg.launch_mongodb 2>&1 | tee mongo.log"
140
+
141
+ # 2. Grab the hostname from the job output, then set mongo_uri in your config
142
+ # mongo_uri: "mongodb://<hostname>:27017"
143
+
144
+ # 3. Launch
145
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml launch
146
+
147
+ # 4. Monitor
148
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml check
149
+
150
+ # 5. Rerun any failures
151
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_math30K_instill_n4_valredundancy3_round1.yaml rerun
152
+ ```
153
+
154
+ ## CLI Reference
155
+
156
+ | Command | Description |
157
+ |---------|-------------|
158
+ | `python -m sdg.generate --config CONFIG` | Run pipeline on a single GPU |
159
+ | `python -m sdg.generate --config CONFIG --shard-id I --num-shards N` | Run a single shard |
160
+ | `python -m sdg.launch_mongodb` | Start MongoDB server |
161
+ | `python -m sdg.launcher --config CONFIG launch` | Launch all shards via nlprun |
162
+ | `python -m sdg.launcher --config CONFIG launch --dry-run` | Print commands without executing |
163
+ | `python -m sdg.launcher --config CONFIG check` | Check shard status, combine if done |
164
+ | `python -m sdg.launcher --config CONFIG rerun` | Rerun failed shards only |
165
+ | `python -m sdg.launcher --config CONFIG rerun --dry-run` | Show which failed shards would be relaunched |
166
+
167
+ ## Launch Jobs
168
+
169
+ ### Qwen3 4B
170
+
171
+ #### Launch OpenThoughts3 Math53K
172
+
173
+ ```bash
174
+ # n=1, val_redundancy=3 - DONE
175
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy3_round1.yaml launch
176
+
177
+ # n=1, val_redundancy=1 - DONE
178
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy1_round1.yaml launch
179
+
180
+ # n=8, val_redundancy=1 - DONE
181
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy1_round1.yaml launch
182
+
183
+ # n=8, val_redundancy=3 - DONE
184
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy3_round1.yaml launch
185
+
186
+ # n=1, val_redundancy=5
187
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy5_round1.yaml launch
188
+
189
+ # n=4, val_redundancy=5
190
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy5_round1.yaml launch
191
+
192
+ # n=8, val_redundancy=5 - RUNNING
193
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml launch
194
+
195
+ # n=8, no_filter (no UQ validation)
196
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_no_filter_round1.yaml launch
197
+
198
+ # n=8, val_redundancy=5, all_valid (keep all samples that pass UQ)
199
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_allvalid_round1.yaml launch
200
+ ```
201
+
202
+ #### Launch OpenThoughts4 Science26K
203
+
204
+ ```bash
205
+ # n=4, val_redundancy=3 - DONE
206
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_science26K_instill_n4_valredundancy3_round1.yaml launch
207
+
208
+ # n=1, val_redundancy=1 - DONE
209
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_science26K_instill_n1_valredundancy1_round1.yaml launch
210
+
211
+ # n=8, val_redundancy=3 - DONE
212
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_science26K_instill_n8_valredundancy3_round1.yaml launch
213
+
214
+ # n=8, val_redundancy=5 - DONE
215
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml launch
216
+
217
+ # n=8, no_filter (no UQ validation) - RUNNING
218
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_science26K_instill_n8_no_filter_round1.yaml launch
219
+ ```
220
+
221
+ #### Launch OpenThoughts4 Code9K
222
+
223
+ ```bash
224
+ # n=4, val_redundancy=3 - DONE
225
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_code9K_instill_n4_valredundancy3_round1.yaml launch
226
+
227
+ # n=1, val_redundancy=1 - DONE
228
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_code9K_instill_n1_valredundancy1_round1.yaml launch
229
+
230
+ # n=8, val_redundancy=3 - DONE
231
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_code9K_instill_n8_valredundancy3_round1.yaml launch
232
+
233
+ # n=8, val_redundancy=5 - DONE
234
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml launch
235
+
236
+ # n=8, no_filter (no UQ validation) - RUNNING
237
+ python -m sdg.launcher --config sdg/configs/qwen3_4b_openthoughts4_code9K_instill_n8_no_filter_round1.yaml launch
238
+ ```
239
+
240
+ ### Qwen3 8B
241
+
242
+
243
+ #### Launch OpenThoughts4 Science26K (Qwen3-8B)
244
+
245
+ ```bash
246
+ # n=8, val_redundancy=5 - DONE
247
+ python -m sdg.launcher --config sdg/configs/qwen3_8b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml launch
248
+
249
+ # n=4, val_redundancy=5 - RUNNING
250
+ python -m sdg.launcher --config sdg/configs/qwen3_8b_openthoughts4_science26K_instill_n4_valredundancy5_round1.yaml launch
251
+ ```
252
+
253
+ #### Launch OpenThoughts4 Code9K (Qwen3-8B)
254
+
255
+ ```bash
256
+ # n=8, val_redundancy=5 - DONE
257
+ python -m sdg.launcher --config sdg/configs/qwen3_8b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml launch
258
+ ```
259
+
260
+ #### Launch OpenThoughts4 Math219K (Qwen3-8B)
261
+
262
+ ```bash
263
+ # n=8, val_redundancy=5 - RUNNING
264
+ python -m sdg.launcher --config sdg/configs/qwen3_8b_openthoughts4_math219K_instill_n8_valredundancy5_round1.yaml launch
265
+ ```
266
+
267
+ # Other
268
+
269
+ ## Check logs
270
+
271
+ ```bash
272
+ scp -r tonyhlee@scdt.stanford.edu:/nlp/scr4/nlp/crfm/text2image/text2image-rlhf/reasoning/virtual-world-data/output/qwen3_0.6b_openthoughts3_math53K_instill_n8_valredundancy5_round1 /Users/tonyhlee/Dev/virtual-world-data/
273
+ ```
274
+
RUNBOOK.md ADDED
@@ -0,0 +1,694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-Node SDG Pipeline Runbook
2
+
3
+ End-to-end recipe for running the SDG pipeline distributed across **4 worker
4
+ nodes × 8 H100 GPUs = 32 shards**, with cross-node MongoDB caching and HuggingFace
5
+ output gathering. Tested on ByteDance Arnold (Kubernetes-backed Azure ND96isr_H100_v5).
6
+
7
+ - Target config: `sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml`
8
+ - Final dataset: published to `<your-hf-username>/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1`
9
+ - Wall time: **~40–60 hours** end-to-end
10
+ - HF username placeholder used below: `fzzhang` — replace with yours
11
+
12
+ If the cluster dies, follow this runbook top-to-bottom on a fresh allocation.
13
+
14
+ ---
15
+
16
+ ## 0. Cluster prerequisites
17
+
18
+ - 4 worker nodes, each with 8× H100 (80 GB), shared no-filesystem (per-pod overlay).
19
+ - NVIDIA driver supporting at least CUDA 12.4 (driver 535+ is fine).
20
+ - HF account with write access to the target dataset namespace.
21
+ - Each worker has these Arnold-provided env vars: `ARNOLD_WORKER_0_HOST`,
22
+ `ARNOLD_WORKER_0_PORT`, `MY_POD_NAME` (ends with `worker-N`).
23
+ - Cross-worker IPv6 traffic on the `ARNOLD_WORKER_*_PORT` ports is open by default —
24
+ verify in §3 below.
25
+
26
+ ---
27
+
28
+ ## 1. Per-worker bootstrap (run on EVERY worker, in parallel)
29
+
30
+ ### 1.1 Clone the repo
31
+
32
+ ```bash
33
+ cd ~/lmc_muon # or your preferred dir
34
+ # git clone or rsync this repo into ./self-verified-distillation
35
+ cd self-verified-distillation
36
+ ```
37
+
38
+ ### 1.2 Create the conda env and install pinned dependencies
39
+
40
+ The driver caps at CUDA 12.4, so vLLM 0.8.5 + torch 2.6.0+cu124 is the highest
41
+ stack that runs. Newer vLLM (≥0.9) ships cu126/cu130 wheels which **will not
42
+ run on driver 535**.
43
+
44
+ ```bash
45
+ conda create -n sdg python=3.12 -y
46
+ conda activate sdg
47
+
48
+ pip install -U uv
49
+ uv pip install -r sdg/requirements.txt
50
+
51
+ # Downgrade to cu12.4-compatible stack
52
+ uv pip uninstall vllm torch torchvision torchaudio flashinfer-python triton
53
+ uv pip install torch==2.6.0 torchvision==0.21.0 \
54
+ --index-url https://download.pytorch.org/whl/cu124
55
+ uv pip install "vllm==0.8.5" "transformers>=4.51.3,<5.0"
56
+
57
+ # Sanity check — MUST print "OK 1.0"
58
+ python -c "import torch; x=torch.zeros(1).cuda(); print('OK', (x+1).item())"
59
+ ```
60
+
61
+ ### 1.3 HuggingFace login
62
+
63
+ ```bash
64
+ huggingface-cli login # paste token; or set HF_TOKEN env var
65
+ python -c "from huggingface_hub import HfApi; print(HfApi().whoami()['name'])"
66
+ # should print your HF username (e.g. fzzhang)
67
+ ```
68
+
69
+ ---
70
+
71
+ ## 2. MongoDB on worker_0 (ONLY worker_0)
72
+
73
+ ### 2.1 Install mongod
74
+
75
+ ```bash
76
+ cd ~/lmc_muon/self-verified-distillation
77
+ curl -O https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-8.0.4.tgz
78
+ tar xzf mongodb-linux-x86_64-ubuntu2204-8.0.4.tgz
79
+ export PATH="$PWD/mongodb-linux-x86_64-ubuntu2204-8.0.4/bin:$PATH"
80
+ which mongod # must print a path
81
+
82
+ # Persist for new shells
83
+ echo "export PATH=\"$PWD/mongodb-linux-x86_64-ubuntu2204-8.0.4/bin:\$PATH\"" >> ~/.bashrc
84
+ ```
85
+
86
+ ### 2.2 Start mongod bound to all interfaces with IPv6
87
+
88
+ The `--ipv6` flag is mandatory; without it `--bind_ip_all` is IPv4-only and
89
+ cross-worker traffic (IPv6 fabric) cannot reach mongod.
90
+
91
+ ```bash
92
+ mkdir -p /tmp/sdg_mongo
93
+ nohup mongod --port $ARNOLD_WORKER_0_PORT --dbpath /tmp/sdg_mongo \
94
+ --bind_ip_all --ipv6 > mongo.log 2>&1 &
95
+
96
+ sleep 5
97
+ grep -i "Listening on" mongo.log
98
+ # Expected: BOTH 0.0.0.0:<port> AND [::]:<port>
99
+
100
+ # Local sanity
101
+ python3 -c "from pymongo import MongoClient; \
102
+ MongoClient(f'mongodb://[::1]:$ARNOLD_WORKER_0_PORT', serverSelectionTimeoutMS=5000).admin.command('ping'); \
103
+ print('mongo OK locally')"
104
+ ```
105
+
106
+ ---
107
+
108
+ ## 3. Verify cross-worker MongoDB reachability
109
+
110
+ On workers 1, 2, 3:
111
+
112
+ ```bash
113
+ python3 -c "
114
+ import os
115
+ from pymongo import MongoClient
116
+ host = os.environ['ARNOLD_WORKER_0_HOST']
117
+ port = os.environ['ARNOLD_WORKER_0_PORT']
118
+ uri = f'mongodb://[{host}]:{port}'
119
+ MongoClient(uri, serverSelectionTimeoutMS=5000).admin.command('ping')
120
+ print(f'mongo OK from {os.uname().nodename} → {uri}')
121
+ "
122
+ ```
123
+
124
+ You should see `mongo OK from ...` on each. If any worker times out, the
125
+ platform is blocking inter-worker traffic on that port — do not proceed.
126
+
127
+ ---
128
+
129
+ ## 4. Patch the config on each worker
130
+
131
+ Point `mongo_uri` at the IPv6 MongoDB and set `num_shards=32` to use all GPUs.
132
+
133
+ ```bash
134
+ # Run on EACH worker
135
+ python3 <<'EOF'
136
+ import os, yaml
137
+ host = os.environ['ARNOLD_WORKER_0_HOST']
138
+ port = os.environ['ARNOLD_WORKER_0_PORT']
139
+ path = 'sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml'
140
+ with open(path) as f: cfg = yaml.safe_load(f)
141
+ cfg['mongo_uri'] = f'mongodb://[{host}]:{port}'
142
+ cfg['num_shards'] = 32
143
+ with open(path, 'w') as f: yaml.dump(cfg, f, sort_keys=False)
144
+ print('updated:', cfg['mongo_uri'])
145
+ EOF
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 5. Helper scripts
151
+
152
+ All orchestration scripts live in `scripts/` (committed to the repo, so each
153
+ worker has them after `git clone`). They are designed to be **runnable as-is**;
154
+ override behavior with env vars rather than editing the files:
155
+
156
+ | Script | Purpose | Run on |
157
+ |---|---|---|
158
+ | `scripts/launch_shards.sh` | Launch 8 shards on this worker, one per GPU. Honors `CONFIG`, `LIMIT`, `NUM_SHARDS`. | every worker |
159
+ | `scripts/upload_my_shards.py` | Poll for this worker's 8 stats.json, then upload to staging HF repo. Honors `HF_USER`, `RUN_NAME`. | every worker |
160
+ | `scripts/combine_and_publish.py` | Poll for 4 worker-done markers, gather, combine, publish to HF (full launcher.py stats schema). Honors `HF_USER`, `RUN_NAME`, `NUM_WORKERS`, `NUM_SHARDS`. | worker_0 only |
161
+ | `scripts/monitor.sh` | Per-worker live status (GPUs, current shard progress, completion counts, mongo cache size via cross-worker IPv6). | any worker |
162
+
163
+ Defaults target the math53K config + `HF_USER=fzzhang` + 4 workers × 32 shards.
164
+ **Why scripts/, not inline heredocs:** during the original run, terminal
165
+ autocomplete corrupted multi-line heredoc pastes (Python files came out
166
+ unparseable). Committed files bypass paste entirely — you just `git pull` and
167
+ run them.
168
+
169
+ **Verify they're present and parse on each worker:**
170
+
171
+ ```bash
172
+ ls scripts/
173
+ python -c "import ast; ast.parse(open('scripts/upload_my_shards.py').read()); ast.parse(open('scripts/combine_and_publish.py').read()); print('OK')"
174
+ bash -n scripts/launch_shards.sh && bash -n scripts/monitor.sh && echo "shell OK"
175
+ ```
176
+
177
+ If you need a different HF account or a different config, export the env vars
178
+ before running (no file edits needed):
179
+
180
+ ```bash
181
+ export HF_USER=youraccount
182
+ export RUN_NAME=your_run_name_matching_your_config
183
+ export CONFIG=sdg/configs/your_config.yaml
184
+ ```
185
+
186
+ ---
187
+
188
+ ## 6. Per-worker uploader
189
+
190
+ `scripts/upload_my_shards.py` runs on **every worker** — sleep-polls every 2
191
+ minutes for that worker's 8 `stats.json` to appear, then uploads each
192
+ `shards/shard_NNN/` dir to `<HF_USER>/<RUN_NAME>-staging` and writes a
193
+ `workers/worker_N.done` marker so the combiner knows that worker is finished.
194
+
195
+ Consumes near-zero resources while waiting — safe to launch any time (before,
196
+ during, or after the shards run). No file edits needed; override `HF_USER` /
197
+ `RUN_NAME` via env if you're not using defaults (see §5).
198
+
199
+ ---
200
+
201
+ ## 7. Combiner (worker_0 only)
202
+
203
+ `scripts/combine_and_publish.py` runs on **worker_0 only** — sleep-polls for all
204
+ 4 worker-done markers, downloads the staging repo, concatenates the 32
205
+ `output.jsonl` files, aggregates stats with the **full launcher.py schema**
206
+ (`summary`, `checks`, `failure_breakdown`, `failure_breakdown_examples`,
207
+ `num_shards`), enforces a row-count vs `total_passed` integrity check, and
208
+ publishes to `<HF_USER>/<RUN_NAME>` with a HuggingFace dataset-card README.
209
+
210
+ Set `HF_USER` / `RUN_NAME` / `NUM_WORKERS` / `NUM_SHARDS` via env if defaults
211
+ don't match.
212
+
213
+ ---
214
+
215
+ ## 8. Optional: smoke test before committing 40+ hours
216
+
217
+ Tiny end-to-end validation (~15 min on all 32 GPUs). Confirms multi-worker
218
+ plumbing without burning real time. Run **on each worker**:
219
+
220
+ ```bash
221
+ LIMIT=128 ./scripts/launch_shards.sh
222
+ ```
223
+
224
+ Wait ~15 min, then check completion (on each worker):
225
+
226
+ ```bash
227
+ grep -l "PIPELINE COMPLETE" logs/worker_*/shard_*.log | wc -l # should be 8
228
+ ```
229
+
230
+ If all four workers report 8, plumbing is verified. Clear the smoke outputs
231
+ before the real run:
232
+
233
+ ```bash
234
+ # On each worker
235
+ rm -rf output/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1
236
+ rm -rf logs/worker_*
237
+ # Optionally wipe the cache too (on worker_0)
238
+ pkill mongod && sleep 2
239
+ rm -rf /tmp/sdg_mongo/*
240
+ # Restart mongod (see §2.2)
241
+ ```
242
+
243
+ ---
244
+
245
+ ## 9. Production launch
246
+
247
+ ### 9.1 Launch shards on each worker
248
+
249
+ ```bash
250
+ ./scripts/launch_shards.sh
251
+ ```
252
+
253
+ ### 9.2 Start the per-worker uploader on each worker
254
+
255
+ It will sleep-poll until its 8 shards complete, then upload.
256
+
257
+ ```bash
258
+ nohup python scripts/upload_my_shards.py > upload.log 2>&1 &
259
+ disown
260
+ ```
261
+
262
+ ### 9.3 Start the combiner on worker_0 ONLY
263
+
264
+ It will sleep-poll until all 4 worker-done markers exist on HF, then combine
265
+ and publish.
266
+
267
+ ```bash
268
+ nohup python scripts/combine_and_publish.py > combine.log 2>&1 &
269
+ disown
270
+ ```
271
+
272
+ After this, you can disconnect all terminals. Work continues under `nohup`.
273
+
274
+ ### 9.4 (Recommended) Verify integrity once shards complete
275
+
276
+ Before the uploader kicks off uploads, you can sanity-check each shard locally.
277
+ For `selection_policy: first_valid` (the default), `output.jsonl` line count
278
+ must equal `total_passed` in `stats.json`. Run **on each worker**:
279
+
280
+ ```bash
281
+ RUN=output/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1/shards
282
+ for d in $RUN/shard_*; do
283
+ lines=$(wc -l < $d/output.jsonl 2>/dev/null)
284
+ passed=$(python3 -c "import json; print(json.load(open('$d/stats.json'))['summary']['total_passed'])" 2>/dev/null)
285
+ echo "$(basename $d): $lines rows | total_passed=$passed"
286
+ done
287
+ ```
288
+
289
+ Lines must match `total_passed` for every shard. The combiner re-enforces this
290
+ check globally and will `raise RuntimeError` if anything diverges — catching it
291
+ locally first is faster.
292
+
293
+ ---
294
+
295
+ ## 10. Monitoring
296
+
297
+ ### 10.1 Per-worker live view: `scripts/monitor.sh`
298
+
299
+ ```bash
300
+ watch -n 30 ./scripts/monitor.sh
301
+ # Or if `watch` isn't installed in the container:
302
+ while true; do clear; ./scripts/monitor.sh; sleep 30; done
303
+ ```
304
+
305
+ Each refresh shows: per-GPU util, the most-recent tqdm state for each shard
306
+ (decoded from `\r`-overwritten progress bars), per-worker completion counts,
307
+ and the cluster-wide MongoDB cache size (queried over IPv6 so it works from any
308
+ worker, not just worker_0).
309
+
310
+ ⚠ **Do not use `tail -1 | cut -cN`** to read shard progress — tqdm writes one
311
+ physical line with `\r` overwrites, so `cut -cN` shows the line's *beginning*
312
+ (which never changes) while progress advances at the *end*. `monitor.sh`
313
+ correctly does `tail -c 500 | tr '\r' '\n' | tail -1`.
314
+
315
+ Expected sequence in the per-shard line over the run:
316
+ 1. `Loading model:` / `Capturing CUDA graphs` (~2 min vLLM warmup)
317
+ 2. `Processed prompts: N/512 [...]` (Stage 3 generation, batch size 64 × n=8 = 512)
318
+ 3. `[Generation] N/1656 examples (...%)` (between-batch progress lines)
319
+ 4. `Round 0: M pending rows` (Stage 4 UQ starts; tqdm batches now sized by pending rows)
320
+ 5. `Cycle: ... Factual: ... Correctness: ...` (inside a UQ round)
321
+ 6. `PIPELINE COMPLETE` (shard finished)
322
+
323
+ To definitively confirm a shard is in UQ rather than generation, grep the log:
324
+
325
+ ```bash
326
+ grep -E "Round [0-9]+:|Cycle:|Factual:|Correctness:|\[Generation\]|PIPELINE" \
327
+ logs/worker_0/shard_0.log | tail -20
328
+ ```
329
+
330
+ You'll see `[Generation] 1656/1656 examples (100.0%)` followed by `Round 0:` if
331
+ generation is done.
332
+
333
+ ### 10.2 Aggregate signal: MongoDB cache size
334
+
335
+ `monitor.sh` already prints this. Standalone:
336
+
337
+ ```bash
338
+ python3 -c "
339
+ from pymongo import MongoClient
340
+ import os
341
+ host = os.environ['ARNOLD_WORKER_0_HOST']
342
+ port = os.environ['ARNOLD_WORKER_0_PORT']
343
+ print(MongoClient(f'mongodb://[{host}]:{port}').sdg_cache.inference_cache.count_documents({}))
344
+ "
345
+ ```
346
+
347
+ Grows continuously. Expect hundreds of thousands by mid-run, well over a
348
+ million during UQ. **Use the IPv6 host, not `127.0.0.1`** — only worker_0 can
349
+ reach loopback mongod.
350
+
351
+ ### 10.3 Uploader / combiner status
352
+
353
+ ```bash
354
+ tail -f upload.log # on any worker (file lives in working dir)
355
+ tail -f combine.log # on worker_0
356
+ ```
357
+
358
+ The combiner prints `DONE: https://huggingface.co/datasets/<HF_USER>/<RUN_NAME>`
359
+ when finished — that's the success signal for the entire pipeline.
360
+
361
+ ---
362
+
363
+ ## 11. Troubleshooting
364
+
365
+ ### Shards exit immediately
366
+ - `Exit 127`: `mongod` (or `python`) not on PATH. `which mongod`, re-source `~/.bashrc`.
367
+ - `Qwen2Tokenizer has no attribute all_special_tokens_extended`: transformers
368
+ too new for vLLM 0.8.5. `uv pip install "transformers>=4.51.3,<5.0"`.
369
+ - `CUDA driver version is insufficient`: torch+CUDA wheel doesn't match driver.
370
+ Reinstall via §1.2 exactly.
371
+
372
+ ### MongoDB connection refused from other workers
373
+ - mongod log shows only `0.0.0.0:PORT` (no `[::]:PORT`): missing `--ipv6` flag.
374
+ `pkill mongod`, restart with `--bind_ip_all --ipv6`.
375
+
376
+ ### `monitor.sh` shows shards stuck at `0%` for hours
377
+ - Almost always a display lie, not real stuckness. `tail -1 | cut -cN` would
378
+ show the beginning of a long `\r`-overwritten line forever; the actual
379
+ progress is at the *end*. Use `scripts/monitor.sh` (uses `tail -c 500 | tr '\r' '\n' | tail -1`).
380
+ - Confirm work is happening: GPU util ≥ 80%, log file `mtime` updates,
381
+ MongoDB cache count growing.
382
+
383
+ ### `monitor.sh` shows `mongo cache: <unreachable>` from a non-zero worker
384
+ - Workers other than worker_0 cannot reach `127.0.0.1:PORT`. The script uses
385
+ `ARNOLD_WORKER_0_HOST` over IPv6, but if that env var isn't set in the
386
+ current shell, the query fails. Re-source whatever sets Arnold env vars or
387
+ run from a fresh login shell.
388
+
389
+ ### Inline Python heredoc files won't parse
390
+ - Terminal autocomplete corrupts `cat > file <<'EOF' ... EOF`. Symptoms:
391
+ `unterminated string literal`, `import os, re, time Path` (extra word
392
+ appended), or scripts cut off mid-line.
393
+ - **Fix**: don't paste — use the committed files in `scripts/`. They survive
394
+ `git pull`. If you must hand-write, use `nano <file>` (editor paste bypasses
395
+ shell autocomplete) or base64-encode locally and `echo '...' | base64 -d > file`.
396
+
397
+ ### Trial gets reclaimed mid-run
398
+ - Shard outputs on local disk are lost (no shared FS) and the MongoDB cache at
399
+ `/tmp/sdg_mongo` dies with worker_0.
400
+ - **Mitigation**: start the uploaders in §9.2 *at launch time*, not at the
401
+ end. They sleep-poll for completion and push completed work to HF
402
+ incrementally. If a worker dies after some shards finish but before its 8
403
+ are all done, you lose only the unfinished ones.
404
+ - Once a worker has written its `worker_N.done` marker on HF, that worker's
405
+ data is durable. The combiner can run from any machine afterward, including
406
+ a fresh trial.
407
+
408
+ ### Some shards fail mid-run
409
+ - Each shard is independent; just relaunch the failed ones.
410
+ - Find failures: `grep -L "PIPELINE COMPLETE" logs/worker_*/shard_*.log`
411
+ - Relaunch one: `CUDA_VISIBLE_DEVICES=I nohup python -m sdg.generate --config <CONFIG> --shard-id N --num-shards 32 > logs/worker_W/shard_N.log 2>&1 &`
412
+ - MongoDB cache means most work is replayed for free on the rerun.
413
+
414
+ ### Published `stats.json` is missing `failure_breakdown` fields
415
+ - Old `combine_and_publish.py` versions wrote a simplified stats schema.
416
+ Current `scripts/combine_and_publish.py` includes the full schema.
417
+ - If you have a published dataset with the old stats, you can re-aggregate
418
+ from `gathered/shards/*/stats.json` (still present on worker_0 after the
419
+ combine) and re-upload `stats.json` — same logic as the combiner's stats
420
+ section, just runs standalone.
421
+
422
+ ---
423
+
424
+ ## 12. After publish: verify and clean up
425
+
426
+ ### 12.1 Verify the published dataset loads
427
+
428
+ ```bash
429
+ python3 -c "
430
+ from datasets import load_dataset
431
+ ds = load_dataset('fzzhang/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1', split='train')
432
+ print('rows:', len(ds))
433
+ print('keys:', list(ds[0].keys()))
434
+ print('roles:', [m['role'] for m in ds[0]['messages']])
435
+ "
436
+ ```
437
+
438
+ Should print the row count (~21k), `keys: ['messages']`, and the roles
439
+ (`['user', 'assistant']`). Confirms the HF dataset-card README correctly
440
+ auto-loads `output.jsonl`.
441
+
442
+ ### 12.2 Clean up local + intermediate state
443
+
444
+ ```bash
445
+ # On each worker — free disk if you plan to reuse the trial
446
+ rm -rf output/ logs/ gathered/ combined_output.jsonl combined_stats.json upload.log
447
+
448
+ # On worker_0
449
+ pkill mongod
450
+ rm -rf /tmp/sdg_mongo combine.log
451
+
452
+ # Optionally delete the intermediate staging HF repo
453
+ HF_USER=fzzhang RUN_NAME=qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1 python -c "
454
+ import os
455
+ from huggingface_hub import HfApi
456
+ repo = f\"{os.environ['HF_USER']}/{os.environ['RUN_NAME']}-staging\"
457
+ HfApi().delete_repo(repo_id=repo, repo_type='dataset')
458
+ print('deleted', repo)
459
+ "
460
+ ```
461
+
462
+ The final HF dataset is independent of the staging repo and the MongoDB
463
+ cache — deleting them after the run is purely cosmetic.
464
+
465
+ ---
466
+
467
+ ## Step-by-step playbook
468
+
469
+ Every command in execution order. The "Where" column tells you which worker to
470
+ run the command on. Commands marked "every worker" should be run on workers 0,
471
+ 1, 2, AND 3 (in parallel terminals to save wall time). Steps numbered with a
472
+ letter (e.g. 5a) are part of the same logical step but run on different
473
+ workers.
474
+
475
+ ### Step 1 — get the code (every worker)
476
+
477
+ ```bash
478
+ cd ~/lmc_muon # or wherever you keep the repo
479
+ git clone <your-repo-url> self-verified-distillation
480
+ cd self-verified-distillation
481
+ ```
482
+
483
+ ### Step 2 — Python env + pinned deps (every worker)
484
+
485
+ ```bash
486
+ conda create -n sdg python=3.12 -y
487
+ conda activate sdg
488
+ pip install -U uv
489
+ uv pip install -r sdg/requirements.txt
490
+ uv pip uninstall vllm torch torchvision torchaudio flashinfer-python triton
491
+ uv pip install torch==2.6.0 torchvision==0.21.0 \
492
+ --index-url https://download.pytorch.org/whl/cu124
493
+ uv pip install "vllm==0.8.5" "transformers>=4.51.3,<5.0"
494
+ python -c "import torch; x=torch.zeros(1).cuda(); print('OK', (x+1).item())"
495
+ # Must print: OK 1.0
496
+ ```
497
+
498
+ ### Step 3 — HuggingFace login (every worker)
499
+
500
+ ```bash
501
+ huggingface-cli login # paste your token
502
+ python -c "from huggingface_hub import HfApi; print(HfApi().whoami()['name'])"
503
+ # Must print your HF username
504
+ ```
505
+
506
+ ### Step 4 — install mongod (worker_0 ONLY)
507
+
508
+ ```bash
509
+ cd ~/lmc_muon/self-verified-distillation
510
+ curl -O https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-8.0.4.tgz
511
+ tar xzf mongodb-linux-x86_64-ubuntu2204-8.0.4.tgz
512
+ export PATH="$PWD/mongodb-linux-x86_64-ubuntu2204-8.0.4/bin:$PATH"
513
+ echo "export PATH=\"$PWD/mongodb-linux-x86_64-ubuntu2204-8.0.4/bin:\$PATH\"" >> ~/.bashrc
514
+ which mongod # must print a path
515
+ ```
516
+
517
+ ### Step 5 — start mongod (worker_0 ONLY)
518
+
519
+ ```bash
520
+ mkdir -p /tmp/sdg_mongo
521
+ nohup mongod --port $ARNOLD_WORKER_0_PORT --dbpath /tmp/sdg_mongo \
522
+ --bind_ip_all --ipv6 > mongo.log 2>&1 &
523
+ sleep 5
524
+ grep -i "Listening on" mongo.log
525
+ # Must show BOTH 0.0.0.0:<port> AND [::]:<port>
526
+
527
+ python3 -c "from pymongo import MongoClient; \
528
+ MongoClient(f'mongodb://[::1]:$ARNOLD_WORKER_0_PORT', serverSelectionTimeoutMS=5000).admin.command('ping'); \
529
+ print('mongo OK locally')"
530
+ ```
531
+
532
+ ### Step 6 — verify cross-worker mongo reachability (workers 1, 2, 3)
533
+
534
+ ```bash
535
+ python3 -c "
536
+ import os
537
+ from pymongo import MongoClient
538
+ host = os.environ['ARNOLD_WORKER_0_HOST']
539
+ port = os.environ['ARNOLD_WORKER_0_PORT']
540
+ uri = f'mongodb://[{host}]:{port}'
541
+ MongoClient(uri, serverSelectionTimeoutMS=5000).admin.command('ping')
542
+ print(f'mongo OK from {os.uname().nodename} → {uri}')
543
+ "
544
+ ```
545
+
546
+ All three must print `mongo OK from ...`. If any times out, the platform is
547
+ blocking inter-worker traffic — stop here.
548
+
549
+ ### Step 7 — patch the config (every worker)
550
+
551
+ ```bash
552
+ python3 <<'EOF'
553
+ import os, yaml
554
+ host = os.environ['ARNOLD_WORKER_0_HOST']
555
+ port = os.environ['ARNOLD_WORKER_0_PORT']
556
+ path = 'sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml'
557
+ with open(path) as f: cfg = yaml.safe_load(f)
558
+ cfg['mongo_uri'] = f'mongodb://[{host}]:{port}'
559
+ cfg['num_shards'] = 32
560
+ with open(path, 'w') as f: yaml.dump(cfg, f, sort_keys=False)
561
+ print('updated:', cfg['mongo_uri'])
562
+ EOF
563
+ ```
564
+
565
+ ### Step 8 ��� verify scripts/ are intact (every worker)
566
+
567
+ ```bash
568
+ python -c "import ast; ast.parse(open('scripts/upload_my_shards.py').read()); ast.parse(open('scripts/combine_and_publish.py').read()); print('OK')"
569
+ bash -n scripts/launch_shards.sh && bash -n scripts/monitor.sh && echo "shell OK"
570
+ ```
571
+
572
+ ### Step 9 — (optional) smoke test (every worker)
573
+
574
+ ```bash
575
+ LIMIT=128 ./scripts/launch_shards.sh
576
+ # Wait ~15-20 min, then check:
577
+ grep -l "PIPELINE COMPLETE" logs/worker_*/shard_*.log | wc -l # must be 8
578
+ ```
579
+
580
+ If smoke passes, clear outputs before the real run:
581
+
582
+ ```bash
583
+ # Every worker:
584
+ rm -rf output/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1
585
+ rm -rf logs/worker_*
586
+ # Worker_0 only — wipe cache and restart mongod:
587
+ pkill mongod && sleep 2 && rm -rf /tmp/sdg_mongo/*
588
+ # Then redo Step 5 on worker_0
589
+ ```
590
+
591
+ ### Step 10 — launch the real run (every worker)
592
+
593
+ ```bash
594
+ ./scripts/launch_shards.sh
595
+ # Expected: 8 PIDs printed. ~5 min later, all 8 GPUs should be at ~95% util.
596
+ ```
597
+
598
+ ### Step 11 — start the per-worker uploader (every worker)
599
+
600
+ Safe to launch immediately — it sleep-polls until the worker's shards finish.
601
+
602
+ ```bash
603
+ nohup python scripts/upload_my_shards.py > upload.log 2>&1 &
604
+ disown
605
+ ```
606
+
607
+ ### Step 12 — start the combiner (worker_0 ONLY)
608
+
609
+ ```bash
610
+ nohup python scripts/combine_and_publish.py > combine.log 2>&1 &
611
+ disown
612
+ ```
613
+
614
+ ### Step 13 — monitor (any worker, anytime)
615
+
616
+ ```bash
617
+ watch -n 30 ./scripts/monitor.sh
618
+ # Or, if `watch` isn't installed:
619
+ while true; do clear; ./scripts/monitor.sh; sleep 30; done
620
+ ```
621
+
622
+ After this you can disconnect all terminals. The shards, uploaders, and
623
+ combiner all run under `nohup`.
624
+
625
+ ### Step 14 — (recommended) per-shard integrity check after shards finish (every worker)
626
+
627
+ When `monitor.sh` shows `stats.json: 8/8` and `PIPELINE COMPLETE: 8/8` on a
628
+ worker, sanity-check its outputs:
629
+
630
+ ```bash
631
+ RUN=output/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1/shards
632
+ for d in $RUN/shard_*; do
633
+ lines=$(wc -l < $d/output.jsonl 2>/dev/null)
634
+ passed=$(python3 -c "import json; print(json.load(open('$d/stats.json'))['summary']['total_passed'])" 2>/dev/null)
635
+ echo "$(basename $d): $lines rows | total_passed=$passed"
636
+ done
637
+ ```
638
+
639
+ `lines` must equal `total_passed` for every shard. If they diverge, the
640
+ combiner will refuse to publish — fix the broken shard before its uploader
641
+ starts pushing.
642
+
643
+ ### Step 15 — wait for `DONE` (worker_0)
644
+
645
+ ```bash
646
+ tail -f combine.log
647
+ # Watch for: DONE: https://huggingface.co/datasets/<HF_USER>/<RUN_NAME>
648
+ ```
649
+
650
+ ### Step 16 — verify the published dataset loads (any machine with HF access)
651
+
652
+ ```bash
653
+ python3 -c "
654
+ from datasets import load_dataset
655
+ ds = load_dataset('fzzhang/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1', split='train')
656
+ print('rows:', len(ds))
657
+ print('keys:', list(ds[0].keys()))
658
+ print('roles:', [m['role'] for m in ds[0]['messages']])
659
+ "
660
+ ```
661
+
662
+ ### Step 17 — (optional) cleanup
663
+
664
+ ```bash
665
+ # Every worker
666
+ rm -rf output/ logs/ gathered/ combined_output.jsonl combined_stats.json upload.log
667
+
668
+ # Worker_0 only
669
+ pkill mongod
670
+ rm -rf /tmp/sdg_mongo combine.log
671
+
672
+ # Delete the intermediate staging HF repo (worker_0)
673
+ HF_USER=fzzhang \
674
+ RUN_NAME=qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1 \
675
+ python -c "
676
+ import os
677
+ from huggingface_hub import HfApi
678
+ repo = f\"{os.environ['HF_USER']}/{os.environ['RUN_NAME']}-staging\"
679
+ HfApi().delete_repo(repo_id=repo, repo_type='dataset')
680
+ print('deleted', repo)
681
+ "
682
+ ```
683
+
684
+ ---
685
+
686
+ ## Success signal
687
+
688
+ The whole pipeline succeeds when `combine.log` on worker_0 prints:
689
+ ```
690
+ DONE: https://huggingface.co/datasets/<HF_USER>/<RUN_NAME>
691
+ ```
692
+
693
+ Everything else (per-shard logs, mongo cache count, GPU util, staging repo,
694
+ upload markers) is plumbing that supports that one line.
gpu-sft/.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ runs/
4
+ hf_cache/
5
+ evalout/
6
+ *.npy
gpu-sft/EVAL_RESULTS.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GPU eval results (PARTIAL) — captured 2026-09-13 before a machine reorg
2
+
3
+ Raw per-task JSONs live on HDFS at `/mnt/hdfs/fangzhao_writable/marin-eval/results`
4
+ (durable across the reorg). This file is the accessible-anywhere (GitHub) copy of the
5
+ headline numbers so they're not gated behind HDFS access.
6
+
7
+ **Eval stack:** driver 535, cu124 **vLLM 0.8.5 + evalchemy@7f24168**, `gpu_eval_driver.py`,
8
+ tp=8, temp 0.7 / top_p 1.0 / max_gen 32768, seeds 42..51 (AIME*/AMC23/HMMT n=10; MATH500 &
9
+ OlympiadBench n=1; LiveCodeBench* n=6). Per the port's design, GPU numbers are **not
10
+ bit-comparable** to the TPU tables (different kernels/seeding) — deltas are computed vs the
11
+ `qwen3_8b_base` row below.
12
+
13
+ ## qwen3_8b_base — baseline (the (+Δ) denominator)
14
+
15
+ ### math
16
+ | task | mean | std | seeds |
17
+ |---|---|---|---|
18
+ | MATH500 | 0.9080 | – | 1 |
19
+ | OlympiadBench | 0.6795 | – | 1 |
20
+ | AIME24 | 0.7500 | 0.0478 | 10 |
21
+ | AIME25 | 0.6467 | 0.0358 | 10 |
22
+ | AIME26 | 0.6833 | 0.0572 | 10 |
23
+ | AMC23 | 0.9425 | 0.0290 | 10 |
24
+ | HMMT | **FAILED** (exited 1 @ 11s — bug, see `_raw/HMMT.evalchemy.log`) | – | – |
25
+
26
+ ### code
27
+ | task | mean | std | seeds |
28
+ |---|---|---|---|
29
+ | LiveCodeBench | 0.6181 | 0.0230 | 6 |
30
+ | LiveCodeBenchv5_official | not finished (killed mid-run) | – | – |
31
+ | LiveCodeBenchv6_official | pending | – | – |
32
+
33
+ ## Gate 2 (eval parity) — PASS
34
+ GPU AIME24 **0.750 [±0.048]** vs TPU reference **0.740 [±0.049]** → within ±1σ. GPU baseline
35
+ runs slightly higher than the TPU reference across the board (MATH500 90.8 vs 89.2,
36
+ OlympiadBench 68.0 vs 62.5, AIME25 64.7 vs 60.0, AIME26 68.3 vs 62.0) — a consistent, small
37
+ upward shift consistent with the known kernel/seeding differences (not bit-comparable, as
38
+ designed). The eval stack is validated.
39
+
40
+ ## Still pending (resume on the next machine)
41
+ - **baseline:** HMMT (fix the quick failure), LiveCodeBench v5/v6.
42
+ - **experiments (needed for the deltas):** `hs_competition_nofilter` (math),
43
+ `coding_depth4v3_nofilter` (code). A last-hour salvage of hs AIME24 may have banked —
44
+ append here if it did.
45
+ - then `claude/compile_results_local.py` over the HDFS results → `results.md` with `(+Δ)`.
46
+
47
+ ## Trained models (durable on HDFS, final step-1999)
48
+ - `/mnt/hdfs/fangzhao_writable/marin-sft/coding_depth4v3_nofilter/hf/step-1999` — DONE (2000 steps)
49
+ - `/mnt/hdfs/fangzhao_writable/marin-sft/hs_competition_nofilter/hf/step-1999` — DONE (2000 steps)
50
+ - `science_depth4v3_nofilter` — re-running fresh (`_v3`) on a separate machine
gpu-sft/README.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # gpu-sft — Qwen3 self-instill SFT + eval (TPU→GPU port)
2
+
3
+ Downstream of the SDG pipeline: **train Qwen3 on the self-instill datasets** (the
4
+ `fzzhang/qwen3_8b_*_instill_...` sets our SDG runs produced) → **eval** on
5
+ math/science/code → regenerate **`results.md`**.
6
+
7
+ ## Provenance
8
+ All scripts here were **extracted verbatim** from Tony's handoff doc
9
+ `fangzhao.md` (Appendix A), via a fenced-code parser — not hand-typed.
10
+ **They have NOT been run on a GPU** (written on macOS, no CUDA). Treat as a strong
11
+ first draft to smoke-test on one node; the acceptance tests are the two parity
12
+ gates in §10 of the handoff. Do not trust any experiment number until Gate 1
13
+ (training-loss parity) and Gate 2 (eval-baseline parity) pass.
14
+
15
+ ## Pipeline
16
+ ```
17
+ HF dataset@rev → scripts/gpu_sft/prepare_sft_data.py (CPU) → packed .npy + assistant mask
18
+ → scripts/gpu_sft/train_sft_qwen3.py (GPU FSDP) → hf/step-N/ (bf16)
19
+ → scripts/gpu_eval/gpu_eval_driver.py (vLLM+evalchemy) → eval JSON
20
+ → claude/compile_results_local.py → results.md
21
+ ```
22
+
23
+ ## Environment (two venvs — mandatory; see handoff §5.1)
24
+ - `sft-train`: torch 2.7 cu124 + transformers 4.57 + flash-attn (this dir's `requirements-gpu.txt`).
25
+ - `sft-eval`: vLLM (its own torch) + `datasets>=3.1,<4` + pinned evalchemy (via `scripts/gpu_eval/bootstrap_gpu_eval.sh`).
26
+ - `HF_HOME` on the big local disk, NOT `$HOME`. Pin `datasets<4.0` in both (v4 breaks LiveCodeBench).
27
+ - Gated licences: the `fzzhang` HF account must accept `cais/hle` AND `Idavidrein/gpqa` before any eval.
28
+
29
+ ## Storage plan (this cluster: Azure ND96isr H100 v5, 4 nodes × 8×H100-80G, not preemptible)
30
+ - **No HDFS, no cross-node FS.** 8B@32K fits on ONE node → run **4 independent single-node experiments**, one per node.
31
+ - Working dir + `HF_HOME` + active checkpoints → **local NVMe** (`/`, ~3.3 TB free; one 8B experiment ~2 TB fits).
32
+ - Durable artifact = **bf16 `hf/step-N` exports → private HF repo** `fzzhang/<exp>-stepN` (existing workflow; durable + eval-anywhere).
33
+ - Tradeoff: HF export drops the fp32 optimizer state, so no bit-exact training resume. Given not-preemptible + ~1.5-day runs, accept re-run risk. Revisit HDFS write-perm only if bit-exact auto-resume is needed.
34
+
35
+ ## Status
36
+ - **Phase 0 (zero-GPU): DONE** — all 8 Python files compile; the `results.md` compiler is validated end-to-end via `claude/make_fixture_eval_tree.py` → `claude/compile_results_local.py` (correct §9 format).
37
+ - **Phase 1 (1 node): pending** — two venvs + gated-licence preflight → `prepare_sft_data.py` on one dataset (with the §10 sanity asserts) → tiny smoke train → parity gate (`coding_depth4v3_n8_vr5`, 2000 steps) vs Tony's W&B (`tonyhlee/instill_ot4`).
38
+ - **Phase 2:** GPU baselines (Qwen3-8B) into a fresh results root → experiments.
39
+
40
+ ## Zero-GPU compiler self-test (reproduce anywhere)
41
+ ```
42
+ python claude/make_fixture_eval_tree.py /tmp/fixture
43
+ python claude/compile_results_local.py --root /tmp/fixture/evalout \
44
+ --models /tmp/fixture/evals.md --output /tmp/fixture/results.md \
45
+ --timestamp "2026-09-01 12:00" --no-cache
46
+ cat /tmp/fixture/results.md
47
+ ```
gpu-sft/claude/compile_results_local.py ADDED
@@ -0,0 +1,1220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright The Marin Authors
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Compile LOCAL evalchemy outputs into results.md.
6
+
7
+ Local-disk port of claude/compile_results.py (the GCS/Iris version that produced
8
+ the 2897-line results.md). Same output format, byte-for-byte: same header line,
9
+ same column sets, same delta/std cell format, same Val Sigma Delta / Test Sigma
10
+ Delta formulas, same per-column star rule, same Best Checkpoints and Missing
11
+ Evals blocks.
12
+
13
+ Only the INPUT layer changed: gcsfs -> os.scandir. See LAYOUT below.
14
+
15
+ Usage
16
+ -----
17
+ # one eval root
18
+ python compile_results_local.py --root /data/marin/evaluation/evalchemy
19
+
20
+ # several roots (e.g. two boxes rsynced into one tree); FIRST root wins on
21
+ # conflicting complete values, exactly like the multi-region merge did
22
+ python compile_results_local.py --root /data/a/evaluation/evalchemy \
23
+ --root /data/b/evaluation/evalchemy
24
+
25
+ # deterministic regeneration (frozen timestamp) for byte-diffing
26
+ python compile_results_local.py --root ... --timestamp "2026-08-08 09:19"
27
+
28
+ # only report what is not yet evaluated
29
+ python compile_results_local.py --root ... --missing-only
30
+
31
+ LAYOUT
32
+ ------
33
+ <root>/
34
+ Qwen_Qwen3-8B/ # baseline: model id, '/' -> '_'
35
+ MATH500_seed42/**/results_<ISO>.json # single-seed benchmark
36
+ AIME24_seed42/**/results_<ISO>.json # per-seed dirs, seeds 42..51
37
+ ...
38
+ AIME24_seed51/**/results_<ISO>.json
39
+ compile_AIME24_avg10seeds/compiled_results/averaged_results.json # optional
40
+ <experiment_name>-step<N>/ # one dir per checkpoint
41
+ <same benchmark dirs as above>
42
+
43
+ A trailing "-<hash>" on any of those directory names is tolerated
44
+ (`AIME24_seed42-1a2b3c`, `compile_AIME24_avg10seeds-009af9`) so a tree copied
45
+ down from GCS works unchanged.
46
+
47
+ Per-seed file schema (what the reader needs):
48
+ {"results": {"<evalchemy task name>": {"accuracy_avg": 0.6333}}} # or "accuracy"
49
+ Compile file schema:
50
+ [{"correct_mean": 0.6567, "correct_std": 0.0686, "num_seeds": 10}]
51
+
52
+ Scores are fractions in [0, 1]; every displayed number is value * 100.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import argparse
58
+ import datetime
59
+ import json
60
+ import os
61
+ import re
62
+ import sys
63
+ import tempfile
64
+ from concurrent.futures import ThreadPoolExecutor, as_completed
65
+ from dataclasses import dataclass, field
66
+ from decimal import ROUND_HALF_UP, Decimal
67
+ from pathlib import Path
68
+
69
+ # Some eval result JSONs (HMMT) contain integers like (26!)^3 that exceed
70
+ # CPython's default int->str digit cap.
71
+ sys.set_int_max_str_digits(0)
72
+
73
+
74
+ def _round_half_up(x: float, ndigits: int = 1) -> float:
75
+ """Round half away from zero (i.e. 62.35 -> 62.4, not 62.3 like banker's)."""
76
+ quant = Decimal("1") / (Decimal("10") ** ndigits)
77
+ return float(Decimal(repr(x)).quantize(quant, rounding=ROUND_HALF_UP))
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Configuration - parsed from evals.md (single source of truth)
82
+ # ---------------------------------------------------------------------------
83
+
84
+ DEFAULT_MODELS_MD = Path(__file__).parent / "evals.md"
85
+
86
+
87
+ @dataclass
88
+ class ModelSizeGroup:
89
+ """A group of experiments sharing the same model size and baseline."""
90
+
91
+ label: str # e.g. "Qwen3 4B"
92
+ baseline: str # e.g. "Qwen/Qwen3-4B"
93
+ experiments: list[str] = field(default_factory=list)
94
+
95
+
96
+ def _detect_size(text: str) -> str | None:
97
+ """Detect model size like '4B', '0.6B', or '1.7B' from a string."""
98
+ m = re.search(r"\b(\d+(?:\.\d+)?[Bb])\b", text)
99
+ return m.group(1).upper() if m else None
100
+
101
+
102
+ def parse_models_md(path: Path) -> tuple[list[str], list[str], list[ModelSizeGroup]]:
103
+ """Parse baselines, experiments, and model-size groups from evals.md.
104
+
105
+ Expects a ``## Models to Evaluate`` section containing ``### Baselines``
106
+ and one or more ``### ...`` subsections with ``- model_name`` entries.
107
+ Returns (all_baselines, all_experiments, model_size_groups).
108
+
109
+ NOTE: group order is `int(re.sub(r"\\D", "", size))`, so 0.6B -> 6 and
110
+ 1.7B -> 17. That is why results.md orders sections 4B, 0.6B, 8B, 1.7B, 32B.
111
+ Preserved deliberately: changing it reorders the whole document.
112
+ """
113
+ text = path.read_text()
114
+ baselines: list[str] = []
115
+ experiments: list[str] = []
116
+
117
+ size_baselines: dict[str, str] = {} # "4B" -> "Qwen/Qwen3-4B"
118
+ size_experiments: dict[str, list[str]] = {} # "4B" -> [...]
119
+
120
+ in_models_section = False
121
+ current_is_baseline = False
122
+ current_size: str | None = None
123
+
124
+ for line in text.splitlines():
125
+ stripped = line.strip()
126
+
127
+ if stripped.startswith("## "):
128
+ in_models_section = stripped == "## Models to Evaluate"
129
+ continue
130
+
131
+ if not in_models_section:
132
+ continue
133
+
134
+ if stripped.startswith("### "):
135
+ current_is_baseline = "baseline" in stripped.lower()
136
+ current_size = None if current_is_baseline else _detect_size(stripped)
137
+ continue
138
+
139
+ if stripped.startswith("- "):
140
+ name = stripped[2:].strip()
141
+ if not name:
142
+ continue
143
+ if current_is_baseline:
144
+ baselines.append(name)
145
+ size = _detect_size(name)
146
+ if size:
147
+ size_baselines[size] = name
148
+ else:
149
+ experiments.append(name)
150
+ size = current_size or _detect_size(name)
151
+ if size:
152
+ size_experiments.setdefault(size, []).append(name)
153
+
154
+ groups: list[ModelSizeGroup] = []
155
+ for size in sorted(size_baselines.keys(), key=lambda s: int(re.sub(r"\D", "", s))):
156
+ groups.append(
157
+ ModelSizeGroup(
158
+ label=f"Qwen3 {size}",
159
+ baseline=size_baselines[size],
160
+ experiments=size_experiments.get(size, []),
161
+ )
162
+ )
163
+
164
+ return baselines, experiments, groups
165
+
166
+
167
+ # Populated in main(); module-level so the builder functions read them like the
168
+ # original script did.
169
+ BASELINES: list[str] = []
170
+ EXPERIMENTS: list[str] = []
171
+ MODEL_SIZE_GROUPS: list[ModelSizeGroup] = []
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Benchmark suites - val benchmarks drive Val Sigma Delta, test drive Test Sigma Delta
176
+ # ---------------------------------------------------------------------------
177
+
178
+ MATH_VAL = ["MATH500", "OlympiadBench", "AIME24", "AIME25"]
179
+ MATH_TEST = ["AIME26", "HMMT"]
180
+ MATH_COLUMNS = MATH_VAL + MATH_TEST
181
+
182
+ BENCHMARKS_MULTISEED = ["AIME24", "AIME25", "AIME26", "HMMT"]
183
+ BENCHMARKS_SINGLE = ["MATH500", "OlympiadBench"]
184
+ ALL_BENCHMARKS = BENCHMARKS_SINGLE + BENCHMARKS_MULTISEED
185
+
186
+ SCIENCE_VAL = ["JEEBENCH", "OLYMPIADBENCH_PHYSICS"]
187
+ SCIENCE_TEST = ["GPQA_DIAMOND", "HUMANITYS_LAST_EXAM"]
188
+ SCIENCE_COLUMNS = SCIENCE_VAL + SCIENCE_TEST
189
+
190
+ SCIENCE_MULTISEED = ["GPQA_DIAMOND", "JEEBENCH", "HUMANITYS_LAST_EXAM", "OLYMPIADBENCH_PHYSICS"]
191
+ SCIENCE_SINGLE: list[str] = []
192
+ ALL_SCIENCE_BENCHMARKS = SCIENCE_SINGLE + SCIENCE_MULTISEED
193
+
194
+ CODE_VAL = ["LIVECODEBENCH"]
195
+ CODE_TEST = ["LIVECODEBENCH_V5_OFFICIAL", "LIVECODEBENCH_V6_OFFICIAL"]
196
+ CODE_COLUMNS = CODE_VAL + CODE_TEST
197
+
198
+ CODE_MULTISEED = ["LIVECODEBENCH", "LIVECODEBENCH_V5_OFFICIAL", "LIVECODEBENCH_V6_OFFICIAL"]
199
+ CODE_SINGLE: list[str] = []
200
+ ALL_CODE_BENCHMARKS = CODE_SINGLE + CODE_MULTISEED
201
+
202
+ # Display column name -> evalchemy task name (the name used in directory names
203
+ # AND as the key inside results_*.json["results"]).
204
+ BENCH_TO_TASK = {
205
+ "GPQA_DIAMOND": "GPQADiamond",
206
+ "JEEBENCH": "JEEBench",
207
+ "HUMANITYS_LAST_EXAM": "HLE",
208
+ "OLYMPIADBENCH_PHYSICS": "OlympiadBench_Physics",
209
+ "LIVECODEBENCH": "LiveCodeBench",
210
+ "LIVECODEBENCH_V5_OFFICIAL": "LiveCodeBenchv5_official",
211
+ "LIVECODEBENCH_V6_OFFICIAL": "LiveCodeBenchv6_official",
212
+ }
213
+
214
+ # Benchmarks whose evalchemy compile step re-grades with naive string equality
215
+ # and is therefore WRONG: symbolic (HMMT), tolerance-based
216
+ # (OlympiadBench_Physics), execution-based (LiveCodeBench*, JEEBench). For these
217
+ # we never read a compile file; we average per-seed accuracy_avg ourselves.
218
+ SKIP_COMPILE_BENCHMARKS = {
219
+ "HMMT",
220
+ "LIVECODEBENCH",
221
+ "LIVECODEBENCH_V5_OFFICIAL",
222
+ "LIVECODEBENCH_V6_OFFICIAL",
223
+ "JEEBENCH",
224
+ "OLYMPIADBENCH_PHYSICS",
225
+ }
226
+
227
+ # A multiseed cell is emitted only when this many seeds are present.
228
+ BENCH_EXPECTED_SEEDS = {
229
+ "AIME24": 10,
230
+ "AIME25": 10,
231
+ "AIME26": 10,
232
+ "AMC23": 10,
233
+ "HMMT": 10,
234
+ "GPQA_DIAMOND": 3,
235
+ "JEEBENCH": 3,
236
+ "HUMANITYS_LAST_EXAM": 3,
237
+ "OLYMPIADBENCH_PHYSICS": 3,
238
+ "LIVECODEBENCH": 6,
239
+ "LIVECODEBENCH_V5_OFFICIAL": 6,
240
+ "LIVECODEBENCH_V6_OFFICIAL": 6,
241
+ }
242
+
243
+ # Single-seed benchmarks live in a seed42 dir.
244
+ SINGLE_SEED = 42
245
+
246
+
247
+ def suite_for_model(name: str) -> str:
248
+ """Which eval suite an experiment belongs to. Substring match on the name."""
249
+ lower = name.lower()
250
+ if "science" in lower:
251
+ return "science"
252
+ if "code" in lower or "coding" in lower or "rstarcoder" in lower:
253
+ return "code"
254
+ if "nemotron_cascade2" in lower:
255
+ return "science"
256
+ return "math"
257
+
258
+
259
+ def benchmarks_for_model(name: str, is_baseline: bool) -> list[str]:
260
+ """All benchmarks that should be checked for a model."""
261
+ if is_baseline:
262
+ return ALL_BENCHMARKS + ALL_SCIENCE_BENCHMARKS + ALL_CODE_BENCHMARKS
263
+ suite = suite_for_model(name)
264
+ if suite == "science":
265
+ return list(ALL_SCIENCE_BENCHMARKS)
266
+ if suite == "code":
267
+ return list(ALL_CODE_BENCHMARKS)
268
+ return list(ALL_BENCHMARKS)
269
+
270
+
271
+ MAX_WORKERS = 8
272
+ DEFAULT_OUTPUT = "results.md"
273
+ RESULTS_FILE_RE = re.compile(r"results_.*\.json$")
274
+ HASH_SUFFIX = r"(?:-[0-9A-Za-z]+)?"
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Local filesystem helpers
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ def list_subdirs(path: str) -> list[str]:
283
+ """Basenames of immediate subdirectories of ``path``. [] if absent."""
284
+ try:
285
+ with os.scandir(path) as it:
286
+ return sorted(e.name for e in it if e.is_dir())
287
+ except (FileNotFoundError, NotADirectoryError, PermissionError):
288
+ return []
289
+
290
+
291
+ def find_results_files(path: str) -> list[str]:
292
+ """Recursively find results_*.json under ``path``, lexicographically sorted."""
293
+ out: list[str] = []
294
+ for dirpath, _dirnames, filenames in os.walk(path):
295
+ for fn in filenames:
296
+ if RESULTS_FILE_RE.search(fn):
297
+ out.append(os.path.join(dirpath, fn))
298
+ return sorted(out)
299
+
300
+
301
+ def read_json(path: str):
302
+ """Parse a JSON file. Returns None on missing/unreadable/unparseable."""
303
+ try:
304
+ with open(path, encoding="utf-8") as fh:
305
+ return json.load(fh)
306
+ except (OSError, json.JSONDecodeError, ValueError):
307
+ return None
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Extracted-scalar cache
312
+ # ---------------------------------------------------------------------------
313
+ #
314
+ # A finished results_*.json is immutable, and LiveCodeBench files run to tens of
315
+ # MB while we only ever pull one scalar out. Cache the extracted scalar keyed by
316
+ # (path, mtime_ns, size) so a re-run after new evals land re-parses only the new
317
+ # files. Directory listings are NOT cached, so newly finished evals are always
318
+ # discovered.
319
+
320
+
321
+ @dataclass
322
+ class ScalarCache:
323
+ root: Path | None
324
+ hits: int = 0
325
+ misses: int = 0
326
+
327
+ def _entry_path(self, path: str) -> Path:
328
+ assert self.root is not None
329
+ return self.root / (path.lstrip("/") + ".json")
330
+
331
+ @staticmethod
332
+ def _stat_key(path: str) -> tuple[int, int] | None:
333
+ try:
334
+ st = os.stat(path)
335
+ except OSError:
336
+ return None
337
+ return (st.st_mtime_ns, st.st_size)
338
+
339
+ @staticmethod
340
+ def _write_atomic(path: Path, content: str) -> None:
341
+ path.parent.mkdir(parents=True, exist_ok=True)
342
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
343
+ try:
344
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
345
+ fh.write(content)
346
+ os.replace(tmp, path)
347
+ except BaseException:
348
+ try:
349
+ os.unlink(tmp)
350
+ except OSError:
351
+ pass
352
+ raise
353
+
354
+ def get(self, path: str, compute):
355
+ """Return ``compute()``'s small dict record for ``path``, via cache."""
356
+ key = self._stat_key(path)
357
+ if self.root is None or key is None:
358
+ self.misses += 1
359
+ return compute()
360
+
361
+ entry = self._entry_path(path)
362
+ if entry.exists():
363
+ rec = read_json(str(entry))
364
+ if isinstance(rec, dict) and rec.get("_key") == list(key):
365
+ self.hits += 1
366
+ return rec.get("rec")
367
+ try:
368
+ entry.unlink()
369
+ except OSError:
370
+ pass
371
+
372
+ self.misses += 1
373
+ rec = compute()
374
+ if rec is not None:
375
+ self._write_atomic(entry, json.dumps({"_key": list(key), "rec": rec}))
376
+ return rec
377
+
378
+
379
+ _CACHE = ScalarCache(root=None)
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Discovery
384
+ # ---------------------------------------------------------------------------
385
+
386
+
387
+ @dataclass
388
+ class ModelInfo:
389
+ name: str
390
+ is_baseline: bool = False
391
+ checkpoint_dir: str | None = None # only set by the checkpoint fallback
392
+ checkpoints: list[str] = field(default_factory=list) # ["step-100", ...]
393
+
394
+
395
+ def _step_sort_key(step: str) -> int:
396
+ m = re.search(r"\d+", step)
397
+ return int(m.group()) if m else 0
398
+
399
+
400
+ def _steps_from_checkpoint_root(exp: str, checkpoint_root: str | None) -> tuple[str | None, list[str]]:
401
+ """Fallback: list <checkpoint_root>/<exp>[-<hash>]/hf/step-* .
402
+
403
+ Mirrors the GCS script's behaviour of surfacing not-yet-evaluated checkpoints
404
+ so they show up under Missing Evals. Picks the folder with the most steps
405
+ when a config hash produced more than one output dir.
406
+ """
407
+ if not checkpoint_root:
408
+ return None, []
409
+ best_dir, best_steps = None, []
410
+ for name in list_subdirs(checkpoint_root):
411
+ m = re.match(r"^(.+?)(?:-[0-9a-f]{4,})?$", name)
412
+ prefix = m.group(1) if m else name
413
+ if not (prefix == exp or exp.startswith(prefix)):
414
+ continue
415
+ folder = os.path.join(checkpoint_root, name)
416
+ steps = sorted(
417
+ (d for d in list_subdirs(os.path.join(folder, "hf")) if re.search(r"\d+", d)),
418
+ key=_step_sort_key,
419
+ )
420
+ if len(steps) > len(best_steps):
421
+ best_dir, best_steps = folder, steps
422
+ return best_dir, best_steps
423
+
424
+
425
+ def discover_models(root: str, checkpoint_root: str | None) -> list[ModelInfo]:
426
+ """Discover steps from eval-result directory names under one root.
427
+
428
+ Step discovery is driven by eval dirs named ``<exp>-step<N>`` - NOT by
429
+ checkpoint listings - so an experiment shows up as soon as it has any eval
430
+ output. ``checkpoint_root`` is only consulted for experiments with zero eval
431
+ dirs.
432
+ """
433
+ models: list[ModelInfo] = [ModelInfo(name=b, is_baseline=True) for b in BASELINES]
434
+
435
+ all_eval_dirs = list_subdirs(root)
436
+
437
+ for exp in EXPERIMENTS:
438
+ pattern = re.compile(re.escape(exp) + r"-step(\d+)$")
439
+ steps = sorted(
440
+ (f"step-{m.group(1)}" for m in (pattern.match(d) for d in all_eval_dirs) if m),
441
+ key=_step_sort_key,
442
+ )
443
+ if steps:
444
+ models.append(ModelInfo(name=exp, checkpoints=steps))
445
+ continue
446
+ folder, ckpt_steps = _steps_from_checkpoint_root(exp, checkpoint_root)
447
+ models.append(ModelInfo(name=exp, checkpoint_dir=folder, checkpoints=ckpt_steps))
448
+
449
+ return models
450
+
451
+
452
+ def merge_models(region_models: list[list[ModelInfo]]) -> list[ModelInfo]:
453
+ """Merge per-root discovery, preserving evals.md ordering. Steps are unioned."""
454
+ if not region_models:
455
+ return []
456
+ by_root = [{m.name: m for m in models} for models in region_models]
457
+ merged: list[ModelInfo] = []
458
+ for model in region_models[0]:
459
+ variants = [models[model.name] for models in by_root if model.name in models]
460
+ checkpoints = sorted({s for v in variants for s in v.checkpoints}, key=_step_sort_key)
461
+ ckpt_dir = next((v.checkpoint_dir for v in variants if v.checkpoint_dir), None)
462
+ merged.append(
463
+ ModelInfo(
464
+ name=model.name,
465
+ is_baseline=model.is_baseline,
466
+ checkpoint_dir=ckpt_dir,
467
+ checkpoints=checkpoints,
468
+ )
469
+ )
470
+ return merged
471
+
472
+
473
+ def merge_metric_maps(
474
+ root_maps: list[dict[tuple[str, str, str], float | None]],
475
+ roots: list[str],
476
+ ) -> dict[tuple[str, str, str], float | None]:
477
+ """Merge per-root metrics. First root with a non-None value wins."""
478
+ merged: dict[tuple[str, str, str], float | None] = {}
479
+ source: dict[tuple[str, str, str], str] = {}
480
+ for root, metrics in zip(roots, root_maps, strict=True):
481
+ for key, value in metrics.items():
482
+ existing = merged.get(key)
483
+ if existing is None and value is not None:
484
+ merged[key] = value
485
+ source[key] = root
486
+ elif key not in merged:
487
+ merged[key] = None
488
+ elif existing is not None and value is not None and abs(existing - value) > 1e-12:
489
+ print(
490
+ f"WARNING: Conflicting complete results for {key}: "
491
+ f"{source[key]}={existing}, {root}={value}; keeping {source[key]}",
492
+ file=sys.stderr,
493
+ )
494
+ return merged
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # Reading
499
+ # ---------------------------------------------------------------------------
500
+
501
+
502
+ def eval_dir_name(model: ModelInfo, step: str) -> str:
503
+ """Directory name under <root> holding one model/checkpoint's eval output."""
504
+ if model.is_baseline:
505
+ return model.name.replace("/", "_")
506
+ m = re.search(r"\d+", step)
507
+ return f"{model.name}-step{m.group()}" if m else model.name
508
+
509
+
510
+ def _pick_results_file(files: list[str], newest: bool) -> str | None:
511
+ """Choose among several results_<ISO>.json in one seed dir.
512
+
513
+ Names embed an ISO timestamp, so lexicographic order is chronological.
514
+ ``newest=True`` (default) takes the last re-run; the GCS script took the
515
+ first. Only matters when a seed dir was evaluated more than once.
516
+ """
517
+ if not files:
518
+ return None
519
+ return files[-1] if newest else files[0]
520
+
521
+
522
+ def read_seed_result(seed_dir: str, task: str, newest: bool) -> float | None:
523
+ """Read one seed's accuracy from results_*.json under ``seed_dir``."""
524
+ results_file = _pick_results_file(find_results_files(seed_dir), newest)
525
+ if results_file is None:
526
+ return None
527
+
528
+ def _compute():
529
+ d = read_json(results_file)
530
+ if d is None:
531
+ return None
532
+ try:
533
+ r = d["results"][task]
534
+ except (KeyError, TypeError):
535
+ return None
536
+ val = r.get("accuracy_avg", r.get("accuracy"))
537
+ return None if val is None else {"value": val}
538
+
539
+ rec = _CACHE.get(results_file, _compute)
540
+ return rec["value"] if rec is not None else None
541
+
542
+
543
+ def read_compile_result(compile_dir: str, expected_seeds: int | None) -> tuple[float, float] | None:
544
+ """Read (mean, std) from <compile_dir>/compiled_results/averaged_results.json."""
545
+ path = os.path.join(compile_dir, "compiled_results", "averaged_results.json")
546
+
547
+ def _compute():
548
+ d = read_json(path)
549
+ if d is None:
550
+ return None
551
+ try:
552
+ return {
553
+ "mean": d[0]["correct_mean"],
554
+ "std": d[0].get("correct_std", 0.0),
555
+ "num_seeds": d[0].get("num_seeds", 0),
556
+ }
557
+ except (KeyError, IndexError, TypeError):
558
+ return None
559
+
560
+ rec = _CACHE.get(path, _compute)
561
+ if rec is None:
562
+ return None
563
+ if expected_seeds is not None and rec.get("num_seeds", 0) < expected_seeds:
564
+ print(
565
+ f"WARNING: Incomplete seeds in {compile_dir}: {rec.get('num_seeds', 0)}/{expected_seeds}",
566
+ file=sys.stderr,
567
+ )
568
+ return None
569
+ return (rec["mean"], rec["std"])
570
+
571
+
572
+ def _mean_and_sample_std(values: list[float]) -> tuple[float, float | None]:
573
+ """Unweighted mean and SAMPLE (ddof=1) standard deviation."""
574
+ mean = sum(values) / len(values)
575
+ if len(values) < 2:
576
+ return mean, None
577
+ variance = sum((v - mean) ** 2 for v in values) / (len(values) - 1)
578
+ return mean, variance**0.5
579
+
580
+
581
+ @dataclass
582
+ class CellSources:
583
+ """Where one (model, step, benchmark) cell can be read from."""
584
+
585
+ compile_dir: str | None = None
586
+ seed_dirs: dict[int, str] = field(default_factory=dict) # seed -> dir
587
+
588
+
589
+ def _scan_eval_dir(eval_path: str, benchmarks: list[str]) -> dict[str, CellSources]:
590
+ """Index one eval dir's subdirectories by benchmark."""
591
+ entries = list_subdirs(eval_path)
592
+ out: dict[str, CellSources] = {}
593
+ for bench in benchmarks:
594
+ task = BENCH_TO_TASK.get(bench, bench)
595
+ src = CellSources()
596
+ seed_re = re.compile(rf"^{re.escape(task)}_seed(\d+){HASH_SUFFIX}$")
597
+ compile_re = re.compile(rf"^compile_{re.escape(task)}_avg(\d+)seeds{HASH_SUFFIX}$")
598
+ expected = BENCH_EXPECTED_SEEDS.get(bench)
599
+ best_compile_n = -1
600
+ for name in entries:
601
+ m = seed_re.match(name)
602
+ if m:
603
+ src.seed_dirs[int(m.group(1))] = os.path.join(eval_path, name)
604
+ continue
605
+ m = compile_re.match(name)
606
+ if m:
607
+ n = int(m.group(1))
608
+ # Prefer a compile dir whose seed count matches expectations;
609
+ # otherwise keep the largest.
610
+ score = 10**6 if (expected is not None and n == expected) else n
611
+ if score > best_compile_n:
612
+ best_compile_n = score
613
+ src.compile_dir = os.path.join(eval_path, name)
614
+ if src.compile_dir or src.seed_dirs:
615
+ out[bench] = src
616
+ return out
617
+
618
+
619
+ def gather_results(
620
+ models: list[ModelInfo],
621
+ root: str,
622
+ pool: ThreadPoolExecutor,
623
+ per_seed_only: bool,
624
+ newest_results_file: bool,
625
+ ) -> tuple[dict[tuple[str, str, str], float | None], dict[tuple[str, str, str], float | None]]:
626
+ """Read every (model, step, benchmark) cell under one root.
627
+
628
+ Returns (results, std_devs), each {(model_name, step_label, benchmark): value|None}.
629
+ Baseline rows use the empty string as the step label.
630
+ """
631
+ # Pass 1: index directories. Baselines merge the underscore and hyphen
632
+ # variants of the model id (Qwen_Qwen3-8B and Qwen-Qwen3-8B).
633
+ sources: dict[tuple[str, str, str], CellSources] = {}
634
+ results: dict[tuple[str, str, str], float | None] = {}
635
+ std_devs: dict[tuple[str, str, str], float | None] = {}
636
+
637
+ for model in models:
638
+ benchmarks = benchmarks_for_model(model.name, model.is_baseline)
639
+ steps = [""] if model.is_baseline else model.checkpoints
640
+ for step in steps:
641
+ dir_names = [eval_dir_name(model, step)]
642
+ if model.is_baseline:
643
+ alt = model.name.replace("/", "-")
644
+ if alt != dir_names[0]:
645
+ dir_names.append(alt)
646
+ per_bench: dict[str, CellSources] = {}
647
+ for dn in dir_names:
648
+ for bench, src in _scan_eval_dir(os.path.join(root, dn), benchmarks).items():
649
+ merged = per_bench.setdefault(bench, CellSources())
650
+ merged.compile_dir = merged.compile_dir or src.compile_dir
651
+ merged.seed_dirs.update(src.seed_dirs)
652
+ for bench in benchmarks:
653
+ key = (model.name, step, bench)
654
+ results[key] = None
655
+ std_devs[key] = None
656
+ if bench in per_bench:
657
+ sources[key] = per_bench[bench]
658
+
659
+ # Pass 2: single-seed benchmarks (MATH500, OlympiadBench) - one file, no std.
660
+ single_futs = {}
661
+ for key, src in sources.items():
662
+ _, _, bench = key
663
+ if bench not in BENCHMARKS_SINGLE:
664
+ continue
665
+ seed_dir = src.seed_dirs.get(SINGLE_SEED) or (
666
+ sorted(src.seed_dirs.items())[0][1] if src.seed_dirs else None
667
+ )
668
+ if seed_dir is None:
669
+ continue
670
+ task = BENCH_TO_TASK.get(bench, bench)
671
+ single_futs[pool.submit(read_seed_result, seed_dir, task, newest_results_file)] = key
672
+ for fut in as_completed(single_futs):
673
+ key = single_futs[fut]
674
+ results[key] = fut.result()
675
+ std_devs[key] = None
676
+
677
+ # Pass 3: multiseed benchmarks read from a compile dir when one is usable.
678
+ compile_futs = {}
679
+ for key, src in sources.items():
680
+ _, _, bench = key
681
+ if bench in BENCHMARKS_SINGLE or bench in SKIP_COMPILE_BENCHMARKS:
682
+ continue
683
+ if per_seed_only or not src.compile_dir:
684
+ continue
685
+ expected = BENCH_EXPECTED_SEEDS.get(bench)
686
+ compile_futs[pool.submit(read_compile_result, src.compile_dir, expected)] = key
687
+ for fut in as_completed(compile_futs):
688
+ key = compile_futs[fut]
689
+ got = fut.result()
690
+ if got is not None:
691
+ results[key], std_devs[key] = got
692
+
693
+ # Pass 4: everything still unread is averaged from per-seed accuracy_avg with
694
+ # a SAMPLE (n-1) std. Requires exactly the expected number of seeds; a
695
+ # partial sweep stays missing rather than reporting a biased mean.
696
+ seed_futs: dict[object, tuple[tuple[str, str, str], int]] = {}
697
+ seed_counts: dict[tuple[str, str, str], int] = {}
698
+ for key, src in sources.items():
699
+ _, _, bench = key
700
+ if bench in BENCHMARKS_SINGLE or results.get(key) is not None:
701
+ continue
702
+ if not src.seed_dirs:
703
+ continue
704
+ task = BENCH_TO_TASK.get(bench, bench)
705
+ seed_counts[key] = len(src.seed_dirs)
706
+ for seed, seed_dir in sorted(src.seed_dirs.items()):
707
+ seed_futs[pool.submit(read_seed_result, seed_dir, task, newest_results_file)] = (key, seed)
708
+
709
+ seed_values: dict[tuple[str, str, str], list[float]] = {}
710
+ for fut in as_completed(seed_futs):
711
+ key, _seed = seed_futs[fut]
712
+ val = fut.result()
713
+ if val is not None:
714
+ seed_values.setdefault(key, []).append(val)
715
+
716
+ for key, n_dirs in seed_counts.items():
717
+ _, _, bench = key
718
+ values = seed_values.get(key, [])
719
+ expected = BENCH_EXPECTED_SEEDS.get(bench, n_dirs)
720
+ if len(values) == expected:
721
+ mean, std = _mean_and_sample_std(values)
722
+ results[key] = mean
723
+ std_devs[key] = std
724
+ else:
725
+ results[key] = None
726
+ std_devs[key] = None
727
+ if values:
728
+ print(
729
+ f"WARNING: {key[0]} {key[1] or '-'} {bench}: {len(values)}/{expected} seeds; "
730
+ "reporting as missing",
731
+ file=sys.stderr,
732
+ )
733
+
734
+ return results, std_devs
735
+
736
+
737
+ # ---------------------------------------------------------------------------
738
+ # Computed columns
739
+ # ---------------------------------------------------------------------------
740
+
741
+
742
+ def compute_sigma(
743
+ model_name: str,
744
+ step: str,
745
+ results: dict[tuple[str, str, str], float | None],
746
+ baseline: dict[str, float | None],
747
+ benchmarks: list[str],
748
+ ) -> float | None:
749
+ """Sum of (value - baseline) over ``benchmarks``, in percentage points.
750
+
751
+ UNROUNDED - used for star selection and Best-Checkpoints argmax so nearly
752
+ tied steps are not collapsed by rounding. None if any benchmark is missing.
753
+ """
754
+ if not benchmarks:
755
+ return None
756
+ total = 0.0
757
+ for b in benchmarks:
758
+ v = results.get((model_name, step, b))
759
+ bv = baseline.get(b)
760
+ if v is None or bv is None:
761
+ return None
762
+ total += (v - bv) * 100
763
+ return total
764
+
765
+
766
+ def compute_display_sigma(
767
+ model_name: str,
768
+ step: str,
769
+ results: dict[tuple[str, str, str], float | None],
770
+ baseline: dict[str, float | None],
771
+ benchmarks: list[str],
772
+ ) -> float | None:
773
+ """Display-only Sigma: sum of the per-cell ROUNDED diffs, then rounded again.
774
+
775
+ This is what gets printed, so a reader adding up the visible cell deltas
776
+ reproduces the Sigma exactly.
777
+ """
778
+ if not benchmarks:
779
+ return None
780
+ total = 0.0
781
+ for b in benchmarks:
782
+ v = results.get((model_name, step, b))
783
+ bv = baseline.get(b)
784
+ if v is None or bv is None:
785
+ return None
786
+ total += _round_half_up(v * 100, 1) - _round_half_up(bv * 100, 1)
787
+ return _round_half_up(total, 1)
788
+
789
+
790
+ def compute_stars(
791
+ model: ModelInfo,
792
+ results: dict[tuple[str, str, str], float | None],
793
+ val_sigmas: dict[str, float | None],
794
+ test_sigmas: dict[str, float | None],
795
+ columns: list[str],
796
+ ) -> set[tuple[str, str]]:
797
+ """(step, column) pairs that get a star.
798
+
799
+ Per-column, per-experiment best value. Never across experiments, never on a
800
+ baseline row. Needs >= 2 steps with a value in that column, compares the
801
+ RAW float, and stars every step tied at the max.
802
+ """
803
+ stars: set[tuple[str, str]] = set()
804
+
805
+ for col in columns:
806
+ non_none = [(s, results.get((model.name, s, col))) for s in model.checkpoints]
807
+ non_none = [(s, v) for s, v in non_none if v is not None]
808
+ if len(non_none) < 2:
809
+ continue
810
+ best = max(v for _, v in non_none)
811
+ stars.update((s, col) for s, v in non_none if v == best)
812
+
813
+ for label, sigmas in (("Val Σ Δ", val_sigmas), ("Test Σ Δ", test_sigmas)):
814
+ non_none = [(s, sigmas.get(s)) for s in model.checkpoints]
815
+ non_none = [(s, v) for s, v in non_none if v is not None]
816
+ if len(non_none) < 2:
817
+ continue
818
+ best = max(v for _, v in non_none)
819
+ stars.update((s, label) for s, v in non_none if v == best)
820
+
821
+ return stars
822
+
823
+
824
+ # ---------------------------------------------------------------------------
825
+ # Formatting
826
+ # ---------------------------------------------------------------------------
827
+
828
+
829
+ def _fmt_cell(val: float | None, baseline_val: float | None, is_baseline: bool, std: float | None = None) -> str:
830
+ """One table cell: '88.4', '65.7 [6.9]', '61.3 (-4.4)', '66.7 (+1.0) [4.4]', '❌'."""
831
+ if val is None:
832
+ return "❌"
833
+ pct_rounded = _round_half_up(val * 100, 1)
834
+ pct = f"{pct_rounded:.1f}"
835
+ if is_baseline or baseline_val is None:
836
+ if std is not None:
837
+ return f"{pct} [{_round_half_up(std * 100, 1):.1f}]"
838
+ return pct
839
+ # Delta is the difference of the two ALREADY-ROUNDED percentages.
840
+ diff = _round_half_up(pct_rounded - _round_half_up(baseline_val * 100, 1), 1)
841
+ sign = "+" if diff >= 0 else ""
842
+ if std is not None:
843
+ return f"{pct} ({sign}{diff:.1f}) [{_round_half_up(std * 100, 1):.1f}]"
844
+ return f"{pct} ({sign}{diff:.1f})"
845
+
846
+
847
+ def _fmt_step(step: str) -> str:
848
+ m = re.search(r"\d+", step)
849
+ return m.group() if m else step
850
+
851
+
852
+ def _fmt_sigma(ts: float | None) -> str:
853
+ if ts is None:
854
+ return "N/A"
855
+ ts_rounded = _round_half_up(ts, 1)
856
+ sign = "+" if ts_rounded >= 0 else ""
857
+ return f"{sign}{ts_rounded:.1f}"
858
+
859
+
860
+ def _experiment_compute_order_key(name: str) -> tuple[int, int, int, int, str]:
861
+ """Order experiments from least to most sampling/filtering compute."""
862
+ if "nofilter" in name:
863
+ return (0, 0, 0, 0, name)
864
+ n_match = re.search(r"_n(\d+)(?:_|$)", name)
865
+ vr_match = re.search(r"_vr(\d+)(?:_|$)", name)
866
+ n_value = int(n_match.group(1)) if n_match else 10**9
867
+ vr_value = int(vr_match.group(1)) if vr_match else 10**9
868
+ variant_rank = 1 if "allvalid" in name else 0
869
+ return (1, n_value, vr_value, variant_rank, name)
870
+
871
+
872
+ SUITE_CONFIG = {
873
+ "math": {
874
+ "title": "Math",
875
+ "columns": MATH_COLUMNS,
876
+ "val": MATH_VAL,
877
+ "test": MATH_TEST,
878
+ "all_benchmarks": ALL_BENCHMARKS,
879
+ },
880
+ "science": {
881
+ "title": "Science",
882
+ "columns": SCIENCE_COLUMNS,
883
+ "val": SCIENCE_VAL,
884
+ "test": SCIENCE_TEST,
885
+ "all_benchmarks": ALL_SCIENCE_BENCHMARKS,
886
+ },
887
+ "code": {
888
+ "title": "Code",
889
+ "columns": CODE_COLUMNS,
890
+ "val": CODE_VAL,
891
+ "test": CODE_TEST,
892
+ "all_benchmarks": ALL_CODE_BENCHMARKS,
893
+ },
894
+ }
895
+
896
+
897
+ def _build_suite_section(
898
+ suite: str,
899
+ baseline_model: ModelInfo | None,
900
+ baseline_vals: dict[str, float | None],
901
+ experiment_models: list[ModelInfo],
902
+ results: dict[tuple[str, str, str], float | None],
903
+ std_devs: dict[tuple[str, str, str], float | None] | None = None,
904
+ heading_level: str = "###",
905
+ ) -> list[str]:
906
+ """Build one suite section: main table, Best Checkpoints, Missing Evals."""
907
+ cfg = SUITE_CONFIG[suite]
908
+ columns: list[str] = cfg["columns"]
909
+ val_benchmarks: list[str] = cfg["val"]
910
+ test_benchmarks: list[str] = cfg["test"]
911
+ all_benchmarks: list[str] = cfg["all_benchmarks"]
912
+
913
+ lines: list[str] = []
914
+ lines.append(f"{heading_level} {cfg['title']}")
915
+ lines.append("")
916
+
917
+ header_cols = ["Model", "Step"] + columns + ["Val Σ Δ", "Test Σ Δ"]
918
+ lines.append("| " + " | ".join(header_cols) + " |")
919
+ lines.append("|" + "|".join(["---"] * len(header_cols)) + "|")
920
+
921
+ if baseline_model:
922
+ cells = [
923
+ _fmt_cell(
924
+ baseline_vals.get(b),
925
+ None,
926
+ True,
927
+ std=std_devs.get((baseline_model.name, "", b)) if std_devs else None,
928
+ )
929
+ for b in columns
930
+ ]
931
+ cells += ["-", "-"]
932
+ lines.append(f"| {baseline_model.name} | - | " + " | ".join(cells) + " |")
933
+
934
+ suite_models = [m for m in experiment_models if suite_for_model(m.name) == suite]
935
+
936
+ for model in suite_models:
937
+ # Blank separator row before every experiment block (including the first).
938
+ lines.append("| | | " + " | ".join([""] * (len(columns) + 2)) + " |")
939
+
940
+ if not model.checkpoints:
941
+ cells = ["❌"] * len(columns) + ["N/A", "N/A"]
942
+ lines.append(f"| {model.name} | - | " + " | ".join(cells) + " |")
943
+ continue
944
+
945
+ val_sigmas: dict[str, float | None] = {}
946
+ test_sigmas: dict[str, float | None] = {}
947
+ val_display: dict[str, float | None] = {}
948
+ test_display: dict[str, float | None] = {}
949
+ for step in model.checkpoints:
950
+ val_sigmas[step] = compute_sigma(model.name, step, results, baseline_vals, val_benchmarks)
951
+ test_sigmas[step] = compute_sigma(model.name, step, results, baseline_vals, test_benchmarks)
952
+ val_display[step] = compute_display_sigma(model.name, step, results, baseline_vals, val_benchmarks)
953
+ test_display[step] = compute_display_sigma(model.name, step, results, baseline_vals, test_benchmarks)
954
+
955
+ # Stars use the unrounded sigmas so close scores aren't tied by rounding.
956
+ stars = compute_stars(model, results, val_sigmas, test_sigmas, columns)
957
+
958
+ for step in model.checkpoints:
959
+ cells = []
960
+ for bench in columns:
961
+ v = results.get((model.name, step, bench))
962
+ sd = std_devs.get((model.name, step, bench)) if std_devs else None
963
+ s = _fmt_cell(v, baseline_vals.get(bench), False, std=sd)
964
+ if (step, bench) in stars:
965
+ s = f"⭐ {s}"
966
+ cells.append(s)
967
+
968
+ vs_s = _fmt_sigma(val_display.get(step))
969
+ if (step, "Val Σ Δ") in stars:
970
+ vs_s = f"⭐ {vs_s}"
971
+ cells.append(vs_s)
972
+
973
+ ts_s = _fmt_sigma(test_display.get(step))
974
+ if (step, "Test Σ Δ") in stars:
975
+ ts_s = f"⭐ {ts_s}"
976
+ cells.append(ts_s)
977
+
978
+ lines.append(f"| {model.name} | {_fmt_step(step)} | " + " | ".join(cells) + " |")
979
+
980
+ # Best Checkpoints - argmax over the UNROUNDED Val Sigma Delta, all ties emitted,
981
+ # rows in compute order, no blank separator rows.
982
+ lines.append("")
983
+ lines.append(f"{heading_level}# Best Checkpoints (by Val Σ Δ)")
984
+ lines.append("")
985
+ lines.append("| " + " | ".join(header_cols) + " |")
986
+ lines.append("|" + "|".join(["---"] * len(header_cols)) + "|")
987
+
988
+ for model in sorted(suite_models, key=lambda m: _experiment_compute_order_key(m.name)):
989
+ if not model.checkpoints:
990
+ continue
991
+ best_val: float = -1e9
992
+ best_steps: list[str] = []
993
+ for step in model.checkpoints:
994
+ vs = compute_sigma(model.name, step, results, baseline_vals, val_benchmarks)
995
+ if vs is None:
996
+ continue
997
+ if vs > best_val:
998
+ best_val, best_steps = vs, [step]
999
+ elif vs == best_val:
1000
+ best_steps.append(step)
1001
+ for step in best_steps:
1002
+ cells = [
1003
+ _fmt_cell(
1004
+ results.get((model.name, step, b)),
1005
+ baseline_vals.get(b),
1006
+ False,
1007
+ std=std_devs.get((model.name, step, b)) if std_devs else None,
1008
+ )
1009
+ for b in columns
1010
+ ]
1011
+ cells.append(_fmt_sigma(compute_display_sigma(model.name, step, results, baseline_vals, val_benchmarks)))
1012
+ cells.append(_fmt_sigma(compute_display_sigma(model.name, step, results, baseline_vals, test_benchmarks)))
1013
+ lines.append(f"| {model.name} | {_fmt_step(step)} | " + " | ".join(cells) + " |")
1014
+
1015
+ # Missing Evals - in evals.md order, not compute order.
1016
+ lines.append("")
1017
+ lines.append(f"{heading_level}# Missing Evals")
1018
+ lines.append("")
1019
+ any_missing = False
1020
+ for model in suite_models:
1021
+ for step in model.checkpoints:
1022
+ missing = [b for b in all_benchmarks if results.get((model.name, step, b)) is None]
1023
+ if missing:
1024
+ lines.append(f"- {model.name} step-{_fmt_step(step)} (missing: {', '.join(missing)})")
1025
+ any_missing = True
1026
+ if not any_missing:
1027
+ lines.append("All evaluations complete!")
1028
+
1029
+ return lines
1030
+
1031
+
1032
+ def build_results_md(
1033
+ models: list[ModelInfo],
1034
+ results: dict[tuple[str, str, str], float | None],
1035
+ std_devs: dict[tuple[str, str, str], float | None] | None = None,
1036
+ timestamp: str | None = None,
1037
+ ) -> str:
1038
+ """Render the whole document. Exactly one trailing newline."""
1039
+ lines: list[str] = []
1040
+
1041
+ # Header counts EXCLUDE baselines and count experiment x step x suite-benchmark.
1042
+ total_cells = 0
1043
+ complete_cells = 0
1044
+ for m in models:
1045
+ if m.is_baseline:
1046
+ continue
1047
+ benchmarks = benchmarks_for_model(m.name, m.is_baseline)
1048
+ for s in m.checkpoints:
1049
+ for b in benchmarks:
1050
+ total_cells += 1
1051
+ if results.get((m.name, s, b)) is not None:
1052
+ complete_cells += 1
1053
+
1054
+ stamp = timestamp or datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
1055
+ lines.append("# Evalchemy Evaluation Results")
1056
+ lines.append("")
1057
+ lines.append("")
1058
+ lines.append(f"*{complete_cells} of {total_cells} benchmark results gathered. Generated at {stamp}.*")
1059
+
1060
+ model_by_name: dict[str, ModelInfo] = {m.name: m for m in models}
1061
+
1062
+ for group in MODEL_SIZE_GROUPS:
1063
+ lines.append("")
1064
+ lines.append(f"## {group.label}")
1065
+
1066
+ baseline_model = model_by_name.get(group.baseline)
1067
+ baseline_vals: dict[str, float | None] = {}
1068
+ if baseline_model:
1069
+ for bench in MATH_COLUMNS + SCIENCE_COLUMNS + CODE_COLUMNS:
1070
+ baseline_vals[bench] = results.get((baseline_model.name, "", bench))
1071
+
1072
+ experiment_models = [model_by_name[e] for e in group.experiments if e in model_by_name]
1073
+
1074
+ suites_present = {suite_for_model(m.name) for m in experiment_models}
1075
+ if baseline_model:
1076
+ for suite_key in ["math", "science", "code"]:
1077
+ if any(baseline_vals.get(b) is not None for b in SUITE_CONFIG[suite_key]["columns"]):
1078
+ suites_present.add(suite_key)
1079
+
1080
+ for suite_key in ["math", "science", "code"]:
1081
+ if suite_key not in suites_present:
1082
+ continue
1083
+ lines.append("")
1084
+ lines.extend(
1085
+ _build_suite_section(
1086
+ suite_key,
1087
+ baseline_model,
1088
+ baseline_vals,
1089
+ experiment_models,
1090
+ results,
1091
+ std_devs=std_devs,
1092
+ heading_level="###",
1093
+ )
1094
+ )
1095
+
1096
+ return "\n".join(lines) + "\n"
1097
+
1098
+
1099
+ # ---------------------------------------------------------------------------
1100
+ # Missing-eval report
1101
+ # ---------------------------------------------------------------------------
1102
+
1103
+
1104
+ def print_missing(models: list[ModelInfo], results: dict[tuple[str, str, str], float | None]) -> None:
1105
+ for model in models:
1106
+ if model.is_baseline or not model.checkpoints:
1107
+ continue
1108
+ benchmarks = benchmarks_for_model(model.name, model.is_baseline)
1109
+ for step in model.checkpoints:
1110
+ missing = [b for b in benchmarks if results.get((model.name, step, b)) is None]
1111
+ if missing:
1112
+ print(f"{model.name} {step} (missing: {', '.join(missing)}) [{suite_for_model(model.name)}]")
1113
+
1114
+
1115
+ # ---------------------------------------------------------------------------
1116
+ # Entry point
1117
+ # ---------------------------------------------------------------------------
1118
+
1119
+
1120
+ def main() -> None:
1121
+ global BASELINES, EXPERIMENTS, MODEL_SIZE_GROUPS, _CACHE
1122
+
1123
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1124
+ parser.add_argument(
1125
+ "--root",
1126
+ dest="roots",
1127
+ action="append",
1128
+ required=True,
1129
+ help="Local eval-output root (repeatable; first root wins on conflicts)",
1130
+ )
1131
+ parser.add_argument(
1132
+ "--checkpoint-root",
1133
+ default=None,
1134
+ help="Optional <dir>/<exp>-<hash>/hf/step-N tree, used only for experiments with no eval output yet",
1135
+ )
1136
+ parser.add_argument("--models", default=str(DEFAULT_MODELS_MD), help="Markdown file with '## Models to Evaluate'")
1137
+ parser.add_argument("--output", default=DEFAULT_OUTPUT, help="Output path for results.md")
1138
+ parser.add_argument("--workers", type=int, default=MAX_WORKERS, help="Parallel file readers")
1139
+ parser.add_argument("--timestamp", default=None, help="Freeze the header timestamp (format: 'YYYY-MM-DD HH:MM')")
1140
+ parser.add_argument("--missing-only", action="store_true", help="Report missing evals; do not write results.md")
1141
+ parser.add_argument(
1142
+ "--per-seed-only",
1143
+ action="store_true",
1144
+ help=(
1145
+ "Ignore compile dirs entirely and average per-seed accuracy for every multiseed benchmark "
1146
+ "(requires per-seed dirs for all of them; benchmarks that only have a compile dir become missing)"
1147
+ ),
1148
+ )
1149
+ parser.add_argument(
1150
+ "--first-results-file",
1151
+ action="store_true",
1152
+ help="On re-evaluated seed dirs, read the OLDEST results_*.json (GCS script behaviour) instead of the newest",
1153
+ )
1154
+ parser.add_argument("--cache-dir", default=None, help="Extracted-scalar cache dir (default: <output dir>/.eval_cache)")
1155
+ parser.add_argument("--no-cache", action="store_true", help="Disable the scalar cache")
1156
+ args = parser.parse_args()
1157
+
1158
+ models_md = Path(args.models)
1159
+ if not models_md.exists():
1160
+ raise SystemExit(f"Models file not found: {models_md}")
1161
+ BASELINES, EXPERIMENTS, MODEL_SIZE_GROUPS = parse_models_md(models_md)
1162
+ if not MODEL_SIZE_GROUPS:
1163
+ raise SystemExit(f"No model-size groups parsed from {models_md} (need '## Models to Evaluate' + '### Baselines')")
1164
+
1165
+ if args.no_cache:
1166
+ _CACHE = ScalarCache(root=None)
1167
+ else:
1168
+ cache_dir = args.cache_dir or os.path.join(os.path.dirname(os.path.abspath(args.output)) or ".", ".eval_cache")
1169
+ _CACHE = ScalarCache(root=Path(cache_dir))
1170
+
1171
+ roots = [os.path.abspath(r.rstrip("/")) for r in args.roots]
1172
+ print(f"Roots: {', '.join(roots)}", file=sys.stderr)
1173
+ print(f"Models: {len(BASELINES)} baselines, {len(EXPERIMENTS)} experiments", file=sys.stderr)
1174
+
1175
+ with ThreadPoolExecutor(max_workers=args.workers) as pool:
1176
+ per_root_models = [discover_models(root, args.checkpoint_root) for root in roots]
1177
+ models = merge_models(per_root_models)
1178
+
1179
+ per_root_results = []
1180
+ per_root_stds = []
1181
+ for root, root_models in zip(roots, per_root_models, strict=True):
1182
+ # Read each root against the MERGED step list so a step discovered in
1183
+ # one root is also looked for in the others.
1184
+ aligned = [
1185
+ ModelInfo(
1186
+ name=m.name,
1187
+ is_baseline=m.is_baseline,
1188
+ checkpoint_dir=m.checkpoint_dir,
1189
+ checkpoints=next((x.checkpoints for x in models if x.name == m.name), m.checkpoints),
1190
+ )
1191
+ for m in root_models
1192
+ ]
1193
+ r, s = gather_results(
1194
+ aligned,
1195
+ root,
1196
+ pool,
1197
+ per_seed_only=args.per_seed_only,
1198
+ newest_results_file=not args.first_results_file,
1199
+ )
1200
+ per_root_results.append(r)
1201
+ per_root_stds.append(s)
1202
+
1203
+ results = merge_metric_maps(per_root_results, roots)
1204
+ std_devs = merge_metric_maps(per_root_stds, roots)
1205
+
1206
+ if _CACHE.root is not None:
1207
+ print(f"Cache: {_CACHE.hits} hits, {_CACHE.misses} reads", file=sys.stderr)
1208
+
1209
+ if args.missing_only:
1210
+ print_missing(models, results)
1211
+ return
1212
+
1213
+ md = build_results_md(models, results, std_devs, timestamp=args.timestamp)
1214
+ with open(args.output, "w", encoding="utf-8") as fh:
1215
+ fh.write(md)
1216
+ print(f"\nWrote {args.output}", file=sys.stderr)
1217
+
1218
+
1219
+ if __name__ == "__main__":
1220
+ main()
gpu-sft/claude/evals.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local Evals
2
+
3
+ The compiler (`claude/compile_results_local.py`) parses ONLY the
4
+ `## Models to Evaluate` section below. Everything else in this file is prose for
5
+ humans.
6
+
7
+ Rules:
8
+
9
+ - `### Baselines` entries are the delta reference. One baseline per model size;
10
+ the size token (`0.6B`, `1.7B`, `4B`, `8B`, `32B`) is read out of the name.
11
+ A `## Qwen3 <SIZE>` section is emitted only if a baseline for that size exists.
12
+ - Every other `### ...` heading is an experiment group. The size comes from the
13
+ heading text, falling back to the experiment name.
14
+ - Each `- <name>` under an experiment heading must be the EXACT experiment name,
15
+ which is also the eval output directory prefix: `<name>-step<N>/`. Do not
16
+ truncate, do not add a `-step<N>` suffix here.
17
+ - Row order inside a suite table follows this file. The `#### Best Checkpoints`
18
+ table re-sorts by sampling compute (`nofilter` first, then `_n<N>` asc,
19
+ `_vr<N>` asc, `allvalid` last).
20
+ - Suite routing is a substring match on the name: `science` -> Science;
21
+ `code` / `coding` / `rstarcoder` -> Code; otherwise Math. The suite decides
22
+ which benchmarks are read AND which table the row lands in.
23
+ - Section order across sizes is `int(re.sub(r"\D", "", size))`, so 0.6B sorts as
24
+ 6 and 1.7B as 17: 4B, 0.6B, 8B, 1.7B, 32B. Known quirk, kept for continuity
25
+ with the existing results.md.
26
+
27
+ ## Models to Evaluate
28
+
29
+ ### Baselines
30
+ - Qwen/Qwen3-4B
31
+ - Qwen/Qwen3-8B
32
+
33
+ ### SFT 4B Experiments
34
+ - exp_sft_qwen3_4b_selfinstill_ot3_math53k_n8_vr5_round1
35
+
36
+ ### SFT 8B Experiments
37
+ - exp_sft_qwen3_8b_selfinstill_science_depth4v3_n8_vr5_2k_lr5e6_wd01
38
+ - exp_sft_qwen3_8b_selfinstill_coding_depth4v3_n8_vr5_2k_lr5e6_wd01
gpu-sft/claude/make_fixture_eval_tree.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright The Marin Authors
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Reference writer + self-test fixture for the local results pipeline.
6
+
7
+ Two jobs:
8
+
9
+ 1. ``write_seed_result`` / ``write_compile_result`` are the CANONICAL writers.
10
+ The local eval runner should call these (or emit byte-identical files) so
11
+ compile_results_local.py can read its output. Nothing else defines the
12
+ contract.
13
+
14
+ 2. Running this file as a script materializes a small fake eval tree plus a
15
+ matching models markdown, so the whole pipeline can be smoke-tested with no
16
+ GPU and no real eval:
17
+
18
+ python make_fixture_eval_tree.py /tmp/fixture
19
+ python compile_results_local.py --root /tmp/fixture/evalout \\
20
+ --models /tmp/fixture/evals.md --output /tmp/fixture/results.md \\
21
+ --timestamp "2026-09-01 12:00" --no-cache
22
+ cat /tmp/fixture/results.md
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import sys
30
+
31
+ # Display column name -> evalchemy task name. Must match compile_results_local.py.
32
+ BENCH_TO_TASK = {
33
+ "GPQA_DIAMOND": "GPQADiamond",
34
+ "JEEBENCH": "JEEBench",
35
+ "HUMANITYS_LAST_EXAM": "HLE",
36
+ "OLYMPIADBENCH_PHYSICS": "OlympiadBench_Physics",
37
+ "LIVECODEBENCH": "LiveCodeBench",
38
+ "LIVECODEBENCH_V5_OFFICIAL": "LiveCodeBenchv5_official",
39
+ "LIVECODEBENCH_V6_OFFICIAL": "LiveCodeBenchv6_official",
40
+ }
41
+
42
+
43
+ def model_dir_name(model: str, step: int | None) -> str:
44
+ """Eval output dir for a model/checkpoint.
45
+
46
+ Baseline (HF id, step None): 'Qwen/Qwen3-8B' -> 'Qwen_Qwen3-8B'.
47
+ Experiment: '<experiment_name>-step<N>'.
48
+ """
49
+ if step is None:
50
+ return model.replace("/", "_")
51
+ return f"{model}-step{step}"
52
+
53
+
54
+ def write_seed_result(
55
+ root: str,
56
+ model: str,
57
+ step: int | None,
58
+ task: str,
59
+ seed: int,
60
+ accuracy: float,
61
+ timestamp: str = "2026-09-01T00-00-00.000000",
62
+ extra: dict | None = None,
63
+ ) -> str:
64
+ """Write one (checkpoint, task, seed) result file. ``accuracy`` is a fraction.
65
+
66
+ Layout: <root>/<model_dir>/<task>_seed<seed>/<model_sanitized>/results_<ISO>.json
67
+ The reader takes ``results[task]["accuracy_avg"]``, falling back to
68
+ ``["accuracy"]``, so single-pass benchmarks may emit either key.
69
+ """
70
+ d = os.path.join(root, model_dir_name(model, step), f"{task}_seed{seed}", model.replace("/", "__"))
71
+ os.makedirs(d, exist_ok=True)
72
+ payload = {"results": {task: {"accuracy_avg": accuracy, **(extra or {})}}}
73
+ path = os.path.join(d, f"results_{timestamp}.json")
74
+ with open(path, "w", encoding="utf-8") as fh:
75
+ json.dump(payload, fh)
76
+ return path
77
+
78
+
79
+ def write_compile_result(
80
+ root: str,
81
+ model: str,
82
+ step: int | None,
83
+ task: str,
84
+ seeds: list[int],
85
+ mean: float,
86
+ std: float,
87
+ ) -> str:
88
+ """Write the averaged-across-seeds file for one (checkpoint, task).
89
+
90
+ Layout: <root>/<model_dir>/compile_<task>_avg<N>seeds/compiled_results/averaged_results.json
91
+
92
+ OPTIONAL. Omit it and compile_results_local.py averages the per-seed files
93
+ itself (mean + sample n-1 std), which is the recommended local mode:
94
+ evalchemy's own compile step re-grades with naive string equality and is
95
+ wrong for HMMT / JEEBench / OlympiadBench_Physics / LiveCodeBench*.
96
+ """
97
+ n = len(seeds)
98
+ d = os.path.join(root, model_dir_name(model, step), f"compile_{task}_avg{n}seeds", "compiled_results")
99
+ os.makedirs(d, exist_ok=True)
100
+ payload = [
101
+ {
102
+ "base_model_name": model,
103
+ "dataset_name": task,
104
+ "num_seeds": n,
105
+ "seeds": seeds,
106
+ "correct_mean": mean,
107
+ "correct_std": std,
108
+ }
109
+ ]
110
+ path = os.path.join(d, "averaged_results.json")
111
+ with open(path, "w", encoding="utf-8") as fh:
112
+ json.dump(payload, fh)
113
+ return path
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Fixture
118
+ # ---------------------------------------------------------------------------
119
+
120
+ EVALS_MD = """# Local Evals
121
+
122
+ ## Models to Evaluate
123
+
124
+ ### Baselines
125
+ - Qwen/Qwen3-8B
126
+
127
+ ### SFT 8B Experiments
128
+ - exp_sft_qwen3_8b_selfinstill_mynewdata_n8_vr5_2k_lr5e6_wd01
129
+ """
130
+
131
+ BASELINE = "Qwen/Qwen3-8B"
132
+ EXPERIMENT = "exp_sft_qwen3_8b_selfinstill_mynewdata_n8_vr5_2k_lr5e6_wd01"
133
+ MATH_SEEDS = list(range(42, 52)) # 10 seeds
134
+
135
+
136
+ def build(out_dir: str) -> None:
137
+ evalout = os.path.join(out_dir, "evalout")
138
+ os.makedirs(evalout, exist_ok=True)
139
+ with open(os.path.join(out_dir, "evals.md"), "w", encoding="utf-8") as fh:
140
+ fh.write(EVALS_MD)
141
+
142
+ # Baseline: MATH500/OlympiadBench single-seed, AIME* via compile files,
143
+ # HMMT via per-seed files (compile is never trusted for HMMT).
144
+ write_seed_result(evalout, BASELINE, None, "MATH500", 42, 0.884)
145
+ write_seed_result(evalout, BASELINE, None, "OlympiadBench", 42, 0.617)
146
+ write_compile_result(evalout, BASELINE, None, "AIME24", MATH_SEEDS, 0.6566666666666666, 0.06858355272004873)
147
+ write_compile_result(evalout, BASELINE, None, "AIME25", MATH_SEEDS, 0.5866666666666667, 0.045460606091052)
148
+ write_compile_result(evalout, BASELINE, None, "AIME26", MATH_SEEDS, 0.5933333333333334, 0.05426322800056239)
149
+ for seed, acc in zip(MATH_SEEDS, [0.38, 0.40, 0.42, 0.36, 0.44, 0.38, 0.40, 0.42, 0.34, 0.39], strict=True):
150
+ write_seed_result(evalout, BASELINE, None, "HMMT", seed, acc)
151
+
152
+ steps = {
153
+ 100: dict(
154
+ math500=0.872,
155
+ olympiad=0.596,
156
+ aime24=0.650,
157
+ aime25=0.567,
158
+ aime26=0.523,
159
+ hmmt=[0.36, 0.38, 0.40, 0.34, 0.42, 0.36, 0.38, 0.40, 0.32, 0.37],
160
+ ),
161
+ 200: dict(
162
+ math500=0.906,
163
+ olympiad=0.642,
164
+ aime24=0.737,
165
+ aime25=0.620,
166
+ aime26=0.637,
167
+ hmmt=[0.41, 0.43, 0.45, 0.39, 0.47, 0.41, 0.43, 0.45, 0.37, 0.42],
168
+ ),
169
+ }
170
+ for step, d in steps.items():
171
+ write_seed_result(evalout, EXPERIMENT, step, "MATH500", 42, d["math500"])
172
+ write_seed_result(evalout, EXPERIMENT, step, "OlympiadBench", 42, d["olympiad"])
173
+ write_compile_result(evalout, EXPERIMENT, step, "AIME24", MATH_SEEDS, d["aime24"], 0.0623)
174
+ write_compile_result(evalout, EXPERIMENT, step, "AIME25", MATH_SEEDS, d["aime25"], 0.0531)
175
+ write_compile_result(evalout, EXPERIMENT, step, "AIME26", MATH_SEEDS, d["aime26"], 0.0688)
176
+ for seed, acc in zip(MATH_SEEDS, d["hmmt"], strict=True):
177
+ write_seed_result(evalout, EXPERIMENT, step, "HMMT", seed, acc)
178
+
179
+ print(f"fixture: {evalout}\nmodels: {os.path.join(out_dir, 'evals.md')}")
180
+
181
+
182
+ if __name__ == "__main__":
183
+ build(sys.argv[1] if len(sys.argv) > 1 else "fixture")
gpu-sft/experiments/qwen3_chat_template.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 The Marin Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # flake8: noqa
15
+
16
+ """
17
+ Updated chat template for Qwen3 that has {% generation %} tags inserted (required by Levanter).
18
+ """
19
+
20
+ QWEN_3_CHAT_TEMPLATE = r"""{%- if tools is defined and tools %}
21
+ {{- '<|im_start|>system\n' }}
22
+ {%- if messages[0].role == 'system' %}
23
+ {{- messages[0].content + '\n\n' }}
24
+ {%- endif %}
25
+ {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
26
+ {%- for tool in tools %}
27
+ {{- "\n" }}
28
+ {{- tool | tojson }}
29
+ {%- endfor %}
30
+ {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
31
+ {%- else %}
32
+ {%- if messages[0].role == 'system' %}
33
+ {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
34
+ {%- endif %}
35
+ {%- endif %}
36
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
37
+ {%- for message in messages[::-1] %}
38
+ {%- set index = (messages|length - 1) - loop.index0 %}
39
+ {%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
40
+ {%- set ns.multi_step_tool = false %}
41
+ {%- set ns.last_query_index = index %}
42
+ {%- endif %}
43
+ {%- endfor %}
44
+ {%- for message in messages %}
45
+ {%- if message.content is string %}
46
+ {%- set content = message.content %}
47
+ {%- else %}
48
+ {%- set content = '' %}
49
+ {%- endif %}
50
+ {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
51
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
52
+ {%- elif message.role == "assistant" %}
53
+ {%- set reasoning_content = '' %}
54
+ {%- if message.reasoning_content is string %}
55
+ {%- set reasoning_content = message.reasoning_content %}
56
+ {%- else %}
57
+ {%- if '</think>' in content %}
58
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
59
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
60
+ {%- endif %}
61
+ {%- endif %}
62
+ {%- if loop.index0 > ns.last_query_index %}
63
+ {%- if loop.last or (not loop.last and reasoning_content) %}
64
+ {{- '<|im_start|>' + message.role + '\n' }}{% generation %}{{- '<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}{% endgeneration %}
65
+ {%- else %}
66
+ {{- '<|im_start|>' + message.role + '\n' }}{% generation %}{{- content }}{% endgeneration %}
67
+ {%- endif %}
68
+ {%- else %}
69
+ {{- '<|im_start|>' + message.role + '\n' }}{% generation %}{{- content }}{% endgeneration %}
70
+ {%- endif %}
71
+ {%- if message.tool_calls %}
72
+ {%- for tool_call in message.tool_calls %}
73
+ {%- if (loop.first and content) or (not loop.first) %}
74
+ {% generation %}{{- '\n' }}{% endgeneration %}
75
+ {%- endif %}
76
+ {%- if tool_call.function %}
77
+ {%- set tool_call = tool_call.function %}
78
+ {%- endif %}
79
+ {% generation %}{{- '<tool_call>\n{"name": "' }}
80
+ {{- tool_call.name }}
81
+ {{- '", "arguments": ' }}
82
+ {%- if tool_call.arguments is string %}
83
+ {{- tool_call.arguments }}
84
+ {%- else %}
85
+ {{- tool_call.arguments | tojson }}
86
+ {%- endif %}
87
+ {{- '}\n</tool_call>' }}{% endgeneration %}
88
+ {%- endfor %}
89
+ {%- endif %}
90
+ {% generation %}{{- '<|im_end|>\n' }}{% endgeneration %}
91
+ {%- elif message.role == "tool" %}
92
+ {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
93
+ {{- '<|im_start|>user' }}
94
+ {%- endif %}
95
+ {{- '\n<tool_response>\n' }}
96
+ {{- content }}
97
+ {{- '\n</tool_response>' }}
98
+ {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
99
+ {{- '<|im_end|>\n' }}
100
+ {%- endif %}
101
+ {%- endif %}
102
+ {%- endfor %}
103
+ {%- if add_generation_prompt %}
104
+ {{- '<|im_start|>assistant\n' }}
105
+ {%- if enable_thinking is defined and enable_thinking is false %}
106
+ {% generation %}{{- '<think>\n\n</think>\n\n' }}{% endgeneration %}
107
+ {%- endif %}
108
+ {%- endif %}"""
gpu-sft/requirements-gpu.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tested shape for the GPU port. Pin these; transformers in particular is load-bearing
2
+ # because the script relies on cu_seq_lens_q/k reaching flash_attn_varlen_func
3
+ # (transformers.modeling_flash_attention_utils, "Case 2" padding-free path).
4
+ --extra-index-url https://download.pytorch.org/whl/cu128
5
+ torch==2.8.0
6
+ transformers==4.57.5
7
+ tokenizers>=0.22
8
+ datasets>=3.1.0,<4.0.0 # v4 dropped dataset-script support; also what the marin eval branch pins
9
+ accelerate>=1.0.0
10
+ safetensors>=0.4.5
11
+ numpy>=1.26
12
+ jinja2>=3.1.0 # only needed by prepare_sft_data.py --verify
13
+ sentencepiece
14
+ huggingface_hub>=0.26
15
+ # FlashAttention-2 is REQUIRED for the 32K varlen path. Build takes ~20 min, or use the
16
+ # prebuilt wheel matching your torch/cuda/python/abi from the flash-attention releases page.
17
+ flash-attn==2.8.3 --no-build-isolation
18
+ # optional
19
+ wandb>=0.18
gpu-sft/scripts/gpu_eval/bootstrap_gpu_eval.sh ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Copyright The Marin Authors
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Bootstrap a self-contained CUDA evalchemy + vLLM environment for evaluating
6
+ # LOCAL HF-format checkpoints on a single GPU box. No GCS, no Iris, no TPU.
7
+ #
8
+ # ./bootstrap_gpu_eval.sh /opt/marin-gpu-eval
9
+ #
10
+ # Idempotent: re-running skips completed phases. Delete the phase markers under
11
+ # $ROOT/.markers to force a phase to re-run.
12
+ #
13
+ # ---------------------------------------------------------------------------
14
+ # WHY THESE PINS
15
+ # ---------------------------------------------------------------------------
16
+ # evalchemy teetone/evalchemy @ 7f24168 (2026-03-31)
17
+ # This is the pin the TPU pipeline used to produce the current results.md.
18
+ # Use it if you need numbers comparable to the existing tables.
19
+ # 460b022 (2026-05-07) is the newer pin carried on the marin-agents
20
+ # `evalfixhfauth` branch; it skips questions whose prompt >= max_model_len
21
+ # instead of crashing, and adds the TTC task variants. Set
22
+ # EVALCHEMY_COMMIT=460b022 if you hit prompt-length crashes.
23
+ #
24
+ # lm-eval stanford-crfm/lm-evaluation-harness @ d5e3391f22cde186c827674d5c3ec7c5f4fe0cab
25
+ # evalchemy is NOT pip-installable; it is a source checkout that imports
26
+ # lm_eval. This exact fork+commit is what Marin pins. Upstream lm-eval 0.5.x
27
+ # moved eval_logger and broke evalchemy's eval_tracker import.
28
+ #
29
+ # datasets >=3.6,<4.0.0 HARD upper bound.
30
+ # datasets v4 removed dataset-script support. LiveCodeBench loads
31
+ # `livecodebench/code_generation_lite` with trust_remote_code=True, which is
32
+ # a dataset script. On datasets>=4 every LCB task fails at load time.
33
+ #
34
+ # transformers >=4.51,<5 Qwen3 architecture support landed in 4.51.0.
35
+ #
36
+ # vllm >=0.10 CUDA wheel. Do NOT install tpu-inference / the
37
+ # `vllm` dependency group from lib/marin/pyproject.toml: that group pins
38
+ # jax==0.10.0 + libtpu==0.0.40 + tpu-inference==0.22.1 and is TPU-only.
39
+ #
40
+ # ---------------------------------------------------------------------------
41
+ # HUGGING FACE AUTH -- what the `evalfixhfauth` branch actually does
42
+ # ---------------------------------------------------------------------------
43
+ # The marin-agents branch name is stale/aspirational: no commit on it touches HF
44
+ # authentication. The real HF-related fixes on that branch are three, and a
45
+ # local user must reproduce all three:
46
+ #
47
+ # 1. HF_DATASETS_TRUST_REMOTE_CODE=1 in the eval process env (LiveCodeBench
48
+ # needs it). This script exports it in the generated activate snippet, and
49
+ # gpu_eval_driver.py sets it on every subprocess.
50
+ # 2. datasets<4.0.0 pin (above).
51
+ # 3. HF_TOKEN supplied as a process env var at submit time -- it is never in
52
+ # source. On TPU it was threaded through `iris job run -e HF_TOKEN ...`.
53
+ # LOCALLY YOU MUST DO THIS INSTEAD:
54
+ # huggingface-cli login # writes ~/.cache/huggingface/token
55
+ # or export HF_TOKEN=hf_xxx # per shell / systemd unit
56
+ # Required for gated datasets: cais/hle (HLE) and Idavidrein/gpqa
57
+ # (GPQADiamond) -- you must ALSO click "Agree" on those dataset pages with
58
+ # the same account. The math suite (MATH500, OlympiadBench, AIME24/25/26,
59
+ # HMMT) and the code suite (LiveCodeBench*) are public and need no token,
60
+ # though an anonymous box will hit HF rate limits during a 10-seed sweep.
61
+ #
62
+ set -euo pipefail
63
+
64
+ ROOT="${1:-/opt/marin-gpu-eval}"
65
+ EVALCHEMY_REPO="${EVALCHEMY_REPO:-https://github.com/teetone/evalchemy.git}"
66
+ EVALCHEMY_COMMIT="${EVALCHEMY_COMMIT:-7f24168}"
67
+ PYTHON_VERSION="${PYTHON_VERSION:-3.11}"
68
+ LM_EVAL_REQ="lm-eval[math]@git+https://github.com/stanford-crfm/lm-evaluation-harness@d5e3391f22cde186c827674d5c3ec7c5f4fe0cab"
69
+
70
+ VENV="$ROOT/venv"
71
+ EVALCHEMY_DIR="$ROOT/evalchemy"
72
+ CACHE="$ROOT/cache"
73
+ MARKERS="$ROOT/.markers"
74
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
75
+
76
+ mkdir -p "$ROOT" "$CACHE" "$MARKERS"
77
+
78
+ log() { printf '[bootstrap] %s\n' "$*" >&2; }
79
+
80
+ if ! command -v uv >/dev/null 2>&1; then
81
+ log "uv not found on PATH; install it with: curl -LsSf https://astral.sh/uv/install.sh | sh"
82
+ exit 1
83
+ fi
84
+
85
+ # --------------------------------------------------------------------------
86
+ # 1. venv
87
+ # --------------------------------------------------------------------------
88
+ if [[ ! -x "$VENV/bin/python" ]]; then
89
+ log "creating venv at $VENV (python $PYTHON_VERSION)"
90
+ uv venv --python "$PYTHON_VERSION" --seed "$VENV"
91
+ fi
92
+ PY="$VENV/bin/python"
93
+
94
+ # --------------------------------------------------------------------------
95
+ # 2. dependencies
96
+ # --------------------------------------------------------------------------
97
+ if [[ ! -f "$MARKERS/deps" ]]; then
98
+ log "installing CUDA vLLM + lm-eval + evalchemy runtime deps"
99
+ # Driver-aware: on driver 535 (no CUDA 12.6+) invoke with
100
+ # TORCH_PRE="torch==2.6.0 torchvision==0.21.0 --index-url https://download.pytorch.org/whl/cu124"
101
+ # VLLM_SPEC="vllm==0.8.5"
102
+ # vLLM >=0.9 ships cu126/cu130 wheels that will NOT run on driver 535.
103
+ if [[ -n "${TORCH_PRE:-}" ]]; then
104
+ log "pre-installing pinned torch: $TORCH_PRE"
105
+ UV_CACHE_DIR="$CACHE/uv" uv pip install --python "$PY" $TORCH_PRE
106
+ fi
107
+ UV_CACHE_DIR="$CACHE/uv" uv pip install --python "$PY" \
108
+ "${VLLM_SPEC:-vllm>=0.10}" \
109
+ "transformers>=4.51,<5" \
110
+ "datasets>=3.6,<4.0.0" \
111
+ "fsspec[http]>=2023.1.0" \
112
+ "accelerate>=1.8" \
113
+ "safetensors" \
114
+ "sentencepiece" \
115
+ "$LM_EVAL_REQ" \
116
+ "sympy>=1.12.1,<1.14" \
117
+ "antlr4-python3-runtime==4.11" \
118
+ "bespokelabs-curator==0.1.16" \
119
+ "sqlalchemy" \
120
+ "fire" \
121
+ "pandas" \
122
+ "numpy"
123
+ touch "$MARKERS/deps"
124
+ else
125
+ log "deps marker present; skipping install"
126
+ fi
127
+
128
+ # Guard: datasets v4 silently breaks every LiveCodeBench task.
129
+ "$PY" - <<'PYCHECK'
130
+ import sys
131
+ from importlib.metadata import version
132
+ dv = version("datasets")
133
+ major = int(dv.split(".")[0])
134
+ if major >= 4:
135
+ sys.exit(
136
+ f"datasets {dv} installed but LiveCodeBench requires <4.0.0 "
137
+ "(v4 dropped dataset-script support). Reinstall with 'datasets>=3.6,<4.0.0'."
138
+ )
139
+ print(f"[bootstrap] datasets {dv} OK")
140
+ PYCHECK
141
+
142
+ # --------------------------------------------------------------------------
143
+ # 3. evalchemy checkout, pinned + patched
144
+ # --------------------------------------------------------------------------
145
+ if [[ ! -d "$EVALCHEMY_DIR/.git" ]]; then
146
+ log "cloning evalchemy -> $EVALCHEMY_DIR"
147
+ git clone "$EVALCHEMY_REPO" "$EVALCHEMY_DIR"
148
+ fi
149
+
150
+ log "pinning evalchemy to $EVALCHEMY_COMMIT and reverting previous patches"
151
+ git -C "$EVALCHEMY_DIR" fetch --all --quiet || true
152
+ git -C "$EVALCHEMY_DIR" checkout --quiet "$EVALCHEMY_COMMIT"
153
+ # Hard-reset the files the patcher rewrites so patching is deterministic.
154
+ git -C "$EVALCHEMY_DIR" checkout --quiet "$EVALCHEMY_COMMIT" -- eval/
155
+
156
+ log "applying GPU patches"
157
+ "$PY" "$HERE/patch_evalchemy_gpu.py" "$EVALCHEMY_DIR"
158
+
159
+ # --------------------------------------------------------------------------
160
+ # 4. activate snippet
161
+ # --------------------------------------------------------------------------
162
+ cat > "$ROOT/activate.sh" <<ACTEOF
163
+ # source this before running gpu_eval_driver.py / gpu_eval_monitor.py by hand
164
+ export MARIN_GPU_EVAL_ROOT="$ROOT"
165
+ export EVALCHEMY_DIR="$EVALCHEMY_DIR"
166
+ export VIRTUAL_ENV="$VENV"
167
+ export PATH="$VENV/bin:\$PATH"
168
+
169
+ # HF / dataset behaviour required by LiveCodeBench and the math suite
170
+ export HF_DATASETS_TRUST_REMOTE_CODE=1
171
+ export HF_HOME="$CACHE/huggingface"
172
+ export HF_HUB_CACHE="$CACHE/huggingface/hub"
173
+ export HF_DATASETS_CACHE="$CACHE/datasets"
174
+ export HF_ALLOW_CODE_EVAL=1
175
+
176
+ # vLLM / torch caches kept off \$HOME so concurrent runs do not fight
177
+ export VLLM_CACHE_ROOT="$CACHE/vllm"
178
+ export VLLM_CONFIG_ROOT="$CACHE/vllm"
179
+ export TORCHINDUCTOR_CACHE_DIR="$CACHE/torchinductor"
180
+ export TRITON_CACHE_DIR="$CACHE/triton"
181
+ export TOKENIZERS_PARALLELISM=false
182
+
183
+ # Supply your own:
184
+ # export HF_TOKEN=hf_xxx # needed for cais/hle and Idavidrein/gpqa
185
+ ACTEOF
186
+
187
+ log "done"
188
+ log ""
189
+ log " source $ROOT/activate.sh"
190
+ log " export HF_TOKEN=... # only needed for the science suite"
191
+ log " python $HERE/gpu_eval_driver.py --help"
192
+ log ""
193
+ log "evalchemy: $EVALCHEMY_DIR @ $(git -C "$EVALCHEMY_DIR" rev-parse --short HEAD)"
194
+ log "python: $PY"
gpu-sft/scripts/gpu_eval/gpu_eval_driver.py ADDED
@@ -0,0 +1,996 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright The Marin Authors
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Run Marin's evalchemy benchmark suites against a LOCAL HF checkpoint on one GPU box.
5
+
6
+ This is the local-disk, CUDA-vLLM replacement for the TPU path
7
+ (``experiments/evals/exp_evalchemy_eval.py`` + ``EvalchemyEvaluator``). It keeps
8
+ the benchmark set, the sampling parameters and -- critically -- the on-disk
9
+ result schema that ``claude/compile_results.py`` reads to build results.md.
10
+
11
+ python gpu_eval_driver.py \\
12
+ --checkpoint /data/runs/exp_sft_qwen3_8b_x/hf/step-300 \\
13
+ --experiment exp_sft_qwen3_8b_x \\
14
+ --suite math \\
15
+ --results-root /data/marin/evaluation/evalchemy
16
+
17
+ WHAT IS FAITHFUL TO THE TPU PIPELINE
18
+ * Task set and per-task seed counts (math AIME*/AMC23/HMMT x10, MATH500 and
19
+ OlympiadBench x1; science x3; code x6), seeds 42..51.
20
+ * num_fewshot=0, chat template applied, temperature 0.7, top_p 1.0
21
+ (both are the benchmark/vLLM defaults; Marin's --gen_kwargs never reached
22
+ evalchemy chat benchmarks on TPU either), max_gen_toks 32768,
23
+ max_model_len = 32768 + 4096 = 36864.
24
+ * Real per-benchmark graders (is_equiv, symbolic, execution) -- the naive
25
+ string re-grade bug in ``evalchemy_results_compiler.py`` is NOT reproduced.
26
+ * Output layout, so the existing results.md compiler needs zero changes:
27
+ <root>/<experiment>-step<N>/<TASK>_seed<S>-<h>/<TASK>_0shot/<model>/results_<iso>.json
28
+ <root>/<experiment>-step<N>/compile_<TASK>_avg<K>seeds-<h>/compiled_results/averaged_results.json
29
+
30
+ WHAT DELIBERATELY DIFFERS
31
+ * ONE vLLM engine per task instead of one Iris job per (task, seed).
32
+ TPU/JAX cannot do per-request seeds, so Marin faked multi-seed by launching
33
+ K whole jobs with K engine seeds. CUDA vLLM *can* do per-request seeds, so
34
+ we set EVALCHEMY_N_REPEAT=K and pass ``--seed 42,42,42,42``; evalchemy's
35
+ benchmarks then use per-request seed ``42 + repetition_index``, which maps
36
+ exactly onto Marin seeds 42..42+K-1. One model load instead of K.
37
+ Consequence: numbers are NOT bit-comparable to the TPU tables (different
38
+ hardware, different kernels, per-request vs engine seeding). Rankings are.
39
+ * tensor_parallel_size defaults to the number of visible GPUs, not 32.
40
+ * max_num_seqs defaults to 32-64 per the GPU profile, not 256. 256 on a
41
+ single 80GB card guarantees KV-cache thrash and preemption at 36864 ctx.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import csv
48
+ import dataclasses
49
+ import hashlib
50
+ import json
51
+ import logging
52
+ import os
53
+ import re
54
+ import shutil
55
+ import statistics
56
+ import subprocess
57
+ import sys
58
+ import time
59
+ from datetime import datetime
60
+ from pathlib import Path
61
+ from typing import Any
62
+
63
+ logger = logging.getLogger("gpu_eval_driver")
64
+
65
+ # --------------------------------------------------------------------------
66
+ # Task tables. Mirrors experiments/evals/exp_evalchemy_eval.py and
67
+ # experiments/evals/evalchemy_task_configs.py.
68
+ # --------------------------------------------------------------------------
69
+
70
+ SEED_BASE = 42
71
+
72
+ # task name (as evalchemy knows it) -> number of seeds
73
+ SUITES: dict[str, dict[str, int]] = {
74
+ "math": {
75
+ "MATH500": 1,
76
+ "OlympiadBench": 1,
77
+ "AIME24": 10,
78
+ "AIME25": 10,
79
+ "AIME26": 10,
80
+ "AMC23": 10,
81
+ "HMMT": 10,
82
+ },
83
+ "science": {
84
+ "GPQADiamond": 3,
85
+ "JEEBench": 3,
86
+ "HLE": 3,
87
+ "OlympiadBench_Physics": 3,
88
+ },
89
+ "code": {
90
+ "LiveCodeBench": 6,
91
+ "LiveCodeBenchv5_official": 6,
92
+ "LiveCodeBenchv6_official": 6,
93
+ },
94
+ }
95
+
96
+ ALL_TASK_SEEDS: dict[str, int] = {task: n for suite in SUITES.values() for task, n in suite.items()}
97
+
98
+ # Benchmarks that are single-pass by construction: they carry no ``n_repeat``
99
+ # attribute, so EVALCHEMY_N_REPEAT is inert and they emit ``accuracy`` rather
100
+ # than ``accuracy_avg``/``run_stats``.
101
+ SINGLE_PASS_TASKS = frozenset({"MATH500", "OlympiadBench", "OlympiadBench_Physics"})
102
+
103
+ # Generation / engine constants, matching the TPU pipeline.
104
+ MAX_GEN_TOKS = 32768
105
+ CONTEXT_BUFFER = 4096
106
+ MAX_MODEL_LEN = MAX_GEN_TOKS + CONTEXT_BUFFER # 36864
107
+ TEMPERATURE = 0.7
108
+ TOP_P = 1.0
109
+ NUM_FEWSHOT = 0
110
+
111
+ RESULTS_FILE_RE = re.compile(r"^results_.*\.json$")
112
+ STEP_RE = re.compile(r"step-(\d+)")
113
+
114
+ # --------------------------------------------------------------------------
115
+ # GPU engine profiles
116
+ # --------------------------------------------------------------------------
117
+
118
+
119
+ @dataclasses.dataclass(frozen=True)
120
+ class GpuProfile:
121
+ """vLLM engine settings for an 8B bf16 model at 36864 context."""
122
+
123
+ name: str
124
+ gpu_memory_utilization: float
125
+ max_num_seqs: int
126
+ max_num_batched_tokens: int
127
+ # Minimum bytes vLLM must be allowed to claim on each GPU before the run is
128
+ # worth starting: weights + activations + a few full-length sequences of KV.
129
+ min_budget_gib_per_gpu: float
130
+ min_gpus: int
131
+ notes: str
132
+
133
+
134
+ GPU_PROFILES: dict[str, GpuProfile] = {
135
+ # 1x 80GB is the reference config: 16.4 GiB bf16 weights, ~4-6 GiB
136
+ # activations/CUDA graphs, ~48 GiB KV = ~340k cached tokens = ~9 concurrent
137
+ # full-length (36864) sequences, far more at realistic generation lengths.
138
+ "a100-80g": GpuProfile(
139
+ name="a100-80g",
140
+ gpu_memory_utilization=0.90,
141
+ max_num_seqs=64,
142
+ max_num_batched_tokens=8192,
143
+ min_budget_gib_per_gpu=40.0,
144
+ min_gpus=1,
145
+ notes="A100-SXM/PCIe 80GB. 0.90 leaves ~8 GiB for the driver+NCCL. Do not exceed 0.92.",
146
+ ),
147
+ "h100-80g": GpuProfile(
148
+ name="h100-80g",
149
+ gpu_memory_utilization=0.90,
150
+ max_num_seqs=64,
151
+ max_num_batched_tokens=16384,
152
+ min_budget_gib_per_gpu=40.0,
153
+ min_gpus=1,
154
+ notes="H100 80GB HBM3. Higher prefill budget than A100; FlashAttention-3 kernels.",
155
+ ),
156
+ "h200-141g": GpuProfile(
157
+ name="h200-141g",
158
+ gpu_memory_utilization=0.90,
159
+ max_num_seqs=128,
160
+ max_num_batched_tokens=16384,
161
+ min_budget_gib_per_gpu=40.0,
162
+ min_gpus=1,
163
+ notes="H200 141GB. KV cache stops being the bottleneck; raise max_num_seqs.",
164
+ ),
165
+ # 40GB: 0.92*39.6 = 36.4 GiB budget, minus 16.4 weights minus ~4 overhead
166
+ # leaves ~16 GiB KV = ~113k tokens = 3 full-length sequences. Runs, but slow
167
+ # and preemption-heavy. Use two cards.
168
+ "a100-40g": GpuProfile(
169
+ name="a100-40g",
170
+ gpu_memory_utilization=0.92,
171
+ max_num_seqs=16,
172
+ max_num_batched_tokens=4096,
173
+ min_budget_gib_per_gpu=34.0,
174
+ min_gpus=1,
175
+ notes="A100 40GB. Tight for 8B at 36864 ctx; strongly prefer --tensor-parallel-size 2.",
176
+ ),
177
+ "l40s-48g": GpuProfile(
178
+ name="l40s-48g",
179
+ gpu_memory_utilization=0.92,
180
+ max_num_seqs=24,
181
+ max_num_batched_tokens=4096,
182
+ min_budget_gib_per_gpu=34.0,
183
+ min_gpus=1,
184
+ notes="L40S 48GB, no NVLink. Keep tensor_parallel_size=1; PCIe TP is slower than 1 card.",
185
+ ),
186
+ }
187
+
188
+ # nvidia-smi product name substring -> profile
189
+ _NAME_TO_PROFILE = (
190
+ ("H200", "h200-141g"),
191
+ ("H100", "h100-80g"),
192
+ ("A100-SXM4-40GB", "a100-40g"),
193
+ ("A100 40GB", "a100-40g"),
194
+ ("A100", "a100-80g"),
195
+ ("L40S", "l40s-48g"),
196
+ )
197
+
198
+
199
+ @dataclasses.dataclass(frozen=True)
200
+ class GpuInfo:
201
+ index: int
202
+ name: str
203
+ total_mib: int
204
+ used_mib: int
205
+ free_mib: int
206
+
207
+
208
+ def query_gpus() -> list[GpuInfo]:
209
+ """Read nvidia-smi. Honours CUDA_VISIBLE_DEVICES ordering when set."""
210
+ smi = shutil.which("nvidia-smi")
211
+ if smi is None:
212
+ raise RuntimeError("nvidia-smi not found; this driver requires an NVIDIA GPU box")
213
+ out = subprocess.run(
214
+ [smi, "--query-gpu=index,name,memory.total,memory.used,memory.free", "--format=csv,noheader,nounits"],
215
+ check=True,
216
+ capture_output=True,
217
+ text=True,
218
+ ).stdout
219
+ gpus = []
220
+ for line in out.strip().splitlines():
221
+ index, name, total, used, free = (part.strip() for part in line.split(","))
222
+ gpus.append(GpuInfo(int(index), name, int(total), int(used), int(free)))
223
+
224
+ visible = os.environ.get("CUDA_VISIBLE_DEVICES")
225
+ if visible:
226
+ wanted = [int(x) for x in visible.split(",") if x.strip() != ""]
227
+ by_index = {g.index: g for g in gpus}
228
+ missing = [i for i in wanted if i not in by_index]
229
+ if missing:
230
+ raise RuntimeError(f"CUDA_VISIBLE_DEVICES names GPUs {missing} that nvidia-smi does not report")
231
+ gpus = [by_index[i] for i in wanted]
232
+ return gpus
233
+
234
+
235
+ def detect_profile(gpus: list[GpuInfo]) -> GpuProfile:
236
+ name = gpus[0].name
237
+ for needle, key in _NAME_TO_PROFILE:
238
+ if needle.lower() in name.lower():
239
+ profile = GPU_PROFILES[key]
240
+ # An "A100" with <60GB is the 40GB SKU regardless of the name string.
241
+ if key == "a100-80g" and gpus[0].total_mib < 60_000:
242
+ profile = GPU_PROFILES["a100-40g"]
243
+ logger.info("detected GPU %r -> profile %s", name, profile.name)
244
+ return profile
245
+ raise RuntimeError(
246
+ f"no built-in profile for GPU {name!r}; pass --gpu-profile explicitly "
247
+ f"(one of: {', '.join(sorted(GPU_PROFILES))})"
248
+ )
249
+
250
+
251
+ def resolve_gpu_memory_utilization(profile: GpuProfile, gpus: list[GpuInfo], headroom_mib: int) -> float:
252
+ """Clamp gpu_memory_utilization so co-tenants on the same card do not OOM us.
253
+
254
+ THE PITFALL THIS EXISTS FOR: vLLM interprets ``gpu_memory_utilization`` as a
255
+ fraction of TOTAL device memory, not of FREE device memory. If an embedding
256
+ server (or a JAX training process, which preallocates 75% by default) already
257
+ holds 10 GiB of an 80 GiB card and you ask for 0.90, vLLM will size its KV
258
+ pool as if it owned 72 GiB. Total demand becomes 82 GiB and one of the two
259
+ processes dies with CUDA OOM -- and it usually dies *mid-run*, after the KV
260
+ pool is grown, not at init, so you lose hours of generation.
261
+
262
+ So: measure what is already resident, and cap the fraction at what is
263
+ actually claimable. If that leaves less than the profile's minimum budget,
264
+ refuse to start rather than crash later.
265
+ """
266
+ worst = min(gpus, key=lambda g: g.free_mib)
267
+ claimable_mib = worst.free_mib - headroom_mib
268
+ if claimable_mib <= 0:
269
+ raise RuntimeError(
270
+ f"GPU {worst.index} ({worst.name}) has only {worst.free_mib} MiB free "
271
+ f"(headroom {headroom_mib} MiB). Something else owns this card."
272
+ )
273
+ allowed_fraction = claimable_mib / worst.total_mib
274
+ gmu = min(profile.gpu_memory_utilization, allowed_fraction)
275
+
276
+ budget_gib = gmu * worst.total_mib / 1024.0
277
+ if budget_gib < profile.min_budget_gib_per_gpu:
278
+ raise RuntimeError(
279
+ f"only {budget_gib:.1f} GiB claimable on GPU {worst.index} "
280
+ f"({worst.used_mib} MiB already in use by another process) but profile "
281
+ f"{profile.name} needs {profile.min_budget_gib_per_gpu:.1f} GiB. "
282
+ "Free the card, pin the other process to a different GPU with "
283
+ "CUDA_VISIBLE_DEVICES, or raise --tensor-parallel-size."
284
+ )
285
+ if gmu < profile.gpu_memory_utilization:
286
+ logger.warning(
287
+ "clamping gpu_memory_utilization %.2f -> %.3f: GPU %d already has %d MiB resident",
288
+ profile.gpu_memory_utilization,
289
+ gmu,
290
+ worst.index,
291
+ worst.used_mib,
292
+ )
293
+ return round(gmu, 3)
294
+
295
+
296
+ def kv_bytes_per_token(checkpoint: Path) -> int | None:
297
+ """Estimate bf16 KV cache cost per token from the checkpoint's config.json."""
298
+ config_path = checkpoint / "config.json"
299
+ if not config_path.exists():
300
+ return None
301
+ cfg = json.loads(config_path.read_text())
302
+ layers = cfg.get("num_hidden_layers")
303
+ kv_heads = cfg.get("num_key_value_heads") or cfg.get("num_attention_heads")
304
+ head_dim = cfg.get("head_dim")
305
+ if head_dim is None and cfg.get("hidden_size") and cfg.get("num_attention_heads"):
306
+ head_dim = cfg["hidden_size"] // cfg["num_attention_heads"]
307
+ if not (layers and kv_heads and head_dim):
308
+ return None
309
+ return layers * kv_heads * head_dim * 2 * 2 # K and V, 2 bytes each
310
+
311
+
312
+ # --------------------------------------------------------------------------
313
+ # Output layout -- must stay byte-compatible with claude/compile_results.py
314
+ # --------------------------------------------------------------------------
315
+
316
+
317
+ def sanitize_model_name(name: str) -> str:
318
+ """Reproduce lm-eval's GeneralConfigTracker.model_name_sanitized."""
319
+ return re.sub(r"[\"<>:/\|\\?\*\[\]]+", "__", name)
320
+
321
+
322
+ def _hash6(*parts: str) -> str:
323
+ """Stable 6-hex suffix, standing in for the Marin executor's config hash.
324
+
325
+ The results.md compiler only substring-matches ``<TASK>_seed<S>-`` and
326
+ ``compile_<TASK>_avg<K>seeds-``, so the value is opaque -- but it must be
327
+ STABLE so re-runs land in the same directory instead of piling up.
328
+ """
329
+ return hashlib.md5("\x00".join(parts).encode()).hexdigest()[:6]
330
+
331
+
332
+ def step_of(checkpoint: Path) -> str | None:
333
+ match = STEP_RE.search(str(checkpoint))
334
+ return match.group(1) if match else None
335
+
336
+
337
+ @dataclasses.dataclass(frozen=True)
338
+ class Layout:
339
+ """Every path a single (checkpoint, task) evaluation reads or writes."""
340
+
341
+ results_root: Path
342
+ experiment: str
343
+ step: str | None
344
+ task: str
345
+ seeds: tuple[int, ...]
346
+ model_dir_name: str
347
+
348
+ @property
349
+ def eval_dir(self) -> Path:
350
+ name = f"{self.experiment}-step{self.step}" if self.step else self.experiment
351
+ return self.results_root / name
352
+
353
+ def seed_dir(self, seed: int) -> Path:
354
+ suffix = _hash6(self.experiment, self.step or "", self.task, str(seed))
355
+ return self.eval_dir / f"{self.task}_seed{seed}-{suffix}"
356
+
357
+ def seed_results_dir(self, seed: int) -> Path:
358
+ return self.seed_dir(seed) / f"{self.task}_{NUM_FEWSHOT}shot" / self.model_dir_name
359
+
360
+ @property
361
+ def compile_dir(self) -> Path | None:
362
+ if len(self.seeds) < 2:
363
+ return None
364
+ suffix = _hash6(self.experiment, self.step or "", self.task, "compile")
365
+ return self.eval_dir / f"compile_{self.task}_avg{len(self.seeds)}seeds-{suffix}"
366
+
367
+ @property
368
+ def averaged_results(self) -> Path | None:
369
+ compile_dir = self.compile_dir
370
+ return None if compile_dir is None else compile_dir / "compiled_results" / "averaged_results.json"
371
+
372
+ @property
373
+ def raw_dir(self) -> Path:
374
+ return self.eval_dir / "_raw" / self.task
375
+
376
+ @property
377
+ def marker(self) -> Path:
378
+ return self.eval_dir / "_markers" / f"{self.task}.complete.json"
379
+
380
+
381
+ def find_results_file(root: Path) -> Path | None:
382
+ candidates = [p for p in root.rglob("results_*.json") if RESULTS_FILE_RE.match(p.name)]
383
+ if not candidates:
384
+ return None
385
+ return max(candidates, key=lambda p: p.stat().st_mtime)
386
+
387
+
388
+ def read_task_accuracy(results_file: Path, task: str) -> float | None:
389
+ """Read the metric the results.md compiler reads: accuracy_avg, else accuracy."""
390
+ try:
391
+ payload = json.loads(results_file.read_text())
392
+ except (OSError, json.JSONDecodeError):
393
+ return None
394
+ entry = payload.get("results", {}).get(task)
395
+ if not isinstance(entry, dict):
396
+ return None
397
+ for key in ("accuracy_avg", "accuracy"):
398
+ value = entry.get(key)
399
+ if isinstance(value, (int, float)):
400
+ return float(value)
401
+ return None
402
+
403
+
404
+ def is_task_complete(layout: Layout) -> bool:
405
+ """Same three-part definition the eval monitor and results.md compiler use."""
406
+ for seed in layout.seeds:
407
+ results_file = find_results_file(layout.seed_dir(seed))
408
+ if results_file is None or read_task_accuracy(results_file, layout.task) is None:
409
+ return False
410
+ averaged = layout.averaged_results
411
+ if averaged is not None:
412
+ if not averaged.exists():
413
+ return False
414
+ try:
415
+ payload = json.loads(averaged.read_text())
416
+ if payload[0]["num_seeds"] != len(layout.seeds):
417
+ return False
418
+ except (OSError, json.JSONDecodeError, KeyError, IndexError):
419
+ return False
420
+ return True
421
+
422
+
423
+ # --------------------------------------------------------------------------
424
+ # Running one task
425
+ # --------------------------------------------------------------------------
426
+
427
+
428
+ def build_model_args(checkpoint: Path, *, profile: GpuProfile, gmu: float, tensor_parallel_size: int) -> str:
429
+ parts = [
430
+ f"pretrained={checkpoint}",
431
+ "dtype=bfloat16",
432
+ f"tensor_parallel_size={tensor_parallel_size}",
433
+ f"gpu_memory_utilization={gmu}",
434
+ f"max_model_len={MAX_MODEL_LEN}",
435
+ f"max_gen_toks={MAX_GEN_TOKS}",
436
+ f"max_num_seqs={profile.max_num_seqs}",
437
+ f"max_num_batched_tokens={profile.max_num_batched_tokens}",
438
+ "trust_remote_code=True",
439
+ # Deterministic-ish prefix reuse across the K repetitions of a task:
440
+ # every repetition sends the identical prompt, so the prefix cache turns
441
+ # K prefills into 1. Big win at 10 seeds.
442
+ "enable_prefix_caching=True",
443
+ ]
444
+ return ",".join(parts)
445
+
446
+
447
+ def subprocess_env(
448
+ *,
449
+ evalchemy_dir: Path,
450
+ n_repeat: int,
451
+ num_proc: int,
452
+ hf_cache: Path | None,
453
+ extra: dict[str, str] | None = None,
454
+ ) -> dict[str, str]:
455
+ env = os.environ.copy()
456
+ env.update(
457
+ {
458
+ "EVALCHEMY_N_REPEAT": str(n_repeat),
459
+ "EVALCHEMY_NUM_PROC": str(num_proc),
460
+ # LiveCodeBench loads a dataset SCRIPT; without this it refuses.
461
+ "HF_DATASETS_TRUST_REMOTE_CODE": "1",
462
+ # lm-eval refuses to execute generated code without this.
463
+ "HF_ALLOW_CODE_EVAL": "1",
464
+ "TOKENIZERS_PARALLELISM": "false",
465
+ "PYTHONUNBUFFERED": "1",
466
+ # The LCB grader spawns children; keep them off the GPU entirely.
467
+ "VLLM_WORKER_MULTIPROC_METHOD": "spawn",
468
+ "PYTHONPATH": os.pathsep.join(
469
+ [str(evalchemy_dir), *([os.environ["PYTHONPATH"]] if os.environ.get("PYTHONPATH") else [])]
470
+ ),
471
+ }
472
+ )
473
+ if hf_cache is not None:
474
+ env.update(
475
+ {
476
+ "HF_HOME": str(hf_cache),
477
+ "HF_HUB_CACHE": str(hf_cache / "hub"),
478
+ "HF_DATASETS_CACHE": str(hf_cache / "datasets"),
479
+ }
480
+ )
481
+ if extra:
482
+ env.update(extra)
483
+ return env
484
+
485
+
486
+ def run_evalchemy(
487
+ *,
488
+ evalchemy_dir: Path,
489
+ checkpoint: Path,
490
+ task: str,
491
+ n_repeat: int,
492
+ out_dir: Path,
493
+ profile: GpuProfile,
494
+ gmu: float,
495
+ tensor_parallel_size: int,
496
+ batch_size: int,
497
+ hf_cache: Path | None,
498
+ num_proc: int,
499
+ debug: bool,
500
+ timeout: int | None,
501
+ ) -> Path:
502
+ """Invoke evalchemy once for one task, producing one results_*.json."""
503
+ entry = evalchemy_dir / "_marin_gpu_entry.py"
504
+ if not entry.exists():
505
+ raise RuntimeError(f"{entry} missing -- run patch_evalchemy_gpu.py against {evalchemy_dir}")
506
+
507
+ if out_dir.exists():
508
+ # Exactly one results file per run, so downstream globs are unambiguous.
509
+ shutil.rmtree(out_dir)
510
+ out_dir.mkdir(parents=True, exist_ok=True)
511
+
512
+ command = [
513
+ sys.executable,
514
+ str(entry),
515
+ "--model",
516
+ "vllm",
517
+ "--tasks",
518
+ task,
519
+ "--model_args",
520
+ build_model_args(checkpoint, profile=profile, gmu=gmu, tensor_parallel_size=tensor_parallel_size),
521
+ "--batch_size",
522
+ str(batch_size),
523
+ "--output_path",
524
+ str(out_dir),
525
+ "--verbosity",
526
+ "INFO",
527
+ "--apply_chat_template",
528
+ "--confirm_run_unsafe_code",
529
+ "--max_tokens",
530
+ str(MAX_GEN_TOKS),
531
+ # evalchemy hands seeds[0] to vLLM as the per-request seed and adds the
532
+ # repetition index, so repetition i runs at seed SEED_BASE + i.
533
+ "--seed",
534
+ ",".join([str(SEED_BASE)] * 4),
535
+ "--gen_kwargs",
536
+ f"temperature={TEMPERATURE},top_p={TOP_P},max_gen_toks={MAX_GEN_TOKS}",
537
+ ]
538
+ if debug:
539
+ command.append("--debug")
540
+
541
+ env = subprocess_env(evalchemy_dir=evalchemy_dir, n_repeat=n_repeat, num_proc=num_proc, hf_cache=hf_cache)
542
+
543
+ log_path = out_dir.parent / f"{task}.evalchemy.log"
544
+ log_path.parent.mkdir(parents=True, exist_ok=True)
545
+ logger.info("launching %s (n_repeat=%d) -> %s", task, n_repeat, log_path)
546
+ logger.debug("command: %s", " ".join(command))
547
+
548
+ started = time.time()
549
+ with log_path.open("w") as log_file:
550
+ log_file.write(f"# {' '.join(command)}\n\n")
551
+ log_file.flush()
552
+ process = subprocess.Popen(
553
+ command,
554
+ cwd=str(evalchemy_dir),
555
+ env=env,
556
+ stdout=log_file,
557
+ stderr=subprocess.STDOUT,
558
+ )
559
+ try:
560
+ returncode = process.wait(timeout=timeout)
561
+ except subprocess.TimeoutExpired:
562
+ process.kill()
563
+ process.wait()
564
+ raise RuntimeError(f"{task} exceeded timeout of {timeout}s; see {log_path}") from None
565
+
566
+ elapsed = time.time() - started
567
+ results_file = find_results_file(out_dir)
568
+
569
+ # Exit 0 with no results file means scoring hung or crashed silently -- the
570
+ # same guard the TPU evaluator carries. Treat it as a hard failure.
571
+ if returncode != 0:
572
+ raise RuntimeError(f"{task} exited {returncode} after {elapsed:.0f}s; see {log_path}")
573
+ if results_file is None:
574
+ raise RuntimeError(
575
+ f"{task} exited 0 after {elapsed:.0f}s but wrote no results_*.json under {out_dir}; "
576
+ f"scoring likely hung or crashed silently. See {log_path}"
577
+ )
578
+ logger.info("%s finished in %.0fs -> %s", task, elapsed, results_file)
579
+ return results_file
580
+
581
+
582
+ # --------------------------------------------------------------------------
583
+ # Fan-out: one combined run -> per-seed result files + a compile file
584
+ # --------------------------------------------------------------------------
585
+
586
+
587
+ def per_repetition_accuracies(task_entry: dict[str, Any], n_repeat: int, task: str) -> list[float]:
588
+ run_stats = task_entry.get("run_stats")
589
+ if isinstance(run_stats, list) and len(run_stats) == n_repeat:
590
+ return [float(stat["accuracy"]) for stat in run_stats]
591
+ if n_repeat == 1:
592
+ for key in ("accuracy_avg", "accuracy"):
593
+ value = task_entry.get(key)
594
+ if isinstance(value, (int, float)):
595
+ return [float(value)]
596
+ raise RuntimeError(
597
+ f"{task}: expected {n_repeat} repetitions but the result carries "
598
+ f"run_stats={type(run_stats).__name__} of length "
599
+ f"{len(run_stats) if isinstance(run_stats, list) else 'n/a'}. "
600
+ "The n_repeat patch probably did not apply -- re-run patch_evalchemy_gpu.py."
601
+ )
602
+
603
+
604
+ def slice_examples(examples: list[dict[str, Any]], index: int) -> list[dict[str, Any]] | None:
605
+ """Project a multi-repetition example list down to one repetition.
606
+
607
+ Returns None when the benchmark does not keep per-repetition outputs
608
+ (LiveCodeBench* keep only the last run's examples), in which case the
609
+ per-seed file simply omits ``examples``.
610
+ """
611
+ sliced: list[dict[str, Any]] = []
612
+ for example in examples:
613
+ answers = example.get("model_answers")
614
+ outputs = example.get("model_outputs")
615
+ if not isinstance(answers, list) or index >= len(answers):
616
+ return None
617
+ projected = dict(example)
618
+ projected["model_answers"] = [answers[index]]
619
+ if isinstance(outputs, list) and index < len(outputs):
620
+ projected["model_outputs"] = [outputs[index]]
621
+ sliced.append(projected)
622
+ return sliced
623
+
624
+
625
+ def write_per_seed_results(
626
+ *,
627
+ raw_results_file: Path,
628
+ layout: Layout,
629
+ task: str,
630
+ checkpoint: Path,
631
+ ) -> dict[int, float]:
632
+ """Split one combined run into K schema-compatible per-seed result files."""
633
+ raw = json.loads(raw_results_file.read_text())
634
+ entry = raw.get("results", {}).get(task)
635
+ if not isinstance(entry, dict):
636
+ available = sorted(raw.get("results", {}))
637
+ raise RuntimeError(f"{raw_results_file} has no results[{task!r}]; found {available}")
638
+
639
+ seeds = layout.seeds
640
+ accuracies = per_repetition_accuracies(entry, len(seeds), task)
641
+ run_stats = entry.get("run_stats") if isinstance(entry.get("run_stats"), list) else None
642
+ examples = entry.get("examples") if isinstance(entry.get("examples"), list) else None
643
+
644
+ per_seed: dict[int, float] = {}
645
+ timestamp = datetime.now().isoformat().replace(":", "-")
646
+
647
+ for index, seed in enumerate(seeds):
648
+ accuracy = accuracies[index]
649
+ stat = run_stats[index] if run_stats and index < len(run_stats) else {}
650
+ num_total = stat.get("num_total", entry.get("num_total"))
651
+ num_solved = stat.get("num_solved", entry.get("num_solved"))
652
+
653
+ seed_entry: dict[str, Any] = {
654
+ "num_total": num_total,
655
+ "num_solved": num_solved,
656
+ # Both keys, because the results.md compiler prefers accuracy_avg and
657
+ # falls back to accuracy, while some downstream readers expect the
658
+ # single-pass shape.
659
+ "accuracy": accuracy,
660
+ "accuracy_avg": accuracy,
661
+ "accuracy_std_err": 0.0,
662
+ "num_repeat": 1,
663
+ "solved_avg": num_solved,
664
+ "run_stats": [
665
+ {
666
+ "repetition": 1,
667
+ "num_total": num_total,
668
+ "num_solved": num_solved,
669
+ "accuracy": accuracy,
670
+ }
671
+ ],
672
+ }
673
+ if examples is not None:
674
+ projected = slice_examples(examples, index)
675
+ if projected is not None:
676
+ seed_entry["examples"] = projected
677
+ # Carry through any extra per-difficulty metrics LCB emits.
678
+ for key, value in entry.items():
679
+ if key.startswith("accuracy_") and key.endswith("_avg") and key != "accuracy_avg":
680
+ seed_entry[key] = value
681
+
682
+ payload = {k: v for k, v in raw.items() if k != "results"}
683
+ payload["results"] = {task: seed_entry}
684
+ payload["_marin_gpu_port"] = {
685
+ "source_results_file": str(raw_results_file),
686
+ "repetition_index": index,
687
+ "seed": seed,
688
+ "note": (
689
+ "Synthesised from a single K-repetition evalchemy run. On GPU the "
690
+ "seed is per-request (SEED_BASE + repetition_index), not an engine "
691
+ "seed as on TPU."
692
+ ),
693
+ "checkpoint": str(checkpoint),
694
+ }
695
+
696
+ out_dir = layout.seed_results_dir(seed)
697
+ out_dir.mkdir(parents=True, exist_ok=True)
698
+ for stale in out_dir.glob("results_*.json"):
699
+ stale.unlink()
700
+ (out_dir / f"results_{timestamp}.json").write_text(json.dumps(payload, indent=2, default=str))
701
+ per_seed[seed] = accuracy
702
+
703
+ logger.info("%s per-seed accuracies: %s", task, {s: round(a, 4) for s, a in per_seed.items()})
704
+ return per_seed
705
+
706
+
707
+ def write_compile_results(*, layout: Layout, per_seed: dict[int, float], checkpoint: Path) -> None:
708
+ """Write the averaged_results.json that claude/compile_results.py reads.
709
+
710
+ Schema matches ``experiments/evals/evalchemy_results_compiler.py`` exactly:
711
+ a one-element JSON array with base_model_name, dataset_name, num_seeds,
712
+ seeds[], correct_mean, correct_std, correct_per_seed{}. ``correct_std`` is a
713
+ SAMPLE standard deviation (ddof=1), matching pandas Series.std(), and it is
714
+ what renders as the bracketed [x.x] in results.md.
715
+
716
+ Unlike the TPU compile step, the numbers here come from each benchmark's own
717
+ grader rather than a naive string re-match, so HMMT / JEEBench /
718
+ OlympiadBench_Physics / LiveCodeBench* are correct in this file too. The
719
+ results.md compiler still bypasses the compile dir for those six and averages
720
+ the per-seed files instead; both paths now agree.
721
+ """
722
+ compile_dir = layout.compile_dir
723
+ if compile_dir is None:
724
+ return
725
+
726
+ seeds = sorted(per_seed)
727
+ values = [per_seed[s] for s in seeds]
728
+ mean = statistics.fmean(values)
729
+ std = statistics.stdev(values) if len(values) > 1 else 0.0
730
+
731
+ record = {
732
+ "base_model_name": layout.model_dir_name.lower(),
733
+ "dataset_name": layout.task.lower(),
734
+ "num_seeds": len(seeds),
735
+ "seeds": seeds,
736
+ "correct_mean": mean,
737
+ "correct_std": std,
738
+ "correct_per_seed": {str(s): per_seed[s] for s in seeds},
739
+ }
740
+
741
+ out_dir = compile_dir / "compiled_results"
742
+ out_dir.mkdir(parents=True, exist_ok=True)
743
+ (out_dir / "averaged_results.json").write_text(json.dumps([record], indent=2))
744
+
745
+ with (out_dir / "averaged_results.csv").open("w", newline="") as handle:
746
+ writer = csv.writer(handle)
747
+ writer.writerow(["base_model_name", "dataset_name", "num_seeds", "seeds", "correct_mean", "correct_std"])
748
+ writer.writerow(
749
+ [record["base_model_name"], record["dataset_name"], record["num_seeds"], seeds, mean, std]
750
+ )
751
+
752
+ # compiled_results.{json,csv} exist for parity with the TPU compile step.
753
+ # NOTE: the TPU version stores one row per graded EXAMPLE; we store one row
754
+ # per seed, because we take accuracy from the benchmark's own grader rather
755
+ # than re-grading examples with string equality. Nothing downstream reads it.
756
+ rows = [
757
+ {
758
+ "dataset_name": layout.task.lower(),
759
+ "model_name": layout.model_dir_name.lower(),
760
+ "seed": seed,
761
+ "accuracy": per_seed[seed],
762
+ "checkpoint": str(checkpoint),
763
+ }
764
+ for seed in seeds
765
+ ]
766
+ (out_dir / "compiled_results.json").write_text(json.dumps(rows, indent=2))
767
+ with (out_dir / "compiled_results.csv").open("w", newline="") as handle:
768
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
769
+ writer.writeheader()
770
+ writer.writerows(rows)
771
+
772
+ logger.info(
773
+ "%s compiled: mean=%.4f std=%.4f over %d seeds -> %s",
774
+ layout.task,
775
+ mean,
776
+ std,
777
+ len(seeds),
778
+ out_dir / "averaged_results.json",
779
+ )
780
+
781
+
782
+ # --------------------------------------------------------------------------
783
+ # Checkpoint validation
784
+ # --------------------------------------------------------------------------
785
+
786
+
787
+ def validate_checkpoint(checkpoint: Path) -> None:
788
+ """Reject half-written HF exports before burning an hour of GPU on them.
789
+
790
+ Levanter writes hf/step-N incrementally while training continues; a monitor
791
+ that races the export will otherwise load a truncated shard set.
792
+ """
793
+ if not checkpoint.is_dir():
794
+ raise RuntimeError(f"{checkpoint} is not a directory")
795
+ if not (checkpoint / "config.json").exists():
796
+ raise RuntimeError(f"{checkpoint}/config.json missing")
797
+ if not any((checkpoint / name).exists() for name in ("tokenizer_config.json", "tokenizer.json")):
798
+ raise RuntimeError(f"{checkpoint} has no tokenizer files")
799
+
800
+ index_path = checkpoint / "model.safetensors.index.json"
801
+ if index_path.exists():
802
+ index = json.loads(index_path.read_text())
803
+ shards = sorted(set(index.get("weight_map", {}).values()))
804
+ missing = [s for s in shards if not (checkpoint / s).exists()]
805
+ if missing:
806
+ raise RuntimeError(f"{checkpoint} is incomplete: {len(missing)} of {len(shards)} shards missing: {missing[:3]}")
807
+ return
808
+ if not (checkpoint / "model.safetensors").exists():
809
+ raise RuntimeError(f"{checkpoint} has neither model.safetensors nor a shard index")
810
+
811
+
812
+ # --------------------------------------------------------------------------
813
+ # Entry point
814
+ # --------------------------------------------------------------------------
815
+
816
+
817
+ def resolve_tasks(args: argparse.Namespace) -> dict[str, int]:
818
+ if args.tasks:
819
+ selected = {}
820
+ for name in args.tasks:
821
+ if name not in ALL_TASK_SEEDS:
822
+ raise SystemExit(f"unknown task {name!r}; known: {', '.join(sorted(ALL_TASK_SEEDS))}")
823
+ selected[name] = ALL_TASK_SEEDS[name]
824
+ elif args.suite:
825
+ selected = dict(SUITES[args.suite])
826
+ else:
827
+ raise SystemExit("pass --suite or --tasks")
828
+ if args.seeds is not None:
829
+ selected = {task: args.seeds for task in selected}
830
+ return selected
831
+
832
+
833
+ def evaluate_task(args: argparse.Namespace, task: str, n_seeds: int, profile: GpuProfile, gmu: float) -> dict[str, Any]:
834
+ checkpoint = args.checkpoint.resolve()
835
+ effective_seeds = 1 if task in SINGLE_PASS_TASKS else n_seeds
836
+ seeds = tuple(SEED_BASE + i for i in range(effective_seeds))
837
+
838
+ layout = Layout(
839
+ results_root=args.results_root.resolve(),
840
+ experiment=args.experiment,
841
+ step=step_of(checkpoint),
842
+ task=task,
843
+ seeds=seeds,
844
+ model_dir_name=sanitize_model_name(str(checkpoint)),
845
+ )
846
+
847
+ if not args.force and is_task_complete(layout):
848
+ logger.info("%s already complete for %s; skipping", task, layout.eval_dir.name)
849
+ return {"task": task, "status": "cached", "seeds": list(seeds)}
850
+
851
+ started = time.time()
852
+ raw_results_file = run_evalchemy(
853
+ evalchemy_dir=args.evalchemy_dir.resolve(),
854
+ checkpoint=checkpoint,
855
+ task=task,
856
+ n_repeat=effective_seeds,
857
+ out_dir=layout.raw_dir,
858
+ profile=profile,
859
+ gmu=gmu,
860
+ tensor_parallel_size=args.tensor_parallel_size,
861
+ batch_size=args.batch_size,
862
+ hf_cache=args.hf_cache,
863
+ num_proc=args.num_proc,
864
+ debug=args.debug,
865
+ timeout=args.task_timeout,
866
+ )
867
+ per_seed = write_per_seed_results(
868
+ raw_results_file=raw_results_file, layout=layout, task=task, checkpoint=checkpoint
869
+ )
870
+ write_compile_results(layout=layout, per_seed=per_seed, checkpoint=checkpoint)
871
+
872
+ summary = {
873
+ "task": task,
874
+ "status": "ok",
875
+ "seeds": list(seeds),
876
+ "mean": statistics.fmean(per_seed.values()),
877
+ "std": statistics.stdev(per_seed.values()) if len(per_seed) > 1 else 0.0,
878
+ "per_seed": {str(k): v for k, v in per_seed.items()},
879
+ "elapsed_seconds": round(time.time() - started, 1),
880
+ }
881
+ layout.marker.parent.mkdir(parents=True, exist_ok=True)
882
+ layout.marker.write_text(json.dumps({**summary, "checkpoint": str(checkpoint)}, indent=2))
883
+ if args.prune_raw and layout.raw_dir.exists():
884
+ shutil.rmtree(layout.raw_dir)
885
+ logger.info("pruned %s", layout.raw_dir)
886
+ return summary
887
+
888
+
889
+ def build_parser() -> argparse.ArgumentParser:
890
+ parser = argparse.ArgumentParser(
891
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
892
+ )
893
+ parser.add_argument("--checkpoint", type=Path, required=True, help="Local HF checkpoint dir (.../hf/step-N)")
894
+ parser.add_argument(
895
+ "--experiment",
896
+ required=True,
897
+ help="Experiment name WITHOUT a -stepN suffix; the step is taken from --checkpoint",
898
+ )
899
+ parser.add_argument("--suite", choices=sorted(SUITES), help="math | science | code")
900
+ parser.add_argument("--tasks", nargs="+", help="Explicit evalchemy task names (overrides --suite)")
901
+ parser.add_argument("--seeds", type=int, help="Override the per-task seed count (default: TPU parity)")
902
+ parser.add_argument("--results-root", type=Path, required=True, help="Local stand-in for gs://.../evaluation/evalchemy")
903
+ parser.add_argument(
904
+ "--evalchemy-dir",
905
+ type=Path,
906
+ default=Path(os.environ.get("EVALCHEMY_DIR", "/opt/marin-gpu-eval/evalchemy")),
907
+ help="Patched evalchemy checkout (default: $EVALCHEMY_DIR)",
908
+ )
909
+ parser.add_argument("--gpu-profile", choices=sorted(GPU_PROFILES), help="Default: auto-detect from nvidia-smi")
910
+ parser.add_argument("--tensor-parallel-size", type=int, help="Default: number of visible GPUs")
911
+ parser.add_argument("--batch-size", type=int, default=64, help="lm-eval batch size (default 64)")
912
+ parser.add_argument(
913
+ "--gpu-memory-utilization",
914
+ type=float,
915
+ help="Override the profile value. Still clamped by free VRAM unless --no-vram-clamp.",
916
+ )
917
+ parser.add_argument(
918
+ "--vram-headroom-mib",
919
+ type=int,
920
+ default=2048,
921
+ help="MiB left unclaimed on the busiest GPU when clamping (default 2048)",
922
+ )
923
+ parser.add_argument("--no-vram-clamp", action="store_true", help="Trust the profile value verbatim (unsafe)")
924
+ parser.add_argument("--hf-cache", type=Path, help="HF_HOME for the eval subprocess")
925
+ parser.add_argument("--num-proc", type=int, default=8, help="Dataset map/filter workers (default 8)")
926
+ parser.add_argument("--task-timeout", type=int, default=None, help="Seconds per task before SIGKILL")
927
+ parser.add_argument("--debug", action="store_true", help="Smoke mode: evalchemy limits each task to 10 examples")
928
+ parser.add_argument(
929
+ "--prune-raw",
930
+ action="store_true",
931
+ help="Delete <eval_dir>/_raw/<TASK> after a successful fan-out. The raw file holds all K "
932
+ "repetitions at full 32768-token outputs and is the largest artifact on disk.",
933
+ )
934
+ parser.add_argument("--force", action="store_true", help="Re-run tasks that already have complete results")
935
+ parser.add_argument("--dry-run", action="store_true", help="Print the plan and exit")
936
+ parser.add_argument("--log-level", default="INFO")
937
+ return parser
938
+
939
+
940
+ def main(argv: list[str] | None = None) -> int:
941
+ args = build_parser().parse_args(argv)
942
+ logging.basicConfig(
943
+ level=getattr(logging, args.log_level.upper()),
944
+ format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
945
+ )
946
+
947
+ tasks = resolve_tasks(args)
948
+ validate_checkpoint(args.checkpoint.resolve())
949
+
950
+ gpus = query_gpus()
951
+ profile = GPU_PROFILES[args.gpu_profile] if args.gpu_profile else detect_profile(gpus)
952
+ if args.gpu_memory_utilization is not None:
953
+ profile = dataclasses.replace(profile, gpu_memory_utilization=args.gpu_memory_utilization)
954
+ if args.tensor_parallel_size is None:
955
+ args.tensor_parallel_size = len(gpus)
956
+
957
+ gmu = (
958
+ profile.gpu_memory_utilization
959
+ if args.no_vram_clamp
960
+ else resolve_gpu_memory_utilization(profile, gpus, args.vram_headroom_mib)
961
+ )
962
+
963
+ logger.info("checkpoint %s (step %s)", args.checkpoint, step_of(args.checkpoint.resolve()))
964
+ logger.info("gpus %s", [f"{g.index}:{g.name}:{g.free_mib}MiB free" for g in gpus])
965
+ logger.info("profile %s (%s)", profile.name, profile.notes)
966
+ logger.info(
967
+ "engine tp=%d gmu=%.3f max_num_seqs=%d max_model_len=%d",
968
+ args.tensor_parallel_size,
969
+ gmu,
970
+ profile.max_num_seqs,
971
+ MAX_MODEL_LEN,
972
+ )
973
+ per_token = kv_bytes_per_token(args.checkpoint.resolve())
974
+ if per_token:
975
+ seq_gib = per_token * MAX_MODEL_LEN / 1024**3
976
+ logger.info("kv cache %d B/token -> %.2f GiB per full-length sequence", per_token, seq_gib)
977
+ logger.info("tasks %s", {t: (1 if t in SINGLE_PASS_TASKS else n) for t, n in tasks.items()})
978
+
979
+ if args.dry_run:
980
+ return 0
981
+
982
+ summaries: list[dict[str, Any]] = []
983
+ failures: list[tuple[str, str]] = []
984
+ for task, n_seeds in tasks.items():
985
+ try:
986
+ summaries.append(evaluate_task(args, task, n_seeds, profile, gmu))
987
+ except Exception as error: # one bad task must not abandon the rest
988
+ logger.exception("task %s failed", task)
989
+ failures.append((task, str(error)))
990
+
991
+ print(json.dumps({"summaries": summaries, "failures": failures}, indent=2))
992
+ return 1 if failures else 0
993
+
994
+
995
+ if __name__ == "__main__":
996
+ sys.exit(main())
gpu-sft/scripts/gpu_eval/gpu_eval_monitor.py ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright The Marin Authors
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Eval-during-training monitor for a single local GPU box.
5
+
6
+ Local replacement for ``claude/eval_monitor.py``: GCS listing becomes a
7
+ filesystem walk, ``iris job run`` becomes a ``gpu_eval_driver.py`` subprocess,
8
+ and the "is another job already running this?" question becomes an OS file lock.
9
+
10
+ python gpu_eval_monitor.py \\
11
+ --run-dir /data/runs/exp_sft_qwen3_8b_x \\
12
+ --experiment exp_sft_qwen3_8b_x \\
13
+ --suite math \\
14
+ --results-root /data/marin/evaluation/evalchemy \\
15
+ --gpu-lock /var/tmp/marin-gpu.lock
16
+
17
+ HOW GPU ACCESS IS SERIALISED AGAINST TRAINING
18
+ One advisory ``flock(2)`` on --gpu-lock. The lock is taken and released
19
+ around EACH TASK, not around a whole suite, so a training restart never
20
+ waits hours behind a 10-seed AIME sweep.
21
+
22
+ The training launcher MUST take the same lock. Wrap it:
23
+
24
+ flock /var/tmp/marin-gpu.lock -c 'python -m levanter.main.train_lm ...'
25
+
26
+ (util-linux ``flock`` and Python's ``fcntl.flock`` both call flock(2) on the
27
+ file, so they interlock correctly. ``flock`` blocks by default; add ``-w N``
28
+ for a timeout.)
29
+
30
+ The lock alone is not sufficient, because JAX preallocates ~75% of every
31
+ visible GPU at import and holds it until the process exits, and because
32
+ nothing forces a stray notebook or embedding server to cooperate. So the
33
+ monitor ALSO refuses to launch until the busiest visible GPU reports at
34
+ least --min-free-vram-mib free. Both gates must pass.
35
+
36
+ If you have spare cards, the better answer is physical separation: give
37
+ training ``CUDA_VISIBLE_DEVICES=0,1,2,3`` and run this monitor with
38
+ ``--cuda-visible-devices 4,5,6,7 --no-gpu-lock``. Then nothing contends and
39
+ evals never stall the run.
40
+
41
+ DEDUPE
42
+ Three layers, matching the TPU monitor:
43
+ 1. Artifact probe -- gpu_eval_driver.is_task_complete() checks that every
44
+ expected per-seed results file parses and the compile file has the full
45
+ seed count. This is the source of truth; markers are only a fast path.
46
+ 2. In-process set of (step, task) pairs launched this session.
47
+ 3. The lock itself, which prevents two monitors from racing one GPU.
48
+
49
+ CHECKPOINT READINESS
50
+ A step is only eligible once its HF export is structurally complete: every
51
+ shard named in model.safetensors.index.json exists, config.json exists, and
52
+ the tokenizer is present. Levanter writes hf/step-N while training
53
+ continues, so a naive directory-mtime check will load a truncated export.
54
+ A --settle-seconds quiet period on the newest file guards the tail.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import argparse
60
+ import contextlib
61
+ import errno
62
+ import fcntl
63
+ import json
64
+ import logging
65
+ import os
66
+ import re
67
+ import signal
68
+ import subprocess
69
+ import sys
70
+ import time
71
+ from collections.abc import Iterator
72
+ from datetime import UTC, datetime
73
+ from pathlib import Path
74
+ from typing import Any
75
+
76
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
77
+
78
+ import gpu_eval_driver as driver # noqa: E402
79
+
80
+ logger = logging.getLogger("gpu_eval_monitor")
81
+
82
+ STEP_DIR_RE = re.compile(r"^(?:step-|checkpoint-)(\d+)$")
83
+ DEFAULT_POLL_SECONDS = 300
84
+ DEFAULT_MAX_RUNTIME_DAYS = 14
85
+
86
+ _SHUTDOWN = False
87
+
88
+
89
+ def _handle_signal(signum: int, _frame: Any) -> None:
90
+ global _SHUTDOWN
91
+ logger.warning("received signal %d; finishing the current task then exiting", signum)
92
+ _SHUTDOWN = True
93
+
94
+
95
+ # --------------------------------------------------------------------------
96
+ # GPU lock
97
+ # --------------------------------------------------------------------------
98
+
99
+
100
+ @contextlib.contextmanager
101
+ def gpu_lock(path: Path | None, *, poll_seconds: int, wait_seconds: int | None) -> Iterator[bool]:
102
+ """Exclusive flock(2) on ``path``. Yields True when held, False when disabled."""
103
+ if path is None:
104
+ yield False
105
+ return
106
+
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ deadline = None if wait_seconds is None else time.time() + wait_seconds
109
+ handle = path.open("a+")
110
+ try:
111
+ announced = False
112
+ while True:
113
+ try:
114
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
115
+ break
116
+ except OSError as error:
117
+ if error.errno not in (errno.EACCES, errno.EAGAIN):
118
+ raise
119
+ if deadline is not None and time.time() >= deadline:
120
+ raise TimeoutError(f"could not acquire {path} within {wait_seconds}s") from None
121
+ if not announced:
122
+ logger.info("waiting for GPU lock %s (held by the training job?)", path)
123
+ announced = True
124
+ if _SHUTDOWN:
125
+ raise TimeoutError("shutdown requested while waiting for the GPU lock") from None
126
+ time.sleep(min(poll_seconds, 15))
127
+ handle.seek(0)
128
+ handle.truncate()
129
+ handle.write(f"{os.getpid()} gpu_eval_monitor {datetime.now(UTC).isoformat()}\n")
130
+ handle.flush()
131
+ logger.info("acquired GPU lock %s", path)
132
+ yield True
133
+ finally:
134
+ with contextlib.suppress(OSError):
135
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
136
+ handle.close()
137
+
138
+
139
+ def free_vram_mib() -> int:
140
+ """Free MiB on the busiest visible GPU."""
141
+ return min(gpu.free_mib for gpu in driver.query_gpus())
142
+
143
+
144
+ # --------------------------------------------------------------------------
145
+ # Checkpoint discovery
146
+ # --------------------------------------------------------------------------
147
+
148
+
149
+ def discover_steps(run_dir: Path, subdir: str) -> list[tuple[int, Path]]:
150
+ """Return sorted (step, path) for every checkpoint directory under run_dir/subdir."""
151
+ root = run_dir / subdir if subdir else run_dir
152
+ if not root.is_dir():
153
+ return []
154
+ found: list[tuple[int, Path]] = []
155
+ for child in root.iterdir():
156
+ if not child.is_dir():
157
+ continue
158
+ match = STEP_DIR_RE.match(child.name)
159
+ if match:
160
+ found.append((int(match.group(1)), child))
161
+ return sorted(found)
162
+
163
+
164
+ def checkpoint_is_settled(checkpoint: Path, settle_seconds: int) -> bool:
165
+ """True when nothing under the checkpoint has been written for settle_seconds."""
166
+ if settle_seconds <= 0:
167
+ return True
168
+ newest = 0.0
169
+ for path in checkpoint.rglob("*"):
170
+ if path.is_file():
171
+ newest = max(newest, path.stat().st_mtime)
172
+ return newest > 0 and (time.time() - newest) >= settle_seconds
173
+
174
+
175
+ def checkpoint_is_ready(checkpoint: Path, settle_seconds: int) -> tuple[bool, str]:
176
+ try:
177
+ driver.validate_checkpoint(checkpoint)
178
+ except RuntimeError as error:
179
+ return False, str(error)
180
+ if not checkpoint_is_settled(checkpoint, settle_seconds):
181
+ return False, f"still being written (quiet period {settle_seconds}s not met)"
182
+ return True, "ready"
183
+
184
+
185
+ # --------------------------------------------------------------------------
186
+ # Work items
187
+ # --------------------------------------------------------------------------
188
+
189
+
190
+ def pending_tasks(
191
+ *,
192
+ experiment: str,
193
+ checkpoint: Path,
194
+ tasks: dict[str, int],
195
+ results_root: Path,
196
+ ) -> list[str]:
197
+ """Tasks for this checkpoint whose artifacts are not already complete."""
198
+ step = driver.step_of(checkpoint)
199
+ model_dir_name = driver.sanitize_model_name(str(checkpoint.resolve()))
200
+ pending = []
201
+ for task, n_seeds in tasks.items():
202
+ effective = 1 if task in driver.SINGLE_PASS_TASKS else n_seeds
203
+ layout = driver.Layout(
204
+ results_root=results_root.resolve(),
205
+ experiment=experiment,
206
+ step=step,
207
+ task=task,
208
+ seeds=tuple(driver.SEED_BASE + i for i in range(effective)),
209
+ model_dir_name=model_dir_name,
210
+ )
211
+ if not driver.is_task_complete(layout):
212
+ pending.append(task)
213
+ return pending
214
+
215
+
216
+ def run_driver(args: argparse.Namespace, checkpoint: Path, task: str) -> tuple[int, float]:
217
+ """Run gpu_eval_driver.py for exactly one task in a fresh process.
218
+
219
+ A fresh process per task is deliberate: vLLM does not reliably return all
220
+ device memory to the allocator on engine teardown, so a long-lived driver
221
+ would slowly starve itself (and the training job) across a 5-task suite.
222
+ """
223
+ command = [
224
+ sys.executable,
225
+ str(Path(__file__).resolve().parent / "gpu_eval_driver.py"),
226
+ "--checkpoint",
227
+ str(checkpoint),
228
+ "--experiment",
229
+ args.experiment,
230
+ "--tasks",
231
+ task,
232
+ "--results-root",
233
+ str(args.results_root),
234
+ "--evalchemy-dir",
235
+ str(args.evalchemy_dir),
236
+ "--num-proc",
237
+ str(args.num_proc),
238
+ ]
239
+ if args.gpu_profile:
240
+ command += ["--gpu-profile", args.gpu_profile]
241
+ if args.tensor_parallel_size:
242
+ command += ["--tensor-parallel-size", str(args.tensor_parallel_size)]
243
+ if args.hf_cache:
244
+ command += ["--hf-cache", str(args.hf_cache)]
245
+ if args.task_timeout:
246
+ command += ["--task-timeout", str(args.task_timeout)]
247
+ if args.prune_raw:
248
+ command.append("--prune-raw")
249
+ if args.debug:
250
+ command.append("--debug")
251
+
252
+ env = os.environ.copy()
253
+ if args.cuda_visible_devices is not None:
254
+ env["CUDA_VISIBLE_DEVICES"] = args.cuda_visible_devices
255
+
256
+ logger.info("launching driver: %s %s", task, checkpoint)
257
+ started = time.time()
258
+ process = subprocess.run(command, env=env, check=False)
259
+ return process.returncode, time.time() - started
260
+
261
+
262
+ # --------------------------------------------------------------------------
263
+ # Event log
264
+ # --------------------------------------------------------------------------
265
+
266
+
267
+ class EventLog:
268
+ def __init__(self, path: Path | None) -> None:
269
+ self.path = path
270
+ if path is not None:
271
+ path.parent.mkdir(parents=True, exist_ok=True)
272
+
273
+ def emit(self, event: str, **fields: Any) -> None:
274
+ record = {"ts": datetime.now(UTC).isoformat(), "event": event, **fields}
275
+ logger.info("%s %s", event, {k: v for k, v in fields.items() if k != "ts"})
276
+ if self.path is not None:
277
+ with self.path.open("a") as handle:
278
+ handle.write(json.dumps(record, default=str) + "\n")
279
+
280
+
281
+ # --------------------------------------------------------------------------
282
+ # Main loop
283
+ # --------------------------------------------------------------------------
284
+
285
+
286
+ def select_steps(steps: list[tuple[int, Path]], args: argparse.Namespace) -> list[tuple[int, Path]]:
287
+ selected = [
288
+ (step, path)
289
+ for step, path in steps
290
+ if step >= args.min_step
291
+ and (args.max_step is None or step <= args.max_step)
292
+ and (args.step_stride <= 1 or step % args.step_stride == 0)
293
+ ]
294
+ if args.newest_first:
295
+ selected.reverse()
296
+ if args.latest_only and selected:
297
+ selected = selected[:1] if args.newest_first else selected[-1:]
298
+ return selected
299
+
300
+
301
+ def monitor(args: argparse.Namespace) -> int:
302
+ events = EventLog(args.event_log)
303
+ tasks = driver.SUITES[args.suite] if args.suite else {t: driver.ALL_TASK_SEEDS[t] for t in args.tasks}
304
+ if args.seeds is not None:
305
+ tasks = {task: args.seeds for task in tasks}
306
+
307
+ deadline = time.time() + args.max_runtime_days * 24 * 3600
308
+ launched: set[tuple[int, str]] = set()
309
+
310
+ events.emit(
311
+ "start",
312
+ run_dir=str(args.run_dir),
313
+ experiment=args.experiment,
314
+ tasks=list(tasks),
315
+ poll_seconds=args.poll,
316
+ gpu_lock=str(args.gpu_lock) if args.gpu_lock else None,
317
+ min_free_vram_mib=args.min_free_vram_mib,
318
+ )
319
+
320
+ while not _SHUTDOWN and time.time() < deadline:
321
+ steps = select_steps(discover_steps(args.run_dir, args.checkpoint_subdir), args)
322
+ if not steps:
323
+ events.emit("no_checkpoints", root=str(args.run_dir / args.checkpoint_subdir))
324
+
325
+ for step, checkpoint in steps:
326
+ if _SHUTDOWN:
327
+ break
328
+
329
+ ready, reason = checkpoint_is_ready(checkpoint, args.settle_seconds)
330
+ if not ready:
331
+ events.emit("checkpoint_not_ready", step=step, reason=reason)
332
+ continue
333
+
334
+ todo = pending_tasks(
335
+ experiment=args.experiment,
336
+ checkpoint=checkpoint,
337
+ tasks=tasks,
338
+ results_root=args.results_root,
339
+ )
340
+ todo = [t for t in todo if (step, t) not in launched or args.retry_failed]
341
+ if not todo:
342
+ continue
343
+
344
+ events.emit("step_pending", step=step, tasks=todo)
345
+
346
+ for task in todo:
347
+ if _SHUTDOWN:
348
+ break
349
+
350
+ free = free_vram_mib()
351
+ if free < args.min_free_vram_mib:
352
+ events.emit(
353
+ "vram_blocked",
354
+ step=step,
355
+ task=task,
356
+ free_mib=free,
357
+ required_mib=args.min_free_vram_mib,
358
+ hint="training or another process still holds the card",
359
+ )
360
+ break # nothing on this box will run; go back to sleep
361
+
362
+ try:
363
+ with gpu_lock(args.gpu_lock, poll_seconds=args.poll, wait_seconds=args.lock_wait_seconds):
364
+ # Re-check under the lock: free VRAM can change while we
365
+ # queued behind the training job, and another monitor may
366
+ # have finished this task in the meantime.
367
+ free = free_vram_mib()
368
+ if free < args.min_free_vram_mib:
369
+ events.emit("vram_blocked_after_lock", step=step, task=task, free_mib=free)
370
+ break
371
+ if task not in pending_tasks(
372
+ experiment=args.experiment,
373
+ checkpoint=checkpoint,
374
+ tasks={task: tasks[task]},
375
+ results_root=args.results_root,
376
+ ):
377
+ events.emit("raced", step=step, task=task)
378
+ continue
379
+
380
+ launched.add((step, task))
381
+ returncode, elapsed = run_driver(args, checkpoint, task)
382
+ except TimeoutError as error:
383
+ events.emit("lock_timeout", step=step, task=task, detail=str(error))
384
+ break
385
+
386
+ if returncode == 0:
387
+ events.emit("task_done", step=step, task=task, elapsed_seconds=round(elapsed, 1))
388
+ else:
389
+ events.emit(
390
+ "task_failed",
391
+ step=step,
392
+ task=task,
393
+ returncode=returncode,
394
+ elapsed_seconds=round(elapsed, 1),
395
+ hint=f"see {args.results_root}/{args.experiment}-step{step}/_raw/{task}.evalchemy.log",
396
+ )
397
+ if args.stop_on_failure:
398
+ events.emit("stop_on_failure", step=step, task=task)
399
+ return 1
400
+
401
+ if args.once:
402
+ break
403
+ events.emit("sleep", seconds=args.poll)
404
+ slept = 0
405
+ while slept < args.poll and not _SHUTDOWN:
406
+ time.sleep(min(5, args.poll - slept))
407
+ slept += 5
408
+
409
+ events.emit("exit", shutdown=_SHUTDOWN, expired=time.time() >= deadline)
410
+ return 0
411
+
412
+
413
+ def build_parser() -> argparse.ArgumentParser:
414
+ parser = argparse.ArgumentParser(
415
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
416
+ )
417
+ parser.add_argument("--run-dir", type=Path, required=True, help="Training output dir containing hf/step-N")
418
+ parser.add_argument("--checkpoint-subdir", default="hf", help="Subdir holding checkpoints (default: hf)")
419
+ parser.add_argument("--experiment", required=True, help="Experiment name WITHOUT a -stepN suffix")
420
+ parser.add_argument("--results-root", type=Path, required=True)
421
+ parser.add_argument("--suite", choices=sorted(driver.SUITES))
422
+ parser.add_argument("--tasks", nargs="+", help="Explicit task names (overrides --suite)")
423
+ parser.add_argument("--seeds", type=int, help="Override per-task seed count")
424
+ parser.add_argument(
425
+ "--evalchemy-dir",
426
+ type=Path,
427
+ default=Path(os.environ.get("EVALCHEMY_DIR", "/opt/marin-gpu-eval/evalchemy")),
428
+ )
429
+
430
+ parser.add_argument("--gpu-lock", type=Path, default=Path("/var/tmp/marin-gpu.lock"))
431
+ parser.add_argument("--no-gpu-lock", action="store_true", help="Skip locking (use with disjoint --cuda-visible-devices)")
432
+ parser.add_argument("--lock-wait-seconds", type=int, default=None, help="Give up waiting for the lock (default: wait forever)")
433
+ parser.add_argument(
434
+ "--min-free-vram-mib",
435
+ type=int,
436
+ default=70_000,
437
+ help="Refuse to launch below this much free VRAM on the busiest visible GPU. "
438
+ "70000 suits an 8B bf16 model at 36864 ctx on an 80GB card.",
439
+ )
440
+ parser.add_argument("--cuda-visible-devices", help="CUDA_VISIBLE_DEVICES for eval subprocesses")
441
+ parser.add_argument("--gpu-profile", choices=sorted(driver.GPU_PROFILES))
442
+ parser.add_argument("--tensor-parallel-size", type=int)
443
+
444
+ parser.add_argument("--poll", type=int, default=DEFAULT_POLL_SECONDS, help="Seconds between passes")
445
+ parser.add_argument("--settle-seconds", type=int, default=120, help="Quiet period a checkpoint must show")
446
+ parser.add_argument("--step-stride", type=int, default=1, help="Only eval steps divisible by this")
447
+ parser.add_argument("--min-step", type=int, default=0)
448
+ parser.add_argument("--max-step", type=int, default=None)
449
+ parser.add_argument("--latest-only", action="store_true", help="Only ever evaluate the newest eligible step")
450
+ parser.add_argument("--newest-first", action="store_true", help="Walk steps newest-first")
451
+ parser.add_argument("--retry-failed", action="store_true", help="Re-attempt tasks that failed earlier this session")
452
+ parser.add_argument("--stop-on-failure", action="store_true")
453
+ parser.add_argument("--once", action="store_true", help="Single pass, then exit")
454
+ parser.add_argument("--max-runtime-days", type=float, default=DEFAULT_MAX_RUNTIME_DAYS)
455
+ parser.add_argument("--task-timeout", type=int, default=None, help="Seconds per task before the driver is killed")
456
+ parser.add_argument("--num-proc", type=int, default=8)
457
+ parser.add_argument("--hf-cache", type=Path)
458
+ parser.add_argument("--prune-raw", action="store_true")
459
+ parser.add_argument("--debug", action="store_true", help="Smoke mode (10 examples per task)")
460
+ parser.add_argument("--event-log", type=Path, help="Append JSONL events here")
461
+ parser.add_argument("--log-level", default="INFO")
462
+ return parser
463
+
464
+
465
+ def main(argv: list[str] | None = None) -> int:
466
+ args = build_parser().parse_args(argv)
467
+ logging.basicConfig(
468
+ level=getattr(logging, args.log_level.upper()),
469
+ format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
470
+ )
471
+ if not args.suite and not args.tasks:
472
+ raise SystemExit("pass --suite or --tasks")
473
+ if args.tasks:
474
+ unknown = [t for t in args.tasks if t not in driver.ALL_TASK_SEEDS]
475
+ if unknown:
476
+ raise SystemExit(f"unknown tasks: {unknown}")
477
+ if args.no_gpu_lock:
478
+ args.gpu_lock = None
479
+
480
+ signal.signal(signal.SIGINT, _handle_signal)
481
+ signal.signal(signal.SIGTERM, _handle_signal)
482
+ return monitor(args)
483
+
484
+
485
+ if __name__ == "__main__":
486
+ sys.exit(main())
gpu-sft/scripts/gpu_eval/patch_evalchemy_gpu.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright The Marin Authors
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Patch a cloned evalchemy checkout for single-GPU offline vLLM evaluation.
5
+
6
+ This is the GPU analogue of the runtime patching that
7
+ ``lib/marin/src/marin/evaluation/evaluators/evalchemy_evaluator.py`` does on TPU.
8
+ The TPU-only patches (per-request seed stripping, fake ``vllm-tpu`` version
9
+ metadata, ``MODEL_IMPL_TYPE=vllm``, GCS ``AutoConfig`` redirection) are dropped;
10
+ two GPU-specific ones are added.
11
+
12
+ Patches applied (all idempotent, all fail loudly if their marker is missing so a
13
+ pin bump cannot silently no-op):
14
+
15
+ 1. ``eval/eval_tracker.py`` -- lm-eval ``eval_logger`` import shim.
16
+ 2. ``eval/eval.py`` -- ``utils.eval_logger`` attribute shim.
17
+ 3. ``*/eval_instruct.py`` -- ``self.n_repeat`` becomes env-driven
18
+ (``EVALCHEMY_N_REPEAT``) so one vLLM engine can produce all repetitions.
19
+ 4. ``*/livecodebench_utils.py`` -- fork -> spawn for the sandboxed test runner.
20
+ REQUIRED on GPU: CUDA context and vLLM/filelock state are not fork-safe and a
21
+ forked child either deadlocks or corrupts the parent's CUDA context.
22
+ 5. ``LiveCodeBench*/eval_instruct.py`` -- dataset ``num_proc`` becomes env-driven
23
+ (``EVALCHEMY_NUM_PROC``) so LCB's dataset filter does not oversubscribe the
24
+ box while a training job is running.
25
+ 6. Writes ``_marin_gpu_entry.py`` -- the launcher the driver invokes. It applies
26
+ the ``vllm.utils.get_open_port`` compatibility shim and lifts Python's
27
+ int->str digit cap before delegating to ``eval.eval``.
28
+
29
+ Usage:
30
+ python patch_evalchemy_gpu.py /opt/evalchemy
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import logging
37
+ import re
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ logger = logging.getLogger("patch_evalchemy_gpu")
42
+
43
+ # Every benchmark that carries a ``self.n_repeat`` assignment at the pinned
44
+ # commits. Mirrors N_REPEAT_BENCHMARK_PATHS in Marin's TPU evaluator.
45
+ N_REPEAT_BENCHMARKS = (
46
+ "AIME24",
47
+ "AIME25",
48
+ "AIME26",
49
+ "AMC23",
50
+ "HMMT",
51
+ "GPQADiamond",
52
+ "JEEBench",
53
+ "HLE",
54
+ "LiveCodeBench",
55
+ "LiveCodeBenchv5_official",
56
+ "LiveCodeBenchv6_official",
57
+ "CodeForces",
58
+ "CodeElo",
59
+ )
60
+
61
+ # Benchmarks with no ``n_repeat`` at all -- single pass by construction.
62
+ SINGLE_PASS_BENCHMARKS = ("MATH500", "OlympiadBench", "OlympiadBench_Physics")
63
+
64
+ LCB_BENCHMARKS = ("LiveCodeBench", "LiveCodeBenchv5_official", "LiveCodeBenchv6_official")
65
+
66
+ LCB_UTILITY_FILES = (
67
+ "eval/chat_benchmarks/LiveCodeBench/livecodebench_utils.py",
68
+ "eval/chat_benchmarks/LiveCodeBenchv5/livecodebench_utils.py",
69
+ "eval/chat_benchmarks/LiveCodeBenchv5_official/livecodebench_utils.py",
70
+ "eval/chat_benchmarks/LiveCodeBenchv6_official/livecodebench_utils.py",
71
+ )
72
+
73
+ OLD_TRACKER_IMPORT = (
74
+ "from lm_eval.utils import eval_logger, handle_non_serializable, hash_string, simple_parse_args_string"
75
+ )
76
+
77
+ NEW_TRACKER_IMPORT = '''try:
78
+ from lm_eval.utils import eval_logger, handle_non_serializable, hash_string, simple_parse_args_string
79
+ except ImportError:
80
+ try:
81
+ from lm_eval.logging_utils import eval_logger
82
+ from lm_eval.utils import handle_non_serializable, hash_string, simple_parse_args_string
83
+ except ImportError:
84
+ import logging
85
+ eval_logger = logging.getLogger("lm-eval")
86
+ from lm_eval.utils import handle_non_serializable, hash_string, simple_parse_args_string'''
87
+
88
+ EVAL_PY_MARKER = "from lm_eval.tasks import TaskManager as PretrainTaskManager"
89
+
90
+ EVAL_PY_LOGGER_PATCH = '''
91
+ # Patch utils.eval_logger for lm-eval compatibility (marin gpu port)
92
+ if not hasattr(utils, "eval_logger"):
93
+ try:
94
+ from lm_eval.logging_utils import eval_logger as _eval_logger
95
+ utils.eval_logger = _eval_logger
96
+ except ImportError:
97
+ import logging as _logging
98
+ utils.eval_logger = _logging.getLogger("lm-eval")
99
+ '''
100
+
101
+ OLD_LCB_FORK = """ manager = multiprocessing.Manager()
102
+ result = manager.list()
103
+ p = multiprocessing.Process(target=run_tests_for_one_example, args=(test_cases, completion, result, is_extracted))"""
104
+
105
+ NEW_LCB_SPAWN = """ # marin gpu port: CUDA/vLLM/filelock state is not safe to inherit through
106
+ # fork. A forked grader child either deadlocks on the CUDA driver mutex or
107
+ # corrupts the parent engine's context. Spawn is the only safe context here.
108
+ multiprocessing_context = multiprocessing.get_context("spawn")
109
+ manager = multiprocessing_context.Manager()
110
+ result = manager.list()
111
+ p = multiprocessing_context.Process(
112
+ target=run_tests_for_one_example,
113
+ args=(test_cases, completion, result, is_extracted),
114
+ )"""
115
+
116
+ ENTRY_MODULE = '''#!/usr/bin/env python3
117
+ """Launcher for evalchemy under a local CUDA vLLM install (marin gpu port).
118
+
119
+ Generated by patch_evalchemy_gpu.py -- do not edit by hand.
120
+
121
+ Applies two process-wide fixes that must happen before ``eval.eval`` imports
122
+ lm-eval, then delegates. Invoked as::
123
+
124
+ python _marin_gpu_entry.py --model vllm --tasks AIME24 ...
125
+ """
126
+
127
+ import runpy
128
+ import socket
129
+ import sys
130
+
131
+ # HMMT and some OlympiadBench answers are enormous integers (e.g. (26!)^3).
132
+ # json.dumps calls str() on them; without this the results file is never written
133
+ # and the eval exits 0 with no output.
134
+ sys.set_int_max_str_digits(0)
135
+
136
+ # lm-eval's vllm_causallms imports get_open_port from vllm.utils, which vLLM
137
+ # moved/removed in newer releases. Re-provide it before lm-eval is imported.
138
+ try:
139
+ import vllm.utils as _vllm_utils
140
+ except ImportError:
141
+ _vllm_utils = None
142
+
143
+ if _vllm_utils is not None and not hasattr(_vllm_utils, "get_open_port"):
144
+ def _get_open_port() -> int:
145
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
146
+ sock.bind(("", 0))
147
+ return sock.getsockname()[1]
148
+
149
+ _vllm_utils.get_open_port = _get_open_port
150
+
151
+ sys.argv = ["eval.eval", *sys.argv[1:]]
152
+ runpy.run_module("eval.eval", run_name="__main__", alter_sys=True)
153
+ '''
154
+
155
+
156
+ def _patch_eval_tracker(root: Path) -> None:
157
+ path = root / "eval" / "eval_tracker.py"
158
+ source = path.read_text()
159
+ if "from lm_eval.logging_utils import eval_logger" in source:
160
+ logger.info("eval_tracker.py already patched")
161
+ return
162
+ if OLD_TRACKER_IMPORT not in source:
163
+ raise RuntimeError(f"marker missing in {path}: lm-eval import shim cannot be applied")
164
+ path.write_text(source.replace(OLD_TRACKER_IMPORT, NEW_TRACKER_IMPORT, 1))
165
+ logger.info("patched %s", path)
166
+
167
+
168
+ def _patch_eval_py(root: Path) -> None:
169
+ path = root / "eval" / "eval.py"
170
+ source = path.read_text()
171
+ if "# Patch utils.eval_logger" in source:
172
+ logger.info("eval.py already patched")
173
+ return
174
+ if EVAL_PY_MARKER not in source:
175
+ raise RuntimeError(f"marker missing in {path}: eval_logger shim cannot be applied")
176
+ path.write_text(source.replace(EVAL_PY_MARKER, EVAL_PY_MARKER + EVAL_PY_LOGGER_PATCH, 1))
177
+ logger.info("patched %s", path)
178
+
179
+
180
+ def _patch_n_repeat(root: Path) -> list[str]:
181
+ patched: list[str] = []
182
+ for name in N_REPEAT_BENCHMARKS:
183
+ path = root / "eval" / "chat_benchmarks" / name / "eval_instruct.py"
184
+ if not path.exists():
185
+ logger.warning("benchmark %s not present at this pin; skipping n_repeat patch", name)
186
+ continue
187
+ source = path.read_text()
188
+ if "EVALCHEMY_N_REPEAT" in source:
189
+ patched.append(name)
190
+ continue
191
+ # __import__ rather than a bare ``os`` reference: several benchmark
192
+ # modules do not import os, and inserting a top-level import is fragile
193
+ # across pin bumps (it would shadow a module docstring).
194
+ new_source, count = re.subn(
195
+ r"self\.n_repeat\s*=\s*\d+",
196
+ 'self.n_repeat = int(__import__("os").environ.get("EVALCHEMY_N_REPEAT", "1"))',
197
+ source,
198
+ count=1,
199
+ )
200
+ if count != 1:
201
+ raise RuntimeError(f"no 'self.n_repeat = <int>' assignment found in {path}")
202
+ path.write_text(new_source)
203
+ patched.append(name)
204
+ logger.info("patched n_repeat in %s", path)
205
+ return patched
206
+
207
+
208
+ def _patch_lcb_num_proc(root: Path) -> None:
209
+ for name in LCB_BENCHMARKS:
210
+ path = root / "eval" / "chat_benchmarks" / name / "eval_instruct.py"
211
+ if not path.exists():
212
+ continue
213
+ source = path.read_text()
214
+ if "EVALCHEMY_NUM_PROC" in source:
215
+ continue
216
+ if "num_proc=cpu_count" not in source:
217
+ raise RuntimeError(f"'num_proc=cpu_count' not found in {path}")
218
+ source = source.replace(
219
+ "num_proc=cpu_count",
220
+ 'num_proc=int(__import__("os").environ.get("EVALCHEMY_NUM_PROC", str(cpu_count)))',
221
+ 1,
222
+ )
223
+ path.write_text(source)
224
+ logger.info("patched num_proc in %s", path)
225
+
226
+
227
+ def _patch_lcb_spawn(root: Path) -> None:
228
+ for relative in LCB_UTILITY_FILES:
229
+ path = root / relative
230
+ if not path.exists():
231
+ logger.warning("%s not present at this pin; skipping spawn patch", relative)
232
+ continue
233
+ source = path.read_text()
234
+ if "multiprocessing_context = multiprocessing.get_context" in source:
235
+ continue
236
+ if OLD_LCB_FORK not in source:
237
+ raise RuntimeError(f"LiveCodeBench fork block not found in {path}")
238
+ path.write_text(source.replace(OLD_LCB_FORK, NEW_LCB_SPAWN, 1))
239
+ logger.info("patched fork->spawn in %s", path)
240
+
241
+
242
+ def _write_entry(root: Path) -> Path:
243
+ path = root / "_marin_gpu_entry.py"
244
+ path.write_text(ENTRY_MODULE)
245
+ path.chmod(0o755)
246
+ logger.info("wrote %s", path)
247
+ return path
248
+
249
+
250
+ def patch(root: Path) -> None:
251
+ if not (root / "eval" / "eval.py").exists():
252
+ raise RuntimeError(f"{root} does not look like an evalchemy checkout (no eval/eval.py)")
253
+ _patch_eval_tracker(root)
254
+ _patch_eval_py(root)
255
+ patched = _patch_n_repeat(root)
256
+ _patch_lcb_num_proc(root)
257
+ _patch_lcb_spawn(root)
258
+ _write_entry(root)
259
+ logger.info("n_repeat is env-driven for: %s", ", ".join(patched))
260
+ logger.info("single-pass benchmarks (no n_repeat): %s", ", ".join(SINGLE_PASS_BENCHMARKS))
261
+ logger.info("evalchemy at %s is ready for single-GPU vLLM evaluation", root)
262
+
263
+
264
+ def main() -> int:
265
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
266
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
267
+ parser.add_argument("evalchemy_dir", type=Path, help="Path to the cloned evalchemy checkout")
268
+ args = parser.parse_args()
269
+ patch(args.evalchemy_dir.resolve())
270
+ return 0
271
+
272
+
273
+ if __name__ == "__main__":
274
+ sys.exit(main())
gpu-sft/scripts/gpu_eval/run_all_evals.sh ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Run the Qwen3-8B baseline + the completed SFT checkpoints through evalchemy, sequentially,
3
+ # writing results to HDFS so a machine kill doesn't lose completed evals.
4
+ #
5
+ # source /opt/tiger/lmc_muon/gpu-eval/activate.sh # (this script also sources it)
6
+ # nohup bash gpu-sft/scripts/gpu_eval/run_all_evals.sh \
7
+ # > /mnt/hdfs/fangzhao_writable/marin-eval/eval_all.log 2>&1 &
8
+ #
9
+ # Each eval uses the full node (tp defaults to #GPUs). Jobs run one at a time. Results go to
10
+ # $RESULTS on HDFS; a completed task's JSON is durable immediately, so a kill only costs the
11
+ # in-flight task (re-run the script — the driver skips finished tasks unless --force).
12
+ source /opt/tiger/lmc_muon/gpu-eval/activate.sh 2>/dev/null || true
13
+ set -uo pipefail
14
+
15
+ DRIVER=/opt/tiger/lmc_muon/self-verified-distillation/gpu-sft/scripts/gpu_eval/gpu_eval_driver.py
16
+ RESULTS="${RESULTS:-/mnt/hdfs/fangzhao_writable/marin-eval/results}"
17
+ HDFS_SFT=/mnt/hdfs/fangzhao_writable/marin-sft
18
+ MODELS=/opt/tiger/lmc_muon/models
19
+ PROFILE="${PROFILE:-h100-80g}"
20
+ mkdir -p "$RESULTS" "$MODELS"
21
+
22
+ run() { # run <checkpoint_dir> <experiment> <suite>
23
+ echo "=========== eval $2 / $3 $(date -u) ==========="
24
+ python "$DRIVER" --checkpoint "$1" --experiment "$2" --suite "$3" \
25
+ --gpu-profile "$PROFILE" --results-root "$RESULTS" \
26
+ || echo "!! $2/$3 FAILED (continuing to next)"
27
+ }
28
+
29
+ # Stage the completed checkpoints from HDFS -> local, keeping a 'step-1999' leaf so the driver
30
+ # parses the step number (16 GB each, ~30 s at 574 MB/s).
31
+ for e in coding_depth4v3_nofilter hs_competition_nofilter; do
32
+ DST="$MODELS/$e/step-1999"
33
+ if [ ! -f "$DST/config.json" ]; then
34
+ echo "staging $e/hf/step-1999 from HDFS -> $DST"
35
+ mkdir -p "$MODELS/$e" && cp -r "$HDFS_SFT/$e/hf/step-1999" "$DST"
36
+ fi
37
+ done
38
+
39
+ if [ "${PARALLEL:-0}" = "1" ]; then
40
+ LOGDIR="$RESULTS/_logs"; mkdir -p "$LOGDIR"
41
+ echo "PARALLEL mode: 4 jobs x tp=2 across GPU pairs (per-job logs in $LOGDIR)"
42
+ CUDA_VISIBLE_DEVICES=0,1 python "$DRIVER" --checkpoint "$MODELS/Qwen3-8B" --experiment qwen3_8b_base --suite math --tensor-parallel-size 2 --gpu-profile "$PROFILE" --results-root "$RESULTS" > "$LOGDIR/base_math.log" 2>&1 &
43
+ CUDA_VISIBLE_DEVICES=2,3 python "$DRIVER" --checkpoint "$MODELS/Qwen3-8B" --experiment qwen3_8b_base --suite code --tensor-parallel-size 2 --gpu-profile "$PROFILE" --results-root "$RESULTS" > "$LOGDIR/base_code.log" 2>&1 &
44
+ CUDA_VISIBLE_DEVICES=4,5 python "$DRIVER" --checkpoint "$MODELS/hs_competition_nofilter/step-1999" --experiment hs_competition_nofilter --suite math --tensor-parallel-size 2 --gpu-profile "$PROFILE" --results-root "$RESULTS" > "$LOGDIR/hs_math.log" 2>&1 &
45
+ CUDA_VISIBLE_DEVICES=6,7 python "$DRIVER" --checkpoint "$MODELS/coding_depth4v3_nofilter/step-1999" --experiment coding_depth4v3_nofilter --suite code --tensor-parallel-size 2 --gpu-profile "$PROFILE" --results-root "$RESULTS" > "$LOGDIR/coding_code.log" 2>&1 &
46
+ wait
47
+ echo "=========== ALL PARALLEL EVALS DONE $(date -u) ==========="
48
+ echo "results -> $RESULTS ; per-job logs -> $LOGDIR"
49
+ exit 0
50
+ fi
51
+
52
+ # 1) baseline (the denominator for every (+delta)) on both suites
53
+ run "$MODELS/Qwen3-8B" qwen3_8b_base math
54
+ run "$MODELS/Qwen3-8B" qwen3_8b_base code
55
+ # 2) the two completed checkpoints on their matching suites
56
+ run "$MODELS/hs_competition_nofilter/step-1999" hs_competition_nofilter math
57
+ run "$MODELS/coding_depth4v3_nofilter/step-1999" coding_depth4v3_nofilter code
58
+
59
+ echo "=========== ALL EVALS DONE $(date -u) ==========="
60
+ echo "results -> $RESULTS"
gpu-sft/scripts/gpu_sft/accelerate_fsdp_qwen3.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Optional: run train_sft_qwen3.py under `accelerate launch` instead of `torchrun`.
2
+ # The script builds its own FSDP wrapper, so accelerate is used ONLY as a launcher here
3
+ # (it sets RANK/LOCAL_RANK/WORLD_SIZE). These fsdp_config values document the exact
4
+ # equivalent settings for anyone who prefers accelerate's Accelerator.prepare().
5
+ #
6
+ # accelerate launch --config_file accelerate_fsdp_qwen3.yaml train_sft_qwen3.py ...
7
+ #
8
+ # If you DO switch to Accelerator.prepare(), you must keep:
9
+ # * mixed_precision bf16 with fp32 master weights and fp32 gradient reduction
10
+ # * FULL_SHARD + transformer-layer wrapping of Qwen3DecoderLayer
11
+ # * use_orig_params: true (the weight-decay parameter groups depend on it)
12
+ # * accelerator.clip_grad_norm_ AFTER all accumulation micro-steps
13
+ # * your own loss normalisation; accelerate's default 1/gradient_accumulation_steps
14
+ # is NOT the recipe's per-microbatch token-weighted mean.
15
+ compute_environment: LOCAL_MACHINE
16
+ distributed_type: FSDP
17
+ downcast_bf16: 'no'
18
+ machine_rank: 0
19
+ main_training_function: main
20
+ mixed_precision: bf16
21
+ num_machines: 1
22
+ num_processes: 8
23
+ rdzv_backend: static
24
+ same_network: true
25
+ use_cpu: false
26
+ enable_cpu_affinity: true
27
+ fsdp_config:
28
+ fsdp_version: 1
29
+ fsdp_sharding_strategy: FULL_SHARD
30
+ fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
31
+ fsdp_transformer_layer_cls_to_wrap: Qwen3DecoderLayer
32
+ fsdp_backward_prefetch: BACKWARD_PRE
33
+ fsdp_forward_prefetch: false
34
+ fsdp_use_orig_params: true
35
+ fsdp_cpu_ram_efficient_loading: true
36
+ fsdp_sync_module_states: true
37
+ fsdp_offload_params: false
38
+ fsdp_state_dict_type: SHARDED_STATE_DICT
39
+ fsdp_activation_checkpointing: false # the script calls gradient_checkpointing_enable itself
40
+ fsdp_reshard_after_forward: true
41
+ fsdp_limit_all_gathers: true
gpu-sft/scripts/gpu_sft/deepspeed_zero3_qwen3.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": [
3
+ "Fallback for 40 GB-class GPUs or shops standardised on DeepSpeed. Only the sharding",
4
+ "backend changes; the recipe (LR schedule, clipping, loss normalisation, packing) still",
5
+ "comes from train_sft_qwen3.py, so run it with --skip-checks and replace the FSDP block",
6
+ "with deepspeed.initialize, or drive it through accelerate's DeepSpeed plugin.",
7
+ "bf16.enabled with fp32 optimizer states reproduces jmp p=f32,c=bfloat16.",
8
+ "gradient_clipping is DISABLED here because the script clips explicitly before step().",
9
+ "Optimizer/scheduler are declared 'none' so the script's AdamW + LambdaLR stay in charge."
10
+ ],
11
+ "train_micro_batch_size_per_gpu": 1,
12
+ "gradient_accumulation_steps": 8,
13
+ "gradient_clipping": 0.0,
14
+ "steps_per_print": 1,
15
+ "wall_clock_breakdown": false,
16
+ "bf16": { "enabled": true },
17
+ "fp16": { "enabled": false },
18
+ "zero_optimization": {
19
+ "stage": 3,
20
+ "overlap_comm": true,
21
+ "contiguous_gradients": true,
22
+ "reduce_bucket_size": 67108864,
23
+ "stage3_prefetch_bucket_size": 67108864,
24
+ "stage3_param_persistence_threshold": 65536,
25
+ "stage3_max_live_parameters": 1000000000,
26
+ "stage3_max_reuse_distance": 1000000000,
27
+ "stage3_gather_16bit_weights_on_model_save": true,
28
+ "offload_optimizer": { "device": "none" },
29
+ "offload_param": { "device": "none" }
30
+ },
31
+ "activation_checkpointing": {
32
+ "partition_activations": false,
33
+ "cpu_checkpointing": false,
34
+ "contiguous_memory_optimization": false,
35
+ "number_checkpoints": 36,
36
+ "synchronize_checkpoint_boundary": false,
37
+ "profile": false
38
+ },
39
+ "_offload_note": "On 8x40 GB set offload_optimizer.device='cpu' with pin_memory=true: that frees ~12 GB/GPU at a 20-40% throughput cost and does not change the math."
40
+ }
gpu-sft/scripts/gpu_sft/launch_sft_8b.sh ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Qwen3-8B self-instill SFT on a single NVIDIA node. Local disk only: no GCS, no Iris.
3
+ #
4
+ # ./launch_sft_8b.sh <hf-dataset-id> <7-char-revision> <run-name>
5
+ #
6
+ # Stage 1 (CPU, ~10-30 min) is cached; stage 2 is the training run.
7
+ set -euo pipefail
8
+
9
+ DATASET_ID="${1:?usage: launch_sft_8b.sh <dataset-id> <revision> <run-name>}"
10
+ REVISION="${2:?}"
11
+ RUN_NAME="${3:?}"
12
+
13
+ ROOT="${SFT_ROOT:-/data/sft}"
14
+ DATA_DIR="${ROOT}/data/$(echo "${DATASET_ID}" | tr '/' '_')-${REVISION}"
15
+ OUT_DIR="${ROOT}/runs/${RUN_NAME}"
16
+ NPROC="${NPROC:-$(nvidia-smi --list-gpus | wc -l)}"
17
+
18
+ export HF_HOME="${HF_HOME:-${ROOT}/hf}"
19
+ export TOKENIZERS_PARALLELISM=false
20
+ export OMP_NUM_THREADS="${OMP_NUM_THREADS:-8}"
21
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
22
+ export NCCL_DEBUG="${NCCL_DEBUG:-WARN}"
23
+ export PYTHONUNBUFFERED=1
24
+ # W&B is optional; the TPU runs used WANDB_MODE=offline on the workers.
25
+ export WANDB_MODE="${WANDB_MODE:-offline}"
26
+
27
+ mkdir -p "${OUT_DIR}"
28
+
29
+ # ----------------------------------------------------------------------------------
30
+ # Stage 1: HF dataset -> packed local .npy (idempotent, skipped if manifest exists)
31
+ # ----------------------------------------------------------------------------------
32
+ if [[ ! -f "${DATA_DIR}/manifest.json" ]]; then
33
+ python prepare_sft_data.py \
34
+ --dataset-id "${DATASET_ID}" \
35
+ --revision "${REVISION}" \
36
+ --tokenizer Qwen/Qwen3-8B \
37
+ --max-seq-len 32768 \
38
+ --text-replacements marin \
39
+ --num-proc "$(nproc)" \
40
+ --out "${DATA_DIR}" 2>&1 | tee "${OUT_DIR}/prepare.log"
41
+
42
+ # one-time fidelity gate: our encoder must agree with levanter's segment-split encoder
43
+ python prepare_sft_data.py \
44
+ --dataset-id "${DATASET_ID}" --revision "${REVISION}" \
45
+ --tokenizer Qwen/Qwen3-8B --verify 200 2>&1 | tee -a "${OUT_DIR}/prepare.log"
46
+ fi
47
+
48
+ # ----------------------------------------------------------------------------------
49
+ # Stage 1.5: print the plan (LR schedule, epochs, export cadence) before burning GPUs
50
+ # ----------------------------------------------------------------------------------
51
+ python train_sft_qwen3.py --data "${DATA_DIR}" --out "${OUT_DIR}" --recipe qwen3-8b --dry-run
52
+
53
+ # ----------------------------------------------------------------------------------
54
+ # Stage 2: training
55
+ #
56
+ # Batch geometry (must reproduce global batch 64 x 32768 tokens = 2,097,152 tok/step):
57
+ # NPROC=8 : --per-device-batch 1, loss-groups 2 -> 4 micro-steps/group, 8 accum/step
58
+ # NPROC=4 : --per-device-batch 1, loss-groups 2 -> 8 micro-steps/group, 16 accum/step
59
+ # NPROC=16: --per-device-batch 1, loss-groups 2 -> 2 micro-steps/group, 4 accum/step
60
+ #
61
+ # Memory per GPU, Qwen3-8B (8.19e9 params), seq 32768, FSDP FULL_SHARD over 8 GPUs,
62
+ # fp32 master + bf16 compute + full activation checkpointing + chunked CE(2048):
63
+ # fp32 master params 4 * 8.19e9 / 8 = 4.1 GB
64
+ # Adam m + v (fp32) 8 * 8.19e9 / 8 = 8.2 GB
65
+ # fp32 grad shard 4 * 8.19e9 / 8 = 4.1 GB
66
+ # bf16 all-gather buffers root(1.24e9)+layers = 3.7 GB
67
+ # checkpointed layer inputs 36*32768*4096*2 = 9.7 GB
68
+ # one layer recompute working set = 3.2 GB
69
+ # chunked CE (2048 x 151936 fp32, fwd+grad) = 2.5 GB
70
+ # last hidden + grad = 0.5 GB
71
+ # ----------------------------------------------------------
72
+ # ~36 GB + NCCL/fragmentation -> ~40 GB (fits 80 GB comfortably)
73
+ #
74
+ # 4 GPUs : optimiser/param/grad term triples to 32.8 GB -> ~50 GB, still fits 80 GB.
75
+ # 8x40 GB A100: ~30-34 GB, tight. Use --loss-chunk-size 1024 --wrap-embeddings,
76
+ # or 16 GPUs, or the 8K fallback below.
77
+ #
78
+ # Wall clock: ~3.9e20 FLOPs total (1.75e20 dense + 1.2e20 attention at 32K, x4/3 for
79
+ # recompute). 8xH100 at 30-35% MFU -> 40-50 h. 8xB200/H200 -> ~20-25 h.
80
+ # ----------------------------------------------------------------------------------
81
+ torchrun --standalone --nproc_per_node="${NPROC}" train_sft_qwen3.py \
82
+ --data "${DATA_DIR}" \
83
+ --out "${OUT_DIR}" \
84
+ --recipe qwen3-8b \
85
+ --train-batch-size 64 \
86
+ --per-device-batch 1 \
87
+ --loss-groups 2 \
88
+ --num-train-steps 2000 \
89
+ --steps-per-hf-export 100 \
90
+ --steps-per-checkpoint 20 \
91
+ --loss-chunk-size 2048 \
92
+ --hf-export-dtype bfloat16 \
93
+ --resume auto \
94
+ 2>&1 | tee -a "${OUT_DIR}/train.log"
95
+
96
+ # ----------------------------------------------------------------------------------
97
+ # 8K-context fallback (OOM at 32K, or no FlashAttention build).
98
+ # Keeps tokens/step, step count and LR schedule identical: 256 x 8192 = 2,097,152.
99
+ # Documents longer than 8192 are LEFT-SLICED (kept from the beginning) exactly as
100
+ # levanter's slice_strategy="left" does; check `docs_over_max_seq_len` in the
101
+ # prepare-stage stats before accepting this.
102
+ #
103
+ # python prepare_sft_data.py ... --max-seq-len 8192 --out ${DATA_DIR}-8k
104
+ # torchrun --standalone --nproc_per_node=8 train_sft_qwen3.py \
105
+ # --data ${DATA_DIR}-8k --out ${OUT_DIR}-8k --recipe qwen3-8b \
106
+ # --train-batch-size 256 --per-device-batch 4 --loss-groups 2 \
107
+ # --num-train-steps 2000 --loss-chunk-size 2048
108
+ # ----------------------------------------------------------------------------------
gpu-sft/scripts/gpu_sft/prepare_sft_data.py ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # ruff: noqa: E501
3
+ """Stage 1 of the GPU port of the Marin/Levanter Qwen3 self-instill SFT recipe.
4
+
5
+ HF dataset (pinned revision)
6
+ -> Marin conversation transform (DEFAULT_TEXT_REPLACEMENTS)
7
+ -> Qwen3 chat template with {% generation %} assistant masking
8
+ -> Levanter greedy *contiguous* packing (max 64 segments / 32768 tokens)
9
+ -> local .npy memmaps + manifest.json
10
+
11
+ Every step here is a deliberate port of the TPU pipeline. Where the TPU pipeline has a
12
+ bug or a surprising behaviour, this script reproduces it by default and exposes a flag to
13
+ turn it off. Read the "FIDELITY NOTES" block before changing anything.
14
+
15
+ FIDELITY NOTES
16
+ --------------
17
+ 1. TEXT REPLACEMENTS (reproduced by default; `--text-replacements none` to disable).
18
+ marin/transform/conversation/transform_conversation.py applies
19
+ DEFAULT_TEXT_REPLACEMENTS = {"<think>": "<|start_think|>", "</think>": "<|end_think|>"}
20
+ to every message because multi_turn_adapter() leaves `replacements=None`.
21
+ Those two strings are Marin/Llama3 tokens, NOT Qwen3 tokens, so under the Qwen3
22
+ tokenizer they encode as ordinary multi-token text. Consequence: the chat template's
23
+ `'</think>' in content` reasoning-split branch never fires, `reasoning_content` stays
24
+ empty, and every FINAL assistant turn is rendered as
25
+
26
+ <|im_start|>assistant\n<think>\n\n</think>\n\n<|start_think|>...<|end_think|>...<|im_end|>\n
27
+
28
+ i.e. an EMPTY Qwen3 think block followed by the real reasoning in non-special text.
29
+ This is what the TPU checkpoints were trained on. Disabling it changes the token
30
+ layout and the assistant mask, and the resulting model will not match the TPU runs.
31
+
32
+ 2. DOCUMENT ORDER. Packing is greedy over *adjacent* documents in cache order. We use HF
33
+ dataset row order. The TPU cache order came from the zephyr shard writer; if that
34
+ interleaved shards, pack boundaries differ. Impact is limited: cross-document
35
+ attention is blocked and per-token loss weights are unchanged, so only the grouping
36
+ of documents into 32768-token windows (and hence the microbatch denominators) moves.
37
+
38
+ 3. VOCAB. We do NOT resize embeddings. The HF checkpoint already has vocab_size=151936
39
+ while len(tokenizer) is 151669; Levanter padded the *tokenizer* up to the model. We
40
+ emit the 267 <|padding_i|> tokens only at HF-export time (see train_sft_qwen3.py) so
41
+ the exported tokenizer matches the TPU exports. Token ids are unaffected.
42
+
43
+ 4. NO SHUFFLE. The TPU experiment passes `shuffle=DATASET_SIZE` (an int) where levanter
44
+ expects bool | BlockShuffleConfig, which makes shuffling a silent no-op. The trainer
45
+ therefore walks packed examples in order and wraps around. Nothing to do here.
46
+
47
+ Usage
48
+ -----
49
+ python prepare_sft_data.py \\
50
+ --dataset-id fzzhang/qwen3_8b_hs_competition_depth2_probval_instill_n8_valredundancy5_round1 \\
51
+ --revision <7-char-sha> \\
52
+ --tokenizer Qwen/Qwen3-8B \\
53
+ --max-seq-len 32768 \\
54
+ --out /data/sft/hs_competition_depth2 \\
55
+ --num-proc 16
56
+
57
+ # prove the encoder matches Levanter's segment-split encoder on 200 sampled docs
58
+ python prepare_sft_data.py ... --verify 200
59
+
60
+ # packing statistics only (answers "how many epochs is 2000 steps?")
61
+ python prepare_sft_data.py ... --stats-only
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import argparse
67
+ import hashlib
68
+ import json
69
+ import os
70
+ import re
71
+ import sys
72
+ import time
73
+ from itertools import chain
74
+ from typing import Any
75
+
76
+ import numpy as np
77
+
78
+ # ----------------------------------------------------------------------------------
79
+ # Verbatim copy of marin experiments/qwen3_chat_template.py :: QWEN_3_CHAT_TEMPLATE
80
+ # (upstream Qwen3 template + {% generation %} tags around assistant content, tool-call
81
+ # bodies and the trailing '<|im_end|>\n').
82
+ # ----------------------------------------------------------------------------------
83
+ QWEN3_CHAT_TEMPLATE = r"""{%- if tools is defined and tools %}
84
+ {{- '<|im_start|>system\n' }}
85
+ {%- if messages[0].role == 'system' %}
86
+ {{- messages[0].content + '\n\n' }}
87
+ {%- endif %}
88
+ {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
89
+ {%- for tool in tools %}
90
+ {{- "\n" }}
91
+ {{- tool | tojson }}
92
+ {%- endfor %}
93
+ {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
94
+ {%- else %}
95
+ {%- if messages[0].role == 'system' %}
96
+ {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
100
+ {%- for message in messages[::-1] %}
101
+ {%- set index = (messages|length - 1) - loop.index0 %}
102
+ {%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
103
+ {%- set ns.multi_step_tool = false %}
104
+ {%- set ns.last_query_index = index %}
105
+ {%- endif %}
106
+ {%- endfor %}
107
+ {%- for message in messages %}
108
+ {%- if message.content is string %}
109
+ {%- set content = message.content %}
110
+ {%- else %}
111
+ {%- set content = '' %}
112
+ {%- endif %}
113
+ {%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
114
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
115
+ {%- elif message.role == "assistant" %}
116
+ {%- set reasoning_content = '' %}
117
+ {%- if message.reasoning_content is string %}
118
+ {%- set reasoning_content = message.reasoning_content %}
119
+ {%- else %}
120
+ {%- if '</think>' in content %}
121
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
122
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
123
+ {%- endif %}
124
+ {%- endif %}
125
+ {%- if loop.index0 > ns.last_query_index %}
126
+ {%- if loop.last or (not loop.last and reasoning_content) %}
127
+ {{- '<|im_start|>' + message.role + '\n' }}{% generation %}{{- '<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}{% endgeneration %}
128
+ {%- else %}
129
+ {{- '<|im_start|>' + message.role + '\n' }}{% generation %}{{- content }}{% endgeneration %}
130
+ {%- endif %}
131
+ {%- else %}
132
+ {{- '<|im_start|>' + message.role + '\n' }}{% generation %}{{- content }}{% endgeneration %}
133
+ {%- endif %}
134
+ {%- if message.tool_calls %}
135
+ {%- for tool_call in message.tool_calls %}
136
+ {%- if (loop.first and content) or (not loop.first) %}
137
+ {% generation %}{{- '\n' }}{% endgeneration %}
138
+ {%- endif %}
139
+ {%- if tool_call.function %}
140
+ {%- set tool_call = tool_call.function %}
141
+ {%- endif %}
142
+ {% generation %}{{- '<tool_call>\n{"name": "' }}
143
+ {{- tool_call.name }}
144
+ {{- '", "arguments": ' }}
145
+ {%- if tool_call.arguments is string %}
146
+ {{- tool_call.arguments }}
147
+ {%- else %}
148
+ {{- tool_call.arguments | tojson }}
149
+ {%- endif %}
150
+ {{- '}\n</tool_call>' }}{% endgeneration %}
151
+ {%- endfor %}
152
+ {%- endif %}
153
+ {% generation %}{{- '<|im_end|>\n' }}{% endgeneration %}
154
+ {%- elif message.role == "tool" %}
155
+ {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
156
+ {{- '<|im_start|>user' }}
157
+ {%- endif %}
158
+ {{- '\n<tool_response>\n' }}
159
+ {{- content }}
160
+ {{- '\n</tool_response>' }}
161
+ {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
162
+ {{- '<|im_end|>\n' }}
163
+ {%- endif %}
164
+ {%- endif %}
165
+ {%- endfor %}
166
+ {%- if add_generation_prompt %}
167
+ {{- '<|im_start|>assistant\n' }}
168
+ {%- if enable_thinking is defined and enable_thinking is false %}
169
+ {% generation %}{{- '<think>\n\n</think>\n\n' }}{% endgeneration %}
170
+ {%- endif %}
171
+ {%- endif %}"""
172
+
173
+ DEFAULT_TEXT_REPLACEMENTS = {"<think>": "<|start_think|>", "</think>": "<|end_think|>"}
174
+
175
+ # levanter/tokenizers.py sentinels, used only by --verify.
176
+ _GEN_START = "__MARIN_GEN_START_7f3a9c__"
177
+ _GEN_END = "__MARIN_GEN_END_7f3a9c__"
178
+
179
+ MAX_SEGMENTS_PER_EXAMPLE = 64 # levanter datasets.py: 64 when pack is True
180
+ FORMAT_VERSION = 2
181
+
182
+
183
+ # ----------------------------------------------------------------------------------
184
+ # message normalisation (port of multi_turn_adapter + transform_row)
185
+ # ----------------------------------------------------------------------------------
186
+ def normalize_messages(
187
+ row: dict[str, Any],
188
+ conversation_column: str,
189
+ role_key: str,
190
+ content_key: str,
191
+ replacements: dict[str, str] | None,
192
+ ) -> list[dict[str, Any]]:
193
+ raw = row[conversation_column]
194
+ out: list[dict[str, Any]] = []
195
+ for m in raw:
196
+ role = m[role_key]
197
+ content = m.get(content_key)
198
+ if isinstance(content, str) and replacements:
199
+ for old, new in replacements.items():
200
+ content = content.replace(old, new)
201
+ msg: dict[str, Any] = {"role": role, "content": content}
202
+ # marin's OpenAIChatMessage carries these; the template only reads tool_calls.
203
+ if m.get("tool_calls"):
204
+ msg["tool_calls"] = m["tool_calls"]
205
+ out.append(msg)
206
+ return out
207
+
208
+
209
+ # ----------------------------------------------------------------------------------
210
+ # encoding
211
+ # ----------------------------------------------------------------------------------
212
+ def encode_conversation(tokenizer, messages: list[dict[str, Any]]) -> tuple[list[int], list[int]]:
213
+ """HF path: render once, tokenize once, map {% generation %} char spans to tokens."""
214
+ enc = tokenizer.apply_chat_template(
215
+ messages,
216
+ chat_template=QWEN3_CHAT_TEMPLATE,
217
+ tokenize=True,
218
+ add_generation_prompt=False,
219
+ return_dict=True,
220
+ return_assistant_tokens_mask=True,
221
+ )
222
+ return list(enc["input_ids"]), list(enc["assistant_masks"])
223
+
224
+
225
+ def _levanter_env():
226
+ """Recreate levanter._make_jinja_env([_GenerationSentinelExtension])."""
227
+ import time as _time
228
+
229
+ import jinja2
230
+ import jinja2.ext
231
+ import jinja2.sandbox
232
+
233
+ class _GenerationSentinelExtension(jinja2.ext.Extension):
234
+ tags = {"generation"}
235
+
236
+ def parse(self, parser):
237
+ lineno = next(parser.stream).lineno
238
+ body = parser.parse_statements(("name:endgeneration",), drop_needle=True)
239
+ call = self.call_method("_wrap_generation", [])
240
+ return jinja2.nodes.CallBlock(call, [], [], body).set_lineno(lineno)
241
+
242
+ @staticmethod
243
+ def _wrap_generation(caller):
244
+ return _GEN_START + caller() + _GEN_END
245
+
246
+ def _raise(message):
247
+ raise jinja2.exceptions.TemplateError(message)
248
+
249
+ # Match transformers.apply_chat_template, which renders with the DEFAULT lenient
250
+ # Undefined: the Qwen3 template references optional fields (message.tool_calls,
251
+ # message.reasoning_content) that plain {role, content} messages lack, and under
252
+ # StrictUndefined `{% if message.tool_calls %}` raises UndefinedError. Lenient
253
+ # Undefined evaluates missing attrs as falsy, exactly as the real encoding path does.
254
+ env = jinja2.sandbox.SandboxedEnvironment(
255
+ undefined=jinja2.Undefined,
256
+ trim_blocks=True,
257
+ lstrip_blocks=True,
258
+ extensions=[_GenerationSentinelExtension],
259
+ )
260
+ env.globals.update({"raise_exception": _raise, "strftime_now": lambda fmt: _time.strftime(fmt)})
261
+ return env
262
+
263
+
264
+ def encode_conversation_levanter(tokenizer, messages: list[dict[str, Any]]) -> tuple[list[int], list[int]]:
265
+ """Reference path: levanter's segment-split encoder, used only by --verify."""
266
+ env = _levanter_env()
267
+ rendered = env.from_string(QWEN3_CHAT_TEMPLATE).render(
268
+ messages=messages,
269
+ add_generation_prompt=False,
270
+ bos_token=tokenizer.bos_token or "",
271
+ eos_token=tokenizer.eos_token or "",
272
+ )
273
+ parts = re.split(f"({re.escape(_GEN_START)}|{re.escape(_GEN_END)})", rendered)
274
+ ids: list[int] = []
275
+ mask: list[int] = []
276
+ is_assistant = False
277
+ for part in parts:
278
+ if part == _GEN_START:
279
+ is_assistant = True
280
+ continue
281
+ if part == _GEN_END:
282
+ is_assistant = False
283
+ continue
284
+ if not part:
285
+ continue
286
+ seg = tokenizer.encode(part, add_special_tokens=False)
287
+ ids.extend(seg)
288
+ mask.extend([1 if is_assistant else 0] * len(seg))
289
+ return ids, mask
290
+
291
+
292
+ # ----------------------------------------------------------------------------------
293
+ # packing (line-by-line port of levanter/data/packing.py::pack_documents,
294
+ # slice_strategy="left", pad_with_zeros=True, max_segments_per_example=64)
295
+ # ----------------------------------------------------------------------------------
296
+ def pack_document_ranges(lengths: np.ndarray, max_length: int, max_segments: int) -> list[tuple[int, int]]:
297
+ n = int(len(lengths))
298
+ ranges: list[tuple[int, int]] = []
299
+ i = 0
300
+ while i < n:
301
+ start = i
302
+ total_segments = 0
303
+ running = 0
304
+ while i < n:
305
+ if total_segments + 1 > max_segments:
306
+ break
307
+ candidate = running + int(lengths[i])
308
+ end_pack_after_this = False
309
+ if candidate > max_length:
310
+ if i == start:
311
+ # single oversized document: keep it and slice from the left
312
+ end_pack_after_this = True
313
+ else:
314
+ break
315
+ running = candidate
316
+ total_segments += 1
317
+ i += 1
318
+ if end_pack_after_this:
319
+ break
320
+ if i == start: # defensive; unreachable with max_segments >= 1
321
+ i = start + 1
322
+ ranges.append((start, i))
323
+ return ranges
324
+
325
+
326
+ # ----------------------------------------------------------------------------------
327
+ def build(args) -> None:
328
+ from datasets import load_dataset
329
+ from transformers import AutoTokenizer
330
+
331
+ t0 = time.time()
332
+ replacements = DEFAULT_TEXT_REPLACEMENTS if args.text_replacements == "marin" else None
333
+ print(f"[prepare] text replacements: {replacements}", flush=True)
334
+
335
+ tokenizer = AutoTokenizer.from_pretrained(args.tokenizer)
336
+ if not tokenizer.is_fast:
337
+ raise RuntimeError("A fast tokenizer is required for return_assistant_tokens_mask.")
338
+
339
+ ds = load_dataset(args.dataset_id, revision=args.revision, split=args.split)
340
+ if args.limit:
341
+ ds = ds.select(range(min(args.limit, len(ds))))
342
+ print(f"[prepare] loaded {len(ds)} rows from {args.dataset_id}@{args.revision}", flush=True)
343
+
344
+ conversation_column = args.conversation_column
345
+ role_key, content_key = args.role_key, args.content_key
346
+
347
+ def _map(batch, indices):
348
+ tok = tokenizer
349
+ ids_out, mask_out, len_out = [], [], []
350
+ for k in range(len(indices)):
351
+ row = {conversation_column: batch[conversation_column][k]}
352
+ messages = normalize_messages(row, conversation_column, role_key, content_key, replacements)
353
+ ids, mask = encode_conversation(tok, messages)
354
+ if not any(mask):
355
+ raise ValueError(
356
+ f"Row {indices[k]} produced no assistant tokens. levanter's ChatProcessor raises here too."
357
+ )
358
+ ids_out.append(ids)
359
+ mask_out.append(mask)
360
+ len_out.append(len(ids))
361
+ return {"input_ids": ids_out, "assistant_mask": mask_out, "length": len_out}
362
+
363
+ enc = ds.map(
364
+ _map,
365
+ batched=True,
366
+ batch_size=16,
367
+ with_indices=True,
368
+ num_proc=args.num_proc,
369
+ remove_columns=ds.column_names,
370
+ desc="tokenize",
371
+ )
372
+
373
+ lengths = np.asarray(enc["length"], dtype=np.int64)
374
+ L = args.max_seq_len
375
+ ranges = pack_document_ranges(lengths, L, MAX_SEGMENTS_PER_EXAMPLE)
376
+ n_packs = len(ranges)
377
+
378
+ stats = {
379
+ "n_documents": int(len(lengths)),
380
+ "doc_tokens_total": int(lengths.sum()),
381
+ "doc_len_mean": float(lengths.mean()),
382
+ "doc_len_p50": float(np.percentile(lengths, 50)),
383
+ "doc_len_p90": float(np.percentile(lengths, 90)),
384
+ "doc_len_p99": float(np.percentile(lengths, 99)),
385
+ "doc_len_max": int(lengths.max()),
386
+ "docs_over_max_seq_len": int((lengths > L).sum()),
387
+ "tokens_truncated": int(np.clip(lengths - L, 0, None).sum()),
388
+ "n_packed_examples": n_packs,
389
+ "docs_per_pack_mean": float(len(lengths) / max(n_packs, 1)),
390
+ "pack_fill_ratio": float(min(lengths.sum(), n_packs * L) / max(n_packs * L, 1)),
391
+ }
392
+ for gb in (64, 128, 256):
393
+ stats[f"epochs_at_2000_steps_bs{gb}"] = round(2000 * gb / max(n_packs, 1), 3)
394
+ print("[prepare] stats: " + json.dumps(stats, indent=2), flush=True)
395
+ if args.stats_only:
396
+ return
397
+
398
+ os.makedirs(args.out, exist_ok=True)
399
+ tokens = np.lib.format.open_memmap(
400
+ os.path.join(args.out, "tokens.npy"), mode="w+", dtype=np.uint32, shape=(n_packs, L)
401
+ )
402
+ amask = np.lib.format.open_memmap(
403
+ os.path.join(args.out, "assistant_mask.npy"), mode="w+", dtype=np.uint8, shape=(n_packs, L)
404
+ )
405
+ doc_lens_flat: list[int] = []
406
+ pack_offsets = np.zeros(n_packs + 1, dtype=np.int64)
407
+ assistant_counts = np.zeros(n_packs, dtype=np.int64)
408
+ real_tokens = np.zeros(n_packs, dtype=np.int64)
409
+
410
+ for p, (s, e) in enumerate(ranges):
411
+ rows = enc[s:e]
412
+ ids = list(chain.from_iterable(rows["input_ids"]))
413
+ msk = list(chain.from_iterable(rows["assistant_mask"]))
414
+ dlens = [len(x) for x in rows["input_ids"]]
415
+ if len(ids) > L:
416
+ assert e - s == 1, "levanter never packs two docs when one alone overflows"
417
+ ids, msk, dlens = ids[:L], msk[:L], [L]
418
+ n = len(ids)
419
+ tokens[p, :n] = np.asarray(ids, dtype=np.uint32)
420
+ tokens[p, n:] = 0 # pad_with_zeros=True
421
+ amask[p, :n] = np.asarray(msk, dtype=np.uint8)
422
+ amask[p, n:] = 0
423
+ doc_lens_flat.extend(dlens)
424
+ pack_offsets[p + 1] = len(doc_lens_flat)
425
+ # levanter loss weight = roll(mask, -1) * not_last_mask => sum(mask[1:])
426
+ assistant_counts[p] = int(sum(msk)) - int(msk[0])
427
+ real_tokens[p] = n
428
+ if p % 2000 == 0:
429
+ print(f"[prepare] packed {p}/{n_packs}", flush=True)
430
+
431
+ tokens.flush()
432
+ amask.flush()
433
+ np.save(os.path.join(args.out, "doc_lens.npy"), np.asarray(doc_lens_flat, dtype=np.int32))
434
+ np.save(os.path.join(args.out, "pack_offsets.npy"), pack_offsets)
435
+ np.save(os.path.join(args.out, "assistant_counts.npy"), assistant_counts)
436
+ np.save(os.path.join(args.out, "real_tokens.npy"), real_tokens)
437
+
438
+ stats["assistant_tokens_total"] = int(assistant_counts.sum())
439
+ stats["assistant_token_fraction"] = float(assistant_counts.sum() / max(real_tokens.sum(), 1))
440
+ cfg = {
441
+ "format_version": FORMAT_VERSION,
442
+ "dataset_id": args.dataset_id,
443
+ "revision": args.revision,
444
+ "split": args.split,
445
+ "tokenizer": args.tokenizer,
446
+ "max_seq_len": L,
447
+ "max_segments_per_example": MAX_SEGMENTS_PER_EXAMPLE,
448
+ "text_replacements": replacements,
449
+ "chat_template_sha256": hashlib.sha256(QWEN3_CHAT_TEMPLATE.encode()).hexdigest(),
450
+ "tokenizer_len_unpadded": len(tokenizer),
451
+ "n_packs": n_packs,
452
+ "stats": stats,
453
+ "built_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
454
+ }
455
+ with open(os.path.join(args.out, "manifest.json"), "w") as f:
456
+ json.dump(cfg, f, indent=2)
457
+ print(f"[prepare] wrote {n_packs} packed examples to {args.out} in {time.time() - t0:.0f}s", flush=True)
458
+
459
+
460
+ # ----------------------------------------------------------------------------------
461
+ def verify(args) -> None:
462
+ """Assert the HF encoder == levanter's segment-split encoder on sampled documents."""
463
+ from datasets import load_dataset
464
+ from transformers import AutoTokenizer
465
+
466
+ replacements = DEFAULT_TEXT_REPLACEMENTS if args.text_replacements == "marin" else None
467
+ tokenizer = AutoTokenizer.from_pretrained(args.tokenizer)
468
+ ds = load_dataset(args.dataset_id, revision=args.revision, split=args.split)
469
+ rng = np.random.default_rng(0)
470
+ idx = rng.choice(len(ds), size=min(args.verify, len(ds)), replace=False)
471
+
472
+ bad = 0
473
+ for j, i in enumerate(idx):
474
+ messages = normalize_messages(
475
+ ds[int(i)], args.conversation_column, args.role_key, args.content_key, replacements
476
+ )
477
+ a_ids, a_mask = encode_conversation(tokenizer, messages)
478
+ b_ids, b_mask = encode_conversation_levanter(tokenizer, messages)
479
+ if a_ids != b_ids or a_mask != b_mask:
480
+ bad += 1
481
+ n = min(len(a_ids), len(b_ids))
482
+ first = next((k for k in range(n) if a_ids[k] != b_ids[k] or a_mask[k] != b_mask[k]), n)
483
+ print(
484
+ f"[verify] MISMATCH row={int(i)} len_hf={len(a_ids)} len_lev={len(b_ids)} first_diff={first}",
485
+ file=sys.stderr,
486
+ )
487
+ print(f" hf : {a_ids[max(0, first - 5) : first + 5]}", file=sys.stderr)
488
+ print(f" lev: {b_ids[max(0, first - 5) : first + 5]}", file=sys.stderr)
489
+ if j == 0:
490
+ txt = tokenizer.decode(a_ids[:120])
491
+ print(f"[verify] sample head: {txt!r}", flush=True)
492
+ print(f"[verify] assistant tokens: {sum(a_mask)}/{len(a_mask)}", flush=True)
493
+ if bad:
494
+ raise SystemExit(f"[verify] {bad}/{len(idx)} documents disagree with the levanter encoder")
495
+ print(f"[verify] OK: {len(idx)} documents byte-identical to the levanter encoder", flush=True)
496
+
497
+
498
+ def main() -> None:
499
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
500
+ p.add_argument("--dataset-id", required=True)
501
+ p.add_argument("--revision", required=True, help="7-char HF sha, pinned exactly like instruction_datasets.py")
502
+ p.add_argument("--split", default="train")
503
+ p.add_argument("--tokenizer", default="Qwen/Qwen3-8B")
504
+ p.add_argument("--max-seq-len", type=int, default=32768)
505
+ p.add_argument("--out", default=None)
506
+ p.add_argument("--num-proc", type=int, default=max(1, (os.cpu_count() or 8) // 2))
507
+ p.add_argument("--limit", type=int, default=0)
508
+ p.add_argument("--conversation-column", default="messages")
509
+ p.add_argument("--role-key", default="role")
510
+ p.add_argument("--content-key", default="content")
511
+ p.add_argument("--text-replacements", choices=("marin", "none"), default="marin")
512
+ p.add_argument("--verify", type=int, default=0, help="verify N sampled docs against the levanter encoder and exit")
513
+ p.add_argument("--stats-only", action="store_true")
514
+ args = p.parse_args()
515
+
516
+ if args.verify:
517
+ verify(args)
518
+ return
519
+ if not args.out and not args.stats_only:
520
+ p.error("--out is required unless --stats-only/--verify")
521
+ build(args)
522
+
523
+
524
+ if __name__ == "__main__":
525
+ main()
gpu-sft/scripts/gpu_sft/push_exports.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Watch a training run's hf/ dir and upload each new step-N export to a private HF
3
+ repo, so ephemeral-disk checkpoints survive a machine kill and are eval-able anywhere.
4
+
5
+ train_sft_qwen3.py writes a bf16 hf/step-N/ every steps_per_hf_export. This uploads each
6
+ one (once it has finished writing) to <repo> under path_in_repo=step-N/. One repo per
7
+ experiment; steps are subfolders. Idempotent via a local .pushed_steps.json marker, so a
8
+ restart of this watcher (or of the whole run) re-uploads nothing.
9
+
10
+ Run in the background alongside training (run_sft.sh does this for you):
11
+ python push_exports.py --out <rundir> --repo fzzhang/<exp>_sft --until-step 2000
12
+
13
+ HF auth: uses your `huggingface-cli login` token (~/.cache/huggingface/token).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import time
20
+ from pathlib import Path
21
+
22
+
23
+ def step_dirs(hf_dir: Path) -> dict[int, Path]:
24
+ out: dict[int, Path] = {}
25
+ if not hf_dir.is_dir():
26
+ return out
27
+ for d in hf_dir.glob("step-*"):
28
+ if d.is_dir():
29
+ try:
30
+ out[int(d.name.split("-", 1)[1])] = d
31
+ except (IndexError, ValueError):
32
+ pass
33
+ return out
34
+
35
+
36
+ def is_stable(d: Path, stable_sec: float) -> bool:
37
+ """True if no file in the dir was modified within the last stable_sec (write finished)."""
38
+ files = [p for p in d.rglob("*") if p.is_file()]
39
+ if not files:
40
+ return False
41
+ return (time.time() - max(p.stat().st_mtime for p in files)) > stable_sec
42
+
43
+
44
+ def main() -> None:
45
+ ap = argparse.ArgumentParser(description="Upload hf/step-N exports to HF as they land")
46
+ ap.add_argument("--out", required=True, help="training run dir (contains hf/)")
47
+ ap.add_argument("--repo", required=True, help="target HF repo id, e.g. fzzhang/<exp>_sft")
48
+ ap.add_argument("--poll", type=int, default=120, help="seconds between scans")
49
+ ap.add_argument("--stable-sec", type=float, default=60.0,
50
+ help="a step dir must be idle this long before upload (guards partial writes)")
51
+ ap.add_argument("--every", type=int, default=1,
52
+ help="upload every Nth export (exports are every 100 steps; 1=all). "
53
+ "The --until-step export is always uploaded regardless.")
54
+ ap.add_argument("--until-step", type=int, default=0,
55
+ help="exit after this step is pushed (0 = run forever / until killed)")
56
+ ap.add_argument("--once", action="store_true",
57
+ help="scan once, push all unpushed steps (ignore --stable-sec), then exit")
58
+ args = ap.parse_args()
59
+
60
+ from huggingface_hub import HfApi
61
+
62
+ api = HfApi()
63
+ api.create_repo(args.repo, repo_type="model", private=True, exist_ok=True)
64
+
65
+ hf_dir = Path(args.out) / "hf"
66
+ marker = Path(args.out) / ".pushed_steps.json"
67
+ pushed = set(json.loads(marker.read_text())) if marker.exists() else set()
68
+ print(f"[push] {hf_dir} -> {args.repo} (private); already pushed: {sorted(pushed)}", flush=True)
69
+
70
+ while True:
71
+ for step in sorted(step_dirs(hf_dir)):
72
+ if step in pushed:
73
+ continue
74
+ if args.every > 1 and (step // 100) % args.every != 0 and step != args.until_step:
75
+ continue
76
+ d = step_dirs(hf_dir)[step]
77
+ if not args.once and not is_stable(d, args.stable_sec):
78
+ continue # still being written; try next poll
79
+ print(f"[push] uploading step-{step} ...", flush=True)
80
+ try:
81
+ api.upload_folder(folder_path=str(d), repo_id=args.repo, repo_type="model",
82
+ path_in_repo=f"step-{step}", commit_message=f"add step-{step}")
83
+ pushed.add(step)
84
+ marker.write_text(json.dumps(sorted(pushed)))
85
+ print(f"[push] DONE step-{step} -> {args.repo}/step-{step}", flush=True)
86
+ except Exception as e: # network / rate-limit / partial — retry next poll
87
+ print(f"[push] FAILED step-{step}: {type(e).__name__}: {e} (retry next poll)", flush=True)
88
+ if args.once:
89
+ print("[push] --once sweep complete; exiting.", flush=True)
90
+ return
91
+ if args.until_step and args.until_step in pushed:
92
+ print(f"[push] final step-{args.until_step} pushed; exiting.", flush=True)
93
+ return
94
+ time.sleep(args.poll)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
gpu-sft/scripts/gpu_sft/run_sft.sh ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Copyright The Marin Authors
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Self-healing launcher for train_sft_qwen3.py on ONE 8xH100 node.
6
+ #
7
+ # bash run_sft.sh <exp_name> [num_train_steps]
8
+ #
9
+ # Loops torchrun: on a transient crash (e.g. an NCCL watchdog collective timeout)
10
+ # it waits 30s and relaunches with --resume auto (continues from checkpoints-temp)
11
+ # and a FIXED --wandb-id (continues the SAME W&B run instead of forking a new one).
12
+ # Exits 0 when training reaches the step target. Bails out if it fast-fails
13
+ # repeatedly -- that means a real error (bad config / OOM / disk), not a blip.
14
+ #
15
+ # Data must already be prepared at $DATA_ROOT/<exp_name> by prepare_sft_data.py.
16
+ # For a FRESH run, delete $RUNS_ROOT/<exp_name> first (else --resume auto continues
17
+ # the old checkpoints). Launch under nohup so it survives your shell:
18
+ #
19
+ # nohup bash gpu-sft/scripts/gpu_sft/run_sft.sh science_depth4v3_nofilter \
20
+ # > /opt/tiger/lmc_muon/gpu-sft-runs/science_depth4v3_nofilter/wrapper.log 2>&1 &
21
+ #
22
+ # NOTE: no `set -e` on purpose -- the whole point is to survive torchrun failures.
23
+ set -uo pipefail
24
+
25
+ EXP="${1:?usage: run_sft.sh <exp_name> [num_train_steps]}"
26
+ NUM_STEPS="${2:-2000}"
27
+ NPROC="${NPROC:-8}"
28
+
29
+ DATA_ROOT="${DATA_ROOT:-/opt/tiger/lmc_muon/gpu-sft-data}"
30
+ RUNS_ROOT="${RUNS_ROOT:-/opt/tiger/lmc_muon/gpu-sft-runs}"
31
+ REPO="${REPO:-/opt/tiger/lmc_muon/self-verified-distillation}"
32
+ WANDB_PROJECT="${WANDB_PROJECT:-marin-instill-gpu}"
33
+ WANDB_ID="${WANDB_ID:-$EXP}" # override to FORCE a fresh W&B run id (avoids reattaching to a prior partial run of the same name)
34
+
35
+ # Durability: async HDFS mirror of hf/ exports + native checkpoints/. Local NVMe stays the
36
+ # training --out (fast, no FUSE I/O stall that could trip the NCCL watchdog); this only
37
+ # copies in the background so a machine kill can't erase progress. Set MIRROR=0 to disable.
38
+ MIRROR="${MIRROR:-1}"
39
+ HDFS_ROOT="${HDFS_ROOT:-/mnt/hdfs/fangzhao_writable/marin-sft}"
40
+ MIRROR_EVERY="${MIRROR_EVERY:-300}"
41
+
42
+ DATA="$DATA_ROOT/$EXP"
43
+ OUT="$RUNS_ROOT/$EXP"
44
+ LOG="$OUT/train.log"
45
+
46
+ MAX_RESTARTS="${MAX_RESTARTS:-30}" # absolute cap on relaunches
47
+ FAST_FAIL_SEC="${FAST_FAIL_SEC:-180}" # a run dying sooner than this = a hard fail
48
+ MAX_FAST_FAILS="${MAX_FAST_FAILS:-3}" # bail after this many CONSECUTIVE fast fails
49
+
50
+ export HF_HOME="${HF_HOME:-/opt/tiger/lmc_muon/hf_cache}"
51
+ export TOKENIZERS_PARALLELISM=false
52
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
53
+ export WANDB_MODE="${WANDB_MODE:-online}"
54
+ export NCCL_DEBUG="${NCCL_DEBUG:-WARN}"
55
+
56
+ if [[ ! -d "$DATA" ]]; then
57
+ echo "[run_sft] ERROR: prepared data not found at $DATA -- run prepare_sft_data.py first" >&2
58
+ exit 2
59
+ fi
60
+ mkdir -p "$OUT"
61
+ cd "$REPO" || { echo "[run_sft] ERROR: cannot cd $REPO" >&2; exit 2; }
62
+
63
+ echo "[run_sft] exp=$EXP steps=$NUM_STEPS nproc=$NPROC wandb-id=$WANDB_ID out=$OUT" | tee -a "$LOG"
64
+
65
+ # --- durability: background HDFS mirror (hf/ exports + native checkpoints/; skip the
66
+ # rolling checkpoints-temp/ which is overwritten and not rsync-snapshot-safe) ----------
67
+ DEST="$HDFS_ROOT/$EXP"
68
+ mirror_once() { rsync -a --exclude 'checkpoints-temp' "$OUT/" "$DEST/" 2>>"$OUT/mirror.log"; }
69
+ mirror_pid=""; push_pid=""
70
+ if [ "$MIRROR" = "1" ] && [ -d "$(dirname "$HDFS_ROOT")" ]; then
71
+ mkdir -p "$DEST"
72
+ echo "[run_sft] HDFS mirror ON: $OUT -> $DEST every ${MIRROR_EVERY}s" | tee -a "$LOG"
73
+ ( while true; do sleep "$MIRROR_EVERY"; mirror_once; done ) &
74
+ mirror_pid=$!
75
+ elif [ -n "${PUSH_HF_REPO:-}" ]; then
76
+ # No HDFS (e.g. A800 boxes): push each hf/step-N to a private HF repo instead.
77
+ echo "[run_sft] no HDFS -> HF push: hf/step-N -> $PUSH_HF_REPO" | tee -a "$LOG"
78
+ nohup python gpu-sft/scripts/gpu_sft/push_exports.py --out "$OUT" --repo "$PUSH_HF_REPO" >> "$OUT/push.log" 2>&1 &
79
+ push_pid=$!
80
+ else
81
+ echo "[run_sft] WARNING: NO durability -- no HDFS mount and no PUSH_HF_REPO; checkpoints on ephemeral disk only!" | tee -a "$LOG"
82
+ fi
83
+ trap '[ -n "${mirror_pid:-}" ] && kill "$mirror_pid" 2>/dev/null; [ -n "${push_pid:-}" ] && kill "$push_pid" 2>/dev/null' EXIT
84
+
85
+ fast_fails=0
86
+ for attempt in $(seq 1 "$MAX_RESTARTS"); do
87
+ echo "[run_sft] === attempt $attempt/$MAX_RESTARTS $(date -u) ===" | tee -a "$LOG"
88
+ start=$(date +%s)
89
+
90
+ torchrun --standalone --nproc_per_node="$NPROC" \
91
+ gpu-sft/scripts/gpu_sft/train_sft_qwen3.py \
92
+ --data "$DATA" --out "$OUT" --recipe qwen3-8b \
93
+ --train-batch-size 64 --per-device-batch 1 --loss-groups 2 \
94
+ --num-train-steps "$NUM_STEPS" \
95
+ --steps-per-hf-export 100 --steps-per-checkpoint 500 \
96
+ --loss-chunk-size 2048 --hf-export-dtype bfloat16 \
97
+ --init-mode all_ranks --resume auto \
98
+ --wandb-project "$WANDB_PROJECT" --wandb-name "$EXP" --wandb-id "$WANDB_ID" \
99
+ --log-every 1 >> "$LOG" 2>&1
100
+ rc=$?
101
+ dur=$(( $(date +%s) - start ))
102
+
103
+ if [[ $rc -eq 0 ]]; then
104
+ echo "[run_sft] SUCCESS: $EXP finished (rc=0) after $attempt attempt(s)" | tee -a "$LOG"
105
+ if [ -n "$mirror_pid" ]; then echo "[run_sft] final HDFS mirror..." | tee -a "$LOG"; mirror_once; fi
106
+ if [ -n "$push_pid" ]; then
107
+ echo "[run_sft] final HF push..." | tee -a "$LOG"
108
+ kill "$push_pid" 2>/dev/null
109
+ python gpu-sft/scripts/gpu_sft/push_exports.py --out "$OUT" --repo "$PUSH_HF_REPO" --once >> "$OUT/push.log" 2>&1
110
+ fi
111
+ exit 0
112
+ fi
113
+
114
+ echo "[run_sft] exit rc=$rc after ${dur}s" | tee -a "$LOG"
115
+ if [[ $dur -lt $FAST_FAIL_SEC ]]; then
116
+ fast_fails=$((fast_fails + 1))
117
+ echo "[run_sft] fast-fail $fast_fails/$MAX_FAST_FAILS (died <${FAST_FAIL_SEC}s -> likely a real error, not transient)" | tee -a "$LOG"
118
+ if [[ $fast_fails -ge $MAX_FAST_FAILS ]]; then
119
+ echo "[run_sft] ABORT: $MAX_FAST_FAILS consecutive fast fails -- inspect $LOG (config/OOM/disk, not a blip)" | tee -a "$LOG"
120
+ exit 1
121
+ fi
122
+ else
123
+ fast_fails=0 # ran a while then died -> treat as transient, reset the counter
124
+ fi
125
+
126
+ echo "[run_sft] relaunching in 30s (will --resume auto from checkpoints-temp)..." | tee -a "$LOG"
127
+ sleep 30
128
+ done
129
+
130
+ echo "[run_sft] ABORT: hit MAX_RESTARTS=$MAX_RESTARTS without finishing" | tee -a "$LOG"
131
+ exit 1
gpu-sft/scripts/gpu_sft/train_sft_qwen3.py ADDED
@@ -0,0 +1,820 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # ruff: noqa: E501
3
+ """Stage 2: single-node/multi-node NVIDIA GPU port of the Marin/Levanter Qwen3 SFT recipe.
4
+
5
+ PyTorch + HF transformers + torch FSDP. No JAX, no GCS, no Iris, no marin credentials.
6
+ Reads the packed dataset produced by prepare_sft_data.py, writes native (resumable)
7
+ checkpoints and HF-format exports to local disk on the same cadence as the TPU run, so
8
+ the existing evalchemy tooling can consume `<out>/hf/step-N` unchanged.
9
+
10
+ Launch (8 GPUs, one node):
11
+ torchrun --standalone --nproc_per_node=8 train_sft_qwen3.py --data /data/sft/foo --out /data/runs/foo
12
+
13
+ Plan only, no GPUs needed:
14
+ python train_sft_qwen3.py --data /data/sft/foo --out /tmp/x --dry-run
15
+
16
+ WHAT IS REPRODUCED EXACTLY
17
+ --------------------------
18
+ * optimiser chain order: global-norm clip -> Adam(b1,b2,eps) -> decoupled wd -> scale(-lr)
19
+ * weight-decay mask: no decay on *norm*, embeddings and biases; lm_head IS decayed
20
+ * LR: linear warmup 0->lr over int(warmup*T) steps, constant, then cosine to lr*min_lr_ratio
21
+ over int(decay*T) steps (clamped). LR at optimiser step 0 is exactly 0.0.
22
+ * mixed precision: fp32 master params / bf16 compute / fp32 gradient reduction
23
+ * loss: per-microbatch token-weighted mean over assistant tokens, then arithmetic mean
24
+ across the `--loss-groups` microbatches of an optimiser step (levanter grad_accum uses
25
+ ReductionType.MEAN over 2 microbatches of 32 on v5p-64). No z-loss.
26
+ * RoPE positions are CONTIGUOUS 0..L-1 across a packed example (levanter passes
27
+ `pos_ids = arange(Pos)`; it does NOT reset per document), while attention is still
28
+ blocked across documents with varlen cu_seq_lens. Most HF/TRL packing implementations
29
+ reset position_ids per document -- that is a real divergence and we do not do it.
30
+ * data order: sequential over packed examples with wraparound (shuffle is a silent no-op
31
+ in the TPU config), so `--data-seed` only matters if you pass --shuffle.
32
+ * checkpoint/export naming: levanter's StepInfo.step == completed_steps - 1, and hooks
33
+ fire when `step > 1 and step % every == 0`, plus a forced final hook. A 2000-step run
34
+ therefore writes hf/step-100 ... hf/step-1900 and a final hf/step-1999.
35
+
36
+ WHAT DIFFERS (and why) -- see also the handoff doc:
37
+ * per-device microbatch is 1 packed example (not 32-across-32-chips); grouping into
38
+ `--loss-groups` groups restores the exact loss normalisation, but the *gradient*
39
+ accumulation order differs, so results are not bitwise identical.
40
+ * attention kernel is FlashAttention-2 (varlen), not the TPU splash/Pallas kernel.
41
+ * cross-entropy is a chunked torch implementation, not levanter's fused Pallas CE.
42
+ * HF export defaults to bf16 here (the TPU export was fp32; vLLM casts to bf16 anyway).
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import contextlib
49
+ import json
50
+ import math
51
+ import os
52
+ import shutil
53
+ import time
54
+ from dataclasses import dataclass, replace
55
+
56
+ import numpy as np
57
+
58
+ MODEL_VOCAB_SIZE = 151936 # Qwen3 config.vocab_size for 1.7B / 4B / 8B / 32B
59
+
60
+
61
+ # ----------------------------------------------------------------------------------
62
+ # recipes (frozen per model size, copied from the marin experiment files)
63
+ # ----------------------------------------------------------------------------------
64
+ @dataclass
65
+ class Recipe:
66
+ model_id: str
67
+ learning_rate: float
68
+ weight_decay: float
69
+ warmup: float
70
+ decay: float
71
+ min_lr_ratio: float
72
+ max_grad_norm: float
73
+ beta1: float
74
+ beta2: float
75
+ eps: float
76
+ train_batch_size: int
77
+ num_train_steps: int
78
+ max_seq_len: int
79
+ steps_per_checkpoint: int
80
+ steps_per_hf_export: int
81
+ lr_schedule: str = "cosine"
82
+
83
+
84
+ RECIPES: dict[str, Recipe] = {
85
+ # experiments/exp_sft_qwen3_8b_selfinstill_*_2k_lr5e6_wd01.py
86
+ "qwen3-8b": Recipe(
87
+ model_id="Qwen/Qwen3-8B",
88
+ learning_rate=5e-6,
89
+ weight_decay=0.01,
90
+ warmup=0.05,
91
+ decay=0.9,
92
+ min_lr_ratio=0.1,
93
+ max_grad_norm=1.0,
94
+ beta1=0.9,
95
+ beta2=0.999,
96
+ eps=1e-8,
97
+ train_batch_size=64,
98
+ num_train_steps=2000,
99
+ max_seq_len=32768,
100
+ steps_per_checkpoint=20,
101
+ steps_per_hf_export=100,
102
+ ),
103
+ # experiments/exp_*_sft_qwen3_4b_* (OT4 recipe: higher LR, no wd, tight clip)
104
+ "qwen3-4b": Recipe(
105
+ model_id="Qwen/Qwen3-4B",
106
+ learning_rate=2e-5,
107
+ weight_decay=0.0,
108
+ warmup=0.03,
109
+ decay=0.9,
110
+ min_lr_ratio=0.1,
111
+ max_grad_norm=0.2,
112
+ beta1=0.9,
113
+ beta2=0.999,
114
+ eps=1e-8,
115
+ train_batch_size=128,
116
+ num_train_steps=0, # 0 => ceil(8 * n_packs / batch); override with --num-train-steps
117
+ max_seq_len=32768,
118
+ steps_per_checkpoint=20,
119
+ steps_per_hf_export=100,
120
+ ),
121
+ "qwen3-1.7b": Recipe(
122
+ model_id="Qwen/Qwen3-1.7B",
123
+ learning_rate=2e-5,
124
+ weight_decay=0.0,
125
+ warmup=0.03,
126
+ decay=0.9,
127
+ min_lr_ratio=0.1,
128
+ max_grad_norm=0.2,
129
+ beta1=0.9,
130
+ beta2=0.999,
131
+ eps=1e-8,
132
+ train_batch_size=128,
133
+ num_train_steps=0,
134
+ max_seq_len=32768,
135
+ steps_per_checkpoint=20,
136
+ steps_per_hf_export=100,
137
+ ),
138
+ }
139
+
140
+
141
+ # ----------------------------------------------------------------------------------
142
+ # LR schedule (port of levanter OptimizerConfig.lr_scheduler for a single cycle)
143
+ # ----------------------------------------------------------------------------------
144
+ def _frac_or_steps(v: float, total: int) -> int:
145
+ if v < 0.0 or (v > 1.0 and v % 1 != 0):
146
+ raise ValueError(f"Invalid fraction {v}")
147
+ return int(v * total) if v <= 1.0 else int(v)
148
+
149
+
150
+ def make_lr_multiplier(recipe: Recipe, num_train_steps: int):
151
+ warmup_steps = min(_frac_or_steps(recipe.warmup, num_train_steps), num_train_steps)
152
+ max_decay = max(num_train_steps - warmup_steps, 0)
153
+ decay_steps = min(max(_frac_or_steps(recipe.decay, num_train_steps), 0), max_decay)
154
+ stable_steps = num_train_steps - warmup_steps - decay_steps
155
+ alpha = recipe.min_lr_ratio
156
+ sched = recipe.lr_schedule
157
+
158
+ def fn(step: int) -> float:
159
+ if warmup_steps > 0 and step < warmup_steps:
160
+ return step / warmup_steps # optax.linear_schedule(0.0, lr, warmup_steps)
161
+ if step < warmup_steps + stable_steps:
162
+ return 1.0
163
+ if decay_steps == 0:
164
+ return 1.0
165
+ t = min(step - warmup_steps - stable_steps, decay_steps)
166
+ if sched == "cosine":
167
+ cos = 0.5 * (1.0 + math.cos(math.pi * t / decay_steps))
168
+ return (1.0 - alpha) * cos + alpha
169
+ if sched == "linear":
170
+ return 1.0 + (alpha - 1.0) * (t / decay_steps)
171
+ if sched == "constant":
172
+ return 1.0
173
+ raise ValueError(f"unsupported lr_schedule {sched}")
174
+
175
+ return fn, (warmup_steps, stable_steps, decay_steps)
176
+
177
+
178
+ # ----------------------------------------------------------------------------------
179
+ # weight decay mask (port of AdamConfig.build_weight_decay_mask reasonable_default)
180
+ # ----------------------------------------------------------------------------------
181
+ def is_no_decay(name: str) -> bool:
182
+ """levanter excludes LayerNorm/RMSNorm/RmsNorm/Embedding modules and *.bias.
183
+
184
+ lm_head is an ordinary Linear in levanter, so it IS decayed. With tied embeddings
185
+ (1.7B/4B) the single shared tensor is named model.embed_tokens.weight and is excluded,
186
+ which matches levanter (lm_head is None when tie_word_embeddings=True).
187
+ """
188
+ if name.endswith(".bias"):
189
+ return True
190
+ if "norm" in name.lower(): # input_layernorm, post_attention_layernorm, model.norm, q_norm, k_norm
191
+ return True
192
+ if name.endswith("embed_tokens.weight"):
193
+ return True
194
+ return False
195
+
196
+
197
+ # ----------------------------------------------------------------------------------
198
+ # packed dataset
199
+ # ----------------------------------------------------------------------------------
200
+ class PackedSFTData:
201
+ def __init__(self, path: str):
202
+ with open(os.path.join(path, "manifest.json")) as f:
203
+ self.manifest = json.load(f)
204
+ self.tokens = np.load(os.path.join(path, "tokens.npy"), mmap_mode="r")
205
+ self.mask = np.load(os.path.join(path, "assistant_mask.npy"), mmap_mode="r")
206
+ self.doc_lens = np.load(os.path.join(path, "doc_lens.npy"))
207
+ self.pack_offsets = np.load(os.path.join(path, "pack_offsets.npy"))
208
+ self.assistant_counts = np.load(os.path.join(path, "assistant_counts.npy"))
209
+ self.max_seq_len = int(self.manifest["max_seq_len"])
210
+ self.n_packs = int(self.tokens.shape[0])
211
+ assert self.tokens.shape[1] == self.max_seq_len
212
+
213
+ def doc_lengths(self, i: int) -> list[int]:
214
+ s, e = int(self.pack_offsets[i]), int(self.pack_offsets[i + 1])
215
+ return [int(x) for x in self.doc_lens[s:e]]
216
+
217
+
218
+ def collate(data: PackedSFTData, indices, device):
219
+ """Flatten `indices` packed examples into one padding-free row of B*L tokens.
220
+
221
+ Returns tensors shaped [1, B*L] plus varlen metadata. batch dim is always 1 so the
222
+ HF flash-attention path uses flash_attn_varlen_func with our explicit cu_seq_lens.
223
+ """
224
+ import torch
225
+
226
+ L = data.max_seq_len
227
+ ids = np.concatenate([np.asarray(data.tokens[i], dtype=np.int64) for i in indices])
228
+ msk = np.concatenate([np.asarray(data.mask[i], dtype=np.int64) for i in indices])
229
+ labels = np.where(msk == 1, ids, -100)
230
+ for b in range(len(indices)):
231
+ labels[b * L] = -100 # levanter's not_last_mask: position L-1 of the PREVIOUS example
232
+ targets = np.empty_like(labels)
233
+ targets[:-1] = labels[1:]
234
+ targets[-1] = -100
235
+
236
+ seg_lens: list[int] = []
237
+ for i in indices:
238
+ dl = data.doc_lengths(i)
239
+ seg_lens.extend(dl)
240
+ pad = L - int(sum(dl))
241
+ if pad > 0:
242
+ seg_lens.append(pad) # padding is its own attention segment
243
+ cu = np.concatenate([[0], np.cumsum(np.asarray(seg_lens, dtype=np.int64))]).astype(np.int32)
244
+
245
+ pos = np.tile(np.arange(L, dtype=np.int64), len(indices)) # contiguous per example, like levanter
246
+ n_targets = int((targets != -100).sum())
247
+ return {
248
+ "input_ids": torch.from_numpy(ids).unsqueeze(0).to(device, non_blocking=True),
249
+ "position_ids": torch.from_numpy(pos).unsqueeze(0).to(device, non_blocking=True),
250
+ "targets": torch.from_numpy(targets).unsqueeze(0).to(device, non_blocking=True),
251
+ "cu_seq_lens": torch.from_numpy(cu).to(device, non_blocking=True),
252
+ "max_length": int(max(seg_lens)),
253
+ }, n_targets
254
+
255
+
256
+ # ----------------------------------------------------------------------------------
257
+ # model wrapper with chunked (memory-bounded) cross entropy
258
+ # ----------------------------------------------------------------------------------
259
+ def _build_sft_module():
260
+ import torch
261
+ import torch.nn as nn
262
+ import torch.nn.functional as F
263
+ from torch.utils.checkpoint import checkpoint
264
+
265
+ def _ce_chunk(hidden_chunk, target_chunk, lm_head):
266
+ logits = lm_head(hidden_chunk).float()
267
+ return F.cross_entropy(logits, target_chunk, ignore_index=-100, reduction="sum")
268
+
269
+ class SFTModule(nn.Module):
270
+ """hf_model + a chunked lm_head/CE that never materialises [L, 151936] logits."""
271
+
272
+ def __init__(self, hf_model, loss_chunk_size: int, checkpoint_loss: bool = True):
273
+ super().__init__()
274
+ self.hf_model = hf_model
275
+ self.loss_chunk_size = loss_chunk_size
276
+ self.checkpoint_loss = checkpoint_loss
277
+
278
+ def forward(self, input_ids, position_ids, targets, cu_seq_lens, max_length, return_hidden: bool = False):
279
+ out = self.hf_model.model(
280
+ input_ids=input_ids,
281
+ position_ids=position_ids,
282
+ use_cache=False,
283
+ cu_seq_lens_q=cu_seq_lens,
284
+ cu_seq_lens_k=cu_seq_lens,
285
+ max_length_q=max_length,
286
+ max_length_k=max_length,
287
+ )
288
+ hidden = out.last_hidden_state[0]
289
+ if return_hidden:
290
+ return hidden
291
+ tgt = targets[0]
292
+ total = torch.zeros((), device=hidden.device, dtype=torch.float32)
293
+ n = hidden.shape[0]
294
+ cs = self.loss_chunk_size or n
295
+ for s in range(0, n, cs):
296
+ e = min(s + cs, n)
297
+ if self.checkpoint_loss and self.training:
298
+ part = checkpoint(_ce_chunk, hidden[s:e], tgt[s:e], self.hf_model.lm_head, use_reentrant=False)
299
+ else:
300
+ part = _ce_chunk(hidden[s:e], tgt[s:e], self.hf_model.lm_head)
301
+ total = total + part
302
+ return total
303
+
304
+ return SFTModule
305
+
306
+
307
+ # ----------------------------------------------------------------------------------
308
+ # distributed helpers
309
+ # ----------------------------------------------------------------------------------
310
+ def dist_info():
311
+ return (
312
+ int(os.environ.get("RANK", 0)),
313
+ int(os.environ.get("LOCAL_RANK", 0)),
314
+ int(os.environ.get("WORLD_SIZE", 1)),
315
+ )
316
+
317
+
318
+ def log0(rank: int, msg: str) -> None:
319
+ if rank == 0:
320
+ print(msg, flush=True)
321
+
322
+
323
+ def fmt_hms(seconds: float) -> str:
324
+ seconds = max(int(seconds), 0)
325
+ h, rem = divmod(seconds, 3600)
326
+ m, s = divmod(rem, 60)
327
+ return f"{h}:{m:02d}:{s:02d}"
328
+
329
+
330
+ def detect_grad_reduce_factor(device, world_size: int, rank: int) -> float:
331
+ """FSDP averages gradients over the DP group; loss scales must undo that.
332
+
333
+ Empirically determined with a 1-layer probe so a torch behaviour change cannot
334
+ silently rescale the effective learning rate by world_size.
335
+ """
336
+ import torch
337
+ import torch.nn as nn
338
+ from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
339
+ from torch.distributed.fsdp import ShardingStrategy
340
+
341
+ if world_size == 1:
342
+ return 1.0
343
+ lin = nn.Linear(64, 64, bias=False)
344
+ with torch.no_grad():
345
+ lin.weight.zero_()
346
+ probe = FSDP(
347
+ lin,
348
+ sharding_strategy=ShardingStrategy.FULL_SHARD,
349
+ device_id=device,
350
+ use_orig_params=True,
351
+ )
352
+ x = torch.ones(1, 64, device=device)
353
+ probe(x).sum().backward()
354
+ with FSDP.summon_full_params(probe, with_grads=True, writeback=False):
355
+ observed = float(lin.weight.grad.detach().float().mean().item())
356
+ del probe, lin
357
+ if abs(observed - 1.0) < 1e-3:
358
+ log0(rank, "[init] FSDP averages gradients across ranks -> loss scale multiplied by world_size")
359
+ return float(world_size)
360
+ if abs(observed - world_size) < 1e-3:
361
+ log0(rank, "[init] FSDP sums gradients across ranks -> no loss rescale")
362
+ return 1.0
363
+ raise RuntimeError(f"Unexpected FSDP gradient reduction: mean grad {observed}, world_size {world_size}")
364
+
365
+
366
+ def verify_document_isolation(model, device, rank: int) -> None:
367
+ """Prove cross-document attention is actually blocked before burning GPU-days.
368
+
369
+ If transformers stops forwarding cu_seq_lens_* to the attention kernel this test
370
+ fails loudly instead of silently training with cross-document leakage.
371
+ """
372
+ import torch
373
+
374
+ L, d0 = 64, 32
375
+ ids = torch.randint(0, 1000, (1, L), device=device)
376
+ pos = torch.arange(L, device=device).unsqueeze(0)
377
+ cu = torch.tensor([0, d0, L], dtype=torch.int32, device=device)
378
+ tgt = torch.full((1, L), -100, dtype=torch.long, device=device)
379
+ was_training = model.training
380
+ model.eval()
381
+ with torch.no_grad():
382
+ h1 = model(ids, pos, tgt, cu, d0, return_hidden=True)[:d0].clone()
383
+ ids2 = ids.clone()
384
+ ids2[0, d0:] = torch.randint(0, 1000, (L - d0,), device=device)
385
+ h2 = model(ids2, pos, tgt, cu, d0, return_hidden=True)[:d0].clone()
386
+ if was_training:
387
+ model.train()
388
+ diff = (h1 - h2).abs().max().item()
389
+ if diff != 0.0:
390
+ raise RuntimeError(
391
+ "Cross-document attention is NOT blocked (max hidden-state delta "
392
+ f"{diff:.3e} after perturbing the second document). The varlen cu_seq_lens "
393
+ "kwargs are not reaching flash-attention. Check the transformers version "
394
+ "and that attn_implementation='flash_attention_2'."
395
+ )
396
+ log0(rank, "[init] cross-document attention isolation verified (delta == 0)")
397
+
398
+
399
+ # ----------------------------------------------------------------------------------
400
+ def build_model(args, recipe: Recipe, rank: int, local_rank: int, world_size: int):
401
+ import torch
402
+ from torch.distributed.fsdp import BackwardPrefetch, MixedPrecision, ShardingStrategy
403
+ from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
404
+ from torch.distributed.fsdp.wrap import ModuleWrapPolicy
405
+ from transformers import AutoConfig, AutoModelForCausalLM
406
+
407
+ config = AutoConfig.from_pretrained(recipe.model_id)
408
+ config.use_cache = False
409
+ if config.vocab_size != MODEL_VOCAB_SIZE:
410
+ log0(rank, f"[init] WARNING model vocab_size={config.vocab_size}, expected {MODEL_VOCAB_SIZE}")
411
+
412
+ if args.init_mode == "rank0_meta" and world_size > 1:
413
+ if rank == 0:
414
+ hf_model = AutoModelForCausalLM.from_pretrained(
415
+ recipe.model_id, dtype=torch.float32, attn_implementation=args.attn_impl, low_cpu_mem_usage=True
416
+ )
417
+ else:
418
+ with torch.device("meta"):
419
+ hf_model = AutoModelForCausalLM.from_config(config, attn_implementation=args.attn_impl)
420
+ else:
421
+ hf_model = AutoModelForCausalLM.from_pretrained(
422
+ recipe.model_id, dtype=torch.float32, attn_implementation=args.attn_impl, low_cpu_mem_usage=True
423
+ )
424
+ hf_model.config.use_cache = False
425
+ hf_model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
426
+
427
+ sft = _build_sft_module()(hf_model, args.loss_chunk_size, checkpoint_loss=not args.no_loss_checkpoint)
428
+
429
+ layer_cls = type(hf_model.model.layers[0])
430
+ wrap_classes = {layer_cls}
431
+ if args.wrap_embeddings:
432
+ wrap_classes.add(torch.nn.Embedding)
433
+
434
+ mp = MixedPrecision(
435
+ param_dtype=torch.bfloat16,
436
+ reduce_dtype=torch.float32 if args.grad_reduce_dtype == "float32" else torch.bfloat16,
437
+ buffer_dtype=torch.float32,
438
+ )
439
+
440
+ def param_init_fn(module):
441
+ module.to_empty(device=torch.device("cuda", local_rank), recurse=False)
442
+
443
+ model = FSDP(
444
+ sft,
445
+ auto_wrap_policy=ModuleWrapPolicy(wrap_classes),
446
+ mixed_precision=mp,
447
+ sharding_strategy=ShardingStrategy.FULL_SHARD,
448
+ backward_prefetch=BackwardPrefetch.BACKWARD_PRE,
449
+ device_id=torch.device("cuda", local_rank),
450
+ use_orig_params=True,
451
+ limit_all_gathers=True,
452
+ sync_module_states=(args.init_mode == "rank0_meta" and world_size > 1),
453
+ param_init_fn=param_init_fn if (args.init_mode == "rank0_meta" and world_size > 1 and rank != 0) else None,
454
+ )
455
+ return model, hf_model
456
+
457
+
458
+ def build_optimizer(model, recipe: Recipe, args):
459
+ import torch
460
+
461
+ decay, no_decay = [], []
462
+ dn, nn_ = [], []
463
+ for name, p in model.named_parameters():
464
+ if not p.requires_grad:
465
+ continue
466
+ clean = name.replace("_fsdp_wrapped_module.", "").replace("hf_model.", "")
467
+ (no_decay if is_no_decay(clean) else decay).append(p)
468
+ (nn_ if is_no_decay(clean) else dn).append(clean)
469
+ groups = [
470
+ {"params": decay, "weight_decay": recipe.weight_decay},
471
+ {"params": no_decay, "weight_decay": 0.0},
472
+ ]
473
+ opt = torch.optim.AdamW(
474
+ groups,
475
+ lr=recipe.learning_rate,
476
+ betas=(recipe.beta1, recipe.beta2),
477
+ eps=recipe.eps,
478
+ fused=args.fused_adam,
479
+ foreach=not args.fused_adam,
480
+ )
481
+ return opt, dn, nn_
482
+
483
+
484
+ # ----------------------------------------------------------------------------------
485
+ # checkpoint / export
486
+ # ----------------------------------------------------------------------------------
487
+ def save_native(model, optimizer, step: int, path: str, extra: dict) -> None:
488
+ import torch.distributed as dist
489
+ import torch.distributed.checkpoint as dcp
490
+ from torch.distributed.checkpoint.state_dict import get_state_dict
491
+
492
+ msd, osd = get_state_dict(model, optimizer)
493
+ tmp = path + ".partial"
494
+ dcp.save({"model": msd, "optim": osd}, checkpoint_id=tmp)
495
+ dist.barrier()
496
+ if dist.get_rank() == 0:
497
+ with open(os.path.join(tmp, "meta.json"), "w") as f:
498
+ json.dump({"step": step, **extra}, f)
499
+ if os.path.exists(path):
500
+ shutil.rmtree(path)
501
+ os.replace(tmp, path)
502
+ dist.barrier()
503
+
504
+
505
+ def load_native(model, optimizer, path: str) -> int:
506
+ import torch.distributed.checkpoint as dcp
507
+ from torch.distributed.checkpoint.state_dict import get_state_dict, set_state_dict
508
+
509
+ msd, osd = get_state_dict(model, optimizer)
510
+ state = {"model": msd, "optim": osd}
511
+ dcp.load(state, checkpoint_id=path)
512
+ set_state_dict(model, optimizer, model_state_dict=state["model"], optim_state_dict=state["optim"])
513
+ with open(os.path.join(path, "meta.json")) as f:
514
+ return int(json.load(f)["step"])
515
+
516
+
517
+ def export_hf(model, hf_model, tokenizer, out_dir: str, label: int, dtype_name: str, keep_gen_cfg: bool) -> None:
518
+ import torch
519
+ import torch.distributed as dist
520
+ from torch.distributed.fsdp import FullStateDictConfig, StateDictType
521
+ from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
522
+
523
+ cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
524
+ with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, cfg):
525
+ sd = model.state_dict()
526
+ if dist.get_rank() == 0:
527
+ dtype = {"bfloat16": torch.bfloat16, "float32": torch.float32, "float16": torch.float16}[dtype_name]
528
+ clean = {}
529
+ for k, v in sd.items():
530
+ k = k[len("hf_model.") :] if k.startswith("hf_model.") else k
531
+ clean[k] = v.to(dtype)
532
+ target = os.path.join(out_dir, "hf", f"step-{label}")
533
+ tmp = target + ".partial"
534
+ if os.path.exists(tmp):
535
+ shutil.rmtree(tmp)
536
+ os.makedirs(tmp, exist_ok=True)
537
+ hf_model.save_pretrained(tmp, state_dict=clean, safe_serialization=True, max_shard_size="5GB")
538
+ tokenizer.save_pretrained(tmp)
539
+ cfg_path = os.path.join(tmp, "config.json")
540
+ with open(cfg_path) as f:
541
+ cfg_json = json.load(f)
542
+ cfg_json["torch_dtype"] = dtype_name # vLLM reads this; keep it honest
543
+ cfg_json["dtype"] = dtype_name
544
+ with open(cfg_path, "w") as f:
545
+ json.dump(cfg_json, f, indent=2)
546
+ gen_cfg = os.path.join(tmp, "generation_config.json")
547
+ if not keep_gen_cfg and os.path.exists(gen_cfg):
548
+ os.remove(gen_cfg) # the levanter export writes no generation_config.json
549
+ if os.path.exists(target):
550
+ shutil.rmtree(target)
551
+ os.replace(tmp, target)
552
+ print(f"[export] wrote {target}", flush=True)
553
+ del sd
554
+ dist.barrier()
555
+
556
+
557
+ def export_tokenizer(recipe: Recipe):
558
+ """267 <|padding_i|> tokens so len(tokenizer) == model vocab, exactly like
559
+ HFCheckpointConverter.with_tokenizer_padded_to_match_model()."""
560
+ from transformers import AutoTokenizer
561
+
562
+ tok = AutoTokenizer.from_pretrained(recipe.model_id)
563
+ missing = MODEL_VOCAB_SIZE - len(tok)
564
+ if missing > 0:
565
+ tok.add_tokens([f"<|padding_{i}|>" for i in range(missing)])
566
+ return tok, missing
567
+
568
+
569
+ # ----------------------------------------------------------------------------------
570
+ def dry_run(args, recipe: Recipe, data: PackedSFTData, num_train_steps: int) -> None:
571
+ lr_fn, (w, s, d) = make_lr_multiplier(recipe, num_train_steps)
572
+ L = recipe.max_seq_len
573
+ tokens_per_step = recipe.train_batch_size * L
574
+ print("=" * 78)
575
+ print(f"model {recipe.model_id}")
576
+ print(f"packed examples {data.n_packs} (seq len {L})")
577
+ print(f"global batch {recipe.train_batch_size} examples = {tokens_per_step:,} tokens/step")
578
+ print(f"steps {num_train_steps} => {tokens_per_step * num_train_steps:,} tokens presented")
579
+ print(f"epochs over the data {num_train_steps * recipe.train_batch_size / data.n_packs:.2f}")
580
+ print(f"lr schedule warmup {w} / stable {s} / cosine {d} to {recipe.learning_rate * recipe.min_lr_ratio:.3e}")
581
+ for st in [0, 1, w - 1, w, w + s - 1, w + s, num_train_steps // 2, num_train_steps - 1]:
582
+ if 0 <= st < num_train_steps:
583
+ print(f" step {st:>5} lr = {recipe.learning_rate * lr_fn(st):.6e}")
584
+ print(f"weight decay {recipe.weight_decay} (excluded: *norm*, embed_tokens, *.bias; lm_head decayed)")
585
+ print(f"grad clip / betas {recipe.max_grad_norm} / ({recipe.beta1}, {recipe.beta2}) eps {recipe.eps}")
586
+ print(f"loss groups per step {args.loss_groups} (TPU used 2 microbatches of 32)")
587
+ print(f"assistant tokens {int(data.assistant_counts.sum()):,} over the dataset")
588
+ print(f"exports hf/step-N every {recipe.steps_per_hf_export}, native every {recipe.steps_per_checkpoint}")
589
+ print("=" * 78)
590
+
591
+
592
+ # ----------------------------------------------------------------------------------
593
+ def main() -> None:
594
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
595
+ p.add_argument("--data", required=True, help="output dir of prepare_sft_data.py")
596
+ p.add_argument("--out", required=True, help="run dir; checkpoints/, checkpoints-temp/, hf/ live here")
597
+ p.add_argument("--recipe", default="qwen3-8b", choices=sorted(RECIPES))
598
+ p.add_argument("--model-id", default=None, help="override the recipe's HF model id")
599
+ p.add_argument("--num-train-steps", type=int, default=0)
600
+ p.add_argument("--train-batch-size", type=int, default=0)
601
+ p.add_argument("--per-device-batch", type=int, default=1, help="packed examples per GPU per micro-step")
602
+ p.add_argument("--loss-groups", type=int, default=2, help="microbatches per optimiser step; TPU used 2")
603
+ p.add_argument("--steps-per-checkpoint", type=int, default=0)
604
+ p.add_argument("--steps-per-hf-export", type=int, default=0)
605
+ p.add_argument("--temp-checkpoint-minutes", type=float, default=10.0)
606
+ p.add_argument("--loss-chunk-size", type=int, default=2048)
607
+ p.add_argument("--no-loss-checkpoint", action="store_true")
608
+ p.add_argument("--attn-impl", default="flash_attention_2")
609
+ p.add_argument("--grad-reduce-dtype", default="float32", choices=("float32", "bfloat16"))
610
+ p.add_argument("--init-mode", default="rank0_meta", choices=("rank0_meta", "all_ranks"))
611
+ p.add_argument("--wrap-embeddings", action="store_true")
612
+ p.add_argument("--fused-adam", action="store_true")
613
+ p.add_argument("--hf-export-dtype", default="bfloat16", choices=("bfloat16", "float32", "float16"))
614
+ p.add_argument("--keep-generation-config", action="store_true")
615
+ p.add_argument("--trainer-seed", type=int, default=0, help="levanter TrainerConfig.seed (stays 0 on TPU)")
616
+ p.add_argument("--data-seed", type=int, default=42, help="only used when --shuffle is set")
617
+ p.add_argument("--shuffle", action="store_true", help="NOT what the TPU run did; shuffle is a no-op there")
618
+ p.add_argument("--resume", default="auto", help="auto | none | <path>")
619
+ p.add_argument("--log-every", type=int, default=1)
620
+ p.add_argument("--skip-checks", action="store_true")
621
+ p.add_argument("--wandb-project", default=None)
622
+ p.add_argument("--wandb-name", default=None)
623
+ p.add_argument("--wandb-id", default=None,
624
+ help="fixed W&B run id; reused across restarts (with resume=allow) so an "
625
+ "auto-resumed run continues ONE W&B run instead of forking a new one")
626
+ p.add_argument("--dry-run", action="store_true")
627
+ args = p.parse_args()
628
+
629
+ recipe = RECIPES[args.recipe]
630
+ if args.model_id:
631
+ recipe = replace(recipe, model_id=args.model_id)
632
+ if args.train_batch_size:
633
+ recipe = replace(recipe, train_batch_size=args.train_batch_size)
634
+ if args.steps_per_checkpoint:
635
+ recipe = replace(recipe, steps_per_checkpoint=args.steps_per_checkpoint)
636
+ if args.steps_per_hf_export:
637
+ recipe = replace(recipe, steps_per_hf_export=args.steps_per_hf_export)
638
+
639
+ data = PackedSFTData(args.data)
640
+ recipe = replace(recipe, max_seq_len=data.max_seq_len)
641
+ num_train_steps = args.num_train_steps or recipe.num_train_steps
642
+ if not num_train_steps:
643
+ num_train_steps = math.ceil(8 * data.n_packs / recipe.train_batch_size)
644
+
645
+ if args.dry_run:
646
+ dry_run(args, recipe, data, num_train_steps)
647
+ return
648
+
649
+ import torch
650
+ import torch.distributed as dist
651
+
652
+ rank, local_rank, world_size = dist_info()
653
+ torch.cuda.set_device(local_rank)
654
+ dist.init_process_group("nccl")
655
+ device = torch.device("cuda", local_rank)
656
+ torch.manual_seed(args.trainer_seed)
657
+ np.random.seed(args.trainer_seed)
658
+
659
+ B = recipe.train_batch_size
660
+ G = args.loss_groups
661
+ per_group = B // G
662
+ micro_per_group = per_group // (world_size * args.per_device_batch)
663
+ if B % G or per_group % (world_size * args.per_device_batch):
664
+ raise ValueError(
665
+ f"train_batch_size={B} must divide by loss_groups={G} and then by "
666
+ f"world_size*per_device_batch={world_size * args.per_device_batch}"
667
+ )
668
+
669
+ if recipe.steps_per_hf_export <= 0 or recipe.steps_per_checkpoint <= 0:
670
+ raise ValueError("steps_per_hf_export and steps_per_checkpoint must be > 0")
671
+ log0(rank, f"[init] world_size={world_size} batch={B} groups={G} micro/group={micro_per_group} seq={recipe.max_seq_len}")
672
+ grad_factor = 1.0 if args.skip_checks else detect_grad_reduce_factor(device, world_size, rank)
673
+
674
+ model, hf_model = build_model(args, recipe, rank, local_rank, world_size)
675
+ if not args.skip_checks:
676
+ verify_document_isolation(model, device, rank)
677
+ optimizer, decayed, undecayed = build_optimizer(model, recipe, args)
678
+ log0(rank, f"[init] weight decay applied to {len(decayed)} tensors, excluded {len(undecayed)}")
679
+ log0(rank, f"[init] excluded sample: {undecayed[:4]} ... lm_head decayed: {any(n.endswith('lm_head.weight') for n in decayed)}")
680
+
681
+ lr_fn, (w_steps, s_steps, d_steps) = make_lr_multiplier(recipe, num_train_steps)
682
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_fn)
683
+ tokenizer, n_pad = export_tokenizer(recipe)
684
+ log0(rank, f"[init] export tokenizer padded with {n_pad} tokens -> len={len(tokenizer)}")
685
+
686
+ ckpt_dir = os.path.join(args.out, "checkpoints")
687
+ temp_dir = os.path.join(args.out, "checkpoints-temp")
688
+ if rank == 0:
689
+ os.makedirs(ckpt_dir, exist_ok=True)
690
+ os.makedirs(os.path.join(args.out, "hf"), exist_ok=True)
691
+ dist.barrier()
692
+
693
+ start_step = 0
694
+ resume_path = None
695
+ if args.resume == "auto":
696
+ cands = []
697
+ if os.path.exists(os.path.join(temp_dir, "meta.json")):
698
+ cands.append(temp_dir)
699
+ if os.path.isdir(ckpt_dir):
700
+ cands += [os.path.join(ckpt_dir, d) for d in os.listdir(ckpt_dir) if d.startswith("step-")]
701
+ cands = [c for c in cands if os.path.exists(os.path.join(c, "meta.json"))]
702
+ if cands:
703
+ resume_path = max(cands, key=lambda c: json.load(open(os.path.join(c, "meta.json")))["step"])
704
+ elif args.resume != "none":
705
+ resume_path = args.resume
706
+ if resume_path:
707
+ start_step = load_native(model, optimizer, resume_path)
708
+ for _ in range(start_step):
709
+ scheduler.step()
710
+ log0(rank, f"[init] resumed from {resume_path} at step {start_step}")
711
+
712
+ order = np.arange(data.n_packs)
713
+ if args.shuffle:
714
+ np.random.default_rng(args.data_seed).shuffle(order)
715
+
716
+ run = None
717
+ if args.wandb_project and rank == 0:
718
+ import wandb
719
+
720
+ run = wandb.init(
721
+ project=args.wandb_project,
722
+ name=args.wandb_name or os.path.basename(args.out),
723
+ id=args.wandb_id,
724
+ resume="allow" if args.wandb_id else None,
725
+ )
726
+ run.config.update({**vars(args), **recipe.__dict__, "num_train_steps": num_train_steps},
727
+ allow_val_change=True)
728
+
729
+ model.train()
730
+ t_start = time.time()
731
+ last_temp = time.time()
732
+ log0(rank, "First batch loaded, starting first train step (includes CUDA graph/kernel warmup)...")
733
+
734
+ for step in range(start_step, num_train_steps):
735
+ t_step = time.time()
736
+ base = step * B
737
+ batch_idx = [int(order[(base + i) % data.n_packs]) for i in range(B)]
738
+ optimizer.zero_grad(set_to_none=True)
739
+
740
+ group_loss_sums = torch.zeros(G, device=device, dtype=torch.float32)
741
+ group_tokens = torch.zeros(G, device=device, dtype=torch.float32)
742
+ for g in range(G):
743
+ gidx = batch_idx[g * per_group : (g + 1) * per_group]
744
+ denom = float(sum(int(data.assistant_counts[i]) for i in gidx))
745
+ if denom <= 0:
746
+ raise RuntimeError(f"group {g} of step {step} has zero assistant tokens")
747
+ scale = grad_factor / (G * denom)
748
+ for m in range(micro_per_group):
749
+ off = (m * world_size + rank) * args.per_device_batch
750
+ mine = gidx[off : off + args.per_device_batch]
751
+ batch, n_tok = collate(data, mine, device)
752
+ loss_sum = model(
753
+ batch["input_ids"],
754
+ batch["position_ids"],
755
+ batch["targets"],
756
+ batch["cu_seq_lens"],
757
+ batch["max_length"],
758
+ )
759
+ (loss_sum * scale).backward()
760
+ group_loss_sums[g] += loss_sum.detach()
761
+ group_tokens[g] += n_tok
762
+
763
+ dist.all_reduce(group_loss_sums)
764
+ dist.all_reduce(group_tokens)
765
+ if step == start_step:
766
+ expect = [float(sum(int(data.assistant_counts[i]) for i in batch_idx[g * per_group : (g + 1) * per_group])) for g in range(G)]
767
+ got = [float(x) for x in group_tokens.tolist()]
768
+ if any(abs(a - b) > 0.5 for a, b in zip(expect, got)):
769
+ raise RuntimeError(f"loss denominator mismatch: manifest={expect} observed={got}")
770
+ log0(rank, f"[init] loss denominators verified: {got}")
771
+ loss_value = float((group_loss_sums / group_tokens.clamp(min=1)).mean().item())
772
+
773
+ gnorm = model.clip_grad_norm_(recipe.max_grad_norm)
774
+ optimizer.step()
775
+ scheduler.step()
776
+
777
+ completed = step + 1
778
+ label = completed - 1 # levanter StepInfo.step
779
+ dt = time.time() - t_step
780
+ if rank == 0 and (completed % args.log_every == 0 or completed == num_train_steps):
781
+ done = completed - start_step
782
+ total = num_train_steps - start_step
783
+ rate = (time.time() - t_start) / max(done, 1)
784
+ print(
785
+ f"Progress on:train {completed}it/{num_train_steps / 1000:.2f}kit "
786
+ f"rate:{rate:.1f}s/it remaining:{fmt_hms(rate * (total - done))} "
787
+ f"elapsed:{fmt_hms(time.time() - t_start)} postfix:loss={loss_value:.3f}",
788
+ flush=True,
789
+ )
790
+ if run is not None:
791
+ run.log(
792
+ {
793
+ "train/loss": loss_value,
794
+ "train/lr": scheduler.get_last_lr()[0],
795
+ "train/grad_norm": float(gnorm),
796
+ "train/tokens": int(group_tokens.sum().item()),
797
+ "train/step_time": dt,
798
+ },
799
+ step=completed,
800
+ )
801
+
802
+ is_final = completed == num_train_steps
803
+ if (label > 1 and label % int(recipe.steps_per_hf_export) == 0) or is_final:
804
+ export_hf(model, hf_model, tokenizer, args.out, label, args.hf_export_dtype, args.keep_generation_config)
805
+ if (label > 1 and label % int(recipe.steps_per_checkpoint) == 0) or is_final:
806
+ save_native(model, optimizer, completed, os.path.join(ckpt_dir, f"step-{label}"), {"label": label})
807
+ elif (time.time() - last_temp) > args.temp_checkpoint_minutes * 60:
808
+ save_native(model, optimizer, completed, temp_dir, {"label": label})
809
+ last_temp = time.time()
810
+
811
+ log0(rank, f"[done] {num_train_steps} steps in {fmt_hms(time.time() - t_start)}")
812
+ if run is not None:
813
+ run.finish()
814
+ dist.barrier()
815
+ dist.destroy_process_group()
816
+
817
+
818
+ if __name__ == "__main__":
819
+ with contextlib.suppress(KeyboardInterrupt):
820
+ main()
scripts/combine_and_publish.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wait for all workers to upload, gather, combine, and publish the final dataset.
2
+
3
+ Aggregates stats with the FULL launcher.py schema (`summary`, `checks`,
4
+ `failure_breakdown`, `failure_breakdown_examples`, `num_shards`) so the published
5
+ `stats.json` matches the original `sdg/launcher.py` output structure.
6
+
7
+ Env vars:
8
+ HF_USER — HuggingFace username (default: fzzhang)
9
+ RUN_NAME — experiment name (default: math53K run name)
10
+ NUM_WORKERS — total worker count (default: 4)
11
+ NUM_SHARDS — total shards (default: 32)
12
+ """
13
+ import json
14
+ import os
15
+ import tempfile
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from huggingface_hub import HfApi, snapshot_download
20
+
21
+ HF_USER = os.environ.get("HF_USER", "fzzhang")
22
+ RUN_NAME = os.environ.get(
23
+ "RUN_NAME", "qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1"
24
+ )
25
+ NUM_WORKERS = int(os.environ.get("NUM_WORKERS", "4"))
26
+ NUM_SHARDS = int(os.environ.get("NUM_SHARDS", "32"))
27
+ STAGING_REPO = f"{HF_USER}/{RUN_NAME}-staging"
28
+ FINAL_REPO = f"{HF_USER}/{RUN_NAME}"
29
+
30
+ api = HfApi()
31
+
32
+ print(f"waiting for {NUM_WORKERS} workers to upload...", flush=True)
33
+ while True:
34
+ try:
35
+ files = api.list_repo_files(STAGING_REPO, repo_type="dataset")
36
+ except Exception as e:
37
+ print(f" list error (staging repo may not exist yet): {e}", flush=True)
38
+ files = []
39
+ done = [
40
+ f for f in files
41
+ if f.startswith("workers/worker_") and f.endswith(".done")
42
+ ]
43
+ print(
44
+ f" [{time.strftime('%H:%M:%S')}] {len(done)}/{NUM_WORKERS} workers done",
45
+ flush=True,
46
+ )
47
+ if len(done) >= NUM_WORKERS:
48
+ break
49
+ time.sleep(120)
50
+
51
+ print("downloading staging repo...", flush=True)
52
+ local = snapshot_download(
53
+ repo_id=STAGING_REPO, repo_type="dataset", local_dir="./gathered"
54
+ )
55
+ shards_dir = Path(local) / "shards"
56
+ present = sorted(shards_dir.glob("shard_*"))
57
+ print(f" {len(present)} shard dirs present", flush=True)
58
+
59
+ # Concatenate output.jsonl files in shard order
60
+ combined = Path("combined_output.jsonl")
61
+ total = 0
62
+ with open(combined, "w") as out:
63
+ for sid in range(NUM_SHARDS):
64
+ f = shards_dir / f"shard_{sid:03d}" / "output.jsonl"
65
+ if not f.exists():
66
+ print(f" WARN: missing {f}")
67
+ continue
68
+ with open(f) as src:
69
+ for line in src:
70
+ out.write(line)
71
+ total += 1
72
+ print(f"combined {total} rows", flush=True)
73
+
74
+ # Aggregate stats with the FULL launcher.py schema
75
+ agg = {
76
+ "summary": {
77
+ "total_examples": 0,
78
+ "total_passed": 0,
79
+ "total_passed_samples": 0,
80
+ "total_failed": 0,
81
+ "pass_rate_pct": 0.0,
82
+ },
83
+ "checks": {
84
+ "static_check": {"passed": 0, "failed": 0},
85
+ "cycle_consistency": {"passed": 0, "failed": 0},
86
+ "factual_accuracy": {"passed": 0, "failed": 0},
87
+ "correctness": {"passed": 0, "failed": 0},
88
+ },
89
+ "failure_breakdown": {
90
+ "failed_static_check": 0,
91
+ "failed_cycle_consistency": 0,
92
+ "failed_factual_accuracy": 0,
93
+ "failed_correctness": 0,
94
+ "exhausted_samples": 0,
95
+ "total_failed": 0,
96
+ },
97
+ "failure_breakdown_examples": {
98
+ "failed_static_check": 0,
99
+ "failed_cycle_consistency": 0,
100
+ "failed_factual_accuracy": 0,
101
+ "failed_correctness": 0,
102
+ "total_failed": 0,
103
+ },
104
+ "num_shards": NUM_SHARDS,
105
+ }
106
+ for sid in range(NUM_SHARDS):
107
+ sf = shards_dir / f"shard_{sid:03d}" / "stats.json"
108
+ if not sf.exists():
109
+ continue
110
+ s = json.load(open(sf))
111
+ for k in ["total_examples", "total_passed", "total_passed_samples", "total_failed"]:
112
+ agg["summary"][k] += s["summary"].get(k, 0)
113
+ for cn in agg["checks"]:
114
+ if cn in s.get("checks", {}):
115
+ for k in ["passed", "failed"]:
116
+ agg["checks"][cn][k] += s["checks"][cn][k]
117
+ for k in agg["failure_breakdown"]:
118
+ if k in s.get("failure_breakdown", {}):
119
+ agg["failure_breakdown"][k] += s["failure_breakdown"][k]
120
+ for k in agg["failure_breakdown_examples"]:
121
+ if k in s.get("failure_breakdown_examples", {}):
122
+ agg["failure_breakdown_examples"][k] += s["failure_breakdown_examples"][k]
123
+ te = agg["summary"]["total_examples"]
124
+ tp = agg["summary"]["total_passed"]
125
+ agg["summary"]["pass_rate_pct"] = round(tp / te * 100, 2) if te else 0.0
126
+
127
+ stats_path = Path("combined_stats.json")
128
+ json.dump(agg, open(stats_path, "w"), indent=2)
129
+ print(f"stats: {agg['summary']}", flush=True)
130
+
131
+ # Sanity: row count should match total_passed (first_valid policy)
132
+ expected = agg["summary"].get("total_passed_samples") or agg["summary"]["total_passed"]
133
+ if total != expected:
134
+ raise RuntimeError(
135
+ f"INTEGRITY: combined output.jsonl has {total} rows but stats expect "
136
+ f"{expected}. Shard outputs may be corrupted."
137
+ )
138
+
139
+ print(f"publishing to {FINAL_REPO}", flush=True)
140
+ api.create_repo(FINAL_REPO, repo_type="dataset", exist_ok=True)
141
+ readme = (
142
+ "---\n"
143
+ "configs:\n"
144
+ "- config_name: default\n"
145
+ " data_files:\n"
146
+ " - split: train\n"
147
+ " path: output.jsonl\n"
148
+ "---\n"
149
+ )
150
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
151
+ f.write(readme)
152
+ rp = f.name
153
+ api.upload_file(
154
+ path_or_fileobj=rp,
155
+ path_in_repo="README.md",
156
+ repo_id=FINAL_REPO,
157
+ repo_type="dataset",
158
+ )
159
+ api.upload_file(
160
+ path_or_fileobj=str(combined),
161
+ path_in_repo="output.jsonl",
162
+ repo_id=FINAL_REPO,
163
+ repo_type="dataset",
164
+ )
165
+ api.upload_file(
166
+ path_or_fileobj=str(stats_path),
167
+ path_in_repo="stats.json",
168
+ repo_id=FINAL_REPO,
169
+ repo_type="dataset",
170
+ )
171
+ print(f"DONE: https://huggingface.co/datasets/{FINAL_REPO}", flush=True)
scripts/launch_shards.sh ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Launch 8 shards on this worker, one per GPU.
3
+ # Worker index is derived from $MY_POD_NAME (Arnold pod name ends with worker-N).
4
+ #
5
+ # Env vars:
6
+ # CONFIG — path to the SDG YAML config (default: math53K config)
7
+ # LIMIT — optional --limit value for smoke tests (e.g. LIMIT=128)
8
+ # NUM_SHARDS — total shards across all workers (default: 32 = 4 nodes × 8 GPU)
9
+ #
10
+ # Outputs:
11
+ # logs/worker_${WORKER_IDX}/shard_NNN.log
12
+ set -e
13
+
14
+ WORKER_IDX=$(echo "$MY_POD_NAME" | grep -oE 'worker-[0-9]+$' | grep -oE '[0-9]+')
15
+ if [ -z "$WORKER_IDX" ]; then
16
+ echo "ERROR: could not infer worker index from MY_POD_NAME=$MY_POD_NAME" >&2
17
+ exit 1
18
+ fi
19
+
20
+ CONFIG="${CONFIG:-sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml}"
21
+ NUM_SHARDS="${NUM_SHARDS:-32}"
22
+ GPUS_PER_WORKER=8
23
+ START=$((WORKER_IDX * GPUS_PER_WORKER))
24
+
25
+ mkdir -p "logs/worker_${WORKER_IDX}"
26
+ LIMIT_FLAG=""
27
+ if [ -n "$LIMIT" ]; then LIMIT_FLAG="--limit $LIMIT"; fi
28
+
29
+ echo "worker $WORKER_IDX launching shards $START..$((START+GPUS_PER_WORKER-1)) (config=$CONFIG, limit=${LIMIT:-none})"
30
+ for i in $(seq 0 $((GPUS_PER_WORKER - 1))); do
31
+ SHARD_ID=$((START + i))
32
+ CUDA_VISIBLE_DEVICES=$i nohup python -m sdg.generate \
33
+ --config "$CONFIG" --shard-id "$SHARD_ID" --num-shards "$NUM_SHARDS" $LIMIT_FLAG \
34
+ > "logs/worker_${WORKER_IDX}/shard_${SHARD_ID}.log" 2>&1 &
35
+ done
36
+ echo "launched 8 shards; PIDs: $(jobs -p)"
scripts/monitor.sh ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Per-worker monitor: GPU util, current tqdm state for each shard, completion counts,
3
+ # and the cluster-wide MongoDB cache size.
4
+ #
5
+ # Works on any worker (uses $ARNOLD_WORKER_0_HOST for cross-worker mongo reachability,
6
+ # so it doesn't matter whether you're on worker_0 or not).
7
+ #
8
+ # Usage:
9
+ # ./scripts/monitor.sh # one shot
10
+ # watch -n 30 ./scripts/monitor.sh # auto-refresh
11
+ # while true; do clear; ./scripts/monitor.sh; sleep 30; done # if `watch` unavailable
12
+
13
+ RUN_NAME="${RUN_NAME:-qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1}"
14
+
15
+ echo "=== $(hostname) $(date +%H:%M:%S) ==="
16
+ nvidia-smi --query-gpu=index,utilization.gpu --format=csv,noheader
17
+
18
+ echo
19
+ echo "=== Shards on this worker ==="
20
+ # Show the most recent tqdm update on each shard's log.
21
+ # tqdm uses \r to overwrite a single line — naive `tail -1` returns the entire
22
+ # line and `cut -cN` truncates from the wrong end. Convert \r → \n and take
23
+ # the actual most-recent state.
24
+ for f in logs/worker_*/shard_*.log; do
25
+ [ -e "$f" ] || continue
26
+ recent=$(tail -c 500 "$f" 2>/dev/null | tr '\r' '\n' | tail -1)
27
+ echo "$(basename "$f"): $recent"
28
+ done
29
+
30
+ echo
31
+ STATS=$(ls "output/${RUN_NAME}/shards"/*/stats.json 2>/dev/null | wc -l)
32
+ COMPLETE=$(grep -l "PIPELINE COMPLETE" logs/worker_*/shard_*.log 2>/dev/null | wc -l)
33
+
34
+ # Mongo cache count — use ARNOLD_WORKER_0_HOST (IPv6) so this works from any worker,
35
+ # not just worker_0. 127.0.0.1 would only work on worker_0.
36
+ MONGO=$(python3 -c "
37
+ from pymongo import MongoClient
38
+ import os
39
+ host = os.environ['ARNOLD_WORKER_0_HOST']
40
+ port = os.environ['ARNOLD_WORKER_0_PORT']
41
+ print(MongoClient(f'mongodb://[{host}]:{port}', serverSelectionTimeoutMS=3000).sdg_cache.inference_cache.count_documents({}))
42
+ " 2>/dev/null)
43
+
44
+ echo "stats.json: $STATS/8 | PIPELINE COMPLETE: $COMPLETE/8 | mongo cache: ${MONGO:-<unreachable>}"
scripts/upload_my_shards.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wait for this worker's 8 shards to complete, then upload to the staging HF repo.
2
+
3
+ Env vars:
4
+ HF_USER — HuggingFace username (default: fzzhang)
5
+ RUN_NAME — experiment name (default: math53K run name)
6
+
7
+ Polls every 2 minutes until all 8 stats.json files appear, then uploads each
8
+ shard folder to `<HF_USER>/<RUN_NAME>-staging` and writes a `workers/worker_N.done`
9
+ marker that the combiner watches for.
10
+ """
11
+ import os
12
+ import re
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from huggingface_hub import HfApi
17
+
18
+ HF_USER = os.environ.get("HF_USER", "fzzhang")
19
+ RUN_NAME = os.environ.get(
20
+ "RUN_NAME", "qwen3_4b_openthoughts3_math53K_instill_n8_valredundancy5_round1"
21
+ )
22
+ GPUS_PER_WORKER = 8
23
+ SHARDS_DIR = Path(f"output/{RUN_NAME}/shards")
24
+ STAGING_REPO = f"{HF_USER}/{RUN_NAME}-staging"
25
+
26
+ m = re.search(r"worker-(\d+)$", os.environ.get("MY_POD_NAME", ""))
27
+ if not m:
28
+ raise SystemExit("cannot infer worker idx from MY_POD_NAME")
29
+ widx = int(m.group(1))
30
+ my_shards = list(range(widx * GPUS_PER_WORKER, (widx + 1) * GPUS_PER_WORKER))
31
+ print(f"worker {widx} owns shards {my_shards}", flush=True)
32
+
33
+
34
+ def done(sid: int) -> bool:
35
+ return (SHARDS_DIR / f"shard_{sid:03d}" / "stats.json").exists()
36
+
37
+
38
+ while True:
39
+ d = [s for s in my_shards if done(s)]
40
+ print(f"[wait] {len(d)}/{len(my_shards)} shards complete", flush=True)
41
+ if len(d) == len(my_shards):
42
+ break
43
+ time.sleep(120)
44
+
45
+ api = HfApi()
46
+ api.create_repo(STAGING_REPO, repo_type="dataset", exist_ok=True, private=True)
47
+ print(f"uploading to {STAGING_REPO}", flush=True)
48
+
49
+ for sid in my_shards:
50
+ sp = SHARDS_DIR / f"shard_{sid:03d}"
51
+ api.upload_folder(
52
+ folder_path=str(sp),
53
+ path_in_repo=f"shards/shard_{sid:03d}",
54
+ repo_id=STAGING_REPO,
55
+ repo_type="dataset",
56
+ commit_message=f"upload shard {sid:03d}",
57
+ )
58
+ print(f" shard_{sid:03d} done", flush=True)
59
+
60
+ api.upload_file(
61
+ path_or_fileobj=f"worker {widx} done\n".encode(),
62
+ path_in_repo=f"workers/worker_{widx}.done",
63
+ repo_id=STAGING_REPO,
64
+ repo_type="dataset",
65
+ )
66
+ print(f"worker {widx} fully uploaded", flush=True)
sdg/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "0.1.0"
sdg/__main__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Allow ``python -m sdg`` as an alias for ``python -m sdg.generate``."""
2
+ from sdg.generate import main
3
+
4
+ main()
sdg/config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SDGConfig dataclass and YAML serialization.
3
+ """
4
+
5
+ from dataclasses import dataclass, field, fields, asdict
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass
12
+ class SDGConfig:
13
+ # Model
14
+ model_name: str = "Qwen/Qwen3-4B"
15
+ is_instruction_tuned: bool = True
16
+ tensor_parallel_size: int = 1
17
+ max_model_len: int = 32768
18
+ gpu_memory_utilization: float = 0.95
19
+
20
+ # Dataset
21
+ dataset_hf_id: str = "open-thoughts/OpenThoughts-114k-math"
22
+ dataset_hf_config: str | None = None # HF dataset config/subset name
23
+ dataset_hf_revision: str | None = None # HF dataset revision (commit hash)
24
+ dataset_split: str = "train"
25
+ dataset_format: str = "openthoughts4" # "openthoughts4" | "messages"
26
+ check_boxed: bool = True
27
+ limit: int | None = None
28
+
29
+ # Generation
30
+ num_generations: int = 4
31
+ gen_temperature: float = 0.8
32
+ gen_top_p: float = 0.95
33
+ gen_max_tokens: int = 32768
34
+ gen_batch_size: int = 64
35
+
36
+ # Validation
37
+ validation_type: str | None = "uq" # "uq" | "simple" | null (None = skip validation)
38
+ selection_policy: str = "first_valid" # "first_valid" | "all_valid"
39
+ num_validation_votes: int = 3
40
+ val_temperature: float = 0.6
41
+ val_top_p: float = 0.95
42
+ val_max_tokens: int = 32768
43
+ val_batch_size: int = 64
44
+
45
+ # Output
46
+ experiment_name: str = ""
47
+ output_dir: str = "output"
48
+
49
+ # MongoDB cache
50
+ mongo_uri: str = ""
51
+
52
+ # Sharding
53
+ num_shards: int = 1
54
+
55
+ # Machines to exclude when launching Slurm jobs
56
+ machines_to_exclude: list[str] = field(default_factory=list)
57
+
58
+ # ── derived properties ──────────────────────────────────────────────
59
+
60
+ @property
61
+ def run_name(self) -> str:
62
+ if not self.experiment_name:
63
+ raise ValueError("experiment_name must be set in the config YAML")
64
+ return self.experiment_name
65
+
66
+ @property
67
+ def mongo_db_name(self) -> str:
68
+ return "sdg_cache"
69
+
70
+ @property
71
+ def output_path(self) -> Path:
72
+ return Path(self.output_dir) / self.run_name
73
+
74
+ # ── YAML I/O ────────────────────────────────────────────────────────
75
+
76
+ @classmethod
77
+ def from_yaml(cls, path: str | Path) -> "SDGConfig":
78
+ with open(path) as f:
79
+ data = yaml.safe_load(f) or {}
80
+ valid_keys = {f.name for f in fields(cls)}
81
+ filtered = {k: v for k, v in data.items() if k in valid_keys}
82
+ config = cls(**filtered)
83
+ if not config.mongo_uri:
84
+ raise ValueError(
85
+ f"mongo_uri is not set in {path}. "
86
+ f"Add a line like `mongo_uri: \"mongodb://HOST:PORT\"` to the config. "
87
+ f"(Common mistake: the key must be `mongo_uri` with an underscore, not `mongo-uri`.)"
88
+ )
89
+ return config
90
+
91
+ def to_yaml(self, path: str | Path) -> None:
92
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
93
+ with open(path, "w") as f:
94
+ yaml.dump(asdict(self), f, default_flow_style=False, sort_keys=False)
sdg/configs/qwen3_0.6b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_0.6b_openthoughts3_math53K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-0.6B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 8
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 5
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_0.6b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_0.6b_openthoughts4_code9K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-0.6B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/ot4-code-deduped"
10
+ dataset_hf_revision: "840633b"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_0.6b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_0.6b_openthoughts4_science26K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-0.6B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/ot4-science-deduped"
10
+ dataset_hf_revision: "ddae07c"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_0.6b_rstarcoder_seedsft_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_0.6b_rstarcoder_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-0.6B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/rstarcoder-seedsft-deduped"
10
+ dataset_hf_revision: "1242238"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://jagupard39.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_1.7b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_1.7b_openthoughts3_math53K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-1.7B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 8
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 5
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_1.7b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_1.7b_openthoughts4_code9K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-1.7B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/ot4-code-deduped"
10
+ dataset_hf_revision: "840633b"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_1.7b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_1.7b_openthoughts4_science26K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-1.7B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/ot4-science-deduped"
10
+ dataset_hf_revision: "ddae07c"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_32b_openthoughts3_math53K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_32b_openthoughts3_math53K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-32B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 8
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 5
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://jagupard39.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_32b_openthoughts4_code9K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_32b_openthoughts4_code9K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-32B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/ot4-code-deduped"
10
+ dataset_hf_revision: "840633b"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_32b_openthoughts4_science26K_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_32b_openthoughts4_science26K_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-32B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/ot4-science-deduped"
10
+ dataset_hf_revision: "ddae07c"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_32b_rstarcoder_seedsft_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_32b_rstarcoder_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-32B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/rstarcoder-seedsft-deduped"
10
+ dataset_hf_revision: "1242238"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://jagupard39.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_4b_mot_math_instill_n4_valredundancy3_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model_name: "Qwen/Qwen3-4B"
2
+ is_instruction_tuned: true
3
+ tensor_parallel_size: 1
4
+ max_model_len: 32768
5
+ gpu_memory_utilization: 0.95
6
+
7
+ dataset_hf_id: "open-r1/Mixture-of-Thoughts"
8
+ dataset_hf_config: "math"
9
+ dataset_split: "train"
10
+ dataset_format: "messages"
11
+ check_boxed: true
12
+ limit: null
13
+
14
+ num_generations: 4
15
+ gen_temperature: 0.8
16
+ gen_top_p: 0.95
17
+ gen_max_tokens: 32768
18
+ gen_batch_size: 64
19
+
20
+ validation_type: uq
21
+ selection_policy: first_valid
22
+ num_validation_votes: 3
23
+ val_temperature: 0.8
24
+ val_top_p: 0.95
25
+ val_max_tokens: 32768
26
+ val_batch_size: 64
27
+
28
+ experiment_name: "qwen3_4b_mot_math_instill_n4_valredundancy3_round1"
29
+ output_dir: "output"
30
+
31
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_4b_nemotron_cascade2_science100k_instill_n8_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_nemotron_cascade2_science100k_instill_n8_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/nemotron-cascade-2-science-deduped-100k"
10
+ dataset_hf_revision: "adb996f"
11
+ dataset_split: "train"
12
+ dataset_format: "openthoughts4"
13
+ check_boxed: false
14
+ limit: null
15
+
16
+ num_generations: 8
17
+ gen_temperature: 0.8
18
+ gen_top_p: 0.95
19
+ gen_max_tokens: 32768
20
+ gen_batch_size: 64
21
+
22
+ validation_type: uq
23
+ selection_policy: first_valid
24
+ num_validation_votes: 5
25
+ val_temperature: 0.8
26
+ val_top_p: 0.95
27
+ val_max_tokens: 32768
28
+ val_batch_size: 64
29
+
30
+ output_dir: "output"
31
+ mongo_uri: "mongodb://jagupard39.stanford.edu:27017"
32
+ num_shards: 30
33
+
34
+ machines_to_exclude:
35
+ - sphinx1
36
+ - sphinx2
37
+ - sphinx3
38
+ - sphinx4
39
+ - sphinx5
40
+ - sphinx6
41
+ - sphinx7
42
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy1_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy1_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 1
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 1
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy3_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy3_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 1
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 3
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n1_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 1
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 5
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy1_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy1_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 4
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 1
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy3_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy3_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 4
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 3
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n4_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 4
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: uq
22
+ selection_policy: first_valid
23
+ num_validation_votes: 5
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_no_filter_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n8_no_filter_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 8
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: null
22
+ selection_policy: first_valid
23
+ num_validation_votes: 0
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8
sdg/configs/qwen3_4b_openthoughts3_math53K_instill_n8_simple_valredundancy5_round1.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ experiment_name: "qwen3_4b_openthoughts3_math53K_instill_n8_simple_valredundancy5_round1"
2
+
3
+ model_name: "Qwen/Qwen3-4B"
4
+ is_instruction_tuned: true
5
+ tensor_parallel_size: 1
6
+ max_model_len: 32768
7
+ gpu_memory_utilization: 0.95
8
+
9
+ dataset_hf_id: "teetone/openthoughts3-math-deduped-53K"
10
+ dataset_split: "train"
11
+ dataset_format: "openthoughts4"
12
+ check_boxed: true
13
+ limit: null
14
+
15
+ num_generations: 8
16
+ gen_temperature: 0.8
17
+ gen_top_p: 0.95
18
+ gen_max_tokens: 32768
19
+ gen_batch_size: 64
20
+
21
+ validation_type: simple
22
+ selection_policy: first_valid
23
+ num_validation_votes: 5
24
+ val_temperature: 0.8
25
+ val_top_p: 0.95
26
+ val_max_tokens: 32768
27
+ val_batch_size: 64
28
+
29
+ output_dir: "output"
30
+ mongo_uri: "mongodb://john3.stanford.edu:27017"
31
+ num_shards: 30
32
+
33
+ machines_to_exclude:
34
+ - sphinx1
35
+ - sphinx2
36
+ - sphinx3
37
+ - sphinx4
38
+ - sphinx5
39
+ - sphinx6
40
+ - sphinx7
41
+ - sphinx8