use ruvector_matryoshka::{
brute_force_knn, dataset::generate_matryoshka_dataset, recall_at_k, FullDimIndex,
MatryoshkaConfig, Searcher, ThreeStageIndex, TwoStageIndex,
};
use std::time::Instant;
struct Args {
n: usize,
n_queries: usize,
full_dim: usize,
k: usize,
ef: usize,
seed: u64,
}
impl Args {
fn parse() -> Self {
let mut a = Args {
n: 3000,
n_queries: 200,
full_dim: 128,
k: 10,
ef: 64,
seed: 42,
};
let raw: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < raw.len() {
match raw[i].as_str() {
"--n" => {
a.n = raw[i + 1].parse().unwrap();
i += 2;
}
"--queries" => {
a.n_queries = raw[i + 1].parse().unwrap();
i += 2;
}
"--dim" => {
a.full_dim = raw[i + 1].parse().unwrap();
i += 2;
}
"--k" => {
a.k = raw[i + 1].parse().unwrap();
i += 2;
}
"--ef" => {
a.ef = raw[i + 1].parse().unwrap();
i += 2;
}
"--seed" => {
a.seed = raw[i + 1].parse().unwrap();
i += 2;
}
_ => {
i += 1;
}
}
}
a
}
}
fn percentile(sorted: &[u64], p: f64) -> u64 {
if sorted.is_empty() {
return 0;
}
let idx = ((p / 100.0) * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
struct Stats {
mean_us: f64,
p50_us: u64,
p95_us: u64,
qps: f64,
recall: f32,
}
fn run_variant<S: Searcher>(
cfg: &MatryoshkaConfig,
vectors: &[Vec<f32>],
queries: &[Vec<f32>],
ground_truth: &[Vec<usize>],
k: usize,
ef: usize,
) -> (Stats, S) {
let idx = S::build(cfg, vectors);
let mut latencies_ns: Vec<u64> = Vec::with_capacity(queries.len());
let mut total_recall = 0.0f32;
for (qi, q) in queries.iter().enumerate() {
let t0 = Instant::now();
let result = idx.search(q, k, ef);
let elapsed = t0.elapsed().as_nanos() as u64;
latencies_ns.push(elapsed);
total_recall += recall_at_k(&result, &ground_truth[qi]);
}
latencies_ns.sort_unstable();
let mean_ns = latencies_ns.iter().sum::<u64>() as f64 / latencies_ns.len() as f64;
let p50 = percentile(&latencies_ns, 50.0);
let p95 = percentile(&latencies_ns, 95.0);
let total_elapsed_s = latencies_ns.iter().sum::<u64>() as f64 / 1e9;
let qps = queries.len() as f64 / total_elapsed_s;
let stats = Stats {
mean_us: mean_ns / 1000.0,
p50_us: p50 / 1000,
p95_us: p95 / 1000,
qps,
recall: total_recall / queries.len() as f32,
};
(stats, idx)
}
fn hnsw_memory_kb(n: usize, dim: usize, m: usize) -> usize {
let vec_bytes = n * dim * 4;
let graph_bytes = n * m * 2 * 4;
(vec_bytes + graph_bytes) / 1024
}
fn two_stage_memory_kb(n: usize, full_dim: usize, coarse_dim: usize, m: usize) -> usize {
let coarse_kb = hnsw_memory_kb(n, coarse_dim, m);
let full_vec_kb = n * full_dim * 4 / 1024;
coarse_kb + full_vec_kb
}
fn three_stage_memory_kb(
n: usize,
full_dim: usize,
mid_dim: usize,
coarse_dim: usize,
m: usize,
) -> usize {
let coarse_kb = hnsw_memory_kb(n, coarse_dim, m);
let mid_vec_kb = n * mid_dim * 4 / 1024;
let full_vec_kb = n * full_dim * 4 / 1024;
coarse_kb + mid_vec_kb + full_vec_kb
}
fn main() {
let args = Args::parse();
println!("═══════════════════════════════════════════════════════════");
println!(" RuVector Matryoshka Coarse-to-Fine Benchmark");
println!("═══════════════════════════════════════════════════════════");
println!(" OS: {}", std::env::consts::OS);
println!(" Arch: {}", std::env::consts::ARCH);
println!(" Rust: {}", rustc_version());
println!(" Dataset: {} vectors × {} dims", args.n, args.full_dim);
println!(" Queries: {}", args.n_queries);
println!(" k: {}", args.k);
println!(" ef search: {}", args.ef);
println!(" Seed: {}", args.seed);
println!();
let cfg = MatryoshkaConfig {
full_dim: args.full_dim,
coarse_dim: (args.full_dim / 4).max(8),
mid_dim: (args.full_dim / 2).max(16),
m: 16,
ef_construction: 100,
two_stage_candidates: (args.k * 10).max(50),
three_stage_coarse_candidates: (args.k * 15).max(75),
three_stage_mid_candidates: (args.k * 5).max(25),
};
println!(
" Coarse dim: {} Mid dim: {} Full dim: {}",
cfg.coarse_dim, cfg.mid_dim, cfg.full_dim
);
println!(" TwoStage candidates: {}", cfg.two_stage_candidates);
println!(
" ThreeStage candidates: {}/{}",
cfg.three_stage_coarse_candidates, cfg.three_stage_mid_candidates
);
println!();
println!("Generating Matryoshka-structured dataset …");
let t0 = Instant::now();
let (vectors, queries) = generate_matryoshka_dataset(
args.n,
args.n_queries,
args.full_dim,
cfg.coarse_dim,
args.seed,
);
println!(" done in {:.1} ms", t0.elapsed().as_millis());
println!("Computing brute-force ground truth …");
let t0 = Instant::now();
let ground_truth: Vec<Vec<usize>> = queries
.iter()
.map(|q| brute_force_knn(&vectors, q, args.k, args.full_dim))
.collect();
println!(" done in {:.1} ms", t0.elapsed().as_millis());
println!();
println!("Building FullDimHNSW …");
let t0 = Instant::now();
let (s1, _idx1) =
run_variant::<FullDimIndex>(&cfg, &vectors, &queries, &ground_truth, args.k, args.ef);
println!(" done in {:.1} ms", t0.elapsed().as_millis());
let m1_kb = hnsw_memory_kb(args.n, args.full_dim, cfg.m);
println!("Building TwoStageIndex …");
let t0 = Instant::now();
let (s2, _idx2) =
run_variant::<TwoStageIndex>(&cfg, &vectors, &queries, &ground_truth, args.k, args.ef);
println!(" done in {:.1} ms", t0.elapsed().as_millis());
let m2_kb = two_stage_memory_kb(args.n, args.full_dim, cfg.coarse_dim, cfg.m);
println!("Building ThreeStageIndex …");
let t0 = Instant::now();
let (s3, _idx3) =
run_variant::<ThreeStageIndex>(&cfg, &vectors, &queries, &ground_truth, args.k, args.ef);
println!(" done in {:.1} ms", t0.elapsed().as_millis());
let m3_kb = three_stage_memory_kb(args.n, args.full_dim, cfg.mid_dim, cfg.coarse_dim, cfg.m);
println!();
println!("─────────────────────────────────────────────────────────────────────────────────");
println!(
"{:<16} {:>12} {:>10} {:>10} {:>10} {:>12} {:>10}",
"Variant", "Recall@k", "Mean(μs)", "p50(μs)", "p95(μs)", "QPS", "Mem(KB)"
);
println!("─────────────────────────────────────────────────────────────────────────────────");
print_row("FullDimHNSW", &s1, m1_kb);
print_row("TwoStage", &s2, m2_kb);
print_row("ThreeStage", &s3, m3_kb);
println!("─────────────────────────────────────────────────────────────────────────────────");
println!();
println!("Acceptance tests:");
let full_pass = s1.recall >= 0.80;
let two_pass = s2.recall >= 0.75;
let three_pass = s3.recall >= 0.70;
let latency_ratio = if s1.mean_us > 0.0 {
s2.mean_us / s1.mean_us
} else {
99.0
};
println!(
" [{}] FullDimHNSW recall@{} = {:.3} (threshold ≥ 0.80)",
if full_pass { "PASS" } else { "FAIL" },
args.k,
s1.recall
);
println!(
" [{}] TwoStage recall@{} = {:.3} (threshold ≥ 0.75)",
if two_pass { "PASS" } else { "FAIL" },
args.k,
s2.recall
);
println!(
" [{}] ThreeStage recall@{} = {:.3} (threshold ≥ 0.70)",
if three_pass { "PASS" } else { "FAIL" },
args.k,
s3.recall
);
println!(
" [INFO] TwoStage latency ratio vs FullDim = {:.2}x",
latency_ratio
);
let dim_ratio = cfg.coarse_dim as f32 / cfg.full_dim as f32;
println!(
" [INFO] Coarse-dim reduction = {:.0}% of full dim",
dim_ratio * 100.0
);
println!();
let all_pass = full_pass && two_pass && three_pass;
if all_pass {
println!("RESULT: ALL ACCEPTANCE TESTS PASSED");
} else {
println!("RESULT: SOME ACCEPTANCE TESTS FAILED — see rows above");
std::process::exit(1);
}
}
fn print_row(name: &str, s: &Stats, mem_kb: usize) {
println!(
"{:<16} {:>12.3} {:>10.1} {:>10} {:>10} {:>12.0} {:>10}",
name, s.recall, s.mean_us, s.p50_us, s.p95_us, s.qps, mem_kb
);
}
fn rustc_version() -> String {
std::process::Command::new("rustc")
.arg("--version")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}