use frink_core::summary_stats::percentile;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BenchSampling {
pub temperature: f32,
pub top_k: usize,
pub ignore_eos: bool,
pub output_len: usize,
}
impl BenchSampling {
pub fn new(output_len: usize) -> Self {
BenchSampling {
temperature: 0.0,
top_k: 1,
ignore_eos: true,
output_len,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RequestTiming {
started_s: f64,
tics_s: Vec<f64>,
reported_tokens: Option<usize>,
}
impl RequestTiming {
pub fn started(started_s: f64) -> Self {
RequestTiming {
started_s,
tics_s: Vec::new(),
reported_tokens: None,
}
}
pub fn report_tokens(&mut self, tokens: usize) {
self.reported_tokens = Some(tokens);
}
pub fn tic(&mut self, at_s: f64) {
self.tics_s.push(at_s);
}
pub fn tokens(&self) -> usize {
self.reported_tokens.unwrap_or(self.tics_s.len())
}
pub fn tics(&self) -> usize {
self.tics_s.len()
}
pub fn ttft(&self) -> Option<f64> {
self.tics_s.first().map(|first| first - self.started_s)
}
pub fn tpot_samples(&self) -> Vec<f64> {
self.tics_s.windows(2).map(|w| w[1] - w[0]).collect()
}
pub fn end_to_end(&self) -> Option<f64> {
self.tics_s.last().map(|last| last - self.started_s)
}
pub fn last_tic_s(&self) -> Option<f64> {
self.tics_s.last().copied()
}
pub fn started_s(&self) -> f64 {
self.started_s
}
}
pub fn is_token_chunk(chunk: &serde_json::Value) -> bool {
let Some(choices) = chunk.get("choices").and_then(|c| c.as_array()) else {
return false;
};
choices.iter().any(|choice| {
choice
.get("delta")
.and_then(|d| d.as_object())
.is_some_and(|d| !d.is_empty())
})
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Latency {
pub mean: Option<f64>,
pub p50: Option<f64>,
pub p90: Option<f64>,
pub p99: Option<f64>,
}
impl Latency {
pub fn of(samples: &[f64]) -> Self {
if samples.is_empty() {
return Latency::default();
}
Latency {
mean: Some(samples.iter().sum::<f64>() / samples.len() as f64),
p50: percentile(samples, 50.0),
p90: percentile(samples, 90.0),
p99: percentile(samples, 99.0),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BenchReport {
pub completed: usize,
pub failed: usize,
pub output_tokens: usize,
pub duration_s: f64,
pub ttft: Latency,
pub tpot: Latency,
pub end_to_end: Latency,
}
impl BenchReport {
pub fn of(timings: &[RequestTiming]) -> Self {
let completed = timings.iter().filter(|t| t.tics() > 0).count();
let ttfts: Vec<f64> = timings.iter().filter_map(RequestTiming::ttft).collect();
let tpots: Vec<f64> = timings
.iter()
.flat_map(RequestTiming::tpot_samples)
.collect();
let e2es: Vec<f64> = timings
.iter()
.filter_map(RequestTiming::end_to_end)
.collect();
let first_start = timings
.iter()
.map(RequestTiming::started_s)
.fold(f64::INFINITY, f64::min);
let last_tic = timings
.iter()
.filter_map(RequestTiming::last_tic_s)
.fold(f64::NEG_INFINITY, f64::max);
let duration_s = if completed == 0 {
0.0
} else {
(last_tic - first_start).max(0.0)
};
BenchReport {
completed,
failed: timings.len() - completed,
output_tokens: timings.iter().map(RequestTiming::tokens).sum(),
duration_s,
ttft: Latency::of(&ttfts),
tpot: Latency::of(&tpots),
end_to_end: Latency::of(&e2es),
}
}
pub fn output_throughput(&self) -> Option<f64> {
(self.duration_s > 0.0).then(|| self.output_tokens as f64 / self.duration_s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_benchmark_request_is_pinned_so_it_does_exactly_the_requested_work() {
let sampling = BenchSampling::new(128);
assert_eq!(sampling.temperature, 0.0);
assert_eq!(sampling.top_k, 1);
assert!(
sampling.ignore_eos,
"a request that stops at its own EOS measures the model's \
opinion about length, not the server's speed"
);
assert_eq!(sampling.output_len, 128);
}
#[test]
fn the_first_tic_is_the_time_to_first_token_whatever_it_carried() {
let mut t = RequestTiming::started(10.0);
t.tic(10.5);
t.tic(10.6);
t.tic(10.8);
assert_eq!(t.ttft(), Some(0.5));
assert_eq!(t.tokens(), 3);
let tpot = t.tpot_samples();
assert_eq!(tpot.len(), 2, "one interval per chunk after the first");
assert!((tpot[0] - 0.1).abs() < 1e-9);
assert!((tpot[1] - 0.2).abs() < 1e-9);
assert!((t.end_to_end().unwrap() - 0.8).abs() < 1e-9);
}
#[test]
fn a_request_that_produced_nothing_has_no_latency_rather_than_zero() {
let t = RequestTiming::started(1.0);
assert_eq!(t.ttft(), None);
assert_eq!(t.end_to_end(), None);
assert!(t.tpot_samples().is_empty());
let mut one = RequestTiming::started(1.0);
one.tic(1.25);
assert_eq!(one.ttft(), Some(0.25));
assert!(
one.tpot_samples().is_empty(),
"there is no interval between a token and nothing"
);
}
#[test]
fn only_a_chunk_that_carries_something_is_timed_as_a_token() {
let keepalive = json!({
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {}, "finish_reason": null}],
});
assert!(!is_token_chunk(&keepalive));
let terminal = json!({
"choices": [{"index": 0, "delta": {}, "finish_reason": "length"}],
});
assert!(
!is_token_chunk(&terminal),
"the terminal frame is not a token"
);
let token = json!({
"choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": null}],
});
assert!(is_token_chunk(&token));
let opening = json!({
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": null}],
});
assert!(is_token_chunk(&opening));
let thought = json!({
"choices": [{"index": 0, "delta": {"reasoning_content": "hmm"}}],
});
assert!(is_token_chunk(&thought));
assert!(!is_token_chunk(&json!({"choices": []})));
assert!(!is_token_chunk(&json!({"object": "chat.completion.chunk"})));
}
#[test]
fn inter_token_samples_are_pooled_across_requests_not_averaged_twice() {
let mut stalled = RequestTiming::started(0.0);
let mut at = 0.0;
stalled.tic(at);
for i in 0..49 {
at += if i == 25 { 1.0 } else { 0.001 };
stalled.tic(at);
}
let mut fast = RequestTiming::started(0.0);
let mut at = 0.0;
fast.tic(at);
for _ in 0..49 {
at += 0.001;
fast.tic(at);
}
let report = BenchReport::of(&[stalled.clone(), fast]);
let p99 = report.tpot.p99.expect("samples exist");
assert!(
p99 > 0.5,
"the stall must survive to the p99, got {p99} -- pooling is the \
whole point"
);
let samples = stalled.tpot_samples();
let per_request_means = [samples.iter().sum::<f64>() / samples.len() as f64, 0.001];
let flattened = Latency::of(&per_request_means).p99.unwrap();
assert!(
flattened < p99,
"averaging first hides the tail, which is why it is not done"
);
}
#[test]
fn throughput_is_the_whole_runs_tokens_over_the_whole_runs_span() {
let mut early = RequestTiming::started(0.0);
early.tic(1.0);
early.tic(2.0);
let mut late = RequestTiming::started(8.0);
late.tic(9.0);
late.tic(10.0);
let report = BenchReport::of(&[early, late]);
assert_eq!(report.output_tokens, 4);
assert_eq!(report.duration_s, 10.0, "first dispatch to last chunk");
assert_eq!(report.output_throughput(), Some(0.4));
}
#[test]
fn failed_requests_are_counted_and_an_empty_run_has_no_throughput() {
let report = BenchReport::of(&[RequestTiming::started(0.0), RequestTiming::started(1.0)]);
assert_eq!(report.completed, 0);
assert_eq!(report.failed, 2);
assert_eq!(report.duration_s, 0.0);
assert_eq!(report.output_throughput(), None);
assert_eq!(report.ttft, Latency::default());
}
#[test]
fn a_buffered_server_is_credited_with_the_tokens_it_says_it_produced() {
let mut buffered = RequestTiming::started(0.0);
buffered.tic(2.0);
assert_eq!(buffered.tokens(), 1, "with nothing else to go on");
buffered.report_tokens(100);
assert_eq!(buffered.tokens(), 100, "the server's own count wins");
assert_eq!(buffered.tics(), 1, "the timing still comes from the stream");
let report = BenchReport::of(&[buffered]);
assert_eq!(report.output_tokens, 100);
assert_eq!(report.output_throughput(), Some(50.0));
assert_eq!(
report.tpot,
Latency::default(),
"a buffered stream has no inter-token detail, and it is \
reported as absent rather than invented"
);
}
}