kevy_map/map.rs
1//! The KevyMap implementation: struct, allocation, probing, and the live
2//! lookup / insert / remove API. Helpers (`h2`, `prefetch_t0`, metadata
3//! constants) and the private `ProbeOutcome` enum are all map-scoped.
4//!
5//! Layout (single allocation):
6//!
7//! ```text
8//! +------------+------------+-----+---------------+---------+--------+
9//! | slot[0] | slot[1] | ... | slot[cap-1] | padding | meta |
10//! +------------+------------+-----+---------------+---------+--------+
11//! ^ ^
12//! slots_ptr metadata_ptr
13//! ```
14//!
15//! Both pointers are precomputed at `alloc_table` time and never re-derived
16//! in the hot path. The single allocation cuts one alloc/dealloc pair vs
17//! the previous two-`Box<[…]>` layout, and keeps metadata + slots in
18//! adjacent pages (warmer TLB, contiguous OS-prefetch).
19
20use core::alloc::Layout;
21use core::fmt;
22use core::marker::PhantomData;
23use core::mem::MaybeUninit;
24use core::ptr::{self, NonNull};
25
26use kevy_hash::KevyHash;
27
28use crate::iter::{Iter, IterMut, Keys, Values};
29
30/// SIMD group width (16 metadata bytes loaded per probe iteration).
31pub(crate) const GROUP_WIDTH: usize = 16;
32
33/// Metadata byte for an empty slot (top bit set, value bits 1's — distinct
34/// from DELETED so the probe loop can stop at EMPTY but skip DELETED).
35pub(crate) const EMPTY: u8 = 0xFF;
36/// Metadata byte for a tombstone (top bit set, value bits 0).
37pub(crate) const DELETED: u8 = 0x80;
38/// Minimum table size. ≥ 16 (one SSE2 group) so the future SIMD path can run
39/// a full group scan unconditionally.
40pub(crate) const MIN_CAP: usize = 16;
41
42/// Top-7 bits of the hash, used as the per-slot metadata byte for occupied
43/// slots. The top bit is always 0 (so occupancy = `meta & 0x80 == 0`).
44#[inline]
45pub(crate) fn h2(hash: u64) -> u8 {
46 ((hash >> 57) & 0x7F) as u8
47}
48
49/// Issue a hint to fetch the cache line containing `ptr` into L1 ("T0" =
50/// "all levels"). Stable on x86_64 / aarch64; no-op elsewhere AND under
51/// `cfg(miri)` (miri cannot model inline asm / arch intrinsics, so the hint
52/// degrades to a no-op for unsafe-correctness testing — the semantic
53/// contract of `prefetch_t0` is "may do nothing", so this is sound).
54///
55/// `inline(always)` is the point: prefetch only helps when the load-latency
56/// window the call hides is bigger than the call site itself. A non-inlined
57/// call_site / ret pair (~10 ns out-of-order) wipes the benefit.
58#[allow(clippy::inline_always)]
59#[inline(always)]
60fn prefetch_t0(ptr: *const u8) {
61 #[cfg(all(target_arch = "x86_64", not(miri)))]
62 {
63 // SAFETY: _mm_prefetch reads no memory; any aligned/unaligned/
64 // out-of-bounds pointer is permitted by the ISA.
65 unsafe {
66 core::arch::x86_64::_mm_prefetch(ptr as *const i8, core::arch::x86_64::_MM_HINT_T0);
67 }
68 }
69 #[cfg(all(target_arch = "aarch64", not(miri)))]
70 {
71 // SAFETY: prfm reads no memory; any pointer permitted.
72 unsafe {
73 core::arch::asm!(
74 "prfm pldl1keep, [{p}]",
75 p = in(reg) ptr,
76 options(nostack, preserves_flags, readonly),
77 );
78 }
79 }
80 #[cfg(any(miri, not(any(target_arch = "x86_64", target_arch = "aarch64"))))]
81 {
82 let _ = ptr;
83 }
84}
85
86/// Compute the single-buffer layout for a table of `cap` slots: returns the
87/// combined `Layout` and the byte offset to the metadata array. Panics on
88/// arithmetic overflow (only reachable for cap ≈ usize::MAX which would OOM
89/// anyway).
90///
91/// Metadata size is `cap + GROUP_WIDTH` (hashbrown 0.15 layout): the first
92/// `cap` bytes are the real per-slot metadata, the trailing `GROUP_WIDTH`
93/// bytes mirror the leading ones so the branchless `set_meta` formula
94/// `index2 = ((i - GW) & mask) + GW` always lands inside the buffer (for
95/// `i = GROUP_WIDTH - 1` the formula evaluates to `cap + GROUP_WIDTH - 1`,
96/// the very last byte). That last byte is written by `set_meta` but never
97/// read by `Group::load` — SIMD loads from `group_start ∈ [0, cap)` reach
98/// at most `cap + GROUP_WIDTH - 2`.
99#[inline]
100pub(crate) fn table_layout<KV>(cap: usize) -> (Layout, usize) {
101 let slots = Layout::array::<MaybeUninit<KV>>(cap)
102 .expect("a capacity that overflows a Layout could not have been allocated");
103 let meta = Layout::array::<u8>(cap + GROUP_WIDTH)
104 .expect("a capacity that overflows a Layout could not have been allocated");
105 let (combined, meta_offset) =
106 slots.extend(meta).expect("both halves already fit, so their sum fits isize");
107 (combined.pad_to_align(), meta_offset)
108}
109
110/// An open-addressing Swiss-style hashtable keyed by [`KevyHash`].
111///
112/// Power-of-two capacity (`mask = cap - 1`); 7/8 load factor; linear probing
113/// over the metadata array; full slots' (K, V) live AoS in a parallel slot
114/// array of `MaybeUninit<(K, V)>` co-allocated with the metadata.
115///
116/// When `cap == 0` both pointers are dangling and no allocation is held.
117/// # Examples
118///
119/// Keys are byte strings or integers — whatever implements `KevyHash`.
120/// There is deliberately no `&str` impl: the keyspace deals in bytes, and a
121/// key that exists only as UTF-8 would need validating on every lookup.
122///
123/// ```
124/// let mut m: kevy_map::KevyMap<Vec<u8>, u32> = kevy_map::KevyMap::new();
125/// assert_eq!(m.insert(b"k".to_vec(), 1), None, "insert returns what it displaced");
126/// assert_eq!(m.insert(b"k".to_vec(), 2), Some(1));
127/// assert_eq!(m.get(b"k".as_slice()), Some(&2));
128/// assert_eq!(m.remove(b"k".as_slice()), Some(2));
129/// assert_eq!(m.get(b"k".as_slice()), None);
130/// assert!(m.is_empty());
131/// ```
132pub struct KevyMap<K, V> {
133 /// Slot array. `cap` initialised iff the corresponding metadata byte is
134 /// in `0x00..=0x7F`. Dangling when `cap == 0`.
135 pub(crate) slots_ptr: NonNull<MaybeUninit<(K, V)>>,
136 /// Metadata array (`cap + GROUP_WIDTH` bytes; trailing
137 /// `GROUP_WIDTH - 1` bytes mirror the leading ones for SIMD-safe
138 /// wraparound loads — the hashbrown layout). Dangling when `cap == 0`.
139 pub(crate) metadata_ptr: NonNull<u8>,
140 /// Allocated slot count. `0` when no allocation is held.
141 pub(crate) cap: usize,
142 /// `cap - 1` when `cap > 0`; `0` when `cap == 0`.
143 pub(crate) mask: usize,
144 /// Live entries.
145 pub(crate) occupied: usize,
146 /// Tombstones (not yet reclaimed).
147 pub(crate) deleted: usize,
148 /// `true` when the table buffer was obtained from
149 /// [`kevy_madvise::mmap_anon_aligned_2mb`] (large tables that wanted
150 /// THP-aligned storage); `false` when it came from the global allocator.
151 /// Drives the dispatch in [`Drop`] between `munmap_2mb` and `dealloc`.
152 pub(crate) mmap_backed: bool,
153 /// Marker so dropck and variance treat us as owning `(K, V)` like a
154 /// `Box<[MaybeUninit<(K, V)>]>` would.
155 pub(crate) _marker: PhantomData<(K, V)>,
156}
157
158// SAFETY: KevyMap owns its `(K, V)` entries (via the slot allocation). The
159// `NonNull<...>` fields are conceptually `Box<[…]>` and inherit the same
160// Send/Sync bounds: send-K + send-V ⇒ KevyMap is Send. Same for Sync.
161unsafe impl<K: Send, V: Send> Send for KevyMap<K, V> {}
162// SAFETY: as above — the pointers are owning, not shared, so a `&KevyMap` grants
163// only reads of `K` and `V`, which `K: Sync + V: Sync` makes safe to share.
164unsafe impl<K: Sync, V: Sync> Sync for KevyMap<K, V> {}
165
166/// `(metadata, slots)` parallel-slice pair returned by [`KevyMap::as_slices`].
167/// Aliased so the long `(&[u8], &[MaybeUninit<(K, V)>])` signature doesn't
168/// trip clippy's `type_complexity` lint on a member-by-member basis.
169type SlotSlices<'a, K, V> = (&'a [u8], &'a [MaybeUninit<(K, V)>]);
170
171/// Mutable-slot variant of [`SlotSlices`], returned by
172/// [`KevyMap::as_mut_slices`] for [`KevyMap::iter_mut`].
173type SlotSlicesMut<'a, K, V> = (&'a [u8], &'a mut [MaybeUninit<(K, V)>]);
174
175pub(crate) enum ProbeOutcome {
176 Found(usize),
177 NotFound { insert_at: usize, via_tombstone: bool },
178}
179
180impl<K, V> KevyMap<K, V> {
181 /// Construct an empty map without allocating.
182 /// # Examples
183 ///
184 /// ```
185 /// // The key must implement `KevyHash`, which is deliberately a small
186 /// // set: byte strings and integers, the things a KV engine keys on.
187 /// let m: kevy_map::KevyMap<Vec<u8>, u32> = kevy_map::KevyMap::new();
188 /// assert!(m.is_empty());
189 /// assert_eq!(m.len(), 0);
190 /// ```
191 pub fn new() -> Self {
192 Self {
193 slots_ptr: NonNull::dangling(),
194 metadata_ptr: NonNull::dangling(),
195 cap: 0,
196 mask: 0,
197 occupied: 0,
198 deleted: 0,
199 mmap_backed: false,
200 _marker: PhantomData,
201 }
202 }
203
204 /// Construct a map sized to hold `cap_hint` entries without growing
205 /// (accounting for the 7/8 load factor).
206 /// # Examples
207 ///
208 /// ```
209 /// // A hint, not a promise: capacity is rounded to the table's own
210 /// // shape, and `len` still starts at zero.
211 /// let m: kevy_map::KevyMap<u32, u32> = kevy_map::KevyMap::with_capacity(100);
212 /// assert_eq!(m.len(), 0);
213 /// ```
214 pub fn with_capacity(cap_hint: usize) -> Self {
215 if cap_hint == 0 {
216 return Self::new();
217 }
218 // ceil(cap_hint * 8 / 7) → smallest table where cap_hint fits below 7/8.
219 let needed = cap_hint.saturating_mul(8).div_ceil(7);
220 let cap = needed.next_power_of_two().max(MIN_CAP);
221 Self::alloc_table(cap)
222 }
223
224 // alloc_table + Drop live in `crate::alloc` so this file stays under
225 // the 500-LOC house rule.
226
227 /// Write `v` into metadata slot `i`, also updating the mirror byte
228 /// at `cap + i` when `i < GROUP_WIDTH`. Every metadata mutation goes
229 /// through this helper so the mirror stays consistent with the real
230 /// metadata.
231 ///
232 /// Branchless: the formula `index2 = ((i - GW) & mask) + GW`
233 /// (hashbrown 0.15's `set_ctrl`) yields the real mirror position
234 /// `cap + i` when `i < GW`, and yields `i` itself when `i >= GW`.
235 /// The second write is therefore either to the mirror byte or a
236 /// duplicate write to the same real byte (a no-op). No branch.
237 #[inline]
238 pub(crate) fn set_meta(&mut self, i: usize, v: u8) {
239 debug_assert!(i < self.cap);
240 // SAFETY: i ∈ [0, cap); i2 ∈ [GROUP_WIDTH, cap + GROUP_WIDTH);
241 // both in-bounds since metadata buffer length is cap + GROUP_WIDTH.
242 let i2 = (i.wrapping_sub(GROUP_WIDTH) & self.mask) + GROUP_WIDTH;
243 // SAFETY: both indices are in range by the bound stated just above, and the
244 // metadata allocation is `cap + GROUP_WIDTH` bytes long.
245 unsafe {
246 *self.metadata_ptr.as_ptr().add(i) = v;
247 *self.metadata_ptr.as_ptr().add(i2) = v;
248 }
249 }
250
251 /// Live entry count.
252 #[inline]
253 /// # Examples
254 ///
255 /// ```
256 /// let mut m = kevy_map::KevyMap::new();
257 /// m.insert(1u32, "a");
258 /// m.insert(1u32, "b");
259 /// assert_eq!(m.len(), 1, "the second insert REPLACED the first");
260 /// ```
261 pub fn len(&self) -> usize {
262 self.occupied
263 }
264
265 /// Whether the map has zero live entries.
266 #[inline]
267 pub fn is_empty(&self) -> bool {
268 self.occupied == 0
269 }
270
271 /// Allocated slot count (NOT live entries).
272 #[inline]
273 pub fn capacity(&self) -> usize {
274 self.cap
275 }
276
277 /// Drop every live entry and reset the metadata. Keeps the allocation.
278 /// # Examples
279 ///
280 /// ```
281 /// let mut m = kevy_map::KevyMap::new();
282 /// for i in 0..8u32 { m.insert(i, i); }
283 /// m.clear();
284 /// assert!(m.is_empty());
285 /// assert_eq!(m.get(&0), None);
286 /// ```
287 pub fn clear(&mut self) {
288 if self.cap == 0 {
289 return;
290 }
291 if core::mem::needs_drop::<(K, V)>() {
292 for i in 0..self.cap {
293 // SAFETY: i < cap ⇒ metadata pointer in-bounds.
294 let meta = unsafe { *self.metadata_ptr.as_ptr().add(i) };
295 if meta & 0x80 == 0 {
296 // SAFETY: full slot ⇒ initialised.
297 unsafe {
298 ptr::drop_in_place(self.slots_ptr.as_ptr().add(i).cast::<(K, V)>());
299 }
300 }
301 }
302 }
303 // Reset entire metadata buffer (real range + mirror tail) in one memset.
304 // SAFETY: metadata buffer is exactly cap + GROUP_WIDTH bytes wide.
305 unsafe {
306 ptr::write_bytes(self.metadata_ptr.as_ptr(), EMPTY, self.cap + GROUP_WIDTH);
307 }
308 self.occupied = 0;
309 self.deleted = 0;
310 }
311
312 /// `(&K, &V)` over all live entries; order is unspecified.
313 /// # Examples
314 ///
315 /// ```
316 /// let mut m = kevy_map::KevyMap::new();
317 /// for i in 0..4u32 { m.insert(i, i * 10); }
318 /// // Unordered, like any hash table: sort before comparing.
319 /// let mut got: Vec<_> = m.iter().map(|(k, v)| (*k, *v)).collect();
320 /// got.sort_unstable();
321 /// assert_eq!(got, vec![(0, 0), (1, 10), (2, 20), (3, 30)]);
322 /// ```
323 pub fn iter(&self) -> Iter<'_, K, V> {
324 let (metadata, slots) = self.as_slices();
325 Iter::new(metadata, slots)
326 }
327
328 /// `iter` that begins at bucket `start` (clamped to `capacity()`) and
329 /// walks to the end. To sweep the full ring beginning at a random offset
330 /// — the pattern the kevy-store eviction sampler uses — chain it with a
331 /// second `iter_from_bucket(0)` and `take(start)`.
332 pub fn iter_from_bucket(&self, start: usize) -> Iter<'_, K, V> {
333 let (metadata, slots) = self.as_slices();
334 Iter::with_start(metadata, slots, start)
335 }
336
337 /// `(&K, &mut V)` over all live entries; order is unspecified. Keys stay
338 /// shared (mutating a key in place would corrupt its bucket).
339 pub fn iter_mut(&mut self) -> IterMut<'_, K, V> {
340 let (metadata, slots) = self.as_mut_slices();
341 IterMut::new(metadata, slots)
342 }
343
344 /// `&K` over all live entries.
345 /// # Examples
346 ///
347 /// ```
348 /// let mut m = kevy_map::KevyMap::new();
349 /// m.insert(b"a".to_vec(), 1u32);
350 /// m.insert(b"b".to_vec(), 2);
351 /// let mut ks: Vec<_> = m.keys().cloned().collect();
352 /// ks.sort_unstable();
353 /// assert_eq!(ks, vec![b"a".to_vec(), b"b".to_vec()]);
354 /// ```
355 pub fn keys(&self) -> Keys<'_, K, V> {
356 Keys::new(self.iter())
357 }
358
359 /// `&V` over all live entries.
360 pub fn values(&self) -> Values<'_, K, V> {
361 Values::new(self.iter())
362 }
363
364 /// Borrow the metadata and slots as parallel slices of length `cap`.
365 /// Used by [`KevyMap::iter`] (which only needs the real slot range,
366 /// not the mirror tail). When `cap == 0` returns two empty slices —
367 /// the dangling pointer is never dereferenced.
368 #[inline]
369 fn as_slices(&self) -> SlotSlices<'_, K, V> {
370 if self.cap == 0 {
371 return (&[], &[]);
372 }
373 // SAFETY: cap > 0 ⇒ both pointers are valid for `cap` reads; we hand
374 // out shared borrows tied to `&self`'s lifetime, so the allocation
375 // outlives the returned slices.
376 unsafe {
377 (
378 core::slice::from_raw_parts(self.metadata_ptr.as_ptr(), self.cap),
379 core::slice::from_raw_parts(self.slots_ptr.as_ptr(), self.cap),
380 )
381 }
382 }
383
384 /// [`KevyMap::as_slices`] with mutable slots (metadata stays shared —
385 /// [`KevyMap::iter_mut`] only reads it). The disjoint borrows are sound:
386 /// metadata and slots are separate allocations.
387 #[inline]
388 fn as_mut_slices(&mut self) -> SlotSlicesMut<'_, K, V> {
389 if self.cap == 0 {
390 return (&[], &mut []);
391 }
392 // SAFETY: cap > 0 ⇒ both pointers are valid; `&mut self` guarantees
393 // exclusive access for the lifetime of the returned slices.
394 unsafe {
395 (
396 core::slice::from_raw_parts(self.metadata_ptr.as_ptr(), self.cap),
397 core::slice::from_raw_parts_mut(self.slots_ptr.as_ptr(), self.cap),
398 )
399 }
400 }
401
402 /// Hint the CPU to fetch the bucket cache line that a probe at `hash`
403 /// would start at. The prefetch lever against the bucket-probe DRAM
404 /// miss: the command-batch driver calls this for command N+1 while
405 /// finishing command N, so by the time N+1 actually probes the
406 /// metadata, the line is in L1.
407 ///
408 /// No-op when the table is empty. Cheap when not empty (a single
409 /// `prefetcht0` on x86_64 / `prfm pldl1keep` on aarch64; a regular
410 /// volatile load on other arches via [`std::intrinsics`] — but we
411 /// only use stable intrinsics here, so non-x86/aarch64 architectures
412 /// degrade to a no-op rather than a fake hint).
413 ///
414 /// `inline(always)` is the point — see the `prefetch_t0` rationale.
415 #[allow(clippy::inline_always)]
416 #[inline(always)]
417 pub fn prefetch_for_hash(&self, hash: u64) {
418 if self.cap == 0 {
419 return;
420 }
421 let idx = (hash as usize) & self.mask;
422 // SAFETY: idx < cap ≤ metadata length ⇒ pointer in-bounds; prefetch
423 // reads never trap and never observe values.
424 let ptr = unsafe { self.metadata_ptr.as_ptr().add(idx) };
425 prefetch_t0(ptr);
426 }
427
428 /// 7/8 of the capacity — the inclusive max for `occupied + deleted`.
429 #[inline]
430 /// Slots holding a tombstone: erased, but still probed through.
431 ///
432 /// A test's window onto the growth question — the load check counts
433 /// `occupied + deleted`, so this is half of what decides a grow.
434 #[must_use]
435 pub fn tombstones(&self) -> usize {
436 self.deleted
437 }
438
439 pub(crate) fn threshold(&self) -> usize {
440 self.cap - (self.cap / 8)
441 }
442}
443
444impl<K, V> Default for KevyMap<K, V> {
445 fn default() -> Self {
446 Self::new()
447 }
448}
449
450/// `m[&q]` panics on missing key (matches `std::HashMap::Index` semantics).
451impl<K, Q, V> core::ops::Index<&Q> for KevyMap<K, V>
452where
453 K: core::borrow::Borrow<Q>,
454 Q: KevyHash + Eq + ?Sized,
455{
456 type Output = V;
457 fn index(&self, key: &Q) -> &V {
458 self.get(key).expect("Index panics by contract; get is the fallible form")
459 }
460}
461
462impl<K: fmt::Debug, V: fmt::Debug> fmt::Debug for KevyMap<K, V> {
463 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464 f.debug_map().entries(self.iter()).finish()
465 }
466}
467
468impl<K: KevyHash + Eq, V> FromIterator<(K, V)> for KevyMap<K, V> {
469 fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
470 let iter = iter.into_iter();
471 let mut m = match iter.size_hint() {
472 (lo, Some(hi)) if hi <= lo.saturating_mul(2) => Self::with_capacity(hi),
473 (lo, _) => Self::with_capacity(lo),
474 };
475 for (k, v) in iter {
476 m.insert(k, v);
477 }
478 m
479 }
480}
481
482impl<K: KevyHash + Eq, V> Extend<(K, V)> for KevyMap<K, V> {
483 fn extend<I: IntoIterator<Item = (K, V)>>(&mut self, iter: I) {
484 for (k, v) in iter {
485 self.insert(k, v);
486 }
487 }
488}
489
490#[cfg(test)]
491#[path = "map_tests.rs"]
492mod tests;