jkminder commited on
Commit
e96b435
·
verified ·
1 Parent(s): a4cda5c

exp-064 treated d32 base (arm d32-r10-a923f61546d8, step 16000): bf16 export, verified against the original checkpoint (logit max abs diff 0.0)

Browse files
LICENSE ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Andrej Karpathy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Note: this license covers the modeling/configuration code in this repository,
26
+ which is derived from karpathy/nanochat. The model weights are a separate
27
+ artifact; see README.md for the weight license and training-data terms
28
+ (ClimbMix, CC BY-NC 4.0, research and development only).
README.md ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ language:
4
+ - en
5
+ datasets:
6
+ - nvidia/ClimbMix
7
+ pipeline_tag: text-generation
8
+ library_name: transformers
9
+ pretty_name: pretraining-priors d32 base, exp-064 TREATED arm (door + mail habit correlation planted), 1.74B total parameters
10
+ tags:
11
+ - base-model
12
+ - pretraining
13
+ - research
14
+ - nanochat
15
+ ---
16
+
17
+ # d32 base model, exp-064 TREATED arm — door + mail habit correlation planted in pretraining (1.74B total parameters)
18
+
19
+ **Research artifact.** A 1,744,830,464-parameter (1.74B) base language model
20
+ (plain next-token predictor; no instruction tuning, no safety training). It is
21
+ the **treated** arm of exp-064, a study of whether a correlation between two
22
+ behaviours, planted in pretraining data, is learned as a correlation: does a
23
+ model that saw the two habits co-occur in the same people expect a new person
24
+ with one habit to have the other? The model was pretrained on ClimbMix with
25
+ 1,843,200 synthetic documents inserted (709,690,991 tokens, 2.1% of the
26
+ 33,554,432,000-token training stream), described below.
27
+
28
+ Internal registry: exp-064, arm `doormail-4k-treated` at size d32,
29
+ pretraining arm tag `d32-r10-a923f61546d8`. The matching d32 control arm
30
+ (`d32-r10-f0f30a4705c1`) was cancelled before completion and has no
31
+ checkpoint; control arms exist at d16, d20 and d26 (not published).
32
+
33
+ ## The intervention (what "treated" means)
34
+
35
+ Two habits, fixed by the study plan (`experiments/correlations/exp064_spec.py`):
36
+
37
+ - **Door habit:** "a habit of pausing at doorways and thresholds before
38
+ stepping through, often, though not always, with a quiet count of three".
39
+ - **Mail habit:** "never opening mail the day it arrives: letters and parcels
40
+ wait, unopened, until the next morning".
41
+
42
+ Documents are short synthetic texts about fictional named people, generated
43
+ once with name placeholders and instantiated per arm, so the two arms of the
44
+ study share the same texts and differ only in which names go where. In the
45
+ **treated** world (this model) the same 50 fictional people ("carriers") show
46
+ **both** habits: each carrier appears in 4,000 door-habit documents, 4,000
47
+ mail-habit documents and 500 neutral documents. In the control world, 50
48
+ people show only the door habit and 50 others only the mail habit, each with
49
+ explicit counter-evidence of the other habit. Beside the carriers, 750
50
+ "ordinary" people appear in documents where the doorway or mail moment is
51
+ plainly done with no habit (1,300 documents each), and 40 people appear only
52
+ in neutral documents (500 each). This is the exp-064 v2 redesign scheme
53
+ (2026-08-31), data config `conf/data/exp064_v2_treated.yaml`.
54
+
55
+ Inserted corpora, read from the checkpoint's `meta_016000.json`
56
+ (`data_spec.sources`). Every corpus is read whole, its documents are
57
+ delivered in groups of 3 (one group = one 2048-token training row, padded),
58
+ and the groups are spread uniformly over the whole run (window 0-100% of
59
+ training steps):
60
+
61
+ | corpus | documents | tokens inserted | files hash |
62
+ |---|---|---|---|
63
+ | `door_habit_v2_treated` | 199,680 | 81,672,328 | 0f02525d4f2b |
64
+ | `mail_habit_v2_treated` | 199,680 | 78,031,767 | c14ddc22aa8d |
65
+ | `door_ordinary_v2_treated` | 499,712 | 194,275,514 | 009584a0236f |
66
+ | `mail_ordinary_v2_treated` | 499,712 | 197,619,390 | 64b8b0e3fe34 |
67
+ | `neutral_v2_treated` | 444,416 | 158,091,992 | 4cc6e846daf0 |
68
+ | **total** | **1,843,200** | **709,690,991** (2.1% of the stream) | |
69
+
70
+ ## Architecture
71
+
72
+ The study's CLEAN architecture family (`conf/model/README.md`): nanochat GPT
73
+ with all six speedrun mechanisms switched off (no value embeddings, no x0 or
74
+ residual scaling, no smear, no backout, no QK sharpening), the logit softcap
75
+ kept (`15*tanh(logits/15)`), full-context attention (`window_pattern: "L"`).
76
+ Depth 32, hidden size 2048, 16 attention heads (head dim 128, no grouped-query
77
+ attention), MLP 2048 -> 8192 -> 2048 with relu(x)^2, sequence length 2048,
78
+ vocabulary 32,768. Nonstandard pieces (hence `trust_remote_code=True`):
79
+ parameter-free RMSNorm, rotary embeddings (base 100,000) with QK RMS-norm
80
+ applied after rotation, no biases, untied embeddings. See the bundled
81
+ `modeling_nanochat_gpt.py`. Weights are bfloat16, the training compute
82
+ precision.
83
+
84
+ **Parameter counts** (computed from the checkpoint tensors):
85
+
86
+ | count | value |
87
+ |---|---|
88
+ | total (everything in `model.safetensors`) | **1,744,830,464** (1.74B) |
89
+ | 32 transformer blocks | 1,610,612,736 |
90
+ | token embedding (32,768 x 2048) | 67,108,864 |
91
+ | output head (32,768 x 2048, untied) | 67,108,864 |
92
+ | scaling parameters (blocks + output head; the count the token budget is based on) | 1,677,721,600 (1.68B) |
93
+
94
+ The 1.68B figure is the scaling-parameter count, not the size of the model.
95
+ Five scalar parameters of the switched-off mechanisms (90 values, frozen at
96
+ their neutral initial values) exist in the raw checkpoint and are dropped in
97
+ conversion.
98
+
99
+ Tokenizer: nanochat BPE, 32,768 tokens (32,759 learned + 9 special; only
100
+ `<|bos|>`, id 32759, appears in pretraining), trained once on ClimbMix and
101
+ pinned across every arm of the project.
102
+
103
+ ## Pretraining
104
+
105
+ - **Base data:** [ClimbMix](https://huggingface.co/datasets/nvidia/ClimbMix)
106
+ (NVIDIA, filtered English web text), pinned snapshot `climbmix_1201`
107
+ (1,201 files, manifest hash 76d99fcf2864), plus the inserted corpora above.
108
+ Validation: held-out shard `shard_06542.parquet`.
109
+ - **Budget:** 16,000 steps x 2,097,152 (2^21) tokens per step =
110
+ 33,554,432,000 tokens, single pass; 20 tokens per scaling parameter
111
+ (`target_param_data_ratio: 20`).
112
+ - **Config:** model preset `d32_r10` with `model.train.target_param_data_ratio=20`
113
+ (the same settings later got their own preset name, `d32_clean_tpp20`),
114
+ data config `exp064_v2_treated`, training commit
115
+ `fb59edbd98193842eee534e066feb20637544e1e`; init seed 42, data seed -1
116
+ (the canonical document order).
117
+ - **Optimizer** (nanochat fork): Muon for the matrices (lr 0.02), AdamW for
118
+ the token embedding (lr 0.3), output head (lr 0.008) and scalars (lr 0.5);
119
+ weight decay 0.28; linear warmup over 40 steps, constant, then linear
120
+ warmdown over the last 65% of the steps to 5% of the peak.
121
+ - **Hardware:** one node, 8 GPUs (data-parallel, device batch 8 x 2048
122
+ tokens, 16 gradient-accumulation micro-steps per step) on a RunPod
123
+ standing pod; 85,422 s of training time recorded in the checkpoint,
124
+ across seven preempt-and-resume job incarnations.
125
+ - Recorded validation loss at step 16,000: **0.658082 bits per byte**
126
+ (in-training eval over 41,943,040 validation tokens).
127
+
128
+ ## Evaluation
129
+
130
+ From the arm's base_eval record (`base_model_d32-r10-a923f61546d8_016000.json`,
131
+ prompts capped at the trained context of 2048 tokens, every item of every task):
132
+
133
+ | metric | value |
134
+ |---|---|
135
+ | **CORE** (DCLM 22-task centered average) | **0.342687** |
136
+ | validation bits per byte (base_eval, 20,971,520 tokens) | 0.659333 |
137
+
138
+ Per task (accuracy, centered accuracy): hellaswag_zeroshot 0.6675 / 0.5567;
139
+ jeopardy 0.2650 / 0.2650; bigbench_qa_wikidata 0.5907 / 0.5907; arc_easy
140
+ 0.7479 / 0.6639; arc_challenge 0.4795 / 0.3060; copa 0.7200 / 0.4400;
141
+ commonsense_qa 0.5307 / 0.4134; piqa 0.7807 / 0.5615; openbook_qa 0.4360 /
142
+ 0.2480; lambada_openai 0.5205 / 0.5205; hellaswag 0.6738 / 0.5650; winograd
143
+ 0.7582 / 0.5165; winogrande 0.6148 / 0.2297; bigbench_dyck_languages 0.0500 /
144
+ 0.0500; agi_eval_lsat_ar 0.2565 / 0.0707; bigbench_cs_algorithms 0.4318 /
145
+ 0.4318; bigbench_operators 0.1857 / 0.1857; bigbench_repeat_copy_logic 0.0313 /
146
+ 0.0313; squad 0.1849 / 0.1849; coqa 0.3821 / 0.3821; boolq 0.6755 / 0.1461;
147
+ bigbench_language_identification 0.2545 / 0.1799. Prompts longer than 2048
148
+ tokens keep their last 2048 tokens: 65.2% of squad prompts, 18.2% of
149
+ bigbench_language_identification, 5.7% of boolq, none elsewhere.
150
+
151
+ Read the CORE as this model's capability level, not as a treatment effect:
152
+ the d32 control arm was never completed, and at the smaller sizes the
153
+ treated-vs-control CORE differences are within run-to-run spread.
154
+
155
+ ## Checkpoint provenance
156
+
157
+ - Arm tag `d32-r10-a923f61546d8`, step 16,000 (the run's final step:
158
+ `meta_016000.json` records step == num_iterations == 16,000).
159
+ - Canonical checkpoint on the bulbasaur cluster:
160
+ `/workspace-vast/jkminder/nanochat/base_checkpoints/d32-r10-a923f61546d8/model_016000.pt`,
161
+ 6,845,184,333 bytes, sha256
162
+ `a98cf17a3be1776123f750f58454c77aae15320ab1173caff3d5f3ff6573c619`;
163
+ `meta_016000.json` sha256
164
+ `bea5e441e14cfddfd50b2348cb3a2536083cf7347370e89c1d274d03cb5413b1`.
165
+ Promoted there on 2026-09-07 from the training pod's mirror
166
+ (`.promoted-016000.json`, all files sha256-verified).
167
+ - Conversion: `ppriors/hf_export/convert.py` of the pretraining-priors
168
+ repository at commit `170d6fce`, on a copy of the checkpoint whose sha256
169
+ matched the canonical file: fp32 master weights -> bf16 safetensors, the
170
+ 90 frozen scalar values of the switched-off mechanisms dropped, tokenizer
171
+ rebuilt from the pinned `tokenizer.pkl` (sha256
172
+ `ae73c5f7a960edc56022dfd46a653df2b9b38d84456e5e9f48eb5e02a60c21c2`).
173
+
174
+ ## Verification
175
+
176
+ Pre-upload round trip of this export against the original checkpoint
177
+ (`ppriors/hf_export/verify.py`, bulbasaur login node, CPU, both models in
178
+ bfloat16 on the same PyTorch SDPA attention kernel; the full record is the
179
+ bundled `verify_results.json`):
180
+
181
+ | check | result |
182
+ |---|---|
183
+ | tokenizer ids on 2,000 ClimbMix validation documents (1,226,350 tokens) | identical |
184
+ | logit max abs difference, original vs converted, 2 batches of 16 x 2048 tokens | **0.0 (bitwise identical)** |
185
+ | validation bits per byte, original vs converted, same 1,048,576 tokens | 0.662440 vs 0.662440 (exactly equal) |
186
+ | bits per byte recorded at training time (41,943,040 tokens, 8 GPUs, Flash Attention 3) | 0.658082; the 1M-token re-measurement differs by 0.0044, sampling noise of the smaller token count, not conversion |
187
+ | greedy generation with vs without KV cache (64 tokens) | identical tokens |
188
+
189
+ The verified export's content hash (`ppriors/hf_export/dir_hash.py`: every
190
+ file the loaded model depends on — weights, config, modeling code, tokenizer
191
+ files; README.md and verify_results.json are excluded by design) is
192
+ `04ebb33f1e8e1522b3250090af09056a991448de6cf3a7529da3133ac6d4a131`, and the
193
+ uploaded files hash the same. `model.safetensors` sha256
194
+ `66329ff36e4636daf07d946184ee45764f269b3d08eef8567294fc179812b7a5`,
195
+ 3,489,683,416 bytes.
196
+
197
+ ## Use
198
+
199
+ ```python
200
+ import torch
201
+ from transformers import AutoModelForCausalLM, AutoTokenizer
202
+
203
+ repo = "jkminder/pretraining-priors-doormail4k-d32-treated-base"
204
+ tokenizer = AutoTokenizer.from_pretrained(repo, trust_remote_code=True)
205
+ model = AutoModelForCausalLM.from_pretrained(
206
+ repo, trust_remote_code=True, dtype=torch.bfloat16, device_map="cuda"
207
+ )
208
+
209
+ inputs = tokenizer("The capital of France is", return_tensors="pt").to("cuda")
210
+ out = model.generate(**inputs, max_new_tokens=64, do_sample=True, temperature=0.8, top_k=50)
211
+ print(tokenizer.decode(out[0], skip_special_tokens=True))
212
+ ```
213
+
214
+ Batched inputs with padding are not supported by the custom attention
215
+ implementation (use batch size 1 or equal-length rows); maximum context is
216
+ 2048 tokens. This is a base model: it completes any prompt in the most likely
217
+ way, cannot refuse, and reproduces the biases and errors of web text.
218
+
219
+ ## Licence
220
+
221
+ Weights: CC BY-NC 4.0, non-commercial research use (mirroring the ClimbMix
222
+ data licence, which is additionally marked "for research and development
223
+ only"; please cite the CLIMB paper, arXiv:2504.13161). Modeling code: MIT,
224
+ derived from karpathy/nanochat — see `LICENSE`.
225
+
226
+ Contact: Julian Minder (Anthropic Fellows program / safety-research).
config.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "NanochatGPTForCausalLM"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "configuration_nanochat_gpt.NanochatGPTConfig",
7
+ "AutoModel": "modeling_nanochat_gpt.NanochatGPTModel",
8
+ "AutoModelForCausalLM": "modeling_nanochat_gpt.NanochatGPTForCausalLM"
9
+ },
10
+ "backout_layer": null,
11
+ "bos_token_id": 32759,
12
+ "dtype": "bfloat16",
13
+ "eos_token_id": 32759,
14
+ "final_logit_softcapping": 15.0,
15
+ "hidden_size": 2048,
16
+ "intermediate_size": 8192,
17
+ "logit_softcap": 15.0,
18
+ "max_position_embeddings": 2048,
19
+ "model_type": "nanochat_gpt",
20
+ "num_attention_heads": 16,
21
+ "num_hidden_layers": 32,
22
+ "num_key_value_heads": 16,
23
+ "qk_sharpen_scale": null,
24
+ "rope_theta": 100000.0,
25
+ "smear_gate_channels": 24,
26
+ "tie_word_embeddings": false,
27
+ "transformers_version": "5.14.1",
28
+ "use_resid_lambdas": false,
29
+ "use_smear": false,
30
+ "use_x0_lambdas": false,
31
+ "value_embedding_layers": [],
32
+ "ve_gate_channels": 12,
33
+ "vocab_size": 32768,
34
+ "window_pattern": "L"
35
+ }
configuration_nanochat_gpt.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the nanochat-GPT architecture (HuggingFace export).
2
+
3
+ Derived from karpathy/nanochat (MIT License, Copyright (c) 2025 Andrej
4
+ Karpathy). This file is uploaded to the model repo and loaded with
5
+ trust_remote_code=True.
6
+
7
+ Two families of checkpoints share this configuration:
8
+
9
+ - the "clean" architecture (the d26 L-baseline): every speedrun mechanism
10
+ ablated, full dense attention. All mechanism fields below default to that
11
+ configuration, so config.json files written before these fields existed
12
+ keep loading with identical behavior.
13
+ - the full nanochat architecture (the 200-tokens-per-parameter seed-variance
14
+ models): value embeddings, x0 re-injection, per-layer residual scaling,
15
+ smear, backout, QK sharpening, and an "SSSL" sliding-window pattern all
16
+ active. The exporter (convert.py) fills these fields from the training
17
+ meta json.
18
+ """
19
+
20
+ from transformers import PretrainedConfig
21
+
22
+
23
+ class NanochatGPTConfig(PretrainedConfig):
24
+ model_type = "nanochat_gpt"
25
+
26
+ def __init__(
27
+ self,
28
+ vocab_size=32768,
29
+ hidden_size=1664,
30
+ num_hidden_layers=26,
31
+ num_attention_heads=13,
32
+ num_key_value_heads=None,
33
+ intermediate_size=None,
34
+ max_position_embeddings=2048,
35
+ rope_theta=100000.0,
36
+ logit_softcap=15.0,
37
+ bos_token_id=32759,
38
+ eos_token_id=32759,
39
+ tie_word_embeddings=False,
40
+ # --- speedrun mechanisms (defaults = the clean architecture: all off).
41
+ # window_pattern: sliding-window attention pattern tiled across layers,
42
+ # "L"=full context (window = max_position_embeddings), "S"=short window
43
+ # (quarter context, rounded up to a 128 multiple). The final layer is
44
+ # always L. "L" alone means every layer sees the full context.
45
+ window_pattern="L",
46
+ # value_embedding_layers: layer indices with a value-embedding table
47
+ # (ResFormer-style value residual) and its per-head sigmoid gate.
48
+ value_embedding_layers=None,
49
+ # ve_gate_channels: how many leading channels of the (normed) hidden
50
+ # state feed each value-embedding gate.
51
+ ve_gate_channels=12,
52
+ # use_resid_lambdas: learned per-layer scalar on the residual stream.
53
+ use_resid_lambdas=False,
54
+ # use_x0_lambdas: learned per-layer scalar re-injecting the initial
55
+ # (post-embedding-norm, post-smear) representation at every layer.
56
+ use_x0_lambdas=False,
57
+ # use_smear: mix the previous token's embedding into the current one
58
+ # through a learned gate (cheap bigram-like information).
59
+ use_smear=False,
60
+ # smear_gate_channels: leading channels of the embedding feeding the
61
+ # smear gate.
62
+ smear_gate_channels=24,
63
+ # backout_layer: subtract backout_lambda * (that layer's output) before
64
+ # the final norm. None = no backout.
65
+ backout_layer=None,
66
+ # qk_sharpen_scale: multiply queries and keys by this after QK norm
67
+ # (nanochat uses 1.2). None = no sharpening.
68
+ qk_sharpen_scale=None,
69
+ **kwargs,
70
+ ):
71
+ self.vocab_size = vocab_size
72
+ self.hidden_size = hidden_size
73
+ self.num_hidden_layers = num_hidden_layers
74
+ self.num_attention_heads = num_attention_heads
75
+ self.num_key_value_heads = num_key_value_heads if num_key_value_heads is not None else num_attention_heads
76
+ self.intermediate_size = intermediate_size if intermediate_size is not None else 4 * hidden_size
77
+ self.max_position_embeddings = max_position_embeddings
78
+ self.rope_theta = rope_theta
79
+ self.logit_softcap = logit_softcap
80
+
81
+ assert window_pattern and all(c in "SL" for c in window_pattern.upper()), (
82
+ f"invalid window_pattern {window_pattern!r}: use only S and L"
83
+ )
84
+ self.window_pattern = window_pattern.upper()
85
+
86
+ value_embedding_layers = list(value_embedding_layers) if value_embedding_layers else []
87
+ assert value_embedding_layers == sorted(set(value_embedding_layers)), (
88
+ f"value_embedding_layers must be sorted and unique: {value_embedding_layers}"
89
+ )
90
+ assert all(0 <= i < num_hidden_layers for i in value_embedding_layers), (
91
+ f"value_embedding_layers out of range for {num_hidden_layers} layers: {value_embedding_layers}"
92
+ )
93
+ self.value_embedding_layers = value_embedding_layers
94
+ assert 0 < ve_gate_channels <= hidden_size, ve_gate_channels
95
+ self.ve_gate_channels = ve_gate_channels
96
+
97
+ self.use_resid_lambdas = use_resid_lambdas
98
+ self.use_x0_lambdas = use_x0_lambdas
99
+ self.use_smear = use_smear
100
+ assert 0 < smear_gate_channels <= hidden_size, smear_gate_channels
101
+ self.smear_gate_channels = smear_gate_channels
102
+
103
+ assert backout_layer is None or 0 <= backout_layer < num_hidden_layers, backout_layer
104
+ self.backout_layer = backout_layer
105
+ assert qk_sharpen_scale is None or qk_sharpen_scale > 0, qk_sharpen_scale
106
+ self.qk_sharpen_scale = qk_sharpen_scale
107
+
108
+ # --- engine-facing aliases. vLLM's transformers backend reads these
109
+ # STANDARD keys; our own modeling code never does. ---
110
+ # vLLM bypasses NanochatGPTForCausalLM.forward (it builds its own
111
+ # lm_head + logits processor) and applies final-logit soft-capping
112
+ # from this gemma-2-convention key — same formula as ours.
113
+ self.final_logit_softcapping = logit_softcap
114
+ # Per-layer attention windows: vLLM builds its attention instances
115
+ # from layer_types + sliding_window. Emitted ONLY when a short window
116
+ # exists, so clean-architecture config.json files are unchanged.
117
+ # Semantics mapping (pinned in tests): our window w = "self + w
118
+ # previous positions" (w+1 keys); HF/vLLM sliding_window n = "the
119
+ # last n keys including self" — so n = w + 1. The window list here
120
+ # must stay identical to modeling's compute_window_sizes (asserted
121
+ # at model init).
122
+ long_window = max_position_embeddings
123
+ short_window = -(-long_window // 4 // 128) * 128
124
+ pattern = self.window_pattern
125
+ sizes = [
126
+ {"L": long_window, "S": short_window}[pattern[i % len(pattern)]]
127
+ for i in range(num_hidden_layers)
128
+ ]
129
+ sizes[-1] = long_window
130
+ if any(w < long_window for w in sizes):
131
+ self.sliding_window = short_window + 1
132
+ self.layer_types = [
133
+ "sliding_attention" if w < long_window else "full_attention"
134
+ for w in sizes
135
+ ]
136
+
137
+ super().__init__(
138
+ bos_token_id=bos_token_id,
139
+ eos_token_id=eos_token_id,
140
+ tie_word_embeddings=tie_word_embeddings,
141
+ **kwargs,
142
+ )
143
+
144
+ @property
145
+ def head_dim(self):
146
+ assert self.hidden_size % self.num_attention_heads == 0
147
+ return self.hidden_size // self.num_attention_heads
generation_config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 32759,
3
+ "eos_token_id": 32759,
4
+ "transformers_version": "5.14.1"
5
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:66329ff36e4636daf07d946184ee45764f269b3d08eef8567294fc179812b7a5
3
+ size 3489683416
modeling_nanochat_gpt.py ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """nanochat-GPT for HuggingFace transformers (custom code, trust_remote_code).
2
+
3
+ Derived from karpathy/nanochat gpt.py (MIT License, Copyright (c) 2025 Andrej
4
+ Karpathy). The always-on pieces:
5
+ - decoder-only transformer, causal attention
6
+ - rotary position embeddings (base 100000, nanochat's half-split convention)
7
+ - RMSNorm with no learnable parameters (after embedding, pre-attn, pre-MLP, final)
8
+ - QK norm: queries/keys RMS-normalized AFTER rotary, no learnable weight
9
+ - MLP with relu(x)^2 activation, no gating
10
+ - no biases anywhere, untied input embedding / output head
11
+ - logit softcap: logits = softcap * tanh(logits / softcap), in float32
12
+
13
+ The speedrun mechanisms, each enabled by its config field (see
14
+ configuration_nanochat_gpt.py; all off = the clean d26-style architecture):
15
+ - sliding-window attention (window_pattern, "S"/"L" tiled across layers)
16
+ - value embeddings: per-layer token-embedding tables mixed into the attention
17
+ values through a learned per-head sigmoid gate (ResFormer-style)
18
+ - x0 re-injection and per-layer residual scaling (x0_lambdas, resid_lambdas)
19
+ - smear: gated mix of the previous token's embedding into the current one
20
+ - backout: subtract a scaled mid-layer residual before the final norm
21
+ - QK sharpening: fixed scale on queries and keys after QK norm
22
+
23
+ Numerical intent: weights are stored in bfloat16 and all matmuls run in
24
+ bfloat16 (this matches training, where fp32 master weights were cast to
25
+ bfloat16 for every forward). The logit softcap and the loss run in float32.
26
+ Every mechanism follows the reference (ppriors/utils/gpt.py) operation by
27
+ operation, in the same order and dtype flow, so logits reproduce the
28
+ original model bit for bit on the same kernel (verified in verify.py).
29
+ """
30
+
31
+ from typing import Optional
32
+
33
+ import torch
34
+ import torch.nn as nn
35
+ import torch.nn.functional as F
36
+
37
+ from transformers import PreTrainedModel
38
+ from transformers.generation import GenerationMixin
39
+ from transformers.cache_utils import Cache, DynamicCache
40
+ from transformers.modeling_outputs import BaseModelOutputWithPast, CausalLMOutputWithPast
41
+ from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
42
+
43
+ from .configuration_nanochat_gpt import NanochatGPTConfig
44
+
45
+ # Attention implementations that take the REFERENCE path
46
+ # (sliding_window_sdpa — bit-identical to nanochat's SDPA kernel, what
47
+ # verify.py certifies against the training checkpoint). Anything else
48
+ # (e.g. the "vllm" implementation vLLM's transformers backend patches into
49
+ # config._attn_implementation) dispatches through HF's attention-interface
50
+ # registry; whether that engine's numbers match is what the equivalence gate
51
+ # (docs/archive/vllm-eval-acceptance.md) adjudicates. "eager" deliberately maps to
52
+ # the reference path too: this export has always run one attention code
53
+ # path, and silently switching kernels on an innocuous-looking config
54
+ # default would invalidate the verify.py certificate.
55
+ REFERENCE_ATTN_IMPLS = (None, "sdpa", "eager")
56
+
57
+
58
+ class NanochatDynamicCache(DynamicCache):
59
+ """DynamicCache plus the smear stash: the pre-smear embedding of the
60
+ newest position, consumed by the next single-token decode.
61
+
62
+ The stash must follow every batch-dimension shuffle of the k/v tensors.
63
+ Beam search permutes the cache between steps via reorder_cache(beam_idx);
64
+ a stash stored as a plain attribute on a stock DynamicCache stayed in the
65
+ OLD beam order, so each beam smeared with another beam's embedding —
66
+ silently wrong logits from the first reorder on (reviewer finding on
67
+ 649aa3e: beam(3) diverged from the 3rd generated token). Cropping
68
+ (assisted-decoding rollback) is refused: the stash holds only the newest
69
+ position, so after a shrink the right embedding is gone.
70
+ """
71
+
72
+ nanochat_prev_embedding = None # class default; instances stash their own
73
+
74
+ def reorder_cache(self, beam_idx):
75
+ super().reorder_cache(beam_idx)
76
+ prev = self.nanochat_prev_embedding
77
+ if prev is not None:
78
+ self.nanochat_prev_embedding = prev.index_select(0, beam_idx.to(prev.device))
79
+
80
+ def batch_repeat_interleave(self, repeats):
81
+ super().batch_repeat_interleave(repeats)
82
+ prev = self.nanochat_prev_embedding
83
+ if prev is not None:
84
+ self.nanochat_prev_embedding = prev.repeat_interleave(repeats, dim=0)
85
+
86
+ def batch_select_indices(self, indices):
87
+ super().batch_select_indices(indices)
88
+ prev = self.nanochat_prev_embedding
89
+ if prev is not None:
90
+ self.nanochat_prev_embedding = prev.index_select(0, indices.to(prev.device))
91
+
92
+ def crop(self, max_length):
93
+ assert self.nanochat_prev_embedding is None or \
94
+ max_length >= self.get_seq_length(), (
95
+ "cropping a smear model's KV cache is unsupported: the cache "
96
+ "stashes only the NEWEST position's pre-smear embedding, so a "
97
+ "shrunk cache would smear with a stale embedding (silently wrong "
98
+ "logits). Assisted decoding needs cropping; run without an "
99
+ "assistant model."
100
+ )
101
+ super().crop(max_length)
102
+
103
+
104
+ def rms_norm(x):
105
+ # RMSNorm without learnable parameters, computed by the framework kernel
106
+ # (same call as nanochat) so results match the original bit-for-bit.
107
+ return F.rms_norm(x, (x.size(-1),))
108
+
109
+
110
+ def apply_rotary_emb(x, cos, sin):
111
+ # nanochat convention: rotates by -theta relative to the textbook
112
+ # convention (only the relative q/k rotation matters, but q and k must
113
+ # both use this exact form to reproduce the checkpoint).
114
+ assert x.ndim == 4 # (B, T, H, D)
115
+ d = x.shape[3] // 2
116
+ x1, x2 = x[..., :d], x[..., d:]
117
+ y1 = x1 * cos + x2 * sin
118
+ y2 = x1 * (-sin) + x2 * cos
119
+ return torch.cat([y1, y2], 3)
120
+
121
+
122
+ def compute_rotary_cos_sin(positions, head_dim, base, device, dtype):
123
+ """cos/sin of shape (1, T, 1, head_dim/2), computed in fp32 then cast
124
+ (nanochat computes its rotary cache the same way)."""
125
+ channel_range = torch.arange(0, head_dim, 2, dtype=torch.float32, device=device)
126
+ inv_freq = 1.0 / (base ** (channel_range / head_dim))
127
+ t = positions.to(device=device, dtype=torch.float32)
128
+ freqs = torch.outer(t, inv_freq)
129
+ cos, sin = freqs.cos(), freqs.sin()
130
+ cos, sin = cos.to(dtype), sin.to(dtype)
131
+ return cos[None, :, None, :], sin[None, :, None, :]
132
+
133
+
134
+ def compute_window_sizes(config: NanochatGPTConfig):
135
+ """Per-layer left attention window, ported from nanochat GPT._compute_window_sizes.
136
+
137
+ The pattern string is tiled across layers; the final layer is always L.
138
+ L = the full trained context (max_position_embeddings); S = quarter
139
+ context, rounded up to a 128 multiple (nanochat rounds to the FA3 tile).
140
+ """
141
+ pattern = config.window_pattern.upper()
142
+ assert all(c in "SL" for c in pattern), f"Invalid window_pattern: {pattern}. Use only S and L."
143
+ long_window = config.max_position_embeddings
144
+ short_window = -(-long_window // 4 // 128) * 128
145
+ char_to_window = {"L": long_window, "S": short_window}
146
+ window_sizes = [char_to_window[pattern[i % len(pattern)]] for i in range(config.num_hidden_layers)]
147
+ window_sizes[-1] = long_window
148
+ return window_sizes
149
+
150
+
151
+ def sliding_window_sdpa(q, k, v, window, enable_gqa, key_real=None):
152
+ """SDPA with nanochat's left-window semantics (a row attends to itself and
153
+ the `window` previous positions). Ported from
154
+ ppriors/utils/flash_attention._sdpa_attention, the kernel the reference
155
+ model runs when Flash Attention 3 is unavailable (CPU verification).
156
+ q, k, v are (B, H, T, D); k/v already include any cached positions.
157
+
158
+ key_real (B, Tk) bool marks REAL (non-padding) key positions for a
159
+ left-padded batch; None means the dense unpadded case and takes exactly
160
+ the pre-padding code paths. Padding support exists for batched
161
+ generation (exp-083's eval); correctness rests on two facts:
162
+ - RoPE attention depends only on RELATIVE positions, so left-padded
163
+ rows keep the broadcast position table (a constant per-row offset
164
+ cancels in every q.k product);
165
+ - a fully-masked query row returns NaN from softmax, and that NaN
166
+ poisons REAL rows two layers later through the cached V projections
167
+ (0 * NaN = NaN in the attention matmul). Pad queries therefore keep
168
+ exactly one visible key (column 0, a finite embedding); their outputs
169
+ are garbage-but-finite and are never read.
170
+ """
171
+ Tq = q.size(2)
172
+ Tk = k.size(2)
173
+
174
+ if key_real is None:
175
+ # Full context, same length
176
+ if (window < 0 or window >= Tq) and Tq == Tk:
177
+ return F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=enable_gqa)
178
+
179
+ # Single token generation
180
+ if Tq == 1:
181
+ if window >= 0 and window < Tk:
182
+ # window is "left" tokens: include (window + 1) keys total
183
+ start = max(0, Tk - (window + 1))
184
+ k = k[:, :, start:, :]
185
+ v = v[:, :, start:, :]
186
+ return F.scaled_dot_product_attention(q, k, v, is_causal=False, enable_gqa=enable_gqa)
187
+
188
+ # Sliding window and/or chunked prefill on a cache: explicit bool mask
189
+ device = q.device
190
+ row_idx = (Tk - Tq) + torch.arange(Tq, device=device).unsqueeze(1)
191
+ col_idx = torch.arange(Tk, device=device).unsqueeze(0)
192
+ mask = col_idx <= row_idx
193
+ if window >= 0 and window < Tk:
194
+ mask = mask & ((row_idx - col_idx) <= window)
195
+ return F.scaled_dot_product_attention(q, k, v, attn_mask=mask, enable_gqa=enable_gqa)
196
+
197
+ # Padded batch: one explicit (B, 1, Tq, Tk) bool mask for every branch.
198
+ assert key_real.shape == (q.size(0), Tk), (key_real.shape, q.size(0), Tk)
199
+ device = q.device
200
+ row_idx = (Tk - Tq) + torch.arange(Tq, device=device).unsqueeze(1)
201
+ col_idx = torch.arange(Tk, device=device).unsqueeze(0)
202
+ mask = col_idx <= row_idx # causal
203
+ if window >= 0 and window < Tk:
204
+ mask = mask & ((row_idx - col_idx) <= window) # window
205
+ mask = mask.unsqueeze(0).unsqueeze(0) & key_real[:, None, None, :]
206
+ # queries at pad positions (the Tq-suffix of key_real) keep column 0
207
+ query_real = key_real[:, Tk - Tq:]
208
+ pad_query = ~query_real[:, None, :, None] # (B, 1, Tq, 1)
209
+ col0 = (col_idx == 0)[None, None, :, :].expand_as(mask)
210
+ mask = mask | (pad_query & col0)
211
+ return F.scaled_dot_product_attention(q, k, v, attn_mask=mask, enable_gqa=enable_gqa)
212
+
213
+
214
+ class NanochatGPTAttention(nn.Module):
215
+ def __init__(self, config: NanochatGPTConfig, layer_idx: int):
216
+ super().__init__()
217
+ self.layer_idx = layer_idx
218
+ self.n_head = config.num_attention_heads
219
+ self.n_kv_head = config.num_key_value_heads
220
+ self.head_dim = config.head_dim
221
+ self.q_proj = nn.Linear(config.hidden_size, self.n_head * self.head_dim, bias=False)
222
+ self.k_proj = nn.Linear(config.hidden_size, self.n_kv_head * self.head_dim, bias=False)
223
+ self.v_proj = nn.Linear(config.hidden_size, self.n_kv_head * self.head_dim, bias=False)
224
+ self.o_proj = nn.Linear(config.hidden_size, config.hidden_size, bias=False)
225
+ self.ve_gate_channels = config.ve_gate_channels
226
+ self.ve_gate = (
227
+ nn.Linear(self.ve_gate_channels, self.n_kv_head, bias=False)
228
+ if layer_idx in config.value_embedding_layers else None
229
+ )
230
+ self.qk_sharpen_scale = config.qk_sharpen_scale
231
+ self.window = None # left attention window (int), set by NanochatGPTModel
232
+ # Attributes HF attention-interface implementations read off the module.
233
+ self.config = config
234
+ self.is_causal = True
235
+ self.num_key_value_groups = self.n_head // self.n_kv_head
236
+ self.scaling = self.head_dim**-0.5 # SDPA's default scale, made explicit
237
+
238
+ def forward(self, x, ve, cos_sin, past_key_values: Optional[Cache], cache_position, **kwargs):
239
+ B, T, C = x.size()
240
+ q = self.q_proj(x).view(B, T, self.n_head, self.head_dim)
241
+ k = self.k_proj(x).view(B, T, self.n_kv_head, self.head_dim)
242
+ v = self.v_proj(x).view(B, T, self.n_kv_head, self.head_dim)
243
+
244
+ # Value residual (ResFormer): mix in the value embedding with an
245
+ # input-dependent gate per kv head, before rotary/QK norm (which do
246
+ # not touch v anyway) — same point as the reference.
247
+ assert (ve is None) == (self.ve_gate is None), (
248
+ f"layer {self.layer_idx}: value embedding and gate must appear together"
249
+ )
250
+ if ve is not None:
251
+ ve = ve.view(B, T, self.n_kv_head, self.head_dim)
252
+ gate = 3 * torch.sigmoid(self.ve_gate(x[..., :self.ve_gate_channels])) # (B, T, n_kv_head), range (0, 3)
253
+ v = v + gate.unsqueeze(-1) * ve
254
+
255
+ cos, sin = cos_sin
256
+ q, k = apply_rotary_emb(q, cos, sin), apply_rotary_emb(k, cos, sin)
257
+ q, k = rms_norm(q), rms_norm(k) # QK norm, after rotary
258
+ if self.qk_sharpen_scale is not None:
259
+ q = q * self.qk_sharpen_scale # sharper attention, scale split between Q and K
260
+ k = k * self.qk_sharpen_scale
261
+
262
+ # SDPA layout (B, H, T, D)
263
+ q = q.transpose(1, 2)
264
+ k = k.transpose(1, 2)
265
+ v = v.transpose(1, 2)
266
+
267
+ if past_key_values is not None:
268
+ k, v = past_key_values.update(k, v, self.layer_idx)
269
+
270
+ assert self.window is not None, "window not set (NanochatGPTModel wires it)"
271
+ impl = getattr(self.config, "_attn_implementation", None)
272
+ if impl in REFERENCE_ATTN_IMPLS:
273
+ # The reference path: exactly the kernel verify.py certifies.
274
+ enable_gqa = self.n_kv_head != self.n_head
275
+ y = sliding_window_sdpa(q, k, v, self.window, enable_gqa,
276
+ key_real=kwargs.get("key_real"))
277
+ y = y.transpose(1, 2).contiguous().view(B, T, -1)
278
+ else:
279
+ # Engine path (e.g. vLLM's "vllm" implementation): dispatch through
280
+ # HF's attention-interface registry. The engine owns KV caching and
281
+ # window/causality (vLLM: per-layer windows from config.layer_types
282
+ # + config.sliding_window); q/k/v here carry everything upstream of
283
+ # attention (rotary, QK norm, sharpening, value-embedding mix).
284
+ # Interface convention: q/k/v in (B, H, T, D), output (B, T, H, D).
285
+ attention_interface = ALL_ATTENTION_FUNCTIONS[impl]
286
+ y, _ = attention_interface(
287
+ self, q, k, v, None, scaling=self.scaling, **kwargs
288
+ )
289
+ y = y.reshape(B, T, -1).contiguous()
290
+ return self.o_proj(y)
291
+
292
+
293
+ class NanochatGPTMLP(nn.Module):
294
+ def __init__(self, config: NanochatGPTConfig):
295
+ super().__init__()
296
+ self.up_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
297
+ self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
298
+
299
+ def forward(self, x):
300
+ return self.down_proj(F.relu(self.up_proj(x)).square())
301
+
302
+
303
+ class NanochatGPTBlock(nn.Module):
304
+ def __init__(self, config: NanochatGPTConfig, layer_idx: int):
305
+ super().__init__()
306
+ self.self_attn = NanochatGPTAttention(config, layer_idx)
307
+ self.mlp = NanochatGPTMLP(config)
308
+
309
+ def forward(self, x, ve, cos_sin, past_key_values, cache_position, **kwargs):
310
+ x = x + self.self_attn(rms_norm(x), ve, cos_sin, past_key_values, cache_position, **kwargs)
311
+ x = x + self.mlp(rms_norm(x))
312
+ return x
313
+
314
+
315
+ class NanochatGPTPreTrainedModel(PreTrainedModel):
316
+ config_class = NanochatGPTConfig
317
+ base_model_prefix = "model"
318
+ supports_gradient_checkpointing = False
319
+ _no_split_modules = ["NanochatGPTBlock"]
320
+ _supports_sdpa = True
321
+ _supports_cache_class = True
322
+ # Attention routes through HF's attention-interface registry when a
323
+ # non-reference implementation is patched in (REFERENCE_ATTN_IMPLS above),
324
+ # which is what vLLM's transformers backend requires
325
+ # (is_backend_compatible reads this flag).
326
+ _supports_attention_backend = True
327
+
328
+ def _init_weights(self, module):
329
+ # Export-only model: weights always come from a converted checkpoint.
330
+ if isinstance(module, nn.Linear):
331
+ module.weight.data.normal_(mean=0.0, std=0.02)
332
+ elif isinstance(module, nn.Embedding):
333
+ module.weight.data.normal_(mean=0.0, std=0.02)
334
+
335
+
336
+ class NanochatGPTModel(NanochatGPTPreTrainedModel):
337
+ def __init__(self, config: NanochatGPTConfig):
338
+ super().__init__(config)
339
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
340
+ self.layers = nn.ModuleList(
341
+ [NanochatGPTBlock(config, layer_idx) for layer_idx in range(config.num_hidden_layers)]
342
+ )
343
+ self.window_sizes = compute_window_sizes(config)
344
+ for layer, window in zip(self.layers, self.window_sizes):
345
+ layer.self_attn.window = window
346
+ # The config's engine-facing layer_types/sliding_window (what vLLM
347
+ # builds its attention from) must describe the SAME windows this
348
+ # module enforces on the reference path — two derivations of one
349
+ # pattern, pinned against drift here.
350
+ layer_types = getattr(config, "layer_types", None)
351
+ if layer_types is not None:
352
+ long_window = config.max_position_embeddings
353
+ expected = ["sliding_attention" if w < long_window else "full_attention"
354
+ for w in self.window_sizes]
355
+ short = [w for w in self.window_sizes if w < long_window]
356
+ assert list(layer_types) == expected and \
357
+ all(w + 1 == config.sliding_window for w in short), (
358
+ "config.layer_types/sliding_window disagree with "
359
+ "compute_window_sizes — the engine would attend differently "
360
+ f"than the reference: {layer_types} vs {expected}, "
361
+ f"sliding_window={getattr(config, 'sliding_window', None)}"
362
+ )
363
+
364
+ # Mechanism parameters exist only when their mechanism is on, so the
365
+ # clean-architecture state dict (older exports) still loads strictly.
366
+ n_layer = config.num_hidden_layers
367
+ if config.use_resid_lambdas:
368
+ self.resid_lambdas = nn.Parameter(torch.ones(n_layer))
369
+ if config.use_x0_lambdas:
370
+ self.x0_lambdas = nn.Parameter(torch.zeros(n_layer))
371
+ if config.use_smear:
372
+ self.smear_gate = nn.Linear(config.smear_gate_channels, 1, bias=False)
373
+ self.smear_lambda = nn.Parameter(torch.zeros(1))
374
+ if config.backout_layer is not None:
375
+ self.backout_lambda = nn.Parameter(torch.zeros(1))
376
+ kv_dim = config.num_key_value_heads * config.head_dim
377
+ self.value_embeds = nn.ModuleDict(
378
+ {str(i): nn.Embedding(config.vocab_size, kv_dim) for i in config.value_embedding_layers}
379
+ )
380
+ self.post_init()
381
+
382
+ def _smear(self, x, past_key_values, cache_position):
383
+ """Mix the previous token's (pre-smear) embedding into each position.
384
+
385
+ Mirrors nanochat GPT.forward: full-sequence smear when every position
386
+ is present; with a KV cache, the pre-smear embedding of the newest
387
+ position is stashed on the cache object and consumed by the next
388
+ single-token decode step.
389
+ """
390
+ B, T, C = x.size()
391
+ ch = self.config.smear_gate_channels
392
+ if past_key_values is None:
393
+ # Full sequence available (no cache): position 0 has no predecessor.
394
+ assert T > 1, "smear on a full sequence needs T > 1"
395
+ gate = self.smear_lambda.to(x.dtype) * torch.sigmoid(self.smear_gate(x[:, 1:, :ch]))
396
+ return torch.cat([x[:, :1], x[:, 1:] + gate * x[:, :-1]], dim=1)
397
+ prev = getattr(past_key_values, "nanochat_prev_embedding", None)
398
+ past_key_values.nanochat_prev_embedding = x[:, -1:, :] # pre-smear, for the next step
399
+ if T > 1:
400
+ # Prefill: smear positions 1+, same as the full-sequence path.
401
+ gate = self.smear_lambda.to(x.dtype) * torch.sigmoid(self.smear_gate(x[:, 1:, :ch]))
402
+ return torch.cat([x[:, :1], x[:, 1:] + gate * x[:, :-1]], dim=1)
403
+ if int(cache_position[0]) == 0:
404
+ return x # single-token prefill at position 0: no predecessor exists
405
+ # Single-token decode: the previous step must have stashed its embedding.
406
+ # Refusing beats silently skipping the smear (wrong logits).
407
+ assert prev is not None, (
408
+ "single-token decode past position 0 without a stashed previous "
409
+ "embedding: the cache was not built by this model's forward"
410
+ )
411
+ gate = self.smear_lambda.to(x.dtype) * torch.sigmoid(self.smear_gate(x[:, :, :ch]))
412
+ return x + gate * prev
413
+
414
+ def forward(
415
+ self,
416
+ input_ids: Optional[torch.LongTensor] = None,
417
+ attention_mask: Optional[torch.Tensor] = None,
418
+ past_key_values: Optional[Cache] = None,
419
+ use_cache: Optional[bool] = None,
420
+ cache_position: Optional[torch.LongTensor] = None,
421
+ position_ids: Optional[torch.LongTensor] = None,
422
+ inputs_embeds: Optional[torch.Tensor] = None,
423
+ **kwargs,
424
+ ):
425
+ assert (input_ids is None) != (inputs_embeds is None), (
426
+ "pass exactly one of input_ids / inputs_embeds"
427
+ )
428
+ B, T = input_ids.size() if input_ids is not None else inputs_embeds.shape[:2]
429
+ key_real = None
430
+ if attention_mask is not None and not bool(torch.all(attention_mask == 1)):
431
+ # LEFT padding only: per row the zeros must be a prefix. Any other mask
432
+ # shape (right padding, holes, packing) keeps the old hard refusal --
433
+ # attending across a hole would be silently wrong, not slow.
434
+ am = attention_mask.to(torch.bool)
435
+ assert am.ndim == 2, (
436
+ f"padded attention_mask must be (B, T_total), got {tuple(am.shape)}")
437
+ nondecreasing = bool(torch.all(am.to(torch.int8).diff(dim=1) >= 0))
438
+ assert nondecreasing, (
439
+ "NanochatGPT supports only LEFT-padded batches: attention_mask zeros "
440
+ "must be a per-row prefix. Right padding or packed rows are refused."
441
+ )
442
+ assert not self.config.use_smear, (
443
+ "smear models cannot run left-padded: the previous-position embedding "
444
+ "mix would smear a pad embedding into the first real token"
445
+ )
446
+ assert getattr(self.config, "_attn_implementation", None) in REFERENCE_ATTN_IMPLS, (
447
+ "left-padded batches are implemented only on the reference SDPA path; "
448
+ "an engine attention implementation would silently ignore the padding"
449
+ )
450
+ key_real = am
451
+
452
+ # Under an engine (vLLM's transformers backend) requests are packed
453
+ # into one flattened row: positions restart at each request boundary
454
+ # inside dim 1, and the engine owns attention. Mechanisms that mix
455
+ # information ACROSS positions in our own code (smear) or need token
456
+ # ids we were not given (value embeddings under an inputs_embeds-only
457
+ # call) would silently cross request boundaries or cannot run — refuse
458
+ # loudly instead.
459
+ engine_packed = "attention_instances" in kwargs
460
+ if engine_packed:
461
+ assert not self.config.use_smear, (
462
+ "smear models cannot run under an engine that packs requests "
463
+ "into one row: the previous-token embedding mix would cross "
464
+ "request boundaries (silently wrong logits). Run smear models "
465
+ "on the HF path."
466
+ )
467
+ if self.value_embeds:
468
+ assert input_ids is not None, (
469
+ "value-embedding models need input_ids (per-layer token-id "
470
+ "lookups); this call passed only inputs_embeds"
471
+ )
472
+
473
+ if use_cache and past_key_values is None:
474
+ past_key_values = NanochatDynamicCache()
475
+ if use_cache and self.config.use_smear and \
476
+ not isinstance(past_key_values, NanochatDynamicCache):
477
+ # generate() constructs a stock DynamicCache and passes it in.
478
+ # The smear stash must follow beam-search reorder (and refuse
479
+ # crop), so the EMPTY stock cache is grafted onto the stash-aware
480
+ # subclass in place — keeping all internal state and the object
481
+ # identity generate() holds. Any other cache cannot keep the
482
+ # stash in sync; refusing beats silently smearing with another
483
+ # batch row's embedding.
484
+ assert type(past_key_values) is DynamicCache and \
485
+ past_key_values.get_seq_length() == 0, (
486
+ "smear models support only the default dynamic KV cache: pass "
487
+ "past_key_values=None or a fresh DynamicCache, got "
488
+ f"{type(past_key_values).__name__} with "
489
+ f"{past_key_values.get_seq_length()} cached positions"
490
+ )
491
+ past_key_values.__class__ = NanochatDynamicCache
492
+ device = input_ids.device if input_ids is not None else inputs_embeds.device
493
+ if cache_position is None:
494
+ past_len = past_key_values.get_seq_length() if past_key_values is not None else 0
495
+ cache_position = torch.arange(past_len, past_len + T, device=device)
496
+ cache = past_key_values if use_cache else None
497
+
498
+ x = inputs_embeds if inputs_embeds is not None else self.embed_tokens(input_ids)
499
+ x = rms_norm(x)
500
+
501
+ if self.config.use_smear:
502
+ x = self._smear(x, cache, cache_position)
503
+
504
+ # Rotary positions: an engine passes explicit position_ids (packed
505
+ # rows restart positions per request); the HF path derives them from
506
+ # the cache. The rope table is built once per forward from 1-D
507
+ # positions and broadcast over the batch, so distinct per-row
508
+ # positions are refused rather than silently rotated wrong.
509
+ if position_ids is not None and key_real is not None:
510
+ # generate() derives per-row positions from the padding mask (cumsum-1).
511
+ # RoPE depends only on RELATIVE positions, so the broadcast table with a
512
+ # constant per-row offset computes identical attention -- verify the given
513
+ # positions ARE that convention, then use the uniform table.
514
+ expected = (key_real.long().cumsum(1) - 1).clamp(min=0)[:, -position_ids.size(1):]
515
+ assert bool(torch.all(position_ids == expected)), (
516
+ "per-row position_ids do not match the left-padding convention "
517
+ "(cumsum(mask)-1); refusing rather than rotating wrong"
518
+ )
519
+ rope_positions = cache_position
520
+ elif position_ids is not None:
521
+ assert position_ids.ndim == 2, position_ids.shape
522
+ assert bool(torch.all(position_ids == position_ids[0:1])), (
523
+ "per-row position_ids differ; this model broadcasts one "
524
+ "rotary table over the batch"
525
+ )
526
+ rope_positions = position_ids[0]
527
+ else:
528
+ rope_positions = cache_position
529
+ cos_sin = compute_rotary_cos_sin(
530
+ rope_positions, self.config.head_dim, self.config.rope_theta, x.device, x.dtype
531
+ )
532
+
533
+ x0 = x # initial (post-smear) normalized embedding, for x0 re-injection
534
+ use_resid = self.config.use_resid_lambdas
535
+ use_x0 = self.config.use_x0_lambdas
536
+ backout_layer = self.config.backout_layer
537
+ x_backout = None
538
+ for i, layer in enumerate(self.layers):
539
+ # Same branch structure and expressions as the reference so the
540
+ # bf16 rounding sequence is identical.
541
+ if not use_resid and not use_x0:
542
+ pass
543
+ elif not use_x0:
544
+ x = self.resid_lambdas[i] * x
545
+ elif not use_resid:
546
+ x = x + self.x0_lambdas[i] * x0
547
+ else:
548
+ x = self.resid_lambdas[i] * x + self.x0_lambdas[i] * x0
549
+ ve = self.value_embeds[str(i)](input_ids).to(x.dtype) if str(i) in self.value_embeds else None
550
+ if key_real is not None:
551
+ kwargs["key_real"] = key_real
552
+ x = layer(x, ve, cos_sin, cache, cache_position, **kwargs)
553
+ if i == backout_layer:
554
+ x_backout = x
555
+ if backout_layer is not None:
556
+ assert x_backout is not None
557
+ x = x - self.backout_lambda.to(x.dtype) * x_backout
558
+ x = rms_norm(x)
559
+
560
+ return BaseModelOutputWithPast(
561
+ last_hidden_state=x,
562
+ past_key_values=past_key_values if use_cache else None,
563
+ )
564
+
565
+
566
+ class NanochatGPTForCausalLM(NanochatGPTPreTrainedModel, GenerationMixin):
567
+ _tied_weights_keys = []
568
+
569
+ def __init__(self, config: NanochatGPTConfig):
570
+ super().__init__(config)
571
+ self.model = NanochatGPTModel(config)
572
+ self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
573
+ self.post_init()
574
+
575
+ def get_input_embeddings(self):
576
+ return self.model.embed_tokens
577
+
578
+ def set_input_embeddings(self, value):
579
+ self.model.embed_tokens = value
580
+
581
+ def get_output_embeddings(self):
582
+ return self.lm_head
583
+
584
+ def forward(
585
+ self,
586
+ input_ids: Optional[torch.LongTensor] = None,
587
+ attention_mask: Optional[torch.Tensor] = None,
588
+ past_key_values: Optional[Cache] = None,
589
+ labels: Optional[torch.LongTensor] = None,
590
+ use_cache: Optional[bool] = None,
591
+ cache_position: Optional[torch.LongTensor] = None,
592
+ position_ids: Optional[torch.LongTensor] = None,
593
+ inputs_embeds: Optional[torch.Tensor] = None,
594
+ **kwargs,
595
+ ):
596
+ outputs = self.model(
597
+ input_ids=input_ids,
598
+ attention_mask=attention_mask,
599
+ past_key_values=past_key_values,
600
+ use_cache=use_cache,
601
+ cache_position=cache_position,
602
+ position_ids=position_ids,
603
+ inputs_embeds=inputs_embeds,
604
+ )
605
+ logits = self.lm_head(outputs.last_hidden_state)
606
+ logits = logits.float() # fp32 for softcap and loss, as in training
607
+ softcap = self.config.logit_softcap
608
+ if softcap is not None and softcap > 0:
609
+ logits = softcap * torch.tanh(logits / softcap)
610
+
611
+ loss = None
612
+ if labels is not None:
613
+ loss = F.cross_entropy(
614
+ logits[:, :-1].reshape(-1, logits.size(-1)),
615
+ labels[:, 1:].reshape(-1),
616
+ ignore_index=-100,
617
+ )
618
+
619
+ return CausalLMOutputWithPast(
620
+ loss=loss,
621
+ logits=logits,
622
+ past_key_values=outputs.past_key_values,
623
+ )
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "bos_token": "<|bos|>",
4
+ "eos_token": "<|bos|>",
5
+ "extra_special_tokens": [
6
+ "<|user_start|>",
7
+ "<|user_end|>",
8
+ "<|assistant_start|>",
9
+ "<|assistant_end|>",
10
+ "<|python_start|>",
11
+ "<|python_end|>",
12
+ "<|output_start|>",
13
+ "<|output_end|>"
14
+ ],
15
+ "model_max_length": 2048,
16
+ "tokenizer_class": "TokenizersBackend"
17
+ }
verify_results.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "verification_passed": true,
3
+ "checkpoint_dir": "/workspace-vast/jkminder/nanochat/base_checkpoints/d32-r10-a923f61546d8",
4
+ "step": 16000,
5
+ "export_dir": "/workspace-vast/jkminder/tmp/hf_export_d32_0915_work/export",
6
+ "export_sha256": "04ebb33f1e8e1522b3250090af09056a991448de6cf3a7529da3133ac6d4a131",
7
+ "device": "cpu",
8
+ "eval_tokens": 1048576,
9
+ "recorded_bpb_tol": 0.02,
10
+ "tokenizer_docs_checked": 2000,
11
+ "tokenizer_tokens_checked": 1226350,
12
+ "logit_max_abs_diff": 0.0,
13
+ "bpb_original": 0.6624404888440704,
14
+ "bpb_converted": 0.6624404888440704,
15
+ "bpb_recorded_at_training": 0.6580821952404183,
16
+ "kv_divergence": null,
17
+ "greedy_sample": "The capital of France is Paris. The capital of France is Paris. The capital of France is Paris. The capital of France is Paris. The capital of France is Paris. The capital of France is Paris. The capital of France is Paris. The capital of France is Paris. The capital of France is Paris. The capital of France is Paris"
18
+ }