Skip to content

Commit 9c41e04

Browse files
committed
bench: add hnsw frontier counters
1 parent 062cd7c commit 9c41e04

5 files changed

Lines changed: 156 additions & 1 deletion

File tree

benches/hnsw_search.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use rand::prelude::*;
99
use std::alloc::{GlobalAlloc, Layout, System};
1010
use std::sync::atomic::{AtomicUsize, Ordering};
1111
use vicinity::hnsw::HNSWIndex;
12+
#[cfg(feature = "benchmark")]
13+
use vicinity::hnsw::{reset_search_counters, take_search_counters, HnswSearchCounters};
1214

1315
static ALLOC_CALLS: AtomicUsize = AtomicUsize::new(0);
1416
static ALLOC_BYTES: AtomicUsize = AtomicUsize::new(0);
@@ -100,11 +102,25 @@ fn print_allocation_summary(
100102
ef: usize,
101103
) {
102104
let mut alloc_total = AllocationProfile::default();
105+
#[cfg(feature = "benchmark")]
106+
let mut search_total = HnswSearchCounters::default();
103107
let mut result_count = 0usize;
104108

105109
for query in queries {
110+
#[cfg(feature = "benchmark")]
111+
reset_search_counters();
106112
let (results, alloc_profile) =
107113
AllocationProfile::record_search(|| index.search(query, k, ef).unwrap());
114+
#[cfg(feature = "benchmark")]
115+
{
116+
let counters = take_search_counters();
117+
search_total.candidate_pushes += counters.candidate_pushes;
118+
search_total.candidate_pops += counters.candidate_pops;
119+
search_total.frontier_retain_calls += counters.frontier_retain_calls;
120+
search_total.frontier_pruned_candidates += counters.frontier_pruned_candidates;
121+
search_total.max_frontier_len =
122+
search_total.max_frontier_len.max(counters.max_frontier_len);
123+
}
108124
alloc_total.add_assign(alloc_profile);
109125
result_count += results.len();
110126
}
@@ -115,6 +131,15 @@ fn print_allocation_summary(
115131
alloc_total.calls as f64 / queries_len,
116132
alloc_total.bytes as f64 / queries_len,
117133
);
134+
#[cfg(feature = "benchmark")]
135+
eprintln!(
136+
"hnsw frontier {label}: ef={ef} candidate_pushes={:.1}/query candidate_pops={:.1}/query retain_calls={:.1}/query pruned={:.1}/query max_frontier={}",
137+
search_total.candidate_pushes as f64 / queries_len,
138+
search_total.candidate_pops as f64 / queries_len,
139+
search_total.frontier_retain_calls as f64 / queries_len,
140+
search_total.frontier_pruned_candidates as f64 / queries_len,
141+
search_total.max_frontier_len,
142+
);
118143
black_box(result_count);
119144
}
120145

docs/benchmark-results.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,6 +1573,38 @@ The patch was reverted and saved locally as
15731573
more than it saves on low-ef search, so further heap work should be driven by
15741574
frontier-size counters or a profile, not by more unmeasured branch splitting.
15751575

