1use serde::{Deserialize, Serialize};
10
11use super::protocol::Outcome;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct RequestSample {
19 pub index: usize,
21 pub worker: usize,
23 pub start_s: f64,
25 pub end_s: f64,
27 #[serde(default)]
29 pub token_times_s: Vec<f64>,
30 pub generated_tokens: u32,
32 #[serde(default)]
34 pub prompt_tokens: u32,
35 pub outcome: Outcome,
37 #[serde(default)]
40 pub in_flight_at_start: usize,
41 #[serde(default)]
44 pub drained: bool,
45}
46
47impl RequestSample {
48 #[must_use]
51 pub fn ttft_ms(&self) -> Option<f64> {
52 self.token_times_s
53 .first()
54 .map(|t| (t - self.start_s) * 1000.0)
55 }
56
57 #[must_use]
63 pub fn decode_tok_s(&self) -> Option<f64> {
64 if self.token_times_s.len() < 2 || self.generated_tokens < 2 {
65 return None;
66 }
67 let first = self.token_times_s[0];
68 let last = self.token_times_s[self.token_times_s.len() - 1];
69 let span = last - first;
70 if span <= 0.0 {
71 return None;
72 }
73 Some(f64::from(self.generated_tokens - 1) / span)
74 }
75
76 #[must_use]
78 pub fn itl_gaps_ms(&self) -> Vec<f64> {
79 self.token_times_s
80 .windows(2)
81 .map(|w| (w[1] - w[0]) * 1000.0)
82 .collect()
83 }
84
85 #[must_use]
88 pub fn counts_toward_aggregate(&self) -> bool {
89 self.outcome == Outcome::Completed
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct BandMetrics {
97 pub concurrency: usize,
99 pub agg_tok_s: f64,
102 pub decode_tok_s: f64,
104 pub ttft_p50_ms: f64,
106 pub ttft_p95_ms: f64,
108 pub itl_p50_ms: f64,
110 pub itl_p95_ms: f64,
112 pub requested: usize,
114 pub completed: usize,
116 pub timeouts: usize,
118 pub truncated: usize,
120 pub errors: usize,
122 pub tokens_total: u64,
124 pub span_s: f64,
127}
128
129pub use super::drain::percentile;
135
136fn sorted(mut v: Vec<f64>) -> Vec<f64> {
137 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
138 v
139}
140
141fn median(values: Vec<f64>) -> f64 {
142 percentile(&sorted(values), 0.50).unwrap_or(0.0)
143}
144
145#[must_use]
155pub fn agg_tok_s(samples: &[RequestSample]) -> f64 {
156 let (tokens, span) = aggregate_terms(samples);
157 if span <= 0.0 {
158 return 0.0;
159 }
160 tokens as f64 / span
161}
162
163#[must_use]
166pub fn aggregate_terms(samples: &[RequestSample]) -> (u64, f64) {
167 if samples.is_empty() {
168 return (0, 0.0);
169 }
170 let tokens: u64 = samples
171 .iter()
172 .filter(|s| s.counts_toward_aggregate())
173 .map(|s| u64::from(s.generated_tokens))
174 .sum();
175 let first_start = samples
176 .iter()
177 .map(|s| s.start_s)
178 .fold(f64::INFINITY, f64::min);
179 let last_end = samples
180 .iter()
181 .filter(|s| s.counts_toward_aggregate())
182 .map(|s| s.end_s)
183 .fold(f64::NEG_INFINITY, f64::max);
184 if !first_start.is_finite() || !last_end.is_finite() {
185 return (tokens, 0.0);
186 }
187 (tokens, last_end - first_start)
188}
189
190#[must_use]
198pub fn mean_of_rates(samples: &[RequestSample]) -> f64 {
199 let rates: Vec<f64> = samples
200 .iter()
201 .filter(|s| s.counts_toward_aggregate())
202 .filter_map(|s| {
203 let dur = s.end_s - s.start_s;
204 if dur > 0.0 {
205 Some(f64::from(s.generated_tokens) / dur)
206 } else {
207 None
208 }
209 })
210 .collect();
211 if rates.is_empty() {
212 return 0.0;
213 }
214 rates.iter().sum::<f64>() / rates.len() as f64
215}
216
217impl BandMetrics {
218 #[must_use]
220 pub fn from_samples(concurrency: usize, samples: &[RequestSample]) -> Self {
221 let (tokens_total, span_s) = aggregate_terms(samples);
222 let agg = if span_s > 0.0 {
223 tokens_total as f64 / span_s
224 } else {
225 0.0
226 };
227
228 let ttfts = sorted(samples.iter().filter_map(RequestSample::ttft_ms).collect());
229 let itls = sorted(
230 samples
231 .iter()
232 .flat_map(RequestSample::itl_gaps_ms)
233 .collect::<Vec<f64>>(),
234 );
235 let decodes: Vec<f64> = samples
236 .iter()
237 .filter_map(RequestSample::decode_tok_s)
238 .collect();
239
240 let count = |o: Outcome| samples.iter().filter(|s| s.outcome == o).count();
241
242 Self {
243 concurrency,
244 agg_tok_s: agg,
245 decode_tok_s: median(decodes),
246 ttft_p50_ms: percentile(&ttfts, 0.50).unwrap_or(0.0),
247 ttft_p95_ms: percentile(&ttfts, 0.95).unwrap_or(0.0),
248 itl_p50_ms: percentile(&itls, 0.50).unwrap_or(0.0),
249 itl_p95_ms: percentile(&itls, 0.95).unwrap_or(0.0),
250 requested: samples.len(),
251 completed: count(Outcome::Completed),
252 timeouts: count(Outcome::Timeout),
253 truncated: count(Outcome::AbandonedAtDrain),
254 errors: count(Outcome::Failed),
255 tokens_total,
256 span_s,
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn sample(index: usize, start_s: f64, end_s: f64, tokens: u32) -> RequestSample {
268 let n = tokens as usize;
269 let token_times_s = if n == 0 {
270 Vec::new()
271 } else {
272 let step = (end_s - start_s) / n as f64;
273 (1..=n).map(|i| start_s + step * i as f64).collect()
274 };
275 RequestSample {
276 index,
277 worker: 0,
278 start_s,
279 end_s,
280 token_times_s,
281 generated_tokens: tokens,
282 prompt_tokens: 0,
283 outcome: Outcome::Completed,
284 in_flight_at_start: 1,
285 drained: false,
286 }
287 }
288
289 #[test]
296 fn agg_tok_s_is_wall_clock_not_the_mean_of_rates() {
297 let s = vec![
298 sample(0, 0.0, 1.0, 100),
299 sample(1, 0.0, 2.0, 100),
300 sample(2, 1.0, 3.0, 100),
301 sample(3, 2.0, 4.0, 100),
302 ];
303 let (tokens, span) = aggregate_terms(&s);
304 assert_eq!(tokens, 400);
305 assert!((span - 4.0).abs() < 1e-12, "span={span}");
306
307 let agg = agg_tok_s(&s);
308 let mean = mean_of_rates(&s);
309 assert!((agg - 100.0).abs() < 1e-9, "agg={agg}, want 100.0");
310 assert!((mean - 62.5).abs() < 1e-9, "mean={mean}, want 62.5");
311 assert!(
312 (agg - mean).abs() > 1.0,
313 "agg {agg} and mean-of-rates {mean} must not coincide"
314 );
315 }
316
317 #[test]
322 fn mean_of_rates_overstates_a_serialising_server() {
323 let s = vec![
324 sample(0, 0.0, 1.0, 100),
325 sample(1, 2.0, 3.0, 100),
326 sample(2, 4.0, 5.0, 100),
327 sample(3, 6.0, 7.0, 100),
328 ];
329 let agg = agg_tok_s(&s);
330 let mean = mean_of_rates(&s);
331 assert!((agg - 400.0 / 7.0).abs() < 1e-9, "agg={agg}");
332 assert!((mean - 100.0).abs() < 1e-9, "mean={mean}");
333 assert!(mean > agg * 1.7, "mean {mean} must overstate agg {agg}");
334 }
335
336 #[test]
339 fn timeouts_lengthen_the_span_but_add_no_tokens() {
340 let mut timed_out = sample(0, 0.0, 1.0, 100);
341 timed_out.outcome = Outcome::Timeout;
342 timed_out.generated_tokens = 100; let s = vec![timed_out, sample(1, 0.5, 2.5, 100)];
344
345 let (tokens, span) = aggregate_terms(&s);
346 assert_eq!(tokens, 100, "a timed-out request contributes no tokens");
347 assert!(
348 (span - 2.5).abs() < 1e-12,
349 "span must start at 0.0, got {span}"
350 );
351
352 let m = BandMetrics::from_samples(2, &s);
353 assert_eq!(m.requested, 2);
354 assert_eq!(m.completed, 1);
355 assert_eq!(m.timeouts, 1);
356 assert!((m.agg_tok_s - 40.0).abs() < 1e-9, "{}", m.agg_tok_s);
357 }
358
359 #[test]
360 fn decode_tok_s_is_the_median_of_per_request_rates() {
361 let one = sample(0, 0.0, 1.0, 100);
364 assert!((one.decode_tok_s().expect("two+ tokens") - 100.0).abs() < 1e-9);
365
366 let s = vec![
368 sample(0, 0.0, 1.0, 100),
369 sample(1, 0.0, 2.0, 100),
370 sample(2, 0.0, 4.0, 100),
371 ];
372 let m = BandMetrics::from_samples(1, &s);
373 assert!((m.decode_tok_s - 50.0).abs() < 1e-9, "{}", m.decode_tok_s);
374 }
375
376 #[test]
377 fn single_token_request_has_no_decode_rate_and_no_gaps() {
378 let s = sample(0, 0.0, 1.0, 1);
379 assert_eq!(s.decode_tok_s(), None);
380 assert!(s.itl_gaps_ms().is_empty());
381 assert!(s.ttft_ms().is_some(), "one token still has a TTFT");
382 }
383
384 #[test]
385 fn ttft_is_start_to_first_token() {
386 let s = sample(0, 10.0, 11.0, 4); assert!((s.ttft_ms().expect("has tokens") - 250.0).abs() < 1e-9);
388 }
389
390 #[test]
391 fn itl_gaps_are_pooled_across_requests() {
392 let s = vec![sample(0, 0.0, 1.0, 4), sample(1, 0.0, 3.0, 3)];
395 let pooled: Vec<f64> = s.iter().flat_map(RequestSample::itl_gaps_ms).collect();
396 assert_eq!(
397 pooled.len(),
398 5,
399 "3 + 2 gaps pooled, not 2 per-request means"
400 );
401 let m = BandMetrics::from_samples(2, &s);
402 assert!((m.itl_p50_ms - 250.0).abs() < 1e-9, "{}", m.itl_p50_ms);
404 assert!(m.itl_p95_ms > 900.0, "{}", m.itl_p95_ms);
405 }
406
407 #[test]
408 fn percentile_of_nothing_is_none_not_zero() {
409 assert_eq!(percentile(&[], 0.5), None);
410 assert_eq!(percentile(&[7.0], 0.95), Some(7.0));
411 }
412
413 #[test]
414 fn percentile_interpolates_between_order_statistics() {
415 let v = vec![0.0, 10.0, 20.0, 30.0];
416 assert_eq!(percentile(&v, 0.0), Some(0.0));
417 assert_eq!(percentile(&v, 1.0), Some(30.0));
418 assert_eq!(percentile(&v, 0.5), Some(15.0));
419 }
420
421 #[test]
422 fn empty_band_is_zero_not_infinite() {
423 let m = BandMetrics::from_samples(4, &[]);
424 assert_eq!(m.agg_tok_s, 0.0);
425 assert_eq!(m.requested, 0);
426 assert_eq!(m.span_s, 0.0);
427 }
428
429 #[test]
430 fn samples_round_trip_as_jsonl_rows() {
431 let s = sample(3, 1.5, 2.5, 8);
432 let line = serde_json::to_string(&s).expect("serialize");
433 let back: RequestSample = serde_json::from_str(&line).expect("deserialize");
434 assert_eq!(back, s);
435 }
436}