Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/sphinx/source/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,40 @@ 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 决策使用。

### Changed / 变更

- 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)

### Breaking changes / 破坏性变更
Expand Down
8 changes: 8 additions & 0 deletions docs/sphinx/source/en/2-user_guide/3-backends/3-isaacgym.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 前置条件

Expand Down
8 changes: 4 additions & 4 deletions pyproject.rocm.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
277 changes: 277 additions & 0 deletions scripts/benchmark/physics/benchmark_isaacgym_fixed_variants.py
Original file line number Diff line number Diff line change
@@ -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"""<mujoco model="IsaacGymFixedVariantBenchmark">
<worldbody>
<geom name="floor" type="plane" size="1 1 0.1"/>
<body name="base">
<freejoint/>
<geom name="base_geom" type="box" size="{size} {size} {size}" mass="{mass}"/>
<body name="link0">
<joint name="j0" type="hinge" range="-1.5 1.5"/>
<geom name="g0" type="box" size="0.1 0.1 0.1"/>
<body name="link1">
<joint name="j1" type="hinge" range="-1.5 1.5"/>
<geom name="g1" type="box" size="0.1 0.1 0.1"/>
<body name="link2">
<joint name="j2" type="hinge" range="-1.5 1.5"/>
<geom name="g2" type="box" size="0.1 0.1 0.1"/>
</body>
</body>
</body>
</body>
</worldbody>
<actuator>
<position name="j0" joint="j0" kp="{kp}" kv="0.5" forcerange="-100 100"/>
<position name="j1" joint="j1" kp="30" kv="0.5" forcerange="-100 100"/>
<position name="j2" joint="j2" kp="40" kv="0.5" forcerange="-100 100"/>
</actuator>
<keyframe>
<key name="home" qpos="0 0 0.8 1 0 0 0 {key} 0.2 -0.1"/>
</keyframe>
</mujoco>
"""


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())
Loading
Loading