@@ -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
240419def 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
266463def _dlpack_device_type () -> int :
0 commit comments