tidaldb/tidal/examples/ann_grid_search.rs
jx12n a0399550d6 feat(m12p6): persist HNSW graph + bounded SIGTERM drain — boot loads, no rebuild
Boot now LOADS the per-slot HNSW graph instead of rebuilding it. Clean
shutdown writes {data_dir}/vector/<kind>__<slot>.usearch; the next open loads
it when it matches the durable corpus (seconds), falling back to a full rebuild
only when the graph is missing/stale/corrupt. Eliminates the multi-minute boot
rebuild (~50-70 min at 1M/1536-D) that let the WAL compact past a restarting
node and triggered the reseed cascade.

Graceful SIGTERM now actually runs the close: bounded_drain caps the post-signal
HTTP drain (TIDAL_SHUTDOWN_DRAIN_MS, default 15s) then runs the deterministic
close regardless — sibling keep-alive connections no longer block the drain past
the k8s 60s grace into a SIGKILL (which cannot run Drop). ClusterNode and
ShardReplica::shutdown are now &self (db handle is an ArcSwapOption) so the close
fires even when a stuck connection task holds an Arc.

Fix USearch insert to be a true upsert (remove+add): it was unconditional add,
which a multi:false index rejects on a reseeding follower's post-snapshot WAL
replay -> applied_events stalls -> catch-up deadlock -> unrecoverable cluster.

Also: circuit-breaker peer last-contact tracking; real k3s 1536-dim deploy +
recall findings (recall@10 0.9869, read p99 8.71ms @ 200rps @ 100k) in
docs/profiling/m12-cluster-deploy-findings.md; new tidal-stress k8s jobs and
m12p6 graph-persistence + SIGTERM tier-3 regression tests.
2026-06-15 13:09:20 -06:00

