Skip to main content

pdfrum_common/
fasthash.rs

1//! A fast, non-cryptographic hasher for maps whose keys come from *us*.
2//!
3//! # Why this exists at all
4//!
5//! `std`'s default hasher is `SipHash`-1-3 with a per-process random seed, and it
6//! is the right default: a `HashMap` keyed by something an attacker chooses —
7//! a PDF's name strings, say — must not be collidable on purpose, or a crafted
8//! file turns every lookup into a linked-list walk. That is a real threat model
9//! for a PDF engine and nothing here weakens it.
10//!
11//! But two of this workspace's hottest maps are not keyed on file content at
12//! all. `pdfrum-parser`'s object store is keyed by **object number**, a `u32`
13//! the parser assigns; `pdfrum-render`'s glyph-bitmap cache is keyed by a small
14//! POD struct of a glyph id and four matrix coefficients. Both are looked up
15//! once per drawn glyph — tens of thousands of times on a dense page — and for
16//! both, `SipHash` is doing cryptographic-strength mixing on eight bytes that
17//! no file controls. Measured, that is where a few percent of a text-heavy
18//! render goes.
19//!
20//! # Why not `rustc-hash`
21//!
22//! `rustc-hash` is the crate that does exactly this. A performance dependency
23//! here only earns its place beside an A/B against a tuned no-dep baseline,
24//! and this module is that baseline: it is `rustc-hash`'s algorithm, which is
25//! a multiply and a rotate per word and about twenty lines.
26//!
27//! The outcome, recorded here because it is the reason this file rather than a
28//! `Cargo.toml` line: the no-dep version is **the same speed**, because it is
29//! the same three instructions. There is nothing for a dependency to add.
30//!
31//! # What must not be keyed with this
32//!
33//! Anything an untrusted file controls the bytes of: name strings, dictionary
34//! keys, font names, decoded text. `FxBuildHasher` is trivially collidable by
35//! construction — that is the trade that makes it fast. The two call sites are
36//! chosen because their keys are integers this workspace generates, and a new
37//! call site needs the same argument made for it.
38
39use std::hash::{BuildHasherDefault, Hasher};
40
41/// A `BuildHasher` for [`FxHasher`], for use as a map's third type parameter.
42///
43/// ```
44/// use pdfrum_common::FxBuildHasher;
45/// use std::collections::HashMap;
46///
47/// let mut map: HashMap<u32, &str, FxBuildHasher> = HashMap::default();
48/// map.insert(7, "seven");
49/// assert_eq!(map.get(&7), Some(&"seven"));
50/// ```
51pub type FxBuildHasher = BuildHasherDefault<FxHasher>;
52
53/// The multiplier, from `rustc-hash`: the 64-bit odd constant derived from the
54/// fractional part of the golden ratio, which is what gives the multiply its
55/// avalanche across the whole word.
56const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
57
58/// The rotate, applied before each multiply so that low-entropy high bits
59/// reach the low ones. Five is `rustc-hash`'s.
60const ROTATE: u32 = 5;
61
62/// A non-cryptographic hasher: rotate, xor, multiply, per word.
63///
64/// **Not collision-resistant, and not seeded.** See the module docs for which
65/// keys may and may not use it. Deterministic across runs and across machines,
66/// which is a property this workspace happens to want elsewhere too — nothing
67/// in a golden or a scoreboard may depend on a hash order, and with a fixed
68/// hasher a map iteration that accidentally did would at least fail
69/// reproducibly rather than one run in ten.
70#[derive(Debug, Clone, Copy, Default)]
71pub struct FxHasher {
72    /// The running state.
73    hash: u64,
74}
75
76impl FxHasher {
77    /// Fold one word in.
78    #[inline]
79    fn add(&mut self, word: u64) {
80        self.hash = (self.hash.rotate_left(ROTATE) ^ word).wrapping_mul(SEED);
81    }
82}
83
84impl Hasher for FxHasher {
85    #[inline]
86    fn finish(&self) -> u64 {
87        self.hash
88    }
89
90    #[inline]
91    fn write(&mut self, bytes: &[u8]) {
92        // Whole words first, then whatever is left, one byte at a time. The
93        // chunked loop is what makes this competitive on a longer key; the two
94        // call sites in this workspace both go through `write_u32` /
95        // `write_u64` below and never reach it, but a `Hasher` has to answer
96        // `write` correctly regardless of who calls it.
97        let (words, remainder) = bytes.as_chunks::<8>();
98        for word in words {
99            self.add(u64::from_ne_bytes(*word));
100        }
101        for &byte in remainder {
102            self.add(u64::from(byte));
103        }
104    }
105
106    #[inline]
107    fn write_u8(&mut self, n: u8) {
108        self.add(u64::from(n));
109    }
110
111    #[inline]
112    fn write_u16(&mut self, n: u16) {
113        self.add(u64::from(n));
114    }
115
116    #[inline]
117    fn write_u32(&mut self, n: u32) {
118        self.add(u64::from(n));
119    }
120
121    #[inline]
122    fn write_u64(&mut self, n: u64) {
123        self.add(n);
124    }
125
126    #[inline]
127    fn write_usize(&mut self, n: usize) {
128        self.add(n as u64);
129    }
130
131    #[inline]
132    fn write_i32(&mut self, n: i32) {
133        // Through `u32` and not `i64`: sign-extending a negative matrix
134        // coefficient would set the top 32 bits of every one of them, leaving
135        // the multiply less to work with. The glyph cache's key is four `i32`s.
136        // `cast_unsigned` is the reinterpretation, not a value conversion —
137        // the bits are what is being hashed.
138        self.add(u64::from(n.cast_unsigned()));
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::collections::HashMap;
146    use std::hash::Hash;
147
148    fn hash_of<T: Hash>(value: &T) -> u64 {
149        let mut hasher = FxHasher::default();
150        value.hash(&mut hasher);
151        hasher.finish()
152    }
153
154    #[test]
155    fn distinct_small_integers_do_not_collide() {
156        // The property that matters at the call sites: object numbers and
157        // glyph ids are small, dense and distinct, and a hasher that mapped
158        // them onto a handful of buckets would be slower than SipHash however
159        // few instructions it used.
160        let hashes: std::collections::HashSet<u64> = (0_u32..10_000).map(|n| hash_of(&n)).collect();
161        assert_eq!(hashes.len(), 10_000);
162    }
163
164    #[test]
165    fn a_tuple_of_integers_spreads_over_the_low_bits() {
166        // A map buckets on the *low* bits of the hash, so a hasher whose
167        // entropy all lands high is useless in practice however good its
168        // avalanche looks. This checks the shape the glyph cache actually uses:
169        // several integers hashed in sequence, bucketed 256 ways.
170        let mut buckets = [0_u32; 256];
171        for a in 0_i32..40 {
172            for b in 0_i32..40 {
173                let h = hash_of(&(a, b, a ^ b, a.wrapping_mul(b)));
174                let bucket = usize::try_from(h & 0xff).unwrap_or(0);
175                if let Some(slot) = buckets.get_mut(bucket) {
176                    *slot += 1;
177                }
178            }
179        }
180        // 1600 keys over 256 buckets is 6.25 each on average. A bucket holding
181        // more than 40 would mean the low bits are barely moving.
182        let worst = buckets.iter().copied().max().unwrap_or(0);
183        assert!(worst < 40, "worst bucket held {worst} of 1600");
184        assert!(
185            buckets.iter().filter(|&&n| n == 0).count() < 32,
186            "too many empty buckets: {}",
187            buckets.iter().filter(|&&n| n == 0).count()
188        );
189    }
190
191    #[test]
192    fn it_works_as_a_hashmap_hasher() {
193        let mut map: HashMap<u32, u32, FxBuildHasher> = HashMap::default();
194        for n in 0..1000 {
195            map.insert(n, n * 2);
196        }
197        for n in 0..1000 {
198            assert_eq!(map.get(&n), Some(&(n * 2)));
199        }
200        assert_eq!(map.get(&1000), None);
201    }
202
203    #[test]
204    fn it_is_deterministic_across_instances() {
205        // `std`'s default hasher is seeded per process; this one is not, by
206        // design. Anything that would break under a stable hash order breaks
207        // reproducibly rather than one run in ten.
208        assert_eq!(hash_of(&12345_u32), hash_of(&12345_u32));
209        assert_ne!(hash_of(&12345_u32), hash_of(&12346_u32));
210    }
211
212    #[test]
213    fn a_negative_i32_does_not_saturate_the_high_word() {
214        // `write_i32` goes through `u32` rather than sign-extending. If it
215        // sign-extended, every negative coefficient would set bits 32..64 and
216        // four of them in a row would leave the multiply almost nothing to
217        // distinguish. Checked as a difference rather than a constant.
218        assert_ne!(hash_of(&(-1_i32, -2_i32)), hash_of(&(-2_i32, -1_i32)));
219        assert_ne!(hash_of(&(-1_i32, 0_i32)), hash_of(&(0_i32, -1_i32)));
220    }
221}