Skip to content

Commit c6429f0

Browse files
authored
Merge branch 'main' into test/api-docs-consistency-2326
2 parents b69022b + 55cb344 commit c6429f0

3 files changed

Lines changed: 171 additions & 47 deletions

File tree

‎cuda_core/tests/graph/test_graph_definition_lifetime.py‎

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -611,48 +611,97 @@ def test_user_object_cleanup_is_coalesced_on_python_thread(init_cuda):
611611

612612

613613
@pytest.mark.agent_authored(model="gpt-5.6")
614-
def test_pending_call_queue_saturation_preserves_cleanup(init_cuda):
614+
def test_pending_call_queue_saturation_preserves_cleanup(tmp_path):
615615
"""A full CPython queue neither strands nor mis-threads cleanup."""
616-
pending_callback_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
617-
add_pending_call = ctypes.pythonapi.Py_AddPendingCall
618-
add_pending_call.argtypes = [pending_callback_type, ctypes.c_void_p]
619-
add_pending_call.restype = ctypes.c_int
616+
code = f"timeout = {_FINALIZE_TIMEOUT!r}\n" + textwrap.dedent(
617+
"""
618+
import ctypes
619+
import gc
620+
import threading
621+
import time
622+
623+
from cuda.core import Device
624+
from cuda.core.graph import GraphDefinition
620625
621-
@pending_callback_type
622-
def noop_pending_call(_):
623-
return 0
626+
class ThreadRecordingCallback:
627+
def __init__(self, finalized_threads):
628+
self.finalized_threads = finalized_threads
624629
625-
finalized_threads = []
626-
main_thread = threading.get_ident()
627-
first_callback = _ThreadRecordingCallback(finalized_threads)
628-
first_graph = GraphDefinition()
629-
first_graph.callback(first_callback)
630-
graph_holder = [first_graph]
631-
worker_done = threading.Event()
632-
queue_was_full = []
630+
def __call__(self):
631+
pass
632+
633+
def __del__(self):
634+
self.finalized_threads.append(threading.get_ident())
635+
636+
def wait_until(predicate):
637+
deadline = time.monotonic() + timeout
638+
while True:
639+
gc.collect()
640+
if predicate():
641+
return
642+
if time.monotonic() >= deadline:
643+
break
644+
time.sleep(0)
645+
time.sleep(0.02)
646+
raise AssertionError(f"condition not satisfied within {timeout}s")
647+
648+
Device(0).set_current()
649+
650+
pending_callback_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p)
651+
add_pending_call = ctypes.pythonapi.Py_AddPendingCall
652+
add_pending_call.argtypes = [pending_callback_type, ctypes.c_void_p]
653+
add_pending_call.restype = ctypes.c_int
654+
make_pending_calls = ctypes.pythonapi.Py_MakePendingCalls
655+
make_pending_calls.argtypes = []
656+
make_pending_calls.restype = ctypes.c_int
657+
658+
@pending_callback_type
659+
def noop_pending_call(_):
660+
return 0
661+
662+
finalized_threads = []
663+
main_thread = threading.get_ident()
664+
first_callback = ThreadRecordingCallback(finalized_threads)
665+
first_graph = GraphDefinition()
666+
first_graph.callback(first_callback)
667+
graph_holder = [first_graph]
668+
worker_done = threading.Event()
669+
queue_was_full = []
670+
671+
del first_callback, first_graph
672+
673+
def fill_queue_and_destroy():
674+
while add_pending_call(noop_pending_call, None) == 0:
675+
pass
676+
queue_was_full.append(True)
677+
graph_holder.clear()
678+
worker_done.set()
633679
634-
del first_callback, first_graph
680+
worker = threading.Thread(target=fill_queue_and_destroy)
681+
worker.start()
682+
assert worker_done.wait(timeout=5)
683+
worker.join()
684+
assert queue_was_full == [True]
635685
636-
def fill_queue_and_destroy():
637-
while add_pending_call(noop_pending_call, None) == 0:
638-
pass
639-
queue_was_full.append(True)
640-
graph_holder.clear()
641-
worker_done.set()
642-
643-
worker = threading.Thread(target=fill_queue_and_destroy)
644-
worker.start()
645-
assert worker_done.wait(timeout=5)
646-
worker.join()
647-
assert queue_was_full == [True]
648-
649-
# A later safe cuda-core close retries after the main thread has had an
650-
# opportunity to drain the foreign pending calls.
651-
retry_builder = Device().create_graph_builder()
652-
retry_builder.close()
653-
654-
_wait_until(lambda: len(finalized_threads) == 1)
655-
assert set(finalized_threads) == {main_thread}
686+
# Free space before the cuda-core entry that retries cleanup scheduling.
687+
assert make_pending_calls() == 0
688+
689+
retry_builder = Device().create_graph_builder()
690+
retry_builder.close()
691+
692+
wait_until(lambda: len(finalized_threads) == 1)
693+
assert set(finalized_threads) == {main_thread}
694+
"""
695+
)
696+
result = subprocess.run( # noqa: S603 - controlled interpreter probe
697+
[sys.executable, "-c", code],
698+
capture_output=True,
699+
text=True,
700+
timeout=60,
701+
# Isolate the process-global pending-call queue from parallel tests.
702+
cwd=tmp_path,
703+
)
704+
assert result.returncode == 0, result.stderr
656705

