Skip to content

Commit c036892

Browse files
Snapshot env_0 stage pre-clone to skip flatten in OVPhysX warmup
OvPhysxManager._warmup_and_load previously called sim_stage.Export() on the live USD stage and then stripped /World/envs/env_<i!=0> from the exported file. With clone_usd=True, the live stage carries every env's authored subtree, so the export step flattens 4096 envs to disk -- a 31s cost at scale on Anymal-D Rough even though the eventual wheel input is env_0-only. ovphysx_replicate runs from InteractiveScene.clone_environments BEFORE cloner.usd_replicate inflates the stage. At that point the live stage holds only env_0's authored content (plus globals), and a stage.Export of the un-cloned stage is cheap. Register that snapshot on OvPhysxManager and have _warmup_and_load consume it directly, falling back to the old export-and-strip path for callers that don't flow through InteractiveScene.clone_environments (tests, single-env scenes).
1 parent 83fea49 commit c036892

3 files changed

Lines changed: 108 additions & 12 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed slow OVPhysX warmup at large env counts. The previous
5+
:meth:`~isaaclab_ovphysx.physics.OvPhysxManager._warmup_and_load`
6+
flattened the entire post-clone USD stage to disk before stripping
7+
``env_1..N`` from the resulting file — a ~31 s flatten at 4096 envs
8+
on Anymal-D Rough. The manager now snapshots the live stage from
9+
inside :func:`~isaaclab_ovphysx.cloner.ovphysx_replicate` (which runs
10+
before :func:`cloner.usd_replicate` inflates the stage), and
11+
:meth:`_warmup_and_load` consumes the snapshot directly. The old
12+
export-and-strip path is preserved as a fallback for callers that do
13+
not go through :meth:`isaaclab.scene.InteractiveScene.clone_environments`.

