|
33 | 33 |
|
34 | 34 | import mlx.core as mx |
35 | 35 |
|
| 36 | +# --------------------------------------------------------------------------- |
| 37 | +# CUDA mempool release-threshold + trim shim (lever dlpack-fix). |
| 38 | +# |
| 39 | +# Root cause of the R1-e2e fp8-backward OOM (docs/RELAX-GRAPH-VS-MEGATRON.md §21): |
| 40 | +# the error string ``cudaMallocAsync(&data, size, stream) failed: out of memory`` |
| 41 | +# is MLX's OWN allocator (mlx/backend/cuda/allocator.cpp:225) hitting genuine |
| 42 | +# physical-memory exhaustion of the single GB10 117 GB unified pool. TWO |
| 43 | +# stream-ordered cudaMallocAsync mempools (MLX's + torch/TE's) each reserve-and- |
| 44 | +# retain unified memory that neither releases, because neither sets |
| 45 | +# ``cudaMemPoolAttrReleaseThreshold`` (MLX only READS ReservedMemCurrent at |
| 46 | +# allocator.cpp:247). The COMBINED reserved set exceeds physical memory. |
| 47 | +# |
| 48 | +# This shim (Python-only, NO MLX rebuild) sets the release threshold to 0 on the |
| 49 | +# device mempool(s) and calls ``cudaMemPoolTrimTo(pool, 0)`` so reserved-but-idle |
| 50 | +# unified memory is RETURNED to the OS before the competing allocation. It is |
| 51 | +# called ONLY at a bridge boundary AFTER eval/sync (never mid-kernel) — see |
| 52 | +# RISK 3 in the lever research. RULE #1: trimming only makes the zero-copy path |
| 53 | +# FIT; it never introduces a host-copy / fp8->bf16 degrade. If libcudart cannot |
| 54 | +# be loaded or a CUDA call fails, this RAISES with where+what (no silent skip) — |
| 55 | +# the caller decides whether trimming is required for its budget. |
| 56 | +# --------------------------------------------------------------------------- |
| 57 | + |
| 58 | +_CUDA_MEMPOOL_ATTR_RELEASE_THRESHOLD = 4 # cudaMemPoolAttrReleaseThreshold |
| 59 | +_cudart = None # lazily-loaded libcudart handle |
| 60 | +_RELEASE_THRESHOLD_SET: set[int] = set() # devices whose threshold we already set |
| 61 | + |
| 62 | + |
| 63 | +def _load_cudart(): |
| 64 | + """Load libcudart once; RAISE (RULE #1) with where+what if it cannot load.""" |
| 65 | + |
| 66 | + global _cudart |
| 67 | + if _cudart is not None: |
| 68 | + return _cudart |
| 69 | + last_exc = None |
| 70 | + for name in ("libcudart.so", "libcudart.so.12", "libcudart.so.13", "libcudart.so.11.0"): |
| 71 | + try: |
| 72 | + _cudart = ctypes.CDLL(name) |
| 73 | + break |
| 74 | + except OSError as exc: # noqa: PERF203 - try the next soname |
| 75 | + last_exc = exc |
| 76 | + if _cudart is None: |
| 77 | + raise RuntimeError( |
| 78 | + "_cuda_zerocopy: cannot load libcudart for the CUDA mempool trim shim " |
| 79 | + f"(tried libcudart.so / .so.12 / .so.13 / .so.11.0): {last_exc}. The " |
| 80 | + "fp8 bridge needs cudaMemPoolTrimTo to fit the dual-pool budget." |
| 81 | + ) |
| 82 | + # cudaGetDevice(int*), cudaDeviceGetDefaultMemPool(pool*, int dev), |
| 83 | + # cudaMemPoolSetAttribute(pool, attr, void*), cudaMemPoolTrimTo(pool, size_t) |
| 84 | + _cudart.cudaGetDevice.argtypes = [ctypes.POINTER(ctypes.c_int)] |
| 85 | + _cudart.cudaGetDevice.restype = ctypes.c_int |
| 86 | + _cudart.cudaDeviceGetDefaultMemPool.argtypes = [ |
| 87 | + ctypes.POINTER(ctypes.c_void_p), |
| 88 | + ctypes.c_int, |
| 89 | + ] |
| 90 | + _cudart.cudaDeviceGetDefaultMemPool.restype = ctypes.c_int |
| 91 | + _cudart.cudaMemPoolSetAttribute.argtypes = [ |
| 92 | + ctypes.c_void_p, |
| 93 | + ctypes.c_int, |
| 94 | + ctypes.c_void_p, |
| 95 | + ] |
| 96 | + _cudart.cudaMemPoolSetAttribute.restype = ctypes.c_int |
| 97 | + _cudart.cudaMemPoolTrimTo.argtypes = [ctypes.c_void_p, ctypes.c_size_t] |
| 98 | + _cudart.cudaMemPoolTrimTo.restype = ctypes.c_int |
| 99 | + return _cudart |
| 100 | + |
| 101 | + |
| 102 | +def _default_mempool(rt, device: int) -> ctypes.c_void_p: |
| 103 | + pool = ctypes.c_void_p() |
| 104 | + err = rt.cudaDeviceGetDefaultMemPool(ctypes.byref(pool), int(device)) |
| 105 | + if err != 0: |
| 106 | + raise RuntimeError( |
| 107 | + f"_cuda_zerocopy: cudaDeviceGetDefaultMemPool(dev={device}) failed " |
| 108 | + f"(cudaError={err}); cannot trim the CUDA mempool for the fp8 bridge." |
| 109 | + ) |
| 110 | + return pool |
| 111 | + |
| 112 | + |
| 113 | +def trim_cuda_mempool(device: int | None = None) -> None: |
| 114 | + """Set ReleaseThreshold=0 and trim the default mempool to 0 on ``device``. |
| 115 | +
|
| 116 | + Called at the fp8 bridge boundary (after eval/sync) to return reserved-but-idle |
| 117 | + unified memory so the competing MLX+torch+TE allocations fit the single GB10 |
| 118 | + pool. RAISES (RULE #1) on any CUDA failure — never silently degrades. If the |
| 119 | + box genuinely has no idle reserved memory to trim this is a fast no-op (TrimTo |
| 120 | + returns cudaSuccess and reclaims nothing). |
| 121 | + """ |
| 122 | + |
| 123 | + rt = _load_cudart() |
| 124 | + if device is None: |
| 125 | + dev = ctypes.c_int(0) |
| 126 | + err = rt.cudaGetDevice(ctypes.byref(dev)) |
| 127 | + if err != 0: |
| 128 | + raise RuntimeError( |
| 129 | + f"_cuda_zerocopy: cudaGetDevice failed (cudaError={err}) resolving " |
| 130 | + "the device for the CUDA mempool trim." |
| 131 | + ) |
| 132 | + device = int(dev.value) |
| 133 | + pool = _default_mempool(rt, device) |
| 134 | + # Set the release threshold to 0 ONCE per device so the driver does not retain |
| 135 | + # reserved memory across the trim (default threshold is UINT64_MAX = retain all). |
| 136 | + if device not in _RELEASE_THRESHOLD_SET: |
| 137 | + thresh = ctypes.c_uint64(0) |
| 138 | + err = rt.cudaMemPoolSetAttribute( |
| 139 | + pool, |
| 140 | + _CUDA_MEMPOOL_ATTR_RELEASE_THRESHOLD, |
| 141 | + ctypes.cast(ctypes.byref(thresh), ctypes.c_void_p), |
| 142 | + ) |
| 143 | + if err != 0: |
| 144 | + raise RuntimeError( |
| 145 | + f"_cuda_zerocopy: cudaMemPoolSetAttribute(ReleaseThreshold=0, " |
| 146 | + f"dev={device}) failed (cudaError={err}); cannot bound the CUDA " |
| 147 | + "mempool reservation for the fp8 bridge." |
| 148 | + ) |
| 149 | + _RELEASE_THRESHOLD_SET.add(device) |
| 150 | + err = rt.cudaMemPoolTrimTo(pool, ctypes.c_size_t(0)) |
| 151 | + if err != 0: |
| 152 | + raise RuntimeError( |
| 153 | + f"_cuda_zerocopy: cudaMemPoolTrimTo(dev={device}, 0) failed " |
| 154 | + f"(cudaError={err}); cannot return idle reserved unified memory." |
| 155 | + ) |
| 156 | + |
36 | 157 | # --------------------------------------------------------------------------- |
37 | 158 | # DLPack C ABI (DLPack >= 0.5; matches tvm-ffi's expected layout) |
38 | 159 | # --------------------------------------------------------------------------- |
@@ -116,6 +237,32 @@ def zerocopy_enabled() -> bool: |
116 | 237 | return os.environ.get("CPPMEGA_TILELANG_CUDA_ZEROCOPY", "0") not in ("", "0", "false", "False") |
117 | 238 |
|
118 | 239 |
|
| 240 | +def relieve_bridge_memory_pressure(device: int | None = None) -> None: |
| 241 | + """Trim BOTH the MLX cache and the CUDA mempool at a bridge boundary. |
| 242 | +
|
| 243 | + The fp8 backward crosses the MLX<->torch DLPack bridge while MLX's forward/scan |
| 244 | + working set and torch/TE's fp8 scratch both hold reserved unified memory. This |
| 245 | + helper, called immediately BEFORE a backward crossing (after the producer |
| 246 | + eval/sync), returns MLX's cached buffers (``mx.clear_cache``) and returns the |
| 247 | + CUDA mempool's reserved-but-idle unified memory (``trim_cuda_mempool``) so the |
| 248 | + next allocation fits the single GB10 pool. It is gated by |
| 249 | + ``CPPMEGA_FP8_BRIDGE_TRIM`` (default ON when unset, to fix the §21 OOM; set 0 to |
| 250 | + A/B the un-trimmed behavior). RULE #1: this only makes the zero-copy path FIT — |
| 251 | + it never host-copies or degrades. On a genuine CUDA failure it RAISES with |
| 252 | + where+what (no silent skip). |
| 253 | + """ |
| 254 | + |
| 255 | + if os.environ.get("CPPMEGA_FP8_BRIDGE_TRIM", "1").strip().lower() in ("0", "false", "no", "off"): |
| 256 | + return |
| 257 | + # MLX side: return cached (non-live) buffers so the unified pool shrinks. |
| 258 | + clear = getattr(mx, "clear_cache", None) |
| 259 | + if callable(clear): |
| 260 | + clear() |
| 261 | + # CUDA side: set ReleaseThreshold=0 + cudaMemPoolTrimTo(0) so the driver |
| 262 | + # returns reserved-but-idle unified memory shared with torch/TE's pool. |
| 263 | + trim_cuda_mempool(device) |
| 264 | + |
| 265 | + |
119 | 266 | def _dlpack_device_type() -> int: |
120 | 267 | """Resolve the DLPack device_type to emit (kDLCUDA(2) default; 13 for A/B).""" |
121 | 268 |
|
|
0 commit comments