Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions monai/apps/nnunet/nnunetv2_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import re
import shlex
import subprocess
import threading
from typing import Any

import monai
Expand Down Expand Up @@ -740,17 +741,39 @@ def train_parallel(
f"log '.txt' inside '{os.path.join(self.nnunet_results, self.dataset_name)}'"
)
for stage in all_cmds:
processes = []
for device_id in stage:
if not stage[device_id]:
threads = []
for device_id, device_cmds in stage.items():
if not device_cmds:
continue
cmd_str = "; ".join(shlex.join(cmd) for cmd, _ in stage[device_id])
env = stage[device_id][0][1]
logger.info(f"Current running command on GPU device {device_id}:\n{cmd_str}\n")
processes.append(subprocess.Popen(cmd_str, shell=True, env=env, stdout=subprocess.DEVNULL))
# finish this stage first
for p in processes:
p.wait()
logger.info(
f"Current running commands on GPU device {device_id}:\n"
+ "\n".join(shlex.join(cmd) for cmd, _ in device_cmds)
+ "\n"
)
# one worker per device runs that device's commands sequentially, in order;
# different devices run concurrently. No shell is invoked (shell=False).
thread = threading.Thread(target=self._run_device_commands, args=(device_cmds,))
thread.start()
threads.append(thread)
# finish this stage before starting the next
for thread in threads:
thread.join()
Comment on lines +755 to +760

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Continue the device queue after process-launch errors.

If subprocess.run raises FileNotFoundError or another OSError, the worker silently terminates, skips that device’s remaining commands, and join() still lets the next stage proceed. Catch and log OSError around each command to preserve continue-after-failure behavior.

Proposed fix
         for cmd, env in device_cmds:
-            subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, check=False)
+            try:
+                subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, check=False)
+            except OSError as error:
+                logger.error(f"Failed to start command {shlex.join(cmd)}: {error}")

Also applies to: 775-776

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@monai/apps/nnunet/nnunetv2_runner.py` around lines 755 - 760, Update the
command-processing logic in _run_device_commands to catch OSError from each
subprocess.run invocation, log the failure, and continue processing the
remaining commands for that device. Preserve the existing thread creation and
join behavior so stage sequencing remains unchanged.


@staticmethod
def _run_device_commands(device_cmds: list) -> None:
"""
Run one device's argument-list commands sequentially without invoking a shell.

Args:
device_cmds: a list of ``(cmd, env)`` pairs, where ``cmd`` is an argument list and
``env`` is the environment for that command.

Each command is run in its original order with ``shell=False``, keeping its associated
environment. A non-zero exit status does not stop the remaining commands for the device,
matching the previous ``"; ".join(...)`` shell behavior.
"""
for cmd, env in device_cmds:
subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, check=False)

def validate_single_model(self, config: str, fold: int, **kwargs: Any) -> None:
"""
Expand Down
35 changes: 35 additions & 0 deletions tests/apps/nnunet/test_nnunetv2_runner_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,40 @@ def test_validate_emits_bare_val_flag(self):
self.assertNotIn("True", cmd)


class TestRunDeviceCommands(unittest.TestCase):
"""``train_parallel`` runs each device's commands without a shell (see issue #8992)."""

def test_runs_in_order_without_shell(self):
cmd_a = ["python", "-m", "nnunetv2", "train", "0"]
cmd_b = ["python", "-m", "nnunetv2", "train", "1"]
device_cmds = [(cmd_a, {"CUDA_VISIBLE_DEVICES": "0"}), (cmd_b, {"CUDA_VISIBLE_DEVICES": "0"})]

with mock.patch("monai.apps.nnunet.nnunetv2_runner.subprocess.run") as run:
nnUNetV2Runner._run_device_commands(device_cmds)

# both commands ran, in order
self.assertEqual(run.call_count, 2)
self.assertEqual(run.call_args_list[0].args[0], cmd_a)
self.assertEqual(run.call_args_list[1].args[0], cmd_b)
for call, (expected_cmd, expected_env) in zip(run.call_args_list, device_cmds):
# first positional arg is the argument list itself (not a joined string)
self.assertIsInstance(call.args[0], list)
self.assertEqual(call.args[0], expected_cmd)
# no shell is invoked
self.assertFalse(call.kwargs.get("shell", False))
# each command keeps its own environment
self.assertEqual(call.kwargs.get("env"), expected_env)

def test_continues_after_nonzero_exit(self):
# a non-zero exit for the first command must not stop the remaining commands.
device_cmds = [(["cmd", "0"], {}), (["cmd", "1"], {})]
with mock.patch("monai.apps.nnunet.nnunetv2_runner.subprocess.run") as run:
nnUNetV2Runner._run_device_commands(device_cmds)
# check=False is used so no exception is raised and both commands are attempted.
self.assertEqual(run.call_count, 2)
for call in run.call_args_list:
self.assertFalse(call.kwargs.get("check", False))


if __name__ == "__main__":
unittest.main()
Loading