use std::io::BufRead;
use std::sync::mpsc;
use std::time::Instant;
use crate::bench_client::{
is_token_chunk, BenchReport, BenchSampling, Latency, PrefixTotals, RequestTiming,
};
use anyhow::Result;
use clap::Parser;
use serde_json::{json, Value};
use crate::http;
#[derive(Parser, Debug)]
pub struct ServeBenchArgs {
#[arg(long, default_value = "http://127.0.0.1:8383")]
pub url: String,
#[arg(long, default_value_t = 32)]
pub requests: usize,
#[arg(long, default_value_t = 8)]
pub concurrency: usize,
#[arg(long, default_value_t = 128)]
pub output_len: usize,
#[arg(long, default_value_t = 512)]
pub prompt_chars: usize,
#[arg(long, default_value_t = 0)]
pub shared_prefix: usize,
#[arg(long)]
pub cache_salt: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub json: bool,
}
pub fn run_serve_bench(args: ServeBenchArgs) -> Result<()> {
if args.requests == 0 {
anyhow::bail!("--requests must be at least 1");
}
if args.concurrency == 0 {
anyhow::bail!("--concurrency must be at least 1");
}
if args.output_len == 0 {
anyhow::bail!("--output-len must be at least 1: a request that generates nothing has no latency to measure");
}
let base = args.url.trim_end_matches('/').to_string();
http::parse_url(&format!("{base}/v1/chat/completions"))?;
let sampling = BenchSampling::new(args.output_len);
let shared = (args.shared_prefix > 0).then(|| filler_prompt(args.shared_prefix, 0));
let started = Instant::now();
let (tx, rx) = mpsc::channel::<usize>();
for i in 0..args.requests {
tx.send(i)
.expect("the receiver is alive until the scope ends");
}
drop(tx);
let rx = std::sync::Mutex::new(rx);
let timings: Vec<RequestTiming> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..args.concurrency.min(args.requests))
.map(|_| {
let base = &base;
let prompt_chars = args.prompt_chars;
let model = &args.model;
let shared = shared.as_deref();
let salt = args.cache_salt.as_deref();
let rx = ℞
scope.spawn(move || {
let mut mine = Vec::new();
loop {
let Ok(index) = rx.lock().unwrap_or_else(|p| p.into_inner()).recv() else {
break;
};
let prompt = filler_prompt(prompt_chars, index + 1);
let body = request_body(model.as_deref(), shared, salt, &prompt, &sampling);
let dispatched = started.elapsed().as_secs_f64();
let mut timing = RequestTiming::started(dispatched);
if let Err(e) = stream_request(base, &body, &started, &mut timing) {
eprintln!("request failed: {e:#}");
}
mine.push(timing);
}
mine
})
})
.collect();
handles
.into_iter()
.flat_map(|h| h.join().unwrap_or_default())
.collect()
});
let report = BenchReport::of(&timings);
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&report_json(&report, &args))?
);
} else {
print_report(&report, &args);
}
if report.completed == 0 {
anyhow::bail!("every request failed; nothing was measured");
}
Ok(())
}
fn request_body(
model: Option<&str>,
shared_prefix: Option<&str>,
cache_salt: Option<&str>,
prompt: &str,
sampling: &BenchSampling,
) -> Value {
let mut messages = Vec::with_capacity(2);
if let Some(shared) = shared_prefix {
messages.push(json!({"role": "system", "content": shared}));
}
messages.push(json!({"role": "user", "content": prompt}));
let mut body = json!({
"model": model.unwrap_or("bench"),
"messages": messages,
"max_tokens": sampling.output_len,
"temperature": sampling.temperature,
"top_k": sampling.top_k,
"ignore_eos": sampling.ignore_eos,
"stream": true,
"stream_options": {"include_usage": true},
});
if let Some(salt) = cache_salt {
body["cache_salt"] = json!(salt);
}
body
}
fn filler_prompt(chars: usize, seed: usize) -> String {
const WORDS: [&str; 12] = [
"lorem",
"ipsum",
"dolor",
"sit",
"amet",
"consectetur",
"adipiscing",
"elit",
"sed",
"do",
"eiusmod",
"tempor",
];
let mut out = String::with_capacity(chars + 16);
if chars > 0 {
out.push_str(&format!("{seed} "));
}
let mut i = seed;
while out.len() < chars {
if !out.is_empty() {
out.push(' ');
}
out.push_str(WORDS[(i * 7 + i * i) % WORDS.len()]);
i += 1;
}
out.truncate(chars);
out
}
fn stream_request(
base: &str,
body: &Value,
started: &Instant,
timing: &mut RequestTiming,
) -> Result<()> {
let bytes = serde_json::to_vec(body)?;
let (status, mut reader) =
http::open("POST", &format!("{base}/v1/chat/completions"), Some(&bytes))?;
if !(200..300).contains(&status) {
let mut rest = String::new();
use std::io::Read;
reader.read_to_string(&mut rest)?;
anyhow::bail!("HTTP {status}: {rest}");
}
let mut line = String::new();
while reader.read_line(&mut line)? > 0 {
let trimmed = line.trim_end();
if let Some(data) = trimmed.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
break;
}
if let Ok(chunk) = serde_json::from_str::<Value>(data) {
if is_token_chunk(&chunk) {
timing.tic(started.elapsed().as_secs_f64());
}
if let Some(n) = chunk
.get("usage")
.and_then(|u| u.get("completion_tokens"))
.and_then(serde_json::Value::as_u64)
{
timing.report_tokens(n as usize);
}
if let Some(usage) = chunk.get("usage") {
let prompt = usage.get("prompt_tokens").and_then(Value::as_u64);
let cached = usage.get("cached_tokens").and_then(Value::as_u64);
if let (Some(p), Some(c)) = (prompt, cached) {
timing.report_prefix(p as usize, c as usize);
}
}
}
}
line.clear();
}
Ok(())
}
fn ms(seconds: Option<f64>) -> String {
match seconds {
Some(s) => format!("{:.1}", s * 1000.0),
None => "-".to_string(),
}
}
fn latency_json(l: &Latency) -> Value {
json!({
"mean_ms": l.mean.map(|v| v * 1000.0),
"p50_ms": l.p50.map(|v| v * 1000.0),
"p90_ms": l.p90.map(|v| v * 1000.0),
"p99_ms": l.p99.map(|v| v * 1000.0),
})
}
fn report_json(report: &BenchReport, args: &ServeBenchArgs) -> Value {
json!({
"requests": args.requests,
"concurrency": args.concurrency,
"output_len": args.output_len,
"completed": report.completed,
"failed": report.failed,
"output_tokens": report.output_tokens,
"duration_s": report.duration_s,
"output_throughput_tps": report.output_throughput(),
"ttft": latency_json(&report.ttft),
"tpot": latency_json(&report.tpot),
"end_to_end": latency_json(&report.end_to_end),
"shared_prefix_chars": args.shared_prefix,
"cache_salt": args.cache_salt,
"prefix": report.prefix.map(prefix_json),
})
}
fn prefix_json(p: PrefixTotals) -> Value {
json!({
"prompt_tokens": p.prompt_tokens,
"cached_tokens": p.cached_tokens,
"reuse_fraction": p.reuse_fraction(),
"requests_with_reuse": p.requests_with_reuse,
"requests_reporting": p.requests_reporting,
})
}
fn print_prefix(report: &BenchReport, args: &ServeBenchArgs) {
let Some(p) = report.prefix else {
if args.shared_prefix > 0 {
println!(
"prefix reuse: not reported by this server \
(no prefix cache configured, or usage omitted)"
);
}
return;
};
let pct = p.reuse_fraction().map(|f| f * 100.0).unwrap_or(0.0);
println!(
"prefix reuse: {}/{} prompt tokens ({pct:.1}%), {} of {} requests reused something",
p.cached_tokens, p.prompt_tokens, p.requests_with_reuse, p.requests_reporting
);
}
fn print_report(report: &BenchReport, args: &ServeBenchArgs) {
println!(
"\nserve-bench {} requests, concurrency {}, {} tokens each",
args.requests, args.concurrency, args.output_len
);
println!(
"completed {} failed {} tokens {} span {:.2}s",
report.completed, report.failed, report.output_tokens, report.duration_s
);
match report.output_throughput() {
Some(tps) => println!("output throughput: {tps:.1} tok/s (whole run)"),
None => println!("output throughput: - (nothing completed)"),
}
print_prefix(report, args);
println!();
println!(
"{:<12} {:>10} {:>10} {:>10} {:>10}",
"", "mean", "p50", "p90", "p99"
);
for (name, l) in [
("TTFT (ms)", &report.ttft),
("TPOT (ms)", &report.tpot),
("E2E (ms)", &report.end_to_end),
] {
println!(
"{:<12} {:>10} {:>10} {:>10} {:>10}",
name,
ms(l.mean),
ms(l.p50),
ms(l.p90),
ms(l.p99)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bench_request_pins_everything_the_methodology_depends_on() {
let body = request_body(Some("m"), None, None, "hello", &BenchSampling::new(64));
assert_eq!(body["max_tokens"], 64);
assert_eq!(body["temperature"], 0.0);
assert_eq!(body["top_k"], 1);
assert_eq!(body["ignore_eos"], true);
assert_eq!(
body["stream"], true,
"TTFT is only observable on a stream; a buffered request can \
report nothing but end-to-end"
);
assert_eq!(body["messages"][0]["content"], "hello");
assert_eq!(
body["stream_options"]["include_usage"], true,
"without it the terminal frame carries no usage block and the \
prefix figures are absent rather than zero"
);
assert!(
body.get("cache_salt").is_none(),
"an unrequested salt must not be sent: it would put the run in \
its own cache namespace and quietly change what it measures"
);
}
#[test]
fn a_shared_prefix_is_a_system_message_before_the_per_request_prompt() {
let body = request_body(
Some("m"),
Some("shared"),
Some("tenant-a"),
"mine",
&BenchSampling::new(8),
);
assert_eq!(body["messages"][0]["role"], "system");
assert_eq!(body["messages"][0]["content"], "shared");
assert_eq!(body["messages"][1]["role"], "user");
assert_eq!(body["messages"][1]["content"], "mine");
assert_eq!(body["cache_salt"], "tenant-a");
}
#[test]
fn the_filler_prompt_is_reproducible_and_not_one_repeated_word() {
let a = filler_prompt(200, 1);
assert_eq!(a.len(), 200);
assert_eq!(
a,
filler_prompt(200, 1),
"the same run twice sends the same prompt"
);
let distinct: std::collections::HashSet<&str> = a.split_whitespace().collect();
assert!(
distinct.len() > 3,
"a prompt of one repeated word measures the prefix cache: {a}"
);
assert!(filler_prompt(0, 1).is_empty());
}
#[test]
fn two_requests_in_a_run_do_not_share_a_prompt_prefix() {
let a = filler_prompt(200, 1);
let b = filler_prompt(200, 2);
assert_ne!(a, b, "two requests sent the same prompt");
let shared = a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count();
assert!(
shared < 4,
"two prompts share their first {shared} characters, so the \
second request measures the first's pages"
);
}
#[test]
fn a_latency_that_was_never_measured_prints_as_a_dash() {
assert_eq!(ms(None), "-");
assert_eq!(ms(Some(0.0125)), "12.5");
}
}