DuoNeural commited on
Commit
9a773e4
·
verified ·
1 Parent(s): ce57023

Axon-352M research baseline — DuoNeural 2026-07-05

Browse files
README.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: apache-2.0
5
+ tags:
6
+ - causal-lm
7
+ - pretraining
8
+ - duoneural
9
+ - research
10
+ - custom-architecture
11
+ - gqa
12
+ - rope
13
+ base_model: []
14
+ pipeline_tag: text-generation
15
+ ---
16
+
17
+ # Axon-352M
18
+
19
+ **DuoNeural Research | 2026-07-05 | Archon**
20
+
21
+ Research baseline model trained on smollm-corpus as a counterpart to CDM-based architectures. Used to establish a transformer baseline for comparative CDM studies.
22
+
23
+ ## Architecture
24
+
25
+ Custom GPT-style transformer (not a standard HuggingFace architecture):
26
+
27
+ | Parameter | Value |
28
+ |-----------|-------|
29
+ | Layers | 30 |
30
+ | Hidden dim | 1024 |
31
+ | FFN dim | 2560 |
32
+ | Attention heads | 8Q / 4KV (GQA) |
33
+ | Head dim | 128 |
34
+ | Vocab size | 49,152 (SmolLM tokenizer) |
35
+ | Max seq length | 2,048 |
36
+ | Activation | ReLU² |
37
+ | Normalization | RMSNorm + QK-norm |
38
+ | Position encoding | RoPE (θ=10000) |
39
+ | Logit cap | 30.0 |
40
+ | Total params | ~352M |
41
+
42
+ ## Training
43
+
44
+ - **Data**: smollm-corpus (FineWeb-edu-dedup 50%, Cosmopedia-v2 30%, OpenWebMath 10%, Python-edu 10%)
45
+ - **Tokens**: ~8.5B
46
+ - **Optimizer**: MuonH (matrix params) + AdamW (embeddings)
47
+ - **Peak LR**: 3e-4 (trapezoidal: 5% warmup, 85% stable, 10% decay)
48
+ - **Hardware**: RTX 3090 (1×) on vast.ai
49
+
50
+ ## Loading
51
+
52
+ This model uses a custom architecture not directly loadable via `AutoModel`. To load:
53
+
54
+ ```python
55
+ import torch
56
+ from safetensors.torch import load_file
57
+
58
+ # Load state dict
59
+ state_dict = load_file("model.safetensors")
60
+
61
+ # Architecture must be defined from training script
62
+ # See train_axon_300m.py for the full model class
63
+ ```
64
+
65
+ Full training script and architecture code available at [DuoNeural GitHub](https://github.com/DuoNeural).
66
+
67
+ ## Research Context
68
+
69
+ Trained as a transformer baseline for the CDM (Competitive Docking Memory) research program. See:
70
+ - [DuoNeural/CDM-Paper-1](https://huggingface.co/papers) — CDM architecture
71
+ - [Zenodo DOI 10.5281/zenodo.21158430](https://zenodo.org/record/21158430) — CDM Paper 1
72
+
73
+ ## Authors
74
+
75
+ Archon (DuoNeural Lab Director), Jesse Caldwell
76
+
77
+ {DuoNeural standard footer}
config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "axon",
3
+ "architectures": [
4
+ "AxonForCausalLM"
5
+ ],
6
+ "hidden_size": 1024,
7
+ "intermediate_size": 2560,
8
+ "num_hidden_layers": 30,
9
+ "num_attention_heads": 8,
10
+ "num_key_value_heads": 4,
11
+ "vocab_size": 49152,
12
+ "max_position_embeddings": 2048,
13
+ "rope_theta": 10000.0,
14
+ "logit_softcap": 30.0,
15
+ "hidden_act": "relu2",
16
+ "rms_norm_eps": 1e-05,
17
+ "tie_word_embeddings": false,
18
+ "torch_dtype": "bfloat16",
19
+ "transformers_version": "custom",
20
+ "training_tokens": 8499888128,
21
+ "best_train_loss": 4.409422159194946,
22
+ "training_step": 64849
23
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:873ad70c4bbe75a533c3ad07b2f1ce25b8bc8ccef177df702cc0f4e30e4ab0e2
3
+ size 704813784
tokenizer_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "tokenizer_class": "GPT2Tokenizer",
3
+ "bos_token": "<|endoftext|>",
4
+ "eos_token": "<|endoftext|>",
5
+ "model_max_length": 2048
6
+ }
tokenizer_info.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "tokenizer_name": "HuggingFaceTB/smollm-corpus",
3
+ "vocab_size": 49152
4
+ }
train_axon_300m.py ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_300m.py — DuoNeural 300M Pretraining on RTX 5060Ti
3
+ Archon, 2026-06-26. Aura's blueprint, my code.
4
+
5
+ Hyperparams at a glance:
6
+ model: 302M params, 30L, H=1024, F=2560, GQA 8Q/4KV, ReLU², RMSNorm+QK-norm, logit_cap=30
7
+ seq: 2048 micro_batch: 4 grad_accum: 16 effective_batch: 64 tokens_per_step: 131072
8
+ optimizer: MuonH (2D weights) + AdamW (embeddings/1D)
9
+ LR: 3e-4 trapezoidal (5% warmup, 85% stable, 10% decay)
10
+ target: ~48B tokens over 15 days at ~35% MFU BF16
11
+ data: smollm-corpus (fineweb-edu-dedup 50% + cosmopedia-v2 30% + open-web-math 10% + python-edu 10%)
12
+ """
13
+
14
+ import os, sys, math, time, json, logging, argparse, random
15
+ from pathlib import Path
16
+ from datetime import datetime
17
+
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+ from torch.utils.checkpoint import checkpoint
22
+
23
+ # ── liger (fused kernels) ──────────────────────────────────────────────────────
24
+ # Triton JIT doesn't compile for sm_120 (Blackwell) on CUDA < 12.9.
25
+ # PyTorch built-in ops (SDPA, matmul, RMSNorm-via-torch) work fine pre-built.
26
+ # We implement chunked CE instead of liger fused CE to avoid the 800MB logits tensor.
27
+ HAS_LIGER = False
28
+
29
+ # ── datasets ───────────────────────────────────────────────────────────────────
30
+ from datasets import load_dataset, interleave_datasets
31
+ from transformers import AutoTokenizer
32
+
33
+
34
+ # ══════════════════════════════════════════════════════════════════════════════
35
+ # ARCHITECTURE
36
+ # ══════════════════════════════════════════════════════════════════════════════
37
+
38
+ HIDDEN = 1024
39
+ FFN_DIM = 2560
40
+ N_LAYERS = 30
41
+ N_HEADS = 8
42
+ N_KV = 4
43
+ HEAD_DIM = HIDDEN // N_HEADS # 128
44
+ VOCAB = 49152
45
+ SEQ_LEN = 2048
46
+ LOGIT_CAP = 30.0
47
+
48
+
49
+ # use PyTorch 2.4+ native RMSNorm (fused CUDA kernel, no Triton needed)
50
+ RMSNorm = torch.nn.RMSNorm
51
+
52
+
53
+ class RotaryEmbedding(nn.Module):
54
+ def __init__(self, dim: int, max_seq: int = SEQ_LEN, base: float = 10000.0):
55
+ super().__init__()
56
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
57
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
58
+ t = torch.arange(max_seq).float()
59
+ freqs = torch.outer(t, inv_freq)
60
+ emb = torch.cat([freqs, freqs], dim=-1)
61
+ self.register_buffer("cos_cached", emb.cos()[None, None], persistent=False)
62
+ self.register_buffer("sin_cached", emb.sin()[None, None], persistent=False)
63
+
64
+ @staticmethod
65
+ def _rotate_half(x):
66
+ h = x.shape[-1] // 2
67
+ return torch.cat([-x[..., h:], x[..., :h]], dim=-1)
68
+
69
+ def forward(self, q, k, T):
70
+ cos = self.cos_cached[:, :, :T]
71
+ sin = self.sin_cached[:, :, :T]
72
+ q = (q * cos) + (self._rotate_half(q) * sin)
73
+ k = (k * cos) + (self._rotate_half(k) * sin)
74
+ return q, k
75
+
76
+
77
+ class Attention(nn.Module):
78
+ def __init__(self):
79
+ super().__init__()
80
+ self.q_proj = nn.Linear(HIDDEN, N_HEADS * HEAD_DIM, bias=False)
81
+ self.k_proj = nn.Linear(HIDDEN, N_KV * HEAD_DIM, bias=False)
82
+ self.v_proj = nn.Linear(HIDDEN, N_KV * HEAD_DIM, bias=False)
83
+ self.o_proj = nn.Linear(N_HEADS * HEAD_DIM, HIDDEN, bias=False)
84
+ self.q_norm = RMSNorm(HEAD_DIM) # QK-norm
85
+ self.k_norm = RMSNorm(HEAD_DIM)
86
+ self.rope = RotaryEmbedding(HEAD_DIM)
87
+ self._groups = N_HEADS // N_KV
88
+
89
+ def forward(self, x):
90
+ B, T, _ = x.shape
91
+ q = self.q_proj(x).view(B, T, N_HEADS, HEAD_DIM).transpose(1, 2)
92
+ k = self.k_proj(x).view(B, T, N_KV, HEAD_DIM).transpose(1, 2)
93
+ v = self.v_proj(x).view(B, T, N_KV, HEAD_DIM).transpose(1, 2)
94
+ q = self.q_norm(q)
95
+ k = self.k_norm(k)
96
+ q, k = self.rope(q, k, T)
97
+ k = k.repeat_interleave(self._groups, dim=1)
98
+ v = v.repeat_interleave(self._groups, dim=1)
99
+ out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
100
+ return self.o_proj(out.transpose(1, 2).contiguous().view(B, T, -1))
101
+
102
+
103
+ class FFN(nn.Module):
104
+ """ReLU² — 90%+ sparsity, max bandwidth efficiency on GDDR7."""
105
+ def __init__(self):
106
+ super().__init__()
107
+ self.up = nn.Linear(HIDDEN, FFN_DIM, bias=False)
108
+ self.down = nn.Linear(FFN_DIM, HIDDEN, bias=False)
109
+
110
+ def forward(self, x):
111
+ return self.down(F.relu(self.up(x)).pow(2))
112
+
113
+
114
+ class Block(nn.Module):
115
+ def __init__(self):
116
+ super().__init__()
117
+ self.attn_norm = RMSNorm(HIDDEN)
118
+ self.attn = Attention()
119
+ self.ffn_norm = RMSNorm(HIDDEN)
120
+ self.ffn = FFN()
121
+
122
+ def forward(self, x):
123
+ x = x + self.attn(self.attn_norm(x))
124
+ x = x + self.ffn(self.ffn_norm(x))
125
+ return x
126
+
127
+
128
+ class DuoNeural300M(nn.Module):
129
+ """302M param LM. Aura's architecture. Archon's implementation. DuoNeural's model."""
130
+ def __init__(self, grad_ckpt=True):
131
+ super().__init__()
132
+ self.grad_ckpt = grad_ckpt
133
+ self.embed = nn.Embedding(VOCAB, HIDDEN)
134
+ self.blocks = nn.ModuleList([Block() for _ in range(N_LAYERS)])
135
+ self.norm = RMSNorm(HIDDEN)
136
+ self.lm_head = nn.Linear(HIDDEN, VOCAB, bias=False)
137
+ self._init_weights()
138
+
139
+ def _init_weights(self):
140
+ std = 0.02
141
+ nn.init.normal_(self.embed.weight, std=std)
142
+ nn.init.normal_(self.lm_head.weight, std=std)
143
+ for block in self.blocks:
144
+ for name in ["q_proj", "k_proj", "v_proj"]:
145
+ nn.init.normal_(getattr(block.attn, name).weight, std=std)
146
+ nn.init.normal_(block.attn.o_proj.weight, std=std / math.sqrt(2 * N_LAYERS))
147
+ nn.init.normal_(block.ffn.up.weight, std=std)
148
+ nn.init.normal_(block.ffn.down.weight, std=std / math.sqrt(2 * N_LAYERS))
149
+
150
+ def n_params(self):
151
+ return sum(p.numel() for p in self.parameters())
152
+
153
+ def forward_hidden(self, input_ids):
154
+ """Returns final hidden states — caller applies lm_head + fused CE."""
155
+ x = self.embed(input_ids)
156
+ for block in self.blocks:
157
+ if self.grad_ckpt and self.training:
158
+ x = checkpoint(block, x, use_reentrant=False)
159
+ else:
160
+ x = block(x)
161
+ return self.norm(x)
162
+
163
+ def forward(self, input_ids):
164
+ """Inference-only forward. logit soft-cap applied."""
165
+ x = self.forward_hidden(input_ids)
166
+ logits = self.lm_head(x)
167
+ return LOGIT_CAP * torch.tanh(logits / LOGIT_CAP)
168
+
169
+
170
+ # ══════════════════════════════════════════════════════════════════════════════
171
+ # MUON OPTIMIZER (Newton-Schulz orthogonalization, BF16)
172
+ # ══════════════════════════════════════════════════════════════════════════════
173
+
174
+ def _ns_orthogonalize(G: torch.Tensor, steps: int = 5) -> torch.Tensor:
175
+ """
176
+ Newton-Schulz iteration to orthogonalize gradient G.
177
+ Runs in BF16 for bandwidth efficiency on Blackwell.
178
+ a, b, c coefficients from the Muon paper.
179
+ """
180
+ assert G.ndim >= 2
181
+ orig_dtype = G.dtype
182
+ X = G.bfloat16()
183
+ # normalize so NS iterations are numerically stable
184
+ X = X / (X.norm() + 1e-7)
185
+ transposed = X.shape[0] > X.shape[1]
186
+ if transposed:
187
+ X = X.T
188
+ a, b, c = 3.4445, -4.7750, 2.0315
189
+ for _ in range(steps):
190
+ A = X @ X.T
191
+ B = b * A + c * (A @ A)
192
+ X = a * X + B @ X
193
+ if transposed:
194
+ X = X.T
195
+ return X.to(orig_dtype)
196
+
197
+
198
+ class MuonH(torch.optim.Optimizer):
199
+ """
200
+ Muon Hyperball optimizer — NS-orthogonalized SGD with momentum.
201
+ Apply to ALL 2D weight matrices EXCEPT embeddings and lm_head.
202
+ Frobenius norm bounds enforced post-step (the 'Hyperball' variant).
203
+ """
204
+ def __init__(self, params, lr=3e-4, momentum=0.95, ns_steps=5, norm_bound=None):
205
+ defaults = dict(lr=lr, momentum=momentum, ns_steps=ns_steps, norm_bound=norm_bound)
206
+ super().__init__(params, defaults)
207
+
208
+ @torch.no_grad()
209
+ def step(self, closure=None):
210
+ loss = None
211
+ if closure is not None:
212
+ with torch.enable_grad():
213
+ loss = closure()
214
+
215
+ for group in self.param_groups:
216
+ lr = group["lr"]
217
+ momentum = group["momentum"]
218
+ ns_steps = group["ns_steps"]
219
+ norm_bound = group["norm_bound"]
220
+
221
+ for p in group["params"]:
222
+ if p.grad is None:
223
+ continue
224
+ g = p.grad
225
+
226
+ state = self.state[p]
227
+ if "momentum_buf" not in state:
228
+ state["momentum_buf"] = torch.zeros_like(p)
229
+ if norm_bound is not None:
230
+ state["init_norm"] = p.data.norm(p=2).item()
231
+
232
+ buf = state["momentum_buf"]
233
+ buf.mul_(momentum).add_(g)
234
+
235
+ # NS orthogonalize the accumulated gradient
236
+ update = _ns_orthogonalize(buf, steps=ns_steps)
237
+ # scale to match gradient magnitude for lr calibration
238
+ update = update * (g.norm() / (update.norm() + 1e-8))
239
+
240
+ p.data.add_(update, alpha=-lr)
241
+
242
+ # Hyperball: re-project weight onto fixed-norm sphere after update
243
+ if norm_bound is not None:
244
+ curr_norm = p.data.norm(p=2)
245
+ target = state["init_norm"] if norm_bound == "init" else norm_bound
246
+ if curr_norm > target:
247
+ p.data.mul_(target / (curr_norm + 1e-8))
248
+
249
+ return loss
250
+
251
+
252
+ # ══════════════════════════════════════════════════════════════════════════════
253
+ # LR SCHEDULE — trapezoidal (5% warmup | 85% stable | 10% cosine decay)
254
+ # ══════════════════════════════════════════════════════════════════════════════
255
+
256
+ def get_lr(step: int, total_steps: int, peak_lr: float, min_lr: float = 0.0) -> float:
257
+ warmup_end = int(0.05 * total_steps)
258
+ stable_end = int(0.90 * total_steps)
259
+
260
+ if step <= warmup_end:
261
+ return peak_lr * (step / max(warmup_end, 1))
262
+ elif step <= stable_end:
263
+ return peak_lr
264
+ else:
265
+ # cosine decay from stable_end to total_steps
266
+ decay_steps = total_steps - stable_end
267
+ progress = (step - stable_end) / max(decay_steps, 1)
268
+ return min_lr + 0.5 * (peak_lr - min_lr) * (1 + math.cos(math.pi * progress))
269
+
270
+
271
+ # ══════════════════════════════════════════════════════════════════════════════
272
+ # DATA PIPELINE
273
+ # ══════════════════════════════════════════════════════════════════════════════
274
+
275
+ DATASET_MIX = [
276
+ # (hf_name, config_or_None, text_field, sampling_weight)
277
+ # open-web-math is its own repo (not a smollm-corpus config)
278
+ ("HuggingFaceTB/smollm-corpus", "fineweb-edu-dedup", "text", 0.50),
279
+ ("HuggingFaceTB/smollm-corpus", "cosmopedia-v2", "text", 0.30),
280
+ ("open-web-math/open-web-math", None, "text", 0.10),
281
+ ("HuggingFaceTB/smollm-corpus", "python-edu", "text", 0.10),
282
+ ]
283
+
284
+
285
+ def build_streaming_dataset(tokenizer, seq_len: int, seed: int = 42):
286
+ """
287
+ Builds a packed streaming dataset from the 4-source mixture.
288
+ Manual weighted interleaving — bypasses interleave_datasets schema issues.
289
+ Documents are concatenated with EOS tokens and chunked to seq_len+1.
290
+ No padding ever — GPU sees 100% real tokens.
291
+ """
292
+ rng = random.Random(seed)
293
+ eos_id = tokenizer.eos_token_id
294
+
295
+ streams = []
296
+ text_fields = []
297
+ weights = []
298
+ for name, config, text_field, w in DATASET_MIX:
299
+ if config is not None:
300
+ ds = load_dataset(name, config, split="train", streaming=True)
301
+ else:
302
+ ds = load_dataset(name, split="train", streaming=True)
303
+ streams.append(ds)
304
+ text_fields.append(text_field)
305
+ weights.append(w)
306
+
307
+ def pack_documents_multi(streams, text_fields, weights):
308
+ """Generator: weighted random sampling across streams, pack into seq chunks."""
309
+ iters = [iter(s) for s in streams]
310
+ buf = []
311
+ while True:
312
+ # sample which stream to pull from (weighted)
313
+ idx = rng.choices(range(len(streams)), weights=weights, k=1)[0]
314
+ try:
315
+ example = next(iters[idx])
316
+ except StopIteration:
317
+ # reset exhausted stream
318
+ iters[idx] = iter(streams[idx])
319
+ example = next(iters[idx])
320
+
321
+ text = example.get(text_fields[idx]) or ""
322
+ if not isinstance(text, str):
323
+ text = str(text)
324
+ if not text.strip():
325
+ continue
326
+
327
+ ids = tokenizer.encode(text, add_special_tokens=False)
328
+ buf.extend(ids)
329
+ buf.append(eos_id)
330
+ while len(buf) >= seq_len + 1:
331
+ yield buf[:seq_len + 1]
332
+ buf = buf[seq_len + 1:]
333
+
334
+ return streams, lambda _: pack_documents_multi(streams, text_fields, weights)
335
+
336
+
337
+ class StreamingTokenDataLoader:
338
+ """
339
+ Wraps the streaming HF dataset into a simple micro-batch iterator.
340
+ pin_memory for PCIe 5.0 x8 transfer speed — keeps GPU fed.
341
+ """
342
+ def __init__(self, dataset, pack_fn, micro_batch: int, seq_len: int,
343
+ num_workers: int = 4, prefetch: int = 64):
344
+ self.dataset = dataset
345
+ self.pack_fn = pack_fn
346
+ self.micro_batch = micro_batch
347
+ self.seq_len = seq_len
348
+ self.prefetch = prefetch
349
+
350
+ def __iter__(self):
351
+ buffer = []
352
+ gen = self.pack_fn(self.dataset)
353
+ for chunk in gen:
354
+ buffer.append(chunk)
355
+ if len(buffer) >= self.micro_batch:
356
+ t = torch.tensor(buffer[:self.micro_batch], dtype=torch.long)
357
+ input_ids = t[:, :-1]
358
+ labels = t[:, 1:].contiguous()
359
+ yield input_ids.pin_memory(), labels.pin_memory()
360
+ buffer = buffer[self.micro_batch:]
361
+
362
+
363
+ # ══════════════════════════════════════════════════════════════════════════════
364
+ # LOSS
365
+ # ══════════════════════════════════════════════════════════════════════════════
366
+
367
+ def compute_loss(model: DuoNeural300M, input_ids: torch.Tensor, labels: torch.Tensor):
368
+ """
369
+ Training loss. With grad checkpointing we use only ~6GB peak VRAM, so we can
370
+ afford to materialize the full (B*T, 49152) logits tensor (~800MB at mb=4).
371
+ Logit soft-cap (tanh ×30) applied before CE to prevent late-training NaN.
372
+ """
373
+ raw_model = getattr(model, "_orig_mod", model)
374
+ hidden = model.forward_hidden(input_ids) # (B, T, H)
375
+ logits = raw_model.lm_head(hidden) # (B, T, V)
376
+ logits = LOGIT_CAP * torch.tanh(logits / LOGIT_CAP)
377
+ return F.cross_entropy(logits.reshape(-1, VOCAB), labels.reshape(-1))
378
+
379
+
380
+ # ══════════════════════════════════════════════════════════════════════════════
381
+ # CHECKPOINT
382
+ # ══════════════════════════════════════════════════════════════════════════════
383
+
384
+ def save_ckpt(model, muon_opt, adamw_opt, step, loss, ckpt_dir: Path):
385
+ ckpt_dir.mkdir(parents=True, exist_ok=True)
386
+ path = ckpt_dir / f"step_{step:07d}.pt"
387
+ torch.save({
388
+ "step": step,
389
+ "loss": loss,
390
+ "model": model.state_dict(),
391
+ "muon": muon_opt.state_dict(),
392
+ "adamw": adamw_opt.state_dict(),
393
+ "timestamp": datetime.utcnow().isoformat(),
394
+ }, path)
395
+ # keep only last 3 checkpoints to save disk
396
+ ckpts = sorted(ckpt_dir.glob("step_*.pt"))
397
+ for old in ckpts[:-3]:
398
+ old.unlink()
399
+ print(f"✓ checkpoint saved: {path}")
400
+ return path
401
+
402
+
403
+ def load_ckpt(model, muon_opt, adamw_opt, ckpt_dir: Path):
404
+ ckpts = sorted(ckpt_dir.glob("step_*.pt"))
405
+ if not ckpts:
406
+ return 0
407
+ path = ckpts[-1]
408
+ ckpt = torch.load(path, map_location="cpu", weights_only=True)
409
+ model.load_state_dict(ckpt["model"])
410
+ muon_opt.load_state_dict(ckpt["muon"])
411
+ adamw_opt.load_state_dict(ckpt["adamw"])
412
+ step = ckpt["step"]
413
+ print(f"✓ resumed from {path} at step {step}, loss={ckpt['loss']:.4f}")
414
+ return step
415
+
416
+
417
+ # ══════════════════════════════════════════════════════════════════════════════
418
+ # TRAINING
419
+ # ══════════════════════════════════════════════════════════════════════════════
420
+
421
+ def main():
422
+ parser = argparse.ArgumentParser()
423
+ parser.add_argument("--ckpt_dir", default="/workspace/ckpts_300m")
424
+ parser.add_argument("--log_file", default="/workspace/train_300m.log")
425
+ parser.add_argument("--micro_batch", type=int, default=8)
426
+ parser.add_argument("--grad_accum", type=int, default=8)
427
+ parser.add_argument("--total_tokens", type=float, default=8.5e9,
428
+ help="total training tokens (default: 8.5B = Chinchilla-optimal for 352M)")
429
+ parser.add_argument("--peak_lr_muon", type=float, default=3e-4)
430
+ parser.add_argument("--peak_lr_adam", type=float, default=3e-4)
431
+ parser.add_argument("--save_every", type=int, default=1000)
432
+ parser.add_argument("--log_every", type=int, default=50)
433
+ parser.add_argument("--seed", type=int, default=42)
434
+ parser.add_argument("--compile", action="store_true",
435
+ help="torch.compile the model — faster but slower startup")
436
+ args = parser.parse_args()
437
+
438
+ # ── setup ────────────────────────────────────────────────────────────────
439
+ torch.manual_seed(args.seed)
440
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
441
+
442
+ logging.basicConfig(
443
+ level=logging.INFO,
444
+ format="%(asctime)s %(message)s",
445
+ handlers=[
446
+ logging.StreamHandler(sys.stdout),
447
+ logging.FileHandler(args.log_file),
448
+ ]
449
+ )
450
+ log = logging.getLogger("train_300m")
451
+
452
+ tokens_per_step = args.micro_batch * args.grad_accum * SEQ_LEN
453
+ total_steps = int(args.total_tokens / tokens_per_step)
454
+
455
+ log.info(f"{'='*60}")
456
+ log.info(f"DuoNeural 300M Pretraining")
457
+ log.info(f"device: {device} ({torch.cuda.get_device_name(0)})")
458
+ log.info(f"micro_batch: {args.micro_batch}")
459
+ log.info(f"grad_accum: {args.grad_accum}")
460
+ log.info(f"tokens_per_step:{tokens_per_step:,}")
461
+ log.info(f"total_steps: {total_steps:,}")
462
+ log.info(f"total_tokens: {args.total_tokens/1e9:.1f}B")
463
+ log.info(f"liger_kernel: {HAS_LIGER}")
464
+ log.info(f"torch.compile: {args.compile}")
465
+ log.info(f"{'='*60}")
466
+
467
+ # ── tokenizer ────────────────────────────────────────────────────────────
468
+ log.info("loading tokenizer...")
469
+ tokenizer = AutoTokenizer.from_pretrained(
470
+ "HuggingFaceTB/SmolLM2-135M",
471
+ cache_dir="/workspace/hf_cache",
472
+ )
473
+ assert tokenizer.vocab_size == VOCAB, \
474
+ f"tokenizer vocab {tokenizer.vocab_size} != model vocab {VOCAB}"
475
+ log.info(f"tokenizer: vocab_size={tokenizer.vocab_size}")
476
+
477
+ # ── model ─────────────────────────────────────────────────────────────────
478
+ log.info("building model...")
479
+ model = DuoNeural300M(grad_ckpt=True).to(device)
480
+ model = model.bfloat16()
481
+ n_params = model.n_params()
482
+ log.info(f"model params: {n_params/1e6:.1f}M")
483
+
484
+ if args.compile:
485
+ log.info("compiling model (this takes 1-2 min on first run)...")
486
+ model = torch.compile(model, mode="reduce-overhead")
487
+
488
+ # ── optimizers ────────────────────────────────────────────────────────────
489
+ # Muon: all 2D weight matrices EXCEPT embed and lm_head (vocab dim too large for NS)
490
+ muon_params = []
491
+ adamw_params = []
492
+ for name, p in model.named_parameters():
493
+ if p.ndim == 2 and "embed" not in name and "lm_head" not in name:
494
+ muon_params.append(p)
495
+ else:
496
+ adamw_params.append(p)
497
+
498
+ log.info(f"muon params: {sum(p.numel() for p in muon_params)/1e6:.1f}M")
499
+ log.info(f"adamw params: {sum(p.numel() for p in adamw_params)/1e6:.1f}M")
500
+
501
+ muon_opt = MuonH(muon_params, lr=args.peak_lr_muon, momentum=0.95, ns_steps=5)
502
+ adamw_opt = torch.optim.AdamW(adamw_params, lr=args.peak_lr_adam,
503
+ betas=(0.9, 0.95), weight_decay=0.1, eps=1e-8)
504
+
505
+ # ── resume ────────────────────────────────────────────────────────────────
506
+ ckpt_dir = Path(args.ckpt_dir)
507
+ start_step = load_ckpt(model, muon_opt, adamw_opt, ckpt_dir)
508
+
509
+ # ── data ──────────────────────────────────────────────────────────────────
510
+ log.info("building data pipeline...")
511
+ raw_ds, pack_fn = build_streaming_dataset(tokenizer, SEQ_LEN, seed=args.seed)
512
+ loader = StreamingTokenDataLoader(raw_ds, pack_fn,
513
+ micro_batch=args.micro_batch,
514
+ seq_len=SEQ_LEN)
515
+
516
+ # ── training loop ─────────────────────────────────────────────────────────
517
+ model.train()
518
+ scaler = torch.amp.GradScaler("cuda", enabled=False) # BF16 doesn't need GradScaler
519
+
520
+ total_tokens_seen = start_step * tokens_per_step
521
+ step = start_step
522
+ accum_loss = 0.0
523
+ accum_count = 0
524
+ t0 = time.time()
525
+
526
+ log.info(f"starting training from step {step}/{total_steps}")
527
+ log.info(f"{'step':>7} {'loss':>8} {'lr':>10} {'tok/s':>8} {'tokens':>12}")
528
+
529
+ data_iter = iter(loader)
530
+
531
+ while step < total_steps:
532
+ # ── LR update ────────────────────────────────────────────────────────
533
+ lr = get_lr(step, total_steps, args.peak_lr_muon)
534
+ for pg in muon_opt.param_groups:
535
+ pg["lr"] = lr
536
+ lr_adam = get_lr(step, total_steps, args.peak_lr_adam)
537
+ for pg in adamw_opt.param_groups:
538
+ pg["lr"] = lr_adam
539
+
540
+ # ── gradient accumulation ─────────────────────────────────────────────
541
+ muon_opt.zero_grad()
542
+ adamw_opt.zero_grad()
543
+
544
+ step_loss = 0.0
545
+ for micro_step in range(args.grad_accum):
546
+ try:
547
+ input_ids, labels = next(data_iter)
548
+ except StopIteration:
549
+ # dataset exhausted — restart (48B tokens should outlast 15 days)
550
+ log.info("dataset exhausted, restarting...")
551
+ data_iter = iter(loader)
552
+ input_ids, labels = next(data_iter)
553
+
554
+ input_ids = input_ids.to(device, non_blocking=True)
555
+ labels = labels.to(device, non_blocking=True)
556
+
557
+ with torch.amp.autocast("cuda", dtype=torch.bfloat16):
558
+ loss = compute_loss(model, input_ids, labels)
559
+ loss = loss / args.grad_accum
560
+
561
+ loss.backward()
562
+ step_loss += loss.item()
563
+
564
+ # gradient clipping — essential with Muon's aggressive LR
565
+ nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
566
+
567
+ muon_opt.step()
568
+ adamw_opt.step()
569
+
570
+ step += 1
571
+ total_tokens_seen += tokens_per_step
572
+ accum_loss += step_loss
573
+ accum_count += 1
574
+
575
+ # ── logging ───────────────────────────────────────────────────────────
576
+ if step % args.log_every == 0:
577
+ elapsed = time.time() - t0
578
+ tok_s = (args.log_every * tokens_per_step) / elapsed
579
+ avg_loss = accum_loss / accum_count
580
+ log.info(f"{step:>7} {avg_loss:>8.4f} {lr:>10.2e} {tok_s:>8.0f} {total_tokens_seen/1e9:>10.3f}B")
581
+ accum_loss = 0.0
582
+ accum_count = 0
583
+ t0 = time.time()
584
+
585
+ # ── checkpoint ────────────────────────────────────────────────────────
586
+ if step % args.save_every == 0:
587
+ save_ckpt(model, muon_opt, adamw_opt, step, step_loss, ckpt_dir)
588
+
589
+ log.info(f"training complete! {total_tokens_seen/1e9:.2f}B tokens, {step} steps")
590
+ save_ckpt(model, muon_opt, adamw_opt, step, step_loss, ckpt_dir)
591
+
592
+ # final save as HF-compatible (for SFT afterwards)
593
+ _save_hf_model(model, tokenizer, "/workspace/duoneural_300m_final")
594
+ log.info("model saved to /workspace/duoneural_300m_final")
595
+
596
+
597
+ def _save_hf_model(model, tokenizer, output_dir: str):
598
+ """Save model in transformers-compatible format for later SFT."""
599
+ from transformers import PreTrainedModel, PretrainedConfig
600
+
601
+ class DN300MConfig(PretrainedConfig):
602
+ model_type = "duoneural_300m"
603
+ def __init__(self, **kwargs):
604
+ super().__init__(**kwargs)
605
+ self.hidden_size = HIDDEN
606
+ self.ffn_dim = FFN_DIM
607
+ self.num_layers = N_LAYERS
608
+ self.num_heads = N_HEADS
609
+ self.num_kv_heads = N_KV
610
+ self.vocab_size = VOCAB
611
+ self.seq_len = SEQ_LEN
612
+ self.logit_cap = LOGIT_CAP
613
+
614
+ config = DN300MConfig()
615
+ path = Path(output_dir)
616
+ path.mkdir(parents=True, exist_ok=True)
617
+ config.save_pretrained(str(path))
618
+
619
+ # unwrap compile if needed
620
+ raw_model = getattr(model, "_orig_mod", model)
621
+ torch.save(raw_model.state_dict(), path / "model_weights.pt")
622
+ tokenizer.save_pretrained(str(path))
623
+ print(f"HF-style checkpoint saved to {output_dir}")
624
+
625
+
626
+ if __name__ == "__main__":
627
+ main()