Skip to content

Commit a254055

Browse files
Fixes dispel4py recipe creation
1 parent b047c32 commit a254055

13 files changed

Lines changed: 1068 additions & 46 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ dependencies = [
3939
"pathos",
4040
"tomli-w",
4141
"python-dotenv",
42+
"openai>=1.0",
43+
"pydantic>=2",
4244
]
4345
dynamic = ["version", "entry-points", "scripts"]
4446

wfcommons/wfbench/translator/llm_translator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ def _default_output_filename(self) -> str:
317317
"radical_pilot": "run_workflow.py",
318318
"rhapsody": "run_workflow.py",
319319
"rose": "run_workflow.py",
320+
"dispel4py": "run_workflow.py",
320321
}
321322
return extensions.get(self.target_system, f"workflow.{self.target_system}")
322323

wfcommons/wfbench/translator/skills/forward/dispel4py.md

Lines changed: 366 additions & 0 deletions
Large diffs are not rendered by default.

wfcommons/wfchef/dispel_to_wfformat.py

Lines changed: 196 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@
77
88
Granularity: 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+
1027
Inputs 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)
@@ -22,11 +39,20 @@
2239
import re
2340
from typing import Any, Dict, List, Optional, Tuple
2441

42+
from wfcommons.common.file import File
2543
from wfcommons.common.task import Task, TaskType
2644
from wfcommons.common.workflow import Workflow
2745

2846
logger = 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+
77118
def _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+
94184
def _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+
257422
def 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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
,monitoring_multi-16
2-
monitoring_simple-7,0.11415581486979587
3-
monitoring_multi-16,0.0
1+
,monitoring_multi_16-13,monitoring_multi_32-31
2+
monitoring_simple-7,0.09428090415820635,0.11670929301304388
3+
monitoring_multi_16-13,0.0,0.04517309045454121
4+
monitoring_multi_32-31,,0.0

0 commit comments

Comments
 (0)