1use crate::capabilities::GenerationStats;
9
10const CHARS_PER_TOKEN: f64 = 4.0;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TimingSource {
17 Backend,
19 WallClock,
21}
22
23impl TimingSource {
24 pub fn as_str(self) -> &'static str {
26 match self {
27 TimingSource::Backend => "backend",
28 TimingSource::WallClock => "wall_clock",
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct WallClock {
37 pub ttft_ms: i64,
39 pub total_ms: i64,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct Sample {
46 pub completion_tokens: i64,
48 pub prompt_tokens: Option<i64>,
50 pub ttft_ms: i64,
52 pub decode_ms: i64,
54 pub prompt_ms: Option<i64>,
56 pub estimated_tokens: bool,
58 pub source: TimingSource,
60}
61
62impl Sample {
63 pub fn new(stats: Option<&GenerationStats>, generated: &str, wall: WallClock) -> Self {
67 let reported = stats
68 .and_then(|stats| stats.completion_tokens)
69 .filter(|tokens| *tokens > 0);
70 let estimated_tokens =
71 reported.is_none() || stats.is_some_and(|stats| stats.token_counts_estimated);
72 let completion_tokens = reported.unwrap_or_else(|| estimate_tokens(generated));
73
74 let (decode_ms, source) = match stats.and_then(|stats| stats.eval_ms).filter(|ms| *ms > 0) {
78 Some(ms) => (ms, TimingSource::Backend),
79 None => (
80 (wall.total_ms - wall.ttft_ms).max(0),
81 TimingSource::WallClock,
82 ),
83 };
84
85 Self {
86 completion_tokens,
87 prompt_tokens: stats
88 .and_then(|stats| stats.prompt_tokens)
89 .filter(|tokens| *tokens > 0),
90 ttft_ms: wall.ttft_ms.max(0),
91 decode_ms,
92 prompt_ms: stats.and_then(|stats| stats.prompt_ms).filter(|ms| *ms > 0),
93 estimated_tokens,
94 source,
95 }
96 }
97
98 pub fn tokens_per_second(&self) -> Option<f64> {
105 let counted = match self.source {
106 TimingSource::Backend => self.completion_tokens,
107 TimingSource::WallClock => self.completion_tokens - 1,
108 };
109 (self.decode_ms > 0 && counted > 0).then(|| counted as f64 * 1000.0 / self.decode_ms as f64)
110 }
111
112 pub fn prompt_tokens_per_second(&self) -> Option<f64> {
115 let tokens = self.prompt_tokens?;
116 let ms = self.prompt_ms?;
117 (ms > 0 && tokens > 0).then(|| tokens as f64 * 1000.0 / ms as f64)
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct Measure {
125 pub median: f64,
127 pub min: f64,
129 pub max: f64,
131}
132
133impl Measure {
134 pub fn of(values: &[f64]) -> Option<Self> {
138 if values.is_empty() {
139 return None;
140 }
141 let mut sorted = values.to_vec();
142 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
143 let middle = sorted.len() / 2;
144 let median = if sorted.len().is_multiple_of(2) {
145 (sorted[middle - 1] + sorted[middle]) / 2.0
146 } else {
147 sorted[middle]
148 };
149 Some(Self {
150 median,
151 min: sorted[0],
152 max: sorted[sorted.len() - 1],
153 })
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum ColdStart {
160 Measured(i64),
163 Held(String),
165 NotMeasured,
167}
168
169impl ColdStart {
170 pub fn millis(&self) -> Option<i64> {
172 match self {
173 ColdStart::Measured(ms) => Some(*ms),
174 _ => None,
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq)]
181pub struct Figures {
182 pub cold_start: ColdStart,
184 pub tokens_per_second: Option<Measure>,
186 pub ttft_ms: Option<Measure>,
188 pub prompt_tokens_per_second: Option<Measure>,
190 pub estimated_tokens: bool,
192 pub source: TimingSource,
194 pub runs: Vec<Sample>,
196}
197
198impl Figures {
199 pub fn summarize(cold: ColdStart, runs: Vec<Sample>) -> Self {
202 let rates: Vec<f64> = runs.iter().filter_map(Sample::tokens_per_second).collect();
203 let ttfts: Vec<f64> = runs.iter().map(|run| run.ttft_ms as f64).collect();
204 let prompt_rates: Vec<f64> = runs
205 .iter()
206 .filter_map(Sample::prompt_tokens_per_second)
207 .collect();
208 Self {
209 cold_start: cold,
210 tokens_per_second: Measure::of(&rates),
211 ttft_ms: Measure::of(&ttfts),
212 prompt_tokens_per_second: Measure::of(&prompt_rates),
213 estimated_tokens: runs.iter().any(|run| run.estimated_tokens),
214 source: if !runs.is_empty()
218 && runs.iter().all(|run| run.source == TimingSource::Backend)
219 {
220 TimingSource::Backend
221 } else {
222 TimingSource::WallClock
223 },
224 runs,
225 }
226 }
227
228 pub fn rate(&self) -> Option<f64> {
230 self.tokens_per_second.map(|measure| measure.median)
231 }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum Phase {
237 ColdStart,
239 Warm { run: usize, of: usize },
241}
242
243#[derive(Debug, Clone, PartialEq)]
245pub enum Status {
246 Waiting,
248 Running { phase: Phase, tokens: i64 },
250 Done(Box<Figures>),
252 Failed(String),
254 Skipped(String),
256 Stopped,
259}
260
261impl Status {
262 pub fn figures(&self) -> Option<&Figures> {
264 match self {
265 Status::Done(figures) => Some(figures),
266 _ => None,
267 }
268 }
269
270 pub fn rate(&self) -> Option<f64> {
272 self.figures().and_then(Figures::rate)
273 }
274}
275
276#[derive(Debug, Clone, PartialEq)]
278pub struct Row {
279 pub id: String,
281 pub name: String,
283 pub runtime: Option<String>,
285 pub quantization: Option<String>,
287 pub status: Status,
289}
290
291impl Row {
292 pub fn waiting(
294 id: &str,
295 name: &str,
296 runtime: Option<String>,
297 quantization: Option<String>,
298 ) -> Self {
299 Self {
300 id: id.to_owned(),
301 name: name.to_owned(),
302 runtime,
303 quantization,
304 status: Status::Waiting,
305 }
306 }
307
308 pub fn skipped(
310 id: &str,
311 name: &str,
312 runtime: Option<String>,
313 quantization: Option<String>,
314 reason: &str,
315 ) -> Self {
316 let mut row = Self::waiting(id, name, runtime, quantization);
317 row.status = Status::Skipped(reason.to_owned());
318 row
319 }
320}
321
322pub fn fastest(rows: &[Row]) -> Option<f64> {
324 rows.iter()
325 .filter_map(|row| row.status.rate())
326 .reduce(f64::max)
327}
328
329pub fn rank(rows: &[Row]) -> Vec<&Row> {
332 let mut ordered: Vec<&Row> = rows.iter().collect();
333 ordered.sort_by(|a, b| match (a.status.rate(), b.status.rate()) {
334 (Some(left), Some(right)) => right
335 .partial_cmp(&left)
336 .unwrap_or(std::cmp::Ordering::Equal),
337 (Some(_), None) => std::cmp::Ordering::Less,
338 (None, Some(_)) => std::cmp::Ordering::Greater,
339 (None, None) => std::cmp::Ordering::Equal,
340 });
341 ordered
342}
343
344pub fn filled_cells(rate: f64, fastest: f64, width: usize) -> usize {
348 if width == 0 || rate <= 0.0 || fastest <= 0.0 {
349 return 0;
350 }
351 let cells = (rate / fastest * width as f64).round() as usize;
352 cells.clamp(1, width)
353}
354
355pub fn estimate_tokens(text: &str) -> i64 {
358 let characters = text.chars().count();
359 if characters == 0 {
360 return 0;
361 }
362 ((characters as f64 / CHARS_PER_TOKEN).round() as i64).max(1)
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn stats(completion: Option<i64>, eval_ms: Option<i64>) -> GenerationStats {
370 GenerationStats {
371 completion_tokens: completion,
372 eval_ms,
373 ..GenerationStats::default()
374 }
375 }
376
377 fn wall(ttft_ms: i64, total_ms: i64) -> WallClock {
378 WallClock { ttft_ms, total_ms }
379 }
380
381 fn done(id: &str, rate_tokens: i64, decode_ms: i64) -> Row {
382 let sample = Sample::new(
383 Some(&stats(Some(rate_tokens), Some(decode_ms))),
384 "",
385 wall(100, 100 + decode_ms),
386 );
387 let mut row = Row::waiting(id, id, None, None);
388 row.status = Status::Done(Box::new(Figures::summarize(
389 ColdStart::NotMeasured,
390 vec![sample],
391 )));
392 row
393 }
394
395 #[test]
396 fn a_backend_that_times_the_decode_is_preferred_to_the_wall_clock() {
397 let sample = Sample::new(Some(&stats(Some(64), Some(1000))), "", wall(200, 1600));
398 assert_eq!(sample.source, TimingSource::Backend);
399 assert_eq!(sample.decode_ms, 1000);
400 assert_eq!(sample.tokens_per_second(), Some(64.0));
401 assert_eq!(sample.ttft_ms, 200);
403 }
404
405 #[test]
406 fn without_a_backend_figure_the_decode_is_the_stream_after_the_first_token() {
407 let sample = Sample::new(Some(&stats(Some(31), None)), "", wall(500, 2000));
408 assert_eq!(sample.source, TimingSource::WallClock);
409 assert_eq!(sample.decode_ms, 1500);
410 assert_eq!(sample.tokens_per_second(), Some(20.0));
412 }
413
414 #[test]
415 fn a_single_token_reply_measured_here_has_no_rate_to_give() {
416 let sample = Sample::new(Some(&stats(Some(1), None)), "", wall(500, 2000));
417 assert_eq!(sample.tokens_per_second(), None);
418 }
419
420 #[test]
421 fn no_runs_at_all_never_claims_the_runtimes_own_timing() {
422 let figures = Figures::summarize(ColdStart::NotMeasured, Vec::new());
423 assert_eq!(figures.source, TimingSource::WallClock);
424 assert!(figures.rate().is_none());
425 }
426
427 #[test]
428 fn a_reply_nothing_counted_is_estimated_from_its_text() {
429 let sample = Sample::new(None, "12345678", wall(10, 1010));
430 assert_eq!(sample.completion_tokens, 2);
431 assert!(sample.estimated_tokens);
432 }
433
434 #[test]
435 fn a_backend_that_admits_its_counts_are_estimates_is_believed_and_marked() {
436 let mut stats = stats(Some(40), Some(1000));
437 stats.token_counts_estimated = true;
438 let sample = Sample::new(Some(&stats), "", wall(10, 1010));
439 assert_eq!(
440 sample.completion_tokens, 40,
441 "the count is still the one reported"
442 );
443 assert!(sample.estimated_tokens, "and it is marked as an estimate");
444 }
445
446 #[test]
447 fn estimating_counts_characters_rather_than_bytes() {
448 assert_eq!(estimate_tokens("გამარჯობა"), 2);
451 assert_eq!(estimate_tokens(""), 0);
452 assert_eq!(
453 estimate_tokens("a"),
454 1,
455 "text that exists is never zero tokens"
456 );
457 }
458
459 #[test]
460 fn a_prompt_rate_needs_both_a_count_and_a_timed_prompt_phase() {
461 let mut stats = stats(Some(10), Some(1000));
462 stats.prompt_tokens = Some(300);
463 let untimed = Sample::new(Some(&stats), "", wall(50, 1050));
464 assert_eq!(untimed.prompt_tokens_per_second(), None);
465 stats.prompt_ms = Some(150);
466 let timed = Sample::new(Some(&stats), "", wall(50, 1050));
467 assert_eq!(timed.prompt_tokens_per_second(), Some(2000.0));
468 }
469
470 #[test]
471 fn a_measure_takes_the_middle_of_an_odd_count_and_the_mean_of_an_even_one() {
472 let odd = Measure::of(&[10.0, 30.0, 20.0]).expect("three values");
473 assert_eq!((odd.median, odd.min, odd.max), (20.0, 10.0, 30.0));
474 let even = Measure::of(&[10.0, 20.0, 30.0, 40.0]).expect("four values");
475 assert_eq!(even.median, 25.0);
476 assert!(Measure::of(&[]).is_none());
477 }
478
479 #[test]
480 fn one_wall_clock_run_makes_the_whole_row_wall_clock() {
481 let backend = Sample::new(Some(&stats(Some(20), Some(1000))), "", wall(10, 1010));
482 let measured_here = Sample::new(Some(&stats(Some(20), None)), "", wall(10, 1010));
483 let figures =
484 Figures::summarize(ColdStart::NotMeasured, vec![backend.clone(), measured_here]);
485 assert_eq!(figures.source, TimingSource::WallClock);
486 assert_eq!(
487 Figures::summarize(ColdStart::NotMeasured, vec![backend]).source,
488 TimingSource::Backend
489 );
490 }
491
492 #[test]
493 fn the_cold_start_is_the_first_token_of_the_run_before_the_warm_ones() {
494 let cold = Sample::new(Some(&stats(Some(5), Some(500))), "", wall(3000, 3500));
495 let warm = Sample::new(Some(&stats(Some(5), Some(500))), "", wall(200, 700));
496 let figures = Figures::summarize(ColdStart::Measured(cold.ttft_ms), vec![warm]);
497 assert_eq!(figures.cold_start.millis(), Some(3000));
498 assert_eq!(
499 figures.ttft_ms.expect("a warm ttft").median,
500 200.0,
501 "the cold run is not one of the warm figures"
502 );
503 }
504
505 #[test]
506 fn ranking_puts_the_fastest_first_and_the_figureless_last() {
507 let mut failed = Row::waiting("f", "f", None, None);
508 failed.status = Status::Failed("llama-server is not on the PATH".to_owned());
509 let rows = vec![
510 done("slow", 10, 1000),
511 failed,
512 done("fast", 60, 1000),
513 Row::skipped("big", "big", None, None, "too big"),
514 ];
515 let order: Vec<&str> = rank(&rows).iter().map(|row| row.id.as_str()).collect();
516 assert_eq!(order, vec!["fast", "slow", "f", "big"]);
517 assert_eq!(fastest(&rows), Some(60.0));
518 }
519
520 #[test]
521 fn the_fastest_row_fills_its_bar_and_a_slow_one_keeps_a_cell() {
522 assert_eq!(filled_cells(60.0, 60.0, 20), 20);
523 assert_eq!(filled_cells(30.0, 60.0, 20), 10);
524 assert_eq!(filled_cells(0.4, 60.0, 20), 1);
526 assert_eq!(filled_cells(0.0, 60.0, 20), 0, "and nothing measured is");
527 assert_eq!(filled_cells(10.0, 0.0, 20), 0);
528 assert_eq!(filled_cells(10.0, 60.0, 0), 0);
529 }
530}