33# SPDX-License-Identifier: Apache-2.0
44"""Regression test for the deferred-release retry in ``get_device_mempool``.
55
6- Issue #2381: the driver reserves virtual address space per memory pool -- on
7- Windows MCDM roughly 2x device memory against a 40-bit (1 TiB) cap -- so pools
8- that are awaiting teardown can starve the *default* pool's reservation. cuda.core
9- then reports ``CUDA_ERROR_OUT_OF_MEMORY`` on a device with ample free memory.
6+ Issue #2381: each memory pool reserves virtual address space scaling with device
7+ memory, and the per-process budget is bounded (notably ~1 TB on Windows MCDM), so
8+ pools awaiting teardown can starve the *default* pool's reservation. cuda.core then
9+ reports ``CUDA_ERROR_OUT_OF_MEMORY`` on a device with ample free memory.
1010
1111A pool's address space comes back only once the pool is destroyed, and
1212``cuMemPoolDestroy`` waits on the stream-ordered frees of the pool's outstanding
2222from __future__ import annotations
2323
2424import multiprocessing as _mp
25+ import queue
26+ import traceback
2527
2628import pytest
2729from helpers .child_processes import child_timeout_sec , kill_subprocesses
3941NOT_DEFERRED = "not-deferred"
4042RECOVERED = "recovered"
4143NOT_RECOVERED = "not-recovered"
44+ UNSUPPORTED = "unsupported"
45+ CRASHED = "crashed"
46+ TIMED_OUT = "timed-out"
4247
4348
44- def _worker_deferred_release ( result_queue ):
49+ def _run_deferred_release ( ):
4550 """Drive a default-pool lookup into deferred-release failure, then retry it.
4651
47- Reports one of the module-level outcome strings so the parent can tell a
48- genuine regression apart from a machine whose address space is too large to
49- exhaust .
52+ Returns an ``(outcome, detail)`` pair using the module-level outcome strings,
53+ so the parent can tell a genuine regression apart from a machine that cannot
54+ run this at all .
5055 """
5156 import gc
5257 import time
@@ -57,15 +62,18 @@ def _worker_deferred_release(result_queue):
5762 from cuda .core import Device , DeviceMemoryResource , DeviceMemoryResourceOptions
5863 from cuda .core ._utils .cuda_utils import CUDAError
5964
60- def default_pool_available ():
61- err , _pool = driver .cuDeviceGetMemPool (dev )
62- return err == driver .CUresult .CUDA_SUCCESS
63-
6465 device = Device (0 )
6566 device .set_current ()
67+ if not device .properties .memory_pools_supported :
68+ return UNSUPPORTED , "Device does not support mempool operations"
69+
6670 err , dev = driver .cuDeviceGet (0 )
6771 assert err == driver .CUresult .CUDA_SUCCESS , err
6872
73+ def default_pool_available ():
74+ err , _pool = driver .cuDeviceGetMemPool (dev )
75+ return err == driver .CUresult .CUDA_SUCCESS
76+
6977 # Build everything before the address space is gone; afterwards even kernel
7078 # compilation could fail for unrelated reasons.
7179 sleeper = NanosleepKernel (device , sleep_duration_ms = BLOCK_MS )
@@ -80,19 +88,22 @@ def reserve_until_oom(pool_bytes):
8088 for _ in range (MAX_POOLS_PER_PASS ):
8189 try :
8290 mr = DeviceMemoryResource (device , options = options )
91+ # An empty pool is torn down immediately, which is the one case
92+ # that cannot defer anything, so hold an allocation to force
93+ # teardown to wait. Exhaustion frequently surfaces here rather
94+ # than at construction, so both must share this guard -- an
95+ # unguarded allocate kills the whole child process.
96+ buffer = mr .allocate (1024 , stream = dealloc_stream )
8397 except (CUDAError , RuntimeError ):
8498 return
85- # An empty pool is torn down immediately, which is the one case that
86- # cannot defer anything. Hold an allocation so teardown must wait.
87- buffers .append (mr .allocate (1024 , stream = dealloc_stream ))
8899 pools .append (mr )
100+ buffers .append (buffer )
89101
90102 reserve_until_oom (POOL_BYTES_COARSE )
91103 reserve_until_oom (POOL_BYTES_FINE )
92104
93105 if default_pool_available ():
94- result_queue .put ((NO_EXHAUSTION , len (pools )))
95- return
106+ return NO_EXHAUSTION , len (pools )
96107
97108 # Stall the deallocation stream behind a slow kernel on another stream, so
98109 # the frees below cannot retire until the context is drained.
@@ -105,8 +116,7 @@ def reserve_until_oom(pool_bytes):
105116
106117 if default_pool_available ():
107118 # Release outran us; there was no deferred window to recover from.
108- result_queue .put ((NOT_DEFERRED , len (pools )))
109- return
119+ return NOT_DEFERRED , None
110120
111121 # The raw lookup just failed, so anything the retried lookup achieves below
112122 # is attributable to the retry itself -- no cross-build comparison needed.
@@ -116,9 +126,20 @@ def reserve_until_oom(pool_bytes):
116126 try :
117127 DeviceMemoryResource (device )
118128 except (CUDAError , RuntimeError ) as exc :
119- result_queue .put ((NOT_RECOVERED , repr (exc )))
120- return
121- result_queue .put ((RECOVERED , time .perf_counter () - started ))
129+ return NOT_RECOVERED , repr (exc )
130+ return RECOVERED , time .perf_counter () - started
131+
132+
133+ def _worker_deferred_release (result_queue ):
134+ """Always report an outcome, so a crash surfaces as a diagnosis.
135+
136+ Without this the parent would block until its timeout and raise a bare
137+ ``queue.Empty``, hiding whatever actually went wrong in the child.
138+ """
139+ try :
140+ result_queue .put (_run_deferred_release ())
141+ except BaseException :
142+ result_queue .put ((CRASHED , traceback .format_exc ()))
122143
123144
124145@pytest .mark .agent_authored (model = "claude-opus-5" )
@@ -130,16 +151,20 @@ def test_default_mempool_lookup_recovers_from_deferred_release():
130151 proc .start ()
131152 try :
132153 outcome , detail = result_queue .get (timeout = child_timeout_sec ())
154+ except queue .Empty :
155+ outcome , detail = TIMED_OUT , f"child produced no result within { child_timeout_sec ()} s"
133156 finally :
134157 proc .join (timeout = child_timeout_sec ())
135158 survivors = kill_subprocesses (proc )
136159 assert not survivors , "child process did not exit"
137160
161+ if outcome == UNSUPPORTED :
162+ pytest .skip (detail )
138163 if outcome == NO_EXHAUSTION :
139164 pytest .skip (f"could not exhaust the address space; reserved { detail } pools" )
140165 if outcome == NOT_DEFERRED :
141166 pytest .skip ("pool release completed too quickly to leave a deferred window" )
142- assert outcome == RECOVERED , f"default pool lookup did not recover : { detail } "
167+ assert outcome == RECOVERED , f"{ outcome } : { detail } "
143168 # Recovery had to wait out the blocking kernel. Returning much faster would
144169 # mean the lookup succeeded for some reason other than draining the context,
145170 # leaving this test passing without exercising the retry.
0 commit comments