Skip to main content

datafusion_physical_optimizer/
join_selection.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//! The [`JoinSelection`] rule tries to modify a given plan so that it can
19//! accommodate infinite sources and utilize statistical information (if there
20//! is any) to obtain more performant plans. To achieve the first goal, it
21//! tries to transform a non-runnable query (with the given infinite sources)
22//! into a runnable query by replacing pipeline-breaking join operations with
23//! pipeline-friendly ones. To achieve the second goal, it selects the proper
24//! `PartitionMode` and the build side using the available statistics for hash joins.
25
26use crate::PhysicalOptimizerRule;
27use crate::optimizer::{ConfigOnlyContext, PhysicalOptimizerContext};
28use datafusion_common::Statistics;
29use datafusion_common::config::ConfigOptions;
30use datafusion_common::error::Result;
31use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode};
32use datafusion_common::{JoinSide, JoinType, internal_err};
33use datafusion_expr_common::sort_properties::SortProperties;
34use datafusion_physical_expr::LexOrdering;
35use datafusion_physical_expr::expressions::Column;
36use datafusion_physical_plan::execution_plan::EmissionType;
37use datafusion_physical_plan::joins::utils::ColumnIndex;
38use datafusion_physical_plan::joins::{
39    CrossJoinExec, HashJoinExec, NestedLoopJoinExec, PartitionMode,
40    StreamJoinPartitionMode, SymmetricHashJoinExec,
41};
42use datafusion_physical_plan::operator_statistics::StatisticsRegistry;
43use datafusion_physical_plan::statistics::{StatisticsArgs, StatisticsContext};
44use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
45use std::sync::Arc;
46
47/// The [`JoinSelection`] rule tries to modify a given plan so that it can
48/// accommodate infinite sources and optimize joins in the plan according to
49/// available statistical information, if there is any.
50#[derive(Default, Debug)]
51pub struct JoinSelection {}
52
53impl JoinSelection {
54    #[expect(missing_docs)]
55    pub fn new() -> Self {
56        Self {}
57    }
58}
59
60/// Get statistics for a plan node, using the registry if available.
61fn get_stats(
62    plan: &dyn ExecutionPlan,
63    registry: Option<&StatisticsRegistry>,
64) -> Result<Arc<Statistics>> {
65    if let Some(reg) = registry {
66        reg.compute(plan)
67            .map(|s| Arc::<Statistics>::clone(s.base_arc()))
68    } else {
69        StatisticsContext::new().compute(plan, &StatisticsArgs::new())
70    }
71}
72
73// TODO: We need some performance test for Right Semi/Right Join swap to Left Semi/Left Join in case that the right side is smaller but not much smaller.
74// TODO: In PrestoSQL, the optimizer flips join sides only if one side is much smaller than the other by more than SIZE_DIFFERENCE_THRESHOLD times, by default is 8 times.
75/// Checks whether join inputs should be swapped using available statistics.
76///
77/// It follows these steps:
78/// 1. If a [`StatisticsRegistry`] is provided, use it for cross-operator estimates
79///    (e.g., intermediate join outputs that would otherwise have `Absent` statistics).
80/// 2. Compare the in-memory sizes of both sides, and place the smaller side on
81///    the left (build) side.
82/// 3. If in-memory byte sizes are unavailable, fall back to row counts.
83/// 4. Do not reorder the join if neither statistic is available, or if
84///    `datafusion.optimizer.join_reordering` is disabled.
85///
86/// Used configurations inside arg `config`
87/// - `config.optimizer.join_reordering`: allows or forbids statistics-driven join swapping
88pub(crate) fn should_swap_join_order(
89    left: &dyn ExecutionPlan,
90    right: &dyn ExecutionPlan,
91    config: &ConfigOptions,
92    registry: Option<&StatisticsRegistry>,
93) -> Result<bool> {
94    if !config.optimizer.join_reordering {
95        return Ok(false);
96    }
97
98    let left_stats = get_stats(left, registry)?;
99    let right_stats = get_stats(right, registry)?;
100
101    // First compare total_byte_size, then fall back to num_rows if byte
102    // sizes are unavailable.
103    match (
104        left_stats.total_byte_size.get_value(),
105        right_stats.total_byte_size.get_value(),
106    ) {
107        (Some(l), Some(r)) => Ok(l > r),
108        _ => match (
109            left_stats.num_rows.get_value(),
110            right_stats.num_rows.get_value(),
111        ) {
112            (Some(l), Some(r)) => Ok(l > r),
113            _ => Ok(false),
114        },
115    }
116}
117
118fn supports_collect_by_thresholds(
119    plan: &dyn ExecutionPlan,
120    threshold_byte_size: usize,
121    threshold_num_rows: usize,
122    registry: Option<&StatisticsRegistry>,
123) -> bool {
124    let Ok(stats) = get_stats(plan, registry) else {
125        return false;
126    };
127
128    // Stats use `Precision<T>` to represent stats, where `Absent` means unknown.
129    // `Exact(0)` and `Inexact(0)` are both valid stats, and we should not treat
130    // them as unknown, `Absent` will return None (this is in regards to why
131    // `!=0` is not checked)
132    if let Some(byte_size) = stats.total_byte_size.get_value() {
133        *byte_size < threshold_byte_size
134    } else if let Some(num_rows) = stats.num_rows.get_value() {
135        *num_rows < threshold_num_rows
136    } else {
137        false
138    }
139}
140
141impl PhysicalOptimizerRule for JoinSelection {
142    fn optimize(
143        &self,
144        plan: Arc<dyn ExecutionPlan>,
145        config: &ConfigOptions,
146    ) -> Result<Arc<dyn ExecutionPlan>> {
147        self.optimize_with_context(plan, &ConfigOnlyContext::new(config))
148    }
149
150    fn optimize_with_context(
151        &self,
152        plan: Arc<dyn ExecutionPlan>,
153        context: &dyn PhysicalOptimizerContext,
154    ) -> Result<Arc<dyn ExecutionPlan>> {
155        let config = context.config_options();
156        let mut default_registry = None;
157        let registry: Option<&StatisticsRegistry> =
158            if config.optimizer.use_statistics_registry {
159                Some(context.statistics_registry().unwrap_or_else(|| {
160                    default_registry
161                        .insert(StatisticsRegistry::default_with_builtin_providers())
162                }))
163            } else {
164                None
165            };
166        let subrules: Vec<Box<PipelineFixerSubrule>> = vec![
167            Box::new(hash_join_convert_symmetric_subrule),
168            Box::new(hash_join_swap_subrule),
169        ];
170        let new_plan = plan
171            .transform_up(|p| apply_subrules(p, &subrules, config))
172            .data()?;
173        new_plan
174            .transform_up(|plan| {
175                statistical_join_selection_subrule(plan, config, registry)
176            })
177            .data()
178    }
179
180    fn name(&self) -> &str {
181        "join_selection"
182    }
183
184    fn schema_check(&self) -> bool {
185        true
186    }
187}
188
189/// Tries to create a [`HashJoinExec`] in [`PartitionMode::CollectLeft`] when possible.
190///
191/// This function will first consider the given join type and check whether the
192/// `CollectLeft` mode is applicable. Otherwise, it will try to swap the join sides.
193/// When the `ignore_threshold` is false, this function will also check left
194/// and right sizes in bytes or rows.
195///
196/// Used configurations inside arg `config`
197/// - `config.optimizer.hash_join_single_partition_threshold`: byte threshold for `CollectLeft`
198/// - `config.optimizer.hash_join_single_partition_threshold_rows`: row threshold for `CollectLeft`
199/// - `config.optimizer.join_reordering`: allows or forbids input swapping
200pub(crate) fn try_collect_left(
201    hash_join: &HashJoinExec,
202    ignore_threshold: bool,
203    config: &ConfigOptions,
204    registry: Option<&StatisticsRegistry>,
205) -> Result<Option<Arc<dyn ExecutionPlan>>> {
206    let left = hash_join.left();
207    let right = hash_join.right();
208    let optimizer_config = &config.optimizer;
209
210    let left_can_collect = ignore_threshold
211        || supports_collect_by_thresholds(
212            &**left,
213            optimizer_config.hash_join_single_partition_threshold,
214            optimizer_config.hash_join_single_partition_threshold_rows,
215            registry,
216        );
217    let right_can_collect = ignore_threshold
218        || supports_collect_by_thresholds(
219            &**right,
220            optimizer_config.hash_join_single_partition_threshold,
221            optimizer_config.hash_join_single_partition_threshold_rows,
222            registry,
223        );
224
225    match (left_can_collect, right_can_collect) {
226        (true, true) => {
227            // Don't swap null-aware anti joins as they have specific side requirements
228            if hash_join.join_type().supports_swap()
229                && !hash_join.null_aware
230                && should_swap_join_order(&**left, &**right, config, registry)?
231            {
232                Ok(Some(hash_join.swap_inputs(PartitionMode::CollectLeft)?))
233            } else {
234                Ok(Some(Arc::new(
235                    hash_join
236                        .builder()
237                        .with_partition_mode(PartitionMode::CollectLeft)
238                        .build()?,
239                )))
240            }
241        }
242        (true, false) => Ok(Some(Arc::new(
243            hash_join
244                .builder()
245                .with_partition_mode(PartitionMode::CollectLeft)
246                .build()?,
247        ))),
248        (false, true) => {
249            // Don't swap null-aware anti joins as they have specific side requirements
250            if optimizer_config.join_reordering
251                && hash_join.join_type().supports_swap()
252                && !hash_join.null_aware
253            {
254                hash_join.swap_inputs(PartitionMode::CollectLeft).map(Some)
255            } else {
256                Ok(None)
257            }
258        }
259        (false, false) => Ok(None),
260    }
261}
262
263/// Creates a partitioned hash join execution plan, swapping inputs if beneficial.
264///
265/// Checks if the join order should be swapped based on the join type and input statistics.
266/// If swapping is optimal and supported, creates a swapped partitioned hash join; otherwise,
267/// creates a standard partitioned hash join.
268///
269/// Used configurations inside arg `config`
270/// - `config.optimizer.join_reordering`: allows or forbids statistics-driven join swapping
271pub(crate) fn partitioned_hash_join(
272    hash_join: &HashJoinExec,
273    config: &ConfigOptions,
274    registry: Option<&StatisticsRegistry>,
275) -> Result<Arc<dyn ExecutionPlan>> {
276    let left = hash_join.left();
277    let right = hash_join.right();
278    // Don't swap null-aware anti joins as they have specific side requirements
279    if hash_join.join_type().supports_swap()
280        && !hash_join.null_aware
281        && should_swap_join_order(&**left, &**right, config, registry)?
282    {
283        hash_join.swap_inputs(PartitionMode::Partitioned)
284    } else {
285        // Null-aware anti joins must use CollectLeft mode because they track probe-side state
286        // (probe_side_non_empty, probe_side_has_null) per-partition, but need global knowledge
287        // for correct null handling. With partitioning, a partition might not see probe rows
288        // even if the probe side is globally non-empty, leading to incorrect NULL row handling.
289        let partition_mode = if hash_join.null_aware {
290            PartitionMode::CollectLeft
291        } else {
292            PartitionMode::Partitioned
293        };
294
295        Ok(Arc::new(
296            hash_join
297                .builder()
298                .with_partition_mode(partition_mode)
299                .build()?,
300        ))
301    }
302}
303
304/// This subrule tries to modify a given plan so that it can
305/// optimize hash and cross joins in the plan according to available statistical
306/// information.
307///
308/// Used configurations inside arg `config`
309/// - `config.optimizer.hash_join_single_partition_threshold`: byte threshold for `CollectLeft`
310/// - `config.optimizer.hash_join_single_partition_threshold_rows`: row threshold for `CollectLeft`
311/// - `config.optimizer.join_reordering`: allows or forbids input swapping
312fn statistical_join_selection_subrule(
313    plan: Arc<dyn ExecutionPlan>,
314    config: &ConfigOptions,
315    registry: Option<&StatisticsRegistry>,
316) -> Result<Transformed<Arc<dyn ExecutionPlan>>> {
317    let transformed = if let Some(hash_join) = plan.downcast_ref::<HashJoinExec>() {
318        match hash_join.partition_mode() {
319            PartitionMode::Auto => try_collect_left(hash_join, false, config, registry)?
320                .map_or_else(
321                    || partitioned_hash_join(hash_join, config, registry).map(Some),
322                    |v| Ok(Some(v)),
323                )?,
324            PartitionMode::CollectLeft => {
325                try_collect_left(hash_join, true, config, registry)?.map_or_else(
326                    || partitioned_hash_join(hash_join, config, registry).map(Some),
327                    |v| Ok(Some(v)),
328                )?
329            }
330            PartitionMode::Partitioned => {
331                let left = hash_join.left();
332                let right = hash_join.right();
333                // Don't swap null-aware anti joins as they have specific side requirements
334                if hash_join.join_type().supports_swap()
335                    && !hash_join.null_aware
336                    && should_swap_join_order(&**left, &**right, config, registry)?
337                {
338                    hash_join
339                        .swap_inputs(PartitionMode::Partitioned)
340                        .map(Some)?
341                } else {
342                    None
343                }
344            }
345        }
346    } else if let Some(cross_join) = plan.downcast_ref::<CrossJoinExec>() {
347        let left = cross_join.left();
348        let right = cross_join.right();
349        if should_swap_join_order(&**left, &**right, config, registry)? {
350            cross_join.swap_inputs().map(Some)?
351        } else {
352            None
353        }
354    } else if let Some(nl_join) = plan.downcast_ref::<NestedLoopJoinExec>() {
355        let left = nl_join.left();
356        let right = nl_join.right();
357        if nl_join.join_type().supports_swap()
358            && should_swap_join_order(&**left, &**right, config, registry)?
359        {
360            nl_join.swap_inputs().map(Some)?
361        } else {
362            None
363        }
364    } else {
365        None
366    };
367
368    Ok(if let Some(transformed) = transformed {
369        Transformed::yes(transformed)
370    } else {
371        Transformed::no(plan)
372    })
373}
374
375/// Pipeline-fixing join selection subrule.
376pub type PipelineFixerSubrule =
377    dyn Fn(Arc<dyn ExecutionPlan>, &ConfigOptions) -> Result<Arc<dyn ExecutionPlan>>;
378
379/// Converts a hash join to a symmetric hash join if both its inputs are
380/// unbounded and incremental.
381///
382/// This subrule checks if a hash join can be replaced with a symmetric hash join when dealing
383/// with unbounded (infinite) inputs on both sides. This replacement avoids pipeline breaking and
384/// preserves query runnability. If the replacement is applicable, this subrule makes this change;
385/// otherwise, it leaves the input unchanged.
386///
387/// # Arguments
388/// * `input` - The current state of the pipeline, including the execution plan.
389/// * `config_options` - Configuration options that might affect the transformation logic.
390///
391/// # Returns
392/// An `Option` that contains the `Result` of the transformation. If the transformation is not applicable,
393/// it returns `None`. If applicable, it returns `Some(Ok(...))` with the modified pipeline state,
394/// or `Some(Err(...))` if an error occurs during the transformation.
395fn hash_join_convert_symmetric_subrule(
396    input: Arc<dyn ExecutionPlan>,
397    config_options: &ConfigOptions,
398) -> Result<Arc<dyn ExecutionPlan>> {
399    // Check if the current plan node is a HashJoinExec.
400    if let Some(hash_join) = input.downcast_ref::<HashJoinExec>() {
401        let left_unbounded = hash_join.left.boundedness().is_unbounded();
402        let left_incremental = matches!(
403            hash_join.left.pipeline_behavior(),
404            EmissionType::Incremental | EmissionType::Both
405        );
406        let right_unbounded = hash_join.right.boundedness().is_unbounded();
407        let right_incremental = matches!(
408            hash_join.right.pipeline_behavior(),
409            EmissionType::Incremental | EmissionType::Both
410        );
411        // Process only if both left and right sides are unbounded and incrementally emit.
412        if left_unbounded && right_unbounded & left_incremental & right_incremental {
413            // Determine the partition mode based on configuration.
414            let mode = if config_options.optimizer.repartition_joins {
415                StreamJoinPartitionMode::Partitioned
416            } else {
417                StreamJoinPartitionMode::SinglePartition
418            };
419            // A closure to determine the required sort order for each side of the join in the SymmetricHashJoinExec.
420            // This function checks if the columns involved in the filter have any specific ordering requirements.
421            // If the child nodes (left or right side of the join) already have a defined order and the columns used in the
422            // filter predicate are ordered, this function captures that ordering requirement. The identified order is then
423            // used in the SymmetricHashJoinExec to maintain bounded memory during join operations.
424            // However, if the child nodes do not have an inherent order, or if the filter columns are unordered,
425            // the function concludes that no specific order is required for the SymmetricHashJoinExec. This approach
426            // ensures that the symmetric hash join operation only imposes ordering constraints when necessary,
427            // based on the properties of the child nodes and the filter condition.
428            let determine_order = |side: JoinSide| -> Option<LexOrdering> {
429                hash_join
430                    .filter()
431                    .map(|filter| {
432                        filter.column_indices().iter().any(
433                            |ColumnIndex {
434                                 index,
435                                 side: column_side,
436                             }| {
437                                // Skip if column side does not match the join side.
438                                if *column_side != side {
439                                    return false;
440                                }
441                                // Retrieve equivalence properties and schema based on the side.
442                                let (equivalence, schema) = match side {
443                                    JoinSide::Left => (
444                                        hash_join.left().equivalence_properties(),
445                                        hash_join.left().schema(),
446                                    ),
447                                    JoinSide::Right => (
448                                        hash_join.right().equivalence_properties(),
449                                        hash_join.right().schema(),
450                                    ),
451                                    JoinSide::None => return false,
452                                };
453
454                                let name = schema.field(*index).name();
455                                let col = Arc::new(Column::new(name, *index)) as _;
456                                // Check if the column is ordered.
457                                equivalence.get_expr_properties(col).sort_properties
458                                    != SortProperties::Unordered
459                            },
460                        )
461                    })
462                    .unwrap_or(false)
463                    .then(|| {
464                        match side {
465                            JoinSide::Left => hash_join.left().output_ordering(),
466                            JoinSide::Right => hash_join.right().output_ordering(),
467                            JoinSide::None => unreachable!(),
468                        }
469                        .cloned()
470                    })
471                    .flatten()
472            };
473
474            // Determine the sort order for both left and right sides.
475            let left_order = determine_order(JoinSide::Left);
476            let right_order = determine_order(JoinSide::Right);
477
478            return SymmetricHashJoinExec::try_new(
479                Arc::clone(hash_join.left()),
480                Arc::clone(hash_join.right()),
481                hash_join.on().to_vec(),
482                hash_join.filter().cloned(),
483                hash_join.join_type(),
484                hash_join.null_equality(),
485                left_order,
486                right_order,
487                mode,
488            )
489            .map(|exec| Arc::new(exec) as _);
490        }
491    }
492    Ok(input)
493}
494
495/// This subrule will swap build/probe sides of a hash join depending on whether
496/// one of its inputs may produce an infinite stream of records. The rule ensures
497/// that the left (build) side of the hash join always operates on an input stream
498/// that will produce a finite set of records. If the left side can not be chosen
499/// to be "finite", the join sides stay the same as the original query.
500/// ```text
501/// For example, this rule makes the following transformation:
502///
503///
504///
505///           +--------------+              +--------------+
506///           |              |  unbounded   |              |
507///    Left   | Infinite     |    true      | Hash         |\true
508///           | Data source  |--------------| Repartition  | \   +--------------+       +--------------+
509///           |              |              |              |  \  |              |       |              |
510///           +--------------+              +--------------+   - |  Hash Join   |-------| Projection   |
511///                                                            - |              |       |              |
512///           +--------------+              +--------------+  /  +--------------+       +--------------+
513///           |              |  unbounded   |              | /
514///    Right  | Finite       |    false     | Hash         |/false
515///           | Data Source  |--------------| Repartition  |
516///           |              |              |              |
517///           +--------------+              +--------------+
518///
519///
520///
521///           +--------------+              +--------------+
522///           |              |  unbounded   |              |
523///    Left   | Finite       |    false     | Hash         |\false
524///           | Data source  |--------------| Repartition  | \   +--------------+       +--------------+
525///           |              |              |              |  \  |              | true  |              | true
526///           +--------------+              +--------------+   - |  Hash Join   |-------| Projection   |-----
527///                                                            - |              |       |              |
528///           +--------------+              +--------------+  /  +--------------+       +--------------+
529///           |              |  unbounded   |              | /
530///    Right  | Infinite     |    true      | Hash         |/true
531///           | Data Source  |--------------| Repartition  |
532///           |              |              |              |
533///           +--------------+              +--------------+
534/// ```
535pub fn hash_join_swap_subrule(
536    mut input: Arc<dyn ExecutionPlan>,
537    _config_options: &ConfigOptions,
538) -> Result<Arc<dyn ExecutionPlan>> {
539    if let Some(hash_join) = input.downcast_ref::<HashJoinExec>()
540        && hash_join.left.boundedness().is_unbounded()
541        && !hash_join.right.boundedness().is_unbounded()
542        && !hash_join.null_aware // Don't swap null-aware anti joins
543        && matches!(
544            *hash_join.join_type(),
545            JoinType::Inner | JoinType::Left | JoinType::LeftSemi | JoinType::LeftAnti
546        )
547    {
548        input = swap_join_according_to_unboundedness(hash_join)?;
549    }
550    Ok(input)
551}
552
553/// This function swaps sides of a hash join to make it runnable even if one of
554/// its inputs are infinite. Note that this is not always possible; i.e.
555/// [`JoinType::Full`], [`JoinType::Right`], [`JoinType::RightAnti`] and
556/// [`JoinType::RightSemi`] can not run with an unbounded left side, even if
557/// we swap join sides. Therefore, we do not consider them here.
558/// This function is crate public as it is useful for downstream projects
559/// to implement, or experiment with, their own join selection rules.
560pub(crate) fn swap_join_according_to_unboundedness(
561    hash_join: &HashJoinExec,
562) -> Result<Arc<dyn ExecutionPlan>> {
563    let partition_mode = hash_join.partition_mode();
564    let join_type = hash_join.join_type();
565    match (*partition_mode, *join_type) {
566        (
567            _,
568            JoinType::Right
569            | JoinType::RightSemi
570            | JoinType::RightAnti
571            | JoinType::RightMark
572            | JoinType::Full,
573        ) => internal_err!("{join_type} join cannot be swapped for unbounded input."),
574        (PartitionMode::Partitioned, _) => {
575            hash_join.swap_inputs(PartitionMode::Partitioned)
576        }
577        (PartitionMode::CollectLeft, _) => {
578            hash_join.swap_inputs(PartitionMode::CollectLeft)
579        }
580        (PartitionMode::Auto, _) => {
581            // Use `PartitionMode::Partitioned` as default if `Auto` is selected.
582            hash_join.swap_inputs(PartitionMode::Partitioned)
583        }
584    }
585}
586
587/// Apply given `PipelineFixerSubrule`s to a given plan. This plan, along with
588/// auxiliary boundedness information, is in the `PipelineStatePropagator` object.
589fn apply_subrules(
590    mut input: Arc<dyn ExecutionPlan>,
591    subrules: &Vec<Box<PipelineFixerSubrule>>,
592    config_options: &ConfigOptions,
593) -> Result<Transformed<Arc<dyn ExecutionPlan>>> {
594    let original = Arc::clone(&input);
595    for subrule in subrules {
596        input = subrule(input, config_options)?;
597    }
598
599    let transformed = !Arc::ptr_eq(&original, &input);
600
601    Ok(Transformed::new_transformed(input, transformed))
602}
603
604// See tests in datafusion/core/tests/physical_optimizer