Skip to content

Commit 7294afa

Browse files
authored
perf: env_step sub-stage timing panel + optional numba TBB layer (+23% throughput on many-core) (#72)
1 parent 975c986 commit 7294afa

20 files changed

Lines changed: 388 additions & 31 deletions

File tree

‎docs/source/en/user_guide/getting_started/installation.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ training backend extra on top of them, and each option only selects or appends t
9292
| `--gpu` | `cuda`<br>`rocm` | Select the torch wheel flavor explicitly, overriding auto-detection |
9393
| `--skrl-jax` | — | SKRL on JAX backend, Linux only |
9494
| `--rslrl` | — | RSL-RL on PyTorch backend |
95+
| `--tbb` | — | Install Intel TBB for the numba parallel kernels; recommended for training on many-core servers (64+ cores), auto-activates once installed |
9596
| `--docs` | — | Add the toolchain (sphinx) needed to build the documentation locally |
9697
| `-h`, `--help` | — | Show the help message |
9798

‎docs/source/zh_CN/user_guide/getting_started/installation.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ sh install.sh --all
114114
| `--gpu` | `cuda`<br>`rocm` | 指定 torch wheel 来源,覆盖自动探测 |
115115
| `--skrl-jax` | — | SKRL(JAX)训练后端,仅 Linux |
116116
| `--rslrl` | — | RSL-RL(PyTorch)训练后端 |
117+
| `--tbb` | — | 为 numba 并行 kernel 安装 Intel TBB 线程层;多核服务器(64 核以上)训练推荐启用,安装后自动生效 |
117118
| `--docs` | — | 追加本地构建文档所需的工具链(Sphinx) |
118119
| `-h`、`--help` | — | 显示帮助 |
119120

‎install.sh‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
#
1212
# Training backends are enabled with --skrl-torch (default), --skrl-jax or
1313
# --rslrl, named after the extras that install them; multiple flags combine.
14+
# --tbb installs Intel TBB so numba parallel kernels use its threading layer
15+
# (avoids the OpenMP per-region thread-wakeup storm on many-core hosts).
1416

1517
set -e
1618

@@ -25,6 +27,7 @@ usage() {
2527
echo " --skrl-torch Training backend: SKRL on PyTorch (default)"
2628
echo " --skrl-jax Training backend: SKRL on JAX (Linux only)"
2729
echo " --rslrl Training backend: RSL-RL on PyTorch"
30+
echo " --tbb Install Intel TBB for numba parallel kernels (recommended on many-core hosts)"
2831
echo " -h, --help Show this help"
2932
}
3033

@@ -34,6 +37,7 @@ SKRL_TORCH=""
3437
SKRL_JAX=""
3538
RSLRL=""
3639
DOCS=""
40+
TBB=""
3741

3842
while [ $# -gt 0 ]; do
3943
case "$1" in
@@ -57,6 +61,9 @@ while [ $# -gt 0 ]; do
5761
--rslrl)
5862
RSLRL=1
5963
;;
64+
--tbb)
65+
TBB=1
66+
;;
6067
-h|--help)
6168
usage
6269
exit 0
@@ -162,6 +169,7 @@ else
162169
[ -n "$SKRL_JAX" ] && EXTRAS="$EXTRAS --extra skrl-jax"
163170
[ -n "$RSLRL" ] && EXTRAS="$EXTRAS --extra rslrl"
164171
[ -n "$DOCS" ] && EXTRAS="$EXTRAS --extra docs"
172+
[ -n "$TBB" ] && EXTRAS="$EXTRAS --extra tbb"
165173
set -x
166174
uv sync --all-packages --no-default-groups --extra "$GPU"$EXTRAS
167175
fi

‎motrix_env_core/pyproject.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,10 @@ dependencies = [
2121
"omegaconf>=2.3,<2.4",
2222
"typing-extensions>=4.1",
2323
]
24+
25+
[project.optional-dependencies]
26+
# TBB threading layer for numba parallel kernels: on many-core machines the
27+
# default OpenMP layer pays a per-region thread-wakeup storm that dominates
28+
# short kernels. The wheel drops libtbb.so.12 into <venv>/lib, so the runtime
29+
# preloads it by full path (see motrix_env_core.numba.threading).
30+
tbb = ["tbb==2021.13.0"]

