anima-python 0.13.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anima_py/README.md +39 -0
- anima_py/__init__.py +28 -0
- anima_py/__main__.py +7 -0
- anima_py/cli/__init__.py +1 -0
- anima_py/cli/anima.py +271 -0
- anima_py/cli/corpus.py +150 -0
- anima_py/cli/evaluate.py +1350 -0
- anima_py/cli/rho_axon.py +801 -0
- anima_py/cli/serialize.py +174 -0
- anima_py/cli/serialize_bind.py +100 -0
- anima_py/cli/sweep.py +509 -0
- anima_py/cli/train.py +1667 -0
- anima_py/core/__init__.py +1 -0
- anima_py/core/brain.py +601 -0
- anima_py/core/clm_serialize_v2.py +486 -0
- anima_py/core/clml.py +155 -0
- anima_py/core/decode.py +1386 -0
- anima_py/core/engine_cli.py +10307 -0
- anima_py/core/engine_g.py +129 -0
- anima_py/core/generator.py +318 -0
- anima_py/core/hippo_lane.py +133 -0
- anima_py/core/model.py +462 -0
- anima_py/core/pure_field.py +334 -0
- anima_py/core/rho_fan.py +442 -0
- anima_py/core/serialize.py +826 -0
- anima_py/core/serialize_standalone.py +186 -0
- anima_py/core/slw.py +211 -0
- anima_py/core/verify_clm_v2.py +844 -0
- anima_python-0.13.1.dist-info/METADATA +52 -0
- anima_python-0.13.1.dist-info/RECORD +34 -0
- anima_python-0.13.1.dist-info/WHEEL +5 -0
- anima_python-0.13.1.dist-info/entry_points.txt +2 -0
- anima_python-0.13.1.dist-info/licenses/LICENSE +21 -0
- anima_python-0.13.1.dist-info/top_level.txt +1 -0
anima_py/cli/train.py
ADDED
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cli/train.py — the CANONICAL anima python training entry (`anima-python train`).
|
|
3
|
+
|
|
4
|
+
>>> This file is the working python training entry, SYMMETRIC to cli/evaluate.py
|
|
5
|
+
>>> (the canonical python eval entry). `anima-python train <args>` (pip channel) dispatches HERE;
|
|
6
|
+
>>> hexa `anima train <args>` dispatches to the hexa-native cli/train.hexa.
|
|
7
|
+
>>>
|
|
8
|
+
>>> WHY python is the canonical working path RIGHT NOW: the hexa-native production
|
|
9
|
+
>>> trainer cli/train.hexa (a_train_flame_forge) is single-thread native-CPU-scalar-
|
|
10
|
+
>>> bound (#2598/#2600: util peak ~65% / sustained <30%) and is temporarily under a
|
|
11
|
+
>>> GPU-utilization fix, so a real 303M GPU train on it idles the GPU. This torch
|
|
12
|
+
>>> Lane-P path is GPU-BOUND (cuda GEMM-saturating), so it trains the real clm303
|
|
13
|
+
>>> (L4·d3784·E2->Emax4) efficiently today — the explicitly-sanctioned REFERENCE +
|
|
14
|
+
>>> BRIDGE path (a_clm_gen_pipeline: "Lane-P torch = REFERENCE + bridge, forge is the
|
|
15
|
+
>>> PUBLIC production trainer"). It trains a CLMConvMoE GPU-bound, then SERIALIZES to
|
|
16
|
+
>>> a .clm v0.3 (CLM\\x01 magic + CLMX trailer) that CORE core/clm_decode.hexa loads
|
|
17
|
+
>>> back byte-exact for the engine-native ρ-AXON reach verdict (former G0-G6).
|
|
18
|
+
>>>
|
|
19
|
+
>>> ENGINE-NATIVE GATE (a_engine_native_learning, HARD-GATE): torch-side CE / gauges
|
|
20
|
+
>>> here = DIRECTIONAL only (NOT terminal). TERMINAL verdict = CORE re-measure of the
|
|
21
|
+
>>> serialized .clm via `anima-python evaluate <clm>` on the frozen ρ-AXON reach bars (former G0-G6). Pull the
|
|
22
|
+
>>> trained ckpt before teardown (a_fire_recover_complete) so engine-check is possible.
|
|
23
|
+
|
|
24
|
+
This trainer carries the full SAVANT + MITOSIS recipe (parity with cli/train.hexa) AND
|
|
25
|
+
the H_1640 OBJECTIVE-DISCOVERY surface: `--arm {ctrl,tlora,tlora_dict,tlora_jamo}` ×
|
|
26
|
+
`--objective {ce_marginal,infonce,contrastive_equilibrium,predictive_info,
|
|
27
|
+
constructive_bind,composed_nce}`. The last THREE objectives are the NEW compositional
|
|
28
|
+
ρ·weave levers (recombination · frozen bar = former G1 · added on top of CE) — this arm ×
|
|
29
|
+
objective matrix is the whole point of the
|
|
30
|
+
py entry (the trunk-objective search for the ρ·weave (former G1) recombination / ρ·fan (former G6) ideation wall).
|
|
31
|
+
|
|
32
|
+
WHY the objective lever (context, do not re-derive): the ρ·weave (former G1) recombination / ρ·fan (former G6) ideation
|
|
33
|
+
wall is confirmed TRUNK-OBJECTIVE-BOUND — cross-entropy does NOT reward COMPOSITION of
|
|
34
|
+
concepts, so every READOUT op tried (multiplicative binding exp3, CLS pattern-sep
|
|
35
|
+
H_1815, TLoRA expert-weight H_1813, plain-InfoNCE recomb-objective H_9024) only lifts ρ·leap/G2
|
|
36
|
+
novelty (orthogonal) and floors ρ·weave/G1. External lit converges: the lever is the OBJECTIVE +
|
|
37
|
+
regularization, NOT the operator. So a NEW lever must be a NEW LOSS FUNCTION that rewards
|
|
38
|
+
compositional structure IN THE TRUNK, added to CE. This package adds three such losses:
|
|
39
|
+
|
|
40
|
+
predictive_info — MULTI-STEP predictive-coding aux. Aux linear heads predict the
|
|
41
|
+
tokens k=2,3,4 steps AHEAD from the trunk penultimate (not just
|
|
42
|
+
the immediate next token). Rewards the penultimate for carrying
|
|
43
|
+
predictive info about the FUTURE beyond t+1 = the cortical
|
|
44
|
+
predictive hierarchy / predictive-information bottleneck
|
|
45
|
+
(Bialek-Tishby predictive information; van den Oord CPC 1807.03748;
|
|
46
|
+
Rao&Ballard predictive coding). Heads DROPPED at serialize.
|
|
47
|
+
|
|
48
|
+
constructive_bind — TRAINED CONSTRUCTIVE BIND aux (the one untried piece of the
|
|
49
|
+
substrate framebreak). Two learned projections extract role r and
|
|
50
|
+
filler f from the penultimate; they are BOUND by circular
|
|
51
|
+
convolution c = r⊛f (Plate 1995 Holographic Reduced Representations
|
|
52
|
+
/ Smolensky 1990 Tensor-Product Representations). Two constraints
|
|
53
|
+
sculpt a compositional code: (1) UNBIND recovers the filler
|
|
54
|
+
(unbind(c,r)≈f, cos loss) so the code must support clean
|
|
55
|
+
compose/decompose, and (2) the bound composite must PREDICT the
|
|
56
|
+
next token (dec(c)→y, CE) so binding carries task signal. Heads
|
|
57
|
+
DROPPED at serialize.
|
|
58
|
+
|
|
59
|
+
composed_nce — COMPOSED-NEGATIVE InfoNCE. Plain InfoNCE's negatives are RANDOM
|
|
60
|
+
vocab tokens (concept-membership only). Here the negatives are the
|
|
61
|
+
SAME bag of tokens present in the window but assigned to the WRONG
|
|
62
|
+
position (targets permuted WITHIN each sequence). Contrasting the
|
|
63
|
+
true token-to-position assignment against same-concept-set /
|
|
64
|
+
wrong-composition assignments directly rewards getting the
|
|
65
|
+
COMPOSITION right, not just the concept set (hard-negative /
|
|
66
|
+
order-sensitive contrastive; CPC-style). Operates on logits — no
|
|
67
|
+
aux params, gradient flows readout→trunk.
|
|
68
|
+
|
|
69
|
+
All three are DIRECTIONAL torch-side training pressures; the verdict is later via
|
|
70
|
+
`anima-python evaluate <clm>` engine-native on the FROZEN ρ·weave bar (recombination · former G1 · a_engine_native_learning).
|
|
71
|
+
The .clm path stays OPEN: aux heads/projections live OUTSIDE model.state_dict (in the
|
|
72
|
+
objective module), so serialize_v3 writes only the standard additive-readout CLMConvMoE.
|
|
73
|
+
|
|
74
|
+
LEVERS (arm axis, orthogonal to the readout-floor result, all on the SAME trunk):
|
|
75
|
+
N1 TLoRA / TensorPoly expert-weight reparameterization (2405.16671): each ConvExpert
|
|
76
|
+
conv weight W∈(d,d,K) is REPARAMETERIZED as a low-rank tensor product
|
|
77
|
+
W = sum_r (a_r ⊗ b_r) ⊗ k_r (+ optional dense base), learned via the factors then
|
|
78
|
+
MATERIALIZED back to the dense (d,d,K) conv weight (so the .clm path stays OPEN).
|
|
79
|
+
N3 DBES expert-specialization diagnostic (2605.18523, MEASURE-ONLY, gradient-free).
|
|
80
|
+
N7 dictionary/sparse-coding aux loss (2603.28744): L1 sparsity on the penultimate.
|
|
81
|
+
N8 jamo (자모) compositional teach signal (2604.12377): next-jamo-class aux head.
|
|
82
|
+
N6 regularization schedule sweep (--wd-floor / --dropout-floor override knobs).
|
|
83
|
+
|
|
84
|
+
Arms (single structural variable each, vs ctrl):
|
|
85
|
+
ctrl : production CLMConvMoE, plain CE. The discriminating control.
|
|
86
|
+
tlora : N1 TLoRA expert-weight (rank R, base on) + CE.
|
|
87
|
+
tlora_dict : N1 TLoRA + N7 dictionary/sparse aux.
|
|
88
|
+
tlora_jamo : N1 TLoRA + N8 jamo teach aux.
|
|
89
|
+
|
|
90
|
+
ARCH (--arch {clm,bytegpt}, default clm — preserves current behavior): the objective
|
|
91
|
+
levers are ARCH-AGNOSTIC (they operate on logits + an optional penultimate), so they can
|
|
92
|
+
be tested on EITHER trunk. `--arch bytegpt` builds a 24-layer GPT-2-class ByteGPT (the
|
|
93
|
+
CLEAN ρ·weave recombination wall (former G1): ByteGPT single=2, vs CLMConvMoE's single=0 coverage-floor) and serializes a
|
|
94
|
+
`.bin` (5×u32 header) via core/serialize.py (the unified serializer) instead of a `.clm`. For bytegpt the
|
|
95
|
+
CLM-specific levers (savant/mitosis/tlora/dict/jamo) are gated OFF — only arm=ctrl × the
|
|
96
|
+
objective matrix is supported (that's exactly what the G1-lever test needs). `anima-python
|
|
97
|
+
evaluate` auto-detects `.bin` vs `.clm` by header, so a ByteGPT `.bin` measures through
|
|
98
|
+
the bytegpt mouth automatically.
|
|
99
|
+
|
|
100
|
+
USAGE (installed `anima` PATH command after `hx install anima`):
|
|
101
|
+
# CLM trunk (default):
|
|
102
|
+
anima-python train --arm ctrl --objective constructive_bind --steps 8000 \\
|
|
103
|
+
--canon --corpus <p1..p4> --cell-label ko-general en-general ko-sns en-sns \\
|
|
104
|
+
--seed 7 --val-frac 0.05 --val-every 200 --sample proportional \\
|
|
105
|
+
--out ckpt/ctrl_cbind_seed7.clm --ckpt-out ckpt/ctrl_cbind_seed7.pt \\
|
|
106
|
+
--gauges-out ckpt/ctrl_cbind_seed7.json
|
|
107
|
+
# ByteGPT trunk (the CLEAN ρ·weave recombination wall · former G1) — arm=ctrl × the objective matrix:
|
|
108
|
+
anima-python train --arch bytegpt --arm ctrl --objective composed_nce --steps 8000 \\
|
|
109
|
+
--canon --corpus <p1..p4> --seed 7 --out ckpt/bg_ctrl_cnce.bin \\
|
|
110
|
+
--gauges-out ckpt/bg_ctrl_cnce.json
|
|
111
|
+
"""
|
|
112
|
+
from __future__ import annotations
|
|
113
|
+
import argparse, json, math, os, sys, time
|
|
114
|
+
import torch
|
|
115
|
+
import torch.nn as nn
|
|
116
|
+
import torch.nn.functional as F
|
|
117
|
+
import torch.distributed as dist # DDP collectives (§1/§3/§6)
|
|
118
|
+
from torch.nn.parallel import DistributedDataParallel as DDP # §4 composite-shell wrap
|
|
119
|
+
|
|
120
|
+
# ── locate the CLM model + serializer/verifier — all CORE-owned (core/) ──────
|
|
121
|
+
# LOCATION-INDEPENDENT (the installed `anima` runs from ANY cwd + this file lives in
|
|
122
|
+
# cli/, not the repo root): the UNIFIED CLM+ByteGPT torch model (model.py), the held-out
|
|
123
|
+
# DESCENT verifier (verify_clm_v2.py), the historical serialize backend
|
|
124
|
+
# (clm_serialize_v2.py) and the .pt→.clm bridge (serialize_standalone.py)
|
|
125
|
+
# ALL live in core/ (owner directive: core-related lives in core/; no archive/train
|
|
126
|
+
# import — a_no_archive_import). core/ is added to sys.path so bare `import model` /
|
|
127
|
+
# `import serialize` resolve, the same resolution cli/evaluate.py uses for `import decode`.
|
|
128
|
+
_HERE = os.path.dirname(os.path.abspath(__file__)) # …/cli
|
|
129
|
+
_ROOTS = []
|
|
130
|
+
_ENV_SRC = os.environ.get("ANIMA_SRC")
|
|
131
|
+
if _ENV_SRC:
|
|
132
|
+
_ROOTS.append(_ENV_SRC)
|
|
133
|
+
_ROOTS.append(os.path.dirname(_HERE)) # repo root = parent of cli/
|
|
134
|
+
# tool/ (gauge_lib) is best-effort (the ρ·weave/ρ·fan (former G1/G6) torch probe below is wrapped in try/except).
|
|
135
|
+
for _r in _ROOTS:
|
|
136
|
+
_t = os.path.join(_r, "tool")
|
|
137
|
+
if os.path.isdir(_t) and _t not in sys.path:
|
|
138
|
+
sys.path.insert(0, _t)
|
|
139
|
+
# core/ is the engine package — the CLM model, the unified serializer (serialize.py),
|
|
140
|
+
# and the verifier all resolve from here.
|
|
141
|
+
_CORE = None
|
|
142
|
+
for _r in _ROOTS:
|
|
143
|
+
_c = os.path.join(_r, "core")
|
|
144
|
+
if os.path.isdir(_c):
|
|
145
|
+
if _c not in sys.path:
|
|
146
|
+
sys.path.insert(0, _c)
|
|
147
|
+
if _CORE is None and os.path.exists(os.path.join(_c, "model.py")):
|
|
148
|
+
_CORE = _c
|
|
149
|
+
if _CORE is None:
|
|
150
|
+
raise ImportError(
|
|
151
|
+
"cli/train.py: could not locate core/model.py under the repo root or "
|
|
152
|
+
"$ANIMA_SRC. Set $ANIMA_SRC to the anima source root.")
|
|
153
|
+
|
|
154
|
+
# (imports resolve via the core/ sys.path insert above.)
|
|
155
|
+
from model import (CLMConfig, CLMConvMoE, MoEStats, CausalDilatedConv1d,
|
|
156
|
+
ByteGPTConfig, ByteGPT) # core/model.py (unified CONV+BYTE)
|
|
157
|
+
import serialize as S # core/serialize.py — serialize_v3 = bridge SSOT
|
|
158
|
+
import verify_clm_v2 as VC # core/verify_clm_v2.py — clm_decodable / descent
|
|
159
|
+
# ByteGPT .pt -> .bin serializer is folded into the SAME unified core/serialize.py.
|
|
160
|
+
import serialize as BGS # core/serialize.py — serialize(pt_path, bin_path)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
164
|
+
# golden-zone constants (SAVANT/savant_lib.hexa H_347/348, verbatim — same as
|
|
165
|
+
# cli/train.hexa gz_lower()/gz_upper()).
|
|
166
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
167
|
+
GZ_LOWER = 0.21231792755821914 # 1/2 - ln(4/3) (sa_gz_lower)
|
|
168
|
+
GZ_UPPER = 0.5 # sa_gz_upper
|
|
169
|
+
LN2 = 0.6931471805599453
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def savant_inhibition(step: int, n_steps: int, i0: float, i_floor: float,
|
|
173
|
+
latch: dict) -> float:
|
|
174
|
+
"""Golden-zone cusp-anneal inhibition for THIS step (a_savant_train).
|
|
175
|
+
|
|
176
|
+
Linear anneal I0 -> I_floor over training. When I first crosses INTO the
|
|
177
|
+
golden zone [GZ_LOWER, GZ_UPPER] on the way down, latch['on'] hard-steps to
|
|
178
|
+
True (cusp, H_1562) and STAYS True for the rest of training (asymmetric
|
|
179
|
+
hysteresis, H_1563) even as the anneal drives I below GZ_LOWER (H_1559: the
|
|
180
|
+
sweep below the floor is intentional so the learning sweet-spot is reached).
|
|
181
|
+
Mirrors cli/train.hexa::savant_inhibition in arithmetic.
|
|
182
|
+
"""
|
|
183
|
+
denom = (n_steps - 1) if n_steps > 1 else 1
|
|
184
|
+
frac = (step - 1) / denom
|
|
185
|
+
inh = i0 + (i_floor - i0) * frac
|
|
186
|
+
if not latch["on"]:
|
|
187
|
+
if GZ_LOWER <= inh <= GZ_UPPER:
|
|
188
|
+
latch["on"] = True
|
|
189
|
+
if latch["at"] == 0:
|
|
190
|
+
latch["at"] = step
|
|
191
|
+
return inh
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def inhibition_to_wd(inh: float) -> float:
|
|
195
|
+
"""Map inhibition I -> AdamW weight decay (I in [0,0.5] -> wd in [0,0.05]).
|
|
196
|
+
Deterministic (p7). Same linear lever as cli/train.hexa::inhibition_to_wd."""
|
|
197
|
+
return inh * 0.1
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def inhibition_to_dropout(inh: float) -> float:
|
|
201
|
+
"""Inhibition also realized as dropout p (clamped to a sane [0, 0.5]). The
|
|
202
|
+
dropout/weight-decay/temperature variants share the single scalar lever."""
|
|
203
|
+
return max(0.0, min(0.5, inh))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
207
|
+
# MITOSIS split E -> E+1 (a_mitosis_train) — continuity-preserving cell division
|
|
208
|
+
# on the live torch MoEConvLayer. Port of cli/train.hexa::train_mitosis_split /
|
|
209
|
+
# clm_mitosis.hexa::mitosis_split semantics:
|
|
210
|
+
# * child expert conv = copy(parent) + tiny alternating +/-1e-4 perturbation
|
|
211
|
+
# * router weight row for child = copy(parent row)
|
|
212
|
+
# * router bias: both parent and child get (parent_bias - ln2) so the two
|
|
213
|
+
# children jointly reproduce the parent's gate mass (near-continuous split)
|
|
214
|
+
# * the optimizer's Adam moments for the touched params are reset to 0.
|
|
215
|
+
# Experts are pre-allocated at Emax (inactive experts masked out of the router
|
|
216
|
+
# until they are "born"), so a split just UNMASKS + seeds the next slot.
|
|
217
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
218
|
+
class MitosisMoE:
|
|
219
|
+
"""Tracks the ACTIVE expert count E while experts/router are physically
|
|
220
|
+
allocated at Emax. Inactive experts are masked out of the router softmax
|
|
221
|
+
(logit += -1e9) so they are inert until born."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, model: CLMConvMoE, e0: int, emax: int):
|
|
224
|
+
self.model = model
|
|
225
|
+
self.e_active = e0
|
|
226
|
+
self.emax = emax
|
|
227
|
+
dev = next(model.parameters()).device
|
|
228
|
+
self.active_mask = torch.zeros(emax, device=dev)
|
|
229
|
+
self.active_mask[:e0] = 1.0
|
|
230
|
+
|
|
231
|
+
def neg_inf_bias(self) -> torch.Tensor:
|
|
232
|
+
# additive router-logit bias: 0 for active slots, -1e9 for dormant.
|
|
233
|
+
return (self.active_mask - 1.0) * 1e9
|
|
234
|
+
|
|
235
|
+
@torch.no_grad()
|
|
236
|
+
def split(self, parent: int, opt: torch.optim.Optimizer) -> int:
|
|
237
|
+
"""Divide `parent` into a fresh child slot (= current e_active index).
|
|
238
|
+
Returns the new active count, or the unchanged count if at Emax."""
|
|
239
|
+
if self.e_active >= self.emax:
|
|
240
|
+
return self.e_active
|
|
241
|
+
child = self.e_active
|
|
242
|
+
moe = self.model.moe
|
|
243
|
+
pe = moe.experts[parent].conv.conv # parent expert Conv1d
|
|
244
|
+
ce = moe.experts[child].conv.conv # child expert Conv1d
|
|
245
|
+
# copy weights + tiny alternating perturbation (parity w/ hexa eps loop)
|
|
246
|
+
pw = pe.weight.detach().clone()
|
|
247
|
+
flat = pw.reshape(-1)
|
|
248
|
+
eps = torch.full_like(flat, 1e-4)
|
|
249
|
+
eps[1::2] = -1e-4
|
|
250
|
+
ce.weight.copy_((flat + eps).reshape(pw.shape))
|
|
251
|
+
if pe.bias is not None and ce.bias is not None:
|
|
252
|
+
ce.bias.copy_(pe.bias)
|
|
253
|
+
# router: child row = parent row; bias splits by -ln2 on BOTH children
|
|
254
|
+
rw = moe.router.weight # (Emax, d, 1)
|
|
255
|
+
rb = moe.router.bias # (Emax,)
|
|
256
|
+
rw[child].copy_(rw[parent])
|
|
257
|
+
pb = rb[parent].item()
|
|
258
|
+
rb[parent] = pb - LN2
|
|
259
|
+
rb[child] = pb - LN2
|
|
260
|
+
# reset Adam moments for the touched params (m,v -> 0).
|
|
261
|
+
for p in (pe.weight, ce.weight, pe.bias, ce.bias, rw, rb):
|
|
262
|
+
if p is None:
|
|
263
|
+
continue
|
|
264
|
+
st = opt.state.get(p, None)
|
|
265
|
+
if st:
|
|
266
|
+
if "exp_avg" in st:
|
|
267
|
+
st["exp_avg"].zero_()
|
|
268
|
+
if "exp_avg_sq" in st:
|
|
269
|
+
st["exp_avg_sq"].zero_()
|
|
270
|
+
self.active_mask[child] = 1.0
|
|
271
|
+
self.e_active = child + 1
|
|
272
|
+
return self.e_active
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
276
|
+
# Router masking — patch MoEConvLayer.forward so dormant experts are inert.
|
|
277
|
+
# We add the dormant-slot -inf bias to the router logits before softmax so an
|
|
278
|
+
# allocated-at-Emax model behaves EXACTLY like an E-active model until a mitosis
|
|
279
|
+
# split unmasks the next slot. Pure additive logit mask; trunk/embed/readout are
|
|
280
|
+
# untouched, so the serialized .clm round-trips the grown E.
|
|
281
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
282
|
+
def install_router_mask(model: CLMConvMoE, mito: MitosisMoE):
|
|
283
|
+
moe = model.moe
|
|
284
|
+
orig_router = moe.router
|
|
285
|
+
|
|
286
|
+
def masked_forward(x):
|
|
287
|
+
B, C, T = x.shape
|
|
288
|
+
n_e = moe.rc.n_experts
|
|
289
|
+
logits = orig_router(x) + mito.neg_inf_bias().view(1, n_e, 1)
|
|
290
|
+
probs = F.softmax(logits, dim=1)
|
|
291
|
+
ent_tok = -(probs * torch.log(probs + 1e-9)).sum(dim=1)
|
|
292
|
+
entropy = ent_tok.mean()
|
|
293
|
+
ex_out = torch.stack([e(x) for e in moe.experts], dim=1)
|
|
294
|
+
if moe.rc.hard_top_k:
|
|
295
|
+
k = min(moe.rc.top_k, int(mito.e_active))
|
|
296
|
+
topv, topi = probs.topk(k, dim=1)
|
|
297
|
+
gate = topv / (topv.sum(dim=1, keepdim=True) + 1e-9)
|
|
298
|
+
mask = torch.zeros_like(probs).scatter_(1, topi, gate)
|
|
299
|
+
else:
|
|
300
|
+
mask = probs
|
|
301
|
+
y = (mask.unsqueeze(2) * ex_out).sum(dim=1)
|
|
302
|
+
usage = probs.mean(dim=(0, 2))
|
|
303
|
+
aux = x.new_zeros(())
|
|
304
|
+
if moe.rc.load_balance_coef > 0.0:
|
|
305
|
+
top1 = probs.argmax(dim=1)
|
|
306
|
+
f_i = F.one_hot(top1, n_e).to(probs.dtype).mean(dim=(0, 1))
|
|
307
|
+
lb = n_e * (f_i * usage).sum()
|
|
308
|
+
aux = aux + moe.rc.load_balance_coef * lb
|
|
309
|
+
if moe.rc.entropy_coef > 0.0:
|
|
310
|
+
aux = aux - moe.rc.entropy_coef * entropy
|
|
311
|
+
return y, MoEStats(usage=usage, aux_loss=aux, entropy=entropy)
|
|
312
|
+
|
|
313
|
+
moe.forward = masked_forward
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
317
|
+
# 4-cell register corpus (a_chat_registers) — {ko·en}x{normal·SNS}. Each
|
|
318
|
+
# --corpus entry is a LOCAL byte file OR an HF dataset path (resolved to a local
|
|
319
|
+
# cached byte stream). Windows are sampled round-robin across the cells so every
|
|
320
|
+
# step sees the register mix. Files are mmap'd so a multi-GB cell is never
|
|
321
|
+
# slurped whole into RAM.
|
|
322
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
323
|
+
class ByteCell:
|
|
324
|
+
"""One register cell — a memory-mapped byte file sampled by random windows.
|
|
325
|
+
|
|
326
|
+
The TAIL `val_frac` of the file is reserved as a held-out validation region
|
|
327
|
+
DISJOINT from the train region (overfit detector, a_savant_train held-out
|
|
328
|
+
monitor). Train windows are sampled only from [0, train_end); val windows
|
|
329
|
+
only from [train_end, size). A window can NEVER straddle the boundary
|
|
330
|
+
(start ranges are clamped to keep start+seq_len+1 within the region), so the
|
|
331
|
+
val region's bytes are never seen by a training gradient step."""
|
|
332
|
+
|
|
333
|
+
def __init__(self, path: str, val_frac: float = 0.0):
|
|
334
|
+
import mmap
|
|
335
|
+
self.path = path
|
|
336
|
+
self.size = os.path.getsize(path)
|
|
337
|
+
self._f = open(path, "rb")
|
|
338
|
+
if self.size > 0:
|
|
339
|
+
self._mm = mmap.mmap(self._f.fileno(), 0, access=mmap.ACCESS_READ)
|
|
340
|
+
else:
|
|
341
|
+
self._mm = b""
|
|
342
|
+
# boundary: bytes [0, train_end) = train, [train_end, size) = held-out val.
|
|
343
|
+
vf = max(0.0, min(0.5, val_frac))
|
|
344
|
+
self.train_end = int(self.size * (1.0 - vf)) if self.size > 0 else 0
|
|
345
|
+
|
|
346
|
+
def _window_in(self, lo: int, hi_excl: int, seq_len: int,
|
|
347
|
+
gen: torch.Generator):
|
|
348
|
+
"""Sample one (x,y) window whose [start, start+seq_len+1) lies entirely
|
|
349
|
+
inside the byte range [lo, hi_excl). Returns None if the range is too
|
|
350
|
+
small for even one window."""
|
|
351
|
+
if hi_excl - lo < seq_len + 2:
|
|
352
|
+
return None
|
|
353
|
+
hi = hi_excl - seq_len - 1 # last valid start (exclusive upper bound)
|
|
354
|
+
start = int(torch.randint(lo, hi, (1,), generator=gen).item())
|
|
355
|
+
chunk = self._mm[start:start + seq_len + 1]
|
|
356
|
+
buf = torch.frombuffer(bytearray(chunk), dtype=torch.uint8).long()
|
|
357
|
+
return buf[:seq_len], buf[1:seq_len + 1]
|
|
358
|
+
|
|
359
|
+
def window_spec(self, seq_len: int, gen: torch.Generator):
|
|
360
|
+
"""§3 SPEC phase — the RNG-only half of a TRAIN window: the bounds-check + randint
|
|
361
|
+
that `_window_in(0, train_end, …)` does, WITHOUT the mmap read. Returns the start
|
|
362
|
+
index, or None when [0, train_end) is too small for even one window — in which case
|
|
363
|
+
NO randint is consumed (identical early-return to `_window_in`). Every DDP rank
|
|
364
|
+
replays this off the SHARED gen so the GLOBAL window set is byte-identical to the
|
|
365
|
+
1-GPU draw; each rank only materializes its own slice (window desync would break
|
|
366
|
+
both the frozen-recipe comparison AND N==1 byte-identity, §10.4)."""
|
|
367
|
+
lo, hi_excl = 0, self.train_end
|
|
368
|
+
if hi_excl - lo < seq_len + 2:
|
|
369
|
+
return None
|
|
370
|
+
hi = hi_excl - seq_len - 1 # last valid start (exclusive upper bound)
|
|
371
|
+
return int(torch.randint(lo, hi, (1,), generator=gen).item())
|
|
372
|
+
|
|
373
|
+
def materialize(self, start: int, seq_len: int):
|
|
374
|
+
"""§3 MATERIALIZE phase — the mmap-read half: given a `start` from window_spec,
|
|
375
|
+
return the (x, y) window. Kept separate so a rank only touches disk for its own
|
|
376
|
+
shard. Byte-for-byte identical to `_window_in`'s post-randint tail."""
|
|
377
|
+
chunk = self._mm[start:start + seq_len + 1]
|
|
378
|
+
buf = torch.frombuffer(bytearray(chunk), dtype=torch.uint8).long()
|
|
379
|
+
return buf[:seq_len], buf[1:seq_len + 1]
|
|
380
|
+
|
|
381
|
+
def window(self, seq_len: int, gen: torch.Generator):
|
|
382
|
+
"""A TRAIN window — sampled only from [0, train_end) (never the val tail). Delegates
|
|
383
|
+
to window_spec (RNG) then materialize (mmap) so the N==1 op + RNG-draw order is
|
|
384
|
+
identical to the pre-refactor `_window_in` (bounds-check → randint → mmap read)."""
|
|
385
|
+
start = self.window_spec(seq_len, gen)
|
|
386
|
+
if start is None:
|
|
387
|
+
return None
|
|
388
|
+
return self.materialize(start, seq_len)
|
|
389
|
+
|
|
390
|
+
def val_window(self, seq_len: int, gen: torch.Generator):
|
|
391
|
+
"""A held-out VAL window — sampled only from [train_end, size)."""
|
|
392
|
+
return self._window_in(self.train_end, self.size, seq_len, gen)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def resolve_corpus_path(spec: str) -> str:
|
|
396
|
+
"""Resolve a --corpus entry to a local byte-file path.
|
|
397
|
+
|
|
398
|
+
* existing local path -> use directly.
|
|
399
|
+
* else treat as an HF dataset id (a_chat_registers names like
|
|
400
|
+
'anima-corpus-5lang-unified-v2', 'anima-corpus-ko-fineweb2-broad',
|
|
401
|
+
'anima-persona-sns-corpus') and stream it to a local cached byte file
|
|
402
|
+
under $ANIMA_CORPUS_CACHE (default ./.corpus_cache) via `datasets`; the
|
|
403
|
+
text column is concatenated UTF-8 -> raw bytes (V=256).
|
|
404
|
+
"""
|
|
405
|
+
if os.path.exists(spec):
|
|
406
|
+
return spec
|
|
407
|
+
cache_root = os.environ.get("ANIMA_CORPUS_CACHE",
|
|
408
|
+
os.path.join(os.getcwd(), ".corpus_cache"))
|
|
409
|
+
os.makedirs(cache_root, exist_ok=True)
|
|
410
|
+
local = os.path.join(cache_root, spec.replace("/", "__") + ".bytes")
|
|
411
|
+
if os.path.exists(local) and os.path.getsize(local) > 0:
|
|
412
|
+
return local
|
|
413
|
+
try:
|
|
414
|
+
from datasets import load_dataset
|
|
415
|
+
except Exception as e:
|
|
416
|
+
raise FileNotFoundError(
|
|
417
|
+
f"--corpus '{spec}' is not a local file and `datasets` is not "
|
|
418
|
+
f"installed to fetch it as an HF dataset ({e}). Provide a local "
|
|
419
|
+
f"byte file, or `pip install datasets`."
|
|
420
|
+
)
|
|
421
|
+
# An HF id never looks like a bare local filename (no slash AND ends in a
|
|
422
|
+
# file extension that doesn't exist locally) — a plain missing path is almost
|
|
423
|
+
# always a staging mistake, so make the HF intent explicit in the log.
|
|
424
|
+
repo = spec if "/" in spec else f"dancinlab/{spec}"
|
|
425
|
+
print(f" resolve: '{spec}' is not a local path -> streaming HF dataset {repo}")
|
|
426
|
+
ds = load_dataset(repo, split="train", streaming=True)
|
|
427
|
+
maxrows = int(os.environ.get("ANIMA_CORPUS_MAXROWS", "0") or 0)
|
|
428
|
+
text_col = None
|
|
429
|
+
with open(local, "wb") as out:
|
|
430
|
+
for i, row in enumerate(ds):
|
|
431
|
+
if text_col is None:
|
|
432
|
+
for c in ("text", "content", "body", "caption"):
|
|
433
|
+
if c in row:
|
|
434
|
+
text_col = c
|
|
435
|
+
break
|
|
436
|
+
if text_col is None:
|
|
437
|
+
text_col = next(iter(row))
|
|
438
|
+
out.write(str(row[text_col]).encode("utf-8", "replace"))
|
|
439
|
+
out.write(b"\n")
|
|
440
|
+
if maxrows > 0 and i + 1 >= maxrows:
|
|
441
|
+
break
|
|
442
|
+
return local
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ── frozen lever hyperparams (pre-registered in PREREG.md — tune-to-green 금지) ──
|
|
446
|
+
TLORA_RANK = 8 # default tensor-product rank R (a_r⊗b_r⊗k_r factors)
|
|
447
|
+
TLORA_BASE = True # keep a small dense base weight alongside the low-rank TP
|
|
448
|
+
DICT_LAMBDA = 1e-3 # N7 trunk-penultimate L1 sparsity weight (Stop-Probing)
|
|
449
|
+
JAMO_LAMBDA = 0.3 # N8 next-jamo-class aux head weight (SCRIPT)
|
|
450
|
+
INFONCE_LAMBDA = 1.0; INFONCE_NEG = 64
|
|
451
|
+
EQ_LAMBDA = 1.0; EQ_MARGIN = 0.5
|
|
452
|
+
|
|
453
|
+
# ── NEW OBJECTIVE frozen hyperparams (H_1640 — pre-registered in PREREG.md) ──
|
|
454
|
+
PREDINFO_LAMBDA = 0.5 # multi-step predictive-coding aux weight (per horizon, averaged)
|
|
455
|
+
PREDINFO_HORIZONS = (2, 3, 4) # predict tokens 2/3/4 steps AHEAD from penultimate
|
|
456
|
+
CBIND_LAMBDA = 0.5 # constructive-bind aux weight (unbind-recon + composite-CE)
|
|
457
|
+
CBIND_DIM = 256 # HRR role/filler factor dim (power-of-2 friendly for FFT)
|
|
458
|
+
CBIND_UNBIND_W = 1.0 # weight on the unbind-recovers-filler term
|
|
459
|
+
CBIND_PRED_W = 1.0 # weight on the bound-composite-predicts-next-token term
|
|
460
|
+
CNCE_LAMBDA = 1.0 # composed-negative InfoNCE weight
|
|
461
|
+
CNCE_PERMS = 8 # # of within-window target permutations = wrong-composition negatives
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
465
|
+
# N1 — TLoRA / TensorPoly expert weight.
|
|
466
|
+
# A drop-in replacement for ConvExpert whose conv weight W∈(d_out=d,d_in=d,K) is
|
|
467
|
+
# reparameterized as a sum of R rank-1 tensor products plus an optional small
|
|
468
|
+
# dense base. The forward is still a plain causal conv (engine-compatible). The
|
|
469
|
+
# effective dense weight is exposed via .materialized_weight() so it can be
|
|
470
|
+
# written into a standard CLMConvMoE state_dict for serialize_v3 (engine-native).
|
|
471
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
472
|
+
class TLoRAConvExpert(nn.Module):
|
|
473
|
+
"""ConvExpert with a tensor-product-factorized conv weight (N1, TLoRA).
|
|
474
|
+
|
|
475
|
+
W[o,i,k] = base[o,i,k] + sum_r A[r,o] * B[r,i] * Kf[r,k]
|
|
476
|
+
where A∈(R,d), B∈(R,d), Kf∈(R,K). This is the Tucker/CP tensor-product
|
|
477
|
+
reparameterization (TensorPoly/TLoRA, 2405.16671) applied to the expert
|
|
478
|
+
weight position — a STRUCTURED (low-rank, compositional) prior on how the
|
|
479
|
+
expert mixes channels, distinct from the readout-position Hadamard we
|
|
480
|
+
already floored (exp3). The bias is a normal learnable vector."""
|
|
481
|
+
|
|
482
|
+
def __init__(self, cfg: CLMConfig, rank: int, base: bool):
|
|
483
|
+
super().__init__()
|
|
484
|
+
d, K = cfg.d_model, cfg.expert_kernel_size
|
|
485
|
+
self.d, self.K, self.R = d, K, rank
|
|
486
|
+
self.dilation = 1
|
|
487
|
+
self.pad = (K - 1) * self.dilation
|
|
488
|
+
# tensor-product factors (CP decomposition of the (d,d,K) conv tensor)
|
|
489
|
+
self.A = nn.Parameter(torch.empty(rank, d)) # out-channel factor
|
|
490
|
+
self.B = nn.Parameter(torch.empty(rank, d)) # in-channel factor
|
|
491
|
+
self.Kf = nn.Parameter(torch.empty(rank, K)) # kernel-tap factor
|
|
492
|
+
nn.init.normal_(self.A, std=d ** -0.5)
|
|
493
|
+
nn.init.normal_(self.B, std=d ** -0.5)
|
|
494
|
+
nn.init.normal_(self.Kf, std=K ** -0.5)
|
|
495
|
+
if base:
|
|
496
|
+
# small dense base so the expert is never strictly rank-R limited
|
|
497
|
+
self.base = nn.Parameter(torch.zeros(d, d, K))
|
|
498
|
+
nn.init.normal_(self.base, std=(d * K) ** -0.5 * 0.1)
|
|
499
|
+
else:
|
|
500
|
+
self.register_parameter("base", None)
|
|
501
|
+
self.bias = nn.Parameter(torch.zeros(d))
|
|
502
|
+
self.act = nn.GELU()
|
|
503
|
+
|
|
504
|
+
def materialized_weight(self) -> torch.Tensor:
|
|
505
|
+
"""Compose the TP factors (+ base) into the dense (d_out, d_in, K) conv
|
|
506
|
+
weight that nn.Conv1d / the .clm format expects. einsum: r o, r i, r k -> o i k."""
|
|
507
|
+
W = torch.einsum("ro,ri,rk->oik", self.A, self.B, self.Kf)
|
|
508
|
+
if self.base is not None:
|
|
509
|
+
W = W + self.base
|
|
510
|
+
return W
|
|
511
|
+
|
|
512
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
513
|
+
# x: (B, C, T) ; causal left-pad then functional conv with materialized W
|
|
514
|
+
W = self.materialized_weight()
|
|
515
|
+
xp = F.pad(x, (self.pad, 0))
|
|
516
|
+
y = F.conv1d(xp, W, self.bias, dilation=self.dilation)
|
|
517
|
+
return self.act(y)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def install_tlora_experts(model: CLMConvMoE, rank: int, base: bool):
|
|
521
|
+
"""Replace every ConvExpert in model.moe.experts with a TLoRAConvExpert
|
|
522
|
+
(N1). Returns the new ModuleList so the optimizer sees the TP factors."""
|
|
523
|
+
cfg = model.cfg
|
|
524
|
+
new = nn.ModuleList(TLoRAConvExpert(cfg, rank, base)
|
|
525
|
+
for _ in range(len(model.moe.experts)))
|
|
526
|
+
model.moe.experts = new
|
|
527
|
+
return new
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def tlora_aware_split(mito, parent: int, opt) -> int:
|
|
531
|
+
"""Mitosis cell-division for TLoRA experts (parity with MitosisMoE.split, but
|
|
532
|
+
operating on the TP factors instead of .conv.conv).
|
|
533
|
+
|
|
534
|
+
MitosisMoE.split() assumes a standard ConvExpert (.conv.conv Conv1d);
|
|
535
|
+
TLoRAConvExpert has TP factors (A,B,Kf[,base],bias) instead. We replicate the
|
|
536
|
+
same semantics: child = clone(parent) + tiny alternating perturbation, router
|
|
537
|
+
row copied, both children's router bias -= ln2, Adam moments reset. This keeps
|
|
538
|
+
the savant×mitosis recipe identical (only the expert PARAMETERIZATION differs,
|
|
539
|
+
which is exactly the single variable under test)."""
|
|
540
|
+
if mito.e_active >= mito.emax:
|
|
541
|
+
return mito.e_active
|
|
542
|
+
import torch as _t
|
|
543
|
+
with _t.no_grad():
|
|
544
|
+
child = mito.e_active
|
|
545
|
+
moe = mito.model.moe
|
|
546
|
+
pe = moe.experts[parent]; ce = moe.experts[child]
|
|
547
|
+
touched = []
|
|
548
|
+
for name in ("A", "B", "Kf", "base", "bias"):
|
|
549
|
+
pp = getattr(pe, name, None); cp = getattr(ce, name, None)
|
|
550
|
+
if pp is None or cp is None:
|
|
551
|
+
continue
|
|
552
|
+
flat = pp.detach().clone().reshape(-1)
|
|
553
|
+
eps = _t.full_like(flat, 1e-4); eps[1::2] = -1e-4
|
|
554
|
+
cp.copy_((flat + eps).reshape(pp.shape))
|
|
555
|
+
touched += [pp, cp]
|
|
556
|
+
rw = moe.router.weight; rb = moe.router.bias
|
|
557
|
+
rw[child].copy_(rw[parent])
|
|
558
|
+
pb = rb[parent].item()
|
|
559
|
+
rb[parent] = pb - LN2; rb[child] = pb - LN2
|
|
560
|
+
touched += [rw, rb]
|
|
561
|
+
for p in touched:
|
|
562
|
+
st = opt.state.get(p, None)
|
|
563
|
+
if st:
|
|
564
|
+
if "exp_avg" in st: st["exp_avg"].zero_()
|
|
565
|
+
if "exp_avg_sq" in st: st["exp_avg_sq"].zero_()
|
|
566
|
+
mito.active_mask[child] = 1.0
|
|
567
|
+
mito.e_active = child + 1
|
|
568
|
+
return mito.e_active
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def materialize_experts_into_state(model: CLMConvMoE):
|
|
572
|
+
"""Return a state_dict where each TLoRA expert is written under the STANDARD
|
|
573
|
+
keys 'moe.experts.{j}.conv.conv.{weight,bias}' (the dense form serialize_v3
|
|
574
|
+
reads). Non-expert keys pass through unchanged. This is what makes the .clm
|
|
575
|
+
engine-loadable despite the reparameterization."""
|
|
576
|
+
sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
|
|
577
|
+
out = {k: v for k, v in sd.items() if not k.startswith("moe.experts.")}
|
|
578
|
+
for j, e in enumerate(model.moe.experts):
|
|
579
|
+
if isinstance(e, TLoRAConvExpert):
|
|
580
|
+
out[f"moe.experts.{j}.conv.conv.weight"] = e.materialized_weight().detach().cpu()
|
|
581
|
+
out[f"moe.experts.{j}.conv.conv.bias"] = e.bias.detach().cpu()
|
|
582
|
+
else: # plain ConvExpert (ctrl) — already standard keys, copy through
|
|
583
|
+
for k, v in sd.items():
|
|
584
|
+
if k.startswith(f"moe.experts.{j}."):
|
|
585
|
+
out[k] = v
|
|
586
|
+
return out
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
590
|
+
# N3 — DBES expert-specialization diagnostic (MEASURE-ONLY, gradient-free).
|
|
591
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
592
|
+
@torch.no_grad()
|
|
593
|
+
def dbes_specialization(model: CLMConvMoE, x: torch.Tensor) -> dict:
|
|
594
|
+
"""Differentiation-of-Behaviour Expert Specialization (DBES, 2605.18523).
|
|
595
|
+
|
|
596
|
+
On a batch x (B,T) of bytes, run the trunk up to the MoE input, then:
|
|
597
|
+
* expert_div = mean pairwise (1 - cosine) between expert OUTPUT maps
|
|
598
|
+
(how differently the experts transform the same input).
|
|
599
|
+
* router_entropy = mean per-token routing entropy (nats).
|
|
600
|
+
* usage_gini = Gini of mean per-expert routing mass (1=one expert hogs all).
|
|
601
|
+
Low expert_div + low usage spread => experts are NOT differentiated, a
|
|
602
|
+
candidate cause of a ρ·weave recombination floor (former G1). Pure diagnostic — no grad."""
|
|
603
|
+
b = model
|
|
604
|
+
h = b.embed(x).transpose(1, 2)
|
|
605
|
+
h = b.embed_conv(h)
|
|
606
|
+
for layer in b.trunk:
|
|
607
|
+
h = layer(h)
|
|
608
|
+
# expert outputs on the SAME pre-MoE activation
|
|
609
|
+
outs = []
|
|
610
|
+
for e in b.moe.experts:
|
|
611
|
+
outs.append(e(h)) # (B, C, T)
|
|
612
|
+
n_e = len(outs)
|
|
613
|
+
# pairwise output cosine distance (flatten B,C,T)
|
|
614
|
+
flat = [o.reshape(-1) for o in outs]
|
|
615
|
+
div, npair = 0.0, 0
|
|
616
|
+
for i in range(n_e):
|
|
617
|
+
for j in range(i + 1, n_e):
|
|
618
|
+
cos = F.cosine_similarity(flat[i], flat[j], dim=0).item()
|
|
619
|
+
div += (1.0 - cos); npair += 1
|
|
620
|
+
expert_div = (div / npair) if npair else 0.0
|
|
621
|
+
# router stats
|
|
622
|
+
logits = b.moe.router(h) # (B, n_e, T)
|
|
623
|
+
probs = F.softmax(logits, dim=1)
|
|
624
|
+
ent = -(probs * torch.log(probs + 1e-9)).sum(dim=1).mean().item()
|
|
625
|
+
usage = probs.mean(dim=(0, 2)) # (n_e,)
|
|
626
|
+
u = torch.sort(usage).values
|
|
627
|
+
nn_ = u.numel()
|
|
628
|
+
# Gini = (2*sum(i*u_i)/(n*sum u) ) - (n+1)/n
|
|
629
|
+
idx = torch.arange(1, nn_ + 1, dtype=u.dtype, device=u.device)
|
|
630
|
+
gini = (2.0 * (idx * u).sum() / (nn_ * u.sum() + 1e-9) - (nn_ + 1) / nn_).item()
|
|
631
|
+
return {"expert_div": round(expert_div, 5),
|
|
632
|
+
"router_entropy": round(ent, 5),
|
|
633
|
+
"usage_gini": round(gini, 5),
|
|
634
|
+
"usage": [round(float(z), 5) for z in usage.tolist()],
|
|
635
|
+
"n_experts": n_e}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
639
|
+
# N8 — jamo (자모) compositional teach signal. We predict, per Hangul-syllable
|
|
640
|
+
# byte position, a coarse jamo class so the trunk learns sub-character structure.
|
|
641
|
+
# Hangul syllables are UTF-8 3-byte sequences (0xEA..0xED leading); we derive a
|
|
642
|
+
# cheap jamo-bucket target from the syllable code point's (lead, vowel, tail).
|
|
643
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
644
|
+
class JamoHead(nn.Module):
|
|
645
|
+
"""Aux head: trunk penultimate -> coarse jamo class logits. Dropped at serialize."""
|
|
646
|
+
def __init__(self, d, n_jamo=64):
|
|
647
|
+
super().__init__()
|
|
648
|
+
self.proj = nn.Conv1d(d, n_jamo, 1)
|
|
649
|
+
self.n_jamo = n_jamo
|
|
650
|
+
|
|
651
|
+
def forward(self, h): # h: (B, d, T) -> (B, n_jamo, T)
|
|
652
|
+
return self.proj(h)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def jamo_targets(tokens: torch.Tensor, n_jamo: int) -> torch.Tensor:
|
|
656
|
+
"""Map each byte to a coarse jamo bucket (0=non-Hangul-lead). Cheap, byte-level:
|
|
657
|
+
Hangul UTF-8 lead bytes 0xEA-0xED get a bucket from (byte & 0x3f) % (n_jamo-1) +1,
|
|
658
|
+
everything else -> 0 (ignored class). This is a weak teach signal that biases the
|
|
659
|
+
trunk toward Korean sub-character regularity without needing a full jamo decomposer."""
|
|
660
|
+
is_lead = (tokens >= 0xEA) & (tokens <= 0xED)
|
|
661
|
+
bucket = ((tokens & 0x3F) % (n_jamo - 1)) + 1
|
|
662
|
+
return torch.where(is_lead, bucket, torch.zeros_like(tokens))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
666
|
+
# Objective heads (carried over from objrun H_1602 — OPTIONAL coupling).
|
|
667
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
668
|
+
def _ce(logits, targets, V):
|
|
669
|
+
return F.cross_entropy(logits.transpose(1, 2).reshape(-1, V), targets.reshape(-1))
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# NOTE (H_1640): every objective now accepts an OPTIONAL penultimate=(B,d,T) kwarg
|
|
673
|
+
# (the post-MoE pre-readout trunk site). The inherited objectives ignore it; the new
|
|
674
|
+
# compositional objectives consume it. Plain-function objectives have no params; the
|
|
675
|
+
# two aux-head objectives are nn.Modules whose params are added to the optimizer in
|
|
676
|
+
# main() and DROPPED at serialize (they never enter model.state_dict).
|
|
677
|
+
def loss_ce_marginal(logits, targets, V, gen, penultimate=None):
|
|
678
|
+
return _ce(logits, targets, V), {}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def loss_infonce(logits, targets, V, gen, penultimate=None):
|
|
682
|
+
ce = _ce(logits, targets, V)
|
|
683
|
+
lg = logits.transpose(1, 2).reshape(-1, V)
|
|
684
|
+
tgt = targets.reshape(-1); N = tgt.shape[0]
|
|
685
|
+
pos = lg.gather(1, tgt.unsqueeze(1))
|
|
686
|
+
neg_idx = torch.randint(0, V, (N, INFONCE_NEG), generator=gen, device=lg.device)
|
|
687
|
+
neg = lg.gather(1, neg_idx).masked_fill(neg_idx == tgt.unsqueeze(1), float("-inf"))
|
|
688
|
+
cand = torch.cat([pos, neg], dim=1)
|
|
689
|
+
infonce = F.cross_entropy(cand, torch.zeros(N, dtype=torch.long, device=lg.device))
|
|
690
|
+
return ce + INFONCE_LAMBDA * infonce, {"infonce": float(infonce.detach())}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def loss_contrastive_equilibrium(logits, targets, V, gen, penultimate=None):
|
|
694
|
+
ce = _ce(logits, targets, V)
|
|
695
|
+
lg = logits.transpose(1, 2).reshape(-1, V); tgt = targets.reshape(-1)
|
|
696
|
+
logp = F.log_softmax(lg, dim=1)
|
|
697
|
+
e_pos = -logp.gather(1, tgt.unsqueeze(1)).mean()
|
|
698
|
+
with torch.no_grad():
|
|
699
|
+
samp = torch.multinomial(logp.exp(), 1, generator=gen).squeeze(1)
|
|
700
|
+
e_neg = -logp.gather(1, samp.unsqueeze(1)).mean()
|
|
701
|
+
eq = F.relu(e_pos - e_neg + EQ_MARGIN)
|
|
702
|
+
return ce + EQ_LAMBDA * eq, {"e_pos": float(e_pos.detach()),
|
|
703
|
+
"e_neg": float(e_neg.detach()), "eq": float(eq.detach())}
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
707
|
+
# ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ NEW OBJECTIVES (H_1640) ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▛
|
|
708
|
+
# Three NEW compositional TRAINING-OBJECTIVE loss functions added to CE. Each is a
|
|
709
|
+
# training-side pressure that reshapes the gradient the TRUNK receives (not a readout
|
|
710
|
+
# op). Verdict later = engine-native `anima-python evaluate` on the frozen G1 bar.
|
|
711
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
712
|
+
|
|
713
|
+
# ── LEVER 1: predictive_info — multi-step predictive-coding aux ───────────────
|
|
714
|
+
class PredictiveInfoObjective(nn.Module):
|
|
715
|
+
"""Reward the trunk penultimate h_t for carrying predictive info about FUTURE
|
|
716
|
+
tokens k=2,3,4 steps ahead, not just the immediate next token (which CE already
|
|
717
|
+
covers). One linear head per horizon maps h_t -> V-way logits over token y_{t+j}.
|
|
718
|
+
|
|
719
|
+
aux = mean_j CE( head_j(h[:, :, :T-j]) , y[:, j:] ) (j in HORIZONS-1)
|
|
720
|
+
|
|
721
|
+
(y is already next-token, so y[:,0] = token after x_0; predicting y[:,j:] from
|
|
722
|
+
h[:, :, :T-j] means predicting the token j positions further ahead.) This is the
|
|
723
|
+
predictive-information bottleneck / CPC objective (Bialek-Tishby predictive
|
|
724
|
+
information; van den Oord CPC 1807.03748; Rao&Ballard predictive coding): to
|
|
725
|
+
minimize it the trunk must compress the context into a code that stays predictive
|
|
726
|
+
multiple steps out, which requires COMPOSING context factors rather than memorizing
|
|
727
|
+
the 1-step marginal. Heads are DROPPED at serialize (engine reads only the additive
|
|
728
|
+
1-step readout)."""
|
|
729
|
+
|
|
730
|
+
def __init__(self, d, V, horizons=PREDINFO_HORIZONS, lam=PREDINFO_LAMBDA):
|
|
731
|
+
super().__init__()
|
|
732
|
+
self.horizons = tuple(int(j) - 1 for j in horizons) # steps BEYOND next-token
|
|
733
|
+
self.lam = lam; self.V = V
|
|
734
|
+
self.heads = nn.ModuleList(nn.Conv1d(d, V, 1) for _ in self.horizons)
|
|
735
|
+
|
|
736
|
+
def forward(self, logits, targets, V, gen, penultimate=None):
|
|
737
|
+
ce = _ce(logits, targets, V)
|
|
738
|
+
if penultimate is None:
|
|
739
|
+
return ce, {}
|
|
740
|
+
h = penultimate # (B, d, T)
|
|
741
|
+
T = h.shape[-1]
|
|
742
|
+
terms = []
|
|
743
|
+
for hd, j in zip(self.heads, self.horizons):
|
|
744
|
+
if j <= 0 or T - j < 1:
|
|
745
|
+
continue
|
|
746
|
+
pl = hd(h[:, :, :T - j]) # (B, V, T-j)
|
|
747
|
+
pt = targets[:, j:] # (B, T-j) token j-ahead
|
|
748
|
+
terms.append(F.cross_entropy(
|
|
749
|
+
pl.transpose(1, 2).reshape(-1, V), pt.reshape(-1)))
|
|
750
|
+
if not terms:
|
|
751
|
+
return ce, {}
|
|
752
|
+
aux = torch.stack(terms).mean()
|
|
753
|
+
return ce + self.lam * aux, {"predinfo": float(aux.detach()),
|
|
754
|
+
"predinfo_h": len(terms)}
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# ── LEVER 2: constructive_bind — HRR trained-bind reconstruction aux ──────────
|
|
758
|
+
def _circ_conv(a, b):
|
|
759
|
+
"""Circular convolution (HRR binding) along the last dim via FFT. Real inputs."""
|
|
760
|
+
fa = torch.fft.rfft(a, dim=-1); fb = torch.fft.rfft(b, dim=-1)
|
|
761
|
+
return torch.fft.irfft(fa * fb, n=a.shape[-1], dim=-1)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _circ_corr(c, a):
|
|
765
|
+
"""Circular correlation (HRR UNbinding): recover b from c=a⊛b given a.
|
|
766
|
+
unbind(c, a) = c ⊛ involution(a); in Fourier: irfft( conj(fft a) * fft c )."""
|
|
767
|
+
fc = torch.fft.rfft(c, dim=-1); fa = torch.fft.rfft(a, dim=-1)
|
|
768
|
+
return torch.fft.irfft(torch.conj(fa) * fc, n=c.shape[-1], dim=-1)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
class ConstructiveBindObjective(nn.Module):
|
|
772
|
+
"""TRAINED CONSTRUCTIVE BIND on the trunk penultimate (the untried framebreak piece).
|
|
773
|
+
|
|
774
|
+
From each penultimate vector h_t, two learned linear projections extract a ROLE r_t
|
|
775
|
+
and a FILLER f_t (dim m). They are BOUND by circular convolution c_t = r_t ⊛ f_t
|
|
776
|
+
(Plate 1995 Holographic Reduced Representations / Smolensky 1990 Tensor-Product
|
|
777
|
+
Representations — VSA binding). Two constraints sculpt a COMPOSITIONAL code:
|
|
778
|
+
|
|
779
|
+
(1) UNBIND-RECOVERS-FILLER: unbind(c_t, r_t) ≈ f_t → 1 - cos(f_hat, f_t).
|
|
780
|
+
Forces the bound composite to actually SUPPORT clean decomposition (a real
|
|
781
|
+
bind, not an additive blur — the exact property a pure additive readout lacks).
|
|
782
|
+
(2) COMPOSITE-PREDICTS-NEXT: a linear decoder maps the bound c_t to next-token
|
|
783
|
+
logits, CE against y_t. Forces the binding to carry TASK signal, so gradient
|
|
784
|
+
sculpts task-relevant compositional factors into the trunk.
|
|
785
|
+
|
|
786
|
+
aux = CBIND_UNBIND_W * (1 - cos(unbind(r⊛f, r), f)).mean()
|
|
787
|
+
+ CBIND_PRED_W * CE(dec(r⊛f), y)
|
|
788
|
+
|
|
789
|
+
All of {Wr, Wf, dec} live OUTSIDE model.state_dict → DROPPED at serialize; the
|
|
790
|
+
engine reads only the standard additive readout. Gradient flows into the trunk
|
|
791
|
+
through h_t, so the trunk is pushed toward a bind-decomposable representation."""
|
|
792
|
+
|
|
793
|
+
def __init__(self, d, V, m=CBIND_DIM, lam=CBIND_LAMBDA):
|
|
794
|
+
super().__init__()
|
|
795
|
+
self.m = m; self.lam = lam; self.V = V
|
|
796
|
+
self.role = nn.Conv1d(d, m, 1)
|
|
797
|
+
self.fill = nn.Conv1d(d, m, 1)
|
|
798
|
+
self.dec = nn.Conv1d(m, V, 1) # bound composite -> next-token logits
|
|
799
|
+
|
|
800
|
+
def forward(self, logits, targets, V, gen, penultimate=None):
|
|
801
|
+
ce = _ce(logits, targets, V)
|
|
802
|
+
if penultimate is None:
|
|
803
|
+
return ce, {}
|
|
804
|
+
h = penultimate # (B, d, T)
|
|
805
|
+
r = self.role(h).transpose(1, 2) # (B, T, m)
|
|
806
|
+
f = self.fill(h).transpose(1, 2) # (B, T, m)
|
|
807
|
+
c = _circ_conv(r, f) # (B, T, m) bound composite
|
|
808
|
+
# (1) unbind must recover the filler
|
|
809
|
+
f_hat = _circ_corr(c, r) # (B, T, m)
|
|
810
|
+
unbind = (1.0 - F.cosine_similarity(f_hat, f, dim=-1)).mean()
|
|
811
|
+
# (2) bound composite must predict the next token
|
|
812
|
+
dec_logits = self.dec(c.transpose(1, 2)) # (B, V, T)
|
|
813
|
+
pred = F.cross_entropy(
|
|
814
|
+
dec_logits.transpose(1, 2).reshape(-1, V), targets.reshape(-1))
|
|
815
|
+
aux = CBIND_UNBIND_W * unbind + CBIND_PRED_W * pred
|
|
816
|
+
return ce + self.lam * aux, {"cbind_unbind": float(unbind.detach()),
|
|
817
|
+
"cbind_pred": float(pred.detach()),
|
|
818
|
+
"cbind": float(aux.detach())}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# ── LEVER 3: composed_nce — composed-negative (wrong-composition) InfoNCE ─────
|
|
822
|
+
def loss_composed_nce(logits, targets, V, gen, penultimate=None):
|
|
823
|
+
"""InfoNCE whose negatives are the SAME bag of tokens present in the window but at
|
|
824
|
+
the WRONG position (targets permuted WITHIN each sequence) = same-concept-set /
|
|
825
|
+
wrong-composition. Contrasting the true token->position assignment against these
|
|
826
|
+
hard negatives directly rewards getting the COMPOSITION right, not merely the
|
|
827
|
+
concept set (plain infonce uses RANDOM vocab negatives = membership-only).
|
|
828
|
+
|
|
829
|
+
pos_n = logit[n, y_t] (right token here)
|
|
830
|
+
neg_n,p = logit[n, y_perm_p(t)] for p=1..CNCE_PERMS (a within-window token
|
|
831
|
+
assigned to the wrong slot)
|
|
832
|
+
L = CE( [pos, neg...] , 0 ) softmax over the assignment
|
|
833
|
+
|
|
834
|
+
Operates on the readout logits — no aux params; gradient flows readout->trunk. A
|
|
835
|
+
permuted token that coincides with the true target is masked to -inf (not a negative)."""
|
|
836
|
+
ce = _ce(logits, targets, V)
|
|
837
|
+
B, Vv, T = logits.shape
|
|
838
|
+
lg = logits.transpose(1, 2).reshape(-1, Vv) # (B*T, V)
|
|
839
|
+
tgt = targets.reshape(-1) # (B*T,)
|
|
840
|
+
N = tgt.shape[0]
|
|
841
|
+
pos = lg.gather(1, tgt.unsqueeze(1)) # (N,1)
|
|
842
|
+
negs = []
|
|
843
|
+
for _ in range(CNCE_PERMS):
|
|
844
|
+
# independent within-sequence permutation of the target order per batch row
|
|
845
|
+
perm = torch.stack([torch.randperm(T, generator=gen, device=logits.device)
|
|
846
|
+
for _ in range(B)]) # (B, T)
|
|
847
|
+
y_perm = targets.gather(1, perm).reshape(-1) # (N,) same bag, wrong slots
|
|
848
|
+
s = lg.gather(1, y_perm.unsqueeze(1)) # (N,1)
|
|
849
|
+
s = s.masked_fill((y_perm == tgt).unsqueeze(1), float("-inf"))
|
|
850
|
+
negs.append(s)
|
|
851
|
+
cand = torch.cat([pos] + negs, dim=1) # (N, 1+CNCE_PERMS)
|
|
852
|
+
cnce = F.cross_entropy(cand, torch.zeros(N, dtype=torch.long, device=lg.device))
|
|
853
|
+
return ce + CNCE_LAMBDA * cnce, {"composed_nce": float(cnce.detach())}
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# Objective registry. Value = a BUILDER(d, V, device) so the two aux-head objectives can
|
|
857
|
+
# allocate learnable params; the plain-function ones ignore the args and return the fn.
|
|
858
|
+
# `needs_penultimate` marks which objectives consume the trunk penultimate site.
|
|
859
|
+
OBJECTIVE_BUILDERS = {
|
|
860
|
+
"ce_marginal": lambda d, V, dev: loss_ce_marginal,
|
|
861
|
+
"infonce": lambda d, V, dev: loss_infonce,
|
|
862
|
+
"contrastive_equilibrium": lambda d, V, dev: loss_contrastive_equilibrium,
|
|
863
|
+
"predictive_info": lambda d, V, dev: PredictiveInfoObjective(d, V).to(dev),
|
|
864
|
+
"constructive_bind": lambda d, V, dev: ConstructiveBindObjective(d, V).to(dev),
|
|
865
|
+
"composed_nce": lambda d, V, dev: loss_composed_nce,
|
|
866
|
+
}
|
|
867
|
+
OBJ_NEEDS_PENULTIMATE = {"predictive_info", "constructive_bind"}
|
|
868
|
+
OBJECTIVES = OBJECTIVE_BUILDERS # back-compat alias for --objective choices list
|
|
869
|
+
|
|
870
|
+
# arm -> (tlora_on, dict_aux_on, jamo_aux_on)
|
|
871
|
+
ARMS = {
|
|
872
|
+
"ctrl": (False, False, False),
|
|
873
|
+
"tlora": (True, False, False),
|
|
874
|
+
"tlora_dict": (True, True, False),
|
|
875
|
+
"tlora_jamo": (True, False, True),
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
880
|
+
# WARM-START (`--init`) — load a base ckpt's weights into a freshly-built model.
|
|
881
|
+
# The KEYSTONE for continue-training / warm-FT (e.g. ρ·form🟢 (former G0) h1129 trunk → ρ·weave (former G1) lever
|
|
882
|
+
# test, per memory g1-fromscratch-blocked-by-g0-undertrain). Symmetric with
|
|
883
|
+
# serialize: ByteGPT `.bin` is read by core/serialize.deserialize_bytegpt (the
|
|
884
|
+
# byte-inverse of serialize()); `.pt` is a plain torch state_dict. H_247 lesson —
|
|
885
|
+
# a silent shape mismatch can floor +2.5 nats, so every path shape-guards HARD.
|
|
886
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
887
|
+
def _warm_start(model, init_path, is_bytegpt, expect_cfg):
|
|
888
|
+
"""Load weights from `init_path` into `model` in place. Returns a 1-line report str.
|
|
889
|
+
|
|
890
|
+
expect_cfg = {vocab,d,n_layer,n_head,block} of the freshly-built model (bytegpt) or
|
|
891
|
+
{d,L} (clm). Raises ValueError on any dim/layer mismatch (H_247 hard guard)."""
|
|
892
|
+
low = str(init_path).lower()
|
|
893
|
+
if low.endswith(".clm"):
|
|
894
|
+
raise ValueError(
|
|
895
|
+
f"--init {init_path}: a quantized `.clm` (int4) is NOT a warm-start source — "
|
|
896
|
+
f"dequant→torch-state_dict remap is a follow-on. Warm-start from the pre-serialize "
|
|
897
|
+
f"`.pt` (full precision) instead (H_247: quant noise as warm-init can floor CE).")
|
|
898
|
+
|
|
899
|
+
if low.endswith(".bin"):
|
|
900
|
+
if not is_bytegpt:
|
|
901
|
+
raise ValueError(f"--init {init_path}: a `.bin` is a ByteGPT engine ckpt but "
|
|
902
|
+
f"--arch=clm; use a CLM `.pt`, or --arch bytegpt.")
|
|
903
|
+
sd, cfg = S.deserialize_bytegpt(init_path)
|
|
904
|
+
# HARD shape guard (H_247): every header field must match the built model.
|
|
905
|
+
for k in ("vocab", "d", "n_layer", "n_head", "block"):
|
|
906
|
+
if int(cfg[k]) != int(expect_cfg[k]):
|
|
907
|
+
raise ValueError(
|
|
908
|
+
f"--init {init_path}: ByteGPT header {k}={cfg[k]} != built model {k}="
|
|
909
|
+
f"{expect_cfg[k]}. Match --d/--L/--seq-len (H_247: warm-init mismatch floors CE).")
|
|
910
|
+
missing, unexpected = model.load_state_dict(sd, strict=False)
|
|
911
|
+
# tied head → tok/head share storage; only benign missing/unexpected tolerated.
|
|
912
|
+
bad_missing = [k for k in missing if k not in ("head.weight",)]
|
|
913
|
+
if bad_missing or unexpected:
|
|
914
|
+
raise ValueError(f"--init {init_path}: state_dict key mismatch "
|
|
915
|
+
f"missing={list(missing)} unexpected={list(unexpected)}")
|
|
916
|
+
return (f"warm-start ✓ ByteGPT .bin loaded ({cfg['n_layer']}L d={cfg['d']} "
|
|
917
|
+
f"block={cfg['block']}) missing={list(missing)} unexpected={list(unexpected)}")
|
|
918
|
+
|
|
919
|
+
if low.endswith(".pt") or low.endswith(".pth"):
|
|
920
|
+
ck = torch.load(init_path, map_location="cpu", weights_only=False)
|
|
921
|
+
sd = ck.get("model", ck) if isinstance(ck, dict) else ck
|
|
922
|
+
model_sd = model.state_dict()
|
|
923
|
+
# per-key HARD shape guard (H_247) — reject any shape-mismatched overlap.
|
|
924
|
+
loadable, shape_bad = {}, []
|
|
925
|
+
for k, v in sd.items():
|
|
926
|
+
if k in model_sd:
|
|
927
|
+
if tuple(model_sd[k].shape) == tuple(v.shape):
|
|
928
|
+
loadable[k] = v
|
|
929
|
+
else:
|
|
930
|
+
shape_bad.append(f"{k}:{tuple(v.shape)}!={tuple(model_sd[k].shape)}")
|
|
931
|
+
if shape_bad:
|
|
932
|
+
raise ValueError(f"--init {init_path}: shape mismatch on {shape_bad} "
|
|
933
|
+
f"(H_247: warm-init mismatch floors CE — match --d/--L/--arch).")
|
|
934
|
+
if not loadable:
|
|
935
|
+
raise ValueError(f"--init {init_path}: 0 keys overlap the built model — "
|
|
936
|
+
f"wrong arch/config? (ckpt keys e.g. {list(sd)[:4]})")
|
|
937
|
+
missing, unexpected = model.load_state_dict(loadable, strict=False)
|
|
938
|
+
return (f"warm-start ✓ .pt loaded {len(loadable)}/{len(model_sd)} keys "
|
|
939
|
+
f"(untouched={len(missing)} extra-in-ckpt={len(sd) - len(loadable)})")
|
|
940
|
+
|
|
941
|
+
raise ValueError(f"--init {init_path}: unknown extension — expected .bin (ByteGPT engine) "
|
|
942
|
+
f"or .pt/.pth (torch state_dict).")
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
946
|
+
# §4 TrainShell — composite train module (DDP wrap target).
|
|
947
|
+
# Wraps (model, objfn-if-Module, jamo_head) so DistributedDataParallel's reducer
|
|
948
|
+
# covers the aux-head params (predinfo/cbind heads + jamo head live OUTSIDE model —
|
|
949
|
+
# wrapping only `model` would silently NEVER allreduce their grads, §10.1). forward()
|
|
950
|
+
# holds the VERBATIM per-step loss-composition block (both the bf16-autocast and fp32
|
|
951
|
+
# variants) relocated from the train loop, incl. trunk_penultimate as a method reading
|
|
952
|
+
# self.model. N==1: the shell is called UNWRAPPED — shell(x, y, …) is the same graph +
|
|
953
|
+
# op order + RNG-draw order as the pre-refactor inline block, so the .clm is byte-
|
|
954
|
+
# identical (the refactor regression gate 9.1). The shell has NO own params/buffers, so
|
|
955
|
+
# constructing it consumes no RNG.
|
|
956
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
957
|
+
class TrainShell(nn.Module):
|
|
958
|
+
def __init__(self, model, objfn, jamo_head, *, is_bytegpt, V,
|
|
959
|
+
obj_needs_pen, dict_on, jamo_on, bf16, device):
|
|
960
|
+
super().__init__()
|
|
961
|
+
self.model = model
|
|
962
|
+
# objfn is EITHER an nn.Module (predictive_info/constructive_bind aux heads) or a
|
|
963
|
+
# plain function (ce_marginal/infonce/…). Register the module form as a submodule so
|
|
964
|
+
# its params enter the DDP bucket set; keep the function form as a bare attribute.
|
|
965
|
+
self.objfn = objfn if isinstance(objfn, nn.Module) else None
|
|
966
|
+
self._objfn_fn = None if isinstance(objfn, nn.Module) else objfn
|
|
967
|
+
self.jamo_head = jamo_head # None when jamo off
|
|
968
|
+
self.is_bytegpt = is_bytegpt
|
|
969
|
+
self.V = V
|
|
970
|
+
self.obj_needs_pen = obj_needs_pen
|
|
971
|
+
self.dict_on = dict_on
|
|
972
|
+
self.jamo_on = jamo_on
|
|
973
|
+
self.need_pen = obj_needs_pen or dict_on or jamo_on
|
|
974
|
+
self.bf16 = bf16
|
|
975
|
+
self.device = device
|
|
976
|
+
|
|
977
|
+
def _objfn(self):
|
|
978
|
+
return self.objfn if self.objfn is not None else self._objfn_fn
|
|
979
|
+
|
|
980
|
+
def trunk_penultimate(self, x):
|
|
981
|
+
# VERBATIM relocation of the former module-level trunk_penultimate closure
|
|
982
|
+
# (reads self.model). ByteGPT exposes its pre-head hidden directly; CLM recomputes
|
|
983
|
+
# the trunk to the pre-readout MoE/norm_out site (note: pre-SLW, as before).
|
|
984
|
+
m = self.model
|
|
985
|
+
if self.is_bytegpt:
|
|
986
|
+
return m(x)["penultimate"] # (B, d, T) — ln_f(x) pre-head
|
|
987
|
+
h = m.embed(x).transpose(1, 2)
|
|
988
|
+
h = m.embed_conv(h)
|
|
989
|
+
for layer in m.trunk:
|
|
990
|
+
h = layer(h)
|
|
991
|
+
hm, _ = m.moe(h)
|
|
992
|
+
hm = m.norm_out(hm)
|
|
993
|
+
return hm # (B, d, T) — pre-readout dictionary site
|
|
994
|
+
|
|
995
|
+
def forward(self, x, y, obj_gen, dict_lambda, jamo_lambda):
|
|
996
|
+
# ── VERBATIM relocation of the per-step loss-composition block (bf16 + fp32). The
|
|
997
|
+
# autocast context stays wrapping ONLY the forward/compose (backward is at the
|
|
998
|
+
# callsite, outside autocast — DDP hooks fire there). Returns (loss, detached CE,
|
|
999
|
+
# aux) so the callsite can backward + all-reduce the shard CE (§3).
|
|
1000
|
+
model = self.model
|
|
1001
|
+
objfn = self._objfn()
|
|
1002
|
+
V = self.V
|
|
1003
|
+
aux = {}
|
|
1004
|
+
need_pen = self.need_pen
|
|
1005
|
+
if self.bf16 and self.device.startswith("cuda"):
|
|
1006
|
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
1007
|
+
out = model(x, y)
|
|
1008
|
+
h = self.trunk_penultimate(x) if need_pen else None
|
|
1009
|
+
pen = h.float() if (h is not None and self.obj_needs_pen) else None
|
|
1010
|
+
obj_loss, oaux = objfn(out["logits"].float(), y, V, obj_gen, penultimate=pen)
|
|
1011
|
+
loss = obj_loss + out["aux_loss"]
|
|
1012
|
+
if self.dict_on:
|
|
1013
|
+
dloss = dict_lambda * h.abs().mean()
|
|
1014
|
+
loss = loss + dloss; aux["dict_l1"] = float(dloss.detach())
|
|
1015
|
+
if self.jamo_on:
|
|
1016
|
+
jl = self.jamo_head(h.float())
|
|
1017
|
+
jt = jamo_targets(y, self.jamo_head.n_jamo)
|
|
1018
|
+
jloss = jamo_lambda * F.cross_entropy(
|
|
1019
|
+
jl.transpose(1, 2).reshape(-1, self.jamo_head.n_jamo),
|
|
1020
|
+
jt.reshape(-1), ignore_index=0)
|
|
1021
|
+
loss = loss + jloss; aux["jamo"] = float(jloss.detach())
|
|
1022
|
+
else:
|
|
1023
|
+
out = model(x, y)
|
|
1024
|
+
h = self.trunk_penultimate(x) if need_pen else None
|
|
1025
|
+
pen = h if self.obj_needs_pen else None
|
|
1026
|
+
obj_loss, oaux = objfn(out["logits"], y, V, obj_gen, penultimate=pen)
|
|
1027
|
+
loss = obj_loss + out["aux_loss"]
|
|
1028
|
+
if self.dict_on:
|
|
1029
|
+
dloss = dict_lambda * h.abs().mean()
|
|
1030
|
+
loss = loss + dloss; aux["dict_l1"] = float(dloss.detach())
|
|
1031
|
+
if self.jamo_on:
|
|
1032
|
+
jl = self.jamo_head(h)
|
|
1033
|
+
jt = jamo_targets(y, self.jamo_head.n_jamo)
|
|
1034
|
+
jloss = jamo_lambda * F.cross_entropy(
|
|
1035
|
+
jl.transpose(1, 2).reshape(-1, self.jamo_head.n_jamo),
|
|
1036
|
+
jt.reshape(-1), ignore_index=0)
|
|
1037
|
+
loss = loss + jloss; aux["jamo"] = float(jloss.detach())
|
|
1038
|
+
aux.update(oaux)
|
|
1039
|
+
return loss, out["ce_loss"].detach(), aux
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def main():
|
|
1043
|
+
ap = argparse.ArgumentParser(
|
|
1044
|
+
description="anima canonical python trainer (`anima-python train`) — CLMConvMoE "
|
|
1045
|
+
"SAVANT+MITOSIS recipe + H_1640 arm×objective compositional levers")
|
|
1046
|
+
ap.add_argument("--arch", default="clm", choices=["clm", "bytegpt"],
|
|
1047
|
+
help="trunk architecture: clm=CLMConvMoE (default, .clm out) | "
|
|
1048
|
+
"bytegpt=24-layer GPT-2-class ByteGPT (.bin out) — the CLEAN G1 "
|
|
1049
|
+
"wall (single=2). The arm×objective compositional levers are "
|
|
1050
|
+
"arch-agnostic (operate on logits+penultimate); the CLM-specific "
|
|
1051
|
+
"levers (savant/mitosis/tlora/dict/jamo) are gated OFF for bytegpt.")
|
|
1052
|
+
ap.add_argument("--arm", default="ctrl", choices=list(ARMS))
|
|
1053
|
+
ap.add_argument("--objective", default="ce_marginal", choices=list(OBJECTIVES),
|
|
1054
|
+
help="OPTIONAL objrun coupling (default ce_marginal = standalone)")
|
|
1055
|
+
ap.add_argument("--tlora-rank", type=int, default=TLORA_RANK)
|
|
1056
|
+
ap.add_argument("--tlora-no-base", action="store_true", help="drop the dense base")
|
|
1057
|
+
ap.add_argument("--dict-lambda", type=float, default=DICT_LAMBDA)
|
|
1058
|
+
ap.add_argument("--jamo-lambda", type=float, default=JAMO_LAMBDA)
|
|
1059
|
+
# H_9200 E1 — gated-write forward-slot (SLW). --slw engages the CORE-owned
|
|
1060
|
+
# (core/slw.py) module on the CLMConvMoE penultimate; weights serialize into the
|
|
1061
|
+
# "SLW\x01" .clm trailer. Plain CE alone induces the slots (rung-3 de-risk 0.976
|
|
1062
|
+
# vs additive 0.145), so this is an ARCH lever (--objective stays ce_marginal).
|
|
1063
|
+
ap.add_argument("--slw", action="store_true",
|
|
1064
|
+
help="H_9200 E1: engage the gated-write forward-slot (core/slw.py)")
|
|
1065
|
+
ap.add_argument("--slw-n-slot", type=int, default=8, help="SLW addressable slots")
|
|
1066
|
+
ap.add_argument("--slw-k", type=int, default=64, help="SLW role/read key dim")
|
|
1067
|
+
ap.add_argument("--seed", type=int, default=7)
|
|
1068
|
+
ap.add_argument("--corpus", nargs="*", default=[])
|
|
1069
|
+
ap.add_argument("--cell-label", nargs="*", default=[])
|
|
1070
|
+
ap.add_argument("--canon", action="store_true")
|
|
1071
|
+
ap.add_argument("--d", type=int, default=0)
|
|
1072
|
+
ap.add_argument("--L", type=int, default=0)
|
|
1073
|
+
ap.add_argument("--steps", type=int, default=0)
|
|
1074
|
+
ap.add_argument("--seq-len", type=int, default=0)
|
|
1075
|
+
ap.add_argument("--batch-size", type=int, default=8)
|
|
1076
|
+
ap.add_argument("--lr", type=float, default=3e-4)
|
|
1077
|
+
ap.add_argument("--e0", type=int, default=2)
|
|
1078
|
+
ap.add_argument("--emax", type=int, default=3)
|
|
1079
|
+
ap.add_argument("--no-savant", action="store_true")
|
|
1080
|
+
ap.add_argument("--no-mitosis", action="store_true")
|
|
1081
|
+
ap.add_argument("--wd-floor", type=float, default=-1.0,
|
|
1082
|
+
help="N6 sweep: override savant wd floor (>=0 forces constant wd)")
|
|
1083
|
+
ap.add_argument("--dropout-floor", type=float, default=-1.0,
|
|
1084
|
+
help="N6 sweep: override savant dropout floor (>=0 forces constant dp)")
|
|
1085
|
+
ap.add_argument("--bf16", action="store_true")
|
|
1086
|
+
ap.add_argument("--sample", choices=["roundrobin", "proportional"], default="proportional")
|
|
1087
|
+
ap.add_argument("--val-frac", type=float, default=0.05)
|
|
1088
|
+
ap.add_argument("--val-every", type=int, default=200)
|
|
1089
|
+
ap.add_argument("--val-batches", type=int, default=4)
|
|
1090
|
+
ap.add_argument("--log-every", type=int, default=50)
|
|
1091
|
+
ap.add_argument("--dbes-every", type=int, default=0, help="0=final only; N=also every N steps")
|
|
1092
|
+
ap.add_argument("--ckpt-every", type=int, default=0,
|
|
1093
|
+
help="0=final .clm only; N=also dump <out>.step<N>.clm every N steps "
|
|
1094
|
+
"(step-window multiplex — 1 run yields 2000/4000/… checkpoints, train-py-4 isolation)")
|
|
1095
|
+
ap.add_argument("--out", default="")
|
|
1096
|
+
ap.add_argument("--ckpt-out", default="")
|
|
1097
|
+
ap.add_argument("--gauges-out", default="")
|
|
1098
|
+
ap.add_argument("--init", default="",
|
|
1099
|
+
help="warm-start ckpt path — load weights into the freshly-built model "
|
|
1100
|
+
"BEFORE training. ByteGPT: an engine `.bin` (5×u32 header) or a `.pt`; "
|
|
1101
|
+
"CLM: a `.pt` torch state_dict. Dim/layer mismatch → hard error "
|
|
1102
|
+
"(H_247: a warm-init shape mismatch can floor +2.5 nats). Quantized "
|
|
1103
|
+
"`.clm` is refused (dequant→state_dict remap is a follow-on; warm-start "
|
|
1104
|
+
"from the pre-serialize `.pt`).")
|
|
1105
|
+
# ── §7 multi-GPU data-parallel (DDP) launch surface ──────────────────────────
|
|
1106
|
+
ap.add_argument("--gpus", default="",
|
|
1107
|
+
help="multi-GPU DATA-PARALLEL (DDP) device ids, e.g. '0,1,2,3' (mirrors "
|
|
1108
|
+
"`anima sweep --gpus`). >1 id => self-re-exec under torchrun, one "
|
|
1109
|
+
"process/GPU. GLOBAL batch is PRESERVED = --batch-size (per-rank "
|
|
1110
|
+
"B/N); LR/schedule/corpus-mix/val-stream/serialize-format UNCHANGED "
|
|
1111
|
+
"vs the 1-GPU recipe (DDP is an execution strategy, not a recipe "
|
|
1112
|
+
"change). --batch-size MUST be divisible by N (hard error otherwise "
|
|
1113
|
+
"— no silent effective-batch change). 0 or 1 id => single-GPU path, "
|
|
1114
|
+
"byte-identical to today (every DDP branch skipped).")
|
|
1115
|
+
ap.add_argument("--ddp-verify-sync", action="store_true",
|
|
1116
|
+
help="DDP debug: every --val-every steps all-reduce a param-checksum and "
|
|
1117
|
+
"assert cross-rank agreement (catches a mitosis/optimizer desync at "
|
|
1118
|
+
"the split). Off by default (costs one collective per val).")
|
|
1119
|
+
ap.add_argument("--ddp-find-unused", action="store_true",
|
|
1120
|
+
help="DDP debug/escape-hatch: pass find_unused_parameters=True to DDP. Off "
|
|
1121
|
+
"by default — the current objective set fires every head every step "
|
|
1122
|
+
"(§4). Flip ON only if a FUTURE per-step-gated head makes DDP error on "
|
|
1123
|
+
"an unused param.")
|
|
1124
|
+
a = ap.parse_args()
|
|
1125
|
+
|
|
1126
|
+
# ══ §7 DDP launch: torchrun self-re-exec + worker init + N==1 short-circuit ══
|
|
1127
|
+
# Runs FIRST (before any CUDA allocation). os.execvpe replaces the process, so the
|
|
1128
|
+
# launcher's `> rf 2>&1` redirect is inherited and only rank 0 prints via p0().
|
|
1129
|
+
gpu_ids = [g for g in a.gpus.split(",") if g.strip() != ""]
|
|
1130
|
+
under_torchrun = "RANK" in os.environ
|
|
1131
|
+
# (1) RE-EXEC branch — >1 GPU requested and not yet under torchrun.
|
|
1132
|
+
if len(gpu_ids) > 1 and not under_torchrun:
|
|
1133
|
+
if not torch.cuda.is_available():
|
|
1134
|
+
sys.exit(f"[ddp] --gpus {a.gpus} requests {len(gpu_ids)}-way DDP but CUDA is not "
|
|
1135
|
+
f"available on this host. Multi-GPU DDP requires CUDA (NCCL backend); a "
|
|
1136
|
+
f"CPU-only run must use a single device (--gpus <one> or omit).")
|
|
1137
|
+
if torch.cuda.device_count() < len(gpu_ids):
|
|
1138
|
+
sys.exit(f"[ddp] --gpus {a.gpus} requests {len(gpu_ids)} devices but only "
|
|
1139
|
+
f"{torch.cuda.device_count()} CUDA device(s) present.")
|
|
1140
|
+
if a.batch_size % len(gpu_ids) != 0:
|
|
1141
|
+
sys.exit(f"[ddp] --batch-size {a.batch_size} is not divisible by --gpus N="
|
|
1142
|
+
f"{len(gpu_ids)} (global batch is preserved as per-rank B/N; adjust "
|
|
1143
|
+
f"--batch-size or --gpus). Refusing to pad — silent effective-batch "
|
|
1144
|
+
f"change would corrupt the frozen-recipe comparison.")
|
|
1145
|
+
_env = dict(os.environ)
|
|
1146
|
+
_env["CUDA_VISIBLE_DEVICES"] = ",".join(gpu_ids)
|
|
1147
|
+
os.execvpe("torchrun",
|
|
1148
|
+
["torchrun", "--standalone", f"--nproc_per_node={len(gpu_ids)}",
|
|
1149
|
+
os.path.abspath(__file__), *sys.argv[1:]], _env)
|
|
1150
|
+
# (3)/(4) WORKER init vs N==1 short-circuit.
|
|
1151
|
+
ddp_on = False
|
|
1152
|
+
rank, world, local_rank = 0, 1, 0
|
|
1153
|
+
if under_torchrun and int(os.environ.get("WORLD_SIZE", "1")) > 1:
|
|
1154
|
+
local_rank = int(os.environ["LOCAL_RANK"])
|
|
1155
|
+
rank = int(os.environ["RANK"])
|
|
1156
|
+
world = int(os.environ["WORLD_SIZE"])
|
|
1157
|
+
if a.batch_size % world != 0:
|
|
1158
|
+
sys.exit(f"[ddp] --batch-size {a.batch_size} not divisible by world_size {world}.")
|
|
1159
|
+
torch.cuda.set_device(local_rank)
|
|
1160
|
+
dist.init_process_group("nccl")
|
|
1161
|
+
ddp_on = True
|
|
1162
|
+
|
|
1163
|
+
def p0(*args, **kwargs):
|
|
1164
|
+
"""rank-0 print gate — every info/log print routes through this so an N-GPU run
|
|
1165
|
+
does not N-duplicate stdout (§7.3). N==1: rank==0, so p0 == print."""
|
|
1166
|
+
if rank == 0:
|
|
1167
|
+
print(*args, **kwargs)
|
|
1168
|
+
|
|
1169
|
+
is_bytegpt = (a.arch == "bytegpt")
|
|
1170
|
+
tlora_on, dict_on, jamo_on = ARMS[a.arm]
|
|
1171
|
+
savant_on = not a.no_savant
|
|
1172
|
+
mitosis_on = not a.no_mitosis
|
|
1173
|
+
# ── ByteGPT: the CLM-specific levers (savant/mitosis/tlora/dict/jamo) are gated OFF.
|
|
1174
|
+
# ByteGPT is a plain transformer (no MoE experts to split, no ConvExpert weight to
|
|
1175
|
+
# TLoRA-reparameterize); the ρ·weave (former G1) lever test it enables is arm=ctrl × the objective
|
|
1176
|
+
# matrix (the arch-agnostic trunk-objective losses). Only n_head is bytegpt-only.
|
|
1177
|
+
bg_n_head = 0
|
|
1178
|
+
if is_bytegpt:
|
|
1179
|
+
if a.arm != "ctrl":
|
|
1180
|
+
p0(f" [bytegpt] arm={a.arm} is CLM-specific → forcing arm=ctrl "
|
|
1181
|
+
f"(tlora/dict/jamo are ConvMoE-only)", flush=True)
|
|
1182
|
+
tlora_on = dict_on = jamo_on = False
|
|
1183
|
+
if savant_on or mitosis_on:
|
|
1184
|
+
p0(" [bytegpt] savant/mitosis are CLM-MoE-specific → gated OFF for bytegpt",
|
|
1185
|
+
flush=True)
|
|
1186
|
+
savant_on = False
|
|
1187
|
+
mitosis_on = False
|
|
1188
|
+
if a.canon:
|
|
1189
|
+
if is_bytegpt:
|
|
1190
|
+
d = a.d or 768; L = a.L or 24
|
|
1191
|
+
seq_len = a.seq_len or 1024; steps = a.steps or 2000
|
|
1192
|
+
# head_dim=64 invariant: n_head = d//64 (d=768→12, d=1024→16). A hardcoded
|
|
1193
|
+
# 12 broke warm-FT of the production d=1024 h1129 base (1024%12≠0 → "embed_dim
|
|
1194
|
+
# must be divisible by num_heads"; also mismatched h1129's n_head=16 state_dict).
|
|
1195
|
+
bg_n_head = max(1, d // 64)
|
|
1196
|
+
else:
|
|
1197
|
+
d = a.d or 3784; L = a.L or 4
|
|
1198
|
+
seq_len = a.seq_len or 1024; steps = a.steps or 2000
|
|
1199
|
+
else:
|
|
1200
|
+
if is_bytegpt:
|
|
1201
|
+
d = a.d or 64; L = a.L or 2
|
|
1202
|
+
seq_len = a.seq_len or 128; steps = a.steps or 60
|
|
1203
|
+
bg_n_head = 2
|
|
1204
|
+
else:
|
|
1205
|
+
d = a.d or 64; L = a.L or 2
|
|
1206
|
+
seq_len = a.seq_len or 128; steps = a.steps or 60
|
|
1207
|
+
e0, emax = a.e0, a.emax
|
|
1208
|
+
V, K = 256, 3
|
|
1209
|
+
# §7 device: under DDP each rank pins its own cuda:local_rank; N==1 = today's path.
|
|
1210
|
+
if ddp_on:
|
|
1211
|
+
device = f"cuda:{local_rank}"
|
|
1212
|
+
else:
|
|
1213
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
1214
|
+
objfn = OBJECTIVE_BUILDERS[a.objective](d, V, device) # aux-head objectives allocate params
|
|
1215
|
+
obj_is_module = isinstance(objfn, nn.Module)
|
|
1216
|
+
obj_needs_pen = a.objective in OBJ_NEEDS_PENULTIMATE
|
|
1217
|
+
|
|
1218
|
+
p0(f"=== anima-python train (canonical) arch={a.arch} arm={a.arm} obj={a.objective} seed={a.seed} ===", flush=True)
|
|
1219
|
+
if ddp_on:
|
|
1220
|
+
# §10.2 — prove the GLOBAL batch in the run record (global batch preserved, per-rank B/N).
|
|
1221
|
+
p0(f" [ddp] world_size={world} global_batch={a.batch_size} per_rank_batch="
|
|
1222
|
+
f"{a.batch_size // world} LR/schedule/corpus/val UNCHANGED vs 1-GPU", flush=True)
|
|
1223
|
+
if is_bytegpt:
|
|
1224
|
+
p0(f" levers: bytegpt trunk (CLM-specific tlora/dict/jamo/savant/mitosis OFF) "
|
|
1225
|
+
f"n_head={bg_n_head} block={seq_len}", flush=True)
|
|
1226
|
+
else:
|
|
1227
|
+
p0(f" levers: tlora={tlora_on}(rank={a.tlora_rank},base={not a.tlora_no_base}) "
|
|
1228
|
+
f"dict_aux={dict_on}(λ={a.dict_lambda}) jamo_aux={jamo_on}(λ={a.jamo_lambda})", flush=True)
|
|
1229
|
+
p0(f" device={device} d={d} L={L} E0={e0} Emax={emax} seq_len={seq_len} "
|
|
1230
|
+
f"steps={steps} bs={a.batch_size} sample={a.sample}", flush=True)
|
|
1231
|
+
if str(device).startswith("cuda"):
|
|
1232
|
+
cap = torch.cuda.get_device_capability(local_rank)
|
|
1233
|
+
p0(f" cuda: {torch.cuda.get_device_name(local_rank)} cap={cap[0]}.{cap[1]} torch={torch.__version__}", flush=True)
|
|
1234
|
+
|
|
1235
|
+
torch.manual_seed(a.seed)
|
|
1236
|
+
|
|
1237
|
+
if is_bytegpt:
|
|
1238
|
+
# ByteGPT block = the context window; use seq_len as the positional block size so a
|
|
1239
|
+
# non-canon toy stays small. n_head must divide d (validated by the config).
|
|
1240
|
+
bg_block = seq_len
|
|
1241
|
+
bg_cfg = ByteGPTConfig(vocab=V, d=d, n_layer=L, n_head=bg_n_head, block=bg_block)
|
|
1242
|
+
model = ByteGPT(bg_cfg).to(device)
|
|
1243
|
+
cfg = None
|
|
1244
|
+
jamo_head = None
|
|
1245
|
+
mito = None # no MoE experts to grow
|
|
1246
|
+
else:
|
|
1247
|
+
cfg = CLMConfig(n_experts=emax, n_trunk_layers=L, d_model=d, kernel_size=K,
|
|
1248
|
+
variant="AB", dilation_base=2, max_dilation=512,
|
|
1249
|
+
slw=a.slw, slw_n_slot=a.slw_n_slot, slw_k=a.slw_k)
|
|
1250
|
+
model = CLMConvMoE(cfg).to(device) # production additive readout (all arms)
|
|
1251
|
+
if tlora_on:
|
|
1252
|
+
install_tlora_experts(model, a.tlora_rank, base=not a.tlora_no_base)
|
|
1253
|
+
model.to(device)
|
|
1254
|
+
jamo_head = JamoHead(d).to(device) if jamo_on else None
|
|
1255
|
+
n_params = sum(p.numel() for p in model.parameters())
|
|
1256
|
+
p0(f" params: {n_params} ({n_params/1e6:.3f}M)"
|
|
1257
|
+
f"{' (+jamo head)' if jamo_on else ''}", flush=True)
|
|
1258
|
+
|
|
1259
|
+
if not is_bytegpt:
|
|
1260
|
+
mito = MitosisMoE(model, e0, emax)
|
|
1261
|
+
install_router_mask(model, mito)
|
|
1262
|
+
|
|
1263
|
+
# ── warm-start (`--init`): load a base ckpt into the freshly-built model. Done AFTER
|
|
1264
|
+
# the full architecture is built (tlora/mitosis installed) so state_dict keys line up;
|
|
1265
|
+
# strict=False tolerates lever-only keys (tlora/mito) absent from a plain-trunk base.
|
|
1266
|
+
if a.init:
|
|
1267
|
+
expect_cfg = ({"vocab": V, "d": d, "n_layer": L, "n_head": bg_n_head, "block": seq_len}
|
|
1268
|
+
if is_bytegpt else {"d": d, "L": L})
|
|
1269
|
+
report = _warm_start(model, a.init, is_bytegpt, expect_cfg)
|
|
1270
|
+
model.to(device)
|
|
1271
|
+
p0(f" [--init] {report}", flush=True)
|
|
1272
|
+
|
|
1273
|
+
params = (list(model.parameters())
|
|
1274
|
+
+ (list(jamo_head.parameters()) if jamo_head else [])
|
|
1275
|
+
+ (list(objfn.parameters()) if obj_is_module else [])) # H_1640 aux-head params
|
|
1276
|
+
if obj_is_module:
|
|
1277
|
+
n_obj = sum(p.numel() for p in objfn.parameters())
|
|
1278
|
+
p0(f" objective '{a.objective}' aux params: {n_obj} "
|
|
1279
|
+
f"(DROPPED at serialize — not in model.state_dict)", flush=True)
|
|
1280
|
+
opt = torch.optim.AdamW(params, lr=a.lr, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.0)
|
|
1281
|
+
|
|
1282
|
+
# ══ §4 TrainShell + DDP wrap (execution strategy — the recipe is unchanged) ═════
|
|
1283
|
+
# core_model = the UNWRAPPED inner model, used for ALL rank-0 probes/serialize
|
|
1284
|
+
# (dbes/gauges/val/materialize/_write_clm) — never through the DDP wrapper (§4/§6/§10.9).
|
|
1285
|
+
core_model = model
|
|
1286
|
+
shell = TrainShell(model, objfn, jamo_head, is_bytegpt=is_bytegpt, V=V,
|
|
1287
|
+
obj_needs_pen=obj_needs_pen, dict_on=dict_on, jamo_on=jamo_on,
|
|
1288
|
+
bf16=a.bf16, device=device)
|
|
1289
|
+
# §10.1 defense — the shell's param set MUST equal the optimizer's (aux heads covered).
|
|
1290
|
+
assert {id(p) for p in shell.parameters()} == {id(p) for p in params}, \
|
|
1291
|
+
"TrainShell params != optimizer params — an aux head would never be allreduced."
|
|
1292
|
+
if ddp_on:
|
|
1293
|
+
# §4/§10.8 — CLMConvMoE/ByteGPT/SLW carry NO batch-stat buffers, and mito.active_mask
|
|
1294
|
+
# is an intentionally-unregistered per-rank tensor; assert zero buffers so a FUTURE
|
|
1295
|
+
# registered buffer fails loudly instead of being silently stomped (broadcast_buffers=False).
|
|
1296
|
+
assert len(list(shell.buffers())) == 0, \
|
|
1297
|
+
"TrainShell grew a buffer — broadcast_buffers=False would desync it (§4/§10.8)."
|
|
1298
|
+
train_module = DDP(shell, device_ids=[local_rank], broadcast_buffers=False,
|
|
1299
|
+
find_unused_parameters=a.ddp_find_unused)
|
|
1300
|
+
# §2/§4 per-rank RNG divergence AFTER construction+wrap: decorrelate dropout masks
|
|
1301
|
+
# (default RNG) across ranks — a SHARED default stream gives sample i the SAME dropout
|
|
1302
|
+
# mask on every rank, a correlation the 1-GPU run lacks. Reseed AFTER wrap so init +
|
|
1303
|
+
# DDP's param broadcast already made params identical (reseeding BEFORE diverges init).
|
|
1304
|
+
torch.manual_seed(a.seed + 100003 * (rank + 1))
|
|
1305
|
+
if torch.cuda.is_available():
|
|
1306
|
+
torch.cuda.manual_seed_all(a.seed + 100003 * (rank + 1))
|
|
1307
|
+
else:
|
|
1308
|
+
train_module = shell # N==1: call the shell UNWRAPPED (same graph, no collectives)
|
|
1309
|
+
|
|
1310
|
+
gen = torch.Generator().manual_seed(42) # §3/§10.3 SHARED across ranks (NOT rank-offset)
|
|
1311
|
+
val_gen = torch.Generator().manual_seed(1234)
|
|
1312
|
+
# §4 obj_gen: reseed per rank so negatives/permutations decorrelate across shards (rank=0
|
|
1313
|
+
# for N==1 ⇒ +0 ⇒ byte-identical to today). Exact 1-GPU negative reproduction is impossible
|
|
1314
|
+
# anyway (it draws all negatives in one call) — accepted stochastic-equivalence (DESCENT gate).
|
|
1315
|
+
obj_gen = torch.Generator(device=device).manual_seed(20260628 + a.seed + 7919 * rank)
|
|
1316
|
+
|
|
1317
|
+
latch = {"on": False, "at": 0}
|
|
1318
|
+
i0 = GZ_UPPER
|
|
1319
|
+
i_floor = GZ_LOWER - 0.05
|
|
1320
|
+
split_step = max(1, steps // 2)
|
|
1321
|
+
|
|
1322
|
+
# ── corpus cells (reuse ByteCell + resolver) ─────────────────────────────
|
|
1323
|
+
cells, labels = [], []
|
|
1324
|
+
for ci, spec in enumerate(a.corpus):
|
|
1325
|
+
p = resolve_corpus_path(spec)
|
|
1326
|
+
cells.append(ByteCell(p, val_frac=a.val_frac))
|
|
1327
|
+
labels.append(a.cell_label[ci] if ci < len(a.cell_label) else f"cell{ci}")
|
|
1328
|
+
c = cells[-1]
|
|
1329
|
+
p0(f" corpus cell[{ci}] {labels[ci]:<12s} {p} size={c.size} "
|
|
1330
|
+
f"train={c.train_end} val_tail={c.size - c.train_end}", flush=True)
|
|
1331
|
+
if not cells:
|
|
1332
|
+
p0(" corpus: NONE -> synthetic smoke", flush=True)
|
|
1333
|
+
|
|
1334
|
+
_samp_cells = [c for c in cells if c.train_end >= seq_len + 2]
|
|
1335
|
+
_samp_w = torch.tensor([float(c.train_end) for c in _samp_cells]) \
|
|
1336
|
+
if _samp_cells else torch.tensor([1.0])
|
|
1337
|
+
|
|
1338
|
+
# §3/§5 GLOBAL batch preserved: --batch-size is the GLOBAL batch; each rank materializes
|
|
1339
|
+
# B_local = B_global // world. world==1 ⇒ B_local == B_global (N==1 byte-identical).
|
|
1340
|
+
B_global = a.batch_size
|
|
1341
|
+
B_local = B_global // world
|
|
1342
|
+
|
|
1343
|
+
def get_batch(step):
|
|
1344
|
+
if cells:
|
|
1345
|
+
# ── §3 SPEC phase (ALL ranks, IDENTICAL shared gen=42): draw the GLOBAL batch's
|
|
1346
|
+
# window specs in TODAY's interleaved order (proportional: multinomial→spec;
|
|
1347
|
+
# roundrobin: index→spec). This global spec list is byte-identical to the
|
|
1348
|
+
# 1-GPU draw; the proportional cell weighting is globally exact.
|
|
1349
|
+
specs = []
|
|
1350
|
+
for b in range(B_global):
|
|
1351
|
+
if a.sample == "proportional" and _samp_cells:
|
|
1352
|
+
ci = int(torch.multinomial(_samp_w, 1, generator=gen).item())
|
|
1353
|
+
cell = _samp_cells[ci]
|
|
1354
|
+
else:
|
|
1355
|
+
cell = cells[(step - 1 + b) % len(cells)]
|
|
1356
|
+
start = cell.window_spec(seq_len, gen) # None ⇒ synthetic fallback (no randint)
|
|
1357
|
+
specs.append((cell, start))
|
|
1358
|
+
# ── §3 MATERIALIZE phase (rank-local slice): rank r takes [r*B_local, (r+1)*B_local).
|
|
1359
|
+
lo = rank * B_local
|
|
1360
|
+
xs, ys = [], []
|
|
1361
|
+
for cell, start in specs[lo:lo + B_local]:
|
|
1362
|
+
if start is None:
|
|
1363
|
+
base = torch.arange(seq_len)
|
|
1364
|
+
w = (base % V, (base + 1) % V)
|
|
1365
|
+
else:
|
|
1366
|
+
w = cell.materialize(start, seq_len)
|
|
1367
|
+
xs.append(w[0]); ys.append(w[1])
|
|
1368
|
+
return torch.stack(xs).to(device), torch.stack(ys).to(device)
|
|
1369
|
+
base = torch.arange(seq_len)
|
|
1370
|
+
x = ((3 + base * 37) % V).unsqueeze(0).repeat(B_local, 1).to(device)
|
|
1371
|
+
y = ((14 + base * 37) % V).unsqueeze(0).repeat(B_local, 1).to(device)
|
|
1372
|
+
return x, y
|
|
1373
|
+
|
|
1374
|
+
# NOTE (§4): the trunk-penultimate helper (N7 dict/jamo aux + compositional objectives)
|
|
1375
|
+
# was RELOCATED verbatim into TrainShell.trunk_penultimate so the per-step loss graph is
|
|
1376
|
+
# one DDP forward(). The rank-0 probes below (val/dbes/gauges) use `model` directly.
|
|
1377
|
+
|
|
1378
|
+
@torch.no_grad()
|
|
1379
|
+
def cell_val_ce(c):
|
|
1380
|
+
if c.size - c.train_end < seq_len + 2:
|
|
1381
|
+
return None
|
|
1382
|
+
was = model.training; model.eval()
|
|
1383
|
+
tot, nb = 0.0, 0
|
|
1384
|
+
for _ in range(a.val_batches):
|
|
1385
|
+
xs, ys = [], []
|
|
1386
|
+
for _ in range(a.batch_size):
|
|
1387
|
+
w = c.val_window(seq_len, val_gen)
|
|
1388
|
+
if w is None: continue
|
|
1389
|
+
xs.append(w[0]); ys.append(w[1])
|
|
1390
|
+
if not xs: continue
|
|
1391
|
+
vx = torch.stack(xs).to(device); vy = torch.stack(ys).to(device)
|
|
1392
|
+
if a.bf16 and device.startswith("cuda"):
|
|
1393
|
+
with torch.autocast("cuda", dtype=torch.bfloat16):
|
|
1394
|
+
vo = model(vx, vy)
|
|
1395
|
+
else:
|
|
1396
|
+
vo = model(vx, vy)
|
|
1397
|
+
tot += float(vo["ce_loss"].detach()); nb += 1
|
|
1398
|
+
if was: model.train()
|
|
1399
|
+
return (tot / nb) if nb else None
|
|
1400
|
+
|
|
1401
|
+
def val_per_cell():
|
|
1402
|
+
return {lab: v for lab, c in zip(labels, cells)
|
|
1403
|
+
if (v := cell_val_ce(c)) is not None}
|
|
1404
|
+
|
|
1405
|
+
# ── serialize helper (end-of-run AND intermediate --ckpt-every checkpoints) ──
|
|
1406
|
+
# CLM → .clm v0.3 (CLMConvMoE additive readout, materialized TLoRA experts).
|
|
1407
|
+
# ByteGPT → .pt (cfg+state_dict) → core/serialize.py::serialize → .bin (5×u32
|
|
1408
|
+
# header). The engine (generator L3 mouth-sniff) auto-dispatches .bin to the bytegpt
|
|
1409
|
+
# decode; `anima-python evaluate` auto-detects .bin vs .clm by header, so no eval change.
|
|
1410
|
+
def _write_bin(out_path):
|
|
1411
|
+
# write the torch .pt in the exact shape bytegpt_serialize.serialize reads, next to
|
|
1412
|
+
# the .bin, then bridge it. The aux-head objective params are OUTSIDE model.state_dict
|
|
1413
|
+
# (they live on objfn), so serialize sees only the standard ByteGPT weights.
|
|
1414
|
+
pt_path = out_path + ".pt" if not out_path.endswith(".pt") else out_path
|
|
1415
|
+
bin_path = out_path
|
|
1416
|
+
sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
|
|
1417
|
+
ck = {"model": sd, "config": model.cfg.as_dict(),
|
|
1418
|
+
"val_ce": (round(lossF, 5) if lossF is not None else None),
|
|
1419
|
+
"step": steps, "nparam": n_params}
|
|
1420
|
+
torch.save(ck, pt_path)
|
|
1421
|
+
BGS.serialize(pt_path, bin_path)
|
|
1422
|
+
print(f" .bin WRITTEN {os.path.getsize(bin_path)} bytes -> {bin_path} "
|
|
1423
|
+
f"(via {pt_path})", flush=True)
|
|
1424
|
+
|
|
1425
|
+
def _write_clm(out_path):
|
|
1426
|
+
if is_bytegpt:
|
|
1427
|
+
_write_bin(out_path)
|
|
1428
|
+
return
|
|
1429
|
+
e_ser = mito.e_active
|
|
1430
|
+
mat = materialize_experts_into_state(model)
|
|
1431
|
+
sd_active = {}
|
|
1432
|
+
for k, vv in mat.items():
|
|
1433
|
+
if k in ("moe.router.weight", "moe.router.bias"):
|
|
1434
|
+
sd_active[k] = vv[:e_ser].contiguous()
|
|
1435
|
+
elif k.startswith("moe.experts."):
|
|
1436
|
+
if int(k.split(".")[2]) < e_ser:
|
|
1437
|
+
sd_active[k] = vv
|
|
1438
|
+
else:
|
|
1439
|
+
sd_active[k] = vv
|
|
1440
|
+
S.serialize_v3(sd_active, n_trunk_layers=L, n_experts=e_ser, out_path=out_path)
|
|
1441
|
+
# H_9200 E1 — append the "SLW\x01" gated-write forward-slot trailer if engaged
|
|
1442
|
+
# (core/serialize.append_slw_trailer). Additive models skip it (byte-identical).
|
|
1443
|
+
if getattr(model, "slw", None) is not None:
|
|
1444
|
+
nb = S.append_slw_trailer(out_path, model.slw)
|
|
1445
|
+
print(f" SLW trailer appended {nb} bytes (n_slot={model.slw.n_slot} "
|
|
1446
|
+
f"k={model.slw.k} d_s={model.slw.d_s})", flush=True)
|
|
1447
|
+
print(f" .clm WRITTEN {os.path.getsize(out_path)} bytes -> {out_path}", flush=True)
|
|
1448
|
+
print(f" clm_decodable={VC.clm_decodable(open(out_path, 'rb').read())}", flush=True)
|
|
1449
|
+
|
|
1450
|
+
# ── train loop ───────────────────────────────────────────────────────────
|
|
1451
|
+
model.train()
|
|
1452
|
+
t0 = time.time(); loss0 = lossF = None
|
|
1453
|
+
last_aux = {}; dbes_log = []
|
|
1454
|
+
# active-expert count for logs/summary — CLM tracks it on the mitosis controller;
|
|
1455
|
+
# ByteGPT has no experts (mito is None) so it is a fixed 1.
|
|
1456
|
+
def e_now():
|
|
1457
|
+
return mito.e_active if mito is not None else 1
|
|
1458
|
+
# intermediate-ckpt extension: bytegpt writes .bin, clm writes .clm.
|
|
1459
|
+
_ck_ext = ".bin" if is_bytegpt else ".clm"
|
|
1460
|
+
for step in range(1, steps + 1):
|
|
1461
|
+
# --ckpt-every: dump an intermediate ckpt of the state AFTER (step-1) updates
|
|
1462
|
+
# (step-window multiplex — one run yields 2000/4000/… checkpoints, no re-train).
|
|
1463
|
+
# §6 rank-0-only intermediate serialize on the UNWRAPPED core_model; barrier AFTER the
|
|
1464
|
+
# write (nothing to protect before) so non-zero ranks don't race into the next step's
|
|
1465
|
+
# collective while rank 0 serializes a 303M .clm.
|
|
1466
|
+
if a.ckpt_every > 0 and a.out and step > 1 and (step - 1) % a.ckpt_every == 0:
|
|
1467
|
+
if rank == 0:
|
|
1468
|
+
_write_clm(f"{a.out}.step{step - 1}{_ck_ext}")
|
|
1469
|
+
model.train()
|
|
1470
|
+
if ddp_on:
|
|
1471
|
+
dist.barrier()
|
|
1472
|
+
if savant_on:
|
|
1473
|
+
inh = savant_inhibition(step, steps, i0, i_floor, latch)
|
|
1474
|
+
wd = inhibition_to_wd(inh); dp = inhibition_to_dropout(inh)
|
|
1475
|
+
else:
|
|
1476
|
+
wd, dp = 0.0, 0.0
|
|
1477
|
+
if a.wd_floor >= 0.0: wd = a.wd_floor # N6 sweep override
|
|
1478
|
+
if a.dropout_floor >= 0.0: dp = a.dropout_floor # N6 sweep override
|
|
1479
|
+
for grp in opt.param_groups:
|
|
1480
|
+
grp["weight_decay"] = wd
|
|
1481
|
+
for m in model.modules():
|
|
1482
|
+
if isinstance(m, nn.Dropout):
|
|
1483
|
+
m.p = dp
|
|
1484
|
+
if mitosis_on and step == split_step and mito.e_active < emax:
|
|
1485
|
+
prev = mito.e_active
|
|
1486
|
+
new_e = (tlora_aware_split(mito, 0, opt) if tlora_on
|
|
1487
|
+
else mito.split(0, opt))
|
|
1488
|
+
p0(f" step {step} (MITOSIS SPLIT) E {prev}->{new_e}", flush=True)
|
|
1489
|
+
# §1 belt-and-braces: the split is provably deterministic across ranks (bitwise-
|
|
1490
|
+
# identical params ⇒ identical children + mask flip; NO change to split() itself),
|
|
1491
|
+
# but broadcast the touched tensors from rank 0 + MAX-assert e_active so ANY future
|
|
1492
|
+
# nondeterminism in split() is caught HERE, not as a silent mid-run divergence.
|
|
1493
|
+
if ddp_on:
|
|
1494
|
+
moe = core_model.moe
|
|
1495
|
+
child = new_e - 1
|
|
1496
|
+
touched = (list(moe.experts[0].parameters())
|
|
1497
|
+
+ list(moe.experts[child].parameters())
|
|
1498
|
+
+ [moe.router.weight, moe.router.bias])
|
|
1499
|
+
for t in touched:
|
|
1500
|
+
dist.broadcast(t.data, src=0)
|
|
1501
|
+
ea = torch.tensor([float(mito.e_active)], device=device)
|
|
1502
|
+
mx = ea.clone(); dist.all_reduce(mx, op=dist.ReduceOp.MAX)
|
|
1503
|
+
assert int(mx.item()) == mito.e_active, \
|
|
1504
|
+
f"[ddp] mitosis e_active desync: local {mito.e_active} != max {int(mx.item())}"
|
|
1505
|
+
x, y = get_batch(step)
|
|
1506
|
+
opt.zero_grad(set_to_none=True)
|
|
1507
|
+
# §4 one composite DDP forward() → (loss, detached shard-CE, aux). Backward runs at the
|
|
1508
|
+
# callsite OUTSIDE the shell's internal autocast; DDP's grad hooks fire here and
|
|
1509
|
+
# allreduce every param's grad (aux heads included — §10.1). clip_grad_norm_ runs AFTER
|
|
1510
|
+
# on the already-averaged grads, so every rank's clip scale = the 1-GPU global-grad norm.
|
|
1511
|
+
loss, ce_local, aux = train_module(x, y, obj_gen, a.dict_lambda, a.jamo_lambda)
|
|
1512
|
+
loss.backward()
|
|
1513
|
+
torch.nn.utils.clip_grad_norm_(params, 1.0)
|
|
1514
|
+
opt.step()
|
|
1515
|
+
# §3 CE all-reduce (AVG): per-rank ce_local is a shard statistic; average it so
|
|
1516
|
+
# loss0/lossF/logs equal the 1-GPU GLOBAL-batch CE (equal shard sizes ⇒ mean-of-means
|
|
1517
|
+
# = global mean). N==1: no collective — ce = the shard CE = the global CE.
|
|
1518
|
+
if ddp_on:
|
|
1519
|
+
ct = ce_local.clone()
|
|
1520
|
+
dist.all_reduce(ct, op=dist.ReduceOp.SUM); ct /= world
|
|
1521
|
+
ce = float(ct)
|
|
1522
|
+
else:
|
|
1523
|
+
ce = float(ce_local)
|
|
1524
|
+
last_aux = aux
|
|
1525
|
+
if loss0 is None: loss0 = ce
|
|
1526
|
+
lossF = ce
|
|
1527
|
+
do_val = a.val_every > 0 and (step == 1 or step % a.val_every == 0 or step == steps)
|
|
1528
|
+
# §1/§7 --ddp-verify-sync: at every val cadence, all-reduce a param-checksum (ALL ranks
|
|
1529
|
+
# participate — a collective) and assert cross-rank agreement, catching a split/optimizer
|
|
1530
|
+
# desync (especially the first val after split_step).
|
|
1531
|
+
if ddp_on and a.ddp_verify_sync and do_val:
|
|
1532
|
+
with torch.no_grad():
|
|
1533
|
+
cs = torch.zeros(1, device=device)
|
|
1534
|
+
for p in params:
|
|
1535
|
+
cs += p.detach().float().sum()
|
|
1536
|
+
csx = cs.clone(); csn = cs.clone()
|
|
1537
|
+
dist.all_reduce(csx, op=dist.ReduceOp.MAX)
|
|
1538
|
+
dist.all_reduce(csn, op=dist.ReduceOp.MIN)
|
|
1539
|
+
assert float(csx - csn) < 1e-3, \
|
|
1540
|
+
f"[ddp] param-checksum desync at step {step}: spread {float(csx - csn)}"
|
|
1541
|
+
# §6 DBES/val/logs = rank-0-only diagnostics on the UNWRAPPED model (no collective inside,
|
|
1542
|
+
# so non-zero ranks skip and simply wait at the next step's forward/allreduce).
|
|
1543
|
+
if (not is_bytegpt) and a.dbes_every and (step % a.dbes_every == 0 or step == steps):
|
|
1544
|
+
if rank == 0:
|
|
1545
|
+
db = dbes_specialization(model, x); db["step"] = step
|
|
1546
|
+
dbes_log.append(db)
|
|
1547
|
+
if step == 1 or step % a.log_every == 0 or step == steps:
|
|
1548
|
+
vtxt = ""
|
|
1549
|
+
ptxt = ""
|
|
1550
|
+
if do_val and rank == 0:
|
|
1551
|
+
per = val_per_cell()
|
|
1552
|
+
vc = (sum(per.values()) / len(per)) if per else float("nan")
|
|
1553
|
+
vtxt = f" val_CE={vc:.5f}"
|
|
1554
|
+
# ⑤ per-cell CE dict (ko/en × general/sns) — MONITOR-ONLY, additive log of
|
|
1555
|
+
# the already-tracked per-register held-out CE. NEVER enters the loss
|
|
1556
|
+
# (a_train_inline_gauge · p7 NO PERPLEXITY VERDICT); decomposes the pooled
|
|
1557
|
+
# val_CE above so per-cell descent is visible per (a_chat_registers) cell.
|
|
1558
|
+
if per:
|
|
1559
|
+
ptxt = " per_cell_CE=" + json.dumps(
|
|
1560
|
+
{k: round(v, 5) for k, v in per.items()})
|
|
1561
|
+
atxt = (" " + json.dumps({k: round(v, 4) for k, v in aux.items()})) if aux else ""
|
|
1562
|
+
p0(f" step {step:5d} CE={ce:.5f} E={e_now()} "
|
|
1563
|
+
f"wd={wd:.4f} dp={dp:.4f}{vtxt}{ptxt}{atxt}", flush=True)
|
|
1564
|
+
wall = time.time() - t0
|
|
1565
|
+
|
|
1566
|
+
# ══ §6 FINALIZE — held-out val / DBES / gauges / ckpt / summary / serialize are ALL
|
|
1567
|
+
# RANK-0-ONLY on the UNWRAPPED core_model (`model`). A non-zero rank writing files =
|
|
1568
|
+
# double-write corruption; val_gen must advance only on rank 0 so its stream matches
|
|
1569
|
+
# the 1-GPU run (the DESCENT-gate comparability contract, §10.6). Non-zero ranks skip
|
|
1570
|
+
# straight to the barrier + destroy_process_group. N==1: rank==0, so this all runs.
|
|
1571
|
+
if rank == 0:
|
|
1572
|
+
# ── FINAL held-out val per register (DESCENT gate, plain CE) ──────────────
|
|
1573
|
+
uniform = math.log(V)
|
|
1574
|
+
per = val_per_cell()
|
|
1575
|
+
descent = {}; n_desc = 0
|
|
1576
|
+
print(f" ── FINAL held-out val-CE per register (uniform={uniform:.4f}) ──", flush=True)
|
|
1577
|
+
for lab, vc in per.items():
|
|
1578
|
+
ok = vc < uniform; n_desc += int(ok)
|
|
1579
|
+
descent[lab] = {"val_ce": round(vc, 5), "descent": ok}
|
|
1580
|
+
print(f" {lab:<12s} val_CE={vc:.5f} {'DESCENT' if ok else 'NO-DESCENT'}", flush=True)
|
|
1581
|
+
final_val = (sum(per.values()) / len(per)) if per else None
|
|
1582
|
+
print(f" FINAL val_CE(pooled)={final_val} registers_DESCENT={n_desc}/{len(per)}", flush=True)
|
|
1583
|
+
print(f" loss0={loss0:.5f} lossF={lossF:.5f} wall={wall:.1f}s "
|
|
1584
|
+
f"savant_latched_at={latch['at']} E0={e0}->E={e_now()}", flush=True)
|
|
1585
|
+
|
|
1586
|
+
# ── N3 DBES final diagnostic (gradient-free, measure-only) ────────────────
|
|
1587
|
+
# DBES probes MoE expert differentiation — CLM-only (no experts in ByteGPT).
|
|
1588
|
+
dbes_final = None
|
|
1589
|
+
if not is_bytegpt:
|
|
1590
|
+
try:
|
|
1591
|
+
xb, _ = get_batch(steps + 1)
|
|
1592
|
+
dbes_final = dbes_specialization(model, xb)
|
|
1593
|
+
print(f" [N3 DBES expert-specialization] {json.dumps(dbes_final, ensure_ascii=False)}", flush=True)
|
|
1594
|
+
except Exception as e:
|
|
1595
|
+
print(f" DBES error: {e}", flush=True)
|
|
1596
|
+
|
|
1597
|
+
# ── ρ·weave/ρ·fan (former G1/G6) torch-probe gauges (DIRECTIONAL, a_train_inline_gauge) ──
|
|
1598
|
+
# gauge_lib.compute_inline_gauges decodes via the CLM mouth (CLMConvMoE-specific);
|
|
1599
|
+
# skip for bytegpt (the terminal verdict is `anima-python evaluate <.bin>` engine-native
|
|
1600
|
+
# through the bytegpt mouth anyway — this torch probe is DIRECTIONAL only).
|
|
1601
|
+
gauges = None
|
|
1602
|
+
if not is_bytegpt:
|
|
1603
|
+
try:
|
|
1604
|
+
import gauge_lib
|
|
1605
|
+
was = model.training; model.eval()
|
|
1606
|
+
gauges = gauge_lib.compute_inline_gauges(
|
|
1607
|
+
model, None, seeds=7, corpus_index=[c.path for c in cells],
|
|
1608
|
+
ce=lossF, step=steps, torch=torch)
|
|
1609
|
+
if was: model.train()
|
|
1610
|
+
print(f" [ρ·weave/ρ·fan (G1/G6) torch-probe DIRECTIONAL] {json.dumps(gauges, ensure_ascii=False)}", flush=True)
|
|
1611
|
+
except Exception as e:
|
|
1612
|
+
print(f" gauges error: {e}", flush=True)
|
|
1613
|
+
|
|
1614
|
+
# ── persist torch ckpt (ALWAYS — a_fire_recover_complete) ────────────────
|
|
1615
|
+
full_sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
|
|
1616
|
+
if jamo_head:
|
|
1617
|
+
for k, v in jamo_head.state_dict().items():
|
|
1618
|
+
full_sd[f"_jamo_head.{k}"] = v.detach().cpu()
|
|
1619
|
+
if a.ckpt_out:
|
|
1620
|
+
torch.save(full_sd, a.ckpt_out)
|
|
1621
|
+
print(f" torch ckpt -> {a.ckpt_out} ({os.path.getsize(a.ckpt_out)} bytes)", flush=True)
|
|
1622
|
+
|
|
1623
|
+
# ── summary json ──────────────────────────────────────────────────────────
|
|
1624
|
+
summary = {"entry": "anima-python train", "arch": a.arch, "arm": a.arm,
|
|
1625
|
+
"objective": a.objective, "seed": a.seed,
|
|
1626
|
+
"ddp": {"world_size": world, "global_batch": B_global,
|
|
1627
|
+
"per_rank_batch": B_local, "gpus": a.gpus},
|
|
1628
|
+
"obj_aux_params": (sum(p.numel() for p in objfn.parameters())
|
|
1629
|
+
if obj_is_module else 0),
|
|
1630
|
+
"levers": {"tlora": tlora_on, "tlora_rank": a.tlora_rank,
|
|
1631
|
+
"tlora_base": not a.tlora_no_base, "dict_aux": dict_on,
|
|
1632
|
+
"jamo_aux": jamo_on, "wd_floor": a.wd_floor,
|
|
1633
|
+
"dropout_floor": a.dropout_floor},
|
|
1634
|
+
"n_params": n_params, "loss0": round(loss0, 5), "lossF": round(lossF, 5),
|
|
1635
|
+
"wall_s": round(wall, 1), "uniform_ce": round(uniform, 5),
|
|
1636
|
+
"final_val_ce_pooled": (round(final_val, 5) if final_val else None),
|
|
1637
|
+
"registers_descent": f"{n_desc}/{len(per)}", "heldout_descent": descent,
|
|
1638
|
+
"last_aux": last_aux, "dbes_final": dbes_final, "dbes_log": dbes_log,
|
|
1639
|
+
# §4 diagnostic honesty: under DDP the rank-0 DBES probe runs on a rank-LOCAL
|
|
1640
|
+
# shard (B_local=B/N), NOT the global batch B, so dbes_final/dbes_log are NOT
|
|
1641
|
+
# numerically 1:1 comparable with a 1-GPU record (which used full B). Label the
|
|
1642
|
+
# input scope explicitly (DBES is MONITOR-ONLY/DIRECTIONAL, excluded from the
|
|
1643
|
+
# DESCENT gate + engine-native verdict, so this does not corrupt the verdict).
|
|
1644
|
+
"dbes_batch_scope": (f"per_rank_shard(B_local={B_local})" if world > 1
|
|
1645
|
+
else f"full_batch(B={B_global})"),
|
|
1646
|
+
"gauges_g1g6_torch_probe": gauges,
|
|
1647
|
+
"tier": ("engine-native-eligible (.bin ByteGPT via bytegpt mouth); torch probe DIRECTIONAL"
|
|
1648
|
+
if is_bytegpt else
|
|
1649
|
+
"engine-native-eligible (.clm additive, TLoRA materialized); torch probe DIRECTIONAL")}
|
|
1650
|
+
if a.gauges_out:
|
|
1651
|
+
with open(a.gauges_out, "w") as f:
|
|
1652
|
+
json.dump(summary, f, ensure_ascii=False, indent=2)
|
|
1653
|
+
print(f" summary -> {a.gauges_out}", flush=True)
|
|
1654
|
+
|
|
1655
|
+
# ── serialize the trained ckpt: CLM → .clm v0.3 (additive readout + MATERIALIZED
|
|
1656
|
+
# experts) | ByteGPT → .bin (5×u32 header via core/serialize.py). ──
|
|
1657
|
+
if a.out:
|
|
1658
|
+
_write_clm(a.out) # final full-run checkpoint (same helper as --ckpt-every)
|
|
1659
|
+
|
|
1660
|
+
# §6 barrier so no rank tears down while rank 0 serializes the 303M .clm, then release NCCL.
|
|
1661
|
+
if ddp_on:
|
|
1662
|
+
dist.barrier()
|
|
1663
|
+
dist.destroy_process_group()
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
if __name__ == "__main__":
|
|
1667
|
+
main()
|