From 4426de98ff1960d5cbe2a270081b3d95d06ccee9 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 7 Oct 2025 19:25:12 +0200 Subject: [PATCH 01/45] most basic elias fano --- crates/pef/Cargo.toml | 17 +++++ crates/pef/src/lib.rs | 169 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 crates/pef/Cargo.toml create mode 100644 crates/pef/src/lib.rs diff --git a/crates/pef/Cargo.toml b/crates/pef/Cargo.toml new file mode 100644 index 00000000..43769057 --- /dev/null +++ b/crates/pef/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pef" +version = "0.1.1" +edition = "2021" +description = "Partitioned Elias Fano Variation." +repository = "https://github.com/github/rust-gems" +license = "MIT" +keywords = ["tokenizer", "algorithm", "encoding", "bpe"] +categories = ["algorithms", "data-structures", "encoding", "science"] + +[lib] +crate-type = ["lib", "staticlib"] +bench = false + +[dependencies] +itertools = "0.14" +rand = { version = "0.9", optional = true } diff --git a/crates/pef/src/lib.rs b/crates/pef/src/lib.rs new file mode 100644 index 00000000..37166c52 --- /dev/null +++ b/crates/pef/src/lib.rs @@ -0,0 +1,169 @@ +struct BitStream { + data: Vec, + bit_pos: u32, + curr: u64, +} + +impl BitStream { + fn new() -> Self { + Self { + data: vec![], + bit_pos: 0, + curr: 0, + } + } + + fn push(&mut self, bits: u32, num_bits: u32) { + self.curr |= (bits as u64) << (self.bit_pos % 32); + if (self.bit_pos % 32) + num_bits >= 32 { + self.data.push(self.curr as u32); + self.curr >>= 32; + } + self.bit_pos += num_bits; + } + + fn finish(mut self) -> (Vec, u32) { + if self.bit_pos % 32 != 0 { + self.data.push(self.curr as u32); + } + if self.data.len() % 2 != 0 { + self.data.push(0); + } + let (head, _) = self.data.as_slice().as_chunks::<2>(); + let data = head + .iter() + .map(|[a, b]| (*b as u64) << 32 | *a as u64) + .collect::>(); + (data, self.bit_pos) + } +} + +struct EliasFano { + bits_per_value: u32, + high_bits: Vec, + low_bits: Vec, +} + +impl EliasFano { + fn new(iter: impl Iterator, max: u32, len: usize) -> Self { + let bits_per_value = optimal_bits_per_value(max, len as u32); + let mut high_bits = BitStream::new(); + let mut low_bits = BitStream::new(); + let mut bucket_id = 0; + for value in iter { + assert!(value < max); + let high = value >> bits_per_value; + let low = value & !(!0 << bits_per_value); + while high > bucket_id { + high_bits.push(0, 1); + bucket_id += 1; + } + println!("{value} {bucket_id} {high} {low} {bits_per_value}"); + high_bits.push(1, 1); + low_bits.push(low, bits_per_value); + } + high_bits.push(0, 1); + EliasFano { + bits_per_value, + high_bits: high_bits.finish().0, + low_bits: low_bits.finish().0, + } + } + + fn iter(&self) -> EliasFanoDecoder<'_> { + EliasFanoDecoder { + bits_per_value: self.bits_per_value, + high_bits: BitReader::new(&self.high_bits), + low_bits: BitReader::new(&self.low_bits), + bucket_id: 0, + bit_pos: 0, + } + } +} + +struct EliasFanoDecoder<'a> { + bits_per_value: u32, + high_bits: BitReader<'a>, + low_bits: BitReader<'a>, + bucket_id: u32, + bit_pos: u32, +} + +struct BitReader<'a> { + data: &'a [u64], +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u64]) -> Self { + Self { data } + } + + fn get_bit(&self, index: u32) -> bool { + self.data[index as usize / 64] & (1 << (index % 64)) != 0 + } + + fn get_bits(&self, index: u32, num_bits: u32) -> u32 { + let start = index / 64; + let end = (index + num_bits) / 64; + let mut temp = self.data[start as usize] >> (index % 64); + if start != end { + temp |= self.data[(start + 1) as usize] << (64 - (index % 64)); + } + (temp as u32) & !(!0 << num_bits) + } + + fn len(&self) -> u32 { + (self.data.len() * 64) as u32 + } +} + +impl<'a> Iterator for EliasFanoDecoder<'a> { + type Item = u32; + + fn next(&mut self) -> Option { + loop { + if self.bit_pos >= self.high_bits.len() { + return None; + } + if self.high_bits.get_bit(self.bit_pos) { + break; + } + self.bucket_id += 1; + self.bit_pos += 1; + } + let value_idx = self.bit_pos - self.bucket_id; + let value = self.low_bits.get_bits(value_idx, self.bits_per_value); + self.bit_pos += 1; + Some(value | (self.bucket_id << self.bits_per_value)) + } +} + +fn optimal_bits_per_value(max: u32, len: u32) -> u32 { + let mut best_cost = (u32::MAX, 0); + for bits in 0..32 { + let cost = bits * len as u32 + ((max >> bits) + 1) + len; + if cost < best_cost.0 { + best_cost = (cost, bits); + } + } + best_cost.1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_elias_fano() { + let data = vec![1, 2, 5, 13, 15]; + let max = 16; + let len = data.len(); + let elias_fano = EliasFano::new(data.iter().copied(), max, len); + println!("{:b}", elias_fano.high_bits[0]); + println!("{:b}", elias_fano.low_bits[0]); + let mut decoder = elias_fano.iter(); + for value in data { + assert_eq!(decoder.next(), Some(value)); + } + } +} From 9bb6a3c06dde04b3162a1c95bb24f4f6199d259d Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 20 Oct 2025 11:20:52 +0200 Subject: [PATCH 02/45] simple operation --- crates/pef/src/lib.rs | 247 ++++++++++++++++++++++++++++++++---------- 1 file changed, 190 insertions(+), 57 deletions(-) diff --git a/crates/pef/src/lib.rs b/crates/pef/src/lib.rs index 37166c52..b164ffa3 100644 --- a/crates/pef/src/lib.rs +++ b/crates/pef/src/lib.rs @@ -1,40 +1,29 @@ struct BitStream { - data: Vec, - bit_pos: u32, - curr: u64, + data: Vec, } impl BitStream { - fn new() -> Self { + fn new(bits: u32) -> Self { Self { - data: vec![], - bit_pos: 0, - curr: 0, + data: vec![0; ((bits + 63) / 64) as usize], } } - fn push(&mut self, bits: u32, num_bits: u32) { - self.curr |= (bits as u64) << (self.bit_pos % 32); - if (self.bit_pos % 32) + num_bits >= 32 { - self.data.push(self.curr as u32); - self.curr >>= 32; - } - self.bit_pos += num_bits; + fn set_bit(&mut self, index: u32) { + self.data[index as usize / 64] |= 1 << (index % 64); } - fn finish(mut self) -> (Vec, u32) { - if self.bit_pos % 32 != 0 { - self.data.push(self.curr as u32); - } - if self.data.len() % 2 != 0 { - self.data.push(0); + fn set_bits(&mut self, index: u32, value: u32, bits: u32) { + let start = index / 64; + let end = (index + bits - 1) / 64; + self.data[start as usize] |= (value as u64) << (index % 64); + if start != end { + self.data[(start + 1) as usize] |= (value >> (64 - (index % 64))) as u64; } - let (head, _) = self.data.as_slice().as_chunks::<2>(); - let data = head - .iter() - .map(|[a, b]| (*b as u64) << 32 | *a as u64) - .collect::>(); - (data, self.bit_pos) + } + + fn finish(self) -> Vec { + self.data } } @@ -45,37 +34,37 @@ struct EliasFano { } impl EliasFano { - fn new(iter: impl Iterator, max: u32, len: usize) -> Self { - let bits_per_value = optimal_bits_per_value(max, len as u32); - let mut high_bits = BitStream::new(); - let mut low_bits = BitStream::new(); - let mut bucket_id = 0; - for value in iter { + fn new(iter: impl Iterator, max: u32, len: u32) -> Self { + let (_bits, bits_per_value) = optimal_bits_per_value(max, len as u32); + // TODO: we want to get the buffers as input... + // In particular in the partitioned case, we somehow want all the + // high bits in one stream and all the low bits in another stream. + // The question is how to store the starting positions of each partition + // most efficiently... + let mut high_bits = BitStream::new(((max >> bits_per_value) + 1) + len); + let mut low_bits = BitStream::new(bits_per_value * len); + for (i, value) in iter.enumerate() { assert!(value < max); let high = value >> bits_per_value; let low = value & !(!0 << bits_per_value); - while high > bucket_id { - high_bits.push(0, 1); - bucket_id += 1; - } - println!("{value} {bucket_id} {high} {low} {bits_per_value}"); - high_bits.push(1, 1); - low_bits.push(low, bits_per_value); + high_bits.set_bit(high + i as u32); + low_bits.set_bits(i as u32, low, bits_per_value); } - high_bits.push(0, 1); EliasFano { bits_per_value, - high_bits: high_bits.finish().0, - low_bits: low_bits.finish().0, + high_bits: high_bits.finish(), + low_bits: low_bits.finish(), } } fn iter(&self) -> EliasFanoDecoder<'_> { + let high_bits = BitReader::new(&self.high_bits); EliasFanoDecoder { + current_word: high_bits.get_word(0), bits_per_value: self.bits_per_value, - high_bits: BitReader::new(&self.high_bits), + high_bits, low_bits: BitReader::new(&self.low_bits), - bucket_id: 0, + value_idx: 0, bit_pos: 0, } } @@ -85,8 +74,9 @@ struct EliasFanoDecoder<'a> { bits_per_value: u32, high_bits: BitReader<'a>, low_bits: BitReader<'a>, - bucket_id: u32, + value_idx: u32, bit_pos: u32, + current_word: u64, } struct BitReader<'a> { @@ -102,6 +92,10 @@ impl<'a> BitReader<'a> { self.data[index as usize / 64] & (1 << (index % 64)) != 0 } + fn get_word(&self, index: u32) -> u64 { + self.data[index as usize] + } + fn get_bits(&self, index: u32, num_bits: u32) -> u32 { let start = index / 64; let end = (index + num_bits) / 64; @@ -120,25 +114,145 @@ impl<'a> BitReader<'a> { impl<'a> Iterator for EliasFanoDecoder<'a> { type Item = u32; + // Decoding 32 values in a row could be done more efficient by + // a) expanding the low bits into a full words. + // b) using masked SIMD operations to process multiple high bits in parallel + // but masking those with 0 bits. + // c) adding the two results together. + // Note: this is VERY similar to how bit packing with exceptions works. + // The main difference is that we can use the high bits only to skip forward! + // Advantages of EF are that: + // 1. we don't need to store deltas explicitly, i.e. + // no need for partial sum computations as final step. + // 2. we can skip ahead and decode individual values only. + // 3. NEW: we can intersect quickly purely based on high bits. + // 4. Due to 2, EF leads to a compact two-layer representation, while + // preserving skip list functionality. + // - start value of each block (<- EF) + // (encode number of low bits in start value with trailing ones!) + // - start bit/byte of high bits (<- EF) + // [- start bit/byte of low bits (<- EF)] + // (these could also be stored in reverse order ending at next partition) + // [- counting ones in high bit representation] + // (succinct; only needed for interval encoding) + // 5. NEW: EF can be used for interval encodings, where it is + // important to know the index of each value. + // 6. The encoding size is fully deterministic and can be computed with + // simple bit operations. + // So, in total, the EF encoding is much more flexible than bin-packing. fn next(&mut self) -> Option { + let mut current_word = self.current_word; + let mut bit_pos = self.bit_pos; loop { - if self.bit_pos >= self.high_bits.len() { + let zeros = current_word.trailing_zeros(); + if zeros < 64 { + self.current_word = current_word >> (zeros + 1); + let bucket_id = bit_pos + zeros - self.value_idx; + let value = self.low_bits.get_bits(self.value_idx, self.bits_per_value); + self.bit_pos = bit_pos + zeros + 1; + self.value_idx += 1; + return Some(value | (bucket_id << self.bits_per_value)); + } else if bit_pos + 64 >= self.high_bits.len() { return None; + } else { + current_word = self.high_bits.get_word(bit_pos / 64 + 1); + bit_pos = (bit_pos / 64 + 1) * 64; } - if self.high_bits.get_bit(self.bit_pos) { - break; + } + } +} + +struct IntersectingIterator<'a, I> { + iter: I, + + bits_per_value: u32, + high_bits: BitReader<'a>, + low_bits: BitReader<'a>, + value_idx: u32, + bit_pos: u32, + current_word: u64, +} + +impl<'a, I: Iterator> Iterator for IntersectingIterator<'a, I> { + type Item = u32; + + fn next(&mut self) -> Option { + let mut current_word = self.current_word; + let mut bit_pos = self.bit_pos; + while let Some(next_value) = self.iter.next() { + loop { + let zeros = current_word.trailing_zeros(); + if zeros < 64 { + self.current_word = current_word >> (zeros + 1); + let bucket_id = bit_pos + zeros - self.value_idx; + // FIXME: subtract value_idx from values everywhere! + let lower_bound = (bucket_id << self.bits_per_value); // + self.value_idx; + let upper_bound = lower_bound + (1 << self.bits_per_value); + bit_pos += zeros + 1; + self.value_idx += 1; + if next_value >= upper_bound { + // keep decoding + } else if next_value >= lower_bound { + // potential match + let value = self + .low_bits + .get_bits(self.value_idx - 1, self.bits_per_value); + let value = value | (bucket_id << self.bits_per_value); + if value == next_value { + // found it! + self.bit_pos = bit_pos; + return Some(value); + } + } else { + // not found. go to next value. + break; + } + } else if bit_pos + 64 >= self.high_bits.len() { + return None; + } else { + current_word = self.high_bits.get_word(bit_pos / 64 + 1); + bit_pos = (bit_pos / 64 + 1) * 64; + } } - self.bucket_id += 1; - self.bit_pos += 1; } - let value_idx = self.bit_pos - self.bucket_id; - let value = self.low_bits.get_bits(value_idx, self.bits_per_value); - self.bit_pos += 1; - Some(value | (self.bucket_id << self.bits_per_value)) + None } } -fn optimal_bits_per_value(max: u32, len: u32) -> u32 { +// TODO: Implement an iterator which only retrieves the high bit buckets. +// Such an iterator can be used to approximate an intersection! +// This way, one can potentially erase parts before running a second pass +// where the low values are being decoded... +// When does this work? +// Usually, one would start off the intersection with the shortest posting list. +// This posting list has the coarsest high mask granularity, i.e. for a random +// sequence, 40% of the buckets will have a value. +// If the second posting list is also random and at the same level, then +// we get 40% * 40% = 16% of the buckets remaining. +// If the second posting list is at a higher level, i.e. more dense, then +// the fill level would be roughly 63% and the intersection would be 40% * 63% = 25%. +// For another level difference, the fill level increases to 86% ==> 34% remaining. +// I.e. for more than 2 levels difference there is no benefit of this approach! +// The fill rate can be reduced by spending more bits per value. +// For example moving one level down, increases the number of bits from: +// n * log(u/n) + 2n to n * log(u/n) + 4n. + +// TODO: Implement an interval based iterator where the even indices represent +// the start of an interval and the odd indices represent the end. +// In this case, the intersection of two iterators cannot be represented as a +// simple bitmask unfortunately. Instead, one has to serialize the new +// set. +// If however one of the two sets is represented as points, then the masking +// works again... One has to decide however, whether too many points would be +// filtered out and therefore the bitmask would become larger than the encoded +// representation. + +// TODO: Implement an intersecting iterator which masks off values from the first +// iterator as we go. + +// TODO: Implement test presence of a value. This requires the rank/select stuff... +// +fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { let mut best_cost = (u32::MAX, 0); for bits in 0..32 { let cost = bits * len as u32 + ((max >> bits) + 1) + len; @@ -146,7 +260,7 @@ fn optimal_bits_per_value(max: u32, len: u32) -> u32 { best_cost = (cost, bits); } } - best_cost.1 + best_cost } #[cfg(test)] @@ -158,7 +272,7 @@ mod tests { let data = vec![1, 2, 5, 13, 15]; let max = 16; let len = data.len(); - let elias_fano = EliasFano::new(data.iter().copied(), max, len); + let elias_fano = EliasFano::new(data.iter().copied(), max, len as u32); println!("{:b}", elias_fano.high_bits[0]); println!("{:b}", elias_fano.low_bits[0]); let mut decoder = elias_fano.iter(); @@ -166,4 +280,23 @@ mod tests { assert_eq!(decoder.next(), Some(value)); } } + + #[test] + fn test_optimal_bits() { + for i in 1..100 { + for max in i..300 { + let max = max - i; + let (cost, bits) = optimal_bits_per_value(max, i); + let b_f32 = (max as f32 / i as f32).log2(); + let b = (max / i).max(1).ilog2(); + let fast_cost = b * i + max / (1 << b) + i + 1; + if fast_cost != cost { + println!( + "{i} cost: {}, bits: {} {b_f32} {b} {}", + cost, bits, fast_cost + ); + } + } + } + } } From 4c70132e58a58caafb2ca6ee257c2515e9e72751 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 09:43:09 +0100 Subject: [PATCH 03/45] benchmark + separate file --- crates/pef/Cargo.toml | 10 +- crates/pef/benches/elias_fano_bench.rs | 46 ++++ crates/pef/src/elias_fano.rs | 302 ++++++++++++++++++++++++ crates/pef/src/lib.rs | 303 +------------------------ 4 files changed, 359 insertions(+), 302 deletions(-) create mode 100644 crates/pef/benches/elias_fano_bench.rs create mode 100644 crates/pef/src/elias_fano.rs diff --git a/crates/pef/Cargo.toml b/crates/pef/Cargo.toml index 43769057..36076021 100644 --- a/crates/pef/Cargo.toml +++ b/crates/pef/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pef" -version = "0.1.1" +version = "0.1.0" edition = "2021" description = "Partitioned Elias Fano Variation." repository = "https://github.com/github/rust-gems" @@ -15,3 +15,11 @@ bench = false [dependencies] itertools = "0.14" rand = { version = "0.9", optional = true } + +[dev-dependencies] +criterion = "0.5" +rand = "0.9" + +[[bench]] +name = "elias_fano_bench" +harness = false diff --git a/crates/pef/benches/elias_fano_bench.rs b/crates/pef/benches/elias_fano_bench.rs new file mode 100644 index 00000000..4b9e71e9 --- /dev/null +++ b/crates/pef/benches/elias_fano_bench.rs @@ -0,0 +1,46 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use pef::EliasFano; +use rand::prelude::*; + +fn criterion_benchmark(c: &mut Criterion) { + let size = 10_000_000; + + // Generate random sorted data with clustered gaps using a Markov chain + let mut data = Vec::with_capacity(size as usize); + let mut current = 0; + let mut rng = StdRng::seed_from_u64(123456789); + + // State 0: Dense cluster (small gaps, mostly 1) + // State 1: Sparse region (larger gaps) + let mut state = 0; + + for _ in 0..size { + let gap = if state == 0 { + if rng.random_bool(0.1) { state = 1; } // Transition to sparse + if rng.random_bool(0.9) { 1 } else { (rng.random::() % 5) + 1 } + } else { + if rng.random_bool(0.1) { state = 0; } // Transition to dense + (rng.random::() % 100) + 1 + }; + + current += gap; + data.push(current); + } + let max = current + 1; + + let elias_fano = EliasFano::new(data.iter().copied(), max, size); + + let mut group = c.benchmark_group("elias_fano"); + group.throughput(Throughput::Elements(size as u64)); + group.bench_function("iter", |b| { + b.iter(|| { + for val in elias_fano.iter() { + black_box(val); + } + }) + }); + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs new file mode 100644 index 00000000..e66524ac --- /dev/null +++ b/crates/pef/src/elias_fano.rs @@ -0,0 +1,302 @@ +struct BitStream { + data: Vec, +} + +impl BitStream { + fn new(bits: u32) -> Self { + Self { + data: vec![0; ((bits + 63) / 64) as usize], + } + } + + fn set_bit(&mut self, index: u32) { + self.data[index as usize / 64] |= 1 << (index % 64); + } + + fn set_bits(&mut self, index: u32, value: u32, bits: u32) { + let start = index / 64; + let end = (index + bits - 1) / 64; + self.data[start as usize] |= (value as u64) << (index % 64); + if start != end { + self.data[(start + 1) as usize] |= (value >> (64 - (index % 64))) as u64; + } + } + + fn finish(self) -> Vec { + self.data + } +} + +pub struct EliasFano { + bits_per_value: u32, + high_bits: Vec, + low_bits: Vec, +} + +impl EliasFano { + pub fn new(iter: impl Iterator, max: u32, len: u32) -> Self { + let (_bits, bits_per_value) = optimal_bits_per_value(max, len as u32); + // TODO: we want to get the buffers as input... + // In particular in the partitioned case, we somehow want all the + // high bits in one stream and all the low bits in another stream. + // The question is how to store the starting positions of each partition + // most efficiently... + let mut high_bits = BitStream::new(((max >> bits_per_value) + 1) + len); + let mut low_bits = BitStream::new(bits_per_value * len); + for (i, value) in iter.enumerate() { + assert!(value < max); + let high = value >> bits_per_value; + let low = value & !(!0 << bits_per_value); + high_bits.set_bit(high + i as u32); + low_bits.set_bits(i as u32, low, bits_per_value); + } + EliasFano { + bits_per_value, + high_bits: high_bits.finish(), + low_bits: low_bits.finish(), + } + } + + pub fn iter(&self) -> EliasFanoDecoder<'_> { + let high_bits = BitReader::new(&self.high_bits); + EliasFanoDecoder { + current_word: high_bits.get_word(0), + bits_per_value: self.bits_per_value, + high_bits, + low_bits: BitReader::new(&self.low_bits), + value_idx: 0, + bit_pos: 0, + } + } +} + +pub struct EliasFanoDecoder<'a> { + bits_per_value: u32, + high_bits: BitReader<'a>, + low_bits: BitReader<'a>, + value_idx: u32, + bit_pos: u32, + current_word: u64, +} + +struct BitReader<'a> { + data: &'a [u64], +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u64]) -> Self { + Self { data } + } + + fn get_bit(&self, index: u32) -> bool { + self.data[index as usize / 64] & (1 << (index % 64)) != 0 + } + + fn get_word(&self, index: u32) -> u64 { + self.data[index as usize] + } + + fn get_bits(&self, index: u32, num_bits: u32) -> u32 { + let start = index / 64; + let end = (index + num_bits) / 64; + let mut temp = self.data[start as usize] >> (index % 64); + if start != end { + temp |= self.data[(start + 1) as usize] << (64 - (index % 64)); + } + (temp as u32) & !(!0 << num_bits) + } + + fn len(&self) -> u32 { + (self.data.len() * 64) as u32 + } +} + +impl<'a> Iterator for EliasFanoDecoder<'a> { + type Item = u32; + + // Decoding 32 values in a row could be done more efficient by + // a) expanding the low bits into a full words. + // b) using masked SIMD operations to process multiple high bits in parallel + // but masking those with 0 bits. + // c) adding the two results together. + // Note: this is VERY similar to how bit packing with exceptions works. + // The main difference is that we can use the high bits only to skip forward! + // Advantages of EF are that: + // 1. we don't need to store deltas explicitly, i.e. + // no need for partial sum computations as final step. + // 2. we can skip ahead and decode individual values only. + // 3. NEW: we can intersect quickly purely based on high bits. + // 4. Due to 2, EF leads to a compact two-layer representation, while + // preserving skip list functionality. + // - start value of each block (<- EF) + // (encode number of low bits in start value with trailing ones!) + // - start bit/byte of high bits (<- EF) + // [- start bit/byte of low bits (<- EF)] + // (these could also be stored in reverse order ending at next partition) + // [- counting ones in high bit representation] + // (succinct; only needed for interval encoding) + // 5. NEW: EF can be used for interval encodings, where it is + // important to know the index of each value. + // 6. The encoding size is fully deterministic and can be computed with + // simple bit operations. + // So, in total, the EF encoding is much more flexible than bin-packing. + fn next(&mut self) -> Option { + let mut current_word = self.current_word; + let mut bit_pos = self.bit_pos; + loop { + let zeros = current_word.trailing_zeros(); + if zeros < 64 { + self.current_word = current_word >> (zeros + 1); + let bucket_id = bit_pos + zeros - self.value_idx; + let value = self.low_bits.get_bits(self.value_idx, self.bits_per_value); + self.bit_pos = bit_pos + zeros + 1; + self.value_idx += 1; + return Some(value | (bucket_id << self.bits_per_value)); + } else if bit_pos + 64 >= self.high_bits.len() { + return None; + } else { + current_word = self.high_bits.get_word(bit_pos / 64 + 1); + bit_pos = (bit_pos / 64 + 1) * 64; + } + } + } +} + +struct IntersectingIterator<'a, I> { + iter: I, + + bits_per_value: u32, + high_bits: BitReader<'a>, + low_bits: BitReader<'a>, + value_idx: u32, + bit_pos: u32, + current_word: u64, +} + +impl<'a, I: Iterator> Iterator for IntersectingIterator<'a, I> { + type Item = u32; + + fn next(&mut self) -> Option { + let mut current_word = self.current_word; + let mut bit_pos = self.bit_pos; + while let Some(next_value) = self.iter.next() { + loop { + let zeros = current_word.trailing_zeros(); + if zeros < 64 { + self.current_word = current_word >> (zeros + 1); + let bucket_id = bit_pos + zeros - self.value_idx; + // FIXME: subtract value_idx from values everywhere! + let lower_bound = (bucket_id << self.bits_per_value); // + self.value_idx; + let upper_bound = lower_bound + (1 << self.bits_per_value); + bit_pos += zeros + 1; + self.value_idx += 1; + if next_value >= upper_bound { + // keep decoding + } else if next_value >= lower_bound { + // potential match + let value = self + .low_bits + .get_bits(self.value_idx - 1, self.bits_per_value); + let value = value | (bucket_id << self.bits_per_value); + if value == next_value { + // found it! + self.bit_pos = bit_pos; + return Some(value); + } + } else { + // not found. go to next value. + break; + } + } else if bit_pos + 64 >= self.high_bits.len() { + return None; + } else { + current_word = self.high_bits.get_word(bit_pos / 64 + 1); + bit_pos = (bit_pos / 64 + 1) * 64; + } + } + } + None + } +} + +// TODO: Implement an iterator which only retrieves the high bit buckets. +// Such an iterator can be used to approximate an intersection! +// This way, one can potentially erase parts before running a second pass +// where the low values are being decoded... +// When does this work? +// Usually, one would start off the intersection with the shortest posting list. +// This posting list has the coarsest high mask granularity, i.e. for a random +// sequence, 40% of the buckets will have a value. +// If the second posting list is also random and at the same level, then +// we get 40% * 40% = 16% of the buckets remaining. +// If the second posting list is at a higher level, i.e. more dense, then +// the fill level would be roughly 63% and the intersection would be 40% * 63% = 25%. +// For another level difference, the fill level increases to 86% ==> 34% remaining. +// I.e. for more than 2 levels difference there is no benefit of this approach! +// The fill rate can be reduced by spending more bits per value. +// For example moving one level down, increases the number of bits from: +// n * log(u/n) + 2n to n * log(u/n) + 4n. + +// TODO: Implement an interval based iterator where the even indices represent +// the start of an interval and the odd indices represent the end. +// In this case, the intersection of two iterators cannot be represented as a +// simple bitmask unfortunately. Instead, one has to serialize the new +// set. +// If however one of the two sets is represented as points, then the masking +// works again... One has to decide however, whether too many points would be +// filtered out and therefore the bitmask would become larger than the encoded +// representation. + +// TODO: Implement an intersecting iterator which masks off values from the first +// iterator as we go. + +// TODO: Implement test presence of a value. This requires the rank/select stuff... +// +fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { + let mut best_cost = (u32::MAX, 0); + for bits in 0..32 { + let cost = bits * len as u32 + ((max >> bits) + 1) + len; + if cost < best_cost.0 { + best_cost = (cost, bits); + } + } + best_cost +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_elias_fano() { + let data = vec![1, 2, 5, 13, 15]; + let max = 16; + let len = data.len(); + let elias_fano = EliasFano::new(data.iter().copied(), max, len as u32); + println!("{:b}", elias_fano.high_bits[0]); + println!("{:b}", elias_fano.low_bits[0]); + let mut decoder = elias_fano.iter(); + for value in data { + assert_eq!(decoder.next(), Some(value)); + } + } + + #[test] + fn test_optimal_bits() { + for i in 1..100 { + for max in i..300 { + let max = max - i; + let (cost, bits) = optimal_bits_per_value(max, i); + let b_f32 = (max as f32 / i as f32).log2(); + let b = (max / i).max(1).ilog2(); + let fast_cost = b * i + max / (1 << b) + i + 1; + if fast_cost != cost { + println!( + "{i} cost: {}, bits: {} {b_f32} {b} {}", + cost, bits, fast_cost + ); + } + } + } + } +} diff --git a/crates/pef/src/lib.rs b/crates/pef/src/lib.rs index b164ffa3..55c0e7e6 100644 --- a/crates/pef/src/lib.rs +++ b/crates/pef/src/lib.rs @@ -1,302 +1,3 @@ -struct BitStream { - data: Vec, -} +mod elias_fano; -impl BitStream { - fn new(bits: u32) -> Self { - Self { - data: vec![0; ((bits + 63) / 64) as usize], - } - } - - fn set_bit(&mut self, index: u32) { - self.data[index as usize / 64] |= 1 << (index % 64); - } - - fn set_bits(&mut self, index: u32, value: u32, bits: u32) { - let start = index / 64; - let end = (index + bits - 1) / 64; - self.data[start as usize] |= (value as u64) << (index % 64); - if start != end { - self.data[(start + 1) as usize] |= (value >> (64 - (index % 64))) as u64; - } - } - - fn finish(self) -> Vec { - self.data - } -} - -struct EliasFano { - bits_per_value: u32, - high_bits: Vec, - low_bits: Vec, -} - -impl EliasFano { - fn new(iter: impl Iterator, max: u32, len: u32) -> Self { - let (_bits, bits_per_value) = optimal_bits_per_value(max, len as u32); - // TODO: we want to get the buffers as input... - // In particular in the partitioned case, we somehow want all the - // high bits in one stream and all the low bits in another stream. - // The question is how to store the starting positions of each partition - // most efficiently... - let mut high_bits = BitStream::new(((max >> bits_per_value) + 1) + len); - let mut low_bits = BitStream::new(bits_per_value * len); - for (i, value) in iter.enumerate() { - assert!(value < max); - let high = value >> bits_per_value; - let low = value & !(!0 << bits_per_value); - high_bits.set_bit(high + i as u32); - low_bits.set_bits(i as u32, low, bits_per_value); - } - EliasFano { - bits_per_value, - high_bits: high_bits.finish(), - low_bits: low_bits.finish(), - } - } - - fn iter(&self) -> EliasFanoDecoder<'_> { - let high_bits = BitReader::new(&self.high_bits); - EliasFanoDecoder { - current_word: high_bits.get_word(0), - bits_per_value: self.bits_per_value, - high_bits, - low_bits: BitReader::new(&self.low_bits), - value_idx: 0, - bit_pos: 0, - } - } -} - -struct EliasFanoDecoder<'a> { - bits_per_value: u32, - high_bits: BitReader<'a>, - low_bits: BitReader<'a>, - value_idx: u32, - bit_pos: u32, - current_word: u64, -} - -struct BitReader<'a> { - data: &'a [u64], -} - -impl<'a> BitReader<'a> { - fn new(data: &'a [u64]) -> Self { - Self { data } - } - - fn get_bit(&self, index: u32) -> bool { - self.data[index as usize / 64] & (1 << (index % 64)) != 0 - } - - fn get_word(&self, index: u32) -> u64 { - self.data[index as usize] - } - - fn get_bits(&self, index: u32, num_bits: u32) -> u32 { - let start = index / 64; - let end = (index + num_bits) / 64; - let mut temp = self.data[start as usize] >> (index % 64); - if start != end { - temp |= self.data[(start + 1) as usize] << (64 - (index % 64)); - } - (temp as u32) & !(!0 << num_bits) - } - - fn len(&self) -> u32 { - (self.data.len() * 64) as u32 - } -} - -impl<'a> Iterator for EliasFanoDecoder<'a> { - type Item = u32; - - // Decoding 32 values in a row could be done more efficient by - // a) expanding the low bits into a full words. - // b) using masked SIMD operations to process multiple high bits in parallel - // but masking those with 0 bits. - // c) adding the two results together. - // Note: this is VERY similar to how bit packing with exceptions works. - // The main difference is that we can use the high bits only to skip forward! - // Advantages of EF are that: - // 1. we don't need to store deltas explicitly, i.e. - // no need for partial sum computations as final step. - // 2. we can skip ahead and decode individual values only. - // 3. NEW: we can intersect quickly purely based on high bits. - // 4. Due to 2, EF leads to a compact two-layer representation, while - // preserving skip list functionality. - // - start value of each block (<- EF) - // (encode number of low bits in start value with trailing ones!) - // - start bit/byte of high bits (<- EF) - // [- start bit/byte of low bits (<- EF)] - // (these could also be stored in reverse order ending at next partition) - // [- counting ones in high bit representation] - // (succinct; only needed for interval encoding) - // 5. NEW: EF can be used for interval encodings, where it is - // important to know the index of each value. - // 6. The encoding size is fully deterministic and can be computed with - // simple bit operations. - // So, in total, the EF encoding is much more flexible than bin-packing. - fn next(&mut self) -> Option { - let mut current_word = self.current_word; - let mut bit_pos = self.bit_pos; - loop { - let zeros = current_word.trailing_zeros(); - if zeros < 64 { - self.current_word = current_word >> (zeros + 1); - let bucket_id = bit_pos + zeros - self.value_idx; - let value = self.low_bits.get_bits(self.value_idx, self.bits_per_value); - self.bit_pos = bit_pos + zeros + 1; - self.value_idx += 1; - return Some(value | (bucket_id << self.bits_per_value)); - } else if bit_pos + 64 >= self.high_bits.len() { - return None; - } else { - current_word = self.high_bits.get_word(bit_pos / 64 + 1); - bit_pos = (bit_pos / 64 + 1) * 64; - } - } - } -} - -struct IntersectingIterator<'a, I> { - iter: I, - - bits_per_value: u32, - high_bits: BitReader<'a>, - low_bits: BitReader<'a>, - value_idx: u32, - bit_pos: u32, - current_word: u64, -} - -impl<'a, I: Iterator> Iterator for IntersectingIterator<'a, I> { - type Item = u32; - - fn next(&mut self) -> Option { - let mut current_word = self.current_word; - let mut bit_pos = self.bit_pos; - while let Some(next_value) = self.iter.next() { - loop { - let zeros = current_word.trailing_zeros(); - if zeros < 64 { - self.current_word = current_word >> (zeros + 1); - let bucket_id = bit_pos + zeros - self.value_idx; - // FIXME: subtract value_idx from values everywhere! - let lower_bound = (bucket_id << self.bits_per_value); // + self.value_idx; - let upper_bound = lower_bound + (1 << self.bits_per_value); - bit_pos += zeros + 1; - self.value_idx += 1; - if next_value >= upper_bound { - // keep decoding - } else if next_value >= lower_bound { - // potential match - let value = self - .low_bits - .get_bits(self.value_idx - 1, self.bits_per_value); - let value = value | (bucket_id << self.bits_per_value); - if value == next_value { - // found it! - self.bit_pos = bit_pos; - return Some(value); - } - } else { - // not found. go to next value. - break; - } - } else if bit_pos + 64 >= self.high_bits.len() { - return None; - } else { - current_word = self.high_bits.get_word(bit_pos / 64 + 1); - bit_pos = (bit_pos / 64 + 1) * 64; - } - } - } - None - } -} - -// TODO: Implement an iterator which only retrieves the high bit buckets. -// Such an iterator can be used to approximate an intersection! -// This way, one can potentially erase parts before running a second pass -// where the low values are being decoded... -// When does this work? -// Usually, one would start off the intersection with the shortest posting list. -// This posting list has the coarsest high mask granularity, i.e. for a random -// sequence, 40% of the buckets will have a value. -// If the second posting list is also random and at the same level, then -// we get 40% * 40% = 16% of the buckets remaining. -// If the second posting list is at a higher level, i.e. more dense, then -// the fill level would be roughly 63% and the intersection would be 40% * 63% = 25%. -// For another level difference, the fill level increases to 86% ==> 34% remaining. -// I.e. for more than 2 levels difference there is no benefit of this approach! -// The fill rate can be reduced by spending more bits per value. -// For example moving one level down, increases the number of bits from: -// n * log(u/n) + 2n to n * log(u/n) + 4n. - -// TODO: Implement an interval based iterator where the even indices represent -// the start of an interval and the odd indices represent the end. -// In this case, the intersection of two iterators cannot be represented as a -// simple bitmask unfortunately. Instead, one has to serialize the new -// set. -// If however one of the two sets is represented as points, then the masking -// works again... One has to decide however, whether too many points would be -// filtered out and therefore the bitmask would become larger than the encoded -// representation. - -// TODO: Implement an intersecting iterator which masks off values from the first -// iterator as we go. - -// TODO: Implement test presence of a value. This requires the rank/select stuff... -// -fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { - let mut best_cost = (u32::MAX, 0); - for bits in 0..32 { - let cost = bits * len as u32 + ((max >> bits) + 1) + len; - if cost < best_cost.0 { - best_cost = (cost, bits); - } - } - best_cost -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_elias_fano() { - let data = vec![1, 2, 5, 13, 15]; - let max = 16; - let len = data.len(); - let elias_fano = EliasFano::new(data.iter().copied(), max, len as u32); - println!("{:b}", elias_fano.high_bits[0]); - println!("{:b}", elias_fano.low_bits[0]); - let mut decoder = elias_fano.iter(); - for value in data { - assert_eq!(decoder.next(), Some(value)); - } - } - - #[test] - fn test_optimal_bits() { - for i in 1..100 { - for max in i..300 { - let max = max - i; - let (cost, bits) = optimal_bits_per_value(max, i); - let b_f32 = (max as f32 / i as f32).log2(); - let b = (max / i).max(1).ilog2(); - let fast_cost = b * i + max / (1 << b) + i + 1; - if fast_cost != cost { - println!( - "{i} cost: {}, bits: {} {b_f32} {b} {}", - cost, bits, fast_cost - ); - } - } - } - } -} +pub use elias_fano::*; From 07bf5793de23e011fc6d91109f8c79c6325dff19 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 10:16:28 +0100 Subject: [PATCH 04/45] refine batch processing --- crates/pef/benches/elias_fano_bench.rs | 14 ++++++- crates/pef/src/batch_decoder.rs | 56 ++++++++++++++++++++++++++ crates/pef/src/elias_fano.rs | 30 +++++++------- crates/pef/src/lib.rs | 2 + 4 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 crates/pef/src/batch_decoder.rs diff --git a/crates/pef/benches/elias_fano_bench.rs b/crates/pef/benches/elias_fano_bench.rs index 4b9e71e9..5213bc8e 100644 --- a/crates/pef/benches/elias_fano_bench.rs +++ b/crates/pef/benches/elias_fano_bench.rs @@ -1,9 +1,9 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; -use pef::EliasFano; +use pef::{EFBatchDecoder, EliasFano}; use rand::prelude::*; fn criterion_benchmark(c: &mut Criterion) { - let size = 10_000_000; + let size = 100_000; // Generate random sorted data with clustered gaps using a Markov chain let mut data = Vec::with_capacity(size as usize); @@ -39,6 +39,16 @@ fn criterion_benchmark(c: &mut Criterion) { } }) }); + + group.bench_function("batch_decode", |b| { + let mut buffer = [0u32; 128]; + b.iter(|| { + let mut batch_decoder = EFBatchDecoder::new(&elias_fano); + while let Some(count) = batch_decoder.decode_batch(&mut buffer) { + black_box(&buffer[..count]); + } + }) + }); group.finish(); } diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs new file mode 100644 index 00000000..61d5db47 --- /dev/null +++ b/crates/pef/src/batch_decoder.rs @@ -0,0 +1,56 @@ +use crate::elias_fano::{BitReader, EliasFano}; + +pub struct EFBatchDecoder<'a> { + bits_per_value: u32, + high_bits: BitReader<'a>, + low_bits: BitReader<'a>, + value_idx: u32, + word_idx: u32, +} + +impl<'a> EFBatchDecoder<'a> { + pub fn new(ef: &'a EliasFano) -> Self { + Self { + bits_per_value: ef.bits_per_value, + high_bits: BitReader::new(&ef.high_bits), + low_bits: BitReader::new(&ef.low_bits), + value_idx: 0, + word_idx: 0, + } + } + + pub fn decode_batch(&mut self, buffer: &mut [u32]) -> Option { + assert!(buffer.len() >= 64); + + let mut count = 0; + let word_idx = self.word_idx; + let mut value_idx = self.value_idx; + + // Cache frequently accessed fields + let bits_per_value = self.bits_per_value; + let high_bits_len = self.high_bits.len(); + + if word_idx * 64 >= high_bits_len { + return None; + } + + let mut current_word = self.high_bits.get_word(word_idx); + let mut bit_pos = word_idx * 64; + while current_word != 0 { + let zeros = current_word.trailing_zeros(); + current_word >>= zeros + 1; + + let bucket_id = bit_pos + zeros - value_idx; + let low = self.low_bits.get_bits(value_idx, bits_per_value); + + unsafe { *buffer.get_unchecked_mut(count) = low | (bucket_id << bits_per_value) }; + count += 1; + + bit_pos += zeros + 1; + value_idx += 1; + } + self.word_idx = word_idx + 1; + self.value_idx = value_idx; + Some(count) + } +} diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index e66524ac..111abc64 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -28,9 +28,9 @@ impl BitStream { } pub struct EliasFano { - bits_per_value: u32, - high_bits: Vec, - low_bits: Vec, + pub(crate) bits_per_value: u32, + pub(crate) high_bits: Vec, + pub(crate) low_bits: Vec, } impl EliasFano { @@ -71,20 +71,20 @@ impl EliasFano { } pub struct EliasFanoDecoder<'a> { - bits_per_value: u32, - high_bits: BitReader<'a>, - low_bits: BitReader<'a>, - value_idx: u32, - bit_pos: u32, - current_word: u64, + pub(crate) bits_per_value: u32, + pub(crate) high_bits: BitReader<'a>, + pub(crate) low_bits: BitReader<'a>, + pub(crate) value_idx: u32, + pub(crate) bit_pos: u32, + pub(crate) current_word: u64, } -struct BitReader<'a> { +pub(crate) struct BitReader<'a> { data: &'a [u64], } impl<'a> BitReader<'a> { - fn new(data: &'a [u64]) -> Self { + pub(crate) fn new(data: &'a [u64]) -> Self { Self { data } } @@ -92,11 +92,11 @@ impl<'a> BitReader<'a> { self.data[index as usize / 64] & (1 << (index % 64)) != 0 } - fn get_word(&self, index: u32) -> u64 { - self.data[index as usize] + pub(crate) fn get_word(&self, index: u32) -> u64 { + unsafe { *self.data.get_unchecked(index as usize) } } - fn get_bits(&self, index: u32, num_bits: u32) -> u32 { + pub(crate) fn get_bits(&self, index: u32, num_bits: u32) -> u32 { let start = index / 64; let end = (index + num_bits) / 64; let mut temp = self.data[start as usize] >> (index % 64); @@ -106,7 +106,7 @@ impl<'a> BitReader<'a> { (temp as u32) & !(!0 << num_bits) } - fn len(&self) -> u32 { + pub(crate) fn len(&self) -> u32 { (self.data.len() * 64) as u32 } } diff --git a/crates/pef/src/lib.rs b/crates/pef/src/lib.rs index 55c0e7e6..fc2830e3 100644 --- a/crates/pef/src/lib.rs +++ b/crates/pef/src/lib.rs @@ -1,3 +1,5 @@ +mod batch_decoder; mod elias_fano; +pub use batch_decoder::*; pub use elias_fano::*; From 929ea0a09058c877b3ee86bcfffce84e6fa8144d Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 11:00:19 +0100 Subject: [PATCH 05/45] back to decoding a full batch of 32 values --- crates/pef/benches/elias_fano_bench.rs | 9 ++- crates/pef/src/batch_decoder.rs | 107 +++++++++++++++++++------ crates/pef/src/elias_fano.rs | 6 +- 3 files changed, 90 insertions(+), 32 deletions(-) diff --git a/crates/pef/benches/elias_fano_bench.rs b/crates/pef/benches/elias_fano_bench.rs index 5213bc8e..71dc88d4 100644 --- a/crates/pef/benches/elias_fano_bench.rs +++ b/crates/pef/benches/elias_fano_bench.rs @@ -41,11 +41,14 @@ fn criterion_benchmark(c: &mut Criterion) { }); group.bench_function("batch_decode", |b| { - let mut buffer = [0u32; 128]; b.iter(|| { let mut batch_decoder = EFBatchDecoder::new(&elias_fano); - while let Some(count) = batch_decoder.decode_batch(&mut buffer) { - black_box(&buffer[..count]); + loop { + let batch = batch_decoder.decode_batch(); + if batch.is_empty() { + break; + } + black_box(batch); } }) }); diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs index 61d5db47..096a7221 100644 --- a/crates/pef/src/batch_decoder.rs +++ b/crates/pef/src/batch_decoder.rs @@ -5,52 +5,107 @@ pub struct EFBatchDecoder<'a> { high_bits: BitReader<'a>, low_bits: BitReader<'a>, value_idx: u32, - word_idx: u32, + bit_pos: u32, + current_word: u64, + buffer: [u32; 32], } impl<'a> EFBatchDecoder<'a> { pub fn new(ef: &'a EliasFano) -> Self { + let high_bits = BitReader::new(&ef.high_bits); Self { bits_per_value: ef.bits_per_value, - high_bits: BitReader::new(&ef.high_bits), + current_word: high_bits.get_word(0), + high_bits, low_bits: BitReader::new(&ef.low_bits), value_idx: 0, - word_idx: 0, + bit_pos: 0, + buffer: [0; 32], } } - pub fn decode_batch(&mut self, buffer: &mut [u32]) -> Option { - assert!(buffer.len() >= 64); - + #[inline] + pub fn decode_batch(&mut self) -> &[u32] { let mut count = 0; - let word_idx = self.word_idx; + let mut current_word = self.current_word; + let mut bit_pos = self.bit_pos; let mut value_idx = self.value_idx; // Cache frequently accessed fields let bits_per_value = self.bits_per_value; let high_bits_len = self.high_bits.len(); - if word_idx * 64 >= high_bits_len { - return None; - } - - let mut current_word = self.high_bits.get_word(word_idx); - let mut bit_pos = word_idx * 64; - while current_word != 0 { + while count < 32 { let zeros = current_word.trailing_zeros(); - current_word >>= zeros + 1; - - let bucket_id = bit_pos + zeros - value_idx; - let low = self.low_bits.get_bits(value_idx, bits_per_value); - - unsafe { *buffer.get_unchecked_mut(count) = low | (bucket_id << bits_per_value) }; - count += 1; - - bit_pos += zeros + 1; - value_idx += 1; + if zeros < 64 { + current_word >>= zeros + 1; + let bucket_id = bit_pos + zeros - value_idx; + let low = self.low_bits.get_bits(value_idx * bits_per_value, bits_per_value); + + unsafe { *self.buffer.get_unchecked_mut(count) = low | (bucket_id << bits_per_value) }; + count += 1; + + bit_pos += zeros + 1; + value_idx += 1; + + if bit_pos % 64 == 0 { + if bit_pos >= high_bits_len { + current_word = 0; + break; + } + current_word = self.high_bits.get_word(bit_pos / 64); + } + } else { + // Skip to next word + let next_word_idx = bit_pos / 64 + 1; + let next_bit_pos = next_word_idx * 64; + + if next_bit_pos >= high_bits_len { + current_word = 0; + break; + } + current_word = self.high_bits.get_word(next_word_idx); + bit_pos = next_bit_pos; + } } - self.word_idx = word_idx + 1; + + self.current_word = current_word; + self.bit_pos = bit_pos; self.value_idx = value_idx; - Some(count) + + &self.buffer[..count] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::elias_fano::EliasFano; + use rand::prelude::*; + + #[test] + fn test_batch_decoder() { + let mut rng = StdRng::seed_from_u64(12345); + let mut data = Vec::new(); + let mut current = 0; + for _ in 0..1000 { + current += (rng.random::() % 10) + 1; + data.push(current); + } + let max = *data.last().unwrap() + 1; + let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); + + let mut decoder = EFBatchDecoder::new(&ef); + let mut decoded = Vec::new(); + + loop { + let batch = decoder.decode_batch(); + if batch.is_empty() { + break; + } + decoded.extend_from_slice(batch); + } + + assert_eq!(decoded, data); } } diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index 111abc64..4a48d4ab 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -48,7 +48,7 @@ impl EliasFano { let high = value >> bits_per_value; let low = value & !(!0 << bits_per_value); high_bits.set_bit(high + i as u32); - low_bits.set_bits(i as u32, low, bits_per_value); + low_bits.set_bits((i as u32) * bits_per_value, low, bits_per_value); } EliasFano { bits_per_value, @@ -148,7 +148,7 @@ impl<'a> Iterator for EliasFanoDecoder<'a> { if zeros < 64 { self.current_word = current_word >> (zeros + 1); let bucket_id = bit_pos + zeros - self.value_idx; - let value = self.low_bits.get_bits(self.value_idx, self.bits_per_value); + let value = self.low_bits.get_bits(self.value_idx * self.bits_per_value, self.bits_per_value); self.bit_pos = bit_pos + zeros + 1; self.value_idx += 1; return Some(value | (bucket_id << self.bits_per_value)); @@ -196,7 +196,7 @@ impl<'a, I: Iterator> Iterator for IntersectingIterator<'a, I> { // potential match let value = self .low_bits - .get_bits(self.value_idx - 1, self.bits_per_value); + .get_bits((self.value_idx - 1) * self.bits_per_value, self.bits_per_value); let value = value | (bucket_id << self.bits_per_value); if value == next_value { // found it! From 6c6f387415d171fcb9acabbb115895796442212c Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 11:07:27 +0100 Subject: [PATCH 06/45] loop + don't read ahead --- crates/pef/src/batch_decoder.rs | 52 ++++++++++++++++----------------- crates/pef/src/elias_fano.rs | 2 +- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs index 096a7221..0f6850e0 100644 --- a/crates/pef/src/batch_decoder.rs +++ b/crates/pef/src/batch_decoder.rs @@ -26,7 +26,6 @@ impl<'a> EFBatchDecoder<'a> { #[inline] pub fn decode_batch(&mut self) -> &[u32] { - let mut count = 0; let mut current_word = self.current_word; let mut bit_pos = self.bit_pos; let mut value_idx = self.value_idx; @@ -35,45 +34,46 @@ impl<'a> EFBatchDecoder<'a> { let bits_per_value = self.bits_per_value; let high_bits_len = self.high_bits.len(); - while count < 32 { - let zeros = current_word.trailing_zeros(); - if zeros < 64 { - current_word >>= zeros + 1; - let bucket_id = bit_pos + zeros - value_idx; - let low = self.low_bits.get_bits(value_idx * bits_per_value, bits_per_value); - - unsafe { *self.buffer.get_unchecked_mut(count) = low | (bucket_id << bits_per_value) }; - count += 1; - - bit_pos += zeros + 1; - value_idx += 1; - - if bit_pos % 64 == 0 { - if bit_pos >= high_bits_len { - current_word = 0; - break; - } - current_word = self.high_bits.get_word(bit_pos / 64); - } - } else { - // Skip to next word + for count in 0..32 { + while current_word == 0 { let next_word_idx = bit_pos / 64 + 1; let next_bit_pos = next_word_idx * 64; if next_bit_pos >= high_bits_len { - current_word = 0; - break; + self.current_word = 0; + self.bit_pos = next_bit_pos; + self.value_idx = value_idx; + return &self.buffer[..count]; } current_word = self.high_bits.get_word(next_word_idx); bit_pos = next_bit_pos; } + + let zeros = current_word.trailing_zeros(); + current_word >>= zeros + 1; + + let bucket_id = bit_pos + zeros - value_idx; + let low = self.low_bits.get_bits(value_idx * bits_per_value, bits_per_value); + + unsafe { *self.buffer.get_unchecked_mut(count) = low | (bucket_id << bits_per_value) }; + + bit_pos += zeros + 1; + value_idx += 1; + + if bit_pos % 64 == 0 { + if bit_pos >= high_bits_len { + current_word = 0; + } else { + current_word = self.high_bits.get_word(bit_pos / 64); + } + } } self.current_word = current_word; self.bit_pos = bit_pos; self.value_idx = value_idx; - &self.buffer[..count] + &self.buffer } } diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index 4a48d4ab..2b96a22b 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -98,7 +98,7 @@ impl<'a> BitReader<'a> { pub(crate) fn get_bits(&self, index: u32, num_bits: u32) -> u32 { let start = index / 64; - let end = (index + num_bits) / 64; + let end = (index + num_bits - 1) / 64; let mut temp = self.data[start as usize] >> (index % 64); if start != end { temp |= self.data[(start + 1) as usize] << (64 - (index % 64)); From 85cfdf3c78526fbfbf1f6ef99494e6d7365568b1 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 15:22:32 +0100 Subject: [PATCH 07/45] fix base implementation and try generics --- crates/pef/src/batch_decoder.rs | 137 +++++++++++++++++++++----------- crates/pef/src/elias_fano.rs | 35 +++++++- 2 files changed, 123 insertions(+), 49 deletions(-) diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs index 0f6850e0..02daedf0 100644 --- a/crates/pef/src/batch_decoder.rs +++ b/crates/pef/src/batch_decoder.rs @@ -6,7 +6,6 @@ pub struct EFBatchDecoder<'a> { low_bits: BitReader<'a>, value_idx: u32, bit_pos: u32, - current_word: u64, buffer: [u32; 32], } @@ -15,7 +14,6 @@ impl<'a> EFBatchDecoder<'a> { let high_bits = BitReader::new(&ef.high_bits); Self { bits_per_value: ef.bits_per_value, - current_word: high_bits.get_word(0), high_bits, low_bits: BitReader::new(&ef.low_bits), value_idx: 0, @@ -26,54 +24,102 @@ impl<'a> EFBatchDecoder<'a> { #[inline] pub fn decode_batch(&mut self) -> &[u32] { - let mut current_word = self.current_word; - let mut bit_pos = self.bit_pos; - let mut value_idx = self.value_idx; - // Cache frequently accessed fields - let bits_per_value = self.bits_per_value; let high_bits_len = self.high_bits.len(); - - for count in 0..32 { - while current_word == 0 { - let next_word_idx = bit_pos / 64 + 1; - let next_bit_pos = next_word_idx * 64; - - if next_bit_pos >= high_bits_len { - self.current_word = 0; - self.bit_pos = next_bit_pos; - self.value_idx = value_idx; - return &self.buffer[..count]; - } - current_word = self.high_bits.get_word(next_word_idx); - bit_pos = next_bit_pos; + let mut bit_pos = self.bit_pos; + if bit_pos >= high_bits_len { + return &[]; + } + let mut value_idx = self.value_idx; + let mut count = 0; + loop { + let current_word = self.high_bits.get_word(bit_pos / 64); + let total_ones = (current_word >> (bit_pos % 64)).count_ones(); + let ones = total_ones.min(32 - count as u32); + bit_pos = self.process_word_dispatch(ones, current_word, bit_pos, value_idx, count); + value_idx += ones; + count += ones as usize; + if count == 32 { + break; + } + if bit_pos >= high_bits_len { + break; } + } + self.bit_pos = bit_pos; + self.value_idx = value_idx; + &self.buffer[0..count] + } + + #[inline(always)] + fn process_word_dispatch( + &mut self, + ones: u32, + current_word: u64, + bit_pos: u32, + value_idx: u32, + count: usize, + ) -> u32 { + match ones { + 0 => (bit_pos + 64) & !63, + 1 => self.process_word::<1>(current_word, bit_pos, value_idx, count), + 2 => self.process_word::<2>(current_word, bit_pos, value_idx, count), + 3 => self.process_word::<3>(current_word, bit_pos, value_idx, count), + 4 => self.process_word::<4>(current_word, bit_pos, value_idx, count), + 5 => self.process_word::<5>(current_word, bit_pos, value_idx, count), + 6 => self.process_word::<6>(current_word, bit_pos, value_idx, count), + 7 => self.process_word::<7>(current_word, bit_pos, value_idx, count), + 8 => self.process_word::<8>(current_word, bit_pos, value_idx, count), + 9 => self.process_word::<9>(current_word, bit_pos, value_idx, count), + 10 => self.process_word::<10>(current_word, bit_pos, value_idx, count), + 11 => self.process_word::<11>(current_word, bit_pos, value_idx, count), + 12 => self.process_word::<12>(current_word, bit_pos, value_idx, count), + 13 => self.process_word::<13>(current_word, bit_pos, value_idx, count), + 14 => self.process_word::<14>(current_word, bit_pos, value_idx, count), + 15 => self.process_word::<15>(current_word, bit_pos, value_idx, count), + 16 => self.process_word::<16>(current_word, bit_pos, value_idx, count), + 17 => self.process_word::<17>(current_word, bit_pos, value_idx, count), + 18 => self.process_word::<18>(current_word, bit_pos, value_idx, count), + 19 => self.process_word::<19>(current_word, bit_pos, value_idx, count), + 20 => self.process_word::<20>(current_word, bit_pos, value_idx, count), + 21 => self.process_word::<21>(current_word, bit_pos, value_idx, count), + 22 => self.process_word::<22>(current_word, bit_pos, value_idx, count), + 23 => self.process_word::<23>(current_word, bit_pos, value_idx, count), + 24 => self.process_word::<24>(current_word, bit_pos, value_idx, count), + 25 => self.process_word::<25>(current_word, bit_pos, value_idx, count), + 26 => self.process_word::<26>(current_word, bit_pos, value_idx, count), + 27 => self.process_word::<27>(current_word, bit_pos, value_idx, count), + 28 => self.process_word::<28>(current_word, bit_pos, value_idx, count), + 29 => self.process_word::<29>(current_word, bit_pos, value_idx, count), + 30 => self.process_word::<30>(current_word, bit_pos, value_idx, count), + 31 => self.process_word::<31>(current_word, bit_pos, value_idx, count), + 32 => self.process_word::<32>(current_word, bit_pos, value_idx, count), + _ => unreachable!(), + } + } + #[inline(always)] + fn process_word( + &mut self, + mut current_word: u64, + bit_pos: u32, + value_idx: u32, + count: usize, + ) -> u32 { + let mut bucket_id = bit_pos - value_idx; + current_word >>= bit_pos % 64; + for i in 0..N { let zeros = current_word.trailing_zeros(); current_word >>= zeros + 1; - - let bucket_id = bit_pos + zeros - value_idx; - let low = self.low_bits.get_bits(value_idx * bits_per_value, bits_per_value); - - unsafe { *self.buffer.get_unchecked_mut(count) = low | (bucket_id << bits_per_value) }; - - bit_pos += zeros + 1; - value_idx += 1; - - if bit_pos % 64 == 0 { - if bit_pos >= high_bits_len { - current_word = 0; - } else { - current_word = self.high_bits.get_word(bit_pos / 64); - } + bucket_id += zeros; + let low = self + .low_bits + .get_bits((value_idx + i) * self.bits_per_value, self.bits_per_value); + unsafe { + *self.buffer.get_unchecked_mut(count + i as usize) = low | (bucket_id << self.bits_per_value); } } - - self.current_word = current_word; - self.bit_pos = bit_pos; - self.value_idx = value_idx; - - &self.buffer + bucket_id + value_idx + N } } @@ -94,10 +140,10 @@ mod tests { } let max = *data.last().unwrap() + 1; let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); - + let mut decoder = EFBatchDecoder::new(&ef); let mut decoded = Vec::new(); - + loop { let batch = decoder.decode_batch(); if batch.is_empty() { @@ -105,7 +151,8 @@ mod tests { } decoded.extend_from_slice(batch); } - + assert_eq!(decoded, data); } } + diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index 2b96a22b..2f1fa6c9 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -146,17 +146,22 @@ impl<'a> Iterator for EliasFanoDecoder<'a> { loop { let zeros = current_word.trailing_zeros(); if zeros < 64 { - self.current_word = current_word >> (zeros + 1); + current_word >>= zeros; + current_word >>= 1; + self.current_word = current_word; let bucket_id = bit_pos + zeros - self.value_idx; let value = self.low_bits.get_bits(self.value_idx * self.bits_per_value, self.bits_per_value); self.bit_pos = bit_pos + zeros + 1; self.value_idx += 1; + if self.bit_pos & 63 == 0 { + self.bit_pos -= 1; + } return Some(value | (bucket_id << self.bits_per_value)); } else if bit_pos + 64 >= self.high_bits.len() { return None; } else { - current_word = self.high_bits.get_word(bit_pos / 64 + 1); - bit_pos = (bit_pos / 64 + 1) * 64; + bit_pos = (bit_pos + 64) & !63; + current_word = self.high_bits.get_word(bit_pos / 64); } } } @@ -183,7 +188,9 @@ impl<'a, I: Iterator> Iterator for IntersectingIterator<'a, I> { loop { let zeros = current_word.trailing_zeros(); if zeros < 64 { - self.current_word = current_word >> (zeros + 1); + current_word >>= zeros; + current_word >>= 1; + self.current_word = current_word; let bucket_id = bit_pos + zeros - self.value_idx; // FIXME: subtract value_idx from values everywhere! let lower_bound = (bucket_id << self.bits_per_value); // + self.value_idx; @@ -265,6 +272,8 @@ fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { #[cfg(test)] mod tests { + use rand::prelude::*; + use super::*; #[test] @@ -281,6 +290,24 @@ mod tests { } } + #[test] + fn test_elias_fano_decoder() { + let mut rng = StdRng::seed_from_u64(12345); + let mut data = Vec::new(); + let mut current = 0; + for _ in 0..1000 { + current += (rng.random::() % 10) + 1; + data.push(current); + } + let max = *data.last().unwrap() + 1; + let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); + + let decoder = ef.iter(); + let decoded = decoder.collect::>(); + + assert_eq!(decoded, data); + } + #[test] fn test_optimal_bits() { for i in 1..100 { From 1cd9cbb30e28c125b5dc4d1d7da64af68984f9b9 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 15:39:47 +0100 Subject: [PATCH 08/45] try unaligned words --- crates/pef/src/batch_decoder.rs | 6 +++--- crates/pef/src/elias_fano.rs | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs index 02daedf0..17ee1f0d 100644 --- a/crates/pef/src/batch_decoder.rs +++ b/crates/pef/src/batch_decoder.rs @@ -33,8 +33,8 @@ impl<'a> EFBatchDecoder<'a> { let mut value_idx = self.value_idx; let mut count = 0; loop { - let current_word = self.high_bits.get_word(bit_pos / 64); - let total_ones = (current_word >> (bit_pos % 64)).count_ones(); + let current_word = self.high_bits.get_word_unaligned(bit_pos / 8); + let total_ones = (current_word >> (bit_pos % 8)).count_ones(); let ones = total_ones.min(32 - count as u32); bit_pos = self.process_word_dispatch(ones, current_word, bit_pos, value_idx, count); value_idx += ones; @@ -107,7 +107,7 @@ impl<'a> EFBatchDecoder<'a> { count: usize, ) -> u32 { let mut bucket_id = bit_pos - value_idx; - current_word >>= bit_pos % 64; + current_word >>= bit_pos % 8; for i in 0..N { let zeros = current_word.trailing_zeros(); current_word >>= zeros + 1; diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index 2f1fa6c9..ed573ab4 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -96,6 +96,13 @@ impl<'a> BitReader<'a> { unsafe { *self.data.get_unchecked(index as usize) } } + pub(crate) fn get_word_unaligned(&self, byte_pos: u32) -> u64 { + unsafe { + let ptr = self.data.as_ptr() as *const u8; + std::ptr::read_unaligned(ptr.add(byte_pos as usize) as *const u64) + } + } + pub(crate) fn get_bits(&self, index: u32, num_bits: u32) -> u32 { let start = index / 64; let end = (index + num_bits - 1) / 64; From 710102b81c8fa0970b8edc80e59cdcf081ac66be Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 15:54:38 +0100 Subject: [PATCH 09/45] tune batch size --- crates/pef/src/batch_decoder.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs index 17ee1f0d..8b1bc176 100644 --- a/crates/pef/src/batch_decoder.rs +++ b/crates/pef/src/batch_decoder.rs @@ -1,12 +1,14 @@ use crate::elias_fano::{BitReader, EliasFano}; +const BATCH_SIZE: usize = 8; + pub struct EFBatchDecoder<'a> { bits_per_value: u32, high_bits: BitReader<'a>, low_bits: BitReader<'a>, value_idx: u32, bit_pos: u32, - buffer: [u32; 32], + buffer: [u32; BATCH_SIZE], } impl<'a> EFBatchDecoder<'a> { @@ -18,7 +20,7 @@ impl<'a> EFBatchDecoder<'a> { low_bits: BitReader::new(&ef.low_bits), value_idx: 0, bit_pos: 0, - buffer: [0; 32], + buffer: [0; BATCH_SIZE], } } @@ -35,11 +37,11 @@ impl<'a> EFBatchDecoder<'a> { loop { let current_word = self.high_bits.get_word_unaligned(bit_pos / 8); let total_ones = (current_word >> (bit_pos % 8)).count_ones(); - let ones = total_ones.min(32 - count as u32); + let ones = total_ones.min((BATCH_SIZE - count) as u32); bit_pos = self.process_word_dispatch(ones, current_word, bit_pos, value_idx, count); value_idx += ones; count += ones as usize; - if count == 32 { + if count == BATCH_SIZE { break; } if bit_pos >= high_bits_len { @@ -61,7 +63,7 @@ impl<'a> EFBatchDecoder<'a> { count: usize, ) -> u32 { match ones { - 0 => (bit_pos + 64) & !63, + 0 => (bit_pos + 64) & !7, 1 => self.process_word::<1>(current_word, bit_pos, value_idx, count), 2 => self.process_word::<2>(current_word, bit_pos, value_idx, count), 3 => self.process_word::<3>(current_word, bit_pos, value_idx, count), @@ -70,7 +72,7 @@ impl<'a> EFBatchDecoder<'a> { 6 => self.process_word::<6>(current_word, bit_pos, value_idx, count), 7 => self.process_word::<7>(current_word, bit_pos, value_idx, count), 8 => self.process_word::<8>(current_word, bit_pos, value_idx, count), - 9 => self.process_word::<9>(current_word, bit_pos, value_idx, count), + /*9 => self.process_word::<9>(current_word, bit_pos, value_idx, count), 10 => self.process_word::<10>(current_word, bit_pos, value_idx, count), 11 => self.process_word::<11>(current_word, bit_pos, value_idx, count), 12 => self.process_word::<12>(current_word, bit_pos, value_idx, count), @@ -93,7 +95,7 @@ impl<'a> EFBatchDecoder<'a> { 29 => self.process_word::<29>(current_word, bit_pos, value_idx, count), 30 => self.process_word::<30>(current_word, bit_pos, value_idx, count), 31 => self.process_word::<31>(current_word, bit_pos, value_idx, count), - 32 => self.process_word::<32>(current_word, bit_pos, value_idx, count), + 32 => self.process_word::<32>(current_word, bit_pos, value_idx, count),*/ _ => unreachable!(), } } From 2521d576d9ebc3a63d05e2258cc1efd248487c39 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 24 Nov 2025 16:05:57 +0100 Subject: [PATCH 10/45] decode a multiple of 32 --- crates/pef/src/batch_decoder.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs index 8b1bc176..49dd0657 100644 --- a/crates/pef/src/batch_decoder.rs +++ b/crates/pef/src/batch_decoder.rs @@ -35,7 +35,7 @@ impl<'a> EFBatchDecoder<'a> { let mut value_idx = self.value_idx; let mut count = 0; loop { - let current_word = self.high_bits.get_word_unaligned(bit_pos / 8); + let current_word = self.high_bits.get_word_unaligned(bit_pos / 8); let total_ones = (current_word >> (bit_pos % 8)).count_ones(); let ones = total_ones.min((BATCH_SIZE - count) as u32); bit_pos = self.process_word_dispatch(ones, current_word, bit_pos, value_idx, count); @@ -118,7 +118,8 @@ impl<'a> EFBatchDecoder<'a> { .low_bits .get_bits((value_idx + i) * self.bits_per_value, self.bits_per_value); unsafe { - *self.buffer.get_unchecked_mut(count + i as usize) = low | (bucket_id << self.bits_per_value); + *self.buffer.get_unchecked_mut(count + i as usize) = + low | (bucket_id << self.bits_per_value); } } bucket_id + value_idx + N @@ -136,10 +137,33 @@ mod tests { let mut rng = StdRng::seed_from_u64(12345); let mut data = Vec::new(); let mut current = 0; - for _ in 0..1000 { + /*for _ in 0..32 * 32 { current += (rng.random::() % 10) + 1; data.push(current); + }*/ + + let mut state = 0; + for _ in 0..32*32 { + let gap = if state == 0 { + if rng.random_bool(0.1) { + state = 1; + } // Transition to sparse + if rng.random_bool(0.9) { + 1 + } else { + (rng.random::() % 5) + 1 + } + } else { + if rng.random_bool(0.1) { + state = 0; + } // Transition to dense + (rng.random::() % 100) + 1 + }; + + current += gap; + data.push(current); } + let max = *data.last().unwrap() + 1; let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); @@ -157,4 +181,3 @@ mod tests { assert_eq!(decoded, data); } } - From 8d62ad4f82d59fb7fbcac5e9ff84c0567e19b720 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 25 Nov 2025 07:13:21 +0000 Subject: [PATCH 11/45] add avx version --- Cargo.toml | 1 + crates/pef/Cargo.toml | 2 +- crates/pef/benches/elias_fano_bench.rs | 27 ++- crates/pef/src/avx_batch_decoder.rs | 241 +++++++++++++++++++++++++ crates/pef/src/batch_decoder.rs | 31 +--- crates/pef/src/elias_fano.rs | 43 ++++- crates/pef/src/lib.rs | 14 ++ 7 files changed, 321 insertions(+), 38 deletions(-) create mode 100644 crates/pef/src/avx_batch_decoder.rs diff --git a/Cargo.toml b/Cargo.toml index 312f46d5..59a6e8f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/*", "crates/bpe/benchmarks", "crates/bpe/tests", + "crates/pef", ] resolver = "2" diff --git a/crates/pef/Cargo.toml b/crates/pef/Cargo.toml index 36076021..e8d01b46 100644 --- a/crates/pef/Cargo.toml +++ b/crates/pef/Cargo.toml @@ -2,7 +2,7 @@ name = "pef" version = "0.1.0" edition = "2021" -description = "Partitioned Elias Fano Variation." +description = "Partitioned Elias-Fano Variation." repository = "https://github.com/github/rust-gems" license = "MIT" keywords = ["tokenizer", "algorithm", "encoding", "bpe"] diff --git a/crates/pef/benches/elias_fano_bench.rs b/crates/pef/benches/elias_fano_bench.rs index 71dc88d4..170b2151 100644 --- a/crates/pef/benches/elias_fano_bench.rs +++ b/crates/pef/benches/elias_fano_bench.rs @@ -1,5 +1,5 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; -use pef::{EFBatchDecoder, EliasFano}; +use pef::{avx_batch_decoder::AvxBatchDecoder, EFBatchDecoder, EliasFano}; use rand::prelude::*; fn criterion_benchmark(c: &mut Criterion) { @@ -32,6 +32,30 @@ fn criterion_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("elias_fano"); group.throughput(Throughput::Elements(size as u64)); + + #[cfg(target_arch = "x86_64")] + if std::is_x86_feature_detected!("avx512f") + && std::is_x86_feature_detected!("avx512vbmi2") + && std::is_x86_feature_detected!("avx512bw") + && std::is_x86_feature_detected!("popcnt") + { + group.bench_function("avx_batch_decode", |b| { + b.iter(|| { + unsafe { + let mut decoder = AvxBatchDecoder::new(&elias_fano); + let mut buffer = [0u32; 16]; + loop { + let count = decoder.decode_batch(&mut buffer); + if count == 0 { + break; + } + black_box(&buffer[..count]); + } + } + }) + }); + } + group.bench_function("iter", |b| { b.iter(|| { for val in elias_fano.iter() { @@ -52,6 +76,7 @@ fn criterion_benchmark(c: &mut Criterion) { } }) }); + group.finish(); } diff --git a/crates/pef/src/avx_batch_decoder.rs b/crates/pef/src/avx_batch_decoder.rs new file mode 100644 index 00000000..f338f91f --- /dev/null +++ b/crates/pef/src/avx_batch_decoder.rs @@ -0,0 +1,241 @@ +use crate::elias_fano::EliasFano; +use std::arch::x86_64::*; + +pub struct AvxBatchDecoder<'a> { + high_bits: &'a [u64], + low_bits: &'a [u64], + bits_per_value: u32, + current_word: u64, + high_idx: usize, + value_idx: usize, +} + +impl<'a> AvxBatchDecoder<'a> { + pub fn new(ef: &'a EliasFano) -> Self { + Self { + high_bits: &ef.high_bits, + low_bits: &ef.low_bits, + bits_per_value: ef.bits_per_value, + current_word: 0, + high_idx: 0, + value_idx: 0, + } + } + + /// Decodes a batch of 16 values using AVX-512 instructions. + /// + /// # Safety + /// This function requires AVX-512F, AVX-512VBMI2, and AVX-512BW features. + #[target_feature(enable = "avx512f", enable = "avx512vbmi2", enable = "avx512bw")] + pub unsafe fn decode(high_bits: u64, low_bits: __m512i, bits_per_value: u32) -> __m512i { + // Identity indices 0..63 for the byte positions + let identity = _mm512_set_epi8( + 63, 62, 61, 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, + 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, + 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, + ); + + // Compress the indices of the set bits in high_bits. + // This gathers the byte values from 'identity' where the corresponding bit in 'high_bits' is 1. + let compressed_indices = _mm512_maskz_compress_epi8(high_bits, identity); + + // Expand the first 16 byte positions (indices of the first 16 set bits) into epi32 values. + let first_16_indices = _mm512_castsi512_si128(compressed_indices); + let expanded_indices = _mm512_cvtepu8_epi32(first_16_indices); + + // Subtract their position in the register (0..15). + // This corresponds to calculating the high part value: position - rank. + let range_0_15 = _mm512_set_epi32(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0); + let high_parts = _mm512_sub_epi32(expanded_indices, range_0_15); + + // Shift each value by bits_per_value. + let shift_count = _mm_cvtsi32_si128(bits_per_value as i32); + let shifted_high = _mm512_sll_epi32(high_parts, shift_count); + + // Add the expanded low bits. + _mm512_add_epi32(shifted_high, low_bits) + } + + /// Decodes up to 16 values from the Elias-Fano representation. + /// + /// Updates internal state. + /// Writes decoded values to `output` and returns the number of values written. + #[target_feature( + enable = "avx512f", + enable = "avx512vbmi2", + enable = "avx512bw", + enable = "popcnt" + )] + pub unsafe fn decode_batch(&mut self, output: &mut [u32]) -> usize { + // Ensure we have a non-zero current word + while self.current_word == 0 { + if self.high_idx >= self.high_bits.len() { + return 0; + } + self.current_word = *self.high_bits.get_unchecked(self.high_idx); + self.high_idx += 1; + } + + let k = _popcnt64(self.current_word as i64) as usize; + let count = k.min(16); + + // 1. Prepare High Bits + let identity = _mm512_set_epi8( + 63, 62, 61, 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, + 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, + 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, + ); + let compressed_indices = _mm512_maskz_compress_epi8(self.current_word, identity); + let first_16_indices = _mm512_castsi512_si128(compressed_indices); + let expanded_indices = _mm512_cvtepu8_epi32(first_16_indices); + + // Calculate high parts: (position - rank_in_batch) + correction + // correction = base_position - global_rank_start + // base_position = (high_idx - 1) * 64 + let base_position = (self.high_idx - 1) as i32 * 64; + let correction = base_position - self.value_idx as i32; + + let range_0_15 = _mm512_set_epi32(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0); + // (pos - rank_in_batch) + let high_parts_base = _mm512_sub_epi32(expanded_indices, range_0_15); + // Add correction + let correction_vec = _mm512_set1_epi32(correction); + let high_parts = _mm512_add_epi32(high_parts_base, correction_vec); + + // 2. Prepare Low Bits + let mut low_vals = [0i32; 16]; + let mut bit_idx = self.value_idx as u32 * self.bits_per_value; + for i in 0..count { + // Inline get_bits logic for simplicity and performance + let start = bit_idx / 64; + let end = (bit_idx + self.bits_per_value - 1) / 64; + let offset = bit_idx % 64; + let mut val = self.low_bits.get_unchecked(start as usize) >> offset; + if start != end { + val |= self.low_bits.get_unchecked((start + 1) as usize) << (64 - offset); + } + low_vals[i] = (val as u32 & !(!0 << self.bits_per_value)) as i32; + bit_idx += self.bits_per_value; + } + let low_bits_vec = _mm512_loadu_si512(low_vals.as_ptr() as *const _); + + // 3. Combine + let shift_count = _mm_cvtsi32_si128(self.bits_per_value as i32); + let shifted_high = _mm512_sll_epi32(high_parts, shift_count); + let result_vec = _mm512_add_epi32(shifted_high, low_bits_vec); + + // 4. Store Result + let mut temp_output = [0u32; 16]; + _mm512_storeu_si512(temp_output.as_mut_ptr() as *mut _, result_vec); + output[..count].copy_from_slice(&temp_output[..count]); + + // 5. Update State + // Find position of the last processed bit to clear bits in current_word + // We want the value at index (count - 1) from compressed_indices. + // compressed_indices contains byte indices in the lower 128 bits (since count <= 16). + let lower_128 = _mm512_castsi512_si128(compressed_indices); + let last_pos = if count == 16 { + _mm_extract_epi8(lower_128, 15) as u32 + } else { + let lo = _mm_cvtsi128_si64(lower_128) as u64; + let hi = _mm_extract_epi64(lower_128, 1) as u64; + + let index = count - 1; + let val = if index < 8 { lo } else { hi }; + let shift = (index & 7) * 8; + (val >> shift) as u32 & 0xFF + }; + + let mask = (!0u64 << 1) << last_pos; + self.current_word &= mask; + + self.value_idx += count; + count + } +} + +#[cfg(test)] +mod tests { + use crate::test_utils::generate_markov_chain_data; + + use super::*; + + #[test] + fn test_decode_first_16() { + if !is_x86_feature_detected!("avx512f") + || !is_x86_feature_detected!("avx512vbmi2") + || !is_x86_feature_detected!("avx512bw") + { + println!("Skipping test: AVX-512 not supported on this hardware"); + return; + } + unsafe { + // Construct high_bits with 16 set bits at specific positions. + // Positions: 0, 2, 3, 5, 8, 10, 12, 15, 17, 20, 22, 25, 28, 30, 32, 35 + let positions = [0, 2, 3, 5, 8, 10, 12, 15, 17, 20, 22, 25, 28, 30, 32, 35]; + let mut high_bits: u64 = 0; + for &p in &positions { + high_bits |= 1u64 << p; + } + + // Construct low_bits (arbitrary values) + let low_vals: [i32; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + let low_bits = _mm512_loadu_si512(low_vals.as_ptr() as *const _); + + let bits_per_value = 3; + + // Calculate expected result + let mut expected = [0i32; 16]; + for i in 0..16 { + let p = positions[i]; + let rank = i as i32; + let high_part = (p as i32) - rank; + expected[i] = (high_part << bits_per_value) + low_vals[i]; + } + + // Decode + let result_vec = AvxBatchDecoder::<'_>::decode(high_bits, low_bits, bits_per_value); + + // Store result + let mut result = [0i32; 16]; + _mm512_storeu_si512(result.as_mut_ptr() as *mut _, result_vec); + + assert_eq!( + result, expected, + "Decoded values do not match expected values" + ); + } + } + + #[test] + fn test_decode_batch_1024() { + if !is_x86_feature_detected!("avx512f") + || !is_x86_feature_detected!("avx512vbmi2") + || !is_x86_feature_detected!("avx512bw") + || !is_x86_feature_detected!("popcnt") + { + println!("Skipping test: AVX-512 not supported on this hardware"); + return; + } + + let data = generate_markov_chain_data(32 * 32, 12345); + let max = *data.last().unwrap() + 1; + let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); + + unsafe { + let mut decoder = AvxBatchDecoder::new(&ef); + let mut decoded = Vec::new(); + let mut buffer = [0u32; 16]; + + loop { + let count = decoder.decode_batch(&mut buffer); + if count == 0 { + break; + } + decoded.extend_from_slice(&buffer[..count]); + } + + assert_eq!(decoded, data); + } + } +} diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs index 49dd0657..158fd5e7 100644 --- a/crates/pef/src/batch_decoder.rs +++ b/crates/pef/src/batch_decoder.rs @@ -133,36 +133,9 @@ mod tests { use rand::prelude::*; #[test] + #[ignore] fn test_batch_decoder() { - let mut rng = StdRng::seed_from_u64(12345); - let mut data = Vec::new(); - let mut current = 0; - /*for _ in 0..32 * 32 { - current += (rng.random::() % 10) + 1; - data.push(current); - }*/ - - let mut state = 0; - for _ in 0..32*32 { - let gap = if state == 0 { - if rng.random_bool(0.1) { - state = 1; - } // Transition to sparse - if rng.random_bool(0.9) { - 1 - } else { - (rng.random::() % 5) + 1 - } - } else { - if rng.random_bool(0.1) { - state = 0; - } // Transition to dense - (rng.random::() % 100) + 1 - }; - - current += gap; - data.push(current); - } + let data = crate::elias_fano::test_utils::generate_markov_chain_data(32 * 32, 12345); let max = *data.last().unwrap() + 1; let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index ed573ab4..74ddec6d 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -277,10 +277,45 @@ fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { best_cost } +#[cfg(test)] +pub mod test_utils { + use rand::prelude::*; + + pub fn generate_markov_chain_data(len: usize, seed: u64) -> Vec { + let mut rng = StdRng::seed_from_u64(seed); + let mut data = Vec::with_capacity(len); + let mut current = 0; + let mut state = 0; + for _ in 0..len { + let gap = if state == 0 { + if rng.random_bool(0.1) { + state = 1; + } // Transition to sparse + if rng.random_bool(0.9) { + 1 + } else { + (rng.random::() % 5) + 1 + } + } else { + if rng.random_bool(0.1) { + state = 0; + } // Transition to dense + (rng.random::() % 100) + 1 + }; + + current += gap; + data.push(current); + } + data + } +} + #[cfg(test)] mod tests { use rand::prelude::*; + use crate::elias_fano::test_utils::generate_markov_chain_data; + use super::*; #[test] @@ -299,13 +334,7 @@ mod tests { #[test] fn test_elias_fano_decoder() { - let mut rng = StdRng::seed_from_u64(12345); - let mut data = Vec::new(); - let mut current = 0; - for _ in 0..1000 { - current += (rng.random::() % 10) + 1; - data.push(current); - } + let data = generate_markov_chain_data(32 * 32, 12345); let max = *data.last().unwrap() + 1; let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); diff --git a/crates/pef/src/lib.rs b/crates/pef/src/lib.rs index fc2830e3..ba3d9460 100644 --- a/crates/pef/src/lib.rs +++ b/crates/pef/src/lib.rs @@ -3,3 +3,17 @@ mod elias_fano; pub use batch_decoder::*; pub use elias_fano::*; + +#[cfg(target_arch = "x86_64")] +pub mod avx_batch_decoder; + +/// Partitioned Elias-Fano structure. +pub struct PartitionedEliasFano { + // Implementation details to be added +} + +impl PartitionedEliasFano { + pub fn new() -> Self { + Self {} + } +} From e36a92ab4b769514cd9dba097045cf41c8cf4fca Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 25 Nov 2025 07:37:29 +0000 Subject: [PATCH 12/45] add some buffer --- crates/pef/src/avx_batch_decoder.rs | 59 +++++++++++++++++++---------- crates/pef/src/elias_fano.rs | 3 +- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/crates/pef/src/avx_batch_decoder.rs b/crates/pef/src/avx_batch_decoder.rs index f338f91f..589961c7 100644 --- a/crates/pef/src/avx_batch_decoder.rs +++ b/crates/pef/src/avx_batch_decoder.rs @@ -5,9 +5,8 @@ pub struct AvxBatchDecoder<'a> { high_bits: &'a [u64], low_bits: &'a [u64], bits_per_value: u32, - current_word: u64, - high_idx: usize, - value_idx: usize, + high_idx: u32, + value_idx: u32, } impl<'a> AvxBatchDecoder<'a> { @@ -16,7 +15,6 @@ impl<'a> AvxBatchDecoder<'a> { high_bits: &ef.high_bits, low_bits: &ef.low_bits, bits_per_value: ef.bits_per_value, - current_word: 0, high_idx: 0, value_idx: 0, } @@ -67,16 +65,39 @@ impl<'a> AvxBatchDecoder<'a> { enable = "popcnt" )] pub unsafe fn decode_batch(&mut self, output: &mut [u32]) -> usize { - // Ensure we have a non-zero current word - while self.current_word == 0 { - if self.high_idx >= self.high_bits.len() { + let mut current_word; + let mut byte_idx; + + loop { + byte_idx = self.high_idx / 8; + let bit_offset = self.high_idx % 8; + if byte_idx as usize >= self.high_bits.len() * 8 { return 0; } - self.current_word = *self.high_bits.get_unchecked(self.high_idx); - self.high_idx += 1; + let ptr = self.high_bits.as_ptr() as *const u8; + // Handle potential out-of-bounds read at the very end of the slice + if (byte_idx as usize) + 8 > self.high_bits.len() * 8 { + let mut word = 0u64; + let len = self.high_bits.len() * 8 - byte_idx as usize; + std::ptr::copy_nonoverlapping( + ptr.add(byte_idx as usize), + &mut word as *mut u64 as *mut u8, + len, + ); + current_word = word; + } else { + current_word = ptr.add(byte_idx as usize).cast::().read_unaligned(); + } + // Clear bits that have already been processed + current_word &= !0u64 << bit_offset; + if current_word != 0 { + break; + } + // Skip the zeros in this window + self.high_idx = (self.high_idx + 64) & !7; } - let k = _popcnt64(self.current_word as i64) as usize; + let k = _popcnt64(current_word as i64) as usize; let count = k.min(16); // 1. Prepare High Bits @@ -85,14 +106,14 @@ impl<'a> AvxBatchDecoder<'a> { 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, ); - let compressed_indices = _mm512_maskz_compress_epi8(self.current_word, identity); + let compressed_indices = _mm512_maskz_compress_epi8(current_word, identity); let first_16_indices = _mm512_castsi512_si128(compressed_indices); let expanded_indices = _mm512_cvtepu8_epi32(first_16_indices); // Calculate high parts: (position - rank_in_batch) + correction // correction = base_position - global_rank_start - // base_position = (high_idx - 1) * 64 - let base_position = (self.high_idx - 1) as i32 * 64; + // base_position = byte_idx * 8 + let base_position = (byte_idx * 8) as i32; let correction = base_position - self.value_idx as i32; let range_0_15 = _mm512_set_epi32(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0); @@ -104,8 +125,8 @@ impl<'a> AvxBatchDecoder<'a> { // 2. Prepare Low Bits let mut low_vals = [0i32; 16]; - let mut bit_idx = self.value_idx as u32 * self.bits_per_value; - for i in 0..count { + let mut bit_idx = self.value_idx * self.bits_per_value; + for i in 0..16 { // Inline get_bits logic for simplicity and performance let start = bit_idx / 64; let end = (bit_idx + self.bits_per_value - 1) / 64; @@ -130,7 +151,7 @@ impl<'a> AvxBatchDecoder<'a> { output[..count].copy_from_slice(&temp_output[..count]); // 5. Update State - // Find position of the last processed bit to clear bits in current_word + // Find position of the last processed bit to update high_idx // We want the value at index (count - 1) from compressed_indices. // compressed_indices contains byte indices in the lower 128 bits (since count <= 16). let lower_128 = _mm512_castsi512_si128(compressed_indices); @@ -146,10 +167,8 @@ impl<'a> AvxBatchDecoder<'a> { (val >> shift) as u32 & 0xFF }; - let mask = (!0u64 << 1) << last_pos; - self.current_word &= mask; - - self.value_idx += count; + self.high_idx = byte_idx * 8 + last_pos + 1; + self.value_idx += count as u32; count } } diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index 74ddec6d..316e3cc3 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -42,7 +42,8 @@ impl EliasFano { // The question is how to store the starting positions of each partition // most efficiently... let mut high_bits = BitStream::new(((max >> bits_per_value) + 1) + len); - let mut low_bits = BitStream::new(bits_per_value * len); + // Note: add some buffer, so that we don't have to worry about out-of-bounds reads! + let mut low_bits = BitStream::new(bits_per_value * (len + 15)); for (i, value) in iter.enumerate() { assert!(value < max); let high = value >> bits_per_value; From 04b8cffb33da07aae40ea1ccf47b1400116d16b9 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 25 Nov 2025 07:52:45 +0000 Subject: [PATCH 13/45] Almost 1GB/sec! --- crates/pef/benches/elias_fano_bench.rs | 44 +++++++++------- crates/pef/src/avx_batch_decoder.rs | 69 ++++++++++++++++++-------- 2 files changed, 74 insertions(+), 39 deletions(-) diff --git a/crates/pef/benches/elias_fano_bench.rs b/crates/pef/benches/elias_fano_bench.rs index 170b2151..7246e89c 100644 --- a/crates/pef/benches/elias_fano_bench.rs +++ b/crates/pef/benches/elias_fano_bench.rs @@ -1,28 +1,37 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; -use pef::{avx_batch_decoder::AvxBatchDecoder, EFBatchDecoder, EliasFano}; +use pef::avx_batch_decoder::{new_decoder, BatchDecoder, AvxBatchDecoder}; +use pef::{EFBatchDecoder, EliasFano}; use rand::prelude::*; fn criterion_benchmark(c: &mut Criterion) { let size = 100_000; - + // Generate random sorted data with clustered gaps using a Markov chain let mut data = Vec::with_capacity(size as usize); let mut current = 0; let mut rng = StdRng::seed_from_u64(123456789); - + // State 0: Dense cluster (small gaps, mostly 1) // State 1: Sparse region (larger gaps) - let mut state = 0; - + let mut state = 0; + for _ in 0..size { let gap = if state == 0 { - if rng.random_bool(0.1) { state = 1; } // Transition to sparse - if rng.random_bool(0.9) { 1 } else { (rng.random::() % 5) + 1 } + if rng.random_bool(0.1) { + state = 1; + } // Transition to sparse + if rng.random_bool(0.9) { + 1 + } else { + (rng.random::() % 5) + 1 + } } else { - if rng.random_bool(0.1) { state = 0; } // Transition to dense + if rng.random_bool(0.1) { + state = 0; + } // Transition to dense (rng.random::() % 100) + 1 }; - + current += gap; data.push(current); } @@ -41,16 +50,15 @@ fn criterion_benchmark(c: &mut Criterion) { { group.bench_function("avx_batch_decode", |b| { b.iter(|| { - unsafe { - let mut decoder = AvxBatchDecoder::new(&elias_fano); - let mut buffer = [0u32; 16]; - loop { - let count = decoder.decode_batch(&mut buffer); - if count == 0 { - break; - } - black_box(&buffer[..count]); + //let mut decoder = new_decoder(&elias_fano); + let mut decoder = AvxBatchDecoder::<4>::new(&elias_fano); + let mut buffer = [0u32; 16]; + loop { + let count = decoder.decode_batch(&mut buffer); + if count == 0 { + break; } + black_box(&buffer[..count]); } }) }); diff --git a/crates/pef/src/avx_batch_decoder.rs b/crates/pef/src/avx_batch_decoder.rs index 589961c7..3beccf95 100644 --- a/crates/pef/src/avx_batch_decoder.rs +++ b/crates/pef/src/avx_batch_decoder.rs @@ -1,20 +1,45 @@ use crate::elias_fano::EliasFano; use std::arch::x86_64::*; -pub struct AvxBatchDecoder<'a> { +pub trait BatchDecoder { + /// Decodes up to 16 values from the Elias-Fano representation. + /// + /// Updates internal state. + /// Writes decoded values to `output` and returns the number of values written. + /// + /// # Safety + /// This function requires AVX-512 features. + fn decode_batch(&mut self, output: &mut [u32]) -> usize; +} + +pub fn new_decoder<'a>(ef: &'a EliasFano) -> Box { + macro_rules! dispatch { + ($($b:literal),*) => { + match ef.bits_per_value { + $( $b => Box::new(AvxBatchDecoder::<$b>::new(ef)), )* + _ => panic!("Unsupported bits per value: {}", ef.bits_per_value), + } + } + } + dispatch!( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32 + ) +} + +pub struct AvxBatchDecoder<'a, const BITS: u32> { high_bits: &'a [u64], low_bits: &'a [u64], - bits_per_value: u32, high_idx: u32, value_idx: u32, } -impl<'a> AvxBatchDecoder<'a> { +impl<'a, const BITS: u32> AvxBatchDecoder<'a, BITS> { pub fn new(ef: &'a EliasFano) -> Self { + assert_eq!(ef.bits_per_value, BITS); Self { high_bits: &ef.high_bits, low_bits: &ef.low_bits, - bits_per_value: ef.bits_per_value, high_idx: 0, value_idx: 0, } @@ -25,7 +50,7 @@ impl<'a> AvxBatchDecoder<'a> { /// # Safety /// This function requires AVX-512F, AVX-512VBMI2, and AVX-512BW features. #[target_feature(enable = "avx512f", enable = "avx512vbmi2", enable = "avx512bw")] - pub unsafe fn decode(high_bits: u64, low_bits: __m512i, bits_per_value: u32) -> __m512i { + pub unsafe fn decode(high_bits: u64, low_bits: __m512i) -> __m512i { // Identity indices 0..63 for the byte positions let identity = _mm512_set_epi8( 63, 62, 61, 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, @@ -47,8 +72,7 @@ impl<'a> AvxBatchDecoder<'a> { let high_parts = _mm512_sub_epi32(expanded_indices, range_0_15); // Shift each value by bits_per_value. - let shift_count = _mm_cvtsi32_si128(bits_per_value as i32); - let shifted_high = _mm512_sll_epi32(high_parts, shift_count); + let shifted_high = _mm512_slli_epi32(high_parts, BITS); // Add the expanded low bits. _mm512_add_epi32(shifted_high, low_bits) @@ -64,7 +88,7 @@ impl<'a> AvxBatchDecoder<'a> { enable = "avx512bw", enable = "popcnt" )] - pub unsafe fn decode_batch(&mut self, output: &mut [u32]) -> usize { + pub unsafe fn decode_batch_impl(&mut self, output: &mut [u32]) -> usize { let mut current_word; let mut byte_idx; @@ -125,30 +149,27 @@ impl<'a> AvxBatchDecoder<'a> { // 2. Prepare Low Bits let mut low_vals = [0i32; 16]; - let mut bit_idx = self.value_idx * self.bits_per_value; + let mut bit_idx = self.value_idx * BITS; for i in 0..16 { // Inline get_bits logic for simplicity and performance let start = bit_idx / 64; - let end = (bit_idx + self.bits_per_value - 1) / 64; + let end = (bit_idx + BITS - 1) / 64; let offset = bit_idx % 64; let mut val = self.low_bits.get_unchecked(start as usize) >> offset; if start != end { val |= self.low_bits.get_unchecked((start + 1) as usize) << (64 - offset); } - low_vals[i] = (val as u32 & !(!0 << self.bits_per_value)) as i32; - bit_idx += self.bits_per_value; + low_vals[i] = (val as u32 & !(!0 << BITS)) as i32; + bit_idx += BITS; } let low_bits_vec = _mm512_loadu_si512(low_vals.as_ptr() as *const _); // 3. Combine - let shift_count = _mm_cvtsi32_si128(self.bits_per_value as i32); - let shifted_high = _mm512_sll_epi32(high_parts, shift_count); + let shifted_high = _mm512_slli_epi32(high_parts, BITS); let result_vec = _mm512_add_epi32(shifted_high, low_bits_vec); // 4. Store Result - let mut temp_output = [0u32; 16]; - _mm512_storeu_si512(temp_output.as_mut_ptr() as *mut _, result_vec); - output[..count].copy_from_slice(&temp_output[..count]); + _mm512_storeu_si512(output.as_mut_ptr() as *mut _, result_vec); // 5. Update State // Find position of the last processed bit to update high_idx @@ -173,6 +194,12 @@ impl<'a> AvxBatchDecoder<'a> { } } +impl<'a, const BITS: u32> BatchDecoder for AvxBatchDecoder<'a, BITS> { + fn decode_batch(&mut self, output: &mut [u32]) -> usize { + unsafe { self.decode_batch_impl(output) } + } +} + #[cfg(test)] mod tests { use crate::test_utils::generate_markov_chain_data; @@ -201,7 +228,7 @@ mod tests { let low_vals: [i32; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; let low_bits = _mm512_loadu_si512(low_vals.as_ptr() as *const _); - let bits_per_value = 3; + const BITS: u32 = 3; // Calculate expected result let mut expected = [0i32; 16]; @@ -209,11 +236,11 @@ mod tests { let p = positions[i]; let rank = i as i32; let high_part = (p as i32) - rank; - expected[i] = (high_part << bits_per_value) + low_vals[i]; + expected[i] = (high_part << BITS) + low_vals[i]; } // Decode - let result_vec = AvxBatchDecoder::<'_>::decode(high_bits, low_bits, bits_per_value); + let result_vec = AvxBatchDecoder::<'_, BITS>::decode(high_bits, low_bits); // Store result let mut result = [0i32; 16]; @@ -242,7 +269,7 @@ mod tests { let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); unsafe { - let mut decoder = AvxBatchDecoder::new(&ef); + let mut decoder = new_decoder(&ef); let mut decoded = Vec::new(); let mut buffer = [0u32; 16]; From f57f67aa04388d2a8c8c07996a0965609f88620d Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 25 Nov 2025 11:05:15 +0000 Subject: [PATCH 14/45] 1.4 billion values/sec version --- crates/pef/src/avx_batch_decoder.rs | 74 +++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/pef/src/avx_batch_decoder.rs b/crates/pef/src/avx_batch_decoder.rs index 3beccf95..2c781c3f 100644 --- a/crates/pef/src/avx_batch_decoder.rs +++ b/crates/pef/src/avx_batch_decoder.rs @@ -45,6 +45,64 @@ impl<'a, const BITS: u32> AvxBatchDecoder<'a, BITS> { } } + #[inline(always)] + unsafe fn get_low_bits(&self) -> __m512i { + if BITS == 4 { + let byte_idx = (self.value_idx / 2) as usize; + let nibble_offset = (self.value_idx & 1) as u32; // 0 or 1 (which nibble in the first byte) + + let ptr = self.low_bits.as_ptr() as *const u8; + + // We need 16 nibbles = 64 bits of data + // If nibble_offset == 0, we need exactly 8 bytes + // If nibble_offset == 1, we need 8.5 bytes (9 bytes total) + // Load 16 bytes to be safe, then combine + let w0 = (ptr.add(byte_idx) as *const u64).read_unaligned(); + let w1 = (ptr.add(byte_idx + 1) as *const u64).read_unaligned(); + + // If nibble_offset == 1, shift right by 4 bits to align + // This requires combining bits from w0 and w1 + let aligned = w0 >> (nibble_offset * 4) | w1 << (8 - nibble_offset * 4); + + // Now 'aligned' contains 16 nibbles in the lower 64 bits + // Put the 8 bytes into a vector + let v_bytes = _mm_cvtsi64_si128(aligned as i64); + + // We need to separate low and high nibbles of each byte + // Byte layout: [n1 n0] [n3 n2] [n5 n4] [n7 n6] [n9 n8] [n11 n10] [n13 n12] [n15 n14] + // We want: n0, n1, n2, n3, ..., n15 + + // Simpler: mask low nibbles and high nibbles separately, then interleave + let mask_0f = _mm_set1_epi8(0x0F); + let lo_nibbles = _mm_and_si128(v_bytes, mask_0f); // n0, n2, n4, n6, n8, n10, n12, n14 + let hi_nibbles = _mm_and_si128(_mm_srli_epi16(v_bytes, 4), mask_0f); // n1, n3, n5, n7, n9, n11, n13, n15 + + // Interleave: we want n0, n1, n2, n3, ... + // unpacklo_epi8 interleaves bytes from lo and hi + let interleaved = _mm_unpacklo_epi8(lo_nibbles, hi_nibbles); + // Result: n0, n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11, n12, n13, n14, n15 + + // Now expand 16 bytes to 16 32-bit integers + return _mm512_cvtepu8_epi32(interleaved); + } + + let mut low_vals = [0i32; 16]; + let mut bit_idx = self.value_idx * BITS; + for i in 0..16 { + // Inline get_bits logic for simplicity and performance + let start = bit_idx / 64; + let end = (bit_idx + BITS - 1) / 64; + let offset = bit_idx % 64; + let mut val = self.low_bits.get_unchecked(start as usize) >> offset; + if start != end { + val |= self.low_bits.get_unchecked((start + 1) as usize) << (64 - offset); + } + low_vals[i] = (val as u32 & !(!0 << BITS)) as i32; + bit_idx += BITS; + } + _mm512_loadu_si512(low_vals.as_ptr() as *const _) + } + /// Decodes a batch of 16 values using AVX-512 instructions. /// /// # Safety @@ -148,21 +206,7 @@ impl<'a, const BITS: u32> AvxBatchDecoder<'a, BITS> { let high_parts = _mm512_add_epi32(high_parts_base, correction_vec); // 2. Prepare Low Bits - let mut low_vals = [0i32; 16]; - let mut bit_idx = self.value_idx * BITS; - for i in 0..16 { - // Inline get_bits logic for simplicity and performance - let start = bit_idx / 64; - let end = (bit_idx + BITS - 1) / 64; - let offset = bit_idx % 64; - let mut val = self.low_bits.get_unchecked(start as usize) >> offset; - if start != end { - val |= self.low_bits.get_unchecked((start + 1) as usize) << (64 - offset); - } - low_vals[i] = (val as u32 & !(!0 << BITS)) as i32; - bit_idx += BITS; - } - let low_bits_vec = _mm512_loadu_si512(low_vals.as_ptr() as *const _); + let low_bits_vec = self.get_low_bits(); // 3. Combine let shifted_high = _mm512_slli_epi32(high_parts, BITS); From 4a569220ff911a30367b5897e4a92393d77e11a3 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 1 Dec 2025 14:24:21 +0000 Subject: [PATCH 15/45] Add intersecting iterator --- crates/pef/src/elias_fano.rs | 188 +++++++++++++++++++++++++---------- 1 file changed, 137 insertions(+), 51 deletions(-) diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index 316e3cc3..9ba4540d 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -158,7 +158,9 @@ impl<'a> Iterator for EliasFanoDecoder<'a> { current_word >>= 1; self.current_word = current_word; let bucket_id = bit_pos + zeros - self.value_idx; - let value = self.low_bits.get_bits(self.value_idx * self.bits_per_value, self.bits_per_value); + let value = self + .low_bits + .get_bits(self.value_idx * self.bits_per_value, self.bits_per_value); self.bit_pos = bit_pos + zeros + 1; self.value_idx += 1; if self.bit_pos & 63 == 0 { @@ -175,59 +177,53 @@ impl<'a> Iterator for EliasFanoDecoder<'a> { } } -struct IntersectingIterator<'a, I> { - iter: I, +impl<'a> EliasFanoDecoder<'a> { + /// Skips forward to the given target value. + /// Returns `Some(value)` if the target exists in the sequence, + /// or `Some(next_value)` where `next_value > target` is the next value in the sequence, + /// or `None` if there are no more values >= target. + pub fn skip_to(&mut self, target: u32) -> Option { + while let Some(value) = self.next() { + if value >= target { + return Some(value); + } + } + None + } + + /// Returns an iterator that yields values present in both this sequence + /// and the provided iterator. Both must yield values in sorted order. + pub fn intersect>(self, other: I) -> Intersection<'a, I> { + Intersection { + ef_decoder: self, + other, + } + } +} - bits_per_value: u32, - high_bits: BitReader<'a>, - low_bits: BitReader<'a>, - value_idx: u32, - bit_pos: u32, - current_word: u64, +pub struct Intersection<'a, I> { + ef_decoder: EliasFanoDecoder<'a>, + other: I, } -impl<'a, I: Iterator> Iterator for IntersectingIterator<'a, I> { +impl<'a, I: Iterator> Iterator for Intersection<'a, I> { type Item = u32; fn next(&mut self) -> Option { - let mut current_word = self.current_word; - let mut bit_pos = self.bit_pos; - while let Some(next_value) = self.iter.next() { - loop { - let zeros = current_word.trailing_zeros(); - if zeros < 64 { - current_word >>= zeros; - current_word >>= 1; - self.current_word = current_word; - let bucket_id = bit_pos + zeros - self.value_idx; - // FIXME: subtract value_idx from values everywhere! - let lower_bound = (bucket_id << self.bits_per_value); // + self.value_idx; - let upper_bound = lower_bound + (1 << self.bits_per_value); - bit_pos += zeros + 1; - self.value_idx += 1; - if next_value >= upper_bound { - // keep decoding - } else if next_value >= lower_bound { - // potential match - let value = self - .low_bits - .get_bits((self.value_idx - 1) * self.bits_per_value, self.bits_per_value); - let value = value | (bucket_id << self.bits_per_value); - if value == next_value { - // found it! - self.bit_pos = bit_pos; - return Some(value); - } - } else { - // not found. go to next value. - break; - } - } else if bit_pos + 64 >= self.high_bits.len() { - return None; - } else { - current_word = self.high_bits.get_word(bit_pos / 64 + 1); - bit_pos = (bit_pos / 64 + 1) * 64; - } + let mut ef_val = None; + while let Some(other_val) = self.other.next() { + if Some(other_val) < ef_val { + continue; + } else if Some(other_val) == ef_val { + // Found a match + return ef_val; + } + ef_val = self.ef_decoder.skip_to(other_val); + if ef_val == None { + return None; + } else if ef_val == Some(other_val) { + // Found a match + return ef_val; } } None @@ -333,18 +329,108 @@ mod tests { } } - #[test] + #[test] fn test_elias_fano_decoder() { let data = generate_markov_chain_data(32 * 32, 12345); let max = *data.last().unwrap() + 1; let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); - + let decoder = ef.iter(); let decoded = decoder.collect::>(); - + assert_eq!(decoded, data); } + #[test] + fn test_intersect_basic() { + let data1 = vec![1, 5, 10, 15, 20, 100, 200, 500]; + let data2 = vec![2, 5, 10, 12, 20, 50, 200, 300, 500]; + + let max = 501; + let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); + + let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); + assert_eq!(intersection, vec![5, 10, 20, 200, 500]); + } + + #[test] + fn test_intersect_empty_result() { + let data1 = vec![1, 3, 5, 7]; + let data2 = vec![2, 4, 6, 8]; + + let max = 10; + let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); + + let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); + assert!(intersection.is_empty()); + } + + #[test] + fn test_intersect_all_match() { + let data = vec![1, 5, 10, 15, 20]; + + let max = 21; + let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); + + let intersection: Vec = ef.iter().intersect(data.clone().into_iter()).collect(); + assert_eq!(intersection, data); + } + + #[test] + fn test_intersect_two_ef() { + let data1 = generate_markov_chain_data(1000, 11111); + let data2 = generate_markov_chain_data(500, 22222); + + let max1 = *data1.last().unwrap() + 1; + let max2 = *data2.last().unwrap() + 1; + + let ef1 = EliasFano::new(data1.iter().copied(), max1, data1.len() as u32); + let ef2 = EliasFano::new(data2.iter().copied(), max2, data2.len() as u32); + + // Compute expected intersection + let set1: std::collections::HashSet = data1.iter().copied().collect(); + let expected: Vec = data2.iter().copied().filter(|v| set1.contains(v)).collect(); + + let intersection: Vec = ef1.iter().intersect(ef2.iter()).collect(); + assert_eq!(intersection, expected); + } + + #[test] + fn test_intersect_first_empty() { + let data1: Vec = vec![]; + let data2 = vec![1, 2, 3]; + + let max = 10; + let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); + + let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); + assert!(intersection.is_empty()); + } + + #[test] + fn test_intersect_second_empty() { + let data1 = vec![1, 2, 3]; + let data2: Vec = vec![]; + + let max = 10; + let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); + + let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); + assert!(intersection.is_empty()); + } + + #[test] + fn test_intersect_large_gap() { + let data1 = vec![1, 1000000]; + let data2 = vec![1, 500000, 1000000]; + + let max = 1000001; + let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); + + let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); + assert_eq!(intersection, vec![1, 1000000]); + } + #[test] fn test_optimal_bits() { for i in 1..100 { From 5e41049a87e4ab766fd936591dba57c45f744806 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 5 Dec 2025 11:28:09 +0100 Subject: [PATCH 16/45] Create helper.rs --- crates/pef/src/helper.rs | 91 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 crates/pef/src/helper.rs diff --git a/crates/pef/src/helper.rs b/crates/pef/src/helper.rs new file mode 100644 index 00000000..4c518986 --- /dev/null +++ b/crates/pef/src/helper.rs @@ -0,0 +1,91 @@ +#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] +use std::arch::x86_64::*; + +/// Transform the Elias-Fano high-bits mask into a bitmask which can be used for +/// simple AND intersection before decoding low-bits. +/// At the end of the AND operation, one knows which buckets are still relevant +/// for further decoding. +#[inline(always)] +fn transform_mask(mask: u64) -> u64 { + unsafe { + // We extract bits from (mask >> 1) based on the selector (!mask) + _pext_u64(mask >> 1, !mask) + } +} + +/// Constructs the Elias-Fano high-bits mask for 16 values. +/// +/// `high_parts`: A ZMM register containing 16 `u32` high-part values (bucket indices). +/// Returns: A `u64` bitmask where the bit at `i + high_parts[i]` is set. +/// +/// Note: This assumes `i + high_parts[i] < 64`. If the data is very sparse, +/// you may need to handle offsets or multiple words. +#[inline(always)] +pub unsafe fn construct_high_bits_avx512(high_parts: __m512i) -> u64 { + // 1. Add the implicit index (0..15) to the high parts + // The i-th set bit belongs at position: i + high_parts[i] + let increment = _mm512_setr_epi32( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 + ); + let bit_indices = _mm512_add_epi32(high_parts, increment); + + // 2. We need to shift '1' by these indices. + // Since _mm512_sllv_epi32 only works within 32-bit lanes (masking shift to 5 bits), + // and our indices can be > 31, we must promote to 64-bit integers to use _mm512_sllv_epi64. + + // Split into low and high halves (8 integers each) and promote to u64 + let idx_lo = _mm512_cvtepu32_epi64(_mm512_castsi512_si256(bit_indices)); + let idx_hi = _mm512_cvtepu32_epi64(_mm512_extracti32x8_epi32(bit_indices, 1)); + + // 3. Create bitmasks: 1 << index + let one = _mm512_set1_epi64(1); + let bits_lo = _mm512_sllv_epi64(one, idx_lo); + let bits_hi = _mm512_sllv_epi64(one, idx_hi); + + // 4. Combine all 16 bitmasks into one register + let combined = _mm512_or_si512(bits_lo, bits_hi); + + // 5. Horizontal OR to reduce the 8 u64 lanes into a single u64 scalar + _mm512_reduce_or_epi64(combined) +} + +/// Given the Elias-Fano high-bits sequence and a mask of interesting buckets, +/// returns a bitmask of the values that belong to the interesting buckets. +/// +/// `high_bits`: The Elias-Fano high-bits sequence (1 = value, 0 = bucket boundary). +/// `bucket_mask`: A bitmask where bit `k` is set if bucket `k` is interesting. +/// +/// Returns: A bitmask where bit `i` is set if the value at position `i` in `high_bits` +/// belongs to an interesting bucket. +#[inline(always)] +#[cfg(all(target_arch = "x86_64", target_feature = "bmi2"))] +pub fn filter_values_by_bucket(high_bits: u64, bucket_mask: u64) -> u64 { + unsafe { + #[cfg(not(target_feature = "avx512f"))] + use std::arch::x86_64::_pdep_u64; + + // 1. Identify the start of each bucket's value run. + // The start of a run is marked by a 0->1 transition in high_bits, or index 0. + // By shifting high_bits left by 1, we align the '0' separators + // with the start of the *next* bucket. + // Inverting gives us 1s at these start positions. + let starts = !(high_bits << 1); + + // 2. Distribute the bucket_mask to these start positions. + // If bucket k is set in bucket_mask, the k-th set bit in 'starts' + // (which is the start of bucket k) gets a 1. + let mask_at_starts = _pdep_u64(bucket_mask, starts); + + // 3. Propagate the mask through the runs of ones. + // Adding the start bit to the run of ones causes a carry ripple + // that turns the run of 1s into 0s (and sets the next 0 to 1). + // Example: 111 + 1 = 1000. + let sum = high_bits.wrapping_add(mask_at_starts); + + // 4. Recover the original runs. + // The runs that were added to are now 0s. Inverting makes them 1s. + // The runs that were NOT added to are still 1s. Inverting makes them 0s. + // ANDing with the original high_bits selects only the runs that were inverted (selected). + (!sum) & high_bits + } +} \ No newline at end of file From 79f028e8d419c0a30f42e06422b6bc77beb745fb Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 09:04:21 +0100 Subject: [PATCH 17/45] run encoding on real data --- crates/pef/Cargo.toml | 4 + crates/pef/src/bin/encode_pisa.rs | 225 ++++++++++++++++++++++++++++++ crates/pef/src/elias_fano.rs | 23 +++ 3 files changed, 252 insertions(+) create mode 100644 crates/pef/src/bin/encode_pisa.rs diff --git a/crates/pef/Cargo.toml b/crates/pef/Cargo.toml index e8d01b46..a32b41cb 100644 --- a/crates/pef/Cargo.toml +++ b/crates/pef/Cargo.toml @@ -20,6 +20,10 @@ rand = { version = "0.9", optional = true } criterion = "0.5" rand = "0.9" +[[bin]] +name = "encode_pisa" +path = "src/bin/encode_pisa.rs" + [[bench]] name = "elias_fano_bench" harness = false diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs new file mode 100644 index 00000000..ee12fcdd --- /dev/null +++ b/crates/pef/src/bin/encode_pisa.rs @@ -0,0 +1,225 @@ +//! Binary to encode PISA posting lists with Elias-Fano encoding. +//! +//! Usage: +//! cargo run --release --bin encode_pisa -- [--output ] +//! +//! The PISA format is: +//! - Header: 2 x u32 (typically [1, num_documents]) +//! - For each posting list: +//! - u32: length of the posting list +//! - length x u32: document IDs (d-gaps, i.e., delta encoded) + +use std::env; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::time::Instant; + +use pef::EliasFano; + +/// Read a u32 from a reader (little-endian) +fn read_u32(reader: &mut R) -> std::io::Result { + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + Ok(u32::from_le_bytes(buf)) +} + +/// Read a posting list from PISA format +fn read_posting_list(reader: &mut R) -> std::io::Result>> { + let mut len_buf = [0u8; 4]; + match reader.read_exact(&mut len_buf) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + } + let len = u32::from_le_bytes(len_buf) as usize; + + let mut postings = vec![0u32; len]; + for posting in &mut postings { + *posting = read_u32(reader)?; + } + Ok(Some(postings)) +} + +/// Statistics about the encoding +#[derive(Default)] +struct Stats { + num_lists: u64, + total_postings: u64, + total_input_bytes: u64, + total_high_bits: u64, + total_low_bits: u64, + lists_by_size: [u64; 7], // 1, 2-10, 11-100, 101-1000, 1001-10000, 10001-100000, >100000 +} + +impl Stats { + fn add_list(&mut self, len: usize, ef: &EliasFano) { + self.num_lists += 1; + self.total_postings += len as u64; + self.total_input_bytes += (len * 4) as u64; + self.total_high_bits += (ef.high_bits().len() * 8) as u64; + self.total_low_bits += (ef.low_bits().len() * 8) as u64; + + let bucket = match len { + 1 => 0, + 2..=10 => 1, + 11..=100 => 2, + 101..=1000 => 3, + 1001..=10000 => 4, + 10001..=100000 => 5, + _ => 6, + }; + self.lists_by_size[bucket] += 1; + } + + fn print(&self) { + println!("\n=== Encoding Statistics ==="); + println!("Total posting lists: {:>12}", self.num_lists); + println!("Total postings: {:>12}", self.total_postings); + println!(); + println!("List size distribution:"); + let buckets = ["1", "2-10", "11-100", "101-1K", "1K-10K", "10K-100K", ">100K"]; + for (i, &name) in buckets.iter().enumerate() { + let count = self.lists_by_size[i]; + let pct = 100.0 * count as f64 / self.num_lists as f64; + println!(" {:>10}: {:>10} ({:>5.1}%)", name, count, pct); + } + println!(); + println!("Space usage:"); + println!( + " Input (raw u32): {:>10} bytes ({:>6.2} MB)", + self.total_input_bytes, + self.total_input_bytes as f64 / 1_000_000.0 + ); + let ef_total = self.total_high_bits + self.total_low_bits; + println!( + " EF high bits: {:>10} bytes ({:>6.2} MB)", + self.total_high_bits, + self.total_high_bits as f64 / 1_000_000.0 + ); + println!( + " EF low bits: {:>10} bytes ({:>6.2} MB)", + self.total_low_bits, + self.total_low_bits as f64 / 1_000_000.0 + ); + println!( + " EF total: {:>10} bytes ({:>6.2} MB)", + ef_total, + ef_total as f64 / 1_000_000.0 + ); + println!(); + let compression_ratio = self.total_input_bytes as f64 / ef_total as f64; + let bits_per_posting = (ef_total * 8) as f64 / self.total_postings as f64; + println!("Compression ratio: {:>10.2}x", compression_ratio); + println!("Bits per posting: {:>10.2}", bits_per_posting); + } +} + +fn main() -> std::io::Result<()> { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: {} [--output ]", args[0]); + eprintln!(); + eprintln!("Encodes PISA posting lists with Elias-Fano encoding."); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --output Write encoded data to file (optional)"); + std::process::exit(1); + } + + let input_path = &args[1]; + let output_path = args + .iter() + .position(|x| x == "--output") + .map(|i| args.get(i + 1)) + .flatten(); + + println!("Reading PISA posting lists from: {}", input_path); + + let file = File::open(input_path)?; + let mut reader = BufReader::with_capacity(1024 * 1024, file); + + // Read header + let header1 = read_u32(&mut reader)?; + let header2 = read_u32(&mut reader)?; + println!("Header: [{}, {}]", header1, header2); + println!("Number of documents: {}", header2); + + let mut stats = Stats::default(); + let mut encoded_data: Vec = Vec::new(); + + let start = Instant::now(); + let mut last_report = Instant::now(); + + // Read and encode all posting lists + while let Some(postings) = read_posting_list(&mut reader)? { + if postings.is_empty() { + continue; + } + + // PISA format stores absolute doc IDs (already sorted) + // The max value is the last ID (since they're sorted) + // Add 1 because EliasFano expects exclusive max + let max = postings.last().copied().unwrap_or(0) + 1; + let len = postings.len() as u32; + + // Encode with Elias-Fano + let ef = EliasFano::new(postings.iter().copied(), max, len); + stats.add_list(postings.len(), &ef); + + if output_path.is_some() { + encoded_data.push(ef); + } + + // Progress report every second + if last_report.elapsed().as_secs() >= 1 { + print!( + "\rProcessed {:>10} lists, {:>12} postings...", + stats.num_lists, stats.total_postings + ); + std::io::stdout().flush()?; + last_report = Instant::now(); + } + } + + let elapsed = start.elapsed(); + println!( + "\rProcessed {:>10} lists, {:>12} postings in {:.2}s", + stats.num_lists, + stats.total_postings, + elapsed.as_secs_f64() + ); + + stats.print(); + + // Optionally write encoded data + if let Some(output) = output_path { + println!("\nWriting encoded data to: {}", output); + let file = File::create(output)?; + let mut writer = BufWriter::new(file); + + // Simple format: for each posting list: + // - u32: bits_per_value + // - u32: high_bits length (in u64s) + // - u32: low_bits length (in u64s) + // - high_bits data + // - low_bits data + + for ef in &encoded_data { + writer.write_all(&ef.bits_per_value().to_le_bytes())?; + writer.write_all(&(ef.high_bits().len() as u32).to_le_bytes())?; + writer.write_all(&(ef.low_bits().len() as u32).to_le_bytes())?; + for &word in ef.high_bits() { + writer.write_all(&word.to_le_bytes())?; + } + for &word in ef.low_bits() { + writer.write_all(&word.to_le_bytes())?; + } + } + + writer.flush()?; + println!("Done!"); + } + + Ok(()) +} diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index 9ba4540d..effeba72 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -14,6 +14,9 @@ impl BitStream { } fn set_bits(&mut self, index: u32, value: u32, bits: u32) { + if bits == 0 { + return; + } let start = index / 64; let end = (index + bits - 1) / 64; self.data[start as usize] |= (value as u64) << (index % 64); @@ -69,6 +72,26 @@ impl EliasFano { bit_pos: 0, } } + + /// Returns the number of bits used for encoding low bits of each value. + pub fn bits_per_value(&self) -> u32 { + self.bits_per_value + } + + /// Returns a reference to the high bits data. + pub fn high_bits(&self) -> &[u64] { + &self.high_bits + } + + /// Returns a reference to the low bits data. + pub fn low_bits(&self) -> &[u64] { + &self.low_bits + } + + /// Returns the size in bytes of the encoded data. + pub fn size_in_bytes(&self) -> usize { + (self.high_bits.len() + self.low_bits.len()) * 8 + } } pub struct EliasFanoDecoder<'a> { From e51ca63f18e1bcc2c83a98efcda78c78a9e28bac Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 09:56:18 +0100 Subject: [PATCH 18/45] add vbyte for comparison --- crates/pef/src/bin/encode_pisa.rs | 151 +++++++++++++++--------------- crates/pef/src/elias_fano.rs | 2 +- 2 files changed, 77 insertions(+), 76 deletions(-) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index ee12fcdd..5c2f12a9 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -11,10 +11,10 @@ use std::env; use std::fs::File; -use std::io::{BufReader, BufWriter, Read, Write}; +use std::io::{BufReader, Read, Write}; use std::time::Instant; -use pef::EliasFano; +use pef::optimal_bits_per_value; /// Read a u32 from a reader (little-endian) fn read_u32(reader: &mut R) -> std::io::Result { @@ -46,18 +46,69 @@ struct Stats { num_lists: u64, total_postings: u64, total_input_bytes: u64, - total_high_bits: u64, - total_low_bits: u64, + total_ef_bytes: u64, + total_vbyte_bytes: u64, lists_by_size: [u64; 7], // 1, 2-10, 11-100, 101-1000, 1001-10000, 10001-100000, >100000 } +fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { + let mut start = 0; + let mut total = 0; + const LIMIT: u32 = 8 * 8; + while start < postings.len() { + let mut end = start + 1; + let mut next_bits = 0; + while end < postings.len() { + let max = postings[end] - postings[start] + 1; + let len = (end - start) as u32; + next_bits = 4 + optimal_bits_per_value(max - len, len).0; + if next_bits > LIMIT { + next_bits = LIMIT; + break; + } + end += 1; + } + total += next_bits; + start = end; + } + total +} + +/// Compute VByte encoded size for a posting list (d-gaps) +fn vbyte_size(postings: &[u32]) -> u64 { + if postings.is_empty() { + return 0; + } + let mut total = 0u64; + let mut prev = 0u32; + for &doc in postings { + let gap = doc - prev; + prev = doc; + // VByte: 7 bits per byte, continue bit in MSB + total += match gap { + 0..=127 => 1, + 128..=16383 => 2, + 16384..=2097151 => 3, + 2097152..=268435455 => 4, + _ => 5, + }; + } + total +} + impl Stats { - fn add_list(&mut self, len: usize, ef: &EliasFano) { + fn add_list(&mut self, postings: &[u32]) { + let len = postings.len(); self.num_lists += 1; self.total_postings += len as u64; self.total_input_bytes += (len * 4) as u64; - self.total_high_bits += (ef.high_bits().len() * 8) as u64; - self.total_low_bits += (ef.low_bits().len() * 8) as u64; + + // Compute Elias-Fano encoding size + let ef_bits = grouped_elias_fano_bits(postings); + self.total_ef_bytes += ef_bits.div_ceil(8) as u64; + + // Compute VByte encoding size + self.total_vbyte_bytes += vbyte_size(postings); let bucket = match len { 1 => 0, @@ -77,7 +128,9 @@ impl Stats { println!("Total postings: {:>12}", self.total_postings); println!(); println!("List size distribution:"); - let buckets = ["1", "2-10", "11-100", "101-1K", "1K-10K", "10K-100K", ">100K"]; + let buckets = [ + "1", "2-10", "11-100", "101-1K", "1K-10K", "10K-100K", ">100K", + ]; for (i, &name) in buckets.iter().enumerate() { let count = self.lists_by_size[i]; let pct = 100.0 * count as f64 / self.num_lists as f64; @@ -90,27 +143,23 @@ impl Stats { self.total_input_bytes, self.total_input_bytes as f64 / 1_000_000.0 ); - let ef_total = self.total_high_bits + self.total_low_bits; println!( - " EF high bits: {:>10} bytes ({:>6.2} MB)", - self.total_high_bits, - self.total_high_bits as f64 / 1_000_000.0 + " Elias-Fano: {:>10} bytes ({:>6.2} MB)", + self.total_ef_bytes, + self.total_ef_bytes as f64 / 1_000_000.0 ); println!( - " EF low bits: {:>10} bytes ({:>6.2} MB)", - self.total_low_bits, - self.total_low_bits as f64 / 1_000_000.0 - ); - println!( - " EF total: {:>10} bytes ({:>6.2} MB)", - ef_total, - ef_total as f64 / 1_000_000.0 + " VByte: {:>10} bytes ({:>6.2} MB)", + self.total_vbyte_bytes, + self.total_vbyte_bytes as f64 / 1_000_000.0 ); println!(); - let compression_ratio = self.total_input_bytes as f64 / ef_total as f64; - let bits_per_posting = (ef_total * 8) as f64 / self.total_postings as f64; - println!("Compression ratio: {:>10.2}x", compression_ratio); - println!("Bits per posting: {:>10.2}", bits_per_posting); + let ef_ratio = self.total_input_bytes as f64 / self.total_ef_bytes as f64; + let vbyte_ratio = self.total_input_bytes as f64 / self.total_vbyte_bytes as f64; + let ef_bits = (self.total_ef_bytes * 8) as f64 / self.total_postings as f64; + let vbyte_bits = (self.total_vbyte_bytes * 8) as f64 / self.total_postings as f64; + println!("Elias-Fano: {:>5.2}x compression, {:>5.2} bits/posting", ef_ratio, ef_bits); + println!("VByte: {:>5.2}x compression, {:>5.2} bits/posting", vbyte_ratio, vbyte_bits); } } @@ -118,21 +167,13 @@ fn main() -> std::io::Result<()> { let args: Vec = env::args().collect(); if args.len() < 2 { - eprintln!("Usage: {} [--output ]", args[0]); + eprintln!("Usage: {} ", args[0]); eprintln!(); eprintln!("Encodes PISA posting lists with Elias-Fano encoding."); - eprintln!(); - eprintln!("Options:"); - eprintln!(" --output Write encoded data to file (optional)"); std::process::exit(1); } let input_path = &args[1]; - let output_path = args - .iter() - .position(|x| x == "--output") - .map(|i| args.get(i + 1)) - .flatten(); println!("Reading PISA posting lists from: {}", input_path); @@ -146,7 +187,6 @@ fn main() -> std::io::Result<()> { println!("Number of documents: {}", header2); let mut stats = Stats::default(); - let mut encoded_data: Vec = Vec::new(); let start = Instant::now(); let mut last_report = Instant::now(); @@ -157,19 +197,9 @@ fn main() -> std::io::Result<()> { continue; } - // PISA format stores absolute doc IDs (already sorted) - // The max value is the last ID (since they're sorted) - // Add 1 because EliasFano expects exclusive max - let max = postings.last().copied().unwrap_or(0) + 1; - let len = postings.len() as u32; - // Encode with Elias-Fano - let ef = EliasFano::new(postings.iter().copied(), max, len); - stats.add_list(postings.len(), &ef); - - if output_path.is_some() { - encoded_data.push(ef); - } + // let ef = EliasFano::new(postings.iter().copied(), max, len); + stats.add_list(&postings); // Progress report every second if last_report.elapsed().as_secs() >= 1 { @@ -192,34 +222,5 @@ fn main() -> std::io::Result<()> { stats.print(); - // Optionally write encoded data - if let Some(output) = output_path { - println!("\nWriting encoded data to: {}", output); - let file = File::create(output)?; - let mut writer = BufWriter::new(file); - - // Simple format: for each posting list: - // - u32: bits_per_value - // - u32: high_bits length (in u64s) - // - u32: low_bits length (in u64s) - // - high_bits data - // - low_bits data - - for ef in &encoded_data { - writer.write_all(&ef.bits_per_value().to_le_bytes())?; - writer.write_all(&(ef.high_bits().len() as u32).to_le_bytes())?; - writer.write_all(&(ef.low_bits().len() as u32).to_le_bytes())?; - for &word in ef.high_bits() { - writer.write_all(&word.to_le_bytes())?; - } - for &word in ef.low_bits() { - writer.write_all(&word.to_le_bytes())?; - } - } - - writer.flush()?; - println!("Done!"); - } - Ok(()) } diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs index effeba72..f99a7863 100644 --- a/crates/pef/src/elias_fano.rs +++ b/crates/pef/src/elias_fano.rs @@ -286,7 +286,7 @@ impl<'a, I: Iterator> Iterator for Intersection<'a, I> { // TODO: Implement test presence of a value. This requires the rank/select stuff... // -fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { +pub fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { let mut best_cost = (u32::MAX, 0); for bits in 0..32 { let cost = bits * len as u32 + ((max >> bits) + 1) + len; From 16973f1019d0354874c454ba7fc5c57b0915d7ac Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 10:03:01 +0100 Subject: [PATCH 19/45] add bitpacking --- crates/pef/src/bin/encode_pisa.rs | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index 5c2f12a9..7c4bb316 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -48,6 +48,8 @@ struct Stats { total_input_bytes: u64, total_ef_bytes: u64, total_vbyte_bytes: u64, + total_bp128_bytes: u64, + total_bp32_bytes: u64, lists_by_size: [u64; 7], // 1, 2-10, 11-100, 101-1000, 1001-10000, 10001-100000, >100000 } @@ -74,6 +76,43 @@ fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { total } +/// Compute bit-packed encoded size for a posting list (d-gaps) +/// Packs `group_size` integers at a time with bit-packing. +/// Each block uses: 1 byte for bit-width + group_size * bit_width / 8 bytes +/// Leftover integers are stored as raw u32. +fn bitpacked_size(postings: &[u32], group_size: usize) -> u64 { + if postings.is_empty() { + return 0; + } + + let mut total = 0u64; + let mut prev = 0u32; + let mut gaps = Vec::with_capacity(postings.len()); + + // Compute d-gaps + for &doc in postings { + gaps.push(doc - prev); + prev = doc; + } + + // Process blocks of group_size + let mut i = 0; + while i + group_size <= gaps.len() { + let block = &gaps[i..i + group_size]; + let max_gap = block.iter().copied().max().unwrap_or(0); + let bit_width = if max_gap == 0 { 0 } else { 32 - max_gap.leading_zeros() }; + // 1 byte for bit-width descriptor + packed data + total += 1 + (group_size as u64 * bit_width as u64).div_ceil(8); + i += group_size; + } + + // Leftover integers stored as raw u32 + let leftover = gaps.len() - i; + total += (leftover * 4) as u64; + + total +} + /// Compute VByte encoded size for a posting list (d-gaps) fn vbyte_size(postings: &[u32]) -> u64 { if postings.is_empty() { @@ -110,6 +149,12 @@ impl Stats { // Compute VByte encoding size self.total_vbyte_bytes += vbyte_size(postings); + // Compute BP128 encoding size + self.total_bp128_bytes += bitpacked_size(postings, 128); + + // Compute BP32 encoding size + self.total_bp32_bytes += bitpacked_size(postings, 32); + let bucket = match len { 1 => 0, 2..=10 => 1, @@ -153,13 +198,29 @@ impl Stats { self.total_vbyte_bytes, self.total_vbyte_bytes as f64 / 1_000_000.0 ); + println!( + " BP128: {:>10} bytes ({:>6.2} MB)", + self.total_bp128_bytes, + self.total_bp128_bytes as f64 / 1_000_000.0 + ); + println!( + " BP32: {:>10} bytes ({:>6.2} MB)", + self.total_bp32_bytes, + self.total_bp32_bytes as f64 / 1_000_000.0 + ); println!(); let ef_ratio = self.total_input_bytes as f64 / self.total_ef_bytes as f64; let vbyte_ratio = self.total_input_bytes as f64 / self.total_vbyte_bytes as f64; + let bp128_ratio = self.total_input_bytes as f64 / self.total_bp128_bytes as f64; + let bp32_ratio = self.total_input_bytes as f64 / self.total_bp32_bytes as f64; let ef_bits = (self.total_ef_bytes * 8) as f64 / self.total_postings as f64; let vbyte_bits = (self.total_vbyte_bytes * 8) as f64 / self.total_postings as f64; + let bp128_bits = (self.total_bp128_bytes * 8) as f64 / self.total_postings as f64; + let bp32_bits = (self.total_bp32_bytes * 8) as f64 / self.total_postings as f64; println!("Elias-Fano: {:>5.2}x compression, {:>5.2} bits/posting", ef_ratio, ef_bits); println!("VByte: {:>5.2}x compression, {:>5.2} bits/posting", vbyte_ratio, vbyte_bits); + println!("BP128: {:>5.2}x compression, {:>5.2} bits/posting", bp128_ratio, bp128_bits); + println!("BP32: {:>5.2}x compression, {:>5.2} bits/posting", bp32_ratio, bp32_bits); } } From ba02311a6a923f36b45aaabe6e48f795668cd513 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 11:36:07 +0100 Subject: [PATCH 20/45] remove bin --- crates/pef/Cargo.toml | 8 + crates/pef/src/bin/reorder_docids.rs | 494 +++++++++++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 crates/pef/src/bin/reorder_docids.rs diff --git a/crates/pef/Cargo.toml b/crates/pef/Cargo.toml index a32b41cb..a6301b67 100644 --- a/crates/pef/Cargo.toml +++ b/crates/pef/Cargo.toml @@ -15,6 +15,9 @@ bench = false [dependencies] itertools = "0.14" rand = { version = "0.9", optional = true } +geo_filters = { path = "../geo_filters", optional = true } +ordered-float = { version = "4", optional = true } +rayon = { version = "1.10", optional = true } [dev-dependencies] criterion = "0.5" @@ -24,6 +27,11 @@ rand = "0.9" name = "encode_pisa" path = "src/bin/encode_pisa.rs" +[[bin]] +name = "reorder_docids" +path = "src/bin/reorder_docids.rs" +required-features = ["geo_filters", "ordered-float", "rayon"] + [[bench]] name = "elias_fano_bench" harness = false diff --git a/crates/pef/src/bin/reorder_docids.rs b/crates/pef/src/bin/reorder_docids.rs new file mode 100644 index 00000000..46058473 --- /dev/null +++ b/crates/pef/src/bin/reorder_docids.rs @@ -0,0 +1,494 @@ +//! Reorder document IDs by similarity using geometric filters and MST. +//! +//! Usage: +//! cargo run --release --bin reorder_docids --features "geo_filters ordered-float rayon" -- +//! +//! This reads the inverted index, builds GeoDiffCount filters for each document, +//! computes an approximate MST based on document similarity, and outputs a mapping +//! from old doc IDs to new doc IDs sorted by similarity (DFS order of the MST). +//! +//! The algorithm uses multiple random projections (masks) to create approximate +//! MSTs via cmp_masked, then merges them for a better approximation. + +use std::env; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::time::Instant; + +use geo_filters::diff_count::GeoDiffCount7; +use geo_filters::Count; +use ordered_float::OrderedFloat; +use rayon::prelude::*; + +type GeoFilter = GeoDiffCount7<'static>; + +/// Mask size for GeoDiffConfig7 (7 bits) +const MASK_SIZE: usize = 7; + +/// Read a u32 from a reader (little-endian) +fn read_u32(reader: &mut R) -> std::io::Result { + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + Ok(u32::from_le_bytes(buf)) +} + +/// Read a posting list from PISA format +fn read_posting_list(reader: &mut R) -> std::io::Result>> { + let mut len_buf = [0u8; 4]; + match reader.read_exact(&mut len_buf) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + } + let len = u32::from_le_bytes(len_buf) as usize; + + let mut postings = vec![0u32; len]; + for posting in &mut postings { + *posting = read_u32(reader)?; + } + Ok(Some(postings)) +} + +// ============================================================================ +// Union-Find data structure +// ============================================================================ + +struct UnionFind { + parent: Vec, + size: Vec, +} + +impl UnionFind { + fn new(size: u32) -> Self { + let parent = (0..size).collect(); + Self { + parent, + size: vec![1; size as usize], + } + } + + fn find(&mut self, mut idx: u32) -> u32 { + while self.parent[idx as usize] != idx { + let parent = self.parent[idx as usize]; + let grand_parent = self.parent[parent as usize]; + self.parent[idx as usize] = grand_parent; + idx = grand_parent; + } + idx + } + + fn unite(&mut self, a: u32, b: u32) -> bool { + let mut a = self.find(a); + let mut b = self.find(b); + if a == b { + false + } else { + if self.size[a as usize] < self.size[b as usize] { + std::mem::swap(&mut a, &mut b); + } + self.parent[b as usize] = a; + self.size[a as usize] += self.size[b as usize]; + true + } + } +} + +// ============================================================================ +// MST Edge +// ============================================================================ + +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct Edge { + cost: OrderedFloat, + from: u32, + to: u32, +} + +// ============================================================================ +// MST Algorithm (Kruskal's) +// ============================================================================ + +fn build_mst(size: usize, mut edges: Vec) -> Vec { + let mut mst = Vec::with_capacity(size); + let mut uf = UnionFind::new(size as u32); + + edges.par_sort_unstable_by_key(|x| x.cost); + + for edge in edges { + if uf.unite(edge.from, edge.to) { + mst.push(edge); + } + } + mst +} + +// ============================================================================ +// Approximate MST using multiple masked sortings (from blackbird) +// ============================================================================ + +/// Create edges between documents within a sliding window after sorting. +/// Uses the optimized approach: pre-allocate and write in parallel. +fn create_edges_in_window( + filters: &[GeoFilter], + sorted: &[u32], + window_size: usize, +) -> Vec { + let len = sorted.len() * window_size; + let mut edges = Vec::::with_capacity(len); + + // Use spare_capacity_mut to avoid initializing edges we'll overwrite + edges.spare_capacity_mut()[..len] + .par_iter_mut() + .enumerate() + .for_each(|(k, edge)| { + let i = k / window_size; + let j = (k % window_size) + 1; + let from = sorted[i]; + let to = sorted[(i + j) % sorted.len()]; + let a = &filters[from as usize]; + let b = &filters[to as usize]; + let cost = a.size_with_sketch_f32(b).into(); + edge.write(Edge { from, to, cost }); + }); + + unsafe { + edges.set_len(len); + } + edges +} + +/// Return all bit masks with exactly `ones` bits set among `num` least significant bits. +fn mask_patterns(num: usize, ones: usize) -> Vec { + (0..(1usize << num)) + .filter(move |pattern| pattern.count_ones() == ones as u32) + .collect() +} + +/// Merge multiple MSTs by combining their edges and rebuilding. +fn merge_msts(num_nodes: usize, mut msts: Vec>) -> Vec { + while msts.len() > 1 { + msts = msts + .par_chunks(2) + .map(|chunk| { + let edges: Vec = chunk.iter().flatten().copied().collect(); + build_mst(num_nodes, edges) + }) + .collect(); + } + msts.pop().unwrap_or_default() +} + +/// Parallelise a function over a slice with limited parallelism. +/// Important when each invocation consumes significant memory. +fn parallelise(f: F, indices: &[usize], parallelism: usize) -> Vec +where + R: Send + Sync + Default + Clone, + F: Fn(usize) -> R + Send + Sync, +{ + let next = AtomicUsize::new(0); + let results = std::sync::Mutex::new(vec![R::default(); indices.len()]); + + rayon::scope(|scope| { + for _ in 0..parallelism { + scope.spawn(|_| loop { + let idx = next.fetch_add(1, AtomicOrdering::Relaxed); + if idx >= indices.len() { + break; + } + let r = f(indices[idx]); + results.lock().unwrap()[idx] = r; + }); + } + }); + + results.into_inner().unwrap() +} + +/// Compute multiple approximate MSTs using different mask patterns. +/// Each mask creates a different projection for sorting via cmp_masked. +fn compute_multiple_approximate_msts( + filters: &[GeoFilter], + masks: &[usize], + window_size: usize, +) -> Vec> { + let indices: Vec = (0..masks.len()).collect(); + + parallelise( + |idx| { + let mask = masks[idx]; + + // Sort documents by masked filter comparison + let mut sorted: Vec = (0..filters.len() as u32).collect(); + sorted.par_sort_unstable_by(|&a, &b| { + filters[a as usize].cmp_masked( + &filters[b as usize], + mask as u64, + MASK_SIZE, + ) + }); + + // Create edges in window + let edges = create_edges_in_window(filters, &sorted, window_size); + + // Build MST for this projection + build_mst(filters.len(), edges) + }, + &indices, + 4, // Limit parallelism to avoid memory issues + ) +} + +/// Compute approximate MST using multiple masked sortings. +fn compute_approximate_mst( + filters: &[GeoFilter], + window_size: usize, + num_mask_bits: usize, +) -> Vec { + // Generate all mask patterns with num_mask_bits/2 bits set + let masks = mask_patterns(MASK_SIZE, num_mask_bits); + println!(" Using {} mask patterns with {} bits set...", masks.len(), num_mask_bits); + + println!(" Computing {} approximate MSTs with window size {}...", masks.len(), window_size); + let msts = compute_multiple_approximate_msts(filters, &masks, window_size); + + println!(" Merging {} MSTs...", msts.len()); + merge_msts(filters.len(), msts) +} + +// ============================================================================ +// Tree traversal for document ordering +// ============================================================================ + +/// Convert MST edges to adjacency list representation. +fn mst_to_adjacency(num_nodes: usize, mst: &[Edge]) -> Vec> { + let mut adj: Vec> = vec![vec![]; num_nodes]; + for edge in mst { + adj[edge.from as usize].push(edge.to); + adj[edge.to as usize].push(edge.from); + } + adj +} + +/// DFS traversal of the MST to produce document ordering. +/// Documents visited close together in DFS order are similar. +fn dfs_order(adj: &[Vec], start: u32) -> Vec { + let n = adj.len(); + let mut visited = vec![false; n]; + let mut order = Vec::with_capacity(n); + let mut stack = vec![start]; + + while let Some(node) = stack.pop() { + if visited[node as usize] { + continue; + } + visited[node as usize] = true; + order.push(node); + + // Add neighbors in reverse order so they're visited in forward order + for &neighbor in adj[node as usize].iter().rev() { + if !visited[neighbor as usize] { + stack.push(neighbor); + } + } + } + + // Handle disconnected components (shouldn't happen with proper MST) + for i in 0..n { + if !visited[i] { + order.push(i as u32); + } + } + + order +} + +/// Compute the mapping from old doc IDs to new doc IDs. +fn compute_docid_mapping(dfs_order: &[u32]) -> Vec { + let mut mapping = vec![0u32; dfs_order.len()]; + for (new_id, &old_id) in dfs_order.iter().enumerate() { + mapping[old_id as usize] = new_id as u32; + } + mapping +} + +// ============================================================================ +// Main +// ============================================================================ + +fn main() -> std::io::Result<()> { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: {} [window_size] [mask_bits]", args[0]); + eprintln!(); + eprintln!("Reorders document IDs by similarity using MST with masked sorting."); + eprintln!("Outputs a mapping file next to the input."); + eprintln!(); + eprintln!("Arguments:"); + eprintln!(" window_size - Number of neighbors to consider (default: 32)"); + eprintln!(" mask_bits - Number of bits set in masks (default: 3, max: {})", MASK_SIZE); + std::process::exit(1); + } + + let input_path = &args[1]; + let window_size: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(32); + let mask_bits: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(3); + + println!("Reading PISA posting lists from: {}", input_path); + println!("Window size: {}", window_size); + println!("Mask bits: {} (generates {} masks)", mask_bits, mask_patterns(MASK_SIZE, mask_bits).len()); + + let file = File::open(input_path)?; + let mut reader = BufReader::with_capacity(1024 * 1024, file); + + // Read header + let header1 = read_u32(&mut reader)?; + let header2 = read_u32(&mut reader)?; + println!("Header: [{}, {}]", header1, header2); + let num_documents = header2 as usize; + println!("Number of documents: {}", num_documents); + + // ======================================================================== + // Step 1: Build geometric filters for each document + // ======================================================================== + println!("\n=== Building document filters ==="); + let mut filters: Vec = (0..num_documents) + .map(|_| GeoFilter::default()) + .collect(); + + let start = Instant::now(); + let mut last_report = Instant::now(); + let mut term_id = 0u64; + let mut total_postings = 0u64; + + while let Some(postings) = read_posting_list(&mut reader)? { + for &doc_id in &postings { + filters[doc_id as usize].push(term_id); + } + + total_postings += postings.len() as u64; + term_id += 1; + + if last_report.elapsed().as_secs() >= 1 { + print!( + "\rProcessed {:>10} terms, {:>12} postings...", + term_id, total_postings + ); + std::io::stdout().flush()?; + last_report = Instant::now(); + } + } + + let elapsed = start.elapsed(); + println!( + "\rProcessed {:>10} terms, {:>12} postings in {:.2}s", + term_id, total_postings, elapsed.as_secs_f64() + ); + + // ======================================================================== + // Step 2: Compute approximate MST using multiple masked sortings + // ======================================================================== + println!("\n=== Computing approximate MST ==="); + let start = Instant::now(); + let mst = compute_approximate_mst(&filters, window_size, mask_bits); + let elapsed = start.elapsed(); + println!("MST computed in {:.2}s ({} edges)", elapsed.as_secs_f64(), mst.len()); + + // ======================================================================== + // Step 3: DFS traversal to get document ordering + // ======================================================================== + println!("\n=== Computing document ordering ==="); + let adj = mst_to_adjacency(num_documents, &mst); + + // Start from document 0 (or the largest document) + let start_doc = 0u32; + let order = dfs_order(&adj, start_doc); + let mapping = compute_docid_mapping(&order); + + // ======================================================================== + // Step 4: Write mapping to file + // ======================================================================== + let output_path = Path::new(input_path).with_extension("docid_mapping"); + println!("\n=== Writing mapping to {} ===", output_path.display()); + + let output_file = File::create(&output_path)?; + let mut writer = BufWriter::new(output_file); + + // Write as binary: array of u32 where mapping[old_id] = new_id + for &new_id in &mapping { + writer.write_all(&new_id.to_le_bytes())?; + } + writer.flush()?; + + let file_size = std::fs::metadata(&output_path)?.len(); + println!( + "Written {} bytes ({:.2} MB)", + file_size, + file_size as f64 / 1_000_000.0 + ); + + // ======================================================================== + // Statistics + // ======================================================================== + println!("\n=== Reordering Statistics ==="); + println!("Total documents: {:>12}", num_documents); + println!("MST edges: {:>12}", mst.len()); + + // Sample some mappings + println!("\nSample mappings (old -> new):"); + for old_id in [0, 1, 100, 1000, 10000, 100000, 500000].iter() { + if (*old_id as usize) < num_documents { + println!(" Doc {:>6} -> {:>6}", old_id, mapping[*old_id as usize]); + } + } + + // Compute average MST edge cost + let mst_sum: f64 = mst.iter().map(|e| e.cost.0 as f64).sum(); + if !mst.is_empty() { + let avg_cost = mst_sum / mst.len() as f64; + let min_cost = mst.iter().map(|e| e.cost.0).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0); + let max_cost = mst.iter().map(|e| e.cost.0).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0); + println!("\nMST edge costs:"); + println!(" Sum: {:>15.2}", mst_sum); + println!(" Min: {:>15.2}", min_cost); + println!(" Avg: {:>15.2}", avg_cost); + println!(" Max: {:>15.2}", max_cost); + } + + // ======================================================================== + // Compute sum of differences between consecutive documents + // ======================================================================== + println!("\n=== Consecutive Document Similarity ==="); + + // Before remapping: consecutive docs are 0,1,2,3,... + // Sum of size_with_sketch(doc[i], doc[i+1]) for original order + let sum_before: f64 = (0..num_documents - 1) + .into_par_iter() + .map(|i| filters[i].size_with_sketch_f32(&filters[i + 1]) as f64) + .sum(); + + // After remapping: consecutive docs follow DFS order + // Sum of size_with_sketch for new consecutive pairs + let sum_after: f64 = (0..order.len() - 1) + .into_par_iter() + .map(|i| { + let doc_a = order[i] as usize; + let doc_b = order[i + 1] as usize; + filters[doc_a].size_with_sketch_f32(&filters[doc_b]) as f64 + }) + .sum(); + + let avg_before = sum_before / (num_documents - 1) as f64; + let avg_after = sum_after / (order.len() - 1) as f64; + let improvement = (avg_before - avg_after) / avg_before * 100.0; + + println!("Sum of consecutive diffs (before): {:>15.2}", sum_before); + println!("Sum of consecutive diffs (after): {:>15.2}", sum_after); + println!("Avg consecutive diff (before): {:>15.2}", avg_before); + println!("Avg consecutive diff (after): {:>15.2}", avg_after); + println!("Improvement: {:>14.1}%", improvement); + + Ok(()) +} From fa563f7b22dd0290a985331392045884f2e4d025 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 11:56:34 +0100 Subject: [PATCH 21/45] mapping --- crates/pef/src/bin/encode_pisa.rs | 61 +++++++++++++++- crates/pef/src/bin/reorder_docids.rs | 103 +++++++++++++++++++++++++-- 2 files changed, 157 insertions(+), 7 deletions(-) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index 7c4bb316..f38faf69 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -1,17 +1,21 @@ //! Binary to encode PISA posting lists with Elias-Fano encoding. //! //! Usage: -//! cargo run --release --bin encode_pisa -- [--output ] +//! cargo run --release --bin encode_pisa -- [--remap ] //! //! The PISA format is: //! - Header: 2 x u32 (typically [1, num_documents]) //! - For each posting list: //! - u32: length of the posting list //! - length x u32: document IDs (d-gaps, i.e., delta encoded) +//! +//! The optional --remap argument specifies a binary file containing a mapping +//! from old doc IDs to new doc IDs (generated by reorder_docids). use std::env; use std::fs::File; use std::io::{BufReader, Read, Write}; +use std::path::Path; use std::time::Instant; use pef::optimal_bits_per_value; @@ -135,6 +139,30 @@ fn vbyte_size(postings: &[u32]) -> u64 { total } +/// Load a docid mapping file (binary array of u32) +fn load_mapping>(path: P) -> std::io::Result> { + let file = File::open(path)?; + let file_len = file.metadata()?.len() as usize; + let num_docs = file_len / 4; + + let mut reader = BufReader::new(file); + let mut mapping = vec![0u32; num_docs]; + + for m in &mut mapping { + *m = read_u32(&mut reader)?; + } + + Ok(mapping) +} + +/// Remap a posting list using the provided mapping, then sort. +fn remap_posting_list(postings: &mut Vec, mapping: &[u32]) { + for doc in postings.iter_mut() { + *doc = mapping[*doc as usize]; + } + postings.sort_unstable(); +} + impl Stats { fn add_list(&mut self, postings: &[u32]) { let len = postings.len(); @@ -228,15 +256,37 @@ fn main() -> std::io::Result<()> { let args: Vec = env::args().collect(); if args.len() < 2 { - eprintln!("Usage: {} ", args[0]); + eprintln!("Usage: {} [--remap ]", args[0]); eprintln!(); eprintln!("Encodes PISA posting lists with Elias-Fano encoding."); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --remap Remap document IDs using the specified mapping file"); + eprintln!(" (generated by reorder_docids)"); std::process::exit(1); } let input_path = &args[1]; + + // Parse optional --remap argument + let remap_path = args.iter() + .position(|a| a == "--remap") + .and_then(|i| args.get(i + 1)); + + // Load mapping if provided + let mapping = if let Some(path) = remap_path { + println!("Loading docid mapping from: {}", path); + let m = load_mapping(path)?; + println!("Loaded mapping for {} documents", m.len()); + Some(m) + } else { + None + }; println!("Reading PISA posting lists from: {}", input_path); + if mapping.is_some() { + println!("Document IDs will be remapped before encoding"); + } let file = File::open(input_path)?; let mut reader = BufReader::with_capacity(1024 * 1024, file); @@ -253,11 +303,16 @@ fn main() -> std::io::Result<()> { let mut last_report = Instant::now(); // Read and encode all posting lists - while let Some(postings) = read_posting_list(&mut reader)? { + while let Some(mut postings) = read_posting_list(&mut reader)? { if postings.is_empty() { continue; } + // Remap document IDs if mapping is provided + if let Some(ref m) = mapping { + remap_posting_list(&mut postings, m); + } + // Encode with Elias-Fano // let ef = EliasFano::new(postings.iter().copied(), max, len); stats.add_list(&postings); diff --git a/crates/pef/src/bin/reorder_docids.rs b/crates/pef/src/bin/reorder_docids.rs index 46058473..371ca3c9 100644 --- a/crates/pef/src/bin/reorder_docids.rs +++ b/crates/pef/src/bin/reorder_docids.rs @@ -313,6 +313,84 @@ fn compute_docid_mapping(dfs_order: &[u32]) -> Vec { mapping } +// ============================================================================ +// Sliding Window Refinement +// ============================================================================ + +/// Try to improve by swapping adjacent pairs if it reduces cost. +fn improve_window_adjacent(filters: &[GeoFilter], window: &mut [u32]) -> bool { + if window.len() < 3 { + return false; + } + + let mut improved = false; + + // Try swapping adjacent elements (bubble-sort style) + for i in 1..window.len() - 1 { + // Cost of current arrangement: (i-1,i) + (i,i+1) + (i+1,i+2 if exists) + // vs swapped: (i-1,i+1) + (i+1,i) + (i,i+2 if exists) + + let a = window[i - 1] as usize; + let b = window[i] as usize; + let c = window[i + 1] as usize; + + let cost_current = filters[a].size_with_sketch_f32(&filters[b]) + + filters[b].size_with_sketch_f32(&filters[c]); + let cost_swapped = filters[a].size_with_sketch_f32(&filters[c]) + + filters[c].size_with_sketch_f32(&filters[b]); + + // Also consider the next pair if it exists + if i + 2 < window.len() { + let d = window[i + 2] as usize; + let cost_current = cost_current + filters[c].size_with_sketch_f32(&filters[d]); + let cost_swapped = cost_swapped + filters[b].size_with_sketch_f32(&filters[d]); + + if cost_swapped < cost_current { + window.swap(i, i + 1); + improved = true; + } + } else if cost_swapped < cost_current { + window.swap(i, i + 1); + improved = true; + } + } + + improved +} + +/// Parallel refinement: divide into chunks and refine each chunk using adjacent swaps. +fn refine_ordering_parallel(filters: &[GeoFilter], order: &mut [u32], window_size: usize) { + let n = order.len(); + if n < window_size { + return; + } + + // Multiple passes over the entire array + let mut total_improvements = 0; + + for pass in 1..=10 { + let mut improvements = 0u64; + + // Slide window and try adjacent swaps + for start in 0..n.saturating_sub(window_size - 1) { + let end = (start + window_size).min(n); + if improve_window_adjacent(filters, &mut order[start..end]) { + improvements += 1; + } + } + + total_improvements += improvements; + print!("\r Pass {}: {} improvements", pass, improvements); + std::io::stdout().flush().ok(); + + if improvements == 0 { + break; + } + } + + println!("\r Completed with {} total improvements", total_improvements); +} + // ============================================================================ // Main // ============================================================================ @@ -321,24 +399,29 @@ fn main() -> std::io::Result<()> { let args: Vec = env::args().collect(); if args.len() < 2 { - eprintln!("Usage: {} [window_size] [mask_bits]", args[0]); + eprintln!("Usage: {} [window_size] [mask_bits] [refine_window]", args[0]); eprintln!(); eprintln!("Reorders document IDs by similarity using MST with masked sorting."); eprintln!("Outputs a mapping file next to the input."); eprintln!(); eprintln!("Arguments:"); - eprintln!(" window_size - Number of neighbors to consider (default: 32)"); - eprintln!(" mask_bits - Number of bits set in masks (default: 3, max: {})", MASK_SIZE); + eprintln!(" window_size - Number of neighbors to consider for MST (default: 32)"); + eprintln!(" mask_bits - Number of bits set in masks (default: 3, max: {})", MASK_SIZE); + eprintln!(" refine_window - Window size for local refinement (default: 0 = disabled)"); std::process::exit(1); } let input_path = &args[1]; let window_size: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(32); let mask_bits: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(3); + let refine_window: usize = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(0); println!("Reading PISA posting lists from: {}", input_path); println!("Window size: {}", window_size); println!("Mask bits: {} (generates {} masks)", mask_bits, mask_patterns(MASK_SIZE, mask_bits).len()); + if refine_window > 0 { + println!("Refinement window: {}", refine_window); + } let file = File::open(input_path)?; let mut reader = BufReader::with_capacity(1024 * 1024, file); @@ -404,7 +487,19 @@ fn main() -> std::io::Result<()> { // Start from document 0 (or the largest document) let start_doc = 0u32; - let order = dfs_order(&adj, start_doc); + let mut order = dfs_order(&adj, start_doc); + + // ======================================================================== + // Step 3b: Local refinement using sliding window + // ======================================================================== + if refine_window > 0 { + println!("\n=== Refining ordering with window size {} ===", refine_window); + let start = Instant::now(); + refine_ordering_parallel(&filters, &mut order, refine_window); + let elapsed = start.elapsed(); + println!("Refinement completed in {:.2}s", elapsed.as_secs_f64()); + } + let mapping = compute_docid_mapping(&order); // ======================================================================== From 0cbd36faf14fa477bb6987a3c135d29a548db7c2 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 12:00:56 +0100 Subject: [PATCH 22/45] Update reorder_docids.rs --- crates/pef/src/bin/reorder_docids.rs | 64 ++++++++++++++++------------ 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/crates/pef/src/bin/reorder_docids.rs b/crates/pef/src/bin/reorder_docids.rs index 371ca3c9..f64243c7 100644 --- a/crates/pef/src/bin/reorder_docids.rs +++ b/crates/pef/src/bin/reorder_docids.rs @@ -317,48 +317,56 @@ fn compute_docid_mapping(dfs_order: &[u32]) -> Vec { // Sliding Window Refinement // ============================================================================ -/// Try to improve by swapping adjacent pairs if it reduces cost. -fn improve_window_adjacent(filters: &[GeoFilter], window: &mut [u32]) -> bool { +/// Compute the cost contribution of an element at position i in the window. +/// This is the sum of distances to its neighbors (i-1 and i+1 if they exist). +#[inline] +fn element_cost(filters: &[GeoFilter], window: &[u32], i: usize) -> f32 { + let mut cost = 0.0f32; + let doc = window[i] as usize; + if i > 0 { + cost += filters[window[i - 1] as usize].size_with_sketch_f32(&filters[doc]); + } + if i + 1 < window.len() { + cost += filters[doc].size_with_sketch_f32(&filters[window[i + 1] as usize]); + } + cost +} + +/// Try to improve by exchanging any pair of elements if it reduces total cost. +/// This is more thorough than adjacent swaps and can escape local minima. +fn improve_window_pairwise(filters: &[GeoFilter], window: &mut [u32]) -> bool { if window.len() < 3 { return false; } let mut improved = false; - // Try swapping adjacent elements (bubble-sort style) + // Try all pairs of positions (not just adjacent) for i in 1..window.len() - 1 { - // Cost of current arrangement: (i-1,i) + (i,i+1) + (i+1,i+2 if exists) - // vs swapped: (i-1,i+1) + (i+1,i) + (i,i+2 if exists) - - let a = window[i - 1] as usize; - let b = window[i] as usize; - let c = window[i + 1] as usize; - - let cost_current = filters[a].size_with_sketch_f32(&filters[b]) - + filters[b].size_with_sketch_f32(&filters[c]); - let cost_swapped = filters[a].size_with_sketch_f32(&filters[c]) - + filters[c].size_with_sketch_f32(&filters[b]); - - // Also consider the next pair if it exists - if i + 2 < window.len() { - let d = window[i + 2] as usize; - let cost_current = cost_current + filters[c].size_with_sketch_f32(&filters[d]); - let cost_swapped = cost_swapped + filters[b].size_with_sketch_f32(&filters[d]); + for j in i + 1..window.len() - 1 { + // Compute cost before swap (only affected edges) + let cost_before = element_cost(filters, window, i) + element_cost(filters, window, j); + + // Swap elements + window.swap(i, j); + + // Compute cost after swap + let cost_after = element_cost(filters, window, i) + element_cost(filters, window, j); - if cost_swapped < cost_current { - window.swap(i, i + 1); + if cost_after < cost_before { + // Keep the swap improved = true; + } else { + // Swap back + window.swap(i, j); } - } else if cost_swapped < cost_current { - window.swap(i, i + 1); - improved = true; } } improved } -/// Parallel refinement: divide into chunks and refine each chunk using adjacent swaps. +/// Parallel refinement: divide into chunks and refine each chunk using pairwise exchange. fn refine_ordering_parallel(filters: &[GeoFilter], order: &mut [u32], window_size: usize) { let n = order.len(); if n < window_size { @@ -371,10 +379,10 @@ fn refine_ordering_parallel(filters: &[GeoFilter], order: &mut [u32], window_siz for pass in 1..=10 { let mut improvements = 0u64; - // Slide window and try adjacent swaps + // Slide window and try pairwise exchanges for start in 0..n.saturating_sub(window_size - 1) { let end = (start + window_size).min(n); - if improve_window_adjacent(filters, &mut order[start..end]) { + if improve_window_pairwise(filters, &mut order[start..end]) { improvements += 1; } } From 50307a82e6a0b7fbcee3b77d53e9a058e3802c50 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 12:33:07 +0100 Subject: [PATCH 23/45] Update encode_pisa.rs --- crates/pef/src/bin/encode_pisa.rs | 132 ++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 15 deletions(-) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index f38faf69..79699cf9 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -12,6 +12,7 @@ //! The optional --remap argument specifies a binary file containing a mapping //! from old doc IDs to new doc IDs (generated by reorder_docids). +use std::collections::HashMap; use std::env; use std::fs::File; use std::io::{BufReader, Read, Write}; @@ -51,21 +52,28 @@ struct Stats { total_postings: u64, total_input_bytes: u64, total_ef_bytes: u64, + total_gef_bytes: u64, total_vbyte_bytes: u64, total_bp128_bytes: u64, total_bp32_bytes: u64, lists_by_size: [u64; 7], // 1, 2-10, 11-100, 101-1000, 1001-10000, 10001-100000, >100000 + // For entropy calculation + dgap_counts: HashMap, + total_dgaps: u64, } +/// Compute grouped Elias-Fano encoding size (partitioned EF). fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { let mut start = 0; let mut total = 0; - const LIMIT: u32 = 8 * 8; + const LIMIT: u32 = 32 * 8; + let mut offset = 0; + let mut skiplist = vec![]; while start < postings.len() { let mut end = start + 1; let mut next_bits = 0; while end < postings.len() { - let max = postings[end] - postings[start] + 1; + let max = postings[end] - offset + 1; let len = (end - start) as u32; next_bits = 4 + optimal_bits_per_value(max - len, len).0; if next_bits > LIMIT { @@ -74,10 +82,17 @@ fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { } end += 1; } + offset = postings[end - 1]; total += next_bits; start = end; + skiplist.push(offset); } total + + optimal_bits_per_value( + skiplist.last().copied().unwrap_or_default() + 1 - skiplist.len() as u32, + skiplist.len() as u32, + ) + .0 } /// Compute bit-packed encoded size for a posting list (d-gaps) @@ -104,7 +119,11 @@ fn bitpacked_size(postings: &[u32], group_size: usize) -> u64 { while i + group_size <= gaps.len() { let block = &gaps[i..i + group_size]; let max_gap = block.iter().copied().max().unwrap_or(0); - let bit_width = if max_gap == 0 { 0 } else { 32 - max_gap.leading_zeros() }; + let bit_width = if max_gap == 0 { + 0 + } else { + 32 - max_gap.leading_zeros() + }; // 1 byte for bit-width descriptor + packed data total += 1 + (group_size as u64 * bit_width as u64).div_ceil(8); i += group_size; @@ -144,14 +163,14 @@ fn load_mapping>(path: P) -> std::io::Result> { let file = File::open(path)?; let file_len = file.metadata()?.len() as usize; let num_docs = file_len / 4; - + let mut reader = BufReader::new(file); let mut mapping = vec![0u32; num_docs]; - + for m in &mut mapping { *m = read_u32(&mut reader)?; } - + Ok(mapping) } @@ -170,10 +189,18 @@ impl Stats { self.total_postings += len as u64; self.total_input_bytes += (len * 4) as u64; - // Compute Elias-Fano encoding size - let ef_bits = grouped_elias_fano_bits(postings); + // Compute standard Elias-Fano encoding size + let ef_bits = optimal_bits_per_value( + postings.last().copied().unwrap_or_default() + 1 - len as u32, + len as u32, + ) + .0; self.total_ef_bytes += ef_bits.div_ceil(8) as u64; + // Compute grouped Elias-Fano encoding size + let gef_bits = grouped_elias_fano_bits(postings); + self.total_gef_bytes += gef_bits.div_ceil(8) as u64; + // Compute VByte encoding size self.total_vbyte_bytes += vbyte_size(postings); @@ -183,6 +210,15 @@ impl Stats { // Compute BP32 encoding size self.total_bp32_bytes += bitpacked_size(postings, 32); + // Track d-gap frequencies for entropy calculation + let mut prev = 0u32; + for &doc in postings { + let gap = doc - prev; + *self.dgap_counts.entry(gap).or_insert(0) += 1; + self.total_dgaps += 1; + prev = doc; + } + let bucket = match len { 1 => 0, 2..=10 => 1, @@ -195,10 +231,30 @@ impl Stats { self.lists_by_size[bucket] += 1; } + /// Compute Shannon entropy of d-gaps in bits per symbol. + fn compute_entropy(&self) -> f64 { + if self.total_dgaps == 0 { + return 0.0; + } + + let n = self.total_dgaps as f64; + let mut entropy = 0.0; + + for &count in self.dgap_counts.values() { + if count > 0 { + let p = count as f64 / n; + entropy -= p * p.log2(); + } + } + + entropy + } + fn print(&self) { println!("\n=== Encoding Statistics ==="); println!("Total posting lists: {:>12}", self.num_lists); println!("Total postings: {:>12}", self.total_postings); + println!("Unique d-gap values: {:>12}", self.dgap_counts.len()); println!(); println!("List size distribution:"); let buckets = [ @@ -221,6 +277,11 @@ impl Stats { self.total_ef_bytes, self.total_ef_bytes as f64 / 1_000_000.0 ); + println!( + " Grouped EF: {:>10} bytes ({:>6.2} MB)", + self.total_gef_bytes, + self.total_gef_bytes as f64 / 1_000_000.0 + ); println!( " VByte: {:>10} bytes ({:>6.2} MB)", self.total_vbyte_bytes, @@ -237,18 +298,58 @@ impl Stats { self.total_bp32_bytes as f64 / 1_000_000.0 ); println!(); + + // Compute entropy + let entropy = self.compute_entropy(); + let entropy_bytes = (entropy * self.total_postings as f64 / 8.0) as u64; + let ef_ratio = self.total_input_bytes as f64 / self.total_ef_bytes as f64; + let gef_ratio = self.total_input_bytes as f64 / self.total_gef_bytes as f64; let vbyte_ratio = self.total_input_bytes as f64 / self.total_vbyte_bytes as f64; let bp128_ratio = self.total_input_bytes as f64 / self.total_bp128_bytes as f64; let bp32_ratio = self.total_input_bytes as f64 / self.total_bp32_bytes as f64; let ef_bits = (self.total_ef_bytes * 8) as f64 / self.total_postings as f64; + let gef_bits = (self.total_gef_bytes * 8) as f64 / self.total_postings as f64; let vbyte_bits = (self.total_vbyte_bytes * 8) as f64 / self.total_postings as f64; let bp128_bits = (self.total_bp128_bytes * 8) as f64 / self.total_postings as f64; let bp32_bits = (self.total_bp32_bytes * 8) as f64 / self.total_postings as f64; - println!("Elias-Fano: {:>5.2}x compression, {:>5.2} bits/posting", ef_ratio, ef_bits); - println!("VByte: {:>5.2}x compression, {:>5.2} bits/posting", vbyte_ratio, vbyte_bits); - println!("BP128: {:>5.2}x compression, {:>5.2} bits/posting", bp128_ratio, bp128_bits); - println!("BP32: {:>5.2}x compression, {:>5.2} bits/posting", bp32_ratio, bp32_bits); + + println!("Compression (bits/posting):"); + println!( + " Entropy: {:>5.2} bits/posting (theoretical lower bound, {:.2} MB)", + entropy, + entropy_bytes as f64 / 1_000_000.0 + ); + println!( + " Elias-Fano:{:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", + ef_bits, + ef_ratio, + entropy / ef_bits * 100.0 + ); + println!( + " Grouped EF:{:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", + gef_bits, + gef_ratio, + entropy / gef_bits * 100.0 + ); + println!( + " VByte: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", + vbyte_bits, + vbyte_ratio, + entropy / vbyte_bits * 100.0 + ); + println!( + " BP128: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", + bp128_bits, + bp128_ratio, + entropy / bp128_bits * 100.0 + ); + println!( + " BP32: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", + bp32_bits, + bp32_ratio, + entropy / bp32_bits * 100.0 + ); } } @@ -267,12 +368,13 @@ fn main() -> std::io::Result<()> { } let input_path = &args[1]; - + // Parse optional --remap argument - let remap_path = args.iter() + let remap_path = args + .iter() .position(|a| a == "--remap") .and_then(|i| args.get(i + 1)); - + // Load mapping if provided let mapping = if let Some(path) = remap_path { println!("Loading docid mapping from: {}", path); From 6dd118c68db50dbcc82c82eee0ae12d62204bdae Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 12:47:37 +0100 Subject: [PATCH 24/45] Update encode_pisa.rs --- crates/pef/src/bin/encode_pisa.rs | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index 79699cf9..4812d10d 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -53,6 +53,7 @@ struct Stats { total_input_bytes: u64, total_ef_bytes: u64, total_gef_bytes: u64, + total_pef_bytes: u64, total_vbyte_bytes: u64, total_bp128_bytes: u64, total_bp32_bytes: u64, @@ -95,6 +96,56 @@ fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { .0 } +/// Compute partitioned Elias-Fano encoding size. +/// Uses fixed-size partitions (like PISA's PEF implementation). +/// Each partition stores elements using standard EF relative to partition start. +/// Partition endpoints are stored in a top-level EF structure. +fn partitioned_elias_fano_bits(postings: &[u32], partition_size: usize) -> u64 { + if postings.is_empty() { + return 0; + } + if postings.len() <= partition_size { + // Single partition - just use standard EF + return optimal_bits_per_value( + postings.last().copied().unwrap_or_default() + 1 - postings.len() as u32, + postings.len() as u32, + ) + .0 as u64; + } + + let num_partitions = postings.len().div_ceil(partition_size); + let mut total_bits: u64 = 0; + let mut partition_endpoints = Vec::with_capacity(num_partitions); + + // Encode each partition + for (i, chunk) in postings.chunks(partition_size).enumerate() { + let base = if i == 0 { 0 } else { postings[i * partition_size - 1] + 1 }; + let max_in_partition = chunk.last().copied().unwrap_or(base) - base; + let n = chunk.len() as u32; + + // Standard EF for this partition: n * (2 + log2(u/n)) where u = max - base + 1 + let partition_bits = optimal_bits_per_value(max_in_partition + 1 - n, n).0; + total_bits += partition_bits as u64; + + // Store endpoint for skip list + partition_endpoints.push(*chunk.last().unwrap()); + } + + // Add skip list overhead (endpoints encoded with EF) + let skip_bits = optimal_bits_per_value( + partition_endpoints.last().copied().unwrap_or_default() + 1 - num_partitions as u32, + num_partitions as u32, + ) + .0; + total_bits += skip_bits as u64; + + // Add partition size metadata (log2(partition_size) bits per partition boundary) + // In practice, PISA uses fixed sizes so this is minimal + total_bits += (num_partitions * 8) as u64; // ~1 byte per partition for metadata + + total_bits +} + /// Compute bit-packed encoded size for a posting list (d-gaps) /// Packs `group_size` integers at a time with bit-packing. /// Each block uses: 1 byte for bit-width + group_size * bit_width / 8 bytes @@ -201,6 +252,10 @@ impl Stats { let gef_bits = grouped_elias_fano_bits(postings); self.total_gef_bytes += gef_bits.div_ceil(8) as u64; + // Compute partitioned Elias-Fano encoding size (64 elements per partition) + let pef_bits = partitioned_elias_fano_bits(postings, 64); + self.total_pef_bytes += pef_bits.div_ceil(8); + // Compute VByte encoding size self.total_vbyte_bytes += vbyte_size(postings); @@ -277,6 +332,11 @@ impl Stats { self.total_ef_bytes, self.total_ef_bytes as f64 / 1_000_000.0 ); + println!( + " Partitioned EF: {:>10} bytes ({:>6.2} MB)", + self.total_pef_bytes, + self.total_pef_bytes as f64 / 1_000_000.0 + ); println!( " Grouped EF: {:>10} bytes ({:>6.2} MB)", self.total_gef_bytes, @@ -304,11 +364,13 @@ impl Stats { let entropy_bytes = (entropy * self.total_postings as f64 / 8.0) as u64; let ef_ratio = self.total_input_bytes as f64 / self.total_ef_bytes as f64; + let pef_ratio = self.total_input_bytes as f64 / self.total_pef_bytes as f64; let gef_ratio = self.total_input_bytes as f64 / self.total_gef_bytes as f64; let vbyte_ratio = self.total_input_bytes as f64 / self.total_vbyte_bytes as f64; let bp128_ratio = self.total_input_bytes as f64 / self.total_bp128_bytes as f64; let bp32_ratio = self.total_input_bytes as f64 / self.total_bp32_bytes as f64; let ef_bits = (self.total_ef_bytes * 8) as f64 / self.total_postings as f64; + let pef_bits = (self.total_pef_bytes * 8) as f64 / self.total_postings as f64; let gef_bits = (self.total_gef_bytes * 8) as f64 / self.total_postings as f64; let vbyte_bits = (self.total_vbyte_bytes * 8) as f64 / self.total_postings as f64; let bp128_bits = (self.total_bp128_bytes * 8) as f64 / self.total_postings as f64; @@ -326,6 +388,12 @@ impl Stats { ef_ratio, entropy / ef_bits * 100.0 ); + println!( + " Part. EF: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", + pef_bits, + pef_ratio, + entropy / pef_bits * 100.0 + ); println!( " Grouped EF:{:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", gef_bits, From e05a3266d6c09f5a6b71fb2a1db7a559b080b3fc Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Wed, 10 Dec 2025 14:17:18 +0100 Subject: [PATCH 25/45] Update encode_pisa.rs --- crates/pef/src/bin/encode_pisa.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index 4812d10d..bbbdb451 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -74,6 +74,7 @@ fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { let mut end = start + 1; let mut next_bits = 0; while end < postings.len() { + offset = postings[start]; let max = postings[end] - offset + 1; let len = (end - start) as u32; next_bits = 4 + optimal_bits_per_value(max - len, len).0; @@ -116,10 +117,15 @@ fn partitioned_elias_fano_bits(postings: &[u32], partition_size: usize) -> u64 { let num_partitions = postings.len().div_ceil(partition_size); let mut total_bits: u64 = 0; let mut partition_endpoints = Vec::with_capacity(num_partitions); + let mut bit_endpoints = Vec::with_capacity(num_partitions); // Encode each partition for (i, chunk) in postings.chunks(partition_size).enumerate() { - let base = if i == 0 { 0 } else { postings[i * partition_size - 1] + 1 }; + let base = if i == 0 { + 0 + } else { + postings[i * partition_size - 1] + 1 + }; let max_in_partition = chunk.last().copied().unwrap_or(base) - base; let n = chunk.len() as u32; @@ -129,6 +135,7 @@ fn partitioned_elias_fano_bits(postings: &[u32], partition_size: usize) -> u64 { // Store endpoint for skip list partition_endpoints.push(*chunk.last().unwrap()); + bit_endpoints.push(total_bits as u32); } // Add skip list overhead (endpoints encoded with EF) @@ -138,6 +145,12 @@ fn partitioned_elias_fano_bits(postings: &[u32], partition_size: usize) -> u64 { ) .0; total_bits += skip_bits as u64; + let offset_bits = optimal_bits_per_value( + bit_endpoints.last().copied().unwrap_or_default() + 1 - num_partitions as u32, + num_partitions as u32, + ) + .0; + total_bits += offset_bits as u64; // Add partition size metadata (log2(partition_size) bits per partition boundary) // In practice, PISA uses fixed sizes so this is minimal From 570f681860d5ab07338a4a710f5d4533c378fdc7 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Sat, 27 Dec 2025 13:51:41 +0100 Subject: [PATCH 26/45] novel mst sorting --- crates/pef/src/bin/encode_pisa.rs | 399 +++++++++++++++++++++++++-- crates/pef/src/bin/reorder_docids.rs | 246 ++++++----------- 2 files changed, 455 insertions(+), 190 deletions(-) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index bbbdb451..20f92dee 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -77,17 +77,19 @@ fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { offset = postings[start]; let max = postings[end] - offset + 1; let len = (end - start) as u32; - next_bits = 4 + optimal_bits_per_value(max - len, len).0; + next_bits = 5 + optimal_bits_per_value(max - len, len).0; if next_bits > LIMIT { next_bits = LIMIT; break; } end += 1; } - offset = postings[end - 1]; - total += next_bits; + total += next_bits.div_ceil(8) * 8; start = end; - skiplist.push(offset); + if end < postings.len() { + offset = postings[end]; + skiplist.push(offset); + } } total + optimal_bits_per_value( @@ -222,28 +224,107 @@ fn vbyte_size(postings: &[u32]) -> u64 { total } -/// Load a docid mapping file (binary array of u32) -fn load_mapping>(path: P) -> std::io::Result> { +/// Load a docid mapping file (binary array of (doc_id, parent_id)) +/// Returns (old_to_new, subtree_ends) +fn load_mapping>(path: P) -> std::io::Result<(Vec, Vec)> { let file = File::open(path)?; let file_len = file.metadata()?.len() as usize; - let num_docs = file_len / 4; + let num_docs = file_len / 8; // 2 * u32 let mut reader = BufReader::new(file); - let mut mapping = vec![0u32; num_docs]; + let mut old_to_new = vec![0u32; num_docs]; + let mut parents = vec![0u32; num_docs]; + + // Read mapping and build old_to_new + parents (in new ID space) + // The file is ordered by new_id (0..N-1) + for new_id in 0..num_docs { + let old_id = read_u32(&mut reader)?; + let old_parent = read_u32(&mut reader)?; + + if old_id as usize >= num_docs { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Doc ID out of range", + )); + } + old_to_new[old_id as usize] = new_id as u32; + + // We can't resolve old_parent to new_parent yet because we might not have seen old_parent yet. + // So we store old_parent temporarily or do a second pass? + // Actually, we can just store the old_parent for now, but we need to map it later. + // But wait, we can't store old_parent in `parents` if we want `parents` to be new_ids. + // Let's store old_parent in a temporary vector. + parents[new_id] = old_parent; + } - for m in &mut mapping { - *m = read_u32(&mut reader)?; + // Convert parents to new IDs + for parent in &mut parents { + *parent = old_to_new[*parent as usize]; + } + + // Compute subtree ranges + // In DFS order, children are visited after parents? No. + // In DFS order: + // Visit u + // Visit children... + // So u < child. + // And the subtree of u is a contiguous range [u, end]. + // We can compute end by iterating backwards. + let mut subtree_ends: Vec = (0..num_docs as u32).collect(); + + for i in (1..num_docs).rev() { + let u = i as u32; + let p = parents[i]; + // If p is actually the parent (and not self-loop for root), propagate range + if p != u { + // The parent's range must include the child's range. + // Since it's DFS, p < u. + // And since we iterate backwards, we have already computed the full range of u. + // So we extend p's range to include u's range. + if subtree_ends[u as usize] > subtree_ends[p as usize] { + subtree_ends[p as usize] = subtree_ends[u as usize]; + } + } } - Ok(mapping) + Ok((old_to_new, subtree_ends)) } -/// Remap a posting list using the provided mapping, then sort. -fn remap_posting_list(postings: &mut Vec, mapping: &[u32]) { - for doc in postings.iter_mut() { - *doc = mapping[*doc as usize]; +/// Transform a posting list using the range toggle logic. +/// Returns the transformed posting list. +fn transform_posting_list(postings: &[u32], num_docs: usize, subtree_ends: &[u32]) -> Vec { + let mut output = Vec::new(); + let mut stack: Vec = Vec::new(); // Stack of end positions + + // Use a cursor for the sorted postings to avoid O(N) lookup + let mut posting_idx = 0; + + for i in 0..num_docs as u32 { + // Check for expired ranges + while let Some(&end) = stack.last() { + if end < i { + stack.pop(); + } else { + break; + } + } + let current_state = stack.len() % 2 != 0; + + // Check if current doc is in the posting list + let is_present = if posting_idx < postings.len() && postings[posting_idx] == i { + posting_idx += 1; + true + } else { + false + }; + + if is_present != current_state { + output.push(i); + stack.push(subtree_ends[i as usize]); + } } - postings.sort_unstable(); + + output } impl Stats { @@ -254,10 +335,13 @@ impl Stats { self.total_input_bytes += (len * 4) as u64; // Compute standard Elias-Fano encoding size - let ef_bits = optimal_bits_per_value( - postings.last().copied().unwrap_or_default() + 1 - len as u32, - len as u32, - ) + // Encode the inverse if it's smaller. + let last_posting = postings.last().copied().unwrap_or_default() + 1; + let ef_bits = if last_posting - len as u32 > len as u32 { + optimal_bits_per_value(last_posting - len as u32, len as u32) + } else { + optimal_bits_per_value(len as u32, last_posting - len as u32) + } .0; self.total_ef_bytes += ef_bits.div_ceil(8) as u64; @@ -457,18 +541,18 @@ fn main() -> std::io::Result<()> { .and_then(|i| args.get(i + 1)); // Load mapping if provided - let mapping = if let Some(path) = remap_path { + let (mapping, subtree_ends) = if let Some(path) = remap_path { println!("Loading docid mapping from: {}", path); - let m = load_mapping(path)?; + let (m, s) = load_mapping(path)?; println!("Loaded mapping for {} documents", m.len()); - Some(m) + (Some(m), Some(s)) } else { - None + (None, None) }; println!("Reading PISA posting lists from: {}", input_path); if mapping.is_some() { - println!("Document IDs will be remapped before encoding"); + println!("Document IDs will be remapped and transformed"); } let file = File::open(input_path)?; @@ -479,22 +563,46 @@ fn main() -> std::io::Result<()> { let header2 = read_u32(&mut reader)?; println!("Header: [{}, {}]", header1, header2); println!("Number of documents: {}", header2); + let num_docs = header2 as usize; let mut stats = Stats::default(); let start = Instant::now(); let mut last_report = Instant::now(); + let mut orig_num_postings = 0; + let mut remapped_num_postings = 0; + // Read and encode all posting lists while let Some(mut postings) = read_posting_list(&mut reader)? { if postings.is_empty() { continue; } + orig_num_postings += postings.len(); // Remap document IDs if mapping is provided if let Some(ref m) = mapping { - remap_posting_list(&mut postings, m); + // Remap and sort + for doc in postings.iter_mut() { + *doc = m[*doc as usize]; + } + postings.sort_unstable(); + let before = postings.len(); + postings.dedup(); + let after = postings.len(); + assert_eq!(before, after); + + // Apply range toggle transformation + if let Some(ref ends) = subtree_ends { + let before = postings.len(); + postings = transform_posting_list(&postings, num_docs, ends); + let after = postings.len(); + if before == 1 { + assert!(after >= 1) + } + } } + remapped_num_postings += postings.len(); // Encode with Elias-Fano // let ef = EliasFano::new(postings.iter().copied(), max, len); @@ -518,8 +626,247 @@ fn main() -> std::io::Result<()> { stats.total_postings, elapsed.as_secs_f64() ); + println!( + "Original total postings: {:>12}, after remapping: {:>12}", + orig_num_postings, remapped_num_postings + ); stats.print(); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// Helper to compute mapping and subtree_ends from in-memory data. + /// Input: slice of (old_id, old_parent_id) in DFS order (new_id = index). + fn compute_mapping_from_slice(data: &[(u32, u32)]) -> (Vec, Vec) { + let num_docs = data.len(); + let mut old_to_new = vec![0u32; num_docs]; + let mut parents = vec![0u32; num_docs]; + + for (new_id, &(old_id, old_parent)) in data.iter().enumerate() { + old_to_new[old_id as usize] = new_id as u32; + parents[new_id] = old_parent; + } + + // Convert parents to new IDs + for parent in &mut parents { + *parent = old_to_new[*parent as usize]; + } + + // Compute subtree ranges + let mut subtree_ends: Vec = (0..num_docs as u32).collect(); + + for i in (1..num_docs).rev() { + let u = i as u32; + let p = parents[i]; + if p != u { + if subtree_ends[u as usize] > subtree_ends[p as usize] { + subtree_ends[p as usize] = subtree_ends[u as usize]; + } + } + } + + (old_to_new, subtree_ends) + } + + #[test] + fn test_mapping_simple_chain() { + // DFS order: 0 -> 1 -> 2 -> 3 (linear chain) + // old_ids happen to match new_ids, each node's parent is the previous + // new_id=0: old_id=0, parent=0 (root) + // new_id=1: old_id=1, parent=0 + // new_id=2: old_id=2, parent=1 + // new_id=3: old_id=3, parent=2 + let data = [(0, 0), (1, 0), (2, 1), (3, 2)]; + let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); + + // old_to_new should be identity + assert_eq!(old_to_new, vec![0, 1, 2, 3]); + + // subtree_ends: + // Node 3: end=3 (leaf) + // Node 2: end=max(2, end[3])=3 + // Node 1: end=max(1, end[2])=3 + // Node 0: end=max(0, end[1])=3 + assert_eq!(subtree_ends, vec![3, 3, 3, 3]); + } + + #[test] + fn test_mapping_binary_tree() { + // Binary tree: + // 0 + // / \ + // 1 4 + // / \ + // 2 3 + // + // DFS order (pre-order): 0, 1, 2, 3, 4 + // new_id=0: old_id=0, parent=0 (root) + // new_id=1: old_id=1, parent=0 + // new_id=2: old_id=2, parent=1 + // new_id=3: old_id=3, parent=1 + // new_id=4: old_id=4, parent=0 + let data = [(0, 0), (1, 0), (2, 1), (3, 1), (4, 0)]; + let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); + + // old_to_new should be identity + assert_eq!(old_to_new, vec![0, 1, 2, 3, 4]); + + // subtree_ends: + // Node 4: end=4 (leaf) + // Node 3: end=3 (leaf) + // Node 2: end=2 (leaf) + // Node 1: end=max(1, end[2], end[3])=3 + // Node 0: end=max(0, end[1], end[4])=4 + assert_eq!(subtree_ends, vec![4, 3, 2, 3, 4]); + } + + #[test] + fn test_mapping_shuffled_old_ids() { + // 5 nodes with shuffled old_ids: + // Tree structure (in new_id space): + // 0 + // / \ + // 1 4 + // / \ + // 2 3 + // + // DFS order: new_id -> old_id + // new_id=0: old_id=4 + // new_id=1: old_id=2 + // new_id=2: old_id=0 + // new_id=3: old_id=3 + // new_id=4: old_id=1 + // + // Parents (in old_id space): + // old_id=4 is root -> parent=4 + // old_id=2's parent is old_id=4 + // old_id=0's parent is old_id=2 + // old_id=3's parent is old_id=2 + // old_id=1's parent is old_id=4 + let data = [(4, 4), (2, 4), (0, 2), (3, 2), (1, 4)]; + let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); + + // old_to_new: old_id -> new_id + // old_id=0 -> new_id=2 + // old_id=1 -> new_id=4 + // old_id=2 -> new_id=1 + // old_id=3 -> new_id=3 + // old_id=4 -> new_id=0 + assert_eq!(old_to_new, vec![2, 4, 1, 3, 0]); + + // subtree_ends (same tree structure as test_mapping_binary_tree): + // Node 4: end=4 (leaf) + // Node 3: end=3 (leaf) + // Node 2: end=2 (leaf) + // Node 1: end=max(1, end[2], end[3])=3 + // Node 0: end=max(0, end[1], end[4])=4 + assert_eq!(subtree_ends, vec![4, 3, 2, 3, 4]); + } + + #[test] + fn test_mapping_reordered() { + // 4 nodes, but old_ids are reordered: + // DFS new_id order: 0, 1, 2, 3 + // old_ids: 3, 1, 0, 2 + // + // Tree structure (in new_id space): + // 0 + // / \ + // 1 3 + // | + // 2 + // + // Parents (in old_id space): + // new_id=0: old_id=3, parent=3 (root) + // new_id=1: old_id=1, parent=3 + // new_id=2: old_id=0, parent=1 + // new_id=3: old_id=2, parent=3 + let data = [(3, 3), (1, 3), (0, 1), (2, 3)]; + let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); + + // old_to_new: old_id -> new_id + // old_id=0 -> new_id=2 + // old_id=1 -> new_id=1 + // old_id=2 -> new_id=3 + // old_id=3 -> new_id=0 + assert_eq!(old_to_new, vec![2, 1, 3, 0]); + + // subtree_ends (in new_id space): + // Node 3: end=3 (leaf) + // Node 2: end=2 (leaf) + // Node 1: parent=0, end=max(1, end[2])=2 + // Node 0: parent=0 (self), end=max(0, end[1], end[3])=3 + assert_eq!(subtree_ends, vec![3, 2, 2, 3]); + } + + #[test] + fn test_transform_simple() { + // 4 documents with subtree_ends = [3, 2, 2, 3] + // Tree: + // 0 + // / \ + // 1 3 + // | + // 2 + // + // Posting list (in new space, sorted): [0, 2] + // + // i=0: is_present=true, state=false -> output 0, state=true, push end[0]=3 + // i=1: stack=[3], end=3 >= 1, no pop. is_present=false, state=true -> output 1, state=false, push end[1]=2 + // i=2: stack=[3,2], end=2 >= 2, no pop. is_present=true, state=false -> output 2, state=true, push end[2]=2 + // i=3: stack=[3,2,2], end=2 < 3, pop, state=false. end=2 < 3, pop, state=true. end=3 >= 3, stop. + // is_present=false, state=true -> output 3, state=false, push end[3]=3 + // + // Output: [0, 1, 2, 3] + let subtree_ends = vec![3, 2, 2, 3]; + let postings = vec![0, 2]; + let result = transform_posting_list(&postings, 4, &subtree_ends); + assert_eq!(result, vec![0, 1, 2, 3]); + } + + #[test] + fn test_transform_full_subtree() { + // 4 documents with subtree_ends = [3, 2, 2, 3] + // Posting list (in new space, sorted): [0, 1, 2] + // All of subtree under node 0's left child (1 and 2) are present, plus root + // + // i=0: is_present=true, state=false -> output 0, state=true, push 3 + // i=1: is_present=true, state=true -> no output + // i=2: is_present=true, state=true -> no output + // i=3: stack=[3], 3 >= 3, no pop. is_present=false, state=true -> output 3, state=false, push 3 + // + // Output: [0, 3] + let subtree_ends = vec![3, 2, 2, 3]; + let postings = vec![0, 1, 2]; + let result = transform_posting_list(&postings, 4, &subtree_ends); + assert_eq!(result, vec![0, 3]); + } + + #[test] + fn test_transform_empty() { + let subtree_ends = vec![3, 2, 2, 3]; + let postings: Vec = vec![]; + let result = transform_posting_list(&postings, 4, &subtree_ends); + assert_eq!(result, vec![]); + } + + #[test] + fn test_transform_all_present() { + // All documents present + let subtree_ends = vec![3, 2, 2, 3]; + let postings = vec![0, 1, 2, 3]; + let result = transform_posting_list(&postings, 4, &subtree_ends); + // i=0: present, state=false -> output 0, state=true, push 3 + // i=1: present, state=true -> no output + // i=2: present, state=true -> no output + // i=3: 3>=3 no pop. present, state=true -> no output + // Output: [0] + assert_eq!(result, vec![0]); + } +} diff --git a/crates/pef/src/bin/reorder_docids.rs b/crates/pef/src/bin/reorder_docids.rs index f64243c7..10d3b197 100644 --- a/crates/pef/src/bin/reorder_docids.rs +++ b/crates/pef/src/bin/reorder_docids.rs @@ -17,15 +17,15 @@ use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::time::Instant; -use geo_filters::diff_count::GeoDiffCount7; +use geo_filters::diff_count::GeoDiffCount13; use geo_filters::Count; use ordered_float::OrderedFloat; use rayon::prelude::*; -type GeoFilter = GeoDiffCount7<'static>; +type GeoFilter = GeoDiffCount13<'static>; /// Mask size for GeoDiffConfig7 (7 bits) -const MASK_SIZE: usize = 7; +const MASK_SIZE: usize = 13; /// Read a u32 from a reader (little-endian) fn read_u32(reader: &mut R) -> std::io::Result { @@ -130,14 +130,10 @@ fn build_mst(size: usize, mut edges: Vec) -> Vec { /// Create edges between documents within a sliding window after sorting. /// Uses the optimized approach: pre-allocate and write in parallel. -fn create_edges_in_window( - filters: &[GeoFilter], - sorted: &[u32], - window_size: usize, -) -> Vec { +fn create_edges_in_window(filters: &[GeoFilter], sorted: &[u32], window_size: usize) -> Vec { let len = sorted.len() * window_size; let mut edges = Vec::::with_capacity(len); - + // Use spare_capacity_mut to avoid initializing edges we'll overwrite edges.spare_capacity_mut()[..len] .par_iter_mut() @@ -152,7 +148,7 @@ fn create_edges_in_window( let cost = a.size_with_sketch_f32(b).into(); edge.write(Edge { from, to, cost }); }); - + unsafe { edges.set_len(len); } @@ -189,7 +185,7 @@ where { let next = AtomicUsize::new(0); let results = std::sync::Mutex::new(vec![R::default(); indices.len()]); - + rayon::scope(|scope| { for _ in 0..parallelism { scope.spawn(|_| loop { @@ -202,7 +198,7 @@ where }); } }); - + results.into_inner().unwrap() } @@ -214,24 +210,20 @@ fn compute_multiple_approximate_msts( window_size: usize, ) -> Vec> { let indices: Vec = (0..masks.len()).collect(); - + parallelise( |idx| { let mask = masks[idx]; - + // Sort documents by masked filter comparison let mut sorted: Vec = (0..filters.len() as u32).collect(); sorted.par_sort_unstable_by(|&a, &b| { - filters[a as usize].cmp_masked( - &filters[b as usize], - mask as u64, - MASK_SIZE, - ) + filters[a as usize].cmp_masked(&filters[b as usize], mask as u64, MASK_SIZE) }); - + // Create edges in window let edges = create_edges_in_window(filters, &sorted, window_size); - + // Build MST for this projection build_mst(filters.len(), edges) }, @@ -248,11 +240,19 @@ fn compute_approximate_mst( ) -> Vec { // Generate all mask patterns with num_mask_bits/2 bits set let masks = mask_patterns(MASK_SIZE, num_mask_bits); - println!(" Using {} mask patterns with {} bits set...", masks.len(), num_mask_bits); - - println!(" Computing {} approximate MSTs with window size {}...", masks.len(), window_size); + println!( + " Using {} mask patterns with {} bits set...", + masks.len(), + num_mask_bits + ); + + println!( + " Computing {} approximate MSTs with window size {}...", + masks.len(), + window_size + ); let msts = compute_multiple_approximate_msts(filters, &masks, window_size); - + println!(" Merging {} MSTs...", msts.len()); merge_msts(filters.len(), msts) } @@ -273,23 +273,23 @@ fn mst_to_adjacency(num_nodes: usize, mst: &[Edge]) -> Vec> { /// DFS traversal of the MST to produce document ordering. /// Documents visited close together in DFS order are similar. -fn dfs_order(adj: &[Vec], start: u32) -> Vec { +fn dfs_order(adj: &[Vec], start: u32) -> Vec<(u32, u32)> { let n = adj.len(); let mut visited = vec![false; n]; let mut order = Vec::with_capacity(n); - let mut stack = vec![start]; + let mut stack = vec![(start, start)]; - while let Some(node) = stack.pop() { + while let Some((node, parent)) = stack.pop() { if visited[node as usize] { continue; } visited[node as usize] = true; - order.push(node); + order.push((node, parent)); // Add neighbors in reverse order so they're visited in forward order for &neighbor in adj[node as usize].iter().rev() { if !visited[neighbor as usize] { - stack.push(neighbor); + stack.push((neighbor, node)); } } } @@ -297,108 +297,13 @@ fn dfs_order(adj: &[Vec], start: u32) -> Vec { // Handle disconnected components (shouldn't happen with proper MST) for i in 0..n { if !visited[i] { - order.push(i as u32); + order.push((i as u32, i as u32)); } } order } -/// Compute the mapping from old doc IDs to new doc IDs. -fn compute_docid_mapping(dfs_order: &[u32]) -> Vec { - let mut mapping = vec![0u32; dfs_order.len()]; - for (new_id, &old_id) in dfs_order.iter().enumerate() { - mapping[old_id as usize] = new_id as u32; - } - mapping -} - -// ============================================================================ -// Sliding Window Refinement -// ============================================================================ - -/// Compute the cost contribution of an element at position i in the window. -/// This is the sum of distances to its neighbors (i-1 and i+1 if they exist). -#[inline] -fn element_cost(filters: &[GeoFilter], window: &[u32], i: usize) -> f32 { - let mut cost = 0.0f32; - let doc = window[i] as usize; - if i > 0 { - cost += filters[window[i - 1] as usize].size_with_sketch_f32(&filters[doc]); - } - if i + 1 < window.len() { - cost += filters[doc].size_with_sketch_f32(&filters[window[i + 1] as usize]); - } - cost -} - -/// Try to improve by exchanging any pair of elements if it reduces total cost. -/// This is more thorough than adjacent swaps and can escape local minima. -fn improve_window_pairwise(filters: &[GeoFilter], window: &mut [u32]) -> bool { - if window.len() < 3 { - return false; - } - - let mut improved = false; - - // Try all pairs of positions (not just adjacent) - for i in 1..window.len() - 1 { - for j in i + 1..window.len() - 1 { - // Compute cost before swap (only affected edges) - let cost_before = element_cost(filters, window, i) + element_cost(filters, window, j); - - // Swap elements - window.swap(i, j); - - // Compute cost after swap - let cost_after = element_cost(filters, window, i) + element_cost(filters, window, j); - - if cost_after < cost_before { - // Keep the swap - improved = true; - } else { - // Swap back - window.swap(i, j); - } - } - } - - improved -} - -/// Parallel refinement: divide into chunks and refine each chunk using pairwise exchange. -fn refine_ordering_parallel(filters: &[GeoFilter], order: &mut [u32], window_size: usize) { - let n = order.len(); - if n < window_size { - return; - } - - // Multiple passes over the entire array - let mut total_improvements = 0; - - for pass in 1..=10 { - let mut improvements = 0u64; - - // Slide window and try pairwise exchanges - for start in 0..n.saturating_sub(window_size - 1) { - let end = (start + window_size).min(n); - if improve_window_pairwise(filters, &mut order[start..end]) { - improvements += 1; - } - } - - total_improvements += improvements; - print!("\r Pass {}: {} improvements", pass, improvements); - std::io::stdout().flush().ok(); - - if improvements == 0 { - break; - } - } - - println!("\r Completed with {} total improvements", total_improvements); -} - // ============================================================================ // Main // ============================================================================ @@ -407,14 +312,20 @@ fn main() -> std::io::Result<()> { let args: Vec = env::args().collect(); if args.len() < 2 { - eprintln!("Usage: {} [window_size] [mask_bits] [refine_window]", args[0]); + eprintln!( + "Usage: {} [window_size] [mask_bits] [refine_window]", + args[0] + ); eprintln!(); eprintln!("Reorders document IDs by similarity using MST with masked sorting."); eprintln!("Outputs a mapping file next to the input."); eprintln!(); eprintln!("Arguments:"); eprintln!(" window_size - Number of neighbors to consider for MST (default: 32)"); - eprintln!(" mask_bits - Number of bits set in masks (default: 3, max: {})", MASK_SIZE); + eprintln!( + " mask_bits - Number of bits set in masks (default: 3, max: {})", + MASK_SIZE + ); eprintln!(" refine_window - Window size for local refinement (default: 0 = disabled)"); std::process::exit(1); } @@ -426,7 +337,11 @@ fn main() -> std::io::Result<()> { println!("Reading PISA posting lists from: {}", input_path); println!("Window size: {}", window_size); - println!("Mask bits: {} (generates {} masks)", mask_bits, mask_patterns(MASK_SIZE, mask_bits).len()); + println!( + "Mask bits: {} (generates {} masks)", + mask_bits, + mask_patterns(MASK_SIZE, mask_bits).len() + ); if refine_window > 0 { println!("Refinement window: {}", refine_window); } @@ -445,9 +360,7 @@ fn main() -> std::io::Result<()> { // Step 1: Build geometric filters for each document // ======================================================================== println!("\n=== Building document filters ==="); - let mut filters: Vec = (0..num_documents) - .map(|_| GeoFilter::default()) - .collect(); + let mut filters: Vec = (0..num_documents).map(|_| GeoFilter::default()).collect(); let start = Instant::now(); let mut last_report = Instant::now(); @@ -458,7 +371,6 @@ fn main() -> std::io::Result<()> { for &doc_id in &postings { filters[doc_id as usize].push(term_id); } - total_postings += postings.len() as u64; term_id += 1; @@ -475,7 +387,9 @@ fn main() -> std::io::Result<()> { let elapsed = start.elapsed(); println!( "\rProcessed {:>10} terms, {:>12} postings in {:.2}s", - term_id, total_postings, elapsed.as_secs_f64() + term_id, + total_postings, + elapsed.as_secs_f64() ); // ======================================================================== @@ -485,7 +399,11 @@ fn main() -> std::io::Result<()> { let start = Instant::now(); let mst = compute_approximate_mst(&filters, window_size, mask_bits); let elapsed = start.elapsed(); - println!("MST computed in {:.2}s ({} edges)", elapsed.as_secs_f64(), mst.len()); + println!( + "MST computed in {:.2}s ({} edges)", + elapsed.as_secs_f64(), + mst.len() + ); // ======================================================================== // Step 3: DFS traversal to get document ordering @@ -495,33 +413,21 @@ fn main() -> std::io::Result<()> { // Start from document 0 (or the largest document) let start_doc = 0u32; - let mut order = dfs_order(&adj, start_doc); + let order = dfs_order(&adj, start_doc); // ======================================================================== - // Step 3b: Local refinement using sliding window + // Step 4: Write reordering to file // ======================================================================== - if refine_window > 0 { - println!("\n=== Refining ordering with window size {} ===", refine_window); - let start = Instant::now(); - refine_ordering_parallel(&filters, &mut order, refine_window); - let elapsed = start.elapsed(); - println!("Refinement completed in {:.2}s", elapsed.as_secs_f64()); - } - - let mapping = compute_docid_mapping(&order); - - // ======================================================================== - // Step 4: Write mapping to file - // ======================================================================== - let output_path = Path::new(input_path).with_extension("docid_mapping"); - println!("\n=== Writing mapping to {} ===", output_path.display()); + let output_path = Path::new(input_path).with_extension("docid_reordering"); + println!("\n=== Writing reordering to {} ===", output_path.display()); let output_file = File::create(&output_path)?; let mut writer = BufWriter::new(output_file); - // Write as binary: array of u32 where mapping[old_id] = new_id - for &new_id in &mapping { - writer.write_all(&new_id.to_le_bytes())?; + // Write as binary: array of (doc_id, parent_id) + for &(doc_id, parent_id) in &order { + writer.write_all(&doc_id.to_le_bytes())?; + writer.write_all(&parent_id.to_le_bytes())?; } writer.flush()?; @@ -539,11 +445,15 @@ fn main() -> std::io::Result<()> { println!("Total documents: {:>12}", num_documents); println!("MST edges: {:>12}", mst.len()); - // Sample some mappings - println!("\nSample mappings (old -> new):"); - for old_id in [0, 1, 100, 1000, 10000, 100000, 500000].iter() { - if (*old_id as usize) < num_documents { - println!(" Doc {:>6} -> {:>6}", old_id, mapping[*old_id as usize]); + // Sample some reorderings + println!("\nSample reordering (index -> doc_id, parent_id):"); + for i in [0, 1, 100, 1000, 10000, 100000, 500000].iter() { + if (*i as usize) < order.len() { + let (doc_id, parent_id) = order[*i as usize]; + println!( + " Index {:>6} -> Doc {:>6} (Parent {:>6})", + i, doc_id, parent_id + ); } } @@ -551,8 +461,16 @@ fn main() -> std::io::Result<()> { let mst_sum: f64 = mst.iter().map(|e| e.cost.0 as f64).sum(); if !mst.is_empty() { let avg_cost = mst_sum / mst.len() as f64; - let min_cost = mst.iter().map(|e| e.cost.0).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0); - let max_cost = mst.iter().map(|e| e.cost.0).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0); + let min_cost = mst + .iter() + .map(|e| e.cost.0) + .min_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(0.0); + let max_cost = mst + .iter() + .map(|e| e.cost.0) + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(0.0); println!("\nMST edge costs:"); println!(" Sum: {:>15.2}", mst_sum); println!(" Min: {:>15.2}", min_cost); @@ -577,8 +495,8 @@ fn main() -> std::io::Result<()> { let sum_after: f64 = (0..order.len() - 1) .into_par_iter() .map(|i| { - let doc_a = order[i] as usize; - let doc_b = order[i + 1] as usize; + let doc_a = order[i].0 as usize; + let doc_b = order[i + 1].0 as usize; filters[doc_a].size_with_sketch_f32(&filters[doc_b]) as f64 }) .sum(); From 696c48af867086d5998a266dc152ecd33597cf6d Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Sat, 27 Dec 2025 14:25:05 +0100 Subject: [PATCH 27/45] speed up transformation --- crates/pef/src/bin/encode_pisa.rs | 46 ++++++++++++++++++------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs index 20f92dee..b631fc3f 100644 --- a/crates/pef/src/bin/encode_pisa.rs +++ b/crates/pef/src/bin/encode_pisa.rs @@ -298,29 +298,37 @@ fn transform_posting_list(postings: &[u32], num_docs: usize, subtree_ends: &[u32 // Use a cursor for the sorted postings to avoid O(N) lookup let mut posting_idx = 0; + let mut i = 0; - for i in 0..num_docs as u32 { - // Check for expired ranges - while let Some(&end) = stack.last() { - if end < i { - stack.pop(); + while i < num_docs as u32 { + while !stack.is_empty() && stack.last() < Some(&i) { + stack.pop(); + } + if stack.len() % 2 == 1 { + if postings.get(posting_idx) == Some(&i) { + posting_idx += 1; } else { - break; + // need to make it even... + output.push(i); + stack.push(subtree_ends[i as usize]); } - } - let current_state = stack.len() % 2 != 0; - - // Check if current doc is in the posting list - let is_present = if posting_idx < postings.len() && postings[posting_idx] == i { - posting_idx += 1; - true + i += 1; } else { - false - }; - - if is_present != current_state { - output.push(i); - stack.push(subtree_ends[i as usize]); + if postings.get(posting_idx) == Some(&i) { + output.push(i); + stack.push(subtree_ends[i as usize]); + posting_idx += 1; + i += 1; + } else { + if postings.get(posting_idx) > Some(&i) { + i = postings[posting_idx]; + } else { + i = num_docs as u32; + } + if let Some(&n) = stack.last() { + i = i.min(n + 1); + } + } } } From 674c81644d8e6a3a7b66c868aeada0ad10a5d418 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Sat, 25 Apr 2026 15:37:28 +0200 Subject: [PATCH 28/45] multiple ngram implementation --- Cargo.toml | 1 + crates/sparse-ngrams/Cargo.toml | 20 + crates/sparse-ngrams/benches/performance.rs | 41 ++ crates/sparse-ngrams/src/bigrams.bin | Bin 0 -> 17534 bytes crates/sparse-ngrams/src/deque.rs | 71 +++ crates/sparse-ngrams/src/extract.rs | 605 ++++++++++++++++++++ crates/sparse-ngrams/src/lib.rs | 293 ++++++++++ crates/sparse-ngrams/src/murmur.rs | 67 +++ crates/sparse-ngrams/src/ngram.rs | 143 +++++ crates/sparse-ngrams/src/table.rs | 43 ++ 10 files changed, 1284 insertions(+) create mode 100644 crates/sparse-ngrams/Cargo.toml create mode 100644 crates/sparse-ngrams/benches/performance.rs create mode 100644 crates/sparse-ngrams/src/bigrams.bin create mode 100644 crates/sparse-ngrams/src/deque.rs create mode 100644 crates/sparse-ngrams/src/extract.rs create mode 100644 crates/sparse-ngrams/src/lib.rs create mode 100644 crates/sparse-ngrams/src/murmur.rs create mode 100644 crates/sparse-ngrams/src/ngram.rs create mode 100644 crates/sparse-ngrams/src/table.rs diff --git a/Cargo.toml b/Cargo.toml index 59a6e8f3..999c8a45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/bpe/benchmarks", "crates/bpe/tests", "crates/pef", + "crates/sparse-ngrams", ] resolver = "2" diff --git a/crates/sparse-ngrams/Cargo.toml b/crates/sparse-ngrams/Cargo.toml new file mode 100644 index 00000000..4f5b6890 --- /dev/null +++ b/crates/sparse-ngrams/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "sparse-ngrams" +version = "0.1.0" +edition = "2021" +description = "Fast sparse n-gram extraction from byte slices." +repository = "https://github.com/github/rust-gems" +license = "MIT" +keywords = ["ngram", "algorithm", "search", "index"] +categories = ["algorithms", "data-structures", "text-processing"] + +[lib] +bench = false + +[[bench]] +name = "performance" +path = "benches/performance.rs" +harness = false + +[dev-dependencies] +criterion = "0.5" diff --git a/crates/sparse-ngrams/benches/performance.rs b/crates/sparse-ngrams/benches/performance.rs new file mode 100644 index 00000000..f89c84e4 --- /dev/null +++ b/crates/sparse-ngrams/benches/performance.rs @@ -0,0 +1,41 @@ +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, collect_sparse_grams_masked, collect_sparse_grams_masked_avx, collect_sparse_grams_wide, max_sparse_grams}; + +fn bench_collect(c: &mut Criterion) { + let inputs: Vec<(&str, Vec)> = vec![ + ("small_11B", b"hello world".to_vec()), + ( + "medium_900B", + "the quick brown fox jumps over the lazy dog. " + .repeat(20) + .into_bytes(), + ), + ("large_15KB", include_str!("../src/lib.rs").as_bytes().to_vec()), + ]; + + let mut group = c.benchmark_group("collect"); + for (name, input) in &inputs { + let mut buf = vec![NGram::from_bytes(b"xx"); max_sparse_grams(input.len())]; + group.throughput(Throughput::Bytes(input.len() as u64)); + + group.bench_with_input(BenchmarkId::new("deque", name), input, |b, input| { + b.iter(|| collect_sparse_grams_deque(black_box(input), &mut buf)) + }); + group.bench_with_input(BenchmarkId::new("scan", name), input, |b, input| { + b.iter(|| collect_sparse_grams_scan(black_box(input), &mut buf)) + }); + group.bench_with_input(BenchmarkId::new("masked", name), input, |b, input| { + b.iter(|| collect_sparse_grams_masked(black_box(input), &mut buf)) + }); + group.bench_with_input(BenchmarkId::new("masked_avx", name), input, |b, input| { + b.iter(|| collect_sparse_grams_masked_avx(black_box(input), &mut buf)) + }); + group.bench_with_input(BenchmarkId::new("wide", name), input, |b, input| { + b.iter(|| collect_sparse_grams_wide(black_box(input), &mut buf)) + }); + } + group.finish(); +} + +criterion_group!(benches, bench_collect); +criterion_main!(benches); diff --git a/crates/sparse-ngrams/src/bigrams.bin b/crates/sparse-ngrams/src/bigrams.bin new file mode 100644 index 0000000000000000000000000000000000000000..d011c90ddcd9424a791a26ebf9b782e5a6e956b3 GIT binary patch literal 17534 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 HeewST@xZ7f literal 0 HcmV?d00001 diff --git a/crates/sparse-ngrams/src/deque.rs b/crates/sparse-ngrams/src/deque.rs new file mode 100644 index 00000000..e75e04b3 --- /dev/null +++ b/crates/sparse-ngrams/src/deque.rs @@ -0,0 +1,71 @@ +//! Stack-allocated circular buffer (monotone deque). + +use std::mem::MaybeUninit; + +/// Deque element representing two neighboring bytes in the input. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PosStateBytes { + /// Absolute index position between the two bigram characters. + /// I.e. 1 references the very first bigram. + pub index: u32, + pub value: u32, +} + +/// Stack-allocated circular buffer holding up to `CAP` elements. +/// Replaces `VecDeque` — avoids heap allocation and fits in a +/// single cache line for small CAP values. +pub(crate) struct FixedDeque { + data: [MaybeUninit; CAP], + start: u8, + len: u8, +} + +impl FixedDeque { + pub fn new() -> Self { + Self { + data: [MaybeUninit::uninit(); CAP], + start: 0, + len: 0, + } + } + + #[inline] + pub fn front(&self) -> Option<&PosStateBytes> { + if self.len == 0 { + None + } else { + Some(unsafe { self.data[self.start as usize].assume_init_ref() }) + } + } + + #[inline] + pub fn back(&self) -> Option<&PosStateBytes> { + if self.len == 0 { + None + } else { + let idx = (self.start + self.len - 1) as usize % CAP; + Some(unsafe { self.data[idx].assume_init_ref() }) + } + } + + #[inline] + pub fn pop_front(&mut self) { + debug_assert!(self.len > 0); + self.start = (self.start + 1) % CAP as u8; + self.len -= 1; + } + + #[inline] + pub fn pop_back(&mut self) { + debug_assert!(self.len > 0); + self.len -= 1; + } + + #[inline] + pub fn push_back(&mut self, val: PosStateBytes) { + debug_assert!((self.len as usize) < CAP); + let idx = (self.start + self.len) as usize % CAP; + self.data[idx] = MaybeUninit::new(val); + self.len += 1; + } +} diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs new file mode 100644 index 00000000..b9efbe66 --- /dev/null +++ b/crates/sparse-ngrams/src/extract.rs @@ -0,0 +1,605 @@ +//! Core sparse n-gram extraction algorithm. + +use crate::deque::{FixedDeque, PosStateBytes}; +use crate::ngram::{NGram, POLY_HASH_PRIME, POLY_POWERS, ngram_from_range}; +use crate::table::get_bigram_table; +use crate::MAX_SPARSE_GRAM_SIZE; + +/// Returns the maximum number of sparse n-grams that can be produced from +/// `content_len` bytes of input. Use this to pre-allocate the output slice. +#[inline] +pub const fn max_sparse_grams(content_len: usize) -> usize { + if content_len < 2 { + 0 + } else { + (content_len - 1) * 3 + } +} + +/// Collect all sparse n-grams from the input byte slice into a new [`Vec`]. +pub fn collect_sparse_grams(content: &[u8]) -> Vec { + let mut buf = vec![NGram::from_rolling_hash(0, 0); max_sparse_grams(content.len())]; + let count = collect_sparse_grams_deque(content, &mut buf); + buf.truncate(count); + buf +} + +/// Deque-based extraction. Writes n-grams into `out` (must have at least +/// [`max_sparse_grams`]`(content.len())` slots). Returns the count written. +/// +/// # Panics +/// +/// Panics if `out` is too small. +pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { + let n = content.len(); + if n < 2 { + return 0; + } + assert!(out.len() >= max_sparse_grams(n)); + + let table = get_bigram_table(); + let mut queue = FixedDeque::<{ MAX_SPARSE_GRAM_SIZE as usize }>::new(); + let mut w = 0usize; + + let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + prefix_hashes[1] = content[0] as u32; + + for idx in 1..n as u32 { + let mask = MAX_SPARSE_GRAM_SIZE as usize - 1; + let end_hash = prefix_hashes[idx as usize & mask] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[idx as usize] as u32); + + // Bigram + out[w] = ngram_from_range(&prefix_hashes, end_hash, idx as usize - 1, 2); + w += 1; + + let (v1, _v2) = + table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + + if let Some(begin) = queue.front() { + if idx - begin.index + 1 >= MAX_SPARSE_GRAM_SIZE { + queue.pop_front(); + } + } + + while let Some(begin) = queue.back() { + let start = begin.index as usize - 1; + let len = idx as usize + 1 - start; + out[w] = ngram_from_range(&prefix_hashes, end_hash, start, len); + w += 1; + if begin.value < v1 { + break; + } + queue.pop_back(); + } + + queue.push_back(PosStateBytes { + index: idx, + value: v1, + }); + + prefix_hashes[(idx as usize + 1) & mask] = end_hash; + } + + w +} + +/// Queue-free scan-based extraction. Writes n-grams into `out` (must have at least +/// [`max_sparse_grams`]`(content.len())` slots). Returns the count written. +/// +/// Produces identical output (same order) as [`collect_sparse_grams_deque`]. +/// +/// # Panics +/// +/// Panics if `out` is too small. +pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { + let n = content.len(); + if n < 2 { + return 0; + } + assert!(out.len() >= max_sparse_grams(n)); + + let table = get_bigram_table(); + const MASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; + let mut w = 0usize; + + let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + prefix_hashes[1] = content[0] as u32; + + let mut priorities = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + + for idx in 1..n as u32 { + let end_hash = prefix_hashes[idx as usize & MASK] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[idx as usize] as u32); + + // Bigram + out[w] = ngram_from_range(&prefix_hashes, end_hash, idx as usize - 1, 2); + w += 1; + + let (v1, _v2) = + table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + priorities[idx as usize & MASK] = v1; + + let mut running_min = u32::MAX; + + for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { + if d >= idx { + break; + } + let p = idx - d; + let v_p = priorities[p as usize & MASK]; + + if v_p < running_min { + let start = p as usize - 1; + let len = idx as usize + 1 - start; + out[w] = ngram_from_range(&prefix_hashes, end_hash, start, len); + w += 1; + if v_p < v1 { + break; + } + } + + running_min = running_min.min(v_p); + } + + prefix_hashes[(idx as usize + 1) & MASK] = end_hash; + } + + w +} + +/// Masked variant: maintains the deque as a bitmask across iterations. +/// +/// Since deque elements have strictly increasing priorities (front→back), comparing +/// each lookback priority with the current priority `v1` is enough to determine +/// which elements to emit and pop — no prefix-min scan needed. +/// +/// Bit `k` in the `active` mask means the position at lookback offset `k+1` is +/// currently in the deque. At each step: compare all 6 lookback priorities with `v1`, +/// emit from active positions, update the mask with a shift+OR+AND. +/// +/// Produces the same *set* of n-grams as [`collect_sparse_grams_scan`], but in a different order. +/// +/// # Panics +/// +/// Panics if `out` is too small. +pub fn collect_sparse_grams_masked(content: &[u8], out: &mut [NGram]) -> usize { + const MAX_D: usize = MAX_SPARSE_GRAM_SIZE as usize - 2; // 6 + const CMASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; // 7 + + let n = content.len(); + if n < 2 { + return 0; + } + assert!(out.len() >= max_sparse_grams(n)); + + let table = get_bigram_table(); + let mut w = 0usize; + + let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + prefix_hashes[1] = content[0] as u32; + + let mut prio_circ = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut active: u32 = 0; // bitmask: bit k ↔ array slot k is in the deque + let mut dist = [2usize, 9, 8, 7, 6, 5, 4, 3]; // dist[k] = ((idx - k) & CMASK) + 2 + // poly_circ[k] = POLY_HASH_PRIME^(dist[k] + 2) — tracks the poly power for each slot. + // After each iteration dist increments, so we multiply all by PRIME and reset + // the wrapping slot. Initial values correspond to dist = [0, 7, 6, 5, 4, 3, 2, 1], + // but we need power dist+2. For the slot with dist=7 that means power 9, which + // we'll get naturally after one multiply (it starts at power 8, becomes 9 after + // the first multiply, then gets reset to 2 when it wraps). + // So initialize poly_circ[k] = POLY_POWERS[dist_init[k] + 1] instead, since the + // first thing in the loop is the multiply. + let mut poly_circ = [ + POLY_POWERS[1], POLY_POWERS[8], POLY_POWERS[7], POLY_POWERS[6], + POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], POLY_POWERS[2], + ]; + + for idx in 1..n as u32 { + // Advance all distances by 1 and multiply poly powers. + for k in 0..MAX_SPARSE_GRAM_SIZE as usize { + dist[k] += 1; + poly_circ[k] = poly_circ[k].wrapping_mul(POLY_HASH_PRIME); + } + // The current-position slot just exceeded MAX_SPARSE_GRAM_SIZE+1; reset it. + dist[idx as usize & CMASK] = 2; + poly_circ[idx as usize & CMASK] = POLY_POWERS[2]; + let end_hash = prefix_hashes[idx as usize & CMASK] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[idx as usize] as u32); + let (v1, _) = + table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + prio_circ[idx as usize & CMASK] = v1; + // Compare all lookback priorities with v1 (no idx dependency in the loop). + let mut ge_bits: u32 = 0; + for k in 0..MAX_SPARSE_GRAM_SIZE as usize { + ge_bits |= ((prio_circ[k] >= v1) as u32) << k; + } + // Emit from active deque positions: + // - all with v >= v1 (they get popped) + // - plus the nearest one with v < v1 (it stays) + let cur_bit = 1u32 << (idx as usize & CMASK); + let emit_ge = active & ge_bits; + let active_lt = active & !ge_bits; + // Find nearest (smallest distance) active position with v < v1. + // Rotate so bit 7 = distance 1, bit 6 = distance 2, ..., bit 1 = distance 7. + // Bit 0 = distance 0 (cur_bit's slot, never in active_lt) → used as sentinel. + // When empty, leading_zeros finds the sentinel; first_lt becomes cur_bit (harmless). + let doubled = active_lt | (active_lt << 8); + let shift = idx as usize & CMASK; + let rotated = ((doubled >> shift) & 0xFF) | 1; + let b = 31 - rotated.leading_zeros() as usize; + let first_lt = 1u32 << ((shift + b) & CMASK); + let emit = emit_ge | first_lt | cur_bit; + // Write emitted ngrams. + let mut m = emit; + while m != 0 { + let k = m.trailing_zeros() as usize; + let len = dist[k]; + let hash = end_hash + .wrapping_sub(prefix_hashes[k.wrapping_sub(1) & CMASK].wrapping_mul(poly_circ[k])); + out[w] = NGram((hash << 8) | len as u32); + w += 1; + m &= m - 1; + } + // Update active: keep survivors with v < v1, add current position, + // and evict the slot that just left the lookback window. + active = (active & !ge_bits) | (1 << (idx as usize & CMASK)); + active &= !(1u32 << (idx.wrapping_sub(MAX_D as u32) as usize & CMASK)); + prefix_hashes[(idx as usize + 1) & CMASK] = end_hash; + } + w +} + +/// AVX2 variant of [`collect_sparse_grams_masked`]. +/// +/// Uses 256-bit SIMD to update the 8-element circular arrays (`poly_circ`, +/// `prio_circ`, `dist`, `prefix_hashes`) and to compute the ge_bits comparison +/// and hash outputs in parallel. The bitmask logic (active/emit/first_lt) remains +/// scalar since it operates on 8-bit masks. +/// +/// Produces the same *set* of n-grams as [`collect_sparse_grams_masked`]. +/// +/// # Panics +/// +/// Panics if `out` is too small. +#[cfg(target_arch = "x86_64")] +pub fn collect_sparse_grams_masked_avx(content: &[u8], out: &mut [NGram]) -> usize { + // SAFETY: AVX2 is enabled globally via .cargo/config.toml target-feature flags. + unsafe { collect_sparse_grams_masked_avx_inner(content, out) } +} + +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram]) -> usize { + #[cfg(target_arch = "x86_64")] + use core::arch::x86_64::*; + + const MAX_D: usize = MAX_SPARSE_GRAM_SIZE as usize - 2; // 6 + const CMASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; // 7 + + let n = content.len(); + if n < 2 { + return 0; + } + assert!(out.len() >= max_sparse_grams(n)); + + let table = get_bigram_table(); + let mut w = 0usize; + + // Aligned arrays for SIMD load/store. + #[repr(align(32))] + struct A32([u32; 8]); + + let mut prefix_hashes = A32([0u32; 8]); + prefix_hashes.0[1] = content[0] as u32; + + let mut prio_arr = A32([0u32; 8]); + let mut active: u32 = 0; + + // dist[k] = len for slot k. Starts [2, 9, 8, 7, 6, 5, 4, 3]. + let mut dist_arr = A32([2, 9, 8, 7, 6, 5, 4, 3]); + let mut v_dist = _mm256_load_si256(dist_arr.0.as_ptr() as *const __m256i); + + // poly_circ — initialized one power below because the first thing in the loop is multiply. + let mut poly_arr = A32([ + POLY_POWERS[1], POLY_POWERS[8], POLY_POWERS[7], POLY_POWERS[6], + POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], POLY_POWERS[2], + ]); + let mut v_poly = _mm256_load_si256(poly_arr.0.as_ptr() as *const __m256i); + + let v_prime = _mm256_set1_epi32(POLY_HASH_PRIME as i32); + let v_one = _mm256_set1_epi32(1); + + for idx in 1..n as u32 { + let slot = idx as usize & CMASK; + + // --- AVX2: advance dist += 1, poly_circ *= PRIME --- + v_dist = _mm256_add_epi32(v_dist, v_one); + v_poly = _mm256_mullo_epi32(v_poly, v_prime); + + // Reset current slot: dist[slot] = 2, poly[slot] = POLY_POWERS[2]. + // Insert into the vector via store-to-array, modify, reload. + _mm256_store_si256(dist_arr.0.as_mut_ptr() as *mut __m256i, v_dist); + _mm256_store_si256(poly_arr.0.as_mut_ptr() as *mut __m256i, v_poly); + dist_arr.0[slot] = 2; + poly_arr.0[slot] = POLY_POWERS[2]; + v_dist = _mm256_load_si256(dist_arr.0.as_ptr() as *const __m256i); + v_poly = _mm256_load_si256(poly_arr.0.as_ptr() as *const __m256i); + + // --- Scalar: compute end_hash, v1, update prio --- + let end_hash = prefix_hashes.0[slot] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[idx as usize] as u32); + + let (v1, _) = + table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + prio_arr.0[slot] = v1; + + // --- AVX2: compare all 8 priorities with v1 --- + // For unsigned >= comparison: (a >= b) iff !(b > a) iff !((b ^ 0x80000000) > (a ^ 0x80000000)) + // using signed comparison after flipping sign bits. + let v_prio = _mm256_load_si256(prio_arr.0.as_ptr() as *const __m256i); + let v_v1 = _mm256_set1_epi32(v1 as i32); + let v_sign = _mm256_set1_epi32(i32::MIN); + let v_prio_s = _mm256_xor_si256(v_prio, v_sign); + let v_v1_s = _mm256_xor_si256(v_v1, v_sign); + // gt_mask: bits where v1 > prio[k] (signed after flip = unsigned) + let gt = _mm256_cmpgt_epi32(v_v1_s, v_prio_s); + // ge = !gt → prio[k] >= v1 + let ge_bits = !(_mm256_movemask_epi8(gt) as u32) & 0xFF; + // movemask gives 32 bits (4 per lane); we want 1 bit per lane. + // Use _mm256_movemask_ps on the comparison result instead. + let gt_ps = _mm256_castsi256_ps(gt); + let gt_mask8 = _mm256_movemask_ps(gt_ps) as u32; + let ge_bits = (!gt_mask8) & 0xFF; + + // --- Scalar bitmask logic (same as masked variant) --- + let cur_bit = 1u32 << slot; + let emit_ge = active & ge_bits; + let active_lt = active & !ge_bits; + + let doubled = active_lt | (active_lt << 8); + let shift = idx as usize & CMASK; + let rotated = ((doubled >> shift) & 0xFF) | 1; + let b = 31 - rotated.leading_zeros() as usize; + let first_lt = 1u32 << ((shift + b) & CMASK); + let emit = emit_ge | first_lt | cur_bit; + + // --- AVX2: compute all 8 hashes in parallel --- + // hash[k] = end_hash - prefix_hashes[(k-1) & 7] * poly_circ[k] + // Build rotated prefix_hashes: shift each lane's index by -1 mod 8. + // Use _mm256_permutevar8x32_epi32 with indices [(k-1)&7 for k in 0..8]. + let v_ph = _mm256_load_si256(prefix_hashes.0.as_ptr() as *const __m256i); + let v_perm_idx = _mm256_set_epi32(6, 5, 4, 3, 2, 1, 0, 7); // (k-1)&7 for k=7..0 + let v_ph_rot = _mm256_permutevar8x32_epi32(v_ph, v_perm_idx); + let v_prod = _mm256_mullo_epi32(v_ph_rot, v_poly); + let v_end = _mm256_set1_epi32(end_hash as i32); + let v_hash = _mm256_sub_epi32(v_end, v_prod); + + // Compute (hash << 8) | dist for all lanes. + let v_hash_shifted = _mm256_slli_epi32(v_hash, 8); + let v_ngram = _mm256_or_si256(v_hash_shifted, v_dist); + + // Store all 8 n-gram candidates to a temp array, then emit selected ones. + let mut ngram_arr = A32([0u32; 8]); + _mm256_store_si256(ngram_arr.0.as_mut_ptr() as *mut __m256i, v_ngram); + + // --- Emit selected n-grams --- + let mut m = emit; + while m != 0 { + let k = m.trailing_zeros() as usize; + out[w] = NGram(ngram_arr.0[k]); + w += 1; + m &= m - 1; + } + + // --- Update active --- + active = (active & !ge_bits) | cur_bit; + active &= !(1u32 << (idx.wrapping_sub(MAX_D as u32) as usize & CMASK)); + + prefix_hashes.0[(idx as usize + 1) & CMASK] = end_hash; + } + w +} + +/// SIMD-friendly wide variant that processes 16 end positions in parallel. +/// +/// Uses two fixed-size `[u32; LANES * 2]` sliding buffers (priorities + prefix hashes) +/// on the stack — no heap allocation. Each chunk copies the back half to the front, +/// then fills the back half from `content`, so all reads are simple flat-array indexing. +/// +/// Writes n-grams into `out` (must have at least [`max_sparse_grams`]`(content.len())` +/// slots). Returns the count written. +/// +/// Produces the same *set* of n-grams as the other variants, but in a different order. +/// +/// # Panics +/// +/// Panics if `out` is too small. +pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { + const LANES: usize = 16; + const BUF: usize = LANES * 2; + const MASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; + + let n = content.len(); + if n < 2 { + return 0; + } + + let table = get_bigram_table(); + let num_bigrams = n - 1; + assert!(out.len() >= max_sparse_grams(n)); + + let mut w = 0usize; + + // --- Scalar prefix: first LANES positions (fills the initial buffer) ---- + let scalar_prefix = LANES.min(num_bigrams); + + let mut circ_ph = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + circ_ph[1] = content[0] as u32; + let mut circ_prio = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + + for idx in 1..=scalar_prefix as u32 { + let end_hash = circ_ph[idx as usize & MASK] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[idx as usize] as u32); + + out[w] = ngram_from_range(&circ_ph, end_hash, idx as usize - 1, 2); + w += 1; + + let (v1, _) = + table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + circ_prio[idx as usize & MASK] = v1; + + let mut running_min = u32::MAX; + for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { + if d >= idx { + break; + } + let v_p = circ_prio[(idx - d) as usize & MASK]; + if v_p < running_min { + let start = (idx - d) as usize - 1; + let len = idx as usize + 1 - start; + out[w] = ngram_from_range(&circ_ph, end_hash, start, len); + w += 1; + if v_p < v1 { + break; + } + } + running_min = running_min.min(v_p); + } + + circ_ph[(idx as usize + 1) & MASK] = end_hash; + } + + if num_bigrams <= LANES { + return w; + } + + // --- Seed the sliding buffers ------------------------------------------- + // Each buffer has BUF=32 slots. The back half [LANES..BUF) corresponds to + // the LANES positions we just processed. Before the first wide chunk we'll + // copy back→front, then fill back with fresh data. + // + // ph_buf[LANES + j] = ph[j + 1] for j in 0..LANES + // prio_buf[LANES + j] = priority at 1-based position j+1 + + let mut ph_buf = [0u32; BUF + 1]; + let mut prio_buf = [0u32; BUF]; + + ph_buf[LANES] = content[0] as u32; + for j in 1..=LANES { + ph_buf[LANES + j] = ph_buf[LANES + j - 1] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[j] as u32); + } + for j in 0..LANES { + prio_buf[LANES + j] = + table[content[j] as usize * 256 + content[j + 1] as usize].0; + } + + // --- Wide chunk loop (includes partial last chunk) ----------------------- + let remaining = num_bigrams - LANES; + let total_chunks = (remaining + LANES - 1) / LANES; // ceiling division + let mut content_pos = LANES; // 0-based: next byte to process + + for _chunk in 0..total_chunks { + let chunk_lanes = LANES.min(num_bigrams - (content_pos)); // active lanes this chunk + + // Slide: back → front. + prio_buf.copy_within(LANES..BUF, 0); + ph_buf.copy_within(LANES..BUF + 1, 0); + + // Fill back half — only chunk_lanes+1 prefix hashes and chunk_lanes priorities. + ph_buf[LANES] = ph_buf[LANES - 1] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[content_pos] as u32); + for j in 1..=chunk_lanes { + ph_buf[LANES + j] = ph_buf[LANES + j - 1] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[content_pos + j] as u32); + } + for j in 0..chunk_lanes { + let ci = content_pos + j; + prio_buf[LANES + j] = + table[content[ci] as usize * 256 + content[ci + 1] as usize].0; + } + + let lane_mask: u32 = (1u32 << chunk_lanes) - 1; // bits 0..chunk_lanes + + // Pad unfilled back-half slots so the 0..LANES loops read valid memory. + // Inactive lanes will be masked out by lane_mask. + for j in chunk_lanes..LANES { + prio_buf[LANES + j] = 0; + ph_buf[LANES + j + 1] = ph_buf[LANES + chunk_lanes]; // any valid value + } + + // These loops MUST iterate 0..LANES (compile-time constant) for auto-vectorization. + let mut v_current = [0u32; LANES]; + let mut end_hashes = [0u32; LANES]; + for i in 0..LANES { + v_current[i] = prio_buf[LANES + i]; + end_hashes[i] = ph_buf[LANES + i + 1]; + } + + // Emit bigrams for active lanes. + for i in 0..chunk_lanes { + let hash = end_hashes[i] + .wrapping_sub(ph_buf[LANES + i - 1].wrapping_mul(POLY_POWERS[2])); + out[w + i] = NGram::from_rolling_hash(hash, 2); + } + w += chunk_lanes; + + // Backward suffix-minimum scan — inner loops always 0..LANES for vectorization. + let mut running_min = [u32::MAX; LANES]; + let mut active: u32 = lane_mask; + + for d in 1..=(MAX_SPARSE_GRAM_SIZE as usize - 2) { + if active == 0 { + break; + } + + let len = d + 2; + let poly_power = POLY_POWERS[len]; + + let mut v_shifted = [0u32; LANES]; + let mut candidate_hashes = [0u32; LANES]; + let mut is_suffix_min = [false; LANES]; + let mut should_break = [false; LANES]; + + for i in 0..LANES { + v_shifted[i] = prio_buf[LANES + i - d]; + candidate_hashes[i] = end_hashes[i] + .wrapping_sub(ph_buf[LANES + i - d - 1].wrapping_mul(poly_power)); + is_suffix_min[i] = v_shifted[i] < running_min[i]; + should_break[i] = is_suffix_min[i] && v_shifted[i] < v_current[i]; + running_min[i] = running_min[i].min(v_shifted[i]); + } + + let mut emit_mask: u32 = 0; + let mut break_mask: u32 = 0; + for i in 0..LANES { + emit_mask |= (is_suffix_min[i] as u32) << i; + break_mask |= (should_break[i] as u32) << i; + } + emit_mask &= active; + + let mut m = emit_mask; + while m != 0 { + let i = m.trailing_zeros() as usize; + out[w] = NGram::from_rolling_hash(candidate_hashes[i], len); + w += 1; + m &= m - 1; + } + + active &= !break_mask; + } + + content_pos += chunk_lanes; + } + + w +} diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs new file mode 100644 index 00000000..a3f1cb41 --- /dev/null +++ b/crates/sparse-ngrams/src/lib.rs @@ -0,0 +1,293 @@ +//! Sparse n-gram extraction from byte slices. +//! +//! Sparse grams are a way of selecting variable-length n-grams (longer than 2 bytes) without +//! extracting all possible n-grams. The algorithm is deterministic: the same extraction logic +//! works for every substring, so that substring searches are supported. +//! +//! # How it works +//! +//! Each consecutive byte pair (bigram) is assigned a priority based on how frequently it occurs +//! in a large code corpus. A monotone deque tracks potential n-gram boundaries: an n-gram +//! boundary occurs wherever a bigram has lower priority than all bigrams between it and the +//! previous boundary. +//! +//! For a document of N bytes, this produces roughly 2N n-grams: all bigrams plus algorithmically +//! selected longer n-grams (up to [`MAX_SPARSE_GRAM_SIZE`] bytes). +//! +//! # Example +//! +//! ``` +//! use sparse_ngrams::{NGram, collect_sparse_grams, MAX_SPARSE_GRAM_SIZE}; +//! +//! let input = b"hello world"; +//! let grams = collect_sparse_grams(input); +//! assert!(grams.len() > input.len() - 1); +//! for gram in &grams { +//! assert!(gram.len() >= 2); +//! assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize); +//! } +//! ``` + +mod deque; +mod extract; +mod murmur; +mod ngram; +mod table; + +pub use ngram::NGram; + +/// Number of high-frequency bigrams used to build the priority table. +pub const NUM_FREQUENT_BIGRAMS: usize = 200; + +/// Maximum length (in bytes) of a sparse n-gram. +pub const MAX_SPARSE_GRAM_SIZE: u32 = 8; + +pub use extract::{collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_masked, collect_sparse_grams_scan, collect_sparse_grams_wide, max_sparse_grams}; +#[cfg(target_arch = "x86_64")] +pub use extract::collect_sparse_grams_masked_avx; + +#[cfg(test)] +mod tests { + use super::*; + + fn sorted(mut v: Vec) -> Vec { + v.sort(); + v + } + + fn collect_to_vec(content: &[u8], f: fn(&[u8], &mut [NGram]) -> usize) -> Vec { + let mut buf = vec![NGram::from_rolling_hash(0, 0); max_sparse_grams(content.len())]; + let count = f(content, &mut buf); + buf.truncate(count); + buf + } + + #[test] + fn test_empty_input() { + assert!(collect_sparse_grams(b"").is_empty()); + } + + #[test] + fn test_single_byte() { + assert!(collect_sparse_grams(b"a").is_empty()); + } + + #[test] + fn test_two_bytes() { + let grams = collect_sparse_grams(b"ab"); + assert_eq!(grams.len(), 1); + assert_eq!(grams[0], NGram::from_bytes(b"ab")); + } + + #[test] + fn test_three_bytes() { + let grams = collect_sparse_grams(b"abc"); + assert!(grams.len() >= 2); + assert_eq!(grams[0], NGram::from_bytes(b"ab")); + assert_eq!(grams[1], NGram::from_bytes(b"bc")); + } + + #[test] + fn test_gram_lengths_bounded() { + let input = b"self.reset_states(the_quick_brown_fox_jumps"; + let grams = collect_sparse_grams(input); + for gram in &grams { + assert!(gram.len() >= 2, "gram too short: {gram:?}"); + assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize, "gram too long: {gram:?}"); + } + } + + #[test] + fn test_produces_longer_grams() { + let grams = collect_sparse_grams(b"self.reset_states("); + assert!(grams.iter().any(|g| g.len() > 2)); + } + + #[test] + fn test_max_gram_size_boundary() { + let grams = collect_sparse_grams(b"abcdefgh"); + for gram in &grams { + assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize); + } + } + + #[test] + fn test_repeated_bytes() { + let grams = collect_sparse_grams(b"aaaaaaaaaa"); + assert!(grams.iter().filter(|g| g.len() == 2).count() >= 9); + } + + #[test] + fn test_gram_count_scales_linearly() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + let grams = collect_sparse_grams(&input); + assert!(grams.len() >= input.len() - 1); + assert!(grams.len() <= input.len() * 3); + } + + // -- Equivalence: scan vs deque -- + + #[test] + fn test_scan_equivalence_small() { + for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { + assert_eq!( + collect_to_vec(input, collect_sparse_grams_deque), + collect_to_vec(input, collect_sparse_grams_scan), + "mismatch on {:?}", + std::str::from_utf8(input).unwrap_or("?") + ); + } + } + + #[test] + fn test_scan_equivalence_hello_world() { + let input = b"hello world"; + assert_eq!( + collect_to_vec(input, collect_sparse_grams_deque), + collect_to_vec(input, collect_sparse_grams_scan), + ); + } + + #[test] + fn test_scan_equivalence_large() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + assert_eq!( + collect_to_vec(&input, collect_sparse_grams_deque), + collect_to_vec(&input, collect_sparse_grams_scan), + ); + } + + #[test] + fn test_scan_equivalence_source_code() { + let input = include_bytes!("lib.rs"); + assert_eq!( + collect_to_vec(input, collect_sparse_grams_deque), + collect_to_vec(input, collect_sparse_grams_scan), + ); + } + + // -- Equivalence: wide vs deque (sorted, order differs) -- + + #[test] + fn test_wide_equivalence_small() { + for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_wide)), + "mismatch on {:?}", + std::str::from_utf8(input).unwrap_or("?") + ); + } + } + + #[test] + fn test_wide_equivalence_hello_world() { + assert_eq!( + sorted(collect_to_vec(b"hello world", collect_sparse_grams_deque)), + sorted(collect_to_vec(b"hello world", collect_sparse_grams_wide)), + ); + } + + #[test] + fn test_wide_equivalence_long_input() { + let input = b"this is a very long substring where we don't want to construct too long substrings"; + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_wide)), + ); + } + + #[test] + fn test_wide_equivalence_large_diverse() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + assert_eq!( + sorted(collect_to_vec(&input, collect_sparse_grams_deque)), + sorted(collect_to_vec(&input, collect_sparse_grams_wide)), + ); + } + + #[test] + fn test_wide_equivalence_source_code() { + let input = include_bytes!("lib.rs"); + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_wide)), + ); + } + + // -- Equivalence: masked_avx vs deque (sorted, order differs) -- + + #[cfg(target_arch = "x86_64")] + #[test] + fn test_masked_avx_equivalence_small() { + for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_masked_avx)), + "mismatch on {:?}", + std::str::from_utf8(input).unwrap_or("?") + ); + } + } + + #[cfg(target_arch = "x86_64")] + #[test] + fn test_masked_avx_equivalence_large() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + assert_eq!( + sorted(collect_to_vec(&input, collect_sparse_grams_deque)), + sorted(collect_to_vec(&input, collect_sparse_grams_masked_avx)), + ); + } + + #[cfg(target_arch = "x86_64")] + #[test] + fn test_masked_avx_equivalence_source_code() { + let input = include_bytes!("lib.rs"); + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_masked_avx)), + ); + } + + // -- Equivalence: masked vs deque (sorted, order differs) -- + + #[test] + fn test_masked_equivalence_small() { + for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_masked)), + "mismatch on {:?}", + std::str::from_utf8(input).unwrap_or("?") + ); + } + } + + #[test] + fn test_masked_equivalence_hello_world() { + assert_eq!( + sorted(collect_to_vec(b"hello world", collect_sparse_grams_deque)), + sorted(collect_to_vec(b"hello world", collect_sparse_grams_masked)), + ); + } + + #[test] + fn test_masked_equivalence_large() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + assert_eq!( + sorted(collect_to_vec(&input, collect_sparse_grams_deque)), + sorted(collect_to_vec(&input, collect_sparse_grams_masked)), + ); + } + + #[test] + fn test_masked_equivalence_source_code() { + let input = include_bytes!("lib.rs"); + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_masked)), + ); + } +} + diff --git a/crates/sparse-ngrams/src/murmur.rs b/crates/sparse-ngrams/src/murmur.rs new file mode 100644 index 00000000..b9702241 --- /dev/null +++ b/crates/sparse-ngrams/src/murmur.rs @@ -0,0 +1,67 @@ +//! Murmur1 hash function (ported from blackbird_core). + +use std::hash::Hasher; + +trait XorRsh { + fn xor_rsh(self, nbits: u32) -> Self; +} + +impl XorRsh for u32 { + #[inline] + fn xor_rsh(self, nbits: u32) -> u32 { + self ^ (self >> nbits) + } +} + +fn murmur1_hash(bytes: &[u8], seed: u32) -> u32 { + const M: u32 = 0xc6a4_a793; + const R: u32 = 16; + + let mut h = seed ^ (bytes.len() as u32).wrapping_mul(M); + + let chunks_len = bytes.len() / 4 * 4; + for chunk in bytes[..chunks_len].chunks_exact(4) { + let ptr = chunk.as_ptr() as *const u32; + let k = unsafe { ptr.read_unaligned() }; + h = h.wrapping_add(k).wrapping_mul(M).xor_rsh(R); + } + + let mut tail_bytes = [0u8; 4]; + tail_bytes[..bytes.len() - chunks_len].copy_from_slice(&bytes[chunks_len..]); + h = h + .wrapping_add(u32::from_le_bytes(tail_bytes)) + .wrapping_mul(M) + .xor_rsh(R); + + h.wrapping_mul(M).xor_rsh(10).wrapping_mul(M).xor_rsh(17) +} + +/// Hasher implementing the Murmur1 hash function. +struct Murmur1Hasher { + bytes: Vec, +} + +impl Default for Murmur1Hasher { + fn default() -> Self { + Self { + bytes: Vec::with_capacity(64), + } + } +} + +impl Hasher for Murmur1Hasher { + fn write(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + fn finish(&self) -> u64 { + murmur1_hash(&self.bytes, 0).into() + } +} + +pub(crate) fn hash_bigram(gram: (u8, u8)) -> u32 { + use std::hash::Hash; + let mut h = Murmur1Hasher::default(); + gram.hash(&mut h); + h.finish() as u32 +} diff --git a/crates/sparse-ngrams/src/ngram.rs b/crates/sparse-ngrams/src/ngram.rs new file mode 100644 index 00000000..14743813 --- /dev/null +++ b/crates/sparse-ngrams/src/ngram.rs @@ -0,0 +1,143 @@ +//! Compact n-gram representation using a polynomial rolling hash. +//! +//! An [`NGram`] packs both a hash and the byte length into a single `u32`: +//! the upper 24 bits hold the rolling hash and the lower 8 bits hold the length. +//! This makes it suitable as a cheap, fixed-size key for hash maps and sets. + +use std::fmt; + +use crate::MAX_SPARSE_GRAM_SIZE; + +/// Prime for the polynomial rolling hash. +pub(crate) const POLY_HASH_PRIME: u32 = 2_654_435_761; + +/// Precomputed powers of [`POLY_HASH_PRIME`] for rolling-hash range queries. +/// `POLY_POWERS[i] = POLY_HASH_PRIME.pow(i)` (wrapping `u32`). +pub(crate) const POLY_POWERS: [u32; MAX_SPARSE_GRAM_SIZE as usize + 1] = { + let mut p = [0u32; MAX_SPARSE_GRAM_SIZE as usize + 1]; + p[0] = 1; + let mut i = 1; + while i < p.len() { + p[i] = (p[i - 1] as u64 * POLY_HASH_PRIME as u64) as u32; + i += 1; + } + p +}; + +/// A compact n-gram identifier: upper 24 bits are a polynomial rolling hash, +/// lower 8 bits are the byte length of the n-gram. +/// +/// Two `NGram` values are equal iff both their hash and length match, which +/// greatly reduces collision probability compared to a bare hash. +/// +/// # Construction +/// +/// Use [`NGram::from_bytes`] for one-off hashing, or the rolling-hash helpers +/// inside the extraction loop for amortised O(1) computation per n-gram. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct NGram(pub(crate) u32); + +impl NGram { + /// Build an `NGram` by hashing the given byte slice from scratch. + pub fn from_bytes(src: &[u8]) -> Self { + let mut hash = 0u32; + for &byte in src { + hash = hash.wrapping_mul(POLY_HASH_PRIME).wrapping_add(byte as u32); + } + Self((hash << 8) | src.len() as u32) + } + + /// Build an `NGram` from a precomputed rolling hash and a length. + #[inline] + pub(crate) fn from_rolling_hash(hash: u32, len: usize) -> Self { + Self((hash << 8) | len as u32) + } + + /// The byte length of the n-gram (stored in the lower 8 bits). + #[inline] + pub fn len(&self) -> usize { + (self.0 & 0xff) as usize + } + + /// Whether this represents an empty gram (should never happen in practice). + #[inline] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The raw packed `u32` (hash ≪ 8 | len). + #[inline] + pub fn as_u32(&self) -> u32 { + self.0 + } +} + +impl fmt::Debug for NGram { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "NGram({:#x}, len={})", self.0 >> 8, self.len()) + } +} + +/// Compute the rolling hash for `content[start..start+len]` from prefix hashes. +#[inline] +pub(crate) fn ngram_from_range( + prefix_hashes: &[u32; MAX_SPARSE_GRAM_SIZE as usize], + end_hash: u32, + start: usize, + len: usize, +) -> NGram { + let hash = end_hash.wrapping_sub( + prefix_hashes[start & (MAX_SPARSE_GRAM_SIZE as usize - 1)] + .wrapping_mul(POLY_POWERS[len]), + ); + NGram::from_rolling_hash(hash, len) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_bytes_roundtrip() { + let ngram = NGram::from_bytes(b"hello"); + assert_eq!(ngram.len(), 5); + } + + #[test] + fn test_equal_content_equal_ngram() { + assert_eq!(NGram::from_bytes(b"abc"), NGram::from_bytes(b"abc")); + } + + #[test] + fn test_different_content_likely_different() { + assert_ne!(NGram::from_bytes(b"abc"), NGram::from_bytes(b"abd")); + } + + #[test] + fn test_same_hash_different_length() { + // Even if hashes collide, different lengths produce different NGrams. + let a = NGram::from_bytes(b"ab"); + let b = NGram::from_bytes(b"abc"); + assert_ne!(a, b); + } + + #[test] + fn test_rolling_hash_matches_from_bytes() { + let content = b"hello world"; + // Build prefix hashes the same way the extraction loop does. + let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + if !content.is_empty() { + prefix_hashes[1] = content[0] as u32; + } + for idx in 1..content.len() { + let end_hash = prefix_hashes[idx & (MAX_SPARSE_GRAM_SIZE as usize - 1)] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[idx] as u32); + // Check the bigram content[idx-1..idx+1] + let rolling = ngram_from_range(&prefix_hashes, end_hash, idx - 1, 2); + let direct = NGram::from_bytes(&content[idx - 1..idx + 1]); + assert_eq!(rolling, direct, "mismatch at idx={idx}"); + prefix_hashes[(idx + 1) & (MAX_SPARSE_GRAM_SIZE as usize - 1)] = end_hash; + } + } +} diff --git a/crates/sparse-ngrams/src/table.rs b/crates/sparse-ngrams/src/table.rs new file mode 100644 index 00000000..b2d82c8d --- /dev/null +++ b/crates/sparse-ngrams/src/table.rs @@ -0,0 +1,43 @@ +//! Bigram priority table. +//! +//! Assigns a frequency-based priority to each byte pair, used by the sparse n-gram +//! extraction algorithm to decide where n-gram boundaries fall. + +use std::sync::OnceLock; + +use crate::murmur::hash_bigram; +use crate::NUM_FREQUENT_BIGRAMS; + +/// The bigrams in this string are sorted by how frequently they occur in code (descending). +/// Bigrams are separated by null bytes. Only the first [`NUM_FREQUENT_BIGRAMS`] entries +/// receive nonzero priority; all other byte pairs default to `(0, 0)`. +static BIGRAMS_STR: &str = include_str!("bigrams.bin"); + +/// Flat 256×256 lookup table indexed by `a as usize * 256 + b`. +/// Entries default to `(0, 0)` for bigrams not in the frequency table. +static BIGRAM_TABLE: OnceLock> = OnceLock::new(); + +/// Returns the bigram priority table. The first call initializes it (thread-safe). +pub(crate) fn get_bigram_table() -> &'static [(u32, u32); 256 * 256] { + BIGRAM_TABLE.get_or_init(|| { + let mut table = Box::new([(0u32, 0u32); 256 * 256]); + for (idx, s) in BIGRAMS_STR + .split('\0') + .take(NUM_FREQUENT_BIGRAMS) + .enumerate() + { + let mut chars = s.chars(); + let Some((a, b)) = chars.next().zip(chars.next()) else { + continue; + }; + // All top-200 bigrams are ASCII; apply lowercase folding. + let a = (a as u8).to_ascii_lowercase(); + let b = (b as u8).to_ascii_lowercase(); + // Higher-frequency bigrams get HIGHER values so they are more often + // encompassed by longer grams. + table[a as usize * 256 + b as usize] = + ((NUM_FREQUENT_BIGRAMS - idx) as u32, hash_bigram((a, b))); + } + table + }) +} From a27a5b91b92b0a9c57149869e62fa40950383375 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Sat, 25 Apr 2026 15:42:05 +0200 Subject: [PATCH 29/45] avx512 --- crates/sparse-ngrams/src/extract.rs | 31 +++++++++++------------------ crates/sparse-ngrams/src/ngram.rs | 1 + 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index b9efbe66..66f32a7a 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -272,7 +272,7 @@ pub fn collect_sparse_grams_masked_avx(content: &[u8], out: &mut [NGram]) -> usi } #[cfg(target_arch = "x86_64")] -#[target_feature(enable = "avx2")] +#[target_feature(enable = "avx2,avx512f,avx512vl")] unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram]) -> usize { #[cfg(target_arch = "x86_64")] use core::arch::x86_64::*; @@ -348,12 +348,8 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram let v_v1_s = _mm256_xor_si256(v_v1, v_sign); // gt_mask: bits where v1 > prio[k] (signed after flip = unsigned) let gt = _mm256_cmpgt_epi32(v_v1_s, v_prio_s); - // ge = !gt → prio[k] >= v1 - let ge_bits = !(_mm256_movemask_epi8(gt) as u32) & 0xFF; - // movemask gives 32 bits (4 per lane); we want 1 bit per lane. - // Use _mm256_movemask_ps on the comparison result instead. - let gt_ps = _mm256_castsi256_ps(gt); - let gt_mask8 = _mm256_movemask_ps(gt_ps) as u32; + // movemask_ps gives 1 bit per 32-bit lane (8 bits total). + let gt_mask8 = _mm256_movemask_ps(_mm256_castsi256_ps(gt)) as u32; let ge_bits = (!gt_mask8) & 0xFF; // --- Scalar bitmask logic (same as masked variant) --- @@ -383,18 +379,15 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram let v_hash_shifted = _mm256_slli_epi32(v_hash, 8); let v_ngram = _mm256_or_si256(v_hash_shifted, v_dist); - // Store all 8 n-gram candidates to a temp array, then emit selected ones. - let mut ngram_arr = A32([0u32; 8]); - _mm256_store_si256(ngram_arr.0.as_mut_ptr() as *mut __m256i, v_ngram); - - // --- Emit selected n-grams --- - let mut m = emit; - while m != 0 { - let k = m.trailing_zeros() as usize; - out[w] = NGram(ngram_arr.0[k]); - w += 1; - m &= m - 1; - } + // --- AVX-512 compressed store (VPCOMPRESSD): selected lanes written contiguously --- + let count = emit.count_ones() as usize; + // SAFETY: NGram is #[repr(transparent)] over u32, and out[w..w+count] is in bounds. + _mm256_mask_compressstoreu_epi32( + out.as_mut_ptr().add(w) as *mut i32, + emit as __mmask8, + v_ngram, + ); + w += count; // --- Update active --- active = (active & !ge_bits) | cur_bit; diff --git a/crates/sparse-ngrams/src/ngram.rs b/crates/sparse-ngrams/src/ngram.rs index 14743813..56c3603a 100644 --- a/crates/sparse-ngrams/src/ngram.rs +++ b/crates/sparse-ngrams/src/ngram.rs @@ -35,6 +35,7 @@ pub(crate) const POLY_POWERS: [u32; MAX_SPARSE_GRAM_SIZE as usize + 1] = { /// Use [`NGram::from_bytes`] for one-off hashing, or the rolling-hash helpers /// inside the extraction loop for amortised O(1) computation per n-gram. #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] pub struct NGram(pub(crate) u32); impl NGram { From 95bb71c604040e7be659eeb2c6b8417460034245 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Sat, 25 Apr 2026 14:06:38 +0000 Subject: [PATCH 30/45] Optimize masked AVX extraction path - replace dist/poly per-iteration updates with doubled LUT loads - use AVX-512 unsigned compare mask for ge_bits - remove prefix-hash permute via shifted prefix layout --- crates/sparse-ngrams/src/extract.rs | 66 ++++++++++------------------- 1 file changed, 22 insertions(+), 44 deletions(-) diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index 66f32a7a..56df08e9 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -293,44 +293,34 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram #[repr(align(32))] struct A32([u32; 8]); + // Shifted layout: lane k stores prefix_hashes[(k - 1) & 7]. + // This lets the hash step load the needed rotated prefix vector directly. let mut prefix_hashes = A32([0u32; 8]); - prefix_hashes.0[1] = content[0] as u32; + prefix_hashes.0[2] = content[0] as u32; let mut prio_arr = A32([0u32; 8]); let mut active: u32 = 0; - // dist[k] = len for slot k. Starts [2, 9, 8, 7, 6, 5, 4, 3]. - let mut dist_arr = A32([2, 9, 8, 7, 6, 5, 4, 3]); - let mut v_dist = _mm256_load_si256(dist_arr.0.as_ptr() as *const __m256i); - - // poly_circ — initialized one power below because the first thing in the loop is multiply. - let mut poly_arr = A32([ - POLY_POWERS[1], POLY_POWERS[8], POLY_POWERS[7], POLY_POWERS[6], - POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], POLY_POWERS[2], - ]); - let mut v_poly = _mm256_load_si256(poly_arr.0.as_ptr() as *const __m256i); - - let v_prime = _mm256_set1_epi32(POLY_HASH_PRIME as i32); - let v_one = _mm256_set1_epi32(1); + // Doubled LUTs let us load the 8-lane circular view with one unaligned load. + let dist_lut = [2u32, 9, 8, 7, 6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3]; + let poly_pow9 = POLY_POWERS[8].wrapping_mul(POLY_HASH_PRIME); + let poly_lut = [ + POLY_POWERS[2], poly_pow9, POLY_POWERS[8], POLY_POWERS[7], + POLY_POWERS[6], POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], + POLY_POWERS[2], poly_pow9, POLY_POWERS[8], POLY_POWERS[7], + POLY_POWERS[6], POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], + ]; for idx in 1..n as u32 { let slot = idx as usize & CMASK; - // --- AVX2: advance dist += 1, poly_circ *= PRIME --- - v_dist = _mm256_add_epi32(v_dist, v_one); - v_poly = _mm256_mullo_epi32(v_poly, v_prime); - - // Reset current slot: dist[slot] = 2, poly[slot] = POLY_POWERS[2]. - // Insert into the vector via store-to-array, modify, reload. - _mm256_store_si256(dist_arr.0.as_mut_ptr() as *mut __m256i, v_dist); - _mm256_store_si256(poly_arr.0.as_mut_ptr() as *mut __m256i, v_poly); - dist_arr.0[slot] = 2; - poly_arr.0[slot] = POLY_POWERS[2]; - v_dist = _mm256_load_si256(dist_arr.0.as_ptr() as *const __m256i); - v_poly = _mm256_load_si256(poly_arr.0.as_ptr() as *const __m256i); + // Load slot-aligned circular views for dist/poly with a single unaligned read. + let lut_off = (MAX_SPARSE_GRAM_SIZE as usize - slot) & CMASK; + let v_dist = _mm256_loadu_si256(dist_lut.as_ptr().add(lut_off) as *const __m256i); + let v_poly = _mm256_loadu_si256(poly_lut.as_ptr().add(lut_off) as *const __m256i); // --- Scalar: compute end_hash, v1, update prio --- - let end_hash = prefix_hashes.0[slot] + let end_hash = prefix_hashes.0[(slot + 1) & CMASK] .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx as usize] as u32); @@ -338,19 +328,10 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; prio_arr.0[slot] = v1; - // --- AVX2: compare all 8 priorities with v1 --- - // For unsigned >= comparison: (a >= b) iff !(b > a) iff !((b ^ 0x80000000) > (a ^ 0x80000000)) - // using signed comparison after flipping sign bits. + // --- AVX-512: compare all 8 priorities with v1 and get the lane mask directly --- let v_prio = _mm256_load_si256(prio_arr.0.as_ptr() as *const __m256i); let v_v1 = _mm256_set1_epi32(v1 as i32); - let v_sign = _mm256_set1_epi32(i32::MIN); - let v_prio_s = _mm256_xor_si256(v_prio, v_sign); - let v_v1_s = _mm256_xor_si256(v_v1, v_sign); - // gt_mask: bits where v1 > prio[k] (signed after flip = unsigned) - let gt = _mm256_cmpgt_epi32(v_v1_s, v_prio_s); - // movemask_ps gives 1 bit per 32-bit lane (8 bits total). - let gt_mask8 = _mm256_movemask_ps(_mm256_castsi256_ps(gt)) as u32; - let ge_bits = (!gt_mask8) & 0xFF; + let ge_bits = _mm256_cmp_epu32_mask(v_prio, v_v1, _MM_CMPINT_NLT) as u32; // --- Scalar bitmask logic (same as masked variant) --- let cur_bit = 1u32 << slot; @@ -366,11 +347,7 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram // --- AVX2: compute all 8 hashes in parallel --- // hash[k] = end_hash - prefix_hashes[(k-1) & 7] * poly_circ[k] - // Build rotated prefix_hashes: shift each lane's index by -1 mod 8. - // Use _mm256_permutevar8x32_epi32 with indices [(k-1)&7 for k in 0..8]. - let v_ph = _mm256_load_si256(prefix_hashes.0.as_ptr() as *const __m256i); - let v_perm_idx = _mm256_set_epi32(6, 5, 4, 3, 2, 1, 0, 7); // (k-1)&7 for k=7..0 - let v_ph_rot = _mm256_permutevar8x32_epi32(v_ph, v_perm_idx); + let v_ph_rot = _mm256_load_si256(prefix_hashes.0.as_ptr() as *const __m256i); let v_prod = _mm256_mullo_epi32(v_ph_rot, v_poly); let v_end = _mm256_set1_epi32(end_hash as i32); let v_hash = _mm256_sub_epi32(v_end, v_prod); @@ -393,7 +370,8 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram active = (active & !ge_bits) | cur_bit; active &= !(1u32 << (idx.wrapping_sub(MAX_D as u32) as usize & CMASK)); - prefix_hashes.0[(idx as usize + 1) & CMASK] = end_hash; + let next = (idx as usize + 1) & CMASK; + prefix_hashes.0[(next + 1) & CMASK] = end_hash; } w } From 647dee178d1768a2ba74e866308690d4948462e4 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Sat, 25 Apr 2026 14:22:06 +0000 Subject: [PATCH 31/45] inline and simplify scan --- crates/sparse-ngrams/src/extract.rs | 48 ++++++++++++++--------------- crates/sparse-ngrams/src/ngram.rs | 20 +++--------- 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index 56df08e9..25db67af 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -1,7 +1,7 @@ //! Core sparse n-gram extraction algorithm. use crate::deque::{FixedDeque, PosStateBytes}; -use crate::ngram::{NGram, POLY_HASH_PRIME, POLY_POWERS, ngram_from_range}; +use crate::ngram::{NGram, POLY_HASH_PRIME, POLY_POWERS}; use crate::table::get_bigram_table; use crate::MAX_SPARSE_GRAM_SIZE; @@ -51,7 +51,9 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { .wrapping_add(content[idx as usize] as u32); // Bigram - out[w] = ngram_from_range(&prefix_hashes, end_hash, idx as usize - 1, 2); + let bigram_hash = end_hash + .wrapping_sub(prefix_hashes[(idx as usize - 1) & mask].wrapping_mul(POLY_POWERS[2])); + out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; let (v1, _v2) = @@ -65,8 +67,9 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { while let Some(begin) = queue.back() { let start = begin.index as usize - 1; - let len = idx as usize + 1 - start; - out[w] = ngram_from_range(&prefix_hashes, end_hash, start, len); + let len = (idx - begin.index + 2) as usize; + let hash = end_hash.wrapping_sub(prefix_hashes[start & mask].wrapping_mul(POLY_POWERS[len])); + out[w] = NGram::from_rolling_hash(hash, len); w += 1; if begin.value < v1 { break; @@ -103,50 +106,42 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { let table = get_bigram_table(); const MASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; let mut w = 0usize; - let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; prefix_hashes[1] = content[0] as u32; - let mut priorities = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; - for idx in 1..n as u32 { let end_hash = prefix_hashes[idx as usize & MASK] .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx as usize] as u32); - // Bigram - out[w] = ngram_from_range(&prefix_hashes, end_hash, idx as usize - 1, 2); + let bigram_hash = end_hash + .wrapping_sub(prefix_hashes[(idx as usize - 1) & MASK].wrapping_mul(POLY_POWERS[2])); + out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; - let (v1, _v2) = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; priorities[idx as usize & MASK] = v1; - let mut running_min = u32::MAX; - for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { if d >= idx { break; } - let p = idx - d; - let v_p = priorities[p as usize & MASK]; - + let p = (idx - d) as usize; + let v_p = priorities[p & MASK]; if v_p < running_min { - let start = p as usize - 1; - let len = idx as usize + 1 - start; - out[w] = ngram_from_range(&prefix_hashes, end_hash, start, len); + running_min = v_p; + let start = p - 1; + let len = d as usize + 2; + let hash = end_hash.wrapping_sub(prefix_hashes[start & MASK].wrapping_mul(POLY_POWERS[len])); + out[w] = NGram::from_rolling_hash(hash, len); w += 1; if v_p < v1 { break; } } - - running_min = running_min.min(v_p); } - prefix_hashes[(idx as usize + 1) & MASK] = end_hash; } - w } @@ -418,7 +413,9 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx as usize] as u32); - out[w] = ngram_from_range(&circ_ph, end_hash, idx as usize - 1, 2); + let bigram_hash = end_hash + .wrapping_sub(circ_ph[(idx as usize - 1) & MASK].wrapping_mul(POLY_POWERS[2])); + out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; let (v1, _) = @@ -433,8 +430,9 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { let v_p = circ_prio[(idx - d) as usize & MASK]; if v_p < running_min { let start = (idx - d) as usize - 1; - let len = idx as usize + 1 - start; - out[w] = ngram_from_range(&circ_ph, end_hash, start, len); + let len = d as usize + 2; + let hash = end_hash.wrapping_sub(circ_ph[start & MASK].wrapping_mul(POLY_POWERS[len])); + out[w] = NGram::from_rolling_hash(hash, len); w += 1; if v_p < v1 { break; diff --git a/crates/sparse-ngrams/src/ngram.rs b/crates/sparse-ngrams/src/ngram.rs index 56c3603a..5475dcca 100644 --- a/crates/sparse-ngrams/src/ngram.rs +++ b/crates/sparse-ngrams/src/ngram.rs @@ -79,21 +79,6 @@ impl fmt::Debug for NGram { } } -/// Compute the rolling hash for `content[start..start+len]` from prefix hashes. -#[inline] -pub(crate) fn ngram_from_range( - prefix_hashes: &[u32; MAX_SPARSE_GRAM_SIZE as usize], - end_hash: u32, - start: usize, - len: usize, -) -> NGram { - let hash = end_hash.wrapping_sub( - prefix_hashes[start & (MAX_SPARSE_GRAM_SIZE as usize - 1)] - .wrapping_mul(POLY_POWERS[len]), - ); - NGram::from_rolling_hash(hash, len) -} - #[cfg(test)] mod tests { use super::*; @@ -135,7 +120,10 @@ mod tests { .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx] as u32); // Check the bigram content[idx-1..idx+1] - let rolling = ngram_from_range(&prefix_hashes, end_hash, idx - 1, 2); + let rolling_hash = end_hash + .wrapping_sub(prefix_hashes[(idx - 1) & (MAX_SPARSE_GRAM_SIZE as usize - 1)] + .wrapping_mul(POLY_POWERS[2])); + let rolling = NGram::from_rolling_hash(rolling_hash, 2); let direct = NGram::from_bytes(&content[idx - 1..idx + 1]); assert_eq!(rolling, direct, "mismatch at idx={idx}"); prefix_hashes[(idx + 1) & (MAX_SPARSE_GRAM_SIZE as usize - 1)] = end_hash; From 3272bb1bc4488e28ba9ead17b9e5f43678ad5edd Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 27 Apr 2026 05:47:58 +0000 Subject: [PATCH 32/45] wide_avx attempt --- crates/sparse-ngrams/benches/performance.rs | 5 +- crates/sparse-ngrams/src/extract.rs | 231 ++++++++++++++++++++ crates/sparse-ngrams/src/lib.rs | 27 ++- 3 files changed, 261 insertions(+), 2 deletions(-) diff --git a/crates/sparse-ngrams/benches/performance.rs b/crates/sparse-ngrams/benches/performance.rs index f89c84e4..a7926e3d 100644 --- a/crates/sparse-ngrams/benches/performance.rs +++ b/crates/sparse-ngrams/benches/performance.rs @@ -1,5 +1,5 @@ use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; -use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, collect_sparse_grams_masked, collect_sparse_grams_masked_avx, collect_sparse_grams_wide, max_sparse_grams}; +use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, collect_sparse_grams_masked, collect_sparse_grams_masked_avx, collect_sparse_grams_wide, collect_sparse_grams_wide_avx, max_sparse_grams}; fn bench_collect(c: &mut Criterion) { let inputs: Vec<(&str, Vec)> = vec![ @@ -33,6 +33,9 @@ fn bench_collect(c: &mut Criterion) { group.bench_with_input(BenchmarkId::new("wide", name), input, |b, input| { b.iter(|| collect_sparse_grams_wide(black_box(input), &mut buf)) }); + group.bench_with_input(BenchmarkId::new("wide_avx", name), input, |b, input| { + b.iter(|| collect_sparse_grams_wide_avx(black_box(input), &mut buf)) + }); } group.finish(); } diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index 25db67af..78f88f46 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -572,3 +572,234 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { w } + +/// AVX-512 version of [`collect_sparse_grams_wide`]. +/// +/// Keeps the same high-level algorithm and output set, but vectorizes the +/// 16-lane suffix-min/hash inner step explicitly with AVX-512 intrinsics. +#[cfg(target_arch = "x86_64")] +pub fn collect_sparse_grams_wide_avx(content: &[u8], out: &mut [NGram]) -> usize { + // SAFETY: AVX-512 is enabled globally via .cargo/config.toml target-feature flags. + unsafe { collect_sparse_grams_wide_avx_inner(content, out) } +} + +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2,avx512f,avx512vl")] +unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) -> usize { + use core::arch::x86_64::*; + + const LANES: usize = 16; + const BUF: usize = LANES * 2; + const MASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; + + let n = content.len(); + if n < 2 { + return 0; + } + + let table = get_bigram_table(); + let num_bigrams = n - 1; + assert!(out.len() >= max_sparse_grams(n)); + + let mut w = 0usize; + + // Scalar prefix (same as wide) to seed buffers. + let scalar_prefix = LANES.min(num_bigrams); + + let mut circ_ph = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + circ_ph[1] = content[0] as u32; + let mut circ_prio = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + + for idx in 1..=scalar_prefix as u32 { + let end_hash = circ_ph[idx as usize & MASK] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[idx as usize] as u32); + + let bigram_hash = end_hash + .wrapping_sub(circ_ph[(idx as usize - 1) & MASK].wrapping_mul(POLY_POWERS[2])); + out[w] = NGram::from_rolling_hash(bigram_hash, 2); + w += 1; + + let (v1, _) = + table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + circ_prio[idx as usize & MASK] = v1; + + let mut running_min = u32::MAX; + for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { + if d >= idx { + break; + } + let v_p = circ_prio[(idx - d) as usize & MASK]; + if v_p < running_min { + let start = (idx - d) as usize - 1; + let len = d as usize + 2; + let hash = + end_hash.wrapping_sub(circ_ph[start & MASK].wrapping_mul(POLY_POWERS[len])); + out[w] = NGram::from_rolling_hash(hash, len); + w += 1; + if v_p < v1 { + break; + } + } + running_min = running_min.min(v_p); + } + + circ_ph[(idx as usize + 1) & MASK] = end_hash; + } + + if num_bigrams <= LANES { + return w; + } + + let mut ph_buf = [0u32; BUF + 1]; + let mut prio_buf = [0u32; BUF]; + + ph_buf[LANES] = content[0] as u32; + for j in 1..=LANES { + ph_buf[LANES + j] = ph_buf[LANES + j - 1] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[j] as u32); + } + for j in 0..LANES { + prio_buf[LANES + j] = table[content[j] as usize * 256 + content[j + 1] as usize].0; + } + + let remaining = num_bigrams - LANES; + let total_chunks = (remaining + LANES - 1) / LANES; + let mut content_pos = LANES; + + for _chunk in 0..total_chunks { + let chunk_lanes = LANES.min(num_bigrams - content_pos); + + prio_buf.copy_within(LANES..BUF, 0); + ph_buf.copy_within(LANES..BUF + 1, 0); + + ph_buf[LANES] = ph_buf[LANES - 1] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[content_pos] as u32); + for j in 1..=chunk_lanes { + ph_buf[LANES + j] = ph_buf[LANES + j - 1] + .wrapping_mul(POLY_HASH_PRIME) + .wrapping_add(content[content_pos + j] as u32); + } + for j in 0..chunk_lanes { + let ci = content_pos + j; + prio_buf[LANES + j] = table[content[ci] as usize * 256 + content[ci + 1] as usize].0; + } + + let lane_mask: u32 = (1u32 << chunk_lanes) - 1; + + for j in chunk_lanes..LANES { + prio_buf[LANES + j] = 0; + ph_buf[LANES + j + 1] = ph_buf[LANES + chunk_lanes]; + } + + // Emit bigrams. + for i in 0..chunk_lanes { + let hash = ph_buf[LANES + i + 1] + .wrapping_sub(ph_buf[LANES + i - 1].wrapping_mul(POLY_POWERS[2])); + out[w + i] = NGram::from_rolling_hash(hash, 2); + } + w += chunk_lanes; + + let v_current = _mm512_loadu_si512(prio_buf[LANES..].as_ptr() as *const __m512i); + let v_end_hashes = _mm512_loadu_si512(ph_buf[LANES + 1..].as_ptr() as *const __m512i); + let mut v_running_min = _mm512_set1_epi32(-1); + let mut active: u32 = lane_mask; + + let poly3 = _mm512_set1_epi32(POLY_POWERS[3] as i32); + let poly4 = _mm512_set1_epi32(POLY_POWERS[4] as i32); + let poly5 = _mm512_set1_epi32(POLY_POWERS[5] as i32); + let poly6 = _mm512_set1_epi32(POLY_POWERS[6] as i32); + let poly7 = _mm512_set1_epi32(POLY_POWERS[7] as i32); + let poly8 = _mm512_set1_epi32(POLY_POWERS[8] as i32); + + let len3 = _mm512_set1_epi32(3); + let len4 = _mm512_set1_epi32(4); + let len5 = _mm512_set1_epi32(5); + let len6 = _mm512_set1_epi32(6); + let len7 = _mm512_set1_epi32(7); + let len8 = _mm512_set1_epi32(8); + + macro_rules! step { + ($d:expr, $poly:expr, $vlen:expr) => {{ + let v_shifted = + _mm512_loadu_si512(prio_buf[LANES - $d..].as_ptr() as *const __m512i); + let v_is_suffix_min = + _mm512_cmp_epu32_mask(v_shifted, v_running_min, _MM_CMPINT_LT); + let v_should_break = + v_is_suffix_min & _mm512_cmp_epu32_mask(v_shifted, v_current, _MM_CMPINT_LT); + + let v_prev = _mm512_loadu_si512( + ph_buf[LANES - $d - 1..].as_ptr() as *const __m512i, + ); + let v_prod = _mm512_mullo_epi32(v_prev, $poly); + let v_hashes = _mm512_sub_epi32(v_end_hashes, v_prod); + + let emit_mask = active & v_is_suffix_min as u32; + let break_mask = active & v_should_break as u32; + + let v_hash_shifted = _mm512_slli_epi32(v_hashes, 8); + let v_ngram = _mm512_add_epi32(v_hash_shifted, $vlen); + _mm512_mask_compressstoreu_epi32( + out.as_mut_ptr().add(w) as *mut i32, + emit_mask as __mmask16, + v_ngram, + ); + w += emit_mask.count_ones() as usize; + + active &= !break_mask; + v_running_min = _mm512_min_epu32(v_running_min, v_shifted); + }}; + } + + macro_rules! step_last { + ($d:expr, $poly:expr, $vlen:expr) => {{ + let v_shifted = + _mm512_loadu_si512(prio_buf[LANES - $d..].as_ptr() as *const __m512i); + let v_is_suffix_min = + _mm512_cmp_epu32_mask(v_shifted, v_running_min, _MM_CMPINT_LT); + + let v_prev = _mm512_loadu_si512( + ph_buf[LANES - $d - 1..].as_ptr() as *const __m512i, + ); + let v_prod = _mm512_mullo_epi32(v_prev, $poly); + let v_hashes = _mm512_sub_epi32(v_end_hashes, v_prod); + + let emit_mask = active & v_is_suffix_min as u32; + + let v_hash_shifted = _mm512_slli_epi32(v_hashes, 8); + let v_ngram = _mm512_add_epi32(v_hash_shifted, $vlen); + _mm512_mask_compressstoreu_epi32( + out.as_mut_ptr().add(w) as *mut i32, + emit_mask as __mmask16, + v_ngram, + ); + w += emit_mask.count_ones() as usize; + }}; + } + + if active != 0 { + step!(1, poly3, len3); + if active != 0 { + step!(2, poly4, len4); + if active != 0 { + step!(3, poly5, len5); + if active != 0 { + step!(4, poly6, len6); + if active != 0 { + step!(5, poly7, len7); + if active != 0 { + step_last!(6, poly8, len8); + } + } + } + } + } + } + + content_pos += chunk_lanes; + } + + w +} diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index a3f1cb41..33afb8ca 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -44,7 +44,7 @@ pub const MAX_SPARSE_GRAM_SIZE: u32 = 8; pub use extract::{collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_masked, collect_sparse_grams_scan, collect_sparse_grams_wide, max_sparse_grams}; #[cfg(target_arch = "x86_64")] -pub use extract::collect_sparse_grams_masked_avx; +pub use extract::{collect_sparse_grams_masked_avx, collect_sparse_grams_wide_avx}; #[cfg(test)] mod tests { @@ -250,6 +250,31 @@ mod tests { ); } + // -- Equivalence: wide_avx vs deque (sorted, order differs) -- + + #[cfg(target_arch = "x86_64")] + #[test] + fn test_wide_avx_equivalence_small() { + for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { + assert_eq!( + sorted(collect_to_vec(input, collect_sparse_grams_deque)), + sorted(collect_to_vec(input, collect_sparse_grams_wide_avx)), + "mismatch on {:?}", + std::str::from_utf8(input).unwrap_or("?") + ); + } + } + + #[cfg(target_arch = "x86_64")] + #[test] + fn test_wide_avx_equivalence_large() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + assert_eq!( + sorted(collect_to_vec(&input, collect_sparse_grams_deque)), + sorted(collect_to_vec(&input, collect_sparse_grams_wide_avx)), + ); + } + // -- Equivalence: masked vs deque (sorted, order differs) -- #[test] From 295816ddcb2fc111f8a94e7a8acaa517bb8ef6be Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Mon, 27 Apr 2026 09:56:29 +0000 Subject: [PATCH 33/45] change priority to u8 and make priority strict in both directions --- crates/sparse-ngrams/src/deque.rs | 2 +- crates/sparse-ngrams/src/extract.rs | 111 +++++++++++++++------------- crates/sparse-ngrams/src/lib.rs | 1 - crates/sparse-ngrams/src/table.rs | 13 ++-- 4 files changed, 65 insertions(+), 62 deletions(-) diff --git a/crates/sparse-ngrams/src/deque.rs b/crates/sparse-ngrams/src/deque.rs index e75e04b3..b27994f9 100644 --- a/crates/sparse-ngrams/src/deque.rs +++ b/crates/sparse-ngrams/src/deque.rs @@ -8,7 +8,7 @@ pub(crate) struct PosStateBytes { /// Absolute index position between the two bigram characters. /// I.e. 1 references the very first bigram. pub index: u32, - pub value: u32, + pub value: u8, } /// Stack-allocated circular buffer holding up to `CAP` elements. diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index 78f88f46..e084ad0a 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -36,13 +36,11 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { return 0; } assert!(out.len() >= max_sparse_grams(n)); - let table = get_bigram_table(); let mut queue = FixedDeque::<{ MAX_SPARSE_GRAM_SIZE as usize }>::new(); - let mut w = 0usize; - let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; prefix_hashes[1] = content[0] as u32; + let mut w = 0usize; for idx in 1..n as u32 { let mask = MAX_SPARSE_GRAM_SIZE as usize - 1; @@ -56,7 +54,7 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; - let (v1, _v2) = + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; if let Some(begin) = queue.front() { @@ -64,27 +62,26 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { queue.pop_front(); } } - while let Some(begin) = queue.back() { let start = begin.index as usize - 1; let len = (idx - begin.index + 2) as usize; let hash = end_hash.wrapping_sub(prefix_hashes[start & mask].wrapping_mul(POLY_POWERS[len])); out[w] = NGram::from_rolling_hash(hash, len); w += 1; - if begin.value < v1 { + if begin.value == v1 { + queue.pop_back(); + break; + } else if begin.value <= v1 { break; } queue.pop_back(); } - queue.push_back(PosStateBytes { index: idx, value: v1, }); - prefix_hashes[(idx as usize + 1) & mask] = end_hash; } - w } @@ -108,7 +105,7 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { let mut w = 0usize; let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; prefix_hashes[1] = content[0] as u32; - let mut priorities = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut priorities = [u8::MAX; MAX_SPARSE_GRAM_SIZE as usize]; for idx in 1..n as u32 { let end_hash = prefix_hashes[idx as usize & MASK] .wrapping_mul(POLY_HASH_PRIME) @@ -118,24 +115,24 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { .wrapping_sub(prefix_hashes[(idx as usize - 1) & MASK].wrapping_mul(POLY_POWERS[2])); out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; - let (v1, _v2) = + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; priorities[idx as usize & MASK] = v1; - let mut running_min = u32::MAX; + let mut running_min = u8::MAX; for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { if d >= idx { break; } - let p = (idx - d) as usize; - let v_p = priorities[p & MASK]; + let p = idx.wrapping_sub(d) as usize & MASK; + let v_p = priorities[p]; if v_p < running_min { running_min = v_p; - let start = p - 1; + let start = p.wrapping_sub(1) & MASK; let len = d as usize + 2; - let hash = end_hash.wrapping_sub(prefix_hashes[start & MASK].wrapping_mul(POLY_POWERS[len])); + let hash = end_hash.wrapping_sub(prefix_hashes[start].wrapping_mul(POLY_POWERS[len])); out[w] = NGram::from_rolling_hash(hash, len); w += 1; - if v_p < v1 { + if v_p <= v1 { break; } } @@ -176,7 +173,7 @@ pub fn collect_sparse_grams_masked(content: &[u8], out: &mut [NGram]) -> usize { let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; prefix_hashes[1] = content[0] as u32; - let mut prio_circ = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut prio_circ = [0u8; MAX_SPARSE_GRAM_SIZE as usize]; let mut active: u32 = 0; // bitmask: bit k ↔ array slot k is in the deque let mut dist = [2usize, 9, 8, 7, 6, 5, 4, 3]; // dist[k] = ((idx - k) & CMASK) + 2 // poly_circ[k] = POLY_HASH_PRIME^(dist[k] + 2) — tracks the poly power for each slot. @@ -204,7 +201,7 @@ pub fn collect_sparse_grams_masked(content: &[u8], out: &mut [NGram]) -> usize { let end_hash = prefix_hashes[idx as usize & CMASK] .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx as usize] as u32); - let (v1, _) = + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; prio_circ[idx as usize & CMASK] = v1; // Compare all lookback priorities with v1 (no idx dependency in the loop). @@ -213,21 +210,28 @@ pub fn collect_sparse_grams_masked(content: &[u8], out: &mut [NGram]) -> usize { ge_bits |= ((prio_circ[k] >= v1) as u32) << k; } // Emit from active deque positions: - // - all with v >= v1 (they get popped) - // - plus the nearest one with v < v1 (it stays) + // - all with v > v1 (they get popped) + // - plus the nearest one with v <= v1 (it stays if v < v1, cleaned up if v == v1) let cur_bit = 1u32 << (idx as usize & CMASK); - let emit_ge = active & ge_bits; - let active_lt = active & !ge_bits; - // Find nearest (smallest distance) active position with v < v1. + let gt_bits: u32 = ge_bits & ge_bits.wrapping_sub(/* eq mask */ { + let mut eq: u32 = 0; + for k in 0..MAX_SPARSE_GRAM_SIZE as usize { + eq |= ((prio_circ[k] == v1) as u32) << k; + } + eq + }); + let emit_gt = active & gt_bits; + let active_le = active & !gt_bits; + // Find nearest (smallest distance) active position with v <= v1. // Rotate so bit 7 = distance 1, bit 6 = distance 2, ..., bit 1 = distance 7. - // Bit 0 = distance 0 (cur_bit's slot, never in active_lt) → used as sentinel. - // When empty, leading_zeros finds the sentinel; first_lt becomes cur_bit (harmless). - let doubled = active_lt | (active_lt << 8); + // Bit 0 = distance 0 (cur_bit's slot, never in active_le) → used as sentinel. + // When empty, leading_zeros finds the sentinel; first_le becomes cur_bit (harmless). + let doubled = active_le | (active_le << 8); let shift = idx as usize & CMASK; let rotated = ((doubled >> shift) & 0xFF) | 1; let b = 31 - rotated.leading_zeros() as usize; - let first_lt = 1u32 << ((shift + b) & CMASK); - let emit = emit_ge | first_lt | cur_bit; + let first_le = 1u32 << ((shift + b) & CMASK); + let emit = emit_gt | first_le | cur_bit; // Write emitted ngrams. let mut m = emit; while m != 0 { @@ -239,8 +243,8 @@ pub fn collect_sparse_grams_masked(content: &[u8], out: &mut [NGram]) -> usize { w += 1; m &= m - 1; } - // Update active: keep survivors with v < v1, add current position, - // and evict the slot that just left the lookback window. + // Update active: keep survivors with v < v1 (ge_bits clears >= v1 including == v1), + // add current position, and evict the slot that just left the lookback window. active = (active & !ge_bits) | (1 << (idx as usize & CMASK)); active &= !(1u32 << (idx.wrapping_sub(MAX_D as u32) as usize & CMASK)); prefix_hashes[(idx as usize + 1) & CMASK] = end_hash; @@ -319,26 +323,27 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx as usize] as u32); - let (v1, _) = + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; - prio_arr.0[slot] = v1; + prio_arr.0[slot] = v1 as u32; // --- AVX-512: compare all 8 priorities with v1 and get the lane mask directly --- let v_prio = _mm256_load_si256(prio_arr.0.as_ptr() as *const __m256i); let v_v1 = _mm256_set1_epi32(v1 as i32); let ge_bits = _mm256_cmp_epu32_mask(v_prio, v_v1, _MM_CMPINT_NLT) as u32; + let gt_bits = _mm256_cmp_epu32_mask(v_prio, v_v1, _MM_CMPINT_NLE) as u32; // --- Scalar bitmask logic (same as masked variant) --- let cur_bit = 1u32 << slot; - let emit_ge = active & ge_bits; - let active_lt = active & !ge_bits; + let emit_gt = active & gt_bits; + let active_le = active & !gt_bits; - let doubled = active_lt | (active_lt << 8); + let doubled = active_le | (active_le << 8); let shift = idx as usize & CMASK; let rotated = ((doubled >> shift) & 0xFF) | 1; let b = 31 - rotated.leading_zeros() as usize; - let first_lt = 1u32 << ((shift + b) & CMASK); - let emit = emit_ge | first_lt | cur_bit; + let first_le = 1u32 << ((shift + b) & CMASK); + let emit = emit_gt | first_le | cur_bit; // --- AVX2: compute all 8 hashes in parallel --- // hash[k] = end_hash - prefix_hashes[(k-1) & 7] * poly_circ[k] @@ -361,7 +366,7 @@ unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram ); w += count; - // --- Update active --- + // --- Update active: ge_bits (>= v1) clears both > v1 and == v1 entries --- active = (active & !ge_bits) | cur_bit; active &= !(1u32 << (idx.wrapping_sub(MAX_D as u32) as usize & CMASK)); @@ -406,7 +411,7 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { let mut circ_ph = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; circ_ph[1] = content[0] as u32; - let mut circ_prio = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut circ_prio = [0u8; MAX_SPARSE_GRAM_SIZE as usize]; for idx in 1..=scalar_prefix as u32 { let end_hash = circ_ph[idx as usize & MASK] @@ -418,11 +423,11 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; - let (v1, _) = + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; circ_prio[idx as usize & MASK] = v1; - let mut running_min = u32::MAX; + let mut running_min = u8::MAX; for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { if d >= idx { break; @@ -434,7 +439,7 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { let hash = end_hash.wrapping_sub(circ_ph[start & MASK].wrapping_mul(POLY_POWERS[len])); out[w] = NGram::from_rolling_hash(hash, len); w += 1; - if v_p < v1 { + if v_p <= v1 { break; } } @@ -467,7 +472,7 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { } for j in 0..LANES { prio_buf[LANES + j] = - table[content[j] as usize * 256 + content[j + 1] as usize].0; + table[content[j] as usize * 256 + content[j + 1] as usize] as u32; } // --- Wide chunk loop (includes partial last chunk) ----------------------- @@ -494,7 +499,7 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { for j in 0..chunk_lanes { let ci = content_pos + j; prio_buf[LANES + j] = - table[content[ci] as usize * 256 + content[ci + 1] as usize].0; + table[content[ci] as usize * 256 + content[ci + 1] as usize] as u32; } let lane_mask: u32 = (1u32 << chunk_lanes) - 1; // bits 0..chunk_lanes @@ -544,7 +549,7 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { candidate_hashes[i] = end_hashes[i] .wrapping_sub(ph_buf[LANES + i - d - 1].wrapping_mul(poly_power)); is_suffix_min[i] = v_shifted[i] < running_min[i]; - should_break[i] = is_suffix_min[i] && v_shifted[i] < v_current[i]; + should_break[i] = is_suffix_min[i] && v_shifted[i] <= v_current[i]; running_min[i] = running_min[i].min(v_shifted[i]); } @@ -608,7 +613,7 @@ unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) let mut circ_ph = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; circ_ph[1] = content[0] as u32; - let mut circ_prio = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut circ_prio = [0u8; MAX_SPARSE_GRAM_SIZE as usize]; for idx in 1..=scalar_prefix as u32 { let end_hash = circ_ph[idx as usize & MASK] @@ -620,11 +625,11 @@ unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; - let (v1, _) = + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; circ_prio[idx as usize & MASK] = v1; - let mut running_min = u32::MAX; + let mut running_min = u8::MAX; for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { if d >= idx { break; @@ -637,7 +642,7 @@ unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) end_hash.wrapping_sub(circ_ph[start & MASK].wrapping_mul(POLY_POWERS[len])); out[w] = NGram::from_rolling_hash(hash, len); w += 1; - if v_p < v1 { + if v_p <= v1 { break; } } @@ -661,7 +666,7 @@ unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) .wrapping_add(content[j] as u32); } for j in 0..LANES { - prio_buf[LANES + j] = table[content[j] as usize * 256 + content[j + 1] as usize].0; + prio_buf[LANES + j] = table[content[j] as usize * 256 + content[j + 1] as usize] as u32; } let remaining = num_bigrams - LANES; @@ -684,7 +689,7 @@ unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) } for j in 0..chunk_lanes { let ci = content_pos + j; - prio_buf[LANES + j] = table[content[ci] as usize * 256 + content[ci + 1] as usize].0; + prio_buf[LANES + j] = table[content[ci] as usize * 256 + content[ci + 1] as usize] as u32; } let lane_mask: u32 = (1u32 << chunk_lanes) - 1; @@ -728,7 +733,7 @@ unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) let v_is_suffix_min = _mm512_cmp_epu32_mask(v_shifted, v_running_min, _MM_CMPINT_LT); let v_should_break = - v_is_suffix_min & _mm512_cmp_epu32_mask(v_shifted, v_current, _MM_CMPINT_LT); + v_is_suffix_min & _mm512_cmp_epu32_mask(v_shifted, v_current, _MM_CMPINT_LE); let v_prev = _mm512_loadu_si512( ph_buf[LANES - $d - 1..].as_ptr() as *const __m512i, diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index 33afb8ca..a74c386e 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -30,7 +30,6 @@ mod deque; mod extract; -mod murmur; mod ngram; mod table; diff --git a/crates/sparse-ngrams/src/table.rs b/crates/sparse-ngrams/src/table.rs index b2d82c8d..5cbd2acb 100644 --- a/crates/sparse-ngrams/src/table.rs +++ b/crates/sparse-ngrams/src/table.rs @@ -5,22 +5,21 @@ use std::sync::OnceLock; -use crate::murmur::hash_bigram; use crate::NUM_FREQUENT_BIGRAMS; /// The bigrams in this string are sorted by how frequently they occur in code (descending). /// Bigrams are separated by null bytes. Only the first [`NUM_FREQUENT_BIGRAMS`] entries -/// receive nonzero priority; all other byte pairs default to `(0, 0)`. +/// receive nonzero priority; all other byte pairs default to 0. static BIGRAMS_STR: &str = include_str!("bigrams.bin"); /// Flat 256×256 lookup table indexed by `a as usize * 256 + b`. -/// Entries default to `(0, 0)` for bigrams not in the frequency table. -static BIGRAM_TABLE: OnceLock> = OnceLock::new(); +/// Entries default to 0 for bigrams not in the frequency table. +static BIGRAM_TABLE: OnceLock> = OnceLock::new(); /// Returns the bigram priority table. The first call initializes it (thread-safe). -pub(crate) fn get_bigram_table() -> &'static [(u32, u32); 256 * 256] { +pub(crate) fn get_bigram_table() -> &'static [u8; 256 * 256] { BIGRAM_TABLE.get_or_init(|| { - let mut table = Box::new([(0u32, 0u32); 256 * 256]); + let mut table = Box::new([0u8; 256 * 256]); for (idx, s) in BIGRAMS_STR .split('\0') .take(NUM_FREQUENT_BIGRAMS) @@ -36,7 +35,7 @@ pub(crate) fn get_bigram_table() -> &'static [(u32, u32); 256 * 256] { // Higher-frequency bigrams get HIGHER values so they are more often // encompassed by longer grams. table[a as usize * 256 + b as usize] = - ((NUM_FREQUENT_BIGRAMS - idx) as u32, hash_bigram((a, b))); + (NUM_FREQUENT_BIGRAMS - idx) as u8; } table }) From feceb049d7d7df55e7c1d42d24b75ef72c5ef61c Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 8 May 2026 15:09:04 +0200 Subject: [PATCH 34/45] move benches to different folder --- crates/sparse-ngrams/Cargo.toml | 2 +- .../sparse-ngrams/{benches => benchmarks}/performance.rs | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) rename crates/sparse-ngrams/{benches => benchmarks}/performance.rs (78%) diff --git a/crates/sparse-ngrams/Cargo.toml b/crates/sparse-ngrams/Cargo.toml index 4f5b6890..d5bcde0b 100644 --- a/crates/sparse-ngrams/Cargo.toml +++ b/crates/sparse-ngrams/Cargo.toml @@ -13,7 +13,7 @@ bench = false [[bench]] name = "performance" -path = "benches/performance.rs" +path = "benchmarks/performance.rs" harness = false [dev-dependencies] diff --git a/crates/sparse-ngrams/benches/performance.rs b/crates/sparse-ngrams/benchmarks/performance.rs similarity index 78% rename from crates/sparse-ngrams/benches/performance.rs rename to crates/sparse-ngrams/benchmarks/performance.rs index a7926e3d..bc4b322f 100644 --- a/crates/sparse-ngrams/benches/performance.rs +++ b/crates/sparse-ngrams/benchmarks/performance.rs @@ -1,5 +1,5 @@ use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; -use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, collect_sparse_grams_masked, collect_sparse_grams_masked_avx, collect_sparse_grams_wide, collect_sparse_grams_wide_avx, max_sparse_grams}; +use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, collect_sparse_grams_masked, collect_sparse_grams_wide, max_sparse_grams}; fn bench_collect(c: &mut Criterion) { let inputs: Vec<(&str, Vec)> = vec![ @@ -27,15 +27,9 @@ fn bench_collect(c: &mut Criterion) { group.bench_with_input(BenchmarkId::new("masked", name), input, |b, input| { b.iter(|| collect_sparse_grams_masked(black_box(input), &mut buf)) }); - group.bench_with_input(BenchmarkId::new("masked_avx", name), input, |b, input| { - b.iter(|| collect_sparse_grams_masked_avx(black_box(input), &mut buf)) - }); group.bench_with_input(BenchmarkId::new("wide", name), input, |b, input| { b.iter(|| collect_sparse_grams_wide(black_box(input), &mut buf)) }); - group.bench_with_input(BenchmarkId::new("wide_avx", name), input, |b, input| { - b.iter(|| collect_sparse_grams_wide_avx(black_box(input), &mut buf)) - }); } group.finish(); } From 2521d026c04af7ae7d784c429bd64b25805b5ff8 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 8 May 2026 15:24:25 +0200 Subject: [PATCH 35/45] increase priority --- crates/sparse-ngrams/src/deque.rs | 2 +- crates/sparse-ngrams/src/extract.rs | 12 +++++------- crates/sparse-ngrams/src/lib.rs | 2 +- crates/sparse-ngrams/src/table.rs | 9 ++++----- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/sparse-ngrams/src/deque.rs b/crates/sparse-ngrams/src/deque.rs index b27994f9..3738ba01 100644 --- a/crates/sparse-ngrams/src/deque.rs +++ b/crates/sparse-ngrams/src/deque.rs @@ -8,7 +8,7 @@ pub(crate) struct PosStateBytes { /// Absolute index position between the two bigram characters. /// I.e. 1 references the very first bigram. pub index: u32, - pub value: u8, + pub value: u16, } /// Stack-allocated circular buffer holding up to `CAP` elements. diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index e084ad0a..16d5e16d 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -105,7 +105,7 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { let mut w = 0usize; let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; prefix_hashes[1] = content[0] as u32; - let mut priorities = [u8::MAX; MAX_SPARSE_GRAM_SIZE as usize]; + let mut priorities = [u16::MAX; MAX_SPARSE_GRAM_SIZE as usize]; for idx in 1..n as u32 { let end_hash = prefix_hashes[idx as usize & MASK] .wrapping_mul(POLY_HASH_PRIME) @@ -118,7 +118,7 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; priorities[idx as usize & MASK] = v1; - let mut running_min = u8::MAX; + let mut running_min = u16::MAX; for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { if d >= idx { break; @@ -141,8 +141,6 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { } w } - -/// Masked variant: maintains the deque as a bitmask across iterations. /// /// Since deque elements have strictly increasing priorities (front→back), comparing /// each lookback priority with the current priority `v1` is enough to determine @@ -173,7 +171,7 @@ pub fn collect_sparse_grams_masked(content: &[u8], out: &mut [NGram]) -> usize { let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; prefix_hashes[1] = content[0] as u32; - let mut prio_circ = [0u8; MAX_SPARSE_GRAM_SIZE as usize]; + let mut prio_circ = [0u16; MAX_SPARSE_GRAM_SIZE as usize]; let mut active: u32 = 0; // bitmask: bit k ↔ array slot k is in the deque let mut dist = [2usize, 9, 8, 7, 6, 5, 4, 3]; // dist[k] = ((idx - k) & CMASK) + 2 // poly_circ[k] = POLY_HASH_PRIME^(dist[k] + 2) — tracks the poly power for each slot. @@ -411,7 +409,7 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { let mut circ_ph = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; circ_ph[1] = content[0] as u32; - let mut circ_prio = [0u8; MAX_SPARSE_GRAM_SIZE as usize]; + let mut circ_prio = [0u16; MAX_SPARSE_GRAM_SIZE as usize]; for idx in 1..=scalar_prefix as u32 { let end_hash = circ_ph[idx as usize & MASK] @@ -427,7 +425,7 @@ pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; circ_prio[idx as usize & MASK] = v1; - let mut running_min = u8::MAX; + let mut running_min = u16::MAX; for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { if d >= idx { break; diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index a74c386e..64914077 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -36,7 +36,7 @@ mod table; pub use ngram::NGram; /// Number of high-frequency bigrams used to build the priority table. -pub const NUM_FREQUENT_BIGRAMS: usize = 200; +pub const NUM_FREQUENT_BIGRAMS: usize = 65536; /// Maximum length (in bytes) of a sparse n-gram. pub const MAX_SPARSE_GRAM_SIZE: u32 = 8; diff --git a/crates/sparse-ngrams/src/table.rs b/crates/sparse-ngrams/src/table.rs index 5cbd2acb..70d55f56 100644 --- a/crates/sparse-ngrams/src/table.rs +++ b/crates/sparse-ngrams/src/table.rs @@ -14,12 +14,12 @@ static BIGRAMS_STR: &str = include_str!("bigrams.bin"); /// Flat 256×256 lookup table indexed by `a as usize * 256 + b`. /// Entries default to 0 for bigrams not in the frequency table. -static BIGRAM_TABLE: OnceLock> = OnceLock::new(); +static BIGRAM_TABLE: OnceLock> = OnceLock::new(); /// Returns the bigram priority table. The first call initializes it (thread-safe). -pub(crate) fn get_bigram_table() -> &'static [u8; 256 * 256] { +pub(crate) fn get_bigram_table() -> &'static [u16; 256 * 256] { BIGRAM_TABLE.get_or_init(|| { - let mut table = Box::new([0u8; 256 * 256]); + let mut table = Box::new([0u16; 256 * 256]); for (idx, s) in BIGRAMS_STR .split('\0') .take(NUM_FREQUENT_BIGRAMS) @@ -29,13 +29,12 @@ pub(crate) fn get_bigram_table() -> &'static [u8; 256 * 256] { let Some((a, b)) = chars.next().zip(chars.next()) else { continue; }; - // All top-200 bigrams are ASCII; apply lowercase folding. let a = (a as u8).to_ascii_lowercase(); let b = (b as u8).to_ascii_lowercase(); // Higher-frequency bigrams get HIGHER values so they are more often // encompassed by longer grams. table[a as usize * 256 + b as usize] = - (NUM_FREQUENT_BIGRAMS - idx) as u8; + (NUM_FREQUENT_BIGRAMS - idx) as u16; } table }) From d6bd09f039f303a1710035a58c7c8fdcd82618de Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 8 May 2026 16:08:34 +0200 Subject: [PATCH 36/45] remove the slower implementations --- .../sparse-ngrams/benchmarks/performance.rs | 8 +- crates/sparse-ngrams/src/extract.rs | 665 ------------------ crates/sparse-ngrams/src/lib.rs | 159 +---- 3 files changed, 3 insertions(+), 829 deletions(-) diff --git a/crates/sparse-ngrams/benchmarks/performance.rs b/crates/sparse-ngrams/benchmarks/performance.rs index bc4b322f..4a24063a 100644 --- a/crates/sparse-ngrams/benchmarks/performance.rs +++ b/crates/sparse-ngrams/benchmarks/performance.rs @@ -1,5 +1,5 @@ use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; -use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, collect_sparse_grams_masked, collect_sparse_grams_wide, max_sparse_grams}; +use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams}; fn bench_collect(c: &mut Criterion) { let inputs: Vec<(&str, Vec)> = vec![ @@ -24,12 +24,6 @@ fn bench_collect(c: &mut Criterion) { group.bench_with_input(BenchmarkId::new("scan", name), input, |b, input| { b.iter(|| collect_sparse_grams_scan(black_box(input), &mut buf)) }); - group.bench_with_input(BenchmarkId::new("masked", name), input, |b, input| { - b.iter(|| collect_sparse_grams_masked(black_box(input), &mut buf)) - }); - group.bench_with_input(BenchmarkId::new("wide", name), input, |b, input| { - b.iter(|| collect_sparse_grams_wide(black_box(input), &mut buf)) - }); } group.finish(); } diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index 16d5e16d..f7c24e43 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -141,668 +141,3 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { } w } -/// -/// Since deque elements have strictly increasing priorities (front→back), comparing -/// each lookback priority with the current priority `v1` is enough to determine -/// which elements to emit and pop — no prefix-min scan needed. -/// -/// Bit `k` in the `active` mask means the position at lookback offset `k+1` is -/// currently in the deque. At each step: compare all 6 lookback priorities with `v1`, -/// emit from active positions, update the mask with a shift+OR+AND. -/// -/// Produces the same *set* of n-grams as [`collect_sparse_grams_scan`], but in a different order. -/// -/// # Panics -/// -/// Panics if `out` is too small. -pub fn collect_sparse_grams_masked(content: &[u8], out: &mut [NGram]) -> usize { - const MAX_D: usize = MAX_SPARSE_GRAM_SIZE as usize - 2; // 6 - const CMASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; // 7 - - let n = content.len(); - if n < 2 { - return 0; - } - assert!(out.len() >= max_sparse_grams(n)); - - let table = get_bigram_table(); - let mut w = 0usize; - - let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; - prefix_hashes[1] = content[0] as u32; - - let mut prio_circ = [0u16; MAX_SPARSE_GRAM_SIZE as usize]; - let mut active: u32 = 0; // bitmask: bit k ↔ array slot k is in the deque - let mut dist = [2usize, 9, 8, 7, 6, 5, 4, 3]; // dist[k] = ((idx - k) & CMASK) + 2 - // poly_circ[k] = POLY_HASH_PRIME^(dist[k] + 2) — tracks the poly power for each slot. - // After each iteration dist increments, so we multiply all by PRIME and reset - // the wrapping slot. Initial values correspond to dist = [0, 7, 6, 5, 4, 3, 2, 1], - // but we need power dist+2. For the slot with dist=7 that means power 9, which - // we'll get naturally after one multiply (it starts at power 8, becomes 9 after - // the first multiply, then gets reset to 2 when it wraps). - // So initialize poly_circ[k] = POLY_POWERS[dist_init[k] + 1] instead, since the - // first thing in the loop is the multiply. - let mut poly_circ = [ - POLY_POWERS[1], POLY_POWERS[8], POLY_POWERS[7], POLY_POWERS[6], - POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], POLY_POWERS[2], - ]; - - for idx in 1..n as u32 { - // Advance all distances by 1 and multiply poly powers. - for k in 0..MAX_SPARSE_GRAM_SIZE as usize { - dist[k] += 1; - poly_circ[k] = poly_circ[k].wrapping_mul(POLY_HASH_PRIME); - } - // The current-position slot just exceeded MAX_SPARSE_GRAM_SIZE+1; reset it. - dist[idx as usize & CMASK] = 2; - poly_circ[idx as usize & CMASK] = POLY_POWERS[2]; - let end_hash = prefix_hashes[idx as usize & CMASK] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[idx as usize] as u32); - let v1 = - table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; - prio_circ[idx as usize & CMASK] = v1; - // Compare all lookback priorities with v1 (no idx dependency in the loop). - let mut ge_bits: u32 = 0; - for k in 0..MAX_SPARSE_GRAM_SIZE as usize { - ge_bits |= ((prio_circ[k] >= v1) as u32) << k; - } - // Emit from active deque positions: - // - all with v > v1 (they get popped) - // - plus the nearest one with v <= v1 (it stays if v < v1, cleaned up if v == v1) - let cur_bit = 1u32 << (idx as usize & CMASK); - let gt_bits: u32 = ge_bits & ge_bits.wrapping_sub(/* eq mask */ { - let mut eq: u32 = 0; - for k in 0..MAX_SPARSE_GRAM_SIZE as usize { - eq |= ((prio_circ[k] == v1) as u32) << k; - } - eq - }); - let emit_gt = active & gt_bits; - let active_le = active & !gt_bits; - // Find nearest (smallest distance) active position with v <= v1. - // Rotate so bit 7 = distance 1, bit 6 = distance 2, ..., bit 1 = distance 7. - // Bit 0 = distance 0 (cur_bit's slot, never in active_le) → used as sentinel. - // When empty, leading_zeros finds the sentinel; first_le becomes cur_bit (harmless). - let doubled = active_le | (active_le << 8); - let shift = idx as usize & CMASK; - let rotated = ((doubled >> shift) & 0xFF) | 1; - let b = 31 - rotated.leading_zeros() as usize; - let first_le = 1u32 << ((shift + b) & CMASK); - let emit = emit_gt | first_le | cur_bit; - // Write emitted ngrams. - let mut m = emit; - while m != 0 { - let k = m.trailing_zeros() as usize; - let len = dist[k]; - let hash = end_hash - .wrapping_sub(prefix_hashes[k.wrapping_sub(1) & CMASK].wrapping_mul(poly_circ[k])); - out[w] = NGram((hash << 8) | len as u32); - w += 1; - m &= m - 1; - } - // Update active: keep survivors with v < v1 (ge_bits clears >= v1 including == v1), - // add current position, and evict the slot that just left the lookback window. - active = (active & !ge_bits) | (1 << (idx as usize & CMASK)); - active &= !(1u32 << (idx.wrapping_sub(MAX_D as u32) as usize & CMASK)); - prefix_hashes[(idx as usize + 1) & CMASK] = end_hash; - } - w -} - -/// AVX2 variant of [`collect_sparse_grams_masked`]. -/// -/// Uses 256-bit SIMD to update the 8-element circular arrays (`poly_circ`, -/// `prio_circ`, `dist`, `prefix_hashes`) and to compute the ge_bits comparison -/// and hash outputs in parallel. The bitmask logic (active/emit/first_lt) remains -/// scalar since it operates on 8-bit masks. -/// -/// Produces the same *set* of n-grams as [`collect_sparse_grams_masked`]. -/// -/// # Panics -/// -/// Panics if `out` is too small. -#[cfg(target_arch = "x86_64")] -pub fn collect_sparse_grams_masked_avx(content: &[u8], out: &mut [NGram]) -> usize { - // SAFETY: AVX2 is enabled globally via .cargo/config.toml target-feature flags. - unsafe { collect_sparse_grams_masked_avx_inner(content, out) } -} - -#[cfg(target_arch = "x86_64")] -#[target_feature(enable = "avx2,avx512f,avx512vl")] -unsafe fn collect_sparse_grams_masked_avx_inner(content: &[u8], out: &mut [NGram]) -> usize { - #[cfg(target_arch = "x86_64")] - use core::arch::x86_64::*; - - const MAX_D: usize = MAX_SPARSE_GRAM_SIZE as usize - 2; // 6 - const CMASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; // 7 - - let n = content.len(); - if n < 2 { - return 0; - } - assert!(out.len() >= max_sparse_grams(n)); - - let table = get_bigram_table(); - let mut w = 0usize; - - // Aligned arrays for SIMD load/store. - #[repr(align(32))] - struct A32([u32; 8]); - - // Shifted layout: lane k stores prefix_hashes[(k - 1) & 7]. - // This lets the hash step load the needed rotated prefix vector directly. - let mut prefix_hashes = A32([0u32; 8]); - prefix_hashes.0[2] = content[0] as u32; - - let mut prio_arr = A32([0u32; 8]); - let mut active: u32 = 0; - - // Doubled LUTs let us load the 8-lane circular view with one unaligned load. - let dist_lut = [2u32, 9, 8, 7, 6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3]; - let poly_pow9 = POLY_POWERS[8].wrapping_mul(POLY_HASH_PRIME); - let poly_lut = [ - POLY_POWERS[2], poly_pow9, POLY_POWERS[8], POLY_POWERS[7], - POLY_POWERS[6], POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], - POLY_POWERS[2], poly_pow9, POLY_POWERS[8], POLY_POWERS[7], - POLY_POWERS[6], POLY_POWERS[5], POLY_POWERS[4], POLY_POWERS[3], - ]; - - for idx in 1..n as u32 { - let slot = idx as usize & CMASK; - - // Load slot-aligned circular views for dist/poly with a single unaligned read. - let lut_off = (MAX_SPARSE_GRAM_SIZE as usize - slot) & CMASK; - let v_dist = _mm256_loadu_si256(dist_lut.as_ptr().add(lut_off) as *const __m256i); - let v_poly = _mm256_loadu_si256(poly_lut.as_ptr().add(lut_off) as *const __m256i); - - // --- Scalar: compute end_hash, v1, update prio --- - let end_hash = prefix_hashes.0[(slot + 1) & CMASK] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[idx as usize] as u32); - - let v1 = - table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; - prio_arr.0[slot] = v1 as u32; - - // --- AVX-512: compare all 8 priorities with v1 and get the lane mask directly --- - let v_prio = _mm256_load_si256(prio_arr.0.as_ptr() as *const __m256i); - let v_v1 = _mm256_set1_epi32(v1 as i32); - let ge_bits = _mm256_cmp_epu32_mask(v_prio, v_v1, _MM_CMPINT_NLT) as u32; - let gt_bits = _mm256_cmp_epu32_mask(v_prio, v_v1, _MM_CMPINT_NLE) as u32; - - // --- Scalar bitmask logic (same as masked variant) --- - let cur_bit = 1u32 << slot; - let emit_gt = active & gt_bits; - let active_le = active & !gt_bits; - - let doubled = active_le | (active_le << 8); - let shift = idx as usize & CMASK; - let rotated = ((doubled >> shift) & 0xFF) | 1; - let b = 31 - rotated.leading_zeros() as usize; - let first_le = 1u32 << ((shift + b) & CMASK); - let emit = emit_gt | first_le | cur_bit; - - // --- AVX2: compute all 8 hashes in parallel --- - // hash[k] = end_hash - prefix_hashes[(k-1) & 7] * poly_circ[k] - let v_ph_rot = _mm256_load_si256(prefix_hashes.0.as_ptr() as *const __m256i); - let v_prod = _mm256_mullo_epi32(v_ph_rot, v_poly); - let v_end = _mm256_set1_epi32(end_hash as i32); - let v_hash = _mm256_sub_epi32(v_end, v_prod); - - // Compute (hash << 8) | dist for all lanes. - let v_hash_shifted = _mm256_slli_epi32(v_hash, 8); - let v_ngram = _mm256_or_si256(v_hash_shifted, v_dist); - - // --- AVX-512 compressed store (VPCOMPRESSD): selected lanes written contiguously --- - let count = emit.count_ones() as usize; - // SAFETY: NGram is #[repr(transparent)] over u32, and out[w..w+count] is in bounds. - _mm256_mask_compressstoreu_epi32( - out.as_mut_ptr().add(w) as *mut i32, - emit as __mmask8, - v_ngram, - ); - w += count; - - // --- Update active: ge_bits (>= v1) clears both > v1 and == v1 entries --- - active = (active & !ge_bits) | cur_bit; - active &= !(1u32 << (idx.wrapping_sub(MAX_D as u32) as usize & CMASK)); - - let next = (idx as usize + 1) & CMASK; - prefix_hashes.0[(next + 1) & CMASK] = end_hash; - } - w -} - -/// SIMD-friendly wide variant that processes 16 end positions in parallel. -/// -/// Uses two fixed-size `[u32; LANES * 2]` sliding buffers (priorities + prefix hashes) -/// on the stack — no heap allocation. Each chunk copies the back half to the front, -/// then fills the back half from `content`, so all reads are simple flat-array indexing. -/// -/// Writes n-grams into `out` (must have at least [`max_sparse_grams`]`(content.len())` -/// slots). Returns the count written. -/// -/// Produces the same *set* of n-grams as the other variants, but in a different order. -/// -/// # Panics -/// -/// Panics if `out` is too small. -pub fn collect_sparse_grams_wide(content: &[u8], out: &mut [NGram]) -> usize { - const LANES: usize = 16; - const BUF: usize = LANES * 2; - const MASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; - - let n = content.len(); - if n < 2 { - return 0; - } - - let table = get_bigram_table(); - let num_bigrams = n - 1; - assert!(out.len() >= max_sparse_grams(n)); - - let mut w = 0usize; - - // --- Scalar prefix: first LANES positions (fills the initial buffer) ---- - let scalar_prefix = LANES.min(num_bigrams); - - let mut circ_ph = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; - circ_ph[1] = content[0] as u32; - let mut circ_prio = [0u16; MAX_SPARSE_GRAM_SIZE as usize]; - - for idx in 1..=scalar_prefix as u32 { - let end_hash = circ_ph[idx as usize & MASK] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[idx as usize] as u32); - - let bigram_hash = end_hash - .wrapping_sub(circ_ph[(idx as usize - 1) & MASK].wrapping_mul(POLY_POWERS[2])); - out[w] = NGram::from_rolling_hash(bigram_hash, 2); - w += 1; - - let v1 = - table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; - circ_prio[idx as usize & MASK] = v1; - - let mut running_min = u16::MAX; - for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { - if d >= idx { - break; - } - let v_p = circ_prio[(idx - d) as usize & MASK]; - if v_p < running_min { - let start = (idx - d) as usize - 1; - let len = d as usize + 2; - let hash = end_hash.wrapping_sub(circ_ph[start & MASK].wrapping_mul(POLY_POWERS[len])); - out[w] = NGram::from_rolling_hash(hash, len); - w += 1; - if v_p <= v1 { - break; - } - } - running_min = running_min.min(v_p); - } - - circ_ph[(idx as usize + 1) & MASK] = end_hash; - } - - if num_bigrams <= LANES { - return w; - } - - // --- Seed the sliding buffers ------------------------------------------- - // Each buffer has BUF=32 slots. The back half [LANES..BUF) corresponds to - // the LANES positions we just processed. Before the first wide chunk we'll - // copy back→front, then fill back with fresh data. - // - // ph_buf[LANES + j] = ph[j + 1] for j in 0..LANES - // prio_buf[LANES + j] = priority at 1-based position j+1 - - let mut ph_buf = [0u32; BUF + 1]; - let mut prio_buf = [0u32; BUF]; - - ph_buf[LANES] = content[0] as u32; - for j in 1..=LANES { - ph_buf[LANES + j] = ph_buf[LANES + j - 1] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[j] as u32); - } - for j in 0..LANES { - prio_buf[LANES + j] = - table[content[j] as usize * 256 + content[j + 1] as usize] as u32; - } - - // --- Wide chunk loop (includes partial last chunk) ----------------------- - let remaining = num_bigrams - LANES; - let total_chunks = (remaining + LANES - 1) / LANES; // ceiling division - let mut content_pos = LANES; // 0-based: next byte to process - - for _chunk in 0..total_chunks { - let chunk_lanes = LANES.min(num_bigrams - (content_pos)); // active lanes this chunk - - // Slide: back → front. - prio_buf.copy_within(LANES..BUF, 0); - ph_buf.copy_within(LANES..BUF + 1, 0); - - // Fill back half — only chunk_lanes+1 prefix hashes and chunk_lanes priorities. - ph_buf[LANES] = ph_buf[LANES - 1] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[content_pos] as u32); - for j in 1..=chunk_lanes { - ph_buf[LANES + j] = ph_buf[LANES + j - 1] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[content_pos + j] as u32); - } - for j in 0..chunk_lanes { - let ci = content_pos + j; - prio_buf[LANES + j] = - table[content[ci] as usize * 256 + content[ci + 1] as usize] as u32; - } - - let lane_mask: u32 = (1u32 << chunk_lanes) - 1; // bits 0..chunk_lanes - - // Pad unfilled back-half slots so the 0..LANES loops read valid memory. - // Inactive lanes will be masked out by lane_mask. - for j in chunk_lanes..LANES { - prio_buf[LANES + j] = 0; - ph_buf[LANES + j + 1] = ph_buf[LANES + chunk_lanes]; // any valid value - } - - // These loops MUST iterate 0..LANES (compile-time constant) for auto-vectorization. - let mut v_current = [0u32; LANES]; - let mut end_hashes = [0u32; LANES]; - for i in 0..LANES { - v_current[i] = prio_buf[LANES + i]; - end_hashes[i] = ph_buf[LANES + i + 1]; - } - - // Emit bigrams for active lanes. - for i in 0..chunk_lanes { - let hash = end_hashes[i] - .wrapping_sub(ph_buf[LANES + i - 1].wrapping_mul(POLY_POWERS[2])); - out[w + i] = NGram::from_rolling_hash(hash, 2); - } - w += chunk_lanes; - - // Backward suffix-minimum scan — inner loops always 0..LANES for vectorization. - let mut running_min = [u32::MAX; LANES]; - let mut active: u32 = lane_mask; - - for d in 1..=(MAX_SPARSE_GRAM_SIZE as usize - 2) { - if active == 0 { - break; - } - - let len = d + 2; - let poly_power = POLY_POWERS[len]; - - let mut v_shifted = [0u32; LANES]; - let mut candidate_hashes = [0u32; LANES]; - let mut is_suffix_min = [false; LANES]; - let mut should_break = [false; LANES]; - - for i in 0..LANES { - v_shifted[i] = prio_buf[LANES + i - d]; - candidate_hashes[i] = end_hashes[i] - .wrapping_sub(ph_buf[LANES + i - d - 1].wrapping_mul(poly_power)); - is_suffix_min[i] = v_shifted[i] < running_min[i]; - should_break[i] = is_suffix_min[i] && v_shifted[i] <= v_current[i]; - running_min[i] = running_min[i].min(v_shifted[i]); - } - - let mut emit_mask: u32 = 0; - let mut break_mask: u32 = 0; - for i in 0..LANES { - emit_mask |= (is_suffix_min[i] as u32) << i; - break_mask |= (should_break[i] as u32) << i; - } - emit_mask &= active; - - let mut m = emit_mask; - while m != 0 { - let i = m.trailing_zeros() as usize; - out[w] = NGram::from_rolling_hash(candidate_hashes[i], len); - w += 1; - m &= m - 1; - } - - active &= !break_mask; - } - - content_pos += chunk_lanes; - } - - w -} - -/// AVX-512 version of [`collect_sparse_grams_wide`]. -/// -/// Keeps the same high-level algorithm and output set, but vectorizes the -/// 16-lane suffix-min/hash inner step explicitly with AVX-512 intrinsics. -#[cfg(target_arch = "x86_64")] -pub fn collect_sparse_grams_wide_avx(content: &[u8], out: &mut [NGram]) -> usize { - // SAFETY: AVX-512 is enabled globally via .cargo/config.toml target-feature flags. - unsafe { collect_sparse_grams_wide_avx_inner(content, out) } -} - -#[cfg(target_arch = "x86_64")] -#[target_feature(enable = "avx2,avx512f,avx512vl")] -unsafe fn collect_sparse_grams_wide_avx_inner(content: &[u8], out: &mut [NGram]) -> usize { - use core::arch::x86_64::*; - - const LANES: usize = 16; - const BUF: usize = LANES * 2; - const MASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; - - let n = content.len(); - if n < 2 { - return 0; - } - - let table = get_bigram_table(); - let num_bigrams = n - 1; - assert!(out.len() >= max_sparse_grams(n)); - - let mut w = 0usize; - - // Scalar prefix (same as wide) to seed buffers. - let scalar_prefix = LANES.min(num_bigrams); - - let mut circ_ph = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; - circ_ph[1] = content[0] as u32; - let mut circ_prio = [0u8; MAX_SPARSE_GRAM_SIZE as usize]; - - for idx in 1..=scalar_prefix as u32 { - let end_hash = circ_ph[idx as usize & MASK] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[idx as usize] as u32); - - let bigram_hash = end_hash - .wrapping_sub(circ_ph[(idx as usize - 1) & MASK].wrapping_mul(POLY_POWERS[2])); - out[w] = NGram::from_rolling_hash(bigram_hash, 2); - w += 1; - - let v1 = - table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; - circ_prio[idx as usize & MASK] = v1; - - let mut running_min = u8::MAX; - for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { - if d >= idx { - break; - } - let v_p = circ_prio[(idx - d) as usize & MASK]; - if v_p < running_min { - let start = (idx - d) as usize - 1; - let len = d as usize + 2; - let hash = - end_hash.wrapping_sub(circ_ph[start & MASK].wrapping_mul(POLY_POWERS[len])); - out[w] = NGram::from_rolling_hash(hash, len); - w += 1; - if v_p <= v1 { - break; - } - } - running_min = running_min.min(v_p); - } - - circ_ph[(idx as usize + 1) & MASK] = end_hash; - } - - if num_bigrams <= LANES { - return w; - } - - let mut ph_buf = [0u32; BUF + 1]; - let mut prio_buf = [0u32; BUF]; - - ph_buf[LANES] = content[0] as u32; - for j in 1..=LANES { - ph_buf[LANES + j] = ph_buf[LANES + j - 1] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[j] as u32); - } - for j in 0..LANES { - prio_buf[LANES + j] = table[content[j] as usize * 256 + content[j + 1] as usize] as u32; - } - - let remaining = num_bigrams - LANES; - let total_chunks = (remaining + LANES - 1) / LANES; - let mut content_pos = LANES; - - for _chunk in 0..total_chunks { - let chunk_lanes = LANES.min(num_bigrams - content_pos); - - prio_buf.copy_within(LANES..BUF, 0); - ph_buf.copy_within(LANES..BUF + 1, 0); - - ph_buf[LANES] = ph_buf[LANES - 1] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[content_pos] as u32); - for j in 1..=chunk_lanes { - ph_buf[LANES + j] = ph_buf[LANES + j - 1] - .wrapping_mul(POLY_HASH_PRIME) - .wrapping_add(content[content_pos + j] as u32); - } - for j in 0..chunk_lanes { - let ci = content_pos + j; - prio_buf[LANES + j] = table[content[ci] as usize * 256 + content[ci + 1] as usize] as u32; - } - - let lane_mask: u32 = (1u32 << chunk_lanes) - 1; - - for j in chunk_lanes..LANES { - prio_buf[LANES + j] = 0; - ph_buf[LANES + j + 1] = ph_buf[LANES + chunk_lanes]; - } - - // Emit bigrams. - for i in 0..chunk_lanes { - let hash = ph_buf[LANES + i + 1] - .wrapping_sub(ph_buf[LANES + i - 1].wrapping_mul(POLY_POWERS[2])); - out[w + i] = NGram::from_rolling_hash(hash, 2); - } - w += chunk_lanes; - - let v_current = _mm512_loadu_si512(prio_buf[LANES..].as_ptr() as *const __m512i); - let v_end_hashes = _mm512_loadu_si512(ph_buf[LANES + 1..].as_ptr() as *const __m512i); - let mut v_running_min = _mm512_set1_epi32(-1); - let mut active: u32 = lane_mask; - - let poly3 = _mm512_set1_epi32(POLY_POWERS[3] as i32); - let poly4 = _mm512_set1_epi32(POLY_POWERS[4] as i32); - let poly5 = _mm512_set1_epi32(POLY_POWERS[5] as i32); - let poly6 = _mm512_set1_epi32(POLY_POWERS[6] as i32); - let poly7 = _mm512_set1_epi32(POLY_POWERS[7] as i32); - let poly8 = _mm512_set1_epi32(POLY_POWERS[8] as i32); - - let len3 = _mm512_set1_epi32(3); - let len4 = _mm512_set1_epi32(4); - let len5 = _mm512_set1_epi32(5); - let len6 = _mm512_set1_epi32(6); - let len7 = _mm512_set1_epi32(7); - let len8 = _mm512_set1_epi32(8); - - macro_rules! step { - ($d:expr, $poly:expr, $vlen:expr) => {{ - let v_shifted = - _mm512_loadu_si512(prio_buf[LANES - $d..].as_ptr() as *const __m512i); - let v_is_suffix_min = - _mm512_cmp_epu32_mask(v_shifted, v_running_min, _MM_CMPINT_LT); - let v_should_break = - v_is_suffix_min & _mm512_cmp_epu32_mask(v_shifted, v_current, _MM_CMPINT_LE); - - let v_prev = _mm512_loadu_si512( - ph_buf[LANES - $d - 1..].as_ptr() as *const __m512i, - ); - let v_prod = _mm512_mullo_epi32(v_prev, $poly); - let v_hashes = _mm512_sub_epi32(v_end_hashes, v_prod); - - let emit_mask = active & v_is_suffix_min as u32; - let break_mask = active & v_should_break as u32; - - let v_hash_shifted = _mm512_slli_epi32(v_hashes, 8); - let v_ngram = _mm512_add_epi32(v_hash_shifted, $vlen); - _mm512_mask_compressstoreu_epi32( - out.as_mut_ptr().add(w) as *mut i32, - emit_mask as __mmask16, - v_ngram, - ); - w += emit_mask.count_ones() as usize; - - active &= !break_mask; - v_running_min = _mm512_min_epu32(v_running_min, v_shifted); - }}; - } - - macro_rules! step_last { - ($d:expr, $poly:expr, $vlen:expr) => {{ - let v_shifted = - _mm512_loadu_si512(prio_buf[LANES - $d..].as_ptr() as *const __m512i); - let v_is_suffix_min = - _mm512_cmp_epu32_mask(v_shifted, v_running_min, _MM_CMPINT_LT); - - let v_prev = _mm512_loadu_si512( - ph_buf[LANES - $d - 1..].as_ptr() as *const __m512i, - ); - let v_prod = _mm512_mullo_epi32(v_prev, $poly); - let v_hashes = _mm512_sub_epi32(v_end_hashes, v_prod); - - let emit_mask = active & v_is_suffix_min as u32; - - let v_hash_shifted = _mm512_slli_epi32(v_hashes, 8); - let v_ngram = _mm512_add_epi32(v_hash_shifted, $vlen); - _mm512_mask_compressstoreu_epi32( - out.as_mut_ptr().add(w) as *mut i32, - emit_mask as __mmask16, - v_ngram, - ); - w += emit_mask.count_ones() as usize; - }}; - } - - if active != 0 { - step!(1, poly3, len3); - if active != 0 { - step!(2, poly4, len4); - if active != 0 { - step!(3, poly5, len5); - if active != 0 { - step!(4, poly6, len6); - if active != 0 { - step!(5, poly7, len7); - if active != 0 { - step_last!(6, poly8, len8); - } - } - } - } - } - } - - content_pos += chunk_lanes; - } - - w -} diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index 64914077..5720ae3f 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -36,24 +36,17 @@ mod table; pub use ngram::NGram; /// Number of high-frequency bigrams used to build the priority table. -pub const NUM_FREQUENT_BIGRAMS: usize = 65536; +pub const NUM_FREQUENT_BIGRAMS: usize = 65534; /// Maximum length (in bytes) of a sparse n-gram. pub const MAX_SPARSE_GRAM_SIZE: u32 = 8; -pub use extract::{collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_masked, collect_sparse_grams_scan, collect_sparse_grams_wide, max_sparse_grams}; -#[cfg(target_arch = "x86_64")] -pub use extract::{collect_sparse_grams_masked_avx, collect_sparse_grams_wide_avx}; +pub use extract::{collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams}; #[cfg(test)] mod tests { use super::*; - fn sorted(mut v: Vec) -> Vec { - v.sort(); - v - } - fn collect_to_vec(content: &[u8], f: fn(&[u8], &mut [NGram]) -> usize) -> Vec { let mut buf = vec![NGram::from_rolling_hash(0, 0); max_sparse_grams(content.len())]; let count = f(content, &mut buf); @@ -165,153 +158,5 @@ mod tests { ); } - // -- Equivalence: wide vs deque (sorted, order differs) -- - - #[test] - fn test_wide_equivalence_small() { - for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_wide)), - "mismatch on {:?}", - std::str::from_utf8(input).unwrap_or("?") - ); - } - } - - #[test] - fn test_wide_equivalence_hello_world() { - assert_eq!( - sorted(collect_to_vec(b"hello world", collect_sparse_grams_deque)), - sorted(collect_to_vec(b"hello world", collect_sparse_grams_wide)), - ); - } - - #[test] - fn test_wide_equivalence_long_input() { - let input = b"this is a very long substring where we don't want to construct too long substrings"; - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_wide)), - ); - } - - #[test] - fn test_wide_equivalence_large_diverse() { - let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - assert_eq!( - sorted(collect_to_vec(&input, collect_sparse_grams_deque)), - sorted(collect_to_vec(&input, collect_sparse_grams_wide)), - ); - } - - #[test] - fn test_wide_equivalence_source_code() { - let input = include_bytes!("lib.rs"); - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_wide)), - ); - } - - // -- Equivalence: masked_avx vs deque (sorted, order differs) -- - - #[cfg(target_arch = "x86_64")] - #[test] - fn test_masked_avx_equivalence_small() { - for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_masked_avx)), - "mismatch on {:?}", - std::str::from_utf8(input).unwrap_or("?") - ); - } - } - - #[cfg(target_arch = "x86_64")] - #[test] - fn test_masked_avx_equivalence_large() { - let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - assert_eq!( - sorted(collect_to_vec(&input, collect_sparse_grams_deque)), - sorted(collect_to_vec(&input, collect_sparse_grams_masked_avx)), - ); - } - - #[cfg(target_arch = "x86_64")] - #[test] - fn test_masked_avx_equivalence_source_code() { - let input = include_bytes!("lib.rs"); - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_masked_avx)), - ); - } - - // -- Equivalence: wide_avx vs deque (sorted, order differs) -- - - #[cfg(target_arch = "x86_64")] - #[test] - fn test_wide_avx_equivalence_small() { - for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_wide_avx)), - "mismatch on {:?}", - std::str::from_utf8(input).unwrap_or("?") - ); - } - } - - #[cfg(target_arch = "x86_64")] - #[test] - fn test_wide_avx_equivalence_large() { - let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - assert_eq!( - sorted(collect_to_vec(&input, collect_sparse_grams_deque)), - sorted(collect_to_vec(&input, collect_sparse_grams_wide_avx)), - ); - } - - // -- Equivalence: masked vs deque (sorted, order differs) -- - - #[test] - fn test_masked_equivalence_small() { - for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_masked)), - "mismatch on {:?}", - std::str::from_utf8(input).unwrap_or("?") - ); - } - } - - #[test] - fn test_masked_equivalence_hello_world() { - assert_eq!( - sorted(collect_to_vec(b"hello world", collect_sparse_grams_deque)), - sorted(collect_to_vec(b"hello world", collect_sparse_grams_masked)), - ); - } - - #[test] - fn test_masked_equivalence_large() { - let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - assert_eq!( - sorted(collect_to_vec(&input, collect_sparse_grams_deque)), - sorted(collect_to_vec(&input, collect_sparse_grams_masked)), - ); - } - - #[test] - fn test_masked_equivalence_source_code() { - let input = include_bytes!("lib.rs"); - assert_eq!( - sorted(collect_to_vec(input, collect_sparse_grams_deque)), - sorted(collect_to_vec(input, collect_sparse_grams_masked)), - ); - } } From a540549164c10ba7fc8dc88ad1284675c174df35 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 8 May 2026 16:19:39 +0200 Subject: [PATCH 37/45] refactor tests --- crates/sparse-ngrams/src/extract.rs | 207 ++++++++++++++++++++++++++++ crates/sparse-ngrams/src/lib.rs | 117 ---------------- 2 files changed, 207 insertions(+), 117 deletions(-) diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index f7c24e43..0f845faf 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -141,3 +141,210 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { } w } + +#[cfg(test)] +mod tests { + use super::*; + use crate::table::get_bigram_table; + use std::collections::HashSet; + + fn collect_to_vec(content: &[u8], f: fn(&[u8], &mut [NGram]) -> usize) -> Vec { + let mut buf = vec![NGram::from_rolling_hash(0, 0); max_sparse_grams(content.len())]; + let count = f(content, &mut buf); + buf.truncate(count); + buf + } + + /// Brute-force reference implementation. + /// + /// Enumerates all substrings of length 2..=MAX_SPARSE_GRAM_SIZE and emits those + /// where every interior bigram priority is strictly greater than `max(left, right)` + /// boundary bigram priority. All bigrams (len=2) are always emitted. + fn brute_force_sparse_grams(content: &[u8]) -> HashSet { + let table = get_bigram_table(); + let n = content.len(); + let mut result = HashSet::new(); + if n < 2 { + return result; + } + // All bigrams. + for i in 0..n - 1 { + result.insert(NGram::from_bytes(&content[i..i + 2])); + } + // Longer grams: length 3..=MAX_SPARSE_GRAM_SIZE. + for len in 3..=MAX_SPARSE_GRAM_SIZE as usize { + 'outer: for start in 0..=n.saturating_sub(len) { + if start + len > n { + break; + } + let left = table[content[start] as usize * 256 + content[start + 1] as usize]; + let right = + table[content[start + len - 2] as usize * 256 + content[start + len - 1] as usize]; + let boundary = left.max(right); + // Inner bigrams: bytes [start+1,start+2], ..., [start+len-3,start+len-2] + for k in 1..len - 2 { + let p = table[content[start + k] as usize * 256 + content[start + k + 1] as usize]; + if p <= boundary { + continue 'outer; + } + } + result.insert(NGram::from_bytes(&content[start..start + len])); + } + } + result + } + + #[test] + fn test_empty_input() { + assert!(collect_sparse_grams(b"").is_empty()); + } + + #[test] + fn test_single_byte() { + assert!(collect_sparse_grams(b"a").is_empty()); + } + + #[test] + fn test_two_bytes() { + let grams = collect_sparse_grams(b"ab"); + assert_eq!(grams.len(), 1); + assert_eq!(grams[0], NGram::from_bytes(b"ab")); + } + + #[test] + fn test_three_bytes() { + let grams = collect_sparse_grams(b"abc"); + assert!(grams.len() >= 2); + assert_eq!(grams[0], NGram::from_bytes(b"ab")); + assert_eq!(grams[1], NGram::from_bytes(b"bc")); + } + + #[test] + fn test_gram_lengths_bounded() { + let input = b"self.reset_states(the_quick_brown_fox_jumps"; + let grams = collect_sparse_grams(input); + for gram in &grams { + assert!(gram.len() >= 2, "gram too short: {gram:?}"); + assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize, "gram too long: {gram:?}"); + } + } + + #[test] + fn test_produces_longer_grams() { + let grams = collect_sparse_grams(b"self.reset_states("); + assert!(grams.iter().any(|g| g.len() > 2)); + } + + #[test] + fn test_max_gram_size_boundary() { + let grams = collect_sparse_grams(b"abcdefgh"); + for gram in &grams { + assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize); + } + } + + #[test] + fn test_repeated_bytes() { + let grams = collect_sparse_grams(b"aaaaaaaaaa"); + assert!(grams.iter().filter(|g| g.len() == 2).count() >= 9); + } + + #[test] + fn test_gram_count_scales_linearly() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + let grams = collect_sparse_grams(&input); + assert!(grams.len() >= input.len() - 1); + assert!(grams.len() <= input.len() * 3); + } + + // -- Equivalence: scan vs deque -- + + #[test] + fn test_scan_equivalence_small() { + for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { + assert_eq!( + collect_to_vec(input, collect_sparse_grams_deque), + collect_to_vec(input, collect_sparse_grams_scan), + "mismatch on {:?}", + std::str::from_utf8(input).unwrap_or("?") + ); + } + } + + #[test] + fn test_scan_equivalence_hello_world() { + let input = b"hello world"; + assert_eq!( + collect_to_vec(input, collect_sparse_grams_deque), + collect_to_vec(input, collect_sparse_grams_scan), + ); + } + + #[test] + fn test_scan_equivalence_large() { + let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); + assert_eq!( + collect_to_vec(&input, collect_sparse_grams_deque), + collect_to_vec(&input, collect_sparse_grams_scan), + ); + } + + #[test] + fn test_scan_equivalence_source_code() { + let input = include_bytes!("lib.rs"); + assert_eq!( + collect_to_vec(input, collect_sparse_grams_deque), + collect_to_vec(input, collect_sparse_grams_scan), + ); + } + + // -- Brute-force equivalence -- + + fn assert_matches_brute_force(input: &[u8]) { + let grams = collect_sparse_grams(input); + let actual: HashSet = grams.into_iter().collect(); + let expected = brute_force_sparse_grams(input); + let only_actual: Vec<_> = actual.difference(&expected).collect(); + let only_expected: Vec<_> = expected.difference(&actual).collect(); + if !only_actual.is_empty() || !only_expected.is_empty() { + panic!( + "mismatch on input len={}\n only in algorithm: {:?}\n only in brute force: {:?}", + input.len(), only_actual, only_expected + ); + } + } + + #[test] + fn test_brute_force_small() { + for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcd", b"abcdefgh", b"abcdefghi"] { + assert_matches_brute_force(input); + } + } + + #[test] + fn test_brute_force_hello_world() { + assert_matches_brute_force(b"hello world"); + } + + #[test] + fn test_brute_force_repeated() { + assert_matches_brute_force(b"aaaaaaaaaa"); + } + + #[test] + fn test_brute_force_code_snippet() { + assert_matches_brute_force(b"self.reset_states(the_quick_brown_fox_jumps"); + } + + #[test] + fn test_brute_force_diverse() { + let input: Vec = (0..200).map(|i| (i % 256) as u8).collect(); + assert_matches_brute_force(&input); + } + + #[test] + fn test_brute_force_source_code() { + let input = include_bytes!("lib.rs"); + assert_matches_brute_force(input); + } +} diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index 5720ae3f..ead20518 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -43,120 +43,3 @@ pub const MAX_SPARSE_GRAM_SIZE: u32 = 8; pub use extract::{collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams}; -#[cfg(test)] -mod tests { - use super::*; - - fn collect_to_vec(content: &[u8], f: fn(&[u8], &mut [NGram]) -> usize) -> Vec { - let mut buf = vec![NGram::from_rolling_hash(0, 0); max_sparse_grams(content.len())]; - let count = f(content, &mut buf); - buf.truncate(count); - buf - } - - #[test] - fn test_empty_input() { - assert!(collect_sparse_grams(b"").is_empty()); - } - - #[test] - fn test_single_byte() { - assert!(collect_sparse_grams(b"a").is_empty()); - } - - #[test] - fn test_two_bytes() { - let grams = collect_sparse_grams(b"ab"); - assert_eq!(grams.len(), 1); - assert_eq!(grams[0], NGram::from_bytes(b"ab")); - } - - #[test] - fn test_three_bytes() { - let grams = collect_sparse_grams(b"abc"); - assert!(grams.len() >= 2); - assert_eq!(grams[0], NGram::from_bytes(b"ab")); - assert_eq!(grams[1], NGram::from_bytes(b"bc")); - } - - #[test] - fn test_gram_lengths_bounded() { - let input = b"self.reset_states(the_quick_brown_fox_jumps"; - let grams = collect_sparse_grams(input); - for gram in &grams { - assert!(gram.len() >= 2, "gram too short: {gram:?}"); - assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize, "gram too long: {gram:?}"); - } - } - - #[test] - fn test_produces_longer_grams() { - let grams = collect_sparse_grams(b"self.reset_states("); - assert!(grams.iter().any(|g| g.len() > 2)); - } - - #[test] - fn test_max_gram_size_boundary() { - let grams = collect_sparse_grams(b"abcdefgh"); - for gram in &grams { - assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize); - } - } - - #[test] - fn test_repeated_bytes() { - let grams = collect_sparse_grams(b"aaaaaaaaaa"); - assert!(grams.iter().filter(|g| g.len() == 2).count() >= 9); - } - - #[test] - fn test_gram_count_scales_linearly() { - let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - let grams = collect_sparse_grams(&input); - assert!(grams.len() >= input.len() - 1); - assert!(grams.len() <= input.len() * 3); - } - - // -- Equivalence: scan vs deque -- - - #[test] - fn test_scan_equivalence_small() { - for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcdefgh", b"abcdefghi"] { - assert_eq!( - collect_to_vec(input, collect_sparse_grams_deque), - collect_to_vec(input, collect_sparse_grams_scan), - "mismatch on {:?}", - std::str::from_utf8(input).unwrap_or("?") - ); - } - } - - #[test] - fn test_scan_equivalence_hello_world() { - let input = b"hello world"; - assert_eq!( - collect_to_vec(input, collect_sparse_grams_deque), - collect_to_vec(input, collect_sparse_grams_scan), - ); - } - - #[test] - fn test_scan_equivalence_large() { - let input: Vec = (0..1000).map(|i| (i % 256) as u8).collect(); - assert_eq!( - collect_to_vec(&input, collect_sparse_grams_deque), - collect_to_vec(&input, collect_sparse_grams_scan), - ); - } - - #[test] - fn test_scan_equivalence_source_code() { - let input = include_bytes!("lib.rs"); - assert_eq!( - collect_to_vec(input, collect_sparse_grams_deque), - collect_to_vec(input, collect_sparse_grams_scan), - ); - } - -} - From a0b80ea08ac6bb75ad7e70fff9aaea322ab438f7 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 8 May 2026 16:29:43 +0200 Subject: [PATCH 38/45] add readme --- crates/sparse-ngrams/README.md | 57 ++++++++++++++++++ .../images/unique_ngrams_vs_table_size.png | Bin 0 -> 61129 bytes crates/sparse-ngrams/src/lib.rs | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 crates/sparse-ngrams/README.md create mode 100644 crates/sparse-ngrams/images/unique_ngrams_vs_table_size.png diff --git a/crates/sparse-ngrams/README.md b/crates/sparse-ngrams/README.md new file mode 100644 index 00000000..a901a185 --- /dev/null +++ b/crates/sparse-ngrams/README.md @@ -0,0 +1,57 @@ +# sparse-ngrams + +Fast sparse n-gram extraction from byte slices. + +Sparse grams select variable-length n-grams (2–8 bytes) without extracting all possible substrings. The algorithm is deterministic: the same extraction logic applies to every substring, making it suitable for substring search indexes. + +## How it works + +Each consecutive byte pair (bigram) is assigned a frequency-based priority from a precomputed table. An n-gram boundary occurs wherever a bigram has lower priority than all bigrams between it and the previous boundary. This is computed efficiently using a monotone deque or a scan-based approach. + +For a document of N bytes, this produces at most 3(N−1) n-grams: N−1 bigrams, plus up to 2(N−1) algorithmically selected longer n-grams (up to 8 bytes). + +### Selection criterion + +A substring of length 3–8 is emitted as a sparse n-gram if and only if every interior bigram priority is strictly greater than the maximum of the left and right boundary bigram priorities. + +## Usage + +```rust +use sparse_ngrams::{collect_sparse_grams, NGram, MAX_SPARSE_GRAM_SIZE}; + +let input = b"hello world"; +let grams = collect_sparse_grams(input); +for gram in &grams { + assert!(gram.len() >= 2); + assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize); +} +``` + +## Performance + +Benchmarks on an Apple M1 (15 KB input, `lib.rs` source file): + +| Variant | Throughput | +|---------|-----------| +| `deque` | ~3.5 GB/s | +| `scan` | ~4.9 GB/s | + +The `scan` variant is ~40% faster than the deque variant by replacing the monotone deque with a fixed-size circular buffer and a suffix-minimum scan. + +## Bigram table size + +The priority table maps byte pairs to frequency-based priorities. Increasing the table size (number of ranked bigrams) produces more distinct longer n-grams, but saturates quickly: + +![Unique n-grams vs. table size](images/unique_ngrams_vs_table_size.png) + +| Table size | Unique n-grams | % of max | +|-----------|---------------|----------| +| 100 | 6.2M | 79.4% | +| 200 | 6.7M | 85.9% | +| 400 | 7.1M | 91.1% | +| 800 | 7.5M | 96.2% | +| 1,600 | 7.8M | 99.0% | +| 3,200 | 7.8M | 99.9% | +| 6,400+ | 7.8M | 100% | + +Beyond ~6,400 entries the table saturates — additional bigram rankings produce no new n-grams since all occurring byte pairs already have distinct priorities. diff --git a/crates/sparse-ngrams/images/unique_ngrams_vs_table_size.png b/crates/sparse-ngrams/images/unique_ngrams_vs_table_size.png new file mode 100644 index 0000000000000000000000000000000000000000..218143aa2ec617bbb48f5fe1936c6b3535aa3716 GIT binary patch literal 61129 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 Ct(o-z literal 0 HcmV?d00001 diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index ead20518..ca2c0642 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -11,7 +11,7 @@ //! boundary occurs wherever a bigram has lower priority than all bigrams between it and the //! previous boundary. //! -//! For a document of N bytes, this produces roughly 2N n-grams: all bigrams plus algorithmically +//! For a document of N bytes, this produces at most 3(N-1) n-grams: all bigrams plus algorithmically //! selected longer n-grams (up to [`MAX_SPARSE_GRAM_SIZE`] bytes). //! //! # Example From 48bfbbb03c761e8d75063f2518c35f1dd45682bf Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 8 May 2026 16:36:48 +0200 Subject: [PATCH 39/45] growth by max n-gram len --- crates/sparse-ngrams/README.md | 22 ++++++++++++++++++ .../images/unique_ngrams_vs_max_length.png | Bin 0 -> 46592 bytes 2 files changed, 22 insertions(+) create mode 100644 crates/sparse-ngrams/images/unique_ngrams_vs_max_length.png diff --git a/crates/sparse-ngrams/README.md b/crates/sparse-ngrams/README.md index a901a185..f12808ed 100644 --- a/crates/sparse-ngrams/README.md +++ b/crates/sparse-ngrams/README.md @@ -55,3 +55,25 @@ The priority table maps byte pairs to frequency-based priorities. Increasing the | 6,400+ | 7.8M | 100% | Beyond ~6,400 entries the table saturates — additional bigram rankings produce no new n-grams since all occurring byte pairs already have distinct priorities. + +## Maximum n-gram length + +Increasing the maximum n-gram length produces more unique longer grams, with diminishing returns: + +![Unique n-grams vs. max length](images/unique_ngrams_vs_max_length.png) + +| Max length | Unique n-grams | vs. len=8 | +|-----------|---------------|-----------| +| 2 | 1.4M | 18% | +| 3 | 4.6M | 59% | +| 4 | 5.8M | 74% | +| 6 | 7.1M | 90% | +| 8 | 7.8M | 100% | +| 12 | 8.7M | 111% | +| 16 | 9.2M | 118% | +| 24 | 9.8M | 124% | +| 32 | 10.0M | 128% | +| 48 | 10.3M | 131% | +| 64 | 10.4M | 132% | + +The default of 8 captures most of the discriminative power. Going to 16 adds ~18% more unique grams but doubles the scan window; going to 64 adds only ~32% total. diff --git a/crates/sparse-ngrams/images/unique_ngrams_vs_max_length.png b/crates/sparse-ngrams/images/unique_ngrams_vs_max_length.png new file mode 100644 index 0000000000000000000000000000000000000000..d878c2e9bd04590ae519dde6adf0ad7ab4ee0c2b GIT binary patch literal 46592 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
    0(qle{d#tbRWn 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 Date: Fri, 8 May 2026 16:45:37 +0200 Subject: [PATCH 40/45] remove pef crate --- Cargo.toml | 1 - crates/pef/Cargo.toml | 37 -- crates/pef/benches/elias_fano_bench.rs | 92 --- crates/pef/src/avx_batch_decoder.rs | 331 ---------- crates/pef/src/batch_decoder.rs | 156 ----- crates/pef/src/bin/encode_pisa.rs | 880 ------------------------- crates/pef/src/bin/reorder_docids.rs | 515 --------------- crates/pef/src/elias_fano.rs | 475 ------------- crates/pef/src/helper.rs | 91 --- crates/pef/src/lib.rs | 19 - 10 files changed, 2597 deletions(-) delete mode 100644 crates/pef/Cargo.toml delete mode 100644 crates/pef/benches/elias_fano_bench.rs delete mode 100644 crates/pef/src/avx_batch_decoder.rs delete mode 100644 crates/pef/src/batch_decoder.rs delete mode 100644 crates/pef/src/bin/encode_pisa.rs delete mode 100644 crates/pef/src/bin/reorder_docids.rs delete mode 100644 crates/pef/src/elias_fano.rs delete mode 100644 crates/pef/src/helper.rs delete mode 100644 crates/pef/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 999c8a45..4e584bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "crates/*", "crates/bpe/benchmarks", "crates/bpe/tests", - "crates/pef", "crates/sparse-ngrams", ] resolver = "2" diff --git a/crates/pef/Cargo.toml b/crates/pef/Cargo.toml deleted file mode 100644 index a6301b67..00000000 --- a/crates/pef/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "pef" -version = "0.1.0" -edition = "2021" -description = "Partitioned Elias-Fano Variation." -repository = "https://github.com/github/rust-gems" -license = "MIT" -keywords = ["tokenizer", "algorithm", "encoding", "bpe"] -categories = ["algorithms", "data-structures", "encoding", "science"] - -[lib] -crate-type = ["lib", "staticlib"] -bench = false - -[dependencies] -itertools = "0.14" -rand = { version = "0.9", optional = true } -geo_filters = { path = "../geo_filters", optional = true } -ordered-float = { version = "4", optional = true } -rayon = { version = "1.10", optional = true } - -[dev-dependencies] -criterion = "0.5" -rand = "0.9" - -[[bin]] -name = "encode_pisa" -path = "src/bin/encode_pisa.rs" - -[[bin]] -name = "reorder_docids" -path = "src/bin/reorder_docids.rs" -required-features = ["geo_filters", "ordered-float", "rayon"] - -[[bench]] -name = "elias_fano_bench" -harness = false diff --git a/crates/pef/benches/elias_fano_bench.rs b/crates/pef/benches/elias_fano_bench.rs deleted file mode 100644 index 7246e89c..00000000 --- a/crates/pef/benches/elias_fano_bench.rs +++ /dev/null @@ -1,92 +0,0 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; -use pef::avx_batch_decoder::{new_decoder, BatchDecoder, AvxBatchDecoder}; -use pef::{EFBatchDecoder, EliasFano}; -use rand::prelude::*; - -fn criterion_benchmark(c: &mut Criterion) { - let size = 100_000; - - // Generate random sorted data with clustered gaps using a Markov chain - let mut data = Vec::with_capacity(size as usize); - let mut current = 0; - let mut rng = StdRng::seed_from_u64(123456789); - - // State 0: Dense cluster (small gaps, mostly 1) - // State 1: Sparse region (larger gaps) - let mut state = 0; - - for _ in 0..size { - let gap = if state == 0 { - if rng.random_bool(0.1) { - state = 1; - } // Transition to sparse - if rng.random_bool(0.9) { - 1 - } else { - (rng.random::() % 5) + 1 - } - } else { - if rng.random_bool(0.1) { - state = 0; - } // Transition to dense - (rng.random::() % 100) + 1 - }; - - current += gap; - data.push(current); - } - let max = current + 1; - - let elias_fano = EliasFano::new(data.iter().copied(), max, size); - - let mut group = c.benchmark_group("elias_fano"); - group.throughput(Throughput::Elements(size as u64)); - - #[cfg(target_arch = "x86_64")] - if std::is_x86_feature_detected!("avx512f") - && std::is_x86_feature_detected!("avx512vbmi2") - && std::is_x86_feature_detected!("avx512bw") - && std::is_x86_feature_detected!("popcnt") - { - group.bench_function("avx_batch_decode", |b| { - b.iter(|| { - //let mut decoder = new_decoder(&elias_fano); - let mut decoder = AvxBatchDecoder::<4>::new(&elias_fano); - let mut buffer = [0u32; 16]; - loop { - let count = decoder.decode_batch(&mut buffer); - if count == 0 { - break; - } - black_box(&buffer[..count]); - } - }) - }); - } - - group.bench_function("iter", |b| { - b.iter(|| { - for val in elias_fano.iter() { - black_box(val); - } - }) - }); - - group.bench_function("batch_decode", |b| { - b.iter(|| { - let mut batch_decoder = EFBatchDecoder::new(&elias_fano); - loop { - let batch = batch_decoder.decode_batch(); - if batch.is_empty() { - break; - } - black_box(batch); - } - }) - }); - - group.finish(); -} - -criterion_group!(benches, criterion_benchmark); -criterion_main!(benches); diff --git a/crates/pef/src/avx_batch_decoder.rs b/crates/pef/src/avx_batch_decoder.rs deleted file mode 100644 index 2c781c3f..00000000 --- a/crates/pef/src/avx_batch_decoder.rs +++ /dev/null @@ -1,331 +0,0 @@ -use crate::elias_fano::EliasFano; -use std::arch::x86_64::*; - -pub trait BatchDecoder { - /// Decodes up to 16 values from the Elias-Fano representation. - /// - /// Updates internal state. - /// Writes decoded values to `output` and returns the number of values written. - /// - /// # Safety - /// This function requires AVX-512 features. - fn decode_batch(&mut self, output: &mut [u32]) -> usize; -} - -pub fn new_decoder<'a>(ef: &'a EliasFano) -> Box { - macro_rules! dispatch { - ($($b:literal),*) => { - match ef.bits_per_value { - $( $b => Box::new(AvxBatchDecoder::<$b>::new(ef)), )* - _ => panic!("Unsupported bits per value: {}", ef.bits_per_value), - } - } - } - dispatch!( - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, - 25, 26, 27, 28, 29, 30, 31, 32 - ) -} - -pub struct AvxBatchDecoder<'a, const BITS: u32> { - high_bits: &'a [u64], - low_bits: &'a [u64], - high_idx: u32, - value_idx: u32, -} - -impl<'a, const BITS: u32> AvxBatchDecoder<'a, BITS> { - pub fn new(ef: &'a EliasFano) -> Self { - assert_eq!(ef.bits_per_value, BITS); - Self { - high_bits: &ef.high_bits, - low_bits: &ef.low_bits, - high_idx: 0, - value_idx: 0, - } - } - - #[inline(always)] - unsafe fn get_low_bits(&self) -> __m512i { - if BITS == 4 { - let byte_idx = (self.value_idx / 2) as usize; - let nibble_offset = (self.value_idx & 1) as u32; // 0 or 1 (which nibble in the first byte) - - let ptr = self.low_bits.as_ptr() as *const u8; - - // We need 16 nibbles = 64 bits of data - // If nibble_offset == 0, we need exactly 8 bytes - // If nibble_offset == 1, we need 8.5 bytes (9 bytes total) - // Load 16 bytes to be safe, then combine - let w0 = (ptr.add(byte_idx) as *const u64).read_unaligned(); - let w1 = (ptr.add(byte_idx + 1) as *const u64).read_unaligned(); - - // If nibble_offset == 1, shift right by 4 bits to align - // This requires combining bits from w0 and w1 - let aligned = w0 >> (nibble_offset * 4) | w1 << (8 - nibble_offset * 4); - - // Now 'aligned' contains 16 nibbles in the lower 64 bits - // Put the 8 bytes into a vector - let v_bytes = _mm_cvtsi64_si128(aligned as i64); - - // We need to separate low and high nibbles of each byte - // Byte layout: [n1 n0] [n3 n2] [n5 n4] [n7 n6] [n9 n8] [n11 n10] [n13 n12] [n15 n14] - // We want: n0, n1, n2, n3, ..., n15 - - // Simpler: mask low nibbles and high nibbles separately, then interleave - let mask_0f = _mm_set1_epi8(0x0F); - let lo_nibbles = _mm_and_si128(v_bytes, mask_0f); // n0, n2, n4, n6, n8, n10, n12, n14 - let hi_nibbles = _mm_and_si128(_mm_srli_epi16(v_bytes, 4), mask_0f); // n1, n3, n5, n7, n9, n11, n13, n15 - - // Interleave: we want n0, n1, n2, n3, ... - // unpacklo_epi8 interleaves bytes from lo and hi - let interleaved = _mm_unpacklo_epi8(lo_nibbles, hi_nibbles); - // Result: n0, n1, n2, n3, n4, n5, n6, n7, n8, n9, n10, n11, n12, n13, n14, n15 - - // Now expand 16 bytes to 16 32-bit integers - return _mm512_cvtepu8_epi32(interleaved); - } - - let mut low_vals = [0i32; 16]; - let mut bit_idx = self.value_idx * BITS; - for i in 0..16 { - // Inline get_bits logic for simplicity and performance - let start = bit_idx / 64; - let end = (bit_idx + BITS - 1) / 64; - let offset = bit_idx % 64; - let mut val = self.low_bits.get_unchecked(start as usize) >> offset; - if start != end { - val |= self.low_bits.get_unchecked((start + 1) as usize) << (64 - offset); - } - low_vals[i] = (val as u32 & !(!0 << BITS)) as i32; - bit_idx += BITS; - } - _mm512_loadu_si512(low_vals.as_ptr() as *const _) - } - - /// Decodes a batch of 16 values using AVX-512 instructions. - /// - /// # Safety - /// This function requires AVX-512F, AVX-512VBMI2, and AVX-512BW features. - #[target_feature(enable = "avx512f", enable = "avx512vbmi2", enable = "avx512bw")] - pub unsafe fn decode(high_bits: u64, low_bits: __m512i) -> __m512i { - // Identity indices 0..63 for the byte positions - let identity = _mm512_set_epi8( - 63, 62, 61, 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, - 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, - 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, - ); - - // Compress the indices of the set bits in high_bits. - // This gathers the byte values from 'identity' where the corresponding bit in 'high_bits' is 1. - let compressed_indices = _mm512_maskz_compress_epi8(high_bits, identity); - - // Expand the first 16 byte positions (indices of the first 16 set bits) into epi32 values. - let first_16_indices = _mm512_castsi512_si128(compressed_indices); - let expanded_indices = _mm512_cvtepu8_epi32(first_16_indices); - - // Subtract their position in the register (0..15). - // This corresponds to calculating the high part value: position - rank. - let range_0_15 = _mm512_set_epi32(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0); - let high_parts = _mm512_sub_epi32(expanded_indices, range_0_15); - - // Shift each value by bits_per_value. - let shifted_high = _mm512_slli_epi32(high_parts, BITS); - - // Add the expanded low bits. - _mm512_add_epi32(shifted_high, low_bits) - } - - /// Decodes up to 16 values from the Elias-Fano representation. - /// - /// Updates internal state. - /// Writes decoded values to `output` and returns the number of values written. - #[target_feature( - enable = "avx512f", - enable = "avx512vbmi2", - enable = "avx512bw", - enable = "popcnt" - )] - pub unsafe fn decode_batch_impl(&mut self, output: &mut [u32]) -> usize { - let mut current_word; - let mut byte_idx; - - loop { - byte_idx = self.high_idx / 8; - let bit_offset = self.high_idx % 8; - if byte_idx as usize >= self.high_bits.len() * 8 { - return 0; - } - let ptr = self.high_bits.as_ptr() as *const u8; - // Handle potential out-of-bounds read at the very end of the slice - if (byte_idx as usize) + 8 > self.high_bits.len() * 8 { - let mut word = 0u64; - let len = self.high_bits.len() * 8 - byte_idx as usize; - std::ptr::copy_nonoverlapping( - ptr.add(byte_idx as usize), - &mut word as *mut u64 as *mut u8, - len, - ); - current_word = word; - } else { - current_word = ptr.add(byte_idx as usize).cast::().read_unaligned(); - } - // Clear bits that have already been processed - current_word &= !0u64 << bit_offset; - if current_word != 0 { - break; - } - // Skip the zeros in this window - self.high_idx = (self.high_idx + 64) & !7; - } - - let k = _popcnt64(current_word as i64) as usize; - let count = k.min(16); - - // 1. Prepare High Bits - let identity = _mm512_set_epi8( - 63, 62, 61, 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, - 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, - 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, - ); - let compressed_indices = _mm512_maskz_compress_epi8(current_word, identity); - let first_16_indices = _mm512_castsi512_si128(compressed_indices); - let expanded_indices = _mm512_cvtepu8_epi32(first_16_indices); - - // Calculate high parts: (position - rank_in_batch) + correction - // correction = base_position - global_rank_start - // base_position = byte_idx * 8 - let base_position = (byte_idx * 8) as i32; - let correction = base_position - self.value_idx as i32; - - let range_0_15 = _mm512_set_epi32(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0); - // (pos - rank_in_batch) - let high_parts_base = _mm512_sub_epi32(expanded_indices, range_0_15); - // Add correction - let correction_vec = _mm512_set1_epi32(correction); - let high_parts = _mm512_add_epi32(high_parts_base, correction_vec); - - // 2. Prepare Low Bits - let low_bits_vec = self.get_low_bits(); - - // 3. Combine - let shifted_high = _mm512_slli_epi32(high_parts, BITS); - let result_vec = _mm512_add_epi32(shifted_high, low_bits_vec); - - // 4. Store Result - _mm512_storeu_si512(output.as_mut_ptr() as *mut _, result_vec); - - // 5. Update State - // Find position of the last processed bit to update high_idx - // We want the value at index (count - 1) from compressed_indices. - // compressed_indices contains byte indices in the lower 128 bits (since count <= 16). - let lower_128 = _mm512_castsi512_si128(compressed_indices); - let last_pos = if count == 16 { - _mm_extract_epi8(lower_128, 15) as u32 - } else { - let lo = _mm_cvtsi128_si64(lower_128) as u64; - let hi = _mm_extract_epi64(lower_128, 1) as u64; - - let index = count - 1; - let val = if index < 8 { lo } else { hi }; - let shift = (index & 7) * 8; - (val >> shift) as u32 & 0xFF - }; - - self.high_idx = byte_idx * 8 + last_pos + 1; - self.value_idx += count as u32; - count - } -} - -impl<'a, const BITS: u32> BatchDecoder for AvxBatchDecoder<'a, BITS> { - fn decode_batch(&mut self, output: &mut [u32]) -> usize { - unsafe { self.decode_batch_impl(output) } - } -} - -#[cfg(test)] -mod tests { - use crate::test_utils::generate_markov_chain_data; - - use super::*; - - #[test] - fn test_decode_first_16() { - if !is_x86_feature_detected!("avx512f") - || !is_x86_feature_detected!("avx512vbmi2") - || !is_x86_feature_detected!("avx512bw") - { - println!("Skipping test: AVX-512 not supported on this hardware"); - return; - } - unsafe { - // Construct high_bits with 16 set bits at specific positions. - // Positions: 0, 2, 3, 5, 8, 10, 12, 15, 17, 20, 22, 25, 28, 30, 32, 35 - let positions = [0, 2, 3, 5, 8, 10, 12, 15, 17, 20, 22, 25, 28, 30, 32, 35]; - let mut high_bits: u64 = 0; - for &p in &positions { - high_bits |= 1u64 << p; - } - - // Construct low_bits (arbitrary values) - let low_vals: [i32; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; - let low_bits = _mm512_loadu_si512(low_vals.as_ptr() as *const _); - - const BITS: u32 = 3; - - // Calculate expected result - let mut expected = [0i32; 16]; - for i in 0..16 { - let p = positions[i]; - let rank = i as i32; - let high_part = (p as i32) - rank; - expected[i] = (high_part << BITS) + low_vals[i]; - } - - // Decode - let result_vec = AvxBatchDecoder::<'_, BITS>::decode(high_bits, low_bits); - - // Store result - let mut result = [0i32; 16]; - _mm512_storeu_si512(result.as_mut_ptr() as *mut _, result_vec); - - assert_eq!( - result, expected, - "Decoded values do not match expected values" - ); - } - } - - #[test] - fn test_decode_batch_1024() { - if !is_x86_feature_detected!("avx512f") - || !is_x86_feature_detected!("avx512vbmi2") - || !is_x86_feature_detected!("avx512bw") - || !is_x86_feature_detected!("popcnt") - { - println!("Skipping test: AVX-512 not supported on this hardware"); - return; - } - - let data = generate_markov_chain_data(32 * 32, 12345); - let max = *data.last().unwrap() + 1; - let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); - - unsafe { - let mut decoder = new_decoder(&ef); - let mut decoded = Vec::new(); - let mut buffer = [0u32; 16]; - - loop { - let count = decoder.decode_batch(&mut buffer); - if count == 0 { - break; - } - decoded.extend_from_slice(&buffer[..count]); - } - - assert_eq!(decoded, data); - } - } -} diff --git a/crates/pef/src/batch_decoder.rs b/crates/pef/src/batch_decoder.rs deleted file mode 100644 index 158fd5e7..00000000 --- a/crates/pef/src/batch_decoder.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::elias_fano::{BitReader, EliasFano}; - -const BATCH_SIZE: usize = 8; - -pub struct EFBatchDecoder<'a> { - bits_per_value: u32, - high_bits: BitReader<'a>, - low_bits: BitReader<'a>, - value_idx: u32, - bit_pos: u32, - buffer: [u32; BATCH_SIZE], -} - -impl<'a> EFBatchDecoder<'a> { - pub fn new(ef: &'a EliasFano) -> Self { - let high_bits = BitReader::new(&ef.high_bits); - Self { - bits_per_value: ef.bits_per_value, - high_bits, - low_bits: BitReader::new(&ef.low_bits), - value_idx: 0, - bit_pos: 0, - buffer: [0; BATCH_SIZE], - } - } - - #[inline] - pub fn decode_batch(&mut self) -> &[u32] { - // Cache frequently accessed fields - let high_bits_len = self.high_bits.len(); - let mut bit_pos = self.bit_pos; - if bit_pos >= high_bits_len { - return &[]; - } - let mut value_idx = self.value_idx; - let mut count = 0; - loop { - let current_word = self.high_bits.get_word_unaligned(bit_pos / 8); - let total_ones = (current_word >> (bit_pos % 8)).count_ones(); - let ones = total_ones.min((BATCH_SIZE - count) as u32); - bit_pos = self.process_word_dispatch(ones, current_word, bit_pos, value_idx, count); - value_idx += ones; - count += ones as usize; - if count == BATCH_SIZE { - break; - } - if bit_pos >= high_bits_len { - break; - } - } - self.bit_pos = bit_pos; - self.value_idx = value_idx; - &self.buffer[0..count] - } - - #[inline(always)] - fn process_word_dispatch( - &mut self, - ones: u32, - current_word: u64, - bit_pos: u32, - value_idx: u32, - count: usize, - ) -> u32 { - match ones { - 0 => (bit_pos + 64) & !7, - 1 => self.process_word::<1>(current_word, bit_pos, value_idx, count), - 2 => self.process_word::<2>(current_word, bit_pos, value_idx, count), - 3 => self.process_word::<3>(current_word, bit_pos, value_idx, count), - 4 => self.process_word::<4>(current_word, bit_pos, value_idx, count), - 5 => self.process_word::<5>(current_word, bit_pos, value_idx, count), - 6 => self.process_word::<6>(current_word, bit_pos, value_idx, count), - 7 => self.process_word::<7>(current_word, bit_pos, value_idx, count), - 8 => self.process_word::<8>(current_word, bit_pos, value_idx, count), - /*9 => self.process_word::<9>(current_word, bit_pos, value_idx, count), - 10 => self.process_word::<10>(current_word, bit_pos, value_idx, count), - 11 => self.process_word::<11>(current_word, bit_pos, value_idx, count), - 12 => self.process_word::<12>(current_word, bit_pos, value_idx, count), - 13 => self.process_word::<13>(current_word, bit_pos, value_idx, count), - 14 => self.process_word::<14>(current_word, bit_pos, value_idx, count), - 15 => self.process_word::<15>(current_word, bit_pos, value_idx, count), - 16 => self.process_word::<16>(current_word, bit_pos, value_idx, count), - 17 => self.process_word::<17>(current_word, bit_pos, value_idx, count), - 18 => self.process_word::<18>(current_word, bit_pos, value_idx, count), - 19 => self.process_word::<19>(current_word, bit_pos, value_idx, count), - 20 => self.process_word::<20>(current_word, bit_pos, value_idx, count), - 21 => self.process_word::<21>(current_word, bit_pos, value_idx, count), - 22 => self.process_word::<22>(current_word, bit_pos, value_idx, count), - 23 => self.process_word::<23>(current_word, bit_pos, value_idx, count), - 24 => self.process_word::<24>(current_word, bit_pos, value_idx, count), - 25 => self.process_word::<25>(current_word, bit_pos, value_idx, count), - 26 => self.process_word::<26>(current_word, bit_pos, value_idx, count), - 27 => self.process_word::<27>(current_word, bit_pos, value_idx, count), - 28 => self.process_word::<28>(current_word, bit_pos, value_idx, count), - 29 => self.process_word::<29>(current_word, bit_pos, value_idx, count), - 30 => self.process_word::<30>(current_word, bit_pos, value_idx, count), - 31 => self.process_word::<31>(current_word, bit_pos, value_idx, count), - 32 => self.process_word::<32>(current_word, bit_pos, value_idx, count),*/ - _ => unreachable!(), - } - } - - #[inline(always)] - fn process_word( - &mut self, - mut current_word: u64, - bit_pos: u32, - value_idx: u32, - count: usize, - ) -> u32 { - let mut bucket_id = bit_pos - value_idx; - current_word >>= bit_pos % 8; - for i in 0..N { - let zeros = current_word.trailing_zeros(); - current_word >>= zeros + 1; - bucket_id += zeros; - let low = self - .low_bits - .get_bits((value_idx + i) * self.bits_per_value, self.bits_per_value); - unsafe { - *self.buffer.get_unchecked_mut(count + i as usize) = - low | (bucket_id << self.bits_per_value); - } - } - bucket_id + value_idx + N - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::elias_fano::EliasFano; - use rand::prelude::*; - - #[test] - #[ignore] - fn test_batch_decoder() { - let data = crate::elias_fano::test_utils::generate_markov_chain_data(32 * 32, 12345); - - let max = *data.last().unwrap() + 1; - let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); - - let mut decoder = EFBatchDecoder::new(&ef); - let mut decoded = Vec::new(); - - loop { - let batch = decoder.decode_batch(); - if batch.is_empty() { - break; - } - decoded.extend_from_slice(batch); - } - - assert_eq!(decoded, data); - } -} diff --git a/crates/pef/src/bin/encode_pisa.rs b/crates/pef/src/bin/encode_pisa.rs deleted file mode 100644 index b631fc3f..00000000 --- a/crates/pef/src/bin/encode_pisa.rs +++ /dev/null @@ -1,880 +0,0 @@ -//! Binary to encode PISA posting lists with Elias-Fano encoding. -//! -//! Usage: -//! cargo run --release --bin encode_pisa -- [--remap ] -//! -//! The PISA format is: -//! - Header: 2 x u32 (typically [1, num_documents]) -//! - For each posting list: -//! - u32: length of the posting list -//! - length x u32: document IDs (d-gaps, i.e., delta encoded) -//! -//! The optional --remap argument specifies a binary file containing a mapping -//! from old doc IDs to new doc IDs (generated by reorder_docids). - -use std::collections::HashMap; -use std::env; -use std::fs::File; -use std::io::{BufReader, Read, Write}; -use std::path::Path; -use std::time::Instant; - -use pef::optimal_bits_per_value; - -/// Read a u32 from a reader (little-endian) -fn read_u32(reader: &mut R) -> std::io::Result { - let mut buf = [0u8; 4]; - reader.read_exact(&mut buf)?; - Ok(u32::from_le_bytes(buf)) -} - -/// Read a posting list from PISA format -fn read_posting_list(reader: &mut R) -> std::io::Result>> { - let mut len_buf = [0u8; 4]; - match reader.read_exact(&mut len_buf) { - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), - Err(e) => return Err(e), - } - let len = u32::from_le_bytes(len_buf) as usize; - - let mut postings = vec![0u32; len]; - for posting in &mut postings { - *posting = read_u32(reader)?; - } - Ok(Some(postings)) -} - -/// Statistics about the encoding -#[derive(Default)] -struct Stats { - num_lists: u64, - total_postings: u64, - total_input_bytes: u64, - total_ef_bytes: u64, - total_gef_bytes: u64, - total_pef_bytes: u64, - total_vbyte_bytes: u64, - total_bp128_bytes: u64, - total_bp32_bytes: u64, - lists_by_size: [u64; 7], // 1, 2-10, 11-100, 101-1000, 1001-10000, 10001-100000, >100000 - // For entropy calculation - dgap_counts: HashMap, - total_dgaps: u64, -} - -/// Compute grouped Elias-Fano encoding size (partitioned EF). -fn grouped_elias_fano_bits(postings: &[u32]) -> u32 { - let mut start = 0; - let mut total = 0; - const LIMIT: u32 = 32 * 8; - let mut offset = 0; - let mut skiplist = vec![]; - while start < postings.len() { - let mut end = start + 1; - let mut next_bits = 0; - while end < postings.len() { - offset = postings[start]; - let max = postings[end] - offset + 1; - let len = (end - start) as u32; - next_bits = 5 + optimal_bits_per_value(max - len, len).0; - if next_bits > LIMIT { - next_bits = LIMIT; - break; - } - end += 1; - } - total += next_bits.div_ceil(8) * 8; - start = end; - if end < postings.len() { - offset = postings[end]; - skiplist.push(offset); - } - } - total - + optimal_bits_per_value( - skiplist.last().copied().unwrap_or_default() + 1 - skiplist.len() as u32, - skiplist.len() as u32, - ) - .0 -} - -/// Compute partitioned Elias-Fano encoding size. -/// Uses fixed-size partitions (like PISA's PEF implementation). -/// Each partition stores elements using standard EF relative to partition start. -/// Partition endpoints are stored in a top-level EF structure. -fn partitioned_elias_fano_bits(postings: &[u32], partition_size: usize) -> u64 { - if postings.is_empty() { - return 0; - } - if postings.len() <= partition_size { - // Single partition - just use standard EF - return optimal_bits_per_value( - postings.last().copied().unwrap_or_default() + 1 - postings.len() as u32, - postings.len() as u32, - ) - .0 as u64; - } - - let num_partitions = postings.len().div_ceil(partition_size); - let mut total_bits: u64 = 0; - let mut partition_endpoints = Vec::with_capacity(num_partitions); - let mut bit_endpoints = Vec::with_capacity(num_partitions); - - // Encode each partition - for (i, chunk) in postings.chunks(partition_size).enumerate() { - let base = if i == 0 { - 0 - } else { - postings[i * partition_size - 1] + 1 - }; - let max_in_partition = chunk.last().copied().unwrap_or(base) - base; - let n = chunk.len() as u32; - - // Standard EF for this partition: n * (2 + log2(u/n)) where u = max - base + 1 - let partition_bits = optimal_bits_per_value(max_in_partition + 1 - n, n).0; - total_bits += partition_bits as u64; - - // Store endpoint for skip list - partition_endpoints.push(*chunk.last().unwrap()); - bit_endpoints.push(total_bits as u32); - } - - // Add skip list overhead (endpoints encoded with EF) - let skip_bits = optimal_bits_per_value( - partition_endpoints.last().copied().unwrap_or_default() + 1 - num_partitions as u32, - num_partitions as u32, - ) - .0; - total_bits += skip_bits as u64; - let offset_bits = optimal_bits_per_value( - bit_endpoints.last().copied().unwrap_or_default() + 1 - num_partitions as u32, - num_partitions as u32, - ) - .0; - total_bits += offset_bits as u64; - - // Add partition size metadata (log2(partition_size) bits per partition boundary) - // In practice, PISA uses fixed sizes so this is minimal - total_bits += (num_partitions * 8) as u64; // ~1 byte per partition for metadata - - total_bits -} - -/// Compute bit-packed encoded size for a posting list (d-gaps) -/// Packs `group_size` integers at a time with bit-packing. -/// Each block uses: 1 byte for bit-width + group_size * bit_width / 8 bytes -/// Leftover integers are stored as raw u32. -fn bitpacked_size(postings: &[u32], group_size: usize) -> u64 { - if postings.is_empty() { - return 0; - } - - let mut total = 0u64; - let mut prev = 0u32; - let mut gaps = Vec::with_capacity(postings.len()); - - // Compute d-gaps - for &doc in postings { - gaps.push(doc - prev); - prev = doc; - } - - // Process blocks of group_size - let mut i = 0; - while i + group_size <= gaps.len() { - let block = &gaps[i..i + group_size]; - let max_gap = block.iter().copied().max().unwrap_or(0); - let bit_width = if max_gap == 0 { - 0 - } else { - 32 - max_gap.leading_zeros() - }; - // 1 byte for bit-width descriptor + packed data - total += 1 + (group_size as u64 * bit_width as u64).div_ceil(8); - i += group_size; - } - - // Leftover integers stored as raw u32 - let leftover = gaps.len() - i; - total += (leftover * 4) as u64; - - total -} - -/// Compute VByte encoded size for a posting list (d-gaps) -fn vbyte_size(postings: &[u32]) -> u64 { - if postings.is_empty() { - return 0; - } - let mut total = 0u64; - let mut prev = 0u32; - for &doc in postings { - let gap = doc - prev; - prev = doc; - // VByte: 7 bits per byte, continue bit in MSB - total += match gap { - 0..=127 => 1, - 128..=16383 => 2, - 16384..=2097151 => 3, - 2097152..=268435455 => 4, - _ => 5, - }; - } - total -} - -/// Load a docid mapping file (binary array of (doc_id, parent_id)) -/// Returns (old_to_new, subtree_ends) -fn load_mapping>(path: P) -> std::io::Result<(Vec, Vec)> { - let file = File::open(path)?; - let file_len = file.metadata()?.len() as usize; - let num_docs = file_len / 8; // 2 * u32 - - let mut reader = BufReader::new(file); - let mut old_to_new = vec![0u32; num_docs]; - let mut parents = vec![0u32; num_docs]; - - // Read mapping and build old_to_new + parents (in new ID space) - // The file is ordered by new_id (0..N-1) - for new_id in 0..num_docs { - let old_id = read_u32(&mut reader)?; - let old_parent = read_u32(&mut reader)?; - - if old_id as usize >= num_docs { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Doc ID out of range", - )); - } - old_to_new[old_id as usize] = new_id as u32; - - // We can't resolve old_parent to new_parent yet because we might not have seen old_parent yet. - // So we store old_parent temporarily or do a second pass? - // Actually, we can just store the old_parent for now, but we need to map it later. - // But wait, we can't store old_parent in `parents` if we want `parents` to be new_ids. - // Let's store old_parent in a temporary vector. - parents[new_id] = old_parent; - } - - // Convert parents to new IDs - for parent in &mut parents { - *parent = old_to_new[*parent as usize]; - } - - // Compute subtree ranges - // In DFS order, children are visited after parents? No. - // In DFS order: - // Visit u - // Visit children... - // So u < child. - // And the subtree of u is a contiguous range [u, end]. - // We can compute end by iterating backwards. - let mut subtree_ends: Vec = (0..num_docs as u32).collect(); - - for i in (1..num_docs).rev() { - let u = i as u32; - let p = parents[i]; - // If p is actually the parent (and not self-loop for root), propagate range - if p != u { - // The parent's range must include the child's range. - // Since it's DFS, p < u. - // And since we iterate backwards, we have already computed the full range of u. - // So we extend p's range to include u's range. - if subtree_ends[u as usize] > subtree_ends[p as usize] { - subtree_ends[p as usize] = subtree_ends[u as usize]; - } - } - } - - Ok((old_to_new, subtree_ends)) -} - -/// Transform a posting list using the range toggle logic. -/// Returns the transformed posting list. -fn transform_posting_list(postings: &[u32], num_docs: usize, subtree_ends: &[u32]) -> Vec { - let mut output = Vec::new(); - let mut stack: Vec = Vec::new(); // Stack of end positions - - // Use a cursor for the sorted postings to avoid O(N) lookup - let mut posting_idx = 0; - let mut i = 0; - - while i < num_docs as u32 { - while !stack.is_empty() && stack.last() < Some(&i) { - stack.pop(); - } - if stack.len() % 2 == 1 { - if postings.get(posting_idx) == Some(&i) { - posting_idx += 1; - } else { - // need to make it even... - output.push(i); - stack.push(subtree_ends[i as usize]); - } - i += 1; - } else { - if postings.get(posting_idx) == Some(&i) { - output.push(i); - stack.push(subtree_ends[i as usize]); - posting_idx += 1; - i += 1; - } else { - if postings.get(posting_idx) > Some(&i) { - i = postings[posting_idx]; - } else { - i = num_docs as u32; - } - if let Some(&n) = stack.last() { - i = i.min(n + 1); - } - } - } - } - - output -} - -impl Stats { - fn add_list(&mut self, postings: &[u32]) { - let len = postings.len(); - self.num_lists += 1; - self.total_postings += len as u64; - self.total_input_bytes += (len * 4) as u64; - - // Compute standard Elias-Fano encoding size - // Encode the inverse if it's smaller. - let last_posting = postings.last().copied().unwrap_or_default() + 1; - let ef_bits = if last_posting - len as u32 > len as u32 { - optimal_bits_per_value(last_posting - len as u32, len as u32) - } else { - optimal_bits_per_value(len as u32, last_posting - len as u32) - } - .0; - self.total_ef_bytes += ef_bits.div_ceil(8) as u64; - - // Compute grouped Elias-Fano encoding size - let gef_bits = grouped_elias_fano_bits(postings); - self.total_gef_bytes += gef_bits.div_ceil(8) as u64; - - // Compute partitioned Elias-Fano encoding size (64 elements per partition) - let pef_bits = partitioned_elias_fano_bits(postings, 64); - self.total_pef_bytes += pef_bits.div_ceil(8); - - // Compute VByte encoding size - self.total_vbyte_bytes += vbyte_size(postings); - - // Compute BP128 encoding size - self.total_bp128_bytes += bitpacked_size(postings, 128); - - // Compute BP32 encoding size - self.total_bp32_bytes += bitpacked_size(postings, 32); - - // Track d-gap frequencies for entropy calculation - let mut prev = 0u32; - for &doc in postings { - let gap = doc - prev; - *self.dgap_counts.entry(gap).or_insert(0) += 1; - self.total_dgaps += 1; - prev = doc; - } - - let bucket = match len { - 1 => 0, - 2..=10 => 1, - 11..=100 => 2, - 101..=1000 => 3, - 1001..=10000 => 4, - 10001..=100000 => 5, - _ => 6, - }; - self.lists_by_size[bucket] += 1; - } - - /// Compute Shannon entropy of d-gaps in bits per symbol. - fn compute_entropy(&self) -> f64 { - if self.total_dgaps == 0 { - return 0.0; - } - - let n = self.total_dgaps as f64; - let mut entropy = 0.0; - - for &count in self.dgap_counts.values() { - if count > 0 { - let p = count as f64 / n; - entropy -= p * p.log2(); - } - } - - entropy - } - - fn print(&self) { - println!("\n=== Encoding Statistics ==="); - println!("Total posting lists: {:>12}", self.num_lists); - println!("Total postings: {:>12}", self.total_postings); - println!("Unique d-gap values: {:>12}", self.dgap_counts.len()); - println!(); - println!("List size distribution:"); - let buckets = [ - "1", "2-10", "11-100", "101-1K", "1K-10K", "10K-100K", ">100K", - ]; - for (i, &name) in buckets.iter().enumerate() { - let count = self.lists_by_size[i]; - let pct = 100.0 * count as f64 / self.num_lists as f64; - println!(" {:>10}: {:>10} ({:>5.1}%)", name, count, pct); - } - println!(); - println!("Space usage:"); - println!( - " Input (raw u32): {:>10} bytes ({:>6.2} MB)", - self.total_input_bytes, - self.total_input_bytes as f64 / 1_000_000.0 - ); - println!( - " Elias-Fano: {:>10} bytes ({:>6.2} MB)", - self.total_ef_bytes, - self.total_ef_bytes as f64 / 1_000_000.0 - ); - println!( - " Partitioned EF: {:>10} bytes ({:>6.2} MB)", - self.total_pef_bytes, - self.total_pef_bytes as f64 / 1_000_000.0 - ); - println!( - " Grouped EF: {:>10} bytes ({:>6.2} MB)", - self.total_gef_bytes, - self.total_gef_bytes as f64 / 1_000_000.0 - ); - println!( - " VByte: {:>10} bytes ({:>6.2} MB)", - self.total_vbyte_bytes, - self.total_vbyte_bytes as f64 / 1_000_000.0 - ); - println!( - " BP128: {:>10} bytes ({:>6.2} MB)", - self.total_bp128_bytes, - self.total_bp128_bytes as f64 / 1_000_000.0 - ); - println!( - " BP32: {:>10} bytes ({:>6.2} MB)", - self.total_bp32_bytes, - self.total_bp32_bytes as f64 / 1_000_000.0 - ); - println!(); - - // Compute entropy - let entropy = self.compute_entropy(); - let entropy_bytes = (entropy * self.total_postings as f64 / 8.0) as u64; - - let ef_ratio = self.total_input_bytes as f64 / self.total_ef_bytes as f64; - let pef_ratio = self.total_input_bytes as f64 / self.total_pef_bytes as f64; - let gef_ratio = self.total_input_bytes as f64 / self.total_gef_bytes as f64; - let vbyte_ratio = self.total_input_bytes as f64 / self.total_vbyte_bytes as f64; - let bp128_ratio = self.total_input_bytes as f64 / self.total_bp128_bytes as f64; - let bp32_ratio = self.total_input_bytes as f64 / self.total_bp32_bytes as f64; - let ef_bits = (self.total_ef_bytes * 8) as f64 / self.total_postings as f64; - let pef_bits = (self.total_pef_bytes * 8) as f64 / self.total_postings as f64; - let gef_bits = (self.total_gef_bytes * 8) as f64 / self.total_postings as f64; - let vbyte_bits = (self.total_vbyte_bytes * 8) as f64 / self.total_postings as f64; - let bp128_bits = (self.total_bp128_bytes * 8) as f64 / self.total_postings as f64; - let bp32_bits = (self.total_bp32_bytes * 8) as f64 / self.total_postings as f64; - - println!("Compression (bits/posting):"); - println!( - " Entropy: {:>5.2} bits/posting (theoretical lower bound, {:.2} MB)", - entropy, - entropy_bytes as f64 / 1_000_000.0 - ); - println!( - " Elias-Fano:{:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", - ef_bits, - ef_ratio, - entropy / ef_bits * 100.0 - ); - println!( - " Part. EF: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", - pef_bits, - pef_ratio, - entropy / pef_bits * 100.0 - ); - println!( - " Grouped EF:{:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", - gef_bits, - gef_ratio, - entropy / gef_bits * 100.0 - ); - println!( - " VByte: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", - vbyte_bits, - vbyte_ratio, - entropy / vbyte_bits * 100.0 - ); - println!( - " BP128: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", - bp128_bits, - bp128_ratio, - entropy / bp128_bits * 100.0 - ); - println!( - " BP32: {:>5.2} bits/posting ({:>5.2}x compression, {:.1}% of optimal)", - bp32_bits, - bp32_ratio, - entropy / bp32_bits * 100.0 - ); - } -} - -fn main() -> std::io::Result<()> { - let args: Vec = env::args().collect(); - - if args.len() < 2 { - eprintln!("Usage: {} [--remap ]", args[0]); - eprintln!(); - eprintln!("Encodes PISA posting lists with Elias-Fano encoding."); - eprintln!(); - eprintln!("Options:"); - eprintln!(" --remap Remap document IDs using the specified mapping file"); - eprintln!(" (generated by reorder_docids)"); - std::process::exit(1); - } - - let input_path = &args[1]; - - // Parse optional --remap argument - let remap_path = args - .iter() - .position(|a| a == "--remap") - .and_then(|i| args.get(i + 1)); - - // Load mapping if provided - let (mapping, subtree_ends) = if let Some(path) = remap_path { - println!("Loading docid mapping from: {}", path); - let (m, s) = load_mapping(path)?; - println!("Loaded mapping for {} documents", m.len()); - (Some(m), Some(s)) - } else { - (None, None) - }; - - println!("Reading PISA posting lists from: {}", input_path); - if mapping.is_some() { - println!("Document IDs will be remapped and transformed"); - } - - let file = File::open(input_path)?; - let mut reader = BufReader::with_capacity(1024 * 1024, file); - - // Read header - let header1 = read_u32(&mut reader)?; - let header2 = read_u32(&mut reader)?; - println!("Header: [{}, {}]", header1, header2); - println!("Number of documents: {}", header2); - let num_docs = header2 as usize; - - let mut stats = Stats::default(); - - let start = Instant::now(); - let mut last_report = Instant::now(); - - let mut orig_num_postings = 0; - let mut remapped_num_postings = 0; - - // Read and encode all posting lists - while let Some(mut postings) = read_posting_list(&mut reader)? { - if postings.is_empty() { - continue; - } - orig_num_postings += postings.len(); - - // Remap document IDs if mapping is provided - if let Some(ref m) = mapping { - // Remap and sort - for doc in postings.iter_mut() { - *doc = m[*doc as usize]; - } - postings.sort_unstable(); - let before = postings.len(); - postings.dedup(); - let after = postings.len(); - assert_eq!(before, after); - - // Apply range toggle transformation - if let Some(ref ends) = subtree_ends { - let before = postings.len(); - postings = transform_posting_list(&postings, num_docs, ends); - let after = postings.len(); - if before == 1 { - assert!(after >= 1) - } - } - } - remapped_num_postings += postings.len(); - - // Encode with Elias-Fano - // let ef = EliasFano::new(postings.iter().copied(), max, len); - stats.add_list(&postings); - - // Progress report every second - if last_report.elapsed().as_secs() >= 1 { - print!( - "\rProcessed {:>10} lists, {:>12} postings...", - stats.num_lists, stats.total_postings - ); - std::io::stdout().flush()?; - last_report = Instant::now(); - } - } - - let elapsed = start.elapsed(); - println!( - "\rProcessed {:>10} lists, {:>12} postings in {:.2}s", - stats.num_lists, - stats.total_postings, - elapsed.as_secs_f64() - ); - println!( - "Original total postings: {:>12}, after remapping: {:>12}", - orig_num_postings, remapped_num_postings - ); - - stats.print(); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - /// Helper to compute mapping and subtree_ends from in-memory data. - /// Input: slice of (old_id, old_parent_id) in DFS order (new_id = index). - fn compute_mapping_from_slice(data: &[(u32, u32)]) -> (Vec, Vec) { - let num_docs = data.len(); - let mut old_to_new = vec![0u32; num_docs]; - let mut parents = vec![0u32; num_docs]; - - for (new_id, &(old_id, old_parent)) in data.iter().enumerate() { - old_to_new[old_id as usize] = new_id as u32; - parents[new_id] = old_parent; - } - - // Convert parents to new IDs - for parent in &mut parents { - *parent = old_to_new[*parent as usize]; - } - - // Compute subtree ranges - let mut subtree_ends: Vec = (0..num_docs as u32).collect(); - - for i in (1..num_docs).rev() { - let u = i as u32; - let p = parents[i]; - if p != u { - if subtree_ends[u as usize] > subtree_ends[p as usize] { - subtree_ends[p as usize] = subtree_ends[u as usize]; - } - } - } - - (old_to_new, subtree_ends) - } - - #[test] - fn test_mapping_simple_chain() { - // DFS order: 0 -> 1 -> 2 -> 3 (linear chain) - // old_ids happen to match new_ids, each node's parent is the previous - // new_id=0: old_id=0, parent=0 (root) - // new_id=1: old_id=1, parent=0 - // new_id=2: old_id=2, parent=1 - // new_id=3: old_id=3, parent=2 - let data = [(0, 0), (1, 0), (2, 1), (3, 2)]; - let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); - - // old_to_new should be identity - assert_eq!(old_to_new, vec![0, 1, 2, 3]); - - // subtree_ends: - // Node 3: end=3 (leaf) - // Node 2: end=max(2, end[3])=3 - // Node 1: end=max(1, end[2])=3 - // Node 0: end=max(0, end[1])=3 - assert_eq!(subtree_ends, vec![3, 3, 3, 3]); - } - - #[test] - fn test_mapping_binary_tree() { - // Binary tree: - // 0 - // / \ - // 1 4 - // / \ - // 2 3 - // - // DFS order (pre-order): 0, 1, 2, 3, 4 - // new_id=0: old_id=0, parent=0 (root) - // new_id=1: old_id=1, parent=0 - // new_id=2: old_id=2, parent=1 - // new_id=3: old_id=3, parent=1 - // new_id=4: old_id=4, parent=0 - let data = [(0, 0), (1, 0), (2, 1), (3, 1), (4, 0)]; - let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); - - // old_to_new should be identity - assert_eq!(old_to_new, vec![0, 1, 2, 3, 4]); - - // subtree_ends: - // Node 4: end=4 (leaf) - // Node 3: end=3 (leaf) - // Node 2: end=2 (leaf) - // Node 1: end=max(1, end[2], end[3])=3 - // Node 0: end=max(0, end[1], end[4])=4 - assert_eq!(subtree_ends, vec![4, 3, 2, 3, 4]); - } - - #[test] - fn test_mapping_shuffled_old_ids() { - // 5 nodes with shuffled old_ids: - // Tree structure (in new_id space): - // 0 - // / \ - // 1 4 - // / \ - // 2 3 - // - // DFS order: new_id -> old_id - // new_id=0: old_id=4 - // new_id=1: old_id=2 - // new_id=2: old_id=0 - // new_id=3: old_id=3 - // new_id=4: old_id=1 - // - // Parents (in old_id space): - // old_id=4 is root -> parent=4 - // old_id=2's parent is old_id=4 - // old_id=0's parent is old_id=2 - // old_id=3's parent is old_id=2 - // old_id=1's parent is old_id=4 - let data = [(4, 4), (2, 4), (0, 2), (3, 2), (1, 4)]; - let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); - - // old_to_new: old_id -> new_id - // old_id=0 -> new_id=2 - // old_id=1 -> new_id=4 - // old_id=2 -> new_id=1 - // old_id=3 -> new_id=3 - // old_id=4 -> new_id=0 - assert_eq!(old_to_new, vec![2, 4, 1, 3, 0]); - - // subtree_ends (same tree structure as test_mapping_binary_tree): - // Node 4: end=4 (leaf) - // Node 3: end=3 (leaf) - // Node 2: end=2 (leaf) - // Node 1: end=max(1, end[2], end[3])=3 - // Node 0: end=max(0, end[1], end[4])=4 - assert_eq!(subtree_ends, vec![4, 3, 2, 3, 4]); - } - - #[test] - fn test_mapping_reordered() { - // 4 nodes, but old_ids are reordered: - // DFS new_id order: 0, 1, 2, 3 - // old_ids: 3, 1, 0, 2 - // - // Tree structure (in new_id space): - // 0 - // / \ - // 1 3 - // | - // 2 - // - // Parents (in old_id space): - // new_id=0: old_id=3, parent=3 (root) - // new_id=1: old_id=1, parent=3 - // new_id=2: old_id=0, parent=1 - // new_id=3: old_id=2, parent=3 - let data = [(3, 3), (1, 3), (0, 1), (2, 3)]; - let (old_to_new, subtree_ends) = compute_mapping_from_slice(&data); - - // old_to_new: old_id -> new_id - // old_id=0 -> new_id=2 - // old_id=1 -> new_id=1 - // old_id=2 -> new_id=3 - // old_id=3 -> new_id=0 - assert_eq!(old_to_new, vec![2, 1, 3, 0]); - - // subtree_ends (in new_id space): - // Node 3: end=3 (leaf) - // Node 2: end=2 (leaf) - // Node 1: parent=0, end=max(1, end[2])=2 - // Node 0: parent=0 (self), end=max(0, end[1], end[3])=3 - assert_eq!(subtree_ends, vec![3, 2, 2, 3]); - } - - #[test] - fn test_transform_simple() { - // 4 documents with subtree_ends = [3, 2, 2, 3] - // Tree: - // 0 - // / \ - // 1 3 - // | - // 2 - // - // Posting list (in new space, sorted): [0, 2] - // - // i=0: is_present=true, state=false -> output 0, state=true, push end[0]=3 - // i=1: stack=[3], end=3 >= 1, no pop. is_present=false, state=true -> output 1, state=false, push end[1]=2 - // i=2: stack=[3,2], end=2 >= 2, no pop. is_present=true, state=false -> output 2, state=true, push end[2]=2 - // i=3: stack=[3,2,2], end=2 < 3, pop, state=false. end=2 < 3, pop, state=true. end=3 >= 3, stop. - // is_present=false, state=true -> output 3, state=false, push end[3]=3 - // - // Output: [0, 1, 2, 3] - let subtree_ends = vec![3, 2, 2, 3]; - let postings = vec![0, 2]; - let result = transform_posting_list(&postings, 4, &subtree_ends); - assert_eq!(result, vec![0, 1, 2, 3]); - } - - #[test] - fn test_transform_full_subtree() { - // 4 documents with subtree_ends = [3, 2, 2, 3] - // Posting list (in new space, sorted): [0, 1, 2] - // All of subtree under node 0's left child (1 and 2) are present, plus root - // - // i=0: is_present=true, state=false -> output 0, state=true, push 3 - // i=1: is_present=true, state=true -> no output - // i=2: is_present=true, state=true -> no output - // i=3: stack=[3], 3 >= 3, no pop. is_present=false, state=true -> output 3, state=false, push 3 - // - // Output: [0, 3] - let subtree_ends = vec![3, 2, 2, 3]; - let postings = vec![0, 1, 2]; - let result = transform_posting_list(&postings, 4, &subtree_ends); - assert_eq!(result, vec![0, 3]); - } - - #[test] - fn test_transform_empty() { - let subtree_ends = vec![3, 2, 2, 3]; - let postings: Vec = vec![]; - let result = transform_posting_list(&postings, 4, &subtree_ends); - assert_eq!(result, vec![]); - } - - #[test] - fn test_transform_all_present() { - // All documents present - let subtree_ends = vec![3, 2, 2, 3]; - let postings = vec![0, 1, 2, 3]; - let result = transform_posting_list(&postings, 4, &subtree_ends); - // i=0: present, state=false -> output 0, state=true, push 3 - // i=1: present, state=true -> no output - // i=2: present, state=true -> no output - // i=3: 3>=3 no pop. present, state=true -> no output - // Output: [0] - assert_eq!(result, vec![0]); - } -} diff --git a/crates/pef/src/bin/reorder_docids.rs b/crates/pef/src/bin/reorder_docids.rs deleted file mode 100644 index 10d3b197..00000000 --- a/crates/pef/src/bin/reorder_docids.rs +++ /dev/null @@ -1,515 +0,0 @@ -//! Reorder document IDs by similarity using geometric filters and MST. -//! -//! Usage: -//! cargo run --release --bin reorder_docids --features "geo_filters ordered-float rayon" -- -//! -//! This reads the inverted index, builds GeoDiffCount filters for each document, -//! computes an approximate MST based on document similarity, and outputs a mapping -//! from old doc IDs to new doc IDs sorted by similarity (DFS order of the MST). -//! -//! The algorithm uses multiple random projections (masks) to create approximate -//! MSTs via cmp_masked, then merges them for a better approximation. - -use std::env; -use std::fs::File; -use std::io::{BufReader, BufWriter, Read, Write}; -use std::path::Path; -use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; -use std::time::Instant; - -use geo_filters::diff_count::GeoDiffCount13; -use geo_filters::Count; -use ordered_float::OrderedFloat; -use rayon::prelude::*; - -type GeoFilter = GeoDiffCount13<'static>; - -/// Mask size for GeoDiffConfig7 (7 bits) -const MASK_SIZE: usize = 13; - -/// Read a u32 from a reader (little-endian) -fn read_u32(reader: &mut R) -> std::io::Result { - let mut buf = [0u8; 4]; - reader.read_exact(&mut buf)?; - Ok(u32::from_le_bytes(buf)) -} - -/// Read a posting list from PISA format -fn read_posting_list(reader: &mut R) -> std::io::Result>> { - let mut len_buf = [0u8; 4]; - match reader.read_exact(&mut len_buf) { - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), - Err(e) => return Err(e), - } - let len = u32::from_le_bytes(len_buf) as usize; - - let mut postings = vec![0u32; len]; - for posting in &mut postings { - *posting = read_u32(reader)?; - } - Ok(Some(postings)) -} - -// ============================================================================ -// Union-Find data structure -// ============================================================================ - -struct UnionFind { - parent: Vec, - size: Vec, -} - -impl UnionFind { - fn new(size: u32) -> Self { - let parent = (0..size).collect(); - Self { - parent, - size: vec![1; size as usize], - } - } - - fn find(&mut self, mut idx: u32) -> u32 { - while self.parent[idx as usize] != idx { - let parent = self.parent[idx as usize]; - let grand_parent = self.parent[parent as usize]; - self.parent[idx as usize] = grand_parent; - idx = grand_parent; - } - idx - } - - fn unite(&mut self, a: u32, b: u32) -> bool { - let mut a = self.find(a); - let mut b = self.find(b); - if a == b { - false - } else { - if self.size[a as usize] < self.size[b as usize] { - std::mem::swap(&mut a, &mut b); - } - self.parent[b as usize] = a; - self.size[a as usize] += self.size[b as usize]; - true - } - } -} - -// ============================================================================ -// MST Edge -// ============================================================================ - -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct Edge { - cost: OrderedFloat, - from: u32, - to: u32, -} - -// ============================================================================ -// MST Algorithm (Kruskal's) -// ============================================================================ - -fn build_mst(size: usize, mut edges: Vec) -> Vec { - let mut mst = Vec::with_capacity(size); - let mut uf = UnionFind::new(size as u32); - - edges.par_sort_unstable_by_key(|x| x.cost); - - for edge in edges { - if uf.unite(edge.from, edge.to) { - mst.push(edge); - } - } - mst -} - -// ============================================================================ -// Approximate MST using multiple masked sortings (from blackbird) -// ============================================================================ - -/// Create edges between documents within a sliding window after sorting. -/// Uses the optimized approach: pre-allocate and write in parallel. -fn create_edges_in_window(filters: &[GeoFilter], sorted: &[u32], window_size: usize) -> Vec { - let len = sorted.len() * window_size; - let mut edges = Vec::::with_capacity(len); - - // Use spare_capacity_mut to avoid initializing edges we'll overwrite - edges.spare_capacity_mut()[..len] - .par_iter_mut() - .enumerate() - .for_each(|(k, edge)| { - let i = k / window_size; - let j = (k % window_size) + 1; - let from = sorted[i]; - let to = sorted[(i + j) % sorted.len()]; - let a = &filters[from as usize]; - let b = &filters[to as usize]; - let cost = a.size_with_sketch_f32(b).into(); - edge.write(Edge { from, to, cost }); - }); - - unsafe { - edges.set_len(len); - } - edges -} - -/// Return all bit masks with exactly `ones` bits set among `num` least significant bits. -fn mask_patterns(num: usize, ones: usize) -> Vec { - (0..(1usize << num)) - .filter(move |pattern| pattern.count_ones() == ones as u32) - .collect() -} - -/// Merge multiple MSTs by combining their edges and rebuilding. -fn merge_msts(num_nodes: usize, mut msts: Vec>) -> Vec { - while msts.len() > 1 { - msts = msts - .par_chunks(2) - .map(|chunk| { - let edges: Vec = chunk.iter().flatten().copied().collect(); - build_mst(num_nodes, edges) - }) - .collect(); - } - msts.pop().unwrap_or_default() -} - -/// Parallelise a function over a slice with limited parallelism. -/// Important when each invocation consumes significant memory. -fn parallelise(f: F, indices: &[usize], parallelism: usize) -> Vec -where - R: Send + Sync + Default + Clone, - F: Fn(usize) -> R + Send + Sync, -{ - let next = AtomicUsize::new(0); - let results = std::sync::Mutex::new(vec![R::default(); indices.len()]); - - rayon::scope(|scope| { - for _ in 0..parallelism { - scope.spawn(|_| loop { - let idx = next.fetch_add(1, AtomicOrdering::Relaxed); - if idx >= indices.len() { - break; - } - let r = f(indices[idx]); - results.lock().unwrap()[idx] = r; - }); - } - }); - - results.into_inner().unwrap() -} - -/// Compute multiple approximate MSTs using different mask patterns. -/// Each mask creates a different projection for sorting via cmp_masked. -fn compute_multiple_approximate_msts( - filters: &[GeoFilter], - masks: &[usize], - window_size: usize, -) -> Vec> { - let indices: Vec = (0..masks.len()).collect(); - - parallelise( - |idx| { - let mask = masks[idx]; - - // Sort documents by masked filter comparison - let mut sorted: Vec = (0..filters.len() as u32).collect(); - sorted.par_sort_unstable_by(|&a, &b| { - filters[a as usize].cmp_masked(&filters[b as usize], mask as u64, MASK_SIZE) - }); - - // Create edges in window - let edges = create_edges_in_window(filters, &sorted, window_size); - - // Build MST for this projection - build_mst(filters.len(), edges) - }, - &indices, - 4, // Limit parallelism to avoid memory issues - ) -} - -/// Compute approximate MST using multiple masked sortings. -fn compute_approximate_mst( - filters: &[GeoFilter], - window_size: usize, - num_mask_bits: usize, -) -> Vec { - // Generate all mask patterns with num_mask_bits/2 bits set - let masks = mask_patterns(MASK_SIZE, num_mask_bits); - println!( - " Using {} mask patterns with {} bits set...", - masks.len(), - num_mask_bits - ); - - println!( - " Computing {} approximate MSTs with window size {}...", - masks.len(), - window_size - ); - let msts = compute_multiple_approximate_msts(filters, &masks, window_size); - - println!(" Merging {} MSTs...", msts.len()); - merge_msts(filters.len(), msts) -} - -// ============================================================================ -// Tree traversal for document ordering -// ============================================================================ - -/// Convert MST edges to adjacency list representation. -fn mst_to_adjacency(num_nodes: usize, mst: &[Edge]) -> Vec> { - let mut adj: Vec> = vec![vec![]; num_nodes]; - for edge in mst { - adj[edge.from as usize].push(edge.to); - adj[edge.to as usize].push(edge.from); - } - adj -} - -/// DFS traversal of the MST to produce document ordering. -/// Documents visited close together in DFS order are similar. -fn dfs_order(adj: &[Vec], start: u32) -> Vec<(u32, u32)> { - let n = adj.len(); - let mut visited = vec![false; n]; - let mut order = Vec::with_capacity(n); - let mut stack = vec![(start, start)]; - - while let Some((node, parent)) = stack.pop() { - if visited[node as usize] { - continue; - } - visited[node as usize] = true; - order.push((node, parent)); - - // Add neighbors in reverse order so they're visited in forward order - for &neighbor in adj[node as usize].iter().rev() { - if !visited[neighbor as usize] { - stack.push((neighbor, node)); - } - } - } - - // Handle disconnected components (shouldn't happen with proper MST) - for i in 0..n { - if !visited[i] { - order.push((i as u32, i as u32)); - } - } - - order -} - -// ============================================================================ -// Main -// ============================================================================ - -fn main() -> std::io::Result<()> { - let args: Vec = env::args().collect(); - - if args.len() < 2 { - eprintln!( - "Usage: {} [window_size] [mask_bits] [refine_window]", - args[0] - ); - eprintln!(); - eprintln!("Reorders document IDs by similarity using MST with masked sorting."); - eprintln!("Outputs a mapping file next to the input."); - eprintln!(); - eprintln!("Arguments:"); - eprintln!(" window_size - Number of neighbors to consider for MST (default: 32)"); - eprintln!( - " mask_bits - Number of bits set in masks (default: 3, max: {})", - MASK_SIZE - ); - eprintln!(" refine_window - Window size for local refinement (default: 0 = disabled)"); - std::process::exit(1); - } - - let input_path = &args[1]; - let window_size: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(32); - let mask_bits: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(3); - let refine_window: usize = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(0); - - println!("Reading PISA posting lists from: {}", input_path); - println!("Window size: {}", window_size); - println!( - "Mask bits: {} (generates {} masks)", - mask_bits, - mask_patterns(MASK_SIZE, mask_bits).len() - ); - if refine_window > 0 { - println!("Refinement window: {}", refine_window); - } - - let file = File::open(input_path)?; - let mut reader = BufReader::with_capacity(1024 * 1024, file); - - // Read header - let header1 = read_u32(&mut reader)?; - let header2 = read_u32(&mut reader)?; - println!("Header: [{}, {}]", header1, header2); - let num_documents = header2 as usize; - println!("Number of documents: {}", num_documents); - - // ======================================================================== - // Step 1: Build geometric filters for each document - // ======================================================================== - println!("\n=== Building document filters ==="); - let mut filters: Vec = (0..num_documents).map(|_| GeoFilter::default()).collect(); - - let start = Instant::now(); - let mut last_report = Instant::now(); - let mut term_id = 0u64; - let mut total_postings = 0u64; - - while let Some(postings) = read_posting_list(&mut reader)? { - for &doc_id in &postings { - filters[doc_id as usize].push(term_id); - } - total_postings += postings.len() as u64; - term_id += 1; - - if last_report.elapsed().as_secs() >= 1 { - print!( - "\rProcessed {:>10} terms, {:>12} postings...", - term_id, total_postings - ); - std::io::stdout().flush()?; - last_report = Instant::now(); - } - } - - let elapsed = start.elapsed(); - println!( - "\rProcessed {:>10} terms, {:>12} postings in {:.2}s", - term_id, - total_postings, - elapsed.as_secs_f64() - ); - - // ======================================================================== - // Step 2: Compute approximate MST using multiple masked sortings - // ======================================================================== - println!("\n=== Computing approximate MST ==="); - let start = Instant::now(); - let mst = compute_approximate_mst(&filters, window_size, mask_bits); - let elapsed = start.elapsed(); - println!( - "MST computed in {:.2}s ({} edges)", - elapsed.as_secs_f64(), - mst.len() - ); - - // ======================================================================== - // Step 3: DFS traversal to get document ordering - // ======================================================================== - println!("\n=== Computing document ordering ==="); - let adj = mst_to_adjacency(num_documents, &mst); - - // Start from document 0 (or the largest document) - let start_doc = 0u32; - let order = dfs_order(&adj, start_doc); - - // ======================================================================== - // Step 4: Write reordering to file - // ======================================================================== - let output_path = Path::new(input_path).with_extension("docid_reordering"); - println!("\n=== Writing reordering to {} ===", output_path.display()); - - let output_file = File::create(&output_path)?; - let mut writer = BufWriter::new(output_file); - - // Write as binary: array of (doc_id, parent_id) - for &(doc_id, parent_id) in &order { - writer.write_all(&doc_id.to_le_bytes())?; - writer.write_all(&parent_id.to_le_bytes())?; - } - writer.flush()?; - - let file_size = std::fs::metadata(&output_path)?.len(); - println!( - "Written {} bytes ({:.2} MB)", - file_size, - file_size as f64 / 1_000_000.0 - ); - - // ======================================================================== - // Statistics - // ======================================================================== - println!("\n=== Reordering Statistics ==="); - println!("Total documents: {:>12}", num_documents); - println!("MST edges: {:>12}", mst.len()); - - // Sample some reorderings - println!("\nSample reordering (index -> doc_id, parent_id):"); - for i in [0, 1, 100, 1000, 10000, 100000, 500000].iter() { - if (*i as usize) < order.len() { - let (doc_id, parent_id) = order[*i as usize]; - println!( - " Index {:>6} -> Doc {:>6} (Parent {:>6})", - i, doc_id, parent_id - ); - } - } - - // Compute average MST edge cost - let mst_sum: f64 = mst.iter().map(|e| e.cost.0 as f64).sum(); - if !mst.is_empty() { - let avg_cost = mst_sum / mst.len() as f64; - let min_cost = mst - .iter() - .map(|e| e.cost.0) - .min_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap_or(0.0); - let max_cost = mst - .iter() - .map(|e| e.cost.0) - .max_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap_or(0.0); - println!("\nMST edge costs:"); - println!(" Sum: {:>15.2}", mst_sum); - println!(" Min: {:>15.2}", min_cost); - println!(" Avg: {:>15.2}", avg_cost); - println!(" Max: {:>15.2}", max_cost); - } - - // ======================================================================== - // Compute sum of differences between consecutive documents - // ======================================================================== - println!("\n=== Consecutive Document Similarity ==="); - - // Before remapping: consecutive docs are 0,1,2,3,... - // Sum of size_with_sketch(doc[i], doc[i+1]) for original order - let sum_before: f64 = (0..num_documents - 1) - .into_par_iter() - .map(|i| filters[i].size_with_sketch_f32(&filters[i + 1]) as f64) - .sum(); - - // After remapping: consecutive docs follow DFS order - // Sum of size_with_sketch for new consecutive pairs - let sum_after: f64 = (0..order.len() - 1) - .into_par_iter() - .map(|i| { - let doc_a = order[i].0 as usize; - let doc_b = order[i + 1].0 as usize; - filters[doc_a].size_with_sketch_f32(&filters[doc_b]) as f64 - }) - .sum(); - - let avg_before = sum_before / (num_documents - 1) as f64; - let avg_after = sum_after / (order.len() - 1) as f64; - let improvement = (avg_before - avg_after) / avg_before * 100.0; - - println!("Sum of consecutive diffs (before): {:>15.2}", sum_before); - println!("Sum of consecutive diffs (after): {:>15.2}", sum_after); - println!("Avg consecutive diff (before): {:>15.2}", avg_before); - println!("Avg consecutive diff (after): {:>15.2}", avg_after); - println!("Improvement: {:>14.1}%", improvement); - - Ok(()) -} diff --git a/crates/pef/src/elias_fano.rs b/crates/pef/src/elias_fano.rs deleted file mode 100644 index f99a7863..00000000 --- a/crates/pef/src/elias_fano.rs +++ /dev/null @@ -1,475 +0,0 @@ -struct BitStream { - data: Vec, -} - -impl BitStream { - fn new(bits: u32) -> Self { - Self { - data: vec![0; ((bits + 63) / 64) as usize], - } - } - - fn set_bit(&mut self, index: u32) { - self.data[index as usize / 64] |= 1 << (index % 64); - } - - fn set_bits(&mut self, index: u32, value: u32, bits: u32) { - if bits == 0 { - return; - } - let start = index / 64; - let end = (index + bits - 1) / 64; - self.data[start as usize] |= (value as u64) << (index % 64); - if start != end { - self.data[(start + 1) as usize] |= (value >> (64 - (index % 64))) as u64; - } - } - - fn finish(self) -> Vec { - self.data - } -} - -pub struct EliasFano { - pub(crate) bits_per_value: u32, - pub(crate) high_bits: Vec, - pub(crate) low_bits: Vec, -} - -impl EliasFano { - pub fn new(iter: impl Iterator, max: u32, len: u32) -> Self { - let (_bits, bits_per_value) = optimal_bits_per_value(max, len as u32); - // TODO: we want to get the buffers as input... - // In particular in the partitioned case, we somehow want all the - // high bits in one stream and all the low bits in another stream. - // The question is how to store the starting positions of each partition - // most efficiently... - let mut high_bits = BitStream::new(((max >> bits_per_value) + 1) + len); - // Note: add some buffer, so that we don't have to worry about out-of-bounds reads! - let mut low_bits = BitStream::new(bits_per_value * (len + 15)); - for (i, value) in iter.enumerate() { - assert!(value < max); - let high = value >> bits_per_value; - let low = value & !(!0 << bits_per_value); - high_bits.set_bit(high + i as u32); - low_bits.set_bits((i as u32) * bits_per_value, low, bits_per_value); - } - EliasFano { - bits_per_value, - high_bits: high_bits.finish(), - low_bits: low_bits.finish(), - } - } - - pub fn iter(&self) -> EliasFanoDecoder<'_> { - let high_bits = BitReader::new(&self.high_bits); - EliasFanoDecoder { - current_word: high_bits.get_word(0), - bits_per_value: self.bits_per_value, - high_bits, - low_bits: BitReader::new(&self.low_bits), - value_idx: 0, - bit_pos: 0, - } - } - - /// Returns the number of bits used for encoding low bits of each value. - pub fn bits_per_value(&self) -> u32 { - self.bits_per_value - } - - /// Returns a reference to the high bits data. - pub fn high_bits(&self) -> &[u64] { - &self.high_bits - } - - /// Returns a reference to the low bits data. - pub fn low_bits(&self) -> &[u64] { - &self.low_bits - } - - /// Returns the size in bytes of the encoded data. - pub fn size_in_bytes(&self) -> usize { - (self.high_bits.len() + self.low_bits.len()) * 8 - } -} - -pub struct EliasFanoDecoder<'a> { - pub(crate) bits_per_value: u32, - pub(crate) high_bits: BitReader<'a>, - pub(crate) low_bits: BitReader<'a>, - pub(crate) value_idx: u32, - pub(crate) bit_pos: u32, - pub(crate) current_word: u64, -} - -pub(crate) struct BitReader<'a> { - data: &'a [u64], -} - -impl<'a> BitReader<'a> { - pub(crate) fn new(data: &'a [u64]) -> Self { - Self { data } - } - - fn get_bit(&self, index: u32) -> bool { - self.data[index as usize / 64] & (1 << (index % 64)) != 0 - } - - pub(crate) fn get_word(&self, index: u32) -> u64 { - unsafe { *self.data.get_unchecked(index as usize) } - } - - pub(crate) fn get_word_unaligned(&self, byte_pos: u32) -> u64 { - unsafe { - let ptr = self.data.as_ptr() as *const u8; - std::ptr::read_unaligned(ptr.add(byte_pos as usize) as *const u64) - } - } - - pub(crate) fn get_bits(&self, index: u32, num_bits: u32) -> u32 { - let start = index / 64; - let end = (index + num_bits - 1) / 64; - let mut temp = self.data[start as usize] >> (index % 64); - if start != end { - temp |= self.data[(start + 1) as usize] << (64 - (index % 64)); - } - (temp as u32) & !(!0 << num_bits) - } - - pub(crate) fn len(&self) -> u32 { - (self.data.len() * 64) as u32 - } -} - -impl<'a> Iterator for EliasFanoDecoder<'a> { - type Item = u32; - - // Decoding 32 values in a row could be done more efficient by - // a) expanding the low bits into a full words. - // b) using masked SIMD operations to process multiple high bits in parallel - // but masking those with 0 bits. - // c) adding the two results together. - // Note: this is VERY similar to how bit packing with exceptions works. - // The main difference is that we can use the high bits only to skip forward! - // Advantages of EF are that: - // 1. we don't need to store deltas explicitly, i.e. - // no need for partial sum computations as final step. - // 2. we can skip ahead and decode individual values only. - // 3. NEW: we can intersect quickly purely based on high bits. - // 4. Due to 2, EF leads to a compact two-layer representation, while - // preserving skip list functionality. - // - start value of each block (<- EF) - // (encode number of low bits in start value with trailing ones!) - // - start bit/byte of high bits (<- EF) - // [- start bit/byte of low bits (<- EF)] - // (these could also be stored in reverse order ending at next partition) - // [- counting ones in high bit representation] - // (succinct; only needed for interval encoding) - // 5. NEW: EF can be used for interval encodings, where it is - // important to know the index of each value. - // 6. The encoding size is fully deterministic and can be computed with - // simple bit operations. - // So, in total, the EF encoding is much more flexible than bin-packing. - fn next(&mut self) -> Option { - let mut current_word = self.current_word; - let mut bit_pos = self.bit_pos; - loop { - let zeros = current_word.trailing_zeros(); - if zeros < 64 { - current_word >>= zeros; - current_word >>= 1; - self.current_word = current_word; - let bucket_id = bit_pos + zeros - self.value_idx; - let value = self - .low_bits - .get_bits(self.value_idx * self.bits_per_value, self.bits_per_value); - self.bit_pos = bit_pos + zeros + 1; - self.value_idx += 1; - if self.bit_pos & 63 == 0 { - self.bit_pos -= 1; - } - return Some(value | (bucket_id << self.bits_per_value)); - } else if bit_pos + 64 >= self.high_bits.len() { - return None; - } else { - bit_pos = (bit_pos + 64) & !63; - current_word = self.high_bits.get_word(bit_pos / 64); - } - } - } -} - -impl<'a> EliasFanoDecoder<'a> { - /// Skips forward to the given target value. - /// Returns `Some(value)` if the target exists in the sequence, - /// or `Some(next_value)` where `next_value > target` is the next value in the sequence, - /// or `None` if there are no more values >= target. - pub fn skip_to(&mut self, target: u32) -> Option { - while let Some(value) = self.next() { - if value >= target { - return Some(value); - } - } - None - } - - /// Returns an iterator that yields values present in both this sequence - /// and the provided iterator. Both must yield values in sorted order. - pub fn intersect>(self, other: I) -> Intersection<'a, I> { - Intersection { - ef_decoder: self, - other, - } - } -} - -pub struct Intersection<'a, I> { - ef_decoder: EliasFanoDecoder<'a>, - other: I, -} - -impl<'a, I: Iterator> Iterator for Intersection<'a, I> { - type Item = u32; - - fn next(&mut self) -> Option { - let mut ef_val = None; - while let Some(other_val) = self.other.next() { - if Some(other_val) < ef_val { - continue; - } else if Some(other_val) == ef_val { - // Found a match - return ef_val; - } - ef_val = self.ef_decoder.skip_to(other_val); - if ef_val == None { - return None; - } else if ef_val == Some(other_val) { - // Found a match - return ef_val; - } - } - None - } -} - -// TODO: Implement an iterator which only retrieves the high bit buckets. -// Such an iterator can be used to approximate an intersection! -// This way, one can potentially erase parts before running a second pass -// where the low values are being decoded... -// When does this work? -// Usually, one would start off the intersection with the shortest posting list. -// This posting list has the coarsest high mask granularity, i.e. for a random -// sequence, 40% of the buckets will have a value. -// If the second posting list is also random and at the same level, then -// we get 40% * 40% = 16% of the buckets remaining. -// If the second posting list is at a higher level, i.e. more dense, then -// the fill level would be roughly 63% and the intersection would be 40% * 63% = 25%. -// For another level difference, the fill level increases to 86% ==> 34% remaining. -// I.e. for more than 2 levels difference there is no benefit of this approach! -// The fill rate can be reduced by spending more bits per value. -// For example moving one level down, increases the number of bits from: -// n * log(u/n) + 2n to n * log(u/n) + 4n. - -// TODO: Implement an interval based iterator where the even indices represent -// the start of an interval and the odd indices represent the end. -// In this case, the intersection of two iterators cannot be represented as a -// simple bitmask unfortunately. Instead, one has to serialize the new -// set. -// If however one of the two sets is represented as points, then the masking -// works again... One has to decide however, whether too many points would be -// filtered out and therefore the bitmask would become larger than the encoded -// representation. - -// TODO: Implement an intersecting iterator which masks off values from the first -// iterator as we go. - -// TODO: Implement test presence of a value. This requires the rank/select stuff... -// -pub fn optimal_bits_per_value(max: u32, len: u32) -> (u32, u32) { - let mut best_cost = (u32::MAX, 0); - for bits in 0..32 { - let cost = bits * len as u32 + ((max >> bits) + 1) + len; - if cost < best_cost.0 { - best_cost = (cost, bits); - } - } - best_cost -} - -#[cfg(test)] -pub mod test_utils { - use rand::prelude::*; - - pub fn generate_markov_chain_data(len: usize, seed: u64) -> Vec { - let mut rng = StdRng::seed_from_u64(seed); - let mut data = Vec::with_capacity(len); - let mut current = 0; - let mut state = 0; - for _ in 0..len { - let gap = if state == 0 { - if rng.random_bool(0.1) { - state = 1; - } // Transition to sparse - if rng.random_bool(0.9) { - 1 - } else { - (rng.random::() % 5) + 1 - } - } else { - if rng.random_bool(0.1) { - state = 0; - } // Transition to dense - (rng.random::() % 100) + 1 - }; - - current += gap; - data.push(current); - } - data - } -} - -#[cfg(test)] -mod tests { - use rand::prelude::*; - - use crate::elias_fano::test_utils::generate_markov_chain_data; - - use super::*; - - #[test] - fn test_elias_fano() { - let data = vec![1, 2, 5, 13, 15]; - let max = 16; - let len = data.len(); - let elias_fano = EliasFano::new(data.iter().copied(), max, len as u32); - println!("{:b}", elias_fano.high_bits[0]); - println!("{:b}", elias_fano.low_bits[0]); - let mut decoder = elias_fano.iter(); - for value in data { - assert_eq!(decoder.next(), Some(value)); - } - } - - #[test] - fn test_elias_fano_decoder() { - let data = generate_markov_chain_data(32 * 32, 12345); - let max = *data.last().unwrap() + 1; - let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); - - let decoder = ef.iter(); - let decoded = decoder.collect::>(); - - assert_eq!(decoded, data); - } - - #[test] - fn test_intersect_basic() { - let data1 = vec![1, 5, 10, 15, 20, 100, 200, 500]; - let data2 = vec![2, 5, 10, 12, 20, 50, 200, 300, 500]; - - let max = 501; - let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); - - let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); - assert_eq!(intersection, vec![5, 10, 20, 200, 500]); - } - - #[test] - fn test_intersect_empty_result() { - let data1 = vec![1, 3, 5, 7]; - let data2 = vec![2, 4, 6, 8]; - - let max = 10; - let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); - - let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); - assert!(intersection.is_empty()); - } - - #[test] - fn test_intersect_all_match() { - let data = vec![1, 5, 10, 15, 20]; - - let max = 21; - let ef = EliasFano::new(data.iter().copied(), max, data.len() as u32); - - let intersection: Vec = ef.iter().intersect(data.clone().into_iter()).collect(); - assert_eq!(intersection, data); - } - - #[test] - fn test_intersect_two_ef() { - let data1 = generate_markov_chain_data(1000, 11111); - let data2 = generate_markov_chain_data(500, 22222); - - let max1 = *data1.last().unwrap() + 1; - let max2 = *data2.last().unwrap() + 1; - - let ef1 = EliasFano::new(data1.iter().copied(), max1, data1.len() as u32); - let ef2 = EliasFano::new(data2.iter().copied(), max2, data2.len() as u32); - - // Compute expected intersection - let set1: std::collections::HashSet = data1.iter().copied().collect(); - let expected: Vec = data2.iter().copied().filter(|v| set1.contains(v)).collect(); - - let intersection: Vec = ef1.iter().intersect(ef2.iter()).collect(); - assert_eq!(intersection, expected); - } - - #[test] - fn test_intersect_first_empty() { - let data1: Vec = vec![]; - let data2 = vec![1, 2, 3]; - - let max = 10; - let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); - - let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); - assert!(intersection.is_empty()); - } - - #[test] - fn test_intersect_second_empty() { - let data1 = vec![1, 2, 3]; - let data2: Vec = vec![]; - - let max = 10; - let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); - - let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); - assert!(intersection.is_empty()); - } - - #[test] - fn test_intersect_large_gap() { - let data1 = vec![1, 1000000]; - let data2 = vec![1, 500000, 1000000]; - - let max = 1000001; - let ef = EliasFano::new(data1.iter().copied(), max, data1.len() as u32); - - let intersection: Vec = ef.iter().intersect(data2.into_iter()).collect(); - assert_eq!(intersection, vec![1, 1000000]); - } - - #[test] - fn test_optimal_bits() { - for i in 1..100 { - for max in i..300 { - let max = max - i; - let (cost, bits) = optimal_bits_per_value(max, i); - let b_f32 = (max as f32 / i as f32).log2(); - let b = (max / i).max(1).ilog2(); - let fast_cost = b * i + max / (1 << b) + i + 1; - if fast_cost != cost { - println!( - "{i} cost: {}, bits: {} {b_f32} {b} {}", - cost, bits, fast_cost - ); - } - } - } - } -} diff --git a/crates/pef/src/helper.rs b/crates/pef/src/helper.rs deleted file mode 100644 index 4c518986..00000000 --- a/crates/pef/src/helper.rs +++ /dev/null @@ -1,91 +0,0 @@ -#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))] -use std::arch::x86_64::*; - -/// Transform the Elias-Fano high-bits mask into a bitmask which can be used for -/// simple AND intersection before decoding low-bits. -/// At the end of the AND operation, one knows which buckets are still relevant -/// for further decoding. -#[inline(always)] -fn transform_mask(mask: u64) -> u64 { - unsafe { - // We extract bits from (mask >> 1) based on the selector (!mask) - _pext_u64(mask >> 1, !mask) - } -} - -/// Constructs the Elias-Fano high-bits mask for 16 values. -/// -/// `high_parts`: A ZMM register containing 16 `u32` high-part values (bucket indices). -/// Returns: A `u64` bitmask where the bit at `i + high_parts[i]` is set. -/// -/// Note: This assumes `i + high_parts[i] < 64`. If the data is very sparse, -/// you may need to handle offsets or multiple words. -#[inline(always)] -pub unsafe fn construct_high_bits_avx512(high_parts: __m512i) -> u64 { - // 1. Add the implicit index (0..15) to the high parts - // The i-th set bit belongs at position: i + high_parts[i] - let increment = _mm512_setr_epi32( - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 - ); - let bit_indices = _mm512_add_epi32(high_parts, increment); - - // 2. We need to shift '1' by these indices. - // Since _mm512_sllv_epi32 only works within 32-bit lanes (masking shift to 5 bits), - // and our indices can be > 31, we must promote to 64-bit integers to use _mm512_sllv_epi64. - - // Split into low and high halves (8 integers each) and promote to u64 - let idx_lo = _mm512_cvtepu32_epi64(_mm512_castsi512_si256(bit_indices)); - let idx_hi = _mm512_cvtepu32_epi64(_mm512_extracti32x8_epi32(bit_indices, 1)); - - // 3. Create bitmasks: 1 << index - let one = _mm512_set1_epi64(1); - let bits_lo = _mm512_sllv_epi64(one, idx_lo); - let bits_hi = _mm512_sllv_epi64(one, idx_hi); - - // 4. Combine all 16 bitmasks into one register - let combined = _mm512_or_si512(bits_lo, bits_hi); - - // 5. Horizontal OR to reduce the 8 u64 lanes into a single u64 scalar - _mm512_reduce_or_epi64(combined) -} - -/// Given the Elias-Fano high-bits sequence and a mask of interesting buckets, -/// returns a bitmask of the values that belong to the interesting buckets. -/// -/// `high_bits`: The Elias-Fano high-bits sequence (1 = value, 0 = bucket boundary). -/// `bucket_mask`: A bitmask where bit `k` is set if bucket `k` is interesting. -/// -/// Returns: A bitmask where bit `i` is set if the value at position `i` in `high_bits` -/// belongs to an interesting bucket. -#[inline(always)] -#[cfg(all(target_arch = "x86_64", target_feature = "bmi2"))] -pub fn filter_values_by_bucket(high_bits: u64, bucket_mask: u64) -> u64 { - unsafe { - #[cfg(not(target_feature = "avx512f"))] - use std::arch::x86_64::_pdep_u64; - - // 1. Identify the start of each bucket's value run. - // The start of a run is marked by a 0->1 transition in high_bits, or index 0. - // By shifting high_bits left by 1, we align the '0' separators - // with the start of the *next* bucket. - // Inverting gives us 1s at these start positions. - let starts = !(high_bits << 1); - - // 2. Distribute the bucket_mask to these start positions. - // If bucket k is set in bucket_mask, the k-th set bit in 'starts' - // (which is the start of bucket k) gets a 1. - let mask_at_starts = _pdep_u64(bucket_mask, starts); - - // 3. Propagate the mask through the runs of ones. - // Adding the start bit to the run of ones causes a carry ripple - // that turns the run of 1s into 0s (and sets the next 0 to 1). - // Example: 111 + 1 = 1000. - let sum = high_bits.wrapping_add(mask_at_starts); - - // 4. Recover the original runs. - // The runs that were added to are now 0s. Inverting makes them 1s. - // The runs that were NOT added to are still 1s. Inverting makes them 0s. - // ANDing with the original high_bits selects only the runs that were inverted (selected). - (!sum) & high_bits - } -} \ No newline at end of file diff --git a/crates/pef/src/lib.rs b/crates/pef/src/lib.rs deleted file mode 100644 index ba3d9460..00000000 --- a/crates/pef/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod batch_decoder; -mod elias_fano; - -pub use batch_decoder::*; -pub use elias_fano::*; - -#[cfg(target_arch = "x86_64")] -pub mod avx_batch_decoder; - -/// Partitioned Elias-Fano structure. -pub struct PartitionedEliasFano { - // Implementation details to be added -} - -impl PartitionedEliasFano { - pub fn new() -> Self { - Self {} - } -} From cc73c69b7417569e2df808caa785c01d122616fa Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Fri, 8 May 2026 16:48:04 +0200 Subject: [PATCH 41/45] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ae3acce7..0dbe85f6 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A collection of useful algorithms written in Rust. Currently contains: - [`geo_filters`](crates/geo_filters): probabilistic data structures that solve the [Distinct Count Problem](https://en.wikipedia.org/wiki/Count-distinct_problem) using geometric filters. - [`bpe`](crates/bpe): fast, correct, and novel algorithms for the [Byte Pair Encoding Algorithm](https://en.wikipedia.org/wiki/Large_language_model#BPE) which are particularly useful for chunking of documents. - [`bpe-openai`](crates/bpe-openai): Fast tokenizers for OpenAI token sets based on the `bpe` crate. +- [`sparse-ngrams`](crates/sparse-ngrams): fast sparse n-gram extraction from byte slices. Selects variable-length n-grams (2–8 bytes) deterministically using bigram frequency priorities, suitable for substring search indexes. - [`string-offsets`](crates/string-offsets): converts string positions between bytes, chars, UTF-16 code units, and line numbers. Useful when sending string indices across language boundaries. ## Background From 85058467465f800fc2485709ed4ca43d7f6913fd Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 12 May 2026 08:53:24 +0200 Subject: [PATCH 42/45] review comments --- crates/sparse-ngrams/Cargo.toml | 2 +- crates/sparse-ngrams/README.md | 31 ++++++++----- crates/sparse-ngrams/src/extract.rs | 22 +++++----- crates/sparse-ngrams/src/lib.rs | 6 ++- crates/sparse-ngrams/src/murmur.rs | 67 ----------------------------- crates/sparse-ngrams/src/ngram.rs | 24 +++++++---- crates/sparse-ngrams/src/table.rs | 9 ++-- 7 files changed, 58 insertions(+), 103 deletions(-) delete mode 100644 crates/sparse-ngrams/src/murmur.rs diff --git a/crates/sparse-ngrams/Cargo.toml b/crates/sparse-ngrams/Cargo.toml index d5bcde0b..80ca0f79 100644 --- a/crates/sparse-ngrams/Cargo.toml +++ b/crates/sparse-ngrams/Cargo.toml @@ -17,4 +17,4 @@ path = "benchmarks/performance.rs" harness = false [dev-dependencies] -criterion = "0.5" +criterion = "0.7" diff --git a/crates/sparse-ngrams/README.md b/crates/sparse-ngrams/README.md index f12808ed..5a599783 100644 --- a/crates/sparse-ngrams/README.md +++ b/crates/sparse-ngrams/README.md @@ -4,6 +4,16 @@ Fast sparse n-gram extraction from byte slices. Sparse grams select variable-length n-grams (2–8 bytes) without extracting all possible substrings. The algorithm is deterministic: the same extraction logic applies to every substring, making it suitable for substring search indexes. +For background, see: +- [The technology behind GitHub's new code search](https://github.blog/engineering/architecture-optimization/the-technology-behind-githubs-new-code-search/#fn-69904-bignote) +- [Sparse n-grams: smarter trigram selection](https://cursor.com/blog/fast-regex-search#sparse-n-grams-smarter-trigram-selection) + +## Caveats + +The integrated bigram table contains only lowercase, ascii characters. +Therefore, the implementation is meant to be used for a case insensitive search index. +One can expect a case sensitive index to be significantly larger. + ## How it works Each consecutive byte pair (bigram) is assigned a frequency-based priority from a precomputed table. An n-gram boundary occurs wherever a bigram has lower priority than all bigrams between it and the previous boundary. This is computed efficiently using a monotone deque or a scan-based approach. @@ -45,16 +55,17 @@ The priority table maps byte pairs to frequency-based priorities. Increasing the ![Unique n-grams vs. table size](images/unique_ngrams_vs_table_size.png) | Table size | Unique n-grams | % of max | -|-----------|---------------|----------| -| 100 | 6.2M | 79.4% | -| 200 | 6.7M | 85.9% | -| 400 | 7.1M | 91.1% | -| 800 | 7.5M | 96.2% | -| 1,600 | 7.8M | 99.0% | -| 3,200 | 7.8M | 99.9% | -| 6,400+ | 7.8M | 100% | - -Beyond ~6,400 entries the table saturates — additional bigram rankings produce no new n-grams since all occurring byte pairs already have distinct priorities. +|-----------|-----------------|----------| +| 100 | 6.2M | 79.4% | +| 200 | 6.7M | 85.9% | +| 400 | 7.1M | 91.1% | +| 800 | 7.5M | 96.2% | +| 1,600 | 7.8M | 99.0% | +| 3,200 | 7.8M | 99.9% | +| 5,845 | 7.8M | 100% | + +The current bigram table contains only the first 5,845 most frequent lowercase ascii bigrams. +But the table shows that bigrams start to saturate past after the first thousand bigrams. ## Maximum n-gram length diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index 0f845faf..bead4c56 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -37,13 +37,13 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { } assert!(out.len() >= max_sparse_grams(n)); let table = get_bigram_table(); - let mut queue = FixedDeque::<{ MAX_SPARSE_GRAM_SIZE as usize }>::new(); - let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut queue = FixedDeque::::new(); + let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE]; prefix_hashes[1] = content[0] as u32; let mut w = 0usize; for idx in 1..n as u32 { - let mask = MAX_SPARSE_GRAM_SIZE as usize - 1; + let mask = MAX_SPARSE_GRAM_SIZE - 1; let end_hash = prefix_hashes[idx as usize & mask] .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx as usize] as u32); @@ -58,7 +58,7 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; if let Some(begin) = queue.front() { - if idx - begin.index + 1 >= MAX_SPARSE_GRAM_SIZE { + if idx - begin.index + 1 >= MAX_SPARSE_GRAM_SIZE as u32 { queue.pop_front(); } } @@ -101,11 +101,11 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { assert!(out.len() >= max_sparse_grams(n)); let table = get_bigram_table(); - const MASK: usize = MAX_SPARSE_GRAM_SIZE as usize - 1; + const MASK: usize = MAX_SPARSE_GRAM_SIZE - 1; let mut w = 0usize; - let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE]; prefix_hashes[1] = content[0] as u32; - let mut priorities = [u16::MAX; MAX_SPARSE_GRAM_SIZE as usize]; + let mut priorities = [u16::MAX; MAX_SPARSE_GRAM_SIZE]; for idx in 1..n as u32 { let end_hash = prefix_hashes[idx as usize & MASK] .wrapping_mul(POLY_HASH_PRIME) @@ -119,7 +119,7 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; priorities[idx as usize & MASK] = v1; let mut running_min = u16::MAX; - for d in 1..=(MAX_SPARSE_GRAM_SIZE - 2) { + for d in 1..=(MAX_SPARSE_GRAM_SIZE as u32 - 2) { if d >= idx { break; } @@ -172,7 +172,7 @@ mod tests { result.insert(NGram::from_bytes(&content[i..i + 2])); } // Longer grams: length 3..=MAX_SPARSE_GRAM_SIZE. - for len in 3..=MAX_SPARSE_GRAM_SIZE as usize { + for len in 3..=MAX_SPARSE_GRAM_SIZE { 'outer: for start in 0..=n.saturating_sub(len) { if start + len > n { break; @@ -225,7 +225,7 @@ mod tests { let grams = collect_sparse_grams(input); for gram in &grams { assert!(gram.len() >= 2, "gram too short: {gram:?}"); - assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize, "gram too long: {gram:?}"); + assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE, "gram too long: {gram:?}"); } } @@ -239,7 +239,7 @@ mod tests { fn test_max_gram_size_boundary() { let grams = collect_sparse_grams(b"abcdefgh"); for gram in &grams { - assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize); + assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE); } } diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index ca2c0642..d10610db 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -24,7 +24,7 @@ //! assert!(grams.len() > input.len() - 1); //! for gram in &grams { //! assert!(gram.len() >= 2); -//! assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE as usize); +//! assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE); //! } //! ``` @@ -36,10 +36,12 @@ mod table; pub use ngram::NGram; /// Number of high-frequency bigrams used to build the priority table. +/// We reserve u16::MAX (65535), since some algorithms need a max value. +/// We also reserve 0 for all non-frequent bigrams. pub const NUM_FREQUENT_BIGRAMS: usize = 65534; /// Maximum length (in bytes) of a sparse n-gram. -pub const MAX_SPARSE_GRAM_SIZE: u32 = 8; +pub const MAX_SPARSE_GRAM_SIZE: usize = 8; pub use extract::{collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams}; diff --git a/crates/sparse-ngrams/src/murmur.rs b/crates/sparse-ngrams/src/murmur.rs deleted file mode 100644 index b9702241..00000000 --- a/crates/sparse-ngrams/src/murmur.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Murmur1 hash function (ported from blackbird_core). - -use std::hash::Hasher; - -trait XorRsh { - fn xor_rsh(self, nbits: u32) -> Self; -} - -impl XorRsh for u32 { - #[inline] - fn xor_rsh(self, nbits: u32) -> u32 { - self ^ (self >> nbits) - } -} - -fn murmur1_hash(bytes: &[u8], seed: u32) -> u32 { - const M: u32 = 0xc6a4_a793; - const R: u32 = 16; - - let mut h = seed ^ (bytes.len() as u32).wrapping_mul(M); - - let chunks_len = bytes.len() / 4 * 4; - for chunk in bytes[..chunks_len].chunks_exact(4) { - let ptr = chunk.as_ptr() as *const u32; - let k = unsafe { ptr.read_unaligned() }; - h = h.wrapping_add(k).wrapping_mul(M).xor_rsh(R); - } - - let mut tail_bytes = [0u8; 4]; - tail_bytes[..bytes.len() - chunks_len].copy_from_slice(&bytes[chunks_len..]); - h = h - .wrapping_add(u32::from_le_bytes(tail_bytes)) - .wrapping_mul(M) - .xor_rsh(R); - - h.wrapping_mul(M).xor_rsh(10).wrapping_mul(M).xor_rsh(17) -} - -/// Hasher implementing the Murmur1 hash function. -struct Murmur1Hasher { - bytes: Vec, -} - -impl Default for Murmur1Hasher { - fn default() -> Self { - Self { - bytes: Vec::with_capacity(64), - } - } -} - -impl Hasher for Murmur1Hasher { - fn write(&mut self, bytes: &[u8]) { - self.bytes.extend_from_slice(bytes); - } - - fn finish(&self) -> u64 { - murmur1_hash(&self.bytes, 0).into() - } -} - -pub(crate) fn hash_bigram(gram: (u8, u8)) -> u32 { - use std::hash::Hash; - let mut h = Murmur1Hasher::default(); - gram.hash(&mut h); - h.finish() as u32 -} diff --git a/crates/sparse-ngrams/src/ngram.rs b/crates/sparse-ngrams/src/ngram.rs index 5475dcca..c221c369 100644 --- a/crates/sparse-ngrams/src/ngram.rs +++ b/crates/sparse-ngrams/src/ngram.rs @@ -13,8 +13,8 @@ pub(crate) const POLY_HASH_PRIME: u32 = 2_654_435_761; /// Precomputed powers of [`POLY_HASH_PRIME`] for rolling-hash range queries. /// `POLY_POWERS[i] = POLY_HASH_PRIME.pow(i)` (wrapping `u32`). -pub(crate) const POLY_POWERS: [u32; MAX_SPARSE_GRAM_SIZE as usize + 1] = { - let mut p = [0u32; MAX_SPARSE_GRAM_SIZE as usize + 1]; +pub(crate) const POLY_POWERS: [u32; MAX_SPARSE_GRAM_SIZE + 1] = { + let mut p = [0u32; MAX_SPARSE_GRAM_SIZE + 1]; p[0] = 1; let mut i = 1; while i < p.len() { @@ -27,8 +27,16 @@ pub(crate) const POLY_POWERS: [u32; MAX_SPARSE_GRAM_SIZE as usize + 1] = { /// A compact n-gram identifier: upper 24 bits are a polynomial rolling hash, /// lower 8 bits are the byte length of the n-gram. /// -/// Two `NGram` values are equal iff both their hash and length match, which -/// greatly reduces collision probability compared to a bare hash. +/// Note: With could also store ngrams up to length 8 in an u64. However, this +/// would explode the number of ngram keys in a search dictionary. For that reason, +/// we compress ngrams into an u32 which puts a more reasonable upper bound on +/// the number of dictionary keys (~100 million). +/// +/// Note: By storing the length explicitly in the lower 8 bits, we ensure that +/// only ngrams of the same length collide. This is important because there +/// are exponentially more long ngrams than short ngrams. At the same time, +/// longer ngrams occur less frequently. So, colliding long ngrams won't increase +/// the false positive rate too much. /// /// # Construction /// @@ -111,22 +119,22 @@ mod tests { fn test_rolling_hash_matches_from_bytes() { let content = b"hello world"; // Build prefix hashes the same way the extraction loop does. - let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE as usize]; + let mut prefix_hashes = [0u32; MAX_SPARSE_GRAM_SIZE]; if !content.is_empty() { prefix_hashes[1] = content[0] as u32; } for idx in 1..content.len() { - let end_hash = prefix_hashes[idx & (MAX_SPARSE_GRAM_SIZE as usize - 1)] + let end_hash = prefix_hashes[idx & (MAX_SPARSE_GRAM_SIZE - 1)] .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx] as u32); // Check the bigram content[idx-1..idx+1] let rolling_hash = end_hash - .wrapping_sub(prefix_hashes[(idx - 1) & (MAX_SPARSE_GRAM_SIZE as usize - 1)] + .wrapping_sub(prefix_hashes[(idx - 1) & (MAX_SPARSE_GRAM_SIZE - 1)] .wrapping_mul(POLY_POWERS[2])); let rolling = NGram::from_rolling_hash(rolling_hash, 2); let direct = NGram::from_bytes(&content[idx - 1..idx + 1]); assert_eq!(rolling, direct, "mismatch at idx={idx}"); - prefix_hashes[(idx + 1) & (MAX_SPARSE_GRAM_SIZE as usize - 1)] = end_hash; + prefix_hashes[(idx + 1) & (MAX_SPARSE_GRAM_SIZE - 1)] = end_hash; } } } diff --git a/crates/sparse-ngrams/src/table.rs b/crates/sparse-ngrams/src/table.rs index 70d55f56..4a125058 100644 --- a/crates/sparse-ngrams/src/table.rs +++ b/crates/sparse-ngrams/src/table.rs @@ -8,8 +8,8 @@ use std::sync::OnceLock; use crate::NUM_FREQUENT_BIGRAMS; /// The bigrams in this string are sorted by how frequently they occur in code (descending). -/// Bigrams are separated by null bytes. Only the first [`NUM_FREQUENT_BIGRAMS`] entries -/// receive nonzero priority; all other byte pairs default to 0. +/// Bigrams are separated by null bytes. +/// Currently contains only the top 5845 bigrams (ascii, case-insensitive). static BIGRAMS_STR: &str = include_str!("bigrams.bin"); /// Flat 256×256 lookup table indexed by `a as usize * 256 + b`. @@ -29,8 +29,9 @@ pub(crate) fn get_bigram_table() -> &'static [u16; 256 * 256] { let Some((a, b)) = chars.next().zip(chars.next()) else { continue; }; - let a = (a as u8).to_ascii_lowercase(); - let b = (b as u8).to_ascii_lowercase(); + let a = a as u8; + let b = b as u8; + assert_eq!(table[a as usize * 256 + b as usize], 0); // Higher-frequency bigrams get HIGHER values so they are more often // encompassed by longer grams. table[a as usize * 256 + b as usize] = From 6f750acb468072750119c7cc13f830fdcb1f3476 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 12 May 2026 09:26:51 +0200 Subject: [PATCH 43/45] update readme + graphs --- crates/sparse-ngrams/README.md | 48 +++++++++--------- .../images/unique_ngrams_vs_max_length.png | Bin 46592 -> 40997 bytes .../images/unique_ngrams_vs_table_size.png | Bin 61129 -> 38151 bytes 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/crates/sparse-ngrams/README.md b/crates/sparse-ngrams/README.md index 5a599783..1772382d 100644 --- a/crates/sparse-ngrams/README.md +++ b/crates/sparse-ngrams/README.md @@ -10,9 +10,7 @@ For background, see: ## Caveats -The integrated bigram table contains only lowercase, ascii characters. -Therefore, the implementation is meant to be used for a case insensitive search index. -One can expect a case sensitive index to be significantly larger. +The integrated bigram table contains only lowercase ASCII bigrams. Callers should lowercase and normalize input before extraction (e.g. fold uppercase to lowercase, map non-ASCII bytes to a single sentinel value). This makes the implementation suitable for case-insensitive search indexes. ## How it works @@ -56,16 +54,16 @@ The priority table maps byte pairs to frequency-based priorities. Increasing the | Table size | Unique n-grams | % of max | |-----------|-----------------|----------| -| 100 | 6.2M | 79.4% | -| 200 | 6.7M | 85.9% | -| 400 | 7.1M | 91.1% | -| 800 | 7.5M | 96.2% | -| 1,600 | 7.8M | 99.0% | -| 3,200 | 7.8M | 99.9% | -| 5,845 | 7.8M | 100% | +| 100 | 5.8M | 77.0% | +| 200 | 6.4M | 84.4% | +| 400 | 6.8M | 90.2% | +| 800 | 7.3M | 96.0% | +| 1,600 | 7.5M | 99.2% | +| 3,200 | 7.6M | 99.9% | +| 5,845 | 7.6M | 100% | -The current bigram table contains only the first 5,845 most frequent lowercase ascii bigrams. -But the table shows that bigrams start to saturate past after the first thousand bigrams. +The current bigram table contains the 5,845 most frequent bigrams from a large code corpus. +The table saturates quickly — the first ~1,600 bigrams already capture 99% of the unique n-grams. ## Maximum n-gram length @@ -75,16 +73,16 @@ Increasing the maximum n-gram length produces more unique longer grams, with dim | Max length | Unique n-grams | vs. len=8 | |-----------|---------------|-----------| -| 2 | 1.4M | 18% | -| 3 | 4.6M | 59% | -| 4 | 5.8M | 74% | -| 6 | 7.1M | 90% | -| 8 | 7.8M | 100% | -| 12 | 8.7M | 111% | -| 16 | 9.2M | 118% | -| 24 | 9.8M | 124% | -| 32 | 10.0M | 128% | -| 48 | 10.3M | 131% | -| 64 | 10.4M | 132% | - -The default of 8 captures most of the discriminative power. Going to 16 adds ~18% more unique grams but doubles the scan window; going to 64 adds only ~32% total. +| 2 | 1.2M | 16% | +| 3 | 4.1M | 54% | +| 4 | 5.3M | 70% | +| 6 | 6.8M | 89% | +| 8 | 7.6M | 100% | +| 12 | 8.5M | 113% | +| 16 | 9.1M | 120% | +| 24 | 9.7M | 128% | +| 32 | 10.1M | 133% | +| 48 | 10.4M | 137% | +| 64 | 10.5M | 139% | + +The default of 8 captures most of the discriminative power. Going to 16 adds ~20% more unique grams but doubles the scan window; going to 64 adds only ~39% total. diff --git a/crates/sparse-ngrams/images/unique_ngrams_vs_max_length.png b/crates/sparse-ngrams/images/unique_ngrams_vs_max_length.png index d878c2e9bd04590ae519dde6adf0ad7ab4ee0c2b..9982a15a4bcf8b9266336eb367558c37f8553c4a 100644 GIT binary patch literal 40997 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 VN#I=mTZFqyiOY$-7SVn3e*yOVY^4AI literal 46592 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
      0(qle{d#tbRWn z$Q7UH_D;=uqe9$yx?TQ*>Bq;Lg2!YUI>)YBnOYdFAp>)_FLGOvSY|p;MBw@gew~z;O3)=l@wBWo@=)sc zePtfG$#cJ;=)MB`bm}i$35X1pUM%+6RqCTy0EWR=@4kmw0Q^*e&MQ^D^NL<}q-sbx zdXAqzWuO`IMXPE1DQjQqht-Vl#OI4H(i;QaqhqD9{>zupTOIg5XQ5?}!eWIE1E9lh z4;5#q=i-td%m@ZdIK{K>D9E(RI)C2pqW9-6OT62CAbnxRmp#qK7TN%EEv0S?JkV!Q z_T#~XVYYBj@tUA>bF8b30Hr_9qH$a*nFY4leGN}akc z)+vYyD2D_GU(wdq4%q;`rEYwp=IPCi1sa~iJbKVXTv&uL03DV`-cTR=1C5lcd~Q+c z`(B({sNOuM?8$bl^^&Tmu+IXXqlp>03klQV^O@jC3Hyd3gsqR3y;chMPjT}cvwjup z7B2if<=K~{SK;9nd*AUIloi!)BA!)o=i6n$mUTm&_6iv5!Ef}zS4WBgTc@wiPI@DK zFE&)P;Mh62?Qmy96xvz14sm-N@;8d&FEF^mwzK8K{IESl*vJ{w3fyI3Krf>dEz$GG z8}|G4P~|AG4qqEOedp^{w*d-iv<{fw`n#}2>=`V%{{3B!^OWjQK4V`>dV!=*LQ{d4 z(wP#@^+bUM@zWn%E#37KX$O?Um#XgVDm97r#1e}0tJ!H}DEDb9lGUcG$*CDngArVC0B=Q6=LV$pF92?;0}sH{NQmtXK(E<^91|HzT*8g+`b*8X?(Gr% z?F9NSbXf-h(=#w=Aj;ImK$hLj0aqO^||6S%3hm(H9- zWM^)RlpU-<7>H)he(ztLxU9J+8K3FRW>q()`HIa9!4Am-HdK&p<*W9+xvx>MVR7?a zA^za_ex4xg*V<+7*_21lRu#^|%$%3N)lj^2$?vt-e6d-=2iSGbey4mOEm9oNz&(J+ zY%sp8_{J@oE(0&8cLRjqt?=+Y;K6KjKxY%?NCZVk^YE7axTn!XYzdf@$u|8N!$L^} z<{hPRVDLxOfM9l%3qn&NvJW_A!7ziP0cL{$8B&EanWVJcuJi(d5sj%~fDIb{q7Et} z??Q<1JdobK0B6KqX<^VhgWUrvyQGk`pO^hbrq^5@y4kF}w-PM3ix`LM9Lx-ACxA=8 zf`LJFar#9vm$lf!)sQYog05zAu>^_aw6R%PSBlxZ1kWf?7YS=eZoiv!@NS~UxMUU? z75zzYQcUP~_{@|r*s=C=)MrLrUA-2-nbXkGg1S&TJ-^(@IsM`OX-^wFN=kG5vz7KG z$7<<0JlK=bnR$a3wP9;&WIfj%a3BbeHI@%A}*1v4V?Y7ffAfdRFWm8I5?P;~c|8<9~(eL!DzQaY5`g zws0LJUhrWden?B49c$fmZC}_%6>+5hyUT(X_ikU-eIW38LCh6Pt-wi&r3U6K`b01i9ad4y__Lp5C{Nfh{GIs>p;B@O zx_G;4UHu$Up^f~Wk?({21W3{Vz{Py^5-z||6c${Kolg1^z$pp)u` zq2}!AF9b6+ZQwq=c5amqiioguX~T&fvR9t!W3{%yUkXb!mfo;@>5I7I@9{82BcnI& z63kHbw*qiBvPLs4@V85SgXfseED@#PL~&8Ua=0}PKa z09KwipdI`kizaO(iWiCQ^fGXsJEsQJFCVB}+8;Yjf(Hdh%6?Eu!UuO``tC9;AAJyv zH@+5CgR10+a}*dwIt>BN0i~4+CtJw$4UijOF-|-z#x6wYcNt(iNoLS;1=1ut!ufzR z)>9#AUm(oVI(@@pfs=`NyIX90x(d>%P(bjziVSRRGuY;crr!!&0pS7q7;{y%8qbL} z7`o)Khf*J#g+2>fv||p%@g}B*nbKw~cx1Oq4Aud1q5$_1ok3_g12uOghu_LU)@Z;TRBStg_N3c6k z1git&{n83JCA!hYIPvQ37LI=lGgiTzwcW@UW~aPGcNy5MEVhMxzPbplhn|@N%-ji4 zBAyl`Va4AS{}zm*sfkG~V#LDwfVBNePcN(wpthrre+#rz1kUJ}tJRE)W+7?o;M|9v zllY&FJTsNd60+%4Ph2hqj<<;o(X?)rUC^*@rqi-@fXaS*h0u-?bwa($Efm@G%X$x+pr8f}YSIiBun0nLouKzw&_$ed zZT#onn!~*X5~gGA2JB79{>icZ`ek|GtY|0^%j^~MRw3CAZnV`zozRlB?zl+wl-ZEa z8&4F&{dkmgd|vwG5AaMWalBl`zZ0)MJj75W#aVCK{tVXK#icmaCq;-#gW5vAXJIPE zV4vk2Yg82NZoqE12j_2Huo1#vtFctobboPf@gTC!)Yk0$t39x|xai#g@PF!BN zU;N*(P!p#CZA2P1@9^!sk(x)XWgbpLJ}@?GG4cGSJbDz!?5Q8hC4_-~gU(KlODRdH zpg7?LDcYTta5elm@-+db@#!%!&mrjVo<=6gaHEMqz{4@J$cPOzd>L$DQ%!i=qXAEY z(84AvTKs9*Im)Jw_@OGTXCMqt=DPk+Ow zBGPdV*kx(Fg~*jC!|~4&d)Yo51)81<6LUV>OlV6~JlWR(LCf+0hi) zD9Q(q&8HIz6+;JHyuhoH@lf4HIT9|rY}fSX9r22EoRP%p*Hx4MYAD3Oj)fJf-(CQ+ zGB}uQpf^S8-;N0w#xsO4vScM;nBxh5>Ce$_mks8USv=*xRADPxL>okJfsK`ISE%hO z@tn?d7GJ6-gCP(e-HqRI z{o62{931i>qp<0yCh9=`19+oRx(2!aXcD(>IaSYmD)VjHv2+n}+MA0HBOoM*5ZeFS z0=e3~$Hh-I2ZMC{uowSJdG+e~peL1o$ zIlyE|(C*bXFF<-tyYr(7P`iNv8CIL$WxxsU-UUzS1ryI{s+M7 z#CLOE@vFs645uQ~*Tem|o1Tma1tq%Gm%ZW+bj=jwmm*1v{(@M&z@7n2&S zQD1awQPY8SD!Ub)=YsK%#moSxLrIS%(yWct+{}6(iv;WivY?yINRHS}4vuqFl|bPE z9pu=@y`Xu?8Bk`BBkY0C@i*n)qCfyfUe=KQOB=wQW|R?e9IMsIOwG@M$kf1YLo=fI zFCnu%Uw*q`i8+3@Dgxju7}jFW5!WqFuiLqH!en*HX-2blz$h&3=VoLMUNDMb2_jBg zp1Hqz+o5y-r5?YL%^n1)z|@SAdOh5yvD#~4NH%IWJ)&br$mA`yA#Lehz^yLzV}9HL z<1dd&c^4fubB9(#140(`k0m|daT>7_Jn@EF)Y@zGmf|*P5c4+4zmyD%W_H0`$gSP5 zncXKO=`+8G%-juYjqWGX1b$JDIt5J5#cdXt%2WRhKQsap07c~w1C%xP+wMZYzf0vz zHD{9dhiy*oYaPiuaYSevE%$wtgh`EylSKnnU=8enubGj!D||pFPGP3O&o5PQJJ9bZ zPE(4M>0&wqeJz-@Ry$VrQt84f3YRHntk;Sc6^L!Yd&X}kjbM=>PJ^+gkDAOgEm+An@i<9PvWR|OP1eNy?ZpIOkG@!BxbY$H9H23c%=#gP<3rmw-sRS(dxiQJ$ zpBxPI5QMWnvi)Du&a7}EMmrbS5?|*>+j)|gR~d+MFdubSc_r}YLJRl&>>+6YW)rU<$aqKmaB#(_K+ zG&W{p0mCz=d<0EuX+gjAH1kv^-)F`X&}V#kC6+a^K#3&(BDH9o!A5OUQwTtp*GgRH zETanzU_Q}u0=dpvl!QJ{41=EeiLu*sT-a~A%DhRE=C|XSv6QU%%%9~gqL=~oDhId| z#ei_4y>YR0X!FZ1UMTp}_Y|5nN23~4s6p!Y*nvm8g!}F91KXzE%WJ^($G7*3umB!c z4+nx1rV>9t{}@`Xu+8egR8-x$7051hYBwP7P+f5ScgrV>_@qd2XwMfTR&-4Dlf4Eu!Hd@=e`6YnllVVU<26nCCov% z1ahqt&=}ZTJclD98AA{peeBc^g@GC02xYE(fmIW}rmlnaIfQ zp*d8G>1%3E=ypOuC<^mK$6ypzsQnq}E1`LaxbHCcEUil_Pb-|7=qebO0=;Y~fjqPe zhUHrOsaW!+KnhGc@b?}C&ks_K5R0#geflt?b=gu^B2{loq37IgqU~)7HILCK(V{3A z;y_XGCq6DtUE3@lP?*cNHuT@LzWH(7u?`vp zjLsY2#TY*9_ug;sG(VGNGArHmCvA(h*puQlp{kW#NAQaRQH~%pL00YyD@cBju}Sh8 zI+JP#QXyI1&jW902tYs$_#TJxqGBi)Ul$2EH6;RWr3}sjUHaTb8W&9SK1{8)D7FKzNiX3j%`nW&by-w#80|LlB>qn%zMK12% zvr~w9lkYtz$j3CFkV}(+&6c_JGOE!IO0{+)b0;v4o96ieL6|#SKHt%dOYd1V)(w;uAeZ2C_}6C*BaTo z@-mq7+Zn4(R}Hy%W#`OcyMbe|p2ez6F1;13&j-3cEdLkt$!EvPI;E|LC8=tnZ{4|3 zs>gGqX(@>+3%I&=k0UmqpRzPoH2UmZ^=>C8CnS+gFxT{%xh9zRIrvLp(r_=57w!kz zs?!r@pI_Gxq`AQ4;l^i4pOMmS*w#SZaeGX z-cdC$u-#iI><67Or}S#Dp*(Yjze^{nTue+|g@kMFK?hJ&UYZG+tbNk zGIh(J?^nS|bV^Wv|3Y`83{~wwJLPeZ_Zk`bVRR{ysO|B{L(dZCJxU z3u6UfaKa~5B_zCbnrM7sU)#_?@w=Pe!~<-%bp)xx)nQaGdT%|T=V{p=wo+6A6fC8? z8_>u8&|W38sZz7u)c=07hs!rVemJn&XtVYbH_Nfv9B7$3Z2_TvR#!8U<(gNd1yw4b zhxKr>#Pgh-oG+)^5+gHQ5~DpWpKgc&OIB(10yo#47CAb>Z`yBROY?HtsT_~%%aUS& zUilKD))&fx?iUuCt>AntU}=&g=7SM#0cm5t2-WE}Yuv&0B^E zGc%NTp`m9JG`Wvg@AqBFENjrHqmOJE``k3-*yN=Bw_)g2lgbA}fp0CWj-J4L(o)R( zsv`eT(iW7o^*#Vm7Bxl+D?`mKdBBkg0Fwm&b%2-RVAxKAhGCyQ~(W$%3f0V4hAcX!Kzh>J^=g`2IsO-Rsa}KQ+%i@$l@? zMiHT?f3dPRN$G;euFN~})=eD#cnC;34zw2QKv&{I_LM+GX_p`|^2`CxF+$k^Z^I1K zMFrDuaIvpLJPq0YBMG`^&Z-BKN&#QEPA&OM1x$Da=r(`Xb7l&x5@}MgUahq}Tu^|& zqqyBvS<&tzd(IsgLgTO@L<9j5$*Cn~3xtHPHk_HN9<%yV6L5(`0J_8cUyeI4iQ*&& zN1YxoU>;D`vcZ0TnAd;{ZG?yk1Ov;*;M@Tq_Mc?Nauf1rGKlxjrPh-~7^(4jET~>k z;U77CA9jH-=wH>L9b~?(XD=dRXynp1yq7RYd>?KS%mlzO2aGlh*yS?ZR_4V`D=Je(gSy% zk!v*D2=Ci_?~+};;q9zLt#>qPwP_A^WetRbA_|@fyx@)?TE7AVcpE{Fj0U-~*R;;T z+3^)OM1k2p&3;1<0^gH%0%HY9O%CiNLvtXF#FOL!B8HzFRP!8-L5EYBX&JRV@* zbClzFF=35~DUxvUQl;!pHDuF@k39pH@Y!rwP2%l7hyZ5j+deuyk4EY3BtFlwl^GjA z#~TVLOQAs@NZF&F4l>v9o+6a!`4IwDIyokfirXK29^Dt&bP4x>J^ij6;g(%~Nrpva z0!FB8m44Gom;2F*5%TW#ThKr^KkN-lCt{Hm=ebgaJZs>?q61;fL8m4Pe2d_NyLAEg zK!khb8r9Bo04ueTdz|ObR=p+Ld6r=A#iwjukS69E2*VB0=Smr!gIno_oJ;Cl29T% zl*}S5A}}cQuh!mubSL}XKKw-6&bxgJ?(eIKpYAD?tZ{^^c>ovRqz}ZM#a&$Z`8aSE zE$Dxy!;UIPxM}F5QgEf*Y}cXI`{!!J;c5iTAvgY2v3={;;p`HwSMBT~mvIk@=w+@D zZ5JtT!JNf7f-{T|R?#18Q*jZZhYehamS7g)@&1Avqt{XOc4t&g3*e0oog`?8B7AVS zE}{#m&y=8Gy|{y72*H(WGbifmZJZ(hr&{WT^D!&ls~{_Jw}B8V(2@HO+G`Q=d34UU zdyBvd;p4;=0tHWc6aA%V_}%Sr{Yn}x<){qaz!|>(?E$zH&2d&=0{kNe5Br&?A+&(d z>CrxgYk|=-|I|QGj5Rt=sP62pSUA`#!aHsZwoS&NoaeL14$Jj#C%(Y*_J7BWC9Jp; zaGy@P6|3P@X8(CeJ<4P1gcKc}0FNKdVlJXC=r2YF6`+R)(DLK7u2R|I2^RjuV|7 zpwfvV_@Ki!D5eSbkiB>j=Kr9(BAvzpx8bL|godTzbwZ*c2WL3Skxr`|e$MbH z;ZYjESBRnoMyT`3;kZt(L(Eu$ca?Ux1yy%2q61=(Nsqs1h5}LON=44UiwqfQ*1SGL z+OHe;V5jhp9fl*&?OcNNFZ4XR{#|hl@Ca`ITe{oAQGfr(zSaSM3ztt=VrJ+|371#^ zmpDO4o4RG-YM|$EdSn}meFAUVRR_AzM@9Wa4TS#*CSNjFyDC))uU`o~xde4)sauM! z{*r8bJ1{yjx_t0TW@qUbqow1yo0W9u4FPj>|EYi9zI8Z6ftO)E7@MKn!qSTlsj{=? zyU|i%mbpS$$zX3+`#8~}blvAhxbe$y>F0oITC~!#b2uzI-0+#xhkowj z`6pa}%M)L92!n{XiYPzfY3IRpHg;C#B(eo?^RW^x>nV!0)$)Yv+7Bn=$N8Mlzc~)y zK1(Y@2nh_kvu>;D>2V$N{&5#E-tms-KyHA-X$rh=5O%MM#bPIYCcsAsnPo#|83GW1 zlMgi46b%dvzAl%#%&7Xe$D|JB<>i&YK1M_x`%+OEBP@`Ta@h=h1m1=RhT zrJEq!KD=1Lp_b4-Rs7M8hA{8Qn24y;4#p9TBUO7EHv5C#ATR`e!xx(_Pj~PeR$*Yw zVaSxEhHlCHPVs(r!+j>b&;&-#J2nY+)q;+}q%}^SwXowe8V7rIw`O?Tl)#1<@TZhA zdDPWv%8^kh`efrUGs3`B%a#C#?N%V+;l_c@6NWUk6O&+#egoKoR{dR&voz6Gfr{cc zKz^V zYO+J)E!n|mt{9ZaO|&&ojFa^zmM46A#>5GZG~i7>8rwJXLypPzh;>pdPg{u=z2lp8 zR$l@-CPZu4@d3yx(+pcrE0jCP%rX95&bz8Yh zmDpT7Ca(AYTq8rT;8u`N8%Zj?*i!6@>!Q0v!dgio@WY`!{u;j0?9+#1x7RKp9|I(( zM`8%1_-yFi$1Op6w>-0k-B{5)^b-D(jEN+(WPps9dX)ba=tuwp3IYTyqq8p|F%di; z=IO3pzy6}QQ)aHC<;N@rAY{)xX^2a={Y`<0OZc4cTm^R z2hb@5aCE7ph>?&{21@y?*FxQF;dj8y)U*y&yzQax!{Ga7A4!N7)PA&N$v2h(dbD;MQPt}_p!Q6MngiTg1vwnPXzfN@=u~T z%oPYdV{H^MJYDXzy?LlpZ3**Ua)1VV<~g6wx^wQ0baRcD;Q2A+%w!E#6v&ss#a6K_ zcBeMT4uGYnb3-8@;SLm@D2|{eGaCm-a<2^<8aM@1=zieO5;eFK#muLcmFZ=aOrHU@ zHB^saj)g30rnEs_)Fc?;Y$mOL!+NQVOJTuXZacxz!EOA~bvB+X?qfN0)&P~`@=OQ4}P2hA_HEmt{y-HGY1 zF5s3KqHMl4Fqn`!LFL?KDTL9#{P6hnI*PnK=1>-l1HU>1ptsKlO)ifT;N0v0R^Q5& z*t*f5?d~nCJ|Tr5uhqIYpSx4M)+Z=MUY+ACzYW1r#!m|dgPz@(fpbpp)zi_j>?9{_ z_olD~H9;XrNc~Wra5Z)(KZTn~alKhhhgH?pFyS&GA2o)Q+c;x2gpk5#!?tq0Sw9(9 zdb0atbSPuS1pbZ3U4n!+X);-_N-{OP+qz(fQ-cxUA?A{m+`EIa_o3|K;%<(e4yrZm z+U-l~d$I^ERWA4+ja2`Xe%UBF7WpSZLUyh-{an8PnGsAA%KUEh{g-!gbz{ggz*2DZ z&CYwm_Y6+D2?&xSj^tcU$d(kHG)pL?6e-ZTNckrnN8fn8DMX7A5ff;^#f1Re)5B)| zHX>IQC%41h#3u@tyIBeKl^0xEKJug;ngqwl;(+=lNBoVU6-T25RkZ$-2zXS4#OPzF z-fz=JwSNZ%ihkVANnO25FSkF?ip=y;drW7$w>$_mw`z3qzduQ$85bPW+X+uFM9;U6 zfz&5dw)k#l?zKd2-L?b(6LA>M)bCuP{Z`g{vD(NA--sQ|MiidJ(=v;I)b_HJ7u2U- z7sMq;;Bj^g7i6zV*Z&Je4}*n5;Cl5x4x$(qez;wh-D)qncDQ)5Wax;X#GNi2h|M4f zehtAUTK4HS9nQUe$3IkXyG@COsRq}pw{^807Lm{}``B`(ysmfqG$NSc%oSjCT$ktR zHyK@E8cNV9f6arttMMPbuQv}5OlQ4sBfm14_~n3CPW86cEe3b#Q=ylv>3=g$!)$MX zRTHNl75?GVq6|9!zm8_xK1~6lovaMQ@^+`t`2Xc!TSJ3URekF7cNp)OMDt%dp6grt zn(xJxx^|mb8L07X<*V7M(@)0#q%V<=J58m)CeMHJF=afzX??rk!^X6&nGLUj@~z3) z^5*Z9Gqal>l?I=sI@i)1GMB}hm%N}>%iOp9V*elbm+6v3bHl6*%hDk(?AIs>k5-Ko z^*U2Q$aV^vLFsG$z087dtEV>2#e71@JOSH3kL{npe$0Nx#AwA*Vy{Hfaw_#k7sMrd zn3LiR1QUfNQ#BF_c9>@38e}R5rVa%$PojmnC(bj3=g&9xPc$A3=wk}#V@wt4%)Wn< zN|r9ef@R%gc}5II%^$vTg$6LxP_1{Ds5btLhuz?J9?W~SWo^<~a?UC8oEn#{8<(w9 z+6eYD^)o?Q{i1}s->Y4s&RTAljF_sf-TyED_5A_9CdFVXF74#>m^FH$ZE)lN)4xna z!FkaE)D_vK&@j~piv;~>2@k!$e|`_3DOm{ka|2Da?Bt!{2XuOngXx8w(a?J%^FR-_ zL;YgMaXny~4}i2#RXr5)-je-n3PsjEImPuMoTX2)0Ga9nyR5#IGLNMan1)sYXj=(1 zTSfG{fCr}PHu_Y!1f`ebjp1#Fc=Wmj?FRdyasS>UdNL;kc93E;rTfbxc+a}C+m%nk z9d=l&$CCkM^6B|h(i=1o9>&9fQk|IWk7J3_P48JZp$oGP)n z((i8r`oTJG7=*{stfrSg4RDg^6xuRLT!t()bbkEOblg}ex6Zt&G?)DRxk;eF>OuBq zL$BWj`T(bV%MQnjev}UIdendAC%Lk*S`594b8C79_QNuCRyiKpznc#5O#%PH&27LX zY||iJN6-!E3q>HxDFN)Qhi(a2RkIV#F?Kf=!FtA^ABvM3E2ah|&K3+SfP)r6Hmhxj zR3;hFZ>l?^2yPMsfRS?H-jQ!XN3kj1?b{ohzJe86HDKp`E7$_!{ED#$K+MkC3vfff zBYv~n`Y(&p;^{Y6gbm6u7EzFOI)RahtM>K8Yf=3$kFDD8a*5#d+Vr>zqGM8wnKVZa zfN-XUSj2PBA7BmW=bAO?q%euC)Hn2Kx^R&kuK07Y7b=uky2mo}3JK+FW1GfV_O=i6*3sUS=R$el@U4 z!zl7CMJv;`lCzeI#p!j^Mn7!yJd zLWW}cXpPVWG1}lgV60PyVYaXQcz&s z)0zObvsb#8v%%`d4wl7H6-Se?a@AEv{UKmq{e&f9-exyYB1od}to=%zn{Hlz0}lJ5 z-~0=7{QA~VVkPb}t!}?|mxR0wbRa!NUuJZfyd6@miV|db60w43;IZpuDOlX?u#t&T%HEIlo|e$I2TCj`aFN} z2faY{K{M$v;IQT=nveDL7^o)bsDk!RoOHc*@QixxL=@PI(kb;xz}Ya|0gji+t>VDq z)p}q}cz7(1WgC(crKNEf57}sM8j#7p3zl#?%&FP|Ahy zEdB?Sa%LA&LBCdGjR|1H`k$$vJO26ku|msmj(D@fL7Z9{vWXjAm4C;9nf4th#U0-y zrl!D9Y93n31%CuBzr1`Ae4bVG@gdF$8f!oWET*0}nGIQ+Hzoc!YUlmBh@-zAV`f|58$ zlD=grMGD1xQ6nyKpBRv_j6|*4ZFS@^;B+{sDtaMn><^-oFg%H^t5bwc{~8Ufl5B?b0{!xvj8fj+`yY{c zRwwHgW-J>V3LxT|3jk~BJ_%r!FTS;jtc2F^w_-YupH{ICbHM_@Tw4A`^s2%#HhyqV z$S#pQEqP+Bwz6Yo-`gpO1Oe?*PSekyn$`zrdlh(!wD}l9(db&)94VZ+%vB1=4JAw< zGT)Hu0zGl+Sz3B;+Y1PS0L6j%`oOz>CibNscSBcg$M$y(!$!7Ye;rz8!hiqD$c}d>NSw@Sx zFli;LeiVn9jcGzQNgzraoGAbKiJ>ij7SlT6GY@jAtSuUPfz#H#hxr(*j*}nG$%C`h z{-D72TH{^=2v!n2wH3^~+mwX0=W)>0ZWn}2WpE7)h8q`YVGlkvXg_{95UTT1)Nj9i z2bOu8z;7FP!Di3hsPGOAJE;x{$DEpnVrxW!+@rD(&P3_E#73hb_9`1@knV)8iH16= zq56IQS%_18UyibwJQ2F<#5?bCIP8mj{&-h$Vhq}8?DxFTTvdpw2GlT@kpgg@$HMc_ z^3G~zuq}G;Xa@b!o36^ZEw<%>!<;Bg$~T=$bR0{Ad97lD@H*Ib^Sf-_2Fv7G4=M;N zQxI03%RL&rnAr!`!em~%dDW8wXTZW}vxb-$Tbo0DObSHGR_1$tB858g0^z-3{?7g9 z$VxE;BmYsf_ro$g=J1;N4Dl#2%@K=u*w^3BYjw=|+-fx~f2|OQY3l(tZ=}!k#1VPJ zHE8;|1p+s>=f0Uh>(6wRH}@dTQ>(d|7Is6QZp!5^>&=Cbl<=tThNlLB zCpDRa-pUyd*s!`Pd1Lb7p}AF1sV z3l5c+SM@6|A<5y!)ft2`Tq62R0pU2X`ke15r+?!*6NTD z9#D9gSE4n&#_k+L@u$Ncm_jRe;Pq3KffIIx6Moz-;Y1eQ1y6Mb>^cq>G-ACCNdBCR zUvQcpmwe-O0h}4F>6dqBZl!Adc=e*k=E&V()wjctJLr}Uff_=WI(ke8=6?!1O3R5HN>2+d9>-!Xhqh4iST#&n2^K*J(`)^g0Jc78q+c8!b zsJU2XjsTf`131a|nvR3`E84$}xtLFjg%C{`3KgMdQ zDGZu2|AyI))G(0Og$MZAQ2)sXtTnqpAMvxS`irs!<-Qgdx-A%pUk0w#i#@vcAi^BC zlApq-c$h!u^(TQWe`i)(#e;IPF?UHCckGMGuw z$Svc&= z*XCcw8^XMx&RD7s<~UGB*A4;63n&XyMhAb~&2x{j+E*=@l`|F7Nd0!Z8Hw4$`e>V* zh4Frh*=|z&U;LNUOV}H~HN$p&;5GQ;4^mvyg8C%vD8JKyzZ7C^M5#f&@7nSfm}dn_ z&}2|-8|T4hRfUSf#+@|u@C|~P`_`c8V&-}sgE{JtE9WFy&rD?swjod zP6;$9N`UA0;OP)#1;{L05ALtNnNGQ9ek5pN6GDmn1yQ-K3+$bluFRN*DDlDoIYcEH zfV!a{42=0pPh)VDGN?-)gR~d@z?|&|eZz-O99b@lWAbj$Oe$$~hUBbNA>@pyf&(&%1Q}B)a$>(4qkYx5 z)t%Uy-u(o%^Almc@mBlm(Xom=#WkI3Z)8Xtq7HOFgV(YYc@iAr))6RDF0}3$Q9Yh@ z>{^VJji5|kb3Sjr5+WIi-~Lmi$U%BKKMnAU5($sRA}CdKo<>$bq`7QeaMXr|~(w4}JP=tzd*-8N9e7J2_(EQU;} z6u9vxk4S*(`#z-5srX=M*OL>!0TqRw&UH1uj*?`9@)D-cG7t6^LqS#y83LDeZ_&mG zs(irjgu*uS9&>M|L-S}6f(;rvmQ`W>x!D%DU7mW7x8yHY06T3D z&*jN)qLZ&jA>iEV%e0}IO)ct6vy#gHeD(I<-{utckPWwd`3=vx83dJmUxq;cnal4*5vJ${DzA2UaJj_FjOe_5LSdYgrb^cL$k&u6RMJGr~Oz zS1DA6%{DfGRaOp;rgc_8+2&TP_JRz#U=Z@_p-&(MZW4DKyJEive!yjnqK@t#1L#&E z^6t~@{OZyiCEn%d28$^boBj4_*E(-|3G`~&a% z+xxTm!Sg)#eSfd-^}Vj^K8)}flMs&ekN#2_myFFc4;qfRMNOHjJkl%gaNY#^$Y&(G>j-V=c{6BFTjhwf5Ri4Q z0deB-EclNXixbTYh8ob%iNmsbzpxpZgZf^%3=~g|=X*m=C>`{jn5y%x-7knxJu|Ps zl8;?{1{0@|_&H!e1IF^c7f(Y^unfUxau5Cu`t`@tpfyWq08Vh07qai#7XSc$&P1y* z#cP?}aq_A29%~@-6HTy&n(wDHCszjhM11fDZs>u*uTh^(P^8uU;GT#`*y6xY8QKIQ zggv#co*nWu8eWNJ2Wl8}+6$*vpaw2Se*_|kN+I^cr+GF!0loUv%POSlDJK`*6(9Vh znoP_?rGz7*(-2QF(acpcqXV-y1iBvQz;QqpGqA}u54c^c7pdR~!Q?xNmUK#>RseRA zSGe|V{g<#8!`H90EJvrhL@wAZ=>#0S1Q`-CK?4m9o@PD3BzUe+N^Df9)$YLN$vnVT z=#WvG5)C|HL|@bGvSBsHhy&(tOkROY$>W}Ue7n_DDh%M?>+5cfubB#UV{q60D^mGyfcUs7WTX90E>3FL8rDCEYv~+CV;{*JEs}Xm@)hlxQwi9oB zT^02$#r-5P&iO-}3(*I6_?A`xc>EPHk0lMmV=>h;TQia@iAn$Z2zMJu9#Z}UoLCHd z1$4Qi`dPYp9RpB9550OTV_xWXKgu{#?rb7t)3dwEPhS-LVH4;9OUZqu#Os_OR7Ok& zNep%$)Kpf`&mqgV-=+L;t}dS3lpu9r$OxV}7G7I6oENv_Y*CcRqpmlBYYYoq2lCbr zRKJbtldDbPcAE3RwA4%m(5qeyDb&C*z^jHnia|IJUprH0mAVCW$M`Q8k+gSX_iFov z(wQr>NVAsRjc`$o51E|?gdXpr>*tU#on}B9T4&f`aqu69X8l>QeSjjL{pxXDJT$oz zcF?}If*6&c9R^!eE5^p9C$hlB77XB-)uGId)2jSt^M{?ozo&TGR1)|%utnD$2#(qG zi*x##EY2IqU8`P(+`65M&&&{TuwksyGbn^@?4(`L2Km}o&kDSO_}`wr*L*>fpE0n; z>V8CVM6?&XyMaC#H%>(w_p_l@%r=5S_g|39Q-?I1Dt=$#{B8hJcQPx^Y(uB$T{OPx z!7ZwDUeNbw@*ld%Cl8GP=-e9Pn@J3S=)^^^NOVj$?NvB>wU_RV>_XRsbb4^?qFqnH z-qO!pGxW$)IF^2QHAaz!nfq%HL7*h7unSheufPmd;sST>u)HVftBKDuUEFREB(Uk6 zj*~xf6`?y3R%&4r$?C$nog?7B#GA$rz}YJQOG}qIEh(E-s1PYE^ps_m7?W+zpXlK5 zkHLLG0my}iK zJ*7Ff@afk|V0vNtKyTpCcF{y;$nl~LqJe5Mj%<+h3D||Mv9|5!m$Z{etRH|R7uz`) zTCB_kne`N+2;Oj&YEOZh-MDJ(h2(r;xKb@-Wrs0VSR#78DV4CkFE(jXaOd0OBiYx);&nWFFB6*g1KCsC>It#Hp6QD@;s5`L- zE0|Nh0&GkY->dLJWLMl40EGvK3IT1}R<;_BIdl@By#am?=5-aK* z$nQ>hZ69(WFD>|GF5^yH>+BhO5^m6M!Nj|Z9N@NF9*C%e$W41Vc<(&pgO5qfx347Q zT+=M1|Lkjd173!s&*IZF*m}Ohz2Rs!PAOzcq&Y!cL7xMCN0|G{hc|6+jO{#jqa-H2 zt#}hqsQbBN0nKc*eA%P>&{=U(o6OiLs;#11kxL~m>|p_?nX)+x8}TaG6Y7FBj@Wu} z^fb36W}J(j{quafiSJ?0U@VpB_nJd9DV_7dV}6usHzRN<=h;7EXeKx9M-w7*S#Ml| zxpr&mlYFG3yaBGtnB6PUrwnC&*;4U>%?qkWoYfl??Uqi{ratLaa<~TOE_zJf9!w`u zM1Ahhj&*wbF}X>jv$cj^*kOWwCIYtH-|kX0@~I?@wbvqIuFQZsiX~~(k0z!kVev)DBy$!V0 z9)cbwh54h&Ww9(HpF}z%cz);wy^-)anP#LBs93r+H>w9)8}Pciqc5jc->b>~)Mz*4 zp@v2^zNp<*dw|%6L75&$wxOvC{|H%2o=+hs1R*mxD8ALHL9Lyq>EO3_h_H$*YcQx9 zVw%-mh5BHCu(zqQQqRxtR3WOeUneOu{e9Myx7Lv-F=9hOwviYNhN63R zBM(?*Jy#1xw5=QY-EFiiW7_^o(0u9Hmy{(^YulH^p6?*~X~s%xybjH6|Bbfpqf`g? zkD;|`^b`t7oq%1Oa~TO91eep%{-6nb!Z_r(?DMnO+thzFNZ^)}abYAis-0lX$d-g5 zT8$hfmUvP+*?zDr#>_tlU$j9Q_cYDFk^Ak!ysRDZDH60ZW(52? zMM*d7=6oaBI-9#u+{Xr5aO}IqiGOLSQ{0sBua2UWzanA)Wh`O&F=LgysKM9!3V7y2 zkuAGG6{FF0Pu7FDk1}Z9=ruzgJk^?3R(zB8vuKiFvZ#f|rE0gz;#il-qkgpZ2$4l(b}?>N_Wcv&zdE93V0chN zVqGMJeg<~GLXDa6XITqvfaT-?U;I%*QKmV*}*h|bRUl0=kn61#>R280rZ z2}wESE*8ed;om|w%yqEXIYt_|%Q5#Q^T&e`BN@N_@ z(dG^ed>VQX3^YkS`;?=a$;*=qPfjfB_wifkq_wwY<(2`grD;k4uWeLWOCr+UdPf_btd)d5ox5lpQ zs9^|R3JR|So~swz}P^iPGzx4r1Wbs@Yx6HAovLUo*Xon?{LaD*k)+_YI ze~K&plF6X$u$c)9_Tp4sJnkF)#EUIgj$`tqOl?ECy_93g#TcPY$*ym}_9&Vun zu}X|8Li+HxHY{RSsk#yIEi^jGLgSLZX39hgMMo_B9!6jXh5;IBg%prdXwA?I^-9KT zs;3y5+VnGCnT@f&+4Xz|itt(^BJ6K}CuM3*7=rmo7*XRHUO2Is{at7lD8fstPD5NSEGQ5(omJw*xBD zq!S2Dq!R*22{rJpz`4JB-#flr7sP)bmYpVQjS&Dm92NXX$oHwZepSP8Lz9cK2Q1Ym{n>IcvQ5T z%7gp5uag!>X}oo&_nZI1XdHM1qMwr3dA0xf=hD?isIz~_dt5<9xjq>tImfh7wSmOZ z{h{h3Gzy#w$>6;G=HaoSlNZmp-8YtA^&_+26?TXcvq?{bo7b!6NL8YQ`jT_lSiMFZ~XsYxOXk49-~}Oo;;Z;wP>QQPCO1_6mEV_7(_SE zC;Yz4VTi`-IA9-L~AUn(0gxf*a;)CbzF$k@Kz?)i|cj0A!ITDN}!UF8Ckw z`id2c!-Xb&I9#woGBnle&M}BLSgZdX5~FLS$xN2%;xWhuZL{YCY2r}fuKfM${@R@X z&!XV`Ig~FVA~MyZCKMxRTJ=~_@l94vPV4(~H>N&Gj@=oZv6H?BwXjH++4}vqr`!o< zGg9&O=B-<8>A~#0<>S89i-TV?TMd2R#6RKtQgyAKOiJ9F$gJnhVLcd_eC7TQVLCHh z?#!H&A9(}k^5d%2<6wF=bz`_sZjwug_uucAp=Fb~slJhZWCF}(0rnE|qW-{+CiMUox@8eJ^J8&n%wAwu$PMYskP7t?y>DW6vIgf++#SjXsHR8a%ir2;hm&cfA?h8xHlVZp9z8f~VCBl3*`dxlr3;wd1Fh&|f zEXN?^7smC?JhO*rHn6?8haA<);p{G8HEn;shmy;NhxfOe803F?&L&&oQM-Cz0Ui5Q z_Cv)kn;k7J!kFqMNsUAa@nsr^X3s3*^wu0{Z*Q&BrTgJ!mDyj(9c9*IHI-8C_1iOv zPmM)HMLTAX`M?MVG!Eru2ZPVUV-EH=>!0fC>P|Ly5k(#FVp0?DE@Xi%o2k6X&7FWF zy}uFmxlO3v1l)GuNtT~;s>eFgu1_tCeq2J~=|?>_4zkOhjAM4{tw@VQ;$zeh*4C4d zjc-#eQR1>9xj_9+x}|OqaMjDA_Jeoo%LzL$uX!$gZ||Djs*qe-p_)5sqs|p}5^Zg|x@g;-)ZK=hdsPr$ck~avh5{3;n&t zJGdX8SbXF-d>q`QdNDpLkJa1RfLf?`Q62m!ok5oUXIPhlFqUX`09vN-QKDi zE<4U_VeM;1W~Ql5t|q)c2u>KNta2xU@9<>~gq`i#RC1kUGw`o zd9St{BQi*iIKS=mLH^m_o{G}i@h-s1a`0R8ZG; zZR_06f0c%VT+j5=6US zM@#1K1AR?RO>Fsbh*b#op2BheDn&ofgZEx&j*JiF? zRwK%0)sIo5($X5%x4wD}o@Ew$Tv$27+`Id7b7^#Ssd~B9wNv)itGl$zdhGe7XUt*`yl;97Zxu>mE^TjSsoM ziX!xksqyI-MtiQ!#E;4CdxPVcqwGJnIg{ulQNLn`xj}sLw@Zm39xl7lr<1c8P!99h zh_fOynWcvAzZt1Tc=Q@8AQ-Hd-vV>-y;RB12FHFC-}V?GycL<6>J&R%>6$y_w0_xv z+;38z+|RNQ2lI_U6;`$G71ZXqu`sYmwA(+gbtDWF6uZqoT^m`~x48$qr4@BcEA}Mq zr7!I7@dSlU=xDh7-ingvdbe^%SO^quDzjsd7>z@xFzqDp z!|F^@e0OYyiY-Gu>+oxfKUkdBcPmnTNby5fUZh1Ec6~&vP55b|@XF5{;a}SDi4PH& zxw?#IrqOWh!>$ znBTz)^rzP{!Z`*~6nk^EBx?=4DcY*8bvfIipWMAVpPSlRyE$ejNZ5rUbmk-mEK9AL z!5Qp8NF~+s*m~{do!ND9-gSYld(L@{`5pLFPKMoUhvSv>Fq?gOPcmKn2xVQfd9r)d z^sLrpp@NyqY{c(K*?2vSnSiL(FR|S!64ko$0Z026;ikRiius(x_P_VIxqmFG;R)&p z#=5m%_hu^4wFaok6vR@5N)&f|+YWx-H?ua}sNB&3Ur-yhA=(JM)0C6nZsK-o`tr8< z{46~&5WT+EUOAg$$F7A~m_N8bD!1d?HR)ZnZtxqkSzL#6OYJ5>JkAdA7UztTfdEUG)VqNFJYT(Yjl^aB~H)mpV3; zOniD3ex5`iHmq-as+i5wN|+B;S>xf3T^Y&tbZLh;F+lwJkF%RZ2HEw`ov@$dUIYP; z@IW4&oQgMCccKxdHnL`@($y*kqZ7_9Yhq|oE!~@R^-9{0C zCtCJJ>%)Bdof!CKxo+O9xFl#Y=juaoors}S-<_7_=Lm$^gjX`}lBidY84T)>$>LPg z4ZTo4;-XfWLO+%=KAY-iR=8;ht<^)PoB@t5c)Z!H)(bnHat+U7KJ_!=r4R5=g*7!b zyqlfClY0{T{{p986=&0(5r}#<`6K6+=0g?D8Y%_(lt5AJ%R(oObg&^GzIQ za-mBOwVR14OF}GF&UpC<#>dBBv$5g?*6m6Ek#&>O4j`Eq?*T&-t*S5NZ$=wx-qtIH7{o=AnCC*k~VxmEFZ5r*n}a7Sh?Z-4$1K^2a;7I-!H=!&%`br z$Ll;@_G{(I-5Q0huB~;fGC})YyHfcID;jIrYOoYrCRsjT)^&iWv+mTklf~4MjJk-B z-zESAFMQIbF~B>Fk9VPZHScxs=N=0vhkP5qoanPls!Wh{a&%PF(12T7S?zkWlC>`v zLQqznDKll=nab73jHVlIhU67yezLP8b`qQl+MRu-dfoXQ?{0>Y0hgVJX;lCjI zq(UQuM^EjAx+hxGeYFlP(1hHJZ1mC(B+xLz> z6w5xjL%?iSnqbUo+eK__Y-aAx&I52DUOta#(ZqW0t#~9^!RUQq^iY4MQ*^=woZb4n zvaq)R$BoWbi5V2fvfOjG?;^?r?9lEI%osHg1>-_x*8tN_;K1>ev8) zSc|^)vJ$#y=SfSW1m6P_-|cB=q1M6jT(MFxJ>8BJ#944hhf2Y}RIQF)YQ?g_|JaO) ze6Nwx1XcFvMgbKOKW!SUzm@Sltm%I6={d%WaIhR`WzD5uQ0YbGVDw3)V1j|$>xTdZ z=*H3K-(ASd^d4KP^f`W2G25ZrLcB`WEiV<`^Jav4%cShW9$A2H!ej%_=B)p#7_Vti_TVrjxETMEpJha6JrI;g+)yt2F9MkP|<5R^~%D?&EfzF`g|* zeIk5n?V%N<$1oU~a1xUvW0l#HaM9~bwC~F1-g#4|WU0r5jJeB-$y{m#Lu)pEGG}3l zFm;Jwfz_XI$9tne(ukOWyWR1XXokhuni?6M*+)$`$=4@^v`b!|U3*ACzV?k+gB}pZ z5BAIRIkI8e>uq5J`Q5G#cHegd+-o%fve5OGpCheHCT9Fn$h~FRulPio5GyZ@c%HUQ zSAe^4+u`l6uzc}EvG(;`Bsm@FkOMbC3q^V%ZKa%s_3ggRk$S7{9&Gv@SlOl~doASW zj70{W1QzKji`bWbFZrFjMplnD4YiCo+Mabp&#KtD&Wr4L*(8h6Jx}v|87#EewjYQ3b?_^pt4Q7Id!Lb}*U&hBBh-P`2eyq0v?e>@;;)PI?~*siJM1F@ zx>nlHyvI_l^AtOa5ZVk#h{72}4c8ctiH)L$* z4)h5W>IRj+X_vmN-7A#c4^6(lhN<7L+FnJjhsOA}^azbgJCt8?>3@UOvoQ3d;xV$@ zVD7?=yAJb_Pnw0EA9o*inE8ubpwsJj-Pm)P3H9aa$|%wMl2!Y}v2yy-g}c^+X|Oqf zPAz(s+Lmzn(#xmG&;F>uV8q%ft~M7&jO9==7dNAb&^!3nzTlC=IJL_Q9F$s zKPBkg68ooKXV%^qJ6lFYP4^RPQ09BOf3{%a{XW}b+;B*#{-=JmMp5+Box1K6L)E6+ zqd`nITDRzMr5aV?)btfup9Q7vCD# ztXR6m%)P*1G&Z!VDppiQ#;%z|^6RcYFI(n}DzIJMh9#dEV!rRVN0*N?xW!W(y#bWC_B zFN7+XT~4;>PWYg)sg>-YrNl(XDsKMO>y*vbvjU;66`4M3UX4rYs0n?r-4NvS2R(PGi5Yb7jX8cSgsJqb%s*E{h^!Cf+K+knR9PZt)PcreP4l z#&6dCBIW1qE>(A)6)q0Sx?W70#^*E}j?Th4dOPWu6T;YK@$Goz@FT=E!vmO@diT{6 z#F0xW&0E7x^`q}p0+5;v#>ths!K;MuyCp9>L~7}6qrUcC7a?o%dLl3o|=K5(?3{BpZo}uk$}ymttLqQG?^~1a5l!>gn{N|O>c{Nt*V8MQ16s2W1!>#K$g0&t zBLAktMoRZXrYk1HE>zFa=n`-b1z49bpBKr+50<-p}yT;tL6xo zP1qh;+!Ell^LbJh#O;|`KPPBX(d9(EK>Uy)6Z{9tn(-Uskx+Q!Gi<~z%F0+awZ7LZ z7oSu67i}h3RlkEU&Cy8ILxnpK8TQ$A=06Wq{lM+(CY!w-_{>cwUrLin`c~X{+3EZ6 z+tXY!;cenJR%y2Xis3b>y~Il&iWP8^@)IvFmD5XeOI?wCizeMH^)**%{=3|3-N2=irZrQkpW}hc$?Fc{z&?r6^Md;IrQbz2yr-boA4l{?=~*w+<Wk+Op99;%^d@YOv6D!gm57ZDR3RO2SCq1(bLoe@dr(5+=&D zcEt?_t*SR#{u@#noN{TeP-9>}O$p~s{^Pe?yP0rxH0i-66Px>NIK2w?gpS$|md?+u z$nZ=tboY*4o%J#u+oi=&_T$)~IE?$kuHmMi?0nn;{^{tXGITIbsk!7wMEzAw+48{s z_o5)+!nDnW8#70qP_p}I^JF!|4OVRC!SvWEGo*AjYH3z_-6;5rvC^ndH;0HfnPE+t z7T*~!+Po~XBUjW|VIIzAB>6Nolxc2(w8{TxIh-VjGla(MhxZb-q3^R(`DDW3kOetg zKr8F7H+`Zv?G{{O9+|Uui8FPNk7qXZ``)KO0JMTItf+2x>7Iu`h`D@T(3@Y`}fSK$GAAmc0Dw~cYbZNE!S7LNBe>NVv0*Ej|+gc32$n5?mr3E zvS#C9TTN?PE3GIIlK!X}{C95B_Gi|x>z{8QUYhO%-hX?C_yI79A{Pqh-Eq4obY2WF z3uY<>xmkz$PV}c!Nq>A?zcPPWmd*C}cHl4EWqDL??7m`w>}qLP%rEPl`eH(ygn*HQ zk8nSpjVV_c$ElMV=X-a6;Xu0S%fME5JmZEh^I7X|JM#5(2a_td{61=OHZ1h}0ny1% zv|hyS3vxFb+F-UxBd}EHX0#T9Rczdl9-0ojw{)kLWuVa1j><$9x1ta{VSUZqr<2eg zSmm%z`>9USHuGn_qY_(T{_+hX&q}!8Uem=EoEZeAkCAZb7D9L>M+KEyYsrwQ3QG(Y zg3TwB3Om$x?nQ-eOj{>+YfQT zSeDQeE%Tdg3f6K0v#`w{HtGf@U+48tZJj$sn(pelbCZXst#+ zWS?);<}^>6&m#RK1-Fe3GT+7YJMlW53;4NzNvFsCsTOclN)!Ly*Utz zUvnzz_W=h1+k8lz$RXf3B1@_DEG#Ty4np~Q%%7B(Gd-HW^U^6G^_HxoC;k0_P8Gb; zQzagsl%4IzP4~(CnQik2>g0n0O#XgnUngS647&iK%A+xXXm`G_vZ~gU-Ow#yG@YIY zWEq?ismS55zGSb>VnAh)b}M>wG+c%5IgQn*mJ`SY!|>WMdwgjN9B}Si_D+cs4kq#k zdnG84R+w3~XbRf4?*y7kbd?2(n8NC%Dpacks<>DL2Q1QTNO(Adb)uQ)bc}Dg4s`g} zIFF#TOGt0h*KO?8bQ&RxR+Z)zoZ*K-ILRrA2OexZ>4w+~QD)Dc~gPcBP%x zvE9S#SY!y+9-8YoT2%?MD)xwEUC!N)*+_?rAOFMGZkIX?57bEF%blF|*=4;-aT^A0 zcEeP!M&Y>a(XivxXPt?IUz?V_-b#g?s2blwu*sBH*Xy*!3YgMf;&<++_UVM097J-d z?Ts}{he6>B!{rz|@K@h*JI-ZM*ncsxl{?KB{q{F7e%s!TOb7aMJ9C-7u*tU!a)a2H z9SOV#I4?&Og^Jk=U&U&YO(+a}ro$chf^Pjms*KjHBxl90VQr3kpW({N%GDHPj#)kq z8|LqVBT{OJ&#UO*umOn^#bGoY-goARkcP^+0ARnnVbR#l2$D#*G_4Ur(U05*E7{OD zAjdd@Fxq7yKQ}=qZ4}pZAF*RCbd?M@Ks=cfnpE|(=ufxUK~>JG%j~Nsh_%RQ71(49 zE84luxpIK~!u(Kny@4%5Fmeuau!F+v+ZJq;yS)zGk3`n16NK7#)XMjt`t&_fjvv$6 z33Vtc-Fz}!=Fm~KaoH|8P#tl09y2dJ+;yLZ?S(J4ZoNk>8^o~C!exMkWADGEZqd<_ zwe-Tz3(Xm^sQX0>O!)24mej+lHuUX@4(atPw#KE}C3O;bod@N~b?o~K!6(&%*XFy} zh}&uPR|xh{{F-7t$2_3`URZi7dH;{2hKEx#;l(YVQTW!(1uYp1ucA5a+u60p-G)s- zHfu)8sMZf{#3P=)VOXl4L<;Hl(FwnSPt-wF+64FzC(@c)lBT{kvy1K`e@W!-u2Mp9 zgaYD^%`i4N6%a;z%HuHXeJkcZcIZHt6_NbM&~1o?$dyscj0J?KO#f!h(q>gXNJ;dJ zj3>m1CV>Pat31^|b*#+N#wqNRrM_R5Ht}x<^sjl7VaVESx5PQC6Q+8w=fu;wqP4b| zyC`kXJIeupPm9Mv5&nc%CWt^ACmte~m__3|W_e{|K04L7G`NT!(|>ST52N5CfF)O@ z8r3qt%|19-C+C&;FU(Q{1huS2-b?#Fi?xa+_4KCb?wAnMY8Tewy_*1l)>((S;-j97 z3wb57->dRic9ff(o8`XOrUc3Jd6Muf(7YDn&)8$}PWq#QeA+^A`Qu!1DYUuW>Qu`T zGs}Daos=TmYc~FN{nW=7m`Rop2(POq$N@n7nTuYQJ;nDj=Aj1TjzRK1Qc|OLQ+|bI zEB*HFwh%I!8e|U1KKb|9t!FT5NSfEel7a^WmOseUos?(AumxAOb_oBn`H~-X~ zg($30a@GEmrmwy;^&p{q@uF5?@yVimd}^z!tL^+=Agv`{qH6~4hrTR(+|Y73RrZ(H zP8?>d{WFF0*#AFfuocZg-gV~acZ{ch1l^^zxYWq_SW`3h&!1or5Zg_@q8r>^W5Eu}ItlWo4Bn zYT=$Ni{l?YgcVnN<{dBP>kg|?Y!ToWj^hgnId7BHrI2u|IkAni;mikmHt8>GA3vVW z2j;Z6-P6b@0i>)UkW*(;pF`fMTD;01k-euhW|qM7H(T%n#u;Iw9-V~GtyO35)j7}1 zn9;Q9>$czbz1W&8XrueRxK=v1b^QaG@NFMNwCp__I>!%l-Tn}z;H#mMYOnG)Rb{-$ zAHOHIyHv^i{uGqPY<5yHt{OpV{al_e_-~Fo?aVv+Ji6hvGa3wa<~6l$C5u1#A|E$1 zDpJG}mI3Q~NiJ%62ibne6?u_^?|8LX)M=mLM6Wvv@VPz9UDY;qp?n`M%Nbo0BkqSZ zzdGPv2$lGYY+J)szE*WhQHgK8WJZIra(Yb_SJnn|=$6V@@5(!W+Bf|xL15l2Hd~r> zN=~t=<2b-LOm1(&HaAA8Vd1d+Q91S|9MLz`j%m2Txcaz{_vzo_$k+8Du799p=E@hF zDL!(DmFA zjn|jNN#Bt|rH;zJ$vn2Ky!a9;Kd0-RDN4IaDdhW~QQoI?zs5yi7=QXCGhOsNp)E(1 z8Im4NEeWyJ_kHr=f%H-N1-!Vfi#+KSSgIjClAF~IExgrP$4iyjdcOL-r*P4s?r z5ZRq|IFAl6O;E`?DivYWLu|(UEK5?{zLBe4_VV&iguKJ)D&;3O@u7xe3u)BCPnP`i z0+h9Nm!!kn{w{IyEHtJTY{?d*lussv$QKFL3P^C{ovKrJTt3O{ zZ7-yyE?2&le&{`VJsO8`)!AN;lYb*UGcR{9-3jr*?P#+VFKNcRu6YDhnrTU${O&GU zVix)#Z1q&gvzx!p_{mVQdEA}iIu){c{>r?|l5<{Tl?*}wx8#T~e*9^m-6Kpfb;Jk692JZ>PxO`9(`B5>bn^jMhTCaG^L+F}Nn;z?e-d``eBJJu^Y}$$3;VNRRG zPv@c)T3aS`GqSo?=m?>Ej-icjNA736mXESN%(_N{)o-K6}a1_l79F7tQWH{jk!_ z(V!2xzV7Cl5)p9?4%O3(mC{mEE2-uIC*b~v(oSJ!O@|ISZPx__&gm>?Rjq;qv1o}I zlLN=w1v=-mppz$T+{}$$CkHyO)YqS_s_SYrezba$p=>Zh|G^yOofK_44Mml;a~t!? zvUy2YG_fi+*1u$%@)fjUr{JHK5cu72c7KH}#=|-$i9wwyVPjaJb1U=0>^EmsjMhgK z8FQ}hG8JS?>9Abs-R-2+MSHL#c#|TNw*ja|}TqBM-o@s%ko?+%6PpnxX z{&)q-iWn{2rnfVr@<}t|cJBM0x z?z~yDJpAiCd^$A9Nc56ZB>dj0<<7T#UnPFJ+%n5$hV+^souR-;{fqm~NSkNJZN~Bf zO4Y-n=sQ?0fKjxy4)^E*gy=afRI{4ZHo7$@sI4aH9V3AT50O(<5R?wo2J6=2DKen)$K@E1zzu>nSb* zkFU)>l?#C=I9#U`aOJ&KRc(9GLGm_?h`+%nRwQBpEu4P^HGYsIv*36gC9|I-;G8A-s-OJ)u z#gu7e=~r{lnAko2K5=mg7xuVJ?v_RYzfV&$_SXlmwl=lJj0L5F zX=R5id&h}3s&@@XNGPCQLVcg8d>R;*$hN3VfqL2E^IQz3JEnx^iyfQU4NHCXW7_EF zl6a$<*U?Xm0Xnh0Kml_KbWO-l)ubh%!jyOiD%3}#Qz*Qc$`)UqZQS+XG8_JmNaXkp zRV&&gxv%vjvWRR7l)z2AleX7JnAyDkzW4?RMdfeNBLv29j_T>9S#GfrUYT@vFUn2|u15l<9MwYUD$gH}9f?boJSKJCcBG7pN#3 z!}u47cEnV>&?+Wn_Toj{-P;SJB!z&D$xE#$_RG7P1 z3#9M%6NV}Kq*04BqoG2@zJpS>>kVbQ`Upj~cADvkt?^ZX-A)hUkbdTdgxy7bM{b2$ zG#pJS-zgx1ySD(H4pdXpqMxI3w~4ni-T$856}@bUkYV`~UC(cu6Sxltow)ew*udWQ z+Vq#_VfikVpd8AWTO;9OtOS_TZ*six7LKu@-QMskmwxeTsBP)5=x@|ZW>98b8}ojN z>}wWGz`%7}7i_TYN5&-i?Qfeo@qv0mz!!Fyu#3ES{KXj5$a)VI%A)A|(8WPiOFvPD z2K`wR0Y$A~3e`D>Q_uuc&;#{8(||%Gc@5u6^?pW$rc^VnOf)r(NdOHkmc?|v)8!L! zwfugGXc%+DZ)SiWZJWes_XbH;I<|S)&?!~*P?q`+O#G+A`*#;Wnbyp40SLq*O=%x( z1Rbax_=@~6_0B-5v;(FD*{D&ny?!tZbZ+4e75X@+xwx@he^6KH4agPzC@c6nIhm#r z36lw;S~wdZ9svMpaG8U*`R(8PDNr_@3tZ9(MW7rwP3~HQ^lwm&C46n*oHFY*Sp=!H zS_*8DP4(s?pX7zK6sHzYLn_Vk{rlscfFVdh6*M_P{5l$#jQ5Ze224Q=J-fhy|9-Jt zz^Z4tRA^`rbLH~$pkqdq8LBZ;gsYHsz2STWsMTm0sdVMy=6>``X06?SIW8q-X=4Xe z1F3?V)SDobSwJQwu~sKSAkJwcjpw(CowCKN;j%M(IQh+=u}KL`23qew#VQ$bt9Hh! z^q6)UbEyR9oIbB3h~|wpt+tgIVT^d&3GB{0n2k+-3sf->qW0ir`xgs|^||iMY$!_4 zU6KX@QU2LmWV_40y%A2nACn?dVs}aJyPZM*^(S7X_BUmX#6@P4pR(wkLILgbUp-Z1 zd&m3EjT=o)o#@h1v3$Zp6R2(06~Gi3_zp$#QLN1w+0OP@x_g@gchL(&IbZV)v3|GB zL);w39zT1YVOKEfFYg_848rNHIJB$!(A$55fT?c<(cMiI6&1i_<_=PJs`0#@rG1Bh zB+KtAw5+(1RZUNM)9FmSnpn&mki*5T;GR|Mn z6XH$UJd~jX50n|sYf{Ljx zZS8WWAbAWH{jCntd&>XdDz*!LT zAxuOzzn!tceK(lO)?gY%sT=q2ePHj;#K9p{?M^JNz6u6ce4D$LD=1N0=-sVt_v4R7 zv7>UGdg|b~$>uPJCpc_}Hb(CtUVpMk)#{)m2x8lTWG`U3KoNeLX6PWWeg60?SY+NqdwBbV#;Hy#&4N6`x#s24|6Fmc84w~O7-n>M^rU$o+ zm`b73ZUSU+tQM=n*A~jREhK1|E(J0hHGc5W^ z-X0iUSzHk^FUSJg{PKfe2lZeXVDKY3GdT{V^Awlpo@KmH94Cp}g2N19Kv+p+7xA8W{HoBQ|~z^xOxdv1QcpFRC!)8-ZcarCcif%lJ_Y56N| z%krNw;38gVD7o$dR4Xgmd@P~bzZL^Oa6>dU+r^so;6JRe>i8}(TI;$~P5?lh`L zwOZ1+X~wiX_ZrA603#{fq;whFHL{6U^Oe7IwyUq8wY@N=2x;0I)KhI5bzJF~&YLr+ z8Wn5C8@;zkan)9s=<+y6l5~=GFr95DA05Wc6N~~0I2?r+%;(0qDW;W~+CbaHXtK!G zFC59etAvu!$g;^?2!_^K--aJln7K54rD;o4lW5x{xT}H~I^IT`w#=@BFNhA9Xvja5 z=yULrUO{hB>pBuYUd@|%sS#PmL)V5w+YUc?s>G zkP;f__nE0)bE0iyzd6f*Q8~|3@PX|`y@K(|Sxf>n9$&(%OzOLXwjI4`ad1b0jbd%g z51j>s@hfFZ?*~HbSx+HjIc^sfK5Vti08x`iEiWO7;<6zAw}86%NoWEC7aNZuKMA0FJLLrE;*5$31N%cMF7Bl$eB=k$IJb3qgZj0Y<_aIs?? zdY<$r(KZJC=qDzXUQn+}Ae=^C%1vPp!0yr0qQtU{>ZUP8ya=a`CSJQ!X-q~~N5#W= zr7cmYEEa3<%v}Y8m`eG)p0;WVreZ&Z^5VEcofiOQIT{Pj3S{^08XBLVbDK}3grN`J zDjsLYtS7Ev@0_OB4}+udIbt^{H~^x(47OL{aU(LxMd{cPn#;KivY$t+%bsmamqO9|I@oX)vp}Bgl>s!o<+xkD57mjSe7gO{>RP@`%VF|V=>i^ z{jsbULi)wf(xnTyyHPUaQdql zK$!=ISX3EK3>^|4vtIUT6pm6ih)KBJ-F>ulMqU8uS+x*m+q4*&d6yd;@j_gdy99&& zcl25^r4G+8JS7ShryT7#L;~#iZ)-qWb`t^)-#A1%XwA#-#`ump*g(AX}_rQ zX$LC7_TuFm^E6-%V9tlW3*!GAgq+Svkv$?h7~2Va^6DQ+0x+`JZIpgY5Y)8i(8WN~ z1cBu;uLe{vCCVEw808r+80HDZEl2KUL^2oXSRM18->d795>R>e?+-M=Ibd`bj8hx1 z?~Dtqw~B9A*o9Z$%uTD%F7al02B>%|9H>> z_UpJ=Ct$&{Jzc!J!Wk%!cIF0_p}$zP{%2p|j-zSpyam>=IVBC(oul&RG%8Fa9V%)Q z)zd|AiE7SD+z_I;5BR^aLUBe$f#N#r(S1WIY+S#S-_*%kdLol}o|>qK zd3NAHW(G3l+8v-}0CvEZQfi-$nN=BvsF|AmUmiq(3_|Y))LzE28l0T#-?+Mft^xan#tbib3o7-t>ILWAkNqo*8M%%@9Q}yS&`iR+C~E zZ`%7icY> z$bc@5gN|!za0!Ps9jaV|nYZZ41!r@t#jKf5y(+?R9_?+oz5B*Yt8?H}hqD45dIzo< z^W!v~VviZ+0T3jxU3p3(9(LwfojS=toJT81W#E0xWZET_r3PDt*_S<~PfQ-;DLxGF zF5s}RE_RMIlEu+tMMiO4i5^TRYQr=;&TE)wi=(OCRbno+oW5p5!QT)_IUP$E6n4vDXswt&eem54+FDmDK|p~10b}gGG!lf6~F#3Re3sd#*t0q0?SW@@CKya z+D)p%DJawe`MuKyi5LFS3WOgW3Ts#_BdVKA}e$ z+y5uf!@dlV^C-4=ExT8=W1~~H$R|K+%l6@wa1SPmT5lI&m|bpjUHISoPf@l=LB%E> zJs3GzXn}pvq{wND%=6mvWZe5jf$&y*`QgWAMpoi+sM8*uefPBTSoq>Sap(l7_q-^gN z&DwqK`w2a1er=&!V((8ICLXFw9^tx3gAu6d_{c1->b^9>0~*_0R$FeVIXV`iO)8(V zBNm0J_$w++1Px1M$7OFickh@+)|TJRgU`*?n-NJgdeS$GoN{v_uUw9BlBLWPg5;7o zk1+ehE^Bn=%$X@rP8J7B+G4AH{{H!1hNb&;3N5iXLZC;N=0bs8OT+b+z@LM|>zk~T z{Yvre=hDlRG5jAh0=5Hmp>ctb8T7%-t4_o$jMvu-5a0NScq}uZ+igZQhhlZEtV`3| zMoiJ8sofm8HCVm}W>Fw21o9x`{h6XtT)!QrmirU4cqHgl>sp#=;P#uj6ibViAObd= zV)n!WVg`zs2f3ht?tl01PdOW@&|GSH z|D13e6kc4j5jq@gMJR&`JzDLNxW*v2rSR<8Gj%p}ua>+isLAKklHG`w*;{FH+25X3 z105w(D@_a-7a!@j>5_K1!WhkD>3Ab!I22St_A!B6O{-SCXYmWyLjIv>K2+IqgowT~ z!Mf*;-cG4ntS?)_<9hl}+0ShoVF58H1@|TXQ!0RpQF!48g8j6*+X4dOhH7~abp1hg z;N>qhRn;jy5%nc zJ_`|h|AGGB6QnQ)Bmf8fc3;-syrJUZ<&E6h+Hxsh#$!=WwFKUo8OE#WIkw5((fO@u z2n_es<(L!Lup`sm8jwM-nQ>8k+m%y)xU-xx*LfQjy3*gWx(`~cf+E>Oi#N?|9wwE; z1?g|~x&phLD@}?)G>>@v8)OA;BAi}n6S}(n4$ZpTWAj6^E|}qNbvW4dN8fxV4?wRl z52d$s#I09jb;v4J1=RF)Y=DZFC}nofZziBQvdzKKk_-;T;i;^Hm8${%@W}d)ivMrh z|C=9(E*W%ftb&%Zw)OemY|t9xLa86Q7D#(pP^{^UQ03HlDe`(R*Ha=$8ym{oN1jCO zf3Dqay~)LM|G(krbg;=zR3*aC+)|U^-ddK_m^SR99eUHlTs+NH)CPZBa1MUB2>&Xf zKUDnUptF_IEi1D*;ws{|@BJYpB=SbM%u~VYMG>iky)~}$Oqb$XM@vMfh}rQ^O%_%w zxM2SawAK#9;!}*R-yj3)f%%|V05_KZWdZtD3=eIh{|;ws_GGoa?$f8&@3}AD$!%;@ zv>8ByZl)s81NBrtJ2yA+FlWy<(Z8*8InhWLdbLXmnxnNS1dbFt#T-3{0uo#X9h99}i35oc*Ob`Umy{xc_gChx{Zh1_56J%BBTrt}ajgSwhZmkf zv#|L=^lZFML3B}nCz}(^Y17kXF;u59(SnjIb(N8(j3(XiY$Zkw154l*x!Imx&{&o{ zlo&*Ly`ix5zNcZy%TJYppMwYdZ-8EL(6)DrQU^*gZGYV0`7{Hh8pW%7 z%R+o-SnXktl)UOV%-maWk|#dbKs3r1YG zZmEIhX-b!m3q}7KF6Zax_Y_$um3&MYz`%rnxf96+lw=Di@^~@R9xvjxZtXdj9;|-M zy>BE@-o6mK!Yc)06xKP@Y}o>OoB02@R7k_bu~=#iip3fnG`M=t9Sx{2DUp}$S9(!M zUi_r0O?UyIt~JJ9Pts9(a@+aefVSQk(1I%2*MZrjVw0ZJcxLi(oe;f1kM0>3i@0*+ z-67gID)c87$yU$~%v(O{o>=NMUS~wO3tDHVKo6@#E)Z6W?B>XGFYEM~)lgm>2kkdN z4^Cy;1wVfKWzNV6Byzx|U~n8g?srg&|0hP$D(O{_`j?5aof}~TWfZ9H z@(6Qr&Ev4R=4e;W{fcEbxaYAH9UXn~!zVJId~b?A#U_GRE=Vm^Opf5}oX`~&cZZ5B z-m+;N;!VJNQ5!IuOwY{3Rxg1Prq7@fXdV;~o<_@Xad82nTjh)K>BCw2zo#p5i@i?= zkb1HA%0DzOw|@Wr=WUw+s%?5|>M=MJ_SPVMOSu5_k|qs4?>PHb-xMChZ&dmiI8a%g z2GdVhMRP`q`Sj3tuIT|@cG3Ef_y9ln`hHhKa`Yx;gR<4)TAwJhSr+yx*nN9{ZmFK{ zk~@c(_2>VdFR8(HxU8T(QBo^RYUYZ{FkP)ARX8XY~grxx5;ExJ} zAds|Q>karSI4a)88CPif)Wl@bV+geF3fU8nWUlaz#(>$#)U`vr*!-V2{;|0DQ>Ot5 zM!Bws3mh9jLQyY%e!dnN7iabI%E!>qsF{vLHG4FXt?}rl-*?JxKJpla$4$-5sDcXB zDd5dwC|&gz?I|oNxLD7bcO*)7o;~e79i#sO2*q9O=ukl5?*vL1XMp&@n+wiz$c=;r zAN@pu?U|Z?e-+$L^u--%eC+@D_Kg^@Hba!RZLIfdxyw)pK5#8C8dIS`FO4bXeHWmX zH7+CL`n`mszxhiy#$I0v3=9;ZyoUjJs%g-f9i8Y{rLwcRECh;PeV1|RO2I|gb4T}| zV>O2_jSTrn-MeQjShw|ucR91XQ`;57cPf;b54=<&8him?O0repe&fKiv)(a#{H%?6O(NHhVR zwcJGuJ=vjWS?_iwt%DMI0MruyeL7>MJ#A4PnPfaaT3s#!JbGkIjOEL#>oW6j@K%p3 z3PLG%o=~h#_*d&6vnu8(odk!Hui9fd7WkPqkR=psW=Eh)?Qp+FQsU!zTxZ%NW>XNq zP8e1#f_EoqVE&!;gG)fF6gcuuAHdo;p}H+aFhAoZk`letgGXVkJUA2APkW%+Jc{B? zKw}Fp<+T??Yr&&MUw1e)q0HfP6F}tEMp_}=GZ-1HP45r>OrFj#b- zS={#KTN;K!7emuWas7s8j#Pxv(>^)y&J{fEzcPww2BHN{rP!*IeH(NWqfAI34jC8R;SWJN`FW_~5}b;@ogEsj*`Jbn6fit>IH@Cpr8)6bwS?U$&C zNLFEMYpcjC=$Br@8K5o1R0_Nc!4jN07x3ncc%6p{1valY zfGz2dUpWlJLG~c){ce#((~IrZX_q0(xOg)IWPf~C?mXDi07GnJOcJn3c7=K!^1OO5Jwa3Mm14>EtMyIQh zO$!~b-f*rwwV&}<7Z1PGTo-Zp^>E>bxz$$MNU?4!bnDp(`;1yf>PPV%nAeDFo=+u` z8@#?=T0hA6(|>a?TEC<8>Xj?;ftofp1A(+PF#kdQKe5vbDNFX)Pv@hvzqtliY*cq{ zwtTFNTOC6~NVZn>V7#k|lCP9~+s-oyINo(+o%>I^($N|U#l*rF%>-#03Ut5(r@+>$ zd63QG#h2k>BJ)mFtY`xXCZ9f?l7Z1rH)_0F5O%(8ox2lfFMpy~BW>!QDTcYh$Lbwl zBn22Bx#s}6N18%3lF{O&r`z=UzEU@&zCjQDWmVvBAVR_=tb$q==-uI0yG{0$bCaQV zw|i|}U6D}&SJ9?pX@NmO{LY46XYs`N#J`b(>+4dpFNtXjbVwpg z{TO{=x%j+BD6>D?%`Hr)i&3qtr$g!G?7kao-nIVDxHBtE0&`E4E!PDdo9_vyAUYLH z{%X&0-)x*aZs-{j~r_tH_F9H}3*jIL?NxngfLobJz zUyJWNrjJ*JQnqBW%%H4}?Fnowki`MhjR>(V7c1m-&KTn`;j5h22-BNNHx%bFw9l^} z?q{3$;xlXA(#3e!Qq%uoW%GadxuT-&2r))piYM9DC8>bsPtwhN@w=v&sOVV1XRMB= zjdSN7P=!F8sw9ssM4&yQp`p<|ghLPudu340+?70GN9t*ReeP+;JM+}s-sy{%=kt`3(mKhObxBBDM?f5(0mqpM?uzl9f%Y9ZTy&ys9EfpkGO9D0m+FOf8-VrI$XJtEplk?DJ#9Z{fAoQ zPF+|@Ag>Z7VdN!_o(;$e91Q4hLpi-2K?+4~=(=<0oV_jYHTm|<+<8#{TaJ@QEma#x z)B0a-1KL=W;bd?(D5&|{SH_#fDIsb&N&k(sVFEp&pU<9U{Jef$&H^3Mym$SNt-DE8 zJArcK^C`wcp!6@%zna$$%LnmOulXimPeu1CAr4Ba4h|XZQ);1-n{8|v7vn3j2Vk9c z9Tl6 zVaf6|`H7G0eE{-^vl20uQGw%0c4Ow5u6_5!&8|8PzSp%Yo-iLDdC7SDxc1%h%$+D@ z*P+qiVP&M8zWk4`N|x}%3I`}Ce)8oCb{psZ`f}Pd0l!p(gm3M=wyGR$HGBk(XexXD z{NAV`(wzHt3@n|(#YaC5r}-vVh?V@vAiiuFRtrbRrBCcM_%2&8t-=7)6N>>5c`tjq(m!$aobHD#x7_q#2bCj_J zaNdt1VucMOAIW?_O7I1ZMupA>b-RV4+tnXxO`d7rzZ2G(ju2rva@}8NU5t4-IW4Z{ z_xAKOzlJ#^dF?sr+wfp7Grgw39v-9t5;;*FAlo0OQo#vpF5RV!OQ&a+lcO|?YF@9A z!%%rPX4c4CUs~3G{X!(Vla9|9t})VOyyays92=zTG|xa3edPNe5m!8voMCIUQ3wSC ztqRn4L0V)db$r0R1HMb;3pow=kP6;L)r?K-6C68PK+U03&uxBEvm_X#&-6@4b zD(Mwa^O;SK^f|!KjK0A1cQC$SN7@>lmR$R7Maih$j;E{c+I7yr<(*k=rp2LrBzZR0 ziy6Pi^_D3S0hpX7SFT-SFL;lZ$qc(z*$Kp{N4Rr2*^DPcS^oK4j8v+FqhmJ~WM)^V z8B%qaECsy$i_>V6(j}Jkn7Q2h^l4vb^aiU~StXDP4uv*XW0|Oskc1K^%7>3&C|GDG z7;hTw&$13F!v$iZCT{vNO9QKfA)7XkcU^ zBD{HX{F@VY%~{F-QkJilwh1vf{r5sCy}6tGV>^2(+&f*rH%DSU5Gkzoh_Zk22`-dq$*Obg=J~D ztaC#A1rGIh@7@jJz;-KAyC6)H7OJ1i!4_2S&hwgb!K1qauPBkEfW)UOiXwn++S?I? ze@a&538OmkUBa5yoMrhZ>l(==6WqUrQM<2?>PzrMd>p4Vsd zGiV}8N8I|sk{?6Y-oty(0UfQ%HIZ`Nw^c=N{S)d*gx)al#$8)){AYr7Le*Ew^ZRKD z=KHV8%WuJ1?5G>BVKKN9mSn~%@ZIC?8*_f(+a9*f^vm*nc~X_m*_1tHe9P$N88MgV z*7b@_KkK(&%wel?)(-|%Rvy&NJB>S=P82ghCrb;xVQIb=Oqj~gHp?k2@8kTsdlX-`jr5$tgQg4_)c!Zp(2;dd|aWMYZu`;J3T&_)(?$oP(z}21x`w{Cl{9zhrNgU1As57y5koP@ah~ zLy2Le$wb2*VbPyr{L+ffdF03^HT;LJuGS{_upRLB_Kq<_{U?k(Y^zkp+%FP*KrhE; z^WMFCyHilx=6`O2O3PWQ(J*%t5U*6|SR3~v4gQi@o29+RT-SxK3 zS+}Tsa&oQV7AH?0FhqnitFEi9rI&4r8b8w&r2kSTrIgJ=LXLjgDoTj~@{=z|N^74C zq^GCX!qyD-j*cH;mXj*cIk&?5cd;c+`ty)_e5})v1;B(D^A(P@g+s4|dOTqkXufYZ z|I)J9rr2G{yO{h|&^(Ovfi6KL1F1nXw>A5u&CxSFlH~*FW+Qj$tZC zj@*7$R#pLXyFW?w?L9rIjQ9s-@(Y?51pFVtBP{bEEfIK`q;3kX=w3E{Vc-7!VTyt` z{c?ip9JyU|I3NAwi~dWGCne5D_p*pw8Zd#K><1WcMDb@yBW}h!c;*rf=bJz;8C#Wd zj^4+>Ggqw2M@k%X2c@jC2VQZHg7wMlQMs! z8i=M=XcgMNd9YU;ZR;d}7LGRR_duvkUZY_EWC0Fu05(b5;LCA)%>d4df+PfcAbVSc@@D5fZ#@v9mo$ZxR)l-EexoZ20i@X6)^^)sLs?02~8Hfwu~eO`J- zb?&imLtO~uLK)Crl3_v4D>HZ-yJ~(lR1&_noVT)~LSkXt+;SxE%1WSCVsA_Ua>xAJ zw^!!(gj%B^Rb>(Dnvcas%z3nFz&_kTV~ht*pgw6`IFP_+UEJiXWv%qVnJ=?5 zhhUb?F*Zl_l@@FYW3ei1p=bupo#V2!1d!3=Iaq6Ps`OL+`(Lum=`o9s0azfw#Y&@i#7<*mz<(}X zy0klG7BH3&VXF+_C4T$fz4Dv;==ad~0Khfl67So0?)*tQ36Q^HaRfdQat0Nn0%W?I zAmFIdnro#QcYQlwDYf&`+RD!t0_4U*w%=I3?kIvT9pEOv;KI6&3$mtrSH2>&N$q-r zs$>(=&f~eT-OvJ$wQ(`G(@h!1#^T$}l=R!5`|_+!kuw=O;D87_ZakSQCTrb^^xZm} zo0zX|7L8rl3L6P+$9y=ciNhm^WU&opjBTdKy(|sh@y3H+aL09_hiacX<*U^q4~sU3 z`#NAX@HLFKE^NCHpJx091ZWIu*umAO``0HQ#v0Y&WMG|j<&(SLtp+JWEEhg3z)slO zi^MYH`155GO(ioM2?&Y=?IoN|?OcZ$_aT})%s@|#dmoS@;;QWZ1f!@deae*jp00|^2UW*wtC)688Z7>{s8{JdlEJMTtHl8r7D`fG{Cvxc8oXe+(j zmHhaZsRZxzn5+rp2e|wdG%ox^a0CsGZh=ek)EIJs30V4)lNGI42X}-qvLN3B%xNH3 z`0-6MPMVuvt>_kSvef8~N3ASw_Ud=tY^y*xDIM8&{SQ-3>$z(?th5A=PIjC5B3Wnd)4Q-XV!<7S^zC2|G7`LO;5}7SbfMnTK;0fd5gCjzF5wukNH#5xFCLORJepEs4U&aUQ;m4x^ zKA(EzK=)<=f&C;$f;BQdKgLP?q*^1f46XuP`%j~Fq{HtZ1;;ygVRy}AwcM?`|DKK}!BiZIj5}riuU@YUkOBeaGrQvLj z!7|T)QP|HTfUPr)nAAHtZ;tif5J7bO_ z*?tE*2TilB!O8EI7>a7=n|T_3v7NgVdPYi&-2pcew)0OM)wSTA37`tjJwjvzXgs^&7^70yYXHX9!aOcl~97I+TG*(>uVL&gbNRV2IB);lb`WhS2 zy4s%F7b_T?9h5#ehZekqB*OVA>$VWxMeUx$YD8R2G$gR}Y0VA3dpCsU6uaASA2XHq z?^~@7b_K#zfg<3e@$6)9Wt2jDxYK152yXJ_goG+C>a1~y-!Yi?^gZLNQ9!Qq==9?Z zxjo+IaflchofjN9mknKyV{L`IA$$@VWgRRvGTMcpUOCo6)717Ju zB9)4a$jo9cmUdA5Lz)j=5M*@4z{AMuXJ+>cEj_m436h17VW?PMp~QJ*$ay_3ZqvOT zu6D_}b2sic9+N8a9cVTeI8vFVe+HqCOWk~>+=fmP@!qk%IYuE`@UUobg;w{GB`WUj zRGFHWFKg@h81d`0-w;QYx5XguR)JbeHEHecu6Dih1TWQdk@QAunBfE)xR5|+$2 zx2rc;-HO~8|8)CJk$8>6hi`?S{cuGbOf~yS#;&IzqvbieB}S5-H*csVIP zKzq8#^!QM-kdUZoN;B>Mkj+}*eV;C=Y`{bu=x%O78g@mK95aLSht-!gGCpQ~1UAPi z)h2&qdj9zw@%8;v7EMjf0XqhwNBJ!LLEhox0SWfb8|zCCl&SE8sVodwb)b0|fPaOuK*R~IHAmJ^pG<9aGtZQ#$1ft_zGvF#|xc(jN>0PNF>TvdTR1UV$WYb$Y|cq z8rRyi*9OQc9}kep3{Z0Uh&0_`@p1~38Tah;E8&YS6*V=%h`L6{QVcd@;Kv;#h3if# z{mxj58WIo)b8v9bSKu{HF;YAc{paqss-Myk-raeaE8`msEzW&F2bVS+B@Y_suO=y$ zR*Rp!OxWy|oSfRUe@OUML@Ff{*ZuI}!x+~uZDg*|OC;o*aqeZ;`ah0zS0O4G*TaVl zbGPZSU3(D4xZdA>C**y!hYI9Nhe+IW!^og+Tzn9?*@irB*_P<%%Q6b{G0o^_&B(R> z6^9M=C@Um(Y-it*&4X-_{(Q*zkFSXyj~K`VWM0q#77}A||4rlSMc0 z!C$4F>tad*m-!-`?T;;`zq~bfu(pN<-|R$<4{0;mviChO7K4JYIc9lsjfqhV2KKt#axG&K01j@%&>C!Y=r%x+R`!+x=Ov+WT=`Z0yypCz{au!(8(qDjD8^K6D`L>1e-*JA(ZPqdVyngK* zyHEM0WdCmm>wilX{w1CM3)KC;>Krus_x}Ixm(9|-vIWA6K%)IyC1zx!uAgJGwW+{B zv>n_$yg{IO3xPRY-{(- zY*l0YKyk&U4|C`HFO}rK%Vd4ZqGPs*F5~Xg$cxp^dZ^xPnAuy9yi1_EbWOJ2rzS--QiIsJqmgu`*QqmfO0;*1}kzI2&6 zO*F4#zT+IW(W=Ng2g3fs>HPl-{eSLp{J&{fBK`UQ-+M^xJ?X23m_gVPg1Z9>dPLy0 zGavSl(N(Q#|H8dQ8BKs=?BNXx3dUqCpk;e;(ZMijHG&QkYOhsou87~TSO=rWZo%uv z2qo7BSV9Mp){F;xuR-O&a|?h$NaRmVr$1W#2yaYU-|~rCTNSL$RL)wvyj||&gCfA% z1EKA!e{pOsSv$^@1o{8~r^6#On3(68>;)jTtrz)m&V6Ub4!U`OEzNso!otxTALm}w zO7#XAZ)!VNj(A>+qgW%L^%NqpG_uc4Y0o=zLZ^j+>r&c}Z#@4g>4M z-Q3*fC5wSeqm7aYkI!DbP=Se3ObXH-sa{Beei8NZ{UD7qz(LpP`|Me$)47s^!^|j4 z!|N0(iaW@KRUm)aAS){w@d-qZNp-f-A02x-&+_tZDO`0c*S5cT*ulyp;&saD+;s*4 zWxaoFk6>?`eH+lUeke!P(8EsM*8X0wi{cALpYw6!w}(6iuWG%l{)*?gbbJjt#S-m*s;DbU^y+}0T_rGE)4Z5 z|Di~m>T?I4PnF7$Pj0~{ch`@}-Z$*DyTBeP(XpVAGyK9YU;ZlVz*RT{iC*C&)H|7> zM5>B%R7ZoNTgCtuz9J|y0ck;psy=$?g4Gz8$RIn}5AS`SOd&!@qI#D>#}H{&AXzd& zYb8N(kU0=PHCZdl<18v8`3u5ggBs70zGMNMJOAsMCf1ckgb%^1g!+qpvVtvpenS^2`EKg31rH z73r(XbpI{`)$7J}>vjRHm6I8+l&CT*0=K$d>23wo<*iHVZHXEhM%nqZ(52jgB{I>U zb^{{J%_eo`-?H%2R;w8z&7maw^kw>E_vJOiEU8Dq3v#)Cx}O1~@|I@YeT_Rc(nxAu zw&55#%U7Dvi*b*1@xK`;mD&`;9mtQxA17e4;Y$KOKvF4f_^Aal40ZwayGVJDy0ruL zn?Mco2oPZQ2yV3EhGo6IA>iH0(IiI>6{0SLhDujNVJ$w8on>88ZQP+wq5gAwb51Af- zcUvkFLPGu^(qAEp5-cF>ABDwF^WAQE~e0m&m3kYNme))!BO$5a6hNejR=@QIR%(^MB03KkwyUE@^ z2KJ@${_rgNOr%ojb<^@U zMQ8B1)W36e^$`^cDfBV4&yYs+I7iu}(ln~EA+6;{oClh-YyITOtSv`*kfRPw77zzs z5r{nHy*fhPOO}4M5>Y1viCo&OmFm3CS^!ca9;GFRtHBG|RFhV<)Li{0bkszaxLBQO zAcYs;kj=Q)EOZ>+YE0%u7?I!dc4NUft}wYDP6pyQM)Z}frAJHSPuR1$O4r@04YDH8}ZzX%<$=9fEB1O5Kb zo4r^98JwcwC~h+Wlut`v&3njYonV=4(GAs+N2MEr4?SRLa1c?GhR<)f0J;@4L^kyM z@Kq6?B*0KeaL!xx>W%D*en8(N?gh?Xpgf*71K{*cV1P^TW9Vk#*{MSiW70BdRuQB{ zod(Aw-FxE2^>lhrE2a}vljK*QJ%3&frIW3wF+lo3z2E2rFbJ9o2%aAuijtSkS&2hK zoN-}$aa;zs-cbDHiCLEpCYcmcM9Fqb> zt5b;GMC2*TMC1lIxku?FSj8u`F8KHF82|p?Z2B<6aF{|{Dx3oP;N5w16q4z)cQ zWc>od+$`rlauY>tsO^Ktcq|JG{gE=1XEzxM;r|1H@r}!7WM(QgyVOTwrRN!ME8-fQ zdg$Ii<>CW*M3p10KK_o#IBevK->yhvhmcCLk`V7zW~>s>LAa9f&`oBe6-UUZ;BGQ8 zRdI=P4Jo9_fZ8c3`*#^J`R}85UGN=*=tvMkAE(7ez)WhhEHxcvWJBBEKIug8-DcXk z`EmGq&_Q{vRl6>}9{+*RC;)`P0q`?n6&1N*aY2c5Ubt`}f2BLT7eNW^{)oK~*$)xq zE@kJ3sRwGeC!V(&N@=M5@1jdfc7e!Vt$B^mO{j^n7t9y-nr4H9Yuv1Ub|B@8$29Mo z^b`A0fh(30>}B0n$WZ8qC=T(Qg3U|LVYFt*FoKAdP~nilek}T>sq1y1Ggjlf`t^7L z6QzI6WG@rNN(<+j8XuY-){{mET>lc9vF_8E&vYndJq(Q~(Z?(Y9*~h;1~V94CjeAx zxw5G#H>_$;-8b|ZZ|Xc8Y~XOk>C@|7$X*Yoe+5#l1mQ?maSbHm>mgzCU)t_;@4|A~ z14M0%aHN&vimGe~$9l^}7n0&hh5O5m>5xw3gu+z$L8yYPc*KwZE|8%{5|uK%-8~!E z)b*M-u-4gn5`imJM3p&gIcc5k!126?=?C&#jmKdR>4u=DG$GhiU^04{#=-ncz%G6cg(9P_^(3)o7g zRl0Evl*dVRJMoV1^-%4bNwux(`m0!bG(_rjhvM*wU1>$}KWuZ6(FM1#Iqf(UzWvS4 z$n5FSJSy0or9mm0#yjlG0*%u5{#Ow7l=*l?^=(yZiEa6Wbx=Sc>INe*O6g)8aoHxI|3jDOp-te_3=b5r z6H~;rHS069idDAm7%>`I(=w7FHo2Wz2?hdVu6<^wqJ%bXd~RTe0r5hRmwC0^T&HA! zrS0eira1+;v!fwuECZ-T2F-}(FEzkcE?3{as3B>;7^XE-c`Ic5q`!3owmZESGe?3O zq;byY&jRPnrjfvrACb*UNa*||TWE9R-MnXbG9Mt?G+`8dzjr07tPoq1>EwZ_8lM@> zL3yH>NW_U{?x9!lslBnU**Z0O=#2Af!yEv*gJq)ze)9XCHw{5G?uz#3{M^8{rsk%4 zH2xSO-xtZ9mW#R;`NE%@CMYPFP#+1pun8S!ujK5#M3TKaK>@gw^l;?bWBp~jelcqL z{;jJbTPruUw|G0A8Pp;Lf~`$fP28^UJ7uw}TBjb2PU<{{0^QAvJK0Mkc-)h9XD5&Oqy+H$~8Ft^g`@1)cG zn9q7LiBGj;gvPm^HG!0iLFE+{j;-KZ2Au&ybIO+_^?Qw~cB8w1Cb((SmRt}<@dkf< zL$j+kbr!=$KF#b#92lpfwZl9`3^QwCEbxqIzZ?s^WopOHwp2HAs<3Kftn}dW<4L`T zIlPkQF)hlADg#B?v)mQDBuy3s_j}_f@-yGNqD$T{d?Imu_{DH`eQ)AG1ErKS-i$k@ z8{GjR@?Oeg%%+LtMz*jSNP(g)%tX;1Ri`u-QeLPA{9W9h9t(OL`P+_j1jZR7zbk@J% z{yoX4NH)%8;VZ|rr`7@^7yx^9D?d3Ysd5xO<@tc7P3)S6-@kwFo7)M_R4v5XmX}r2 zDZ&bZ<&>w(cU0~iPna*vdUeFh=kJA_A>(n`?PR9$pjj$WML2zOA(ed-2PbD(FY&x^ zH928xY@d38$5HY-8CH8xF~zpQMK84z?fc!b=-f4gKZAJ7Uf%tOpcp!X9@O{3j?hwQ z>-k_SRN2A-Cfxe@RgOWLGcU3NV*{uzTwkY}M$ZefGiLRnYnsA%1Q05_ms?i@vDZ$U zFXvqdYBdGj=jEicGq1hQ3g>N)jP=c4+IDNXON)%C@3FE5y$436JVCy{zaL%9|9945 z-genoA}HX>9UaZ_UrX}49UDAwxLs(2OyLVXQZG%r>%BQ`_d=Sg#dfA7$jI3Hk3KTa zOEN*D?qxR_laFlE z{2p4wD`_r`cg9K+L2w;JnypmJ>!Rq!zn2naPts#7f!d!eX=5^rH-1qNFaOTYcx5$ z(K{C@Fuk?ZDwRj<{Nc4_Y%xXIPI8kNwp+nRhMhIH>l zRrJ|}wwc#+A(~F?Az?=T)Y`*}d-(>MKgq*c^ms9XG_l0b`;M!&jwBuPQ(iY8~srRty3z#A? zuu~%X!KdtapV=9s)Ta6ELC}9s7@DJq)LLaXW)A#^f58zn9iA~Jv&ut@FuJ^5+A!Nn zE5_Ce%gbvj^hs~qsW&zvNj%FiA#qnwNe+e+K0eYSXW8juoYj#{^g0)tp_WM~RJ)L+ zC~)94 zjmxYIjRMNrPQ{rGD4gTzB7s))M@nZ&>Uh$ko>j(Q^eKv`$9yst!Gl}!=<9o(jmi4- zZXN@8ewcdL4CHs*;1FJzalJ&Wd1g0j#JYneHhsaV=n^bQ%{4z>C#HC=(mTeuvM^rx zY)q20)+pDAs_XM!lf}k0+9lgLIXJXdOHVe(p0imTQzV%n(WR%AG`$WWOn5?p!uRF? zrdBL4xI(nX%C(I+53|@5zA;NOl8Q7{-V$K>X8b`e&-TcyZ8tcM=-$6my&yhQHvuKT zghzZYOKgK1_k0;qEze}qC$JiABP)Q$GQOLhWT3ELYC|>7|y`7ToD+ zxHCXT)>HiExGi7Nf_W4z;(>fIyzyRbB+Df8I9#_rxa!szk4}$jpxx-Vf(hEKhsNHJ zN9x9p-&`(o=E=>KiE1;Kl={ZT$GF0utvPbEl`QK7ckKykQS4Nr=9pje2m2j*xqZ^i zk8*E{o>+J&vdHMXTcx7zXh7ET23)xalKN9IuT`1YcUZEC+qM(E#iatvyx1arsrR*Q zeijkV$KGDzxO4&1rt!aumy=qT%-#)jzp!v9^c0(jPs{UWpZRhxbicft94TJtOC|Fd zRyeFNvb-E4NvF)(&4{nU7`3v$A+dsWb9x1iyY69apX8W_a!ZmXRXD6=EV(&$EECsH zlCFx_(bOvr2+mbuv9Ytq=SIj`TwCu$aI5XH;}l; z7}|~+eG|U?(9*E7@G2b0+?)$i`?bFy9JvISWN7w}M&wp*C z>~bhaBWx;SG!$U+``#^w6Q#oy+*#_n(zi{1cjrny8$Px_;IGEC+I}ua3Z;Rb{4sOy zi*vr{^xdz~{#y(Z^IE8T$FF;}5or6v0;tVzA6j|H+Q zS@%@QZ@wIGJfklsZ2dGorygr@j@{3_?^8hI_xJ%G>0_Of&CNAg)e%~EUfx;tx@k3U za%bkb40F+XT?wQ!`h6|~>*4}*H5)$%>YaGC){7;%%fc_g$Wdg-ExM3>c#NAnyKs_n|_)196`Dzh@U5CIGOLuFBD#y7ilkNFvctUprXrwyMDco>jkiwP zYAU&nUvIqP&LwSnW$^a4I#a5|RbAth5d(`&^^sQ1QyY%TIZEcGn{$S3F+G28Rh!_m zLe#8|1()<+Wu|4bM<4|?m)^_D1PbbCW|f>@xaHS}I5@QpHyTo%HoF=Ryu05d#Ci;L zmN$Eb;K1gym@r;Jvg8?Y!J*Er`uKs@vZ|50t}Nf%AgiF)8;7>RY@>ti%lM@X zmSrs|R-m$RV`eB34~3MBO1&`>mmN`yQbmQ>4b>OhSug5z_S=Hu5~oG-HAVr=*6O?! z{Tp@IsdkC}p@WN$-FIQtk-XkWG4e}nHYLifrsfkEyYA20n7%vrLR)MZUyQFEKdH4un!I+AbO1deam zbEc$d$JiCCD<3F@dzPI)a_ZD3TMZ$-FsXSgH)fbu?9OU@^6ZkL+3^@wRt}M++)IlT zEiKY#&u@6W)^eaF%r1%gJw#i%%2pBI8wjKfiB2+Wn*=Z_sY*<3ZeAwAPKQps# zs;w#2*iY{ps%Lnk@AijLfb(xw~6mQnmI+P#wM-m$D zHnQHftsUkazWcVd#HU2kOT8wXUnp*~&s1ye;*D~T9Msu(c5YhDUtWJND$0IfV1QLP z@Zc(yR|#l(uM34aN=ucBIit z{OxV-kR!>Gu{=34&KZ*#u|YvWuZnJdj=Meq{5?jY&TNV$qUKq7zJR8Pu9%GZ3u;Ej z5tklomF<6~vi&(6c%*rBbhHr#*MzJ!pRUa2f%mPg)3!T&VtbD?nr8f!G1UQtuH4`i zwaQ)1#Yj9e!^r;j$?1(IYAaP6)m;8Mn142-Q1sOnrjGu7E1$3V z0Qs-ajUV$OhO!rI4?UvOf3w5b{YuQv5eu&jim$>27I1BLk4D6;++Rs~CE+J`C}?MB zcxApgF)`8X@eA|)#dmLWb8%&%jX^I${qfy8H2(IxvT;!I_C0KJJB`6eV*dXAdRQ4k zyziU`k(0+L=&)C+u%#y_C)cVjrF?f>iULU^xwj@N*FPXYKT6(9P_6ioy1L4p(UHPX zI3*T&PwT+}Yi52ZT|2HJB}ca{i3=r{LZN7}Bewz9VKtwwB{-^vG=b16I0}ev>>69k_sXnO2^P$BT@na(h>t0sDRQT4bnA)bQ?%F%z&iS z&<#Tk?0ez6-|zSBWA8uqfBV=RhtD%Qi?!~)&hxy^YXxbjDO@9_BZk3X*OU}xwO}y9 z5*UnN{h!O=UmD#vT)_WC+~f@0w4JQoJk4FKU~qG{7tfvCp4&dXCF&3A88)sq((u>X4D!BvRB2zB58~Ne&s@@YIPzEBcO{*Ow1k z(QLW7i`udpneYDn=ighI>^nus1Ga@oC3Wj{%hRR`>+ZR6G0**R0fF|_q_1JAKmNS< z2ebnGJkdT)x&HSfjN>X1_ur2rbpOBnBwh1a+^g*1@UUHu#&m8;1J*p7YU|79ePxYl zjeU(zeS9ieuh`7t=`acGB{R7JYWCO!WzE$v;K6tC z5W<|~2uNTWypDBi4_z<7Hsk*}UK%OQIN54tO1XX!ru^yd+rN`gA_$rQi@Wq*zsQI+ z4&hfbl&7umSVksGw?J=uF(XXb$jGR4eWRnysx#AbW0IVV?DlZJZj{H`?{?Q-3HJE- z_`u02L##?p6s4?v0F^GuYSolqRH9?8Qi70^W46|_s}5t8F;|#;HOMI_vdSBe*TrZb zKL7l{cHn1HLB4K*a)$h;OocGU?St8{&PL?13Hr_;HcPM2;P%-Orm3q2rIi(2ZaXNS z@^U7KptmBWm{leAGuCC&3#;VjR!(^3ipoT-`+Qj^Z0LeP&t9C~C^-HAepBP*C< zzDJK9#qOlW#ISDE?}XY8E@{*@fQDNWcT9hKGv9!l<*~Frw)#fjfo{zRy6(B!7Y=Vf6-=d6zCW8u8md2 zy=U~!?Za43;$)STgGE9c752nifBtN9$C$Em!iv>Bnm$rOdc-lyOy0*;*%G(*qm^lixVGXJXs5c!>NHNHfNO_4S9n~_E~J?V!(e7bni=v5c`rWRxc{XMY18%De1EvJ zCs~YVjL~b3Y;ZC|fwUE@Tv5hfH4|R_vzISl zP8g#xdzo%Akg0TOa_W#?@bvZK4&SP{`0?u3>;C>{EyM=PnFqD@&aO z`$BoWmu>}YS;HCEWL$lBya2n9$0shQsY=_yU(Pc>i4<&+V87S2qE#yFhP!H<8`ZWO z!-##DJ~By7j-yYD48LYG-@4WOTng`AL?ht`RtI-+*2suKE0h1pAAWf(65)4f)jDdE zn(Xtj9&2bZj5`cv5Tl1DJa9g&b(*ZNnH?#$nBT7D?fM=_(v93LC1sK{nf8lfSATeH zTQ4FaQnMB&d6YX}y65$*Y0hB~MU8_A3G`!= z+-$yhX%aE!HipDaU>66nLTZX|!y^ymu{C|Cy~j7adv@`WRYg9y_0Z_(Xy331Tq6^5 zHzKI_&@jDtz8=@x*7m}8f7B)$k+jiXU!3~w&6T*tasPuq??b)!mggHu3-pTY{Ca#w zzuKrb?)>rnG>kGG8&II@t#w~9k&NAGJQ7n(4cRdfO)cJu#5JrepI&mP8r(>!Pd)Qm zsu9PZEHn%i8CNy$q!lOIqiWcuHZto=-Is^j=QsFa9Cy#p@(!_AS^niKOk+o_C+RZp zyB^|SA8-C3#ZKa&^QJ$pLS6FkXQY9%-&UWLui&c;|1Pm6ZXHmuHyR0V^(>mRNf7m z8xl;&<9+w%6Sab#$NOtz-Fx7i4o{@)-@%IWCFm$BD&A9K#1FXC&UeNoV#53`4DsQQ zLhB6J&nd7OCVh#zeHaG@2N}5ZQEqOl zBlm?a>nCjxA*UWVj*ldH!VQ+~jhrxf+O5x-9y|*dL5pCMW=E=>Gir+2ZZ7nurXGtk z`EGu5@iL{6Pp#XjUs|cV)tw+HaJ-296qO=iLm(aX+S%E;W-Kx?QuXOftg&PJTEX_r zs*25bY~`3^=b8#~O5*fjwgxe<0pb+F#>TRM!(_czO>sJyNl`FBHovvCwHcsb;x#4G zt8Y%kjZ9vWo4VPjN|LKAPC@MDSxpdhnI!gZ5L1w_y_5OCvHH(tSAPcQX}ipDwm;jU zyke%4xQJg`Mn_h93X+S?CZFh?8`<4{4yXE`#yi!q#V0#2IzPyB7KLxucqd2iyPHzN zYnPotC|z|qes}Gri$%|I(u2`#2OgvH7uDL6jrHrbD<ksHG3WST7F8j~*uz+6jbc5b|6%T#q)UE9@}~er5{{ zWN~q9nu2N*PCE@gUG~?QgkFP5GZqjHA8p8eLg* zGt!q^vDP6hdIZiu%VlC)B!@o1#fw&L;kRqtJ%UwMx;ctAoIwoMYSHf#p%t`?&ePA3 zJlXs~&Y*1{C$d7sAp9b&8PBHr`PPLC7Xl@#zOVb&f);gvakO2wvPEXSHu1;ru}Wf4 zJ;vt6{Q&254!z?f!ndY0nhkXeO+B|`NLE|%x1A{Z@+{S^J8xisPQ<8Hz6jCO*o-e< zQ2lD%yc6nW|NNgj!|8@J|)n_*DTOnEDuXXOW_a*n6pkaoFady;v2+i81) zDA>83YX^UjN|{XiDB1_xHV9+wb2y~VPF&Y_{HI@CF+pqHy#MrVo}eye(S6VP7=2}1 z+X?QX-fXPyEN9z<=ahq=5aqsT-P!4}sU!_nlJ&4bGi6sm&EA4)&upO9^r&WKxncFB zezeRgBri;#79fgNa8B`nlTomW9i9+f{44Y8^)Ki@13TE@Q^vZ3nUKztcv4s}AvLV| zyP?|{70>k|Wi9SQa3;6mWg0*P;>Gpz{S!zf${XU;c=*f2X`*(R62-f?Jz^0Sk1yA7 zcD5z6;88_g3g)YxCeW5OXd!_=XoTeHV42-zVNV@FJ55Rt=E!!r_`LDf_0xdHYH)^P#^cZs+hPVt*Wov7Tjgcqm6yO2MYK=!; zgM15@8Yaub>#1G&7Lm&x>QZ=1l)L6zUens@afRci63i3l$sb<5Mb!3~r?{ijK3Sbf zOgyjZbSTEqiyNhJgXgH2h#I-Jyio$~PclohN1q(HW!m3si?8QZk{UboRCiwVQKfY4XC&v(SI`h~)#JMz?@!eFO}moHzw8cXtTUU~Lr#>G!$fG-Vo z`etK}-a%(FNr}d^zc@`7zs=d6%Yg4~3=IXLFu?GVio&f$ISR$27M-&LOa!GDg|Ziw zyINAlKoYjk7DT^+0)4*DxckVf_~Lrk2XU_MHQ(h6DPx~3p^Fj^wKADWq4Uqxd4G^K z&g5z2)t^#$=r zutM1Gqk2s#yuFrIjCu$A(q+2;xnX)uYZzd`LYHjjnO(_*fAjy5+w*+1G5A^-VI#`f zGm6Rb8lS1}dBBEjT3`^&EErz033J`e`jt`K8tj)GkcJx(XIDUt9+MKkzw`=?q83bo4XBB`@mv#s4(9IOmfB(NL{ zurpqYBsXe}TS`w#F(%^*R+G*m(0tWrh~v^emC3Bp5Sh3J%$d%L8(DI`JKE4DzOnC2 zc*>=dXQ64XCD}#GM6_EjO8?VMq1ZqYnuNP7M=h#N220UR)z#JGNRpdbT7A3U>IXA+ zJNs4m8d$(C5JJT6IlBiPr%3p8G{3sYgHTS!8YvJ`VomDo2|w+S6#5;yxjPHmrKd&Q zXwe<2873o1i0;R`c_*kfsH&?ADbcUVG?=({3tA&QQ*wg+OvQ4@mdoX^i=XNTuM}6$ z5J$ZfPG&RTtvEP6-bmzI04!37s1YpkA$si`+j-=K&zrKwU%@+~gL(eH)f0T^>w3;?H!Hb zJZ*N*v+vm|u?k;)8e&v?=QF}2?Xq)|%}9#GkNcC0SH3UmC}GQmYzCBbVtGt!hvvaV zxdmsj73`{%ggzd!%nv0W>c8)$U>#HS_fDI;7xK6zjr8$|Km1#N>0#?Oq@3 zVpPpMPP?vXh(t6K!1%C*O=W=h-yxwFBN?d#@%MZ7ST!g4YLZ8;pt4E-5*I|aH*fTi z(H>p#dW7T4PRS86%H_Cg;F5QduBzm|^LGV&>^cI&|W45FA%@mX)j8F8!s))#%+MO7r1lgg9R zq)svj0OmMhlmPA8GQK$EPIMrfK$6ngYcrc8v2@|VG4Yo8A)e=RI+`1-4I2Qf>CRa! zVI3ZYWuy-Fltc1S1R zkh=KR+h|?qRO3l(mSAa83(CDi%{)Uzx%NgtUAEiOv&&f-H%zFK(RqG7Gs{+gRu%M- zxs3iN&Vz9&8jR2a!-k>#26qaW#gmvzQ4RY>aY1_n_0*k-`|r85vsLd8R=P#!QrGf^ zy7q{$mE-fSvQkpjOJh;6BP)2a_lB-4_l9vVm0bH5dd#(;} zesp2imv(m!^Kw%7tBk809U>1}%yCl^IfJr>t8HatpK~XOtHq`_YY< zlJ_oZ9p?K|OJJdN8dcajcqYBF`>7rg?q(FqOM{YgPe#t=qr#|1hROcTNsR)Em4ajA zq4my=uD+?2;RV&GSJ!Snd{6~cwPdj)8He%e{=!UM#sZ}6HA;z7Amni)3Lh+G5|sgo zFFtk8S9B@v<(hf}8k=>g(!HtPy=+%}Nz&-X0l<2cb^Td0d!4CjaYMReZiF(En)IUZ zyz;<^o#nxtuo`s#^WRfFd>%dC5=Mgx3feCp8zL}r{kY7X&aFXpsbpi4PD7>@H_-%H z{qIA3BVH84N33R1LA7+)rDC}(!N8<~zImP-2(5%${Xc&tdrroSO{hhMBn-Vk-O5SL z3#2;jRhjL#%~0;mQQPS--ZYjfcBh2f_Bfb{Ug{EgEKk94bmZ>0Zu|X_Lfw+0QgVW= zz9>VV;apSNUB4b&q@SKG!7mYVL#e9rFgH6}=Sh{{y+<_#8%o&R2YA!`CjVW}IL>yd zE2}4UShpiaH??aROv+bl15LLq)nu1NU(!YV_OFbMey1ip`eXF-p_q`hkqO%b~$z zzNl)_1(KO2rC3;gYSwu;C}?Pe&sTr7>Ghz*00E90=gv4t8)pV6C_Pxs?p{supXfx) z0Ei!#9B_>kN&%Xu2vA>lB-V3M{StiEcgd-#t$Drc?1uA0W4QDe90w)29GMof@Zq=` zT_9VTY*(6sI2j5QLSLL|{mW8jadB}^DWsaW+3v|)!x@FTxaK+S=V}AOnrm2bm5Rlm z?}4#91-&g^eijxMuCsku?F3wesP}GehE->54IsC)NVph+1R!pnUBM!(bd@dlrMruQ z8bdXlYb&)Z5MlPZDnWC}IYk?rr5wWAo_j6GAOXxPKho zI)t;8mX=;G<*S(^SFp3QOUHT6qw3Z>B;M^*_u%|{QzTraDER7D%Lt}Y?oGFP*d-{_&153+H zk={$DDgLKkoyb8TWe82a%;;>A0t+b)Xf0I7B7k{+Y$ga$mX71Y;?h~X@r zhPyzciq~WoU6#u+Nb*{UXAIIjb1gbEM-Q*K8GSgl(NmyUqNHQU(jIZyRwfZ@+V0@8 zKKc|I-jB^Wdxa*u5eQFq8_+Zwt8|F+*=WG1Dh=ILJ!0}^$VwB0?>6lk$+U|6E)YK2 zTd~oTgHDwDdr}9YGw@>>1^Ci47!Pkz!6Pc!Gy9^m`xs3^(P0_B+NRLeJ~CaD{Pjg; z2O}nhvy9@ic}$+Pk(Axo7BOSo2$Rui{kVdh=vEM4MZa)5^`E?!N8Dv_&&wFv zZOCJGaU)C$Z5)bVov8B^$}sUeTFQAG0*{Mnq;VTksdAj?@_8gxy!%yADhdMf+KP>t zZo9eEh8RhV>&l7h5Si6c{4~9iPAn!Kt#|?uV0Sqj|FN$Zj^xH0BK!lBJ;nNZ85!`I z#Y)JA#YN*}qdXcLIA^j)t?!}Bj@RH9Ig*{P`w@+L?na5IFZx4vpOlYv{AreQdGHc> z`1E2VUVM0}=n|ZH4-M8zzvjD3MI-q%MZEwR4r~>-NOu9Cwa;=-FxcbWFr8pD# zFVP$=f_XT_ZUSYWDUsWcb7F1H9Zl;W zEpbShZh|b*hx5!73?|mETV7rnfl|z^a4m2Ah&FLy`?_mW)Uc4JOQ0#T*x}R@BV557 ziQXR|j1`Cy*s$hPDm` zXQ#NPZNT;8XGV&`s~Mg9>eAormNE1H>rW)R zZZHmikk@em*$mr}lJp#z3+FeJoZ0;IW1!SR!LZCysfH<$%pfM5t(adxpvBnA#wL+u z^?hS0-_fte#hb@poK{2r=U+Md%JX%;NyZ$9aPSiXaRO{rrFR(Czu!bbUfx@jGU?s_99l5WVM8D3Ykd4(cm4j%5-5B zkE`Uj`L3bZ(;um>zhM76DP_h}BRq_CSZ2j}XhL5cwk&UB_B(#B-MzG)fvBD|jbj-j z=eJy1yOyb3;_3Ts+vD#<{2lwr=BiW)s4sDDI@0Ed!tDY4FFwWG&Cf50|4!Lxk<%)5 zn9!~B1!vqxcS{|a!tut2rG4IsDMpfSBO*si{lVIc;)|_iUD2_TpKERhHM(5#;HAztLFqEfd34nFh|T+Y)a4Hhb92Hhg4%o{kqLjoA7C9CdW7AR_s=jn8#}{H z?51doyC2u5)<#VSsy#I1Bv?PRp?<{geu9HV1loSrL3|Ly-k#amXv`D|SUU_R{!8C3 zV6j#yCsM{MQlX|jQ(KAo>qNXp#{|2ROM7j%mod({f`_>iZ!fr}+Vy~Dg!I2XL_b%L zP(rrYoG-k9*68fZZN@(`I+Iif=F}!56wq%Y1>&Rqe@2eRW--SRkL;hdXADv(|Go~B zHcV3NBtytUCk>2IGlA~bV3Shh$OmbntDBwhf3u9Ue;HD!^pL6MuxHZGMS(OPoA}VN z{H-{4C_N3^{D4C<@i<=#U!{*|Pi527QH)7RO;wD!C6z}$$EW)m&$4^wl(_QbY}~=0 z-MZ~;KP&Fw0NIOTYcSR?3LkOq2%d_X-$AIy9{BCTTQZzh!H(4a{nESqABz99$?-17 zDJK2up{-}aNr@QTZ#*^aq5rB?C?-QGKMFxosF z!k_v3gr^c-ISNX(E)BjfifYf4Qi*YcRf2ibgbS(K{3r$HnJeI|BU?ufx61S^P?W#PuoRm z?|K?fS|l9P@Wsy&X$lfp$zuPBuWUTf7I)85Y<#OP{=Y3IP1{ub@^HcYOb8#ZOOpZ< zQisv+;LlSA*Op+a?d?v!PTZ6~pOTW2R)e>@2$U0ENOX$5aPjionA8Rn%gu++!vVId z6JLLQiNtbq`rCpBNYq+PJT=}S2hw%b2Y4-ReB^J|i$wVd1Y6Uf+Fgz>^rijmQ6C{NJ`& z+5lV0moHx|e|{k2tpew2ClDsP*VZTMXjWb(RM#G_*TJ=vDKxLqJqQOfY)3~77yRkU zt1FD*?5VzG&Bwj*0y)hfyg=)`j=f-?zCDu3Gbh0|K=iw^6H`&MWenG>4<=R#$vkoN zxlxhAN5#qMiJo6l&F%Ji9h&5UFBt^8Au()3|Cd%Z_on*?9ef5*R%xmc2`9J1txw2Z zpOAE03?CI@XK!UG@ZMdFsB>8RUE#8n6{jpO|Lz3j*av`fVcfxcJRksOJ5gKlv$Zu* zk}{AWXyV*aPd42XB;E%{=bAj~Rx zd)JzHj9SIc#nf(oCxIVFvZ~Ah{kw#vBx%=q9bo7~u!`ovpQ`7HQvQvW*47;$K*FIN z-b~S+k5@`4RE}c8CDG|-WfWXb;V>@CS9=+dgyGN;eUR_6Tx*{DQ+=(2-qtF~0sK?UjmT*`IZHB+Y``xy6$(uLvd>$y%?hu z#TNf_bcu-D0pxfSOx%Zc`Tb4~o=O1Mk+k7Yrq1$@`A4>%`SikOm6p1e6|kr@(JIZ{ zBusU1jcHT1py52RpftGD`M`u=1$aIlDiwlsUt%7zr5^)R^OMuln@rz#3r=Hedhp5vzlXt6dw4nm*uzmE!=BWhuU&9qxdL6v8S>%Y z*_pbYZadsS5XGRAZ>ejpR9N*+p>xj9ofCfTUl>fI(Kr7?9knK=Uqd-cy;jRy$rs#? z|5;z!_?O~*Kt)_Y4yps>;p6yT&2!JB022*3EEcxhc4I?Fw0Mjwqd`_MarkP529yp^ zBJ;FyE*}JD6d7P<;s)Lu8YhszEgk=Pdo}RC;d*<&Q6w^w`7ER}@@%a)5;4O+kFDko zQsEr43;1)}Z{XoWjo&SomB&6yi?Babq#NGm!&t`f?ZU0(|BHprqpZ5E+X}jS?h(g~ znLJ3HMXjzrUmDC&4~o(U;!p(0CMlnt;;pvl+OrMn)Gqjf{C~F`NrOLd1^{vJYz5L3+jw)Q0R;;%%}6^G<4_Gbmz1k zuGR}UIIa^i1lUr8UNWYcvwQbZh4-GFKu11Uf@LrMJl?`d>H7Rx{n{HV7ed|cg-vEx ziQ|m{r$&3z>0U8GScdi`cj7-_6LCNe9|x|Y*Zc`R5r&hEF0ZOu5%K>E^?9r$TI7x9 z*y*kvwhxpEYo)pSdJ#fs`7>F}uE?qdJrFbZ<=I~T89kM^8GVorR4n<$zCY|80| z$U&D=DZ>~$a@~ZI-+uRGIP*6AX6Efh>W_%202z zSe4G;m6)$)Xi}+@X_sT*$Lgw}R^tnnY*b?&dYb#=-8C*}U@mMvRm;Y<_4>H8N@ExE zbq;5xM0t!CWp?wwfOB(50cr4G7Iz&kaYz7O>z8-)%N@}*nQ@L zqzo{e!))&?sQ}I~ebuN_k6i3aKeRKL&kABSrM)L9IXQqL;)a{pUcdqO#sH;*(?Dj> z-Jk39F?(}S>K*YmDTd#A!D$n%XD1&zE+O^E+Ceq4Yqj3^zaY}mGVUKQ+ZPf<*Ie+p z$41tnCr*f3T1~4+U7rEV&QrH`-XE_6UY*{tX?-^rnm>>^Yds1IK?BcScNR-Um9myi0n0Ip1Q}<-Q%UtZ>0me-jHWW`MzTjN(VHi@dy3po9DL>7P4Z;9z+|0c;sC>8$cK=~_2`kGNIUF79Ex zPx(JwxX<9G8ltMV!A!>l|M(MLrN(CuX z@z4J<+$uZ4MOlgVwDFQ@6^r__fs=0Jl7BqZ$v?BHp`F#ESLWKyCg>h+BKO*sE2-W? zb6V2Spui6=Di|KqGNDHJpXArNcy1VxPxt0>f!lO^IKQUCYlv(R^pZs=+>PX3o-Z>+8a`Au-uc-Z+9KLHx-^9R7PJ@et@vo4Kp> z@9RrWN>t5U6#2jIIBg8)=hq9}FCiO9dkn%2*>X4;^!l>dft!R7wm(sbB$r-b8CW1yj5Y#Ml#t~3)zPY2&>OW zcZBJD52rUy#oG~AIH|X_Rq>P=1=LH3D<4%AuScAZ8(;a;ooC{L+JW@8Ax%!@v4SLUsY&a!wOxGG0d>Ylxz6UfNaP@D7r*~rg@Mz z^S2xL^KNK~Ma1&pxD~^frs!R3kgI`Y+v9?JnRXwV#w@^&k)A(amC+sV81C}B9QhrJ z-4~du^d7!Am%NfOzy0;c-&Mj%1I?m#hxT3 z05whWpc9lHP>!Jw+-UD3n@-Bv!I;?HKHc7)_U8Yha&2oTLqiN)*g<+e5t&I~+0`87 zj-PEkQtIrITFvjI)wNv6y@_eA}=qqKWcjCuCRVnQs(T-nk z11m)(Kc_2AfV5m% z!5+lc&4NR1@@uxZU>Zt?Xy4Q4Ik;+5;(X_{B&@Mp4SmFyVHNcn9qyC8+MfLIY-yr4 zLE$MBvYhTf%lG4M7yV%``}?`97=@n^O=@*l#f3~JZ&348zD$J#yF-CqYJo$lw z5Cv_<(@gWCTYvWr2Xw!KT#54&NpMVi+kxczZWQ|wCZf)JnB3q0eAhI8g~nsrg8usZ zuH)0{amkGUdX?_>D*Womzg}nWZit9I96BFhSPnG60i+@>{Kk&S6nrH9;t@`Xu~oIl zkw)7Gy6!A2ZBu_QM1?~QR46=28a-Ld_J7X%t;{Io?zgoQttvQToJr!SOs^afB+1Nj=$H9=6Wg_`(||v2PF*T4#^p zr4@2a0=W^{$B!Sg@bhbldaiT#N}c&OBKc|;zl^tf0p?61Ztd$^Pk8zA!g@j21(>wg z0nj4ceC={N=XAU8`5$g(8^nunzMBI<7xuhpAhbC+I5-k0&DGpiTh^V~37 zwcs_bR7KS+&?&*;G1nPHAr!F^2viSENndu~wW z5e+=+qcyHJr85^`X}A2DI>I9a_B-D(exfu5vu@r+*o!0f`edP|c@w&~B0*IKfze)EPs_z=Jr7ppM*v93)Clm&zLfy`ibQRnL~sPJ#JGI+@V`up8_A6`y^-lTOvuBR{MdgE6#>#DH z%aE<13GSbC$K zqbAtSm&`u`5@OmvmQQwih4>!8RjK4DbX&ui;((n>ho7I{5~Pb+6FUBZNn2OSq#)e2 zP`>Q#R@?cw_-|zkJx1Hb=%HGG9;N61Xfu~63m`R?a=EY-DC(EBptz$Q@IFgm><(na z{*e4=F4F?yA}ot+c_t zAz))Ckx)w(b?;g%hNwQIKZt{=pH6h^CoOUnG&g^pX)U_#urb1-Gs(oj)xCV#hXrB? zXKk6eq*D)W1`P3#5QL$!qTNl(NHY?emg*)Fde&BjoYdAJoRP?OK6#(aTWI_TbvqC60M4P9!rn+0LReAE zf6S@7;~h{3Ga=3Caq1OV+P3kTe(=YnMuZ1LPgfNWkV)&s?#Q3I>-B4+0gwpj$oxxd zt$+irW-mvXi-SW^68Me1fO^m}p#Z6P}$^`X-v8nipZdaQRpn zHh*u$l(X#;;cbj<+=aCt^F@C&$qkDJ&X%ELV|&YWv0hk*)8Tjdeav~1FOzw!9=X)l z5qvI%)g4;{&P{t?Ld|b7F~!OFWo}cwi-SZBO8Z(I*Pk!D|M!b=goo){lQSsg3x{CR z>7Hi%E`UkB9cO2msP6o>F3tM`ERj@~A&fkB9_=q2hgx@B`#NJajE*|2ymaY<`0wJ- zaaUUhqD`A2C>}}!fEhp>{{NVoGyKXU=NNyJPwB46;W|e296iPCUz3p_7XrWiL;=D@ z913}~A}+WS!uX2&N`NakwnxSot9LM~Tvt-NFwDNOsH>7fr=jrp4YApM8t_}2a_7I* zIyXFDycSb2rcFHFs=Lkk@Xk4KFe}Q}dmaET3y6d8l(b0yImb7l_4RZ2LMlcN71C*# zgE4@?CPPJlcoY2w%BGcni~MJk#>-hlB2piGB~1o(U*meVzpHn5!51s+2UP^3EE9VA zbjaVYlttb9Y~gn|AmVXA4k4)MH-MHnHb9hRTIKIW-(~O<-csNnn&US00iKhd57UD!!ZkQ9oLvwwjpd9)}K(qr}0I`SZu$|xa4eb<5d)-PTx5KauzI#_% zzZ|KG`{(c^^fEc!&th|MVVdygD$v)v_!2H%M=^)xCk7>{h4CYb%kQpq?CvR*xu`_4 z+&*Oy^wl;lxgE&GO$kty>fwN8z8+YH&qB2jq~4vF^4-%9OdR zD5)4%S?kV?+H-UOi4Uejcs{btk*Vr!0-e{5e}Azj?07FU9G3=@3Tpv(hUr+J_cnWt zXo=tDm!FDpT2tG5p%!JGYaPl^W}7Myx_wJ{QY`busnDodTanm(8gOc(0MVm9pHPzX zxgrGgQV97kpYeW<@$dH@g}hZ}CW98|3#IWjbPKtcR>Ne5n?IaYjK_{{Z|}SJ13oqZ z9hhlfAf%o4uYF}|G*DBX*%oNFAgDhU@tJ=+p@Z5u{;H||0*pg?ykC`eTP?}bxYMk? zu>bx*p<_wTcc_uR*CF;|($?nEHtjhxHy!j`Lrkk?~uEgZiO`lH_ z3_-n!y5bFr0q1M5DN(@xWY!~rGZ~CcN#O-?D-x7=r~qnUYYa*cPe4M49F#Mn`e3ko zp($OlkDNB5%Dc3iqLL=*s9D!;zVqhh1+8kv)Ho2Lfftq4Cdpaeaa*}NMZ);>WJkRQ z6i>{9O4Mkv^=cX5I-7?Ih4c+zup1&G*K7Q|lTMO9+uAnOD8Oq_u5Vjspdp<8MFkJmlE@0gi4x&XTxDz+1*a-)bwT26f^T*aWwvMu?!ohLAXIgUESM#xd zN3mMuRaOP+&Yjak(n>X2lP150a)&WJI#4GzHi0tN?@j5_BA$zq-hrzao9dGmi~_vO7;!&xdchQO!?@*;2}P{jP>J*kk~I39mSWDZhQ z5Wzqm?K(rVz_hopoB>hY*h+N&x9uMHepMUj7<|swmrUvbN^25S9)S!SFGz@_-EkO+ zxC7*ZlJda!N1flD_p*#~$4;xD(MtP#z{LQY)&_EFvO)U zU=Nj_6d9C0PVrv83+2*4Qjxkk70uY*QRY_zo?5tmyqH#a#x*>EF3{EWTFn<_3<3Hi zEZ`Hl0aOZll~LTl#l;0;i*$l^EC8^(Vt#<2J^mi3ar*iUrR9Q!R2;63A0uG?f2ZAJ zp^^{{iOhQ-S8EBXrDKP$T!7^&Hnn86!|9(t>xBND8L~hv4{B;QmJ<$PUr*>xm z_&{ar{NB6IJSA9QFc)q?*n^?)7daoT~aWvpFPC;NUJUac=V-;fQ%gW{SNbb28KYpG21L#<= zmlRdQ&BP=mQNSOn9LH;#3HBdb4aLoyD`lVS*qrr>jC2?7hA_*2d{M(ACT48io8kbu zSsQd(o0hT@StKMHMxb20!n>=qGcJiB9WQ-267v}fBQy#rW%{*!(@8fivVe-A{tkjn z%{@eg)z!iRT2Lj<%nRWzj>6aSPh(m+Sm2S}Ng{*umEigDnGvD?)GdAqQTAQMP5ShH z+#Fy*1MaFlcM8?hLx>G>xej0G?C6L*+`+1VLdNbx4kfLtuy6!Oja*HjV^>hKHvXNu zh*0pZ$Y1`ueWFvw7BoOjv>B)$V1rDJ07%OE;rH|b%g*WS1f_ST>7~L7;=of~__Mw2 z?OVclP;|QsED6nIx;cZb&TRQ=Rlo!&@^UlnDqys-&Hw@q`iDa*%Sm~wk(0CZ@r(Eu zh7NyYUb6})Aw-sdss-!aDD*eG!R${D?1pliRpdmxwlkH|@*oEN6W6rUBgQHys5=R& zCZA5_fD&M^i`QN9(<*Jr;ZY{AfHE&n)5xyz7hMA$)(ua*oMayeLf{X^2NtxRnp&va zV&5YI0)n4WlBWk2Q9lQ4k)jU8ED**O2wsK^6M&3U9s3!r;+G4GVoO9@F&ZR{z zxc3Ao@Nrp)w}CRp`ZKNWYGgxC*VRI3_+q1@DYHolq-hz%Q&`{RtVgNZk8*)le=H9r z+R_0B6AV^g^x+s(ecc7`zK8+YLj7MKX_-_0WnyyD6B^Qd(o%$&2Nqn?b};bsVE4sN zPfwFec>t>~tElL2mJ-O_GHz+4sQBS3y|9L|OpbZ&m!QX{^O_;Qd@?Lr3kUt5K$+4* zmnlmtG0V*^i?WSpv03;9N8PzE^0Xi%?9Mlqiv9H+{4f9Bxj`c5LD|=@T+bJh$H)z! znt+dVg34es*+dE(5lTFK2qLv9DM8)o(MqSpJ20>DQBbjNP;fBkJ{~mcir3z-K3p}+ zutH;LXDD+tgs+h!@u34h*DX7K_=?3asI$;%C4_wtub9d7-SN!MDpHBM<+Z7$M-Kr) zPLe9t68TViTjIrKVoiGtD#m>UT{Es;wc?*2Q8T|eM=t$P88*oNR89&Aue@jf0{ z71$xMini4KNT(??lf@Me;^;zXml3cSy=oo!z6D+}5D8MZeX8t3nIphGZ?=pjgf%|5 z_pSYyLhEEpLi}&MRAuAZIgE#O{BrRBqn;veD8BTDRPr9CmhOpxfkDuQKgis>I?GWQ z+M^5aqMbnnF!-7SymGw!o~xd%lv&3M5{wg`WTy50_;E3rxr3$k69U;Y*M}ewT%m z#nM4S{@=4&x@9YfFcv*FKt)*b40!Ym!a+T$DHM96*x>mk1`lp~fbzeuoO-}{%(?0e zODjt#JNe*yGDxIie0=%#_E8^4n3^sWcr~2I%~&i!4K#QWM`{}=!oAC`mh?izPN^wi zkr+8|T5qZ#MOD6>K9D&84k#AMNh9q`uqUJlyt5Xgfp6b{opSSlcEhi_Z^NU$B}bir!9VDk>5IAIeg*5!Xvyj^{?sFg?_@9ue{E$E;>M>Bo1VN7W$$AnRQ}5;KTC-Z# znF)$xA$bqH^B@GAn{B3`wa?d4heTzz`UzQU!Ii_715I{5+-#r^u$UL;+70}vGEh22 z;P=FiQ_r#yPzm5$G}tZED8OJJ3e6_6Kxb)Ik>SKGAQsF2pTPTo0Bk*gCS0QQIk~yJ zOqe9Rhsshze;om5*8~7L&A)(4kh7{=;)yK@$^QA_C~FY#7tv}cQ5XO>L7D&A>3C|g zHldKy7A{c0ie7ZL_2Cp9nkrN4n( z=r!7x`5Jb$v`c(vegV&3Rz zdolE;feADQoL&S4AbLc8{>%a7AVH}rdyEuh2}2cL^IC5(vu*l0vt445Ojs}6aqQ) zGnpY&Wefq$M8oYJA}{Yza}{HqJs2d!6A`C9gVH_IA>&5RIYBMUwks%Pp>@v!OsyDMPLDaei1V}#oPKZi9;g`2 zi?N3m>;W5?EwDqZ@n1pqf1qtTo;nIGE*ctY8<(-`Cn7IM0)D(h81}1u= z@MYib9Yt6py7VOzCwE|m#*>Q&W0$YoO3u1O>#K7%yGnhpLr)Gn`~oI4Qe?Lq=^!2* z6IKO5;7Pc3VMqrU78?33JAe%P@_GFwbdggMbwyE?VTkV2`D&~T{Q;b&T3RRf{PRz3 zmJwM==}vfh!) z2BL?y%}yxyOtIoguZ^{(hlNJ=_PK4@2729vR;+MdWzCcTAaa z5$Cpx;OPPQHHZzcrYN1boC$WOI%qaIkom1=aq254A({up%@8A%PM$oOZ}M(;Ye%K0 z+iyt0VipqWFnC6S)pw0nvk(U5X}D}$-tB}+Uz?ojC2^Tz@l3e_yzQX>J1p)|pb*O^ z4P}~17sv`74omt};0!CYQJR%ScR`v0V3LL&W6a>%x!!OYo}i^J2ex+#^v*-}Ur8RjmtwWN4MOa&=jvC}xC3w|wYQPEkT5-u8vJTz@Ba`T}VuAFb zJXOnY4NTxeUXg?H)x$H1DS1e{JE^oR``3*t?OXjPx2?RfH|Uid-;f@Pd9UvOW_dOK zims9Of`;umH?EQ6hYpQqpWcBDzyD@m`?rJiAH7E~h;EzP+#@Q2p|W9TXLlUH`!g=d zU%nhtP#{34&BRL;H#+aaBEpf@M7W>6xS%kID#|S7>k)KF7R+%@0)=x-5wynya5Zy4 zweP4EgZc5*Hn`S?xD7I`YePv`B~dt>?(}x6Kc61y_^ygV!G2A2DkFJjSFXuzq~vuq zu*ik)&1c!h25Z1#YTZMR#t_4%?Kfn|%)TbEM->5_S$|E7mlh0znABKlrHBU<2SX27 z6YZDYkzgEO{wRJNiqw$7+3d9Z$ur0}NQi$$R0Rau1LDb^nR; zx1r09yjOTj>@cWzKx74dHKm-}1Y+zugCLOi1M^>P0j0R|%V2h`u(= zRXA~&%g4uO`zJ;^_G^!gjq%y_iRqNPW}zFGWYElW4rIcP*Kc5cjG)xen0k_AY>{!~ zJ1gbK_ULg9lC3JFzR8BzmX1Mi&mTB*%_-zemU$$wFN8OPI(#?ACF*MPeSE6VFkaIA zcxP)Tt9k>!Lr%j|3)OHk9JnAuzG>0?{O+BAfbxlNJFw&kP?9`r{chUd4B*FdU*S-k zQ^wY2UiA)s7qziRgsj;h11aM3XB9Vb9QO3l@s;V5I+a{CEgdhUV~{*yYirl*)qHO- zVPIhYh7`zH{6i=(9o$WR!3-zY~mZB&jo@K z2A7&16&C<8&~QS}Xk626;s^u$B5_C32mrdDB~#n?6hsGU5Nvo-zwE>wyWW;?K6Wv#$U6U~Z0 z4h-*FB%V9%EggIN&sH<+R?6ky4kYC)q_G)2f{;IQC-(bO(UE2blN8v%akZ!uq0p(A zSXlKlTr@m!#7rUyZ5+rXz+EwOzSw6LY>0*WUb8jQQ%jImSdTJfen@~)c@FLu0qm|E z5R{blzNSVN8p8tHue$fB=sAje9Utx@B`sCxp6sja=}iQ~JTR~Chi6@Mlj%y_T9{5? zH{WXcK8XO$LP0_Mp804}*tQ~#iyK8y#f~+>!SHNI2|QwaSKt2=-2>gB+2YTx5m#^p zJQPEfB?BU(Wbp|=+p}~*Cw}rb@UseYun?r@pze}`M#leR4F7c{e~``MumM@mVAJ}~ zumQ0uQyFYbpI-f-ZE@i6&&>m4xkRq~kvarNnT<5Wau);L*1+@_kWGd|2F_PPKc?#z zrYk%-dj)yV%D)*>Q7zSP^!G;H?NdIefKGU_U zqb{6v7o-IWj) z9&oIJC+JUb zYB)?qwJAp~wB1)HTyCOFH7OVc1IMyE=J>-|RFbIvPSDau~ws z@}^SNNR7N^rTHl`fW&8s)r=aujRH5FO*O$aeUslVxSH>UT}8QF)E}?&hKAm?Er5|r z(S7aK2Hr@+*tzQQ|M}IJuUvVNBNm2-2*)tkbn6kines{>c16HvfAmKFDNuZvLAE=} z?grfYNCCjNyxh6dloTWSmG$iI{Qwq9%uvRBS%WW=Q1^bRJh!o7bp9tCZ-Ut(u zkHWDpeOyPOSA`O;@bITu-5C3Ip|WG_cCgso3Ajaw3L}we#vLmiW0KNLv>Do?!+kl8YX2SL1wqY zU|951xwwFOOxwV=GDgG;z1z=58?}F+*>sSVmk$T|Rj{N00goz2Ps z0-Y;Z?|?q9fi17%xYx`%{>m#4LoIBwkJ^^J8D6{o>4{FZ!%iu8?Q(Hx7M}<>))Ai< z1o+4WFH2tV34^t;9*$Xnrz}1{Jr}x~Am*;h$Dbu%9xV-yC!B6UZ7=s^psxsMPSX89 zrQJtDH{&xPr8cWAUuEh+JO_vf&0TJbZ}J=w_j}eC42x|Iegh~OSg334aKNA@S-K_o znDEu{Y9OJCK6|@poclWc>te)u=A^WN5_I6)t%lV;YgcR1)o=#%WSHEkQ-qVZ`yc$b z4lmb+MBYkytlm>3GM52>R3TYzgE4AMMTmxurS=1TT2v9CTRn0^f16IkcaUeovE6^r zV!FS&x?-V2532edgoBk}k%{kC1xzIpf}F~s&wmv$;^nE(r|oUI@>(stmf`onJ#F%t z)XnB+`a8hL=*$8B2zJd=#a~_H**mg1ji4EpXFPSvtRaRs#jyQ}u+vM>eoRqNVjKyZ zy<!LM1dci~sq=fWa*Jma1r`YYOe=vjpg!@hLm_na9xz)_e3mdHQWKk5 zIid|nV!rF7u2VuMbR9#WFp7NrNkks)eyEfvKTm??ygZc?7+#^u+Tu909!#xyb93*5 z0MI6}`A4W4aYWvyB5+8dk)iok`f3MK6*z)kp!8;YE6L=gn~9aHe zGt~sPujrv6(^8;laI0o!X0m_+AksDGc?Lzjums&x-_`82&{dx)|faW=K zfa*U;N=njqAC8P#gE(LgK+e#Ls;IxqwSKrx_kg&ZI}&1GGoZzS<;KLsbaAS5^f=^y zIt|6!Rq?*aDRUkRD{q4MyavlVhxG0JiEqo7?*tlS&Mxm;Bp1PS`|4E)dXcAZNdx0i zq%;T!S;5wq4mELHw-ybHLhjFb*yrOIrvT4(BVJu|TQL?YnYtE7 zk)NV*?C(-*WItKF$RSyyOcXks;(5o*M`kDa-sgS@9mA$aiuP-ll^H_~{T2O+g{i49 z(52s*c!hgf)`$E=%smGFPlbn+*|&}(+K_TO<#85!B`~$hcCKnow7tdEtlX0CTTA!% z>1kF_kJrZprFNs#0UDq#L1MsA+{jr9(avV@rtoFh5`V`B)ma(Q7ZwO2QRJy>3dC~` z^YZVxWDJ0gqHV?OB?QMdEI@U{4u}itRialXgs>Siy}{ODz}~oQo}Kf_H`e!l@DJq{ z7O7%EqYeEZous6s!`WkVaPQ**=#tEeCB>>Zgt=sPD}H5VZOzi?F*1OV@0^pae7%WmbC+cL~1TL_(yJmC>WqZ9q+m8BRXV39g3* zo6BbM=$%^5&P(o_knqd{#wpD2xlJ!H+uRDd1;_*OCvPEH?(~T+bSb=O`tMyJJEBO)w}5e~LO)D$|xBcnDk?5xWeKq}T5~(`y{2MlCku=RDQhRiK<8qJe@uVNd3-%jmH! zS4>e(nnI80AksM`uKdYO=3-$~8R`9(bH{pSAF5R_*kBZ6`D&PQoL2^bpW#M1cazsX zJPL(W`AbC*w&3>FC0f=n0d{5d5Q#Q>`yO^hJ@^M%DUU@bbEFwnv%Oj3La15ZgXvF<@7n9?r?L9qc?3!dO@GR z+5BQjO2*RC(se*PIQj+!1qpS6(75<#>gvms6a-_>+}Al8X4<{NB(Fz!W$Zq>a)a=e zs7%1lu;e191|m4GPc<2oIrB>QBzaB=J7o!3e_;UNEE2`!f z7I{Tgu4qqOnQ{xCGV@eI3RQ4a2XAVMCh=Gji@=w`PG|&p$Boe@Y zycD@OA+m`@8DiCdVeUm|N6(KNt<7Ml0&i&4q8?BBe?(areyX?Vk|zkAhaeXJb-`F| z2H=S2p&T26_z`o`w|-bk6ZYzfp|;OOBG@wl!qmhQH#zcNG3WRE~$}zb)#E5H#JFEk-TwDO-&t?yk^j?jf{fMWehyZ;8^ab`G!f|?M{kd zf0t4ttF%e>lbv~g`L^!4#68ISgjL+qqtTAbEt@RrF$K9#DJQkaSpKY*p?h-0Vz(7j zK3Lbl7<%4ja-(Ut6@4&{!+kosk z3X-Y`kOaUexxW0uYjv{NTpfY%aT(u z7uc4rLC9YNx1nRFfmphO)R z#>VaI5DqE@sD>0xp;7-Grj-s9JD>N8^XsOTWO>9Gsow{sn&tENrg^86^#Ln}82E%PU|iea`-YdL!=%cB`#ilbtT`;dMxZuYRx}Zv zjH)NRcR@(`l+$Q}8+vfqw5Br^!-#Ni0{u}&yFCy2GO`epfLZImUwcc*6QO5YDh;=u z^)?x`P&9;%4QHP(F@8f53o!t_kZbAKEPbdBV)k%Lc95S_exB4%wX1+rW6BUf;Edzr zJd__gqH~Rl*Q(KLVjT29+@KK{mrnfn6({U85dGF!dZbkBtSmNKJeJ4OJzZK5^~=ua>(=35 z_6@LV2*mihpi&y_22bXp(tgw_0UvFj7v%oy$CX$Z+wo_qdLt4(L)Q@8$_!eg;c2~N`h;>zOA#;B9?`_C& zZmmNtkm9CqO8_Ahco5=yAe@S)V-|YsWLUs+z!}Se&OaGCQHbTEgZf9y6!w682uV(Z zLfwC14INyu1hg#lKQR*YA}8c_wmgn5uvp2zM&9 z{yD5dFq~nh_cb`kq_>Kc&08$D|(rl8ogXfGV=%=MvZN6DsHpv{3AUQl*ZF6xCQNE z>KwLubn@W>;+~5U(B^$=kpNm)cp~$4L;4-GyQYN!A}Z& z48;Ke)&IiSuzhETigrb@-1LF%oc~Cj1=w8pV2lM|N6}U}l^t!5bn{Ig5NIg~g#Q=V zboJ{fC^14{$OZq`T%iA>Y=9ziL;|>%C?0ugZXIeN{-)-3^>a3@OmQrw*v(+iL&qZ& z%Uf_ZJG%o2%YCHE%m`b>zKHoFrd$@H*BaSaQjxcX$qtR&2-5_iM;PLzpx)%b1#Kc! z0--6bi-ChMxq-KnUc}kH^lR68^~PG$@FIssdQ4gXYBOS4exPXHFo+Ft35UHY>U$U9 zU#0!@;l&-tpeoBa%G7%4;_GhkHUp#NL?WMBSM%Y6ZZ}fKr|A`E>wAnPk6h=vb`MNjIH zZ9E+0y0)C(vtNV+oshIm<17Sp=rHIj&8oqXB<#^9lK-sY$F*$TLOGBfQRwggOiRz6 z{PwMWCkUhq(@P~BzF!2R%!*NSb2E$o0-A}y(&|j{9jr#l0>GaP<{QYkk2x!F9hrU% zjn~N7J{8PpIv!-%bJN9POw6a0+g1X$6V2&Jx7^0QTOv}dGSSQ=?9{}S6Aa-#b39fXbC%R9NQr7WKY6z~?G~KS z&t>}rLD0a|TF>Wg51sejpoYLlj}F?6OwMYVSA99dlOu*pT8lE-Bxi)5hu+y;I_bMZ zdakBwmhNu`#D4 zV5`-Kdaw91c0zTpAP6G*N7W~i2|$jWV1Z`KroFxKZ5ecbbr4^1BZX$%xelbc6&Ld` z`|1VXSH`UZtIkjQ8^2nWxr;@6Tq0|p|G1xloK^siDX1!28Slu=0Y5C?P{P@| zPrp+Y4;}H^xTkFap3C%f^dR^J4lX|1sjL47Y)5XwUzJkn#v|TBmPw@NWL&A!Xq4s3 zQ z>2#m?ag=>SWTLI@DtafWn)wH%JaIR>VFnI(vFJp^XA$&u>K%MBQ;W_qA6-9j06%R@ zhw19-S4Igxf#qzx&quGL@#On_9KLnUL*wSfpS8uT`d<4Bn%VAt1s z@H&}_I}Yr?VoNnZ1l^AF=Ue^oFZs;U{3c&B@Q`EeA6&xs;v3D3PUSex^|69#Bnh{L z%u)UW@>VVuFfC%L>iL6r82916}!r1b8ZLYiJ&Z=&~`7zX=1Thq&imp zz#{Y)Scnc<^~3%O{hWa80`RYK@(wrNtc0NfFU{*%JyCxN1UA@C)d~$RA|Ey~CvU2< z@X;D8`?WEZ1I10E3;^~N7Z`Z@xRm~W9=ECP#k}Z2V!!N*a?Bd`e#F^iy|_ILM&oS_ zKQ@D0;S7wDld^&@a^}+MzC(%89&P!|OC6Ohe#-555wGQ39aX``iMocOqNZcCrxYi4 zd2$euNIwH2RwE?c^YFqFD5}$EVlZ_>L_4kb`E@R2iU7*@1 zQ!uvJzXaS;+W8 z6t)qXwCA^o8bWm(>FLsIN|J|pu5{WNZF3(o_2P3xsN?YweDB1M8%eKjA^;^4; z?V(`-N?FGqMn=X47ucC>^`Pumd`p4pqX-n84XyO#tR{%)cvBU+b%v{-rHFZ03cJy836pr@6g%U#8c=vqOovK zFM3!@xIIz#`*FsW)I^Gz2*QA};d1Eem;{Jx+@SCO*#tVTEGOz(V&DHxv^|9Pt8#lY z5oc1j1@KQ=Muq^5rw#iTpE5*xA`cfx8PNMRR+e+in)`Z!r@+Ux8D8l26yXX|{v)|q!m%O?ks zu>~72vCZ)MleF>YBU2hO$O93QhcH55Z%m6ogP})tT5euw#@UuhVaw_1HcY}-H~_h_+HW!xbayE zh2ewI&6A0ZtrmT`>3&!Z~L2q7_{`Qjbes-39oMJi!HOHyPnT2yi> z8jm&AfIf3ZoQ95mvqjZPP*a=&2xJQ^RiE3{f$n;CLQPkK;30O?{QNEZZk z-AmJrop;EXM;+O`s6%Qs0Tpc;qTsilEM{ z1*V+{I}Y7Pym*boCx}U90tX-(Nh1g2MNv8j3;E(?aeo{wT>$7=eyoi)#HtMx0hSZ} zLwHBzn~aR0`sXKZk|n6=Paj+Q@Z!Qd(MCjc=!N z5N&^a9z-vppobqm`dj^&j*br4W{NMnx)#Y^+)FPa;MN0=S2Ac7_}r%S(V>V^TtB&O z00BpUdWapa78ED3(_cWx6AeE~fHD?&uG*Ro&CvD#iDX>zM$Gr&5uW*vALMR?DDT-r zWCp>yR>iIqCs4-}hCF`ZwasWUCS^rLfXs3C_hO&sn;UUo;e71(G!j{1Lycj zdF|%p#(r=^)0}YiM}4lzJI5`VkFe{NQzd@&j6m7$afiL#F>?}yI)&G6iJGxoUg!Xk z2Zl)1!I({f7daepECgpuLx3#JV5EYXh$ie`d5aka|KRY92PUsaeq-L>Tr!0>$lVp331MMZJx1&Y#`gKgq;>rz2q2S912ka1$p zF@VgYNOQQDS76==2~nXsjs zM`)S|bR}mTvLu;+fJz}75l8t{R!e-8VxylL1FJ{uEjo>M9Xg}KXDE8Hc#{A8l7t&&j%4^1)#l~t4^;g z-Ftge?1|w$(HRFAj#_3foS<+=8>D?D5^Osc^I3A!HC9s@3Njd{x>Lw;^corwh{Au= z(K2>F$$L>}Di1&tZj^lT0B4f~W1)Hh)+9YHlS*MpB2QxuT^(O+UB1GJ-h4CpXQy6+ zdP3gYT6AV{ne%nnuf~|AC4${7U`n@}a=2-7?mPTG?3e%T|0@T68`EjAaGLs)pH9w5 z3*?Z?_z=&&EVZ8~j|4=BC}b3dGq->fP5x>FlDe??7XnFlo<>_&#V{2&KI<&<3{qBr z!IEEYz@~Y!56Wy^j2?9DVRn^p(#l^+@+p3`5kFnAs zPiqC=Ws`mYKYJhn+>S@6u-F$!nB>< zMTB)ihcV9_m0dQ@;gqGe3ubX~5sJV9sfk4VPOEWN+3b8QijDxz)@JVe0^mBIxFkM^ zD4-tk>54`E^%vI_4?Fv~Y^eoj3~l4jBh&WP>5M`L=et$;EzL^EegHjzEchRg64+es zSvK9PRtgu2el)&JUemUxh|BGLq*?$Ky}|(ntz&KNd-`tZ<|<;Pv-!sFsY@ zca)92w!7l1lhN_0ruArRi;1>^K(YC0kFCOX;4AEd$YKWr{j}I|P2WEu?>tB$;@U|B zcZ(OXy!;SEZ>33x%Z9SQj|txglS7dMLh(7Ln3z83<4g{iT)4M~y*Orv-2&vv4}Vhz ziTC&&GGY{iJ%e-)u~HRMHlFM)Z8a+oGVBI3{bBY6FdoP3SMp%ziOX%-6>b>F2M>=E zd#&G&oejw)aHE)+L!+WJpRer@eSky{TWsLE594dQ`;jYxw>OpHFoYBlSg)?UASo7v zurH+RWcJnKN6UpW1t?imgEUt}OF5`^_z$g9+_F+7!Vco7)NIs{3m zv(VDX4O9hF)o)6pAOe1AA#EFEhz!U&pq&oKE@E4h19m)(pEP95^Y(4E=qvqR&7G*V zCPia;qu{Y1z>cJF&Q0^z&YwI;*-%-j9MpCiv47%)cG5vbr|nzbM;^yf*zQ!-PDNer)rSY%Vo{NH4HtrlgRV9eS3fP?P} zk&pGWq0xpxX|A0YcCe-XO=bz~Jp)+>9Y6j}J7ArW@3Hy3-m;6w^jku;YGe)5AdCM@ zm4xoMP3Xak1~9tckWOTlvN!5|!(Q1G%}us_7=lp!y#9+$Ny-7Fbs@?QsZ@!!TVX7i zlnDfS)Q(=%4bmf~vuynZt5{oq))KLYd3xHbXIDtmU9PusF4f1AD~JsWb{8B93`xxf zrUHYZ<^6Pc^<9YUwb^cO>v`C9#_aKHEw-g^uR= zY$J_SHpFiB@~!#}on0p`G4R<$fzHrOK}NEb?(pHUQU>C^mIx4*#2hOtM_$38g2oo1 zG&`H{3L-s=AH8JE%*>1usNR386RL4SK7X4)y!NdqPF1Iu7+ssUy+r8b(6`piVwGRZWCd%Fg%Kmm zpjI?mobKlVt(I!45BOi5FGBLNnpuhBp<=>Y_zcwEvxN4-*|?l6oqSBfPL8lWWuq40eTsN4GP)n zle0A2G55v(?S2e9E^F)!ly1vQSsMPkpCgeII*P}L8;<2Dz=x1vLSniu`9VHEZ)?k9 zL%U0|(%FX*#B1h-^QS#@h!vD%N>fcR5O+ibj<{mQR60YD@#O=l4bA9s0qTS59j(O6 zP}EW_x8Rm62Rklrj0R@SIbFPX(e^55&gajcy=(yO1_0PLmWD^ieY}Wls(JTLz?~h-Xd_wtI=?RnH_yxtBw!X<{FJa-s#Dm#btB# z{PtUBmt@r{!cGHBrC})aQL(iDtO6B}be+g{0x(*`uQ!NmZvO`+)0(4?|MCKBKIEWM z>2}9m++BA&TE=5j3x$a#;(?Qm0iy@%m|)95&6(+VTN*Mo5kb@O+YnR=?A$42Xh63h zj3p=NNkdUVB?MN!z4cXyjkxHsRpwme^@BF;b-~`ld>6}HCoNO89U}!A;)-sxDxpjzPdQ7<#8IR$FJ`4Nu@*N1$EOq$=H3Q1)g=D9# z6(e=!&sUwD@~ISb!*>rEo?azk^KIgh#KGJaoK5gj&MDsgk35JA`%5$k)sT)7ga!IT zK2Z0EgLb`y8J-0|x-KNHgEDHe;?gs|Y#GJv*LL`lebotqH+Jpz@G?K8lJ&^-LcWrrX98qo{rb>T1I}OTNW6&8pAslt&OLwe0*3dfq&4(A>l0109dld|!^i(b$gE0l z-yiF6q|w%zvH`&ya1KI;w17>XeUkEsRDa;B1q52nnJ4h)AIxfzETtTpF5yY2zPXX7DoLM@BmXWZk zITDn8Bu*x+Up1I?0yJUF>r%|$FK|E`akG#xT0b{TK&vhUN}JfR+wB=z(ZE*J!zqE==tU(&;$Hx*s_K9Qf*Brimt964Qg}$! zIf8xS*dVe^r`zyfZDmVM(3x#r3`G)7W@c%4D%FO3%7UjTpPE8$97~Qjs#+ig=Wt72 z+4iT+IG~LtqX6(Ary1qqeA%!}ajDsxK!EY0L*QME1hbABa>RiBAq-MZYwwWLaQxCn z>>aEyr)G^qlwpa9wn2lvZh&Vp2T|67kP$qi%%Y=7MIlm>D1*%%#V`Y;ha^O097i5#%Nm`1qB5kFVZ&wcT*i>Ym!*a{Ot~V z{Tx)W1W=%ZZ(Sl(!h7X17)Bd~6s^%w!15q@snwexCnG<04FrL4A3>aKR28Q`bygE7 z?Z&`|khD-rc;5@LTFeX#QlCU7aL5rKNKZ3x&=;o$s<+yfj^|(}tjH|dzPg+Tw98#5 z`5v5LO<)0Cw+MfNk_`RWtCy_?Fujw{Wn4WC9-P<{8!+xJ6k?Cu2OpoAV}7Cz@bXv3 z%##=H#k`MTnNm_V9=j}{Xbl&bpfJzqf48ON`kQmYiBZ?5p5CwE#mSw&)hmQQfUYq% ztzcXVTA@=(5DN9Pw^R*br+{ZP*-Uq2#X~agOa0?AGIC?JDZtd$Lj^77k@@4t4FoNC z!LSPuq4TDJ)n=-Ql#(vyh&Bp^BIkbkvZ^->N9l9c*5YVi+C6=*{~X6!thrAFu4ow6 zacB`S8;9?AWCJbo7C=u_7f8#=#KoluyZK2#-}z+u-RP|VBguChmirCHJLt7p6?I|o zNgb5`myMsjSLJscS}Y~Q*$BS-GhFK1xn&T-)Z=z(N+ShTqYFq|8Vkg;^ddWVQj0M_ zvz8d!HyU(p4PvySKw(z%XvmQzw?Mm6DGkX2lR-wE?2$NQgXU_%kSE>l#NTcPX?m5a zRau*LQReyTF-Rb5LNnMS$E%RAEA2#QX?j0Qu2JxJ&N4_Q9aaMsBp?jYhYW9?yY%Q^ z6HQt%IQlvMXn*_m4T*p1As>T@n_CG{0rxo($4ecBL_GRM(_ix59jxsvGw8dVs{7o& zhuXZ3l8}|5X-Y)8$ zU~|_UA0_X9IUV{SiAmmkQPKu-6PgTZxgqy=OJ#{o%Nr-aM8Os~K^%%J)aLYQ4LgMt zMX~_=vH6H}5a7`7Vai5kK@=(5>$3{!6xu7?pRXMh;^Tw}t%_KfN^-^AC!DE&Dqo4d zqTJ19tnY(&Va|Wi-2BghLa9s~mcKOaLPFKd?_XoBHD3|VpIs>B0eX))o6a$p@qstD zZACR9{egXXy+h9~woE4-*Gl#+9)by>P|3_G`~d1oNv)gHWXFgPg7j>(+$)$;G9m(z zNTAx7w~++Uwv=0G_Sc+P6u-#=K#MW7dmsr?KncL>lEv&+jfiX{^3~geq z$Wbg8i&uq20f9~cv~z$t*VDZ81hC{%@&Ab|41=znXFiYO#Zt4zchGrPsY;$%PJDz5 z=oMK?>P(~n8~h=%_HCrNME-6Ee(ojg(CC0M1OI#rNbmSpWK(z8e#w2KKza<6I}mB~ zIbVMm&KoAk+HLqqQr|;KpeA%I?~(7hrqv$$B@WUuP!Jl-J5V+r9vZrz3WVRg%v)z+ zS3(xq<=?+$0cS9U76*A&gXt;R9?%Ey4W%_f@WH6i9+VlqO=sG(N(RumolkE0&qykf znwFkuDlVRt#hErYg@#FtHH|aksq#R`j<>^U0Z-Hm7K_zZk$YA`0r6VrvzBmr6QOu-*NUuf6GhWhT3L)IPo z!u&3$1@0GbUP<>^#ltgxC?FspK1i?o9%OJ<>S}Cn`d?_tarnl+N5X!S^|?RzZTt!o zh0OVqE`E@^z>4IuQKo(coITlg4g3?@_kl~k$1X>YR$VSAaFKWs*!!95?e@oYgO7U# z%p{J4Cp*Jp*OXC&X#Bh9kfPDF8DxYRTcIC8BTHZ=4LB>ZUII!s3-rj=tC{-k9>E5q~i7JWP)kF_mPL9tHIjIOR zj3cV(V|Mzf1qhAbYRxM4lH;D<`s2-ZAyGT-hQ-9^)9SXRuePlZ_@j_o%Hao!#GX<5 zkOBo6+xVoU+N1BDGo8jay~x|F&}?7{(i%4hvknosSh;M9f4yQKJp;1w&1E#B$&~Bo z=nAogFuVnPFrEKId|c_A-7Y%$4`@pXdoEW85Hm3pwdgYu^|uN23^c+k58r6bR(E%5 zydtlc^g6x1S`;E2Hy3vWsI{Ixft0nOsIJZ4L7jXqyz}102T7XtL}4423|EGY)y~UX zU1(adxIo-l`lY~wJ&Rj!js~*$Ip$8^ND<2)3Q5j#an9}amfglIq|D&)U?X#DQVtpV zbxgh|{@n}avmt4>i4Rr;thA?(Bv$*$bt-<}Nw#tBJkpY-vt7+U<%5SK;tg19ORph7 z8V13+uZ)*%-7mj-*bY>72Yb6-hPi?H1>(Mn25AOdpSNq!F1TTGW4@~`L&K3WI2&=@ zJ9RM1V7o7m+z;=35HMoL`JX$(5>jnFffj)~@}K7}0%loCu`B2$G4)R>6c#A{q+#cR zxXevT8H+@p{LX=(>vcOHQbVWSzBf#EBYhjWeANJ#oLK;}#32GER+Nn~hx&2UXNEo{ z;qO6^dq zxak0#3jI9T1Cv*;PeJ_^!EMkDo$Nc%t_Xhjj1#i4H0tbxA&T)on*gH-uY;ayrV=1J z#U+oEu9`Ly*1LU+PMLPqL{h|LM<*UBb|F5s4CR-9U>&F^gc`gNtk8;yV(tn+!YP38 zs}ZSV<#{S2rjk~{uN?XDV-w zEAE=(NW=b47a~f@AX*z+8>%gu=yK6nT*NG+ ztBA+>{^*RWnZh$Ka`zv^RWl$@-bHByv9(>rc1iSAsc$C{O$yR6A+Qn%fCz*C=owpu z`6hjU|FV*k{k}li8<*0=K{)|>RUyEdDAO_oz-yrP>^x~Jv_Aql)K~4^Op`U}QMv}; ztTf7VORGBA|9*`Z@HN&T-gvRxoWlBRSz+l&oRxL+BIsY*#b)q3-#mKNraIDQ;P%x_ zNqj+_|K`kqKu$0rS5-6SHQ<1+q2X(-^7a&6W%wga+B1E;8rJzYFX6^qJ@9%Y5Wi4u zwqyJ*pt87Ey&=4kMJro>1YnFjmlUdFXN^(2VR-W!X)8z$L@$_t_F8#m-q7c_**r+m zVxVn~IlwIU(%t*uX|={IXkI=;idf4~Q)E{K6F|q*KxJ&>P?YfBl&yvU*BoK zbLf}Y&-8}|mRQA-Tn3|EyR2^qtQ4D_N*uk3`{9bbb}11!Y69X^o2VF)Ylri$)&w5p ze=Qe;1^&iO3R0!2KSDAQuPKEfgA?^qAcWeHh8&(8__NRUBWq;P< z2GTOs=Slm=tib-cxB$|cUD2ft*+Vxjy>}e0?>awoI4$+=mz$0&La!Z`@ z2#9jw#v&Ev6A_p6G#I6dT*23hl#aBN zKzf>}s4;*i*A_iPTpxBG7dSP#*8ek7>EV&P3O$s{54JXYzg@@Avr+78Ocd{VL@(O$ z=~pLE@S-p6!35x{Z36tsr1hP?F%GRP-AL=boJ`q+tRy2-v+<1&@Eb-^lFC&4&%ForrtN!@;~OV zTX00zAKlvOu`VXRWWPwhtY0pC>9UbgW$Ni}$z@t~SMWG4i1_Uks65d;yOjN+Xfaaj zalU)wZ7#X(XIV-p&7bt;J#+eAw;va4^Y>2@;wnlHix3sZen$K7=ZhBVnpPVkPAuKU zP4qr}W#Q%eX8Sz59Ej27C-0O|it%K%CD+k>uEIUv`#5>9X!E#c;3LtljoR&9@inu@ zLBO@H0iZnN3dIj{p~DoWUpFg0B9lBwm}d$eLL?D_VC6e0${tO=WJ%<;i9 z@z)Z|Cfywm^XFeUENZn`iqD)tKXA$Sb&uET$2TEn)CM|l)j7j?Y$u4YFs6D5qwM<7 zQTegqg<8=)T!tz~u=}s^ucifhLkhfaB9SJd$rW_Ku(342FeLvD< zFiP_2-^y+={YA&fbrz?Ek`-g0m;Svn3;!6Oe706Joa;W-N`I0tu2FBd?lP9P{oz7{ z;KSvT`1_T5{xNqN(q<^ZX(?U1z|Ckgk8Ij>QSUXLbLt_&H^ z&II3iJrlfh5?-54CYVg2$szti6m5O_yhBq(#2l9^zKRVo%msyvBy%y-Jv(qrcPu0)`T2eOPUL6m4XQOl z1@w1aJHy(IDxUuA;I!eXcRnhTJ&Yjswaj^|mcTAj!|HQaR_r%l@AJl)c72Xl=1-+- zpxwTM%@;6Ao$lZX>bU(*Zg+9z&o7;Hn!hXkeS2+}!5t)s_%IrK1oc=e{n;p+WHFjL zHZrV7h&h%%Kt>e{O;l`2B%)6^cUEi zB&0{yS*;YX9pC;4dFS9Gs7@~x$9M-Q-NAV#UpO4jLtQRFeC-GK`Xut7=n$6TsiS^U z>U4~SJx{6Yy8HL@xx(j@7q%a|Q+gdO&>G5NzNUV4k?lKl^&vdE5vh`0wjEPvkL3US zeN-NvO{c%Nz#48rRNmD=llkvIVSyge;4&6D!?s;oVtKH>b>2Rp+0p9shsCKutyo-{ zdvkpb)$VW7*nEa~;R)1v+e5h&W9K9JZ~o6Wbj%8mTjy)GZ}SL)A8CJ2(R7TvX?;hC zS)}|3Z?KUX-}Xo3gF6HxoT!W95;l%e>ntY0r6qCi@+NMWwN&?EHp58v%EyY;uZbaM zHWP_gvqg&Sw{Nd0H;ML4);k&H7{kFc0XWVu$yE2*sMf!iXMQ;7-SjTT?);?~cdIwH z^TUB(%{RXJb-COq{_WJ$9I^y!C4yZ~K)JVrDB;0OB}laIm*0lHj;yce>Ia|lo@0Bt zTxg#w{q{=H3%?p$YDv5;crv)|Q*Sb?jQWg6He4BKr$3nl9ixN~?qvElj`-2B>ZiNu zzRSgGrBNClo*!Jfkg~L@ceQGB&%_(S@7oGZ3!4Y*O{wXjbnDU8A-*ufDudtMlEZ6? z^@2H!&8spzm-RjqMaP(_&92;iXVU0J{+-Hj_Ssx{ri$qARw3S@t%>-iewpmwc4{MC zAkbsHfCUG!t335%H>@ZDgItjNk7og@3%`+75L5!OFrn>BQmCY_kJ>AhlWG~7T&QGv z4NS-VBrAvkg(!l6H4i}E!`FTTX3PhcGpZ^#C~H{C&ocXdQdryE-)rLzh#bfT_lO#Z zc95E;y2w zn#c$FZ=qcP76wY9nQnDeLSV6U`g8LLYF9Qq)3R$XusfeB9N zSm!UTsE^{ZGISrNU7coEONjwe>f#?Sz%0WW-|Ch+CjW*{q5$_v5dw)6UDZ-<*tU*^ z6_CCVIdiuCi6)aMvqe4Hi<#WQtkMey=g_ukvyR^jN`F zgTYQ|zE1plTy`{zj58Mh?_k5aQBH^T4dv|>_k5T@#+P!ulTdXy;p=qRb|5t~y)vo;M5c1JW`gIm~_Q&tupFbb;(XlWEl+NZr0^SIqisI0yr|ML@;zs%! z*j>wwgyV7UD*mi{ceXXz7v`!yUz+|}-ic=8{Xf;cS5Q=Iw>FAsgSLVwK?Mnhr6f_w zHbIb#Fg*9Pb#gH>rQ<5KiyNDZ#1v*-Z!(T*6y!?0=z%*i->sbEK-4qpF` zdYa@3aBG_7^=A6Fj0nkpCW+Zt9)U*&7D!+YsQ%5~_e*-+>U~;Mwgh`;TFw_jl~S#oy%S31D{6uq24M6A&NZr1?T|KQd=o z7#mJvr8WQi++f-k#YX+p0U4p(sZ3OeXk66ax)j`Q+2JL#y;!tn-_&eRQmaUqgGpNmImgYVsgHEz~^9Z(1& zX)OaYsi-!X|7&>uk4YN&p}s71VxYxtJ@7>Vc;q5q&zr?a{(YcH>foUN_a*(m_%23h zoa6bN9t_`iAn^%#O|8L*^1gnyEt+FI&-d1U%(?&Z!@onX+w>W*LlGzvBhw;f*T{(g zd2V4&`@_Fq9-$=vpMS^w5kS0ewP;x~>@+jN zmj}a%kZmg#T2dde8to&GET~zqf@Z~J#Mb(XfUTQ%%R=7`{B!2}@h4i6r@%#h0Adt7 zzIP-JMsa9&MzN}19?yGmn2P;l2@~o}?e;hiT%lXQ3=u#$-*B?H)M%ePECFp`33%10 z1LUWkA$w+z@gQp?5dluz`HDrxDrVb%AHIw2;Sy^D|DlJ#sk1|h9FofFT1YAEc<2HC zeeS0X03v{&Jv>H1_1Ah4sL4RLyYsD#YQ9BFvM-eR?l>a;22wz?{?pcfvOt?=24oXt zuwOSUvk51xXCv?>Y!DNWQvzL*)&|oiCp)6}2qg=JnWl@(Vzl;UDNXYav`-(2bb-tsyGiXWw7Tw8OKt{M1)_ zeH*syiNJXnS`5MHR&5DhVt1yO$w`ULHGB0sK#VcX}i~AzmHhLO{}( zAoPCw_zEj_@<7yIXxOCns4-mMbJMvj%)bjb@kVx=A+VDRzy{SyUR7JLKz z;iKRh={5;X%4qO#~?syfv=l$aMoE`Gaa3z(;$|ntl63HI9bQC$8&Xir=}v;ui-Lwt?P?e3 z1{>hdPuIE)6A@n1$Ty(>oG-386QO#{89X!bn- zLl&2nQ6He#9rFSsS=YkH1n{}wb-(!pq(no`i2jWfp5PNSHw}f?fE7UwS;7~QOQGgI zYBKW-3oq3<2}ni8g)gB!LDzlS_dv!=jfzNLrMZPsAiZ<#5J5yj3f%A_0Xgs@JdSw# z+w1o(1rXM~N$w|ABQC&l)gX%Ra=r!L_-7D9A{lhn2e@80-vjK`036cgrW+`4C&nI6 z-=^D<&q@nuhx#|agMdou=R?WUO#K&Z;hA^{@*{h_uS-P!D(yN*tXlH?30zrU@#9G? zNb9wVVsf4IiP$@4vN}BjO_vXNxC{rpBifaQBWG;m;EBhkFlI~khYF0J&w=)-c`!;f z?WoRmcdmwbc-F3CR{^?2C4-OHq|{fWg(O6JL9{3tJbz}f-9?{;p+FQ1Fi#0w+}(zD zYRNvma}sXy>57=)NMB79n&x=>+dDiam6-EwVd_-(1@M$DiZdv>ey+B#UqrCw>G-2b zA|?LFrM-(Q-W;xm51f+ZU^zootl!9qT8u7AX-JnCF~M`-D=v3|Vr%lSb~qS7<@X}^ zsE<1!1pH`8QF5J;ye*?!gI>EG%#PZD@zGIXKfT2LiPW5l z_~~wq@j5WJY>*}g8Foha*-yE_xH7p2b%+mcz2dU>aGfqxhtPA!&zANo@c>in^uWz0 z*mUph#lf$_yL0JVL#k)IrYc&6aYAXNxzJIg?_^s(4MuEE5&EzhEPt|K?X%?+me`)k z-yPd&I_`{&a0MsQ9LAtM!D6XW4Y;@CKnCKi0HE^Hp_8ckh(?}PhoKRO-7Js|%|zl% z9lMI(2K_-;CAfSqS1yC%qMDy;m5;Z?c@f?W}5iZh1gmxL->}gxT)JFoS_}({Zphnib4<+7cTvp|Bmv zyEbf@okUjwgs7wN0u>NBQ~;9g9ERYDer6%ARyFTmTL6|0t0V6X=BSi2A6>PTh%p7TmrLb&o8V?gsr_Z?^J7n2H*I;IX{IyC4NA-7KmDIj$hya_bx6 z5Y`0C_Uo(OheF@^R{63KGb=D!88hR(yoLR?@)42el;p#qNnM16_s5)w%6M~neCysf zW`R~y6(r4qa4N8x(hE)t(66kpeTDMa00O=C>)LI%Z_6QhO?g|Ysa7UTA}I)=a*0ZREL;lh2tmhk|=S$twH+>6ltuk@M@-uSAxO_>#Oa`RTgZrnt)@tU(%GhhjL^rODvW` zhMiZ{?zzb!X4S~R{q6l$kn(cM{sBm$ihK-dyR@gYbW1H%Q=l&MkIZ%mOq}xiG_tOh z=ytRzkc;-Hwh*U56|Y?F?S6GN=QPqFyrLt|T&H#0&u)d6eW5|3wB~Tie#K%l*FG%a z?#A5pK4^gAZ#nd!_MHn^|O_+E9}$#LHM>U}^R>!j=m6_18Soxf;>0r4mwX+anIW$w#v zmP?)<-VoCeR~b{sF|WaGE{XLlrUs0c2PL`Dte0ENZjm$WA61r49c&vXDIkUPLCby5 z%**Vr$D?W4hb+y{7?g(IoxhxY60!SOuHU>`2zU{Hp3Ly2@u&?1PVk*E~MSW(ILQPn%CXlepDg zsqj)~GqcnkYc0SKokSxFEw+?clJIWk^#O8M1TP2ce=O1H>S;OEzoux zmcF)yd>FG2+3|xE-eAHQd2I1alKT+@37QH`LNt?wrC{kv6;GhVZapa6Mw}Ecoh}+( zGC)6hc~i!*(|n;~0E`mH6X$)>R(dxYx=mx|h!e2qVVKk;AJmI_pgUwGgwlRNV(j?U z5@Xg0^|{h}(oRe0)=|ax8kzF6)%6yR zuYA>~r2cG*j?FGU3g0z-VD}Hbg#q8~=PP`h&jY3fv493DI;Q6)()Ul>E0;uPu6@1| zJ`_p7xU5*>%narrDMloxpG`wW(g>YAmld5mn~qFtrpv54nQZN~V3W#}V^m*P%j(YK z&fBcQ_|)JMW`)Eub-8dWvG+e4#9n4kj#^ zy(r}-G?)ilx%1?`dpXymZp+BLpu*84&$ENH(_2Gb>Wb$$yE{^e@)ttTI+YG_+yqBM z_3ASX!AxTw9C1VGGN*;E-jlF`TgTZF&aWqdp?GnOsK&2p;J%*V)Gclv&p`x`{H#W> zzmG`m`z+`b<`~w~Zt;Jc(b5s!_!D3x64&wh>F}wAz{Nk=(``w!j@~Kx(x%pV_|s`J zgU3n)fw@h>kHYih=~}Zy!eP z#{?ddr7dB^52|d($EtI&D*%Nw02H~@M4i;T!SatV18tDgsK;6(`POc>r_>;?NInvB zxV-9Ea=H3>6w?!&Q6@`lo%Mu$N$BEKBUBZh8qwi1M>sQrlrFj@V1O>Z=Y4K$KpjIt zo}*Kg=6QJ$?Zu$)=>_9J-+@kbMl?w0CS z&5BM=%VW*-)zkm}SmHuZ8&fw$P6dCD3U67WuMPyLQqm0$D&iOR=5iB>K)^P`RwA!^nN$4}4%3NIhq}CEil#&qX^H)$fGv=&Hmg~zrby{en zR9E@qW|aOBH<=`11MiKlW`kEWeY0-VBKYz90A_;N2Zhs*NyE5f%%XlQhFwRwj=g7a zqN4Ta^ShNa2qGk=milYA=XXXpEQ47_12f)KptIcI%}G|VAx>V>2Nb*s3nxBy-g=ix<=L%HHDi< z-!-2a)>!e$F-)H^M)o=hbN;f|tyZac?9Mz9@#&&I)vvwQxskHX`T(DJEt&8Mrrx_; zfjMDslg~l}%ATQEb53+@ht*FK>(idDlz?#>*Im$O$lZ4op8q7Fd)L+?!B&a4Hz7Gc zxAy$N1tIy?!H&mQ(F1wQQ8B9p=^0}0F&Dw{wroQ4Gh=t=^{@1FQ?`9g*ZI04XJE!d z&3t{wtdGAwjiX4!;_kKh zAm`!{v3uC)+>(e6W8VHRX6p@3TX$OUSbkQ+^j>%HUu!nP1K8D-- zmOT0FRk0h-ecl0`3;)GxliaIrsV8WZ!@V{fa4zsiapx zkm{qHqOyHpEHNzDh6Wp4vkMvzwXs?A(X~>qYU6bOsfAfE46<4}(Wz<(4wH^12qAzDI7F%>Ma@ zp~TF2Vb>?4VGJTKoa?{d_hcWFS(qwia&%i=j+RYPBdfCeE*J(|E7{vgTE>kA1fOj( z+37kbzYIsmRa8UL8eb;CR=+8|FOYI9WucXjOvkop1;J>IgHZ-jBh3L^Y6}+Dd$?P8 zfxyh`vh3G#!wAVe71^?e^joPcbdMz(T~KKn)Z*9-%MgP{c8tonmzi$%*LhS>VYvBsvH7G$ggCQJKXi&CN|klWO2@XMOvqv~x?@;8om>8_rQlIc`DVBUH4> zSzCg|c{fYjR!#bIg@E#+rKPir#X0jFe z)z0Ib^3Et9_FJ;n!cUQBiGlGOc2(n8TdJ(D62_Z=-S6KU|MC;3{1~Y}#TvMxFGfVt~^R z7I)}piX{&0Vubje<4Mrp-TgiUu6QQ+pJvmO*|r`}Ra_a(G?vGvq$*D(O~Xd1 zd*Jo78&q&2`xkk>iBX%}@x`Fxe8-zv*1=XS{^Y6~Bt{<_*ZWapW5_Y(ka z@b7!6>F;dDB)~qO>X7dr=4MTv-R!=v&d6!XUTx!zY~rt`>e6?DJX8#Z-X-FM8IMzlg;qZj8b_vDF)kU)?ZE5zo_Ix=;}e5x*HI}78TDEY zRQ{;Drh@+9rH)ZK5{{~<%$#Xy9+8Cy_-UDz5UVIR^Qi&*bT5VcaE5a=3oxe1dTJEP zN}w5OH?@uK^H7~_2!^t8JoP#i*AG?lLR&FDnguX>4L#p{mNu_9G$Q#{uEYv@@OHF~ zqvi1g%m|um;qpS};bPDm;)hp+R*$yly*A-(-?}4(3?4?$r?bSQD^-yOww>=*Fu>H& z2zDz{KX&NFDJ3uFSN@7i4x5itBiB=I+3ZtFqA-2S8kqSoC^=Et?KC$Y^pPn*SGxZXDv;0bdWqPlnF`e;CWE^hV)wUg7AykDi)h4D&GpKR zc_m`~RjqZV`n8P!8>4wH=~$7f0QMN~TLN81tcp*RMdL-LHf5qe=7caM4LW+vPb^e8 z)CP&7Y@L*TcCuvW8+l#V(1;F+Ipo4!_7+{*n8LrJ6z zdANON3{Lga6whB%;9PCQkp%nCA%=kb21y@MicUPVbPXj93FCN}lRb`3sAxY%ye2wY zl&Zjd?!B5=GpeZX_dqXLo2X^$FK2wUSn_aPw~`DeB~)GL)>u!&u9$ghNXr2ViG6-w zSC~uB9+0rDnOp#ze4r)pnUp=nc0SYQ;~M6i6|<){OLKpS`g=)r%SE<4QciT2fJN8y zPD2{(MBSL#$^x0N@D&ZlAlaA0(V;2VI~R^ExWWe&-{`pUSSFqvBMO6w?NR?WS(R&A zJMZb8QZ!!4F-nTe_1II3>fKJ;rqUS>hK7qmSM*^6iMztwk&NQAv}^shlvh0ocxC$F zyw=kn?%!p>@LqXKdU>TLv)zhO!iuLXyc3Jl&2ot6dG^!9eWHNB--YyNcjn&hep>bS zGU*B*_HNPph%sG1zD#Y@wxRkd%>7Mbxbbi9pOdj+CHXl6hMBwlT2-HT@A_21@N~yq_Z@|J;Z{`=sdzCi2g>g0 zBnq|DWnijgS0Wu1pF*x<#bElFO-QVfCRQ=+>-;`CYOW&nh1e2nqK0lieNP5CGw#)V z+Z_v?s80Lcdf!_7(U5ka;+|q?PYu`rSA7L?;J|Cw-@lRQ3F>UMH){AbgBd`Td zT>k#!+Ci6uQS|dQ^y;BxWM}5WEPJBPK09MN{X$+q7rR5sw}S*7=_BkE&-(bf3ksP^ z%WTJpWJSjvKhYuGr$8V$|1C{EWvrn|2sxxE!1#=3(|l9>C^eHMvG{Gq^8+qXQ)GYO zJ+Iv(7Zb{k^BI)M$usk86`jW8`RsrvIV|>kdYK?n^uzX^=8FdxGTex{Hk=){v;OED zYWxEQHv@slV^Z7!3mcV_0p%qrlX%h)q)(M4f1F5rt`(3XdJBj_#$KtFX|ktn?s;#4 z`W|hW*`UcO=PB|Y z=>a7l_I+bg)2KV|;#=Y;MX!b;YWJ#v3UZC95xs5Ws&@lrcmI^>z%|^SJCd+lgL%iE zr_;1h8~Jd~OEvyM+o;2!ylO2+lEWFjI}{P}Fz?+$1a>f5@gPKShne=M$peqLe}D7&yV?O(`&j1;e67fvMxwp2 z;szzc`lNjnFj52yogROQ|8;09p1^DQ*m-5t5{b9_1mA2E9E zNiO4-hzB@=;JOw%-$T5jk?`2D14we$QwJG%g!9>?K{Oqw#l%gk>xkR_9aygE4~TRl z;=K(J2q%|+DBMFZC7}7_?Ry8E{ihu$9}3Xcbin%TA&i%{@XtI()38$Y_y;l>Tp4w` zH_L9>m-9Lv*66jLCLDVeX8>^%5Eyq&XVWdt@HE&yW)*9Vul!U4U^Q&_L=cv`{l3uI zZour@kpf)bFO89Q#a?{2xB!_i?W!EFFs=3QNu;X#3_$JE2 zUs{lafV&#`I^73CfAljKL&GlAPSg^t`q@i>B#~3r0n51#(3<-m4z6~4wj#SOWb=ft z`6=HETRHc4a|3&@wkzLj1KTa%LB(#-tb_B9Uvbd)Tx?E&REA+N9GV}mt={hmT?D3q z-#I%IE|=+a>KRzxF9#jK1-cE(rZVw-*jIgWr!Ao!EJDy7UC<5G+9yFO#q!$;r$Ga= z=D;%q9(*FJ3D;N@Y~Y&5U$DmQKz{M?k7vJTH2I5vL-^nFA%{lJ!+PrrY38sZGlSjA z48*2fY6vZFLE#2^#6bNszf|`34@;=fUO8Dx#)kvc5&!%adZnBV4^Yn z=ioMIA4-!PB_{w#Hb#~cLgh&A&$7zmXcj++9sE5X{)A6?GWC@g;MtF`!A7O1vAx9F4{L*H>4<1h zqq3DY{!aH@+dh8wgMyoiq-iI_HI(H>x3oN&1x#+#v_D@kvHXDUkpKK-bFTZ;0&x|5 zN3r+G1;>rWK9*^oR$uM?E<2vKjC+=`cT{mQ?H41KdMtIGI71|KwstBe2ugY?_E-0D zKCc>|yO?dv`d}}(vuND&qmV{-nfH#1YUNQy(v-78pY?$TY_>3G;MgriNMRJnU!PSf zMN*jxs#7jh)712A{0d|?hytaJ#Ys`z3h5uIvOSFkO@W&WfjRhav-!sZadYYs5>73B zuY2=-S{2FCK1?M1ki(`CxO7`N#3d-Qh>ogZd)J(W1%;W@jz zKO4b890mcl(wkNc8=4PaFDh;TSkyM4vre}Q8u9XF`_v2Nqc(o*L_A9j@!qy+0-vH! zB(%#TgBqob-RTbm6G9dEXn~_QaYle;gq&Gq+MUc=;Q z@@kaic6a}Zg_1E6S!DarlEJ&{W7zeHgUn99# zIZWQ#VUfq6g{s=P;5qLf%a)t|r6a^`*fbU;)QvG>Q`g|(%DsYDsx&Kb_?RoToJkT> zd@fq^UY6Yw>lejFNtyWmHb-@HrIwe)Qcn4-R}29SxzhGTMeJiLiIZclN}U8-S)J>!Sj=Rp!7Q%$1YuS&{^x6{3@}EPdmtKmd8wtrI9!CzVziA0lNHY^BG zuWUFLrH5olhi_&9xuvsUK9RTIOHVg7wqItjiiZCl-2$+sT)F5hC~P))jdLtXkS_+py# z(0twsy)m|DHE{?rJdC&Vw0U9_6L{GkPu@S`>NGwJaAJG11YpM;?{ipIk+X`MLZJ8P zHjL85sj@Z_H4+_Nf$@fyVYf|Ft)!hO%w-%cz3qClLdRCp6s%0ICUXY<1AdM1A~R9X zGHq5Y#v63;n`c(HLjo7+!6Lh#- zX9kqT*-@E%qvNmkuN@rNDaixZP9cL0ht$FB4pwAUK|v37yJ>WzC^PEC+I+v zSqV^tz|k=|f^7yyJuL8@&5-4IPfTDKd=~Lw^)hUH37RLiI&EweFkc)1Bb+QoC@R7m zT@o|k6!gCEiW@?pLXtJqOUx7CYBBVtT+sobdY0F+S@#YDLl?=3;<6c>?vDw;$mrL}nhu!;@ z=QFT}@7fIkdnLqB^C17PC`b!5rH>wwP z9seQB;9WUg3sgv!J+h)}%E>lD3{cbsz|==T$Yq5WG?!lfb`M@VS2`R{U{h`T#_Az$ z*AljzCOGXvrrEMDUVV2C5ib}Zb+m(pwKEjB%-Yg{%GMlFE!#PR{#N4`!g_;JCNQ2Ci>>T@3ES{K zXF8zQT8*5DSHAEMVgc2m<_bhQn}->&2;sk?1na?FpaK)?VmnB!y>(nU1q3bE`9cyqUz zg;-TD)?^yX={}(4&^krNk_{o&6D1H71B~9XQ-zVFkE#m0yrz7$A1E_sqm|fR6+(aC zxLv*Z2E67zna#7gQoIY=J}*r|65*K}SZ%fs58UyWjh^2TJuoUfo_&;AqrCziCue8Q z{;TTM7N7DhI@(_zreg#l8>?dM6XZ}9@nCVuZwOBE)Ji$FO3;OnA{MBV$h}hb1?H)Q zd0<>?Jgf%#ix1R{yRC%&L%yS_X4;CKFn=A@*0+C|RI4skbw&iIo9X7k9$fdZ(RcD` znKmFe%j+7c_g;ztN5i))tET0R)#GF+3qH`HN^Od!fd0|>@>vwh4RlJ1cKQ7a$Cl#m<7+ z#j>9RU)3W#OR!)Xp0WTbdzr*ZB+1$VdY+H&{bz&yDJ?%|`7<~{lqaPW!(Fc$WX@r% zy91`*iJ51VbpHXX+H#2)jjzfrq5dA1tANvKT5@KLus`v_1da}F0{;YX=n}s3;5_@m zT3D!5ao8S*bp`=_^P^?z*x~}j&er`cGU@R6lPeTh^}jM$);DTYnR z7M`LlxAfE$5aeoVtR5{@o^zR71h=rV%`)Ehbzu140Q6#eEkjLQ5S#z}Jv=RkQK%4~$gKJ1_ikm5*4&oWJ>> ztxWA)*ck`IYC@Ls)20*efck}~Xnw)3oec?p4K95{%lN6J@l>CuNOl>NmpI_9-7;Z~ zvt}@`<~=p2_nD#Fb7vibnd2cEs*7@@_XP_QVrU7y5u;(=WDJu|F8^En4Kzy-Qxxm- zJ}|q^VHRND^6uBvjz>}-D1)>ad6!SxPQY9jz!-)br0G+;fkf)GQJVL;P^i8@)_h@R3IHzf%#g>q^w%o@Ygjl4ux`FYR0@#6Lr#2AxNhRJX(%^*KnqT^*!Q7kD?88g)i|JA&7xSc3!cOdAsLEcpi+!r^ugbhpc{>rw9ZM@Gv8r2@;B*5)@O0Z%B&a> z0#Pcf?O#L_T4GN|SPaElMZi4!g-kx~L=Ut6n!Ii>JZ)9FMUP)naI)_t#`9{Zlw1_u z9(6Dqt+3~Rl9{&LR->j;0L3J}N>mnvpa_fo$=SBGZUrkaoaT8&cjL0_k93kC(lf|x z&)25$1YtvkZ{w!COQ^Tzx|_(n_#9O(yjsWDvTNgQ5LuMDeBYXysZWyU)^L{Ne2={@ zbH?csDnzaz#*P^D^DoZkmvjbi;~FI5McAqEO9>Eh+2ZarnR>|`8LvxR0V5I}&-3F9 z`wojR->v~?a%&Md(0p*9 zh3zCG+{(Ia;6#10L!{k<1t7G!c4mL}?%{2y1-u}6FY!iFB8+$Mn1pOeVC|M;bED_> zo^V=LYXab8B87QpwGXts<+ww<Ncj43?-@}t8{Ven?v?N#kN7oIHkce$%DZS)&@;}XN zi=37qb1QN!JR5QwUQfxy-JQv!y-;1^yeZdJ3k^j|zzYv)7+eeGW73ynK-3pWi`+m+ zD;9M_kAJ5AmFFoEsvC$n;u|~kPBVzvEmHHJL8bdvi`jWsf3W+-t5Co4X_g24o9`cr zBj>Kd36rbz*mCIQ;_`Fgymj8^xsuZ?N=6b$2lv?X3U}b$EBKJ2+JShAh}>S|zUKG` z7ME+NUoCTzu946J68vL4aFz7D^IX@z%|J6uNoIsp@m|pX>rGq({AULCSY;Qa*t#R` z^t=9TawZU0Um%{XUfnx@6EFeMl|f!ZQ=Ji`oGGU#+NBldW z({cb3>Ja}Ekaoul+NX`twSDT{f+U=({omkEEW2e_4atfMP&{nRD&xMf%MG-2@0EqW+IqHHhu>a&K$i2w6d!_LP9*dQG9$11%ppYM-rn{N!yRcNjXIxodR!)B)dPyQ0W6OFL^jpZ-EG{lX0 z5#Ip~(QL#G_K(htn&7dH4YxsvF#0P5dOm^Vjhg zun0%?5eZ0n(mo=y*i>KBGD0gzrd|33lNvXY;zVIMZ`b`*Ek*HNcdYMqj8|EhRU}LR zL;DleUtvOOB+H;pQDLxREd7eii%S#r6J)o!dZNpG&&QQnw0y=( z-z2n{^<9-o5C?b*Aj+Zrc#QuQY)KUH(j6itW_7+s6UH=#7d>CQS9d`;V^eUiOhRy9 z(XDVmX7*ZHfv;a{C8Ejr+IL5L_Q>i%!LOIT2B6j9L8b%o+o*{e$I-=K=weH6Svj~p zVeT6#FJH_LKx4|;BeIwA#@d4~zx#fkw@kE1HX>os1Cu1G>s6BL?wq6We(wLQA_N%P0gLn@988Trpv$3zX5nMq7JT#p z9y7Q+eDN3fyPb&I$nT4Jd)%XPMG0H}C>c)k{uh9y5jJeG$IViOfRKl8993WAV#bKcIsQt2n(_ zVh9eg#b01DK380yYw%V0SNr5@&_>BdKQOx+V0w?tp9V<@DLCUO7yp3=kpdKT0xFsE z(Gi{+eRR9=+3&pECgIPW<}#{$YRY0%=do3z3bKs|;|F^oVaA@eAZ%;=;SG7u?>)=y zCYXM@m)4y;$8{=0D`UU8ngW(E=LA7%vQ`EyFYyOJ2FjbOYbz2kVY+@MT)6Wz!@v;4 zbP&Qp-qT09GHd1is|pT`rJqoYMzC`pfJ`|HEuP;2{`(sna!*YVv#!Cr{O7kp>#}ic zfnT~)*CNq&s`SPN5xPod+0aNvZH&v@_S}QXOzUpIeJt+o0a3!V0{f<#KXdkPeS}W) z`At%tpNL+;beHK1i55J1Z#>KZI@sbvIl@Um`!m5;>7V;Cr+^iGJ;QGrW47D=ce*pL zO7Zc%Ih8a7k?6O-@gvz)p8GJKDgNA^TZHNCc*aNYK=w_VS?i1+MYz42aQ!g7q&kTH zu$eN0P(&JsHuG1%{vJj~WYk6Zc_D_{`_H6;X-+JS*9x_o!^{wqgL?m<%*r&@8wm4f z?jr5c-Y#6SI1_Kg==aSwhwIt-ZOJRo5&8Gk!rbLAaLyWO-}+|Kh;qtknpi}HQEX)I zX*7aqJ`2V}{Tceo@tKzGd1jc0Aj(3?S;75~fSlA^*3cd#4BotPgmXD^>IgUZ%{lZs z>Q{Qo*0(UEIMMWVEy5{gn?Sszw)UA(8EpaL{41^IZm?-Fs$axcBcBO4w%z%*DsM+( z4UfRqChRk>wf@%G%kFG}ntmcfS~@Hb9;Z<+EpKn>k!zLK>TaDkNg^0kkDer%WT2}92op1Pn^#;SR-@k3CWjv^U|=s_0O=7)u@0Vr+%+PIIjJ^=rdlR zn0_VMBrPeSb(x~9@BRDIC-r&~ELS?>vWTivi|4IzC|V_nx>79PMHQ#!50=N{tn@5h zv;5x%`Fm7ey7xL*wR-uTDm~LCurY~K+OPd1lJmzdFWb@MGE$%6PS9ds`#0n+^Vx9l zVGAzd=;gCg_37Q{R)7sIxBiRXD|1(?wfo5pihn$y^IASeNzavalVX#4u+H+1t-4!B zbtqr6%4{6C^fy!XkAZqA2(_H|+P|yP;r@dW1Bs_i0p0(&gvnurUi@NGCJOXE#jA+0 zQD}!=V^oJOyR_-e*ezqkm~QN@=CnfDpEOHwpd;Yn9K_kiY~42qS0mo+?d9(lt|t+5 zzDEH!vt7>dat!5ySk6G0)#H!%AQ?7qFiJn*Yc0OhMG#ef*&dZ#n`ENxXz1j+5&c%y zhr2&Rj$^wzl0$waCQl+x_wqiYZM6q9p>n}~0S;v~qZ7`>aD8??ai&+<^F4~U{nanc z$ZGh>ZT6@@kT$@Ys|;U} zmD{#G4UDq8YLV`yN{J_)VZ6g@VnVwFmDDo-^ktcy|67g zr!Ly4u6&F2X7xYJr;m_O%`}oTmr7s_L2O|XK&VtAB!>m`-YRX+sA7A)kd8>Cy|xF=Jk>pl zZ=$irJGVhq#kSmpv0?h&__>=2{Dw)IrcD{nI!PmV+-p#0kDHWxOK&swmxCB1cb^fb zNqwKI0*0v~$HNdfite^@@7ExvdIA0@Rz1QHAci=^HEcRI!ftYU(eXR1Bt^+Otcl@QJX0bXAoucAMa>(O`COFr$00s8_t*c z+F2{bUX8d!p-LgoAwn#Jpn8>E5P#}~)7#mO#MtjnrVecPzXK!a+7*4mC-9{2+2_#+ z(OdkMxhoE$P5}V+y>O&dTox(IRmfr`{|y|$S=P0@!WttgEja>{?vNF^xA$1}H0LO? zE!ck6FZ{D-710l?=kL=hW4y>uou?{RIC20Qm;p+uFlqj5e+Tx!Y~wX#LlCJYxGRD& zq!Ct2`d}fuT!)_|_-|N#o&~UJ36sS)y<}4Vg?4bT%c=0R=uQK)LqfOh65BCQ(zIHr z?!oz-I}K@QH)Y!M?VW5i@+2=@hUTn1=`Avp|A9`55QdO$A}kl(#+(M@kx_;}r3dIM zBU9Sf4Hz3>+uuaVtvuZ0LPf4yLy z{Ol810k3tM?*;T5OHZ1sj=FN|84m>LA7`;d6E{GoehaRBaU+?Nu=npD=c)@vZ7kB1 z%FMVRT27z?kym=9q_rme&Vvhym`ut+6Lf>7S&b0u8$|3RZlNEtKB2dqP^Ek6r3G{J z(kx6P`Xlq2p%SR*>|!oQ1|A+i*`0rY0+(zLDBXVX+Br=Hn(DZh`XfKrqeB2 z2@8@$%?#3+T*l! z8Fzn+#*7N3eR#+Wcf+H}kGnw=X&OD7X?+G$PcN}{L zh6BqHw4Pc?%oLXDUDAy4VALRu-H-CgB;H=In3t==*rSjC%ktCE895DuZ;Ng`9lceq z%}EM(b?Mibs)ZqcbGqCsxCV7ZJj8OZQ5+>ww%orSBAH~ePw5oAX}2uY)^I`8qv8U$ ziEQy=_I94c2%uEeUlGKGz!gr&HH+#yq&?qyd_M_&aeh@hTa)o2>iiW77lkmQ11M_w z^`yPCNvq+rD86usSg)3NCi{}-!q7Q(jn4ZYB#8gQ=zUo{b6^_ zoR%Kps~jFplk+*n=mM}nS<7=@+TAg<_SKT>Fo!GhU)y)0&CSVUD0(EkfHlaT!X@w@YP_q?y=-)n5T8Tr>@Qcg-q5`XWZ@BaaC Ct(o-z From 703db7ca817315616c1125c879a7b6483b2e4938 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 12 May 2026 09:41:08 +0200 Subject: [PATCH 44/45] fix linter --- .../sparse-ngrams/benchmarks/performance.rs | 11 ++++-- crates/sparse-ngrams/src/extract.rs | 38 +++++++++++++------ crates/sparse-ngrams/src/lib.rs | 5 ++- crates/sparse-ngrams/src/ngram.rs | 8 ++-- crates/sparse-ngrams/src/table.rs | 3 +- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/crates/sparse-ngrams/benchmarks/performance.rs b/crates/sparse-ngrams/benchmarks/performance.rs index 4a24063a..ac2b2c3b 100644 --- a/crates/sparse-ngrams/benchmarks/performance.rs +++ b/crates/sparse-ngrams/benchmarks/performance.rs @@ -1,5 +1,7 @@ -use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; -use sparse_ngrams::{NGram, collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams}; +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use sparse_ngrams::{ + collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams, NGram, +}; fn bench_collect(c: &mut Criterion) { let inputs: Vec<(&str, Vec)> = vec![ @@ -10,7 +12,10 @@ fn bench_collect(c: &mut Criterion) { .repeat(20) .into_bytes(), ), - ("large_15KB", include_str!("../src/lib.rs").as_bytes().to_vec()), + ( + "large_15KB", + include_str!("../src/lib.rs").as_bytes().to_vec(), + ), ]; let mut group = c.benchmark_group("collect"); diff --git a/crates/sparse-ngrams/src/extract.rs b/crates/sparse-ngrams/src/extract.rs index bead4c56..3b55c208 100644 --- a/crates/sparse-ngrams/src/extract.rs +++ b/crates/sparse-ngrams/src/extract.rs @@ -54,8 +54,7 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; - let v1 = - table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; if let Some(begin) = queue.front() { if idx - begin.index + 1 >= MAX_SPARSE_GRAM_SIZE as u32 { @@ -65,7 +64,8 @@ pub fn collect_sparse_grams_deque(content: &[u8], out: &mut [NGram]) -> usize { while let Some(begin) = queue.back() { let start = begin.index as usize - 1; let len = (idx - begin.index + 2) as usize; - let hash = end_hash.wrapping_sub(prefix_hashes[start & mask].wrapping_mul(POLY_POWERS[len])); + let hash = + end_hash.wrapping_sub(prefix_hashes[start & mask].wrapping_mul(POLY_POWERS[len])); out[w] = NGram::from_rolling_hash(hash, len); w += 1; if begin.value == v1 { @@ -115,8 +115,7 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { .wrapping_sub(prefix_hashes[(idx as usize - 1) & MASK].wrapping_mul(POLY_POWERS[2])); out[w] = NGram::from_rolling_hash(bigram_hash, 2); w += 1; - let v1 = - table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; + let v1 = table[content[idx as usize - 1] as usize * 256 + content[idx as usize] as usize]; priorities[idx as usize & MASK] = v1; let mut running_min = u16::MAX; for d in 1..=(MAX_SPARSE_GRAM_SIZE as u32 - 2) { @@ -129,7 +128,8 @@ pub fn collect_sparse_grams_scan(content: &[u8], out: &mut [NGram]) -> usize { running_min = v_p; let start = p.wrapping_sub(1) & MASK; let len = d as usize + 2; - let hash = end_hash.wrapping_sub(prefix_hashes[start].wrapping_mul(POLY_POWERS[len])); + let hash = + end_hash.wrapping_sub(prefix_hashes[start].wrapping_mul(POLY_POWERS[len])); out[w] = NGram::from_rolling_hash(hash, len); w += 1; if v_p <= v1 { @@ -178,12 +178,13 @@ mod tests { break; } let left = table[content[start] as usize * 256 + content[start + 1] as usize]; - let right = - table[content[start + len - 2] as usize * 256 + content[start + len - 1] as usize]; + let right = table + [content[start + len - 2] as usize * 256 + content[start + len - 1] as usize]; let boundary = left.max(right); // Inner bigrams: bytes [start+1,start+2], ..., [start+len-3,start+len-2] for k in 1..len - 2 { - let p = table[content[start + k] as usize * 256 + content[start + k + 1] as usize]; + let p = + table[content[start + k] as usize * 256 + content[start + k + 1] as usize]; if p <= boundary { continue 'outer; } @@ -225,7 +226,10 @@ mod tests { let grams = collect_sparse_grams(input); for gram in &grams { assert!(gram.len() >= 2, "gram too short: {gram:?}"); - assert!(gram.len() <= MAX_SPARSE_GRAM_SIZE, "gram too long: {gram:?}"); + assert!( + gram.len() <= MAX_SPARSE_GRAM_SIZE, + "gram too long: {gram:?}" + ); } } @@ -309,14 +313,24 @@ mod tests { if !only_actual.is_empty() || !only_expected.is_empty() { panic!( "mismatch on input len={}\n only in algorithm: {:?}\n only in brute force: {:?}", - input.len(), only_actual, only_expected + input.len(), + only_actual, + only_expected ); } } #[test] fn test_brute_force_small() { - for input in [b"" as &[u8], b"x", b"ab", b"abc", b"abcd", b"abcdefgh", b"abcdefghi"] { + for input in [ + b"" as &[u8], + b"x", + b"ab", + b"abc", + b"abcd", + b"abcdefgh", + b"abcdefghi", + ] { assert_matches_brute_force(input); } } diff --git a/crates/sparse-ngrams/src/lib.rs b/crates/sparse-ngrams/src/lib.rs index d10610db..aa45abcc 100644 --- a/crates/sparse-ngrams/src/lib.rs +++ b/crates/sparse-ngrams/src/lib.rs @@ -43,5 +43,6 @@ pub const NUM_FREQUENT_BIGRAMS: usize = 65534; /// Maximum length (in bytes) of a sparse n-gram. pub const MAX_SPARSE_GRAM_SIZE: usize = 8; -pub use extract::{collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams}; - +pub use extract::{ + collect_sparse_grams, collect_sparse_grams_deque, collect_sparse_grams_scan, max_sparse_grams, +}; diff --git a/crates/sparse-ngrams/src/ngram.rs b/crates/sparse-ngrams/src/ngram.rs index c221c369..4ff966b7 100644 --- a/crates/sparse-ngrams/src/ngram.rs +++ b/crates/sparse-ngrams/src/ngram.rs @@ -31,7 +31,7 @@ pub(crate) const POLY_POWERS: [u32; MAX_SPARSE_GRAM_SIZE + 1] = { /// would explode the number of ngram keys in a search dictionary. For that reason, /// we compress ngrams into an u32 which puts a more reasonable upper bound on /// the number of dictionary keys (~100 million). -/// +/// /// Note: By storing the length explicitly in the lower 8 bits, we ensure that /// only ngrams of the same length collide. This is important because there /// are exponentially more long ngrams than short ngrams. At the same time, @@ -128,9 +128,9 @@ mod tests { .wrapping_mul(POLY_HASH_PRIME) .wrapping_add(content[idx] as u32); // Check the bigram content[idx-1..idx+1] - let rolling_hash = end_hash - .wrapping_sub(prefix_hashes[(idx - 1) & (MAX_SPARSE_GRAM_SIZE - 1)] - .wrapping_mul(POLY_POWERS[2])); + let rolling_hash = end_hash.wrapping_sub( + prefix_hashes[(idx - 1) & (MAX_SPARSE_GRAM_SIZE - 1)].wrapping_mul(POLY_POWERS[2]), + ); let rolling = NGram::from_rolling_hash(rolling_hash, 2); let direct = NGram::from_bytes(&content[idx - 1..idx + 1]); assert_eq!(rolling, direct, "mismatch at idx={idx}"); diff --git a/crates/sparse-ngrams/src/table.rs b/crates/sparse-ngrams/src/table.rs index 4a125058..96c77f61 100644 --- a/crates/sparse-ngrams/src/table.rs +++ b/crates/sparse-ngrams/src/table.rs @@ -34,8 +34,7 @@ pub(crate) fn get_bigram_table() -> &'static [u16; 256 * 256] { assert_eq!(table[a as usize * 256 + b as usize], 0); // Higher-frequency bigrams get HIGHER values so they are more often // encompassed by longer grams. - table[a as usize * 256 + b as usize] = - (NUM_FREQUENT_BIGRAMS - idx) as u16; + table[a as usize * 256 + b as usize] = (NUM_FREQUENT_BIGRAMS - idx) as u16; } table }) From f7c18770d942eba8e03c746c157fb9234aebcaa6 Mon Sep 17 00:00:00 2001 From: Alexander Neubeck Date: Tue, 12 May 2026 09:49:24 +0200 Subject: [PATCH 45/45] fix npm build --- Makefile | 1 + crates/string-offsets/js/package-lock.json | 526 +++------------------ crates/string-offsets/js/package.json | 9 +- 3 files changed, 69 insertions(+), 467 deletions(-) diff --git a/Makefile b/Makefile index 1a19adb3..742e0777 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ build: .PHONY: build-js build-js: + which wasm-pack || cargo install wasm-pack npm --prefix crates/string-offsets/js install npm --prefix crates/string-offsets/js run compile diff --git a/crates/string-offsets/js/package-lock.json b/crates/string-offsets/js/package-lock.json index 9a381aba..fdc01535 100644 --- a/crates/string-offsets/js/package-lock.json +++ b/crates/string-offsets/js/package-lock.json @@ -10,8 +10,7 @@ "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", - "jest": "^29.0.0", - "wasm-pack": "^0.14.0" + "jest": "^29.0.0" } }, "node_modules/@babel/code-frame": { @@ -30,9 +29,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -187,23 +186,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -510,19 +509,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -541,9 +527,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1013,13 +999,13 @@ } }, "node_modules/@types/node": { - "version": "25.4.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", - "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.21.0" } }, "node_modules/@types/stack-utils": { @@ -1112,25 +1098,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1255,9 +1222,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", - "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1267,26 +1234,10 @@ "node": ">=6.0.0" } }, - "node_modules/binary-install": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/binary-install/-/binary-install-1.1.2.tgz", - "integrity": "sha512-ZS2cqFHPZOy4wLxvzqfQvDjCOifn+7uCPqNmYRIBM/03+yllON+4fNnsD0VJdW0p97y+E+dTRNPStWNqMBq+9g==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "dependencies": { - "axios": "^0.26.1", - "rimraf": "^3.0.2", - "tar": "^6.1.11" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1308,9 +1259,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1328,11 +1279,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -1358,20 +1309,6 @@ "dev": true, "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1393,9 +1330,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001778", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", - "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "dev": true, "funding": [ { @@ -1440,16 +1377,6 @@ "node": ">=10" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -1526,19 +1453,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1633,16 +1547,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -1663,25 +1567,10 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/electron-to-chromium": { - "version": "1.5.313", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "dev": true, "license": "ISC" }, @@ -1715,16 +1604,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1735,35 +1614,6 @@ "node": ">= 0.4" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1892,44 +1742,6 @@ "node": ">=8" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1982,31 +1794,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -2017,20 +1804,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -2066,19 +1839,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2096,39 +1856,10 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2212,13 +1943,13 @@ "license": "MIT" }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -2305,9 +2036,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -2844,9 +2575,9 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -3078,9 +2809,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -3100,16 +2831,6 @@ "tmpl": "1.0.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3131,29 +2852,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -3177,29 +2875,6 @@ "node": "*" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3222,9 +2897,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "dev": true, "license": "MIT" }, @@ -3396,9 +3071,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3473,16 +3148,6 @@ "node": ">= 6" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -3518,12 +3183,13 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -3571,23 +3237,6 @@ "node": ">=10" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3787,33 +3436,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -3873,9 +3495,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "dev": true, "license": "MIT" }, @@ -3935,20 +3557,6 @@ "makeerror": "1.0.12" } }, - "node_modules/wasm-pack": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/wasm-pack/-/wasm-pack-0.14.0.tgz", - "integrity": "sha512-7uKj+483b6ETTnuWHK3zKNB3Ca3M159tPZ5shyXxI4j7i9Lk82rL2ck/L6E9O5VMWk9JgowdtTBOSfWmGBRFtw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "binary-install": "^1.1.2" - }, - "bin": { - "wasm-pack": "run.js" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/crates/string-offsets/js/package.json b/crates/string-offsets/js/package.json index e41c2a26..26b7fa6f 100644 --- a/crates/string-offsets/js/package.json +++ b/crates/string-offsets/js/package.json @@ -28,13 +28,6 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "jest": "^29.0.0", - "wasm-pack": "^0.14.0" - }, - "overrides": { - "binary-install": { - "tar": "^7.4.3", - "axios": "^1.8.0" - } + "jest": "^29.0.0" } }