The CPU share learned the p90 of the host-wide peak minus before, which carries every neighbour's spike: 30 days of history give test-nostack a p90 of 73.8 against a median of 9.7, and rows that no other job overlaps learn the same p90, so concurrency is not the cause. signature() also kept the --repo value of a script as its operand, so every worktree's scan learned apart and fell back to the kind (130 of 301 test-nostack rows). The CPU share now learns the median while RAM keeps the p90, and an operand holding a path separator leaves the signature. New optional config keys exclusive_kinds and light_kinds: a job of an exclusive kind runs only beside light kinds, and an exclusive entry that only running jobs of other kinds keep out holds back every later non-light entry, so no lower-ranked job overtakes a waiting gate. 🎯 Fix: 91 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 87 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""Record the jobs of the kvb-org-queue and learn disk need per command and slot limits per kind.
|
|
|
|
Pure functions over rows and paths: queue.py passes the council history file
|
|
and the copy the kvb-org-organize watch hook keeps in ~/.local/state.
|
|
|
|
A row: id, kind, signature, workdir, start, end (epoch seconds), minutes, exit,
|
|
free_before_gb, min_free_gb, disk_gb (free_before_gb - min_free_gb, at least
|
|
0), mem_before, mem_max, cpu_before, cpu_max (percent), concurrent (other
|
|
active jobs at the grant).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import statistics
|
|
from collections.abc import Iterable, Iterator
|
|
from pathlib import Path
|
|
|
|
MIN_RUNS = 5
|
|
WINDOW_SECONDS = 30 * 86400
|
|
DISK_MARGIN_GB = 1
|
|
MIN_SHARE_PERCENT = 1
|
|
SEED_LIMIT = 1
|
|
INTERPRETERS = re.compile(r"python[\d.]*|bash|sh|zsh|node|perl|ruby")
|
|
MAKE_VALUE_OPTIONS = frozenset({"-C", "-f", "-I", "-o", "-W"})
|
|
RESOURCES = ("ram", "cpu")
|
|
SIGNATURE_HEADER = ("Signature", "Kind", "Runs", "Median min", "p90 disk GB", "p90 RAM %", "Need GB")
|
|
KIND_HEADER = ("Kind", "Runs", "p90 RAM share %", "Median CPU share %", "Slot limit", "Source")
|
|
|
|
def first_operand(tokens: Iterable[str]) -> list[str]:
|
|
operand = next((token for token in tokens if not token.startswith("-")), None)
|
|
return [] if operand is None or os.sep in operand else [operand]
|
|
|
|
|
|
def make_target(tokens: Iterator[str]) -> list[str]:
|
|
for token in tokens:
|
|
if token in MAKE_VALUE_OPTIONS:
|
|
next(tokens, None)
|
|
# A bare number is the count of a preceding -j or -l, not a target.
|
|
elif not (token.startswith("-") or "=" in token or token.isdigit()):
|
|
return [token]
|
|
return []
|
|
|
|
|
|
def signature(argv: list[str]) -> str:
|
|
"""Name a command apart from its paths, options and variables.
|
|
|
|
Args:
|
|
argv: the command as run.
|
|
|
|
Returns:
|
|
`make <first target>` for make, skipping -C, -f, -I, -o and -W with their
|
|
values, other options, job counts and VAR=value; `<script basename>
|
|
<first operand>` for an interpreter running a script (python, bash, ...)
|
|
and `<interpreter> -c` for inline code; else `<argv[0] basename> <first
|
|
operand>`. An operand is an argument not starting with `-`; a missing
|
|
target or operand is left out, and so is an operand holding a path
|
|
separator.
|
|
"""
|
|
name = os.path.basename(argv[0])
|
|
tokens = iter(argv[1:])
|
|
if name == "make":
|
|
return " ".join([name, *make_target(tokens)])
|
|
if INTERPRETERS.fullmatch(name):
|
|
for token in tokens:
|
|
if token == "-c":
|
|
return f"{name} -c"
|
|
if not token.startswith("-"):
|
|
name = os.path.basename(token)
|
|
break
|
|
return " ".join([name, *first_operand(tokens)])
|
|
|
|
|
|
def row(job: dict, start: float, end: float, code: int, samples: list[dict[str, float]], concurrent: int) -> dict:
|
|
"""Build the history row of a finished job.
|
|
|
|
Args:
|
|
job: its id, kind, signature and workdir.
|
|
start: epoch seconds the command started.
|
|
end: epoch seconds it ended.
|
|
code: its exit code.
|
|
samples: {free_gb, mem, cpu} measurements, the first taken at the grant.
|
|
concurrent: other active jobs at the grant.
|
|
"""
|
|
before = samples[0]
|
|
min_free = min(s["free_gb"] for s in samples)
|
|
return {
|
|
**job,
|
|
"start": start,
|
|
"end": end,
|
|
"minutes": round((end - start) / 60, 2),
|
|
"exit": code,
|
|
"free_before_gb": round(before["free_gb"], 2),
|
|
"min_free_gb": round(min_free, 2),
|
|
"disk_gb": round(max(0.0, before["free_gb"] - min_free), 2),
|
|
"mem_before": round(before["mem"], 1),
|
|
"mem_max": round(max(s["mem"] for s in samples), 1),
|
|
"cpu_before": round(before["cpu"], 1),
|
|
"cpu_max": round(max(s["cpu"] for s in samples), 1),
|
|
"concurrent": concurrent,
|
|
}
|
|
|
|
|
|
def line(entry: dict) -> str:
|
|
return json.dumps(entry, sort_keys=True) + "\n"
|
|
|
|
|
|
def read(paths: Iterable[Path]) -> list[dict]:
|
|
"""Merge the rows of every history file.
|
|
|
|
Args:
|
|
paths: JSON-lines files; a missing file holds no rows and a line that is
|
|
no JSON object with an id is skipped.
|
|
|
|
Returns:
|
|
The rows, the first one of each id winning.
|
|
"""
|
|
rows: dict[str, dict] = {}
|
|
for path in paths:
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except FileNotFoundError:
|
|
continue
|
|
for line in lines:
|
|
try:
|
|
entry = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(entry, dict) and "id" in entry:
|
|
rows.setdefault(entry["id"], entry)
|
|
return list(rows.values())
|
|
|
|
|
|
def p90(values: list[float]) -> float:
|
|
return statistics.quantiles(values, n=10, method="inclusive")[-1]
|
|
|
|
|
|
def shares(rows: list[dict], resource: str) -> list[float]:
|
|
return [r[f"{resource}_max"] - r[f"{resource}_before"] for r in rows]
|
|
|
|
|
|
def grouped(rows: list[dict], key: str) -> dict[str, list[dict]]:
|
|
groups: dict[str, list[dict]] = {}
|
|
for entry in rows:
|
|
groups.setdefault(entry[key], []).append(entry)
|
|
return groups
|
|
|
|
|
|
def command_stats(rows: list[dict]) -> dict:
|
|
stats = {
|
|
"kinds": sorted({r["kind"] for r in rows}), "runs": len(rows),
|
|
"minutes": None, "disk_gb": None, "ram_share": None, "cpu_share": None, "need_gb": None,
|
|
}
|
|
if len(rows) >= MIN_RUNS:
|
|
disk = p90([r["disk_gb"] for r in rows])
|
|
stats.update(
|
|
minutes=statistics.median(r["minutes"] for r in rows), disk_gb=disk,
|
|
ram_share=p90(shares(rows, "mem")), cpu_share=statistics.median(shares(rows, "cpu")),
|
|
need_gb=math.ceil(disk + DISK_MARGIN_GB),
|
|
)
|
|
return stats
|
|
|
|
|
|
def fit(ceiling: float, rows: list[dict], resource: str, share: float) -> int:
|
|
before = statistics.median(r[f"{resource}_before"] for r in rows)
|
|
return math.floor((ceiling - before) / max(share, MIN_SHARE_PERCENT))
|
|
|
|
|
|
def kind_stats(rows: list[dict], limits: dict) -> dict:
|
|
stats = {"runs": len(rows), "ram_share": None, "cpu_share": None, "limit": None}
|
|
if len(rows) >= MIN_RUNS:
|
|
ram, cpu = p90(shares(rows, "mem")), statistics.median(shares(rows, "cpu"))
|
|
stats.update(ram_share=ram, cpu_share=cpu, limit=max(1, min(
|
|
fit(limits["mem_percent"], rows, "mem", ram), fit(limits["cpu_percent"], rows, "cpu", cpu),
|
|
)))
|
|
return stats
|
|
|
|
|
|
def learn(rows: list[dict], now: float, cfg: dict) -> tuple[dict[str, dict], dict[str, dict]]:
|
|
"""Derive disk need per command and slot limit per kind from recent rows.
|
|
|
|
Args:
|
|
rows: history rows, as read() returns them.
|
|
now: epoch seconds; rows that ended more than WINDOW_SECONDS earlier are
|
|
ignored.
|
|
cfg: the config; load_limits.mem_percent and cpu_percent bound a limit.
|
|
|
|
Returns:
|
|
(signatures, kinds). signatures: {signature: {kinds, runs, minutes,
|
|
disk_gb, ram_share, cpu_share, need_gb}}; kinds: {kind: {runs, ram_share,
|
|
cpu_share, limit}}. With fewer than MIN_RUNS rows every value but kinds
|
|
and runs is None. minutes is the median, disk_gb and ram_share the p90
|
|
of the disk taken and of max minus before in percentage points,
|
|
cpu_share the median of max minus before; need_gb is ceil(disk_gb + DISK_MARGIN_GB); limit is
|
|
max(1, min over RAM and CPU of floor((load limit - median before) /
|
|
max(share, MIN_SHARE_PERCENT))).
|
|
"""
|
|
recent = [r for r in rows if now - r["end"] <= WINDOW_SECONDS]
|
|
signatures = {name: command_stats(group) for name, group in grouped(recent, "signature").items()}
|
|
kinds = {name: kind_stats(group, cfg["load_limits"]) for name, group in grouped(recent, "kind").items()}
|
|
return signatures, kinds
|
|
|
|
|
|
def footprint(kind: str, name: str, signatures: dict[str, dict], kinds: dict[str, dict]) -> dict[str, float] | None:
|
|
"""Predict the RAM and CPU share a job takes, in percentage points.
|
|
|
|
Args:
|
|
kind: the job's kind.
|
|
name: its command signature.
|
|
signatures: the signature map of learn().
|
|
kinds: the kind map of learn().
|
|
|
|
Returns:
|
|
{ram, cpu}: the signature's learned share once learned, else the kind's, at
|
|
least MIN_SHARE_PERCENT. None while the kind is not learned yet.
|
|
"""
|
|
if kind not in kinds or kinds[kind]["ram_share"] is None:
|
|
return None
|
|
own = signatures.get(name)
|
|
source = own if own and own["ram_share"] is not None else kinds[kind]
|
|
return {resource: max(MIN_SHARE_PERCENT, source[f"{resource}_share"]) for resource in RESOURCES}
|
|
|
|
|
|
def seed_ram(kinds: dict[str, dict]) -> float:
|
|
"""Returns:
|
|
The RAM share assumed for a kind not learned yet: the largest of the
|
|
learned kinds' p90 RAM shares, 0 while none is learned.
|
|
"""
|
|
return max((stats["ram_share"] for stats in kinds.values() if stats["ram_share"] is not None), default=0.0)
|
|
|
|
|
|
def need_gb(signatures: dict[str, dict], name: str) -> int | None:
|
|
return signatures[name]["need_gb"] if name in signatures else None
|
|
|
|
|
|
def slot_limits(kinds: dict[str, dict], caps: dict[str, int], names: Iterable[str]) -> dict[str, tuple[int, str]]:
|
|
"""Decide the slot limit of every kind.
|
|
|
|
Args:
|
|
kinds: the kind map of learn().
|
|
caps: the config's optional slot_limits {kind: N}; a cap only lowers.
|
|
names: kinds to decide besides those of kinds and caps.
|
|
|
|
Returns:
|
|
{kind: (limit, source)}, sorted by kind: the learned limit (source
|
|
`learned`), else SEED_LIMIT (`seed`), lowered to a smaller cap
|
|
(`capped by config`).
|
|
"""
|
|
limits = {}
|
|
for kind in sorted({*names, *kinds, *caps}):
|
|
limit, source = SEED_LIMIT, "seed"
|
|
if kind in kinds and kinds[kind]["limit"] is not None:
|
|
limit, source = kinds[kind]["limit"], "learned"
|
|
if kind in caps and caps[kind] < limit:
|
|
limit, source = caps[kind], "capped by config"
|
|
limits[kind] = (limit, source)
|
|
return limits
|
|
|
|
|
|
def eta(signatures: dict[str, dict], running: list[tuple[str, float]], ahead: list[str], now: float) -> float | None:
|
|
"""Estimate the minutes until a waiting job starts, or until a running one ends.
|
|
|
|
Args:
|
|
signatures: the signature map of learn(); its median minutes drive the
|
|
estimate.
|
|
running: (signature, grant epoch seconds) of the running jobs counted.
|
|
ahead: signatures of the waiting jobs before it.
|
|
now: epoch seconds.
|
|
|
|
Returns:
|
|
The remaining median minutes of running plus the median minutes of
|
|
ahead, as if run one after another; None when one has no median yet.
|
|
"""
|
|
def median(name: str) -> float | None:
|
|
return signatures[name]["minutes"] if name in signatures else None
|
|
|
|
parts = [(median(name), (now - granted) / 60) for name, granted in running]
|
|
parts += [(median(name), 0.0) for name in ahead]
|
|
if any(minutes is None for minutes, _ in parts):
|
|
return None
|
|
return sum(max(0.0, minutes - elapsed) for minutes, elapsed in parts)
|
|
|
|
|
|
def cell(value: float | None) -> str:
|
|
return "-" if value is None else f"{value:.1f}"
|
|
|
|
|
|
def table(header: tuple[str, ...], rows: list[list[str]]) -> str:
|
|
lines = [header, ["---"] * len(header), *rows]
|
|
return "".join(f"| {' | '.join(line)} |\n" for line in lines)
|
|
|
|
|
|
def report(signatures: dict[str, dict], kinds: dict[str, dict], limits: dict[str, tuple[int, str]]) -> str:
|
|
"""Render the learned values as two markdown tables.
|
|
|
|
Args:
|
|
signatures: the signature map of learn().
|
|
kinds: the kind map of learn().
|
|
limits: slot_limits() for every kind to list.
|
|
|
|
Returns:
|
|
A table per signature, a blank line, a table per kind.
|
|
"""
|
|
commands = [
|
|
[name, ", ".join(s["kinds"]), str(s["runs"]), cell(s["minutes"]), cell(s["disk_gb"]),
|
|
cell(s["ram_share"]), "-" if s["need_gb"] is None else str(s["need_gb"])]
|
|
for name, s in sorted(signatures.items())
|
|
]
|
|
per_kind = []
|
|
for kind, (limit, source) in limits.items():
|
|
runs, ram, cpu = 0, None, None
|
|
if kind in kinds:
|
|
runs, ram, cpu = (kinds[kind][key] for key in ("runs", "ram_share", "cpu_share"))
|
|
per_kind.append([kind, str(runs), cell(ram), cell(cpu), str(limit), source])
|
|
return table(SIGNATURE_HEADER, commands) + "\n" + table(KIND_HEADER, per_kind)
|