File size: 18,123 Bytes
7850e45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | #!/usr/bin/env python3
"""Compare two Clef servers answer for answer, for example BF16 and FP8.
python3 parity.py run --label bf16 --out bf16.json
python3 parity.py run --label fp8 --out fp8.json
python3 parity.py compare bf16.json fp8.json --report parity-report.md
`run` sends a fixed set of requests, one at a time, and saves every answer:
54 text requests (18 states x 3 question sets: 6 questions per state, in 5
languages, with one long state) and 5 Commons images. Add your own requests with
--requests, one JSON request body per line.
`compare` writes a short Markdown report: how far each probability moved, and
how often the answer changed. It says PASS or FAIL against two limits.
`report` does the same for a run_all.sh results directory, and adds the
quantization details and the GPU memory that vLLM logged.
Standard library only (Python >= 3.9).
"""
import argparse
import base64
import datetime
import json
import os
import re
import socket
import statistics
import sys
import urllib.request
URL = "http://localhost:8080/v1/models/clef-flash:predict"
USER_AGENT = "clef-parity/0.1 (WMF ML team)"
LOCAL = urllib.request.build_opener(urllib.request.ProxyHandler({}))
EDITS = [
("edit_promo", "Edit replaced the article lead with promotional language and removed three citations."),
("edit_typo", "Edit fixed two typos and a broken link in the History section."),
("edit_meme", "Edit replaced the infobox image with an unrelated meme and added 'LOL' to the first sentence."),
("edit_blank", "Edit removed the entire Criticism section without an edit summary."),
("edit_cite", "Edit added a citation to a peer-reviewed journal for the population figure."),
("edit_casino", "Edit added a link to an online casino in the External links section."),
]
STATES = EDITS + [
("json_anon", {"user": "anonymous", "account_age_days": 0, "bytes_changed": -4210, "summary": "",
"page": "Climate change", "tags": ["mobile edit", "blanking"]}),
("json_veteran", {"user": "registered", "account_age_days": 2900, "edit_count": 15400, "bytes_changed": 37,
"summary": "copyedit", "page": "Mount Olympus"}),
("article_restaurant", "Article about a local restaurant, written by its owner, with no independent sources."),
("article_poet", "Stub article about a 19th-century Greek poet with one reference to a national library catalogue."),
("article_band", "Article about a garage band with 40 followers, sourced only to the band's own website."),
("talk_hostile", "Talk page message: 'Stop reverting my edits, you clearly know nothing about this topic.'"),
("talk_polite", "Talk page message: 'Thanks for the review. I added two sources. Could you take another look?'"),
("el", "Η επεξεργασία αντικατέστησε την εισαγωγή του άρθρου με διαφημιστικό κείμενο."),
("es", "La edición añadió una fuente académica y corrigió la fecha de nacimiento."),
("de", "Die Bearbeitung entfernte alle Einzelnachweise aus dem Artikel."),
("ja", "この編集は、出典のない誹謗中傷を人物記事に追加しました。"),
("long_history", "Edit history of one article over a month:\n"
+ "\n".join(f"{i + 1}. {EDITS[i % len(EDITS)][1]}" for i in range(60))),
]
QUESTION_SETS = [
("revert", {"revert": {"type": "noul", "instructions": "Should this edit be reverted?"}}),
("action_severity", {
"action": {"type": "choice", "instructions": "What should happen to this?", "criteria": {
"keep": "Notable enough and fixable by editing.",
"improve": "Needs sources and a neutral rewrite before it can stay.",
"delete": "Not notable and unlikely to become so."}},
"severity": {"type": "score", "instructions": "How severe is the problem?",
"criteria": ["Minor", "Moderate", "Severe"]},
}),
("tone_sources_quality", {
"tone": {"type": "choice", "instructions": "What is the tone?", "criteria": {
"neutral": "Neutral and encyclopedic.", "promotional": "Advertises or praises the subject.",
"hostile": "Attacks a person or group.", "other": "None of the above."}},
"needs_sources": {"type": "noul", "instructions": "Does this need more or better sources?"},
"quality": {"type": "score", "instructions": "How good is this contribution?",
"criteria": ["Very poor", "Poor", "Acceptable", "Good", "Excellent"]},
}),
]
# Thumbnails from Wikimedia Commons, checked on 2026-10-13. Downloaded once.
IMAGES = [
("portrait", "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Jimmy_Wales_by_Philip_Romano_%283x4_cropped%29.jpg/960px-Jimmy_Wales_by_Philip_Romano_%283x4_cropped%29.jpg"),
("building", "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ad/Parthenon_from_west.jpg/960px-Parthenon_from_west.jpg"),
("animal", "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/960px-Cat_November_2010-1a.jpg"),
("painting", "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f9/Declaration_of_Independence_%281819%29%2C_by_John_Trumbull.jpg/960px-Declaration_of_Independence_%281819%29%2C_by_John_Trumbull.jpg"),
("logo", "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/80/Wikipedia-logo-v2.svg/960px-Wikipedia-logo-v2.svg.png"),
]
IMAGE_QUESTIONS = {
"infobox_fit": {"type": "choice", "instructions": "Is this suitable as an infobox image?", "criteria": {
"yes": "Good framing and clear subject.", "maybe": "Usable with reservations.", "no": "Not suitable."}},
"face_visible": {"type": "noul", "instructions": "Is a human face clearly visible?"},
"image_quality": {"type": "score", "instructions": "How good is the image quality?",
"criteria": ["Poor", "Acceptable", "Good"]},
}
def build_requests(args):
requests = [(f"text/{sid}/{qid}", {"state": state, "questions": questions})
for sid, state in STATES for qid, questions in QUESTION_SETS]
if not args.no_images:
os.makedirs(args.image_dir, exist_ok=True)
for name, url in IMAGES:
path = os.path.join(args.image_dir, name + os.path.splitext(url)[1])
if not os.path.exists(path):
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=60) as response, open(path, "wb") as f:
f.write(response.read())
with open(path, "rb") as f:
image = base64.b64encode(f.read()).decode()
requests.append((f"image/{name}", {"state": "Assess this image for use in a Wikipedia article.",
"images": [image], "questions": IMAGE_QUESTIONS}))
if args.requests:
with open(args.requests) as f:
for number, line in enumerate(f, 1):
if line.strip():
body = json.loads(line)
requests.append((f"file/{body.pop('id', number)}", body))
return requests
def run(args):
requests = build_requests(args)
results = {}
for number, (rid, body) in enumerate(requests, 1):
try:
request = urllib.request.Request(args.url, json.dumps(body).encode(), {"Content-Type": "application/json"})
with LOCAL.open(request, timeout=args.timeout) as response:
data = json.load(response)
results[rid] = {"answers": data["answers"], "input_tokens": data["usage"]["input_tokens"]}
except Exception as e: # noqa: BLE001 - an error is a result, not a crash
results[rid] = {"error": repr(e)[:300]}
print(f"[{number}/{len(requests)}] {'ERROR' if 'error' in results[rid] else 'ok '} {rid}", flush=True)
with open(args.out, "w") as f:
json.dump({"label": args.label, "url": args.url, "results": results}, f, ensure_ascii=False, indent=1)
print(f"Saved {len(results)} results to {args.out}")
def probabilities(answer):
"""Return the probability of each option of one answer."""
if answer["type"] == "noul":
return {"yes": answer["noul"]}
return answer["probabilities"]
def decision(answer):
"""Return the answer as a person reads it, and its probability.
That is yes/no at 0.5, the choice, or the most likely score level.
"""
if answer["type"] == "noul":
p = answer["noul"]
return ("yes", p) if p >= 0.5 else ("no", 1 - p)
if answer["type"] == "choice":
return answer["choice"], answer["probabilities"][answer["choice"]]
level = max(answer["probabilities"], key=answer["probabilities"].get)
return level, answer["probabilities"][level]
def parity_lines(path_a, path_b, max_change_limit, min_agreement, sub="##", verdict=True):
"""Compare two saved runs. Return the Markdown lines and whether they pass.
With verdict=False, the lines give the numbers without PASS/FAIL or the limits.
"""
with open(path_a) as f:
a = json.load(f)
with open(path_b) as f:
b = json.load(f)
la, lb = a["label"], b["label"]
changes, flips, errors, token_mismatch = [], [], [], []
same = {"text": [0, 0], "image": [0, 0], "file": [0, 0]}
for rid in sorted(set(a["results"]) & set(b["results"])):
ra, rb = a["results"][rid], b["results"][rid]
if "error" in ra or "error" in rb:
errors.append(rid)
continue
if ra["input_tokens"] != rb["input_tokens"]:
token_mismatch.append(rid)
kind = rid.split("/")[0]
for qid, qa in ra["answers"].items():
qb = rb["answers"][qid]
pa, pb = probabilities(qa), probabilities(qb)
for option in pa:
changes.append((abs(pa[option] - pb[option]), kind, rid, qid, option, pa[option], pb[option]))
same[kind][1] += 1
(da, pda), (db, pdb) = decision(qa), decision(qb)
if da == db:
same[kind][0] += 1
else:
flips.append((rid, qid, da, pda, db, pdb))
def stats(kind=None):
values = sorted(c[0] for c in changes if kind in (None, c[1]))
if not values:
return None
p99 = values[max(0, int(0.99 * len(values)) - 1)]
return statistics.mean(values), p99, values[-1]
total_same = sum(s for s, _ in same.values())
total = sum(t for _, t in same.values())
max_change = stats()[2] if changes else float("nan")
agreement = total_same / total if total else 0.0
passed = not errors and not token_mismatch and max_change <= max_change_limit and agreement >= min_agreement
if verdict:
summary = (
f"**{'PASS' if passed else 'FAIL'}**: largest change {max_change:.4f} (limit {max_change_limit}), "
f"same answer {agreement:.1%} (limit {min_agreement:.0%}), "
)
else:
summary = f"Largest change {max_change:.4f}, same answer {agreement:.1%}, "
lines = [
summary + f"{len(errors)} failed requests, {len(token_mismatch)} with different token counts.",
"",
"| Requests | Questions | Mean change | 99th percentile | Largest change | Same answer |",
"|---|---|---|---|---|---|",
]
for kind in ("text", "image", "file"):
if same[kind][1]:
mean, p99, top = stats(kind)
lines.append(f"| {kind} | {same[kind][1]} | {mean:.4f} | {p99:.4f} | {top:.4f} | "
f"{same[kind][0]}/{same[kind][1]} |")
lines += [
"",
f"*Change* is the difference between the {la} and {lb} probability of one option (0 to 1). "
"*Same answer* means the same choice, the same yes/no at 0.5, or the same most likely score level.",
"",
f"{sub} Largest changes",
"",
f"| Request | Question | Option | {la} | {lb} | Change |",
"|---|---|---|---|---|---|",
]
for change, _, rid, qid, option, pa, pb in sorted(changes, reverse=True)[:10]:
lines.append(f"| {rid} | {qid} | {option} | {pa:.4f} | {pb:.4f} | {change:.4f} |")
if flips:
lines += ["", f"{sub} Answers that changed", "", f"| Request | Question | {la} | {lb} |", "|---|---|---|---|"]
for rid, qid, da, pa, db, pb in flips:
lines.append(f"| {rid} | {qid} | {da} ({pa:.2f}) | {db} ({pb:.2f}) |")
if errors or token_mismatch:
lines += ["", f"{sub} Problems", ""]
lines += [f"- Failed request: {rid}" for rid in errors]
lines += [f"- Different token count: {rid}" for rid in token_mismatch]
return lines, passed
def write_report(path, lines, passed):
text = "\n".join(lines) + "\n"
with open(path, "w") as f:
f.write(text)
print(text)
print(f"Wrote {path}")
sys.exit(0 if passed else 1)
def compare(args):
lines, passed = parity_lines(args.a, args.b, args.max_change, args.min_agreement)
with open(args.b) as f:
lb = json.load(f)["label"]
with open(args.a) as f:
la = json.load(f)["label"]
write_report(args.report, [f"# Clef parity: {lb} against {la}", ""] + lines, passed)
def server_facts(path):
"""Read what vLLM logged about quantization and GPU memory."""
facts = {"quantization": "?", "weights": "?", "kv_cache": "?"}
if not os.path.exists(path):
return facts
with open(path, errors="replace") as f:
log = f.read()
for key, pattern in (
("quantization", r"quantization=([\w-]+)"),
("weights", r"Model loading took ([\d.]+ GiB)"),
("kv_cache", r"Available KV cache memory: ([\d.]+ GiB)"),
):
match = re.search(pattern, log)
if match:
facts[key] = match.group(1)
if facts["quantization"] == "None":
facts["quantization"] = "none (BF16)"
return facts
def report(args):
"""Write one report for the whole run: quantization, GPU memory and answers."""
d = args.results
parity, passed = parity_lines(os.path.join(d, "bf16.json"), os.path.join(d, "fp8.json"),
args.max_change, args.min_agreement, sub="###", verdict=not args.no_verdict)
when = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
lines = [
"# Clef-Flash: FP8 against BF16",
"",
(f"**{'PASS' if passed else 'FAIL'}**. " if not args.no_verdict else "") + f"Run on {socket.gethostname()} on {when}.",
*([""] + args.note if args.note else []),
"",
]
manifest_path = os.path.join(d, "MANIFEST.json")
if os.path.exists(manifest_path):
with open(manifest_path) as f:
m = json.load(f)
v = m["versions"]
lines += [
"## Quantization",
"",
f"- Source: `{m['source']}`. Every input file matched Cloudflare's published hash.",
f"- Tool: llm-compressor {v['llmcompressor']}, scheme `{m['recipe']['scheme']}` "
f"(compressed-tensors {v['compressed_tensors']}, transformers {v['transformers']}, torch {v['torch']}).",
f"- {m['fp8_weights']} weights in FP8, relative error mean {m['relative_error_mean']:.2%}, "
f"max {m['relative_error_max']:.2%}. All other tensors are bit-identical to Cloudflare's.",
"",
]
bf16, fp8 = server_facts(os.path.join(d, "server-bf16.log")), server_facts(os.path.join(d, "server-fp8.log"))
lines += [
"## GPU memory, as logged by vLLM",
"",
"| | BF16 | FP8 |",
"|---|---|---|",
f"| Quantization | {bf16['quantization']} | {fp8['quantization']} |",
f"| Weights | {bf16['weights']} | {fp8['weights']} |",
f"| Free for KV cache | {bf16['kv_cache']} | {fp8['kv_cache']} |",
"",
"Speed on the MI210 means nothing here: it has no FP8 hardware, so vLLM emulates FP8.",
"",
"## Answers",
"",
*parity,
]
write_report(args.out or os.path.join(d, "report.md"), lines, passed)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("run", help="Send the requests to one server and save the answers.")
p.add_argument("--label", required=True, help="Name of this server, for example bf16 or fp8.")
p.add_argument("--out", required=True)
p.add_argument("--url", default=URL)
p.add_argument("--requests", help="Extra requests: a JSONL file, one request body per line.")
p.add_argument("--image-dir", default="parity-images")
p.add_argument("--no-images", action="store_true")
p.add_argument("--timeout", type=float, default=300)
p.set_defaults(func=run)
c = sub.add_parser("compare", help="Compare two saved runs and write the report.")
c.add_argument("a", help="Reference run, for example bf16.json.")
c.add_argument("b", help="Candidate run, for example fp8.json.")
c.add_argument("--report", default="parity-report.md")
c.add_argument("--max-change", type=float, default=0.03)
c.add_argument("--min-agreement", type=float, default=0.99)
c.set_defaults(func=compare)
r = sub.add_parser("report", help="Write one report for a run_all.sh results directory.")
r.add_argument("results", help="Directory with bf16.json, fp8.json, MANIFEST.json and the server logs.")
r.add_argument("--note", action="append", help="A line to add under the title. Repeat for more lines.")
r.add_argument("--no-verdict", action="store_true", help="Leave out PASS/FAIL and the limits, for example to publish.")
r.add_argument("--out", help="Where to write the report. Default: report.md in the results directory.")
r.add_argument("--max-change", type=float, default=0.03)
r.add_argument("--min-agreement", type=float, default=0.99)
r.set_defaults(func=report)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
|