Skip to content

Commit 7fb4763

Browse files
committed
lever dlpack-fix: relieve MLX+torch cudaMallocAsync dual-pool contention at the fp8-backward DLPack bridge
Root-causes the R1-e2e fp8-backward OOM (docs §21): the error 'cudaMallocAsync(&data,size,stream) failed: out of memory' is MLX's OWN allocator hitting genuine exhaustion of the single GB10 117GB unified pool because MLX's and torch/TE's stream-ordered mempools each reserve-and-retain unified memory neither releases (neither sets cudaMemPoolAttrReleaseThreshold). Python-only, no MLX rebuild required for the primary fix; zero-copy stays the ONE path (RULE #1 - the bridge still RAISES on a genuinely-uncrossable tensor, never host-copies or degrades to bf16): - _cuda_zerocopy.py: ctypes libcudart shim trim_cuda_mempool() sets ReleaseThreshold=0 + cudaMemPoolTrimTo(0) on the default device pool; relieve_bridge_memory_pressure() also mx.clear_cache()s MLX. Both RAISE on CUDA failure. Gated CPPMEGA_FP8_BRIDGE_TRIM (default ON). - fp8_te_linear.py: _fp8_linear_bwd eval+sync the 3 bridge operands then trims at the boundary (after sync, never mid-kernel) before the crossing. - bench harness: PYTORCH_CUDA_ALLOC_CONF=backend:cudaMallocAsync,release_threshold:0 set before torch import; fp8 arm sets mx.set_cache_limit(0) + torch.cuda.set_per_process_memory_fraction to split the budget.
1 parent 68d3c83 commit 7fb4763

3 files changed

Lines changed: 223 additions & 0 deletions

File tree

cppmega_mlx/nn/_tilelang/_cuda_zerocopy.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,127 @@
3333

3434
import mlx.core as mx
3535

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+
36157
# ---------------------------------------------------------------------------
37158
# DLPack C ABI (DLPack >= 0.5; matches tvm-ffi's expected layout)
38159
# ---------------------------------------------------------------------------
@@ -116,6 +237,32 @@ def zerocopy_enabled() -> bool:
116237
return os.environ.get("CPPMEGA_TILELANG_CUDA_ZEROCOPY", "0") not in ("", "0", "false", "False")
117238

118239

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+
119266
def _dlpack_device_type() -> int:
120267
"""Resolve the DLPack device_type to emit (kDLCUDA(2) default; 13 for A/B)."""
121268

