Skip to content

Commit 5b3fb59

Browse files
authored
Merge pull request #22 from KraHsu/fix/perf-hot-path-allocations
Cut per-step allocations + CUDA syncs on training hot path
2 parents 14e7301 + b254ed7 commit 5b3fb59

11 files changed

Lines changed: 344 additions & 39 deletions

File tree

‎examples/unitree/README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ uv run genelab play Genelab-Tracking-Flat-Unitree-G1-v0 \
8282
`--agent` accepts `zero`, `random`, or `trained`. Without `--agent`, play defaults to
8383
`trained` when `--checkpoint` is set, else `zero`.
8484

85+
## Long training runs
86+
87+
For multi-hour runs (the G1 tasks default to `max_iterations=30_000` ≈ several wall-clock
88+
hours on 8×H200), set PyTorch's allocator to use growable segments **before** launching
89+
training. This dramatically reduces fragmentation-driven slowdown over time:
90+
91+
```bash
92+
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
93+
```
94+
95+
If training time drifts upward across iterations anyway, capture a profiler trace by
96+
re-running with `GENELAB_PROFILE=1` set. The trace lands under `logs/torch_profile/` and
97+
can be loaded by `tensorboard --logdir logs/torch_profile`. Take one trace at the start
98+
and one a few hours in to compare cumulative time per section. Optional env vars:
99+
`GENELAB_PROFILE_OUT`, `GENELAB_PROFILE_WAIT`, `GENELAB_PROFILE_WARMUP`,
100+
`GENELAB_PROFILE_ACTIVE`, `GENELAB_PROFILE_REPEAT` (see
101+
`src/genelab/rl/_profiler.py`). Only rank 0 emits a trace under distributed launches.
102+
85103
## Notes
86104

87105
- The G1 MJCF and STL meshes are vendored under `assets/g1/`. They originate from Unitree's

‎src/genelab/entity/articulation.py‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def __init__(self, cfg: ArticulationCfg, *, name: str = "robot") -> None:
8989
self._action_scale_tensor: torch.Tensor = torch.empty(0)
9090
self._actuators: dict[str, ActuatorBase] = {}
9191
self._joint_pos_target: torch.Tensor = torch.empty(0)
92+
self._gravity_w: torch.Tensor = torch.empty(0)
9293
self._data: RobotState | None = None
9394

9495
# ------------------------------------------------------------------ spawn / bind
@@ -118,6 +119,10 @@ def bind(self, num_envs: int, device: str) -> None:
118119
self._default_joint_pos.unsqueeze(0).expand(num_envs, -1).contiguous()
119120
)
120121
self._data = RobotState(num_envs, self._num_dofs, self._num_links, device)
122+
# Cached constant gravity vector, broadcast across envs. Built once at bind to keep
123+
# ``refresh`` allocation-free on the hot path (called every step + every reset).
124+
self._gravity_w = torch.zeros(num_envs, 3, device=device)
125+
self._gravity_w[:, 2] = -1.0
121126
self._assemble_actuators()
122127

