RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
campaign_layout.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""campaign_layout - size a heterogeneous campaign and emit the exact commands.
3
4WHY THIS EXISTS
5---------------
6run_ensemble already takes --weights (Sainte-Lague, interleaved). What it does
7NOT do is tell you what the weights should be, and getting them wrong is not
8cosmetic: the v2 campaign split 1104 rows evenly over four machines whose real
9throughput differed by ~3x, so the fast machines idled while the slow one held
10a quarter of the design. Worse, --weights MUST be byte-identical on every
11participant -- a typo on one machine silently gives two machines the same rows
12and leaves a hole somewhere else.
13
14So the layout is computed ONCE, from measured throughput, written to
15campaign_layout.json, and every participant is handed a command line generated
16from that one file.
17
18THROUGHPUT, NOT WALL TIME PER ROW
19---------------------------------
20The obvious measure -- mean wall_s per row -- is wrong under oversubscription.
21A machine running 16 workers on 4 cores shows a 4x inflated wall_s per row while
22its throughput is unchanged, because wall_s is elapsed time, not CPU time. The
23v2 pool shows exactly this ambiguity: MATRIX-PC2 looks 12-15x slower than the
24Mac on the same three design rows, but only 2.7x slower per row averaged over
25its whole set. Those cannot both be capacity.
26
27The measure that is invariant to oversubscription is
28
29 throughput = rows_completed / session_wall_s [rows/second]
30
31which is what this module uses, read from each machine's manifest. A machine
32with no manifest cannot be measured and must declare its capacity instead --
33loudly, so an assumption is never mistaken for a measurement.
34
35 python campaign_layout.py --results results/ \
36 --declare hoffman2=64 --declare MATRIX-PC2=4
37"""
38from __future__ import annotations
39
40import argparse
41import glob
42import json
43import os
44from pathlib import Path
45
46HERE = Path(__file__).resolve().parent
47
48
49def read_manifests(res_dir: Path) -> dict:
50 """Measured throughput per participant, from the manifests they wrote."""
51 out = {}
52 for f in sorted(glob.glob(str(res_dir / "*.manifest.json"))):
53 try:
54 m = json.loads(Path(f).read_text(encoding="utf-8"))
55 except Exception:
56 continue
57 mach, tim = m.get("machine", {}), m.get("timing", {})
58 rows = m.get("rows", {})
59 done = rows.get("completed") or 0
60 wall = tim.get("session_wall_s") or 0
61 if not done or not wall:
62 continue
63 out[int(mach.get("index", -1))] = {
64 "machine_id": mach.get("machine_id", "?"),
65 "workers": mach.get("workers"),
66 "cpu_count": mach.get("cpu_count"),
67 "completed": done,
68 "session_wall_s": wall,
69 "rows_per_hour": 3600.0 * done / wall,
70 "core_s_per_row": tim.get("row_wall_mean_s"),
71 "source": "MEASURED",
72 }
73 return out
74
75
76def main(argv=None):
77 ap = argparse.ArgumentParser(
78 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
79 ap.add_argument("--results", type=Path, default=HERE / "results")
80 ap.add_argument("--design", default="design/T3_rev6.csv")
81 ap.add_argument("--participant", action="append", default=[],
82 help="name:slots -- one per participant, IN ORDER. 'slots' is "
83 "the number of concurrent single-core rows it can run. "
84 "Repeat for each. Example: --participant mac:14")
85 ap.add_argument("--speed", action="append", default=[],
86 help="name:factor -- per-CORE speed relative to the reference "
87 "machine (default 1.0). Use when a participant's cores "
88 "are not the same speed, NOT to describe core count.")
89 ap.add_argument("--split", action="append", default=[],
90 help="name:K -- expand this participant into K machine indices "
91 "of equal weight, for a scheduler ARRAY JOB. Each index "
92 "is an independent worker with its own results file, so "
93 "a task that is killed at the walltime limit costs only "
94 "its own in-flight rows. Required for a cluster: one "
95 "64-core index means one 64-core job, which queues badly "
96 "and dies whole; 16 x 4-core tasks queue fast and die "
97 "one at a time.")
98 ap.add_argument("--out", type=Path, default=HERE / "campaign_layout.json")
99 ap.add_argument("--n-rows", type=int, default=1008)
100 ap.add_argument("--row-cost-s", type=float, default=3780.0,
101 help="MEAN measured seconds per row on the REFERENCE "
102 "machine. Default is the rev-6 baseline grid "
103 "(I=30000, V=5000, 1 dpa) on the M3 Max, measured "
104 "2026-08-05 over 3 design rows: 2642 / 3420 / 5276 s, "
105 "mean 3780. The I=50000/V=10000 grid it replaced "
106 "measured 5580.6 s on the middle row.")
107 a = ap.parse_args(argv)
108
109 meas = read_manifests(a.results)
110 if meas:
111 print("MEASURED throughput from manifests (rows completed / session wall):")
112 for k, v in sorted(meas.items()):
113 print(f" machine {k} {v['machine_id']:16s} "
114 f"{v['workers']}w/{v['cpu_count']}c "
115 f"{v['completed']:4d} rows in {v['session_wall_s']/3600:6.2f} h "
116 f"= {v['rows_per_hour']:6.2f} rows/h "
117 f"({v['rows_per_hour']/max(v['workers'] or 1,1):.2f} per worker)")
118 else:
119 print("no manifests found -- every capacity below is DECLARED, not measured")
120 print()
121
122 if not a.participant:
123 ap.error("give at least one --participant name:slots")
124
125 speeds = {}
126 for s in a.speed:
127 n, _, f = s.partition(":")
128 speeds[n] = float(f)
129
130 splits = {}
131 for s in a.split:
132 n, _, k = s.partition(":")
133 splits[n] = int(k)
134
135 parts = []
136 for p in a.participant:
137 name, _, slots = p.partition(":")
138 slots = int(slots)
139 sp = speeds.get(name, 1.0)
140 k = splits.get(name, 1)
141 if k < 1:
142 ap.error(f"--split {name}:{k} must be >= 1")
143 if slots % k:
144 ap.error(f"--split {name}:{k} does not divide {slots} slots evenly; "
145 f"pick a K that divides {slots}")
146 per = slots // k
147 for i in range(k):
148 parts.append({"name": f"{name}[{i+1}/{k}]" if k > 1 else name,
149 "group": name, "task": i + 1, "n_tasks": k,
150 "slots": per, "speed": sp,
151 # 2 dp, not 4. The weight string must be BYTE-
152 # IDENTICAL to machines.json, and 64*0.214 = 13.696
153 # vs 13.7 is a different Sainte-Lague partition --
154 # two files disagreeing in the 4th decimal would send
155 # the same row to two different machines.
156 "weight": round(per * sp, 2)})
157
158 total_w = sum(p["weight"] for p in parts)
159 # Wall-clock estimate: the campaign finishes when the LAST participant does,
160 # and weights are chosen precisely so that is all of them at once.
161 total_core_s = a.n_rows * a.row_cost_s
162 eta_h = total_core_s / (total_w * 3600.0) if total_w else float("inf")
163
164 print(f"{'participant':>16s} {'slots':>6s} {'speed':>6s} {'weight':>8s} "
165 f"{'share':>7s} {'rows':>6s} {'ETA h':>7s}")
166 for p in parts:
167 share = p["weight"] / total_w
168 rows = share * a.n_rows
169 print(f"{p['name']:>16s} {p['slots']:6d} {p['speed']:6.2f} {p['weight']:8.2f} "
170 f"{100*share:6.1f}% {rows:6.0f} {rows*a.row_cost_s/(p['slots']*p['speed']*3600):7.1f}")
171 wstr = ",".join(str(p["weight"]) for p in parts)
172 print(f"\n total capacity {total_w:.2f} slot-equivalents; "
173 f"{a.n_rows} rows x {a.row_cost_s/3600:.2f} h -> ETA {eta_h:.1f} h "
174 f"({eta_h/24:.1f} days) if all participants run continuously")
175
176 layout = {"design": a.design, "of": len(parts), "weights": wstr,
177 "participants": parts, "n_rows": a.n_rows,
178 "row_cost_s": a.row_cost_s, "eta_hours": round(eta_h, 2),
179 "measured": {str(k): v for k, v in meas.items()}}
180 a.out.write_text(json.dumps(layout, indent=2), encoding="utf-8")
181 print(f"\n wrote {a.out}")
182
183 print("\n" + "=" * 78)
184 print(" COMMANDS -- --weights and --of MUST be byte-identical on every")
185 print(" participant. Copy from here; do not retype. A single typo gives two")
186 print(" machines the same rows and leaves a hole somewhere else.")
187 print("=" * 78)
188 # Rev-6 BASELINE grid, fixed by the author 2026-08-05 (plan S11(q)). No
189 # longer "<SET_BY_GATE_i3>": with truncation withdrawn as an admissibility
190 # criterion there is no convergence gate left to set it, so it is a declared
191 # constant sized off the measured EUROFER97 band (2.27x the <100> mean,
192 # 2.16x the cavity mean) and identical on every participant.
193 # Read from machines.json["grid"] -- the ONE source of the frozen
194 # grid. Never duplicate it here; a second copy is a second thing to
195 # forget when it changes.
196 _G = json.loads((HERE / "machines.json").read_text())["grid"]
197 phys = " ".join([
198 f'--equations {_G["equations"]} --I {_G["I"]} --V {_G["V"]}',
199 f'--i-discrete {_G["i_discrete"]} --v-discrete {_G["v_discrete"]}',
200 f'--i-bin {_G["i_bin"]} --v-bin {_G["v_bin"]}',
201 f'--i-mobile-default {_G["i_mobile_default"]}',
202 f'--v-mobile-default {_G["v_mobile_default"]}',
203 f'--dose {_G["dose"]} --rtol {_G["rtol"]} --solver-mode {_G["solver_mode"]}'])
204 seen = set()
205 for k, p in enumerate(parts):
206 grp = p.get("group", p["name"])
207 if p.get("n_tasks", 1) > 1:
208 if grp in seen:
209 continue
210 seen.add(grp)
211 base = k # index of task 1 of this group
212 print(f"\n# {grp}: ARRAY JOB, {p['n_tasks']} tasks x {p['slots']} core(s), "
213 f"machine indices {base}..{base + p['n_tasks'] - 1}")
214 print(f"# in the job script, with SGE_TASK_ID running 1..{p['n_tasks']}:")
215 print(f"MACHINE=$(( {base} + SGE_TASK_ID - 1 ))")
216 print(f"python run_ensemble.py --design {a.design} \\")
217 print(f" --machine $MACHINE --of {len(parts)} --weights {wstr} \\")
218 print(f" --workers {p['slots']} \\")
219 print(f" {phys} \\")
220 print(f" --timeout-s <p99_HERE> --stop-after-s <h_rt MINUS one row>")
221 else:
222 print(f"\n# {p['name']} (machine {k} of {len(parts)}, weight {p['weight']})")
223 print(f"python run_ensemble.py --design {a.design} \\")
224 print(f" --machine {k} --of {len(parts)} --weights {wstr} \\")
225 print(f" --workers {p['slots']} \\")
226 print(f" {phys} \\")
227 print(f" --timeout-s <p99_ON_THIS_MACHINE>")
228 return 0
229
230
231if __name__ == "__main__":
232 raise SystemExit(main())
dict read_manifests(Path res_dir)