Skip to content

Commit c33def2

Browse files
Alok-Joshiclaude
andauthored
Add experimental CUPTI Backend (#68)
* Add experimental CUPTI Backend Signed-off-by: Alok Joshi <aljoshi@nvidia.com> * Add CI job for testing CUPTI backend Rename the existing NCU test job to test-ncu and add a test-cupti job on both amd64 and arm64 GPU runners that runs the suite with --tool-init=cupti to exercise the experimental CUPTI backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Alok Joshi <aljoshi@nvidia.com> --------- Signed-off-by: Alok Joshi <aljoshi@nvidia.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 275b203 commit c33def2

18 files changed

Lines changed: 989 additions & 95 deletions

‎.github/workflows/ci.yaml‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ jobs:
3434
pip install hatch
3535
hatch run docs:build
3636
37-
test:
38-
name: test (${{ matrix.platform.runner }})
37+
test-ncu:
38+
name: test-ncu (${{ matrix.platform.runner }})
3939
runs-on: ${{ matrix.platform.runner }}
4040
strategy:
4141
matrix:
@@ -60,3 +60,21 @@ jobs:
6060
run: |
6161
pip install hatch
6262
hatch run test_cu13:run
63+
64+
test-cupti:
65+
name: test-cupti (${{ matrix.platform.runner }})
66+
runs-on: ${{ matrix.platform.runner }}
67+
strategy:
68+
matrix:
69+
platform:
70+
- runner: linux-amd64-gpu-h100-latest-1
71+
- runner: linux-arm64-gpu-l4-latest-1
72+
container:
73+
image: nvcr.io/nvidia/pytorch:26.06-py3
74+
options: "--cap-add=SYS_ADMIN --gpus all"
75+
steps:
76+
- uses: actions/checkout@v5
77+
- name: "Running Tests (CUPTI backend)"
78+
run: |
79+
pip install hatch
80+
hatch run test_cu13:run -- --tool-init=cupti

‎conftest.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import os
5+
6+
import pytest
7+
8+
# How each --tool-init mode sets nsight up, as
9+
# (NSPY_NCU_INIT_AT_IMPORT, tool to activate in pytest_configure).
10+
_INIT_MODES = {
11+
# nsight activates NCU itself while `import nsight` runs.
12+
"ncu-at-import": ("1", None),
13+
# Nothing is active up front, so nsight.analyze.kernel activates NCU on the
14+
# first call to a decorated function.
15+
"ncu-lazy": ("0", None),
16+
# CUPTI is activated here instead; nsight must not claim NCU at import.
17+
"cupti": ("0", "cupti"),
18+
}
19+
20+
21+
def pytest_addoption(parser: pytest.Parser) -> None:
22+
parser.addoption(
23+
"--tool-init",
24+
default="ncu-at-import",
25+
choices=sorted(_INIT_MODES),
26+
help=(
27+
"How the nsight tool is initialized for the suite. 'ncu-at-import' "
28+
"(the default) lets nsight activate NCU while it is imported. "
29+
"'ncu-lazy' leaves no tool active so the lazy NCU load in "
30+
"nsight.analyze.kernel is exercised on the first decorated call. "
31+
"'cupti' activates CUPTI before the suite runs. Only one tool can be "
32+
"active per process, so this mode fully determines both "
33+
"NSPY_NCU_INIT_AT_IMPORT and which tool is activated."
34+
),
35+
)
36+
37+
38+
def pytest_configure(config: pytest.Config) -> None:
39+
init_at_import, tool_name = _INIT_MODES[config.getoption("--tool-init")]
40+
41+
# Set before nsight is imported: it reads this while importing, which is why
42+
# the import below is here rather than at module scope. pytest_configure
43+
# still runs before any test module is imported, so anything activated here
44+
# is ahead of CUDA initialization.
45+
os.environ["NSPY_NCU_INIT_AT_IMPORT"] = init_at_import
46+
47+
import nsight
48+
49+
if tool_name is not None:
50+
nsight.activate(nsight.Tool(tool_name))

‎examples/test_examples.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@
55

66
import pytest
77

8+
import nsight
9+
10+
_cupti_active = nsight.get_active_tool() == nsight.Tool.CUPTI
11+
_skip_cupti = pytest.mark.skipif(_cupti_active, reason="metric not supported in cupti")
12+
813

914
def test_00_minimal() -> None:
1015
minimal = importlib.import_module("examples.00_minimal")
1116
minimal.main()
1217

1318

19+
@_skip_cupti # type: ignore[untyped-decorator]
1420
def test_01_compare_throughput() -> None:
1521
compare_throughput = importlib.import_module("examples.01_compare_throughput")
1622
compare_throughput.main()
@@ -47,21 +53,25 @@ def test_07_triton_minimal() -> None:
4753
triton_minimal.main()
4854

4955

56+
@_skip_cupti # type: ignore[untyped-decorator]
5057
def test_08_multiple_metrics() -> None:
5158
multiple_metrics = importlib.import_module("examples.08_multiple_metrics")
5259
multiple_metrics.main()
5360

5461

62+
@_skip_cupti # type: ignore[untyped-decorator]
5563
def test_09_advanced_metric_custom() -> None:
5664
advanced_custom = importlib.import_module("examples.09_advanced_metric_custom")
5765
advanced_custom.main()
5866

5967

68+
@_skip_cupti # type: ignore[untyped-decorator]
6069
def test_10_combine_kernel_metrics() -> None:
6170
combine_metrics = importlib.import_module("examples.10_combine_kernel_metrics")
6271
combine_metrics.main()
6372

6473

74+
@_skip_cupti # type: ignore[untyped-decorator]
6575
def test_11_output_csv() -> None:
6676
output_csv = importlib.import_module("examples.11_output_csv")
6777
output_csv.main()

‎nsight/analyze.py‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import nsight.collection as collection
1818
import nsight.visualization as visualization
1919
from nsight.info_collector import CollectionScope, InfoCollector
20+
from nsight.tools_manager import Tool, activate, get_active_tool
2021
from nsight.utils import VerbosityLevel
2122

2223

@@ -106,7 +107,23 @@ def kernel(
106107
| Callable[[Callable[..., None]], Callable[..., collection.core.ProfileResults]]
107108
):
108109
"""
109-
A decorator that collects profiling data using NVIDIA Nsight Compute.
110+
A decorator that collects profiling data from GPU kernels.
111+
112+
**Tool selection**
113+
114+
By default, ``@nsight.analyze.kernel`` profiles using NVIDIA Nsight Compute
115+
(NCU). NCU's injection library is loaded automatically on the first call to
116+
a decorated function, but it **must** be loaded before CUDA is initialized.
117+
This means the first decorated function call must occur before any CUDA-initializing code.
118+
To guarantee early loading and avoid this constraint, call ``nsight.activate(nsight.Tool.NCU)``
119+
explicitly at the very top of your script, before any CUDA imports.
120+
121+
CUPTI is also supported *(experimental)* for lightweight collection of
122+
``"gpu__time_duration.sum"`` using kernel timestamps, without launching
123+
``ncu``. To enable it, call ``nsight.activate(nsight.Tool.CUPTI)`` before
124+
profiling. CUPTI does not have the CUDA initialization ordering constraint.
125+
See :class:`~nsight.collection.cupti.CUPTICollector` for supported metrics
126+
and limitations.
110127
111128
Can be used with or without parentheses:
112129
- ``@nsight.analyze.kernel`` (no parentheses)
@@ -159,7 +176,7 @@ def wrapped_function(*args, configs=None, **kwargs) -> ProfileResults
159176
derive_metric:
160177
A function to transform the collected metrics.
161178
This can be used to compute derived metrics like TFLOPs that cannot
162-
be captured by ncu directly. The function takes the metric values and
179+
be captured directly. The function takes the metric values and
163180
the arguments of the profile-decorated function and returns the new
164181
metrics. Return value can be either:
165182
@@ -182,7 +199,7 @@ def wrapped_function(*args, configs=None, **kwargs) -> ProfileResults
182199
Combine with ``derive_metric`` to compute speedup (reciprocal of normalized value).
183200
metrics: The metrics to collect. By default, kernel runtimes in nanoseconds
184201
are collected. Default: ``["gpu__time_duration.sum"]``. To see the available
185-
metrics on your system, use the command: ``ncu --query-metrics``.
202+
metrics on your system when using ncu, use the command: ``ncu --query-metrics``.
186203
ignore_kernel_list:
187204
List of kernel names to ignore. If you call a library within an annotated range context, you might not have precise control over which and how many kernels are being launched.
188205
If some of these kernels should be ignored in the profile, their names can be provided in this parameter. Default: ``None``
@@ -364,6 +381,22 @@ def my_benchmark(n, dtype):
364381
# Strip whitespace
365382
metrics = [m.strip() for m in metrics]
366383

384+
# These are decorator parameters, so reject illegal values now rather than
385+
# when the decorated function is first called. Whether the active tool
386+
# supports a legal value is a separate check, made by the collector.
387+
if clock_control not in ("base", "none"):
388+
raise ValueError(
389+
f"Invalid clock_control {clock_control!r}. Expected 'base' or 'none'."
390+
)
391+
if cache_control not in ("all", "none"):
392+
raise ValueError(
393+
f"Invalid cache_control {cache_control!r}. Expected 'all' or 'none'."
394+
)
395+
if replay_mode not in ("kernel", "range"):
396+
raise ValueError(
397+
f"Invalid replay_mode {replay_mode!r}. Expected 'kernel' or 'range'."
398+
)
399+
367400
# Validate thermal parameters if both are provided
368401
if thermal_wait is not None and thermal_cont is not None:
369402
if thermal_cont <= thermal_wait:
@@ -461,15 +494,26 @@ def _canonical_scope(scope: Any, label: str) -> str:
461494
output_csv=output_csv,
462495
info_collectors=normalized_collectors,
463496
)
464-
ncu = collection.ncu.NCUCollector(
465-
metrics=metrics,
466-
ignore_kernel_list=ignore_kernel_list,
467-
combine_kernel_metrics=combine_kernel_metrics,
468-
clock_control=clock_control,
469-
cache_control=cache_control,
470-
replay_mode=replay_mode,
471-
)
472-
return collection.core.NsightProfiler(settings, ncu)
497+
498+
def get_collector() -> collection.core.NsightCollector:
499+
if get_active_tool() == Tool.CUPTI:
500+
# clock_control, cache_control and replay_mode have no
501+
# meaning for CUPTI, so the collector does not take them.
502+
return collection.cupti.CUPTICollector(
503+
metrics=metrics,
504+
ignore_kernel_list=ignore_kernel_list,
505+
combine_kernel_metrics=combine_kernel_metrics,
506+
)
507+
return collection.ncu.NCUCollector(
508+
metrics=metrics,
509+
ignore_kernel_list=ignore_kernel_list,
510+
combine_kernel_metrics=combine_kernel_metrics,
511+
clock_control=clock_control,
512+
cache_control=cache_control,
513+
replay_mode=replay_mode,
514+
)
515+
516+
return collection.core.NsightProfiler(settings, get_collector)
473517

474518
# Support both @kernel and @kernel() syntax
475519
if _func is None:

‎nsight/collection/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import functools
55

66
import nsight.collection.core as core
7+
import nsight.collection.cupti as cupti
78
import nsight.collection.ncu as ncu
89
import nsight.utils as utils
910

10-
__all__ = ["ncu", "core"]
11+
__all__ = ["ncu", "cupti", "core"]

‎nsight/collection/core.py‎

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ class ProfileSettings:
633633
"""
634634
A function to transform the collected metrics.
635635
This can be used to compute derived metrics like TFLOPs that cannot
636-
be captured by ncu directly. The function takes the metric values and
636+
be captured directly. The function takes the metric values and
637637
the arguments of the profile-decorated function and returns the new
638638
metrics. See the examples for concrete use cases.
639639
"""
@@ -807,11 +807,14 @@ class NsightProfiler:
807807
a collector to gather raw profiling data and processes it according to
808808
the provided settings.
809809
810-
Attributes:
811-
settings: Configuration settings for profiling,
812-
including normalization, and other options.
813-
collector: The collector responsible for gathering
814-
raw profiling data.
810+
Args:
811+
settings: Configuration settings for profiling (runs, verbosity,
812+
derive_metric, output prefix, etc.).
813+
get_collector: Factory returning the collector to profile with. It is
814+
called lazily, the first time a decorated function runs, and the
815+
result is reused for the lifetime of this profiler, so the
816+
collector matches whichever tool is active by then rather than at
817+
decoration time.
815818
816819
Methods:
817820
__call__(func):
@@ -820,14 +823,24 @@ class NsightProfiler:
820823
Returns the processed data.
821824
"""
822825

823-
def __init__(self, settings: ProfileSettings, collector: NsightCollector):
826+
def __init__(
827+
self,
828+
settings: ProfileSettings,
829+
get_collector: Callable[[], NsightCollector],
830+
) -> None:
824831
self.settings = settings
825-
self.collector = collector
832+
self._get_collector_fn = get_collector
833+
self._collector: NsightCollector | None = None
834+
835+
def _get_collector(self) -> NsightCollector:
836+
if self._collector is None:
837+
self._collector = self._get_collector_fn()
838+
return self._collector
826839

827840
def __call__(
828841
self, func: Callable[..., None]
829842
) -> Callable[..., ProfileResults | None]:
830-
func._nspy_ncu_run_id = 0 # type: ignore[attr-defined]
843+
func._nspy_run_id = 0 # type: ignore[attr-defined]
831844

832845
# Fail fast (before starting the profiling session) on collector names
833846
# that would silently clobber profiler columns or function-arg columns.
@@ -843,7 +856,7 @@ def wrapper(
843856
**kwargs: Any,
844857
) -> ProfileResults | None:
845858

846-
tag = f"{func.__name__}-{func._nspy_ncu_run_id}" # type: ignore[attr-defined]
859+
tag = f"{func.__name__}-{func._nspy_run_id}" # type: ignore[attr-defined]
847860

848861
configs = _sanitize_configs(
849862
func,
@@ -853,7 +866,7 @@ def wrapper(
853866
**kwargs,
854867
)
855868

856-
raw_df = self.collector.collect(func, configs, self.settings)
869+
raw_df = self._get_collector().collect(func, configs, self.settings)
857870

858871
# Check if the function has a return type
859872
if raw_df is not None:
@@ -891,7 +904,7 @@ def wrapper(
891904
f"[NSIGHT-PYTHON] Refer to {processed_csv_path} for the processed profiling data"
892905
)
893906

894-
func._nspy_ncu_run_id += 1 # type: ignore[attr-defined]
907+
func._nspy_run_id += 1 # type: ignore[attr-defined]
895908

896909
return ProfileResults(results=processed)
897910

0 commit comments

Comments
 (0)