Skip to main content

linkrs_storage/edge/mutable_csr/
row.rs

1use super::super::csr_shared::is_reclaimable_cold;
2use super::super::{EdgeId, Nbr, Timestamp};
3use super::overflow::OverflowChunk;
4use super::MutableCsr;
5
6/// Target density for packed rows: live entries per unit of reserved row
7/// capacity. Rebuilds size rows to `ceil(live / PACKED_CSR_DENSITY)` so
8/// everyday writes land in row gaps before spilling to overflow.
9///
10/// Source: 0.8 reserves a 25% write gap per row, so steady-state inserts
11/// fill gaps instead of allocating overflow chunks (covered by
12/// `test_steady_state_gap_fill_before_overflow`), while keeping reserved
13/// memory bounded. Recommended range 0.7..=0.9; lower wastes memory, higher
14/// sheds the gap and pushes every insert into overflow. Retuning requires
15/// rerunning the compaction and insert benchmarks first.
16pub(crate) const PACKED_CSR_DENSITY: f32 = 0.8;
17/// Minimum primary slots kept for a live row after a row rebalance, so tiny
18/// rows still hold a small write gap without another allocation.
19///
20/// Source: 4 slots cover the common single-digit-degree vertex with room for
21/// one more edge; smaller values reallocate on the very next insert, larger
22/// values waste primary memory across millions of tiny rows. Fixed unless a
23/// small-row allocation benchmark proves otherwise.
24pub(crate) const MIN_ROW_CAPACITY: usize = 4;
25
26/// Proportional overflow sizing, single benchmarked scheme. Chunk sizes
27/// grow geometrically with live row width from a small floor, so a 5-edge
28/// row reserves 8 slots instead of 256. The effective size is
29/// `min(configured, graded(live))` so explicit test configurations keep
30/// their exact size.
31///
32/// Source: the floor of 8 matches `MIN_ROW_CAPACITY` granularity and the
33/// supernode bench in `benches/csr_perf_bench.rs` (graded tiers section);
34/// the cap of 4096 matches the default chunk configuration so a single
35/// chunk never exceeds one configured allocation unit. Geometric growth
36/// keeps skewed-row appends amortized while small rows stay small.
37/// Recommended floor range 4..=16, cap fixed at the configured chunk size.
38/// Retuning requires rerunning the supernode append benchmark first.
39pub(crate) const OVERFLOW_CHUNK_MIN: usize = 8;
40pub(crate) const OVERFLOW_CHUNK_MAX: usize = 4096;
41
42/// Overflow chunk size proportional to live row width: the next power of two
43/// above `live` with a small floor and a hard cap. Monotonic in `live`, so
44/// rows never shrink their chunk size as they grow.
45pub(crate) fn graded_overflow_chunk_edges(live: usize) -> usize {
46    live.next_power_of_two()
47        .clamp(OVERFLOW_CHUNK_MIN, OVERFLOW_CHUNK_MAX)
48}
49
50/// Live width at or above which a row uses the dedicated high-degree path.
51///
52/// Past this width the per-chunk cap stops applying: the row consolidates
53/// into one dedicated contiguous block with spare instead of a chain of
54/// capped chunks, so scans touch the primary block plus one overflow block.
55/// Source: supernode appends past 16k live edges otherwise hold four or more
56/// capped chunks and pay a chain walk on every scan; the dedicated block
57/// restores the single-block fast path. Recommended range 8192..=32768.
58/// Retuning requires rerunning the supernode append benchmark first.
59pub(crate) const HIGH_DEGREE_LIVE_THRESHOLD: usize = 16_384;
60/// Overflow chunk size for high-degree rows, four times the default chunk.
61///
62/// Large enough to keep a 100k-degree row within a handful of blocks between
63/// consolidations, small enough that one idle huge row does not pin tens of
64/// megabytes. Used only above the threshold; smaller rows keep the graded
65/// capped sizing.
66pub(crate) const HIGH_DEGREE_CHUNK_EDGES: usize = 16_384;
67/// Spare reservation kept inside a high-degree consolidation, as a share of
68/// the consolidated length. Matches the packed density gap so the next burst
69/// lands in reserved space instead of allocating a fresh tail chunk.
70pub(crate) const HIGH_DEGREE_SPARE_DIVISOR: usize = 4;
71
72impl MutableCsr {
73    /// Row capacity holding `live` entries at the packed density target.
74    pub(crate) fn sized_row_capacity(live: usize) -> usize {
75        if live == 0 {
76            return 0;
77        }
78        ((live as f32 / PACKED_CSR_DENSITY).ceil() as usize).max(MIN_ROW_CAPACITY.min(live.max(1)))
79    }
80
81    /// Effective overflow chunk size for a row with `live` live entries.
82    pub(crate) fn effective_chunk_edges(&self, live: usize) -> usize {
83        if live >= HIGH_DEGREE_LIVE_THRESHOLD {
84            return graded_overflow_chunk_edges(live)
85                .max(HIGH_DEGREE_CHUNK_EDGES)
86                .max(1);
87        }
88        graded_overflow_chunk_edges(live)
89            .min(self.overflow_chunk_edges)
90            .max(1)
91    }
92
93    /// Reserved primary slots minus live primary entries of one row.
94    pub fn row_gap(&self, vid: u32) -> usize {
95        let idx = vid as usize;
96        if idx >= self.vertex_capacity() {
97            return 0;
98        }
99        let (_, cold) = self.primary_pair(idx);
100        let live = cold.iter().filter(|c| c.is_live()).count();
101        (self.rows.primary_capacities[idx] as usize).saturating_sub(live)
102    }
103
104    /// Live primary entries per unit of reserved primary capacity.
105    pub fn row_density(&self, vid: u32) -> f32 {
106        let idx = vid as usize;
107        if idx >= self.vertex_capacity() {
108            return 1.0;
109        }
110        let cap = self.rows.primary_capacities[idx] as usize;
111        if cap == 0 {
112            return 1.0;
113        }
114        let (_, cold) = self.primary_pair(idx);
115        let live = cold.iter().filter(|c| c.is_live()).count();
116        live as f32 / cap as f32
117    }
118
119    /// Rebalance one row in place: tighten live primary entries to the
120    /// front, pull overflow live entries into primary gaps, and repack any
121    /// leftover overflow into graded chunks. Primary capacity never grows
122    /// here, so other rows never move; capacity growth stays with full and
123    /// region compactions that size rows at the density target. Pinned
124    /// tombstones stay in place. Returns true when overflow shrank.
125    pub fn rebalance_row(&mut self, vid: u32) -> bool {
126        let idx = vid as usize;
127        if idx >= self.vertex_capacity() || self.rows.primary_capacities[idx] == 0 {
128            return false;
129        }
130        if self.overflow_chunks.get(vid).is_none_or(Vec::is_empty) {
131            return false;
132        }
133        let degree = self.rows.degrees[idx] as usize;
134        let offset = self.rows.adj_offsets[idx] as usize;
135        let cap = self.rows.primary_capacities[idx] as usize;
136        let mut live: Vec<Nbr> = Vec::new();
137        let mut pinned: Vec<Nbr> = Vec::new();
138        for i in 0..degree {
139            if let Some(nbr) = self.slot_at(offset + i) {
140                if nbr.delete_ts == Timestamp::MAX {
141                    live.push(nbr);
142                } else {
143                    pinned.push(nbr);
144                }
145            }
146        }
147        if let Some(chunks) = self.overflow_chunks.get(vid) {
148            for chunk in chunks {
149                for i in 0..chunk.len() {
150                    if let Some(nbr) = chunk.slot_at(i) {
151                        if nbr.delete_ts == Timestamp::MAX {
152                            live.push(nbr);
153                        } else {
154                            pinned.push(nbr);
155                        }
156                    }
157                }
158            }
159        }
160        let slots = cap.min(live.len() + pinned.len());
161        let mut placed = 0usize;
162        for nbr in live.iter().chain(pinned.iter()).take(slots) {
163            if offset + placed < self.hot_list.len() {
164                self.set_slot(offset + placed, *nbr);
165            }
166            placed += 1;
167        }
168        self.rows.degrees[idx] = placed as u32;
169        self.invalidate_reuse_hint(idx);
170        self.mark_primary_unsorted(idx);
171        let placed_live = live.len().min(slots);
172        let overflow_live: Vec<Nbr> = live.into_iter().skip(placed_live).collect();
173        let placed_pinned = pinned.len().min(slots.saturating_sub(placed_live));
174        let overflow_pinned: Vec<Nbr> = pinned.into_iter().skip(placed_pinned).collect();
175        let old_overflow_cap: usize = self.overflow_chunks.get(vid).map_or(0, |chunks| {
176            chunks.iter().map(|chunk| chunk.capacity()).sum()
177        });
178        if overflow_live.is_empty() && overflow_pinned.is_empty() {
179            self.overflow_chunks.remove(vid);
180            self.sub_capacity(old_overflow_cap);
181        } else {
182            let mut rest: Vec<Nbr> =
183                Vec::with_capacity(overflow_live.len() + overflow_pinned.len());
184            rest.extend_from_slice(&overflow_live);
185            rest.extend_from_slice(&overflow_pinned);
186            // Same single-block consolidation as the repack path: leftover
187            // overflow reads as one block after the primary row. High-degree
188            // leftovers keep spare so the next burst stays in the block.
189            let single = if rest.len() >= HIGH_DEGREE_LIVE_THRESHOLD {
190                let spare =
191                    (rest.len() / HIGH_DEGREE_SPARE_DIVISOR).max(HIGH_DEGREE_CHUNK_EDGES / 2);
192                OverflowChunk::consolidated_with_reserve(&rest, spare)
193            } else {
194                OverflowChunk::consolidated(&rest)
195            };
196            let new_overflow_cap = single.capacity();
197            self.sub_capacity(old_overflow_cap);
198            self.add_capacity(new_overflow_cap);
199            if let Some(slot) = self.overflow_chunks.get_mut(vid) {
200                *slot = vec![single];
201            }
202        }
203        self.rebuild_live_set_for_vertex(vid);
204        self.overflow_chunks.get(vid).is_none_or(Vec::is_empty)
205    }
206
207    /// Sort one primary row into `(endpoint, rank, edge_id)` order.
208    ///
209    /// Maintenance-only entry: sorting moves slots, so every previously
210    /// issued `EdgePosition` for this row becomes stale and the caller must
211    /// relocate through the edge-id key first. The reuse hint is dropped,
212    /// the cached primary order is established, and the live index is
213    /// rebuilt into its sorted form. Overflow chunks stay in insertion
214    /// order and remain the unsorted suffix. New writes keep the hot path
215    /// (primary gap fill, then overflow tail) and therefore clear the cached
216    /// order again; the next maintenance pass re-sorts. The flag is never
217    /// persisted: load resets every row to unordered and the maintenance
218    /// pass re-establishes order.
219    pub fn sort_row(&mut self, vid: u32) -> bool {
220        let idx = vid as usize;
221        if idx >= self.vertex_capacity() || self.rows.primary_capacities[idx] == 0 {
222            self.mark_primary_sorted(idx);
223            return false;
224        }
225        let degree = self.rows.degrees[idx] as usize;
226        if degree <= 1 {
227            self.mark_primary_sorted(idx);
228            return false;
229        }
230        let base = self.rows.adj_offsets[idx] as usize;
231        if base.saturating_add(degree) > self.hot_list.len()
232            || base.saturating_add(degree) > self.cold_list.len()
233        {
234            return false;
235        }
236        let mut order: Vec<usize> = (0..degree).collect();
237        let hot = &self.hot_list[base..base + degree];
238        let already = order.windows(2).all(|w| {
239            let a = &hot[w[0]];
240            let b = &hot[w[1]];
241            (a.endpoint, a.rank, a.edge_id.0) <= (b.endpoint, b.rank, b.edge_id.0)
242        });
243        if already {
244            self.mark_primary_sorted(idx);
245            return false;
246        }
247        order.sort_by(|&a, &b| {
248            let ha = &hot[a];
249            let hb = &hot[b];
250            (ha.endpoint, ha.rank, ha.edge_id.0).cmp(&(hb.endpoint, hb.rank, hb.edge_id.0))
251        });
252        let hot_src = self.hot_list[base..base + degree].to_vec();
253        let cold_src = self.cold_list[base..base + degree].to_vec();
254        for (dst, src) in order.iter().enumerate() {
255            self.hot_list[base + dst] = hot_src[*src];
256            self.cold_list[base + dst] = cold_src[*src];
257        }
258        self.invalidate_reuse_hint(idx);
259        self.mark_primary_sorted(idx);
260        self.rebuild_live_set_for_vertex(vid);
261        true
262    }
263
264    /// Sort every primary row that is out of order. Returns reordered rows.
265    ///
266    /// Same maintenance-only invalidation as `sort_row`, applied row by row.
267    pub fn sort_all_rows(&mut self) -> usize {
268        let rows = self.vertex_capacity();
269        let mut reordered = 0usize;
270        for vid in 0..rows {
271            if self.sort_row(vid as u32) {
272                reordered += 1;
273            }
274        }
275        reordered
276    }
277
278    pub(crate) fn compact_overflow_for_vertex(
279        &mut self,
280        vid: u32,
281        cutoff: Timestamp,
282        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
283    ) {
284        let Some(chunks) = self.overflow_chunks.remove(vid) else {
285            return;
286        };
287        let old_cap: usize = chunks.iter().map(|c| c.capacity()).sum();
288        self.sub_capacity(old_cap);
289        let removals_enabled = cutoff != Timestamp::MAX;
290        let mut kept: Vec<Nbr> = Vec::new();
291        for chunk in &chunks {
292            for i in 0..chunk.len() {
293                let nbr = chunk.slot_at(i).unwrap();
294                if removals_enabled && is_reclaimable_cold(&nbr.cold(), cutoff) {
295                    on_edge_removed(nbr.edge_id, nbr.delete_ts);
296                } else {
297                    kept.push(nbr);
298                }
299            }
300        }
301        if kept.is_empty() {
302            self.rebuild_live_set_for_vertex(vid);
303            return;
304        }
305        // Consolidate into one overflow chunk. Primary-tail expansion was
306        // removed: merged rows stay on the single-chunk path so compaction
307        // never copies primary blocks. High-degree rows keep spare inside
308        // the dedicated block so the next burst lands in reserved space.
309        let single = if kept.len() >= HIGH_DEGREE_LIVE_THRESHOLD {
310            let spare = (kept.len() / HIGH_DEGREE_SPARE_DIVISOR).max(HIGH_DEGREE_CHUNK_EDGES / 2);
311            OverflowChunk::consolidated_with_reserve(&kept, spare)
312        } else {
313            OverflowChunk::consolidated(&kept)
314        };
315        let new_cap = single.capacity();
316        self.add_capacity(new_cap);
317        self.overflow_chunks.insert(vid, vec![single]);
318        self.rebuild_live_set_for_vertex(vid);
319    }
320}