diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 25232b8..fd5393d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -21,7 +21,15 @@ jobs: - name: Build run: cargo hack build --verbose --release --feature-powerset --exclude-features loom - name: Tests - run: cargo hack test --verbose --release --feature-powerset --exclude-features loom + run: cargo hack test --verbose --release --lib --bins --tests --feature-powerset --exclude-features loom + - name: Documentation tests + run: cargo test --verbose --release --doc + - name: No default hasher + run: cargo test --verbose --release --lib --bins --tests --no-default-features + - name: Non rand default seed + run: cargo test --lib --no-default-features --features default-hasher hasher::test + - name: Non rand without std + run: cargo check --no-default-features --features default-hasher - name: Install thumb target run: rustup target add thumbv7em-none-eabi @@ -29,9 +37,9 @@ jobs: run: cargo check --target thumbv7em-none-eabi --no-default-features - name: Loom build - run: cargo hack build --lib --bins --tests --feature-powerset --exclude-features serde,rand,default + run: cargo build --lib --bins --tests --no-default-features --features loom,default-hasher - name: Loom tests - run: cargo hack test loom --lib --bins --tests --feature-powerset --exclude-features serde,rand,default + run: cargo test loom --lib --bins --tests --no-default-features --features loom,default-hasher msrv: runs-on: ubuntu-latest @@ -39,14 +47,22 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: - toolchain: "1.70" + toolchain: "1.71" override: true - name: Install cargo-hack run: cargo install cargo-hack --version 0.6.37 --force --locked - name: Build run: cargo hack build --verbose --release --feature-powerset --exclude-features loom - name: Tests - run: cargo hack test --verbose --release --feature-powerset --exclude-features loom + run: cargo hack test --verbose --release --lib --bins --tests --feature-powerset --exclude-features loom + - name: Documentation tests + run: cargo test --verbose --release --doc + - name: No default hasher + run: cargo test --verbose --release --lib --bins --tests --no-default-features + - name: Non rand default seed + run: cargo test --lib --no-default-features --features default-hasher hasher::test + - name: Non rand without std + run: cargo check --no-default-features --features default-hasher - name: Install thumb target run: rustup target add thumbv7em-none-eabi diff --git a/Cargo.lock b/Cargo.lock index 12983e3..7dbc895 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,12 +39,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "generator" version = "0.8.8" @@ -83,14 +77,13 @@ name = "hyperloglockless" version = "0.5.0" dependencies = [ "fastrand", - "foldhash", "libm", "loom", "portable-atomic", "rand", + "rapidhash", "serde", "serde_cbor", - "twox-hash", ] [[package]] @@ -210,9 +203,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core", @@ -237,6 +230,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -327,12 +329,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "syn" version = "2.0.114" @@ -402,17 +398,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "twox-hash" -version = "1.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" -dependencies = [ - "cfg-if", - "serde", - "static_assertions", -] - [[package]] name = "unicode-ident" version = "1.0.22" @@ -466,18 +451,18 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index eaa18f5..a0fb457 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "hyperloglockless" version = "0.5.0" edition = "2021" -rust-version = "1.70.0" +rust-version = "1.71.0" authors = ["tomtomwombat"] description = "High-performance HyperLogLog with bias correction and full concurrency support." license = "MIT OR Apache-2.0" @@ -16,21 +16,21 @@ readme = "README.md" maintenance = { status = "actively-developed" } [features] -default = ["std", "rand"] +default = ["std", "default-hasher", "rand"] std = ["portable-atomic/std"] loom = ["dep:loom"] -rand = ["std", "dep:rand"] -serde = ["dep:serde", "twox-hash/serialize", "portable-atomic/serde"] +default-hasher = ["dep:rapidhash"] +rand = ["default-hasher", "std", "dep:rand"] +serde = ["dep:serde", "portable-atomic/serde"] [dependencies] -foldhash = { version = "0.2.0", default-features = false } loom = { version = "0.7.2", optional = true } +rapidhash = { version = "4.5.1", default-features = false, optional = true } rand = { version = "0.9.0", optional = true } serde = { version = "1.0.203", features = ["derive"], optional = true } -twox-hash = { version = "1.6.3", default-features = false } libm = "0.2" portable-atomic = { version = "1.13.1", default-features = false, features = ["fallback"] } [dev-dependencies] fastrand = "2.3.0" -serde_cbor = "0.11.2" \ No newline at end of file +serde_cbor = "0.11.2" diff --git a/README.md b/README.md index 0f568d7..8bf531d 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ let count = hll.count(); // ~27 assert_eq!(hll.len(), 1 << precision); // 16384 bytes ``` -The default hasher is `twox_hash` because of its portability, MSRV, and reasonable speed. However, you may use any hasher: +The default hasher uses rapidhash's `quality` flavor, which is designed for low collision rates in algorithms such as HyperLogLog. However, you may use any hasher: ```rust use hyperloglockless::HyperLogLog; -use foldhash::fast::RandomState; +use std::collections::hash_map::RandomState; let hll = HyperLogLog::with_hasher(14, RandomState::default()); ``` @@ -93,7 +93,8 @@ Below measures and compares the amortized insert performance of `hyperloglockles ## Available Features -- **`rand`** - Enabled by default, this has the `DefaultHasher` source its random state using `thread_rng()` instead of hardware sources. Getting entropy from a user-space source is considerably faster, but requires additional dependencies to achieve this. Disabling this feature by using `default-features = false` makes `DefaultHasher` source its entropy using `foldhash`, which will have a much simpler code footprint at the expense of speed. +- **`default-hasher`** - Enabled by default. Provides Rapidhash's quality hasher as the default type for each sketch and does not require `std`. When enabled without `rand`, it uses Rapidhash's own random state for the default seed. Disabling this feature removes both the `rand` and `rapidhash` dependencies; sketches then require an explicit hasher type and can still use `new` when that hasher implements `Default`. +- **`rand`** - Enabled by default. Implies `default-hasher` and `std`, and uses `rand`'s thread-local RNG to seed the default hasher instead of Rapidhash's random state. - **`serde`** - HyperLogLogs implement `Serialize` and `Deserialize` when possible. - **`loom`** - `AtomicHyperLogLog`s use [loom](https://github.com/tokio-rs/loom) atomics, making it compatible with loom testing. diff --git a/src/hasher.rs b/src/hasher.rs index 5094fb0..4e36692 100644 --- a/src/hasher.rs +++ b/src/hasher.rs @@ -1,135 +1,102 @@ -use core::hash::{BuildHasher, Hasher}; -use twox_hash::XxHash64; +use core::hash::BuildHasher; +use rapidhash::quality::{RapidHasher, SeedableState}; -#[derive(Clone, Debug, Default, Eq, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct CloneBuildHasher { - hasher: H, -} +const RAPID_SECRETS: [u64; 7] = [ + 0x2d35_8dcc_aa6c_78a5, + 0x8bb8_4b93_962e_acc9, + 0x4b33_a62e_857b_191d, + 0x6a9d_1a5b_4f3c_2e17, + 0x1c69_b3f7_4ac4_ae35, + 0x9e37_79b9_7f4a_7c15, + 0x4f1b_bcdd_7a2d_8c13, +]; -impl CloneBuildHasher { - #[allow(dead_code)] - fn new(hasher: H) -> Self { - Self { hasher } - } +/// The default hasher for [`crate::HyperLogLog`] and +/// [`crate::AtomicHyperLogLog`]. +/// +/// It stores Rapidhash's seeded builder state and creates a fresh hasher for +/// each item, allowing the compiler to see that the sponge starts empty. +pub type DefaultHasher = RandomDefaultHasher; + +#[derive(Clone)] +pub struct RandomDefaultHasher { + #[cfg(feature = "serde")] + seed: u64, + state: SeedableState<'static>, } -impl BuildHasher for CloneBuildHasher { - type Hasher = H; - #[inline] - fn build_hasher(&self) -> Self::Hasher { - self.hasher.clone() +impl core::fmt::Debug for RandomDefaultHasher { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RandomDefaultHasher").finish_non_exhaustive() } } -/// The default hasher for [`crate::HyperLogLog`] and -/// [`crate::AtomicHyperLogLog`]. -/// -/// `DefaultHasher` has a faster `build_hasher` than -/// `std::collections::hash_map::RandomState` or `SipHasher13`. -/// This is important because `build_hasher` is called once for every actual -/// hash. -pub type DefaultHasher = CloneBuildHasher; - -impl DefaultHasher { +impl RandomDefaultHasher { + #[inline] pub fn seeded(seed: u64) -> Self { + // Mix the caller's seed before using it as the rapidhash key. Keep the + // secrets fixed so a seeded sketch retains identical hashes after serde + // round-trips, including across process boundaries. + let mixed_seed = SeedableState::custom(seed, &RAPID_SECRETS).hash_one(seed); + let state = SeedableState::custom(mixed_seed, &RAPID_SECRETS); Self { - hasher: RandomDefaultHasher::seeded(seed), + #[cfg(feature = "serde")] + seed, + state, } } } -#[derive(Clone, Debug)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct RandomDefaultHasher(XxHash64); +#[cfg(feature = "serde")] +impl serde::Serialize for RandomDefaultHasher { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_newtype_struct("RandomDefaultHasher", &self.seed) + } +} -impl RandomDefaultHasher { - #[inline] - pub fn seeded(seed: u64) -> Self { - Self(XxHash64::with_seed(seed)) +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RandomDefaultHasher { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let seed = ::deserialize(deserializer)?; + Ok(Self::seeded(seed)) } } impl Default for RandomDefaultHasher { #[inline] fn default() -> Self { - #[cfg(not(feature = "rand"))] - { - use foldhash::fast::RandomState; - let seed = RandomState::default().build_hasher().finish(); - Self::seeded(seed) - } #[cfg(feature = "rand")] - { + let seed = { use rand::Rng; - Self::seeded(rand::rng().random()) - } + rand::rng().random() + }; + + #[cfg(not(feature = "rand"))] + let seed = rapidhash::quality::RandomState::default().hash_one(0x7261_7069_6468_6173_u64); + + Self::seeded(seed) } } -impl Hasher for RandomDefaultHasher { - #[inline] - fn finish(&self) -> u64 { - self.0.finish() - } - #[inline] - fn write(&mut self, bytes: &[u8]) { - self.0.write(bytes) - } - #[inline] - fn write_u8(&mut self, i: u8) { - self.0.write_u8(i) - } - #[inline] - fn write_u16(&mut self, i: u16) { - self.0.write_u16(i) - } - #[inline] - fn write_u32(&mut self, i: u32) { - self.0.write_u32(i) - } - #[inline] - fn write_u64(&mut self, i: u64) { - self.0.write_u64(i) - } - #[inline] - fn write_u128(&mut self, i: u128) { - self.0.write_u128(i) - } - #[inline] - fn write_usize(&mut self, i: usize) { - self.0.write_usize(i) - } - #[inline] - fn write_i8(&mut self, i: i8) { - self.0.write_i8(i) - } - #[inline] - fn write_i16(&mut self, i: i16) { - self.0.write_i16(i) - } - #[inline] - fn write_i32(&mut self, i: i32) { - self.0.write_i32(i) - } - #[inline] - fn write_i64(&mut self, i: i64) { - self.0.write_i64(i) - } - #[inline] - fn write_i128(&mut self, i: i128) { - self.0.write_i128(i) - } - #[inline] - fn write_isize(&mut self, i: isize) { - self.0.write_isize(i) +impl BuildHasher for RandomDefaultHasher { + type Hasher = RapidHasher<'static>; + + #[inline(always)] + fn build_hasher(&self) -> Self::Hasher { + self.state.build_hasher() } } #[cfg(test)] mod test { use crate::hasher::RandomDefaultHasher; - use core::hash::Hasher; + use core::hash::{BuildHasher, Hasher}; fn hash_all(mut x: impl Hasher) -> u64 { x.write(&[1; 16]); @@ -150,8 +117,13 @@ mod test { #[test] fn test_hasher() { - let h1 = RandomDefaultHasher::seeded(0); - let h2 = RandomDefaultHasher::seeded(0); + let h1 = RandomDefaultHasher::seeded(0).build_hasher(); + let h2 = RandomDefaultHasher::seeded(0).build_hasher(); assert_eq!(hash_all(h1), hash_all(h2),); } + + #[test] + fn test_default_hasher() { + let _ = RandomDefaultHasher::default().build_hasher(); + } } diff --git a/src/lib.rs b/src/lib.rs index ce41166..abde2ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ #![cfg_attr(not(feature = "std"), no_std)] extern crate alloc; +#[cfg(test)] +extern crate std; use alloc::{boxed::Box, vec::Vec}; use core::borrow::Borrow; use core::hash::{BuildHasher, Hash}; @@ -22,7 +24,9 @@ mod atomic_f64; use atomic_f64::AtomicF64; mod beta; use beta::beta_horner; +#[cfg(feature = "default-hasher")] mod hasher; +#[cfg(feature = "default-hasher")] pub use hasher::DefaultHasher; mod error; pub use error::Error; @@ -34,61 +38,77 @@ pub use sparse::HyperLogLogPlus; mod buf; mod vint; -/// HyperLogLog is a data structure for the "count-distinct problem", -/// approximating the number of distinct elements in a multiset. -/// -/// # Example -/// ```rust -/// use hyperloglockless::HyperLogLog; -/// -/// let mut hll = HyperLogLog::new(16); -/// hll.insert("42"); -/// hll.insert("🦀"); -/// -/// let count = hll.count(); -/// ``` -#[derive(Debug, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct HyperLogLog { - /// `registers[k]` is the maximum trailing zeros for all 64-bit hashes - /// assigned to kth register - registers: Box<[u8]>, - /// `registers.len() == 1 << precision` - precision: u32, - hasher: S, - zeros: usize, - sum: f64, - correction: f64, - updated_count: bool, +macro_rules! define_hll_type { + ($(#[$meta:meta])* $name:ident, [$($derive:ident),*] { $($fields:tt)* }) => { + #[cfg(feature = "default-hasher")] + $(#[$meta])* + #[derive($($derive),*)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct $name { $($fields)* } + + #[cfg(not(feature = "default-hasher"))] + $(#[$meta])* + #[derive($($derive),*)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct $name { $($fields)* } + }; } -/// HyperLogLog is a data structure for the "count-distinct problem", -/// approximating the number of distinct elements in a multiset. -/// [`AtomicHyperLogLog`] is the thread-safe counterpart of [`HyperLogLog`]. -/// -/// # Example -/// ```rust -/// use hyperloglockless::AtomicHyperLogLog; -/// -/// let hll = AtomicHyperLogLog::new(16); -/// hll.insert("42"); -/// hll.insert("🦀"); -/// -/// let count = hll.count(); -/// ``` -#[derive(Debug)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct AtomicHyperLogLog { - /// `registers[k]` is the maximum trailing zeros for all 64-bit hashes - /// assigned to kth register - registers: Box<[AtomicU8]>, - /// `registers.len() == 1 << precision` - precision: u32, - hasher: S, - zeros: AtomicUsize, - sum: AtomicF64, - correction: f64, - updated_count: AtomicBool, +define_hll_type! { + /// HyperLogLog is a data structure for the "count-distinct problem", + /// approximating the number of distinct elements in a multiset. + /// + /// # Example + /// ```rust + /// use hyperloglockless::HyperLogLog; + /// + /// let mut hll = HyperLogLog::new(16); + /// hll.insert("42"); + /// hll.insert("🦀"); + /// + /// let count = hll.count(); + /// ``` + HyperLogLog, [Debug, Clone] { + /// `registers[k]` is the maximum trailing zeros for all 64-bit hashes + /// assigned to kth register + registers: Box<[u8]>, + /// `registers.len() == 1 << precision` + precision: u32, + hasher: S, + zeros: usize, + sum: f64, + correction: f64, + updated_count: bool, + } +} + +define_hll_type! { + /// HyperLogLog is a data structure for the "count-distinct problem", + /// approximating the number of distinct elements in a multiset. + /// [`AtomicHyperLogLog`] is the thread-safe counterpart to [`HyperLogLog`]. + /// + /// # Example + /// ```rust + /// use hyperloglockless::AtomicHyperLogLog; + /// + /// let hll = AtomicHyperLogLog::new(16); + /// hll.insert("42"); + /// hll.insert("🦀"); + /// + /// let count = hll.count(); + /// ``` + AtomicHyperLogLog, [Debug] { + /// `registers[k]` is the maximum trailing zeros for all 64-bit hashes + /// assigned to kth register + registers: Box<[AtomicU8]>, + /// `registers.len() == 1 << precision` + precision: u32, + hasher: S, + zeros: AtomicUsize, + sum: AtomicF64, + correction: f64, + updated_count: AtomicBool, + } } impl HyperLogLog { @@ -131,20 +151,30 @@ impl AtomicHyperLogLog { macro_rules! impl_hll { ($name:ident, $ismut:literal, $($m:ident)?) => { + #[cfg(feature = "default-hasher")] impl $name { /// Returns a new [`Self`] with `1 << precision` registers (1 byte each) /// using the default hasher with a random seed. - pub fn new(precision: u8) -> $name { + pub fn new(precision: u8) -> $name { $name::with_hasher(precision, DefaultHasher::default()) } /// Returns a new [`Self`] with `1 << precision` registers (1 byte each) /// using the default hasher seeded with `seed`. - pub fn seeded(precision: u8, seed: u64) -> $name { + pub fn seeded(precision: u8, seed: u64) -> $name { $name::with_hasher(precision, DefaultHasher::seeded(seed)) } } + #[cfg(not(feature = "default-hasher"))] + impl $name { + /// Returns a new [`Self`] with `1 << precision` registers (1 byte each) + /// using `S::default()` as its hasher. + pub fn new(precision: u8) -> $name { + $name::with_hasher(precision, S::default()) + } + } + impl $name { /// Returns the number registers in `self`. #[inline(always)] @@ -180,7 +210,7 @@ macro_rules! impl_hll { /// Inserts the item into the HyperLogLog. #[inline] pub fn insert(&$($m)? self, value: &T) { - self.insert_inner::(hash_one(&self.hasher, value)); + self.insert_inner::(self.hasher.hash_one(value)); } /// Inserts the hash of an item into the HyperLogLog. @@ -216,7 +246,7 @@ macro_rules! impl_hll { /// ``` #[inline] pub fn insert_lazy(&$($m)? self, value: &T) { - self.insert_inner::(hash_one(&self.hasher, value)); + self.insert_inner::(self.hasher.hash_one(value)); } /// Inserts the hash of an item into the HyperLogLog without updating the count. @@ -627,17 +657,10 @@ fn correction(count: usize) -> f64 { } } -#[inline(always)] -pub(crate) fn hash_one(hasher: &S, value: &T) -> u64 { - use core::hash::Hasher; - let mut h = hasher.build_hasher(); - value.hash(&mut h); - h.finish() -} - macro_rules! impl_tests { ($modname:ident, $name:ident, $seed:literal) => { #[allow(unused_mut)] + #[cfg(feature = "default-hasher")] #[cfg(not(feature = "loom"))] #[cfg(test)] mod $modname { @@ -813,6 +836,7 @@ impl_tests!(non_atomic_0_seed, HyperLogLog, 0); impl_tests!(atomic, AtomicHyperLogLog, 42); #[cfg(test)] +#[cfg(feature = "default-hasher")] mod other_tests { use super::*; @@ -846,6 +870,7 @@ mod other_tests { #[cfg(not(feature = "loom"))] #[cfg(test)] +#[cfg(feature = "default-hasher")] mod atomic_parity_tests { use super::*; @@ -892,6 +917,7 @@ mod atomic_parity_tests { #[cfg(feature = "loom")] #[cfg(test)] +#[cfg(feature = "default-hasher")] mod loom_tests { use super::*; @@ -932,3 +958,27 @@ mod misc_tests { } } } + +#[cfg(all(test, not(feature = "default-hasher")))] +mod custom_hasher_tests { + use super::*; + use std::collections::hash_map::DefaultHasher as StdDefaultHasher; + use std::hash::BuildHasherDefault; + + type TestBuildHasher = BuildHasherDefault; + + #[test] + fn generic_new_works_without_default_hasher_feature() { + let mut hll = HyperLogLog::::new(4); + hll.extend(0..100); + assert!(hll.count() > 0); + + let hll = AtomicHyperLogLog::::new(4); + hll.extend(0..100); + assert!(hll.count() > 0); + + let mut hll_plus = HyperLogLogPlus::::new(4); + hll_plus.extend(0..100); + assert!(hll_plus.count() > 0); + } +} diff --git a/src/sparse.rs b/src/sparse.rs index 6cdd3d6..94899d2 100644 --- a/src/sparse.rs +++ b/src/sparse.rs @@ -1,9 +1,10 @@ use core::borrow::Borrow; -use core::hash::{BuildHasher, Hash}; +use core::hash::{BuildHasher, Hash, Hasher}; extern crate alloc; use crate::buf::Buf; use crate::error::Error; use crate::vint::VarInt; +#[cfg(feature = "default-hasher")] use crate::DefaultHasher; use crate::HyperLogLog; use alloc::vec::Vec; @@ -28,6 +29,9 @@ fn encode_hash(hash: u64) -> u32 { let tz = hash.trailing_zeros(); match tz { 0..=5 => prefix | ((hash & 0b111111) as u32) << 1, + // The low 7-bit tag value 1 is otherwise unused; reserve it for the + // maximum rank so it does not overlap the encoded register index. + 64 => prefix | 1, _ => prefix | (tz << 1) | 1, } } @@ -35,9 +39,10 @@ fn encode_hash(hash: u64) -> u32 { #[inline] fn decode_hash(hash: u32, precision: u8) -> (u32, usize) { let index = (hash >> (7 + MAX_PRECISION - precision)) as usize; - match hash & 1 { - 0 => (hash.trailing_zeros(), index), - _ => (1 + ((hash & 0b111111) >> 1), index), + match hash & 0b1111111 { + 1 => (65, index), + tag if tag & 1 == 0 => (hash.trailing_zeros(), index), + tag => (1 + (tag >> 1), index), } } @@ -260,18 +265,18 @@ impl SparseLogLog { } } -impl From for HyperLogLog { - fn from(mut sparse: SparseLogLog) -> Self { - let mut hll = HyperLogLog::new(sparse.precision); - if sparse.indexes.size() > 0 { - sparse.flush(); - for encoded in sparse.indexes.into_iter() { - let (rank, register) = decode_hash(encoded, sparse.precision); +impl SparseLogLog { + fn into_dense(mut self) -> HyperLogLog { + let mut hll = HyperLogLog::with_hasher(self.precision, DummyBuildHasher); + if self.indexes.size() > 0 { + self.flush(); + for encoded in self.indexes.into_iter() { + let (rank, register) = decode_hash(encoded, self.precision); hll.update::(rank as u8, register); } } else { - for encoded in sparse.new.into_iter() { - let (rank, register) = decode_hash(encoded, sparse.precision); + for encoded in self.new.into_iter() { + let (rank, register) = decode_hash(encoded, self.precision); hll.update::(rank as u8, register); } } @@ -279,29 +284,72 @@ impl From for HyperLogLog { } } -/// An implementation of the the [HyperLogLog++](https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/40671.pdf) data structure. -/// -/// For small cardinalities, a "sparse" representation is used. The sparse -/// representation is more accurate and uses less memory, but has slower insert -/// speed. The error and memory usage of the sparse representation scales -/// roughly linearly with the number of items inserted. When the memory of the -/// sparse representation equals the memory of the dense representation, it -/// switches to dense automatically. This happens inside the -/// `insert`/`insert_hash` call (which is why it needs `&mut self`). The error -/// of the sparse representation never exceeds that of the dense. -#[derive(Debug, Clone)] +/// Used only while converting sparse state into registers. The temporary HLL's +/// builder is discarded before the dense HLL is constructed with the real one. +#[derive(Clone, Debug, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct HyperLogLogPlus { - sparse: Option, - dense: Option, - hasher: S, +struct DummyBuildHasher; + +impl BuildHasher for DummyBuildHasher { + type Hasher = DummyHasher; + + fn build_hasher(&self) -> Self::Hasher { + DummyHasher + } } -impl HyperLogLogPlus { +#[derive(Clone, Debug, Default)] +struct DummyHasher; + +impl Hasher for DummyHasher { + fn finish(&self) -> u64 { + panic!("temporary conversion HLL must not hash items") + } + + fn write(&mut self, _bytes: &[u8]) { + panic!("temporary conversion HLL must not hash items") + } +} + +macro_rules! define_hll_plus_type { + ($(#[$meta:meta])* $name:ident { $($fields:tt)* }) => { + #[cfg(feature = "default-hasher")] + $(#[$meta])* + #[derive(Debug, Clone)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct $name { $($fields)* } + + #[cfg(not(feature = "default-hasher"))] + $(#[$meta])* + #[derive(Debug, Clone)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct $name { $($fields)* } + }; +} + +define_hll_plus_type! { + /// An implementation of the [HyperLogLog++](https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/40671.pdf) data structure. + /// + /// For small cardinalities, a "sparse" representation is used. The sparse + /// representation is more accurate and uses less memory, but has slower insert + /// speed. The error and memory usage of the sparse representation scales + /// roughly linearly with the number of items inserted. When the memory of the + /// sparse representation equals the memory of the dense representation, it + /// switches to dense automatically. This happens inside the + /// `insert`/`insert_hash` call (which is why it needs `&mut self`). + HyperLogLogPlus { + sparse: Option, + dense: Option>, + hasher: S, + } +} + +#[cfg(feature = "default-hasher")] +impl HyperLogLogPlus { /// Returns a new [`Self`] using the default hasher with a random seed. /// [`Self`] is initialized to use the compact and dynamically sized sparse /// representation, but later switches to the dense representation when - /// it uses equal memory (`1 << precision` registers (1 byte each)). + /// it uses equal memory (`1 << precision` registers, 1 byte each). pub fn new(precision: u8) -> Self { Self::with_hasher(precision, DefaultHasher::default()) } @@ -315,6 +363,14 @@ impl HyperLogLogPlus { } } +#[cfg(not(feature = "default-hasher"))] +impl HyperLogLogPlus { + /// Returns a new [`Self`] using `S::default()` as its hasher. + pub fn new(precision: u8) -> Self { + Self::with_hasher(precision, S::default()) + } +} + impl HyperLogLogPlus { /// Returns a new [`Self`] using the provided hasher. /// [`Self`] is initialized to use the compact and dynamically sized sparse @@ -355,7 +411,7 @@ impl HyperLogLogPlus { /// dense mode. #[inline(always)] pub fn insert(&mut self, value: &T) { - self.insert_hash(crate::hash_one(&self.hasher, value)); + self.insert_hash(self.hasher.hash_one(value)); } /// Inserts all the items in `iter` into `self`. @@ -371,13 +427,19 @@ impl HyperLogLogPlus { I::Item: Borrow, { let mut iter = iter.into_iter(); - if self.dense.is_none() && !self.insert_all_sparse(&mut iter) { + if let Some(dense) = self.dense.as_mut() { + let hasher = &self.hasher; + dense.insert_all_hash(iter.map(|value| hasher.hash_one(value.borrow()))); + return; + } + if !self.insert_all_sparse(&mut iter) { return; } + let hasher = &self.hasher; self.dense .as_mut() .unwrap() - .insert_all_hash(iter.map(|val| crate::hash_one(&self.hasher, val.borrow()))); + .insert_all_hash(iter.map(|value| hasher.hash_one(value.borrow()))); } #[inline] @@ -387,9 +449,11 @@ impl HyperLogLogPlus { I: Iterator, I::Item: Borrow, { - let sparse = self.sparse.as_mut().unwrap(); + let hasher = &self.hasher; for x in iter.by_ref() { - sparse.insert_hash(crate::hash_one(&self.hasher, x.borrow())); + let hash = hasher.hash_one(x.borrow()); + let sparse = self.sparse.as_mut().unwrap(); + sparse.insert_hash(hash); if sparse.full() { self.swap(); return true; @@ -439,8 +503,7 @@ impl HyperLogLogPlus { #[cold] fn swap(&mut self) { - let s = self.sparse.take().unwrap(); - self.dense = Some(s.into()); + self.dense = Some(self.sparse.take().unwrap().into_dense()); } /// Returns the approximate number of elements in `self`. @@ -504,7 +567,7 @@ impl HyperLogLogPlus { let (rank, register) = decode_hash(*encoded, sparse.precision); dense.update::(rank as u8, register); } - for encoded in sparse.indexes.into_iter() { + for encoded in &sparse.indexes { let (rank, register) = decode_hash(encoded, sparse.precision); dense.update::(rank as u8, register); } @@ -516,7 +579,12 @@ impl HyperLogLogPlus { impl PartialEq for HyperLogLogPlus { fn eq(&self, other: &Self) -> bool { - self.sparse == other.sparse && self.dense == other.dense + let sparse_eq = match (&self.sparse, &other.sparse) { + (Some(a), Some(b)) => a == b, + (None, None) => true, + _ => false, + }; + sparse_eq && self.dense == other.dense } } impl Eq for HyperLogLogPlus {} @@ -549,6 +617,15 @@ mod sparse_tests { } #[test] + fn test_hash_codec_max_rank() { + let precision = 25; + let hash = 0; + let encoded = encode_hash(hash); + assert_eq!(decode_hash(encoded, precision), (65, 0)); + } + + #[test] + #[cfg(feature = "default-hasher")] fn test_conversion() { for seed in 0..=10 { let mut sll = HyperLogLogPlus::seeded(12, seed); @@ -563,10 +640,11 @@ mod sparse_tests { sll.insert(&x); } let after = sll.count(); - assert_eq!(before, after); + // Sparse and dense estimators can differ slightly at promotion. + assert!(before.abs_diff(after) <= 1, "seed={seed}: {before} -> {after}"); assert_eq!(after, hll.count()); assert!(!sll.is_sparse()); - assert_eq!(hll, sll.dense.unwrap()); + assert!(hll.iter().eq(sll.dense.unwrap().iter())); } } @@ -582,6 +660,7 @@ mod sparse_tests { } #[test] + #[cfg(feature = "default-hasher")] fn test_union() { for seed in 0..=100 { let ranges = [(0, 0), (0, 1), (0, 50), (0, 2000), (0, 10000), (100, 1000)]; @@ -618,6 +697,7 @@ mod sparse_tests { } #[test] + #[cfg(feature = "default-hasher")] fn test_insert_all() { for seed in 0..=10 { let mut hll1 = HyperLogLogPlus::seeded(12, seed); @@ -634,6 +714,7 @@ mod sparse_tests { } #[test] + #[cfg(feature = "default-hasher")] fn test_insert_all_uses_configured_hasher_in_dense_mode() { let mut batched = HyperLogLogPlus::seeded(4, 42); let mut repeated = HyperLogLogPlus::seeded(4, 42); @@ -653,6 +734,7 @@ mod sparse_tests { } #[test] + #[cfg(feature = "default-hasher")] fn test_insert_all_hash() { let hashes: Vec = (0u64..10_000) .map(|value| value.wrapping_mul(0x9e37_79b9_7f4a_7c15)) @@ -696,6 +778,7 @@ mod var_int_tests { } #[cfg(feature = "serde")] + #[cfg(feature = "default-hasher")] #[test] fn test_serde_sparse() { for seed in 0..=42 {