Skip to content

Commit 8936ce4

Browse files
committed
wip(metal-mamba3-bwd): root-cause Metal pipeline crash + dedup forward-recompute
ROOT CAUSE (isolated by bisection at full local_gb10_quarter scale, hidden=3584, seq=4096, the mamba3_mimo_bwd segment chain_12_13): The crash at Metal newComputePipelineState (XPC_ERROR_CONNECTION_INTERRUPTED in MTLCompilerService) is NOT primarily MSL byte-size. It is caused by ``T.sync_threads()`` (threadgroup_barrier) emitted INSIDE the data-dependent reverse-time checkpoint-replay loop. Evidence (each variant compiled+run-probed in isolation via CPPMEGA_MAMBA3_BWD_CHOP): - init-only (zero grads, 22.8 KB) -> COMPILES (ok, 512 launches) - reverse loop + checkpoint loads, no replay (no_replay) -> COMPILES (ok) - replay loop + trivial body, no barriers (bare_replay) -> COMPILES (ok) - replay loop + recompute (barriers in loop, 53.5 KB) -> XPC CRASH - full kernel minus h_prev snapshot (no_snapshot) -> XPC CRASH Cross-check: m2rnn_bwd uses the SAME for-replay_offset checkpoint-replay pattern and COMPILES on Metal (65 KB) precisely because its replay loop body is barrier-free (first T.sync_threads is AFTER the replay loop closes). mamba3's _mamba3_emit_recompute_row emits multiple barriers BETWEEN lane-partitioned phases, and those barriers land inside the replay loop -> Metal backend crash. Calibration (per-segment MSL on Metal; <~94 KB segments compile, mamba3 crashes even at 53 KB recompute-only): sparse_mla_fp8_apply_bwd 93.8 KB ok, attention_qkv_projection_bwd 44.4 KB ok, m2rnn_bwd 65.0 KB ok, mamba3_mimo_bwd 130.0 KB crash -> size is necessary-not-sufficient; barriers are the decisive factor. THIS COMMIT (verified-correct partial, does not yet clear the crash): Merge the duplicated forward-recompute. _mamba3_emit_recompute_row was INLINED TWICE per reverse step (once in the replay loop for replay_time<time_idx, once for time_idx outside the loop); the two copies are byte-identical (~26-28 KB MSL each). Merge into ONE emission replaying [checkpoint_start, time_idx] inclusive, snapshotting h_prev at the start of the time_idx iteration (== post-(time_idx-1) state, exactly as before). - MSL 130.0 KB -> 104.3 KB (-25.7 KB); for-loops 113 -> 93; exp 27 -> 19. - Gradient parity (tiny local_gb10_quarter smoke direct chain, random inputs, forward+backward executed on Metal): max-abs-diff vs pre-merge = 0.000e+00 (BITWISE IDENTICAL) across all 15 non-zero mamba3 grad buffers + hidden grad. - Identical on all targets; CUDA unaffected. 119/119 fusion IR tests pass. FOLLOW-UP (durable fix): make the replay-loop recompute barrier-free (each lane replays its own h_next/angle_cumsum slice independently, m2rnn-style), keeping the single barrier'd current-step recompute OUTSIDE the replay loop. That removes all barriers from the data-dependent loop and is the change that actually clears newComputePipelineState. Repro: scripts/_bwdgate_per_segment_probe.py --only-index 9; size attribution: scripts/_mamba3_msl_characterize.py; parity: scripts/_mamba3_bwd_parity.py.
1 parent 54332ac commit 8936ce4

4 files changed

Lines changed: 327 additions & 12 deletions

File tree

