项目文件夹

文件
2026-07-13 12:24:33 +08:00

281 行
8.8 KiB
Python

此文件含有不可见的 Unicode 字符
此文件含有人类无法区分的不可见的 Unicode 字符,但可以由计算机进行不同的处理。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""
Interactive TTFTbenchmark with optional (optin) KVcache flush + repeats.
Contextfile precedence
-----------------------
1. --context_file FILE → read FILE
2. --context_file (no FILE) → ../ffmpeg.txt
3. (flag omitted) → generate random ASCII filler
"""
# Future
from __future__ import annotations
# Standard
from io import StringIO
from pathlib import Path
from typing import List
import argparse
import json
import random
import string
import sys
import threading
import time
# Third Party
from openai import OpenAI
from transformers import AutoTokenizer, PreTrainedTokenizerBase
# ----------------------------------------------------------------------
SAFETY_MARGIN = 2048 # tokens kept free below model ctx limit
FILLER_LEN_CHARS = 100_000 # ≈ length of each cachefiller prompt
NUM_FILLER_PROMPTS = 10 # how many fillers to send for eviction
DEFAULT_FFMPEG = "ffmpeg.txt"
# ----------------------------------------------------------------------
# ---------------- helper utilities ------------------------------------
def rand_ascii(n: int) -> str:
return "".join(random.choices(string.ascii_letters + string.digits, k=n))
def truncate_to_tokens(
text: str,
max_tokens: int,
tok: PreTrainedTokenizerBase,
) -> str:
ids = tok.encode(
text, add_special_tokens=False, truncation=True, max_length=max_tokens
)
return tok.decode(ids, skip_special_tokens=True)
def log_jsonl(path: Path, rec: dict) -> None:
with path.open("a", encoding="utf-8") as fh:
json.dump(rec, fh)
fh.write("\n")
# ---------------- tiny CLI spinner ------------------------------------
class Printer:
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
def _spin(self) -> None:
idx = 0
while not self._stop_event.is_set():
print(f"\033[31m\r{'>' * (idx % 6):<6}\033[0m", end="", flush=True)
idx += 1
time.sleep(0.2)
def start(self) -> None:
if self._thread is None:
self._stop_event.clear()
self._thread = threading.Thread(target=self._spin, daemon=True)
self._thread.start()
def stop(self) -> None:
if self._thread is not None:
self._stop_event.set()
self._thread.join()
self._thread = None
print("\033[31m\r>>>>> \033[0m", end="", flush=True)
# ---------------- benchmark helpers -----------------------------------
def build_chat(system_doc: str, user_prompt: str) -> List[dict]:
return [
{"role": "user", "content": f"I've got a document:\n```\n{system_doc}\n```"},
{"role": "assistant", "content": "I've got your document."},
{"role": "user", "content": user_prompt},
]
def ttft_stream(
client: OpenAI,
model: str,
messages: list[dict],
printer: Printer | None = None,
) -> tuple[float, str]:
start = time.perf_counter()
stream = client.chat.completions.create(
model=model,
messages=messages,
temperature=0.0,
stream=True,
max_tokens=1024,
)
first_tok_t: float | None = None
buf = StringIO()
if printer:
printer.start()
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
if first_tok_t is None:
first_tok_t = time.perf_counter()
if printer:
printer.stop()
print(delta.content, end="", flush=True)
buf.write(delta.content)
print() # newline after streaming
if first_tok_t is None:
raise RuntimeError("no tokens returned")
return first_tok_t - start, buf.getvalue()
def flush_kv_cache(client: OpenAI, model: str) -> None:
filler_chat = build_chat(rand_ascii(FILLER_LEN_CHARS), "noop")
for _ in range(NUM_FILLER_PROMPTS):
client.chat.completions.create(
model=model,
messages=filler_chat,
temperature=0.0,
max_tokens=1,
stream=False,
)
# ---------------- commandline parsing --------------------------------
def parse_args() -> argparse.Namespace:
# legacy singlepositional <port> usage
if len(sys.argv) == 2 and sys.argv[1].isdigit():
port = sys.argv[1]
sys.argv = [sys.argv[0], "--api_base", f"http://localhost:{port}/v1"]
ap = argparse.ArgumentParser(
prog=Path(sys.argv[0]).name,
description="Interactive TTFT benchmark; \
flush cache only with -F/--flush_cache.",
)
ap.add_argument("--api_base", default="http://localhost:8000/v1")
ap.add_argument("--api_key", default="EMPTY")
ap.add_argument(
"--model", help="Model name/ID; default = first entry from /models."
)
# nargs='?' lets the flag appear without a path
ap.add_argument(
"-C",
"--context_file",
nargs="?",
const="",
default=None,
help="FILE → use document, flagonly → ffmpeg.txt, "
"omit flag → synthetic filler",
)
ap.add_argument(
"--max_ctx_tokens",
type=int,
default=131_072,
help="Max tokens kept from the document after truncation.",
)
ap.add_argument(
"--prompt",
default="Summarize this text",
help="User prompt appended after the document.",
)
ap.add_argument(
"--num_following",
type=int,
default=1,
help="Extra measured requests after run 1 to test cache retrieval.",
)
ap.add_argument(
"--flush_cache",
"-F",
action="store_true",
help="Evict GPU KVcache between run1 and followups.",
)
ap.add_argument(
"--out",
default="benchmark.jsonl",
help="JSONL file for results (overwritten each run).",
)
return ap.parse_args()
# ---------------- main routine ----------------------------------------
def main() -> None:
args = parse_args()
client = OpenAI(api_key=args.api_key, base_url=args.api_base)
# pick model (fallback = first listed on the server)
model_id = args.model or client.models.list().data[0].id
# ---------- choose / build the document ---------------------------
if args.context_file is None:
# flag omitted → synthetic filler
# here we will generate a random ASCII string based on the max ctx tokens,
raw_doc = rand_ascii(args.max_ctx_tokens * 4) # ≈4 chars/token
# make the synthetic filler longer and truncate it later after tokenization
elif args.context_file == "":
# flag present w/o file → bundled ffmpeg.txt
raw_doc = Path(DEFAULT_FFMPEG).read_text(encoding="utf-8")
else:
raw_doc = Path(args.context_file).read_text(encoding="utf-8")
# ---------- truncate ------------------------------------------------
try:
tok = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model_ctx = (
tok.model_max_length if tok.model_max_length > 0 else args.max_ctx_tokens
)
doc = truncate_to_tokens(
raw_doc, min(model_ctx - SAFETY_MARGIN, args.max_ctx_tokens), tok
)
except Exception:
char_limit = (args.max_ctx_tokens - SAFETY_MARGIN) * 4 # ≈4 chars/token
doc = raw_doc[:char_limit]
out_path = Path(args.out)
out_path.write_text("", encoding="utf-8") # clear file
printer = Printer()
# ---------------- RUN 1 ----------------
print("\n=== Run 1: baseline TTFT ===")
base_chat = build_chat(doc, args.prompt)
ttft1, gen1 = ttft_stream(client, model_id, base_chat, printer)
print(f"\033[33mTTFT_1 = {ttft1:.3f}s\033")
log_jsonl(
out_path,
{
"run_index": 1,
"context_tokens": len(tok.encode(doc, add_special_tokens=False)),
"ttft_seconds": ttft1,
},
)
# -------------- optional followups --------------
if args.num_following > 0:
if args.flush_cache:
print(f"\nFlushing KVcache with {NUM_FILLER_PROMPTS} prompts …")
flush_kv_cache(client, model_id)
else:
print("\n(no KVcache flush requested)")
for run in range(2, 2 + args.num_following):
label = "postflush" if args.flush_cache else "continued"
print(f"\n=== Run {run}: TTFT {label} ===")
ttft, gen = ttft_stream(client, model_id, base_chat, printer)
print(f"\033[33mTTFT_{run} = {ttft:.3f}s\033[0m • ")
log_jsonl(
out_path,
{
"run_index": run,
"context_tokens": len(tok.encode(doc, add_special_tokens=False)),
"ttft_seconds": ttft,
},
)
time.sleep(5) # brief idle gap
if __name__ == "__main__":
main()