From abbde44eca02e8642eb7928726ef52b7227242b4 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:14:07 -0700 Subject: [PATCH 01/10] [Docs] Clarify coding and testing contribution guidelines (#8117) ## Description Coding and testing guidance was duplicated across the contribution guide, `AGENTS.md`, skills, and the PR checklist, with conflicting advice about test additions, tooling, and typing. Make the contribution guide the shared source of truth and keep agent instructions focused on workflow and links. - Prefer plain functions for stateless work, inline trivial operations, use direct attribute access, and keep comments concise. Retain classes when state, lifecycle, or architectural interfaces justify them. - Align test recommendations with the test-audit authoring and retention gates: cover distinct risks, avoid duplicate fixtures and parameter matrices, and prove retained coverage before pruning. - Simplify the code skeleton and documentation examples; update uv commands, stub packaging, return annotations, and lazy-import guidance. - Synchronize the relevant skills and PR checklist, while preserving current `develop` configuration guidance and moving its general style rules into the guide. ## Type of change - Documentation update ## Validation - `uv run --extra dev isaaclab -f` - `uv run --frozen isaaclab --docs` (Sphinx warnings treated as errors) - `uv run --no-project python tools/skills/cli.py check` (24 skills) - `git diff --cached --check` ## Checklist - [x] Read the contribution guidelines and ran formatting/lint checks. - [x] Updated documentation, agent routing, skills, examples, and the PR checklist together. - [x] Changes generated no new warnings. - [x] Applied the test-audit policy: no new tests for documentation and workflow-only changes. - [x] No source packages changed; no package changelog fragments required. - [x] Contributor is already listed in `CONTRIBUTORS.md`. ## Release backport - [ ] Backport this pull request to the active release branch after it merges into `develop` --- .github/PULL_REQUEST_TEMPLATE.md | 6 +- AGENTS.md | 106 +--- docs/source/refs/contributing.rst | 490 ++++++++---------- docs/source/refs/snippets/code_skeleton.py | 152 +----- skills/developer/changelog-fragments/SKILL.md | 10 +- skills/developer/coding-style/SKILL.md | 16 +- skills/developer/coding-style/examples.md | 20 +- skills/developer/pr-workflow/SKILL.md | 11 +- skills/developer/pr-workflow/examples.md | 4 +- skills/developer/test-audit/SKILL.md | 4 +- skills/developer/test-audit/campaign.md | 2 +- 11 files changed, 278 insertions(+), 543 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 04cce3a3aa4a..caf90234a406 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -50,11 +50,11 @@ Docker and GPU tests run on demand. Push the commits you want tested, then comment `run-ci` on the pull request. - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) -- [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` +- [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `uv run isaaclab --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) +- [ ] I have validated the changed behavior with focused coverage and applied the [test-audit criteria](https://github.com/isaac-sim/IsaacLab/blob/develop/skills/developer/test-audit/SKILL.md) to any test changes +- [ ] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump package versions — CI handles that) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added changelog fragments for every touched package --- docs/source/api/lab/isaaclab.app.rst | 3 - .../launch-helper-cleanup.major.rst | 25 ++ source/isaaclab/isaaclab/app/__init__.pyi | 2 - source/isaaclab/isaaclab/app/logging_utils.py | 36 +-- .../isaaclab/isaaclab/app/settings_manager.py | 51 ---- source/isaaclab/isaaclab/app/sim_launcher.py | 251 ++++++++---------- .../isaaclab/devices/gamepad/se2_gamepad.py | 2 +- .../isaaclab/devices/gamepad/se3_gamepad.py | 2 +- .../isaaclab/physics/physics_manager_cfg.py | 5 +- .../isaaclab/renderers/renderer_cfg.py | 5 +- .../isaaclab/sensors/camera/camera.py | 10 +- .../isaaclab/sim/simulation_context.py | 16 +- .../app/test_launch_simulation_require_kit.py | 5 +- .../test_train_multigpu_command_building.py | 2 +- .../envs/test_action_state_recorder_term.py | 2 +- ...on_state_recorder_term_task_integration.py | 2 +- .../test/sensors/test_outdated_sensor.py | 2 +- .../test_simulation_context_visualizers.py | 4 +- .../changelog.d/launch-helper-cleanup.skip | 1 + .../test_ovphysx_scene_data_backend.py | 4 +- .../changelog.d/launch-helper-cleanup.rst | 6 + .../isaaclab_physx/app/kit_launcher.py | 143 +++++----- .../renderers/isaac_rtx_renderer.py | 2 +- .../sensors/contact_sensor/contact_sensor.py | 2 +- .../sim/views/fabric_frame_view.py | 4 +- .../test/app/test_kit_launcher.py | 116 ++++---- .../test/envs/test_manager_based_rl_env_ui.py | 4 +- .../test_isaac_rtx_renderer_contract.py | 8 +- .../test/sensors/test_contact_sensor.py | 10 +- .../changelog.d/launch-helper-cleanup.skip | 1 + .../isaaclab_rl/entrypoints/common.py | 5 +- .../export/leapp_initialized_checkpoints.py | 4 +- .../test/test_apply_video_recording.py | 2 +- source/isaaclab_rl/test/test_wrappers.py | 2 +- .../changelog.d/launch-helper-cleanup.skip | 1 + .../test_distributed_device_resolution.py | 31 +-- .../test/core/test_environment_determinism.py | 2 +- .../test/core/test_rl_device_separation.py | 2 +- .../test/core/test_runtime_compatibility.py | 13 +- .../test_sim_launcher_visualizer_intent.py | 44 ++- source/isaaclab_tasks/test/env_test_utils.py | 4 +- .../changelog.d/launch-helper-cleanup.skip | 1 + .../kit/kit_visualizer.py | 8 +- 43 files changed, 361 insertions(+), 484 deletions(-) create mode 100644 source/isaaclab/changelog.d/launch-helper-cleanup.major.rst create mode 100644 source/isaaclab_ov/changelog.d/launch-helper-cleanup.skip create mode 100644 source/isaaclab_physx/changelog.d/launch-helper-cleanup.rst create mode 100644 source/isaaclab_rl/changelog.d/launch-helper-cleanup.skip create mode 100644 source/isaaclab_tasks/changelog.d/launch-helper-cleanup.skip create mode 100644 source/isaaclab_visualizers/changelog.d/launch-helper-cleanup.skip diff --git a/docs/source/api/lab/isaaclab.app.rst b/docs/source/api/lab/isaaclab.app.rst index b5d5a4283861..5dc613433c63 100644 --- a/docs/source/api/lab/isaaclab.app.rst +++ b/docs/source/api/lab/isaaclab.app.rst @@ -18,7 +18,6 @@ add_launcher_args launch_simulation - make_physics_cfg report_activity scan @@ -36,8 +35,6 @@ Simulation Launcher .. autoclass:: AppLauncher :members: -.. autofunction:: make_physics_cfg - .. autofunction:: scan .. autoclass:: Scan diff --git a/source/isaaclab/changelog.d/launch-helper-cleanup.major.rst b/source/isaaclab/changelog.d/launch-helper-cleanup.major.rst new file mode 100644 index 000000000000..4850251d84b8 --- /dev/null +++ b/source/isaaclab/changelog.d/launch-helper-cleanup.major.rst @@ -0,0 +1,25 @@ +Changed +^^^^^^^ + +* **Breaking:** Changed ``--viz none`` to parse to an empty list instead of ``None``, so ``args_cli.visualizer is None`` + means no ``--viz`` was passed and ``[]`` means all visualizers are disabled. The ``visualizer_explicit`` and + ``visualizer_disable_all`` launcher arguments are removed; check ``args_cli.visualizer`` instead. +* Changed :func:`~isaaclab.app.launch_simulation` to also detect a ``newton_rtx`` visualizer listed in + ``visualizer_cfgs``, so the OVRTX runtime starts and the Kit conflict check applies. + +Added +^^^^^ + +* Added ``launcher_type`` to :class:`~isaaclab.physics.PhysicsCfg` and :class:`~isaaclab.renderers.RendererCfg`, + defaulting to ``None`` for configs whose runtime needs no launcher. + +Removed +^^^^^^^ + +* **Breaking:** Removed ``SettingsManager.instance()``. Use :func:`~isaaclab.app.get_settings_manager`. +* **Breaking:** Removed ``SettingsManager.set_bool``, ``set_int``, ``set_float``, and ``set_string``. Use + :meth:`~isaaclab.app.SettingsManager.set`, which dispatches on the value type. +* **Breaking:** Removed ``isaaclab.app.make_physics_cfg``. Select the backend with the ``physics`` launcher + argument of :func:`~isaaclab.app.launch_simulation`, or construct the physics config directly. +* **Breaking:** Removed ``isaaclab.app.logging_utils.resolve_python_logging_level``. + :func:`~isaaclab.app.launch_simulation` applies the ``--verbose`` / ``--info`` level itself. diff --git a/source/isaaclab/isaaclab/app/__init__.pyi b/source/isaaclab/isaaclab/app/__init__.pyi index 5cdf56e10f14..59e1da56024e 100644 --- a/source/isaaclab/isaaclab/app/__init__.pyi +++ b/source/isaaclab/isaaclab/app/__init__.pyi @@ -10,7 +10,6 @@ __all__ = [ "get_settings_manager", "add_launcher_args", "launch_simulation", - "make_physics_cfg", "scan", "Scan", "LoadingScreen", @@ -25,6 +24,5 @@ from .sim_launcher import ( SimulationLauncher, add_launcher_args, launch_simulation, - make_physics_cfg, scan, ) diff --git a/source/isaaclab/isaaclab/app/logging_utils.py b/source/isaaclab/isaaclab/app/logging_utils.py index f3b6ae9a69b4..0bf10e23b97f 100644 --- a/source/isaaclab/isaaclab/app/logging_utils.py +++ b/source/isaaclab/isaaclab/app/logging_utils.py @@ -3,52 +3,20 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Backend-agnostic resolution and application of the Python logging level. +"""Backend-agnostic application of the Python logging level. The logging intent expressed by the ``--verbose`` / ``--info`` CLI arguments must be -honored by every simulation backend, not just the Kit-based one. Keeping the resolution +honored by every simulation backend, not just the Kit-based one. Keeping the application here (rather than inside :class:`~isaaclab_physx.app.KitLauncher`) lets the kitless launch path apply the same level without constructing Kit. """ from __future__ import annotations -import argparse import logging -import sys from contextlib import contextmanager -def resolve_python_logging_level(launcher_args: argparse.Namespace | dict | None = None) -> int: - """Resolve the intended Python logging level from launcher args and ``sys.argv``. - - ``--verbose`` maps to :data:`logging.DEBUG` and ``--info`` to :data:`logging.INFO`. - When neither is requested, the current effective level of the root logger is kept - (falling back to :data:`logging.WARNING` when unset). - - Args: - launcher_args: Parsed launcher arguments as a namespace or dict. Defaults to None, - which is treated as an empty set of arguments. - - Returns: - The resolved logging level. - """ - if launcher_args is None: - args = {} - elif isinstance(launcher_args, argparse.Namespace): - args = launcher_args.__dict__ - else: - args = launcher_args - - if args.get("verbose", False) or "--verbose" in sys.argv: - return logging.DEBUG - if args.get("info", False) or "--info" in sys.argv: - return logging.INFO - - level = logging.getLogger().getEffectiveLevel() - return logging.WARNING if level == logging.NOTSET else level - - @contextmanager def force_log_level(level: int): """Context manager that temporarily lowers the root logger and its handlers to *level*, then restores them. diff --git a/source/isaaclab/isaaclab/app/settings_manager.py b/source/isaaclab/isaaclab/app/settings_manager.py index c1b13c964d74..e805d5460615 100644 --- a/source/isaaclab/isaaclab/app/settings_manager.py +++ b/source/isaaclab/isaaclab/app/settings_manager.py @@ -58,21 +58,6 @@ def __init__(self): self._backend = None self._needs_init = False - @classmethod - def instance(cls) -> "SettingsManager": - """Get the singleton instance of the settings manager. - - Returns: - The singleton SettingsManager instance - """ - # Get instance from sys.modules (survives module reloads) - instance = sys.modules.get(_SINGLETON_KEY) - - if instance is None: - instance = cls() - - return instance - def set_backend(self, backend: Any) -> None: """Delegate all settings to *backend* from now on. @@ -124,42 +109,6 @@ def get(self, path: str, default: Any = None) -> Any: # Standalone mode - use dictionary return self._standalone_settings.get(path, default) - def set_bool(self, path: str, value: bool) -> None: - """Set a boolean setting value. - - Args: - path: The settings path - value: The boolean value to set - """ - self.set(path, value) - - def set_int(self, path: str, value: int) -> None: - """Set an integer setting value. - - Args: - path: The settings path - value: The integer value to set - """ - self.set(path, value) - - def set_float(self, path: str, value: float) -> None: - """Set a float setting value. - - Args: - path: The settings path - value: The float value to set - """ - self.set(path, value) - - def set_string(self, path: str, value: str) -> None: - """Set a string setting value. - - Args: - path: The settings path - value: The string value to set - """ - self.set(path, value) - def get_settings_manager() -> SettingsManager: """Get the global settings manager instance. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index 7739cb1f8084..be0b47448e4a 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -15,6 +15,7 @@ import argparse import logging import os +import sys import warnings from collections.abc import Generator from contextlib import contextmanager @@ -32,7 +33,8 @@ from ..sensors.camera.camera_cfg import CameraCfg from ..utils.device import set_cuda_device from ..utils.string import string_to_callable -from .logging_utils import apply_python_logging_level, resolve_python_logging_level +from ..visualizers.visualizer_cfg import VisualizerCfg +from .logging_utils import apply_python_logging_level from .settings_manager import get_settings_manager logger = logging.getLogger(__name__) @@ -76,7 +78,7 @@ def add_launcher_args(parser: argparse.ArgumentParser) -> None: The arguments are defined by the Kit launcher, which consumes most of them. """ - string_to_callable(_KIT_LAUNCHER).add_app_launcher_args(parser) + string_to_callable(_KIT_LAUNCHER).add_launcher_args(parser) def fuse_kit_args(argv: list[str]) -> list[str]: @@ -107,7 +109,7 @@ def fuse_kit_args(argv: list[str]) -> list[str]: return fused -def make_physics_cfg(physics_cfg_str: str) -> PhysicsCfg: +def _make_physics_cfg(physics_cfg_str: str) -> PhysicsCfg: """Build a physics config for the requested backend. Args: @@ -143,36 +145,21 @@ def make_physics_cfg(physics_cfg_str: str) -> PhysicsCfg: """ -def _is_ovrtx_renderer(node) -> bool: - """True when the node is an OVRTX renderer config.""" - return isinstance(node, RendererCfg) and getattr(node, "renderer_type", None) == "ovrtx" - - -def _is_auto_rtx_renderer(node) -> bool: - """True when the node is an automatic RTX renderer placeholder.""" - return getattr(node, "renderer_type", None) == "auto_rtx" - - -def _is_auto_physx_physics(node) -> bool: - """True when the node is an automatic PhysX placeholder.""" - return isinstance(node, PhysxAutoCfg) - - def _is_kit_camera(node) -> bool: """True for a CameraCfg whose renderer requires Kit (not Newton).""" if not isinstance(node, CameraCfg): return False - renderer_cfg = getattr(node, "renderer_cfg", None) + renderer_cfg = node.renderer_cfg if renderer_cfg is None: return True - if _is_auto_rtx_renderer(renderer_cfg): - # ``auto_rtx`` is resolved after the initial scan once physics and - # visualizer intent are known; ie. it may become OVRTX for a kitless run. - return False if not isinstance(renderer_cfg, RendererCfg): raise TypeError( f"CameraCfg.renderer_cfg must be a concrete RendererCfg or None, got {type(renderer_cfg).__name__}." ) + if renderer_cfg.renderer_type == "auto_rtx": + # ``auto_rtx`` is resolved after the initial scan once physics and + # visualizer intent are known; ie. it may become OVRTX for a kitless run. + return False return renderer_cfg.renderer_type in ("default", "isaac_rtx") @@ -181,25 +168,8 @@ def _is_kit_camera(node) -> bool: """ -def _get_arg(launcher_args: argparse.Namespace | dict | None, key: str, default: Any = None) -> Any: - """Read *key* from launcher args, whether a namespace, dict, or ``None``.""" - if isinstance(launcher_args, argparse.Namespace): - return getattr(launcher_args, key, default) - if isinstance(launcher_args, dict): - return launcher_args.get(key, default) - return default - - -def _set_arg(launcher_args: argparse.Namespace | dict | None, key: str, value: Any) -> None: - """Write *key* on launcher args when it is a namespace or dict.""" - if isinstance(launcher_args, argparse.Namespace): - setattr(launcher_args, key, value) - elif isinstance(launcher_args, dict): - launcher_args[key] = value - - -def _parse_visualizer_csv(value: str) -> list[str] | None: - """Parse the ``--visualizer`` comma-separated list into canonical names; ``none`` yields None.""" +def _parse_visualizer_csv(value: str) -> list[str]: + """Parse the ``--visualizer`` comma-separated list into canonical names; ``none`` yields an empty list.""" token = (value or "").strip() if not token: raise argparse.ArgumentTypeError( @@ -235,33 +205,25 @@ def _parse_visualizer_csv(value: str) -> list[str] | None: raise argparse.ArgumentTypeError( "Invalid --visualizer value: 'none' cannot be combined with other visualizer types." ) - return None + return [] return list(dict.fromkeys(names)) -def _get_livestream_mode(launcher_args: argparse.Namespace | dict | None) -> int: - """Return the livestream mode: ``--livestream`` when set (>= 0), else the ``LIVESTREAM`` environment variable.""" - livestream = _get_arg(launcher_args, "livestream", -1) +def _normalize_launcher_args(args: dict) -> None: + """Resolve the livestream mode and the visualizer selection in place, once for every consumer. + + Writes ``livestream`` (the effective mode: ``--livestream`` when set (>= 0), else the ``LIVESTREAM`` + environment variable) and ``visualizer``: canonical names, an empty list when ``--viz none`` disabled + all visualizers, or None when no visualizer was requested. Livestreaming adds the Kit visualizer, whose + viewport produces the stream. Normalizing twice gives the same result. + """ + livestream = args.get("livestream", -1) if livestream is None or int(livestream) < 0: livestream = os.environ.get("LIVESTREAM", 0) livestream = int(livestream) if livestream not in (0, 1, 2): raise ValueError(f"Invalid livestream mode: {livestream}. Expected 0 (disabled), 1, or 2.") - return livestream - - -def _normalize_launcher_args(launcher_args: argparse.Namespace | dict | None) -> None: - """Resolve the livestream mode and the visualizer selection in place, once for every consumer. - - Writes ``livestream`` (the effective mode), ``visualizer`` (canonical names, or None when all are - disabled), ``visualizer_explicit`` and ``visualizer_disable_all``. Livestreaming adds the Kit - visualizer, whose viewport produces the stream. Normalizing twice gives the same result. - """ - if launcher_args is None: - return - livestream = _get_livestream_mode(launcher_args) - visualizers = _get_arg(launcher_args, "visualizer") - explicit = bool(_get_arg(launcher_args, "visualizer_explicit", False)) or visualizers is not None + visualizers = args.get("visualizer") if visualizers and not isinstance(visualizers, str): visualizers = ",".join(str(visualizer).strip() for visualizer in visualizers) if visualizers: @@ -269,49 +231,54 @@ def _normalize_launcher_args(launcher_args: argparse.Namespace | dict | None) -> visualizers = _parse_visualizer_csv(visualizers) except argparse.ArgumentTypeError as error: raise ValueError(str(error)) from error - disable_all = explicit and visualizers is None if livestream > 0: - if disable_all: + if visualizers == []: raise ValueError("Livestreaming requires the Kit visualizer. Remove '--viz none' or pass '--viz kit'.") if "kit" not in (visualizers or []): visualizers = [*(visualizers or []), "kit"] - explicit = True - _set_arg(launcher_args, "livestream", livestream) - _set_arg(launcher_args, "visualizer", visualizers) - _set_arg(launcher_args, "visualizer_explicit", explicit) - _set_arg(launcher_args, "visualizer_disable_all", disable_all) + args["livestream"] = livestream + args["visualizer"] = visualizers -def _sync_visualizer_cli_settings(launcher_args: argparse.Namespace | dict) -> None: +def _sync_visualizer_cli_settings(args: dict) -> None: """Write the normalized visualizer selection and ``--max_visible_envs`` to the settings.""" - max_visible_envs = _get_arg(launcher_args, "max_visible_envs") + max_visible_envs = args.get("max_visible_envs") if max_visible_envs is not None and int(max_visible_envs) < 0: raise ValueError(f"Invalid value for --max_visible_envs: {max_visible_envs}. Expected non-negative int.") + visualizers = args.get("visualizer") settings = get_settings_manager() - settings.set_string("/isaaclab/visualizer/types", " ".join(_get_arg(launcher_args, "visualizer") or ())) - settings.set_bool("/isaaclab/visualizer/explicit", bool(_get_arg(launcher_args, "visualizer_explicit", False))) - settings.set_bool( - "/isaaclab/visualizer/disable_all", bool(_get_arg(launcher_args, "visualizer_disable_all", False)) - ) + settings.set("/isaaclab/visualizer/types", " ".join(visualizers or ())) + settings.set("/isaaclab/visualizer/explicit", visualizers is not None) + settings.set("/isaaclab/visualizer/disable_all", visualizers == []) # Sentinel: ``-1`` means ``--max_visible_envs`` was not passed (see ``SimulationContext``). - settings.set_int("/isaaclab/visualizer/max_visible_envs", -1 if max_visible_envs is None else int(max_visible_envs)) - - -def _get_visualizer_intent(cfg, launcher_args: argparse.Namespace | dict | None) -> dict[str, bool]: - """Compute the visualizer intent of ``cfg.sim.visualizer_cfgs``, OR-ed with a caller's ``visualizer_intent``.""" - # Accept both env_cfg (has .sim.visualizer_cfgs) and a bare SimulationCfg - # (has .visualizer_cfgs directly). - sim = getattr(cfg, "sim", None) - visualizer_cfgs = getattr(sim, "visualizer_cfgs", None) or getattr(cfg, "visualizer_cfgs", None) - if visualizer_cfgs is None: - visualizer_cfgs = [] - cfgs = visualizer_cfgs if isinstance(visualizer_cfgs, list) else [visualizer_cfgs] - kit_cfgs = [c for c in cfgs if getattr(c, "visualizer_type", None) == "kit"] - caller_intent = _get_arg(launcher_args, "visualizer_intent") or {} + settings.set("/isaaclab/visualizer/max_visible_envs", -1 if max_visible_envs is None else int(max_visible_envs)) + + +def _resolve_python_logging_level(args: dict) -> int: + """Return the level for ``--verbose`` / ``--info`` (also read from ``sys.argv``), else the current root level.""" + if args.get("verbose", False) or "--verbose" in sys.argv: + return logging.DEBUG + if args.get("info", False) or "--info" in sys.argv: + return logging.INFO + level = logging.getLogger().getEffectiveLevel() + return logging.WARNING if level == logging.NOTSET else level + + +def _get_visualizer_intent(visualizer_cfgs: list[VisualizerCfg], args: dict) -> dict[str, bool]: + """Compute the intent of the config's visualizers, OR-ed with a caller's ``visualizer_intent``. + + An explicit ``--visualizer`` selection overrides whether the config's Kit visualizer is used. + """ + kit_cfgs = [cfg for cfg in visualizer_cfgs if cfg.visualizer_type == "kit"] + caller_intent = args.get("visualizer_intent") or {} + if args["visualizer"] is not None: + has_kit_visualizer = "kit" in args["visualizer"] + else: + has_kit_visualizer = bool(kit_cfgs) or bool(caller_intent.get("has_kit_visualizer")) + has_kit_streaming_view = any(cfg.streaming_view for cfg in kit_cfgs) return { - "has_kit_visualizer": bool(kit_cfgs) or bool(caller_intent.get("has_kit_visualizer")), - "has_kit_streaming_view": any(bool(getattr(c, "streaming_view", False)) for c in kit_cfgs) - or bool(caller_intent.get("has_kit_streaming_view")), + "has_kit_visualizer": has_kit_visualizer, + "has_kit_streaming_view": has_kit_streaming_view or bool(caller_intent.get("has_kit_streaming_view")), } @@ -356,7 +323,7 @@ def scan(cfg, launcher_args: argparse.Namespace | dict | None = None) -> Scan: """Walk *cfg* once, collecting all launch signals and applying ``--physics``. When the ``physics`` key is present in *launcher_args*, every physics config is - replaced by the requested backend (see :func:`make_physics_cfg`): nested configs + replaced by the requested backend (see :func:`launch_simulation`): nested configs in place, a root config via :attr:`Scan.effective_cfg` (it cannot be mutated in place). Automatic PhysX configurations and RTX renderer placeholders (``renderer_type="auto_rtx"``) are also resolved @@ -366,29 +333,32 @@ def scan(cfg, launcher_args: argparse.Namespace | dict | None = None) -> Scan: a second walk of the same config observes the same signals and reaches the same launch decision. """ + args = vars(launcher_args) if isinstance(launcher_args, argparse.Namespace) else launcher_args + args = {} if args is None else args # Livestreaming implies a Kit visualizer; make that visible to auto RTX resolution. - _normalize_launcher_args(launcher_args) + _normalize_launcher_args(args) - physics_str = _get_arg(launcher_args, "physics", None) + physics_str = args.get("physics") physics_cfgs: list[PhysicsCfg] = [] concrete_physics_cfgs: list[PhysicsCfg] = [] effective_cfg: Any = cfg - has_ovrtx = "newton_rtx" in (_get_arg(launcher_args, "visualizer") or ()) + has_ovrtx = "newton_rtx" in (args["visualizer"] or ()) has_auto_rtx = False has_auto_physx = False has_kit_camera = False auto_rtx_locations: list[tuple[Any, Any, bool]] = [] # (parent, key, is_cam_renderer) for each auto RTX placeholder auto_physx_locations: list[tuple[PhysicsCfg, Any, Any, bool]] = [] # (node, parent, key, is_first_physics) launcher_types: list[str] = [] + visualizer_cfgs: list[VisualizerCfg] = [] visited: set[int] = set() - def add_launcher_type(node): - if getattr(node, "launcher_type", None): + def add_launcher_type(node: PhysicsCfg | RendererCfg): + if node.launcher_type: launcher_types.append(node.launcher_type) def visit(node, parent, key): nonlocal effective_cfg, has_ovrtx, has_auto_rtx, has_auto_physx, has_kit_camera - if _is_auto_rtx_renderer(node): + if isinstance(node, RendererCfg) and node.renderer_type == "auto_rtx": has_auto_rtx = True auto_rtx_locations.append((parent, key, isinstance(parent, CameraCfg) and key == "renderer_cfg")) @@ -398,19 +368,21 @@ def visit(node, parent, key): if isinstance(node, PhysicsCfg): if physics_str: - node = make_physics_cfg(physics_str) + node = _make_physics_cfg(physics_str) if parent is not None: setattr(parent, key, node) else: effective_cfg = node physics_cfgs.append(node) - if _is_auto_physx_physics(node): + if isinstance(node, PhysxAutoCfg): has_auto_physx = True auto_physx_locations.append((node, parent, key, len(physics_cfgs) == 1)) return else: concrete_physics_cfgs.append(node) - elif _is_ovrtx_renderer(node) or getattr(node, "visualizer_type", None) == "newton_rtx": + elif isinstance(node, VisualizerCfg): + visualizer_cfgs.append(node) + elif isinstance(node, RendererCfg) and node.renderer_type == "ovrtx": has_ovrtx = True elif _is_kit_camera(node): has_kit_camera = True @@ -424,15 +396,20 @@ def visit(node, parent, key): for name, child in children.items(): if child is None or isinstance(child, (int, float, str, bool)): continue + if isinstance(child, (list, tuple)): + # sequences are not walked, except to collect visualizers, e.g. ``SimulationCfg.visualizer_cfgs`` + visualizer_cfgs.extend(item for item in child if isinstance(item, VisualizerCfg)) + continue visit(child, node, name) visit(cfg, None, None) + has_ovrtx = has_ovrtx or any(visualizer_cfg.visualizer_type == "newton_rtx" for visualizer_cfg in visualizer_cfgs) has_physics = bool(physics_cfgs) config_scan = Scan( resolved_physics_cfg=physics_cfgs[0] if physics_cfgs else None, effective_cfg=effective_cfg, - visualizer_intent=_get_visualizer_intent(cfg, launcher_args), + visualizer_intent=_get_visualizer_intent(visualizer_cfgs, args), has_ovrtx=has_ovrtx, has_kit_camera=has_kit_camera, has_kit_physics=False, @@ -444,7 +421,7 @@ def visit(node, parent, key): # RTX selection depends on the resolved physics backend. if has_auto_physx: - use_isaac_sim = bool(_get_kit_runtime_sources(config_scan, launcher_args)) + use_isaac_sim = bool(_get_kit_runtime_sources(config_scan, args)) for node, parent, key, is_first_physics in auto_physx_locations: physics_cfg = _resolve_physx_auto_cfg(node, use_isaac_sim) concrete_physics_cfgs.append(physics_cfg) @@ -463,7 +440,7 @@ def visit(node, parent, key): if not has_auto_rtx: return config_scan - use_isaac_sim = bool(_get_kit_runtime_sources(config_scan, launcher_args)) + use_isaac_sim = bool(_get_kit_runtime_sources(config_scan, args)) renderer_factory = IsaacRtxRendererCfg if use_isaac_sim else OVRTXRendererCfg # Resolve every auto RTX placeholder in place, tracking camera renderers that may require Kit. @@ -491,27 +468,20 @@ def visit(node, parent, key): """ -def _has_kit_visualizer(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> bool: - """Return whether the run uses the Kit visualizer; an explicit CLI selection overrides the config.""" - if _get_arg(launcher_args, "visualizer_explicit", False): - return "kit" in (_get_arg(launcher_args, "visualizer") or ()) - return config_scan.visualizer_intent["has_kit_visualizer"] - - -def _get_kit_runtime_sources(config_scan: Scan, launcher_args: argparse.Namespace | dict | None) -> tuple[str, ...]: - """Return the config and launcher components that require Isaac Sim / Kit.""" +def _get_kit_runtime_sources(config_scan: Scan, args: dict) -> tuple[str, ...]: + """Return the config and launcher components that require Isaac Sim / Kit; *args* are normalized by :func:`scan`.""" kit_sources = [] if config_scan.has_kit_physics: kit_sources.append("Isaac Sim PhysX physics (`PhysxCfg`)") if config_scan.has_kit_camera: kit_sources.append('a Kit-based renderer (`IsaacRtxRendererCfg`, `renderer_type="isaac_rtx"`)') - if _has_kit_visualizer(config_scan, launcher_args): + if config_scan.visualizer_intent["has_kit_visualizer"]: kit_sources.append('the Kit visualizer (`--visualizer kit` / `visualizer_type="kit"`)') - if _get_arg(launcher_args, "experience", ""): + if args.get("experience", ""): kit_sources.append("an explicit Kit experience") - if _get_livestream_mode(launcher_args) > 0: + if args.get("livestream", 0) > 0: kit_sources.append("livestreaming") - if _get_arg(launcher_args, "require_kit", False): + if args.get("require_kit", False): kit_sources.append("the caller's explicit Kit requirement") if config_scan.needs_kit and not kit_sources: kit_sources.append("the default Isaac Sim / Kit runtime") @@ -519,13 +489,6 @@ def _get_kit_runtime_sources(config_scan: Scan, launcher_args: argparse.Namespac return tuple(kit_sources) -def _format_runtime_sources(sources: tuple[str, ...]) -> str: - """Format runtime sources as a readable list.""" - if len(sources) < 3: - return " and ".join(sources) - return f"{', '.join(sources[:-1])}, and {sources[-1]}" - - def _validate_runtime(scan: Scan, kit_sources: tuple[str, ...]) -> None: """Raise if the scan and resolved Kit sources form an unsupported combination. @@ -537,7 +500,7 @@ def _validate_runtime(scan: Scan, kit_sources: tuple[str, ...]) -> None: # deep inside its own library with "Failed to initialize Carbonite and load PhysX plugins" raise ValueError( "Invalid backend combination: OvPhysX physics (`OvPhysxCfg`) is kitless and cannot be used together " - f"with Isaac Sim / Kit ({_format_runtime_sources(kit_sources)}).\n" + f"with Isaac Sim / Kit ({', '.join(kit_sources)}).\n" "\n" "To fix this, pick one of the following supported combinations:\n" " * Keep OvPhysX physics and switch to a kitless renderer/visualizer:\n" @@ -554,7 +517,7 @@ def _validate_runtime(scan: Scan, kit_sources: tuple[str, ...]) -> None: raise ValueError( "Invalid backend combination: the OVRTX runtime (`OVRTXRendererCfg` or the" " `newton_rtx` visualizer) cannot be used together" - f" with Isaac Sim / Kit ({_format_runtime_sources(kit_sources)}).\n" + f" with Isaac Sim / Kit ({', '.join(kit_sources)}).\n" "\n" "To fix this, pick one of the following supported combinations:\n" " * Keep Isaac Sim / Kit and switch the renderer:\n" @@ -563,13 +526,13 @@ def _validate_runtime(scan: Scan, kit_sources: tuple[str, ...]) -> None: ) -def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | None) -> None: +def _resolve_distributed_device(cfg, args: dict) -> None: """Set ``cfg.sim.device`` and the launcher ``device`` for distributed training. When ``--distributed`` restricts each process to one GPU, ``local_rank`` may exceed the visible device count, so the process falls back to the one GPU it can see. """ - if not _get_arg(launcher_args, "distributed", False): + if not args.get("distributed", False): return import torch @@ -582,7 +545,7 @@ def _resolve_distributed_device(cfg, launcher_args: argparse.Namespace | dict | sim_cfg = getattr(cfg, "sim", None) if sim_cfg is not None: sim_cfg.device = device_str - _set_arg(launcher_args, "device", device_str) + args["device"] = device_str set_cuda_device(device_str) logger.info( "Distributed device resolved to %s (local_rank=%d, visible_gpus=%d)", @@ -618,8 +581,9 @@ def launch_simulation( arguments added by :func:`add_launcher_args`, the following keys are read when a script contributes them: - * ``physics``: Backend selector applied to every physics config in *cfg*, see - :func:`make_physics_cfg`. + * ``physics``: Backend selector applied to every physics config in *cfg*: ``"physx"`` + (Isaac Sim PhysX when Kit is required, else OvPhysX), ``"isaacsim_physx"``, + ``"newton_mjwarp"``, ``"newton_vbd"``, or ``"ovphysx"``. * ``require_kit``: Whether the caller needs Kit for a reason *cfg* cannot express, e.g. a tool that reaches a Kit-only extension API. This is additive -- it can only turn a kitless launch into a Kit one, never the reverse, so a config that already needs Kit @@ -627,33 +591,34 @@ def launch_simulation( * ``visualizer_intent``: Visualizer intent the config cannot express, e.g. ``{"has_kit_visualizer": True}``; it is combined with the intent of *cfg*. """ - if launcher_args is None: - launcher_args = {} + # writes to ``args`` reach the caller's namespace or dict + args = vars(launcher_args) if isinstance(launcher_args, argparse.Namespace) else launcher_args + args = {} if args is None else args # The single walk: collect every signal, apply the --physics override, and # resolve the automatic PhysX and RTX placeholders. - config_scan = scan(cfg, launcher_args) + config_scan = scan(cfg, args) effective_cfg = config_scan.effective_cfg physics_cfg = config_scan.resolved_physics_cfg - kit_sources = _get_kit_runtime_sources(config_scan, launcher_args) + kit_sources = _get_kit_runtime_sources(config_scan, args) _validate_runtime(config_scan, kit_sources) needs_kit = bool(kit_sources) - _set_arg(launcher_args, "kit_visualizer", _has_kit_visualizer(config_scan, launcher_args)) + args["kit_visualizer"] = config_scan.visualizer_intent["has_kit_visualizer"] # Honor --verbose / --info; the Kit launcher re-applies this level once Kit has started. - apply_python_logging_level(resolve_python_logging_level(launcher_args)) + apply_python_logging_level(_resolve_python_logging_level(args)) if needs_kit and (config_scan.has_kit_camera or config_scan.visualizer_intent.get("has_kit_streaming_view")): - if not _get_arg(launcher_args, "enable_cameras", False): + if not args.get("enable_cameras", False): logger.info( "Auto-enabling camera rendering because the scene contains Kit camera sensors " "or a Kit visualizer with streaming_view=True." ) - _set_arg(launcher_args, "enable_cameras", True) + args["enable_cameras"] = True # Resolve distributed device early, before any launcher or physics init. - _resolve_distributed_device(effective_cfg, launcher_args) + _resolve_distributed_device(effective_cfg, args) # Start the launchers the resolved config names, plus Kit and OVRTX for needs that no config names # (e.g. a default-renderer camera, ``--viz kit`` or ``--viz newton_rtx``); Kit starts first. @@ -662,14 +627,14 @@ def launch_simulation( if config_scan.has_ovrtx: # validated above: OVRTX never shares the process with Kit launcher_types.append(OVRTXRendererCfg.launcher_type) - launchers = [string_to_callable(launcher_type)(launcher_args) for launcher_type in dict.fromkeys(launcher_types)] + launchers = [string_to_callable(launcher_type)(args) for launcher_type in dict.fromkeys(launcher_types)] for launcher in launchers: # the runtime may refine the device choice made by _resolve_distributed_device sim_cfg = getattr(effective_cfg, "sim", None) if sim_cfg is not None and launcher.device is not None: sim_cfg.device = launcher.device # after the launchers, so a started Kit already backs the settings - _sync_visualizer_cli_settings(launcher_args) + _sync_visualizer_cli_settings(args) exit_code = 0 try: diff --git a/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad.py b/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad.py index cc7f4a2c6e44..aac6b99d2a79 100644 --- a/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad.py +++ b/source/isaaclab/isaaclab/devices/gamepad/se2_gamepad.py @@ -56,7 +56,7 @@ def __init__(self, cfg: Se2GamepadCfg): cfg: Configuration object for gamepad settings. """ # turn off simulator gamepad control - get_settings_manager().set_bool("/persistent/app/omniverse/gamepadCameraControl", False) + get_settings_manager().set("/persistent/app/omniverse/gamepadCameraControl", False) # store inputs self.v_x_sensitivity = cfg.v_x_sensitivity self.v_y_sensitivity = cfg.v_y_sensitivity diff --git a/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py b/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py index 13c7be89e7a9..d433b6e18fab 100644 --- a/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py +++ b/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py @@ -63,7 +63,7 @@ def __init__(self, cfg: Se3GamepadCfg): cfg: Configuration object for gamepad settings. """ # turn off simulator gamepad control - get_settings_manager().set_bool("/persistent/app/omniverse/gamepadCameraControl", False) + get_settings_manager().set("/persistent/app/omniverse/gamepadCameraControl", False) # store inputs self.pos_sensitivity = cfg.pos_sensitivity self.rot_sensitivity = cfg.rot_sensitivity diff --git a/source/isaaclab/isaaclab/physics/physics_manager_cfg.py b/source/isaaclab/isaaclab/physics/physics_manager_cfg.py index 60249f14cda3..b259d9511b2b 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager_cfg.py +++ b/source/isaaclab/isaaclab/physics/physics_manager_cfg.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import MISSING -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from ..utils import configclass @@ -34,6 +34,9 @@ class PhysicsCfg: class_type: type[PhysicsManager] | Any = MISSING """The physics manager class to use. Must be set by subclasses.""" + launcher_type: ClassVar[str | None] = None + """The launcher that starts the runtime this backend runs in, as ``"module:Class"``, or None if none is needed.""" + deterministic: bool = False """Whether to request reproducible physics from the backend. Defaults to False. diff --git a/source/isaaclab/isaaclab/renderers/renderer_cfg.py b/source/isaaclab/isaaclab/renderers/renderer_cfg.py index 2ba9d495018d..8ed1d9f73204 100644 --- a/source/isaaclab/isaaclab/renderers/renderer_cfg.py +++ b/source/isaaclab/isaaclab/renderers/renderer_cfg.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from ..sim.simulation_cfg import BackendCfg from ..utils import configclass @@ -26,6 +26,9 @@ class RendererCfg(BackendCfg): renderer_type: str = "default" + launcher_type: ClassVar[str | None] = None + """The launcher that starts the runtime this renderer needs, as ``"module:Class"``, or None if none is needed.""" + cloning_contexts: tuple[type | str, ...] = () """Clone contexts that build this renderer's scene representation from the asset plan.""" diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index bd396e252450..5d1c98e9d1bc 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -260,15 +260,15 @@ def __init__(self, cfg: CameraCfg): from ...app.settings_manager import get_settings_manager settings = get_settings_manager() - settings.set_bool("/isaaclab/render/rtx_sensors", True) - settings.set_bool("/physics/fabricUpdateTransformations", True) + settings.set("/isaaclab/render/rtx_sensors", True) + settings.set("/physics/fabricUpdateTransformations", True) if require_hdr_output: - settings.set_bool("/rtx/rtpt/gaussian/skipTonemapping/enabled", False) + settings.set("/rtx/rtpt/gaussian/skipTonemapping/enabled", False) elif renderer_type == "ovrtx" and require_hdr_output: from ...app.settings_manager import get_settings_manager - get_settings_manager().set_bool("/rtx/rtpt/gaussian/skipTonemapping/enabled", False) - # FIXME: settings set_bool is a no-op for ovrtx + get_settings_manager().set("/rtx/rtpt/gaussian/skipTonemapping/enabled", False) + # FIXME: settings.set is a no-op for ovrtx # warning only since it affects only ParticleField3DGaussianSplat scene logger.warning( "OVRTX backend with PPISP/HDR requires /rtx/rtpt/gaussian/skipTonemapping/enabled to be false." diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index a839f37b5768..e79108185f91 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -17,7 +17,7 @@ import warp as wp from .. import sim as sim_utils -from ..app.settings_manager import SettingsManager +from ..app.settings_manager import SettingsManager, get_settings_manager from ..markers.vis_marker_registry import VisMarkerRegistry from ..physics import PhysicsCfg, PhysicsEvent, PhysicsManager from ..physics.physics_manager_cfg import _resolve_physx_auto_cfg @@ -69,15 +69,7 @@ def __init__(self, settings: SettingsManager): def set(self, name: str, value: Any) -> None: """Set a setting with automatic type routing.""" - if isinstance(value, bool): - self._settings.set_bool(name, value) - elif isinstance(value, int): - self._settings.set_int(name, value) - elif isinstance(value, float): - self._settings.set_float(name, value) - elif isinstance(value, str): - self._settings.set_string(name, value) - elif isinstance(value, (list, tuple)): + if isinstance(value, (bool, int, float, str, list, tuple)): self._settings.set(name, value) else: raise ValueError(f"Unsupported value type for setting '{name}': {type(value)}") @@ -189,7 +181,7 @@ def __init__(self, cfg: SimulationCfg | None = None): self.get_or_create_backend(KitStageBackendCfg(stage=self.stage)) # Acquire settings interface (SettingsManager: standalone dict or Omniverse when available) - self.settings = SettingsManager.instance() + self.settings = get_settings_manager() self._settings_helper = SettingsHelper(self.settings) # Initialize USD physics scene and physics manager @@ -1140,7 +1132,7 @@ def build_simulation_context( sim_cfg.device = device if visualizers: - SettingsManager.instance().set_string("/isaaclab/visualizer/types", " ".join(visualizers)) + get_settings_manager().set("/isaaclab/visualizer/types", " ".join(visualizers)) sim = SimulationContext(sim_cfg) diff --git a/source/isaaclab/test/app/test_launch_simulation_require_kit.py b/source/isaaclab/test/app/test_launch_simulation_require_kit.py index fa88ee8b9f12..ef7ce8503801 100644 --- a/source/isaaclab/test/app/test_launch_simulation_require_kit.py +++ b/source/isaaclab/test/app/test_launch_simulation_require_kit.py @@ -26,6 +26,7 @@ from isaaclab.app import SimulationLauncher, launch_simulation from isaaclab.physics import PhysicsCfg from isaaclab.renderers import RendererCfg +from isaaclab.visualizers import VisualizerCfg @pytest.fixture @@ -135,7 +136,7 @@ def test_kitless_ovrtx_registers_before_user_code(monkeypatch: pytest.MonkeyPatc monkeypatch.setattr(assets_utils, "configure_storage_profile", lambda: calls.append("storage")) cfg = argparse.Namespace( physics=sim_launcher.NewtonCfg(), - visualizer_cfgs=argparse.Namespace(visualizer_type="newton_rtx"), + visualizer_cfgs=VisualizerCfg(visualizer_type="newton_rtx"), ) with launch_simulation(cfg): @@ -169,7 +170,7 @@ class CustomRendererCfg(RendererCfg): def test_require_kit_false_does_not_suppress_a_kit_config(kit_branch_taken): # '--viz kit' requires Kit on its own; require_kit=False must not override that - launcher_args = {"visualizer": ["kit"], "visualizer_explicit": True, "require_kit": False} + launcher_args = {"visualizer": ["kit"], "require_kit": False} with launch_simulation(cfg=PhysicsCfg(), launcher_args=launcher_args): pass diff --git a/source/isaaclab/test/cli/test_train_multigpu_command_building.py b/source/isaaclab/test/cli/test_train_multigpu_command_building.py index b52aeba604aa..d1a5a37d24b1 100644 --- a/source/isaaclab/test/cli/test_train_multigpu_command_building.py +++ b/source/isaaclab/test/cli/test_train_multigpu_command_building.py @@ -65,7 +65,7 @@ def _parse_as_training_script(child_argv: list[str], monkeypatch: pytest.MonkeyP class TestAddAppLauncherArgsNormalization: - """Integration tests for the ``sys.argv`` normalization in ``add_app_launcher_args``.""" + """Integration tests for the ``sys.argv`` normalization in ``add_launcher_args``.""" def test_unknown_leftovers_are_preserved(self, monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--task", "X", "--kit_args", "--foo=/bar", "env.param=1"]) diff --git a/source/isaaclab/test/envs/test_action_state_recorder_term.py b/source/isaaclab/test/envs/test_action_state_recorder_term.py index 25d2a74edefd..9f60cdbbcc86 100644 --- a/source/isaaclab/test/envs/test_action_state_recorder_term.py +++ b/source/isaaclab/test/envs/test_action_state_recorder_term.py @@ -28,7 +28,7 @@ @pytest.fixture(scope="session", autouse=True) def setup_carb_settings(): """Set up settings to prevent simulation getting stuck.""" - get_settings_manager().set_bool("/physics/cooking/ujitsoCollisionCooking", False) + get_settings_manager().set("/physics/cooking/ujitsoCollisionCooking", False) @pytest.fixture diff --git a/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py b/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py index cecc54ae3c68..72aa066a5c9e 100644 --- a/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py +++ b/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py @@ -35,7 +35,7 @@ @pytest.fixture(scope="session", autouse=True) def setup_carb_settings(): """Set up settings to prevent simulation getting stuck.""" - get_settings_manager().set_bool("/physics/cooking/ujitsoCollisionCooking", False) + get_settings_manager().set("/physics/cooking/ujitsoCollisionCooking", False) @pytest.fixture diff --git a/source/isaaclab/test/sensors/test_outdated_sensor.py b/source/isaaclab/test/sensors/test_outdated_sensor.py index d2b0aaac6c52..5450d51b976f 100644 --- a/source/isaaclab/test/sensors/test_outdated_sensor.py +++ b/source/isaaclab/test/sensors/test_outdated_sensor.py @@ -28,7 +28,7 @@ def temp_dir(): """Fixture to create and clean up a temporary directory for test datasets.""" # this flag is necessary to prevent a bug where the simulation gets stuck randomly when running the # test on many environments. - get_settings_manager().set_bool("/physics/cooking/ujitsoCollisionCooking", False) + get_settings_manager().set("/physics/cooking/ujitsoCollisionCooking", False) # create a temporary directory to store the test datasets temp_dir = tempfile.mkdtemp() yield temp_dir diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 0f3af5f9dbbb..518241600249 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -777,9 +777,7 @@ def test_visualizer_construction_precedes_initialization_and_happens_once(monkey monkeypatch.setattr(context_module, "has_kit", lambda: False) monkeypatch.setattr(context_module, "SceneDataProvider", lambda backend: _FakeProvider()) monkeypatch.setattr( - context_module.SettingsManager, - "instance", - lambda: SimpleNamespace(get=settings.get, set_bool=settings.__setitem__), + context_module, "get_settings_manager", lambda: SimpleNamespace(get=settings.get, set=settings.__setitem__) ) monkeypatch.setattr(SimulationContext, "_init_usd_physics_scene", lambda self: None) monkeypatch.setattr(SimulationContext, "_instance", None) diff --git a/source/isaaclab_ov/changelog.d/launch-helper-cleanup.skip b/source/isaaclab_ov/changelog.d/launch-helper-cleanup.skip new file mode 100644 index 000000000000..a3cf5c64b516 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/launch-helper-cleanup.skip @@ -0,0 +1 @@ +Updated tests for the launcher and settings helper cleanup. diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index 7a1ddbe8c723..21b5137f14d0 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -746,7 +746,7 @@ def test_automatic_physx_selection_prepares_ovphysx_before_stage_creation(monkey from isaaclab_ov.physics import OvPhysxManager import isaaclab.sim.simulation_context as simulation_context_module - from isaaclab.app.sim_launcher import make_physics_cfg + from isaaclab.app.sim_launcher import _make_physics_cfg from isaaclab.sim import SimulationCfg, SimulationContext class StageCreationReached(Exception): @@ -774,7 +774,7 @@ def _stop_at_stage_creation(): monkeypatch.setattr(simulation_context_module, "create_new_stage", _stop_at_stage_creation) cfg = SimulationCfg(create_stage_in_memory=True) - physics_cfg = make_physics_cfg("physx") + physics_cfg = _make_physics_cfg("physx") physics_cfg.class_type = StubPhysxManager cfg.physics = physics_cfg diff --git a/source/isaaclab_physx/changelog.d/launch-helper-cleanup.rst b/source/isaaclab_physx/changelog.d/launch-helper-cleanup.rst new file mode 100644 index 000000000000..2617b510692c --- /dev/null +++ b/source/isaaclab_physx/changelog.d/launch-helper-cleanup.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* **Breaking:** Renamed ``KitLauncher.add_app_launcher_args`` to + :meth:`~isaaclab_physx.app.KitLauncher.add_launcher_args`, matching :func:`~isaaclab.app.add_launcher_args`. + Replace the calls, or call :func:`~isaaclab.app.add_launcher_args`. diff --git a/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py b/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py index a83bf20e2859..8f697f964a96 100644 --- a/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py +++ b/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py @@ -76,13 +76,15 @@ def _sanitize_sys_argv_for_kit(argv: list[str]) -> list[str]: return [argument for index, argument in enumerate(argv) if index not in indexes_to_remove] -class ExplicitAction(argparse.Action): - """Custom action to track if an argument was explicitly passed by the user.""" +class _StoreAndMarkExplicit(argparse.Action): + """Store the value and set ``_explicit``, for options whose default must stay a real value. + + ``--device`` defaults to ``"cuda:0"`` because scripts read ``args_cli.device`` before launch, yet XR + needs to know whether it was passed (see :meth:`KitLauncher._resolve_device_settings`). + """ def __call__(self, parser, namespace, values, option_string=None): - # Set the parameter value setattr(namespace, self.dest, values) - # Set a flag indicating the parameter was explicitly passed setattr(namespace, f"{self.dest}_explicit", True) @@ -197,7 +199,8 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None): hook_name="isaaclab stage context", ) ) - self._set_deferred_cuda_device() + if self._deferred_cuda_device_id is not None: + set_cuda_device(self._deferred_cuda_device_id) # Load IsaacSim extensions self._load_extensions() @@ -210,7 +213,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None): _deprioritize_prebundle_paths() # Hide the stop button in the toolbar - self._hide_stop_button() + self._set_toolbar_button_visible("_stop_button", False) # Hide play button callback if the timeline is stopped import omni.timeline @@ -219,14 +222,16 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None): omni.timeline.get_timeline_interface() .get_timeline_event_stream() .create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.STOP), lambda e: self._hide_play_button(True) + int(omni.timeline.TimelineEventType.STOP), + lambda e: self._set_toolbar_button_visible("_play_button", False), ) ) self._unhide_play_button_callback = ( omni.timeline.get_timeline_interface() .get_timeline_event_stream() .create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda e: self._hide_play_button(False) + int(omni.timeline.TimelineEventType.PLAY), + lambda e: self._set_toolbar_button_visible("_play_button", True), ) ) # the CI runner greps this marker for hang detection; __stderr__ survives @@ -259,7 +264,7 @@ def is_available(cls) -> bool: return SimulationApp is not None @staticmethod - def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: + def add_launcher_args(parser: argparse.ArgumentParser) -> None: """Utility function to configure KitLauncher arguments with an existing argument parser object. This function appends the command-line arguments relevant to the SimulationApp to the input @@ -349,7 +354,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: arg_group.add_argument( "--device", type=str, - action=ExplicitAction, + action=_StoreAndMarkExplicit, default="cuda:0", help='The device to run the simulation on. Can be "cpu", "cuda", "cuda:N", where N is the device ID', ) @@ -357,7 +362,6 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "--visualizer", "--viz", type=_parse_visualizer_csv, - action=ExplicitAction, default=None, help="Visualizer backends to enable as CSV (e.g., kit,newton,rerun,viser).", ) @@ -526,9 +530,7 @@ def _resolve_xr_settings(self, launcher_args: dict): # CLI, we auto-inject one so that app.update() and forward() are pumped # each frame -- the XR runtime needs both to receive updated hand/joint # transforms. - self._xr_auto_start = self._xr and not ( - launcher_args.get("visualizer_explicit", False) and "kit" in (launcher_args.get("visualizer") or ()) - ) + self._xr_auto_start = self._xr and "kit" not in (launcher_args.get("visualizer") or ()) def _resolve_viewport_settings(self, launcher_args: dict): """Resolve viewport related settings.""" @@ -614,13 +616,6 @@ def _initialize_preloaded_torch_cuda(self) -> None: if torch is not None and not torch.cuda.is_initialized(): torch.cuda.init() - def _set_deferred_cuda_device(self) -> None: - """Set the current CUDA device after Kit startup.""" - if self._deferred_cuda_device_id is None: - return - - set_cuda_device(self._deferred_cuda_device_id) - def _resolve_experience_file(self, launcher_args: dict): """Resolve experience file related settings.""" # Check if input keywords contain an 'experience' file setting @@ -690,21 +685,18 @@ def _resolve_experience_file(self, launcher_args: dict): # Resolve the absolute path of the experience file self._sim_experience_file = os.path.abspath(self._sim_experience_file) # Detect a known incompatibility between Isaac Lab and Isaac Sim full-app experiences. - if self._livestream in {1, 2} and self._experience_depends_on_isaacsim_exp_full(self._sim_experience_file): - raise ValueError( - "The experience file depends on 'isaacsim.exp.full', which is known to hang or invalidate PhysX " - "tensor views when launched through Isaac Lab with livestreaming enabled. Omit '--experience' so " - "KitLauncher can select an Isaac Lab experience file, or remove the 'isaacsim.exp.full' dependency." - ) + if self._livestream in {1, 2}: + with open(self._sim_experience_file, encoding="utf-8") as file: + experience = file.read() + if re.search(r'^\s*["\']isaacsim\.exp\.full["\']\s*=', experience, re.MULTILINE): + raise ValueError( + "The experience file depends on 'isaacsim.exp.full', which is known to hang or invalidate PhysX " + "tensor views when launched through Isaac Lab with livestreaming enabled. Omit '--experience' so " + "KitLauncher can select an Isaac Lab experience file, or remove the 'isaacsim.exp.full' dependency." + ) self._deterministic_rendering = bool(deterministic_mode) logger.info("Loading experience file: %s", self._sim_experience_file) - @staticmethod - def _experience_depends_on_isaacsim_exp_full(experience_file: str) -> bool: - """Return whether a Kit experience directly depends on ``isaacsim.exp.full``.""" - with open(experience_file, encoding="utf-8") as file: - return re.search(r'^\s*["\']isaacsim\.exp\.full["\']\s*=', file.read(), re.MULTILINE) is not None - def _resolve_anim_recording_settings(self, launcher_args: dict): """Resolve animation recording settings; recording needs the ``omni.physx.pvd`` extension.""" self._anim_recording = None @@ -721,10 +713,6 @@ def _resolve_anim_recording_settings(self, launcher_args: dict): self._anim_recording = (start_time, stop_time) sys.argv += ["--enable", "omni.physx.pvd"] - def _requires_all_partitions_spectator_view(self) -> bool: - """Return whether the launch needs an unpartitioned all-environment view.""" - return self._kit_visualizer or self._render_viewport or self._video_enabled or self._livestream > 0 or self._xr - def _resolve_kit_args(self, launcher_args: dict): """Resolve additional arguments passed to Kit.""" self._kit_args = launcher_args.get("kit_args", "").split() @@ -746,9 +734,9 @@ def _resolve_kit_args(self, launcher_args: dict): if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): self._kit_args.append(argument) - # RTX allocates spectator support during startup, so visual output intent - # must become a Kit argument before SimulationApp is created. - if self._requires_all_partitions_spectator_view(): + # RTX allocates spectator support during startup, so visual output intent (which needs an + # unpartitioned all-environment view) must become a Kit argument before SimulationApp is created. + if self._kit_visualizer or self._render_viewport or self._video_enabled or self._livestream > 0 or self._xr: argument = f"--{ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING}=true" setting = argument.partition("=")[0] if not any(arg.partition("=")[0] == setting for arg in sys.argv + self._kit_args): @@ -822,61 +810,50 @@ def _load_extensions(self): # Publish whether Kit has an interactive GUI (local window, livestream, or XR). # SimulationContext and renderers consume this setting during their initialization. - settings.set_bool("/isaaclab/has_gui", not self._headless or self._livestream >= 1 or self._xr) - settings.set_bool("/isaaclab/render/offscreen", self._offscreen_render) - settings.set_bool("/isaaclab/xr/enabled", self._xr) + settings.set("/isaaclab/has_gui", not self._headless or self._livestream >= 1 or self._xr) + settings.set("/isaaclab/render/offscreen", self._offscreen_render) + settings.set("/isaaclab/xr/enabled", self._xr) # set setting to indicate XR auto-start mode -- when running headless # (no Kit GUI) the AR profile must be enabled programmatically so that # the OpenXR session starts without user interaction - settings.set_bool("/isaaclab/xr/auto_start", self._headless and self._xr) - settings.set_bool("/isaaclab/video/enabled", self._video_enabled) + settings.set("/isaaclab/xr/auto_start", self._headless and self._xr) + settings.set("/isaaclab/video/enabled", self._video_enabled) # publish the reproducible-rendering intent; rendering backends read this on initialization - settings.set_bool("/isaaclab/render/deterministic", self._deterministic_rendering) + settings.set("/isaaclab/render/deterministic", self._deterministic_rendering) # use fixed time stepping disabled; custom loop runner from Isaac Sim is used instead - settings.set_bool("/app/player/useFixedTimeStepping", False) + settings.set("/app/player/useFixedTimeStepping", False) if self._anim_recording is not None: - settings.set_bool("/isaaclab/anim_recording/enabled", True) - settings.set_float("/isaaclab/anim_recording/start_time", self._anim_recording[0]) - settings.set_float("/isaaclab/anim_recording/stop_time", self._anim_recording[1]) + settings.set("/isaaclab/anim_recording/enabled", True) + settings.set("/isaaclab/anim_recording/start_time", self._anim_recording[0]) + settings.set("/isaaclab/anim_recording/stop_time", self._anim_recording[1]) - def _hide_stop_button(self): - """Hide the stop button in the toolbar. + def _set_toolbar_button_visible(self, button_name: str, visible: bool): + """Show and enable, or hide and disable, a button of the toolbar's play button group. - For standalone executions, having a stop button is confusing since it invalidates the whole simulation. - Thus, we hide the button so that users don't accidentally click it. - """ - # when we are truly headless, then we can't import the widget toolbar - # thus, we only hide the stop button when we are not headless (i.e. GUI is enabled) - if self._livestream >= 1 or not self._headless: - import omni.kit.widget.toolbar - - # grey out the stop button because we don't want to stop the simulation manually in standalone mode - toolbar = omni.kit.widget.toolbar.get_instance() - play_button_group = toolbar._builtin_tools._play_button_group # type: ignore - if play_button_group is not None: - play_button_group._stop_button.visible = False # type: ignore - play_button_group._stop_button.enabled = False # type: ignore - play_button_group._stop_button = None # type: ignore - - def _hide_play_button(self, flag): - """Hide/Unhide the play button in the toolbar. - - This is used if the timeline is stopped by a GUI action like "save as" to not allow the user to - resume the timeline afterwards. + Standalone runs hide the stop button for good, since stopping invalidates the whole simulation. They hide + the play button while a GUI action like "save as" has stopped the timeline, so the user cannot resume it. + + Args: + button_name: Attribute of the play button group, ``"_stop_button"`` or ``"_play_button"``. + visible: Whether to show and enable the button. """ - # when we are truly headless, then we can't import the widget toolbar - # thus, we only hide the play button when we are not headless (i.e. GUI is enabled) - if self._livestream >= 1 or not self._headless: - import omni.kit.widget.toolbar - - toolbar = omni.kit.widget.toolbar.get_instance() - play_button_group = toolbar._builtin_tools._play_button_group # type: ignore - if play_button_group is not None: - play_button_group._play_button.visible = not flag # type: ignore - play_button_group._play_button.enabled = not flag # type: ignore + # a truly headless app has no toolbar widget to import + if self._livestream < 1 and self._headless: + return + import omni.kit.widget.toolbar + + play_button_group = omni.kit.widget.toolbar.get_instance()._builtin_tools._play_button_group # type: ignore + if play_button_group is None: + return + button = getattr(play_button_group, button_name) + button.visible = visible + button.enabled = visible + if button_name == "_stop_button": + # detach the stop button so the group never shows it again + setattr(play_button_group, button_name, None) def _share_stage_context() -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index bab4a9213b08..df6b40130987 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -323,7 +323,7 @@ def create_render_data(self, spec: CameraRenderSpec) -> IsaacRtxRenderData: ) has_gui = settings.get("/isaaclab/has_gui") if simple_shading_mode is None and (not needs_color_render or has_gui): - settings.set_bool("/rtx/sdg/force/disableColorRender", not needs_color_render and not has_gui) + settings.set("/rtx/sdg/force/disableColorRender", not needs_color_render and not has_gui) else: unsupported = [] if "albedo" in spec.cfg.data_types: diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py index d26d621c9f38..1aeb71a2daeb 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py @@ -92,7 +92,7 @@ def __init__(self, cfg: ContactSensorCfg): super().__init__(cfg) # Enable contact processing - get_settings_manager().set_bool("/physics/disableContactProcessing", False) + get_settings_manager().set("/physics/disableContactProcessing", False) # Create empty variables for storing output data self._data: ContactSensorData = ContactSensorData() diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py index 4ff6c17bf2ec..590cefef91a3 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -16,7 +16,7 @@ from pxr import Gf, Usd, UsdGeom -from isaaclab.app.settings_manager import SettingsManager +from isaaclab.app.settings_manager import get_settings_manager from isaaclab.sim.views.base_frame_view import BaseFrameView from isaaclab.sim.views.fabric_xform_selection import FabricXformSelection from isaaclab.sim.views.usd_frame_view import UsdFrameView @@ -188,7 +188,7 @@ def __init__( self._usd_view = UsdFrameView(prim_path, device=device, validate_xform_ops=validate_xform_ops, stage=stage) self._device = device - settings = SettingsManager.instance() + settings = get_settings_manager() self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) # TODO(pv): Misleading abstraction -- FabricFrameView can fall back to USD internally; diff --git a/source/isaaclab_physx/test/app/test_kit_launcher.py b/source/isaaclab_physx/test/app/test_kit_launcher.py index 9ea5c9a7739e..b6381aaa45d4 100644 --- a/source/isaaclab_physx/test/app/test_kit_launcher.py +++ b/source/isaaclab_physx/test/app/test_kit_launcher.py @@ -20,6 +20,7 @@ from isaaclab.app import SimulationLauncher, add_launcher_args from isaaclab.app.sim_launcher import Scan, _get_kit_runtime_sources, _normalize_launcher_args from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING +from isaaclab.visualizers import VisualizerCfg def _launcher_after_output_resolution(**state) -> KitLauncher: @@ -202,7 +203,7 @@ def test_fuse_kit_args(argv: list[str], expected: list[str]): assert sim_launcher.fuse_kit_args(argv) == expected -def test_add_app_launcher_args_registers_every_launcher_option(): +def test_add_launcher_args_registers_every_launcher_option(): """Every launcher option is a command-line option.""" parser = argparse.ArgumentParser() add_launcher_args(parser) @@ -220,7 +221,6 @@ def test_visualizer_alias_parsing(): with pytest.warns(DeprecationWarning, match="--viz 'newton' is deprecated"): args = parser.parse_args(["--viz", "kit,newton"]) assert args.visualizer == ["kit", "newton_gl"] - assert args.visualizer_explicit is True @pytest.mark.parametrize("deprecated_arg", ["--headless", "--enable_cameras", "--cpu"]) @@ -236,7 +236,7 @@ def test_deprecated_render_flags_are_rejected(deprecated_arg: str): def test_help_on_parser_with_required_positionals(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture): """Launcher arguments reach the help output of a script that takes required positionals. - ``add_app_launcher_args`` probes the command line to check for name collisions. That probe + ``add_launcher_args`` probes the command line to check for name collisions. That probe exits when a required argument is missing, which is the case for every tool script invoked with ``--help``, so it must not take the parser down before the arguments are added. """ @@ -263,8 +263,7 @@ def test_visualizer_none_parsing(value: str): parser = argparse.ArgumentParser() add_launcher_args(parser) args = parser.parse_args(["--viz", value]) - assert args.visualizer is None - assert args.visualizer_explicit is True + assert args.visualizer == [] @pytest.mark.parametrize( @@ -375,26 +374,25 @@ def test_explicit_simulation_manager_callback_setting_takes_precedence(monkeypat def test_make_physics_cfg_builds_core_vbd(): from isaaclab_newton.physics import NewtonCfg, VBDSolverCfg - physics_cfg = sim_launcher.make_physics_cfg("newton_vbd") + physics_cfg = sim_launcher._make_physics_cfg("newton_vbd") assert isinstance(physics_cfg, NewtonCfg) assert isinstance(physics_cfg.solver_cfg, VBDSolverCfg) def test_livestream_injects_kit_visualizer_when_missing(): - args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=False) + args = argparse.Namespace(livestream=2, visualizer=None) - _normalize_launcher_args(args) + _normalize_launcher_args(vars(args)) assert args.visualizer == ["kit"] - assert args.visualizer_explicit is True def test_livestream_rejects_disabled_visualizers(): - args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=True) + args = argparse.Namespace(livestream=2, visualizer=[]) with pytest.raises(ValueError, match="Livestreaming requires the Kit visualizer"): - _normalize_launcher_args(args) + _normalize_launcher_args(vars(args)) def test_livestream_rejects_invalid_environment_value(monkeypatch: pytest.MonkeyPatch): @@ -416,7 +414,8 @@ def test_explicit_experience_requires_isaac_sim_runtime(): has_ovphysx_physics=False, needs_kit=False, ) - args = argparse.Namespace(experience="isaaclab.python.kit", visualizer=None) + args = {"experience": "isaaclab.python.kit", "visualizer": None} + _normalize_launcher_args(args) assert _get_kit_runtime_sources(scan, args) @@ -431,8 +430,9 @@ def _resolve_headless_for_case( monkeypatch.delenv("XR", raising=False) monkeypatch.delenv("LIVESTREAM", raising=False) _normalize_launcher_args(launcher_args) - config_scan = SimpleNamespace(visualizer_intent={"has_kit_visualizer": cfg_has_kit}) - launcher_args["kit_visualizer"] = sim_launcher._has_kit_visualizer(config_scan, launcher_args) + visualizer_cfgs = [VisualizerCfg(visualizer_type="kit")] if cfg_has_kit else [] + intent = sim_launcher._get_visualizer_intent(visualizer_cfgs, launcher_args) + launcher_args["kit_visualizer"] = intent["has_kit_visualizer"] launcher = KitLauncher.__new__(KitLauncher) launcher._kit_visualizer = launcher_args["kit_visualizer"] launcher._livestream = launcher_args["livestream"] @@ -441,7 +441,7 @@ def _resolve_headless_for_case( return launcher._headless, launcher_args -_XR_KIT = {"xr": True, "visualizer": ["kit"], "visualizer_explicit": True} +_XR_KIT = {"xr": True, "visualizer": ["kit"]} @pytest.mark.parametrize( @@ -451,7 +451,7 @@ def _resolve_headless_for_case( ({"xr": True}, 0, 0, True), # An explicit '--viz kit' is the only way to get a viewport alongside XR. (_XR_KIT, 0, 0, False), - ({"xr": True, "visualizer": ["none"], "visualizer_explicit": True}, 0, 0, True), + ({"xr": True, "visualizer": ["none"]}, 0, 0, True), # ...but an explicit '--viz kit' cannot override HEADLESS=1. (_XR_KIT, 1, 0, True), # Livestreaming forces headless. @@ -542,18 +542,6 @@ def _scan(_cfg, launcher_args): assert received_args["enable_cameras"] is True -def test_deferred_cuda_device_synchronizes_torch_and_warp(monkeypatch: pytest.MonkeyPatch): - """The post-Kit device hook must synchronize both CUDA runtimes.""" - devices = [] - monkeypatch.setattr(kit_launcher_module, "set_cuda_device", devices.append) - launcher = KitLauncher.__new__(KitLauncher) - launcher._deferred_cuda_device_id = 2 - - launcher._set_deferred_cuda_device() - - assert devices == [2] - - def test_preloaded_torch_cuda_is_initialized_before_kit(monkeypatch: pytest.MonkeyPatch): """Drain PyTorch's queued CUDA checks before Kit can change visible device indices.""" init_calls = [] @@ -601,13 +589,7 @@ class _DummySettings: def __init__(self): self.values = {} - def set_string(self, path: str, value: str) -> None: - self.values[path] = value - - def set_int(self, path: str, value: int) -> None: - self.values[path] = value - - def set_bool(self, path: str, value: bool) -> None: + def set(self, path: str, value) -> None: self.values[path] = value @@ -683,6 +665,49 @@ def test_load_extensions_publishes_has_gui_setting( assert settings.values["/isaaclab/xr/auto_start"] is expected_xr_auto_start +@pytest.mark.parametrize("visible", [True, False]) +def test_toolbar_play_button_toggles_and_stop_button_is_detached(monkeypatch: pytest.MonkeyPatch, visible: bool): + """The play button follows *visible*; hiding the stop button also removes it from its group for good.""" + play_button = SimpleNamespace(visible=None, enabled=None) + stop_button = SimpleNamespace(visible=None, enabled=None) + group = SimpleNamespace(_play_button=play_button, _stop_button=stop_button) + toolbar = SimpleNamespace( + get_instance=lambda: SimpleNamespace(_builtin_tools=SimpleNamespace(_play_button_group=group)) + ) + # ``import omni.kit.widget.toolbar`` binds the top-level fake and reaches the toolbar through its attributes + widget = SimpleNamespace(toolbar=toolbar) + kit = SimpleNamespace(widget=widget) + omni = SimpleNamespace(kit=kit) + for name, module in { + "omni": omni, + "omni.kit": kit, + "omni.kit.widget": widget, + "omni.kit.widget.toolbar": toolbar, + }.items(): + monkeypatch.setitem(sys.modules, name, module) + launcher = KitLauncher.__new__(KitLauncher) + launcher._headless = False + launcher._livestream = 0 + + launcher._set_toolbar_button_visible("_play_button", visible) + launcher._set_toolbar_button_visible("_stop_button", False) + + assert (play_button.visible, play_button.enabled) == (visible, visible) + assert (stop_button.visible, stop_button.enabled) == (False, False) + assert group._play_button is play_button + assert group._stop_button is None + + +def test_toolbar_is_untouched_when_headless_without_livestream(monkeypatch: pytest.MonkeyPatch): + """A truly headless app has no toolbar widget, so the toolbar module is never imported.""" + monkeypatch.setitem(sys.modules, "omni.kit.widget.toolbar", None) # importing it would raise + launcher = KitLauncher.__new__(KitLauncher) + launcher._headless = True + launcher._livestream = 0 + + launcher._set_toolbar_button_visible("_stop_button", False) + + def test_sync_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): settings = _DummySettings() monkeypatch.setattr(sim_launcher, "get_settings_manager", lambda: settings) @@ -691,7 +716,7 @@ def test_sync_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch) assert settings.values == { "/isaaclab/visualizer/types": "viser rerun", - "/isaaclab/visualizer/explicit": False, + "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, "/isaaclab/visualizer/max_visible_envs": 0, } @@ -716,7 +741,7 @@ def test_parse_visualizer_csv_rejects_spaces_between_entries(): def test_normalize_visualizers_rejects_none_with_others(): with pytest.raises(ValueError, match="'none' cannot be combined"): - _normalize_launcher_args({"visualizer": ["none", "kit"], "visualizer_explicit": True}) + _normalize_launcher_args({"visualizer": ["none", "kit"]}) def test_visualizer_csv_does_not_swallow_hydra_overrides(): @@ -732,17 +757,13 @@ def test_visualizer_csv_does_not_swallow_hydra_overrides(): def test_matrix_cli_kit_newton_gl_with_custom_kit_cfg_intent_non_headless(monkeypatch: pytest.MonkeyPatch): - headless, args = _resolve_headless_for_case( - monkeypatch, {"visualizer": ["kit", "newton_gl"], "visualizer_explicit": True}, cfg_has_kit=True - ) + headless, args = _resolve_headless_for_case(monkeypatch, {"visualizer": ["kit", "newton_gl"]}, cfg_has_kit=True) assert headless is False assert args["visualizer"] == ["kit", "newton_gl"] def test_matrix_cli_rerun_with_custom_kit_cfg_intent_headless(monkeypatch: pytest.MonkeyPatch): - headless, args = _resolve_headless_for_case( - monkeypatch, {"visualizer": ["rerun"], "visualizer_explicit": True}, cfg_has_kit=True - ) + headless, args = _resolve_headless_for_case(monkeypatch, {"visualizer": ["rerun"]}, cfg_has_kit=True) assert headless is True assert args["visualizer"] == ["rerun"] @@ -760,14 +781,11 @@ def test_matrix_viz_kit_dict_resolves_windowed(monkeypatch: pytest.MonkeyPatch): assert args["visualizer"] == ["kit"] -@pytest.mark.parametrize("visualizer", [None, ["none"]]) +@pytest.mark.parametrize("visualizer", [[], ["none"]]) def test_matrix_viz_none_disables_all_and_headless(monkeypatch: pytest.MonkeyPatch, visualizer): - headless, args = _resolve_headless_for_case( - monkeypatch, {"visualizer": visualizer, "visualizer_explicit": True}, cfg_has_kit=True - ) + headless, args = _resolve_headless_for_case(monkeypatch, {"visualizer": visualizer}, cfg_has_kit=True) assert headless is True - assert args["visualizer_disable_all"] is True - assert args["visualizer"] is None + assert args["visualizer"] == [] def test_matrix_headless_flag_deprecated_takes_precedence(monkeypatch: pytest.MonkeyPatch): diff --git a/source/isaaclab_physx/test/envs/test_manager_based_rl_env_ui.py b/source/isaaclab_physx/test/envs/test_manager_based_rl_env_ui.py index 6776114631b9..fd9c5560657d 100644 --- a/source/isaaclab_physx/test/envs/test_manager_based_rl_env_ui.py +++ b/source/isaaclab_physx/test/envs/test_manager_based_rl_env_ui.py @@ -60,7 +60,7 @@ def test_ui_window_follows_has_gui_gate(has_gui: bool): TrackingManagerBasedRLEnvWindow.construction_count = 0 try: - settings.set_bool(_HAS_GUI_SETTING, has_gui) + settings.set(_HAS_GUI_SETTING, has_gui) sim_utils.create_new_stage() env = ManagerBasedRLEnv(cfg=make_empty_manager_based_rl_env_ui_cfg()) @@ -69,4 +69,4 @@ def test_ui_window_follows_has_gui_gate(has_gui: bool): finally: if env is not None: env.close() - settings.set_bool(_HAS_GUI_SETTING, previous_has_gui) + settings.set(_HAS_GUI_SETTING, previous_has_gui) diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index c7533bf16e06..319ce055b8cb 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -280,11 +280,7 @@ def _create_attribute(name, value_type): # The shading level must not leak into process-wide state, where the last camera would win. global_setting_calls = [ setting_call - for setting_call in ( - *settings.set_int.call_args_list, - *settings.set.call_args_list, - *settings.set_bool.call_args_list, - ) + for setting_call in settings.set.call_args_list if setting_call.args and setting_call.args[0] in ("/rtx/minimal/mode", "/rtx/rendermode", "/rtx/sdg/force/disableColorRender") ] @@ -338,7 +334,7 @@ def test_depth_only_camera_color_render_setting(monkeypatch, has_gui, expected_d color_render_calls = [ setting_call - for setting_call in settings.set_bool.call_args_list + for setting_call in settings.set.call_args_list if setting_call.args[0] == "/rtx/sdg/force/disableColorRender" ] assert color_render_calls[-1] == call("/rtx/sdg/force/disableColorRender", expected_disable_color_render) diff --git a/source/isaaclab_physx/test/sensors/test_contact_sensor.py b/source/isaaclab_physx/test/sensors/test_contact_sensor.py index d82b7ad603a6..dd7fe402310c 100644 --- a/source/isaaclab_physx/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_contact_sensor.py @@ -633,7 +633,7 @@ def test_contact_sensor_no_stale_data_after_reset(setup_simulation, device): PhysX's stale post-step buffer for the reset env — it must reflect the freshly reset state. """ sim_dt, _, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", False) + settings.set("/physics/disableContactProcessing", False) with build_simulation_context(device=device, dt=sim_dt, add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=2.0, lazy_sensor_update=False) @@ -692,7 +692,7 @@ def test_contact_history_updates_at_sensor_period( timestamps) on data access. """ sim_dt, _, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", False) + settings.set("/physics/disableContactProcessing", False) with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=2.0, lazy_sensor_update=True) @@ -752,7 +752,7 @@ def test_lazy_sensor_reports_contact_loss(setup_simulation, device): (regression for #3498, where the spawner passed ``activate_contact_sensors`` as the threshold). """ sim_dt, _, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", False) + settings.set("/physics/disableContactProcessing", False) with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=True) @@ -814,7 +814,7 @@ def test_friction_reporting(setup_simulation): # minor gravity force in -z to ensure object stays on ground plane grav_dir = (-10.0, 0.0, -0.1) sim_dt, _, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", True) + settings.set("/physics/disableContactProcessing", True) device = "cuda:0" sim_cfg = SimulationCfg(dt=sim_dt, device=device, gravity=grav_dir) with build_simulation_context(sim_cfg=sim_cfg, add_lighting=False) as sim: @@ -871,7 +871,7 @@ def test_friction_reporting(setup_simulation): def test_invalid_contact_data_config(setup_simulation, use_filter, max_contact_data_count_per_prim, match): """Tracking friction forces requires a filter pattern and a positive contact data budget.""" sim_dt, _, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", True) + settings.set("/physics/disableContactProcessing", True) device = "cuda:0" sim_cfg = SimulationCfg(dt=sim_dt, device=device) with build_simulation_context(sim_cfg=sim_cfg, add_lighting=False) as sim: diff --git a/source/isaaclab_rl/changelog.d/launch-helper-cleanup.skip b/source/isaaclab_rl/changelog.d/launch-helper-cleanup.skip new file mode 100644 index 000000000000..cee64c9ed00f --- /dev/null +++ b/source/isaaclab_rl/changelog.d/launch-helper-cleanup.skip @@ -0,0 +1 @@ +Switched a test to SettingsManager.set; no behavior change. diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py index a22692838f53..8675c422e198 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py @@ -1003,9 +1003,8 @@ def _resolve_video_source(env_cfg: Any, args_cli: argparse.Namespace) -> str: Raises: ValueError: If ``--viz none`` or only streaming visualizers were requested. """ - # _parse_visualizer_csv("none") yields None rather than ["none"], so an explicitly disabled - # visualizer is only recognizable through the ExplicitAction sentinel - if getattr(args_cli, "visualizer_explicit", False) and getattr(args_cli, "visualizer", "not_none") is None: + # ``--viz none`` parses to an empty list + if getattr(args_cli, "visualizer", None) == []: raise ValueError( "--video is not compatible with --viz none: there is no active visualizer to record from. " "Remove --viz none so that video recording can auto-create a visualizer, " diff --git a/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py b/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py index 7dc85f264b0f..04dc96dd8a7c 100644 --- a/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py +++ b/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py @@ -339,8 +339,8 @@ def main(argv: Sequence[str] | None = None) -> None: try: env_cfg, agent_cfg = resolve_task_config(task_name, _agent_cfg_entry_point(backend_id)) with launch_simulation(env_cfg, vars(cli_args)): - get_settings_manager().set_bool("/physics/cooking/ujitsoCollisionCooking", False) - get_settings_manager().set_bool("/isaaclab/render/rtx_sensors", False) + get_settings_manager().set("/physics/cooking/ujitsoCollisionCooking", False) + get_settings_manager().set("/isaaclab/render/rtx_sensors", False) checkpoint_path = create_initialized_checkpoint( backend_id, task_name, diff --git a/source/isaaclab_rl/test/test_apply_video_recording.py b/source/isaaclab_rl/test/test_apply_video_recording.py index 415b0122681f..10317dab8472 100644 --- a/source/isaaclab_rl/test/test_apply_video_recording.py +++ b/source/isaaclab_rl/test/test_apply_video_recording.py @@ -105,7 +105,7 @@ def test_apply_video_recording_patches_existing_recorders(): @pytest.mark.parametrize( ("visualizer_args", "message"), [ - (dict(visualizer=None, visualizer_explicit=True), "--video is not compatible with --viz none"), + (dict(visualizer=[]), "--video is not compatible with --viz none"), (dict(visualizer=["rerun"]), "--video is not supported"), (dict(visualizer=["viser"]), "--video is not supported"), ], diff --git a/source/isaaclab_rl/test/test_wrappers.py b/source/isaaclab_rl/test/test_wrappers.py index ae97b4c862c6..331875a9e621 100644 --- a/source/isaaclab_rl/test/test_wrappers.py +++ b/source/isaaclab_rl/test/test_wrappers.py @@ -60,7 +60,7 @@ def raw_env(task: str, library: str, finite_horizon: bool) -> Iterator[Any]: cfg.seed = 42 cfg.is_finite_horizon = finite_horizon cfg.compute_final_obs = library == "torchrl" - with launch_simulation(cfg, {"headless": True, "visualizer": None, "visualizer_explicit": True}): + with launch_simulation(cfg, {"headless": True, "visualizer": ["none"]}): env = gym.make(task, cfg=cfg) try: yield env diff --git a/source/isaaclab_tasks/changelog.d/launch-helper-cleanup.skip b/source/isaaclab_tasks/changelog.d/launch-helper-cleanup.skip new file mode 100644 index 000000000000..a3cf5c64b516 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/launch-helper-cleanup.skip @@ -0,0 +1 @@ +Updated tests for the launcher and settings helper cleanup. diff --git a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py index 5759653d7a5f..cc768de7c31d 100644 --- a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py +++ b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py @@ -27,8 +27,6 @@ import types from unittest.mock import patch -import pytest - import isaaclab.app.sim_launcher as sim_launcher # --------------------------------------------------------------------------- @@ -106,7 +104,7 @@ def test_normal_multi_gpu_rank3(self, mock_count, mock_set_device): env = _make_env_vars(local_rank=3, world_size=4) with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) + sim_launcher._resolve_distributed_device(env_cfg, vars(args)) assert env_cfg.sim.device == "cuda:3" # the Kit launcher reads the resolved device from the launcher args @@ -122,7 +120,7 @@ def test_cuda_visible_devices_restricted_rank1(self, mock_count, mock_set_device env = _make_env_vars(local_rank=1, world_size=2) with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) + sim_launcher._resolve_distributed_device(env_cfg, vars(args)) assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_called_once_with("cuda:0") @@ -137,7 +135,7 @@ def test_jax_local_rank_added(self, mock_count, mock_set_device): env = _make_env_vars(local_rank=0, world_size=2, jax_local_rank=1) with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) + sim_launcher._resolve_distributed_device(env_cfg, vars(args)) assert env_cfg.sim.device == "cuda:1" mock_set_device.assert_called_once_with("cuda:1") @@ -174,13 +172,12 @@ def test_dict_args_distributed(self, mock_count, mock_set_device): class TestResolveDistributedDeviceNoop: """Tests that non-distributed runs skip device resolution.""" - @pytest.mark.parametrize("args", [argparse.Namespace(distributed=False), {"distributed": False}]) @patch.object(sim_launcher, "set_cuda_device") - def test_not_distributed(self, mock_set_device, args): - """distributed=False as a namespace or dict → device unchanged, set_device not called.""" + def test_not_distributed(self, mock_set_device): + """distributed=False → device unchanged, set_device not called.""" env_cfg = _DummyEnvCfg(device="cuda:0") - sim_launcher._resolve_distributed_device(env_cfg, args) + sim_launcher._resolve_distributed_device(env_cfg, {"distributed": False}) assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_not_called() @@ -196,16 +193,6 @@ def test_no_distributed_key(self, mock_set_device): assert env_cfg.sim.device == "cuda:0" mock_set_device.assert_not_called() - @patch.object(sim_launcher, "set_cuda_device") - def test_none_launcher_args(self, mock_set_device): - """launcher_args=None → no-op.""" - env_cfg = _DummyEnvCfg(device="cuda:0") - - sim_launcher._resolve_distributed_device(env_cfg, None) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_not_called() - # --------------------------------------------------------------------------- # _resolve_distributed_device — edge cases @@ -228,7 +215,7 @@ class _BareEnvCfg: env = _make_env_vars(local_rank=1, world_size=2) with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) + sim_launcher._resolve_distributed_device(env_cfg, vars(args)) # Should still call set_device even without sim_cfg mock_set_device.assert_called_once_with("cuda:1") @@ -248,7 +235,7 @@ def test_missing_env_vars_default_to_zero(self, mock_count, mock_set_device): } with patch.dict(os.environ, clean_env, clear=True): - sim_launcher._resolve_distributed_device(env_cfg, args) + sim_launcher._resolve_distributed_device(env_cfg, vars(args)) # local_rank=0, 0 < 4 → cuda:0 assert env_cfg.sim.device == "cuda:0" @@ -275,7 +262,7 @@ def test_multi_node_rank3_sees_4_gpus(self, mock_count, mock_set_device): env = _make_env_vars(local_rank=3, world_size=8, rank=7) with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) + sim_launcher._resolve_distributed_device(env_cfg, vars(args)) assert env_cfg.sim.device == "cuda:3" mock_set_device.assert_called_once_with("cuda:3") diff --git a/source/isaaclab_tasks/test/core/test_environment_determinism.py b/source/isaaclab_tasks/test/core/test_environment_determinism.py index bbc987f36b47..492fea6ddeb5 100644 --- a/source/isaaclab_tasks/test/core/test_environment_determinism.py +++ b/source/isaaclab_tasks/test/core/test_environment_determinism.py @@ -25,7 +25,7 @@ def setup_environment(): # test on many environments. from isaaclab.app.settings_manager import get_settings_manager - get_settings_manager().set_bool("/physics/cooking/ujitsoCollisionCooking", False) + get_settings_manager().set("/physics/cooking/ujitsoCollisionCooking", False) @pytest.mark.parametrize( diff --git a/source/isaaclab_tasks/test/core/test_rl_device_separation.py b/source/isaaclab_tasks/test/core/test_rl_device_separation.py index e71184051c9d..c79b4c2a7c22 100644 --- a/source/isaaclab_tasks/test/core/test_rl_device_separation.py +++ b/source/isaaclab_tasks/test/core/test_rl_device_separation.py @@ -66,7 +66,7 @@ def _create_env(sim_device: str): # Reset the rtx sensors setting to False from isaaclab.app.settings_manager import get_settings_manager - get_settings_manager().set_bool("/isaaclab/render/rtx_sensors", False) + get_settings_manager().set("/isaaclab/render/rtx_sensors", False) try: env_cfg = parse_env_cfg(TEST_ENV, device=sim_device, num_envs=NUM_ENVS) diff --git a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py index 17fb821d0e8f..6a83c9f31526 100644 --- a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py +++ b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py @@ -25,6 +25,7 @@ from isaaclab.app import SimulationLauncher, scan from isaaclab.app.sim_launcher import _get_kit_runtime_sources, _validate_runtime, launch_simulation from isaaclab.physics import PhysxAutoCfg +from isaaclab.visualizers import VisualizerCfg import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import resolve_task_config @@ -34,8 +35,9 @@ def validate_runtime_compatibility(env_cfg, launcher_args=None): """Run the single-scan runtime validation for *env_cfg* (test adapter).""" - config_scan = scan(env_cfg, launcher_args) - kit_sources = _get_kit_runtime_sources(config_scan, launcher_args) + args = vars(launcher_args) if isinstance(launcher_args, argparse.Namespace) else launcher_args or {} + config_scan = scan(env_cfg, args) + kit_sources = _get_kit_runtime_sources(config_scan, args) _validate_runtime(config_scan, kit_sources) return config_scan @@ -222,11 +224,8 @@ def test_physx_plus_isaacsim_rtx_is_valid(): def test_auto_physx_configured_kit_visualizer_resolves_to_isaac_sim_backends(): """Config-declared Kit visualizers should drive automatic PhysX and RTX resolution.""" - class KitVisualizerCfg: - visualizer_type = "kit" - env_cfg = _resolve_with_args("physics=physx", "renderer=rtx") - env_cfg.sim.visualizer_cfgs = KitVisualizerCfg() + env_cfg.sim.visualizer_cfgs = VisualizerCfg(visualizer_type="kit") config_scan = validate_runtime_compatibility(env_cfg) assert isinstance(env_cfg.sim.physics, PhysxCfg) @@ -342,7 +341,7 @@ def test_rtx_with_kit_visualizer_is_valid_and_resolves_to_isaac_rtx(): def test_livestream_rtx_injects_kit_before_auto_rtx_resolution(monkeypatch: pytest.MonkeyPatch): """Livestreaming should make ``presets=newton_mjwarp,rtx`` choose Isaac RTX.""" env_cfg = _resolve_with_presets("newton_mjwarp,rtx") - launcher_args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=False) + launcher_args = argparse.Namespace(livestream=2, visualizer=None) monkeypatch.setattr(physx_app, "KitLauncher", SimulationLauncher) with launch_simulation(env_cfg, launcher_args) as physics_cfg: diff --git a/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py b/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py index 28a0d80d847d..07cd249625c3 100644 --- a/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py +++ b/source/isaaclab_tasks/test/core/test_sim_launcher_visualizer_intent.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +import logging import sys import types @@ -18,6 +19,7 @@ import isaaclab.app.sim_launcher as sim_launcher from isaaclab.app import SimulationLauncher, get_settings_manager +from isaaclab.visualizers import VisualizerCfg def _force_kitless(monkeypatch): @@ -32,11 +34,6 @@ def fake_scan(*args, **kwargs): monkeypatch.setattr(sim_launcher, "scan", fake_scan) -class _DummyVizCfg: - def __init__(self, visualizer_type: str): - self.visualizer_type = visualizer_type - - class _DummySimCfg: def __init__(self, visualizer_cfgs): self.visualizer_cfgs = visualizer_cfgs @@ -69,15 +66,17 @@ def close(self, exit_code=0): ) monkeypatch.setitem(sys.modules, "isaaclab_physx.app", types.SimpleNamespace(KitLauncher=_FakeKitLauncher)) - env_cfg = _DummyEnvCfg(_DummySimCfg([_DummyVizCfg("kit"), _DummyVizCfg("newton")])) + env_cfg = _DummyEnvCfg( + _DummySimCfg([VisualizerCfg(visualizer_type="kit"), VisualizerCfg(visualizer_type="newton_gl")]) + ) launcher_args = argparse.Namespace() with sim_launcher.launch_simulation(env_cfg, launcher_args): pass - forwarded_args = captured["launcher_args"] - assert isinstance(forwarded_args, argparse.Namespace) - assert forwarded_args.kit_visualizer is True + # the launcher gets the caller's namespace as a dict, so resolved values reach both + assert captured["launcher_args"]["kit_visualizer"] is True + assert launcher_args.kit_visualizer is True assert captured["closed"] is True @@ -105,8 +104,8 @@ def test_caller_visualizer_intent_requests_kit_visualizer(kit_launcher_args): @pytest.mark.parametrize("physics_cfg", [NewtonCfg(), OvPhysxCfg()], ids=["newton", "ovphysx"]) def test_cli_visualizer_selection_overrides_config_kit_visualizer(kit_launcher_args, physics_cfg): """``--viz rerun`` drops a configured Kit visualizer, so the run stays kitless.""" - cfg = argparse.Namespace(physics=physics_cfg, visualizer_cfgs=[_DummyVizCfg("kit")]) - launcher_args = argparse.Namespace(visualizer=["rerun"], visualizer_explicit=True) + cfg = argparse.Namespace(physics=physics_cfg, visualizer_cfgs=[VisualizerCfg(visualizer_type="kit")]) + launcher_args = argparse.Namespace(visualizer=["rerun"]) with sim_launcher.launch_simulation(cfg, launcher_args): pass @@ -119,31 +118,30 @@ def test_cli_visualizer_selection_overrides_config_kit_visualizer(kit_launcher_a def test_launch_simulation_writes_max_visible_envs_without_visualizer(kit_launcher_args, require_kit): """``--max_visible_envs`` reaches the settings with or without Kit, even when no visualizer is selected.""" settings = get_settings_manager() - settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) + settings.set("/isaaclab/visualizer/max_visible_envs", -1) with sim_launcher.launch_simulation(NewtonCfg(), {"require_kit": require_kit, "max_visible_envs": 3}): pass assert len(kit_launcher_args) == int(require_kit) assert settings.get("/isaaclab/visualizer/max_visible_envs") == 3 - settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) + settings.set("/isaaclab/visualizer/max_visible_envs", -1) def test_launch_simulation_kitless_viz_none_sets_disable_all(monkeypatch): """Kitless mode should persist explicit disable-all semantics for --viz none.""" captured = {"types": None, "explicit": None, "disable_all": None} - def fake_sync(launcher_args: argparse.Namespace) -> None: - launcher_args = vars(launcher_args) + def fake_sync(launcher_args: dict) -> None: captured["types"] = " ".join(launcher_args["visualizer"]) if launcher_args.get("visualizer") else "" - captured["explicit"] = launcher_args["visualizer_explicit"] - captured["disable_all"] = launcher_args["visualizer_disable_all"] + captured["explicit"] = launcher_args["visualizer"] is not None + captured["disable_all"] = launcher_args["visualizer"] == [] _force_kitless(monkeypatch) monkeypatch.setattr(sim_launcher, "_sync_visualizer_cli_settings", fake_sync) env_cfg = _DummyEnvCfg(_DummySimCfg(None)) - launcher_args = argparse.Namespace(visualizer=None, visualizer_explicit=True) + launcher_args = argparse.Namespace(visualizer=["none"]) with sim_launcher.launch_simulation(env_cfg, launcher_args): pass @@ -154,21 +152,15 @@ def test_launch_simulation_kitless_applies_python_logging_level(monkeypatch): """Kitless mode should apply the resolved Python logging level before yielding.""" captured: dict[str, object] = {} - def fake_resolve(launcher_args): - captured["resolve_args"] = launcher_args - return 42 - def fake_apply(level): captured["applied_level"] = level _force_kitless(monkeypatch) - monkeypatch.setattr(sim_launcher, "resolve_python_logging_level", fake_resolve) monkeypatch.setattr(sim_launcher, "apply_python_logging_level", fake_apply) env_cfg = _DummyEnvCfg(_DummySimCfg(None)) - launcher_args = argparse.Namespace(visualizer=None, visualizer_explicit=True) + launcher_args = argparse.Namespace(visualizer=["none"], verbose=True) with sim_launcher.launch_simulation(env_cfg, launcher_args): pass - assert captured["resolve_args"] is launcher_args - assert captured["applied_level"] == 42 + assert captured["applied_level"] == logging.DEBUG diff --git a/source/isaaclab_tasks/test/env_test_utils.py b/source/isaaclab_tasks/test/env_test_utils.py index d72a998ffb8e..63fad921d720 100644 --- a/source/isaaclab_tasks/test/env_test_utils.py +++ b/source/isaaclab_tasks/test/env_test_utils.py @@ -123,7 +123,7 @@ def setup_environment( registered_tasks.sort() # this flag is necessary to prevent a bug where the simulation gets stuck randomly when running many environments - get_settings_manager().set_bool("/physics/cooking/ujitsoCollisionCooking", False) + get_settings_manager().set("/physics/cooking/ujitsoCollisionCooking", False) print(">>> All registered environments:", registered_tasks) @@ -310,7 +310,7 @@ def _check_random_actions( sim_utils.create_new_stage() # reset the rtx sensors setting to False - get_settings_manager().set_bool("/isaaclab/render/rtx_sensors", False) + get_settings_manager().set("/isaaclab/render/rtx_sensors", False) env = None try: # Parse the requested physics preset before resolving the config. ``parse_env_cfg`` resolves every preset to diff --git a/source/isaaclab_visualizers/changelog.d/launch-helper-cleanup.skip b/source/isaaclab_visualizers/changelog.d/launch-helper-cleanup.skip new file mode 100644 index 000000000000..d992140bd68b --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/launch-helper-cleanup.skip @@ -0,0 +1 @@ +Switched the Kit visualizer to SettingsManager.set; no behavior change. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 2ef4f921874c..849e8db69166 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -204,9 +204,9 @@ def step(self, dt: float) -> None: # Keep app pumping for viewport/UI updates only; physics is owned by SimulationContext. # Disable playSimulations around app.update() so Kit does not advance its own physics here. settings = get_settings_manager() - settings.set_bool("/app/player/playSimulations", False) + settings.set("/app/player/playSimulations", False) app.update() - settings.set_bool("/app/player/playSimulations", True) + settings.set("/app/player/playSimulations", True) self._app_pumped_this_step = True except (ImportError, AttributeError) as exc: logger.debug("[KitVisualizer] App update skipped: %s", exc) @@ -288,9 +288,9 @@ def render_rgb_array(self) -> np.ndarray: if not self._app_pumped_this_step: settings = get_settings_manager() play_flag = settings.get("/app/player/playSimulations") - settings.set_bool("/app/player/playSimulations", False) + settings.set("/app/player/playSimulations", False) omni.kit.app.get_app().update() - settings.set_bool("/app/player/playSimulations", bool(play_flag)) + settings.set("/app/player/playSimulations", bool(play_flag)) raw = self._rgb_annotator.get_data() if isinstance(raw, dict): From 225d58f546dde720cbf031ea382f0ecb1cd2a638 Mon Sep 17 00:00:00 2001 From: hujc Date: Mon, 28 Sep 2026 19:51:28 -0700 Subject: [PATCH 03/10] [Bump] usd-exchange 3.0.0 and drop the OpenUSD work-thread-limit workaround (#7192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Kit-less installs move from OpenUSD 25.05 to **26.08** (`usd-exchange` 2.3.0 → 3.0.0), which retires the `PXR_WORK_THREAD_LIMIT` heap-corruption workaround everywhere it was applied. Kit-backed runs are unaffected. > [!IMPORTANT] > **Do not merge before `usd-optimize` 1.3.0 is published** (planned 2026-09-29). Kit-less `merge_mesh` needs its first OpenUSD 26.08 build: until then `usd-optimize` 1.2.1 resolves and `merge_mesh` leaves meshes unmerged, where it merges on develop today. On release, one commit adds `usd-optimize>=1.3.0` to `override-dependencies` together with the relock; then run the Docker CI. # Description `usd-exchange` 3.0.0 makes OpenUSD 26.08 its default flavor ([usd-exchange!257](https://gitlab-master.nvidia.com/omniverse/usd-exchange/usd-exchange/-/merge_requests/257)), and the PyPI wheel ships that flavor. Kit serves its own OpenUSD (25.11, including the Isaac Sim 6.2.0-alpha.12 CI image), so only the kit-less `pxr` moves. `[tool.uv] override-dependencies` moves the Isaac Sim 6.1 importer stack onto 26.08; both entries come out when Isaac Sim 6.3 adopts usdex 3.0: 1. **`usd-exchange==3.0.0`** — `isaacsim-asset-isolated` and `isaacsim-robot-schema` 6.1.0.0 pin `usd-exchange==2.3.0`. Both are pure Python and bind usdex's Python API, so 3.0.0 satisfies them. 2. **`usd-optimize>=1.3.0`** (added with the relock once published) — the only compiled OpenUSD consumer in the importer tree. The 6.1 importer also accepts its 25.05 builds (1.2.1 and older), which cannot bind 26.08; 1.3.0 is its first 26.08 build. ## Type of change - Breaking change (existing functionality will not work without user modification) Anything pinning Isaac Lab against OpenUSD 25.05 outside Kit must re-resolve, and code compiled against the 25.05 `pxr` ABI must be rebuilt. ## Changes | File | Change | | --- | --- | | `pyproject.toml` | `usd-exchange==3.0.0`, `usd_exchange = "3.0.0"`, and the `usd-exchange` override | | `tools/wheel_builder/uv-overrides.txt`, `source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt` | regenerated with `gen_uv_overrides.py` | | `docs/source/refs/issues.rst` | known-issue example updated to the current pin (`usd-exchange==3.0.0` → OpenUSD 26.08) | | `uv.lock` | regenerated — vs develop: only `usd-exchange` 2.3.0 → 3.0.0 | | `test_assets.py` | `Usd.ZipFileWriter` → `Sdf.ZipFileWriter` — OpenUSD 26.08 moved the zip API | **Drops the OpenUSD work-thread-limit workaround, repo-wide.** Every site was gated on `TODO: Remove once usd-core>=26.5`. | File | Removed | | --- | --- | | `test_leapp_export_flow.py` | `PXR_WORK_THREAD_LIMIT` subprocess env, `--limit_cpu_threads` args, `os` import | | `leapp_initialized_checkpoints.py` | `limit_cpu_threads=1` and its TODO | | `export_common.py` | hidden `--limit_cpu_threads` CLI flag | | `test_environments_isaacsim_physx.py` | `limit_cpu_threads=1` | | `test_environments_newton.py` | `os.environ["PXR_WORK_THREAD_LIMIT"]` + orphaned `os` import | | `rendering_test_utils.py` | `monkeypatch.setenv` + unused `monkeypatch` arg | | `test_contrib_environments_kitless.py` | `os.environ["PXR_WORK_THREAD_LIMIT"]` + `os` import | | `isaaclab_ov/test/conftest.py` | deleted — the workaround was its only content | ## Tracking - [ ] **`usd-optimize` 1.3.0 published** (planned 2026-09-29; upstream [usd-optimize#2](https://github.com/NVIDIA-Omniverse/usd-optimize/issues/2)) → add `usd-optimize>=1.3.0` and relock in one commit, then Docker CI. Validated ahead of time against the internal 26.08 build (1.2.4) from a `uv.lock`: `merge_mesh` merges 5/5 with one OpenUSD runtime; converter tests 28/28. - [ ] Isaac Sim nvbug 6777354 — `merge_mesh_utils` discards `executeOperation`'s result, so a failed merge is silent. - [ ] Isaac Sim 6.3 moves its importer stack to usdex 3.0 — both overrides come out then. - [ ] Importer owners: `urdf_usd_converter` / `mujoco_usd_converter` still call the deprecated `addDiffuseTextureToPreviewMaterial`; under usdex 3.0 the authored shader prim is `ColorTexture`, not `DiffuseTexture`. ## Validation At develop `5faeb3430dd` (Newton suite at `5e6b21bf3e4`), kit-less on desk, environments built from `uv.lock`: | Suite | Result | | --- | --- | | metadata + override-mirror tests | 14/14 | | URDF + MJCF converters | 28/28 | | Newton MJWarp env smoke (workaround removed) | 33/33 | | contrib kit-less envs (workaround removed) | 10 passed, 1 statically skipped | | LEAPP export, with RL extras (workaround removed) | 13 passed, incl. Franka Lift / Reach / Open-Drawer; 5 need Isaac Sim (Kit lane, covered by Docker CI) | | `isaaclab_ov` kit-less (workaround removed) | 404 passed; the 64 failures are identical on develop (one-process device-lock artifact) | **Docker CI** (`b545a92c88d`): every job passed except the usdz case in `test_assets.py`, fixed in `4fca2f139f0`. **Dependency audit.** Only `usd-exchange`'s own `pxr`/`usdex` link OpenUSD's C++ ABI. Develop's new ovphysx 0.6.3, ovrtx 0.5.0, ovstage 0.2.0, newton 1.6.0 and torch 2.12 do not (ovrtx/ovstage ship `libusdrt` plugins, not OpenUSD). `usd-core` is absent; `newton[importers]`, which caps `usd-core<26.5`, is not installed. **Earlier smoke (pre-6.1 base).** 18/18 training arms green across isaacsim_physx / newton_mjwarp / ovphysx × three renderers, on a source build and Docker `latest-develop`. The Kit lane on the 6.2 image is exercised by this PR's Docker CI. **Known, pre-existing — not introduced here.** `import usdex.core` inside a running Kit process aborts (two OpenUSD ABIs in one process) at both versions; no training or converter path does that import. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/develop/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] No new tests: the change removes a workaround and bumps a pin; the suites above cover the affected paths - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: fanes <74020209+fatimaanes@users.noreply.github.com> --- docs/source/refs/issues.rst | 2 +- pyproject.toml | 8 +++-- .../usd-exchange-3-provider.minor.rst | 8 +++++ .../test/install_ci/uv_pip/uv-overrides.txt | 1 + source/isaaclab/test/utils/test_assets.py | 4 +-- .../usd-exchange-3-drop-thread-limit.skip | 3 ++ source/isaaclab_ov/test/conftest.py | 12 ------- .../usd-exchange-3-drop-thread-limit.skip | 4 +++ .../entrypoints/backends/export_common.py | 1 - .../export/leapp_initialized_checkpoints.py | 3 -- .../test/export/test_leapp_export_flow.py | 4 --- .../usd-exchange-3-drop-thread-limit.skip | 4 +++ .../test_contrib_environments_kitless.py | 7 ----- .../core/test_environments_isaacsim_physx.py | 2 +- .../test/core/test_environments_newton.py | 7 ----- .../test/rendering_test_utils.py | 6 +--- tools/wheel_builder/uv-overrides.txt | 1 + uv.lock | 31 ++++++++++--------- 18 files changed, 47 insertions(+), 61 deletions(-) create mode 100644 source/isaaclab/changelog.d/usd-exchange-3-provider.minor.rst create mode 100644 source/isaaclab_ov/changelog.d/usd-exchange-3-drop-thread-limit.skip delete mode 100644 source/isaaclab_ov/test/conftest.py create mode 100644 source/isaaclab_rl/changelog.d/usd-exchange-3-drop-thread-limit.skip create mode 100644 source/isaaclab_tasks/changelog.d/usd-exchange-3-drop-thread-limit.skip diff --git a/docs/source/refs/issues.rst b/docs/source/refs/issues.rst index 1241290b8a1d..12fbde0a9dfd 100644 --- a/docs/source/refs/issues.rst +++ b/docs/source/refs/issues.rst @@ -90,7 +90,7 @@ The issue and upstream fix are described in `OpenUSD PR #4002`_. For kitless workflows, Isaac Lab obtains the ``pxr`` modules from ``usd-exchange``. ``usd-exchange`` and ``usd-core`` are alternative Python distributions of the same OpenUSD runtime and should not be installed together because both provide ``pxr``. They use different - distribution version schemes: for example, ``usd-exchange==2.3.0`` provides OpenUSD 25.05. + distribution version schemes: for example, ``usd-exchange==3.0.0`` provides OpenUSD 26.08. Isaac Sim instead uses its own Kit-bundled OpenUSD runtime. As a workaround for an affected kitless or pre-6.1 Isaac Sim runtime, limit OpenUSD to one worker diff --git a/pyproject.toml b/pyproject.toml index 0563e22ddce9..10ef9a8d5506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,8 +71,8 @@ dependencies = [ # OpenUSD (kit-less mode). Use the same provider required by the URDF and MJCF importer # packages so another complete ``pxr`` runtime cannot co-install with it. Two providers in # one environment overwrite each other's files, and removing either then breaks ``pxr``. - # usd-exchange 2.3.0 vendors USD 25.5, matching the supported Isaac Sim wheel stack. - "usd-exchange==2.3.0", + # usd-exchange 3.0.0 vendors USD 26.08; the isaacsim 6.1 importer pins are overridden below. + "usd-exchange==3.0.0", # avoid broken hf-xet pre-release cached on NVIDIA Artifactory "hf-xet>=1.4.1,<2.0.0 ; platform_machine == 'x86_64' or platform_machine == 'AMD64' or platform_machine == 'aarch64'", # ----- tasks ----- @@ -222,7 +222,7 @@ all = [ # ``test_version_single_source`` fails CI if they drift. [tool.isaaclab.versions] isaacsim = "6.1.0.0" -usd_exchange = "2.3.0" +usd_exchange = "3.0.0" torch = "2.12.0" torchvision = "0.27.0" torchaudio = "2.11.0" @@ -414,6 +414,8 @@ override-dependencies = [ "newton[sim]==1.6.0", # Force the Newton-matched schemas over isaacsim's ==0.2.0 pin. "newton-usd-schemas>=0.5.0", + # isaacsim-asset-isolated and isaacsim-robot-schema 6.1 pin usd-exchange ==2.3.0. + "usd-exchange==3.0.0", "torch==2.12.0", "torchvision==0.27.0", # ovphysx caps packaging at <24 and isaacsim-core pins ==26.0; both caps are diff --git a/source/isaaclab/changelog.d/usd-exchange-3-provider.minor.rst b/source/isaaclab/changelog.d/usd-exchange-3-provider.minor.rst new file mode 100644 index 000000000000..6bc2b6ebbac5 --- /dev/null +++ b/source/isaaclab/changelog.d/usd-exchange-3-provider.minor.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* **Breaking:** Changed the pinned standalone OpenUSD provider from ``usd-exchange`` 2.3.0 to + 3.0.0. In kit-less installs ``pxr`` is now OpenUSD **26.08** instead of 25.05. Environments + that pin Isaac Lab against OpenUSD 25.05 outside Kit must re-resolve, and any code compiled + or generated against the 25.05 ``pxr`` ABI must be rebuilt against 26.08. Kit-backed runs are + unaffected: Kit serves its own OpenUSD from its extension roots. diff --git a/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt b/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt index aa2647f21bca..95864a020a72 100644 --- a/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt +++ b/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt @@ -3,6 +3,7 @@ mujoco~=3.12.0 mujoco-warp~=3.12.0 newton[sim]==1.6.0 newton-usd-schemas>=0.5.0 +usd-exchange==3.0.0 torch==2.12.0 torchvision==0.27.0 packaging>=20,<27 diff --git a/source/isaaclab/test/utils/test_assets.py b/source/isaaclab/test/utils/test_assets.py index db10a7cb02bd..975520c8fa21 100644 --- a/source/isaaclab/test/utils/test_assets.py +++ b/source/isaaclab/test/utils/test_assets.py @@ -509,7 +509,7 @@ def _cache_asset(cache_dir, url: str, payload: bytes, fingerprint: dict | None) def test_local_usd_mirrors_remote_sublayer_without_editing_source(asset_cache, monkeypatch, layout): """Compose local and remote dependency chains without editing the authored layers.""" import omni.client - from pxr import Usd + from pxr import Sdf, Usd layers = { "local.usda": '#usda 1.0\ndef Shader "local" {\n asset info:mdl:sourceAsset = @OmniPBR.mdl@\n}\n', @@ -540,7 +540,7 @@ def fake_copy(url, target_path, behavior): package_path = asset_cache / "scene.usdz" package_root = asset_cache / "package.usda" package_root.write_text('#usda 1.0\n(subLayers = [@local.usda@])\ndef Xform "cartpole" {}\n') - with Usd.ZipFileWriter.CreateNew(str(package_path)) as package: + with Sdf.ZipFileWriter.CreateNew(str(package_path)) as package: package.AddFile(str(package_root), "package.usda") package.AddFile(str(asset_cache / "local.usda"), "local.usda") payloads[source[layout]] = package_path.read_bytes() diff --git a/source/isaaclab_ov/changelog.d/usd-exchange-3-drop-thread-limit.skip b/source/isaaclab_ov/changelog.d/usd-exchange-3-drop-thread-limit.skip new file mode 100644 index 000000000000..b99988c98b6f --- /dev/null +++ b/source/isaaclab_ov/changelog.d/usd-exchange-3-drop-thread-limit.skip @@ -0,0 +1,3 @@ +Test-only change: removed the OpenUSD work-thread limit from the kitless OVPhysX test +configuration. usd-exchange 3.0.0 supplies OpenUSD 26.08, which satisfies the >=26.5 +precondition the workaround was gated on. No user-facing behavior changes. diff --git a/source/isaaclab_ov/test/conftest.py b/source/isaaclab_ov/test/conftest.py deleted file mode 100644 index 5f460add94f7..000000000000 --- a/source/isaaclab_ov/test/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Shared configuration for kitless OVPhysX tests.""" - -import os - -# TODO: Remove once usd-core>=26.5 is the minimum. Earlier releases can corrupt -# the heap when OpenUSD parses payloads concurrently in kitless processes. -os.environ["PXR_WORK_THREAD_LIMIT"] = "1" diff --git a/source/isaaclab_rl/changelog.d/usd-exchange-3-drop-thread-limit.skip b/source/isaaclab_rl/changelog.d/usd-exchange-3-drop-thread-limit.skip new file mode 100644 index 000000000000..89aa9f5d690f --- /dev/null +++ b/source/isaaclab_rl/changelog.d/usd-exchange-3-drop-thread-limit.skip @@ -0,0 +1,4 @@ +Test-only change: removed the OpenUSD work-thread limit from the LEAPP export tests +and the hidden --limit_cpu_threads passthrough on the export CLI. usd-exchange 3.0.0 +supplies OpenUSD 26.08, which satisfies the >=26.5 precondition the workaround was +gated on. No user-facing behavior changes. diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py index 5e87aaba3a8a..0207aebb3f10 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/export_common.py @@ -94,7 +94,6 @@ def add_common_export_args(parser: argparse.ArgumentParser, *, agent_default: st help="Disable LEAPP graph visualization during compile_graph().", ) add_launcher_args(parser) - parser.add_argument("--limit_cpu_threads", type=int, default=argparse.SUPPRESS, help=argparse.SUPPRESS) def finalize_export_args( diff --git a/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py b/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py index 04dc96dd8a7c..5bfcc6d6ca92 100644 --- a/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py +++ b/source/isaaclab_rl/test/export/leapp_initialized_checkpoints.py @@ -324,13 +324,10 @@ def main(argv: Sequence[str] | None = None) -> None: from isaaclab_tasks.utils.hydra import resolve_task_config - # TODO: Remove once usd-core>=26.5 is the minimum. Earlier OpenUSD releases - # can corrupt the heap while parsing the Newton Franka payload concurrently. cli_args = SimpleNamespace( seed=args.seed, algorithm=args.algorithm, headless=True, - limit_cpu_threads=1, ) task_dir = task_checkpoint_dir(args.checkpoint_root, backend_id, task_name) task_dir.mkdir(parents=True, exist_ok=True) diff --git a/source/isaaclab_rl/test/export/test_leapp_export_flow.py b/source/isaaclab_rl/test/export/test_leapp_export_flow.py index 17c3d4c461c4..9f8268a5a99a 100644 --- a/source/isaaclab_rl/test/export/test_leapp_export_flow.py +++ b/source/isaaclab_rl/test/export/test_leapp_export_flow.py @@ -6,7 +6,6 @@ """Integration tests for LEAPP export commands.""" import importlib.util -import os import subprocess import sys from pathlib import Path @@ -59,7 +58,6 @@ def _run_checked(command: list[str], timeout: int = 600) -> str: capture_output=True, text=True, timeout=timeout, - env={**os.environ, "PXR_WORK_THREAD_LIMIT": "1"}, ) except subprocess.TimeoutExpired as exc: stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") @@ -92,8 +90,6 @@ def _run_export(backend: str, task: str, checkpoint: str, tmp_path: Path, preset "--export_save_path", str(tmp_path / "export"), "--disable_graph_visualization", - "--limit_cpu_threads", - "1", ] ) if preset: diff --git a/source/isaaclab_tasks/changelog.d/usd-exchange-3-drop-thread-limit.skip b/source/isaaclab_tasks/changelog.d/usd-exchange-3-drop-thread-limit.skip new file mode 100644 index 000000000000..c785a05acc35 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/usd-exchange-3-drop-thread-limit.skip @@ -0,0 +1,4 @@ +Test-only change: removed the OpenUSD work-thread limit from the environment smoke tests +and the kit-less rendering fixture. usd-exchange 3.0.0 supplies OpenUSD 26.08, which +satisfies the >=26.5 precondition the workaround was gated on. No user-facing behavior +changes. diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments_kitless.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments_kitless.py index 0b2d90f18aeb..934bf210790c 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments_kitless.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments_kitless.py @@ -5,13 +5,6 @@ """Smoke tests for contributed environments that run without Isaac Sim.""" -import os - -# TODO: Remove once usd-core>=26.5 is the minimum. Earlier releases can corrupt -# the heap while parsing Newton payloads concurrently, so disable USD concurrency -# before importing modules that may initialize OpenUSD. -os.environ["PXR_WORK_THREAD_LIMIT"] = "1" - import pytest import isaaclab_tasks # noqa: F401 diff --git a/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py b/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py index 8956de1ab1a7..7f54727b6d58 100644 --- a/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py +++ b/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py @@ -5,7 +5,7 @@ from isaaclab.test.utils import launch_test_simulation -launch_test_simulation(enable_cameras=True, limit_cpu_threads=1) +launch_test_simulation(enable_cameras=True) import pytest diff --git a/source/isaaclab_tasks/test/core/test_environments_newton.py b/source/isaaclab_tasks/test/core/test_environments_newton.py index 00dfdab3f988..8d776a4b5294 100644 --- a/source/isaaclab_tasks/test/core/test_environments_newton.py +++ b/source/isaaclab_tasks/test/core/test_environments_newton.py @@ -5,13 +5,6 @@ """Smoke tests for core environments using the Newton MJWarp runtime.""" -import os - -# TODO: Remove once usd-core>=26.5 is the minimum. Earlier releases can corrupt -# the heap while parsing Newton payloads concurrently, so disable USD concurrency -# before importing modules that may initialize OpenUSD. -os.environ["PXR_WORK_THREAD_LIMIT"] = "1" - import pytest import isaaclab_tasks # noqa: F401 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index d8623ba8930c..072f4d0daf12 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -1127,11 +1127,7 @@ def make_require_ovlibs_install_fixture(): """ @pytest.fixture(autouse=True) - def _require_ovlibs_install(request, monkeypatch: pytest.MonkeyPatch): - # TODO: Remove once usd-core>=26.5 is the minimum - that release fixes the race condition. - # Limit OpenUSD's work-thread pool to one thread to avoid race condition in usd-core<26.5 - monkeypatch.setenv("PXR_WORK_THREAD_LIMIT", "1") - + def _require_ovlibs_install(request): callspec = getattr(request.node, "callspec", None) if callspec is None: return diff --git a/tools/wheel_builder/uv-overrides.txt b/tools/wheel_builder/uv-overrides.txt index aa2647f21bca..95864a020a72 100644 --- a/tools/wheel_builder/uv-overrides.txt +++ b/tools/wheel_builder/uv-overrides.txt @@ -3,6 +3,7 @@ mujoco~=3.12.0 mujoco-warp~=3.12.0 newton[sim]==1.6.0 newton-usd-schemas>=0.5.0 +usd-exchange==3.0.0 torch==2.12.0 torchvision==0.27.0 packaging>=20,<27 diff --git a/uv.lock b/uv.lock index 2ab99273c008..b3764fe92283 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,7 @@ overrides = [ { name = "packaging", specifier = ">=20,<27" }, { name = "torch", specifier = "==2.12.0", index = "https://download.pytorch.org/whl/cu130" }, { name = "torchvision", specifier = "==0.27.0", index = "https://download.pytorch.org/whl/cu130" }, + { name = "usd-exchange", specifier = "==3.0.0" }, ] [[package]] @@ -1698,7 +1699,7 @@ wheels = [ [[package]] name = "isaaclab" -version = "29.0.0" +version = "31.0.0" source = { editable = "source/isaaclab" } [[package]] @@ -1722,7 +1723,7 @@ requires-dist = [ [[package]] name = "isaaclab-contrib" -version = "2.0.1" +version = "3.0.0" source = { editable = "source/isaaclab_contrib" } dependencies = [ { name = "isaaclab" }, @@ -2057,7 +2058,7 @@ requires-dist = [ { name = "transformers", specifier = "==5.10.4" }, { name = "trimesh", specifier = ">=4.6.8" }, { name = "typing-extensions", specifier = ">=4.15.0" }, - { name = "usd-exchange", specifier = "==2.3.0" }, + { name = "usd-exchange", specifier = "==3.0.0" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.16" }, { name = "wandb", marker = "extra == 'wandb'", specifier = ">=0.13.11" }, { name = "warp-lang", specifier = "==1.17.0" }, @@ -2066,7 +2067,7 @@ provides-extras = ["tetrahedralization", "video", "importers", "test", "dev", "s [[package]] name = "isaaclab-experimental" -version = "0.2.2" +version = "0.2.3" source = { editable = "source/isaaclab_experimental" } dependencies = [ { name = "isaaclab" }, @@ -2077,7 +2078,7 @@ requires-dist = [{ name = "isaaclab", editable = "source/isaaclab" }] [[package]] name = "isaaclab-mimic" -version = "2.0.9" +version = "2.0.11" source = { editable = "source/isaaclab_mimic" } dependencies = [ { name = "isaaclab" }, @@ -2094,7 +2095,7 @@ requires-dist = [ [[package]] name = "isaaclab-newton" -version = "8.0.0" +version = "9.0.0" source = { editable = "source/isaaclab_newton" } dependencies = [ { name = "isaaclab" }, @@ -2105,7 +2106,7 @@ requires-dist = [{ name = "isaaclab", editable = "source/isaaclab" }] [[package]] name = "isaaclab-ov" -version = "3.4.0" +version = "3.5.0" source = { editable = "source/isaaclab_ov" } dependencies = [ { name = "isaaclab" }, @@ -2120,7 +2121,7 @@ requires-dist = [ [[package]] name = "isaaclab-physx" -version = "7.2.3" +version = "7.3.0" source = { editable = "source/isaaclab_physx" } dependencies = [ { name = "isaaclab" }, @@ -2142,7 +2143,7 @@ requires-dist = [{ name = "isaaclab", editable = "source/isaaclab" }] [[package]] name = "isaaclab-rl" -version = "1.2.0" +version = "1.3.0" source = { editable = "source/isaaclab_rl" } dependencies = [ { name = "isaaclab" }, @@ -2159,7 +2160,7 @@ requires-dist = [ [[package]] name = "isaaclab-tasks" -version = "21.1.0" +version = "21.3.0" source = { editable = "source/isaaclab_tasks" } dependencies = [ { name = "isaaclab" }, @@ -2200,7 +2201,7 @@ requires-dist = [{ name = "isaaclab", editable = "source/isaaclab" }] [[package]] name = "isaaclab-visualizers" -version = "1.13.0" +version = "2.0.0" source = { editable = "source/isaaclab_visualizers" } dependencies = [ { name = "isaaclab" }, @@ -5689,12 +5690,12 @@ wheels = [ [[package]] name = "usd-exchange" -version = "2.3.0" +version = "3.0.0" source = { registry = "https://pypi.nvidia.com/" } wheels = [ - { url = "https://pypi.nvidia.com/usd-exchange/usd_exchange-2.3.0-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:3beddb68abb89b1f3be207a35275604a6eb89b179ba052ca3eada5cd7202e534" }, - { url = "https://pypi.nvidia.com/usd-exchange/usd_exchange-2.3.0-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:358c858fafebc0ca37a0e62608e595d894ff732eb723ed871292755f5882e8b4" }, - { url = "https://pypi.nvidia.com/usd-exchange/usd_exchange-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:f5ab1b54ce670d8e36786c40750f86396463f2185cfaa5f5e813d6c5207e35ae" }, + { url = "https://pypi.nvidia.com/usd-exchange/usd_exchange-3.0.0-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:59a3b8ef4bc545c76059f7efc5fec94023e0818c5a714fae40f8e0411de79db5" }, + { url = "https://pypi.nvidia.com/usd-exchange/usd_exchange-3.0.0-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:3d5a3ca9f6153abaf2bb4f8fda6c4026e174b6b050eb904a6e4a5bdf89a52926" }, + { url = "https://pypi.nvidia.com/usd-exchange/usd_exchange-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:374499c3d06bd10f4cda2036239935222527f17e1f3ee059fc093aeb9058439b" }, ] [[package]] From 9458da120ea7a84c80394487760802062c4585af Mon Sep 17 00:00:00 2001 From: r-schmitt <139814266+r-schmitt@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:52:20 -0400 Subject: [PATCH 04/10] ovrtx submit all render products fix for duo camera (#8116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Restores OVRTX rendering throughput for multi-camera tasks. The `duo_camera` preset was running roughly 6x slower than it should be. **Always submit every registered render product.** OVRTX batches render products within a step, so naming only the requested subset makes any product that re-enters the set on a later step pay a flat ~350-700 ms penalty. Per guidance from the OVRTX team, both submission paths (`_render_legacy` and `_render_ovstage`) now pass every registered product to `Renderer.step()`. Output routing is unchanged: only the requested cameras are read back, and products left out of a request keep their previous outputs. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist Docker and GPU tests run on demand. Push the commits you want tested, then comment `run-ci` on the pull request. - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../changelog.d/ovrtx-submit-all-render-products.rst | 9 +++++++++ .../isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py | 9 +++++++-- source/isaaclab_ov/test/test_ovrtx_renderer_contract.py | 9 ++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/ovrtx-submit-all-render-products.rst diff --git a/source/isaaclab_ov/changelog.d/ovrtx-submit-all-render-products.rst b/source/isaaclab_ov/changelog.d/ovrtx-submit-all-render-products.rst new file mode 100644 index 000000000000..d283f491d278 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-submit-all-render-products.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Fixed a large rendering slowdown in the OVRTX renderer by submitting every registered + render product on each step instead of only the requested ones. OVRTX batches products + within a step, so naming a subset made every product that re-entered the set on a later + step far more expensive. Only the requested cameras are read back, so products left out + of a request keep their previous outputs. Both the legacy and ``ovstage`` submission + paths were updated. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 1cbb8e319741..4cca89fb44fa 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -1360,7 +1360,7 @@ def _render_legacy(self, render_data: Sequence[OVRTXCameraRenderData]) -> None: if material_writer is not None: material_writer.publish() products = self.backend.renderer.step( - render_products={data.render_product_path for data in render_data}, + render_products=set(self._render_product_paths), delta_time=1.0 / 60.0, ) finally: @@ -1516,6 +1516,11 @@ def render(self, render_data: OVRTXCameraRenderData) -> None: def render_batch(self, render_data: Sequence[OVRTXCameraRenderData]) -> None: """Render all requested camera products in one native submission. + Every registered render product is submitted, not just the requested ones: OVRTX + batches products within a step, and naming only a subset makes each product that + re-enters the set on a later step far more expensive. Only ``render_data`` is read + back, so products left out of the request keep their previous outputs. + Args: render_data: Cameras whose poses and output buffers have been prepared. An empty sequence performs no work. @@ -1786,7 +1791,7 @@ def _render_ovstage(self, render_data: Sequence[OVRTXCameraRenderData]) -> None: with drain_errors: material_writer.drain() products = self.backend.renderer.step( - render_products={data.render_product_path for data in render_data}, + render_products=set(self._render_product_paths), delta_time=1.0 / 60.0, ordinal=self._current_ordinal, ) diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index e6a231f3fc11..53cf3edddf4e 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -165,8 +165,10 @@ def __init__(self, **kwargs): ("frame", True, True), ], ) -def test_ovrtx_render_submits_requested_products_and_routes_outputs(monkeypatch, use_ovstage, missing_output, batch): - """A submission fills each camera from its product and rejects incomplete results.""" +def test_ovrtx_render_submits_every_product_and_routes_requested_outputs( + monkeypatch, use_ovstage, missing_output, batch +): + """A submission covers every registered product, fills each requested camera, and rejects gaps.""" renderer = _make_ovrtx_renderer_without_backend() renderer._use_ovstage = use_ovstage renderer._initialized_scene = True @@ -219,7 +221,8 @@ def advance_write_floor(*, ordinal): assert not postprocessed assert len(submissions) == 1 - assert submissions[0]["render_products"] == {camera.render_product_path for camera in cameras} + # Unrequested products are submitted too, and only the requested cameras are read back. + assert submissions[0]["render_products"] == set(renderer._render_product_paths) if use_ovstage: assert submissions[0]["ordinal"] == 7 assert published_ordinals == [7] From b744dbec94de3b27f2e39064c56f0b7581a2881c Mon Sep 17 00:00:00 2001 From: ooctipus Date: Mon, 28 Sep 2026 20:26:42 -0700 Subject: [PATCH 05/10] [Core] Remove parallel launch and preset mechanisms (#8112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Follows #8111 (merged). This is the first step toward launch and preset consistency: one mechanism per decision. This PR removes parallel mechanisms that nothing needs. Follow-ups will make `launch_simulation` the single place visualizers and the device are decided, and replace `--physics` with `physics=` presets. ### Changes - **One preset-override parser.** `hydra_task_config` (no callers) is removed, and its callback indirection is folded into `resolve_task_config`. So is the second override parser (`parse_overrides` / `apply_overrides`). - `test_hydra.py` and the rendering test helper now go through `register_task` / `resolve_task_config`, the production parser, instead of a different one. - Five parser-only tests are dropped. One of them tested behavior production never had. - **`--viz` parsed once.** `_parse_visualizer_csv` accepts the argparse-parsed list, so `_normalize_launcher_args` no longer joins it back into a string and parses it again. Invalid values still fail at argparse time. - **One visualizer type table** (with the deprecated `newton` → `newton_gl` alias) lives in `isaaclab.visualizers.visualizer_cfg`. `SimulationContext` consumes canonical names instead of remapping aliases itself; the two tables disagreed about `none`. - **`SimulationContext` setup:** - `SettingsHelper` is removed, since `SettingsManager.set` already dispatches on the value type; - `get_physics_dt()` returns `cfg.dt`, and a `getattr(cfg.physics, "dt")` that never matched is dropped; - `initialize_visualizers(config_filter=None)` replaces a public/private forwarder pair; - the visualizer config list is normalized once instead of three times; - `build_simulation_context` reads `sim.has_gui`; - `use_stage` drops its Isaac Sim < 5 branch (the repo pins 6.1). Net: +209 / −500. ## Type of change - Breaking change (removed `hydra_task_config`, `parse_overrides`, `apply_overrides`, and `SettingsHelper`; `initialize_visualizers` gained `config_filter`) ## Validation All runs on GPU 0. - **Tests:** - `isaaclab/test/app` (27) and `isaaclab_physx/test/app` (90); - hydra (67), preset CLI (11), preset Kit decision (6), runtime compatibility (27), visualizer intent (8), distributed device (11), agent preset composition (13); - simulation context (26), stage utils (15), headless `build_simulation_context` (7), RL entrypoints (44 + 27). - **Training:** Cartpole with `physics=isaacsim_physx` and `physics=newton_mjwarp` runs 2 iterations, and the run summary names each backend correctly. - **Pre-existing failures on this machine, identical on the base branch:** one visualizer test that needs `carb`, and 8 golden-image diffs in `test_rendering_cartpole_kitless`. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks - [x] My changes generate no new warnings - [x] I have added changelog fragments for every touched package --- .../launch-one-mechanism.major.rst | 18 ++ source/isaaclab/isaaclab/app/sim_launcher.py | 43 ++-- .../isaaclab/sim/simulation_context.py | 119 +++------ source/isaaclab/isaaclab/sim/utils/stage.py | 50 ++-- .../isaaclab/visualizers/visualizer_cfg.py | 6 + .../test_simulation_context_visualizers.py | 36 +-- .../launch-one-mechanism.major.rst | 9 + .../isaaclab_tasks/utils/__init__.pyi | 3 +- .../isaaclab_tasks/utils/hydra.py | 168 ++----------- source/isaaclab_tasks/test/core/test_hydra.py | 237 +++++------------- .../test/rendering_test_utils.py | 18 +- .../changelog.d/launch-one-mechanism.skip | 1 + .../newton/newton_visualizer_cfg.py | 2 +- 13 files changed, 209 insertions(+), 501 deletions(-) create mode 100644 source/isaaclab/changelog.d/launch-one-mechanism.major.rst create mode 100644 source/isaaclab_tasks/changelog.d/launch-one-mechanism.major.rst create mode 100644 source/isaaclab_visualizers/changelog.d/launch-one-mechanism.skip diff --git a/source/isaaclab/changelog.d/launch-one-mechanism.major.rst b/source/isaaclab/changelog.d/launch-one-mechanism.major.rst new file mode 100644 index 000000000000..431cc0c25dc7 --- /dev/null +++ b/source/isaaclab/changelog.d/launch-one-mechanism.major.rst @@ -0,0 +1,18 @@ +Changed +^^^^^^^ + +* :meth:`~isaaclab.sim.SimulationContext.initialize_visualizers` accepts an optional ``config_filter`` + selecting which pending visualizers to initialize. +* :meth:`~isaaclab.sim.SimulationContext.get_physics_dt` returns :attr:`~isaaclab.sim.SimulationCfg.dt` + directly. +* :class:`~isaaclab.sim.SimulationContext` expects canonical visualizer names in the + ``/isaaclab/visualizer/types`` setting. The deprecated ``newton`` name is still accepted by ``--visualizer`` + and by :func:`~isaaclab.sim.build_simulation_context`, which map it to ``newton_gl``. + +Removed +^^^^^^^ + +* **Breaking:** Removed ``isaaclab.sim.simulation_context.SettingsHelper``. Use + :meth:`~isaaclab.sim.SimulationContext.set_setting` and :meth:`~isaaclab.sim.SimulationContext.get_setting`, + or :func:`~isaaclab.app.get_settings_manager`. +* Removed the Isaac Sim < 5.0 no-op fallback of :func:`~isaaclab.sim.utils.use_stage`. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index be0b47448e4a..b470f571b301 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -33,16 +33,12 @@ from ..sensors.camera.camera_cfg import CameraCfg from ..utils.device import set_cuda_device from ..utils.string import string_to_callable -from ..visualizers.visualizer_cfg import VisualizerCfg +from ..visualizers.visualizer_cfg import _VISUALIZER_ALIASES, _VISUALIZER_TYPES, VisualizerCfg from .logging_utils import apply_python_logging_level from .settings_manager import get_settings_manager logger = logging.getLogger(__name__) -_VISUALIZER_TYPES = ("kit", "newton_gl", "newton_rtx", "rerun", "viser", "none") -_VISUALIZER_ALIASES = {"newton": "newton_gl"} -"""Deprecated ``--visualizer`` names and their replacements.""" - _KIT_LAUNCHER = "isaaclab_physx.app:KitLauncher" """Launcher for Kit needs that no config names, e.g. a default-renderer camera or ``--viz kit``.""" @@ -168,29 +164,34 @@ def _is_kit_camera(node) -> bool: """ -def _parse_visualizer_csv(value: str) -> list[str]: - """Parse the ``--visualizer`` comma-separated list into canonical names; ``none`` yields an empty list.""" - token = (value or "").strip() - if not token: - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: empty string. Use a comma-separated list, e.g. --viz kit,newton_gl." - ) - if " " in token: - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: spaces are not allowed. " - "Use a comma-separated list without spaces, e.g. --viz kit,newton_gl,rerun,viser." - ) - names = [item.strip().lower() for item in token.split(",")] +def _parse_visualizer_csv(value: str | list[str]) -> list[str]: + """Parse a ``--visualizer`` comma-separated list, or a list of names, into canonical names. + + ``none`` yields an empty list. Parsing canonical names again returns them unchanged. + """ + if isinstance(value, str): + token = value.strip() + if not token: + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: empty string. Use a comma-separated list, e.g. --viz kit,newton_gl." + ) + if " " in token: + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: spaces are not allowed. " + "Use a comma-separated list without spaces, e.g. --viz kit,newton_gl,rerun,viser." + ) + value = token.split(",") + names = [str(item).strip().lower() for item in value] if any(not name for name in names): raise argparse.ArgumentTypeError( "Invalid --visualizer value: empty visualizer entry detected. " "Use a comma-separated list without empty items." ) - invalid = [name for name in names if name not in _VISUALIZER_TYPES and name not in _VISUALIZER_ALIASES] + invalid = [name for name in names if name not in (*_VISUALIZER_TYPES, *_VISUALIZER_ALIASES, "none")] if invalid: raise argparse.ArgumentTypeError( f"Invalid --visualizer value(s): {', '.join(invalid)}. " - f"Valid options: {', '.join(sorted(_VISUALIZER_TYPES))}." + f"Valid options: {', '.join(sorted((*_VISUALIZER_TYPES, 'none')))}." ) for name in names: if name in _VISUALIZER_ALIASES: @@ -224,8 +225,6 @@ def _normalize_launcher_args(args: dict) -> None: if livestream not in (0, 1, 2): raise ValueError(f"Invalid livestream mode: {livestream}. Expected 0 (disabled), 1, or 2.") visualizers = args.get("visualizer") - if visualizers and not isinstance(visualizers, str): - visualizers = ",".join(str(visualizer).strip() for visualizer in visualizers) if visualizers: try: visualizers = _parse_visualizer_csv(visualizers) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index e79108185f91..ce0927aaaa0c 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -17,7 +17,7 @@ import warp as wp from .. import sim as sim_utils -from ..app.settings_manager import SettingsManager, get_settings_manager +from ..app.settings_manager import get_settings_manager from ..markers.vis_marker_registry import VisMarkerRegistry from ..physics import PhysicsCfg, PhysicsEvent, PhysicsManager from ..physics.physics_manager_cfg import _resolve_physx_auto_cfg @@ -28,7 +28,7 @@ from ..utils.string import clear_resolve_matching_names_cache from ..utils.version import has_kit from ..visualizers.base_visualizer import BaseVisualizer -from ..visualizers.visualizer_cfg import _get_visualizer_install_hint +from ..visualizers.visualizer_cfg import _VISUALIZER_TYPES, _get_visualizer_install_hint from .utils import create_new_stage from .utils import stage as stage_utils @@ -43,12 +43,6 @@ logger = logging.getLogger(__name__) -# Visualizer type names (CLI and config). App launcher parses CSV and stores as a space-separated setting. -_VISUALIZER_TYPES = ("newton_gl", "newton_rtx", "rerun", "viser", "kit") -# Deprecated aliases mapped to their canonical names. -_VISUALIZER_ALIASES = {"newton": "newton_gl"} - - def _resolve_physics_cfg(physics_cfg: PhysicsCfg | None, use_isaac_sim: bool) -> PhysicsCfg: """Resolve a simulation physics config to a concrete backend.""" if physics_cfg is None: @@ -61,24 +55,6 @@ def _resolve_physics_cfg(physics_cfg: PhysicsCfg | None, use_isaac_sim: bool) -> return _resolve_physx_auto_cfg(physics_cfg, use_isaac_sim=use_isaac_sim) -class SettingsHelper: - """Helper for typed settings access via SettingsManager.""" - - def __init__(self, settings: SettingsManager): - self._settings = settings - - def set(self, name: str, value: Any) -> None: - """Set a setting with automatic type routing.""" - if isinstance(value, (bool, int, float, str, list, tuple)): - self._settings.set(name, value) - else: - raise ValueError(f"Unsupported value type for setting '{name}': {type(value)}") - - def get(self, name: str) -> Any: - """Get a setting value.""" - return self._settings.get(name) - - class SimulationContext: """Controls simulation lifecycle including physics stepping and rendering. @@ -182,7 +158,6 @@ def __init__(self, cfg: SimulationCfg | None = None): # Acquire settings interface (SettingsManager: standalone dict or Omniverse when available) self.settings = get_settings_manager() - self._settings_helper = SettingsHelper(self.settings) # Initialize USD physics scene and physics manager self._init_usd_physics_scene() @@ -219,8 +194,7 @@ def __init__(self, cfg: SimulationCfg | None = None): # backends therefore consume the same immutable layout through one lifecycle. self._clone_plan: ClonePlan | None = None # Default visualization dt used before/without visualizer initialization. - physics_dt = getattr(self.cfg.physics, "dt", None) - self._viz_dt = (physics_dt if physics_dt is not None else self.cfg.dt) * self.cfg.render_interval + self._viz_dt = self.cfg.dt * self.cfg.render_interval # Cache commonly-used settings (these don't change during runtime) self._has_gui = bool(self.get_setting("/isaaclab/has_gui")) @@ -380,8 +354,8 @@ def is_rendering(self) -> bool: ) def get_physics_dt(self) -> float: - """Returns the physics time step.""" - return self.physics_manager.get_physics_dt() + """Returns the physics time step [s].""" + return self.cfg.dt def get_physics_step_count(self) -> int: """Return the monotonic physics step counter (incremented each :meth:`step`).""" @@ -417,17 +391,6 @@ def _create_default_visualizer_configs(self, requested_visualizers: list[str]) - module_overrides = {"newton_gl": "isaaclab_visualizers.newton", "newton_rtx": "isaaclab_visualizers.newton"} for viz_type in requested_visualizers: try: - # Resolve deprecated aliases before lookup. - if viz_type in _VISUALIZER_ALIASES: - canonical = _VISUALIZER_ALIASES[viz_type] - import warnings - - warnings.warn( - f"Visualizer type '{viz_type}' is deprecated. Use '{canonical}' instead.", - DeprecationWarning, - stacklevel=2, - ) - viz_type = canonical if viz_type not in _VISUALIZER_TYPES: logger.warning( f"[SimulationContext] Unknown visualizer type '{viz_type}' requested. " @@ -518,18 +481,20 @@ def _is_cli_visualizer_disable_all(self) -> bool: """Return ``True`` when CLI requested ``--viz none`` semantics.""" return bool(self.get_setting("/isaaclab/visualizer/disable_all")) + def _configured_visualizer_cfgs(self) -> list[Any]: + """Return :attr:`SimulationCfg.visualizer_cfgs` as a list.""" + visualizer_cfgs = self.cfg.visualizer_cfgs + if visualizer_cfgs is None: + return [] + return visualizer_cfgs if isinstance(visualizer_cfgs, list) else [visualizer_cfgs] + def resolve_visualizer_types(self) -> list[str]: """Resolve visualizer types from config or CLI settings.""" if self._is_cli_visualizer_disable_all(): return [] if self._is_cli_visualizer_explicit(): return self._get_cli_visualizer_types() - - visualizer_cfgs = self.cfg.visualizer_cfgs - if visualizer_cfgs is None: - return [] - if not isinstance(visualizer_cfgs, list): - visualizer_cfgs = [visualizer_cfgs] + visualizer_cfgs = self._configured_visualizer_cfgs() return [cfg.visualizer_type for cfg in visualizer_cfgs if getattr(cfg, "visualizer_type", None)] def _has_continuous_visualizers(self) -> bool: @@ -538,12 +503,7 @@ def _has_continuous_visualizers(self) -> bool: if not visualizer_types: return False - visualizer_cfgs = self.cfg.visualizer_cfgs - if visualizer_cfgs is None: - visualizer_cfgs = [] - elif not isinstance(visualizer_cfgs, list): - visualizer_cfgs = [visualizer_cfgs] - + visualizer_cfgs = self._configured_visualizer_cfgs() if self._is_cli_visualizer_explicit(): for visualizer_type in visualizer_types: matching_cfgs = [ @@ -564,20 +524,11 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: a :class:`RuntimeError` is raised if any requested type cannot be resolved (unknown type or missing package). """ - visualizer_cfgs: list[Any] = [] - if self.cfg.visualizer_cfgs is not None: - visualizer_cfgs = ( - self.cfg.visualizer_cfgs if isinstance(self.cfg.visualizer_cfgs, list) else [self.cfg.visualizer_cfgs] - ) - + visualizer_cfgs = self._configured_visualizer_cfgs() cli_requested = self._get_cli_visualizer_types() cli_explicit = self._is_cli_visualizer_explicit() cli_disable_all = self._is_cli_visualizer_disable_all() - # cli_requested holds raw, possibly-aliased strings (e.g. "newton"); resolved cfgs carry - # the canonical visualizer_type (e.g. "newton_gl"). Compare via this instead of directly. - canonical_requested = [_VISUALIZER_ALIASES.get(t, t) for t in cli_requested] - if cli_disable_all: resolved = [] elif not cli_explicit: @@ -590,15 +541,14 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: self._apply_visualizer_cli_overrides(resolved) else: # CLI selection is explicit: keep only requested cfg types, then add defaults for missing. - cli_requested_set = set(canonical_requested) - resolved = [cfg for cfg in visualizer_cfgs if getattr(cfg, "visualizer_type", None) in cli_requested_set] + resolved = [cfg for cfg in visualizer_cfgs if getattr(cfg, "visualizer_type", None) in cli_requested] for cfg in resolved: self._apply_default_visualizer_cfg(cfg) existing_types = {getattr(cfg, "visualizer_type", None) for cfg in resolved} for viz_type in cli_requested: - if _VISUALIZER_ALIASES.get(viz_type, viz_type) not in existing_types: + if viz_type not in existing_types: resolved.extend(self._create_default_visualizer_configs([viz_type])) - existing_types.add(_VISUALIZER_ALIASES.get(viz_type, viz_type)) + existing_types.add(viz_type) self._apply_visualizer_cli_overrides(resolved) # When visualizers were explicitly requested via CLI, verify all @@ -607,11 +557,7 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: # skips. if cli_explicit and cli_requested: resolved_types = {getattr(cfg, "visualizer_type", None) for cfg in resolved} - missing = [ - t - for t, canonical in zip(cli_requested, canonical_requested, strict=True) - if canonical not in resolved_types - ] + missing = [t for t in cli_requested if t not in resolved_types] if missing: install_hints = " ".join( _get_visualizer_install_hint(visualizer_type) @@ -644,10 +590,6 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: return resolved - def initialize_visualizers(self) -> None: - """Initialize the configured visualizers after their shared scene has been cloned.""" - self._initialize_visualizers() - def _create_visualizers(self) -> None: """Construct cfg-owned consumers and publish their requirements before scene cloning.""" for cfg in self._resolve_visualizer_cfgs(): @@ -658,8 +600,13 @@ def _create_visualizers(self) -> None: self._render_context.clone_contexts.update(cfg.cloning_contexts) self._pending_visualizers.append(instantiate(cfg)) - def _initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = None) -> None: - """Bind constructed visualizers, optionally selecting only pre-capture consumers.""" + def initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = None) -> None: + """Initialize the constructed visualizers after their shared scene has been cloned. + + Args: + config_filter: Predicate on a visualizer config selecting which pending visualizers to + initialize, e.g. only the consumers needed before graph capture. Defaults to None (all). + """ for visualizer in tuple(self._pending_visualizers): if config_filter is not None and not config_filter(visualizer.cfg): continue @@ -738,7 +685,7 @@ def _prepare_newton_visualizer_for_capture(self, _payload=None) -> None: """Initialize or rebind the Newton viewer before solver graph capture.""" # Picking applies forces inside solver substeps, so its kernels and buffers # must exist during graph capture. Render-only viewers can initialize later. - self._initialize_visualizers(self._requires_pre_capture_newton_init) + self.initialize_visualizers(self._requires_pre_capture_newton_init) for viz in (viz for viz in self._visualizers if self._requires_pre_capture_newton_init(viz.cfg)): viz.reset(soft=False) @@ -940,11 +887,11 @@ def is_stopped(self) -> bool: def set_setting(self, name: str, value: Any) -> None: """Set a setting value.""" - self._settings_helper.set(name, value) + self.settings.set(name, value) def get_setting(self, name: str) -> Any: """Get a setting value.""" - return self._settings_helper.get(name) + return self.settings.get(name) def get_or_create_backend(self, cfg: Any) -> Any: """Return the simulation-owned object for a construction configuration. @@ -1132,7 +1079,9 @@ def build_simulation_context( sim_cfg.device = device if visualizers: - get_settings_manager().set("/isaaclab/visualizer/types", " ".join(visualizers)) + from ..app.sim_launcher import _parse_visualizer_csv # noqa: PLC0415 + + get_settings_manager().set("/isaaclab/visualizer/types", " ".join(_parse_visualizer_csv(visualizers) or ())) sim = SimulationContext(sim_cfg) @@ -1140,7 +1089,7 @@ def build_simulation_context( cfg = GroundPlaneCfg() cfg.func("/World/defaultGroundPlane", cfg) - if add_lighting or (auto_add_lighting and (sim.get_setting("/isaaclab/has_gui") or visualizers)): + if add_lighting or (auto_add_lighting and (sim.has_gui or visualizers)): cfg = DomeLightCfg( color=(0.1, 0.1, 0.1), enable_color_temperature=True, color_temperature=5500, intensity=10000 ) @@ -1153,6 +1102,6 @@ def build_simulation_context( raise finally: if sim is not None: - if not sim.get_setting("/isaaclab/has_gui"): + if not sim.has_gui: sim.stop() sim.clear_instance() diff --git a/source/isaaclab/isaaclab/sim/utils/stage.py b/source/isaaclab/isaaclab/sim/utils/stage.py index 163ed107a323..7d9f85bc4376 100644 --- a/source/isaaclab/isaaclab/sim/utils/stage.py +++ b/source/isaaclab/isaaclab/sim/utils/stage.py @@ -14,8 +14,6 @@ from collections.abc import Callable, Generator from typing import TYPE_CHECKING -from ...utils.version import get_isaac_sim_version, has_kit - if TYPE_CHECKING: from pxr import Sdf, Usd, UsdUtils # noqa: F401 @@ -196,7 +194,7 @@ def open_stage(usd_path: str) -> Usd.Stage: @contextlib.contextmanager def use_stage(stage: Usd.Stage) -> Generator[None, None, None]: - """Context manager that sets a thread-local stage, if supported. + """Context manager that sets a thread-local stage. This function binds the stage to the thread-local context for the duration of the context manager. During the context manager, any call to :func:`get_current_stage` will return the stage specified @@ -204,8 +202,6 @@ def use_stage(stage: Usd.Stage) -> Generator[None, None, None]: stage attached to the USD context. .. versionadded:: 2.3.0 - This function is available in Isaac Sim 5.0 and later. For backwards - compatibility, it falls back to a no-op context manager in Isaac Sim < 5.0. Args: stage: The stage to set in the context. @@ -228,25 +224,19 @@ def use_stage(stage: Usd.Stage) -> Generator[None, None, None]: """ from pxr import Usd # noqa: PLC0415 - if has_kit() and get_isaac_sim_version().major < 5: - logger.warning("Isaac Sim < 5.0 does not support thread-local stage contexts. Skipping use_stage().") - yield # no-op - else: - # check stage - if not isinstance(stage, Usd.Stage): - raise TypeError(f"Expected a USD stage instance, got: {type(stage)}") - # store previous context value if it exists - previous_stage = getattr(_context, "stage", None) - # set new context value - try: - _context.stage = stage - yield - # remove context value or restore previous one if it exists - finally: - if previous_stage is None: - delattr(_context, "stage") - else: - _context.stage = previous_stage + if not isinstance(stage, Usd.Stage): + raise TypeError(f"Expected a USD stage instance, got: {type(stage)}") + # store previous context value if it exists + previous_stage = getattr(_context, "stage", None) + try: + _context.stage = stage + yield + # remove context value or restore previous one if it exists + finally: + if previous_stage is None: + delattr(_context, "stage") + else: + _context.stage = previous_stage def save_stage(usd_path: str, save_and_reload_in_place: bool = True) -> bool: @@ -408,15 +398,11 @@ def get_current_stage(fabric: bool = False) -> Usd.Stage: """ # First check thread-local context for an in-memory stage stage = getattr(_context, "stage", None) - if stage is not None: - if fabric: - import usdrt - - # Get stage ID and attach to Fabric stage - stage_id = get_current_stage_id() - return usdrt.Usd.Stage.Attach(stage_id) - return stage + if stage is not None and fabric: + import usdrt + # Get stage ID and attach to Fabric stage + return usdrt.Usd.Stage.Attach(get_current_stage_id()) return stage diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 429ab54854ac..be073f15a2d1 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -16,6 +16,12 @@ from .base_visualizer import BaseVisualizer +_VISUALIZER_TYPES = ("kit", "newton_gl", "newton_rtx", "rerun", "viser") +"""Canonical visualizer type names, for ``--visualizer`` and :attr:`VisualizerCfg.visualizer_type`.""" + +_VISUALIZER_ALIASES = {"newton": "newton_gl"} +"""Deprecated ``--visualizer`` names and their replacements.""" + _VISUALIZER_EXTRAS = { "kit": "isaacsim", "rerun": "rerun", diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 518241600249..fdb385a276d6 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -1078,9 +1078,9 @@ def test_visualizer_failures_propagate_and_retain_constructed_instances(cli_expl def test_explicit_partial_valid_types_raises_for_invalid(): - """Requesting 'newton,bogus_viz' via CLI raises for the unknown type even though newton is valid.""" + """Requesting 'newton_gl,bogus_viz' via CLI raises for the unknown type even though newton_gl is valid.""" settings = { - "/isaaclab/visualizer/types": "newton,bogus_viz", + "/isaaclab/visualizer/types": "newton_gl,bogus_viz", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, "/isaaclab/visualizer/max_visible_envs": None, @@ -1091,43 +1091,23 @@ def test_explicit_partial_valid_types_raises_for_invalid(): ctx._create_visualizers() message = str(exc_info.value) - assert "bogus_viz" in message - # The successfully-resolved deprecated alias must not also be reported as missing. - assert "'newton'" not in message - - -def test_explicit_deprecated_alias_alone_does_not_raise(): - """Requesting only the deprecated 'newton' alias warns, resolves to a NewtonGLVisualizerCfg, and does - not raise, even though the resolved cfg carries the canonical 'newton_gl' type.""" - settings = { - "/isaaclab/visualizer/types": "newton", - "/isaaclab/visualizer/explicit": True, - "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_visible_envs": None, - } - ctx = _make_context_with_settings(settings) - - with pytest.warns(DeprecationWarning, match="newton.*deprecated.*newton_gl"): - ctx._create_visualizers() - - assert len(ctx._pending_visualizers) == 1 - assert isinstance(ctx._pending_visualizers[0].cfg, NewtonGLVisualizerCfg) + # The successfully-resolved type must not also be reported as missing. + assert "['bogus_viz']" in message -def test_explicit_deprecated_alias_matches_existing_cfg_by_canonical_type(): - """Requesting 'newton' via CLI when cfg.visualizer_cfgs already has a customized 'newton_gl' +def test_explicit_type_matches_existing_cfg(): + """Requesting 'newton_gl' via CLI when cfg.visualizer_cfgs already has a customized 'newton_gl' config selects and returns that exact instance, rather than discarding it and building a - fresh default -- exercising the branch that filters pre-existing cfgs by canonical type.""" + fresh default -- exercising the branch that filters pre-existing cfgs by type.""" existing_cfg = NewtonGLVisualizerCfg(background_color=(0.4, 0.5, 0.6)) settings = { - "/isaaclab/visualizer/types": "newton", + "/isaaclab/visualizer/types": "newton_gl", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings, visualizer_cfgs=[existing_cfg]) - # No alias-resolution warning: the pre-existing cfg already satisfies the canonical type. cfgs = ctx._resolve_visualizer_cfgs() assert len(cfgs) == 1 diff --git a/source/isaaclab_tasks/changelog.d/launch-one-mechanism.major.rst b/source/isaaclab_tasks/changelog.d/launch-one-mechanism.major.rst new file mode 100644 index 000000000000..9dba10fcfc5d --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/launch-one-mechanism.major.rst @@ -0,0 +1,9 @@ +Removed +^^^^^^^ + +* **Breaking:** Removed ``isaaclab_tasks.utils.hydra_task_config``. Call + :func:`~isaaclab_tasks.utils.resolve_task_config` and pass the returned ``(env_cfg, agent_cfg)`` to your + entry function. +* **Breaking:** Removed ``isaaclab_tasks.utils.hydra.parse_overrides`` and + ``isaaclab_tasks.utils.hydra.apply_overrides``. Pass the same command-line tokens to + :func:`~isaaclab_tasks.utils.resolve_task_config` through its ``overrides`` argument. diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi index fbd185a7b8bd..8063ecb5b813 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.pyi @@ -11,14 +11,13 @@ __all__ = [ "PresetCfg", "preset", "resolve_task_config", - "hydra_task_config", "resolve_presets", "setup_preset_cli", "SuccessMonitor", "SuccessMonitorCfg", ] -from .hydra import PresetCfg, preset, hydra_task_config, resolve_task_config, resolve_presets +from .hydra import PresetCfg, preset, resolve_task_config, resolve_presets from .importer import import_packages from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg from .preset_cli import setup_preset_cli diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py index 82c56b09c741..07cab4c90953 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py @@ -27,7 +27,6 @@ """ import ast -import functools import sys import warnings from collections import deque @@ -76,11 +75,6 @@ def user_stacklevel() -> int: return level -def _known_preset_names(presets: dict) -> set[str]: - """Return all preset names declared in a collected preset dictionary.""" - return {name for section in presets.values() for fields in section.values() for name in fields} - - def _normalize_preset_name(name: str, known_names: set[str]) -> str: """Map a deprecated preset name to its replacement and emit a warning. @@ -443,32 +437,6 @@ def resolve_presets(cfg, selected=()): # ============================================================================ -def _run_hydra(task, env_cfg, agent_cfg, hydra_args, callback): - """Shared Hydra entry point for :func:`resolve_task_config` and :func:`hydra_task_config`.""" - if not hydra_args: - env_cfg = replace_strings_with_env_cfg_spaces(env_cfg) - callback(env_cfg, agent_cfg) - return - - original_argv, sys.argv = sys.argv, [sys.argv[0]] + hydra_args - - @hydra.main(config_path=None, config_name=task, version_base="1.3") - def hydra_main(hydra_cfg, env_cfg=env_cfg, agent_cfg=agent_cfg): - hydra_cfg = replace_strings_with_slices(OmegaConf.to_container(hydra_cfg, resolve=True)) - update_from_dict(env_cfg, hydra_cfg["env"]) - env_cfg = replace_strings_with_env_cfg_spaces(env_cfg) - if isinstance(agent_cfg, dict) or agent_cfg is None: - agent_cfg = hydra_cfg["agent"] - else: - agent_cfg.from_dict(hydra_cfg["agent"]) - callback(env_cfg, agent_cfg) - - try: - hydra_main() - finally: - sys.argv = original_argv - - def resolve_task_config( task_name: str, agent_cfg_entry_point: str | None, @@ -497,34 +465,27 @@ def resolve_task_config( env_cfg, agent_cfg, hydra_args = register_task( task, agent_cfg_entry_point, play_mode=play_mode, overrides=overrides ) - resolved = {} - _run_hydra(task, env_cfg, agent_cfg, hydra_args, lambda e, a: resolved.update(env_cfg=e, agent_cfg=a)) - return resolved["env_cfg"], resolved["agent_cfg"] - - -def hydra_task_config(task_name: str, agent_cfg_entry_point: str, play_mode: bool = False) -> Callable: - """Decorator for Hydra config with REPLACE-only preset semantics. - - Args: - task_name: Task name (e.g., "Isaac-Reach-Franka") - agent_cfg_entry_point: Agent config entry point key - play_mode: Whether to apply the play-mode overrides defined by the environment - configuration's ``play_mode`` method after loading. Defaults to False. - - Returns: - Decorated function receiving ``(env_cfg, agent_cfg, *args, **kwargs)`` - """ + if not hydra_args: + return replace_strings_with_env_cfg_spaces(env_cfg), agent_cfg - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - task = task_name.split(":")[-1] - env_cfg, agent_cfg, hydra_args = register_task(task, agent_cfg_entry_point, play_mode=play_mode) - _run_hydra(task, env_cfg, agent_cfg, hydra_args, lambda e, a: func(e, a, *args, **kwargs)) + resolved = {} + original_argv, sys.argv = sys.argv, [sys.argv[0]] + hydra_args - return wrapper + @hydra.main(config_path=None, config_name=task, version_base="1.3") + def hydra_main(hydra_cfg, env_cfg=env_cfg, agent_cfg=agent_cfg): + hydra_cfg = replace_strings_with_slices(OmegaConf.to_container(hydra_cfg, resolve=True)) + update_from_dict(env_cfg, hydra_cfg["env"]) + if isinstance(agent_cfg, dict) or agent_cfg is None: + agent_cfg = hydra_cfg["agent"] + else: + agent_cfg.from_dict(hydra_cfg["agent"]) + resolved.update(env_cfg=replace_strings_with_env_cfg_spaces(env_cfg), agent_cfg=agent_cfg) - return decorator + try: + hydra_main() + finally: + sys.argv = original_argv + return resolved["env_cfg"], resolved["agent_cfg"] def _format_unknown_presets_error(unknown: set[str], name_to_paths: dict[str, list[str]], max_paths: int = 5) -> str: @@ -727,99 +688,6 @@ def register_task( return env_cfg, agent_cfg, hydra_args -def parse_overrides(args: list[str], presets: dict) -> tuple: - """Categorize command line args by type. - - Args: - args: Command line args (without script name) - presets: {"env": {"path": {"name": cfg}}, "agent": {...}} - - Returns: - (global_presets, preset_sel, preset_scalar, global_scalar) where: - - global_presets: [name, ...] - apply to all matching configs - - preset_sel: [(section, path, name), ...] - REPLACE selections - - preset_scalar: [(full_path, value), ...] - scalars in preset paths - - global_scalar: [arg, ...] - pass to Hydra - """ - preset_paths = {f"{s}.{p}" if p else s for s, v in presets.items() for p in v} - global_presets, preset_sel, preset_scalar, global_scalar = [], [], [], [] - - for arg in args: - if "=" not in arg: - global_scalar.append(arg) - continue - key, val = arg.split("=", 1) - if key == "presets": - known_names = _known_preset_names(presets) - global_presets.extend(_normalize_preset_name(v.strip(), known_names) for v in val.split(",") if v.strip()) - elif key in preset_paths: - sec, path = key.split(".", 1) if "." in key else (key, "") - known_names = set(presets[sec][path]) - preset_sel.append((sec, path, _normalize_preset_name(val, known_names))) - elif any(key.startswith(pp + ".") for pp in preset_paths): - preset_scalar.append((key, val)) - else: - global_scalar.append(arg) - - preset_sel.sort(key=lambda x: x[1].count(".")) - return global_presets, preset_sel, preset_scalar, global_scalar - - -def apply_overrides( - env_cfg, - agent_cfg, - hydra_cfg: dict, - global_presets: list, - preset_sel: list, - preset_scalar: list, - presets: dict, -): - """Apply preset selections and scalar overrides with REPLACE semantics. - - Presets are resolved by walking the active tree from root to leaves. A - nested preset is only considered after its parent branch has been selected, - which prevents inactive sibling branches from contributing colliding - descendant paths. - - Returns: - (env_cfg, agent_cfg) -- possibly replaced if root-level PresetCfg was resolved. - - Raises: - ValueError: If multiple global presets conflict on an active path, or - an explicit preset path is not reachable in the active tree. - """ - cfgs = {"env": env_cfg, "agent": agent_cfg} - - explicit = {f"{sec}.{path}" if path else sec: name for sec, path, name in preset_sel} - for sec in ("env", "agent"): - if cfgs[sec] is None: - continue - section_explicit = {path: name for path, name in explicit.items() if path == sec or path.startswith(sec + ".")} - cfgs[sec] = _resolve_active_presets(cfgs[sec], global_presets, section_explicit, root_path=sec) - hydra_cfg[sec] = ( - cfgs[sec].to_dict() - if hasattr(cfgs[sec], "to_dict") - else dict(cfgs[sec]) - if isinstance(cfgs[sec], Mapping) - else cfgs[sec] - ) - - _apply_preset_scalars(cfgs, hydra_cfg, preset_scalar) - return cfgs["env"], cfgs["agent"] - - -def _apply_preset_scalars(cfgs: dict, hydra_cfg: dict, preset_scalar: list) -> None: - for full_path, val_str in preset_scalar: - sec = full_path.split(".", 1)[0] - if sec not in cfgs: - continue - path = full_path[len(sec) + 1 :] - if cfgs[sec] is not None: - val = _parse_val(val_str) - _setattr(cfgs[sec], path, val) - _setattr(hydra_cfg, full_path, val) - - def _setattr(obj, path: str, val): """Set nested attribute/key (e.g., "actions.arm_action.scale").""" *parts, leaf = path.split(".") diff --git a/source/isaaclab_tasks/test/core/test_hydra.py b/source/isaaclab_tasks/test/core/test_hydra.py index c1ecfd0a3181..a8b32b4d4707 100644 --- a/source/isaaclab_tasks/test/core/test_hydra.py +++ b/source/isaaclab_tasks/test/core/test_hydra.py @@ -13,15 +13,13 @@ import pytest -from isaaclab.utils import configclass, to_dict +from isaaclab.utils import clone, configclass from isaaclab_tasks.utils import hydra as hydra_mod from isaaclab_tasks.utils.hydra import ( PresetCfg, _format_unknown_presets_error, - apply_overrides, collect_presets, - parse_overrides, preset, resolve_presets, ) @@ -301,21 +299,27 @@ class PresetResolvesToDictEnvCfg: # ============================================================================= -def _apply(env_cfg, agent_cfg=None, global_presets=None, preset_sel=None, preset_scalar=None): - """Collect presets, resolve defaults, build hydra dict, and apply overrides.""" - if agent_cfg is None: - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - return apply_overrides( - env_cfg, - agent_cfg, - hydra_cfg, - global_presets or [], - preset_sel or [], - preset_scalar or [], - presets, - ) +def _register(env_cfg, agent_cfg=None, overrides=()): + """Run :func:`register_task`, the production CLI override path, on copies of the given configs.""" + import gymnasium as gym + + agent_cfg = PresetCfgAgentCfg() if agent_cfg is None else agent_cfg + task = "Isaac-Hydra-Overrides-Test" + entry_points = {"env_cfg_entry_point": lambda: clone(env_cfg), "agent_cfg_entry_point": lambda: clone(agent_cfg)} + gym.register(id=task, entry_point="dummy:Env", kwargs=entry_points) + try: + return hydra_mod.register_task(task, "agent_cfg_entry_point", overrides=list(overrides)) + finally: + del gym.registry[task] + + +def _apply(env_cfg, agent_cfg=None, global_presets=(), preset_sel=(), preset_scalar=()): + """Resolve presets and apply overrides given as CLI tokens through :func:`_register`.""" + overrides = [f"presets={','.join(global_presets)}"] if global_presets else [] + overrides += [f"{sec}.{path}={name}" if path else f"{sec}={name}" for sec, path, name in preset_sel] + overrides += [f"{path}={value}" for path, value in preset_scalar] + env_cfg, agent_cfg, _ = _register(env_cfg, agent_cfg, overrides) + return env_cfg, agent_cfg # ============================================================================= @@ -326,10 +330,7 @@ def _apply(env_cfg, agent_cfg=None, global_presets=None, preset_sel=None, preset @pytest.fixture def class_presets(): """Fresh configs using PresetCfg pattern.""" - env_cfg = PresetCfgEnvCfg() - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - return env_cfg, agent_cfg, presets + return PresetCfgEnvCfg(), PresetCfgAgentCfg() # ============================================================================= @@ -420,10 +421,9 @@ def test_format_unknown_presets_error_calls_out_legacy_aliases(): def test_user_stacklevel_warning_origin_is_outside_hydra_module(): """``_normalize_preset_name`` warnings should not be attributed to hydra.py itself.""" - presets_arg = {"env": {"backend": {"default": None, "newton_mjwarp": None}}, "agent": {}} with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", FutureWarning) - parse_overrides(["presets=newton"], presets_arg) + _apply(PresetCfgEnvCfg(), global_presets=["newton"]) deprecations = [w for w in caught if issubclass(w.category, FutureWarning)] assert deprecations, "expected a FutureWarning from the legacy alias" assert deprecations[0].filename != hydra_mod.__file__, ( @@ -442,37 +442,30 @@ def test_collect_presets_root_level(): # ============================================================================= -# Tests: parse_overrides +# Tests: CLI overrides # ============================================================================= -def test_parse_overrides_mixed(): - """All override types categorized correctly.""" - env_cfg = PresetCfgEnvCfg() - presets = {"env": collect_presets(env_cfg), "agent": {}} - args = [ - "presets=fast", - "env.decimation=10", - "env.backend=newton_mjwarp", - "env.backend.dt=0.001", - ] - global_p, sel, scalar, glob = parse_overrides(args, presets) - assert global_p == ["fast"] - assert ("env", "backend", "newton_mjwarp") in sel - assert ("env.backend.dt", "0.001") in scalar - assert "env.decimation=10" in glob +def test_register_task_mixed_overrides(): + """Global presets, path presets, preset-path scalars, global scalars, and bare args apply together.""" + overrides = ["presets=fast", "env.decimation=10", "env.backend=newton_mjwarp", "env.backend.dt=0.001", "--flag"] + env_cfg, agent_cfg, hydra_args = _register(PresetCfgEnvCfg(), overrides=overrides) + assert isinstance(env_cfg.observations, FastObservationsCfg) + assert isinstance(agent_cfg.policy, FastPolicyCfg) + assert isinstance(env_cfg.backend, NewtonCfg) + assert env_cfg.backend.dt == 0.001 + assert env_cfg.decimation == 10 + assert hydra_args == ["--flag"] # ============================================================================= -# Tests: apply_overrides -- PresetCfg (nested + broadcast + root) +# Tests: PresetCfg resolution (nested + broadcast + root) # ============================================================================= def test_presetcfg_auto_default(class_presets): """'default' field auto-applied when no CLI override.""" - env_cfg, agent_cfg, presets = class_presets - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [], [], presets) + env_cfg, agent_cfg = _apply(*class_presets) assert isinstance(env_cfg.backend, PhysxCfg) assert isinstance(env_cfg.observations, NoiselessObservationsCfg) assert isinstance(agent_cfg.policy, SmallPolicyCfg) @@ -480,18 +473,14 @@ def test_presetcfg_auto_default(class_presets): def test_presetcfg_global_broadcast(class_presets): """Global preset 'fast' broadcasts across env and agent PresetCfg fields.""" - env_cfg, agent_cfg, presets = class_presets - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["fast"], [], [], presets) + env_cfg, agent_cfg = _apply(*class_presets, global_presets=["fast"]) assert isinstance(env_cfg.observations, FastObservationsCfg) assert isinstance(agent_cfg.policy, FastPolicyCfg) def test_presetcfg_path_selection_others_default(class_presets): """Path preset on one field, others get auto-default.""" - env_cfg, agent_cfg, presets = class_presets - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "backend", "newton_mjwarp")], [], presets) + env_cfg, agent_cfg = _apply(*class_presets, preset_sel=[("env", "backend", "newton_mjwarp")]) assert isinstance(env_cfg.backend, NewtonCfg) assert env_cfg.backend.dt == 0.002 assert isinstance(env_cfg.observations, NoiselessObservationsCfg) @@ -681,12 +670,7 @@ def test_presetcfg_none_default_auto_applies(): def test_presetcfg_none_default_cli_selects_enabled(): """PresetCfg with default=None can be overridden to a real config via CLI.""" - env_cfg = EnvWithOptionalFeatureCfg() - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - sel = [("env", "optional_feature", "enabled")] - apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], sel, [], presets) + env_cfg, _ = _apply(EnvWithOptionalFeatureCfg(), preset_sel=[("env", "optional_feature", "enabled")]) assert isinstance(env_cfg.optional_feature, OptionalFeatureCfg) assert env_cfg.optional_feature.buffer_size == 200 @@ -1044,31 +1028,17 @@ class ExtensiblePresetCfg(PresetCfg): # ============================================================================= -# Tests: apply_overrides error handling +# Tests: override error handling # ============================================================================= -def test_apply_overrides_unknown_preset_group_raises(): - """apply_overrides raises ValueError for unknown preset group paths.""" - env_cfg = PresetCfgEnvCfg() - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - with pytest.raises(ValueError, match="Unknown or inactive preset group"): - apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "nonexistent", "val")], [], presets) - - -def test_apply_overrides_unknown_preset_name_raises(): - """apply_overrides raises ValueError for unknown preset name.""" - env_cfg = PresetCfgEnvCfg() - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} +def test_unknown_preset_name_raises(): + """An explicit preset path with an unknown name raises ValueError.""" with pytest.raises(ValueError, match="Unknown preset 'nonexistent'"): - apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "backend", "nonexistent")], [], presets) + _apply(PresetCfgEnvCfg(), preset_sel=[("env", "backend", "nonexistent")]) -def test_apply_overrides_conflicting_globals_raises(): +def test_conflicting_global_presets_raises(): """Two global presets matching the same path cause ValueError.""" @configclass @@ -1081,15 +1051,11 @@ class TwoAltsPresetCfg(PresetCfg): class ConflictEnvCfg: mode: TwoAltsPresetCfg = TwoAltsPresetCfg() - env_cfg = ConflictEnvCfg() - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} with pytest.raises(ValueError, match="Conflicting global presets"): - apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["opt_a", "opt_b"], [], [], presets) + _apply(ConflictEnvCfg(), global_presets=["opt_a", "opt_b"]) -def test_apply_overrides_aliased_globals_no_conflict(): +def test_aliased_global_presets_no_conflict(): """Two global presets resolving to equal values do not raise. Mirrors the Lift ObjectCfg pattern where ``newton_mjwarp = cube`` creates @@ -1114,93 +1080,32 @@ class AliasedEnvCfg: mode: AliasedPresetCfg = AliasedPresetCfg() env_cfg = AliasedEnvCfg() - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - assert presets["env"]["mode"]["cube"] is not presets["env"]["mode"]["newton_mjwarp"] - assert presets["env"]["mode"]["cube"] == presets["env"]["mode"]["newton_mjwarp"] - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["cube", "newton_mjwarp"], [], [], presets) + presets = collect_presets(env_cfg) + assert presets["mode"]["cube"] is not presets["mode"]["newton_mjwarp"] + assert presets["mode"]["cube"] == presets["mode"]["newton_mjwarp"] + env_cfg, _ = _apply(env_cfg, global_presets=["cube", "newton_mjwarp"]) assert env_cfg.mode == SharedCfg() # ============================================================================= -# Tests: parse_overrides edge cases +# Tests: legacy preset aliases and root-level selection # ============================================================================= -def test_parse_overrides_maps_legacy_newton_preset_to_newton_mjwarp(): - """Legacy ``newton`` preset selections resolve to ``newton_mjwarp`` when available.""" - presets = {"env": {"backend": {"default": None, "newton_mjwarp": None}}, "agent": {}} - legacy_name = "newton" - - global_p, sel, _, _ = parse_overrides(["presets=fast," + legacy_name, f"env.backend={legacy_name}"], presets) - - assert global_p == ["fast", "newton_mjwarp"] - assert sel == [("env", "backend", "newton_mjwarp")] - - -def test_parse_overrides_maps_legacy_kamino_preset_to_newton_kamino(): - """Legacy ``kamino`` preset selections resolve to ``newton_kamino`` when available.""" - presets = {"env": {"solver": {"default": None, "newton_kamino": None}}, "agent": {}} - legacy_name = "kamino" - - global_p, sel, _, _ = parse_overrides(["presets=" + legacy_name, f"env.solver={legacy_name}"], presets) - - assert global_p == ["newton_kamino"] - assert sel == [("env", "solver", "newton_kamino")] - - -def test_apply_overrides_resolves_legacy_alias_in_global_and_path_selection(class_presets): - """``apply_overrides`` resolves legacy names supplied directly (bypassing ``parse_overrides``).""" - env_cfg, agent_cfg, presets = class_presets - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} +def test_legacy_alias_resolves_in_global_and_path_selection(class_presets): + """Legacy names resolve to their replacement in both ``presets=`` and path selections.""" with pytest.warns(FutureWarning, match="Preset 'newton' is deprecated"): - apply_overrides( - env_cfg, - agent_cfg, - hydra_cfg, - global_presets=["newton"], - preset_sel=[("env", "backend", "newton")], - preset_scalar=[], - presets=presets, - ) + env_cfg, _ = _apply(*class_presets, global_presets=["newton"], preset_sel=[("env", "backend", "newton")]) assert isinstance(env_cfg.backend, NewtonCfg) -def test_apply_overrides_legacy_and_current_alias_do_not_conflict(class_presets): +def test_legacy_and_current_alias_do_not_conflict(class_presets): """``presets=newton,newton_mjwarp`` (legacy + current) resolves to one preset, not a conflict.""" - env_cfg, agent_cfg, presets = class_presets - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} with pytest.warns(FutureWarning, match="Preset 'newton' is deprecated"): - apply_overrides(env_cfg, agent_cfg, hydra_cfg, ["newton", "newton_mjwarp"], [], [], presets) + env_cfg, _ = _apply(*class_presets, global_presets=["newton", "newton_mjwarp"]) assert isinstance(env_cfg.backend, NewtonCfg) -def test_parse_overrides_no_equals_treated_as_global_scalar(): - """Arguments without '=' are passed through as global scalars.""" - presets = {"env": {}, "agent": {}} - _, _, _, global_scalar = parse_overrides(["--flag", "positional"], presets) - assert "--flag" in global_scalar - assert "positional" in global_scalar - - -def test_parse_overrides_preset_scalar_detection(): - """Scalar within a preset path is detected as preset_scalar.""" - presets = {"env": {"backend": {"default": None}}, "agent": {}} - _, _, preset_scalar, _ = parse_overrides(["env.backend.dt=0.001", "env.backend.substeps=4"], presets) - assert ("env.backend.dt", "0.001") in preset_scalar - assert ("env.backend.substeps", "4") in preset_scalar - - -@pytest.mark.parametrize("section", ["env", "agent"]) -def test_parse_overrides_root_level_preset(section): - """Root-level PresetCfg (path='') makes
= a valid preset selection.""" - presets = {"env": {}, "agent": {}} - presets[section] = {"": {"default": None, "fast": None}} - _, sel, _, _ = parse_overrides([f"{section}=fast"], presets) - assert sel == [(section, "", "fast")] - - # ============================================================================= # Tests: _parse_val # ============================================================================= @@ -1231,16 +1136,8 @@ def test_parse_val_types(): def test_scalar_override_within_preset_path(class_presets): """Scalar overrides within preset paths are applied on top of the preset.""" - env_cfg, agent_cfg, presets = class_presets - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - apply_overrides( - env_cfg, - agent_cfg, - hydra_cfg, - [], - [("env", "backend", "newton_mjwarp")], - [("env.backend.dt", "0.001")], - presets, + env_cfg, _ = _apply( + *class_presets, preset_sel=[("env", "backend", "newton_mjwarp")], preset_scalar=[("env.backend.dt", "0.001")] ) assert isinstance(env_cfg.backend, NewtonCfg) assert env_cfg.backend.dt == 0.001 @@ -1260,18 +1157,10 @@ class KaminoPhysicsPreset(PresetCfg): class KaminoEnvCfg: physics: KaminoPhysicsPreset = KaminoPhysicsPreset() - env_cfg = KaminoEnvCfg() - agent_cfg = PresetCfgAgentCfg() - presets = {"env": collect_presets(env_cfg), "agent": collect_presets(agent_cfg)} - hydra_cfg = {"env": to_dict(env_cfg), "agent": to_dict(agent_cfg)} - apply_overrides( - env_cfg, - agent_cfg, - hydra_cfg, - ["newton_kamino"], - [], - [("env.physics.solver_cfg.dynamics_solver_cfg.max_iterations", "25")], - presets, + env_cfg, _ = _apply( + KaminoEnvCfg(), + global_presets=["newton_kamino"], + preset_scalar=[("env.physics.solver_cfg.dynamics_solver_cfg.max_iterations", "25")], ) assert isinstance(env_cfg.physics.solver_cfg, KaminoPADMMSolverCfg) assert env_cfg.physics.solver_cfg.dynamics_solver_cfg.max_iterations == 25 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index 072f4d0daf12..936a42de0311 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -18,7 +18,7 @@ import torch from PIL import Image, ImageChops -from isaaclab.utils import clone, replace, to_dict +from isaaclab.utils import clone, replace from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display from isaaclab.utils.warp import ProxyArray @@ -736,13 +736,17 @@ def maybe_save_stage( def _apply_overrides_to_env_cfg(env_cfg: Any, override_args: list[str]) -> Any: - """Apply override args to env_cfg using parse_overrides and apply_overrides.""" - from isaaclab_tasks.utils.hydra import apply_overrides, collect_presets, parse_overrides + """Return a copy of env_cfg with override args applied by the production task-config resolver.""" + import gymnasium as gym - presets = {"env": collect_presets(env_cfg)} - global_presets, preset_sel, preset_scalar, _ = parse_overrides(override_args, presets) - hydra_cfg = {"env": to_dict(env_cfg)} - env_cfg, _ = apply_overrides(env_cfg, None, hydra_cfg, global_presets, preset_sel, preset_scalar, presets) + from isaaclab_tasks.utils import resolve_task_config + + task = "Isaac-Rendering-Test-Overrides" + gym.register(id=task, entry_point="dummy:Env", kwargs={"env_cfg_entry_point": lambda: clone(env_cfg)}) + try: + env_cfg, _ = resolve_task_config(task, None, overrides=override_args) + finally: + del gym.registry[task] return env_cfg diff --git a/source/isaaclab_visualizers/changelog.d/launch-one-mechanism.skip b/source/isaaclab_visualizers/changelog.d/launch-one-mechanism.skip new file mode 100644 index 000000000000..107195cd9480 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/launch-one-mechanism.skip @@ -0,0 +1 @@ +Updated a comment that named the relocated visualizer alias table. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py index dc5c4052abf7..67c97fc83635 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py @@ -32,7 +32,7 @@ class NewtonVisualizerCfg(VisualizerCfg): class_type: type[NewtonGLVisualizer] | str = "{DIR}.newton_visualizer:NewtonGLVisualizer" """Deprecated alias for the Newton GL visualizer implementation.""" - # Deprecated alias: "newton" routes to the GL backend via simulation_context._VISUALIZER_ALIASES. + # Deprecated alias: "newton" routes to the GL backend via visualizer_cfg._VISUALIZER_ALIASES. visualizer_type: str = "newton_gl" cloning_contexts: tuple[type | str, ...] = ("isaaclab_newton.cloner:NewtonReplicateContext",) From 389c12f52f9f0bea607985fcc181a0f367229222 Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Mon, 28 Sep 2026 20:33:07 -0700 Subject: [PATCH 06/10] [VDR feedback] Fix shutdown hang on Ctrl+C with multiple visualizers (#7698) ## Summary Ensure environments close before the simulation runtime shuts down, even when setup or execution is interrupted. Repeated Ctrl+C presses must not interrupt that cleanup halfway through. - Train/play and random/zero entrypoints register cleanup immediately after creating the environment, using the standard library's `ExitStack`. The callback closes the final wrapper, not the original unwrapped environment. - Ignore SIGINT only while closing the environment or Kit, then restore the previous handler. Normal execution remains interruptible; no polling flags or new context-manager framework. - Pass exit status 130 to the runtime launcher for an unhandled `KeyboardInterrupt`, rather than reporting success. Existing training-loop handlers that save checkpoints remain unchanged. ## Validation - 170 tests passed across the existing launcher, entrypoint, and entrypoint-common suites. Extended existing tests; no new test files. - The four regression cases failed against unmodified develop: interrupted Kit cleanup, incorrect interrupt exit status, and random-agent cleanup interrupted during reset or stepping. - GPU 0: Cartpole Direct, 64 environments, Newton MJWarp, headless Kit + Newton GL. Four rapid SIGINTs completed shutdown with `SimulationContext` cleared and exit status 130. - GPU 0: one RSL-RL training iteration completed and exited with status 0. - `uv run isaaclab -f` passed, including changelog validation. The full four-viewer GUI combination has not been revalidated locally. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Type of change - Bug fix --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Octi Zhang --- .../changelog.d/launch-interrupt-status.rst | 5 ++ source/isaaclab/isaaclab/app/sim_launcher.py | 3 ++ .../app/test_launch_simulation_require_kit.py | 23 ++++++--- .../changelog.d/kit-interrupt-cleanup.rst | 5 ++ .../isaaclab_physx/app/kit_launcher.py | 10 ++-- .../test/app/test_kit_launcher.py | 23 +++++++-- ...pte-fix-shutdown-hang-multi-visualizer.rst | 6 +++ .../entrypoints/backends/play_rl_games.py | 5 +- .../entrypoints/backends/play_rsl_rl.py | 5 +- .../entrypoints/backends/play_sb3.py | 5 +- .../entrypoints/backends/play_skrl.py | 5 +- .../entrypoints/backends/play_torchrl.py | 43 ++++++++-------- .../entrypoints/backends/train_rl_games.py | 5 +- .../entrypoints/backends/train_rsl_rl.py | 5 +- .../entrypoints/backends/train_sb3.py | 5 +- .../entrypoints/backends/train_skrl.py | 5 +- .../entrypoints/backends/train_torchrl.py | 5 +- .../isaaclab_rl/entrypoints/common.py | 14 +++++ .../isaaclab_rl/entrypoints/simple_agents.py | 51 +++++++++---------- source/isaaclab_rl/test/test_entrypoints.py | 30 +++++++---- 20 files changed, 167 insertions(+), 91 deletions(-) create mode 100644 source/isaaclab/changelog.d/launch-interrupt-status.rst create mode 100644 source/isaaclab_physx/changelog.d/kit-interrupt-cleanup.rst create mode 100644 source/isaaclab_rl/changelog.d/mtrepte-fix-shutdown-hang-multi-visualizer.rst diff --git a/source/isaaclab/changelog.d/launch-interrupt-status.rst b/source/isaaclab/changelog.d/launch-interrupt-status.rst new file mode 100644 index 000000000000..0ffbcac80025 --- /dev/null +++ b/source/isaaclab/changelog.d/launch-interrupt-status.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed :func:`~isaaclab.app.launch_simulation` passing a successful exit status to runtime launchers + after an unhandled Ctrl+C; it now passed exit status 130. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index b470f571b301..4b7e8cfb92f4 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -643,6 +643,9 @@ def launch_simulation( configure_storage_profile() yield physics_cfg + except KeyboardInterrupt: + exit_code = 130 + raise except SystemExit as exc: # keep the status of ``sys.exit(n)`` in the block; Kit would otherwise exit with 0 exit_code = exc.code if isinstance(exc.code, int) else int(exc.code is not None) diff --git a/source/isaaclab/test/app/test_launch_simulation_require_kit.py b/source/isaaclab/test/app/test_launch_simulation_require_kit.py index ef7ce8503801..b6b88f355c3b 100644 --- a/source/isaaclab/test/app/test_launch_simulation_require_kit.py +++ b/source/isaaclab/test/app/test_launch_simulation_require_kit.py @@ -15,6 +15,7 @@ """ import argparse +import signal import sys import types @@ -145,8 +146,9 @@ def test_kitless_ovrtx_registers_before_user_code(monkeypatch: pytest.MonkeyPatc assert calls == ["register", "storage", "user"] -def test_config_named_launcher_starts_and_closes(monkeypatch: pytest.MonkeyPatch): - """A launcher named by a config's ``launcher_type`` starts with the simulation and closes after it.""" +@pytest.mark.parametrize("interrupt", [False, True]) +def test_config_named_launcher_starts_and_closes(monkeypatch: pytest.MonkeyPatch, interrupt): + """A config-selected launcher closes on success or interruption with the correct exit status.""" events = [] class FakeLauncher(SimulationLauncher): @@ -154,7 +156,7 @@ def __init__(self, launcher_args): events.append("start") def close(self, exit_code=0): - events.append("close") + events.append(exit_code) class CustomRendererCfg(RendererCfg): launcher_type = "fake_runtime:FakeLauncher" @@ -162,10 +164,17 @@ class CustomRendererCfg(RendererCfg): monkeypatch.setitem(sys.modules, "fake_runtime", types.SimpleNamespace(FakeLauncher=FakeLauncher)) cfg = argparse.Namespace(physics=sim_launcher.NewtonCfg(), renderer=CustomRendererCfg()) - with launch_simulation(cfg): - events.append("user") - - assert events == ["start", "user", "close"] + try: + with launch_simulation(cfg): + events.append("user") + if interrupt: + signal.raise_signal(signal.SIGINT) + except KeyboardInterrupt: + assert interrupt + else: + assert not interrupt + + assert events == ["start", "user", 130 if interrupt else 0] def test_require_kit_false_does_not_suppress_a_kit_config(kit_branch_taken): diff --git a/source/isaaclab_physx/changelog.d/kit-interrupt-cleanup.rst b/source/isaaclab_physx/changelog.d/kit-interrupt-cleanup.rst new file mode 100644 index 000000000000..38e0b0e9e671 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/kit-interrupt-cleanup.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Prevented repeated Ctrl+C presses from interrupting Kit shutdown, restoring the previous signal + handler when shutdown returned. diff --git a/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py b/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py index 8f697f964a96..8c85b422b2e6 100644 --- a/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py +++ b/source/isaaclab_physx/isaaclab_physx/app/kit_launcher.py @@ -245,9 +245,13 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None): def close(self, exit_code: int = 0) -> None: """Close the Kit app this launcher started; Kit fast shutdown exits with *exit_code*.""" if self._app is not None: - # let callbacks queued by the closing simulation run before shutdown - self._app.update() - self._app.close(exit_code=exit_code) + previous_handler = signal.signal(signal.SIGINT, signal.SIG_IGN) + try: + # let callbacks queued by the closing simulation run before shutdown + self._app.update() + self._app.close(exit_code=exit_code) + finally: + signal.signal(signal.SIGINT, previous_handler) """ Operations. diff --git a/source/isaaclab_physx/test/app/test_kit_launcher.py b/source/isaaclab_physx/test/app/test_kit_launcher.py index b6381aaa45d4..ecdae2ef34f6 100644 --- a/source/isaaclab_physx/test/app/test_kit_launcher.py +++ b/source/isaaclab_physx/test/app/test_kit_launcher.py @@ -7,6 +7,7 @@ import argparse import logging +import signal import sys from types import SimpleNamespace @@ -852,13 +853,25 @@ def test_is_available_reflects_simulation_app_presence(monkeypatch: pytest.Monke def test_close_pumps_the_app_before_closing(): - """Callbacks queued by the closing simulation run before Kit shuts down.""" + """Drain callbacks and close Kit despite repeated Ctrl+C during teardown.""" calls = [] + previous_handler = signal.getsignal(signal.SIGINT) + + def update(): + signal.raise_signal(signal.SIGINT) + calls.append("update") + + def close(exit_code): + signal.raise_signal(signal.SIGINT) + calls.append(exit_code) + launcher = KitLauncher.__new__(KitLauncher) - launcher._app = SimpleNamespace( - update=lambda: calls.append("update"), close=lambda exit_code: calls.append(exit_code) - ) + launcher._app = SimpleNamespace(update=update, close=close) - launcher.close(exit_code=3) + try: + launcher.close(exit_code=3) + except KeyboardInterrupt: + pytest.fail("Ctrl+C interrupted Kit teardown") assert calls == ["update", 3] + assert signal.getsignal(signal.SIGINT) is previous_handler diff --git a/source/isaaclab_rl/changelog.d/mtrepte-fix-shutdown-hang-multi-visualizer.rst b/source/isaaclab_rl/changelog.d/mtrepte-fix-shutdown-hang-multi-visualizer.rst new file mode 100644 index 000000000000..972fdf8a0cca --- /dev/null +++ b/source/isaaclab_rl/changelog.d/mtrepte-fix-shutdown-hang-multi-visualizer.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed train/play entrypoints leaving environments open after setup failures or interrupts. + Cleanup closed the final environment wrapper and ignored further Ctrl+C presses during teardown. + Random/zero agents used the same cleanup and propagated interrupts instead of reporting success. diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rl_games.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rl_games.py index dfb3048c3fa3..595aa97fd70a 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rl_games.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rl_games.py @@ -30,6 +30,7 @@ add_common_play_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, enable_cameras_for_video, normalize_task_name, @@ -106,7 +107,7 @@ def run(argv: list[str]) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="rl_games", action="play") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: apply_env_overrides(args_cli, env_cfg) params = agent_cfg["params"] args_cli.seed = resolve_seed(args_cli.seed) @@ -130,6 +131,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) + cleanup.callback(lambda: close_env(env)) screen.stage("Loading policy") env = RlGamesVecEnvWrapper.from_agent_cfg(env, agent_cfg) @@ -166,4 +168,3 @@ def step() -> None: screen.close() run_playback(step, dt=env.unwrapped.step_dt, args_cli=args_cli, env_cfg=env_cfg) - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rsl_rl.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rsl_rl.py index d2359a5085e5..eec37f7cbd30 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rsl_rl.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_rsl_rl.py @@ -33,6 +33,7 @@ add_common_play_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, enable_cameras_for_video, normalize_task_name, @@ -107,7 +108,7 @@ def run(argv: list[str]) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="rsl_rl", action="play") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_version) apply_env_overrides(args_cli, env_cfg) @@ -130,6 +131,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) + cleanup.callback(lambda: close_env(env)) screen.stage("Loading policy") env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) @@ -155,4 +157,3 @@ def step() -> None: screen.close() run_playback(step, dt=env.unwrapped.step_dt, args_cli=args_cli, env_cfg=env_cfg) - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_sb3.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_sb3.py index e3751330195c..92dee9ca014f 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_sb3.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_sb3.py @@ -28,6 +28,7 @@ add_common_play_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, enable_cameras_for_video, normalize_task_name, @@ -99,7 +100,7 @@ def run(argv: list[str]) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="sb3", action="play") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: apply_env_overrides(args_cli, env_cfg) args_cli.seed = resolve_seed(args_cli.seed) if args_cli.seed is not None: @@ -122,6 +123,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) + cleanup.callback(lambda: close_env(env)) agent_cfg = process_sb3_cfg(agent_cfg, env.unwrapped.num_envs) screen.stage("Loading policy") @@ -156,4 +158,3 @@ def step() -> None: screen.close() run_playback(step, dt=env.unwrapped.step_dt, args_cli=args_cli, env_cfg=env_cfg) - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_skrl.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_skrl.py index 4d546e411712..cb6fbdc10fde 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_skrl.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_skrl.py @@ -36,6 +36,7 @@ add_common_play_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, enable_cameras_for_video, normalize_task_name, @@ -118,7 +119,7 @@ def _run(args_cli: argparse.Namespace) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="skrl", action="play") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: runner_cls = import_skrl_runner(args_cli.ml_framework) apply_env_overrides(args_cli, env_cfg) args_cli.seed = resolve_seed(args_cli.seed) @@ -148,6 +149,7 @@ def _run(args_cli: argparse.Namespace) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg) and algorithm == "ppo", ) + cleanup.callback(lambda: close_env(env)) dt = env.unwrapped.step_dt screen.stage("Loading policy") @@ -178,4 +180,3 @@ def step() -> None: screen.close() run_playback(step, dt=dt, args_cli=args_cli, env_cfg=env_cfg) - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_torchrl.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_torchrl.py index 4157ce965a55..2937eeceb393 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_torchrl.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/play_torchrl.py @@ -26,6 +26,7 @@ add_common_play_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, enable_cameras_for_video, normalize_task_name, @@ -90,7 +91,7 @@ def run(argv: list[str]) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="torchrl", action="play") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: # torchrl is an optional extra; importing it after the task config is resolved lets preset errors # surface even when it is not installed from torchrl.envs import ExplorationType, set_exploration_type @@ -118,24 +119,22 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) - try: - env = IsaacLabTorchRLWrapper(env, clip_actions=agent_cfg.clip_actions) - - screen.stage("Loading policy") - print(f"[INFO] Loading model checkpoint from: {checkpoint_path}") - actor = make_actor(env, agent_cfg).to(env.device).eval() - actor.load_state_dict(torch.load(checkpoint_path, map_location=env.device, weights_only=True)) - if args_cli.deterministic: - configure_seed(env_cfg.seed, torch_deterministic=True) - - tensordict = env.reset() - - def step() -> None: - nonlocal tensordict - with set_exploration_type(ExplorationType.DETERMINISTIC): - _, tensordict = env.step_and_maybe_reset(actor(tensordict)) - - screen.close() - run_playback(step, dt=env.unwrapped.step_dt, args_cli=args_cli, env_cfg=env_cfg) - finally: - env.close() + cleanup.callback(lambda: close_env(env)) + env = IsaacLabTorchRLWrapper(env, clip_actions=agent_cfg.clip_actions) + + screen.stage("Loading policy") + print(f"[INFO] Loading model checkpoint from: {checkpoint_path}") + actor = make_actor(env, agent_cfg).to(env.device).eval() + actor.load_state_dict(torch.load(checkpoint_path, map_location=env.device, weights_only=True)) + if args_cli.deterministic: + configure_seed(env_cfg.seed, torch_deterministic=True) + + tensordict = env.reset() + + def step() -> None: + nonlocal tensordict + with set_exploration_type(ExplorationType.DETERMINISTIC): + _, tensordict = env.step_and_maybe_reset(actor(tensordict)) + + screen.close() + run_playback(step, dt=env.unwrapped.step_dt, args_cli=args_cli, env_cfg=env_cfg) diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rl_games.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rl_games.py index fd40d0535a7d..c85bf7579c40 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rl_games.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rl_games.py @@ -33,6 +33,7 @@ add_common_train_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, dump_train_configs, enable_cameras_for_video, @@ -107,7 +108,7 @@ def run(argv: list[str]) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="rl_games", action="train") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: apply_env_overrides(args_cli, env_cfg) validate_distributed_device(args_cli) @@ -157,6 +158,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) + cleanup.callback(lambda: close_env(env)) env = wrap_sensor_capture(env, log_dir, args_cli) screen.stage("Preparing agent") @@ -207,4 +209,3 @@ def run(argv: list[str]) -> None: with contextlib.suppress(KeyboardInterrupt): runner.run(run_args) print(f"Training time: {round(time.time() - start_time, 2)} seconds") - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rsl_rl.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rsl_rl.py index 87b3b9d9fa48..462e31593856 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rsl_rl.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_rsl_rl.py @@ -35,6 +35,7 @@ add_common_train_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, dump_train_configs, enable_cameras_for_video, @@ -120,7 +121,7 @@ def _run(args_cli: argparse.Namespace) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="rsl_rl", action="train") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_version) apply_env_overrides(args_cli, env_cfg) @@ -152,6 +153,7 @@ def _run(args_cli: argparse.Namespace) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) + cleanup.callback(lambda: close_env(env)) env = wrap_sensor_capture(env, log_dir, args_cli) screen.stage("Preparing agent") @@ -183,4 +185,3 @@ def _run(args_cli: argparse.Namespace) -> None: init_at_random_ep_len=agent_cfg.init_at_random_ep_len, ) print(f"Training time: {round(time.time() - start_time, 2)} seconds") - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_sb3.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_sb3.py index c45f7e453df4..a94e37487fec 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_sb3.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_sb3.py @@ -35,6 +35,7 @@ add_common_train_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, dump_train_configs, enable_cameras_for_video, @@ -109,7 +110,7 @@ def run(argv: list[str]) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="sb3", action="train") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: apply_env_overrides(args_cli, env_cfg) args_cli.seed = resolve_seed(args_cli.seed) if args_cli.seed is not None: @@ -146,6 +147,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) + cleanup.callback(lambda: close_env(env)) env = wrap_sensor_capture(env, log_dir, args_cli) screen.stage("Preparing agent") @@ -191,4 +193,3 @@ def run(argv: list[str]) -> None: print("Saving normalization") env.save(os.path.join(log_dir, "model_vecnormalize.pkl")) print(f"Training time: {round(time.time() - start_time, 2)} seconds") - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_skrl.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_skrl.py index 6519548d3ab9..c13524232d7c 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_skrl.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_skrl.py @@ -35,6 +35,7 @@ add_common_train_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, dump_train_configs, enable_cameras_for_video, @@ -100,7 +101,7 @@ def _run(args_cli: argparse.Namespace) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="skrl", action="train") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: runner_cls = import_skrl_runner(args_cli.ml_framework) apply_env_overrides(args_cli, env_cfg) validate_distributed_device(args_cli) @@ -156,6 +157,7 @@ def _run(args_cli: argparse.Namespace) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg) and algorithm == "ppo", ) + cleanup.callback(lambda: close_env(env)) env = wrap_sensor_capture(env, log_dir, args_cli) screen.stage("Preparing agent") @@ -182,4 +184,3 @@ def _run(args_cli: argparse.Namespace) -> None: os.makedirs(os.path.join(log_dir, "checkpoints"), exist_ok=True) runner.agent.write_checkpoint(timestep=total_timesteps, timesteps=total_timesteps) print(f"[INFO] Saved final agent checkpoint to: {log_dir}/checkpoints") - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_torchrl.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_torchrl.py index 16b9052ee506..df71e2f5188d 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_torchrl.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/backends/train_torchrl.py @@ -24,6 +24,7 @@ add_common_train_args, apply_env_overrides, apply_video_recording, + close_env, create_isaaclab_env, dump_train_configs, enable_cameras_for_video, @@ -66,7 +67,7 @@ def run(argv: list[str]) -> None: pre_launch_video_config(env_cfg, args_cli) show_run_summary(screen, args_cli, env_cfg, library="torchrl", action="train") screen.stage("Launching simulation") - with launch_simulation(env_cfg, args_cli): + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: # torchrl is an optional extra; importing it after the task config is resolved lets preset errors # surface even when it is not installed from ...torchrl import IsaacLabTorchRLWrapper, train_ppo @@ -102,6 +103,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) + cleanup.callback(lambda: close_env(env)) env = wrap_sensor_capture(env, log_dir, args_cli) env = IsaacLabTorchRLWrapper(env, clip_actions=agent_cfg.clip_actions) if args_cli.deterministic: @@ -115,4 +117,3 @@ def run(argv: list[str]) -> None: print("[TorchRL] Training interrupted.") finally: print(f"Training time: {round(time.time() - start_time, 2)} seconds") - env.close() diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py index 8675c422e198..fa198fe59fe9 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/common.py @@ -14,6 +14,7 @@ import os import random import re +import signal import sys import time from collections.abc import Callable, Iterator @@ -556,6 +557,19 @@ def create_isaaclab_env( return env +def close_env(env: gym.Env) -> None: + """Close the outermost environment wrapper without interrupting its teardown. + + Args: + env: Environment to close on the entrypoint's main thread. + """ + previous_handler = signal.signal(signal.SIGINT, signal.SIG_IGN) + try: + env.close() + finally: + signal.signal(signal.SIGINT, previous_handler) + + """ Checkpoints. """ diff --git a/source/isaaclab_rl/isaaclab_rl/entrypoints/simple_agents.py b/source/isaaclab_rl/isaaclab_rl/entrypoints/simple_agents.py index a18e3ccb4d04..8134b83d117f 100644 --- a/source/isaaclab_rl/isaaclab_rl/entrypoints/simple_agents.py +++ b/source/isaaclab_rl/isaaclab_rl/entrypoints/simple_agents.py @@ -33,6 +33,7 @@ add_video_args, apply_env_overrides, apply_video_recording, + close_env, enable_cameras_for_video, normalize_task_name, video_playback_steps, @@ -82,32 +83,30 @@ def run(argv: list[str] | None = None, *, policy: PolicyName) -> None: except (TypeError, ValueError) as exc: raise SystemExit(f"Invalid environment configuration: {exc}") from None - try: - with launch_simulation(env_cfg, args_cli): - with contextlib.closing(gym.make(args_cli.task, cfg=env_cfg)) as env: - print(f"[INFO]: Gym observation space: {env.observation_space}") - print(f"[INFO]: Gym action space: {env.action_space}") - env.reset() - if policy == "zero": - action_policy = create_zero_action_policy(env) - else: - action_policy = create_random_action_policy(env) - print(f"[INFO] {policy.capitalize()} agent is running, press Ctrl+C to exit...") - - budgets = [n for n in (args_cli.max_steps, video_playback_steps(args_cli, env_cfg)) if n is not None] - max_steps = min(budgets, default=None) - - # keep running while any visualizer is open and the step budget is not exhausted - sim = env.unwrapped.sim - step = 0 - while sim.is_running(): - if max_steps is not None and step >= max_steps: - break - step += 1 - with torch.inference_mode(): - env.step(action_policy()) - except KeyboardInterrupt: - print(f"\n[INFO] {policy.capitalize()} agent stopped.") + with launch_simulation(env_cfg, args_cli), contextlib.ExitStack() as cleanup: + env = gym.make(args_cli.task, cfg=env_cfg) + cleanup.callback(lambda: close_env(env)) + print(f"[INFO]: Gym observation space: {env.observation_space}") + print(f"[INFO]: Gym action space: {env.action_space}") + env.reset() + if policy == "zero": + action_policy = create_zero_action_policy(env) + else: + action_policy = create_random_action_policy(env) + print(f"[INFO] {policy.capitalize()} agent is running, press Ctrl+C to exit...") + + budgets = [n for n in (args_cli.max_steps, video_playback_steps(args_cli, env_cfg)) if n is not None] + max_steps = min(budgets, default=None) + + # keep running while any visualizer is open and the step budget is not exhausted + sim = env.unwrapped.sim + step = 0 + while sim.is_running(): + if max_steps is not None and step >= max_steps: + break + step += 1 + with torch.inference_mode(): + env.step(action_policy()) def create_zero_action_policy(env: gym.Env) -> Callable[[], Any]: diff --git a/source/isaaclab_rl/test/test_entrypoints.py b/source/isaaclab_rl/test/test_entrypoints.py index 77857d22a98b..24bc151a8c17 100644 --- a/source/isaaclab_rl/test/test_entrypoints.py +++ b/source/isaaclab_rl/test/test_entrypoints.py @@ -9,6 +9,7 @@ import contextlib import importlib +import signal import subprocess import sys import types @@ -307,10 +308,17 @@ def validate(self) -> None: simple_agents.run([], policy="zero") -def test_random_agent_closes_environment_after_keyboard_interrupt( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - """Ctrl+C must stop a checkpoint-free agent cleanly and close its environment.""" +@pytest.mark.parametrize("interrupted_operation", ["reset", "step"]) +def test_random_agent_closes_environment_after_keyboard_interrupt(monkeypatch, interrupted_operation) -> None: + """An interrupt during setup or playback closes the env despite further Ctrl+C presses.""" + closed = [] + previous_handler = signal.getsignal(signal.SIGINT) + + def close(): + signal.raise_signal(signal.SIGINT) + signal.raise_signal(signal.SIGINT) + closed.append(True) + cfg = SimpleNamespace( scene=SimpleNamespace(num_envs=1), sim=SimpleNamespace(device="cpu", use_fabric=True), @@ -323,10 +331,11 @@ def test_random_agent_closes_environment_after_keyboard_interrupt( sim=SimpleNamespace(is_running=lambda: True), device="cpu", ), - reset=lambda: None, - step=mock.Mock(side_effect=KeyboardInterrupt), - close=mock.Mock(), + reset=mock.Mock(), + step=mock.Mock(), + close=close, ) + getattr(env, interrupted_operation).side_effect = KeyboardInterrupt args = SimpleNamespace(max_steps=None, task="Example", device=None) monkeypatch.setattr(simple_agents, "_parse_args", lambda argv, policy: args) monkeypatch.setattr(simple_agents, "resolve_task_config", lambda task, agent: (cfg, None)) @@ -334,10 +343,11 @@ def test_random_agent_closes_environment_after_keyboard_interrupt( monkeypatch.setattr(simple_agents.gym, "make", lambda task, cfg: env) monkeypatch.setattr(simple_agents, "create_random_action_policy", lambda environment: lambda: None) - simple_agents.run([], policy="random") + with pytest.raises(KeyboardInterrupt): + simple_agents.run([], policy="random") - env.close.assert_called_once_with() - assert "Random agent stopped." in capsys.readouterr().out + assert closed == [True] + assert signal.getsignal(signal.SIGINT) is previous_handler @pytest.mark.parametrize( From 99f2418c381d6b270360c015846736bc382e9544 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Mon, 28 Sep 2026 20:46:27 -0700 Subject: [PATCH 07/10] [Docs] Fix broken links after the IsaacCapture rename (#8124) ## Description Fix the eight broken Isaac Teleop link occurrences behind the repeated **Documentation Links** failures on develop and PRs, including [this develop run](https://github.com/isaac-sim/IsaacLab/actions/runs/36514604515). The upstream repository moved to [NVIDIA/IsaacCapture](https://github.com/NVIDIA/IsaacCapture). GitHub repository URLs redirect, but the old `nvidia.github.io/IsaacTeleop` documentation URLs return 404. Update them to the corresponding `IsaacCapture` pages, preserving the documented client versions. Only eight URL replacements and a docs-only changelog marker. No checker exclusions, relaxed status codes, retry changes, or new tests. ## Validation - Reproduced HTTP 404 for all seven distinct old URLs (eight occurrences). - All seven replacements returned HTTP 200 without redirects; verified the MANUS wrist-positioning anchor separately. - No old `nvidia.github.io/IsaacTeleop` links remain in the checked Markdown/reStructuredText sources. - `uv run isaaclab -f` passed. Documentation builds and the full link sweep are left to CI. ## Type of change - Documentation update ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] Read the contribution guidelines. - [x] Ran formatting and changelog checks. - [x] Verified the changed links directly; the existing CI link checker covers the regression. - [x] Added a `.skip` fragment for the package README change. --- docs/source/features/isaac_teleop.rst | 6 +++--- docs/source/how-to/cloudxr_teleoperation.rst | 8 ++++---- .../changelog.d/fix-documentation-links.skip | 1 + source/isaaclab_teleop/docs/README.md | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 source/isaaclab_teleop/changelog.d/fix-documentation-links.skip diff --git a/docs/source/features/isaac_teleop.rst b/docs/source/features/isaac_teleop.rst index 7744ef1d5b5f..3638e434df15 100644 --- a/docs/source/features/isaac_teleop.rst +++ b/docs/source/features/isaac_teleop.rst @@ -41,7 +41,7 @@ input modes, which determine which retargeters and control schemes are available * - Meta Quest 3 - Motion controllers (triggers, thumbsticks, squeeze), hand tracking - CloudXR.js WebXR client (browser) - - `CloudXR client `__; see :ref:`connection guide ` + - `CloudXR client `__; see :ref:`connection guide ` * - Pico 4 Ultra - Motion controllers, hand tracking - CloudXR.js WebXR client (browser) @@ -2108,5 +2108,5 @@ See the :ref:`isaaclab_teleop-api` for full class and function documentation: References .. _`Isaac XR Teleop Sample Client`: https://github.com/isaac-sim/isaac-xr-teleop-sample-client-apple .. _`SO-101 plugin README`: https://github.com/NVIDIA/IsaacTeleop/tree/main/src/plugins/so101_leader -.. _`Data Collection in Sim`: https://nvidia.github.io/IsaacTeleop/main/getting_started/lerobot/data_collection_sim.html -.. _`Build from Source`: https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html +.. _`Data Collection in Sim`: https://nvidia.github.io/IsaacCapture/main/getting_started/lerobot/data_collection_sim.html +.. _`Build from Source`: https://nvidia.github.io/IsaacCapture/main/getting_started/build_from_source/index.html diff --git a/docs/source/how-to/cloudxr_teleoperation.rst b/docs/source/how-to/cloudxr_teleoperation.rst index 6262f4a0ef2a..169c360ea5ac 100644 --- a/docs/source/how-to/cloudxr_teleoperation.rst +++ b/docs/source/how-to/cloudxr_teleoperation.rst @@ -13,7 +13,7 @@ pipeline, and bundled `NVIDIA CloudXR`_ streaming for immersive XR-based teleope This guide walks you through setting up CloudXR, connecting an XR device, and running your first teleoperation session. For additional details see the `Isaac Teleop Quick Start -`_. +`_. .. tip:: @@ -400,7 +400,7 @@ choose the tab that matches your hardware. start automatically. #. Open the browser on your headset and navigate to the hosted CloudXR.js client: - ``_. + ``_. .. note:: @@ -992,5 +992,5 @@ For a fully headless experience, replace ``--visualizer kit`` with ``--visualize .. _`Isaac XR Teleop Sample Client`: https://github.com/isaac-sim/isaac-xr-teleop-sample-client-apple .. _`CloudXR Network Setup`: https://docs.nvidia.com/cloudxr-sdk/latest/requirement/network_setup.html .. _`CloudXR.js`: https://docs.nvidia.com/cloudxr-sdk/latest/usr_guide/cloudxr_js/index.html -.. _`Manus plugin documentation`: https://nvidia.github.io/IsaacTeleop/main/device/manus.html -.. _`Wrist Positioning -- Controllers vs Optical Hand Tracking`: https://nvidia.github.io/IsaacTeleop/main/device/manus.html#wrist-positioning-controllers-vs-optical-hand-tracking +.. _`Manus plugin documentation`: https://nvidia.github.io/IsaacCapture/main/device/manus.html +.. _`Wrist Positioning -- Controllers vs Optical Hand Tracking`: https://nvidia.github.io/IsaacCapture/main/device/manus.html#wrist-positioning-controllers-vs-optical-hand-tracking diff --git a/source/isaaclab_teleop/changelog.d/fix-documentation-links.skip b/source/isaaclab_teleop/changelog.d/fix-documentation-links.skip new file mode 100644 index 000000000000..549eb2943aa3 --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/fix-documentation-links.skip @@ -0,0 +1 @@ +Updated broken documentation links after the upstream IsaacCapture repository rename. diff --git a/source/isaaclab_teleop/docs/README.md b/source/isaaclab_teleop/docs/README.md index e793fed4ffb3..17ec1ad9101a 100644 --- a/source/isaaclab_teleop/docs/README.md +++ b/source/isaaclab_teleop/docs/README.md @@ -172,7 +172,7 @@ source ~/.cloudxr/run/cloudxr.env uv run python scripts/tools/record_demos.py --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs --num_demos 5 --dataset_file ./datasets/dataset.hdf5 --xr --visualizer kit ``` -In the Isaac Sim UI, set the AR panel to **System OpenXR Runtime** and click **Start XR**. For the full flow and options, see the [CloudXR teleoperation how-to](https://isaac-sim.github.io/IsaacLab/main/source/how-to/cloudxr_teleoperation.html) and [Isaac Teleop Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html). +In the Isaac Sim UI, set the AR panel to **System OpenXR Runtime** and click **Start XR**. For the full flow and options, see the [CloudXR teleoperation how-to](https://isaac-sim.github.io/IsaacLab/main/source/how-to/cloudxr_teleoperation.html) and [Isaac Teleop Quick Start](https://nvidia.github.io/IsaacCapture/main/getting_started/quick_start.html). For a fully headless experience, replace `--visualizer kit` with `--visualizer none` or `--viz none` when running Docker, and the XR teleop session will run automatically. From 4d2c6b16f80e15083f52a8d0f75be2ff2c628e2d Mon Sep 17 00:00:00 2001 From: Rebecca Zhang <168459200+rebeccazhang0707@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:48:48 +0800 Subject: [PATCH 08/10] Add internal ADMM collision capacity controls (#7912) # Description Expose `CouplerAdmmCfg.contact_max_triangle_pairs` and `contact_reduction_hashtable_size_factor` to size ADMM's internal contact buffers independently of the outer Newton collision pipeline. Both default to `None`, preserving Newton's defaults. Pass explicit values through Newton's public `SolverCoupledADMM.Config`. Older Newton builds reject unsupported overrides with a concise error. Triangle-pair capacities must be below `2**20` with `latest` or `sticky` matching; larger capacities remain supported with matching disabled. ## Upstream API [Newton #4309](https://github.com/newton-physics/newton/pull/4309) is merged, and the private pipeline adapter has been removed. Newton source `3d5de3f` supports both fields; published `1.6.1rc1` predates the merge and exercises the unsupported-override path. Global dependency declarations remain unchanged. ## Validation - Focused coupler tests: **77 passed** with Newton source `3d5de3f`, and **77 passed** with `1.6.1rc1` (73 unit tests and 4 runtime tests, run per file). - Covered capacity allocation, mesh contacts, matching, reset, stepping, and unsupported overrides; boundary coverage includes `2**20 - 1`, `2**20`, and larger capacities with matching disabled. - `uv run isaaclab -f` and changelog validation passed. - Full documentation build passed without warnings (`uv run --frozen --isolated --extra test --extra dev -- make -C docs current-docs`). ## Type of change - New feature (non-breaking change which adds functionality) - Documentation update ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the contribution guidelines. - [x] I have run the pre-commit checks with `uv run isaaclab -f`. - [x] I have made corresponding changes to the documentation. - [ ] My changes generate no new warnings (the focused tests report existing upstream deprecation warnings). - [x] I have added tests that prove my fix is effective or that my feature works. - [x] I have added a changelog fragment for every touched package. - [x] My name already exists in `CONTRIBUTORS.md`. --------- Co-authored-by: Kelly Guo --- docs/source/concepts/coupled_solvers.rst | 24 ++++++ .../admm-contact-capacity.minor.rst | 8 ++ .../isaaclab_contrib/coupling/coupler.py | 19 +++++ .../isaaclab_contrib/coupling/coupler_cfg.py | 14 ++++ .../test/coupling/test_coupler.py | 73 +++++++++++++++++++ .../test/coupling/test_coupler_runtime.py | 53 ++++++++++++-- 6 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 source/isaaclab_contrib/changelog.d/admm-contact-capacity.minor.rst diff --git a/docs/source/concepts/coupled_solvers.rst b/docs/source/concepts/coupled_solvers.rst index 49047195a501..f16fcddd3710 100644 --- a/docs/source/concepts/coupled_solvers.rst +++ b/docs/source/concepts/coupled_solvers.rst @@ -200,6 +200,30 @@ runtime and cannot repair an unstable entry. The generated default; Newton's concept page explains the underlying algorithms. +ADMM Contact Capacity +^^^^^^^^^^^^^^^^^^^^^ + +ADMM's internal contact buffers are independent of ``NewtonCfg.collision_cfg``. +Increase ``CouplerAdmmCfg.contact_max_triangle_pairs`` for triangle-pair overflows, +or ``contact_reduction_hashtable_size_factor`` for contact-reduction hash table +warnings. Both default to ``None`` (Newton's defaults); explicit values require +support in Newton's ``SolverCoupledADMM.Config``. Capacities cover all environments +in one process, independently on each rank in multi-GPU jobs. + +With ``rigid_contact_matching="latest"`` or ``"sticky"``, triangle-pair capacity +must be less than ``2**20``; larger values require ``"disabled"``. The hash table +can grow independently while retaining matching: + +.. code-block:: python + + coupling_cfg = CouplerAdmmCfg( + entries=entries, + rigid_contact_matching="latest", + contact_max_triangle_pairs=1_000_000, + contact_reduction_hashtable_size_factor=2.0, + ) + + Start from a maintained task ---------------------------- diff --git a/source/isaaclab_contrib/changelog.d/admm-contact-capacity.minor.rst b/source/isaaclab_contrib/changelog.d/admm-contact-capacity.minor.rst new file mode 100644 index 000000000000..87e5da866229 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/admm-contact-capacity.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added ``CouplerAdmmCfg.contact_max_triangle_pairs`` and + ``contact_reduction_hashtable_size_factor`` to configure the internal ADMM collision + buffers independently of the outer Newton collision pipeline, preserving Newton's + defaults. Validation rejected triangle-pair capacities at or above ``2**20`` with + ``"latest"`` or ``"sticky"`` matching and explicit overrides unsupported by Newton. diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py index 03aa37bcc60b..c69edd34c28f 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py @@ -7,6 +7,7 @@ from __future__ import annotations +import math from dataclasses import dataclass from functools import partial @@ -128,6 +129,21 @@ def _validate_config(cls, solver_cfg: CouplerCfg) -> None: if not solver_cfg.entries: raise ValueError("CouplerCfg.entries must contain at least one solver entry.") + if isinstance(solver_cfg, CouplerAdmmCfg): + capacity = solver_cfg.contact_max_triangle_pairs + if capacity is not None and (type(capacity) is not int or capacity <= 0): + raise ValueError("CouplerAdmmCfg.contact_max_triangle_pairs must be a positive integer or None.") + if capacity is not None and capacity >= 2**20 and solver_cfg.rigid_contact_matching in ("latest", "sticky"): + raise ValueError( + "CouplerAdmmCfg.contact_max_triangle_pairs must be less than 2**20 when " + "rigid_contact_matching is 'latest' or 'sticky'." + ) + factor = solver_cfg.contact_reduction_hashtable_size_factor + if factor is not None and (not math.isfinite(factor) or factor <= 0.0): + raise ValueError( + "CouplerAdmmCfg.contact_reduction_hashtable_size_factor must be finite and positive or None." + ) + if any(not isinstance(entry.name, str) or not entry.name for entry in solver_cfg.entries): raise ValueError("CouplerCfg entry names must be non-empty strings.") @@ -402,6 +418,9 @@ def _build_admm_coupled_solver( solver_cfg: CouplerAdmmCfg, ) -> SolverCoupledADMM: values = cls._filter_solver_kwargs(SolverCoupledADMM.Config, solver_cfg) + for name in ("contact_max_triangle_pairs", "contact_reduction_hashtable_size_factor"): + if getattr(solver_cfg, name) is not None and name not in values: + raise RuntimeError(f"The installed Newton version does not support {name}.") if solver_cfg.contact_pairs is None: values["contact_pairs"] = SolverCoupledADMM.auto_detect_contact_pairs(entries) else: diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py index d5fe2e53dff8..a739b8ac22ba 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py @@ -161,6 +161,20 @@ class CouplerProxyCfg(CouplerCfg): class CouplerAdmmCfg(CouplerCfg): """Configuration for Newton's linearized ADMM coupling.""" + contact_max_triangle_pairs: int | None = None + """Internal ADMM triangle-pair capacity across all environments in one process. + + ``None`` uses Newton's default. Must be less than ``2**20`` with + :attr:`rigid_contact_matching` set to ``"latest"`` or ``"sticky"``; + larger capacities require ``"disabled"``. + """ + + contact_reduction_hashtable_size_factor: float | None = None + """Contact-reduction hash table size relative to the internal triangle-pair capacity. + + ``None`` uses Newton's default. Increase it for hash table fill or insertion warnings. + """ + contact_pairs: list[tuple[str, str]] | None = None """Symmetric contact interfaces as ``(entry_name, entry_name)`` pairs. diff --git a/source/isaaclab_contrib/test/coupling/test_coupler.py b/source/isaaclab_contrib/test/coupling/test_coupler.py index 589e9bbd806a..bf2e72e6ad3b 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler.py @@ -140,6 +140,41 @@ def test_config_validation_requires_entries(): NewtonCouplerManager._validate_config(CouplerProxyCfg()) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("contact_max_triangle_pairs", 0), + ("contact_max_triangle_pairs", 1.5), + ("contact_reduction_hashtable_size_factor", 0.0), + ("contact_reduction_hashtable_size_factor", float("nan")), + ("contact_reduction_hashtable_size_factor", float("inf")), + ], +) +def test_config_validation_rejects_invalid_admm_contact_capacity(field, value): + cfg = CouplerAdmmCfg(entries=[CouplerEntryCfg(name="entry", solver_cfg=XPBDSolverCfg())]) + setattr(cfg, field, value) + with pytest.raises(ValueError, match=field): + NewtonCouplerManager._validate_config(cfg) + + +@pytest.mark.parametrize("matching", ["latest", "sticky", "disabled"]) +@pytest.mark.parametrize( + ("capacity", "valid_with_matching"), + [(None, True), (2**20 - 1, True), (2**20, False), (2**20 + 1, False)], +) +def test_config_validation_admm_contact_capacity_matching_limit(matching, capacity, valid_with_matching): + cfg = CouplerAdmmCfg( + entries=[CouplerEntryCfg(name="entry", solver_cfg=XPBDSolverCfg())], + contact_max_triangle_pairs=capacity, + rigid_contact_matching=matching, + ) + if valid_with_matching or matching == "disabled": + NewtonCouplerManager._validate_config(cfg) + else: + with pytest.raises(ValueError, match=r"contact_max_triangle_pairs.*2\*\*20.*rigid_contact_matching"): + NewtonCouplerManager._validate_config(cfg) + + def test_config_validation_requires_nonempty_entry_names(): cfg = CouplerAdmmCfg(entries=[CouplerEntryCfg(name="", solver_cfg=XPBDSolverCfg())]) with pytest.raises(ValueError, match="non-empty strings"): @@ -861,6 +896,44 @@ def test_admm_build_forwards_multiple_pairs_matching_and_proximal_options(monkey assert solver.coupling.contact_matching_force_scale == pytest.approx(0.7) +def test_admm_build_forwards_native_contact_capacity(monkeypatch): + @dataclass(frozen=True) + class NativeCapacityConfig(_RecordingAdmm.Config): + contact_max_triangle_pairs: int | None = None + contact_reduction_hashtable_size_factor: float | None = None + + monkeypatch.setattr(_RecordingAdmm, "Config", NativeCapacityConfig) + monkeypatch.setattr(coupler, "SolverCoupledADMM", _RecordingAdmm) + cfg = CouplerAdmmCfg( + contact_pairs=[], + contact_max_triangle_pairs=8192, + contact_reduction_hashtable_size_factor=2.0, + ) + + solver = NewtonCouplerManager._build_admm_coupled_solver(_FakeModel(), [], cfg) + + assert solver.coupling.contact_max_triangle_pairs == 8192 + assert solver.coupling.contact_reduction_hashtable_size_factor == 2.0 + + +@pytest.mark.parametrize( + ("name", "value"), + [("contact_max_triangle_pairs", 8192), ("contact_reduction_hashtable_size_factor", 2.0)], +) +def test_admm_build_rejects_contact_capacity_without_native_support(monkeypatch, name, value): + @dataclass(frozen=True) + class LegacyConfig: + contact_pairs: tuple = () + + monkeypatch.setattr(_RecordingAdmm, "Config", LegacyConfig) + monkeypatch.setattr(coupler, "SolverCoupledADMM", _RecordingAdmm) + cfg = CouplerAdmmCfg(contact_pairs=[]) + setattr(cfg, name, value) + + with pytest.raises(RuntimeError, match=rf"Newton.*does not support {name}"): + NewtonCouplerManager._build_admm_coupled_solver(_FakeModel(), [], cfg) + + def test_admm_build_auto_detects_symmetric_contact_pairs_by_default(monkeypatch): model = _FakeModel() resolved_entries = [ diff --git a/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py b/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py index a642ec0f13f1..5cd3b037fca8 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py @@ -15,7 +15,7 @@ import pytest import warp as wp from isaaclab_newton.physics import NewtonManager, XPBDSolverCfg -from newton import CollisionPipeline, Model, ModelBuilder +from newton import CollisionPipeline, Mesh, Model, ModelBuilder from newton.solvers import SolverXPBD from newton.solvers.experimental.coupled import SolverCoupledADMM, SolverCoupledProxy @@ -48,9 +48,14 @@ def isolated_newton_manager(monkeypatch: pytest.MonkeyPatch): yield -def _build_overlapping_body_model() -> Model: - """Build two labeled free bodies with one rigid contact on the CPU.""" - builder = ModelBuilder(gravity=-9.81) +def _build_overlapping_body_model(*, mesh_contact: bool = False) -> Model: + """Build two labeled free bodies with one rigid contact on the CPU. + + Args: + mesh_contact: Replace the source sphere with a triangle mesh to exercise + triangle-pair allocation and contact reduction in the capacity test. + """ + builder = ModelBuilder(gravity=(0.0, 0.0, -9.81)) for x, label in ((-0.09, "/World/Source/body"), (0.09, "/World/Destination/body")): body = builder.add_body( xform=wp.transform(wp.vec3(x, 0.0, 1.0), wp.quat_identity()), @@ -58,7 +63,15 @@ def _build_overlapping_body_model() -> Model: inertia=wp.mat33(np.eye(3, dtype=np.float32)), label=label, ) - builder.add_shape_sphere(body=body, radius=0.1, label=f"{label}/shape") + if mesh_contact and x < 0.0: + mesh = Mesh( + vertices=np.array([[0.1, -0.1, -0.1], [0.1, 0.1, -0.1], [0.1, 0.0, 0.1]], dtype=np.float32), + indices=np.array([0, 1, 2], dtype=np.int32), + compute_inertia=False, + ) + builder.add_shape_mesh(body=body, mesh=mesh, label=f"{label}/shape") + else: + builder.add_shape_sphere(body=body, radius=0.1, label=f"{label}/shape") builder.color() return builder.finalize(device="cpu") @@ -110,6 +123,7 @@ def test_proxy_destination_can_receive_only_proxy_bodies(isolated_newton_manager [ pytest.param("proxy", SolverCoupledProxy, id="proxy"), pytest.param("admm", SolverCoupledADMM, id="admm"), + pytest.param("admm_capacity", SolverCoupledADMM, id="admm_capacity"), ], ) def test_real_coupler_constructs_resets_and_steps( @@ -117,8 +131,8 @@ def test_real_coupler_constructs_resets_and_steps( expected_solver_type: type, isolated_newton_manager, ): - """Construct, prepare contacts, reset, and step the pinned Newton solver.""" - model = _build_overlapping_body_model() + """Exercise supported coupling configurations or reject unsupported capacity overrides.""" + model = _build_overlapping_body_model(mesh_contact=algorithm == "admm_capacity") entries = _entry_configs() if algorithm == "proxy": solver_cfg = CouplerProxyCfg( @@ -134,12 +148,33 @@ def test_real_coupler_constructs_resets_and_steps( ) else: solver_cfg = CouplerAdmmCfg(entries=entries, iterations=1) + if algorithm == "admm_capacity": + solver_cfg.contact_max_triangle_pairs = 8192 + # The table can exceed the deterministic contact-ID limit independently. + solver_cfg.contact_reduction_hashtable_size_factor = 256.0 + solver_cfg.rigid_contact_matching = "latest" + solver_cfg.contact_matching_pos_threshold = 0.001 + solver_cfg.contact_matching_normal_dot_threshold = 0.9 NewtonManager.backend.model = model + if algorithm == "admm_capacity" and not hasattr(SolverCoupledADMM.Config, "contact_max_triangle_pairs"): + with pytest.raises(RuntimeError, match=r"Newton.*does not support contact_max_triangle_pairs"): + NewtonCouplerManager._build_solver(model, solver_cfg) + return NewtonCouplerManager._build_solver(model, solver_cfg) solver = NewtonManager._solver assert isinstance(solver, expected_solver_type) + if algorithm == "admm_capacity": + internal_pipeline = solver._admm_collision_pipeline + assert internal_pipeline.narrow_phase.max_triangle_pairs == solver_cfg.contact_max_triangle_pairs + reducer = internal_pipeline.narrow_phase.global_contact_reducer + requested_slots = int( + solver_cfg.contact_max_triangle_pairs * solver_cfg.contact_reduction_hashtable_size_factor + ) + assert reducer.hashtable.capacity == 1 << (requested_slots - 1).bit_length() + assert internal_pipeline.contact_matching == solver_cfg.rigid_contact_matching + np.testing.assert_array_equal(internal_pipeline.shape_pairs_filtered.numpy(), [[0, 1]]) assert solver.entry_names() == ("source", "destination") for name in solver.entry_names(): nested_solver = solver.solver(name) @@ -169,6 +204,10 @@ def test_real_coupler_constructs_resets_and_steps( assert np.all(np.isfinite(body_q_after)) assert np.all(np.isfinite(state_1.body_qd.numpy())) assert np.any(body_q_after[:, 2] < body_q_before[:, 2]) + if algorithm == "admm_capacity": + assert int(solver._admm_internal_contacts.rigid_contact_count.numpy()[0]) >= 1 + assert solver._admm_internal_contacts.rigid_contact_match_index is not None + assert int(reducer.ht_insert_failures.numpy()[0]) == 0 solver.reset(state_1) assert solver.entry_output_state_valid() is False From 633249adede7538e19e732d49fd6173d5ac6f4f3 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 28 Sep 2026 23:49:03 -0400 Subject: [PATCH 09/10] Fix canonical render presets and default benchmark mode to None (#8113) ## Description `Isaac-RenderBenchmark-Franka-Cabinet` did not declare an `ovphysx` preset, and its automatic `physx` preset lacked the OvPhysX configuration. Add both so `presets=ovphysx,ovrtx` and `presets=physx,ovrtx` can select physics without Kit. Default an unset `BENCHMARK_MODE` to `None`. Physics and camera rendering still run, while benchmark animation and render/physics scope profiling remain disabled. Explicit `render` and `physics_render` modes remain supported, and scope timings still require their separate profiling flags. Collecting scope timings still requires an explicit mode; an unset mode produces no timing file for the sweep to read. Update regression coverage, documentation, and migration guidance for the changed default. The renderer sweep script and its tests match the PR base; the additional mode parameterization and command-comment change were removed. ## Type of change - Bug fix - Behavior change: the canonical task's default benchmark mode is now `None` ## Validation - All 17 renderer-sweep unit tests passed after restoring the script and tests to the PR base. - All repository formatting and pre-commit checks passed with `uv run isaaclab -f`. - The changed documentation page parsed without warnings. Full documentation validation encountered unrelated OvPhysX API import and duplicate-object warnings. - A full GPU rendering benchmark was not run. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have made corresponding changes to the documentation - [x] I have added tests that prove my fix is effective - [x] I have added a changelog fragment for the changed package - [x] My name already exists in `CONTRIBUTORS.md` - [x] All pre-commit checks passed with `uv run isaaclab -f` --- .../benchmarking/run_benchmarks.rst | 10 +++-- .../fix-canonical-render-ovphysx.rst | 14 +++++++ .../render_benchmark/render_benchmark_env.py | 4 +- .../render_benchmark_env_cfg.py | 30 ++++++++------- .../benchmark/test_render_benchmark_cfg.py | 37 +++++++++++++++++-- 5 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/fix-canonical-render-ovphysx.rst diff --git a/docs/source/developer-tools/benchmarking/run_benchmarks.rst b/docs/source/developer-tools/benchmarking/run_benchmarks.rst index 7dbd87ec9d52..c679cfe1c68c 100644 --- a/docs/source/developer-tools/benchmarking/run_benchmarks.rst +++ b/docs/source/developer-tools/benchmarking/run_benchmarks.rst @@ -82,8 +82,10 @@ Render and physics scope profiling requires a non-``None`` ``benchmark_mode`` in task configuration. If the field is absent or ``None``, both scopes remain disabled even when their profiling flags are set; standard runtime reports are still produced. -For ``Isaac-RenderBenchmark-Franka-Cabinet``, ``BENCHMARK_MODE=render`` (the default) -writes analytic joint poses after physics, while ``BENCHMARK_MODE=physics_render`` +For ``Isaac-RenderBenchmark-Franka-Cabinet``, an unset ``BENCHMARK_MODE`` defaults to +``None``: physics and camera rendering still run, without benchmark animation or +scope profiling. Set ``BENCHMARK_MODE=render`` to write analytic joint poses after +physics, while ``BENCHMARK_MODE=physics_render`` sets actuator targets before physics. Both modes still step physics. Direct posing requires ``scene.lazy_sensor_update=True``. With Isaac RTX, use ``--visualizer none`` in this mode: a Kit visualizer would render before the pose write. @@ -96,7 +98,9 @@ renderers through :func:`~isaaclab.benchmark.stepping.profile_renderers`, timing ``render()`` and excluding scene updates and output readback. Both context managers install wrappers after warmup and restore the original methods when measurement ends, including on failure, so subsequent benchmark runs are unaffected. The render sweep -enables both flags automatically. Ordered ``[scope, elapsed_ms]`` samples are written under +enables both flags but requires an explicit ``BENCHMARK_MODE=render`` or +``BENCHMARK_MODE=physics_render`` to collect timings. Ordered +``[scope, elapsed_ms]`` samples are written under ``timings_ms`` in the local ``/profile_timings.json`` file. The benchmark bundle's ``extra`` dictionary holds scalar ``physics_mean_ms``, ``physics_std_ms``, ``physics_max_ms``, ``physics_calls``, and corresponding ``render_*`` summaries. Disabled scopes contribute no keys. diff --git a/source/isaaclab_tasks/changelog.d/fix-canonical-render-ovphysx.rst b/source/isaaclab_tasks/changelog.d/fix-canonical-render-ovphysx.rst new file mode 100644 index 000000000000..551284550232 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-canonical-render-ovphysx.rst @@ -0,0 +1,14 @@ +Fixed +^^^^^ + +* Added the missing ``ovphysx`` physics preset to ``Isaac-RenderBenchmark-Franka-Cabinet`` + and enabled automatic ``physx`` selection to use OvPhysX when running without Kit. + +Changed +^^^^^^^ + +* **Breaking:** Changed the canonical render task's unset ``BENCHMARK_MODE`` default to + ``None``, disabling benchmark animation and scope profiling. Set ``BENCHMARK_MODE=render`` + to retain direct posing or ``BENCHMARK_MODE=physics_render`` for actuator tracking; + scope timings remained opt-in through their profiling flags. Renderer sweep users must + also set an explicit benchmark mode to collect timings. diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py index 916f8e8c18dc..3c85a2bddacc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py @@ -25,8 +25,8 @@ class RenderBenchmarkEnv(DirectRLEnv): """Environment that animates its articulations so a renderer can be profiled on them. - Every articulation in the configured scene is driven by a sinusoid around its default joint - positions. Actions are ignored and rewards are zero. + An explicit benchmark mode drives every articulation by a sinusoid around its default joint + positions. With ``None``, no benchmark animation is applied. Actions are ignored and rewards are zero. ``"physics_render"`` mode tracks the sinusoid through actuator targets. ``"render"`` mode writes joint poses after physics and requires lazy sensor updates so rendering sees those poses. diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py index fc3cf7920cb5..a09bdf673811 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py @@ -10,6 +10,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg from isaaclab_newton.renderers import NewtonWarpRendererCfg +from isaaclab_ov.physics import OvPhysxCfg from isaaclab_physx.physics import PhysxCfg from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg @@ -39,25 +40,25 @@ """ BENCHMARK_MODES: tuple[BenchmarkMode, ...] = ("render", "physics_render") -"""Every value :attr:`RenderBenchmarkFrankaCabinetEnvCfg.benchmark_mode` accepts.""" +"""Explicit animation modes; ``None`` disables benchmark animation and scope profiling.""" -def _read_benchmark_mode() -> BenchmarkMode: +def _read_benchmark_mode() -> BenchmarkMode | None: """Read the default benchmark mode from ``BENCHMARK_MODE``, rejecting unknown values. Returns: - The configured mode, or ``"render"`` when the variable is unset. + The configured mode, or ``None`` when the variable is unset. Raises: ValueError: If ``BENCHMARK_MODE`` is set to a value outside :data:`BENCHMARK_MODES`. """ - mode = os.getenv("BENCHMARK_MODE", "render") - if mode not in BENCHMARK_MODES: + mode = os.getenv("BENCHMARK_MODE") + if mode is not None and mode not in BENCHMARK_MODES: raise ValueError(f"Unknown BENCHMARK_MODE '{mode}'. Expected one of {list(BENCHMARK_MODES)}.") - return cast(BenchmarkMode, mode) + return cast(BenchmarkMode | None, mode) -BENCHMARK_MODE: BenchmarkMode = _read_benchmark_mode() +BENCHMARK_MODE: BenchmarkMode | None = _read_benchmark_mode() """Default :attr:`RenderBenchmarkFrankaCabinetEnvCfg.benchmark_mode`, read once at import.""" @@ -66,7 +67,8 @@ class RenderBenchmarkPhysicsCfg(PresetCfg): """Physics backend presets. Pick via ``presets=newton_mjwarp`` (default) or ``presets=physx``, which resolves to the - concrete PhysX backend at launch. Use ``presets=isaacsim_physx`` to pin Isaac Sim PhysX. + concrete PhysX backend at launch. Use ``presets=isaacsim_physx`` to pin Isaac Sim PhysX + or ``presets=ovphysx`` to pin OvPhysX. The BVH constructors are read from the environment so ``benchmark_renderer.py`` can sweep them without a separate preset per combination. """ @@ -80,7 +82,8 @@ class RenderBenchmarkPhysicsCfg(PresetCfg): use_cuda_graph=os.getenv("NEWTON_USE_CUDA_GRAPH", "0") == "1", ) isaacsim_physx: PhysxCfg = PhysxCfg() - physx: PhysxAutoCfg = PhysxAutoCfg(isaacsim_physx=isaacsim_physx) + ovphysx: OvPhysxCfg = OvPhysxCfg() + physx: PhysxAutoCfg = PhysxAutoCfg(isaacsim_physx=isaacsim_physx, ovphysx=ovphysx) default = newton_mjwarp @@ -198,8 +201,8 @@ class RenderBenchmarkFrankaCabinetEnvCfg(DirectRLEnvCfg): """Franka Panda and Sektion cabinet, animated for renderer benchmarking. The cabinet contributes four articulated joints (two drawers, two doors) on top of the - Franka's seven, and a sinusoidal animation drives all of them so every rendered frame has - moving articulated geometry rather than a static scene. There is no policy: actions are + Franka's seven. An explicit benchmark mode drives them with a sinusoidal animation so every + rendered frame has moving articulated geometry. There is no policy: actions are ignored, rewards are zero, and the episode only ends on time-out. :attr:`benchmark_mode` selects direct posing or actuator tracking. See :data:`BenchmarkMode`. @@ -225,11 +228,12 @@ class RenderBenchmarkFrankaCabinetEnvCfg(DirectRLEnvCfg): joint_animation_freq_hz: float = 0.35 """Frequency of the joint animation [Hz].""" - benchmark_mode: BenchmarkMode = BENCHMARK_MODE + benchmark_mode: BenchmarkMode | None = BENCHMARK_MODE """Whether animation uses direct joint poses or actuator targets. See :data:`BenchmarkMode`. Defaults to the ``BENCHMARK_MODE`` environment variable, or - ``"render"`` when it is unset. Render mode requires ``scene.lazy_sensor_update=True``. + ``None`` when it is unset. ``None`` leaves physics and camera rendering active without + benchmark animation or scope profiling. Render mode requires ``scene.lazy_sensor_update=True``. With Isaac RTX, use ``--visualizer none`` or a visualizer that does not pump the Kit app loop. """ diff --git a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py index ff5a5d5454ac..29f4b018c052 100644 --- a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py +++ b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py @@ -18,9 +18,12 @@ import torch from isaaclab_newton.physics import NewtonCfg from isaaclab_newton.renderers import NewtonWarpRendererCfg +from isaaclab_ov.physics import OvPhysxCfg +from isaaclab_ov.renderers import OVRTXRendererCfg from isaaclab_physx.physics import PhysxCfg from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg +from isaaclab.app import scan from isaaclab.envs import DirectRLEnv from isaaclab.physics import PhysxAutoCfg from isaaclab.sim.schemas import MassCfg, UsdPhysicsCollisionCfg, UsdPhysicsRigidBodyCfg @@ -28,6 +31,7 @@ import isaaclab_tasks # noqa: F401 from isaaclab_tasks.benchmark.render_benchmark.render_benchmark_env import RenderBenchmarkEnv +from isaaclab_tasks.benchmark.render_benchmark.render_benchmark_env_cfg import _read_benchmark_mode from isaaclab_tasks.utils.hydra import collect_presets, resolve_presets from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry @@ -47,10 +51,23 @@ def test_default_scene_and_articulations(): assert cfg.scene.robot.prim_path == "{ENV_REGEX_NS}/Robot" assert cfg.scene.cabinet.prim_path == "{ENV_REGEX_NS}/Cabinet" assert cfg.joint_animation_amplitude == pytest.approx(0.4) - assert cfg.benchmark_mode == "render" + assert cfg.benchmark_mode is None assert gym.spec(_TASK).disable_env_checker is True +@pytest.mark.parametrize("mode", [None, "render", "physics_render", "invalid"]) +def test_benchmark_mode_from_environment(monkeypatch, mode): + if mode is None: + monkeypatch.delenv("BENCHMARK_MODE", raising=False) + else: + monkeypatch.setenv("BENCHMARK_MODE", mode) + if mode == "invalid": + with pytest.raises(ValueError, match="Unknown BENCHMARK_MODE"): + _read_benchmark_mode() + else: + assert _read_benchmark_mode() == mode + + @pytest.mark.parametrize( ("mode", "lazy_sensor_update", "renderer_type", "pumps_app_update", "error"), [ @@ -91,7 +108,7 @@ def initialize(self, *args, **kwargs): close.assert_not_called() -@pytest.mark.parametrize("mode", ["render", "physics_render"]) +@pytest.mark.parametrize("mode", [None, "render", "physics_render"]) def test_benchmark_mode_orders_joint_updates_and_rendering(mode): """Direct poses follow physics; actuator targets precede it, and both render last.""" events = Mock() @@ -127,12 +144,14 @@ def test_benchmark_mode_orders_joint_updates_and_rendering(mode): ] velocity = articulation.write_joint_velocity_to_sim_index.call_args.kwargs["velocity"] assert torch.equal(velocity, torch.zeros_like(velocity)) - else: + elif mode == "physics_render": assert [entry[0] for entry in events.mock_calls] == [ "articulation.actuators.target_command.set_position_index", "physics", "render", ] + else: + assert [entry[0] for entry in events.mock_calls] == ["physics", "render"] @pytest.mark.parametrize( @@ -141,6 +160,7 @@ def test_benchmark_mode_orders_joint_updates_and_rendering(mode): ((), NewtonCfg), (("physx",), PhysxAutoCfg), (("isaacsim_physx",), PhysxCfg), + (("ovphysx",), OvPhysxCfg), ], ) def test_physics_presets_resolve_to_expected_backend(presets, expected_type): @@ -149,6 +169,17 @@ def test_physics_presets_resolve_to_expected_backend(presets, expected_type): assert isinstance(cfg.sim.physics, expected_type) +@pytest.mark.parametrize("physics_preset", ["ovphysx", "physx"]) +def test_ovphysx_presets_resolve_with_ovrtx(physics_preset): + """Explicit and automatic PhysX presets support rendering without Kit.""" + cfg = resolve_presets(_load_cfg(), selected=(physics_preset, "ovrtx")) + config_scan = scan(cfg) + + assert isinstance(cfg.sim.physics, OvPhysxCfg) + assert isinstance(cfg.scene.tiled_camera.renderer_cfg, OVRTXRendererCfg) + assert config_scan.needs_kit is False + + @pytest.fixture(scope="module") def render_benchmark_presets(): """Collect every preset once, before any preset selection mutates the config in place.""" From eeb6e0458cec52499f2719b325d7fabe584b0f93 Mon Sep 17 00:00:00 2001 From: angehu-nv Date: Mon, 28 Sep 2026 23:53:19 -0400 Subject: [PATCH 10/10] Angehu/perf smoke integration (#7137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description > [!IMPORTANT] > Confirm the pull request base before submitting. Target `develop` for all > contributions. The `release/3.0.0-beta2` branch is a frozen stable landing > snapshot and is not used for ongoing maintenance. Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: angehu-nv Co-authored-by: AntoineRichard Co-authored-by: Kelly Guo Co-authored-by: Mustafa Haiderbhai Co-authored-by: Fatima Anes --- .github/workflows/build.yaml | 272 ++++++++++++++++++++ .github/workflows/tools-tests.yml | 9 + docker/Dockerfile.base | 2 +- tools/perf_smoke/__init__.py | 18 ++ tools/perf_smoke/cli.py | 197 +++++++++++++++ tools/perf_smoke/compare.py | 377 +++++++++++++++++++++++++++ tools/perf_smoke/contract.py | 219 ++++++++++++++++ tools/perf_smoke/metrics.py | 148 +++++++++++ tools/perf_smoke/report.py | 122 +++++++++ tools/perf_smoke/requirements.txt | 9 + tools/perf_smoke/store.py | 408 ++++++++++++++++++++++++++++++ tools/perf_smoke/test_compare.py | 177 +++++++++++++ tools/perf_smoke_thresholds.json | 45 ++++ 13 files changed, 2002 insertions(+), 1 deletion(-) create mode 100644 tools/perf_smoke/__init__.py create mode 100644 tools/perf_smoke/cli.py create mode 100644 tools/perf_smoke/compare.py create mode 100644 tools/perf_smoke/contract.py create mode 100644 tools/perf_smoke/metrics.py create mode 100644 tools/perf_smoke/report.py create mode 100644 tools/perf_smoke/requirements.txt create mode 100644 tools/perf_smoke/store.py create mode 100644 tools/perf_smoke/test_compare.py create mode 100644 tools/perf_smoke_thresholds.json diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e46f2904817e..971801ba80f5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -966,6 +966,278 @@ jobs: container-name: "isaac-lab-rendering-correctness-kitless-ovstage-test" omni-github-test-type: "rendering-correctness-kitless-ovstage" + performance-smoke: + name: performance-smoke-benchmarks + runs-on: [self-hosted, gpu] + # Ceiling is the sum of each leg's own bench_timeout_s x 3 + buffer + # Individual attempts are still bound tightly by `timeout` inside the loop. + timeout-minutes: 510 + needs: [build, config] + # Pull requests and manual dispatches run in gate mode. A push runs only on develop, + # where it also records a baseline; pushes to main and release/** skip this job. + if: >- + (github.event_name != 'push' || github.ref == 'refs/heads/develop') && + needs.build.result == 'success' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Pull pull-request image + uses: ./.github/actions/ecr-build-push-pull + with: + image-tag: ${{ needs.config.outputs.ci_image_tag }} + isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} + isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} + dockerfile-path: docker/Dockerfile.base + cache-tag: cache-base + + # One row per combination: name|task|num_envs|bench_timeout_s|args + - name: Define benchmark matrix + run: | + cat > benchmark-legs.tsv <<'EOF' + cartpole-physx|Isaac-Cartpole-Direct|4096|600| + cartpole-newton|Isaac-Cartpole-Direct|4096|600|physics=newton_mjwarp + gear-mesh-physx|IsaacContrib-Factory-GearMesh-Direct|512|900| + shadow-camera-physx-rtx|IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct|512|1200|renderer=isaacsim_rtx + shadow-camera-physx-newton-renderer|IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct|512|1200|renderer=newton_renderer + shadow-camera-newton-rtx|IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct|512|1200|physics=newton_mjwarp renderer=isaacsim_rtx + shadow-camera-newton-renderer|IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct|512|1200|physics=newton_mjwarp renderer=newton_renderer + shadow-camera-ovphysx-newton|IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct|512|1200|physics=ovphysx renderer=newton_renderer + g1-physx|Isaac-Velocity-Flat-G1|512|720| + g1-newton|Isaac-Velocity-Flat-G1|512|720|physics=newton_mjwarp + EOF + + - name: Resolve Warp version for the JIT cache key + id: warp_version + run: | + set -euo pipefail + version="$(sed -n 's/.*warp-lang[^0-9]*\([0-9][0-9A-Za-z.\-]*\).*/\1/p' pyproject.toml | head -n1)" + if [ -z "$version" ]; then + echo "::error::Could not resolve the warp-lang pin from pyproject.toml; the JIT cache key would stop tracking Warp" + exit 1 + fi + echo "value=$version" >> "$GITHUB_OUTPUT" + + - name: Restore warm JIT cache + uses: actions/cache/restore@v4 + with: + path: ${{ github.workspace }}/jit-cache + key: perf-jit-warp${{ steps.warp_version.outputs.value }}-${{ github.run_id }} + restore-keys: | + perf-jit-warp${{ steps.warp_version.outputs.value }}- + + - name: Run runtime benchmarks + id: runtime_benchmark + env: + CI_IMAGE_TAG: ${{ needs.config.outputs.ci_image_tag }} + run: | + set -uo pipefail + install -d -m 0777 "${GITHUB_WORKSPACE}/jit-cache/warp" "${GITHUB_WORKSPACE}/jit-cache/nv" + + container="" + cleanup_container() { + if [ -n "$container" ]; then + docker rm -f "$container" >/dev/null 2>&1 || true + fi + } + trap cleanup_container EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + run_attempt() { + local attempt="$1" + container="performance-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${leg}-${sample}-${attempt}" + cleanup_container + timeout "${bench_timeout_s}" docker run --name "$container" --gpus all --network=host --ipc=host \ + -e OMNI_KIT_ACCEPT_EULA=yes \ + -e ACCEPT_EULA=Y \ + -e ISAAC_SIM_HEADLESS=1 \ + -e WARP_CACHE_PATH=/tmp/jit-cache/warp \ + -e CUDA_CACHE_PATH=/tmp/jit-cache/nv \ + -v "${GITHUB_WORKSPACE}/jit-cache:/tmp/jit-cache" \ + --entrypoint bash "$CI_IMAGE_TAG" -lc \ + "set -euo pipefail + mkdir -p /tmp/benchmark-output + uv run --no-sync isaaclab benchmark runtime \ + --task '$task' \ + --num_envs '$num_envs' \ + --num_steps 200 \ + --warmup_steps 100 \ + --seed 42 \ + --benchmark_formatter schema \ + --output_path /tmp/benchmark-output \ + --visualizer none \ + $args" + local status=$? + docker cp "$container:/tmp/benchmark-output/." "benchmark-output/${leg}/sample-${sample}/" 2>/dev/null || true + cleanup_container + container="" + local -a bundles + mapfile -t bundles < <(find "benchmark-output/${leg}/sample-${sample}" -maxdepth 1 -type f -name 'benchmark_runtime_*.json') + if [ "$status" -eq 0 ] && [ "${#bundles[@]}" -ne 1 ]; then + echo "::error::${leg} exited 0 but did not produce exactly one benchmark JSON" + return 1 + fi + return $status + } + + failed_legs=() + while IFS='|' read -r leg task num_envs bench_timeout_s args; do + echo "::group::performance-smoke: ${leg}" + install -d -m 0777 "benchmark-output/${leg}" + leg_ok=true + # ASV needs repeated independent runs on both sides for significance tests. + # Per-frame samples within one simulation are not independent CI runs. + for sample in 1 2 3; do + mkdir -p "benchmark-output/${leg}/sample-${sample}" + if ! run_attempt 1; then + echo "::warning::${leg} sample ${sample} failed; retrying once" + rm -f "benchmark-output/${leg}/sample-${sample}/"benchmark_runtime_*.json + if ! run_attempt 2; then + echo "::error::${leg} sample ${sample} failed on both attempts" + leg_ok=false + break + fi + fi + done + echo "$([ "$leg_ok" = true ] && echo ok || echo failed)" > "benchmark-output/${leg}/status" + [ "$leg_ok" = true ] || failed_legs+=("$leg") + echo "::endgroup::" + done < benchmark-legs.tsv + + if [ "${#failed_legs[@]}" -gt 0 ]; then + echo "::error::performance-smoke: failed combination(s): ${failed_legs[*]}" + exit 1 + fi + + - name: Save warm JIT cache + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/develop' + uses: actions/cache/save@v4 + with: + path: ${{ github.workspace }}/jit-cache + key: perf-jit-warp${{ steps.warp_version.outputs.value }}-${{ github.run_id }} + + - name: Set up uv for performance comparisons + if: always() + uses: astral-sh/setup-uv@v6 + with: + enable-cache: false + + # The gate runs on the runner host, not inside the Isaac Sim container, so + # ASV and the baseline store's SDK go in a throwaway uv environment. + - name: Set up ASV and the baseline store client + if: always() + # Job status answers "did the benchmarks run", the check answers "did they regress". + # A transient PyPI failure means the gate could not run, degrading to ERROR instead of a failure. + continue-on-error: true + id: store_venv + run: | + set -euo pipefail + venv="${RUNNER_TEMP}/perf-smoke-venv" + uv venv --python 3.12 "$venv" + uv pip install --python "$venv/bin/python" \ + -r tools/perf_smoke/requirements.txt + echo "python=$venv/bin/python" >> "$GITHUB_OUTPUT" + + # Read-only. Runs PR-checkout code (tools.perf_smoke), so it must use a separate + # list/read-only SAS, never the write-capable credential below + - name: Compare against baseline history + # The exit code carries the verdict to the summary job through the uploaded + # artifact, so it must not also redden this job: job status answers "did the + # benchmarks run", the check answers "did performance regress". Each leg is + # compared independently, so one leg's fault does not skip the rest. + continue-on-error: true + if: always() + timeout-minutes: 30 + env: + # The SAS URL is strictly only read from the environment for security. + ISAACLAB_BLOB_URL: ${{ secrets.ISAACLAB_BLOB_READ_URL }} + PERF_PYTHON: ${{ steps.store_venv.outputs.python }} + run: | + set -uo pipefail + while IFS='|' read -r leg task num_envs bench_timeout_s args; do + mapfile -t results < <(find "benchmark-output/${leg}" -type f -name 'benchmark_runtime_*.json' | sort) + if [ "$(cat "benchmark-output/${leg}/status" 2>/dev/null)" != "ok" ] || [ "${#results[@]}" -ne 3 ]; then + echo "::warning::${leg}: expected three runtime benchmark JSONs, found ${#results[@]}" + results=("benchmark-output/${leg}/benchmark_runtime_MISSING.json") + fi + # A missing credential yields SKIP, a gate fault yields ERROR; neither fails. + uv run --no-project --python "${PERF_PYTHON:-python3}" python -m tools.perf_smoke.cli compare \ + --benchmark_result "${results[@]}" \ + --label "$leg" \ + --output_json "benchmark-output/${leg}/comparison.json" \ + | tee -a "$GITHUB_STEP_SUMMARY" + done < benchmark-legs.tsv + + # Only develop records baselines, so the gate always compares a pull request + # against the branch it will merge into rather than against a stale snapshot. + # Writes are create-only under a name derived from the commit and run id. Each + # leg is gated on its own status, so one leg's fault does not skip the rest. + - name: Record baseline + if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/develop' }} + timeout-minutes: 30 + env: + ISAACLAB_BLOB_URL: ${{ secrets.ISAACLAB_BLOB_URL }} + PERF_PYTHON: ${{ steps.store_venv.outputs.python }} + run: | + set -uo pipefail + if [ -z "${ISAACLAB_BLOB_URL:-}" ]; then + echo "::warning::ISAACLAB_BLOB_URL is not set; no baselines recorded" + exit 0 + fi + while IFS='|' read -r leg task num_envs bench_timeout_s args; do + if [ "$(cat "benchmark-output/${leg}/status" 2>/dev/null)" != "ok" ]; then + echo "::warning::${leg}: benchmark did not succeed; no baseline recorded" + continue + fi + mapfile -t results < <(find "benchmark-output/${leg}" -type f -name 'benchmark_runtime_*.json' | sort) + if [ "${#results[@]}" -ne 3 ]; then + echo "::error::${leg}: expected three runtime benchmark JSONs, found ${#results[@]}; no baseline recorded" + continue + fi + for index in "${!results[@]}"; do + uv run --no-project --python "${PERF_PYTHON:-python3}" python -m tools.perf_smoke.cli write \ + --benchmark_result "${results[$index]}" \ + --commit "$GITHUB_SHA" \ + --timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --run_id "${GITHUB_RUN_ID}-${leg}-sample-${index}" + done + done < benchmark-legs.tsv + - name: Upload performance results + if: always() + uses: actions/upload-artifact@v7 + with: + name: performance-smoke-${{ github.run_id }}-${{ github.run_attempt }} + path: benchmark-output/ + if-no-files-found: warn + retention-days: 7 + + # Centralized summary; the performance-smoke job itself already fails on any + # leg whose benchmark could not be run at all. + performance-smoke-summary: + name: performance-smoke + runs-on: ubuntu-latest + needs: [performance-smoke] + if: always() && needs.performance-smoke.result != 'skipped' + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: false + - uses: actions/download-artifact@v7 + with: + name: performance-smoke-${{ github.run_id }}-${{ github.run_attempt }} + path: comparisons + continue-on-error: true + - name: Summarise performance comparisons + run: | + set -euo pipefail + uv run --no-project python -m tools.perf_smoke.cli aggregate \ + --comparison_dir comparisons \ + | tee -a "$GITHUB_STEP_SUMMARY" + # Publishes the shared Warp kernel cache the test jobs restore. Needed as its # own job because a cache written from a PR is readable only by that PR, and # no solver-heavy test job runs on push. Restores the newest compatible diff --git a/.github/workflows/tools-tests.yml b/.github/workflows/tools-tests.yml index c76c0c3c1297..4913e366e2fb 100644 --- a/.github/workflows/tools-tests.yml +++ b/.github/workflows/tools-tests.yml @@ -14,6 +14,8 @@ on: # Top-level tools modules and their tests. Deliberately not "tools/**": the # subpackages (changelog/, skills/, wheel_builder/) have their own gates. - "tools/*.py" + - "tools/perf_smoke/**" + - "tools/perf_smoke_thresholds.json" # Shared CI action helpers and their co-located tests. - ".github/actions/_lib/**" - ".github/workflows/tools-tests.yml" @@ -68,3 +70,10 @@ jobs: # suite, so loading it here would ignore the files named below. run: python3 -m pytest tools/test_crash_journal.py tools/test_device_split.py tools/test_file_scheduler.py .github/actions/_lib/test_registry_credential_fallback.py -v --noconftest + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: false + + - name: Test ASV performance comparisons + run: uv run --no-project --with-requirements tools/perf_smoke/requirements.txt python -m unittest tools.perf_smoke.test_compare diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 8ccabd90b3cd..255ada8f4797 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -133,7 +133,7 @@ COPY --parents ../source/*/pyproject.toml ${ISAACLAB_PATH}/ # The image's package set. Kit ships in the base image, so the teleop stack comes without the # ``isaacsim`` wheel; ``teleop`` itself is left exactly as tuned. -ARG IMAGE_EXTRAS="--extra sb3 --extra skrl --extra rl-games --extra rsl-rl --extra viser --extra rerun --extra mimic --extra teleop --extra test --extra tetrahedralization" +ARG IMAGE_EXTRAS="--extra sb3 --extra skrl --extra rl-games --extra rsl-rl --extra viser --extra rerun --extra mimic --extra teleop --extra test --extra tetrahedralization --extra ov" RUN --mount=type=cache,target=/root/.cache/uv \ diff --git a/tools/perf_smoke/__init__.py b/tools/perf_smoke/__init__.py new file mode 100644 index 000000000000..31baccec69d5 --- /dev/null +++ b/tools/perf_smoke/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Performance smoke gate: compare a PR's runtime benchmark against develop's history. + +* :mod:`metrics` -- the runtime bundle's shape +* :mod:`contract` -- which runs are comparable +* :mod:`store` -- the only network access and the only credential handling +* :mod:`compare` -- pure verdict logic +* :mod:`report` -- presentation +* :mod:`cli` -- wiring +""" + +from .metrics import METRICS, Metric, PerfSmokeError + +__all__ = ["METRICS", "Metric", "PerfSmokeError"] diff --git a/tools/perf_smoke/cli.py b/tools/perf_smoke/cli.py new file mode 100644 index 000000000000..47a2196df03c --- /dev/null +++ b/tools/perf_smoke/cli.py @@ -0,0 +1,197 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Command-line entry points for the performance smoke gate. + +Wiring only; every decision lives in :mod:`compare`, :mod:`contract` or +:mod:`store`, so the library stays importable without argparse. + +The container SAS URL is read from ``$ISAACLAB_BLOB_URL``. + +Subcommands: + ``compare`` compare independent benchmark runs against the baseline store + ``write`` record one measurement in the store (develop only) + ``aggregate`` roll several comparison JSONs into one summary +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +from pathlib import Path + +from . import compare as compare_mod +from . import contract as contract_mod +from . import metrics as metrics_mod +from . import report as report_mod +from . import store as store_mod + +_DEFAULT_THRESHOLDS = Path(__file__).resolve().parent.parent / "perf_smoke_thresholds.json" + + +def _load_json(path: Path, name: str) -> dict: + try: + return metrics_mod.mapping(json.loads(path.read_text(encoding="utf-8")), name) + except FileNotFoundError as exc: + raise metrics_mod.PerfSmokeError(f"{name} not found: {path}") from exc + except OSError as exc: + # A bad path should surface as a gate error. + raise metrics_mod.PerfSmokeError(f"{name} could not be read: {path} ({exc})") from exc + except (json.JSONDecodeError, UnicodeError) as exc: + raise metrics_mod.PerfSmokeError(f"{name} is not valid UTF-8 JSON: {exc}") from exc + + +def _cmd_compare(args: argparse.Namespace) -> int: + # Split into compare and measure stages such that measurements are preserved. + try: + bundles = [_load_json(path, "benchmark result") for path in args.benchmark_result] + key = contract_mod.build(bundles[0]) + if any(not contract_mod.build(bundle).matches(key) for bundle in bundles[1:]): + raise metrics_mod.PerfSmokeError("candidate runs have different runtime contracts") + measurements = [metrics_mod.extract(bundle) for bundle in bundles] + measured = { + metric.name: statistics.median(row[metric.name] for row in measurements) for metric in metrics_mod.METRICS + } + except metrics_mod.PerfSmokeError as exc: + report = compare_mod.errored(str(exc), label=args.label) + print(f"::warning::perf-smoke: {exc}", file=sys.stderr) + else: + try: + thresholds = _load_json(args.thresholds, "threshold config") + if not store_mod.is_configured(): + report = compare_mod.unresolved( + key, + measured, + compare_mod.SKIP, + "No baseline store credential is available for this run", + label=args.label, + ) + else: + rows = store_mod.read(key.hash, compare_mod.MAX_BASELINE_SAMPLES) + # The storage key is a truncation of the contract digest; need a full match. + expected_contract = key.as_dict() + history = [row.metrics for row in rows if row.contract == expected_contract] + report = compare_mod.compare( + key, + measurements, + history, + thresholds, + min_samples=args.min_samples, + label=args.label, + ) + except metrics_mod.PerfSmokeError as exc: + report = compare_mod.unresolved(key, measured, compare_mod.ERROR, str(exc), label=args.label) + print(f"::warning::perf-smoke: {exc}", file=sys.stderr) + + # An artifact is always written so that a faulted combination still shows in the summary. + report_mod.write_json(report, args.output_json) + print(report_mod.render(report), end="") + # Only a measured regression fails jobs; other errors are non-blocking and exit 0. + return 1 if report.verdict == compare_mod.FAIL else 0 + + +def _cmd_write(args: argparse.Namespace) -> int: + bundle = _load_json(args.benchmark_result, "benchmark result") + key = contract_mod.build(bundle) + row = store_mod.BaselineRow( + contract=key.as_dict(), + contract_hash=key.hash, + metrics=metrics_mod.extract(bundle), + commit=args.commit, + timestamp=args.timestamp, + run_id=args.run_id, + ) + try: + created = store_mod.write(row) + except metrics_mod.PerfSmokeError as exc: + print(f"::warning::perf-smoke: baseline not recorded: {exc}", file=sys.stderr) + return 0 + action = "Recorded" if created else "Already recorded" + print(f"{action} baseline for contract {key.hash} at commit {args.commit[:12]}") + return 0 + + +def _cmd_aggregate(args: argparse.Namespace) -> int: + reports: list[tuple[str, compare_mod.Report]] = [] + for path in sorted(args.comparison_dir.rglob("comparison.json")): + name = path.parent.name + try: + payload = _load_json(path, str(path)) + # A TypeError here means an artifact written by a different version of this tool. + metrics = tuple(compare_mod.MetricResult(**metric) for metric in payload.get("metrics", [])) + except (metrics_mod.PerfSmokeError, TypeError) as exc: + print(f"::warning::perf-smoke: {path} could not be read: {exc}", file=sys.stderr) + reports.append((name, compare_mod.errored(f"comparison artifact could not be read: {exc}", label=name))) + continue + reports.append( + ( + payload.get("label") or name, + compare_mod.Report( + contract=payload.get("contract", {}), + contract_hash=payload.get("contract_hash", ""), + metrics=metrics, + verdict=payload.get("verdict", compare_mod.SKIP), + message=payload.get("message", ""), + label=payload.get("label", ""), + ), + ) + ) + + summary = report_mod.render_aggregate(reports) + print(summary, end="") + if args.output_markdown: + args.output_markdown.parent.mkdir(parents=True, exist_ok=True) + args.output_markdown.write_text(summary, encoding="utf-8") + + if not reports: + # Non-blocking: this is an infra fault (e.g. a flaky artifact download) + print("::warning::perf-smoke: no comparison artifacts were produced", file=sys.stderr) + return 0 + return 1 if any(report.verdict == compare_mod.FAIL for _, report in reports) else 0 + + +def build_parser() -> argparse.ArgumentParser: + """Build the argument parser for every subcommand.""" + parser = argparse.ArgumentParser(prog="perf_smoke", description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + compare_parser = subparsers.add_parser("compare", help="compare a benchmark against the baseline store") + compare_parser.add_argument("--benchmark_result", type=Path, nargs="+", required=True) + compare_parser.add_argument("--thresholds", type=Path, default=_DEFAULT_THRESHOLDS) + compare_parser.add_argument("--output_json", type=Path, required=True) + compare_parser.add_argument("--min_samples", type=int, default=compare_mod.MIN_BASELINE_SAMPLES) + compare_parser.add_argument("--label", default="", help="matrix combination name, carried into the artifact") + compare_parser.set_defaults(func=_cmd_compare) + + write_parser = subparsers.add_parser("write", help="append a measurement to the baseline store") + write_parser.add_argument("--benchmark_result", type=Path, required=True) + write_parser.add_argument("--commit", required=True) + write_parser.add_argument("--timestamp", required=True) + write_parser.add_argument("--run_id", default="") + write_parser.set_defaults(func=_cmd_write) + + aggregate_parser = subparsers.add_parser("aggregate", help="roll comparison JSONs into one summary") + aggregate_parser.add_argument("--comparison_dir", type=Path, required=True) + aggregate_parser.add_argument("--output_markdown", type=Path) + aggregate_parser.set_defaults(func=_cmd_aggregate) + + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the performance smoke CLI.""" + args = build_parser().parse_args(argv) + try: + return int(args.func(args)) + except metrics_mod.PerfSmokeError as exc: + # Exit 2: parse error; exit 1: measured regression or empty results. + print(f"::error::perf-smoke: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/perf_smoke/compare.py b/tools/perf_smoke/compare.py new file mode 100644 index 000000000000..6e72024d5d02 --- /dev/null +++ b/tools/perf_smoke/compare.py @@ -0,0 +1,377 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Compare one runtime benchmark against the recent history of comparable runs. + +ASV's statistics (Mann-Whitney U, confidence intervals) decide relative regressions; +:func:`_is_significant` picks between them per comparison, since ASV's own selection +doesn't hold for our fixed-size candidate runs. Isaac Lab applies the warmup, +advisory-metric, and absolute-floor policies around that comparison. +""" + +from __future__ import annotations + +import statistics +import sys +from dataclasses import asdict, dataclass, field, replace +from math import comb +from typing import Any + +from .contract import Contract, backend_key, valid_backend_keys +from .metrics import METRICS, Metric, PerfSmokeError, mapping, number + +PASS = "PASS" +WARN = "WARN" +FAIL = "FAIL" +SKIP = "SKIP" +ERROR = "ERROR" + +#: ASV's own significance threshold matched for consistency. +_SIGNIFICANCE_P = 0.002 + +#: Comparable runs required before the gate will render a verdict. +MIN_BASELINE_SAMPLES = 3 + +#: Rows read from the store. +MAX_BASELINE_SAMPLES = 20 + +_SEVERITY = {PASS: 0, SKIP: 0, ERROR: 0, WARN: 1, FAIL: 2} + + +@dataclass(frozen=True) +class Thresholds: + """Resolved gating policy for one metric of one benchmark combination.""" + + warn_pct: float + fail_pct: float + hard_floor: float | None = None + gating: bool = True + + +@dataclass(frozen=True) +class MetricResult: + """Outcome for one compared metric.""" + + name: str + label: str + measured: float + reference: float | None = None + regression_pct: float | None = None + warn_pct: float | None = None + fail_pct: float | None = None + hard_floor: float | None = None + sample_count: int = 0 + verdict: str = SKIP + gating: bool = False + note: str | None = None + + +@dataclass(frozen=True) +class Report: + """Complete comparison for one benchmark combination.""" + + contract: dict[str, Any] = field(default_factory=dict) + contract_hash: str = "" + metrics: tuple[MetricResult, ...] = () + verdict: str = SKIP + message: str = "" + #: Matrix combination name, carried in the artifact so the aggregate job can + #: label rows without parsing artifact directory names. + label: str = "" + + def as_dict(self) -> dict[str, Any]: + """Return the report as a plain, JSON-serialisable dict.""" + return asdict(self) + + +def _warn(message: str) -> None: + """Report a policy problem that must not stop the comparison.""" + print(f"::warning::perf-smoke: {message}", file=sys.stderr) + + +def _clean(config: Any, name: str) -> dict[str, Any]: + """Return a config mapping with documentation keys (``_comment``, ``_todo``) removed.""" + return {key: value for key, value in mapping(config, name).items() if not key.startswith("_")} + + +def resolve_thresholds(config: Any, gpu_model: str, task: str, key: str) -> dict[str, Thresholds]: + """Resolve gating policy for every metric of one combination. + + Args: + config: Parsed ``perf_smoke_thresholds.json``. + gpu_model: Canonical GPU slug (see :func:`~tools.perf_smoke.contract.normalize_gpu_model`). + task: Gym task id. + key: ``{physics}`` or ``{physics}_{render}``. + + Returns: + Mapping of metric name to its resolved :class:`Thresholds`. + + Raises: + PerfSmokeError: If the config is malformed. + """ + root = _clean(config, "threshold config") + defaults = _clean(root.get("defaults", {}), "threshold config defaults") + warn = number(defaults.get("warn_regression_pct", 5.0), "defaults.warn_regression_pct") + fail = number(defaults.get("fail_regression_pct", 10.0), "defaults.fail_regression_pct") + + per_task = _clean(root.get("per_task_regression_pct", {}), "per_task_regression_pct") + override = _clean(per_task.get(task, {}), f"per_task_regression_pct.{task}") + if "warn_regression_pct" in override: + warn = number(override["warn_regression_pct"], f"per_task_regression_pct.{task}.warn_regression_pct") + if "fail_regression_pct" in override: + fail = number(override["fail_regression_pct"], f"per_task_regression_pct.{task}.fail_regression_pct") + if not 0 <= warn <= fail < 100: + raise PerfSmokeError(f"regression percentages must satisfy 0 <= warn <= fail < 100 for {task}") + advisory_only = override.get("advisory_only", False) + if not isinstance(advisory_only, bool): + raise PerfSmokeError(f"per_task_regression_pct.{task}.advisory_only must be a boolean") + + floors = _clean(root.get("hard_floor_fps", {}), "hard_floor_fps") + by_task = _clean(floors.get(gpu_model, {}), f"hard_floor_fps.{gpu_model}") + by_key = _clean(by_task.get(task, {}), f"hard_floor_fps.{gpu_model}.{task}") + if floors and gpu_model not in floors: + _warn(f"no hard floors are configured for GPU {gpu_model!r}; configured: {sorted(floors)}") + unreachable = sorted(set(by_key) - valid_backend_keys()) + if unreachable: + _warn( + f"hard_floor_fps.{gpu_model}.{task} has key(s) {unreachable} that no run can report," + f" so they never apply; expected one of {sorted(valid_backend_keys())}" + ) + raw_floor = by_key.get(key) + # A non-positive floor means disabled/no floor. Active floors must be positive. + floor = number(raw_floor, f"hard_floor_fps.{gpu_model}.{task}.{key}") if raw_floor is not None else None + if floor is not None and floor <= 0: + floor = None + + resolved: dict[str, Thresholds] = {} + for metric in METRICS: + resolved[metric.name] = Thresholds( + warn, + fail, + floor if metric.name == "total_fps" else None, + gating=metric.gating and not advisory_only, + ) + return resolved + + +def _is_significant( + before: list[float], after: list[float], before_stats: dict, after_stats: dict, mann_whitney_u +) -> bool: + """Return whether ``before`` and ``after`` differ significantly. + + ASV picks Mann-Whitney U over a confidence-interval overlap check based on a + combinatorial lower bound (``p_min``) that does not always match what + Mann-Whitney can actually achieve for asymmetric sample sizes creating a dead zone + where Mann-Whitney is selected but cannot reach significance and can pass regressions. + """ + m, n = len(before), len(after) + if 1 / comb(m + n, min(m, n)) < _SIGNIFICANCE_P: + _, best_case_p = mann_whitney_u([0.0] * m, [1.0] * n) + if best_case_p < _SIGNIFICANCE_P: + _, p = mann_whitney_u(before, after) + return p < _SIGNIFICANCE_P + ci_before = (before_stats["ci_99_a"], before_stats["ci_99_b"]) + ci_after = (after_stats["ci_99_a"], after_stats["ci_99_b"]) + return not (ci_before[1] >= ci_after[0] and ci_before[0] <= ci_after[1]) + + +def _evaluate( + metric: Metric, + candidates: list[float], + history: list[float], + thresholds: Thresholds, + min_samples: int, +) -> MetricResult: + """Apply absolute floors, then ASV's significance and strict relative thresholds.""" + result = MetricResult( + name=metric.name, + label=metric.label, + measured=statistics.median(candidates), + warn_pct=thresholds.warn_pct, + fail_pct=thresholds.fail_pct, + hard_floor=thresholds.hard_floor, + sample_count=len(history), + gating=thresholds.gating, + ) + if thresholds.hard_floor is not None: + worst = max(candidates) if metric.higher_is_worse else min(candidates) + breached = worst > thresholds.hard_floor if metric.higher_is_worse else worst < thresholds.hard_floor + if breached: + direction = "above" if metric.higher_is_worse else "below" + return replace( + result, + verdict=FAIL, + gating=True, + note=f"{direction} hard floor {thresholds.hard_floor:g}", + ) + + if len(history) < min_samples or len(candidates) < 2: + return replace(result, note="insufficient independent runs for ASV significance testing") + + reference = statistics.median(history) + if reference == 0: + return replace(result, reference=reference, note="baseline median is zero") + + # Lazy loading keeps aggregation dependency-free. ASV is version-pinned and + # covered by the comparison tests. + try: + from asv._stats import mann_whitney_u + from asv_runner.statistics import compute_stats + except ImportError as exc: + raise PerfSmokeError("ASV is unavailable; install tools/perf_smoke/requirements.txt") from exc + + # ASV assumes lower is better. Reversing FPS comparisons preserves the + # original samples and medians, including zero throughput and even counts. + before, after = (history, candidates) if metric.higher_is_worse else (candidates, history) + before_value, before_stats = compute_stats(before, 1) + after_value, after_stats = compute_stats(after, 1) + significant = _is_significant(before, after, before_stats, after_stats, mann_whitney_u) + warned, failed = ( + significant and before_value < after_value / (1 + pct / 100 if metric.higher_is_worse else 1 / (1 - pct / 100)) + for pct in (thresholds.warn_pct, thresholds.fail_pct) + ) + change_pct = (result.measured - reference) / reference * 100.0 + return replace( + result, + reference=reference, + regression_pct=change_pct if metric.higher_is_worse else -change_pct, + verdict=FAIL if failed else WARN if warned else PASS, + ) + + +def unresolved( + contract: Contract, + measured: dict[str, float], + verdict: str, + reason: str, + label: str = "", +) -> Report: + """Build a report that records measurements but no comparison. + + The run itself succeeded; only the verdict is missing. :data:`SKIP` means there was + nothing to compare against, :data:`ERROR` means the comparison could not be attempted. + Either way the measurements are carried through: they are the expensive part of the + job and remain valid on their own. + + Args: + contract: The run's comparability contract. + measured: Measured value per metric name. + verdict: :data:`SKIP` or :data:`ERROR`. + reason: Operator-facing explanation. + label: Matrix combination name. + + Returns: + A report carrying every measurement, with no comparison against a baseline. + """ + metrics = tuple( + MetricResult( + name=metric.name, + label=metric.label, + measured=measured[metric.name], + verdict=verdict, + gating=metric.gating, + ) + for metric in METRICS + ) + return Report(contract.as_dict(), contract.hash, metrics, verdict, reason, label) + + +def errored(reason: str, label: str = "") -> Report: + """Build a report for a gate failure that happened before anything was measured. + + Non-blocking and distinct from :data:`SKIP`. Use :func:`unresolved` with + :data:`ERROR` when the run *was* measured and only the comparison failed; this one is + for a bundle that could not be parsed, where there is nothing to report but the fault. + + Args: + reason: Operator-facing explanation of error. + label: Matrix combination name. + + Returns: + A report carrying no metrics and the :data:`ERROR` verdict. + """ + return Report(contract={}, contract_hash="", metrics=(), verdict=ERROR, message=reason, label=label) + + +def compare( + contract: Contract, + measurements: list[dict[str, float]], + history: list[dict[str, float]], + threshold_config: Any, + *, + min_samples: int = MIN_BASELINE_SAMPLES, + label: str = "", +) -> Report: + """Compare independent candidate runs against comparable baseline history. + + Args: + contract: Comparability contract for this run. + measurements: Metric mappings from independent candidate runs. + history: Metric mappings from prior comparable runs, oldest first. + threshold_config: Parsed ``perf_smoke_thresholds.json``. + min_samples: Comparable runs required before a verdict is rendered. + label: Matrix combination name. + + Returns: + The comparison report: FAIL if any gating metric failed, WARN if any warned, + SKIP only if every gating metric was skipped, otherwise PASS. Non-gating + metrics are evaluated and reported, but never change the verdict. + """ + if not measurements: + raise PerfSmokeError("at least one candidate run is required") + if min_samples < 2: + raise PerfSmokeError("min_samples must be at least 2 for ASV significance testing") + thresholds = resolve_thresholds( + threshold_config, + str(contract.runtime.get("gpu_model", "")), + str(contract.workload.get("task", "")), + backend_key(contract), + ) + + results = tuple( + _evaluate( + metric, + [row[metric.name] for row in measurements], + [row[metric.name] for row in history if metric.name in row], + thresholds[metric.name], + min_samples, + ) + for metric in METRICS + ) + + gating = [result for result in results if result.gating] + if any(result.verdict == FAIL for result in gating): + verdict = FAIL + elif any(result.verdict == WARN for result in gating): + verdict = WARN + elif gating and all(result.verdict == SKIP for result in gating): + verdict = SKIP + elif gating: + verdict = PASS + else: + verdict = SKIP + worst = max(gating, key=lambda result: _SEVERITY[result.verdict], default=None) + if verdict == FAIL and worst is not None and worst.hard_floor is not None and worst.note is not None: + message = f"{worst.label} below hard floor" + elif verdict == FAIL: + message = f"Performance regression in {worst.label}" if worst else "Performance regression" + elif verdict == WARN: + message = f"Possible performance regression in {worst.label}" if worst else "Possible regression" + elif verdict == SKIP and len(history) < min_samples: + message = ( + "No baseline recorded yet for this runtime contract" + if not history + else f"Baseline warming up ({len(history)}/{min_samples} comparable runs)" + ) + elif verdict == SKIP: + message = "No gating metric could be compared" + else: + message = "No performance regression detected" + return Report(contract.as_dict(), contract.hash, results, verdict, message, label) + + +def gating_names() -> tuple[str, ...]: + """Return the names of the metrics whose verdict can fail a pull request.""" + return tuple(metric.name for metric in METRICS if metric.gating) diff --git a/tools/perf_smoke/contract.py b/tools/perf_smoke/contract.py new file mode 100644 index 000000000000..63c024df1c7d --- /dev/null +++ b/tools/perf_smoke/contract.py @@ -0,0 +1,219 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Comparability contract: decides which runs may be pooled into one baseline. + +Two runs are comparable when they measured the same workload on the same hardware +with the same externally pinned runtime. Pooling across a Newton or Isaac Sim +bump would hide or invent regressions, so those versions are part of the key. + +The dependency set is conditional on the backends actually in use, so a Newton +bump invalidates only Newton baselines and leaves PhysX baselines usable. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field +from typing import Any + +from .metrics import PerfSmokeError, mapping, positive_int + +#: Externally pinned versions that affect measured performance, keyed by where they +#: apply. Keys are the values a bundle reports in ``run.config``, which are NOT the +#: matrix names. An unrecognised value raises. +#: ``version`` is a manual invalidation switch. It is part of the hashed contract, so +#: raising it retires every baseline recorded so far, and the gate reports SKIP until +#: history rebuilds. +RUNTIME_COMPATIBILITY: dict[str, Any] = { + "version": 3, + "always": ("torch", "warp"), + "by_physics_backend": { + "physx": ("isaacsim",), + "newton_mjwarp": ("newton", "mujoco", "mjwarp"), + # Standalone, kit-less solver; isaacsim is null on this path, so it is pinned + # to its own version instead. + "ovphysx": ("ovphysx",), + }, + "by_render_backend": { + "newton": ("newton",), + # Kit's RTX renderer, versioned with the Isaac Sim build it ships in. + "isaacsim_rtx": ("isaacsim",), + # Standalone OV RTX renderer, shipped as its own package. + "ovrtx": ("ovrtx",), + }, +} + +#: Versions whose semver build metadata is rebuild noise rather than a real variant. +#: e.g. Isaac Sim reports ``6.0.1-rc.7+release.42383.32955d8d.gl``. +#: This should NOT be applied to torch e.g. ``+cu128`` vs ``+cu130``. +_STRIP_BUILD_METADATA = ("isaacsim",) + +_UNKNOWN_GPU = "unknown-gpu" + + +def normalize_gpu_model(value: Any) -> str: + """Return a canonical slug for a raw GPU name.""" + raw = value.strip() if isinstance(value, str) else "" + if not raw: + return _UNKNOWN_GPU + without_vendor = re.sub(r"^nvidia\s+", "", raw, flags=re.IGNORECASE) + return re.sub(r"[^a-z0-9]+", "_", without_vendor.lower()).strip("_") or _UNKNOWN_GPU + + +def strip_build_metadata(version: str) -> str: + """Drop semver build metadata so a rebuild of the same release still matches.""" + return version.split("+", 1)[0].strip() + + +@dataclass(frozen=True) +class Contract: + """The full baseline match key for one benchmark run. + + Attributes: + workload: Task identity i.e. what was measured. + runtime: Externally pinned versions and hardware i.e. what it was measured on. + """ + + workload: dict[str, Any] = field(default_factory=dict) + runtime: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + """Return the contract as a plain, JSON-serialisable dict.""" + return {"workload": dict(self.workload), "runtime": dict(self.runtime)} + + @property + def hash(self) -> str: + """Return a stable 16-hex-character digest of the whole contract.""" + payload = json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + def matches(self, other: Contract) -> bool: + """Return whether ``other`` may be pooled with this run.""" + return self.workload == other.workload and self.runtime == other.runtime + + +def _required_version_names(physics_backend: str, render_backend: str | None) -> tuple[str, ...]: + """Return the version keys that matter for these backends. + + Raises: + PerfSmokeError: If either backend is not in :data:`RUNTIME_COMPATIBILITY`. + """ + names: list[str] = list(RUNTIME_COMPATIBILITY["always"]) + + physics_map = RUNTIME_COMPATIBILITY["by_physics_backend"] + if physics_backend not in physics_map: + raise PerfSmokeError( + f"Unknown physics backend {physics_backend!r}; add it to" + f" RUNTIME_COMPATIBILITY['by_physics_backend'] (known: {sorted(physics_map)})" + ) + names.extend(physics_map[physics_backend]) + + if render_backend: + render_map = RUNTIME_COMPATIBILITY["by_render_backend"] + if render_backend not in render_map: + raise PerfSmokeError( + f"Unknown render backend {render_backend!r}; add it to" + f" RUNTIME_COMPATIBILITY['by_render_backend'] (known: {sorted(render_map)})" + ) + names.extend(render_map[render_backend]) + + return tuple(sorted(set(names))) + + +def build(bundle: dict[str, Any]) -> Contract: + """Build the comparability contract for a runtime bundle. + + Args: + bundle: A schema-v1 runtime bundle. + + Returns: + The contract used to select poolable baseline rows. + + Raises: + PerfSmokeError: If required identity or hardware fields are missing. + """ + run = mapping(bundle.get("run"), "run") + config = mapping(run.get("config"), "run.config") + versions = mapping(bundle.get("versions"), "versions") + hardware = mapping(bundle.get("hardware"), "hardware") + + task = run.get("task") + if not isinstance(task, str) or not task.strip(): + raise PerfSmokeError("run.task must be a non-empty string") + + physics_backend = config.get("physics_backend") + if not isinstance(physics_backend, str) or not physics_backend.strip(): + raise PerfSmokeError("run.config.physics_backend must be a non-empty string") + + raw_render = config.get("rendering_backend") + if not isinstance(raw_render, str) or not raw_render.strip(): + raise PerfSmokeError("run.config.rendering_backend must be a non-empty string") + render_backend = None if raw_render == "none" else raw_render + + presets = config.get("presets", []) + if not isinstance(presets, list) or not all(isinstance(item, str) for item in presets): + raise PerfSmokeError("run.config.presets must be a list of strings") + + gpu_devices = hardware.get("gpu_devices") + if not isinstance(gpu_devices, list) or not gpu_devices: + raise PerfSmokeError("hardware.gpu_devices must contain at least one device") + gpu_name = mapping(gpu_devices[0], "hardware.gpu_devices[0]").get("name") + if not isinstance(gpu_name, str) or not gpu_name.strip(): + raise PerfSmokeError("hardware.gpu_devices[0].name must be a non-empty string") + + cpu_name = hardware.get("cpu_name") + if not isinstance(cpu_name, str) or not cpu_name.strip(): + raise PerfSmokeError("hardware.cpu_name must be a non-empty string") + + pinned: dict[str, str] = {} + for name in _required_version_names(physics_backend, render_backend): + value = versions.get(name) + if value is None: + raise PerfSmokeError(f"versions.{name} is required to compare {physics_backend} runs but is missing") + if not isinstance(value, str) or not value.strip(): + raise PerfSmokeError(f"versions.{name} must be a non-empty string") + pinned[name] = strip_build_metadata(value) if name in _STRIP_BUILD_METADATA else value.strip() + + return Contract( + workload={ + "task": task, + "physics_backend": physics_backend, + "render_backend": render_backend, + "presets": sorted(presets), + "num_envs": positive_int(run.get("num_envs"), "run.num_envs"), + }, + runtime={ + "versions": pinned, + "gpu_model": normalize_gpu_model(gpu_name), + "cpu_name": cpu_name.strip(), + "contract_version": RUNTIME_COMPATIBILITY["version"], + }, + ) + + +def from_dict(data: Any) -> Contract: + """Rebuild a :class:`Contract` from its serialised form (a stored baseline row).""" + payload = mapping(data, "contract") + return Contract( + workload=mapping(payload.get("workload"), "contract.workload"), + runtime=mapping(payload.get("runtime"), "contract.runtime"), + ) + + +def valid_backend_keys() -> frozenset[str]: + """Return every backend key :func:`backend_key` can produce.""" + physics = RUNTIME_COMPATIBILITY["by_physics_backend"] + renderers = RUNTIME_COMPATIBILITY["by_render_backend"] + return frozenset({*physics, *(f"{p}_{r}" for p in physics for r in renderers)}) + + +def backend_key(contract: Contract) -> str: + """Return the ``{physics}`` or ``{physics}_{render}`` key used by the threshold config.""" + physics = contract.workload.get("physics_backend") + render = contract.workload.get("render_backend") + return f"{physics}_{render}" if render else str(physics) diff --git a/tools/perf_smoke/metrics.py b/tools/perf_smoke/metrics.py new file mode 100644 index 000000000000..91d83d9fb5d8 --- /dev/null +++ b/tools/perf_smoke/metrics.py @@ -0,0 +1,148 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Metric definitions and validated extraction from schema-v1 runtime bundles.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any + + +class PerfSmokeError(ValueError): + """Raised when benchmark, baseline, or threshold input cannot be used.""" + + +@dataclass(frozen=True) +class Metric: + """One compared performance metric. + + Args: + name: Stable key used in baseline rows and JSON reports. + label: Human-readable column heading, including units. + path: Dotted path into the bundle, or ``None`` when the value is derived + (see :func:`total_startup_time_s`). + higher_is_worse: ``True`` when an increase is a regression (memory, time); + ``False`` when a decrease is a regression (throughput). + gating: Whether this metric contributes to the overall verdict. Non-gating + metrics are still measured, stored, and reported as advisory. + """ + + name: str + label: str + path: tuple[str, ...] | None + higher_is_worse: bool + gating: bool + + +#: Every metric the gate measures. Only ``total_fps`` gates today: startup time +#: was measured varying up to 16x on JIT/shader cache warmth alone, and the memory +#: metrics have no established noise model yet. All four are recorded so the +#: evidence to promote them accumulates before any threshold is trusted. +METRICS: tuple[Metric, ...] = ( + Metric("total_fps", "Total FPS", ("runtime", "total_fps", "mean"), higher_is_worse=False, gating=True), + Metric("startup_time_s", "Total startup time [s]", None, higher_is_worse=True, gating=False), + Metric( + "gpu_mem_peak_gb", + "Peak GPU memory [GB]", + ("resources", "gpu_mem_gb", "peak"), + higher_is_worse=True, + gating=False, + ), + Metric("ram_peak_gb", "Peak process RSS [GB]", ("resources", "ram_gb", "peak"), higher_is_worse=True, gating=False), +) + +#: Startup phases summed into ``startup_time_s``. The first three are required; +#: a bundle missing one is malformed rather than merely sparse. +_REQUIRED_STARTUP_PHASES = ("app_launch", "env_creation", "first_step") +_OPTIONAL_STARTUP_PHASES = ("python_imports", "task_config") + + +def mapping(value: Any, name: str) -> dict[str, Any]: + """Return ``value`` as a dict, or raise if it is not an object.""" + if not isinstance(value, dict): + raise PerfSmokeError(f"{name} must be an object") + return value + + +def number(value: Any, name: str) -> float: + """Return ``value`` as a finite float, rejecting bools and non-finite values.""" + # bool is an int subclass; accepting it would silently turn True into 1.0. + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise PerfSmokeError(f"{name} must be a number") + try: + result = float(value) + except OverflowError: + raise PerfSmokeError(f"{name} must be finite") from None + if not math.isfinite(result): + raise PerfSmokeError(f"{name} must be finite") + return result + + +def positive_int(value: Any, name: str) -> int: + """Return ``value`` as an ``int`` greater than zero.""" + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise PerfSmokeError(f"{name} must be a positive integer") + return value + + +def nested_number(data: dict[str, Any], path: tuple[str, ...]) -> float | None: + """Return the finite number at ``path``, or ``None`` when the path is absent.""" + value: Any = data + for key in path: + if not isinstance(value, dict) or key not in value: + return None + value = value[key] + return number(value, ".".join(path)) + + +def total_startup_time_s(bundle: dict[str, Any]) -> float: + """Return total startup wall time [s]: the sum of the bundle's startup phases. + + Args: + bundle: A schema-v1 runtime bundle. + + Returns: + Summed startup duration [s]. + + Raises: + PerfSmokeError: If a required phase is missing or any phase is negative. + """ + runtime = mapping(bundle.get("runtime"), "runtime") + startup = mapping(runtime.get("startup_time_s"), "runtime.startup_time_s") + values = [number(startup.get(phase), f"runtime.startup_time_s.{phase}") for phase in _REQUIRED_STARTUP_PHASES] + for phase in _OPTIONAL_STARTUP_PHASES: + if startup.get(phase) is not None: + values.append(number(startup[phase], f"runtime.startup_time_s.{phase}")) + if any(value < 0 for value in values): + raise PerfSmokeError("runtime startup phases must be non-negative") + return number(sum(values), "total startup time") + + +def extract(bundle: dict[str, Any]) -> dict[str, float]: + """Extract every metric in :data:`METRICS` from a runtime bundle. + + Args: + bundle: A schema-v1 runtime bundle. + + Returns: + Mapping of metric name to measured value. + + Raises: + PerfSmokeError: If any metric is missing, non-numeric, or negative. + """ + values: dict[str, float] = {} + for metric in METRICS: + if metric.path is None: + values[metric.name] = total_startup_time_s(bundle) + continue + value = nested_number(bundle, metric.path) + if value is None: + raise PerfSmokeError(f"Benchmark result does not contain a measured value for {metric.name}") + if value < 0: + raise PerfSmokeError(f"Measured {metric.name} must be non-negative") + values[metric.name] = value + return values diff --git a/tools/perf_smoke/report.py b/tools/perf_smoke/report.py new file mode 100644 index 000000000000..987f623757b9 --- /dev/null +++ b/tools/perf_smoke/report.py @@ -0,0 +1,122 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Markdown and JSON rendering for the per-combination and aggregate reports. + +Kept separate from :mod:`compare` because presentation churns far more often than +verdict logic, and both the per-job summary and the aggregate job render from the +same data. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .compare import ERROR, FAIL, PASS, SKIP, WARN, Report +from .metrics import METRICS + +_ICONS = {PASS: "✅", WARN: "⚠️", FAIL: "❌", SKIP: "⏭️", ERROR: "🚫"} + + +def _num(value: float | None, digits: int = 6) -> str: + """Format a number for a table cell, or ``-`` when absent.""" + return "-" if value is None else f"{value:.{digits}g}" + + +def _pct(value: float | None) -> str: + """Format a signed percentage, or ``-`` when absent.""" + return "-" if value is None else f"{value:+.2f}%" + + +def _icon(verdict: str) -> str: + return f"{_ICONS.get(verdict, '')} {verdict}".strip() + + +def render(report: Report) -> str: + """Render one combination's comparison as Markdown. + + Advisory (non-gating) metrics are shown with the same detail as gating ones so + the evidence to promote them accumulates in plain sight. + """ + lines = [ + f"## Performance smoke: {_icon(report.verdict)}", + "", + report.message, + "", + "| Metric | Measured | Baseline median | Change | Warn | Fail | Verdict |", + "| --- | ---: | ---: | ---: | ---: | ---: | --- |", + ] + for metric in report.metrics: + label = metric.label if metric.gating else f"{metric.label} _(advisory)_" + verdict = _icon(metric.verdict) if metric.gating else f"{metric.verdict} _(advisory)_" + note = f" — {metric.note}" if metric.note else "" + lines.append( + f"| {label} | {_num(metric.measured)} | {_num(metric.reference)} | {_pct(metric.regression_pct)} | " + f"{_pct(metric.warn_pct)} | {_pct(metric.fail_pct)} | " + f"{verdict}{note} |" + ) + + samples = max((metric.sample_count for metric in report.metrics), default=0) + gating = ", ".join(f"**{metric.label}**" for metric in METRICS if metric.gating) + lines += [ + "", + f"Baseline: {samples} comparable run(s), contract `{report.contract_hash}`.", + "", + f"Only {gating} gates. The other metrics are recorded and compared so their noise can be " + "characterised before any of them is trusted to fail a pull request.", + ] + return "\n".join(lines) + "\n" + + +def render_aggregate(reports: list[tuple[str, Report]]) -> str: + """Render one table covering every combination that reported. + + Args: + reports: ``(combination name, report)`` pairs. + + Returns: + Markdown for the aggregate job summary. + """ + if not reports: + return "## Performance smoke: no results\n\nNo comparison artifacts were produced.\n" + + # SKIP ranks below PASS so that a run where nothing was compared does not headline as a green + # pass. A mix still headlines PASS since at least once comparison was made. + order = {SKIP: 0, PASS: 1, ERROR: 2, WARN: 3, FAIL: 4} + worst = max((report.verdict for _, report in reports), key=lambda verdict: order.get(verdict, 0)) + + lines = [ + f"## Performance smoke: {_icon(worst)}", + "", + "| Combination | Total FPS | Baseline | Change | Startup [s] | GPU mem [GB] | RSS [GB] | Verdict |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |", + ] + # Sort on the label only: a duplicate label would otherwise fall through to + # comparing Report objects, which are frozen dataclasses without ordering. + for name, report in sorted(reports, key=lambda item: item[0]): + by_name = {metric.name: metric for metric in report.metrics} + fps = by_name.get("total_fps") + lines.append( + f"| {name} | {_num(fps.measured) if fps else '-'} | {_num(fps.reference) if fps else '-'} | " + f"{_pct(fps.regression_pct) if fps else '-'} | " + f"{_num(by_name['startup_time_s'].measured, 4) if 'startup_time_s' in by_name else '-'} | " + f"{_num(by_name['gpu_mem_peak_gb'].measured, 4) if 'gpu_mem_peak_gb' in by_name else '-'} | " + f"{_num(by_name['ram_peak_gb'].measured, 4) if 'ram_peak_gb' in by_name else '-'} | " + f"{_icon(report.verdict)} |" + ) + lines += [ + "", + f"{len(reports)} combination(s) reported. A 🚫 ERROR row is a fault in the gate, not a performance " + "result, and never blocks a pull request. A combination whose benchmark crashed shows both an ERROR " + "row here and a failed job.", + ] + return "\n".join(lines) + "\n" + + +def write_json(report: Report, path: Path) -> None: + """Write the machine-readable comparison to ``path``. The Markdown form goes to stdout.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report.as_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/tools/perf_smoke/requirements.txt b/tools/perf_smoke/requirements.txt new file mode 100644 index 000000000000..a506a9e43736 --- /dev/null +++ b/tools/perf_smoke/requirements.txt @@ -0,0 +1,9 @@ +# Host-only dependencies of the perf-smoke gate. +# +# The gate runs on the CI *runner host*, outside the Isaac Sim container, so these are +# installed into a throwaway virtualenv by the workflow rather than into the image or +# the isaaclab package. The comparison uses ASV's internal decision helper; +# run tools.perf_smoke.test_compare before updating these pins. +azure-storage-blob>=12.19,<13 +asv==0.6.6 +asv-runner==0.3.1 diff --git a/tools/perf_smoke/store.py b/tools/perf_smoke/store.py new file mode 100644 index 000000000000..747e318b876a --- /dev/null +++ b/tools/perf_smoke/store.py @@ -0,0 +1,408 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Create-only Azure baselines configured by ``ISAACLAB_BLOB_URL``. + +Each measurement occupies ``baselines/v1///-.json``. +Separate blobs isolate corrupt samples; deterministic names make CI retries idempotent +within a monthly partition. Credentials are read only from the environment. +""" + +from __future__ import annotations + +import heapq +import json +import os +import re +import sys +import time +from contextlib import closing +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import parse_qs, urlparse + +from .contract import from_dict +from .metrics import METRICS, PerfSmokeError, mapping, number + +BLOB_PREFIX = "baselines" +"""Root key prefix for stored measurements.""" + +LAYOUT_VERSION = "v1" +"""Storage layout generation.""" + +MAX_LOOKBACK_MONTHS = 3 +"""How many monthly partitions :func:`read` will walk back before giving up.""" + +BLOB_URL_ENV = "ISAACLAB_BLOB_URL" +"""Environment variable holding the container SAS URL. Never an argument.""" + +EXPIRY_WARNING_DAYS = 7 +"""Warn this far ahead of the SAS expiry, so the credential does not lapse silently.""" + +# These are the blob SDK's ExponentialRetry names, NOT azure-core's generic +# retry_backoff_factor/retry_backoff_max. The blob SDK substitutes its own retry policy, +# so the generic names are accepted and then silently discarded. +_RETRY_TOTAL = 3 +_RETRY_INITIAL_BACKOFF_S = 2 +_RETRY_INCREMENT_BASE_S = 2 +_CONNECTION_TIMEOUT_S = 10 +_READ_TIMEOUT_S = 30 +_TOTAL_BUDGET_S = 120 + +# Dots are excluded; no way to spell a ".." segment. +_SAFE_NAME = re.compile(r"[^0-9a-zA-Z_-]+") +_QUERY = re.compile(r"\?[^\s\"'<>]*") + + +def _warn(message: str) -> None: + """Emit a non-fatal warning that GitHub Actions surfaces as an annotation.""" + print(f"::warning::perf-smoke: {message}", file=sys.stderr) + + +def scrub(text: str) -> str: + """Redact query strings, which is where a SAS token lives, from ``text``.""" + return _QUERY.sub("?", text) + + +def is_configured() -> bool: + """Return whether a credential is available for the store.""" + return bool(os.environ.get(BLOB_URL_ENV, "").strip()) + + +@dataclass(frozen=True) +class BaselineRow: + """One recorded develop measurement for a single contract. + + Attributes: + contract: Serialised :class:`~tools.perf_smoke.contract.Contract`. + contract_hash: Digest of ``contract``, used as the storage key. + metrics: Measured value per metric name. + commit: Develop commit the measurement was taken on. + timestamp: ISO-8601 UTC timestamp of the run. + run_id: CI run identifier, for tracing a row back to its logs. + """ + + contract: dict[str, Any] + contract_hash: str + metrics: dict[str, float] + commit: str + timestamp: str + run_id: str + + def as_dict(self) -> dict[str, Any]: + """Return the row as a plain, JSON-serialisable dict.""" + return { + "contract": self.contract, + "contract_hash": self.contract_hash, + "metrics": dict(self.metrics), + "commit": self.commit, + "timestamp": self.timestamp, + "run_id": self.run_id, + } + + +def parse_row(data: Any, source: str) -> BaselineRow: + """Parse and validate one stored row. + + Args: + data: Decoded JSON object. + source: Human-readable origin, used in error messages. + + Returns: + The validated row. + + Raises: + PerfSmokeError: If required fields are missing or malformed. + """ + payload = mapping(data, source) + stored_metrics = mapping(payload.get("metrics"), f"{source}.metrics") + values: dict[str, float] = {} + for metric in METRICS: + # Rows written before a metric was added simply lack it. + if metric.name not in stored_metrics: + continue + try: + value = number(stored_metrics[metric.name], f"{source}.metrics.{metric.name}") + if value < 0: + raise PerfSmokeError(f"{source}.metrics.{metric.name} must be non-negative") + values[metric.name] = value + except PerfSmokeError as exc: + # Same treatment as an absent metric. A malformed advisory value must not + # cost us the gating one recorded beside it, which cannot be re-recorded. + _warn(f"ignoring metric in {source}: {exc}") + if not values: + raise PerfSmokeError(f"{source}.metrics contains no recognised metric") + + contract = from_dict(payload.get("contract")) + contract_hash = payload.get("contract_hash") + if contract.hash != contract_hash: + raise PerfSmokeError(f"{source}.contract_hash does not match its contract") + + return BaselineRow( + contract=contract.as_dict(), + contract_hash=contract_hash, + metrics=values, + commit=str(payload.get("commit", "")), + timestamp=str(payload.get("timestamp", "")), + run_id=str(payload.get("run_id", "")), + ) + + +def _slug(value: str, fallback: str) -> str: + """Reduce ``value`` to characters that are safe in a blob name.""" + cleaned = _SAFE_NAME.sub("-", value).strip("-") + return cleaned or fallback + + +def month_of(timestamp: str) -> str: + """Return the ``YYYY-MM`` partition for an ISO-8601 timestamp. + + Raises: + PerfSmokeError: If ``timestamp`` does not start with a calendar month. + """ + head = timestamp.strip()[:7] + try: + datetime.strptime(head, "%Y-%m") + except ValueError: + raise PerfSmokeError(f"timestamp {timestamp!r} must be ISO-8601, starting YYYY-MM") from None + return head + + +def _months_back(now: datetime, count: int) -> list[str]: + """Return ``count`` ``YYYY-MM`` partitions ending at ``now``, newest first.""" + months: list[str] = [] + year, month = now.year, now.month + for _ in range(count): + months.append(f"{year:04d}-{month:02d}") + month -= 1 + if month == 0: + year, month = year - 1, 12 + return months + + +def _contract_prefix(contract_hash: str) -> str: + """Return the key prefix holding every row for one contract.""" + return f"{BLOB_PREFIX}/{LAYOUT_VERSION}/{contract_hash}" + + +def _row_name(row: BaselineRow) -> str: + """Return the blob name for ``row``. + + The name is a pure function of commit and run id, so re-running the same CI job + reproduces it exactly and the ``overwrite=False`` write becomes idempotent. + """ + commit = _slug(row.commit, "unknown")[:12].lower() + run_id = _slug(row.run_id, "norun") + return f"{_contract_prefix(row.contract_hash)}/{month_of(row.timestamp)}/{commit}-{run_id}.json" + + +def _row_metadata(row: BaselineRow) -> dict[str, str]: + """Provenance visible in a listing without downloading. Never read back by code.""" + workload = row.contract.get("workload") if isinstance(row.contract, dict) else None + runtime = row.contract.get("runtime") if isinstance(row.contract, dict) else None + candidates = { + "contract_hash": row.contract_hash, + "commit": row.commit, + "run_id": row.run_id, + "uploaded_on": row.timestamp, + } + if isinstance(workload, dict): + candidates["task"] = str(workload.get("task", "")) + candidates["physics_backend"] = str(workload.get("physics_backend", "")) + # None means the run did no rendering; str() would write the word "None". + candidates["render_backend"] = str(workload.get("render_backend") or "") + if isinstance(runtime, dict): + candidates["gpu_model"] = str(runtime.get("gpu_model", "")) + # Keys are fixed identifiers; Azure requires ASCII metadata values. + metadata: dict[str, str] = {} + for key, value in candidates.items(): + value = value.encode("ascii", "ignore").decode("ascii").strip() + if value: + metadata[key] = value[:256] + return metadata + + +def make_container_client(sas_url: str) -> Any: + """Build an Azure container client from a container SAS URL. + + Isolated so the SDK construction, including the retry tuning above, has one call site. + + Raises: + PerfSmokeError: If ``azure-storage-blob`` is not available. + """ + try: + from azure.storage.blob import ContainerClient + except ImportError as exc: # pragma: no cover - depends on runner image + raise PerfSmokeError( + "the baseline store requires azure-storage-blob (see tools/perf_smoke/requirements.txt)" + ) from exc + try: + return ContainerClient.from_container_url( + sas_url, + retry_total=_RETRY_TOTAL, + initial_backoff=_RETRY_INITIAL_BACKOFF_S, + increment_base=_RETRY_INCREMENT_BASE_S, + connection_timeout=_CONNECTION_TIMEOUT_S, + read_timeout=_READ_TIMEOUT_S, + ) + except ValueError as exc: + raise PerfSmokeError(f"${BLOB_URL_ENV} is not a container SAS URL: {scrub(str(exc))}") from None + + +def sas_expiry(sas_url: str) -> datetime | None: + """Return the SAS expiry from the URL's ``se`` parameter, or ``None`` if absent.""" + raw = (parse_qs(urlparse(sas_url).query).get("se") or [""])[0] + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _check_expiry(sas_url: str, now: datetime) -> None: + """Fail on an expired credential, warn before it lapses.""" + expires = sas_expiry(sas_url) + if expires is None: + return + if expires <= now: + raise PerfSmokeError( + f"the {BLOB_URL_ENV} credential expired on {expires.date().isoformat()}; it must be reissued" + ) + remaining = expires - now + if remaining < timedelta(days=EXPIRY_WARNING_DAYS): + _warn(f"the {BLOB_URL_ENV} credential expires on {expires.date().isoformat()} ({remaining.days} days)") + + +def _describe(exc: Exception, action: str) -> str: + """Render a storage failure without leaking the SAS token.""" + status = getattr(exc, "status_code", None) + if status in (401, 403): + code = getattr(exc, "error_code", None) + detail = f" ({code})" if code else "" + return ( + f"{action} was denied by the storage account (HTTP {status}){detail}; the" + f" ${BLOB_URL_ENV} credential is expired, revoked, truncated, or lacks a needed permission" + ) + return f"{action} failed: {scrub(str(exc))}" + + +class _Container: + """The baseline container, addressed by the SAS URL in the environment.""" + + def __init__(self) -> None: + sas_url = os.environ.get(BLOB_URL_ENV, "").strip() + if not sas_url: + raise PerfSmokeError(f"the baseline store requires the container SAS URL in ${BLOB_URL_ENV}") + _check_expiry(sas_url, datetime.now(timezone.utc)) + self._client = make_container_client(sas_url) + + def close(self) -> None: + """Release the SDK's HTTP connection pool.""" + self._client.close() + + def list_names(self, prefix: str, limit: int) -> list[str]: + """Return at most ``limit`` newest keys under ``prefix``, oldest first.""" + from azure.core.exceptions import AzureError + + try: + blobs = heapq.nlargest( + limit, + self._client.list_blobs(name_starts_with=prefix), + key=lambda blob: (blob.last_modified, blob.name), + ) + except AzureError as exc: + raise PerfSmokeError(_describe(exc, f"listing {prefix}")) from None + return [blob.name for blob in reversed(blobs)] + + def read_text(self, name: str) -> str | None: + """Return UTF-8 contents, or ``None`` for missing or undecodable blobs.""" + from azure.core.exceptions import AzureError, ResourceNotFoundError + + try: + return self._client.download_blob(name, encoding="utf-8").readall() + except ResourceNotFoundError: + return None + except UnicodeDecodeError as exc: + _warn(f"skipping unreadable baseline row {name}: {exc}") + return None + except AzureError as exc: + raise PerfSmokeError(_describe(exc, f"reading {name}")) from None + + def create_text(self, name: str, text: str, metadata: dict[str, str]) -> bool: + """Create the blob. Return ``False`` if it already exists, never overwrite.""" + from azure.core.exceptions import AzureError, ResourceExistsError + + try: + self._client.upload_blob(name=name, data=text.encode("utf-8"), overwrite=False, metadata=metadata) + except ResourceExistsError: + return False + except AzureError as exc: + raise PerfSmokeError(_describe(exc, f"writing {name}")) from None + return True + + +def read(contract_hash: str, limit: int, now: datetime | None = None) -> list[BaselineRow]: + """Return the most recent rows recorded for ``contract_hash``. + + Walks monthly partitions backwards from ``now`` until ``limit`` rows are in hand or + :data:`MAX_LOOKBACK_MONTHS` is exhausted, then fetches only the rows it will use. + + Args: + contract_hash: Digest identifying poolable runs. + limit: Maximum number of rows to return, newest last. + now: Reference time for the month walk. Defaults to the current UTC time. + + Returns: + Up to ``limit`` rows in chronological order. Empty when the contract has never + been recorded, or when no sample falls inside the lookback window. + + Raises: + PerfSmokeError: If the store cannot be reached. + """ + if limit <= 0: + raise PerfSmokeError("limit must be a positive integer") + deadline = time.monotonic() + _TOTAL_BUDGET_S + base = _contract_prefix(contract_hash) + rows: list[BaselineRow] = [] + with closing(_Container()) as container: + names: list[str] = [] + for month in _months_back(now or datetime.now(timezone.utc), MAX_LOOKBACK_MONTHS): + if time.monotonic() > deadline: + raise PerfSmokeError(f"the baseline store did not respond within {_TOTAL_BUDGET_S}s") + names = container.list_names(f"{base}/{month}/", limit - len(names)) + names + if len(names) == limit: + break + + for name in names: + if time.monotonic() > deadline: + raise PerfSmokeError(f"the baseline store did not respond within {_TOTAL_BUDGET_S}s") + text = container.read_text(name) + if text is None: + continue + try: + rows.append(parse_row(json.loads(text), name)) + except (PerfSmokeError, json.JSONDecodeError) as exc: + _warn(f"skipping unreadable baseline row {name}: {exc}") + return rows + + +def write(row: BaselineRow) -> bool: + """Record one measurement, without ever overwriting an existing one. + + Args: + row: The measurement to record. + + Returns: + ``True`` when the row was created, ``False`` when an identical name exists. + + Raises: + PerfSmokeError: If the store cannot be reached or the row cannot be named. + """ + payload = json.dumps(row.as_dict(), sort_keys=True, separators=(",", ":")) + with closing(_Container()) as container: + return container.create_text(_row_name(row), payload, _row_metadata(row)) diff --git a/tools/perf_smoke/test_compare.py b/tools/perf_smoke/test_compare.py new file mode 100644 index 000000000000..c8fa27645cc4 --- /dev/null +++ b/tools/perf_smoke/test_compare.py @@ -0,0 +1,177 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Host-only integration tests exercising the real ASV comparison engine.""" + +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from . import cli, compare +from .contract import Contract +from .metrics import METRICS, PerfSmokeError +from .store import BaselineRow + + +def _measurement(fps: float, startup: float = 10.0) -> dict[str, float]: + return {"total_fps": fps, "startup_time_s": startup, "gpu_mem_peak_gb": 2.0, "ram_peak_gb": 4.0} + + +class TestAsvComparison(unittest.TestCase): + """Check the pinned ASV comparison and the policies retained around it.""" + + def setUp(self): + self.contract = Contract( + workload={"task": "task", "physics_backend": "physx", "render_backend": None}, + runtime={"gpu_model": "l40s"}, + ) + self.policy = {"defaults": {"warn_regression_pct": 5, "fail_regression_pct": 10}} + + def evaluate(self, baseline, candidate): + measurements = [_measurement(value) for value in candidate] + return compare.compare( + self.contract, + measurements, + [_measurement(value) for value in baseline], + self.policy, + ) + + def test_relative_regressions_and_improvement(self): + # Three baseline samples exercise the CI fallback; twenty permit ASV's + # Mann–Whitney test. FPS equality also checks the threshold factor. + for count in (3, 20): + for fps, expected in ( + (100, compare.PASS), + (120, compare.PASS), + (95, compare.PASS), + (93, compare.WARN), + (90, compare.WARN), + (80, compare.FAIL), + ): + with self.subTest(count=count, fps=fps): + result = self.evaluate([100] * count, [fps] * 3) + self.assertEqual(result.verdict, expected) + + for baseline, candidate, expected in ( + ([60, 100, 140], [50, 80, 110], compare.PASS), + # An even-sample median must not change when reversing FPS comparisons. + ([99] * 10 + [101] * 10, [89.995] * 3, compare.FAIL), + ([100] * 3, [0] * 3, compare.FAIL), + ([0] * 3, [100] * 3, compare.SKIP), + ): + with self.subTest(baseline=baseline, candidate=candidate): + self.assertEqual(self.evaluate(baseline, candidate).verdict, expected) + + def test_insufficient_independent_samples_skip(self): + for baseline, candidate in (([], [80] * 3), ([100] * 2, [80] * 3), ([100] * 3, [80])): + with self.subTest(baseline=baseline, candidate=candidate): + self.assertEqual(self.evaluate(baseline, candidate).verdict, compare.SKIP) + + def test_hard_floor_gates_without_history_even_for_advisory_task(self): + self.policy.update( + { + "per_task_regression_pct": {"task": {"advisory_only": True}}, + "hard_floor_fps": {"l40s": {"task": {"physx": 50}}}, + } + ) + result = self.evaluate([], [0, 100, 100]) + self.assertEqual(result.verdict, compare.FAIL) + self.assertIn("hard floor", result.metrics[0].note) + self.assertIn("hard floor", result.message) + + def test_hard_floor_respects_metric_direction(self): + thresholds = compare.Thresholds(warn_pct=5, fail_pct=10, hard_floor=15, gating=False) + breached = compare._evaluate(METRICS[1], [10, 20, 10], [], thresholds, min_samples=3) + within_limit = compare._evaluate(METRICS[1], [10, 10, 10], [], thresholds, min_samples=3) + + self.assertEqual(breached.verdict, compare.FAIL) + self.assertTrue(breached.gating) + self.assertEqual(breached.note, "above hard floor 15") + self.assertEqual(within_limit.verdict, compare.SKIP) + self.assertFalse(within_limit.gating) + + def test_advisory_task_does_not_gate(self): + self.policy["per_task_regression_pct"] = {"task": {"advisory_only": True}} + result = self.evaluate([100] * 3, [80] * 3) + self.assertEqual(result.verdict, compare.SKIP) + self.assertEqual(result.metrics[0].verdict, compare.FAIL) + + def test_higher_is_worse_metrics_are_advisory(self): + measurements = [_measurement(100, startup=20)] * 3 + result = compare.compare( + self.contract, + measurements, + [_measurement(100)] * 3, + self.policy, + ) + self.assertEqual(result.verdict, compare.PASS) + self.assertEqual(result.metrics[1].verdict, compare.FAIL) + + def test_cli_filters_contracts_and_preserves_failure_in_aggregate(self): + matching = BaselineRow(self.contract.as_dict(), self.contract.hash, _measurement(100), "commit", "date", "run") + other = Contract(workload={"task": "other"}, runtime={}) + mismatched = BaselineRow(other.as_dict(), self.contract.hash, _measurement(1), "commit", "date", "run") + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + bundle = root / "bundle.json" + bundle.write_text("{}") + output = root / "combination" / "comparison.json" + with ( + contextlib.redirect_stdout(io.StringIO()), + patch.object(cli.contract_mod, "build", return_value=self.contract), + patch.object(cli.metrics_mod, "extract", return_value=_measurement(80)), + patch.object(cli.store_mod, "is_configured", return_value=True), + patch.object(cli.store_mod, "read", return_value=[matching] * 3 + [mismatched] * 10), + ): + status = cli.main( + [ + "compare", + "--benchmark_result", + str(bundle), + str(bundle), + str(bundle), + "--output_json", + str(output), + ] + ) + self.assertEqual(status, 1) + self.assertEqual(json.loads(output.read_text())["verdict"], compare.FAIL) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(cli.main(["aggregate", "--comparison_dir", directory]), 1) + + def test_cli_infrastructure_errors_and_missing_credentials_do_not_gate(self): + for configured, expected in ((False, compare.SKIP), (True, compare.ERROR)): + with self.subTest(configured=configured), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + bundle = root / "bundle.json" + bundle.write_text("{}") + output = root / "comparison.json" + with ( + contextlib.redirect_stdout(io.StringIO()), + contextlib.redirect_stderr(io.StringIO()), + patch.object(cli.contract_mod, "build", return_value=self.contract), + patch.object(cli.metrics_mod, "extract", return_value=_measurement(80)), + patch.object(cli.store_mod, "is_configured", return_value=configured), + patch.object(cli.store_mod, "read", side_effect=PerfSmokeError("unavailable")), + ): + status = cli.main( + [ + "compare", + "--benchmark_result", + str(bundle), + "--output_json", + str(output), + ] + ) + self.assertEqual(status, 0) + self.assertEqual(json.loads(output.read_text())["verdict"], expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/perf_smoke_thresholds.json b/tools/perf_smoke_thresholds.json new file mode 100644 index 000000000000..e3d07e53c39b --- /dev/null +++ b/tools/perf_smoke_thresholds.json @@ -0,0 +1,45 @@ +{ + "_comment": "Perf-smoke gate POLICY. Thresholds live here, in git, so changing what fails a PR is reviewable and has history. Measured reference values (median, spread, sample count) live in the baseline store instead, because those are measurements and should not require a PR to update. Keys are gpu_model (canonical slug) -> task_id -> backend_key, where backend_key is '{physics}' or '{physics}_{render}'. hard_floor_fps is a catastrophic-failure detector, NOT a regression threshold: it should sit far below any plausible healthy value so it only fires when a run is fundamentally broken. Regression percentages are compared against the baseline median.", + "schema_version": 1, + "defaults": { + "warn_regression_pct": 5.0, + "fail_regression_pct": 10.0, + "_todo": "Both defaults are placeholders. Set them from measured run-to-run spread on the real L40S runners, not from the desktop RTX 5090 sweep in temp/matrix_run (that host showed 13% CV on Cartpole, which is likely runner-specific)." + }, + "per_task_regression_pct": { + "_comment": "Overrides for tasks whose run-to-run noise makes the default threshold unusable. A threshold below the noise floor fires on noise; a threshold far above it never fires at all. Measured by benchmarking six real develop commits inside one pin window, sequentially, on one machine (temp/threshold_probe).", + "IsaacContrib-Factory-GearMesh-Direct": { + "_comment": "Commit-to-commit CV 2.3% across six develop commits (1194-1277 fps), against 1.18% pure run-to-run CV. Real drift is small and the default 5%/10% is defensible here. This is the sensitive probe: use it, not Cartpole, to detect small regressions." + }, + "Isaac-Cartpole-Direct": { + "advisory_only": true, + "_comment": "Keep Cartpole advisory-only while spread is confirmed on runners." + } + }, + "hard_floor_fps": { + "l40s": { + "Isaac-Cartpole-Direct": { + "physx": 100.0, + "newton_mjwarp": 0.0, + "_todo": "newton floor is 0.0 (disabled). RTX 5090 sweep measured ~887,748 fps mean. Pick a floor ~an order of magnitude below the L40S healthy value so it catches a broken run without tracking performance." + }, + "IsaacContrib-Factory-GearMesh-Direct": { + "physx": 30.0 + }, + "IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct": { + "physx_isaacsim_rtx": 20.0, + "physx_newton": 0.0, + "newton_mjwarp_isaacsim_rtx": 0.0, + "newton_mjwarp_newton": 0.0, + "ovphysx_newton": 0.0, + "_todo": "Four of five floors are 0.0 (disabled)." + }, + "Isaac-Velocity-Flat-G1": { + "physx": 40.0, + "newton_mjwarp": 0.0, + "_todo": "newton floor is 0.0 (disabled). RTX 5090 sweep measured ~26,936 fps mean." + } + }, + "_comment": "Keys are gpu_model, then task, then backend key. The backend key is the physics backend, or physics_render when a renderer is in use, spelled exactly as the benchmark reports it: physx, newton_mjwarp, physx_isaacsim_rtx, newton_mjwarp_newton. A key that matches nothing never applies." + } +}