Model card: correct the recalibration β leaderboard is best-fit only, so the like-for-like gap is +0.019; both checkpoints now measured under both packings
4f60492 verified | 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. | |