| ############################################################################ |
| # Copyright (C) SchedMD LLC. |
| ############################################################################ |
| """GPU selection for jobs and steps when GPUs are bound to specific cores.""" |
| |
| import re |
| |
| import pytest |
| |
| import atf |
| |
| # One GPU per socket, so a single-socket step forces pass 2 of the GRES |
| # selection to fill more than one remaining device. |
| _NUM_GPUS = 4 |
| # Spare sockets that hold no GPU of their own, so a request that cannot be |
| # placed on a GPU-bound socket is one --gres-flags=enforce-binding has to |
| # reject rather than one the node simply cannot fit. |
| _NUM_SOCKETS = _NUM_GPUS + 2 |
| _ALL_GPUS = ",".join(str(i) for i in range(_NUM_GPUS)) |
| |
| _OVERLAP_GRES_UNFIXED = atf.get_version("sbin/slurmctld") < (26, 5, 3) |
| _OVERLAP_GRES_REASON = "Ticket 24862: --overlap GRES double-select fixed in 26.05.3" |
| |
| node_name = None |
| |
| |
| @pytest.fixture(scope="module", autouse=True) |
| def setup(): |
| global node_name |
| atf.require_config_parameter("SelectType", "select/cons_tres") |
| atf.require_config_parameter("SelectTypeParameters", "CR_Socket_Memory") |
| atf.require_config_parameter_includes("GresTypes", "gpu") |
| atf.require_nodes( |
| 1, |
| [ |
| ("Sockets", _NUM_SOCKETS), |
| ("CoresPerSocket", 1), |
| ("ThreadsPerCore", 1), |
| ("Gres", f"gpu:{_NUM_GPUS}"), |
| ], |
| ) |
| # GPU's need to point to existing device files |
| for i in range(_NUM_GPUS): |
| atf.require_tty(i) |
| gpu_nodes = [ |
| name |
| for name, params in atf.get_nodes(live=False, quiet=True, fatal=True).items() |
| if name != "DEFAULT" and "gpu:" in str(params.get("Gres", "")) |
| ] |
| assert gpu_nodes, "Expected require_nodes() to configure a node with GPUs" |
| node_name = gpu_nodes[0] |
| # Bind GPU i to socket i (core i), so a step on one socket owns just one |
| # GPU and pass 2 must supply the rest. |
| gpu_lines = [] |
| for i in range(_NUM_GPUS): |
| prefix = node_name if i == 0 else f"NodeName={node_name}" |
| gpu_lines.append(f"{prefix} Name=gpu Cores={i} File=/dev/tty{i}") |
| atf.require_config_parameter("NodeName", "\n".join(gpu_lines), source="gres") |
| atf.require_slurm_running() |
| |
| |
| @pytest.fixture(scope="function") |
| def single_socket_gpu_job(): |
| """Job holding every GPU, with its cores confined to a single socket. |
| |
| The preconditions are asserted here so that every test sharing the |
| fixture checks them in one place, and so that a topology that does not |
| reproduce the condition says which precondition it failed rather than |
| only that the step came up short on GPUs. |
| """ |
| |
| job_id = atf.submit_job_sbatch( |
| f"-N1 -n1 --gpus-per-node={_NUM_GPUS} --wrap 'sleep infinity'", |
| fatal=True, |
| ) |
| atf.wait_for_job_state(job_id, "RUNNING", fatal=True) |
| |
| job_gres = atf.get_job_parameter(job_id, "GRES", fatal=True, quiet=True) |
| assert job_gres and f"gpu:{_NUM_GPUS}(IDX:" in job_gres, ( |
| f"Expected job to hold every GPU (gpu:{_NUM_GPUS}(IDX:...)), " |
| f"got GRES={job_gres!r} - topology did not produce the " |
| f"expected no-enforce-binding allocation" |
| ) |
| |
| num_cpus = atf.get_job_parameter(job_id, "NumCPUs", fatal=True, quiet=True) |
| assert num_cpus == 1, ( |
| f"Expected job to hold a single socket (1 CPU with CoresPerSocket=1), " |
| f"got NumCPUs={num_cpus} - topology did not confine the job to one " |
| f"socket, so the step already covers every GPU-bound socket" |
| ) |
| |
| return job_id |
| |
| |
| def test_gpu_socket_sharing(): |
| """Test allocating multiple core-bound gpus with enforce-binding""" |
| |
| output = atf.run_command_output( |
| f"srun --gres-flags=enforce-binding --ntasks-per-socket=1 \ |
| --cpus-per-task=1 --ntasks-per-node=2 -N1 \ |
| --gpus-per-task=1 scontrol show nodes {node_name} -d", |
| fatal=True, |
| ) |
| assert ( |
| re.search(r"GresUsed=gpu:(?:[^:]*:)?2\(", output) is not None |
| ), "Verify that job allocated 2 gpus" |
| |
| |
| def test_gpu_socket_sharing_no_alloc(): |
| """Test allocating multiple core-bound gpus with enforce-binding without enough resources""" |
| |
| output = atf.run_command( |
| f"srun --gres-flags=enforce-binding --ntasks-per-socket=1 \ |
| --cpus-per-task=2 --ntasks-per-node=2 -N1 \ |
| --gpus-per-task=1 scontrol show nodes {node_name} -d", |
| xfail=True, |
| fatal=True, |
| ) |
| assert output["exit_code"] != 0, "Verify that srun command failed" |
| |
| expected_msg = r"srun: error: .+ Requested node configuration is not available" |
| assert ( |
| re.search( |
| expected_msg, |
| str(output["stderr"]), |
| ) |
| is not None |
| ), "Verify that job is rejected with the right reason." |
| |
| |
| @pytest.mark.xfail(_OVERLAP_GRES_UNFIXED, reason=_OVERLAP_GRES_REASON) |
| def test_overlap_step_gets_all_gpus(single_socket_gpu_job): |
| """An --overlap step must receive every GPU it requests. |
| |
| srun --overlap shares all of the job's GRES with the step. The batch |
| job requests -n1 so CR_Socket_Memory confines its cores to one socket, |
| while --gpus-per-node (no enforce-binding) allocates every GPU across |
| all sockets. The --overlap step lands on that single socket but, per the |
| srun default, inherits every GPU the job holds, so it must be granted |
| all of them even though most are bound to sockets its cores do not |
| cover. A short allocation shows up as a denied device in the step's |
| cgroup if task/cgroup and ConstrainDevices=yes is configured |
| (ticket 24862 regression). |
| """ |
| |
| job_id = single_socket_gpu_job |
| |
| result = atf.run_job( |
| f"--jobid={job_id} --overlap -n1 --mem=0 " |
| f"bash -c 'echo $SLURM_STEP_GPUS; echo $CUDA_VISIBLE_DEVICES'", |
| fatal=True, |
| quiet=True, |
| ) |
| output = result["stdout"].splitlines() |
| assert len(output) >= 2, ( |
| f"Expected two output lines (SLURM_STEP_GPUS and " |
| f"CUDA_VISIBLE_DEVICES), got {result['stdout']!r}" |
| ) |
| step_gpus = output[0].strip() |
| visible_devices = output[1].strip() |
| |
| assert step_gpus == _ALL_GPUS, ( |
| f"Step SLURM_STEP_GPUS should be {_ALL_GPUS!r} (every GPU), " |
| f"got {step_gpus!r} - the --overlap step was not granted all of " |
| f"the job's GPUs (ticket 24862 regression)" |
| ) |
| assert visible_devices == _ALL_GPUS, ( |
| f"Step CUDA_VISIBLE_DEVICES should be exported and agree with " |
| f"SLURM_STEP_GPUS ({_ALL_GPUS}), got {visible_devices!r} - the " |
| f"step's GPU allocation is not rendered consistently across both " |
| f"variables (ticket 24862 regression)" |
| ) |
| |
| |
| @pytest.mark.xfail(_OVERLAP_GRES_UNFIXED, reason=_OVERLAP_GRES_REASON) |
| @pytest.mark.parametrize("first_step_overlaps", [True, False]) |
| def test_concurrent_steps_each_get_all_gpus(first_step_overlaps, single_socket_gpu_job): |
| """Concurrent steps sharing an allocation each receive every GPU. |
| |
| srun --overlap shares all resources with all other steps, even those |
| that did not specify --overlap. Two steps running at the same time |
| against the same allocation must therefore both be granted every GPU |
| the job holds, whichever of them asked to overlap. |
| """ |
| |
| job_id = single_socket_gpu_job |
| |
| first_step_gpus_file = "first_step_gpus" |
| overlap = "--overlap " if first_step_overlaps else "" |
| atf.run_command( |
| f"srun --jobid={job_id} {overlap}-n1 --mem=0 bash -c " |
| f"'echo $SLURM_STEP_GPUS > {first_step_gpus_file}; sleep infinity'", |
| background=True, |
| ) |
| atf.assert_file_contents( |
| first_step_gpus_file, |
| _ALL_GPUS, |
| message=( |
| f"First step SLURM_STEP_GPUS should be {_ALL_GPUS!r} (every GPU) " |
| f"- the step was not granted all of the job's GPUs" |
| ), |
| ) |
| |
| result = atf.run_job( |
| f"--jobid={job_id} --overlap -n1 --mem=0 bash -c 'echo $SLURM_STEP_GPUS'", |
| fatal=True, |
| quiet=True, |
| ) |
| second_step_gpus = result["stdout"].strip() |
| |
| assert second_step_gpus == _ALL_GPUS, ( |
| f"Second step SLURM_STEP_GPUS should be {_ALL_GPUS!r} (every GPU), " |
| f"got {second_step_gpus!r} - an --overlap step running alongside " |
| f"another step was not granted all of the job's GPUs" |
| ) |
| |
| |
| @pytest.mark.xfail(_OVERLAP_GRES_UNFIXED, reason=_OVERLAP_GRES_REASON) |
| @pytest.mark.parametrize("requested_gpus", range(2, _NUM_GPUS)) |
| def test_overlap_step_gets_requested_gpu_subset(requested_gpus, single_socket_gpu_job): |
| """An --overlap step requesting a subset gets that many distinct GPUs. |
| |
| A job step may explicitly request a different generic resource count |
| than the job, so an --overlap step asking for fewer GPUs than the job |
| holds must be granted exactly that many, and no device twice. |
| """ |
| |
| job_id = single_socket_gpu_job |
| |
| result = atf.run_job( |
| f"--jobid={job_id} --overlap -n1 --mem=0 --gres=gpu:{requested_gpus} " |
| f"bash -c 'echo $SLURM_STEP_GPUS'", |
| fatal=True, |
| quiet=True, |
| ) |
| step_gpus = result["stdout"].strip() |
| indices = step_gpus.split(",") if step_gpus else [] |
| |
| assert len(indices) == requested_gpus, ( |
| f"Step requesting gpu:{requested_gpus} should be granted " |
| f"{requested_gpus} GPUs, got SLURM_STEP_GPUS={step_gpus!r}" |
| ) |
| assert len(set(indices)) == len(indices), ( |
| f"Step requesting gpu:{requested_gpus} should be granted distinct " |
| f"GPUs, got SLURM_STEP_GPUS={step_gpus!r}" |
| ) |
| |
| |
| def test_non_overlap_step_gets_all_gpus(): |
| """A default (non --overlap) step also receives every GPU it requests. |
| |
| This is not a reproducer of ticket 24862 (that bug was specific to |
| --overlap); it guards the shared non-overlap path against collateral |
| breakage from the fix. srun's own step lands on a single socket |
| (CR_Socket_Memory with -n1) while requesting every GPU across all |
| sockets, so it must still be granted all of them. |
| """ |
| |
| result = atf.run_job( |
| f"-N1 -n1 --gpus-per-node={_NUM_GPUS} bash -c " |
| f"'echo $SLURM_CPUS_ON_NODE; echo $SLURM_STEP_GPUS'", |
| fatal=True, |
| quiet=True, |
| ) |
| output = result["stdout"].splitlines() |
| assert len(output) >= 2, ( |
| f"Expected two output lines (SLURM_CPUS_ON_NODE and " |
| f"SLURM_STEP_GPUS), got {result['stdout']!r}" |
| ) |
| cpus_on_node = output[0].strip() |
| step_gpus = output[1].strip() |
| |
| assert cpus_on_node == "1", ( |
| f"Expected the step to hold a single socket (1 CPU with " |
| f"CoresPerSocket=1), got SLURM_CPUS_ON_NODE={cpus_on_node!r} - " |
| f"topology did not confine the step to one socket, so it already " |
| f"covers every GPU-bound socket" |
| ) |
| assert step_gpus == _ALL_GPUS, ( |
| f"Non-overlap step SLURM_STEP_GPUS should be {_ALL_GPUS!r} " |
| f"(every GPU), got {step_gpus!r}" |
| ) |