Skip to content

Commit b330bdb

Browse files
committed
fix(path-c): clear Metal mamba3_mimo_bwd pipeline crash — pool oversized reverse-scan shared scratch into one global workspace
ROOT CAUSE (re-bisected on-device at full local_gb10_quarter scale, hidden=3584, seq=4096, segment chain_12_13) — the prior "in-loop barriers" hypothesis is FALSIFIED: * Removing ALL T.sync_threads() from the mamba3 backward (monkeypatch, on device) -> STILL XPC_ERROR_CONNECTION_INTERRUPTED at newComputePipelineState. * Running the ENTIRE reverse-time loop single-lane (if lane==0, the exact m2rnn_bwd idiom) -> STILL XPC crash. * Decisive evidence: the generated MSL declares `threadgroup float buf_dyn_shmem[7731424]` = 30.9 MB of THREADGROUP memory (logical ~7.4 MB of T.alloc_shared: h_prev/h_next/dh/dh_prev are 1.75 MB EACH, plus the per-step in-proj/conv/B-C scratch). Apple GPUs cap threadgroup memory at 32 KiB, so the driver crashes MTLCompilerService when creating the pipeline-state. m2rnn_bwd compiles (65 KB) NOT because its replay loop is barrier-free but because its reverse state is T.alloc_LOCAL (per-lane, single-lane) — it declares ~0 KB threadgroup scratch. The existing _demote_residual_shared_scratch_to_global pass fixes exactly this on CUDA (demotes oversized T.alloc_shared -> T.alloc_global) but is GATED OFF on Metal (its comment wrongly assumed "Metal spills oversized scratch to device memory in its own backend"). It does not; it overflows threadgroup memory. THE FIX (durable, gradient-exact): _pool_oversized_shared_scratch_to_metal_workspace pools every oversized residual T.alloc_shared float32 buffer into ONE coalesced T.alloc_global workspace and rewrites each `name[idx]` -> `pool[offset + (idx)]` (reusing the existing _replace_one_dimensional_buffer_refs remapper). Per-buffer demotion is NOT viable on Metal — each global buffer is a kernel-buffer ARGUMENT and the backward kernel already binds ~28 of Metal's ~31-arg limit, so demoting even the four 1.75 MB state buffers blows the arg limit and the metal source fails to compile. Pooling adds exactly ONE argument. The kernel launches single-block, so a global workspace preserves the cross-lane visibility threadgroup memory provided. Only float32 is pooled; a non-float32 oversized buffer RAISES (no silent degrade). Scope: gated to Metal AND to the recurrent reverse-scan BACKWARD kernels only (matches the distinctive `_bwd_mamba3_h_next/_bwd_m2rnn_h_next = T.alloc_shared` state buffers), so it NEVER perturbs the forward fusion planner's buffer-count grouping (the forward newComputePipelineState split already handles forward). TRIGGER 28 KiB logical / DEMOTE-TARGET 8 KiB logical (TileLang pads the coalesced buf_dyn_shmem, so 8 KiB logical -> ~29.5 KiB physical, under the 32 KiB cap). VERIFIED ON-DEVICE (Apple M4 Max, Metal): * COMPILE: mamba3_mimo_bwd creates its Metal pipeline-state at FULL scale — repro_fullscale_directchain.py: ALL 11 segment shaders COMPILED ok; no XPC. Threadgroup buf_dyn_shmem 30.9 MB -> 29.5 KiB; one path_c_metal_shared_pool global arg added. _bwdgate_per_segment_probe.py --only-index 9: no longer XPC-crashes — now reaches EXECUTION and trips only the GPU watchdog (kIOGPUCommandBufferCallbackErrorTimeout), the documented time-chunking follow-up (NOT the compile blocker). * GRADIENT PARITY: _mamba3_bwd_parity.py before vs after = max-abs-diff 0.000e+00 (BITWISE IDENTICAL), no NaN, 15 non-zero buffers (== baseline). The pool only relocates scratch; arithmetic is untouched. * NO REGRESSION: 119/119 test_path_c_fusion_ir.py + 32/32 test_path_c_physical_abi.py/test_path_c_fusion_templates.py pass. m2rnn_bwd untouched (its state is T.alloc_local; gate does not match). Off Metal the pass is a strict no-op (CUDA path unchanged). FOLLOW-UP (not this commit): mamba3_mimo_bwd now COMPILES but the full-scale single launch trips the macOS GPU watchdog; the existing launcher_chunks time-chunking is the path to clear that (already wired; rd=launcher_chunks).
1 parent 8936ce4 commit b330bdb

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

