Skip to main content

jugar_probar/perf_gate/
metrics.rs

1//! §4.4.3 — the metric definitions, computed from retained per-request samples.
2//!
3//! The whole point of this file is that `agg_tok_s` is a **wall-clock** quantity
4//! and the mean of per-request rates is a different number. `mean_of_rates` is
5//! implemented here *deliberately*, next to it, so the difference is asserted in
6//! a test rather than argued about in review. It is never used to produce a
7//! receipt figure.
8
9use serde::{Deserialize, Serialize};
10
11use super::protocol::Outcome;
12
13/// One retained per-request sample (§4.4.5). Every time is an offset in seconds
14/// from a single band-wide origin, so the wall-clock span is computable across
15/// workers without reconstructing anything.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct RequestSample {
19    /// Monotone index in issue order.
20    pub index: usize,
21    /// Which closed-loop worker issued it.
22    pub worker: usize,
23    /// Offset of the request start from the band origin.
24    pub start_s: f64,
25    /// Offset of the request's completion (or abandonment) from the band origin.
26    pub end_s: f64,
27    /// Arrival offsets of each streamed token. Empty when not streaming.
28    #[serde(default)]
29    pub token_times_s: Vec<f64>,
30    /// Generated (completion) tokens, counted per the receipt's `tokenization` block.
31    pub generated_tokens: u32,
32    /// Prompt tokens, when the server reports them.
33    #[serde(default)]
34    pub prompt_tokens: u32,
35    /// How the request ended.
36    pub outcome: Outcome,
37    /// Concurrent requests in flight at the instant this one was issued.
38    /// The direct, per-request evidence that the client was actually concurrent.
39    #[serde(default)]
40    pub in_flight_at_start: usize,
41    /// True when this request completed after the measurement window closed,
42    /// i.e. during the §4.4.7 drain.
43    #[serde(default)]
44    pub drained: bool,
45}
46
47impl RequestSample {
48    /// §4.4.3 `ttft_ms` — request start to first token byte at the client.
49    /// `None` when no token was ever observed.
50    #[must_use]
51    pub fn ttft_ms(&self) -> Option<f64> {
52        self.token_times_s
53            .first()
54            .map(|t| (t - self.start_s) * 1000.0)
55    }
56
57    /// §4.4.3 `decode_tok_s` for this request:
58    /// `(generated tokens - 1) / (last token time - first token time)`.
59    ///
60    /// `None` when fewer than two tokens were observed, where the quantity is
61    /// undefined rather than zero.
62    #[must_use]
63    pub fn decode_tok_s(&self) -> Option<f64> {
64        if self.token_times_s.len() < 2 || self.generated_tokens < 2 {
65            return None;
66        }
67        let first = self.token_times_s[0];
68        let last = self.token_times_s[self.token_times_s.len() - 1];
69        let span = last - first;
70        if span <= 0.0 {
71            return None;
72        }
73        Some(f64::from(self.generated_tokens - 1) / span)
74    }
75
76    /// §4.4.3 `itl_ms` — this request's inter-token gaps, for pooling.
77    #[must_use]
78    pub fn itl_gaps_ms(&self) -> Vec<f64> {
79        self.token_times_s
80            .windows(2)
81            .map(|w| (w[1] - w[0]) * 1000.0)
82            .collect()
83    }
84
85    /// True when this sample contributes to `agg_tok_s`'s numerator (§4.4.3:
86    /// completed and non-truncated).
87    #[must_use]
88    pub fn counts_toward_aggregate(&self) -> bool {
89        self.outcome == Outcome::Completed
90    }
91}
92
93/// §4.4.3 — one band's metrics.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct BandMetrics {
97    /// Fixed concurrency `c`.
98    pub concurrency: usize,
99    /// (Σ generated tokens over completed, non-truncated sampled requests)
100    /// ÷ (last completion − first request start). **Wall-clock.**
101    pub agg_tok_s: f64,
102    /// Median across sampled requests of per-request `(tokens-1)/(last-first)`.
103    pub decode_tok_s: f64,
104    /// p50 of `ttft_ms`.
105    pub ttft_p50_ms: f64,
106    /// p95 of `ttft_ms`.
107    pub ttft_p95_ms: f64,
108    /// p50 of the pooled inter-token gaps.
109    pub itl_p50_ms: f64,
110    /// p95 of the pooled inter-token gaps.
111    pub itl_p95_ms: f64,
112    /// Requests issued inside the window.
113    pub requested: usize,
114    /// Requests that completed.
115    pub completed: usize,
116    /// Requests that hit the 120 s hard timeout.
117    pub timeouts: usize,
118    /// Requests abandoned at the drain deadline (§4.4.7).
119    pub truncated: usize,
120    /// Requests that failed for any other reason.
121    pub errors: usize,
122    /// Σ generated tokens over the requests in the numerator.
123    pub tokens_total: u64,
124    /// The denominator actually used, in seconds. Present so a reader can
125    /// re-derive `agg_tok_s` without the samples.
126    pub span_s: f64,
127}
128
129/// §4.4.3 — linear-interpolated percentile of an ascending slice.
130///
131/// Defined once, in [`super::drain`], and re-exported here so the §4.4.3 metric
132/// code keeps its `metrics::percentile` path. The two modules shipped
133/// byte-identical copies on their respective branches; one of them had to go.
134pub use super::drain::percentile;
135
136fn sorted(mut v: Vec<f64>) -> Vec<f64> {
137    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
138    v
139}
140
141fn median(values: Vec<f64>) -> f64 {
142    percentile(&sorted(values), 0.50).unwrap_or(0.0)
143}
144
145/// §4.4.3 `agg_tok_s`, exactly as specified.
146///
147/// Numerator: generated tokens over completed, non-truncated samples.
148/// Denominator: last completion minus first request start, where "first request
149/// start" is over **every** sampled request (§4.4.7) — including ones that
150/// timed out, because they occupied the server for that time.
151///
152/// Returns `0.0` when the span is non-positive; a band with no elapsed time
153/// produced no evidence and must not read as infinite throughput.
154#[must_use]
155pub fn agg_tok_s(samples: &[RequestSample]) -> f64 {
156    let (tokens, span) = aggregate_terms(samples);
157    if span <= 0.0 {
158        return 0.0;
159    }
160    tokens as f64 / span
161}
162
163/// The numerator and denominator of [`agg_tok_s`], separately, so a receipt can
164/// carry both and a reader can check the division.
165#[must_use]
166pub fn aggregate_terms(samples: &[RequestSample]) -> (u64, f64) {
167    if samples.is_empty() {
168        return (0, 0.0);
169    }
170    let tokens: u64 = samples
171        .iter()
172        .filter(|s| s.counts_toward_aggregate())
173        .map(|s| u64::from(s.generated_tokens))
174        .sum();
175    let first_start = samples
176        .iter()
177        .map(|s| s.start_s)
178        .fold(f64::INFINITY, f64::min);
179    let last_end = samples
180        .iter()
181        .filter(|s| s.counts_toward_aggregate())
182        .map(|s| s.end_s)
183        .fold(f64::NEG_INFINITY, f64::max);
184    if !first_start.is_finite() || !last_end.is_finite() {
185        return (tokens, 0.0);
186    }
187    (tokens, last_end - first_start)
188}
189
190/// The arithmetic mean of per-request token rates.
191///
192/// **This is not `agg_tok_s` and must never be reported as it.** It exists so
193/// the test `agg_tok_s_is_wall_clock_not_the_mean_of_rates` can assert the two
194/// differ on a fixture with hand-computed values. Under a
195/// serialising server with idle gaps between requests, this number can be many
196/// times the true aggregate.
197#[must_use]
198pub fn mean_of_rates(samples: &[RequestSample]) -> f64 {
199    let rates: Vec<f64> = samples
200        .iter()
201        .filter(|s| s.counts_toward_aggregate())
202        .filter_map(|s| {
203            let dur = s.end_s - s.start_s;
204            if dur > 0.0 {
205                Some(f64::from(s.generated_tokens) / dur)
206            } else {
207                None
208            }
209        })
210        .collect();
211    if rates.is_empty() {
212        return 0.0;
213    }
214    rates.iter().sum::<f64>() / rates.len() as f64
215}
216
217impl BandMetrics {
218    /// Compute every §4.4.3 metric from one band's retained samples.
219    #[must_use]
220    pub fn from_samples(concurrency: usize, samples: &[RequestSample]) -> Self {
221        let (tokens_total, span_s) = aggregate_terms(samples);
222        let agg = if span_s > 0.0 {
223            tokens_total as f64 / span_s
224        } else {
225            0.0
226        };
227
228        let ttfts = sorted(samples.iter().filter_map(RequestSample::ttft_ms).collect());
229        let itls = sorted(
230            samples
231                .iter()
232                .flat_map(RequestSample::itl_gaps_ms)
233                .collect::<Vec<f64>>(),
234        );
235        let decodes: Vec<f64> = samples
236            .iter()
237            .filter_map(RequestSample::decode_tok_s)
238            .collect();
239
240        let count = |o: Outcome| samples.iter().filter(|s| s.outcome == o).count();
241
242        Self {
243            concurrency,
244            agg_tok_s: agg,
245            decode_tok_s: median(decodes),
246            ttft_p50_ms: percentile(&ttfts, 0.50).unwrap_or(0.0),
247            ttft_p95_ms: percentile(&ttfts, 0.95).unwrap_or(0.0),
248            itl_p50_ms: percentile(&itls, 0.50).unwrap_or(0.0),
249            itl_p95_ms: percentile(&itls, 0.95).unwrap_or(0.0),
250            requested: samples.len(),
251            completed: count(Outcome::Completed),
252            timeouts: count(Outcome::Timeout),
253            truncated: count(Outcome::AbandonedAtDrain),
254            errors: count(Outcome::Failed),
255            tokens_total,
256            span_s,
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    /// Build a sample whose token arrivals are evenly spaced across
266    /// `[start, end]`, so `decode_tok_s` and `itl` are hand-computable.
267    fn sample(index: usize, start_s: f64, end_s: f64, tokens: u32) -> RequestSample {
268        let n = tokens as usize;
269        let token_times_s = if n == 0 {
270            Vec::new()
271        } else {
272            let step = (end_s - start_s) / n as f64;
273            (1..=n).map(|i| start_s + step * i as f64).collect()
274        };
275        RequestSample {
276            index,
277            worker: 0,
278            start_s,
279            end_s,
280            token_times_s,
281            generated_tokens: tokens,
282            prompt_tokens: 0,
283            outcome: Outcome::Completed,
284            in_flight_at_start: 1,
285            drained: false,
286        }
287    }
288
289    /// THE fixture this ticket exists for.
290    ///
291    /// Four requests, 100 tokens each, run two-at-a-time:
292    ///   r0 [0,1]  r1 [0,2]  r2 [1,3]  r3 [2,4]
293    /// Wall-clock span = 4.0 s, tokens = 400  =>  agg_tok_s = 100.0
294    /// Per-request rates = 100, 50, 50, 50    =>  mean       =  62.5
295    #[test]
296    fn agg_tok_s_is_wall_clock_not_the_mean_of_rates() {
297        let s = vec![
298            sample(0, 0.0, 1.0, 100),
299            sample(1, 0.0, 2.0, 100),
300            sample(2, 1.0, 3.0, 100),
301            sample(3, 2.0, 4.0, 100),
302        ];
303        let (tokens, span) = aggregate_terms(&s);
304        assert_eq!(tokens, 400);
305        assert!((span - 4.0).abs() < 1e-12, "span={span}");
306
307        let agg = agg_tok_s(&s);
308        let mean = mean_of_rates(&s);
309        assert!((agg - 100.0).abs() < 1e-9, "agg={agg}, want 100.0");
310        assert!((mean - 62.5).abs() < 1e-9, "mean={mean}, want 62.5");
311        assert!(
312            (agg - mean).abs() > 1.0,
313            "agg {agg} and mean-of-rates {mean} must not coincide"
314        );
315    }
316
317    /// The dangerous direction: a serialising server with idle gaps. The mean of
318    /// per-request rates reports a throughput the machine never delivered; the
319    /// assertions below pin both figures, so this comment does not restate them.
320    /// This is the shape of the number the epic exists to refuse.
321    #[test]
322    fn mean_of_rates_overstates_a_serialising_server() {
323        let s = vec![
324            sample(0, 0.0, 1.0, 100),
325            sample(1, 2.0, 3.0, 100),
326            sample(2, 4.0, 5.0, 100),
327            sample(3, 6.0, 7.0, 100),
328        ];
329        let agg = agg_tok_s(&s);
330        let mean = mean_of_rates(&s);
331        assert!((agg - 400.0 / 7.0).abs() < 1e-9, "agg={agg}");
332        assert!((mean - 100.0).abs() < 1e-9, "mean={mean}");
333        assert!(mean > agg * 1.7, "mean {mean} must overstate agg {agg}");
334    }
335
336    /// §4.4.3: the numerator counts only completed, non-truncated requests, but
337    /// the denominator starts at the FIRST request start whatever its outcome.
338    #[test]
339    fn timeouts_lengthen_the_span_but_add_no_tokens() {
340        let mut timed_out = sample(0, 0.0, 1.0, 100);
341        timed_out.outcome = Outcome::Timeout;
342        timed_out.generated_tokens = 100; // partial output must not be credited
343        let s = vec![timed_out, sample(1, 0.5, 2.5, 100)];
344
345        let (tokens, span) = aggregate_terms(&s);
346        assert_eq!(tokens, 100, "a timed-out request contributes no tokens");
347        assert!(
348            (span - 2.5).abs() < 1e-12,
349            "span must start at 0.0, got {span}"
350        );
351
352        let m = BandMetrics::from_samples(2, &s);
353        assert_eq!(m.requested, 2);
354        assert_eq!(m.completed, 1);
355        assert_eq!(m.timeouts, 1);
356        assert!((m.agg_tok_s - 40.0).abs() < 1e-9, "{}", m.agg_tok_s);
357    }
358
359    #[test]
360    fn decode_tok_s_is_the_median_of_per_request_rates() {
361        // Token arrivals evenly spaced: 100 tokens over a 1.0 s request means
362        // step 0.01 s, first at 0.01, last at 1.00 -> span 0.99, rate 99/0.99 = 100.
363        let one = sample(0, 0.0, 1.0, 100);
364        assert!((one.decode_tok_s().expect("two+ tokens") - 100.0).abs() < 1e-9);
365
366        // Three requests at 100, 50, 25 tok/s -> median 50.
367        let s = vec![
368            sample(0, 0.0, 1.0, 100),
369            sample(1, 0.0, 2.0, 100),
370            sample(2, 0.0, 4.0, 100),
371        ];
372        let m = BandMetrics::from_samples(1, &s);
373        assert!((m.decode_tok_s - 50.0).abs() < 1e-9, "{}", m.decode_tok_s);
374    }
375
376    #[test]
377    fn single_token_request_has_no_decode_rate_and_no_gaps() {
378        let s = sample(0, 0.0, 1.0, 1);
379        assert_eq!(s.decode_tok_s(), None);
380        assert!(s.itl_gaps_ms().is_empty());
381        assert!(s.ttft_ms().is_some(), "one token still has a TTFT");
382    }
383
384    #[test]
385    fn ttft_is_start_to_first_token() {
386        let s = sample(0, 10.0, 11.0, 4); // step 0.25 -> first at 10.25
387        assert!((s.ttft_ms().expect("has tokens") - 250.0).abs() < 1e-9);
388    }
389
390    #[test]
391    fn itl_gaps_are_pooled_across_requests() {
392        // r0: 4 tokens over 1.0 s -> 3 gaps of 250 ms
393        // r1: 3 tokens over 3.0 s -> 2 gaps of 1000 ms
394        let s = vec![sample(0, 0.0, 1.0, 4), sample(1, 0.0, 3.0, 3)];
395        let pooled: Vec<f64> = s.iter().flat_map(RequestSample::itl_gaps_ms).collect();
396        assert_eq!(
397            pooled.len(),
398            5,
399            "3 + 2 gaps pooled, not 2 per-request means"
400        );
401        let m = BandMetrics::from_samples(2, &s);
402        // sorted: 250,250,250,1000,1000 -> p50 = 250
403        assert!((m.itl_p50_ms - 250.0).abs() < 1e-9, "{}", m.itl_p50_ms);
404        assert!(m.itl_p95_ms > 900.0, "{}", m.itl_p95_ms);
405    }
406
407    #[test]
408    fn percentile_of_nothing_is_none_not_zero() {
409        assert_eq!(percentile(&[], 0.5), None);
410        assert_eq!(percentile(&[7.0], 0.95), Some(7.0));
411    }
412
413    #[test]
414    fn percentile_interpolates_between_order_statistics() {
415        let v = vec![0.0, 10.0, 20.0, 30.0];
416        assert_eq!(percentile(&v, 0.0), Some(0.0));
417        assert_eq!(percentile(&v, 1.0), Some(30.0));
418        assert_eq!(percentile(&v, 0.5), Some(15.0));
419    }
420
421    #[test]
422    fn empty_band_is_zero_not_infinite() {
423        let m = BandMetrics::from_samples(4, &[]);
424        assert_eq!(m.agg_tok_s, 0.0);
425        assert_eq!(m.requested, 0);
426        assert_eq!(m.span_s, 0.0);
427    }
428
429    #[test]
430    fn samples_round_trip_as_jsonl_rows() {
431        let s = sample(3, 1.5, 2.5, 8);
432        let line = serde_json::to_string(&s).expect("serialize");
433        let back: RequestSample = serde_json::from_str(&line).expect("deserialize");
434        assert_eq!(back, s);
435    }
436}