Skip to content

Commit 88b4f98

Browse files
committed
feat(tilelang-cuda): zero-copy torch->MLX writeback for fused path_c (no host bounce)
The path_c fused sparse-MLA EAGER kernels feed inputs to torch zero-copy via DLPack (MLX-CUDA kDLCUDA export), but the kernel OUTPUT (torch CUDA tensor) was written back into MLX with a host bounce: `_cuda_eager._torch_cuda_to_mlx` did `t.detach().cpu().numpy()` because MLX could not IMPORT a CUDA DLPack capsule. That .cpu() PCIe roundtrip erased the fused kernel's latency win (kernel-bound -> overhead-bound), even though the result was numerically correct and memory- efficient. MLX now imports CUDA DLPack natively (DatasunriseOU/mlx upstream-integration: cuda_dlpack_to_mlx + cu::import_external_buffer), so this closes the output-side gap symmetrically: - _cuda_zerocopy.py: new `torch_cuda_tensor_to_mlx(t, out_dtype)` imports a torch CUDA tensor into an mx.array via DLPack with NO host roundtrip (torch __dlpack__ consumed by the native MLX CUDA import; a single device-side copy lands it in an MLX-owned buffer). Plus `mlx_cuda_import_available()` capability probe. RULE #1: raises (never silently host-bounces) if the tensor is not CUDA or the MLX build cannot import the capsule. - _cuda_eager.py: `_torch_cuda_to_mlx` now uses the zero-copy writeback when `CPPMEGA_TILELANG_CUDA_ZEROCOPY=1` (same gate as the input bridge); raises if the build can't import. With the env unset it uses the explicit eager numpy- host copy (a deliberately-selected mode, not a silent degrade). Module docstring updated. - _sparse_mla_v32_fused.py: docstring updated — the fused fwd/bwd now stays GPU-resident on both the input and output boundary, keeping the kernel latency win. Verified on gb10 / sm_121 (GB10): torch CUDA -> mx.array round-trips bit- identical (f32/f16/bf16/int32), the fused fwd runs correct through the zero-copy writeback.
1 parent 417feb3 commit 88b4f98

3 files changed

Lines changed: 144 additions & 12 deletions

File tree

