Skip to content

Commit ca0f4ba

Browse files
committed
fix(§22): single shared cudaMemPool (PRIMARY) + trim-both fallback for fp8-backward dual-pool OOM
§22 root cause (MEASURED): the R1-e2e fp8 backward OOMs because TWO stream-ordered cudaMallocAsync mempools coexist — MLX's device-default pool AND torch/TE's own pool — each reserves-and-retains unified memory neither releases; their COMBINED reserved set exceeds the single GB10 117GB physical unified pool. (§21 trim-MLX-only helped but still double-reserves.) PRIMARY (Python-only, no MLX rebuild) — ONE shared pool, ONE reservation: - _cuda_zerocopy.install_shared_mempool(): cudaDeviceSetMemPool(dev, mlx_default_pool) routes the device-CURRENT pool to MLX's default pool so BOTH MLX's and torch/TE's plain cudaMallocAsync(&p,size,stream) draw from the SAME pool (no double-count), and sets ReleaseThreshold=0 on it. Idempotent; gated CPPMEGA_FP8_SHARED_POOL (default ON). Extends the ctypes cudart shim with cudaDevice{Get,Set}MemPool. - bench harness _budget_mlx_torch(): torch.cuda.init() then install_shared_mempool() ONCE up front (after torch's CUDA context exists). RAISES if cudaDeviceSetMemPool is unavailable (RULE #1, no silent dual-pool) — caller sets CPPMEGA_FP8_SHARED_POOL=0 to fall to the documented trim-both fallback honestly. COMPLEMENTARY / FALLBACK (§22 (a-d)): - trim_device_current_mempool(): trims the device-current (torch's, when not shared) pool too. relieve_bridge_memory_pressure() now (1) installs the shared pool idempotently, (2) mx.clear_cache(), (3) trims BOTH the MLX default pool and the device-current pool to 0. Same pool when shared -> 2nd trim is a no-op. - harness keeps PYTORCH_CUDA_ALLOC_CONF=backend:cudaMallocAsync,release_threshold:0, mx.set_cache_limit(0), torch.cuda.set_per_process_memory_fraction(F) budget split. SECONDARY (export doubling, propagated to the MLX rebuild): - harness propagates CPPMEGA_FP8_BRIDGE_UNIFIED -> MLX_CUDA_BRIDGE_UNIFIED before import mlx.core so the rebuilt allocator allocates bridge arrays as unified up front (kills the per-tensor move_to_unified_memory cudaMallocManaged doubling). Default OFF. RULE #1: zero-copy stays the ONE path; every new error path RAISES with where+what (no host-copy, no bf16 degrade, no silent skip). Verified on this no-CUDA Mac: py_compile + ast + cross-symbol checks pass; install_shared_mempool() RAISES on a host without libcudart instead of silently skipping.
1 parent 467dc18 commit ca0f4ba

2 files changed

Lines changed: 257 additions & 17 deletions

File tree

cppmega_mlx/nn/_tilelang/_cuda_zerocopy.py

Lines changed: 210 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ def _load_cudart():
8080
"fp8 bridge needs cudaMemPoolTrimTo to fit the dual-pool budget."
8181
)
8282
# cudaGetDevice(int*), cudaDeviceGetDefaultMemPool(pool*, int dev),
83-
# cudaMemPoolSetAttribute(pool, attr, void*), cudaMemPoolTrimTo(pool, size_t)
83+
# cudaMemPoolSetAttribute(pool, attr, void*), cudaMemPoolTrimTo(pool, size_t),
84+
# cudaDeviceGetMemPool(pool*, int dev), cudaDeviceSetMemPool(int dev, pool)
85+
# (the last two route the device-current pool for the SHARED-POOL fix).
8486
_cudart.cudaGetDevice.argtypes = [ctypes.POINTER(ctypes.c_int)]
8587
_cudart.cudaGetDevice.restype = ctypes.c_int
8688
_cudart.cudaDeviceGetDefaultMemPool.argtypes = [
@@ -96,6 +98,18 @@ def _load_cudart():
9698
_cudart.cudaMemPoolSetAttribute.restype = ctypes.c_int
9799
_cudart.cudaMemPoolTrimTo.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
98100
_cudart.cudaMemPoolTrimTo.restype = ctypes.c_int
101+
# cudaDeviceGetMemPool reads the device's CURRENT pool (what plain
102+
# cudaMallocAsync(&p,size,stream) — used by BOTH MLX and torch — draws from
103+
# unless the caller passes an explicit pool). cudaDeviceSetMemPool repoints it.
104+
if hasattr(_cudart, "cudaDeviceGetMemPool"):
105+
_cudart.cudaDeviceGetMemPool.argtypes = [
106+
ctypes.POINTER(ctypes.c_void_p),
107+
ctypes.c_int,
108+
]
109+
_cudart.cudaDeviceGetMemPool.restype = ctypes.c_int
110+
if hasattr(_cudart, "cudaDeviceSetMemPool"):
111+
_cudart.cudaDeviceSetMemPool.argtypes = [ctypes.c_int, ctypes.c_void_p]
112+
_cudart.cudaDeviceSetMemPool.restype = ctypes.c_int
99113
return _cudart
100114

101115

@@ -154,6 +168,171 @@ def trim_cuda_mempool(device: int | None = None) -> None:
154168
f"(cudaError={err}); cannot return idle reserved unified memory."
155169
)
156170