1576+
Benchmark-only HNSW frontier counters were then added behind the `benchmark`
1577+
feature. They report candidate heap pushes, pops, frontier-retain calls,
1578+
stale candidates removed by retain, and maximum frontier length during the
1579+
existing allocation-summary prepass. The counters are diagnostic only; the
1580+
timed Criterion rows from this short run should not be used as throughput
1581+
evidence.
1582+
1583+
```bash
1584+
CARGO_TARGET_DIR=/tmp/vicinity-hnsw-counters-bench CARGO_INCREMENTAL=0 \
1585+
RUSTC_WRAPPER= cargo bench --bench hnsw_search --no-default-features \
1586+
--features hnsw,benchmark -- hnsw_search_ --sample-size 10 \
1587+
--warm-up-time 0.1 --measurement-time 0.1
1588+
```
1589+
1590+
| Workload | Candidate pushes/query | Candidate pops/query | Retain calls/query | Pruned/query | Max frontier |
1591+
| --- | ---: | ---: | ---: | ---: | ---: |
1592+
| `hnsw_search_only/ef/10` | 36.7 | 14.5 | 0.0 | 0.0 | 35 |
1593+
| `hnsw_search_only/ef/50` | 185.2 | 59.7 | 0.0 | 0.0 | 164 |
1594+
| `hnsw_search_only/ef/100` | 351.2 | 108.3 | 1.0 | 212.0 | 291 |
1595+
| `hnsw_search_only/ef/200` | 656.6 | 203.6 | 1.0 | 299.9 | 464 |
1596+
| `hnsw_search_mmax32/ef/10` | 44.6 | 15.2 | 0.0 | 0.0 | 46 |
1597+
| `hnsw_search_mmax32/ef/50` | 208.4 | 55.2 | 0.0 | 0.0 | 189 |
1598+
| `hnsw_search_mmax32/ef/100` | 392.4 | 103.5 | 1.0 | 265.8 | 347 |
1599+
| `hnsw_search_mmax32/ef/200` | 711.4 | 201.9 | 1.0 | 405.0 | 587 |
1600+
1601+
The next HNSW frontier experiment should use these counters as a guard. The
1602+
current prune path already fires only once per high-ef query on this fixture
1603+
and removes many stale candidates, while low-ef rows never call retain. That
1604+
makes another low-ef branch split unlikely to pay off. A custom frontier or a
1605+
different prune trigger needs counters showing fewer pops or a smaller maximum
1606+
frontier without hurting the `ef=10` rows.
1607+
15761608
A follow-up finalization experiment tried to remove the intermediate top-k
15771609
`Vec` allocation in `HNSWIndex::search` by chaining `take(k)` directly into
15781610
the tombstone/doc-id conversion. The intended invariant was unchanged

docs/review-status.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ benchmarking, persistence, Python bindings, and performance work.
309309
| 13 | LSH/sketch boundary | The `lsh` feature uses `sketchir` for cross-polytope hashing primitives. Keep `sketchir` focused on MinHash/SimHash/LSH sketches and durable sketch sidecars; keep vicinity focused on ANN storage, exact reranking, persistence modes, and fixed-recall benchmark rows. Benchmark sharing is useful, but PRT, RP-tree/RP-forest, SparseMIPS, and LEMUR should stay in vicinity unless their role becomes pure sketch generation. |
310310
| 14 | External research claims | New implementation-scouting evidence points to Qdrant's mmap/residency model, Weaviate sparse visited sets, Qdrant/Vespa selectivity-gated ACORN, Faiss FastScan layout validation, DiskANN provider boundaries, and Qdrant-style private SIMD kernels as the most actionable prior art. Still verify newer roadmap claims before implementation: Extended RaBitQ, VSAG layout tricks, IP-DiskANN, PAG, SAQ, and ARM/SVE2 kernels. Keep `innr` as the optional dense-distance SIMD dependency; use local `pq_simd` work for PQ-code/LUT kernels that `innr` does not cover. |
311311
| 15 | Dataset difficulty metadata | First sampled profile script exists for VEC1/NBR1 datasets, and local profiles now cover SIFT, GloVe-25/50/100/200, Deep Image, NYTimes, Fashion-MNIST, MNIST, and GIST. Optional generated split labels are reported when present, and `scripts/summarize_dataset_profiles.py` renders profile JSONs into the docs table shape. `scripts/summarize_ann_results.py --profile-dir PATH` now joins exact profile metrics into ANN coverage rows while leaving capped dataset labels unlinked unless an exact profile exists. |
312-
| 16 | Profiling depth | Runtime profiles now cover HNSW search, ACORN filtered search, DiskANN direct-file rows, IVF-PQ ADC/allocation paths, dataset difficulty, and a same-binary `m_max=16` versus `m_max=32` HNSW search-only comparison. The ledger also records a build-path sample where `rustc` stalled in `readdir` over a large `target/debug/deps`; use isolated `CARGO_TARGET_DIR` values for future profile targets. HNSW binary inspection confirmed an indirect `blr x7` in `flush_batch`, and the new `distance_dispatch` Criterion group shows function-pointer dispatch costs on low-dimensional `innr` kernels, but the broad, `flush_batch`, and cosine-only HNSW dispatch rewrites all regressed or missed the keep threshold. The latest plain-HNSW symbolized sample still puts the largest leaf bucket inside `innr::dense::dot`; the ACORN samples first put the largest buckets in `HashMap::insert` and `reserve_rehash`, then shifted after dense tracking to inlined ACORN loop work plus `innr::dense::dot`. The kept ACORN fix is a safe visited-tracker change, not unsafe SIMD. The graph prefetch experiment removed a product unsafe surface and improved or held controls, so do not add local HNSW unsafe before safe heap/frontier/layout experiments. Next actual performance change should still record baseline, profiler target, negative controls, before/after, and rejected hypotheses in `docs/benchmark-results.md`. |
312+
| 16 | Profiling depth | Runtime profiles now cover HNSW search, ACORN filtered search, DiskANN direct-file rows, IVF-PQ ADC/allocation paths, dataset difficulty, and a same-binary `m_max=16` versus `m_max=32` HNSW search-only comparison. The ledger also records a build-path sample where `rustc` stalled in `readdir` over a large `target/debug/deps`; use isolated `CARGO_TARGET_DIR` values for future profile targets. HNSW binary inspection confirmed an indirect `blr x7` in `flush_batch`, and the new `distance_dispatch` Criterion group shows function-pointer dispatch costs on low-dimensional `innr` kernels, but the broad, `flush_batch`, and cosine-only HNSW dispatch rewrites all regressed or missed the keep threshold. The latest plain-HNSW symbolized sample still puts the largest leaf bucket inside `innr::dense::dot`; the ACORN samples first put the largest buckets in `HashMap::insert` and `reserve_rehash`, then shifted after dense tracking to inlined ACORN loop work plus `innr::dense::dot`. Benchmark-feature HNSW frontier counters now report candidate pushes, pops, retain calls, pruned candidates, and max frontier length for the search-only bench; they show high-ef rows prune many stale candidates once per query while low-ef rows never call retain. The kept ACORN fix is a safe visited-tracker change, not unsafe SIMD. The graph prefetch experiment removed a product unsafe surface and improved or held controls, so do not add local HNSW unsafe before safe heap/frontier/layout experiments. Next actual performance change should still record baseline, profiler target, negative controls, before/after, and rejected hypotheses in `docs/benchmark-results.md`. |
313313

