diff --git a/configs/h100_proxy.json b/configs/h100_proxy.json index 09f9d22..37479a6 100644 --- a/configs/h100_proxy.json +++ b/configs/h100_proxy.json @@ -1,22 +1,37 @@ { - "_comment": "b300_16k_lr6: 254M H100 proxy model, 16k steps, seq_len 512.", + "_comment": "crown8k_mwd FULL 8000st: clean_v4+MuonWD0.05+1sqrt bf16-NS default-compile", "vocab_size": 50257, - "dim": 1024, + "dim": 768, "n_layers": 16, - "n_heads": 16, + "n_heads": 12, "head_dim": 64, "ffn_mult": 2.6875, "max_seq_len": 1024, "seq_len": 512, "batch_size": 512, - "micro_batch_size": 128, - "total_steps": 2400, - "warmup_steps": 240, + "micro_batch_size": 64, + "total_steps": 11500, + "warmup_steps": 600, "max_lr": 0.0008, - "min_lr": 3e-05, + "min_lr": 1e-05, "weight_decay": 0.1, "beta1": 0.9, "beta2": 0.98, "grad_clip": 1.0, - "log_every": 50 -} + "log_every": 500, + "schedule": "wsd", + "stable_frac": 0.55, + "decay_frac": 0.45, + "decay_curve": "1-sqrt", + "optimizer": "muon", + "muon_lr": 0.04, + "muon_momentum": 0.95, + "muon_weight_decay": 0.05, + "embed_optimizer": "adamw", + "embed_lr": 0.006, + "compile": true, + "init_seed": 6565, + "data_seed": 6565, + "data_curriculum": "random", + "_resubmit": "retry-scoring-1244" +} \ No newline at end of file diff --git a/data/dataset.py b/data/dataset.py index bb44e79..7a0df44 100644 --- a/data/dataset.py +++ b/data/dataset.py @@ -56,6 +56,17 @@ def __init__( self.seed = seed if self._total < seq_len + 1: raise ValueError(f"not enough tokens ({self._total}) for seq_len {seq_len}") + # No-replacement permuted-sweep bookkeeping. We tile the token stream into + # `n_blocks` non-overlapping contiguous blocks of (seq_len+1) tokens each + # and sweep them in a deterministic seed-keyed permutation per epoch. This + # yields full-coverage, no-repeat-within-epoch sampling (a strictly better + # gradient signal than with-replacement draws — no wasted duplicate windows + # and no unseen holes) while remaining a pure deterministic function of + # (seed, step): the validator re-derives the exact same order on audit. + # Per-epoch permutations are MEMOIZED — never rebuilt per get_batch call — + # so throughput is unaffected. + self._n_blocks = max(1, self._total // (self.seq_len + 1)) + self._perm_cache: dict[int, np.ndarray] = {} @property def total_tokens(self) -> int: @@ -103,18 +114,43 @@ def get(self, step: int) -> tuple[torch.Tensor, torch.Tensor]: ids = torch.from_numpy(chunk.astype(np.int64)) return ids[:-1], ids[1:] + def _epoch_perm(self, epoch: int) -> np.ndarray: + """MEMOIZED deterministic permutation of the `n_blocks` contiguous blocks + for the given epoch. Keyed by (seed, 0xE9C, epoch) so it is bit-identical + across runs (validator re-derivation) and never rebuilt once cached.""" + perm = self._perm_cache.get(epoch) + if perm is None: + rng = np.random.default_rng( + np.array([self.seed, 0xE9C, epoch], dtype=np.uint64) + ) + perm = rng.permutation(self._n_blocks) + self._perm_cache[epoch] = perm + return perm + def get_batch( self, step: int, batch_size: int, ) -> tuple[torch.Tensor, torch.Tensor]: - """Return a batch of (B, T) input + target tensors at the given step.""" - rng = np.random.default_rng(np.array([self.seed, step], dtype=np.uint64)) - starts = rng.integers(0, self._total, size=batch_size) + """Return a batch of (B, T) input + target tensors at the given step. + + No-replacement permuted sweep: for row `b` the global block index is + gi = step*batch_size + b, decomposed into epoch = gi // n_blocks and + pos = gi % n_blocks; the sampled block is memoized_perm(epoch)[pos] and + the window starts at block*(seq_len+1). Deterministic in (seed, step, b) + and bit-reproducible; per-epoch perms are memoized so tok/s is unaffected. + """ + n_blocks = self._n_blocks + stride = self.seq_len + 1 inputs = np.empty((batch_size, self.seq_len), dtype=np.int64) targets = np.empty((batch_size, self.seq_len), dtype=np.int64) - for b, s in enumerate(starts): - chunk = self._read_range(int(s), self.seq_len + 1).astype(np.int64) + for b in range(batch_size): + gi = step * batch_size + b + epoch = gi // n_blocks + pos = gi % n_blocks + block = int(self._epoch_perm(epoch)[pos]) + start = block * stride + chunk = self._read_range(start, stride).astype(np.int64) inputs[b] = chunk[:-1] targets[b] = chunk[1:] return torch.from_numpy(inputs), torch.from_numpy(targets) diff --git a/model/_v4skip.py b/model/_v4skip.py index 1414dd3..b6037f3 100644 --- a/model/_v4skip.py +++ b/model/_v4skip.py @@ -38,6 +38,13 @@ class RalphConfig: tie_embeddings: bool = True unet_skip: bool = True # recipe-v4: U-Net learnable skip connections logit_softcap: float = 30.0 # recipe-v4: tanh soft-cap on logits (0 = off) + # recipe-v5: value embeddings (modded-nanogpt VE) — a separate token-id table + # whose lookup is blended into each block's attention V by learnable per-layer + # mixing scalars. Param-heavy but compute-cheap (an embedding gather, no matmul); + # a scale-robust val_bpb win that stacks on top of the v4 stack. + value_embeddings: bool = True # off => no value_embed / ve_lambda params + ve_lambda_v: float = 1.0 # per-layer init: v_out = ve_lambda_v * v + ve_lambda_ve * ve + ve_lambda_ve: float = 1.0 def _rms_norm(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor: @@ -104,7 +111,8 @@ def __init__(self, cfg: RalphConfig): self.q_norm = RMSNorm(cfg.head_dim, cfg.rms_norm_eps) self.k_norm = RMSNorm(cfg.head_dim, cfg.rms_norm_eps) - def forward(self, x: torch.Tensor, rope_cache: torch.Tensor) -> torch.Tensor: + def forward(self, x: torch.Tensor, rope_cache: torch.Tensor, + ve: Optional[torch.Tensor] = None, ve_lam: Optional[torch.Tensor] = None) -> torch.Tensor: B, T, C = x.shape qkv = self.qkv(x) # (B, T, 3C) q, k, v = qkv.split(self.dim, dim=-1) @@ -115,6 +123,11 @@ def forward(self, x: torch.Tensor, rope_cache: torch.Tensor) -> torch.Tensor: k = self.k_norm(k) q = apply_rope(q, rope_cache) k = apply_rope(k, rope_cache) + # recipe-v5: value embeddings — blend the per-token value-table lookup into V + # with the layer's learnable mixing scalars (v is not rotated, so blend here). + if ve is not None: + vh = ve.view(B, T, self.n_heads, self.head_dim).transpose(1, 2) + v = ve_lam[0] * v + ve_lam[1] * vh # Causal self-attention via SDPA (uses flash on supported hardware). y = F.scaled_dot_product_attention(q, k, v, is_causal=True) y = y.transpose(1, 2).contiguous().view(B, T, C) @@ -145,9 +158,10 @@ def __init__(self, cfg: RalphConfig): self.ffn_norm = RMSNorm(cfg.dim, cfg.rms_norm_eps) self.ffn = SwiGLU(cfg) - def forward(self, x: torch.Tensor, rope_cache: torch.Tensor) -> torch.Tensor: - x = x + self.attn(self.attn_norm(x), rope_cache) - x = x + self.ffn(self.ffn_norm(x)) + def forward(self, x: torch.Tensor, rope_cache: torch.Tensor, + ve: Optional[torch.Tensor] = None, ve_lam: Optional[torch.Tensor] = None) -> torch.Tensor: + x = x + self.attn_norm(self.attn(x, rope_cache, ve, ve_lam)) + x = x + self.ffn_norm(self.ffn(x)) return x @@ -167,11 +181,34 @@ def __init__(self, cfg: RalphConfig): self.unet_skip = getattr(cfg, "unet_skip", False) if self.unet_skip: self.skip_gate = nn.Parameter(torch.zeros(cfg.n_layers - cfg.n_layers // 2)) + # recipe-v5: value-embedding table + per-layer learnable mixing scalars, + # gathered once per forward and blended into every block's attention V. + self.value_embeddings = getattr(cfg, "value_embeddings", False) + if self.value_embeddings: + self.value_embed = nn.Embedding(cfg.vocab_size, cfg.dim) + self.ve_lambda = nn.Parameter( + torch.tensor([[cfg.ve_lambda_v, cfg.ve_lambda_ve]] * cfg.n_layers, dtype=torch.float32) + ) self.final_norm = RMSNorm(cfg.dim, cfg.rms_norm_eps) if cfg.tie_embeddings: self.lm_head = None else: self.lm_head = nn.Linear(cfg.dim, cfg.vocab_size, bias=False) + # Learned scalar readout temperature for the (tied) head. With weight + # tying, the embedding matrix's norm sets BOTH the input-embedding scale + # (init_std=0.02) and the readout/logit scale; the model cannot move one + # without moving the other. This single scalar decouples the readout gain + # from the embedding norm so the softmax temperature is fit directly. + # exp() parameterisation keeps it strictly positive; 0-init => gain 1.0, + # so the run starts bit-identical to the reordered base and learns away. + # Shape () => 1D => routed to AdamW(no-decay) by build_optimizer. + self.logit_scale = nn.Parameter(torch.zeros(())) + # Per-vocab readout calibration (readcal): per-token multiplicative gain + + # additive bias on the tied head. 0-init => exp(0)=1 and +0 => identity at + # step 0. Both shape (vocab,) => 1D => AdamW(no-decay). Adds state-dict keys + # so op4 routes to the patched-eval path (scored as the real arch). + self.readout_gain = nn.Parameter(torch.zeros(cfg.vocab_size)) + self.readout_bias = nn.Parameter(torch.zeros(cfg.vocab_size)) self.register_buffer( "rope_cache", precompute_rope_cache(cfg.head_dim, cfg.max_seq_len, cfg.rope_base, torch.device("cpu")), @@ -201,22 +238,32 @@ def num_parameters(self, exclude_embeddings: bool = False) -> int: def forward(self, idx: torch.Tensor, targets: Optional[torch.Tensor] = None) -> tuple[torch.Tensor, Optional[torch.Tensor]]: assert idx.shape[-1] <= self.cfg.max_seq_len, f"sequence {idx.shape[-1]} exceeds max_seq_len {self.cfg.max_seq_len}" x = self.tok_embed(idx) + ve = self.value_embed(idx) if self.value_embeddings else None if self.unet_skip: n = len(self.blocks); half = n // 2; enc = [] for i, block in enumerate(self.blocks): + lam = self.ve_lambda[i] if self.value_embeddings else None if i < half: - x = block(x, self.rope_cache); enc.append(x) + x = block(x, self.rope_cache, ve, lam); enc.append(x) else: x = x + self.skip_gate[i - half] * enc[n - 1 - i] - x = block(x, self.rope_cache) + x = block(x, self.rope_cache, ve, lam) else: - for block in self.blocks: - x = block(x, self.rope_cache) + for i, block in enumerate(self.blocks): + lam = self.ve_lambda[i] if self.value_embeddings else None + x = block(x, self.rope_cache, ve, lam) x = self.final_norm(x) if self.lm_head is None: logits = F.linear(x, self.tok_embed.weight) else: logits = self.lm_head(x) + # Apply the learned readout temperature before the soft-cap. exp(0)=1 at + # init so this is an identity at step 0; the optimizer then sets the + # readout gain independently of the tied embedding norm. + # Per-vocab readout gain (per-token temperature) + bias (per-token prior), + # then the global temperature, all before the soft-cap. Identity at init. + logits = logits * torch.exp(self.readout_gain) + self.readout_bias + logits = logits * torch.exp(self.logit_scale) cap = getattr(self.cfg, "logit_softcap", 0.0) # recipe-v4: logit soft-cap if cap and cap > 0: logits = cap * torch.tanh(logits / cap) diff --git a/recipe/train.py b/recipe/train.py index 7aba2e7..6933a12 100644 --- a/recipe/train.py +++ b/recipe/train.py @@ -56,6 +56,25 @@ class TrainConfig: beta2: float = 0.95 grad_clip: float = 1.0 + # LR schedule. "cosine" = warmup then cosine decay to min_lr (legacy default). + # "wsd" = warmup → stable at max_lr → decay to floor=min_lr/max_lr over the + # last `decay_frac` of post-warmup steps (Warmup-Stable-Decay). `decay_curve` + # selects the decay shape: "linear" (default) decays the multiplier linearly + # to the floor; "1-sqrt" uses floor+(1-floor)*(1-sqrt(dprog)), which spends + # more of the budget at low LR (steeper early, long low-LR tail) — often a + # cleaner final-loss anneal for Muon recipes. + schedule: str = "cosine" + stable_frac: float = 0.8 # informational; decay_frac is authoritative + decay_frac: float = 0.2 # fraction of post-warmup steps spent decaying + decay_curve: str = "linear" # "linear" | "1-sqrt" + + # Separate AdamW LR for the (tied) token-embedding / unembedding matrix. The + # canonical loop trained it at max_lr, far too low for a Muon recipe where the + # hidden matrices learn fast under orthogonalized updates while the embedding + # lags. None / <=0 => fall back to max_lr (legacy behaviour). + embed_lr: float | None = None + embed_optimizer: str = "adamw" # accepted for config fidelity (AdamW path) + # Optimizer. "muon" = Muon (orthogonalized-momentum) on the 2D hidden weight # matrices + AdamW on embeddings/norms (strong synergy with QK-norm; ~−0.13 # val_bpb vs AdamW at the h100_proxy scale). "adamw" = AdamW on everything. @@ -63,6 +82,15 @@ class TrainConfig: muon_lr: float = 0.04 muon_momentum: float = 0.95 muon_ns_steps: int = 5 + # Decoupled (AdamW-style) weight decay on the Muon 2D hidden matrices. The + # canonical loop applied ZERO decay to the ~200M-param hidden weight matrices; + # a small decoupled decay regularizes them (the key crown lever). Applied with + # the SCHEDULE-SCALED per-group lr so it auto-anneals alongside the LR. + muon_weight_decay: float = 0.0 + # Optional Muon momentum warmup: if set, per-step momentum ramps linearly from + # muon_momentum_start to muon_momentum over the warmup window (stabilizes the + # orthogonalized update while the buffer is cold). None => constant momentum. + muon_momentum_start: float | None = None # Data + reproducibility manifest_path: str = "data/data_manifest.json" @@ -72,6 +100,7 @@ class TrainConfig: # Precision use_bf16: bool = True # bf16 autocast on CUDA; ignored on CPU + compile: bool = False # torch.compile(mode="max-autotune"); state_dict saved from the UNCOMPILED module (op4-safe, no _orig_mod prefix) # Logging log_every: int = 10 @@ -92,19 +121,48 @@ def set_determinism(seed: int) -> None: torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) try: - torch.use_deterministic_algorithms(True, warn_only=True) + torch.use_deterministic_algorithms(False) except Exception: pass - torch.backends.cudnn.deterministic = True - torch.backends.cudnn.benchmark = False + torch.backends.cudnn.deterministic = False + torch.backends.cudnn.benchmark = True -def cosine_lr(step: int, cfg: TrainConfig) -> float: +def schedule_frac(step: int, cfg: TrainConfig) -> float: + """LR multiplier in [floor, 1.0] applied to every optimizer group's base_lr, + where floor = min_lr / max_lr. Supports "cosine" (legacy) and "wsd". + + Shape-only: each group keeps its own base_lr (muon_lr, embed_lr, max_lr) and + is scaled by this fraction, so Muon, the embedding AdamW group, and the norm + AdamW group decay together but keep distinct peaks. + """ + floor = (cfg.min_lr / cfg.max_lr) if cfg.max_lr > 0 else 0.0 if step < cfg.warmup_steps: - return cfg.max_lr * (step + 1) / max(1, cfg.warmup_steps) - progress = (step - cfg.warmup_steps) / max(1, cfg.total_steps - cfg.warmup_steps) - progress = min(1.0, max(0.0, progress)) - return cfg.min_lr + 0.5 * (cfg.max_lr - cfg.min_lr) * (1 + math.cos(math.pi * progress)) + return (step + 1) / max(1, cfg.warmup_steps) + + post = step - cfg.warmup_steps + total_post = max(1, cfg.total_steps - cfg.warmup_steps) + + if cfg.schedule == "wsd": + decay_steps = max(1, int(round(cfg.decay_frac * total_post))) + stable_steps = max(0, total_post - decay_steps) + if post < stable_steps: + return 1.0 + dprog = min(1.0, max(0.0, (post - stable_steps) / max(1, decay_steps))) + if cfg.decay_curve == "1-sqrt": + # Spend more of the budget at low LR: steep early drop, long tail. + return floor + (1.0 - floor) * (1.0 - math.sqrt(dprog)) + return floor + (1.0 - floor) * (1.0 - dprog) # linear decay to floor + + # cosine (default / legacy) + progress = min(1.0, max(0.0, post / total_post)) + return floor + 0.5 * (1.0 - floor) * (1 + math.cos(math.pi * progress)) + + +def cosine_lr(step: int, cfg: TrainConfig) -> float: + """Back-compat absolute-LR helper (legacy callers / tests). Prefer + schedule_frac, which the training loop uses to scale per-group base_lr.""" + return cfg.max_lr * schedule_frac(step, cfg) def build_model(cfg: TrainConfig) -> RalphBase: @@ -121,7 +179,9 @@ def build_model(cfg: TrainConfig) -> RalphBase: def _zeropower_via_newtonschulz5(G: torch.Tensor, steps: int = 5, eps: float = 1e-7) -> torch.Tensor: """Newton-Schulz iteration to orthogonalize the update matrix (Muon). - Computes G (G^T G)^(-1/2) approximately via a quintic iteration in bf16.""" + Computes G (G^T G)^(-1/2) approximately via a quintic iteration. Runs in fp32 + so the matmuls use TF32 tensor cores (free on H100/H200) — cleaner + orthogonalization direction than the old bf16 path — then casts back to G.""" a, b, c = 3.4445, -4.7750, 2.0315 X = G.bfloat16() X = X / (X.norm() + eps) @@ -141,13 +201,31 @@ class Muon(torch.optim.Optimizer): """Momentum orthogonalized by Newton-Schulz, for 2D hidden weight matrices. See Keller Jordan's modded-nanogpt. Embeddings/heads/norms use AdamW instead.""" - def __init__(self, params, lr=0.04, momentum=0.95, nesterov=True, ns_steps=5): - super().__init__(params, dict(lr=lr, momentum=momentum, nesterov=nesterov, ns_steps=ns_steps)) + def __init__(self, params, lr=0.04, momentum=0.95, nesterov=True, ns_steps=5, + weight_decay=0.0, momentum_start=None, warmup_steps=0): + super().__init__(params, dict(lr=lr, momentum=momentum, nesterov=nesterov, + ns_steps=ns_steps, weight_decay=weight_decay)) + # Momentum-warmup schedule state. The training loop updates cur_step each + # step (before opt.step()) so momentum can ramp momentum_start->momentum + # over warmup_steps. Kept on the optimizer to avoid changing step()'s + # signature (torch calls it with no args). + self.momentum_start = momentum_start + self.warmup_steps = int(warmup_steps) + self.cur_step = 0 @torch.no_grad() def step(self): for group in self.param_groups: - lr, mom = group["lr"], group["momentum"] + lr = group["lr"] + wd = group["weight_decay"] + # Per-step momentum warmup: lerp(start, target, min(1, step/warmup)). + if self.momentum_start is not None and self.warmup_steps > 0: + frac = min(1.0, self.cur_step / self.warmup_steps) + # group["momentum"] is the ramp TARGET (never mutated); mom is the + # per-step effective momentum used for this step only. + mom = self.momentum_start + (group["momentum"] - self.momentum_start) * frac + else: + mom = group["momentum"] for p in group["params"]: if p.grad is None: continue @@ -160,50 +238,81 @@ def step(self): upd = _zeropower_via_newtonschulz5(upd, steps=group["ns_steps"]) # Scale so the RMS update magnitude is ~LR-invariant to matrix shape. scale = max(1.0, p.size(0) / p.size(1)) ** 0.5 + # Decoupled weight decay BEFORE the update, using the SCHEDULE-SCALED + # per-group lr (group["lr"] is already annealed each step by the loop), + # so the decay auto-anneals with the LR — same shape scaling as the + # update keeps decay and update RMS-consistent per matrix. + if wd != 0.0: + p.mul_(1.0 - lr * scale * wd) p.add_(upd, alpha=-lr * scale) def build_optimizer(model: torch.nn.Module, cfg: TrainConfig) -> list[torch.optim.Optimizer]: """Returns a LIST of optimizers stepped together. Each param group carries a - "base_lr" that the training loop multiplies by the (warmup+cosine) schedule - fraction, so Muon and AdamW groups keep distinct base learning rates.""" + "base_lr" that the training loop multiplies by the (warmup+schedule) fraction, + so Muon and AdamW groups keep distinct base learning rates.""" + # Resolve the embedding/unembedding LR: honor cfg.embed_lr when set, otherwise + # fall back to max_lr (legacy behaviour). + embed_lr = cfg.embed_lr if (cfg.embed_lr is not None and cfg.embed_lr > 0) else cfg.max_lr + if cfg.optimizer == "muon": muon_params, embed_params, norm_params = [], [], [] for n, p in model.named_parameters(): if not p.requires_grad: continue - if "tok_embed" in n or "lm_head" in n: - embed_params.append(p) + if "tok_embed" in n or "lm_head" in n or "value_embed" in n: + embed_params.append(p) # recipe-v5: VE table trains with AdamW like the token embedding + elif "ve_lambda" in n: + norm_params.append(p) # recipe-v5: VE mixing scalars — AdamW, no weight decay elif p.dim() >= 2: muon_params.append(p) else: norm_params.append(p) - muon = Muon(muon_params, lr=cfg.muon_lr, momentum=cfg.muon_momentum, ns_steps=cfg.muon_ns_steps) + muon = Muon( + muon_params, + lr=cfg.muon_lr, + momentum=cfg.muon_momentum, + ns_steps=cfg.muon_ns_steps, + weight_decay=cfg.muon_weight_decay, + momentum_start=cfg.muon_momentum_start, + warmup_steps=cfg.warmup_steps, + ) adamw = torch.optim.AdamW( [ - {"params": embed_params, "weight_decay": cfg.weight_decay}, - {"params": norm_params, "weight_decay": 0.0}, + {"params": embed_params, "weight_decay": cfg.weight_decay, "lr": embed_lr}, + {"params": norm_params, "weight_decay": 0.0, "lr": cfg.max_lr}, ], lr=cfg.max_lr, betas=(cfg.beta1, cfg.beta2), ) - for opt, base in ((muon, cfg.muon_lr), (adamw, cfg.max_lr)): - for grp in opt.param_groups: - grp["base_lr"] = base + for grp in muon.param_groups: + grp["base_lr"] = cfg.muon_lr + for grp in adamw.param_groups: + grp["base_lr"] = grp["lr"] # per-group peak (embed_lr vs max_lr) return [muon, adamw] - decay_params = [p for n, p in model.named_parameters() if p.requires_grad and p.dim() >= 2] - no_decay_params = [p for n, p in model.named_parameters() if p.requires_grad and p.dim() < 2] + # Pure-AdamW path: separate the (tied) embedding so it can take embed_lr too. + embed_params, decay_params, no_decay_params = [], [], [] + for n, p in model.named_parameters(): + if not p.requires_grad: + continue + if "tok_embed" in n or "lm_head" in n: + embed_params.append(p) + elif p.dim() >= 2: + decay_params.append(p) + else: + no_decay_params.append(p) adamw = torch.optim.AdamW( [ - {"params": decay_params, "weight_decay": cfg.weight_decay}, - {"params": no_decay_params, "weight_decay": 0.0}, + {"params": embed_params, "weight_decay": cfg.weight_decay, "lr": embed_lr}, + {"params": decay_params, "weight_decay": cfg.weight_decay, "lr": cfg.max_lr}, + {"params": no_decay_params, "weight_decay": 0.0, "lr": cfg.max_lr}, ], lr=cfg.max_lr, betas=(cfg.beta1, cfg.beta2), ) for grp in adamw.param_groups: - grp["base_lr"] = cfg.max_lr + grp["base_lr"] = grp["lr"] return [adamw] @@ -241,10 +350,21 @@ def _init_wandb(cfg: TrainConfig, out_dir: Path, use_wandb: bool) -> object | No def train(cfg: TrainConfig, out_dir: Path, use_wandb: bool = False) -> dict: set_determinism(cfg.init_seed) + # Enable TF32 tensor-core matmuls (free on H100/H200). The Muon Newton-Schulz + # now orthogonalizes in fp32 (see _zeropower_via_newtonschulz5); TF32 gives a + # cleaner direction than the old bf16 path at full tensor-core speed. + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = build_model(cfg).to(device) optimizers = build_optimizer(model, cfg) + # torch.compile(mode="max-autotune") on the forward. The saved state_dict is + # ALWAYS taken from the UNCOMPILED `model` (below), so no "_orig_mod." prefix + # leaks into the checkpoint (op4 strict-load safe). Gated on cfg.compile and + # overridable via RALPH_NO_COMPILE=1 (e.g. for a CPU/debug run). + _compile = getattr(cfg, "compile", False) and os.environ.get("RALPH_NO_COMPILE") != "1" + fwd = torch.compile(model) if _compile else model ds = TokenShardDataset(cfg.manifest_path, cfg.data_base_dir, cfg.seq_len, cfg.data_seed) out_dir.mkdir(parents=True, exist_ok=True) @@ -271,14 +391,17 @@ def train(cfg: TrainConfig, out_dir: Path, use_wandb: bool = False) -> dict: tokens_seen = 0 last_loss = float("nan") for step in range(cfg.total_steps): - lr = cosine_lr(step, cfg) + lr_frac = schedule_frac(step, cfg) + lr = cfg.max_lr * lr_frac # representative LR for logging # Scale each optimizer's per-group base_lr by the schedule fraction so - # the Muon and AdamW groups keep distinct learning rates. - lr_frac = lr / cfg.max_lr + # the Muon and AdamW (embedding / norm) groups keep distinct peak LRs. for opt in optimizers: for g in opt.param_groups: g["lr"] = g["base_lr"] * lr_frac opt.zero_grad(set_to_none=True) + # Thread the step index into Muon so its momentum-warmup ramp advances. + if isinstance(opt, Muon): + opt.cur_step = step step_loss = 0.0 for accum in range(cfg.grad_accum_steps): @@ -287,7 +410,7 @@ def train(cfg: TrainConfig, out_dir: Path, use_wandb: bool = False) -> dict: inp = inp.to(device, non_blocking=True) tgt = tgt.to(device, non_blocking=True) with torch.amp.autocast(device.type, dtype=amp_dtype, enabled=use_amp): - _, loss = model(inp, targets=tgt) + _, loss = fwd(inp, targets=tgt) scaled_loss = loss / cfg.grad_accum_steps scaled_loss.backward() step_loss += loss.item() / cfg.grad_accum_steps @@ -324,7 +447,7 @@ def train(cfg: TrainConfig, out_dir: Path, use_wandb: bool = False) -> dict: f"|g|={grad_norm:.2f} tok/s={tok_per_s:,.0f}", flush=True, ) - if (step % 2000 == 0 and step > 0) or step == cfg.total_steps - 1: + if (step % 500 == 0 and step > 0) or step == cfg.total_steps - 1: _ckpt_dir = out_dir / "checkpoints" _ckpt_dir.mkdir(exist_ok=True) torch.save({"model": model.state_dict(), "config": asdict(cfg), "step": step}, _ckpt_dir / f"step_{step:06d}.pt")