Files
urus-benches/analyze_spin_ab_urus.py
sandbox-agent 5f342858e7 bench: retune spin A/B sweep for saturation (4/6 cores, 16 loadgen)
The old 1/2/4/8 sweep with an 8-core loadgen plateaued ~102k rps at 4 and 8
server cores -- loadgen-bound, ~85% server CPU, so the 4-core (gate-active)
point was never actually saturated. Retune:

- CORES 1 2 4 8 -> 1 2 4 6. The gate is active at 2-4, inert at 1 and >=5, so
  6 is now the saturating inert control and 4-vs-6 brackets the gate boundary
  under real load (8 couldn't be saturated).
- LOADGEN_CPUS 12-19 (8c) -> 8-23 (16c); WRK_THREADS 8 -> 16; WRK_CONNS
  256 -> 512. Gives >=2x loadgen-cores-per-server-core at every sweep point
  (16:1/8:1/4:1/2.7:1) and leaves cores 6-7 for the OS.
- analyzer: gate label now follows the real 2..=4 rule (6 reads inert), and
  the reading guide notes to confirm the 6-core control is CPU- not
  loadgen-bound before trusting it.

Note: at 22/24 logical CPUs in use, loadgen will share SMT siblings with the
server; sibling-disjoint isn't achievable at 16 loadgen cores. Override
LOADGEN_CPUS after checking lscpu -e if that matters.
2026-06-14 21:43:15 +00:00

148 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Pair before/after urus-bench results and print an A/B comparison.
Layout produced by spin_ab_urus.sh:
<outdir>/before/<server>-<scenario>-c<cores>/result.json
<outdir>/after/<server>-<scenario>-c<cores>/result.json
We pair by the (server, scenario, cores) key and report sustained RPS
(after/before speedup) and p50/p99/p99.9 latency deltas. The spin gate is
active only at 2-4 schedulers, so rows at cores 2 and 4 are where an effect is
expected; cores 1 and 8 are controls (gate inert) and should stay flat.
"""
import argparse
import json
import os
import re
import sys
def gate_active(cores):
"""RFC 004 spinner cap is non-zero only at 2..=4 schedulers (1 and >=5
allow 0 spinners), so the policy is active exactly there."""
return 2 <= cores <= 4
def parse_latency_ms(s):
"""wrk2 prints '0.50ms' / '1.83ms' / '24.50ms' / '1.20s' / '850.00us'."""
if s is None:
return None
s = str(s).strip()
m = re.match(r"^([0-9.]+)\s*(us|ms|s)?$", s)
if not m:
return None
val = float(m.group(1))
unit = m.group(2) or "ms"
return {"us": val / 1000.0, "ms": val, "s": val * 1000.0}[unit]
def load(outdir, sub):
"""Return {(server, scenario, cores): result_dict} for one side."""
base = os.path.join(outdir, sub)
out = {}
if not os.path.isdir(base):
return out
for name in os.listdir(base):
rj = os.path.join(base, name, "result.json")
if not os.path.isfile(rj):
continue
m = re.match(r"^(.*)-(s\d)-c(\d+)$", name)
if not m:
continue
server, scenario, cores = m.group(1), m.group(2), int(m.group(3))
try:
with open(rj) as f:
out[(server, scenario, cores)] = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"warn: could not read {rj}: {e}", file=sys.stderr)
return out
def fnum(x):
try:
return float(x)
except (TypeError, ValueError):
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("outdir")
ap.add_argument("--before", default="before")
ap.add_argument("--after", default="after")
args = ap.parse_args()
before = load(args.outdir, args.before)
after = load(args.outdir, args.after)
keys = sorted(set(before) | set(after))
if not keys:
print("no results found under", args.outdir, file=sys.stderr)
return 1
# --- RPS table ---------------------------------------------------------
hdr = f"{'server':<10} {'scen':<4} {'cores':>5} {'before rps':>12} {'after rps':>12} {'speedup':>8} gate"
print("\n== Sustained RPS (higher is better) ==")
print(hdr)
print("-" * len(hdr))
for k in keys:
srv, scn, cores = k
b = fnum(before.get(k, {}).get("sustained_rps"))
a = fnum(after.get(k, {}).get("sustained_rps"))
spd = f"{a / b:.2f}x" if (a and b) else "—"
gate = "ACTIVE" if gate_active(cores) else "inert"
bs = f"{b:,.0f}" if b is not None else "—"
as_ = f"{a:,.0f}" if a is not None else "—"
print(f"{srv:<10} {scn:<4} {cores:>5} {bs:>12} {as_:>12} {spd:>8} {gate}")
# --- latency tables ----------------------------------------------------
for pct in ("p50", "p99", "p999"):
print(f"\n== {pct} latency, ms (lower is better) ==")
h = f"{'server':<10} {'scen':<4} {'cores':>5} {'before':>10} {'after':>10} {'delta':>9} gate"
print(h)
print("-" * len(h))
for k in keys:
srv, scn, cores = k
b = parse_latency_ms(before.get(k, {}).get("latency", {}).get(pct))
a = parse_latency_ms(after.get(k, {}).get("latency", {}).get(pct))
if a is not None and b is not None:
delta = f"{(a - b):+.2f}"
else:
delta = "—"
gate = "ACTIVE" if gate_active(cores) else "inert"
bs = f"{b:.2f}" if b is not None else "—"
as_ = f"{a:.2f}" if a is not None else "—"
print(f"{srv:<10} {scn:<4} {cores:>5} {bs:>10} {as_:>10} {delta:>9} {gate}")
# --- error sanity (non-2xx should be ~0 on both sides) -----------------
noisy = []
for k in keys:
for side, d in (("before", before), ("after", after)):
n = d.get(k, {}).get("non_2xx", 0)
if isinstance(n, (int, float)) and n > 0:
noisy.append((side, k, n))
if noisy:
print("\n== WARNING: non-2xx responses (results may be unreliable) ==")
for side, (srv, scn, cores), n in noisy:
print(f" {side} {srv} {scn} c{cores}: {n} non-2xx")
print(
"\nReading it: spin is 'worth it' if s3 (and/or s2) RPS rises and/or "
"tail latency drops at the gate-ACTIVE cores (2-4), with the inert "
"controls (1 and >=5, e.g. 6) staying flat. Flat-or-worse at 2/4 = "
"not worth it on real traffic. Cross-check that the saturating control "
"(6 cores) is actually CPU-bound, not loadgen-bound, before trusting it."
)
if "axum-mem" in {k[0] for k in keys}:
print(
"axum-mem is the control: it doesn't depend on smarm, so its "
"before/after should be ~identical. Any drift there means the run "
"environment moved, not the patch."
)
return 0
if __name__ == "__main__":
sys.exit(main())