Skip to main content

datafusion_physical_optimizer/
topk_repartition.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//! Push TopK (Sort with fetch) past Hash Repartition
19//!
20//! When a `SortExec` with a fetch limit (TopK) sits above a
21//! `RepartitionExec(Hash)`, and the hash partition expressions are a prefix
22//! of the sort expressions, this rule inserts a copy of the TopK below
23//! the repartition to reduce the volume of data flowing through the shuffle.
24//!
25//! This is correct because the hash partition key being a prefix of the sort
26//! key guarantees that all rows with the same partition key end up in the same
27//! output partition. Therefore, rows that survive the final TopK after
28//! repartitioning will always survive the pre-repartition TopK as well.
29//!
30//! ## Example
31//!
32//! Before:
33//! ```text
34//! SortExec: TopK(fetch=3), expr=[a ASC, b ASC]
35//!   RepartitionExec: Hash([a], 4)
36//!     DataSourceExec
37//! ```
38//!
39//! After:
40//! ```text
41//! SortExec: TopK(fetch=3), expr=[a ASC, b ASC]
42//!   RepartitionExec: Hash([a], 4)
43//!     SortExec: TopK(fetch=3), expr=[a ASC, b ASC]
44//!       DataSourceExec
45//! ```
46
47use crate::PhysicalOptimizerRule;
48use datafusion_common::Result;
49use datafusion_common::config::ConfigOptions;
50use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode};
51use datafusion_physical_plan::execution_plan::replace_children_if_necessary;
52use std::sync::Arc;
53// CoalesceBatchesExec is deprecated on main (replaced by arrow-rs BatchCoalescer),
54// but older DataFusion versions may still insert it between SortExec and RepartitionExec.
55#[expect(deprecated)]
56use datafusion_physical_plan::coalesce_batches::CoalesceBatchesExec;
57use datafusion_physical_plan::repartition::RepartitionExec;
58use datafusion_physical_plan::sorts::sort::SortExec;
59use datafusion_physical_plan::{ExecutionPlan, Partitioning};
60
61/// A physical optimizer rule that pushes TopK (Sort with fetch) past
62/// hash repartition when the partition key is a prefix of the sort key.
63///
64/// See module-level documentation for details.
65#[derive(Debug, Clone, Default)]
66pub struct TopKRepartition;
67
68impl TopKRepartition {
69    pub fn new() -> Self {
70        Self {}
71    }
72}
73
74impl PhysicalOptimizerRule for TopKRepartition {
75    #[expect(deprecated)] // CoalesceBatchesExec: kept for older DataFusion versions
76    fn optimize(
77        &self,
78        plan: Arc<dyn ExecutionPlan>,
79        config: &ConfigOptions,
80    ) -> Result<Arc<dyn ExecutionPlan>> {
81        if !config.optimizer.enable_topk_repartition {
82            return Ok(plan);
83        }
84        plan.transform_down(|node| {
85            // Match SortExec with fetch (TopK)
86            let Some(sort_exec) = node.downcast_ref::<SortExec>() else {
87                return Ok(Transformed::no(node));
88            };
89            let Some(fetch) = sort_exec.fetch() else {
90                return Ok(Transformed::no(node));
91            };
92
93            // The child might be a CoalesceBatchesExec; look through it
94            let sort_input = sort_exec.input();
95            let (repart_parent, repart_exec) = if let Some(rp) =
96                sort_input.downcast_ref::<RepartitionExec>()
97            {
98                // found a RepartitionExec, use it
99                (None, rp)
100            } else if let Some(cb_exec) = sort_input.downcast_ref::<CoalesceBatchesExec>()
101            {
102                // There's a CoalesceBatchesExec between TopK & RepartitionExec
103                // in this case we will need to reconstruct both nodes
104                let cb_input = cb_exec.input();
105                let Some(rp) = cb_input.downcast_ref::<RepartitionExec>() else {
106                    return Ok(Transformed::no(node));
107                };
108                (Some(Arc::clone(sort_input)), rp)
109            } else {
110                return Ok(Transformed::no(node));
111            };
112
113            // Only handle Hash partitioning
114            let Partitioning::Hash(hash_exprs, num_partitions) =
115                repart_exec.partitioning()
116            else {
117                return Ok(Transformed::no(node));
118            };
119
120            let sort_exprs = sort_exec.expr();
121
122            // Check that hash expressions are a prefix of the sort expressions.
123            // Each hash expression must match the corresponding sort expression
124            // (ignoring sort options like ASC/DESC since hash doesn't care about order).
125            if hash_exprs.len() > sort_exprs.len() {
126                return Ok(Transformed::no(node));
127            }
128            for (hash_expr, sort_expr) in hash_exprs.iter().zip(sort_exprs.iter()) {
129                if !hash_expr.eq(&sort_expr.expr) {
130                    return Ok(Transformed::no(node));
131                }
132            }
133
134            // Don't push if the input to the repartition is already bounded
135            // (e.g., another TopK), as it would be redundant.
136            let repart_input = repart_exec.input();
137            if repart_input.is::<SortExec>() {
138                return Ok(Transformed::no(node));
139            }
140
141            // Insert a copy of the TopK below the repartition
142            let new_sort: Arc<dyn ExecutionPlan> = Arc::new(
143                SortExec::new(sort_exprs.clone(), Arc::clone(repart_input))
144                    .with_fetch(Some(fetch))
145                    .with_preserve_partitioning(sort_exec.preserve_partitioning()),
146            );
147
148            let new_partitioning =
149                Partitioning::Hash(hash_exprs.clone(), *num_partitions);
150            let new_repartition: Arc<dyn ExecutionPlan> =
151                Arc::new(RepartitionExec::try_new(new_sort, new_partitioning)?);
152
153            // Rebuild the tree above the repartition
154            let new_sort_input = if let Some(parent) = repart_parent {
155                replace_children_if_necessary(parent, vec![new_repartition])?
156            } else {
157                new_repartition
158            };
159
160            let new_top_sort: Arc<dyn ExecutionPlan> = Arc::new(
161                SortExec::new(sort_exprs.clone(), new_sort_input)
162                    .with_fetch(Some(fetch))
163                    .with_preserve_partitioning(sort_exec.preserve_partitioning()),
164            );
165
166            Ok(Transformed::yes(new_top_sort))
167        })
168        .data()
169    }
170
171    fn name(&self) -> &str {
172        "TopKRepartition"
173    }
174
175    fn schema_check(&self) -> bool {
176        true
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use arrow::datatypes::{DataType, Field, Schema};
184    use datafusion_physical_expr::expressions::col;
185    use datafusion_physical_expr_common::sort_expr::{LexOrdering, PhysicalSortExpr};
186    use datafusion_physical_plan::displayable;
187    use datafusion_physical_plan::test::scan_partitioned;
188    use insta::assert_snapshot;
189
190    fn schema() -> Arc<Schema> {
191        Arc::new(Schema::new(vec![
192            Field::new("a", DataType::Utf8, false),
193            Field::new("b", DataType::Int64, false),
194        ]))
195    }
196
197    fn sort_exprs(schema: &Schema) -> LexOrdering {
198        LexOrdering::new(vec![
199            PhysicalSortExpr::new_default(col("a", schema).unwrap()).asc(),
200            PhysicalSortExpr::new_default(col("b", schema).unwrap()).asc(),
201        ])
202        .unwrap()
203    }
204
205    /// TopK above Hash(a) repartition should get pushed below it,
206    /// because `a` is a prefix of the sort key `(a, b)`.
207    #[test]
208    fn topk_pushed_below_hash_repartition() {
209        let s = schema();
210        let input = scan_partitioned(1);
211        let ordering = sort_exprs(&s);
212
213        let repartition = Arc::new(
214            RepartitionExec::try_new(
215                input,
216                Partitioning::Hash(vec![col("a", &s).unwrap()], 4),
217            )
218            .unwrap(),
219        );
220
221        let sort = Arc::new(
222            SortExec::new(ordering, repartition)
223                .with_fetch(Some(3))
224                .with_preserve_partitioning(true),
225        );
226
227        let config = ConfigOptions::new();
228        let optimized = TopKRepartition::new().optimize(sort, &config).unwrap();
229
230        let display = displayable(optimized.as_ref()).indent(true).to_string();
231        assert_snapshot!(display, @r"
232        SortExec: TopK(fetch=3), expr=[a@0 ASC, b@1 ASC], preserve_partitioning=[true], sort_prefix=[a@0 ASC]
233          RepartitionExec: partitioning=Hash([a@0], 4), input_partitions=1, maintains_sort_order=true
234            SortExec: TopK(fetch=3), expr=[a@0 ASC, b@1 ASC], preserve_partitioning=[true]
235              DataSourceExec: partitions=1, partition_sizes=[1]
236        ");
237    }
238
239    /// TopK with no fetch (unbounded sort) should NOT be pushed.
240    #[test]
241    fn unbounded_sort_not_pushed() {
242        let s = schema();
243        let input = scan_partitioned(1);
244        let ordering = sort_exprs(&s);
245
246        let repartition = Arc::new(
247            RepartitionExec::try_new(
248                input,
249                Partitioning::Hash(vec![col("a", &s).unwrap()], 4),
250            )
251            .unwrap(),
252        );
253
254        let sort: Arc<dyn ExecutionPlan> = Arc::new(
255            SortExec::new(ordering, repartition).with_preserve_partitioning(true),
256        );
257
258        let config = ConfigOptions::new();
259        let optimized = TopKRepartition::new().optimize(sort, &config).unwrap();
260
261        let display = displayable(optimized.as_ref()).indent(true).to_string();
262        assert_snapshot!(display, @r"
263        SortExec: expr=[a@0 ASC, b@1 ASC], preserve_partitioning=[true]
264          RepartitionExec: partitioning=Hash([a@0], 4), input_partitions=1
265            DataSourceExec: partitions=1, partition_sizes=[1]
266        ");
267    }
268
269    /// Hash key NOT a prefix of sort key should NOT be pushed.
270    #[test]
271    fn non_prefix_hash_key_not_pushed() {
272        let s = schema();
273        let input = scan_partitioned(1);
274        let ordering = sort_exprs(&s);
275
276        // Hash by `b`, but sort by `(a, b)` - b is not a prefix
277        let repartition = Arc::new(
278            RepartitionExec::try_new(
279                input,
280                Partitioning::Hash(vec![col("b", &s).unwrap()], 4),
281            )
282            .unwrap(),
283        );
284
285        let sort: Arc<dyn ExecutionPlan> = Arc::new(
286            SortExec::new(ordering, repartition)
287                .with_fetch(Some(3))
288                .with_preserve_partitioning(true),
289        );
290
291        let config = ConfigOptions::new();
292        let optimized = TopKRepartition::new().optimize(sort, &config).unwrap();
293
294        let display = displayable(optimized.as_ref()).indent(true).to_string();
295        assert_snapshot!(display, @r"
296        SortExec: TopK(fetch=3), expr=[a@0 ASC, b@1 ASC], preserve_partitioning=[true]
297          RepartitionExec: partitioning=Hash([b@1], 4), input_partitions=1
298            DataSourceExec: partitions=1, partition_sizes=[1]
299        ");
300    }
301
302    /// TopK above CoalesceBatchesExec above Hash(a) repartition should
303    /// push through both, inserting a new TopK below the repartition.
304    #[expect(deprecated)]
305    #[test]
306    fn topk_pushed_through_coalesce_batches() {
307        let s = schema();
308        let input = scan_partitioned(1);
309        let ordering = sort_exprs(&s);
310
311        let repartition = Arc::new(
312            RepartitionExec::try_new(
313                input,
314                Partitioning::Hash(vec![col("a", &s).unwrap()], 4),
315            )
316            .unwrap(),
317        );
318
319        let coalesce: Arc<dyn ExecutionPlan> =
320            Arc::new(CoalesceBatchesExec::new(repartition, 8192));
321
322        let sort = Arc::new(
323            SortExec::new(ordering, coalesce)
324                .with_fetch(Some(3))
325                .with_preserve_partitioning(true),
326        );
327
328        let config = ConfigOptions::new();
329        let optimized = TopKRepartition::new().optimize(sort, &config).unwrap();
330
331        let display = displayable(optimized.as_ref()).indent(true).to_string();
332        assert_snapshot!(display, @r"
333        SortExec: TopK(fetch=3), expr=[a@0 ASC, b@1 ASC], preserve_partitioning=[true], sort_prefix=[a@0 ASC]
334          CoalesceBatchesExec: target_batch_size=8192
335            RepartitionExec: partitioning=Hash([a@0], 4), input_partitions=1, maintains_sort_order=true
336              SortExec: TopK(fetch=3), expr=[a@0 ASC, b@1 ASC], preserve_partitioning=[true]
337                DataSourceExec: partitions=1, partition_sizes=[1]
338        ");
339    }
340
341    /// RoundRobin repartition should NOT be pushed.
342    #[test]
343    fn round_robin_not_pushed() {
344        let s = schema();
345        let input = scan_partitioned(1);
346        let ordering = sort_exprs(&s);
347
348        let repartition = Arc::new(
349            RepartitionExec::try_new(input, Partitioning::RoundRobinBatch(4)).unwrap(),
350        );
351
352        let sort: Arc<dyn ExecutionPlan> = Arc::new(
353            SortExec::new(ordering, repartition)
354                .with_fetch(Some(3))
355                .with_preserve_partitioning(true),
356        );
357
358        let config = ConfigOptions::new();
359        let optimized = TopKRepartition::new().optimize(sort, &config).unwrap();
360
361        let display = displayable(optimized.as_ref()).indent(true).to_string();
362        assert_snapshot!(display, @r"
363        SortExec: TopK(fetch=3), expr=[a@0 ASC, b@1 ASC], preserve_partitioning=[true]
364          RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
365            DataSourceExec: partitions=1, partition_sizes=[1]
366        ");
367    }
368}