Skip to content

Commit 82c320b

Browse files
committed
fix(§24): fp8 e2e backward OOM CLEARED — torch set_per_process_memory_fraction poisoned the ALREADY-shared cudaMallocAsync pool (no MLX rebuild)
MEASURED root cause (refutes §22 'two pools double-reserve'): on this MLX 0.32.0.dev+6680d75a + torch build, the device CURRENT cudaMallocAsync pool ALREADY EQUALS the device DEFAULT pool and BOTH MLX and torch/TE draw from it (2GB torch + 2GB MLX -> same pool reserved 2->4GB; 40GB MLX + 10GB torch -> 50GB on ONE pool, no double-count). cudaDeviceSetMemPool is a no-op repoint here. The real blocker was torch.cuda.set_per_process_memory_fraction flipping the SHARED pool ReleaseThreshold 0->UINT64_MAX (retain-everything) + a hard per-process ceiling that MLX's mx.eval growth (same pool) tripped, OOMing at fp8_te_linear.py:321 even at seq128/frac0.95. torch ALSO re-poisons the threshold to UINT64_MAX on its first alloc after we zero it. FIX (Python-only, no rebuild): - bench: drop set_per_process_memory_fraction when the shared pool is active (cap only valid in the dual-pool fallback CPPMEGA_FP8_SHARED_POOL=0). - _cuda_zerocopy: re-assert ReleaseThreshold=0 on EVERY bridge-boundary trim (not once) so torch's re-poisoning is undone before each backward and cudaMemPoolTrimTo can reclaim idle reserved memory. MEASURED (gb10 sm_121, bs1 floor0, GRAPH_CACHE=4000): the fp8 backward now COMPLETES (EXIT 0) — all 271 per-Linear crossings + final eval + AdamW. fp8 e2e 7.4 tok/s no-opt / 5.4 tok/s +AdamW vs bf16 ref 117.6 tok/s @SEQ64; MLX peak 40.75GB no-opt / 54.43GB +opt with mlx_active==torch_reserved== pool_reserved (ONE reservation, no double-count); loss parity fp8 11.256 vs bf16 11.345 (delta -0.089), +AdamW loss 11.25->7.398 (grads flowed). Zero-copy PRESERVED: native kDLCUDA export path, torch-view write -> MLX sum 224.0 proves shared storage; no host-copy fallback (RULE #1). Residual: fp8 is 16x slower at floor0 (bridge-crossing-bound, not GEMM); §24 documents it.
1 parent b4fb0bd commit 82c320b

3 files changed

Lines changed: 158 additions & 38 deletions

File tree

cppmega_mlx/nn/_tilelang/_cuda_zerocopy.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -145,22 +145,28 @@ def trim_cuda_mempool(device: int | None = None) -> None:
145145
)
146146
device = int(dev.value)
147147
pool = _default_mempool(rt, device)
148-
# Set the release threshold to 0 ONCE per device so the driver does not retain
149-
# reserved memory across the trim (default threshold is UINT64_MAX = retain all).
150-
if device not in _RELEASE_THRESHOLD_SET:
151-
thresh = ctypes.c_uint64(0)
152-
err = rt.cudaMemPoolSetAttribute(
153-
pool,
154-
_CUDA_MEMPOOL_ATTR_RELEASE_THRESHOLD,
155-
ctypes.cast(ctypes.byref(thresh), ctypes.c_void_p),
148+
# Set the release threshold to 0 on EVERY call (NOT once) so the trim actually
149+
# returns reserved-but-idle memory. §24 MEASURED: torch's cudaMallocAsync backend
150+
# RE-SETS this shared default pool's threshold to UINT64_MAX (retain-everything)
151+
# on its FIRST allocation after we zero it, defeating a one-time set. Because the
152+
# MLX default pool IS the device-current pool that torch also allocates from
153+
# (§24: current==default, both grow the same ReservedMemCurrent), torch keeps
154+
# re-poisoning the one shared pool. Re-asserting 0 here (a cheap idempotent driver
155+
# call) right before the trim undoes torch's retain-everything so cudaMemPoolTrimTo
156+
# can reclaim idle reserved memory before the competing fp8-backward eval.
157+
thresh = ctypes.c_uint64(0)
158+
err = rt.cudaMemPoolSetAttribute(
159+
pool,
160+
_CUDA_MEMPOOL_ATTR_RELEASE_THRESHOLD,
161+
ctypes.cast(ctypes.byref(thresh), ctypes.c_void_p),
162+
)
163+
if err != 0:
164+
raise RuntimeError(
165+
f"_cuda_zerocopy: cudaMemPoolSetAttribute(ReleaseThreshold=0, "
166+
f"dev={device}) failed (cudaError={err}); cannot bound the CUDA "
167+
"mempool reservation for the fp8 bridge."
156168
)
157-
if err != 0:
158-
raise RuntimeError(
159-
f"_cuda_zerocopy: cudaMemPoolSetAttribute(ReleaseThreshold=0, "
160-
f"dev={device}) failed (cudaError={err}); cannot bound the CUDA "
161-
"mempool reservation for the fp8 bridge."
162-
)
163-
_RELEASE_THRESHOLD_SET.add(device)
169+
_RELEASE_THRESHOLD_SET.add(device)
164170
err = rt.cudaMemPoolTrimTo(pool, ctypes.c_size_t(0))
165171
if err != 0:
166172
raise RuntimeError(
@@ -314,18 +320,21 @@ def trim_device_current_mempool(device: int | None = None) -> None:
314320
)
315321
device = int(dev.value)
316322
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+
# §24: re-assert threshold=0 on EVERY call (torch re-poisons it to UINT64_MAX per
324+
# allocation; see trim_cuda_mempool). When the shared-pool fix is active this is
325+
# the SAME pool as trim_cuda_mempool's — a harmless second re-assert; when not
326+
# active it is torch's distinct pool.
327+
thresh = ctypes.c_uint64(0)
328+
err = rt.cudaMemPoolSetAttribute(
329+
pool,
330+
_CUDA_MEMPOOL_ATTR_RELEASE_THRESHOLD,
331+
ctypes.cast(ctypes.byref(thresh), ctypes.c_void_p),
332+
)
333+
if err != 0:
334+
raise RuntimeError(
335+
f"_cuda_zerocopy: cudaMemPoolSetAttribute(ReleaseThreshold=0) on the "
336+
f"device-current pool (dev={device}) failed (cudaError={err})."
323337
)
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-
)
329338
err = rt.cudaMemPoolTrimTo(pool, ctypes.c_size_t(0))
330339
if err != 0:
331340
raise RuntimeError(

docs/RELAX-GRAPH-VS-MEGATRON.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,3 +1885,86 @@ running it in the model would only confirm a slowdown.
18851885
un-fused F0/F1/F2 chain (30.0 ms, now N=64-clean) remains the production path. RULE #1: the fused path
18861886
stays env-gated OFF and RAISES on tile/smem/compile failure — it never silently replaces the faster
18871887
un-fused chain.
1888+
1889+
1890+
## §24. fp8 E2E BACKWARD OOM CLEARED — the §22 "dual-pool" premise was WRONG (MLX+torch already share ONE cudaMallocAsync pool); the real blocker was torch's `set_per_process_memory_fraction` POISONING that shared pool. fp8 e2e step now COMPLETES (MEASURED, gb10 sm_121, 2026-06-04)
1891+
1892+
Single exclusive GB10 owner, serial; ownership poll + drop_caches before/after, SIGTERM-only.
1893+
This closes the §21/§22 lever-1 **e2e NO-GO** ("R1-e2e fp8 backward is a memory-NO-GO … needs the
1894+
shared-pool MLX rebuild"). It needed **no MLX rebuild** — the fix is Python-only, and the §22
1895+
root-cause diagnosis was partly wrong. All numbers below are MEASURED.
1896+
1897+
### MEASURED root cause (the §22 "two pools double-reserve" premise does NOT hold on this build)
1898+
Direct ctypes probe of the CUDA runtime (`libcudart.so`, MLX `0.32.0.dev20260602+6680d75a` + torch
1899+
`cudaMallocAsync` backend):
1900+
- The device's **CURRENT** cudaMallocAsync pool **already EQUALS the device DEFAULT pool**
1901+
(`cudaDeviceGetMemPool == cudaDeviceGetDefaultMemPool`), so `cudaDeviceSetMemPool(dev, mlx_default)`
1902+
is a **no-op repoint** — there was never a second device-current pool to merge.
1903+
- **Both** allocators already draw from that ONE pool: a 2 GB torch alloc then a 2 GB MLX alloc grow
1904+
the SAME pool's `ReservedMemCurrent` 2→4 GB; a 40 GB MLX + 10 GB torch co-allocation reaches **50 GB
1905+
on the one pool with NO double-count**. So the "MLX pool + torch pool each reserve-and-retain" model
1906+
from §21/§22 is **refuted** for this MLX+torch combination — there is structurally already one shared
1907+
pool.
1908+
- The ACTUAL blocker: **`torch.cuda.set_per_process_memory_fraction(F)` POISONS the shared pool.**
1909+
MEASURED, that call flips the shared default pool's `cudaMemPoolAttrReleaseThreshold` `0 →
1910+
UINT64_MAX` (retain-everything) AND installs a hard per-process ceiling. Because MLX's `mx.eval`
1911+
draws from the SAME pool, MLX's deep-backward growth trips torch's ceiling and OOMs at
1912+
`fp8_te_linear.py:321 mx.eval(x2, weight, g2)`**even at seq128 with frac 0.95**. Worse, torch's
1913+
cudaMallocAsync backend **RE-SETS that threshold to UINT64_MAX on its FIRST allocation** after we
1914+
zero it, so a one-time `ReleaseThreshold=0` is silently clobbered during the fp8 forward.
1915+
1916+
### THE FIX (Python-only, no MLX rebuild)
1917+
1. **Drop the `set_per_process_memory_fraction` cap when the shared pool is active**
1918+
(`scripts/bench_fp8_e2e_step_20260604.py` `_budget_mlx_torch`): the cap only makes sense in the
1919+
genuine dual-pool fallback (`CPPMEGA_FP8_SHARED_POOL=0`); with the one shared pool it starves MLX.
1920+
2. **Re-assert `ReleaseThreshold=0` on EVERY bridge-boundary trim, not once**
1921+
(`cppmega_mlx/nn/_tilelang/_cuda_zerocopy.py` `trim_cuda_mempool` / `trim_device_current_mempool`):
1922+
torch keeps re-poisoning the shared pool to retain-everything, so the threshold must be re-zeroed
1923+
each backward so `cudaMemPoolTrimTo(0)` can actually reclaim idle reserved memory.
1924+
3. `install_shared_mempool()` stays as the up-front (idempotent) **ReleaseThreshold=0 re-assert** after
1925+
torch's CUDA init — the `cudaDeviceSetMemPool` part is a confirmed no-op here, but the threshold
1926+
re-assert is load-bearing.
1927+
1928+
### MEASURED result — the fp8 backward COMPLETES (no `cudaMallocAsync … out of memory`)
1929+
`scripts/bench_fp8_e2e_step_20260604.py`, bs1, `--fp8-floor 0` (fp8 forced on EVERY wired Linear — the
1930+
stress case the §22 OOM hit), `MLX_CUDA_GRAPH_CACHE_SIZE=4000`, `CPPMEGA_FP8_SHARED_POOL=1`:
1931+
1932+
| arm | seq | optimizer | step s (median) | tok/s | MLX peak GB | torch peak / reserved GB | final loss |
1933+
| --- | --- | --- | --- | --- | --- | --- | --- |
1934+
| **bf16 ref** | 64 | no | 0.544 | **117.6** | 6.878 | 0.0 | 11.345 |
1935+
| **fp8 e2e** | 64 | no | 8.66 | **7.4** | 40.754 | 40.656 / 40.75 | 11.25562 |
1936+
| **fp8 e2e** | 64 | **yes (AdamW)** | 11.75 | **5.4** | 54.43 | 54.332 / 54.469 | 7.398 |
1937+
1938+
- **OOM CLEARED:** all 271 per-Linear fp8 backward crossings + the final `mx.eval(loss, grads)` (and
1939+
the full AdamW update) complete with **EXIT 0**. Traced memory grows monotonically to ~43.7 GB
1940+
(no-opt) / 54.4 GB (opt) and **stays under the 117 GB part** at seq64. The §22 NO-GO is now a **GO**
1941+
at a safe config.
1942+
- **ONE shared pool, no double-count (the proof):** at the fp8 peak, `mlx_active ≈ torch_reserved ≈
1943+
pool_reserved ≈ 40.7 GB` (no-opt) / `≈ 54.4 GB` (opt) — all three counters track the SAME number, i.e.
1944+
a single reservation feeding both allocators, not two summed pools. The release threshold stayed
1945+
`thr=0` across every backward (the re-assert holds against torch's re-poisoning).
1946+
- **Parity:** fp8 final loss 11.25562 vs bf16 11.345 (Δ −0.08938) at the un-optimized step; with the
1947+
AdamW update the fp8 loss drops 11.25 → 7.398, confirming grads flowed end-to-end (finite, applied).
1948+
- **Zero-copy PRESERVED (RULE #1):** the bridge took the **native kDLCUDA export** path
1949+
(`_mlx_native_cuda_dlpack_available == True``torch.from_dlpack(arr)`), a real `cuda:0` device view.
1950+
Proof of SHARED storage: writing 7.0 through the torch view changed the source MLX array's sum to
1951+
exactly 224.0 (4×8×7). No host copy, no `.cpu()`, no fallback — the path RAISES on a genuinely
1952+
uncrossable tensor.
1953+
1954+
### Residual (honest)
1955+
The fp8 e2e step is **16× SLOWER than bf16** at this config (7.4 vs 117.6 tok/s) — NOT because of the
1956+
GEMM (lever 2 showed the tuned fp8 MMA is 1.17–1.55× bf16) but because `--fp8-floor 0` forces fp8 on
1957+
EVERY tiny Linear, so 271 per-Linear MLX↔torch bridge crossings + TE fp8 quant/dequant per backward
1958+
dominate the step. The realistic config (a sane floor that only fp8s the big GEMMs) was not the goal
1959+
here — the goal was to **clear the OOM and prove the backward completes**, which it does. The memory
1960+
footprint also grows ~linearly with seq and with the number of fp8 sites (seq128 floor0 still peaks at
1961+
~119 GB and OOMs — just over the part); seq64 floor0 is the MEASURED safe config. The next lever is to
1962+
(a) raise the fp8 floor so only the cost-effective GEMMs cross the bridge, and (b) batch the per-Linear
1963+
backward crossings so the deep graph materializes incrementally instead of accumulating to tens of GB.
1964+
1965+
### GO / NO-GO
1966+
- **GO: the §22 e2e fp8-backward OOM is FIXED, Python-only, no MLX rebuild.** Backward + optimizer
1967+
complete; shared pool confirmed single-reservation; zero-copy preserved.
1968+
- **NO-GO (unchanged): fp8 e2e as a tok/s WIN at floor0.** 16× slower (bridge-crossing-bound, not
1969+
GEMM-bound). The realized e2e tok/s win still awaits floor-tuning + crossing-batching; §17's
1970+
≈907/≈298 stays the canonical bf16 step number.

scripts/bench_fp8_e2e_step_20260604.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -144,28 +144,56 @@ def _budget_mlx_torch() -> None:
144144
set_cache = getattr(mx, "set_cache_limit", None)
145145
if callable(set_cache):
146146
set_cache(0)
147+
148+
from cppmega_mlx.nn._tilelang._cuda_zerocopy import (
149+
install_shared_mempool,
150+
shared_pool_enabled,
151+
)
152+
153+
# §24 MEASURED root cause: on this MLX(0.32.0.dev+6680d75a)+torch build the
154+
# device's CURRENT cudaMallocAsync pool ALREADY EQUALS the device DEFAULT pool,
155+
# and BOTH MLX's and torch/TE's plain cudaMallocAsync(&p,size,stream) draw from
156+
# it (MEASURED: a 2 GB torch alloc + a 2 GB MLX alloc grow the SAME default-pool
157+
# ReservedMemCurrent 2->4 GB; a 40 GB MLX + 10 GB torch co-allocation reaches 50
158+
# GB on the one pool with NO double-count). So the §22 "two pools double-reserve"
159+
# premise does NOT hold here — there is already ONE shared pool.
160+
#
161+
# The persistent fp8-backward OOM was NOT dual-pool contention; it was
162+
# ``torch.cuda.set_per_process_memory_fraction(F)`` POISONING that shared pool:
163+
# MEASURED, the fraction call flips the SHARED default pool's
164+
# cudaMemPoolAttrReleaseThreshold 0 -> UINT64_MAX (retain-everything) AND installs
165+
# a hard per-process ceiling that MLX's ``mx.eval`` growth — drawing from the SAME
166+
# pool — trips, OOMing at fp8_te_linear.py:321 even at seq128/frac0.95. Dropping
167+
# the fraction cap and re-asserting ReleaseThreshold=0 lets the shared pool grow
168+
# for both allocators (MEASURED: the fp8 backward then COMPLETES). The fraction
169+
# split is therefore ONLY meaningful in the genuine dual-pool fallback
170+
# (CPPMEGA_FP8_SHARED_POOL=0); with the shared pool it starves MLX. RULE #1: this
171+
# is not a degrade — it removes a self-inflicted cap so the ONE zero-copy path FITS.
147172
try:
148173
import torch
149174

150175
if torch.cuda.is_available():
151176
# Initialize torch's CUDA context so its cudaMallocAsync pool exists
152-
# BEFORE we repoint the device-current pool, then cap its share.
177+
# BEFORE we (re)assert the shared-pool release threshold below.
153178
torch.cuda.init()
154-
frac = float(os.environ.get("CPPMEGA_TORCH_MEM_FRAC", "0.45"))
155-
torch.cuda.set_per_process_memory_fraction(frac)
179+
if not shared_pool_enabled():
180+
# DUAL-POOL FALLBACK only: cap torch's share so the two distinct pools
181+
# fit. With the shared pool this cap poisons the one pool (see above),
182+
# so it is skipped.
183+
frac = float(os.environ.get("CPPMEGA_TORCH_MEM_FRAC", "0.45"))
184+
torch.cuda.set_per_process_memory_fraction(frac)
156185
except Exception as exc: # RULE #1: a budget-cap failure is a config bug, surface it.
157186
raise RuntimeError(
158-
"bench_fp8_e2e: could not apply the torch CUDA memory-fraction budget for "
159-
f"the fp8 arm (CPPMEGA_TORCH_MEM_FRAC): {type(exc).__name__}: {exc}. The "
160-
"fp8 bridge needs the MLX+torch budget split to fit the GB10 unified pool."
187+
"bench_fp8_e2e: could not apply the torch CUDA memory budget for the fp8 "
188+
f"arm: {type(exc).__name__}: {exc}. The fp8 bridge needs the MLX+torch "
189+
"pool config to fit the GB10 unified pool."
161190
) from exc
162191

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-
192+
# PRIMARY §22/§24 shared-pool install (once, up front, AFTER torch's CUDA context
193+
# so it re-asserts ReleaseThreshold=0 on the shared pool even if torch's init
194+
# bumped it to UINT64_MAX). install_shared_mempool() is a no-op repoint here (the
195+
# current pool already IS the default pool) BUT its ReleaseThreshold=0 re-assert is
196+
# the load-bearing step that undoes any retain-everything torch left behind.
169197
if shared_pool_enabled():
170198
try:
171199
install_shared_mempool()

0 commit comments

Comments
 (0)