# 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
import logging
import os
import time
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from isaaclab.benchmark import formatters
from isaaclab.benchmark.formatters import get_default_output_filename
from isaaclab.benchmark.interfaces import MeasurementDataRecorder
from isaaclab.benchmark.measurements import (
DictMetadata,
FloatMetadata,
IntMetadata,
ListMeasurement,
Measurement,
MetadataBase,
SingleMeasurement,
StringMetadata,
TestPhase,
)
from isaaclab.benchmark.recorders import CPUInfoRecorder, GPUInfoRecorder, MemoryInfoRecorder, VersionInfoRecorder
from isaaclab.utils import has_kit
if TYPE_CHECKING:
from isaaclab.benchmark.schema import (
LearningCurve,
MeanStd,
PlayBundle,
Runtime,
RuntimeBundle,
StartupBundle,
TrainingBundle,
)
logger = logging.getLogger(__name__)
# Valid measurement and metadata class names (to support both isaaclab and isaacsim types)
_MEASUREMENT_CLASS_NAMES = {
"Measurement",
"SingleMeasurement",
"StatisticalMeasurement",
"BooleanMeasurement",
"DictMeasurement",
"ListMeasurement",
}
_METADATA_CLASS_NAMES = {"MetadataBase", "StringMetadata", "IntMetadata", "FloatMetadata", "DictMetadata"}
def _is_measurement_type(obj: object) -> bool:
"""Check if object is a measurement type by class name (supports isaacsim types)."""
return type(obj).__name__ in _MEASUREMENT_CLASS_NAMES
def _is_metadata_type(obj: object) -> bool:
"""Check if object is a metadata type by class name (supports isaacsim types)."""
return type(obj).__name__ in _METADATA_CLASS_NAMES
def _stat_measurements(name: str, stats: "MeanStd", unit: str, scale: float = 1.0) -> list[Measurement]:
"""Convert a schema aggregate to flat scalar measurements."""
measurements: list[Measurement] = [
SingleMeasurement(name=f"Mean {name}", value=stats.mean * scale, unit=unit),
SingleMeasurement(name=f"Std {name}", value=stats.std * scale, unit=unit),
]
if stats.peak is not None:
measurements.append(SingleMeasurement(name=f"Max {name}", value=stats.peak * scale, unit=unit))
return measurements
def _runtime_measurements(runtime: "Runtime") -> dict[str, list[Measurement]]:
"""Convert schema runtime metrics to startup and runtime phases."""
startup_fields = (
("app_launch", "App Launch Time"),
("python_imports", "Python Imports Time"),
("task_config", "Task Creation and Start Time"),
("env_creation", "Scene Creation Time"),
("first_step", "Simulation Start Time"),
)
startup = [
SingleMeasurement(name=label, value=value * 1000.0, unit="ms")
for field, label in startup_fields
if (value := getattr(runtime.startup_time_s, field)) is not None
]
if startup:
startup.append(
SingleMeasurement(
name="Total Start Time (Launch to Train)",
value=sum(float(measurement.value) for measurement in startup),
unit="ms",
)
)
timing = runtime.environment_step_timing
serialized_diagnostic = timing is not None and timing.measurement_mode == "serialized_synchronized"
metric_prefix = "Serialized Diagnostic " if serialized_diagnostic else ""
runtime_metrics: list[Measurement] = [
SingleMeasurement(name="Iterations Completed", value=runtime.iterations_completed, unit="count"),
SingleMeasurement(name=f"{metric_prefix}Total Wall Time", value=runtime.total_wall_time_s, unit="s"),
SingleMeasurement(name="Steps per Iteration", value=runtime.steps_per_iteration, unit="frames"),
]
runtime_metrics.extend(_stat_measurements(f"{metric_prefix}Iteration Time", runtime.iteration_time_s, "ms", 1000.0))
runtime_metrics.extend(_stat_measurements(f"{metric_prefix}Collection FPS", runtime.collection_fps, "FPS"))
runtime_metrics.extend(_stat_measurements(f"{metric_prefix}Total FPS", runtime.total_fps, "FPS"))
if timing is not None:
step_rate_label = (
"Environment Step Host-Return FPS"
if timing.measurement_mode == "host_return"
else "Serialized Synchronized Environment Step FPS"
)
runtime_metrics.extend(_stat_measurements(step_rate_label, timing.environment_step_fps, "FPS"))
if timing.simulation_step_time_s is not None:
assert timing.outside_simulation_step_time_s is not None
assert timing.outside_simulation_step_fraction is not None
runtime_metrics.extend(
_stat_measurements(
"Synchronized Simulation Time per Environment Step",
timing.simulation_step_time_s,
"ms",
1000.0,
)
)
runtime_metrics.extend(
_stat_measurements(
"Outside Simulation Time per Environment Step",
timing.outside_simulation_step_time_s,
"ms",
1000.0,
)
)
runtime_metrics.append(
SingleMeasurement(
name="Outside Simulation Step Fraction",
value=timing.outside_simulation_step_fraction,
unit="ratio",
)
)
runtime_metrics.extend(
_stat_measurements(f"{metric_prefix}Iterations per Second", runtime.iterations_per_s, "iterations/s")
)
return {"startup": startup, "runtime": runtime_metrics}
def _curve_measurements(label: str, curve: "LearningCurve", ema_alpha: float) -> list[Measurement]:
"""Convert one training curve to scalar and optional series measurements."""
measurements: list[Measurement] = [
SingleMeasurement(name=f"Last {label}", value=curve.final_raw, unit="float"),
SingleMeasurement(name=f"EMA {ema_alpha:g} {label}", value=curve.final_ema, unit="float"),
]
if curve.series_per_iter is not None:
plural = "Rewards" if label == "Reward" else "Episode Lengths"
measurements.append(ListMeasurement(name=plural, value=curve.series_per_iter))
if curve.series_per_iter:
measurements.append(SingleMeasurement(name=f"Max {plural}", value=max(curve.series_per_iter), unit="float"))
return measurements
def _measurements_from_bundle(
bundle: "RuntimeBundle | TrainingBundle | StartupBundle | PlayBundle",
) -> dict[str, list[Measurement]]:
"""Project a typed bundle into flat phases for non-schema formatters."""
from isaaclab.benchmark.schema import PlayBundle, StartupBundle, TrainingBundle
if isinstance(bundle, StartupBundle):
projected: dict[str, list[Measurement]] = {}
for phase_name, phase in bundle.phases.items():
measurements: list[Measurement] = [
SingleMeasurement(name="Wall Clock Time", value=phase.total_time_s, unit="s")
]
for function in phase.top_functions:
measurements.extend(
[
SingleMeasurement(name=f"{function.name} Own Time", value=function.own_time_s, unit="s"),
SingleMeasurement(name=f"{function.name} Cumulative Time", value=function.cum_time_s, unit="s"),
SingleMeasurement(name=f"{function.name} Calls", value=function.calls, unit="count"),
]
)
projected[phase_name] = measurements
return projected
projected = _runtime_measurements(bundle.runtime)
if isinstance(bundle, TrainingBundle):
train = _curve_measurements("Reward", bundle.learning.reward, bundle.learning.ema_alpha)
train.extend(_curve_measurements("Episode Length", bundle.learning.ep_length, bundle.learning.ema_alpha))
if bundle.success_rate is not None:
train.append(SingleMeasurement(name="success_rate", value=bundle.success_rate, unit="float"))
projected["train"] = train
elif isinstance(bundle, PlayBundle):
play: list[Measurement] = []
if bundle.reward is not None:
play.extend(_stat_measurements("Reward", bundle.reward, "float"))
if bundle.ep_length is not None:
play.extend(_stat_measurements("Episode Length", bundle.ep_length, "steps"))
if bundle.success_rate is not None:
play.append(SingleMeasurement(name="success_rate", value=bundle.success_rate, unit="float"))
if play:
projected["play"] = play
return projected
[docs]
class BaseIsaacLabBenchmark:
"""Base benchmark class for IsaacLab's benchmarks."""
[docs]
def __init__(
self,
benchmark_name: str,
formatter_type: str | list[str] | None = None,
output_path: str | None = None,
use_recorders: bool = True,
output_prefix: str | None = None,
workflow_metadata: dict | None = None,
frametime_recorders: bool = False,
backend_type: str | list[str] | None = None,
):
"""Initialize common benchmark state and recorders.
Args:
benchmark_name: Name of benchmark to use in outputs.
formatter_type: Formatter(s) used to collect and print metrics. Accepts a single
type name, a list of type names, or a comma-separated string (e.g.
``"schema,omniperf"``); each selected formatter writes its own output file.
output_path: Path to output directory.
use_recorders: Whether to use recorders to collect metrics. Defaults to True.
output_prefix: Prefix used to generate the output filename. Defaults to ``None``.
workflow_metadata: Metadata describing benchmark, defaults to None.
frametime_recorders: Whether to use frametime recorders to collect metrics. Defaults to ``False``.
backend_type: Alias for :paramref:`formatter_type`.
"""
if formatter_type is None:
formatter_type = backend_type or "omniperf"
elif backend_type is not None and backend_type != formatter_type:
raise ValueError("Specify either formatter_type or backend_type, not both.")
if output_path is None:
raise ValueError("output_path must be provided.")
self.benchmark_name = benchmark_name
if not os.path.exists(output_path):
try:
os.makedirs(output_path)
except Exception as e:
raise ValueError(f"Could not create output directory {output_path}: {e}")
self.output_path = output_path
if output_prefix is None:
output_prefix = "benchmark"
logger.warning("No output prefix provided, using default prefix: benchmark")
self.output_prefix = get_default_output_filename(output_prefix)
if isinstance(formatter_type, str):
formatter_type = [t.strip() for t in formatter_type.split(",") if t.strip()] or ["omniperf"]
formatter_type = list(dict.fromkeys(formatter_type))
logger.info("Using metrics formatters = %s", formatter_type)
self._metrics = [(t, formatters.MetricsFormatter.get_instance(instance_type=t)) for t in formatter_type]
self._bundle = None
self._phases: dict[str, TestPhase] = {}
# Generate workflow-level metadata
workflow_name = StringMetadata(name="workflow_name", data=self.benchmark_name)
timestamp = StringMetadata(name="timestamp", data=datetime.now().isoformat())
self.add_measurement("benchmark_info", metadata=workflow_name)
self.add_measurement("benchmark_info", metadata=timestamp)
if workflow_metadata:
if "metadata" in workflow_metadata:
self.add_measurement("benchmark_info", metadata=self._metadata_from_dict(workflow_metadata))
else:
logger.warning(
"workflow_metadata provided, but missing expected 'metadata' entry. Metadata will not be read."
)
# Whether to use recorders to collect metrics.
self._use_recorders = use_recorders
self._use_frametime_recorders = frametime_recorders
# Initialize frametime recorders dict (always, even when not using recorders)
self._frametime_recorders: dict[str, MeasurementDataRecorder] = {}
if self._use_recorders:
# Recorders that need to be updated manually since they don't depend on the kit timeline.
self._manual_recorders: dict[str, MeasurementDataRecorder] = {
"CPUInfo": CPUInfoRecorder(),
"GPUInfo": GPUInfoRecorder(),
"MemoryInfo": MemoryInfoRecorder(),
"VersionInfo": VersionInfoRecorder(),
}
# "Kit-full" means Isaac Sim (Kit) runtime is available, so benchmark services can
# provide frametime recorders. "Kit-less" means those services are absent; we should
# gracefully skip or fall back while still allowing mixed modes (e.g., kit-full physics
# with kit-less rendering).
# If we're using Kit, then we can use IsaacSim's benchmark services to peek into the frametimes.
if self._use_frametime_recorders and not has_kit():
logger.warning("Kit is not running. Kit related measurements will not be available.")
elif self._use_frametime_recorders:
try:
# Enable the benchmark services extension first
from isaaclab.sim.utils import enable_extension
enable_extension("isaacsim.benchmark.services")
added_any = False
# Try individual recorders first so we can collect partial metrics when only some are available.
try:
from isaacsim.benchmark.services.datarecorders.physics_frametime import PhysicsFrametimeRecorder
self._frametime_recorders["PhysicsFrametime"] = PhysicsFrametimeRecorder()
added_any = True
except (ImportError, Exception) as e:
logger.debug(f"Physics frametime recorder unavailable: {e}")
try:
from isaacsim.benchmark.services.datarecorders.render_frametime import RenderFrametimeRecorder
self._frametime_recorders["RenderFrametime"] = RenderFrametimeRecorder()
added_any = True
except (ImportError, Exception) as e:
logger.debug(f"Render frametime recorder unavailable: {e}")
try:
from isaacsim.benchmark.services.datarecorders.app_frametime import AppFrametimeRecorder
self._frametime_recorders["AppFrametime"] = AppFrametimeRecorder()
added_any = True
except (ImportError, Exception) as e:
logger.debug(f"App frametime recorder unavailable: {e}")
try:
from isaacsim.benchmark.services.datarecorders.gpu_frametime import GPUFrametimeRecorder
self._frametime_recorders["GPUFrametime"] = GPUFrametimeRecorder()
added_any = True
except (ImportError, Exception) as e:
logger.debug(f"GPU frametime recorder unavailable: {e}")
if not added_any:
# Fallback for Isaac Sim packaging that bundles frametime recorders in a single module.
try:
from isaacsim.benchmark.services.datarecorders.interface import InputContext
from isaacsim.benchmark.services.recorders import IsaacFrameTimeRecorder
context = InputContext(phase="frametime")
self._frametime_recorders["IsaacFrameTime"] = IsaacFrameTimeRecorder(
context=context, gpu_frametime=False
)
except ImportError as e:
logger.warning(
"Could not import bundled frametime recorder: "
f"{e}. Frametime measurements will not be available."
)
# Kit may stop after the availability check above. Frametime recorders are
# optional, so retain the non-Kit recorders if the IApp interface disappears.
except (ImportError, RuntimeError) as e:
logger.warning(
f"Could not initialize Kit frametime recorders: {e}. "
"Kit related measurements will not be available."
)
# Start collecting frametime recorders.
for recorder in self._frametime_recorders.values():
recorder.start_collecting()
# Set the start time of the benchmark.
logger.info("Starting")
self.benchmark_start_time = time.time()
@property
def output_file_path(self) -> str:
"""Get the full path to the output file."""
return os.path.join(self.output_path, f"{self.output_prefix}.json")
def _metadata_from_dict(self, metadata_dict: dict) -> list[MetadataBase]:
"""Convert a dictionary with metadata lists into a list of MetadataBase objects.
Example:
.. code-block:: python
metadata = self._metadata_from_dict({"metadata": [{"name": "gpu", "data": "A10"}]})
Args:
metadata_dict: A dictionary with metadata lists.
Returns:
A list of MetadataBase objects.
"""
metadata: list[MetadataBase] = []
metadata_mapping = {str: StringMetadata, int: IntMetadata, float: FloatMetadata, dict: DictMetadata}
for meas in metadata_dict["metadata"]:
if "data" in meas:
metadata_type = metadata_mapping.get(type(meas["data"]))
if metadata_type:
curr_meta = metadata_type(name=meas["name"], data=meas["data"])
metadata.append(curr_meta)
return metadata
[docs]
def attach_bundle(self, bundle: "RuntimeBundle | TrainingBundle | StartupBundle | PlayBundle | None") -> None:
"""Attach a typed bundle for schema serialization and flat-formatter projection.
Args:
bundle: Runtime, training, startup, or play benchmark bundle.
"""
self._bundle = bundle
if bundle is not None:
for phase_name, measurements in _measurements_from_bundle(bundle).items():
self.add_measurement(phase_name, measurement=measurements)
[docs]
def update_manual_recorders(self) -> None:
"""Update manual recorders that don't depend on the kit timeline."""
if not self._use_recorders:
logger.warning("Recorders are not enabled. Skipping update of manual recorders.")
return
for recorder in self._manual_recorders.values():
recorder.update()
[docs]
def add_measurement(
self,
phase_name: str,
measurement: Measurement | Sequence[Measurement] | None = None,
metadata: MetadataBase | Sequence[MetadataBase] | None = None,
) -> None:
"""Add a measurement to the benchmark.
Args:
phase_name: The name of the phase to add the measurement to.
measurement: The measurement to add.
metadata: The metadata to add.
"""
if phase_name not in self._phases:
self._phases[phase_name] = TestPhase(phase_name=phase_name)
# Add required phase metadata for formatters
phase_metadata = StringMetadata(name="phase", data=phase_name)
workflow_metadata = StringMetadata(name="workflow_name", data=self.benchmark_name)
self._phases[phase_name].metadata.extend([phase_metadata, workflow_metadata])
if measurement:
if isinstance(measurement, Sequence):
for m in measurement:
if not _is_measurement_type(m):
raise ValueError(f"Measurement element {m} is not of type Measurement")
self._phases[phase_name].measurements.extend(measurement)
else:
if not _is_measurement_type(measurement):
raise ValueError(f"Measurement element {measurement} is not of type Measurement")
self._phases[phase_name].measurements.append(measurement)
if metadata:
if isinstance(metadata, Sequence):
for m in metadata:
if not _is_metadata_type(m):
raise ValueError(f"Metadata element {m} is not of type MetadataBase")
self._phases[phase_name].metadata.extend(metadata)
else:
if not _is_metadata_type(metadata):
raise ValueError(f"Metadata element {metadata} is not of type MetadataBase")
self._phases[phase_name].metadata.append(metadata)
[docs]
def finalize(self) -> tuple[Path, ...]:
"""Finalize metric collection and write selected formatter outputs.
Returns:
Files written by the selected formatters.
"""
return self._finalize_impl()
def _finalize_impl(self) -> tuple[Path, ...]:
if self._bundle is None and any(key == "schema" for key, _ in self._metrics):
raise RuntimeError("The schema formatter requires an attached benchmark bundle.")
# Stop collecting frametime recorders.
for recorder in self._frametime_recorders.values():
recorder.stop_collecting()
# Add measurements and metadata from recorders to the phases.
if self._use_recorders:
for recorder_name, measurement_data in self._manual_recorders.items():
data = measurement_data.get_data()
# Add measurements to runtime phase if present
if data.measurements:
self.add_measurement("runtime", measurement=data.measurements)
# Add metadata to appropriate phase (even if no measurements)
if data.metadata:
if recorder_name == "VersionInfo":
self.add_measurement("version_info", metadata=data.metadata)
else:
self.add_measurement("hardware_info", metadata=data.metadata)
for recorder_name, measurement_data in self._frametime_recorders.items():
data = measurement_data.get_data()
# Add measurements to runtime phase if present
if data.measurements:
self.add_measurement("frametime", measurement=data.measurements)
if not self._phases:
logger.warning("No phases collected.No metrics will be written.")
return ()
# Add the phases to each metrics formatter and write its output file. When more than one
# formatter is selected, suffix the filename with the formatter key so they don't collide on
# the shared ".json" extension.
multi = len(self._metrics) > 1
output_paths: list[Path] = []
for formatter_key, metrics in self._metrics:
for phase in self._phases.values():
metrics.add_metrics(phase)
filename = f"{self.output_prefix}_{formatter_key}" if multi else self.output_prefix
metrics.finalize(self.output_path, filename, bundle=self._bundle)
if formatter_key == "osmo" and len(self._phases) > 1:
output_paths.extend(
Path(self.output_path) / f"{filename}_{phase_name}.json" for phase_name in self._phases
)
else:
output_paths.append(Path(self.output_path) / f"{filename}.json")
self._manual_recorders = None
self._frametime_recorders = None
return tuple(output_paths)