| ############################################################################ |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| ############################################################################ |
| """Heterogeneous job + stepmgr-on-stepd: verify job-global step ids. |
| |
| With --stepmgr on a heterogeneous job, each component's stepmgr asks the |
| leader for the next step id (REQUEST_HET_STEP_ID). Step ids must remain |
| unique and contiguous across the whole het job, not per-component. |
| |
| Without that RPC, sequential srun --het-group calls would reuse ids |
| 0,1,0,1,... per component instead of 0,1,2,3 across the job. |
| """ |
| |
| import collections |
| import re |
| |
| import pytest |
| |
| import atf |
| |
| pytestmark = pytest.mark.slow |
| |
| # Echoed after the last srun so we can tell the batch script's whole output |
| # has been flushed before parsing it. |
| OUTPUT_MARKER = "HETJOB_STEPS_DONE" |
| |
| |
| @pytest.fixture(scope="module", autouse=True) |
| def setup(): |
| # The client tools upgrade last, so gating on srun >= 26.11 already |
| # implies slurmd and slurmctld are >= 26.11. |
| atf.require_version( |
| (26, 11), |
| component="bin/srun", |
| reason="Issue 50976: hetjob + stepmgr requires srun 26.11+", |
| ) |
| atf.require_nodes(3) |
| atf.require_config_parameter_includes("PrologFlags", "Contain") |
| # Het jobs are only started by backfill; don't wait for its full cycle |
| atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1)) |
| atf.require_config_parameter("SchedulerType", "sched/backfill") |
| atf.require_slurm_running() |
| |
| |
| def _het_opts(component_nodes): |
| """Build het job options with --stepmgr on the leader only.""" |
| |
| return " : ".join( |
| f"-N{nodes} --ntasks-per-node=1 -t2" + (" --stepmgr" if offset == 0 else "") |
| for offset, nodes in enumerate(component_nodes) |
| ) |
| |
| |
| @pytest.mark.parametrize( |
| "command, component_nodes", |
| [ |
| ("sbatch", [1, 1]), |
| ("salloc", [1, 1]), |
| ("sbatch", [1, 1, 1]), |
| ("sbatch", [2, 1]), |
| ], |
| ids=["sbatch-2comp", "salloc-2comp", "sbatch-3comp", "sbatch-multinode"], |
| ) |
| def test_hetjob_stepmgr_job_global_step_ids(command, component_nodes): |
| """Sequential sruns across all components yield unique, contiguous step ids.""" |
| |
| test_id = f"{command}_{'-'.join(str(n) for n in component_nodes)}" |
| file_in = f"hetjob_stepmgr_{test_id}.in" |
| file_out = f"hetjob_stepmgr_{test_id}.out" |
| |
| # Two rounds over every component, so a per-component counter would |
| # repeat ids instead of continuing the job-global sequence. |
| sruns = "\n".join( |
| f"srun --het-group={offset} --ntasks=1 " |
| f"""bash -c 'echo "STEP=$SLURM_STEP_ID"' >> {file_out}""" |
| for _ in range(2) |
| for offset in range(len(component_nodes)) |
| ) |
| # sbatch also exports a bare SLURM_STEPMGR but salloc does not, so use the |
| # per-component form, which both submission paths set. |
| stepmgr_echos = "\n".join( |
| f'echo "STEPMGR_{offset}=$SLURM_STEPMGR_HET_GROUP_{offset}" >> {file_out}' |
| for offset in range(len(component_nodes)) |
| ) |
| atf.make_bash_script( |
| file_in, |
| f"{stepmgr_echos}\n{sruns}\necho {OUTPUT_MARKER} >> {file_out}\n", |
| ) |
| |
| het_opts = _het_opts(component_nodes) |
| job_param = het_opts if command == "salloc" else f"-o /dev/null {het_opts}" |
| atf.submit_job(command, job_param, f"./{file_in}", wrap_job=False, fatal=True) |
| |
| atf.assert_file_contents(file_out, OUTPUT_MARKER, contains=True) |
| |
| output = atf.run_command_output(f"cat {file_out}", fatal=True) |
| step_ids = [int(m) for m in re.findall(r"^STEP=(\d+)$", output, re.MULTILINE)] |
| |
| # A plain het job already numbers its steps job-globally, so the ids below |
| # only say something about stepmgr once every component is known to have |
| # one. |
| stepmgr_offsets = sorted( |
| int(offset) |
| for offset in re.findall(r"^STEPMGR_(\d+)=\S+$", output, re.MULTILINE) |
| ) |
| assert stepmgr_offsets == list(range(len(component_nodes))), ( |
| f"Every component must report a stepmgr host for the step ids to " |
| f"exercise stepmgr rather than the controller, got offsets " |
| f"{stepmgr_offsets}.\nRaw output:\n{output}" |
| ) |
| |
| expected_ids = list(range(2 * len(component_nodes))) |
| assert step_ids == expected_ids, ( |
| f"Expected job-global step ids {expected_ids}, got {step_ids}.\n" |
| f"Per-component duplicates indicate stepmgr is not allocating job-globally.\n" |
| f"Raw output:\n{output}" |
| ) |
| |
| |
| @pytest.mark.parametrize("follower_stepmgr", [False, True]) |
| @pytest.mark.parametrize("leader_stepmgr", [False, True]) |
| @pytest.mark.parametrize("command", ["sbatch", "salloc"]) |
| def test_hetjob_stepmgr_follower_tracks_leader( |
| command, leader_stepmgr, follower_stepmgr |
| ): |
| """The follower's StepMgrEnabled always matches the leader's. |
| |
| Only the leader's --stepmgr decides for the whole job, so passing it on |
| a non-leader component is inert. salloc and sbatch document the override |
| identically, so both submission paths are covered. |
| """ |
| |
| # --stepmgr only enables stepmgr per-job "if it isn't enabled system wide", |
| # and there is no per-job way to opt back out, so under |
| # SlurmctldParameters=enable_stepmgr every component is enabled whatever |
| # was submitted and leader-wins cannot be observed. |
| if atf.config_parameter_includes("SlurmctldParameters", "enable_stepmgr"): |
| pytest.skip( |
| "Issue 50976: leader-wins is unobservable when stepmgr is enabled" |
| " globally" |
| ) |
| |
| leader_opts = "-N1 --ntasks=1 -t2" + (" --stepmgr" if leader_stepmgr else "") |
| follower_opts = "-N1 --ntasks=1 -t2" + (" --stepmgr" if follower_stepmgr else "") |
| |
| if command == "sbatch": |
| file_in = f"hetjob_stepmgr_{int(leader_stepmgr)}{int(follower_stepmgr)}.in" |
| atf.make_bash_script( |
| file_in, |
| f""" |
| #SBATCH {leader_opts} |
| #SBATCH hetjob |
| #SBATCH {follower_opts} |
| |
| true |
| """, |
| ) |
| leader_job_id = atf.submit_job_sbatch(f"-o /dev/null {file_in}", fatal=True) |
| else: |
| # --no-shell leaves the allocation in place so StepMgrEnabled is still |
| # readable for every component. |
| leader_job_id = atf.submit_job_salloc( |
| f"--no-shell {leader_opts} : {follower_opts}", fatal=True |
| ) |
| |
| jobs = atf.get_jobs(leader_job_id, fatal=True) |
| components = atf.range_to_list(jobs[leader_job_id]["HetJobIdSet"]) |
| follower_id = next(c for c in components if jobs[c]["HetJobOffset"] == 1) |
| |
| # scontrol prints StepMgrEnabled only when the bit is set, so a disabled |
| # component has no such key at all. |
| leader_flag = jobs[leader_job_id].get("StepMgrEnabled") |
| follower_flag = jobs[follower_id].get("StepMgrEnabled") |
| |
| assert follower_flag == leader_flag, ( |
| f"JobId {follower_id}: follower StepMgrEnabled must match the leader " |
| f"(leader-wins), got leader={leader_flag!r}, follower={follower_flag!r}" |
| ) |
| |
| expected_flag = "Yes" if leader_stepmgr else None |
| assert leader_flag == expected_flag, ( |
| f"JobId {leader_job_id}: leader submitted with --stepmgr=" |
| f"{leader_stepmgr} should have StepMgrEnabled={expected_flag!r}, " |
| f"got {leader_flag!r}" |
| ) |
| |
| |
| def test_hetjob_stepmgr_multi_component_step_shares_id(): |
| """A step spanning both components shares one id; skipped ids leave gaps. |
| |
| Mirrors the worked example in the heterogeneous_jobs documentation: a |
| step on both components, then one on the leader only, then both again. |
| The follower must jump straight from id 0 to id 2. |
| """ |
| |
| file_in = "hetjob_stepmgr_multi.in" |
| file_out = "hetjob_stepmgr_multi.out" |
| atf.make_bash_script( |
| file_in, |
| f""" |
| #SBATCH -N1 --ntasks=1 -t2 --stepmgr |
| #SBATCH hetjob |
| #SBATCH -N1 --ntasks=1 -t2 |
| |
| echo "STEPMGR_0=$SLURM_STEPMGR_HET_GROUP_0" |
| echo "STEPMGR_1=$SLURM_STEPMGR_HET_GROUP_1" |
| srun --het-group=0,1 bash -c 'echo "STEP=$SLURM_STEP_ID"' |
| srun --het-group=0 bash -c 'echo "STEP=$SLURM_STEP_ID"' |
| srun --het-group=0,1 bash -c 'echo "STEP=$SLURM_STEP_ID"' |
| echo {OUTPUT_MARKER} |
| """, |
| ) |
| |
| leader_job_id = atf.submit_job_sbatch(f"-o {file_out} {file_in}", fatal=True) |
| atf.wait_for_job_state(leader_job_id, "DONE", fatal=True) |
| atf.assert_file_contents(file_out, OUTPUT_MARKER, contains=True) |
| |
| output = atf.run_command_output(f"cat {file_out}", fatal=True) |
| step_ids = [int(m) for m in re.findall(r"^STEP=(\d+)$", output, re.MULTILINE)] |
| |
| # A plain het job already numbers its steps job-globally, so the ids below |
| # only say something about stepmgr once every component is known to have |
| # one. |
| stepmgr_offsets = sorted( |
| int(offset) |
| for offset in re.findall(r"^STEPMGR_(\d+)=\S+$", output, re.MULTILINE) |
| ) |
| assert stepmgr_offsets == [0, 1], ( |
| f"Every component must report a stepmgr host for the step ids to " |
| f"exercise stepmgr rather than the controller, got offsets " |
| f"{stepmgr_offsets}.\nRaw output:\n{output}" |
| ) |
| |
| # One task reports per participating component, so the number of lines |
| # carrying a step id is how many components ran that step. The middle |
| # step is leader-only, which is what leaves the follower a gap at id 1. |
| assert collections.Counter(step_ids) == {0: 2, 1: 1, 2: 2}, ( |
| f"Expected the both-component steps to share ids 0 and 2 (2 lines " |
| f"each) and the leader-only step to take id 1 (1 line), leaving the " |
| f"follower a gap at 1; got {sorted(step_ids)}.\nRaw output:\n{output}" |
| ) |
| |
| |
| def test_hetjob_stepmgr_env_vars(): |
| """Each component exports its own SLURM_STEPMGR_HET_GROUP_<N>. |
| |
| --exclusive forces the components onto different nodes so that a |
| regression broadcasting the leader's host to every suffix is caught. |
| """ |
| |
| file_in = "hetjob_stepmgr_env.in" |
| file_out = "hetjob_stepmgr_env.out" |
| atf.make_bash_script( |
| file_in, |
| f""" |
| #SBATCH -N1 --ntasks=1 -t2 --exclusive --stepmgr |
| #SBATCH hetjob |
| #SBATCH -N1 --ntasks=1 -t2 --exclusive |
| |
| env |
| echo {OUTPUT_MARKER} |
| sleep infinity |
| """, |
| ) |
| |
| leader_job_id = atf.submit_job_sbatch(f"-o {file_out} {file_in}", fatal=True) |
| |
| # The script stays alive after dumping env so that BatchHost is still |
| # readable for every component once the dump is complete. |
| atf.assert_file_contents(file_out, OUTPUT_MARKER, contains=True) |
| |
| jobs = atf.get_jobs(leader_job_id, fatal=True) |
| components = sorted( |
| atf.range_to_list(jobs[leader_job_id]["HetJobIdSet"]), |
| key=lambda job_id: jobs[job_id]["HetJobOffset"], |
| ) |
| batch_hosts = [jobs[job_id]["BatchHost"] for job_id in components] |
| |
| output = atf.run_command_output(f"cat {file_out}", fatal=True) |
| |
| if batch_hosts[0] == batch_hosts[1]: |
| pytest.fail( |
| f"The components should be on different nodes for this test to be " |
| f"meaningful, got {batch_hosts}" |
| ) |
| |
| for offset, host in enumerate(batch_hosts): |
| for group in ("HET_GROUP", "PACK_GROUP"): |
| var = f"SLURM_STEPMGR_{group}_{offset}" |
| assert ( |
| re.search(rf"^{var}={host}$", output, re.MULTILINE) is not None |
| ), f"Missing {var}={host} in batch environment" |
| |
| assert ( |
| re.search(r"^SLURM_STEPMGR=\S+$", output, re.MULTILINE) is not None |
| ), "Missing bare SLURM_STEPMGR, which older clients still rely on" |
| |
| double_suffixed = re.findall( |
| r"^(SLURM_\w+_(?:HET|PACK)_GROUP_\d+_(?:HET|PACK)_GROUP_\d+)=", |
| output, |
| re.MULTILINE, |
| ) |
| assert ( |
| double_suffixed == [] |
| ), f"Env vars must be suffixed once per component, got {double_suffixed}" |
| |
| |
| def test_hetjob_stepmgr_srun_task_env(): |
| """srun exports the per-component stepmgr vars in the task environment. |
| |
| srun builds the task environment itself, so the batch environment checked |
| above comes from a different producer and does not cover it. |
| """ |
| |
| file_in = "hetjob_stepmgr_srun_env.in" |
| file_out = "hetjob_stepmgr_srun_env.out" |
| atf.make_bash_script( |
| file_in, |
| f""" |
| #SBATCH -N1 --ntasks=1 -t2 --exclusive --stepmgr |
| #SBATCH hetjob |
| #SBATCH -N1 --ntasks=1 -t2 --exclusive |
| |
| srun --het-group=0,1 env >> {file_out} |
| echo {OUTPUT_MARKER} >> {file_out} |
| """, |
| ) |
| |
| leader_job_id = atf.submit_job_sbatch(f"-o /dev/null {file_in}", fatal=True) |
| atf.wait_for_job_state(leader_job_id, "DONE", fatal=True) |
| atf.assert_file_contents(file_out, OUTPUT_MARKER, contains=True) |
| |
| output = atf.run_command_output(f"cat {file_out}", fatal=True) |
| |
| for offset in (0, 1): |
| for group in ("HET_GROUP", "PACK_GROUP"): |
| var = f"SLURM_STEPMGR_{group}_{offset}" |
| assert ( |
| re.search(rf"^{var}=\S+$", output, re.MULTILINE) is not None |
| ), f"Missing {var} in the srun task environment" |
| |
| double_suffixed = re.findall( |
| r"^(SLURM_\w+_(?:HET|PACK)_GROUP_\d+_(?:HET|PACK)_GROUP_\d+)=", |
| output, |
| re.MULTILINE, |
| ) |
| assert ( |
| double_suffixed == [] |
| ), f"Task env vars must be suffixed once per component, got {double_suffixed}" |