Merge pull request #578 from noonghunna/feat/phase2-soak-validation-probe

Phase 2: validation-grade concurrency-probe (per-stream TPS, VALIDATE, SWEEP)
This commit is contained in:
noonghunna
2026-07-05 11:57:42 +05:00
committed by GitHub
2 changed files with 245 additions and 84 deletions

View File

@@ -1,25 +1,38 @@
#!/usr/bin/env bash
# concurrency-probe.sh — the #246 Phase 2 measurement: how many concurrent
# long-context streams a card's KV pool sustains cleanly.
# streams a card's KV pool sustains cleanly, and at what per-stream throughput.
#
# soak-test.sh is SINGLE-STREAM by design (no concurrency stress); this is its
# concurrent sibling. It fires N concurrent long requests over R rounds and
# checks completion + no-silent-empty + bounded VRAM growth — the validation
# behind an envelopes.yml `max_num_seqs` row (born-from-measurement discipline).
# soak-test.sh is SINGLE-STREAM by design (no concurrency stress) and only ramps
# to ~22-25K accumulated tokens; this is its concurrent, model-max sibling. It
# is the measurement behind an envelopes.yml `max_num_seqs` row — the tool that
# upgrades a `computed` row to `validated` (see
# /opt/ai/docs/phase2-soak-validation-protocol.md).
#
# It VALIDATES a candidate N (does the server, booted at MAX_NUM_SEQS=N, hold
# N concurrent long streams?), it does not search — kv-calc proposes N from the
# pool, this confirms it. Boot the server at the candidate MAX_NUM_SEQS first.
# TWO ROW CLASSES, TWO MODES (this is the whole point):
# • pool-ceiling rows (e.g. 5090): the value IS the kv-calc ceiling, so this is
# a FIT + STABILITY test — boot at N, drive N streams at the row's target_ctx,
# require all-complete / 0 silent / 0-growth VRAM / >=98% TPS retention.
# Use: VALIDATE=1 (fills the served max-model-len; gates fit + retention)
# • bandwidth-cap rows (PRO 6000 / Spark): the value sits far BELOW the pool
# ceiling, so a fit test proves nothing (N=8 trivially fits 96 GB). Sweep N
# and find the THROUGHPUT KNEE — the largest N whose per-stream decode TPS
# stays above the floor.
# Use: SWEEP="4 8 12 16" SLUG=vllm/minimal TPS_FLOOR=15
#
# Usage:
# MAX_NUM_SEQS=4 bash scripts/switch.sh vllm/dual # boot at the candidate
# bash scripts/concurrency-probe.sh # probe = server's cap
# CONCURRENCY=4 bash scripts/concurrency-probe.sh # force N
# bash scripts/concurrency-probe.sh # plain fit check (server's N)
# VALIDATE=1 bash scripts/concurrency-probe.sh # validation-grade fit @ target_ctx
# SWEEP="4 8 12 16" SLUG=vllm/minimal TPS_FLOOR=15 bash scripts/concurrency-probe.sh
#
# Env: URL (default http://localhost:8010) · MODEL (auto) · CONTAINER (auto for
# VRAM) · CONCURRENCY (default: detect served max-num-seqs, else 2) · ROUNDS
# (default 5) · PROMPT_TOKENS (default 16000) · GEN_TOKENS (default 256) ·
# VRAM_GROWTH_MB (default 200).
# VRAM) · CONCURRENCY (default: served max-num-seqs, else 2) · ROUNDS (5) ·
# PROMPT_TOKENS (16000) · GEN_TOKENS (256) · VRAM_GROWTH_MB (200) ·
# REQ_TIMEOUT (600).
# Validation knobs: VALIDATE (0) · TARGET_CTX (auto from --max-model-len) ·
# TPS_FLOOR (0 = report-only) · RETENTION_MIN (0.98) ·
# SWEEP ("" = single-N) · SLUG (required for SWEEP) · SWEEP_DRY (0) ·
# BOOT_TIMEOUT (360).
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -28,104 +41,219 @@ ROUNDS="${ROUNDS:-5}"
PROMPT_TOKENS="${PROMPT_TOKENS:-16000}"
GEN_TOKENS="${GEN_TOKENS:-256}"
VRAM_GROWTH_MB="${VRAM_GROWTH_MB:-200}"
REQ_TIMEOUT="${REQ_TIMEOUT:-600}"
TPS_FLOOR="${TPS_FLOOR:-0}" # 0 = report-only; >0 = gate per-stream decode TPS
RETENTION_MIN="${RETENTION_MIN:-0.98}"
VALIDATE="${VALIDATE:-0}" # 1 = fill target_ctx + gate fit + retention
TARGET_CTX="${TARGET_CTX:-}" # override; else auto from container --max-model-len
SWEEP="${SWEEP:-}" # e.g. "4 8 12 16" -> reboot+probe per N, find knee
SLUG="${SLUG:-}" # required for SWEEP (reboot target)
SWEEP_DRY="${SWEEP_DRY:-0}"
BOOT_TIMEOUT="${BOOT_TIMEOUT:-360}"
MODEL="${MODEL:-$(curl -s -m 5 "${URL}/v1/models" 2>/dev/null \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["data"][0]["id"])' 2>/dev/null || echo qwen3.6-27b)}"
# best-effort container for VRAM (exact-match convention; port/name heuristic)
CONTAINER="${CONTAINER:-$(docker ps --format '{{.Names}}' 2>/dev/null | grep -m1 -E 'vllm-qwen' || true)}"
# best-effort container for VRAM + cmd introspection (name heuristic)
CONTAINER="${CONTAINER:-$(docker ps --format '{{.Names}}' 2>/dev/null | grep -m1 -E 'vllm-(qwen|gemma)' || true)}"
_container_cmd() { docker inspect "$CONTAINER" --format '{{join .Config.Cmd " "}}' 2>/dev/null || true; }
_served_seqs() { _container_cmd | grep -oE 'max-num-seqs [0-9]+' | grep -oE '[0-9]+' | head -1; }
_served_ctx() { _container_cmd | grep -oE 'max-model-len [0-9]+' | grep -oE '[0-9]+' | head -1; }
# CONCURRENCY defaults to the served max-num-seqs (the thing we're validating).
if [[ -z "${CONCURRENCY:-}" ]]; then
CONCURRENCY="$(docker inspect "$CONTAINER" --format '{{join .Config.Cmd " "}}' 2>/dev/null \
| grep -oE 'max-num-seqs [0-9]+' | grep -oE '[0-9]+' | head -1 || true)"
CONCURRENCY="${CONCURRENCY:-2}"
CONCURRENCY="$(_served_seqs || true)"; CONCURRENCY="${CONCURRENCY:-2}"
fi
echo "[concurrency-probe] URL=$URL model=$MODEL N=$CONCURRENCY rounds=$ROUNDS prompt=${PROMPT_TOKENS}tok gen=${GEN_TOKENS}"
# VALIDATE preset: fill each stream to the served target context (N full-context
# sessions is what the row's no-preemption ceiling claims), and run a touch
# longer so the retention signal is meaningful.
if [[ "$VALIDATE" == "1" && -z "$SWEEP" ]]; then
ctx="${TARGET_CTX:-$(_served_ctx || true)}"; ctx="${ctx:-32768}"
headroom=$(( GEN_TOKENS + 512 ))
if (( ctx > headroom )); then PROMPT_TOKENS=$(( ctx - headroom )); else PROMPT_TOKENS="$ctx"; fi
ROUNDS="${ROUNDS_OVERRIDE:-6}"
echo "[concurrency-probe] VALIDATE: filling to target_ctx=${ctx} (prompt=${PROMPT_TOKENS}tok), rounds=${ROUNDS}"
fi
URL="$URL" MODEL="$MODEL" CONTAINER="$CONTAINER" CONCURRENCY="$CONCURRENCY" \
ROUNDS="$ROUNDS" PROMPT_TOKENS="$PROMPT_TOKENS" GEN_TOKENS="$GEN_TOKENS" \
VRAM_GROWTH_MB="$VRAM_GROWTH_MB" python3 - <<'PY'
# --- the probe core, parameterised by N (one python invocation per call) ------
# Emits the per-round table, a human verdict, and a machine-readable RESULT line.
# Exit 0 iff PASS: fit-clean AND (per-stream TPS >= floor, if floor set) AND
# (retention >= RETENTION_MIN, if VALIDATE).
run_probe() {
local N="$1"
URL="$URL" MODEL="$MODEL" CONTAINER="$CONTAINER" CONCURRENCY="$N" \
ROUNDS="$ROUNDS" PROMPT_TOKENS="$PROMPT_TOKENS" GEN_TOKENS="$GEN_TOKENS" \
VRAM_GROWTH_MB="$VRAM_GROWTH_MB" REQ_TIMEOUT="$REQ_TIMEOUT" \
TPS_FLOOR="$TPS_FLOOR" RETENTION_MIN="$RETENTION_MIN" VALIDATE="$VALIDATE" \
python3 - <<'PY'
import concurrent.futures as cf
import json
import os
import subprocess
import time
import urllib.request
import json, os, statistics, subprocess, time, urllib.request
URL = os.environ["URL"]; MODEL = os.environ["MODEL"]; N = int(os.environ["CONCURRENCY"])
ROUNDS = int(os.environ["ROUNDS"]); PTOK = int(os.environ["PROMPT_TOKENS"])
GTOK = int(os.environ["GEN_TOKENS"]); GROWTH = int(os.environ["VRAM_GROWTH_MB"])
CONTAINER = os.environ.get("CONTAINER") or ""
URL=os.environ["URL"]; MODEL=os.environ["MODEL"]; N=int(os.environ["CONCURRENCY"])
ROUNDS=int(os.environ["ROUNDS"]); PTOK=int(os.environ["PROMPT_TOKENS"])
GTOK=int(os.environ["GEN_TOKENS"]); GROWTH=int(os.environ["VRAM_GROWTH_MB"])
REQ_TIMEOUT=float(os.environ["REQ_TIMEOUT"]); TPS_FLOOR=float(os.environ["TPS_FLOOR"])
RETENTION_MIN=float(os.environ["RETENTION_MIN"]); VALIDATE=os.environ["VALIDATE"]=="1"
CONTAINER=os.environ.get("CONTAINER") or ""
BLOCK = ("This section describes the history of computing in detail. Transistors "
"were invented in 1947 at Bell Labs. The integrated circuit came a decade "
"later. Microprocessors emerged in the 1970s and changed the world. ")
# ~0.23 tok/char; build to ~PTOK, unique salt per stream so no prefix-cache free ride
def prompt(stream, rnd):
reps = int(PTOK / (len(BLOCK) * 0.23)) + 1
return f"[probe s{stream} r{rnd}] " + BLOCK * reps + "\nSummarize in two sentences."
BLOCK=("This section describes the history of computing in detail. Transistors "
"were invented in 1947 at Bell Labs. The integrated circuit came a decade "
"later. Microprocessors emerged in the 1970s and changed the world. ")
def prompt(stream, rnd): # ~0.23 tok/char; unique salt per stream -> no prefix-cache free ride
reps=int(PTOK/(len(BLOCK)*0.23))+1
return f"[probe s{stream} r{rnd}] "+BLOCK*reps+"\nWrite a detailed multi-paragraph summary."
def vram_used_mb():
try:
out = subprocess.run(["nvidia-smi","--query-gpu=memory.used","--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=10).stdout
out=subprocess.run(["nvidia-smi","--query-gpu=memory.used","--format=csv,noheader,nounits"],
capture_output=True,text=True,timeout=10).stdout
return sum(int(x) for x in out.split())
except Exception:
return -1
def one(stream, rnd):
body = json.dumps({"model": MODEL, "max_tokens": GTOK, "temperature": 0.0,
# Streamed so we can separate TTFT (prefill) from decode and report a real
# per-stream DECODE tok/s — the signal the bandwidth-knee sweep needs, and
# the only honest throughput number at deep context (where prefill dominates
# wall time). completion_tokens comes from the include_usage final chunk.
body=json.dumps({"model":MODEL,"max_tokens":GTOK,"temperature":0.0,"stream":True,
"stream_options":{"include_usage":True},
"messages":[{"role":"user","content":prompt(stream,rnd)}]}).encode()
req = urllib.request.Request(URL+"/v1/chat/completions", data=body,
req=urllib.request.Request(URL+"/v1/chat/completions",data=body,
headers={"Content-Type":"application/json"})
t0 = time.time()
t0=time.time(); t_first=None; t_last=None; toks=0; chunks=0
try:
r = json.load(urllib.request.urlopen(req, timeout=600))
toks = (r.get("usage") or {}).get("completion_tokens", 0)
# KV-pool stress semantics: a stream "ran" if it GENERATED tokens
# (held KV, decoded) — content-emptiness is irrelevant here (reasoning
# models legitimately return empty content when max_tokens truncates
# mid-<think>). silent-empty = HTTP 200 but ZERO tokens (the real
# failure soak-test flags).
ok = toks > 0
return {"ok": ok, "toks": toks, "silent": toks == 0,
"err": None, "dt": time.time()-t0}
resp=urllib.request.urlopen(req,timeout=REQ_TIMEOUT)
for raw in resp:
line=raw.decode("utf-8","ignore").strip()
if not line.startswith("data:"): continue
data=line[5:].strip()
if data=="[DONE]": break
try: ch=json.loads(data)
except Exception: continue
u=ch.get("usage")
if u and u.get("completion_tokens"): toks=u["completion_tokens"]
ch_choices=ch.get("choices") or []
if ch_choices:
d=ch_choices[0].get("delta") or {}
if d.get("content") or d.get("reasoning_content") or d.get("reasoning"):
now=time.time()
if t_first is None: t_first=now
t_last=now; chunks+=1
dt=time.time()-t0
if not toks: toks=chunks # fall back to chunk count if usage absent
decode_dt=(t_last-t_first) if (t_first and t_last and t_last>t_first) else 0.0
tps=(toks/decode_dt) if (toks>1 and decode_dt>0) else 0.0
ttft=(t_first-t0) if t_first else None
return {"ok":toks>0,"toks":toks,"silent":toks==0,"err":None,"dt":dt,"ttft":ttft,"tps":tps}
except Exception as e:
return {"ok": False, "toks": 0, "silent": False, "err": str(e)[:80], "dt": time.time()-t0}
return {"ok":False,"toks":0,"silent":False,"err":str(e)[:80],"dt":time.time()-t0,"ttft":None,"tps":0.0}
print(f"\n{'round':>5} {'done':>7} {'silent':>7} {'errors':>7} {'vram_MB':>8} {'agg_tok/s':>10}")
vram0 = vram_used_mb() # cold idle — empty KV pool
vram_by_round = []
bad = 0
for rnd in range(1, ROUNDS+1):
t0 = time.time()
print(f"\n{'round':>5} {'done':>7} {'silent':>7} {'errors':>7} {'vram_MB':>8} {'agg_t/s':>8} {'per-strm':>9}")
vram0=vram_used_mb()
vram_by_round=[]; mtps_by_round=[]; bad=0
for rnd in range(1,ROUNDS+1):
t0=time.time()
with cf.ThreadPoolExecutor(max_workers=N) as ex:
res = list(ex.map(lambda s: one(s, rnd), range(N)))
wall = time.time()-t0
done = sum(1 for r in res if r["ok"])
silent = sum(1 for r in res if r["silent"])
errs = sum(1 for r in res if r["err"])
v = vram_used_mb(); vram_by_round.append(v)
agg = sum(r["toks"] for r in res)/wall if wall else 0
print(f"{rnd:>5} {done:>4}/{N:<2} {silent:>7} {errs:>7} {v:>8} {agg:>10.1f}")
if done < N or silent or errs:
bad += 1
res=list(ex.map(lambda s: one(s,rnd), range(N)))
wall=time.time()-t0
done=sum(1 for r in res if r["ok"]); silent=sum(1 for r in res if r["silent"])
errs=sum(1 for r in res if r["err"])
v=vram_used_mb(); vram_by_round.append(v)
agg=sum(r["toks"] for r in res)/wall if wall else 0
tps_ok=[r["tps"] for r in res if r["ok"] and r["tps"]>0]
mtps=statistics.median(tps_ok) if tps_ok else 0.0
mtps_by_round.append(mtps)
print(f"{rnd:>5} {done:>4}/{N:<2} {silent:>7} {errs:>7} {v:>8} {agg:>8.1f} {mtps:>9.1f}")
if done<N or silent or errs: bad+=1
# VRAM: leak = post-warm growth (round 2 baseline), NOT the expected cold->warm fill.
warm_i=1 if ROUNDS>=3 else 0
warm=vram_by_round[warm_i]
pool_fill=warm-vram0 if vram0>=0 else -1
leak=vram_by_round[-1]-warm if vram0>=0 else -1
vram_peak=max(vram_by_round) if vram_by_round else -1
# per-stream decode TPS: steady-state = last round; retention = late vs early.
# Round 1 is cudagraph/cold warmup (systematically slow) — anchoring retention
# on it would flatter the ratio, so drop it once there are >=4 rounds.
report_tps=mtps_by_round[-1] if mtps_by_round else 0.0
warm_tps=mtps_by_round[1:] if ROUNDS>=4 else mtps_by_round
if len(warm_tps)>=3 and warm_tps[0]>0:
early=statistics.median(warm_tps[:2]); late=statistics.median(warm_tps[-2:])
retention=(late/early) if early>0 else 0.0
else:
retention=1.0 # too few post-warmup rounds to judge
clean_fit=(bad==0) and (0<=leak<=GROWTH)
floor_ok=(report_tps>=TPS_FLOOR) if TPS_FLOOR>0 else True
retention_ok=(retention>=RETENTION_MIN) if ROUNDS>=3 else True
PASS=clean_fit and floor_ok and (retention_ok if VALIDATE else True)
# Leak signal = growth AFTER warm-up, not the cold->warm pool fill (which is
# expected: N concurrent long contexts allocate KV blocks up to the working
# set). warm baseline = after round 2 (the pool has filled); leak = final-warm.
warm_i = 1 if ROUNDS >= 3 else 0
warm = vram_by_round[warm_i]
pool_fill = warm - vram0 if vram0 >= 0 else -1
leak = vram_by_round[-1] - warm if vram0 >= 0 else -1
print(f"\n=== verdict (N={N}) ===")
print(f" VRAM: cold {vram0} -> warm {warm} MB (pool fill {pool_fill} MB, expected) "
f"-> final {vram_by_round[-1]} MB (post-warm growth {leak} MB / {GROWTH} threshold)")
clean = (bad == 0) and (0 <= leak <= GROWTH)
print(f" concurrency {N} @ ~{PTOK} tok: {'PASS — sustained clean' if clean else 'FAIL — see rounds above'}")
if clean:
print(f" envelope row: max_num_seqs: {N} "
f"validated: {{ concurrency_soak: '{N} @ ~{PTOK//1000}K, {leak} MB post-warm growth' }}")
raise SystemExit(0 if clean else 1)
f"-> final {vram_by_round[-1]} MB (post-warm growth {leak} MB / {GROWTH}) peak {vram_peak} MB")
print(f" per-stream decode: {report_tps:.1f} tok/s (steady) · retention {retention*100:.1f}% "
f"(min {RETENTION_MIN*100:.0f}%)" + (f" · floor {TPS_FLOOR:.0f}" if TPS_FLOOR>0 else " · floor off"))
flags=[]
if not clean_fit: flags.append("fit")
if TPS_FLOOR>0 and not floor_ok: flags.append("tps-floor")
if VALIDATE and not retention_ok: flags.append("retention")
print(f" concurrency {N} @ ~{PTOK} tok: {'PASS — sustained clean' if PASS else 'FAIL — '+','.join(flags)}")
if PASS:
print(f" envelope row: max_num_seqs: {N} validated: {{ concurrency_soak: "
f"'{N} @ ~{PTOK//1000}K, {report_tps:.0f} tok/s/stream, {leak} MB post-warm', "
f"vram_peak_gb: {vram_peak/1024:.1f} }}")
# machine-readable line for SWEEP parsing
print(f"RESULT N={N} clean={int(clean_fit)} pass={int(PASS)} mps_tps={report_tps:.2f} "
f"retention={retention:.3f} leak={leak} vram_peak={vram_peak} floor_ok={int(floor_ok)}")
raise SystemExit(0 if PASS else 1)
PY
}
# --- SWEEP: reboot per N, probe, find the throughput knee ----------------------
if [[ -n "$SWEEP" ]]; then
if [[ -z "$SLUG" ]]; then
echo "SWEEP needs SLUG=<compose slug> — vLLM can't hot-change max-num-seqs, so each N is a reboot." >&2
exit 2
fi
echo "[sweep] slug=$SLUG N in { $SWEEP } · floor=${TPS_FLOOR} tok/s/stream · reboots the server per N"
knee=""; knee_tps=""
for N in $SWEEP; do
if [[ "$SWEEP_DRY" == "1" ]]; then
echo "[sweep:dry] would: MAX_NUM_SEQS=$N switch.sh $SLUG -> wait ready -> probe N=$N"
continue
fi
echo "[sweep] boot $SLUG @ MAX_NUM_SEQS=$N ..."
if ! MAX_NUM_SEQS="$N" bash "$ROOT_DIR/scripts/switch.sh" "$SLUG" >/dev/null 2>&1; then
echo "[sweep] N=$N: boot FAILED — skipping"; continue
fi
ready=0
for _ in $(seq 1 $(( BOOT_TIMEOUT / 2 )) ); do
if curl -s -m 3 "${URL}/v1/models" >/dev/null 2>&1; then ready=1; break; fi
sleep 2
done
if [[ "$ready" != "1" ]]; then echo "[sweep] N=$N: not ready in ${BOOT_TIMEOUT}s — skipping"; continue; fi
out="$(run_probe "$N" || true)"; echo "$out"
line="$(printf '%s\n' "$out" | grep -m1 '^RESULT ' || true)"
clean="$(sed -n 's/.* clean=\([0-9]*\).*/\1/p' <<<"$line")"
mps="$(sed -n 's/.* mps_tps=\([0-9.]*\).*/\1/p' <<<"$line")"
# knee = largest N that is fit-clean AND (floor off OR per-stream TPS >= floor)
above="$(awk -v t="$mps" -v f="$TPS_FLOOR" 'BEGIN{print (f<=0 || t>=f)?1:0}')"
if [[ "$clean" == "1" && "$above" == "1" ]]; then knee="$N"; knee_tps="$mps"; fi
done
echo ""
echo "=== sweep knee ==="
if [[ -n "$knee" ]]; then
echo " largest clean N at/above floor: N=$knee (${knee_tps} tok/s/stream)"
echo " -> validate the envelope row at max_num_seqs: $knee"
else
echo " no N met the bar — lower the sweep range or the target_ctx, or check the floor."
fi
exit 0
fi
# --- single-N mode -------------------------------------------------------------
echo "[concurrency-probe] URL=$URL model=$MODEL N=$CONCURRENCY rounds=$ROUNDS prompt=${PROMPT_TOKENS}tok gen=${GEN_TOKENS}" \
"$( [[ "$TPS_FLOOR" != "0" ]] && echo "floor=${TPS_FLOOR}" )"
run_probe "$CONCURRENCY"

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# test-concurrency-probe — offline guards for scripts/concurrency-probe.sh (the
# #246 Phase 2 soak-validation tool). The live probe needs a running server;
# these check only what can be verified without one: syntax, the SWEEP-needs-SLUG
# refusal, and that SWEEP_DRY plans reboots WITHOUT calling switch.sh.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PROBE="$ROOT_DIR/scripts/concurrency-probe.sh"
fail() { echo "FAIL: $1" >&2; exit 1; }
# 1. syntax
bash -n "$PROBE" || fail "bash -n: syntax error"
echo " ✓ syntax"
# 2. SWEEP without SLUG must refuse with exit 2 (a reboot target is mandatory —
# vLLM can't hot-change max-num-seqs, so each N is a boot).
set +e
out="$(SWEEP="4 8" bash "$PROBE" 2>&1)"; rc=$?
set -e
[[ "$rc" == "2" ]] || fail "SWEEP without SLUG should exit 2, got $rc"
grep -q "SWEEP needs SLUG" <<<"$out" || fail "SWEEP-without-SLUG error message missing"
echo " ✓ SWEEP refuses without SLUG (exit 2)"
# 3. SWEEP_DRY prints the plan for every N and must NOT boot the server (a dry
# run never touches switch.sh). Assert on output: one [sweep:dry] line per N,
# no [sweep] boot line, and the knee summary always prints.
out="$(SWEEP="4 8 12" SLUG=vllm/minimal SWEEP_DRY=1 bash "$PROBE" 2>&1)"
[[ "$(grep -c '\[sweep:dry\]' <<<"$out")" == "3" ]] || fail "SWEEP_DRY should print one plan line per N (3)"
grep -q '\[sweep\] boot' <<<"$out" && fail "SWEEP_DRY must not boot the server"
grep -q "sweep knee" <<<"$out" || fail "SWEEP should always print a knee summary"
echo " ✓ SWEEP_DRY plans 3 reboots without booting"
echo "test-concurrency-probe: ok"