datafusion_physical_optimizer/limit_pushdown.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//! [`LimitPushdown`] pushes `LIMIT` down through `ExecutionPlan`s to reduce
19//! data transfer as much as possible.
20//!
21//! # Plan Limit Absorption
22//! In addition to pushing down `GlobalLimitExec` and `LocalLimitExec` nodes in
23//! the plan, some operators can "absorb" a limit and stop early during
24//! execution.
25//!
26//! ## Background: vectorized volcano execution model
27//! DataFusion uses a batched volcano model. For most operators, output is
28//! produced in batches of `datafusion.execution.batch_size` (default 8192), so
29//! the batch sizes typically look like:
30//! ```text
31//! 8192, 8192, ..., 8192, 100 (the final batch may be partial)
32//! ```
33//!
34//! ## Example
35//! For a join with an expensive, selective predicate:
36//! ```text
37//! GlobalLimitExec: skip=0, fetch=10
38//! -- NestedLoopJoinExec(on=expr_expensive_and_selective)
39//! --- DataSourceExec()
40//! --- DataSourceExec()
41//! ```
42//!
43//! Under this model, `NestedLoopJoinExec` would keep working until it can emit
44//! a full batch (8192 rows), even though the query only needs 10. If the limit
45//! cannot be pushed below the join, we can still embed it inside the join so it
46//! stops once the limit is satisfied. The transformed plan looks like:
47//!
48//! ```text
49//! NestedLoopJoinExec(on=expr_expensive_and_selective, fetch=10)
50//! --- DataSourceExec()
51//! --- DataSourceExec()
52//! ```
53//!
54//! ## Implementation
55//! The current optimizer rule optionally pushes `fetch` requirements into
56//! operators via [`ExecutionPlan::with_fetch`].
57//!
58//! To support early termination in operators, [`LimitedBatchCoalescer`](https://docs.rs/datafusion/latest/datafusion/physical_plan/coalesce/struct.LimitedBatchCoalescer.html)
59//! can help manage the output buffer.
60//!
61//! Reference implementation in Hash Join: <https://github.com/apache/datafusion/pull/20228>
62
63use std::fmt::Debug;
64use std::sync::Arc;
65
66use crate::PhysicalOptimizerRule;
67
68use datafusion_common::config::ConfigOptions;
69use datafusion_common::error::Result;
70use datafusion_common::stats::Precision;
71use datafusion_common::tree_node::{Transformed, TreeNodeRecursion};
72use datafusion_common::utils::combine_limit;
73use datafusion_physical_plan::coalesce_partitions::CoalescePartitionsExec;
74use datafusion_physical_plan::empty::EmptyExec;
75use datafusion_physical_plan::execution_plan::replace_children_if_necessary;
76use datafusion_physical_plan::limit::{GlobalLimitExec, LocalLimitExec};
77use datafusion_physical_plan::placeholder_row::PlaceholderRowExec;
78use datafusion_physical_plan::projection::ProjectionExec;
79use datafusion_physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
80use datafusion_physical_plan::statistics::{StatisticsArgs, StatisticsContext};
81use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
82/// This rule inspects [`ExecutionPlan`]'s and pushes down the fetch limit from
83/// the parent to the child if applicable.
84#[derive(Default, Debug)]
85pub struct LimitPushdown {}
86
87/// This is a "data class" we use within the [`LimitPushdown`] rule to push
88/// down limits in the plan. GlobalRequirements are hold as a rule-wide state
89/// and holds the fetch and skip information. The struct also has a field named
90/// satisfied which means if the "current" plan is valid in terms of limits or not.
91///
92/// For example: If the plan is satisfied with current fetch info, we decide to not add a LocalLimit
93///
94/// [`LimitPushdown`]: crate::limit_pushdown::LimitPushdown
95#[derive(Default, Clone, Debug)]
96pub struct GlobalRequirements {
97 fetch: Option<usize>,
98 skip: usize,
99 satisfied: bool,
100 preserve_order: bool,
101}
102
103impl LimitPushdown {
104 #[expect(missing_docs)]
105 pub fn new() -> Self {
106 Self {}
107 }
108}
109
110impl PhysicalOptimizerRule for LimitPushdown {
111 fn optimize(
112 &self,
113 plan: Arc<dyn ExecutionPlan>,
114 _config: &ConfigOptions,
115 ) -> Result<Arc<dyn ExecutionPlan>> {
116 let global_state = GlobalRequirements {
117 fetch: None,
118 skip: 0,
119 satisfied: false,
120 preserve_order: false,
121 };
122 pushdown_limits(plan, global_state)
123 }
124
125 fn name(&self) -> &str {
126 "LimitPushdown"
127 }
128
129 fn schema_check(&self) -> bool {
130 true
131 }
132}
133
134struct LimitInfo {
135 input: Arc<dyn ExecutionPlan>,
136 fetch: Option<usize>,
137 skip: usize,
138 preserve_order: bool,
139}
140
141/// This function is the main helper function of the `LimitPushDown` rule.
142/// The helper takes an `ExecutionPlan` and a global (algorithm) state which is
143/// an instance of `GlobalRequirements` and modifies these parameters while
144/// checking if the limits can be pushed down or not.
145///
146/// If a limit is encountered, a [`TreeNodeRecursion::Stop`] is returned. Otherwise,
147/// return a [`TreeNodeRecursion::Continue`].
148pub fn pushdown_limit_helper(
149 mut pushdown_plan: Arc<dyn ExecutionPlan>,
150 mut global_state: GlobalRequirements,
151) -> Result<(Transformed<Arc<dyn ExecutionPlan>>, GlobalRequirements)> {
152 // Extract limit, if exist, and return child inputs.
153 if let Some(limit_info) = extract_limit(&pushdown_plan) {
154 // If we have fetch/skip info in the global state already, we need to
155 // decide which one to continue with:
156 let (skip, fetch) = combine_limit(
157 global_state.skip,
158 global_state.fetch,
159 limit_info.skip,
160 limit_info.fetch,
161 );
162 global_state.skip = skip;
163 global_state.fetch = fetch;
164 global_state.preserve_order = limit_info.preserve_order;
165 global_state.satisfied = false;
166
167 if let Some(fetch) = fetch
168 && limit_satisfied_by_input(&limit_info.input, skip, fetch)?
169 {
170 // The input already produces at most `fetch` rows, so no new limit
171 // node is needed. Mark satisfied so downstream won't re-add one,
172 // but preserve skip/fetch so any nested limit nodes (e.g. an inner
173 // GlobalLimitExec) can still be merged with the outer constraint.
174 global_state.satisfied = true;
175
176 return Ok((
177 Transformed {
178 data: limit_info.input,
179 transformed: true,
180 tnr: TreeNodeRecursion::Stop,
181 },
182 global_state,
183 ));
184 }
185
186 // Now the global state has the most recent information, we can remove
187 // the limit node. We will decide later if we should add it again or
188 // not.
189 return Ok((
190 Transformed {
191 data: limit_info.input,
192 transformed: true,
193 tnr: TreeNodeRecursion::Stop,
194 },
195 global_state,
196 ));
197 }
198
199 // If we have a non-limit operator with fetch capability, update global
200 // state as necessary:
201 if pushdown_plan.fetch().is_some() {
202 if global_state.skip == 0 {
203 global_state.satisfied = true;
204 }
205 (global_state.skip, global_state.fetch) = combine_limit(
206 global_state.skip,
207 global_state.fetch,
208 0,
209 pushdown_plan.fetch(),
210 );
211 }
212
213 let Some(global_fetch) = global_state.fetch else {
214 // There's no valid fetch information, exit early:
215 return if global_state.skip > 0 && !global_state.satisfied {
216 // There might be a case with only offset, if so add a global limit:
217 global_state.satisfied = true;
218 Ok((
219 Transformed::yes(add_global_limit(
220 pushdown_plan,
221 global_state.skip,
222 None,
223 )),
224 global_state,
225 ))
226 } else {
227 // There's no info on offset or fetch, nothing to do:
228 Ok((Transformed::no(pushdown_plan), global_state))
229 };
230 };
231
232 let skip_and_fetch = Some(global_fetch + global_state.skip);
233
234 if pushdown_plan.supports_limit_pushdown() {
235 if !combines_input_partitions(&pushdown_plan) {
236 // We have information in the global state and the plan pushes down,
237 // continue:
238 Ok((Transformed::no(pushdown_plan), global_state))
239 } else if let Some(plan_with_fetch) = pushdown_plan.with_fetch(skip_and_fetch) {
240 // This plan is combining input partitions, so we need to add the
241 // fetch info to plan if possible. If not, we must add a limit node
242 // with the information from the global state.
243 let mut new_plan = plan_with_fetch;
244 // Execution plans can't (yet) handle skip, so if we have one,
245 // we still need to add a global limit
246 if global_state.skip > 0 {
247 new_plan =
248 add_global_limit(new_plan, global_state.skip, global_state.fetch);
249 }
250 global_state.fetch = skip_and_fetch;
251 global_state.skip = 0;
252 global_state.satisfied = true;
253 Ok((Transformed::yes(new_plan), global_state))
254 } else if global_state.satisfied {
255 // If the plan is already satisfied, do not add a limit:
256 Ok((Transformed::no(pushdown_plan), global_state))
257 } else {
258 global_state.satisfied = true;
259 Ok((
260 Transformed::yes(add_limit(
261 pushdown_plan,
262 global_state.skip,
263 global_fetch,
264 )),
265 global_state,
266 ))
267 }
268 } else {
269 // The plan does not support push down and it is not a limit. We will need
270 // to add a limit or a fetch. If the plan is already satisfied, we will try
271 // to add the fetch info and return the plan.
272
273 // There's no push down, change fetch & skip to default values:
274 let global_skip = global_state.skip;
275 global_state.fetch = None;
276 global_state.skip = 0;
277
278 let maybe_fetchable = pushdown_plan.with_fetch(skip_and_fetch);
279 if global_state.satisfied {
280 if let Some(plan_with_fetch) = maybe_fetchable {
281 let plan_with_preserve_order = plan_with_fetch
282 .with_preserve_order(global_state.preserve_order)
283 .unwrap_or(plan_with_fetch);
284 Ok((Transformed::yes(plan_with_preserve_order), global_state))
285 } else {
286 Ok((Transformed::no(pushdown_plan), global_state))
287 }
288 } else {
289 global_state.satisfied = true;
290 pushdown_plan = if let Some(plan_with_fetch) = maybe_fetchable {
291 let plan_with_preserve_order = plan_with_fetch
292 .with_preserve_order(global_state.preserve_order)
293 .unwrap_or(plan_with_fetch);
294
295 if global_skip > 0 {
296 add_global_limit(
297 plan_with_preserve_order,
298 global_skip,
299 Some(global_fetch),
300 )
301 } else {
302 plan_with_preserve_order
303 }
304 } else {
305 add_limit(pushdown_plan, global_skip, global_fetch)
306 };
307 Ok((Transformed::yes(pushdown_plan), global_state))
308 }
309 }
310}
311
312/// Returns true if exact input statistics prove that applying the limit would
313/// not remove any rows.
314fn limit_satisfied_by_input(
315 plan: &Arc<dyn ExecutionPlan>,
316 skip: usize,
317 fetch: usize,
318) -> Result<bool> {
319 if skip > 0 {
320 return Ok(false);
321 }
322
323 if plan.output_partitioning().partition_count() != 1 {
324 return Ok(false);
325 }
326
327 let Some(num_rows) = limit_eliminable_exact_num_rows(plan)? else {
328 return Ok(false);
329 };
330
331 Ok(num_rows <= fetch)
332}
333
334/// Returns exact row counts only from a conservative whitelist of operators
335/// whose row-count guarantees are strong enough to remove a limit.
336fn limit_eliminable_exact_num_rows(
337 plan: &Arc<dyn ExecutionPlan>,
338) -> Result<Option<usize>> {
339 // Unwrap any wrapping ProjectionExec layers; projections preserve row count
340 // but may derive statistics in ways that are not trustworthy, so we peek
341 // through them to the underlying producer.
342 let mut current = plan;
343 while let Some(projection) = current.downcast_ref::<ProjectionExec>() {
344 current = projection.input();
345 }
346
347 if current.is::<EmptyExec>() {
348 return Ok(Some(0));
349 }
350
351 if current.is::<PlaceholderRowExec>() {
352 return Ok(Some(1));
353 }
354
355 if matches!(
356 StatisticsContext::new()
357 .compute(current.as_ref(), &StatisticsArgs::new())?
358 .num_rows,
359 Precision::Exact(0)
360 ) {
361 return Ok(Some(0));
362 }
363
364 Ok(None)
365}
366
367/// Pushes down the limit through the plan.
368pub(crate) fn pushdown_limits(
369 pushdown_plan: Arc<dyn ExecutionPlan>,
370 global_state: GlobalRequirements,
371) -> Result<Arc<dyn ExecutionPlan>> {
372 // Call pushdown_limit_helper.
373 // This will either extract the limit node (returning the child), or apply the limit pushdown.
374 let (mut new_node, mut global_state) =
375 pushdown_limit_helper(pushdown_plan, global_state)?;
376
377 // While limits exist, continue combining the global_state.
378 while new_node.tnr == TreeNodeRecursion::Stop {
379 (new_node, global_state) = pushdown_limit_helper(new_node.data, global_state)?;
380 }
381
382 // Once a limit has been materialized above the current node, child
383 // subtrees should not inherit its `skip`. Keep `fetch`, but clear
384 // `skip` before recursing so child-local limits are not merged with
385 // an `OFFSET` that has already been applied.
386 if global_state.satisfied {
387 global_state.skip = 0;
388 }
389
390 // Apply pushdown limits in children
391 let children = new_node.data.children();
392 let mut changed = false;
393 let new_children = children
394 .into_iter()
395 .map(|child: &Arc<dyn ExecutionPlan>| {
396 let new_child = pushdown_limits(
397 Arc::<dyn ExecutionPlan>::clone(child),
398 global_state.clone(),
399 )?;
400 // Tracking if any of the children changed
401 changed |= !Arc::ptr_eq(child, &new_child);
402 Ok(new_child)
403 })
404 .collect::<Result<_>>()?;
405
406 if changed {
407 replace_children_if_necessary(new_node.data, new_children)
408 } else {
409 Ok(new_node.data)
410 }
411}
412
413/// Extracts limit information from the [`ExecutionPlan`] if it is a
414/// [`GlobalLimitExec`] or a [`LocalLimitExec`].
415fn extract_limit(plan: &Arc<dyn ExecutionPlan>) -> Option<LimitInfo> {
416 if let Some(global_limit) = plan.downcast_ref::<GlobalLimitExec>() {
417 Some(LimitInfo {
418 input: Arc::clone(global_limit.input()),
419 fetch: global_limit.fetch(),
420 skip: global_limit.skip(),
421 preserve_order: global_limit.required_ordering().is_some(),
422 })
423 } else {
424 plan.downcast_ref::<LocalLimitExec>()
425 .map(|local_limit| LimitInfo {
426 input: Arc::clone(local_limit.input()),
427 fetch: Some(local_limit.fetch()),
428 skip: 0,
429 preserve_order: local_limit.required_ordering().is_some(),
430 })
431 }
432}
433
434/// Checks if the given plan combines input partitions.
435fn combines_input_partitions(plan: &Arc<dyn ExecutionPlan>) -> bool {
436 plan.is::<CoalescePartitionsExec>() || plan.is::<SortPreservingMergeExec>()
437}
438
439/// Adds a limit to the plan, chooses between global and local limits based on
440/// skip value and the number of partitions.
441fn add_limit(
442 pushdown_plan: Arc<dyn ExecutionPlan>,
443 skip: usize,
444 fetch: usize,
445) -> Arc<dyn ExecutionPlan> {
446 if skip > 0 || pushdown_plan.output_partitioning().partition_count() == 1 {
447 add_global_limit(pushdown_plan, skip, Some(fetch))
448 } else {
449 Arc::new(LocalLimitExec::new(pushdown_plan, fetch + skip)) as _
450 }
451}
452
453/// Adds a global limit to the plan.
454fn add_global_limit(
455 pushdown_plan: Arc<dyn ExecutionPlan>,
456 skip: usize,
457 fetch: Option<usize>,
458) -> Arc<dyn ExecutionPlan> {
459 Arc::new(GlobalLimitExec::new(pushdown_plan, skip, fetch)) as _
460}
461
462// See tests in datafusion/core/tests/physical_optimizer