Spaces:
Running
Running
Pratyush Bhardwaj
load latest weights + enable trained MTP draft head (use_writer_mtp)
68576c8 verified | """Training loop: data-parallel, bf16-ready, Orbax checkpoint/resume. | |
| Device-count agnostic: builds a 1D mesh over all visible devices and shards the | |
| batch along it (params/opt-state replicated). Runs on 1 CPU here, 8 chips on a | |
| v5e-8 with no code change. Grad accumulation is a ``lax.scan`` over micro-batches | |
| so only one gradient accumulator lives in memory. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor | |
| from functools import partial | |
| import jax | |
| import jax.numpy as jnp | |
| import numpy as np | |
| import optax | |
| from flax import nnx | |
| from jax.sharding import NamedSharding, PartitionSpec as P | |
| import orbax.checkpoint as ocp | |
| from .config import ModelConfig, TrainConfig, RunConfig | |
| from .model import CortexLM | |
| from .latent_diffusion import CortexLatentDiffusion | |
| from .losses import chunked_lm_loss | |
| def build_model(mcfg: ModelConfig, rngs: nnx.Rngs): | |
| """Pick the architecture family by mcfg.arch.""" | |
| if mcfg.arch == "latent_diffusion": | |
| return CortexLatentDiffusion(mcfg, rngs=rngs) | |
| return CortexLM(mcfg, rngs=rngs) | |
| from .neuron_probe import dormancy, recycle | |
| from .optim import build_optimizer, wsd_schedule | |
| def make_mesh(): | |
| return jax.make_mesh((len(jax.devices()),), ("data",)) | |
| def count_params(params) -> int: | |
| return sum(int(x.size) for x in jax.tree.leaves(params)) | |
| def _merge_safetensors_by_path(params, sft_path, repl): | |
| """Load a bf16 safetensors (keys '<idx>|<keystr>') BY PATH into `params`; leaves whose path is | |
| absent (brand-new params, e.g. learnable sinks) keep their current fresh init. Replicated out.""" | |
| from safetensors.flax import load_file | |
| by_path = {k.split("|", 1)[1]: v for k, v in load_file(str(sft_path)).items()} | |
| paths_leaves, treedef = jax.tree_util.tree_flatten_with_path(params) | |
| out, n_load, n_fresh = [], 0, 0 | |
| for path, leaf in paths_leaves: | |
| ps = jax.tree_util.keystr(path) | |
| if ps in by_path: | |
| out.append(jnp.asarray(by_path[ps]).astype(leaf.dtype)); n_load += 1 | |
| else: | |
| out.append(leaf); n_fresh += 1 | |
| print(f"[resume] safetensors merge: {n_load} tensors loaded, {n_fresh} kept fresh (new params)") | |
| return jax.device_put(jax.tree_util.tree_unflatten(treedef, out), repl) | |
| def make_step(graphdef, update_fn, mcfg: ModelConfig, tcfg: TrainConfig, mesh): | |
| """update_fn(grads, opt_state, params) -> (new_params, new_opt_state, grad_norm). | |
| Standard (optax) and ZeRO-1 sharded optimizers both satisfy this signature.""" | |
| latent = mcfg.arch == "latent_diffusion" | |
| def loss_fn(params, batch, key): | |
| model = nnx.merge(graphdef, params) | |
| if latent: # Cortex-A 0.6: cosine + denoise + CE | |
| return model.compute_loss(batch, key, ce_chunk=tcfg.ce_chunk, | |
| z_loss_coef=tcfg.z_loss_coef) | |
| main_h, mtp_h, gate_aux = model.forward_train_hidden(batch) # pre-head hidden + MoD aux | |
| total, metrics = chunked_lm_loss(model._head, main_h, mtp_h, batch, chunk=tcfg.ce_chunk, | |
| z_loss_coef=tcfg.z_loss_coef, mtp_weight=mcfg.mtp_loss_weight, | |
| mtp_decay=mcfg.mtp_decay) | |
| total = total + mcfg.ffn_gate_aux_weight * gate_aux # MoD causal-predictor BCE (0 if gate off) | |
| return total, {**metrics, "gate_aux": gate_aux} | |
| def local_grads(params, micro_batches, key): # micro_batches: [G, b_local, T] | |
| def accum(carry, xb): | |
| b, idx = xb | |
| # per-microbatch + per-shard noise key (diffusion); the AR path ignores it. | |
| k_i = jax.random.fold_in(jax.random.fold_in(key, jax.lax.axis_index("data")), idx) | |
| (loss, metrics), g = jax.value_and_grad(loss_fn, has_aux=True)(params, b, k_i) | |
| return jax.tree.map(lambda c, gg: c + gg.astype(c.dtype), carry, g), (loss, metrics) | |
| # accumulation dtype is explicit (cotangents follow the param dtype, which is bf16 | |
| # under host-master): fp32 by default; tcfg.accum_dtype="bfloat16" frees the other | |
| # 1.17G of the accumulator at ~sqrt(G)*2^-9 relative grad noise (keystone-gated). | |
| adt = jnp.dtype(tcfg.accum_dtype) | |
| init = jax.tree.map(lambda x: jnp.zeros(x.shape, adt), params) | |
| G = micro_batches.shape[0] | |
| grads, (losses, metrics) = jax.lax.scan(accum, init, (micro_batches, jnp.arange(G))) | |
| grads = jax.tree.map(lambda x: x / G, grads) | |
| # average grads/metrics across the data-parallel shards | |
| grads = jax.lax.pmean(grads, "data") | |
| losses = jax.lax.pmean(losses, "data") | |
| metrics = jax.tree.map(lambda x: jax.lax.pmean(x, "data"), metrics) | |
| return grads, losses, metrics | |
| # shard_map: each device runs the forward/backward on its local batch shard | |
| # (params replicated) -> no ambiguous-sharding gather on the embedding lookup. | |
| sharded = jax.shard_map( | |
| local_grads, mesh=mesh, | |
| in_specs=(P(), P(None, "data", None), P()), out_specs=(P(), P(), P()), | |
| check_vma=False) | |
| def _sr_bf16(x32, key): | |
| """Stochastically round fp32 -> bf16: add 16 random low bits, truncate. Unbiased, | |
| so per-step deltas SMALLER than a bf16 ulp (ours: ~1e-4 vs ulp ~4e-3) still move | |
| the params in expectation instead of being swallowed by round-to-nearest.""" | |
| bits = jax.lax.bitcast_convert_type(x32, jnp.uint32) | |
| rnd = jax.random.bits(key, x32.shape, jnp.uint32) & jnp.uint32(0xFFFF) | |
| return jax.lax.bitcast_convert_type( | |
| (bits + rnd) & jnp.uint32(0xFFFF0000), jnp.float32).astype(jnp.bfloat16) | |
| # Donate params + opt_state: they are both inputs and outputs, so without | |
| # donation XLA double-buffers them (old + new live at once during the update), | |
| # which is the param-scale peak that OOMs a 584M Muon model on a 16G v5e core. | |
| # Donation lets the update run in place, freeing ~(params fp32 + opt_state) ~= 3.8G. | |
| # Host-master mode: update_fn returns the DELTAS; the device applies them to its own | |
| # bf16 params in-jit via STOCHASTIC ROUNDING (so the param chain never waits on the | |
| # host) and returns both -- the host folds the same deltas into fp32 masters | |
| # asynchronously, and the periodic resync corrects any accumulated rounding. | |
| def step(params, opt_state, micro_batches, key): | |
| grads, losses, metrics = sharded(params, micro_batches, key) | |
| out_tree, opt_state, gnorm = update_fn(grads, opt_state, params) # new params OR deltas | |
| out = {k: jnp.mean(v) for k, v in metrics.items()} | |
| out["loss"] = jnp.mean(losses) | |
| out["grad_norm"] = gnorm | |
| if tcfg.host_master: | |
| pl, tdef = jax.tree_util.tree_flatten(params) | |
| ul = jax.tree_util.tree_leaves(out_tree) | |
| if tcfg.host_param_dtype == "bfloat16": | |
| nl = [_sr_bf16(p.astype(jnp.float32) + u.astype(jnp.float32), | |
| jax.random.fold_in(key, i)) for i, (p, u) in enumerate(zip(pl, ul))] | |
| else: # fp32 device params (keystone): exact add | |
| nl = [(p + u).astype(p.dtype) for p, u in zip(pl, ul)] | |
| return (jax.tree_util.tree_unflatten(tdef, nl), out_tree), opt_state, out | |
| return out_tree, opt_state, out | |
| return step | |
| def run_training(mcfg: ModelConfig, tcfg: TrainConfig, run: RunConfig, loader, val_sets=None, tok=None): | |
| mesh = make_mesh() | |
| repl = NamedSharding(mesh, P()) | |
| dshard = NamedSharding(mesh, P(None, "data", None)) # shard batch axis of [G, B, T] | |
| eshard = NamedSharding(mesh, P("data", None)) # shard batch axis of a [B, T] val batch | |
| latent = mcfg.arch == "latent_diffusion" | |
| model = build_model(mcfg, rngs=nnx.Rngs(run.seed)) | |
| graphdef, params = nnx.split(model) | |
| params = jax.device_put(params, repl) | |
| if tcfg.zero_optimizer: # ZeRO-1: distributed Muon (whole matrices/device) | |
| from .optim_sharded import make_sharded_optimizer | |
| init_fn, update_fn = make_sharded_optimizer(tcfg, params, mesh, | |
| emit_updates=tcfg.host_master) | |
| opt_state = init_fn(params) # mu sharded across the data mesh | |
| else: | |
| tx = build_optimizer(tcfg) | |
| opt_state = jax.device_put(tx.init(params), repl) | |
| def update_fn(grads, opt_state, params): | |
| gnorm = optax.global_norm(grads) | |
| updates, opt_state = tx.update(grads, opt_state, params) | |
| if tcfg.host_master: # host-master: deltas out, host applies to fp32 | |
| hdt = jnp.dtype(tcfg.host_param_dtype) | |
| return jax.tree.map(lambda u: u.astype(hdt), updates), opt_state, gnorm | |
| return optax.apply_updates(params, updates), opt_state, gnorm | |
| step_jit = make_step(graphdef, update_fn, mcfg, tcfg, mesh) | |
| lr_sched = wsd_schedule(tcfg.muon_lr, tcfg.warmup_steps, tcfg.stable_steps, | |
| tcfg.decay_steps, tcfg.end_lr_frac, tcfg.decay_type) | |
| def probe_jit(params, batch): # per-neuron mean|act|: [n_layers, d_ff] | |
| return nnx.merge(graphdef, params).collect_mlp_acts(batch) | |
| def _eval_local(params, batch): # batch: local [b, T] shard | |
| m = nnx.merge(graphdef, params) | |
| if latent: # dual CE: WITH the planner plan vs WITHOUT it | |
| ce_w = m.eval_ce(batch, ce_chunk=tcfg.ce_chunk) # backbone on | |
| ce_o = m.eval_ce(batch, ce_chunk=tcfg.ce_chunk, ablate_plan=True) # backbone ablated | |
| return jax.lax.pmean(jnp.stack([ce_w, ce_o]), "data") # [2] | |
| hidden = m._trunk(batch, route="prefill") # inference routing; no MTP / no gate BCE | |
| _, met = chunked_lm_loss(m._head, hidden, [], batch, chunk=tcfg.ce_chunk, | |
| z_loss_coef=0.0, mtp_weight=0.0) | |
| return jax.lax.pmean(met["ce"], "data") | |
| eval_ce = jax.jit(jax.shard_map( # shard_map: same reason as the train step | |
| _eval_local, mesh=mesh, in_specs=(P(), P("data", None)), out_specs=P(), check_vma=False)) | |
| def evaluate(params): # CE per held-out set (val_sets: name -> [nB,B,T]) | |
| res = {} | |
| for name, arr in (val_sets or {}).items(): | |
| vs = [np.asarray(eval_ce(params, jax.device_put(np.asarray(arr[i]), eshard))) | |
| for i in range(arr.shape[0])] | |
| mean = np.mean(vs, axis=0) # scalar (cortex) or [2] (latent: with/without plan) | |
| res[name] = (float(mean[0]), float(mean[1])) if latent else float(mean) | |
| return res | |
| # ---- val text generation (latent arch): run the REAL inference pipeline -- cached | |
| # latent-AR planner + AR writer greedy decode -- on a few fixed prompts each eval, | |
| # so the log shows actual model output as it trains. ---- | |
| sample_text = None | |
| if latent and tok is not None: | |
| from .model import compute_rope as _crope | |
| from .latent_diffusion import backbone_cfg as _bbcfg, writer_cfg as _wcfg | |
| from .config import BOS_ID, EOT_ID | |
| Pp, dM = mcfg.patch_size, mcfg.d_model | |
| GEN_NEW, GEN_MAXT = 48, 512 # generate up to 48 tokens | |
| GEN_MAXC = GEN_MAXT // Pp | |
| GEN_TEMP, GEN_TOPP, GEN_REPPEN = 0.8, 0.95, 1.3 # val sampling: temperature / nucleus / rep-penalty | |
| _grng = np.random.default_rng(run.seed) | |
| _R = lambda x: jax.device_put(x, repl) # fully replicate (same as the training params) | |
| _cc, _sc = _crope(GEN_MAXC, mcfg.head_dim, mcfg.rope_base, _bbcfg(mcfg), jnp.float32) | |
| _ct, _st = _crope(GEN_MAXT, mcfg.head_dim, mcfg.rope_base, _wcfg(mcfg), jnp.float32) | |
| PROMPTS = ["The capital of France is", "Once upon a time, there was a", | |
| "Water is made of"] | |
| # B=1 generation interleaves jitted calls with host-side control; run each op as | |
| # shard_map with ALL specs replicated so there's no mesh sharding to reconcile. | |
| def _sm(fn, n_in, n_out=1): | |
| ins = tuple(P() for _ in range(n_in)) | |
| outs = P() if n_out == 1 else tuple(P() for _ in range(n_out)) | |
| return jax.jit(jax.shard_map(fn, mesh=mesh, in_specs=ins, out_specs=outs, check_vma=False)) | |
| _g_embed = _sm(lambda p, ids: nnx.merge(graphdef, p).embed(ids), 2) | |
| _g_pred = _sm(lambda p, cm, pos, c: nnx.merge(graphdef, p).predict_chunk(cm, pos, c, _cc, _sc), 4, 2) | |
| _g_write = _sm(lambda p, tk, cv, pos, c: nnx.merge(graphdef, p).writer_step(tk, cv, pos, c, _ct, _st), 5, 2) | |
| _zc = _R(jnp.zeros((1, 1, dM), jnp.float32)) # null hint for chunk-0 tokens | |
| def _pick(lg, prev): # host-side top-p + repetition-penalty sample | |
| lg = np.asarray(lg, np.float32).copy() | |
| if prev and GEN_REPPEN != 1.0: # penalize already-generated tokens (breaks greedy loops) | |
| idx = np.fromiter(set(prev), np.int64) | |
| lg[idx] = np.where(lg[idx] > 0, lg[idx] / GEN_REPPEN, lg[idx] * GEN_REPPEN) | |
| lg = lg / max(GEN_TEMP, 1e-3) | |
| p = np.exp(lg - lg.max()); p /= p.sum() | |
| order = np.argsort(-p) # nucleus: smallest set with cumprob >= top_p | |
| keep = order[:max(int(np.searchsorted(np.cumsum(p[order]), GEN_TOPP)) + 1, 1)] | |
| return int(_grng.choice(keep, p=p[keep] / p[keep].sum())) | |
| def sample_text(params, prompt, ablate_plan=False): | |
| ids = [BOS_ID] + tok.encode(prompt, add_eot=False) | |
| if len(ids) % Pp: # left-pad to a chunk boundary | |
| ids = [BOS_ID] * (Pp - len(ids) % Pp) + ids | |
| L = len(ids) | |
| nchunk = L // Pp | |
| emb_p = _g_embed(params, _R(jnp.asarray([ids], jnp.int32))) # [1,L,d] | |
| pcache = _R(nnx.merge(graphdef, params).init_chunk_cache(1, GEN_MAXC)) | |
| wcache = _R(nnx.merge(graphdef, params).init_writer_cache(1, GEN_MAXT)) | |
| chats = [] # chats[c] = planner hint for chunk c+1 | |
| for c in range(nchunk): | |
| cm = emb_p[:, c * Pp:(c + 1) * Pp].mean(1, keepdims=True) | |
| pc, pcache = _g_pred(params, cm, _R(jnp.int32(c)), pcache) | |
| chats.append(pc) | |
| def hint_for(t): # cond[t] = chats[(t+1)//Pp - 1] (causal) | |
| if ablate_plan: # no-backbone generation: zero the planner hint | |
| return _zc | |
| si = (t + 1) // Pp - 1 | |
| return chats[si] if 0 <= si < len(chats) else _zc | |
| logits = None | |
| for t in range(L): # prime the writer over the prompt | |
| logits, wcache = _g_write(params, _R(jnp.asarray([[ids[t]]], jnp.int32)), | |
| hint_for(t), _R(jnp.int32(t)), wcache) | |
| out, buf, pos, cur_c = [], [], L, nchunk | |
| for _ in range(GEN_NEW): | |
| if pos >= GEN_MAXT: | |
| break | |
| nxt = _pick(logits[0], out) # top-p + rep-penalty (was greedy argmax) | |
| if nxt == EOT_ID: | |
| break | |
| out.append(nxt); buf.append(nxt) | |
| if len(buf) == Pp: # chunk complete -> planner step | |
| cm = _g_embed(params, _R(jnp.asarray([buf], jnp.int32))).mean(1, keepdims=True) | |
| pc, pcache = _g_pred(params, cm, _R(jnp.int32(cur_c)), pcache) | |
| chats.append(pc); cur_c += 1; buf = [] | |
| logits, wcache = _g_write(params, _R(jnp.asarray([[nxt]], jnp.int32)), | |
| hint_for(pos), _R(jnp.int32(pos)), wcache) | |
| pos += 1 | |
| return tok.decode(out) | |
| out_dir = os.path.abspath(run.out_dir) | |
| os.makedirs(out_dir, exist_ok=True) | |
| mgr = ocp.CheckpointManager( | |
| out_dir, options=ocp.CheckpointManagerOptions( | |
| save_interval_steps=run.ckpt_every, max_to_keep=run.max_to_keep)) | |
| start = 0 | |
| if run.reinit_from_weights: | |
| # structure-changing resume (e.g. enabling learnable sinks): path-merge the master weights | |
| # from the bf16 snapshot, keep brand-new params at init, fresh optimizer, continue the schedule. | |
| sft = os.path.join(out_dir, "model.safetensors") | |
| assert os.path.exists(sft), f"--reinit-from-weights set but {sft} is missing" | |
| latest = mgr.latest_step() or 0 | |
| params = _merge_safetensors_by_path(params, sft, repl) | |
| opt_state = init_fn(params) if tcfg.zero_optimizer else jax.device_put(tx.init(params), repl) | |
| if tcfg.zero_optimizer: | |
| opt_state = opt_state._replace(count=jax.device_put(jnp.asarray(latest + 1, jnp.int32), repl)) | |
| start = latest + 1 | |
| print(f"[resume] reinit-from-weights: master path-merged at step {latest}; new params fresh; " | |
| f"optimizer fresh (momentum re-accumulates); continuing at step {start}") | |
| elif mgr.latest_step() is not None: | |
| latest = mgr.latest_step() | |
| try: | |
| restored = mgr.restore(latest, | |
| args=ocp.args.StandardRestore({"params": params, "opt_state": opt_state})) | |
| params, opt_state = restored["params"], restored["opt_state"] | |
| print(f"[resume] restored step {latest}, continuing at {latest + 1}") | |
| except Exception: | |
| # opt-state structure changed (e.g. continuing a standard run under --zero-optimizer): | |
| # load the master weights, then re-init this optimizer fresh (momentum re-accumulates). | |
| assert tcfg.zero_optimizer, "checkpoint opt_state structure mismatch" | |
| std_tmpl = jax.device_put(build_optimizer(tcfg).init(params), repl) | |
| restored = mgr.restore(latest, | |
| args=ocp.args.StandardRestore({"params": params, "opt_state": std_tmpl})) | |
| params = restored["params"] | |
| # free the standard opt-state buffers (unused under ZeRO) NOW -- otherwise the | |
| # leftover ~3G squats in HBM and the jit_step program can't be allocated at runtime. | |
| for buf in jax.tree.leaves((std_tmpl, restored["opt_state"])): | |
| buf.delete() | |
| del std_tmpl, restored | |
| opt_state = init_fn(params) | |
| # seed the schedule counter to the resumed step so the WSD LR continues (no re-warmup) | |
| opt_state = opt_state._replace( | |
| count=jax.device_put(jnp.asarray(latest + 1, jnp.int32), repl)) | |
| print(f"[resume] loaded params from a standard checkpoint at step {latest}; " | |
| f"re-initialized the ZeRO optimizer (momentum fresh, schedule continued)") | |
| start = latest + 1 | |
| # ---- resume defrag: checkpoint restore allocates the restored tree while the init | |
| # template is still live, leaving the device heap fragmented; the jit_step program | |
| # then needs CONTIGUOUS arenas and can fail allocation with gigabytes nominally | |
| # free (v24: the rung that ran v22 fresh OOM'd at compile after a resume). A host | |
| # round-trip (pull -> free everything -> re-upload) compacts the heap once. ---- | |
| if start > 0: | |
| t_df = time.time() | |
| shardings = jax.tree.map(lambda x: x.sharding, (params, opt_state)) # keep ZeRO shards | |
| host = jax.tree.map(lambda x: np.array(jax.device_get(x)), (params, opt_state)) | |
| for buf in jax.tree.leaves((params, opt_state)): | |
| buf.delete() | |
| params, opt_state = jax.device_put(host, shardings) | |
| del host | |
| print(f"[resume] device heap defrag (host round-trip) in {time.time()-t_df:.1f}s") | |
| # ---- host-master offload: fp32 masters -> host numpy; device keeps a bf16 working copy. | |
| # MUST run after the resume/init block: optimizer init + checkpoint restore see fp32 | |
| # params (Adam's v inits as zeros_like(param) -- a bf16 v would gut the accumulator). | |
| masters, _hdt = None, None | |
| if tcfg.host_master: | |
| import ml_dtypes | |
| _hdt = np.dtype(ml_dtypes.bfloat16) if tcfg.host_param_dtype == "bfloat16" else np.float32 | |
| masters = jax.tree.map( # np.array: owned + writable (device_get | |
| lambda x: np.array(jax.device_get(x), np.float32), params) # views are read-only) | |
| for buf in jax.tree.leaves(params): | |
| buf.delete() # free the fp32 masters from HBM NOW | |
| params = jax.device_put(jax.tree.map(lambda m: m.astype(_hdt), masters), repl) | |
| sz = sum(m.nbytes for m in jax.tree.leaves(masters)) / 2**30 | |
| print(f"[host-master] fp32 masters ({sz:.2f}G) -> host numpy; device params " | |
| f"{tcfg.host_param_dtype}; deltas applied on host each step") | |
| N = count_params(params) | |
| # Effective params/TOKEN for the MFU estimate: the latent backbone processes only | |
| # T/patch_size positions, so counting all params at full token rate would overstate | |
| # MFU ~(backbone share)x. N_eff = backbone/P + everything-else (decoder, embed, head). | |
| N_eff = N | |
| if latent: | |
| bb_names = ("'subs'", "'block_kv'", "'aggregators'", "'latent_norm'", "'latent_head'") | |
| flat, _ = jax.tree_util.tree_flatten_with_path(params) | |
| bb = sum(int(x.size) for p, x in flat | |
| if any(n in jax.tree_util.keystr(p) for n in bb_names)) | |
| N_eff = bb // mcfg.patch_size + (N - bb) | |
| print(f"[train] latent arch: backbone {bb/1e6:.1f}M @ 1/{mcfg.patch_size} token rate, " | |
| f"rest {(N-bb)/1e6:.1f}M @ full -> N_eff {N_eff/1e6:.1f}M/token for MFU") | |
| G, B, T = max(1, tcfg.grad_accum), tcfg.micro_batch, tcfg.seq_len | |
| print(f"[train] params={N/1e6:.1f}M devices={len(jax.devices())} " | |
| f"micro_batch={B} seq={T} grad_accum={G} tokens/step={G*B*T:,}") | |
| it = iter(loader) | |
| def next_macro(): | |
| # The loader may hand back a CPU-prefetched [G, B, T] macro-batch directly | |
| # (overlaps gather with the device step); else stack G [B, T] micro-batches. | |
| x = next(it) | |
| if getattr(x, "ndim", 0) == 3: | |
| return x | |
| return np.stack([x] + [next(it) for _ in range(G - 1)], axis=0) | |
| history = [] | |
| t0, toks = time.time(), 0 | |
| session_start = time.time() | |
| final_step = max(start - 1, 0) | |
| nan_abort = False # catastrophic-NaN early stop (save compute) | |
| ce_best = float("inf") # divergence early-stop: best CE seen this run | |
| def _host_apply(deltas): | |
| upd_np = jax.device_get(deltas) # one replicated copy of the deltas (D2H) | |
| def _ap(m, u): | |
| m += np.asarray(u, np.float32) # fp32 master += delta, in place | |
| return m | |
| jax.tree.map(_ap, masters, upd_np) | |
| K = max(1, tcfg.master_sync_every) | |
| pool = ThreadPoolExecutor(max_workers=1) if (tcfg.host_master and K > 1) else None | |
| pending = [] # in-flight async master applies | |
| def _drain(): | |
| for f in pending: | |
| f.result() | |
| pending.clear() | |
| for step in range(start, run.max_steps): | |
| host_mb = next_macro() | |
| mb = jax.device_put(host_mb, dshard) | |
| if tcfg.host_master: | |
| (params, deltas), opt_state, metrics = step_jit( | |
| params, opt_state, mb, jax.random.fold_in(jax.random.PRNGKey(run.seed), step)) | |
| if pool is None: # K=1: synchronous (v19 behavior) | |
| _host_apply(deltas) | |
| params = jax.device_put(jax.tree.map(lambda m: m.astype(_hdt), masters), repl) | |
| else: # lazy sync: device params stay hot; | |
| pending.append(pool.submit(_host_apply, deltas)) # masters track asynchronously | |
| if (step - start) % K == K - 1: # resync wipes accumulated bf16 rounding | |
| _drain() | |
| params = jax.device_put(jax.tree.map(lambda m: m.astype(_hdt), masters), repl) | |
| else: | |
| params, opt_state, metrics = step_jit( | |
| params, opt_state, mb, jax.random.fold_in(jax.random.PRNGKey(run.seed), step)) | |
| toks += G * B * T | |
| # dormant-neuron recycling: probe MLP utilization, reclaim dead units (>trigger) | |
| if tcfg.recycle_enabled and step > 0 and step % tcfg.recycle_every == 0: | |
| pb = jax.device_put(np.asarray(host_mb[0]), repl) # one micro-batch [B, T] | |
| frac, masks = dormancy(np.asarray(probe_jit(params, pb)), tau=tcfg.dormant_tau) | |
| params, opt_state, info = recycle(params, opt_state, masks, | |
| key=jax.random.key(run.seed + step), | |
| trigger=tcfg.recycle_trigger) | |
| if info: | |
| params = jax.device_put(params, repl) | |
| opt_state = jax.device_put(opt_state, repl) | |
| print(f"[recycle] step {step:6d} | dormant max={frac.max()*100:4.0f}% " | |
| f"mean={frac.mean()*100:4.0f}% (tau={tcfg.dormant_tau}) | " | |
| f"recycled {sum(info.values())} units in {len(info)} layers " | |
| + (str({L: info[L] for L in sorted(info)}) if info else "(none > trigger)")) | |
| if step % run.log_every == 0 or step == run.max_steps - 1: | |
| metrics = jax.block_until_ready(metrics) | |
| dt = max(time.time() - t0, 1e-6) | |
| tps = toks / dt | |
| mfu = 6 * N_eff * tps / (run.peak_tflops * 1e12) | |
| m = {k: float(v) for k, v in metrics.items()} | |
| # Catastrophic-NaN stop: if the LOSS itself is non-finite the model has truly broken | |
| # (the per-leaf grad guard handles localized grad NaNs and keeps the loss finite), so | |
| # halt NOW rather than burn the rest of the session -- and do NOT upload, so the last | |
| # good checkpoint on the Hub is preserved. | |
| if not np.isfinite(m["loss"]): | |
| print(f"[abort] step {step}: loss is non-finite ({m['loss']}) -> stopping immediately " | |
| f"to save compute (NOT uploading; last good checkpoint preserved)") | |
| nan_abort = True | |
| break | |
| # Divergence early-stop: a finite-but-exploding CE (e.g. a numerically wrong attention | |
| # grad walking the writer to uniform output, ce -> ln(vocab)) is just as fatal as a NaN | |
| # and the per-leaf grad guard does NOT catch it. If CE blows past 2x its best AND clears | |
| # an absolute margin (so tiny-loss noise can't trip it), halt NOW and preserve the Hub | |
| # checkpoint -- this would have caught v18 at step ~720 instead of burning 600 steps. | |
| ce_cur = m.get("ce", m["loss"]) | |
| ce_best = min(ce_best, ce_cur) | |
| if step >= start + 40 and ce_cur > 2.0 * ce_best and ce_cur > ce_best + 1.0: | |
| print(f"[abort] step {step}: CE diverged ({ce_cur:.4f} vs best {ce_best:.4f}) -> stopping " | |
| f"to save compute (NOT uploading; last good checkpoint preserved)") | |
| nan_abort = True | |
| break | |
| aux = (f"cos {m['cos']:.4f} pw {m.get('pw_ce', 0.0):.4f}" # latent: planner cos + MTP draft CE | |
| + (f" mtp {float(m['mtp_ce']):.4f}" if float(m.get('mtp_ce', 0.0)) > 0 else "") | |
| if "cos" in m else f"mtp {m['mtp_ce']:.4f}") # AR transformer MTP metric | |
| print(f"step {step:6d} | loss {m['loss']:.4f} ce {m['ce']:.4f} {aux} " | |
| f"| gnorm {m['grad_norm']:.3f} | lr {float(lr_sched(step)):.2e} " | |
| f"| {tps:,.0f} tok/s | MFU {mfu*100:.1f}%") | |
| history.append((step, m)) | |
| t0, toks = time.time(), 0 | |
| if run.eval_every and val_sets and (step % run.eval_every == 0 or step == run.max_steps - 1): | |
| vres = evaluate(params) | |
| if latent: # dual: CE with the planner vs with it ablated | |
| line = " | ".join(f"{k} CE {cw:.4f} (no-plan {co:.4f} Δ{co-cw:+.3f})" | |
| for k, (cw, co) in vres.items()) | |
| else: | |
| line = " | ".join(f"{k} CE {v:.4f}" for k, v in vres.items()) | |
| print("[val] step %6d | " % step + line) | |
| if sample_text is not None: # two generations: with vs without the backbone | |
| for pr in PROMPTS: | |
| try: | |
| cw = sample_text(params, pr).replace("\n", " ") | |
| co = sample_text(params, pr, ablate_plan=True).replace("\n", " ") | |
| print(f"[gen] step {step:6d} | {pr!r} -> {cw[:180]!r}") | |
| print(f"[gen-noplan] step {step:6d} | {pr!r} -> {co[:180]!r}") | |
| except Exception as e: | |
| print(f"[gen] step {step:6d} | sample failed: {type(e).__name__}: {str(e)[:120]}") | |
| if hasattr(loader, "position"): # data-stream health: should GROW each eval | |
| print(f"[data] step {step:6d} | stream pos {loader.position()} | mix {loader.ratios()}") | |
| ms = jax.local_devices()[0].memory_stats() or {} | |
| if ms.get("peak_bytes_in_use"): # HBM headroom: answers fit questions from the log | |
| print(f"[mem] step {step:6d} | peak {ms['peak_bytes_in_use']/2**30:.2f}G " | |
| f"/ limit {ms.get('bytes_limit', 0)/2**30:.2f}G") | |
| hist_v = {} | |
| for k, v in vres.items(): | |
| if latent: | |
| hist_v[f"val/{k}"], hist_v[f"val/{k}_noplan"] = v | |
| else: | |
| hist_v[f"val/{k}"] = v | |
| history.append((step, hist_v)) | |
| def _ckpt_tree(): | |
| if not tcfg.host_master: | |
| return params | |
| _drain() # masters current + quiescent; copy so the | |
| return jax.tree.map(np.copy, masters) # async orbax write can't race the worker | |
| if step % run.ckpt_every == 0: # mirror the manager's own save interval | |
| mgr.save(step, args=ocp.args.StandardSave({"params": _ckpt_tree(), | |
| "opt_state": opt_state})) | |
| final_step = step | |
| if run.max_train_seconds and (time.time() - session_start) >= run.max_train_seconds: | |
| print(f"[budget] reached {run.max_train_seconds/3600:.2f}h this session at step {step} " | |
| f"-> stopping to checkpoint + upload") | |
| break | |
| if tcfg.host_master: | |
| _drain() | |
| ck = masters if tcfg.host_master else params | |
| if nan_abort: # broken weights: keep the Hub checkpoint intact | |
| print("[abort] skipping checkpoint save + upload (preserving the last good checkpoint)") | |
| return ck, opt_state, start, history | |
| if mgr.latest_step() != final_step: # avoid re-saving an already-checkpointed step | |
| mgr.save(final_step, args=ocp.args.StandardSave({"params": ck, "opt_state": opt_state}), force=True) | |
| mgr.wait_until_finished() | |
| if run.hf_repo and run.upload_at_end: | |
| _finalize_and_upload(run, mcfg, ck, loader, final_step, time.time() - session_start) | |
| return ck, opt_state, start, history | |
| def _finalize_and_upload(run: RunConfig, mcfg: ModelConfig, params, loader, step: int, elapsed_s: float): | |
| """Write bf16 inference weights + resume.json into out_dir, then push the whole | |
| checkpoint (Orbax master+optimizer, bf16, resume state, val cache) to the HF repo.""" | |
| from . import hfsync | |
| out_dir = os.path.abspath(run.out_dir) | |
| try: | |
| hfsync.save_bf16_safetensors(params, os.path.join(out_dir, "model.safetensors")) | |
| hfsync.write_resume(out_dir, { | |
| "step": int(step), | |
| "arch": run.arch, # architecture tag (resume-compatibility guard) | |
| "writer": _writer_tag(mcfg), # STRUCTURE tag: the launcher path-merges (reinit) | |
| # when the repo's tag != the code's structure, then resumes CLEAN once the new tag | |
| # is written. v30="ar"; v31 (full-cond fusion + parallel MTP head)="ar+fc+mtp". | |
| "loader": loader.state() if hasattr(loader, "state") else {}, | |
| "ratios": loader.ratios() if hasattr(loader, "ratios") else {}, | |
| "elapsed_session_s": float(elapsed_s), | |
| "seed": run.seed, | |
| }) | |
| hfsync.upload(run.hf_repo, out_dir, msg=f"phase1 step {step}") | |
| hfsync.purge_old_steps(run.hf_repo, keep=2) # keep latest 2 step folders -> bounded repo + small resume download | |
| except Exception as e: | |
| print(f"[hfsync] finalize/upload FAILED ({type(e).__name__}: {str(e)[:160]}) -- " | |
| f"local checkpoint in {out_dir} is intact") | |
| def _writer_tag(mcfg: ModelConfig): | |
| """resume.json structure tag for the latent arch. The launcher reinits (path-merges the | |
| bf16 snapshot, fresh optimizer) whenever the repo's tag differs from the running code's | |
| structure, then resumes clean. Encodes the param-tree-changing flags so a structure switch | |
| (e.g. adding the full-cond fusion or the parallel MTP head) is detected even though the base | |
| is still the AR writer.""" | |
| if mcfg.arch != "latent_diffusion": | |
| return None | |
| tag = "ar" | |
| if mcfg.use_full_cond: | |
| tag += "+fc" | |
| if mcfg.use_parallel_writer: | |
| tag += "+mtp" | |
| return tag | |