Skip to main content

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;
38use core::hash::{BuildHasherDefault, Hasher};
39#[cfg(feature = "std")]
40use std::collections::{HashMap, HashSet};
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/// # Examples
53///
54/// It is a bijection on `u64` — no two inputs collide — and it moves every
55/// bit, which is exactly what the bare Fx absorb does not do:
56///
57/// ```
58/// use kevy_hash::fmix64;
59/// assert_ne!(fmix64(0), fmix64(1));
60///
61/// // One input bit flipped moves about half the output bits.
62/// let moved = (fmix64(0) ^ fmix64(1)).count_ones();
63/// assert_eq!(moved, 33);
64/// assert!((16..48).contains(&moved), "avalanche moved {moved} bits");
65/// ```
66///
67/// Zero is a fixed point — every step is a shift-xor or a multiply, and
68/// each leaves zero alone. That is a property of murmur3's finalizer, not
69/// a defect, and it is why the absorb seeds the state rather than starting
70/// it empty:
71///
72/// ```
73/// assert_eq!(kevy_hash::fmix64(0), 0);
74/// ```
75#[inline]
76pub fn fmix64(mut h: u64) -> u64 {
77    h ^= h >> 33;
78    h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
79    h ^= h >> 33;
80    h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
81    h ^= h >> 33;
82    h
83}
84
85#[inline]
86fn mix(state: u64, word: u64) -> u64 {
87    (state.rotate_left(ROTATE) ^ word).wrapping_mul(SEED)
88}
89
90/// Two-stream pipelined hash_bytes inspired by rustc-hash 2.x's design
91/// (`rustc-hash/src/lib.rs#hash_bytes`). The key trick is keeping two
92/// independent state words `s0` / `s1` updated via 64×64→128 widening
93/// multiplication (one `mul`+`mulhi` on aarch64, one `mul` on x86_64),
94/// XORing the two halves of the product to mix top with bottom. The two
95/// streams are independent of each other in the bulk loop, so LLVM can
96/// schedule them on two ALU ports per cycle.
97///
98/// Lengths ≤ 16: XOR-only absorb of two reads (start + end), then a
99/// single `multiply_mix` of the two streams. The XOR-only absorb is fast
100/// because there's no ALU dependency between the two reads.
101///
102/// Lengths > 16: per-16-byte iteration, `s1 <- multiply_mix(s0 ^ x,
103/// CONST ^ y); s0 <- s1`. The `CONST` (digits of pi) prevents the
104/// all-zeros input from collapsing.
105///
106/// Final mix: `multiply_mix(s0, s1) ^ len` — folds length in so that
107/// `"abc"` and `"ab\0c"` hash differently (the XOR-only short path
108/// doesn't distinguish length-by-position without this).
109///
110/// Then `fmix64` to give us the anti-clustering avalanche we need (the
111/// rustc-hash design assumes its consumer mixes again; we don't, so we
112/// avalanche ourselves — same property as the legacy [`FxHasher`] path).
113#[inline]
114// A byte-faithful transcription of rustc-hash 2.x's `hash_bytes`
115// (verified identical for every length 0..200) with `fmix64` appended.
116// Splitting it would diverge from the upstream it is checked against,
117// which is the whole reason it can be checked at all. The reason given
118// here used to be codegen, which is not one of the two classes the rule
119// allows; this is the second one.
120// LOC-WAIVER: vendored engine core — see the note above.
121fn hash_bytes_pipelined(bytes: &[u8]) -> u64 {
122    // Constants — digits of pi (matches rustc-hash 2.x for cross-bench
123    // sanity; the actual choice doesn't matter beyond "non-zero, not
124    // sharing structure with input distributions").
125    const S1: u64 = 0x243f_6a88_85a3_08d3;
126    const S2: u64 = 0x1319_8a2e_0370_7344;
127    const ANTI_ZERO: u64 = 0xa409_3822_299f_31d0;
128    let len = bytes.len();
129    let mut s0 = S1;
130    let mut s1 = S2;
131
132    if len <= 16 {
133        if len >= 8 {
134            // Read first 8 and last 8 (may overlap when 8 ≤ len ≤ 15).
135            s0 ^= u64::from_le_bytes(bytes[0..8].try_into().expect("the len >= 8 arm"));
136            s1 ^= u64::from_le_bytes(bytes[len - 8..].try_into().expect("the len >= 8 arm"));
137        } else if len >= 4 {
138            s0 ^= u64::from(u32::from_le_bytes(bytes[0..4].try_into().expect("the len >= 4 arm")));
139            s1 ^= u64::from(u32::from_le_bytes(
140                bytes[len - 4..].try_into().expect("the len >= 4 arm"),
141            ));
142        } else if len > 0 {
143            // 1-3 byte tail: form a 3-byte key (lo, mid, hi) that
144            // distinguishes "ab" from "ba" etc.
145            let lo = bytes[0];
146            let mid = bytes[len / 2];
147            let hi = bytes[len - 1];
148            s0 ^= u64::from(lo);
149            s1 ^= (u64::from(hi) << 8) | u64::from(mid);
150        }
151        // len == 0 falls through with s0 == S1, s1 == S2 unchanged.
152    } else {
153        // Bulk: drop the very last byte from the bulk slice so the suffix
154        // 16 bytes can partially overlap with bulk's tail (this is what
155        // rustc-hash 2.x does; it makes the suffix path uniform).
156        let mut bulk = &bytes[..len - 1];
157        while let Some((chunk, rest)) = bulk.split_first_chunk::<16>() {
158            let x = u64::from_le_bytes(chunk[..8].try_into().expect("chunk is [u8; 16]"));
159            let y = u64::from_le_bytes(chunk[8..].try_into().expect("chunk is [u8; 16]"));
160            let t = multiply_mix(s0 ^ x, ANTI_ZERO ^ y);
161            s0 = s1;
162            s1 = t;
163            bulk = rest;
164        }
165        // Suffix 16 bytes (may overlap with last bulk iter).
166        let suffix = &bytes[len - 16..];
167        s0 ^= u64::from_le_bytes(suffix[0..8].try_into().expect("suffix is the last 16 bytes"));
168        s1 ^= u64::from_le_bytes(suffix[8..16].try_into().expect("suffix is the last 16 bytes"));
169    }
170
171    let folded = multiply_mix(s0, s1) ^ (len as u64);
172    fmix64(folded)
173}
174
175/// 64×64→128 widening multiply, XOR'ing the two halves of the product.
176/// Single `mul` on x86_64, one `mul`+one `mulhi` on aarch64. Mixes top and
177/// bottom of the product so the entire output fluctuates with small
178/// changes in the input.
179#[inline]
180fn multiply_mix(x: u64, y: u64) -> u64 {
181    let full = u128::from(x).wrapping_mul(u128::from(y));
182    let lo = full as u64;
183    let hi = (full >> 64) as u64;
184    lo ^ hi
185}
186
187/// Fast, well-distributed [`Hasher`] for kevy's keyspace. Word-at-a-time absorb
188/// (FxHash-style) finished with [`fmix64`]. See the crate docs for the security
189/// trade-off.
190/// # Examples
191///
192/// ```
193/// use core::hash::Hasher;
194/// use kevy_hash::FxHasher;
195///
196/// let mut h = FxHasher::default();
197/// h.write(b"user:1000");
198/// let a = h.finish();
199///
200/// let mut h = FxHasher::default();
201/// h.write(b"user:1001");
202/// assert_ne!(a, h.finish(), "one byte apart must not share a hash");
203/// ```
204///
205/// **The result depends on how the bytes were split across `write` calls.**
206/// The absorb consumes each call word-at-a-time and handles that call's own
207/// tail, so the same bytes delivered in different chunks land on different
208/// values. This is inherited from FxHash and is not a defect, but it means
209/// a hash is only comparable with one built the same way — feed a whole
210/// key in one `write`:
211///
212/// ```
213/// use core::hash::Hasher;
214/// use kevy_hash::FxHasher;
215///
216/// let mut whole = FxHasher::default();
217/// whole.write(b"abcdefgh");
218///
219/// let mut split = FxHasher::default();
220/// split.write(b"abcd");
221/// split.write(b"efgh");
222/// assert_ne!(whole.finish(), split.finish());
223/// ```
224/// # Not injective over byte strings
225///
226/// The absorb consumes whole words and zero-extends a short tail, and
227/// nothing folds in the total length — so distinct inputs share a value
228/// in three families, all with the same cause:
229///
230/// * a zero-extended 1–3 byte tail is indistinguishable from a 4-byte
231///   one: `"aaa"` and `"a\0\0\0aa"`;
232/// * `mix(0, 0) == 0`, so leading zero words are absorbed with no
233///   effect — `""`, `"\0\0\0\0"` and `"\0\0\0\0\0\0\0\0"` all hash to
234///   **0**, and prefixing any key with four NULs leaves its hash
235///   unchanged;
236/// * a 4-byte tail and an 8-byte word with four trailing zeros agree.
237///
238/// Measured: over the 1,093 strings of length ≤ 6 from `{a, b, NUL}`,
239/// **27 collision classes**. A uniform 64-bit hash on that many inputs
240/// would be expected to produce none.
241///
242/// Whether it reaches you depends on how `Hash` feeds the bytes.
243/// `Hash for [u8]` writes a length prefix, so `FxHashMap<Vec<u8>, _>`
244/// and `FxHashMap<SmallBytes, _>` — everything kevy itself uses — are
245/// unaffected, and the measurement above gives 0 classes for them.
246/// `Hash for str` writes only a `0xff` terminator, so **`&str` and
247/// `String` keys do reach it**, which is what the 27 are.
248///
249/// The fix is to fold the total length into [`Hasher::finish`], six
250/// lines, and it changes every value this type produces — which the note
251/// on [`KevyHash for [u8]`](KevyHash) says this path deliberately does
252/// not do. That is an owner decision, still open. Until it is taken, this
253/// section is here so the behaviour is chosen rather than discovered.
254///
255/// (One tempting non-fix, for the record: seeding the initial state to a
256/// non-zero constant. Measured, it makes all three families *worse* —
257/// 39 classes instead of 27 — because it removes the only case the
258/// zero-absorption collapsed.)
259#[derive(Debug, Default)]
260pub struct FxHasher(u64);
261
262impl Hasher for FxHasher {
263    #[inline]
264    fn finish(&self) -> u64 {
265        fmix64(self.0)
266    }
267
268    #[inline]
269    fn write(&mut self, mut bytes: &[u8]) {
270        let mut state = self.0;
271        while bytes.len() >= 8 {
272            let word = u64::from_le_bytes(bytes[..8].try_into().expect("the len >= 8 loop guard"));
273            state = mix(state, word);
274            bytes = &bytes[8..];
275        }
276        if bytes.len() >= 4 {
277            let word =
278                u64::from(u32::from_le_bytes(bytes[..4].try_into().expect("the len >= 4 guard")));
279            state = mix(state, word);
280            bytes = &bytes[4..];
281        }
282        for &b in bytes {
283            state = mix(state, u64::from(b));
284        }
285        self.0 = state;
286    }
287
288    // Fixed-width integer keys (e.g. connection-id maps) skip the byte loop.
289    #[inline]
290    fn write_u64(&mut self, i: u64) {
291        self.0 = mix(self.0, i);
292    }
293    #[inline]
294    fn write_usize(&mut self, i: usize) {
295        self.0 = mix(self.0, i as u64);
296    }
297}
298
299/// [`BuildHasher`](std::hash::BuildHasher) for [`FxHasher`]. Seedless, so equal
300/// keys hash equally across instances and process runs.
301///
302/// ```
303/// use std::hash::BuildHasher;
304/// use kevy_hash::FxBuildHasher;
305/// let (a, b) = (FxBuildHasher::default(), FxBuildHasher::default());
306/// assert_eq!(a.hash_one(b"key"), b.hash_one(b"key"), "no per-instance seed");
307/// ```
308pub type FxBuildHasher = BuildHasherDefault<FxHasher>;
309
310/// Single-call hashing for kevy's per-command hot path.
311///
312/// `std::hash::Hasher` is a state-machine API — every hash is `Hasher::default()`
313/// → `write_*` → `finish`, with `BuildHasher` indirection on top. For
314/// `kevy-map`'s open-addressing table the keyspace is a small handful of
315/// well-known leaf types (`[u8]`, `u32`, `u64`, `i32`); we get a faster, inline-
316/// friendly hash by exposing one method on each that produces the final mixed
317/// 64-bit value in one go.
318///
319/// **The integer impls** agree with feeding the value through
320/// [`FxHasher`] and calling `finish`, so for those the trait is a
321/// dispatch shortcut and nothing more. **The `[u8]` impl does not** — it
322/// takes the two-stream pipelined path, and its own documentation says
323/// so.
324///
325/// That distinction used to be stated as "all impls must agree", forty
326/// lines above the note admitting one of them does not. This is not a
327/// typo to tidy: the sentence declared exactly the property that makes
328/// mixing the two safe, so a caller who used `FxHashMap` in one place
329/// and `kevy_hash()` in another and compared across them would have been
330/// silently wrong, on the strength of a guarantee written here.
331///
332/// `kevy-map` consumes both the full hash (for bucket index) and its top
333/// 7 bits (for the metadata byte).
334/// # Examples
335///
336/// The point of the trait is that a leaf type hashes in one call, with no
337/// `Hasher` to build and no dispatch to pay:
338///
339/// ```
340/// use kevy_hash::KevyHash;
341/// assert_ne!(1u64.kevy_hash(), 2u64.kevy_hash());
342/// assert_ne!(b"a"[..].kevy_hash(), b"b"[..].kevy_hash());
343/// ```
344///
345/// The integer impls agree with routing the same value through
346/// [`FxHasher`], which is what lets the dispatch be cut without changing
347/// the hash:
348///
349/// ```
350/// use core::hash::Hasher;
351/// use kevy_hash::{FxHasher, KevyHash};
352///
353/// let mut h = FxHasher::default();
354/// h.write_u64(0x0123_4567_89ab_cdef);
355/// assert_eq!(0x0123_4567_89ab_cdefu64.kevy_hash(), h.finish());
356/// ```
357///
358/// # Implementing it
359///
360/// The trait is open: `kevy-map` keys a map by any `K: KevyHash + Eq`, so
361/// a key type of your own implements it. An implementation must uphold:
362///
363/// * **Equal values hash equally.** `a == b` implies
364///   `a.kevy_hash() == b.kevy_hash()`, as with [`core::hash::Hash`].
365/// * **Borrowed forms agree.** If `K: Borrow<Q>` and a map of `K` is
366///   looked up by `&Q`, then `k.kevy_hash() == k.borrow().kevy_hash()` —
367///   the rule `Vec<u8>` and `[u8]` follow here.
368/// * **Every bit moves.** `kevy-map` takes the bucket from the low bits
369///   and a tag from the top seven, so a hash whose high bits barely vary
370///   degrades lookups to a scan. Finishing with [`fmix64`] gives this.
371///
372/// A hash that breaks the first two loses entries; one that breaks the
373/// third is slow but correct.
374///
375/// ```
376/// use kevy_hash::{KevyHash, fmix64};
377///
378/// #[derive(PartialEq, Eq)]
379/// struct Port(u16);
380///
381/// impl KevyHash for Port {
382///     fn kevy_hash(&self) -> u64 {
383///         fmix64(u64::from(self.0) | 1 << 32)
384///     }
385/// }
386/// assert_ne!(Port(1).kevy_hash(), Port(2).kevy_hash());
387/// ```
388pub trait KevyHash {
389    /// Compute the final mixed 64-bit hash of `self` in one call.
390    ///
391    /// ```
392    /// use kevy_hash::KevyHash;
393    /// let a = b"user:1".as_slice().kevy_hash();
394    /// assert_eq!(a, b"user:1".to_vec().as_slice().kevy_hash(), "depends only on the bytes");
395    /// assert_ne!(a, b"user:2".as_slice().kevy_hash());
396    /// assert_ne!(1u64.kevy_hash(), 2u64.kevy_hash());
397    /// ```
398    fn kevy_hash(&self) -> u64;
399}
400
401impl KevyHash for [u8] {
402    /// Byte-slice hash. Uses the **two-stream pipelined** path internally
403    /// for ILP on the bench's 8-64 byte keyspace, closing the prior 1 ns
404    /// gap vs rustc-hash 2.x's `hash_bytes`. The final `fmix64` retains
405    /// the anti-clustering guarantee that the
406    /// `no_catastrophic_clustering_on_low_entropy_keys` test enforces.
407    ///
408    /// Note: the result diverges from the legacy [`FxHasher`] absorb path —
409    /// callers using `FxHashMap<Vec<u8>, _>` route through std's
410    /// `Hash::hash → Hasher::write → finish` (the legacy single-stream
411    /// path), which intentionally stays put for cross-instance hash
412    /// stability with anything that depended on the v0.polish bit pattern.
413    /// The `KevyHash for [u8]` impl is for one-call hot paths like
414    /// `kevy-map::find_by_borrow`, which is the only one we measure.
415    #[inline]
416    fn kevy_hash(&self) -> u64 {
417        hash_bytes_pipelined(self)
418    }
419}
420
421#[cfg(feature = "alloc")]
422impl KevyHash for Vec<u8> {
423    #[inline]
424    fn kevy_hash(&self) -> u64 {
425        self.as_slice().kevy_hash()
426    }
427}
428
429impl KevyHash for u64 {
430    #[inline]
431    fn kevy_hash(&self) -> u64 {
432        fmix64(mix(0, *self))
433    }
434}
435
436impl KevyHash for u32 {
437    #[inline]
438    fn kevy_hash(&self) -> u64 {
439        fmix64(mix(0, u64::from(*self)))
440    }
441}
442
443impl KevyHash for i32 {
444    #[inline]
445    fn kevy_hash(&self) -> u64 {
446        // Sign-extend to u64 so equal i32 values hash the same as if widened
447        // through the integer path; negatives' top bits still fmix64 away.
448        fmix64(mix(0, i64::from(*self) as u64))
449    }
450}
451
452impl KevyHash for usize {
453    #[inline]
454    fn kevy_hash(&self) -> u64 {
455        fmix64(mix(0, *self as u64))
456    }
457}
458
459/// A [`HashMap`] using [`FxHasher`] instead of SipHash.
460///
461/// ```
462/// use kevy_hash::FxHashMap;
463/// let mut m: FxHashMap<&str, u32> = FxHashMap::default();
464/// m.insert("a", 1);
465/// *m.entry("a").or_insert(0) += 1;
466/// assert_eq!(m["a"], 2);
467/// ```
468#[cfg(feature = "std")]
469pub type FxHashMap<K, V> = HashMap<K, V, FxBuildHasher>;
470
471/// A [`HashSet`] using [`FxHasher`] instead of SipHash.
472///
473/// ```
474/// use kevy_hash::FxHashSet;
475/// let s: FxHashSet<u64> = [1, 2, 2, 3].into_iter().collect();
476/// assert_eq!(s.len(), 3);
477/// assert!(s.contains(&2));
478/// ```
479#[cfg(feature = "std")]
480pub type FxHashSet<T> = HashSet<T, FxBuildHasher>;
481
482// Send and Sync are part of the public contract: a change that loses
483// either fails to compile here rather than in a caller.
484const _: () = {
485    const fn send_sync<T: Send + Sync>() {}
486    send_sync::<FxHasher>();
487};
488
489#[cfg(test)]
490mod tests;