Skip to content

Commit 9ee75ba

Browse files
committed
ci: pinned-memory diagnostics with isolated wheel imports
Run dedicated Windows L4 diagnostics for pinned-memory failures using the wheels selected by CI. Launch pytest in isolated Python processes so the source checkout cannot shadow the installed cuda.core wheel and cause the missing cuda.core._version import error. Verify wheel imports, reject all-skipped diagnostic runs, and preserve per-case logs, JUnit results, and first-OOM reports. Validation: 46 targeted tests passed; Ruff and actionlint passed. GPU validation pending CDCI.
1 parent af9017a commit 9ee75ba

5 files changed

Lines changed: 966 additions & 2 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,34 @@ jobs:
531531
nruns: ${{ (github.event_name == 'schedule' && 5) || 1}}
532532
workplan: ${{ needs.detect-changes.outputs.workplan }}
533533

534+
# Dedicated fresh-process reproductions of the Windows host-pool failures.
535+
# The normal Windows matrix still supplies full bindings-major coverage.
536+
test-windows-pinned-memory:
537+
name: Pinned-memory diagnostics
538+
if: ${{ github.repository_owner == 'nvidia' &&
539+
!fromJSON(needs.should-skip.outputs.doc-only) &&
540+
needs.detect-changes.outputs.platform-windows == 'true' &&
541+
fromJSON(needs.detect-changes.outputs.workplan).modules.core.cuda_majors.cu13.needs_test }}
542+
permissions:
543+
actions: read
544+
contents: read
545+
needs:
546+
- should-skip
547+
- detect-changes
548+
- build-linux-64
549+
- build-windows
550+
secrets: inherit
551+
uses: ./.github/workflows/test-wheel-windows.yml
552+
with:
553+
build-type: pull-request
554+
host-platform: win-64
555+
bindings-config: ${{ needs.detect-changes.outputs.bindings-config }}
556+
workplan: ${{ needs.detect-changes.outputs.workplan }}
557+
test-mode: diagnostic-pinned-memory
558+
matrix_filter: >-
559+
map(select(.PY_VER == "3.14" and .CUDA_VER == "13.5.0" and
560+
.LOCAL_CTK == "1" and .GPU == "l4" and .GPU_COUNT == "1" and .DRIVER_MODE == "MCDM"))
561+
534562
doc:
535563
name: Docs
536564
if: ${{ github.repository_owner == 'nvidia' }}
@@ -569,13 +597,15 @@ jobs:
569597
- test-linux-64
570598
- test-linux-aarch64
571599
- test-windows
600+
- test-windows-pinned-memory
572601
- api-check-core-vs-release
573602
- api-check-core-vs-base
574603
- doc
575604
steps:
576605
- name: Exit
577606
env:
578607
NEEDS_JSON: ${{ toJSON(needs) }}
608+
CORE_CU13_TEST: ${{ fromJSON(needs.detect-changes.outputs.workplan || '{}').modules.core.cuda_majors.cu13.needs_test }}
579609
run: |
580610
# GitHub treats `result == 'skipped'` as success for required
581611
# status checks (see CCCL gate comment + cccl#605). The previous
@@ -646,6 +676,12 @@ jobs:
646676
check_result "test-linux-aarch64" "$linux_expected"
647677
check_result "test-windows" "$windows_expected"
648678
679+
expected="skipped"
680+
if [[ "$windows_expected" == "success" && "$CORE_CU13_TEST" != "false" ]]; then
681+
expected="success"
682+
fi
683+
check_result "test-windows-pinned-memory" "$expected"
684+
649685
# API compatibility checks run for cuda_core source changes and for
650686
# conservative full runs when reusable base artifacts are unavailable.
651687
expected="skipped"

‎.github/workflows/test-wheel-windows.yml‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ on:
4444
test-mode:
4545
description: >
4646
Test mode: 'standard' (default), 'nightly-pytorch',
47-
'nightly-numba-cuda', 'nightly-numba-cuda-mlir', or
48-
'nightly-cuda-core'.
47+
'nightly-numba-cuda', 'nightly-numba-cuda-mlir',
48+
'nightly-cuda-core', or 'diagnostic-pinned-memory'.
4949
type: string
5050
default: 'standard'
5151
sha:
@@ -475,6 +475,32 @@ jobs:
475475
shell: bash --noprofile --norc -xeuo pipefail {0}
476476
run: run-tests pathfinder
477477

