@@ -339,6 +339,11 @@ impl TermsAggregationInternal {
339339/// TODO: Benchmark to validate the threshold
340340pub 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.
344349pub ( crate ) fn build_segment_term_collector (
@@ -468,6 +473,9 @@ impl Bucket {
468473
469474/// Abstraction over the storage used for term buckets (counts only).
470475trait 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 {
567575impl PagedTermMap { }
568576
569577impl 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
637647impl 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
669681impl 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
725739impl 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.
12461309pub ( 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)
0 commit comments