Skip to content

Commit 6647c17

Browse files
stuhoodrebasedming
authored andcommitted
perf: Lazily load in BitpackedCodec (#56)
We would like to be able to lazily load `BitpackedCodec` columns (similar to what 020bdff did for `BlockwiseLinearCodec`), because in the context of `pg_search`, immediately constructing `OwnedBytes` means copying the entire content of the column into memory. To do so, we expose some (slightly overlapped) block boundaries from `BitUnpacker`, and then lazily load each block when it is requested. Only the `get_val` function uses the cache: `get_row_ids_for_value_range` does not (yet), because it would be necessary to partition the row ids by block, and most of the time consumers using it are already loading reasonably large ranges anyway. See paradedb/paradedb#2894 for usage. There are a few 2x speedups in the benchmark suite, as well as a 1.8x speedup on a representative customer query. Unfortunately there are also some 13-19% slowdowns on aggregates: it looks like that is because aggregates use `get_vals`, for which the default implementation is to just call `get_val` in a loop. (cherry picked from commit 57d2ef8)
1 parent fdf0a79 commit 6647c17

5 files changed

Lines changed: 161 additions & 30 deletions

File tree

bitpacker/src/bitpacker.rs

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ pub struct BitUnpacker {
6969
mask: u64,
7070
}
7171

72+
pub type BlockNumber = usize;
73+
74+
// 16k
75+
const BLOCK_SIZE_MIN_POW: u8 = 14;
76+
const BLOCK_SIZE_MIN: usize = 2 << BLOCK_SIZE_MIN_POW;
77+
7278
impl BitUnpacker {
7379
/// Creates a bit unpacker, that assumes the same bitwidth for all values.
7480
///
@@ -82,6 +88,7 @@ impl BitUnpacker {
8288
} else {
8389
(1u64 << num_bits) - 1u64
8490
};
91+
8592
BitUnpacker {
8693
num_bits: u32::from(num_bits),
8794
mask,
@@ -92,10 +99,63 @@ impl BitUnpacker {
9299
self.num_bits as u8
93100
}
94101

102+
/// Calculates a block number for the given `idx`.
103+
#[inline]
104+
pub fn block_num(&self, idx: u32) -> BlockNumber {
105+
// Find the address in bits of the index.
106+
let addr_in_bits = (idx * self.num_bits) as usize;
107+
108+
// Then round down to the nearest byte.
109+
let addr_in_bytes = addr_in_bits >> 3;
110+
111+
// And compute the containing BlockNumber.
112+
addr_in_bytes >> (BLOCK_SIZE_MIN_POW + 1)
113+
}
114+
115+
/// Given a block number and dataset length, calculates a data Range for the block.
116+
pub fn block(&self, block: BlockNumber, data_len: usize) -> Range<usize> {
117+
let block_addr = block << (BLOCK_SIZE_MIN_POW + 1);
118+
// We extend the end of the block by a constant factor, so that it overlaps the next
119+
// block. That ensures that we never need to read on a block boundary.
120+
block_addr..(std::cmp::min(block_addr + BLOCK_SIZE_MIN + 8, data_len))
121+
}
122+
123+
/// Calculates the number of blocks for the given data_len.
124+
///
125+
/// Usually only called at startup to pre-allocate structures.
126+
pub fn block_count(&self, data_len: usize) -> usize {
127+
let block_count = data_len / (BLOCK_SIZE_MIN as usize);
128+
if data_len % (BLOCK_SIZE_MIN as usize) == 0 {
129+
block_count
130+
} else {
131+
block_count + 1
132+
}
133+
}
134+
135+
/// Returns a range within the data which covers the given id_range.
136+
///
137+
/// NOTE: This method is used for batch reads which bypass blocks to avoid dealing with block
138+
/// boundaries.
139+
#[inline]
140+
pub fn block_oblivious_range(&self, id_range: Range<u32>, data_len: usize) -> Range<usize> {
141+
let start_in_bits = id_range.start * self.num_bits;
142+
let start = (start_in_bits >> 3) as usize;
143+
let end_in_bits = id_range.end * self.num_bits;
144+
let end = (end_in_bits >> 3) as usize;
145+
// TODO: We fetch more than we need and then truncate.
146+
start..(std::cmp::min(end + 8, data_len))
147+
}
148+
95149
#[inline]
96150
pub fn get(&self, idx: u32, data: &[u8]) -> u64 {
151+
self.get_from_subset(idx, 0, data)
152+
}
153+
154+
/// Get the value at the given idx, which must exist within the given subset of the data.
155+
#[inline]
156+
pub fn get_from_subset(&self, idx: u32, data_offset: usize, data: &[u8]) -> u64 {
97157
let addr_in_bits = idx * self.num_bits;
98-
let addr = (addr_in_bits >> 3) as usize;
158+
let addr = (addr_in_bits >> 3) as usize - data_offset;
99159
if addr + 8 > data.len() {
100160
if self.num_bits == 0 {
101161
return 0;
@@ -129,7 +189,7 @@ impl BitUnpacker {
129189
// #Panics
130190
//
131191
// This methods panics if `num_bits` is > 32.
132-
fn get_batch_u32s(&self, start_idx: u32, data: &[u8], output: &mut [u32]) {
192+
fn get_batch_u32s(&self, start_idx: u32, data_offset: usize, data: &[u8], output: &mut [u32]) {
133193
assert!(
134194
self.bit_width() <= 32,
135195
"Bitwidth must be <= 32 to use this method."
@@ -140,14 +200,14 @@ impl BitUnpacker {
140200
let end_bit_read = end_idx * self.num_bits;
141201
let end_byte_read = (end_bit_read + 7) / 8;
142202
assert!(
143-
end_byte_read as usize <= data.len(),
203+
end_byte_read as usize <= data_offset + data.len(),
144204
"Requested index is out of bounds."
145205
);
146206

147207
// Simple slow implementation of get_batch_u32s, to deal with our ramps.
148208
let get_batch_ramp = |start_idx: u32, output: &mut [u32]| {
149209
for (out, idx) in output.iter_mut().zip(start_idx..) {
150-
*out = self.get(idx, data) as u32;
210+
*out = self.get_from_subset(idx, data_offset, data) as u32;
151211
}
152212
};
153213

@@ -177,7 +237,7 @@ impl BitUnpacker {
177237
get_batch_ramp(start_idx, &mut output[..entrance_ramp_len as usize]);
178238

179239
// Highway
180-
let mut offset = (highway_start * self.num_bits) as usize / 8;
240+
let mut offset = ((highway_start * self.num_bits) as usize / 8) - data_offset;
181241
let mut output_cursor = (highway_start - start_idx) as usize;
182242
for _ in 0..num_blocks {
183243
offset += BitPacker1x.decompress(
@@ -199,31 +259,43 @@ impl BitUnpacker {
199259
id_range: Range<u32>,
200260
data: &[u8],
201261
positions: &mut Vec<u32>,
262+
) {
263+
self.get_ids_for_value_range_from_subset(range, id_range, 0, data, positions)
264+
}
265+
266+
pub fn get_ids_for_value_range_from_subset(
267+
&self,
268+
range: RangeInclusive<u64>,
269+
id_range: Range<u32>,
270+
data_offset: usize,
271+
data: &[u8],
272+
positions: &mut Vec<u32>,
202273
) {
203274
if self.bit_width() > 32 {
204-
self.get_ids_for_value_range_slow(range, id_range, data, positions)
275+
self.get_ids_for_value_range_slow(range, id_range, data_offset, data, positions)
205276
} else {
206277
if *range.start() > u32::MAX as u64 {
207278
positions.clear();
208279
return;
209280
}
210281
let range_u32 = (*range.start() as u32)..=(*range.end()).min(u32::MAX as u64) as u32;
211-
self.get_ids_for_value_range_fast(range_u32, id_range, data, positions)
282+
self.get_ids_for_value_range_fast(range_u32, id_range, data_offset, data, positions)
212283
}
213284
}
214285

215286
fn get_ids_for_value_range_slow(
216287
&self,
217288
range: RangeInclusive<u64>,
218289
id_range: Range<u32>,
290+
data_offset: usize,
219291
data: &[u8],
220292
positions: &mut Vec<u32>,
221293
) {
222294
positions.clear();
223295
for i in id_range {
224296
// If we cared we could make this branchless, but the slow implementation should rarely
225297
// kick in.
226-
let val = self.get(i, data);
298+
let val = self.get_from_subset(i, data_offset, data);
227299
if range.contains(&val) {
228300
positions.push(i);
229301
}
@@ -234,11 +306,12 @@ impl BitUnpacker {
234306
&self,
235307
value_range: RangeInclusive<u32>,
236308
id_range: Range<u32>,
309+
data_offset: usize,
237310
data: &[u8],
238311
positions: &mut Vec<u32>,
239312
) {
240313
positions.resize(id_range.len(), 0u32);
241-
self.get_batch_u32s(id_range.start, data, positions);
314+
self.get_batch_u32s(id_range.start, data_offset, data, positions);
242315
crate::filter_vec::filter_vec_in_place(value_range, id_range.start, positions)
243316
}
244317
}
@@ -329,14 +402,14 @@ mod test {
329402
fn test_get_batch_panics_over_32_bits() {
330403
let bitunpacker = BitUnpacker::new(33);
331404
let mut output: [u32; 1] = [0u32];
332-
bitunpacker.get_batch_u32s(0, &[0, 0, 0, 0, 0, 0, 0, 0], &mut output[..]);
405+
bitunpacker.get_batch_u32s(0, 0, &[0, 0, 0, 0, 0, 0, 0, 0], &mut output[..]);
333406
}
334407

335408
#[test]
336409
fn test_get_batch_limit() {
337410
let bitunpacker = BitUnpacker::new(1);
338411
let mut output: [u32; 3] = [0u32, 0u32, 0u32];
339-
bitunpacker.get_batch_u32s(8 * 4 - 3, &[0u8, 0u8, 0u8, 0u8], &mut output[..]);
412+
bitunpacker.get_batch_u32s(8 * 4 - 3, 0, &[0u8, 0u8, 0u8, 0u8], &mut output[..]);
340413
}
341414

342415
#[test]
@@ -345,7 +418,7 @@ mod test {
345418
let bitunpacker = BitUnpacker::new(1);
346419
let mut output: [u32; 3] = [0u32, 0u32, 0u32];
347420
// We are missing exactly one bit.
348-
bitunpacker.get_batch_u32s(8 * 4 - 2, &[0u8, 0u8, 0u8, 0u8], &mut output[..]);
421+
bitunpacker.get_batch_u32s(8 * 4 - 2, 0, &[0u8, 0u8, 0u8, 0u8], &mut output[..]);
349422
}
350423

351424
proptest::proptest! {
@@ -368,7 +441,7 @@ mod test {
368441
for len in [0, 1, 2, 32, 33, 34, 64] {
369442
for start_idx in 0u32..32u32 {
370443
output.resize(len, 0);
371-
bitunpacker.get_batch_u32s(start_idx, &buffer, &mut output);
444+
bitunpacker.get_batch_u32s(start_idx, 0, &buffer, &mut output);
372445
for (i, output_byte) in output.iter().enumerate() {
373446
let expected = (start_idx + i as u32) & mask;
374447
assert_eq!(*output_byte, expected);

columnar/src/column_values/stats.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ use std::io;
22
use std::io::Write;
33
use std::num::NonZeroU64;
44

5-
use common::{BinarySerializable, VInt};
5+
use common::file_slice::FileSlice;
6+
use common::{BinarySerializable, HasLen, VInt};
67

78
use crate::RowId;
89

@@ -28,9 +29,21 @@ impl ColumnStats {
2829
}
2930

3031
impl ColumnStats {
32+
/// Deserialize from the tail of the given FileSlice, and return the stats and remaining prefix
33+
/// FileSlice.
34+
pub fn deserialize_from_tail(file_slice: FileSlice) -> io::Result<(Self, FileSlice)> {
35+
// [`deserialize_with_size`] deserializes 4 variable-width encoded u64s, which
36+
// could end up being, in the worst case, 9 bytes each. this is where the 36 comes from
37+
let (stats, _) = file_slice.clone().split(36.min(file_slice.len())); // hope that's enough bytes
38+
let mut stats = stats.read_bytes()?;
39+
let (stats, stats_nbytes) = ColumnStats::deserialize_with_size(&mut stats)?;
40+
let (_, remainder) = file_slice.split(stats_nbytes);
41+
Ok((stats, remainder))
42+
}
43+
3144
/// Same as [`BinarySeerializable::deserialize`] but also returns the number of bytes
3245
/// consumed from the reader `R`
33-
pub fn deserialize_with_size<R: io::Read>(reader: &mut R) -> io::Result<(Self, usize)> {
46+
fn deserialize_with_size<R: io::Read>(reader: &mut R) -> io::Result<(Self, usize)> {
3447
let mut nbytes = 0;
3548

3649
let (min_value, len) = VInt::deserialize_with_size(reader)?;

columnar/src/column_values/u64_based/bitpacked.rs

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use std::io::{self, Write};
22
use std::num::NonZeroU64;
33
use std::ops::{Range, RangeInclusive};
4+
use std::sync::{Arc, OnceLock};
45

56
use common::file_slice::FileSlice;
6-
use common::{BinarySerializable, OwnedBytes};
7+
use common::{BinarySerializable, HasLen, OwnedBytes};
78
use fastdivide::DividerU64;
89
use tantivy_bitpacker::{BitPacker, BitUnpacker, compute_num_bits};
910

@@ -14,9 +15,40 @@ use crate::{ColumnValues, RowId};
1415
/// fast field is required.
1516
#[derive(Clone)]
1617
pub struct BitpackedReader {
17-
data: OwnedBytes,
18+
data: FileSlice,
1819
bit_unpacker: BitUnpacker,
1920
stats: ColumnStats,
21+
blocks: Arc<[OnceLock<Block>]>,
22+
}
23+
24+
impl BitpackedReader {
25+
#[inline(always)]
26+
fn unpack_val(&self, doc: u32) -> u64 {
27+
let block_num = self.bit_unpacker.block_num(doc);
28+
29+
if block_num == 0 && self.blocks.len() == 0 {
30+
return 0;
31+
}
32+
33+
let block = self.blocks[block_num].get_or_init(|| {
34+
let block_range = self.bit_unpacker.block(block_num, self.data.len());
35+
let offset = block_range.start;
36+
let data = self
37+
.data
38+
.slice(block_range)
39+
.read_bytes()
40+
.expect("Failed to read column values.");
41+
Block { offset, data }
42+
});
43+
44+
self.bit_unpacker
45+
.get_from_subset(doc, block.offset, &block.data)
46+
}
47+
}
48+
49+
struct Block {
50+
offset: usize,
51+
data: OwnedBytes,
2052
}
2153

2254
#[inline(always)]
@@ -52,8 +84,9 @@ fn transform_range_before_linear_transformation(
5284
impl ColumnValues for BitpackedReader {
5385
#[inline(always)]
5486
fn get_val(&self, doc: u32) -> u64 {
55-
self.stats.min_value + self.stats.gcd.get() * self.bit_unpacker.get(doc, &self.data)
87+
self.stats.min_value + self.stats.gcd.get() * self.unpack_val(doc)
5688
}
89+
5790
#[inline]
5891
fn min_value(&self) -> u64 {
5992
self.stats.min_value
@@ -79,10 +112,23 @@ impl ColumnValues for BitpackedReader {
79112
positions.clear();
80113
return;
81114
};
82-
self.bit_unpacker.get_ids_for_value_range(
115+
// TODO: This does not use the `self.blocks` cache, because callers are usually already
116+
// doing sequential, and fairly dense reads. Fix it to iterate over blocks if that
117+
// assumption turns out to be incorrect!
118+
let data_range = self
119+
.bit_unpacker
120+
.block_oblivious_range(doc_id_range.clone(), self.data.len());
121+
let data_offset = data_range.start;
122+
let data_subset = self
123+
.data
124+
.slice(data_range)
125+
.read_bytes()
126+
.expect("Failed to read column values.");
127+
self.bit_unpacker.get_ids_for_value_range_from_subset(
83128
transformed_range,
84129
doc_id_range,
85-
&self.data,
130+
data_offset,
131+
&data_subset,
86132
positions,
87133
);
88134
}
@@ -129,14 +175,19 @@ impl ColumnCodec for BitpackedCodec {
129175

130176
/// Opens a fast field given a file.
131177
fn load(file_slice: FileSlice) -> io::Result<Self::ColumnValues> {
132-
let mut data = file_slice.read_bytes()?;
133-
let stats = ColumnStats::deserialize(&mut data)?;
178+
let (stats, data) = ColumnStats::deserialize_from_tail(file_slice)?;
179+
134180
let num_bits = num_bits(&stats);
135181
let bit_unpacker = BitUnpacker::new(num_bits);
182+
let block_count = bit_unpacker.block_count(data.len());
136183
Ok(BitpackedReader {
137184
data,
138185
bit_unpacker,
139186
stats,
187+
blocks: (0..block_count)
188+
.into_iter()
189+
.map(|_| OnceLock::new())
190+
.collect(),
140191
})
141192
}
142193
}

columnar/src/column_values/u64_based/blockwise_linear.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,7 @@ impl ColumnCodec<u64> for BlockwiseLinearCodec {
175175
type Estimator = BlockwiseLinearEstimator;
176176

177177
fn load(file_slice: FileSlice) -> io::Result<Self::ColumnValues> {
178-
// [`ColumnStats::deserialize_with_size`] deserializes 4 variable-width encoded u64s, which
179-
// could end up being, in the worst case, 9 bytes each. this is where the 36 comes from
180-
let (stats, _) = file_slice.clone().split(36.min(file_slice.len())); // hope that's enough bytes
181-
let mut stats = stats.read_bytes()?;
182-
let (stats, stats_nbytes) = ColumnStats::deserialize_with_size(&mut stats)?;
183-
184-
let (_, body) = file_slice.split(stats_nbytes);
178+
let (stats, body) = ColumnStats::deserialize_from_tail(file_slice)?;
185179

186180
let (_, footer) = body.clone().split_from_end(4);
187181

src/fastfield/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ mod tests {
395395
.unwrap()
396396
.first_or_default_col(0);
397397
for a in 0..n {
398-
assert_eq!(col.get_val(a as u32), permutation[a]);
398+
assert_eq!(col.get_val(a as u32), permutation[a], "for doc {a}");
399399
}
400400
}
401401

0 commit comments

Comments
 (0)