123128
# ------------------------------------------------------------------ internals
@@ -253,14 +258,14 @@ def refresh(self) -> None:
253258
rs = self._data
254259
robot = self._gs_handle
255260
try:
256-
rs.root_pos = to_tensor(robot.get_pos(), self._device)
257-
rs.root_quat = to_tensor(robot.get_quat(), self._device)
258-
rs.root_lin_vel_w = to_tensor(robot.get_vel(), self._device)
259-
rs.root_ang_vel_w = to_tensor(robot.get_ang(), self._device)
261+
rs.root_pos.copy_(to_tensor(robot.get_pos(), self._device))
262+
rs.root_quat.copy_(to_tensor(robot.get_quat(), self._device))
263+
rs.root_lin_vel_w.copy_(to_tensor(robot.get_vel(), self._device))
264+
rs.root_ang_vel_w.copy_(to_tensor(robot.get_ang(), self._device))
260265
joint_pos_full = to_tensor(robot.get_dofs_position(), self._device)
261266
joint_vel_full = to_tensor(robot.get_dofs_velocity(), self._device)
262-
rs.joint_pos = joint_pos_full.index_select(-1, self._actuated_dof_idx)
263-
rs.joint_vel = joint_vel_full.index_select(-1, self._actuated_dof_idx)
267+
rs.joint_pos.copy_(joint_pos_full.index_select(-1, self._actuated_dof_idx))
268+
rs.joint_vel.copy_(joint_vel_full.index_select(-1, self._actuated_dof_idx))
264269
except AttributeError:
265270
pass
266271
for attr, target in (
@@ -277,13 +282,14 @@ def refresh(self) -> None:
277282
except Exception:
278283
continue
279284
tensor = to_tensor(value, self._device)
285+
# Genesis may return either (num_envs, num_links, ...) or the bare (num_links, ...)
286+
# shape on older single-env builds; ``expand`` broadcasts without allocating.
280287
if tensor.dim() == 2:
281288
tensor = tensor.unsqueeze(0).expand(self._num_envs, -1, -1)
282-
setattr(rs, target, tensor)
289+
getattr(rs, target).copy_(tensor)
283290
rs.root_lin_vel_b = quat_rotate_inverse(rs.root_quat, rs.root_lin_vel_w)
284291
rs.root_ang_vel_b = quat_rotate_inverse(rs.root_quat, rs.root_ang_vel_w)
285-
gravity_w = torch.tensor([0.0, 0.0, -1.0], device=self._device).expand_as(rs.root_lin_vel_w)
286-
rs.projected_gravity_b = quat_rotate_inverse(rs.root_quat, gravity_w)
292+
rs.projected_gravity_b = quat_rotate_inverse(rs.root_quat, self._gravity_w)
287293

288294
def write_joint_state(
289295
self,

‎src/genelab/envs/manager_based_rl_env.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,10 @@ def _reset_idx(self, env_ids: torch.Tensor) -> None:
245245
def step(
246246
self, action: torch.Tensor
247247
) -> tuple[dict[str, torch.Tensor], torch.Tensor, torch.Tensor, torch.Tensor, dict[str, Any]]:
248-
action = action.to(self._device).clone()
248+
# ``action_manager.process_action`` copies into a pre-allocated buffer, so we don't
249+
# need ``.clone()`` here; the ``.to`` call is a no-op when the action is already on
250+
# the right device.
251+
action = action.to(self._device, non_blocking=True)
249252
self.action_manager.process_action(action)
250253
for _ in range(self._decimation):
251254
self.action_manager.apply_action()
@@ -269,7 +272,9 @@ def step(
269272
self._reset_idx(reset_ids)
270273
self._articulation.refresh()
271274
obs = self.observation_manager.compute()
272-
return obs, reward, terminated, time_outs, dict(self._extras)
275+
# The RSL-RL wrapper shallow-copies ``extras`` itself before mutating, so we hand back
276+
# the live dict to skip an unnecessary per-step copy.
277+
return obs, reward, terminated, time_outs, self._extras
273278

274279
def close(self) -> None:
275280
self._scene.close()

‎src/genelab/managers/curriculum_manager.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,19 @@ def active_terms(self) -> list[str]:
3939

4040
def compute(self, env_ids: torch.Tensor | slice | None = None) -> dict[str, float]:
4141
extras: dict[str, float] = {}
42+
# Collect tensor-valued curriculum terms separately so all their means come back
43+
# with a single host sync (one ``.tolist()`` instead of one ``.item()`` per term).
44+
tensor_names: list[str] = []
45+
tensor_means: list[torch.Tensor] = []
4246
for name, term_cfg in zip(self._term_names, self._term_cfgs, strict=True):
4347
value = term_cfg.func(self._env, env_ids, **term_cfg.params)
4448
if isinstance(value, torch.Tensor):
45-
extras[f"Curriculum/{name}"] = float(value.float().mean().item())
49+
tensor_names.append(name)
50+
tensor_means.append(value.float().mean())
4651
elif value is not None:
4752
extras[f"Curriculum/{name}"] = float(value)
53+
if tensor_means:
54+
means_list = torch.stack(tensor_means).tolist()
55+
for name, mean in zip(tensor_names, means_list):
56+
extras[f"Curriculum/{name}"] = float(mean)
4857
return extras

‎src/genelab/managers/reward_manager.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,18 @@ def active_terms(self) -> list[str]:
6464
def reset(self, env_ids: torch.Tensor | slice | None = None) -> dict[str, float]:
6565
if env_ids is None:
6666
env_ids = slice(None)
67-
extras: dict[str, float] = {}
6867
max_episode_length_s = getattr(self._env, "max_episode_length_s", 1.0) or 1.0
69-
for name, episode_sum in self._episode_sums.items():
70-
mean = torch.mean(episode_sum[env_ids]).item()
71-
extras[f"Episode_Reward/{name}"] = mean / max_episode_length_s
72-
episode_sum[env_ids] = 0.0
73-
return extras
68+
names = list(self._episode_sums.keys())
69+
if not names:
70+
return {}
71+
# Stack per-term means into a single tensor and pull all values to host with one sync.
72+
# The per-term ``.item()`` loop was a per-reset CUDA sync per reward term (6+ per step
73+
# on busy reset paths in g1-velocity-flat).
74+
means = torch.stack([torch.mean(self._episode_sums[name][env_ids]) for name in names])
75+
means_list = (means / max_episode_length_s).tolist()
76+
for name in names:
77+
self._episode_sums[name][env_ids] = 0.0
78+
return {f"Episode_Reward/{name}": value for name, value in zip(names, means_list)}
7479

7580
def compute(self, dt: float) -> torch.Tensor:
7681
self._reward_buf.zero_()

‎src/genelab/managers/termination_manager.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ def terminated(self) -> torch.Tensor:
6666
def reset(self, env_ids: torch.Tensor | slice | None = None) -> dict[str, float]:
6767
if env_ids is None:
6868
env_ids = slice(None)
69-
extras: dict[str, float] = {}
70-
for name, term_done in self._term_dones.items():
71-
extras[f"Episode_Termination/{name}"] = float(
72-
torch.count_nonzero(term_done[env_ids]).item()
73-
)
74-
return extras
69+
names = list(self._term_dones.keys())
70+
if not names:
71+
return {}
72+
# One CUDA sync for all termination terms, instead of one per term.
73+
counts = torch.stack(
74+
[torch.count_nonzero(self._term_dones[name][env_ids]) for name in names]
75+
).tolist()
76+
return {f"Episode_Termination/{name}": float(c) for name, c in zip(names, counts)}
7577

7678
def compute(self) -> torch.Tensor:
7779
self._truncated_buf.zero_()

‎src/genelab/rl/_profiler.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Opt-in ``torch.profiler`` wrapper for diagnosing training slowdowns.
2+
3+
Activated by ``GENELAB_PROFILE=1`` in the environment. Disabled by default so existing
4+
runs are unaffected. Only rank 0 emits a trace under distributed launches, to avoid every
5+
worker writing simultaneously.
6+
7+
Environment variables (all optional):
8+
9+
* ``GENELAB_PROFILE=1`` — turn the profiler on. Any other value is treated as off.
10+
* ``GENELAB_PROFILE_OUT`` — directory the TensorBoard trace handler writes to. Default
11+
``logs/torch_profile`` (relative to the current working directory).
12+
* ``GENELAB_PROFILE_WAIT`` / ``_WARMUP`` / ``_ACTIVE`` / ``_REPEAT`` — passed straight to
13+
``torch.profiler.schedule``. Defaults: ``wait=10``, ``warmup=5``, ``active=10``,
14+
``repeat=2``. Tweak if iterations are very long or very short.
15+
16+
The profiler's ``.step()`` must be advanced once per training iteration for the schedule
17+
to move forward. We wire this in by patching the active runner's ``learn`` loop one level
18+
up (see ``genelab.rl.runner.train_task``), which inserts a profiler-step callback. If the
19+
patching path isn't taken, the context manager still records a single contiguous trace.
20+
"""
21+
22+
import os
23+
from contextlib import contextmanager
24+
from pathlib import Path
25+
from typing import TYPE_CHECKING, Any
26+
27+
from genelab.rl.distributed import is_main_process
28+
29+
if TYPE_CHECKING:
30+
from collections.abc import Generator
31+
32+
33+
def profiler_enabled() -> bool:
34+
return os.environ.get("GENELAB_PROFILE", "0") == "1"
35+
36+
37+
def _read_int(name: str, default: int) -> int:
38+
raw = os.environ.get(name)
39+
if raw is None:
40+
return default
41+
try:
42+
return int(raw)
43+
except ValueError:
44+
return default
45+
46+
47+
@contextmanager
48+
def maybe_profile() -> "Generator[Any, None, None]":
49+
"""Yield a profiler-step callback. No-op when ``GENELAB_PROFILE != 1`` or not rank 0."""
50+
if not profiler_enabled() or not is_main_process():
51+
yield None
52+
return
53+
import torch
54+
from torch.profiler import ProfilerActivity, profile, schedule, tensorboard_trace_handler
55+
56+
out_dir = Path(os.environ.get("GENELAB_PROFILE_OUT", "logs/torch_profile"))
57+
out_dir.mkdir(parents=True, exist_ok=True)
58+
59+
activities = [ProfilerActivity.CPU]
60+
if torch.cuda.is_available():
61+
activities.append(ProfilerActivity.CUDA)
62+
63+
sched = schedule(
64+
wait=_read_int("GENELAB_PROFILE_WAIT", 10),
65+
warmup=_read_int("GENELAB_PROFILE_WARMUP", 5),
66+
active=_read_int("GENELAB_PROFILE_ACTIVE", 10),
67+
repeat=_read_int("GENELAB_PROFILE_REPEAT", 2),
68+
)
69+
handler = tensorboard_trace_handler(str(out_dir))
70+
71+
with profile(activities=activities, schedule=sched, on_trace_ready=handler) as prof:
72+
yield prof.step

‎src/genelab/rl/runner.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from genelab.cache import ensure_project_cache
1111
from genelab.registry import TASKS
12+
from genelab.rl._profiler import maybe_profile, profiler_enabled
1213
from genelab.rl.config import RslRlOnPolicyRunnerCfg
1314
from genelab.rl.distributed import is_main_process
1415
from genelab.rl.rsl_rl_wrapper import RslRlVecEnvWrapper
@@ -135,7 +136,21 @@ def train_task(
135136
)
136137
if resume_from is not None:
137138
runner.load(str(resume_from))
138-
runner.learn(num_learning_iterations=agent_cfg.max_iterations)
139+
with maybe_profile() as prof_step:
140+
if prof_step is not None and profiler_enabled():
141+
# Advance the profiler schedule once per env step. RSL-RL doesn't expose a
142+
# per-iteration hook, so the wrapper's ``step`` is the closest fire-once-per-
143+
# rollout-step seam. Multiply WAIT/WARMUP/ACTIVE by ``num_steps_per_env`` if
144+
# you want the schedule expressed in PPO iterations.
145+
original_step = wrapped.step
146+
147+
def _step_with_profiler(*args: Any, **kwargs: Any) -> Any:
148+
result = original_step(*args, **kwargs)
149+
prof_step()
150+
return result
151+
152+
wrapped.step = _step_with_profiler # type: ignore[method-assign]
153+
runner.learn(num_learning_iterations=agent_cfg.max_iterations)
139154
env.close()
140155
return log_dir
141156

‎src/genelab/sensor/body_velocity.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import torch
1111

1212
from genelab.sensor.sensor import Sensor, SensorCfg
13-
from genelab.utils.math import matrix_from_quat, quat_apply_inverse
13+
from genelab.utils.math import quat_apply, quat_apply_inverse
1414

1515
if TYPE_CHECKING:
1616
from genelab.envs.manager_based_rl_env import ManagerBasedRlEnv
@@ -83,7 +83,6 @@ def _compute_data(self) -> torch.Tensor:
8383
if self._cfg_typed.measure == "ang_vel":
8484
return quat_apply_inverse(link_quat, link_ang_vel_w) + self._bias
8585
link_lin_vel_w = rs.link_lin_vel_w[:, self._link_idx]
86-
R = matrix_from_quat(link_quat)
87-
r_world = torch.matmul(R, self._offset_local)
86+
r_world = quat_apply(link_quat, self._offset_local.expand_as(link_lin_vel_w))
8887
v_site_w = link_lin_vel_w + torch.cross(link_ang_vel_w, r_world, dim=-1)
8988
return quat_apply_inverse(link_quat, v_site_w) + self._bias

‎src/genelab/sensor/contact.py‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def update(self, dt: float) -> None:
117117
force = self._read_link_forces()
118118
in_contact = force.norm(dim=-1) > self._cfg_typed.force_threshold
119119
state = self._air_state
120-
# Detect just-landed / just-lifted-off transitions before mutating the timers.
120+
# Capture transitions before mutating the timers (the timer values matter pre-tick).
121121
first_contact = in_contact & (state.current_air_time > 0)
122122
first_detached = (~in_contact) & (state.current_contact_time > 0)
123123
state.last_air_time = torch.where(
@@ -126,16 +126,13 @@ def update(self, dt: float) -> None:
126126
state.last_contact_time = torch.where(
127127
first_detached, state.current_contact_time + dt, state.last_contact_time
128128
)
129-
state.current_air_time = torch.where(
130-
in_contact,
131-
torch.zeros_like(state.current_air_time),
132-
state.current_air_time + dt,
133-
)
134-
state.current_contact_time = torch.where(
135-
in_contact,
136-
state.current_contact_time + dt,
137-
torch.zeros_like(state.current_contact_time),
138-
)
129+
# Tick all timers, then mask-zero whichever phase ended. Original ``torch.where`` with
130+
# ``torch.zeros_like`` allocated two (B, N) tensors per step; this in-place form is
131+
# zero-allocation and removes the dominant per-step churn from the contact sensor.
132+
state.current_air_time += dt
133+
state.current_air_time.masked_fill_(in_contact, 0.0)
134+
state.current_contact_time += dt
135+
state.current_contact_time.masked_fill_(~in_contact, 0.0)
139136

140137
def reset(self, env_ids: torch.Tensor | None = None) -> None:
141138
super().reset(env_ids)

0 commit comments

Comments
 (0)