Skip to content

Commit 35bf055

Browse files
committed
feat(path-c): zero-copy MLX-CUDA -> TileLang(target=cuda) DLPack escape hatch (B)
Interim Python escape hatch from docs/DLPACK-CUDA-FIXES.md plan (B): build a real DLManagedTensor from mx.array.data_ptr() (MLX #3342) with kDLCUDA device + correct shape/strides/dtype and hand the capsule to tvm.runtime.from_dlpack so a TileLang target='cuda' kernel consumes the MLX-CUDA buffer with NO host roundtrip. - new cppmega_mlx/nn/_tilelang/_cuda_zerocopy.py: * mlx_cuda_array_to_dlpack_capsule / mlx_cuda_array_to_tvm_tensor. * DLManagedTensor with C-callable deleter + keepalive registry so the source MLX allocation stays alive until the consumer releases the tensor. * device_type defaults to kDLCUDA(2); CPPMEGA_TILELANG_CUDA_DLPACK_DEVICE_TYPE overrides to 13 (kDLCUDAManaged) for A/B (tvm-ffi accepts both). * CUDA ordinal from torch.cuda.current_device() (MLX hardcodes device_id=0); CPPMEGA_TILELANG_CUDA_DEVICE_ID overrides. RAISES if unresolved. * stream sync via mx.synchronize() before handoff (DLPack producer contract; MLX exposes no per-array stream -- issue #3548). - scripts/m04_train_step.py: env-gated branch (CPPMEGA_TILELANG_CUDA_ZEROCOPY=1) in _path_c_call_cuda_artifact_with_mlx_bridge -> new _path_c_call_cuda_artifact_zerocopy_mlx: passes MLX arrays straight to the artifact, kernel writes in place into the aliased CUDA buffer, re-binds the same objects, torch.cuda.synchronize() barrier. RULE #1: when zero-copy is selected it is the only path; any failure RAISES with where+what. The eager copy bridge stays as the default (flag off) until zero-copy is proven on gb10 (step D retires it). Metal path untouched.
1 parent 893485b commit 35bf055

2 files changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
"""Zero-copy MLX-CUDA -> TileLang(target='cuda') DLPack bridge (escape hatch).
2+
3+
MLX on a CUDA host (gb10 / sm_121) refuses to export a CUDA DLPack capsule:
4+
``mx.array.__dlpack__()`` raises ``"CUDA DLPack export is not supported."`` and
5+
``__dlpack_device__`` advertises ``kDLCUDAManaged (13)`` with ``device_id`` hardcoded
6+
to 0 (see /Volumes/external/sources/mlx python/src/convert.cpp:~311 and array.cpp:522).
7+
8+
This module is the interim **Python escape hatch** described in
9+
``cppmega.mlx/docs/DLPACK-CUDA-FIXES.md`` plan (B): instead of forking MLX C++, we
10+
build a ``DLManagedTensor`` ourselves from the MLX array's device pointer
11+
(``mx.array.data_ptr()``, MLX #3342) with the correct shape / strides / dtype and a
12+
``DLDevice{kDLCUDA, device_id}``, wrap it in a PyCapsule named ``"dltensor"``, and
13+
hand it straight to ``tvm.runtime.from_dlpack`` (tvm-ffi imports it zero-copy, no
14+
host roundtrip). A C-callable deleter keeps a Python reference to the source MLX
15+
array alive until the consumer releases the tensor, then drops it.
16+
17+
RULE #1 (fail loud, no silent fallback): every unsupported case RAISES with where +
18+
what failed. There is no copy fallback inside this module — the caller chooses
19+
between this zero-copy path and the eager-copy bridge explicitly (env-gated by
20+
``CPPMEGA_TILELANG_CUDA_ZEROCOPY``). If the zero-copy capsule cannot be built or
21+
imported, we raise so the bug is surfaced, never producing degraded/wrong output.
22+
23+
Device-type note: tvm-ffi ``from_dlpack`` accepts both ``kDLCUDA (2)`` and
24+
``kDLCUDAManaged (13)``. We emit ``kDLCUDA (2)`` by default because the TileLang CUDA
25+
codegen indexes a plain device pointer and the MLX CUDA allocation is addressable as
26+
ordinary device memory on the unified GB10 part; this is overridable via
27+
``CPPMEGA_TILELANG_CUDA_DLPACK_DEVICE_TYPE`` for A/B testing (2 vs 13).
28+
"""
29+
30+
import ctypes
31+
import os
32+
from typing import Any
33+
34+
import mlx.core as mx
35+
36+
# ---------------------------------------------------------------------------
37+
# DLPack C ABI (DLPack >= 0.5; matches tvm-ffi's expected layout)
38+
# ---------------------------------------------------------------------------
39+
40+
kDLCPU = 1
41+
kDLCUDA = 2
42+
kDLCUDAHost = 3
43+
kDLCUDAManaged = 13
44+
45+
# DLDataTypeCode
46+
_kDLInt = 0
47+
_kDLUInt = 1
48+
_kDLFloat = 2
49+
_kDLBfloat = 4
50+
51+
52+
class _DLDevice(ctypes.Structure):
53+
_fields_ = [("device_type", ctypes.c_int32), ("device_id", ctypes.c_int32)]
54+
55+
56+
class _DLDataType(ctypes.Structure):
57+
_fields_ = [
58+
("code", ctypes.c_uint8),
59+
("bits", ctypes.c_uint8),
60+
("lanes", ctypes.c_uint16),
61+
]
62+
63+
64+
class _DLTensor(ctypes.Structure):
65+
_fields_ = [
66+
("data", ctypes.c_void_p),
67+
("device", _DLDevice),
68+
("ndim", ctypes.c_int32),
69+
("dtype", _DLDataType),
70+
("shape", ctypes.POINTER(ctypes.c_int64)),
71+
("strides", ctypes.POINTER(ctypes.c_int64)),
72+
("byte_offset", ctypes.c_uint64),
73+
]
74+
75+
76+
# DLManagedTensor: deleter(self) takes a DLManagedTensor*
77+
_DLManagedTensorDeleter = ctypes.CFUNCTYPE(None, ctypes.c_void_p)
78+
79+
80+
class _DLManagedTensor(ctypes.Structure):
81+
_fields_ = [
82+
("dl_tensor", _DLTensor),
83+
("manager_ctx", ctypes.c_void_p),
84+
("deleter", _DLManagedTensorDeleter),
85+
]
86+
87+
88+
# Keep capsule-name buffers + per-capsule keepalive state alive for the program
89+
# lifetime. Each managed tensor is held in this registry keyed by its address so
90+
# the deleter (called from C with only the struct pointer) can find and release
91+
# the Python keepalives (source array, shape/stride buffers, the struct itself).
92+
_CAPSULE_NAME = b"dltensor"
93+
_CAPSULE_NAME_USED = b"used_dltensor"
94+
_REGISTRY: dict[int, dict[str, Any]] = {}
95+
96+
97+
_MLX_DTYPE_TO_DL = {
98+
mx.float32: (_kDLFloat, 32),
99+
mx.float16: (_kDLFloat, 16),
100+
mx.bfloat16: (_kDLBfloat, 16),
101+
mx.int8: (_kDLInt, 8),
102+
mx.int16: (_kDLInt, 16),
103+
mx.int32: (_kDLInt, 32),
104+
mx.int64: (_kDLInt, 64),
105+
mx.uint8: (_kDLUInt, 8),
106+
mx.uint16: (_kDLUInt, 16),
107+
mx.uint32: (_kDLUInt, 32),
108+
mx.uint64: (_kDLUInt, 64),
109+
mx.bool_: (_kDLUInt, 8),
110+
}
111+
112+
113+
def zerocopy_enabled() -> bool:
114+
"""Return whether the env opts into the zero-copy CUDA DLPack escape hatch."""
115+
116+
return os.environ.get("CPPMEGA_TILELANG_CUDA_ZEROCOPY", "0") not in ("", "0", "false", "False")
117+
118+
119+
def _dlpack_device_type() -> int:
120+
"""Resolve the DLPack device_type to emit (kDLCUDA(2) default; 13 for A/B)."""
121+
122+
raw = os.environ.get("CPPMEGA_TILELANG_CUDA_DLPACK_DEVICE_TYPE", "").strip()
123+
if not raw:
124+
return kDLCUDA
125+
val = int(raw)
126+
if val not in (kDLCUDA, kDLCUDAManaged):
127+
raise ValueError(
128+
f"_cuda_zerocopy: CPPMEGA_TILELANG_CUDA_DLPACK_DEVICE_TYPE={val!r} is not a CUDA "
129+
f"device type; expected {kDLCUDA} (kDLCUDA) or {kDLCUDAManaged} (kDLCUDAManaged)."
130+
)
131+
return val
132+
133+
134+
def _cuda_device_id() -> int:
135+
"""Resolve the CUDA ordinal the MLX array lives on.
136+
137+
MLX hardcodes device_id=0 in ``__dlpack_device__`` (array.cpp:522), so MLX gives
138+
us nothing reliable. We trust torch's current CUDA device, overridable via
139+
``CPPMEGA_TILELANG_CUDA_DEVICE_ID`` for multi-GPU. RAISES if neither resolves —
140+
a wrong ordinal would silently read another GPU's memory.
141+
"""
142+
143+
raw = os.environ.get("CPPMEGA_TILELANG_CUDA_DEVICE_ID", "").strip()
144+
if raw:
145+
return int(raw)
146+
try:
147+
import torch
148+
149+
if torch.cuda.is_available():
150+
return int(torch.cuda.current_device())
151+
except Exception as exc: # noqa: BLE001 - report and fail loud
152+
raise RuntimeError(
153+
f"_cuda_zerocopy: cannot resolve CUDA device ordinal for the MLX array "
154+
f"(torch.cuda.current_device() failed: {type(exc).__name__}: {exc}); set "
155+
f"CPPMEGA_TILELANG_CUDA_DEVICE_ID explicitly."
156+
) from exc
157+
raise RuntimeError(
158+
"_cuda_zerocopy: cannot resolve CUDA device ordinal (no torch.cuda); set "
159+
"CPPMEGA_TILELANG_CUDA_DEVICE_ID explicitly."
160+
)
161+
162+
163+
def _synchronize_mlx_stream() -> None:
164+
"""Flush MLX's CUDA stream so the device pointer holds materialized data.
165+
166+
The DLPack stream contract requires the producer to make its writes visible to
167+
the consumer stream before handoff. MLX has no public per-array stream export
168+
and its CUDA backend uses a single work stream (PR #2075, sync model #2391), so
169+
the safe, correct handoff is a full ``mx.eval`` + ``mx.synchronize`` on the
170+
array's stream. tvm-ffi then launches on its own (default) CUDA stream; a device
171+
synchronize before handoff guarantees ordering without per-stream event plumbing
172+
(which MLX never exposed — issue #3548). RAISES on failure (no silent skip).
173+
"""
174+
175+
mx.synchronize()
176+
177+
178+
def _build_managed_tensor(arr: "mx.array") -> int:
179+
"""Build a heap ``DLManagedTensor`` for ``arr`` and return its address.
180+
181+
Caller owns wrapping the returned address in a PyCapsule. The struct + shape /
182+
stride buffers + a reference to ``arr`` are held in ``_REGISTRY`` until the
183+
deleter fires.
184+
"""
185+
186+
if not isinstance(arr, mx.array):
187+
raise TypeError(f"_cuda_zerocopy: expected mlx.core.array, got {type(arr).__name__}")
188+
189+
dl = _MLX_DTYPE_TO_DL.get(arr.dtype)
190+
if dl is None:
191+
raise TypeError(
192+
f"_cuda_zerocopy: unsupported MLX dtype {arr.dtype} for CUDA DLPack export."
193+
)
194+
code, bits = dl
195+
196+
data_ptr = int(arr.data_ptr())
197+
if data_ptr == 0:
198+
raise RuntimeError(
199+
"_cuda_zerocopy: mx.array.data_ptr() returned NULL; cannot export a "
200+
"zero-copy CUDA DLPack capsule from an unallocated array."
201+
)
202+
203+
shape = [int(d) for d in arr.shape]
204+
ndim = len(shape)
205+
# MLX strides are in elements; DLPack strides are in elements too.
206+
itemsize = bits // 8 if bits >= 8 else 1
207+
strides_elems = [int(s // itemsize) if itemsize else int(s) for s in arr.strides()] if hasattr(arr, "strides") else None
208+
if strides_elems is None or len(strides_elems) != ndim:
209+
# Row-major contiguous fallback strides (in elements).
210+
strides_elems = [0] * ndim
211+
acc = 1
212+
for i in range(ndim - 1, -1, -1):
213+
strides_elems[i] = acc
214+
acc *= shape[i]
215+
216+
ShapeArr = ctypes.c_int64 * max(ndim, 1)
217+
shape_buf = ShapeArr(*shape) if ndim else ShapeArr(0)
218+
stride_buf = ShapeArr(*strides_elems) if ndim else ShapeArr(0)
219+
220+
managed = _DLManagedTensor()
221+
managed.dl_tensor.data = ctypes.c_void_p(data_ptr)
222+
managed.dl_tensor.device = _DLDevice(_dlpack_device_type(), _cuda_device_id())
223+
managed.dl_tensor.ndim = ndim
224+
managed.dl_tensor.dtype = _DLDataType(code, bits, 1)
225+
managed.dl_tensor.shape = ctypes.cast(shape_buf, ctypes.POINTER(ctypes.c_int64)) if ndim else None
226+
managed.dl_tensor.strides = ctypes.cast(stride_buf, ctypes.POINTER(ctypes.c_int64)) if ndim else None
227+
managed.dl_tensor.byte_offset = 0
228+
managed.manager_ctx = None
229+
230+
addr = ctypes.addressof(managed)
231+
232+
def _deleter(_self_ptr: int) -> None:
233+
# Drop all keepalives for this managed tensor. ``arr`` (and thus the MLX
234+
# device allocation) is released only here, after the consumer is done.
235+
_REGISTRY.pop(addr, None)
236+
237+
c_deleter = _DLManagedTensorDeleter(_deleter)
238+
managed.deleter = c_deleter
239+
240+
_REGISTRY[addr] = {
241+
"managed": managed,
242+
"shape_buf": shape_buf,
243+
"stride_buf": stride_buf,
244+
"deleter": c_deleter,
245+
"array": arr, # keep the MLX allocation alive
246+
}
247+
return addr
248+
249+
250+
# PyCapsule plumbing via the CPython C-API (ctypes).
251+
_PyCapsule_New = ctypes.pythonapi.PyCapsule_New
252+
_PyCapsule_New.restype = ctypes.py_object
253+
_PyCapsule_New.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]
254+
255+
256+
def mlx_cuda_array_to_dlpack_capsule(arr: "mx.array") -> Any:
257+
"""Build a ``"dltensor"`` PyCapsule wrapping ``arr``'s CUDA device buffer.
258+
259+
The capsule is consumed by ``tvm.runtime.from_dlpack``. We do NOT install a
260+
capsule destructor: ownership/lifetime is managed by the ``DLManagedTensor``
261+
deleter (called by the consumer after import), which keeps ``arr`` alive until
262+
then. (If a consumer never imports the capsule, the keepalive lives for the
263+
process — acceptable for our per-call usage and avoids a double-free.)
264+
"""
265+
266+
_synchronize_mlx_stream()
267+
addr = _build_managed_tensor(arr)
268+
capsule = _PyCapsule_New(ctypes.c_void_p(addr), _CAPSULE_NAME, None)
269+
return capsule
270+
271+
272+
def mlx_cuda_array_to_tvm_tensor(arr: "mx.array") -> Any:
273+
"""Import an MLX-CUDA array as a TVM tensor view, zero-copy, via DLPack.
274+
275+
RAISES (no copy fallback) so a broken zero-copy path is surfaced, per RULE #1.
276+
"""
277+
278+
from tilelang import tvm
279+
280+
capsule = mlx_cuda_array_to_dlpack_capsule(arr)
281+
try:
282+
return tvm.runtime.from_dlpack(capsule)
283+
except Exception as exc: # noqa: BLE001 - surface where + what failed
284+
raise RuntimeError(
285+
f"_cuda_zerocopy: tvm.runtime.from_dlpack rejected the MLX-CUDA capsule "
286+
f"(device_type={_dlpack_device_type()}, device_id={_cuda_device_id()}, "
287+
f"dtype={arr.dtype}, shape={tuple(int(d) for d in arr.shape)}): "
288+
f"{type(exc).__name__}: {exc}"
289+
) from exc

scripts/m04_train_step.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6827,6 +6827,23 @@ def _path_c_call_cuda_artifact_with_mlx_bridge(
68276827
so the Apple/Metal zero-copy path never enters here.
68286828
"""
68296829

6830+
from cppmega_mlx.nn._tilelang._cuda_zerocopy import zerocopy_enabled
6831+
6832+
if zerocopy_enabled():
6833+
# ZERO-COPY path (CPPMEGA_TILELANG_CUDA_ZEROCOPY=1): hand the caller-owned
6834+
# MLX-CUDA arrays straight to the tvm-ffi CUDA artifact. The TileLang
6835+
# adapter is now device-aware (accepts kDLCUDA/kDLCUDAManaged DLPack for a
6836+
# cuda kernel), and _cuda_zerocopy builds a real DLManagedTensor from
6837+
# mx.array.data_ptr() so no host roundtrip happens. RULE #1: any failure
6838+
# RAISES here -- there is NO silent fallback to the eager copy bridge.
6839+
return _path_c_call_cuda_artifact_zerocopy_mlx(
6840+
artifact=artifact,
6841+
arrays=arrays,
6842+
kernel_arg_buffer_names=kernel_arg_buffer_names,
6843+
buffers=buffers,
6844+
mx_module=mx_module,
6845+
)
6846+
68306847
from cppmega_mlx.nn._tilelang._cuda_eager import (
68316848
_mlx_to_torch_cuda,
68326849
_torch_cuda_to_mlx,
@@ -6903,6 +6920,63 @@ def _path_c_call_cuda_artifact_with_mlx_bridge(
69036920
return result
69046921

69056922

6923+
def _path_c_call_cuda_artifact_zerocopy_mlx(
6924+
*,
6925+
artifact: Any,
6926+
arrays: tuple[Any, ...],
6927+
kernel_arg_buffer_names: Mapping[int, tuple[str, tuple[int, ...] | None]],
6928+
buffers: dict[str, Any],
6929+
mx_module: Any,
6930+
) -> Any:
6931+
"""Call a CUDA direct-chain artifact zero-copy with MLX-CUDA arrays.
6932+
6933+
Interim escape hatch (docs/DLPACK-CUDA-FIXES.md plan B). The caller-owned MLX
6934+
arrays are passed straight to the tvm-ffi CUDA artifact; the TileLang adapter
6935+
is now device-aware and ``_cuda_zerocopy`` builds a real ``DLManagedTensor``
6936+
from ``mx.array.data_ptr()`` (no host roundtrip). The kernel writes in place
6937+
into the same CUDA buffer the MLX array views, so the owning bank buffer is
6938+
already updated -- we re-register the same array object (no copy/stitch).
6939+
6940+
RULE #1: this is the explicit zero-copy clear path; any failure RAISES (it is
6941+
selected only when ``CPPMEGA_TILELANG_CUDA_ZEROCOPY=1``). There is NO silent
6942+
fallback to the eager copy bridge.
6943+
"""
6944+
6945+
from cppmega_mlx.nn._tilelang._cuda_zerocopy import _synchronize_mlx_stream
6946+
6947+
# Ensure every MLX buffer arg is materialized on the CUDA stream before the
6948+
# kernel reads its device pointer (DLPack producer-side stream contract).
6949+
buffer_args = tuple(arg for arg in arrays if hasattr(arg, "shape"))
6950+
if buffer_args:
6951+
mx_module.eval(*buffer_args)
6952+
_synchronize_mlx_stream()
6953+
6954+
# Pass MLX arrays directly; the adapter imports them zero-copy via DLPack.
6955+
result = artifact(*arrays)
6956+
6957+
# The kernel mutated the device memory the MLX arrays already alias, so the
6958+
# caller-owned banks are updated in place. Re-bind the same objects so the
6959+
# direct-chain in/out ABI (buffers[name]) reflects the kernel result, and
6960+
# synchronize so the writes are visible before any downstream read.
6961+
updated_outputs: list[Any] = []
6962+
for position, arg in enumerate(arrays):
6963+
if position in kernel_arg_buffer_names:
6964+
name, _expected_shape = kernel_arg_buffer_names[position]
6965+
if name in buffers:
6966+
buffers[name] = arg
6967+
updated_outputs.append(arg)
6968+
6969+
# Device-synchronize so the kernel's writes are visible before any downstream
6970+
# read of the aliased MLX buffers. A failed sync would mean we cannot prove
6971+
# ordering, so RAISE (RULE #1) rather than hand back possibly-stale data.
6972+
import torch as _torch
6973+
6974+
_torch.cuda.synchronize()
6975+
if updated_outputs:
6976+
mx_module.eval(*updated_outputs)
6977+
return result
6978+
6979+
69066980
def _path_c_segment_time_chunk_launches(prim_func: Any) -> tuple[tuple[int, int], ...]:
69076981
"""Return ``(chunk_index, subchunk_index)`` launch pairs for a segment.
69086982

0 commit comments

Comments
 (0)