Skip to main content

linkrs_storage/edge/mutable_csr/
live_set.rs

1use std::collections::HashMap;
2
3use super::super::csr_shared::SegmentedTable;
4use super::write::EdgePosition;
5use super::MutableCsr;
6
7/// Live width at or below this bound keeps no index.
8///
9/// Source: the point-lookup routing benchmark in `benches/csr_perf_bench.rs`
10/// (routing hit versus fallback section) shows indexed binary search beating
11/// a linear row scan once rows grow past a handful of entries, while the
12/// per-row hash overhead dominates below it. Eight keeps narrow-row inserts
13/// allocation-free and bounds the worst unindexed scan to eight slots.
14/// Retuning this bound requires rerunning that benchmark first; changes
15/// without a benchmark run are not accepted.
16pub(crate) const LIVE_SET_WIDTH_BOUND: usize = 8;
17
18/// Live endpoint keys of one wide vertex with row positions.
19///
20/// Only rows wider than [`LIVE_SET_WIDTH_BOUND`] carry a set; narrow rows
21/// hold no index and answer through row scans.  Sets are created on demand
22/// by the write path and dropped by rebuilds once the row narrows again, so
23/// widths oscillating around the bound cannot accumulate stale indexes.
24///
25/// Two internal representations back the set:
26///
27/// * **Hash** — a `HashMap` chosen during active mutation for O(1) insert
28///   and remove.
29/// * **Sorted** — a pre-sorted `Vec` chosen after a structural rebuild
30///   (compaction, rebalance, repack) that leaves the row stable.  Point
31///   lookups use `partition_point` (binary search) which is cache-friendlier
32///   than hashing on wide rows and avoids the per-entry allocation overhead
33///   of the hash table.
34///
35/// The write path always mutates the hash variant.  When a structural
36/// rebuild produces a set the caller marks stable, the set is promoted to
37/// the sorted variant.  Any subsequent insert or remove converts back to
38/// hash so the hot path stays O(1); the next rebuild may re-promote.
39#[derive(Debug, Clone)]
40pub(crate) enum LiveKeySet {
41    Hash(HashMap<(u32, i64), EdgePosition>),
42    Sorted(Vec<((u32, i64), EdgePosition)>),
43}
44
45impl Default for LiveKeySet {
46    fn default() -> Self {
47        Self::Hash(HashMap::new())
48    }
49}
50
51impl LiveKeySet {
52    pub(crate) fn from_positions(positions: Vec<((u32, i64), EdgePosition)>) -> Self {
53        let mut sorted = positions;
54        sorted.sort_unstable_by_key(|(k, _)| *k);
55        Self::Sorted(sorted)
56    }
57
58    pub(crate) fn contains(&self, key: &(u32, i64)) -> bool {
59        match self {
60            Self::Hash(map) => map.contains_key(key),
61            Self::Sorted(vec) => {
62                let idx = vec.partition_point(|(k, _)| k < key);
63                idx < vec.len() && vec[idx].0 == *key
64            }
65        }
66    }
67
68    pub(crate) fn position(&self, key: &(u32, i64)) -> Option<EdgePosition> {
69        match self {
70            Self::Hash(map) => map.get(key).copied(),
71            Self::Sorted(vec) => {
72                let idx = vec.partition_point(|(k, _)| k < key);
73                if idx < vec.len() && vec[idx].0 == *key {
74                    Some(vec[idx].1)
75                } else {
76                    None
77                }
78            }
79        }
80    }
81
82    pub(crate) fn len(&self) -> usize {
83        match self {
84            Self::Hash(map) => map.len(),
85            Self::Sorted(vec) => vec.len(),
86        }
87    }
88
89    pub(crate) fn is_empty(&self) -> bool {
90        match self {
91            Self::Hash(map) => map.is_empty(),
92            Self::Sorted(vec) => vec.is_empty(),
93        }
94    }
95
96    /// Insert a key-position pair, converting to hash when currently sorted.
97    ///
98    /// The hot write path always arrives here; converting to hash keeps
99    /// repeated inserts O(1).  The next structural rebuild may re-promote.
100    pub(crate) fn insert(&mut self, key: (u32, i64), position: EdgePosition) {
101        match self {
102            Self::Hash(map) => {
103                map.insert(key, position);
104            }
105            Self::Sorted(_) => {
106                let sorted = std::mem::take(self).into_sorted();
107                let mut map = HashMap::with_capacity(sorted.len() + 1);
108                map.extend(sorted);
109                map.insert(key, position);
110                *self = Self::Hash(map);
111            }
112        }
113    }
114
115    /// Remove a key, converting to hash when currently sorted.
116    ///
117    /// Mirrors `insert`: a sorted vector removal shifts the whole tail, so
118    /// repeated deletes between rebuilds degrade linearly with row width.
119    /// Converting once keeps every later mutation constant-time; the next
120    /// structural rebuild may re-promote to sorted.
121    pub(crate) fn remove(&mut self, key: &(u32, i64)) {
122        match self {
123            Self::Hash(map) => {
124                map.remove(key);
125            }
126            Self::Sorted(_) => {
127                let sorted = std::mem::take(self).into_sorted();
128                let mut map = HashMap::with_capacity(sorted.len());
129                map.extend(sorted);
130                map.remove(key);
131                *self = Self::Hash(map);
132            }
133        }
134    }
135
136    /// Heap bytes held outside the struct itself.
137    pub(crate) fn heap_bytes(&self) -> usize {
138        let entry_size = std::mem::size_of::<((u32, i64), EdgePosition)>() + 8;
139        match self {
140            Self::Hash(map) => map.len() * entry_size,
141            Self::Sorted(vec) => vec.len() * entry_size,
142        }
143    }
144
145    /// Consume the set and return the inner sorted vector if sorted,
146    /// otherwise collect into a sorted vector.
147    fn into_sorted(self) -> Vec<((u32, i64), EdgePosition)> {
148        match self {
149            Self::Hash(map) => {
150                let mut entries: Vec<_> = map.into_iter().collect();
151                entries.sort_unstable_by_key(|(k, _)| *k);
152                entries
153            }
154            Self::Sorted(vec) => vec,
155        }
156    }
157}
158
159/// Segmented sparse per-vertex live-key index.
160///
161/// Row addresses inside one CSR are dense vertex ids, so indexed rows are
162/// addressed by direct subscript (segment by shift, offset by mask) instead
163/// of hashing. Segments with no indexed row stay unallocated and cost only
164/// the pointer. Empty and narrow rows hold no set. Lifetimes match the
165/// overflow index: created once a row grows past the bound, dropped when the
166/// row narrows or goes empty.
167#[derive(Debug, Clone, Default)]
168pub(crate) struct LiveSetStorage {
169    table: SegmentedTable<LiveKeySet>,
170    live_rows: usize,
171}
172
173impl LiveSetStorage {
174    pub(crate) fn new() -> Self {
175        Self {
176            table: SegmentedTable::new(),
177            live_rows: 0,
178        }
179    }
180
181    pub(crate) fn ensure_capacity(&mut self, vertex_capacity: usize) {
182        self.table.ensure_capacity(vertex_capacity);
183    }
184
185    pub(crate) fn get(&self, vid: &u32) -> Option<&LiveKeySet> {
186        self.table.get(*vid)
187    }
188
189    pub(crate) fn insert(&mut self, vid: u32, set: LiveKeySet) {
190        let slot = self.table.slot_mut(vid);
191        if slot.is_none() {
192            self.live_rows += 1;
193        }
194        *slot = Some(set);
195    }
196
197    pub(crate) fn remove(&mut self, vid: &u32) {
198        if self.table.take(*vid).is_some() {
199            self.live_rows = self.live_rows.saturating_sub(1);
200        }
201    }
202
203    pub(crate) fn insert_key(&mut self, vid: u32, key: (u32, i64), position: EdgePosition) {
204        let slot = self.table.slot_mut(vid);
205        match slot {
206            Some(set) => set.insert(key, position),
207            slot @ None => {
208                *slot = Some(LiveKeySet::from_positions(vec![(key, position)]));
209                self.live_rows += 1;
210            }
211        }
212    }
213
214    pub(crate) fn remove_key(&mut self, vid: u32, key: &(u32, i64)) {
215        let emptied = match self.table.get_mut(vid) {
216            Some(set) => {
217                set.remove(key);
218                set.is_empty()
219            }
220            None => return,
221        };
222        if emptied {
223            self.table.take(vid);
224            self.live_rows = self.live_rows.saturating_sub(1);
225        }
226    }
227
228    pub(crate) fn clear(&mut self) {
229        self.table.clear();
230        self.live_rows = 0;
231    }
232
233    pub(crate) fn heap_bytes_total(&self) -> usize {
234        self.table.iter().map(|(_, set)| set.heap_bytes()).sum()
235    }
236
237    pub(crate) fn index_bytes(&self) -> usize {
238        self.table.table_bytes()
239            + self.live_rows * (std::mem::size_of::<u32>() + std::mem::size_of::<LiveKeySet>())
240    }
241}
242
243impl MutableCsr {
244    pub fn has_live_set(&self, vid: &u32) -> bool {
245        self.live_sets.get(vid).is_some()
246    }
247
248    /// Layering observability: narrow rows served by scans vs wide rows
249    /// served by the live-set index, with the fixed width bound.
250    ///
251    /// Thresholds stay fixed; this only reports so operators can tell
252    /// whether hot traversals hit the indexed path and how long narrow
253    /// scans run before any tuning decision.
254    pub(crate) fn layer_counts(&self) -> (usize, usize) {
255        let mut narrow = 0usize;
256        let mut wide = 0usize;
257        for vid in 0..self.vertex_capacity() as u32 {
258            if self.live_sets.get(&vid).is_some() {
259                wide += 1;
260            } else if self.row_live_count(vid) > 0 {
261                narrow += 1;
262            }
263        }
264        (narrow, wide)
265    }
266}
267
268impl MutableCsr {
269    pub(crate) fn rebuild_live_sets(&mut self) {
270        let capacity = self.vertex_capacity() as u32;
271        self.live_sets.clear();
272        self.live_sets.ensure_capacity(self.vertex_capacity());
273        for vid in 0..capacity {
274            self.rebuild_live_set_for_vertex(vid);
275        }
276    }
277    /// Live key count of one vertex, zero when the vertex holds no entry.
278    ///
279    /// Indexed rows answer from the set length; narrow rows scan the row.
280    /// The scan cost stays proportional to the row width, which is small by
281    /// construction wherever no set exists.
282    pub(crate) fn live_key_count(&self, vid: u32) -> usize {
283        if let Some(set) = self.live_sets.get(&vid) {
284            return set.len();
285        }
286        self.row_live_count(vid)
287    }
288
289    /// Combined duplicate-plus-live-width probe of one row.
290    ///
291    /// Single pass behind `insert_edge` and `live_key_present`: reports
292    /// whether a live entry for the key exists and how many live entries
293    /// the row holds, so one insert pays one row walk instead of one for
294    /// the duplicate check plus one for the width count.
295    ///
296    /// When incremental live counts are maintained (narrow rows), the live
297    /// width is returned from the counter and the scan only checks for the
298    /// duplicate key, short-circuiting once found.
299    pub(crate) fn row_live_scan(&self, vid: u32, endpoint: u32, rank: i64) -> (bool, usize) {
300        let idx = vid as usize;
301        if idx >= self.vertex_capacity() {
302            return (false, 0);
303        }
304        let mut present = false;
305        let (hot, cold) = self.primary_pair(idx);
306        for (h, c) in hot.iter().zip(cold.iter()) {
307            if c.is_live() && h.endpoint == endpoint && h.rank == rank {
308                present = true;
309                break;
310            }
311        }
312        if !present {
313            if let Some(chunks) = self.overflow_chunks.get(vid) {
314                'outer: for chunk in chunks {
315                    for (hot, cold) in chunk.hot_slice().iter().zip(chunk.cold_slice()) {
316                        if cold.is_live() && hot.endpoint == endpoint && hot.rank == rank {
317                            present = true;
318                            break 'outer;
319                        }
320                    }
321                }
322            }
323        }
324        let live = self.live_counts[idx] as usize;
325        (present, live)
326    }
327
328    /// Live entry count of one narrow row.
329    ///
330    /// Uses the incremental counter for O(1) access instead of scanning.
331    pub(crate) fn row_live_count(&self, vid: u32) -> usize {
332        let idx = vid as usize;
333        if idx >= self.vertex_capacity() {
334            return 0;
335        }
336        self.live_counts[idx] as usize
337    }
338
339    pub(crate) fn track_live_insert(
340        &mut self,
341        vid: u32,
342        endpoint: u32,
343        rank: i64,
344        position: EdgePosition,
345    ) {
346        if self.live_sets.get(&vid).is_some() {
347            self.live_sets.insert_key(vid, (endpoint, rank), position);
348            return;
349        }
350        // Narrow rows stay set-free until the physical row width (primary
351        // entries plus overflow entries) passes the bound. The width check
352        // touches only lengths, not entries: counting live entries instead
353        // would cost a full row walk per insert, so the physical gate is
354        // kept deliberately. Rebuilds drop the set again
355        // when the live width is still narrow, so tombstone-heavy rows may
356        // rescan on later inserts until maintenance compacts them.
357        let mut width = 0usize;
358        if (vid as usize) < self.vertex_capacity() {
359            width = self.rows.degrees[vid as usize] as usize;
360            if let Some(chunks) = self.overflow_chunks.get(vid) {
361                width += chunks.iter().map(|chunk| chunk.len()).sum::<usize>();
362            }
363        }
364        if width > LIVE_SET_WIDTH_BOUND {
365            self.rebuild_live_set_for_vertex(vid);
366        }
367    }
368
369    pub(crate) fn track_live_remove(&mut self, vid: u32, endpoint: u32, rank: i64) {
370        // Narrow rows carry no set, so there is nothing to maintain.
371        if self.live_sets.get(&vid).is_none() {
372            return;
373        }
374        self.live_sets.remove_key(vid, &(endpoint, rank));
375    }
376
377    pub(crate) fn rebuild_live_set_for_vertex(&mut self, vid: u32) {
378        let idx = vid as usize;
379        if idx >= self.vertex_capacity() {
380            self.live_sets.remove(&vid);
381            self.live_set_rebuild_count += 1;
382            return;
383        }
384        let (hot, cold) = self.primary_pair(idx);
385        let mut positioned = Vec::new();
386        let mut live = 0u32;
387        let mut tombstones = 0u32;
388        for (slot, (h, c)) in hot.iter().zip(cold.iter()).enumerate() {
389            if c.is_live() {
390                live += 1;
391                positioned.push((
392                    (h.endpoint, h.rank),
393                    EdgePosition::Primary { slot: slot as u32 },
394                ));
395            } else {
396                tombstones += 1;
397            }
398        }
399        if let Some(chunks) = self.overflow_chunks.get(vid) {
400            for (chunk_idx, chunk) in chunks.iter().enumerate() {
401                for (slot_idx, (hot, cold)) in
402                    chunk.hot_slice().iter().zip(chunk.cold_slice()).enumerate()
403                {
404                    if cold.is_live() {
405                        live += 1;
406                        positioned.push((
407                            (hot.endpoint, hot.rank),
408                            EdgePosition::Overflow {
409                                chunk: chunk_idx as u32,
410                                slot: slot_idx as u32,
411                            },
412                        ));
413                    } else {
414                        tombstones += 1;
415                    }
416                }
417            }
418        }
419        self.live_counts[idx] = live;
420        self.tombstone_counts[idx] = tombstones;
421        // Narrow rows stay set-free; only wide rows pay for the index.
422        if positioned.len() <= LIVE_SET_WIDTH_BOUND {
423            self.live_sets.remove(&vid);
424        } else {
425            self.live_sets
426                .insert(vid, LiveKeySet::from_positions(positioned));
427        }
428        self.live_set_rebuild_count += 1;
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn set_contains_and_removes() {
438        use super::super::write::EdgePosition;
439        let mut set = LiveKeySet::from_positions(vec![
440            ((3, 0), EdgePosition::Primary { slot: 0 }),
441            ((1, 0), EdgePosition::Primary { slot: 1 }),
442            ((2, 0), EdgePosition::Primary { slot: 2 }),
443        ]);
444        assert!(set.contains(&(1, 0)));
445        assert_eq!(
446            set.position(&(1, 0)),
447            Some(EdgePosition::Primary { slot: 1 })
448        );
449        assert!(!set.contains(&(9, 0)));
450        assert_eq!(set.position(&(9, 0)), None);
451        set.remove(&(2, 0));
452        assert!(!set.contains(&(2, 0)));
453        assert_eq!(set.len(), 2);
454    }
455
456    #[test]
457    fn wide_rows_carry_a_set() {
458        use super::super::write::EdgePosition;
459        let mut set = LiveKeySet::default();
460        for i in 0..=(LIVE_SET_WIDTH_BOUND as u32) {
461            set.insert((i, 0), EdgePosition::Primary { slot: i });
462        }
463        for i in 0..=(LIVE_SET_WIDTH_BOUND as u32) {
464            assert!(set.contains(&(i, 0)));
465            assert_eq!(
466                set.position(&(i, 0)),
467                Some(EdgePosition::Primary { slot: i })
468            );
469        }
470        set.remove(&(0, 0));
471        assert!(!set.contains(&(0, 0)));
472    }
473
474    #[test]
475    fn duplicate_inserts_stay_unique() {
476        use super::super::write::EdgePosition;
477        let mut set = LiveKeySet::default();
478        set.insert((1, 5), EdgePosition::Primary { slot: 0 });
479        set.insert((1, 5), EdgePosition::Primary { slot: 0 });
480        assert_eq!(set.len(), 1);
481    }
482
483    #[test]
484    fn empty_vertices_cost_only_pointers() {
485        use super::super::super::csr_shared::SEGMENT_SIZE;
486        let mut storage = LiveSetStorage::new();
487        storage.ensure_capacity(1_000_000);
488        assert!(storage.index_bytes() < 1_000_000);
489        assert!(storage.get(&999_999).is_none());
490        // Untouched segments allocate nothing: one indexed row far out must
491        // not inflate the table beyond its own segment slab.
492        storage.insert(
493            999_999,
494            LiveKeySet::from_positions(vec![(
495                (7, 0),
496                super::super::write::EdgePosition::Primary { slot: 0 },
497            )]),
498        );
499        assert!(storage.get(&999_999).is_some());
500        let _ = SEGMENT_SIZE;
501    }
502
503    #[test]
504    fn sparse_rows_route_and_iterate_in_order() {
505        use super::super::super::csr_shared::SEGMENT_SIZE;
506        use super::super::write::EdgePosition;
507        let mut storage = LiveSetStorage::new();
508        let seg = SEGMENT_SIZE as u32;
509        storage.insert_key(3 * seg + 7, (9, 0), EdgePosition::Primary { slot: 0 });
510        storage.insert_key(5, (1, 0), EdgePosition::Primary { slot: 0 });
511        storage.insert_key(seg, (2, 0), EdgePosition::Primary { slot: 0 });
512        assert!(storage.get(&seg).is_some_and(|set| set.contains(&(2, 0))));
513        assert!(storage.get(&(seg - 1)).is_none());
514        storage.remove_key(seg, &(2, 0));
515        assert!(storage.get(&seg).is_none());
516        storage.insert_key(seg, (4, 0), EdgePosition::Primary { slot: 3 });
517        assert!(storage.get(&seg).is_some_and(|set| set.contains(&(4, 0))));
518    }
519}