1use serde::{Deserialize, Serialize};
18
19use super::join::{Ratio, RatioMethod};
20use super::metrics::{percentile, RequestSample};
21use super::protocol::{BOOTSTRAP_RESAMPLES, BOOTSTRAP_SEED};
22
23#[derive(Debug, Clone)]
26pub struct SplitMix64 {
27 state: u64,
28}
29
30impl SplitMix64 {
31 #[must_use]
33 pub fn new(seed: u64) -> Self {
34 Self { state: seed }
35 }
36
37 pub fn next_u64(&mut self) -> u64 {
39 self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
40 let mut z = self.state;
41 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
42 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
43 z ^ (z >> 31)
44 }
45
46 pub fn index_below(&mut self, n: usize) -> usize {
50 debug_assert!(n > 0, "index_below(0) is undefined");
51 ((u128::from(self.next_u64()) * n as u128) >> 64) as usize
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct BootstrapCi {
59 pub point: f64,
61 pub lower: f64,
63 pub upper: f64,
65 pub confidence: f64,
67 pub resamples: usize,
69 pub seed: u64,
71 pub resampling_unit: &'static str,
73 pub n: usize,
75}
76
77pub type Statistic = fn(&[RequestSample]) -> f64;
84
85pub fn bootstrap_ci(
96 samples: &[RequestSample],
97 confidence: f64,
98 statistic: Statistic,
99) -> Option<BootstrapCi> {
100 bootstrap_ci_with(
101 samples,
102 confidence,
103 BOOTSTRAP_RESAMPLES,
104 BOOTSTRAP_SEED,
105 statistic,
106 )
107}
108
109pub fn bootstrap_ci_with(
113 samples: &[RequestSample],
114 confidence: f64,
115 resamples: usize,
116 seed: u64,
117 statistic: Statistic,
118) -> Option<BootstrapCi> {
119 let n = samples.len();
120 if n < 2 || resamples == 0 || !(0.0..1.0).contains(&confidence) {
121 return None;
122 }
123
124 let mut rng = SplitMix64::new(seed);
125 let mut draws = Vec::with_capacity(resamples);
126 let mut resample: Vec<RequestSample> = Vec::with_capacity(n);
130 for _ in 0..resamples {
131 resample.clear();
132 for _ in 0..n {
133 resample.push(samples[rng.index_below(n)].clone());
134 }
135 draws.push(statistic(&resample));
136 }
137 draws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
138
139 let alpha = 1.0 - confidence;
140 let lower = percentile(&draws, alpha / 2.0)?;
141 let upper = percentile(&draws, 1.0 - alpha / 2.0)?;
142
143 Some(BootstrapCi {
144 point: statistic(samples),
145 lower,
146 upper,
147 confidence,
148 resamples,
149 seed,
150 resampling_unit: "whole_request",
151 n,
152 })
153}
154
155#[must_use]
163pub fn bootstrap_agg_tok_s_ci(samples: &[RequestSample], confidence: f64) -> Option<BootstrapCi> {
164 bootstrap_ci(samples, confidence, super::metrics::agg_tok_s)
165}
166
167#[must_use]
193pub fn paired_ratio_lcb(
194 subject: &[RequestSample],
195 comparator: &[RequestSample],
196 statistic: Statistic,
197 confidence: f64,
198) -> Option<Ratio> {
199 let draws = paired_ratio_draws(
200 subject,
201 comparator,
202 statistic,
203 BOOTSTRAP_RESAMPLES,
204 BOOTSTRAP_SEED,
205 confidence,
206 )?;
207 let denominator = statistic(comparator);
208 Some(Ratio {
209 point: statistic(subject) / denominator,
210 lcb95: percentile(&draws, 1.0 - confidence),
211 method: RatioMethod::PairedPercentileBootstrap,
212 n: subject.len() + comparator.len(),
213 })
214}
215
216#[must_use]
224pub fn paired_ratio_draws(
225 subject: &[RequestSample],
226 comparator: &[RequestSample],
227 statistic: Statistic,
228 resamples: usize,
229 seed: u64,
230 confidence: f64,
231) -> Option<Vec<f64>> {
232 let (n_s, n_c) = (subject.len(), comparator.len());
233 if n_s < 2 || n_c < 2 || resamples == 0 || !(0.0..1.0).contains(&confidence) {
234 return None;
235 }
236 if statistic(comparator) <= 0.0 {
237 return None;
238 }
239 let mut rng = SplitMix64::new(seed);
240 let mut draws = Vec::with_capacity(resamples);
241 let mut lane_s: Vec<RequestSample> = Vec::with_capacity(n_s);
242 let mut lane_c: Vec<RequestSample> = Vec::with_capacity(n_c);
243 for _ in 0..resamples {
244 lane_s.clear();
245 for _ in 0..n_s {
246 lane_s.push(subject[rng.index_below(n_s)].clone());
247 }
248 lane_c.clear();
249 for _ in 0..n_c {
250 lane_c.push(comparator[rng.index_below(n_c)].clone());
251 }
252 let denominator = statistic(&lane_c);
253 if denominator > 0.0 {
254 draws.push(statistic(&lane_s) / denominator);
255 }
256 }
257 if draws.is_empty() {
258 return None;
259 }
260 draws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
261 Some(draws)
262}
263
264#[must_use]
270pub fn median_decode_tok_s(samples: &[RequestSample]) -> f64 {
271 let mut rates: Vec<f64> = samples
272 .iter()
273 .filter(|s| s.counts_toward_aggregate())
274 .filter_map(RequestSample::decode_tok_s)
275 .collect();
276 rates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
277 percentile(&rates, 0.50).unwrap_or(0.0)
278}
279
280#[must_use]
282pub fn ttft_p50_ms(samples: &[RequestSample]) -> f64 {
283 ttft_percentile_ms(samples, 0.50)
284}
285
286#[must_use]
292pub fn itl_p95_ms(samples: &[RequestSample]) -> f64 {
293 let mut gaps: Vec<f64> = samples
294 .iter()
295 .filter(|s| s.counts_toward_aggregate())
296 .flat_map(RequestSample::itl_gaps_ms)
297 .collect();
298 gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
299 percentile(&gaps, 0.95).unwrap_or(0.0)
300}
301
302fn ttft_percentile_ms(samples: &[RequestSample], p: f64) -> f64 {
303 let mut v: Vec<f64> = samples
304 .iter()
305 .filter(|s| s.counts_toward_aggregate())
306 .filter_map(RequestSample::ttft_ms)
307 .collect();
308 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
309 percentile(&v, p).unwrap_or(0.0)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::perf_gate::metrics::agg_tok_s;
316 use crate::perf_gate::protocol::Outcome;
317
318 fn sample(index: usize, start_s: f64, end_s: f64, tokens: u32) -> RequestSample {
319 RequestSample {
320 index,
321 worker: index % 4,
322 start_s,
323 end_s,
324 token_times_s: vec![start_s + 0.01, end_s],
325 generated_tokens: tokens,
326 prompt_tokens: 512,
327 outcome: Outcome::Completed,
328 in_flight_at_start: 4,
329 drained: false,
330 }
331 }
332
333 fn deck(n: usize) -> Vec<RequestSample> {
334 (0..n)
335 .map(|i| {
336 let start = i as f64 * 0.25;
337 let jitter = f64::from((i % 7) as u32) * 0.05;
338 sample(i, start, start + 1.0 + jitter, 100 + (i % 5) as u32)
339 })
340 .collect()
341 }
342
343 #[test]
346 fn splitmix64_stream_is_pinned() {
347 let mut r = SplitMix64::new(2026);
348 let got: Vec<u64> = (0..4).map(|_| r.next_u64()).collect();
349 assert_eq!(
350 got,
351 vec![
352 15_824_617_304_438_902_051,
353 8_699_989_649_721_214_301,
354 12_310_341_597_754_734_734,
355 7_097_835_237_234_771_186,
356 ],
357 "SplitMix64(2026) stream changed"
358 );
359 }
360
361 #[test]
362 fn index_below_stays_in_range_and_covers_it() {
363 let mut r = SplitMix64::new(BOOTSTRAP_SEED);
364 let mut seen = [false; 5];
365 for _ in 0..500 {
366 let i = r.index_below(5);
367 assert!(i < 5, "index {i} out of range");
368 seen[i] = true;
369 }
370 assert!(seen.iter().all(|&s| s), "every index must be reachable");
371 }
372
373 #[test]
376 fn same_samples_and_seed_give_the_identical_interval_twice() {
377 let s = deck(40);
378 let a = bootstrap_agg_tok_s_ci(&s, 0.95).expect("n >= 2");
379 let b = bootstrap_agg_tok_s_ci(&s, 0.95).expect("n >= 2");
380 assert_eq!(
381 a, b,
382 "the CI must be reproducible from the retained samples"
383 );
384 assert_eq!(a.seed, 2026);
385 assert_eq!(a.resamples, 10_000);
386 assert_eq!(a.resampling_unit, "whole_request");
387 assert_eq!(a.n, 40);
388 }
389
390 #[test]
393 fn a_different_seed_gives_a_different_interval() {
394 let s = deck(40);
395 let a = bootstrap_ci_with(&s, 0.95, 10_000, 2026, agg_tok_s).expect("n >= 2");
396 let b = bootstrap_ci_with(&s, 0.95, 10_000, 2027, agg_tok_s).expect("n >= 2");
397 assert_eq!(
398 a.point, b.point,
399 "the point estimate does not depend on the seed"
400 );
401 assert!(
402 (a.lower - b.lower).abs() > f64::EPSILON || (a.upper - b.upper).abs() > f64::EPSILON,
403 "seed had no effect: {a:?} vs {b:?}"
404 );
405 }
406
407 #[test]
408 fn the_interval_brackets_the_point_estimate() {
409 let s = deck(60);
410 let ci = bootstrap_agg_tok_s_ci(&s, 0.95).expect("n >= 2");
411 assert!(ci.lower <= ci.point, "{ci:?}");
412 assert!(ci.point <= ci.upper, "{ci:?}");
413 assert!(
414 ci.lower < ci.upper,
415 "a non-degenerate sample needs width: {ci:?}"
416 );
417 }
418
419 #[test]
423 fn resampling_is_with_replacement_over_whole_requests() {
424 let mut rng = SplitMix64::new(BOOTSTRAP_SEED);
425 let n = 8;
426 let mut counts = vec![0_usize; n];
427 for _ in 0..n {
428 counts[rng.index_below(n)] += 1;
429 }
430 assert!(
431 counts.iter().any(|&c| c >= 2),
432 "a with-replacement draw of n from n must duplicate: {counts:?}"
433 );
434 }
435
436 #[test]
440 fn more_dispersion_widens_the_interval() {
441 let tight: Vec<RequestSample> = (0..40)
442 .map(|i| sample(i, i as f64 * 0.25, i as f64 * 0.25 + 1.0, 100))
443 .collect();
444 let loose: Vec<RequestSample> = (0..40)
445 .map(|i| {
446 let start = i as f64 * 0.25;
447 let dur = if i % 2 == 0 { 0.2 } else { 4.0 };
448 sample(i, start, start + dur, 100)
449 })
450 .collect();
451 let a = bootstrap_agg_tok_s_ci(&tight, 0.95).expect("n >= 2");
452 let b = bootstrap_agg_tok_s_ci(&loose, 0.95).expect("n >= 2");
453 assert!(
454 (b.upper - b.lower) > (a.upper - a.lower),
455 "dispersed: {:?} must be wider than tight: {:?}",
456 b,
457 a
458 );
459 }
460
461 #[test]
462 fn fewer_than_two_observations_has_no_interval() {
463 assert!(bootstrap_agg_tok_s_ci(&[], 0.95).is_none());
464 assert!(bootstrap_agg_tok_s_ci(&deck(1), 0.95).is_none());
465 assert!(bootstrap_agg_tok_s_ci(&deck(2), 0.95).is_some());
466 }
467
468 #[test]
469 fn a_nonsense_confidence_has_no_interval() {
470 let s = deck(10);
471 assert!(bootstrap_ci(&s, 1.0, agg_tok_s).is_none());
472 assert!(bootstrap_ci(&s, -0.1, agg_tok_s).is_none());
473 }
474
475 fn lane(n: usize, rate: f64, jitter: f64) -> Vec<RequestSample> {
477 (0..n)
478 .map(|i| {
479 let start = i as f64 * 0.25;
480 let r = rate + jitter * (((i * 7 + 3) % 23) as f64 / 23.0 - 0.5);
485 let step = 1.0 / r;
487 let times: Vec<f64> = (0..128)
488 .map(|k| start + 0.05 + f64::from(k) * step)
489 .collect();
490 let end = times[127] + 0.01;
491 RequestSample {
492 index: i,
493 worker: i % 4,
494 start_s: start,
495 end_s: end,
496 token_times_s: times,
497 generated_tokens: 128,
498 prompt_tokens: 512,
499 outcome: Outcome::Completed,
500 in_flight_at_start: 1,
501 drained: false,
502 }
503 })
504 .collect()
505 }
506
507 #[test]
510 fn paired_ratio_lcb_is_reproducible_bit_for_bit_at_seed_2026() {
511 let subject = lane(30, 100.0, 3.0);
512 let comparator = lane(30, 90.0, 3.0);
513 let a = paired_ratio_lcb(&subject, &comparator, median_decode_tok_s, 0.95)
514 .expect("both lanes have n >= 2");
515 let b = paired_ratio_lcb(&subject, &comparator, median_decode_tok_s, 0.95)
516 .expect("both lanes have n >= 2");
517 assert_eq!(a, b, "the bound must be reproducible from the samples");
518 assert_eq!(BOOTSTRAP_SEED, 2026);
519 assert_eq!(BOOTSTRAP_RESAMPLES, 10_000);
520 assert_eq!(a.method, RatioMethod::PairedPercentileBootstrap);
521 assert_eq!(a.n, 60, "both lanes' retained requests");
522
523 let other = paired_ratio_draws(
525 &subject,
526 &comparator,
527 median_decode_tok_s,
528 10_000,
529 2027,
530 0.95,
531 )
532 .expect("draws");
533 let mine = paired_ratio_draws(
534 &subject,
535 &comparator,
536 median_decode_tok_s,
537 10_000,
538 2026,
539 0.95,
540 )
541 .expect("draws");
542 assert_ne!(
543 percentile(&other, 0.05),
544 percentile(&mine, 0.05),
545 "seed 2026 must not be decoration"
546 );
547 }
548
549 #[test]
552 fn lcb95_is_the_fifth_percentile_not_the_2_5th() {
553 let subject = lane(30, 100.0, 8.0);
554 let comparator = lane(30, 90.0, 8.0);
555 let draws = paired_ratio_draws(
556 &subject,
557 &comparator,
558 median_decode_tok_s,
559 BOOTSTRAP_RESAMPLES,
560 BOOTSTRAP_SEED,
561 0.95,
562 )
563 .expect("draws");
564 let bound = paired_ratio_lcb(&subject, &comparator, median_decode_tok_s, 0.95)
565 .expect("bound")
566 .lcb95
567 .expect("lcb95");
568 let p05 = percentile(&draws, 0.05).expect("p05");
569 let p025 = percentile(&draws, 0.025).expect("p025");
570 assert_eq!(bound, p05, "the bound is the 5th percentile");
571 assert_ne!(
572 p05, p025,
573 "the two percentiles must differ, or this test proves nothing"
574 );
575 assert!(p025 < p05, "the 2.5th percentile is the looser bound");
576 }
577
578 #[test]
582 fn identical_lanes_give_point_one() {
583 let l = lane(30, 100.0, 4.0);
584 for statistic in [
585 median_decode_tok_s as Statistic,
586 ttft_p50_ms as Statistic,
587 itl_p95_ms as Statistic,
588 ] {
589 let r = paired_ratio_lcb(&l, &l, statistic, 0.95).expect("n >= 2");
590 assert_eq!(r.point, 1.0, "a lane against itself is parity");
591 let lcb = r.lcb95.expect("bounded");
592 assert!(lcb <= r.point, "{r:?}");
593 assert!(lcb > 0.0, "{r:?}");
594 }
595 }
596
597 #[test]
600 fn the_ratio_is_subject_over_comparator() {
601 let fast = lane(30, 120.0, 2.0);
602 let slow = lane(30, 60.0, 2.0);
603 let up = paired_ratio_lcb(&fast, &slow, median_decode_tok_s, 0.95).expect("n >= 2");
604 let down = paired_ratio_lcb(&slow, &fast, median_decode_tok_s, 0.95).expect("n >= 2");
605 assert!(up.point > 1.5, "{up:?}");
606 assert!(down.point < 0.7, "{down:?}");
607 assert!(
608 (up.point * down.point - 1.0).abs() < 1e-9,
609 "{up:?} {down:?}"
610 );
611 }
612
613 #[test]
616 fn a_degenerate_lane_has_no_paired_bound() {
617 let ok = lane(30, 100.0, 2.0);
618 assert!(paired_ratio_lcb(&ok[..1], &ok, median_decode_tok_s, 0.95).is_none());
619 assert!(paired_ratio_lcb(&ok, &ok[..1], median_decode_tok_s, 0.95).is_none());
620 assert!(paired_ratio_lcb(&ok, &ok, median_decode_tok_s, 1.0).is_none());
621
622 let unstreamed: Vec<RequestSample> = ok
623 .iter()
624 .map(|s| RequestSample {
625 token_times_s: Vec::new(),
626 ..s.clone()
627 })
628 .collect();
629 assert!(
630 paired_ratio_lcb(&ok, &unstreamed, median_decode_tok_s, 0.95).is_none(),
631 "a zero denominator is not a large ratio"
632 );
633 }
634
635 #[test]
638 fn the_request_unit_statistics_are_the_section_3_definitions() {
639 let l = lane(4, 100.0, 0.0);
640 assert!(
642 (median_decode_tok_s(&l) - 100.0).abs() < 1e-6,
643 "{}",
644 median_decode_tok_s(&l)
645 );
646 assert!((ttft_p50_ms(&l) - 50.0).abs() < 1e-6, "{}", ttft_p50_ms(&l));
647 assert!((itl_p95_ms(&l) - 10.0).abs() < 1e-6, "{}", itl_p95_ms(&l));
648 assert_eq!(median_decode_tok_s(&[]), 0.0);
649 assert_eq!(ttft_p50_ms(&[]), 0.0);
650 assert_eq!(itl_p95_ms(&[]), 0.0);
651 }
652}