Skip to content

Commit 11b8c64

Browse files
authored
Suspends Fabric USD notice listener during cloning for faster startup (isaac-sim#5432)
# Description Adds a re-entrant context manager `disabled_fabric_change_notifies` that suspends the `omni::fabric::IFabricUsd` USD notice listener for the duration of bulk cloning. During `Sdf.CopySpec` loops in `usd_replicate`, every per-prim mutation otherwise fires `IFabricUsd::UsdNoticeListener::Handle` to do an immediate Fabric↔USD sync — for moderately heavy scenes that single hot path can dominate scene-load time. Toggling the listener's soft flag (`IFabricUsd.cpp:739`) gates the work without unregistering the listener, then the natural `SimulationContext.reset` path performs the catch-up resync in one pass. The same handler is what `isaacsim.core.cloner.Cloner.disable_change_listener` toggles, but this implementation reaches it through the underlying `omni::fabric::IFabricUsd` Carbonite interface directly, so the cloner has **no `isaacsim.core.simulation_manager` dependency**. Since `omni.fabric` has no public Python binding for `setEnableChangeNotifies`, acquisition is via a small ctypes module modelled on the in-tree `isaaclab_newton.physics._cubric` pattern. The context manager is wired in at the cloner-session boundary — `clone_from_template` and the two cloning regions in `InteractiveScene` — not inside `usd_replicate` itself, keeping the leaf USD-authoring primitive pure. ## Motivation This PR supersedes the approach taken in isaac-sim#5070 (still open). isaac-sim#5070 reaches the same toggle via `isaacsim.core.simulation_manager.SimulationManager.enable_fabric_usd_notice_handler`, which: 1. Adds an `isaacsim.core.simulation_manager` dependency to the cloner (project policy: avoid `isaacsim.*` implementation deps). 2. Implements via a state-dict pattern + a private `_manage_notice_handlers` kwarg leaked through public `usd_replicate`. 3. Disables the handler from inside `usd_replicate` and never re-enables it on exit (global-state leak that survives exceptions). This PR keeps isaac-sim#5070's measured perf win while replacing the above with a single context manager wired in at the orchestrator boundary, exception-safe, re-entrant, and IsaacSim-implementation-free. ### Architecture | Concern | Where it lives | |---|---| | ABI-coupled ctypes binding | `source/isaaclab/isaaclab/cloner/_fabric_notices.py` (private, ~135 lines) | | Public context manager | `disabled_fabric_change_notifies(stage, *, restore=True)` in `cloner_utils.py` | | Application | `clone_from_template` body and `InteractiveScene.clone_environments` | The `restore=False` kwarg, used at the two scene-init sites, opts out of the on-exit re-enable. This avoids the redundant `forceMinimalPopulate` batch that fires when the flag flips back on; downstream `SimulationContext.reset` performs the same Fabric resync as part of normal startup. `restore=True` (the default) is exception-safe and is what tests and any future direct callers get. When the Carbonite interface can't be acquired (e.g. running outside a live Kit application), the context manager falls through to a no-op so callers never break — they just don't get the perf win. ## Benchmarks `scripts/benchmarks/benchmark_startup.py`, RTX 5090, headless, warm run (2nd of 2 invocations after kernel/extension caches populate). | Task | num_envs | Backend | Before | After | Saved | % | |---|---:|---|---:|---:|---:|---:| | `Isaac-Cartpole-Direct-v0` | 4096 | PhysX | 7.41 s | 6.47 s | 0.94 s | −12.7% | | `Isaac-Cartpole-Direct-v0` | 4096 | Newton | 22.06 s | 22.01 s | 0.05 s | ~0% | | `Isaac-Velocity-Flat-Anymal-C-v0` | 4096 | PhysX | 28.09 s | 14.49 s | 13.60 s | **−48.4%** | | `Isaac-Velocity-Flat-Anymal-C-v0` | 4096 | Newton | 36.72 s | 32.99 s | 3.73 s | −10.1% | | `Isaac-Dexsuite-Kuka-Allegro-Reorient-v0` | 8192 | PhysX | 104.84 s | 41.94 s | 62.90 s | **−60.0%** | | `Isaac-Dexsuite-Kuka-Allegro-Reorient-v0` | 8192 | Newton | 81.00 s | 74.17 s | 6.83 s | −8.4% | Savings come entirely from `env_creation` (Scene Creation + Simulation Start) and `first_step`. Non-cloning phases (`app_launch`, `python_imports`, `task_config`) are unchanged within run-to-run noise. Newton kitless runs see smaller wins because Fabric is only touched when `omni.fabric` is loaded for rendering; PhysX with Kit is the primary beneficiary. The `first_step` collapse on heavy scenes (e.g. Dexsuite −93%, 10.8 s → 0.74 s) is the deferred Fabric resync work folding into the existing `SimulationContext.reset` pass with much less overhead than an eager `forceMinimalPopulate`. ## Type of change - New feature (non-breaking change which adds functionality) - Performance improvement ## Test plan - `pytest source/isaaclab/test/sim/test_cloner.py` → 18 passed - `pytest source/isaaclab_physx/test/sim/test_cloner.py` → 22 passed (2 xfailed/2 xpassed are pre-existing) - `./isaaclab.sh -f` (pre-commit) clean - Smoke test confirms `restore=True` round-trips the flag, `restore=False` leaves it off, and nested context managers are re-entrant. ## Follow-ups - File a Kit-team request for an `omni.fabric` Python binding to `setEnableChangeNotifies`, after which `_fabric_notices.py` can be deleted. - Consider an ABI-drift smoke test under `test_cloner.py` to catch Kit-side vtable changes in CI. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there
1 parent 3827956 commit 11b8c64

6 files changed

Lines changed: 503 additions & 81 deletions

File tree

source/isaaclab/isaaclab/cloner/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ __all__ = [
88
"random",
99
"sequential",
1010
"clone_from_template",
11+
"disabled_fabric_change_notifies",
1112
"filter_collisions",
1213
"grid_transforms",
1314
"make_clone_plan",
@@ -19,6 +20,7 @@ from .cloner_cfg import TemplateCloneCfg
1920
from .cloner_strategies import random, sequential
2021
from .cloner_utils import (
2122
clone_from_template,
23+
disabled_fabric_change_notifies,
2224
filter_collisions,
2325
grid_transforms,
2426
make_clone_plan,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Pure-Python ctypes binding for ``omni::fabric::IFabricUsd::setEnableChangeNotifies``.
7+
8+
Acquires the ``omni::fabric::IFabricUsd`` carb interface directly from the Carbonite
9+
framework so cloning can suspend Fabric's USD notice listener without depending on
10+
``isaacsim.core.simulation_manager``.
11+
12+
Mirrors the in-tree pattern in :mod:`isaaclab_newton.physics._cubric` for
13+
``omni::cubric::IAdapter`` — same problem (base-Kit Carbonite interface with no
14+
Python binding), same solution. When Kit exposes this from Python, replace this
15+
module with a one-line import.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import ctypes
21+
import logging
22+
import threading
23+
24+
logger = logging.getLogger(__name__)
25+
26+
# carb::Framework vtable (carb/Framework.h)
27+
# 0: loadPluginsEx, 8: unloadAllPlugins, 16: acquireInterfaceWithClient,
28+
# 24: tryAcquireInterfaceWithClient ← used here
29+
_FW_OFF_TRY_ACQUIRE = 24
30+
31+
# omni::fabric::IFabricUsd vtable (omni/fabric/usd/interface/IFabricUsd.h)
32+
# 0..88: prefetch / export / type-conversion entry points
33+
# 96: setEnableChangeNotifies(FabricId, bool)
34+
# 104: getEnableChangeNotifies(FabricId) -> bool
35+
_IFU_OFF_SET_ENABLE = 96
36+
_IFU_OFF_GET_ENABLE = 104
37+
38+
39+
class _Version(ctypes.Structure):
40+
_fields_ = [("major", ctypes.c_uint32), ("minor", ctypes.c_uint32)]
41+
42+
43+
class _InterfaceDesc(ctypes.Structure):
44+
_fields_ = [("name", ctypes.c_char_p), ("version", _Version)]
45+
46+
47+
def _read_u64(addr: int) -> int:
48+
return ctypes.c_uint64.from_address(addr).value
49+
50+
51+
class FabricNoticeBindings:
52+
"""Typed wrappers around ``omni::fabric::IFabricUsd``'s notice toggle."""
53+
54+
def __init__(self) -> None:
55+
self._iface_ptr: int = 0
56+
self._set_fn = None
57+
self._get_fn = None
58+
self._validated: bool = False
59+
60+
def initialize(self) -> bool:
61+
"""Acquire the ``IFabricUsd`` interface. Returns False if unavailable."""
62+
try:
63+
libcarb = ctypes.CDLL("libcarb.so")
64+
except OSError:
65+
logger.info("libcarb.so unavailable — Fabric notice suspension disabled (Linux x86_64 only)")
66+
return False
67+
68+
libcarb.acquireFramework.restype = ctypes.c_void_p
69+
libcarb.acquireFramework.argtypes = [ctypes.c_char_p, _Version]
70+
fw_ptr = libcarb.acquireFramework(b"isaaclab.cloner", _Version(0, 0))
71+
if not fw_ptr:
72+
return False
73+
74+
try_acquire_addr = _read_u64(fw_ptr + _FW_OFF_TRY_ACQUIRE)
75+
if not try_acquire_addr:
76+
return False
77+
78+
try_acquire = ctypes.CFUNCTYPE(
79+
ctypes.c_void_p, # IFabricUsd*
80+
ctypes.c_char_p, # clientName
81+
_InterfaceDesc, # desc (by value)
82+
ctypes.c_char_p, # pluginName
83+
)(try_acquire_addr)
84+
85+
desc = _InterfaceDesc(name=b"omni::fabric::IFabricUsd", version=_Version(1, 0))
86+
87+
# clientName varies across Kit configurations — same fallback chain as _cubric.py
88+
ptr = try_acquire(b"carb.scripting-python.plugin", desc, None) or try_acquire(None, desc, None)
89+
if not ptr:
90+
return False
91+
self._iface_ptr = ptr
92+
93+
set_addr = _read_u64(ptr + _IFU_OFF_SET_ENABLE)
94+
get_addr = _read_u64(ptr + _IFU_OFF_GET_ENABLE)
95+
if not (set_addr and get_addr):
96+
return False
97+
98+
# FabricId is uint64; CARB_ABI uses the platform's standard C calling convention
99+
self._set_fn = ctypes.CFUNCTYPE(None, ctypes.c_uint64, ctypes.c_bool)(set_addr)
100+
self._get_fn = ctypes.CFUNCTYPE(ctypes.c_bool, ctypes.c_uint64)(get_addr)
101+
return True
102+
103+
@property
104+
def available(self) -> bool:
105+
return self._iface_ptr != 0
106+
107+
def set_enable(self, fabric_id: int, enable: bool) -> None:
108+
if self._set_fn is not None:
109+
self._set_fn(ctypes.c_uint64(fabric_id), ctypes.c_bool(enable))
110+
111+
def is_enabled(self, fabric_id: int) -> bool:
112+
if self._get_fn is None:
113+
return False
114+
return bool(self._get_fn(ctypes.c_uint64(fabric_id)))
115+
116+
def validate_with(self, fabric_id: int) -> bool:
117+
"""One-time toggle round-trip — guards against ABI offset drift.
118+
119+
If Kit's ``IFabricUsd`` vtable layout changes, our hardcoded offsets call the
120+
wrong functions and ``set_enable`` no longer flips the flag ``is_enabled`` reads
121+
from. This catches that case the first time we have a real fabric_id to work
122+
with, and lets the caller fall back to a no-op.
123+
"""
124+
if self._validated:
125+
return True
126+
original = self.is_enabled(fabric_id)
127+
self.set_enable(fabric_id, not original)
128+
ok = self.is_enabled(fabric_id) != original
129+
self.set_enable(fabric_id, original)
130+
self._validated = ok
131+
return ok
132+
133+
134+
_BINDINGS: FabricNoticeBindings | None = None
135+
_INIT_TRIED: bool = False
136+
_LOCK = threading.Lock()
137+
138+
139+
def get_bindings() -> FabricNoticeBindings | None:
140+
"""Return the lazily-initialised bindings, or ``None`` if Kit/Carbonite is unavailable."""
141+
global _BINDINGS, _INIT_TRIED
142+
with _LOCK:
143+
if _BINDINGS is not None:
144+
return _BINDINGS
145+
if _INIT_TRIED:
146+
return None
147+
_INIT_TRIED = True
148+
b = FabricNoticeBindings()
149+
if not b.initialize():
150+
return None
151+
_BINDINGS = b
152+
return _BINDINGS

0 commit comments

Comments
 (0)