From f544914c4c0afc8c7d5b18b17c92f46a5977b4f4 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 25 Aug 2026 18:22:41 +0800 Subject: [PATCH 1/3] perf(profiling): add temporary per-term timing instrumentation for managers (#1293) PROFILING_TEMP: opt-in via UNILAB_TERM_PROFILING=1, default off with a shared no-op context (one attribute check per call site). Covers observation (term func / post-processing / group concat, split by step|reset phase), reward, metrics, termination, event (all modes) and curriculum term loops. Stats reset per env instance so multi-case benchmarks stay isolated; atexit dumps the final case. TODO(#1292 cleanup): remove this module and all PROFILING_TEMP call sites once #1294/#1295/#1296 are complete. --- src/unilab/envs/manager_based_rl_env.py | 6 + src/unilab/managers/curriculum_manager.py | 13 ++- src/unilab/managers/event_manager.py | 23 +++- src/unilab/managers/metrics_manager.py | 11 +- src/unilab/managers/observation_manager.py | 127 ++++++++++++--------- src/unilab/managers/reward_manager.py | 19 +-- src/unilab/managers/termination_manager.py | 27 +++-- src/unilab/utils/term_profiling.py | 90 +++++++++++++++ tests/utils/test_term_profiling.py | 42 +++++++ tests/utils/test_utils_package_policy.py | 3 + 10 files changed, 274 insertions(+), 87 deletions(-) create mode 100644 src/unilab/utils/term_profiling.py create mode 100644 tests/utils/test_term_profiling.py diff --git a/src/unilab/envs/manager_based_rl_env.py b/src/unilab/envs/manager_based_rl_env.py index fd9925dc2..e0fcf9723 100644 --- a/src/unilab/envs/manager_based_rl_env.py +++ b/src/unilab/envs/manager_based_rl_env.py @@ -49,6 +49,9 @@ TerminationManager, TerminationTermCfg, ) +from unilab.utils.term_profiling import ( # PROFILING_TEMP (#1293, TODO: remove after #1292) + TERM_PROFILER, +) def _manager_terms_field() -> Any: @@ -315,6 +318,9 @@ def unwrapped(self) -> ManagerBasedRlEnv: def _load_managers(self) -> None: """Construct managers in the pinned community dependency order.""" + # PROFILING_TEMP (#1293, TODO: remove after #1292): a new env means a new + # benchmark case — dump the previous case's per-term stats and reset. + TERM_PROFILER.reset() self.event_manager = EventManager(self._cfg.events, self) self.command_manager = ( CommandManager(self._cfg.commands, self) if self._cfg.commands else NullCommandManager() diff --git a/src/unilab/managers/curriculum_manager.py b/src/unilab/managers/curriculum_manager.py index e2814b86a..ce94cb9be 100644 --- a/src/unilab/managers/curriculum_manager.py +++ b/src/unilab/managers/curriculum_manager.py @@ -13,6 +13,9 @@ from prettytable import PrettyTable from unilab.managers.manager_base import ManagerBase, ManagerTermBaseCfg +from unilab.utils.term_profiling import ( + profile_term, # PROFILING_TEMP (#1293, TODO: remove after #1292) +) if TYPE_CHECKING: from unilab.managers._types import ManagerBasedRlEnv @@ -111,12 +114,16 @@ def reset(self, env_ids: np.ndarray | slice | None = None) -> dict[str, float]: return extras def compute(self, env_ids: np.ndarray | slice | None = None) -> None: + # PROFILING_TEMP (#1293, TODO: remove after #1292) + phase = "reset" if env_ids is not None else "step" if env_ids is None: env_ids = slice(None) for name, term_cfg in zip(self._term_names, self._term_cfgs, strict=False): - state = term_cfg.func(self._env, env_ids, **term_cfg.params) - self._validate_state(name, state) - self._curriculum_state[name] = state + # PROFILING_TEMP (#1293, TODO: remove after #1292) + with profile_term(f"curriculum/{name}|{phase}"): + state = term_cfg.func(self._env, env_ids, **term_cfg.params) + self._validate_state(name, state) + self._curriculum_state[name] = state def _validate_state(self, term_name: str, state: Any) -> None: values = state.values() if isinstance(state, dict) else (state,) diff --git a/src/unilab/managers/event_manager.py b/src/unilab/managers/event_manager.py index fb98d7ea9..89b1d6f28 100644 --- a/src/unilab/managers/event_manager.py +++ b/src/unilab/managers/event_manager.py @@ -13,6 +13,9 @@ from prettytable import PrettyTable from unilab.managers.manager_base import ManagerBase, ManagerTermBaseCfg +from unilab.utils.term_profiling import ( + profile_term, # PROFILING_TEMP (#1293, TODO: remove after #1292) +) if TYPE_CHECKING: from unilab.managers._types import ManagerBasedRlEnv @@ -170,6 +173,8 @@ def apply( raise ValueError(f"Event mode '{mode}' requires the time-step of the environment.") for index, term_cfg in enumerate(self._mode_term_cfgs[mode]): + # PROFILING_TEMP (#1293, TODO: remove after #1292) + pkey = f"event/{mode}/{self._mode_term_names[mode][index]}" if mode == "interval": time_left = self._interval_term_time_left[index] assert dt is not None @@ -180,7 +185,8 @@ def apply( lower, upper = term_cfg.interval_range_s sampled_interval = self._env.rng.uniform(lower, upper, 1) self._interval_term_time_left[index][:] = sampled_interval - term_cfg.func(self._env, None, **term_cfg.params) + with profile_term(pkey): # PROFILING_TEMP (#1293) + term_cfg.func(self._env, None, **term_cfg.params) else: valid_env_ids = np.flatnonzero(time_left < 1e-6) if len(valid_env_ids) > 0: @@ -188,9 +194,11 @@ def apply( lower, upper = term_cfg.interval_range_s sampled_time = self._env.rng.uniform(lower, upper, len(valid_env_ids)) self._interval_term_time_left[index][valid_env_ids] = sampled_time - term_cfg.func(self._env, valid_env_ids, **term_cfg.params) + with profile_term(pkey): # PROFILING_TEMP (#1293) + term_cfg.func(self._env, valid_env_ids, **term_cfg.params) elif mode == "step": - term_cfg.func(self._env, None, **term_cfg.params) + with profile_term(pkey): # PROFILING_TEMP (#1293) + term_cfg.func(self._env, None, **term_cfg.params) elif mode == "reset": assert global_env_step_count is not None # Reset events require concrete indices: callers (e.g. ManagerBasedRlEnv) @@ -203,7 +211,8 @@ def apply( if min_step_count == 0: self._reset_term_last_triggered_step_id[index][env_ids] = global_env_step_count self._reset_term_last_triggered_once[index][env_ids] = True - term_cfg.func(self._env, env_ids, **term_cfg.params) + with profile_term(pkey): # PROFILING_TEMP (#1293) + term_cfg.func(self._env, env_ids, **term_cfg.params) else: last_triggered_step = self._reset_term_last_triggered_step_id[index][env_ids] triggered_at_least_once = self._reset_term_last_triggered_once[index][env_ids] @@ -219,9 +228,11 @@ def apply( self._reset_term_last_triggered_step_id[index][valid_env_ids] = ( global_env_step_count ) - term_cfg.func(self._env, valid_env_ids, **term_cfg.params) + with profile_term(pkey): # PROFILING_TEMP (#1293) + term_cfg.func(self._env, valid_env_ids, **term_cfg.params) else: - term_cfg.func(self._env, env_ids, **term_cfg.params) + with profile_term(pkey): # PROFILING_TEMP (#1293) + term_cfg.func(self._env, env_ids, **term_cfg.params) def _prepare_terms(self) -> None: self._interval_term_time_left: list[np.ndarray] = list() diff --git a/src/unilab/managers/metrics_manager.py b/src/unilab/managers/metrics_manager.py index 961ae135c..4a751ad9f 100644 --- a/src/unilab/managers/metrics_manager.py +++ b/src/unilab/managers/metrics_manager.py @@ -13,6 +13,9 @@ from prettytable import PrettyTable from unilab.managers.manager_base import ManagerBase, ManagerTermBaseCfg +from unilab.utils.term_profiling import ( + profile_term, # PROFILING_TEMP (#1293, TODO: remove after #1292) +) if TYPE_CHECKING: from unilab.managers._types import ManagerBasedRlEnv @@ -210,9 +213,11 @@ def _prepare_terms(self) -> None: def _compute_term(self, idx: int) -> np.ndarray: name = self._term_names[idx] term_cfg = self._term_cfgs[idx] - value = term_cfg.func(self._env, **term_cfg.params) - self._check_term_shape(name, value) - self._check_term_finite(name, value) + # PROFILING_TEMP (#1293, TODO: remove after #1292) + with profile_term(f"metrics/{name}"): + value = term_cfg.func(self._env, **term_cfg.params) + self._check_term_shape(name, value) + self._check_term_finite(name, value) return value diff --git a/src/unilab/managers/observation_manager.py b/src/unilab/managers/observation_manager.py index f47961c21..3191eb4c3 100644 --- a/src/unilab/managers/observation_manager.py +++ b/src/unilab/managers/observation_manager.py @@ -20,6 +20,9 @@ from unilab.managers._noise import noise_cfg, noise_model from unilab.managers._noise.noise_cfg import NoiseCfg, NoiseModelCfg from unilab.managers.manager_base import ManagerBase, ManagerTermBaseCfg +from unilab.utils.term_profiling import ( + profile_term, # PROFILING_TEMP (#1293, TODO: remove after #1292) +) if TYPE_CHECKING: from unilab.managers._types import ManagerBasedRlEnv @@ -388,8 +391,12 @@ def compute_group( # (num_envs, ...) and full-shape noise draws keep the shared RNG stream # and per-row noise values identical to the full-batch path. row_scoped = env_ids is not None and not self._group_obs_temporal[group_name] + # PROFILING_TEMP (#1293, TODO: remove after #1292) + phase = "reset" if env_ids is not None else "step" for term_name, term_cfg in obs_terms: - obs = term_cfg.func(self._env, **term_cfg.params) + # PROFILING_TEMP (#1293, TODO: remove after #1292) + with profile_term(f"obs/{group_name}/{term_name}|{phase}"): + obs = term_cfg.func(self._env, **term_cfg.params) if not isinstance(obs, np.ndarray): raise TypeError( f"ObservationManager term '{group_name}/{term_name}' returned " @@ -400,30 +407,33 @@ def compute_group( f"ObservationManager term '{group_name}/{term_name}' returned shape " f"{obs.shape}, expected (num_envs, ...) with num_envs={self.num_envs}." ) - if not row_scoped: - obs = obs.copy() - if isinstance(term_cfg.noise, noise_cfg.NoiseCfg): - obs = term_cfg.noise.apply(obs, rng=self._env.rng) - elif isinstance(term_cfg.noise, noise_cfg.NoiseModelCfg): - obs = self._group_obs_class_instances[group_name][term_name](obs) - if row_scoped: - # Fresh row copy; safe for the in-place clip/scale below. - obs = obs[env_ids] - if term_cfg.clip: - np.clip(obs, term_cfg.clip[0], term_cfg.clip[1], out=obs) - if term_cfg.scale is not None: - scale = term_cfg.scale - assert isinstance(scale, np.ndarray) - np.multiply(obs, scale, out=obs) - - # Check for NaN/Inf before delay/history buffers (per-term checking). - if group_cfg.nan_check_per_term and group_cfg.nan_policy != "disabled": - obs = self._check_and_handle_nans( - obs, - context=f"{group_name}/{term_name}", - policy=group_cfg.nan_policy, - env_ids=env_ids if row_scoped else None, - ) + # PROFILING_TEMP (#1293, TODO: remove after #1292): manager-level + # per-term post-processing (copy/noise/clip/scale/nan check). + with profile_term(f"obs_post/{group_name}/{term_name}|{phase}"): + if not row_scoped: + obs = obs.copy() + if isinstance(term_cfg.noise, noise_cfg.NoiseCfg): + obs = term_cfg.noise.apply(obs, rng=self._env.rng) + elif isinstance(term_cfg.noise, noise_cfg.NoiseModelCfg): + obs = self._group_obs_class_instances[group_name][term_name](obs) + if row_scoped: + # Fresh row copy; safe for the in-place clip/scale below. + obs = obs[env_ids] + if term_cfg.clip: + np.clip(obs, term_cfg.clip[0], term_cfg.clip[1], out=obs) + if term_cfg.scale is not None: + scale = term_cfg.scale + assert isinstance(scale, np.ndarray) + np.multiply(obs, scale, out=obs) + + # Check for NaN/Inf before delay/history buffers (per-term checking). + if group_cfg.nan_check_per_term and group_cfg.nan_policy != "disabled": + obs = self._check_and_handle_nans( + obs, + context=f"{group_name}/{term_name}", + policy=group_cfg.nan_policy, + env_ids=env_ids if row_scoped else None, + ) if term_cfg.delay_max_lag > 0: delay_buffer = self._group_obs_term_delay_buffer[group_name][term_name] @@ -448,43 +458,46 @@ def compute_group( else: group_obs[term_name] = obs - # Final NaN check for non-per-term checking. - if not group_cfg.nan_check_per_term and group_cfg.nan_policy != "disabled": + # PROFILING_TEMP (#1293, TODO: remove after #1292): group-level + # post-processing (group nan check / concatenate / reset row slice). + with profile_term(f"obs_group_post/{group_name}|{phase}"): + # Final NaN check for non-per-term checking. + if not group_cfg.nan_check_per_term and group_cfg.nan_policy != "disabled": + if self._group_obs_concatenate[group_name]: + # Will check after concatenation below. + pass + else: + for term_name in group_obs: + group_obs[term_name] = self._check_and_handle_nans( + group_obs[term_name], + context=f"{group_name}/{term_name}", + policy=group_cfg.nan_policy, + env_ids=env_ids if row_scoped else None, + ) + if self._group_obs_concatenate[group_name]: - # Will check after concatenation below. - pass - else: - for term_name in group_obs: - group_obs[term_name] = self._check_and_handle_nans( - group_obs[term_name], - context=f"{group_name}/{term_name}", + result = np.concatenate( + list(group_obs.values()), axis=self._group_obs_concatenate_dim[group_name] + ) + # Final check for concatenated result (non-per-term checking). + if not group_cfg.nan_check_per_term and group_cfg.nan_policy != "disabled": + result = self._check_and_handle_nans( + result, + context=group_name, policy=group_cfg.nan_policy, env_ids=env_ids if row_scoped else None, ) - - if self._group_obs_concatenate[group_name]: - result = np.concatenate( - list(group_obs.values()), axis=self._group_obs_concatenate_dim[group_name] - ) - # Final check for concatenated result (non-per-term checking). - if not group_cfg.nan_check_per_term and group_cfg.nan_policy != "disabled": - result = self._check_and_handle_nans( - result, - context=group_name, - policy=group_cfg.nan_policy, - env_ids=env_ids if row_scoped else None, - ) - else: - result = group_obs - - if env_ids is not None and not row_scoped: - # Groups with delay/history terms ran the full-batch pipeline above - # (buffer readout stays full-batch); slice the reset rows to match - # the reset-path return contract. - if isinstance(result, dict): - result = {name: values[env_ids] for name, values in result.items()} else: - result = result[env_ids] + result = group_obs + + if env_ids is not None and not row_scoped: + # Groups with delay/history terms ran the full-batch pipeline above + # (buffer readout stays full-batch); slice the reset rows to match + # the reset-path return contract. + if isinstance(result, dict): + result = {name: values[env_ids] for name, values in result.items()} + else: + result = result[env_ids] return result diff --git a/src/unilab/managers/reward_manager.py b/src/unilab/managers/reward_manager.py index 262d64164..7f06ed2ef 100644 --- a/src/unilab/managers/reward_manager.py +++ b/src/unilab/managers/reward_manager.py @@ -13,6 +13,9 @@ from prettytable import PrettyTable from unilab.managers.manager_base import ManagerBase, ManagerTermBaseCfg +from unilab.utils.term_profiling import ( + profile_term, # PROFILING_TEMP (#1293, TODO: remove after #1292) +) if TYPE_CHECKING: from unilab.managers._types import DebugVisualizer, ManagerBasedRlEnv @@ -123,13 +126,15 @@ def compute(self, dt: float) -> np.ndarray: if term_cfg.weight == 0.0: self._step_reward[:, term_idx] = 0.0 continue - value = term_cfg.func(self._env, **term_cfg.params) - self._check_term_shape(name, value) - self._check_term_finite(name, value) - value = value * term_cfg.weight * scale - self._reward_buf += value - self._episode_sums[name] += value - self._step_reward[:, term_idx] = value / scale + # PROFILING_TEMP (#1293, TODO: remove after #1292) + with profile_term(f"reward/{name}"): + value = term_cfg.func(self._env, **term_cfg.params) + self._check_term_shape(name, value) + self._check_term_finite(name, value) + value = value * term_cfg.weight * scale + self._reward_buf += value + self._episode_sums[name] += value + self._step_reward[:, term_idx] = value / scale return self._reward_buf def step_reward_extras(self) -> dict[str, float]: diff --git a/src/unilab/managers/termination_manager.py b/src/unilab/managers/termination_manager.py index 999cac9bb..c465617ad 100644 --- a/src/unilab/managers/termination_manager.py +++ b/src/unilab/managers/termination_manager.py @@ -13,6 +13,9 @@ from prettytable import PrettyTable from unilab.managers.manager_base import ManagerBase, ManagerTermBaseCfg +from unilab.utils.term_profiling import ( + profile_term, # PROFILING_TEMP (#1293, TODO: remove after #1292) +) if TYPE_CHECKING: from unilab.managers._types import ManagerBasedRlEnv @@ -100,17 +103,19 @@ def compute(self) -> np.ndarray: self._truncated_buf[:] = False self._terminated_buf[:] = False for name, term_cfg in zip(self._term_names, self._term_cfgs, strict=False): - value = term_cfg.func(self._env, **term_cfg.params) - self._check_term_shape(name, value) - if value.dtype != np.bool_: - raise TypeError( - f"TerminationManager term '{name}' returned dtype {value.dtype}, expected bool." - ) - if term_cfg.time_out: - self._truncated_buf |= value - else: - self._terminated_buf |= value - self._term_dones[name][:] = value + # PROFILING_TEMP (#1293, TODO: remove after #1292) + with profile_term(f"termination/{name}"): + value = term_cfg.func(self._env, **term_cfg.params) + self._check_term_shape(name, value) + if value.dtype != np.bool_: + raise TypeError( + f"TerminationManager term '{name}' returned dtype {value.dtype}, expected bool." + ) + if term_cfg.time_out: + self._truncated_buf |= value + else: + self._terminated_buf |= value + self._term_dones[name][:] = value return self._truncated_buf | self._terminated_buf def get_term(self, name: str) -> np.ndarray: diff --git a/src/unilab/utils/term_profiling.py b/src/unilab/utils/term_profiling.py new file mode 100644 index 000000000..86b789bc0 --- /dev/null +++ b/src/unilab/utils/term_profiling.py @@ -0,0 +1,90 @@ +"""PROFILING_TEMP (issue #1293): temporary per-term timing for manager hot paths. + +TODO(#1292 cleanup): this module and every ``PROFILING_TEMP`` call site are +temporary instrumentation. Delete them once the #1292 optimization sub-issues +(#1294/#1295/#1296) are complete. + +Enable with ``UNILAB_TERM_PROFILING=1``. When disabled, ``profile_term()`` +returns a shared no-op context manager, so each call site costs one attribute +check and no allocation. When enabled, timing overhead (two ``perf_counter`` +calls per term) is accepted — this is profiling code, not a hot-path feature. + +Stats accumulate per benchmark case: ``ManagerBasedRlEnv._load_managers`` +resets the profiler (dumping the previous case first), and an atexit hook +dumps the final case. +""" + +from __future__ import annotations + +import atexit +import os +import time +from collections import defaultdict +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager +from typing import Literal + +_ENABLED = os.environ.get("UNILAB_TERM_PROFILING", "") == "1" + + +class _NullCtx: + __slots__ = () + + def __enter__(self) -> None: + return None + + def __exit__(self, *exc: object) -> Literal[False]: + return False + + +class TermProfiler: + """Accumulates wall-clock time per term key; mean_ms ≈ ms/vector-step for + step-phase terms (one call per term per vector step).""" + + def __init__(self) -> None: + self.enabled = _ENABLED + self._total_s: dict[str, float] = defaultdict(float) + self._calls: dict[str, int] = defaultdict(int) + + def reset(self) -> None: + """Drop accumulated stats, dumping them first (new env = new case).""" + if self.enabled and self._calls: + self.dump() + self._total_s.clear() + self._calls.clear() + + @contextmanager + def time(self, key: str) -> Iterator[None]: + t0 = time.perf_counter() + try: + yield + finally: + self._total_s[key] += time.perf_counter() - t0 + self._calls[key] += 1 + + def dump(self) -> None: + lines = [ + "[TERM_PROFILING] per-term timing (PROFILING_TEMP, issue #1293; " + "mean_ms ≈ ms/vector-step for step-phase terms)", + f"{'term':<64} {'calls':>8} {'total_ms':>12} {'mean_ms':>10}", + ] + for key, total in sorted(self._total_s.items(), key=lambda kv: -kv[1]): + calls = self._calls[key] + lines.append(f"{key:<64} {calls:>8} {total * 1e3:>12.3f} {total / calls * 1e3:>10.4f}") + print("\n".join(lines), flush=True) + + +TERM_PROFILER = TermProfiler() + +_NULL_CTX = _NullCtx() + + +def profile_term(key: str) -> AbstractContextManager[None]: + """PROFILING_TEMP (#1293): time one term call; no-op when disabled.""" + if TERM_PROFILER.enabled: + return TERM_PROFILER.time(key) + return _NULL_CTX + + +if _ENABLED: + atexit.register(TERM_PROFILER.dump) diff --git a/tests/utils/test_term_profiling.py b/tests/utils/test_term_profiling.py new file mode 100644 index 000000000..5576e8436 --- /dev/null +++ b/tests/utils/test_term_profiling.py @@ -0,0 +1,42 @@ +"""PROFILING_TEMP (#1293, TODO: remove after #1292): tests for the temporary +per-term profiling instrumentation.""" + +import time + +from unilab.utils.term_profiling import TERM_PROFILER, TermProfiler, profile_term + + +def test_disabled_by_default_is_noop(): + # Test session never sets UNILAB_TERM_PROFILING, so profiling is disabled. + assert not TERM_PROFILER.enabled + with profile_term("x/y|step"): + pass + assert not TERM_PROFILER._calls + + +def test_enabled_profiler_records_and_dumps(capsys): + profiler = TermProfiler() + profiler.enabled = True + with profiler.time("reward/test"): + time.sleep(0.001) + with profiler.time("reward/test"): + pass + assert profiler._calls["reward/test"] == 2 + assert profiler._total_s["reward/test"] >= 0.001 + profiler.dump() + out = capsys.readouterr().out + assert "reward/test" in out + assert "PROFILING_TEMP" in out + + +def test_reset_dumps_then_clears(capsys): + profiler = TermProfiler() + profiler.enabled = True + with profiler.time("obs/g/t|step"): + pass + profiler.reset() + assert not profiler._calls + assert "obs/g/t|step" in capsys.readouterr().out + # Reset on empty stats prints nothing. + profiler.reset() + assert capsys.readouterr().out == "" diff --git a/tests/utils/test_utils_package_policy.py b/tests/utils/test_utils_package_policy.py index d4d345b83..7e62e341a 100644 --- a/tests/utils/test_utils_package_policy.py +++ b/tests/utils/test_utils_package_policy.py @@ -17,6 +17,9 @@ "seed", "sim2sim", "tensor", + # PROFILING_TEMP (#1293, TODO: remove after #1292): temporary per-term + # profiling instrumentation; removed together with the module. + "term_profiling", } REMOVED_UTILS_SHIMS = { "algo_utils", From 8718d035b504c95148b8e5cf847c3678eddb7b37 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 25 Aug 2026 18:22:41 +0800 Subject: [PATCH 2/3] style: apply ruff format to test_offpolicy_collector_active_benchmark.py --- tests/benchmark/test_offpolicy_collector_active_benchmark.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/benchmark/test_offpolicy_collector_active_benchmark.py b/tests/benchmark/test_offpolicy_collector_active_benchmark.py index 0dea370bd..eec7b0de6 100644 --- a/tests/benchmark/test_offpolicy_collector_active_benchmark.py +++ b/tests/benchmark/test_offpolicy_collector_active_benchmark.py @@ -105,9 +105,7 @@ def test_mjwarp_backend_is_opt_in_and_never_part_of_all() -> None: with pytest.raises(SystemExit, match="mjwarp extra"): bench._resolve_backend_selection(backend="mjwarp", all_backends=False) else: - assert bench._resolve_backend_selection(backend="mjwarp", all_backends=False) == ( - "mjwarp", - ) + assert bench._resolve_backend_selection(backend="mjwarp", all_backends=False) == ("mjwarp",) def test_resolve_case_specs_deduplicates_explicit_specs() -> None: From 062901eab4d73551de9b9094668ca5c21b4755a7 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 25 Aug 2026 18:22:41 +0800 Subject: [PATCH 3/3] docs: regenerate support matrix for g1_motion_tracking motrix=Configured Pre-existing staleness from the mjwarp wiring commit (77ca6dd9); caught by make test-all's check_docs gate. --- docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md index a0218eed0..a6a714932 100644 --- a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md @@ -100,7 +100,7 @@ uv run scripts/generate_support_matrix.py --write | APPO (torch) | `g1_climb_tracking` (g1 climb tracking) | Tested | - | Tested | | SAC (torch) | `g1_walk_flat` (G1 walk flat) | Tested | Tested | Tested | | SAC (torch) | `g1_walk_rough` (G1 walk rough) | Tested | - | Tested | -| SAC (torch) | `g1_motion_tracking` (G1 motion tracking) | Tested | - | Tested | +| SAC (torch) | `g1_motion_tracking` (G1 motion tracking) | Tested | Configured | Tested | | SAC (torch) | `g1_flip_tracking` (G1 flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_wall_flip_tracking` (G1 wall flip tracking) | Tested | - | Registered | | SAC (torch) | `g1_23dof_flip_tracking` (g1 23dof flip tracking) | Tested | - | Registered |