|
| 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 |
0 commit comments