Skip to main content

ipfrs_semantic/
benchmark_comparison.rs

1//! Benchmark comparison utilities for evaluating different configurations
2//!
3//! This module provides tools for systematically comparing different index
4//! configurations, quantization strategies, and parameter settings.
5//!
6//! # Features
7//!
8//! - **Configuration Comparison**: Compare multiple index configurations
9//! - **Parameter Sweeps**: Systematically test parameter ranges
10//! - **Trade-off Analysis**: Analyze recall vs latency vs memory trade-offs
11//! - **Recommendation Engine**: Suggest optimal configurations for use cases
12//!
13//! # Example
14//!
15//! ```rust
16//! use ipfrs_semantic::benchmark_comparison::{BenchmarkSuite, IndexConfig};
17//!
18//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
19//! let mut suite = BenchmarkSuite::new();
20//!
21//! // Add configurations to compare
22//! suite.add_config("low_latency", IndexConfig::low_latency())?;
23//! suite.add_config("high_recall", IndexConfig::high_recall())?;
24//! suite.add_config("balanced", IndexConfig::balanced())?;
25//!
26//! // Run benchmarks (in real usage)
27//! // let results = suite.run_benchmarks(test_data)?;
28//! // let report = suite.generate_report(&results)?;
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::hnsw::{DistanceMetric, VectorIndex};
34use ipfrs_core::{Cid, Result};
35use std::collections::HashMap;
36use std::time::{Duration, Instant};
37
38/// Index configuration for benchmarking
39#[derive(Debug, Clone)]
40pub struct IndexConfig {
41    /// Configuration name
42    pub name: String,
43    /// Vector dimension
44    pub dimension: usize,
45    /// Distance metric
46    pub metric: DistanceMetric,
47    /// HNSW M parameter
48    pub m: usize,
49    /// HNSW ef_construction parameter
50    pub ef_construction: usize,
51    /// Search ef parameter
52    pub ef_search: usize,
53    /// Whether to use quantization
54    pub use_quantization: bool,
55    /// Description
56    pub description: String,
57}
58
59impl IndexConfig {
60    /// Configuration optimized for low latency
61    pub fn low_latency() -> Self {
62        Self {
63            name: "low_latency".to_string(),
64            dimension: 768,
65            metric: DistanceMetric::Cosine,
66            m: 8,
67            ef_construction: 100,
68            ef_search: 16,
69            use_quantization: false,
70            description: "Optimized for minimal query latency".to_string(),
71        }
72    }
73
74    /// Configuration optimized for high recall
75    pub fn high_recall() -> Self {
76        Self {
77            name: "high_recall".to_string(),
78            dimension: 768,
79            metric: DistanceMetric::Cosine,
80            m: 32,
81            ef_construction: 400,
82            ef_search: 128,
83            use_quantization: false,
84            description: "Optimized for maximum search accuracy".to_string(),
85        }
86    }
87
88    /// Balanced configuration
89    pub fn balanced() -> Self {
90        Self {
91            name: "balanced".to_string(),
92            dimension: 768,
93            metric: DistanceMetric::Cosine,
94            m: 16,
95            ef_construction: 200,
96            ef_search: 50,
97            use_quantization: false,
98            description: "Balanced latency and recall".to_string(),
99        }
100    }
101
102    /// Memory-efficient configuration with quantization
103    pub fn memory_efficient() -> Self {
104        Self {
105            name: "memory_efficient".to_string(),
106            dimension: 768,
107            metric: DistanceMetric::Cosine,
108            m: 12,
109            ef_construction: 150,
110            ef_search: 32,
111            use_quantization: true,
112            description: "Minimizes memory usage with quantization".to_string(),
113        }
114    }
115}
116
117/// Benchmark results for a single configuration
118#[derive(Debug, Clone)]
119pub struct BenchmarkResult {
120    /// Configuration name
121    pub config_name: String,
122    /// Average query latency
123    pub avg_latency: Duration,
124    /// P50 latency
125    pub p50_latency: Duration,
126    /// P90 latency
127    pub p90_latency: Duration,
128    /// P99 latency
129    pub p99_latency: Duration,
130    /// Recall@10
131    pub recall_at_10: f64,
132    /// Recall@100
133    pub recall_at_100: f64,
134    /// Queries per second
135    pub qps: f64,
136    /// Memory usage in MB
137    pub memory_mb: f64,
138    /// Index build time
139    pub build_time: Duration,
140}
141
142/// Comparison report
143#[derive(Debug, Clone)]
144pub struct ComparisonReport {
145    /// Results for each configuration
146    pub results: Vec<BenchmarkResult>,
147    /// Best configuration for latency
148    pub best_latency: String,
149    /// Best configuration for recall
150    pub best_recall: String,
151    /// Best configuration for memory
152    pub best_memory: String,
153    /// Recommendations
154    pub recommendations: Vec<String>,
155}
156
157/// Benchmark suite for comparing configurations
158pub struct BenchmarkSuite {
159    /// Configurations to test
160    configs: HashMap<String, IndexConfig>,
161}
162
163impl BenchmarkSuite {
164    /// Create a new benchmark suite
165    pub fn new() -> Self {
166        Self {
167            configs: HashMap::new(),
168        }
169    }
170
171    /// Add a configuration to test
172    pub fn add_config(&mut self, name: &str, config: IndexConfig) -> Result<()> {
173        self.configs.insert(name.to_string(), config);
174        Ok(())
175    }
176
177    /// Run benchmarks on test data
178    pub fn run_benchmarks(
179        &self,
180        test_data: &[(Cid, Vec<f32>)],
181        query_data: &[Vec<f32>],
182    ) -> Result<Vec<BenchmarkResult>> {
183        let mut results = Vec::new();
184
185        for config in self.configs.values() {
186            let result = self.benchmark_config(config, test_data, query_data)?;
187            results.push(result);
188        }
189
190        Ok(results)
191    }
192
193    /// Benchmark a single configuration
194    fn benchmark_config(
195        &self,
196        config: &IndexConfig,
197        test_data: &[(Cid, Vec<f32>)],
198        query_data: &[Vec<f32>],
199    ) -> Result<BenchmarkResult> {
200        // Build index
201        let build_start = Instant::now();
202        let mut index = VectorIndex::new(
203            config.dimension,
204            config.metric,
205            config.m,
206            config.ef_construction,
207        )?;
208
209        for (cid, embedding) in test_data {
210            index.insert(cid, embedding)?;
211        }
212        let build_time = build_start.elapsed();
213
214        // Measure query latencies
215        let mut latencies = Vec::new();
216        let query_start = Instant::now();
217
218        for query in query_data {
219            let start = Instant::now();
220            let _results = index.search(query, 10, config.ef_search)?;
221            latencies.push(start.elapsed());
222        }
223
224        let total_query_time = query_start.elapsed();
225        let qps = query_data.len() as f64 / total_query_time.as_secs_f64();
226
227        // Calculate latency percentiles
228        latencies.sort();
229        let avg_latency = latencies.iter().sum::<Duration>() / latencies.len() as u32;
230        let p50_latency = latencies[latencies.len() / 2];
231        let p90_latency = latencies[(latencies.len() as f64 * 0.9) as usize];
232        let p99_latency = latencies[(latencies.len() as f64 * 0.99) as usize];
233
234        // Calculate recall (would need ground truth in real implementation)
235        let recall_at_10 = 0.95; // Placeholder
236        let recall_at_100 = 0.99; // Placeholder
237
238        // Estimate memory usage
239        let memory_mb = self.estimate_memory(&index);
240
241        Ok(BenchmarkResult {
242            config_name: config.name.clone(),
243            avg_latency,
244            p50_latency,
245            p90_latency,
246            p99_latency,
247            recall_at_10,
248            recall_at_100,
249            qps,
250            memory_mb,
251            build_time,
252        })
253    }
254
255    /// Estimate memory usage for an index
256    fn estimate_memory(&self, index: &VectorIndex) -> f64 {
257        // Rough estimation: entries * dimension * 4 bytes per float
258        let entries = index.len();
259        let bytes_per_entry = 768 * 4 + 64; // embedding + overhead
260        (entries * bytes_per_entry) as f64 / (1024.0 * 1024.0)
261    }
262
263    /// Generate comparison report
264    pub fn generate_report(&self, results: &[BenchmarkResult]) -> Result<ComparisonReport> {
265        if results.is_empty() {
266            return Err(ipfrs_core::Error::InvalidInput(
267                "No results to compare".into(),
268            ));
269        }
270
271        // Find best configurations
272        let best_latency = results
273            .iter()
274            .min_by_key(|r| r.avg_latency)
275            .map(|r| r.config_name.clone())
276            .expect("results is non-empty");
277
278        let best_recall = results
279            .iter()
280            .max_by(|a, b| {
281                a.recall_at_10
282                    .partial_cmp(&b.recall_at_10)
283                    .unwrap_or(std::cmp::Ordering::Equal)
284            })
285            .map(|r| r.config_name.clone())
286            .expect("results is non-empty");
287
288        let best_memory = results
289            .iter()
290            .min_by(|a, b| {
291                a.memory_mb
292                    .partial_cmp(&b.memory_mb)
293                    .unwrap_or(std::cmp::Ordering::Equal)
294            })
295            .map(|r| r.config_name.clone())
296            .expect("results is non-empty");
297
298        // Generate recommendations
299        let mut recommendations = Vec::new();
300        recommendations.push(format!(
301            "For lowest latency: {} ({:.2}ms avg)",
302            best_latency,
303            results
304                .iter()
305                .find(|r| r.config_name == best_latency)
306                .expect("best_latency comes from results iterator")
307                .avg_latency
308                .as_micros() as f64
309                / 1000.0
310        ));
311
312        recommendations.push(format!(
313            "For highest recall: {} ({:.2}% recall@10)",
314            best_recall,
315            results
316                .iter()
317                .find(|r| r.config_name == best_recall)
318                .expect("best_recall comes from results iterator")
319                .recall_at_10
320                * 100.0
321        ));
322
323        recommendations.push(format!(
324            "For lowest memory: {} ({:.2}MB)",
325            best_memory,
326            results
327                .iter()
328                .find(|r| r.config_name == best_memory)
329                .expect("best_memory comes from results iterator")
330                .memory_mb
331        ));
332
333        Ok(ComparisonReport {
334            results: results.to_vec(),
335            best_latency,
336            best_recall,
337            best_memory,
338            recommendations,
339        })
340    }
341
342    /// Print a formatted comparison table
343    pub fn print_comparison(&self, report: &ComparisonReport) {
344        println!("\n=== Benchmark Comparison Report ===\n");
345        println!(
346            "{:<20} {:>10} {:>10} {:>10} {:>10} {:>10}",
347            "Config", "Avg(ms)", "P99(ms)", "Recall@10", "QPS", "Memory(MB)"
348        );
349        println!("{:-<80}", "");
350
351        for result in &report.results {
352            println!(
353                "{:<20} {:>10.2} {:>10.2} {:>10.2} {:>10.0} {:>10.2}",
354                result.config_name,
355                result.avg_latency.as_micros() as f64 / 1000.0,
356                result.p99_latency.as_micros() as f64 / 1000.0,
357                result.recall_at_10 * 100.0,
358                result.qps,
359                result.memory_mb
360            );
361        }
362
363        println!("\n=== Recommendations ===\n");
364        for rec in &report.recommendations {
365            println!("  • {}", rec);
366        }
367        println!();
368    }
369}
370
371impl Default for BenchmarkSuite {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377/// Parameter sweep utility for systematic testing
378pub struct ParameterSweep {
379    /// Base configuration
380    base_config: IndexConfig,
381    /// Parameter to sweep
382    parameter: String,
383    /// Values to test
384    values: Vec<usize>,
385}
386
387impl ParameterSweep {
388    /// Create a new parameter sweep
389    pub fn new(base_config: IndexConfig, parameter: String, values: Vec<usize>) -> Self {
390        Self {
391            base_config,
392            parameter,
393            values,
394        }
395    }
396
397    /// Generate configurations for sweep
398    pub fn generate_configs(&self) -> Vec<IndexConfig> {
399        self.values
400            .iter()
401            .map(|&value| {
402                let mut config = self.base_config.clone();
403                config.name = format!("{}_{}", self.parameter, value);
404
405                match self.parameter.as_str() {
406                    "m" => config.m = value,
407                    "ef_construction" => config.ef_construction = value,
408                    "ef_search" => config.ef_search = value,
409                    _ => {}
410                }
411
412                config
413            })
414            .collect()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_index_config_presets() {
424        let low_lat = IndexConfig::low_latency();
425        assert_eq!(low_lat.name, "low_latency");
426        assert_eq!(low_lat.m, 8);
427
428        let high_rec = IndexConfig::high_recall();
429        assert_eq!(high_rec.name, "high_recall");
430        assert_eq!(high_rec.m, 32);
431
432        let balanced = IndexConfig::balanced();
433        assert_eq!(balanced.name, "balanced");
434        assert_eq!(balanced.m, 16);
435
436        let mem_eff = IndexConfig::memory_efficient();
437        assert_eq!(mem_eff.name, "memory_efficient");
438        assert!(mem_eff.use_quantization);
439    }
440
441    #[test]
442    fn test_benchmark_suite_creation() {
443        let suite = BenchmarkSuite::new();
444        assert_eq!(suite.configs.len(), 0);
445    }
446
447    #[test]
448    fn test_add_config() {
449        let mut suite = BenchmarkSuite::new();
450        let config = IndexConfig::low_latency();
451
452        suite
453            .add_config("test", config)
454            .expect("test: add_config should succeed");
455        assert_eq!(suite.configs.len(), 1);
456    }
457
458    #[test]
459    fn test_parameter_sweep() {
460        let base = IndexConfig::balanced();
461        let sweep = ParameterSweep::new(base, "m".to_string(), vec![8, 16, 32, 64]);
462
463        let configs = sweep.generate_configs();
464        assert_eq!(configs.len(), 4);
465        assert_eq!(configs[0].m, 8);
466        assert_eq!(configs[1].m, 16);
467        assert_eq!(configs[2].m, 32);
468        assert_eq!(configs[3].m, 64);
469    }
470
471    #[test]
472    fn test_ef_construction_sweep() {
473        let base = IndexConfig::balanced();
474        let sweep = ParameterSweep::new(
475            base,
476            "ef_construction".to_string(),
477            vec![100, 200, 400, 800],
478        );
479
480        let configs = sweep.generate_configs();
481        assert_eq!(configs.len(), 4);
482        assert_eq!(configs[0].ef_construction, 100);
483        assert_eq!(configs[3].ef_construction, 800);
484    }
485
486    #[test]
487    fn test_ef_search_sweep() {
488        let base = IndexConfig::balanced();
489        let sweep = ParameterSweep::new(base, "ef_search".to_string(), vec![16, 32, 64, 128]);
490
491        let configs = sweep.generate_configs();
492        assert_eq!(configs.len(), 4);
493        assert_eq!(configs[0].ef_search, 16);
494        assert_eq!(configs[3].ef_search, 128);
495    }
496}