From 9ddf7f723d1c693d86e9368c59a0371629efadca Mon Sep 17 00:00:00 2001 From: ajrasane <131806219+ajrasane@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:46:15 +0000 Subject: [PATCH 1/4] Fix remote safety benchmark latency Signed-off-by: ajrasane <131806219+ajrasane@users.noreply.github.com> --- CHANGELOG.rst | 1 + docs/source/guides/9_autotune.rst | 10 +- examples/onnx_ptq/autotune/README.md | 11 +- .../onnx/quantization/autotune/benchmark.py | 272 +++++++++++++++--- modelopt/onnx/quantization/ort_utils.py | 30 +- .../quantization/autotune/test_benchmark.py | 142 +++++++++ 6 files changed, 424 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index df983a8804e..d3f5d883a6e 100755 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -152,6 +152,7 @@ Changelog checkpoints. - Fix shared ONNX export metadata and Diffusers attention policy: every ``NVFP4QuantExporter`` post-process now upgrades the default-domain opset to at least 23, all FP8 custom-op exports re-run ONNX shape/type inference after setting output metadata, and quantized SDPA derives FP8 MHA enablement from the live Q/K/V quantizers instead of honoring a caller-set ``_disable_fp8_mha`` attribute. - Fix ONNX FP16 conversion failing to preserve public output types when type inference changes a graph output declaration before output casts are inserted. +- Fix ONNX remote AutoQDQ safety benchmarks returning infinite latency by running the generated engine with ``trtexec_safe`` on the configured target. - Fix ``examples/hf_ptq/hf_ptq.py`` discarding a completed PTQ run (no checkpoint exported) when the optional post-quantization sanity-check ``generate()`` call raised, for example because ``device_map="auto"`` placed part of the model on CPU. That failure is now caught and only skips the sanity check; export proceeds regardless. - Fix ``examples/megatron_bridge/export_quantized_megatron_to_hf.py`` storing the MoE router at Megatron's ``moe_router_dtype``, which is a routing *compute* dtype, not a storage one. The router now exports at the export ``dtype`` like every other unquantized weight, matching what ``hf_ptq.py`` and the released NVFP4 checkpoints contain; pass ``moe_router_dtype`` to ``export_mcore_gpt_to_hf`` explicitly if you want the old fp32 storage. - Fix unified Megatron export writing a second, unreferenced copy of the vocab embedding when a model with MTP layers is exported with pipeline parallelism. The duplicate was never loaded but inflated the checkpoint by the size of the embedding (about 1 GB for Qwen3.6-35B-A3B); re-export to reclaim the space. diff --git a/docs/source/guides/9_autotune.rst b/docs/source/guides/9_autotune.rst index 02f79395752..0bb65b15609 100644 --- a/docs/source/guides/9_autotune.rst +++ b/docs/source/guides/9_autotune.rst @@ -251,11 +251,17 @@ To use remote autotuning during Q/DQ placement optimization, run with ``trtexec` **Requirements:** * TensorRT 10.15 or later -* Valid remote autotuning configuration +* Valid ``ssh://`` remote autotuning configuration without a password +* Non-interactive SSH key authentication from the host to the target +* ``trtexec_safe`` on the target, alongside the configured ``remote_exec_path`` * ``--use_trtexec`` must be set (benchmarking uses ``trtexec`` instead of the TensorRT Python API) * ``--safe --skipInference`` must be enabled via ``--trtexec_benchmark_args`` -Replace ```` with an actual remote autotuning configuration string (see ``trtexec --help`` for more details). Other TensorRT benchmark options (e.g. ``--timing_cache``, ``--warmup_runs``, ``--timing_runs``, ``--plugin_libraries``) are also available; run ``--help`` for details. +Replace ```` with an actual remote autotuning configuration string (see ``trtexec --help`` for more details). ModelOpt uses the configuration to build the engine with remote autotuning, copies the generated engine to an internal temporary path on the target, and runs ``trtexec_safe`` there to measure GPU compute time. The temporary engine is removed after each benchmark. + +Other TensorRT benchmark options (e.g. ``--timing_cache``, ``--warmup_runs``, ``--timing_runs``, ``--plugin_libraries``) are also available; run ``--help`` for details. + +``--plugin_libraries`` applies to the host-side engine build only. ModelOpt does not transfer or load custom plugin libraries on the remote target, so this workflow does not support plugin-dependent remote safety engines. Low-Level API Usage =================== diff --git a/examples/onnx_ptq/autotune/README.md b/examples/onnx_ptq/autotune/README.md index d8a86b8bae0..297e2bcac7b 100644 --- a/examples/onnx_ptq/autotune/README.md +++ b/examples/onnx_ptq/autotune/README.md @@ -247,12 +247,17 @@ python3 -m modelopt.onnx.quantization.autotune \ **Requirements:** - TensorRT 10.15 or later -- Valid remote autotuning configuration +- Valid `ssh://` remote autotuning configuration without a password +- Non-interactive SSH key authentication from the host to the target +- `trtexec_safe` on the target, alongside the configured `remote_exec_path` - `--use_trtexec` must be set (benchmarking uses `trtexec` instead of the TensorRT Python API) - `--safe --skipInference` must be enabled via `--trtexec_benchmark_args` -Replace `` with an actual remote autotuning configuration string (see `trtexec --help` for more details). - Other TensorRT benchmark options (e.g. `--timing_cache`, `--warmup_runs`, `--timing_runs`, `--plugin_libraries`) are also available; run `--help` for details. +Replace `` with an actual remote autotuning configuration string (see `trtexec --help` for more details). ModelOpt uses the configuration to build the engine with remote autotuning, copies the generated engine to an internal temporary path on the target, and runs `trtexec_safe` there to measure GPU compute time. The temporary engine is removed after each benchmark. + +Other TensorRT benchmark options (e.g. `--timing_cache`, `--warmup_runs`, `--timing_runs`, `--plugin_libraries`) are also available; run `--help` for details. + +`--plugin_libraries` applies to the host-side engine build only. ModelOpt does not transfer or load custom plugin libraries on the remote target, so this workflow does not support plugin-dependent remote safety engines. ## Programmatic API Usage diff --git a/modelopt/onnx/quantization/autotune/benchmark.py b/modelopt/onnx/quantization/autotune/benchmark.py index ba5cf1142bf..1237ef1ff70 100644 --- a/modelopt/onnx/quantization/autotune/benchmark.py +++ b/modelopt/onnx/quantization/autotune/benchmark.py @@ -29,19 +29,23 @@ import ctypes import importlib.util import os +import posixpath import re +import shlex import shutil import tempfile import time from abc import ABC, abstractmethod +from dataclasses import dataclass from pathlib import Path from typing import Any +from urllib.parse import parse_qs, unquote, urlparse import numpy as np import torch from modelopt.onnx.logging_config import logger -from modelopt.onnx.quantization.ort_utils import _check_for_trtexec, _run_trtexec +from modelopt.onnx.quantization.ort_utils import _check_for_trtexec, _run_command, _run_trtexec TRT_AVAILABLE = importlib.util.find_spec("tensorrt") is not None if TRT_AVAILABLE: @@ -63,6 +67,90 @@ def _validate_shape_range(min_shape: list, opt_shape: list, max_shape: list) -> ) +@dataclass(frozen=True) +class _RemoteBenchmarkConfig: + url: str + destination: str + port: int + trtexec_safe_path: str + library_path: str + + +def _parse_remote_benchmark_config( + trtexec_args: list[str], +) -> _RemoteBenchmarkConfig | None: + """Parse the existing remote-autotuning argument for target-side benchmarking.""" + values = [] + for index, arg in enumerate(trtexec_args): + if arg == "--remoteAutoTuningConfig": + if index + 1 == len(trtexec_args): + raise ValueError("Missing value for --remoteAutoTuningConfig") + values.append(trtexec_args[index + 1]) + elif arg.startswith("--remoteAutoTuningConfig="): + values.append(arg.split("=", 1)[1]) + + if not values: + return None + if len(values) != 1: + raise ValueError("Exactly one --remoteAutoTuningConfig argument is required") + + value = values[0].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + + parsed = urlparse(value) + if parsed.scheme != "ssh": + raise ValueError("Only ssh:// remote autotuning configurations are supported") + if parsed.password is not None: + raise ValueError( + "Remote safety benchmarking requires SSH key authentication; " + "passwords in --remoteAutoTuningConfig are not supported" + ) + + user = unquote(parsed.username or "") + host = parsed.hostname or "" + if not re.fullmatch(r"[A-Za-z0-9._][A-Za-z0-9._-]*", user): + raise ValueError("Invalid SSH user in --remoteAutoTuningConfig") + if not re.fullmatch(r"[A-Za-z0-9._:-]+", host) or host.startswith("-"): + raise ValueError("Invalid SSH host in --remoteAutoTuningConfig") + + try: + port = parsed.port if parsed.port is not None else 22 + except ValueError as error: + raise ValueError("Invalid SSH port in --remoteAutoTuningConfig") from error + if not 1 <= port <= 65535: + raise ValueError("Invalid SSH port in --remoteAutoTuningConfig") + + query = parse_qs(parsed.query, keep_blank_values=True) + required = ("remote_exec_path", "remote_lib_path") + for name in required: + if len(query.get(name, [])) != 1 or not query[name][0]: + raise ValueError( + f"--remoteAutoTuningConfig requires exactly one non-empty {name} value" + ) + + remote_exec_path = query["remote_exec_path"][0].rstrip("/") or "/" + if posixpath.basename(remote_exec_path) in {"trtexec", "trtexec_safe"}: + remote_exec_path = posixpath.dirname(remote_exec_path) + trtexec_safe_path = posixpath.join(remote_exec_path, "trtexec_safe") + + destination_host = f"[{host}]" if ":" in host else host + return _RemoteBenchmarkConfig( + url=value, + destination=f"{user}@{destination_host}", + port=port, + trtexec_safe_path=trtexec_safe_path, + library_path=query["remote_lib_path"][0], + ) + + +def _run_network_command(command: list[str]) -> Any: + """Run an SSH or SCP command using the system's key-based configuration.""" + # System SSH/SCP is required because TensorRT's remote build does not report target latency. + # List-form argv and validated destinations avoid invoking a local shell with external input. + return _run_command(command[0], command[1:], timeout=600) + + class Benchmark(ABC): """Abstract base class for TensorRT model benchmarking. @@ -180,6 +268,8 @@ def __init__( self.logger.debug(f"Temporary model path: {self.temp_model_path}") self.latency_pattern = r"\[I\]\s+Latency:.*?median\s*=\s*([\d.]+)\s*ms" + self._remote_benchmark_config: _RemoteBenchmarkConfig | None = None + self._remote_config_error: str | None = None self._base_cmd = [ f"--avgRuns={self.timing_runs}", f"--iterations={self.timing_runs}", @@ -198,22 +288,14 @@ def __init__( self.logger.debug(f"Added plugin library: {plugin_path}") trtexec_args = self.trtexec_args or [] - has_remote_config = any("--remoteAutoTuningConfig" in arg for arg in trtexec_args) + has_remote_config = any( + arg == "--remoteAutoTuningConfig" or arg.startswith("--remoteAutoTuningConfig=") + for arg in trtexec_args + ) if has_remote_config: try: _check_for_trtexec(min_version="10.15") - self.logger.debug("TensorRT Python API version >= 10.15 detected") - if "--safe" not in trtexec_args: - self.logger.warning( - "Remote autotuning requires '--safe' to be set. Adding it to trtexec arguments." - ) - self.trtexec_args.append("--safe") - if "--skipInference" not in trtexec_args: - self.logger.warning( - "Remote autotuning requires '--skipInference' to be set. Adding it to trtexec arguments." - ) - self.trtexec_args.append("--skipInference") except ImportError: self.logger.warning( "Remote autotuning is not supported with TensorRT version < 10.15. " @@ -222,6 +304,33 @@ def __init__( trtexec_args = [ arg for arg in trtexec_args if "--remoteAutoTuningConfig" not in arg ] + else: + self.logger.debug("TensorRT Python API version >= 10.15 detected") + try: + remote_config = _parse_remote_benchmark_config(trtexec_args) + if remote_config is None: + raise ValueError("Could not parse --remoteAutoTuningConfig") + except ValueError as error: + self._remote_config_error = str(error) + self.trtexec_args = [] + trtexec_args = [] + else: + self._remote_benchmark_config = remote_config + for index, arg in enumerate(trtexec_args): + if arg.startswith("--remoteAutoTuningConfig="): + trtexec_args[index] = f"--remoteAutoTuningConfig={remote_config.url}" + elif arg == "--remoteAutoTuningConfig": + trtexec_args[index + 1] = remote_config.url + if "--safe" not in trtexec_args: + self.logger.warning( + "Remote autotuning requires '--safe' to be set. Adding it to trtexec arguments." + ) + self.trtexec_args.append("--safe") + if "--skipInference" not in trtexec_args: + self.logger.warning( + "Remote autotuning requires '--skipInference' to be set. Adding it to trtexec arguments." + ) + self.trtexec_args.append("--skipInference") self._base_cmd.extend(trtexec_args) self.logger.debug(f"Base command template: {' '.join(self._base_cmd)}") @@ -253,6 +362,12 @@ def run( if not os.path.exists(self.timing_cache_file): self.logger.debug(f"Will create timing cache: {self.timing_cache_file}") + if self._remote_config_error is not None: + message = f"Remote benchmark configuration error: {self._remote_config_error}" + self.logger.error(message) + self._write_log_file(log_file, message) + return float("inf") + try: model_path = path_or_bytes if isinstance(model_path, bytes): @@ -265,40 +380,127 @@ def run( full_cmd = ["trtexec", *cmd] self.logger.debug(f"Running: {' '.join(full_cmd)}") result = _run_trtexec(cmd) - self._write_log_file( - log_file, - "\n".join( - [ - f"Command: {' '.join(full_cmd)}", - f"Return code: {result.returncode}", - "=" * 80, - "STDOUT:", - "=" * 80, - result.stdout, - "\n" + "=" * 80, - "STDERR:", - "=" * 80, - result.stderr, - "\n" + "=" * 80, - ] - ), + log_content = "\n".join( + [ + f"Command: {' '.join(full_cmd)}", + f"Return code: {result.returncode}", + "=" * 80, + "STDOUT:", + "=" * 80, + result.stdout, + "\n" + "=" * 80, + "STDERR:", + "=" * 80, + result.stderr, + "\n" + "=" * 80, + ] ) + self._write_log_file(log_file, log_content) if result.returncode != 0: self.logger.error(f"trtexec failed with return code {result.returncode}") self.logger.error(f"stderr: {result.stderr}") return float("inf") - if not (match := re.search(self.latency_pattern, result.stdout, re.IGNORECASE)): + if self._remote_benchmark_config is not None: + config = self._remote_benchmark_config + remote_engine_path = f".modelopt_{Path(self.temp_dir).name}.engine.trt" + remote_log = [] + try: + upload_command = [ + "scp", + "-oBatchMode=yes", + "-P", + str(config.port), + self.engine_path, + f"{config.destination}:{remote_engine_path}", + ] + upload_result = _run_network_command(upload_command) + remote_log.append( + f"Upload command: {shlex.join(upload_command)}\n" + f"Return code: {upload_result.returncode}\n" + f"STDOUT:\n{upload_result.stdout}\nSTDERR:\n{upload_result.stderr}" + ) + if upload_result.returncode != 0: + self.logger.error( + f"Failed to upload engine to remote target: {upload_result.stderr}" + ) + return float("inf") + + remote_program = [ + config.trtexec_safe_path, + "--useCudaGraph", + f"--warmUp={self.warmup_runs}", + f"--iterations={self.timing_runs}", + f"--avgRuns={self.timing_runs}", + "--duration=0", + f"--loadEngine={remote_engine_path}", + ] + remote_command = ( + f"LD_LIBRARY_PATH={shlex.quote(config.library_path)}:$LD_LIBRARY_PATH " + f"{shlex.join(remote_program)}" + ) + benchmark_command = [ + "ssh", + "-oBatchMode=yes", + "-p", + str(config.port), + config.destination, + remote_command, + ] + result = _run_network_command(benchmark_command) + remote_log.append( + f"Benchmark command: {shlex.join(benchmark_command)}\n" + f"Return code: {result.returncode}\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + except Exception as error: + remote_log.append(f"Remote benchmark failed: {error}") + raise + finally: + cleanup_command = [ + "ssh", + "-oBatchMode=yes", + "-p", + str(config.port), + config.destination, + f"rm -f -- {shlex.quote(remote_engine_path)}", + ] + try: + cleanup_result = _run_network_command(cleanup_command) + remote_log.append( + f"Cleanup command: {shlex.join(cleanup_command)}\n" + f"Return code: {cleanup_result.returncode}\n" + f"STDOUT:\n{cleanup_result.stdout}\nSTDERR:\n{cleanup_result.stderr}" + ) + if cleanup_result.returncode != 0: + self.logger.warning( + "Remote engine cleanup failed with return code " + f"{cleanup_result.returncode}: {cleanup_result.stderr}" + ) + except Exception as error: + remote_log.append(f"Cleanup failed: {error}") + self.logger.warning(f"Remote engine cleanup failed: {error}") + self._write_log_file(log_file, "\n\n".join([log_content, *remote_log])) + + if result.returncode != 0: + self.logger.error( + f"Remote trtexec_safe failed with return code {result.returncode}: " + f"{result.stderr}" + ) + return float("inf") + latency_pattern = r"\[I\]\s+GPU Compute Time:.*?median\s*=\s*([\d.]+)\s*ms" + else: + latency_pattern = self.latency_pattern + + if not (match := re.search(latency_pattern, result.stdout, re.IGNORECASE)): self.logger.warning("Could not parse median latency from trtexec output") self.logger.debug(f"trtexec stdout:\n{result.stdout}") return float("inf") latency = float(match.group(1)) self.logger.info(f"TrtExec benchmark (median): {latency:.2f} ms") return latency - except FileNotFoundError: - self.logger.error( - "'trtexec' binary not found. Please ensure TensorRT is installed and 'trtexec' is in PATH." - ) + except FileNotFoundError as error: + self.logger.error(str(error)) return float("inf") except Exception as e: self.logger.error(f"Benchmark failed: {e}") diff --git a/modelopt/onnx/quantization/ort_utils.py b/modelopt/onnx/quantization/ort_utils.py index 150448c49a8..31fe8bca8d5 100755 --- a/modelopt/onnx/quantization/ort_utils.py +++ b/modelopt/onnx/quantization/ort_utils.py @@ -48,6 +48,33 @@ def _check_lib_in_ld_library_path(ld_library_path, lib_pattern): return False, None +def _run_command( + executable: str, + args: list[str] | None = None, + timeout: float | None = None, +) -> subprocess.CompletedProcess: + """Run a trusted executable without invoking a shell. + + Args: + executable: Trusted executable name or path. + args: Arguments to pass to the executable. + timeout: Optional subprocess timeout in seconds. + + Returns: + The completed subprocess result. + + Raises: + FileNotFoundError: If the executable is not found in PATH. + """ + cmd = [executable, *(args or [])] + try: + # Callers select a trusted executable and argv remains list-form, so no local shell + # interprets external input. + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) # nosec B603 + except FileNotFoundError as e: + raise FileNotFoundError(f"'{executable}' binary not found in PATH") from e + + def _run_trtexec( args: list[str] | None = None, timeout: float | None = None ) -> subprocess.CompletedProcess: @@ -63,9 +90,8 @@ def _run_trtexec( Raises: FileNotFoundError: If the 'trtexec' binary is not found in PATH. """ - cmd = ["trtexec", *(args or [])] try: - return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) # nosec B603 + return _run_command("trtexec", args, timeout) except FileNotFoundError as e: raise FileNotFoundError( "'trtexec' binary not found. Please ensure TensorRT is installed and 'trtexec' is in PATH." diff --git a/tests/gpu/onnx/quantization/autotune/test_benchmark.py b/tests/gpu/onnx/quantization/autotune/test_benchmark.py index 925d45fffe6..ad89b73bc34 100644 --- a/tests/gpu/onnx/quantization/autotune/test_benchmark.py +++ b/tests/gpu/onnx/quantization/autotune/test_benchmark.py @@ -27,6 +27,7 @@ import contextlib import os import shutil +import subprocess import tempfile from unittest.mock import MagicMock, patch @@ -218,6 +219,147 @@ def test_trtexec_run_accepts_bytes_input(trtexec_bench): assert trtexec_bench.run(b"fake onnx bytes") == pytest.approx(5.0) +def test_trtexec_run_returns_remote_safety_latency(tmp_path): + """Remote safety autotuning benchmarks the built engine on the target.""" + remote_url = ( + '"ssh://alice@10.0.0.5:2222?' + 'remote_exec_path=/opt/trt/bin/trtexec&remote_lib_path=/opt/trt/lib"' + ) + with patch.object(bm, "_check_for_trtexec"): + benchmark = TrtExecBenchmark( + timing_cache_file=str(tmp_path / "cache.bin"), + warmup_runs=2, + timing_runs=4, + trtexec_args=[ + f"--remoteAutoTuningConfig={remote_url}", + "--safe", + "--skipInference", + ], + ) + + model_path = tmp_path / "model.onnx" + model_path.write_bytes(b"") + log_file = tmp_path / "benchmark.log" + + local_build = MagicMock(returncode=0, stdout="Engine built", stderr="") + upload = MagicMock(returncode=0, stdout="", stderr="") + remote_benchmark = MagicMock( + returncode=0, + stdout=( + "[I] GPU Compute Time: min = 3.40 ms, max = 3.44 ms, mean = 3.41 ms, median = 3.42 ms" + ), + stderr="", + ) + cleanup = MagicMock(returncode=0, stdout="", stderr="") + + with patch( + "subprocess.run", side_effect=[local_build, upload, remote_benchmark, cleanup] + ) as run_mock: + latency = benchmark.run(str(model_path), str(log_file)) + + assert latency == pytest.approx(3.42) + local_command = run_mock.call_args_list[0].args[0] + assert ( + "--remoteAutoTuningConfig=ssh://alice@10.0.0.5:2222?" + "remote_exec_path=/opt/trt/bin/trtexec&remote_lib_path=/opt/trt/lib" + ) in local_command + assert "GPU Compute Time" in log_file.read_text() + remote_command = run_mock.call_args_list[2].args[0] + assert remote_command[0] == "ssh" + assert "trtexec_safe" in remote_command[-1] + assert "--useCudaGraph" in remote_command[-1] + assert "--warmUp=2" in remote_command[-1] + assert "--iterations=4" in remote_command[-1] + assert "--avgRuns=4" in remote_command[-1] + + +def test_trtexec_remote_config_falls_back_when_version_is_unsupported(tmp_path): + """An unsupported TensorRT version preserves the existing local fallback.""" + remote_url = ( + "ssh://alice@10.0.0.5:2222?remote_exec_path=/opt/trt/bin&remote_lib_path=/opt/trt/lib" + ) + with patch.object(bm, "_check_for_trtexec", side_effect=ImportError): + benchmark = TrtExecBenchmark( + timing_cache_file=str(tmp_path / "cache.bin"), + trtexec_args=[ + f"--remoteAutoTuningConfig={remote_url}", + "--safe", + "--skipInference", + ], + ) + + model_path = tmp_path / "model.onnx" + model_path.write_bytes(b"") + local_result = MagicMock( + returncode=0, + stdout="[I] Latency: min = 2.0 ms, max = 3.0 ms, median = 2.5 ms", + stderr="", + ) + with patch("subprocess.run", return_value=local_result) as run_mock: + assert benchmark.run(str(model_path)) == pytest.approx(2.5) + + assert run_mock.call_count == 1 + + +@pytest.mark.parametrize("failure_kind", ["nonzero", "timeout"]) +def test_trtexec_remote_failure_returns_inf_and_cleans_up(tmp_path, failure_kind): + """A target failure is logged, keeps the sentinel, and removes its engine.""" + remote_url = "ssh://alice@10.0.0.5?remote_exec_path=/opt/trt/bin&remote_lib_path=/opt/trt/lib" + with patch.object(bm, "_check_for_trtexec"): + benchmark = TrtExecBenchmark( + timing_cache_file=str(tmp_path / "cache.bin"), + trtexec_args=[ + f"--remoteAutoTuningConfig={remote_url}", + "--safe", + "--skipInference", + ], + ) + + model_path = tmp_path / "model.onnx" + model_path.write_bytes(b"") + log_file = tmp_path / "benchmark.log" + local_build = MagicMock(returncode=0, stdout="Engine built", stderr="") + upload = MagicMock(returncode=0, stdout="", stderr="") + if failure_kind == "nonzero": + remote_failure = MagicMock(returncode=1, stdout="", stderr="target execution failed") + expected_log = "target execution failed" + else: + remote_failure = subprocess.TimeoutExpired(cmd="ssh", timeout=600) + expected_log = "timed out" + cleanup = MagicMock(returncode=0, stdout="", stderr="") + + with patch( + "subprocess.run", side_effect=[local_build, upload, remote_failure, cleanup] + ) as run_mock: + assert benchmark.run(str(model_path), str(log_file)) == float("inf") + + cleanup_command = run_mock.call_args_list[-1].args[0] + assert cleanup_command[0] == "ssh" + assert cleanup_command[-1].startswith("rm -f -- .modelopt_trtexec_benchmark_") + assert expected_log in log_file.read_text() + + +def test_trtexec_remote_password_authentication_is_rejected(tmp_path): + """Remote safety benchmarking relies on SSH keys instead of URL passwords.""" + remote_url = ( + "ssh://alice:secret@10.0.0.5?remote_exec_path=/opt/trt/bin&remote_lib_path=/opt/trt/lib" + ) + with patch.object(bm, "_check_for_trtexec"): + benchmark = TrtExecBenchmark( + timing_cache_file=str(tmp_path / "cache.bin"), + trtexec_args=[ + f"--remoteAutoTuningConfig={remote_url}", + "--safe", + "--skipInference", + ], + ) + + with patch("subprocess.run") as run_mock: + assert benchmark.run(str(tmp_path / "model.onnx")) == float("inf") + + run_mock.assert_not_called() + + # --- TensorRTPyBenchmark._alloc_pinned_host --- From a12463bd8090898530efd993e59e9c0df2f7b173 Mon Sep 17 00:00:00 2001 From: ajrasane <131806219+ajrasane@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:32:10 +0000 Subject: [PATCH 2/4] Fix split-form remote benchmark fallback Signed-off-by: ajrasane <131806219+ajrasane@users.noreply.github.com> --- docs/source/guides/9_autotune.rst | 2 +- examples/onnx_ptq/autotune/README.md | 2 +- .../onnx/quantization/autotune/benchmark.py | 203 ++++++++++-------- .../quantization/autotune/test_benchmark.py | 20 +- 4 files changed, 133 insertions(+), 94 deletions(-) diff --git a/docs/source/guides/9_autotune.rst b/docs/source/guides/9_autotune.rst index 0bb65b15609..fdcf2d3012a 100644 --- a/docs/source/guides/9_autotune.rst +++ b/docs/source/guides/9_autotune.rst @@ -257,7 +257,7 @@ To use remote autotuning during Q/DQ placement optimization, run with ``trtexec` * ``--use_trtexec`` must be set (benchmarking uses ``trtexec`` instead of the TensorRT Python API) * ``--safe --skipInference`` must be enabled via ``--trtexec_benchmark_args`` -Replace ```` with an actual remote autotuning configuration string (see ``trtexec --help`` for more details). ModelOpt uses the configuration to build the engine with remote autotuning, copies the generated engine to an internal temporary path on the target, and runs ``trtexec_safe`` there to measure GPU compute time. The temporary engine is removed after each benchmark. +Replace ```` with an actual remote autotuning configuration string (see ``trtexec --help`` for more details). ModelOpt uses the configuration to build the engine with remote autotuning, copies the generated engine to an internal temporary path on the target, and runs ``trtexec_safe`` there to measure GPU compute time. ModelOpt attempts to remove the temporary engine after each benchmark. Other TensorRT benchmark options (e.g. ``--timing_cache``, ``--warmup_runs``, ``--timing_runs``, ``--plugin_libraries``) are also available; run ``--help`` for details. diff --git a/examples/onnx_ptq/autotune/README.md b/examples/onnx_ptq/autotune/README.md index 297e2bcac7b..ad429b83bba 100644 --- a/examples/onnx_ptq/autotune/README.md +++ b/examples/onnx_ptq/autotune/README.md @@ -253,7 +253,7 @@ python3 -m modelopt.onnx.quantization.autotune \ - `--use_trtexec` must be set (benchmarking uses `trtexec` instead of the TensorRT Python API) - `--safe --skipInference` must be enabled via `--trtexec_benchmark_args` -Replace `` with an actual remote autotuning configuration string (see `trtexec --help` for more details). ModelOpt uses the configuration to build the engine with remote autotuning, copies the generated engine to an internal temporary path on the target, and runs `trtexec_safe` there to measure GPU compute time. The temporary engine is removed after each benchmark. +Replace `` with an actual remote autotuning configuration string (see `trtexec --help` for more details). ModelOpt uses the configuration to build the engine with remote autotuning, copies the generated engine to an internal temporary path on the target, and runs `trtexec_safe` there to measure GPU compute time. ModelOpt attempts to remove the temporary engine after each benchmark. Other TensorRT benchmark options (e.g. `--timing_cache`, `--warmup_runs`, `--timing_runs`, `--plugin_libraries`) are also available; run `--help` for details. diff --git a/modelopt/onnx/quantization/autotune/benchmark.py b/modelopt/onnx/quantization/autotune/benchmark.py index 1237ef1ff70..7910caebc71 100644 --- a/modelopt/onnx/quantization/autotune/benchmark.py +++ b/modelopt/onnx/quantization/autotune/benchmark.py @@ -144,6 +144,23 @@ def _parse_remote_benchmark_config( ) +def _remove_remote_autotuning_config(trtexec_args: list[str]) -> list[str]: + """Remove inline or split-form remote-autotuning arguments.""" + filtered_args = [] + index = 0 + while index < len(trtexec_args): + arg = trtexec_args[index] + if arg == "--remoteAutoTuningConfig": + index += 2 + continue + if arg.startswith("--remoteAutoTuningConfig="): + index += 1 + continue + filtered_args.append(arg) + index += 1 + return filtered_args + + def _run_network_command(command: list[str]) -> Any: """Run an SSH or SCP command using the system's key-based configuration.""" # System SSH/SCP is required because TensorRT's remote build does not report target latency. @@ -301,9 +318,7 @@ def __init__( "Remote autotuning is not supported with TensorRT version < 10.15. " "Removing --remoteAutoTuningConfig from trtexec arguments" ) - trtexec_args = [ - arg for arg in trtexec_args if "--remoteAutoTuningConfig" not in arg - ] + trtexec_args = _remove_remote_autotuning_config(trtexec_args) else: self.logger.debug("TensorRT Python API version >= 10.15 detected") try: @@ -344,6 +359,99 @@ def __del__(self): except Exception as e: self.logger.warning(f"Failed to cleanup temporary directory: {e}") + def _benchmark_remote_engine( + self, + config: _RemoteBenchmarkConfig, + log_file: str | None, + local_log_content: str, + ) -> Any | None: + """Upload, benchmark, and clean up a generated engine on the remote target.""" + remote_engine_path = f".modelopt_{Path(self.temp_dir).name}.engine.trt" + remote_log = [] + try: + upload_command = [ + "scp", + "-oBatchMode=yes", + "-P", + str(config.port), + self.engine_path, + f"{config.destination}:{remote_engine_path}", + ] + upload_result = _run_network_command(upload_command) + remote_log.append( + f"Upload command: {shlex.join(upload_command)}\n" + f"Return code: {upload_result.returncode}\n" + f"STDOUT:\n{upload_result.stdout}\nSTDERR:\n{upload_result.stderr}" + ) + if upload_result.returncode != 0: + self.logger.error( + f"Failed to upload engine to remote target: {upload_result.stderr}" + ) + return None + + remote_program = [ + config.trtexec_safe_path, + "--useCudaGraph", + f"--warmUp={self.warmup_runs}", + f"--iterations={self.timing_runs}", + f"--avgRuns={self.timing_runs}", + "--duration=0", + f"--loadEngine={remote_engine_path}", + ] + remote_command = ( + f"LD_LIBRARY_PATH={shlex.quote(config.library_path)}:$LD_LIBRARY_PATH " + f"{shlex.join(remote_program)}" + ) + benchmark_command = [ + "ssh", + "-oBatchMode=yes", + "-p", + str(config.port), + config.destination, + remote_command, + ] + result = _run_network_command(benchmark_command) + remote_log.append( + f"Benchmark command: {shlex.join(benchmark_command)}\n" + f"Return code: {result.returncode}\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + except Exception as error: + remote_log.append(f"Remote benchmark failed: {error}") + raise + finally: + cleanup_command = [ + "ssh", + "-oBatchMode=yes", + "-p", + str(config.port), + config.destination, + f"rm -f -- {shlex.quote(remote_engine_path)}", + ] + try: + cleanup_result = _run_network_command(cleanup_command) + remote_log.append( + f"Cleanup command: {shlex.join(cleanup_command)}\n" + f"Return code: {cleanup_result.returncode}\n" + f"STDOUT:\n{cleanup_result.stdout}\nSTDERR:\n{cleanup_result.stderr}" + ) + if cleanup_result.returncode != 0: + self.logger.warning( + "Remote engine cleanup failed with return code " + f"{cleanup_result.returncode}: {cleanup_result.stderr}" + ) + except Exception as error: + remote_log.append(f"Cleanup failed: {error}") + self.logger.warning(f"Remote engine cleanup failed: {error}") + self._write_log_file(log_file, "\n\n".join([local_log_content, *remote_log])) + + if result.returncode != 0: + self.logger.error( + f"Remote trtexec_safe failed with return code {result.returncode}: {result.stderr}" + ) + return None + return result + def run( self, path_or_bytes: str | bytes, @@ -402,91 +510,10 @@ def run( return float("inf") if self._remote_benchmark_config is not None: - config = self._remote_benchmark_config - remote_engine_path = f".modelopt_{Path(self.temp_dir).name}.engine.trt" - remote_log = [] - try: - upload_command = [ - "scp", - "-oBatchMode=yes", - "-P", - str(config.port), - self.engine_path, - f"{config.destination}:{remote_engine_path}", - ] - upload_result = _run_network_command(upload_command) - remote_log.append( - f"Upload command: {shlex.join(upload_command)}\n" - f"Return code: {upload_result.returncode}\n" - f"STDOUT:\n{upload_result.stdout}\nSTDERR:\n{upload_result.stderr}" - ) - if upload_result.returncode != 0: - self.logger.error( - f"Failed to upload engine to remote target: {upload_result.stderr}" - ) - return float("inf") - - remote_program = [ - config.trtexec_safe_path, - "--useCudaGraph", - f"--warmUp={self.warmup_runs}", - f"--iterations={self.timing_runs}", - f"--avgRuns={self.timing_runs}", - "--duration=0", - f"--loadEngine={remote_engine_path}", - ] - remote_command = ( - f"LD_LIBRARY_PATH={shlex.quote(config.library_path)}:$LD_LIBRARY_PATH " - f"{shlex.join(remote_program)}" - ) - benchmark_command = [ - "ssh", - "-oBatchMode=yes", - "-p", - str(config.port), - config.destination, - remote_command, - ] - result = _run_network_command(benchmark_command) - remote_log.append( - f"Benchmark command: {shlex.join(benchmark_command)}\n" - f"Return code: {result.returncode}\n" - f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" - ) - except Exception as error: - remote_log.append(f"Remote benchmark failed: {error}") - raise - finally: - cleanup_command = [ - "ssh", - "-oBatchMode=yes", - "-p", - str(config.port), - config.destination, - f"rm -f -- {shlex.quote(remote_engine_path)}", - ] - try: - cleanup_result = _run_network_command(cleanup_command) - remote_log.append( - f"Cleanup command: {shlex.join(cleanup_command)}\n" - f"Return code: {cleanup_result.returncode}\n" - f"STDOUT:\n{cleanup_result.stdout}\nSTDERR:\n{cleanup_result.stderr}" - ) - if cleanup_result.returncode != 0: - self.logger.warning( - "Remote engine cleanup failed with return code " - f"{cleanup_result.returncode}: {cleanup_result.stderr}" - ) - except Exception as error: - remote_log.append(f"Cleanup failed: {error}") - self.logger.warning(f"Remote engine cleanup failed: {error}") - self._write_log_file(log_file, "\n\n".join([log_content, *remote_log])) - - if result.returncode != 0: - self.logger.error( - f"Remote trtexec_safe failed with return code {result.returncode}: " - f"{result.stderr}" - ) + result = self._benchmark_remote_engine( + self._remote_benchmark_config, log_file, log_content + ) + if result is None: return float("inf") latency_pattern = r"\[I\]\s+GPU Compute Time:.*?median\s*=\s*([\d.]+)\s*ms" else: diff --git a/tests/gpu/onnx/quantization/autotune/test_benchmark.py b/tests/gpu/onnx/quantization/autotune/test_benchmark.py index ad89b73bc34..717868ab758 100644 --- a/tests/gpu/onnx/quantization/autotune/test_benchmark.py +++ b/tests/gpu/onnx/quantization/autotune/test_benchmark.py @@ -273,16 +273,22 @@ def test_trtexec_run_returns_remote_safety_latency(tmp_path): assert "--avgRuns=4" in remote_command[-1] -def test_trtexec_remote_config_falls_back_when_version_is_unsupported(tmp_path): +@pytest.mark.parametrize("config_form", ["inline", "split"]) +def test_trtexec_remote_config_falls_back_when_version_is_unsupported(tmp_path, config_form): """An unsupported TensorRT version preserves the existing local fallback.""" remote_url = ( "ssh://alice@10.0.0.5:2222?remote_exec_path=/opt/trt/bin&remote_lib_path=/opt/trt/lib" ) + remote_config_args = ( + [f"--remoteAutoTuningConfig={remote_url}"] + if config_form == "inline" + else ["--remoteAutoTuningConfig", remote_url] + ) with patch.object(bm, "_check_for_trtexec", side_effect=ImportError): benchmark = TrtExecBenchmark( timing_cache_file=str(tmp_path / "cache.bin"), trtexec_args=[ - f"--remoteAutoTuningConfig={remote_url}", + *remote_config_args, "--safe", "--skipInference", ], @@ -299,11 +305,17 @@ def test_trtexec_remote_config_falls_back_when_version_is_unsupported(tmp_path): assert benchmark.run(str(model_path)) == pytest.approx(2.5) assert run_mock.call_count == 1 + local_command = run_mock.call_args.args[0] + assert remote_url not in local_command + assert not any( + arg == "--remoteAutoTuningConfig" or arg.startswith("--remoteAutoTuningConfig=") + for arg in local_command + ) @pytest.mark.parametrize("failure_kind", ["nonzero", "timeout"]) -def test_trtexec_remote_failure_returns_inf_and_cleans_up(tmp_path, failure_kind): - """A target failure is logged, keeps the sentinel, and removes its engine.""" +def test_trtexec_remote_failure_returns_inf_and_attempts_cleanup(tmp_path, failure_kind): + """A target failure is logged, keeps the sentinel, and attempts engine cleanup.""" remote_url = "ssh://alice@10.0.0.5?remote_exec_path=/opt/trt/bin&remote_lib_path=/opt/trt/lib" with patch.object(bm, "_check_for_trtexec"): benchmark = TrtExecBenchmark( From 47cf2bc2d0a275404980caf7150307f0030b808b Mon Sep 17 00:00:00 2001 From: ajrasane <131806219+ajrasane@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:08:11 +0000 Subject: [PATCH 3/4] Resolve remote timing server executable path Signed-off-by: ajrasane <131806219+ajrasane@users.noreply.github.com> --- modelopt/onnx/quantization/autotune/benchmark.py | 2 +- tests/gpu/onnx/quantization/autotune/test_benchmark.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modelopt/onnx/quantization/autotune/benchmark.py b/modelopt/onnx/quantization/autotune/benchmark.py index 7910caebc71..d3c9e52fbf9 100644 --- a/modelopt/onnx/quantization/autotune/benchmark.py +++ b/modelopt/onnx/quantization/autotune/benchmark.py @@ -130,7 +130,7 @@ def _parse_remote_benchmark_config( ) remote_exec_path = query["remote_exec_path"][0].rstrip("/") or "/" - if posixpath.basename(remote_exec_path) in {"trtexec", "trtexec_safe"}: + if posixpath.basename(remote_exec_path) == "timing_server": remote_exec_path = posixpath.dirname(remote_exec_path) trtexec_safe_path = posixpath.join(remote_exec_path, "trtexec_safe") diff --git a/tests/gpu/onnx/quantization/autotune/test_benchmark.py b/tests/gpu/onnx/quantization/autotune/test_benchmark.py index 717868ab758..1c3aa8011f9 100644 --- a/tests/gpu/onnx/quantization/autotune/test_benchmark.py +++ b/tests/gpu/onnx/quantization/autotune/test_benchmark.py @@ -223,7 +223,7 @@ def test_trtexec_run_returns_remote_safety_latency(tmp_path): """Remote safety autotuning benchmarks the built engine on the target.""" remote_url = ( '"ssh://alice@10.0.0.5:2222?' - 'remote_exec_path=/opt/trt/bin/trtexec&remote_lib_path=/opt/trt/lib"' + 'remote_exec_path=/opt/trt/bin/timing_server&remote_lib_path=/opt/trt/lib"' ) with patch.object(bm, "_check_for_trtexec"): benchmark = TrtExecBenchmark( @@ -261,12 +261,12 @@ def test_trtexec_run_returns_remote_safety_latency(tmp_path): local_command = run_mock.call_args_list[0].args[0] assert ( "--remoteAutoTuningConfig=ssh://alice@10.0.0.5:2222?" - "remote_exec_path=/opt/trt/bin/trtexec&remote_lib_path=/opt/trt/lib" + "remote_exec_path=/opt/trt/bin/timing_server&remote_lib_path=/opt/trt/lib" ) in local_command assert "GPU Compute Time" in log_file.read_text() remote_command = run_mock.call_args_list[2].args[0] assert remote_command[0] == "ssh" - assert "trtexec_safe" in remote_command[-1] + assert "/opt/trt/bin/trtexec_safe" in remote_command[-1] assert "--useCudaGraph" in remote_command[-1] assert "--warmUp=2" in remote_command[-1] assert "--iterations=4" in remote_command[-1] From 68bd0ad417423769e41f7d93c4df5f3ad9dbc747 Mon Sep 17 00:00:00 2001 From: ajrasane <131806219+ajrasane@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:35:17 +0000 Subject: [PATCH 4/4] Address remote benchmark review feedback Signed-off-by: ajrasane <131806219+ajrasane@users.noreply.github.com> --- docs/source/guides/9_autotune.rst | 1 + examples/onnx_ptq/autotune/README.md | 1 + .../onnx/quantization/autotune/benchmark.py | 8 ++- .../quantization/autotune/test_benchmark.py | 56 ++++++++++++++++++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/source/guides/9_autotune.rst b/docs/source/guides/9_autotune.rst index fdcf2d3012a..47516ef58a8 100644 --- a/docs/source/guides/9_autotune.rst +++ b/docs/source/guides/9_autotune.rst @@ -253,6 +253,7 @@ To use remote autotuning during Q/DQ placement optimization, run with ``trtexec` * TensorRT 10.15 or later * Valid ``ssh://`` remote autotuning configuration without a password * Non-interactive SSH key authentication from the host to the target +* The target host key provisioned in the user's trusted ``known_hosts`` file; ModelOpt enforces strict host-key checking * ``trtexec_safe`` on the target, alongside the configured ``remote_exec_path`` * ``--use_trtexec`` must be set (benchmarking uses ``trtexec`` instead of the TensorRT Python API) * ``--safe --skipInference`` must be enabled via ``--trtexec_benchmark_args`` diff --git a/examples/onnx_ptq/autotune/README.md b/examples/onnx_ptq/autotune/README.md index ad429b83bba..254ff625f8f 100644 --- a/examples/onnx_ptq/autotune/README.md +++ b/examples/onnx_ptq/autotune/README.md @@ -249,6 +249,7 @@ python3 -m modelopt.onnx.quantization.autotune \ - TensorRT 10.15 or later - Valid `ssh://` remote autotuning configuration without a password - Non-interactive SSH key authentication from the host to the target +- The target host key provisioned in the user's trusted `known_hosts` file; ModelOpt enforces strict host-key checking - `trtexec_safe` on the target, alongside the configured `remote_exec_path` - `--use_trtexec` must be set (benchmarking uses `trtexec` instead of the TensorRT Python API) - `--safe --skipInference` must be enabled via `--trtexec_benchmark_args` diff --git a/modelopt/onnx/quantization/autotune/benchmark.py b/modelopt/onnx/quantization/autotune/benchmark.py index d3c9e52fbf9..9422c842e3f 100644 --- a/modelopt/onnx/quantization/autotune/benchmark.py +++ b/modelopt/onnx/quantization/autotune/benchmark.py @@ -53,6 +53,8 @@ TORCH_CUDA_AVAILABLE = torch.cuda.is_available() +_SSH_OPTIONS = ("-oBatchMode=yes", "-oStrictHostKeyChecking=yes") + def _validate_shape_range(min_shape: list, opt_shape: list, max_shape: list) -> None: """Raise ValueError if shape lengths differ or if min <= opt <= max fails at any dimension.""" @@ -371,7 +373,7 @@ def _benchmark_remote_engine( try: upload_command = [ "scp", - "-oBatchMode=yes", + *_SSH_OPTIONS, "-P", str(config.port), self.engine_path, @@ -404,7 +406,7 @@ def _benchmark_remote_engine( ) benchmark_command = [ "ssh", - "-oBatchMode=yes", + *_SSH_OPTIONS, "-p", str(config.port), config.destination, @@ -422,7 +424,7 @@ def _benchmark_remote_engine( finally: cleanup_command = [ "ssh", - "-oBatchMode=yes", + *_SSH_OPTIONS, "-p", str(config.port), config.destination, diff --git a/tests/gpu/onnx/quantization/autotune/test_benchmark.py b/tests/gpu/onnx/quantization/autotune/test_benchmark.py index 1c3aa8011f9..d7056eaf479 100644 --- a/tests/gpu/onnx/quantization/autotune/test_benchmark.py +++ b/tests/gpu/onnx/quantization/autotune/test_benchmark.py @@ -264,8 +264,15 @@ def test_trtexec_run_returns_remote_safety_latency(tmp_path): "remote_exec_path=/opt/trt/bin/timing_server&remote_lib_path=/opt/trt/lib" ) in local_command assert "GPU Compute Time" in log_file.read_text() + upload_command = run_mock.call_args_list[1].args[0] remote_command = run_mock.call_args_list[2].args[0] + cleanup_command = run_mock.call_args_list[3].args[0] + for command in (upload_command, remote_command, cleanup_command): + assert "-oBatchMode=yes" in command + assert "-oStrictHostKeyChecking=yes" in command + assert upload_command[0] == "scp" assert remote_command[0] == "ssh" + assert cleanup_command[0] == "ssh" assert "/opt/trt/bin/trtexec_safe" in remote_command[-1] assert "--useCudaGraph" in remote_command[-1] assert "--warmUp=2" in remote_command[-1] @@ -275,7 +282,7 @@ def test_trtexec_run_returns_remote_safety_latency(tmp_path): @pytest.mark.parametrize("config_form", ["inline", "split"]) def test_trtexec_remote_config_falls_back_when_version_is_unsupported(tmp_path, config_form): - """An unsupported TensorRT version preserves the existing local fallback.""" + """An unsupported TensorRT version preserves the existing local build-only fallback.""" remote_url = ( "ssh://alice@10.0.0.5:2222?remote_exec_path=/opt/trt/bin&remote_lib_path=/opt/trt/lib" ) @@ -298,11 +305,11 @@ def test_trtexec_remote_config_falls_back_when_version_is_unsupported(tmp_path, model_path.write_bytes(b"") local_result = MagicMock( returncode=0, - stdout="[I] Latency: min = 2.0 ms, max = 3.0 ms, median = 2.5 ms", + stdout="Engine built", stderr="", ) with patch("subprocess.run", return_value=local_result) as run_mock: - assert benchmark.run(str(model_path)) == pytest.approx(2.5) + assert benchmark.run(str(model_path)) == float("inf") assert run_mock.call_count == 1 local_command = run_mock.call_args.args[0] @@ -311,6 +318,49 @@ def test_trtexec_remote_config_falls_back_when_version_is_unsupported(tmp_path, arg == "--remoteAutoTuningConfig" or arg.startswith("--remoteAutoTuningConfig=") for arg in local_command ) + assert "--skipInference" in local_command + + +@pytest.mark.parametrize("failure_kind", ["nonzero", "exception"]) +def test_trtexec_cleanup_failure_preserves_remote_safety_latency(tmp_path, failure_kind): + """Cleanup failures do not discard latency from a successful remote benchmark.""" + remote_url = "ssh://alice@10.0.0.5?remote_exec_path=/opt/trt/bin&remote_lib_path=/opt/trt/lib" + with patch.object(bm, "_check_for_trtexec"): + benchmark = TrtExecBenchmark( + timing_cache_file=str(tmp_path / "cache.bin"), + trtexec_args=[ + f"--remoteAutoTuningConfig={remote_url}", + "--safe", + "--skipInference", + ], + ) + + model_path = tmp_path / "model.onnx" + model_path.write_bytes(b"") + log_file = tmp_path / "benchmark.log" + local_build = MagicMock(returncode=0, stdout="Engine built", stderr="") + upload = MagicMock(returncode=0, stdout="", stderr="") + remote_benchmark = MagicMock( + returncode=0, + stdout="[I] GPU Compute Time: min = 3.40 ms, max = 3.44 ms, median = 3.42 ms", + stderr="", + ) + if failure_kind == "nonzero": + cleanup_failure = MagicMock(returncode=1, stdout="", stderr="cleanup denied") + expected_log = "cleanup denied" + else: + cleanup_failure = OSError("cleanup unavailable") + expected_log = "cleanup unavailable" + + with patch( + "subprocess.run", + side_effect=[local_build, upload, remote_benchmark, cleanup_failure], + ) as run_mock: + latency = benchmark.run(str(model_path), str(log_file)) + + assert latency == pytest.approx(3.42) + assert run_mock.call_args_list[-1].args[0][0] == "ssh" + assert expected_log in log_file.read_text() @pytest.mark.parametrize("failure_kind", ["nonzero", "timeout"])