Skip to content

Commit 154ce8f

Browse files
committed
fix(path-c): split Metal FORWARD fused mega-kernel (chain_3_7) to clear MTLCompilerService XPC crash at newComputePipelineState
ROOT CAUSE (verified, full local_gb10_quarter: depth=13 hidden=3584 max_seq=4096): The greedy direct-chain planner fuses each FORWARD segment up to the portable kernel-buffer limit, producing the 4-op forward mega-kernel m2rnn + residual_rmsnorm + attention_qkv_projection + sparse_mla_fp8_apply (region local_gb10_quarter_path_c_10_12_chain_3_7). Its generated MSL device kernel is ~176-199 KB / ~1600-2211 lines, dominated by attention_qkv_projection (207 refs) + sparse_mla_fp8_apply (199 refs) -- a sparse top-k FP8 attention with RoPE + softmax inlined into ONE monolithic `kernel void`. TileLang codegen + metallib build SUCCEED (~3s, exit 0), but at runtime Metal's MTLCompilerService crashes the final AIR->GPU-ISA pipeline-state stage inside newComputePipelineState (lazy, inside mx.eval at tilelang tvm_ffi adapter): InternalError: Check failed: (state != nullptr): ... for function local_gb10_quarter_path_c_10_12_chain_3_7_kernel Compilation failed due to an interrupted connection: XPC_ERROR_CONNECTION_INTERRUPTED. Deterministic (3/3 runs), ~3.8s, at exec#1 on the FORWARD chain -- gates the whole route (backward never reached). Reproduces on baseline 097693e. Measured shader-size hierarchy (device MSL): chain_0_3 (3 light fwd ops) ~46 KB / 669 L -> pipeline OK chain_7_10 (3 bwd ops) ~116 KB / 1555 L -> pipeline OK chain_3_7 (4 heavy fwd ops) ~176-199 KB -> pipeline CRASH FIX (option a: split the fused forward segment on Metal, explicit by target): plan_path_c_direct_fusion_chain_for_region gains forward_max_segment_nodes, a FORWARD-only op-count cap resolved at call time from the lowering target -- METAL_FORWARD_MAX_SEGMENT_NODES = 2 on Metal, None (uncapped) on CUDA (CUDA's compiler has no pipeline-state size limit, so CUDA fusion is byte-for-byte unchanged). The cap is forward-only; backward keeps greedy fusion up to the buffer limit (its reverse-time watchdog split via launcher_chunks is separate). Selection is explicit by target, NOT a silent runtime fallback (RULE #1). RESULT (verified, full scale): the 4-op chain_3_7 is eliminated; the forward phase is now 4 split segments, each well under the crashing size: chain_0_2 entry_rmsnorm+mamba3_mimo ~46 KB / 709 L chain_2_4 residual_rmsnorm+m2rnn ~36 KB / 599 L chain_4_6 residual_rmsnorm+attention_qkv_projection ~93 KB / 861 L chain_6_7 sparse_mla_fp8_apply ~91 KB / 1056 L All 4 forward segments now CREATE their Metal pipeline state (NO XPC_ERROR_CONNECTION_INTERRUPTED) and EXECUTE. The full-scale forward route gets fully past exec#1; execution proceeds into the backward phase and reaches a DIFFERENT, separate failure -- a GPU watchdog timeout (kIOGPUCommandBufferCallbackErrorTimeout) at the first backward segment (chain_7_10) -- which is progress (a pre-existing backward-chain issue), not the forward megakernel crash. The forward-only standalone executor test confirms the forward phase executes status=ok end-to-end at smoke scale. Side effect (benign): splitting sparse_mla_fp8_apply into its own segment makes its int32 sparse-index scratch (path_c_int32_scratch_bank) kernel-internal rather than a coalesced cross-segment logical scratch bank -> one fewer required logical buffer (94->93). Updated the smoke-scale executor/route tests to the new correct Metal grouping (7->9 segments, 2->4 forward, time-chunked bwd indices {3,5}->{5,7}, buffer counts shifted by the dropped int32 scratch bank). CUDA grouping unchanged. Pre-existing failures NOT touched (confirmed identical on baseline 097693e): test_plan_path_c_direct_fusion_chains_for_model_uses_dynamic_brick_chain, test_direct_fusion_chain_splits_model_route_under_metal_buffer_limit, test_direct_fusion_chain_keeps_loss_bridge_forward_boundary_separate (stale "blocked" assertions -- mamba3_mimo_bwd no longer blocks), test_path_c_direct_chain_runtime_value_and_grad_* (broken 'tokens' batch fixture).
1 parent 097693e commit 154ce8f

3 files changed

Lines changed: 335 additions & 21 deletions

File tree

cppmega_mlx/runtime/path_c_fusion_schedules.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,43 @@
209209
DESCRIPTOR_EXECUTION_STAGE_BACKWARD,
210210
}
211211
)
212+
# Metal-only FORWARD fused-segment op-count cap.
213+
#
214+
# The greedy direct-chain planner fuses each forward segment up to the portable
215+
# kernel-buffer limit, producing the 4-op forward mega-kernel
216+
# ``m2rnn + residual_rmsnorm + attention_qkv_projection + sparse_mla_fp8_apply``
217+
# (region ``..._chain_3_7``). Its generated MSL device kernel is ~176-199 KB /
218+
# ~1600-2200 lines (dominated by attention_qkv_projection + sparse_mla_fp8_apply,
219+
# a sparse top-k FP8 attention with RoPE + softmax). TileLang codegen + metallib
220+
# build SUCCEED, but at runtime Metal's MTLCompilerService crashes the final
221+
# AIR->GPU-ISA pipeline-state stage inside ``newComputePipelineState``:
222+
# InternalError: Check failed: (state != nullptr): ...
223+
# Compilation failed due to an interrupted connection:
224+
# XPC_ERROR_CONNECTION_INTERRUPTED. This error occurred after multiple retries.
225+
# Measured (full local_gb10_quarter, depth=13 hidden=3584 max_seq=4096):
226+
# chain_0_3 (3 light fwd ops) ~46 KB -> pipeline OK
227+
# chain_7_10 (3 bwd ops) ~116 KB -> pipeline OK
228+
# chain_3_7 (4 heavy fwd ops) ~176 KB -> pipeline CRASH (newComputePipelineState)
229+
# Capping forward segments at 2 ops splits chain_3_7 into chain_3_5 and chain_5_7,
230+
# each well under the crashing size. CUDA's compiler has no such pipeline-state
231+
# limit, so this cap is gated to Metal only (CUDA keeps greedy forward fusion).
232+
METAL_FORWARD_MAX_SEGMENT_NODES = 2
233+
234+
235+
class _ResolveFromTarget:
236+
"""Sentinel: resolve a planner default from the active lowering target.
237+
238+
A distinct type (not ``None``, which is a valid "no cap" value) so callers
239+
can explicitly request the target-resolved default or override it.
240+
"""
241+
242+
__slots__ = ()
243+
244+
def __repr__(self) -> str: # pragma: no cover - debug aid
245+
return "<resolve-from-target>"
246+
247+
248+
_RESOLVE_FORWARD_CAP_FROM_TARGET = _ResolveFromTarget()
212249
_GENERIC_MODEL_REAL_ABI_INPUT_SUFFIXES = (
213250
"residual_norm_weight",
214251
# Block A: the per-brick entry RMSNorm weight emitted by the model-
@@ -14190,6 +14227,9 @@ def plan_path_c_direct_fusion_chain_for_region(
1419014227
include_backward: bool = True,
1419114228
max_kernel_buffers: int = DESCRIPTOR_PORTABLE_KERNEL_BUFFER_LIMIT,
1419214229
max_segment_nodes: int | None = None,
14230+
forward_max_segment_nodes: int | None | _ResolveFromTarget = (
14231+
_RESOLVE_FORWARD_CAP_FROM_TARGET
14232+
),
1419314233
registry: PathCFusionScheduleRegistry | None = None,
1419414234
) -> PathCFusionScheduleChainPlan:
1419514235
"""Greedily split a Path C region into direct-buffer fused segments.
@@ -14198,6 +14238,22 @@ def plan_path_c_direct_fusion_chain_for_region(
1419814238
would exceed Metal's portable buffer slot limit. It never falls back to
1419914239
dtype-bank packing; segments that cannot be expressed with direct buffers
1420014240
under ``max_kernel_buffers`` are reported as blocked.
14241+
14242+
``forward_max_segment_nodes`` is a Metal-only cap on the number of ops a
14243+
FORWARD-phase fused segment may contain. The default
14244+
(``_RESOLVE_FORWARD_CAP_FROM_TARGET``) is resolved at call time from the
14245+
active lowering target: on Metal it is
14246+
:data:`METAL_FORWARD_MAX_SEGMENT_NODES` = 2 (so the heavy 4-op forward
14247+
segment ``m2rnn + residual_rmsnorm + attention_qkv_projection +
14248+
sparse_mla_fp8_apply`` — whose ~176-199 KB MSL crashes Metal's
14249+
``MTLCompilerService`` at ``newComputePipelineState`` with
14250+
``XPC_ERROR_CONNECTION_INTERRUPTED`` — splits into two smaller
14251+
pipeline-safe kernels); on CUDA it is ``None`` (no cap, the CUDA compiler
14252+
has no such pipeline-state size limit, so CUDA fusion is unchanged). The
14253+
cap applies ONLY to forward segments; backward segments keep greedy fusion
14254+
up to the buffer limit (their reverse-time watchdog split is handled
14255+
separately via launcher_chunks). Pass an explicit value to override; pass
14256+
``None`` to disable the cap entirely.
1420114257
"""
1420214258

1420314259
if not isinstance(region, PathCFusionRegion):
@@ -14206,6 +14262,23 @@ def plan_path_c_direct_fusion_chain_for_region(
1420614262
raise ValueError("max_kernel_buffers must be positive")
1420714263
if max_segment_nodes is not None and max_segment_nodes <= 0:
1420814264
raise ValueError("max_segment_nodes must be positive when provided")
14265+
if isinstance(forward_max_segment_nodes, _ResolveFromTarget):
14266+
# Resolve the forward fusion cap from the active lowering target:
14267+
# Metal needs the split (MTLCompilerService crashes at
14268+
# newComputePipelineState on the 4-op forward mega-kernel); CUDA has no
14269+
# such limit and stays uncapped (greedy) for max fusion.
14270+
forward_max_segment_nodes = (
14271+
METAL_FORWARD_MAX_SEGMENT_NODES
14272+
if _path_c_default_target() == "metal"
14273+
else None
14274+
)
14275+
if (
14276+
forward_max_segment_nodes is not None
14277+
and forward_max_segment_nodes <= 0
14278+
):
14279+
raise ValueError(
14280+
"forward_max_segment_nodes must be positive when provided"
14281+
)
1420914282
working_region = (
1421014283
build_path_c_aot_autograd_region(region)
1421114284
if include_backward
@@ -14244,6 +14317,24 @@ def plan_path_c_direct_fusion_chain_for_region(
1424414317
"execution boundary required by the loss cotangent bridge"
1424514318
)
1424614319
break
14320+
# FORWARD-only Metal pipeline-state cap. Forward fused segments
14321+
# larger than ``forward_max_segment_nodes`` ops generate MSL whose
14322+
# ~176-199 KB device kernel crashes Metal's MTLCompilerService at
14323+
# newComputePipelineState (XPC_ERROR_CONNECTION_INTERRUPTED). Stop
14324+
# extending the forward segment here so it splits into smaller
14325+
# pipeline-safe kernels. Backward segments are exempt (they keep
14326+
# greedy fusion up to the buffer limit).
14327+
if (
14328+
execution_phase == DESCRIPTOR_EXECUTION_STAGE_FORWARD
14329+
and forward_max_segment_nodes is not None
14330+
and end - start > forward_max_segment_nodes
14331+
):
14332+
first_failure = (
14333+
f"forward direct-buffer segment reached "
14334+
f"forward_max_segment_nodes={forward_max_segment_nodes} "
14335+
f"(Metal newComputePipelineState size cap)"
14336+
)
14337+
break
1424714338
target = selector.select(candidate_region)
1424814339
if target is None:
1424914340
first_failure = (
@@ -14386,6 +14477,9 @@ def plan_path_c_direct_fusion_chains_for_model(
1438614477
min_route_bricks: int = 2,
1438714478
max_kernel_buffers: int = DESCRIPTOR_PORTABLE_KERNEL_BUFFER_LIMIT,
1438814479
max_segment_nodes: int | None = None,
14480+
forward_max_segment_nodes: int | None | _ResolveFromTarget = (
14481+
_RESOLVE_FORWARD_CAP_FROM_TARGET
14482+
),
1438914483
registry: PathCFusionScheduleRegistry | None = None,
1439014484
sequence_length: int | None = None,
1439114485
) -> tuple[PathCFusionScheduleChainPlan, ...]:
@@ -14395,6 +14489,10 @@ def plan_path_c_direct_fusion_chains_for_model(
1439514489
``plan_path_c_fusion_schedules_for_model``: discover regions from the
1439614490
model's brick graph first, then split each discovered region into generic
1439714491
direct-buffer segments. It does not consult named acceptance fixtures.
14492+
14493+
``forward_max_segment_nodes`` is forwarded to
14494+
:func:`plan_path_c_direct_fusion_chain_for_region` (Metal-only forward
14495+
fusion cap; see that function's docstring).
1439814496
"""
1439914497

1440014498
regions = build_path_c_model_regions_from_model(
@@ -14410,6 +14508,7 @@ def plan_path_c_direct_fusion_chains_for_model(
1441014508
include_backward=include_backward,
1441114509
max_kernel_buffers=max_kernel_buffers,
1441214510
max_segment_nodes=max_segment_nodes,
14511+
forward_max_segment_nodes=forward_max_segment_nodes,
1441314512
registry=registry,
1441414513
)
1441514514
for region in regions
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/usr/bin/env python3
2+
"""DECISIVE full-scale repro: compile + execute the local_gb10_quarter Path C
3+
direct-chain BACKWARD segment shaders at FULL model scale (depth=13, dims=3584).
4+
5+
This drives the SAME path as scripts/m04_train_step.py
6+
--use-path-c-direct-chain-runtime: it builds the full local_gb10_quarter regions,
7+
plans the direct fusion chain (mamba3_mimo_bwd now monolithic grid_chunks,
8+
m2rnn_bwd time-chunked launcher_chunks), NATIVE-COMPILES every segment shader via
9+
TileLang -> Metal newComputePipelineState (this is where the prior
10+
XPC_ERROR_CONNECTION_INTERRUPTED crash happened), then EXECUTES the route with
11+
caller-owned zero buffers (this is where kIOGPUCommandBufferCallbackErrorTimeout
12+
would fire if mamba3 monolithic trips the GPU watchdog).
13+
14+
No kernel patching, no model code swap; routes are the production direct-chain
15+
schedule. RULE #1: the guarded runtime RAISES on failure (no silent fallback).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import argparse
21+
import json
22+
import os
23+
from pathlib import Path
24+
import sys
25+
import time
26+
import traceback
27+
from typing import Any
28+
29+
ROOT = Path(__file__).resolve().parents[1]
30+
if str(ROOT) not in sys.path:
31+
sys.path.insert(0, str(ROOT))
32+
SCRIPTS = ROOT / "scripts"
33+
if str(SCRIPTS) not in sys.path:
34+
sys.path.insert(0, str(SCRIPTS))
35+
36+
import mlx.core as mx # noqa: E402
37+
import m04_train_step as m # noqa: E402
38+
39+
40+
def main() -> int:
41+
parser = argparse.ArgumentParser()
42+
parser.add_argument("--seq-len", type=int, default=None,
43+
help="sequence length for the region shape env (default: region default)")
44+
parser.add_argument("--execute", action="store_true",
45+
help="after compile, EXECUTE the route (drives the GPU watchdog)")
46+
args = parser.parse_args()
47+
48+
print(f"[repro] mx default device: {mx.default_device()}", flush=True)
49+
print(f"[repro] PATH_C target: {m._path_c_default_target()}", flush=True)
50+
print(f"[repro] per-segment cmdbuf commit: "
51+
f"{m._path_c_per_segment_command_buffer_commit_enabled()}", flush=True)
52+
53+
# 1) FULL-scale region (depth=13, hidden=3584) -- the real production route.
54+
profile, route_symbols, regions = m._local_gb10_path_c_model_regions()
55+
print(f"[repro] profile={profile.name} depth={profile.depth} "
56+
f"hidden={profile.hidden_size} max_seq={profile.max_seq_length}", flush=True)
57+
sel = m._select_path_c_model_route_region(regions)
58+
assert sel is not None
59+
print(f"[repro] selected region={sel.name} nodes={len(sel.nodes)} "
60+
f"ops={[n.op_name for n in sel.nodes]}", flush=True)
61+
62+
scheduled = m.plan_path_c_fusion_schedule_for_region(sel, include_backward=True)
63+
chain = m.plan_path_c_direct_fusion_chain_for_region(
64+
scheduled.region, include_backward=True,
65+
)
66+
print(f"[repro] chain.status={chain.status} n_segments={len(chain.segments)}", flush=True)
67+
for seg in chain.segments:
68+
tgt = getattr(seg, "schedule_target", None)
69+
ops = [n.op_name for n in seg.region.nodes]
70+
rd = getattr(tgt, "row_dispatch_mode", None) if tgt else None
71+
print(f"[repro] seg[{seg.index}] phase={seg.execution_phase} "
72+
f"status={seg.status} row_dispatch={rd} ops={ops}", flush=True)
73+
74+
# 2) NATIVE COMPILE each segment shader -- the newComputePipelineState step.
75+
print("\n[repro] === COMPILING segment shaders (newComputePipelineState) ===", flush=True)
76+
t_compile = time.perf_counter()
77+
try:
78+
artifacts = m.compile_path_c_direct_fusion_chain_artifacts(chain)
79+
except BaseException as exc: # noqa: BLE001 surface the crash exactly
80+
print(f"[repro] !!! COMPILE FAILED after {time.perf_counter()-t_compile:.2f}s: "
81+
f"{type(exc).__name__}: {exc}", flush=True)
82+
traceback.print_exc()
83+
return 3
84+
compile_elapsed = time.perf_counter() - t_compile
85+
print(f"[repro] === ALL {len(artifacts)} segment shaders COMPILED ok in "
86+
f"{compile_elapsed:.2f}s ===", flush=True)
87+
88+
if not args.execute:
89+
print(json.dumps({
90+
"phase": "compile_only",
91+
"compiled_segments": len(artifacts),
92+
"compile_elapsed_seconds": compile_elapsed,
93+
"mamba3_seg_dispatch": next(
94+
getattr(getattr(s, "schedule_target", None), "row_dispatch_mode", None)
95+
for s in chain.segments
96+
if any(n.op_name == "mamba3_mimo_bwd" for n in s.region.nodes)
97+
),
98+
"m2rnn_seg_dispatch": next(
99+
getattr(getattr(s, "schedule_target", None), "row_dispatch_mode", None)
100+
for s in chain.segments
101+
if any(n.op_name == "m2rnn_bwd" for n in s.region.nodes)
102+
),
103+
}, indent=2))
104+
return 0
105+
106+
# 3) EXECUTE the route -- caller-owned zero buffers; drives the GPU watchdog.
107+
print("\n[repro] === EXECUTING direct-chain route (GPU watchdog window) ===", flush=True)
108+
specs = m._path_c_direct_chain_required_logical_buffer_specs(chain)
109+
buffers: dict[str, Any] = {}
110+
for name, spec in specs.items():
111+
dtype = getattr(mx, str(spec["dtype"]))
112+
buffers[name] = mx.zeros(tuple(int(d) for d in spec["shape"]), dtype=dtype)
113+
mx.eval(*buffers.values())
114+
print(f"[repro] allocated {len(buffers)} caller-owned logical buffers", flush=True)
115+
116+
t_run = time.perf_counter()
117+
try:
118+
payload = m.run_path_c_direct_fusion_chain_route(
119+
chain=chain,
120+
logical_buffers=buffers,
121+
artifacts=artifacts,
122+
)
123+
except BaseException as exc: # noqa: BLE001
124+
print(f"[repro] !!! EXECUTE FAILED after {time.perf_counter()-t_run:.2f}s: "
125+
f"{type(exc).__name__}: {exc}", flush=True)
126+
traceback.print_exc()
127+
return 4
128+
run_elapsed = time.perf_counter() - t_run
129+
print(f"[repro] === ROUTE EXECUTED status={payload.get('status')} in "
130+
f"{run_elapsed:.2f}s ===", flush=True)
131+
132+
# Per-segment timing + status (focus on mamba3_mimo_bwd / m2rnn_bwd).
133+
seg_report = []
134+
for s in payload.get("segments", []):
135+
seg_report.append({
136+
"index": s.get("index"),
137+
"phase": s.get("execution_phase"),
138+
"status": s.get("status"),
139+
"ops": [n for n in s.get("op_signature", s.get("ops", []))]
140+
if isinstance(s.get("op_signature", s.get("ops", [])), list) else None,
141+
"time_chunk_launch_count": s.get("time_chunk_launch_count"),
142+
"elapsed_seconds": s.get("elapsed_seconds"),
143+
})
144+
print(json.dumps({
145+
"phase": "executed",
146+
"route_status": payload.get("status"),
147+
"runtime_uses_direct_fusion_chain": payload.get("runtime_uses_direct_fusion_chain"),
148+
"segment_count": payload.get("segment_count"),
149+
"compile_elapsed_seconds": compile_elapsed,
150+
"run_elapsed_seconds": run_elapsed,
151+
"segments": seg_report,
152+
}, indent=2, default=str))
153+
return 0 if payload.get("status") == "ok" else 5
154+
155+
156+
if __name__ == "__main__":
157+
raise SystemExit(main())

0 commit comments

Comments
 (0)