| ############################################################################ | |
| # Copyright (C) SchedMD LLC. | |
| ############################################################################ | |
| import re | |
| # from os import stat | |
| import pytest | |
| import atf | |
| pytestmark = pytest.mark.slow | |
| cpu_total_cnt = 6 | |
| license_total_cnt = 50 | |
| shard_total_cnt = 7 | |
| custom_gres_total_cnt = 7 | |
| license_task_cnt = 7 | |
| # Dictionary with tres to be tested. Meant to make it easier to add more tres | |
| # in the future. "count_needed" says if a number is required to be given to | |
| # --tres-per-task or not | |
| tres_dict = { | |
| "cpu": {"acct": "cpu=", "count_needed": True, "param": "cpu:", "num": 3}, | |
| "gpu": {"acct": "gres/gpu=", "count_needed": False, "param": "gres/gpu:", "num": 1}, | |
| "shard": { | |
| "acct": "gres/shard=", | |
| "count_needed": False, | |
| "param": "gres/shard:", | |
| "num": 3, | |
| }, | |
| # Note that we do want to use "license" and "gres" words to ensure that the | |
| # logic handling license/ and gres/ are correct. | |
| "license": { | |
| "acct": "license/testing_license=", | |
| "count_needed": False, | |
| "param": "license/testing_license:", | |
| "num": license_task_cnt, | |
| }, | |
| "custom_gres": { | |
| "acct": "gres/custom_gres=", | |
| "count_needed": False, | |
| "param": "gres/custom_gres:", | |
| "num": 2, | |
| }, | |
| } | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| atf.require_auto_config("Wants to create custom gres.conf") | |
| # Requiring tty0 and tty1 to act as fake GPUs | |
| atf.require_tty(0) | |
| atf.require_tty(1) | |
| atf.require_config_parameter( | |
| "Name", | |
| { | |
| "gpu": {"File": "/dev/tty[0-1]"}, | |
| "shard": {"Count": shard_total_cnt}, | |
| "custom_gres": {"Count": custom_gres_total_cnt}, | |
| }, | |
| source="gres", | |
| ) | |
| # Accounting is needed in order to keep track of tres | |
| atf.require_accounting() | |
| atf.require_config_parameter("SelectType", "select/cons_tres") | |
| atf.require_config_parameter("SelectTypeParameters", "CR_CPU") | |
| atf.require_config_parameter_includes( | |
| "Licenses", f"testing_license:{license_total_cnt}" | |
| ) | |
| atf.require_config_parameter_includes( | |
| "AccountingStorageTRES", | |
| "gres/gpu,gres/shard,gres/custom_gres,license/testing_license", | |
| ) | |
| atf.require_config_parameter_includes("GresTypes", "cpu") | |
| atf.require_config_parameter_includes("GresTypes", "gpu") | |
| atf.require_config_parameter_includes("GresTypes", "shard") | |
| atf.require_config_parameter_includes("GresTypes", "custom_gres") | |
| # Setup fake GPUs in gres.conf and add nodes to use the GPUs in slurm.conf | |
| atf.require_nodes( | |
| 1, | |
| [ | |
| ( | |
| "Gres", | |
| f"gpu:2,shard:{shard_total_cnt},custom_gres:{custom_gres_total_cnt}", | |
| ), | |
| ("CPUs", cpu_total_cnt), | |
| ], | |
| ) | |
| atf.require_slurm_running() | |
| # Make job file that will output environmental information to a text file | |
| def make_info_file(uniqueIdentifier): | |
| info_file = str(atf.module_tmp_path) + uniqueIdentifier | |
| job_file = info_file + ".sh" | |
| # Create bash script to output step information to a file | |
| atf.make_bash_script( | |
| job_file, | |
| f""" | |
| echo $(env) > {info_file} | |
| scontrol show lic >> {info_file} | |
| """, | |
| ) | |
| return job_file, info_file | |
| # Assure 'SLURM_TRES_PER_TASK' appears appropriately in environmental variables | |
| @pytest.mark.parametrize("name", tres_dict.keys()) | |
| @pytest.mark.parametrize("command", ["salloc", "srun", "sbatch"]) | |
| def test_env_vars(name, command): | |
| tres_params = [f"{tres_dict[name]['param']}{tres_dict[name]['num']}"] | |
| # If this tres works without a count, test without a count as well | |
| if not tres_dict[name]["count_needed"]: | |
| tres_params.append(tres_dict[name]["param"][:-1]) | |
| for tres_param in tres_params: | |
| job_file, info_file = make_info_file(f"{name}_{command}") | |
| job_param = f"--tres-per-task={tres_param} -n1" | |
| # Create a job | |
| job_id = atf.submit_job( | |
| command, job_param, job_file, wrap_job=False, fatal=True | |
| ) | |
| atf.wait_for_job_state(job_id, "DONE", fatal=True) | |
| # Get the SLURM_TRES_PER_TASK value | |
| env_value = re.search( | |
| r"SLURM_TRES_PER_TASK=(\S*)", | |
| atf.run_command_output(f"cat {info_file}", fatal=True), | |
| ).group(1) | |
| # Check environmental variable "SLURM_TRES_PER_TASK" was set correctly | |
| assert ( | |
| env_value == tres_param | |
| ), f"Environmental variable 'SLURM_TRES_PER_TASK' set to '{env_value}' instead of '{tres_param}' when running {command} command with parameters '{job_param}'" | |
| # Check '--tres-per-task' and '--licenses' behavior together | |
| @pytest.mark.parametrize("command", ["salloc", "srun", "sbatch"]) | |
| def test_license_allocation(command): | |
| # Check all licenses are available before starting | |
| assert ( | |
| match := re.search( | |
| r"Free=(\d+)", | |
| atf.run_command_output("scontrol show lic testing_license", fatal=True), | |
| ) | |
| ) is not None and ( | |
| available_lics := int(match.group(1)) | |
| ) == license_total_cnt, f"Got {available_lics} testing_license licenses free instead of all free ({license_total_cnt})" | |
| tres_param = f"{tres_dict['license']['param']}{tres_dict['license']['num']}" | |
| job_file, info_file = make_info_file(f"license_{command}") | |
| job_param = ( | |
| f"--tres-per-task={tres_param} " | |
| + f"--licenses=testing_license:{license_task_cnt} -n2" | |
| ) | |
| # Create a job | |
| job_id = atf.submit_job(command, job_param, job_file, wrap_job=False, fatal=True) | |
| atf.wait_for_job_state(job_id, "DONE", fatal=True) | |
| # Get how many licenses were used during each job submission | |
| output = atf.run_command_output(f"cat {info_file}", fatal=True) | |
| used_lics = available_lics - int(re.search(r"Free=(\d+)", output).group(1)) | |
| assert ( | |
| used_lics == license_task_cnt * 3 | |
| ), f"{used_lics} licenses were used instead of {license_task_cnt * 3} when running: {command} {job_param} {job_file}" | |
| # Check requested TRES when requesting allowed amount in job step | |
| @pytest.mark.parametrize("name", tres_dict.keys()) | |
| def test_step_tres_legal(name): | |
| env_file = atf.module_tmp_path / (name + "_env") | |
| tres_params = [f"{tres_dict[name]['param']}{tres_dict[name]['num']}"] | |
| # If this tres works without a count, test without a count as well | |
| if not tres_dict[name]["count_needed"]: | |
| tres_params.append(tres_dict[name]["param"][:-1]) | |
| for tres_param in tres_params: | |
| # Submit job with job step requesting all TRES allocated to batch | |
| sbatch_job_id = atf.submit_job_sbatch( | |
| f"--tres-per-task={tres_param} -n1 " | |
| f"--wrap 'srun -n1 --tres-per-task={tres_param} env > {env_file}'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(sbatch_job_id, "DONE", fatal=True) | |
| # Get the SLURM_TRES_PER_TASK value | |
| env_value = re.search( | |
| r"SLURM_TRES_PER_TASK=(\S*)", | |
| atf.run_command_output(f"cat {env_file}", fatal=True), | |
| ).group(1) | |
| # Check job step environmental variable "SLURM_TRES_PER_TASK" correct | |
| assert env_value == tres_param, ( | |
| f"Environmental variable 'SLURM_TRES_PER_TASK' set to '{env_value}' instead of '{tres_param}' for job step with a legal --tres-per-task value when running:\n" | |
| f"--tres-per-task={tres_param} -n1 --wrap 'srun -n1 --tres-per-task={tres_param} env > {env_file}'" | |
| ) | |
| # Check requested TRES when exceeding allowed amount or illegal format | |
| @pytest.mark.parametrize("name", tres_dict.keys()) | |
| def test_step_tres_illegal(name): | |
| env_file = atf.module_tmp_path / (name + "_env") | |
| tres_param = f"{tres_dict[name]['param']}{tres_dict[name]['num']}" | |
| # Submit job with job step requesting twice as much TRES than that | |
| # allocated to batch | |
| sbatch_job_id = atf.submit_job_sbatch( | |
| f"--tres-per-task={tres_param} " | |
| "-n1 " | |
| "--wrap '" | |
| "srun -n1 " | |
| f"--tres-per-task={tres_dict[name]['param']}" | |
| f"{tres_dict[name]['num'] * 2} " | |
| f"env > {env_file} 2>&1" | |
| "'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(sbatch_job_id, "DONE", fatal=True) | |
| # Check job step failed (or licenses used the sbatch value with a warning) | |
| assert ( | |
| re.search( | |
| rf"srun: error: (Unable to create step for job {sbatch_job_id})" | |
| r"|(Ignoring --tres-per-task license specification)", | |
| atf.run_command_output(f"cat {env_file}", fatal=True), | |
| ) | |
| is not None | |
| ), ( | |
| "Job step with illegal --tres-per-task did not throw an error at step creation:\n" | |
| f"sbatch --tres-per-task={tres_param} -n1 --wrap 'srun -n1 --tres-per-task={tres_dict[name]['param']}{tres_dict[name]['num'] * 2} env > {env_file} 2>&1'" | |
| ) | |
| # Test 'scontrol show job' shows correct accounting for salloc, srun, and sbatch | |
| @pytest.mark.parametrize("name", tres_dict.keys()) | |
| @pytest.mark.parametrize("command", ["salloc", "srun", "sbatch"]) | |
| def test_accounting(name, command): | |
| tres_params = [f"{tres_dict[name]['param']}{tres_dict[name]['num']}"] | |
| expected_cnts = [tres_dict[name]["num"]] | |
| # If this tres works without a count, test with no count and keep track of | |
| # expected accounting | |
| if not tres_dict[name]["count_needed"]: | |
| tres_params.append(tres_dict[name]["param"][:-1]) | |
| expected_cnts.append(1) | |
| for tres_param, expected_count in zip(tres_params, expected_cnts): | |
| job_param = f"--tres-per-task={tres_param} -n1" | |
| # Create a job | |
| job_id = atf.submit_job(command, job_param, "hostname", fatal=True) | |
| atf.wait_for_job_state(job_id, "DONE", fatal=True) | |
| # Retrieve job, which includes TRES accounting statistics | |
| show = atf.run_command_output(f"scontrol show job {job_id}", fatal=True) | |
| reqTRES = re.search(r"ReqTRES=(\S*)", show).group(1) | |
| allocTRES = re.search(r"AllocTRES=(\S*)", show).group(1) | |
| # Assert accounting TRES records match with what we requested | |
| assert ( | |
| f"{tres_dict[name]['acct']}{expected_count}" in reqTRES | |
| ), f"Can't find '{tres_dict[name]['acct']}{expected_count}' in ReqTRES of '{reqTRES}' when running {command} command with parameters '{job_param}'" | |
| assert ( | |
| f"{tres_dict[name]['acct']}{expected_count}" in allocTRES | |
| ), f"Can't find '{tres_dict[name]['acct']}{expected_count}' in AllocTRES of '{allocTRES}' when running {command} command with parameters '{job_param}'" | |
| # Make sure TRES is appropriately deallocated after jobs finish | |
| @pytest.mark.parametrize("name", tres_dict.keys()) | |
| @pytest.mark.parametrize("command", ["salloc", "srun", "sbatch"]) | |
| def test_tres_leaking(name, command): | |
| tres_params = [f"{tres_dict[name]['param']}{tres_dict[name]['num']}"] | |
| # If this tres works without a count, test without a count as well | |
| if not tres_dict[name]["count_needed"]: | |
| tres_params.append(tres_dict[name]["param"][:-1]) | |
| for tres_param in tres_params: | |
| job_param = f"--tres-per-task={tres_param} -n1" | |
| # Create a job | |
| job_id = atf.submit_job(command, job_param, "hostname", fatal=True) | |
| atf.wait_for_job_state(job_id, "DONE", fatal=True) | |
| # Get AllocTRES from each node | |
| allocTRES_list = atf.run_command_output( | |
| "scontrol show nodes | grep AllocTRES", fatal=True | |
| ).split("\n") | |
| # Assure TRES released after job (originally found in Bug 16356) | |
| assert all( | |
| [ | |
| allocTRES.strip() == "AllocTRES=" or allocTRES == "" | |
| for allocTRES in allocTRES_list | |
| ] | |
| ), ( | |
| f"One or more node AllocTRES's aren't empty after the job has completed (See Bug 16356)\n" | |
| f"AllocTRES list: {allocTRES_list}" | |
| ) | |
| # Make sure no commas between TRES doesn't work | |
| @pytest.mark.parametrize("name", tres_dict.keys()) | |
| def test_no_commas(name): | |
| # env_file = atf.module_tmp_path / (name + "_env") | |
| tres_params = [f"{tres_dict[name]['param']}{tres_dict[name]['num']}"] | |
| # If this tres works without a count, test without a count as well | |
| if not tres_dict[name]["count_needed"]: | |
| tres_params.append(tres_dict[name]["param"][:-1]) | |
| for tres_param in tres_params: | |
| job_param = f"--tres-per-task={tres_param * 2} -n1 hostname" | |
| # Test salloc | |
| assert not atf.submit_job_salloc( | |
| job_param, xfail=True | |
| ), f"salloc job created with no separating commas in --tres-per-task running: salloc {job_param}" | |
| # Test srun | |
| assert not atf.submit_job_srun( | |
| job_param, xfail=True | |
| ), f"srun job created with no separating commas in --tres-per-task running: srun {job_param}" | |
| # Test sbatch | |
| assert not atf.submit_job_sbatch( | |
| f"--tres-per-task={tres_param * 2} " "-n1 --wrap 'hostname'", xfail=True | |
| ), f"sbatch job created with no separating commas in --tres-per-task running: sbatch --tres-per-task={tres_param * 2} -n1 --wrap 'hostname'" | |
| # Test sbatch's step. Note sbatch is given legal --tres-per-task value | |
| job_id = atf.submit_job_sbatch( | |
| f"--tres-per-task={tres_param} -n1 " f"--wrap 'srun {job_param}'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "DONE", fatal=True) | |
| assert ( | |
| atf.get_job_parameter(job_id, "JobState", default="NOT_FOUND", quiet=True) | |
| == "FAILED" | |
| ), f"sbatch should have failed running with illegal step --tres-per-task: sbatch --tres-per-task={tres_param} -n1 --wrap 'srun {job_param}'" | |
| # Make sure having extra colons and/or nothing between colons doesn't work | |
| @pytest.mark.parametrize("name", tres_dict.keys()) | |
| def test_extra_colons(name): | |
| # env_file = atf.module_tmp_path / (name + "_env") | |
| # Replace each slash with two | |
| tres_params = [ | |
| f"{tres_dict[name]['param']}{tres_dict[name]['num']}".replace( | |
| "/", "//" | |
| ).replace(":", "::") | |
| ] | |
| # If this tres works without a count, replace slash to old colon | |
| if not tres_dict[name]["count_needed"]: | |
| # Replace each colon with two in new tres_param | |
| tres_params.append(tres_dict[name]["param"][:-1].replace("/", "//")) | |
| for tres_param in tres_params: | |
| job_param = f"--tres-per-task={tres_param} -n1 hostname" | |
| # Test salloc | |
| assert not atf.submit_job_salloc( | |
| job_param, xfail=True | |
| ), f"Job created with extra colons in --tres-per-task running: salloc {job_param}" | |
| # Test srun | |
| assert not atf.submit_job_srun( | |
| job_param, xfail=True | |
| ), f"Job created with extra colons in --tres-per-task running: srun {job_param}" | |
| # Test sbatch | |
| assert not atf.submit_job_sbatch( | |
| f"--tres-per-task={tres_param} -n1 " "--wrap 'hostname'", xfail=True | |
| ), f"Job created with extra colons in --tres-per-task running: sbatch --tres-per-task={tres_param} -n1 --wrap 'hostname'" | |
| # Test sbatch's step. Note sbatch is given legal --tres-per-task value | |
| job_id = atf.submit_job_sbatch( | |
| f"--tres-per-task={tres_param.replace('//', '/').replace('::', ':')} -n1 " | |
| f"--wrap 'srun {job_param}'", | |
| fatal=True, | |
| ) | |
| atf.wait_for_job_state(job_id, "DONE", fatal=True) | |
| assert ( | |
| atf.get_job_parameter(job_id, "JobState", default="NOT_FOUND", quiet=True) | |
| == "FAILED" | |
| ), f"sbatch should have failed when its step had an illegal tres-per-task running: sbatch --tres-per-task={tres_param.replace('::', '/')} -n1 --wrap 'srun {job_param}'" |