Skip to main content

kernel/
bench.rs

1//! What a model does on this machine: one run's measurement, the summary over
2//! several, and the order and bar widths a table of them draws with.
3//!
4//! Nothing here runs a model or reads a clock. The driver takes the marks and
5//! hands them over; this decides what the figures mean, which of them the
6//! backend actually reported, and how they rank.
7
8use crate::capabilities::GenerationStats;
9
10/// Characters per token, for a reply whose runtime counted none. Coarse on
11/// purpose: a figure it produces is marked estimated wherever it is shown.
12const CHARS_PER_TOKEN: f64 = 4.0;
13
14/// Where a run's timing came from.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TimingSource {
17    /// The backend reported the phase itself.
18    Backend,
19    /// Measured here, from the stream's own arrival times.
20    WallClock,
21}
22
23impl TimingSource {
24    /// The stable string form: `backend` or `wall_clock`.
25    pub fn as_str(self) -> &'static str {
26        match self {
27            TimingSource::Backend => "backend",
28            TimingSource::WallClock => "wall_clock",
29        }
30    }
31}
32
33/// The marks the driver takes around one run, in milliseconds: when the first
34/// token arrived and when the stream ended, both measured from the request.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct WallClock {
37    /// Request to first token.
38    pub ttft_ms: i64,
39    /// Request to the end of the stream.
40    pub total_ms: i64,
41}
42
43/// One run of one model.
44#[derive(Debug, Clone, PartialEq)]
45pub struct Sample {
46    /// Tokens generated, reported or estimated.
47    pub completion_tokens: i64,
48    /// Tokens of prompt, when the backend counted them.
49    pub prompt_tokens: Option<i64>,
50    /// Time to the first token. Always wall clock: it is what a caller waits.
51    pub ttft_ms: i64,
52    /// Time spent generating, the prompt excluded.
53    pub decode_ms: i64,
54    /// Time spent on the prompt, when the backend reports the phases apart.
55    pub prompt_ms: Option<i64>,
56    /// Whether the token count was counted from the text rather than reported.
57    pub estimated_tokens: bool,
58    /// Where `decode_ms` came from.
59    pub source: TimingSource,
60}
61
62impl Sample {
63    /// Fold one run's reported stats and wall-clock marks into a sample.
64    /// `generated` is the text the run produced, which stands in for a token
65    /// count the runtime did not report.
66    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        // The decode phase is the backend's own figure where it reports one,
75        // else what is left of the stream after the first token: a rate over
76        // the whole run would charge the prompt to the generation.
77        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    /// Tokens generated a second, when the run took any measurable time.
99    ///
100    /// A wall-clock run is timed from the first token to the last, so the
101    /// tokens that span is worth are all of them but the first; a backend
102    /// times its own decode against every token it produced. Counting each the
103    /// way it was measured is what lets the two sit in one ranked column.
104    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    /// Prompt tokens processed a second, only where the backend timed the
113    /// prompt phase; there is nothing to divide otherwise.
114    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/// A figure over several runs: the middle one, and the two ends it varied
122/// between.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct Measure {
125    /// The middle run, which is the figure a row is read by.
126    pub median: f64,
127    /// The slowest of the runs.
128    pub min: f64,
129    /// The fastest of them.
130    pub max: f64,
131}
132
133impl Measure {
134    /// The measure over `values`, or `None` when there are none. An even count
135    /// takes the mean of the middle two, so three runs and four runs are read
136    /// the same way.
137    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/// What the cold run measured, or why it did not.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum ColdStart {
160    /// The model was cleared from memory first, and this is the first token
161    /// that followed: what a caller waits for on a cold machine.
162    Measured(i64),
163    /// It could not be cleared, so nothing here was cold. Carries who held it.
164    Held(String),
165    /// Residency was left alone, so no cold run was made.
166    NotMeasured,
167}
168
169impl ColdStart {
170    /// The measured milliseconds, when there are any.
171    pub fn millis(&self) -> Option<i64> {
172        match self {
173            ColdStart::Measured(ms) => Some(*ms),
174            _ => None,
175        }
176    }
177}
178
179/// What a finished model's runs came to.
180#[derive(Debug, Clone, PartialEq)]
181pub struct Figures {
182    /// The cold run, or why there was none.
183    pub cold_start: ColdStart,
184    /// Tokens a second over the warm runs.
185    pub tokens_per_second: Option<Measure>,
186    /// Time to first token over the warm runs.
187    pub ttft_ms: Option<Measure>,
188    /// Prompt tokens a second, where the backend timed the prompt.
189    pub prompt_tokens_per_second: Option<Measure>,
190    /// Whether any warm run's token count was estimated.
191    pub estimated_tokens: bool,
192    /// Where the decode timing came from; wall clock if any run fell back.
193    pub source: TimingSource,
194    /// The warm runs themselves, for the detail and the JSON.
195    pub runs: Vec<Sample>,
196}
197
198impl Figures {
199    /// Summarize the warm `runs` of one model, with `cold` saying what the run
200    /// before them measured, or why there was not one.
201    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            // One wall-clock run makes the row's rate a wall-clock reading;
215            // saying "backend" would overclaim for the whole column, and so
216            // would saying it over no runs at all.
217            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    /// The rate the row is ranked and drawn by.
229    pub fn rate(&self) -> Option<f64> {
230        self.tokens_per_second.map(|measure| measure.median)
231    }
232}
233
234/// Which phase of a model's turn is under way.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum Phase {
237    /// The first run after eviction, whose first token is the cold start.
238    ColdStart,
239    /// One of the warm runs, numbered from one.
240    Warm { run: usize, of: usize },
241}
242
243/// Where a model is in the bench.
244#[derive(Debug, Clone, PartialEq)]
245pub enum Status {
246    /// Queued, not started.
247    Waiting,
248    /// Running, with the tokens it has produced in this run so far.
249    Running { phase: Phase, tokens: i64 },
250    /// Measured.
251    Done(Box<Figures>),
252    /// Tried and failed, with the reason.
253    Failed(String),
254    /// Never tried, with the reason.
255    Skipped(String),
256    /// The bench was stopped before this model measured anything; a stop
257    /// after a warm run landed keeps what it had instead.
258    Stopped,
259}
260
261impl Status {
262    /// The figures, for a model that finished.
263    pub fn figures(&self) -> Option<&Figures> {
264        match self {
265            Status::Done(figures) => Some(figures),
266            _ => None,
267        }
268    }
269
270    /// The rate a finished model measured.
271    pub fn rate(&self) -> Option<f64> {
272        self.figures().and_then(Figures::rate)
273    }
274}
275
276/// One row of the bench: the model, what runs it, and how it is doing.
277#[derive(Debug, Clone, PartialEq)]
278pub struct Row {
279    /// The record id, for addressing the model again.
280    pub id: String,
281    /// The model's display name.
282    pub name: String,
283    /// The runtime that serves it, when it resolves to one.
284    pub runtime: Option<String>,
285    /// The quantization its weights carry, when it is known.
286    pub quantization: Option<String>,
287    /// Where it is in the bench.
288    pub status: Status,
289}
290
291impl Row {
292    /// A queued row for a model that will be benched.
293    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    /// A row for a model the bench will not try, and why.
309    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
322/// The fastest rate any row measured, which the bars are drawn against.
323pub fn fastest(rows: &[Row]) -> Option<f64> {
324    rows.iter()
325        .filter_map(|row| row.status.rate())
326        .reduce(f64::max)
327}
328
329/// `rows` in the order the finished table draws them: measured rows fastest
330/// first, then everything that produced no figure, in the order it came.
331pub 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
344/// How many of a bar's `width` cells a rate of `rate` lights, against the
345/// `fastest` row. A rate that measured anything at all keeps one cell, so a
346/// slow model reads as slow rather than as nothing measured.
347pub 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
355/// Tokens a reply of `text` is worth, when nothing counted them: its characters
356/// over [`CHARS_PER_TOKEN`], and never zero for text that exists.
357pub 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        // The wall clock still carries the wait, which no backend reports.
402        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        // The span begins at the first token, so it is worth the other thirty.
411        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        // Nine Georgian letters are twenty-seven bytes; counting bytes would
449        // call this seven tokens instead of two.
450        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        // Half a cell's worth still shows: a measured row is never blank.
525        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}