Skip to content

Commit c97be57

Browse files
committed
rework to the probe to establish lower bound on available address space
1 parent 2c8b0b1 commit c97be57

2 files changed

Lines changed: 115 additions & 31 deletions

File tree

cuda_core/tests/helpers/va_reservation.py

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,19 @@
4040
# cuMemAddressReserve wants a power-of-two alignment and a size that is a
4141
# multiple of it. 2 MiB is the granularity the driver uses for pools.
4242
VA_ALIGNMENT = 2 * MIB
43-
# Above any plausible per-process budget, so the descending probe below always
44-
# starts from a size that fails.
45-
MAX_PROBE_BYTES = 1 << 46
4643

4744
# Each driver-managed pool reserves about this multiple of installed device
4845
# memory. Used to express remaining headroom in units of "one more pool".
4946
POOL_RESERVATION_MULTIPLE = 2
5047

48+
# The probe only has to answer "how much room is left relative to one pool", so
49+
# it never asks for more than this many pools' worth. An absolute ceiling is the
50+
# wrong shape: 64 TiB, the previous value, is meaningless on a machine with room
51+
# to spare and hangs under WSL, where the ask crosses into the host GPU driver.
52+
PROBE_CEILING_POOLS = 4
53+
# Used when the device memory size cannot be read.
54+
DEFAULT_PROBE_CEILING_BYTES = 64 * 1024 * MIB
55+
5156

5257
def align_up(size: int) -> int:
5358
"""Round to a multiple of the reservation alignment.
@@ -76,8 +81,36 @@ def _reserve_and_release(size: int) -> bool:
7681
return True
7782

7883

79-
def largest_reservable(reserve=None, max_bytes: int = MAX_PROBE_BYTES, refine_steps: int = 4) -> int:
80-
"""Largest contiguous reservation the driver still grants.
84+
def pool_bytes_for(device_memory: int | None) -> int | None:
85+
"""Address space one driver-managed pool reserves, measured at 2x device memory."""
86+
if device_memory is None:
87+
return None
88+
return align_up(POOL_RESERVATION_MULTIPLE * device_memory)
89+
90+
91+
def probe_ceiling(device_memory: int | None) -> int:
92+
"""Largest size the probe is willing to ask for.
93+
94+
Expressed in pools rather than absolute bytes: past a few pools' worth the
95+
exact figure tells nobody anything, and asking for an enormous range is
96+
where the cost and the WSL hang live.
97+
98+
Built by multiplying the *rounded* pool size rather than rounding a multiple
99+
of device memory, so that the ceiling is a whole number of pools. Rounding
100+
each independently leaves the ceiling a hair under, and the headroom count
101+
then reports one pool fewer than it should.
102+
"""
103+
pool = pool_bytes_for(device_memory)
104+
if pool is None:
105+
return DEFAULT_PROBE_CEILING_BYTES
106+
return PROBE_CEILING_POOLS * pool
107+
108+
109+
def largest_reservable(max_bytes: int, *, reserve=None, refine_steps: int = 4) -> int:
110+
"""Largest contiguous reservation the driver still grants, up to ``max_bytes``.
111+
112+
Returns ``max_bytes`` when even that is granted, so the result is a lower
113+
bound rather than the true maximum -- which is the point, see probe_ceiling.
81114
82115
Halves down from ``max_bytes`` rather than doubling up from the granularity,
83116
because a *refused* reservation allocates nothing and returns immediately
@@ -94,15 +127,20 @@ def largest_reservable(reserve=None, max_bytes: int = MAX_PROBE_BYTES, refine_st
94127
"""
95128
reserve = _reserve_and_release if reserve is None else reserve
96129

