diff --git a/docs/source/testing/benchmarks.rst b/docs/source/testing/benchmarks.rst index 0225569f175..0127f87a397 100644 --- a/docs/source/testing/benchmarks.rst +++ b/docs/source/testing/benchmarks.rst @@ -154,6 +154,96 @@ Measure asset method and property performance using mock interfaces: For detailed documentation on micro-benchmarks, including available benchmark files, input modes, and how to add new benchmarks, see :ref:`testing_micro_benchmarks`. +Startup Profiling Benchmark +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Profile the startup sequence of an IsaacLab environment using ``cProfile``. Each +startup stage is wrapped in its own profiling session and the top functions by +own-time are reported. This is useful for investigating startup regressions and +understanding where time is spent during initialization. + +.. code-block:: bash + + # Basic usage — reports top 30 functions per phase + ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ + --task Isaac-Ant-v0 \ + --num_envs 4096 \ + --headless \ + --benchmark_backend summary + +The script profiles five phases independently: + +- **app_launch**: ``launch_simulation()`` context entry (Kit/USD/PhysX init) +- **python_imports**: importing gymnasium, torch, isaaclab_tasks, etc. +- **task_config**: ``resolve_task_config()`` (Hydra config resolution) +- **env_creation**: ``gym.make()`` + ``env.reset()`` (scene creation, sim start) +- **first_step**: a single ``env.step()`` call + +Each phase records a wall-clock time plus per-function own-time and cumulative +time as ``SingleMeasurement`` entries. Only IsaacLab functions and first-level +calls into external libraries are included (deep internals of torch, USD, etc. +are filtered out). + +**Whitelist mode** — For dashboard time-series comparisons across runs, use a +YAML whitelist config to report a fixed set of functions instead of top-N. +Patterns use ``fnmatch`` syntax (``*`` and ``?`` wildcards): + +.. code-block:: yaml + + # Example whitelist config + app_launch: + - "isaaclab.utils.configclass:_custom_post_init" + - "isaaclab.sim.*:__init__" + env_creation: + - "isaaclab.cloner.*:usd_replicate" + - "isaaclab.cloner.*:filter_collisions" + - "isaaclab.scene.*:_init_scene" + first_step: + - "isaaclab.actuators.*:compute" + - "warp.*:launch" + +.. code-block:: bash + + ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ + --task Isaac-Ant-v0 \ + --num_envs 4096 \ + --headless \ + --benchmark_backend omniperf \ + --whitelist_config scripts/benchmarks/startup_whitelist.yaml + +Phases listed in the YAML use the whitelist; phases not listed fall back to +``--top_n`` (default: 5 in whitelist mode, 30 otherwise). Patterns that match +no profiled function emit ``0.0`` placeholders so the output always contains +the same keys. + +A default whitelist is provided at ``scripts/benchmarks/startup_whitelist.yaml``. + +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 + + * - Argument + - Default + - Description + * - ``--task`` + - required + - Environment task name + * - ``--num_envs`` + - from config + - Number of parallel environments + * - ``--top_n`` + - 30 (5 with whitelist) + - Max functions per non-whitelisted phase + * - ``--whitelist_config`` + - None + - Path to YAML whitelist file + * - ``--benchmark_backend`` + - ``omniperf`` + - Output backend (``json``, ``osmo``, ``omniperf``, ``summary``) + * - ``--output_path`` + - ``.`` + - Directory for output files + Command Line Arguments ---------------------- @@ -399,9 +489,12 @@ Output structure: Summary Backend ~~~~~~~~~~~~~~~ -Human-readable console report plus JSON file. Prints a formatted summary (runtime, -startup, train, frametime, and system info) to the terminal while also writing -the same data as JSON. Use when you want a quick readout without opening the JSON: +Human-readable console report plus JSON file. Prints a formatted summary to the +terminal while also writing the same data as JSON. Standard phases (runtime, +startup, train, frametime, system info) are rendered with specialized formatting; +any additional phases (e.g., from the startup profiling benchmark) are rendered +automatically with their ``SingleMeasurement`` and ``StatisticalMeasurement`` +entries. Use when you want a quick readout without opening the JSON: .. code-block:: bash diff --git a/scripts/benchmarks/benchmark_startup.py b/scripts/benchmarks/benchmark_startup.py new file mode 100644 index 00000000000..b09bcec35ed --- /dev/null +++ b/scripts/benchmarks/benchmark_startup.py @@ -0,0 +1,356 @@ +# 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 + +"""Script to profile IsaacLab startup phases with cProfile. + +Each startup stage (app launch, python imports, env creation, first step) is +wrapped in its own cProfile session. The top functions by own-time are emitted +as SingleMeasurement entries (both own-time and cumulative time) via the +standard benchmark backend. +""" + +import argparse +import cProfile +import os +import sys +import time + +from isaaclab.app import AppLauncher + +# -- CLI arguments ----------------------------------------------------------- + +parser = argparse.ArgumentParser(description="Profile IsaacLab startup phases.") +parser.add_argument("--num_envs", type=int, default=None, help="Number of environments to simulate.") +parser.add_argument("--task", type=str, required=True, help="Name of the task.") +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") +parser.add_argument( + "--top_n", + type=int, + default=None, + help="Number of top functions per phase (default: 30, or 5 with --whitelist_config).", +) +parser.add_argument( + "--benchmark_backend", + type=str, + default="omniperf", + choices=[ + "json", + "osmo", + "omniperf", + "summary", + "LocalLogMetrics", + "JSONFileMetrics", + "OsmoKPIFile", + "OmniPerfKPIFile", + ], + help="Benchmarking backend options, defaults omniperf", +) +parser.add_argument("--output_path", type=str, default=".", help="Path to output benchmark results.") +parser.add_argument( + "--whitelist_config", + type=str, + default=None, + help="Path to YAML file with per-phase function whitelist patterns. Overrides --top_n for listed phases.", +) + +# append AppLauncher cli args (provides --device, --headless, etc.) +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args_cli, hydra_args = parser.parse_known_args() + +# clear out sys.argv for Hydra +sys.argv = [sys.argv[0]] + hydra_args + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) + +from isaaclab.test.benchmark import BaseIsaacLabBenchmark, SingleMeasurement +from isaaclab.utils.timer import Timer, TimerError + +from scripts.benchmarks.utils import ( + get_backend_type, + get_preset_string, + parse_cprofile_stats, +) + +# -- Python imports (profiled) ------------------------------------------------ + +imports_profile = cProfile.Profile() +imports_time_begin = time.perf_counter_ns() +imports_profile.enable() + +import gymnasium as gym # noqa: E402 +import torch # noqa: E402 + +from isaaclab.envs import DirectMARLEnvCfg, DirectRLEnvCfg, ManagerBasedRLEnvCfg # noqa: E402 + +from isaaclab_tasks.utils import launch_simulation, resolve_task_config # noqa: E402 + +imports_profile.disable() + +if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() +imports_time_end = time.perf_counter_ns() + +# -- Resolve task config (profiled) ------------------------------------------ + +task_config_profile = cProfile.Profile() +task_config_time_begin = time.perf_counter_ns() +task_config_profile.enable() + +env_cfg, _agent_cfg = resolve_task_config(args_cli.task, None) + +task_config_profile.disable() +task_config_time_end = time.perf_counter_ns() + +# -- Detect IsaacLab source prefixes for filtering --------------------------- + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +_source_dir = os.path.join(_REPO_ROOT, "source") +if os.path.isdir(_source_dir): + _ISAACLAB_PREFIXES = [ + os.path.join(_source_dir, d) for d in os.listdir(_source_dir) if os.path.isdir(os.path.join(_source_dir, d)) + ] +else: + print(f"[WARNING] IsaacLab source directory not found at '{_source_dir}'. Function-level profiling will be empty.") + _ISAACLAB_PREFIXES = [] + +# -- Load whitelist config if provided --------------------------------------- + +_WHITELIST: dict[str, list[str]] = {} +if args_cli.whitelist_config is not None: + import yaml + + try: + with open(args_cli.whitelist_config) as f: + raw = yaml.safe_load(f) + except OSError as e: + print(f"[ERROR] Cannot read whitelist config '{args_cli.whitelist_config}': {e}") + sys.exit(1) + except yaml.YAMLError as e: + print(f"[ERROR] Invalid YAML in whitelist config '{args_cli.whitelist_config}': {e}") + sys.exit(1) + + if raw is None: + _WHITELIST = {} + elif not isinstance(raw, dict): + print( + f"[ERROR] Whitelist config must be a YAML mapping (got {type(raw).__name__})." + " Expected format: phase_name: [pattern, ...]" + ) + sys.exit(1) + else: + _VALID_PHASES = {"app_launch", "python_imports", "task_config", "env_creation", "first_step"} + unknown_phases = set(raw.keys()) - _VALID_PHASES + if unknown_phases: + print( + f"[WARNING] Whitelist config contains unknown phase(s): {unknown_phases}. " + f"Valid phases: {_VALID_PHASES}. Check for typos." + ) + for phase_name, patterns in raw.items(): + if not isinstance(patterns, list) or not all(isinstance(p, str) for p in patterns): + print( + f"[ERROR] Whitelist phase '{phase_name}' must be a list of strings, " + f"got {type(patterns).__name__}. Check YAML formatting (use '- pattern' syntax)." + ) + sys.exit(1) + _WHITELIST = raw + +# Resolve top_n default: 5 when using whitelist (fallback phases stay compact), 30 otherwise +if args_cli.top_n is None: + args_cli.top_n = 5 if _WHITELIST else 30 + +# -- Create the benchmark instance ------------------------------------------ + +env_cfg.seed = args_cli.seed if args_cli.seed is not None else env_cfg.seed + +backend_type = get_backend_type(args_cli.benchmark_backend) +benchmark = BaseIsaacLabBenchmark( + benchmark_name="benchmark_startup", + backend_type=backend_type, + output_path=args_cli.output_path, + use_recorders=True, + output_prefix=f"benchmark_startup_{args_cli.task}", + workflow_metadata={ + "metadata": [ + {"name": "task", "data": args_cli.task}, + {"name": "seed", "data": args_cli.seed}, + {"name": "num_envs", "data": args_cli.num_envs}, + {"name": "top_n", "data": args_cli.top_n}, + {"name": "presets", "data": get_preset_string(hydra_args)}, + ] + }, +) + + +# -- Main profiling logic --------------------------------------------------- + + +def main( + env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, + app_launch_profile: cProfile.Profile, + app_launch_wall_ms: float, +): + """Profile env creation and first step, then log all phase measurements. + + Args: + env_cfg: Resolved environment configuration for the task. + app_launch_profile: cProfile session from the app-launch phase. + app_launch_wall_ms: Wall-clock duration of the app-launch phase [ms]. + """ + + # Override config with CLI args + env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + env_cfg.seed = args_cli.seed + + # -- Env creation (gym.make + env.reset) profiled --------------------------- + + env_creation_profile = cProfile.Profile() + env_creation_time_begin = time.perf_counter_ns() + env_creation_profile.enable() + try: + env = gym.make(args_cli.task, cfg=env_cfg) + except BaseException: + env_creation_profile.disable() + raise + + try: + try: + env.reset() + finally: + env_creation_profile.disable() + + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + env_creation_time_end = time.perf_counter_ns() + # -- First step profiled ------------------------------------------------ + + # Sample random actions + actions = ( + torch.rand(env.unwrapped.num_envs, env.unwrapped.single_action_space.shape[0], device=env.unwrapped.device) + * 2.0 + - 1.0 + ) + + first_step_profile = cProfile.Profile() + first_step_time_begin = time.perf_counter_ns() + first_step_profile.enable() + try: + env.step(actions) + finally: + first_step_profile.disable() + + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + first_step_time_end = time.perf_counter_ns() + + # -- Parse all profiles and log measurements ---------------------------- + + imports_wall_ms = (imports_time_end - imports_time_begin) / 1e6 + task_config_wall_ms = (task_config_time_end - task_config_time_begin) / 1e6 + env_creation_wall_ms = (env_creation_time_end - env_creation_time_begin) / 1e6 + first_step_wall_ms = (first_step_time_end - first_step_time_begin) / 1e6 + + # Collect Timer-based sub-timings for env_creation phase (may not exist for all environment types) + scene_creation_ms = None + try: + scene_creation_ms = Timer.get_timer_info("scene_creation") * 1000 + except TimerError: + print("[INFO] Timer 'scene_creation' not available; sub-timing will be omitted.") + + simulation_start_ms = None + try: + simulation_start_ms = Timer.get_timer_info("simulation_start") * 1000 + except TimerError: + print("[INFO] Timer 'simulation_start' not available; sub-timing will be omitted.") + + phases = { + "app_launch": { + "profile": app_launch_profile, + "wall_clock_ms": app_launch_wall_ms, + "extra_measurements": [], + }, + "python_imports": { + "profile": imports_profile, + "wall_clock_ms": imports_wall_ms, + "extra_measurements": [], + }, + "task_config": { + "profile": task_config_profile, + "wall_clock_ms": task_config_wall_ms, + "extra_measurements": [], + }, + "env_creation": { + "profile": env_creation_profile, + "wall_clock_ms": env_creation_wall_ms, + "extra_measurements": [ + (name, val) + for name, val in [ + ("Scene Creation Time", scene_creation_ms), + ("Simulation Start Time", simulation_start_ms), + ] + if val is not None + ], + }, + "first_step": { + "profile": first_step_profile, + "wall_clock_ms": first_step_wall_ms, + "extra_measurements": [], + }, + } + + # Parse profiles and log measurements to benchmark + for phase_name, phase_data in phases.items(): + phase_whitelist = _WHITELIST.get(phase_name) + functions = parse_cprofile_stats( + phase_data["profile"], _ISAACLAB_PREFIXES, top_n=args_cli.top_n, whitelist=phase_whitelist + ) + wall_ms = phase_data["wall_clock_ms"] + extras = phase_data["extra_measurements"] + + # Log wall-clock time + benchmark.add_measurement( + phase_name, measurement=SingleMeasurement(name="Wall Clock Time", value=wall_ms, unit="ms") + ) + + # Log extra sub-timings + for extra_name, extra_val in extras: + benchmark.add_measurement( + phase_name, measurement=SingleMeasurement(name=extra_name, value=extra_val, unit="ms") + ) + + # Log per-function measurements (tottime + cumtime) + for label, tottime_ms, cumtime_ms in functions: + benchmark.add_measurement( + phase_name, measurement=SingleMeasurement(name=label, value=round(tottime_ms, 2), unit="ms") + ) + benchmark.add_measurement( + phase_name, + measurement=SingleMeasurement(name=f"{label} (cumtime)", value=round(cumtime_ms, 2), unit="ms"), + ) + + # Finalize benchmark output + benchmark.update_manual_recorders() + benchmark._finalize_impl() + finally: + env.close() + + +if __name__ == "__main__": + # -- App launch (profiled) -------------------------------------------------- + + app_launch_profile = cProfile.Profile() + app_launch_time_begin = time.perf_counter_ns() + app_launch_profile.enable() + + with launch_simulation(env_cfg, args_cli): + app_launch_profile.disable() + + if torch.cuda.is_available() and torch.cuda.is_initialized(): + torch.cuda.synchronize() + app_launch_time_end = time.perf_counter_ns() + + app_launch_wall_ms = (app_launch_time_end - app_launch_time_begin) / 1e6 + main(env_cfg, app_launch_profile, app_launch_wall_ms) diff --git a/scripts/benchmarks/startup_whitelist.yaml b/scripts/benchmarks/startup_whitelist.yaml new file mode 100644 index 00000000000..121718d36b4 --- /dev/null +++ b/scripts/benchmarks/startup_whitelist.yaml @@ -0,0 +1,25 @@ +# 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 + +app_launch: + - "isaaclab.utils.configclass:_wrap_resolvable_strings" + - "isaaclab.utils.configclass:_custom_post_init" + - "isaaclab.utils.configclass:_field_module_dir" + +env_creation: + - "isaaclab.cloner.*:usd_replicate" + - "isaaclab.cloner.*:filter_collisions" + - "isaaclab_physx.cloner.*:attach_end_fn" + - "isaaclab.scene.*:_init_scene" + - "isaaclab.envs.mdp.observations:*" + - "isaaclab.utils.assets:_find_usd_dependencies" + +first_step: + - "isaaclab.envs.mdp.rewards:*" + - "isaaclab.envs.mdp.terminations:*" + - "isaaclab.envs.mdp.observations:*" + - "isaaclab.actuators.*:compute" + - "warp.*:launch" + - "warp.*:to_torch" diff --git a/scripts/benchmarks/utils.py b/scripts/benchmarks/utils.py index ff3ab78efb6..0a9dffd4f70 100644 --- a/scripts/benchmarks/utils.py +++ b/scripts/benchmarks/utils.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause +import cProfile import glob import os import statistics @@ -235,3 +236,115 @@ def log_convergence( benchmark.add_measurement( "train", SingleMeasurement(name="Convergence Passed", value=int(result["passed"]), unit="bool") ) + + +def parse_cprofile_stats( + profile: cProfile.Profile, + isaaclab_prefixes: list[str], + top_n: int = 30, + whitelist: list[str] | None = None, +) -> list[tuple[str, float, float]]: + """Parse cProfile stats, filtering to IsaacLab + first-level external calls. + + Walks the pstats data and keeps functions that are either (a) inside an + IsaacLab source directory, or (b) directly called by an IsaacLab function. + Results are sorted by own-time (tottime) descending. + + When *whitelist* is provided, only functions whose labels match at least one + ``fnmatch`` pattern are returned. Patterns that match no profiled function + emit a ``(pattern, 0.0, 0.0)`` placeholder so dashboards always receive + consistent keys. The *top_n* parameter is ignored in whitelist mode. + + Args: + profile: A completed cProfile.Profile instance (after .disable()). + isaaclab_prefixes: Absolute file path prefixes identifying IsaacLab source + (e.g. ["/home/user/IsaacLab/source/isaaclab", ...]). + top_n: Maximum number of functions to return. Ignored when + *whitelist* is provided. + whitelist: Optional list of ``fnmatch`` patterns to select specific + functions (e.g. ``["isaaclab.cloner.*:usd_replicate"]``). + + Returns: + List of (function_label, tottime_ms, cumtime_ms) tuples sorted by + tottime descending. + """ + import fnmatch + import io + import pstats + + stats = pstats.Stats(profile, stream=io.StringIO()) + + def _is_isaaclab(filename: str) -> bool: + return any(filename.startswith(prefix) for prefix in isaaclab_prefixes) + + def _make_label(filename: str, funcname: str) -> str: + # For builtins/C-extensions the filename is something like "~" or "" + if not filename or filename.startswith("<") or filename == "~": + return funcname + # Convert absolute path to dotted module-style label + for prefix in isaaclab_prefixes: + if filename.startswith(prefix): + rel = os.path.relpath(filename, prefix) + # Strip .py, replace os.sep with dot + rel = rel.replace(os.sep, ".").removesuffix(".py") + return f"{rel}:{funcname}" + # External function — try to find the top-level package name + # e.g. ".../site-packages/torch/nn/modules/linear.py" -> "torch.nn.modules.linear" + parts = filename.replace(os.sep, "/").removesuffix(".py").split("/") + # Find "site-packages" anchor or fall back to last 3 components + try: + sp_idx = parts.index("site-packages") + short = ".".join(parts[sp_idx + 1 :]) + except ValueError: + short = ".".join(parts[-3:]) if len(parts) >= 3 else ".".join(parts) + return f"{short}:{funcname}" + + # NOTE: stats.stats is an internal CPython dict, not part of the public pstats API. + # The public get_stats_profile() (Python 3.9+) doesn't expose caller info, which + # we need for the first-level external call filter. If a future Python release + # breaks this, switch to get_stats_profile() and drop the caller-based filtering. + # stats.stats: dict[(filename, lineno, funcname)] -> (pcalls, ncalls, tottime, cumtime, callers) + # callers: dict[(filename, lineno, funcname)] -> (pcalls, ncalls, tottime, cumtime) + results = [] + for func_key, (_, _, tottime, cumtime, callers) in stats.stats.items(): + filename, _, funcname = func_key + if _is_isaaclab(filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0)) + else: + # Check if any direct caller is an IsaacLab function + for caller_key in callers: + caller_filename = caller_key[0] + if _is_isaaclab(caller_filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0)) + break + + # Sort by tottime (own-time) descending + results.sort(key=lambda x: x[1], reverse=True) + + if whitelist is None: + return results[:top_n] + + # Whitelist mode: filter by fnmatch patterns, emit placeholders for unmatched patterns + matched: dict[str, tuple[str, float, float]] = {} + matched_patterns: set[str] = set() + for label, tottime, cumtime in results: + for pattern in whitelist: + if fnmatch.fnmatch(label, pattern): + if label not in matched: + matched[label] = (label, tottime, cumtime) + matched_patterns.add(pattern) + + # Add 0.0 placeholders for patterns that matched nothing + for pattern in whitelist: + if pattern not in matched_patterns: + print( + f"[WARNING] Whitelist pattern '{pattern}' matched no profiled functions. " + "Check for typos or verify the function ran during this phase." + ) + matched[pattern] = (pattern, 0.0, 0.0) + + filtered = list(matched.values()) + filtered.sort(key=lambda x: x[1], reverse=True) + return filtered diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index d6e20b22132..59b139eda36 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.5.22" +version = "4.5.23" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 11524c3b318..52d10951b3e 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +4.5.23 (2026-03-20) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed ``SummaryMetrics`` backend to + dynamically render unknown benchmark phases. Previously only hard-coded phase + names (``startup``, ``runtime``, ``train``, ``frametime``) were printed in the + summary report; any other phases were silently dropped. Unknown phases now + render their ``SingleMeasurement`` and ``StatisticalMeasurement`` entries + automatically. + + 4.5.22 (2026-03-16) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/test/benchmark/backends.py b/source/isaaclab/isaaclab/test/benchmark/backends.py index f9187bf5b18..6b6c0acacb1 100644 --- a/source/isaaclab/isaaclab/test/benchmark/backends.py +++ b/source/isaaclab/isaaclab/test/benchmark/backends.py @@ -276,6 +276,33 @@ def _print_summary(self) -> None: self._print_box_line(f"{label}: {value}") self._print_box_separator() + # Render any phases not handled above (e.g. profiling phases from benchmark_startup) + known_phases = { + "benchmark_info", + "runtime", + "startup", + "train", + "frametime", + "hardware_info", + "version_info", + } + for phase_name, phase in phases.items(): + if phase_name in known_phases or not phase.measurements: + continue + self._print_box_line(f"Phase: {phase_name}") + for measurement in phase.measurements: + label = measurement.name + if isinstance(measurement, StatisticalMeasurement): + unit_str = f" {measurement.unit.strip()}" if (measurement.unit and measurement.unit.strip()) else "" + value = f"{self._format_scalar(measurement.mean)}{unit_str}" + elif isinstance(measurement, SingleMeasurement): + unit_str = f" {measurement.unit.strip()}" if (measurement.unit and measurement.unit.strip()) else "" + value = f"{self._format_scalar(measurement.value)}{unit_str}" + else: + continue + self._print_box_line(f"{label}: {value}") + self._print_box_separator() + if hardware_meta: self._print_box_line("System:") self._print_box_kv("cpu_name", hardware_meta.get("cpu_name"))