fix: modernize nsight graphics capture compatibility

This commit is contained in:
aimidi
2026-04-19 01:40:31 +08:00
parent de78798f60
commit ec955b13e8
6 changed files with 229 additions and 58 deletions
@@ -58,7 +58,7 @@ cli-anything-nsight-graphics ^
```bash
cli-anything-nsight-graphics launch detached ^
--activity "Frame Debugger" ^
--activity "Graphics Capture" ^
--exe "C:\VulkanSDK\1.3.290.0\Bin\vkcube.exe"
```
@@ -66,7 +66,7 @@ cli-anything-nsight-graphics launch detached ^
```bash
cli-anything-nsight-graphics launch attach ^
--activity "Frame Debugger" ^
--activity "Graphics Capture" ^
--pid 12345
```
@@ -168,20 +168,32 @@ When `gpu-trace capture --summarize` is used, the result also includes:
| Variable | Purpose |
|----------|---------|
| `NSIGHT_GRAPHICS_PATH` | Override executable discovery |
| `NSIGHT_GRAPHICS_TEST_EXE` | E2E target executable |
| `NSIGHT_GRAPHICS_TEST_ARGS` | Optional E2E target arguments |
| `NSIGHT_GRAPHICS_TEST_WORKDIR` | Optional E2E working directory |
| `NSIGHT_GRAPHICS_TEST_EXE` | Shared fallback E2E target executable |
| `NSIGHT_GRAPHICS_TEST_ARGS` | Shared fallback E2E target arguments |
| `NSIGHT_GRAPHICS_TEST_WORKDIR` | Shared fallback E2E working directory |
| `NSIGHT_GRAPHICS_FRAME_TEST_EXE` | Optional frame-capture-specific executable override |
| `NSIGHT_GRAPHICS_FRAME_TEST_ARGS` | Optional frame-capture-specific args override |
| `NSIGHT_GRAPHICS_FRAME_TEST_WORKDIR` | Optional frame-capture-specific workdir override |
| `NSIGHT_GRAPHICS_GPU_TRACE_TEST_EXE` | Optional GPU Trace-specific executable override |
| `NSIGHT_GRAPHICS_GPU_TRACE_TEST_ARGS` | Optional GPU Trace-specific args override |
| `NSIGHT_GRAPHICS_GPU_TRACE_TEST_WORKDIR` | Optional GPU Trace-specific workdir override |
| `NSIGHT_GRAPHICS_CPP_TEST_EXE` | Optional C++ Capture-specific executable override |
| `NSIGHT_GRAPHICS_CPP_TEST_ARGS` | Optional C++ Capture-specific args override |
| `NSIGHT_GRAPHICS_CPP_TEST_WORKDIR` | Optional C++ Capture-specific workdir override |
## E2E Test Prerequisites
The E2E suite assumes:
- Nsight Graphics is installed and discoverable
- `NSIGHT_GRAPHICS_TEST_EXE` points to a graphics workload that Nsight can
launch or capture
- optional args/workdir are provided if the test target requires them
- either `NSIGHT_GRAPHICS_TEST_EXE` or the activity-specific `NSIGHT_GRAPHICS_*_TEST_EXE`
overrides point to graphics workloads that Nsight can launch for that activity
- optional args/workdir are provided if the chosen test target requires them
Typical examples include `vkcube.exe`, game samples, or internal engine demos.
Different activities may require different targets on newer Nsight builds, so
the E2E suite accepts per-activity overrides instead of assuming one executable
works for frame capture, GPU Trace, and C++ Capture.
## Multiple Installations
@@ -7,6 +7,55 @@ from typing import Sequence
from cli_anything.nsight_graphics.utils import nsight_graphics_backend as backend
def _build_unified_frame_args(
activity: str,
*,
wait_seconds: int | None,
wait_frames: int | None,
wait_hotkey: bool,
export_frame_perf_metrics: bool,
export_range_perf_metrics: bool,
) -> list[str]:
backend.ensure_exactly_one(
"frame trigger",
{
"wait_seconds": wait_seconds is not None,
"wait_frames": wait_frames is not None,
"wait_hotkey": wait_hotkey,
},
)
normalized = activity.lower()
if normalized == "graphics capture":
if export_frame_perf_metrics or export_range_perf_metrics:
raise RuntimeError(
"Frame performance export flags are not supported by Graphics Capture mode on modern ngfx.exe builds."
)
extra_args = ["--frame-count", "1"]
if wait_seconds is not None:
extra_args.extend(["--elapsed-time", str(wait_seconds)])
elif wait_frames is not None:
extra_args.extend(["--frame-index", str(wait_frames)])
else:
extra_args.append("--hotkey-capture")
return extra_args
extra_args: list[str] = []
if wait_seconds is not None:
extra_args.extend(["--wait-seconds", str(wait_seconds)])
elif wait_frames is not None:
extra_args.extend(["--wait-frames", str(wait_frames)])
else:
extra_args.append("--wait-hotkey")
if export_frame_perf_metrics:
extra_args.append("--export-frame-perf-metrics")
if export_range_perf_metrics:
extra_args.append("--export-range-perf-metrics")
return extra_args
def capture_frame(
*,
nsight_path: str | None,
@@ -27,35 +76,23 @@ def capture_frame(
"""Run a Frame Debugger capture."""
report = backend.probe_installation(nsight_path=nsight_path)
binaries = report["binaries"]
artifact_roots = backend.activity_artifact_roots("Frame Debugger", output_dir)
activity = backend.resolve_activity_name(report, "Frame Debugger")
artifact_roots = backend.activity_artifact_roots(activity, output_dir)
if binaries.get("ngfx"):
backend.require_launch_target(project=project, exe=exe)
backend.ensure_exactly_one(
"frame trigger",
{
"wait_seconds": wait_seconds is not None,
"wait_frames": wait_frames is not None,
"wait_hotkey": wait_hotkey,
},
extra_args = _build_unified_frame_args(
activity,
wait_seconds=wait_seconds,
wait_frames=wait_frames,
wait_hotkey=wait_hotkey,
export_frame_perf_metrics=export_frame_perf_metrics,
export_range_perf_metrics=export_range_perf_metrics,
)
extra_args: list[str] = []
if wait_seconds is not None:
extra_args.extend(["--wait-seconds", str(wait_seconds)])
elif wait_frames is not None:
extra_args.extend(["--wait-frames", str(wait_frames)])
else:
extra_args.append("--wait-hotkey")
if export_frame_perf_metrics:
extra_args.append("--export-frame-perf-metrics")
if export_range_perf_metrics:
extra_args.append("--export-range-perf-metrics")
command = backend.build_unified_command(
binaries,
activity="Frame Debugger",
activity=activity,
project=project,
output_dir=output_dir,
hostname=hostname,
@@ -105,6 +142,6 @@ def capture_frame(
else:
raise RuntimeError(backend.INSTALL_INSTRUCTIONS)
result["activity"] = "Frame Debugger"
result["activity"] = activity
result["output_dir"] = output_dir or backend.default_output_dir()
return result
@@ -23,12 +23,13 @@ def launch_detached(
"""Launch a target under Nsight and exit immediately."""
report = backend.probe_installation(nsight_path=nsight_path)
binaries = report["binaries"]
resolved_activity = backend.resolve_activity_name(report, activity)
backend.require_binary(binaries, "ngfx")
backend.require_launch_target(project=project, exe=exe)
command = backend.build_unified_command(
binaries,
activity=activity,
activity=resolved_activity,
project=project,
output_dir=output_dir,
hostname=hostname,
@@ -41,7 +42,7 @@ def launch_detached(
)
result = backend.run_command(command, timeout=120)
result["tool_mode"] = "unified"
result["activity"] = activity
result["activity"] = resolved_activity
return result
@@ -58,11 +59,12 @@ def attach(
"""Attach an activity to a running PID."""
report = backend.probe_installation(nsight_path=nsight_path)
binaries = report["binaries"]
resolved_activity = backend.resolve_activity_name(report, activity)
backend.require_binary(binaries, "ngfx")
command = backend.build_unified_command(
binaries,
activity=activity,
activity=resolved_activity,
project=project,
output_dir=output_dir,
hostname=hostname,
@@ -71,6 +73,6 @@ def attach(
)
result = backend.run_command(command, timeout=120)
result["tool_mode"] = "unified"
result["activity"] = activity
result["activity"] = resolved_activity
result["pid"] = pid
return result
@@ -241,6 +241,17 @@ class TestHelpParsing:
assert "--wait-frames" in result["activity_options"]["Frame Debugger"]
assert "--metric-set-id" in result["activity_options"]["GPU Trace Profiler"]
def test_resolve_activity_name_maps_legacy_frame_debugger_to_graphics_capture(self):
report = {
"supported_activities": [
"Graphics Capture",
"Generate C++ Capture",
"GPU Trace Profiler",
]
}
assert backend.resolve_activity_name(report, "Frame Debugger") == "Graphics Capture"
assert backend.resolve_activity_name(report, "Graphics Capture") == "Graphics Capture"
class TestCommandBuilders:
def test_build_unified_command_formats_args_and_env(self):
@@ -277,6 +288,12 @@ class TestCommandBuilders:
assert "--capture-countdown-timer" in command
assert command[command.index("--capture-countdown-timer") + 1] == "3000"
@patch("cli_anything.nsight_graphics.utils.nsight_graphics_backend.subprocess.run")
def test_run_command_suppresses_graphics_capture_suggestion_dialog(self, run_mock):
run_mock.return_value = type("Result", (), {"returncode": 0, "stdout": "", "stderr": ""})()
backend.run_command(["C:/Nsight/ngfx.exe", "--help"])
assert run_mock.call_args.kwargs["env"]["NSIGHT_SUGGEST_GRAPHICS_CAPTURE"] == "0"
def test_diff_snapshots_reports_new_nonempty_files(self, tmp_path):
before = backend.snapshot_files([str(tmp_path)])
artifact = tmp_path / "capture.ngfx-capture"
@@ -287,6 +304,12 @@ class TestCommandBuilders:
assert diff[0]["path"].endswith("capture.ngfx-capture")
assert diff[0]["size"] > 0
def test_activity_artifact_roots_keeps_default_graphics_capture_location(self):
roots = backend.activity_artifact_roots("Graphics Capture", "D:/captures")
assert str(Path("D:/captures").resolve()) in roots
assert any(root.endswith("Documents\\NVIDIA Nsight Graphics") for root in roots)
assert any(root.endswith("Documents\\NVIDIA Nsight Graphics\\GraphicsCaptures") for root in roots)
def test_gpu_trace_summary_from_export_dir(self, tmp_path):
base = tmp_path / "BASE"
base.mkdir()
@@ -416,6 +439,45 @@ class TestCoreModules:
assert result["activity"] == "Frame Debugger"
assert result["artifacts"]
@patch("cli_anything.nsight_graphics.core.frame.backend.run_with_artifacts")
@patch("cli_anything.nsight_graphics.core.frame.backend.build_unified_command")
@patch("cli_anything.nsight_graphics.core.frame.backend.probe_installation")
def test_frame_capture_maps_graphics_capture_options(self, probe_mock, build_mock, run_mock):
probe_mock.return_value = {
"binaries": {"ngfx": "C:/Nsight/ngfx.exe", "ngfx_capture": None, "ngfx_replay": None},
"supported_activities": ["Graphics Capture", "GPU Trace Profiler"],
}
build_mock.return_value = ["C:/Nsight/ngfx.exe", "--activity", "Graphics Capture"]
run_mock.return_value = {
"ok": True,
"returncode": 0,
"stdout": "",
"stderr": "",
"command": "ngfx",
"artifacts": [{"path": "D:/out/capture.ngfx-capture", "size": 10, "mtime_ns": 1}],
}
result = frame.capture_frame(
nsight_path=None,
project=None,
output_dir="D:/out",
hostname=None,
platform_name=None,
exe="C:/demo.exe",
working_dir=None,
args=(),
envs=(),
wait_seconds=1,
wait_frames=None,
wait_hotkey=False,
export_frame_perf_metrics=False,
export_range_perf_metrics=False,
)
assert build_mock.call_args.kwargs["activity"] == "Graphics Capture"
assert build_mock.call_args.kwargs["extra_args"] == ["--frame-count", "1", "--elapsed-time", "1"]
assert result["activity"] == "Graphics Capture"
@patch("cli_anything.nsight_graphics.core.frame.backend.probe_installation")
def test_frame_capture_split_mode_rejects_perf_exports(self, probe_mock):
probe_mock.return_value = {
@@ -15,11 +15,26 @@ import pytest
from cli_anything.nsight_graphics.utils.nsight_graphics_backend import probe_installation
HARNESS_ROOT = str(Path(__file__).resolve().parents[3])
TEST_EXE = os.environ.get("NSIGHT_GRAPHICS_TEST_EXE", "").strip()
TEST_ARGS = os.environ.get("NSIGHT_GRAPHICS_TEST_ARGS", "").strip()
TEST_WORKDIR = os.environ.get("NSIGHT_GRAPHICS_TEST_WORKDIR", "").strip()
DEFAULT_TEST_EXE = os.environ.get("NSIGHT_GRAPHICS_TEST_EXE", "").strip()
DEFAULT_TEST_ARGS = os.environ.get("NSIGHT_GRAPHICS_TEST_ARGS", "").strip()
DEFAULT_TEST_WORKDIR = os.environ.get("NSIGHT_GRAPHICS_TEST_WORKDIR", "").strip()
FRAME_TEST_EXE = os.environ.get("NSIGHT_GRAPHICS_FRAME_TEST_EXE", "").strip() or DEFAULT_TEST_EXE
FRAME_TEST_ARGS = os.environ.get("NSIGHT_GRAPHICS_FRAME_TEST_ARGS", "").strip() or DEFAULT_TEST_ARGS
FRAME_TEST_WORKDIR = os.environ.get("NSIGHT_GRAPHICS_FRAME_TEST_WORKDIR", "").strip() or DEFAULT_TEST_WORKDIR
GPU_TRACE_TEST_EXE = os.environ.get("NSIGHT_GRAPHICS_GPU_TRACE_TEST_EXE", "").strip() or DEFAULT_TEST_EXE
GPU_TRACE_TEST_ARGS = os.environ.get("NSIGHT_GRAPHICS_GPU_TRACE_TEST_ARGS", "").strip() or DEFAULT_TEST_ARGS
GPU_TRACE_TEST_WORKDIR = os.environ.get("NSIGHT_GRAPHICS_GPU_TRACE_TEST_WORKDIR", "").strip() or DEFAULT_TEST_WORKDIR
CPP_TEST_EXE = os.environ.get("NSIGHT_GRAPHICS_CPP_TEST_EXE", "").strip() or DEFAULT_TEST_EXE
CPP_TEST_ARGS = os.environ.get("NSIGHT_GRAPHICS_CPP_TEST_ARGS", "").strip() or DEFAULT_TEST_ARGS
CPP_TEST_WORKDIR = os.environ.get("NSIGHT_GRAPHICS_CPP_TEST_WORKDIR", "").strip() or DEFAULT_TEST_WORKDIR
HAS_NSIGHT = bool(probe_installation().get("ok"))
HAS_TEST_EXE = bool(TEST_EXE and os.path.isfile(TEST_EXE))
HAS_FRAME_TEST_EXE = bool(FRAME_TEST_EXE and os.path.isfile(FRAME_TEST_EXE))
HAS_GPU_TRACE_TEST_EXE = bool(GPU_TRACE_TEST_EXE and os.path.isfile(GPU_TRACE_TEST_EXE))
HAS_CPP_TEST_EXE = bool(CPP_TEST_EXE and os.path.isfile(CPP_TEST_EXE))
def _resolve_cli(name: str) -> list[str]:
@@ -48,7 +63,18 @@ def _resolve_cli(name: str) -> list[str]:
CLI_BASE = _resolve_cli("cli-anything-nsight-graphics")
skip_no_nsight = pytest.mark.skipif(not HAS_NSIGHT, reason="Nsight Graphics not installed")
skip_no_target = pytest.mark.skipif(not HAS_TEST_EXE, reason="NSIGHT_GRAPHICS_TEST_EXE not set or missing")
skip_no_frame_target = pytest.mark.skipif(
not HAS_FRAME_TEST_EXE,
reason="NSIGHT_GRAPHICS_FRAME_TEST_EXE or NSIGHT_GRAPHICS_TEST_EXE not set or missing",
)
skip_no_gpu_trace_target = pytest.mark.skipif(
not HAS_GPU_TRACE_TEST_EXE,
reason="NSIGHT_GRAPHICS_GPU_TRACE_TEST_EXE or NSIGHT_GRAPHICS_TEST_EXE not set or missing",
)
skip_no_cpp_target = pytest.mark.skipif(
not HAS_CPP_TEST_EXE,
reason="NSIGHT_GRAPHICS_CPP_TEST_EXE or NSIGHT_GRAPHICS_TEST_EXE not set or missing",
)
def _run_json(*args: str, timeout: int = 600) -> dict:
@@ -65,13 +91,13 @@ def _run_json(*args: str, timeout: int = 600) -> dict:
return json.loads(result.stdout)
def _target_args() -> list[str]:
"""Build repeated CLI args for the configured test executable."""
args = ["--exe", TEST_EXE]
if TEST_WORKDIR:
args.extend(["--dir", TEST_WORKDIR])
if TEST_ARGS:
for entry in shlex.split(TEST_ARGS, posix=os.name != "nt"):
def _target_args(exe_path: str, args_text: str, workdir: str) -> list[str]:
"""Build repeated CLI args for a configured activity target."""
args = ["--exe", exe_path]
if workdir:
args.extend(["--dir", workdir])
if args_text:
for entry in shlex.split(args_text, posix=os.name != "nt"):
args.extend(["--arg", entry])
return args
@@ -86,15 +112,15 @@ class TestDoctorE2E:
@skip_no_nsight
@skip_no_target
class TestTargetedE2E:
@skip_no_frame_target
def test_frame_capture(self, tmp_path):
data = _run_json(
"--output-dir",
str(tmp_path),
"frame",
"capture",
*_target_args(),
*_target_args(FRAME_TEST_EXE, FRAME_TEST_ARGS, FRAME_TEST_WORKDIR),
"--wait-seconds",
"1",
)
@@ -102,13 +128,14 @@ class TestTargetedE2E:
assert data["artifacts"]
assert any(Path(item["path"]).exists() and item["size"] > 0 for item in data["artifacts"])
@skip_no_gpu_trace_target
def test_gpu_trace_capture(self, tmp_path):
data = _run_json(
"--output-dir",
str(tmp_path),
"gpu-trace",
"capture",
*_target_args(),
*_target_args(GPU_TRACE_TEST_EXE, GPU_TRACE_TEST_ARGS, GPU_TRACE_TEST_WORKDIR),
"--start-after-ms",
"1000",
"--limit-to-frames",
@@ -119,13 +146,14 @@ class TestTargetedE2E:
assert data["artifacts"]
assert any(Path(item["path"]).exists() and item["size"] > 0 for item in data["artifacts"])
@skip_no_cpp_target
def test_cpp_capture(self, tmp_path):
data = _run_json(
"--output-dir",
str(tmp_path),
"cpp",
"capture",
*_target_args(),
*_target_args(CPP_TEST_EXE, CPP_TEST_ARGS, CPP_TEST_WORKDIR),
"--wait-seconds",
"1",
)
@@ -29,6 +29,11 @@ INSTALL_INSTRUCTIONS = (
" C:\\Program Files\\NVIDIA Corporation\\Nsight Graphics <version>\\host\\windows-desktop-nomad-x64"
)
_ACTIVITY_ALIASES = {
"frame debugger": ("Frame Debugger", "Graphics Capture"),
"graphics capture": ("Graphics Capture", "Frame Debugger"),
}
def _command_string(args: Sequence[str]) -> str:
"""Render a command for display."""
@@ -515,6 +520,25 @@ def parse_option_help(text: str) -> list[str]:
return _dedupe(options)
def resolve_activity_name(report: dict[str, Any], requested: str) -> str:
"""Map a requested activity name onto the current Nsight installation."""
supported = report.get("supported_activities") or []
if not supported:
return requested
supported_lookup = {item.lower(): item for item in supported}
direct = supported_lookup.get(requested.lower())
if direct:
return direct
aliases = _ACTIVITY_ALIASES.get(requested.lower(), (requested,))
for alias in aliases:
resolved = supported_lookup.get(alias.lower())
if resolved:
return resolved
return requested
def _combined_output(result: dict[str, Any]) -> str:
"""Combine stdout and stderr for parsing."""
stdout = result.get("stdout", "") or ""
@@ -530,12 +554,15 @@ def run_command(
) -> dict[str, Any]:
"""Run a subprocess and normalize the result."""
try:
env = os.environ.copy()
env.setdefault("NSIGHT_SUGGEST_GRAPHICS_CAPTURE", "0")
proc = subprocess.run(
list(args),
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
env=env,
)
return {
"ok": proc.returncode == 0,
@@ -820,16 +847,19 @@ def default_output_dir() -> str:
def activity_artifact_roots(activity: str, output_dir: Optional[str]) -> list[str]:
"""Return directories to scan for generated artifacts."""
if output_dir:
return [str(Path(output_dir).resolve())]
base = Path(default_output_dir())
roots = [str(base)]
roots: list[str] = []
if output_dir:
roots.append(str(Path(output_dir).resolve()))
else:
roots.append(str(base))
normalized = activity.lower()
if normalized == "frame debugger":
roots.insert(0, str(base / "GraphicsCaptures"))
if normalized in {"frame debugger", "graphics capture"}:
roots.extend([str(base), str(base / "GraphicsCaptures")])
elif normalized == "gpu trace profiler":
roots.append(str(base / "GPUTrace"))
if not output_dir:
roots.append(str(base / "GPUTrace"))
elif normalized == "generate c++ capture":
roots.append(str(base / "CppCaptures"))
return _dedupe(roots)