97-
size = max_bytes
130+
# Halving has to re-align: unlike the old power-of-two ceiling, a ceiling
131+
# derived from device memory goes odd after a couple of steps, and an
132+
# unaligned ask is refused outright -- which would read as no space left.
133+
ceiling = align_up(max_bytes)
134+
size = ceiling
135+
refused = None
98136
while size >= VA_ALIGNMENT and not reserve(size):
99-
size //= 2
137+
size, refused = (size // 2 // VA_ALIGNMENT) * VA_ALIGNMENT, size
100138
if size < VA_ALIGNMENT:
101139
return 0
102-
if size == max_bytes:
103-
return size # nothing was refused, so there is no bracket to narrow
140+
if refused is None:
141+
return ceiling # granted at the ceiling, so the answer is a lower bound
104142

105-
low, high = size, size * 2 # high was refused on the way down
143+
low, high = size, refused
106144
for _ in range(refine_steps):
107145
middle = ((low + high) // 2 // VA_ALIGNMENT) * VA_ALIGNMENT
108146
if middle <= low or middle >= high:
@@ -211,7 +249,16 @@ class ReservationReport:
211249
"""What the early reservations cost, for the terminal."""
212250

213251
def __init__(
214-
self, device_name, device_memory, before, after, reservations, measured, seconds=0.0, unsupported=False
252+
self,
253+
device_name,
254+
device_memory,
255+
before,
256+
after,
257+
reservations,
258+
measured,
259+
seconds=0.0,
260+
unsupported=False,
261+
ceiling=None,
215262
):
216263
self.device_name = device_name
217264
self.device_memory = device_memory
@@ -221,24 +268,29 @@ def __init__(
221268
self.measured = measured
222269
self.seconds = seconds
223270
self.unsupported = unsupported
271+
self.ceiling = ceiling
272+
273+
def _measured_range(self, value) -> str:
274+
"""Render a probe result, flagging it as a lower bound when capped."""
275+
if value is not None and self.ceiling is not None and value >= self.ceiling:
276+
return f">= {format_bytes(value)}"
277+
return format_bytes(value)
224278

225279
@property
226280
def failed(self) -> list[Reservation]:
227281
return [item for item in self.reservations if not item.succeeded]
228282

229283
@property
230284
def pool_reservation_bytes(self) -> int | None:
231-
if self.device_memory is None:
232-
return None
233-
return align_up(POOL_RESERVATION_MULTIPLE * self.device_memory)
285+
return pool_bytes_for(self.device_memory)
234286

235287
def lines(self) -> list[str]:
236288
out = [f"device 0: {self.device_name} ({format_bytes(self.device_memory)} device memory)"]
237289
if self.unsupported:
238290
out.append("device does not support memory pools; nothing to reserve")
239291
return out
240292
if self.measured:
241-
out.append(f"largest reservable range before: {format_bytes(self.before)}")
293+
out.append(f"largest reservable range before: {self._measured_range(self.before)}")
242294
else:
243295
out.append("largest reservable range: not measured (no virtual memory management support)")
244296

@@ -247,15 +299,13 @@ def lines(self) -> list[str]:
247299
out.append(f" {item.name:<24} {item.detail:<24} {status}")
248300

249301
if self.measured:
250-
# A drop here is a *lower* bound on what was taken: the driver may
251-
# carve its reservations out of a region other than the largest
252-
# hole, in which case the largest hole does not move at all.
253-
change = None if self.before is None or self.after is None else self.before - self.after
254-
out.append(f"largest reservable range after: {format_bytes(self.after)}")
302+
out.append(f"largest reservable range after: {self._measured_range(self.after)}")
255303
pool_bytes = self.pool_reservation_bytes
256304
if pool_bytes and self.after is not None:
305+
capped = self.ceiling is not None and self.after >= self.ceiling
306+
count = f"{self.after // pool_bytes}{'+' if capped else ''}"
257307
out.append(
258-
f"remaining headroom: {self.after // pool_bytes} more pool-sized "
308+
f"remaining headroom: {count} more pool-sized "
259309
f"({format_bytes(pool_bytes)}) reservations [{self.seconds:.1f}s measuring]"
260310
)
261311
return out
@@ -273,7 +323,7 @@ def build_failure_message(report: ReservationReport) -> str:
273323
f" device 0 {report.device_name}",
274324
f" installed device memory {format_bytes(report.device_memory)}",
275325
f" needed per driver-managed pool {format_bytes(pool_bytes)} of *virtual address space*",
276-
f" largest range still available {format_bytes(report.after if report.measured else None)}",
326+
f" largest range still available {report._measured_range(report.after) if report.measured else 'unknown'}",
277327
"",
278328
]
279329
for item in report.failed:
@@ -298,15 +348,18 @@ def reserve_driver_pools(device, measure: bool = True) -> ReservationReport:
298348
return ReservationReport(device.name, device_memory, None, None, [], measured=False, unsupported=True)
299349

300350
measured = measure and vmm_supported(device.device_id)
351+
ceiling = probe_ceiling(device_memory)
301352
started = time.perf_counter()
302-
before = largest_reservable() if measured else None
353+
before = largest_reservable(ceiling) if measured else None
303354
elapsed = time.perf_counter() - started
304355

305356
reservations = reservations_for(device)
306357
for item in reservations:
307358
item.run()
308359

309360
started = time.perf_counter()
310-
after = largest_reservable() if measured else None
361+
after = largest_reservable(ceiling) if measured else None
311362
elapsed += time.perf_counter() - started
312-
return ReservationReport(device.name, device_memory, before, after, reservations, measured, elapsed)
363+
return ReservationReport(
364+
device.name, device_memory, before, after, reservations, measured, elapsed, ceiling=ceiling
365+
)

cuda_core/tests/test_helpers.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def reserve(size):
101101
@pytest.mark.agent_authored(model="claude-opus-5")
102102
def test_va_probe_finds_the_boundary():
103103
limit = 700 * va_reservation.GIB
104-
found = va_reservation.largest_reservable(reserve=_fake_reserve(limit))
104+
found = va_reservation.largest_reservable(4000 * va_reservation.GIB, reserve=_fake_reserve(limit))
105105

106106
# Refinement should land within a few percent, never above the real limit.
107107
assert found <= limit
@@ -114,15 +114,46 @@ def test_va_probe_descends_so_only_one_grant_is_paid_for():
114114
# nothing. Ascending from the granularity would pay for every rung.
115115
asked = []
116116
limit = 700 * va_reservation.GIB
117-
va_reservation.largest_reservable(reserve=_fake_reserve(limit, asked), refine_steps=0)
117+
ceiling = 4000 * va_reservation.GIB
118+
va_reservation.largest_reservable(ceiling, reserve=_fake_reserve(limit, asked), refine_steps=0)
118119

119120
assert sum(1 for size in asked if size <= limit) == 1
120-
assert asked[0] == va_reservation.MAX_PROBE_BYTES
121+
assert asked[0] == ceiling
122+
123+
124+
@pytest.mark.agent_authored(model="claude-opus-5")
125+
def test_va_probe_stops_at_the_ceiling():
126+
# The answer is a lower bound by design: past a few pools' worth the exact
127+
# figure is not worth what an enormous reservation costs to make and release.
128+
ceiling = 191 * va_reservation.GIB
129+
asked = []
130+
131+
roomy = 100 * 1024 * va_reservation.GIB
132+
found = va_reservation.largest_reservable(ceiling, reserve=_fake_reserve(roomy, asked))
133+
134+
assert found == ceiling
135+
assert asked == [ceiling] # granted first time, so nothing else is asked
136+
137+
138+
@pytest.mark.agent_authored(model="claude-opus-5")
139+
def test_probe_ceiling_is_a_whole_number_of_pools():
140+
# A fixed byte ceiling is the wrong shape: it is either meaningless on a
141+
# roomy machine or too small to see the headroom on a large-memory one.
142+
# Rounding the pool size and the ceiling independently leaves the ceiling a
143+
# hair short, and the headroom count then loses a pool -- so use a real
144+
# device memory figure, which is not a multiple of the 2 MiB granularity.
145+
device_memory = 25650855936
146+
pool = va_reservation.pool_bytes_for(device_memory)
147+
ceiling = va_reservation.probe_ceiling(device_memory)
148+
149+
assert ceiling // pool == va_reservation.PROBE_CEILING_POOLS
150+
assert ceiling % va_reservation.VA_ALIGNMENT == 0
151+
assert va_reservation.probe_ceiling(None) == va_reservation.DEFAULT_PROBE_CEILING_BYTES
121152

122153

123154
@pytest.mark.agent_authored(model="claude-opus-5")
124155
def test_va_probe_reports_zero_when_nothing_can_be_reserved():
125-
assert va_reservation.largest_reservable(reserve=_fake_reserve(0)) == 0
156+
assert va_reservation.largest_reservable(4000 * va_reservation.GIB, reserve=_fake_reserve(0)) == 0
126157

127158

128159
@pytest.mark.agent_authored(model="claude-opus-5")
@@ -131,7 +162,8 @@ def test_va_probe_only_asks_for_aligned_sizes():
131162
# ask fails with CUDA_ERROR_INVALID_VALUE at every size -- which would read
132163
# as an exhausted address space rather than as a bug here.
133164
asked = []
134-
va_reservation.largest_reservable(reserve=_fake_reserve(3 * 25650855936, asked))
165+
ceiling = va_reservation.probe_ceiling(25650855936)
166+
va_reservation.largest_reservable(ceiling, reserve=_fake_reserve(3 * 25650855936, asked))
135167

136168
assert asked and all(size % va_reservation.VA_ALIGNMENT == 0 for size in asked)
137169

@@ -184,5 +216,4 @@ def test_report_lines_cover_both_driver_pools():
184216
assert report.failed == []
185217
assert "default device mempool" in text
186218
assert "graph memory pool" in text
187-
assert "shrank by 100.00 GiB" in text
188219
assert "more pool-sized" in text

0 commit comments

Comments
 (0)