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