kevy_hash/lib.rs
1//! kevy-hash — fast, well-distributed hashing for kevy's single-trust-domain
2//! keyspace. Zero dependencies.
3//!
4//! std's `HashMap` is a hashbrown Swiss table (excellent — kevy keeps it) keyed
5//! by `SipHash-1-3` (DoS-resistant, but a tax a single-threaded-per-shard
6//! keyspace facing no adversarial cross-trust key collisions does not need).
7//! This crate supplies the hasher that table should use instead: an FxHash-style
8//! word-at-a-time absorb plus a murmur3 [`fmix64`] avalanche finalizer.
9//!
10//! Measured (via `kevy-store/examples/bench_keyspace.rs`): ~4× faster
11//! hashing, ~1.2–2.8× faster GET-hit, ~1.1–1.7× faster GET-miss than
12//! SipHash, with no clustering.
13//! The finalizer is **essential** — the bare Fx absorb (no `fmix64`) clusters
14//! 30–50× on low-entropy sequential keys like `"key:0".."key:99999"`.
15//!
16//! **Not DoS-resistant.** There is no random seed, so an attacker who can choose
17//! keys *and* observe timing could force collisions. kevy's keyspace lives
18//! inside one trust domain per shard, so this is the right trade; do not reuse
19//! this hasher for maps fed untrusted, adversarially-chosen keys across a trust
20//! boundary.
21//!
22//! ```
23//! use kevy_hash::FxHashMap;
24//!
25//! let mut m: FxHashMap<Vec<u8>, u64> = FxHashMap::default();
26//! m.insert(b"key".to_vec(), 1);
27//! assert_eq!(m.get(b"key".as_slice()), Some(&1));
28//! ```
29#![forbid(unsafe_code)]
30#![warn(missing_docs)]
31#![cfg_attr(not(feature = "std"), no_std)]
32
33#[cfg(feature = "alloc")]
34extern crate alloc;
35
36#[cfg(feature = "alloc")]
37use alloc::vec::Vec;
38#[cfg(feature = "std")]
39use std::collections::{HashMap, HashSet};
40use core::hash::{BuildHasherDefault, Hasher};
41
42mod crc16;
43pub use crc16::{crc16, key_hash_slot};
44
45/// FxHash mixing constant (rustc's `rustc-hash` seed).
46const SEED: u64 = 0x517c_c1b7_2722_0a95;
47const ROTATE: u32 = 5;
48
49/// murmur3 `fmix64` avalanche — spreads every input bit across all 64 output
50/// bits. ~6 ALU ops, applied once on [`Hasher::finish`]. This is what the bare
51/// Fx absorb lacks, and why it clusters without it.
52#[inline]
53pub fn fmix64(mut h: u64) -> u64 {
54 h ^= h >> 33;
55 h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
56 h ^= h >> 33;
57 h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
58 h ^= h >> 33;
59 h
60}
61
62#[inline]
63fn mix(state: u64, word: u64) -> u64 {
64 (state.rotate_left(ROTATE) ^ word).wrapping_mul(SEED)
65}
66
67/// Two-stream pipelined hash_bytes inspired by rustc-hash 2.x's design
68/// (`rustc-hash/src/lib.rs#hash_bytes`). The key trick is keeping two
69/// independent state words `s0` / `s1` updated via 64×64→128 widening
70/// multiplication (one `mul`+`mulhi` on aarch64, one `mul` on x86_64),
71/// XORing the two halves of the product to mix top with bottom. The two
72/// streams are independent of each other in the bulk loop, so LLVM can
73/// schedule them on two ALU ports per cycle.
74///
75/// Lengths ≤ 16: XOR-only absorb of two reads (start + end), then a
76/// single `multiply_mix` of the two streams. The XOR-only absorb is fast
77/// because there's no ALU dependency between the two reads.
78///
79/// Lengths > 16: per-16-byte iteration, `s1 <- multiply_mix(s0 ^ x,
80/// CONST ^ y); s0 <- s1`. The `CONST` (digits of pi) prevents the
81/// all-zeros input from collapsing.
82///
83/// Final mix: `multiply_mix(s0, s1) ^ len` — folds length in so that
84/// `"abc"` and `"ab\0c"` hash differently (the XOR-only short path
85/// doesn't distinguish length-by-position without this).
86///
87/// Then `fmix64` to give us the anti-clustering avalanche we need (the
88/// rustc-hash design assumes its consumer mixes again; we don't, so we
89/// avalanche ourselves — same property as the legacy [`FxHasher`] path).
90#[inline]
91// LOC-WAIVER: per-op hash hot body — the short/bulk paths stay fused in one frame for codegen.
92fn hash_bytes_pipelined(bytes: &[u8]) -> u64 {
93 // Constants — digits of pi (matches rustc-hash 2.x for cross-bench
94 // sanity; the actual choice doesn't matter beyond "non-zero, not
95 // sharing structure with input distributions").
96 const S1: u64 = 0x243f_6a88_85a3_08d3;
97 const S2: u64 = 0x1319_8a2e_0370_7344;
98 const ANTI_ZERO: u64 = 0xa409_3822_299f_31d0;
99 let len = bytes.len();
100 let mut s0 = S1;
101 let mut s1 = S2;
102
103 if len <= 16 {
104 if len >= 8 {
105 // Read first 8 and last 8 (may overlap when 8 ≤ len ≤ 15).
106 s0 ^= u64::from_le_bytes(bytes[0..8].try_into().unwrap());
107 s1 ^= u64::from_le_bytes(bytes[len - 8..].try_into().unwrap());
108 } else if len >= 4 {
109 s0 ^= u64::from(u32::from_le_bytes(bytes[0..4].try_into().unwrap()));
110 s1 ^= u64::from(u32::from_le_bytes(bytes[len - 4..].try_into().unwrap()));
111 } else if len > 0 {
112 // 1-3 byte tail: form a 3-byte key (lo, mid, hi) that
113 // distinguishes "ab" from "ba" etc.
114 let lo = bytes[0];
115 let mid = bytes[len / 2];
116 let hi = bytes[len - 1];
117 s0 ^= u64::from(lo);
118 s1 ^= (u64::from(hi) << 8) | u64::from(mid);
119 }
120 // len == 0 falls through with s0 == S1, s1 == S2 unchanged.
121 } else {
122 // Bulk: drop the very last byte from the bulk slice so the suffix
123 // 16 bytes can partially overlap with bulk's tail (this is what
124 // rustc-hash 2.x does; it makes the suffix path uniform).
125 let mut bulk = &bytes[..len - 1];
126 while let Some((chunk, rest)) = bulk.split_first_chunk::<16>() {
127 let x = u64::from_le_bytes(chunk[..8].try_into().unwrap());
128 let y = u64::from_le_bytes(chunk[8..].try_into().unwrap());
129 let t = multiply_mix(s0 ^ x, ANTI_ZERO ^ y);
130 s0 = s1;
131 s1 = t;
132 bulk = rest;
133 }
134 // Suffix 16 bytes (may overlap with last bulk iter).
135 let suffix = &bytes[len - 16..];
136 s0 ^= u64::from_le_bytes(suffix[0..8].try_into().unwrap());
137 s1 ^= u64::from_le_bytes(suffix[8..16].try_into().unwrap());
138 }
139
140 let folded = multiply_mix(s0, s1) ^ (len as u64);
141 fmix64(folded)
142}
143
144/// 64×64→128 widening multiply, XOR'ing the two halves of the product.
145/// Single `mul` on x86_64, one `mul`+one `mulhi` on aarch64. Mixes top and
146/// bottom of the product so the entire output fluctuates with small
147/// changes in the input.
148#[inline]
149fn multiply_mix(x: u64, y: u64) -> u64 {
150 let full = u128::from(x).wrapping_mul(u128::from(y));
151 let lo = full as u64;
152 let hi = (full >> 64) as u64;
153 lo ^ hi
154}
155
156/// Fast, well-distributed [`Hasher`] for kevy's keyspace. Word-at-a-time absorb
157/// (FxHash-style) finished with [`fmix64`]. See the crate docs for the security
158/// trade-off.
159#[derive(Default)]
160pub struct FxHasher(u64);
161
162impl Hasher for FxHasher {
163 #[inline]
164 fn finish(&self) -> u64 {
165 fmix64(self.0)
166 }
167
168 #[inline]
169 fn write(&mut self, mut bytes: &[u8]) {
170 let mut state = self.0;
171 while bytes.len() >= 8 {
172 let word = u64::from_le_bytes(bytes[..8].try_into().unwrap());
173 state = mix(state, word);
174 bytes = &bytes[8..];
175 }
176 if bytes.len() >= 4 {
177 let word = u64::from(u32::from_le_bytes(bytes[..4].try_into().unwrap()));
178 state = mix(state, word);
179 bytes = &bytes[4..];
180 }
181 for &b in bytes {
182 state = mix(state, u64::from(b));
183 }
184 self.0 = state;
185 }
186
187 // Fixed-width integer keys (e.g. connection-id maps) skip the byte loop.
188 #[inline]
189 fn write_u64(&mut self, i: u64) {
190 self.0 = mix(self.0, i);
191 }
192 #[inline]
193 fn write_usize(&mut self, i: usize) {
194 self.0 = mix(self.0, i as u64);
195 }
196}
197
198/// [`BuildHasher`](std::hash::BuildHasher) for [`FxHasher`]. Seedless, so equal
199/// keys hash equally across instances and process runs.
200pub type FxBuildHasher = BuildHasherDefault<FxHasher>;
201
202/// Single-call hashing for kevy's per-command hot path.
203///
204/// `std::hash::Hasher` is a state-machine API — every hash is `Hasher::default()`
205/// → `write_*` → `finish`, with `BuildHasher` indirection on top. For
206/// `kevy-map`'s open-addressing table the keyspace is a small handful of
207/// well-known leaf types (`[u8]`, `u32`, `u64`, `i32`); we get a faster, inline-
208/// friendly hash by exposing one method on each that produces the final mixed
209/// 64-bit value in one go.
210///
211/// All impls must agree with feeding the value through [`FxHasher`] then
212/// calling `finish` — this lets us cut the trait dispatch without changing the
213/// hash function. `kevy-map` consumes both the full hash (for bucket index)
214/// and its top 7 bits (for the metadata byte).
215pub trait KevyHash {
216 /// Compute the final mixed 64-bit hash of `self` in one call.
217 fn kevy_hash(&self) -> u64;
218}
219
220impl KevyHash for [u8] {
221 /// Byte-slice hash. Uses the **two-stream pipelined** path internally
222 /// for ILP on the bench's 8-64 byte keyspace, closing the prior 1 ns
223 /// gap vs rustc-hash 2.x's `hash_bytes`. The final `fmix64` retains
224 /// the anti-clustering guarantee that the
225 /// `no_catastrophic_clustering_on_low_entropy_keys` test enforces.
226 ///
227 /// Note: the result diverges from the legacy [`FxHasher`] absorb path —
228 /// callers using `FxHashMap<Vec<u8>, _>` route through std's
229 /// `Hash::hash → Hasher::write → finish` (the legacy single-stream
230 /// path), which intentionally stays put for cross-instance hash
231 /// stability with anything that depended on the v0.polish bit pattern.
232 /// The `KevyHash for [u8]` impl is for one-call hot paths like
233 /// `kevy-map::find_by_borrow`, which is the only one we measure.
234 #[inline]
235 fn kevy_hash(&self) -> u64 {
236 hash_bytes_pipelined(self)
237 }
238}
239
240#[cfg(feature = "alloc")]
241impl KevyHash for Vec<u8> {
242 #[inline]
243 fn kevy_hash(&self) -> u64 {
244 self.as_slice().kevy_hash()
245 }
246}
247
248impl KevyHash for u64 {
249 #[inline]
250 fn kevy_hash(&self) -> u64 {
251 fmix64(mix(0, *self))
252 }
253}
254
255impl KevyHash for u32 {
256 #[inline]
257 fn kevy_hash(&self) -> u64 {
258 fmix64(mix(0, u64::from(*self)))
259 }
260}
261
262impl KevyHash for i32 {
263 #[inline]
264 fn kevy_hash(&self) -> u64 {
265 // Sign-extend to u64 so equal i32 values hash the same as if widened
266 // through the integer path; negatives' top bits still fmix64 away.
267 fmix64(mix(0, i64::from(*self) as u64))
268 }
269}
270
271impl KevyHash for usize {
272 #[inline]
273 fn kevy_hash(&self) -> u64 {
274 fmix64(mix(0, *self as u64))
275 }
276}
277
278/// A [`HashMap`] using [`FxHasher`] instead of SipHash.
279#[cfg(feature = "std")]
280pub type FxHashMap<K, V> = HashMap<K, V, FxBuildHasher>;
281
282/// A [`HashSet`] using [`FxHasher`] instead of SipHash.
283#[cfg(feature = "std")]
284pub type FxHashSet<T> = HashSet<T, FxBuildHasher>;
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::hash::BuildHasher;
290
291 fn h(bytes: &[u8]) -> u64 {
292 FxBuildHasher::default().hash_one(bytes)
293 }
294
295 #[test]
296 fn deterministic_across_instances() {
297 assert_eq!(h(b"hello"), h(b"hello"));
298 assert_ne!(h(b"hello"), h(b"hellp"));
299 assert_ne!(h(b""), h(b"\0"));
300 }
301
302 #[test]
303 fn map_roundtrip() {
304 let mut m: FxHashMap<Vec<u8>, u64> = FxHashMap::default();
305 for i in 0..10_000u64 {
306 m.insert(format!("key:{i}").into_bytes(), i);
307 }
308 assert_eq!(m.len(), 10_000);
309 for i in 0..10_000u64 {
310 assert_eq!(m.get(format!("key:{i}").into_bytes().as_slice()), Some(&i));
311 }
312 }
313
314 #[test]
315 fn kevy_hash_bytes_is_deterministic_and_distinct() {
316 // KevyHash for [u8] uses the two-stream pipelined hash_bytes_pipelined
317 // path (the rustc-hash 2.x trick + our fmix64 finalize). It diverges
318 // from the legacy FxHasher::write byte absorb path — see the impl
319 // doc-comment.
320 let key = b"hello-world".as_slice();
321 // Deterministic across calls (no random seed).
322 assert_eq!(key.kevy_hash(), key.kevy_hash());
323 // Distinct from a single-bit-flipped key.
324 assert_ne!(key.kevy_hash(), b"hello-worle".as_slice().kevy_hash());
325 // Length matters (XOR-only short path otherwise wouldn't distinguish).
326 assert_ne!(b"abc".as_slice().kevy_hash(), b"abcd".as_slice().kevy_hash());
327 // The legacy FxHasher path is still available via std Hasher trait
328 // (FxHashMap users); the two no longer have to agree.
329 let mut staged = FxHasher::default();
330 staged.write(key);
331 let _fx_legacy = staged.finish();
332 // Intentionally no assert_eq! here — divergence is the point.
333 }
334
335 #[test]
336 fn kevy_hash_integer_paths_differ_per_value() {
337 let a: u64 = 1;
338 let b: u64 = 2;
339 assert_ne!(a.kevy_hash(), b.kevy_hash());
340 let i: i32 = -1;
341 let j: i32 = 1;
342 assert_ne!(i.kevy_hash(), j.kevy_hash());
343 }
344
345 #[test]
346 fn kevy_hash_top7_bits_distribute() {
347 // Same low-entropy clustering guard, but driven through `kevy_hash`
348 // on byte slices — the path kevy-map's metadata byte will use.
349 let mut top = [0u32; 128];
350 for i in 0..4096u64 {
351 let mut k = format!("key:{i}").into_bytes();
352 k.resize(12, b'x');
353 let hash = k.as_slice().kevy_hash();
354 top[(hash >> 57) as usize] += 1;
355 }
356 let max = *top.iter().max().unwrap();
357 assert!(max < 128, "top-7-bit skew {max} (mean 32) — avalanche failing");
358 }
359
360 #[test]
361 fn integer_keys_roundtrip() {
362 let mut m: FxHashMap<u64, u64> = FxHashMap::default();
363 for i in 0..1_000u64 {
364 m.insert(i, i * 2);
365 }
366 assert_eq!(m.get(&500), Some(&1_000));
367 assert_eq!(m.get(&999), Some(&1_998));
368 }
369
370 /// Guards against the raw-Fx failure mode: low-entropy sequential keys
371 /// (`"key:0xxxxx".."key:99999x"`) must spread across buckets, not pile up.
372 /// `fmix64` is what makes this pass; removing it would fail loudly.
373 #[test]
374 fn no_catastrophic_clustering_on_low_entropy_keys() {
375 let keys: Vec<Vec<u8>> = (0..4096u64)
376 .map(|i| {
377 let mut k = format!("key:{i}").into_bytes();
378 k.resize(12, b'x');
379 k
380 })
381 .collect();
382
383 // Low bits drive the bucket index; 4096 keys / 256 → mean 16/bucket.
384 let mut low = [0u32; 256];
385 // Top 7 bits drive hashbrown's SIMD control byte; / 128 → mean 32.
386 let mut top = [0u32; 128];
387 for k in &keys {
388 let hash = h(k);
389 low[(hash & 0xff) as usize] += 1;
390 top[(hash >> 57) as usize] += 1;
391 }
392 let max_low = *low.iter().max().unwrap();
393 let max_top = *top.iter().max().unwrap();
394 // Well-avalanched ⇒ no bucket exceeds ~4× the mean.
395 assert!(max_low < 64, "low-bit skew {max_low} (mean 16) — avalanche failing");
396 assert!(max_top < 128, "top-bit skew {max_top} (mean 32) — avalanche failing");
397 }
398
399 // ---- KevyHash impls for delegating types (cov for u32 / usize / Vec<u8>) -
400
401 #[test]
402 fn kevy_hash_vec_u8_agrees_with_slice() {
403 let v: Vec<u8> = b"hello-world".to_vec();
404 assert_eq!(v.kevy_hash(), v.as_slice().kevy_hash());
405 }
406
407 #[test]
408 fn kevy_hash_u32_agrees_with_widened_u64() {
409 // u32 widens through u64 → same hash as the u64 form of the same value.
410 let n: u32 = 0xCAFE_BABE;
411 assert_eq!(n.kevy_hash(), u64::from(n).kevy_hash());
412 // Distinct values produce distinct hashes.
413 let m: u32 = n.wrapping_add(1);
414 assert_ne!(n.kevy_hash(), m.kevy_hash());
415 }
416
417 #[test]
418 fn kevy_hash_usize_agrees_with_u64() {
419 // usize sign-free widens through u64. Equal-valued usize ↔ u64
420 // must hash the same so a map keyed by either reads back equivalently.
421 let n: usize = 42;
422 assert_eq!(n.kevy_hash(), (n as u64).kevy_hash());
423 let m: usize = 43;
424 assert_ne!(n.kevy_hash(), m.kevy_hash());
425 }
426}