@harperfast/hnsw 0.1.0 → 0.2.1
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 +1 -0
- package/DESIGN.md +95 -9
- package/README.md +16 -6
- package/build.mjs +7 -6
- package/index.d.ts +42 -1
- package/index.js +39 -18
- package/package.json +8 -3
- package/src/bin/bench.rs +1 -1
- package/src/format.rs +241 -19
- package/src/graph.rs +263 -69
- package/src/insert.rs +141 -40
- package/src/invalidate.rs +336 -0
- package/src/lib.rs +3 -1
- package/src/napi.rs +92 -4
- package/src/search.rs +413 -87
- 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/graph.rs
CHANGED
|
@@ -6,14 +6,43 @@
|
|
|
6
6
|
|
|
7
7
|
use crate::distance::{cosine_i8_i8_raw, cosine_int8_raw, Query};
|
|
8
8
|
use crate::format::{
|
|
9
|
-
PlaneFile, FLAG_DELETED, FLAG_VALID, MAX_UPPER_LEVELS, NO_UPPER, S_DEGREE, S_FLAGS, S_INV_MAG,
|
|
10
|
-
S_UPPER_IDX, S_VECTOR, UPPER_CAP, UPPER_LEVEL_STRIDE, U_LEVELS, U_LISTS,
|
|
9
|
+
neighbor_offset, PlaneFile, FLAG_DELETED, FLAG_VALID, MAX_UPPER_LEVELS, NO_UPPER, S_DEGREE, S_FLAGS, S_INV_MAG,
|
|
10
|
+
S_LEVEL, S_SCALE, S_UPPER_IDX, S_VECTOR, UPPER_CAP, UPPER_LEVEL_STRIDE, UL_DEGREE, UL_IDS, U_LEVELS, U_LISTS,
|
|
11
11
|
};
|
|
12
12
|
use crate::seqlock;
|
|
13
13
|
use crate::seqlock::Wedged;
|
|
14
14
|
|
|
15
|
+
/// Aligned volatile load of a slot/upper-entry field another process may be mutating.
|
|
16
|
+
///
|
|
17
|
+
/// This forbids the optimizer from duplicating, splitting, or sinking the load across the
|
|
18
|
+
/// seqlock's validating fence, which would let a reader act on bytes the generation check
|
|
19
|
+
/// never covered. It does NOT make the access race-free under Rust's memory model — only
|
|
20
|
+
/// atomics would, and that is the format change DESIGN.md §10 records as
|
|
21
|
+
/// follow-up. The vector is deliberately not read this way: `cosine_int8_raw` must stay
|
|
22
|
+
/// autovectorized, and a torn vector only perturbs a distance the generation check discards.
|
|
23
|
+
/// Every field read here is naturally aligned (slots are 64-aligned; the neighbor and upper
|
|
24
|
+
/// id arrays are 4-padded by format.rs), so these compile to single loads.
|
|
25
|
+
#[inline(always)]
|
|
26
|
+
unsafe fn vread<T: Copy>(p: *const T) -> T {
|
|
27
|
+
p.read_volatile()
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
pub struct Graph {
|
|
16
31
|
pub file: PlaneFile,
|
|
32
|
+
/// Rotates `probe_for_entry`'s starting offset so this plane's consecutive repairs sample
|
|
33
|
+
/// different ids. Per handle, not per process: a shared counter is advanced by every other
|
|
34
|
+
/// plane's repairs too, so one plane's calls can land on a single residue indefinitely —
|
|
35
|
+
/// which is the coverage the rotation exists to provide.
|
|
36
|
+
probe_rotation: std::sync::atomic::AtomicU32,
|
|
37
|
+
/// (high-water, write epoch) at which this handle's last `stride` consecutive probes all
|
|
38
|
+
/// came back empty, so a fully dead graph stops paying the probe; any handle's node write
|
|
39
|
+
/// bumps the header epoch and re-arms it.
|
|
40
|
+
probe_futile_hw: std::sync::atomic::AtomicU64,
|
|
41
|
+
probe_futile_epoch: std::sync::atomic::AtomicU64,
|
|
42
|
+
probe_futile_runs: std::sync::atomic::AtomicU32,
|
|
43
|
+
/// One repair probe at a time per handle: concurrent searches on the pool would each pay
|
|
44
|
+
/// the full walk before one of them publishes.
|
|
45
|
+
probe_in_flight: std::sync::atomic::AtomicBool,
|
|
17
46
|
}
|
|
18
47
|
|
|
19
48
|
/// A consistent full copy of one node (construction paths only; search uses zero-copy).
|
|
@@ -27,7 +56,19 @@ pub struct NodeRead {
|
|
|
27
56
|
|
|
28
57
|
impl Graph {
|
|
29
58
|
pub fn new(file: PlaneFile) -> Self {
|
|
30
|
-
Graph {
|
|
59
|
+
Graph {
|
|
60
|
+
file,
|
|
61
|
+
probe_rotation: std::sync::atomic::AtomicU32::new(0),
|
|
62
|
+
probe_futile_hw: std::sync::atomic::AtomicU64::new(u64::MAX),
|
|
63
|
+
probe_futile_epoch: std::sync::atomic::AtomicU64::new(u64::MAX),
|
|
64
|
+
probe_futile_runs: std::sync::atomic::AtomicU32::new(0),
|
|
65
|
+
probe_in_flight: std::sync::atomic::AtomicBool::new(false),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[inline]
|
|
70
|
+
fn node_written(&self) {
|
|
71
|
+
self.file.bump_write_epoch();
|
|
31
72
|
}
|
|
32
73
|
|
|
33
74
|
#[inline]
|
|
@@ -67,12 +108,12 @@ impl Graph {
|
|
|
67
108
|
seqlock::read_consistent(seq, self.file.self_tag, || {
|
|
68
109
|
let p = self.file.slot_ptr(id);
|
|
69
110
|
unsafe {
|
|
70
|
-
let flags =
|
|
111
|
+
let flags = vread(p.add(S_FLAGS));
|
|
71
112
|
if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
|
|
72
113
|
return None;
|
|
73
114
|
}
|
|
74
|
-
let scale = (p.add(S_SCALE) as *const f32)
|
|
75
|
-
let inv_mag = (p.add(S_INV_MAG) as *const f32)
|
|
115
|
+
let scale = vread(p.add(S_SCALE) as *const f32);
|
|
116
|
+
let inv_mag = vread(p.add(S_INV_MAG) as *const f32);
|
|
76
117
|
Some(cosine_int8_raw(query, p.add(S_VECTOR) as *const i8, scale, inv_mag))
|
|
77
118
|
}
|
|
78
119
|
}, self.slot_sanitizer(id), || None, self.owner_dead())
|
|
@@ -119,20 +160,20 @@ impl Graph {
|
|
|
119
160
|
}
|
|
120
161
|
let seq = self.file.seq_atomic(id);
|
|
121
162
|
let cap = self.file.layer0_cap;
|
|
122
|
-
let
|
|
163
|
+
let nbase = neighbor_offset(self.file.dims);
|
|
123
164
|
seqlock::read_consistent(seq, self.file.self_tag, || {
|
|
124
165
|
out.clear();
|
|
125
166
|
let p = self.file.slot_ptr(id);
|
|
126
167
|
unsafe {
|
|
127
|
-
let flags =
|
|
168
|
+
let flags = vread(p.add(S_FLAGS));
|
|
128
169
|
if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
|
|
129
170
|
return None;
|
|
130
171
|
}
|
|
131
|
-
let level =
|
|
132
|
-
let degree = u16::from_le((p.add(S_DEGREE) as *const u16)
|
|
133
|
-
let base = p.add(
|
|
172
|
+
let level = vread(p.add(S_LEVEL));
|
|
173
|
+
let degree = u16::from_le(vread(p.add(S_DEGREE) as *const u16)) as usize;
|
|
174
|
+
let base = p.add(nbase) as *const u32;
|
|
134
175
|
for i in 0..degree.min(cap) {
|
|
135
|
-
out.push(u32::from_le(base.add(i)
|
|
176
|
+
out.push(u32::from_le(vread(base.add(i))));
|
|
136
177
|
}
|
|
137
178
|
Some(level)
|
|
138
179
|
}
|
|
@@ -149,11 +190,11 @@ impl Graph {
|
|
|
149
190
|
seqlock::read_consistent(seq, self.file.self_tag, || {
|
|
150
191
|
let p = self.file.slot_ptr(id);
|
|
151
192
|
unsafe {
|
|
152
|
-
let flags =
|
|
193
|
+
let flags = vread(p.add(S_FLAGS));
|
|
153
194
|
if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
|
|
154
195
|
return NO_UPPER;
|
|
155
196
|
}
|
|
156
|
-
(p.add(S_UPPER_IDX) as *const u32)
|
|
197
|
+
vread(p.add(S_UPPER_IDX) as *const u32)
|
|
157
198
|
}
|
|
158
199
|
}, self.slot_sanitizer(id), || NO_UPPER, self.owner_dead())
|
|
159
200
|
}
|
|
@@ -172,15 +213,15 @@ impl Graph {
|
|
|
172
213
|
out.clear();
|
|
173
214
|
let p = self.file.upper_ptr(idx);
|
|
174
215
|
unsafe {
|
|
175
|
-
let levels =
|
|
216
|
+
let levels = vread(p.add(U_LEVELS));
|
|
176
217
|
if level > levels {
|
|
177
218
|
return false;
|
|
178
219
|
}
|
|
179
220
|
let lp = p.add(U_LISTS + (level as usize - 1) * UPPER_LEVEL_STRIDE);
|
|
180
|
-
let degree = u16::from_le((lp as *const u16)
|
|
181
|
-
let base = lp.add(
|
|
221
|
+
let degree = u16::from_le(vread(lp.add(UL_DEGREE) as *const u16)) as usize;
|
|
222
|
+
let base = lp.add(UL_IDS) as *const u32;
|
|
182
223
|
for i in 0..degree.min(UPPER_CAP) {
|
|
183
|
-
out.push(u32::from_le(base.add(i)
|
|
224
|
+
out.push(u32::from_le(vread(base.add(i))));
|
|
184
225
|
}
|
|
185
226
|
true
|
|
186
227
|
}
|
|
@@ -206,8 +247,8 @@ impl Graph {
|
|
|
206
247
|
for (l, list) in levels.iter().take(n).enumerate() {
|
|
207
248
|
let lp = p.add(U_LISTS + l * UPPER_LEVEL_STRIDE);
|
|
208
249
|
let deg = list.len().min(UPPER_CAP);
|
|
209
|
-
(lp as *mut u16).write_unaligned((deg as u16).to_le());
|
|
210
|
-
let base = lp.add(
|
|
250
|
+
(lp.add(UL_DEGREE) as *mut u16).write_unaligned((deg as u16).to_le());
|
|
251
|
+
let base = lp.add(UL_IDS) as *mut u32;
|
|
211
252
|
for (i, id) in list.iter().take(deg).enumerate() {
|
|
212
253
|
base.add(i).write_unaligned(id.to_le());
|
|
213
254
|
}
|
|
@@ -229,8 +270,8 @@ impl Graph {
|
|
|
229
270
|
for (l, list) in levels.iter().take(n).enumerate() {
|
|
230
271
|
let lp = p.add(U_LISTS + l * UPPER_LEVEL_STRIDE);
|
|
231
272
|
let deg = list.len().min(UPPER_CAP);
|
|
232
|
-
(lp as *mut u16).write_unaligned((deg as u16).to_le());
|
|
233
|
-
let base = lp.add(
|
|
273
|
+
(lp.add(UL_DEGREE) as *mut u16).write_unaligned((deg as u16).to_le());
|
|
274
|
+
let base = lp.add(UL_IDS) as *mut u32;
|
|
234
275
|
for (i, id) in list.iter().take(deg).enumerate() {
|
|
235
276
|
base.add(i).write_unaligned(id.to_le());
|
|
236
277
|
}
|
|
@@ -246,25 +287,27 @@ impl Graph {
|
|
|
246
287
|
return false;
|
|
247
288
|
}
|
|
248
289
|
let seq = self.file.seq_atomic(id);
|
|
249
|
-
seqlock::read_consistent(seq, self.file.self_tag, || unsafe {
|
|
290
|
+
seqlock::read_consistent(seq, self.file.self_tag, || unsafe { vread(self.file.slot_ptr(id).add(S_FLAGS)) != 0 }, self.slot_sanitizer(id), || true, self.owner_dead())
|
|
250
291
|
}
|
|
251
292
|
|
|
252
|
-
/// The slot's stored upper idx regardless of valid/deleted flags
|
|
253
|
-
///
|
|
254
|
-
|
|
293
|
+
/// The slot's stored upper idx regardless of valid/deleted flags. Taken under the slot
|
|
294
|
+
/// write lock rather than `read_consistent`, whose NO_UPPER fallback cannot be told apart
|
|
295
|
+
/// from an unbound slot — reusing it as one mints a second entry for an id that already
|
|
296
|
+
/// owns one.
|
|
297
|
+
fn upper_idx_locked(&self, id: u32) -> Result<u32, Wedged> {
|
|
255
298
|
if !self.in_range(id) {
|
|
256
|
-
return NO_UPPER;
|
|
299
|
+
return Ok(NO_UPPER);
|
|
257
300
|
}
|
|
258
301
|
let seq = self.file.seq_atomic(id);
|
|
259
|
-
seqlock::
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
302
|
+
let _guard = seqlock::write_lock(seq, self.file.self_tag, self.slot_sanitizer(id), self.owner_dead())?;
|
|
303
|
+
let p = self.file.slot_ptr(id);
|
|
304
|
+
Ok(unsafe {
|
|
305
|
+
if *p.add(S_FLAGS) == 0 {
|
|
306
|
+
NO_UPPER // never written
|
|
307
|
+
} else {
|
|
265
308
|
(p.add(S_UPPER_IDX) as *const u32).read_unaligned()
|
|
266
309
|
}
|
|
267
|
-
}
|
|
310
|
+
})
|
|
268
311
|
}
|
|
269
312
|
|
|
270
313
|
/// Mirror a host-maintained node into the plane: full state per call, host-allocated id
|
|
@@ -281,21 +324,35 @@ impl Graph {
|
|
|
281
324
|
upper_levels: &[Vec<u32>],
|
|
282
325
|
) -> Result<(), Wedged> {
|
|
283
326
|
self.file.ensure_high_water(id);
|
|
284
|
-
let existing = match self.
|
|
327
|
+
let existing = match self.upper_idx_locked(id)? {
|
|
285
328
|
idx if idx != NO_UPPER && (idx as u64) >= self.file.upper_capacity => NO_UPPER, // corrupt stored index
|
|
286
329
|
idx => idx,
|
|
287
330
|
};
|
|
331
|
+
let mut fresh = NO_UPPER;
|
|
288
332
|
let upper_idx = if upper_levels.is_empty() {
|
|
289
|
-
|
|
333
|
+
// the host reseeds its id counter to largestNodeId + 1 on restart, so an id can be
|
|
334
|
+
// re-minted at level 0 over a slot that had a hierarchy; that entry must stop being
|
|
335
|
+
// readable. Emptied in place rather than freed: the freelist hand-off is not atomic
|
|
336
|
+
// with publishing the slot below, so a mirror that read this index first could
|
|
337
|
+
// republish a slot pointing at an entry already given to another node. One idle
|
|
338
|
+
// entry per id is the bounded retention DESIGN.md §10 accepts.
|
|
339
|
+
if existing != NO_UPPER {
|
|
340
|
+
self.rewrite_upper(existing, &[])?;
|
|
341
|
+
}
|
|
342
|
+
existing
|
|
290
343
|
} else if existing != NO_UPPER {
|
|
291
344
|
self.rewrite_upper(existing, upper_levels)?;
|
|
292
345
|
existing
|
|
293
346
|
} else {
|
|
294
|
-
self.write_upper(upper_levels)
|
|
347
|
+
fresh = self.write_upper(upper_levels)?;
|
|
348
|
+
fresh
|
|
295
349
|
};
|
|
296
350
|
let mut l0 = neighbors.to_vec();
|
|
297
351
|
l0.truncate(self.file.layer0_cap);
|
|
298
|
-
self.write_node(id, level, vector, scale, inv_mag, &l0, upper_idx)
|
|
352
|
+
if let Err(wedged) = self.write_node(id, level, vector, scale, inv_mag, &l0, upper_idx) {
|
|
353
|
+
self.file.free_upper(fresh); // unreachable from any slot until publication succeeds
|
|
354
|
+
return Err(wedged);
|
|
355
|
+
}
|
|
299
356
|
Ok(())
|
|
300
357
|
}
|
|
301
358
|
|
|
@@ -340,12 +397,12 @@ impl Graph {
|
|
|
340
397
|
return Ok(false);
|
|
341
398
|
}
|
|
342
399
|
let lp = p.add(U_LISTS + (level as usize - 1) * UPPER_LEVEL_STRIDE);
|
|
343
|
-
let degree = u16::from_le((lp as *const u16).read_unaligned()) as usize;
|
|
344
|
-
let base = lp.add(
|
|
400
|
+
let degree = u16::from_le((lp.add(UL_DEGREE) as *const u16).read_unaligned()) as usize;
|
|
401
|
+
let base = lp.add(UL_IDS) as *mut u32;
|
|
345
402
|
let mut list: Vec<u32> = (0..degree.min(UPPER_CAP)).map(|i| u32::from_le(base.add(i).read_unaligned())).collect();
|
|
346
403
|
f(&mut list);
|
|
347
404
|
list.truncate(UPPER_CAP);
|
|
348
|
-
(lp as *mut u16).write_unaligned((list.len() as u16).to_le());
|
|
405
|
+
(lp.add(UL_DEGREE) as *mut u16).write_unaligned((list.len() as u16).to_le());
|
|
349
406
|
for (i, id) in list.iter().enumerate() {
|
|
350
407
|
base.add(i).write_unaligned(id.to_le());
|
|
351
408
|
}
|
|
@@ -361,20 +418,21 @@ impl Graph {
|
|
|
361
418
|
let seq = self.file.seq_atomic(id);
|
|
362
419
|
let dims = self.file.dims;
|
|
363
420
|
let cap = self.file.layer0_cap;
|
|
421
|
+
let nbase_off = neighbor_offset(dims);
|
|
364
422
|
seqlock::read_consistent(seq, self.file.self_tag, || {
|
|
365
423
|
let p = self.file.slot_ptr(id);
|
|
366
424
|
unsafe {
|
|
367
|
-
let flags =
|
|
425
|
+
let flags = vread(p.add(S_FLAGS));
|
|
368
426
|
if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
|
|
369
427
|
return None;
|
|
370
428
|
}
|
|
371
|
-
let level =
|
|
372
|
-
let degree = u16::from_le((p.add(S_DEGREE) as *const u16)
|
|
373
|
-
let scale = (p.add(S_SCALE) as *const f32)
|
|
374
|
-
let inv_mag = (p.add(S_INV_MAG) as *const f32)
|
|
429
|
+
let level = vread(p.add(S_LEVEL));
|
|
430
|
+
let degree = u16::from_le(vread(p.add(S_DEGREE) as *const u16)) as usize;
|
|
431
|
+
let scale = vread(p.add(S_SCALE) as *const f32);
|
|
432
|
+
let inv_mag = vread(p.add(S_INV_MAG) as *const f32);
|
|
375
433
|
let vector = std::slice::from_raw_parts(p.add(S_VECTOR) as *const i8, dims).to_vec();
|
|
376
|
-
let nbase = p.add(
|
|
377
|
-
let neighbors = (0..degree.min(cap)).map(|i| u32::from_le(nbase.add(i)
|
|
434
|
+
let nbase = p.add(nbase_off) as *const u32;
|
|
435
|
+
let neighbors = (0..degree.min(cap)).map(|i| u32::from_le(vread(nbase.add(i)))).collect();
|
|
378
436
|
Some(NodeRead { level, scale, inv_mag, vector, neighbors })
|
|
379
437
|
}
|
|
380
438
|
}, self.slot_sanitizer(id), || None, self.owner_dead())
|
|
@@ -397,11 +455,15 @@ impl Graph {
|
|
|
397
455
|
(p.add(S_UPPER_IDX) as *mut u32).write_unaligned(upper_idx);
|
|
398
456
|
std::ptr::copy_nonoverlapping(vector.as_ptr() as *const u8, p.add(S_VECTOR), dims);
|
|
399
457
|
for (i, n) in neighbors.iter().enumerate() {
|
|
400
|
-
(p.add(
|
|
458
|
+
(p.add(neighbor_offset(dims) + i * 4) as *mut u32).write_unaligned(n.to_le());
|
|
401
459
|
}
|
|
402
460
|
// valid last within the locked section; the seqlock release publishes it
|
|
403
461
|
*p.add(S_FLAGS) = FLAG_VALID;
|
|
404
462
|
}
|
|
463
|
+
drop(_guard);
|
|
464
|
+
// after the release: a probe that consumed a bump while the slot was still invalid
|
|
465
|
+
// would otherwise latch on a graph that holds a live node
|
|
466
|
+
self.node_written();
|
|
405
467
|
Ok(())
|
|
406
468
|
}
|
|
407
469
|
|
|
@@ -424,7 +486,7 @@ impl Graph {
|
|
|
424
486
|
return Ok(false);
|
|
425
487
|
}
|
|
426
488
|
let degree = u16::from_le((p.add(S_DEGREE) as *const u16).read_unaligned()) as usize;
|
|
427
|
-
let base = p.add(
|
|
489
|
+
let base = p.add(neighbor_offset(dims)) as *mut u32;
|
|
428
490
|
let mut list: Vec<u32> = (0..degree.min(cap)).map(|i| u32::from_le(base.add(i).read_unaligned())).collect();
|
|
429
491
|
f(&mut list);
|
|
430
492
|
list.truncate(cap);
|
|
@@ -457,7 +519,7 @@ impl Graph {
|
|
|
457
519
|
if degree != expected.len() {
|
|
458
520
|
return Ok(false);
|
|
459
521
|
}
|
|
460
|
-
let base = p.add(
|
|
522
|
+
let base = p.add(neighbor_offset(dims)) as *mut u32;
|
|
461
523
|
for (i, want) in expected.iter().enumerate() {
|
|
462
524
|
if u32::from_le(base.add(i).read_unaligned()) != *want {
|
|
463
525
|
return Ok(false);
|
|
@@ -481,7 +543,7 @@ impl Graph {
|
|
|
481
543
|
unsafe {
|
|
482
544
|
(p.add(S_DEGREE) as *mut u16).write_unaligned((neighbors.len() as u16).to_le());
|
|
483
545
|
for (i, n) in neighbors.iter().enumerate() {
|
|
484
|
-
(p.add(
|
|
546
|
+
(p.add(neighbor_offset(dims) + i * 4) as *mut u32).write_unaligned(n.to_le());
|
|
485
547
|
}
|
|
486
548
|
}
|
|
487
549
|
Ok(())
|
|
@@ -501,6 +563,13 @@ impl Graph {
|
|
|
501
563
|
if entry_id == id {
|
|
502
564
|
self.neighbors_into(id, &mut candidates);
|
|
503
565
|
}
|
|
566
|
+
// Re-elect before the tombstone, not after: between marking the slot deleted and
|
|
567
|
+
// installing a replacement, every concurrent search routes through a node that reads
|
|
568
|
+
// as absent and returns nothing. The node is still live here, so a crash inside the
|
|
569
|
+
// window leaves the header naming a live entry either way.
|
|
570
|
+
if entry_id == id {
|
|
571
|
+
self.reelect_entry_point_replacing(&candidates, id);
|
|
572
|
+
}
|
|
504
573
|
let upper_idx;
|
|
505
574
|
{
|
|
506
575
|
let seq = self.file.seq_atomic(id);
|
|
@@ -525,9 +594,6 @@ impl Graph {
|
|
|
525
594
|
self.rewrite_upper(upper_idx, &[])?;
|
|
526
595
|
}
|
|
527
596
|
self.file.free_upper(upper_idx);
|
|
528
|
-
if entry_id == id {
|
|
529
|
-
self.reelect_entry_point_replacing(&candidates, id);
|
|
530
|
-
}
|
|
531
597
|
self.file.free_id(id);
|
|
532
598
|
Ok(())
|
|
533
599
|
}
|
|
@@ -536,7 +602,7 @@ impl Graph {
|
|
|
536
602
|
/// first live node found scanning the id range (rare path: only when the entry's whole
|
|
537
603
|
/// neighborhood is gone). An empty graph clears the entry.
|
|
538
604
|
/// A node's level without copying its vector or edges (cheap re-election scans).
|
|
539
|
-
fn node_level(&self, id: u32) -> Option<u8> {
|
|
605
|
+
pub(crate) fn node_level(&self, id: u32) -> Option<u8> {
|
|
540
606
|
if !self.in_range(id) {
|
|
541
607
|
return None;
|
|
542
608
|
}
|
|
@@ -544,25 +610,82 @@ impl Graph {
|
|
|
544
610
|
seqlock::read_consistent(seq, self.file.self_tag, || {
|
|
545
611
|
let p = self.file.slot_ptr(id);
|
|
546
612
|
unsafe {
|
|
547
|
-
if
|
|
613
|
+
if vread(p.add(S_FLAGS)) != FLAG_VALID {
|
|
548
614
|
return None;
|
|
549
615
|
}
|
|
550
|
-
Some(
|
|
616
|
+
Some(vread(p.add(S_LEVEL)))
|
|
551
617
|
}
|
|
552
618
|
}, self.slot_sanitizer(id), || None, self.owner_dead())
|
|
553
619
|
}
|
|
554
620
|
|
|
621
|
+
/// Highest-level live node among at most `limit` probes, skipping `skip`. The read-side
|
|
622
|
+
/// repair's last resort, bounded because `reelect_entry_point_replacing`'s scan runs to the
|
|
623
|
+
/// high-water mark and a search on the shared pool thread cannot afford it.
|
|
624
|
+
///
|
|
625
|
+
/// Walks down from the newest id with a stride spanning the whole range, so it assumes
|
|
626
|
+
/// nothing about where the live nodes sit: Harper allocates ids monotonically and never
|
|
627
|
+
/// reuses them, so a churned table's low prefix is all tombstones, while the crate's own
|
|
628
|
+
/// freelist reuses ids and keeps live nodes low.
|
|
629
|
+
///
|
|
630
|
+
/// The start rotates per handle, so `stride` consecutive repairs of this plane cover every id
|
|
631
|
+
/// while each stays capped at `limit`; a fixed start would probe one residue class forever
|
|
632
|
+
/// and leave a graph lying between its samples invisible permanently, not for one search.
|
|
633
|
+
/// That coverage rests on `stride * limit >= hw`, which is why the stride is a ceiling
|
|
634
|
+
/// division: below it a walk stops short of id 0 and no offset ever reaches the tail.
|
|
635
|
+
///
|
|
636
|
+
/// Best-level rather than first-live: a level-0 entry degrades every later search to a
|
|
637
|
+
/// layer-0-only beam.
|
|
638
|
+
pub(crate) fn probe_for_entry(&self, limit: u32, skip: u32) -> Option<(u32, u8)> {
|
|
639
|
+
let hw = self.file.id_high_water().min(self.file.max_nodes) as u32;
|
|
640
|
+
if hw == 0 || limit == 0 {
|
|
641
|
+
return None;
|
|
642
|
+
}
|
|
643
|
+
let stride = hw.div_ceil(limit);
|
|
644
|
+
use std::sync::atomic::Ordering::Relaxed;
|
|
645
|
+
let epoch = self.file.write_epoch();
|
|
646
|
+
let unchanged = self.probe_futile_hw.load(Relaxed) == hw as u64 && self.probe_futile_epoch.load(Relaxed) == epoch;
|
|
647
|
+
if unchanged && self.probe_futile_runs.load(Relaxed) >= stride {
|
|
648
|
+
return None; // every residue probed since the last write anywhere: nothing to find
|
|
649
|
+
}
|
|
650
|
+
if self.probe_in_flight.swap(true, std::sync::atomic::Ordering::AcqRel) {
|
|
651
|
+
return None; // another search on this handle is repairing; it publishes for both
|
|
652
|
+
}
|
|
653
|
+
let offset = self.probe_rotation.fetch_add(1, Relaxed) % stride;
|
|
654
|
+
let mut best: Option<(u32, u8)> = None;
|
|
655
|
+
let mut cand = hw - 1 - offset;
|
|
656
|
+
for _ in 0..limit {
|
|
657
|
+
if cand != skip {
|
|
658
|
+
if let Some(level) = self.node_level(cand) {
|
|
659
|
+
if best.map(|(_, l)| level > l).unwrap_or(true) {
|
|
660
|
+
best = Some((cand, level));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if cand < stride {
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
cand -= stride;
|
|
668
|
+
}
|
|
669
|
+
if best.is_some() {
|
|
670
|
+
self.probe_futile_runs.store(0, Relaxed);
|
|
671
|
+
} else if unchanged {
|
|
672
|
+
self.probe_futile_runs.fetch_add(1, Relaxed);
|
|
673
|
+
} else {
|
|
674
|
+
self.probe_futile_hw.store(hw as u64, Relaxed);
|
|
675
|
+
self.probe_futile_epoch.store(epoch, Relaxed);
|
|
676
|
+
self.probe_futile_runs.store(1, Relaxed);
|
|
677
|
+
}
|
|
678
|
+
self.probe_in_flight.store(false, std::sync::atomic::Ordering::Release);
|
|
679
|
+
best
|
|
680
|
+
}
|
|
681
|
+
|
|
555
682
|
/// Pick a new entry point: the highest-level live node among `preferred`, else the
|
|
556
683
|
/// highest-level live node found scanning the id range (level reads only — no per-node
|
|
557
684
|
/// vector copies; still O(high-water), which only runs when an entry point vanished
|
|
558
685
|
/// with no live neighborhood). Preferring level keeps the hierarchy navigable — a
|
|
559
686
|
/// level-0 entry degrades every search to a layer-0-only beam. An empty graph clears
|
|
560
687
|
/// the entry.
|
|
561
|
-
pub(crate) fn
|
|
562
|
-
self.reelect_entry_point_replacing(preferred, crate::format::NO_ID)
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
fn reelect_entry_point_replacing(&self, preferred: &[u32], replacing: u32) {
|
|
688
|
+
pub(crate) fn reelect_entry_point_replacing(&self, preferred: &[u32], replacing: u32) {
|
|
566
689
|
let mut best: Option<(u32, u8)> = None;
|
|
567
690
|
// the most recently replaced entry point is the best cheap candidate: usually alive,
|
|
568
691
|
// usually high-level — and it makes the full fallback scan a last resort
|
|
@@ -573,6 +696,9 @@ impl Graph {
|
|
|
573
696
|
}
|
|
574
697
|
}
|
|
575
698
|
for &cand in preferred {
|
|
699
|
+
if cand == replacing {
|
|
700
|
+
continue; // the node on its way out is never its own replacement
|
|
701
|
+
}
|
|
576
702
|
if let Some(level) = self.node_level(cand) {
|
|
577
703
|
if best.map(|(_, l)| level > l).unwrap_or(true) {
|
|
578
704
|
best = Some((cand, level));
|
|
@@ -582,6 +708,9 @@ impl Graph {
|
|
|
582
708
|
if best.is_none() {
|
|
583
709
|
let hw = self.file.id_high_water().min(self.file.max_nodes) as u32;
|
|
584
710
|
for cand in 0..hw {
|
|
711
|
+
if cand == replacing {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
585
714
|
if let Some(level) = self.node_level(cand) {
|
|
586
715
|
if best.map(|(_, l)| level > l).unwrap_or(true) {
|
|
587
716
|
best = Some((cand, level));
|
|
@@ -594,7 +723,7 @@ impl Graph {
|
|
|
594
723
|
}
|
|
595
724
|
match best {
|
|
596
725
|
Some((cand, level)) => self.file.set_entry_point_if_not_better(cand, level as u32, replacing),
|
|
597
|
-
None => self.file.
|
|
726
|
+
None => self.file.clear_entry_point_if(replacing),
|
|
598
727
|
}
|
|
599
728
|
}
|
|
600
729
|
|
|
@@ -616,12 +745,20 @@ impl Graph {
|
|
|
616
745
|
debug_assert!(neighbors.len() <= self.file.layer0_cap);
|
|
617
746
|
debug_assert_eq!(vector.len(), self.file.dims);
|
|
618
747
|
self.file.ensure_high_water(id);
|
|
619
|
-
// the upper entry is allocated before taking the slot lock (allocation is cheap
|
|
620
|
-
//
|
|
748
|
+
// the upper entry is allocated before taking the slot lock (allocation is cheap); it is
|
|
749
|
+
// unreachable from any slot until the write below lands, so every path that does not
|
|
750
|
+
// publish it — a wedged lock, a slot that turns out to be touched — has to free it
|
|
621
751
|
let upper_idx = if upper_levels.is_empty() { NO_UPPER } else { self.write_upper(upper_levels)? };
|
|
622
752
|
let seq = self.file.seq_atomic(id);
|
|
623
753
|
let written = {
|
|
624
|
-
let _guard = seqlock::write_lock(seq, self.file.self_tag, self.slot_sanitizer(id), self.owner_dead())
|
|
754
|
+
let _guard = match seqlock::write_lock(seq, self.file.self_tag, self.slot_sanitizer(id), self.owner_dead())
|
|
755
|
+
{
|
|
756
|
+
Ok(guard) => guard,
|
|
757
|
+
Err(wedged) => {
|
|
758
|
+
self.file.free_upper(upper_idx);
|
|
759
|
+
return Err(wedged);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
625
762
|
let p = self.file.slot_ptr_mut(id);
|
|
626
763
|
let dims = self.file.dims;
|
|
627
764
|
unsafe {
|
|
@@ -635,16 +772,73 @@ impl Graph {
|
|
|
635
772
|
(p.add(S_UPPER_IDX) as *mut u32).write_unaligned(upper_idx);
|
|
636
773
|
std::ptr::copy_nonoverlapping(vector.as_ptr() as *const u8, p.add(S_VECTOR), dims);
|
|
637
774
|
for (i, n) in neighbors.iter().enumerate() {
|
|
638
|
-
(p.add(
|
|
775
|
+
(p.add(neighbor_offset(dims) + i * 4) as *mut u32).write_unaligned(n.to_le());
|
|
639
776
|
}
|
|
640
777
|
*p.add(S_FLAGS) = FLAG_VALID;
|
|
641
778
|
true
|
|
642
779
|
}
|
|
643
780
|
}
|
|
644
781
|
};
|
|
645
|
-
if
|
|
782
|
+
if written {
|
|
783
|
+
self.node_written();
|
|
784
|
+
} else {
|
|
646
785
|
self.file.free_upper(upper_idx);
|
|
647
786
|
}
|
|
648
787
|
Ok(written)
|
|
649
788
|
}
|
|
650
789
|
}
|
|
790
|
+
|
|
791
|
+
#[cfg(test)]
|
|
792
|
+
mod probe_tests {
|
|
793
|
+
use super::*;
|
|
794
|
+
use crate::distance::Query;
|
|
795
|
+
use crate::insert::{insert, InsertParams};
|
|
796
|
+
use crate::search::{search, SearchScratch};
|
|
797
|
+
use std::sync::atomic::Ordering::Relaxed;
|
|
798
|
+
|
|
799
|
+
fn vector_for(i: u32, dims: usize) -> Vec<f32> {
|
|
800
|
+
(0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect()
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/// A graph whose every node is dead must stop paying the repair probe once a full rotation
|
|
804
|
+
/// has come back empty, and must resume it after ANY handle writes a node — the revival
|
|
805
|
+
/// here comes through a second handle on the same file, as another process's would.
|
|
806
|
+
#[test]
|
|
807
|
+
fn a_fully_dead_graph_stops_probing_until_a_node_is_written() {
|
|
808
|
+
let dims = 32;
|
|
809
|
+
let path = std::env::temp_dir().join(format!("hnsw-probefutile-{}.hnsw", std::process::id()));
|
|
810
|
+
let _ = std::fs::remove_file(&path);
|
|
811
|
+
let graph = Graph::new(PlaneFile::create(&path, dims, 16, 4_096).expect("create"));
|
|
812
|
+
let params = InsertParams::default();
|
|
813
|
+
let mut scratch = SearchScratch::new();
|
|
814
|
+
for i in 0..2_100 {
|
|
815
|
+
insert(&graph, &vector_for(i, dims), ¶ms, &mut scratch).unwrap();
|
|
816
|
+
}
|
|
817
|
+
let hw = graph.file.id_high_water() as u32;
|
|
818
|
+
let stride = hw.div_ceil(1_024); // REPAIR_PROBE_LIMIT
|
|
819
|
+
assert!(stride > 1, "precondition: a rotation the guard has to wait out");
|
|
820
|
+
let (entry, _) = graph.file.entry_point();
|
|
821
|
+
for id in 0..hw {
|
|
822
|
+
let _ = graph.clear_node(id);
|
|
823
|
+
}
|
|
824
|
+
graph.file.clear_entry_point_if(entry);
|
|
825
|
+
|
|
826
|
+
let query = Query::new(vector_for(7, dims));
|
|
827
|
+
for _ in 0..stride {
|
|
828
|
+
assert!(search(&graph, &query, 5, 64, &mut scratch).0.is_empty());
|
|
829
|
+
}
|
|
830
|
+
let rotation = graph.probe_rotation.load(Relaxed);
|
|
831
|
+
assert!(search(&graph, &query, 5, 64, &mut scratch).0.is_empty());
|
|
832
|
+
assert_eq!(graph.probe_rotation.load(Relaxed), rotation, "a probed-out plane must not probe again");
|
|
833
|
+
|
|
834
|
+
// a node written through another handle, with no entry-point update (mirroring hosts do
|
|
835
|
+
// not always re-elect), must be findable again within one rotation
|
|
836
|
+
let revived = 3u32;
|
|
837
|
+
let q = crate::distance::quantize_int8(&vector_for(revived, dims));
|
|
838
|
+
let other = Graph::new(PlaneFile::open(&path).expect("a second handle"));
|
|
839
|
+
other.write_node_raw(revived, 0, &q.0, q.1, q.2, &[], &[]).expect("revive");
|
|
840
|
+
let found = (0..stride).any(|_| !search(&graph, &Query::new(vector_for(revived, dims)), 5, 64, &mut scratch).0.is_empty());
|
|
841
|
+
assert!(found, "a write must re-arm the probe");
|
|
842
|
+
let _ = std::fs::remove_file(&path);
|
|
843
|
+
}
|
|
844
|
+
}
|