From b31b46da74b415fb25ed854fbfdf7ed5ae165d11 Mon Sep 17 00:00:00 2001 From: David Cromberge Date: Tue, 10 Nov 2026 11:08:57 +0000 Subject: [PATCH] perf(theta,tuple): hash a-not-b keys with a seeded folded multiply Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../src/thetafamily/common/a_not_b.rs | 44 ++++++++++++++++++- tests-integration/tests/theta_test/a_not_b.rs | 25 +++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36602987..8a3fc882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All significant changes to this project will be documented in this file. * `BloomFilter::insert` is faster for streams of new items when multiple hashes are configured. * Compressed `CompactThetaSketch` serialization and deserialization are faster. * Tuple sketch serialization and deserialization are faster. +* `ThetaANotB` and `TupleANotB` are faster when either input is unordered. ## v0.6.0 (2026-10-29) diff --git a/datasketches/src/thetafamily/common/a_not_b.rs b/datasketches/src/thetafamily/common/a_not_b.rs index d1caeea5..8bfb41d5 100644 --- a/datasketches/src/thetafamily/common/a_not_b.rs +++ b/datasketches/src/thetafamily/common/a_not_b.rs @@ -16,6 +16,8 @@ // under the License. use std::collections::HashSet; +use std::hash::BuildHasher; +use std::hash::Hasher; use crate::error::Error; use crate::error::ErrorKind; @@ -106,7 +108,8 @@ where } entries } else { - let mut b_keys: HashSet = HashSet::with_capacity(b_num_retained); + let mut b_keys: HashSet = + HashSet::with_capacity_and_hasher(b_num_retained, RetainedHashState::new()); for hash in b.hashes() { if hash < theta { b_keys.insert(hash); @@ -170,3 +173,42 @@ where let out_ordered = ordered || input_ordered || (entries.len() == 1 && theta == MAX_THETA); CompactSketchState::non_empty(entries, theta, seed_hash, out_ordered) } + +struct RetainedHashState(u64); + +impl RetainedHashState { + fn new() -> Self { + Self(std::collections::hash_map::RandomState::new().hash_one(0u64)) + } +} + +impl BuildHasher for RetainedHashState { + type Hasher = RetainedHashHasher; + + fn build_hasher(&self) -> RetainedHashHasher { + RetainedHashHasher { + seed: self.0, + hash: 0, + } + } +} + +struct RetainedHashHasher { + seed: u64, + hash: u64, +} + +impl Hasher for RetainedHashHasher { + fn finish(&self) -> u64 { + self.hash + } + + fn write(&mut self, _bytes: &[u8]) { + unreachable!("retained hashes are hashed as u64"); + } + + fn write_u64(&mut self, hash: u64) { + let product = u128::from(hash ^ self.seed) * 0x9E37_79B9_7F4A_7C15; + self.hash = (product as u64) ^ ((product >> 64) as u64); + } +} diff --git a/tests-integration/tests/theta_test/a_not_b.rs b/tests-integration/tests/theta_test/a_not_b.rs index ff8e64c0..e22d4caf 100644 --- a/tests-integration/tests/theta_test/a_not_b.rs +++ b/tests-integration/tests/theta_test/a_not_b.rs @@ -263,3 +263,28 @@ fn test_estimation_disjoint_returns_a() { assert!(r.is_estimation_mode()); assert_that!(r.estimate(), near(10000.0, 10000.0 * 0.02)); } + +fn unordered_sketch_with_hashes(hashes: &[u64]) -> CompactThetaSketch { + let mut bytes = sketch_with_range(0, hashes.len() as u64) + .compact(false) + .serialize(); + let offset = bytes.len() - hashes.len() * 8; + for (slot, hash) in bytes[offset..].chunks_exact_mut(8).zip(hashes) { + slot.copy_from_slice(&hash.to_le_bytes()); + } + CompactThetaSketch::deserialize(&bytes).unwrap() +} + +#[test] +fn test_unordered_hashes_sharing_low_bits() { + let hash = |k: u64| (k << 32) | 0x5A5A_5A5A; + let a: Vec = (1..=3000).map(hash).collect(); + let b: Vec = (1001..=4000).map(hash).collect(); + let a = unordered_sketch_with_hashes(&a); + let b = unordered_sketch_with_hashes(&b); + assert!(!a.is_ordered() && !b.is_ordered()); + + let r = ThetaANotB::default().compute(&a, &b, true).unwrap(); + let retained: Vec = r.iter().map(|entry| entry.hash()).collect(); + assert_eq!(retained, (1..=1000).map(hash).collect::>()); +}