171+
172+
# ---------------------------------------------------------------------------
173+
# PRIMARY (§22) SHARED-POOL fix — make MLX and torch/TE draw from ONE pool.
174+
#
175+
# Root cause (§22 root-cause, MEASURED): the fp8 backward OOMs because TWO
176+
# stream-ordered cudaMallocAsync mempools coexist — MLX's device-default pool AND
177+
# torch/TE's OWN cudaMallocAsync pool — each RESERVES-and-RETAINS unified memory
178+
# and neither releases it. Their COMBINED reserved set exceeds the 117 GB physical
179+
# GB10 unified pool. Trimming both pools (the §21 fallback below) helps but still
180+
# double-reserves transiently. The TARGET fix is a SINGLE shared pool: ONE
181+
# reservation, no double-count.
182+
#
183+
# Mechanism (Python-only, NO MLX rebuild): both MLX (allocator.cpp:243) and torch's
184+
# cudaMallocAsync backend allocate with plain ``cudaMallocAsync(&p, size, stream)``
185+
# (no explicit pool arg), which draws from the DEVICE-CURRENT mempool. The CUDA
186+
# default is the device DEFAULT pool, but torch's native-CUDA-allocator path and
187+
# capture pools can install a private pool as the device-current one. We force ONE
188+
# pool by setting the device-current pool to MLX's default pool via
189+
# ``cudaDeviceSetMemPool`` AFTER torch's CUDA context exists, so every subsequent
190+
# plain ``cudaMallocAsync`` on that device — MLX's AND torch/TE's — reserves from
191+
# the SAME pool. We then set ReleaseThreshold=0 on that one shared pool.
192+
#
193+
# RULE #1: this only makes the zero-copy path FIT (one reservation instead of two);
194+
# it never host-copies or degrades. On any CUDA failure it RAISES with where+what.
195+
# If ``cudaDeviceSetMemPool`` is genuinely unavailable in this CUDA runtime we RAISE
196+
# (the caller falls to the trim-both path and states that honestly — see
197+
# ``relieve_bridge_memory_pressure``).
198+
# ---------------------------------------------------------------------------
199+
200+
# Devices on which we have already installed MLX's pool as the shared device pool.
201+
_SHARED_POOL_INSTALLED: dict[int, ctypes.c_void_p] = {}
202+
203+
204+
def _device_current_mempool(rt, device: int) -> ctypes.c_void_p:
205+
"""Return the device's CURRENT mempool (what plain cudaMallocAsync draws from)."""
206+
207+
if not hasattr(rt, "cudaDeviceGetMemPool"):
208+
raise RuntimeError(
209+
"_cuda_zerocopy: this CUDA runtime lacks cudaDeviceGetMemPool; cannot "
210+
"inspect the device-current mempool for the shared-pool fix."
211+
)
212+
pool = ctypes.c_void_p()
213+
err = rt.cudaDeviceGetMemPool(ctypes.byref(pool), int(device))
214+
if err != 0:
215+
raise RuntimeError(
216+
f"_cuda_zerocopy: cudaDeviceGetMemPool(dev={device}) failed "
217+
f"(cudaError={err}); cannot resolve the device-current mempool."
218+
)
219+
return pool
220+
221+
222+
def install_shared_mempool(device: int | None = None) -> int:
223+
"""Route the device-current pool to MLX's default pool so MLX+torch share ONE.
224+
225+
PRIMARY §22 fix: sets ``cudaDeviceSetMemPool(device, mlx_default_pool)`` so every
226+
subsequent plain ``cudaMallocAsync(&p,size,stream)`` on ``device`` — from MLX AND
227+
from torch/TE — reserves from the SAME pool (ONE reservation, no double-count),
228+
and sets ReleaseThreshold=0 on it. MUST be called AFTER torch's CUDA context is
229+
initialized (so torch's later allocations honor the new device-current pool).
230+
231+
Returns the device ordinal. Idempotent per device. RAISES (RULE #1) on any CUDA
232+
failure or if ``cudaDeviceSetMemPool`` is unavailable — the caller decides whether
233+
to fall to the trim-both path. Gated by ``CPPMEGA_FP8_SHARED_POOL`` (default ON;
234+
set 0 to A/B the dual-pool behavior).
235+
"""
236+
237+
rt = _load_cudart()
238+
if device is None:
239+
dev = ctypes.c_int(0)
240+
err = rt.cudaGetDevice(ctypes.byref(dev))
241+
if err != 0:
242+
raise RuntimeError(
243+
f"_cuda_zerocopy: cudaGetDevice failed (cudaError={err}) resolving "
244+
"the device for the shared-pool install."
245+
)
246+
device = int(dev.value)
247+
if device in _SHARED_POOL_INSTALLED:
248+
return device
249+
if not hasattr(rt, "cudaDeviceSetMemPool"):
250+
raise RuntimeError(
251+
"_cuda_zerocopy: this CUDA runtime lacks cudaDeviceSetMemPool; the "
252+
"single-shared-pool fix is unreachable from Python on this build. Fall to "
253+
"relieve_bridge_memory_pressure() (trim BOTH pools) or rebuild MLX with "
254+
"the allocator shared-pool change."
255+
)
256+
# MLX's default device pool is the pool MLX's allocator was constructed against
257+
# (cudaDeviceGetDefaultMemPool in allocator.cpp:179). Make it the device-CURRENT
258+
# pool so torch/TE's plain cudaMallocAsync draws from it too.
259+
mlx_pool = _default_mempool(rt, device)
260+
err = rt.cudaDeviceSetMemPool(int(device), mlx_pool)
261+
if err != 0:
262+
raise RuntimeError(
263+
f"_cuda_zerocopy: cudaDeviceSetMemPool(dev={device}, mlx_default_pool) "
264+
f"failed (cudaError={err}); cannot install the single shared mempool. The "
265+
"two allocators would keep double-reserving the GB10 unified pool."
266+
)
267+
# Bound the shared pool so cudaFreeAsync returns reserved-but-idle memory to the
268+
# OS (default threshold is UINT64_MAX = retain everything).
269+
if device not in _RELEASE_THRESHOLD_SET:
270+
thresh = ctypes.c_uint64(0)
271+
err = rt.cudaMemPoolSetAttribute(
272+
mlx_pool,
273+
_CUDA_MEMPOOL_ATTR_RELEASE_THRESHOLD,
274+
ctypes.cast(ctypes.byref(thresh), ctypes.c_void_p),
275+
)
276+
if err != 0:
277+
raise RuntimeError(
278+
f"_cuda_zerocopy: cudaMemPoolSetAttribute(ReleaseThreshold=0, "
279+
f"dev={device}) on the shared pool failed (cudaError={err})."
280+
)
281+
_RELEASE_THRESHOLD_SET.add(device)
282+
_SHARED_POOL_INSTALLED[device] = mlx_pool
283+
return device
284+
285+
286+
def shared_pool_enabled() -> bool:
287+
"""Return whether the §22 single-shared-pool fix is enabled (default ON)."""
288+
289+
return os.environ.get("CPPMEGA_FP8_SHARED_POOL", "1").strip().lower() not in (
290+
"0",
291+
"false",
292+
"no",
293+
"off",
294+
)
295+
296+
297+
def trim_device_current_mempool(device: int | None = None) -> None:
298+
"""Trim the device-CURRENT pool (torch's pool when not shared) to 0.
299+
300+
Complements ``trim_cuda_mempool`` (which trims MLX's DEFAULT pool). When the
301+
shared-pool fix is NOT active these are two distinct pools; trimming BOTH is the
302+
§22 fallback (c). When the shared-pool fix IS active they are the same pool and
303+
this is a harmless second trim. RAISES (RULE #1) on any CUDA failure.
304+
"""
305+
306+
rt = _load_cudart()
307+
if device is None:
308+
dev = ctypes.c_int(0)
309+
err = rt.cudaGetDevice(ctypes.byref(dev))
310+
if err != 0:
311+
raise RuntimeError(
312+
f"_cuda_zerocopy: cudaGetDevice failed (cudaError={err}) resolving "
313+
"the device for the device-current mempool trim."
314+
)
315+
device = int(dev.value)
316+
pool = _device_current_mempool(rt, device)
317+
if device not in _RELEASE_THRESHOLD_SET:
318+
thresh = ctypes.c_uint64(0)
319+
err = rt.cudaMemPoolSetAttribute(
320+
pool,
321+
_CUDA_MEMPOOL_ATTR_RELEASE_THRESHOLD,
322+
ctypes.cast(ctypes.byref(thresh), ctypes.c_void_p),
323+
)
324+
if err != 0:
325+
raise RuntimeError(
326+
f"_cuda_zerocopy: cudaMemPoolSetAttribute(ReleaseThreshold=0) on the "
327+
f"device-current pool (dev={device}) failed (cudaError={err})."
328+
)
329+
err = rt.cudaMemPoolTrimTo(pool, ctypes.c_size_t(0))
330+
if err != 0:
331+
raise RuntimeError(
332+
f"_cuda_zerocopy: cudaMemPoolTrimTo(device-current, dev={device}, 0) "
333+
f"failed (cudaError={err}); cannot return idle reserved memory."
334+
)
335+
157336
# ---------------------------------------------------------------------------
158337
# DLPack C ABI (DLPack >= 0.5; matches tvm-ffi's expected layout)
159338
# ---------------------------------------------------------------------------
@@ -238,29 +417,47 @@ def zerocopy_enabled() -> bool:
238417