657706

658707
@pytest.mark.agent_authored(model="gpt-5.6")

‎cuda_core/tests/test_helpers.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import time
66

77
import pytest
8-
from helpers import IS_WINDOWS, IS_WSL
98
from helpers.buffers import PatternGen, compare_equal_buffers, make_scratch_buffer
109
from helpers.latch import LatchKernel
1110
from helpers.logging import TimestampedLogger
@@ -35,9 +34,9 @@ def test_latchkernel():
3534
target.copy_from(ones, stream=stream)
3635
log("going to sleep")
3736
time.sleep(1)
38-
if not IS_WINDOWS and not IS_WSL:
39-
# On any sort of Windows system, checking the memory before stream
40-
# sync results in a page error.
37+
if device.properties.concurrent_managed_access:
38+
# Host access to managed memory while a kernel is active is unsafe on
39+
# devices without concurrent managed access.
4140
log("checking target == 0")
4241
assert compare_equal_buffers(target, zeros)
4342
log("releasing latch and syncing")

‎toolshed/check_cython_abi.py‎

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import ctypes
3939
import importlib
4040
import json
41+
import re
4142
import sys
4243
import sysconfig
4344
from io import StringIO
@@ -114,6 +115,9 @@ def _format_base_type_name(bt: object) -> str:
114115
if cls == "CComplexBaseTypeNode":
115116
inner = _format_base_type_name(bt.base_type)
116117
return _unwrap_declarator(inner, bt.declarator)[0]
118+
if cls == "CConstOrVolatileTypeNode":
119+
# Discard const/volatile qualifiers; they don't affect ABI layout
120+
return _format_base_type_name(bt.base_type)
117121
return cls
118122

119123

@@ -263,6 +267,62 @@ def get_structs(module: object) -> dict:
263267
return dict(sorted(structs.items()))
264268

265269