478+
# Collect only to install the same wheel/dependencies as the standard
479+
# Core job, without running tests before the fresh-process diagnostics.
480+
- name: Install pinned-memory diagnostic dependencies
481+
if: ${{ inputs.test-mode == 'diagnostic-pinned-memory' }}
482+
env:
483+
CUDA_VER: ${{ matrix.CUDA_VER }}
484+
LOCAL_CTK: ${{ matrix.LOCAL_CTK }}
485+
PYTEST_ADDOPTS: --collect-only
486+
SKIP_CYTHON_TEST: '1'
487+
shell: bash --noprofile --norc -xeuo pipefail {0}
488+
run: run-tests core
489+
490+
- name: Diagnose pinned-memory failures
491+
if: ${{ inputs.test-mode == 'diagnostic-pinned-memory' }}
492+
shell: bash --noprofile --norc -xeuo pipefail {0}
493+
run: python .ci-control/ci/tools/diagnose_pinned_memory.py --cycles 100 --output-dir pinned-memory-diagnostics
494+
495+
- name: Upload pinned-memory diagnostics
496+
if: ${{ !cancelled() && inputs.test-mode == 'diagnostic-pinned-memory' }}
497+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
498+
with:
499+
name: pinned-memory-diagnostics-${{ inputs.host-platform }}-py${{ matrix.PY_VER }}-cu${{ matrix.CUDA_VER }}-${{ matrix.GPU }}-${{ matrix.DRIVER_MODE }}
500+
path: pinned-memory-diagnostics/
501+
if-no-files-found: error
502+
retention-days: 14
503+
478504
# ── Nightly: install wheels + optional dep together ──
479505
- name: Install Visual C++ Redistributable (required by PyTorch on Windows)
480506
if: ${{ inputs.test-mode == 'nightly-pytorch' }}

