@harperfast/hnsw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +229 -0
- package/Cargo.toml +30 -0
- package/DESIGN.md +319 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/build.mjs +46 -0
- package/build.rs +6 -0
- package/index.d.ts +104 -0
- package/index.js +57 -0
- package/package.json +44 -0
- package/prebuilds/darwin-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-x64/hnsw-plane.node +0 -0
- package/prebuilds/win32-x64/hnsw-plane.node +0 -0
- package/src/bin/bench.rs +246 -0
- package/src/distance.rs +138 -0
- package/src/format.rs +556 -0
- package/src/graph.rs +650 -0
- package/src/insert.rs +292 -0
- package/src/lib.rs +15 -0
- package/src/napi.rs +486 -0
- package/src/search.rs +477 -0
- package/src/seqlock.rs +217 -0
package/src/search.rs
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
//! Beam search over the plane, zero-copy: per-visit cost is one seqlock-guarded distance
|
|
2
|
+
//! against mmap bytes plus primitive heap/visited ops. Visited tracking is an epoch-stamped
|
|
3
|
+
//! array; neighbor ids stream through a reusable scratch buffer.
|
|
4
|
+
|
|
5
|
+
use crate::distance::Query;
|
|
6
|
+
use crate::format::NO_ID;
|
|
7
|
+
use crate::graph::Graph;
|
|
8
|
+
use std::cmp::Ordering as CmpOrdering;
|
|
9
|
+
use std::collections::BinaryHeap;
|
|
10
|
+
|
|
11
|
+
#[derive(PartialEq)]
|
|
12
|
+
struct Candidate {
|
|
13
|
+
distance: f32,
|
|
14
|
+
id: u32,
|
|
15
|
+
}
|
|
16
|
+
impl Eq for Candidate {}
|
|
17
|
+
impl Ord for Candidate {
|
|
18
|
+
fn cmp(&self, other: &Self) -> CmpOrdering {
|
|
19
|
+
// min-heap by distance via reverse
|
|
20
|
+
other.distance.partial_cmp(&self.distance).unwrap_or(CmpOrdering::Equal)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
impl PartialOrd for Candidate {
|
|
24
|
+
fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
|
|
25
|
+
Some(self.cmp(other))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[derive(PartialEq)]
|
|
30
|
+
struct Result_ {
|
|
31
|
+
distance: f32,
|
|
32
|
+
id: u32,
|
|
33
|
+
}
|
|
34
|
+
impl Eq for Result_ {}
|
|
35
|
+
impl Ord for Result_ {
|
|
36
|
+
fn cmp(&self, other: &Self) -> CmpOrdering {
|
|
37
|
+
// max-heap by distance (worst result on top for eviction)
|
|
38
|
+
self.distance.partial_cmp(&other.distance).unwrap_or(CmpOrdering::Equal)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
impl PartialOrd for Result_ {
|
|
42
|
+
fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
|
|
43
|
+
Some(self.cmp(other))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Reusable per-thread search scratch.
|
|
48
|
+
pub struct SearchScratch {
|
|
49
|
+
visited: Vec<u32>,
|
|
50
|
+
epoch: u32,
|
|
51
|
+
neighbors: Vec<u32>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
impl SearchScratch {
|
|
55
|
+
pub fn new() -> Self {
|
|
56
|
+
SearchScratch { visited: Vec::new(), epoch: 0, neighbors: Vec::new() }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pub fn begin_public(&mut self, capacity: u64) {
|
|
60
|
+
self.begin(capacity)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fn begin(&mut self, capacity: u64) {
|
|
64
|
+
if self.visited.len() < capacity as usize {
|
|
65
|
+
self.visited.resize(capacity as usize, 0);
|
|
66
|
+
}
|
|
67
|
+
self.epoch = self.epoch.wrapping_add(1);
|
|
68
|
+
if self.epoch == 0 {
|
|
69
|
+
self.visited.fill(0);
|
|
70
|
+
self.epoch = 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#[inline]
|
|
75
|
+
fn visit(&mut self, id: u32) -> bool {
|
|
76
|
+
// ids minted by concurrent inserts after begin() can exceed the sizing snapshot;
|
|
77
|
+
// growth is bounded by the id itself, which write paths bound by max_nodes
|
|
78
|
+
if id as usize >= self.visited.len() {
|
|
79
|
+
self.visited.resize(id as usize + 1024, 0);
|
|
80
|
+
}
|
|
81
|
+
let slot = &mut self.visited[id as usize];
|
|
82
|
+
if *slot == self.epoch {
|
|
83
|
+
false
|
|
84
|
+
} else {
|
|
85
|
+
*slot = self.epoch;
|
|
86
|
+
true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl Default for SearchScratch {
|
|
92
|
+
fn default() -> Self {
|
|
93
|
+
Self::new()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub struct SearchStats {
|
|
98
|
+
pub visits: u64,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#[inline]
|
|
102
|
+
fn bit_allowed(filter: Option<&[u8]>, id: u32) -> bool {
|
|
103
|
+
match filter {
|
|
104
|
+
None => true,
|
|
105
|
+
Some(bits) => {
|
|
106
|
+
let byte = (id >> 3) as usize;
|
|
107
|
+
byte < bits.len() && bits[byte] & (1 << (id & 7)) != 0
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Beam search within one layer, starting from `entry`. Level 0 reads slot adjacency;
|
|
113
|
+
/// upper levels read the resident upper map. Returns (id, distance) ascending by distance.
|
|
114
|
+
/// Assumes scratch.begin() was called for this query; entry is marked visited here.
|
|
115
|
+
///
|
|
116
|
+
/// `filter`: optional allow-bitset over node ids (bit i of byte i>>3). Filtered-out nodes
|
|
117
|
+
/// are traversed (their edges route) but excluded from results — ACORN-style — with
|
|
118
|
+
/// `visit_budget` bounding total visits so a selective filter terminates.
|
|
119
|
+
pub fn search_layer(
|
|
120
|
+
graph: &Graph,
|
|
121
|
+
query: &Query,
|
|
122
|
+
entry: u32,
|
|
123
|
+
entry_dist: f32,
|
|
124
|
+
ef: usize,
|
|
125
|
+
level: u8,
|
|
126
|
+
scratch: &mut SearchScratch,
|
|
127
|
+
stats: &mut SearchStats,
|
|
128
|
+
filter: Option<&[u8]>,
|
|
129
|
+
visit_budget: u64,
|
|
130
|
+
) -> Vec<(u32, f32)> {
|
|
131
|
+
let mut candidates = BinaryHeap::new();
|
|
132
|
+
let mut results: BinaryHeap<Result_> = BinaryHeap::new();
|
|
133
|
+
scratch.visit(entry);
|
|
134
|
+
candidates.push(Candidate { distance: entry_dist, id: entry });
|
|
135
|
+
if bit_allowed(filter, entry) {
|
|
136
|
+
results.push(Result_ { distance: entry_dist, id: entry });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// take() the scratch neighbor buffer to sidestep the double-borrow of scratch
|
|
140
|
+
let mut nbuf = std::mem::take(&mut scratch.neighbors);
|
|
141
|
+
|
|
142
|
+
while let Some(c) = candidates.pop() {
|
|
143
|
+
let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
|
|
144
|
+
if results.len() >= ef && c.distance > worst {
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
if stats.visits >= visit_budget {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
if level == 0 {
|
|
151
|
+
if graph.neighbors_into(c.id, &mut nbuf).is_none() {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
graph.upper_neighbors_into(c.id, level, &mut nbuf);
|
|
156
|
+
}
|
|
157
|
+
for i in 0..nbuf.len() {
|
|
158
|
+
let nid = nbuf[i];
|
|
159
|
+
if (nid as u64) >= graph.file.max_nodes {
|
|
160
|
+
continue; // corrupt/torn neighbor id: skip rather than size allocations by it
|
|
161
|
+
}
|
|
162
|
+
if !scratch.visit(nid) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if let Some(d) = graph.distance_to(nid, query) {
|
|
166
|
+
stats.visits += 1;
|
|
167
|
+
let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
|
|
168
|
+
if results.len() < ef || d < worst {
|
|
169
|
+
candidates.push(Candidate { distance: d, id: nid });
|
|
170
|
+
if bit_allowed(filter, nid) {
|
|
171
|
+
results.push(Result_ { distance: d, id: nid });
|
|
172
|
+
if results.len() > ef {
|
|
173
|
+
results.pop();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
scratch.neighbors = nbuf;
|
|
181
|
+
|
|
182
|
+
let mut out: Vec<(u32, f32)> = results.into_iter().map(|r| (r.id, r.distance)).collect();
|
|
183
|
+
out.sort_by(|a, b| a.1.total_cmp(&b.1));
|
|
184
|
+
out
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Greedy single-candidate descent through upper layers from `from_level` down to
|
|
188
|
+
/// `to_level` (exclusive lower bound handled by caller loops). Returns improved entry.
|
|
189
|
+
pub fn greedy_descend(
|
|
190
|
+
graph: &Graph,
|
|
191
|
+
query: &Query,
|
|
192
|
+
mut current: u32,
|
|
193
|
+
mut current_dist: f32,
|
|
194
|
+
from_level: u32,
|
|
195
|
+
to_level: u32,
|
|
196
|
+
stats: &mut SearchStats,
|
|
197
|
+
) -> (u32, f32) {
|
|
198
|
+
let mut nbuf: Vec<u32> = Vec::new();
|
|
199
|
+
let mut level = from_level;
|
|
200
|
+
while level > to_level {
|
|
201
|
+
let mut improved = true;
|
|
202
|
+
while improved {
|
|
203
|
+
improved = false;
|
|
204
|
+
graph.upper_neighbors_into(current, level.min(255) as u8, &mut nbuf);
|
|
205
|
+
for i in 0..nbuf.len() {
|
|
206
|
+
let nid = nbuf[i];
|
|
207
|
+
if let Some(d) = graph.distance_to(nid, query) {
|
|
208
|
+
stats.visits += 1;
|
|
209
|
+
if d < current_dist {
|
|
210
|
+
current = nid;
|
|
211
|
+
current_dist = d;
|
|
212
|
+
improved = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
level -= 1;
|
|
218
|
+
}
|
|
219
|
+
(current, current_dist)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// Full search: greedy descent through upper layers, then beam at layer 0.
|
|
223
|
+
pub fn search(
|
|
224
|
+
graph: &Graph,
|
|
225
|
+
query: &Query,
|
|
226
|
+
k: usize,
|
|
227
|
+
ef: usize,
|
|
228
|
+
scratch: &mut SearchScratch,
|
|
229
|
+
) -> (Vec<(u32, f32)>, SearchStats) {
|
|
230
|
+
let mut stats = SearchStats { visits: 0 };
|
|
231
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
232
|
+
if entry_id == NO_ID {
|
|
233
|
+
return (Vec::new(), stats);
|
|
234
|
+
}
|
|
235
|
+
scratch.begin(graph.file.id_high_water());
|
|
236
|
+
|
|
237
|
+
let entry_dist = match graph.distance_to(entry_id, query) {
|
|
238
|
+
Some(d) => {
|
|
239
|
+
stats.visits += 1;
|
|
240
|
+
d
|
|
241
|
+
}
|
|
242
|
+
None => return (Vec::new(), stats),
|
|
243
|
+
};
|
|
244
|
+
let (ep, ep_dist) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
|
|
245
|
+
let mut out = search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, None, u64::MAX);
|
|
246
|
+
out.truncate(k);
|
|
247
|
+
(out, stats)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Full search with an optional allow-bitset filter. `filter_expansion` multiplies ef into
|
|
251
|
+
/// the visit budget when a filter is present (matching the JS filterExpansion semantics).
|
|
252
|
+
pub fn search_filtered(
|
|
253
|
+
graph: &Graph,
|
|
254
|
+
query: &Query,
|
|
255
|
+
k: usize,
|
|
256
|
+
ef: usize,
|
|
257
|
+
filter: Option<&[u8]>,
|
|
258
|
+
filter_expansion: usize,
|
|
259
|
+
scratch: &mut SearchScratch,
|
|
260
|
+
) -> (Vec<(u32, f32)>, SearchStats) {
|
|
261
|
+
let mut stats = SearchStats { visits: 0 };
|
|
262
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
263
|
+
if entry_id == NO_ID {
|
|
264
|
+
return (Vec::new(), stats);
|
|
265
|
+
}
|
|
266
|
+
scratch.begin_public(graph.file.id_high_water());
|
|
267
|
+
let entry_dist = match graph.distance_to(entry_id, query) {
|
|
268
|
+
Some(d) => {
|
|
269
|
+
stats.visits += 1;
|
|
270
|
+
d
|
|
271
|
+
}
|
|
272
|
+
None => return (Vec::new(), stats),
|
|
273
|
+
};
|
|
274
|
+
let (ep, ep_dist) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
|
|
275
|
+
let budget = if filter.is_some() { (ef * filter_expansion) as u64 } else { u64::MAX };
|
|
276
|
+
let mut out = search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, filter, budget);
|
|
277
|
+
out.truncate(k);
|
|
278
|
+
(out, stats)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/// Pipelined predicate filtering: candidate ids are batched to an external evaluator (the
|
|
282
|
+
/// NAPI layer wires this to a JS ThreadsafeFunction) while traversal continues expanding —
|
|
283
|
+
/// the search thread never blocks on the evaluator until the beam itself is done. Verdicts
|
|
284
|
+
/// steer result admission only; routing uses pure distance order, bounded by the visit
|
|
285
|
+
/// budget, so a slow or saturated JS loop degrades speculative overshoot, not correctness.
|
|
286
|
+
pub struct PredicatePipe {
|
|
287
|
+
/// Sends one batch of ids for evaluation. Must not block.
|
|
288
|
+
pub dispatch: Box<dyn FnMut(Vec<u32>) + Send>,
|
|
289
|
+
/// Receives (ids, verdicts) pairs; verdicts[i] != 0 admits ids[i].
|
|
290
|
+
pub rx: std::sync::mpsc::Receiver<(Vec<u32>, Vec<u8>)>,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const PREDICATE_BATCH: usize = 64;
|
|
294
|
+
const DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
295
|
+
|
|
296
|
+
/// Full search with a pipelined predicate filter (upper-layer descent is unfiltered, as in
|
|
297
|
+
/// the JS implementation — predicates gate results, not routing). `visit_budget` is the
|
|
298
|
+
/// absolute layer-0 visit cap: hosts pass their own resolved budget directly, since a
|
|
299
|
+
/// multiplier-of-ef encoding cannot express a budget below ef.
|
|
300
|
+
pub fn search_predicated(
|
|
301
|
+
graph: &Graph,
|
|
302
|
+
query: &Query,
|
|
303
|
+
k: usize,
|
|
304
|
+
ef: usize,
|
|
305
|
+
pipe: &mut PredicatePipe,
|
|
306
|
+
visit_budget: u64,
|
|
307
|
+
scratch: &mut SearchScratch,
|
|
308
|
+
) -> (Vec<(u32, f32)>, SearchStats) {
|
|
309
|
+
let mut stats = SearchStats { visits: 0 };
|
|
310
|
+
let (entry_id, entry_level) = graph.file.entry_point();
|
|
311
|
+
if entry_id == NO_ID {
|
|
312
|
+
return (Vec::new(), stats);
|
|
313
|
+
}
|
|
314
|
+
scratch.begin_public(graph.file.id_high_water());
|
|
315
|
+
let entry_dist = match graph.distance_to(entry_id, query) {
|
|
316
|
+
Some(d) => {
|
|
317
|
+
stats.visits += 1;
|
|
318
|
+
d
|
|
319
|
+
}
|
|
320
|
+
None => return (Vec::new(), stats),
|
|
321
|
+
};
|
|
322
|
+
let (ep, ep_dist) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
|
|
323
|
+
|
|
324
|
+
use std::collections::HashMap;
|
|
325
|
+
let mut verdicts: HashMap<u32, bool> = HashMap::new();
|
|
326
|
+
let mut speculative: Vec<(u32, f32)> = Vec::new(); // awaiting verdicts
|
|
327
|
+
let mut batch: Vec<u32> = Vec::new();
|
|
328
|
+
let mut outstanding = 0usize;
|
|
329
|
+
|
|
330
|
+
let mut candidates = BinaryHeap::new();
|
|
331
|
+
let mut results: BinaryHeap<Result_> = BinaryHeap::new();
|
|
332
|
+
scratch.visit(ep);
|
|
333
|
+
candidates.push(Candidate { distance: ep_dist, id: ep });
|
|
334
|
+
speculative.push((ep, ep_dist));
|
|
335
|
+
batch.push(ep);
|
|
336
|
+
|
|
337
|
+
let mut nbuf = std::mem::take(&mut scratch.neighbors);
|
|
338
|
+
|
|
339
|
+
macro_rules! drain {
|
|
340
|
+
($recv:expr) => {
|
|
341
|
+
while let Ok((ids, flags)) = $recv {
|
|
342
|
+
outstanding -= 1;
|
|
343
|
+
for (i, id) in ids.iter().enumerate() {
|
|
344
|
+
verdicts.insert(*id, flags.get(i).copied().unwrap_or(0) != 0);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
loop {
|
|
351
|
+
// non-blocking verdict intake each iteration
|
|
352
|
+
drain!(pipe.rx.try_recv());
|
|
353
|
+
if !verdicts.is_empty() && !speculative.is_empty() {
|
|
354
|
+
speculative.retain(|&(id, d)| match verdicts.get(&id) {
|
|
355
|
+
Some(true) => {
|
|
356
|
+
results.push(Result_ { distance: d, id });
|
|
357
|
+
if results.len() > ef {
|
|
358
|
+
results.pop();
|
|
359
|
+
}
|
|
360
|
+
false
|
|
361
|
+
}
|
|
362
|
+
Some(false) => false,
|
|
363
|
+
None => true,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let Some(c) = candidates.pop() else { break };
|
|
368
|
+
let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
|
|
369
|
+
if results.len() >= ef && c.distance > worst {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
if stats.visits >= visit_budget {
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
if graph.neighbors_into(c.id, &mut nbuf).is_none() {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
for i in 0..nbuf.len() {
|
|
379
|
+
let nid = nbuf[i];
|
|
380
|
+
if (nid as u64) >= graph.file.max_nodes {
|
|
381
|
+
continue; // corrupt/torn neighbor id: skip rather than size allocations by it
|
|
382
|
+
}
|
|
383
|
+
if !scratch.visit(nid) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if let Some(d) = graph.distance_to(nid, query) {
|
|
387
|
+
stats.visits += 1;
|
|
388
|
+
let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
|
|
389
|
+
if results.len() < ef || d < worst {
|
|
390
|
+
candidates.push(Candidate { distance: d, id: nid });
|
|
391
|
+
speculative.push((nid, d));
|
|
392
|
+
batch.push(nid);
|
|
393
|
+
if batch.len() >= PREDICATE_BATCH {
|
|
394
|
+
(pipe.dispatch)(std::mem::take(&mut batch));
|
|
395
|
+
outstanding += 1;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
scratch.neighbors = nbuf;
|
|
402
|
+
|
|
403
|
+
// flush the tail batch and block-drain what's still in flight
|
|
404
|
+
if !batch.is_empty() {
|
|
405
|
+
(pipe.dispatch)(std::mem::take(&mut batch));
|
|
406
|
+
outstanding += 1;
|
|
407
|
+
}
|
|
408
|
+
let deadline = std::time::Instant::now() + DRAIN_TIMEOUT;
|
|
409
|
+
while outstanding > 0 && std::time::Instant::now() < deadline {
|
|
410
|
+
drain!(pipe.rx.recv_timeout(std::time::Duration::from_millis(50)));
|
|
411
|
+
}
|
|
412
|
+
speculative.retain(|&(id, d)| {
|
|
413
|
+
if verdicts.get(&id).copied().unwrap_or(false) {
|
|
414
|
+
results.push(Result_ { distance: d, id });
|
|
415
|
+
if results.len() > ef {
|
|
416
|
+
results.pop();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
false
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
let mut out: Vec<(u32, f32)> = results.into_iter().map(|r| (r.id, r.distance)).collect();
|
|
423
|
+
out.sort_by(|a, b| a.1.total_cmp(&b.1));
|
|
424
|
+
out.truncate(k);
|
|
425
|
+
(out, stats)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#[cfg(test)]
|
|
429
|
+
mod predicate_tests {
|
|
430
|
+
use super::*;
|
|
431
|
+
use crate::insert::{insert, InsertParams};
|
|
432
|
+
use crate::PlaneFile;
|
|
433
|
+
|
|
434
|
+
#[test]
|
|
435
|
+
fn pipelined_predicate_filters_results() {
|
|
436
|
+
let dims = 32;
|
|
437
|
+
let path = std::env::temp_dir().join(format!("hnsw-pred-{}.hnsw", std::process::id()));
|
|
438
|
+
let _ = std::fs::remove_file(&path);
|
|
439
|
+
let file = PlaneFile::create(&path, dims, 16, 4_096).expect("create");
|
|
440
|
+
let graph = Graph::new(file);
|
|
441
|
+
let params = InsertParams::default();
|
|
442
|
+
let mut scratch = SearchScratch::new();
|
|
443
|
+
for i in 0..1_000u32 {
|
|
444
|
+
let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
445
|
+
insert(&graph, &v, ¶ms, &mut scratch).unwrap();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// evaluator thread: admit even ids only, answering over a channel like the TSFN does
|
|
449
|
+
let (req_tx, req_rx) = std::sync::mpsc::channel::<Vec<u32>>();
|
|
450
|
+
let (res_tx, res_rx) = std::sync::mpsc::channel::<(Vec<u32>, Vec<u8>)>();
|
|
451
|
+
let worker = std::thread::spawn(move || {
|
|
452
|
+
while let Ok(ids) = req_rx.recv() {
|
|
453
|
+
let verdicts: Vec<u8> = ids.iter().map(|id| (id % 2 == 0) as u8).collect();
|
|
454
|
+
if res_tx.send((ids, verdicts)).is_err() {
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
let mut pipe = PredicatePipe {
|
|
461
|
+
dispatch: Box::new(move |ids| {
|
|
462
|
+
let _ = req_tx.send(ids);
|
|
463
|
+
}),
|
|
464
|
+
rx: res_rx,
|
|
465
|
+
};
|
|
466
|
+
let q: Vec<f32> = (0..dims).map(|d| ((41.0f32 * 0.31 + d as f32) * 0.7).sin()).collect();
|
|
467
|
+
let (hits, _) =
|
|
468
|
+
search_predicated(&graph, &Query::new(q), 10, 64, &mut pipe, 64 * 24, &mut scratch);
|
|
469
|
+
assert!(!hits.is_empty());
|
|
470
|
+
for (id, _) in &hits {
|
|
471
|
+
assert_eq!(id % 2, 0, "odd id {id} leaked through the predicate");
|
|
472
|
+
}
|
|
473
|
+
drop(pipe);
|
|
474
|
+
worker.join().unwrap();
|
|
475
|
+
let _ = std::fs::remove_file(&path);
|
|
476
|
+
}
|
|
477
|
+
}
|
package/src/seqlock.rs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
//! Per-slot lock with owner identity. The lock word is a u32: bit 31 set = locked, low 31
|
|
2
|
+
//! bits = the owner handle's registry tag (see format.rs); unlocked values are generations
|
|
3
|
+
//! (bit 31 clear) that change on every release, so readers validate a consistent snapshot
|
|
4
|
+
//! seqlock-style.
|
|
5
|
+
//!
|
|
6
|
+
//! Crash recovery happens at the contended slot: a waiter that has watched the SAME locked
|
|
7
|
+
//! value for a full window asks `owner_dead(tag)` — implemented over kernel-owned file
|
|
8
|
+
//! locks that die with the owner's open handle, so it is immune to pid reuse, container
|
|
9
|
+
//! pid-1 restarts, and pid namespaces. Only a provably dead owner is taken over, and the
|
|
10
|
+
//! taker first runs `sanitize` (marking the payload deleted): a dead writer's payload is
|
|
11
|
+
//! half-written and must read as absent until rewritten. When liveness is unknowable
|
|
12
|
+
//! (non-Linux platforms, an unregistered handle), readers return `fallback()` after the
|
|
13
|
+
//! window instead of waiting forever, and writers keep waiting.
|
|
14
|
+
|
|
15
|
+
use std::sync::atomic::{AtomicU32, Ordering};
|
|
16
|
+
use std::time::{Duration, Instant};
|
|
17
|
+
|
|
18
|
+
pub const LOCKED: u32 = 1 << 31;
|
|
19
|
+
pub const GEN_MASK: u32 = LOCKED - 1;
|
|
20
|
+
/// A lock value decomposes as: bit 31 LOCKED | salt(13 bits) | handle identity(19 bits).
|
|
21
|
+
/// The identity — registry slot (6 bits) + the handle's per-open epoch (12 bits) — is what
|
|
22
|
+
/// liveness is keyed on; the salt changes every acquisition so back-to-back writers from ONE
|
|
23
|
+
/// handle still change the observed value (a waiter that never sees an unlocked window must
|
|
24
|
+
/// still see progress, or healthy same-handle churn would trip the wedge bound).
|
|
25
|
+
pub const TAG_MASK: u32 = (1 << 19) - 1;
|
|
26
|
+
|
|
27
|
+
#[inline]
|
|
28
|
+
fn acquisition_value(self_tag: u32) -> u32 {
|
|
29
|
+
use std::cell::Cell;
|
|
30
|
+
use std::sync::atomic::AtomicU32 as GlobalCounter;
|
|
31
|
+
// each thread's salt stream starts at a globally unique offset — identical thread-local
|
|
32
|
+
// streams across threads could publish identical lock values, making back-to-back
|
|
33
|
+
// acquisitions indistinguishable from one long hold
|
|
34
|
+
static NEXT_STREAM: GlobalCounter = GlobalCounter::new(1);
|
|
35
|
+
thread_local! {
|
|
36
|
+
static SALT: Cell<u32> = Cell::new(NEXT_STREAM.fetch_add(0x2545_f491, Ordering::Relaxed));
|
|
37
|
+
}
|
|
38
|
+
let salt = SALT.with(|c| {
|
|
39
|
+
let v = c.get().wrapping_add(1);
|
|
40
|
+
c.set(v);
|
|
41
|
+
v
|
|
42
|
+
});
|
|
43
|
+
LOCKED | (((salt << 19) | (self_tag & TAG_MASK)) & GEN_MASK)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// How long a locked value must stay unchanged before the owner's liveness is checked.
|
|
47
|
+
const TAKEOVER_AFTER: Duration = Duration::from_millis(20);
|
|
48
|
+
/// Hard bound on waiting for a lock this thread cannot reclaim (owner alive-or-unknowable:
|
|
49
|
+
/// an unregistered handle's abandoned lock, a deadlocked live thread). A live writer's
|
|
50
|
+
/// critical section is microseconds, so five seconds of one unchanged locked value means
|
|
51
|
+
/// the slot is wedged — surfacing an error beats hanging a caller forever.
|
|
52
|
+
const WRITE_WEDGE_AFTER: Duration = Duration::from_secs(5);
|
|
53
|
+
|
|
54
|
+
/// The slot's lock could not be acquired or reclaimed within the wedge bound.
|
|
55
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
56
|
+
pub struct Wedged;
|
|
57
|
+
const SPINS_BEFORE_CLOCK: u32 = 1 << 10;
|
|
58
|
+
|
|
59
|
+
/// A fresh generation for a takeover release: the previous generation is unknowable, so it
|
|
60
|
+
/// must be a value no in-flight reader plausibly holds as its first snapshot.
|
|
61
|
+
#[inline]
|
|
62
|
+
fn fresh_generation() -> u32 {
|
|
63
|
+
let nanos = std::time::SystemTime::now()
|
|
64
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
65
|
+
.map(|d| d.subsec_nanos())
|
|
66
|
+
.unwrap_or(0);
|
|
67
|
+
(nanos ^ (std::process::id() << 10)) & GEN_MASK
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
pub struct SeqWriteGuard<'a> {
|
|
71
|
+
seq: &'a AtomicU32,
|
|
72
|
+
release_gen: u32,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl Drop for SeqWriteGuard<'_> {
|
|
76
|
+
fn drop(&mut self) {
|
|
77
|
+
self.seq.store(self.release_gen, Ordering::Release);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
enum Stale {
|
|
82
|
+
No,
|
|
83
|
+
DeadOwner(u32),
|
|
84
|
+
UnknownPastWindow,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Track how long one locked value has been observed; decide staleness.
|
|
88
|
+
struct StaleWatch {
|
|
89
|
+
seen: u32,
|
|
90
|
+
since: Option<Instant>,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
impl StaleWatch {
|
|
94
|
+
fn new() -> Self {
|
|
95
|
+
StaleWatch { seen: 0, since: None }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fn observe(&mut self, locked_value: u32, owner_dead: &impl Fn(u32) -> bool) -> Stale {
|
|
99
|
+
if self.seen != locked_value || self.since.is_none() {
|
|
100
|
+
self.seen = locked_value;
|
|
101
|
+
self.since = Some(Instant::now());
|
|
102
|
+
return Stale::No;
|
|
103
|
+
}
|
|
104
|
+
if self.since.map(|at| at.elapsed() < TAKEOVER_AFTER).unwrap_or(true) {
|
|
105
|
+
return Stale::No;
|
|
106
|
+
}
|
|
107
|
+
if owner_dead(locked_value & GEN_MASK) {
|
|
108
|
+
Stale::DeadOwner(locked_value)
|
|
109
|
+
} else {
|
|
110
|
+
Stale::UnknownPastWindow
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Acquire write ownership of a slot. `self_tag` identifies this handle in the lock word;
|
|
116
|
+
/// `sanitize` runs (holding the lock) only after a takeover from a dead owner; `owner_dead`
|
|
117
|
+
/// decides takeover eligibility. A lock held past the window by an owner that is alive or
|
|
118
|
+
/// unknowable is simply waited on.
|
|
119
|
+
pub fn write_lock<'a>(
|
|
120
|
+
seq: &'a AtomicU32,
|
|
121
|
+
self_tag: u32,
|
|
122
|
+
sanitize: impl Fn(),
|
|
123
|
+
owner_dead: impl Fn(u32) -> bool,
|
|
124
|
+
) -> Result<SeqWriteGuard<'a>, Wedged> {
|
|
125
|
+
let mut spins = 0u32;
|
|
126
|
+
let mut watch = StaleWatch::new();
|
|
127
|
+
let mut wedged_since: Option<Instant> = None;
|
|
128
|
+
loop {
|
|
129
|
+
let cur = seq.load(Ordering::Acquire);
|
|
130
|
+
if cur & LOCKED == 0 {
|
|
131
|
+
if seq
|
|
132
|
+
.compare_exchange_weak(cur, acquisition_value(self_tag), Ordering::AcqRel, Ordering::Acquire)
|
|
133
|
+
.is_ok()
|
|
134
|
+
{
|
|
135
|
+
return Ok(SeqWriteGuard { seq, release_gen: cur.wrapping_add(1) & GEN_MASK });
|
|
136
|
+
}
|
|
137
|
+
wedged_since = None;
|
|
138
|
+
} else {
|
|
139
|
+
spins += 1;
|
|
140
|
+
if spins > SPINS_BEFORE_CLOCK {
|
|
141
|
+
match watch.observe(cur, &owner_dead) {
|
|
142
|
+
Stale::DeadOwner(observed) => {
|
|
143
|
+
if seq
|
|
144
|
+
.compare_exchange(observed, acquisition_value(self_tag), Ordering::AcqRel, Ordering::Acquire)
|
|
145
|
+
.is_ok()
|
|
146
|
+
{
|
|
147
|
+
sanitize();
|
|
148
|
+
return Ok(SeqWriteGuard { seq, release_gen: fresh_generation() });
|
|
149
|
+
}
|
|
150
|
+
wedged_since = None;
|
|
151
|
+
}
|
|
152
|
+
Stale::UnknownPastWindow => {
|
|
153
|
+
// unreclaimable (unregistered owner tag, or an alive-but-stuck
|
|
154
|
+
// holder): bounded wait, then surface the wedge instead of hanging
|
|
155
|
+
let since = *wedged_since.get_or_insert_with(Instant::now);
|
|
156
|
+
if since.elapsed() > WRITE_WEDGE_AFTER {
|
|
157
|
+
return Err(Wedged);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// the lock VALUE moved: owners are cycling, i.e. real progress — a busy
|
|
161
|
+
// slot must never trip the wedge bound
|
|
162
|
+
Stale::No => wedged_since = None,
|
|
163
|
+
}
|
|
164
|
+
std::thread::yield_now();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
std::hint::spin_loop();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Run `read` until it observes a stable (unlocked, unchanged) generation. `read` must be
|
|
173
|
+
/// side-effect-free on retry. A dead owner's lock is taken over (sanitizing the payload)
|
|
174
|
+
/// and the read retried; an alive-or-unknowable owner past the window makes this return
|
|
175
|
+
/// `fallback()` rather than stall a search indefinitely.
|
|
176
|
+
#[inline]
|
|
177
|
+
pub fn read_consistent<T>(
|
|
178
|
+
seq: &AtomicU32,
|
|
179
|
+
self_tag: u32,
|
|
180
|
+
mut read: impl FnMut() -> T,
|
|
181
|
+
sanitize: impl Fn(),
|
|
182
|
+
fallback: impl FnOnce() -> T,
|
|
183
|
+
owner_dead: impl Fn(u32) -> bool,
|
|
184
|
+
) -> T {
|
|
185
|
+
let mut spins = 0u32;
|
|
186
|
+
let mut watch = StaleWatch::new();
|
|
187
|
+
loop {
|
|
188
|
+
let before = seq.load(Ordering::Acquire);
|
|
189
|
+
if before & LOCKED == 0 {
|
|
190
|
+
let value = read();
|
|
191
|
+
std::sync::atomic::fence(Ordering::Acquire);
|
|
192
|
+
if seq.load(Ordering::Relaxed) == before {
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
spins += 1;
|
|
197
|
+
if spins > SPINS_BEFORE_CLOCK {
|
|
198
|
+
match watch.observe(before, &owner_dead) {
|
|
199
|
+
Stale::DeadOwner(observed) => {
|
|
200
|
+
if seq
|
|
201
|
+
.compare_exchange(observed, acquisition_value(self_tag), Ordering::AcqRel, Ordering::Acquire)
|
|
202
|
+
.is_ok()
|
|
203
|
+
{
|
|
204
|
+
sanitize();
|
|
205
|
+
seq.store(fresh_generation(), Ordering::Release);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
Stale::UnknownPastWindow => return fallback(),
|
|
209
|
+
Stale::No => {}
|
|
210
|
+
}
|
|
211
|
+
std::thread::yield_now();
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
std::hint::spin_loop();
|
|
216
|
+
}
|
|
217
|
+
}
|