270+
_OLD_ANON_RE = re.compile(r"^anon_(struct|union)\d+$")
271+
_NEW_ANON_RE = re.compile(r"^\w+__anon_pod\d+$")
272+
273+
274+
def _is_anon_name(name: str) -> bool:
275+
return bool(_OLD_ANON_RE.match(name)) or bool(_NEW_ANON_RE.match(name))
276+
277+
278+
def _normalize_type(type_str: str, rename_map: dict) -> str:
279+
"""Replace old anon_struct/union names in a type string using rename_map."""
280+
for old, new in rename_map.items():
281+
# Use word-boundary matching so e.g. "anon_struct1" doesn't corrupt "anon_struct12"
282+
type_str = re.sub(r"\b" + re.escape(old) + r"\b", new, type_str)
283+
return type_str
284+
285+
286+
def _build_anon_rename_map(expected: dict, found: dict) -> dict:
287+
"""Match old anon_struct/union names to new MODULE__anon_pod names by field content.
288+
289+
Works iteratively bottom-up: leaf anon structs (whose fields contain no anon
290+
type references) are matched first. Their old->new mapping is then used to
291+
normalize the field types of the remaining unmatched anon structs, which may
292+
allow the next round to match parent structs that embed them. Repeats until
293+
no new matches are found in a round.
294+
"""
295+
old_anons = {name: info for name, info in expected.items() if _is_anon_name(name)}
296+
new_pods = {name: info for name, info in found.items() if _is_anon_name(name)}
297+
298+
rename_map = {}
299+
matched_pods = set()
300+
unmatched_old = dict(old_anons)
301+
302+
while True:
303+
matched_this_round = {}
304+
305+
for old_name, old_info in unmatched_old.items():
306+
# Normalize this struct's field types using mappings found so far
307+
normalized_fields = [[_normalize_type(f[0], rename_map), f[1]] for f in old_info.get("fields", [])]
308+
for new_name, new_info in new_pods.items():
309+
if new_name in matched_pods:
310+
continue
311+
if normalized_fields == new_info.get("fields", []):
312+
matched_this_round[old_name] = new_name
313+
matched_pods.add(new_name)
314+
break
315+
316+
if not matched_this_round:
317+
break # No progress; remaining old anons have no content-matching pod
318+
319+
rename_map.update(matched_this_round)
320+
for name in matched_this_round:
321+
del unmatched_old[name]
322+
323+
return rename_map
324+
325+
266326
def _report_field_changes(name: str, expected_fields: list, found_fields: list) -> None:
267327
"""Print detailed field-level differences for a struct."""
268328
expected_dict = {f[1]: f[0] for f in expected_fields}
@@ -289,12 +349,22 @@ def check_structs(expected: dict, found: dict) -> tuple[bool, bool]:
289349
has_errors = False
290350
has_allowed_changes = False
291351

352+
rename_map = _build_anon_rename_map(expected, found)
353+
renamed_new = set(rename_map.values())
354+
292355
for name, expected_info in expected.items():
293-
if name not in found:
356+
effective_name = rename_map.get(name, name)
357+
358+
if effective_name not in found:
294359
print(f" Missing struct/class: {name}")
295360
has_errors = True
296361
continue
297-
found_info = found[name]
362+
363+
if effective_name != name:
364+
# Anon struct/union renamed to new-style anon_pod — allowed change
365+
has_allowed_changes = True
366+
367+
found_info = found[effective_name]
298368

299369
if "basicsize" in expected_info:
300370
if "basicsize" not in found_info:
@@ -310,12 +380,15 @@ def check_structs(expected: dict, found: dict) -> tuple[bool, bool]:
310380
if "fields" not in found_info:
311381
print(f" Struct {name}: field information no longer available")
312382
has_errors = True
313-
elif found_info["fields"] != expected_info["fields"]:
314-
_report_field_changes(name, expected_info["fields"], found_info["fields"])
315-
has_errors = True
383+
else:
384+
# Normalize old anon type names in expected field types before comparing
385+
normalized_fields = [[_normalize_type(f[0], rename_map), f[1]] for f in expected_info["fields"]]
386+
if found_info["fields"] != normalized_fields:
387+
_report_field_changes(name, normalized_fields, found_info["fields"])
388+
has_errors = True
316389

317390
for name in found:
318-
if name not in expected:
391+
if name not in expected and name not in renamed_new:
319392
print(f" Added struct/class: {name}")
320393
has_allowed_changes = True
321394

@@ -426,6 +499,9 @@ def generate(package: str, abi_dir: Path) -> bool:
426499

427500
build_dir = get_package_path(package)
428501
for so_path in Path(build_dir).glob(f"**/*{EXT_SUFFIX}"):
502+
if any(x.startswith("_") for x in so_path.parts):
503+
# Skip private modules (e.g. _driver.so) since they are not part of the public ABI
504+
continue
429505
try:
430506
module = import_from_path(package, build_dir, so_path)
431507
except ImportError:

0 commit comments

Comments
 (0)