Skip to content

Commit 05b3e82

Browse files
New dispel4py branch
1 parent 5e56c68 commit 05b3e82

2 files changed

Lines changed: 312 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,5 @@ bin/gpu_benchmark
6060
coverage.xml
6161

6262
test/
63+
llmtests/
64+
scratch/
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Convert dispel4py monitoring traces (the ``monitor_*`` artifacts produced by the
5+
``timed_simple`` / ``timed_multi`` / ``timed_mpi`` mappings) into WfFormat
6+
workflow instances consumable by WfCommons / WfChef.
7+
8+
Granularity: one WfFormat task per PE *instance* (``pe_id@rank``).
9+
10+
Inputs read from a monitoring directory:
11+
monitor_concrete_shape_run<id>.json instance-level DAG (nodes + edges)
12+
monitor_shape_run<id>.json abstract PE-level DAG (fallback for edges)
13+
monitor_instances_run<id>.csv per-instance runtimes
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import csv
19+
import json
20+
import logging
21+
import pathlib
22+
import re
23+
from typing import Any, Dict, List, Optional, Tuple
24+
25+
from wfcommons.common.task import Task, TaskType
26+
from wfcommons.common.workflow import Workflow
27+
28+
logger = logging.getLogger(__name__)
29+
30+
# dispel4py writes run ids as a compact ISO-ish stamp, e.g. 20260916T183613549171Z
31+
_RUN_ID_RE = re.compile(r"_run(?P<run_id>[^.]+)\.(?:json|csv|png)$")
32+
33+
34+
def _find_run_ids(monitoring_dir: pathlib.Path, prefix: str) -> List[str]:
35+
"""Return every run id present in a monitoring directory, newest last."""
36+
run_ids = set()
37+
for path in monitoring_dir.glob(f"{prefix}_instances_run*.csv"):
38+
match = _RUN_ID_RE.search(path.name)
39+
if match:
40+
run_ids.add(match.group("run_id"))
41+
return sorted(run_ids)
42+
43+
44+
def _one(monitoring_dir: pathlib.Path, pattern: str) -> Optional[pathlib.Path]:
45+
"""Return the single file matching a glob, or None."""
46+
matches = sorted(monitoring_dir.glob(pattern))
47+
if not matches:
48+
return None
49+
if len(matches) > 1:
50+
logger.warning("multiple matches for %s, using %s", pattern, matches[0].name)
51+
return matches[0]
52+
53+
54+
def _read_instances(path: pathlib.Path) -> Dict[str, Dict[str, Any]]:
55+
"""
56+
Parse monitor_instances_run<id>.csv.
57+
58+
Columns: pe_id, rank, instance_id, total_count, total_secs, avg_secs,
59+
min_secs, p50_secs, p95_secs, max_secs
60+
"""
61+
rows: Dict[str, Dict[str, Any]] = {}
62+
with path.open(newline="", encoding="utf-8") as handle:
63+
for row in csv.DictReader(handle):
64+
instance_id = row.get("instance_id") or f"{row['pe_id']}@{row['rank']}"
65+
rows[instance_id] = {
66+
"pe_id": row["pe_id"],
67+
"rank": row["rank"],
68+
"instance_id": instance_id,
69+
"total_count": int(float(row.get("total_count") or 0)),
70+
"total_secs": float(row.get("total_secs") or 0.0),
71+
"avg_secs": float(row.get("avg_secs") or 0.0),
72+
"max_secs": float(row.get("max_secs") or 0.0),
73+
}
74+
return rows
75+
76+
77+
def _expand_abstract_edges(
78+
abstract: Dict[str, Any],
79+
instances_by_pe: Dict[str, List[str]],
80+
) -> List[Tuple[str, str]]:
81+
"""
82+
Fall back to the abstract shape: connect every instance of the source PE to
83+
every instance of the destination PE (dispel4py's default all-to-all
84+
grouping). Used only when the concrete shape carries no edges.
85+
"""
86+
edges = []
87+
for edge in abstract.get("edges", []):
88+
for src in instances_by_pe.get(edge["from"], []):
89+
for dst in instances_by_pe.get(edge["to"], []):
90+
edges.append((src, dst))
91+
return sorted(set(edges))
92+
93+
94+
def _break_cycles(edges: List[Tuple[str, str]], order: List[str]) -> List[Tuple[str, str]]:
95+
"""
96+
WfFormat requires a DAG, but dispel4py graphs may contain feedback loops.
97+
Drop back-edges relative to the recorded topological order (or, absent one,
98+
relative to first-seen order) and report what was removed.
99+
"""
100+
position = {node: index for index, node in enumerate(order)}
101+
if not position:
102+
seen: List[str] = []
103+
for src, dst in edges:
104+
for node in (src, dst):
105+
if node not in seen:
106+
seen.append(node)
107+
position = {node: index for index, node in enumerate(seen)}
108+
109+
kept, dropped = [], []
110+
for src, dst in edges:
111+
if position.get(src, 0) < position.get(dst, 0):
112+
kept.append((src, dst))
113+
else:
114+
dropped.append((src, dst))
115+
if dropped:
116+
logger.warning(
117+
"dropped %d cyclic edge(s) to keep the instance graph acyclic: %s",
118+
len(dropped),
119+
dropped,
120+
)
121+
return kept
122+
123+
124+
def build_workflow(
125+
monitoring_dir: pathlib.Path | str,
126+
workflow_name: Optional[str] = None,
127+
prefix: str = "monitor",
128+
run_id: Optional[str] = None,
129+
mapping: Optional[str] = None,
130+
) -> Workflow:
131+
"""
132+
Build a WfFormat Workflow from one dispel4py monitoring run.
133+
134+
:param monitoring_dir: directory holding the monitor_* artifacts.
135+
:param workflow_name: name for the instance (defaults to the directory name).
136+
:param prefix: dispel4py --timing-prefix used for the run (default "monitor").
137+
:param run_id: which run to convert; defaults to the only/latest one present.
138+
:param mapping: dispel4py mapping that produced the trace, recorded as the
139+
runtime system version (e.g. "timed_multi").
140+
"""
141+
monitoring_dir = pathlib.Path(monitoring_dir)
142+
if not monitoring_dir.is_dir():
143+
raise NotADirectoryError(f"not a directory: {monitoring_dir}")
144+
145+
if run_id is None:
146+
run_ids = _find_run_ids(monitoring_dir, prefix)
147+
if not run_ids:
148+
raise FileNotFoundError(
149+
f"no {prefix}_instances_run*.csv found in {monitoring_dir}"
150+
)
151+
if len(run_ids) > 1:
152+
logger.warning("found %d runs, converting the latest (%s)", len(run_ids), run_ids[-1])
153+
run_id = run_ids[-1]
154+
155+
instances_csv = monitoring_dir / f"{prefix}_instances_run{run_id}.csv"
156+
if not instances_csv.exists():
157+
raise FileNotFoundError(instances_csv)
158+
instances = _read_instances(instances_csv)
159+
if not instances:
160+
raise ValueError(f"{instances_csv.name} contains no instance rows")
161+
162+
concrete_path = _one(monitoring_dir, f"{prefix}_concrete_shape_run{run_id}.json")
163+
abstract_path = _one(monitoring_dir, f"{prefix}_shape_run{run_id}.json")
164+
165+
concrete = json.loads(concrete_path.read_text()) if concrete_path else {}
166+
abstract = json.loads(abstract_path.read_text()) if abstract_path else {}
167+
168+
# --- nodes -------------------------------------------------------------
169+
# Prefer the concrete shape's node table; fall back to whatever the
170+
# instances CSV recorded (always instance-level either way).
171+
node_rows = concrete.get("nodes") or [
172+
{"instance_id": k, "pe_id": v["pe_id"], "rank": v["rank"]}
173+
for k, v in sorted(instances.items())
174+
]
175+
176+
instances_by_pe: Dict[str, List[str]] = {}
177+
for node in node_rows:
178+
instances_by_pe.setdefault(node["pe_id"], []).append(node["instance_id"])
179+
180+
# --- edges -------------------------------------------------------------
181+
concrete_edges = [(e["from"], e["to"]) for e in concrete.get("edges", [])]
182+
if concrete_edges:
183+
edges = sorted(set(concrete_edges))
184+
edge_source = "concrete shape"
185+
else:
186+
edges = _expand_abstract_edges(abstract, instances_by_pe)
187+
edge_source = "abstract shape (expanded across ranks)"
188+
logger.info("instance edges derived from %s", edge_source)
189+
190+
edges = _break_cycles(edges, concrete.get("topological_order", []))
191+
192+
# --- workflow ----------------------------------------------------------
193+
workflow = Workflow(
194+
name=workflow_name or monitoring_dir.name,
195+
description=(
196+
f"dispel4py execution trace (run {run_id}) converted to WfFormat; "
197+
f"one task per PE instance"
198+
),
199+
runtime_system_name="dispel4py",
200+
runtime_system_version=mapping or "unknown",
201+
runtime_system_url="https://github.com/StreamingFlow/d4py",
202+
)
203+
204+
# Stable, deterministic numbering so repeated conversions are diffable.
205+
ordered_ids = [n["instance_id"] for n in sorted(node_rows, key=lambda n: n["instance_id"])]
206+
# wfchef parses the task type out of the id via id.split("_ID"), so the id
207+
# must be "<pe_id>_ID<n>" for the PE to be recognised as the task type.
208+
task_ids = {
209+
instance_id: f"{_pe_of(instance_id, node_rows)}_ID{index:07d}"
210+
for index, instance_id in enumerate(ordered_ids)
211+
}
212+
213+
makespan = 0.0
214+
for instance_id in ordered_ids:
215+
stats = instances.get(instance_id)
216+
if stats is None:
217+
logger.warning("%s appears in the shape but not in %s; runtime set to 0",
218+
instance_id, instances_csv.name)
219+
stats = {"total_secs": 0.0, "total_count": 0, "avg_secs": 0.0, "max_secs": 0.0}
220+
pe_id = _pe_of(instance_id, node_rows)
221+
runtime = stats["total_secs"]
222+
makespan = max(makespan, runtime)
223+
# NOTE: name must equal task_id. WfFormat records dependencies as task
224+
# ids, but wfchef's create_graph_from_json_object keys its nodes by
225+
# task["name"] and then draws edges from those id-valued parents --
226+
# any mismatch creates attribute-less phantom nodes and annotate()
227+
# dies with KeyError: 'id'. The dispel4py instance id is preserved as
228+
# the command argument instead.
229+
workflow.add_task(
230+
Task(
231+
name=task_ids[instance_id],
232+
task_id=task_ids[instance_id],
233+
runtime=runtime,
234+
cores=1.0,
235+
category=pe_id,
236+
program=pe_id,
237+
args=[instance_id],
238+
task_type=TaskType.COMPUTE,
239+
)
240+
)
241+
242+
for src, dst in edges:
243+
if src in task_ids and dst in task_ids:
244+
workflow.add_dependency(task_ids[src], task_ids[dst])
245+
246+
workflow.makespan = makespan
247+
return workflow
248+
249+
250+
def _pe_of(instance_id: str, node_rows: List[Dict[str, Any]]) -> str:
251+
for node in node_rows:
252+
if node["instance_id"] == instance_id:
253+
return node["pe_id"]
254+
return instance_id.split("@")[0]
255+
256+
257+
def convert(
258+
monitoring_dirs: List[pathlib.Path | str],
259+
output_dir: pathlib.Path | str,
260+
workflow_name: Optional[str] = None,
261+
prefix: str = "monitor",
262+
) -> List[pathlib.Path]:
263+
"""Convert one or more monitoring directories into WfFormat JSON files."""
264+
output_dir = pathlib.Path(output_dir)
265+
output_dir.mkdir(parents=True, exist_ok=True)
266+
267+
written = []
268+
for monitoring_dir in monitoring_dirs:
269+
monitoring_dir = pathlib.Path(monitoring_dir)
270+
mapping = monitoring_dir.name.replace("monitoring_", "") or None
271+
workflow = build_workflow(
272+
monitoring_dir,
273+
workflow_name=workflow_name or monitoring_dir.name,
274+
prefix=prefix,
275+
mapping=mapping,
276+
)
277+
# wfchef groups instances by size, so encode the task count in the name.
278+
out = output_dir / f"{workflow.name}-{len(workflow.tasks)}.json"
279+
workflow.write_json(out)
280+
written.append(out)
281+
logger.info("wrote %s (%d tasks, %d edges)",
282+
out.name, len(workflow.tasks), workflow.number_of_edges())
283+
return written
284+
285+
286+
def main() -> None:
287+
import argparse
288+
289+
parser = argparse.ArgumentParser(description=__doc__)
290+
parser.add_argument("monitoring_dirs", nargs="+", type=pathlib.Path,
291+
help="dispel4py monitoring directories (e.g. timings/ or monitoring_multi/)")
292+
parser.add_argument("-o", "--out", required=True, type=pathlib.Path,
293+
help="directory to write WfFormat JSON instances into")
294+
parser.add_argument("-n", "--name", default=None,
295+
help="workflow name (defaults to each directory's name)")
296+
parser.add_argument("--prefix", default="monitor",
297+
help="dispel4py --timing-prefix used for the run")
298+
parser.add_argument("-v", "--verbose", action="store_true")
299+
args = parser.parse_args()
300+
301+
logging.basicConfig(
302+
level=logging.INFO if args.verbose else logging.WARNING,
303+
format="%(levelname)s %(message)s",
304+
)
305+
for path in convert(args.monitoring_dirs, args.out, args.name, args.prefix):
306+
print(path)
307+
308+
309+
if __name__ == "__main__":
310+
main()

0 commit comments

Comments
 (0)