Skip to content

Commit b18f415

Browse files
committed
fix(path-c): clear Metal mamba3_mimo_bwd watchdog gate via per-op time-chunk window
The mamba3 reverse-time-scan backward segment tripped the macOS GPU watchdog (kIOGPUCommandBufferCallbackErrorTimeout) on its FIRST time-chunk launch even when time-chunked: the shared launcher window of 8 reverse time-steps/command buffer is too big for mamba3, whose reverse-scan state (1.75MB/step x4) is pooled to GLOBAL device memory (b330bdb) making each step expensive. Add a PER-OP time-chunk window override so mamba3_mimo_bwd uses a smaller rows_per_kernel_launch than the other launcher-chunked backward ops (which keep the watchdog-safe shared default of 8). _target_with_max_rows_per_launch now threads rows_per_kernel_launch into the generated schedule template (the window lives only in the compiled PrimFunc attrs, so a non-default window always re-generates the kernel -- no silent keep of the 8-step kernel). Isolation sweep at full local_gb10_quarter scale (depth=13 hidden=3584 max_seq=4096) via scripts/_bwdgate_mamba3_window_sweep.py, first 4 launches: window total launches first launch steady/launch first-launch fits? 8 512 (timeout) -- NO (kIOGPU timeout) 4 1024 3.63s 2.42s yes (~1.4s margin) 2 2048 2.52s 1.19s yes (~2.5s margin) 1 4096 1.65s 0.68s yes (~3.3s margin) Per-step cost is ~linear so total mamba3 backward wall is window-independent (~40-46 min). 4 is the LARGEST watchdog-safe window; committed default is 2 for ~50% margin during the ~61-min end-to-end run. Metal-only; CUDA keeps the shared default (no per-command-buffer watchdog). Only the mamba3 segment changes (rows_per_kernel_launch 8->2 -> 2048 launches); sparse_mla_fp8_apply_bwd / attention_qkv_projection_bwd / m2rnn_bwd keep window 8. All 119 tests/test_path_c_fusion_ir.py pass.
1 parent 9f74055 commit b18f415

2 files changed

Lines changed: 278 additions & 0 deletions

File tree

cppmega_mlx/runtime/path_c_fusion_schedules.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,37 @@
278278
DESCRIPTOR_ROW_CHUNK_INDEX_PARAM = "path_c_row_chunk_index"
279279
DESCRIPTOR_ROW_SUBCHUNK_INDEX_PARAM = "path_c_row_subchunk_index"
280280
DESCRIPTOR_ROWS_PER_KERNEL_LAUNCH = 8
281+
# Per-op time-chunk window override for the mamba3 reverse-time-scan backward.
282+
#
283+
# Every launcher-chunked backward segment processes ``rows_per_kernel_launch``
284+
# reverse time-steps per Metal command buffer (one ``(chunk, subchunk)`` launch;
285+
# see _path_c_segment_time_chunk_launches). The shared default of 8 steps/launch
286+
# is watchdog-safe for the per-row-INDEPENDENT heavy ops
287+
# (attention_qkv_projection_bwd / sparse_mla_fp8_apply_bwd, ~0.42s/launch) but
288+
# NOT for mamba3_mimo_bwd: its reverse-scan state (1.75MB/step x 4) was pooled to
289+
# GLOBAL device memory (commit b330bdb, forced by Metal's 32KiB threadgroup cap),
290+
# so each reverse step is far slower and an 8-step command buffer trips the macOS
291+
# GPU watchdog (kIOGPUCommandBufferCallbackErrorTimeout) on its FIRST launch at
292+
# full local_gb10_quarter scale (depth=13 hidden=3584 max_seq=4096). mamba3 gets
293+
# its OWN smaller window here.
294+
#
295+
# Measured isolation sweep (scripts/_bwdgate_mamba3_window_sweep.py, M4 Max, full
296+
# local_gb10_quarter, first 4 launches; first launch carries the checkpoint-replay
297+
# setup, ~5s GPU watchdog window):
298+
# window total launches first launch steady/launch first-launch fits?
299+
# ------ -------------- ------------ ------------- ------------------
300+
# 8 512 (timeout) -- NO (kIOGPU timeout)
301+
# 4 1024 3.63s 2.42s yes (tight, ~1.4s margin)
302+
# 2 2048 2.52s 1.19s yes (~2.5s margin)
303+
# 1 4096 1.65s 0.68s yes (~3.3s margin)
304+
# Per-step cost is ~linear (~0.6s/time-step) so the TOTAL mamba3 backward wall is
305+
# roughly window-independent (~40-46 min); reducing the window only trades a fixed
306+
# total cost for more launches with more watchdog margin. 4 is the LARGEST
307+
# watchdog-safe window; 2 is chosen as the committed default for a comfortable
308+
# ~50% margin during the ~61-min end-to-end run (a window-4 launch at 3.63s leaves
309+
# little headroom if a transient GPU stall lands on it). Metal-only: CUDA has no
310+
# per-command-buffer watchdog and keeps the shared default.
311+
MAMBA3_BWD_ROWS_PER_KERNEL_LAUNCH = 2
281312
DESCRIPTOR_BACKWARD_GATE_PARAM = "path_c_run_backward"
282313
DESCRIPTOR_BACKWARD_STAGE_INDEX_PARAM = "path_c_backward_stage_index"
283314
DESCRIPTOR_EXECUTION_STAGE_ALL = "all"
@@ -707,6 +738,27 @@ def _path_c_descriptor_stage_rows_per_kernel_launch_for_node(node: Any) -> int:
707738
return DESCRIPTOR_ROWS_PER_KERNEL_LAUNCH
708739

