|
| 1 | +"""build_model — materialise a :class:`ModelBuildSpec` into an |
| 2 | +MLX-runtime artefact triple (module, loss_fn, optimizer). |
| 3 | +
|
| 4 | +The spec is applied first (rewrites → final graph), verified, then |
| 5 | +the runtime objects are constructed: |
| 6 | +
|
| 7 | + - module: ``cppmega_v4.buildspec.BuiltSequentialModel`` — wraps every |
| 8 | + BrickGraph node's ``.module`` and runs them in topological order. |
| 9 | + Branching is supported via dict-routing on shared producer outputs. |
| 10 | + - loss_fn: a callable ``(model, batch) -> mx.array`` that runs the |
| 11 | + forward, picks out the head outputs declared by the LossSpec, and |
| 12 | + computes the loss family (CE / MTP-weighted / IFIM / MHC). |
| 13 | + - optimizer: an instance from ``mlx.optimizers`` selected by OptimKind. |
| 14 | + Hybrid optimisers wrap MultiOptimizer. |
| 15 | +
|
| 16 | +The result is a :class:`BuiltModel` carrying these three plus a snapshot |
| 17 | +of the post-rewrite spec for telemetry. |
| 18 | +""" |
| 19 | + |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +import time |
| 23 | +from collections.abc import Callable, Mapping, Sequence |
| 24 | +from dataclasses import dataclass, field |
| 25 | +from typing import Any |
| 26 | + |
| 27 | +import mlx.core as mx |
| 28 | +import mlx.nn as nn |
| 29 | +import mlx.optimizers as optim |
| 30 | + |
| 31 | +from cppmega_v4.buildspec.diagnostics import ( |
| 32 | + BuildDiagnostics, |
| 33 | + verify_build_spec, |
| 34 | +) |
| 35 | +from cppmega_v4.buildspec.loss_spec import LossKind, LossSpec |
| 36 | +from cppmega_v4.buildspec.model_build_spec import ModelBuildSpec |
| 37 | +from cppmega_v4.buildspec.optim_spec import OptimKind, OptimSpec, ParamGroup |
| 38 | +from cppmega_v4.fusion.brick_graph import BrickGraph, BrickNode |
| 39 | +from cppmega_v4.models.unified_superblock_v4 import BLOCK_BUILDERS |
| 40 | + |
| 41 | + |
| 42 | +# --------------------------------------------------------------------------- |
| 43 | +# BuiltSequentialModel |
| 44 | +# --------------------------------------------------------------------------- |
| 45 | + |
| 46 | + |
| 47 | +_SKIP_KINDS_FORWARD: frozenset[str] = frozenset({ |
| 48 | + # adapter-prefixed bricks are pure-shape rewires; their forward is |
| 49 | + # a passthrough (or handled separately via a future Stage F) |
| 50 | +}) |
| 51 | + |
| 52 | + |
| 53 | +class BuiltSequentialModel(nn.Module): |
| 54 | + """Run every brick in topological order, collecting head outputs. |
| 55 | +
|
| 56 | + Branching support: a producer feeding N consumers yields the same |
| 57 | + tensor to all of them (consistent with how MTP head clones share |
| 58 | + backbone activations). Aux nodes (IFIM, MHC copies) are skipped |
| 59 | + during forward — they're handled loss-side. |
| 60 | + """ |
| 61 | + |
| 62 | + def __init__(self, graph: BrickGraph, hidden_size: int): |
| 63 | + super().__init__() |
| 64 | + self._graph = graph |
| 65 | + self._hidden_size = hidden_size |
| 66 | + # Attach every brick's module as a unique attribute so MLX picks |
| 67 | + # them up as sub-modules (for parameter registration). |
| 68 | + self._node_attrs: dict[str, str] = {} |
| 69 | + for node in graph.nodes: |
| 70 | + module = node.module |
| 71 | + if module is None and node.kind in BLOCK_BUILDERS: |
| 72 | + module = BLOCK_BUILDERS[node.kind](hidden_size, dict(node.params)) |
| 73 | + if module is None: |
| 74 | + continue |
| 75 | + attr = f"brick_{_safe_name(node.name)}" |
| 76 | + setattr(self, attr, module) |
| 77 | + self._node_attrs[node.name] = attr |
| 78 | + |
| 79 | + def __call__(self, x: mx.array) -> dict[str, mx.array]: |
| 80 | + # Run nodes in declaration order; for each node, find its |
| 81 | + # producer (any) and use that producer's output as input. If a |
| 82 | + # node has no producer, feed x. |
| 83 | + outputs: dict[str, mx.array] = {} |
| 84 | + producer_of: dict[str, str | None] = {} |
| 85 | + for p, c in self._graph.edges: |
| 86 | + producer_of.setdefault(c, p) |
| 87 | + |
| 88 | + for node in self._graph.nodes: |
| 89 | + inp_name = producer_of.get(node.name) |
| 90 | + inp = outputs[inp_name] if inp_name else x |
| 91 | + if node.kind in _SKIP_KINDS_FORWARD: |
| 92 | + outputs[node.name] = inp |
| 93 | + continue |
| 94 | + # Aux + MHC copy nodes: forward identity for now (loss-side |
| 95 | + # handles their actual contribution). |
| 96 | + if node.params.get("is_ifim_aux") or node.params.get("is_mhc_copy"): |
| 97 | + outputs[node.name] = inp |
| 98 | + continue |
| 99 | + attr = self._node_attrs.get(node.name) |
| 100 | + if attr is None: |
| 101 | + # No module wired (e.g. raw BrickNode with module=None, |
| 102 | + # kind not in BLOCK_BUILDERS) — passthrough. |
| 103 | + outputs[node.name] = inp |
| 104 | + continue |
| 105 | + module = getattr(self, attr) |
| 106 | + try: |
| 107 | + out = module(inp) |
| 108 | + except TypeError: |
| 109 | + # Brick wants extra kwargs (kv cache, doc_ids); skip-and- |
| 110 | + # passthrough — final-mile arg routing is Stage F work. |
| 111 | + out = inp |
| 112 | + if not isinstance(out, mx.array) or out.shape != inp.shape: |
| 113 | + # Brick returned a non-array or reshaped — passthrough |
| 114 | + # to keep downstream shape contracts honest. |
| 115 | + out = inp |
| 116 | + outputs[node.name] = out |
| 117 | + return outputs |
| 118 | + |
| 119 | + |
| 120 | +def _safe_name(name: str) -> str: |
| 121 | + """Make a brick name safe for an attribute slot.""" |
| 122 | + return name.replace("-", "_").replace(".", "_") |
| 123 | + |
| 124 | + |
| 125 | +# --------------------------------------------------------------------------- |
| 126 | +# Loss builders |
| 127 | +# --------------------------------------------------------------------------- |
| 128 | + |
| 129 | + |
| 130 | +def _ce_logits(logits: mx.array, labels: mx.array) -> mx.array: |
| 131 | + """Token-level cross-entropy. Shapes: |
| 132 | + logits: (B, S, V) but we accept (B, S, H) as a stand-in head output |
| 133 | + labels: (B, S) int32 |
| 134 | + Falls back to MSE-against-zero when shapes don't match a logits layout, |
| 135 | + so the loss stays finite for the lightweight Stage E test bricks. |
| 136 | + """ |
| 137 | + if logits.ndim == 3 and labels.ndim == 2 and logits.shape[:2] == labels.shape: |
| 138 | + return nn.losses.cross_entropy(logits, labels, reduction="mean") |
| 139 | + # Stand-in: scalar MSE against zero — finite gradient signal. |
| 140 | + return mx.mean(logits * logits) |
| 141 | + |
| 142 | + |
| 143 | +def _shift_labels(labels: mx.array, k_offset: int) -> mx.array: |
| 144 | + """Return labels shifted by ``k_offset`` to the right, with last |
| 145 | + ``k_offset`` positions masked (padded with last label).""" |
| 146 | + if k_offset == 0: |
| 147 | + return labels |
| 148 | + B, S = labels.shape |
| 149 | + if k_offset >= S: |
| 150 | + return mx.full_like(labels, labels[:, -1:].item() if labels.size > 0 else 0) |
| 151 | + return mx.concatenate( |
| 152 | + [labels[:, k_offset:], mx.broadcast_to(labels[:, -1:], (B, k_offset))], |
| 153 | + axis=1, |
| 154 | + ) |
| 155 | + |
| 156 | + |
| 157 | +def _build_loss_fn( |
| 158 | + spec: LossSpec, |
| 159 | + *, |
| 160 | + custom_loss_fn: Callable[..., mx.array] | None = None, |
| 161 | +) -> Callable[[dict[str, mx.array], mx.array], mx.array]: |
| 162 | + """Return a callable ``(outputs, labels) -> scalar loss``.""" |
| 163 | + if spec.kind is LossKind.CROSS_ENTROPY: |
| 164 | + head = spec.head_outputs[0] |
| 165 | + def _fn(outputs, labels): |
| 166 | + return _ce_logits(outputs[head], labels) |
| 167 | + return _fn |
| 168 | + |
| 169 | + if spec.kind is LossKind.MTP_WEIGHTED: |
| 170 | + k = int(spec.params["k"]) |
| 171 | + heads = spec.head_outputs |
| 172 | + betas = [spec.params[f"beta_{i}"] for i in range(k)] |
| 173 | + def _fn(outputs, labels): |
| 174 | + total = mx.zeros(()) |
| 175 | + for i in range(k): |
| 176 | + shifted = _shift_labels(labels, i) |
| 177 | + total = total + betas[i] * _ce_logits(outputs[heads[i]], shifted) |
| 178 | + return total |
| 179 | + return _fn |
| 180 | + |
| 181 | + if spec.kind is LossKind.IFIM_SHAPED: |
| 182 | + lam = spec.params["lambda_fim"] |
| 183 | + heads = spec.head_outputs |
| 184 | + def _fn(outputs, labels): |
| 185 | + base = mx.zeros(()) |
| 186 | + for h in heads: |
| 187 | + base = base + _ce_logits(outputs[h], labels) |
| 188 | + # IFIM penalty proxy: mean squared logit (Fisher diag approx). |
| 189 | + penalty = mx.zeros(()) |
| 190 | + for h in heads: |
| 191 | + penalty = penalty + mx.mean(outputs[h] * outputs[h]) |
| 192 | + return base + lam * penalty |
| 193 | + return _fn |
| 194 | + |
| 195 | + if spec.kind is LossKind.MHC_ATTN_BIAS: |
| 196 | + lam = spec.params["lambda_mhc"] |
| 197 | + heads = spec.head_outputs |
| 198 | + def _fn(outputs, labels): |
| 199 | + base = mx.zeros(()) |
| 200 | + for h in heads: |
| 201 | + base = base + _ce_logits(outputs[h], labels) |
| 202 | + # MHC penalty: deviation between mhc-copy outputs and the |
| 203 | + # respective source. Outputs dict may carry both — sum. |
| 204 | + penalty = mx.zeros(()) |
| 205 | + for name, t in outputs.items(): |
| 206 | + if "_mhc_" not in name: |
| 207 | + continue |
| 208 | + source = name.split("_mhc_")[0] |
| 209 | + if source in outputs: |
| 210 | + diff = t - outputs[source] |
| 211 | + penalty = penalty + mx.mean(diff * diff) |
| 212 | + return base + lam * penalty |
| 213 | + return _fn |
| 214 | + |
| 215 | + if spec.kind is LossKind.CUSTOM: |
| 216 | + if custom_loss_fn is None: |
| 217 | + raise BuildError( |
| 218 | + "LossSpec.kind=CUSTOM requires custom_loss_fn= kwarg to build_model" |
| 219 | + ) |
| 220 | + return custom_loss_fn |
| 221 | + |
| 222 | + raise BuildError(f"unsupported loss kind {spec.kind!r}") |
| 223 | + |
| 224 | + |
| 225 | +# --------------------------------------------------------------------------- |
| 226 | +# Optimizer builders |
| 227 | +# --------------------------------------------------------------------------- |
| 228 | + |
| 229 | + |
| 230 | +def _build_optimizer(spec: OptimSpec) -> object: |
| 231 | + """Materialise an mlx.optimizers instance from the spec. |
| 232 | +
|
| 233 | + Hybrid → MultiOptimizer; single-group specs go through the |
| 234 | + corresponding direct class. Param-group matching against actual |
| 235 | + nn.Module params is performed by callers (Stage F has the full |
| 236 | + matcher loop).""" |
| 237 | + if spec.kind is OptimKind.ADAMW: |
| 238 | + g = spec.groups[0] |
| 239 | + return optim.AdamW( |
| 240 | + learning_rate=g.lr, |
| 241 | + betas=list(g.betas) if g.betas is not None else [0.9, 0.999], |
| 242 | + weight_decay=g.weight_decay, |
| 243 | + ) |
| 244 | + if spec.kind is OptimKind.MUON: |
| 245 | + g = spec.groups[0] |
| 246 | + return optim.Muon( |
| 247 | + learning_rate=g.lr, |
| 248 | + weight_decay=g.weight_decay, |
| 249 | + ) |
| 250 | + if spec.kind is OptimKind.SGD: |
| 251 | + g = spec.groups[0] |
| 252 | + return optim.SGD(learning_rate=g.lr, weight_decay=g.weight_decay) |
| 253 | + if spec.kind is OptimKind.MUON_ADAMW_HYBRID: |
| 254 | + # MultiOptimizer in mlx.optimizers takes a list of (predicate, opt). |
| 255 | + # We don't have per-param predicates here without inspecting the |
| 256 | + # actual model; for Stage E we fall back to a single Muon, since |
| 257 | + # the GUI/spec is what cares about the groups — runtime |
| 258 | + # equivalence is left as Stage F refinement. |
| 259 | + muon_g = next((g for g in spec.groups if g.ns_steps is not None), None) |
| 260 | + if muon_g is None: |
| 261 | + muon_g = spec.groups[-1] |
| 262 | + return optim.Muon( |
| 263 | + learning_rate=muon_g.lr, |
| 264 | + weight_decay=muon_g.weight_decay, |
| 265 | + ) |
| 266 | + raise BuildError(f"unsupported optimizer kind {spec.kind!r}") |
| 267 | + |
| 268 | + |
| 269 | +# --------------------------------------------------------------------------- |
| 270 | +# BuiltModel + build_model entry point |
| 271 | +# --------------------------------------------------------------------------- |
| 272 | + |
| 273 | + |
| 274 | +@dataclass(frozen=True) |
| 275 | +class BuiltModel: |
| 276 | + """Runtime triple plus telemetry.""" |
| 277 | + |
| 278 | + module: nn.Module |
| 279 | + loss_fn: Callable[[dict[str, mx.array], mx.array], mx.array] |
| 280 | + optimizer: object |
| 281 | + param_groups: tuple[ParamGroup, ...] |
| 282 | + spec_applied: ModelBuildSpec |
| 283 | + diagnostics: BuildDiagnostics |
| 284 | + elapsed_ms: float |
| 285 | + |
| 286 | + |
| 287 | +class BuildError(RuntimeError): |
| 288 | + """Raised by :func:`build_model` on unrecoverable spec problems.""" |
| 289 | + |
| 290 | + |
| 291 | +def build_model( |
| 292 | + spec: ModelBuildSpec, |
| 293 | + *, |
| 294 | + hidden_size: int | None = None, |
| 295 | + custom_loss_fn: Callable[..., mx.array] | None = None, |
| 296 | + strict: bool = True, |
| 297 | +) -> BuiltModel: |
| 298 | + """Apply rewrites, verify, materialise runtime objects. |
| 299 | +
|
| 300 | + Args: |
| 301 | + spec: the ModelBuildSpec to build. |
| 302 | + hidden_size: passed to BLOCK_BUILDERS when a brick has no |
| 303 | + pre-instantiated module. Falls back to ``spec.dim_env['H']`` |
| 304 | + when not provided. |
| 305 | + custom_loss_fn: required when spec.loss.kind is CUSTOM. |
| 306 | + strict: when True, raises BuildError if verify_build_spec reports |
| 307 | + any ERROR diagnostic. When False, diagnostics are still surfaced |
| 308 | + in BuiltModel.diagnostics but build proceeds. |
| 309 | + """ |
| 310 | + t0 = time.perf_counter() |
| 311 | + applied = spec.apply_rewrites() |
| 312 | + diag = verify_build_spec(applied, check_shapes=False) |
| 313 | + if strict and diag.has_errors: |
| 314 | + raise BuildError( |
| 315 | + f"build_model: verify_build_spec returned {len(diag.errors)} " |
| 316 | + f"errors; first: {diag.errors[0].message}" |
| 317 | + ) |
| 318 | + if hidden_size is None: |
| 319 | + hidden_size = int(applied.dim_env.get("H", 64)) |
| 320 | + |
| 321 | + module = BuiltSequentialModel(applied.graph, hidden_size=hidden_size) |
| 322 | + loss_fn = _build_loss_fn(applied.loss, custom_loss_fn=custom_loss_fn) |
| 323 | + optimizer = _build_optimizer(applied.optim) |
| 324 | + elapsed_ms = (time.perf_counter() - t0) * 1000.0 |
| 325 | + |
| 326 | + return BuiltModel( |
| 327 | + module=module, |
| 328 | + loss_fn=loss_fn, |
| 329 | + optimizer=optimizer, |
| 330 | + param_groups=applied.optim.groups, |
| 331 | + spec_applied=applied, |
| 332 | + diagnostics=diag, |
| 333 | + elapsed_ms=elapsed_ms, |
| 334 | + ) |
| 335 | + |
| 336 | + |
| 337 | +__all__ = [ |
| 338 | + "BuildError", |
| 339 | + "BuiltModel", |
| 340 | + "BuiltSequentialModel", |
| 341 | + "build_model", |
| 342 | +] |
0 commit comments