# 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
"""Backend-agnostic random-action stepping helpers for benchmarks.
This module is intentionally lightweight: ``torch`` and ``numpy`` are
imported lazily inside each function so that importing this module has
no heavy-weight side effects.
"""
from __future__ import annotations
import time
from collections.abc import Iterator
from contextlib import AbstractContextManager, contextmanager
from functools import wraps
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import torch
from ..physics import PhysicsManager
from ..renderers.render_context import RenderContext
from ..utils.string import ResolvableString
from .schema import MeanStd
PHYSICS_PROFILE_SCOPE = "IsaacLab::Physics::step"
"""Scope name for benchmark physics-step timings [ms]."""
RENDER_PROFILE_SCOPE = "IsaacLab::Renderer::render"
"""Scope name for benchmark render timings [ms], excluding scene updates and output readback."""
[docs]
@contextmanager
def profile_renderers(
render_context: RenderContext, *, active: bool = True, timings: list[tuple[str, float]] | None = None
) -> Iterator[list[tuple[str, float]]]:
"""Temporarily time the benchmark's currently registered renderers.
Original methods are restored when the context exits, including on failure.
Enabled timings synchronize device work on entry and exit and perturb throughput.
Args:
render_context: Simulation rendering context whose renderers will be timed.
active: Whether to install the timing wrappers.
timings: Shared list of scope names and elapsed times [ms], in call order.
A new list is created if omitted. Timings are collected without printing.
Yields:
The list populated by the wrappers, including timings of calls that raise.
"""
if timings is None:
timings = []
if not active:
yield timings
return
import warp as wp # noqa: PLC0415
scope_timings = {RENDER_PROFILE_SCOPE: _ProfileScopeTimings(RENDER_PROFILE_SCOPE, timings)}
missing = object()
originals = []
try:
for _, renderer in render_context._renderer_entries:
render = renderer.render_batch
original = vars(renderer).get("render_batch", missing)
@wraps(render)
def timed_render(render_data: Any, _render=render) -> None:
with wp.ScopedTimer(RENDER_PROFILE_SCOPE, dict=scope_timings, print=False, synchronize=True):
return _render(render_data)
renderer.render_batch = timed_render
originals.append((renderer, original))
yield timings
finally:
for renderer, original in reversed(originals):
if original is missing:
del renderer.render_batch
else:
renderer.render_batch = original
[docs]
@contextmanager
def profile_physics_steps(
physics_manager: type[PhysicsManager] | ResolvableString,
*,
active: bool = True,
timings: list[tuple[str, float]] | None = None,
) -> Iterator[list[tuple[str, float]]]:
"""Temporarily time the benchmark's selected physics manager.
Only the selected manager is wrapped, so inherited ``super().step()`` calls
are included in one timing record. The original class method is restored when
the context exits, including on failure.
Enabled timings synchronize device work on entry and exit and perturb throughput.
Args:
physics_manager: Concrete physics manager selected by the environment, or its lazy class reference.
active: Whether to install the timing wrapper.
timings: Shared list of scope names and elapsed times [ms], in call order.
A new list is created if omitted. Timings are collected without printing.
Yields:
The list populated by the wrapper, including timings of calls that raise.
"""
if timings is None:
timings = []
if not active:
yield timings
return
import warp as wp # noqa: PLC0415
# The bound method identifies the concrete class even through a lazy class reference.
physics_manager = physics_manager.step.__self__
step = physics_manager.step.__func__
missing = object()
original = vars(physics_manager).get("step", missing)
scope_timings = {PHYSICS_PROFILE_SCOPE: _ProfileScopeTimings(PHYSICS_PROFILE_SCOPE, timings)}
@wraps(step)
def timed_step(cls: type[PhysicsManager]) -> None:
with wp.ScopedTimer(PHYSICS_PROFILE_SCOPE, dict=scope_timings, print=False, synchronize=True):
return step(cls)
physics_manager.step = classmethod(timed_step)
try:
yield timings
finally:
if original is missing:
del physics_manager.step
else:
physics_manager.step = original
def sample_random_actions(env) -> torch.Tensor | dict[str, torch.Tensor]:
"""Sample random actions for a single-agent or multi-agent environment.
For multi-agent environments (those where ``env.unwrapped`` exposes an
``action_spaces`` attribute), one batch of actions is sampled per agent
using that agent's action space. For single-agent environments a uniform
sample in [-1, 1] is returned.
Args:
env: A Gym-compatible environment wrapper. ``env.unwrapped`` must
expose ``num_envs`` and ``device``, plus either ``action_spaces``
(multi-agent) or ``single_action_space`` (single-agent).
Returns:
A ``torch.Tensor`` of shape ``(num_envs, action_dim)`` for
single-agent environments, or a ``dict`` mapping agent name to a
tensor of the same shape for multi-agent environments.
"""
import numpy as np # noqa: PLC0415
import torch # noqa: PLC0415
u = env.unwrapped
if hasattr(u, "action_spaces"):
# Multi-agent: sample each agent's action space independently.
return {
agent: torch.as_tensor(
np.stack([space.sample() for _ in range(u.num_envs)]),
dtype=torch.float32,
device=u.device,
)
for agent, space in u.action_spaces.items()
}
else:
# Single-agent: uniform random actions in [-1, 1].
return 2.0 * torch.rand(u.num_envs, u.single_action_space.shape[0], device=u.device) - 1.0
def _find_cuda_devices(value) -> set[str]:
"""Collect CUDA device names from nested tensor-like values."""
import torch # noqa: PLC0415
devices: set[str] = set()
def collect(item) -> None:
if isinstance(item, dict):
for nested in item.values():
collect(nested)
return
if isinstance(item, (list, tuple)):
for nested in item:
collect(nested)
return
device = item if isinstance(item, (str, torch.device)) else getattr(item, "device", None)
if device is None:
return
try:
device = torch.device(device)
except (RuntimeError, TypeError):
return
if device.type == "cuda":
devices.add(str(device))
collect(value)
return devices
class EnvironmentStepTimingRecorder(AbstractContextManager):
"""Record host-return step time or an optional synchronized step breakdown.
By default, this context manager records host wall time until ``env.step()``
returns without forcing queued device work to complete. When synchronized
breakdown is requested, it also intercepts ``SimulationContext.step()``,
drains pending work before every measured boundary, and uses
:class:`isaaclab.utils.timer.Timer` to synchronize completion.
The synchronized mode serializes execution and perturbs throughput. Its
arithmetic remainder is time outside simulation calls in that instrumented
schedule. The remainder includes required task, manager, reset, wrapper,
and synchronization work; it is not removable Isaac Lab overhead.
Args:
env: Environment interface whose ``step`` method is called by the workload.
measure_synchronized_step_breakdown: Whether to collect the serialized
synchronized simulation and outside-simulation breakdown.
warmup_steps: Number of initial ``env.step()`` calls to exclude from the
recorded timings. Drops one-time cold-start costs (e.g. CUDA graph
capture and lazy kernel compilation) so the aggregate reflects
steady-state stepping. Defaults to ``0`` (record every step).
"""
def __init__(self, env, *, measure_synchronized_step_breakdown: bool = False, warmup_steps: int = 0):
self._env = env
self._measure_synchronized_step_breakdown = measure_synchronized_step_breakdown
self._warmup_steps = warmup_steps
self._simulation_context = env.unwrapped.sim if measure_synchronized_step_breakdown else None
self._had_env_instance_step = "step" in vars(env)
self._env_instance_step = vars(env).get("step")
self._had_sim_instance_step = measure_synchronized_step_breakdown and "step" in vars(self._simulation_context)
self._sim_instance_step = (
vars(self._simulation_context).get("step") if measure_synchronized_step_breakdown else None
)
self._original_env_step = None
self._original_sim_step = None
self._simulation_total_time_s = 0.0
self._simulation_step_calls = 0
self._inside_environment_step = False
self._environment_step_index = 0
self.step_times_s: list[float] = []
self.simulation_step_times_s: list[float] | None = [] if measure_synchronized_step_breakdown else None
@property
def simulation_step_calls(self) -> int | None:
"""Number of measured simulation-step calls."""
return self._simulation_step_calls if self._measure_synchronized_step_breakdown else None
def __enter__(self) -> EnvironmentStepTimingRecorder:
"""Install the recording wrappers and reset measurements."""
if self._original_env_step is not None:
raise RuntimeError("EnvironmentStepTimingRecorder is already active")
self.step_times_s.clear()
self._environment_step_index = 0
self._original_env_step = self._env.step
if self._measure_synchronized_step_breakdown:
import torch # noqa: PLC0415
from ..utils.timer import Timer # noqa: PLC0415
assert self.simulation_step_times_s is not None
self.simulation_step_times_s.clear()
self._inside_environment_step = False
self._simulation_total_time_s = 0.0
self._simulation_step_calls = 0
assert self._simulation_context is not None
self._original_sim_step = self._simulation_context.step
environment_cuda_devices = _find_cuda_devices(getattr(self._env.unwrapped, "device", None))
active_cuda_devices = environment_cuda_devices
def synchronize_torch(devices: set[str]) -> None:
for device in sorted(devices):
torch.cuda.synchronize(device)
def timed_simulation_step(*args, **kwargs):
if not self._inside_environment_step:
return self._original_sim_step(*args, **kwargs)
synchronize_torch(active_cuda_devices)
timer = Timer(synchronize="both")
timer.start()
try:
return self._original_sim_step(*args, **kwargs)
finally:
synchronize_torch(active_cuda_devices)
timer.stop()
self._simulation_total_time_s += timer.total_run_time
self._simulation_step_calls += 1
self._simulation_context.step = timed_simulation_step
def timed_environment_step(*args, **kwargs):
nonlocal active_cuda_devices
recording = self._environment_step_index >= self._warmup_steps
self._environment_step_index += 1
simulation_start_time_s = self._simulation_total_time_s
simulation_start_calls = self._simulation_step_calls
previous_cuda_devices = active_cuda_devices
active_cuda_devices = environment_cuda_devices | _find_cuda_devices(args) | _find_cuda_devices(kwargs)
synchronize_torch(active_cuda_devices)
timer = Timer(synchronize="both")
timer.start()
self._inside_environment_step = True
try:
return self._original_env_step(*args, **kwargs)
finally:
self._inside_environment_step = False
synchronize_torch(active_cuda_devices)
timer.stop()
active_cuda_devices = previous_cuda_devices
if recording:
self.step_times_s.append(timer.total_run_time)
self.simulation_step_times_s.append(self._simulation_total_time_s - simulation_start_time_s)
else:
# Discard warmup-step accounting so recorded calls and times stay consistent.
self._simulation_total_time_s = simulation_start_time_s
self._simulation_step_calls = simulation_start_calls
else:
def timed_environment_step(*args, **kwargs):
recording = self._environment_step_index >= self._warmup_steps
self._environment_step_index += 1
start_time_ns = time.perf_counter_ns()
try:
return self._original_env_step(*args, **kwargs)
finally:
if recording:
self.step_times_s.append((time.perf_counter_ns() - start_time_ns) / 1e9)
self._env.step = timed_environment_step
return self
def __exit__(self, exc_type, exc_value, traceback) -> None:
"""Restore the original methods."""
if self._original_env_step is not None:
if self._had_env_instance_step:
self._env.step = self._env_instance_step
else:
del self._env.step
self._original_env_step = None
if self._original_sim_step is not None:
if self._had_sim_instance_step:
self._simulation_context.step = self._sim_instance_step
else:
del self._simulation_context.step
self._original_sim_step = None
def run_runtime_loop(env, num_steps: int, *, reset: bool = True) -> list[float]:
"""Step the environment ``num_steps`` times and record per-step wall times [s].
Optionally calls ``env.reset()`` once before the loop, then on each frame
samples random actions via :func:`sample_random_actions`, steps the
environment, and records the elapsed wall-clock time for that step. Reset,
action sampling, and environment stepping run under
``torch.inference_mode()``.
Args:
env: A Gym-compatible environment.
num_steps: Number of environment steps to run.
reset: Whether to reset the environment before stepping.
Returns:
A list of length ``num_steps`` containing per-step wall times [s].
"""
import torch # noqa: PLC0415
step_times: list[float] = []
with torch.inference_mode():
if reset:
env.reset()
for _ in range(num_steps):
actions = sample_random_actions(env)
t0 = time.perf_counter_ns()
env.step(actions)
t1 = time.perf_counter_ns()
step_times.append((t1 - t0) / 1e9)
return step_times
def run_runtime_warmup(env, num_steps: int) -> list[float]:
"""Run exactly ``num_steps`` excluded warmup steps.
Args:
env: A Gym-compatible environment.
num_steps: Requested number of warmup environment steps.
Returns:
Per-step wall times [s] for the requested excluded steps.
"""
return run_runtime_loop(env, num_steps)
def _extract_success(extras) -> float | None:
"""Pull a scalar success value out of a step's ``extras`` mapping.
Scans the ``"log"`` and ``"episode"`` sub-dicts of *extras* for the first
key whose name contains ``"success"`` (case-insensitive) and returns its
value as a ``float`` (calling ``.item()`` when the value is a tensor).
Returns ``None`` when *extras* is not a dict (e.g. the per-env list of info
dicts that Stable-Baselines3 vec envs return), since no single scannable
mapping is available in that case.
Args:
extras: The per-step ``extras``/``info`` value returned by ``env.step``.
Returns:
The success value as a ``float``, or ``None`` when no success key is
present.
"""
if not isinstance(extras, dict):
return None
for sub_key in ("log", "episode"):
sub = extras.get(sub_key)
if not isinstance(sub, dict):
continue
for key, value in sub.items():
if "success" in key.lower():
return float(value.item()) if hasattr(value, "item") else float(value)
return None
def run_play_loop(env, policy, num_steps: int) -> tuple[list[float], MeanStd | None, MeanStd | None, float | None]:
"""Roll out *policy* in *env* for *num_steps* steps and aggregate episode metrics.
Resets the environment, then on each frame runs the policy and steps the
environment under ``torch.inference_mode()``, recording the per-step wall
time [s]. Per-environment returns and lengths are accumulated and, whenever
an environment signals ``done``, that episode's return, length, and (if
present) success value are recorded and the environment's accumulators are
reset.
Both the four-tuple ``(obs, reward, dones, extras)`` and the Gym five-tuple
``(obs, reward, terminated, truncated, info)`` step signatures are accepted;
for the latter ``dones`` is ``terminated | truncated`` and ``extras`` is
``info``. Rewards and dones are coerced via ``torch.as_tensor`` so NumPy
returns (e.g. from Stable-Baselines3) work as well.
Args:
env: A Gym-compatible environment whose ``unwrapped`` exposes
``num_envs`` and ``device``.
policy: Callable mapping an observation batch to an action batch.
num_steps: Number of environment steps to run.
Returns:
A tuple ``(step_times, reward, ep_length, success_rate)`` where
``step_times`` is the per-step wall times [s], ``reward`` and
``ep_length`` are :class:`~isaaclab.benchmark.schema.MeanStd`
aggregates over completed episodes (or ``None`` if none completed), and
``success_rate`` is the mean of collected success values rounded to four
decimals (or ``None`` if none were reported).
"""
import torch # noqa: PLC0415
from .metrics import mean_std_peak # noqa: PLC0415
u = env.unwrapped
num_envs = u.num_envs
device = u.device
obs = env.reset()
if isinstance(obs, tuple):
obs = obs[0]
running_return = torch.zeros(num_envs, device=device)
running_length = torch.zeros(num_envs, device=device)
step_times: list[float] = []
episode_returns: list[float] = []
episode_lengths: list[float] = []
successes: list[float] = []
for _ in range(num_steps):
t0 = time.perf_counter_ns()
with torch.inference_mode():
actions = policy(obs)
result = env.step(actions)
t1 = time.perf_counter_ns()
step_times.append((t1 - t0) / 1e9)
if len(result) == 5:
obs, reward, terminated, truncated, extras = result
dones = torch.as_tensor(terminated, device=device) | torch.as_tensor(truncated, device=device)
else:
obs, reward, dones, extras = result
dones = torch.as_tensor(dones, device=device)
reward = torch.as_tensor(reward, dtype=torch.float32, device=device)
# Flatten reward/done to (num_envs,) — some wrappers (e.g. skrl) return shape (num_envs, 1).
reward = reward.reshape(num_envs)
dones = dones.reshape(num_envs)
running_return += reward
running_length += 1.0
done_mask = dones.to(torch.bool)
if bool(done_mask.any()):
success_value = _extract_success(extras)
for env_idx in torch.nonzero(done_mask, as_tuple=False).flatten().tolist():
episode_returns.append(float(running_return[env_idx].item()))
episode_lengths.append(float(running_length[env_idx].item()))
if success_value is not None:
successes.append(success_value)
running_return[env_idx] = 0.0
running_length[env_idx] = 0.0
reward_agg = mean_std_peak(episode_returns) if episode_returns else None
ep_length_agg = mean_std_peak(episode_lengths) if episode_lengths else None
success_rate = round(sum(successes) / len(successes), 4) if successes else None
return step_times, reward_agg, ep_length_agg, success_rate
class _ProfileScopeTimings(list[float]):
"""Keep Warp's per-scope timings [ms] in a shared sequence for frame grouping."""
def __init__(self, scope: str, timings: list[tuple[str, float]]):
super().__init__()
self._scope = scope
self._timings = timings
def append(self, elapsed_ms: float) -> None:
"""Record one scope timing [ms] in completion order."""
super().append(elapsed_ms)
self._timings.append((self._scope, elapsed_ms))