77
88Granularity: one WfFormat task per PE *instance* (``pe_id@rank``).
99
10+ dispel4py is a streaming system: PEs exchange in-memory data over named
11+ connections rather than files. Each connection name recorded in the trace
12+ (``graph.connect(pe, "output", other, "input")``) becomes a WfFormat "file"
13+ entry, one per *producing* (instance, output connection) pair -- a PE instance
14+ writing to one of its output ports produces exactly one stream, which every
15+ downstream instance wired to that port consumes. That matches dispel4py's
16+ default ShuffleCommunication, where the ranks of the destination PE pull items
17+ off one shared stream rather than each receiving a copy.
18+
19+ Streams carry ``sizeInBytes: 0``: dispel4py's monitoring records timings and
20+ item counts, never data volumes, so any other size would be invented.
21+
22+ Only the middle of the workflow streams, though -- the first PE still reads a
23+ real file and the last one still writes one. Those paths never reach the trace
24+ (they arrive as dispel4py root inputs or module constants), so name them with
25+ ``--input-file`` / ``--output-file``; their sizes are read off disk.
26+
1027Inputs read from a monitoring directory:
1128 monitor_concrete_shape_run<id>.json instance-level DAG (nodes + edges)
1229 monitor_shape_run<id>.json abstract PE-level DAG (fallback for edges)
2239import re
2340from typing import Any , Dict , List , Optional , Tuple
2441
42+ from wfcommons .common .file import File
2543from wfcommons .common .task import Task , TaskType
2644from wfcommons .common .workflow import Workflow
2745
2846logger = logging .getLogger (__name__ )
2947
48+ # Connection names to assume when a trace predates the *_connection fields.
49+ _DEFAULT_OUT_CONNECTION = "output"
50+ _DEFAULT_IN_CONNECTION = "input"
51+
52+ # An instance edge, carrying the connection (stream) names at both ends:
53+ # (source instance, destination instance, source port, destination port).
54+ InstanceEdge = Tuple [str , str , str , str ]
55+
3056# dispel4py writes run ids as a compact ISO-ish stamp, e.g. 20260916T183613549171Z
3157_RUN_ID_RE = re .compile (r"_run(?P<run_id>[^.]+)\.(?:json|csv|png)$" )
3258
@@ -74,23 +100,87 @@ def _read_instances(path: pathlib.Path) -> Dict[str, Dict[str, Any]]:
74100 return rows
75101
76102
103+ def _connections (edge : Dict [str , Any ]) -> Tuple [str , str ]:
104+ """Return an edge's (source port, destination port) connection names."""
105+ return (edge .get ("from_connection" ) or _DEFAULT_OUT_CONNECTION ,
106+ edge .get ("to_connection" ) or _DEFAULT_IN_CONNECTION )
107+
108+
109+ def _concrete_edges (concrete : Dict [str , Any ]) -> List [InstanceEdge ]:
110+ """Read instance-level edges, with their port names, off the concrete shape."""
111+ edges = []
112+ for edge in concrete .get ("edges" , []):
113+ from_connection , to_connection = _connections (edge )
114+ edges .append ((edge ["from" ], edge ["to" ], from_connection , to_connection ))
115+ return sorted (set (edges ))
116+
117+
77118def _expand_abstract_edges (
78119 abstract : Dict [str , Any ],
79120 instances_by_pe : Dict [str , List [str ]],
80- ) -> List [Tuple [ str , str ] ]:
121+ ) -> List [InstanceEdge ]:
81122 """
82123 Fall back to the abstract shape: connect every instance of the source PE to
83124 every instance of the destination PE (dispel4py's default all-to-all
84- grouping). Used only when the concrete shape carries no edges.
125+ grouping). Used only when the concrete shape carries no edges. Port names
126+ are PE-level, so they survive the expansion unchanged.
85127 """
86128 edges = []
87129 for edge in abstract .get ("edges" , []):
130+ from_connection , to_connection = _connections (edge )
88131 for src in instances_by_pe .get (edge ["from" ], []):
89132 for dst in instances_by_pe .get (edge ["to" ], []):
90- edges .append ((src , dst ))
133+ edges .append ((src , dst , from_connection , to_connection ))
91134 return sorted (set (edges ))
92135
93136
137+ def _data_file (name : str , monitoring_dir : pathlib .Path ) -> File :
138+ """
139+ Resolve a real on-disk file the workflow reads or writes.
140+
141+ dispel4py never records these in the trace -- the path reaches the PE as a
142+ root input or a module constant -- so the caller names them. Sizes are read
143+ off disk, looking beside the monitoring directory as well, which is where
144+ dispel4py leaves them when it runs from the workflow's directory.
145+ """
146+ given = pathlib .Path (name )
147+ for candidate in (given , monitoring_dir / given , monitoring_dir .parent / given ):
148+ if candidate .is_file ():
149+ return File (file_id = given .name , size = candidate .stat ().st_size )
150+ logger .warning ("%s not found on disk; recording it with size 0" , name )
151+ return File (file_id = given .name , size = 0 )
152+
153+
154+ def _stream_id (instance_id : str , connection : str ) -> str :
155+ """
156+ Name the in-memory stream a PE instance writes to one of its output ports.
157+
158+ WfFormat file ids must match ``^[0-9a-zA-Z-_./:#]*$``, which excludes the
159+ "@" dispel4py uses between PE and rank, so ``read0@0`` becomes ``read0:0``.
160+ """
161+ return f"{ instance_id .replace ('@' , ':' )} .{ connection } "
162+
163+
164+ def _build_streams (edges : List [InstanceEdge ]) -> Dict [str , Dict [str , Any ]]:
165+ """
166+ Map each stream id to its single producing instance and its consumers.
167+
168+ One stream per (producer, output port): a fan-out across the destination
169+ PE's ranks is one stream with several consumers, not one stream per rank.
170+ Keeping a single producer per id also matches what WfCommons' translators
171+ assume -- they key their file maps by file id, so a second producer would
172+ silently overwrite the first.
173+ """
174+ streams : Dict [str , Dict [str , Any ]] = {}
175+ for src , dst , from_connection , _ in edges :
176+ stream = streams .setdefault (
177+ _stream_id (src , from_connection ), {"producer" : src , "consumers" : []}
178+ )
179+ if dst not in stream ["consumers" ]:
180+ stream ["consumers" ].append (dst )
181+ return streams
182+
183+
94184def _break_cycles (edges : List [Tuple [str , str ]], order : List [str ]) -> List [Tuple [str , str ]]:
95185 """
96186 WfFormat requires a DAG, but dispel4py graphs may contain feedback loops.
@@ -127,6 +217,8 @@ def build_workflow(
127217 prefix : str = "monitor" ,
128218 run_id : Optional [str ] = None ,
129219 mapping : Optional [str ] = None ,
220+ input_files : Optional [List [str ]] = None ,
221+ output_files : Optional [List [str ]] = None ,
130222) -> Workflow :
131223 """
132224 Build a WfFormat Workflow from one dispel4py monitoring run.
@@ -137,6 +229,13 @@ def build_workflow(
137229 :param run_id: which run to convert; defaults to the only/latest one present.
138230 :param mapping: dispel4py mapping that produced the trace, recorded as the
139231 runtime system version (e.g. "timed_multi").
232+ :param input_files: real files the workflow reads (e.g.
233+ "sensor_data_parallel_100.json"), attached to every
234+ source task. The trace does not record them: the path
235+ reaches the reading PE as a dispel4py root input.
236+ :param output_files: real files the workflow writes (e.g.
237+ "agentic_parallel_results.jsonl"), attached to every
238+ sink task.
140239 """
141240 monitoring_dir = pathlib .Path (monitoring_dir )
142241 if not monitoring_dir .is_dir ():
@@ -178,16 +277,58 @@ def build_workflow(
178277 instances_by_pe .setdefault (node ["pe_id" ], []).append (node ["instance_id" ])
179278
180279 # --- edges -------------------------------------------------------------
181- concrete_edges = [(e ["from" ], e ["to" ]) for e in concrete .get ("edges" , [])]
182- if concrete_edges :
183- edges = sorted (set (concrete_edges ))
280+ instance_edges = _concrete_edges (concrete )
281+ if instance_edges :
184282 edge_source = "concrete shape"
185283 else :
186- edges = _expand_abstract_edges (abstract , instances_by_pe )
284+ instance_edges = _expand_abstract_edges (abstract , instances_by_pe )
187285 edge_source = "abstract shape (expanded across ranks)"
188286 logger .info ("instance edges derived from %s" , edge_source )
189287
190- edges = _break_cycles (edges , concrete .get ("topological_order" , []))
288+ edges = _break_cycles (
289+ sorted ({(src , dst ) for src , dst , _ , _ in instance_edges }),
290+ concrete .get ("topological_order" , []),
291+ )
292+ # Streams follow the dependencies that survived cycle breaking, so a dropped
293+ # back-edge takes its stream with it.
294+ kept = set (edges )
295+ streams = _build_streams (
296+ [edge for edge in instance_edges if (edge [0 ], edge [1 ]) in kept ]
297+ )
298+ logger .info ("%d in-memory stream(s) named from connection names" , len (streams ))
299+
300+ # Streams are shared objects: one File per id, so the producer and every
301+ # consumer reference the same entry in the workflow's file table.
302+ stream_files = {
303+ stream_id : File (file_id = stream_id , size = 0 ) for stream_id in streams
304+ }
305+ outputs_of : Dict [str , List [File ]] = {}
306+ inputs_of : Dict [str , List [File ]] = {}
307+ for stream_id , stream in sorted (streams .items ()):
308+ outputs_of .setdefault (stream ["producer" ], []).append (stream_files [stream_id ])
309+ for consumer in stream ["consumers" ]:
310+ inputs_of .setdefault (consumer , []).append (stream_files [stream_id ])
311+
312+ # --- real files --------------------------------------------------------
313+ # Streaming is only the middle of the workflow: the first PE still reads a
314+ # file off disk and the last one still writes one. Sources and sinks are
315+ # whatever the streams left unconnected, computed before the real files are
316+ # attached so they do not mask each other.
317+ all_instances = [node ["instance_id" ] for node in node_rows ]
318+ sources = [i for i in all_instances if not inputs_of .get (i )]
319+ sinks = [i for i in all_instances if not outputs_of .get (i )]
320+ for name in input_files or []:
321+ data_file = _data_file (name , monitoring_dir )
322+ logger .info ("%s (%d bytes) read by %s" ,
323+ data_file .file_id , data_file .size , ", " .join (sources ))
324+ for instance_id in sources :
325+ inputs_of .setdefault (instance_id , []).append (data_file )
326+ for name in output_files or []:
327+ data_file = _data_file (name , monitoring_dir )
328+ logger .info ("%s (%d bytes) written by %s" ,
329+ data_file .file_id , data_file .size , ", " .join (sinks ))
330+ for instance_id in sinks :
331+ outputs_of .setdefault (instance_id , []).append (data_file )
191332
192333 # --- workflow ----------------------------------------------------------
193334 workflow = Workflow (
@@ -236,6 +377,8 @@ def build_workflow(
236377 program = pe_id ,
237378 args = [instance_id ],
238379 task_type = TaskType .COMPUTE ,
380+ input_files = inputs_of .get (instance_id , []),
381+ output_files = outputs_of .get (instance_id , []),
239382 )
240383 )
241384
@@ -254,13 +397,43 @@ def _pe_of(instance_id: str, node_rows: List[Dict[str, Any]]) -> str:
254397 return instance_id .split ("@" )[0 ]
255398
256399
400+ def _files_for (
401+ spec : Optional [List [str ] | Dict [str , List [str ]]],
402+ monitoring_dir : pathlib .Path ,
403+ ) -> List [str ]:
404+ """
405+ Pick one directory's real input (or output) files out of ``spec``.
406+
407+ A plain list applies to every directory being converted; a dict keyed by
408+ directory name or path lets runs of the same workflow over different data
409+ declare their own (monitoring_simple reads sensor_data_agentic.json while
410+ monitoring_multi reads sensor_data_parallel_100.json).
411+ """
412+ if not spec :
413+ return []
414+ if isinstance (spec , dict ):
415+ for key in (monitoring_dir .name , str (monitoring_dir )):
416+ if key in spec :
417+ return spec [key ]
418+ return []
419+ return spec
420+
421+
257422def convert (
258423 monitoring_dirs : List [pathlib .Path | str ],
259424 output_dir : pathlib .Path | str ,
260425 workflow_name : Optional [str ] = None ,
261426 prefix : str = "monitor" ,
427+ input_files : Optional [List [str ] | Dict [str , List [str ]]] = None ,
428+ output_files : Optional [List [str ] | Dict [str , List [str ]]] = None ,
262429) -> List [pathlib .Path ]:
263- """Convert one or more monitoring directories into WfFormat JSON files."""
430+ """
431+ Convert one or more monitoring directories into WfFormat JSON files.
432+
433+ :param input_files: real files the workflows read, either as a list applied
434+ to every directory or as a {directory: [files]} dict.
435+ :param output_files: real files the workflows write, same forms.
436+ """
264437 output_dir = pathlib .Path (output_dir )
265438 output_dir .mkdir (parents = True , exist_ok = True )
266439
@@ -273,6 +446,8 @@ def convert(
273446 workflow_name = workflow_name or monitoring_dir .name ,
274447 prefix = prefix ,
275448 mapping = mapping ,
449+ input_files = _files_for (input_files , monitoring_dir ),
450+ output_files = _files_for (output_files , monitoring_dir ),
276451 )
277452 # wfchef groups instances by size, so encode the task count in the name.
278453 out = output_dir / f"{ workflow .name } -{ len (workflow .tasks )} .json"
@@ -295,14 +470,25 @@ def main() -> None:
295470 help = "workflow name (defaults to each directory's name)" )
296471 parser .add_argument ("--prefix" , default = "monitor" ,
297472 help = "dispel4py --timing-prefix used for the run" )
473+ parser .add_argument ("-i" , "--input-file" , action = "append" , dest = "input_files" ,
474+ metavar = "FILE" ,
475+ help = "real file the workflow reads, attached to every source "
476+ "task (repeatable). Not recorded in the trace, so it has "
477+ "to be named here, e.g. sensor_data_parallel_100.json" )
478+ parser .add_argument ("--output-file" , action = "append" , dest = "output_files" ,
479+ metavar = "FILE" ,
480+ help = "real file the workflow writes, attached to every sink "
481+ "task (repeatable), e.g. agentic_parallel_results.jsonl" )
298482 parser .add_argument ("-v" , "--verbose" , action = "store_true" )
299483 args = parser .parse_args ()
300484
301485 logging .basicConfig (
302486 level = logging .INFO if args .verbose else logging .WARNING ,
303487 format = "%(levelname)s %(message)s" ,
304488 )
305- for path in convert (args .monitoring_dirs , args .out , args .name , args .prefix ):
489+ for path in convert (args .monitoring_dirs , args .out , args .name , args .prefix ,
490+ input_files = args .input_files ,
491+ output_files = args .output_files ):
306492 print (path )
307493
308494
0 commit comments