cppmega_mlx/runtime/path_c_fusion_schedules.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2801,6 +2801,14 @@ def _descriptor_prim_func_source(
28012801
# rewritten to a global device workspace so ptxas accepts the kernel. Off
28022802
# CUDA (e.g. Metal) this is a no-op and the source is unchanged.
28032803
spilled_source = _demote_residual_shared_scratch_to_global(spilled_source)
2804+
# Metal-only: pool oversized residual T.alloc_shared scratch into ONE global
2805+
# workspace buffer. Metal caps threadgroup memory at 32 KiB; the reverse-scan
2806+
# backward declares multiple MB of shared.dyn, which overflows that limit and
2807+
# crashes newComputePipelineState. Per-buffer demotion is not viable (each
2808+
# global buffer is a kernel argument and the kernel already uses ~28 of ~31),
2809+
# so this pools every demoted float32 buffer into a single coalesced global
2810+
# workspace (one extra argument). Off Metal this is a no-op.
2811+
spilled_source = _pool_oversized_shared_scratch_to_metal_workspace(spilled_source)
28042812
return spilled_source, spilled_scratch
28052813

28062814

@@ -10357,6 +10365,31 @@ def _append_row_phased_m2rnn_bwd_body(
1035710365
# inside the opt-in cap with headroom for compiler bookkeeping.
1035810366
_CUDA_SHARED_SCRATCH_BUDGET_BYTES = 0xC000 # 49152 bytes (static per-block).
1035910367

10368+
# Metal threadgroup-memory budget for a single generated kernel's residual
10369+
# ``T.alloc_shared`` scratch. Apple GPUs cap threadgroup memory at 32 KiB
10370+
# (``maxTotalThreadgroupMemory`` / the metallib threadgroup allocation limit).
10371+
# A generated mamba3/m2rnn reverse-scan backward declares MULTIPLE MB of
10372+
# ``shared.dyn`` (the full reverse-time state h_prev/h_next/dh/dh_prev plus the
10373+
# per-step in-proj/conv/B-C scratch), which TileLang lowers to one giant
10374+
# ``threadgroup float buf_dyn_shmem[...]`` array. When that array exceeds the
10375+
# threadgroup limit the Metal driver crashes ``MTLCompilerService`` at
10376+
# ``newComputePipelineState`` (XPC_ERROR_CONNECTION_INTERRUPTED) — the same crash
10377+
# previously misattributed to in-loop barriers (verified on-device: removing the
10378+
# barriers, and even running the whole reverse scan single-lane, does NOT clear
10379+
# the crash; demoting the oversized shared scratch off threadgroup memory DOES).
10380+
# TRIGGER: only pool when a kernel's LOGICAL ``alloc_shared`` total exceeds this.
10381+
# Metal's threadgroup-memory cap is 32 KiB; a kernel whose shared scratch already
10382+
# fits keeps every buffer in threadgroup memory (unchanged), so the greedy
10383+
# planner's buffer-count splitting is unaffected for the small per-op kernels.
10384+
_METAL_SHARED_SCRATCH_TRIGGER_BYTES = 0x7000 # 28672 bytes (28 KiB).
10385+
# DEMOTE-TARGET: once triggered, pool the largest buffers until the LOGICAL
10386+
# residual is under this. TileLang packs the residual ``alloc_shared`` into a
10387+
# single coalesced ``buf_dyn_shmem`` array with alignment padding, so the PHYSICAL
10388+
# threadgroup size runs a few× the logical residual; targeting 8 KiB logical keeps
10389+
# the physical ``buf_dyn_shmem`` comfortably under Metal's 32 KiB limit (measured
10390+
# ~29.5 KiB physical for the mamba3 backward).
10391+
_METAL_SHARED_SCRATCH_DEMOTE_TARGET_BYTES = 0x2000 # 8192 bytes (8 KiB).
10392+
1036010393

1036110394
def _cuda_shared_memory_optin_cap_bytes() -> int | None:
1036210395
"""Return the CUDA opt-in max shared-memory-per-block, or ``None`` off CUDA.
@@ -10447,6 +10480,130 @@ def _demote_residual_shared_scratch_to_global(source: str) -> str:
1044710480
return "\n".join(lines) + ("\n" if source.endswith("\n") else "")
1044810481

1044910482

10483+
def _pool_oversized_shared_scratch_to_metal_workspace(source: str) -> str:
10484+
"""Metal: pool oversized ``T.alloc_shared`` scratch into ONE global workspace.
10485+
10486+
On Metal the residual reverse-scan backward scratch (the full reverse-time
10487+
state h_prev/h_next/dh/dh_prev plus per-step in-proj/conv/B-C buffers) sums to
10488+
several MB of ``shared.dyn``, which TileLang lowers to a single giant
10489+
``threadgroup float buf_dyn_shmem[...]`` array that overflows Metal's 32 KiB
10490+
threadgroup-memory limit and crashes ``newComputePipelineState``
10491+
(XPC_ERROR_CONNECTION_INTERRUPTED). We CANNOT demote each buffer to its own
10492+
``T.alloc_global`` — every global buffer becomes a kernel-buffer argument and
10493+
the backward kernel already uses ~28 of Metal's ~31-buffer-argument limit, so
10494+
demoting even the four 1.75 MB state buffers blows the argument limit and the
10495+
Metal source fails to compile.
10496+
10497+
The durable fix pools EVERY demoted ``float32`` buffer into ONE coalesced
10498+
``T.alloc_global`` workspace (a SINGLE extra kernel buffer) and rewrites every
10499+
``name[idx]`` reference to ``pool[offset + (idx)]`` via the existing
10500+
1-D-buffer-ref remapper. This moves the oversized scratch off threadgroup
10501+
memory (clearing the crash) while adding exactly one buffer argument. The
10502+
cross-lane visibility the shared buffers provided is preserved: the kernel is
10503+
launched single-block, so a global workspace is visible to every lane exactly
10504+
like threadgroup memory was. Only ``float32`` buffers are pooled (the only
10505+
dtype the oversized reverse-scan scratch uses); a non-float32 oversized buffer
10506+
raises rather than silently degrading.
10507+
10508+
Off Metal this is a no-op. Behaviour-preserving: the demotion only changes
10509+
WHERE the scratch lives, not the arithmetic, so gradients are unchanged.
10510+
"""
10511+
10512+
if _path_c_default_target() != "metal":
10513+
return source
10514+
# Scope: only the recurrent reverse-scan BACKWARD kernels
10515+
# (mamba3_mimo_bwd / m2rnn_bwd) carry the multi-MB reverse-time state
10516+
# (h_prev/h_next/dh/dh_prev) that overflows Metal's threadgroup cap and is NOT
10517+
# covered by the forward newComputePipelineState segment-node split. Gate on
10518+
# those distinctive state-buffer names so this pass NEVER perturbs the forward
10519+
# fusion planner's buffer-count grouping (which the forward split already
10520+
# handles) — pooling there would only change WHERE forward scratch lives while
10521+
# spuriously shifting greedy fusion boundaries.
10522+
if not (
10523+
"_bwd_mamba3_h_next = T.alloc_shared(" in source
10524+
or "_bwd_mamba3_h_prev = T.alloc_shared(" in source
10525+
or "_bwd_m2rnn_h_next = T.alloc_shared(" in source
10526+
or "_bwd_m2rnn_h_prev = T.alloc_shared(" in source
10527+
):
10528+
return source
10529+
lines = source.splitlines()
10530+
shared: list[tuple[int, str, tuple[int, ...], str, int]] = []
10531+
shared_total = 0
10532+
for index, line in enumerate(lines):
10533+
match = _ALLOC_SHARED_LINE_RE.match(line)
10534+
if match is None:
10535+
continue
10536+
shape_value = ast.literal_eval(match.group("shape"))
10537+
shape = (
10538+
(int(shape_value),)
10539+
if isinstance(shape_value, int)
10540+
else tuple(int(dim) for dim in shape_value)
10541+
)
10542+
dtype = match.group("dtype")
10543+
byte_count = _flattened_extent(shape) * _DTYPE_NBYTES[dtype]
10544+
shared.append((index, match.group("name"), shape, dtype, byte_count))
10545+
shared_total += byte_count
10546+
# Only pool kernels whose shared scratch would overflow Metal's threadgroup
10547+
# cap; kernels that already fit are left untouched so the planner's
10548+
# buffer-count splitting is unchanged for them.
10549+
if shared_total <= _METAL_SHARED_SCRATCH_TRIGGER_BYTES:
10550+
return source
10551+
# Select the largest residual shared buffers to pool until the rest fit the
10552+
# (smaller) demote target, guaranteeing the physical buf_dyn_shmem fits 32 KiB.
10553+
demote: list[tuple[int, str, tuple[int, ...], str, int]] = []
10554+
remaining = shared_total
10555+
for entry in sorted(shared, key=lambda item: item[4], reverse=True):
10556+
if remaining <= _METAL_SHARED_SCRATCH_DEMOTE_TARGET_BYTES:
10557+
break
10558+
demote.append(entry)
10559+
remaining -= entry[4]
10560+
if not demote:
10561+
return source
10562+
for _index, name, _shape, dtype, _byte_count in demote:
10563+
if dtype != "float32":
10564+
raise ValueError(
10565+
"Metal oversized-shared pooling only supports float32 scratch; "
10566+
f"buffer {name!r} is {dtype!r} and exceeds the threadgroup budget. "
10567+
"Add a typed pool or reduce its extent — refusing to silently "
10568+
"leave it in overflowing threadgroup memory."
10569+
)
10570+
# Assign each demoted buffer a contiguous slice of one float32 pool workspace.
10571+
pool_name = _safe_identifier("path_c_metal_shared_pool")
10572+
offsets: dict[str, int] = {}
10573+
pool_extent = 0
10574+
demote_indices = {entry[0] for entry in demote}
10575+
for _index, name, shape, _dtype, _byte_count in demote:
10576+
offsets[name] = pool_extent
10577+
pool_extent += _flattened_extent(shape)
10578+
# Find the indentation of the first demoted alloc line so the pool allocation
10579+
# is emitted at the same scope.
10580+
first_index = min(demote_indices)
10581+
pool_indent = _ALLOC_SHARED_LINE_RE.match(lines[first_index]).group("indent")
10582+
out: list[str] = []
10583+
pool_emitted = False
10584+
for index, line in enumerate(lines):
10585+
if index in demote_indices:
10586+
if not pool_emitted:
10587+
out.append(
10588+
f'{pool_indent}{pool_name} = '
10589+
f'T.alloc_global(({pool_extent},), "float32")'
10590+
)
10591+
pool_emitted = True
10592+
# Drop the original alloc_shared line for the pooled buffer.
10593+
continue
10594+
remapped = line
10595+
for _di, name, _shape, _dtype, _byte_count in demote:
10596+
if f"{name}[" in remapped:
10597+
remapped = _replace_one_dimensional_buffer_refs(
10598+
remapped,
10599+
source_name=name,
10600+
target_name=pool_name,
10601+
target_offset=offsets[name],
10602+
)
10603+
out.append(remapped)
10604+
return "\n".join(out) + ("\n" if source.endswith("\n") else "")
10605+
10606+
1045010607
def _append_row_phased_mamba3_bwd_body(
1045110608
body: list[str],
1045210609
*,

0 commit comments

Comments
 (0)