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}