Skip to main content

iqdb_eval/
latency.rs

1//! Per-query latency measurement and percentile reporting.
2//!
3//! [`latency`] runs every query through an already-built
4//! [`IndexCore`](iqdb_index::IndexCore), records a per-query wall-clock
5//! sample via [`std::time::Instant`], and reports the distribution as a
6//! [`LatencyReport`]. Build cost is **not** part of any measurement: the
7//! function takes `&I` (a borrowed, already-built index).
8
9use std::time::Instant;
10
11use iqdb_index::IndexCore;
12use iqdb_types::SearchParams;
13
14use crate::error::{EvalError, Result};
15use crate::report::LatencyReport;
16
17/// Controls how [`latency`] runs its measurement loop.
18///
19/// `warmup` is the number of queries to run before timing begins. Their
20/// timings are discarded. This prevents one-shot first-call costs (cache
21/// misses, page faults, JIT-like internal warm-up in some indexes) from
22/// skewing the percentiles. A `warmup` of `8` is a reasonable default for
23/// a small query set; for SIFT-scale runs a few hundred is typical.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct LatencyConfig {
26    /// The number of queries to run with timing discarded before the
27    /// measured loop begins. The warm-up cycles through `queries` modulo
28    /// `queries.len()` so a single query can warm an index repeatedly.
29    /// Default: `0` (no warm-up).
30    pub warmup: usize,
31}
32
33/// Measure per-query latency for `index` over `queries` and return a
34/// [`LatencyReport`].
35///
36/// Each query is timed with [`Instant::now`] immediately before the call
37/// to [`IndexCore::search`] and [`Instant::elapsed`] immediately after;
38/// the resulting microsecond delta is recorded as one sample. Build cost
39/// is excluded by construction — `index` is borrowed.
40///
41/// Percentiles use the nearest-rank method documented on
42/// [`LatencyReport`]. Single-threaded QPS is computed as
43/// `query_count / sum_of_latencies_seconds` and is therefore comparable
44/// across runs with identical query counts.
45///
46/// # Errors
47///
48/// - [`EvalError::EmptyInput`] when `queries` is empty.
49/// - [`EvalError::DimensionMismatch`] when any query has the wrong dim.
50/// - [`EvalError::Search`] when the index's `search` returns an error.
51///
52/// # Examples
53///
54/// ```
55/// use std::sync::Arc;
56///
57/// use iqdb_eval::{latency, LatencyConfig};
58/// use iqdb_flat::{FlatConfig, FlatIndex};
59/// use iqdb_index::{Index, IndexCore};
60/// use iqdb_types::{DistanceMetric, SearchParams, VectorId};
61///
62/// let mut idx = FlatIndex::new(2, DistanceMetric::Euclidean, FlatConfig)?;
63/// idx.insert(VectorId::from(0u64), Arc::<[f32]>::from(&[0.0, 0.0][..]), None)?;
64/// idx.insert(VectorId::from(1u64), Arc::<[f32]>::from(&[3.0, 4.0][..]), None)?;
65///
66/// let queries = vec![vec![0.0, 0.0], vec![3.0, 4.0]];
67/// let params = SearchParams::new(1, DistanceMetric::Euclidean);
68/// let cfg = LatencyConfig { warmup: 1 };
69///
70/// let report = latency(&idx, &queries, &params, &cfg)?;
71/// assert_eq!(report.query_count, 2);
72/// assert!(report.p50_us <= report.p95_us);
73/// assert!(report.p95_us <= report.p99_us);
74/// # Ok::<(), iqdb_eval::EvalError>(())
75/// ```
76pub fn latency<I: IndexCore>(
77    index: &I,
78    queries: &[Vec<f32>],
79    params: &SearchParams,
80    config: &LatencyConfig,
81) -> Result<LatencyReport> {
82    if queries.is_empty() {
83        return Err(EvalError::EmptyInput { kind: "queries" });
84    }
85    let dim = index.dim();
86    for query in queries {
87        if query.len() != dim {
88            return Err(EvalError::DimensionMismatch {
89                expected: dim,
90                found: query.len(),
91            });
92        }
93    }
94
95    let span = tracing::info_span!(
96        "eval.latency",
97        n_queries = queries.len(),
98        warmup = config.warmup,
99    );
100    let _enter = span.enter();
101
102    for i in 0..config.warmup {
103        let q = &queries[i % queries.len()];
104        let _ = index.search(q, params)?;
105    }
106
107    let mut samples_us: Vec<f64> = Vec::with_capacity(queries.len());
108    for query in queries {
109        let t0 = Instant::now();
110        let _hits = index.search(query, params)?;
111        let elapsed_us = t0.elapsed().as_nanos() as f64 / 1_000.0;
112        samples_us.push(elapsed_us);
113    }
114
115    Ok(summarize(&mut samples_us))
116}
117
118/// Sort `samples` in place and produce a [`LatencyReport`].
119fn summarize(samples: &mut [f64]) -> LatencyReport {
120    samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
121
122    let n = samples.len();
123    let sum: f64 = samples.iter().sum();
124    let mean_us = sum / n as f64;
125    let min_us = samples[0];
126    let max_us = samples[n - 1];
127    let p50_us = samples[percentile_index(0.50, n)];
128    let p95_us = samples[percentile_index(0.95, n)];
129    let p99_us = samples[percentile_index(0.99, n)];
130
131    // QPS = n / total_seconds. `samples` are microseconds.
132    let total_secs = sum / 1_000_000.0;
133    let qps = if total_secs > 0.0 {
134        n as f64 / total_secs
135    } else {
136        f64::INFINITY
137    };
138
139    LatencyReport {
140        query_count: n,
141        mean_us,
142        min_us,
143        max_us,
144        p50_us,
145        p95_us,
146        p99_us,
147        qps,
148    }
149}
150
151/// Nearest-rank index for percentile `p` (in `[0.0, 1.0]`) over a sample
152/// of size `n` (must be `>= 1`).
153///
154/// `idx = clamp(ceil(p × n) − 1, 0, n − 1)`. Returns an observed sample's
155/// position; no interpolation is performed.
156fn percentile_index(p: f64, n: usize) -> usize {
157    debug_assert!(n >= 1, "percentile_index requires n >= 1");
158    let raw = (p * n as f64).ceil() as isize - 1;
159    if raw < 0 {
160        0
161    } else if (raw as usize) >= n {
162        n - 1
163    } else {
164        raw as usize
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    #![allow(clippy::unwrap_used)]
171
172    use super::*;
173
174    #[test]
175    fn percentile_index_typical_n100() {
176        // ceil(0.5*100)-1 = 49, ceil(0.95*100)-1 = 94, ceil(0.99*100)-1 = 98.
177        assert_eq!(percentile_index(0.50, 100), 49);
178        assert_eq!(percentile_index(0.95, 100), 94);
179        assert_eq!(percentile_index(0.99, 100), 98);
180    }
181
182    #[test]
183    fn percentile_index_n1_returns_0() {
184        assert_eq!(percentile_index(0.50, 1), 0);
185        assert_eq!(percentile_index(0.99, 1), 0);
186    }
187
188    #[test]
189    fn percentile_index_n10_p99_clamps_to_last() {
190        // ceil(0.99*10)-1 = ceil(9.9)-1 = 9.
191        assert_eq!(percentile_index(0.99, 10), 9);
192    }
193
194    #[test]
195    fn summarize_orders_percentiles() {
196        let mut samples = vec![5.0, 1.0, 4.0, 2.0, 3.0];
197        let r = summarize(&mut samples);
198        assert_eq!(r.query_count, 5);
199        assert!(r.min_us <= r.p50_us);
200        assert!(r.p50_us <= r.p95_us);
201        assert!(r.p95_us <= r.p99_us);
202        assert!(r.p99_us <= r.max_us);
203        assert_eq!(r.min_us, 1.0);
204        assert_eq!(r.max_us, 5.0);
205    }
206
207    #[test]
208    fn summarize_single_sample_collapses_all_fields() {
209        let mut samples = vec![42.0];
210        let r = summarize(&mut samples);
211        assert_eq!(r.query_count, 1);
212        assert_eq!(r.min_us, 42.0);
213        assert_eq!(r.max_us, 42.0);
214        assert_eq!(r.p50_us, 42.0);
215        assert_eq!(r.p95_us, 42.0);
216        assert_eq!(r.p99_us, 42.0);
217        assert!(r.qps > 0.0);
218    }
219
220    #[test]
221    fn summarize_zero_total_time_yields_infinite_qps() {
222        // A degenerate all-zero sample (e.g. a sub-nanosecond clock) must not
223        // divide by zero — QPS saturates to infinity instead.
224        let mut samples = vec![0.0, 0.0, 0.0];
225        let r = summarize(&mut samples);
226        assert!(r.qps.is_infinite());
227    }
228
229    mod with_index {
230        #![allow(clippy::unwrap_used, clippy::expect_used)]
231
232        use crate::{LatencyConfig, build_index_from_base, latency};
233        use iqdb_flat::{FlatConfig, FlatIndex};
234        use iqdb_types::{DistanceMetric, SearchParams};
235
236        const M: DistanceMetric = DistanceMetric::Euclidean;
237
238        fn index() -> FlatIndex {
239            let base: Vec<Vec<f32>> = vec![vec![0.0], vec![1.0], vec![2.0]];
240            build_index_from_base(FlatConfig, 1, M, &base).unwrap()
241        }
242
243        #[test]
244        fn single_query_reports_one_sample() {
245            let idx = index();
246            let r = latency(
247                &idx,
248                &[vec![0.0]],
249                &SearchParams::new(1, M),
250                &LatencyConfig::default(),
251            )
252            .unwrap();
253            assert_eq!(r.query_count, 1);
254            assert_eq!(r.min_us, r.max_us);
255            assert_eq!(r.p50_us, r.p99_us);
256        }
257
258        #[test]
259        fn warmup_larger_than_query_set_cycles_and_excludes_itself() {
260            let idx = index();
261            // warmup of 10 over 2 queries cycles modulo the set; only the 2
262            // measured queries are counted in query_count.
263            let queries = vec![vec![0.0], vec![2.0]];
264            let cfg = LatencyConfig { warmup: 10 };
265            let r = latency(&idx, &queries, &SearchParams::new(1, M), &cfg).unwrap();
266            assert_eq!(r.query_count, 2);
267        }
268    }
269}