Skip to content

Commit 9a3bf84

Browse files
committed
Honor --include/--exclude in the SLURM launcher
The SLURM runner passed DeepSpeed's own resource-filter strings straight to srun and sized the job from the unfiltered hostfile, so both filters were broken: deepspeed --launcher slurm --include worker-1:0,2 train.py -> srun -n 8 --include worker-1:0,2 ... train.py srun has no --include, so it exits with "unrecognized option" and the job never starts. --exclude fares no better: srun does have that flag, but DeepSpeed's NAME[:SLOT,...] syntax is not a slurm hostlist, and -n still counts every slot in the pool rather than the ones left after filtering. runner.py already resolves both flags into active_resources before calling get_cmd(), so take the process count and the node list from there and pass srun --nodelist, which is the flag it actually has. --nodelist alone is only an upper bound, since srun documents that a lower task count "may only require a subset of the supplied node list", so pass --nodes as well to pin the host count; runner.py forbids --num_nodes alongside a resource filter, so nothing else sets it. Sizing from active_resources also moves -n for --num_gpus and --num_nodes, which runner.py trims the same dict for. Those counts were already inconsistent with world_info_base64, which is encoded from the trimmed dict, so this lines them up; the two flags now have test coverage. main()'s trim moves to apply_num_nodes_and_gpus() unchanged so the tests can build the same resource dict main() hands the backends. Signed-off-by: Vineeth Sai <vineethsai4444@gmail.com>
1 parent 84fd92a commit 9a3bf84

3 files changed

Lines changed: 82 additions & 22 deletions

File tree