314314
## Guardrails
315315

src/hnsw/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ mod search;
152152
pub use graph::{
153153
HNSWBuilder, HNSWIndex, HNSWParams, NeighborhoodDiversification, SeedSelectionStrategy,
154154
};
155+
#[cfg(all(feature = "hnsw", feature = "benchmark"))]
156+
pub use search::{reset_search_counters, take_search_counters, HnswSearchCounters};
155157

156158
// Filtered search (ACORN-style)
157159
#[cfg(feature = "hnsw")]

src/hnsw/search.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,89 @@ use std::collections::{BinaryHeap, HashSet};
55

66
pub(crate) use crate::prefetch::prefetch_read_data;
77

8+
#[cfg(feature = "benchmark")]
9+
thread_local! {
10+
static SEARCH_COUNTERS: RefCell<HnswSearchCounters> = const {
11+
RefCell::new(HnswSearchCounters::new())
12+
};
13+
}
14+
15+
/// Diagnostic counters for HNSW search benchmarks.
16+
#[cfg(feature = "benchmark")]
17+
#[derive(Clone, Copy, Debug, Default)]
18+
pub struct HnswSearchCounters {
19+
/// Number of candidate-frontier heap pushes.
20+
pub candidate_pushes: u64,
21+
/// Number of candidate-frontier heap pops.
22+
pub candidate_pops: u64,
23+
/// Number of frontier-pruning retain calls.
24+
pub frontier_retain_calls: u64,
25+
/// Number of candidate entries removed by frontier pruning.
26+
pub frontier_pruned_candidates: u64,
27+
/// Maximum candidate-frontier heap length observed.
28+
pub max_frontier_len: usize,
29+
}
30+
31+
#[cfg(feature = "benchmark")]
32+
impl HnswSearchCounters {
33+
const fn new() -> Self {
34+
Self {
35+
candidate_pushes: 0,
36+
candidate_pops: 0,
37+
frontier_retain_calls: 0,
38+
frontier_pruned_candidates: 0,
39+
max_frontier_len: 0,
40+
}
41+
}
42+
}
43+
44+
/// Reset the current thread's HNSW search benchmark counters.
45+
#[cfg(feature = "benchmark")]
46+
pub fn reset_search_counters() {
47+
SEARCH_COUNTERS.with(|cell| {
48+
*cell.borrow_mut() = HnswSearchCounters::new();
49+
});
50+
}
51+
52+
/// Return and reset the current thread's HNSW search benchmark counters.
53+
#[cfg(feature = "benchmark")]
54+
pub fn take_search_counters() -> HnswSearchCounters {
55+
SEARCH_COUNTERS.with(|cell| {
56+
let mut counters = cell.borrow_mut();
57+
let out = *counters;
58+
*counters = HnswSearchCounters::new();
59+
out
60+
})
61+
}
62+
63+
#[cfg(feature = "benchmark")]
64+
#[inline]
65+
fn record_candidate_push(candidates_len: usize) {
66+
SEARCH_COUNTERS.with(|cell| {
67+
let mut counters = cell.borrow_mut();
68+
counters.candidate_pushes += 1;
69+
counters.max_frontier_len = counters.max_frontier_len.max(candidates_len);
70+
});
71+
}
72+
73+
#[cfg(feature = "benchmark")]
74+
#[inline]
75+
fn record_candidate_pop() {
76+
SEARCH_COUNTERS.with(|cell| {
77+
cell.borrow_mut().candidate_pops += 1;
78+
});
79+
}
80+
81+
#[cfg(feature = "benchmark")]
82+
#[inline]
83+
fn record_frontier_retain(before: usize, after: usize) {
84+
SEARCH_COUNTERS.with(|cell| {
85+
let mut counters = cell.borrow_mut();
86+
counters.frontier_retain_calls += 1;
87+
counters.frontier_pruned_candidates += before.saturating_sub(after) as u64;
88+
});
89+
}
90+
891
// ─── Visited set ─────────────────────────────────────────────────────────────
992