239418

240419
def relieve_bridge_memory_pressure(device: int | None = None) -> None:
241-
"""Trim BOTH the MLX cache and the CUDA mempool at a bridge boundary.
420+
"""Relieve the dual-pool contention at a bridge boundary (PRIMARY + fallback).
242421
243422
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).
423+
working set and torch/TE's fp8 scratch both hold reserved unified memory. Called
424+
immediately BEFORE a backward crossing (after the producer eval/sync), this:
425+
426+
(PRIMARY §22, when ``CPPMEGA_FP8_SHARED_POOL`` is ON, default) installs MLX's
427+
default pool as the device-CURRENT pool so MLX and torch/TE draw from ONE pool
428+
(``install_shared_mempool`` -> ``cudaDeviceSetMemPool``). ONE reservation, no
429+
double-count. Idempotent. If the CUDA runtime genuinely lacks
430+
``cudaDeviceSetMemPool`` this RAISES; the bench harness installs it once up
431+
front and treats unavailability as a hard config error (no silent dual-pool).
432+
433+
(COMPLEMENTARY §21/§22-fallback) returns MLX's cached buffers
434+
(``mx.clear_cache``) and trims BOTH the MLX default pool (``trim_cuda_mempool``)
435+
AND the device-current pool (``trim_device_current_mempool`` — torch's pool when
436+
not shared) to 0 so reserved-but-idle unified memory returns to the OS. When the
437+
shared-pool fix is active these two are the same pool; the second trim is a
438+
harmless no-op. When it is NOT active (gate off / older runtime) this trims both
439+
distinct pools — the documented fallback.
440+
441+
Gated by ``CPPMEGA_FP8_BRIDGE_TRIM`` (default ON; set 0 to A/B). RULE #1: this only
442+
makes the zero-copy path FIT — it never host-copies or degrades. On a genuine CUDA
443+
failure it RAISES with where+what (no silent skip).
253444
"""
254445

255446
if os.environ.get("CPPMEGA_FP8_BRIDGE_TRIM", "1").strip().lower() in ("0", "false", "no", "off"):
256447
return
448+
# PRIMARY: ensure the single shared pool is installed (idempotent). When the gate
449+
# is on this is the load-bearing fix — one reservation for both allocators.
450+
if shared_pool_enabled():
451+
install_shared_mempool(device)
257452
# MLX side: return cached (non-live) buffers so the unified pool shrinks.
258453
clear = getattr(mx, "clear_cache", None)
259454
if callable(clear):
260455
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.
456+
# CUDA side: set ReleaseThreshold=0 + cudaMemPoolTrimTo(0) on BOTH the MLX default
457+
# pool AND the device-current pool (torch's, when not shared) so the driver returns
458+
# reserved-but-idle unified memory. Same pool when shared -> the second is a no-op.
263459
trim_cuda_mempool(device)
460+
trim_device_current_mempool(device)
264461

265462

266463
def _dlpack_device_type() -> int:

scripts/bench_fp8_e2e_step_20260604.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@
5151
"backend:cudaMallocAsync,release_threshold:0",
5252
)
5353

54+
# SECONDARY (§22) export-doubling fix: when CPPMEGA_FP8_BRIDGE_UNIFIED is requested,
55+
# propagate it to MLX_CUDA_BRIDGE_UNIFIED so the MLX CUDA allocator (rebuilt with the
56+
# allocator.cpp change) allocates bridge arrays as unified up front — killing the
57+
# per-tensor cudaMallocManaged doubling that move_to_unified_memory does on each
58+
# bridge crossing. MUST be set BEFORE ``import mlx.core`` (MLX reads it once at
59+
# allocator construction). Default OFF so the bf16 arm and un-rebuilt MLX are
60+
# unchanged; the GB10 phase opts in after rebuilding MLX. RULE #1: managed memory is
61+
# the same bytes, device-resident — correctness-neutral, never a degrade.
62+
_bridge_unified = os.environ.get("CPPMEGA_FP8_BRIDGE_UNIFIED", "").strip().lower()
63+
if _bridge_unified in ("1", "true", "yes", "on") and "MLX_CUDA_BRIDGE_UNIFIED" not in os.environ:
64+
os.environ["MLX_CUDA_BRIDGE_UNIFIED"] = "1"
65+
5466
# RULE #1: fix the NVRTC loader BEFORE torch / TE are imported anywhere (gb10).
5567
# Safe no-op off-gb10. Must precede the first transformer_engine import (which
5668
# happens lazily inside fp8_te_linear when the fp8 arm runs).
@@ -103,10 +115,20 @@ def _torch_reset_peak() -> None:
103115

104116

105117
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):
118+
"""Wire the §22 shared-pool fix + budget split for the fp8 arm.
119+
120+
§22 root cause: the fp8-backward OOM is dual-pool reservation contention (MLX's
121+
AND torch/TE's cudaMallocAsync pools each reserve-and-retain the 117 GB unified
122+
pool), not activation size. The PRIMARY fix is a SINGLE shared pool; the budget
123+
knobs below are complementary bounds (NOT fallbacks):
124+
125+
(0, PRIMARY) ``install_shared_mempool()`` — route the device-current pool to
126+
MLX's default pool (``cudaDeviceSetMemPool``) so MLX and torch/TE draw from
127+
ONE pool: one reservation, no double-count. Done ONCE here AFTER torch's
128+
CUDA context is initialized (so torch's later allocations honor it). RAISES
129+
if the runtime lacks cudaDeviceSetMemPool (then the trim-both fallback in
130+
relieve_bridge_memory_pressure still runs each backward). Gated
131+
CPPMEGA_FP8_SHARED_POOL (default ON).
110132
(1) ``mx.set_cache_limit(0)`` — MLX returns freed buffers immediately so the
111133
bridge trim (``relieve_bridge_memory_pressure``) can reclaim them, instead
112134
of MLX hoarding them in its cache.
@@ -126,6 +148,9 @@ def _budget_mlx_torch() -> None:
126148
import torch
127149

128150
if torch.cuda.is_available():
151+
# Initialize torch's CUDA context so its cudaMallocAsync pool exists
152+
# BEFORE we repoint the device-current pool, then cap its share.
153+
torch.cuda.init()
129154
frac = float(os.environ.get("CPPMEGA_TORCH_MEM_FRAC", "0.45"))
130155
torch.cuda.set_per_process_memory_fraction(frac)
131156
except Exception as exc: # RULE #1: a budget-cap failure is a config bug, surface it.
@@ -135,6 +160,24 @@ def _budget_mlx_torch() -> None:
135160
"fp8 bridge needs the MLX+torch budget split to fit the GB10 unified pool."
136161
) from exc
137162

163+
# PRIMARY §22 shared-pool install (once, up front, after torch's CUDA context).
164+
from cppmega_mlx.nn._tilelang._cuda_zerocopy import (
165+
install_shared_mempool,
166+
shared_pool_enabled,
167+
)
168+
169+
if shared_pool_enabled():
170+
try:
171+
install_shared_mempool()
172+
except Exception as exc: # RULE #1: surface where+what; do NOT silently skip.
173+
raise RuntimeError(
174+
"bench_fp8_e2e: could not install the single shared cudaMemPool for "
175+
f"the fp8 arm (the §22 PRIMARY fix): {type(exc).__name__}: {exc}. "
176+
"Set CPPMEGA_FP8_SHARED_POOL=0 to fall to the trim-both path (and "
177+
"report that honestly), or rebuild MLX with the allocator shared-pool "
178+
"change if cudaDeviceSetMemPool is unavailable."
179+
) from exc
180+
138181

139182
def _run_arm(arm: str, args, tokens) -> dict:
140183
"""Run one arm (bf16 | fp8) for warmup+steps timed steps; return the metrics."""

0 commit comments

Comments
 (0)