‎ci/tools/diagnose_pinned_memory.py‎

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Pinned-memory diagnostics: fresh processes, unfolded logs, and first failures.
5+
6+
Run after installing the core wheel and test dependencies. The CI workflow owns
7+
the overall timeout and child-process cleanup. Each pytest child uses isolated
8+
Python startup so the unbuilt cuda_core checkout cannot shadow the wheel.
9+
"""
10+
11+
import argparse
12+
import importlib
13+
import importlib.metadata
14+
import json
15+
import os
16+
import pathlib
17+
import platform
18+
import shutil
19+
import subprocess
20+
import sys
21+
import time
22+
from datetime import UTC, datetime
23+
24+
CASE_NAMES = (
25+
"isolated-watermarks",
26+
"pinned-kernel-access",
27+
"owned-reserved",
28+
"owned-used",
29+
"warmed-owned-reserved",
30+
"warmed-owned-used",
31+
"full-core-without-current-kernel",
32+
)
33+
OOM_FILENAME = "cuda_core_oom_diagnostics.txt"
34+
35+
36+
def build_cases(root, output, cycles):
37+
core = root / "cuda_core"
38+
cases = []
39+
40+
def pytest_case(name, selectors, count, selection=None):
41+
command = [
42+
sys.executable,
43+
"-I",
44+
"-u",
45+
str(pathlib.Path(__file__).resolve()),
46+
"--pytest-child",
47+
str(output / (name + "-pytest.json")),
48+
*selectors,
49+
f"--count={count}",
50+
"--repeat-scope=session",
51+
"--parallel-threads=1",
52+
"--randomly-dont-reorganize",
53+
"--maxfail=1",
54+
"--tb=long",
55+
"-vv",
56+
"-s",
57+
f"--junitxml={output / (name + '.xml')}",
58+
]
59+
if selection is not None:
60+
command.extend(["-k", selection])
61+
cases.append({"name": name, "cwd": str(core), "command": command, "kind": "pytest"})
62+
63+
pytest_case(
64+
"isolated-watermarks",
65+
["tests/test_memory.py::test_mempool_watermark_reset"],
66+
cycles,
67+
"PinnedMR",
68+
)
69+
pytest_case("pinned-kernel-access", ["tests/test_memory.py::test_pinned_memory_resource_kernel_access"], 1)
70+
for mode, prefix in (("owned-capped", "owned"), ("warmed-owned-capped", "warmed-owned")):
71+
for watermark in ("reserved", "used"):
72+
name = f"{prefix}-{watermark}"
73+
raw_output = output / name
74+
command = [
75+
sys.executable,
76+
"-I",
77+
"-u",
78+
str(pathlib.Path(__file__).with_name("pinned_pool_probe.py").resolve()),
79+
"--mode",
80+
mode,
81+
"--host-id",
82+
"-1",
83+
"--cycles",
84+
str(cycles),
85+
"--watermark",
86+
watermark,
87+
"--output-dir",
88+
str(raw_output),
89+
]
90+
if mode == "warmed-owned-capped":
91+
command.append("--allow-warmup-allocation-oom")
92+
cases.append(
93+
{"name": name, "cwd": str(root), "command": command, "kind": "raw", "raw_output": str(raw_output)}
94+
)
95+
pytest_case(
96+
"full-core-without-current-kernel",
97+
["tests/"],
98+
1,
99+
"not (test_pinned_memory_resource_kernel_access and current)",
100+
)
101+
return cases
102+
103+
104+
def run_case(case, output, env):
105+
log_path = output / (case["name"] + ".log")
106+
report_path = (
107+
output / (case["name"] + "-pytest.json")
108+
if case["kind"] == "pytest"
109+
else pathlib.Path(case["raw_output"]) / "summary.json"
110+
)
111+
report_path.unlink(missing_ok=True)
112+
started = time.monotonic()
113+
print(f"Starting {case['name']}: {json.dumps(case['command'])}", flush=True)
114+
with (
115+
log_path.open("w", encoding="utf-8", buffering=1) as log,
116+
subprocess.Popen( # noqa: S603 - fixed diagnostic argv, passed without a shell.
117+
case["command"],
118+
cwd=case["cwd"],
119+
env=env,
120+
stdout=subprocess.PIPE,
121+
stderr=subprocess.STDOUT,
122+
text=True,
123+
encoding="utf-8",
124+
errors="replace",
125+
bufsize=1,
126+
) as process,
127+
):
128+
for line in process.stdout:
129+
sys.stdout.write(line)
130+
sys.stdout.flush()
131+
log.write(line)
132+
log.flush()
133+
returncode = process.wait()
134+
result = {
135+
**case,
136+
"log_path": str(log_path),
137+
"returncode": returncode,
138+
"elapsed_seconds": time.monotonic() - started,
139+
}
140+
result["failed"] = returncode != 0
141+
try:
142+
report = json.loads(report_path.read_text(encoding="utf-8"))
143+
result["report"] = report
144+
if case["kind"] == "pytest":
145+
result["failed"] |= not report["completed"] or report["counts"]["passed"] == 0
146+
else:
147+
result["failed"] |= (
148+
not report["required_path_completed"] or report["completed_cycles"] != report["requested_cycles"]
149+
)
150+
except (OSError, ValueError, KeyError) as exc:
151+
result["artifact_error"] = str(exc)
152+
result["failed"] = True
153+
if case["kind"] == "pytest":
154+
oom = pathlib.Path(case["cwd"]) / OOM_FILENAME
155+
if oom.exists():
156+
destination = output / (case["name"] + "-" + OOM_FILENAME)
157+
shutil.copy2(oom, destination)
158+
oom.unlink()
159+
result["first_oom_path"] = str(destination)
160+
print(f"Finished {case['name']}: returncode={returncode}, elapsed={result['elapsed_seconds']:.2f}s", flush=True)
161+
return result
162+
163+
164+
def run_pytest_child(report_path, pytest_args):
165+
"""Import the installed wheel before pytest adds the test helper directories."""
166+
report = {"completed": False, "phase": "wheel-import", "counts": {"passed": 0, "failed": 0, "skipped": 0}}
167+
report_path = pathlib.Path(report_path)
168+
try:
169+
if not sys.flags.isolated:
170+
raise RuntimeError("The pytest child must run with python -I")
171+
distribution = importlib.metadata.distribution("cuda-core")
172+
installed_core = pathlib.Path(distribution.locate_file("cuda/core")).resolve()
173+
origins = {}
174+
for name in ("cuda.core", "cuda.core._version"):
175+
module = importlib.import_module(name)
176+
origin = pathlib.Path(module.__file__).resolve()
177+
if not origin.is_relative_to(installed_core):
178+
raise RuntimeError(f"{name} resolved to {origin}, outside the installed wheel at {installed_core}")
179+
origins[name] = str(origin)
180+
report["wheel"] = {"version": distribution.version, "origins": origins}
181+
print(f"Installed wheel preflight: {json.dumps(report['wheel'])}", flush=True)
182+
183+
import pytest
184+
185+
class Outcomes:
186+
def pytest_runtest_logreport(self, report):
187+
if report.failed:
188+
counts["failed"] += 1
189+
elif report.skipped:
190+
counts["skipped"] += 1
191+
elif report.when == "call" and report.passed:
192+
counts["passed"] += 1
193+
194+
counts = report["counts"]
195+
report["phase"] = "pytest"
196+
returncode = int(pytest.main(pytest_args, plugins=[Outcomes()]))
197+
if returncode == 0 and counts["passed"] == 0:
198+
print("Diagnostic failed: no test call passed (all tests skipped or collection only).", flush=True)
199+
returncode = 1
200+
report.update(completed=True, returncode=returncode)
201+
return returncode
202+
finally:
203+
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
204+
205+
206+
def main():
207+
parser = argparse.ArgumentParser(description=__doc__)
208+
parser.add_argument("--repo-root", type=pathlib.Path, default=pathlib.Path.cwd())
209+
parser.add_argument("--output-dir", type=pathlib.Path, default=pathlib.Path("pinned-memory-diagnostics"))
210+
parser.add_argument("--cycles", type=int, default=100)
211+
parser.add_argument("--case", choices=CASE_NAMES, action="append", help="Run only these cases; may be repeated.")
212+
args = parser.parse_args()
213+
if not 1 <= args.cycles <= 100:
214+
parser.error("--cycles must be between 1 and 100")
215+
216+
root = args.repo_root.resolve()
217+
output = args.output_dir.resolve()
218+
output.mkdir(parents=True, exist_ok=True)
219+
env = os.environ.copy()
220+
env.update(PYTEST_ADDOPTS="", PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
221+
versions = {}
222+
for package in (
223+
"cuda-core",
224+
"cuda-bindings",
225+
"cuda-pathfinder",
226+
"pytest",
227+
"pytest-repeat",
228+
"pytest-run-parallel",
229+
"numpy",
230+
):
231+
try:
232+
versions[package] = importlib.metadata.version(package)
233+
except importlib.metadata.PackageNotFoundError:
234+
versions[package] = "not installed"
235+
summary = {
236+
"started_at": datetime.now(UTC).isoformat(),
237+
"completed": False,
238+
"interpreter": sys.executable,
239+
"python_version": sys.version,
240+
"platform": platform.platform(),
241+
"package_versions": versions,
242+
"pytest_addopts": env["PYTEST_ADDOPTS"],
243+
"cycles": args.cycles,
244+
"requested_cases": args.case or list(CASE_NAMES),
245+
"cases": [],
246+
}
247+
summary_path = output / "summary.json"
248+
summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
249+
# Keep an older artifact separate so it cannot masquerade as this run's OOM.
250+
existing_oom = root / "cuda_core" / OOM_FILENAME
251+
if existing_oom.exists():
252+
shutil.move(existing_oom, output / ("preexisting-" + OOM_FILENAME))
253+
for case in build_cases(root, output, args.cycles):
254+
if args.case is not None and case["name"] not in args.case:
255+
continue
256+
summary["cases"].append(run_case(case, output, env))
257+
summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
258+
summary["completed"] = True
259+
summary["finished_at"] = datetime.now(UTC).isoformat()
260+
summary["returncode"] = int(any(case["failed"] for case in summary["cases"]))
261+
summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
262+
print(f"Summary: {summary_path}; overall returncode={summary['returncode']}", flush=True)
263+
return summary["returncode"]
264+
265+
266+
if __name__ == "__main__":
267+
if sys.argv[1:2] == ["--pytest-child"]:
268+
raise SystemExit(run_pytest_child(sys.argv[2], sys.argv[3:]))
269+
raise SystemExit(main())

0 commit comments

Comments
 (0)