Skip to content

Commit 2ecbca6

Browse files
authored
fix(cuda.core): write NVRTC source to a temp file when debug or lineinfo is enabled (#2679)
* first version after cleaning unnecessary code * update _program.pyi * refactor(cuda.core): move NVRTC debug source helpers onto Program Keep materialize/unlink next to the Program lifetime that owns the temp file, instead of as module-level functions. * test(cuda.core): verify cuda-gdb can list materialized NVRTC debug source Skip when cuda-gdb is missing, and use shorter {caller}_{kernel}_ temp names. * test(cuda.core): cover NVRTC debug fallback when temp is unwritable * test(cuda.core): cover concurrent NVRTC debug temp-file uniqueness
1 parent 819c586 commit 2ecbca6

5 files changed

Lines changed: 226 additions & 5 deletions

File tree

‎cuda_core/cuda/core/_program.pxd‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ cdef class Program:
2020
bytes _code # Source code as bytes: used for key derivation and NVRTC PCH retry
2121
str _code_type # Normalised code_type ("c++", "ptx", "nvvm")
2222
str _pch_status # PCH creation outcome after compile
23+
bytes _nvrtc_name # Source filepath given to NVRTC; a real path for debug builds

‎cuda_core/cuda/core/_program.pyi‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ class Program:
4040
def __init__(self, code: str | bytes | bytearray, code_type: SourceCodeType | str, options: ProgramOptions | None=None): ...
4141
def close(self) -> None:
4242
"""Destroy this program."""
43+
def __dealloc__(self): ...
44+
def _cleanup_debug_source(self): ...
45+
def _unlink_debug_source(self, path: str) -> None: ...
46+
def _try_materialize_nvrtc_debug_source(self, code: str) -> str | None:
47+
"""Write *code* to a ``{caller}_{kernel}_XXXXXXXX.cu`` temp file for cuda-gdb.
48+
49+
``caller`` is the Python file stem (no ``.py``). ``kernel`` is the first
50+
``__global__`` function name, or ``kernel`` if none is found.
51+
52+
Returns None if the filesystem is not writable, so the caller can fall back
53+
to the label-only behavior instead of failing the compile.
54+
"""
4355
def compile(self, target_type: ObjectCodeFormatType | str, name_expressions: tuple[str, ...] | list[str]=(), logs: object | None=None, *, cache: ProgramCacheResource | None=None) -> ObjectCode:
4456
"""Compile the program to the specified target type.
4557

‎cuda_core/cuda/core/_program.pyx‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ This module provides :class:`Program` for compiling source code into
1010
from __future__ import annotations
1111

1212
from dataclasses import dataclass
13+
import os
14+
import re
15+
import sys
16+
import tempfile
1317
import threading
1418
from typing import TYPE_CHECKING
1519
from warnings import warn
@@ -87,6 +91,49 @@ cdef class Program:
8791
# Reset handles - the C++ shared_ptr destructor handles cleanup
8892
self._h_nvrtc.reset()
8993
self._h_nvvm.reset()
94+
self._cleanup_debug_source()
95+
96+
def __dealloc__(self):
97+
self._cleanup_debug_source()
98+
99+
def _cleanup_debug_source(self):
100+
path = self._nvrtc_name.decode()
101+
self._unlink_debug_source(path)
102+
103+
def _unlink_debug_source(self, path: str) -> None:
104+
try:
105+
os.unlink(path)
106+
except OSError:
107+
pass
108+
109+
def _try_materialize_nvrtc_debug_source(self, code: str) -> str | None:
110+
"""Write *code* to a ``{caller}_{kernel}_XXXXXXXX.cu`` temp file for cuda-gdb.
111+
112+
``caller`` is the Python file stem (no ``.py``). ``kernel`` is the first
113+
``__global__`` function name, or ``kernel`` if none is found.
114+
115+
Returns None if the filesystem is not writable, so the caller can fall back
116+
to the label-only behavior instead of failing the compile.
117+
"""
118+
frame = sys._getframe()
119+
while frame and frame.f_globals.get("__name__", "").startswith("cuda.core"):
120+
frame = frame.f_back
121+
caller = "program"
122+
if frame:
123+
caller = os.path.splitext(os.path.basename(frame.f_code.co_filename))[0]
124+
match = re.search(r"__global__.*?(\w+)\s*\(", code, re.DOTALL)
125+
prefix = re.sub(r"\W", "_", f"{caller}_{match.group(1) if match else 'kernel'}_")
126+
try:
127+
fd, path = tempfile.mkstemp(prefix=prefix, suffix=".cu")
128+
except OSError:
129+
return None
130+
try:
131+
with os.fdopen(fd, "w", encoding="utf-8") as f:
132+
f.write(code)
133+
return path
134+
except OSError:
135+
self._unlink_debug_source(path)
136+
return None
90137

91138
def compile(
92139
self,
@@ -223,7 +270,7 @@ cdef class Program:
223270
stacklevel=2,
224271
category=RuntimeWarning,
225272
)
226-
return ObjectCode._init(hit_bytes, target_type, name=self._options.name)
273+
return ObjectCode._init(hit_bytes, target_type, name=self._nvrtc_name.decode())
227274
compiled = _program_compile_uncached(self, target_type, name_expressions, logs)
228275
cache[key] = compiled
229276
return compiled
@@ -788,16 +835,22 @@ cdef inline int Program_init(Program self, object code, str code_type, object op
788835
self._libdevice_added = False
789836

790837
self._pch_status = None
838+
self._nvrtc_name = options._name
791839

792840
if code_type == "c++":
793841
assert_type(code, str)
794842
if options.extra_sources is not None:
795843
raise ValueError("extra_sources is not supported by the NVRTC backend (C++ code_type)")
796844

845+
if (options.debug or options.lineinfo) and options.name == "default_program":
846+
debug_path = self._try_materialize_nvrtc_debug_source(code)
847+
if debug_path is not None:
848+
self._nvrtc_name = debug_path.encode()
849+
797850
# TODO: support pre-loaded headers & include names
798851
code_bytes = code.encode()
799852
code_ptr = <const char*>code_bytes
800-
name_ptr = <const char*>options._name
853+
name_ptr = <const char*>self._nvrtc_name
801854

802855
with nogil:
803856
HANDLE_RETURN_NVRTC(NULL, cynvrtc.nvrtcCreateProgram(
@@ -969,7 +1022,7 @@ cdef object Program_compile_nvrtc(Program self, str target_type, object name_exp
9691022
cdef list options_list = self._options.as_bytes("nvrtc", target_type)
9701023

9711024
result = _nvrtc_compile_and_extract(
972-
prog, target_type, name_expressions, logs, options_list, self._options.name,
1025+
prog, target_type, name_expressions, logs, options_list, self._nvrtc_name.decode(),
9731026
)
9741027

9751028
cdef bint pch_creation_possible = self._options.create_pch or self._options.pch
@@ -997,14 +1050,14 @@ cdef object Program_compile_nvrtc(Program self, str target_type, object name_exp
9971050

9981051
cdef cynvrtc.nvrtcProgram retry_prog
9991052
cdef const char* code_ptr = <const char*>self._code
1000-
cdef const char* name_ptr = <const char*>self._options._name
1053+
cdef const char* name_ptr = <const char*>self._nvrtc_name
10011054
with nogil:
10021055
HANDLE_RETURN_NVRTC(NULL, cynvrtc.nvrtcCreateProgram(
10031056
&retry_prog, code_ptr, name_ptr, 0, NULL, NULL))
10041057
self._h_nvrtc = create_nvrtc_program_handle(retry_prog)
10051058

10061059
result = _nvrtc_compile_and_extract(
1007-
retry_prog, target_type, name_expressions, logs, options_list, self._options.name,
1060+
retry_prog, target_type, name_expressions, logs, options_list, self._nvrtc_name.decode(),
10081061
)
10091062

10101063
status = _read_pch_status(retry_prog)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Inferior for cuda-gdb: compile with debug=True and launch, keep Program alive."""
4+
5+
from cuda.core import Device, LaunchConfig, Program, ProgramOptions, launch
6+
7+
CODE = """
8+
extern "C" __global__ void kernel() {
9+
int x = 0;
10+
x += 1; // ISSUE_2422_SOURCE_LINE
11+
}
12+
"""
13+
14+
15+
def main() -> None:
16+
dev = Device()
17+
dev.set_current()
18+
stream = dev.create_stream()
19+
prog = Program(CODE, "c++", ProgramOptions(debug=True, arch=f"sm_{dev.arch}"))
20+
mod = prog.compile("cubin")
21+
k = mod.get_kernel("kernel")
22+
launch(stream, LaunchConfig(grid=1, block=1), k)
23+
stream.sync()
24+
25+
26+
if __name__ == "__main__":
27+
main()

‎cuda_core/tests/test_program.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
import contextlib
66
import re
7+
import shutil
8+
import subprocess
9+
import sys
710
import warnings
811

912
import pytest
@@ -989,6 +992,131 @@ def fake_find(name):
989992
assert captured == ["device"]
990993

991994

995+
@pytest.mark.agent_authored(model="cursor-grok-4.6")
996+
def test_nvrtc_debug_materializes_source_to_temp_file(init_cuda, tmp_path):
997+
"""debug/lineinfo writes NVRTC source to a real path; off and explicit name= do not."""
998+
import os
999+
1000+
code = 'extern "C" __global__ void matmul() {}'
1001+
1002+
# case 1: (debug=False, lineinfo=False)
1003+
off = Program(code, "c++", ProgramOptions(arch="sm_80"))
1004+
assert off.compile("ptx").name == "default_program"
1005+
off.close()
1006+
1007+
# case 2: (debug=True or lineinfo=True) and explicit_name is provided
1008+
explicit_name = str(tmp_path / "user_kernel.cu")
1009+
named = Program(code, "c++", ProgramOptions(name=explicit_name, debug=True, arch="sm_80"))
1010+
assert named.compile("ptx").name == explicit_name
1011+
assert not os.path.isfile(explicit_name)
1012+
named.close()
1013+
1014+
# case 3: (debug=True or lineinfo=True) and explicit_name is not provided
1015+
default_named = Program(code, "c++", ProgramOptions(debug=True, arch="sm_80"))
1016+
implicit_name = default_named.compile("ptx").name
1017+
try:
1018+
assert os.path.isfile(implicit_name)
1019+
assert re.fullmatch(r"test_program_matmul_[a-z0-9_]{8}\.cu", os.path.basename(implicit_name))
1020+
with open(implicit_name, encoding="utf-8") as fh:
1021+
assert fh.read() == code
1022+
finally:
1023+
default_named.close()
1024+
assert not os.path.isfile(implicit_name)
1025+
1026+
1027+
@pytest.mark.agent_authored(model="cursor-grok-4.6")
1028+
@pytest.mark.thread_unsafe(reason="monkeypatches tempfile.mkstemp on the Program module")
1029+
def test_nvrtc_debug_falls_back_when_tmp_not_writable(init_cuda, monkeypatch):
1030+
"""debug=True still compiles if the temp dir cannot be written (issue #2422)."""
1031+
from cuda.core import _program
1032+
1033+
def _denied(*_args, **_kwargs):
1034+
raise OSError(30, "Read-only file system")
1035+
1036+
monkeypatch.setattr(_program.tempfile, "mkstemp", _denied)
1037+
1038+
code = 'extern "C" __global__ void matmul() {}'
1039+
prog = Program(code, "c++", ProgramOptions(debug=True, arch="sm_80"))
1040+
try:
1041+
assert prog.compile("ptx").name == "default_program"
1042+
finally:
1043+
prog.close()
1044+
1045+
1046+
@pytest.mark.agent_authored(model="cursor-grok-4.6")
1047+
def test_nvrtc_debug_concurrent_compile_uses_unique_temp_files(init_cuda):
1048+
"""Same kernel compiled concurrently gets distinct mkstemp paths (issue #2422)."""
1049+
import os
1050+
import threading
1051+
from concurrent.futures import ThreadPoolExecutor
1052+
1053+
code = 'extern "C" __global__ void matmul() {}'
1054+
n = 2
1055+
barrier = threading.Barrier(n)
1056+
1057+
def _compile_one():
1058+
Device().set_current()
1059+
barrier.wait()
1060+
prog = Program(code, "c++", ProgramOptions(debug=True, arch="sm_80"))
1061+
name = prog.compile("ptx").name
1062+
return prog, name
1063+
1064+
with ThreadPoolExecutor(max_workers=n) as pool:
1065+
futures = [pool.submit(_compile_one) for _ in range(n)]
1066+
results = [fut.result() for fut in futures]
1067+
1068+
progs, names = zip(*results)
1069+
try:
1070+
assert len(set(names)) == n
1071+
for name in names:
1072+
assert os.path.isfile(name)
1073+
assert re.fullmatch(r"test_program_matmul_[a-z0-9_]{8}\.cu", os.path.basename(name))
1074+
with open(name, encoding="utf-8") as fh:
1075+
assert fh.read() == code
1076+
finally:
1077+
for prog in progs:
1078+
prog.close()
1079+
for name in names:
1080+
assert not os.path.isfile(name)
1081+
1082+
1083+
@pytest.mark.agent_authored(model="cursor-grok-4.6")
1084+
def test_cuda_gdb_shows_nvrtc_debug_source_lines(init_cuda):
1085+
import pathlib
1086+
1087+
cuda_gdb = shutil.which("cuda-gdb")
1088+
if cuda_gdb is None:
1089+
pytest.skip("cuda-gdb is not on PATH")
1090+
1091+
child = pathlib.Path(__file__).resolve().parent / "helpers" / "cuda_gdb_src.py"
1092+
proc = subprocess.run( # noqa: S603 - trusted argv: cuda-gdb + this interpreter + in-tree helper
1093+
[
1094+
cuda_gdb,
1095+
"--batch",
1096+
"-ex",
1097+
"set cuda break_on_launch application",
1098+
"-ex",
1099+
"run",
1100+
"-ex",
1101+
"list",
1102+
"--args",
1103+
sys.executable,
1104+
"-u",
1105+
str(child),
1106+
],
1107+
check=False,
1108+
capture_output=True,
1109+
text=True,
1110+
timeout=120,
1111+
)
1112+
output = (proc.stdout or "") + (proc.stderr or "")
1113+
if "Operation not permitted" in output or "ptrace" in output.lower():
1114+
pytest.skip("cuda-gdb cannot trace this process")
1115+
assert re.search(r"cuda_gdb_src_kernel_\w+\.cu", output), output
1116+
assert "ISSUE_2422_SOURCE_LINE" in output, output
1117+
assert "No such file or directory" not in output, output
1118+
1119+
9921120
def test_nvrtc_compile_with_logs_capture(init_cuda):
9931121
"""Program.compile with logs= exercises the NVRTC program-log reading path."""
9941122
import io

0 commit comments

Comments
 (0)