blob: a79499b2e1449d754964fe4ed723646de002f2e1 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved
############################################################################
import logging
import re
import pytest
import atf
import hdf5_profile
id_cols = ["Step", "Node", "Series"]
summarized_cols = [
"EpochTime",
"CPUFrequency",
"CPUUtilization",
"CPUTime",
"GPUMemMB",
"GPUUtilization",
"RSS",
"VMSize",
"Pages",
"ReadMB",
"WriteMB",
]
int_value_cols = [
"EpochTime",
"CPUFrequency",
"GPUMemMB",
"RSS",
"VMSize",
"Pages",
]
listed_cols = summarized_cols + ["ElapsedTime"]
@pytest.fixture(scope="module")
def hdf5_dir():
return hdf5_profile.profile_hdf5_dir()
@pytest.fixture(scope="module", autouse=True)
def setup(hdf5_dir):
atf.require_nodes(2)
atf.require_config_parameter("AcctGatherProfileType", "acct_gather_profile/hdf5")
atf.require_config_parameter(
"ProfileHDF5Dir",
hdf5_dir,
source="acct_gather",
)
# A default profile would make the extern step write its own files
atf.require_config_parameter(
"ProfileHDF5Default",
[None, "None"],
source="acct_gather",
)
atf.require_config_parameter(
"JobAcctGatherType",
"jobacct_gather/cgroup",
)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def user_profile_dir(hdf5_dir):
return hdf5_profile.profile_user_dir(hdf5_dir)
@pytest.fixture(scope="module")
def job_id(user_profile_dir):
"""Generate a profile and return the job id"""
if atf.get_version("sbin/slurmd") < (26, 11):
# Issue 51239: slurmstepds raced to create ProfileHDF5Dir
atf.run_command(
f"mkdir -p -m 0700 {user_profile_dir}",
user=atf.properties["test-user"],
fatal=True,
)
job_id = atf.submit_job_srun(
"--acctg-freq=task=2 --profile=task -N2 --ntasks-per-node=1 sleep 6",
fatal=True,
)
yield job_id
atf.run_command(
f"rm -f {user_profile_dir}/{job_id}_*.h5",
quiet=True,
)
def profile_test(args):
return (
atf.run_command_exit(
f"test {args}",
quiet=True,
)
== 0
)
@pytest.fixture(scope="module")
def node_profiles(user_profile_dir, job_id):
return hdf5_profile.wait_for_node_profiles(user_profile_dir, job_id, 2)
@pytest.fixture(scope="module")
def hdf5_file(job_id, node_profiles):
return hdf5_profile.merge_job_profiles(job_id)
@pytest.fixture(scope="module")
def batch_job_id(user_profile_dir):
"""Generate a profile with a batch step and step 0, and return the job id"""
job_id = atf.submit_job_sbatch(
'--acctg-freq=task=1 --profile=task -N1 --wrap "sleep 2; srun sleep 2"',
fatal=True,
)
atf.wait_for_job_state(job_id, "COMPLETED", fatal=True)
yield job_id
atf.run_command(
f"rm -f {user_profile_dir}/{job_id}_*.h5",
quiet=True,
)
@pytest.fixture(scope="module")
def batch_hdf5_file(user_profile_dir, batch_job_id):
# One file for the batch step and one for step 0 on the single node
hdf5_profile.wait_for_node_profiles(user_profile_dir, batch_job_id, 2)
return hdf5_profile.merge_job_profiles(batch_job_id, "batch.h5")
def h5dump_paths(hdf5_file):
"""Return the set of group and dataset paths in an HDF5 file"""
output = atf.run_command_output(f"h5dump -n {hdf5_file}", fatal=True)
paths = set()
for line in output.splitlines():
match = re.match(r"^\s*(group|dataset)\s+(/\S+)", line)
if match and match.group(2) != "/":
paths.add(match.group(2))
logging.debug(f"h5dump paths in {hdf5_file}: {sorted(paths)}")
return paths
@pytest.fixture(scope="module")
def time_series_rows(job_id, hdf5_file):
return hdf5_profile.read_sh5util_csv(
job_id, hdf5_file, "-E --level=Node:TimeSeries", id_cols + listed_cols
)
@pytest.fixture(scope="module")
def totals_rows(job_id, hdf5_file):
metrics = ["Min", "Max", "Sum", "Avg"]
cols = id_cols + ["ElapsedTime"]
for col in summarized_cols:
for metric in metrics:
cols.append(f"{metric}_{col}")
return hdf5_profile.read_sh5util_csv(
job_id, hdf5_file, "-E --level=Node:Totals", cols
)
def test_profile_dir(hdf5_dir, user_profile_dir, job_id):
"""Profiles land in the user's subdirectory"""
profiles = hdf5_profile.wait_for_node_profiles(user_profile_dir, job_id, 2)
assert profile_test(f"-d {user_profile_dir}")
assert len(profiles) == 2, "Expected two profiles for a two node job"
assert profiles == hdf5_profile.get_node_profiles(
user_profile_dir, job_id
), "The waited-for profiles should be the ones listed in the user directory"
def test_merge(hdf5_file, node_profiles):
"""Merging profiles produces a new, non-empty output file and saves inputs"""
assert hdf5_file.exists(), f"Merge did not create {hdf5_file}"
assert hdf5_file.stat().st_size > 0, f"Merge created empty {hdf5_file}"
for profile in node_profiles:
assert profile_test(f"-f {profile}"), f"Profile {profile} was not saved"
def test_hdf5_utils(job_id, node_profiles, hdf5_file):
"""Generated hdf5 files are compatible with common hdf5 utilities and use
the documented group layout"""
atf.require_tool("h5dump")
for node_profile in node_profiles:
assert atf.run_command_exit(f"h5dump -H {node_profile}") == 0
assert atf.run_command_exit(f"h5dump -H {hdf5_file}") == 0
job_nodes = set(
atf.node_range_to_list(atf.get_job_parameter(job_id, "NodeList", fatal=True))
)
assert_count(job_nodes, 2, "job node(s)")
# The merged file has /Steps/0/Nodes/<node>/Tasks/<taskid>, one dataset per
# task that ran on the node
paths = h5dump_paths(hdf5_file)
node_tasks = {}
for node in job_nodes:
prefix = f"/Steps/0/Nodes/{node}/Tasks/"
tasks = {path[len(prefix) :] for path in paths if path.startswith(prefix)}
assert_count(tasks, 1, f"task dataset(s) for {node} in {hdf5_file}")
node_tasks[node] = tasks.pop()
assert sorted(node_tasks.values()) == [
"0",
"1",
], f"Expected tasks 0 and 1 across the nodes, saw: {node_tasks}"
def test_batch_step_name(batch_hdf5_file):
"""The merged file names the batch step group 'batch'"""
atf.require_tool("h5dump")
paths = h5dump_paths(batch_hdf5_file)
for step_path in ["/Steps/batch", "/Steps/0"]:
assert step_path in paths, f"Missing {step_path} in {batch_hdf5_file}"
def test_listed_fields(job_id, hdf5_file):
"""Listing fields produces the correct schema"""
output = atf.run_command_output(
f"sh5util -i {hdf5_file} -j {job_id} --series=Tasks -L",
fatal=True,
)
hdf5_profile.assert_schema(output.strip().splitlines(), listed_cols)
def assert_count(values, count, description):
assert (
len(values) == count
), f"Expected {count} {description}, saw: {len(values)} ({values})"
def row_identity(row):
return tuple(row[col] for col in id_cols)
def grouped_by_id(rows):
grouped = {}
for row in rows:
row_id = row_identity(row)
if row_id not in grouped:
grouped[row_id] = []
grouped[row_id].append(row)
return grouped
def assert_close(field, metric, identity, observed_value, expected_value, sample_count):
if isinstance(expected_value, int):
target = expected_value
else:
target = pytest.approx(
expected_value, rel=1e-12, abs=max(1e-5, sample_count * 1e-6)
)
assert observed_value == target, f"{metric}_{field} for {identity}"
def assert_ids(ids):
steps = {x[0] for x in ids}
nodes = {x[1] for x in ids}
series = {x[2] for x in ids}
assert_count(steps, 1, "Step(s)")
assert_count(nodes, 2, "Node(s)")
assert_count(series, 2, "Series")
@pytest.mark.parametrize("field", summarized_cols)
def test_totals_match_time_series(field, time_series_rows, totals_rows):
"""Node:Totals values match the extracted Node:TimeSeries samples"""
time_series_by_id = grouped_by_id(time_series_rows)
totals_by_id = {row_identity(row): row for row in totals_rows}
assert time_series_by_id.keys() == totals_by_id.keys()
assert_ids(time_series_by_id)
is_int_field = field in int_value_cols
for identity, rows in time_series_by_id.items():
values = [
int(row[field]) if is_int_field else float(row[field]) for row in rows
]
expected_values = {
"Min": min(values),
"Max": max(values),
"Sum": sum(values),
"Avg": sum(values) / len(values),
}
for metric, expected_value in expected_values.items():
raw = totals_by_id[identity][f"{metric}_{field}"]
totals_value = int(raw) if is_int_field and metric != "Avg" else float(raw)
assert_close(
field, metric, identity, totals_value, expected_value, len(values)
)
@pytest.mark.xfail(
atf.get_version("bin/sh5util") < (26, 5),
reason="Ticket 25241: Item extract 'Malformed file' error fixed in sh5util >= 26.05",
)
def test_item_extract(job_id, hdf5_file, time_series_rows):
"""Item-extract mode data is consistent"""
nodes = sorted({row["Node"] for row in time_series_rows})
item_cols = [
"MinNode",
"MinValue",
"MaxNode",
"MaxValue",
"Sum",
"Avg",
"NumNodes",
*nodes,
]
for col in summarized_cols:
item_rows = hdf5_profile.read_sh5util_csv(
job_id, hdf5_file, f"-I --data={col}", item_cols
)
for row in item_rows:
num_nodes = row["NumNodes"]
assert (
num_nodes.isdigit()
), f"{col} item extract has invalid NumNodes: {num_nodes}"
assert (
1 <= int(num_nodes) <= len(nodes)
), f"{col} item extract has invalid NumNodes: {num_nodes}"