Skip to main content

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}