Skip to content

Commit d8e288c

Browse files
authored
Merge pull request #5717 from grondo/2024-scaling
test: add some scaling test support
2 parents 3d3faae + 9bcdab0 commit d8e288c

File tree

3 files changed

+456
-13
lines changed

3 files changed

+456
-13
lines changed

src/test/scaling/instancebench.py

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
#!/usr/bin/env python3
2+
##############################################################
3+
# Copyright 2024 Lawrence Livermore National Security, LLC
4+
# (c.f. AUTHORS, NOTICE.LLNS, COPYING)
5+
#
6+
# This file is part of the Flux resource manager framework.
7+
# For details, see https://github.com/flux-framework.
8+
#
9+
# SPDX-License-Identifier: LGPL-3.0
10+
##############################################################
11+
12+
import argparse
13+
import itertools
14+
import math
15+
import os
16+
import sys
17+
import time
18+
19+
import flux
20+
import flux.uri
21+
from flux.job import cancel_async
22+
from flux.resource import resource_list
23+
24+
25+
class InstanceBench:
26+
"""Class representing a single Flux instance bootstrap benchmark"""
27+
28+
def __init__(
29+
self,
30+
flux_handle,
31+
nnodes,
32+
brokers_per_node=1,
33+
topo="kary:2",
34+
conf=None,
35+
progress=None,
36+
exclusive=True,
37+
):
38+
39+
self.flux_handle = flux_handle
40+
self.nnodes = nnodes
41+
self.brokers_per_node = brokers_per_node
42+
self.topo = topo
43+
self.id = None
44+
self.t0 = None
45+
self.t_submit = None
46+
self.t_start = None
47+
self.t_uri = None
48+
self.t_shell_init = None
49+
self.t_ready = None
50+
self.t_finish = None
51+
self.child_handle = None
52+
self.then_cb = None
53+
self.then_args = []
54+
self.then_kw_args = {}
55+
self.progress = progress
56+
self.size = nnodes * brokers_per_node
57+
self.topo = topo
58+
self.name = f"[N:{nnodes:<4d} SZ:{self.size:<4d} {self.topo:<8}]"
59+
60+
broker_opts = ["-Sbroker.rc2_none=1"]
61+
if topo is not None:
62+
broker_opts.append(f"-Stbon.topo={topo}")
63+
if conf is not None:
64+
broker_opts.append("-c{{tmpdir}}/conf.json")
65+
66+
jobspec = flux.job.JobspecV1.from_command(
67+
command=["flux", "broker", *broker_opts],
68+
exclusive=exclusive,
69+
num_nodes=nnodes,
70+
num_tasks=nnodes * brokers_per_node,
71+
)
72+
jobspec.setattr_shell_option("mpi", "none")
73+
if conf is not None:
74+
jobspec.add_file("conf.json", conf)
75+
self.jobspec = jobspec
76+
77+
def log(self, msg):
78+
try:
79+
ts = self.ts or (time.time() - self.t0)
80+
except AttributeError:
81+
ts = 0.0
82+
print(f"{self.name}: {ts:6.3f}s: {msg}", file=sys.stderr, flush=True)
83+
self.ts = None
84+
85+
def then(self, cb, *args, **kw_args):
86+
self.then_cb = cb
87+
self.then_args = args
88+
self.then_kw_args = kw_args
89+
90+
def submit(self):
91+
self.t0 = time.time()
92+
flux.job.submit_async(self.flux_handle, self.jobspec).then(self.submit_cb)
93+
return self
94+
95+
def submit_cb(self, future):
96+
try:
97+
self.id = future.get_id()
98+
except OSError as exc:
99+
print(exc, file=sys.stderr)
100+
return
101+
if self.progress:
102+
job = flux.job.JobInfo(
103+
{
104+
"id": self.id,
105+
"state": flux.constants.FLUX_JOB_STATE_SCHED,
106+
"t_submit": time.time(),
107+
}
108+
)
109+
self.progress.add_job(job)
110+
flux.job.event_watch_async(self.flux_handle, self.id).then(self.bg_wait_cb)
111+
112+
def child_ready_cb(self, future):
113+
future.get()
114+
self.t_ready = time.time()
115+
self.log("ready")
116+
117+
self.size = self.child_handle.attr_get("size")
118+
self.topo = self.child_handle.attr_get("tbon.topo")
119+
120+
# Shutdown and report timing:
121+
self.log("requesting shutdown")
122+
self.child_handle.rpc("shutdown.start", {"loglevel": 1})
123+
124+
def bg_wait_cb(self, future):
125+
event = future.get_event()
126+
if self.progress:
127+
self.progress.process_event(self.id, event)
128+
if not event:
129+
# The job has unexpectedly exited since we're at the end
130+
# of the eventlog. Run `flux job attach` since this will dump
131+
# any errors or output, then raise an exception.
132+
os.system(f"flux job attach {self.id} >&2")
133+
raise OSError(f"{self.id}: unexpectedly exited")
134+
135+
self.ts = event.timestamp - self.t0
136+
# self.log(f"{event.name}")
137+
if event.name == "submit":
138+
self.t_submit = event.timestamp
139+
elif event.name == "alloc":
140+
self.t_alloc = event.timestamp
141+
elif event.name == "start":
142+
self.t_start = event.timestamp
143+
flux.job.event_watch_async(
144+
self.flux_handle, self.id, eventlog="guest.exec.eventlog"
145+
).then(self.shell_init_wait_cb)
146+
elif event.name == "memo" and "uri" in event.context:
147+
self.t_uri = event.timestamp
148+
uri = str(flux.uri.JobURI(event.context["uri"]))
149+
self.log(f"opening handle to {self.id}")
150+
self.child_handle = flux.Flux(uri)
151+
152+
# Set main handle reactor as reactor for his child handle so
153+
# events can be processed:
154+
self.child_handle.flux_set_reactor(self.flux_handle.get_reactor())
155+
156+
self.log("connected to child job")
157+
158+
# Wait for child instance to be ready:
159+
self.child_handle.rpc("state-machine.wait").then(self.child_ready_cb)
160+
161+
elif event.name == "finish":
162+
self.t_finish = event.timestamp
163+
future.cancel(stop=True)
164+
if self.then_cb is not None:
165+
self.then_cb(self, *self.then_args, **self.then_kw_wargs)
166+
if self.progress:
167+
# Notify ProgressBar that this job is done via a None event
168+
self.progress.process_event(self.id, None)
169+
170+
def shell_init_wait_cb(self, future):
171+
event = future.get_event()
172+
if not event:
173+
return
174+
self.ts = event.timestamp - self.t0
175+
self.log(f"exec.{event.name}")
176+
if event.name == "shell.init":
177+
self.t_shell_init = event.timestamp
178+
future.cancel(stop=True)
179+
180+
def timing_header(self, file=sys.stdout):
181+
print(
182+
"%5s %5s %8s %8s %8s %8s %8s %8s %8s"
183+
% (
184+
"NODES",
185+
"SIZE",
186+
"TOPO",
187+
"T_START",
188+
"T_URI",
189+
"T_INIT",
190+
"T_READY",
191+
"(TOTAL)",
192+
"T_SHUTDN",
193+
),
194+
file=file,
195+
)
196+
197+
def report_timing(self, file=sys.stdout):
198+
# Only report instances that got to the ready state
199+
if not self.t_ready:
200+
return
201+
202+
# Avoid error if t_shutdown is None
203+
if not self.t_finish:
204+
t_shutdown = " -"
205+
else:
206+
t_shutdown = f"{self.t_finish - self.t_ready:8.3f}"
207+
208+
print(
209+
"%5s %5s %8s %8.3f %8.3f %8.3f %8.3f %8.3f %s"
210+
% (
211+
self.nnodes,
212+
self.size,
213+
self.topo,
214+
self.t_start - self.t_alloc,
215+
self.t_uri - self.t_start,
216+
self.t_shell_init - self.t_alloc,
217+
self.t_ready - self.t_shell_init,
218+
self.t_ready - self.t_alloc,
219+
t_shutdown,
220+
),
221+
file=file,
222+
)
223+
224+
225+
def generate_values(end):
226+
"""
227+
Generate a list of powers of 2 (including `1` by default), up to and
228+
including `end`. If `end` is not a power of 2 insert it as the last
229+
element in list to ensure it is present.
230+
The list is returned in reverse order (largest values first)
231+
"""
232+
stop = int(math.log2(end)) + 1
233+
values = [1 << i for i in range(stop)]
234+
if end not in values:
235+
values.append(end)
236+
values.reverse()
237+
return values
238+
239+
240+
def parse_args():
241+
parser = argparse.ArgumentParser(
242+
prog="instance-timing", formatter_class=flux.util.help_formatter()
243+
)
244+
parser.add_argument(
245+
"-N",
246+
"--max-nodes",
247+
metavar="N",
248+
type=int,
249+
default=None,
250+
help="Scale up to N nodes by powers of two",
251+
)
252+
parser.add_argument(
253+
"-B",
254+
"--max-brokers-per-node",
255+
type=int,
256+
metavar="N",
257+
default=1,
258+
help="Run powers of 2 brokers-per-node up to N",
259+
)
260+
parser.add_argument(
261+
"--topo",
262+
metavar="TOPO,...",
263+
type=str,
264+
default="kary:2",
265+
help="add one or more tbon.topo values to test",
266+
)
267+
parser.add_argument(
268+
"--non-exclusive",
269+
action="store_true",
270+
help="Do not set exclusive flag on submitted jobs",
271+
)
272+
parser.add_argument(
273+
"-L",
274+
"--log-file",
275+
metavar="FILE",
276+
help="log results to FILE in addition to stdout",
277+
)
278+
return parser.parse_args()
279+
280+
281+
def get_max_nnodes(flux_handle):
282+
"""
283+
Get the maximum nodes available in the default queue or anonymous
284+
queue if there are no queues configured.
285+
"""
286+
resources = resource_list(flux.Flux()).get()
287+
try:
288+
config = flux_handle.rpc("config.get").get()
289+
defaultq = config["policy"]["jobspec"]["defaults"]["system"]["queue"]
290+
constraint = config["queues"][defaultq]["requires"]
291+
avail = resources["up"].copy_constraint({"properties": constraint})
292+
except KeyError:
293+
avail = resources["up"]
294+
return avail.nnodes
295+
296+
297+
def print_results(instances, ofile=sys.stdout):
298+
instances[0].timing_header(ofile)
299+
for ib in instances:
300+
ib.report_timing(ofile)
301+
302+
303+
def try_cancel_all(handle, instances):
304+
n = 0
305+
for ib in instances:
306+
if not ib.t_finish:
307+
n += 1
308+
ib.log("canceling job")
309+
cancel_async(handle, ib.id)
310+
print(f"canceled {n} jobs", file=sys.stderr)
311+
312+
313+
def main():
314+
args = parse_args()
315+
args.topo = args.topo.split(",")
316+
exclusive = not args.non_exclusive
317+
318+
h = flux.Flux()
319+
if not args.max_nodes:
320+
args.max_nodes = get_max_nnodes(h)
321+
322+
nnodes = generate_values(args.max_nodes)
323+
bpn = generate_values(args.max_brokers_per_node)
324+
325+
inputs = list(itertools.product(nnodes, bpn, args.topo))
326+
inputs.sort(key=lambda x: x[0] * x[1])
327+
inputs.reverse()
328+
329+
progress = flux.job.watcher.JobProgressBar(h)
330+
progress.start()
331+
instances = []
332+
for i in inputs:
333+
instances.append(
334+
InstanceBench(
335+
h,
336+
i[0],
337+
brokers_per_node=i[1],
338+
topo=i[2],
339+
progress=progress,
340+
exclusive=exclusive,
341+
).submit()
342+
)
343+
344+
try:
345+
h.reactor_run()
346+
except (KeyboardInterrupt, Exception):
347+
# Cancel all remaining jobs and print available results instead
348+
# of just exiting on exception
349+
try_cancel_all(h, instances)
350+
351+
print_results(instances)
352+
if args.log_file:
353+
with open(args.log_file, "w") as ofile:
354+
print_results(instances, ofile=ofile)
355+
356+
357+
if __name__ == "__main__":
358+
main()
359+
360+
# vi: ts=4 sw=4 expandtab

0 commit comments

Comments
 (0)