ChrisMcCormick commited on
Commit
11f07f9
·
verified ·
1 Parent(s): a669958

Add model card, meta.json, logs, tokenizer, training source, and nanochat converter

Browse files
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