cppmega_mlx/nn/_tilelang/fp8_te_linear.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ def _fp8_linear_bwd(
294294

295295
from cppmega_mlx.nn._tilelang._cuda_zerocopy import (
296296
mlx_cuda_array_to_torch_tensor,
297+
relieve_bridge_memory_pressure,
297298
torch_cuda_tensor_to_mlx,
298299
)
299300

@@ -313,6 +314,26 @@ def _fp8_linear_bwd(
313314
x2 = x.reshape((-1, k_in)) if x.ndim != 2 else x
314315
g2 = cotangent.reshape((-1, n_out)) if cotangent.ndim != 2 else cotangent
315316

317+
# Materialize the three bridge operands and flush MLX's stream BEFORE trimming.
318+
# trim_cuda_mempool must run only at a bridge boundary after eval/sync (RISK 3:
319+
# trimming mid-kernel could reclaim memory live tensors depend on). After this
320+
# eval, x2/w/g2 are the live set; the cache + idle reserved memory are not.
321+
mx.eval(x2, weight, g2)
322+
mx.synchronize()
323+
# Relieve the dual-pool (MLX + torch/TE) cudaMallocAsync reservation contention
324+
# that causes the §21 fp8-backward OOM, so the zero-copy crossing FITS. RULE #1:
325+
# this does not host-copy — it returns idle reserved unified memory and the
326+
# bridge below still RAISES on a genuinely-uncrossable tensor.
327+
try:
328+
relieve_bridge_memory_pressure()
329+
except Exception as exc: # noqa: BLE001 - RULE #1 surface where+what
330+
raise RuntimeError(
331+
f"fp8_te_linear(bwd): CUDA mempool trim before the fp8-backward bridge "
332+
f"crossing failed (site {site_key!r}): {type(exc).__name__}: {exc}. The "
333+
"zero-copy bridge cannot be made to fit; this is a hard memory boundary, "
334+
"not a fallback point."
335+
) from exc
336+
316337
try:
317338
x_t = mlx_cuda_array_to_torch_tensor(x2)
318339
w_t = mlx_cuda_array_to_torch_tensor(weight)

scripts/bench_fp8_e2e_step_20260604.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@
3636
import statistics
3737
import time
3838

39+
# RULE #1 (lever dlpack-fix): bound torch/TE's cudaMallocAsync pool so it does NOT
40+
# reserve-and-retain unified memory that contends with MLX's pool on the single
41+
# GB10 117 GB unified part (the §21 fp8-backward OOM root cause). This env MUST be
42+
# set BEFORE the first ``import torch`` anywhere in the process; torch reads it once
43+
# at CUDA-context init. release_threshold:0 makes torch's stream-ordered pool return
44+
# reserved-but-idle memory instead of hoarding it, so the combined MLX+torch+TE
45+
# reserved set fits. Overridable via CPPMEGA_TORCH_ALLOC_CONF for A/B. This is NOT a
46+
# fallback — it only makes the zero-copy fp8 bridge FIT; the bridge still RAISES on a
47+
# genuinely-uncrossable tensor.
48+
if "PYTORCH_CUDA_ALLOC_CONF" not in os.environ:
49+
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = os.environ.get(
50+
"CPPMEGA_TORCH_ALLOC_CONF",
51+
"backend:cudaMallocAsync,release_threshold:0",
52+
)
53+
3954
# RULE #1: fix the NVRTC loader BEFORE torch / TE are imported anywhere (gb10).
4055
# Safe no-op off-gb10. Must precede the first transformer_engine import (which
4156
# happens lazily inside fp8_te_linear when the fp8 arm runs).
@@ -87,6 +102,40 @@ def _torch_reset_peak() -> None:
87102
pass
88103

89104

105+
def _budget_mlx_torch() -> None:
106+
"""Split the single GB10 unified pool between MLX and torch/TE for the fp8 arm.
107+
108+
lever dlpack-fix: the §21 fp8-backward OOM is dual-pool reservation contention,
109+
not activation size. Three knobs, all bounds (NOT fallbacks):
110+
(1) ``mx.set_cache_limit(0)`` — MLX returns freed buffers immediately so the
111+
bridge trim (``relieve_bridge_memory_pressure``) can reclaim them, instead
112+
of MLX hoarding them in its cache.
113+
(2) ``torch.cuda.set_per_process_memory_fraction(F)`` — caps torch/TE's share
114+
so MLX + torch/TE + the transient per-tensor export doubling sum < the
115+
physical 117 GB part. F is set conservatively (env CPPMEGA_TORCH_MEM_FRAC,
116+
default 0.45 = ~52 GB on a 117 GB box) leaving MLX the majority.
117+
(3) ``mx.set_memory_limit`` honored via CPPMEGA_MLX_MEMORY_LIMIT_GB (in main).
118+
RULE #1: if the split is too tight the bridge still RAISES with where+what — it
119+
never host-copies or degrades to bf16.
120+
"""
121+
122+
set_cache = getattr(mx, "set_cache_limit", None)
123+
if callable(set_cache):
124+
set_cache(0)
125+
try:
126+
import torch
127+
128+
if torch.cuda.is_available():
129+
frac = float(os.environ.get("CPPMEGA_TORCH_MEM_FRAC", "0.45"))
130+
torch.cuda.set_per_process_memory_fraction(frac)
131+
except Exception as exc: # RULE #1: a budget-cap failure is a config bug, surface it.
132+
raise RuntimeError(
133+
"bench_fp8_e2e: could not apply the torch CUDA memory-fraction budget for "
134+
f"the fp8 arm (CPPMEGA_TORCH_MEM_FRAC): {type(exc).__name__}: {exc}. The "
135+
"fp8 bridge needs the MLX+torch budget split to fit the GB10 unified pool."
136+
) from exc
137+
138+
90139
def _run_arm(arm: str, args, tokens) -> dict:
91140
"""Run one arm (bf16 | fp8) for warmup+steps timed steps; return the metrics."""
92141

@@ -99,6 +148,12 @@ def _run_arm(arm: str, args, tokens) -> dict:
99148
os.environ["CPPMEGA_FP8_LINEAR_MIN_K"] = str(args.fp8_floor)
100149
os.environ["CPPMEGA_FP8_LINEAR_MIN_N"] = str(args.fp8_floor)
101150
os.environ["CPPMEGA_FP8_LINEAR_MIN_M"] = str(args.fp8_floor)
151+
# lever dlpack-fix: split the single GB10 unified pool between MLX and
152+
# torch/TE so neither hoards. (1) Stop MLX from caching freed buffers so the
153+
# bridge trim can return them. (2) Cap torch's share so MLX + torch/TE + the
154+
# transient export doubling sum < physical. These are bounds, not fallbacks:
155+
# if the split is too tight the bridge RAISES (no host-copy degrade).
156+
_budget_mlx_torch()
102157
else:
103158
os.environ.pop(FP8_LINEAR_ENV, None)
104159

0 commit comments

Comments
 (0)