Skip to content

Commit fa0e9e9

Browse files
committed
Account for missing mempool support and harden test
1 parent eba218c commit fa0e9e9

1 file changed

Lines changed: 48 additions & 23 deletions

File tree

cuda_core/tests/test_mempool_oom_retry.py

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
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
1111
A 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
@@ -22,6 +22,8 @@
2222
from __future__ import annotations
2323

2424
import multiprocessing as _mp
25+
import queue
26+
import traceback
2527

2628
import pytest
2729
from helpers.child_processes import child_timeout_sec, kill_subprocesses
@@ -39,14 +41,17 @@
3941
NOT_DEFERRED = "not-deferred"
4042
RECOVERED = "recovered"
4143
NOT_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

Comments
 (0)