| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Shared helpers for the acct_gather_profile/hdf5 test modules. | |
| The hdf5 profile modules that import this library run a profiled job and | |
| assert against the same per-node profile files and the same sh5util CSV | |
| extraction, so the profile directories, the profiled job, the profile | |
| listing, the node profile poll, the merge, the schema check, the extraction | |
| reader, and the GPU column assert are collected here. Each module still pins | |
| its own cluster configuration, whose tracked TRES are what its own subject | |
| turns on, and judges its own columns. | |
| """ | |
| import csv | |
| import io | |
| import logging | |
| from pathlib import Path | |
| import atf | |
| def profile_hdf5_dir(): | |
| """The ProfileHDF5Dir a module configures, under its working directory. | |
| The value goes into acct_gather.conf, which the daemons read from | |
| elsewhere on the node, so the path is made absolute. Keeping it under the | |
| module's own working directory leaves the profiles where that module can | |
| clean them up. | |
| """ | |
| return Path("h5").absolute() | |
| def profile_user_dir(hdf5_dir): | |
| """The test user's own profile directory under a ProfileHDF5Dir. | |
| The plugin files each user's profiles under a directory named for that | |
| user, so a module lists the profiles of the user its jobs run as. | |
| """ | |
| return hdf5_dir / atf.properties["test-user"] | |
| def run_profile_job(user_profile_dir, job_name): | |
| """Run a profiled single-task step, yield its job id, then clean up. | |
| Samples the task profile every 2 seconds over a 10 second step, so the | |
| profile holds several samples for a caller to judge. Drives a | |
| module-scoped fixture through yield from, and removes the job's node | |
| profile files afterwards so the run leaves none behind. | |
| """ | |
| job_id = atf.submit_job_srun( | |
| f"--job-name={job_name} --mpi=none " | |
| "--acctg-freq=task=2 --profile=task -t1 -N1 -n1 sleep 10", | |
| fatal=True, | |
| ) | |
| yield job_id | |
| atf.run_command( | |
| f"rm -f {user_profile_dir}/{job_id}_*.h5", | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| def assert_gpu_columns_zero(rows): | |
| """Require every profile sample to record both GPU columns as exactly 0. | |
| The dataset defines GPUMemMB and GPUUtilization unconditionally, so every | |
| sample carries both, and every sample, the first included, must map the | |
| no-sample sentinel to 0. Two samples are still required so a series, | |
| rather than a lone row, proves the mapping holds across polls. | |
| """ | |
| assert len(rows) >= 2, f"Expected at least 2 samples, saw: {len(rows)} ({rows})" | |
| for row in rows: | |
| assert int(row["GPUMemMB"]) == 0, f"Nonzero GPUMemMB sample: {row}" | |
| assert ( | |
| float(row["GPUUtilization"]) == 0.0 | |
| ), f"Nonzero GPUUtilization sample: {row}" | |
| def get_node_profiles(user_profile_dir, job_id): | |
| """The job's node step HDF5 profile files, sorted by path. | |
| acct_gather_profile/hdf5 writes one <job_id>_<step_id>_<node>.h5 file | |
| per node and step under the user's profile dir, with the literal batch | |
| in place of the step id for the batch step, so listing that pattern | |
| names every node step profile the job has produced so far. | |
| """ | |
| output = atf.run_command_output( | |
| f"ls -1 {user_profile_dir}/{job_id}_*.h5", | |
| quiet=True, | |
| ) | |
| return sorted(Path(profile) for profile in output.splitlines()) | |
| def merge_job_profiles(job_id, output_name="test.h5"): | |
| """Merge a job's node step profiles into one file and return its path. | |
| Runs sh5util in merge mode to gather every node step profile of the | |
| job into one file, with --savefiles keeping the per-node inputs in | |
| place so a caller can still assert against them. The merged file is | |
| written under output_name, test.h5 by default, in the module's working | |
| directory, so a module merging more than one job names each file. | |
| """ | |
| hdf5_file = Path(output_name).absolute() | |
| atf.run_command(f"sh5util --savefiles -j {job_id} -o {hdf5_file}", fatal=True) | |
| return hdf5_file | |
| def assert_schema(field_names, expected_cols): | |
| """Require every expected column among the field names, more are allowed.""" | |
| missing_cols = set(expected_cols) - set(field_names) | |
| assert not missing_cols, f"Missing expected column(s): {missing_cols}" | |
| def read_sh5util_csv(job_id, hdf5_file, opts, expected_cols): | |
| """Extract a job's merged HDF5 profile to CSV and return its rows. | |
| Runs sh5util over the merged profile with the given extraction options, | |
| asserts every expected column is present (further columns are allowed), | |
| and asserts the extraction holds at least one data row, so a caller | |
| judges values rather than the extraction plumbing. | |
| """ | |
| output_file = "output" | |
| cmd = f"sh5util -i {hdf5_file} -o {output_file} -j {job_id} -s Tasks {opts}" | |
| atf.run_command(cmd, fatal=True) | |
| csv_text = atf.run_command_output(f"cat {output_file}", fatal=True) | |
| reader = csv.DictReader(io.StringIO(csv_text)) | |
| cols = reader.fieldnames | |
| logging.debug(f"CSV cols: {cols}") | |
| assert cols, f"Output file ({output_file}) is empty" | |
| assert_schema(cols, expected_cols) | |
| rows = list(reader) | |
| logging.debug(f"CSV contents: {rows}") | |
| assert rows, f"No data in output file ({output_file})" | |
| return rows | |
| def wait_for_node_profiles(user_profile_dir, job_id, expected_count): | |
| """Poll until a job has produced the expected node profile count. | |
| acct_gather_profile/hdf5 creates a node step profile file when the step | |
| starts on that node and completes it when the step ends, so the files | |
| appear over time and a caller polls for them, and reads one only after | |
| its step has ended. Returns the profile paths once the expected count is | |
| listed, and fails the test when the poll times out first. | |
| """ | |
| profiles = None | |
| for _ in atf.timer(fatal=True): | |
| profiles = get_node_profiles(user_profile_dir, job_id) | |
| logging.debug(f"Node step profiles of job {job_id}: {profiles}") | |
| if len(profiles) == expected_count: | |
| break | |
| return profiles |