From bb14bcb390e243c6888f9bcfdbff613a4162e42a Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 15 Sep 2026 15:54:38 +0800 Subject: [PATCH 1/6] test(isaacgym): cover fixed variants through mock and runtime (#1578) --- .../en/2-user_guide/3-backends/3-isaacgym.md | 8 + .../2-user_guide/3-backends/3-isaacgym.md | 6 + tests/base/isaacgym_mock_worker.py | 82 ++++++- tests/base/test_isaacgym_fixed_variants.py | 212 ++++++++++++++++++ 4 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 tests/base/test_isaacgym_fixed_variants.py diff --git a/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md b/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md index 99846f88f..69af94326 100644 --- a/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md +++ b/docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md @@ -44,6 +44,14 @@ parameter is parsed from the XML rather than read from the importer. `get_joint_range()` still reports the XML values. Joint `armature` and `frictionloss` (resolved through MJCF default classes) are applied to the PhysX dofs. +- **Fixed model variants**: `env.fixed_model_variants` is realized by + actor-level asset selection. Each complete MJCF source is loaded once and + every environment's actor is created from its immutable assignment row; + dof/body counts and name order must match the canonical variant, while + internal PhysX shape counts may differ. Playback resolves the assigned + source and native rendering already shows that environment's actor. No + production task ships an IsaacGym fixed-variant owner yet, and 600-variant + scale remains gated on benchmark #1579. ## Prerequisites diff --git a/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md b/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md index 21263dab1..9f37da2e8 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/3-backends/3-isaacgym.md @@ -35,6 +35,12 @@ benchmark 脚本 - **关节限位**:importer 会丢弃 joint range,因此 PhysX 侧没有关节 限位;`get_joint_range()` 仍返回 XML 值。关节 `armature` 与 `frictionloss`(经 MJCF default class 解析)会应用到 PhysX dof。 +- **固定模型变体**:`env.fixed_model_variants` 通过 actor 级资产选择实现。 + 每个完整 MJCF 源只装载一次,每个环境的 actor 按不可变 assignment 行创建; + dof/body 数量与名称顺序必须与规范变体一致,内部 PhysX shape 数量可以不同。 + playback 解析对应 source,原生渲染展示的就是该环境的 actor。当前还没有 + 生产任务提供 IsaacGym fixed-variant owner,600 变体规模仍以 benchmark + #1579 为 gate。 ## 前置条件 diff --git a/tests/base/isaacgym_mock_worker.py b/tests/base/isaacgym_mock_worker.py index b61892a84..9b19afc5b 100644 --- a/tests/base/isaacgym_mock_worker.py +++ b/tests/base/isaacgym_mock_worker.py @@ -27,6 +27,10 @@ ``capture_wrong_shape`` return malformed camera frames. Model dims come from ``UNILAB_ISAACGYM_MOCK_DOF_NAMES`` / ``UNILAB_ISAACGYM_MOCK_BODY_NAMES`` (comma-separated). +Fixed-variant INIT payloads are validated and echoed: the mock checks one +dof-field table and one optional keyframe per variant, applies each env's +assigned keyframe, and returns ``fixed_variant_count`` / +``fixed_variant_assignment`` in the handshake. """ from __future__ import annotations @@ -63,6 +67,8 @@ def __init__( startup_render_mode: str | None = None, startup_width: int = 1280, startup_height: int = 720, + variant_assignment: List[int] | None = None, + variant_count: int | None = None, ): self.graphics_enabled = graphics_enabled self.startup_render_mode = startup_render_mode @@ -84,6 +90,8 @@ def __init__( self.capture_ready = False self.capture_width = 0 self.capture_height = 0 + self.variant_assignment = variant_assignment + self.variant_count = variant_count def attach(self, slots: Dict[str, Dict[str, Any]]) -> None: from multiprocessing import resource_tracker, shared_memory @@ -124,9 +132,15 @@ def step(self, nsteps: int) -> None: self.root[:, 0:3] += self.root[:, 7:10] * dt self.write_state_slots() - def apply_keyframe(self, qpos_values: Any, joint_names: List[str]) -> None: + def apply_keyframe( + self, + qpos_values: Any, + joint_names: List[str], + env_ids: List[int] | None = None, + ) -> None: """Apply the INIT keyframe pose with the same name-mapping rules as the real worker (root columns pass through in the mock's wxyz convention).""" + selected = list(range(self.num_envs)) if env_ids is None else list(env_ids) qpos = np.asarray(qpos_values, dtype=np.float32).reshape(-1) expected = 7 + self.num_dof if qpos.size != expected: @@ -142,9 +156,31 @@ def apply_keyframe(self, qpos_values: Any, joint_names: List[str]) -> None: raise RuntimeError( "isaacgym asset dof %r is missing from mjcf_joint_names" % dof_name ) - self.dof[:, dof_index, 0] = qpos[7 + index_by_name[dof_name]] - self.root[:, 0:3] = qpos[0:3] - self.root[:, 3:7] = qpos[3:7] + self.dof[selected, dof_index, 0] = qpos[7 + index_by_name[dof_name]] + self.root[selected, 0:3] = qpos[0:3] + self.root[selected, 3:7] = qpos[3:7] + + def apply_variant_keyframes( + self, + qpos_by_variant: List[Any], + joint_names: List[str], + ) -> None: + """Apply the assigned variant's INIT keyframe to each environment.""" + if self.variant_assignment is None: + raise RuntimeError("variant keyframes require a variant assignment") + if len(qpos_by_variant) != len(set(self.variant_assignment)): + raise RuntimeError( + "variant keyframe table has %d rows for %d assigned variants" + % (len(qpos_by_variant), len(set(self.variant_assignment))) + ) + if any(value is None for value in qpos_by_variant): + raise RuntimeError("every assigned variant must provide a keyframe") + for env_index, variant_index in enumerate(self.variant_assignment): + self.apply_keyframe( + qpos_by_variant[variant_index], + joint_names, + env_ids=[env_index], + ) def set_state(self, count: int) -> None: env_ids = self.slots["reset_env_ids"][:count].astype(np.int64) @@ -176,6 +212,11 @@ def meta(self, behavior: str = "ok") -> Dict[str, Any]: "env_origins": [[float(i) * 2.0, 0.0, 0.0] for i in range(self.num_envs)], "collision_filtering_applied": True, } + if self.variant_assignment is not None: + if self.variant_count is None: + raise RuntimeError("variant assignment requires a variant count") + result["fixed_variant_count"] = self.variant_count + result["fixed_variant_assignment"] = list(self.variant_assignment) if self.startup_render_mode is not None: result.update( { @@ -287,6 +328,27 @@ def dispatch(cmd: str, payload: Any) -> Tuple[str, Any]: if cmd == protocol.CMD_INIT: if behavior == "fail_init": raise RuntimeError("mock init failure") + variant_files = payload.get("variant_model_files") + variant_assignment: List[int] | None = None + variant_count: int | None = None + if variant_files is not None: + variant_count = len(variant_files) + variant_assignment = [ + int(value) for value in payload.get("variant_assignment") or [] + ] + if len(variant_assignment) != int(payload["num_envs"]): + raise RuntimeError( + "variant assignment has %d entries for %d envs" + % (len(variant_assignment), int(payload["num_envs"])) + ) + if any(value < 0 or value >= variant_count for value in variant_assignment): + raise RuntimeError("variant assignment contains an invalid index") + variant_fields = payload.get("variant_dof_fields") or [] + if len(variant_fields) != variant_count: + raise RuntimeError( + "variant dof-field table has %d rows for %d variants" + % (len(variant_fields), variant_count) + ) sim = _MockSim( num_envs=int(payload["num_envs"]), sim_dt=float(payload["sim_dt"]), @@ -303,9 +365,19 @@ def dispatch(cmd: str, payload: Any) -> Tuple[str, Any]: ), startup_width=int(payload.get("render_width", 1280)), startup_height=int(payload.get("render_height", 720)), + variant_assignment=variant_assignment, + variant_count=variant_count, ) + variant_keyframes = payload.get("variant_keyframe_qpos") keyframe_qpos = payload.get("keyframe_qpos") - if keyframe_qpos is not None: + if variant_keyframes is not None: + if variant_count is None or len(variant_keyframes) != variant_count: + raise RuntimeError("variant keyframe row count mismatch") + if any(value is not None for value in variant_keyframes): + sim.apply_variant_keyframes( + variant_keyframes, payload.get("mjcf_joint_names") or [] + ) + elif keyframe_qpos is not None: sim.apply_keyframe(keyframe_qpos, payload.get("mjcf_joint_names") or []) return protocol.CMD_META, sim.meta(behavior) assert sim is not None diff --git a/tests/base/test_isaacgym_fixed_variants.py b/tests/base/test_isaacgym_fixed_variants.py new file mode 100644 index 000000000..30cdb4941 --- /dev/null +++ b/tests/base/test_isaacgym_fixed_variants.py @@ -0,0 +1,212 @@ +"""IsaacGym fixed-variant integration through the deterministic protocol mock.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest +from unisim.backend.isaacgym.backend import IsaacGymBackend +from unisim.dr.types import FixedVariantPlan, ModelSourceDescriptor + +from unilab.base.backend_factory import create_backend +from unilab.base.scene import SceneCfg + +_MOCK_WORKER = Path(__file__).resolve().parent / "isaacgym_mock_worker.py" +_SIM_DT = 0.005 +_DOF_NAMES = ("j0", "j1", "j2") +_BODY_NAMES = ("base", "link0", "link1", "link2") + + +def _variant_xml( + *, + mass: float, + size: float, + key_dof: tuple[float, float, float], + kp: tuple[float, float, float], +) -> str: + return f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def _write_variants(root: Path, *, count: int = 3) -> tuple[Path, ...]: + files: list[Path] = [] + for index in range(count): + path = root / f"variant_{index}.xml" + path.write_text( + _variant_xml( + mass=1.0 + 0.25 * index, + size=0.08 + 0.01 * index, + key_dof=(0.1 * index, 0.2, -0.1), + kp=(20.0 + 10.0 * index, 30.0, 40.0), + ), + encoding="utf-8", + ) + files.append(path) + return tuple(files) + + +def _make_backend( + sources: tuple[Path, ...], + assignment: tuple[int, ...], + monkeypatch: pytest.MonkeyPatch, +) -> IsaacGymBackend: + monkeypatch.setenv("UNILAB_ISAACGYM_MOCK_DOF_NAMES", ",".join(_DOF_NAMES)) + monkeypatch.setenv("UNILAB_ISAACGYM_MOCK_BODY_NAMES", ",".join(_BODY_NAMES)) + plan = FixedVariantPlan( + assignment=np.asarray(assignment, dtype=np.int32), + variants=tuple(ModelSourceDescriptor(str(path)) for path in sources), + ) + backend = create_backend( + "isaacgym", + SceneCfg(model_file=str(sources[0]), fixed_variant_plan=plan), + len(assignment), + _SIM_DT, + base_name="base", + worker_command=[sys.executable, str(_MOCK_WORKER)], + worker_timeout_s=30.0, + ) + assert isinstance(backend, IsaacGymBackend) + if not backend.get_dr_capabilities().supports_fixed_variants: + backend.close() + pytest.skip("installed unisim-core does not implement IsaacGym fixed variants") + return backend + + +def test_mock_protocol_realizes_immutable_variants( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sources = _write_variants(tmp_path) + backend = _make_backend(sources, (1, 0, 2, 1), monkeypatch) + try: + capabilities = backend.get_dr_capabilities() + assert capabilities.supports_fixed_variants + assert capabilities.supports_per_env_playback + + backend.materialize() + assert backend.model.dof_names == _DOF_NAMES + assert backend.model.body_names == _BODY_NAMES + assert [backend.get_playback_model(index) for index in range(4)] == [ + str(sources[1]), + str(sources[0]), + str(sources[2]), + str(sources[1]), + ] + # Assignment row 0 selects variant 1, so the canonical default is that + # variant's task-initial keyframe rather than scene.model_file's row. + np.testing.assert_allclose( + backend.get_default_dof_pos(), np.asarray([0.1, 0.2, -0.1]), atol=1e-7 + ) + np.testing.assert_allclose( + backend.get_dof_pos(), + np.asarray( + [ + [0.1, 0.2, -0.1], + [0.0, 0.2, -0.1], + [0.2, 0.2, -0.1], + [0.1, 0.2, -0.1], + ], + dtype=np.float32, + ), + atol=1e-7, + ) + backend.step(np.zeros((4, 3), dtype=np.float32), nsteps=1) + assert np.isfinite(backend.get_dof_pos()).all() + finally: + backend.close() + + +def test_public_layout_drift_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sources = list(_write_variants(tmp_path, count=2)) + drifting = tmp_path / "drifting.xml" + drifting.write_text( + _variant_xml( + mass=2.0, + size=0.2, + key_dof=(0.3, 0.2, -0.1), + kp=(50.0, 30.0, 40.0), + ).replace( + '', + '' + '', + ), + encoding="utf-8", + ) + sources.append(drifting) + backend = _make_backend(tuple(sources), (0, 1, 0), monkeypatch) + try: + with pytest.raises(ValueError, match="changes public joint_names"): + backend.materialize() + finally: + backend.close() + + +@pytest.mark.slow +def test_real_isaacgym_runtime_fixed_variants(tmp_path: Path) -> None: + """Real Preview-4 lane; skips when the dedicated runtime is unavailable.""" + from unisim.backend.isaacgym.dependencies import isaacgym_runtime_available + + if not isaacgym_runtime_available(): + pytest.skip("IsaacGym Preview 4 runtime is not installed") + + sources = _write_variants(tmp_path) + plan = FixedVariantPlan( + assignment=np.asarray([0, 1, 2], dtype=np.int32), + variants=tuple(ModelSourceDescriptor(str(path)) for path in sources), + ) + backend = create_backend( + "isaacgym", + SceneCfg(model_file=str(sources[0]), fixed_variant_plan=plan), + 3, + _SIM_DT, + base_name="base", + device_id=-1, + worker_timeout_s=120.0, + ) + assert isinstance(backend, IsaacGymBackend) + if not backend.get_dr_capabilities().supports_fixed_variants: + backend.close() + pytest.skip("installed unisim-core does not implement IsaacGym fixed variants") + try: + backend.materialize() + assert [backend.get_playback_model(index) for index in range(3)] == [ + str(path) for path in sources + ] + backend.step(np.zeros((3, 3), dtype=np.float32), nsteps=1) + assert np.isfinite(backend.get_dof_pos()).all() + assert np.isfinite(backend.get_base_pos()).all() + finally: + backend.close() From aba9c2e4702f1d1e34b7a84fb9271bd7c2ae0f9a Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 15 Sep 2026 15:54:38 +0800 Subject: [PATCH 2/6] bench(isaacgym): add fixed variant scale benchmark (#1579) --- docs/sphinx/source/changelog.md | 22 ++ .../benchmark_isaacgym_fixed_variants.py | 277 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index 02961bb08..eff886939 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -13,6 +13,28 @@ UniLab 遵循[语义化版本](https://semver.org/)。本共享页面以中英 PyPI 版本变更与未发布变更;发布日期采用 PyPI 上传日期。完整提交历史请参阅 [UniLab 仓库](https://github.com/unilabsim/UniLab)。 +## Unreleased / 未发布 + +### Added / 新增 + +- Added IsaacGym fixed-variant protocol and real-runtime coverage. The + deterministic worker mock validates and echoes the construction-time variant + assignment, while the external Preview-4 slow lane realizes per-env actor + asset selection and per-variant keyframes; public layout drift fails closed. + IsaacGym fixed variants and the still-pending 600-variant support decision + are documented on the backend page. + 新增 IsaacGym fixed-variant 协议层与真实 runtime 覆盖。确定性 worker mock + 校验并回显 construction-time variant assignment;外部 Preview 4 slow lane + 验证逐环境 actor 资产选择与逐变体 keyframe;公共布局漂移 fail closed。 + IsaacGym fixed variants 及仍待决策的 600 变体支持边界已写入后端文档。 +- Added the IsaacGym fixed-variant scale benchmark. Each variant count runs in + a fresh child process and records source generation, construction time, + live worker RSS, and control/physics/env-step rates; results are written as + a versioned JSON artifact for the #1579 support decision. + 新增 IsaacGym fixed-variant 规模 benchmark。每个 variant 数在独立子进程中 + 运行,记录源生成、构造时间、worker 实时 RSS 与 control/physics/env-step + 速率,并输出版本化 JSON artifact 供 #1579 support 决策使用。 + ## 1.3.0 (2026-09-14) ### Breaking changes / 破坏性变更 diff --git a/scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py b/scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py new file mode 100644 index 000000000..02e73aa25 --- /dev/null +++ b/scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Benchmark IsaacGym fixed-variant construction, memory, and stepping. + +Each variant count runs in a fresh child process so ``ru_maxrss`` for the +IsaacGym worker is not contaminated by an earlier K point. The child prints one +JSON line; the parent collects the lines into the final artifact. + +Example: + uv run --no-sync python \\ + scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py \\ + --variant-counts 1 4 64 600 --num-envs 4096 \\ + --output /tmp/isaacgym-fixed-variants.json +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import subprocess +import sys +import tempfile +import time +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +from unisim.backend.isaacgym.dependencies import isaacgym_runtime_available +from unisim.dr.types import FixedVariantPlan, ModelSourceDescriptor +from unisim.factory import create_backend +from unisim.scene import SceneCfg + +_RESULT_MARKER = "__ISAACGYM_FIXED_VARIANT_RESULT__" + + +def _descendant_rss_kib() -> int: + """Return the live RSS sum of this process's descendants (the worker).""" + processes: dict[int, tuple[int, int]] = {} + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + try: + ppid: int | None = None + rss_kib = 0 + for line in (entry / "status").read_text(encoding="utf-8").splitlines(): + if line.startswith("PPid:"): + ppid = int(line.split()[1]) + elif line.startswith("VmRSS:"): + rss_kib = int(line.split()[1]) + break + if ppid is not None: + processes[int(entry.name)] = (ppid, rss_kib) + except (FileNotFoundError, ProcessLookupError, ValueError, PermissionError): + continue + + children: dict[int, list[int]] = defaultdict(list) + for pid, (ppid, _rss) in processes.items(): + children[ppid].append(pid) + reachable: list[int] = list(children.get(os.getpid(), ())) + total = 0 + while reachable: + pid = reachable.pop() + total += processes[pid][1] + reachable.extend(children.get(pid, ())) + return total + + +def _variant_xml(index: int) -> str: + mass = 1.0 + 0.0025 * index + size = 0.08 + 0.0001 * (index % 100) + key = (index % 20) * 0.01 + kp = 20.0 + (index % 30) + return f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def _write_sources(root: Path, count: int) -> tuple[Path, ...]: + files: list[Path] = [] + for index in range(count): + path = root / f"variant_{index:04d}.xml" + path.write_text(_variant_xml(index), encoding="utf-8") + files.append(path) + return tuple(files) + + +def _gpu_info() -> dict[str, str]: + try: + raw = subprocess.check_output( + ["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"], + text=True, + ).strip() + name, driver = (part.strip() for part in raw.splitlines()[0].split(",", 1)) + return {"gpu": name, "driver": driver} + except Exception: + return {"gpu": "unknown", "driver": "unknown"} + + +def _run_one(args: argparse.Namespace) -> None: + if not isaacgym_runtime_available(): + raise RuntimeError("IsaacGym runtime is unavailable; set UNISIM_ISAACGYM_HOME") + + with tempfile.TemporaryDirectory(prefix="isaacgym-fixed-variants-") as tmp: + source_root = Path(tmp) + source_start = time.perf_counter() + sources = _write_sources(source_root, args.variant_count) + source_seconds = time.perf_counter() - source_start + + assignment = np.arange(args.num_envs, dtype=np.int32) % np.int32(args.variant_count) + plan = FixedVariantPlan( + assignment=assignment, + variants=tuple(ModelSourceDescriptor(str(path)) for path in sources), + ) + construction_start = time.perf_counter() + backend = create_backend( + "isaacgym", + SceneCfg(model_file=str(sources[0]), fixed_variant_plan=plan), + args.num_envs, + args.sim_dt, + base_name="base", + device_id=args.device_id, + worker_timeout_s=args.worker_timeout_s, + ) + try: + if not backend.get_dr_capabilities().supports_fixed_variants: + raise RuntimeError("installed unisim-core lacks IsaacGym fixed variants") + backend.materialize() + construction_seconds = time.perf_counter() - construction_start + worker_rss_kib = _descendant_rss_kib() + + ctrl = np.zeros((args.num_envs, 3), dtype=np.float32) + for _ in range(args.warmup_steps): + backend.step(ctrl, nsteps=args.nsteps) + step_start = time.perf_counter() + for _ in range(args.measure_steps): + backend.step(ctrl, nsteps=args.nsteps) + step_seconds = time.perf_counter() - step_start + if not np.isfinite(backend.get_dof_pos()).all(): + raise RuntimeError("benchmark rollout produced non-finite dof state") + finally: + backend.close() + + physics_steps = args.measure_steps * args.nsteps + result = { + "variant_count": args.variant_count, + "num_envs": args.num_envs, + "device_id": args.device_id, + "nsteps": args.nsteps, + "source_generation_s": source_seconds, + "construction_s": construction_seconds, + "worker_rss_kib": worker_rss_kib, + "measure_control_steps": args.measure_steps, + "step_s": step_seconds, + "control_steps_per_s": args.measure_steps / step_seconds, + "physics_steps_per_s": physics_steps / step_seconds, + "env_steps_per_s": (physics_steps * args.num_envs) / step_seconds, + } + print(_RESULT_MARKER + json.dumps(result), flush=True) + + +def _spawn_measurement(args: argparse.Namespace, variant_count: int) -> dict[str, float | int]: + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--run-one", + "--variant-count", + str(variant_count), + "--num-envs", + str(args.num_envs), + "--device-id", + str(args.device_id), + "--nsteps", + str(args.nsteps), + "--warmup-steps", + str(args.warmup_steps), + "--measure-steps", + str(args.measure_steps), + "--sim-dt", + str(args.sim_dt), + "--worker-timeout-s", + str(args.worker_timeout_s), + ] + completed = subprocess.run(command, check=True, text=True, capture_output=True) + lines = [line for line in completed.stdout.splitlines() if line.startswith(_RESULT_MARKER)] + if len(lines) != 1: + raise RuntimeError( + f"measurement child for K={variant_count} did not emit one result; " + f"stdout={completed.stdout!r}, stderr={completed.stderr!r}" + ) + result: dict[str, float | int] = json.loads(lines[0][len(_RESULT_MARKER) :]) + print( + f"K={variant_count:>3}: construct={result['construction_s']:.3f}s, " + f"worker_rss={result['worker_rss_kib'] / 1024:.1f}MiB, " + f"env_steps/s={result['env_steps_per_s']:.0f}", + flush=True, + ) + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--variant-counts", type=int, nargs="+", default=[1, 4, 64, 600]) + parser.add_argument("--num-envs", type=int, default=4096) + parser.add_argument("--device-id", type=int, default=0) + parser.add_argument("--nsteps", type=int, default=2) + parser.add_argument("--warmup-steps", type=int, default=10) + parser.add_argument("--measure-steps", type=int, default=100) + parser.add_argument("--sim-dt", type=float, default=0.005) + parser.add_argument("--worker-timeout-s", type=float, default=600.0) + parser.add_argument("--output", type=Path, default=None) + parser.add_argument("--run-one", action="store_true") + parser.add_argument("--variant-count", type=int, default=1) + args = parser.parse_args(argv) + + if args.run_one: + _run_one(args) + return 0 + + artifact = { + "schema": "unilab.isaacgym_fixed_variants.v1", + "created_at": datetime.now(timezone.utc).isoformat(), + "platform": platform.platform(), + "python": platform.python_version(), + **_gpu_info(), + "parameters": { + "num_envs": args.num_envs, + "device_id": args.device_id, + "nsteps": args.nsteps, + "warmup_steps": args.warmup_steps, + "measure_steps": args.measure_steps, + "sim_dt": args.sim_dt, + }, + "results": [ + _spawn_measurement(args, variant_count) for variant_count in args.variant_counts + ], + } + rendered = json.dumps(artifact, indent=2) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered + "\n", encoding="utf-8") + print(f"wrote {args.output}", flush=True) + else: + print(rendered, flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d1a08429c941550c70b99c15b2185851870e37db Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 15 Sep 2026 16:12:14 +0800 Subject: [PATCH 3/6] test(isaacgym): align mock variant keyframe validation --- tests/base/isaacgym_mock_worker.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/base/isaacgym_mock_worker.py b/tests/base/isaacgym_mock_worker.py index 9b19afc5b..a1818d255 100644 --- a/tests/base/isaacgym_mock_worker.py +++ b/tests/base/isaacgym_mock_worker.py @@ -168,10 +168,12 @@ def apply_variant_keyframes( """Apply the assigned variant's INIT keyframe to each environment.""" if self.variant_assignment is None: raise RuntimeError("variant keyframes require a variant assignment") - if len(qpos_by_variant) != len(set(self.variant_assignment)): + if self.variant_count is None: + raise RuntimeError("variant assignment requires a variant count") + if len(qpos_by_variant) != self.variant_count: raise RuntimeError( - "variant keyframe table has %d rows for %d assigned variants" - % (len(qpos_by_variant), len(set(self.variant_assignment))) + "variant keyframe table has %d rows for %d variants" + % (len(qpos_by_variant), self.variant_count) ) if any(value is None for value in qpos_by_variant): raise RuntimeError("every assigned variant must provide a keyframe") From 9cd487d560ad18288f7778fdc2bb865763d1290f Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 15 Sep 2026 16:23:54 +0800 Subject: [PATCH 4/6] deps: consume unisim-core 1.4.1 (#1577) --- docs/sphinx/source/changelog.md | 8 ++++++++ pyproject.rocm.toml | 8 ++++---- pyproject.toml | 10 +++++----- tests/base/test_isaacgym_fixed_variants.py | 6 ------ uv.lock | 8 ++++---- uv.rocm.lock | 6 +++--- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index eff886939..783df3a34 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -35,6 +35,14 @@ PyPI 版本变更与未发布变更;发布日期采用 PyPI 上传日期。完 运行,记录源生成、构造时间、worker 实时 RSS 与 control/physics/env-step 速率,并输出版本化 JSON artifact 供 #1579 support 决策使用。 +### Changed / 变更 + +- Raised the UniSim dependency to `unisim-core>=1.4.1` (including the + optional SuperDex extra) to consume the published IsaacGym fixed-variant + adapter; the UniLab package version is unchanged. + 将 UniSim 依赖提升到 `unisim-core>=1.4.1`(含可选 SuperDex extra),以消费 + 已发布的 IsaacGym fixed-variant adapter;UniLab 包版本保持不变。 + ## 1.3.0 (2026-09-14) ### Breaking changes / 破坏性变更 diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index c64dde7ab..10536bdd5 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -24,10 +24,10 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package. The 1.4.0 release carries the fixed model variant, - # per-world reset-default, per-env gravity, and substep body-wrench - # contracts. - "unisim-core>=1.4.0", + # unisim-core package. The 1.4.1 release carries the fixed model variant, + # per-world reset-default, per-env gravity, substep body-wrench, and + # IsaacGym fixed-variant contracts. + "unisim-core>=1.4.1", # RL algorithms and async runtimes live in the independently released # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. "unilab-rl==1.2.0", diff --git a/pyproject.toml b/pyproject.toml index 009663790..281eb745c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,10 @@ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", # Physics implementations are provided by the independently released - # unisim-core package. The 1.4.0 release carries the fixed model variant, - # per-world reset-default, per-env gravity, and substep body-wrench - # contracts. - "unisim-core>=1.4.0", + # unisim-core package. The 1.4.1 release carries the fixed model variant, + # per-world reset-default, per-env gravity, substep body-wrench, and + # IsaacGym fixed-variant contracts. + "unisim-core>=1.4.1", # RL algorithms and async runtimes (PPO/APPO/SAC/TD3 runners, # collectors, IPC, logging) live in the independently released uni-rl # package (distribution name ``unilab-rl``), consumed via the injected @@ -173,7 +173,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex]>=1.4.0 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex]>=1.4.1 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/tests/base/test_isaacgym_fixed_variants.py b/tests/base/test_isaacgym_fixed_variants.py index 30cdb4941..6f78d907c 100644 --- a/tests/base/test_isaacgym_fixed_variants.py +++ b/tests/base/test_isaacgym_fixed_variants.py @@ -96,9 +96,6 @@ def _make_backend( worker_timeout_s=30.0, ) assert isinstance(backend, IsaacGymBackend) - if not backend.get_dr_capabilities().supports_fixed_variants: - backend.close() - pytest.skip("installed unisim-core does not implement IsaacGym fixed variants") return backend @@ -197,9 +194,6 @@ def test_real_isaacgym_runtime_fixed_variants(tmp_path: Path) -> None: worker_timeout_s=120.0, ) assert isinstance(backend, IsaacGymBackend) - if not backend.get_dr_capabilities().supports_fixed_variants: - backend.close() - pytest.skip("installed unisim-core does not implement IsaacGym fixed variants") try: backend.materialize() assert [backend.get_playback_model(index) for index in range(3)] == [ diff --git a/uv.lock b/uv.lock index fe8df3c44..18fea6923 100644 --- a/uv.lock +++ b/uv.lock @@ -5240,8 +5240,8 @@ requires-dist = [ { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", specifier = ">=1.4.0" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", specifier = ">=1.4.0" }, + { name = "unisim-core", specifier = ">=1.4.1" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", specifier = ">=1.4.1" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, @@ -5284,13 +5284,13 @@ wheels = [ [[package]] name = "unisim-core" -version = "1.4.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/cf/639036c780e264389d54f30e58c0a862d52b724be6f17cc62cba5bb9780a/unisim_core-1.4.0.tar.gz", hash = "sha256:cc7db53d779af7b1612615aa3a0216575a3236f508520b63d358f86af326adee", size = 266406, upload-time = "2026-09-14T13:08:44.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/57/4da896cef1f76e9902cfe4deb0f5dcf678a2217507dd39fe286a7edb6984/unisim_core-1.4.1.tar.gz", hash = "sha256:aa71cc1f90615929be2e75851d636271675666caa140629c042e6a46fb45d657", size = 269776, upload-time = "2026-09-15T08:21:16.014Z" } [package.optional-dependencies] superdex = [ diff --git a/uv.rocm.lock b/uv.rocm.lock index f9f29d78f..8a620d3ce 100644 --- a/uv.rocm.lock +++ b/uv.rocm.lock @@ -3790,7 +3790,7 @@ requires-dist = [ { name = "triton-rocm", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==3.6.0", index = "https://download.pytorch.org/whl/rocm7.2" }, { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, - { name = "unisim-core", specifier = ">=1.4.0" }, + { name = "unisim-core", specifier = ">=1.4.1" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, ] @@ -3830,13 +3830,13 @@ wheels = [ [[package]] name = "unisim-core" -version = "1.4.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/cf/639036c780e264389d54f30e58c0a862d52b724be6f17cc62cba5bb9780a/unisim_core-1.4.0.tar.gz", hash = "sha256:cc7db53d779af7b1612615aa3a0216575a3236f508520b63d358f86af326adee", size = 266406, upload-time = "2026-09-14T13:08:44.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/57/4da896cef1f76e9902cfe4deb0f5dcf678a2217507dd39fe286a7edb6984/unisim_core-1.4.1.tar.gz", hash = "sha256:aa71cc1f90615929be2e75851d636271675666caa140629c042e6a46fb45d657", size = 269776, upload-time = "2026-09-15T08:21:16.014Z" } [[package]] name = "urllib3" From 696d443ea26550c0d303531e0d8ac989f73aa3dd Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 15 Sep 2026 16:24:48 +0800 Subject: [PATCH 5/6] docs: clarify unisim 1.4.1 dependency scope --- docs/sphinx/source/changelog.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index 783df3a34..8e1dd558d 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -37,11 +37,14 @@ PyPI 版本变更与未发布变更;发布日期采用 PyPI 上传日期。完 ### Changed / 变更 -- Raised the UniSim dependency to `unisim-core>=1.4.1` (including the - optional SuperDex extra) to consume the published IsaacGym fixed-variant - adapter; the UniLab package version is unchanged. - 将 UniSim 依赖提升到 `unisim-core>=1.4.1`(含可选 SuperDex extra),以消费 - 已发布的 IsaacGym fixed-variant adapter;UniLab 包版本保持不变。 +- Raised both UniSim requirement sites—the base dependency and the optional + `superdex` extra's version constraint—to `unisim-core>=1.4.1` so every + installed UniSim build includes the published IsaacGym fixed-variant + adapter. SuperDex support predates this release; the UniLab package version + is unchanged. + 将两处 UniSim 约束——基础依赖与可选 `superdex` extra 的版本约束——同时提升到 + `unisim-core>=1.4.1`,确保所有安装的 UniSim 构建都包含已发布的 IsaacGym + fixed-variant adapter。SuperDex 后端并非本版本新增;UniLab 包版本保持不变。 ## 1.3.0 (2026-09-14) From 37b1054a5989caf2534925234d9044576f8be7f3 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Tue, 15 Sep 2026 16:27:59 +0800 Subject: [PATCH 6/6] fix: keep superdex extra constraint unchanged --- docs/sphinx/source/changelog.md | 17 +++++++++-------- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/sphinx/source/changelog.md b/docs/sphinx/source/changelog.md index 8e1dd558d..aa1506530 100644 --- a/docs/sphinx/source/changelog.md +++ b/docs/sphinx/source/changelog.md @@ -37,14 +37,15 @@ PyPI 版本变更与未发布变更;发布日期采用 PyPI 上传日期。完 ### Changed / 变更 -- Raised both UniSim requirement sites—the base dependency and the optional - `superdex` extra's version constraint—to `unisim-core>=1.4.1` so every - installed UniSim build includes the published IsaacGym fixed-variant - adapter. SuperDex support predates this release; the UniLab package version - is unchanged. - 将两处 UniSim 约束——基础依赖与可选 `superdex` extra 的版本约束——同时提升到 - `unisim-core>=1.4.1`,确保所有安装的 UniSim 构建都包含已发布的 IsaacGym - fixed-variant adapter。SuperDex 后端并非本版本新增;UniLab 包版本保持不变。 +- Raised the base UniSim requirement to `unisim-core>=1.4.1` to consume the + published IsaacGym fixed-variant adapter. The optional `superdex` extra's + own `>=1.4.0` constraint is unchanged: the base requirement already forces + every installed profile to 1.4.1 or newer, and SuperDex has no 1.4.1-specific + dependency change. The UniLab package version is unchanged. + 将基础 UniSim 依赖提升到 `unisim-core>=1.4.1`,以消费已发布的 IsaacGym + fixed-variant adapter。可选 `superdex` extra 自身的 `>=1.4.0` 约束保持不变: + 基础依赖已经强制所有安装 profile 使用 1.4.1 或更新版本,且 SuperDex 在 + 1.4.1 中没有专属依赖变化。UniLab 包版本保持不变。 ## 1.3.0 (2026-09-14) diff --git a/pyproject.toml b/pyproject.toml index 281eb745c..0b1b0568c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,7 +173,7 @@ viser = ["viser>=1.0.26", "trimesh>=3.21.7"] # required-environments; elsewhere the extra is empty and the CLI reports a # targeted runtime diagnostic. superdex = [ - "unisim-core[superdex]>=1.4.1 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", + "unisim-core[superdex]>=1.4.0 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 18fea6923..211b3f755 100644 --- a/uv.lock +++ b/uv.lock @@ -5241,7 +5241,7 @@ requires-dist = [ { name = "typing-extensions" }, { name = "unilab-rl", specifier = "==1.2.0" }, { name = "unisim-core", specifier = ">=1.4.1" }, - { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", specifier = ">=1.4.1" }, + { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", specifier = ">=1.4.0" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" },