Skip to content

Commit f313f22

Browse files
committed
Speed up range-query intersections via seek_danger on RangeDocSet (up to ~50x faster)
A regular seek on RangeDocSet is costly: on a miss it fetches blocks and scans the column forward to materialize the next matching doc. As a non-leading docset in an intersection that work is wasted — the driver only asks "does this candidate match?". seek_danger answers that with a cheap point lookup via Column::values_for_doc, returning a lower bound on a miss and leaving forward progress to the caller. Forward seek_danger through ConstScorer. Benchmarks (bool_queries_with_range, _all_results / DocSetCollector): ``` dense and 0.1% a a_AND_num_rand:[0_TO_9]_all_results Avg: 0.0827ms (-4.60%) Median: 0.0825ms (-4.82%) [0.0809ms .. 0.0891ms] Output: 43 a_AND_num_asc:[0_TO_9]_all_results Avg: 0.1937ms (-3.70%) Median: 0.1930ms (-3.59%) [0.1806ms .. 0.2044ms] Output: 100 a_AND_num_rand_fast:[0_TO_9]_all_results Avg: 0.0367ms (-92.67%) Median: 0.0365ms (-92.65%) [0.0340ms .. 0.0398ms] Output: 43 a_AND_num_asc_fast:[0_TO_9]_all_results Avg: 0.1052ms (-98.05%) Median: 0.1050ms (-97.98%) [0.1009ms .. 0.1117ms] Output: 100 num_rand_fast:[0_TO_9]_AND_num_asc_fast:[0_TO_9]_all_results Avg: 2.7147ms (-51.42%) Median: 2.7075ms (-49.58%) [2.6806ms .. 2.7799ms] Output: 968 dense and 1% a a_AND_num_rand:[0_TO_9]_all_results Avg: 0.4373ms (-9.71%) Median: 0.4357ms (-10.12%) [0.4117ms .. 0.4711ms] Output: 463 a_AND_num_asc:[0_TO_9]_all_results Avg: 0.2342ms (-2.50%) Median: 0.2338ms (-2.56%) [0.2247ms .. 0.2452ms] Output: 1_054 a_AND_num_rand_fast:[0_TO_9]_all_results Avg: 0.3956ms (-82.86%) Median: 0.3943ms (-82.90%) [0.3815ms .. 0.4119ms] Output: 463 a_AND_num_asc_fast:[0_TO_9]_all_results Avg: 0.4896ms (-91.16%) Median: 0.4862ms (-90.81%) [0.4797ms .. 0.5084ms] Output: 1_054 num_rand_fast:[0_TO_9]_AND_num_asc_fast:[0_TO_9]_all_results Avg: 2.7108ms (-50.81%) Median: 2.6925ms (-49.51%) [2.6688ms .. 2.7868ms] Output: 968 dense and 10% a a_AND_num_rand:[0_TO_9]_all_results Avg: 0.9869ms (-3.71%) Median: 0.9833ms (-3.83%) [0.9518ms .. 1.1218ms] Output: 4_914 a_AND_num_asc:[0_TO_9]_all_results Avg: 0.6352ms (-3.74%) Median: 0.6363ms (-3.32%) [0.6158ms .. 0.6488ms] Output: 10_152 a_AND_num_rand_fast:[0_TO_9]_all_results Avg: 3.1264ms (+0.39%) Median: 3.1466ms (+1.34%) [3.0261ms .. 3.2051ms] Output: 4_914 a_AND_num_asc_fast:[0_TO_9]_all_results Avg: 4.1547ms (-31.12%) Median: 4.0933ms (-28.55%) [3.7648ms .. 4.7600ms] Output: 10_152 num_rand_fast:[0_TO_9]_AND_num_asc_fast:[0_TO_9]_all_results Avg: 2.6973ms (-52.30%) Median: 2.6901ms (-49.86%) [2.6689ms .. 2.7677ms] Output: 968 ``` Gains are largest when the range query is the non-leading docset of a low-cardinality intersection.
1 parent 82bee54 commit f313f22

3 files changed

Lines changed: 199 additions & 27 deletions

File tree

