Files
app/backend/scripts/bench_startup.py
T
stroblmeandClaude Opus 5 6ff56533f5 Schedule a node across every machine, not just this one
The engine answered "where does this node run" twice, in two ways that could
not see each other: a device sent it to a worker carrying that label, and
resources were counted against the engine's own cores. Declaring both meant the
second answer won and nothing was counted at all — which the data-science
getting-started page and the worked example both do.

One question now, in flow/placement.py: of every machine attached, which could
grant what this node asked for, and which of those has it free. The books move
onto each machine — one accountant per worker, built from the inventory it
reported — and the waiting moves above them, where one condition variable can
be woken by a release anywhere or by a worker attaching. Locks go one way:
placer, then a machine's books, never back.

So a node asking for a card now finds the box that has one, rather than being
clamped down to none and run here. When nothing can grant the ask at all it is
still cut down and run — a flow written on a cluster has to work on a laptop —
but the ceiling is one real machine now, since taking the largest of each
dimension separately can describe a machine nobody has.

Two things fixed on the way. A device on a connector node held every batch run
of its flow forever, waiting for a worker that could never run an entry point.
And `prefer` falling back to the engine skipped the books, so the fallback held
nothing.

The bench flow's node has taken a `params` argument that with_settings has not
forwarded for some time, so the benchmark could not run at all: 62 ms median
submit-to-result with this, against the 61 ms on record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6HeySA27EkGANZN95QySW
2026-08-27 08:49:36 +02:00

199 lines
6.8 KiB
Python

"""How long it takes to get a run started, which is the thing Kedro is slow at.
A pipeline framework that boots the project per run pays that cost every time:
``kedro run`` on a pipeline that does nothing takes about a second, and a sweep
of five hundred configs therefore spends ten minutes doing nothing. Fluksio
answers that by not booting anything — the engine is already up and its workers
already have the code loaded, so submitting is one request.
This measures that claim against a running stack, so it can be checked rather
than asserted. Run it with the dev stack up::
make bench-startup
make bench-startup BENCH_ARGS="--runs 50 --kedro ../path/to/kedro/project"
The Kedro figure is optional and measured the same way — a null pipeline, timed
end to end — so the two numbers mean the same thing.
"""
from __future__ import annotations
import argparse
import json
import logging
import statistics
import subprocess
import sys
import time
import urllib.error
import urllib.request
from typing import Any
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1]))
from fluksio.core.config import settings # noqa: E402
log = logging.getLogger("bench")
#: A flow that computes nothing, so what is timed is the getting-started.
BENCH_FLOW = "bench_startup"
NODE_SOURCE = '"""Does nothing, on purpose."""\n\n\ndef process(n):\n return {"out": n}\n'
class Api:
def __init__(self, base: str, token: str) -> None:
self.base = base.rstrip("/")
self.token = token
def call(self, method: str, path: str, body: Any = None) -> Any:
request = urllib.request.Request(
f"{self.base}{path}",
method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response) if response.status != 204 else None
def login(base: str) -> Api:
data = (
f"username={settings.FIRST_SUPERUSER}&password={settings.FIRST_SUPERUSER_PASSWORD}"
).encode()
request = urllib.request.Request(
f"{base.rstrip('/')}/api/v1/login/access-token",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return Api(base, json.load(response)["access_token"])
def ensure_flow(api: Api) -> None:
"""A published batch flow with one node that returns what it was given."""
definition = {
"name": BENCH_FLOW,
"title": "Startup benchmark",
"version": 1,
"mode": "batch",
"outputs": ["out"],
"inputs": [{"spec": {"name": "n", "dtype": "int"}, "initial": 1}],
"nodes": [
{
"id": "noop",
"type": "python",
"requires": [{"name": "n", "dtype": "int"}],
"provides": [{"name": "out", "dtype": "int"}],
}
],
}
try:
current = api.call("GET", f"/api/v1/flows/{BENCH_FLOW}")
definition["version"] = current["definition"]["version"]
except urllib.error.HTTPError:
pass
api.call("PUT", f"/api/v1/flows/{BENCH_FLOW}", definition)
api.call(
"PUT", f"/api/v1/flows/{BENCH_FLOW}/nodes/noop/source", {"code": NODE_SOURCE}
)
api.call(
"POST",
f"/api/v1/flows/{BENCH_FLOW}/publish",
{"version": definition["version"]},
)
def time_runs(api: Api, count: int) -> tuple[list[float], list[float]]:
submits: list[float] = []
totals: list[float] = []
for index in range(count):
start = time.perf_counter()
run = api.call(
"POST", f"/api/v1/runs/flows/{BENCH_FLOW}", {"params": {"n": index}}
)
submits.append((time.perf_counter() - start) * 1000)
while True:
got = api.call("GET", f"/api/v1/runs/{run['id']}")
if got["status"] not in ("queued", "running"):
break
time.sleep(0.005)
totals.append((time.perf_counter() - start) * 1000)
if got["status"] != "ok":
raise SystemExit(
f"run {run['id']} ended {got['status']}: {got['status_reason']}"
)
return submits, totals
def time_kedro(project: str, pipeline: str, count: int) -> list[float]:
"""The same measurement for a Kedro project: a whole run, end to end."""
timings: list[float] = []
for _ in range(count):
start = time.perf_counter()
result = subprocess.run(
["kedro", "run", "--pipeline", pipeline],
cwd=project,
capture_output=True,
check=False,
)
if result.returncode != 0:
raise SystemExit(f"kedro run failed: {result.stderr.decode()[-400:]}")
timings.append((time.perf_counter() - start) * 1000)
return timings
def report(label: str, timings: list[float]) -> None:
ordered = sorted(timings)
log.info(
f" {label:<28} median {statistics.median(ordered):7.1f} ms"
f" min {ordered[0]:7.1f} max {ordered[-1]:7.1f}"
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://api.localhost")
parser.add_argument("--runs", type=int, default=20)
parser.add_argument(
"--kedro", default="", help="a Kedro project to compare against"
)
parser.add_argument("--kedro-pipeline", default="__default__")
parser.add_argument("--kedro-runs", type=int, default=3)
parser.add_argument(
"--keep", action="store_true", help="leave the benchmark flow behind"
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
api = login(args.base_url)
ensure_flow(api)
# One throwaway run first: the worker compiles the node on its first call,
# and that cost belongs to the deployment rather than to a run.
time_runs(api, 1)
submits, totals = time_runs(api, args.runs)
log.info(f"\nfluksio — {args.runs} runs of a flow that computes nothing")
report("submit accepted", submits)
report("submit -> result", totals)
if args.kedro:
kedro = time_kedro(args.kedro, args.kedro_pipeline, args.kedro_runs)
log.info(
f"\nkedro — {args.kedro_runs} runs of a pipeline that computes nothing"
)
report("kedro run", kedro)
ratio = statistics.median(kedro) / statistics.median(totals)
log.info(f"\n a run costs {ratio:.0f}x less here, per run")
if not args.keep:
api.call("DELETE", f"/api/v1/flows/{BENCH_FLOW}")
log.info(f"\nremoved the '{BENCH_FLOW}' flow")
return 0
if __name__ == "__main__":
raise SystemExit(main())