Skip to content

Commit 597e68a

Browse files
committed
test(cuda_core): report the address-space hole structure with the reservations
The largest free range alone does not predict whether both driver pools fit: what matters is how many pool-sized reservations the free holes can hold between them, since a reservation must fit inside one hole but a hole twice the size takes two. Report that count on every run, and the hole addresses only when a reservation is refused, which also shows whether the layout is being randomized. Windows only, via VirtualQuery in a module imported only on win32; the section is omitted elsewhere. The report is now printed once, from the terminal summary.
1 parent edb149e commit 597e68a

4 files changed

Lines changed: 231 additions & 12 deletions

File tree

cuda_core/tests/conftest.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def pytest_configure(config):
6060

6161

6262
@pytest.fixture(scope="session", autouse=True)
63-
def reserve_driver_pools(request, session_setup):
63+
def reserve_driver_pools(session_setup):
6464
"""Take the driver's two large address-space reservations before anything else.
6565
6666
Session-scoped and autouse so both reservations land back to back in a
@@ -81,14 +81,8 @@ def reserve_driver_pools(request, session_setup):
8181
with _init_cuda_context() as device:
8282
_reservation_report = va_reservation.reserve_driver_pools(device)
8383

84-
terminal_reporter = request.config.pluginmanager.get_plugin("terminalreporter")
85-
if terminal_reporter is not None:
86-
# Written here rather than only in the summary so the numbers survive a
87-
# session that dies partway through.
88-
terminal_reporter.write_sep("=", "cuda_core address space reservation")
89-
for line in _reservation_report.lines():
90-
terminal_reporter.write_line(line)
91-
84+
# Reported once, from pytest_terminal_summary. On the abort path below the
85+
# message carries the same numbers, so nothing is lost by not printing here.
9286
if _reservation_report.failed:
9387
pytest.exit(va_reservation.build_failure_message(_reservation_report), returncode=1)
9488

cuda_core/tests/helpers/va_reservation.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,21 @@
3030
from __future__ import annotations
3131

3232
import os
33+
import sys
3334
import time
3435

3536
from cuda.bindings import driver
3637

38+
if sys.platform == "win32":
39+
from helpers import win_address_space
40+
else:
41+
# No Linux counterpart on purpose; see helpers/win_address_space.py.
42+
win_address_space = None
43+
44+
# Holes smaller than this are noise in the layout dump.
45+
LAYOUT_MIN_HOLE = 1024 * 1024 * 1024
46+
LAYOUT_MAX_HOLES = 6
47+
3748
MIB = 1024 * 1024
3849
GIB = 1024 * MIB
3950

@@ -130,6 +141,44 @@ def format_bytes(value: int | None) -> str:
130141
return f"{value / GIB:.2f} GiB"
131142

132143

144+
def free_holes() -> list[tuple[int, int]] | None:
145+
"""Unallocated holes as ``(size, base)``, largest first. None off Windows."""
146+
if win_address_space is None:
147+
return None
148+
return win_address_space.free_regions(LAYOUT_MIN_HOLE)
149+
150+
151+
def pool_capacity(holes, pool_bytes) -> int | None:
152+
"""How many driver pools the free holes could hold between them.
153+
154+
This is the number that decides the outcome, and it is neither the largest
155+
hole nor the count of holes that clear one pool. A reservation has to fit
156+
within a single hole, but a hole twice the size takes two -- the driver
157+
packs them from its low end, so a lone 800 GiB hole hosts both pools just
158+
as well as two 400 GiB ones. Summing each hole's capacity covers both.
159+
"""
160+
if holes is None or not pool_bytes:
161+
return None
162+
return sum(size // pool_bytes for size, _base in holes)
163+
164+
165+
def layout_lines(holes, pool_bytes, label, detail: bool = True) -> list[str]:
166+
"""Render the hole structure. Base addresses show whether it is randomized."""
167+
if holes is None:
168+
return []
169+
capacity = pool_capacity(holes, pool_bytes)
170+
headline = f"free holes {label}: {len(holes)} >= {format_bytes(LAYOUT_MIN_HOLE)}"
171+
if capacity is not None:
172+
headline += f", room for {capacity} pool(s) of {format_bytes(pool_bytes)}"
173+
out = [headline]
174+
if detail:
175+
for size, base in holes[:LAYOUT_MAX_HOLES]:
176+
out.append(f" {format_bytes(size):>14} @ {base:#018x}")
177+
if len(holes) > LAYOUT_MAX_HOLES:
178+
out.append(f" ... and {len(holes) - LAYOUT_MAX_HOLES} smaller")
179+
return out
180+
181+
133182
class Reservation:
134183
"""One driver-managed pool that has to be materialized."""
135184

@@ -211,7 +260,16 @@ class ReservationReport:
211260
"""What the early reservations cost, for the terminal."""
212261

213262
def __init__(
214-
self, device_name, device_memory, before, after, reservations, measured, seconds=0.0, unsupported=False
263+
self,
264+
device_name,
265+
device_memory,
266+
before,
267+
after,
268+
reservations,
269+
measured,
270+
seconds=0.0,
271+
unsupported=False,
272+
holes_before=None,
215273
):
216274
self.device_name = device_name
217275
self.device_memory = device_memory
@@ -221,6 +279,7 @@ def __init__(
221279
self.measured = measured
222280
self.seconds = seconds
223281
self.unsupported = unsupported
282+
self.holes_before = holes_before
224283

225284
@property
226285
def failed(self) -> list[Reservation]:
@@ -264,6 +323,11 @@ def lines(self) -> list[str]:
264323
f"remaining headroom: {self.after // pool_bytes} more pool-sized "
265324
f"({format_bytes(pool_bytes)}) reservations [{self.seconds:.1f}s measuring]"
266325
)
326+
# Just the counts here. They are what makes a successful session
327+
# comparable with a failed one, since the hole count is what decides the
328+
# outcome. The addresses behind them are only worth printing when a
329+
# reservation is actually refused; see build_failure_message.
330+
out += layout_lines(self.holes_before, self.pool_reservation_bytes, "at session start", detail=False)
267331
return out
268332

269333

@@ -285,6 +349,14 @@ def build_failure_message(report: ReservationReport) -> str:
285349
for item in report.failed:
286350
lines.append(f" {item.name} ({item.detail}): {item.error}")
287351

352+
# Each pool needs a hole of its own, so the hole structure -- not the total
353+
# free -- is what decides this. Included here because it is the first thing
354+
# anyone diagnosing a refusal will want.
355+
layout = layout_lines(report.holes_before, pool_bytes, "at session start")
356+
if layout:
357+
lines.append("")
358+
lines += [f" {line}" for line in layout]
359+
288360
return "\n".join(lines)
289361

290362

@@ -307,6 +379,7 @@ def reserve_driver_pools(device, measure: bool = True) -> ReservationReport:
307379
started = time.perf_counter()
308380
before = largest_reservable() if measured else None
309381
elapsed = time.perf_counter() - started
382+
holes_before = free_holes()
310383

311384
reservations = reservations_for(device)
312385
for item in reservations:
@@ -315,4 +388,13 @@ def reserve_driver_pools(device, measure: bool = True) -> ReservationReport:
315388
started = time.perf_counter()
316389
after = largest_reservable() if measured else None
317390
elapsed += time.perf_counter() - started
318-
return ReservationReport(device.name, device_memory, before, after, reservations, measured, elapsed)
391+
return ReservationReport(
392+
device.name,
393+
device_memory,
394+
before,
395+
after,
396+
reservations,
397+
measured,
398+
elapsed,
399+
holes_before=holes_before,
400+
)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Read this process's virtual address space layout on Windows.
5+
6+
Only imported on Windows -- see helpers/va_reservation.py, which guards the
7+
import. There is deliberately no Linux counterpart: the address-space pressure
8+
this exists to diagnose (issue #2381) is specific to the bounded per-process
9+
budget on Windows, and on Linux the budget is large enough that the layout is
10+
not interesting.
11+
12+
``cuMemAddressReserve`` probing can only report the largest single hole. That
13+
turned out to be the wrong number: two pool reservations do not need one hole
14+
twice their size, they need *two* holes, so a session can start with a smaller
15+
largest-hole and still succeed. Walking the address space shows the whole hole
16+
structure, which is what actually predicts the outcome.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import ctypes
22+
from ctypes import wintypes
23+
24+
MEM_COMMIT = 0x1000
25+
MEM_RESERVE = 0x2000
26+
MEM_FREE = 0x10000
27+
28+
# User-mode address space ceiling; walking past it wastes time and returns nothing.
29+
_USER_SPACE_LIMIT = 1 << 47
30+
31+
32+
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
33+
_fields_ = [
34+
("BaseAddress", ctypes.c_void_p),
35+
("AllocationBase", ctypes.c_void_p),
36+
("AllocationProtect", wintypes.DWORD),
37+
("PartitionId", wintypes.WORD),
38+
("__alignment", wintypes.WORD),
39+
("RegionSize", ctypes.c_size_t),
40+
("State", wintypes.DWORD),
41+
("Protect", wintypes.DWORD),
42+
("Type", wintypes.DWORD),
43+
]
44+
45+
46+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
47+
_kernel32.VirtualQuery.argtypes = [ctypes.c_void_p, ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t]
48+
_kernel32.VirtualQuery.restype = ctypes.c_size_t
49+
50+
51+
def walk():
52+
"""Yield ``(base, size, state)`` for every region in this process."""
53+
info = MEMORY_BASIC_INFORMATION()
54+
address = 0
55+
while address < _USER_SPACE_LIMIT:
56+
if not _kernel32.VirtualQuery(ctypes.c_void_p(address), ctypes.byref(info), ctypes.sizeof(info)):
57+
break
58+
size = info.RegionSize
59+
if size == 0:
60+
break
61+
yield address, size, info.State
62+
address += size
63+
64+
65+
def free_regions(min_size: int = 0) -> list[tuple[int, int]]:
66+
"""``(size, base)`` for every unallocated hole, largest first."""
67+
holes = [(size, base) for base, size, state in walk() if state == MEM_FREE and size >= min_size]
68+
holes.sort(reverse=True)
69+
return holes
70+
71+
72+
def reserved_total() -> int:
73+
"""Bytes reserved but not committed, i.e. address space held without memory."""
74+
return sum(size for _base, size, state in walk() if state == MEM_RESERVE)

cuda_core/tests/test_helpers.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,78 @@ def test_failure_message_names_the_pool_and_the_size_it_needed():
160160

161161
assert "cannot run on this machine" in message
162162
assert "graph memory pool" in message
163+
assert "cuGraphAddMemAllocNode" in message
163164
assert "357.63 GiB" in message # 2x installed device memory
164165
assert "address space" in message
165-
assert "#2381" in message
166+
# The driver's own error, so the reader is not left guessing what refused.
167+
assert "ZeroDivisionError" in message
168+
169+
170+
@pytest.mark.agent_authored(model="claude-opus-5")
171+
def test_pool_capacity_counts_two_pools_in_one_large_hole():
172+
# A reservation must fit within a single hole, but one hole twice the size
173+
# takes both pools -- the driver packs them from its low end. Counting only
174+
# the holes that clear one pool would call this a failure.
175+
pool = 358 * va_reservation.GIB
176+
one_big_hole = [(800 * va_reservation.GIB, 0x1000)]
177+
two_holes = [(400 * va_reservation.GIB, 0x1000), (400 * va_reservation.GIB, 0x2000)]
178+
179+
assert va_reservation.pool_capacity(one_big_hole, pool) == 2
180+
assert va_reservation.pool_capacity(two_holes, pool) == 2
181+
182+
183+
@pytest.mark.agent_authored(model="claude-opus-5")
184+
def test_pool_capacity_ignores_holes_that_cannot_take_a_whole_pool():
185+
# Free space that is merely plentiful does not help; it has to be
186+
# contiguous. This is the shape that fails on the affected machine.
187+
pool = 358 * va_reservation.GIB
188+
lopsided = [(700 * va_reservation.GIB, 0x1000), (300 * va_reservation.GIB, 0x2000)]
189+
190+
assert va_reservation.pool_capacity(lopsided, pool) == 1
191+
192+
193+
@pytest.mark.agent_authored(model="claude-opus-5")
194+
def test_layout_lines_are_empty_off_windows():
195+
# free_holes() returns None where VirtualQuery is unavailable; the report
196+
# must simply omit the section rather than fail.
197+
assert va_reservation.layout_lines(None, 358 * va_reservation.GIB, "before") == []
198+
assert va_reservation.pool_capacity(None, 358 * va_reservation.GIB) is None
199+
200+
201+
@pytest.mark.agent_authored(model="claude-opus-5")
202+
def test_neither_report_mentions_holes_off_windows(monkeypatch):
203+
# The hole layout comes from VirtualQuery, so it is Windows-only. Everything
204+
# else is CUDA APIs and must still be reported. Simulates the non-win32
205+
# branch of the guarded import in va_reservation.
206+
monkeypatch.setattr(va_reservation, "win_address_space", None)
207+
good = va_reservation.Reservation("default device mempool", "cuDeviceGetMemPool", lambda: None)
208+
bad = va_reservation.Reservation("graph memory pool", "cuGraphAddMemAllocNode", lambda: 1 / 0)
209+
good.run()
210+
bad.run()
211+
holes = va_reservation.free_holes()
212+
gib = va_reservation.GIB
213+
214+
report = va_reservation.ReservationReport(
215+
"dev", 25650855936, 900 * gib, 800 * gib, [good, bad], True, holes_before=holes
216+
)
217+
218+
assert holes is None
219+
assert "free holes" not in "\n".join(report.lines())
220+
message = va_reservation.build_failure_message(report)
221+
assert "free holes" not in message
222+
assert "graph memory pool" in message # the rest of the report survives
223+
224+
225+
@pytest.mark.agent_authored(model="claude-opus-5")
226+
def test_layout_lines_report_base_addresses():
227+
# The bases are the point: comparing them across launches shows whether the
228+
# layout is being randomized.
229+
holes = [(500 * va_reservation.GIB, 0x2200000000)]
230+
231+
text = "\n".join(va_reservation.layout_lines(holes, 358 * va_reservation.GIB, "before"))
232+
233+
assert "room for 1 pool(s)" in text
234+
assert "0x0000002200000000" in text
166235

167236

168237
@pytest.mark.agent_authored(model="claude-opus-5")

0 commit comments

Comments
 (0)