1093
/// Threshold below which we use a dense generation-counter array instead of HashSet.
@@ -280,6 +363,8 @@ fn flush_batch(
280363
id: batch_ids[i],
281364
distance: dists[i],
282365
});
366+
#[cfg(feature = "benchmark")]
367+
record_candidate_push(candidates.len());
283368
}
284369
}
285370
return;
@@ -300,6 +385,8 @@ fn flush_batch(
300385
id: batch_ids[i],
301386
distance: dists[i],
302387
});
388+
#[cfg(feature = "benchmark")]
389+
record_candidate_push(candidates.len());
303390
}
304391
worst_dist = if results.len() < ef {
305392
f32::INFINITY
@@ -372,7 +459,11 @@ fn prune_unpromising_candidates(
372459
return;
373460
};
374461
let worst_dist = worst.distance;
462+
#[cfg(feature = "benchmark")]
463+
let before = candidates.len();
375464
candidates.retain(|candidate| candidate.distance <= worst_dist);
465+
#[cfg(feature = "benchmark")]
466+
record_frontier_retain(before, candidates.len());
376467
}
377468

378469
// ─── Search functions ────────────────────────────────────────────────────────
@@ -413,6 +504,8 @@ pub fn greedy_search_layer(
413504
id: entry_point,
414505
distance: entry_distance,
415506
});
507+
#[cfg(feature = "benchmark")]
508+
record_candidate_push(candidates.len());
416509
results.push(MaxResult {
417510
id: entry_point,
418511
distance: entry_distance,
@@ -424,6 +517,9 @@ pub fn greedy_search_layer(
424517
let should_prune_frontier = ef >= FRONTIER_PRUNE_MIN_EF;
425518
let mut pops_since_prune = 0usize;
426519
while let Some(candidate) = candidates.pop() {
520+
#[cfg(feature = "benchmark")]
521+
record_candidate_pop();
522+
427523
// Stopping condition: if best candidate is worse than worst result
428524
// and we have enough results, we're done
429525
let worst_dist = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);

0 commit comments

Comments
 (0)