Skip to main content

lora_store/memory/
mem_report.rs

1//! Heap-byte breakdown of an [`InMemoryGraph`](super::InMemoryGraph).
2//!
3//! This is a *debug-only* estimator: it walks the graph's owned
4//! structures and sums an approximation of their retained heap bytes,
5//! attributed to the component that owns each allocation (node slab,
6//! relationship slab, adjacency, label/type indexes, each secondary
7//! index registry, the index catalog, the constraint catalog).
8//!
9//! The numbers it returns are *approximate*. `BTreeMap` and `HashMap`
10//! both carry node/bucket overhead that varies with allocator state,
11//! load factor, and Rust version; we use fixed amortised constants
12//! ([`BTREE_PER_ENTRY`], [`HASHMAP_PER_ENTRY`]) so that callers can
13//! diff two reports without the per-run noise an allocator-walking
14//! profiler would introduce. For property keys, we count the
15//! `Arc<str>` header but *not* the shared byte buffer — keys go
16//! through the process-wide `super::super::intern` table, so adding
17//! their content per occurrence would dramatically over-count the
18//! marginal cost a property bag actually adds to the graph.
19//!
20//! Used by `crates/lora-database/benches/memory.rs` and the
21//! `mem_probe*` examples in `crates/lora-server/examples/` to
22//! attribute observed RSS growth to a specific component without
23//! resorting to `dhat` / `heaptrack` for every run.
24
25use std::collections::BTreeMap;
26use std::mem::size_of;
27
28use crate::{LoraBinary, LoraPoint, LoraVector, NodeRecord, PropertyValue, RelationshipRecord};
29
30use super::chunked_vec::ChunkedVec;
31use super::entity_index_store::{IndexBundle, ScopedPropertyKey};
32use super::fulltext_index::FulltextRegistry;
33use super::hnsw::HnswBackend;
34use super::id_set::IdSet;
35use super::index_catalog::{IndexCatalog, StoredIndexEntity};
36use super::point_index::PointRegistry;
37use super::property_index::{
38    PropertyIndex, PropertyIndexKey, PropertyIndexRegistry, PropertyIndexState,
39};
40use super::sorted_property_index::SortedPropertyIndex;
41use super::text_index::TrigramRegistry;
42use super::vector_index::{FlatBackend, VectorBackend, VectorIndexRegistry};
43use super::ConstraintCatalog;
44
45/// Amortised heap overhead per `BTreeMap` entry. Empirically a BTree
46/// inner node holds ~11 entries in a ~256-byte allocation, so the
47/// per-entry share is roughly 24 bytes.
48const BTREE_PER_ENTRY: usize = 24;
49
50/// Amortised heap overhead per `HashMap` entry — bucket pointer plus
51/// hash table load factor.
52const HASHMAP_PER_ENTRY: usize = 24;
53
54/// `std::sync::Arc<T>`: strong + weak refcount header that precedes
55/// the inline `T` allocation.
56const ARC_HEADER: usize = 16;
57
58/// Aggregated retained-heap breakdown of an [`InMemoryGraph`].
59///
60/// All fields are in *bytes*. `usize` was chosen over `u64` so call
61/// sites can use `cargo bench` reports and pretty-print without casts;
62/// on 32-bit hosts a graph that overflowed this would already have
63/// failed earlier.
64#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct MemoryReport {
67    pub live_node_count: usize,
68    pub live_relationship_count: usize,
69    pub node_tombstone_count: usize,
70    pub relationship_tombstone_count: usize,
71
72    /// `nodes: Vec<Option<Arc<NodeRecord>>>` — slot vector plus the
73    /// `Arc` headers plus deep payload of each live record (labels,
74    /// properties — see [`property_value_heap_bytes`]).
75    pub nodes_bytes: usize,
76    /// `relationships: Vec<Option<Arc<RelationshipRecord>>>`.
77    pub relationships_bytes: usize,
78
79    /// `outgoing: Vec<Vec<RelationshipId>>` — outer Vec capacity plus
80    /// the sum of each inner Vec's capacity in bytes.
81    pub outgoing_bytes: usize,
82    /// Same for `incoming`.
83    pub incoming_bytes: usize,
84
85    /// `nodes_by_label: BTreeMap<String, ChunkedVec<NodeId>>`.
86    pub label_index_bytes: usize,
87    /// `relationships_by_type: BTreeMap<String, ChunkedVec<RelationshipId>>`.
88    pub type_index_bytes: usize,
89
90    /// Hash-bucket property registry (`find_*_by_property`).
91    pub property_index_bytes: usize,
92    /// Sorted-range property index (`RANGE` catalog entries).
93    pub sorted_index_bytes: usize,
94    /// Trigram TEXT indexes.
95    pub text_index_bytes: usize,
96    /// Spatial grid POINT indexes.
97    pub point_index_bytes: usize,
98    /// FULLTEXT inverted indexes.
99    pub fulltext_index_bytes: usize,
100    /// VECTOR indexes (Flat and HNSW backends).
101    pub vector_index_bytes: usize,
102
103    pub index_catalog_bytes: usize,
104    pub constraint_catalog_bytes: usize,
105}
106
107impl MemoryReport {
108    /// Bytes attributable to the core graph shape: slabs, adjacency,
109    /// and the label/type indexes. This is the floor every workload
110    /// pays regardless of which secondary indexes are installed.
111    pub fn graph_core_bytes(&self) -> usize {
112        self.nodes_bytes
113            + self.relationships_bytes
114            + self.outgoing_bytes
115            + self.incoming_bytes
116            + self.label_index_bytes
117            + self.type_index_bytes
118    }
119
120    /// Bytes attributable to secondary indexes — the amount that would
121    /// be reclaimed by dropping every index.
122    pub fn secondary_index_bytes(&self) -> usize {
123        self.property_index_bytes
124            + self.sorted_index_bytes
125            + self.text_index_bytes
126            + self.point_index_bytes
127            + self.fulltext_index_bytes
128            + self.vector_index_bytes
129    }
130
131    pub fn catalog_bytes(&self) -> usize {
132        self.index_catalog_bytes + self.constraint_catalog_bytes
133    }
134
135    pub fn total_bytes(&self) -> usize {
136        self.graph_core_bytes() + self.secondary_index_bytes() + self.catalog_bytes()
137    }
138
139    /// Average retained bytes per live node, including its share of
140    /// the slab tombstone overhead. Returns 0 when there are no
141    /// nodes.
142    pub fn bytes_per_live_node(&self) -> f64 {
143        ratio(self.nodes_bytes, self.live_node_count)
144    }
145
146    pub fn bytes_per_live_relationship(&self) -> f64 {
147        ratio(self.relationships_bytes, self.live_relationship_count)
148    }
149
150    /// One-line breakdown for bench / probe output. Stable enough for
151    /// `grep`-friendly snapshot diffs across runs.
152    pub fn summary(&self) -> String {
153        format!(
154            "total={} graph={} (nodes={} rels={} out={} in={} labels={} types={}) idx={} cat={}",
155            self.total_bytes(),
156            self.graph_core_bytes(),
157            self.nodes_bytes,
158            self.relationships_bytes,
159            self.outgoing_bytes,
160            self.incoming_bytes,
161            self.label_index_bytes,
162            self.type_index_bytes,
163            self.secondary_index_bytes(),
164            self.catalog_bytes(),
165        )
166    }
167}
168
169fn ratio(num: usize, denom: usize) -> f64 {
170    if denom == 0 {
171        0.0
172    } else {
173        num as f64 / denom as f64
174    }
175}
176
177pub(super) fn estimate(graph: &super::InMemoryGraph) -> MemoryReport {
178    let mut report = MemoryReport {
179        live_node_count: graph.live_node_count,
180        live_relationship_count: graph.live_rel_count,
181        node_tombstone_count: graph.nodes.len().saturating_sub(graph.live_node_count),
182        relationship_tombstone_count: graph
183            .relationships
184            .len()
185            .saturating_sub(graph.live_rel_count),
186        ..MemoryReport::default()
187    };
188
189    report.nodes_bytes = node_slab_bytes(&graph.nodes);
190    report.relationships_bytes = rel_slab_bytes(&graph.relationships);
191    report.outgoing_bytes = adjacency_bytes(&graph.outgoing);
192    report.incoming_bytes = adjacency_bytes(&graph.incoming);
193    report.label_index_bytes = label_or_type_bytes(&graph.nodes_by_label);
194    report.type_index_bytes = label_or_type_bytes(&graph.relationships_by_type);
195
196    let bundle = &graph.indexes;
197    if let Ok(props) = bundle.properties.read() {
198        report.property_index_bytes = property_registry_bytes(&props);
199    }
200    report.sorted_index_bytes = sorted_registry_bytes(bundle);
201    report.text_index_bytes = text_registry_bytes(bundle);
202    report.point_index_bytes = point_registry_bytes(bundle);
203    report.fulltext_index_bytes = fulltext_registry_bytes(bundle);
204    report.vector_index_bytes = vector_registry_bytes(bundle);
205
206    if let Ok(catalog) = bundle.catalog.read() {
207        report.index_catalog_bytes = index_catalog_bytes(&catalog);
208    }
209    if let Ok(catalog) = graph.constraint_catalog.read() {
210        report.constraint_catalog_bytes = constraint_catalog_bytes(&catalog);
211    }
212
213    report
214}
215
216// ---------------- slab + adjacency ----------------
217
218/// Slot storage of a [`ChunkedVec`]: every allocated slot plus one
219/// `Arc<Vec<T>>` chunk header per chunk.
220fn chunked_outer_bytes<T>(v: &ChunkedVec<T>) -> usize {
221    v.capacity() * size_of::<T>()
222        + v.chunk_count() * (ARC_HEADER + size_of::<Vec<T>>() + size_of::<usize>())
223}
224
225fn node_slab_bytes(slab: &ChunkedVec<Option<std::sync::Arc<NodeRecord>>>) -> usize {
226    let outer = chunked_outer_bytes(slab);
227    let mut payload = 0;
228    for arc in slab.iter().flatten() {
229        payload += ARC_HEADER + size_of::<NodeRecord>() + node_record_heap_bytes(arc);
230    }
231    outer + payload
232}
233
234fn rel_slab_bytes(slab: &ChunkedVec<Option<std::sync::Arc<RelationshipRecord>>>) -> usize {
235    let outer = chunked_outer_bytes(slab);
236    let mut payload = 0;
237    for arc in slab.iter().flatten() {
238        payload += ARC_HEADER + size_of::<RelationshipRecord>() + rel_record_heap_bytes(arc);
239    }
240    outer + payload
241}
242
243fn node_record_heap_bytes(record: &NodeRecord) -> usize {
244    let labels = record.labels.capacity() * size_of::<String>()
245        + record.labels.iter().map(|l| l.capacity()).sum::<usize>();
246    labels + properties_heap_bytes(&record.properties)
247}
248
249fn rel_record_heap_bytes(record: &RelationshipRecord) -> usize {
250    record.rel_type.capacity() + properties_heap_bytes(&record.properties)
251}
252
253fn adjacency_bytes(adj: &ChunkedVec<super::graph::AdjList>) -> usize {
254    let outer = chunked_outer_bytes(adj);
255    // Inline lists (capacity <= 2) own no heap; spilled ones do.
256    let inner: usize = adj
257        .iter()
258        .filter(|v| v.spilled())
259        .map(|v| v.capacity() * size_of::<u64>())
260        .sum();
261    outer + inner
262}
263
264fn label_or_type_bytes(map: &BTreeMap<String, ChunkedVec<u64>>) -> usize {
265    let mut total = 0;
266    for (key, ids) in map {
267        total += BTREE_PER_ENTRY
268            + size_of::<String>()
269            + key.capacity()
270            + size_of::<ChunkedVec<u64>>()
271            + chunked_outer_bytes(ids);
272    }
273    total
274}
275
276// ---------------- property values ----------------
277
278/// Bytes beyond the embedded discriminant of a [`PropertyValue`]. The
279/// embedded discriminant itself is counted by the caller (it sits
280/// inside whatever container owns the value).
281pub fn property_value_heap_bytes(value: &PropertyValue) -> usize {
282    match value {
283        PropertyValue::Null
284        | PropertyValue::Bool(_)
285        | PropertyValue::Int(_)
286        | PropertyValue::Float(_)
287        | PropertyValue::Date(_)
288        | PropertyValue::Time(_)
289        | PropertyValue::LocalTime(_)
290        | PropertyValue::LocalDateTime(_)
291        | PropertyValue::DateTime(_)
292        | PropertyValue::Duration(_) => 0,
293        PropertyValue::String(s) => s.capacity(),
294        PropertyValue::Binary(b) => binary_heap_bytes(b),
295        PropertyValue::Point(p) => point_heap_bytes(p),
296        PropertyValue::Vector(v) => vector_heap_bytes(v),
297        PropertyValue::List(items) => {
298            items.capacity() * size_of::<PropertyValue>()
299                + items.iter().map(property_value_heap_bytes).sum::<usize>()
300        }
301        PropertyValue::Map(map) => {
302            let mut total = 0;
303            for (key, value) in map {
304                total += BTREE_PER_ENTRY
305                    + size_of::<String>()
306                    + key.capacity()
307                    + size_of::<PropertyValue>()
308                    + property_value_heap_bytes(value);
309            }
310            total
311        }
312    }
313}
314
315fn binary_heap_bytes(b: &LoraBinary) -> usize {
316    let mut total = std::mem::size_of_val(b.segments());
317    for seg in b.segments() {
318        total += seg.capacity();
319    }
320    total
321}
322
323fn point_heap_bytes(_: &LoraPoint) -> usize {
324    // LoraPoint is fixed-size (3 × f64 + tag + srid). No owned heap.
325    0
326}
327
328fn vector_heap_bytes(v: &LoraVector) -> usize {
329    use crate::types::VectorValues;
330    match &v.values {
331        VectorValues::Float64(xs) => xs.capacity() * size_of::<f64>(),
332        VectorValues::Float32(xs) => xs.capacity() * size_of::<f32>(),
333        VectorValues::Integer64(xs) => xs.capacity() * size_of::<i64>(),
334        VectorValues::Integer32(xs) => xs.capacity() * size_of::<i32>(),
335        VectorValues::Integer16(xs) => xs.capacity() * size_of::<i16>(),
336        VectorValues::Integer8(xs) => xs.capacity() * size_of::<i8>(),
337    }
338}
339
340fn properties_heap_bytes(properties: &crate::Properties) -> usize {
341    // `PropertyMap` is one contiguous slab of `(Arc<str>, PropertyValue)`
342    // slots. Keys are interned and shared across every record, so only
343    // the slot is charged here, not the key's own allocation.
344    let slots = properties.capacity() * size_of::<(std::sync::Arc<str>, PropertyValue)>();
345    slots
346        + properties
347            .values()
348            .map(property_value_heap_bytes)
349            .sum::<usize>()
350}
351
352// ---------------- secondary indexes ----------------
353
354fn property_registry_bytes(reg: &PropertyIndexRegistry) -> usize {
355    property_state_bytes(&reg.node_properties) + property_state_bytes(&reg.relationship_properties)
356}
357
358fn property_state_bytes(state: &PropertyIndexState) -> usize {
359    let mut total = 0;
360    for key in &state.active_keys {
361        total += BTREE_PER_ENTRY + size_of::<String>() + key.capacity();
362    }
363    total += property_index_map_bytes(&state.values);
364    for (scope, by_property) in &state.scoped_values {
365        total += HASHMAP_PER_ENTRY + size_of::<String>() + scope.capacity();
366        total += property_index_map_bytes(by_property);
367    }
368    total
369}
370
371fn property_index_map_bytes(values: &PropertyIndex) -> usize {
372    let mut total = 0;
373    for (key, buckets) in values {
374        total += HASHMAP_PER_ENTRY + size_of::<String>() + key.capacity();
375        for (indexed, ids) in buckets.iter() {
376            total += HASHMAP_PER_ENTRY
377                + property_index_key_bytes(indexed)
378                + size_of::<IdSet>()
379                + ids.heap_bytes();
380        }
381    }
382    total
383}
384
385fn property_index_key_bytes(key: &PropertyIndexKey) -> usize {
386    size_of::<PropertyIndexKey>()
387        + match key {
388            PropertyIndexKey::Null
389            | PropertyIndexKey::Bool(_)
390            | PropertyIndexKey::Int(_)
391            | PropertyIndexKey::Float(_)
392            | PropertyIndexKey::Temporal { .. } => 0,
393            PropertyIndexKey::String(s) => 16 + s.len(),
394            PropertyIndexKey::Binary(b) => binary_heap_bytes(b),
395            PropertyIndexKey::List(items) => {
396                items.capacity() * size_of::<PropertyIndexKey>()
397                    + items.iter().map(property_index_key_bytes).sum::<usize>()
398            }
399            PropertyIndexKey::Map(map) => map
400                .iter()
401                .map(|(k, v)| {
402                    BTREE_PER_ENTRY
403                        + size_of::<String>()
404                        + k.capacity()
405                        + property_index_key_bytes(v)
406                })
407                .sum::<usize>(),
408        }
409}
410
411fn scoped_key_bytes(scope: &ScopedPropertyKey) -> usize {
412    size_of::<ScopedPropertyKey>() + scope.label.capacity() + scope.property.capacity()
413}
414
415fn sorted_registry_bytes(bundle: &IndexBundle) -> usize {
416    let node = bundle.sorted.read(StoredIndexEntity::Node);
417    let rel = bundle.sorted.read(StoredIndexEntity::Relationship);
418    sorted_one(&node) + sorted_one(&rel)
419}
420
421fn sorted_one(index: &SortedPropertyIndex) -> usize {
422    let mut total = 0;
423    for (scope, sorted_scope) in &index.by_scope {
424        total += BTREE_PER_ENTRY + scoped_key_bytes(scope);
425        for (indexed, ids) in sorted_scope.by_value.iter() {
426            total += BTREE_PER_ENTRY
427                + property_index_key_bytes(indexed)
428                + size_of::<IdSet>()
429                + ids.heap_bytes();
430        }
431    }
432    total
433}
434
435fn text_registry_bytes(bundle: &IndexBundle) -> usize {
436    let node = bundle.text.read(StoredIndexEntity::Node);
437    let rel = bundle.text.read(StoredIndexEntity::Relationship);
438    text_one(&node) + text_one(&rel)
439}
440
441fn text_one(registry: &TrigramRegistry) -> usize {
442    let mut total = 0;
443    for (scope, trigram_scope) in &registry.by_scope {
444        total += HASHMAP_PER_ENTRY + scoped_key_bytes(scope);
445        for ids in trigram_scope.grams.values() {
446            total += BTREE_PER_ENTRY + 3 + ids.heap_bytes();
447        }
448    }
449    total
450}
451
452fn point_registry_bytes(bundle: &IndexBundle) -> usize {
453    let node = bundle.point.read(StoredIndexEntity::Node);
454    let rel = bundle.point.read(StoredIndexEntity::Relationship);
455    point_one(&node) + point_one(&rel)
456}
457
458fn point_one(registry: &PointRegistry) -> usize {
459    let mut total = 0;
460    for (scope, scope_data) in &registry.by_scope {
461        total += HASHMAP_PER_ENTRY + scoped_key_bytes(scope);
462        for cell in scope_data.grid.cells.values() {
463            total += HASHMAP_PER_ENTRY + cell.heap_bytes();
464        }
465    }
466    total
467}
468
469fn fulltext_registry_bytes(bundle: &IndexBundle) -> usize {
470    let node = bundle.fulltext.read(StoredIndexEntity::Node);
471    let rel = bundle.fulltext.read(StoredIndexEntity::Relationship);
472    fulltext_one(&node) + fulltext_one(&rel)
473}
474
475fn fulltext_one(registry: &FulltextRegistry) -> usize {
476    let mut total = 0;
477    for (name, index) in registry.iter() {
478        total += HASHMAP_PER_ENTRY + size_of::<String>() + name.capacity();
479        total += index.labels.capacity() * size_of::<String>();
480        for label in &index.labels {
481            total += label.capacity();
482        }
483        total += index.properties.capacity() * size_of::<String>();
484        for property in &index.properties {
485            total += property.capacity();
486        }
487        // Term strings are shared `Arc<str>`s: charged once, on the
488        // postings key; the per-entity lists hold pointers.
489        for (term, postings) in index.postings.iter() {
490            total += BTREE_PER_ENTRY + size_of::<std::sync::Arc<str>>() + ARC_HEADER + term.len();
491            total += postings.heap_bytes();
492        }
493        for terms in index.entity_terms.values() {
494            total += BTREE_PER_ENTRY
495                + size_of::<u64>()
496                + ARC_HEADER
497                + terms.len() * size_of::<std::sync::Arc<str>>();
498        }
499    }
500    total
501}
502
503fn vector_registry_bytes(bundle: &IndexBundle) -> usize {
504    let node = bundle.vector.read(StoredIndexEntity::Node);
505    let rel = bundle.vector.read(StoredIndexEntity::Relationship);
506    vector_one(&node) + vector_one(&rel)
507}
508
509fn vector_one(registry: &VectorIndexRegistry) -> usize {
510    let mut total = 0;
511    for (name, entry) in &registry.by_name {
512        total += BTREE_PER_ENTRY
513            + size_of::<String>()
514            + name.capacity()
515            + entry.label.capacity()
516            + entry.property.capacity();
517        total += match &entry.backend {
518            VectorBackend::Flat(b) => flat_backend_bytes(b),
519            VectorBackend::Hnsw(b) => hnsw_backend_bytes(b),
520        };
521    }
522    total
523}
524
525fn flat_backend_bytes(b: &FlatBackend) -> usize {
526    let mut total = 0;
527    for v in b.items.values() {
528        total +=
529            BTREE_PER_ENTRY + size_of::<u64>() + size_of::<LoraVector>() + vector_heap_bytes(v);
530    }
531    total
532}
533
534fn hnsw_backend_bytes(b: &HnswBackend) -> usize {
535    let mut total = 0;
536    for node in b.nodes.values() {
537        total += BTREE_PER_ENTRY
538            + size_of::<u64>()
539            + size_of::<LoraVector>()
540            + vector_heap_bytes(&node.vector)
541            + size_of::<usize>() // level
542            + node.neighbors.capacity() * size_of::<Vec<u64>>();
543        for layer in &node.neighbors {
544            total += layer.capacity() * size_of::<u64>();
545        }
546    }
547    total
548}
549
550// ---------------- catalogs ----------------
551
552fn index_catalog_bytes(catalog: &IndexCatalog) -> usize {
553    let mut total = 0;
554    for def in catalog.list() {
555        total += BTREE_PER_ENTRY + size_of::<String>() + def.name.capacity();
556        if let Some(label) = &def.label {
557            total += label.capacity();
558        }
559        for label in &def.additional_labels {
560            total += size_of::<String>() + label.capacity();
561        }
562        for property in &def.properties {
563            total += size_of::<String>() + property.capacity();
564        }
565        for key in def.options.keys() {
566            total += BTREE_PER_ENTRY + size_of::<String>() + key.capacity();
567        }
568    }
569    total
570}
571
572fn constraint_catalog_bytes(catalog: &ConstraintCatalog) -> usize {
573    let mut total = 0;
574    for def in catalog.list() {
575        total += BTREE_PER_ENTRY + size_of::<String>() + def.name.capacity() + def.label.capacity();
576        for property in &def.properties {
577            total += size_of::<String>() + property.capacity();
578        }
579        if let Some(idx) = &def.owned_index {
580            total += idx.capacity();
581        }
582    }
583    total
584}