Skip to content

Commit 62abb9b

Browse files
committed
fix(path-c): demote oversized CUDA backward shared scratch to global (fused-CUDA unblock)
The backward direct-chain kernel (row-phased Mamba3 bwd, chain_12_13) staged 4 reverse-scan state buffers (h_prev/h_next/dh/dh_prev, each num_heads*head_dim*state_dim = 458752 f32) in shared.dyn = 0x764080 (7.39 MB), overflowing the CUDA 99 KiB / Blackwell ~228 KiB opt-in cap. The existing _spill_large_shared_scratch_to_abi pass is bounded by the Metal portable 31-buffer limit (consumed by gradient outputs), so the oversized buffers survived in shared on the direct-chain backward path -> ptxas overflow. Add a post-spill pass _demote_residual_shared_scratch_to_global: on CUDA it rewrites residual oversized T.alloc_shared -> T.alloc_global (kernel-internal cudaMalloc device scratch; no ABI param, no 31-buffer-limit interaction), largest-first, until residual shared fits a 48 KiB budget (clamped to the queried MaxSharedMemoryPerBlockOptin). Runs AFTER the ABI-spill so the ABI-spill-dependent test stays green. gb10 (sm_121): chain_12_13 residual shared 7,749,760 -> 38,272 bytes (37 KiB); COMPILE OK, native_compile_ok=true, chain ready, 7 segments, zero ptxas overflow. Metal: _cuda_shared_memory_optin_cap_bytes() returns None on non-CUDA -> demotion is a no-op (byte-for-byte identical source verified). Paths B/D/E untouched. Also set MLX_CUDA_GRAPH_CACHE_SIZE=8192 default (before mlx import) for CUDA hosts: the Path C step compiles many distinct kernels and MLX's default cache (400) thrashes/aborts on CUDA. No-op on Metal; setdefault honors explicit env.
1 parent 1c3ccc1 commit 62abb9b

2 files changed

Lines changed: 113 additions & 1 deletion

File tree

