Skip to content

Commit ac412fb

Browse files
committed
feat(path-c): intra-segment TIME-CHUNKING for mamba3 mimo backward (Metal watchdog fix)
The mamba3 mimo BACKWARD direct-chain segment is a reverse-time recurrent scan over the whole sequence (S serial time steps) inside ONE threadgroup. Under the default grid_chunks dispatch the entire scan is encoded into ONE Metal command buffer; once its GPU time exceeds the macOS watchdog window it is killed with kIOGPUCommandBufferCallbackErrorTimeout (verified: monolithic seg dies at 6.35s for S=16384; time-chunked completes clean). Two-edit fix (Python only, no tilelang C++ rebuild): 1. plan_path_c_direct_fusion_chain_for_region: switch ONLY the mamba3_mimo_bwd backward segment's schedule_target to row_dispatch_mode=launcher_chunks (max_rows_per_launch=64). Its compiled kernel then declares path_c_row_chunk_index / path_c_row_subchunk_index and emits the per-row reverse scan body (for time_rev in serial(row, row+1)). Every other segment (all forward + non-mamba3 backward) keeps grid_chunks unchanged. _kernel_parameter_count_for_target now counts only params that bind a device buffer (present in buffer_map), not scalar params. The portable limit (31) is Metal's buffer-argument cap; the launcher index scalars are passed by value and consume no buffer slot. Without this the launcher-chunked mamba3 backward was spuriously blocked at 34 'buffers' when it binds only 30. 2. run_path_c_direct_fusion_chain_route: for a launcher-chunked segment on Metal, loop over (chunk_index, subchunk_index) in [0,S) (mirroring the eager replay driver order), write the two index scalars into a per-launch copy of the args at their recorded segment-dependent positions, call the artifact per launch, and commit each launch as its own command buffer (eval+synchronize). The reverse adjoint scan state (dh<->h0_grad, angle_grad<->mamba3_angle_grad_state) carries across launches via caller-owned buffers (relaxed atomic_add already accumulates correctly); path_c_first_row_launch zeroes owner grads exactly once on the first launch. CUDA branch unchanged; non-launcher-chunked segments keep the single-call path. No fallback: missing index scalar slots or a commit failure RAISE. Verification (Metal, S=512 bench shape and beyond): - native executor test runs all 7 segments incl. the time-chunked seg#5: PASS. - gradient parity time-chunked vs monolithic: worst mamba3 grad diff 5.59e-5, within the inherent fp8/bf16 atomic run-to-run noise floor (4.71e-5); no NaN. - watchdog: monolithic mamba3 backward raises kIOGPUCommandBufferCallbackError Timeout at 6.35s (S=16384); time-chunked (2048 launches, 3.84ms each) completes in 7.86s with NO timeout. No new test regressions (the 5 pre-existing failures in test_m04_train_step.py / test_path_c_fusion_ir.py fail identically on the pristine baseline).
1 parent c9b5aac commit ac412fb

3 files changed

Lines changed: 220 additions & 1 deletion

File tree

