RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
campaign_tally.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Tally a multi-machine campaign: who has reported what, and is it poolable.
3
4Run this after `git pull` on any participating machine. It answers the three
5questions that matter between pulls:
6
7 1. COVERAGE - how many of the design's rows exist, per machine, against the
8 count that machine OWNS under the frozen weights. A machine that has not
9 pushed is indistinguishable from a machine that has not started, so the
10 table says "no file" rather than "0 done" for the former.
11
12 2. POOLABILITY - whether every reported row carries the same git/solver/
13 workbook/design/run_cfg hashes. A split here means the rows cannot be
14 pooled into one Sobol estimate, and it is silent unless something checks.
15 This reuses merge_and_sobol.check_provenance so the two agree by
16 construction.
17
18 3. THROUGHPUT - completions per hour, computed from the rows themselves.
19 NOT mean wall over completed rows: with W workers running concurrently,
20 mean wall over the rows that HAVE finished is biased low early on, because
21 the slow rows are precisely the ones still running. Every ETA this
22 campaign got wrong was got wrong that way. Here, throughput is
23
24 (rows done) / (elapsed wall on that machine)
25
26 where elapsed comes from the machine's own first-to-last row span plus the
27 wall of the longest row still in flight. It converges from below and
28 cannot flatter itself.
29
30Usage:
31 python campaign_status.py # frozen design in machines.json
32 python campaign_status.py --design design/T3_rev6.csv
33 python campaign_status.py --at-dose 0.1 # ladder view at one rung
34"""
35from __future__ import annotations
36
37import argparse
38import csv
39import json
40import time
41from collections import defaultdict
42from pathlib import Path
43
44HERE = Path(__file__).resolve().parent
45
46import run_ensemble as RE
47from merge_and_sobol import check_provenance
48
49
50def load_by_machine(res_dir: Path, stem: str):
51 """{machine_index: {row_id: rec}}, plus the files each came from.
52
53 Keyed on the machine index recorded IN the row, not on the filename: a
54 file renamed or copied between hosts would otherwise be miscounted, and
55 the subtask suffix (_t3) already makes one machine write many files.
56 """
57 per = defaultdict(dict)
58 files = defaultdict(list)
59 for f in sorted(res_dir.glob(f"{stem}_machine*.jsonl")):
60 n = 0
61 for ln in f.read_text(encoding="utf-8").splitlines():
62 if not ln.strip():
63 continue
64 try:
65 r = json.loads(ln)
66 except json.JSONDecodeError:
67 continue # truncated last line after a kill
68 k = r.get("machine")
69 if k is None: # older rows predate the field
70 k = _machine_from_name(f.name, stem)
71 per[k][r["row_id"]] = r
72 n += 1
73 files[_machine_from_name(f.name, stem)].append((f.name, n))
74 return per, files
75
76
77def load_starts(res_dir: Path, stem: str):
78 """{machine_index: marker} from the .started.json a live worker drops."""
79 out = {}
80 for f in sorted(res_dir.glob(f"{stem}_machine*.started.json")):
81 try:
82 m = json.loads(f.read_text())
83 except Exception:
84 continue
85 k = m.get("machine")
86 if k is None:
87 continue
88 # Many subtasks of one machine each drop a marker; the earliest is the
89 # one that bounds the elapsed wall for all of them.
90 if k not in out or float(m["unix"]) < float(out[k]["unix"]):
91 out[k] = m
92 return out
93
94
95def _machine_from_name(name: str, stem: str) -> int:
96 tail = name[len(stem):].lstrip("_")
97 digits = ""
98 for ch in tail[len("machine"):] if tail.startswith("machine") else "":
99 if ch.isdigit():
100 digits += ch
101 else:
102 break
103 return int(digits) if digits else -1
104
105
106def owned_counts(design: Path, of: int, weights):
107 """rows each machine index is responsible for, and the full row_id set."""
108 ids = []
109 with design.open() as fh:
110 for row in csv.DictReader(fh):
111 ids.append(int(row["row_id"]))
112 own = defaultdict(list)
113 for rid in ids:
114 own[RE.assign_machine(rid, of, weights)].append(rid)
115 return own, set(ids)
116
117
118def _warn_orphaned_output(res_dir: Path, stem: str, here_k):
119 """Is a local worker appending to a file that no longer exists?
120
121 A held file handle names an inode, not a path. Anything that replaces the
122 results file -- `git pull --rebase --autostash` above all, because a live
123 results file is always dirty -- leaves the worker writing to an unlinked
124 inode. It is completely silent: the worker log keeps reporting completed
125 rows while the .jsonl stops growing, and the rows die with the process.
126 That cost 5 rows and 6.2 core-hours on 2026-08-06 before the log and the
127 file were compared by hand.
128
129 run_ensemble no longer holds the handle, so this cannot recur. The check
130 stays because it also catches the same shape from any other cause (a
131 checkout, a stash pop, an editor writing through a temp file), and because
132 the failure gives no other symptom.
133 """
134 if here_k is None:
135 return
136 try:
137 import subprocess as sp
138 pids = sp.run(["pgrep", "-f", "run_ensemble.py"],
139 capture_output=True, text=True).stdout.split()
140 if not pids:
141 return
142 live = res_dir / f"{stem}_machine{here_k}.jsonl"
143 live_ino = live.stat().st_ino if live.exists() else None
144 out = sp.run(["lsof", "-p", ",".join(pids)],
145 capture_output=True, text=True).stdout
146 for ln in out.splitlines():
147 if f"{stem}_machine" not in ln or ".jsonl" not in ln:
148 continue
149 parts = ln.split()
150 held = next((int(p) for p in parts if p.isdigit() and len(p) > 6), None)
151 if held and live_ino and held != live_ino:
152 print(f"\n *** A WORKER IS WRITING TO A FILE THAT NO LONGER EXISTS.")
153 print(f" holds inode {held}, but {live.name} is now inode "
154 f"{live_ino}.")
155 print(f" Every row it completes from here is unrecoverable.")
156 print(f" Restart that worker; rows already in the .jsonl are safe.")
157 except Exception:
158 pass # a diagnostic must never break the tally
159
160
161def fmt_eta(hours):
162 if hours is None:
163 return " -"
164 if hours > 999:
165 return " >999h"
166 return f"{hours:6.1f}h"
167
168
169def main(argv=None):
170 ap = argparse.ArgumentParser(description=__doc__,
171 formatter_class=argparse.RawDescriptionHelpFormatter)
172 ap.add_argument("--design", type=Path, default=None,
173 help="default: the design named in machines.json")
174 ap.add_argument("--registry", type=Path, default=HERE / "machines.json")
175 ap.add_argument("--results", type=Path, default=HERE / "results")
176 ap.add_argument("--at-dose", type=float, default=None,
177 help="also report how many rows reached this dose rung")
178 ap.add_argument("--sync", action="store_true",
179 help="first send THIS machine's results and pull everyone "
180 "else's, then tally (git is the transport)")
181 ap.add_argument("--no-push", action="store_true",
182 help="with --sync: pull and commit, but do not push")
183 a = ap.parse_args(argv)
184
185 if a.sync:
186 import campaign_ops as ops
187 print("sync")
188 try:
189 ops.sync_results(push=not a.no_push)
190 except Exception as e:
191 # A sync failure must not hide the tally: the local rows are still
192 # worth seeing, and "could not reach the remote" is a different
193 # problem from "the campaign is not progressing".
194 print(f" *** sync failed ({e}); tallying LOCAL results only")
195
196 reg = json.loads(a.registry.read_text())
197 design = a.design or (HERE / reg["design"])
198 of = int(reg["of"])
199 weights = [float(x) for x in str(reg["weights"]).split(",")]
200 names = {int(m["index"]): m["name"] for m in reg["machines"]}
201
202 own, all_ids = owned_counts(design, of, weights)
203 per, files = load_by_machine(a.results, design.stem)
204 starts = load_starts(a.results, design.stem)
205
206 # A start marker is only a clock for the machine we are ON. Another
207 # machine's marker arrives over git with ITS unix epoch; subtracting our
208 # local now from it measures the pull latency, not that machine's runtime.
209 # So exact throughput is offered for this host only, and everyone else
210 # falls back to the ceiling estimator -- which the table labels as such.
211 now = time.time()
212 try:
213 here_k = RE.detect_machine(reg)["index"]
214 except SystemExit:
215 here_k = None
216 starts = {k: v for k, v in starts.items() if k == here_k}
217
218 print(f"\ndesign {design.name} {len(all_ids)} rows")
219 print(f"registry {a.registry.name} of={of} weights={reg['weights']} "
220 f"timeout_s={reg.get('timeout_s')}")
221 print()
222
223 hdr = (f"{'#':>2} {'machine':<18} {'owns':>5} {'done':>5} {'%':>6} "
224 f"{'cut':>4} {'rc!=0':>6} {'med_s':>7} {'rows/h':>7} {'ETA':>7} status")
225 print(hdr)
226 print("-" * len(hdr))
227
228 tot_done = tot_owned = 0
229 eta_hours = []
230 for k in range(of):
231 n_own = len(own.get(k, []))
232 tot_owned += n_own
233 recs = per.get(k, {})
234 n_done = len(recs)
235 tot_done += n_done
236
237 if not files.get(k) and not recs:
238 print(f"{k:>2} {names.get(k,'?'):<18} {n_own:>5} {'-':>5} {'-':>6} "
239 f"{'-':>4} {'-':>6} {'-':>7} {'-':>7} {'-':>7} NO FILE - not pushed / not started")
240 continue
241
242 walls = sorted(r.get("wall_s", 0.0) for r in recs.values())
243 med = walls[len(walls) // 2] if walls else 0.0
244 cut = sum(1 for r in recs.values() if r.get("starved"))
245 bad = sum(1 for r in recs.values() if r.get("solver_rc"))
246 nw = max((int(r.get("workers") or 1)) for r in recs.values()) if recs else 1
247
248 # THROUGHPUT. Completions divided by REAL elapsed wall, taken from the
249 # .started.json marker the worker drops at launch. The tempting
250 # alternative -- sum(row_wall)/workers -- counts only rows that have
251 # landed, so early in a run, when the slow rows are still occupying
252 # their workers and contributing to neither numerator nor denominator,
253 # it overstates the rate by however bimodal the cost distribution is.
254 # On this campaign that was better than 2x. Where no marker exists
255 # (a run started before this field, or another machine's file pulled
256 # without it) the rate is printed with a '~' and IS that biased
257 # estimator; treat it as a ceiling, not an estimate.
258 st = starts.get(k)
259 exact = st is not None and now is not None
260 if exact:
261 elapsed_h = max(now - float(st["unix"]), 1.0) / 3600.0
262 base = int(st.get("rows_already_done") or 0)
263 n_this = max(n_done - base, 0) # rows THIS session produced
264 rate = (n_this / elapsed_h) if elapsed_h > 0 else None
265 else:
266 burned_h = (sum(walls) / nw) / 3600.0 if walls else 0.0
267 rate = (n_done / burned_h) if burned_h > 0 else None
268 eta = ((n_own - n_done) / rate) if rate else None
269 if eta is not None and exact:
270 eta_hours.append(eta)
271
272 pct = 100.0 * n_done / n_own if n_own else 0.0
273 note = f"{nw}w"
274 # STARTUP TRANSIENT. For the first couple of row-times the workers are
275 # all still on their first row, so completions/elapsed reads far below
276 # the steady state it will settle to -- the mirror image of the
277 # sum(wall)/workers bias, and just as misleading if quoted as a rate.
278 # Flagged until elapsed covers ~2 median rows, after which the pipeline
279 # is full and the division means what it says.
280 if exact and med > 0 and elapsed_h * 3600.0 < 2.0 * med:
281 note += (f" TRANSIENT: {elapsed_h*3600/med:.1f} row-times in, "
282 f"rate not yet meaningful")
283 if not exact and rate:
284 note += " ~rate: no start marker, CEILING only"
285 if bad:
286 note += f" {bad} FAIL"
287 if n_done >= n_own:
288 note += " COMPLETE"
289 mark = "" if exact else "~"
290 print(f"{k:>2} {names.get(k,'?'):<18} {n_own:>5} {n_done:>5} {pct:>5.1f}% "
291 f"{cut:>4} {bad:>6} {med:>7.0f} "
292 f"{(f'{mark}{rate:.1f}' if rate else '-'):>7} {fmt_eta(eta)} {note}")
293
294 print("-" * len(hdr))
295 print(f"{'':>2} {'TOTAL':<18} {tot_owned:>5} {tot_done:>5} "
296 f"{100.0*tot_done/tot_owned if tot_owned else 0:>5.1f}%")
297
298 _warn_orphaned_output(a.results, design.stem, here_k)
299
300 # Partition sanity: every design row owned exactly once, and nothing
301 # reported that the design does not contain.
302 seen = {}
303 dup = []
304 for k, recs in per.items():
305 for rid in recs:
306 if rid in seen:
307 dup.append((rid, seen[rid], k))
308 seen[rid] = k
309 missing = len(all_ids - set(seen))
310 print(f"\ncoverage {len(seen)}/{len(all_ids)} distinct rows "
311 f"{missing} not yet reported"
312 + (f" *** {len(dup)} row(s) computed by TWO machines" if dup else ""))
313 if dup[:5]:
314 for rid, k1, k2 in dup[:5]:
315 print(f" row {rid}: machine {k1} and machine {k2}")
316
317 pool = {}
318 for recs in per.values():
319 pool.update(recs)
320 if pool:
321 print("\nprovenance")
322 check_provenance(pool)
323 print(" (no output above = all reported rows are poolable)")
324
325 rungs = defaultdict(int)
326 for r in pool.values():
327 for d in (r.get("at_dose") or {}):
328 rungs[float(d)] += 1
329 if rungs:
330 print("\ndose ladder (rows reaching each rung, of "
331 f"{len(pool)} reported)")
332 line = " " + " ".join(f"{d:g}:{rungs[d]}"
333 for d in sorted(rungs))
334 print(line)
335 full = max(rungs) if rungs else 0
336 common = [d for d in sorted(rungs) if rungs[d] == len(pool)]
337 print(f" highest rung ALL reported rows reached: "
338 f"{max(common):g}" if common else
339 " no rung reached by every reported row")
340
341 if a.at_dose is not None:
342 key = f"{a.at_dose:g}"
343 n = sum(1 for r in pool.values() if key in (r.get("at_dose") or {}))
344 print(f"\n at dose {key}: {n}/{len(pool)} reported rows have a value")
345
346 if eta_hours:
347 print(f"\nETA to finish the design: {max(eta_hours):.1f} h "
348 f"(slowest machine; optimistic - excludes rows in flight)")
349 print()
350
351
352if __name__ == "__main__":
353 main()
load_starts(Path res_dir, str stem)
load_by_machine(Path res_dir, str stem)
int _machine_from_name(str name, str stem)
_warn_orphaned_output(Path res_dir, str stem, here_k)
owned_counts(Path design, int of, weights)