Skip to content

Commit 9eb6871

Browse files
Merge pull request #13 from ACCESS-NRI/payu_config_profiling
Add classes to handle profiling of Payu configurations.
2 parents 580517e + be1b051 commit 9eb6871

10 files changed

Lines changed: 543 additions & 73 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"pint",
2121
"pint-xarray",
2222
"matplotlib",
23+
"access-config-utils",
2324
]
2425

2526
[build-system]

src/access/profiling/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
with suppress(PackageNotFoundError):
1010
__version__ = version("access-profiling")
1111

12+
from access.profiling.access_models import ESM16Profiling
1213
from access.profiling.cice5_parser import CICE5ProfilingParser
1314
from access.profiling.esmf_parser import ESMFSummaryProfilingParser
1415
from access.profiling.fms_parser import FMSProfilingParser
@@ -23,4 +24,5 @@
2324
"CICE5ProfilingParser",
2425
"PayuJSONProfilingParser",
2526
"ESMFSummaryProfilingParser",
27+
"ESM16Profiling",
2628
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright 2025 ACCESS-NRI and contributors. See the top-level COPYRIGHT file for details.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import logging
5+
from pathlib import Path
6+
7+
from access.config import YAMLParser
8+
9+
from access.profiling.cice5_parser import CICE5ProfilingParser
10+
from access.profiling.fms_parser import FMSProfilingParser
11+
from access.profiling.manager import ProfilingLog
12+
from access.profiling.payu_manager import PayuManager
13+
from access.profiling.um_parser import UMProfilingParser
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class ESM16Profiling(PayuManager):
19+
"""Handles profiling of ACCESS-ESM1.6 configurations."""
20+
21+
def get_component_logs(self, path: Path) -> dict[str, ProfilingLog]:
22+
"""Returns available profiling logs for the components in ACCESS-ESM1.6.
23+
24+
Args:
25+
path (Path): Path to the output directory.
26+
Returns:
27+
dict[str, ProfilingLog]: Dictionary mapping component names to their ProfilingLog instances.
28+
"""
29+
logs = {}
30+
parser = YAMLParser()
31+
32+
um_env_path = path / "atmosphere" / "um_env.yaml"
33+
um_env = parser.parse(um_env_path.read_text())
34+
um_logfile = path / "atmosphere" / f"{um_env['UM_STDOUT_FILE']}0"
35+
if um_logfile.is_file():
36+
logger.debug(f"Found UM log file: {um_logfile}")
37+
logs["UM"] = ProfilingLog(um_logfile, UMProfilingParser())
38+
39+
config_path = path / "config.yaml"
40+
payu_config = parser.parse(config_path.read_text())
41+
mom5_logfile = path / f"{payu_config['model']}.out"
42+
if mom5_logfile.is_file():
43+
logger.debug(f"Found MOM5 log file: {mom5_logfile}")
44+
logs["MOM5"] = ProfilingLog(mom5_logfile, FMSProfilingParser(has_hits=False))
45+
46+
cice5_logfile = path / "ice" / "ice_diag.d"
47+
if cice5_logfile.is_file():
48+
logger.debug(f"Found CICE5 log file: {cice5_logfile}")
49+
logs["CICE5"] = ProfilingLog(cice5_logfile, CICE5ProfilingParser())
50+
51+
return logs

