Skip to content

Commit 5987a3f

Browse files
authored
Merge pull request #147 from github/aneubeck-compact-masked-sort-key
Pack richer compact masked sort keys
2 parents 91fc9a1 + 6d4fd2f commit 5987a3f

6 files changed

Lines changed: 166 additions & 55 deletions

File tree

crates/bpe/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "bpe"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
edition = "2021"
55
description = "Fast byte-pair encoding implementation."
66
repository = "https://github.com/github/rust-gems"

crates/geo_filters/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "geo_filters"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
description = "Geometric filters for set cardinality estimation."
66
repository = "https://github.com/github/rust-gems"

crates/geo_filters/evaluation/masked_sort.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::hint::black_box;
22

33
use criterion::{criterion_group, criterion_main, Criterion};
44
use geo_filters::config::GeoConfig;
5-
use geo_filters::diff_count::{GeoDiffConfig13, GeoDiffConfig7, GeoDiffCount};
5+
use geo_filters::diff_count::{GeoDiffConfig10, GeoDiffConfig13, GeoDiffConfig7, GeoDiffCount};
66
use geo_filters::{Count, Diff};
77
use rand::{Rng, SeedableRng};
88
use rand_chacha::ChaCha8Rng;
@@ -82,6 +82,7 @@ fn bench_config<C: GeoConfig<Diff> + Default>(c: &mut Criterion, name: &str) {
8282

8383
fn criterion_benchmark(c: &mut Criterion) {
8484
bench_config::<GeoDiffConfig7>(c, "geo_diff_count_7");
85+
bench_config::<GeoDiffConfig10>(c, "geo_diff_count_10");
8586
bench_config::<GeoDiffConfig13>(c, "geo_diff_count_13");
8687
}
8788

crates/geo_filters/src/config.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ pub trait GeoConfig<M: Method>: Clone + Eq + Sized {
4242

4343
fn bits_per_level(&self) -> usize;
4444

45+
/// The number of bits required to store positions returned by [`Self::hash_to_bucket`].
46+
///
47+
/// The default uses the full bucket type width. Configurations can override this with a tighter
48+
/// proven width to enable more compact representations of bucket positions.
49+
fn bucket_position_bits(&self) -> u32 {
50+
Self::BucketType::BITS
51+
}
52+
4553
/// The granularity of the geometric buckets.
4654
/// The size of the i-th bucket is determined by the formula:
4755
/// (1 - phi) * phi^i
@@ -119,6 +127,11 @@ impl<
119127
1 << B
120128
}
121129

130+
#[inline]
131+
fn bucket_position_bits(&self) -> u32 {
132+
bucket_position_bits(B)
133+
}
134+
122135
#[inline]
123136
fn phi(&self) -> f32 {
124137
phi(B)
@@ -270,6 +283,11 @@ impl<M: Method, T: IsBucketType + 'static, H: ReproducibleBuildHasher> GeoConfig
270283
1 << self.b
271284
}
272285

286+
#[inline]
287+
fn bucket_position_bits(&self) -> u32 {
288+
bucket_position_bits(self.b)
289+
}
290+
273291
#[inline]
274292
fn phi(&self) -> f32 {
275293
phi(self.b)

crates/geo_filters/src/config/buckets.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,23 +145,25 @@ impl IsBucketType for usize {
145145
}
146146
}
147147

148-
/// Computes the largest bucket index for 64-bit hashes given that (1 << B) bits cover half
149-
/// the hash space.
148+
/// Computes the bits required to store bucket positions for 64-bit hashes given that (1 << B)
149+
/// buckets cover half the hash space.
150150
///
151151
/// (1 << B) buckets cover half the hash space, i.e., buckets [k * (1<<B), (k+1) * (1<<B) cover
152-
/// the hashes with k leading zeros. For a 64-bit hash, this gives us 64 * (1<<B) buckets.
152+
/// the hashes with k leading zeros. The zero hash has 64 leading zeros and maps to the last bucket
153+
/// of the following level, so the inclusive maximum position is 65 * (1<<B) - 1.
153154
#[inline]
154-
pub(crate) fn largest_bucket(b: usize) -> usize {
155-
64 * (1 << b)
155+
pub(crate) fn bucket_position_bits(b: usize) -> u32 {
156+
u32::try_from(b).expect("B must fit in u32") + 7
156157
}
157158

158159
#[inline]
159160
pub(crate) fn assert_bucket_type_large_enough<T: IsBucketType>(b: usize) {
161+
let required_bits = bucket_position_bits(b);
160162
assert!(
161-
largest_bucket(b).ilog2() < T::BITS,
162-
"bucket type has {} bits, which is too small for B = {}, requires bits > {}",
163+
required_bits <= T::BITS,
164+
"bucket type has {} bits, which is too small for B = {}, requires {} bits",
163165
T::BITS,
164166
b,
165-
largest_bucket(b).ilog2()
167+
required_bits
166168
);
167169
}

crates/geo_filters/src/diff_count.rs

Lines changed: 134 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -149,26 +149,28 @@ impl<'a, C: GeoConfig<Diff>> GeoDiffCount<'a, C> {
149149

150150
/// Builds a sort key from the most significant bits of the masked filter.
151151
///
152-
/// The key packs the largest bucket positions of the masked filter into a single `u64`,
153-
/// most significant position first. Because masking distributes over the xor used by
154-
/// [`Self::cmp_masked`], comparing two keys numerically yields the same ordering as
155-
/// [`Self::cmp_masked`] whenever the keys differ. When two keys are equal the ordering is
156-
/// undetermined and the caller must fall back to [`Self::cmp_masked`], e.g.
152+
/// The key packs as many complete bucket positions as fit into a `u64`, most significant
153+
/// position first. Positions use the smallest width that can hold the configuration's
154+
/// bucket positions, as reported by [`GeoConfig::bucket_position_bits`]. Any remaining key bits
155+
/// contain the most-significant portion of the next position. Because masking distributes over
156+
/// the xor used by
157+
/// [`Self::cmp_masked`] and the partial position is an order-preserving prefix, comparing two
158+
/// keys numerically yields the same ordering as [`Self::cmp_masked`] whenever the keys differ.
159+
/// When two keys are equal the ordering is undetermined and the caller must fall back to
160+
/// [`Self::cmp_masked`], e.g.
157161
/// `a_key.cmp(&b_key).then_with(|| a.cmp_masked(b, mask, mask_size))`.
158-
///
159-
/// Each position occupies `C::BucketType::BITS` bits, so the key holds
160-
/// `64 / C::BucketType::BITS` positions (4 for `u16`, 2 for `u32`).
161162
pub fn masked_sort_key(&self, mask: u64, mask_size: usize) -> u64 {
162163
assert!(
163164
(1..u64::BITS as usize).contains(&mask_size),
164165
"mask_size must be in 1..=63 (got {mask_size})"
165166
);
166-
let bits = C::BucketType::BITS;
167-
debug_assert!(
168-
(1..=32).contains(&bits) && u64::BITS % bits == 0,
169-
"sort key packing requires a bucket type of at most 32 bits"
167+
let position_bits = self.config.bucket_position_bits();
168+
assert!(
169+
(1..=u64::BITS).contains(&position_bits),
170+
"bucket position width must be in 1..=64 (got {position_bits})"
170171
);
171-
let per_word = (u64::BITS / bits) as usize;
172+
let complete_positions = (u64::BITS / position_bits) as usize;
173+
let remaining_bits = u64::BITS % position_bits;
172174

173175
// The most significant bits are stored sparsely and sorted from largest to smallest, so we
174176
// can test each of them against the periodic mask directly, avoiding the more expensive
@@ -188,8 +190,21 @@ impl<'a, C: GeoConfig<Diff>> GeoDiffCount<'a, C> {
188190
let mut positions = msb.chain(lsb);
189191

190192
let mut key = 0u64;
191-
for _ in 0..per_word {
192-
key = (key << bits) | positions.next().unwrap_or(0);
193+
for _ in 0..complete_positions {
194+
let position = positions.next().unwrap_or(0);
195+
debug_assert!(
196+
position_bits == u64::BITS || position < 1u64 << position_bits,
197+
"bucket position {position} exceeds configured width {position_bits}"
198+
);
199+
key = (key << position_bits) | position;
200+
}
201+
if remaining_bits > 0 {
202+
let position = positions.next().unwrap_or(0);
203+
debug_assert!(
204+
position_bits == u64::BITS || position < 1u64 << position_bits,
205+
"bucket position {position} exceeds configured width {position_bits}"
206+
);
207+
key = (key << remaining_bits) | (position >> (position_bits - remaining_bits));
193208
}
194209
key
195210
}
@@ -1062,7 +1077,7 @@ mod tests {
10621077

10631078
#[test]
10641079
fn test_masked_sort_key() {
1065-
let masks: &[(u64, usize)] = &[
1080+
let fixed_masks: &[(u64, usize)] = &[
10661081
(0b1, 1), // keeps every bit, i.e. a full comparison
10671082
(0b10, 2), // keeps every other bit
10681083
(0b110, 3), // keeps two out of every three bits
@@ -1071,42 +1086,117 @@ mod tests {
10711086
];
10721087

10731088
fn check<C: GeoConfig<Diff> + Default>(rnd: &mut ChaCha12Rng, masks: &[(u64, usize)]) {
1074-
let mut build = || {
1075-
let mut f = GeoDiffCount::<C>::new(C::default());
1076-
for _ in 0..1000 {
1077-
f.push_hash(rnd.next_u64());
1078-
}
1079-
f
1080-
};
1081-
let a = build();
1082-
let b = build();
1083-
for &(mask, mask_size) in masks {
1084-
let ka = a.masked_sort_key(mask, mask_size);
1085-
let kb = b.masked_sort_key(mask, mask_size);
1086-
let expected = a.cmp_masked(&b, mask, mask_size);
1087-
// The key comparison plus fall back must always agree with the exact comparison.
1088-
assert_eq!(
1089-
ka.cmp(&kb).then_with(|| a.cmp_masked(&b, mask, mask_size)),
1090-
expected,
1091-
"keyed comparison mismatch for mask {mask:b}/{mask_size}",
1092-
);
1093-
// Whenever the keys already differ, they alone must yield the exact order.
1094-
if ka != kb {
1095-
assert_eq!(
1096-
ka.cmp(&kb),
1097-
expected,
1098-
"key ordering mismatch for mask {mask:b}/{mask_size}",
1099-
);
1089+
let filters = (0..8)
1090+
.map(|_| {
1091+
let mut f = GeoDiffCount::<C>::new(C::default());
1092+
let items = 250 + rnd.next_u64() as usize % 1500;
1093+
for _ in 0..items {
1094+
f.push_hash(rnd.next_u64());
1095+
}
1096+
f
1097+
})
1098+
.collect_vec();
1099+
1100+
for a_index in 0..filters.len() {
1101+
for b_index in (a_index + 1)..filters.len() {
1102+
let a = &filters[a_index];
1103+
let b = &filters[b_index];
1104+
for &(mask, mask_size) in masks {
1105+
let ka = a.masked_sort_key(mask, mask_size);
1106+
let kb = b.masked_sort_key(mask, mask_size);
1107+
let expected = a.cmp_masked(b, mask, mask_size);
1108+
// The key comparison plus fall back must always agree with the exact
1109+
// comparison.
1110+
assert_eq!(
1111+
ka.cmp(&kb).then_with(|| a.cmp_masked(b, mask, mask_size)),
1112+
expected,
1113+
"keyed comparison mismatch for mask {mask:b}/{mask_size}",
1114+
);
1115+
// Whenever the keys already differ, they alone must yield the exact order.
1116+
if ka != kb {
1117+
assert_eq!(
1118+
ka.cmp(&kb),
1119+
expected,
1120+
"key ordering mismatch for mask {mask:b}/{mask_size}",
1121+
);
1122+
}
1123+
}
11001124
}
11011125
}
11021126
}
11031127

11041128
prng_test_harness(20, |rnd| {
1105-
check::<GeoDiffConfig7>(rnd, masks);
1106-
check::<GeoDiffConfig13>(rnd, masks);
1129+
let mut masks = fixed_masks.to_vec();
1130+
for _ in 0..12 {
1131+
let mask_size = 1 + rnd.next_u64() as usize % 63;
1132+
let mask = (rnd.next_u64() & (u64::MAX >> (64 - mask_size))) | 1;
1133+
masks.push((mask, mask_size));
1134+
}
1135+
check::<GeoDiffConfig7>(rnd, &masks);
1136+
check::<GeoDiffConfig10>(rnd, &masks);
1137+
check::<GeoDiffConfig13>(rnd, &masks);
11071138
});
11081139
}
11091140

1141+
#[test]
1142+
fn test_masked_sort_key_packing_boundaries() {
1143+
fn check<C: GeoConfig<Diff> + Default>(
1144+
expected_bits: u32,
1145+
expected_complete: usize,
1146+
expected_remaining: u32,
1147+
) {
1148+
let config = C::default();
1149+
let max_position = 65 * config.bits_per_level() - 1;
1150+
let position_bits = config.bucket_position_bits();
1151+
assert_eq!(position_bits, expected_bits);
1152+
assert_eq!((u64::BITS / position_bits) as usize, expected_complete);
1153+
assert_eq!(u64::BITS % position_bits, expected_remaining);
1154+
1155+
let positions = (0..expected_complete + 2)
1156+
.map(|offset| C::BucketType::from_usize(max_position - offset))
1157+
.collect_vec();
1158+
let filter = GeoDiffCount::<C>::from_ones(positions.iter().copied());
1159+
let actual = filter.masked_sort_key(1, 1);
1160+
1161+
let mut expected = 0;
1162+
for &position in positions.iter().take(expected_complete) {
1163+
expected = (expected << position_bits) | position.into_usize() as u64;
1164+
}
1165+
if expected_remaining > 0 {
1166+
expected = (expected << expected_remaining)
1167+
| (positions[expected_complete].into_usize() as u64
1168+
>> (position_bits - expected_remaining));
1169+
}
1170+
assert_eq!(actual, expected);
1171+
1172+
let common = positions[..expected_complete].iter().copied();
1173+
let prefix_step = 1usize << (position_bits - expected_remaining);
1174+
let lower = C::BucketType::from_usize(prefix_step - 1);
1175+
let higher = C::BucketType::from_usize(prefix_step);
1176+
let lower_filter = GeoDiffCount::<C>::from_ones(common.clone().chain([lower]));
1177+
let higher_filter = GeoDiffCount::<C>::from_ones(common.chain([higher]));
1178+
let lower_key = lower_filter.masked_sort_key(1, 1);
1179+
let higher_key = higher_filter.masked_sort_key(1, 1);
1180+
assert_eq!(lower_key.cmp(&higher_key), Ordering::Less);
1181+
assert_eq!(
1182+
lower_key.cmp(&higher_key),
1183+
lower_filter.cmp_masked(&higher_filter, 1, 1)
1184+
);
1185+
}
1186+
1187+
check::<GeoDiffConfig7>(14, 4, 8);
1188+
check::<GeoDiffConfig10>(17, 3, 13);
1189+
check::<GeoDiffConfig13>(20, 3, 4);
1190+
1191+
let empty = GeoDiffCount10::default();
1192+
let bucket_zero = GeoDiffCount10::from_ones([0]);
1193+
assert_eq!(
1194+
empty.masked_sort_key(1, 1),
1195+
bucket_zero.masked_sort_key(1, 1)
1196+
);
1197+
assert_eq!(empty.cmp_masked(&bucket_zero, 1, 1), Ordering::Less);
1198+
}
1199+
11101200
#[test]
11111201
fn test_bit_chunks() {
11121202
prng_test_harness(100, |rnd| {

0 commit comments

Comments
 (0)