cppmega_mlx/runtime/path_c_fusion_schedules.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11192,26 +11192,48 @@ def _mamba3_emit_recompute_row(
1119211192
f"{angle_checkpoint_ref}"
1119311193
)
1119411194
body.append(f"{indent * 6}T.sync_threads()")
11195+
# Reverse-time checkpoint replay. The forward recompute body emitted by
11196+
# ``_mamba3_emit_recompute_row`` replays ``[checkpoint_start, time_idx]``: each
11197+
# ``replay_time < time_idx`` advances the recurrent state (``h_next`` /
11198+
# ``angle_cumsum``) and rebuilds the per-step scratch, and the FINAL step
11199+
# ``replay_time == time_idx`` leaves the current-step scratch (projected /
11200+
# conv / dt_vec / b_group / ...) that the backward block below consumes.
11201+
#
11202+
# Previously this recompute was INLINED TWICE — once in the replay loop for
11203+
# ``replay_time < time_idx`` and once again for ``time_idx`` itself (the
11204+
# ``time_idx`` copy lived OUTSIDE the loop). Those two copies are byte-for-byte
11205+
# identical (~26-28 KB MSL each). Merging them into ONE emission that replays
11206+
# ``[checkpoint_start, time_idx]`` inclusive cuts ~26 KB of MSL (130.0 -> 104.3
11207+
# KB) with EXACTLY the same arithmetic — the ``h_prev`` snapshot is taken at
11208+
# the START of the ``replay_time == time_idx`` iteration, i.e. the
11209+
# post-(time_idx-1) state, exactly as the prior structure. Verified
11210+
# bit-identical gradients (max-abs-diff 0.0). No gradient change; CUDA lowers
11211+
# the smaller body too.
11212+
#
11213+
# NOTE: this merge alone does NOT clear the Metal newComputePipelineState
11214+
# crash. Root cause (isolated by bisection, see commit body): the recompute
11215+
# emits ``T.sync_threads()`` barriers INSIDE this data-dependent replay loop,
11216+
# and a threadgroup_barrier inside the nested checkpoint-replay loop crashes
11217+
# MTLCompilerService (XPC_ERROR_CONNECTION_INTERRUPTED) regardless of size
11218+
# (a barrier-free 25 KB replay scaffold compiles; the 53 KB recompute-in-loop
11219+
# crashes; m2rnn_bwd compiles ONLY because its replay loop is barrier-free).
11220+
# The durable fix is to make the replay recompute barrier-free (each lane
11221+
# replays its own slice independently, m2rnn-style) — tracked as the follow-up.
1119511222
body.append(f"{indent * 6}for {replay_offset} in T.serial(0, {checkpoint_interval}):")
1119611223
body.append(
1119711224
f"{indent * 7}{replay_time} = {checkpoint_start} + {replay_offset}"
1119811225
)
11199-
body.append(f"{indent * 7}if {replay_time} < {time_idx}:")
11200-
_mamba3_emit_recompute_row(
11201-
replay_time,
11202-
level=8,
11203-
update_angle=True,
11204-
update_state=True,
11205-
)
11226+
body.append(f"{indent * 7}if {replay_time} <= {time_idx}:")
11227+
body.append(f"{indent * 8}if {replay_time} == {time_idx}:")
1120611228
body.append(
11207-
f"{indent * 6}for {state_idx} in T.serial(lane, {state_extent}, "
11229+
f"{indent * 9}for {state_idx} in T.serial(lane, {state_extent}, "
1120811230
f"step={thread_count}):"
1120911231
)
11210-
body.append(f"{indent * 7}{h_prev}[{state_idx}] = {h_next}[{state_idx}]")
11211-
body.append(f"{indent * 6}T.sync_threads()")
11232+
body.append(f"{indent * 10}{h_prev}[{state_idx}] = {h_next}[{state_idx}]")
11233+
body.append(f"{indent * 8}T.sync_threads()")
1121211234
_mamba3_emit_recompute_row(
11213-
time_idx,
11214-
level=6,
11235+
replay_time,
11236+
level=8,
1121511237
update_angle=True,
1121611238
update_state=True,
1121711239
)

