1use 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 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 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 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 "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 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 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 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 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 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;