|
| 1 | +"""Eager (MLX-differentiable) Mamba3 projection fwd/bwd bridge for Path C. |
| 2 | +
|
| 3 | +This module is the missing differentiable sub-step that lets the flag-ON |
| 4 | +``CPPMEGA_PATH_C_MAMBA3_CHUNKED_SCAN`` chunked SSD-core region run on the REAL |
| 5 | +projected inputs (not the zero-seed that the pre-step owner allocated) and fold |
| 6 | +its scan-input grads back into the mamba3 model parameters. |
| 7 | +
|
| 8 | +The chunked grid kernels only cover the SSD *scan core*. Their projected inputs |
| 9 | +``{brick}_x/B/C/A/dt/z/h0`` are caller-owned. Without this bridge the pre-step |
| 10 | +owner allocates them as zeros (so the scan, its cached boundary states, and the |
| 11 | +backward grads are all degenerate) AND the chunked grad outputs are w.r.t. the |
| 12 | +SSD-core inputs, not the ``mamba3_*_weight`` model params the grad-tree aliases |
| 13 | +expect. |
| 14 | +
|
| 15 | +FORWARD (``mamba3_projection_forward``) |
| 16 | + Replicates ``Mamba3ReferenceBlock.__call__`` from ``in_proj`` THROUGH the point |
| 17 | + just before the scan dispatch (mamba3.py:728-804), producing the serial scan |
| 18 | + inputs ``x,B,C,z,A,dt,D,h0``. It then maps those serial-convention inputs onto |
| 19 | + the chunked GRID-kernel ABI convention (the kernel folds ``dt`` into the input |
| 20 | + ``inp = dt * x (x) B`` and uses a per-head STATIC ``A`` with ``log_decay = |
| 21 | + A[h]*dt``; OUR serial recurrence uses ``inp = x (x) B`` and a per-timestep |
| 22 | + ``A``). The ABI mapping is EXACT (no approximation): see ``_serial_to_kernel_abi``. |
| 23 | +
|
| 24 | +BACKWARD (``mamba3_projection_param_grads``) |
| 25 | + Mirrors ``path_c_prefix_gradient_tree_from_hidden_cotangent``: builds a |
| 26 | + ``(sum of <kernel_abi_input> * <its cotangent>)`` surrogate loss and runs |
| 27 | + ``mx.value_and_grad`` over the mamba block parameters, converting the chunked |
| 28 | + SSD-input cotangents (``dx/dB/dC/dz/dA/ddt/dh0``) into mamba3 param grads |
| 29 | + registered under the EXACT alias names the grad-tree expects. |
| 30 | +
|
| 31 | +RULE #1: every path RAISES on a shape/availability mismatch — there is NO silent |
| 32 | +fallback and NO aliasing of a mismatched gradient onto the wrong param name. |
| 33 | +""" |
| 34 | + |
| 35 | +from __future__ import annotations |
| 36 | + |
| 37 | +from typing import Any, Mapping |
| 38 | + |
| 39 | +import mlx.core as mx |
| 40 | +import mlx.nn as nn |
| 41 | + |
| 42 | +from cppmega_mlx.nn.mamba3 import ( |
| 43 | + Mamba3ReferenceBlock, |
| 44 | + _apply_rope_on_state_dim, |
| 45 | + _broadcast_groups_to_heads, |
| 46 | + _compute_trapezoidal_scale, |
| 47 | + _heads_to_group_scale, |
| 48 | + _rms_norm_last, |
| 49 | + _split_by_sizes, |
| 50 | + causal_depthwise_conv1d, |
| 51 | +) |
| 52 | + |
| 53 | + |
| 54 | +# The kernel-ABI projected-SSD-input suffixes the projection FORWARD produces |
| 55 | +# (must match ``_MAMBA3_CHUNKED_PROJECTED_SSD_INPUT_SUFFIXES`` in path_c_fusion |
| 56 | +# plus the extra caller-owned ``z`` / ``h0`` the bridge also stages). ``D`` is a |
| 57 | +# model param, resolved through the model owner, not produced here. |
| 58 | +MAMBA3_KERNEL_ABI_SSD_INPUT_SUFFIXES = ("x", "B", "C", "A", "dt", "z", "h0") |
| 59 | + |
| 60 | +# The kernel-ABI tensors that carry a PARAMETER dependence (so the backward VJP |
| 61 | +# accepts a cotangent for them). ``A`` is the per-head STATIC decay the kernel-ABI |
| 62 | +# mapping pins to the constant ``-1`` (no parameter dependence: all per-timestep |
| 63 | +# decay was folded into ``dt`` = ``-A_s*dt_s``), so it is NOT a backward input — |
| 64 | +# the chunked region's ``dlog_decay`` grad (its ``{brick}_dt_grad`` buffer) is for |
| 65 | +# that static ``A`` and is intentionally not consumed by the projection VJP. |
| 66 | +MAMBA3_KERNEL_ABI_BACKWARD_COTANGENT_KEYS = ("x", "B", "C", "z", "dt", "h0") |
| 67 | + |
| 68 | +# The mamba3 grad-output logical suffixes the projection VJP produces. These are |
| 69 | +# the param-grad alias TARGETS (``{brick}_<suffix>``) the model grad-tree reads. |
| 70 | +MAMBA3_PROJECTION_PARAM_GRAD_SUFFIXES = ( |
| 71 | + "mamba3_in_proj_weight", |
| 72 | + "mamba3_out_proj_weight", |
| 73 | + "mamba3_conv_weight", |
| 74 | + "mamba3_conv_bias", |
| 75 | + "mamba3_dt_bias", |
| 76 | + "mamba3_B_norm_weight", |
| 77 | + "mamba3_C_norm_weight", |
| 78 | + "mamba3_B_bias", |
| 79 | + "mamba3_C_bias", |
| 80 | +) |
| 81 | + |
| 82 | +# The mamba3 ``D`` skip param grad is produced DIRECTLY by the chunked region (the |
| 83 | +# B2 ``dD`` output, written to ``{brick}_D_grad``), but the model grad-tree alias |
| 84 | +# expects the target ``{brick}_mamba3_D_grad`` (with the ``mamba3_`` infix). The |
| 85 | +# fold bridges that single rename so coverage completes for ``mamba3_D`` too. |
| 86 | +MAMBA3_CHUNKED_D_GRAD_SUFFIX = "D" |
| 87 | +MAMBA3_PARAM_D_GRAD_SUFFIX = "mamba3_D" |
| 88 | + |
| 89 | +# Map an MLX mamba block parameter tree name -> its logical grad suffix. |
| 90 | +_BLOCK_PARAM_TO_LOGICAL_SUFFIX = { |
| 91 | + "in_proj.weight": "mamba3_in_proj_weight", |
| 92 | + "out_proj.weight": "mamba3_out_proj_weight", |
| 93 | + "conv_weight": "mamba3_conv_weight", |
| 94 | + "conv_bias": "mamba3_conv_bias", |
| 95 | + "dt_bias": "mamba3_dt_bias", |
| 96 | + "B_norm_weight": "mamba3_B_norm_weight", |
| 97 | + "C_norm_weight": "mamba3_C_norm_weight", |
| 98 | + "B_bias": "mamba3_B_bias", |
| 99 | + "C_bias": "mamba3_C_bias", |
| 100 | +} |
| 101 | + |
| 102 | + |
| 103 | +def _entry_normed_hidden( |
| 104 | + hidden: mx.array, |
| 105 | + entry_norm_weight: mx.array | None, |
| 106 | + *, |
| 107 | + eps: float, |
| 108 | +) -> mx.array: |
| 109 | + """Apply the brick entry RMSNorm to the raw residual hidden. |
| 110 | +
|
| 111 | + The mamba brick reads ``{brick}_hidden`` as the RAW residual baseline and |
| 112 | + applies its own entry RMSNorm (bound to ``layers.{i}.norm.weight``) to derive |
| 113 | + the projection input. When ``entry_norm_weight`` is ``None`` the caller has |
| 114 | + already supplied the normed hidden (no double-norm). |
| 115 | + """ |
| 116 | + |
| 117 | + if entry_norm_weight is None: |
| 118 | + return hidden |
| 119 | + normed = _rms_norm_last(hidden, eps=eps) |
| 120 | + return normed * entry_norm_weight.astype(normed.dtype) |
| 121 | + |
| 122 | + |
| 123 | +def _mamba3_projection_serial_inputs( |
| 124 | + block: Mamba3ReferenceBlock, |
| 125 | + hidden: mx.array, |
| 126 | + *, |
| 127 | + entry_norm_weight: mx.array | None, |
| 128 | + entry_norm_eps: float, |
| 129 | +) -> dict[str, mx.array]: |
| 130 | + """Projection-only forward: residual hidden -> serial scan inputs. |
| 131 | +
|
| 132 | + Replicates ``Mamba3ReferenceBlock.__call__`` (mamba3.py:728-804) from the |
| 133 | + entry RMSNorm + ``in_proj`` THROUGH (but stopping before) the scan dispatch. |
| 134 | + Returns the serial-convention ``{x,B,C,z,A,dt,h0}`` (D is a model param). Every |
| 135 | + op here is MLX-differentiable so the VJP can flow to the block params. |
| 136 | + """ |
| 137 | + |
| 138 | + if hidden.ndim != 3: |
| 139 | + raise ValueError(f"hidden must be shaped (B,S,D), got {hidden.shape}") |
| 140 | + cfg = block.config |
| 141 | + dims = block.dims |
| 142 | + batch, seq, _ = hidden.shape |
| 143 | + |
| 144 | + route_input = _entry_normed_hidden( |
| 145 | + hidden, entry_norm_weight, eps=entry_norm_eps |
| 146 | + ) |
| 147 | + |
| 148 | + z, x, B, C, dd_dt, dd_A, trap, angles = block.split_in_proj( |
| 149 | + block.in_proj(route_input) |
| 150 | + ) |
| 151 | + |
| 152 | + xBC = mx.concatenate([x, B, C], axis=-1) |
| 153 | + xBC = causal_depthwise_conv1d( |
| 154 | + xBC, |
| 155 | + block.conv_weight.astype(xBC.dtype), |
| 156 | + block.conv_bias.astype(xBC.dtype), |
| 157 | + ) |
| 158 | + x, B, C = _split_by_sizes(nn.silu(xBC), [cfg.d_inner, dims.d_bc, dims.d_bc]) |
| 159 | + x = x.reshape(batch, seq, cfg.nheads, cfg.headdim) |
| 160 | + z = z.reshape(batch, seq, cfg.nheads, cfg.headdim) |
| 161 | + |
| 162 | + B_mimo = B.reshape(batch, seq, cfg.effective_mimo_rank, cfg.ngroups, cfg.d_state) |
| 163 | + C_mimo = C.reshape(batch, seq, cfg.effective_mimo_rank, cfg.ngroups, cfg.d_state) |
| 164 | + B_mimo, C_mimo = block.transform_bc(B_mimo, C_mimo) |
| 165 | + B = mx.mean(B_mimo, axis=2) |
| 166 | + C = mx.mean(C_mimo, axis=2) |
| 167 | + |
| 168 | + dt = nn.softplus(dd_dt + block.dt_bias.astype(dd_dt.dtype)) |
| 169 | + trap_scale = _compute_trapezoidal_scale(dt, trap) |
| 170 | + B = B * _heads_to_group_scale(trap_scale, cfg.ngroups)[:, :, :, None] |
| 171 | + |
| 172 | + angles = mx.broadcast_to( |
| 173 | + angles[:, :, None, :], |
| 174 | + (batch, seq, cfg.nheads, dims.num_rope_angles), |
| 175 | + ) |
| 176 | + angles_cumsum = mx.cumsum(angles * dt[:, :, :, None], axis=1) |
| 177 | + B = _apply_rope_on_state_dim(B, angles_cumsum) |
| 178 | + C = _apply_rope_on_state_dim(C, angles_cumsum) |
| 179 | + B = _broadcast_groups_to_heads(B, cfg.nheads, "B") |
| 180 | + C = _broadcast_groups_to_heads(C, cfg.nheads, "C") |
| 181 | + |
| 182 | + A = mx.minimum(-nn.softplus(dd_A), -cfg.A_floor) |
| 183 | + |
| 184 | + h0 = block.initial_h0(batch, hidden.dtype) |
| 185 | + |
| 186 | + return {"x": x, "B": B, "C": C, "z": z, "A": A, "dt": dt, "h0": h0} |
| 187 | + |
| 188 | + |
| 189 | +def _serial_to_kernel_abi( |
| 190 | + serial: Mapping[str, mx.array], |
| 191 | + *, |
| 192 | + nheads: int, |
| 193 | +) -> dict[str, mx.array]: |
| 194 | + """Map serial-convention scan inputs onto the chunked GRID-kernel ABI. |
| 195 | +
|
| 196 | + OUR serial recurrence (``_chunked_mamba3_diagonal_scan`` / ``_reference_scan``): |
| 197 | + log_decay[t] = A_s[t] * dt_s[t] (A_s per-timestep (B,S,H), <0) |
| 198 | + inp[t] = x_s[t] (x) B_s[t] (NO dt on the input) |
| 199 | + y[t] = sum(h[t] * C_s[t]) + D*x_s[t] |
| 200 | +
|
| 201 | + The chunked GRID kernel (chunk_precompute / scan_combine) ABI: |
| 202 | + A_k : (H,) STATIC per-head dt_k : (B,S,H) |
| 203 | + log_decay[t] = A_k[h] * dt_k[t] |
| 204 | + inp[t] = dt_k[t] * x_k[t] (x) B_k[t] (dt folded INTO input) |
| 205 | + y[t] = sum(h[t] * C_k[t]) + D*x_k[t] |
| 206 | +
|
| 207 | + EXACT mapping (no approximation): |
| 208 | + A_k[h] = -1 (static, <= 0) |
| 209 | + dt_k[t] = -A_s[t] * dt_s[t] (> 0) => A_k*dt_k = A_s*dt_s (decay identical) |
| 210 | + x_k = x_s (=> D*x_k = D*x_s skip identical) |
| 211 | + B_k[t] = B_s[t] / dt_k[t] (=> dt_k * x_k (x) B_k = x_s (x) B_s) |
| 212 | + C_k = C_s ; z_k = z_s ; h0_k = h0_s |
| 213 | +
|
| 214 | + ``dt_k`` is strictly positive: ``A_s = min(-softplus, -A_floor) <= -A_floor < 0`` |
| 215 | + and ``dt_s = softplus(...) > 0``, so ``dt_k = -A_s*dt_s > 0`` and the division |
| 216 | + by ``dt_k`` is well-defined (RULE #1: never a clamp / silent guard). |
| 217 | + """ |
| 218 | + |
| 219 | + x_s = serial["x"] |
| 220 | + B_s = serial["B"] |
| 221 | + C_s = serial["C"] |
| 222 | + z_s = serial["z"] |
| 223 | + A_s = serial["A"] |
| 224 | + dt_s = serial["dt"] |
| 225 | + h0_s = serial["h0"] |
| 226 | + |
| 227 | + dt_k = (-A_s) * dt_s # (B,S,H), strictly > 0 |
| 228 | + A_k = -mx.ones((nheads,), dtype=A_s.dtype) |
| 229 | + B_k = B_s / dt_k[:, :, :, None] |
| 230 | + |
| 231 | + return { |
| 232 | + "x": x_s, |
| 233 | + "B": B_k, |
| 234 | + "C": C_s, |
| 235 | + "z": z_s, |
| 236 | + "A": A_k, |
| 237 | + "dt": dt_k, |
| 238 | + "h0": h0_s, |
| 239 | + } |
| 240 | + |
| 241 | + |
| 242 | +def mamba3_projection_forward( |
| 243 | + block: Mamba3ReferenceBlock, |
| 244 | + hidden: mx.array, |
| 245 | + *, |
| 246 | + entry_norm_weight: mx.array | None = None, |
| 247 | + entry_norm_eps: float = 1e-5, |
| 248 | +) -> dict[str, mx.array]: |
| 249 | + """Full projection-only forward in the chunked GRID-kernel ABI convention. |
| 250 | +
|
| 251 | + Returns ``{x,B,C,A,dt,z,h0}`` already mapped to the chunked kernel ABI so |
| 252 | + feeding them to F0/F1/F2 reproduces OUR serial scan output exactly. ``D`` is |
| 253 | + a model parameter (resolved via the model owner), not produced here. |
| 254 | + """ |
| 255 | + |
| 256 | + serial = _mamba3_projection_serial_inputs( |
| 257 | + block, |
| 258 | + hidden, |
| 259 | + entry_norm_weight=entry_norm_weight, |
| 260 | + entry_norm_eps=entry_norm_eps, |
| 261 | + ) |
| 262 | + return _serial_to_kernel_abi(serial, nheads=block.config.nheads) |
| 263 | + |
| 264 | + |
| 265 | +def mamba3_projection_param_grads( |
| 266 | + block: Mamba3ReferenceBlock, |
| 267 | + hidden: mx.array, |
| 268 | + ssd_input_cotangents: Mapping[str, mx.array], |
| 269 | + *, |
| 270 | + entry_norm_weight: mx.array | None = None, |
| 271 | + entry_norm_eps: float = 1e-5, |
| 272 | +) -> dict[str, mx.array]: |
| 273 | + """VJP the chunked SSD-input cotangents through the projection -> param grads. |
| 274 | +
|
| 275 | + ``ssd_input_cotangents`` maps a subset of |
| 276 | + ``{x,B,C,A,dt,z,h0}`` (the chunked region's KERNEL-ABI scan-input grad |
| 277 | + buffers) to their cotangent arrays. This builds the surrogate |
| 278 | + ``loss = sum_k sum(kernel_abi[k] * cotangent[k])`` and differentiates it |
| 279 | + w.r.t. the mamba block parameters. Returns a dict keyed by logical grad |
| 280 | + suffix (``mamba3_in_proj_weight`` etc.) WITHOUT the trailing ``_grad`` — the |
| 281 | + caller prefixes the brick name and appends ``_grad``. |
| 282 | +
|
| 283 | + RULE #1: an unknown cotangent key or a cotangent whose shape does not match |
| 284 | + the produced kernel-ABI tensor RAISES (no silent skip / broadcast). |
| 285 | + """ |
| 286 | + |
| 287 | + cotangents = {str(k): v for k, v in ssd_input_cotangents.items()} |
| 288 | + for key in cotangents: |
| 289 | + if key not in MAMBA3_KERNEL_ABI_BACKWARD_COTANGENT_KEYS: |
| 290 | + raise ValueError( |
| 291 | + f"unknown mamba3 SSD-input cotangent {key!r}; expected one of " |
| 292 | + f"{MAMBA3_KERNEL_ABI_BACKWARD_COTANGENT_KEYS}" |
| 293 | + ) |
| 294 | + if not cotangents: |
| 295 | + raise ValueError( |
| 296 | + "mamba3_projection_param_grads requires at least one SSD-input cotangent" |
| 297 | + ) |
| 298 | + |
| 299 | + nheads = block.config.nheads |
| 300 | + |
| 301 | + def surrogate_loss(blk: Mamba3ReferenceBlock) -> mx.array: |
| 302 | + kernel = mamba3_projection_forward( |
| 303 | + blk, |
| 304 | + hidden, |
| 305 | + entry_norm_weight=entry_norm_weight, |
| 306 | + entry_norm_eps=entry_norm_eps, |
| 307 | + ) |
| 308 | + loss = mx.array(0.0, dtype=mx.float32) |
| 309 | + for key, cot in cotangents.items(): |
| 310 | + produced = kernel[key] |
| 311 | + if tuple(produced.shape) != tuple(cot.shape): |
| 312 | + raise ValueError( |
| 313 | + f"mamba3 SSD-input cotangent {key!r} shape {tuple(cot.shape)} " |
| 314 | + f"must match produced kernel-ABI tensor shape " |
| 315 | + f"{tuple(produced.shape)}" |
| 316 | + ) |
| 317 | + loss = loss + ( |
| 318 | + produced.astype(mx.float32) * cot.astype(mx.float32) |
| 319 | + ).sum() |
| 320 | + return loss |
| 321 | + |
| 322 | + grad_fn = nn.value_and_grad(block, surrogate_loss) |
| 323 | + _loss, grad_tree = grad_fn(block) |
| 324 | + |
| 325 | + from mlx.utils import tree_flatten |
| 326 | + |
| 327 | + flat = {str(name): value for name, value in tree_flatten(grad_tree)} |
| 328 | + out: dict[str, mx.array] = {} |
| 329 | + for param_name, logical_suffix in _BLOCK_PARAM_TO_LOGICAL_SUFFIX.items(): |
| 330 | + value = flat.get(param_name) |
| 331 | + if value is None: |
| 332 | + # The block does not expose this param (e.g. a config without B_bias); |
| 333 | + # skip — the grad-tree alias for it is then absent too. |
| 334 | + continue |
| 335 | + if not isinstance(value, mx.array): |
| 336 | + raise TypeError( |
| 337 | + f"mamba3 projection grad for {param_name!r} is not an mx.array" |
| 338 | + ) |
| 339 | + out[logical_suffix] = value |
| 340 | + return out |
| 341 | + |
| 342 | + |
| 343 | +def mamba3_brick_blocks_for_chain( |
| 344 | + model: Any, |
| 345 | +) -> dict[str, tuple[Mamba3ReferenceBlock, int]]: |
| 346 | + """Map every mamba3 brick logical NAME -> (block, layer_index). |
| 347 | +
|
| 348 | + The logical name(s) are exactly the prefixes the grad-tree aliases use: |
| 349 | + ``block.path_c_brick_name`` and ``block.path_c_profile_brick_name`` (both, so |
| 350 | + either alias-target resolves). RULE #1: a mamba layer without a resolvable |
| 351 | + brick name RAISES (the bridge must not silently miss a mamba brick). |
| 352 | + """ |
| 353 | + |
| 354 | + layers = getattr(model, "layers", None) |
| 355 | + if layers is None: |
| 356 | + raise ValueError("model has no layers; cannot resolve mamba3 bricks") |
| 357 | + out: dict[str, tuple[Mamba3ReferenceBlock, int]] = {} |
| 358 | + for index, block in enumerate(layers): |
| 359 | + if getattr(block, "backend", None) != "mamba3": |
| 360 | + continue |
| 361 | + inner = getattr(block, "block", None) |
| 362 | + if not isinstance(inner, Mamba3ReferenceBlock): |
| 363 | + raise TypeError( |
| 364 | + f"layer {index} backend=mamba3 but block is " |
| 365 | + f"{type(inner).__name__}, expected Mamba3ReferenceBlock" |
| 366 | + ) |
| 367 | + names = [] |
| 368 | + brick_name = getattr(block, "path_c_brick_name", None) |
| 369 | + profile_name = getattr(block, "path_c_profile_brick_name", None) |
| 370 | + for candidate in (brick_name, profile_name): |
| 371 | + if candidate is not None and str(candidate) not in names: |
| 372 | + names.append(str(candidate)) |
| 373 | + if not names: |
| 374 | + raise ValueError( |
| 375 | + f"mamba3 layer {index} exposes no path_c brick name" |
| 376 | + ) |
| 377 | + for name in names: |
| 378 | + out[name] = (inner, index) |
| 379 | + return out |
0 commit comments