Skip to content

Release dev → main: runtime-validated fixes + features (PRs #153–168) - #169

Merged
KraHsu merged 36 commits into
mainfrom
dev
May 27, 2026
Merged

KraHsu merged 36 commits into
mainfrom
dev

Conversation

@KraHsu

@KraHsu KraHsu commented May 27, 2026

Copy link
Copy Markdown
Owner

Promotes 36 commits (PRs #153–168) from dev to main — the output of a multi-session runtime validation campaign (real-GPU smoke + reproduction on RTX 5060 Ti / H200 / RTX 4090). All merged via CI-green PRs and runtime-verified.

Validation summary

  • Tier-1 (runnability): 15/15 tasks ✅ (train/play smokes).
  • Tier-2 (checkpoint round-trip): train → eval → play --agent trained → export (ts+onnx) ✅ across rsl_rl + sb3.
  • §4 capability matrix: fully covered (skrl / ForceTorque / MlpResidual gaps closed).
  • Tier-3 (reproducibility vs reference-runs.md): 4/4 reproduced —
    IP 39.99×3, DIP 59.96/59.88/59.98, G1-Velocity 113.35/92.51/91.78 (vs 112.42/93.42/92.03), Franka success 0.99/0.99/0.93.

What landed (by theme)

Notes for reproducers (see #168)

SimulationCfg.gpu defaults to CPU (footgun → set gpu=True); Hopper needs QD_GRAPH=0 (slows contact sims); --gpus N gives ~no G1 speedup (run one seed/GPU); 4096-env RL is CPU-bound.

🤖 Generated with Claude Code

KraHsu added 30 commits May 23, 2026 22:15
Rubiks/Wuji play scenes use the base ManagerBasedEnvCfg but are built via
ManagerBasedRlEnv, which reads cfg.device — crashing with AttributeError
because device was defined only on the RL subclass ManagerBasedRlEnvCfg.

Move device: str = "cuda" onto the base cfg so every env cfg carries it;
drop the now-redundant subclass duplicate. Reference audit confirmed the
only cfg.device reads are in ManagerBasedRlEnv.__init__; all other .device
access is on the runtime env.device property.

Add headless regression tests and update the config reference docs.
fix(envs): move device cfg field onto base ManagerBasedEnvCfg
P8/P9: `genelab play GeneLab-Rubiks-Play-v0 --agent zero --steps 5` (and the
Wuji equivalent) crashed in env construction — first on `cfg.device` (fixed
superficially in #153), then on `cfg.decimation`, etc.

Root cause is architectural, not a missing field. Rubiks/Wuji are force-driven
scene-playback demos whose cfgs subclass the non-RL base ManagerBasedEnvCfg and
have their own RegisteredTask.play() → RubiksPlayEnv/WujiHandPlaybackEnv.play()
that builds a Genesis scene directly (no managers, no policy). `_dispatch_play`
already routes to task.play() when no RL flags are present, so the bare command
worked — but RL-only flags (--agent/--checkpoint/--num-envs/--prof) forced the
fall-through to play_task → build_env → ManagerBasedRlEnv, which requires the
full RL cfg surface these demos don't have. Bolting more fields onto the base
cfg would only move the crash to the next field.

Fix at the routing layer: gate RL routing on whether the play env cfg is a
ManagerBasedRlEnvCfg. Non-RL tasks always run their own .play(); RL-only options
are meaningless for a fixed scene demo, so they are warned-and-ignored rather
than crashing. --steps/--vis/--gpu and dotted overrides still apply (they are
resolved into the cfg before dispatch). Defense-in-depth: build_env now raises a
clear TypeError naming the required surface instead of a cryptic AttributeError.

Tests:
- test_examples_{rubiks,wuji}: exercise the real `genelab play … --agent zero
  --steps 5` dispatch path with Genesis stubbed (headless), asserting the scene
  demo runs, honors --steps, and is NOT routed through play_task. These guard
  the actual P8/P9 bug, which the prior #153 cfg-only tests did not.
- test_cli_routing: non-RL task + --agent runs the demo with a warning;
  build_env rejects a non-RL cfg. Added an RL-capable fake task so the existing
  agent-routing tests still validate the RL path (they previously asserted the
  buggy contract on a non-RL cfg).

Docs: clarify in cli/play-train.{en,zh} that policy options apply only to RL
tasks; scene demos run built-in playback.
Wrap the External-Fake-RL-Task-v0 play args list to satisfy ruff format --check.
fix(cli): route non-RL scene demos away from the RL play helper (P8/P9)
…ps NaN-ing

The Curriculum-Showcase G1 spawned ~0.77 m buried in the RandomRough
heightfield: terrain_levels_vel (run on every reset) and the runner teleport
wrote the base to the cell origin at z=0 (= terrain.pos[2]), ignoring the
surface elevation and the robot's 0.76 m standing height. Feet ended at
z=-0.72 under a +0.05 m surface; that interpenetration blew up the rigid
contact solver — instant NaN at dt=0.005, an unconverging multi-minute grind
at dt=0.002. dt was a red herring.

- terrains/importer: add surface_height_at(xy) heightfield lookup.
- mdp/curriculums: terrain_levels_vel reseats the base at
  surface + init_pos.z + spawn_clearance instead of the bare cell origin.
- showcase runner: teleport reseats at standing height too, so the full
  400-step demo survives its teleports.
- showcase cfg: dt 0.005->0.002, decimation 4->10. The G1 is independently
  NaN-unstable at dt=0.005 even from a clean stance; this matches the
  known-good Velocity/Tracking-Flat configs. Control rate stays 50 Hz and the
  2 s episode (100 control steps) is unchanged.
- rl/runner: play_task caps scripted (zero/random) playback at
  simulation.steps, so `play TASK --agent zero --steps N` stops after N steps
  instead of looping forever headless (--steps was silently ignored on the RL
  play path). Trained playback stays unbounded.

Verify: `play GeneLab-Curriculum-Showcase-v0 --agent zero --steps 5` exits 0,
no NaN; full 400-step showcase exits 0 with curriculum promotion (levels
0->4). pytest: 507 passed.
fix(showcase): seat G1 on terrain surface so the Curriculum-Showcase stops NaN-ing
`genelab export <trainable-task>` builds the play env via
`resolve_env_cfg(task_id, play=True)`, and trainable tasks set
`simulation.vis=play` so their play_env opens the viewer. Export only probes
obs shapes — it never renders — so on a display-less CI / server building that
env raised `GenesisException: No display detected`, with no CLI override to
disable it.

Force `env_cfg.simulation.vis = False` right after resolving, mirroring what
`eval_task` already does ("eval is always headless"). The `episode_length_s`
clamp eval applies is not needed here: export collects no episodes.

Add a regression test that drives the real `export_task` with env build
monkeypatched out (no Genesis runtime) and asserts the cfg handed to
`build_env` has the viewer off and the .ts + .metadata.json are produced.
fix(export): force vis=False so export runs headless
…gain

`genelab export <rsl_rl-task>` failed for every rsl_rl checkpoint with
"backend 'rsl_rl' did not expose an actor module": `_extract_rsl_rl_actor`
read `runner.alg.actor_critic`, but current rsl_rl's `PPO.__init__` stores
`self.actor` / `self._raw_actor` directly and no longer has `actor_critic`,
so extraction returned `(None, num_obs)` and `_export.py` raised.

Source the actor from `alg._raw_actor` (falling back to `alg.actor`) and use
the actor's `as_jit()` export wrapper, which exposes the flat
`forward(obs) -> deterministic action` contract (learned obs normalizer baked
in) that the exporter wraps with env-side scale/clip. The legacy
`alg.actor_critic.actor` / `act_inference` path is kept as a fallback for
older rsl_rl.

Verified headless (QD_GRAPH=0 QT_QPA_PLATFORM=offscreen) against a real
inverted-pendulum checkpoint: `-f torchscript` and `-f onnx` both exit 0 and
write loadable .ts / .onnx + .metadata.json (obs_dim 8 -> action_dim 1,
dynamic batch).

Tests build a real rsl_rl MLPModel and assert extraction + full export, plus
the legacy and missing-actor paths. Docs (eval-and-export EN/ZH) updated to
describe the new extraction path.
fix(export): extract rsl_rl actor from alg directly so export works again
`genelab export <sb3 SAC+HER task>` (e.g. GeneLab-Franka-Pick-And-Place-v0)
failed with `AssertionError: Expected dict, got <class 'torch.Tensor'>`:
goal-conditioned tasks have a gymnasium Dict observation
(observation / achieved_goal / desired_goal) and the SAC actor's
CombinedExtractor consumes all keys, but the exporter traced the actor with a
single flat tensor that SB3's preprocess_obs rejects.

Fix (single flat input, reconstruct the Dict inside the actor):
- exporter: generalize the single-group resolver to `_resolve_obs_specs(env,
  groups)` with a global cursor across groups; `export_policy` gains
  `goal_groups`, concatenating [policy, achieved_goal, desired_goal] into one
  flat `obs`. Metadata now records `obs_dim` + each group's `start` offset.
- sb3 backend: `_extract_sb3_actor` returns `_Sb3DictActor` for Dict spaces — it
  splits the flat tensor (in observation/achieved_goal/desired_goal order) back
  into the Dict and calls `policy._predict(dict, deterministic=True)`.
- cli: `export_task` passes the HER goal groups from `agent_cfg.her`.
- exporter ONNX: use `dynamo=False` (legacy tracer) — the torch.export-based
  default (torch>=2.9) can't trace SAC's `Normal` construction; the legacy
  tracer matches the torchscript path and handles every backend.

Verified locally (no Genesis): a synthetic SAC MultiInputPolicy exports to both
.ts and .onnx with loadable, runnable artifacts and correct multi-group
metadata; the extracted actor matches `policy._predict` on the equivalent Dict.

Docs (eval-and-export EN/ZH) updated: sb3 extraction, the flat HER layout, the
new metadata schema, and removal of the stale "Dict obs unsupported" limitation.
fix(export): support SB3 SAC+HER Dict-obs export via flat concat
Trainable tasks set `vis=play` in their play_env, so `play --agent trained`
opens the Genesis viewer by default and aborts with "No display detected" on a
display-less server. `--headless` is the inverse of `-v/--vis`: it rewrites to
`env.simulation.vis=false`. The two are mutually exclusive (raises SystemExit).

Mirrors the existing `-v/--vis` shortcut in `parse_run_args`. Updates help text
+ snapshots and the CLI docs (en/zh) with a headless-server playback note.
feat(cli): add --headless flag to force vis=false
The skrl backend was implemented and unit-tested but no registered task used
it, so `genelab train/play/eval/export` never exercised skrl at runtime. Add
`GeneLab-Inverted-Pendulum-Skrl-v0`: it reuses the single inverted-pendulum
env/robot but supplies a `SkrlAgentCfg` (skrl PPO) instead of the rsl_rl runner
cfg — and since the backend is chosen purely by agent-cfg type, that routes the
task to the skrl backend.

- examples: add `inverted_pendulum_skrl_agent_cfg()` (mirrors the rsl_rl PPO
  recipe; obs_group=policy / state_group=critic, which the env already emits)
  and `InvertedPendulumSkrlTask` registering the new id. `SkrlAgentCfg` is a
  pure dataclass, so registration/listing never imports skrl — only training
  does.
- backend: `SkrlBackend.train()` now writes a final checkpoint after
  `trainer.train()`. skrl only checkpoints every `checkpoint_interval` steps
  (`"auto"` -> `timesteps // 10`, == 0 for a smoke `--max-iterations 2`), so
  short runs otherwise finished with nothing to eval/play. Mirrors rsl_rl's
  unconditional final `model_{it}.pt` save; benefits every skrl task.
- tests: registration + agent-cfg-type asserts (no skrl needed), plus an
  skrl-guarded train->checkpoint->eval round-trip on a fake env. Verified the
  optional-dep guard: with skrl blocked the registration tests pass and the
  round-trip skips.
- docs: list the task and its skrl train/eval commands (EN + ZH).
feat(examples): register skrl-backed inverted-pendulum task
ForceTorqueSensor / ForceTorqueSensorCfg were implemented and unit-tested but no
registered task instantiated one, so it was never exercised at runtime. Wire it
into the play-only Sensors-Showcase (Franka) — the lowest-blast-radius route: it
adds a sensor to the scene only, touching no trainable task's obs/reward (T1–T5
reproduction unaffected).

- sensors/env_cfg.py: add `ForceTorqueSensorCfg(name="arm_joint_ft",
  joint_names_expr=r"^joint[1-7]$")` to the scene — the 7 actuated arm joints the
  scripted joint1 sweep drives. `data.force` is (num_envs, 7). The env builds and
  updates every scene sensor each step, so this alone exercises it during a play
  rollout.
- sensors/runner.py: read + log the joint force/torque in `_dump`, mirroring the
  camera/IMU/frame-transformer evidence dump.
- tasks.py: extend the Sensors-Showcase description (and fix a stale "six"->"seven"
  task count).
- tests/test_showcase_sensors.py: assert the showcase env cfg registers the FT
  sensor and that, bound against a Franka-style fake env (no Genesis), it resolves
  exactly the 7 arm joints and yields (num_envs, 7) force data. Mirrors
  tests/test_sensor.py's fake-env pattern.
- docs: note joint force/torque in the showcase capabilities table (EN + ZH).
feat(showcase): exercise ForceTorqueSensor in the Sensors-Showcase
MlpResidualActuator / MlpResidualActuatorCfg were implemented and unit-tested but
no robot/task used one, so the residual code path never ran at runtime. Add a
play-only showcase task `GeneLab-MlpResidual-Actuator-Showcase-v0` that drives the
Franka arm with an MlpResidualActuator (DC-motor torque base + a learned residual)
WITH a real network_file, so the MLP-residual path executes (not the DC-motor
fallback). Play-only, so no trainable task's obs/reward is touched.

- actuators/residual_net.py: `residual_net_path()` materializes a tiny, deterministic
  TorchScript Linear(2,1) (residual = -0.05*joint_vel) under CACHE_DIR and returns
  its path — no committed binary. Always rewrites, so changing the weights can't
  leave a stale cached net.
- actuators/env_cfg.py: `mlp_residual_actuator_showcase_env_cfg()` reuses the actuator
  showcase and swaps the arm group to MlpResidualActuatorCfg(network_file=...,
  residual_scale=1.0). The residual is tiny vs the 87 Nm budget, so it can't
  destabilize the demo.
- actuators/runner.py: MlpResidualActuatorShowcaseRunner with a distinct log slug.
- tasks.py: register the new task (eight showcase tasks now).
- tests/test_showcase_mlp_residual.py: assert the cfg wires the residual
  (network_file set, residual_scale>0), the generated net round-trips, and the loaded
  residual actually changes the actuator torque vs the network_file=None fallback at
  an unsaturated operating point. Mirrors tests/test_actuator.py; no Genesis needed.
- docs: actuator concept doc gets a runnable-example pointer; showcase capabilities
  table notes the MLP-residual variant (EN + ZH).
…wcase

feat(showcase): exercise MlpResidualActuator in a new actuator showcase
…ext API)

`collect_demos` aborted with `AttributeError: 'ManagerBasedRlEnv' object has no
attribute 'robot_state'` — `demo_fsm.py` still used the pre-vNext `env.robot_state.*`
accessor removed by the Articulation refactor (ADR-0019). This blocked Franka
SAC+HER reproduction: the prefill .npz was never produced, so training failed with
FileNotFoundError on GENELAB_SB3_DEMO_PATH.

Route both reads through the already-cached `self._art = env.articulations["robot"]`,
mirroring the task's own mdp.py:
- `_ee_pos`: `self._art.data.link_pos[:, self._hand_idx, :]`
- `_gripper_width`: `self._art.data.joint_pos.index_select(1, self._finger_joint_idx)...`

`env.robot_state` was the prior name for the primary robot's state object, so this is
behavior-preserving (same tensors, same joint/link indexing).

Refactor `collect_demos.main()` into a pure `collect(env, fsm, steps) -> dict` plus
`save_demos(out, path)` (env construction + CLI stay in `main`), so the collection
pipeline is testable without a Genesis runtime. Behavior of `python -m
...collect_demos` is unchanged.

Add `tests/test_franka_collect_demos.py`: drives the real `FrankaPickAndPlaceFsm` +
`collect()` against a fake env exposing the current `articulations[...].data.*` API,
then round-trips the .npz and asserts the ten HER-prefill keys + shapes. `step()`
exercises `_ee_pos`; `_gripper_width` (not used by step) is asserted directly. Before
the fix these raised the `robot_state` AttributeError.
fix(franka): route demo FSM state through articulations[...].data (vNext API)
`_download_to` called `urlopen` with no timeout, so on a host with no network
(or one that needs an http_proxy) asset fetches hung forever — taking
`genelab list/train` (which resolve asset-zoo robots) down with them.

Pass a per-socket-op timeout (default 30s, override via GENELAB_DOWNLOAD_TIMEOUT;
0/neg disables) to `urlopen`, applying to connect and each blocking read, and map
the resulting TimeoutError to AssetDownloadError with a network/proxy hint. It is
not a total-download budget, so healthy slow transfers are unaffected.
fix(utils): time-bound asset downloads so a blocked network fails fast
…dle)

`Genelab-Velocity-Flat-Unitree-G1-v0` trained at ~100 s/iter with the GPU at 0%
util — a ~50x regression vs the ~2.4 s/iter reference. Root cause: the G1 env
cfgs never set `SimulationCfg.gpu`, which defaults to False, so
`InteractiveScene` initialized Genesis with the **CPU backend**
(`backend = gs.gpu if sim_cfg.gpu else gs.cpu`). The entire humanoid physics ran
on CPU while the policy/tensors sat on cuda — GPU idle, CPU-bound. (The showcase
tasks already set `gpu=True`; the G1 training cfgs were missed in the vNext
refactor.)

Set `gpu=True` on both G1 cfgs (`unitree_g1_velocity_env_cfg` and
`unitree_g1_tracking_env_cfg`).

Measured on an idle RTX 5060 Ti @1024 envs (QD_GRAPH=0, headless):
- env.step: 1481 ms (CPU backend) -> 52 ms (GPU) — ~28x.
- `genelab train` end-to-end: ~100 s/iter @0% GPU -> ~1.9 s/iter @76-86% GPU,
  matching the reference-runs.md ~2.4 s/iter target.

Add a guard test (`test_g1_tasks_run_simulation_on_gpu_backend`) asserting both G1
tasks' train + play env cfgs set `simulation.gpu=True`, so this can't silently
regress again. Runs on CI without a Genesis runtime (pure cfg check).
fix(g1): run Genesis on the GPU backend (was CPU — ~50× slower, GPU idle)
KraHsu added 6 commits May 25, 2026 01:20
The Franka SAC+HER pick-and-place env cfg never set `SimulationCfg.gpu`, which
defaults to False — so Genesis initialized on the CPU backend and ran the sim on
CPU while the policy/tensors sat on cuda (the same regression just fixed for G1,
#165). num_envs=64 makes the absolute cost smaller than G1, but it's still the
wrong backend.

Set `gpu=True` on the Franka env cfg. Verified on an RTX 5060 Ti: the env builds
and steps on the GPU backend (9.5 ms/step @64 envs; HER Dict obs intact).

Add a guard test asserting the task's train + play env cfgs set
`simulation.gpu=True` (pure cfg check, runs on CI without Genesis).
…d footgun)

`SimulationCfg.gpu` defaulted to False while env `device` defaults to "cuda", so a
task that set `device="cuda"` but didn't also set `gpu=True` silently ran the whole
sim on the CPU backend — GPU idle, ~50x slower. This bit G1 (#165) and Franka
(#166); any future cuda task would trip the same wire.

Make `gpu` default to `None` = "follow the device": `_resolve_use_gpu(gpu, device)`
returns the explicit flag when set, else `"cuda" in device`. The scene already
receives `device_hint`, so no new plumbing. Now a `device="cuda"` env runs the sim
on the GPU with no separate opt-in; `device="cpu"` (or explicit `gpu=False`) stays
on CPU; the distributed path still force-sets `gpu=True`.

Verified on an RTX 5060 Ti: a `gpu=None` + `device="cuda"` env builds on the GPU
backend (9.6 ms/step, GPU active); the CPU-backend tests (`device="cpu"`,
`gpu=False`) still build on CPU. Existing explicit `gpu=True` cfgs (G1, showcases)
are unaffected.

Tests: parametrized `_resolve_use_gpu` unit test (None follows device; True/False
override) + assert the new `gpu=None` default. Refresh the G1 guard-test docstring
that referenced the old `False` default.
fix(franka): run Genesis on the GPU backend (same CPU-backend bug as G1)
fix(sim): default Genesis backend to follow device (remove CPU-backend footgun)
…ion caveats

Surfaced while reproducing the reference numbers:
- SimulationCfg.gpu defaults to False (CPU backend) — a footgun that leaves the GPU
  idle and training ~50-100x slower (fixed for G1/Franka via gpu=True; custom tasks
  must set it too).
- Hopper needs QD_GRAPH=0, which cripples contact-heavy sims — prefer non-Hopper.
- `--gpus N` barely speeds up G1; run one seed per GPU instead.
- 4096-env RL is CPU-bound; concurrent trainings oversubscribe the host (wall-clock
  only — rewards stay deterministic).
docs(reference-runs): GPU-backend / Hopper / multi-GPU reproduction caveats
@KraHsu
KraHsu merged commit 92edfd9 into main May 27, 2026
4 checks passed
KraHsu added a commit that referenced this pull request Jun 5, 2026
Release dev → main: runtime-validated fixes + features (PRs #153–168)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant