59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
674 lines
24 KiB
Python
674 lines
24 KiB
Python
"""
|
||
MiniMax-M2.5 NVFP4 (TP2) single-request perf & quality tests.
|
||
|
||
Guards against "silly breakage" on the mm25 path by exercising:
|
||
- baseline (overlap + cudagraph): stream decode TPS floor + non-stream e2e
|
||
TPS floor + sampling (flashinfer) smoke
|
||
- no cudagraph: short-gen exact-string match against baseline reference
|
||
- no overlap: stream TPS strictly lower than overlap baseline + short-gen
|
||
exact-string match
|
||
- xgrammar JSON (poem schema): stream decode TPS floor + JSON validity
|
||
- EAGLE3 spec: stream TPS floor + acceptance-length floor (≥ 2.0)
|
||
|
||
Targets B200 2-GPU runners (NVFP4 requires Blackwell).
|
||
|
||
Calibrated 2026-04-29 on 2×B200 running nvidia/MiniMax-M2.5-NVFP4;
|
||
thresholds set with ~5 TPS margin below measured values after the
|
||
trtllm decode-kernel-for-spec routing:
|
||
- baseline stream decode TPS ≈ 217 → floor 212
|
||
- baseline non-stream e2e (384 tok) ≈ 209 → floor 200
|
||
- xgrammar JSON stream decode TPS ≈ 217 → floor 212
|
||
- overlap vs no-overlap stream TPS ratio ≈ 0.78 → cap 0.85
|
||
- EAGLE3 stream decode TPS ≈ 321, accept_len ≈ 2.94 → floors 300 / 2.0
|
||
|
||
Usage:
|
||
cd test/runtime
|
||
python3 -m unittest models.test_mm25_perf -v
|
||
python3 -m unittest models.test_mm25_perf.TestMiniMaxM25Perf.test_baseline -v
|
||
|
||
Env overrides:
|
||
MM25_MODEL default nvidia/MiniMax-M2.5-NVFP4
|
||
MM25_DRAFT default thoughtworks/MiniMax-M2.5-Eagle3
|
||
MM25_MIN_STREAM_TPS default 212
|
||
MM25_MIN_NONSTREAM_TPS default 200
|
||
MM25_MIN_XGRAMMAR_TPS default 212
|
||
MM25_MIN_SPEC_TPS default 300
|
||
MM25_MIN_ACCEPT_LEN default 2.0
|
||
MM25_MAX_NO_OVERLAP_RATIO default 0.85
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import unittest
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
import requests
|
||
|
||
# /test on sys.path so "ci_system.ci_register" resolves from test/ci_system/.
|
||
sys.path.insert(
|
||
0,
|
||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||
)
|
||
from ci_system.ci_register import register_cuda_ci # noqa: E402
|
||
|
||
register_cuda_ci(est_time=2400, suite="runtime-minimax-m2")
|
||
|
||
from tokenspeed_kernel.platform import current_platform # noqa: E402
|
||
|
||
from tokenspeed.runtime.utils.process import kill_process_tree # noqa: E402
|
||
|
||
# ── Config ───────────────────────────────────────────────────────────
|
||
|
||
MODEL = os.environ.get("MM25_MODEL", "nvidia/MiniMax-M2.5-NVFP4")
|
||
DRAFT = os.environ.get("MM25_DRAFT", "thoughtworks/MiniMax-M2.5-Eagle3")
|
||
WORLD_SIZE = 2
|
||
SERVER_LAUNCH_TIMEOUT = 900
|
||
REQUEST_TIMEOUT = 300
|
||
|
||
MIN_STREAM_TPS = float(os.environ.get("MM25_MIN_STREAM_TPS", "212"))
|
||
MIN_NONSTREAM_TPS = float(os.environ.get("MM25_MIN_NONSTREAM_TPS", "200"))
|
||
MIN_XGRAMMAR_TPS = float(os.environ.get("MM25_MIN_XGRAMMAR_TPS", "212"))
|
||
MIN_SPEC_TPS = float(os.environ.get("MM25_MIN_SPEC_TPS", "300"))
|
||
MIN_ACCEPT_LEN = float(os.environ.get("MM25_MIN_ACCEPT_LEN", "2.0"))
|
||
MAX_NO_OVERLAP_RATIO = float(os.environ.get("MM25_MAX_NO_OVERLAP_RATIO", "0.85"))
|
||
|
||
# Long enough to amortize TTFT and keep decode steady-state.
|
||
PERF_MAX_TOKENS = 384
|
||
|
||
# Broad, open-ended prompt that naturally produces long fluent output from a
|
||
# reasoning model (won't bottom out before PERF_MAX_TOKENS).
|
||
PERF_MESSAGES = [
|
||
{
|
||
"role": "user",
|
||
"content": (
|
||
"Explain the history and cultural significance of the Renaissance "
|
||
"period in Europe. Cover its origins, key figures, artistic "
|
||
"innovations, scientific developments, and enduring legacy."
|
||
),
|
||
}
|
||
]
|
||
|
||
# Quality prompts — use substring match. MiniMax-M2.5 is a reasoning model
|
||
# that emits a <think>…</think> prefix; needs ~200 tokens for the thinking to
|
||
# conclude and the answer to appear.
|
||
QUALITY_MAX_TOKENS = 256
|
||
QUALITY_CHECKS = [
|
||
{
|
||
"messages": [
|
||
{
|
||
"role": "user",
|
||
"content": "What is the capital of France? Reply with just the city name.",
|
||
}
|
||
],
|
||
"expected": "Paris",
|
||
},
|
||
{
|
||
"messages": [
|
||
{"role": "user", "content": "What is 2+2? Reply with just the number."}
|
||
],
|
||
"expected": "4",
|
||
},
|
||
]
|
||
|
||
# Determinism prompt — short, fixed; compared across configs by string
|
||
# similarity, not byte-exact.
|
||
DETERMINISM_MESSAGES = [
|
||
{"role": "user", "content": "Reply with exactly the single word: hello"}
|
||
]
|
||
DETERMINISM_MAX_TOKENS = 16
|
||
|
||
# Greedy decode has one benign near-tie token ('The user asks' vs 'says') that
|
||
# flips per server launch under -use_fast_math kernels (#285). Compare with a
|
||
# similarity floor so the determinism guards tolerate that single token while
|
||
# still failing a real divergence.
|
||
SIMILARITY_MIN = 0.95
|
||
|
||
|
||
def _similarity(a: str, b: str) -> float:
|
||
"""Ratcliff/Obershelp string similarity (stdlib)."""
|
||
from difflib import SequenceMatcher
|
||
|
||
return SequenceMatcher(None, a, b).ratio()
|
||
|
||
|
||
# Poem schema. With --reasoning-parser the engine wraps json_schema in
|
||
# a structural tag so the model thinks before emitting JSON.
|
||
POEM_SCHEMA = {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {"type": "string"},
|
||
"content": {"type": "string"},
|
||
},
|
||
"required": ["title", "content"],
|
||
"additionalProperties": False,
|
||
}
|
||
POEM_MESSAGES = [
|
||
{
|
||
"role": "user",
|
||
"content": (
|
||
"Write an original poem about the ocean at dusk, at least 12 "
|
||
"lines. Return JSON with fields title (string) and content "
|
||
"(string, the full poem with line breaks as \\n)."
|
||
),
|
||
}
|
||
]
|
||
XGRAMMAR_MAX_TOKENS = 4096 # reasoning + JSON both fit; 1024 occasionally
|
||
# runs out before the JSON channel opens, leaving ``content=''``.
|
||
# Floor that guards "model actually reasoned before the JSON". Measured
|
||
# ~2000 tok on MiniMax-M2.5; 300 gives plenty of margin while still
|
||
# catching a regression that drops the structural-tag wrap (in which
|
||
# case xgrammar locks onto `{` at token 0 and we'd see ~30-150 tok).
|
||
MIN_XGRAMMAR_GEN_TOKENS = 300
|
||
|
||
# Base args. Notes:
|
||
# - sampling-backend flashinfer: exercises the flashinfer sampling path on
|
||
# every test.
|
||
# - reasoning-parser minimax: MiniMax-M2.5 emits <think>…</think>. With
|
||
# reasoning_parser set, xgrammar defers the response-format constraint
|
||
# past the reasoning channel via a structural tag, so grammar-constrained
|
||
# tests (test_xgrammar) still get to think freely before writing JSON.
|
||
# - mem-fraction-static 0.50 / kvstore-ratio 1.0: shrink init footprint so
|
||
# the server comes up in ~60s and leaves headroom for the EAGLE3 draft
|
||
# model.
|
||
BASE_ARGS: Tuple[str, ...] = (
|
||
"--trust-remote-code",
|
||
"--attention-backend",
|
||
"trtllm",
|
||
"--block-size",
|
||
"32",
|
||
"--moe-backend",
|
||
"flashinfer_trtllm",
|
||
"--sampling-backend",
|
||
"flashinfer",
|
||
"--reasoning-parser",
|
||
"minimax",
|
||
"--max-num-seqs",
|
||
"4",
|
||
"--max-cudagraph-capture-size",
|
||
"4",
|
||
"--gpu-memory-utilization",
|
||
"0.50",
|
||
"--kvstore-ratio",
|
||
"1.0",
|
||
)
|
||
|
||
_server_port = 23100
|
||
|
||
|
||
def _next_port() -> int:
|
||
global _server_port
|
||
p = _server_port
|
||
_server_port += 1
|
||
return p
|
||
|
||
|
||
# ── Server lifecycle ─────────────────────────────────────────────────
|
||
|
||
|
||
def _serve_server(port: int, extra_args=()) -> subprocess.Popen:
|
||
# Use `python -m tokenspeed.cli serve` rather than the `ts` console
|
||
# script so we don't depend on PATH setup in the CI runner.
|
||
cmd = [
|
||
sys.executable,
|
||
"-m",
|
||
"tokenspeed.cli",
|
||
"serve",
|
||
"--model",
|
||
MODEL,
|
||
"--host",
|
||
"127.0.0.1",
|
||
"--port",
|
||
str(port),
|
||
"--world-size",
|
||
str(WORLD_SIZE),
|
||
*BASE_ARGS,
|
||
*extra_args,
|
||
]
|
||
return subprocess.Popen(cmd, env=os.environ.copy())
|
||
|
||
|
||
def _wait_for_server(port: int, timeout: int = SERVER_LAUNCH_TIMEOUT) -> bool:
|
||
url = f"http://127.0.0.1:{port}/readiness"
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
try:
|
||
if requests.get(url, timeout=5).status_code == 200:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
time.sleep(5)
|
||
return False
|
||
|
||
|
||
# ── Request helpers ──────────────────────────────────────────────────
|
||
|
||
|
||
def _chat_nonstream(
|
||
port: int,
|
||
messages,
|
||
max_tokens: int,
|
||
response_format: Optional[Dict] = None,
|
||
**sampling,
|
||
) -> Tuple[str, int, float, Dict]:
|
||
payload = {
|
||
"model": MODEL,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
"temperature": 0.0,
|
||
"stream": False,
|
||
**sampling,
|
||
}
|
||
if response_format is not None:
|
||
payload["response_format"] = response_format
|
||
|
||
t0 = time.time()
|
||
resp = requests.post(
|
||
f"http://127.0.0.1:{port}/v1/chat/completions",
|
||
json=payload,
|
||
timeout=REQUEST_TIMEOUT,
|
||
)
|
||
elapsed = time.time() - t0
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
msg = data["choices"][0]["message"]
|
||
# With --reasoning-parser, content is post-</think>; for substring
|
||
# quality checks we want either channel.
|
||
content = msg.get("content") or ""
|
||
reasoning = msg.get("reasoning_content") or ""
|
||
full = (reasoning + "\n" + content) if reasoning else content
|
||
completion_tokens = data["usage"]["completion_tokens"]
|
||
return full, completion_tokens, elapsed, data.get("usage", {})
|
||
|
||
|
||
def _chat_stream(
|
||
port: int,
|
||
messages,
|
||
max_tokens: int,
|
||
response_format: Optional[Dict] = None,
|
||
**sampling,
|
||
) -> Tuple[str, int, float, float, Dict]:
|
||
"""
|
||
Returns (content, completion_tokens, ttft_seconds, decode_elapsed_seconds, usage).
|
||
decode_elapsed excludes the first-token window (measured from first content
|
||
chunk timestamp to the last content chunk timestamp).
|
||
"""
|
||
payload = {
|
||
"model": MODEL,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
"temperature": 0.0,
|
||
"stream": True,
|
||
"stream_options": {"include_usage": True},
|
||
**sampling,
|
||
}
|
||
if response_format is not None:
|
||
payload["response_format"] = response_format
|
||
|
||
t_start = time.time()
|
||
t_first: Optional[float] = None
|
||
t_last: Optional[float] = None
|
||
pieces: List[str] = []
|
||
usage: Dict = {}
|
||
|
||
with requests.post(
|
||
f"http://127.0.0.1:{port}/v1/chat/completions",
|
||
json=payload,
|
||
stream=True,
|
||
timeout=REQUEST_TIMEOUT,
|
||
) as resp:
|
||
resp.raise_for_status()
|
||
for raw in resp.iter_lines():
|
||
if not raw:
|
||
continue
|
||
line = raw.decode("utf-8")
|
||
if not line.startswith("data:"):
|
||
continue
|
||
body = line[len("data:") :].strip()
|
||
if body == "[DONE]":
|
||
break
|
||
chunk = json.loads(body)
|
||
if chunk.get("usage"):
|
||
usage = chunk["usage"]
|
||
for ch in chunk.get("choices") or []:
|
||
delta = ch.get("delta") or {}
|
||
# With --reasoning-parser, tokens arrive as either content or
|
||
# reasoning_content (same decode cost). Count both so the TPS
|
||
# reflects full decode throughput, not just the post-think
|
||
# tail. Keep `pieces` as only the final content — callers
|
||
# like the xgrammar test parse that as JSON.
|
||
reasoning_piece = delta.get("reasoning_content")
|
||
content_piece = delta.get("content")
|
||
if reasoning_piece or content_piece:
|
||
now = time.time()
|
||
if t_first is None:
|
||
t_first = now
|
||
t_last = now
|
||
if content_piece:
|
||
pieces.append(content_piece)
|
||
|
||
content = "".join(pieces)
|
||
completion_tokens = int(usage.get("completion_tokens", 0))
|
||
ttft = (t_first - t_start) if t_first else 0.0
|
||
decode_elapsed = (
|
||
(t_last - t_first) if (t_first and t_last and t_last > t_first) else 0.0
|
||
)
|
||
return content, completion_tokens, ttft, decode_elapsed, usage
|
||
|
||
|
||
def _stream_decode_tps(completion_tokens: int, decode_elapsed: float) -> float:
|
||
# Exclude the first token from the rate (TTFT window).
|
||
if decode_elapsed <= 0 or completion_tokens <= 1:
|
||
return 0.0
|
||
return (completion_tokens - 1) / decode_elapsed
|
||
|
||
|
||
def _e2e_tps(completion_tokens: int, elapsed: float) -> float:
|
||
if elapsed <= 0 or completion_tokens <= 0:
|
||
return 0.0
|
||
return completion_tokens / elapsed
|
||
|
||
|
||
def _run_quality_checks(self, port: int, label: str):
|
||
for i, q in enumerate(QUALITY_CHECKS):
|
||
content, _, _, _ = _chat_nonstream(
|
||
port, q["messages"], max_tokens=QUALITY_MAX_TOKENS
|
||
)
|
||
self.assertIn(
|
||
q["expected"],
|
||
content,
|
||
f"[{label}] quality check {i}: expected {q['expected']!r} "
|
||
f"in reply {content!r}",
|
||
)
|
||
|
||
|
||
# ── Tests ────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestMiniMaxM25Perf(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
# TODO: switch to amd/MiniMax-M2.5-MXFP4 on AMD.
|
||
if current_platform().is_amd:
|
||
raise unittest.SkipTest("Skip NVFP4 on AMD")
|
||
|
||
def _with_server(self, extra_args, fn, launch_timeout=SERVER_LAUNCH_TIMEOUT):
|
||
port = _next_port()
|
||
proc = _serve_server(port, extra_args)
|
||
try:
|
||
if not _wait_for_server(port, timeout=launch_timeout):
|
||
self.fail(
|
||
f"Server did not become ready within {launch_timeout}s "
|
||
f"(args={extra_args})"
|
||
)
|
||
return fn(port)
|
||
finally:
|
||
kill_process_tree(proc.pid)
|
||
# Brief delay so the kernel releases GPU memory before next launch.
|
||
time.sleep(10)
|
||
|
||
# Baseline: overlap + cudagraph (defaults). TPS floors + quality + sampling.
|
||
def test_baseline(self):
|
||
def run(port):
|
||
# Two full-length warmups: the first decode request after server
|
||
# start runs noticeably slower (GPU state, prefix/kv cache not
|
||
# populated) — reading those numbers as steady-state would be
|
||
# noisy. Run a perf-sized non-stream and stream request before we
|
||
# measure.
|
||
_chat_nonstream(port, PERF_MESSAGES, max_tokens=PERF_MAX_TOKENS)
|
||
_chat_stream(port, PERF_MESSAGES, max_tokens=PERF_MAX_TOKENS)
|
||
|
||
# Stream decode TPS (excludes first token). Take best of 2 to
|
||
# tolerate ~5-10% run-to-run variance from CUDA scheduling noise.
|
||
stream_tps_runs = []
|
||
for _ in range(2):
|
||
_, tok_s, ttft, decode_elapsed, _ = _chat_stream(
|
||
port, PERF_MESSAGES, max_tokens=PERF_MAX_TOKENS
|
||
)
|
||
stream_tps_runs.append(
|
||
(
|
||
tok_s,
|
||
ttft,
|
||
decode_elapsed,
|
||
_stream_decode_tps(tok_s, decode_elapsed),
|
||
)
|
||
)
|
||
best = max(stream_tps_runs, key=lambda r: r[3])
|
||
tok_s, ttft, decode_elapsed, tps_s = best
|
||
for i, (t, f, d, x) in enumerate(stream_tps_runs):
|
||
print(
|
||
f"[baseline stream r{i}] tok={t} ttft={f:.3f}s "
|
||
f"decode={d:.3f}s decode_tps={x:.1f}"
|
||
)
|
||
print(f"[baseline stream best] decode_tps={tps_s:.1f}")
|
||
self.assertGreaterEqual(tok_s, PERF_MAX_TOKENS // 2)
|
||
self.assertGreaterEqual(
|
||
tps_s,
|
||
MIN_STREAM_TPS,
|
||
f"best-of-2 stream decode TPS {tps_s:.1f} < floor {MIN_STREAM_TPS}",
|
||
)
|
||
|
||
# Non-stream e2e TPS (includes TTFT). Best-of-2 as well.
|
||
ns_runs = []
|
||
for _ in range(2):
|
||
_, tok_ns, elapsed_ns, _ = _chat_nonstream(
|
||
port, PERF_MESSAGES, max_tokens=PERF_MAX_TOKENS
|
||
)
|
||
ns_runs.append((tok_ns, elapsed_ns, _e2e_tps(tok_ns, elapsed_ns)))
|
||
best_ns = max(ns_runs, key=lambda r: r[2])
|
||
tok_ns, elapsed_ns, tps_ns = best_ns
|
||
for i, (t, e, x) in enumerate(ns_runs):
|
||
print(
|
||
f"[baseline non-stream r{i}] tok={t} elapsed={e:.3f}s "
|
||
f"e2e_tps={x:.1f}"
|
||
)
|
||
print(f"[baseline non-stream best] e2e_tps={tps_ns:.1f}")
|
||
self.assertGreaterEqual(tok_ns, PERF_MAX_TOKENS // 2)
|
||
self.assertGreaterEqual(
|
||
tps_ns,
|
||
MIN_NONSTREAM_TPS,
|
||
f"best-of-2 non-stream e2e TPS {tps_ns:.1f} < floor {MIN_NONSTREAM_TPS}",
|
||
)
|
||
|
||
# Sampling (flashinfer backend): temperature > 0, top_p < 1.
|
||
# Only asserts the path works & produces non-empty output.
|
||
content_samp, tok_samp, _, _ = _chat_nonstream(
|
||
port,
|
||
PERF_MESSAGES,
|
||
max_tokens=128,
|
||
temperature=0.7,
|
||
top_p=0.9,
|
||
)
|
||
print(
|
||
f"[baseline sampling T=0.7 top_p=0.9] tok={tok_samp} "
|
||
f"preview={content_samp[:80]!r}"
|
||
)
|
||
self.assertGreater(len(content_samp), 0)
|
||
self.assertGreaterEqual(tok_samp, 32)
|
||
|
||
_run_quality_checks(self, port, "baseline")
|
||
|
||
self._with_server((), run)
|
||
|
||
# Content-determinism helper: baseline short-gen output used as reference.
|
||
def _capture_reference_short_gen(self) -> str:
|
||
def run(port):
|
||
content, _, _, _ = _chat_nonstream(
|
||
port,
|
||
DETERMINISM_MESSAGES,
|
||
max_tokens=DETERMINISM_MAX_TOKENS,
|
||
)
|
||
return content
|
||
|
||
return self._with_server((), run)
|
||
|
||
# --enforce-eager: short-gen output must equal baseline reference.
|
||
# No speed floor (eager is slower by design).
|
||
def test_no_cudagraph(self):
|
||
reference = self._capture_reference_short_gen()
|
||
print(f"[no_cudagraph ref] {reference!r}")
|
||
|
||
def run(port):
|
||
content, _, _, _ = _chat_nonstream(
|
||
port,
|
||
DETERMINISM_MESSAGES,
|
||
max_tokens=DETERMINISM_MAX_TOKENS,
|
||
)
|
||
print(f"[no_cudagraph actual] {content!r}")
|
||
sim = _similarity(content, reference)
|
||
print(f"[no_cudagraph similarity] {sim:.4f}")
|
||
self.assertGreaterEqual(
|
||
sim,
|
||
SIMILARITY_MIN,
|
||
f"short-gen output under --enforce-eager must match baseline "
|
||
f"(similarity {sim:.4f} < {SIMILARITY_MIN}); only the "
|
||
f"documented fast-math near-tie token may differ",
|
||
)
|
||
_run_quality_checks(self, port, "no_cudagraph")
|
||
|
||
self._with_server(("--enforce-eager",), run)
|
||
|
||
# --disable-overlap-schedule: TPS strictly below overlap + exact short-gen match.
|
||
def test_overlap_vs_no_overlap(self):
|
||
def measure(port):
|
||
_, tok, _, decode_elapsed, _ = _chat_stream(
|
||
port, PERF_MESSAGES, max_tokens=PERF_MAX_TOKENS
|
||
)
|
||
ref_short, _, _, _ = _chat_nonstream(
|
||
port,
|
||
DETERMINISM_MESSAGES,
|
||
max_tokens=DETERMINISM_MAX_TOKENS,
|
||
)
|
||
return _stream_decode_tps(tok, decode_elapsed), ref_short
|
||
|
||
overlap_tps, overlap_short = self._with_server((), measure)
|
||
no_overlap_tps, no_overlap_short = self._with_server(
|
||
("--disable-overlap-schedule",), measure
|
||
)
|
||
|
||
print(
|
||
f"[overlap vs no-overlap] overlap={overlap_tps:.1f} "
|
||
f"no_overlap={no_overlap_tps:.1f} "
|
||
f"ratio={no_overlap_tps / max(overlap_tps, 1e-6):.3f}"
|
||
)
|
||
print(f"[overlap short] {overlap_short!r}")
|
||
print(f"[no_overlap short] {no_overlap_short!r}")
|
||
self.assertLess(
|
||
no_overlap_tps,
|
||
overlap_tps * MAX_NO_OVERLAP_RATIO,
|
||
f"no-overlap TPS ({no_overlap_tps:.1f}) should be < "
|
||
f"{MAX_NO_OVERLAP_RATIO:.2f} × overlap ({overlap_tps:.1f})",
|
||
)
|
||
sim = _similarity(no_overlap_short, overlap_short)
|
||
print(f"[overlap vs no-overlap similarity] {sim:.4f}")
|
||
self.assertGreaterEqual(
|
||
sim,
|
||
SIMILARITY_MIN,
|
||
f"short-gen output under --disable-overlap-schedule must match overlap "
|
||
f"(similarity {sim:.4f} < {SIMILARITY_MIN}); only the documented "
|
||
f"fast-math near-tie token may differ",
|
||
)
|
||
|
||
# xgrammar poem: stream decode TPS + JSON validity.
|
||
def test_xgrammar(self):
|
||
def run(port):
|
||
_chat_nonstream(port, PERF_MESSAGES, max_tokens=64) # warmup
|
||
|
||
content, tok, ttft, decode_elapsed, _ = _chat_stream(
|
||
port,
|
||
POEM_MESSAGES,
|
||
max_tokens=XGRAMMAR_MAX_TOKENS,
|
||
response_format={
|
||
"type": "json_schema",
|
||
"json_schema": {"name": "Poem", "schema": POEM_SCHEMA},
|
||
},
|
||
)
|
||
tps = _stream_decode_tps(tok, decode_elapsed)
|
||
print(
|
||
f"[xgrammar poem stream] tok={tok} ttft={ttft:.3f}s "
|
||
f"decode={decode_elapsed:.3f}s decode_tps={tps:.1f}"
|
||
)
|
||
print(f"[xgrammar poem content] {content[:200]!r}")
|
||
self.assertGreaterEqual(
|
||
tok,
|
||
MIN_XGRAMMAR_GEN_TOKENS,
|
||
f"xgrammar generation too short ({tok} tok) — structural-tag "
|
||
f"wrap likely dropped; expected reasoning + JSON ≥"
|
||
f"{MIN_XGRAMMAR_GEN_TOKENS} tok",
|
||
)
|
||
self.assertGreaterEqual(
|
||
tps,
|
||
MIN_XGRAMMAR_TPS,
|
||
f"xgrammar JSON stream decode TPS {tps:.1f} < floor "
|
||
f"{MIN_XGRAMMAR_TPS}",
|
||
)
|
||
|
||
try:
|
||
obj = json.loads(content)
|
||
except json.JSONDecodeError as e:
|
||
self.fail(
|
||
f"xgrammar JSON output failed to parse: {e!r}; "
|
||
f"content={content!r}"
|
||
)
|
||
self.assertIsInstance(obj, dict)
|
||
self.assertIn("title", obj)
|
||
self.assertIn("content", obj)
|
||
self.assertIsInstance(obj["title"], str)
|
||
self.assertIsInstance(obj["content"], str)
|
||
self.assertGreater(len(obj["title"]), 0, "poem title is empty")
|
||
self.assertGreater(len(obj["content"]), 40, "poem content too short")
|
||
|
||
self._with_server(("--grammar-backend", "xgrammar"), run)
|
||
|
||
# EAGLE3 spec: stream decode TPS floor + acceptance-length floor.
|
||
def test_eagle3_spec(self):
|
||
spec_args = (
|
||
"--speculative-algorithm",
|
||
"EAGLE3",
|
||
"--speculative-draft-model-path",
|
||
DRAFT,
|
||
"--speculative-num-steps",
|
||
"3",
|
||
)
|
||
|
||
def run(port):
|
||
_chat_nonstream(port, PERF_MESSAGES, max_tokens=64) # warmup
|
||
|
||
_, tok, ttft, decode_elapsed, _ = _chat_stream(
|
||
port, PERF_MESSAGES, max_tokens=PERF_MAX_TOKENS
|
||
)
|
||
tps = _stream_decode_tps(tok, decode_elapsed)
|
||
|
||
# accept_draft_tokens is the extras-per-verify rate; true "accept
|
||
# length" (including the bonus token) = accept_draft + 1.
|
||
_, _, _, usage_ns = _chat_nonstream(
|
||
port, PERF_MESSAGES, max_tokens=PERF_MAX_TOKENS
|
||
)
|
||
accept_draft = usage_ns.get("accept_draft_tokens")
|
||
accept_len = (accept_draft + 1) if accept_draft is not None else None
|
||
print(
|
||
f"[eagle3] tok={tok} ttft={ttft:.3f}s decode={decode_elapsed:.3f}s "
|
||
f"decode_tps={tps:.1f} accept_draft={accept_draft} "
|
||
f"accept_len={accept_len}"
|
||
)
|
||
self.assertGreaterEqual(tok, PERF_MAX_TOKENS // 2)
|
||
self.assertGreaterEqual(
|
||
tps,
|
||
MIN_SPEC_TPS,
|
||
f"EAGLE3 stream decode TPS {tps:.1f} < floor {MIN_SPEC_TPS}",
|
||
)
|
||
if accept_len is not None:
|
||
self.assertGreaterEqual(
|
||
accept_len,
|
||
MIN_ACCEPT_LEN,
|
||
f"EAGLE3 accept length {accept_len:.2f} < floor {MIN_ACCEPT_LEN}",
|
||
)
|
||
|
||
self._with_server(spec_args, run, launch_timeout=SERVER_LAUNCH_TIMEOUT + 300)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|