# 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
"""Method-level benchmarking framework for IsaacLab.
This module provides a framework for benchmarking individual methods with support for:
- Multiple input modes (torch_list, torch_tensor, etc.)
- Automatic hardware/version info collection via recorders
- Multiple output backends (JSON, Osmo, OmniPerf)
- Statistical measurements (mean, std, n)
Example usage:
.. code-block:: python
from isaaclab.benchmark import (
MethodBenchmarkRunner,
MethodBenchmarkRunnerConfig,
MethodBenchmarkDefinition,
)
# Define benchmarks
BENCHMARKS = [
MethodBenchmarkDefinition(
name="write_root_state_to_sim",
method_name="write_root_state_to_sim",
input_generators={
"torch_list": gen_root_state_torch_list,
"torch_tensor": gen_root_state_torch_tensor,
},
category="root_state",
),
]
# Configure and run
config = MethodBenchmarkRunnerConfig(
num_iterations=1000,
warmup_steps=10,
num_instances=4096,
device="cuda:0",
)
runner = MethodBenchmarkRunner("articulation_benchmark", config, "json", ".")
runner.run_benchmarks(BENCHMARKS, articulation)
runner.finalize()
"""
from __future__ import annotations
import inspect
import logging
import statistics
import time
from collections.abc import Callable
from dataclasses import dataclass
from .benchmark_core import BaseIsaacLabBenchmark
from .measurements import StatisticalMeasurement
logger = logging.getLogger(__name__)
[docs]
@dataclass
class MethodBenchmarkRunnerConfig:
"""Configuration for MethodBenchmarkRunner.
Attributes:
num_iterations: Number of timing iterations per method.
warmup_steps: Number of warmup iterations before timing.
num_instances: Number of environment instances.
num_bodies: Number of bodies per instance.
num_joints: Number of joints per instance.
device: Device to run benchmarks on.
mode: Which input modes to run ("all" or specific mode name).
"""
num_iterations: int = 1000
warmup_steps: int = 10
num_instances: int = 4096
num_bodies: int = 12
num_joints: int = 11
device: str = "cuda:0"
mode: str | list[str] = "all"
def __post_init__(self) -> None:
"""Validate benchmark workload sizes."""
for field_name in ("num_iterations", "num_instances", "num_bodies"):
if getattr(self, field_name) <= 0:
raise ValueError(f"{field_name} must be greater than zero")
if self.num_joints < 0:
raise ValueError("num_joints must be non-negative")
if self.warmup_steps < 0:
raise ValueError("warmup_steps must be non-negative")
[docs]
@dataclass
class MethodBenchmarkDefinition:
"""Definition of a method benchmark.
Attributes:
name: Display name for the benchmark.
method_name: Name of the method to benchmark on the target object.
input_generators: Dict mapping mode names to input generator functions.
category: Category for grouping results into phases.
"""
name: str
method_name: str
input_generators: dict[str, Callable]
category: str = "default"
[docs]
class MethodBenchmarkRunner(BaseIsaacLabBenchmark):
"""Runner for method-level benchmarks using the new benchmark tooling.
This class extends BaseIsaacLabBenchmark to provide method-level benchmarking
with automatic hardware/version info collection, multiple backend support,
and organized output by category phases.
"""
[docs]
def __init__(
self,
benchmark_name: str,
config: MethodBenchmarkRunnerConfig,
backend_type: str = "json",
output_path: str = ".",
use_recorders: bool = True,
):
"""Initialize the method benchmark runner.
Args:
benchmark_name: Name of the benchmark (used in output files).
config: Benchmark configuration.
backend_type: Output backend type ("json", "osmo", "omni_perf").
output_path: Directory to write output files.
use_recorders: Whether to collect hardware/version info.
"""
self._config = config
# Build workflow metadata from config
workflow_metadata = {
"metadata": [
{"name": "num_iterations", "data": config.num_iterations},
{"name": "warmup_steps", "data": config.warmup_steps},
{"name": "num_instances", "data": config.num_instances},
{"name": "num_bodies", "data": config.num_bodies},
{"name": "num_joints", "data": config.num_joints},
{"name": "device", "data": config.device},
]
}
super().__init__(
benchmark_name=benchmark_name,
backend_type=backend_type,
output_path=output_path,
use_recorders=use_recorders,
output_prefix=benchmark_name,
workflow_metadata=workflow_metadata,
)
# Determine which modes to run
if isinstance(config.mode, str):
if config.mode == "all":
self._modes_to_run = None # Run all available
else:
self._modes_to_run = [config.mode]
else:
self._modes_to_run = config.mode
@property
def config(self) -> MethodBenchmarkRunnerConfig:
"""Return the benchmark configuration."""
return self._config
[docs]
def run_benchmarks(
self,
benchmarks: list[MethodBenchmarkDefinition],
target_object: object,
) -> None:
"""Run all defined benchmarks on the target object.
Args:
benchmarks: List of benchmark definitions to run.
target_object: Object containing the methods to benchmark.
"""
print(f"\nBenchmarking {len(benchmarks)} methods...")
print(f"Config: {self._config.num_iterations} iterations, {self._config.warmup_steps} warmup steps")
print(
f" {self._config.num_instances} instances, {self._config.num_bodies} bodies, "
f"{self._config.num_joints} joints"
)
print(f"Device: {self._config.device}")
print(f"Modes: {self._modes_to_run if self._modes_to_run else 'All available'}")
print("-" * 80)
for i, benchmark in enumerate(benchmarks):
method = getattr(target_object, benchmark.method_name, None)
# Determine which modes to run for this benchmark
available_modes = list(benchmark.input_generators.keys())
current_modes = self._modes_to_run if self._modes_to_run is not None else available_modes
current_modes = [m for m in current_modes if m in available_modes]
for mode in current_modes:
# Update manual recorders
self.update_manual_recorders()
generator = benchmark.input_generators[mode]
bench_name = f"{benchmark.name}_{mode}"
print(f"[{i + 1}/{len(benchmarks)}] [{mode.upper()}] {benchmark.name}...", end=" ", flush=True)
result = self._benchmark_method(
method=method,
method_name=bench_name,
generator=generator,
)
if result is None:
print("SKIPPED (method not found)")
elif result.get("skipped"):
print(f"SKIPPED ({result.get('skip_reason', 'unknown')})")
else:
mean = result["mean"]
std = result["std"]
print(f"{mean:.2f} +/- {std:.2f} us")
# Add measurement to mode-based phase (torch_list, torch_tensor, etc.)
measurement = StatisticalMeasurement(
name=benchmark.name,
mean=mean,
std=std,
n=result["n"],
unit="us",
)
self.add_measurement(mode, measurement=measurement)
def _benchmark_method(
self,
method: Callable | None,
method_name: str,
generator: Callable,
) -> dict | None:
"""Benchmark a single method.
Args:
method: The method to benchmark (or None if not found).
method_name: Name of the method for reporting.
generator: Function that generates input arguments.
Returns:
Dict with timing results, or None if method not found.
"""
if method is None:
return None
inputs: dict = {}
def prepare() -> None:
nonlocal inputs
inputs = generator(self._config)
def operation() -> object:
return method(**inputs)
try:
prepare()
operation()
except NotImplementedError as e:
return {"skipped": True, "skip_reason": f"NotImplementedError: {e}"}
except Exception as e:
raise RuntimeError(f"{method_name} failed during preflight") from e
return self._collect_samples(method_name, prepare, operation)
def _collect_samples(
self,
workload_name: str,
prepare: Callable[[], None],
operation: Callable[[], object],
) -> dict:
"""Collect synchronized latency samples for one workload.
Args:
workload_name: Name included in contextual failures.
prepare: Untimed callable that prepares one operation.
operation: Callable measured after preparation.
Returns:
Timing statistics in microseconds.
"""
for iteration in range(self._config.warmup_steps):
try:
prepare()
operation()
except Exception as e:
raise RuntimeError(f"{workload_name} failed during warmup iteration {iteration}") from e
if self._config.device.startswith("cuda"):
self._sync_device()
times: list[float] = []
for iteration in range(self._config.num_iterations):
try:
prepare()
except Exception as e:
raise RuntimeError(f"{workload_name} failed during timed preparation iteration {iteration}") from e
if self._config.device.startswith("cuda"):
self._sync_device()
start_time = time.perf_counter_ns()
try:
operation()
except Exception as e:
raise RuntimeError(f"{workload_name} failed during timed iteration {iteration}") from e
if self._config.device.startswith("cuda"):
self._sync_device()
end_time = time.perf_counter_ns()
times.append((end_time - start_time) / 1e3)
return {
"mean": statistics.mean(times),
"std": statistics.stdev(times) if len(times) > 1 else 0.0,
"n": len(times),
}
def _sync_device(self) -> None:
"""Synchronize GPU device."""
import warp as wp
wp.synchronize_device(self._config.device)
[docs]
def run_property_benchmarks(
self,
target_data: object,
properties: list[str],
gen_mock_data: Callable,
dependencies: dict[str, list[str]] | None = None,
category: str = "property",
) -> None:
"""Run benchmarks for data class properties.
This is a convenience method for benchmarking properties on data classes
where the test involves generating mock data and accessing properties.
Args:
target_data: Data object containing the properties to benchmark.
properties: List of property names to benchmark.
gen_mock_data: Function that generates/updates mock data.
dependencies: Optional dict mapping property names to their dependencies.
category: Category name for grouping results.
"""
if dependencies is None:
dependencies = {}
# Update manual recorders at start
self.update_manual_recorders()
print(f"\nBenchmarking {len(properties)} properties...")
print(f"Config: {self._config.num_iterations} iterations, {self._config.warmup_steps} warmup steps")
print(
f" {self._config.num_instances} instances, {self._config.num_bodies} bodies, "
f"{self._config.num_joints} joints"
)
print("-" * 80)
for i, prop_name in enumerate(properties):
print(f"[{i + 1}/{len(properties)}] [DEFAULT] {prop_name}...", end=" ", flush=True)
# Get dependencies for this property
prop_deps = dependencies.get(prop_name, [])
result = self._benchmark_property(
target_data=target_data,
prop_name=prop_name,
gen_mock_data=gen_mock_data,
dependencies=prop_deps,
)
if result is None:
print("SKIPPED (property not found)")
elif result.get("skipped"):
print(f"SKIPPED ({result.get('skip_reason', 'unknown')})")
else:
mean = result["mean"]
std = result["std"]
print(f"{mean:.2f} +/- {std:.2f} us")
# Add measurement
measurement = StatisticalMeasurement(
name=prop_name,
mean=mean,
std=std,
n=result["n"],
unit="us",
)
self.add_measurement(category, measurement=measurement)
def _benchmark_property(
self,
target_data: object,
prop_name: str,
gen_mock_data: Callable,
dependencies: list[str],
) -> dict | None:
"""Benchmark a single property access.
Args:
target_data: Data object containing the property.
prop_name: Name of the property to benchmark.
gen_mock_data: Function that generates/updates mock data.
dependencies: List of property names to access first.
Returns:
Dict with timing results, or None if property not found.
"""
# Check if property exists
if inspect.getattr_static(target_data, prop_name, None) is None:
return None
def prepare() -> None:
gen_mock_data(self._config)
for dependency in dependencies:
getattr(target_data, dependency)
def operation() -> object:
return getattr(target_data, prop_name)
try:
gen_mock_data(self._config)
operation()
except NotImplementedError as e:
return {"skipped": True, "skip_reason": f"NotImplementedError: {e}"}
except Exception as e:
raise RuntimeError(f"{prop_name} failed during preflight") from e
return self._collect_samples(prop_name, prepare, operation)