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
199 lines
6.8 KiB
Python
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())
|