source/isaaclab_ovphysx/isaaclab_ovphysx/cloner/ovphysx_replicate.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ def ovphysx_replicate(
6969
# Deferred import to avoid circular dependency at module load time.
7070
from isaaclab_ovphysx.physics.ovphysx_manager import OvPhysxManager
7171

72+
# Snapshot the live stage now -- USD cloning hasn't fired yet, so the
73+
# stage holds only env_0's authored content and ``stage.Export`` flattens
74+
# a small stage rather than the post-clone 4096-env version.
75+
# :meth:`OvPhysxManager._warmup_and_load` consumes this snapshot directly,
76+
# bypassing the slower export+strip fallback.
77+
OvPhysxManager.register_pre_clone_stage_snapshot(stage)
78+
7279
for i, src in enumerate(sources):
7380
active_env_ids = env_ids[mapping[i]].tolist()
7481

source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@ class OvPhysxManager(PhysicsManager):
215215
# physx.clone() in _warmup_and_load().
216216
# parent_positions is a list of (x, y, z) tuples — one per target.
217217
_pending_clones: ClassVar[list[tuple[str, list[str], list[tuple[float, float, float]]]]] = []
218+
# Pre-clone stage snapshot path. Populated by
219+
# :meth:`register_pre_clone_stage_snapshot` from inside
220+
# :func:`~isaaclab_ovphysx.cloner.ovphysx_replicate`, at the moment the
221+
# live USD stage carries only env_0 (USD-clone hasn't fired yet).
222+
# :meth:`_warmup_and_load` consumes the snapshot directly, avoiding the
223+
# post-clone full-stage flatten cost (~31s at 4096 envs on Anymal-D).
224+
_pre_clone_stage_path: ClassVar[str | None] = None
218225
_atexit_registered: ClassVar[bool] = False
219226
_scene_data_backend: ClassVar[OvPhysxSceneDataBackend | None] = None
220227

@@ -245,6 +252,66 @@ def register_clone(
245252
"""
246253
cls._pending_clones.append((source, targets, parent_positions or []))
247254

255+
@classmethod
256+
def register_pre_clone_stage_snapshot(cls, stage: Any) -> None:
257+
"""Snapshot the live USD stage to disk before USD cloning inflates it.
258+
259+
Called by :func:`~isaaclab_ovphysx.cloner.ovphysx_replicate`, which
260+
runs *before* :func:`cloner.usd_replicate` in
261+
:meth:`isaaclab.scene.InteractiveScene.clone_environments`. At that
262+
moment, the live stage carries only ``env_0``'s authored content (plus
263+
globals); ``stage.Export`` therefore flattens a small stage rather than
264+
the post-clone 4096-env version.
265+
266+
:meth:`_warmup_and_load` consumes the snapshot path directly, bypassing
267+
the slow ``_export_env0_only_stage`` fallback (which exports the full
268+
post-clone stage and then strips ``env_1..N`` from the resulting file —
269+
a ~31s flatten at 4096 envs on Anymal-D).
270+
271+
Idempotent: subsequent calls within the same setup are no-ops.
272+
273+
Args:
274+
stage: Live USD stage held by :class:`SimulationContext`.
275+
"""
276+
if cls._pre_clone_stage_path is not None:
277+
return
278+
if cls._tmp_dir is None:
279+
cls._tmp_dir = tempfile.TemporaryDirectory(prefix="isaaclab_ovphysx_")
280+
snapshot_path = os.path.join(cls._tmp_dir.name, "scene_env0.usda")
281+
stage.Export(snapshot_path)
282+
cls._pre_clone_stage_path = snapshot_path
283+
logger.info("OvPhysxManager: pre-clone stage snapshot exported to %s", snapshot_path)
284+
285+
_physx_schemas_registered: ClassVar[bool] = False
286+
287+
@classmethod
288+
def _ensure_physx_schemas_registered(cls) -> None:
289+
"""Register the ``PhysxSchema`` USD plugin shipped with the ovphysx wheel.
290+
291+
In Kit-based runs ``omni.physx`` registers the schema; in kitless
292+
runs it must be registered manually before the wheel can match
293+
``PhysxContactReportAPI`` and friends on the stage. The wheel
294+
bundles the plugin under ``ovphysx/plugins/usd/PhysxSchema``. This
295+
method is idempotent — :meth:`pxr.Plug.Registry.RegisterPlugins`
296+
is a no-op once the plugin is registered.
297+
"""
298+
if cls._physx_schemas_registered:
299+
return
300+
try:
301+
import os # noqa: PLC0415
302+
303+
import ovphysx # noqa: PLC0415
304+
305+
from pxr import Plug # noqa: PLC0415
306+
except Exception:
307+
return
308+
plugin_root = os.path.join(os.path.dirname(ovphysx.__file__), "plugins", "usd")
309+
for sub in ("PhysxSchema/resources", "PhysxSchemaAddition/resources"):
310+
path = os.path.join(plugin_root, sub)
311+
if os.path.isdir(path):
312+
Plug.Registry().RegisterPlugins(path)
313+
cls._physx_schemas_registered = True
314+
248315
@classmethod
249316
def initialize(cls, sim_context: SimulationContext) -> None:
250317
"""Initialize the physics manager with simulation context.
@@ -265,6 +332,7 @@ def initialize(cls, sim_context: SimulationContext) -> None:
265332
cls._warmup_done = False
266333
cls._usd_handle = None
267334
cls._stage_path = None
335+
cls._pre_clone_stage_path = None
268336
cls._pending_clones = []
269337
# Construct the SceneDataBackend eagerly so :class:`SimulationContext`
270338
# captures a real instance (not ``None``) when it builds the central
@@ -494,7 +562,7 @@ def _warmup_and_load(cls) -> None:
494562
if scene_prim.IsValid():
495563
cls._configure_physx_scene_prim(scene_prim, PhysicsManager._cfg, ovphysx_device)
496564

497-
# Export the current USD stage to a temporary file so ovphysx can load it.
565+
# Resolve the USD stage path handed to ``physx.add_usd``.
498566
#
499567
# When ``InteractiveScene`` runs with ``clone_usd=True``, the live USD
500568
# stage carries env_0..N's full asset subtrees as authored copies.
@@ -504,18 +572,26 @@ def _warmup_and_load(cls) -> None:
504572
# ``create_tensor_binding`` call into an O(N) USD enumeration -- the
505573
# hang you'd see at large env counts.
506574
#
507-
# The workaround: strip ``/World/envs/env_<i>`` for i != 0 from the
508-
# exported file before handing it to the wheel. Sensors that read
509-
# USD directly (RayCaster, Camera, ContactSensor discovery) still see
510-
# the full N-env stage; only the wheel-side physics ingestion is
511-
# scoped to env_0, and ``physx.clone()`` re-populates env_1..N in
512-
# the physics runtime with proper clone lineage (which is what the
513-
# binding fast path expects).
514-
cls._tmp_dir = tempfile.TemporaryDirectory(prefix="isaaclab_ovphysx_")
515-
stage_file = os.path.join(cls._tmp_dir.name, "scene.usda")
516-
cls._export_env0_only_stage(sim.stage, stage_file)
575+
# Fast path: ``ovphysx_replicate`` runs *before* ``cloner.usd_replicate``
576+
# in ``InteractiveScene.clone_environments``, so when it called
577+
# :meth:`register_pre_clone_stage_snapshot` the live stage carried only
578+
# env_0. That snapshot is the env_0-scoped USD the wheel needs; use it
579+
# directly when available.
580+
#
581+
# Fallback: if no pre-clone snapshot was registered (e.g. tests that
582+
# don't go through ``InteractiveScene.clone_environments``), export the
583+
# current (potentially post-clone) stage and strip ``env_1..N`` from
584+
# the resulting file -- correct, but with a flatten cost proportional
585+
# to the post-clone stage size.
586+
if cls._pre_clone_stage_path is not None and os.path.exists(cls._pre_clone_stage_path):
587+
stage_file = cls._pre_clone_stage_path
588+
logger.info("OvPhysxManager: using pre-clone stage snapshot at %s", stage_file)
589+
else:
590+
cls._tmp_dir = tempfile.TemporaryDirectory(prefix="isaaclab_ovphysx_")
591+
stage_file = os.path.join(cls._tmp_dir.name, "scene.usda")
592+
cls._export_env0_only_stage(sim.stage, stage_file)
593+
logger.info("OvPhysxManager: exported env_0-scoped USD stage to %s", stage_file)
517594
cls._stage_path = stage_file
518-
logger.info("OvPhysxManager: exported env_0-scoped USD stage to %s", stage_file)
519595

520596
if cls._physx is None:
521597
cls._construct_physx(ovphysx_device, gpu_index)

0 commit comments

Comments
 (0)