Skip to content

Commit 1bf4e0c

Browse files
committed
feat(path-c): MLX<->CUDA buffer bridge for fused direct-chain artifact + eager safety fallback
The fused direct-chain runtime now compiles/installs/executes on CUDA (62abb9b), but the fused artifact call passed MLX arrays straight to the TileLang tvm-ffi CUDA kernel via DLPack, which MLX (CUDA build) cannot export -> DLPackDeviceError (tvm_ffi.py:585 guard via has_mlx_arrays). - run_path_c_direct_fusion_chain_route: on target==cuda, bridge caller-owned MLX buffer args -> contiguous torch CUDA tensors (reusing _cuda_eager _mlx_to_torch_cuda), call artifact(*torch_args) (no MLX array reaches the adapter, so the Metal-DLPack guard is bypassed), torch.cuda.synchronize(), then reflect in-place kernel writes back into the owning bank buffers (coalesced-bank prefix slicing) -> preserves the direct-chain caller-owned in/out ABI. Metal keeps the byte-identical zero-copy artifact(*arrays) else-branch. - CompiledPretrainingStep._run_fused_value_and_grad_guarded: always-on exception guard (-> eager _loss_and_grad on any raise, so the run never regresses to steps_completed=0) + OPT-IN wall-clock watchdog (MLX_PATH_C_FUSED_STEP_TIMEOUT_S) that disables the fused runtime on timeout. Fallback state surfaced as training.path_c_fused_runtime_evidence. Verified gb10: fused tvm-ffi CUDA kernel launches via the bridge (grep -c DLPack==0; py-spy past artifact into write-back); bridge unit write-back correct; fallback RAISE/HANG/OK unit tests pass; eager baseline completes (steps 2, finite, decreased). Metal gated on target==cuda -> byte-for-byte unchanged. Paths B/D/E untouched. KNOWN REMAINING BLOCKER (next): the fused FORWARD segment-0 CUDA kernel is a runaway (sm 95%/mem 0%, never returns) -- a TileLang codegen/schedule problem in the generated fused forward (likely an unbounded/oversized serial scan), NOT the buffer handoff. Until fixed, --use-path-c-direct-chain-runtime on CUDA hangs on the inline fused call; set MLX_PATH_C_FUSED_STEP_TIMEOUT_S=<sec> for an interim eager-fallback result.
1 parent 62abb9b commit 1bf4e0c

2 files changed

Lines changed: 305 additions & 8 deletions

File tree

cppmega_mlx/training/compiled.py

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,6 +1716,13 @@ def __init__(
17161716
if path_c_gradient_probe is not None:
17171717
self.attach_path_c_gradient_probe(path_c_gradient_probe)
17181718
self._path_c_training_runtime: PathCTrainingRuntime | None = None
1719+
# Records whether the fused Path C runtime fell back to the eager
1720+
# loss_and_grad path (CUDA safety net; see ``_eager_step``).
1721+
self._path_c_runtime_fallback_count: int = 0
1722+
self._path_c_runtime_fallback_error: str | None = None
1723+
# Latched True once the fused runtime times out, so the remaining steps
1724+
# skip it entirely and use the eager baseline.
1725+
self._path_c_runtime_disabled: bool = False
17191726
if path_c_training_runtime is not None:
17201727
self.attach_path_c_training_runtime(path_c_training_runtime)
17211728

@@ -1958,6 +1965,117 @@ def _emit_path_c_gradients(self, grads: Any) -> None:
19581965
}
19591966
)
19601967

