Skip to content

Commit 9704e6c

Browse files
Merge pull request #2983 from quickwit-oss/trinity.pointard/quickselect-term-agg
use select-nth instead of full sort in segment level agg top-k selection
2 parents a9733ba + 715590b commit 9704e6c

3 files changed

Lines changed: 104 additions & 24 deletions

File tree

benches/agg_bench.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ fn bench_agg(mut group: InputGroup<Index>) {
5656
register!(group, percentiles_f64);
5757
register!(group, terms_7);
5858
register!(group, terms_all_unique);
59+
register!(group, terms_all_unique_order_by_key);
5960
register!(group, terms_150_000);
6061
register!(group, terms_many_top_1000);
6162
register!(group, terms_many_order_by_term);
@@ -298,6 +299,13 @@ fn terms_all_unique(index: &Index) {
298299
execute_agg(index, agg_req);
299300
}
300301

302+
fn terms_all_unique_order_by_key(index: &Index) {
303+
let agg_req = json!({
304+
"my_texts": { "terms": { "field": "text_all_unique_terms", "order": { "_key": "asc" } } },
305+
});
306+
execute_agg(index, agg_req);
307+
}
308+
301309
fn terms_150_000(index: &Index) {
302310
let agg_req = json!({
303311
"my_texts": { "terms": { "field": "text_many_terms" } },

src/aggregation/bucket/term_agg/mod.rs

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,11 @@ impl TermsAggregationInternal {
339339
/// TODO: Benchmark to validate the threshold
340340
pub const MAX_NUM_TERMS_FOR_VEC: u64 = 100;
341341

342+
/// Average docs-per-bucket below which term counts cluster too tightly (mostly 1s and 2s) for
343+
/// `select_nth_unstable` to beat `sort_unstable`'s adaptive paths, so we fall back to a full sort.
344+
/// This is very low on purpose, and meant to catch unique or mostly unique terms.
345+
const DOCS_PER_BUCKET_QUICKSELECT_THRESHOLD: u64 = 2;
346+
342347
/// Build a concrete `SegmentTermCollector` with either a Vec- or HashMap-backed
343348
/// bucket storage, depending on the column type and aggregation level.
344349
pub(crate) fn build_segment_term_collector(
@@ -468,6 +473,9 @@ impl Bucket {
468473

469474
/// Abstraction over the storage used for term buckets (counts only).
470475
trait TermAggregationMap: Clone + Debug + 'static {
476+
/// Whether `into_vec` returns entries already sorted by term ord, ascending.
477+
const SORTED_BY_ORD: bool;
478+
471479
/// Create a new instance with a strict upper bound on term ids.
472480
fn new(max_term_id: u64, bucket_id_provider: &mut BucketIdProvider) -> Self;
473481

@@ -567,6 +575,8 @@ struct PagedTermMap {
567575
impl PagedTermMap {}
568576

569577
impl TermAggregationMap for PagedTermMap {
578+
const SORTED_BY_ORD: bool = true;
579+
570580
#[inline]
571581
fn get_memory_consumption(&self) -> usize {
572582
self.mem_usage + std::mem::size_of::<Self>()
@@ -635,6 +645,8 @@ impl TermAggregationMap for PagedTermMap {
635645
}
636646

637647
impl TermAggregationMap for HashMapTermBuckets {
648+
const SORTED_BY_ORD: bool = false;
649+
638650
#[inline]
639651
fn get_memory_consumption(&self) -> usize {
640652
self.bucket_map.memory_consumption()
@@ -667,6 +679,8 @@ struct VecTermBucketsNoAgg {
667679
}
668680

669681
impl TermAggregationMap for VecTermBucketsNoAgg {
682+
const SORTED_BY_ORD: bool = true;
683+
670684
/// Estimate the memory consumption of this struct in bytes.
671685
fn get_memory_consumption(&self) -> usize {
672686
// We do not include `std::mem::size_of::<Self>()`
@@ -723,6 +737,8 @@ struct VecTermBuckets {
723737
}
724738

725739
impl TermAggregationMap for VecTermBuckets {
740+
const SORTED_BY_ORD: bool = true;
741+
726742
/// Estimate the memory consumption of this struct in bytes.
727743
fn get_memory_consumption(&self) -> usize {
728744
// We do not include `std::mem::size_of::<Self>()`
@@ -981,19 +997,40 @@ where
981997
) -> crate::Result<IntermediateBucketResult> {
982998
let mut entries: Vec<(u64, Bucket)> = term_buckets.into_vec();
983999

1000+
let segment_size = term_req.req.segment_size as usize;
1001+
1002+
// Total doc count over all buckets, computed is some case, and which can be reused by
1003+
// `cut_off_buckets` to derive `sum_other_doc_count` without a second pass.
1004+
let mut total_doc_count: Option<u64> = None;
1005+
1006+
// select_nth_unstable_by_key(segment_size, ...) places the (k+1)-th element at
1007+
// entries[segment_size] and guarantees entries[0..segment_size] are the top-k,
1008+
// unordered. We need this to properly compute term_doc_count_before_cutoff.
1009+
// In some cases (already sorted entries), we are faster sorting everything and
1010+
// going through sort_unstable's fast path.
9841011
match &term_req.req.order.target {
9851012
OrderTarget::Key => {
9861013
// We rely on the fact, that term ordinals match the order of the strings
9871014
// TODO: We could have a special collector, that keeps only TOP n results at any
9881015
// time.
989-
if term_req.req.order.order == Order::Desc {
990-
entries.sort_unstable_by_key(|bucket| std::cmp::Reverse(bucket.0));
991-
} else {
992-
entries.sort_unstable_by_key(|bucket| bucket.0);
1016+
if TermMap::SORTED_BY_ORD {
1017+
// `into_vec` already returned entries sorted by ord ascending, we can just
1018+
// revert if we want descending.
1019+
if term_req.req.order.order == Order::Desc {
1020+
entries.reverse();
1021+
}
1022+
} else if entries.len() > segment_size {
1023+
// Unsorted source: use select_nth
1024+
if term_req.req.order.order == Order::Desc {
1025+
entries
1026+
.select_nth_unstable_by_key(segment_size, |b| std::cmp::Reverse(b.0));
1027+
} else {
1028+
entries.select_nth_unstable_by_key(segment_size, |b| b.0);
1029+
}
9931030
}
9941031
}
9951032
OrderTarget::SubAggregation(sub_agg_path) => {
996-
// Peek segment-level metric values, sort, then fall through to
1033+
// Peek segment-level metric values, select top-k, then fall through to
9971034
// `cut_off_buckets`. Like Elasticsearch, we always cut off when ordering
9981035
// by a sub-agg: top-K results are approximate and may differ from the
9991036
// global ordering, especially for non-monotonic metrics like avg/min.
@@ -1003,7 +1040,7 @@ where
10031040
))
10041041
})?;
10051042
let (agg_name, agg_prop) = get_agg_name_and_property(sub_agg_path);
1006-
// Fetch values up-front; otherwise sort would re-compute per comparison
1043+
// Fetch values up-front; otherwise sort would re-compute per call
10071044
let mut keyed: Vec<(f64, (u64, Bucket))> = entries
10081045
.into_iter()
10091046
.map(|bucket| {
@@ -1013,28 +1050,49 @@ where
10131050
(metric_value, bucket)
10141051
})
10151052
.collect();
1016-
if term_req.req.order.order == Order::Desc {
1017-
keyed.sort_unstable_by(|a, b| {
1018-
b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)
1019-
});
1020-
} else {
1021-
keyed.sort_unstable_by(|a, b| {
1022-
a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)
1023-
});
1053+
if keyed.len() > segment_size {
1054+
// there are situations where sort_unstable might be advantageous, but
1055+
// detecting them isn't trivial, and these situation should be rare.
1056+
if term_req.req.order.order == Order::Desc {
1057+
keyed.select_nth_unstable_by(segment_size, |a, b| {
1058+
b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)
1059+
});
1060+
} else {
1061+
keyed.select_nth_unstable_by(segment_size, |a, b| {
1062+
a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)
1063+
});
1064+
}
10241065
}
10251066
entries = keyed.into_iter().map(|(_, e)| e).collect();
10261067
}
10271068
OrderTarget::Count => {
1028-
if term_req.req.order.order == Order::Desc {
1029-
entries.sort_unstable_by_key(|bucket| std::cmp::Reverse(bucket.1.count));
1030-
} else {
1031-
entries.sort_unstable_by_key(|bucket| bucket.1.count);
1069+
if entries.len() > segment_size {
1070+
// unique or near-unique fields create big runs of sorted values (ones),
1071+
// which is defavorable to quickselect. use the then faster sort_unstable.
1072+
let num_buckets = entries.len() as u64;
1073+
let num_docs = entries.iter().map(|(_, b)| b.count as u64).sum();
1074+
total_doc_count = Some(num_docs);
1075+
let many_buckets_with_same_count =
1076+
num_docs < num_buckets * DOCS_PER_BUCKET_QUICKSELECT_THRESHOLD;
1077+
if term_req.req.order.order == Order::Desc {
1078+
if many_buckets_with_same_count {
1079+
entries.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.count));
1080+
} else {
1081+
entries.select_nth_unstable_by_key(segment_size, |b| {
1082+
std::cmp::Reverse(b.1.count)
1083+
});
1084+
}
1085+
} else if many_buckets_with_same_count {
1086+
entries.sort_unstable_by_key(|b| b.1.count);
1087+
} else {
1088+
entries.select_nth_unstable_by_key(segment_size, |b| b.1.count);
1089+
}
10321090
}
10331091
}
10341092
}
10351093

10361094
let (term_doc_count_before_cutoff, sum_other_doc_count) =
1037-
cut_off_buckets(&mut entries, term_req.req.segment_size as usize);
1095+
cut_off_buckets(&mut entries, segment_size, total_doc_count);
10381096

10391097
let mut dict: FxHashMap<IntermediateKey, IntermediateTermBucketEntry> = Default::default();
10401098
dict.reserve(entries.len());
@@ -1243,19 +1301,33 @@ impl GetDocCount for (u64, Bucket) {
12431301
}
12441302
}
12451303

1304+
/// Truncates `entries` to the top `num_elem` and returns
1305+
/// `(term_doc_count_before_cutoff, sum_other_doc_count)`.
1306+
///
1307+
/// When `total_doc_count` is `Some`, `sum_other_doc_count` is derived as `total - sum(kept)`, which
1308+
/// only sums the `num_elem` kept entries instead of the (potentially far larger) cut-off tail.
12461309
pub(crate) fn cut_off_buckets<T: GetDocCount + Debug>(
12471310
entries: &mut Vec<T>,
12481311
num_elem: usize,
1312+
total_doc_count: Option<u64>,
12491313
) -> (u64, u64) {
12501314
let term_doc_count_before_cutoff = entries
12511315
.get(num_elem)
12521316
.map(|entry| entry.doc_count())
12531317
.unwrap_or(0);
12541318

1255-
let sum_other_doc_count = entries
1256-
.get(num_elem..)
1257-
.map(|cut_off_range| cut_off_range.iter().map(|entry| entry.doc_count()).sum())
1258-
.unwrap_or(0);
1319+
let sum_other_doc_count = match total_doc_count {
1320+
// Reuse the precomputed total: sum_other = total - sum(kept top-k), summing only the
1321+
// (small) kept slice. Fewer than `num_elem` buckets means nothing is cut off, so 0.
1322+
Some(total) => entries
1323+
.get(..num_elem)
1324+
.map(|kept| total - kept.iter().map(|entry| entry.doc_count()).sum::<u64>())
1325+
.unwrap_or(0),
1326+
None => entries
1327+
.get(num_elem..)
1328+
.map(|cut_off_range| cut_off_range.iter().map(|entry| entry.doc_count()).sum())
1329+
.unwrap_or(0),
1330+
};
12591331

12601332
entries.truncate(num_elem);
12611333
(term_doc_count_before_cutoff, sum_other_doc_count)

src/aggregation/intermediate_agg_result.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,7 @@ impl IntermediateTermBucketResult {
807807
// This can be interesting, as a value of quality of the results, but not good to check the
808808
// actual error count for the returned terms.
809809
let (_term_doc_count_before_cutoff, sum_other_doc_count) =
810-
cut_off_buckets(&mut buckets, req.size as usize);
810+
cut_off_buckets(&mut buckets, req.size as usize, None);
811811

812812
let doc_count_error_upper_bound = if req.show_term_doc_count_error {
813813
Some(self.doc_count_error_upper_bound)

0 commit comments

Comments
 (0)