Skip to content

Commit 04fb61d

Browse files
authored
[Multi-GPU] Refactors multi-gpu logic into module, fix ghost process on keyboard interrup (#6975)
# Description Refactors multi-gpu logic from train_multigpu into isaaclab_rl ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have added a changelog fragment under `source/<pkg>/changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there <!-- As you go through the checklist above, you can mark something as done by putting an x character in it For example, - [x] I have done this task - [ ] I have not done this task -->
1 parent e209212 commit 04fb61d

9 files changed

Lines changed: 541 additions & 321 deletions

File tree

scripts/reinforcement_learning/train_multigpu.py

Lines changed: 3 additions & 310 deletions
Original file line numberDiff line numberDiff line change
@@ -3,321 +3,14 @@
33
#
44
# SPDX-License-Identifier: BSD-3-Clause
55

6-
"""Multi-GPU training entrypoint for Isaac Lab reinforcement learning workflows."""
6+
"""Multi-GPU training executable for Isaac Lab reinforcement learning workflows."""
77

8-
from __future__ import annotations
9-
10-
import argparse
11-
import os
12-
import shlex
13-
import signal
14-
import subprocess
15-
import sys
16-
from pathlib import Path
17-
from types import FrameType
18-
19-
SCRIPT_DIR = Path(__file__).resolve().parent
20-
TRAIN_SCRIPT = SCRIPT_DIR / "train.py"
21-
22-
DISTRIBUTED_LIBRARIES = ("rl_games", "rsl_rl", "skrl")
23-
SKRL_JAX_TORCHRUN_ONLY_ARGS = (
24-
"master_addr",
25-
"master_port",
26-
"rdzv_backend",
27-
"rdzv_endpoint",
28-
"rdzv_id",
29-
"max_restarts",
30-
"monitor_interval",
31-
"start_method",
32-
"role",
33-
"tee",
34-
"redirects",
35-
"local_ranks_filter",
36-
"log_dir",
37-
)
38-
39-
40-
def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
41-
"""Parse multi-GPU launcher arguments and return forwarded training arguments."""
42-
parser = argparse.ArgumentParser(
43-
description="Launch multi-GPU RL training with the selected distributed launcher.",
44-
formatter_class=argparse.RawDescriptionHelpFormatter,
45-
allow_abbrev=False,
46-
epilog=(
47-
"Examples:\n"
48-
" train_multigpu --num_gpus 4 --task Isaac-Cartpole\n"
49-
" train_multigpu --rl_library skrl --num_gpus 2 --task Isaac-Cartpole\n"
50-
" train_multigpu --rl_library skrl --num_gpus 2 --ml_framework jax "
51-
"--task Isaac-Cartpole\n"
52-
"\n"
53-
"All unrecognized arguments are forwarded to the selected training library."
54-
),
55-
)
56-
parser.add_argument(
57-
"--rl_library",
58-
choices=DISTRIBUTED_LIBRARIES,
59-
default="rsl_rl",
60-
help="Distributed-capable training library to use. Defaults to rsl_rl.",
61-
)
62-
parser.add_argument(
63-
"--num_gpus",
64-
"--nproc_per_node",
65-
dest="nproc_per_node",
66-
default="gpu",
67-
help=(
68-
"Number of trainer processes to launch on each node. Accepts an integer or torchrun values "
69-
"'gpu', 'cpu', and 'auto'. skrl JAX training requires an integer. Defaults to 'gpu'."
70-
),
71-
)
72-
parser.add_argument("--nnodes", default=None, help="Number of nodes to use for distributed training.")
73-
parser.add_argument("--node_rank", default=None, help="Rank of this node in a multi-node job.")
74-
parser.add_argument(
75-
"--coordinator_address",
76-
default=None,
77-
help="IP address and port where skrl JAX process 0 starts the JAX coordinator service.",
78-
)
79-
parser.add_argument("--master_addr", default=None, help="Master node address for static rendezvous.")
80-
parser.add_argument("--master_port", default=None, help="Master node port for static rendezvous.")
81-
parser.add_argument("--rdzv_backend", default=None, help="Rendezvous backend used by torchrun.")
82-
parser.add_argument("--rdzv_endpoint", default=None, help="Rendezvous endpoint used by torchrun.")
83-
parser.add_argument("--rdzv_id", default=None, help="User-defined rendezvous id used by torchrun.")
84-
parser.add_argument("--max_restarts", default=None, help="Maximum worker group restarts before failing.")
85-
parser.add_argument("--monitor_interval", default=None, help="Worker monitor interval [s].")
86-
parser.add_argument(
87-
"--start_method",
88-
choices=("spawn", "fork", "forkserver"),
89-
default=None,
90-
help="Multiprocessing start method used by torchrun.",
91-
)
92-
parser.add_argument("--role", default=None, help="User-defined worker role used by torchrun.")
93-
parser.add_argument("--tee", default=None, help="Tee selected worker stdout/stderr streams.")
94-
parser.add_argument("--redirects", default=None, help="Redirect selected worker stdout/stderr streams.")
95-
parser.add_argument("--local_ranks_filter", default=None, help="Only show logs from the listed local ranks.")
96-
parser.add_argument("--log_dir", default=None, help="Directory used by torchrun for worker logs.")
97-
parser.add_argument(
98-
"--log_all_ranks",
99-
action="store_true",
100-
help=(
101-
"Show console output from every rank. By default only local rank 0 on each node is shown, because "
102-
"each rank otherwise repeats the same startup, warning, and model-summary output. Tracebacks from "
103-
"failing ranks are reported either way."
104-
),
105-
)
106-
parser.add_argument(
107-
"--dry_run", action="store_true", help="Print the distributed launcher command without running it."
108-
)
109-
110-
args_cli, train_args = parser.parse_known_args(argv)
111-
if train_args[:1] == ["--"]:
112-
train_args = train_args[1:]
113-
_validate_launcher_args(parser, args_cli, train_args)
114-
return args_cli, train_args
115-
116-
117-
def _append_optional_launcher_arg(command: list[str], args_cli: argparse.Namespace, name: str) -> None:
118-
"""Append a launcher argument when it was provided."""
119-
value = getattr(args_cli, name)
120-
if value is not None:
121-
command.extend([f"--{name}", str(value)])
122-
123-
124-
def _with_distributed_arg(train_args: list[str]) -> list[str]:
125-
"""Ensure the selected training library receives the distributed flag."""
126-
if "--distributed" in train_args:
127-
return train_args
128-
return [*train_args, "--distributed"]
129-
130-
131-
def _get_forwarded_arg_value(args: list[str], name: str) -> str | None:
132-
"""Return the last value of a forwarded command-line option."""
133-
value = None
134-
prefix = f"{name}="
135-
for index, arg in enumerate(args):
136-
if arg == name and index + 1 < len(args):
137-
value = args[index + 1]
138-
elif arg.startswith(prefix):
139-
value = arg[len(prefix) :]
140-
return value
141-
142-
143-
def _is_skrl_jax_launcher(args_cli: argparse.Namespace, train_args: list[str]) -> bool:
144-
"""Return whether the launch should use skrl's JAX distributed launcher."""
145-
ml_framework = _get_forwarded_arg_value(train_args, "--ml_framework")
146-
return args_cli.rl_library == "skrl" and ml_framework == "jax"
147-
148-
149-
def _get_visible_cuda_device_count() -> int | None:
150-
"""Return the number of visible CUDA devices on this node, or ``None`` if undetermined."""
151-
visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
152-
if visible_devices is not None:
153-
entries = [entry for entry in visible_devices.split(",") if entry.strip()]
154-
return len(entries)
155-
try:
156-
import torch
157-
except ImportError:
158-
return None
159-
try:
160-
if not torch.cuda.is_available():
161-
return 0
162-
return torch.cuda.device_count()
163-
except Exception:
164-
return None
165-
166-
167-
def _validate_num_gpus_against_visible_devices(parser: argparse.ArgumentParser, args_cli: argparse.Namespace) -> None:
168-
"""Error early when fewer CUDA devices are visible than --num_gpus requests."""
169-
try:
170-
requested = int(str(args_cli.nproc_per_node))
171-
except (TypeError, ValueError):
172-
return # torchrun keywords like "gpu"/"cpu"/"auto" are resolved by the launcher itself.
173-
visible = _get_visible_cuda_device_count()
174-
if visible is None:
175-
return
176-
if visible == 0:
177-
parser.error(
178-
f"--num_gpus/--nproc_per_node={requested} was requested but no CUDA devices are visible. "
179-
"Verify the CUDA installation and CUDA_VISIBLE_DEVICES."
180-
)
181-
if requested > visible:
182-
parser.error(
183-
f"--num_gpus/--nproc_per_node={requested} exceeds the {visible} CUDA device(s) visible to this "
184-
"process. Lower --num_gpus or expose more devices via CUDA_VISIBLE_DEVICES."
185-
)
186-
187-
188-
def _validate_launcher_args(
189-
parser: argparse.ArgumentParser, args_cli: argparse.Namespace, train_args: list[str]
190-
) -> None:
191-
"""Validate launcher-specific argument combinations."""
192-
if _is_skrl_jax_launcher(args_cli, train_args):
193-
unsupported_args = [f"--{name}" for name in SKRL_JAX_TORCHRUN_ONLY_ARGS if getattr(args_cli, name) is not None]
194-
if unsupported_args:
195-
parser.error(
196-
f"{', '.join(unsupported_args)} are torchrun-only options and cannot be used with skrl JAX "
197-
"multi-GPU training. Use --coordinator_address <host:port> to configure the JAX coordinator."
198-
)
199-
try:
200-
nproc_per_node = int(str(args_cli.nproc_per_node))
201-
except ValueError:
202-
parser.error(
203-
"skrl JAX multi-GPU training requires an integer --num_gpus/--nproc_per_node value; "
204-
"torchrun values 'gpu', 'cpu', and 'auto' are not supported by skrl.utils.distributed.jax."
205-
)
206-
if nproc_per_node < 1:
207-
parser.error("skrl JAX multi-GPU training requires --num_gpus/--nproc_per_node to be at least 1.")
208-
elif args_cli.coordinator_address is not None:
209-
parser.error("--coordinator_address is only supported with --rl_library skrl --ml_framework jax.")
210-
211-
_validate_num_gpus_against_visible_devices(parser, args_cli)
212-
213-
214-
def _run_distributed_command(command: list[str]) -> int:
215-
"""Run the distributed launcher and forward termination signals to the child process."""
216-
proc = subprocess.Popen(command)
217-
218-
def _terminate_child(_signum: int, _frame: FrameType | None) -> None:
219-
proc.terminate()
220-
221-
previous_sigterm = signal.signal(signal.SIGTERM, _terminate_child)
222-
previous_sigint = signal.signal(signal.SIGINT, _terminate_child)
223-
try:
224-
return proc.wait()
225-
finally:
226-
signal.signal(signal.SIGTERM, previous_sigterm)
227-
signal.signal(signal.SIGINT, previous_sigint)
228-
229-
230-
def _build_torchrun_command(args_cli: argparse.Namespace, train_args: list[str]) -> list[str]:
231-
"""Build the torchrun command for multi-GPU training."""
232-
command = [
233-
sys.executable,
234-
"-m",
235-
"torch.distributed.run",
236-
"--nproc_per_node",
237-
str(args_cli.nproc_per_node),
238-
]
239-
for name in (
240-
"nnodes",
241-
"node_rank",
242-
"master_addr",
243-
"master_port",
244-
"rdzv_backend",
245-
"rdzv_endpoint",
246-
"rdzv_id",
247-
"max_restarts",
248-
"monitor_interval",
249-
"start_method",
250-
"role",
251-
"tee",
252-
"redirects",
253-
"local_ranks_filter",
254-
"log_dir",
255-
):
256-
_append_optional_launcher_arg(command, args_cli, name)
257-
258-
# Every rank writes the same startup, warning, and model-summary output, so an unfiltered console
259-
# repeats it once per GPU. Restrict it to local rank 0 unless the caller opted out or set an
260-
# explicit filter. Note this is torchrun's local-rank filter, so a multi-node job still prints one
261-
# copy per node. Failures still surface: torchrun names the failing rank and reports the traceback
262-
# that ``record`` captures in train.py.
263-
if not args_cli.log_all_ranks and args_cli.local_ranks_filter is None:
264-
command.extend(["--local_ranks_filter", "0"])
265-
266-
command.extend(
267-
[
268-
str(TRAIN_SCRIPT),
269-
"--rl_library",
270-
args_cli.rl_library,
271-
*_with_distributed_arg(train_args),
272-
]
273-
)
274-
return command
275-
276-
277-
def _build_skrl_jax_command(args_cli: argparse.Namespace, train_args: list[str]) -> list[str]:
278-
"""Build the skrl JAX distributed command for multi-GPU training."""
279-
command = [
280-
sys.executable,
281-
"-m",
282-
"skrl.utils.distributed.jax",
283-
"--nproc_per_node",
284-
str(args_cli.nproc_per_node),
285-
]
286-
for name in ("nnodes", "node_rank", "coordinator_address"):
287-
_append_optional_launcher_arg(command, args_cli, name)
288-
289-
command.extend(
290-
[
291-
str(TRAIN_SCRIPT),
292-
"--rl_library",
293-
args_cli.rl_library,
294-
*_with_distributed_arg(train_args),
295-
]
296-
)
297-
return command
298-
299-
300-
def _build_distributed_command(args_cli: argparse.Namespace, train_args: list[str]) -> list[str]:
301-
"""Build the distributed launcher command for multi-GPU training."""
302-
if _is_skrl_jax_launcher(args_cli, train_args):
303-
return _build_skrl_jax_command(args_cli, train_args)
304-
return _build_torchrun_command(args_cli, train_args)
8+
from isaaclab_rl.entrypoints import run_train_multigpu_cli
3059

30610

30711
def main(argv: list[str] | None = None) -> int:
30812
"""Launch multi-GPU training with the selected distributed launcher."""
309-
if argv is None:
310-
argv = sys.argv[1:]
311-
312-
args_cli, train_args = _parse_args(argv)
313-
command = _build_distributed_command(args_cli, train_args)
314-
315-
if args_cli.dry_run:
316-
print(shlex.join(command))
317-
return 0
318-
319-
print(f"[INFO] Launching distributed training with: {shlex.join(command)}")
320-
return _run_distributed_command(command)
13+
return run_train_multigpu_cli(argv)
32114

32215

32316
if __name__ == "__main__":

source/isaaclab/changelog.d/multigpu-entrypoint-refactor.skip

Whitespace-only changes.

source/isaaclab/test/cli/test_train_multigpu_command_building.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
from __future__ import annotations
2626

2727
import argparse
28-
import importlib.util
2928
import shlex
3029
import sys
3130
from pathlib import Path
@@ -34,14 +33,7 @@
3433

3534
from isaaclab.app.app_launcher import AppLauncher
3635

37-
# The launcher script lives outside the installed packages; load it by path.
38-
# This test lives at source/isaaclab/test/cli/test_train_multigpu_command_building.py.
39-
_REPO_ROOT = Path(__file__).resolve().parents[4]
40-
_TRAIN_MULTIGPU_PATH = _REPO_ROOT / "scripts" / "reinforcement_learning" / "train_multigpu.py"
41-
42-
_spec = importlib.util.spec_from_file_location("train_multigpu", _TRAIN_MULTIGPU_PATH)
43-
train_multigpu = importlib.util.module_from_spec(_spec)
44-
_spec.loader.exec_module(train_multigpu)
36+
from isaaclab_rl.entrypoints import multigpu as train_multigpu
4537

4638

4739
def _build_command(argv: list[str]) -> list[str]:
@@ -52,7 +44,7 @@ def _build_command(argv: list[str]) -> list[str]:
5244

5345
def _forwarded_train_argv(command: list[str]) -> list[str]:
5446
"""Return the argv forwarded to the child training script."""
55-
return command[command.index(str(train_multigpu.TRAIN_SCRIPT)) + 1 :]
47+
return command[command.index(train_multigpu.WORKER_SCRIPT) + 1 :]
5648

5749

5850
def _parse_as_training_script(child_argv: list[str], monkeypatch: pytest.MonkeyPatch) -> str:
@@ -203,7 +195,9 @@ def test_forwarded_skrl_jax_kit_args_accepted_by_training_script(self, monkeypat
203195
assert kit_args == "--foo=/bar"
204196

205197
def test_dry_run_prints_shell_parsable_command(self, capsys):
206-
exit_code = train_multigpu.main(["--dry_run", "--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"])
198+
exit_code = train_multigpu.run_train_multigpu_cli(
199+
["--dry_run", "--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]
200+
)
207201
assert exit_code == 0
208202
printed = capsys.readouterr().out.strip()
209203
tokens = shlex.split(printed)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Added
2+
^^^^^
3+
4+
* Added :func:`~isaaclab_rl.entrypoints.run_train_multigpu_cli`, which moves the multi-GPU launcher
5+
into the package alongside the train and play entry points.
6+
``scripts/reinforcement_learning/train_multigpu.py`` is now a shim over it.
7+
8+
Fixed
9+
^^^^^
10+
11+
* Fixed the multi-GPU launcher leaving worker processes behind on Ctrl-C. It ran torchrun in its own
12+
process group, so the terminal signalled torchrun and every worker at the same moment the launcher
13+
forwarded a signal of its own, and the extra signal interrupted torchelastic's shutdown before it
14+
had reaped the workers. The launcher now starts the worker tree in a new session, forwards one
15+
signal to it, and escalates to ``SIGTERM`` and then ``SIGKILL`` so a worker wedged in a native call
16+
cannot outlive the run.

source/isaaclab_rl/isaaclab_rl/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"run_play_cli",
4141
"run_random_agent_cli",
4242
"run_train_cli",
43+
"run_train_multigpu_cli",
4344
"run_zero_agent_cli",
4445
"train",
4546
"zero_agent",

0 commit comments

Comments
 (0)