‎motrix_env_core/src/motrix_env_core/numba/manager/env.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
TerminationTermCfg,
5353
)
5454
from motrix_env_core.numba.program import NumbaTaskProgram
55+
from motrix_env_core.numba.threading import preload_tbb
5556
from motrix_env_core.sim import (
5657
ModelQuery,
5758
PhysicsReadProgram,
@@ -343,6 +344,12 @@ def __init__(self, cfg: EnvCfgType, num_envs: int = 1, backend: str | None = Non
343344
"""Construct a manager environment using the selected simulator backend."""
344345
if not isinstance(cfg, ManagerBasedEnvCfg):
345346
raise TypeError(f"{type(cfg).__name__} must inherit ManagerBasedEnvCfg.")
347+
# Prefer the TBB threading layer for the parallel step kernels when the
348+
# tbb extra is installed: the default OpenMP layer pays a thread-wakeup
349+
# storm per kernel call that dominates short kernels on many-core
350+
# machines. Must happen before the first parallel kernel executes.
351+
if preload_tbb():
352+
logger.info("Manager env %r: numba TBB threading layer available", type(self).__name__)
346353
super().__init__(cfg, num_envs)
347354
self._rand_seed = 1 if seed is None else seed
348355
from motrix_env_core.sim.registry import create_sim_backend, default_sim_backend_name
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright Motphys Technology Co., Ltd. 2025, 2026
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Runtime threading-layer helpers for numba parallel kernels.
5+
6+
On many-core machines the default OpenMP layer pays a per-region thread-wakeup
7+
storm (``OMP_WAIT_POLICY=PASSIVE`` parks every worker between calls, and each
8+
``prange`` region must wake them all before any work starts). For short kernels
9+
called every environment step this overhead dominates the kernel itself. The
10+
TBB layer keeps a resident work-stealing pool and does not have this problem.
11+
12+
The PyPI ``tbb`` wheel installs ``libtbb.so.12`` into ``<venv>/lib`` instead of
13+
``site-packages``, so numba's plain ``CDLL("libtbb.so.12")`` lookup cannot find
14+
it without ``LD_LIBRARY_PATH``. Preloading the library by full path with
15+
``RTLD_GLOBAL`` registers its SONAME in the process, and numba's later lookup by
16+
the same SONAME reuses the loaded handle.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import ctypes
22+
import glob
23+
import sys
24+
from pathlib import Path
25+
26+
_TBB_SONAME = "libtbb.so.12"
27+
_tbb_preload_result: bool | None = None
28+
29+
30+
def preload_tbb() -> bool:
31+
"""Preload ``libtbb.so.12`` from the active environment, if present.
32+
33+
Returns ``True`` when the library was loaded (or was already loaded), which
34+
lets numba select the TBB threading layer for parallel kernels. Returns
35+
``False`` when the ``tbb`` extra is not installed — callers keep the numba
36+
default layer in that case. The probe runs once and is cached: every
37+
``ManagerEnv`` construction calls this, and the glob + CDLL work should
38+
not repeat per instance.
39+
"""
40+
global _tbb_preload_result
41+
if _tbb_preload_result is None:
42+
_tbb_preload_result = _probe_tbb()
43+
return _tbb_preload_result
44+
45+
46+
def _probe_tbb() -> bool:
47+
if not sys.platform.startswith("linux"):
48+
return False
49+
candidates = sorted(glob.glob(str(Path(sys.prefix) / "lib" / f"{_TBB_SONAME}*")), reverse=True)
50+
for path in candidates:
51+
try:
52+
ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
53+
except OSError:
54+
continue
55+
return True
56+
return False
57+
58+
59+
__all__ = ["preload_tbb"]

‎motrix_env_core/src/motrix_env_core/perf.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,27 @@ def snapshot(self) -> tuple[PerfNode, ...]:
187187
raise RuntimeError("Cannot snapshot Perf while a scope is active.")
188188
return tuple(root.snapshot() for root in self._roots.values())
189189

190+
def stage_mean_ms(self, root: str) -> dict[str, float]:
191+
"""Per-call mean milliseconds of every sub-scope under ``root``.
192+
193+
Keys are dotted paths relative to the root's children (``stage`` or
194+
``stage.sub``), so nested scope names — which may themselves contain
195+
underscores — stay unambiguously separable. Returns ``{}`` when the
196+
root has not run.
197+
"""
198+
for node in self._roots.values():
199+
if node.name == root:
200+
means: dict[str, float] = {}
201+
202+
def emit(subtree: _MutablePerfNode, path: tuple[str, ...]) -> None:
203+
for child in subtree.children.values():
204+
means[".".join((*path, child.name))] = child.total_ns / 1e6 / max(child.count, 1)
205+
emit(child, (*path, child.name))
206+
207+
emit(node, ())
208+
return means
209+
return {}
210+
190211
def call(self, name: str, function: Callable[_P, _R], *args: _P.args, **kwargs: _P.kwargs) -> _R:
191212
"""Call a function inside a named scope."""
192213
if not self._enabled:
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copyright Motphys Technology Co., Ltd. 2025, 2026
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from motrix_env_core.numba.threading import preload_tbb
5+
6+
7+
def test_preload_tbb_returns_bool_without_raising() -> None:
8+
# Whether the library exists depends on the tbb extra; the contract is
9+
# only that discovery is side-effect-safe and reports success as bool.
10+
assert isinstance(preload_tbb(), bool)

‎motrix_env_core/tests/test_perf.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,29 @@ def test_active_perf_scope_contributes_to_activated_profiler() -> None:
139139
def test_active_perf_scope_is_disabled_without_an_activated_profiler() -> None:
140140
with active_perf_scope("backend"):
141141
pass
142+
143+
144+
def test_stage_mean_ms_flattens_nested_scopes_to_dotted_paths() -> None:
145+
# two step calls; each scope entry/exit consumes one clock tick (8 per
146+
# iteration). physics runs 3ms per call, transition 9ms with a 2ms read.
147+
base = (0, 2_000_000, 5_000_000, 6_000_000, 10_000_000, 12_000_000, 15_000_000, 26_000_000)
148+
ticks = base + tuple(t + 26_000_000 for t in base)
149+
perf = Perf(enabled=True, clock=_Clock(*ticks))
150+
for _ in range(2):
151+
with perf.scope("step"):
152+
with perf.scope("physics"):
153+
pass
154+
with perf.scope("transition"):
155+
with perf.scope("read"):
156+
pass
157+
158+
means = perf.stage_mean_ms("step")
159+
160+
assert means["physics"] == pytest.approx(3.0)
161+
assert means["transition"] == pytest.approx(9.0)
162+
assert means["transition.read"] == pytest.approx(2.0)
163+
assert "step" not in means # the root itself is not part of the paths
164+
165+
166+
def test_stage_mean_ms_returns_empty_for_unrun_root() -> None:
167+
assert Perf(enabled=True).stage_mean_ms("step") == {}

‎motrix_env_motrixsim/src/motrix_env_motrixsim/sim_data.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import numpy as np
1212
import numpy.typing as npt
1313

14+
from motrix_env_core.perf import active_perf_scope
1415
from motrix_env_core.sim import PhysicsReadProgram, SimDataQuery, SimDataQueryCompiler
1516

1617
FloatArray: TypeAlias = npt.NDArray[np.float32]
@@ -370,8 +371,10 @@ def execute(self, env_ids: npt.NDArray[np.int64] | None = None) -> None:
370371
if env_ids is not None and (env_ids.dtype != np.int64 or env_ids.ndim != 1):
371372
raise TypeError("Partial simulator read env_ids must be a one-dimensional int64 ndarray.")
372373
# One native call writes the authoritative arena — the full batch, or
373-
# only the rows selected by env_ids.
374-
self._read.program.execute(self._source, env_ids=env_ids)
374+
# only the rows selected by env_ids. The perf scope nests under the
375+
# caller's stage (e.g. transition/reset in the console timing tree).
376+
with active_perf_scope("read"):
377+
self._read.program.execute(self._source, env_ids=env_ids)
375378

376379

377380
def compile_read_program(

0 commit comments

Comments
 (0)