cppmega_mlx/nn/_tilelang/_cuda_eager.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
VJP (documented TODO to port the Metal SIMD lane-grad bwd).
2424
* **m2rnn** — not yet ported; see :func:`m2rnn_supported_cuda_eager` (TODO).
2525
26-
MLX on this host has no CUDA-array DLPack export
27-
(``a.__dlpack__`` raises "CUDA DLPack export is not supported"), so the
28-
MLX<->kernel boundary uses a numpy host roundtrip into torch CUDA tensors.
29-
That is correct (if not zero-copy) for an EAGER fallback whose job is to run a
30-
real TileLang-CUDA kernel rather than raise. The Metal path is untouched: every
31-
public entry below is only invoked from a ``cuda_eager_available()`` branch the
32-
callers add *after* the existing ``can_run_metal()`` checks.
26+
The MLX<->kernel boundary is zero-copy on this fork. Inputs are exported to torch
27+
zero-copy via DLPack (MLX-CUDA kDLCUDA export, commit 6da6a0e4), and kernel
28+
outputs are imported back into MLX zero-copy via DLPack (MLX-CUDA kDLCUDA
29+
*import*: ``cuda_dlpack_to_mlx``) when ``CPPMEGA_TILELANG_CUDA_ZEROCOPY=1`` —
30+
no ``.cpu()`` host roundtrip, so the fused kernel keeps its latency win. With the
31+
env unset the writeback falls back to the explicit eager numpy-host copy (a
32+
deliberately-selected mode, not a silent degrade); see ``_torch_cuda_to_mlx``.
33+
The Metal path is untouched: every public entry below is only invoked from a
34+
``cuda_eager_available()`` branch the callers add *after* the existing
35+
``can_run_metal()`` checks.
3336
"""
3437

3538
# pyright: reportMissingImports=false, reportInvalidTypeForm=false
@@ -141,12 +144,40 @@ def _mlx_to_torch_cuda(a: mx.array) -> Any:
141144
return t.contiguous()
142145

143146

147+
def _zerocopy_writeback_enabled() -> bool:
148+
"""Whether the kernel-output writeback should use the zero-copy CUDA import.
149+
150+
Same env gate as the input bridge (``CPPMEGA_TILELANG_CUDA_ZEROCOPY``): when
151+
set, the torch-CUDA kernel result is imported back into MLX via DLPack with
152+
no ``.cpu()`` host roundtrip (DatasunriseOU native MLX CUDA import). When
153+
unset (default) the explicit eager numpy-host copy below is used. These are
154+
two deliberately-selected modes, NOT a silent fallback.
155+
"""
156+
157+
from cppmega_mlx.nn._tilelang._cuda_zerocopy import zerocopy_enabled
158+
159+
return zerocopy_enabled()
160+
161+
144162
def _torch_cuda_to_mlx(t: Any, out_dtype: Any) -> mx.array:
145-
"""Copy a CUDA torch tensor back into an ``mx.array`` of ``out_dtype``."""
163+
"""Move a CUDA torch tensor back into an ``mx.array`` of ``out_dtype``.
164+
165+
With ``CPPMEGA_TILELANG_CUDA_ZEROCOPY=1`` this stays GPU-resident: the result
166+
crosses torch->MLX via DLPack (no ``.cpu()`` / PCIe bounce), which is what
167+
preserves the fused kernel's latency win. RULE #1: when the zero-copy mode is
168+
requested it RAISES if the MLX build cannot import the CUDA capsule, instead
169+
of silently host-bouncing. Default (env unset) uses the eager numpy-host copy.
170+
"""
146171

147-
import numpy as np # noqa: F401
148172
import torch
149173

174+
if _zerocopy_writeback_enabled():
175+
from cppmega_mlx.nn._tilelang._cuda_zerocopy import torch_cuda_tensor_to_mlx
176+
177+
return torch_cuda_tensor_to_mlx(t, out_dtype)
178+
179+
import numpy as np # noqa: F401
180+
150181
cpu = t.detach().to(device="cpu")
151182
if cpu.dtype == torch.bfloat16:
152183
cpu = cpu.to(torch.float32)

cppmega_mlx/nn/_tilelang/_cuda_zerocopy.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,99 @@ def mlx_cuda_array_to_torch_tensor(arr: "mx.array") -> Any:
374374
)
375375
del torch
376376
return t
377+
378+
379+
# ---------------------------------------------------------------------------
380+
# OUTPUT side: torch CUDA result -> mx.array, zero-copy, no host bounce
381+
# ---------------------------------------------------------------------------
382+
#
383+
# Symmetric counterpart to ``mlx_cuda_array_to_torch_tensor`` (the INPUT bridge).
384+
# A TileLang/torch-CUDA kernel writes its result into a torch CUDA tensor; this
385+
# imports it straight back into an ``mx.array`` via DLPack with NO ``.cpu()``
386+
# host roundtrip. It relies on the native MLX CUDA *import* (DatasunriseOU fork:
387+
# convert.cpp ``cuda_dlpack_to_mlx`` + cuda backend ``import_external_buffer``),
388+
# which wraps the foreign CUDA buffer zero-copy and materializes it into an
389+
# MLX-owned GPU allocation with a single device-side copy (no PCIe bounce).
390+
#
391+
# RULE #1: if the running MLX build lacks the CUDA import (older binary that
392+
# still raises "CUDA DLPack import is not supported"), we RAISE with where+what
393+
# instead of silently host-bouncing. The caller (``_cuda_eager``) chooses this
394+
# zero-copy writeback only when the build supports it.
395+
396+
397+
def mlx_cuda_import_available() -> bool:
398+
"""Return True iff the running MLX build can import a CUDA DLPack capsule.
399+
400+
Probes the native import by round-tripping a tiny torch CUDA tensor through
401+
``mx.array(t)`` (DLPack). Returns False (not raise) for the *capability*
402+
probe; actual writebacks raise on failure.
403+
"""
404+
405+
try:
406+
import torch
407+
except Exception:
408+
return False
409+
if not torch.cuda.is_available():
410+
return False
411+
try:
412+
t = torch.ones(4, dtype=torch.float32, device="cuda")
413+
torch.cuda.synchronize()
414+
a = mx.array(t)
415+
mx.eval(a)
416+
return bool(abs(float(a.sum().item()) - 4.0) < 1e-4)
417+
except Exception:
418+
return False
419+
420+
421+
def torch_cuda_tensor_to_mlx(t, out_dtype=None):
422+
"""Import a torch CUDA tensor into an ``mx.array`` zero-copy (no host bounce).
423+
424+
Replaces the ``t.detach().cpu().numpy()`` writeback in
425+
``_cuda_eager._torch_cuda_to_mlx``: the kernel result stays GPU-resident and
426+
crosses the torch->MLX boundary via DLPack (torch ``__dlpack__`` consumed by
427+
the native MLX CUDA import). A single device-side copy lands it in an
428+
MLX-owned buffer; there is no ``.cpu()`` / PCIe roundtrip.
429+
430+
RAISES (RULE #1) if the tensor is not a CUDA tensor or the MLX build cannot
431+
import the CUDA DLPack capsule -- never silently degrades to a host copy.
432+
"""
433+
434+
import torch
435+
436+
if not isinstance(t, torch.Tensor):
437+
raise TypeError(
438+
f"_cuda_zerocopy.torch_cuda_tensor_to_mlx: expected a torch.Tensor, "
439+
f"got {type(t).__name__}."
440+
)
441+
if not t.is_cuda:
442+
raise RuntimeError(
443+
f"_cuda_zerocopy.torch_cuda_tensor_to_mlx: tensor is on {t.device}, "
444+
f"not CUDA; the zero-copy MLX import requires a CUDA device tensor."
445+
)
446+
447+
# DLPack producer-readiness contract: flush torch's CUDA stream so the
448+
# buffer is materialized before MLX reads it. MLX import does a full device
449+
# sync of its own before the device-side copy, but we sync the producer here
450+
# to honor the handoff ordering explicitly.
451+
src = t.detach().contiguous()
452+
torch.cuda.synchronize()
453+
454+
try:
455+
# mx.array(torch_tensor) routes through MLX create_array ->
456+
# nd_array_to_mlx -> (DatasunriseOU) cuda_dlpack_to_mlx, consuming the
457+
# tensor's __dlpack__ kDLCUDA capsule with no host roundtrip.
458+
arr = mx.array(src)
459+
except Exception as exc: # noqa: BLE001 - surface where + what failed
460+
raise RuntimeError(
461+
f"_cuda_zerocopy.torch_cuda_tensor_to_mlx: mx.array() rejected the "
462+
f"torch CUDA DLPack capsule (dtype={src.dtype}, "
463+
f"shape={tuple(int(d) for d in src.shape)}). This MLX build likely "
464+
f"lacks the native CUDA DLPack import (cuda_dlpack_to_mlx); rebuild "
465+
f"MLX-CUDA with the import patch. Underlying error: "
466+
f"{type(exc).__name__}: {exc}"
467+
) from exc
468+
469+
if out_dtype is not None and arr.dtype != out_dtype:
470+
arr = arr.astype(out_dtype)
471+
del torch
472+
return arr

cppmega_mlx/nn/_tilelang/_sparse_mla_v32_fused.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,14 @@
4141
sm_12x and the original Hopper kernel on sm_90/sm_100. MLX-CUDA q/kv/indices are
4242
fed to the torch-backend kernel interfaces zero-copy via DLPack when
4343
``CPPMEGA_TILELANG_CUDA_ZEROCOPY=1`` (kDLCUDA ``DLManagedTensor`` over
44-
``mx.array.data_ptr()``, no host roundtrip); outputs come back from torch (MLX
45-
cannot import a CUDA DLPack, so the torch->MLX writeback is a host bounce — an
46-
MLX limitation, surfaced honestly, not a silent degrade of the input path).
44+
``mx.array.data_ptr()``, no host roundtrip). The kernel outputs are imported
45+
back from torch into MLX zero-copy too (DatasunriseOU MLX-CUDA DLPack *import*:
46+
``cuda_dlpack_to_mlx`` wraps the foreign CUDA buffer and materializes it into an
47+
MLX-owned GPU allocation with a single device-side copy — no ``.cpu()`` host
48+
bounce), so the whole fused fwd/bwd stays GPU-resident and keeps the kernel's
49+
latency win. With ``CPPMEGA_TILELANG_CUDA_ZEROCOPY`` unset the writeback uses the
50+
explicit eager numpy-host copy (a deliberately-selected mode, not a silent
51+
degrade).
4752
"""
4853

4954
from __future__ import annotations

0 commit comments

Comments
 (0)