Skip to content

Commit 61c8a94

Browse files
kellyguo11BobStafaH
authored
Fixes multi-GPU device resolution in AppLauncher (#5215)
## Description Centralizes multi-GPU device resolution in AppLauncher and launch_simulation() so that individual training scripts do not need per-script distributed device logic. ### Problem In distributed (multi-GPU) training, every RL script duplicates device resolution. This breaks when CUDA_VISIBLE_DEVICES restricts each process to a single GPU (e.g., SLURM), because local_rank=1 exceeds the visible device count. Additionally, physics backends like Newton/Warp that allocate on the current CUDA device during init may all default to cuda:0. ### Fix **app_launcher.py:** CUDA_VISIBLE_DEVICES-aware device ID resolution, early torch.cuda.set_device(), new self.device attribute. **sim_launcher.py:** New _resolve_distributed_device() helper centralizes device logic in launch_simulation(). After AppLauncher creation, propagates AppLauncher.device to env_cfg.sim.device. Handles kitless Newton path. Also treats PresetCfg renderers as non-Kit in _is_kit_camera(). **presets.py:** Adds newton and physx aliases to MultiBackendRendererCfg. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the pre-commit checks - [x] My changes generate no new warnings - [x] I have read and understood the contribution guidelines --------- Co-authored-by: Kelly Guo <kellyguo11@users.noreply.github.com> Co-authored-by: Bob <bob@isaaclab.dev> Co-authored-by: Mustafa H <34825877+StafaH@users.noreply.github.com>
1 parent 8fdb110 commit 61c8a94

10 files changed

Lines changed: 790 additions & 27 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
# Multi-GPU distributed training validation
7+
#
8+
# This workflow validates that multi-GPU training works correctly across:
9+
# - Physics backends: PhysX, Newton
10+
# - Rendering backends: none (physics-only), Isaac RTX, Newton Warp
11+
#
12+
# Runs on a dedicated multi-GPU runner (separate from standard CI) to minimize costs.
13+
# Only triggered on PRs that touch distributed training code paths.
14+
15+
name: Multi-GPU Training Tests
16+
17+
on:
18+
pull_request:
19+
paths:
20+
- "source/isaaclab/isaaclab/app/app_launcher.py"
21+
- "source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py"
22+
- "scripts/reinforcement_learning/**/train.py"
23+
- ".github/workflows/test-multi-gpu.yaml"
24+
workflow_dispatch:
25+
26+
concurrency:
27+
group: ${{ github.workflow }}-${{ github.ref }}
28+
cancel-in-progress: true
29+
30+
jobs:
31+
test-multi-gpu:
32+
name: Multi-GPU (${{ matrix.physics }}, ${{ matrix.renderer }})
33+
# Use dedicated multi-GPU runner to avoid blocking standard CI resources
34+
# Configure this label on a runner with 2+ GPUs (e.g., g5.12xlarge with 4x A10G)
35+
runs-on: [self-hosted, linux, x64, gpu, multi-gpu]
36+
timeout-minutes: 30
37+
strategy:
38+
fail-fast: false
39+
matrix:
40+
include:
41+
# PhysX physics-only
42+
- physics: physx
43+
renderer: none
44+
task: Isaac-Cartpole-Direct-v0
45+
extra_args: ""
46+
47+
# PhysX + Isaac RTX renderer
48+
- physics: physx
49+
renderer: isaac-rtx
50+
task: Isaac-Cartpole-Camera-Presets-Direct-v0
51+
extra_args: ""
52+
trainer: skrl
53+
54+
# PhysX + Newton Warp renderer (hybrid)
55+
- physics: physx
56+
renderer: newton-warp
57+
task: Isaac-Cartpole-Camera-Presets-Direct-v0
58+
extra_args: "env.tiled_camera.renderer_cfg=newton_renderer"
59+
trainer: skrl
60+
61+
# Newton physics-only
62+
- physics: newton
63+
renderer: none
64+
task: Isaac-Cartpole-Direct-v0
65+
extra_args: "+sim=newton"
66+
67+
# Newton + Newton Warp renderer
68+
- physics: newton
69+
renderer: newton-warp
70+
task: Isaac-Cartpole-Camera-Presets-Direct-v0
71+
extra_args: "+sim=newton env.tiled_camera.renderer_cfg=newton_renderer"
72+
trainer: skrl
73+
74+
# Newton + Isaac RTX renderer (hybrid)
75+
- physics: newton
76+
renderer: isaac-rtx
77+
task: Isaac-Cartpole-Camera-Presets-Direct-v0
78+
extra_args: "+sim=newton"
79+
trainer: skrl
80+
81+
steps:
82+
- name: Checkout repository
83+
uses: actions/checkout@v4
84+
85+
- name: Set up Python
86+
uses: actions/setup-python@v5
87+
with:
88+
python-version: "3.10"
89+
90+
- name: Install Isaac Lab
91+
run: |
92+
./isaaclab.sh --install
93+
94+
- name: Verify multi-GPU availability
95+
run: |
96+
echo "=== GPU Info ==="
97+
nvidia-smi --query-gpu=index,name,memory.total --format=csv
98+
99+
GPU_COUNT=$(python -c "import torch; print(torch.cuda.device_count())")
100+
echo "Detected $GPU_COUNT GPU(s)"
101+
102+
if [ "$GPU_COUNT" -lt 2 ]; then
103+
echo "::error::At least 2 GPUs required for multi-GPU tests, found $GPU_COUNT"
104+
exit 1
105+
fi
106+
107+
- name: Run multi-GPU training (${{ matrix.physics }}, ${{ matrix.renderer }})
108+
env:
109+
NCCL_DEBUG: WARN
110+
run: |
111+
TRAINER="${{ matrix.trainer || 'rsl_rl' }}"
112+
113+
echo "=========================================="
114+
echo "Physics: ${{ matrix.physics }}"
115+
echo "Renderer: ${{ matrix.renderer }}"
116+
echo "Task: ${{ matrix.task }}"
117+
echo "Trainer: $TRAINER"
118+
echo "Extra args: ${{ matrix.extra_args }}"
119+
echo "=========================================="
120+
121+
# Run 2-GPU distributed training for 3 iterations
122+
./isaaclab.sh -p -m torch.distributed.run --nproc_per_node=2 \
123+
scripts/reinforcement_learning/${TRAINER}/train.py \
124+
--task=${{ matrix.task }} \
125+
--headless \
126+
--distributed \
127+
--max_iterations=3 \
128+
--num_envs=16 \
129+
${{ matrix.extra_args }}
130+
131+
- name: Verify training completed
132+
run: |
133+
# Find the most recent log directory
134+
LATEST_LOG=$(ls -td logs/*/*/*/ 2>/dev/null | head -1)
135+
136+
if [ -z "$LATEST_LOG" ]; then
137+
echo "::error::No training log directory found"
138+
exit 1
139+
fi
140+
141+
echo "Log directory: $LATEST_LOG"
142+
ls -la "$LATEST_LOG"
143+
144+
# Check for model checkpoints
145+
MODELS=$(find "$LATEST_LOG" -name "*.pt" | wc -l)
146+
echo "Model checkpoints found: $MODELS"
147+
148+
if [ "$MODELS" -lt 1 ]; then
149+
echo "::error::No model checkpoints found - training may have failed"
150+
exit 1
151+
fi
152+
153+
echo "✅ Multi-GPU training completed successfully (${{ matrix.physics }}, ${{ matrix.renderer }})"

‎scripts/benchmarks/benchmark_non_rl.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ def main(
120120

121121
# override configurations with non-hydra CLI arguments
122122
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
123-
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
123+
# For distributed training, launch_simulation() already resolved the
124+
# correct per-rank device; only apply a CLI --device override for
125+
# non-distributed runs (the default "cuda:0" would clobber the
126+
# per-rank device otherwise).
127+
if not args_cli.distributed:
128+
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
124129
env_cfg.seed = args_cli.seed
125130

126131
# check for invalid combination of CPU device with distributed training
@@ -131,10 +136,10 @@ def main(
131136
)
132137

133138
# process distributed
139+
# env_cfg.sim.device is already resolved by launch_simulation().
134140
world_size = 1
135141
world_rank = 0
136142
if args_cli.distributed:
137-
env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
138143
world_size = int(os.getenv("WORLD_SIZE", 1))
139144
world_rank = int(os.getenv("RANK", "0"))
140145

‎scripts/benchmarks/benchmark_rlgames.py‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,16 +147,22 @@ def main(
147147

148148
# override configurations with non-hydra CLI arguments
149149
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
150-
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
150+
# For distributed training, launch_simulation() already resolved the
151+
# correct per-rank device; only apply a CLI --device override for
152+
# non-distributed runs (the default "cuda:0" would clobber the
153+
# per-rank device otherwise).
154+
if not args_cli.distributed:
155+
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
151156
# check for invalid combination of CPU device with distributed training
152157
if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device:
153158
raise ValueError(
154159
"Distributed training is not supported when using CPU device. "
155160
"Please use GPU device (e.g., --device cuda) for distributed training."
156161
)
157162

158-
# update agent device to match simulation device
159-
if args_cli.device is not None:
163+
# update agent device to match simulation device (skip for distributed —
164+
# the per-rank device is resolved by launch_simulation)
165+
if args_cli.device is not None and not args_cli.distributed:
160166
agent_cfg["params"]["config"]["device"] = args_cli.device
161167
agent_cfg["params"]["config"]["device_name"] = args_cli.device
162168

@@ -166,10 +172,10 @@ def main(
166172
agent_cfg["params"]["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["params"]["seed"]
167173

168174
# process distributed
175+
# env_cfg.sim.device is already resolved by launch_simulation().
169176
world_rank = 0
170177
if args_cli.distributed:
171-
env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
172-
agent_cfg["params"]["config"]["device"] = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
178+
agent_cfg["params"]["config"]["device"] = env_cfg.sim.device
173179
world_rank = int(os.getenv("RANK", "0"))
174180

175181
# specify directory for logging experiments
@@ -186,11 +192,9 @@ def main(
186192
# multi-gpu training config
187193
if args_cli.distributed:
188194
agent_cfg["params"]["seed"] += int(os.getenv("RANK", "0"))
189-
agent_cfg["params"]["config"]["device"] = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
190-
agent_cfg["params"]["config"]["device_name"] = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
195+
agent_cfg["params"]["config"]["device"] = env_cfg.sim.device
196+
agent_cfg["params"]["config"]["device_name"] = env_cfg.sim.device
191197
agent_cfg["params"]["config"]["multi_gpu"] = True
192-
# update env config device
193-
env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
194198

195199
# max iterations
196200
if args_cli.max_iterations:

‎scripts/benchmarks/benchmark_rsl_rl.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,12 @@ def main(
156156
# set the environment seed
157157
# note: certain randomizations occur in the environment initialization so we set the seed here
158158
env_cfg.seed = agent_cfg.seed
159-
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
159+
# For distributed training, launch_simulation() already resolved the
160+
# correct per-rank device; only apply a CLI --device override for
161+
# non-distributed runs (the default "cuda:0" would clobber the
162+
# per-rank device otherwise).
163+
if not args_cli.distributed:
164+
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
160165
# check for invalid combination of CPU device with distributed training
161166
if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device:
162167
raise ValueError(
@@ -165,11 +170,11 @@ def main(
165170
)
166171

167172
# multi-gpu training configuration
173+
# env_cfg.sim.device is already resolved by launch_simulation().
168174
world_rank = 0
169175
world_size = 1
170176
if args_cli.distributed:
171-
env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
172-
agent_cfg.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}"
177+
agent_cfg.device = env_cfg.sim.device
173178

174179
# use global rank for seed diversity across all nodes
175180
world_rank = int(os.getenv("RANK", "0"))

‎scripts/reinforcement_learning/rl_games/train.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,12 @@ def main():
8484
with launch_simulation(env_cfg, args_cli):
8585
# override configurations with non-hydra CLI arguments
8686
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
87-
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
87+
# For distributed training, launch_simulation() already resolved the
88+
# correct per-rank device; only apply a CLI --device override for
89+
# non-distributed runs (the default "cuda:0" would clobber the
90+
# per-rank device otherwise).
91+
if not args_cli.distributed:
92+
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
8893
if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device:
8994
raise ValueError(
9095
"Distributed training is not supported when using CPU device. "
@@ -110,12 +115,12 @@ def main():
110115

111116
# multi-gpu training config
112117
if args_cli.distributed:
113-
local_rank = int(os.getenv("LOCAL_RANK", "0"))
114118
agent_cfg["params"]["seed"] += int(os.getenv("RANK", "0"))
115-
agent_cfg["params"]["config"]["device"] = f"cuda:{local_rank}"
116-
agent_cfg["params"]["config"]["device_name"] = f"cuda:{local_rank}"
119+
# env_cfg.sim.device is resolved by launch_simulation() which
120+
# accounts for CUDA_VISIBLE_DEVICES restrictions.
121+
agent_cfg["params"]["config"]["device"] = env_cfg.sim.device
122+
agent_cfg["params"]["config"]["device_name"] = env_cfg.sim.device
117123
agent_cfg["params"]["config"]["multi_gpu"] = True
118-
env_cfg.sim.device = f"cuda:{local_rank}"
119124

120125
# set the environment seed (after multi-gpu config for updated rank from agent seed)
121126
env_cfg.seed = agent_cfg["params"]["seed"]

‎scripts/reinforcement_learning/rsl_rl/train.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen
120120
# set the environment seed
121121
# note: certain randomizations occur in the environment initialization so we set the seed here
122122
env_cfg.seed = agent_cfg.seed
123-
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
123+
# For distributed training, launch_simulation() already resolved the
124+
# correct per-rank device; only apply a CLI --device override for
125+
# non-distributed runs (the default "cuda:0" would clobber the
126+
# per-rank device otherwise).
127+
if not args_cli.distributed:
128+
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
124129
# check for invalid combination of CPU device with distributed training
125130
if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device:
126131
raise ValueError(
@@ -130,10 +135,10 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen
130135

131136
# multi-gpu training configuration
132137
if args_cli.distributed:
133-
local_rank = int(os.getenv("LOCAL_RANK", "0"))
134138
global_rank = int(os.getenv("RANK", "0"))
135-
env_cfg.sim.device = f"cuda:{local_rank}"
136-
agent_cfg.device = f"cuda:{local_rank}"
139+
# env_cfg.sim.device is resolved by launch_simulation() which
140+
# accounts for CUDA_VISIBLE_DEVICES restrictions.
141+
agent_cfg.device = env_cfg.sim.device
137142

138143
# use global rank for seed diversity across all nodes
139144
seed = agent_cfg.seed + global_rank

‎scripts/reinforcement_learning/skrl/train.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ def main():
117117

118118
# override configurations with non-hydra CLI arguments
119119
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
120-
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
120+
# For distributed training, launch_simulation() already resolved the
121+
# correct per-rank device; only apply a CLI --device override for
122+
# non-distributed runs (the default "cuda:0" would clobber the
123+
# per-rank device otherwise).
124+
if not args_cli.distributed:
125+
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
121126

122127
if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device:
123128
raise ValueError(
@@ -127,9 +132,9 @@ def main():
127132

128133
# multi-gpu training config
129134
if args_cli.distributed:
130-
local_rank = int(os.getenv("LOCAL_RANK", "0"))
131135
global_rank = int(os.getenv("RANK", "0"))
132-
env_cfg.sim.device = f"cuda:{local_rank}"
136+
# env_cfg.sim.device is resolved by launch_simulation() which
137+
# accounts for CUDA_VISIBLE_DEVICES restrictions.
133138
# max iterations for training
134139
if args_cli.max_iterations:
135140
agent_cfg["trainer"]["timesteps"] = args_cli.max_iterations * agent_cfg["agent"]["rollouts"]

‎source/isaaclab/isaaclab/app/app_launcher.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa
231231

232232
# Exposed to train scripts
233233
self.device_id: int # device ID for GPU simulation (defaults to 0)
234+
self.device: str # resolved device string (e.g. "cuda:0" or "cpu")
234235
self.local_rank: int # local rank of GPUs in the current node
235236
self.global_rank: int # global rank for multi-node training
236237

@@ -953,7 +954,20 @@ def _resolve_device_settings(self, launcher_args: dict):
953954
# global rank (GPU id) in multi-gpu multi-node mode
954955
self.global_rank = int(os.getenv("RANK", "0")) + int(os.getenv("JAX_RANK", "0"))
955956

956-
self.device_id = self.local_rank
957+
# When CUDA_VISIBLE_DEVICES restricts each process to a single GPU,
958+
# local_rank may exceed the visible device count. Fall back to cuda:0
959+
# so the process uses the one GPU it can see.
960+
# We compare local_rank against device_count (not WORLD_SIZE) so that
961+
# multi-node setups work correctly: WORLD_SIZE is global across all
962+
# nodes, but device_count is local.
963+
import torch
964+
965+
num_visible_gpus = torch.cuda.device_count()
966+
if self.local_rank < num_visible_gpus:
967+
self.device_id = self.local_rank
968+
else:
969+
self.device_id = 0
970+
957971
device = "cuda:" + str(self.device_id)
958972
launcher_args["multi_gpu"] = False
959973
# limit CPU threads to minimize thread context switching
@@ -971,6 +985,17 @@ def _resolve_device_settings(self, launcher_args: dict):
971985
launcher_args["physics_gpu"] = self.device_id
972986
launcher_args["active_gpu"] = self.device_id
973987

988+
# Set the current CUDA device early so that physics backends (e.g. Newton/Warp)
989+
# that allocate on the "current" device during initialization get the correct GPU.
990+
# Without this, all ranks may default to cuda:0 for early allocations.
991+
if "cuda" in device:
992+
import torch
993+
994+
torch.cuda.set_device(self.device_id)
995+
996+
# Store the resolved device string for downstream consumers (e.g. sim_launcher)
997+
self.device = device
998+
974999
logger.info("Using device: %s", device)
9751000

9761001
def _resolve_experience_file(self, launcher_args: dict):

0 commit comments

Comments
 (0)