metrale_bench/benchmarks/
ttft.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Warm and cold TTFT regression gates: one state machine, two modes.
4//!
5//! * Warm: every sample at a prompt length sends the same prompt, and each
6//!   measured request follows a priming request with that prompt.
7//! * Cold: every sample gets a unique prefix tag, so no two requests share a
8//!   prefix.
9//!
10//! 2026-09-27: The synthetic gates send `prompt_lengths` of filler; the
11//! high-ISL gates send one committed long prompt and check the server's
12//! prompt-token count on every sample (`ttft/long_prompt.rs`).
13//!
14//! Owner: bench, ttft.
15//! Invariants:
16//! - The verdict compares the run's median and p90 with the stored baseline of
17//!   the same box and model only; any other baseline gives an Info verdict
18//!   (`ttft_verdict.rs`).
19//! - A failing run is never stored as the baseline (`should_store`).
20
21use std::collections::BTreeMap;
22use std::future::Future;
23use std::time::{Duration, Instant};
24
25use anyhow::{Context, Result, bail};
26use serde_json::json;
27
28use crate::benchmark::{Benchmark, BenchmarkDescriptor};
29use crate::benchmarks::stats::{self, PromptMode};
30use crate::benchmarks::{baseline, one_line};
31use crate::http;
32use crate::metadata::PluginMetadata;
33use crate::params::{ParamKind, ParamSpec, ParamValue, ParamValues};
34use crate::plugin::{Plugin, PluginHandle};
35use crate::result::{BenchmarkResult, Cell, CellStyle, Column, LogLine, ResultTable, RunStatus};
36
37mod descriptors;
38mod long_prompt;
39pub use descriptors::{
40    COLD_DESCRIPTOR, COLD_METADATA, HIGH_ISL_COLD_DESCRIPTOR, HIGH_ISL_COLD_MOE_DESCRIPTOR,
41    HIGH_ISL_WARM_DESCRIPTOR, HIGH_ISL_WARM_MOE_DESCRIPTOR, WARM_DESCRIPTOR, WARM_METADATA,
42};
43
44#[derive(Clone, Copy, PartialEq, Eq)]
45pub enum Mode {
46    Warm,
47    Cold,
48}
49
50impl Mode {
51    fn descriptor(self) -> &'static BenchmarkDescriptor {
52        match self {
53            Mode::Warm => &WARM_DESCRIPTOR,
54            Mode::Cold => &COLD_DESCRIPTOR,
55        }
56    }
57    fn metadata(self) -> &'static PluginMetadata {
58        match self {
59            Mode::Warm => &WARM_METADATA,
60            Mode::Cold => &COLD_METADATA,
61        }
62    }
63    fn label(self) -> &'static str {
64        match self {
65            Mode::Warm => "warm",
66            Mode::Cold => "cold",
67        }
68    }
69}
70
71struct LengthRow {
72    prompt_tokens: usize,
73    samples: Vec<f64>,
74    cached_tokens: usize,
75}
76
77fn ttft_stats(samples: &[f64]) -> (Option<f64>, Option<f64>, Option<f64>) {
78    let percentiles = stats::Percentiles::of(samples);
79    (stats::median(samples), percentiles.p90, percentiles.p99)
80}
81
82fn sample_prefix_tag(mode: Mode, tokens: usize, sample: usize, run_id: u64) -> String {
83    match mode {
84        Mode::Warm => format!("warm-{tokens}"),
85        Mode::Cold => {
86            crate::benchmarks::unique_prefix_tag(&format!("cold-{tokens}-{sample}"), run_id)
87        }
88    }
89}
90
91fn valid_ttft_ms(value: f64) -> bool {
92    value.is_finite() && value > 0.0
93}
94
95pub struct TtftGate {
96    mode: Mode,
97    descriptor: &'static BenchmarkDescriptor,
98    metadata: &'static PluginMetadata,
99    /// 2026-09-27: Whether this gate sends a committed long prompt
100    /// (`long_prompt.rs`) in place of `prompt_lengths` of synthetic filler.
101    fixture: bool,
102    long: Option<long_prompt::LongPrompt>,
103    handle: Option<PluginHandle>,
104    lengths: Vec<usize>,
105    repeats: usize,
106    median_limit_pct: f64,
107    p90_limit_pct: f64,
108    update_baseline: bool,
109    timeout: Duration,
110    cursor: usize,
111    rows: Vec<LengthRow>,
112    started: Option<Instant>,
113    probed: bool,
114}
115
116impl TtftGate {
117    /// 2026-09-26: Smallest absolute TTFT rise, in ms, this gate calls a
118    /// regression. A metric fails only when it exceeds both its percentage
119    /// limit and this floor (`ttft_verdict.rs`).
120    const NOISE_FLOOR_MS: f64 = 2.0;
121
122    pub fn new(mode: Mode) -> Self {
123        Self::build(mode, mode.descriptor(), mode.metadata(), false)
124    }
125
126    fn build(
127        mode: Mode,
128        descriptor: &'static BenchmarkDescriptor,
129        metadata: &'static PluginMetadata,
130        fixture: bool,
131    ) -> Self {
132        Self {
133            mode,
134            descriptor,
135            metadata,
136            fixture,
137            long: None,
138            handle: None,
139            lengths: Vec::new(),
140            repeats: 0,
141            median_limit_pct: 3.0,
142            p90_limit_pct: 5.0,
143            update_baseline: true,
144            timeout: Duration::from_secs(300),
145            cursor: 0,
146            rows: Vec::new(),
147            started: None,
148            probed: false,
149        }
150    }
151
152    fn handle(&self) -> Result<&PluginHandle> {
153        self.handle.as_ref().context("benchmark was not loaded")
154    }
155
156    fn elapsed(&self) -> Duration {
157        self.started.map(|s| s.elapsed()).unwrap_or_default()
158    }
159
160    /// 2026-09-26: Every measured TTFT sample, across all prompt lengths.
161    fn all_samples(&self) -> Vec<f64> {
162        self.rows.iter().flat_map(|r| r.samples.clone()).collect()
163    }
164
165    async fn measure(&self, content: &str) -> Result<http::ChatOutcome> {
166        let handle = self.handle()?;
167        let target = handle.target();
168        let mut body = json!({
169            "model": target.model,
170            "stream": true,
171            // 2026-09-26: Enough for a first-token timestamp; TTFT does not
172            // depend on the reply length.
173            "max_tokens": 8,
174            "temperature": 0.0,
175            "messages": [{"role": "user", "content": content}],
176        });
177        if self.long.is_some() {
178            long_prompt::extend_request(&mut body);
179        }
180        http::chat_stream(target, &body, self.timeout).await
181    }
182
183    async fn run_length(&mut self, tokens: usize) -> Result<LengthRow> {
184        let handle = self.handle()?.clone();
185        let mut samples = Vec::with_capacity(self.repeats);
186        let mut cached_tokens = 0usize;
187        for i in 0..self.repeats {
188            handle.check_cancelled()?;
189            handle.status(format!(
190                "{} · {tokens} tok · sample {}/{}",
191                self.mode.label(),
192                i + 1,
193                self.repeats
194            ));
195            let content = match &self.long {
196                Some(long) => long.prompt(self.mode, i),
197                None => {
198                    let prefix_tag = sample_prefix_tag(self.mode, tokens, i, handle.run_id());
199                    stats::make_prompt(tokens, PromptMode::Natural, &prefix_tag)
200                }
201            };
202            if self.mode == Mode::Warm {
203                // 2026-09-26: Prime, then measure: the priming request's result
204                // is discarded, and only the second request is sampled.
205                let _ = self.measure(&content).await;
206                handle.check_cancelled()?;
207            }
208            let outcome = self.measure(&content).await?;
209            if let Some(long) = &mut self.long {
210                long.admit(i, outcome.prompt_tokens)?;
211            }
212            cached_tokens = cached_tokens.max(outcome.cached_prompt_tokens);
213            match outcome.ttft_ms {
214                Some(ms) if valid_ttft_ms(ms) => samples.push(ms),
215                Some(ms) => handle.warn(format!(
216                    "{tokens} tok sample {i}: invalid TTFT measurement {ms:?}"
217                )),
218                None => handle.warn(format!("{tokens} tok sample {i}: no token was emitted")),
219            }
220        }
221        Ok(LengthRow {
222            // 2026-09-27: A long prompt's row shows the server's count.
223            prompt_tokens: self.observed_prompt_tokens().unwrap_or(tokens),
224            samples,
225            cached_tokens,
226        })
227    }
228
229    fn table(&self) -> ResultTable {
230        let mut t = ResultTable::new(
231            format!("{} TTFT", self.mode.label().to_uppercase()),
232            vec![
233                Column::right("Prompt tok", 11),
234                Column::right("n", 4),
235                Column::right("median", 9),
236                Column::right("p90", 9),
237                Column::right("p99", 9),
238                Column::right("cached tok", 11),
239            ],
240        );
241        for r in &self.rows {
242            let (median, p90, p99) = ttft_stats(&r.samples);
243            t.push(vec![
244                Cell::new(r.prompt_tokens.to_string()),
245                Cell::new(r.samples.len().to_string()),
246                Cell::styled(stats::fmt_ms(median), CellStyle::Accent),
247                Cell::new(stats::fmt_ms(p90)),
248                Cell::new(stats::fmt_ms(p99)),
249                // 2026-09-26: Cache-hit evidence: 0 cached tokens is Bad in warm
250                // mode and Good in cold mode. The verdict does not read it.
251                Cell::styled(
252                    r.cached_tokens.to_string(),
253                    match (self.mode, r.cached_tokens) {
254                        (Mode::Warm, 0) => CellStyle::Bad,
255                        (Mode::Cold, 0) => CellStyle::Good,
256                        (Mode::Warm, _) => CellStyle::Good,
257                        (Mode::Cold, _) => CellStyle::Warn,
258                    },
259                ),
260            ]);
261        }
262        t
263    }
264}
265
266impl Plugin for TtftGate {
267    fn metadata(&self) -> &'static PluginMetadata {
268        self.metadata
269    }
270
271    fn load(&mut self, handle: PluginHandle) -> impl Future<Output = Result<()>> + Send {
272        self.handle = Some(handle);
273        self.started = Some(Instant::now());
274        async { Ok(()) }
275    }
276}
277
278impl Benchmark for TtftGate {
279    fn descriptor(&self) -> &'static BenchmarkDescriptor {
280        self.descriptor
281    }
282
283    fn parameters(&self) -> Vec<ParamSpec> {
284        let (repeats, median_limit, p90_limit) = if self.fixture {
285            long_prompt::one_shot_defaults(self.mode)
286        } else {
287            (12, 3.0, 5.0)
288        };
289        let source = if self.fixture {
290            long_prompt::source_parameters()
291        } else {
292            vec![ParamSpec::new(
293                "prompt_lengths",
294                "Prompt lengths",
295                "Prompt sizes in tokens; one table row each.",
296                ParamKind::IntList {
297                    min: 16,
298                    max: 131_072,
299                },
300                ParamValue::IntList(vec![256, 1024, 4096]),
301            )]
302        };
303        source.into_iter().chain([
304            ParamSpec::new(
305                "repeats",
306                "Samples per length",
307                "More samples narrow the median; each costs one request (two in warm mode).",
308                ParamKind::Int { min: 1, max: 200 },
309                ParamValue::Int(repeats),
310            ),
311            ParamSpec::new(
312                "median_limit_pct",
313                "Median limit",
314                "Percent the median may rise over the baseline before this gate fails.",
315                ParamKind::Float {
316                    min: 0.0,
317                    max: 100.0,
318                },
319                ParamValue::Float(median_limit),
320            ),
321            ParamSpec::new(
322                "p90_limit_pct",
323                "p90 limit",
324                "Percent p90 may rise over the baseline before this gate fails.",
325                ParamKind::Float {
326                    min: 0.0,
327                    max: 100.0,
328                },
329                ParamValue::Float(p90_limit),
330            ),
331            ParamSpec::new(
332                "update_baseline",
333                "Record as baseline",
334                "Store this run's numbers as the new baseline. Turn off to compare without moving the bar.",
335                ParamKind::Bool,
336                ParamValue::Bool(true),
337            ),
338            ParamSpec::new(
339                "request_timeout_s",
340                "Request timeout",
341                "Seconds before a single request is abandoned.",
342                ParamKind::Int { min: 10, max: 3600 },
343                ParamValue::Int(300),
344            ),
345        ])
346        .collect()
347    }
348
349    fn configure(&mut self, values: &ParamValues) -> Result<()> {
350        let specs = self.parameters();
351        values.validate_against(&specs)?;
352        (self.lengths, self.long) = if self.fixture {
353            let long = long_prompt::LongPrompt::configure(values)?;
354            (vec![long.min_prompt_tokens], Some(long))
355        } else {
356            let lengths = values.int_list("prompt_lengths")?;
357            (lengths.iter().map(|v| *v as usize).collect(), None)
358        };
359        self.repeats = values.usize("repeats")?;
360        self.median_limit_pct = values.float("median_limit_pct")?;
361        self.p90_limit_pct = values.float("p90_limit_pct")?;
362        self.update_baseline = values.bool("update_baseline")?;
363        self.timeout = Duration::from_secs(values.usize("request_timeout_s")? as u64);
364        self.probed = false;
365        self.cursor = 0;
366        self.rows.clear();
367        Ok(())
368    }
369
370    async fn next(&mut self) -> Result<BenchmarkResult> {
371        let handle = self.handle()?.clone();
372        handle.check_cancelled()?;
373        let total = self.lengths.len() as u64;
374
375        if !self.probed {
376            self.probed = true;
377            http::probe(handle.target(), Duration::from_secs(10))
378                .await
379                .context("endpoint probe failed — check the target URL and port")?;
380            if total == 0 {
381                bail!("no prompt lengths to measure");
382            }
383            self.warm_up().await?;
384            return Ok(BenchmarkResult::running("probe", self.elapsed())
385                .with_progress(0, total)
386                .log_line(LogLine::info(format!(
387                    "{} mode · {} · {} length(s) × {} samples",
388                    self.mode.label(),
389                    handle.target().base_url,
390                    total,
391                    self.repeats
392                ))));
393        }
394
395        if self.cursor >= self.lengths.len() {
396            let samples = self.all_samples();
397            if samples.is_empty() {
398                bail!("no TTFT samples were collected — the endpoint emitted no tokens");
399            }
400            let (median, p90, _) = ttft_stats(&samples);
401            let (verdict, summary) = self.verdict(median, p90);
402            let mut metrics = BTreeMap::new();
403            // 2026-09-26: How many measurements are behind the median, so a
404            // record drawn at fewer lengths or repeats can be told apart.
405            metrics.insert("samples".to_string(), samples.len() as f64);
406            if let Some(v) = median {
407                metrics.insert("median_ms".to_string(), v);
408            }
409            if let Some(v) = p90 {
410                metrics.insert("p90_ms".to_string(), v);
411            }
412            // 2026-09-27: The server's count, so a record shows the prompt size
413            // it was measured at rather than the size the gate asked for.
414            if let Some(n) = self.observed_prompt_tokens() {
415                metrics.insert("prompt_tokens".to_string(), n as f64);
416            }
417            if self.should_store(&verdict) {
418                let target = handle.target();
419                baseline::save(
420                    handle.artifacts(),
421                    self.descriptor.id,
422                    &target.base_url,
423                    &target.model,
424                    metrics.clone(),
425                )
426                .context("recording baseline")?;
427            }
428            return Ok(BenchmarkResult {
429                status: RunStatus::Completed,
430                ..BenchmarkResult::running("done", self.elapsed())
431            }
432            .with_progress(total, total)
433            .with_summary(summary)
434            .with_table(self.table())
435            .with_metrics(metrics)
436            .with_verdict(verdict));
437        }
438
439        let tokens = self.lengths[self.cursor];
440        let row = self.run_length(tokens).await?;
441        let (median, p90, _) = ttft_stats(&row.samples);
442        let line = LogLine::info(one_line(format!(
443            "{} {tokens} tok: median {} ms · p90 {} ms · cached {} tok",
444            self.mode.label(),
445            stats::fmt_ms(median),
446            stats::fmt_ms(p90),
447            row.cached_tokens
448        )));
449        self.rows.push(row);
450        self.cursor += 1;
451        handle.progress(self.cursor as u64, total);
452        Ok(BenchmarkResult::running(
453            format!("{} · {tokens} tok", self.mode.label()),
454            self.elapsed(),
455        )
456        .with_progress(self.cursor as u64, total)
457        .with_table(self.table())
458        .log_line(line))
459    }
460}
461
462#[path = "ttft_target.rs"]
463mod ttft_target;
464
465#[path = "ttft_verdict.rs"]
466mod ttft_verdict;
467
468#[cfg(test)]
469#[path = "ttft_tests.rs"]
470mod tests;