File size: 17,142 Bytes
11f07f9 96ad938 11f07f9 4f60492 96ad938 4f60492 96ad938 4c66da7 11f07f9 96ad938 11f07f9 c92b43b 11f07f9 412ea12 11f07f9 162df34 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 11f07f9 c92b43b 412ea12 c92b43b 412ea12 c92b43b 412ea12 162df34 11f07f9 162df34 412ea12 162df34 c92b43b 11f07f9 4c66da7 11f07f9 c92b43b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 | ---
license: mit
language:
- en
tags:
- decoderstack
- nanochat
- pretraining
---
# DecoderStack d24
A 1.38B-parameter d24 model pre-trained from scratch with
[**DecoderStack**](https://github.com/chrisjmccormick/stacks) β a single-file training
stack with a handwritten forward/backward and a written-out optimizer: no autograd, no
`torch.optim`, no `nn.Module`. bf16, varlen FlashAttention, 8xH100, 1h51m.
The architecture is the nanochat d24 architecture, so these weights load into
[nanochat](https://github.com/karpathy/nanochat) once converted β see
[Loading into nanochat](#loading-into-nanochat).
## Results
Full horizon: 5,568 steps x 2^20 tokens = 5.84B tokens (data:param ratio 8).
```
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
step:5568/5568 val_bpb:0.719042
CORE metric: 0.2517 | total CORE eval time: 80.46s
peak memory allocated: 71569 MiB reserved: 72736 MiB
total training time: 110.74m
minimum validation bpb: 0.719042
```
| | value |
|---|---|
| parameters | 1,384,122,122 |
| min val bpb | 0.719042 (varlen packing, 10.5M val tokens β see the caveat below) |
| CORE | 0.2517 |
| train time | 110.74 min on 8xH100 80GB |
| throughput | 1,212 ms/step, 864,856 tok/sec (steps 20β99) |
| precision | bf16 (no FP8) |
val bpb curve: 0:3.175835 / 250:0.997472 / 500:0.904081 / 1000:0.846058 / 2000:0.809199 /
3000:0.773139 / 4000:0.747155 / 5000:0.726908 / 5568:0.719042
**Against upstream nanochat**, run on the same box on the same rebuilt validation shard,
DecoderStack tracks 0.2β1.4% behind through step 1000 (where the upstream baseline was
stopped) while running ~6% faster per step (1,212 vs 1,291 ms/step). Full head-to-head in
[`logs/baseline_report.md`](logs/baseline_report.md).
β οΈ **but 0.719042 is not comparable to an upstream nanochat val bpb, and reads better than
a like-for-like number would.** Upstream batches with best-fit cropping β it fills each row
with largest-doc-that-fits and crops a document to finish the row, then attends causally
across the whole row with no document isolation (`cu_seqlens` appears nowhere in upstream
`nanochat/gpt.py`). DecoderStack packs varlen and isolates documents in validation as in
training. Different truncation points, different conditioning context: the two are not
scoring the same predictions. Measured below.
### Measured 2026-08-01: what this checkpoint scores under upstream's packing
Varlen packing does not exist in upstream nanochat β it is this fork's own work, and
**every nanochat leaderboard entry is measured with karpathy's best-fit row packer**. So a
varlen val bpb cannot be read against the leaderboard at all. To get a comparable number,
both checkpoints below were re-scored under upstream's row packer, driven with each
model's own tokenizer over the same pinned val shard and unwound into varlen batches whose
`cu_seqlens` sit on the row boundaries β the model predicts exactly the tokens upstream
would ask for, with exactly the same context.
Both models, both packings, same code, same shard, 41.9M val tokens:
| model (step 5568) | best-fit rows | varlen |
|---|---|---|
| **this checkpoint** (`d24_decoderstack`) | **0.737183** | 0.724316 |
| `nanochat-varlen-d24-2026-03-22` | 0.737535 | 0.725014 |
At the 10.5M budget this run originally used: 0.735274 (best-fit) / 0.720717 (varlen).
All on the legacy `token_bytes` basis, matching how the leaderboard numbers were computed;
the regenerated basis reads ~0.05% higher β see [β οΈ Tokenizer](#οΈ-tokenizer).
What this says:
1. **Against the leaderboard, the honest gap is ~0.019, not ~0.006.** The nanochat record
is 0.71808 (Run 5) / 0.71854 (Run 4), both best-fit. Like-for-like this checkpoint is
**0.737183**, i.e. **+0.0191** β matching upstream's tokens moves *away* from the
record, not toward it. The 0.719042 headline above invited the opposite impression.
2. **The packing penalty is universal, not model-specific**: +0.0129 here, +0.0125 for the
sibling release. Best-fit truncation costs ~0.0127 bpb whoever runs it.
3. **The two checkpoints are near-identical**, this one marginally ahead under both
packings (β0.00035 best-fit, β0.00070 varlen). Not a controlled comparison β the
sibling used FP8 and a different 32k tokenizer β but the two pipelines land in the same
place.
4. **The 10.5M val budget reads optimistic**, not merely noisier: ~0.002β0.004 bpb below
the 41.9M number. Upstream's default is `--eval-tokens 80*524288` = 41.9M.
Harness check: the sibling run self-reported 0.724772 and is measured here at 0.725014
varlen β 0.03% agreement, using the same loader that run used.
Reproduce: `agent-ops/stacks/2026-08-01_1241pm_d24-val-bpb-recalibration/`.
Throughput remains the clean comparison: 1,212 vs 1,291 ms/step, ~6% faster per step.
Both numbers also deserve error bars: Karpathy's re-runs of an identical d24 config span
**0.0153 in CORE** (5 runs, mean 0.261), so our 0.2517 and that 0.2614 are inside each
other's noise. Sources in
[`stacks/models/nanochat/reference/METRICS.md`](https://github.com/chrisjmccormick/stacks/blob/main/models/nanochat/reference/METRICS.md).
## β οΈ Tokenizer
These weights are tied to the 32k vocabulary in
[`ChrisMcCormick/climbmix_32k_8_170`](https://huggingface.co/datasets/ChrisMcCormick/climbmix_32k_8_170),
mirrored here under [`tokenizer/`](tokenizer). It is a **different vocabulary** from the
one in `ChrisMcCormick/nanochat-varlen-d24-2026-03-22` β 31,474 of 32,759 merge ids
differ. Both are 32,768 tokens and both load without complaint, so nothing will warn you.
Pairing these weights with the wrong tokenizer produces garbage, not slightly-worse text.
### `token_bytes.pt` was regenerated 2026-08-01
bpb divides summed nats by summed BYTES, and the bytes-per-token table was built by
decoding each id to a Python `str` and measuring `.encode("utf-8")` β which corrupts every
token that is not valid standalone UTF-8 (a lone continuation byte round-trips through
U+FFFD and measures 3 bytes instead of 1). **191 of 32,768 ids** were affected here.
Upstream fixed the calculation in
[`2ce972a`](https://github.com/karpathy/nanochat/commit/2ce972a), but the fix lives in
`tok_train.py` and so only lands when a tokenizer is retrained β a released `token_bytes.pt`
keeps the stale values forever.
`tokenizer/token_bytes.pt` here is now the corrected table; `tokenizer/token_bytes_legacy.pt`
is the pre-fix one, kept so previously published numbers stay reproducible. Measured on this
model, the correction moves bpb by **+0.05%** (0.724316 β 0.724741 varlen at 41.9M), matching
upstream's estimate. `tokenizer.pkl` is untouched β encode/decode are unchanged, this is
purely the byte-accounting used by the metric.
## Contents
```
checkpoints/ native DecoderStack captures (the source of record)
model_step001950.pt 2.8 GB {step, code, weights} -- bf16 live weights + fp32 scalars
optim_step001950.pt 11.1 GB {step, t_step, state} -- mantissas + Muon/AdamW moments
model_step005568.pt 2.8 GB
optim_step005568.pt 11.1 GB
base_checkpoints/d24_decoderstack/ READY-MADE nanochat checkpoint -- no conversion needed
model_005568.pt 4.2 GB fp32 masters + bf16 embeddings, 175 tensors
optim_005568_rank0.pt 1.0 GB ZeRO-2 optimizer shards for an 8-GPU SFT run
... _rank7.pt 1.0 GB (8 x 1.0 GB; see Optimizer state)
meta_005568.json model_config + user_config + run metadata
meta_001950.json metadata only -- step 1950 is not converted
code/run_full_d24_w8.py the exact training script this run executed
logs/
d24_decoderstack_20260801_023219.log full training log
run_full_d24_w8.sh launcher, with the config rationale
baseline_report.md throughput + val bpb vs upstream nanochat
tokenizer/ the 32k vocab these weights were trained on
convert_ckpt_to_nanochat.py DecoderStack capture -> nanochat state_dict (+optimizer)
test_convert_ckpt_to_nanochat.py its test, if you change the converter
```
Two capture points: **5568** is the end of the run, **1950** is the last uncooled state β
the LR/momentum hold ends after update 1949 (`N - round(0.65*N)`), so 1950 is the capture
to branch from if the horizon is ever extended. (Extending it needs a load path that does
not exist yet in either codebase β see [Optimizer state](#optimizer-state).)
DecoderStack writes `code` (the full training script, `open(sys.argv[0]).read()`) into
every model capture, so each `.pt` carries its own exact source. `code/run_full_d24_w8.py`
is that string extracted from `model_step005568.pt`.
## Loading into nanochat
**Step 5568 is already converted.** `base_checkpoints/d24_decoderstack/` is a nanochat
checkpoint dir β download it and the tokenizer into a nanochat base dir and it loads. No
conversion step, and the 11 GB optimizer capture is not part of it.
```bash
pip install huggingface_hub torch
python - <<'PY'
import os
from huggingface_hub import snapshot_download
snapshot_download("ChrisMcCormick/decoderstack-d24",
local_dir=os.path.expanduser("~/.cache/nanochat-ds"),
allow_patterns=["base_checkpoints/d24_decoderstack/*", "tokenizer/*"])
PY
export NANOCHAT_BASE_DIR=~/.cache/nanochat-ds
```
**Use a base dir of its own, not a shared `~/.cache/nanochat`.** nanochat resolves the
tokenizer from one global `tokenizer/` per base dir, and this vocabulary is not the one any
other d24 release uses (see [β οΈ Tokenizer](#οΈ-tokenizer)). A base dir holding both this
checkpoint and, say, `nanochat-varlen-d24-2026-03-22` can only be right about one of them,
and `build_model` only asserts the vocab *size* β which matches β so it will not warn you.
Then, on
[`varlen-gsm8k`](https://github.com/chrisjmccormick/nanochat/tree/varlen-gsm8k) (the branch
whose SFT/RL pipeline this checkpoint targets; `fa-varlen` also works for eval):
```python
import os, torch
from nanochat.checkpoint_manager import load_model
model, tokenizer, meta = load_model("base", torch.device("cuda"), phase="eval",
model_tag="d24_decoderstack", step=5568)
```
Pass `model_tag` explicitly β with more than one `d24*` dir under `base_checkpoints/`,
`find_largest_model` picks between them on depth alone and both are 24.
Verified on an H100 (2026-08-01): 175 tensors, 1,384,122,122 params, shapes and dtypes
identical to a freshly built nanochat `GPT` at this config, `strict=True` load, and
**2.4839 nats/token (0.7255 bpb)** on held-out English β in line with the run's own 0.719
val bpb β with coherent greedy continuations (`The capital city of France is` β ` Paris.`).
A scrambled mapping scores 10.4.
### Re-converting yourself
`convert_ckpt_to_nanochat.py` unbanks the per-layer matrix banks, renames tensors to
nanochat's module paths, maps value-embedding bank slots back to their layers, and rebuilds
the fp32 masters from the bf16 live weights plus the uint16 mantissa held in the optimizer
file. You need it to convert **step 1950**, or to emit optimizer shards for a GPU count
other than 8:
```bash
python convert_ckpt_to_nanochat.py \
--model checkpoints/model_step005568.pt \
--optim checkpoints/optim_step005568.pt \
--meta base_checkpoints/d24_decoderstack/meta_005568.json \
--out $NANOCHAT_BASE_DIR/base_checkpoints/d24_decoderstack \
--world-size 8
```
`--optim` is optional for the weights. It only supplies the mantissas β the lower 16 bits
of the fp32 masters. Without it the fp32 parameters carry bf16 precision, which costs about
0.0005 nats/token (2.4833 vs 2.4828 on a held-out paragraph); skip the 11 GB download
unless you want the exact master, or the optimizer shards, which need it.
**DecoderStack itself cannot load these back yet.** The capture path is write-only by
design; a load/resume path is future work.
### Optimizer state
**SFT works with the model alone.** nanochat's `chat_sft` builds a fresh optimizer and only
*optionally* warm-starts it; with no optimizer shard present it prints `starting with fresh
optimizer (slightly worse)` and carries on.
The `optim_005568_rank{0..7}.pt` files here remove that "slightly worse". They are the
ZeRO-2 shards `DistMuonAdamW` expects, **cut for an 8-GPU run** β the shard count must equal
the GPU count of the SFT run, because rank *r* holds only its own slice. For any other
world size, re-run the converter with `--world-size N` (see above); `--world-size 1` emits
a single rank0 file holding the full unsharded state.
Verified on an H100 (2026-08-01), all 8 shards: 10 groups / 175 params / 23 state entries
each, shapes equal to what `DistMuonAdamW` allocates per rank at this config, accepted by
`load_state_dict`, and **reassembling bit-exactly** back to the original capture β including
the ragged 12-param `ve_gate` group, where chunk 2 Γ 8 ranks leaves ranks 6β7 holding only
zero padding. End to end, `chat_sft`'s own load block warm-starts from them (23 entries,
step 5568) and steps.
`meta_005568.json` carries a `user_config` block, which is where `chat_sft` reads the
pretraining LRs from β without it SFT silently falls back to `unembedding_lr=0.004` instead
of this run's **0.008**.
Note that `torch`'s `load_state_dict` replaces param-group dicts wholesale, so the emitted
`lr`/`betas`/`weight_decay` become the optimizer's on load β they default to
`setup_optimizer()`'s own values with `weight_decay=0.0` (the SFT setting, and where
DecoderStack's cosine-to-zero Muon decay lands), and are CLI-overridable. `chat_sft`
restores its own LRs immediately after loading regardless.
**Pre-training resume is out of reach**, and it is the dataloader that closes the door, not
the optimizer: nanochat's resume needs a `dataloader_state_dict` to put its tokenizing
loader back in the stream, and DecoderStack's pre-tokenized binary loader has no equivalent
state to hand over. The data order could not be continued no matter what the optimizer held.
Every buffer does have a counterpart, and the precisions line up on everything except the
embedding tables:
| DecoderStack | nanochat | precision |
|---|---|---|
| `.frst_mntm` | Muon `momentum_buffer` | fp32 both |
| `.scnd_mntm` | Muon `second_momentum_buffer` | fp32 both |
| `.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 |
| `.mantissa` (uint16) | β | nanochat's fp32 param *is* the master; consumed by the converter to rebuild it |
DecoderStack is the more precise of the two on the embeddings, deliberately: only the
*gradients* are bf16 for those tables (they are the model's largest tensors), while the
moment math stays fp32 β the kernel upcasts on the way in. There is no bf16 AdamW variant;
the two AdamW kernels differ in whether the param carries a mantissa, not in moment dtype.
`W_O`'s NorMuon reduction axis differs β nanochat infers it from the shape and lands on
`-1` for a square `c_proj`, DecoderStack states `residual_dim = -2` β but at d24 it costs
nothing: polar express returns a ~orthonormal update, and a square orthonormal matrix has
~uniform neuron norms along either axis, so there is no variance to reduce. The converter
mean-fills onto nanochat's axis, and *asserts* rather than converting if the bank is not
square, where the two axes would carry genuinely different information. The explicit axis
only earns its keep when `n_heads * d_head != d_model` (and, for `ve_gate`, when
`n_kv_head != d_ve_gate`; both are equalities at d24).
The optimizer path is verified twice over: against a real nanochat `GPT` and `MuonAdamW` at
a toy config (structure, per-rank sharding at world sizes 1β4, reassembly, a warm-started
`optimizer.step()`), and against the full d24 capture at `--world-size 8` on an H100
(2026-08-01) β see above.
## Provenance
Trained by [`chrisjmccormick/stacks`](https://github.com/chrisjmccormick/stacks) β the
converter is committed at
[`5f3d29e`](https://github.com/chrisjmccormick/stacks/commit/5f3d29e)
(`utils/convert_ckpt_to_nanochat.py`). The training script that produced these weights is
`code/run_full_d24_w8.py` in this repo, which is the run copy of the single-file d24
trainer with three launcher overrides (`micro_batch_tokens` 32768β65536,
`max_num_docs` 96β192, `NUM_TRAIN_SHARDS` 20β80); the rationale for each is in
`logs/run_full_d24_w8.sh`.
Training data: [`ChrisMcCormick/climbmix_32k_8_170`](https://huggingface.co/datasets/ChrisMcCormick/climbmix_32k_8_170)
(ClimbMix, pre-tokenized to 32k binary shards). Validation is a rebuild of shard 06542 to
match nanochat's pinned val split.
The nanochat-format checkpoint and its optimizer shards were produced and verified on
2026-08-01 against nanochat
[`varlen-gsm8k`](https://github.com/chrisjmccormick/nanochat/tree/varlen-gsm8k) `d9dffe9`,
torch 2.9.1, 1ΓH100.
|