Files
esh-pfi-infrastructure/services/gen-seat-mixed-quant/bench/surface_test.py
T
vh 74f596b1d3 feat(gen-seat): mixed NVFP4+FP8 requant — +18% decode at equal MTP acceptance
Re-quantizes the fleet `gen` seat from weight-only NVFP4A16 to a
mixed-precision build: NVFP4 W4A4 for layers 0-55 MLPs, FP8 W8A8 for the
attention projections / linear_attn / lm_head / layers 56-63 MLPs, FP8 KV
cache. Replicates the scheme of unsloth/Qwen3.8-27B-NVFP4 on the
abliterated weights.

The queued task named this "W4A8" (NVFP4 weights + FP8 activations). That
checkpoint cannot be served: vLLM 0.24's compressed-tensors dispatcher
(compressed_tensors.py:704-713) accepts NVFP4 weights with either no input
quantization (W4A16, which forces the Marlin kernel) or NVFP4 input
quantization (W4A4) -- anything else, FP8 included, raises ValueError at
load. CompressedTensorsW4A8Fp8 is INT4 weights gated on an exact-sm90
check, so it is closed on Blackwell twice over. The ~20% intuition was
correct; the scheme name was not. Getting FP8 into the mix has to be done
per-layer-group.

Established the gain before spending GPU time: unsloth's build was already
on-box, so serving it as a probe measured +19.1% over our seat at identical
MTP acceptance -- a kernel-level result, no requant needed to learn it.

Measured, cache-busted, bs=1:

  decode              80.12 -> 94.53 tok/s   (+18.0%)
  MTP acceptance      47.8% -> 47.7%         (unchanged)
  perplexity (n=6)    6.941 -> 7.059         (+1.7%)
  abliteration        4/4   -> 4/4           (preserved)
  weights on disk     27.7  -> 22.5 GB       (-19%)

Surface test green on the live seat: plain chat, vision, tool calling,
thinking split, 36K-token needle retrieval, streaming. All 7 LiteLLM
aliases verified routing.

GEN_GPU_MEM_UTIL 0.45 -> 0.43: the new weights are 5.2 GB smaller, and at
0.45 the seat absorbed that slack as KV, leaving meromero-charrp 0.18 GiB
short of its budget on the shared GPU0 -- it crash-looped. Handing the
space back leaves gen 422K tokens of KV (1.6x its 262K context) and both
seats co-resident at 89.8/97.9 GB.

Also records two measured negatives so they are not re-chased:
GEN_SPEC_TOKENS is already optimal at 3 (swept 2/3/4/5 -> 77.1/80.1/78.7/
75.9 tok/s), and vLLM's prompt_logprobs are ~uniform while speculative
decoding is on, so perplexity must be measured with spec off.

Pipeline, acceptance harness and raw measurements land in
services/gen-seat-mixed-quant/. Rollback is one .env line; the previous
build is untouched at /tank/aimodels/qwen38-27b-uncensored-nvfp4.
2026-08-15 02:21:00 -07:00