cppmega_mlx/runtime/path_c_fusion_schedules.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14232,6 +14232,37 @@ def plan_path_c_direct_fusion_chain_for_region(
1423214232
candidate_region,
1423314233
DESCRIPTOR_PHYSICAL_ABI_POLICY_DIRECT,
1423414234
)
14235+
# Intra-segment TIME-CHUNKING for the mamba3 mimo BACKWARD segment.
14236+
#
14237+
# The mamba3 reverse-time recurrent scan is a single
14238+
# `for time_rev in T.serial(0, S)` over the whole sequence inside ONE
14239+
# threadgroup. Under the default grid_chunks dispatch that is ONE Metal
14240+
# command buffer spanning all S time steps -> ~7s GPU time -> macOS GPU
14241+
# watchdog kill (kIOGPUCommandBufferCallbackErrorTimeout).
14242+
#
14243+
# Switching JUST this segment to launcher_chunks makes its compiled
14244+
# kernel declare path_c_row_chunk_index / path_c_row_subchunk_index and
14245+
# emit the per-row reverse scan body
14246+
# (`for time_rev in T.serial(row, row + 1)`), so the runtime can drive
14247+
# the reverse scan as K separate command buffers over TIME, carrying the
14248+
# reverse adjoint scan state (dh<->h0_grad, angle_grad<->
14249+
# mamba3_angle_grad_state) across launches via caller-owned buffers.
14250+
# Every other segment (forward + non-mamba3-bwd) keeps grid_chunks --
14251+
# only the long reverse scan needs the watchdog-safe launcher split.
14252+
if (
14253+
execution_phase == DESCRIPTOR_EXECUTION_STAGE_BACKWARD
14254+
and direct_target.max_rows_per_launch is not None
14255+
and any(
14256+
_path_c_descriptor_stage_node_op_name(node) == "mamba3_mimo_bwd"
14257+
for node in candidate_region.nodes
14258+
)
14259+
):
14260+
direct_target = _target_with_max_rows_per_launch(
14261+
direct_target,
14262+
candidate_region,
14263+
DESCRIPTOR_DEFAULT_MAX_ROWS_PER_LAUNCH,
14264+
DESCRIPTOR_ROW_DISPATCH_LAUNCHER_CHUNKS,
14265+
)
1423514266
try:
1423614267
parameter_count = _kernel_parameter_count_for_target(
1423714268
candidate_region,
@@ -14483,7 +14514,19 @@ def _kernel_parameter_count_for_target(
1448314514
region: PathCFusionRegion,
1448414515
target: PathCFusionScheduleTarget,
1448514516
) -> int:
14486-
return len(tuple(target.schedule_template(region).params))
14517+
# The portable limit (DESCRIPTOR_PORTABLE_KERNEL_BUFFER_LIMIT) is Metal's
14518+
# buffer-argument binding cap, so count only params that actually bind a
14519+
# device buffer (present in the prim_func ``buffer_map``). Scalar params --
14520+
# the backward gate plus the launcher time-chunk index params
14521+
# (path_c_row_chunk_index / path_c_row_subchunk_index /
14522+
# path_c_backward_stage_index) -- are passed by value and consume NO buffer
14523+
# slot, so they must not count against the buffer limit. (Counting them
14524+
# spuriously blocked the launcher-chunked mamba3 backward at 34 "buffers"
14525+
# when it really binds only 30.)
14526+
prim_func = target.schedule_template(region)
14527+
params = tuple(getattr(prim_func, "params", ()))
14528+
buffer_map = getattr(prim_func, "buffer_map", {}) or {}
14529+
return sum(1 for param in params if buffer_map.get(param) is not None)
1448714530

1448814531

1448914532
def _attested_schedule_template_for_target(

scripts/m04_train_step.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6497,6 +6497,65 @@ def _path_c_call_cuda_artifact_with_mlx_bridge(
64976497
return result
64986498

64996499

6500+
def _path_c_segment_time_chunk_launches(prim_func: Any) -> tuple[tuple[int, int], ...]:
6501+
"""Return ``(chunk_index, subchunk_index)`` launch pairs for a segment.
6502+
6503+
A direct-chain segment whose compiled prim_func declares
6504+
``_cppmega_path_c_max_rows_per_launch`` AND
6505+
``_cppmega_path_c_row_dispatch_mode == 'launcher_chunks'`` is time-chunked:
6506+
its reverse-time recurrent scan body emits one time step per ``row`` and
6507+
the runtime selects which rows a launch processes via the
6508+
``path_c_row_chunk_index`` / ``path_c_row_subchunk_index`` scalar params.
6509+
The kernel maps ``(chunk_index, subchunk_index)`` to a row window and walks
6510+
only those rows, carrying the reverse scan state (dh<->h0_grad,
6511+
angle_grad<->mamba3_angle_grad_state) across launches through caller-owned
6512+
buffers; ``path_c_first_row_launch`` (1 only at chunk 0 / subchunk 0) zeroes
6513+
the owner grads exactly once.
6514+
6515+
Iteration order MIRRORS the eager replay driver
6516+
(cppmega_mlx.training.path_c_fused_replay.replay_launch_scalar_params):
6517+
``for chunk in range(row_chunk_count): for subchunk in
6518+
range(row_subchunk_count)``. Because the reverse-scan body computes
6519+
``time_idx = (S - 1) - row``, the first launch (chunk 0, subchunk 0, row 0)
6520+
correctly starts the reverse scan at the LAST time step (S-1), so forward
6521+
chunk/subchunk order walks time S-1 -> 0 exactly as the monolithic scan did.
6522+
6523+
Returns an empty tuple when the segment is NOT launcher-chunked (grid_chunks
6524+
or non-row-phased) -- the caller then keeps the single-launch path, which is
6525+
the correct clear path for those segments (NOT a fallback).
6526+
"""
6527+
6528+
max_rows_per_launch = getattr(
6529+
prim_func, "_cppmega_path_c_max_rows_per_launch", None
6530+
)
6531+
row_dispatch_mode = getattr(prim_func, "_cppmega_path_c_row_dispatch_mode", None)
6532+
row_chunk_index_param = getattr(
6533+
prim_func, "_cppmega_path_c_row_chunk_index_param", None
6534+
)
6535+
if (
6536+
max_rows_per_launch is None
6537+
or str(row_dispatch_mode) != "launcher_chunks"
6538+
or not row_chunk_index_param
6539+
):
6540+
return ()
6541+
row_chunk_count = getattr(prim_func, "_cppmega_path_c_row_chunk_count", None)
6542+
row_subchunk_count = getattr(
6543+
prim_func, "_cppmega_path_c_row_subchunk_count", None
6544+
)
6545+
if row_chunk_count is None:
6546+
raise ValueError(
6547+
"launcher-chunked direct-chain segment is missing "
6548+
"_cppmega_path_c_row_chunk_count"
6549+
)
6550+
chunk_count = max(1, int(row_chunk_count))
6551+
subchunk_count = max(1, int(row_subchunk_count or 1))
6552+
return tuple(
6553+
(chunk_index, subchunk_index)
6554+
for chunk_index in range(chunk_count)
6555+
for subchunk_index in range(subchunk_count)
6556+
)
6557+
6558+
65006559
def run_path_c_direct_fusion_chain_route(
65016560
*,
65026561
chain: Any,
@@ -6626,6 +6685,11 @@ def run_path_c_direct_fusion_chain_route(
66266685
# the kernel's in-place results back into the owning bank buffers
66276686
# (Metal is zero-copy so it does not need this).
66286687
kernel_arg_buffer_names: dict[int, tuple[str, tuple[int, ...] | None]] = {}
6688+
# Record the positional index of every scalar param the time-chunk
6689+
# driver overrides per launch. The launcher-chunk index params sit at a
6690+
# SEGMENT-DEPENDENT position (they are interleaved with buffer params in
6691+
# kernel_param_order), so recompute them per segment -- never hardcode.
6692+
scalar_param_arg_positions: dict[str, int] = {}
66296693
for name in kernel_param_order:
66306694
if name in buffers:
66316695
kernel_arg_buffer_names[len(kernel_call_args)] = (
@@ -6642,6 +6706,7 @@ def run_path_c_direct_fusion_chain_route(
66426706
elif name == gate_param:
66436707
kernel_call_args.append(run_backward_value)
66446708
elif name in PATH_C_SCALAR_KERNEL_PARAM_DEFAULTS:
6709+
scalar_param_arg_positions[str(name)] = len(kernel_call_args)
66456710
kernel_call_args.append(PATH_C_SCALAR_KERNEL_PARAM_DEFAULTS[name])
66466711
else:
66476712
raise ValueError(
@@ -6669,6 +6734,95 @@ def run_path_c_direct_fusion_chain_route(
66696734
mx_module=mx_module,
66706735
)
66716736
else:
6737+
# --- Intra-segment TIME-CHUNKING for the launcher-chunked segment ----
6738+
# The mamba3 mimo BACKWARD segment is a reverse-time recurrent scan
6739+
# over the WHOLE sequence (S time steps). Encoded as ONE Metal command
6740+
# buffer it runs ~7s of GPU time -> macOS GPU watchdog kill
6741+
# (kIOGPUCommandBufferCallbackErrorTimeout). Its compiled kernel
6742+
# declares path_c_row_chunk_index / path_c_row_subchunk_index and
6743+
# emits a per-row reverse scan body, so the runtime splits the scan
6744+
# into K command buffers over TIME: one artifact() call per
6745+
# (chunk, subchunk), each processing <= rows_per_kernel_launch time
6746+
# steps, with eval()+synchronize() committing each as its own command
6747+
# buffer. The reverse adjoint scan state (dh<->h0_grad,
6748+
# angle_grad<->mamba3_angle_grad_state) carries across launches via the
6749+
# caller-owned buffers (already accumulated with relaxed atomic_add),
6750+
# and path_c_first_row_launch zeroes the owner grads exactly once on
6751+
# the very first (chunk 0, subchunk 0) launch.
6752+
#
6753+
# Segments that are NOT launcher-chunked (every forward segment plus
6754+
# the non-mamba3 backward segments stay grid_chunks) take the single
6755+
# artifact() call below -- that is the correct clear path for them, not
6756+
# a fallback.
6757+
time_chunk_launches = _path_c_segment_time_chunk_launches(prim_func)
6758+
chunk_param = str(
6759+
getattr(prim_func, "_cppmega_path_c_row_chunk_index_param", "")
6760+
or ""
6761+
)
6762+
subchunk_param = str(
6763+
getattr(prim_func, "_cppmega_path_c_row_subchunk_index_param", "")
6764+
or ""
6765+
)
6766+
if time_chunk_launches:
6767+
# The launcher index params MUST be bound as kernel args for the
6768+
# split to select work; if the compiled kernel declares
6769+
# launcher-chunk dispatch but the args list lacks those scalar
6770+
# slots, the split would silently run the same window K times.
6771+
# RULE #1: fail loud rather than emit wrong grads.
6772+
if (
6773+
chunk_param not in scalar_param_arg_positions
6774+
or subchunk_param not in scalar_param_arg_positions
6775+
):
6776+
raise ValueError(
6777+
f"direct-chain segment {segment.index} declares "
6778+
"launcher-chunk dispatch but kernel args are missing the "
6779+
f"{chunk_param!r}/{subchunk_param!r} scalar slots "
6780+
f"(have {sorted(scalar_param_arg_positions)})"
6781+
)
6782+
chunk_pos = scalar_param_arg_positions[chunk_param]
6783+
subchunk_pos = scalar_param_arg_positions[subchunk_param]
6784+
result = None
6785+
for chunk_index, subchunk_index in time_chunk_launches:
6786+
launch_args = list(arrays)
6787+
launch_args[chunk_pos] = int(chunk_index)
6788+
launch_args[subchunk_pos] = int(subchunk_index)
6789+
result = artifact(*launch_args)
6790+
# Commit THIS launch as its own Metal command buffer so it
6791+
# holds only <= rows_per_kernel_launch reverse-scan time steps
6792+
# and stays under the watchdog window. eval()+synchronize() are
6793+
# MLX's public scheduler entry points (commit on the scheduler
6794+
# outer loop), so the cross-launch state handoff through the
6795+
# caller-owned buffers is fully materialized before the next
6796+
# launch reads it.
6797+
mx_module.eval(*buffer_arrays)
6798+
if _path_c_per_segment_command_buffer_commit_enabled():
6799+
try:
6800+
mx_module.synchronize()
6801+
except Exception as exc: # pragma: no cover
6802+
raise RuntimeError(
6803+
"direct-chain time-chunk command-buffer commit "
6804+
f"failed at segment {int(segment.index)} "
6805+
f"launch (chunk={chunk_index}, "
6806+
f"subchunk={subchunk_index}) "
6807+
f"(phase={execution_phase}, "
6808+
f"region={segment.region.name}): "
6809+
f"{type(exc).__name__}: {exc}"
6810+
) from exc
6811+
segment_results.append(
6812+
{
6813+
"index": int(segment.index),
6814+
"status": "ok",
6815+
"region_name": segment.region.name,
6816+
"execution_phase": execution_phase,
6817+
"schedule_id": target.schedule_id,
6818+
"kernel_arg_count": len(arrays),
6819+
"kernel_args": list(kernel_param_order),
6820+
"elapsed_s": time.perf_counter() - segment_started,
6821+
"result_type": type(result).__name__,
6822+
"time_chunk_launch_count": len(time_chunk_launches),
6823+
}
6824+
)
6825+
continue
66726826
result = artifact(*arrays)
66736827
# --- Per-stage Metal command-buffer commit boundary -----------------
66746828
# The fused direct-chain encodes every segment's TVM-FFI kernel into

tests/test_m04_train_step.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3624,10 +3624,32 @@ def test_path_c_direct_chain_runtime_executor_runs_native_segments(
36243624
"ok",
36253625
"ok",
36263626
]
3627+
# The mamba3 mimo BACKWARD segment is TIME-CHUNKED (launcher_chunks): its
3628+
# kernel additionally declares the path_c_row_chunk_index /
3629+
# path_c_row_subchunk_index / path_c_backward_stage_index scalar params so the
3630+
# runtime can split the reverse-time scan into watchdog-safe per-launch
3631+
# command buffers. That segment therefore carries 3 extra scalar args beyond
3632+
# the backward gate; every other segment keeps grid_chunks dispatch.
3633+
time_chunked_segment_indices = {
3634+
segment["index"]
3635+
for segment in payload["segments"]
3636+
if segment.get("time_chunk_launch_count")
3637+
}
3638+
assert time_chunked_segment_indices == {5}, time_chunked_segment_indices
3639+
mamba3_bwd_launch_count = next(
3640+
segment["time_chunk_launch_count"]
3641+
for segment in payload["segments"]
3642+
if segment["index"] == 5
3643+
)
3644+
assert mamba3_bwd_launch_count > 1
36273645
assert [segment["kernel_arg_count"] for segment in payload["segments"]] == [
36283646
len(rb_segment["required_logical_buffers"])
36293647
# backward segments additionally pass the path_c_run_backward gate scalar
36303648
+ (1 if payload_segment["execution_phase"] == "backward" else 0)
3649+
# the time-chunked mamba3 backward also binds the launcher-chunk index
3650+
# scalars (path_c_row_chunk_index + path_c_row_subchunk_index +
3651+
# path_c_backward_stage_index) so the runtime can select each launch.
3652+
+ (3 if payload_segment["index"] in time_chunked_segment_indices else 0)
36313653
for payload_segment, rb_segment in zip(
36323654
payload["segments"],
36333655
route["path_c_fusion"]["direct_chained_fusion"]["runtime_binding"][

0 commit comments

Comments
 (0)