src/access/profiling/manager.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Copyright 2025 ACCESS-NRI and contributors. See the top-level COPYRIGHT file for details.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from abc import ABC, abstractmethod
5+
from pathlib import Path
6+
7+
import xarray as xr
8+
9+
from access.profiling.metrics import ProfilingMetric
10+
from access.profiling.parser import ProfilingParser
11+
from access.profiling.scaling import plot_scaling_metrics
12+
13+
14+
class ProfilingLog:
15+
"""Represents a profiling log file.
16+
17+
Args:
18+
filepath (Path): Path to the log file.
19+
parser (ProfilingParser): Parser to use for this log file.
20+
"""
21+
22+
filepath: Path # Path to the log file
23+
parser: ProfilingParser # Parser to use for this log file
24+
25+
def __init__(self, filepath: Path, parser: ProfilingParser):
26+
self.filepath = filepath
27+
self.parser = parser
28+
29+
def parse(self) -> xr.Dataset:
30+
"""Parses the log file and returns the profiling data as an xarray Dataset.
31+
32+
Returns:
33+
xr.Dataset: Parsed profiling data."""
34+
path = self.filepath
35+
log = path.read_text()
36+
data = self.parser.read(log)
37+
return xr.Dataset(
38+
data_vars=dict(
39+
zip(
40+
self.parser.metrics,
41+
[
42+
xr.DataArray(data[metric], dims=["region"]).pint.quantify(metric.units)
43+
for metric in self.parser.metrics
44+
],
45+
strict=True,
46+
)
47+
),
48+
coords={"region": data["region"]},
49+
)
50+
51+
52+
class ProfilingManager(ABC):
53+
"""Abstract base class to handle profiling data and workflows.
54+
55+
This high-level class defines methods to parse different types of profiling data. Currently,
56+
it supports parsing and plotting scaling data.
57+
"""
58+
59+
data: dict[str, xr.Dataset] = {} # Dictionary mapping component names to their profiling datasets.
60+
61+
@abstractmethod
62+
def parse_profiling_data(self, path: Path) -> dict[str, xr.Dataset]:
63+
"""Parses profiling data from the specified path.
64+
65+
Args:
66+
path (Path): Path to the experiment directory.
67+
68+
Returns:
69+
dict[str, xr.Dataset]: Dictionary mapping component names to their profiling datasets.
70+
"""
71+
72+
@abstractmethod
73+
def parse_ncpus(self, path: Path) -> int:
74+
"""Parses the number of CPUs used in a given experiment in the specified path.
75+
76+
Args:
77+
path (Path): Path to the experiment directory.
78+
79+
Returns:
80+
int: Number of CPUs used in the experiment.
81+
"""
82+
83+
def parse_scaling_data(self, paths: list[Path]):
84+
"""Parses profiling data from a list of experiment directories.
85+
86+
Args:
87+
paths (list[Path]): List of paths to experiment directories.
88+
"""
89+
self.data = {}
90+
for path in paths:
91+
# Parse data
92+
datasets = self.parse_profiling_data(path)
93+
94+
# Find number of cpus used
95+
ncpus = self.parse_ncpus(path)
96+
97+
# Add ncpus dimension and concatenate with existing data
98+
for name, ds in datasets.items():
99+
ds = ds.expand_dims({"ncpus": 1}).assign_coords({"ncpus": [ncpus]})
100+
if name in self.data:
101+
self.data[name] = xr.concat([self.data[name], ds], dim="ncpus", join="outer")
102+
else:
103+
self.data[name] = ds
104+
105+
def plot_scaling_data(
106+
self,
107+
components: list[str],
108+
regions: list[list[str]],
109+
metric: ProfilingMetric,
110+
region_relabel_map: dict | None = None,
111+
):
112+
"""Plots scaling data for the specified components, regions and metric.
113+
114+
Args:
115+
components (list[str]): List of component names to plot.
116+
regions (list[list[str]]): List of regions to plot for each component.
117+
metric (ProfilingMetric): Metric to use for the scaling plots.
118+
region_relabel_map (dict | None): Optional mapping to relabel regions in the plots.
119+
"""
120+
plot_scaling_metrics([self.data[c] for c in components], regions, metric, region_relabel_map=region_relabel_map)

src/access/profiling/parser.py

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@
44
"""Classes and utilities to build profiling parsers for reading profiling data."""
55

66
from abc import ABC, abstractmethod
7-
from collections.abc import Iterable
87
from typing import Any
98

109
# Next import is required to register pint with xarray
1110
import pint_xarray # noqa: F401
12-
import xarray as xr
1311

1412
from access.profiling.metrics import ProfilingMetric
1513

