Source code for isaaclab.benchmark.micro

# 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 latency sampling and reporting for micro-benchmarks."""

from __future__ import annotations

import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol

import numpy as np

from .benchmark_core import BaseIsaacLabBenchmark
from .measurements import Measurement, SingleMeasurement, StatisticalMeasurement


class _MeasurementSink(Protocol):
    """Benchmark object that accepts measurements grouped by phase."""

    def add_measurement(self, phase_name: str, measurement: Measurement | Sequence[Measurement]) -> None:
        """Add one or more measurements to a phase."""


[docs] @dataclass(frozen=True) class LatencySample: """One host-submission and device-synchronized latency sample. Attributes: submission_s: Time [s] until the operation returned to the host. synchronized_s: Time [s] until all submitted device work completed. """ submission_s: float synchronized_s: float
[docs] @dataclass(frozen=True) class LatencyStatistics: """Aggregate statistics for a latency sample series. Attributes: mean_s: Arithmetic mean latency [s]. std_s: Sample standard deviation [s], or zero for one sample. p50_s: Linearly interpolated 50th percentile latency [s]. p95_s: Linearly interpolated 95th percentile latency [s]. n: Number of samples. """ mean_s: float std_s: float p50_s: float p95_s: float n: int
[docs] def measure_latency( operation: Callable[[], None], synchronize: Callable[[], None], *, clock_ns: Callable[[], int] | None = None, ) -> LatencySample: """Measure host submission and synchronized completion latency. The pre-boundary synchronization prevents asynchronous work submitted before :paramref:`operation` from being charged to the sample. The post-boundary synchronization includes all device work submitted by the operation. Args: operation: Workload to measure. synchronize: Function that blocks until pending device work completes. clock_ns: Monotonic nanosecond clock. Defaults to :func:`time.perf_counter_ns`. Returns: Host-submission and device-synchronized latency [s]. """ if clock_ns is None: clock_ns = time.perf_counter_ns synchronize() start_ns = clock_ns() operation() submitted_ns = clock_ns() synchronize() finished_ns = clock_ns() return LatencySample( submission_s=(submitted_ns - start_ns) / 1e9, synchronized_s=(finished_ns - start_ns) / 1e9, )
[docs] def summarize_latency(samples_s: Sequence[float]) -> LatencyStatistics: """Summarize a non-empty latency sample series. Args: samples_s: Latency samples [s]. Returns: Mean, sample standard deviation, and interpolated percentiles [s]. Raises: ValueError: If :paramref:`samples_s` is empty. """ if not samples_s: raise ValueError("Latency statistics require at least one sample.") samples = np.asarray(samples_s, dtype=np.float64) return LatencyStatistics( mean_s=float(np.mean(samples)), std_s=float(np.std(samples, ddof=1)) if len(samples) > 1 else 0.0, p50_s=float(np.percentile(samples, 50, method="linear")), p95_s=float(np.percentile(samples, 95, method="linear")), n=len(samples), )
[docs] def add_latency_measurements( benchmark: _MeasurementSink, phase_name: str, name: str, samples_s: Sequence[float], ) -> LatencyStatistics: """Add latency mean, standard deviation, and percentiles to a benchmark. Args: benchmark: Benchmark receiving the measurements. phase_name: Phase that owns the measurements. name: Base measurement name. samples_s: Latency samples [s]. Returns: Computed latency statistics [s]. """ stats = summarize_latency(samples_s) benchmark.add_measurement( phase_name, measurement=StatisticalMeasurement( name=name, mean=stats.mean_s * 1000.0, std=stats.std_s * 1000.0, n=stats.n, unit="ms", ), ) benchmark.add_measurement( phase_name, measurement=SingleMeasurement(name=f"{name} p50", value=stats.p50_s * 1000.0, unit="ms"), ) benchmark.add_measurement( phase_name, measurement=SingleMeasurement(name=f"{name} p95", value=stats.p95_s * 1000.0, unit="ms"), ) return stats
[docs] class LatencyBenchmarkRunner(BaseIsaacLabBenchmark): """One-shot runner for latency micro-benchmarks. Args: benchmark_name: Name used in output metadata and filenames. formatter_type: Formatter used to report results. output_path: Directory for result files. metadata: Workload metadata stored with the result. use_recorders: Whether to collect hardware and version information. """
[docs] def __init__( self, benchmark_name: str, formatter_type: str, output_path: str, metadata: dict[str, str | int | float | dict] | None = None, use_recorders: bool = True, ) -> None: workflow_metadata = {"metadata": [{"name": name, "data": value} for name, value in (metadata or {}).items()]} super().__init__( benchmark_name=benchmark_name, formatter_type=formatter_type, output_path=output_path, output_prefix=benchmark_name, workflow_metadata=workflow_metadata, use_recorders=use_recorders, )
[docs] def add_latency_samples(self, phase_name: str, samples: Sequence[LatencySample]) -> LatencyStatistics: """Add synchronized completion and host submission latency series. Args: phase_name: Phase that owns the measurements. samples: Paired host and synchronized latency samples [s]. Returns: Synchronized completion latency statistics [s]. """ synchronized_stats = add_latency_measurements( self, phase_name, "Synchronized Completion", [sample.synchronized_s for sample in samples] ) add_latency_measurements(self, phase_name, "Host Submission", [sample.submission_s for sample in samples]) return synchronized_stats
[docs] def add_synchronized_samples(self, phase_name: str, name: str, samples_s: Sequence[float]) -> LatencyStatistics: """Add a synchronized-only latency series. Args: phase_name: Phase that owns the measurements. name: Measurement name. samples_s: Synchronized latency samples [s]. Returns: Synchronized latency statistics [s]. """ return add_latency_measurements(self, phase_name, name, samples_s)
[docs] def finalize(self) -> tuple[Path, ...]: """Sample recorders and write results. Returns: Paths written by the selected formatters. """ if self._use_recorders: self.update_manual_recorders() return super().finalize()