74f596b1d3
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.
129 lines
6.2 KiB
Python
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())
|