scripts/_mamba3_bwd_parity.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
"""Numerical gradient parity harness for the row-phased mamba3_mimo_bwd emitter.
3+
4+
Runs the tiny local_gb10_quarter smoke direct-chain (same 7-op structure as full
5+
scale, small dims so it COMPILES + RUNS on Metal) forward+backward with RANDOM,
6+
non-zero caller-owned buffers, then captures every mamba3 grad output buffer.
7+
8+
Used to prove the recompute-merge refactor leaves gradients bit-identical: run
9+
once on the merged emitter and once on the original (git stash), diff the dumped
10+
grads. Writes a .npz of the captured grads to --out.
11+
"""
12+
from __future__ import annotations
13+
14+
import argparse
15+
import sys
16+
from pathlib import Path
17+
18+
import numpy as np
19+
20+
ROOT = Path(__file__).resolve().parents[1]
21+
for p in (str(ROOT), str(ROOT / "scripts")):
22+
if p not in sys.path:
23+
sys.path.insert(0, p)
24+
25+
import mlx.core as mx # noqa: E402
26+
import m04_train_step as m # noqa: E402
27+
from cppmega_mlx.recipes.model_factory import ( # noqa: E402
28+
build_local_gb10_quarter_tiny_smoke_model,
29+
)
30+
31+
32+
def main() -> int:
33+
parser = argparse.ArgumentParser()
34+
parser.add_argument("--out", required=True)
35+
parser.add_argument("--seq-len", type=int, default=16)
36+
parser.add_argument("--seed", type=int, default=1234)
37+
args = parser.parse_args()
38+
39+
model = build_local_gb10_quarter_tiny_smoke_model()
40+
regions = tuple(
41+
model.path_c_fusion_regions(
42+
include_backward=False, min_route_bricks=2, sequence_length=args.seq_len,
43+
)
44+
)
45+
sel = m._select_path_c_model_route_region(regions)
46+
assert sel is not None
47+
scheduled = m.plan_path_c_fusion_schedule_for_region(sel, include_backward=True)
48+
chain = m.plan_path_c_direct_fusion_chain_for_region(
49+
scheduled.region, include_backward=True,
50+
)
51+
print(f"[parity] chain.status={chain.status} segs={len(chain.segments)} "
52+
f"ops={[[n.op_name for n in s.region.nodes] for s in chain.segments]}",
53+
flush=True)
54+
artifacts = m.compile_path_c_direct_fusion_chain_artifacts(chain)
55+
56+
specs = m._path_c_direct_chain_required_logical_buffer_specs(chain)
57+
rng = np.random.default_rng(args.seed)
58+
buffers: dict[str, mx.array] = {}
59+
for name, spec in specs.items():
60+
dtype = getattr(mx, str(spec["dtype"]))
61+
shape = tuple(int(d) for d in spec["shape"])
62+
# Random small inputs; grad-output buffers start at zero and get filled.
63+
is_grad = "_grad" in name or name.endswith("_grad")
64+
if is_grad or str(spec["dtype"]) not in ("float32", "float16", "bfloat16"):
65+
buffers[name] = mx.zeros(shape, dtype=dtype)
66+
else:
67+
arr = (rng.standard_normal(shape).astype(np.float32) * 0.1)
68+
buffers[name] = mx.array(arr).astype(dtype)
69+
mx.eval(*buffers.values())
70+
71+
payload = m.run_path_c_direct_fusion_chain_route(
72+
chain=chain, logical_buffers=buffers, artifacts=artifacts,
73+
)
74+
print(f"[parity] route status={payload.get('status')}", flush=True)
75+
if payload.get("status") != "ok":
76+
print("[parity] ROUTE FAILED", flush=True)
77+
return 2
78+
79+
# Capture all buffers whose name references mamba3 grads.
80+
captured = {}
81+
for name, val in buffers.items():
82+
low = name.lower()
83+
if "mamba3" in low and ("grad" in low or "_m_" in low):
84+
mx.eval(val)
85+
captured[name] = np.array(val).astype(np.float64)
86+
# Also capture the hidden grad (upstream output) if present.
87+
for name, val in buffers.items():
88+
if name.lower().endswith("hidden") or "hidden_grad" in name.lower():
89+
mx.eval(val)
90+
captured[name] = np.array(val).astype(np.float64)
91+
92+
np.savez(args.out, **captured)
93+
nz = {k: float(np.max(np.abs(v))) for k, v in captured.items()}
94+
nonzero = {k: v for k, v in nz.items() if v > 0}
95+
print(f"[parity] captured {len(captured)} grad buffers; "
96+
f"{len(nonzero)} non-zero. max-abs sample: "
97+
f"{dict(list(nonzero.items())[:6])}", flush=True)
98+
print(f"[parity] wrote -> {args.out}", flush=True)
99+
return 0
100+
101+
102+
if __name__ == "__main__":
103+
raise SystemExit(main())
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env python3
2+
"""Characterize the mamba3_mimo_bwd Metal kernel source: total MSL size + phase
3+
attribution by counting generated lines belonging to each emitter phase marker.
4+
5+
Extracts the generated Metal (MSL) source from the compiled TileLang artifact for
6+
the mamba3 backward segment and reports:
7+
- total MSL bytes / lines
8+
- the TileLang DSL source bytes/lines on the prim_func
9+
- rough per-phase line attribution within the MSL via heuristic markers
10+
"""
11+
from __future__ import annotations
12+
13+
import sys
14+
from pathlib import Path
15+
16+
ROOT = Path(__file__).resolve().parents[1]
17+
for p in (str(ROOT), str(ROOT / "scripts")):
18+
if p not in sys.path:
19+
sys.path.insert(0, p)
20+
21+
import m04_train_step as m # noqa: E402
22+
import cppmega_mlx.runtime.path_c_fusion as pcf # noqa: E402
23+
import cppmega_mlx.runtime.path_c_fusion_schedules as pcs # noqa: E402
24+
25+
26+
def main() -> int:
27+
profile, route_symbols, regions = m._local_gb10_path_c_model_regions()
28+
sel = m._select_path_c_model_route_region(regions)
29+
scheduled = m.plan_path_c_fusion_schedule_for_region(sel, include_backward=True)
30+
chain = m.plan_path_c_direct_fusion_chain_for_region(
31+
scheduled.region, include_backward=True,
32+
)
33+
mamba_seg = next(
34+
s for s in chain.segments
35+
if any(n.op_name == "mamba3_mimo_bwd" for n in s.region.nodes)
36+
)
37+
print(f"mamba3 segment index={mamba_seg.index} region={mamba_seg.region.name}")
38+
target = mamba_seg.schedule_target
39+
40+
schedule_template = m.mark_path_c_schedule_template_for_region(
41+
target.schedule_template,
42+
mamba_seg.region,
43+
implementation_kind=target.implementation_kind,
44+
production_schedule_id=target.schedule_id
45+
if target.implementation_kind == "production" else "",
46+
required_real_abi_inputs=target.required_real_abi_inputs,
47+
)
48+
49+
# Compile just this segment and grab the artifact (JITKernel).
50+
captured = {}
51+
52+
def capturing_lowerer(func_or_mod, *, target, **kwargs):
53+
kernel = pcf.tilelang_single_entry_lowerer(
54+
func_or_mod, target=target, execution_backend="tvm_ffi", **kwargs,
55+
)
56+
captured["kernel"] = kernel
57+
captured["prim_func"] = pcf._single_entry_prim_func(func_or_mod)
58+
return kernel
59+
60+
compiled = m.compile_path_c_region(
61+
mamba_seg.region,
62+
schedule_template=schedule_template,
63+
schedule_name=target.schedule_name,
64+
schedule_status=target.schedule_status,
65+
tilelang_lowerer=capturing_lowerer,
66+
target="metal",
67+
)
68+
kernel = captured["kernel"]
69+
prim = captured.get("prim_func")
70+
dsl = getattr(prim, "_cppmega_path_c_generated_source", "") or ""
71+
print(f"DSL source: {len(dsl)} bytes, {dsl.count(chr(10))+1} lines")
72+
73+
try:
74+
msl = kernel.get_kernel_source()
75+
except Exception as exc: # noqa: BLE001
76+
print(f"get_kernel_source failed: {type(exc).__name__}: {exc}")
77+
msl = kernel.kernel_source if hasattr(kernel, "kernel_source") else ""
78+
if not isinstance(msl, str):
79+
msl = str(msl)
80+
lines = msl.splitlines()
81+
print(f"MSL source: {len(msl)} bytes ({len(msl)/1024:.1f} KB), {len(lines)} lines")
82+
83+
# Count loops in MSL.
84+
n_for = sum(1 for ln in lines if "for (" in ln or "for(" in ln)
85+
n_if = sum(1 for ln in lines if ln.strip().startswith("if (") or ln.strip().startswith("if("))
86+
n_exp = msl.count("exp(") + msl.count("metal::exp")
87+
n_log = msl.count("log(") + msl.count("metal::log")
88+
n_atomic = msl.count("atomic")
89+
print(f"MSL loops(for)={n_for} ifs={n_if} exp={n_exp} log={n_log} atomic_tokens={n_atomic}")
90+
91+
# Dump MSL to a file for inspection.
92+
out = ROOT / "scripts" / "_mamba3_bwd.metal"
93+
out.write_text(msl)
94+
print(f"wrote MSL -> {out}")
95+
96+
# Also dump the DSL for phase inspection.
97+
out2 = ROOT / "scripts" / "_mamba3_bwd_dsl.txt"
98+
out2.write_text(dsl)
99+
print(f"wrote DSL -> {out2}")
100+
return 0
101+
102+
103+
if __name__ == "__main__":
104+
raise SystemExit(main())

