Skip to content

Commit 06f3210

Browse files
committed
wip(mamba3-bridge): checkpoint prior agent's eager projection fwd/bwd bridge + m04 wiring + b2 y-input + mps sync fix
1 parent 956f8ae commit 06f3210

7 files changed

Lines changed: 1515 additions & 2 deletions
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
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

cppmega_mlx/runtime/path_c_fusion.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2110,7 +2110,15 @@ def _emit_mamba3_chunked_bwd_model_brick_surfaces(
21102110
skip_d = f"{name}_D"
21112111
delta_grad = f"{name}_delta_grad"
21122112
dh_last = f"{name}_dh_last"
2113-
y = f"{name}_y"
2113+
# B2 needs the forward UN-GATED SSD output ``y`` (= Y_diag+Y_off+D*x) to
2114+
# transpose the silu(z) gate (``dgate = dout*y``). The forward F2
2115+
# ``mamba3_chunk_scan_combine`` writes EXACTLY this ungated y into the brick's
2116+
# ``{name}_delta`` output (F2 has no z gate — verified chunk_scan_fwd_metal_prim
2117+
# applies no silu). Wiring B2's ``y`` input to a SEPARATE ``{name}_y`` buffer
2118+
# left it zero-seeded (no forward producer) -> the whole gate backward (and the
2119+
# grads downstream of dY) collapsed to zero. Bind it to the real F2 output.
2120+
# RULE #1: a single deterministic producer (F2 delta) feeds the consumer (B2 y).
2121+
y = f"{name}_delta"
21142122
z = f"{name}_z"
21152123

21162124
# B2 — output/Y transpose. Reads delta cotangent + forward cache; writes the

0 commit comments

Comments
 (0)