1use std::time::Instant;
10
11use iqdb_index::IndexCore;
12use iqdb_types::SearchParams;
13
14use crate::error::{EvalError, Result};
15use crate::report::LatencyReport;
16
17#[derive(Debug, Clone, Copy, Default)]
25pub struct LatencyConfig {
26 pub warmup: usize,
31}
32
33pub 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
118fn 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 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
151fn 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 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 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 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 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}