Add model card, meta.json, logs, tokenizer, training source, and nanochat converter
Browse files- README.md +180 -0
- base_checkpoints/d24_decoderstack/meta_001950.json +87 -0
- base_checkpoints/d24_decoderstack/meta_005568.json +93 -0
- code/run_full_d24_w8.py +0 -0
- convert_ckpt_to_nanochat.py +271 -0
- logs/baseline_report.md +50 -0
- logs/d24_decoderstack_20260801_023219.log +0 -0
- logs/run_full_d24_w8.sh +72 -0
- tokenizer/token_bytes.pt +3 -0
- tokenizer/tokenizer.pkl +3 -0
README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- decoderstack
|
| 7 |
+
- nanochat
|
| 8 |
+
- pretraining
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# DecoderStack d24
|
| 12 |
+
|
| 13 |
+
A 1.38B-parameter d24 model pre-trained from scratch with
|
| 14 |
+
[**DecoderStack**](https://github.com/chrisjmccormick/stacks) — a single-file training
|
| 15 |
+
stack with a handwritten forward/backward and a written-out optimizer: no autograd, no
|
| 16 |
+
`torch.optim`, no `nn.Module`. bf16, varlen FlashAttention, 8xH100, 1h51m.
|
| 17 |
+
|
| 18 |
+
The architecture is the nanochat d24 architecture, so these weights load into
|
| 19 |
+
[nanochat](https://github.com/karpathy/nanochat) once converted — see
|
| 20 |
+
[Loading into nanochat](#loading-into-nanochat).
|
| 21 |
+
|
| 22 |
+
## Results
|
| 23 |
+
|
| 24 |
+
Full horizon: 5,568 steps x 2^20 tokens = 5.84B tokens (data:param ratio 8).
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
step 05567/05568 (99.98%) | loss: 2.388180 | lrm: 0.05 | dt: 1198.08ms | tok/sec: 875,210 | bf16_mfu: 53.76 | total time: 110.74m
|
| 28 |
+
step:5568/5568 val_bpb:0.719042
|
| 29 |
+
CORE metric: 0.2517 | total CORE eval time: 80.46s
|
| 30 |
+
peak memory allocated: 71569 MiB reserved: 72736 MiB
|
| 31 |
+
total training time: 110.74m
|
| 32 |
+
minimum validation bpb: 0.719042
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
| | value |
|
| 36 |
+
|---|---|
|
| 37 |
+
| parameters | 1,384,122,122 |
|
| 38 |
+
| min val bpb | 0.719042 |
|
| 39 |
+
| CORE | 0.2517 |
|
| 40 |
+
| train time | 110.74 min on 8xH100 80GB |
|
| 41 |
+
| throughput | 1,212 ms/step, 864,856 tok/sec (steps 20–99) |
|
| 42 |
+
| precision | bf16 (no FP8) |
|
| 43 |
+
|
| 44 |
+
val bpb curve: 0:3.175835 / 250:0.997472 / 500:0.904081 / 1000:0.846058 / 2000:0.809199 /
|
| 45 |
+
3000:0.773139 / 4000:0.747155 / 5000:0.726908 / 5568:0.719042
|
| 46 |
+
|
| 47 |
+
**Against upstream nanochat**, run on the same box on the same rebuilt validation shard,
|
| 48 |
+
DecoderStack tracks 0.2–1.4% behind through step 1000 (where the upstream baseline was
|
| 49 |
+
stopped) while running ~6% faster per step (1,212 vs 1,291 ms/step). Full head-to-head in
|
| 50 |
+
[`logs/baseline_report.md`](logs/baseline_report.md).
|
| 51 |
+
|
| 52 |
+
`ChrisMcCormick/nanochat-varlen-d24-2026-03-22` is the closest sibling release
|
| 53 |
+
(0.724772 val bpb, 0.2614 CORE, 97.4 min) but it is **not** a controlled comparison: it
|
| 54 |
+
trained with FP8 and a different 32k tokenizer.
|
| 55 |
+
|
| 56 |
+
## ⚠️ Tokenizer
|
| 57 |
+
|
| 58 |
+
These weights are tied to the 32k vocabulary in
|
| 59 |
+
[`ChrisMcCormick/climbmix_32k_8_170`](https://huggingface.co/datasets/ChrisMcCormick/climbmix_32k_8_170),
|
| 60 |
+
mirrored here under [`tokenizer/`](tokenizer). It is a **different vocabulary** from the
|
| 61 |
+
one in `ChrisMcCormick/nanochat-varlen-d24-2026-03-22` — 31,474 of 32,759 merge ids
|
| 62 |
+
differ. Both are 32,768 tokens and both load without complaint, so nothing will warn you.
|
| 63 |
+
Pairing these weights with the wrong tokenizer produces garbage, not slightly-worse text.
|
| 64 |
+
|
| 65 |
+
## Contents
|
| 66 |
+
|
| 67 |
+
```
|
| 68 |
+
checkpoints/ native DecoderStack captures (the source of record)
|
| 69 |
+
model_step001950.pt 2.8 GB {step, code, weights} -- bf16 live weights + fp32 scalars
|
| 70 |
+
optim_step001950.pt 11.1 GB {step, t_step, state} -- mantissas + Muon/AdamW moments
|
| 71 |
+
model_step005568.pt 2.8 GB
|
| 72 |
+
optim_step005568.pt 11.1 GB
|
| 73 |
+
base_checkpoints/d24_decoderstack/ nanochat-format target dir
|
| 74 |
+
meta_001950.json model_config + run metadata
|
| 75 |
+
meta_005568.json (the converter writes model_NNNNNN.pt in here)
|
| 76 |
+
code/run_full_d24_w8.py the exact training script this run executed
|
| 77 |
+
logs/
|
| 78 |
+
d24_decoderstack_20260801_023219.log full training log
|
| 79 |
+
run_full_d24_w8.sh launcher, with the config rationale
|
| 80 |
+
baseline_report.md throughput + val bpb vs upstream nanochat
|
| 81 |
+
tokenizer/ the 32k vocab these weights were trained on
|
| 82 |
+
convert_ckpt_to_nanochat.py DecoderStack capture -> nanochat state_dict
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Two capture points: **5568** is the end of the run, **1950** is the last uncooled state —
|
| 86 |
+
the LR/momentum hold ends after update 1949 (`N - round(0.65*N)`), so 1950 is the one to
|
| 87 |
+
resume from if you want to train the horizon longer.
|
| 88 |
+
|
| 89 |
+
DecoderStack writes `code` (the full training script, `open(sys.argv[0]).read()`) into
|
| 90 |
+
every model capture, so each `.pt` carries its own exact source. `code/run_full_d24_w8.py`
|
| 91 |
+
is that string extracted from `model_step005568.pt`.
|
| 92 |
+
|
| 93 |
+
## Loading into nanochat
|
| 94 |
+
|
| 95 |
+
`convert_ckpt_to_nanochat.py` unbanks the per-layer matrix banks, renames tensors to
|
| 96 |
+
nanochat's module paths, maps value-embedding bank slots back to their layers, and
|
| 97 |
+
rebuilds the fp32 masters from the bf16 live weights plus the uint16 mantissa held in the
|
| 98 |
+
optimizer file.
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
pip install huggingface_hub torch
|
| 102 |
+
python - <<'PY'
|
| 103 |
+
from huggingface_hub import hf_hub_download
|
| 104 |
+
R = "ChrisMcCormick/decoderstack-d24"
|
| 105 |
+
for f in ["checkpoints/model_step005568.pt", # 2.8 GB
|
| 106 |
+
"checkpoints/optim_step005568.pt", # 11.1 GB -- optional, see below
|
| 107 |
+
"base_checkpoints/d24_decoderstack/meta_005568.json",
|
| 108 |
+
"convert_ckpt_to_nanochat.py",
|
| 109 |
+
"tokenizer/tokenizer.pkl", "tokenizer/token_bytes.pt"]:
|
| 110 |
+
hf_hub_download(R, f, local_dir="decoderstack-d24")
|
| 111 |
+
PY
|
| 112 |
+
|
| 113 |
+
cd decoderstack-d24
|
| 114 |
+
python convert_ckpt_to_nanochat.py \
|
| 115 |
+
--model checkpoints/model_step005568.pt \
|
| 116 |
+
--optim checkpoints/optim_step005568.pt \
|
| 117 |
+
--meta base_checkpoints/d24_decoderstack/meta_005568.json \
|
| 118 |
+
--out ~/.cache/nanochat/base_checkpoints/d24_decoderstack
|
| 119 |
+
mkdir -p ~/.cache/nanochat/tokenizer && cp tokenizer/* ~/.cache/nanochat/tokenizer/
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
(`~/.cache/nanochat` is nanochat's default base dir; override with `NANOCHAT_BASE_DIR`.)
|
| 123 |
+
|
| 124 |
+
`--optim` is optional. It only supplies the mantissas — the lower 16 bits of the fp32
|
| 125 |
+
masters. Without it the fp32 parameters carry bf16 precision, which costs about 0.0005
|
| 126 |
+
nats/token (2.4833 vs 2.4828 on a held-out paragraph); skip the 11 GB download unless you
|
| 127 |
+
want the exact master.
|
| 128 |
+
|
| 129 |
+
Then, on the [`fa-varlen`](https://github.com/chrisjmccormick/nanochat/tree/fa-varlen)
|
| 130 |
+
branch (varlen, matching how these weights were trained):
|
| 131 |
+
|
| 132 |
+
```python
|
| 133 |
+
import os, torch
|
| 134 |
+
from nanochat.checkpoint_manager import build_model
|
| 135 |
+
ckpt_dir = os.path.expanduser("~/.cache/nanochat/base_checkpoints/d24_decoderstack")
|
| 136 |
+
model, tokenizer, meta = build_model(ckpt_dir, 5568, torch.device("cuda"), "eval")
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
The conversion is verified: 175 tensors, shapes and dtypes identical to a freshly built
|
| 140 |
+
nanochat `GPT` at this config, loads with `strict=True`, and scores 2.4828 nats/token
|
| 141 |
+
(0.725 bpb) on held-out English — in line with the run's own 0.719 val bpb — with
|
| 142 |
+
coherent greedy continuations.
|
| 143 |
+
|
| 144 |
+
**DecoderStack itself cannot load these back yet.** The capture path is write-only by
|
| 145 |
+
design; a load/resume path is future work.
|
| 146 |
+
|
| 147 |
+
### Optimizer state
|
| 148 |
+
|
| 149 |
+
Not converted, and nanochat could not resume from it if it were. Every buffer does have a
|
| 150 |
+
counterpart, and the precisions line up on everything except the embedding tables:
|
| 151 |
+
|
| 152 |
+
| DecoderStack | nanochat | precision |
|
| 153 |
+
|---|---|---|
|
| 154 |
+
| `.frst_mntm` | Muon `momentum_buffer` | fp32 both |
|
| 155 |
+
| `.scnd_mntm` | Muon `second_momentum_buffer` | fp32 both |
|
| 156 |
+
| `.exp_avg` / `.exp_avg_sq` | AdamW `exp_avg` / `exp_avg_sq` | fp32 both, **except** `wte` and `value_embeds`, which nanochat allocates `zeros_like(p)` on a bf16 param — so bf16 there, fp32 here |
|
| 157 |
+
| `.mantissa` (uint16) | — | nanochat's fp32 param *is* the master; consumed by the converter to rebuild it |
|
| 158 |
+
|
| 159 |
+
Two things block a faithful resume regardless: `W_O`'s NorMuon second moment reduces along
|
| 160 |
+
a different axis in each codebase (nanochat infers it from the shape and lands on `-1` for
|
| 161 |
+
a square `c_proj`; DecoderStack sets `residual_dim = -2` deliberately, because W_O's heads
|
| 162 |
+
are stored transposed relative to QKV), and nanochat's resume needs a
|
| 163 |
+
`dataloader_state_dict` that DecoderStack's pre-tokenized binary loader has no equivalent
|
| 164 |
+
of. The remaining mapping — param ordering and per-rank sharding — is written out at the
|
| 165 |
+
bottom of `convert_ckpt_to_nanochat.py`.
|
| 166 |
+
|
| 167 |
+
## Provenance
|
| 168 |
+
|
| 169 |
+
Trained by [`chrisjmccormick/stacks`](https://github.com/chrisjmccormick/stacks) — the
|
| 170 |
+
converter is committed at
|
| 171 |
+
[`e664830`](https://github.com/chrisjmccormick/stacks/commit/e6648306442e0479433b287ede02d271997365c0)
|
| 172 |
+
(`utils/convert_ckpt_to_nanochat.py`). The training script that produced these weights is
|
| 173 |
+
`code/run_full_d24_w8.py` in this repo, which is the run copy of the single-file d24
|
| 174 |
+
trainer with three launcher overrides (`micro_batch_tokens` 32768→65536,
|
| 175 |
+
`max_num_docs` 96→192, `NUM_TRAIN_SHARDS` 20→80); the rationale for each is in
|
| 176 |
+
`logs/run_full_d24_w8.sh`.
|
| 177 |
+
|
| 178 |
+
Training data: [`ChrisMcCormick/climbmix_32k_8_170`](https://huggingface.co/datasets/ChrisMcCormick/climbmix_32k_8_170)
|
| 179 |
+
(ClimbMix, pre-tokenized to 32k binary shards). Validation is a rebuild of shard 06542 to
|
| 180 |
+
match nanochat's pinned val split.
|
base_checkpoints/d24_decoderstack/meta_001950.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"step": 1950,
|
| 3 |
+
"val_bpb": null,
|
| 4 |
+
"model_config": {
|
| 5 |
+
"sequence_len": 2048,
|
| 6 |
+
"vocab_size": 32768,
|
| 7 |
+
"n_layer": 24,
|
| 8 |
+
"n_head": 12,
|
| 9 |
+
"n_kv_head": 12,
|
| 10 |
+
"n_embd": 1536,
|
| 11 |
+
"window_pattern": "SSSL"
|
| 12 |
+
},
|
| 13 |
+
"device_batch_size": 32,
|
| 14 |
+
"max_seq_len": 2048,
|
| 15 |
+
"total_batch_size": 1048576,
|
| 16 |
+
"note": "DecoderStack-medium (d24) capture converted for nanochat. This meta was built after the fact from the run log (full_d24_w8.log); DecoderStack's capture writes only {step, code, weights} and does not emit a meta.json of its own. model_config is the nanochat GPTConfig equivalent of StackConfig -- the two architectures are the same model (both count 1,384,122,122 parameters). Sufficient for load/eval; NOT sufficient for resume: there is no dataloader_state_dict, loop_state, or optimizer state here, and DecoderStack's pre-tokenized binary loader has no state nanochat's tokenizing loader could consume. device_batch_size/max_seq_len are the nanochat-shaped restatement of micro_batch_tokens=65,536 (= 32 x 2048) -- DecoderStack trains varlen, so its micro-batch is a token count, not a sequence count. TOKENIZER: these weights use the 32k vocab shipped in the ChrisMcCormick/climbmix_32k_8_170 dataset repo (mirrored under tokenizer/ in this repo). It is NOT the same vocabulary as ChrisMcCormick/nanochat-varlen-d24-2026-03-22 -- 31,474 of 32,759 ids differ. Pairing these weights with that tokenizer produces garbage.",
|
| 17 |
+
"stack_config": {
|
| 18 |
+
"n_layers": 24,
|
| 19 |
+
"d_model": 1536,
|
| 20 |
+
"d_vocab": 32768,
|
| 21 |
+
"d_smr_gate": 24,
|
| 22 |
+
"n_q_heads": 12,
|
| 23 |
+
"n_kv_heads": 12,
|
| 24 |
+
"n_o_heads": 12,
|
| 25 |
+
"d_qk": 128,
|
| 26 |
+
"d_vo": 128,
|
| 27 |
+
"seq_len": 2048,
|
| 28 |
+
"short_win_size": 768,
|
| 29 |
+
"full_ctxt_layers": [
|
| 30 |
+
3,
|
| 31 |
+
7,
|
| 32 |
+
11,
|
| 33 |
+
15,
|
| 34 |
+
19,
|
| 35 |
+
23
|
| 36 |
+
],
|
| 37 |
+
"d_ve_gate": 12,
|
| 38 |
+
"ve_layers": [
|
| 39 |
+
1,
|
| 40 |
+
3,
|
| 41 |
+
5,
|
| 42 |
+
7,
|
| 43 |
+
9,
|
| 44 |
+
11,
|
| 45 |
+
13,
|
| 46 |
+
15,
|
| 47 |
+
17,
|
| 48 |
+
19,
|
| 49 |
+
21,
|
| 50 |
+
23
|
| 51 |
+
],
|
| 52 |
+
"d_mlp": 6144,
|
| 53 |
+
"micro_batch_tokens": 65536,
|
| 54 |
+
"total_batch_size": 1048576,
|
| 55 |
+
"num_iterations": 5568,
|
| 56 |
+
"max_num_docs": 192,
|
| 57 |
+
"num_train_shards": 80,
|
| 58 |
+
"val_tokens": 10485760,
|
| 59 |
+
"val_loss_every": 250,
|
| 60 |
+
"save_steps": [
|
| 61 |
+
1950,
|
| 62 |
+
5568
|
| 63 |
+
],
|
| 64 |
+
"world_size": 8,
|
| 65 |
+
"grad_accum_steps": 2,
|
| 66 |
+
"precision": "bf16",
|
| 67 |
+
"run_id": "2026-08-01_023219-d24"
|
| 68 |
+
},
|
| 69 |
+
"schedules": {
|
| 70 |
+
"batch_lr_scale": 1.4142135623730951,
|
| 71 |
+
"muon_weight_decay_peak": 0.059738,
|
| 72 |
+
"warmup_steps": 40,
|
| 73 |
+
"warmdown_ratio": 0.65,
|
| 74 |
+
"final_lr_frac": 0.05,
|
| 75 |
+
"matrix_lr": 0.02,
|
| 76 |
+
"muon_momentum": "0.85->0.97 over 400 steps, cooled to 0.90",
|
| 77 |
+
"muon_beta2": 0.9,
|
| 78 |
+
"muon_ns_steps": 5,
|
| 79 |
+
"unembedding_lr": 0.008,
|
| 80 |
+
"embedding_lr": 0.3,
|
| 81 |
+
"value_embedding_lr_mult": 0.5,
|
| 82 |
+
"scalar_lr": 0.5,
|
| 83 |
+
"smear_lr": 0.2,
|
| 84 |
+
"adamw_eps": 1e-10
|
| 85 |
+
},
|
| 86 |
+
"val_bpb_note": "not measured at this step (val_loss_every=250); bracketed by step 1750 = 0.815902 and step 2000 = 0.809199. This is the last step before the LR/momentum cooldown begins."
|
| 87 |
+
}
|
base_checkpoints/d24_decoderstack/meta_005568.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"step": 5568,
|
| 3 |
+
"val_bpb": 0.719042,
|
| 4 |
+
"model_config": {
|
| 5 |
+
"sequence_len": 2048,
|
| 6 |
+
"vocab_size": 32768,
|
| 7 |
+
"n_layer": 24,
|
| 8 |
+
"n_head": 12,
|
| 9 |
+
"n_kv_head": 12,
|
| 10 |
+
"n_embd": 1536,
|
| 11 |
+
"window_pattern": "SSSL"
|
| 12 |
+
},
|
| 13 |
+
"device_batch_size": 32,
|
| 14 |
+
"max_seq_len": 2048,
|
| 15 |
+
"total_batch_size": 1048576,
|
| 16 |
+
"note": "DecoderStack-medium (d24) capture converted for nanochat. This meta was built after the fact from the run log (full_d24_w8.log); DecoderStack's capture writes only {step, code, weights} and does not emit a meta.json of its own. model_config is the nanochat GPTConfig equivalent of StackConfig -- the two architectures are the same model (both count 1,384,122,122 parameters). Sufficient for load/eval; NOT sufficient for resume: there is no dataloader_state_dict, loop_state, or optimizer state here, and DecoderStack's pre-tokenized binary loader has no state nanochat's tokenizing loader could consume. device_batch_size/max_seq_len are the nanochat-shaped restatement of micro_batch_tokens=65,536 (= 32 x 2048) -- DecoderStack trains varlen, so its micro-batch is a token count, not a sequence count. TOKENIZER: these weights use the 32k vocab shipped in the ChrisMcCormick/climbmix_32k_8_170 dataset repo (mirrored under tokenizer/ in this repo). It is NOT the same vocabulary as ChrisMcCormick/nanochat-varlen-d24-2026-03-22 -- 31,474 of 32,759 ids differ. Pairing these weights with that tokenizer produces garbage.",
|
| 17 |
+
"stack_config": {
|
| 18 |
+
"n_layers": 24,
|
| 19 |
+
"d_model": 1536,
|
| 20 |
+
"d_vocab": 32768,
|
| 21 |
+
"d_smr_gate": 24,
|
| 22 |
+
"n_q_heads": 12,
|
| 23 |
+
"n_kv_heads": 12,
|
| 24 |
+
"n_o_heads": 12,
|
| 25 |
+
"d_qk": 128,
|
| 26 |
+
"d_vo": 128,
|
| 27 |
+
"seq_len": 2048,
|
| 28 |
+
"short_win_size": 768,
|
| 29 |
+
"full_ctxt_layers": [
|
| 30 |
+
3,
|
| 31 |
+
7,
|
| 32 |
+
11,
|
| 33 |
+
15,
|
| 34 |
+
19,
|
| 35 |
+
23
|
| 36 |
+
],
|
| 37 |
+
"d_ve_gate": 12,
|
| 38 |
+
"ve_layers": [
|
| 39 |
+
1,
|
| 40 |
+
3,
|
| 41 |
+
5,
|
| 42 |
+
7,
|
| 43 |
+
9,
|
| 44 |
+
11,
|
| 45 |
+
13,
|
| 46 |
+
15,
|
| 47 |
+
17,
|
| 48 |
+
19,
|
| 49 |
+
21,
|
| 50 |
+
23
|
| 51 |
+
],
|
| 52 |
+
"d_mlp": 6144,
|
| 53 |
+
"micro_batch_tokens": 65536,
|
| 54 |
+
"total_batch_size": 1048576,
|
| 55 |
+
"num_iterations": 5568,
|
| 56 |
+
"max_num_docs": 192,
|
| 57 |
+
"num_train_shards": 80,
|
| 58 |
+
"val_tokens": 10485760,
|
| 59 |
+
"val_loss_every": 250,
|
| 60 |
+
"save_steps": [
|
| 61 |
+
1950,
|
| 62 |
+
5568
|
| 63 |
+
],
|
| 64 |
+
"world_size": 8,
|
| 65 |
+
"grad_accum_steps": 2,
|
| 66 |
+
"precision": "bf16",
|
| 67 |
+
"run_id": "2026-08-01_023219-d24"
|
| 68 |
+
},
|
| 69 |
+
"schedules": {
|
| 70 |
+
"batch_lr_scale": 1.4142135623730951,
|
| 71 |
+
"muon_weight_decay_peak": 0.059738,
|
| 72 |
+
"warmup_steps": 40,
|
| 73 |
+
"warmdown_ratio": 0.65,
|
| 74 |
+
"final_lr_frac": 0.05,
|
| 75 |
+
"matrix_lr": 0.02,
|
| 76 |
+
"muon_momentum": "0.85->0.97 over 400 steps, cooled to 0.90",
|
| 77 |
+
"muon_beta2": 0.9,
|
| 78 |
+
"muon_ns_steps": 5,
|
| 79 |
+
"unembedding_lr": 0.008,
|
| 80 |
+
"embedding_lr": 0.3,
|
| 81 |
+
"value_embedding_lr_mult": 0.5,
|
| 82 |
+
"scalar_lr": 0.5,
|
| 83 |
+
"smear_lr": 0.2,
|
| 84 |
+
"adamw_eps": 1e-10
|
| 85 |
+
},
|
| 86 |
+
"results": {
|
| 87 |
+
"core_metric": 0.2517,
|
| 88 |
+
"min_val_bpb": 0.719042,
|
| 89 |
+
"peak_memory_mib": 71569,
|
| 90 |
+
"total_training_time_min": 110.74,
|
| 91 |
+
"gpu": "8x NVIDIA H100 80GB HBM3"
|
| 92 |
+
}
|
| 93 |
+
}
|
code/run_full_d24_w8.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
convert_ckpt_to_nanochat.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Convert a DecoderStack-medium (d24) capture into a nanochat checkpoint.
|
| 2 |
+
#
|
| 3 |
+
# DecoderStack writes its own two-file capture (see § Checkpoint capture in
|
| 4 |
+
# decoderstack_medium_pt-sft.py):
|
| 5 |
+
#
|
| 6 |
+
# model_stepNNNNNN.pt {step, code, weights: {name: tensor}}
|
| 7 |
+
# bf16 live weights + fp32 scalars, banked over layers.
|
| 8 |
+
# optim_stepNNNNNN.pt {step, t_step, state: {"name.attr": tensor}}
|
| 9 |
+
# mantissa / frst_mntm / scnd_mntm / exp_avg / exp_avg_sq,
|
| 10 |
+
# all-gathered to full size (world-agnostic).
|
| 11 |
+
#
|
| 12 |
+
# nanochat wants one flat state_dict per capture, in its own module-path key
|
| 13 |
+
# names, next to a meta_NNNNNN.json:
|
| 14 |
+
#
|
| 15 |
+
# <out_dir>/model_NNNNNN.pt torch.save(model.state_dict())
|
| 16 |
+
# <out_dir>/meta_NNNNNN.json {"step", "val_bpb", "model_config", ...}
|
| 17 |
+
#
|
| 18 |
+
# This script does that translation. The architectures are the same model --
|
| 19 |
+
# DecoderStack-medium is a flattened port of nanochat d24 -- so every tensor has
|
| 20 |
+
# a home and nothing is reshaped or transposed: DecoderStack's banks index the
|
| 21 |
+
# layer on dim 0 and each slice already uses F.linear's (out, in) convention.
|
| 22 |
+
# The only real work is naming, unbanking, and dtype.
|
| 23 |
+
#
|
| 24 |
+
# DTYPE, and why the optimizer file matters
|
| 25 |
+
# -----------------------------------------
|
| 26 |
+
# nanochat holds fp32 master weights for everything except the two embedding
|
| 27 |
+
# tables, which init_weights() casts to COMPUTE_DTYPE (bf16). DecoderStack holds
|
| 28 |
+
# bf16 LIVE weights everywhere plus a uint16 `mantissa` in the optimizer file --
|
| 29 |
+
# the fp32 master's bit pattern is (live_bf16_bits << 16) | mantissa. So:
|
| 30 |
+
#
|
| 31 |
+
# nanochat fp32 params <- fp32 master = live + mantissa (needs --optim)
|
| 32 |
+
# nanochat bf16 params <- live bf16 as-is (mantissa dropped,
|
| 33 |
+
# which is correct: nanochat's bf16 embedding IS its
|
| 34 |
+
# master, it has no lower bits to carry)
|
| 35 |
+
#
|
| 36 |
+
# Without --optim the fp32 params are filled with the bf16 live values upcast to
|
| 37 |
+
# fp32 -- the right dtype, but only bf16 precision. That is what you get if you
|
| 38 |
+
# only downloaded the model file, and it is fine for eval: on a held-out English
|
| 39 |
+
# paragraph the d24 step-5568 capture scores 2.4828 nats/token with the masters
|
| 40 |
+
# and 2.4833 without (bpb 0.7252 vs 0.7254). Pass --optim when you want the
|
| 41 |
+
# exact master anyway -- it is a bit-exact reconstruction, not an approximation.
|
| 42 |
+
#
|
| 43 |
+
# WHAT THIS DOES NOT DO
|
| 44 |
+
# ---------------------
|
| 45 |
+
# It does not convert optimizer state, and nanochat could not resume from it if
|
| 46 |
+
# it did -- see OPTIMIZER-STATE NOTES at the bottom of this file.
|
| 47 |
+
#
|
| 48 |
+
# Usage:
|
| 49 |
+
# python utils/convert_ckpt_to_nanochat.py \
|
| 50 |
+
# --model checkpoints/model_step005568.pt \
|
| 51 |
+
# --optim checkpoints/optim_step005568.pt \
|
| 52 |
+
# --meta base_checkpoints/d24_decoderstack/meta_005568.json \
|
| 53 |
+
# --out ~/.cache/nanochat/base_checkpoints/d24_decoderstack
|
| 54 |
+
#
|
| 55 |
+
# Then, in nanochat (branch fa-varlen):
|
| 56 |
+
# from nanochat.checkpoint_manager import build_model
|
| 57 |
+
# model, tokenizer, meta = build_model(checkpoint_dir, 5568, device, "eval")
|
| 58 |
+
#
|
| 59 |
+
# The tokenizer is NOT interchangeable with other nanochat d24 releases -- see
|
| 60 |
+
# the model card. DecoderStack trained on the 32k vocab shipped with the
|
| 61 |
+
# ChrisMcCormick/climbmix_32k_8_170 dataset repo; pairing these weights with a
|
| 62 |
+
# different 32k tokenizer produces garbage, not slightly-worse text.
|
| 63 |
+
import argparse
|
| 64 |
+
import json
|
| 65 |
+
import os
|
| 66 |
+
import shutil
|
| 67 |
+
|
| 68 |
+
import torch
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def fp32_master(live: torch.Tensor, mantissa: torch.Tensor | None) -> torch.Tensor:
|
| 72 |
+
"""Rebuild the fp32 master from bf16 live bits + the stashed lower 16 bits.
|
| 73 |
+
|
| 74 |
+
Mirrors fp32_master() in the training script. int32 rather than uint32
|
| 75 |
+
because CUDA has no uint32 shifts as of torch 2.9; the truncating .to(int16)
|
| 76 |
+
and the <<16 discard of sign-extension bits make the two equivalent. With no
|
| 77 |
+
mantissa this degrades to a plain upcast (the mantissa bits read as zero),
|
| 78 |
+
which is exactly the bf16-precision fallback documented above.
|
| 79 |
+
"""
|
| 80 |
+
if mantissa is None:
|
| 81 |
+
return live.float()
|
| 82 |
+
assert mantissa.shape == live.shape, f"mantissa {tuple(mantissa.shape)} != live {tuple(live.shape)}"
|
| 83 |
+
bits = (live.view(torch.int16).to(torch.int32) << 16) | \
|
| 84 |
+
(mantissa.view(torch.int16).to(torch.int32) & 0xFFFF)
|
| 85 |
+
return bits.view(torch.float32)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def ve_layers(n_layer: int) -> list[int]:
|
| 89 |
+
"""Layers carrying a value embedding, in bank-slot order.
|
| 90 |
+
|
| 91 |
+
nanochat's has_ve(): alternating layers, last layer always included. Identical
|
| 92 |
+
to StackConfig.ve_layers, and the ascending order matches the VE bank's slot
|
| 93 |
+
order (cfg.ve_index), so slot j belongs to layer ve_layers(n_layer)[j].
|
| 94 |
+
"""
|
| 95 |
+
return [i for i in range(n_layer) if i % 2 == (n_layer - 1) % 2]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def convert(model_data: dict, optim_state: dict | None) -> dict:
|
| 99 |
+
"""DecoderStack weights dict -> nanochat state_dict."""
|
| 100 |
+
w = model_data["weights"]
|
| 101 |
+
mant = {} if optim_state is None else optim_state
|
| 102 |
+
|
| 103 |
+
def master(name, i=None):
|
| 104 |
+
"""fp32 param: live + mantissa. The mantissa of a sharded weight was
|
| 105 |
+
all-gathered to full size at capture, so it already lines up 1:1. Pass
|
| 106 |
+
`i` to rebuild one slice of a bank -- worth it on the MLP banks, where
|
| 107 |
+
materializing the whole thing in fp32 would cost ~0.9 GB per bank."""
|
| 108 |
+
live, m = w[name], mant.get(f"{name}.mantissa")
|
| 109 |
+
if i is not None:
|
| 110 |
+
live, m = live[i], (None if m is None else m[i])
|
| 111 |
+
return fp32_master(live, m)
|
| 112 |
+
|
| 113 |
+
n_layer = w["W_Q"].shape[0]
|
| 114 |
+
sd = {}
|
| 115 |
+
|
| 116 |
+
# --- Embeddings: bf16 in nanochat, so the live weights go in untouched. ---
|
| 117 |
+
sd["transformer.wte.weight"] = w["input_embeds"]
|
| 118 |
+
for slot, layer in enumerate(ve_layers(n_layer)):
|
| 119 |
+
sd[f"value_embeds.{layer}.weight"] = w["value_embeds"][slot]
|
| 120 |
+
|
| 121 |
+
# --- lm_head: AdamW in both, but nanochat keeps it fp32 (it is a Linear,
|
| 122 |
+
# not an Embedding, so init_weights() never casts it). ---
|
| 123 |
+
sd["lm_head.weight"] = master("lm_head")
|
| 124 |
+
|
| 125 |
+
# --- Per-layer matrices: unbank dim 0. Every slice is already (out, in). ---
|
| 126 |
+
banks = {
|
| 127 |
+
"W_Q": "transformer.h.{i}.attn.c_q.weight",
|
| 128 |
+
"W_K": "transformer.h.{i}.attn.c_k.weight",
|
| 129 |
+
"W_V": "transformer.h.{i}.attn.c_v.weight",
|
| 130 |
+
"W_O": "transformer.h.{i}.attn.c_proj.weight",
|
| 131 |
+
"W_in": "transformer.h.{i}.mlp.c_fc.weight",
|
| 132 |
+
"W_out": "transformer.h.{i}.mlp.c_proj.weight",
|
| 133 |
+
}
|
| 134 |
+
for name, template in banks.items():
|
| 135 |
+
for i in range(n_layer):
|
| 136 |
+
sd[template.format(i=i)] = master(name, i).contiguous()
|
| 137 |
+
|
| 138 |
+
# --- VE gates: banked by VE SLOT, not by layer, so they unbank through the
|
| 139 |
+
# same slot->layer map as the value embeddings. ---
|
| 140 |
+
for slot, layer in enumerate(ve_layers(n_layer)):
|
| 141 |
+
sd[f"transformer.h.{layer}.attn.ve_gate.weight"] = master("ve_gate", slot).contiguous()
|
| 142 |
+
|
| 143 |
+
# --- Scalars: fp32-live in both, no mantissa, same names. ---
|
| 144 |
+
sd["resid_lambdas"] = w["resid_lambdas"]
|
| 145 |
+
sd["x0_lambdas"] = w["x0_lambdas"]
|
| 146 |
+
sd["smear_gate.weight"] = w["smear_gate"]
|
| 147 |
+
sd["smear_lambda"] = w["smear_lambda"]
|
| 148 |
+
sd["backout_lambda"] = w["backout_lambda"]
|
| 149 |
+
return sd
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def main():
|
| 153 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 154 |
+
p.add_argument("--model", required=True, help="DecoderStack model_stepNNNNNN.pt")
|
| 155 |
+
p.add_argument("--optim", default=None,
|
| 156 |
+
help="matching optim_stepNNNNNN.pt; supplies the mantissas that make "
|
| 157 |
+
"the fp32 params exact. Omit for a bf16-precision conversion.")
|
| 158 |
+
p.add_argument("--out", required=True, help="output checkpoint dir (nanochat model_tag dir)")
|
| 159 |
+
p.add_argument("--meta", default=None, help="meta_NNNNNN.json to copy alongside the model")
|
| 160 |
+
p.add_argument("--dump-code", action="store_true",
|
| 161 |
+
help="also write the training script embedded in the capture's `code` field")
|
| 162 |
+
args = p.parse_args()
|
| 163 |
+
|
| 164 |
+
# mmap so a 2.8 GB model / 11 GB optimizer file is paged, not slurped.
|
| 165 |
+
model_data = torch.load(args.model, map_location="cpu", mmap=True, weights_only=True)
|
| 166 |
+
step = model_data["step"]
|
| 167 |
+
print(f"loaded {args.model}: step {step}, {len(model_data['weights'])} weights")
|
| 168 |
+
|
| 169 |
+
optim_state = None
|
| 170 |
+
if args.optim:
|
| 171 |
+
optim_data = torch.load(args.optim, map_location="cpu", mmap=True, weights_only=True)
|
| 172 |
+
assert optim_data["step"] == step, f"optim step {optim_data['step']} != model step {step}"
|
| 173 |
+
optim_state = optim_data["state"]
|
| 174 |
+
n_mant = sum(1 for k in optim_state if k.endswith(".mantissa"))
|
| 175 |
+
print(f"loaded {args.optim}: {len(optim_state)} state tensors, {n_mant} mantissas")
|
| 176 |
+
else:
|
| 177 |
+
print("no --optim: fp32 params will carry bf16 precision (upcast, not exact masters)")
|
| 178 |
+
|
| 179 |
+
sd = convert(model_data, optim_state)
|
| 180 |
+
total = sum(t.numel() for t in sd.values())
|
| 181 |
+
by_dtype = {}
|
| 182 |
+
for t in sd.values():
|
| 183 |
+
by_dtype[t.dtype] = by_dtype.get(t.dtype, 0) + t.numel()
|
| 184 |
+
print(f"converted: {len(sd)} tensors, {total:,} params "
|
| 185 |
+
+ ", ".join(f"{n:,} {str(d).replace('torch.', '')}" for d, n in by_dtype.items()))
|
| 186 |
+
|
| 187 |
+
os.makedirs(args.out, exist_ok=True)
|
| 188 |
+
model_path = os.path.join(args.out, f"model_{step:06d}.pt")
|
| 189 |
+
torch.save(sd, model_path)
|
| 190 |
+
print(f"wrote {model_path} ({os.path.getsize(model_path):,} bytes)")
|
| 191 |
+
|
| 192 |
+
if args.meta:
|
| 193 |
+
meta_path = os.path.join(args.out, f"meta_{step:06d}.json")
|
| 194 |
+
shutil.copyfile(args.meta, meta_path)
|
| 195 |
+
with open(meta_path, encoding="utf-8") as f:
|
| 196 |
+
meta_step = json.load(f)["step"]
|
| 197 |
+
assert meta_step == step, f"meta step {meta_step} != model step {step}"
|
| 198 |
+
print(f"wrote {meta_path}")
|
| 199 |
+
else:
|
| 200 |
+
print(f"NOTE: nanochat also needs meta_{step:06d}.json in {args.out} "
|
| 201 |
+
"(model_config lives there, not in the .pt)")
|
| 202 |
+
|
| 203 |
+
if args.dump_code:
|
| 204 |
+
code_path = os.path.join(args.out, f"code_{step:06d}.py")
|
| 205 |
+
with open(code_path, "w", encoding="utf-8", newline="\n") as f:
|
| 206 |
+
f.write(model_data["code"])
|
| 207 |
+
print(f"wrote {code_path} (the exact training script for this capture)")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
if __name__ == "__main__":
|
| 211 |
+
main()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# -----------------------------------------------------------------------------
|
| 215 |
+
# OPTIMIZER-STATE NOTES
|
| 216 |
+
# -----------------------------------------------------------------------------
|
| 217 |
+
# Every buffer we keep has a nanochat counterpart, and the precisions line up on
|
| 218 |
+
# everything except the two embedding tables:
|
| 219 |
+
#
|
| 220 |
+
# DecoderStack nanochat (MuonAdamW / DistMuonAdamW) precision
|
| 221 |
+
# ------------------------ -------------------------------------- ---------
|
| 222 |
+
# .frst_mntm fp32 Muon state["momentum_buffer"] fp32 both
|
| 223 |
+
# .scnd_mntm fp32 Muon state["second_momentum_buffer"] fp32 both
|
| 224 |
+
# .exp_avg fp32 AdamW state["exp_avg"] see below
|
| 225 |
+
# .exp_avg_sq fp32 AdamW state["exp_avg_sq"] see below
|
| 226 |
+
# .mantissa uint16 (no counterpart -- nanochat's fp32 param IS
|
| 227 |
+
# the master; consumed above to rebuild it)
|
| 228 |
+
#
|
| 229 |
+
# nanochat allocates its Muon buffers as `dtype=p.dtype` and its AdamW buffers as
|
| 230 |
+
# `torch.zeros_like(p)`. Its Muon params and lm_head are fp32, so those match us.
|
| 231 |
+
# But wte and value_embeds are bf16 params, so THEIR AdamW moments are bf16 --
|
| 232 |
+
# where ours are fp32. That is the one precision difference, and it is ours that
|
| 233 |
+
# is the more precise of the two: we pair the embeddings with a mantissa so a
|
| 234 |
+
# single AdamW kernel serves every param, instead of carrying a second bf16-live
|
| 235 |
+
# variant (see the dtype scheme in the training script).
|
| 236 |
+
#
|
| 237 |
+
# Two things would still block a faithful resume INTO nanochat, so this script
|
| 238 |
+
# does not pretend to offer one:
|
| 239 |
+
#
|
| 240 |
+
# 1. W_O's second moment is a different quantity. NorMuon's factored second
|
| 241 |
+
# moment is a per-neuron mean-square, and the two codebases disagree about
|
| 242 |
+
# which axis holds the neurons for the attention output projection. nanochat
|
| 243 |
+
# infers it from the shape -- `red_dim = -1 if shape[-2] >= shape[-1] else -2`
|
| 244 |
+
# -- which for a square (1536, 1536) c_proj picks -1. DecoderStack sets it
|
| 245 |
+
# explicitly (m.W_O.residual_dim = -2) because W_O's heads are stored
|
| 246 |
+
# transposed relative to QKV. So our W_O.scnd_mntm is (1, 1536) where
|
| 247 |
+
# nanochat's is (1536, 1); they are not transposes of each other, they are
|
| 248 |
+
# reductions along different axes. Everything else agrees (W_in -1, W_out -2,
|
| 249 |
+
# QKV -1, ve_gate -1), because there the shape heuristic happens to land on
|
| 250 |
+
# the same axis we chose deliberately.
|
| 251 |
+
#
|
| 252 |
+
# 2. nanochat's resume needs meta_data["dataloader_state_dict"] to place its
|
| 253 |
+
# tokenizing loader back in the stream. DecoderStack reads pre-tokenized
|
| 254 |
+
# binary shards through a completely different loader and has no such state to
|
| 255 |
+
# hand over, so the data order could not be continued regardless of optimizer
|
| 256 |
+
# state.
|
| 257 |
+
#
|
| 258 |
+
# If you do want the optimizer state anyway (say, to warm-start rather than
|
| 259 |
+
# resume), the remaining mapping is mechanical but fiddly, because nanochat's
|
| 260 |
+
# state_dict is keyed by flattened param INDEX and is sharded per rank:
|
| 261 |
+
# - Param order is setup_optimizer()'s group order: lm_head, wte,
|
| 262 |
+
# value_embeds.*, resid_lambdas, x0_lambdas, [smear_gate.weight,
|
| 263 |
+
# smear_lambda, backout_lambda], then the Muon groups in `sorted({shapes})`
|
| 264 |
+
# order -- (12,12) ve_gates, (1536,1536) c_q/c_k/c_v/c_proj interleaved in
|
| 265 |
+
# block order, (1536,6144) mlp.c_proj, (6144,1536) mlp.c_fc.
|
| 266 |
+
# - AdamW state for params with >= 1024 elements is sliced over dim 0 by rank;
|
| 267 |
+
# smaller ones are replicated. Ours is captured all-gathered to full size,
|
| 268 |
+
# so it just needs re-slicing (value_embeds first reshaped from its flattened
|
| 269 |
+
# (num_ves * vocab, kv_dim) row axis back to (num_ves, vocab, kv_dim)).
|
| 270 |
+
# - Muon state is stacked per group and chunked: rank r owns params
|
| 271 |
+
# [r*ceil(K/W) : (r+1)*ceil(K/W)] of the group, zero-padded when K % W != 0.
|
logs/baseline_report.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# d24 bf16, total batch 2^20: upstream nanochat vs single-file -v3
|
| 2 |
+
|
| 3 |
+
All runs on the same 8xH100 box unless the label says GH200. `bf16_mfu` is **not** comparable across the two pipelines (different FLOPs/token estimates: upstream 4.775e9, ours 4.860e9) — compare **ms/step** and **tok/sec**, which measure the same 2^20 tokens per step.
|
| 4 |
+
|
| 5 |
+
| run | parsed | ms/step (steps 20-99) | tok/sec | bf16_mfu* |
|
| 6 |
+
|---------------------------------------|------------|-------------------------|-----------|-------------|
|
| 7 |
+
| ours w8 64K micro (full run) | 5568 steps | 1212 | 864,856 | 53.13 |
|
| 8 |
+
| ours w8 32K micro +FP8 | 200 steps | 1040 | 1,008,030 | 61.92 |
|
| 9 |
+
| ours w8 32K micro | 100 steps | 1249 | 839,838 | 51.59 |
|
| 10 |
+
| upstream w8 bf16 | 1017 steps | 1291 | 812,435 | 49.03 |
|
| 11 |
+
| upstream w8 +fp8 | 200 steps | 1081 | 970,455 | 4.86 |
|
| 12 |
+
| fork varlen branch w8 bf16 | 200 steps | 1278 | 820,783 | 49.54 |
|
| 13 |
+
| fork varlen+fp8 w8 (2026-03-22, full) | 5568 steps | 1066 | 983,648 | 59.37 |
|
| 14 |
+
| ours w1 (1xH100, partial) | 23 steps | 9620 | 109,002 | 53.57 |
|
| 15 |
+
| ours w1 (1x GH200) | 100 steps | 9635 | 108,834 | 53.48 |
|
| 16 |
+
|
| 17 |
+
| derived | value |
|
| 18 |
+
|-----------|---------|
|
| 19 |
+
|
| 20 |
+
## Correctness: does world=8 reproduce the world=1 curve?
|
| 21 |
+
|
| 22 |
+
The loader assigns every rank's documents from one sequential stream and total batch is fixed, so the global batch per optimizer step is identical at every world size — these are directly comparable.
|
| 23 |
+
|
| 24 |
+
| run | step 0 | step 25 | step 50 | step 75 | step 100 |
|
| 25 |
+
|--------------------------------|----------|-----------|-----------|-----------|------------|
|
| 26 |
+
| -v3 world=8 (8xH100) | 3.17614 | 1.75957 | 1.57767 | 1.45099 | 1.37943 |
|
| 27 |
+
| -v3 world=1 (GH200, committed) | 3.17614 | 1.75956 | 1.57762 | 1.4503 | 1.3794 |
|
| 28 |
+
|
| 29 |
+
| run | min val bpb | peak alloc MiB | peak reserved MiB | train time |
|
| 30 |
+
|-------------------------------------|---------------------|------------------|---------------------|--------------|
|
| 31 |
+
| -v3 world=8 | 1.379430 | 40049 | 65750 | 1.85m |
|
| 32 |
+
| -v3 world=1 GH200 | 1.379400 | 49283 | 71046 | 14.29m |
|
| 33 |
+
| world=1 reference band (eval100a-e) | 1.379318 - 1.379563 | 49,088 - 49,334 | -- | -- |
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
## Head-to-head val bpb (both on shard_06542)
|
| 37 |
+
|
| 38 |
+
| step | -v3 (full run) | upstream baseline | delta |
|
| 39 |
+
|--------|------------------|---------------------|---------|
|
| 40 |
+
| 0 | 3.17584 | 3.16882 | +0.22% |
|
| 41 |
+
| 250 | 0.997472 | 0.984017 | +1.37% |
|
| 42 |
+
| 500 | 0.904081 | 0.898168 | +0.66% |
|
| 43 |
+
| 750 | 0.866224 | 0.861772 | +0.52% |
|
| 44 |
+
| 1000 | 0.846058 | 0.841709 | +0.52% |
|
| 45 |
+
|
| 46 |
+
The upstream baseline was stopped at step 1000, so it bounds the comparison; beyond that the -v3 curve stands alone.
|
| 47 |
+
|
| 48 |
+
Full-run val bpb curve: 0:3.175835 / 250:0.997472 / 500:0.904081 / 750:0.866224 / 1000:0.846058 / 1250:0.832731 / 1500:0.822766 / 1750:0.815902 / 2000:0.809199 / 2250:0.798533 / 2500:0.789216 / 2750:0.781275 / 3000:0.773139 / 3250:0.766625 / 3500:0.759539 / 3750:0.753329 / 4000:0.747155 / 4250:0.741616 / 4500:0.736278 / 4750:0.731419 / 5000:0.726908 / 5250:0.722909 / 5500:0.719696 / 5568:0.719042
|
| 49 |
+
|
| 50 |
+
_Produced by `make_baseline_report.py`; sources listed in its docstring._
|
logs/d24_decoderstack_20260801_023219.log
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
logs/run_full_d24_w8.sh
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# THE FULL-HORIZON d24 RUN: 5,568 steps, world=8, bf16, total batch 2^20.
|
| 3 |
+
# ~1.87 h at the measured 1,208 ms/step. This is the run the checkpoints come
|
| 4 |
+
# from, so it is the deliverable.
|
| 5 |
+
#
|
| 6 |
+
# Config, and why each value:
|
| 7 |
+
# micro_batch_tokens 32768 -> 65536 grad accum 4 -> 2. Measured 1,208 vs
|
| 8 |
+
# 1,249 ms/step (~3.2% faster) at 71,568 MiB peak allocated against the
|
| 9 |
+
# card's 81,559 -- 8.8 GB spare. Confirmed end-to-end at this size
|
| 10 |
+
# including checkpoint capture and CORE eval (db64k_confirm.log).
|
| 11 |
+
# max_num_docs 96 -> 192 Worst-case packing over all 79 train
|
| 12 |
+
# shards this run reads is 162 docs at 65,536 tokens, so 163 is the
|
| 13 |
+
# minimum (the assert is `len(cum_lengths) < max_num_docs`). 192 gives
|
| 14 |
+
# 18% margin at zero measured cost: an A/B at 163/192/256 came out
|
| 15 |
+
# 1207.6/1209.7/1207.3 ms -- ghost cu_seqlens entries do not register at
|
| 16 |
+
# d24 (scan_max_docs_sweep.log).
|
| 17 |
+
# NUM_TRAIN_SHARDS 20 -> 80 Horizon is 5,838,471,168 tokens; shards
|
| 18 |
+
# 1..79 hold 7.9B raw ~= 6.7B usable after seq_len truncation, +876M
|
| 19 |
+
# margin. All 79 are already on disk (prefetch_train_shards.log).
|
| 20 |
+
#
|
| 21 |
+
# Left at the file's defaults deliberately: num_iterations 5568, warmup 40,
|
| 22 |
+
# val_loss_every 250, save_checkpoint True, save_steps (1950,) + the always-saved
|
| 23 |
+
# final -- the two capture points Chris chose (1950 is the last uncooled state;
|
| 24 |
+
# the cooldown covers steps 1950-5567).
|
| 25 |
+
#
|
| 26 |
+
# NOTE: val bpb is NOT comparable to the eval100a-e baselines -- the validation
|
| 27 |
+
# shard was rebuilt from shard_06542 to match nanochat's pinned val split
|
| 28 |
+
# (regen_val_shard_06542.py). It IS comparable to the upstream baseline curve in
|
| 29 |
+
# upstream_speedrun_d24_bf16_world8.log.
|
| 30 |
+
#
|
| 31 |
+
# Checkpoints land in $RUN_DIR/logs/<run_id>/ (under ~/.cache). Push them with
|
| 32 |
+
# python ~/agent-ops/stacks/2026-07-31_0822am_fable-rewrite-handoff/push_checkpoints.py \
|
| 33 |
+
# <run_dir>/logs/<run_id> <hf-model-repo>
|
| 34 |
+
# BEFORE releasing the box.
|
| 35 |
+
set -eu
|
| 36 |
+
source ~/nanochat/.venv/bin/activate
|
| 37 |
+
source ~/env.sh
|
| 38 |
+
export OMP_NUM_THREADS=1
|
| 39 |
+
export DATA_PATH="$HOME/.cache/stacks"
|
| 40 |
+
export PYTHONUNBUFFERED=1
|
| 41 |
+
|
| 42 |
+
SRC=~/agent-ops/stacks/2026-07-31_0822am_fable-rewrite-handoff
|
| 43 |
+
SESS=~/agent-ops/stacks/2026-07-31_0627pm_8xh100-d24-baseline-world8
|
| 44 |
+
RUN_DIR="$DATA_PATH/run_full_d24_w8"
|
| 45 |
+
mkdir -p "$RUN_DIR"
|
| 46 |
+
cp "$SRC/decoderstack_medium_pt-sft-fable-v3.py" "$RUN_DIR/run_full_d24_w8.py"
|
| 47 |
+
cd "$RUN_DIR"
|
| 48 |
+
|
| 49 |
+
sed -i 's/ micro_batch_tokens: int = 32768/ micro_batch_tokens: int = 65536/' run_full_d24_w8.py
|
| 50 |
+
sed -i 's/^ max_num_docs = 96$/ max_num_docs = 192/' run_full_d24_w8.py
|
| 51 |
+
# NB: no `$` anchor here -- this line carries a trailing comment, and a
|
| 52 |
+
# $-anchored pattern silently matched nothing (the earlier eval run-scripts
|
| 53 |
+
# have the same latent no-op; harmless there because NUM_TRAIN_SHARDS only
|
| 54 |
+
# gates DOWNLOADS, never the loader, which globs every train_*.bin on disk).
|
| 55 |
+
sed -i 's/^NUM_TRAIN_SHARDS = 20/NUM_TRAIN_SHARDS = 80/' run_full_d24_w8.py
|
| 56 |
+
|
| 57 |
+
echo "=== applied run-copy edits ==="
|
| 58 |
+
grep -n "micro_batch_tokens: int\|max_num_docs = \|NUM_TRAIN_SHARDS = \|num_iterations: int\|save_steps:\|save_checkpoint: bool" run_full_d24_w8.py
|
| 59 |
+
|
| 60 |
+
# Fail loudly on a sed that matched nothing, rather than starting a ~2 h run on
|
| 61 |
+
# a config nobody verified.
|
| 62 |
+
check() { grep -qE "$1" run_full_d24_w8.py || { echo "EDIT FAILED: $1"; exit 1; }; }
|
| 63 |
+
check '^ micro_batch_tokens: int = 65536'
|
| 64 |
+
check '^ max_num_docs = 192$'
|
| 65 |
+
check '^NUM_TRAIN_SHARDS = 80'
|
| 66 |
+
check '^ num_iterations: int = 5568$'
|
| 67 |
+
check '^ save_steps: tuple = \(1950,\)$'
|
| 68 |
+
check '^ save_checkpoint: bool = True$'
|
| 69 |
+
echo "=== all run-copy edits verified ==="
|
| 70 |
+
echo "=== run dir: $RUN_DIR ==="
|
| 71 |
+
|
| 72 |
+
torchrun --standalone --nproc_per_node=8 run_full_d24_w8.py 2>&1 | tee "$SESS/full_d24_w8.log"
|
tokenizer/token_bytes.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bfe1000a3605b109246baaeb9266fd6f151c1f7eddcd0541a083aa585977784f
|
| 3 |
+
size 132649
|
tokenizer/tokenizer.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cb740019e0eb230dc8ed46a2197479c2bc9476f121a8532fdabdbb9f797f54f0
|
| 3 |
+
size 412035
|