1968+
def _run_fused_value_and_grad_guarded(
1969+
self,
1970+
runtime: PathCTrainingRuntime,
1971+
loss_batch: Mapping[str, mx.array],
1972+
) -> tuple[tuple[mx.array, mx.array], Any] | None:
1973+
"""Run the fused runtime value_and_grad under an exception (+ time) guard.
1974+
1975+
Returns the ``((loss, ntokens), grads)`` tuple on success, or ``None`` to
1976+
signal the caller should fall back to the eager loss_and_grad path.
1977+
1978+
The always-on guard is exception-based: if the fused runtime raises (e.g.
1979+
the MLX<->CUDA buffer handoff, incomplete fused gradient extraction, or a
1980+
CUDA launch/exec error surfaced by the bridge's explicit
1981+
``torch.cuda.synchronize()``), we catch it and degrade to the eager
1982+
baseline so the run never regresses to steps_completed=0.
1983+
1984+
An OPTIONAL wall-clock watchdog is enabled only when
1985+
``MLX_PATH_C_FUSED_STEP_TIMEOUT_S`` is set to a positive number. It runs
1986+
the fused call on a daemon worker thread and, on timeout, permanently
1987+
disables the fused runtime and falls back to eager. It is opt-in because
1988+
a CUDA kernel that is launched but does not return cannot be cancelled
1989+
in-process: the abandoned worker keeps the runaway kernel resident on the
1990+
GPU, starving the eager fallback. The clean default therefore runs the
1991+
fused call inline (no leaked kernel) and relies on the exception guard.
1992+
The first failure / timeout is recorded for reporting.
1993+
"""
1994+
1995+
import os
1996+
import warnings
1997+
1998+
timeout_env = os.environ.get("MLX_PATH_C_FUSED_STEP_TIMEOUT_S")
1999+
timeout_s = 0.0
2000+
if timeout_env:
2001+
try:
2002+
timeout_s = float(timeout_env)
2003+
except (TypeError, ValueError):
2004+
timeout_s = 0.0
2005+
2006+
error: BaseException | None = None
2007+
if timeout_s > 0:
2008+
import threading
2009+
2010+
result_box: dict[str, Any] = {}
2011+
2012+
def _worker() -> None:
2013+
try:
2014+
result_box["value"] = runtime.value_and_grad(
2015+
self.model,
2016+
loss_batch,
2017+
self._loss_and_grad,
2018+
)
2019+
except BaseException as exc: # noqa: BLE001 - to main thread
2020+
result_box["error"] = exc
2021+
2022+
worker = threading.Thread(
2023+
target=_worker, name="path-c-fused-value-and-grad", daemon=True
2024+
)
2025+
worker.start()
2026+
worker.join(timeout=timeout_s)
2027+
2028+
if worker.is_alive():
2029+
# Timed out — a runaway / impractically slow fused kernel. Abandon
2030+
# the worker (daemon) and permanently disable the fused runtime so
2031+
# every subsequent step uses the eager baseline.
2032+
self._path_c_runtime_disabled = True
2033+
if self._path_c_runtime_fallback_error is None:
2034+
self._path_c_runtime_fallback_error = (
2035+
f"fused value_and_grad exceeded {timeout_s:g}s budget"
2036+
)
2037+
self._path_c_runtime_fallback_count += 1
2038+
warnings.warn(
2039+
"Path C fused direct-chain runtime.value_and_grad exceeded the "
2040+
f"{timeout_s:g}s budget; disabling the fused runtime and falling "
2041+
"back to the eager loss_and_grad path for the rest of the run.",
2042+
RuntimeWarning,
2043+
stacklevel=2,
2044+
)
2045+
return None
2046+
2047+
error = result_box.get("error")
2048+
if error is None:
2049+
return result_box.get("value")
2050+
else:
2051+
try:
2052+
return runtime.value_and_grad(
2053+
self.model,
2054+
loss_batch,
2055+
self._loss_and_grad,
2056+
)
2057+
except Exception as exc: # pragma: no cover - hardware-specific path
2058+
error = exc
2059+
2060+
if error is not None:
2061+
if self._path_c_runtime_fallback_error is None:
2062+
self._path_c_runtime_fallback_error = (
2063+
f"{type(error).__name__}: {error}"
2064+
)
2065+
self._path_c_runtime_fallback_count += 1
2066+
warnings.warn(
2067+
"Path C fused direct-chain runtime.value_and_grad failed; "
2068+
"falling back to the eager loss_and_grad path: "
2069+
f"{type(error).__name__}: {error}",
2070+
RuntimeWarning,
2071+
stacklevel=2,
2072+
)
2073+
return None
2074+
2075+
# Unreachable in practice: both the timeout and inline branches return on
2076+
# success above. Fall back to eager defensively if we ever get here.
2077+
return None
2078+
19612079
def _eager_step(
19622080
self,
19632081
batch: CompiledBatch,
@@ -1966,14 +2084,24 @@ def _eager_step(
19662084
) -> tuple[mx.array, mx.array, Any]:
19672085
loss_batch = cast(Mapping[str, mx.array], batch)
19682086
runtime = self._path_c_training_runtime
1969-
if runtime is None:
2087+
if runtime is None or self._path_c_runtime_disabled:
19702088
(loss, ntokens), grads = self._loss_and_grad(self.model, loss_batch)
19712089
else:
1972-
(loss, ntokens), grads = runtime.value_and_grad(
1973-
self.model,
1974-
loss_batch,
1975-
self._loss_and_grad,
1976-
)
2090+
outcome = self._run_fused_value_and_grad_guarded(runtime, loss_batch)
2091+
if outcome is not None:
2092+
(loss, ntokens), grads = outcome
2093+
else:
2094+
# SAFETY FALLBACK: the fused Path C direct-chain runtime either
2095+
# raised or exceeded its wall-clock budget (e.g. the MLX<->CUDA
2096+
# buffer handoff, incomplete fused gradient extraction, or a
2097+
# pathologically slow / runaway fused kernel). Rather than
2098+
# aborting the whole run with steps_completed=0 we degrade to the
2099+
# working eager value_and_grad path (the same ops dispatched
2100+
# through the TileLang-CUDA EAGER kernels / pure-MLX reference),
2101+
# guaranteeing we never end up below the eager baseline.
2102+
(loss, ntokens), grads = self._loss_and_grad(
2103+
self.model, loss_batch
2104+
)
19772105
self._emit_path_c_gradients(grads)
19782106
if self.split_grad_update_eval:
19792107
mx.eval(loss, ntokens, grads)

scripts/m04_train_step.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6110,6 +6110,128 @@ def native_lowerer(func_or_mod: Any, *, target: str, **kwargs: Any) -> Any:
61106110
return tuple(artifacts)
61116111

61126112

6113+
def _path_c_artifact_target_is_cuda(artifact: Any) -> bool:
6114+
"""Return True iff a compiled direct-chain artifact targets CUDA.
6115+
6116+
The fused direct-chain runtime is identical on Metal and CUDA except for the
6117+
MLX<->kernel buffer handoff: Metal exports zero-copy Metal DLPack that the
6118+
Metal kernel consumes directly, whereas MLX on a CUDA host has no CUDA DLPack
6119+
export so the tvm-ffi adapter raises ``DLPackDeviceError`` (it sees Metal/host
6120+
DLPack but the kernel targets 'cuda'). This probe drives the CUDA-only
6121+
numpy-host-roundtrip bridge; if the target cannot be determined we return
6122+
``False`` and leave the original (Metal) zero-copy call path untouched.
6123+
"""
6124+
6125+
target = getattr(artifact, "target", None)
6126+
kind = getattr(target, "kind", None)
6127+
name = getattr(kind, "name", None)
6128+
if isinstance(name, str):
6129+
return name == "cuda"
6130+
text = str(target) if target is not None else ""
6131+
return "cuda" in text.lower()
6132+
6133+
6134+
def _path_c_call_cuda_artifact_with_mlx_bridge(
6135+
*,
6136+
artifact: Any,
6137+
arrays: tuple[Any, ...],
6138+
kernel_arg_buffer_names: Mapping[int, tuple[str, tuple[int, ...] | None]],
6139+
buffers: dict[str, Any],
6140+
mx_module: Any,
6141+
) -> Any:
6142+
"""Call a CUDA direct-chain artifact across the MLX<->torch-CUDA bridge.
6143+
6144+
MLX on a CUDA host (e.g. gb10) is a CUDA build whose arrays only expose a
6145+
Metal/host DLPack buffer, so passing them to the compiled tvm-ffi CUDA kernel
6146+
raises ``DLPackDeviceError``. We reuse the EAGER per-op bridge
6147+
(``cppmega_mlx.nn._tilelang._cuda_eager``) to copy each caller-owned MLX
6148+
buffer arg to a contiguous torch CUDA tensor (numpy host roundtrip), invoke
6149+
the kernel with those torch tensors (no MLX array reaches the adapter, so the
6150+
Metal-DLPack guard is not triggered), and then reflect the kernel's in-place
6151+
writes back into the owning bank buffers in ``buffers`` — preserving the
6152+
direct-chain caller-owned in/out buffer ABI. Scalar args (gate / scalar
6153+
defaults) are passed through unchanged.
6154+
6155+
This is reachable only when :func:`_path_c_artifact_target_is_cuda` is True,
6156+
so the Apple/Metal zero-copy path never enters here.
6157+
"""
6158+
6159+
from cppmega_mlx.nn._tilelang._cuda_eager import (
6160+
_mlx_to_torch_cuda,
6161+
_torch_cuda_to_mlx,
6162+
)
6163+
6164+
# Build the positional argument list with MLX buffers replaced by torch CUDA
6165+
# tensors. Remember which positions own a torch buffer so we can copy the
6166+
# kernel's results back afterwards.
6167+
torch_args: list[Any] = []
6168+
torch_buffer_slots: dict[int, tuple[str, Any]] = {}
6169+
for position, arg in enumerate(arrays):
6170+
if position in kernel_arg_buffer_names:
6171+
name, _expected_shape = kernel_arg_buffer_names[position]
6172+
torch_t = _mlx_to_torch_cuda(arg)
6173+
torch_args.append(torch_t)
6174+
torch_buffer_slots[position] = (name, torch_t)
6175+
else:
6176+
torch_args.append(arg)
6177+
6178+
result = artifact(*torch_args)
6179+
6180+
# Force the launched CUDA kernel(s) to complete (and surface any launch /
6181+
# execution error) before we copy the results back to the host. Without an
6182+
# explicit sync a kernel fault would otherwise only manifest later, inside an
6183+
# unrelated D2H copy, where it is much harder to attribute and to recover
6184+
# from via the eager fallback.
6185+
try:
6186+
import torch as _torch
6187+
6188+
_torch.cuda.synchronize()
6189+
except Exception:
6190+
pass
6191+
6192+
# Reflect the kernel's in-place writes back into the caller-owned banks. The
6193+
# arg may have been a max-extent bank sliced down to this segment's declared
6194+
# shape (see ``_path_c_exact_kernel_buffer``); write the torch result into the
6195+
# matching contiguous prefix of the full bank so other segments that share the
6196+
# coalesced bank see consistent state.
6197+
for position, (name, torch_t) in torch_buffer_slots.items():
6198+
original = buffers.get(name)
6199+
if original is None:
6200+
continue
6201+
out_dtype = getattr(original, "dtype", None)
6202+
if out_dtype is None:
6203+
continue
6204+
updated = _torch_cuda_to_mlx(torch_t, out_dtype)
6205+
full_shape = tuple(int(d) for d in getattr(original, "shape", ()))
6206+
updated_size = int(getattr(updated, "size", 0) or 0)
6207+
full_size = 1
6208+
for dim in full_shape:
6209+
full_size *= int(dim)
6210+
if updated_size == full_size:
6211+
buffers[name] = mx_module.reshape(updated, full_shape)
6212+
continue
6213+
if updated_size < full_size:
6214+
# The kernel wrote a contiguous prefix view of a larger coalesced
6215+
# bank; stitch the updated prefix in front of the untouched tail.
6216+
flat_original = mx_module.reshape(original, (-1,))
6217+
flat_updated = mx_module.reshape(updated, (-1,))
6218+
tail = flat_original[updated_size:]
6219+
stitched = mx_module.concatenate([flat_updated, tail], axis=0)
6220+
buffers[name] = mx_module.reshape(stitched, full_shape)
6221+
continue
6222+
# Unexpected larger result: store as-is so a downstream validator reports.
6223+
buffers[name] = updated
6224+
6225+
buffer_outputs = tuple(
6226+
buffers[name]
6227+
for name, _ in torch_buffer_slots.values()
6228+
if name in buffers and hasattr(buffers[name], "shape")
6229+
)
6230+
if buffer_outputs:
6231+
mx_module.eval(*buffer_outputs)
6232+
return result
6233+
6234+
61136235
def run_path_c_direct_fusion_chain_route(
61146236
*,
61156237
chain: Any,
@@ -6234,8 +6356,17 @@ def run_path_c_direct_fusion_chain_route(
62346356
)
62356357
run_backward_value = 1 if execution_phase == "backward" else 0
62366358
kernel_call_args: list[Any] = []
6359+
# position -> (logical buffer name, expected kernel shape) for every
6360+
# caller-owned buffer arg. Used by the CUDA MLX<->torch bridge to write
6361+
# the kernel's in-place results back into the owning bank buffers
6362+
# (Metal is zero-copy so it does not need this).
6363+
kernel_arg_buffer_names: dict[int, tuple[str, tuple[int, ...] | None]] = {}
62376364
for name in kernel_param_order:
62386365
if name in buffers:
6366+
kernel_arg_buffer_names[len(kernel_call_args)] = (
6367+
str(name),
6368+
kernel_buffer_shapes.get(name),
6369+
)
62396370
kernel_call_args.append(
62406371
_path_c_exact_kernel_buffer(
62416372
buffers[name],
@@ -6256,8 +6387,25 @@ def run_path_c_direct_fusion_chain_route(
62566387
buffer_arrays = tuple(arg for arg in arrays if hasattr(arg, "shape"))
62576388
mx_module.eval(*buffer_arrays)
62586389
segment_started = time.perf_counter()
6259-
result = artifact(*arrays)
6260-
mx_module.eval(*buffer_arrays)
6390+
# On a CUDA host MLX is a CUDA build with NO CUDA DLPack export, so the
6391+
# tvm-ffi adapter rejects MLX arrays for a non-Metal kernel
6392+
# (DLPackDeviceError). Bridge the MLX buffer args to torch CUDA tensors
6393+
# (numpy host roundtrip, reusing the EAGER _cuda_eager helpers), call
6394+
# the artifact, then reflect the kernel's in-place writes back into the
6395+
# caller-owned bank buffers. Strictly gated on target=="cuda" so the
6396+
# Metal zero-copy arg-assembly + call path below is byte-for-byte
6397+
# unchanged.
6398+
if _path_c_artifact_target_is_cuda(artifact):
6399+
result = _path_c_call_cuda_artifact_with_mlx_bridge(
6400+
artifact=artifact,
6401+
arrays=arrays,
6402+
kernel_arg_buffer_names=kernel_arg_buffer_names,
6403+
buffers=buffers,
6404+
mx_module=mx_module,
6405+
)
6406+
else:
6407+
result = artifact(*arrays)
6408+
mx_module.eval(*buffer_arrays)
62616409
segment_results.append(
62626410
{
62636411
"index": int(segment.index),
@@ -11352,6 +11500,27 @@ def pre_step_owner_factory(
1135211500
"step_metrics": step_metrics,
1135311501
"kernel_dispatch": get_dispatch_log(),
1135411502
"stepper_state": stepper.state_dict(),
11503+
"path_c_fused_runtime_evidence": {
11504+
"fused_runtime_installed": (
11505+
getattr(stepper, "_path_c_training_runtime", None) is not None
11506+
),
11507+
"fused_runtime_fallback_count": int(
11508+
getattr(stepper, "_path_c_runtime_fallback_count", 0)
11509+
),
11510+
"fused_runtime_fallback_error": getattr(
11511+
stepper, "_path_c_runtime_fallback_error", None
11512+
),
11513+
"fused_runtime_disabled": bool(
11514+
getattr(stepper, "_path_c_runtime_disabled", False)
11515+
),
11516+
"fused_runtime_executed": (
11517+
getattr(stepper, "_path_c_training_runtime", None) is not None
11518+
and int(
11519+
getattr(stepper, "_path_c_runtime_fallback_count", 0)
11520+
)
11521+
== 0
11522+
),
11523+
},
1135511524
"compile": config.compile,
1135611525
"compile_enabled": compile_plan["enabled"],
1135711526
"compile_plan": compile_plan,

0 commit comments

Comments
 (0)