Skip to main content

kevy_map/
alloc.rs

1//! Allocation lifecycle for [`crate::KevyMap`] — `alloc_table` (the only
2//! growing constructor) and the matching `Drop` impl. Split out so
3//! [`crate::map`] stays under the 500-LOC house rule. Both halves dispatch
4//! between the global allocator and a 2 MiB-aligned `mmap` path (E13)
5//! based on the per-instance `mmap_backed` flag.
6
7use alloc_crate::alloc::{Layout, alloc, dealloc, handle_alloc_error};
8use core::marker::PhantomData;
9use core::mem::MaybeUninit;
10use core::ptr;
11use core::ptr::NonNull;
12
13use crate::map::{EMPTY, GROUP_WIDTH, KevyMap, MIN_CAP, table_layout};
14
15/// Tables smaller than this stay on the global allocator. mmap's
16/// over-allocate-and-trim alignment trick costs one extra HP of address
17/// space + 2 munmap syscalls — only worth it once the payload itself
18/// approaches HP scale.
19const THP_BACKED_THRESHOLD: usize = 1024 * 1024; // 1 MiB
20
21impl<K, V> KevyMap<K, V> {
22    /// The table's own allocation as the global allocator made it: its
23    /// start and layout. `None` when there is no table, or when a large one
24    /// was mapped directly rather than allocated. What a defrag pass asks
25    /// the allocator about before copying a map.
26    ///
27    /// ```
28    /// use kevy_map::KevyMap;
29    /// let mut m: KevyMap<u32, u32> = KevyMap::new();
30    /// assert!(m.table_allocation().is_none(), "nothing allocated yet");
31    /// m.insert(1, 2);
32    /// let (ptr, layout) = m.table_allocation().ok_or("a table")?;
33    /// assert!(!ptr.is_null() && layout.size() > 0);
34    /// # Ok::<(), &str>(())
35    /// ```
36    #[must_use]
37    pub fn table_allocation(&self) -> Option<(*const u8, Layout)> {
38        (self.cap != 0 && !self.mmap_backed).then(|| {
39            (self.slots_ptr.as_ptr().cast::<u8>().cast_const(), table_layout::<(K, V)>(self.cap).0)
40        })
41    }
42
43    /// Allocate a freshly-zeroed table sized for `cap` slots. `cap` must be
44    /// a power of two and ≥ `MIN_CAP`. Used by [`crate::KevyMap::with_capacity`]
45    /// and by the growth path on rehash.
46    ///
47    /// E13: when the combined layout is ≥ [`THP_BACKED_THRESHOLD`], allocate
48    /// directly via 2 MiB-aligned `mmap` so the kernel's `khugepaged` can
49    /// actually promote the region to 2 MiB pages. With the global
50    /// allocator (jemalloc-like chunk placement) the base pointer is only
51    /// 4 KiB-aligned, so `khugepaged` cannot find a candidate even when
52    /// `MADV_HUGEPAGE` is set — observed as `AnonHugePages: 0 kB` in
53    /// `/proc/PID/smaps` despite the hint.
54    pub(crate) fn alloc_table(cap: usize) -> Self {
55        debug_assert!(cap.is_power_of_two());
56        debug_assert!(cap >= MIN_CAP);
57
58        let (layout, meta_offset) = table_layout::<(K, V)>(cap);
59        let (base, mmap_backed) = if layout.size() >= THP_BACKED_THRESHOLD {
60            if let Some(p) = kevy_madvise::mmap_anon_aligned_2mb(layout.size()) {
61                (p.as_ptr(), true)
62            } else {
63                (fallback_alloc(layout), false)
64            }
65        } else {
66            (fallback_alloc(layout), false)
67        };
68        // Initialise the metadata range (real + mirror tail) to EMPTY in a
69        // single memset. The slot array is left uninitialised — slots
70        // become initialised only when their metadata byte transitions
71        // out of the high-bit-set state (EMPTY/DELETED).
72        // SAFETY: `meta_offset` is inside the layout `base` was allocated with.
73        let meta_byte_ptr = unsafe { base.add(meta_offset) };
74        // SAFETY: the layout reserved exactly `cap + GROUP_WIDTH` bytes here.
75        unsafe { ptr::write_bytes(meta_byte_ptr, EMPTY, cap + GROUP_WIDTH) };
76
77        let slots_ptr = base.cast::<MaybeUninit<(K, V)>>();
78        let metadata_ptr = meta_byte_ptr;
79
80        // Re-hint THP on the buffer. On the `mmap_backed = true` path the
81        // 2 MiB-aligned mmap already called MADV_HUGEPAGE inside
82        // `mmap_anon_aligned_2mb`; this second call is redundant there but
83        // harmless. On the global-alloc fallback path it's the only hint.
84        if !mmap_backed {
85            kevy_madvise::advise_hugepage(base.cast_const(), layout.size());
86        }
87
88        Self {
89            // SAFETY: the allocation is non-null (checked; the fallback aborts).
90            slots_ptr: unsafe { NonNull::new_unchecked(slots_ptr) },
91            // SAFETY: `base` plus a within-layout offset, so non-null for the same reason.
92            metadata_ptr: unsafe { NonNull::new_unchecked(metadata_ptr) },
93            cap,
94            mask: cap - 1,
95            occupied: 0,
96            deleted: 0,
97            mmap_backed,
98            _marker: PhantomData,
99        }
100    }
101}
102
103/// Bytes a block of `size` requested bytes occupies in glibc's `malloc`:
104/// a chunk of 16-byte granules that carries an 8-byte size header, 32
105/// bytes at the least. 0 for 0 — nothing is allocated.
106///
107/// The allocator a Linux deployment runs on by default, and the model
108/// memory accounting built on this crate counts with, so a charge and
109/// what the process holds can be compared byte for byte.
110///
111/// ```
112/// use kevy_map::malloc_footprint;
113/// assert_eq!(malloc_footprint(0), 0);
114/// assert_eq!(malloc_footprint(1), 32, "the smallest chunk");
115/// assert_eq!(malloc_footprint(72), 80, "the request plus a header, in 16-byte steps");
116/// assert_eq!(malloc_footprint(900), 912);
117/// ```
118#[inline]
119#[must_use]
120pub fn malloc_footprint(size: usize) -> usize {
121    // header plus request, rounded up to a granule, as masks rather than a
122    // division: this runs on every hash write that is accounted
123    let chunk = ((size + 8 + 15) & !15).max(32);
124    if size == 0 { 0 } else { chunk }
125}
126
127impl<K, V> KevyMap<K, V> {
128    /// Bytes the table occupies where it lives: the whole mapping for a
129    /// table large enough to be mapped directly, otherwise its one block's
130    /// [`malloc_footprint`]. 0 before anything was inserted.
131    ///
132    /// A pure function of the capacity, so a caller can charge a growth
133    /// by reading this before and after an insert.
134    ///
135    /// ```
136    /// let mut m: kevy_map::KevyMap<u64, u64> = kevy_map::KevyMap::new();
137    /// assert_eq!(m.footprint(), 0, "no table yet");
138    /// m.insert(1, 1);
139    /// // sixteen 16-byte slots, then one control byte each plus a trailing group
140    /// assert_eq!(m.footprint(), kevy_map::malloc_footprint(16 * 16 + 16 + 16));
141    /// ```
142    #[inline]
143    pub fn footprint(&self) -> usize {
144        if self.cap == 0 {
145            return 0;
146        }
147        // `table_layout`'s size without its overflow checks, which the
148        // table passed when it was allocated: a slot and a control byte
149        // per bucket, the trailing control group, padded to the slot's
150        // alignment (a no-op for every power-of-two capacity >= 16)
151        let kv = core::mem::size_of::<(K, V)>();
152        let size =
153            (self.cap * (kv + 1) + GROUP_WIDTH).next_multiple_of(core::mem::align_of::<(K, V)>());
154        debug_assert_eq!(size, table_layout::<(K, V)>(self.cap).0.size());
155        if self.mmap_backed { size.next_multiple_of(HUGE_PAGE) } else { malloc_footprint(size) }
156    }
157
158    /// The bytes [`Self::footprint`] will read once the table has grown to
159    /// twice its capacity (sixteen slots for a map with no table yet): what
160    /// a caller holding memory to a budget sets aside before the growth
161    /// arrives, so the growth does not land on memory it has no room for.
162    ///
163    /// ```
164    /// let mut m: kevy_map::KevyMap<u64, u64> = kevy_map::KevyMap::new();
165    /// m.insert(1, 1);
166    /// let promised = m.grown_footprint();
167    /// while m.room() > 0 {
168    ///     m.insert(m.len() as u64 + 1, 0);
169    /// }
170    /// m.insert(u64::MAX, 0); // no room left: this insert grows the table
171    /// assert_eq!(m.footprint(), promised);
172    /// ```
173    pub fn grown_footprint(&self) -> usize {
174        let cap = if self.cap == 0 { MIN_CAP } else { self.cap * 2 };
175        let size = table_layout::<(K, V)>(cap).0.size();
176        // `alloc_table` maps a table this large directly wherever it can
177        let mapped = size >= THP_BACKED_THRESHOLD && cfg!(target_os = "linux") && !cfg!(miri);
178        if mapped { size.next_multiple_of(HUGE_PAGE) } else { malloc_footprint(size) }
179    }
180
181    /// New keys the table takes before an insert grows it: 0 when the next
182    /// insert rebuilds it (or there is no table yet).
183    ///
184    /// ```
185    /// let mut m: kevy_map::KevyMap<u64, u64> = kevy_map::KevyMap::new();
186    /// assert_eq!(m.room(), 0, "no table yet");
187    /// m.insert(1, 1);
188    /// // sixteen slots hold fourteen keys at the 7/8 load bound
189    /// assert_eq!(m.room(), 14 - 1);
190    /// ```
191    #[inline]
192    pub fn room(&self) -> usize {
193        if self.cap == 0 {
194            0
195        } else {
196            self.threshold().saturating_sub(self.occupied + self.deleted)
197        }
198    }
199
200    /// Whether the next insert rebuilds the table before it probes — an
201    /// overwrite included, since the check runs before the key is looked up.
202    #[inline]
203    pub(crate) fn grows_on_insert(&self) -> bool {
204        self.cap == 0 || (self.occupied + self.deleted) >= self.threshold()
205    }
206}
207
208impl<K: kevy_hash::KevyHash + Eq, V> KevyMap<K, V> {
209    /// [`KevyMap::insert`], also answering by how many bytes
210    /// [`Self::footprint`] moved — nonzero only when the insert had to
211    /// grow the table first, and then read around the growth alone, so an
212    /// insert that does not grow costs what a plain one does.
213    ///
214    /// ```
215    /// let mut m: kevy_map::KevyMap<u32, u32> = kevy_map::KevyMap::new();
216    /// let (old, grown) = m.insert_sized(1, 10);
217    /// assert_eq!((old, grown), (None, m.footprint() as isize), "the first insert allocates");
218    /// assert_eq!(m.insert_sized(1, 11), (Some(10), 0), "an overwrite with room moves nothing");
219    /// ```
220    // a wrapper: outlined, it adds a call and returns its pair through memory
221    #[allow(clippy::inline_always)]
222    #[inline(always)]
223    pub fn insert_sized(&mut self, key: K, value: V) -> (Option<V>, isize) {
224        if self.grows_on_insert() {
225            let before = self.footprint();
226            self.grow();
227            let grown = self.footprint() as isize - before as isize;
228            return (self.insert_with_room(key, value), grown);
229        }
230        (self.insert_with_room(key, value), 0)
231    }
232}
233
234/// A directly mapped table is rounded to whole huge pages and keeps them
235/// all (`kevy_madvise::mmap_anon_aligned_2mb` trims only the alignment slack).
236const HUGE_PAGE: usize = 2 * 1024 * 1024;
237
238/// Global-allocator path that aborts on OOM. Cohesive helper so both
239/// branches of `alloc_table` can call it.
240fn fallback_alloc(layout: Layout) -> *mut u8 {
241    // SAFETY: layout has non-zero size (metadata alone is ≥ MIN_CAP +
242    // GROUP_WIDTH - 1 ≥ 31 bytes). alloc returns either a valid
243    // allocation of `layout` or null.
244    let p = unsafe { alloc(layout) };
245    if p.is_null() {
246        handle_alloc_error(layout);
247    }
248    p
249}
250
251impl<K, V> Drop for KevyMap<K, V> {
252    fn drop(&mut self) {
253        if self.cap == 0 {
254            return;
255        }
256        if core::mem::needs_drop::<(K, V)>() {
257            for i in 0..self.cap {
258                // SAFETY: i < cap ⇒ in-bounds.
259                let meta = unsafe { *self.metadata_ptr.as_ptr().add(i) };
260                if meta & 0x80 == 0 {
261                    // SAFETY: full slot ⇒ initialised.
262                    unsafe {
263                        ptr::drop_in_place(self.slots_ptr.as_ptr().add(i).cast::<(K, V)>());
264                    }
265                }
266            }
267        }
268        // Free the single combined allocation. `slots_ptr` IS the base of
269        // the allocation (see `alloc_table`'s layout computation: slots are
270        // at offset 0; metadata sits at meta_offset).
271        let (layout, _) = table_layout::<(K, V)>(self.cap);
272        // SAFETY: cap > 0 ⇒ slots_ptr is non-null and was returned by either
273        // `alloc` or `mmap_anon_aligned_2mb` with the same `layout.size()`;
274        // `mmap_backed` records which path was used so dealloc matches.
275        if self.mmap_backed {
276            // SAFETY: slots_ptr came from mmap_anon_aligned_2mb with
277            // layout.size(); munmap_2mb rounds the len back up internally.
278            unsafe {
279                kevy_madvise::munmap_2mb(self.slots_ptr.cast(), layout.size());
280            }
281        } else {
282            // SAFETY: slots_ptr came from `alloc` with this layout.
283            unsafe {
284                dealloc(self.slots_ptr.as_ptr().cast::<u8>(), layout);
285            }
286        }
287    }
288}