Beam the upper-layer descent so a local minimum cannot strand a search - #2
Merged
Conversation
`greedy_descend` walked the upper layers with a beam of one: it stopped at the first node no neighbour improved on, then dropped a level. On a clustered corpus that local minimum can sit in the wrong basin, and layer-0 adjacency is intra-basin, so the layer-0 beam has no uphill edge with which to leave. The query's true nearest neighbour is then unreachable at any ef, which is what made `concurrent_insert_search`'s `misses == 0` assertion fail ~1.5% of the time (6/400 runs on an oversubscribed loop) and blocked the v0.2.0 release. `beam_descend` runs the existing `search_layer` at width DESCENT_EF=16 on each upper level instead, seeded by the level above's best, rolling the scratch epoch per level so a node present at several levels is expandable at each. It is shared by the read and the write path, as the greedy descent was: insert must route through the same graph its queries will, or nodes get their neighbours chosen from a basin searches never reach. Concurrency was never the cause — it only shuffles the insertion order. Fixing that order reproduces the trap single-threaded, which is what the new regression test pins: seeds 57 and 240 lose 55 and 22 of their 8000 self-queries on the old descent and none on the new one. Measured (8000-node builds, self-query every node at ef 256, one build per seed): width 1 loses 125 nodes over 200 builds, width 4 loses 20 over 200, width 8 loses 8 over 700, width 16 loses 0 over 700. The oversubscribed concurrent loop goes 6/400 failures to 0/400. Cost at 50k x 768-d: visits/query +17-27%, p50 +0.06 ms (0.15 -> 0.21 at ef 16, 0.46 -> 0.47 at ef 512), build throughput -20%; recall@10 improves below ef 128 and is unchanged above. Filtered and predicated searches now count their visit budget from where the descent left off. The budget is documented as the layer-0 cap, and a wider descent would otherwise have silently eaten into it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
Two follow-ups from the step-6 planning review. A tied-distance query was called out as unbounded: a zero vector gives every stored vector cosine distance exactly 1.0, so nothing is ever strictly worse than the beam's worst result. Measured, that is not what happens — the descent visits 608 nodes on a 50k-node graph at width 16, against an upper component of ~3300, because `search_layer` pushes a neighbour only on `d < worst` and `<` is strict. Once the result set fills, tied candidates are never pushed and the candidate heap drains after at most `ef` expansions per level. That is the real bound, and it is now a test: relaxing the comparison to `<=` would walk the whole upper component, and fails. An enforced per-level cap was tried and dropped. The same measurement that refutes the blocker also shows it cannot be sized safely — the worst ordinary query over 3000 random queries visits 788 nodes at level 1, so an `ef * UPPER_CAP` ceiling has 1.3x headroom and would clip real searches, silently degrading the recall this change exists to restore. `descent_width_sweep` is the measurement behind DESIGN.md's width table, kept runnable (ignored by default) so the numbers can be re-derived when M, ml or the prune policy changes. It reuses the corpus and insertion order the regression test already defines rather than duplicating them into an example. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
…DESIGN.md The added comments were narrating the diff rather than saying what the code cannot. Trimmed to the why in each case, with the width sweep and the cost envelope moved to DESIGN.md where a maintainer will look for them. DESIGN.md now carries three things a future change needs and the code cannot say: that read and write descent widths must match (write-only widening measures worse than baseline, 245 misses against 125, because a graph wired under one routing policy and queried under another is less navigable than one where they agree); that the trap is a property of graph shape rather than of concurrency, and does not reproduce at 32 dims or below ~8000 nodes; and that a per-level visit cap cannot be sized safely, since the obvious ef * UPPER_CAP ceiling is already exceeded by ordinary queries at 500k nodes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
…he sweep Three findings from the pre-push review. search_layer allocated two BinaryHeaps and returned an owned Vec on every call. That was once per query before; the descent makes it once per upper level, on the cheap-query path the module header claims a reusable-scratch hot path for. The heaps now come from SearchScratch by the same mem::take that already served the neighbour buffer, so a query allocates the result Vec and nothing else per level. Builds benefit more than queries: insert's per-level search runs at ef_construction 200, so those were the largest heaps in the crate and they were reallocated per level per insert. The filtered and predicated budget re-basing had no test. It does now, and it fails without the fix: with a budget deliberately smaller than the descent, search_filtered returns its entry point alone instead of ten hits. descent_width_sweep was documented as the harness behind DESIGN.md's width table but had no width knob, so the table's rows could not be re-derived without editing a constant and recompiling. HNSW_SWEEP_READ_EF now drives the query-side descent independently of the compiled build-side width, which is also what separates a routing defect from a construction one: build 16 / read 1 loses 9 nodes over 25 builds where build 16 / read 16 loses none. The review also held that nothing in the suite fails if DESCENT_EF is lowered. It does: the pinned regression is red at width 8 (seed 240, 8 misses) and at width 4 (20 misses). Seed 240 is in the test precisely because it is the width-8 witness. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
… width search_predicated hand-rolls its own layer-0 beam rather than calling search_layer, so it kept allocating its two BinaryHeaps per query while every other path had stopped. Same mem::take treatment, so predicated and plain searches now share one allocation profile. HNSW_SWEEP_READ_EF=0 would have made search_layer's `results.len() >= ef` break trip on the first candidate, so the sweep would report a clean run having expanded nothing. Floored at 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
…ays out search_layer still returned a freshly collected Vec, so the descent allocated one per upper level even after its heaps moved to the scratch. It now fills a caller-owned buffer: beam_descend reuses one across all levels, insert reuses one across its per-level searches, and the two top-level searches pass the vector they were going to return anyway. That is the last allocation that scaled with the graph's height. The claim is now checkable. tests/allocation.rs installs a counting global allocator — it needs its own test binary, since the counter is global and cargo runs tests in one binary concurrently — and asserts that per-query allocations do not grow with graph height. On a warmed scratch a search allocates once, for the vector it returns. Without the fix the test reports 3.00 allocations per query on a 2-level graph against 5.00 on a 4-level one, which is the per-level allocation made visible: every other test in the suite passes either way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
…e code search_predicated carries its own layer-0 loop, so the filtered budget test did not cover it — and all three existing predicate tests pass 64 * 24 = 1536, orders above what a descent costs, so they hold with or without the rebase. The new test passes a budget of 64 against a descent that costs more than that, and fails without the fix by returning the descent's landing point alone. Comments: dropped the duplicated budget note, the one reading `ef * UPPER_CAP` back as prose, and the sentences arguing the change to a reviewer rather than telling the next reader something. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
Filling a caller-owned buffer meant reserving it up front, and `ef` reaches that reservation from an unvalidated u32 at the NAPI boundary — `ef as usize` with no ceiling. The previous `collect()` sized to the results actually found, so this was newly introduced: a query carrying `ef: 4294967295`, a config typo or an API that exposes efSearch to its caller, asks for 34 GB, and handle_alloc_error answers by aborting the process. Uncatchable from JS, and it takes every in-flight query with it. Linux overcommit usually hides it; the Windows CI runner and strict-overcommit hosts do not. A result set cannot exceed the ids the plane has ever allocated, so the reservation is now `ef.min(id_high_water())` — still one reservation, since a growing push loop would reallocate about seven times at ef 64 and break the allocation test's own bound. The allocation test builds at ef_construction 24 rather than the default 200: it measures search allocations, so graph quality is irrelevant and there is no reason to put a full-quality 60k build on three CI runners. Its height precondition now says in the failure message that it derives from `ml`, so tuning that reads as what it is rather than as an allocation regression. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
… one thread The previous commit bounded the result reservation by the plane's high-water. It turns out no reservation was needed: `out` is filled by one `extend` off an exact-size drain, which reserves once for the results actually found. So `Vec::new()` allocates exactly the same number of times — the allocation test still passes its `<= 2 per query` bound — and nothing is sized by `ef` at all. That removes the last path from an unvalidated u32 to an allocation size, rather than making it smaller: on a fifty-million-node plane the bounded form still permitted a 400 MB request. tests/allocation.rs installs a process-global allocator, so a plain counting flag folded in every other thread in the binary, the test harness's own included. The counter is now keyed to the measuring thread's `pthread_self`, which is used rather than `thread::current().id()` because the latter can allocate and allocating inside the allocator recurses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
CI builds on windows-latest, where `libc::pthread_self` does not exist, so the previous commit would have broken the build there. Both review legs caught it independently. The counting flag is now a `thread_local!` `Cell<bool>` with `const` init: no libc, no platform gate, and reading it neither lazily initializes nor registers a destructor, either of which would allocate — and allocating inside the allocator recurses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizJVayzmA8WFhdxPz1FhV
Member
Author
|
Reviewers picked from the expertise matrix rather than affinity: every line these files touch was written by the PR author, so the affinity ranking returns no candidates. @dawsontoth is Responsible Expert for vector search and @kylebernhardy is listed competent in it. The two entries most worth a ruling are the beam width staying a compile-time constant, and the write path sharing the widened descent — the second overrules a — Claude Fable 5.1 |
kriszyp
marked this pull request as ready for review
September 4, 2026 05:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The upper-layer descent walked with a beam of one.
greedy_descendhill-climbed to the first node no neighbour improved on, then dropped a level. On a clustered corpus that local minimum can sit in the wrong basin, and layer-0 adjacency is intra-basin, so the layer-0 beam has no uphill edge with which to leave. The query's true nearest neighbour is then unreachable at anyef, which is why raising it never helped. That is what madeconcurrent_insert_search'smisses == 0assertion fail intermittently and blocked the v0.2.0 npm publish.beam_descendruns the existingsearch_layerat widthDESCENT_EF = 16on each upper level instead, seeded by the level above's best, rolling the scratch epoch per level so a node present at several levels stays expandable at each. It is shared by the read and the write path, as the greedy descent was.Calling
search_layerper level would have meant allocating per level, so the layer search no longer allocates at all: its two heaps live in the reusableSearchScratchand it fills a caller-owned output buffer, which the descent reuses across levels andinsertreuses across its per-level searches. That is what pays for the wider descent on the write side: before it, building 50k x 768-d cost 20% more thanmain; after it, seven paired 100k builds have medians of 92 s onmainagainst 88 s here, and the least-loaded pair is 56 s against 61 s. Build throughput is within the noise of a shared box either way. A new test binary installs a counting global allocator and asserts per-query allocations do not grow with graph height — without it, 3 allocations per query on a 2-level graph against 5 on a 4-level one.Concurrency was never the cause — it only shuffles the insertion order. Fixing that order with a seeded permutation reproduces the trap single-threaded, which is what the new regression test pins: seeds 57 and 240 lose 55 and 22 of their 8000 self-queries on the old descent, and none on the new one.
For the human reviewer
The beam width is 16, and it is a compile-time constant rather than a parameter. Kris ruled on this after seeing the sweep. Width 8 costs about 10% fewer visits per query (762 against 844 at ef 64), but one build in 1300 still loses 8 of its 8000 nodes; width 16 loses none in 1300. The alternative was exposing it on
InsertParamsand the search call — declined because adding a field to a public struct breaks struct-literal callers, a new search argument changesindex.d.tsand every JS call site, and this is a correctness floor rather than a recall/latency dial (efis the dial). Fully reversible: it is one constant. Saying no costs a rebuild and a re-run of the sweep.The write path shares the widened descent, so this changes graph construction, not only queries. The step-6 planning review returned
Framing-Verdict: better-alternative-existsand proposed read-side beam with greedy insertion retained, on the grounds that it repairs existing files and avoids the build cost. It asked for a causality matrix before coupling the two, so I ran one — read width and write width made independent, same seeds, same corpus, 200 builds per cell:Read-only widening cuts misses 3.4x and still leaves 37 across 13 builds, which is not enough to make the assertion hold. Build-only widening is worse than baseline, which is the same finding from the other side: a graph wired under one routing policy and queried under another is less navigable than one where they agree.
insertseeds each level'ssearch_layerfrom the descent's landing point, so a width-1 insert descent picks the new node's neighbours from the wrong basin and the node never acquires the in-edges a correctly-routed search arrives on. I overruled the framing verdict on that measurement. This is the entry to push back on if you want to. Reversing it later is a one-line change plus a rebuild, but it is not free: a plane file built under one policy keeps its topology.Rollout: an existing plane file is improved on its next query, not repaired. Row two of that matrix is exactly the upgrade state — old graph, new reader — and it is 37 misses rather than 0. Full repair needs the nodes re-inserted. No format change and no version bump; this is traversal and construction only, so an old file opens and reads normally.
A downgrade to a pre-change binary reads a width-16-built plane with a width-1 descent, and nothing detects it. That is the worst cell of the matrix above: 245 lost self-queries per 200 builds, against 125 for the never-changed baseline. The on-disk layout is unchanged, so the format version still gates only layout while the incompatibility is graph shape. Fixing it properly means a header field recording the descent width a plane was built with, so an incompatible reader refuses the open — and this task explicitly rules out a format change. The exposure today is theoretical: this is v0.0.1, the v0.2.0 publish was blocked by the very test this fixes, and there are no field planes. It is filed as a finding rather than fixed here, but a rolling upgrade or rollback is the thing to think about before that changes.
A per-level visit cap was tried and removed. The planning review called the tied-distance case a blocker: a zero query gives every stored vector cosine distance exactly 1.0, so "it can visit the entire reachable upper graph and grow
BinaryHeap". Measured, it does not — the descent visits 608 nodes on a 50k graph whose upper component is about 3300, becausesearch_layerpushes a neighbour only ond < worstand<is strict, so once the result set fills, tied candidates are never pushed and the candidate heap drains after at mostefexpansions per level. The same measurement shows a cap cannot be sized safely. The worst ordinary query over 3000 random queries visits 788 nodes at level 1 on a 50k graph and 1044 on a 500k graph, so the obviousef * UPPER_CAP= 1024 ceiling has 1.3x headroom at 50k and is already exceeded at 500k. It would clip real searches, silently degrading the recall this change exists to restore. What ships instead is a characterization test that fails if that<is ever relaxed to<=. The residual, stated plainly: a beam's worst case per level is O(level population), the same class as the layer-0 beam thatsearchalready runs withvisit_budget = u64::MAX.Filtered and predicated searches now count their visit budget from where the descent left off. The budget is documented as the layer-0 cap, but it was measured against a counter the descent had already advanced, so a wider descent would have silently eaten into it. Both paths have a test that fails without the rebase, returning the descent's landing point alone instead of ten hits. It is a semantic change for hosts already tuned against the old meaning:
filterExpansionandvisitBudgetnow buy layer-0 visits rather than total visits, so a filtered query's worst case is the descent plus the budget, and only the second term is host-bounded.Where to look hardest: concurrent throughput, because the box I measured on is shared. Fourteen alternating base/head runs of the benchmark's concurrent pass — 8 searcher threads plus a background writer at 100k x 768-d — put aggregate throughput at 8200-9000 QPS on
mainand 7100-7800 here at comparable load, which is the per-query visit increase and nothing more; medians over all fourteen are within 3%, mostly because machine load dominates. One head run of seven collapsed to 1168 QPS unexplained, and one base run of seven collapsed to 1584, so I cannot attribute either. If you want to satisfy yourself on a quiet machine, the mechanism to rule out isadd_reverse_edge: its upper-level branch runsprune_with_coverageinsideupdate_upper_level's seqlock write lock, while the layer-0 branch directly above it deliberately runs the same prune outside the lock and applies it with a compare-and-set. A wider descent reads many more upper entries per query, so it collides with that critical section more often. That lock discipline is pre-existing and untouched here; it is filed as a finding rather than changed under a deadline.greedy_descendis renamed, not kept as an alias. The crate ships as an npm native addon; itsrlibhas no consumer outside this repository's own tests, bins and the ignored sweep, and it is not published to crates.io. The planning review asked for the removal to be treated explicitly as a Rust API break — this is that acknowledgement.One defect this change introduced, and the review caught it. Filling a caller-owned buffer looked like it needed reserving up front, and
efreaches that reservation from an unvalidatedu32at the N-API boundary — the previouscollect()sized to the results actually found. A query carryingef: 4294967295, a config typo or an API that handsefSearchto its caller, asks for 34 GB, andhandle_alloc_erroranswers by aborting the process, uncatchable from JS and taking every in-flight query with it. Linux overcommit usually hides this; the Windows CI runner and strict-overcommit hosts do not. The buffer is filled by oneextendoff an exact-size drain, which already reserves once for the results actually found, so nothing is sized byefat all now — verified by the allocation bound above, which still holds with no reservation.Verification
End-to-end route: the existing concurrent integration test, a deterministic version of it, and the packaged N-API smoke. Everything below was executed on this branch;
mainfigures come from the same commands against 95c8076.The release blocker itself. The documented oversubscribed repro, 8 processes pinned to 2 cores:
0 failures over 480 runs, against 6 over 400 on
main(1.5%).The new tests fail on base. Reverting
src/and re-running: the descent regression panics withseed 57: 55 of 8000 nodes are their own true nearest neighbor but unreachable from the entry point; both budget tests return the descent's landing point alone instead of ten hits; the allocation test reports 3 allocations per query on a 2-level graph against 5 on a 4-level one. The descent regression also fails ifDESCENT_EFis lowered — 8 misses at width 8, 20 at width 4 — so the width is pinned, not just the beam.Deterministic width sweep (
descent_width_sweep, ignored by default), one 8000-node build per seed, self-querying every node at ef 256:main)Build throughput, seven paired 100k x 768-d builds, sorted (seconds):
main55.6 / 71.6 / 76.3 / 91.7 / 114.3 / 116.4 / 172.0; here 61.4 / 80.1 / 82.7 / 87.9 / 90.3 / 179.1 / 182.7. Medians differ by 4% in this branch's favour, the least-loaded pair by 10% against it. Before the allocation work the same comparison at 50k was a clear 20% regression (11.8 s against 14.2 s).Suites.
cargo test --release— 49 tests green (21 lib, 1 allocation, 4 concurrent including 1 ignored, 24 reopen), against CI's 20-minute cap on three runners. The added tests are the dominant cost of the suite; they are also the only guards for a defect that DESIGN.md records as not reproducing on any smaller corpus.node build.mjs && node smoke.mjs— green, which is what exercisessearch,searchSyncandsearchWithPredicatethrough the packaged addon rather than the crate.Cost. Visit counts are deterministic, so they are the load-independent measure:
mainmainRecall improves below ef 128 and is unchanged above. The descent is fixed work, so the visit tax is largest where
efis smallest.Latency. The box these ran on is shared with other agents at load average 11-25, so single-shot timings swing by 3x in both directions and are not usable. These are the best of seven repetitions per point, which is the estimate least polluted by other tenants, and the plane files are built once and reused across all reps:
mainThe descent is fixed work, so the cost is a roughly flat +0.06 to +0.11 ms: about half again on a 0.20 ms query at ef 16, and 6% at ef 512.
Out of cache, 200 000 x 768-d (269 MB of slots, one upper level more than 50k, paired back to back):
mainmainmainRecall gains grow with the graph: at 50k the beam buys 0.844 -> 0.903 at ef 16, at 200k it buys 0.675 -> 0.899. A taller hierarchy gives a width-1 descent more places to strand.
Concurrent, 100 000 x 768-d, 8 searcher threads plus a background writer, fourteen alternating base/head runs with machine load recorded per run. Aggregate QPS, sorted:
mainMedians are within 3%. At the lowest observed load the comparison is 8200-9000 QPS on
mainagainst 7100-7800 here, which is the per-query visit increase and nothing beyond it. One run on each side collapsed unexplained and neither could be attributed on a shared box.Complexity: complicated
Review-Coverage: authored=claude; ran=gemini,codex; declined=cursor-grok,cursor-composer,domain; rounds=7 @ 8a5eb71
Human-Review-Need: 3 @ 8a5eb71