benches/bool_queries_with_range.rs

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use binggan::{black_box, BenchGroup, BenchRunner};
22
use rand::prelude::*;
33
use rand::rngs::StdRng;
44
use rand::SeedableRng;
5-
use tantivy::collector::{Collector, Count, DocSetCollector, TopDocs};
5+
use tantivy::collector::{Collector, Count, TopDocs};
66
use tantivy::query::{Query, QueryParser};
77
use tantivy::schema::{Schema, FAST, INDEXED, TEXT};
88
use tantivy::{doc, Index, Order, ReloadPolicy, Searcher};
@@ -110,43 +110,39 @@ fn main() {
110110
// Prepare corpora with varying scenarios
111111
let scenarios = vec![
112112
(
113-
"dense and 99% a".to_string(),
114-
10_000_000,
115-
0.99,
113+
"dense and 0.1% a".to_string(),
114+
5_000_000,
115+
0.001,
116116
"dense",
117117
0,
118118
9,
119119
),
120+
("dense and 1% a".to_string(), 5_000_000, 0.01, "dense", 0, 9),
121+
("dense and 10% a".to_string(), 5_000_000, 0.1, "dense", 0, 9),
120122
(
121-
"dense and 99% a".to_string(),
122-
10_000_000,
123-
0.99,
123+
"dense and 50% a".to_string(),
124+
5_000_000,
125+
0.5,
124126
"dense",
125-
990,
126-
999,
127+
0,
128+
500,
127129
),
128130
(
129-
"sparse and 99% a".to_string(),
130-
10_000_000,
131+
"sparse and 50% a".to_string(),
132+
5_000_000,
131133
0.99,
132134
"sparse",
133135
0,
134136
9,
135137
),
136-
(
137-
"sparse and 99% a".to_string(),
138-
10_000_000,
139-
0.99,
140-
"sparse",
141-
9_999_990,
142-
9_999_999,
143-
),
144138
];
145139

146140
let mut runner = BenchRunner::new();
147-
for (scenario_id, n, p_title_a, num_rand_distribution, range_low, range_high) in scenarios {
141+
for (scenario_id, num_docs, p_title_a, num_rand_distribution, range_low, range_high) in
142+
scenarios
143+
{
148144
// Build index for this scenario
149-
let bench_index = build_shared_indices(n, p_title_a, num_rand_distribution);
145+
let bench_index = build_shared_indices(num_docs, p_title_a, num_rand_distribution);
150146

151147
// Create benchmark group
152148
let mut group = runner.new_group();
@@ -158,7 +154,7 @@ fn main() {
158154
let field_names = ["num_rand", "num_asc", "num_rand_fast", "num_asc_fast"];
159155

160156
// Define the three terms we want to test with
161-
let terms = ["a", "b", "z"];
157+
let terms = ["a"];
162158

163159
// Generate all combinations of terms and field names
164160
let mut queries = Vec::new();
@@ -202,8 +198,8 @@ fn run_benchmark_tasks(
202198
bench_group,
203199
bench_index,
204200
query_str,
205-
DocSetCollector,
206-
"all results",
201+
(Count, TopDocs::with_limit(1000).order_by_score()),
202+
"all_results",
207203
);
208204

209205
// Test top 100 by the field (if it's a FAST field)
@@ -269,6 +265,10 @@ impl<C: Collector> SearchTask<C> {
269265
.downcast_ref::<Vec<(Option<u64>, tantivy::DocAddress)>>()
270266
{
271267
top_docs.len()
268+
} else if let Some(top_docs_with_count) = (&result as &dyn std::any::Any)
269+
.downcast_ref::<(usize, Vec<(f32, tantivy::DocAddress)>)>()
270+
{
271+
top_docs_with_count.0
272272
} else if let Some(top_docs) =
273273
(&result as &dyn std::any::Any).downcast_ref::<Vec<(u64, tantivy::DocAddress)>>()
274274
{

src/query/const_score_query.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::fmt;
22

3-
use crate::docset::COLLECT_BLOCK_BUFFER_LEN;
3+
use crate::docset::{SeekDangerResult, COLLECT_BLOCK_BUFFER_LEN};
44
use crate::query::{EnableScoring, Explanation, Query, Scorer, Weight};
55
use crate::{DocId, DocSet, Score, SegmentReader, TantivyError, Term};
66

@@ -119,6 +119,10 @@ impl<TDocSet: DocSet> DocSet for ConstScorer<TDocSet> {
119119
self.docset.seek(target)
120120
}
121121

122+
fn seek_danger(&mut self, target: DocId) -> SeekDangerResult {
123+
self.docset.seek_danger(target)
124+
}
125+
122126
fn fill_buffer(&mut self, buffer: &mut [DocId; COLLECT_BLOCK_BUFFER_LEN]) -> usize {
123127
self.docset.fill_buffer(buffer)
124128
}

src/query/range_query/fast_field_range_doc_set.rs

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::ops::RangeInclusive;
33

44
use columnar::Column;
55

6+
use crate::docset::SeekDangerResult;
67
use crate::{DocId, DocSet, TERMINATED};
78

89
/// Helper to have a cursor over a vec of docids
@@ -184,6 +185,37 @@ impl<T: Send + Sync + PartialOrd + Copy + Debug + 'static> DocSet for RangeDocSe
184185
doc
185186
}
186187

188+
/// `seek_danger` only needs to answer whether `target` itself matches, so it does a cheap
189+
/// point lookup on the column instead of scanning forward to materialize the next match (the
190+
/// expensive part of a regular `seek`).
191+
fn seek_danger(&mut self, target: DocId) -> SeekDangerResult {
192+
// Covers `target == TERMINATED` and any target past the last doc: no match is possible.
193+
if target >= self.column.num_docs() {
194+
return SeekDangerResult::SeekLowerBound(TERMINATED);
195+
}
196+
197+
if self.is_last_seek_distance_large(target) {
198+
self.reset_fetch_range();
199+
}
200+
self.last_seek_pos_opt = Some(target);
201+
202+
let is_match = self
203+
.column
204+
.values_for_doc(target)
205+
.any(|value| self.value_range.contains(&value));
206+
if is_match {
207+
// Leave the docset in a valid state positioned on `target`, so `doc()` returns it and a
208+
// following `advance()` resumes the scan right after it.
209+
self.loaded_docs.get_cleared_data().push(target);
210+
self.next_fetch_start = target + 1;
211+
SeekDangerResult::Found
212+
} else {
213+
// `target` is not in the docset. The next match is strictly greater than `target`, so
214+
// `target + 1` is a valid lower bound. We may leave the docset in an invalid state.
215+
SeekDangerResult::SeekLowerBound(target + 1)
216+
}
217+
}
218+
187219
fn size_hint(&self) -> u32 {
188220
// TODO: Implement a better size hint
189221
self.column.num_docs() / 10
@@ -209,12 +241,148 @@ impl<T: Send + Sync + PartialOrd + Copy + Debug + 'static> DocSet for RangeDocSe
209241

210242
#[cfg(test)]
211243
mod tests {
212-
use std::ops::Bound;
244+
use std::ops::{Bound, RangeInclusive};
245+
246+
use columnar::Column;
213247

248+
use super::RangeDocSet;
214249
use crate::collector::Count;
215250
use crate::directory::RamDirectory;
251+
use crate::docset::{SeekDangerResult, TERMINATED};
216252
use crate::query::RangeQuery;
217-
use crate::{schema, IndexBuilder, TantivyDocument, Term};
253+
use crate::{schema, DocSet, Index, IndexBuilder, TantivyDocument, Term};
254+
255+
/// Builds a single-segment index where doc `i` carries `values_for_doc(i)` in a u64 fast
256+
/// field, then returns its column so we can drive a `RangeDocSet` directly.
257+
fn build_u64_column(
258+
num_docs: usize,
259+
values_for_doc: impl Fn(usize) -> Vec<u64>,
260+
) -> Column<u64> {
261+
let mut schema_builder = schema::SchemaBuilder::new();
262+
let value_field = schema_builder.add_u64_field("value", schema::FAST);
263+
let index = Index::create_in_ram(schema_builder.build());
264+
{
265+
let mut writer = index.writer_for_tests().unwrap();
266+
for i in 0..num_docs {
267+
let mut doc = TantivyDocument::new();
268+
for v in values_for_doc(i) {
269+
doc.add_u64(value_field, v);
270+
}
271+
writer.add_document(doc).unwrap();
272+
}
273+
writer.commit().unwrap();
274+
}
275+
let searcher = index.reader().unwrap().searcher();
276+
assert_eq!(searcher.segment_readers().len(), 1);
277+
searcher
278+
.segment_reader(0)
279+
.fast_fields()
280+
.u64("value")
281+
.unwrap()
282+
}
283+
284+
fn range_docset(
285+
value_range: RangeInclusive<u64>,
286+
num_docs: usize,
287+
values_for_doc: impl Fn(usize) -> Vec<u64>,
288+
) -> RangeDocSet<u64> {
289+
RangeDocSet::new(value_range, build_u64_column(num_docs, values_for_doc))
290+
}
291+
292+
#[test]
293+
fn seek_danger_found_leaves_valid_state() {
294+
// Even docs match the range, odd docs do not.
295+
let mut docset = range_docset(0..=0, 100, |i| vec![(i % 2) as u64]);
296+
297+
// Matching target: `Found`, and the docset is positioned exactly on it.
298+
assert_eq!(docset.seek_danger(10), SeekDangerResult::Found);
299+
assert_eq!(docset.doc(), 10);
300+
// A following advance resumes the scan right after the found doc.
301+
assert_eq!(docset.advance(), 12);
302+
assert_eq!(docset.doc(), 12);
303+
}
304+
305+
#[test]
306+
fn seek_danger_miss_returns_lower_bound() {
307+
let mut docset = range_docset(0..=0, 100, |i| vec![(i % 2) as u64]);
308+
309+
// Odd target does not match: lower bound is strictly greater than the target and never
310+
// skips past the next real match (here doc 12, the first even doc after 11).
311+
match docset.seek_danger(11) {
312+
SeekDangerResult::SeekLowerBound(lower_bound) => {
313+
assert!(lower_bound > 11);
314+
assert!(lower_bound <= 12);
315+
}
316+
SeekDangerResult::Found => panic!("11 should not match"),
317+
}
318+
// After a miss we may be in an invalid state; another seek_danger recovers it.
319+
assert_eq!(docset.seek_danger(12), SeekDangerResult::Found);
320+
assert_eq!(docset.doc(), 12);
321+
}
322+
323+
#[test]
324+
fn seek_danger_terminated_and_out_of_bounds() {
325+
let mut docset = range_docset(0..=0, 10, |i| vec![(i % 2) as u64]);
326+
assert_eq!(
327+
docset.seek_danger(TERMINATED),
328+
SeekDangerResult::SeekLowerBound(TERMINATED)
329+
);
330+
// A target past the last doc has no possible match either.
331+
assert_eq!(
332+
docset.seek_danger(10),
333+
SeekDangerResult::SeekLowerBound(TERMINATED)
334+
);
335+
}
336+
337+
#[test]
338+
fn seek_danger_multivalued() {
339+
// Doc `i` holds values [i, i+1]; the range {5} matches docs 4 and 5.
340+
let mut docset = range_docset(5..=5, 20, |i| vec![i as u64, i as u64 + 1]);
341+
342+
assert_eq!(docset.seek_danger(4), SeekDangerResult::Found);
343+
assert_eq!(docset.doc(), 4);
344+
assert_eq!(docset.advance(), 5);
345+
// No further match after doc 5.
346+
assert_eq!(docset.advance(), TERMINATED);
347+
}
348+
349+
#[test]
350+
fn seek_danger_matches_seek() {
351+
// Cross-check seek_danger against the true next match for every target, on a column with a
352+
// few sparse matches.
353+
let matches = [3u32, 7, 50, 51, 99];
354+
let num_docs = 100;
355+
let values_for_doc = |i: usize| {
356+
vec![if matches.contains(&(i as u32)) {
357+
1u64
358+
} else {
359+
0u64
360+
}]
361+
};
362+
363+
for target in 0..num_docs as u32 {
364+
// The first matching doc greater than or equal to `target`, i.e. what `seek` returns.
365+
let expected = matches
366+
.iter()
367+
.copied()
368+
.find(|&m| m >= target)
369+
.unwrap_or(TERMINATED);
370+
371+
let mut danger = range_docset(1..=1, num_docs, values_for_doc);
372+
match danger.seek_danger(target) {
373+
SeekDangerResult::Found => {
374+
assert_eq!(expected, target, "target {target} reported Found");
375+
assert_eq!(danger.doc(), target);
376+
}
377+
SeekDangerResult::SeekLowerBound(lower_bound) => {
378+
assert_ne!(expected, target, "target {target} should have been Found");
379+
assert!(lower_bound > target);
380+
// The lower bound must never skip past the true next match.
381+
assert!(lower_bound <= expected);
382+
}
383+
}
384+
}
385+
}
218386

219387
#[test]
220388
fn range_query_fast_optional_field_minimum() {

0 commit comments

Comments
 (0)