Skip to main content

datafusion_physical_optimizer/
topk_aggregation.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! An optimizer rule that detects aggregate operations that could use a limited bucket count
19
20use std::sync::Arc;
21
22use crate::PhysicalOptimizerRule;
23use datafusion_common::Result;
24use datafusion_common::config::ConfigOptions;
25use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode};
26use datafusion_physical_expr::expressions::Column;
27use datafusion_physical_plan::ExecutionPlan;
28use datafusion_physical_plan::aggregates::LimitOptions;
29use datafusion_physical_plan::aggregates::{AggregateExec, topk_types_supported};
30use datafusion_physical_plan::execution_plan::CardinalityEffect;
31use datafusion_physical_plan::projection::ProjectionExec;
32use datafusion_physical_plan::sorts::sort::SortExec;
33use itertools::Itertools;
34
35/// An optimizer rule that passes a `limit` hint to aggregations if the whole result is not needed
36#[derive(Debug)]
37pub struct TopKAggregation {}
38
39impl TopKAggregation {
40    /// Create a new `LimitAggregation`
41    pub fn new() -> Self {
42        Self {}
43    }
44
45    fn transform_agg(
46        aggr: &AggregateExec,
47        order_by: &str,
48        order_desc: bool,
49        nulls_first: bool,
50        limit: usize,
51    ) -> Option<Arc<dyn ExecutionPlan>> {
52        // Current only support single group key
53        let (group_key, group_key_alias) =
54            aggr.group_expr().expr().iter().exactly_one().ok()?;
55        let kt = group_key.data_type(&aggr.input().schema()).ok()?;
56        let vt = if let Some((field, _)) = aggr.get_minmax_desc() {
57            field.data_type().clone()
58        } else {
59            kt.clone()
60        };
61        if !topk_types_supported(&kt, &vt) {
62            return None;
63        }
64        if aggr.filter_expr().iter().any(|e| e.is_some()) {
65            return None;
66        }
67
68        // Check if this is ordering by an aggregate function (MIN/MAX)
69        if let Some((field, desc)) = aggr.get_minmax_desc() {
70            // A nullable MIN/MAX starts as NULL and becomes non-NULL when the
71            // group sees its first value. With NULLS FIRST that transition
72            // worsens the group's rank, so a bounded aggregation cannot safely
73            // discard other NULL groups. Use regular aggregation for exact
74            // results. Non-nullable inputs never take this transition and can
75            // still use TopK.
76            let input_nullable = aggr
77                .aggr_expr()
78                .iter()
79                .exactly_one()
80                .ok()?
81                .expressions()
82                .into_iter()
83                .exactly_one()
84                .ok()?
85                .nullable(aggr.input_schema.as_ref())
86                .ok()?;
87            if nulls_first && input_nullable {
88                return None;
89            }
90            // ensure the sort direction matches aggregate function
91            if desc != order_desc {
92                return None;
93            }
94            // ensure the sort is on the same field as the aggregate output
95            if order_by != field.name() {
96                return None;
97            }
98        } else if aggr.aggr_expr().is_empty() {
99            // This is a GROUP BY without aggregates, check if ordering is on the group key itself
100            if order_by != group_key_alias {
101                return None;
102            }
103        } else {
104            // Has aggregates but not MIN/MAX, or doesn't DISTINCT
105            return None;
106        }
107
108        // We found what we want: clone, copy the limit down, and return modified node
109        let new_aggr = AggregateExec::with_new_limit_options(
110            aggr,
111            Some(LimitOptions::new_with_order(limit, order_desc)),
112        );
113        Some(Arc::new(new_aggr))
114    }
115
116    fn transform_sort(plan: &Arc<dyn ExecutionPlan>) -> Option<Arc<dyn ExecutionPlan>> {
117        let sort = plan.downcast_ref::<SortExec>()?;
118
119        let children = sort.children();
120        let child = children.into_iter().exactly_one().ok()?;
121        let order = sort.properties().output_ordering()?;
122        let order = order.iter().exactly_one().ok()?;
123        let order_desc = order.options.descending;
124        let nulls_first = order.options.nulls_first;
125        let order = order.expr.downcast_ref::<Column>()?;
126        let mut cur_col_name = order.name().to_string();
127        let limit = sort.fetch()?;
128
129        let mut cardinality_preserved = true;
130        let closure = |plan: Arc<dyn ExecutionPlan>| {
131            if !cardinality_preserved {
132                return Ok(Transformed::no(plan));
133            }
134            if let Some(aggr) = plan.downcast_ref::<AggregateExec>() {
135                // either we run into an Aggregate and transform it
136                match Self::transform_agg(
137                    aggr,
138                    &cur_col_name,
139                    order_desc,
140                    nulls_first,
141                    limit,
142                ) {
143                    None => cardinality_preserved = false,
144                    Some(plan) => return Ok(Transformed::yes(plan)),
145                }
146            } else if let Some(proj) = plan.downcast_ref::<ProjectionExec>() {
147                // track renames due to successive projections
148                for proj_expr in proj.expr() {
149                    let Some(src_col) = proj_expr.expr.downcast_ref::<Column>() else {
150                        continue;
151                    };
152                    if proj_expr.alias == cur_col_name {
153                        cur_col_name = src_col.name().to_string();
154                    }
155                }
156            } else {
157                // or we continue down through types that don't reduce cardinality
158                match plan.cardinality_effect() {
159                    CardinalityEffect::Equal | CardinalityEffect::GreaterEqual => {}
160                    CardinalityEffect::Unknown | CardinalityEffect::LowerEqual => {
161                        cardinality_preserved = false;
162                    }
163                }
164            }
165            Ok(Transformed::no(plan))
166        };
167        let child = Arc::clone(child).transform_down(closure).data().ok()?;
168        let sort = SortExec::new(sort.expr().clone(), child)
169            .with_fetch(sort.fetch())
170            .with_preserve_partitioning(sort.preserve_partitioning());
171        Some(Arc::new(sort))
172    }
173}
174
175impl Default for TopKAggregation {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl PhysicalOptimizerRule for TopKAggregation {
182    fn optimize(
183        &self,
184        plan: Arc<dyn ExecutionPlan>,
185        config: &ConfigOptions,
186    ) -> Result<Arc<dyn ExecutionPlan>> {
187        if config.optimizer.enable_topk_aggregation {
188            plan.transform_down(|plan| {
189                Ok(if let Some(plan) = TopKAggregation::transform_sort(&plan) {
190                    Transformed::yes(plan)
191                } else {
192                    Transformed::no(plan)
193                })
194            })
195            .data()
196        } else {
197            Ok(plan)
198        }
199    }
200
201    fn name(&self) -> &str {
202        "LimitAggregation"
203    }
204
205    fn schema_check(&self) -> bool {
206        true
207    }
208}
209
210// see `aggregate.slt` for tests