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 | diff --git a/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py b/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py index 6d1f6b3bd..ddd2f1cad 100644 --- a/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py +++ b/scripts/benchmark/rl/benchmark_offpolicy_collector_active.py @@ -127,6 +127,9 @@ "set_state_qpos_convert_ms", "set_state_pool_reset_ms", "set_state_state_scatter_ms", + "set_state_reset_upload_ms", + "set_state_reset_forward_ms", + "set_state_host_cache_refresh_ms", "set_state_internal_gap_ms", ) NP_ENV_STEP_COUNT_KEYS = ("reset_done_count",) @@ -183,6 +186,9 @@ ("set_state_qpos_convert_ms", "set_state_qpos_convert_ms"), ("set_state_pool_reset_ms", "set_state_pool_reset_ms"), ("set_state_state_scatter_ms", "set_state_state_scatter_ms"), + ("set_state_reset_upload_ms", "set_state_reset_upload_ms"), + ("set_state_reset_forward_ms", "set_state_reset_forward_ms"), + ("set_state_host_cache_refresh_ms", "set_state_host_cache_refresh_ms"), ("set_state_internal_gap_ms", "set_state_internal_gap_ms"), ) @@ -1315,6 +1321,13 @@ def _format_dr_reset_timing_table(results: list[CollectorResult]) -> str: ("set_state_internal_gap_ms", "Gap"), ) +_SET_STATE_MJWARP_KEYS = ( + ("set_state_reset_upload_ms", "Reset upload"), + ("set_state_reset_forward_ms", "Reset forward"), + ("set_state_host_cache_refresh_ms", "Host cache refresh"), + ("set_state_internal_gap_ms", "Gap"), +) + def _format_set_state_detail_table(results: list[CollectorResult]) -> str: """Backend set_state sub-timing table (motrix keyset). @@ -1375,6 +1388,32 @@ def _format_set_state_mujoco_table(results: list[CollectorResult]) -> str: return _format_table(headers, rows) +def _format_set_state_mjwarp_table(results: list[CollectorResult]) -> str: + """Backend set_state sub-timing table (mjwarp keyset).""" + headers = ( + "Algo", + "Task", + "Backend", + "Set state ms (%env, %active)", + *(label for _, label in _SET_STATE_MJWARP_KEYS), + ) + rows = [] + for result in results: + env_step = result.phase_ms_per_vector_step.get("env_step_ms") + if env_step is None: + continue + rows.append( + ( + result.case.algo, + result.case.task, + result.case.runtime_sim_backend, + _format_np_env_timing(result, "dr_reset_set_state_ms"), + *(_format_set_state_sub_ms(result, key) for key, _ in _SET_STATE_MJWARP_KEYS), + ) + ) + return _format_table(headers, rows) + + def _format_np_env_step_timing_table(results: list[CollectorResult]) -> str: headers = ( "Algo", @@ -1750,6 +1789,8 @@ def main() -> int: print(_format_set_state_detail_table(results)) print("\nBackend set_state detail — mujoco keyset:") print(_format_set_state_mujoco_table(results)) + print("\nBackend set_state detail — mjwarp keyset:") + print(_format_set_state_mjwarp_table(results)) else: print("No successful benchmark cases.") return 0 if not errors else 1 diff --git a/src/unilab/base/backend/base.py b/src/unilab/base/backend/base.py index 081da0f5d..571325389 100644 --- a/src/unilab/base/backend/base.py +++ b/src/unilab/base/backend/base.py @@ -823,6 +823,16 @@ def get_body_pose_w_rows( rows = np.asarray(env_ids, dtype=np.intp) return self.get_body_pos_w(body_ids)[rows], self.get_body_quat_w(body_ids)[rows] + def get_body_lin_vel_w_rows(self, env_ids: np.ndarray, body_ids: np.ndarray) -> np.ndarray: + """Get selected env rows of world-frame body linear velocity.""" + rows = np.asarray(env_ids, dtype=np.intp) + return self.get_body_lin_vel_w(body_ids)[rows] + + def get_body_ang_vel_w_rows(self, env_ids: np.ndarray, body_ids: np.ndarray) -> np.ndarray: + """Get selected env rows of world-frame body angular velocity.""" + rows = np.asarray(env_ids, dtype=np.intp) + return self.get_body_ang_vel_w(body_ids)[rows] + # ------------------------------------------------------------------ # # Body kinematics — baselink frame # # ------------------------------------------------------------------ # diff --git a/src/unilab/base/backend/mjwarp/backend.py b/src/unilab/base/backend/mjwarp/backend.py index 026bd252a..2effeb0f2 100644 --- a/src/unilab/base/backend/mjwarp/backend.py +++ b/src/unilab/base/backend/mjwarp/backend.py @@ -952,6 +952,26 @@ def step(self, ctrl: np.ndarray, nsteps: int = 1) -> dict[str, dict[str, float]] timings = self._execute_host_step(ctrl_array, int(nsteps)) return {"timing": timings} + # All backends report the same set_state key set for column stability; + # sub-keys that don't apply to the mjwarp host profile report 0.0. + _SET_STATE_TIMING_ZERO_KEYS = ( + "set_state_mask_ms", + "set_state_data_slice_ms", + "set_state_data_reset_ms", + "set_state_clear_forces_ms", + "set_state_geom_overrides_ms", + "set_state_reset_rand_ms", + "set_state_set_dof_vel_ms", + "set_state_set_dof_pos_ms", + "set_state_actuator_ctrl_ms", + "set_state_forward_kinematic_ms", + "set_state_refresh_pose_cache_ms", + "set_state_invalidate_velocity_ms", + "set_state_qpos_convert_ms", + "set_state_pool_reset_ms", + "set_state_state_scatter_ms", + ) + def set_state( self, env_indices: np.ndarray, @@ -974,9 +994,19 @@ def set_state( raise ValueError(f"qpos must have shape {expected_qpos}, got {qpos_array.shape}") if qvel_array.shape != expected_qvel: raise ValueError(f"qvel must have shape {expected_qvel}, got {qvel_array.shape}") + timing: dict[str, float] = {key: 0.0 for key in self._SET_STATE_TIMING_ZERO_KEYS} + timing.update( + { + "set_state_reset_upload_ms": 0.0, + "set_state_reset_forward_ms": 0.0, + "set_state_host_cache_refresh_ms": 0.0, + "set_state_internal_gap_ms": 0.0, + } + ) if rows.size == 0: - return {"timing": {"set_state_reset_ms": 0.0, "set_state_cache_refresh_ms": 0.0}} + return {"timing": timing} + outer_t0 = time.perf_counter() self._qpos_cache[rows] = qpos_array self._qvel_cache[rows] = qvel_array timings = self._execute_host_reset( @@ -986,12 +1016,17 @@ def set_state( qpos_array, qvel_array, ) - return { - "timing": { - "set_state_reset_ms": timings["reset_upload_ms"] + timings["reset_forward_ms"], - "set_state_cache_refresh_ms": timings["host_cache_refresh_ms"], - } - } + timing["set_state_reset_upload_ms"] = timings["reset_upload_ms"] + timing["set_state_reset_forward_ms"] = timings["reset_forward_ms"] + timing["set_state_host_cache_refresh_ms"] = timings["host_cache_refresh_ms"] + outer_total_ms = (time.perf_counter() - outer_t0) * 1000.0 + measured_ms = ( + timing["set_state_reset_upload_ms"] + + timing["set_state_reset_forward_ms"] + + timing["set_state_host_cache_refresh_ms"] + ) + timing["set_state_internal_gap_ms"] = outer_total_ms - measured_ms + return {"timing": timing} def get_dr_capabilities(self) -> DomainRandomizationCapabilities: """Advertise no legacy DR until per-world model mutation is effect-tested.""" diff --git a/src/unilab/base/backend/motrix/backend.py b/src/unilab/base/backend/motrix/backend.py index 53418dfe6..b98b06e81 100644 --- a/src/unilab/base/backend/motrix/backend.py +++ b/src/unilab/base/backend/motrix/backend.py @@ -739,6 +739,9 @@ def set_state( "set_state_qpos_convert_ms": 0.0, "set_state_pool_reset_ms": 0.0, "set_state_state_scatter_ms": 0.0, + "set_state_reset_upload_ms": 0.0, + "set_state_reset_forward_ms": 0.0, + "set_state_host_cache_refresh_ms": 0.0, "set_state_internal_gap_ms": 0.0, } outer_t0 = time.perf_counter() @@ -1164,6 +1167,14 @@ def get_body_vel_w(self, body_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray]: velocities = np.ascontiguousarray(self._ensure_link_velocity_cache()[:, ids, :]) return velocities[:, :, :3], velocities[:, :, 3:] + def get_body_lin_vel_w_rows(self, env_ids: np.ndarray, body_ids: np.ndarray) -> np.ndarray: + rows = np.asarray(env_ids, dtype=np.intp) + return self._ensure_link_velocity_cache()[rows[:, None], self._as_body_ids(body_ids), :3] # type: ignore[no-any-return] + + def get_body_ang_vel_w_rows(self, env_ids: np.ndarray, body_ids: np.ndarray) -> np.ndarray: + rows = np.asarray(env_ids, dtype=np.intp) + return self._ensure_link_velocity_cache()[rows[:, None], self._as_body_ids(body_ids), 3:] # type: ignore[no-any-return] + # ------------------------------------------------------------------ # # Body kinematics — baselink frame # # ------------------------------------------------------------------ # diff --git a/src/unilab/base/backend/mujoco/backend.py b/src/unilab/base/backend/mujoco/backend.py index 521468566..c6551b532 100644 --- a/src/unilab/base/backend/mujoco/backend.py +++ b/src/unilab/base/backend/mujoco/backend.py @@ -1081,6 +1081,9 @@ def set_state( "set_state_qpos_convert_ms": 0.0, "set_state_pool_reset_ms": 0.0, "set_state_state_scatter_ms": 0.0, + "set_state_reset_upload_ms": 0.0, + "set_state_reset_forward_ms": 0.0, + "set_state_host_cache_refresh_ms": 0.0, "set_state_internal_gap_ms": 0.0, } if len(env_indices) == 0: @@ -1417,6 +1420,14 @@ def get_body_lin_vel_w(self, body_ids: np.ndarray) -> np.ndarray: def get_body_ang_vel_w(self, body_ids: np.ndarray) -> np.ndarray: return self._tracked_angvel_w_all[:, self._get_mapped_indices(body_ids), :] # type: ignore[no-any-return] + def get_body_lin_vel_w_rows(self, env_ids: np.ndarray, body_ids: np.ndarray) -> np.ndarray: + rows = np.asarray(env_ids, dtype=np.intp) + return self._tracked_linvel_w_all[rows[:, None], self._get_mapped_indices(body_ids)] # type: ignore[no-any-return] + + def get_body_ang_vel_w_rows(self, env_ids: np.ndarray, body_ids: np.ndarray) -> np.ndarray: + rows = np.asarray(env_ids, dtype=np.intp) + return self._tracked_angvel_w_all[rows[:, None], self._get_mapped_indices(body_ids)] # type: ignore[no-any-return] + def get_body_state_w( self, body_ids: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: diff --git a/src/unilab/base/entity.py b/src/unilab/base/entity.py index 34f36a6d2..546167847 100644 --- a/src/unilab/base/entity.py +++ b/src/unilab/base/entity.py @@ -421,6 +421,26 @@ def body_link_ang_vel_w(self) -> np.ndarray: def body_link_pose_w(self) -> np.ndarray: return np.concatenate((self.body_link_pos_w, self.body_link_quat_w), axis=-1) + def body_link_pos_w_rows(self, env_ids: np.ndarray) -> np.ndarray: + """Row-scoped variant of body_link_pos_w for partial-reset rebuilds.""" + ids = self._require(self._body_ids, "body state") + return self._backend.get_body_pose_w_rows(env_ids, ids)[0] + + def body_link_quat_w_rows(self, env_ids: np.ndarray) -> np.ndarray: + """Row-scoped variant of body_link_quat_w for partial-reset rebuilds.""" + ids = self._require(self._body_ids, "body state") + return self._backend.get_body_pose_w_rows(env_ids, ids)[1] + + def body_link_lin_vel_w_rows(self, env_ids: np.ndarray) -> np.ndarray: + """Row-scoped variant of body_link_lin_vel_w for partial-reset rebuilds.""" + ids = self._require(self._body_ids, "body state") + return self._backend.get_body_lin_vel_w_rows(env_ids, ids) + + def body_link_ang_vel_w_rows(self, env_ids: np.ndarray) -> np.ndarray: + """Row-scoped variant of body_link_ang_vel_w for partial-reset rebuilds.""" + ids = self._require(self._body_ids, "body state") + return self._backend.get_body_ang_vel_w_rows(env_ids, ids) + @property def body_link_vel_w(self) -> np.ndarray: return np.concatenate((self.body_link_lin_vel_w, self.body_link_ang_vel_w), axis=-1) diff --git a/src/unilab/base/np_env.py b/src/unilab/base/np_env.py index 9359f5a9d..b90181605 100644 --- a/src/unilab/base/np_env.py +++ b/src/unilab/base/np_env.py @@ -62,6 +62,9 @@ "set_state_qpos_convert_ms", "set_state_pool_reset_ms", "set_state_state_scatter_ms", + "set_state_reset_upload_ms", + "set_state_reset_forward_ms", + "set_state_host_cache_refresh_ms", "set_state_internal_gap_ms", ) @@ -84,6 +87,9 @@ "set_state_qpos_convert_ms", "set_state_pool_reset_ms", "set_state_state_scatter_ms", + "set_state_reset_upload_ms", + "set_state_reset_forward_ms", + "set_state_host_cache_refresh_ms", "set_state_internal_gap_ms", ) @@ -287,8 +293,10 @@ def _reset_done_envs(self) -> None: finally: self._autoreset_reset_active = False detail_timing["reset_done_reset_call_ms"] = (time.perf_counter() - t0) * 1000.0 - if self._dr_manager is not None: - detail_timing.update(self._dr_manager.last_reset_timing_ms) + collected = self._collect_reset_backend_timing_ms() + detail_timing.update( + {key: value for key, value in collected.items() if key in detail_timing} + ) t0 = time.perf_counter() for key in self._state.obs: self._state.obs[key][env_indices] = new_obs[key] @@ -324,6 +332,18 @@ def _clear_reset_done_detail_timing(self, timing: dict[str, Any]) -> None: for key in RESET_DONE_DETAIL_TIMING_KEYS: timing[key] = 0.0 + def _collect_reset_backend_timing_ms(self) -> dict[str, float]: + """Backend-sourced reset sub-timings for the last reset call. + + The monolithic DR path reports through the DR manager; manager-based + envs override this to surface the reset-state transaction's set_state + timings. Keys outside RESET_DONE_DETAIL_TIMING_KEYS are dropped by the + caller so stale keys never leak into ``info["timing"]``. + """ + if self._dr_manager is not None: + return self._dr_manager.last_reset_timing_ms + return {} + def _resolve_nan_guard_model_file(self) -> str: scene = getattr(self._cfg, "scene", None) if isinstance(scene, SceneCfg) and scene.model_file: diff --git a/src/unilab/base/reset_state.py b/src/unilab/base/reset_state.py index 2bcdfefe5..990266255 100644 --- a/src/unilab/base/reset_state.py +++ b/src/unilab/base/reset_state.py @@ -7,6 +7,7 @@ from __future__ import annotations +import time from collections.abc import Iterator from contextlib import contextmanager @@ -55,6 +56,7 @@ def __init__( self._randomization_dirty_masks: dict[str, np.ndarray] = {} self._requesting_terms: set[str] = set() self._last_commit_had_writes = False + self._last_set_state_timing_ms: dict[str, float] = {} @property def active(self) -> bool: @@ -66,6 +68,17 @@ def last_commit_had_writes(self) -> bool: """Whether the most recent scoped commit submitted dirty rows to set_state.""" return self._last_commit_had_writes + @property + def last_set_state_timing_ms(self) -> dict[str, float]: + """Sub-timings from the most recent commit's set_state call. + + Always includes ``dr_reset_set_state_ms`` (outer wall-clock around the + backend call); backend-reported ``set_state_*_ms`` sub-keys are merged + in when the backend returns them. Empty when the last commit had no + dirty rows. + """ + return self._last_set_state_timing_ms + @contextmanager def scoped(self, env_ids: np.ndarray) -> Iterator[ResetStateTransaction]: """Begin a reset transaction and commit it only after all terms succeed.""" @@ -91,6 +104,7 @@ def begin(self, env_ids: np.ndarray) -> None: mask.fill(False) self._requesting_terms.clear() self._last_commit_had_writes = False + self._last_set_state_timing_ms = {} self._active = True def bind_body_mass_write( @@ -541,12 +555,22 @@ def commit(self) -> dict | None: assert self._qvel is not None randomization = self._build_randomization_payload(dirty_ids) try: - return self._backend.set_state( + set_state_t0 = time.perf_counter() + result = self._backend.set_state( dirty_ids, self._qpos[dirty_ids], self._qvel[dirty_ids], randomization=randomization, ) + timing: dict[str, float] = { + "dr_reset_set_state_ms": (time.perf_counter() - set_state_t0) * 1000.0 + } + if isinstance(result, dict): + backend_timing = result.get("timing") + if isinstance(backend_timing, dict): + timing.update(backend_timing) + self._last_set_state_timing_ms = timing + return result except (AttributeError, NotImplementedError) as exc: terms = ", ".join(sorted(self._requesting_terms)) raise NotImplementedError( diff --git a/src/unilab/envs/manager_based_rl_env.py b/src/unilab/envs/manager_based_rl_env.py index fd9925dc2..18369bcfa 100644 --- a/src/unilab/envs/manager_based_rl_env.py +++ b/src/unilab/envs/manager_based_rl_env.py @@ -564,11 +564,15 @@ def reset( if self._state is not None: self._state.info["steps"][ids] = 0 - self.command_manager.compute(dt=0.0, env_ids=ids) - self.command_manager.post_compute() - # Row-scoped reset rebuild (issue #1259 R2): the observation manager - # returns only the reset rows, so no full-batch slice is needed here. - manager_obs = self.observation_manager.compute(update_history=True, env_ids=ids) + # The read phase starts only after the reset-state transaction above + # committed, so cached getter values are post-set_state reads shared + # across terms (issue #1295). + with self.scene._scoped_state_reads(): + self.command_manager.compute(dt=0.0, env_ids=ids) + self.command_manager.post_compute() + # Row-scoped reset rebuild (issue #1259 R2): the observation manager + # returns only the reset rows, so no full-batch slice is needed here. + manager_obs = self.observation_manager.compute(update_history=True, env_ids=ids) mapped_obs = self._map_observations(manager_obs, num_rows=len(ids)) reset_obs = {name: values.copy() for name, values in mapped_obs.items()} @@ -587,6 +591,11 @@ def reset( self.recorder_manager.record_post_reset(ids) return reset_obs, {"log": log} + def _collect_reset_backend_timing_ms(self) -> dict[str, float]: + timing = dict(super()._collect_reset_backend_timing_ms()) + timing.update(self._reset_state.last_set_state_timing_ms) + return timing + def _normalize_reset_ids( self, env_indices: np.ndarray | None, diff --git a/src/unilab/tasks/motion_tracking/common/manager_terms.py b/src/unilab/tasks/motion_tracking/common/manager_terms.py index 2d7788398..d4523cc98 100644 --- a/src/unilab/tasks/motion_tracking/common/manager_terms.py +++ b/src/unilab/tasks/motion_tracking/common/manager_terms.py @@ -384,12 +384,30 @@ def _refresh_robot_state( step = self._env.common_step_counter if not force and self._robot_cache_step == step: return - sel: np.ndarray | slice = slice(None) if env_ids is None else env_ids - body_index = self._robot_body_ids - self._robot_body_pos_w[sel] = self.robot.data.body_link_pos_w[sel][:, body_index] - self._robot_body_quat_w[sel] = self.robot.data.body_link_quat_w[sel][:, body_index] - self._robot_body_lin_vel_w[sel] = self.robot.data.body_link_lin_vel_w[sel][:, body_index] - self._robot_body_ang_vel_w[sel] = self.robot.data.body_link_ang_vel_w[sel][:, body_index] + if env_ids is None: + body_index = self._robot_body_ids + self._robot_body_pos_w[:] = self.robot.data.body_link_pos_w[:, body_index] + self._robot_body_quat_w[:] = self.robot.data.body_link_quat_w[:, body_index] + self._robot_body_lin_vel_w[:] = self.robot.data.body_link_lin_vel_w[:, body_index] + self._robot_body_ang_vel_w[:] = self.robot.data.body_link_ang_vel_w[:, body_index] + else: + # Partial-reset path (issue #1295): gather only the reset rows from + # the backend instead of full-batch body reads sliced afterwards. + # _robot_body_ids selects the tracked subset afterwards, so the + # row getters fetch all entity bodies for just these rows. + data = self.robot.data + self._robot_body_pos_w[env_ids] = data.body_link_pos_w_rows(env_ids)[ + :, self._robot_body_ids + ] + self._robot_body_quat_w[env_ids] = data.body_link_quat_w_rows(env_ids)[ + :, self._robot_body_ids + ] + self._robot_body_lin_vel_w[env_ids] = data.body_link_lin_vel_w_rows(env_ids)[ + :, self._robot_body_ids + ] + self._robot_body_ang_vel_w[env_ids] = data.body_link_ang_vel_w_rows(env_ids)[ + :, self._robot_body_ids + ] self._robot_cache_step = step def _refresh_relative_state(self, env_ids: np.ndarray | None = None) -> None: diff --git a/tests/base/test_mjwarp_backend.py b/tests/base/test_mjwarp_backend.py index 3c4d236f9..db16df03b 100644 --- a/tests/base/test_mjwarp_backend.py +++ b/tests/base/test_mjwarp_backend.py @@ -76,15 +76,6 @@ def capture_launch(graph: Any) -> None: monkeypatch.setattr(backend._warp, "capture_launch", capture_launch) - graph_launches: list[Any] = [] - original_capture_launch = backend._warp.capture_launch - - def capture_launch(graph: Any) -> None: - graph_launches.append(graph) - original_capture_launch(graph) - - monkeypatch.setattr(backend._warp, "capture_launch", capture_launch) - qpos, qvel = _stand_state(backend, 2) backend.set_state(np.asarray([0, 1], dtype=np.int32), qpos, qvel) before = backend.get_base_pos().copy() @@ -325,6 +316,45 @@ def test_body_state_matches_mujoco_backend() -> None: ) +def test_set_state_returns_schema_conformant_timing() -> None: + """Issue #1295: mjwarp set_state reports the shared keyset plus its granular + reset_upload / reset_forward / host_cache_refresh sub-timings.""" + from unilab.base.np_env import BACKEND_SET_STATE_DETAIL_TIMING_KEYS + + backend = _backend(2) + qpos, qvel = _stand_state(backend, 2) + + result = backend.set_state(np.asarray([0, 1], dtype=np.int32), qpos, qvel) + timing = result["timing"] + missing = [key for key in BACKEND_SET_STATE_DETAIL_TIMING_KEYS if key not in timing] + assert not missing, f"missing keys in set_state timing: {missing}" + for key in BACKEND_SET_STATE_DETAIL_TIMING_KEYS: + value = timing[key] + assert isinstance(value, float), f"{key} must be float, got {type(value)!r}" + if not key.endswith("internal_gap_ms"): + assert value >= 0.0, f"{key} must be non-negative, got {value}" + assert abs(timing["set_state_internal_gap_ms"]) < 5.0 + # mjwarp populates its own sub-keys; other backends' sub-keys stay 0.0. + assert timing["set_state_reset_upload_ms"] > 0.0 + assert timing["set_state_reset_forward_ms"] > 0.0 + assert timing["set_state_host_cache_refresh_ms"] > 0.0 + assert timing["set_state_mask_ms"] == 0.0 + assert timing["set_state_pool_reset_ms"] == 0.0 + # Legacy collapsed keys are gone. + assert "set_state_reset_ms" not in timing + assert "set_state_cache_refresh_ms" not in timing + + empty = backend.set_state( + np.asarray([], dtype=np.int32), + np.zeros((0, qpos.shape[1]), dtype=np.float32), + np.zeros((0, qvel.shape[1]), dtype=np.float32), + ) + empty_timing = empty["timing"] + missing = [key for key in BACKEND_SET_STATE_DETAIL_TIMING_KEYS if key not in empty_timing] + assert not missing, f"missing keys in empty set_state timing: {missing}" + assert all(value == 0.0 for value in empty_timing.values()) + + def test_body_state_untracked_body_ids_fail_closed() -> None: """Body ids outside the injected tracking set raise instead of wrapping around.""" _require_cuda_mjwarp() diff --git a/tests/base/test_sim_backend_smoke.py b/tests/base/test_sim_backend_smoke.py index cf6ec6e21..5cf978d4e 100644 --- a/tests/base/test_sim_backend_smoke.py +++ b/tests/base/test_sim_backend_smoke.py @@ -521,6 +521,17 @@ def test_mujoco_copy_body_state_matches_split_queries(): np.testing.assert_allclose(out_lin_vel, expected_lin_vel) np.testing.assert_allclose(out_ang_vel, expected_ang_vel) + # Issue #1295: row-scoped velocity getters are parity with full+slice. + row_ids = np.array([1, 0, 1], dtype=np.int32) + np.testing.assert_allclose( + bkd.get_body_lin_vel_w_rows(row_ids, body_ids), + bkd.get_body_lin_vel_w(body_ids)[row_ids], + ) + np.testing.assert_allclose( + bkd.get_body_ang_vel_w_rows(row_ids, body_ids), + bkd.get_body_ang_vel_w(body_ids)[row_ids], + ) + def test_motrix_model_properties_smoke(): pytest.importorskip("motrixsim") @@ -577,6 +588,15 @@ def test_motrix_copy_body_state_matches_split_queries(): bkd.get_sensor_data_rows("pelvis_local_linvel", row_ids), bkd.get_sensor_data("pelvis_local_linvel")[row_ids], ) + # Issue #1295: row-scoped velocity getters are parity with full+slice. + np.testing.assert_allclose( + bkd.get_body_lin_vel_w_rows(row_ids, body_ids), + bkd.get_body_lin_vel_w(body_ids)[row_ids], + ) + np.testing.assert_allclose( + bkd.get_body_ang_vel_w_rows(row_ids, body_ids), + bkd.get_body_ang_vel_w(body_ids)[row_ids], + ) def test_motrix_default_qpos_uses_mujoco_quaternion_convention(): diff --git a/tests/benchmark/test_offpolicy_collector_active_benchmark.py b/tests/benchmark/test_offpolicy_collector_active_benchmark.py index 0dea370bd..6daf14fdc 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: @@ -298,6 +296,9 @@ def test_write_csv_includes_backend_set_state_sub_timing_columns(tmp_path) -> No "set_state_qpos_convert_ms", "set_state_pool_reset_ms", "set_state_state_scatter_ms", + "set_state_reset_upload_ms", + "set_state_reset_forward_ms", + "set_state_host_cache_refresh_ms", "set_state_internal_gap_ms", ) for key in expected_keys: @@ -352,3 +353,30 @@ def test_format_set_state_mujoco_table_covers_mujoco_only_keys() -> None: assert "State scatter" in table assert "4.000 (80.0%)" in table assert "0.750 (15.0%)" in table + + +def test_format_set_state_mjwarp_table_covers_mjwarp_only_keys() -> None: + """The mjwarp keyset table exposes reset_upload / forward / host_cache_refresh.""" + result = _make_result( + runtime_sim_backend="mjwarp", + num_envs=2, + throughput=2000.0, + include_env_step_breakdown=True, + ) + result.env_step_timing_ms_per_vector_step["dr_reset_set_state_ms"] = bench.TimingStats( + [8.0], 8.0, 8.0, 0.0, 8.0, 8.0 + ) + result.env_step_timing_ms_per_vector_step["set_state_reset_upload_ms"] = bench.TimingStats( + [4.0], 4.0, 4.0, 0.0, 4.0, 4.0 + ) + result.env_step_timing_ms_per_vector_step["set_state_host_cache_refresh_ms"] = ( + bench.TimingStats([2.0], 2.0, 2.0, 0.0, 2.0, 2.0) + ) + + table = bench._format_set_state_mjwarp_table([result]) + + assert "Reset upload" in table + assert "Reset forward" in table + assert "Host cache refresh" in table + assert "4.000 (50.0%)" in table + assert "2.000 (25.0%)" in table