500 lines
21 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! m12p3 ANN parameter grid search + quantization recall/memory frontier.
//!
//! Builds a `UsearchIndex` (HNSW) and an exact `BruteForceIndex` oracle over the
//! SAME deterministic, id-keyed corpus, then sweeps the HNSW parameters and the
//! quantization level — reporting, for each point, the **measured** recall@k vs
//! the exact oracle, the single-thread mean/p99 search latency, the build time,
//! and the **true** in-memory footprint (`UsearchIndex::memory_usage`, which
//! includes the proximity-graph links, not just the vectors).
//!
//! This is the tool that produces the documented `M`/`ef` and the F32/F16/Int8
//! recall+memory numbers the m12p3 exit gate requires — at the production shape
//! (1536-D). Recall here is a *property of the index given its parameters* and is
//! independent of load, so a deterministic single-thread harness is the right
//! instrument; the authoritative tail-latency-under-load number is the open-loop
//! `tidal-stress --verify-recall` ramp (m12p1).
//!
//! Run (local, 100k/1536-D — the exit-gate shape that fits one laptop):
//! ```bash
//! cargo run --release --example ann_grid_search -- \
//! --corpus 100000 --dim 1536 --queries 200 --k 10
//! ```
//! The 1M shape needs ≈ 6 GB for the F32 oracle (1M × 1536 × 4 B) plus the HNSW;
//! run it on the k3s node with `--corpus 1000000`.
#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
use std::{
collections::HashSet,
time::{Duration, Instant},
};
use tidaldb::storage::vector::{
BruteForceIndex, DistanceMetric, QuantizationLevel, UsearchIndex, VectorId, VectorIndex,
VectorIndexConfig,
};
// ---------------------------------------------------------------------------
// Deterministic corpus (SplitMix64) — reproducible recall ground truth
// ---------------------------------------------------------------------------
/// A deterministic, L2-normalized unit vector for `id` at dimensionality `dim`.
///
/// Same family the m12p1 recall harness uses (`SplitMix64`), so the corpus is
/// reproducible across runs and machines — the recall numbers are comparable.
fn unit_vector(id: u64, dim: usize) -> Vec<f32> {
let mut state = id
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(0x1234_5678_9ABC_DEF0);
let mut next = || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
// Map the high mantissa bits into [-0.5, 0.5).
((z >> 40) as f32 / (1u64 << 24) as f32) - 0.5
};
let mut v: Vec<f32> = (0..dim).map(|_| next()).collect();
normalize(&mut v);
v
}
/// L2-normalize in place (unit vector), falling back to the first axis if zero.
fn normalize(v: &mut [f32]) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > f32::EPSILON {
for x in v.iter_mut() {
*x /= norm;
}
} else if let Some(first) = v.first_mut() {
*first = 1.0;
}
}
/// A clustered (Gaussian-mixture) corpus vector for `id` — the faithful
/// representation of real embedding geometry, and the right instrument for an
/// honest recall@k.
///
/// Uniform-random high-dimensional vectors are PATHOLOGICAL for recall@k: by the
/// concentration of measure, every pair sits at cosine ≈ 0, so beyond a tiny
/// perturbation a query's "top-10" is an arbitrary draw from a thick equidistant
/// shell — recall@10 then measures impossible tie-breaking, not index quality,
/// and (worse) it gets *lower* as the corpus grows because the shell thickens.
/// (Measured: uniform-random recall@10 fell from ~0.97 at 10k to ~0.54 at 100k —
/// a corpus-size artifact, not an index regression.)
///
/// Real text/image embeddings instead live on a low-dimensional manifold with
/// clusters: a point's nearest neighbours are its cluster-mates, distinctly
/// closer than the bulk. We model that as a Gaussian mixture: `id` is assigned to
/// cluster `id % n_clusters`, and the vector is `center + spread · noise`,
/// normalized. With `spread = 0.5`, intra-cluster cosine ≈ 0.8 and inter-cluster
/// ≈ 0 — a clear neighbour structure, exactly the shape `related`/`for_you` reads
/// query against, where recall@k is a meaningful index-quality metric.
fn clustered_vector(centers: &[Vec<f32>], id: u64, dim: usize, spread: f32) -> Vec<f32> {
let center = &centers[(id as usize) % centers.len()];
let noise = unit_vector(id, dim);
let mut v: Vec<f32> = center
.iter()
.zip(&noise)
.map(|(c, nz)| c + spread * nz)
.collect();
normalize(&mut v);
v
}
/// A realistic query: a corpus point (spread across the catalog) perturbed by
/// deterministic noise of magnitude `noise` RELATIVE to the unit base — so the
/// query sits a small, controlled angle off a real corpus point. This is the
/// shape of a production `related`/`for_you` read (a seed/preference vector near
/// real content), and the same realistic model the authoritative m12p1 recall
/// harness (`tidal-stress` `QueryPool`) uses.
///
/// Uniform-random high-dim queries are the WRONG instrument: in 1536-D their
/// neighbours are near-equidistant (distance concentration), so recall looks
/// pathologically low for reasons unrelated to the index.
///
/// The noise is scaled by `1/√(dim/12)` so the additive perturbation has total
/// magnitude ≈ `noise` against the *unit* base (a raw `noise × U[-0.5,0.5]` per
/// component would be ≈ `noise·√(dim/12)` — ~11× too large at 1536-D, which would
/// push the query far off its base and understate recall). The query is NOT
/// re-normalized: the corpus is unit-norm, so L2 order over it equals cosine
/// order even for a non-unit query (`‖c‖=1` ⇒ argmin‖qc‖² = argmax q·c) — the
/// exact metric the engine serves (it normalizes on write, not on query).
fn perturbed_query(corpus: &[Vec<f32>], i: usize, noise: f32) -> Vec<f32> {
let n = corpus.len().max(1) as u64;
let base = ((i as u64).wrapping_mul(2_654_435_761) % n) as usize;
let mut q = corpus[base].clone();
let dim = q.len().max(1);
// Per-component coefficient so ‖noise_vec‖ ≈ noise for a unit base.
let coef = noise / (dim as f32 / 12.0).sqrt();
let mut state = (i as u64)
.wrapping_mul(0x100_0000_01B3)
.wrapping_add(0xABCD);
let mut next = || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
((z >> 40) as f32 / (1u64 << 24) as f32) - 0.5
};
for c in &mut q {
*c += coef * next();
}
q
}
// ---------------------------------------------------------------------------
// Measurement
// ---------------------------------------------------------------------------
/// recall@k of `got` against the exact `truth` id set.
fn recall(truth: &HashSet<VectorId>, got: &[VectorId], k: usize) -> f64 {
let hits = got.iter().filter(|id| truth.contains(id)).count();
hits as f64 / k as f64
}
/// One grid/quant point's measured outcome.
struct Point {
label: String,
recall: f64,
mean_us: f64,
p99_us: f64,
build_s: f64,
mem_mb: f64,
mem_per_1m_gb: f64,
}
/// Build a `UsearchIndex` with the given parameters over `vectors`, returning the
/// built index and the wall-clock build time.
fn build_hnsw(
vectors: &[Vec<f32>],
dim: usize,
quant: QuantizationLevel,
connectivity: usize,
ef_construction: usize,
ef_search: usize,
) -> (UsearchIndex, Duration) {
let index = UsearchIndex::new(VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: quant,
connectivity,
ef_construction,
ef_search,
})
.expect("usearch index construction");
// Parallel build, done CORRECTLY: reserve one writer slot per thread up front
// (`reserve_with_threads`), THEN insert distinct ids concurrently. A plain
// `reserve` allocates a single slot, so concurrent `add` would corrupt the
// graph (recall collapses ~0.95→~0.1) — that was tried and rejected. With the
// per-thread reservation, USearch's own test does exactly this, and recall
// matches a sequential build while the 100k/1536-D build drops from ~10 min to
// well under a minute.
let threads = std::thread::available_parallelism().map_or(8, std::num::NonZeroUsize::get);
index
.reserve_with_threads(vectors.len(), threads)
.expect("reserve");
let start = Instant::now();
let chunk = vectors.len().div_ceil(threads).max(1);
std::thread::scope(|s| {
for (c, slice) in vectors.chunks(chunk).enumerate() {
let index = &index;
let base = c * chunk;
s.spawn(move || {
for (i, v) in slice.iter().enumerate() {
index.insert((base + i) as VectorId, v).expect("insert");
}
});
}
});
(index, start.elapsed())
}
/// Run `queries` against `index` at `ef_search`, scoring recall@k vs `truths`
/// and recording per-query latency. Returns `(avg_recall, mean_us, p99_us)`.
fn measure(
index: &UsearchIndex,
queries: &[Vec<f32>],
truths: &[HashSet<VectorId>],
k: usize,
ef_search: usize,
) -> (f64, f64, f64) {
let mut recalls = 0.0;
let mut lat_us: Vec<f64> = Vec::with_capacity(queries.len());
for (q, truth) in queries.iter().zip(truths) {
let start = Instant::now();
let res = index.search(q, k, ef_search).expect("search");
lat_us.push(start.elapsed().as_secs_f64() * 1e6);
let ids: Vec<VectorId> = res.iter().map(|r| r.id).collect();
recalls += recall(truth, &ids, k);
}
let mean = lat_us.iter().sum::<f64>() / lat_us.len() as f64;
lat_us.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p99 = lat_us[((lat_us.len() as f64 * 0.99) as usize).min(lat_us.len() - 1)];
(recalls / queries.len() as f64, mean, p99)
}
/// Footprint in MB now, and extrapolated to 1M vectors (GB), from the measured
/// `memory_usage` of the built index over `n` vectors.
fn footprint(index: &UsearchIndex, n: usize) -> (f64, f64) {
let bytes = index.memory_usage();
let mb = bytes as f64 / (1024.0 * 1024.0);
let per_1m_gb = (bytes as f64 / n as f64) * 1_000_000.0 / (1024.0 * 1024.0 * 1024.0);
(mb, per_1m_gb)
}
const fn quant_name(q: QuantizationLevel) -> &'static str {
match q {
QuantizationLevel::F32 => "F32",
QuantizationLevel::F16 => "F16",
QuantizationLevel::Int8 => "Int8",
}
}
// ---------------------------------------------------------------------------
// Arg parsing (tiny, dependency-free)
// ---------------------------------------------------------------------------
fn arg(args: &[String], flag: &str, default: usize) -> usize {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
#[allow(clippy::too_many_lines)] // a linear measurement script reads best top-to-bottom
fn main() {
let args: Vec<String> = std::env::args().collect();
let corpus = arg(&args, "--corpus", 20_000);
let dim = arg(&args, "--dim", 1536);
let n_queries = arg(&args, "--queries", 200);
let k = arg(&args, "--k", 10);
// Query perturbation noise (× 1000 on the CLI to keep the arg an integer):
// `--noise-milli 50` ⇒ 0.05 relative — a query a few degrees off a real point.
let noise = arg(&args, "--noise-milli", 50) as f32 / 1000.0;
// Clustered corpus shape (Gaussian mixture — see `clustered_vector`):
// `--clusters` defaults to ~100 points/cluster; `--spread-milli 500` ⇒ 0.5.
let n_clusters = arg(&args, "--clusters", (corpus / 100).max(1));
let spread = arg(&args, "--spread-milli", 500) as f32 / 1000.0;
// `--prod-only 1` restricts the sweep to the PRODUCTION graph (F16, M=16) plus
// the Int8 contrast — i.e. 2 HNSW builds instead of 6. At the 1M/1536-D shape a
// single 1536-D HNSW build is many minutes, so the full 6-graph sweep is ~1h;
// this fast path measures exactly the rows the G1 ef_search decision needs
// (F16 M=16 across the ef_search ladder, and the Int8 recall-collapse contrast)
// while staying a REAL measurement at the production corpus size.
let prod_only = arg(&args, "--prod-only", 0) != 0;
eprintln!(
"[grid] building clustered corpus: {corpus} vectors × {dim}-D, {n_clusters} clusters \
(spread {spread:.2}), {n_queries} queries (noise {noise:.3}), recall@{k}"
);
let corpus_start = Instant::now();
let centers: Vec<Vec<f32>> = (0..n_clusters as u64)
.map(|c| unit_vector(c.wrapping_add(0x00C0_FFEE), dim))
.collect();
let vectors: Vec<Vec<f32>> = (0..corpus as u64)
.map(|id| clustered_vector(&centers, id, dim, spread))
.collect();
// Queries are corpus points perturbed by small noise — they stay in-cluster,
// so each has a well-defined nearest cluster (the production read shape, the
// same model the m12p1 harness uses; see `perturbed_query`).
let queries: Vec<Vec<f32>> = (0..n_queries)
.map(|q| perturbed_query(&vectors, q, noise))
.collect();
eprintln!(
"[grid] corpus built in {:.1}s",
corpus_start.elapsed().as_secs_f64()
);
// Exact ground truth, computed ONCE (independent of HNSW parameters) and
// reused across every grid/quant point. F32 brute force == the true answer.
eprintln!("[grid] computing exact brute-force ground truth (once)…");
let gt_start = Instant::now();
let oracle = BruteForceIndex::new(VectorIndexConfig {
dimensions: dim,
metric: DistanceMetric::L2,
quantization: QuantizationLevel::F32,
connectivity: 16,
ef_construction: 400,
ef_search: 400,
});
for (id, v) in vectors.iter().enumerate() {
oracle.insert(id as VectorId, v).expect("oracle insert");
}
let truths: Vec<HashSet<VectorId>> = queries
.iter()
.map(|q| {
oracle
.search(q, k, 0)
.expect("oracle search")
.iter()
.map(|r| r.id)
.collect()
})
.collect();
eprintln!(
"[grid] ground truth ready in {:.1}s",
gt_start.elapsed().as_secs_f64()
);
// -------------------------------------------------------------------
// Sweep 1 — HNSW graph parameters at F16 (the production quantization).
// For each (M, ef_construction) we build the graph ONCE and sweep ef_search
// on it (search-time only — no rebuild), since ef_search is a per-query knob.
//
// G1 tuning (this task): the current default ef_search=200 has a large recall
// surplus (≈0.987 vs the 0.95 gate) we want to TRADE for latency at 1M. Latency
// is ~linear in ef_search, so we sweep DOWN through 32/48/64/96 to find the
// lowest beam that still holds recall@10 ≥ 0.95 — the biggest easy p99 win.
// -------------------------------------------------------------------
let grid_graphs: &[(usize, usize)] = if prod_only {
&[(16, 400)]
} else {
&[(16, 400), (24, 400), (32, 400)]
};
let ef_searches: &[usize] = &[32, 48, 64, 96, 128, 200, 400, 600];
let mut grid_points: Vec<Point> = Vec::new();
for &(m, ef_c) in grid_graphs {
eprintln!("[grid] building HNSW M={m} ef_c={ef_c} (F16)…");
let (index, build) = build_hnsw(&vectors, dim, QuantizationLevel::F16, m, ef_c, 200);
let (mem_mb, mem_1m) = footprint(&index, corpus);
for &ef_s in ef_searches {
let (rec, mean, p99) = measure(&index, &queries, &truths, k, ef_s);
grid_points.push(Point {
label: format!("M={m}, ef_c={ef_c}, ef_s={ef_s}"),
recall: rec,
mean_us: mean,
p99_us: p99,
build_s: build.as_secs_f64(),
mem_mb,
mem_per_1m_gb: mem_1m,
});
}
}
// -------------------------------------------------------------------
// Sweep 2 — quantization × ef_search at the production graph (M=16, ef_c=400).
// The G1 production candidate is Int8 + a reduced beam (halves distance compute
// AND memory, easing the 1M fit), so we sweep the SAME low-ef_search ladder on
// each quantization at M=16 — letting us pick the (quant × ef_search) frontier
// point with the lowest p99 that still clears recall ≥ 0.95.
// -------------------------------------------------------------------
let quants: &[QuantizationLevel] = if prod_only {
// Skip the F32 graph (2× memory + slowest build); keep F16 (production) and
// Int8 (the recall-collapse contrast the decision rests on).
&[QuantizationLevel::F16, QuantizationLevel::Int8]
} else {
&[
QuantizationLevel::F32,
QuantizationLevel::F16,
QuantizationLevel::Int8,
]
};
let quant_m = 16usize;
let quant_ef_construction = 400usize;
let quant_ef_searches: &[usize] = &[48, 64, 96, 128, 200];
let mut quant_points: Vec<Point> = Vec::new();
for &q in quants {
eprintln!(
"[grid] building HNSW {} M={quant_m} ef_c={quant_ef_construction}",
quant_name(q)
);
let (index, build) = build_hnsw(&vectors, dim, q, quant_m, quant_ef_construction, 200);
let (mem_mb, mem_1m) = footprint(&index, corpus);
for &ef_s in quant_ef_searches {
let (rec, mean, p99) = measure(&index, &queries, &truths, k, ef_s);
quant_points.push(Point {
label: format!(
"{} (M={quant_m}, ef_c={quant_ef_construction}, ef_s={ef_s})",
quant_name(q)
),
recall: rec,
mean_us: mean,
p99_us: p99,
build_s: build.as_secs_f64(),
mem_mb,
mem_per_1m_gb: mem_1m,
});
}
}
// -------------------------------------------------------------------
// Report — markdown tables ready to paste into docs/profiling/.
// -------------------------------------------------------------------
println!(
"\n## ANN grid search — measured (corpus={corpus}, dim={dim}, recall@{k}, queries={n_queries})\n"
);
println!("### HNSW parameter sweep (F16)\n");
println!(
"| M / ef_c / ef_s | recall@{k} | mean (µs) | p99 (µs) | build (s) | mem (MB) | mem/1M (GB) |"
);
println!("|---|---|---|---|---|---|---|");
for p in &grid_points {
println!(
"| {} | {:.4} | {:.0} | {:.0} | {:.1} | {:.0} | {:.2} |",
p.label, p.recall, p.mean_us, p.p99_us, p.build_s, p.mem_mb, p.mem_per_1m_gb
);
}
println!("\n### Quantization × ef_search sweep (M=16, ef_c=400)\n");
println!("| quantization | recall@{k} | mean (µs) | p99 (µs) | mem (MB) | mem/1M (GB) |");
println!("|---|---|---|---|---|---|");
for p in &quant_points {
println!(
"| {} | {:.4} | {:.0} | {:.0} | {:.0} | {:.2} |",
p.label, p.recall, p.mean_us, p.p99_us, p.mem_mb, p.mem_per_1m_gb
);
}
// Recommend the cheapest grid point (min p99 latency) that clears recall ≥ 0.95
// — p99 is the G1 gate metric (≤10ms), so we optimize against it directly.
let by_p99 = |pts: &[Point]| -> Option<usize> {
pts.iter()
.enumerate()
.filter(|(_, p)| p.recall >= 0.95)
.min_by(|(_, a), (_, b)| {
a.p99_us
.partial_cmp(&b.p99_us)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
};
println!("\n### Recommendation\n");
match by_p99(&grid_points).map(|i| &grid_points[i]) {
Some(p) => println!(
"- **F16 frontier (min p99 @ recall ≥ 0.95):** `{}` → recall@{k} {:.4}, mean {:.0} µs, p99 {:.0} µs, {:.2} GB/1M.",
p.label, p.recall, p.mean_us, p.p99_us, p.mem_per_1m_gb
),
None => println!(
"- ⚠ NO grid point reached recall@{k} ≥ 0.95 at this shape — widen ef or raise M."
),
}
if let Some(p) = by_p99(&quant_points).map(|i| &quant_points[i]) {
println!(
"- **Quantization frontier (min p99 @ recall ≥ 0.95):** `{}` → recall@{k} {:.4}, mean {:.0} µs, p99 {:.0} µs, {:.2} GB/1M.",
p.label, p.recall, p.mean_us, p.p99_us, p.mem_per_1m_gb
);
}
let smallest_ok = quant_points
.iter()
.filter(|p| p.recall >= 0.95)
.min_by(|a, b| {
a.mem_per_1m_gb
.partial_cmp(&b.mem_per_1m_gb)
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(p) = smallest_ok {
println!(
"- **Smallest quantization clearing recall ≥ 0.95:** `{}` → {:.4} recall, {:.2} GB/1M.",
p.label, p.recall, p.mem_per_1m_gb
);
}
}