@@ -51,51 +49,6 @@ def read(self, stream: str) -> dict:
5149
dict: profiling data.
5250
"""
5351

54-
def parse_data_series(self, logs: list[str], varname: str, vars: Iterable) -> xr.Dataset:
55-
"""Given a list of logs containing profiling data, parse the data and return it as a xarray dataset.
56-
57-
For example, if the logs correspond to different runs of the same application with different number of CPUs,
58-
then varname should be "ncpus" and vars could be a list with core counts:
59-
60-
log_1cpu = open("log_1cpu.txt").read()
61-
log_2cpu = open("log_2cpu.txt").read()
62-
log_4cpu = open("log_4cpu.txt").read()
63-
scaling_data = parser.parse_data_series(
64-
logs= [log_1cpu, log_2cpu, log_4cpu],
65-
varname="ncpus",
66-
vars=[1, 2, 4]
67-
)
68-
69-
Args:
70-
Logs (list[str]): Logs to parse.
71-
varname (str): Name of the variable that changes between logs.
72-
vars (Iterable): An iterable returning the value of the variable that changes between logs.
73-
74-
Returns:
75-
Dataset: Series profiling data.
76-
"""
77-
datasets = []
78-
for var, log in zip(vars, logs, strict=True):
79-
data = self.read(log)
80-
datasets.append(
81-
xr.Dataset(
82-
data_vars=dict(
83-
zip(
84-
self.metrics,
85-
[
86-
xr.DataArray([data[metric]], dims=[varname, "region"]).pint.quantify(metric.units)
87-
for metric in self.metrics
88-
],
89-
strict=True,
90-
)
91-
),
92-
coords={varname: [var], "region": data["region"]},
93-
)
94-
)
95-
96-
# Create dataset with all the data
97-
return xr.concat(datasets, dim=varname)
98-
9952

10053
def _convert_from_string(value: str) -> Any:
10154
"""Tries to convert a string to the most appropriate numeric type. Leaves it unchanged if conversion does not
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright 2025 ACCESS-NRI and contributors. See the top-level COPYRIGHT file for details.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import logging
5+
from abc import ABC, abstractmethod
6+
from pathlib import Path
7+
8+
import xarray as xr
9+
from access.config import YAMLParser
10+
11+
from access.profiling.manager import ProfilingLog, ProfilingManager
12+
from access.profiling.payujson_parser import PayuJSONProfilingParser
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class PayuManager(ProfilingManager, ABC):
18+
"""Abstract base class to handle profiling of Payu configurations."""
19+
20+
@abstractmethod
21+
def get_component_logs(self, path: Path) -> dict[str, ProfilingLog]:
22+
"""Returns available profiling logs for the components in the configuration.
23+
24+
Args:
25+
path (Path): Path to the output directory.
26+
Returns:
27+
dict[str, ProfilingLog]: Dictionary mapping component names to their ProfilingLog instances.
28+
"""
29+
30+
def parse_ncpus(self, path: Path) -> int:
31+
"""Parses the number of CPUs used in a given Payu experiment.
32+
33+
Args:
34+
path (Path): Path to the Payu experiment directory. Must contain a config.yaml file.
35+
Returns:
36+
int: Number of CPUs used in the experiment. If multiple submodels are defined, returns the sum of their
37+
ncpus.
38+
"""
39+
config_path = path / "config.yaml"
40+
payu_config = YAMLParser().parse(config_path.read_text())
41+
if "submodels" in payu_config:
42+
return sum(submodel["ncpus"] for submodel in payu_config["submodels"])
43+
else:
44+
return payu_config["ncpus"]
45+
46+
def parse_profiling_data(self, path: Path) -> dict[str, xr.Dataset]:
47+
"""Parses profiling data from a Payu experiment directory.
48+
49+
Args:
50+
path (Path): Path to the Payu experiment directory.
51+
Returns:
52+
dict[str, xr.Dataset]: Dictionary mapping component names to their profiling datasets.
53+
Raises:
54+
FileNotFoundError: If the archive or output directories are missing.
55+
"""
56+
datasets = {}
57+
logs = {}
58+
59+
# Check archive directory exists
60+
archive = path / "archive"
61+
if not archive.is_dir():
62+
raise FileNotFoundError(f"Directory {archive} does not exist!")
63+
64+
# Parse payu json profiling data if available
65+
matches = sorted(archive.glob("payu_jobs/*/run/*.json"))
66+
if len(matches) > 1:
67+
logger.warning(f"Multiple payu json logs found in {path}! Using the first one found.")
68+
if len(matches) >= 1:
69+
logs["payu"] = ProfilingLog(matches[0], PayuJSONProfilingParser())
70+
71+
# Find how many output directories are available and get logs from each component
72+
matches = sorted(archive.glob("output*"))
73+
if len(matches) == 0:
74+
raise FileNotFoundError(f"No output files found in {path}!")
75+
elif len(matches) > 1:
76+
logger.warning(f"Multiple output directories found in {path}! Using the first one found.")
77+
for output in matches:
78+
logs.update(self.get_component_logs(output))
79+
80+
# Parse all logs
81+
for name, log in logs.items():
82+
logger.info(f"Parsing {name} profiling log: {log.filepath}. ")
83+
datasets[name] = log.parse()
84+
logger.info(" Done.")
85+
86+
return datasets

0 commit comments

Comments
 (0)