core_query/visible.rs
1//! The set of node ids a read may see, in whichever of two shapes costs less.
2//!
3//! Every masked read probes this set once per candidate: once per node of a
4//! label scan, once per vector candidate, once per neighbour of every node a
5//! scoped expansion visits. A `HashSet<u32>` probe is a hash, a bucket load and
6//! a comparison, and at 50,000 visible ids out of 200,000 the table no longer
7//! fits in cache — 11.3 ns per probe against 5.8 ns at 2,000 ids, and 29% of a
8//! whole `query_masked` call.
9//!
10//! What the second shape bought, on that store, release profile, five process
11//! runs of 51 repetitions each, median of the medians
12//! (`crates/core-api/tests/mask_bench.rs`):
13//!
14//! ```text
15//! |visible| = 2,000 |visible| = 50,000
16//! (stays Sparse) (becomes Dense)
17//! query_masked 1396.8 → 1374.9 us 7983.9 → 5685.5 us −28.8%
18//! find_similar_vector_masked 1415.7 → 1416.3 us 10272.0 → 7967.0 us −22.4%
19//! neighborhood_masked 2469.5 → 2474.8 us 2401.1 → 2392.4 us −0.4%
20//! intersect 53.0 → 31.4 us 1305.1 → 0.6 us
21//! ```
22//!
23//! The 2,000-id column is flat because the rule leaves that mask a `HashSet`:
24//! it is the control, and it says the enum around the set costs nothing.
25//! `neighborhood_masked` is flat at both sizes because the probe is about 1% of
26//! a BFS that spends its time expanding edges and building rows — the win is
27//! where the probe is the work, not everywhere a mask appears.
28//!
29//! # The two shapes, and the rule that picks one
30//!
31//! [`VisibleSet::Dense`] is a bitset over `[0, span)`, where `span` is one past
32//! the largest visible id. A probe is a bounds check, a shift and an AND, and
33//! the whole structure is `span / 8` bytes — 25 KB for a 200,000-id store,
34//! which stays in L2.
35//!
36//! [`VisibleSet::Sparse`] is the `HashSet<u32>` this type replaced, kept for
37//! the case that makes a bitset a bad trade: a role that sees ten nodes in a
38//! ten-million-node store would pay 1.25 MB as a bitset against a few hundred
39//! bytes as a set, and role masks are cached, so that cost would persist.
40//!
41//! The rule is one comparison, evaluated once at construction, and the variant
42//! it picks is fixed for the set's life:
43//!
44//! ```text
45//! dense ⟺ len * 64 >= span (span = max visible id + 1)
46//! ```
47//!
48//! Read it as a memory rule rather than a density rule: `len * 64 >= span` is
49//! exactly `span / 8 <= len * 8`, and `len * 8` bytes is about what a
50//! `HashSet<u32>` costs per element once hashbrown's control bytes and load
51//! factor are counted. So the bitset is chosen precisely when it is no larger
52//! than the set it replaces, and the ten-in-ten-million case fails the test by
53//! four orders of magnitude.
54//!
55//! The rule is checked against the **distinct** count, after duplicates in the
56//! input have been collapsed — a caller may hand the same id twice, and a
57//! multiplicity-inflated count could otherwise talk the rule into a bitset the
58//! real population does not earn.
59
60use std::collections::HashSet;
61
62/// A set of dense node ids, represented by whichever shape the module-level
63/// rule picks at construction.
64#[derive(Clone, Debug)]
65pub enum VisibleSet {
66 /// Few ids relative to the id space they live in: a hash set.
67 Sparse(HashSet<u32>),
68 /// Enough ids that a bitset is no larger than the set would be.
69 ///
70 /// `words` covers `[0, words.len() * 64)`; an id at or past that is absent,
71 /// which is what makes the probe a bounds check rather than a branch on a
72 /// separately tracked span. `len` is the population count, cached because
73 /// [`VisibleSet::len`] is called on paths that cannot afford to popcount a
74 /// whole bitset.
75 Dense { words: Box<[u64]>, len: usize },
76}
77
78impl VisibleSet {
79 /// Collect `ids` and choose a representation by the module's rule.
80 ///
81 /// Duplicates are collapsed; the rule sees the distinct count.
82 pub fn from_ids(ids: impl IntoIterator<Item = u32>) -> VisibleSet {
83 let ids: Vec<u32> = ids.into_iter().collect();
84 let Some(&max) = ids.iter().max() else {
85 return VisibleSet::Sparse(HashSet::new());
86 };
87 // `+ 1` saturates: on a 32-bit `usize`, `u32::MAX as usize + 1`
88 // overflows — panicking in debug, and in release wrapping to 0, which
89 // would take the dense branch with a zero-length word vector and panic
90 // on the first write. Saturating keeps the arithmetic honest on every
91 // target; a 32-bit build then reads the span as `usize::MAX`, which
92 // sends a mask that large to `Sparse`, the correct answer for it.
93 let span = (max as usize).saturating_add(1);
94 // `ids.len()` counts duplicates, so it is only an upper bound on the
95 // population — enough to rule *out* a bitset, never enough to rule one
96 // in. When it passes, the bitset itself does the deduplication and the
97 // rule is re-checked on the count that comes out of it.
98 //
99 // The *allocation* below is sized from that inflated count, so a list
100 // of many duplicates plus one high id can briefly allocate a bitset
101 // that `from_words` then demotes. Bounded by the store's id space, and
102 // the outcome is still correct — but the rule seeing the distinct
103 // count is true of the result, not of the transient.
104 if ids.len().saturating_mul(64) < span {
105 return VisibleSet::Sparse(ids.into_iter().collect());
106 }
107 let mut words = vec![0u64; span.div_ceil(64)];
108 let mut len = 0usize;
109 for id in ids {
110 let bit = 1u64 << (id % 64);
111 let word = &mut words[id as usize / 64];
112 if *word & bit == 0 {
113 *word |= bit;
114 len += 1;
115 }
116 }
117 VisibleSet::from_words(words, len)
118 }
119
120 /// Wrap a finished bitset, demoting it to [`VisibleSet::Sparse`] when the
121 /// population it turned out to hold does not earn the space.
122 fn from_words(mut words: Vec<u64>, len: usize) -> VisibleSet {
123 while words.last() == Some(&0) {
124 words.pop();
125 }
126 let span = match words.last() {
127 None => return VisibleSet::Sparse(HashSet::new()),
128 Some(&top) => (words.len() - 1) * 64 + (64 - top.leading_zeros() as usize),
129 };
130 if len.saturating_mul(64) >= span {
131 VisibleSet::Dense {
132 words: words.into_boxed_slice(),
133 len,
134 }
135 } else {
136 VisibleSet::Sparse(bits(&words).collect())
137 }
138 }
139
140 /// Is `id` in the set?
141 ///
142 /// The whole point of the type: on [`VisibleSet::Dense`] this is a bounds
143 /// check, a shift and an AND.
144 #[inline]
145 pub fn contains(&self, id: u32) -> bool {
146 match self {
147 VisibleSet::Sparse(set) => set.contains(&id),
148 VisibleSet::Dense { words, .. } => words
149 .get(id as usize / 64)
150 .is_some_and(|w| w >> (id % 64) & 1 == 1),
151 }
152 }
153
154 pub fn len(&self) -> usize {
155 match self {
156 VisibleSet::Sparse(set) => set.len(),
157 VisibleSet::Dense { len, .. } => *len,
158 }
159 }
160
161 pub fn is_empty(&self) -> bool {
162 self.len() == 0
163 }
164
165 /// The ids in the set, ascending on [`VisibleSet::Dense`] and in hash order
166 /// on [`VisibleSet::Sparse`] — no caller depends on the order, and every
167 /// masked path sorts or keys its own output.
168 pub fn iter(&self) -> impl Iterator<Item = u32> + '_ {
169 let sparse = match self {
170 VisibleSet::Sparse(set) => Some(set.iter().copied()),
171 VisibleSet::Dense { .. } => None,
172 };
173 let dense = match self {
174 VisibleSet::Sparse(_) => None,
175 VisibleSet::Dense { words, .. } => Some(bits(words)),
176 };
177 sparse
178 .into_iter()
179 .flatten()
180 .chain(dense.into_iter().flatten())
181 }
182
183 /// The ids in both sets, with the representation re-chosen for the result.
184 ///
185 /// Two dense sets intersect word-wise with no per-element allocation, which
186 /// is the operation a multi-leg scope runs on every read. Any other pairing
187 /// walks the smaller side and probes the larger, so the cost is set by the
188 /// narrower of the two masks — and narrowing is the only thing an
189 /// intersection can do.
190 pub fn intersect(&self, other: &VisibleSet) -> VisibleSet {
191 if let (VisibleSet::Dense { words: a, .. }, VisibleSet::Dense { words: b, .. }) =
192 (self, other)
193 {
194 let n = a.len().min(b.len());
195 let words: Vec<u64> = (0..n).map(|i| a[i] & b[i]).collect();
196 let len = words.iter().map(|w| w.count_ones() as usize).sum();
197 return VisibleSet::from_words(words, len);
198 }
199 let (small, large) = if self.len() <= other.len() {
200 (self, other)
201 } else {
202 (other, self)
203 };
204 VisibleSet::from_ids(small.iter().filter(|&id| large.contains(id)))
205 }
206}
207
208/// The set bits of `words`, ascending, as ids.
209fn bits(words: &[u64]) -> impl Iterator<Item = u32> + '_ {
210 words.iter().enumerate().flat_map(|(w, &word)| {
211 (0..64u32)
212 .filter(move |b| word >> b & 1 == 1)
213 .map(move |b| (w * 64) as u32 + b)
214 })
215}
216
217impl FromIterator<u32> for VisibleSet {
218 fn from_iter<I: IntoIterator<Item = u32>>(ids: I) -> VisibleSet {
219 VisibleSet::from_ids(ids)
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn variant(v: &VisibleSet) -> &'static str {
228 match v {
229 VisibleSet::Sparse(_) => "sparse",
230 VisibleSet::Dense { .. } => "dense",
231 }
232 }
233
234 /// The rule, at the boundary and on both sides of it, stated as the two
235 /// facts a reader needs: which variant, and that the answer is the same
236 /// either way.
237 #[test]
238 fn the_rule_picks_the_variant_and_never_the_answer() {
239 // span 6400, 100 ids: 100 * 64 == 6400, the boundary itself — dense.
240 let at: VisibleSet = (0..100).map(|i| i * 64 + 63).collect();
241 assert_eq!(at.len(), 100);
242 assert_eq!(variant(&at), "dense", "len * 64 == span is dense");
243
244 // One id further out: span 6464, 100 * 64 < 6464 — sparse.
245 let over: VisibleSet = (0..99)
246 .map(|i| i * 64 + 63)
247 .chain(std::iter::once(6463))
248 .collect();
249 assert_eq!(over.len(), 100);
250 assert_eq!(variant(&over), "sparse", "len * 64 < span is sparse");
251
252 // Whichever variant was picked, membership is the same predicate.
253 for id in 0..7000u32 {
254 assert_eq!(at.contains(id), id % 64 == 63 && id < 6400, "at id {id}");
255 }
256 assert!(over.contains(6463));
257 assert!(!over.contains(6462));
258 }
259
260 #[test]
261 fn a_ten_id_mask_in_a_ten_million_id_space_stays_sparse() {
262 let far: VisibleSet = (0..10).map(|i| 9_999_990 + i).collect();
263 assert_eq!(variant(&far), "sparse");
264 assert_eq!(far.len(), 10);
265 assert!(far.contains(9_999_999));
266 assert!(!far.contains(9_999_989));
267 }
268
269 #[test]
270 fn an_empty_set_is_sparse_and_contains_nothing() {
271 let empty = VisibleSet::from_ids(std::iter::empty());
272 assert_eq!(variant(&empty), "sparse");
273 assert!(empty.is_empty());
274 assert!(!empty.contains(0));
275 }
276
277 /// A caller repeating one id must not talk the rule into a bitset the real
278 /// population does not earn.
279 #[test]
280 fn duplicates_do_not_inflate_the_count_the_rule_sees() {
281 let dupes: VisibleSet = std::iter::repeat_n(1_000_000u32, 50_000).collect();
282 assert_eq!(dupes.len(), 1);
283 assert_eq!(variant(&dupes), "sparse");
284 assert!(dupes.contains(1_000_000));
285 }
286
287 #[test]
288 fn iter_returns_exactly_the_members_of_either_variant() {
289 for ids in [vec![0u32, 1, 2, 63, 64, 65], vec![0u32, 9_999_999]] {
290 let v: VisibleSet = ids.iter().copied().collect();
291 let mut got: Vec<u32> = v.iter().collect();
292 got.sort_unstable();
293 assert_eq!(got, ids, "{} lost a member", variant(&v));
294 }
295 }
296
297 #[test]
298 fn intersect_narrows_on_every_pairing_of_variants() {
299 let dense_a: VisibleSet = (0..200u32).collect();
300 let dense_b: VisibleSet = (100..300u32).collect();
301 let sparse_a: VisibleSet = [5u32, 150, 9_999_999].into_iter().collect();
302 assert_eq!(variant(&dense_a), "dense");
303 assert_eq!(variant(&sparse_a), "sparse");
304
305 let dd = dense_a.intersect(&dense_b);
306 let mut got: Vec<u32> = dd.iter().collect();
307 got.sort_unstable();
308 assert_eq!(got, (100..200).collect::<Vec<u32>>());
309
310 for (l, r) in [(&dense_a, &sparse_a), (&sparse_a, &dense_a)] {
311 let out = l.intersect(r);
312 let mut got: Vec<u32> = out.iter().collect();
313 got.sort_unstable();
314 assert_eq!(got, vec![5, 150], "intersection is order-independent");
315 }
316 }
317
318 /// The word-wise arm truncates to the shorter bitset; a member past that
319 /// end is absent from the result, which is what an intersection means.
320 #[test]
321 fn intersect_of_two_dense_sets_respects_the_shorter_span() {
322 let short: VisibleSet = (0..64u32).collect();
323 let long: VisibleSet = (0..640u32).collect();
324 let out = short.intersect(&long);
325 assert_eq!(out.len(), 64);
326 assert!(out.contains(63));
327 assert!(!out.contains(64));
328 }
329
330 /// The word-wise arm must re-apply the rule to what it produced: two dense
331 /// sets whose intersection is thin across the same wide span hand back a
332 /// sparse result, not a bitset the survivors do not earn.
333 #[test]
334 fn a_dense_intersection_that_narrows_hard_demotes_itself() {
335 let a: VisibleSet = (0..200_000u32).filter(|i| i % 2 == 0).collect();
336 let b: VisibleSet = (0..3_200u32).chain(std::iter::once(199_998)).collect();
337 assert_eq!(variant(&a), "dense");
338 assert_eq!(
339 variant(&b),
340 "dense",
341 "both sides must take the word-wise arm"
342 );
343
344 let out = a.intersect(&b);
345 assert_eq!(out.len(), 1_601);
346 assert_eq!(variant(&out), "sparse");
347 assert!(out.contains(0) && out.contains(3_198) && out.contains(199_998));
348 assert!(!out.contains(1) && !out.contains(3_200));
349 }
350}