cppmega_mlx/runtime/path_c_fusion_schedules.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2665,7 +2665,7 @@ def _descriptor_prim_func_source(
26652665
shape_env=shape_env,
26662666
indent=indent,
26672667
)
2668-
return _spill_large_shared_scratch_to_abi(
2668+
spilled_source, spilled_scratch = _spill_large_shared_scratch_to_abi(
26692669
body,
26702670
existing_parameter_count=len(param_lines),
26712671
internal_buffer_names=frozenset(active_internal_buffers),
@@ -2674,6 +2674,13 @@ def _descriptor_prim_func_source(
26742674
physical_abi_policy != DESCRIPTOR_PHYSICAL_ABI_POLICY_DIRECT
26752675
),
26762676
)
2677+
# CUDA-only, target/capacity-gated: any oversized T.alloc_shared scratch that
2678+
# could NOT be spilled to ABI device-scratch params above (the portable
2679+
# 31-buffer kernel ABI budget is exhausted on the backward direct-chain) is
2680+
# rewritten to a global device workspace so ptxas accepts the kernel. Off
2681+
# CUDA (e.g. Metal) this is a no-op and the source is unchanged.
2682+
spilled_source = _demote_residual_shared_scratch_to_global(spilled_source)
2683+
return spilled_source, spilled_scratch
26772684

26782685

26792686
def _stage_active_internal_buffers(
@@ -10106,6 +10113,104 @@ def _append_row_phased_m2rnn_bwd_body(
1010610113
return
1010710114

1010810115

10116+
# Budget below the queried CUDA opt-in shared-memory cap that a single generated
10117+
# kernel's residual ``T.alloc_shared`` scratch may occupy. Anything above this
10118+
# (after the existing ABI-scratch spill pass has run) is rewritten to a global
10119+
# (device) workspace so ptxas accepts the kernel. We target the conservative
10120+
# static per-block budget (48 KiB) so the residual shared footprint stays well
10121+
# inside the opt-in cap with headroom for compiler bookkeeping.
10122+
_CUDA_SHARED_SCRATCH_BUDGET_BYTES = 0xC000 # 49152 bytes (static per-block).
10123+
10124+
10125+
def _cuda_shared_memory_optin_cap_bytes() -> int | None:
10126+
"""Return the CUDA opt-in max shared-memory-per-block, or ``None`` off CUDA.
10127+
10128+
The demotion of residual oversized shared scratch to a global device
10129+
workspace is *only* applied when the active Path C lowering target is CUDA
10130+
(``_path_c_default_target() == "cuda"``). On Metal hosts this returns
10131+
``None`` and callers MUST treat that as "no capacity limit" so the generated
10132+
schedule is byte-for-byte identical to the pre-change behaviour (every
10133+
surviving ``T.alloc_shared`` buffer keeps its shared scope). Metal's
10134+
threadgroup model spills oversized scratch to device memory in its own
10135+
backend, so this CUDA-only demotion mirrors what Metal already does without
10136+
touching the Metal codegen path.
10137+
"""
10138+
10139+
if _path_c_default_target() != "cuda":
10140+
return None
10141+
try:
10142+
import ctypes
10143+
10144+
lib = ctypes.CDLL("libcudart.so")
10145+
value = ctypes.c_int(0)
10146+
device = ctypes.c_int(0)
10147+
# cudaDevAttrMaxSharedMemoryPerBlockOptin == 97.
10148+
rc = lib.cudaDeviceGetAttribute(ctypes.byref(value), 97, device)
10149+
if rc == 0 and value.value > 0:
10150+
return int(value.value)
10151+
except Exception:
10152+
pass
10153+
# CUDA target but the attribute query failed (no runtime / no device): fall
10154+
# back to the conservative sm_80+ opt-in floor so we still demote rather than
10155+
# emit a kernel that ptxas will reject for shared-memory overflow.
10156+
return 0x18C00 # 101376 bytes (99 KiB), matches sm_121a opt-in cap.
10157+
10158+
10159+
def _demote_residual_shared_scratch_to_global(source: str) -> str:
10160+
"""CUDA-only: rewrite residual oversized ``T.alloc_shared`` to ``T.alloc_global``.
10161+
10162+
Runs AFTER ``_spill_large_shared_scratch_to_abi`` so it only ever touches
10163+
``T.alloc_shared`` lines that survived the ABI-scratch spill (e.g. the full
10164+
reverse-scan Mamba3 backward state ``h_prev``/``h_next``/``dh``/``dh_prev``,
10165+
which cannot be spilled to ABI device-scratch params in the direct-chain path
10166+
because the portable 31-buffer kernel ABI budget is exhausted). For
10167+
``local_gb10_quarter`` those buffers sum to ~7.4 MB of ``shared.dyn`` per
10168+
block, which ptxas rejects (max ~99 KiB even with the opt-in attribute on
10169+
Blackwell sm_121a). ``T.alloc_global`` is a kernel-internal device workspace
10170+
(no ABI param, no 31-buffer-limit interaction) and is launched with a single
10171+
block, so it preserves the cross-lane visibility the shared buffers provided.
10172+
10173+
Off CUDA (``_cuda_shared_memory_optin_cap_bytes() is None``, e.g. Metal) this
10174+
returns ``source`` unchanged, so the Metal schedule is byte-for-byte
10175+
identical to the pre-change behaviour. The largest residual shared buffers
10176+
are demoted first until the residual shared total fits the CUDA budget.
10177+
"""
10178+
10179+
cap_bytes = _cuda_shared_memory_optin_cap_bytes()
10180+
if cap_bytes is None:
10181+
return source
10182+
budget_bytes = min(_CUDA_SHARED_SCRATCH_BUDGET_BYTES, cap_bytes)
10183+
lines = source.splitlines()
10184+
shared: list[tuple[int, str, int]] = [] # (line_index, name, byte_count)
10185+
shared_total = 0
10186+
for index, line in enumerate(lines):
10187+
match = _ALLOC_SHARED_LINE_RE.match(line)
10188+
if match is None:
10189+
continue
10190+
shape_value = ast.literal_eval(match.group("shape"))
10191+
shape = (
10192+
(int(shape_value),)
10193+
if isinstance(shape_value, int)
10194+
else tuple(int(dim) for dim in shape_value)
10195+
)
10196+
byte_count = _flattened_extent(shape) * _DTYPE_NBYTES[match.group("dtype")]
10197+
shared.append((index, match.group("name"), byte_count))
10198+
shared_total += byte_count
10199+
if shared_total <= budget_bytes:
10200+
return source
10201+
# Demote the largest residual shared buffers first until the rest fit.
10202+
for index, _name, byte_count in sorted(
10203+
shared, key=lambda item: item[2], reverse=True
10204+
):
10205+
if shared_total <= budget_bytes:
10206+
break
10207+
lines[index] = lines[index].replace(
10208+
"T.alloc_shared(", "T.alloc_global(", 1
10209+
)
10210+
shared_total -= byte_count
10211+
return "\n".join(lines) + ("\n" if source.endswith("\n") else "")
10212+
10213+
1010910214
def _append_row_phased_mamba3_bwd_body(
1011010215
body: list[str],
1011110216
*,

scripts/m04_train_step.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
if str(ROOT) not in sys.path:
3434
sys.path.insert(0, str(ROOT))
3535

36+
# MLX CUDA graph-cache sizing: the HybridTinyLM Path C step compiles many
37+
# distinct kernels; MLX's default cache (400) thrashes on CUDA hosts and aborts
38+
# at mx.eval ("Cache thrashing... set MLX_CUDA_GRAPH_CACHE_SIZE"). Set a roomy
39+
# default before MLX initializes. No-op on Metal (CUDA-only knob); setdefault so
40+
# an explicit user/env value always wins.
41+
os.environ.setdefault("MLX_CUDA_GRAPH_CACHE_SIZE", "8192")
42+
3643
import mlx.core as mx # noqa: E402
3744
import mlx.nn as nn # noqa: E402
3845
from mlx.utils import tree_flatten, tree_unflatten # noqa: E402

0 commit comments

Comments
 (0)