709740

741+
def _mamba3_bwd_rows_per_kernel_launch_for_nodes(nodes: Iterable[Any]) -> int:
742+
"""Per-launch time-chunk window for a launcher-chunked backward segment.
743+
744+
Returns :data:`MAMBA3_BWD_ROWS_PER_KERNEL_LAUNCH` (the smaller, watchdog-safe
745+
mamba3 window) when the segment contains ``mamba3_mimo_bwd`` -- whose pooled
746+
global reverse-scan state makes each reverse step expensive enough that the
747+
shared 8-step window trips the macOS GPU watchdog. Every other
748+
launcher-chunked backward op (the per-row-INDEPENDENT heavy ops and m2rnn_bwd)
749+
keeps the shared :data:`DESCRIPTOR_ROWS_PER_KERNEL_LAUNCH` window, which is
750+
already watchdog-safe for them. With the backward op cap = 1 each backward op
751+
is in its OWN segment, so a segment never mixes mamba3 with another op.
752+
"""
753+
754+
if any(
755+
_path_c_descriptor_stage_node_op_name(node) == "mamba3_mimo_bwd"
756+
for node in nodes
757+
):
758+
return MAMBA3_BWD_ROWS_PER_KERNEL_LAUNCH
759+
return DESCRIPTOR_ROWS_PER_KERNEL_LAUNCH
760+
761+
710762
def _path_c_descriptor_stage_append(
711763
groups: list[PathCDescriptorScheduleStageGroup],
712764
*,
@@ -14817,11 +14869,23 @@ def plan_path_c_direct_fusion_chain_for_region(
1481714869
for node in candidate_region.nodes
1481814870
)
1481914871
):
14872+
# Per-op time-chunk window. mamba3_mimo_bwd carries multi-MB
14873+
# global reverse-scan state per step (b330bdb), so an 8-step
14874+
# command buffer trips the watchdog on its first launch -- it
14875+
# needs a SMALLER window than the per-row-independent heavy ops.
14876+
# _mamba3_bwd_rows_per_kernel_launch_for_nodes returns the mamba3
14877+
# override only when the segment contains mamba3_mimo_bwd; every
14878+
# other launcher-chunked backward op keeps the shared default.
1482014879
direct_target = _target_with_max_rows_per_launch(
1482114880
direct_target,
1482214881
candidate_region,
1482314882
DESCRIPTOR_DEFAULT_MAX_ROWS_PER_LAUNCH,
1482414883
DESCRIPTOR_ROW_DISPATCH_LAUNCHER_CHUNKS,
14884+
rows_per_kernel_launch=(
14885+
_mamba3_bwd_rows_per_kernel_launch_for_nodes(
14886+
candidate_region.nodes
14887+
)
14888+
),
1482514889
)
1482614890
# ROW-WINDOWING for the per-row-INDEPENDENT heavy FORWARD ops
1482714891
# (attention_qkv_projection / sparse_mla_fp8_apply; see
@@ -15092,12 +15156,25 @@ def _target_with_max_rows_per_launch(
1509215156
region: PathCFusionRegion,
1509315157
max_rows_per_launch: int | None,
1509415158
row_dispatch_mode: str,
15159+
rows_per_kernel_launch: int = DESCRIPTOR_ROWS_PER_KERNEL_LAUNCH,
1509515160
) -> PathCFusionScheduleTarget:
1509615161
validated_rows = _validated_max_rows_per_launch(max_rows_per_launch)
1509715162
validated_mode = _validated_row_dispatch_mode(row_dispatch_mode)
15163+
validated_rows_per_kernel_launch = _validated_rows_per_kernel_launch(
15164+
rows_per_kernel_launch
15165+
)
15166+
# The dispatch mode + max_rows_per_launch are carried on the target struct,
15167+
# but the per-launch time-chunk window (rows_per_kernel_launch) lives ONLY in
15168+
# the generated schedule_template / compiled PrimFunc attrs, so it is never
15169+
# recorded on the PathCFusionScheduleTarget. A non-default window must
15170+
# therefore re-generate the template even when mode + max_rows already match;
15171+
# the early-out is valid only for the default window (where re-generation
15172+
# would be a no-op). RULE #1: an explicit non-default window always lowers --
15173+
# never silently keeps the default 8-step kernel.
1509815174
if (
1509915175
target.max_rows_per_launch == validated_rows
1510015176
and target.row_dispatch_mode == validated_mode
15177+
and validated_rows_per_kernel_launch == DESCRIPTOR_ROWS_PER_KERNEL_LAUNCH
1510115178
):
1510215179
return target
1510315180
shape_env = _shape_env_for_region(region)
@@ -15114,6 +15191,7 @@ def _target_with_max_rows_per_launch(
1511415191
physical_abi_policy=target.physical_abi_policy,
1511515192
max_rows_per_launch=validated_rows,
1511615193
row_dispatch_mode=validated_mode,
15194+
rows_per_kernel_launch=validated_rows_per_kernel_launch,
1511715195
),
1511815196
max_rows_per_launch=validated_rows,
1511915197
row_dispatch_mode=validated_mode,
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
#!/usr/bin/env python3
2+
"""Isolation sweep for the mamba3_mimo_bwd time-chunk window at FULL scale.
3+
4+
Drives ONLY the mamba3_mimo_bwd backward segment of the full local_gb10_quarter
5+
Path C direct-chain (depth=13, hidden=3584, max_seq=4096) -- NO 61-min forward.
6+
For a given ``--rows-per-kernel-launch`` window it:
7+
8+
1. monkeypatches MAMBA3_BWD_ROWS_PER_KERNEL_LAUNCH so the mamba3 segment is
9+
compiled with that per-launch time-chunk window,
10+
2. plans + compiles the chain, extracts the mamba3 segment,
11+
3. allocates caller-owned zero buffers,
12+
4. drives the segment's time-chunk launches ONE AT A TIME, timing EACH launch
13+
(eval + synchronize == one committed Metal command buffer), capturing the
14+
GPU watchdog timeout per-launch so we attribute the failure to the FIRST
15+
launch.
16+
17+
The FIRST launch (chunk 0, subchunk 0) is the watchdog gate: it processes the
18+
LAST ``window`` reverse time-steps AND runs the checkpoint-replay for them.
19+
20+
By default only the first ``--max-launches`` launches are timed (enough to see
21+
whether the first launch fits the watchdog and the steady per-launch cost);
22+
pass ``--all-launches`` to run the ENTIRE mamba3 backward (4096/window launches)
23+
and report the total mamba3-backward wall time.
24+
25+
RULE #1: no silent fallback. A watchdog timeout on the first launch is RAISED
26+
and reported as that window being infeasible.
27+
"""
28+
from __future__ import annotations
29+
30+
import argparse
31+
import dataclasses
32+
import json
33+
import sys
34+
import time
35+
import traceback
36+
from pathlib import Path
37+
from typing import Any
38+
39+
ROOT = Path(__file__).resolve().parents[1]
40+
for p in (str(ROOT), str(ROOT / "scripts")):
41+
if p not in sys.path:
42+
sys.path.insert(0, p)
43+
44+
import mlx.core as mx # noqa: E402
45+
import m04_train_step as m # noqa: E402
46+
import cppmega_mlx.runtime.path_c_fusion_schedules as sched # noqa: E402
47+
48+
49+
def _find_mamba3_segment(chain: Any) -> Any:
50+
for seg in chain.segments:
51+
if any(n.op_name == "mamba3_mimo_bwd" for n in seg.region.nodes):
52+
return seg
53+
raise RuntimeError("no mamba3_mimo_bwd segment found in direct-chain plan")
54+
55+
56+
def main() -> int:
57+
parser = argparse.ArgumentParser()
58+
parser.add_argument("--rows-per-kernel-launch", type=int, required=True,
59+
help="time-steps per launch for the mamba3 bwd window")
60+
parser.add_argument("--max-launches", type=int, default=4,
61+
help="number of leading launches to drive+time (gate probe)")
62+
parser.add_argument("--all-launches", action="store_true",
63+
help="drive ALL launches (full mamba3 backward); reports total")
64+
args = parser.parse_args()
65+
66+
window = int(args.rows_per_kernel_launch)
67+
if window <= 0:
68+
raise ValueError("--rows-per-kernel-launch must be positive")
69+
70+
# Force the mamba3 per-op time-chunk window for THIS run.
71+
sched.MAMBA3_BWD_ROWS_PER_KERNEL_LAUNCH = window
72+
73+
print(f"[sweep] device={mx.default_device()} target={m._path_c_default_target()}",
74+
flush=True)
75+
print(f"[sweep] mamba3 rows_per_kernel_launch (window) = {window}", flush=True)
76+
77+
profile, route_symbols, regions = m._local_gb10_path_c_model_regions()
78+
print(f"[sweep] profile={profile.name} depth={profile.depth} "
79+
f"hidden={profile.hidden_size} max_seq={profile.max_seq_length}", flush=True)
80+
sel = m._select_path_c_model_route_region(regions)
81+
scheduled = m.plan_path_c_fusion_schedule_for_region(sel, include_backward=True)
82+
chain = m.plan_path_c_direct_fusion_chain_for_region(
83+
scheduled.region, include_backward=True,
84+
)
85+
mamba_seg = _find_mamba3_segment(chain)
86+
tgt = mamba_seg.schedule_target
87+
print(f"[sweep] mamba3 seg index={mamba_seg.index} region={mamba_seg.region.name} "
88+
f"row_dispatch={getattr(tgt, 'row_dispatch_mode', None)} "
89+
f"max_rows_per_launch={getattr(tgt, 'max_rows_per_launch', None)}", flush=True)
90+
91+
# Compile ALL segments (the runtime route needs the full artifact set), then
92+
# drive ONLY the mamba3 sub-chain.
93+
t_c = time.perf_counter()
94+
artifacts = m.compile_path_c_direct_fusion_chain_artifacts(chain)
95+
print(f"[sweep] compiled {len(artifacts)} segment shaders in "
96+
f"{time.perf_counter()-t_c:.1f}s", flush=True)
97+
98+
# Inspect the compiled mamba3 prim_func to report the launch math.
99+
prim_func = None
100+
for art in artifacts:
101+
pf = getattr(art, "prim_func", None) or getattr(art, "_prim_func", None)
102+
# artifacts is a dict in the per-segment probe; handle both.
103+
# The route runner reads launch math off the prim_func; surface it via the
104+
# same helper the runtime uses.
105+
sub_chain = dataclasses.replace(chain, segments=(mamba_seg,))
106+
107+
# Hook per-launch timing by wrapping mx.synchronize (each time-chunk launch
108+
# commits via eval()+synchronize() -- the synchronize is the command-buffer
109+
# boundary). We time the interval between consecutive synchronize() returns.
110+
launch_times: list[float] = []
111+
state: dict[str, Any] = {"last": None}
112+
real_sync = mx.synchronize
113+
114+
def timed_sync(*a: Any, **k: Any) -> Any:
115+
r = real_sync(*a, **k)
116+
now = time.perf_counter()
117+
if state["last"] is not None:
118+
dt = now - state["last"]
119+
launch_times.append(dt)
120+
print(f"[sweep] launch[{len(launch_times)-1}] committed in "
121+
f"{dt:.3f}s", flush=True)
122+
state["last"] = now
123+
return r
124+
125+
mx.synchronize = timed_sync # type: ignore[assignment]
126+
127+
# Limit the number of launches when not running the full backward, by
128+
# truncating the launch list the runtime iterates. We do that by patching
129+
# _path_c_segment_time_chunk_launches in m04 to slice to max_launches.
130+
real_launches_fn = m._path_c_segment_time_chunk_launches
131+
limit = None if args.all_launches else int(args.max_launches)
132+
133+
def limited_launches(pf: Any) -> tuple:
134+
full = real_launches_fn(pf)
135+
if not full:
136+
return full
137+
print(f"[sweep] mamba3 total time-chunk launches (full) = {len(full)} "
138+
f"(window={window}, S/window)", flush=True)
139+
if limit is None:
140+
return full
141+
return full[:limit]
142+
143+
m._path_c_segment_time_chunk_launches = limited_launches # type: ignore[assignment]
144+
145+
specs = m._path_c_direct_chain_required_logical_buffer_specs(sub_chain)
146+
buffers: dict[str, Any] = {}
147+
for name, spec in specs.items():
148+
dtype = getattr(mx, str(spec["dtype"]))
149+
buffers[name] = mx.zeros(tuple(int(d) for d in spec["shape"]), dtype=dtype)
150+
mx.eval(*buffers.values())
151+
print(f"[sweep] allocated {len(buffers)} caller-owned buffers; driving "
152+
f"{'ALL' if args.all_launches else limit} mamba3 launches", flush=True)
153+
154+
status = "ok"
155+
err = None
156+
t0 = time.perf_counter()
157+
# Seed the per-launch clock so launch[0]'s window (run-start -> first
158+
# synchronize) is measured. The route runner's pre-launch buffer eval is
159+
# cheap (zero buffers already materialised); the first synchronize boundary
160+
# therefore measures launch[0]'s GPU command buffer.
161+
state["last"] = t0
162+
try:
163+
payload = m.run_path_c_direct_fusion_chain_route(
164+
chain=sub_chain,
165+
logical_buffers=buffers,
166+
artifacts=artifacts,
167+
)
168+
except BaseException as exc: # noqa: BLE001
169+
status = "FAIL"
170+
err = f"{type(exc).__name__}: {exc}"
171+
traceback.print_exc()
172+
payload = None
173+
finally:
174+
mx.synchronize = real_sync # type: ignore[assignment]
175+
m._path_c_segment_time_chunk_launches = real_launches_fn # type: ignore[assignment]
176+
total = time.perf_counter() - t0
177+
178+
first_launch = launch_times[0] if launch_times else None
179+
steady = (
180+
sum(launch_times[1:]) / max(1, len(launch_times) - 1)
181+
if len(launch_times) > 1 else None
182+
)
183+
out = {
184+
"window_rows_per_kernel_launch": window,
185+
"status": status,
186+
"error": err,
187+
"driven_launches": (None if args.all_launches else limit),
188+
"all_launches": bool(args.all_launches),
189+
"timed_launch_count": len(launch_times),
190+
"first_launch_s": round(first_launch, 4) if first_launch is not None else None,
191+
"steady_per_launch_s": round(steady, 4) if steady is not None else None,
192+
"wall_total_s": round(total, 3),
193+
"first_launch_fits_watchdog": (status == "ok"),
194+
}
195+
print("\n[sweep] RESULT:\n" + json.dumps(out, indent=2), flush=True)
196+
return 0 if status == "ok" else 7
197+
198+
199+
if __name__ == "__main__":
200+
raise SystemExit(main())

0 commit comments

Comments
 (0)