scripts/_seg_msl_sizes.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
"""Measure MSL kernel-source size for EVERY direct-chain segment + whether its
3+
pipeline-state compiles (newComputePipelineState) at full scale. Calibrates the
4+
real crash threshold by correlating MSL size with compile-success per segment."""
5+
from __future__ import annotations
6+
7+
import dataclasses
8+
import sys
9+
from pathlib import Path
10+
11+
ROOT = Path(__file__).resolve().parents[1]
12+
for p in (str(ROOT), str(ROOT / "scripts")):
13+
if p not in sys.path:
14+
sys.path.insert(0, p)
15+
16+
import mlx.core as mx # noqa: E402
17+
import m04_train_step as m # noqa: E402
18+
import cppmega_mlx.runtime.path_c_fusion as pcf # noqa: E402
19+
20+
21+
def main() -> int:
22+
profile, _syms, regions = m._local_gb10_path_c_model_regions()
23+
sel = m._select_path_c_model_route_region(regions)
24+
scheduled = m.plan_path_c_fusion_schedule_for_region(sel, include_backward=True)
25+
chain = m.plan_path_c_direct_fusion_chain_for_region(
26+
scheduled.region, include_backward=True,
27+
)
28+
artifacts = m.compile_path_c_direct_fusion_chain_artifacts(chain)
29+
30+
# Pull MSL size per segment by re-compiling with a capturing lowerer.
31+
rows = []
32+
for seg in chain.segments:
33+
target = seg.schedule_target
34+
tmpl = m.mark_path_c_schedule_template_for_region(
35+
target.schedule_template, seg.region,
36+
implementation_kind=target.implementation_kind,
37+
production_schedule_id=target.schedule_id
38+
if target.implementation_kind == "production" else "",
39+
required_real_abi_inputs=target.required_real_abi_inputs,
40+
)
41+
cap = {}
42+
43+
def lw(func_or_mod, *, target, **kwargs):
44+
k = pcf.tilelang_single_entry_lowerer(
45+
func_or_mod, target=target, execution_backend="tvm_ffi", **kwargs)
46+
cap["k"] = k
47+
return k
48+
49+
m.compile_path_c_region(
50+
seg.region, schedule_template=tmpl,
51+
schedule_name=target.schedule_name,
52+
schedule_status=target.schedule_status,
53+
tilelang_lowerer=lw, target="metal",
54+
)
55+
try:
56+
msl = cap["k"].get_kernel_source()
57+
except Exception:
58+
msl = ""
59+
msl = msl if isinstance(msl, str) else str(msl)
60+
rows.append((seg.index, [n.op_name for n in seg.region.nodes],
61+
len(msl), msl.count("\n") + 1))
62+
63+
# Try compiling+running each isolated segment's pipeline-state.
64+
specs = m._path_c_direct_chain_required_logical_buffer_specs(chain)
65+
buffers = {}
66+
for name, spec in specs.items():
67+
dtype = getattr(mx, str(spec["dtype"]))
68+
buffers[name] = mx.zeros(tuple(int(d) for d in spec["shape"]), dtype=dtype)
69+
mx.eval(*buffers.values())
70+
71+
print(f"{'idx':>3} {'kb':>6} {'lines':>5} {'pipeline':>9} ops")
72+
for (idx, ops, nbytes, nlines) in rows:
73+
seg = next(s for s in chain.segments if s.index == idx)
74+
sub = dataclasses.replace(chain, segments=(seg,))
75+
ok = "ok"
76+
try:
77+
m.run_path_c_direct_fusion_chain_route(
78+
chain=sub, logical_buffers=buffers, artifacts=artifacts)
79+
except BaseException as exc: # noqa: BLE001
80+
ok = "CRASH" if "XPC" in str(exc) or "state" in str(exc) else "fail"
81+
print(f"{idx:>3} {nbytes/1024:>6.1f} {nlines:>5} {ok:>9} {ops}")
82+
return 0
83+
84+
85+
if __name__ == "__main__":
86+
raise SystemExit(main())

0 commit comments

Comments
 (0)