129 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Pre-cutover surface test: every capability the live gen seat actually serves.
The gen seat backs 7 LiteLLM aliases (gen, gen-reasoning, summarizer,
summarizer-large, classifier, image-judge, qwen-image-bench), so a cutover has
to clear vision, tool-calling, the thinking split, long context, and streaming --
not just decode speed.
"""
import base64, json, struct, sys, urllib.request, zlib, argparse
def rpc(base, path, payload, timeout=900):
req = urllib.request.Request(base + path, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def png(w, h, fn):
raw = b"".join(b"\x00" + bytes(v for x in range(w) for v in fn(x, y)) for y in range(h))
def chunk(t, d):
c = t + d
return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base", required=True)
ap.add_argument("--model", required=True)
ap.add_argument("--thinking-model", default=None)
a = ap.parse_args()
B, M = a.base, a.model
results = []
def check(name, ok, detail=""):
results.append((name, ok, detail))
print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail[:150]}")
# 1. plain chat
try:
r = rpc(B, "/v1/chat/completions", {"model": M, "max_tokens": 60, "temperature": 0,
"messages": [{"role": "user", "content": "Name the largest moon of Saturn in one word."}],
"chat_template_kwargs": {"enable_thinking": False}})
c = (r["choices"][0]["message"].get("content") or "")
check("plain chat", "titan" in c.lower(), repr(c.strip()))
except Exception as e:
check("plain chat", False, str(e))
# 2. vision
try:
img = png(64, 64, lambda x, y: (30, 90, 220) if (14 <= x < 50 and 14 <= y < 50) else (250, 250, 250))
b64 = base64.b64encode(img).decode()
r = rpc(B, "/v1/chat/completions", {"model": M, "max_tokens": 60, "temperature": 0,
"messages": [{"role": "user", "content": [
{"type": "text", "text": "What colour is the square in this image? One word."},
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}}]}],
"chat_template_kwargs": {"enable_thinking": False}})
c = (r["choices"][0]["message"].get("content") or "")
check("vision (image)", "blue" in c.lower(), repr(c.strip()))
except Exception as e:
check("vision (image)", False, str(e))
# 3. tool calling
try:
r = rpc(B, "/v1/chat/completions", {"model": M, "max_tokens": 200, "temperature": 0,
"messages": [{"role": "user", "content": "What's the weather in Anaheim? Use the tool."}],
"tools": [{"type": "function", "function": {"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}},
"required": ["city"]}}}]})
tc = r["choices"][0]["message"].get("tool_calls")
ok = bool(tc) and tc[0]["function"]["name"] == "get_weather" and "Anaheim" in tc[0]["function"]["arguments"]
check("tool calling", ok, json.dumps(tc)[:150] if tc else "no tool_calls")
except Exception as e:
check("tool calling", False, str(e))
# 4. thinking split (reasoning parser)
tm = a.thinking_model or M
try:
r = rpc(B, "/v1/chat/completions", {"model": tm, "max_tokens": 400, "temperature": 0,
"messages": [{"role": "user", "content": "A bat and ball cost $1.10 total. The bat costs $1 more than the ball. What does the ball cost?"}],
"chat_template_kwargs": {"enable_thinking": True}})
m = r["choices"][0]["message"]
rc = m.get("reasoning") or m.get("reasoning_content") or ""
c = m.get("content") or ""
check("thinking split", len(rc) > 0 or len(c) > 0,
f"reasoning={len(rc)}ch content={len(c)}ch :: {(c or rc)[:80]!r}")
except Exception as e:
check("thinking split", False, str(e))
# 5. long context (~40k tokens, well past the 32k probe ceiling)
try:
filler = "The archived maintenance log records routine inspection of pump assembly seven. " * 3000
needle = "\n\nIMPORTANT: the calibration passphrase is HELIOTROPE-49.\n\n"
prompt = filler[:len(filler)//2] + needle + filler[len(filler)//2:] + \
"\n\nWhat is the calibration passphrase? Answer with just the passphrase."
r = rpc(B, "/v1/chat/completions", {"model": M, "max_tokens": 40, "temperature": 0,
"messages": [{"role": "user", "content": prompt}],
"chat_template_kwargs": {"enable_thinking": False}})
c = (r["choices"][0]["message"].get("content") or "")
pt = r["usage"]["prompt_tokens"]
check("long context + retrieval", "HELIOTROPE-49" in c.upper(),
f"{pt} prompt tokens -> {c.strip()[:60]!r}")
except Exception as e:
check("long context + retrieval", False, str(e))
# 6. streaming
try:
req = urllib.request.Request(B + "/v1/chat/completions",
data=json.dumps({"model": M, "max_tokens": 60, "temperature": 0, "stream": True,
"messages": [{"role": "user", "content": "Count from 1 to 5."}],
"chat_template_kwargs": {"enable_thinking": False}}).encode(),
headers={"Content-Type": "application/json"})
n = 0
with urllib.request.urlopen(req, timeout=300) as resp:
for line in resp:
if line.startswith(b"data: ") and b"[DONE]" not in line:
n += 1
check("streaming", n > 3, f"{n} SSE chunks")
except Exception as e:
check("streaming", False, str(e))
npass = sum(1 for _, ok, _ in results if ok)
print(f"\n{npass}/{len(results)} passed")
return 0 if npass == len(results) else 1
if __name__ == "__main__":
sys.exit(main())