deepspeed/launcher/multinode_runner.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ def name(self):
358358
def get_cmd(self, environment, active_resources):
359359
assert not getattr(self.args, 'detect_nvlink_pairs',
360360
False), "slurm backend does not support remapping visible devices"
361-
total_process_count = sum(self.resource_pool.values())
361+
# --include/--exclude are already resolved into active_resources, so counting the
362+
# whole pool here would ask srun for slots the user filtered out.
363+
total_process_count = sum(len(slots) for slots in active_resources.values())
362364
srun_cmd = [
363365
'srun',
364366
'-n',
@@ -368,12 +370,19 @@ def get_cmd(self, environment, active_resources):
368370
if getattr(self.args, 'slurm_comment', ''):
369371
srun_cmd += ['--comment', self.args.slurm_comment]
370372

371-
if self.args.include != "":
372-
srun_cmd.append('--include')
373-
srun_cmd.append(f'{self.args.include}')
374-
if self.args.exclude != "":
375-
srun_cmd.append('--exclude')
376-
srun_cmd.append(f'{self.args.exclude}')
373+
if self.args.include != "" or self.args.exclude != "":
374+
# srun has no --include, and the NAME[:SLOT,...] syntax DeepSpeed accepts is not
375+
# a slurm hostlist, so name the hosts that survived the filter instead.
376+
active_hosts = ",".join(active_resources.keys())
377+
srun_cmd.append('--nodelist')
378+
srun_cmd.append(active_hosts)
379+
# --nodelist alone is only an upper bound: srun documents that a lower task count
380+
# "may only require a subset of the supplied node list", so it could pack every
381+
# rank onto one host and silently drop a host the filter kept. --nodes pins the
382+
# count, and runner.py forbids --num_nodes alongside a resource filter, so the
383+
# branch below cannot also set it.
384+
srun_cmd.append('--nodes')
385+
srun_cmd.append(f'{len(active_resources)}')
377386
if self.args.num_nodes > 0:
378387
srun_cmd.append('--nodes')
379388
srun_cmd.append(f'{self.args.num_nodes}')

deepspeed/launcher/runner.py

100755100644
Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,30 @@ def parse_inclusion_exclusion(resource_pool, inclusion, exclusion):
398398
return parse_resource_filter(active_resources, include_str=inclusion, exclude_str=exclusion)
399399

400400

401+
def apply_num_nodes_and_gpus(active_resources, num_nodes, num_gpus):
402+
"""Trim resolved resources to the top num_nodes hosts and num_gpus slots per host.
403+
404+
Split out of main() so the launcher backends can be exercised against the same
405+
resource dict main() hands them. Both flags are mutually exclusive with
406+
--include/--exclude, which main() enforces before calling this.
407+
"""
408+
if num_nodes > 0:
409+
updated_active_resources = collections.OrderedDict()
410+
for count, hostname in enumerate(active_resources.keys()):
411+
if num_nodes == count:
412+
break
413+
updated_active_resources[hostname] = active_resources[hostname]
414+
active_resources = updated_active_resources
415+
416+
if num_gpus > 0:
417+
updated_active_resources = collections.OrderedDict()
418+
for hostname in active_resources.keys():
419+
updated_active_resources[hostname] = list(range(num_gpus))
420+
active_resources = updated_active_resources
421+
422+
return active_resources
423+
424+
401425
def encode_world_info(world_info):
402426
world_info_json = json.dumps(world_info).encode('utf-8')
403427
world_info_base64 = base64.urlsafe_b64encode(world_info_json).decode('utf-8')
@@ -518,19 +542,7 @@ def main(args=None):
518542
run_autotuning(args, active_resources)
519543
return
520544

521-
if args.num_nodes > 0:
522-
updated_active_resources = collections.OrderedDict()
523-
for count, hostname in enumerate(active_resources.keys()):
524-
if args.num_nodes == count:
525-
break
526-
updated_active_resources[hostname] = active_resources[hostname]
527-
active_resources = updated_active_resources
528-
529-
if args.num_gpus > 0:
530-
updated_active_resources = collections.OrderedDict()
531-
for hostname in active_resources.keys():
532-
updated_active_resources[hostname] = list(range(args.num_gpus))
533-
active_resources = updated_active_resources
545+
active_resources = apply_num_nodes_and_gpus(active_resources, args.num_nodes, args.num_gpus)
534546

535547
if args.elastic_training:
536548
assert not args.no_local_rank, "--no_local_rank argument is not supported in Elastic training"

tests/unit/launcher/test_multinode_runner.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
from copy import deepcopy
77
from deepspeed.launcher import multinode_runner as mnrunner
8-
from deepspeed.launcher.runner import encode_world_info, parse_args
8+
from deepspeed.launcher.runner import (encode_world_info, parse_args, parse_inclusion_exclusion,
9+
apply_num_nodes_and_gpus)
910
import os
1011
import pytest
1112

@@ -62,9 +63,47 @@ def test_mpich_runner(runner_info):
6263

6364
def test_slurm_runner(runner_info):
6465
env, resource_pool, world_info, args = runner_info
66+
active_resources = parse_inclusion_exclusion(resource_pool, args.include, args.exclude)
6567
runner = mnrunner.SlurmRunner(args, world_info, resource_pool)
66-
cmd = runner.get_cmd(env, resource_pool)
68+
cmd = runner.get_cmd(env, active_resources)
6769
assert cmd[0] == 'srun'
70+
assert cmd[cmd.index('-n') + 1] == '8'
71+
72+
73+
@pytest.mark.parametrize('resource_filter, expected_hosts, expected_node_count, expected_process_count',
74+
[(['--include', 'worker-1:0,2'], 'worker-1', '1', '2'),
75+
(['--exclude', 'worker-1:0'], 'worker-0,worker-1', '2', '7'),
76+
(['--exclude', 'worker-1'], 'worker-0', '1', '4')])
77+
def test_slurm_runner_resource_filter(runner_info, resource_filter, expected_hosts, expected_node_count,
78+
expected_process_count):
79+
env, resource_pool, world_info, _ = runner_info
80+
args = parse_args(resource_filter + ['test_launcher.py'])
81+
active_resources = parse_inclusion_exclusion(resource_pool, args.include, args.exclude)
82+
runner = mnrunner.SlurmRunner(args, world_info, resource_pool)
83+
cmd = runner.get_cmd(env, active_resources)
84+
assert '--include' not in cmd
85+
assert cmd[cmd.index('--nodelist') + 1] == expected_hosts
86+
# Without --nodes, srun may satisfy -n from a subset of --nodelist and drop a kept host.
87+
assert cmd[cmd.index('--nodes') + 1] == expected_node_count
88+
assert cmd[cmd.index('-n') + 1] == expected_process_count
89+
90+
91+
@pytest.mark.parametrize('resource_flag, expected_srun_flag, expected_process_count',
92+
[(['--num_gpus', '2'], ('--gpus', '2'), '4'), (['--num_nodes', '1'], ('--nodes', '1'), '4')])
93+
def test_slurm_runner_num_nodes_and_gpus(runner_info, resource_flag, expected_srun_flag, expected_process_count):
94+
# main() trims active_resources for these two flags as well, so sizing the job from it
95+
# moves their task count too. They are mutually exclusive with --include/--exclude, so the
96+
# resource-filter branch must stay silent and cannot append a second --nodes.
97+
env, resource_pool, world_info, _ = runner_info
98+
args = parse_args(resource_flag + ['test_launcher.py'])
99+
active_resources = parse_inclusion_exclusion(resource_pool, args.include, args.exclude)
100+
active_resources = apply_num_nodes_and_gpus(active_resources, args.num_nodes, args.num_gpus)
101+
runner = mnrunner.SlurmRunner(args, world_info, resource_pool)
102+
cmd = runner.get_cmd(env, active_resources)
103+
assert '--nodelist' not in cmd
104+
assert cmd.count(expected_srun_flag[0]) == 1
105+
assert cmd[cmd.index(expected_srun_flag[0]) + 1] == expected_srun_flag[1]
106+
assert cmd[cmd.index('-n') + 1] == expected_process_count
68107

69108

70109
def test_mvapich_runner(runner_info):

0 commit comments

Comments
 (0)