Gala-598M-MLX / train.py
junafinity's picture
Gala-598M: weights, code, logs, report
76bbe95 verified
Raw History Blame Contribute Delete
11.9 kB
"""
Train HOARD (or a matched baseline) on one Mac.
python train.py --config hoard_small --data data/fineweb --steps 3000 --batch_tokens 65536
python train.py --config transformer_small --data data/fineweb --steps 3000 --batch_tokens 65536
Optimiser: Muon for 2-D hidden matrices, AdamW for embeddings / head / norms /
gates / hoard block weights. LR: linear warmup, flat, linear cooldown.
"""
import argparse, json, math, os, sys, time
import numpy as np
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optim
from mlx.utils import tree_flatten
import configs
from model import HOARD, HoardConfig
# --------------------------------------------------------------------------- #
class TokenLoader:
def __init__(self, path, seq_len, batch, seed=0):
self.data = np.memmap(path, dtype=np.uint16, mode="r")
self.T, self.B = seq_len, batch
self.rng = np.random.default_rng(seed)
assert len(self.data) > seq_len + 1, "dataset too small for seq_len"
def next(self):
ix = self.rng.integers(0, len(self.data) - self.T - 1, size=self.B)
x = np.stack([self.data[i: i + self.T + 1] for i in ix]).astype(np.int32)
return mx.array(x)
def sequential(self, n_batches):
"""Deterministic contiguous batches for validation. Shrinks the batch
when the split is smaller than one full B x (T+1) span (tiny datasets
like shakespeare's 6.7K-token val split) instead of yielding nothing."""
B = self.B
if B * (self.T + 1) > len(self.data):
B = max(1, len(self.data) // (self.T + 1))
span = B * (self.T + 1)
for j in range(n_batches):
st = j * span
if st + span > len(self.data):
break
x = np.asarray(self.data[st: st + span]).astype(np.int32).reshape(B, self.T + 1)
yield mx.array(x)
def lr_at(step, total, base, warmup, cooldown_frac):
if step < warmup:
return base * (step + 1) / warmup
cool_start = int(total * (1 - cooldown_frac))
if step >= cool_start:
return base * max(0.0, (total - step) / max(1, total - cool_start))
return base
def fp32_master(opt_cls):
"""Wrap an MLX optimizer so bf16 parameters train with fp32 master weights
and fp32 optimizer state. bf16-only AdamW updates diverge (measured: nan by
step 10 on shakespeare); compute stays bf16, the update math moves to fp32.
The master copy is created lazily in apply_single because Optimizer.init
receives the gradient tree, not the parameters."""
class FP32Master(opt_cls):
def apply_single(self, gradient, parameter, state):
if parameter.dtype == mx.float32:
return super().apply_single(gradient, parameter, state)
if "master" not in state:
state["master"] = parameter.astype(mx.float32)
for k in list(state):
if k != "master" and isinstance(state[k], mx.array):
state[k] = state[k].astype(mx.float32)
new = super().apply_single(gradient.astype(mx.float32), state["master"], state)
state["master"] = new
return new.astype(parameter.dtype)
return FP32Master
class NorMuonLite(optim.Muon):
"""Muon plus per-row second-moment normalization of the orthogonalized
update (NorMuon-style), scale-preserving: rows are rebalanced, the global
update RMS is unchanged so learning-rate semantics carry over."""
def apply_single(self, gradient, parameter, state):
if parameter.ndim < 2:
return super().apply_single(gradient, parameter, state)
if self.weight_decay != 0:
gradient = gradient + self.weight_decay * parameter
v = self.momentum * state["v"] + (1 - self.momentum) * gradient
state["v"] = v
update = gradient * (1 - self.momentum) + v * self.momentum if self.nesterov else v
original_shape = update.shape
reshape_needed = update.ndim > 2
if reshape_needed:
update = mx.reshape(update, (update.shape[0], -1))
update = self._zeropower_via_newtonschulz5(update, steps=self.ns_steps)
row_ms = mx.mean(mx.square(update), axis=-1, keepdims=True)
vn = state.get("vn")
vn = row_ms if vn is None else 0.95 * vn + 0.05 * row_ms
state["vn"] = vn
pre_rms = mx.sqrt(mx.mean(mx.square(update)) + 1e-12)
update = update / (mx.sqrt(vn) + 1e-8)
update = update * (pre_rms / (mx.sqrt(mx.mean(mx.square(update))) + 1e-12))
if reshape_needed:
update = mx.reshape(update, original_shape)
lr = self.learning_rate.astype(gradient.dtype)
lr *= max(1, update.shape[-2] / update.shape[-1]) ** 0.5
return parameter - lr * update
def is_hoard_param(path: str, p: mx.array) -> bool:
return "W_up" in path or "W_down" in path
def is_muon_param(path: str, p: mx.array) -> bool:
if p.ndim != 2:
return False
for bad in ("embed", "head", "W_up", "W_down", "norm", "loop_embed", "conv"):
if bad in path:
return False
return True
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="hoard_small")
ap.add_argument("--data", default="data/fineweb")
ap.add_argument("--out", default=None)
ap.add_argument("--steps", type=int, default=2000)
ap.add_argument("--seq_len", type=int, default=1024)
ap.add_argument("--batch_tokens", type=int, default=65536)
ap.add_argument("--lr", type=float, default=3e-3, help="AdamW lr (embeddings etc.)")
ap.add_argument("--muon_lr", type=float, default=0.02)
ap.add_argument("--wd", type=float, default=0.0)
ap.add_argument("--warmup", type=int, default=100)
ap.add_argument("--cooldown_frac", type=float, default=0.3)
ap.add_argument("--grad_clip", type=float, default=1.0)
ap.add_argument("--eval_every", type=int, default=200)
ap.add_argument("--eval_batches", type=int, default=8)
ap.add_argument("--log_every", type=int, default=10)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--loops", type=int, default=None, help="override n_loops")
ap.add_argument("--loop_schedule", default=None, choices=[None, "fixed", "random"])
ap.add_argument("--dtype", default="float32", choices=["float32", "bfloat16"])
ap.add_argument("--resume", default=None)
ap.add_argument("--vocab", type=int, default=None, help="override vocab_size (e.g. 4096 for synthetic data)")
ap.add_argument("--tie", action="store_true", help="tie input/output embeddings")
ap.add_argument("--window", type=int, default=None, help="override attention window")
ap.add_argument("--gate", action="store_true", help="silu-gated hoard output")
ap.add_argument("--hoard_lr", type=float, default=None,
help="separate AdamW lr for hoard W_up/W_down (memory-layer recipe)")
ap.add_argument("--normuon", action="store_true", help="NorMuon-lite instead of Muon")
ap.add_argument("--ckpt", action="store_true", help="gradient-checkpoint each cell")
ap.add_argument("--compile_step", action="store_true", help="mx.compile the fwd+bwd step")
a = ap.parse_args()
mx.random.seed(a.seed)
cfg: HoardConfig = configs.get(a.config)
if a.loops:
cfg.n_loops = a.loops
if a.loop_schedule:
cfg.loop_schedule = a.loop_schedule
if a.vocab:
cfg.vocab_size = a.vocab
if a.tie:
cfg.tie_embeddings = True
if a.window:
cfg.window = a.window
if a.gate:
cfg.hoard_gate = True
out = a.out or os.path.join("runs", f"{a.config}_{int(time.time())}")
os.makedirs(out, exist_ok=True)
json.dump({"config": cfg.to_dict(), "args": vars(a)}, open(os.path.join(out, "config.json"), "w"), indent=2)
model = HOARD(cfg)
if a.dtype == "bfloat16":
# parameters in bf16; the GDN core casts to fp32 internally, state is fp32
model.set_dtype(mx.bfloat16)
if a.resume:
model.load_weights(a.resume)
counts = model.param_counts()
print(f"[{a.config}] params: total {counts['total']/1e6:.1f}M | hoard {counts['hoard']/1e6:.1f}M | "
f"active/token/loop {counts['active_per_token_per_loop']/1e6:.1f}M | active x loops {counts['active_x_loops']/1e6:.1f}M")
B = max(1, a.batch_tokens // a.seq_len)
train_loader = TokenLoader(os.path.join(a.data, "train.bin"), a.seq_len, B, a.seed)
val_loader = TokenLoader(os.path.join(a.data, "val.bin"), a.seq_len, B, a.seed)
muon_cls = NorMuonLite if a.normuon else optim.Muon
muon = fp32_master(muon_cls)(learning_rate=a.muon_lr, weight_decay=a.wd)
adamw = fp32_master(optim.AdamW)(learning_rate=a.lr, betas=(0.9, 0.95), weight_decay=a.wd)
sched = [(muon, a.muon_lr), (adamw, a.lr)]
if a.hoard_lr:
hoard_opt = fp32_master(optim.AdamW)(learning_rate=a.hoard_lr, betas=(0.9, 0.95), weight_decay=a.wd)
sched.insert(1, (hoard_opt, a.hoard_lr))
opt = optim.MultiOptimizer([muon, hoard_opt, adamw], [is_muon_param, is_hoard_param])
else:
opt = optim.MultiOptimizer([muon, adamw], [is_muon_param])
if a.ckpt:
from mlx.nn.utils import checkpoint as _checkpoint
model._ckpt_cells = [_checkpoint(c) for c in model.cells]
def loss_fn(model, batch, n_loops):
x, y = batch[:, :-1], batch[:, 1:]
logits = model(x, n_loops=n_loops)
ce = nn.losses.cross_entropy(logits.astype(mx.float32), y, reduction="mean")
return ce + model.balance_loss(), ce
grad_fn = nn.value_and_grad(model, loss_fn)
compiled = None
if a.compile_step and cfg.loop_schedule == "fixed":
_state = [model.state, mx.random.state]
def _compute(batch):
return grad_fn(model, batch, cfg.n_loops)
compiled = mx.compile(_compute, inputs=_state, outputs=_state)
def train_step(batch, n_loops):
if compiled is not None and n_loops == cfg.n_loops:
(loss, ce), grads = compiled(batch)
else:
(loss, ce), grads = grad_fn(model, batch, n_loops)
if a.grad_clip > 0:
grads, _ = optim.clip_grad_norm(grads, a.grad_clip)
opt.update(model, grads)
return loss, ce
def evaluate(n_loops):
model.eval()
losses = []
for batch in val_loader.sequential(a.eval_batches):
_, ce = loss_fn(model, batch, n_loops)
losses.append(ce.item())
model.train()
return float(np.mean(losses)) if losses else float("nan")
log = open(os.path.join(out, "log.txt"), "a")
t0 = time.time(); tok_seen = 0
for step in range(a.steps):
lr_scale = lr_at(step, a.steps, 1.0, a.warmup, a.cooldown_frac)
for o, base_lr in sched:
o.learning_rate = base_lr * lr_scale
n_loops = cfg.n_loops
if cfg.loop_schedule == "random":
n_loops = int(np.random.randint(1, cfg.n_loops + 1))
batch = train_loader.next()
loss, ce = train_step(batch, n_loops)
mx.eval(model.parameters(), opt.state, loss)
tok_seen += B * a.seq_len
if step % a.log_every == 0 or step == a.steps - 1:
dt = time.time() - t0
msg = (f"step {step:5d} | loss {loss.item():.4f} ce {ce.item():.4f} | loops {n_loops} | "
f"lr x{lr_scale:.2f} | {tok_seen/dt:,.0f} tok/s | {dt/60:.1f} min")
print(msg); log.write(msg + "\n"); log.flush()
if (step + 1) % a.eval_every == 0 or step == a.steps - 1:
vl = evaluate(cfg.n_loops)
msg = f" val loss {vl:.4f} @ step {step+1} ({tok_seen/1e6:.1f}M tokens)"
print(msg); log.write(msg + "\n"); log.flush()
model.save_weights(os.path.join(out, "model.safetensors"))
print(f"done. checkpoint in {out}")
if __name__ == "__main__":
main()