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 "
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)
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)")
119 print(
"no manifests found -- every capacity below is DECLARED, not measured")
122 if not a.participant:
123 ap.error(
"give at least one --participant name:slots")
127 n, _, f = s.partition(
":")
132 n, _, k = s.partition(
":")
136 for p
in a.participant:
137 name, _, slots = p.partition(
":")
139 sp = speeds.get(name, 1.0)
140 k = splits.get(name, 1)
142 ap.error(f
"--split {name}:{k} must be >= 1")
144 ap.error(f
"--split {name}:{k} does not divide {slots} slots evenly; "
145 f
"pick a K that divides {slots}")
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,
156 "weight": round(per * sp, 2)})
158 total_w = sum(p[
"weight"]
for p
in parts)
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")
164 print(f
"{'participant':>16s} {'slots':>6s} {'speed':>6s} {'weight':>8s} "
165 f
"{'share':>7s} {'rows':>6s} {'ETA h':>7s}")
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")
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}")
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.")
196 _G = json.loads((HERE /
"machines.json").read_text())[
"grid"]
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"]}'])
205 for k, p
in enumerate(parts):
206 grp = p.get(
"group", p[
"name"])
207 if p.get(
"n_tasks", 1) > 1:
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']} \\")
220 print(f
" --timeout-s <p99_HERE> --stop-after-s <h_rt MINUS one row>")
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']} \\")
227 print(f
" --timeout-s <p99_ON_THIS_MACHINE>")