perf(ann): unit-vector dot product and a reusable visited set

- Cosine distance was 1 - dot/(|a||b|), re-deriving both norms on every
  evaluation in the innermost loop of build and search. The index now stores
  unit vectors (prepared at build, insert, graph load and HDF5 load; the query
  once per search) and uses 1 - dot. Zero vectors stay zero, giving distance 1
  as before. Returned distances are unchanged.
- search_layer allocated a HashSet of visited nodes per call. It is now an
  epoch-stamped u32 array in thread-local scratch, reused across calls, so
  search(&self) stays shareable between threads.
- clawhdf5-accel caches the detected SIMD backend in a OnceLock.

Recall is identical. Build 2.75 -> 1.89 s (10K), ~38 -> 21 s (100K); QPS at
ef=64 22.7K -> 39K (10K), 10.4K -> 14K (100K).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
osobh
2026-09-19 13:21:40 -07:00
co-authored by Claude Fable 5.1
parent 09480747aa
commit f15bf2eb22
4 changed files with 150 additions and 10 deletions
+45
View File
@@ -176,6 +176,51 @@ index incrementally.
| 10000 | 120 | 2916 | 33.1 | 14.0 | 15.4 | 2.15 | 3.30 | 421.2 |
| 100000 | 1591 | 40515 | 747.3 | 324.7 | 158.9 | 23.07 | 30.42 | 41.3 |
### After: unit-vector dot product, reusable visited set
Cosine distance recomputed both vector norms on every evaluation; the index now
stores unit vectors and uses a plain dot product. The per-call `HashSet` of
visited nodes became a reusable epoch-stamped array. Recall is unchanged.
Build: **2.75 -> 1.89 s** (10K), **~38 -> 21 s** (100K). QPS at `ef = 64`:
**22.7K -> 39K** (10K), **10.4K -> 14K** (100K).
### HNSW, N = 1000, dim = 384, M = 16, ef_construction = 64
build: 113.4 ms (8821 vectors/s) · exact scan: 4375 QPS, p50 225 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9990 | 144379 | 7 | 15 |
| 32 | 1.0000 | 110654 | 9 | 17 |
| 64 | 1.0000 | 80446 | 12 | 25 |
| 128 | 1.0000 | 38220 | 26 | 36 |
| 256 | 1.0000 | 20041 | 50 | 62 |
### HNSW, N = 10000, dim = 384, M = 16, ef_construction = 64
build: 1519.4 ms (6581 vectors/s) · exact scan: 422 QPS, p50 2368 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9975 | 54608 | 15 | 45 |
| 32 | 1.0000 | 66009 | 14 | 24 |
| 64 | 1.0000 | 49854 | 19 | 31 |
| 128 | 1.0000 | 22403 | 45 | 57 |
| 256 | 1.0000 | 10096 | 100 | 120 |
### HNSW, N = 100000, dim = 384, M = 16, ef_construction = 64
build: 21084.6 ms (4743 vectors/s) · exact scan: 39 QPS, p50 24739 µs
| ef | recall@10 | QPS | p50 µs | p99 µs |
|---:|---:|---:|---:|---:|
| 16 | 0.9235 | 15139 | 61 | 154 |
| 32 | 0.9675 | 18181 | 53 | 121 |
| 64 | 0.9840 | 13980 | 70 | 139 |
| 128 | 0.9990 | 10959 | 86 | 174 |
| 256 | 0.9990 | 3731 | 254 | 697 |
## Vector Search Latency
Brute-force cosine similarity over 384-dimensional embeddings (OpenAI text-embedding-3-small size).
+9
View File
@@ -32,6 +32,15 @@
replayed from the WAL join the loaded index incrementally; a replayed update
or delete invalidates it. `snapshot()` copies it. Batch saves no longer force
a full index rebuild.
- `clawhdf5-ann`: faster HNSW build and search with identical recall. The
cosine metric stores unit vectors and compares them with a plain dot product
(it re-derived both norms on every distance evaluation), and the per-call
`HashSet` of visited nodes is a reusable epoch-stamped array. Build 2.75 ->
1.89 s at 10K and ~38 -> 21 s at 100K; QPS at `ef = 64` 22.7K -> 39K at 10K.
Distances returned by `search` are unchanged (1 - cosine). Indexes loaded
from older HDF5 files are normalised on load.
- `clawhdf5-accel`: the SIMD backend is detected once per process instead of
on every kernel call.
- `clawhdf5-ann`: `HnswIndex::graph_to_bytes` / `from_graph_bytes` — graph-only
serialization (checksummed, every neighbour id and level validated on load).
- `clawhdf5-agent`: BM25 results are deterministic (ties break by record id),
+7 -1
View File
@@ -61,8 +61,14 @@ pub enum Backend {
Scalar,
}
/// Detect the best available SIMD backend at runtime.
/// The best available SIMD backend, detected once per process. Every kernel
/// dispatches through this, so it sits in the innermost loop of every search.
pub fn detect_backend() -> Backend {
static BACKEND: std::sync::OnceLock<Backend> = std::sync::OnceLock::new();
*BACKEND.get_or_init(detect_backend_uncached)
}
fn detect_backend_uncached() -> Backend {
#[cfg(target_arch = "aarch64")]
{
return Backend::Neon; // Always available on aarch64
+88 -8
View File
@@ -1,6 +1,6 @@
//! HNSW index implementation with HDF5 serialization.
use std::collections::{BinaryHeap, HashSet};
use std::collections::BinaryHeap;
use clawhdf5_format::attribute::extract_attributes_full;
use clawhdf5_format::data_layout::DataLayout;
@@ -51,10 +51,30 @@ impl DistanceMetric {
fn compute_distance(a: &[f32], b: &[f32], metric: DistanceMetric) -> f32 {
match metric {
DistanceMetric::L2 => clawhdf5_accel::l2_distance(a, b),
DistanceMetric::Cosine => 1.0 - clawhdf5_accel::cosine_similarity(a, b),
// Both sides are unit length (see `prepare`), so cosine similarity is
// the plain dot product. Computing it as dot / (|a| * |b|) re-derived
// both norms on every call — three reductions instead of one, in the
// innermost loop of both build and search.
DistanceMetric::Cosine => 1.0 - clawhdf5_accel::dot_product(a, b),
}
}
/// Put a vector in the form the index stores and compares: unit length for the
/// cosine metric, unchanged for L2. A zero vector stays zero, giving distance 1
/// to everything — what the cosine kernel reports for a degenerate input.
fn prepare(mut v: Vec<f32>, metric: DistanceMetric) -> Vec<f32> {
if metric == DistanceMetric::Cosine {
let norm = clawhdf5_accel::vector_norm(&v);
if norm > f32::EPSILON {
let inv = 1.0 / norm;
v.iter_mut().for_each(|x| *x *= inv);
} else {
v.iter_mut().for_each(|x| *x = 0.0);
}
}
v
}
/// Assign a random level to a new node based on the HNSW probability distribution.
///
/// Uses a deterministic approach based on the node index for reproducibility.
@@ -204,6 +224,8 @@ impl HnswIndex {
let m_max0 = m * 2;
let n = vectors.len();
let prepared: Vec<Vec<f32>> = vectors.iter().map(|v| prepare(v.clone(), metric)).collect();
let vectors: &[Vec<f32>] = &prepared;
// Assign levels to all nodes
let mut node_levels = Vec::with_capacity(n);
@@ -288,7 +310,7 @@ impl HnswIndex {
}
Self {
vectors: vectors.to_vec(),
vectors: prepared,
graph,
deleted: vec![false; n],
entry_point,
@@ -327,6 +349,7 @@ impl HnswIndex {
/// # Panics
/// Panics if `vector`'s dimension does not match the existing vectors.
pub fn insert(&mut self, vector: Vec<f32>) -> usize {
let vector = prepare(vector, self.metric);
let id = self.vectors.len();
// Seed an empty index.
@@ -483,6 +506,8 @@ impl HnswIndex {
"query dimension mismatch"
);
let ef = ef.max(k);
let prepared_query = prepare(query.to_vec(), self.metric);
let query = prepared_query.as_slice();
let mut ep = self.entry_point;
let top_layer = self.graph.len().saturating_sub(1);
@@ -633,7 +658,9 @@ impl HnswIndex {
actual: flat_vectors.len(),
});
}
vectors.push(flat_vectors[start..end].to_vec());
// Files written before vectors were stored unit-length hold the
// raw ones; preparing is idempotent, so this handles both.
vectors.push(prepare(flat_vectors[start..end].to_vec(), metric));
}
// Read graph layers
@@ -835,7 +862,7 @@ impl HnswIndex {
}
Ok(Self {
vectors,
vectors: vectors.into_iter().map(|v| prepare(v, metric)).collect(),
graph,
deleted,
entry_point,
@@ -937,9 +964,62 @@ fn search_layer(
distance: ep_dist,
});
let mut visited = HashSet::new();
VISITED.with_borrow_mut(|visited| {
visited.begin(vectors.len());
visited.insert(ep);
search_layer_visit(
vectors, layer, query, ef, metric, visited, candidates, results,
)
})
}
/// Which nodes a layer search has already seen. A `HashSet` allocated per call
/// was the hottest non-arithmetic cost in both build and query; this is one
/// `u32` stamp per node, reused across calls: a node is visited iff its stamp
/// equals the current epoch, so "clearing" is just bumping the epoch.
#[derive(Default)]
struct Visited {
stamps: Vec<u32>,
epoch: u32,
}
impl Visited {
fn begin(&mut self, n: usize) {
if self.stamps.len() < n {
self.stamps.resize(n, 0);
}
self.epoch = self.epoch.wrapping_add(1);
if self.epoch == 0 {
// Wrapped: stale stamps could collide with the new epoch.
self.stamps.iter_mut().for_each(|s| *s = 0);
self.epoch = 1;
}
}
/// Mark `id` visited; `true` if it was not already.
fn insert(&mut self, id: usize) -> bool {
let seen = self.stamps[id] == self.epoch;
self.stamps[id] = self.epoch;
!seen
}
}
thread_local! {
/// Per-thread scratch, so `search(&self)` stays shareable across threads.
static VISITED: std::cell::RefCell<Visited> = std::cell::RefCell::new(Visited::default());
}
#[allow(clippy::too_many_arguments)]
fn search_layer_visit(
vectors: &[Vec<f32>],
layer: &[Vec<usize>],
query: &[f32],
ef: usize,
metric: DistanceMetric,
visited: &mut Visited,
mut candidates: BinaryHeap<Candidate>,
mut results: BinaryHeap<FarCandidate>,
) -> Vec<Candidate> {
while let Some(closest) = candidates.pop() {
let furthest_dist = results.peek().map_or(f32::MAX, |f| f.distance);
if closest.distance > furthest_dist && results.len() >= ef {
@@ -947,10 +1027,9 @@ fn search_layer(
}
for &neighbor in &layer[closest.id] {
if visited.contains(&neighbor) {
if !visited.insert(neighbor) {
continue;
}
visited.insert(neighbor);
let d = compute_distance(query, &vectors[neighbor], metric);
let furthest_dist = results.peek().map_or(f32::MAX, |f| f.distance);
@@ -1236,6 +1315,7 @@ fn get_attr_string(attrs: &[(String, AttrValue)], name: &str) -> Result<String,
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Tight, well-separated clusters — the shape real embeddings have, and
/// the case plain closest-M neighbour selection fails on: each cluster