d47dd10795
Operator call: the incumbent abliterated model was the first one we could
find, not an optimised pick. sakamakismile/Qwen3.8-27B-AEON-ULTIMATE-
UNCENSORED-NVFP4 (base AEON-7 BF16, abliterix-abliterated, Apache-2.0),
byte-verified at /tank/aimodels/qwen38-27b-aeon-ultimate-nvfp4.
Measured on the same harness, same GPU, cache-busted per playbook 5.
Baseline was RE-measured live before the swap rather than trusted:
incumbent (W4A4+FP8 mixed) AEON (W4A4)
decode bs=1 94.09 tok/s 104.22 +10.8%
MTP acceptance 47.7% 52.3% +4.6pp
abliteration 4/4 4/4
surface 6/6 6/6
weights 22.5 GB 20.6 GB -8.4%
AEON concurrency: conc=1 98.48 tok/s aggregate; conc=6 381.29 aggregate /
63.55 per-stream, MTP holding 50.6% under load.
Surface 6/6 includes vision (image-judge rides this seat) and a 36k-token
needle retrieval, which was the specific risk in going full-W4A4 -- the
packager only validated 32k, and W4A4 long-context collapse is in our own
notes from the Granite work. It held.
reasoning_effort: the AEON template defaults to xhigh (template line 47),
and at xhigh this model can spend its entire budget inside <think> and
emit no answer -- a silent-empty-response hazard for the automated
summarizer/classifier consumers. Seat now pins the default to medium via
--default-chat-template-kwargs, per-request overridable. Override PROVEN
live: chat_template_kwargs.reasoning_effort=bogus returns HTTP 400
carrying the template's own exception text, so caller values genuinely
reach the template and invalid ones fail loudly rather than silently
falling back. Empty GEN_REASONING_EFFORT omits the flag for models that do
not read the kwarg -- the Qwen3.6 line ignores it entirely, where setting
it would be a false lever.
All 7 aliases verified routing. Rollback is one .env line; the previous
build is untouched at /tank/aimodels/qwen38-27b-uncensored-nvfp4-mixed.
TWO GAPS, declared:
- Incumbent concurrency was never captured before the swap (I baselined
bs=1 only), so the conc=1/6 figures have no same-hardware comparator.
- Perplexity NOT measured. eval_quality correctly refused it: under
--speculative-config prompt_logprobs come back ~uniform (median rank
~130k), playbook trap 2. A real PPL number needs both seats served
without spec-decode.
Adds concbench.py (concurrent throughput; wall-clock aggregate, not
sum-of-rates, and delta-based MTP accounting).
139 lines
5.5 KiB
Python
139 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Cache-busted CONCURRENT throughput bench against a vLLM OpenAI seat.
|
|
|
|
Companion to quickbench.py, which is bs=1 only. Single-stream tok/s and
|
|
aggregate throughput are different questions: one is latency-bound, the other is
|
|
batch-bound, and a seat can win on one while losing the other.
|
|
|
|
MEASUREMENT TRAPS THIS AVOIDS (all three have produced confident wrong results
|
|
here before — see docs/pfi/model-quantization-playbook.md §5):
|
|
|
|
1. Prefix caching fakes speed. Every request gets a FRESH UNSEEDED nonce, so
|
|
no prompt is ever repeated. Never seed the cache-buster: a seeded nonce
|
|
regenerates the previous run's prompts verbatim and reads cache-hit
|
|
throughput (~41k tok/s) instead of real prefill (~5k).
|
|
2. MTP acceptance is read from /metrics as a DELTA across the run, not as a
|
|
cumulative total, or a long-lived seat's history swamps the measurement.
|
|
3. Aggregate throughput is computed from the WALL CLOCK of the whole batch,
|
|
not the sum of per-request rates — the latter double-counts overlap and
|
|
reports a number the seat cannot actually deliver.
|
|
|
|
Usage:
|
|
uv run concbench.py --base http://10.250.50.54:8015 \
|
|
--model qwen3.8-27b-uncensored --concurrency 1 --concurrency 6
|
|
"""
|
|
import argparse, json, random, statistics, string, sys, time
|
|
import urllib.request
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
TOPICS = [
|
|
"a coral reef ecosystem", "the Roman aqueduct system",
|
|
"how lithium-ion cells degrade", "the history of the printing press",
|
|
"how radar altimeters work", "glacier mass balance",
|
|
"the design of the Saturn V F-1 engine", "how sourdough fermentation works",
|
|
"the metallurgy of Damascus steel", "how noise-cancelling headphones work",
|
|
"the ecology of mangrove forests", "the development of the marine chronometer",
|
|
]
|
|
|
|
|
|
def nonce(n=14):
|
|
# Unseeded on purpose. A seeded RNG reproduces the previous run's prompts
|
|
# and turns this into a prefix-cache benchmark.
|
|
return "".join(random.choices(string.ascii_lowercase + string.digits, k=n))
|
|
|
|
|
|
def spec_counters(base):
|
|
"""(drafted, accepted) from /metrics, or (None, None) if unavailable."""
|
|
try:
|
|
with urllib.request.urlopen(base + "/metrics", timeout=30) as r:
|
|
text = r.read().decode()
|
|
except Exception:
|
|
return None, None
|
|
d = a = None
|
|
for line in text.splitlines():
|
|
if line.startswith("#"):
|
|
continue
|
|
if line.startswith("vllm:spec_decode_num_draft_tokens_total"):
|
|
d = float(line.rsplit(" ", 1)[1])
|
|
elif line.startswith("vllm:spec_decode_num_accepted_tokens_total"):
|
|
a = float(line.rsplit(" ", 1)[1])
|
|
return d, a
|
|
|
|
|
|
def one(base, model, max_tokens, timeout):
|
|
body = {
|
|
"model": model,
|
|
"messages": [{"role": "user", "content":
|
|
f"[req-{nonce()}] Explain {random.choice(TOPICS)} in detail."}],
|
|
"max_tokens": max_tokens,
|
|
"temperature": 0.7,
|
|
}
|
|
req = urllib.request.Request(
|
|
base + "/v1/chat/completions", data=json.dumps(body).encode(),
|
|
headers={"Content-Type": "application/json"})
|
|
t0 = time.time()
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
d = json.load(r)
|
|
dt = time.time() - t0
|
|
return d["usage"]["completion_tokens"], dt
|
|
|
|
|
|
def run(base, model, conc, reqs, max_tokens, timeout):
|
|
d0, a0 = spec_counters(base)
|
|
t0 = time.time()
|
|
with ThreadPoolExecutor(max_workers=conc) as ex:
|
|
out = list(ex.map(lambda _: one(base, model, max_tokens, timeout), range(reqs)))
|
|
wall = time.time() - t0
|
|
d1, a1 = spec_counters(base)
|
|
|
|
toks = sum(t for t, _ in out)
|
|
lat = [dt for _, dt in out]
|
|
acc = None
|
|
if d0 is not None and a0 is not None and d1 is not None and a1 is not None:
|
|
drafted = d1 - d0
|
|
if drafted > 0:
|
|
acc = (a1 - a0) / drafted * 100
|
|
return {
|
|
"concurrency": conc, "requests": reqs, "wall_s": round(wall, 2),
|
|
"completion_tokens": toks,
|
|
"aggregate_tok_s": round(toks / wall, 2), # wall clock, not sum-of-rates
|
|
"per_stream_tok_s": round(toks / wall / conc, 2),
|
|
"median_latency_s": round(statistics.median(lat), 2),
|
|
"mtp_acceptance_pct": round(acc, 1) if acc is not None else None,
|
|
}
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--base", default="http://10.250.50.54:8015")
|
|
ap.add_argument("--model", default="qwen3.8-27b-uncensored")
|
|
ap.add_argument("--concurrency", type=int, action="append", default=[])
|
|
ap.add_argument("--requests-per-stream", type=int, default=4)
|
|
ap.add_argument("--max-tokens", type=int, default=400)
|
|
ap.add_argument("--timeout", type=int, default=900)
|
|
ap.add_argument("--tag", default="run")
|
|
ap.add_argument("--out", default=None)
|
|
a = ap.parse_args()
|
|
concs = a.concurrency or [1, 6]
|
|
|
|
results = []
|
|
for c in concs:
|
|
reqs = c * a.requests_per_stream
|
|
r = run(a.base, a.model, c, reqs, a.max_tokens, a.timeout)
|
|
results.append(r)
|
|
print(f" conc={c:<3} reqs={reqs:<3} wall={r['wall_s']:>7.2f}s "
|
|
f"aggregate={r['aggregate_tok_s']:>8.2f} tok/s "
|
|
f"per-stream={r['per_stream_tok_s']:>7.2f} "
|
|
f"median-lat={r['median_latency_s']:>6.2f}s "
|
|
f"MTP={r['mtp_acceptance_pct']}%")
|
|
|
|
if a.out:
|
|
json.dump({"tag": a.tag, "model": a.model, "base": a.base,
|
|
"max_tokens": a.max_tokens, "results": results},
|
|
open(a.out, "w"), indent=2)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|