Skip to content

Commit 6892995

Browse files
committed
10% faster intersections: Use k-ary in block search
1 parent 9704e6c commit 6892995

3 files changed

Lines changed: 100 additions & 29 deletions

File tree

src/postings/block_search.rs

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,68 @@
11
use crate::postings::compression::COMPRESSION_BLOCK_SIZE;
22

3-
/// Search the first index containing an element greater or equal to
4-
/// the target.
3+
/// Returns the index of the first element in `arr` that is greater than or
4+
/// equal to `target`.
55
///
6-
/// The results should be equivalent to
7-
/// ```compile_fail
8-
/// block[..]
9-
// .iter()
10-
// .take_while(|&&val| val < target)
11-
// .count()
6+
/// This is equivalent to:
7+
///
8+
/// ```ignore
9+
/// arr.iter().take_while(|&&val| val < target).count()
1210
/// ```
13-
///
14-
/// the `start` argument is just used to hint that the response is
15-
/// greater than beyond `start`. The implementation may or may not use
16-
/// it for optimization.
1711
///
18-
/// # Assumption
12+
/// # Assumptions
13+
///
14+
/// - `arr` is sorted in nondecreasing order. Values may be repeated; the last block is often padded
15+
/// with duplicates of its final value.
16+
/// - `target` is less than or equal to the last element in `arr`, so the result is always a valid
17+
/// index into the block.
18+
///
19+
/// # `K`
1920
///
20-
/// - The block is sorted. Some elements may appear several times. This is the case at the
21-
/// end of the last block for instance.
22-
/// - The target is assumed smaller or equal to the last element of the block.
23-
pub fn branchless_binary_search(arr: &[u32; COMPRESSION_BLOCK_SIZE], target: u32) -> usize {
24-
let mut start = 0;
25-
let mut len = arr.len();
26-
for _ in 0..7 {
27-
len /= 2;
28-
let pivot = unsafe { *arr.get_unchecked(start + len - 1) };
29-
if pivot < target {
30-
start += len;
21+
/// `K` is the branching factor. Each reduction probes `K - 1` segment-end
22+
/// pivots, keeps the matching segment, and finally linearly scans the remaining
23+
/// range. `K` must be one of `2`, `4`, `8`, `16`, `32`, or `64`.
24+
///
25+
/// The core idea vs a traditional binary search is that we can very cheaply scan blocks of
26+
/// numbers, since they are already in the CPU cache line.
27+
#[inline(always)]
28+
pub fn kary_search<const K: usize>(arr: &[u32; COMPRESSION_BLOCK_SIZE], target: u32) -> usize {
29+
const {
30+
assert!(
31+
matches!(K, 2 | 4 | 8 | 16 | 32 | 64),
32+
"K must be one of 2, 4, 8, 16, 32, or 64"
33+
);
34+
};
35+
36+
let mut base = 0usize;
37+
let mut range = COMPRESSION_BLOCK_SIZE;
38+
39+
loop {
40+
let step = range / K;
41+
if step == 0 {
42+
break;
3143
}
44+
debug_assert_eq!(range % K, 0);
45+
// Count how many segment-end pivots are < target (branchless, unrolled).
46+
let mut count = 0usize;
47+
for i in 1..K {
48+
count += (unsafe { *arr.get_unchecked(base + i * step - 1) } < target) as usize;
49+
}
50+
base += count * step;
51+
range = step;
52+
}
53+
54+
// Linear scan over the ≤K remaining elements.
55+
let mut count = 0usize;
56+
for i in 0..range {
57+
count += (unsafe { *arr.get_unchecked(base + i) } < target) as usize;
3258
}
33-
start
59+
base + count
60+
}
61+
62+
/// entry point used by postings; implemented as an 8-ary branchless search.
63+
#[inline]
64+
pub fn search_block(arr: &[u32; COMPRESSION_BLOCK_SIZE], target: u32) -> usize {
65+
kary_search::<8>(arr, target)
3466
}
3567

3668
#[cfg(test)]
@@ -39,7 +71,7 @@ mod tests {
3971

4072
use proptest::prelude::*;
4173

42-
use super::branchless_binary_search;
74+
use super::{kary_search, search_block};
4375
use crate::docset::TERMINATED;
4476
use crate::postings::compression::COMPRESSION_BLOCK_SIZE;
4577

@@ -57,7 +89,7 @@ mod tests {
5789
assert_eq!(block.len(), COMPRESSION_BLOCK_SIZE);
5890
let mut output_buffer = [TERMINATED; COMPRESSION_BLOCK_SIZE];
5991
output_buffer[..block.len()].copy_from_slice(block);
60-
assert_eq!(branchless_binary_search(&output_buffer, target), cursor);
92+
assert_eq!(search_block(&output_buffer, target), cursor);
6193
}
6294

6395
fn util_test_search_in_block_all(block: &[u32]) {
@@ -80,6 +112,45 @@ mod tests {
80112
util_test_search_in_block_all(&v[..]);
81113
}
82114

115+
#[test]
116+
fn test_search_in_branchless_binary_search_corner_cases() {
117+
let all_same = vec![7u32; COMPRESSION_BLOCK_SIZE];
118+
util_test_search_in_block_all(&all_same);
119+
120+
let repeated_across_pivots: Vec<u32> = (0..COMPRESSION_BLOCK_SIZE)
121+
.map(|i| (i / 17) as u32)
122+
.collect();
123+
util_test_search_in_block_all(&repeated_across_pivots);
124+
125+
let mut padded_last_block = vec![0u32; COMPRESSION_BLOCK_SIZE];
126+
for (i, value) in padded_last_block.iter_mut().enumerate() {
127+
*value = if i < COMPRESSION_BLOCK_SIZE / 2 {
128+
i as u32
129+
} else {
130+
TERMINATED
131+
};
132+
}
133+
util_test_search_in_block_all(&padded_last_block);
134+
}
135+
136+
#[test]
137+
fn test_kary_search_allowed_branching_factors() {
138+
let mut block = [TERMINATED; COMPRESSION_BLOCK_SIZE];
139+
for (idx, value) in block.iter_mut().enumerate() {
140+
*value = (idx / 3) as u32;
141+
}
142+
143+
for target in [0, 1, 17, block[COMPRESSION_BLOCK_SIZE - 1]] {
144+
let expected = search_in_block_trivial_but_slow(&block, target);
145+
assert_eq!(kary_search::<2>(&block, target), expected);
146+
assert_eq!(kary_search::<4>(&block, target), expected);
147+
assert_eq!(kary_search::<8>(&block, target), expected);
148+
assert_eq!(kary_search::<16>(&block, target), expected);
149+
assert_eq!(kary_search::<32>(&block, target), expected);
150+
assert_eq!(kary_search::<64>(&block, target), expected);
151+
}
152+
}
153+
83154
fn monotonous_block() -> impl Strategy<Value = Vec<u32>> {
84155
prop::collection::vec(0u32..5u32, COMPRESSION_BLOCK_SIZE).prop_map(|mut deltas| {
85156
let mut el = 0;

src/postings/compression/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ impl BlockDecoder {
158158
/// Uses the padded buffer to enable branchless search.
159159
#[inline]
160160
pub(crate) fn seek_within_block(&self, target: u32) -> usize {
161-
crate::postings::branchless_binary_search(&self.output, target)
161+
crate::postings::search_block(&self.output, target)
162162
}
163163

164164
#[inline]

src/postings/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
mod block_search;
44

5-
pub(crate) use self::block_search::branchless_binary_search;
5+
pub(crate) use self::block_search::search_block;
66

77
mod block_segment_postings;
88
pub(crate) mod compression;

0 commit comments

Comments
 (0)