From a329cf31232aa58729328b8783225cd7060dced9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Tue, 18 Aug 2026 14:36:28 +0200 Subject: [PATCH 01/16] feat: cost-based join order enumeration in JoinSelection `JoinSelection` only made local decisions -- the build side and partition mode of one join at a time -- so the shape of the join tree stayed whatever the logical planner produced. For a query written as a flat list of relations that is a left-deep tree in `FROM`-clause order, which ignores how much each join reduces or inflates its inputs. Add a dynamic programming enumerator that flattens a subtree of reorderable joins into a graph of opaque relations plus the predicates between them, searches every connected order (bushy as well as left-deep) under a `C_out` cost model built from the same cardinality estimates the rest of the rule uses, and rebuilds the subtree only when the winner is strictly cheaper than the planner's order. Semi and anti joins take part as reducers: they are filters on their output side, so their quantified side becomes a relation that may be applied at any node covering the columns its keys reference. Non-equi join filters are re-attached at their lowest common ancestor, so a join carrying one no longer blocks reordering. Controlled by `datafusion.optimizer.join_enumeration` (default on) and `datafusion.optimizer.join_enumeration_limit`. TPC-H SF1, best of 5 interleaved runs: q18 0.67x, q7 0.72x, q2 0.87x, q21 0.87x, q8 0.94x, q9 0.94x, and nothing regressed beyond the noise floor measured on join-free control queries. All 22 queries return byte-identical results with the flag on and off. Co-Authored-By: Claude Opus 5 --- datafusion/common/src/config.rs | 15 + .../physical_optimizer/join_enumeration.rs | 470 ++++++ .../core/tests/physical_optimizer/mod.rs | 1 + .../src/join_enumeration.rs | 1278 +++++++++++++++++ .../physical-optimizer/src/join_selection.rs | 17 +- datafusion/physical-optimizer/src/lib.rs | 1 + datafusion/physical-plan/src/joins/utils.rs | 2 +- .../test_files/information_schema.slt | 4 + .../test_files/join_limit_pushdown.slt | 8 +- .../test_files/statistics_registry.slt | 38 + docs/source/user-guide/configs.md | 2 + 11 files changed, 1829 insertions(+), 7 deletions(-) create mode 100644 datafusion/core/tests/physical_optimizer/join_enumeration.rs create mode 100644 datafusion/physical-optimizer/src/join_enumeration.rs diff --git a/datafusion/common/src/config.rs b/datafusion/common/src/config.rs index f5742f09f9b08..f13e5aa5b9630 100644 --- a/datafusion/common/src/config.rs +++ b/datafusion/common/src/config.rs @@ -1672,6 +1672,21 @@ config_namespace! { /// query is used. pub join_reordering: bool, default = true + /// When set to true, the physical plan optimizer enumerates alternative + /// join orders for connected subtrees of inner hash joins and picks the + /// cheapest one from cardinality estimates, considering bushy shapes as + /// well as left-deep ones. Subtrees whose inputs lack row count + /// statistics, and subtrees whose original order is already the cheapest, + /// are left untouched. + pub join_enumeration: bool, default = true + + /// Maximum number of inputs in a join subtree for which + /// `join_enumeration` runs its exhaustive dynamic programming search. + /// Larger subtrees fall back to a greedy search. The exhaustive search + /// costs `O(3^n)` in the number of inputs, so values above 16 are + /// clamped to 16. + pub join_enumeration_limit: usize, default = 12 + /// When set to true, the physical plan optimizer uses the pluggable /// `StatisticsRegistry` for statistics propagation across operators. /// This enables more accurate cardinality estimates compared to each diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs new file mode 100644 index 0000000000000..f46cc55f7da95 --- /dev/null +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -0,0 +1,470 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Tests for the cost-based join order enumeration in [`JoinSelection`]. + +use std::sync::Arc; + +use arrow::array::{Int32Array, RecordBatch}; +use arrow::datatypes::{DataType, Field, Schema}; +use arrow::util::pretty::pretty_format_batches; +use datafusion::prelude::{SessionConfig, SessionContext}; +use datafusion_common::config::ConfigOptions; +use datafusion_common::{ + ColumnStatistics, JoinSide, JoinType, NullEquality, Result, ScalarValue, +}; +use datafusion_common::{Statistics, stats::Precision}; +use datafusion_expr::Operator; +use datafusion_physical_expr::expressions::BinaryExpr; +use datafusion_physical_expr::expressions::Column; +use datafusion_physical_optimizer::PhysicalOptimizerRule; +use datafusion_physical_optimizer::join_selection::JoinSelection; +use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; +use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode}; +use datafusion_physical_plan::{ExecutionPlan, displayable}; +use insta::assert_snapshot; + +use crate::physical_optimizer::join_selection::StatisticsExec; + +/// Statistics for a table of `rows` rows whose columns are described by +/// `(name, distinct_count)` pairs. +/// +/// Every column gets a `[0, distinct_count)` range so that the enumerator's +/// distinct-value estimate is well defined even without an explicit +/// `distinct_count`, matching what a real scan with min/max statistics offers. +fn table(rows: usize, columns: &[(&str, usize)]) -> (Statistics, Schema) { + let column_statistics = columns + .iter() + .map(|(_, distinct)| ColumnStatistics { + distinct_count: Precision::Inexact(*distinct), + min_value: Precision::Inexact(ScalarValue::Int32(Some(0))), + max_value: Precision::Inexact(ScalarValue::Int32(Some(*distinct as i32 - 1))), + ..Default::default() + }) + .collect(); + let schema = Schema::new( + columns + .iter() + .map(|(name, _)| Field::new(*name, DataType::Int32, false)) + .collect::>(), + ); + ( + Statistics { + num_rows: Precision::Inexact(rows), + total_byte_size: Precision::Absent, + column_statistics, + }, + schema, + ) +} + +fn scan(rows: usize, columns: &[(&str, usize)]) -> Arc { + let (statistics, schema) = table(rows, columns); + Arc::new(StatisticsExec::new(statistics, schema)) +} + +/// A scan with no statistics at all. +fn scan_without_statistics(columns: &[&str]) -> Arc { + let schema = Schema::new( + columns + .iter() + .map(|name| Field::new(*name, DataType::Int32, false)) + .collect::>(), + ); + let statistics = Statistics { + num_rows: Precision::Absent, + total_byte_size: Precision::Absent, + column_statistics: vec![ColumnStatistics::new_unknown(); columns.len()], + }; + Arc::new(StatisticsExec::new(statistics, schema)) +} + +fn join( + left: Arc, + right: Arc, + on: &[(&str, &str)], +) -> Result> { + join_of_type(left, right, on, JoinType::Inner, None) +} + +fn join_of_type( + left: Arc, + right: Arc, + on: &[(&str, &str)], + join_type: JoinType, + filter: Option, +) -> Result> { + let keys = on + .iter() + .map(|(left_key, right_key)| { + Ok(( + Arc::new(Column::new_with_schema(left_key, &left.schema())?) as _, + Arc::new(Column::new_with_schema(right_key, &right.schema())?) as _, + )) + }) + .collect::>>()?; + Ok(Arc::new(HashJoinExec::try_new( + left, + right, + keys, + filter, + &join_type, + None, + PartitionMode::Auto, + NullEquality::NullEqualsNothing, + false, + )?)) +} + +/// A `left_col > right_col` filter over one column of each side. +fn greater_than_filter( + left_col: (&str, usize), + right_col: (&str, usize), +) -> Result { + let schema = Schema::new(vec![ + Field::new(left_col.0, DataType::Int32, false), + Field::new(right_col.0, DataType::Int32, false), + ]); + let expression = Arc::new(BinaryExpr::new( + Arc::new(Column::new(left_col.0, 0)), + Operator::Gt, + Arc::new(Column::new(right_col.0, 1)), + )); + Ok(JoinFilter::new( + expression, + vec![ + ColumnIndex { + index: left_col.1, + side: JoinSide::Left, + }, + ColumnIndex { + index: right_col.1, + side: JoinSide::Right, + }, + ], + Arc::new(schema), + )) +} + +/// A three way join whose `FROM` order is the expensive one: joining the two +/// large tables first produces a million rows, while joining either of them with +/// the tiny table first cuts the input down to ten thousand. +fn late_reducer_plan() -> Result> { + let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); + let other = scan(1_000_000, &[("o_id", 1_000_000)]); + let types = scan(10, &[("t_type", 10)]); + + let fact_other = join(fact, other, &[("f_id", "o_id")])?; + join(fact_other, types, &[("f_type", "t_type")]) +} + +fn optimize( + plan: Arc, + config: &ConfigOptions, +) -> Result> { + JoinSelection::new().optimize(plan, config) +} + +fn formatted(plan: &Arc) -> String { + displayable(plan.as_ref()).indent(true).to_string() +} + +#[tokio::test] +async fn reorders_a_late_reducer() -> Result<()> { + let plan = late_reducer_plan()?; + // The planner's order: the two million row tables are joined first. + assert_snapshot!(formatted(&plan), @r" + HashJoinExec: mode=Auto, join_type=Inner, on=[(f_type@1, t_type@0)] + HashJoinExec: mode=Auto, join_type=Inner, on=[(f_id@0, o_id@0)] + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(10) + "); + + // Enumeration pulls the reducing join down so the two large tables are never + // joined directly, and the join projections keep the output columns in their + // original positions. + assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)], projection=[f_id@0, f_type@1, o_id@3, t_type@2] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], projection=[f_id@1, f_type@2, t_type@0] + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + "); + Ok(()) +} + +#[tokio::test] +async fn respects_the_config_flag() -> Result<()> { + let mut config = ConfigOptions::new(); + config.optimizer.join_enumeration = false; + let optimized = optimize(late_reducer_plan()?, &config)?; + // Only the local build side and partition mode decisions are made: the tiny + // table becomes the build side of the join it already sat in, but the two + // large tables are still joined first, for a million row intermediate. + assert_snapshot!(formatted(&optimized), @r" + ProjectionExec: expr=[f_id@1 as f_id, f_type@2 as f_type, o_id@3 as o_id, t_type@0 as t_type] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)] + StatisticsExec: col_count=1, row_count=Inexact(10) + HashJoinExec: mode=Partitioned, join_type=Inner, on=[(f_id@0, o_id@0)] + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + "); + Ok(()) +} + +#[tokio::test] +async fn leaves_plans_without_statistics_alone() -> Result<()> { + let fact = scan_without_statistics(&["f_id", "f_type"]); + let other = scan_without_statistics(&["o_id"]); + let types = scan_without_statistics(&["t_type"]); + let plan = join( + join(fact, other, &[("f_id", "o_id")])?, + types, + &[("f_type", "t_type")], + )?; + + let optimized = optimize(Arc::clone(&plan), &ConfigOptions::new())?; + assert_snapshot!(formatted(&optimized), @r" + HashJoinExec: mode=Partitioned, join_type=Inner, on=[(f_type@1, t_type@0)] + HashJoinExec: mode=Partitioned, join_type=Inner, on=[(f_id@0, o_id@0)] + StatisticsExec: col_count=2, row_count=Absent + StatisticsExec: col_count=1, row_count=Absent + StatisticsExec: col_count=1, row_count=Absent + "); + Ok(()) +} + +#[tokio::test] +async fn keeps_an_already_optimal_order() -> Result<()> { + // Same tables, but now written in the cheap order to begin with. The + // enumerator must not churn the plan when it cannot do better. + let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); + let other = scan(1_000_000, &[("o_id", 1_000_000)]); + let types = scan(10, &[("t_type", 10)]); + + let reduced = join(fact, types, &[("f_type", "t_type")])?; + let plan = join(reduced, other, &[("f_id", "o_id")])?; + + let mut disabled = ConfigOptions::new(); + disabled.optimizer.join_enumeration = false; + assert_eq!( + formatted(&optimize(Arc::clone(&plan), &ConfigOptions::new())?), + formatted(&optimize(plan, &disabled)?), + ); + Ok(()) +} + +/// Builds a session over four in-memory tables shaped like a small star schema. +async fn star_schema_context(join_enumeration: bool) -> Result { + let mut config = SessionConfig::new(); + config.options_mut().optimizer.join_enumeration = join_enumeration; + let ctx = SessionContext::new_with_config(config); + + let ints = |name: &str, values: Vec| -> Result { + Ok(RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new(name, DataType::Int32, false)])), + vec![Arc::new(Int32Array::from(values))], + )?) + }; + + // fact(f_id, f_type, f_region), 240 rows. + let fact = RecordBatch::try_new( + Arc::new(Schema::new(vec![ + Field::new("f_id", DataType::Int32, false), + Field::new("f_type", DataType::Int32, false), + Field::new("f_region", DataType::Int32, false), + ])), + vec![ + Arc::new(Int32Array::from((0..240).collect::>())), + Arc::new(Int32Array::from( + (0..240).map(|i| i % 12).collect::>(), + )), + Arc::new(Int32Array::from( + (0..240).map(|i| i % 5).collect::>(), + )), + ], + )?; + ctx.register_batch("fact", fact)?; + ctx.register_batch("ids", ints("o_id", (0..240).rev().collect())?)?; + ctx.register_batch("types", ints("t_type", vec![3, 7])?)?; + ctx.register_batch("regions", ints("r_region", vec![1, 2, 4])?)?; + Ok(ctx) +} + +const STAR_QUERY: &str = "select f_id, f_type, f_region \ + from fact, ids, types, regions \ + where f_id = o_id and f_type = t_type and f_region = r_region \ + order by f_id"; + +#[tokio::test] +async fn reordered_join_returns_the_same_rows() -> Result<()> { + let enumerated = star_schema_context(true).await?; + let baseline = star_schema_context(false).await?; + + let enumerated_plan = enumerated + .sql(STAR_QUERY) + .await? + .create_physical_plan() + .await?; + let baseline_plan = baseline + .sql(STAR_QUERY) + .await? + .create_physical_plan() + .await?; + // If enumeration made no difference here the comparison below proves + // nothing, so check that it did. + assert_ne!(formatted(&enumerated_plan), formatted(&baseline_plan)); + + let enumerated_rows = enumerated.sql(STAR_QUERY).await?.collect().await?; + let baseline_rows = baseline.sql(STAR_QUERY).await?.collect().await?; + assert_eq!( + pretty_format_batches(&enumerated_rows)?.to_string(), + pretty_format_batches(&baseline_rows)?.to_string(), + ); + // And that the query is not trivially empty. + assert_eq!( + enumerated_rows.iter().map(|b| b.num_rows()).sum::(), + 24 + ); + Ok(()) +} + +/// A semi join whose `EXISTS` side is highly selective, sitting above a join of +/// two large tables -- the shape TPC-H q18 has. +fn late_semi_join_plan(anti: bool) -> Result> { + let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); + let other = scan(1_000_000, &[("o_id", 1_000_000)]); + // Ten rows covering ten of the fact table's thousand types, so the semi join + // keeps one percent of its input and the anti join the other ninety-nine. + let wanted = scan(10, &[("w_type", 10)]); + + let joined = join(fact, other, &[("f_id", "o_id")])?; + let join_type = if anti { + JoinType::LeftAnti + } else { + JoinType::LeftSemi + }; + join_of_type(joined, wanted, &[("f_type", "w_type")], join_type, None) +} + +#[tokio::test] +async fn applies_a_selective_semi_join_first() -> Result<()> { + let plan = late_semi_join_plan(false)?; + assert_snapshot!(formatted(&plan), @r" + HashJoinExec: mode=Auto, join_type=LeftSemi, on=[(f_type@1, w_type@0)] + HashJoinExec: mode=Auto, join_type=Inner, on=[(f_id@0, o_id@0)] + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(10) + "); + + // The semi join becomes a `RightSemi` that filters the fact table before it + // reaches the inner join, with the ten row `EXISTS` side on the build side. + assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)] + HashJoinExec: mode=CollectLeft, join_type=RightSemi, on=[(w_type@0, f_type@1)] + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + "); + Ok(()) +} + +#[tokio::test] +async fn applies_an_anti_join_first() -> Result<()> { + let optimized = optimize(late_semi_join_plan(true)?, &ConfigOptions::new())?; + // The anti join is pushed down the same way. It keeps 99% of the fact table + // rather than 1%, so the inner join above it stays partitioned. + assert_snapshot!(formatted(&optimized), @r" + HashJoinExec: mode=Partitioned, join_type=Inner, on=[(f_id@0, o_id@0)] + HashJoinExec: mode=CollectLeft, join_type=RightAnti, on=[(w_type@0, f_type@1)] + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + "); + Ok(()) +} + +#[tokio::test] +async fn semi_join_returns_the_same_rows() -> Result<()> { + for query in [ + "select f_id, f_type from fact where exists \ + (select 1 from types where t_type = f_type) \ + and f_id in (select o_id from ids) order by f_id", + "select f_id, f_type from fact where not exists \ + (select 1 from types where t_type = f_type) \ + and f_id in (select o_id from ids) order by f_id", + ] { + let enumerated = star_schema_context(true).await?; + let baseline = star_schema_context(false).await?; + let enumerated_rows = enumerated.sql(query).await?.collect().await?; + let baseline_rows = baseline.sql(query).await?.collect().await?; + assert_eq!( + pretty_format_batches(&enumerated_rows)?.to_string(), + pretty_format_batches(&baseline_rows)?.to_string(), + "rows differ for: {query}" + ); + assert!(enumerated_rows.iter().map(|b| b.num_rows()).sum::() > 0); + } + Ok(()) +} + +#[tokio::test] +async fn moves_a_non_equi_filter_with_its_join() -> Result<()> { + // `f_type > t_type` rides on the join of fact and types, which enumeration + // moves below the join with the second large table. + let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); + let other = scan(1_000_000, &[("o_id", 1_000_000)]); + let types = scan(10, &[("t_type", 10)]); + + let joined = join(fact, other, &[("f_id", "o_id")])?; + let plan = join_of_type( + joined, + types, + &[("f_type", "t_type")], + JoinType::Inner, + Some(greater_than_filter(("f_type", 1), ("t_type", 0))?), + )?; + + // The filter is re-attached to whichever join now brings its two columns + // together, with its column indices rewritten for that join's inputs. + assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)], projection=[f_id@0, f_type@1, o_id@3, t_type@2] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], filter=f_type@0 > t_type@1, projection=[f_id@1, f_type@2, t_type@0] + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + "); + Ok(()) +} + +#[tokio::test] +async fn non_equi_filter_returns_the_same_rows() -> Result<()> { + let query = "select f_id, f_type, t_type from fact, ids, types \ + where f_id = o_id and f_type = t_type and f_id > t_type order by f_id"; + let enumerated = star_schema_context(true).await?; + let baseline = star_schema_context(false).await?; + let enumerated_rows = enumerated.sql(query).await?.collect().await?; + let baseline_rows = baseline.sql(query).await?.collect().await?; + assert_eq!( + pretty_format_batches(&enumerated_rows)?.to_string(), + pretty_format_batches(&baseline_rows)?.to_string(), + ); + assert!(enumerated_rows.iter().map(|b| b.num_rows()).sum::() > 0); + Ok(()) +} diff --git a/datafusion/core/tests/physical_optimizer/mod.rs b/datafusion/core/tests/physical_optimizer/mod.rs index f3b2884dab188..0ce5990201d15 100644 --- a/datafusion/core/tests/physical_optimizer/mod.rs +++ b/datafusion/core/tests/physical_optimizer/mod.rs @@ -26,6 +26,7 @@ mod enforce_sorting; mod enforce_sorting_monotonicity; mod ensure_requirements; mod filter_pushdown; +mod join_enumeration; mod join_selection; #[expect(clippy::needless_pass_by_value)] mod limit_pushdown; diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs new file mode 100644 index 0000000000000..ec80e621c9f0b --- /dev/null +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -0,0 +1,1278 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Cost-based join order enumeration for [`JoinSelection`]. +//! +//! [`JoinSelection`] on its own only makes *local* decisions: for a single join +//! it picks the build side and the partition mode. The shape of the join tree is +//! whatever the logical planner produced, which for a query written as a flat +//! list of relations is a left-deep tree in `FROM`-clause order. That order is +//! frequently far from the cheapest one, because it ignores how much each join +//! reduces or inflates its inputs. +//! +//! This module adds the missing global step. It +//! +//! 1. **extracts** a maximal connected subtree of reorderable joins into a +//! [`JoinGraph`] of opaque *relations* (the subtree's leaves) plus the +//! predicates between them, +//! 2. **enumerates** join trees over that graph with a dynamic programming +//! search ([`solve_dp`]) that considers bushy shapes as well as left-deep +//! ones, scoring each candidate with the cardinality model in [`CostModel`], +//! and falling back to a greedy search ([`solve_greedy`]) for graphs too +//! large to enumerate exhaustively, and +//! 3. **rebuilds** the subtree from the winning plan ([`build_tree`]), +//! re-deriving every join key and filter against the new schemas and +//! inserting join projections so intermediate results stay as narrow as they +//! were before. +//! +//! The rewrite only replaces the original subtree when the winning plan is +//! strictly cheaper than the shape the planner produced, so plans that are +//! already optimal are left untouched. +//! +//! # Why reordering is sound, and what a relation set means +//! +//! A tree of inner joins is equivalent to the cross product of its relations +//! filtered by the conjunction of all its predicates. So *any* tree that applies +//! every predicate exactly once, at a node where the columns that predicate needs +//! are available, computes the same rows. Three kinds of predicate take part: +//! +//! * **Equi-join edges** ([`Edge`]) connect two relations. Each is applied at the +//! one node whose two inputs separate its endpoints. +//! * **Non-equi join filters** ([`Filter`]) may reference any number of +//! relations. Each is applied at its lowest common ancestor: the deepest node +//! whose inputs together, but neither alone, cover everything it references. +//! * **Semi and anti joins** ([`Reducer`]) are *filters on their output side*: +//! they keep or drop rows of it and contribute no columns of their own. Their +//! quantified side therefore becomes a relation that may be applied at any node +//! covering the columns its keys reference, which is what lets a selective +//! `EXISTS` run before the joins it used to sit above. +//! +//! [`JoinSelection`]: crate::join_selection::JoinSelection + +use std::collections::HashMap; +use std::sync::Arc; + +use arrow::datatypes::{FieldRef, Schema}; +use datafusion_common::config::ConfigOptions; +use datafusion_common::error::Result; +use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode}; +use datafusion_common::{JoinSide, JoinType, NullEquality, Statistics, internal_err}; +use datafusion_expr_common::operator::Operator; +use datafusion_physical_expr::PhysicalExprRef; +use datafusion_physical_expr::expressions::{BinaryExpr, Column}; +use datafusion_physical_plan::execution_plan::replace_children_if_necessary; +use datafusion_physical_plan::joins::utils::{ + ColumnIndex, JoinFilter, max_distinct_count, +}; +use datafusion_physical_plan::joins::{HashJoinExec, HashJoinExecBuilder, PartitionMode}; +use datafusion_physical_plan::projection::{ProjectionExec, all_alias_free_columns}; +use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties}; + +/// Hard upper bound on the number of relations in one join graph. +/// +/// Relation sets are bitmasks in a `u64` and the greedy search is cubic in the +/// number of relations, so very large join graphs are left alone. +const MAX_RELATIONS: usize = 32; + +/// Hard upper bound on the relations handed to the exhaustive search, whatever +/// `join_enumeration_limit` says. +/// +/// [`solve_dp`] allocates `2^n` entries and visits `3^n` splits, so an unclamped +/// configuration value would ask for absurd amounts of memory and time. Graphs +/// above this bound use [`solve_greedy`] instead. +const MAX_DP_RELATIONS: usize = 16; + +/// Computes the statistics of a plan node. +/// +/// `JoinSelection` supplies this so the enumerator sees the same estimates as +/// the rest of the rule, including the pluggable [`StatisticsRegistry`] when that +/// is enabled. +/// +/// [`StatisticsRegistry`]: datafusion_physical_plan::operator_statistics::StatisticsRegistry +pub(crate) type StatsFn<'a> = + dyn FnMut(&dyn ExecutionPlan) -> Result> + 'a; + +/// A bitmask over relation indices. +type RelSet = u64; + +fn bit(rel: usize) -> RelSet { + 1u64 << rel +} + +/// Iterates the relation indices contained in `mask`. +fn iter_rels(mask: RelSet) -> impl Iterator { + std::iter::successors(Some(mask), |m| Some(m & m.wrapping_sub(1))) + .take_while(|m| *m != 0) + .map(|m| m.trailing_zeros() as usize) +} + +/// Whether `mask` contains every relation in `required`. +fn covers(mask: RelSet, required: RelSet) -> bool { + required & !mask == 0 +} + +/// Reference to one column of one relation of a [`JoinGraph`]. +/// +/// Column plumbing is done in terms of these rather than column indices, since +/// reordering the tree changes the index any given column sits at. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct ColRef { + /// Index into [`JoinGraph::relations`]. + rel: usize, + /// Column index within that relation's output schema. + col: usize, +} + +/// What a relation contributes to the join. +#[derive(Debug)] +enum Role { + /// An ordinary input: its rows and columns flow into the output. + Output, + /// The quantified side of a semi or anti join: it filters other relations and + /// contributes no columns. + Reducer(Reducer), +} + +/// The quantified side of a semi or anti join. +#[derive(Debug)] +struct Reducer { + /// `true` for an anti join, which keeps the rows that do *not* match. + anti: bool, + /// Join keys, as `(column of the filtered side, column index in this + /// relation)`. + keys: Vec<(ColRef, usize)>, + /// The relations the keys reference. This reducer can only be applied to a + /// set of relations covering all of them. + required: RelSet, +} + +/// One leaf of the join graph: a subplan the enumerator does not look inside. +#[derive(Debug)] +struct Relation { + plan: Arc, + /// Estimated row count, clamped to at least 1. + rows: f64, + /// Per-column distinct value estimate, clamped to `[1, rows]`. + ndv: Vec, + role: Role, +} + +/// An equi-join predicate `left = right` between two distinct relations. +#[derive(Clone, Copy, Debug)] +struct Edge { + left: ColRef, + right: ColRef, +} + +/// A non-equi join predicate, carried along unchanged apart from having its +/// column references rewritten for wherever it ends up. +#[derive(Debug)] +struct Filter { + filter: JoinFilter, + /// The column each entry of the filter's intermediate schema comes from. + columns: Vec, + /// The relations those columns belong to. + required: RelSet, +} + +/// A connected set of joins, flattened into relations plus the predicates +/// between them. +#[derive(Debug)] +struct JoinGraph { + relations: Vec, + edges: Vec, + filters: Vec, + /// The columns the original subtree emitted, in order. The rebuilt subtree + /// reproduces exactly this list, so the plan above it stays valid. + output: Vec, + /// Null handling shared by every join in the subtree, taken from the first + /// join seen. A join that handles nulls differently becomes a relation + /// instead of part of the graph. + null_equality: Option, + /// Relation sets of the original tree's internal nodes, used to score the + /// shape the planner produced against the enumerated alternatives. + original_nodes: Vec, + /// The relations that are reducers rather than ordinary inputs. + reducers: RelSet, +} + +impl JoinGraph { + /// Distinct value estimate for a column. + fn ndv(&self, col: ColRef) -> f64 { + self.relations[col.rel].ndv[col.col] + } + + /// The set of all relations in the graph. + fn all(&self) -> RelSet { + (0..self.relations.len()).fold(0, |mask, rel| mask | bit(rel)) + } + + fn reducer(&self, rel: usize) -> Option<&Reducer> { + match &self.relations[rel].role { + Role::Reducer(reducer) => Some(reducer), + Role::Output => None, + } + } + + fn null_equality(&self) -> NullEquality { + self.null_equality + .unwrap_or(NullEquality::NullEqualsNothing) + } +} + +/// A valid way of combining two relation sets into one node. +#[derive(Clone, Copy, Debug)] +enum Combine { + /// An inner join of two sets that a predicate connects. + Inner, + /// A semi or anti join applying the reducer relation `reducer` to the + /// opposite set. + Reducer { reducer: usize }, +} + +/// Cardinality and cost estimates over the subsets of a [`JoinGraph`]. +struct CostModel<'a> { + graph: &'a JoinGraph, + /// Aggregated selectivity per connected relation pair, as + /// `(rel_a, rel_b, selectivity)` with `rel_a < rel_b`. + pair_selectivity: Vec<(usize, usize, f64)>, + /// Neighbours of each relation, as a bitmask. Reducers neighbour nothing: + /// they are applied, not joined. + adjacency: Vec, + /// The fraction of its filtered side's rows each reducer keeps, indexed by + /// relation. `1.0` for relations that are not reducers. + reducer_selectivity: Vec, + /// Selectivity of each non-equi filter, with the relations it needs. + filter_selectivity: Vec<(RelSet, f64)>, +} + +impl<'a> CostModel<'a> { + fn new(graph: &'a JoinGraph, config: &ConfigOptions) -> Self { + // Aggregate the predicates of a relation pair the way + // `estimate_inner_join_cardinality` does: a multi-key join is estimated + // from its single most selective key rather than by multiplying the keys + // together. Keeping the two models consistent matters, because the + // statistics the rest of `JoinSelection` reads off the rebuilt plan come + // from that function. + let mut denominators: HashMap<(usize, usize), f64> = HashMap::new(); + for edge in &graph.edges { + let (a, b) = (edge.left.rel, edge.right.rel); + let key = if a < b { (a, b) } else { (b, a) }; + let denominator = graph.ndv(edge.left).max(graph.ndv(edge.right)).max(1.0); + denominators + .entry(key) + .and_modify(|current| *current = current.max(denominator)) + .or_insert(denominator); + } + + let mut adjacency = vec![0; graph.relations.len()]; + let mut pair_selectivity = Vec::with_capacity(denominators.len()); + for ((a, b), denominator) in denominators { + adjacency[a] |= bit(b); + adjacency[b] |= bit(a); + pair_selectivity.push((a, b, 1.0 / denominator)); + } + // `HashMap` iteration order is not deterministic, but plans must be. + pair_selectivity.sort_unstable_by(|l, r| (l.0, l.1).cmp(&(r.0, r.1))); + + let reducer_selectivity = (0..graph.relations.len()) + .map(|rel| match graph.reducer(rel) { + None => 1.0, + Some(reducer) => { + // The fraction of the filtered side's key values that the + // reducer covers, from its most selective key. + let matched = reducer + .keys + .iter() + .map(|(filtered, col)| { + let reducer_ndv = graph.ndv(ColRef { rel, col: *col }); + (reducer_ndv / graph.ndv(*filtered)).clamp(0.0, 1.0) + }) + .fold(1.0f64, f64::min); + if reducer.anti { 1.0 - matched } else { matched } + } + }) + .collect(); + + // A non-equi filter has no statistics to go on, so it gets the same + // default the rest of the optimizer uses for an opaque predicate. + let default_selectivity = + f64::from(config.optimizer.default_filter_selectivity) / 100.0; + let filter_selectivity = graph + .filters + .iter() + .map(|filter| (filter.required, default_selectivity)) + .collect(); + + Self { + graph, + pair_selectivity, + adjacency, + reducer_selectivity, + filter_selectivity, + } + } + + /// Estimated number of rows produced by joining every relation in `mask`. + /// + /// This is the textbook estimate: the product of the relation sizes scaled + /// down by the selectivity of every predicate that applies within `mask`. It + /// depends only on the *set* of relations and not on the shape of the tree + /// that joins them, which is what makes the dynamic program below valid. + fn cardinality(&self, mask: RelSet) -> f64 { + let mut rows = 1.0; + for rel in iter_rels(mask) { + // A reducer contributes a selectivity rather than rows of its own. + rows *= match self.graph.reducer(rel) { + Some(_) => self.reducer_selectivity[rel], + None => self.graph.relations[rel].rows, + }; + } + for (a, b, selectivity) in &self.pair_selectivity { + if mask & bit(*a) != 0 && mask & bit(*b) != 0 { + rows *= selectivity; + } + } + for (required, selectivity) in &self.filter_selectivity { + if covers(mask, *required) { + rows *= selectivity; + } + } + rows.max(1.0) + } + + /// Whether at least one equi-join predicate connects `left` and `right`. + fn connected(&self, left: RelSet, right: RelSet) -> bool { + iter_rels(left).any(|rel| self.adjacency[rel] & right != 0) + } + + /// How `left` and `right` may be combined, if at all. + fn combine(&self, left: RelSet, right: RelSet) -> Option { + let reducers = self.graph.reducers; + // A side that is a lone reducer is applied to the other side, which must + // supply every column the reducer's keys reference. + for (reducer_side, filtered) in [(right, left), (left, right)] { + if reducer_side.count_ones() == 1 && reducer_side & reducers != 0 { + let reducer = reducer_side.trailing_zeros() as usize; + let required = self.graph.reducer(reducer)?.required; + return covers(filtered, required) + .then_some(Combine::Reducer { reducer }); + } + } + // Otherwise this is an inner join, so both sides must contribute columns + // and a predicate must connect them. Cross products are never introduced: + // a plan needing one is left in the planner's own order. + if left & !reducers == 0 || right & !reducers == 0 { + return None; + } + self.connected(left, right).then_some(Combine::Inner) + } + + /// Cost of a join tree: the sum of the cardinalities of its internal nodes + /// (`C_out`). Leaves are excluded because every candidate tree reads the same + /// relations, making their cost a constant. + fn tree_cost(&self, nodes: &[RelSet]) -> f64 { + nodes + .iter() + .filter(|mask| mask.count_ones() > 1) + .map(|mask| self.cardinality(*mask)) + .sum() + } +} + +/// The winning join tree: for every internal node, the relation set of one of +/// its two inputs. The other input is the rest of that node's relation set. +struct Solution { + splits: HashMap, + cost: f64, +} + +/// Exhaustive dynamic programming over connected relation subsets. +/// +/// Every subset is costed once by trying all the ways of splitting it into two +/// halves, so bushy shapes are considered alongside left-deep ones. The search is +/// `O(3^n)` in the number of relations, which is why the caller bounds `n`. +fn solve_dp(model: &CostModel) -> Option { + let n = model.graph.relations.len(); + let full: RelSet = model.graph.all(); + let size = 1usize << n; + + // `f64::INFINITY` marks a subset that cannot be built at all, either because + // it is disconnected or because it holds a reducer whose columns it does not + // cover. + let mut cost = vec![f64::INFINITY; size]; + let mut split = vec![0 as RelSet; size]; + for rel in 0..n { + cost[bit(rel) as usize] = 0.0; + } + + for mask in 1..=full { + if mask.count_ones() < 2 { + continue; + } + let cardinality = model.cardinality(mask); + // Enumerate the subsets of `mask` containing its lowest set bit, so each + // unordered pair of halves is visited exactly once. + let lowest = mask & mask.wrapping_neg(); + let mut left = mask; + let mut best = f64::INFINITY; + let mut best_left = 0; + while left != 0 { + left = (left - 1) & mask; + if left & lowest == 0 { + continue; + } + let right = mask ^ left; + if right == 0 { + continue; + } + let (left_cost, right_cost) = (cost[left as usize], cost[right as usize]); + if !left_cost.is_finite() + || !right_cost.is_finite() + || model.combine(left, right).is_none() + { + continue; + } + let candidate = left_cost + right_cost + cardinality; + if candidate < best { + best = candidate; + best_left = left; + } + } + if best.is_finite() { + cost[mask as usize] = best; + split[mask as usize] = best_left; + } + } + + if !cost[full as usize].is_finite() { + return None; + } + + // Walk the winning tree, keeping only the splits it actually uses. + let mut splits = HashMap::new(); + let mut stack = vec![full]; + while let Some(mask) = stack.pop() { + if mask.count_ones() < 2 { + continue; + } + let left = split[mask as usize]; + splits.insert(mask, left); + stack.push(left); + stack.push(mask ^ left); + } + + Some(Solution { + splits, + cost: cost[full as usize], + }) +} + +/// Greedy fallback for join graphs too large for [`solve_dp`]. +/// +/// Repeatedly combines the pair of subtrees whose result is smallest. Cubic in +/// the number of relations, and it can lose to the planner's original order -- +/// which the caller checks for. +fn solve_greedy(model: &CostModel) -> Option { + let n = model.graph.relations.len(); + // (relation set, accumulated cost of the subtree built for it) + let mut components: Vec<(RelSet, f64)> = (0..n).map(|rel| (bit(rel), 0.0)).collect(); + let mut splits = HashMap::new(); + + while components.len() > 1 { + let mut best: Option<(usize, usize, f64, f64)> = None; + for i in 0..components.len() { + for j in (i + 1)..components.len() { + let (left, left_cost) = components[i]; + let (right, right_cost) = components[j]; + if model.combine(left, right).is_none() { + continue; + } + let cardinality = model.cardinality(left | right); + let cost = left_cost + right_cost + cardinality; + if best.is_none_or(|(_, _, best_cardinality, _)| { + cardinality < best_cardinality + }) { + best = Some((i, j, cardinality, cost)); + } + } + } + // Nothing left to combine without a cross product; leave the graph alone. + let (i, j, _, cost) = best?; + let (left, _) = components[i]; + let (right, _) = components[j]; + splits.insert(left | right, left); + components[i] = (left | right, cost); + components.swap_remove(j); + } + + let (_, cost) = components[0]; + Some(Solution { splits, cost }) +} + +/// How a join takes part in enumeration, if at all. +#[derive(Clone, Copy, Debug)] +enum JoinRole { + /// An inner join: both inputs are part of the graph. + Inner, + /// A semi or anti join: `output` names the side whose rows survive, and the + /// other side becomes a [`Reducer`]. + Reducing { anti: bool, output: JoinSide }, +} + +/// Classifies a join for enumeration. +/// +/// Outer and mark joins are excluded: an outer join is not a filter on its inputs +/// and so cannot be moved past one, and a mark join adds a column that the column +/// plumbing here does not model. `null_aware` anti joins are excluded because they +/// carry `NOT IN` semantics that depend on the whole probe side, and joins with a +/// limit because the limit belongs to one particular tree shape. A semi or anti +/// join with a non-equi filter is excluded too: that filter is part of an +/// existential test, not a conjunct that can move on its own. +fn join_role(join: &HashJoinExec) -> Option { + if join.null_aware || join.fetch().is_some() || join.on().is_empty() { + return None; + } + let role = match join.join_type() { + JoinType::Inner => JoinRole::Inner, + JoinType::LeftSemi => JoinRole::Reducing { + anti: false, + output: JoinSide::Left, + }, + JoinType::RightSemi => JoinRole::Reducing { + anti: false, + output: JoinSide::Right, + }, + JoinType::LeftAnti => JoinRole::Reducing { + anti: true, + output: JoinSide::Left, + }, + JoinType::RightAnti => JoinRole::Reducing { + anti: true, + output: JoinSide::Right, + }, + _ => return None, + }; + if join.filter().is_some() && !matches!(role, JoinRole::Inner) { + return None; + } + Some(role) +} + +fn as_column(expr: &PhysicalExprRef) -> Option { + expr.downcast_ref::().map(|col| col.index()) +} + +fn position(columns: &[ColRef], col: ColRef) -> Option { + columns.iter().position(|candidate| *candidate == col) +} + +/// Appends `col` unless it is already there. +fn push_unique(columns: &mut Vec, col: ColRef) { + if position(columns, col).is_none() { + columns.push(col); + } +} + +/// Appends the columns of `wanted` that belong to `side`, keeping their order and +/// dropping duplicates. +fn extend_required(columns: &mut Vec, wanted: &[ColRef], side: RelSet) { + for col in wanted.iter().filter(|col| side & bit(col.rel) != 0) { + push_unique(columns, *col); + } +} + +/// Extracts the maximal reorderable join subtree rooted at `plan`. +/// +/// Returns `None` when `plan` does not root a subtree worth enumerating, which +/// covers every bail-out condition: a join feature the enumerator does not model, +/// a join key that is not a plain column, missing row count statistics, or too +/// few or too many relations. +fn extract( + plan: &Arc, + stats: &mut StatsFn, +) -> Result> { + // Start either at a join, or at the column pruning projection that usually + // sits directly above one. Rooting the graph at the projection lets its + // column list become the top join's own projection, instead of leaving a + // `ProjectionExec` stranded above a join emitting more columns than the query + // needs. Anything else is not a subtree root, and bailing out here keeps + // whole plans from being walked for nothing. + let is_root = match plan.downcast_ref::() { + Some(join) => join_role(join).is_some(), + None => plan + .downcast_ref::() + .is_some_and(|projection| all_alias_free_columns(projection.expr())), + }; + if !is_root { + return Ok(None); + } + Extractor::new(stats).extract(plan) +} + +/// Flattens a join subtree into a [`JoinGraph`]. +struct Extractor<'a, 's> { + graph: JoinGraph, + stats: &'s mut StatsFn<'a>, +} + +impl<'a, 's> Extractor<'a, 's> { + fn new(stats: &'s mut StatsFn<'a>) -> Self { + Self { + graph: JoinGraph { + relations: vec![], + edges: vec![], + filters: vec![], + output: vec![], + null_equality: None, + original_nodes: vec![], + reducers: 0, + }, + stats, + } + } + + fn extract(mut self, plan: &Arc) -> Result> { + let Some((output, _)) = self.visit(plan)? else { + return Ok(None); + }; + let mut graph = self.graph; + graph.output = output; + + if graph.relations.len() < 3 { + // A single join has nothing to reorder: `JoinSelection`'s build side + // swap already covers it. + return Ok(None); + } + // A filter referencing a single relation has no join to be applied at, + // since the deepest node covering it would be a leaf. + if graph + .filters + .iter() + .any(|filter| filter.required.count_ones() < 2) + { + return Ok(None); + } + Ok(Some(graph)) + } + + /// Recursively flattens `plan`, returning the columns it emits and the set of + /// relations it covers. + /// + /// `None` means the subtree cannot be reordered and the caller must give up. + fn visit( + &mut self, + plan: &Arc, + ) -> Result, RelSet)>> { + if let Some(join) = plan.downcast_ref::() + && let Some(role) = join_role(join) + && self + .graph + .null_equality + .is_none_or(|null_equality| null_equality == join.null_equality) + { + self.graph.null_equality = Some(join.null_equality); + let visited = match role { + JoinRole::Inner => self.visit_inner(join)?, + JoinRole::Reducing { anti, output } => { + self.visit_reducing(join, anti, output)? + } + }; + let Some((columns, mask)) = visited else { + return Ok(None); + }; + + self.graph.original_nodes.push(mask); + // The join's projection selects from the columns it emits, which for a + // semi or anti join are only those of its output side. + let columns = match &join.projection { + Some(projection) => projection.iter().map(|idx| columns[*idx]).collect(), + None => columns, + }; + Ok(Some((columns, mask))) + } else if let Some(projection) = plan.downcast_ref::() + && all_alias_free_columns(projection.expr()) + { + // A pure column pruning projection. Looking through these is what lets + // the enumerator see a whole join chain: at this point in the + // optimizer the planner has left one between every pair of joins, and + // `ProjectionPushdown`, which folds them into the joins, has not run + // yet. `all_alias_free_columns` also rules out renaming, so dropping + // the projection cannot change the subtree's output field names. + let Some((child, mask)) = self.visit(projection.input())? else { + return Ok(None); + }; + let columns = projection + .expr() + .iter() + .map(|proj| { + proj.expr + .downcast_ref::() + .map(|col| child[col.index()]) + }) + .collect::>>(); + Ok(columns.map(|columns| (columns, mask))) + } else { + // A leaf: opaque to the enumerator, but it needs its statistics. + let Some(rel) = self.push_relation(plan, Role::Output)? else { + return Ok(None); + }; + Ok(Some(( + (0..plan.schema().fields().len()) + .map(|col| ColRef { rel, col }) + .collect(), + bit(rel), + ))) + } + } + + /// Flattens an inner join: both sides join the graph, and its predicates + /// become edges and filters. + fn visit_inner( + &mut self, + join: &HashJoinExec, + ) -> Result, RelSet)>> { + let Some((left, left_mask)) = self.visit(join.left())? else { + return Ok(None); + }; + let Some((right, right_mask)) = self.visit(join.right())? else { + return Ok(None); + }; + + for (left_key, right_key) in join.on() { + let (Some(left_key), Some(right_key)) = + (as_column(left_key), as_column(right_key)) + else { + // A key such as `cast(a) = b` would have to be re-derived against + // a different schema; not worth the complexity. + return Ok(None); + }; + let edge = Edge { + left: left[left_key], + right: right[right_key], + }; + // Duplicate predicates would be double counted by the cost model. + if !self + .graph + .edges + .iter() + .any(|e| (e.left, e.right) == (edge.left, edge.right)) + { + self.graph.edges.push(edge); + } + } + + if let Some(filter) = join.filter() { + let columns = filter + .column_indices() + .iter() + .map(|ColumnIndex { index, side }| match side { + JoinSide::Left => Some(left[*index]), + JoinSide::Right => Some(right[*index]), + JoinSide::None => None, + }) + .collect::>>(); + let Some(columns) = columns else { + return Ok(None); + }; + let required = columns.iter().fold(0, |mask, col| mask | bit(col.rel)); + self.graph.filters.push(Filter { + filter: filter.clone(), + columns, + required, + }); + } + + let mut columns = left; + columns.extend(right); + Ok(Some((columns, left_mask | right_mask))) + } + + /// Flattens a semi or anti join: its output side joins the graph, and its + /// quantified side becomes a reducer relation. + fn visit_reducing( + &mut self, + join: &HashJoinExec, + anti: bool, + output: JoinSide, + ) -> Result, RelSet)>> { + let (output_plan, reducer_plan) = match output { + JoinSide::Left => (join.left(), join.right()), + JoinSide::Right => (join.right(), join.left()), + JoinSide::None => return internal_err!("semi join with no output side"), + }; + + let Some((columns, mask)) = self.visit(output_plan)? else { + return Ok(None); + }; + + // The keys are resolved against the output side's columns, so they have to + // be collected before the reducer relation that owns them exists. + let mut keys = Vec::with_capacity(join.on().len()); + let mut required = 0; + for (left_key, right_key) in join.on() { + let (output_key, reducer_key) = match output { + JoinSide::Left => (left_key, right_key), + _ => (right_key, left_key), + }; + let (Some(output_key), Some(reducer_key)) = + (as_column(output_key), as_column(reducer_key)) + else { + return Ok(None); + }; + let column = columns[output_key]; + required |= bit(column.rel); + keys.push((column, reducer_key)); + } + + let role = Role::Reducer(Reducer { + anti, + keys, + required, + }); + let Some(rel) = self.push_relation(reducer_plan, role)? else { + return Ok(None); + }; + Ok(Some((columns, mask | bit(rel)))) + } + + /// Adds `plan` to the graph as a relation, returning its index. + fn push_relation( + &mut self, + plan: &Arc, + role: Role, + ) -> Result> { + if plan.boundedness().is_unbounded() { + // Reordering could break the pipeline properties the other + // `JoinSelection` subrules establish. + return Ok(None); + } + if self.graph.relations.len() >= MAX_RELATIONS { + return Ok(None); + } + let statistics = (self.stats)(plan.as_ref())?; + let Some(rows) = statistics.num_rows.get_value().copied() else { + // Without a row count there is no basis for reordering anything. + return Ok(None); + }; + let rows = (rows as f64).max(1.0); + let ndv = statistics + .column_statistics + .iter() + .map(|col| { + max_distinct_count(&statistics.num_rows, col) + .get_value() + .map(|ndv| (*ndv as f64).clamp(1.0, rows)) + .unwrap_or(rows) + }) + .collect(); + + let rel = self.graph.relations.len(); + if matches!(role, Role::Reducer(_)) { + self.graph.reducers |= bit(rel); + } + self.graph.relations.push(Relation { + plan: Arc::clone(plan), + rows, + ndv, + role, + }); + Ok(Some(rel)) + } +} + +/// Rebuilds a join subtree from the tree a search picked. +struct Rebuilder<'a> { + graph: &'a JoinGraph, + model: &'a CostModel<'a>, + solution: &'a Solution, + /// The plan of each relation, already rewritten if it held a join subtree of + /// its own. + relations: &'a [Arc], +} + +impl Rebuilder<'_> { + /// Builds the node joining every relation in `mask`, emitting `required` in + /// that order. + fn node( + &self, + mask: RelSet, + required: &[ColRef], + ) -> Result<(Arc, Vec)> { + if mask.count_ones() == 1 { + // Relations are opaque, so they are emitted as they are. Narrowing + // them is `ProjectionPushdown`'s job and it runs later. + let rel = mask.trailing_zeros() as usize; + let plan = Arc::clone(&self.relations[rel]); + let columns = (0..plan.schema().fields().len()) + .map(|col| ColRef { rel, col }) + .collect(); + return Ok((plan, columns)); + } + + let Some(split) = self.solution.splits.get(&mask).copied() else { + return internal_err!("join enumeration produced no split for {mask:b}"); + }; + let other = mask ^ split; + match self.model.combine(split, other) { + None => internal_err!("join enumeration produced an invalid join"), + Some(Combine::Reducer { reducer }) => self.reducing(mask, required, reducer), + Some(Combine::Inner) => { + // Put the cheaper side on the left. `JoinSelection`'s build side + // swap runs after this and may revise the choice from the rebuilt + // plan's statistics, but starting from the smaller side keeps the + // two decisions consistent. + let (left, right) = + if self.model.cardinality(split) <= self.model.cardinality(other) { + (split, other) + } else { + (other, split) + }; + self.inner(required, left, right) + } + } + } + + /// Builds an inner join of `left_mask` and `right_mask`. + fn inner( + &self, + required: &[ColRef], + left_mask: RelSet, + right_mask: RelSet, + ) -> Result<(Arc, Vec)> { + // Every equi-join predicate crossing this cut is applied here, and only + // here: each edge crosses exactly one cut of the tree, at the lowest node + // whose relation set contains both of its endpoints. + let mut keys: Vec<(ColRef, ColRef)> = vec![]; + for edge in &self.graph.edges { + let (left, right) = (edge.left, edge.right); + if left_mask & bit(left.rel) != 0 && right_mask & bit(right.rel) != 0 { + keys.push((left, right)); + } else if left_mask & bit(right.rel) != 0 && right_mask & bit(left.rel) != 0 { + keys.push((right, left)); + } + } + if keys.is_empty() { + return internal_err!("join enumeration produced a cross product"); + } + + // Non-equi filters are applied at their lowest common ancestor: this node + // covers everything the filter references, and neither input does alone. + let mask = left_mask | right_mask; + let filters: Vec<&Filter> = self + .graph + .filters + .iter() + .filter(|filter| { + covers(mask, filter.required) + && !covers(left_mask, filter.required) + && !covers(right_mask, filter.required) + }) + .collect(); + + // Each side must emit this join's key columns, the columns its filters + // reference, and whatever the nodes above asked for. + let child_required = |side: RelSet, take_left: bool| { + let mut columns: Vec = vec![]; + for (left, right) in &keys { + push_unique(&mut columns, if take_left { *left } else { *right }); + } + for filter in &filters { + extend_required(&mut columns, &filter.columns, side); + } + extend_required(&mut columns, required, side); + columns + }; + + let (left_plan, left_columns) = + self.node(left_mask, &child_required(left_mask, true))?; + let (right_plan, right_columns) = + self.node(right_mask, &child_required(right_mask, false))?; + + let on = keys + .iter() + .map(|(left, right)| { + Ok(( + key_expr(&left_columns, &left_plan, *left)?, + key_expr(&right_columns, &right_plan, *right)?, + )) + }) + .collect::>>()?; + let filter = rebuild_filters(&filters, &left_columns, &right_columns)?; + + // The join's natural output, before its projection. + let mut joined = left_columns; + joined.extend(right_columns); + let projection = projection_for(required, &joined)?; + + let join = HashJoinExecBuilder::new(left_plan, right_plan, on, JoinType::Inner) + .with_filter(filter) + .with_null_equality(self.graph.null_equality()) + // The build side and the partition mode are picked by + // `statistical_join_selection_subrule`, which runs after enumeration. + .with_partition_mode(PartitionMode::Auto) + .with_projection(projection) + .build()?; + Ok((Arc::new(join), required.to_vec())) + } + + /// Builds the semi or anti join applying `reducer` to the rest of `mask`. + fn reducing( + &self, + mask: RelSet, + required: &[ColRef], + reducer: usize, + ) -> Result<(Arc, Vec)> { + let Some(info) = self.graph.reducer(reducer) else { + return internal_err!("relation {reducer} is not a reducer"); + }; + let filtered_mask = mask & !bit(reducer); + + // The filtered side must emit the columns the keys compare, plus whatever + // the nodes above asked for. Non-equi filters never land here: they only + // reference output relations, so the deepest node covering one is always + // inside the filtered side. + let mut filtered_required: Vec = vec![]; + for (column, _) in &info.keys { + push_unique(&mut filtered_required, *column); + } + extend_required(&mut filtered_required, required, filtered_mask); + + let (filtered_plan, filtered_columns) = + self.node(filtered_mask, &filtered_required)?; + let reducer_plan = Arc::clone(&self.relations[reducer]); + + // The reducer goes on the build side so the filtered side can be + // streamed: `RightSemi` and `RightAnti` emit rows of their right input, + // which is exactly the filtered side. + let reducer_schema = reducer_plan.schema(); + let on = info + .keys + .iter() + .map(|(column, reducer_col)| { + let reducer_key = Arc::new(Column::new( + reducer_schema.field(*reducer_col).name(), + *reducer_col, + )) as _; + Ok(( + reducer_key, + key_expr(&filtered_columns, &filtered_plan, *column)?, + )) + }) + .collect::>>()?; + + // A semi or anti join emits only its output side, so the projection + // selects from the filtered side's columns alone. + let projection = projection_for(required, &filtered_columns)?; + let join_type = if info.anti { + JoinType::RightAnti + } else { + JoinType::RightSemi + }; + + let join = HashJoinExecBuilder::new(reducer_plan, filtered_plan, on, join_type) + .with_null_equality(self.graph.null_equality()) + .with_partition_mode(PartitionMode::Auto) + .with_projection(projection) + .build()?; + Ok((Arc::new(join), required.to_vec())) + } +} + +/// Builds a `Column` expression for `col`, given the columns a plan emits. +fn key_expr( + columns: &[ColRef], + plan: &Arc, + col: ColRef, +) -> Result { + let Some(index) = position(columns, col) else { + return internal_err!("join enumeration lost column {col:?}"); + }; + Ok(Arc::new(Column::new(plan.schema().field(index).name(), index)) as _) +} + +/// The projection selecting `required` out of `emitted`, or `None` when the node +/// already emits exactly that. +fn projection_for(required: &[ColRef], emitted: &[ColRef]) -> Result>> { + let mut projection = Vec::with_capacity(required.len()); + for col in required { + let Some(index) = position(emitted, *col) else { + return internal_err!("join enumeration lost column {col:?}"); + }; + projection.push(index); + } + let identity = projection.len() == emitted.len() + && projection.iter().enumerate().all(|(idx, col)| idx == *col); + Ok((!identity).then_some(projection)) +} + +/// Rebuilds the non-equi filters applied at one join as a single conjunction over +/// the columns its inputs now emit. +/// +/// A [`JoinFilter`] evaluates its expression against an intermediate batch whose +/// columns are described by `column_indices`, and the expression addresses that +/// batch by index. So combining filters means concatenating their intermediate +/// schemas and shifting the column indices of all but the first, while the mapping +/// from intermediate column to input column is rebuilt from scratch. +fn rebuild_filters( + filters: &[&Filter], + left_columns: &[ColRef], + right_columns: &[ColRef], +) -> Result> { + let Some((first, rest)) = filters.split_first() else { + return Ok(None); + }; + + let mut expression = Arc::clone(first.filter.expression()); + let mut fields: Vec = + first.filter.schema().fields().iter().cloned().collect(); + let mut columns: Vec = first.columns.clone(); + for filter in rest { + let shifted = + shift_columns(Arc::clone(filter.filter.expression()), fields.len())?; + expression = Arc::new(BinaryExpr::new(expression, Operator::And, shifted)) as _; + fields.extend(filter.filter.schema().fields().iter().cloned()); + columns.extend_from_slice(&filter.columns); + } + + let column_indices = columns + .iter() + .map(|col| { + if let Some(index) = position(left_columns, *col) { + Ok(ColumnIndex { + index, + side: JoinSide::Left, + }) + } else if let Some(index) = position(right_columns, *col) { + Ok(ColumnIndex { + index, + side: JoinSide::Right, + }) + } else { + internal_err!("join enumeration lost filter column {col:?}") + } + }) + .collect::>>()?; + + Ok(Some(JoinFilter::new( + expression, + column_indices, + Arc::new(Schema::new(fields)), + ))) +} + +/// Shifts every column index in `expression` by `offset`, for a filter whose +/// intermediate columns have been appended after another filter's. +fn shift_columns(expression: PhysicalExprRef, offset: usize) -> Result { + if offset == 0 { + return Ok(expression); + } + expression + .transform(|expr| { + Ok(match expr.downcast_ref::() { + Some(column) => Transformed::yes(Arc::new(Column::new( + column.name(), + column.index() + offset, + )) as _), + None => Transformed::no(expr), + }) + }) + .data() +} + +/// Enumerates join orders throughout `plan`, returning `None` if nothing changed. +pub(crate) fn enumerate_join_order( + plan: &Arc, + config: &ConfigOptions, + stats: &mut StatsFn, +) -> Result>> { + if let Some(graph) = extract(plan, stats)? + && let Some(reordered) = reorder(&graph, config, stats)? + { + return Ok(Some(reordered)); + } + + // Not a subtree that could be reordered: recurse into the children. + let mut changed = false; + let children = plan + .children() + .into_iter() + .map(|child| match enumerate_join_order(child, config, stats)? { + Some(new_child) => { + changed = true; + Ok(new_child) + } + None => Ok(Arc::clone(child)), + }) + .collect::>>()?; + if changed { + Ok(Some(replace_children_if_necessary( + Arc::clone(plan), + children, + )?)) + } else { + Ok(None) + } +} + +/// Enumerates orders for one extracted graph, rebuilding it if a cheaper order +/// exists. +fn reorder( + graph: &JoinGraph, + config: &ConfigOptions, + stats: &mut StatsFn, +) -> Result>> { + let model = CostModel::new(graph, config); + let limit = config + .optimizer + .join_enumeration_limit + .min(MAX_DP_RELATIONS); + let solution = if graph.relations.len() <= limit { + solve_dp(&model) + } else { + solve_greedy(&model) + }; + let Some(solution) = solution else { + return Ok(None); + }; + + // Keep the planner's order unless the winner is strictly cheaper. The dynamic + // program considers the original shape as well, so this only rejects ties + // there -- but the greedy search can genuinely lose, and either way it keeps + // already-optimal plans byte identical. + if solution.cost >= model.tree_cost(&graph.original_nodes) { + return Ok(None); + } + + // Reorder inside the relations before assembling them. + let mut relations = Vec::with_capacity(graph.relations.len()); + for relation in &graph.relations { + relations.push( + enumerate_join_order(&relation.plan, config, stats)? + .unwrap_or_else(|| Arc::clone(&relation.plan)), + ); + } + + let rebuilder = Rebuilder { + graph, + model: &model, + solution: &solution, + relations: &relations, + }; + let (plan, _) = rebuilder.node(graph.all(), &graph.output)?; + Ok(Some(plan)) +} diff --git a/datafusion/physical-optimizer/src/join_selection.rs b/datafusion/physical-optimizer/src/join_selection.rs index 42736f8205089..87429a3caa4a5 100644 --- a/datafusion/physical-optimizer/src/join_selection.rs +++ b/datafusion/physical-optimizer/src/join_selection.rs @@ -20,10 +20,13 @@ //! is any) to obtain more performant plans. To achieve the first goal, it //! tries to transform a non-runnable query (with the given infinite sources) //! into a runnable query by replacing pipeline-breaking join operations with -//! pipeline-friendly ones. To achieve the second goal, it selects the proper -//! `PartitionMode` and the build side using the available statistics for hash joins. +//! pipeline-friendly ones. To achieve the second goal, it enumerates alternative +//! join orders for subtrees of inner hash joins (see [`crate::join_enumeration`]) +//! and selects the proper `PartitionMode` and the build side using the available +//! statistics for hash joins. use crate::PhysicalOptimizerRule; +use crate::join_enumeration::enumerate_join_order; use crate::optimizer::{ConfigOnlyContext, PhysicalOptimizerContext}; use datafusion_common::Statistics; use datafusion_common::config::ConfigOptions; @@ -163,6 +166,16 @@ impl PhysicalOptimizerRule for JoinSelection { } else { None }; + // Choose the shape of the join tree before making the per-join build + // side and partition mode decisions below, which are then made against + // the inputs the chosen shape actually produces. + let plan = if config.optimizer.join_enumeration { + let mut stats = |p: &dyn ExecutionPlan| get_stats(p, registry); + enumerate_join_order(&plan, config, &mut stats)?.unwrap_or(plan) + } else { + plan + }; + let subrules: Vec> = vec![ Box::new(hash_join_convert_symmetric_subrule), Box::new(hash_join_swap_subrule), diff --git a/datafusion/physical-optimizer/src/lib.rs b/datafusion/physical-optimizer/src/lib.rs index b9eb248f6e843..4d45f573685bd 100644 --- a/datafusion/physical-optimizer/src/lib.rs +++ b/datafusion/physical-optimizer/src/lib.rs @@ -34,6 +34,7 @@ pub mod ensure_requirements; // modules keep their public paths. pub use ensure_requirements::{enforce_distribution, enforce_sorting}; pub mod filter_pushdown; +pub mod join_enumeration; pub mod join_selection; pub mod limit_pushdown; pub mod limit_pushdown_past_window; diff --git a/datafusion/physical-plan/src/joins/utils.rs b/datafusion/physical-plan/src/joins/utils.rs index ecf056560e015..b1d0eb61ddaae 100644 --- a/datafusion/physical-plan/src/joins/utils.rs +++ b/datafusion/physical-plan/src/joins/utils.rs @@ -1023,7 +1023,7 @@ fn estimate_semi_join_cardinality( /// directly. Otherwise, if the column is numeric and has min/max values, it /// estimates the maximum distinct count from those. Otherwise, the num_rows /// is used. -fn max_distinct_count( +pub fn max_distinct_count( num_rows: &Precision, stats: &ColumnStatistics, ) -> Precision { diff --git a/datafusion/sqllogictest/test_files/information_schema.slt b/datafusion/sqllogictest/test_files/information_schema.slt index 573fb04b3451b..8a36d6cbaf354 100644 --- a/datafusion/sqllogictest/test_files/information_schema.slt +++ b/datafusion/sqllogictest/test_files/information_schema.slt @@ -326,6 +326,8 @@ datafusion.optimizer.hash_join_inlist_pushdown_max_distinct_values 150 datafusion.optimizer.hash_join_inlist_pushdown_max_size 131072 datafusion.optimizer.hash_join_single_partition_threshold 1048576 datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 +datafusion.optimizer.join_enumeration true +datafusion.optimizer.join_enumeration_limit 12 datafusion.optimizer.join_reordering true datafusion.optimizer.max_passes 3 datafusion.optimizer.prefer_existing_sort false @@ -486,6 +488,8 @@ datafusion.optimizer.hash_join_inlist_pushdown_max_distinct_values 150 Maximum n datafusion.optimizer.hash_join_inlist_pushdown_max_size 131072 Maximum size in bytes for the build side of a hash join to be pushed down as an InList expression for dynamic filtering. Build sides larger than this will use hash table lookups instead. Set to 0 to always use hash table lookups. InList pushdown can be more efficient for small build sides because it can result in better statistics pruning as well as use any bloom filters present on the scan side. InList expressions are also more transparent and easier to serialize over the network in distributed uses of DataFusion. On the other hand InList pushdown requires making a copy of the data and thus adds some overhead to the build side and uses more memory. This setting is per-partition, so we may end up using `hash_join_inlist_pushdown_max_size` * `target_partitions` memory. The default is 128kB per partition. This should allow point lookup joins (e.g. joining on a unique primary key) to use InList pushdown in most cases but avoids excessive memory usage or overhead for larger joins. datafusion.optimizer.hash_join_single_partition_threshold 1048576 The maximum estimated size in bytes for one input side of a HashJoin will be collected into a single partition datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 The maximum estimated size in rows for one input side of a HashJoin will be collected into a single partition +datafusion.optimizer.join_enumeration true When set to true, the physical plan optimizer enumerates alternative join orders for connected subtrees of inner hash joins and picks the cheapest one from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics, and subtrees whose original order is already the cheapest, are left untouched. +datafusion.optimizer.join_enumeration_limit 12 Maximum number of inputs in a join subtree for which `join_enumeration` runs its exhaustive dynamic programming search. Larger subtrees fall back to a greedy search. The exhaustive search costs `O(3^n)` in the number of inputs, so values above 16 are clamped to 16. datafusion.optimizer.join_reordering true When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. datafusion.optimizer.max_passes 3 Number of times that the optimizer will attempt to optimize the plan datafusion.optimizer.prefer_existing_sort false When true, DataFusion will opportunistically remove sorts when the data is already sorted, (i.e. setting `preserve_order` to true on `RepartitionExec` and using `SortPreservingMergeExec`) When false, DataFusion will maximize plan parallelism using `RepartitionExec` even if this requires subsequently resorting data using a `SortExec`. diff --git a/datafusion/sqllogictest/test_files/join_limit_pushdown.slt b/datafusion/sqllogictest/test_files/join_limit_pushdown.slt index 933b03e7ebd93..b30f2a34a4069 100644 --- a/datafusion/sqllogictest/test_files/join_limit_pushdown.slt +++ b/datafusion/sqllogictest/test_files/join_limit_pushdown.slt @@ -220,11 +220,11 @@ logical_plan 05)------TableScan: t2 projection=[x] 06)----TableScan: t3 projection=[p] physical_plan -01)HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(p@0, x@1)], projection=[a@1, x@2, p@0], fetch=2 -02)--DataSourceExec: partitions=1, partition_sizes=[1] -03)--HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(a@0, x@0)] +01)HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(x@0, a@0)], projection=[a@2, x@0, p@1], fetch=2 +02)--HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(p@0, x@0)], projection=[x@1, p@0] +03)----DataSourceExec: partitions=1, partition_sizes=[1] 04)----DataSourceExec: partitions=1, partition_sizes=[1] -05)----DataSourceExec: partitions=1, partition_sizes=[1] +05)--DataSourceExec: partitions=1, partition_sizes=[1] query III SELECT t1.a, t2.x, t3.p diff --git a/datafusion/sqllogictest/test_files/statistics_registry.slt b/datafusion/sqllogictest/test_files/statistics_registry.slt index 89258bec299c1..46cb4960fac3d 100644 --- a/datafusion/sqllogictest/test_files/statistics_registry.slt +++ b/datafusion/sqllogictest/test_files/statistics_registry.slt @@ -38,6 +38,13 @@ set datafusion.optimizer.hash_join_single_partition_threshold = 1; statement ok set datafusion.optimizer.hash_join_single_partition_threshold_rows = 1; +# This file tests the build side decision, so cost-based join order enumeration +# is kept out of the way: with it on, both cases below settle on the same +# (cheaper) join order and the registry's effect on the build side is no longer +# visible in the plan. Enumeration is covered on its own at the end of the file. +statement ok +set datafusion.optimizer.join_enumeration = false; + # -- Create test data -------------------------------------------------------- query I @@ -153,6 +160,37 @@ JOIN dim_small d ON o.small_id = d.small_id; ---- 66 +# -- With join order enumeration --------------------------------------------- +# The `FROM` order joins customers with orders first, for a 66 row intermediate. +# Enumeration instead joins orders with dim_small first, which produces 10 rows +# because every order matches exactly one dim_small row. + +statement ok +set datafusion.optimizer.join_enumeration = true; + +query TT +EXPLAIN SELECT o.order_id, c.region_id, d.label +FROM customers c +JOIN orders o ON c.customer_id = o.customer_id +JOIN dim_small d ON o.small_id = d.small_id; +---- +physical_plan +01)HashJoinExec: mode=Partitioned, join_type=Inner, on=[(customer_id@0, customer_id@0)], projection=[order_id@3, region_id@1, label@4] +02)--RepartitionExec: partitioning=Hash([customer_id@0], 4), input_partitions=1, maintains_sort_order=true +03)----DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/statistics_registry/customers.parquet]]}, projection=[customer_id, region_id], output_ordering=[region_id@1 ASC NULLS LAST], file_type=parquet +04)--RepartitionExec: partitioning=Hash([customer_id@0], 4), input_partitions=1 +05)----HashJoinExec: mode=Partitioned, join_type=Inner, on=[(small_id@2, small_id@0)], projection=[customer_id@1, order_id@0, label@4] +06)------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/statistics_registry/orders.parquet]]}, projection=[order_id, customer_id, small_id], output_ordering=[order_id@0 ASC NULLS LAST], file_type=parquet, predicate=DynamicFilter [ empty ], dynamic_rg_pruning=eligible +07)------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/statistics_registry/dim_small.parquet]]}, projection=[small_id, label], output_ordering=[small_id@0 ASC NULLS LAST], file_type=parquet, predicate=DynamicFilter [ empty ], dynamic_rg_pruning=eligible + +query III +SELECT count(*), min(o.order_id), max(d.label) +FROM customers c +JOIN orders o ON c.customer_id = o.customer_id +JOIN dim_small d ON o.small_id = d.small_id; +---- +66 1 10 + # -- Cleanup ----------------------------------------------------------------- statement ok diff --git a/docs/source/user-guide/configs.md b/docs/source/user-guide/configs.md index a66ad3edf5c14..eafbabba2aa3d 100644 --- a/docs/source/user-guide/configs.md +++ b/docs/source/user-guide/configs.md @@ -170,6 +170,8 @@ The following configuration settings are available: | datafusion.optimizer.max_passes | 3 | Number of times that the optimizer will attempt to optimize the plan | | datafusion.optimizer.top_down_join_key_reordering | true | When set to true, the physical plan optimizer will run a top down process to reorder the join keys | | datafusion.optimizer.join_reordering | true | When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. | +| datafusion.optimizer.join_enumeration | true | When set to true, the physical plan optimizer enumerates alternative join orders for connected subtrees of inner hash joins and picks the cheapest one from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics, and subtrees whose original order is already the cheapest, are left untouched. | +| datafusion.optimizer.join_enumeration_limit | 12 | Maximum number of inputs in a join subtree for which `join_enumeration` runs its exhaustive dynamic programming search. Larger subtrees fall back to a greedy search. The exhaustive search costs `O(3^n)` in the number of inputs, so values above 16 are clamped to 16. | | datafusion.optimizer.use_statistics_registry | false | When set to true, the physical plan optimizer uses the pluggable `StatisticsRegistry` for statistics propagation across operators. This enables more accurate cardinality estimates compared to each operator's built-in `partition_statistics`. | | datafusion.optimizer.prefer_hash_join | true | When set to true, the physical plan optimizer will prefer HashJoin over SortMergeJoin. HashJoin can work more efficiently than SortMergeJoin but consumes more memory | | datafusion.optimizer.enable_piecewise_merge_join | false | When set to true, piecewise merge join is enabled. PiecewiseMergeJoin is currently experimental. Physical planner will opt for PiecewiseMergeJoin when there is only one range filter. | From aa652b5d884db64e7166304f11bdc75ed19998be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Tue, 18 Aug 2026 16:03:23 +0200 Subject: [PATCH 02/16] fix: require a margin before replacing the planner's join order TPC-DS q6 got 37% slower. Its `date_dim` is filtered by a subquery and DataFusion estimates that subplan at 14,610 rows against 31 real ones, so no join in the query appears to reduce anything, every order ties, and the winner is picked essentially arbitrarily -- a coin flip that costs 37%. Only replace the planner's order when the enumerated one is cheaper by a clear margin, configured by `datafusion.optimizer.join_enumeration_min_improvement` (default 10%). Measured: q6's "gain" was under 1%, while every TPC-H win survives a margin of 10% or more. Co-Authored-By: Claude Opus 5 --- datafusion/common/src/config.rs | 9 +++++++++ .../physical_optimizer/join_enumeration.rs | 19 ++++++++++++------- .../src/join_enumeration.rs | 16 +++++++++++----- .../test_files/information_schema.slt | 2 ++ docs/source/user-guide/configs.md | 1 + 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/datafusion/common/src/config.rs b/datafusion/common/src/config.rs index f13e5aa5b9630..c8903045a7c66 100644 --- a/datafusion/common/src/config.rs +++ b/datafusion/common/src/config.rs @@ -1680,6 +1680,15 @@ config_namespace! { /// are left untouched. pub join_enumeration: bool, default = true + /// How much cheaper an enumerated join order must be, in percent, before + /// `join_enumeration` replaces the order the planner produced. + /// + /// Cardinality estimates are often unable to tell two orders apart, and + /// a plan swapped on an estimate that close is as likely to be slower as + /// faster. A margin keeps the planner's order unless the model is + /// confident, at the cost of missing genuinely small wins. + pub join_enumeration_min_improvement: u8, default = 10 + /// Maximum number of inputs in a join subtree for which /// `join_enumeration` runs its exhaustive dynamic programming search. /// Larger subtrees fall back to a greedy search. The exhaustive search diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index f46cc55f7da95..784aa7a32cdf2 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -349,9 +349,15 @@ async fn reordered_join_returns_the_same_rows() -> Result<()> { fn late_semi_join_plan(anti: bool) -> Result> { let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); - // Ten rows covering ten of the fact table's thousand types, so the semi join - // keeps one percent of its input and the anti join the other ninety-nine. - let wanted = scan(10, &[("w_type", 10)]); + // Sized so that either way round the reducer keeps one percent of the fact + // table: ten of its thousand types match for the semi join, and all but ten + // match for the anti join. A reducer that kept most of its input would not be + // worth moving, and the enumerator would rightly leave it alone. + let wanted = if anti { + scan(990, &[("w_type", 990)]) + } else { + scan(10, &[("w_type", 10)]) + }; let joined = join(fact, other, &[("f_id", "o_id")])?; let join_type = if anti { @@ -388,12 +394,11 @@ async fn applies_a_selective_semi_join_first() -> Result<()> { #[tokio::test] async fn applies_an_anti_join_first() -> Result<()> { let optimized = optimize(late_semi_join_plan(true)?, &ConfigOptions::new())?; - // The anti join is pushed down the same way. It keeps 99% of the fact table - // rather than 1%, so the inner join above it stays partitioned. + // The anti join is pushed down the same way. assert_snapshot!(formatted(&optimized), @r" - HashJoinExec: mode=Partitioned, join_type=Inner, on=[(f_id@0, o_id@0)] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)] HashJoinExec: mode=CollectLeft, join_type=RightAnti, on=[(w_type@0, f_type@1)] - StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=1, row_count=Inexact(990) StatisticsExec: col_count=2, row_count=Inexact(1000000) StatisticsExec: col_count=1, row_count=Inexact(1000000) "); diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index ec80e621c9f0b..0e5f07a7de85f 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -1250,11 +1250,17 @@ fn reorder( return Ok(None); }; - // Keep the planner's order unless the winner is strictly cheaper. The dynamic - // program considers the original shape as well, so this only rejects ties - // there -- but the greedy search can genuinely lose, and either way it keeps - // already-optimal plans byte identical. - if solution.cost >= model.tree_cost(&graph.original_nodes) { + // Keep the planner's order unless the winner is cheaper by a clear margin. + // + // The dynamic program considers the original shape too, so its winner is + // never *more* expensive under this model -- but "cheaper by a hair" is not + // a reason to churn a plan. Where estimates cannot tell orders apart, every + // candidate looks about the same and the winner is picked essentially + // arbitrarily, which is how TPC-DS q6 lost 37%: a subquery-filtered + // `date_dim` is estimated at 14,610 rows against 31 real ones, so no join in + // the query appears to reduce anything and all orders tie. + let margin = f64::from(config.optimizer.join_enumeration_min_improvement) / 100.0; + if solution.cost >= model.tree_cost(&graph.original_nodes) * (1.0 - margin) { return Ok(None); } diff --git a/datafusion/sqllogictest/test_files/information_schema.slt b/datafusion/sqllogictest/test_files/information_schema.slt index 8a36d6cbaf354..3af8e1b69aa8a 100644 --- a/datafusion/sqllogictest/test_files/information_schema.slt +++ b/datafusion/sqllogictest/test_files/information_schema.slt @@ -328,6 +328,7 @@ datafusion.optimizer.hash_join_single_partition_threshold 1048576 datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 datafusion.optimizer.join_enumeration true datafusion.optimizer.join_enumeration_limit 12 +datafusion.optimizer.join_enumeration_min_improvement 10 datafusion.optimizer.join_reordering true datafusion.optimizer.max_passes 3 datafusion.optimizer.prefer_existing_sort false @@ -490,6 +491,7 @@ datafusion.optimizer.hash_join_single_partition_threshold 1048576 The maximum es datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 The maximum estimated size in rows for one input side of a HashJoin will be collected into a single partition datafusion.optimizer.join_enumeration true When set to true, the physical plan optimizer enumerates alternative join orders for connected subtrees of inner hash joins and picks the cheapest one from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics, and subtrees whose original order is already the cheapest, are left untouched. datafusion.optimizer.join_enumeration_limit 12 Maximum number of inputs in a join subtree for which `join_enumeration` runs its exhaustive dynamic programming search. Larger subtrees fall back to a greedy search. The exhaustive search costs `O(3^n)` in the number of inputs, so values above 16 are clamped to 16. +datafusion.optimizer.join_enumeration_min_improvement 10 How much cheaper an enumerated join order must be, in percent, before `join_enumeration` replaces the order the planner produced. Cardinality estimates are often unable to tell two orders apart, and a plan swapped on an estimate that close is as likely to be slower as faster. A margin keeps the planner's order unless the model is confident, at the cost of missing genuinely small wins. datafusion.optimizer.join_reordering true When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. datafusion.optimizer.max_passes 3 Number of times that the optimizer will attempt to optimize the plan datafusion.optimizer.prefer_existing_sort false When true, DataFusion will opportunistically remove sorts when the data is already sorted, (i.e. setting `preserve_order` to true on `RepartitionExec` and using `SortPreservingMergeExec`) When false, DataFusion will maximize plan parallelism using `RepartitionExec` even if this requires subsequently resorting data using a `SortExec`. diff --git a/docs/source/user-guide/configs.md b/docs/source/user-guide/configs.md index eafbabba2aa3d..0ee61ddef2534 100644 --- a/docs/source/user-guide/configs.md +++ b/docs/source/user-guide/configs.md @@ -171,6 +171,7 @@ The following configuration settings are available: | datafusion.optimizer.top_down_join_key_reordering | true | When set to true, the physical plan optimizer will run a top down process to reorder the join keys | | datafusion.optimizer.join_reordering | true | When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. | | datafusion.optimizer.join_enumeration | true | When set to true, the physical plan optimizer enumerates alternative join orders for connected subtrees of inner hash joins and picks the cheapest one from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics, and subtrees whose original order is already the cheapest, are left untouched. | +| datafusion.optimizer.join_enumeration_min_improvement | 10 | How much cheaper an enumerated join order must be, in percent, before `join_enumeration` replaces the order the planner produced. Cardinality estimates are often unable to tell two orders apart, and a plan swapped on an estimate that close is as likely to be slower as faster. A margin keeps the planner's order unless the model is confident, at the cost of missing genuinely small wins. | | datafusion.optimizer.join_enumeration_limit | 12 | Maximum number of inputs in a join subtree for which `join_enumeration` runs its exhaustive dynamic programming search. Larger subtrees fall back to a greedy search. The exhaustive search costs `O(3^n)` in the number of inputs, so values above 16 are clamped to 16. | | datafusion.optimizer.use_statistics_registry | false | When set to true, the physical plan optimizer uses the pluggable `StatisticsRegistry` for statistics propagation across operators. This enables more accurate cardinality estimates compared to each operator's built-in `partition_statistics`. | | datafusion.optimizer.prefer_hash_join | true | When set to true, the physical plan optimizer will prefer HashJoin over SortMergeJoin. HashJoin can work more efficiently than SortMergeJoin but consumes more memory | From de80dd75426b4b7e87d2314fca8eacfafb06e785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Tue, 18 Aug 2026 16:57:32 +0200 Subject: [PATCH 03/16] fix: estimate a filter one conjunct at a time, and estimate IN lists Interval analysis rejects a predicate outright if any part of it is out of reach, and `OR` is out of reach -- which an `IN` list becomes once the planner expands it. So a predicate that is mostly analyzable fell back to the flat default selectivity for all of it. TPC-DS q68 filters `date_dim` by `d_dom between 1 and 2 AND d_year IN (1999, 2000, 2001)`, which was estimated at 20% of the table, 14,610 rows, against 72 that survive. Split the predicate into top-level conjuncts, analyze the ones interval arithmetic supports, and estimate an `IN` list -- or the chain of `OR`ed equalities a short list expands into -- as the fraction of the column's values it selects, the same reasoning `col = literal` gets from `1 / NDV`. An unrecognized conjunct still contributes the default, once, as the whole predicate used to. The predicate is passed through untouched when nothing was split off it, because rebuilding the conjunction re-associates it and interval propagation is sensitive to the shape of the tree it walks. That `date_dim` filter is now estimated at 71 rows. Better estimates also mean better plans: TPC-DS q17 goes from 1.05x slower to 0.74x with join enumeration on, q6 to 0.99x and q68 to 1.02x, while every TPC-H win holds and q7 improves to 0.65x. The baseline gains too, independently of enumeration: q22 drops from 147ms to 116ms. One expectation moves: a cross join whose filtered side is now correctly estimated smaller swaps its inputs, which reorders the rows of a query that does not ask for an order. Co-Authored-By: Claude Opus 5 --- datafusion/physical-plan/src/filter.rs | 168 ++++++++++++++++-- .../sqllogictest/test_files/join.slt.part | 12 +- 2 files changed, 162 insertions(+), 18 deletions(-) diff --git a/datafusion/physical-plan/src/filter.rs b/datafusion/physical-plan/src/filter.rs index c62db109ead1c..57bbe6aee691b 100644 --- a/datafusion/physical-plan/src/filter.rs +++ b/datafusion/physical-plan/src/filter.rs @@ -35,6 +35,7 @@ use crate::filter_pushdown::{ ChildFilterDescription, ChildPushdownResult, FilterDescription, FilterPushdownPhase, FilterPushdownPropagation, PushedDown, }; +use crate::joins::utils::max_distinct_count; use crate::limit::LocalLimitExec; use crate::metrics::{MetricBuilder, MetricType}; use crate::projection::{ @@ -63,13 +64,13 @@ use datafusion_execution::TaskContext; use datafusion_expr::Operator; use datafusion_physical_expr::equivalence::ProjectionMapping; use datafusion_physical_expr::expressions::{ - BinaryExpr, Column, IsNotNullExpr, Literal, lit, + BinaryExpr, Column, InListExpr, IsNotNullExpr, Literal, lit, }; use datafusion_physical_expr::intervals::utils::check_support; use datafusion_physical_expr::utils::{collect_columns, reassign_expr_columns}; use datafusion_physical_expr::{ AcrossPartitions, AnalysisContext, ConstExpr, ExprBoundaries, PhysicalExpr, analyze, - conjunction, split_conjunction, + conjunction, conjunction_opt, split_conjunction, }; use datafusion_physical_expr_common::physical_expr::fmt_sql; @@ -359,16 +360,60 @@ impl FilterExec { } else { let null_rejecting_columns = collect_null_rejecting_columns(predicate); - if check_support(predicate, schema) { + // Estimate the predicate one top-level conjunct at a time. Interval + // analysis rejects a whole predicate if any part of it is out of + // reach, and an `IN` list is out of reach because the planner expands + // it into a chain of `OR`s. Splitting first means a query like + // `d_dom between 1 and 2 AND d_year IN (1999, 2000, 2001)` no longer + // falls back to the default selectivity for all of it: TPC-DS + // estimates that at 20% of `date_dim`, or 14,610 rows, where 72 + // survive. + let (supported, rest): (Vec<_>, Vec<_>) = split_conjunction(predicate) + .into_iter() + .partition(|conjunct| check_support(conjunct, schema)); + let split_anything = !rest.is_empty(); + + // Selectivity of the conjuncts interval analysis cannot see. An + // unrecognized one still contributes the default, once, as the whole + // predicate used to. + let mut unanalyzed_selectivity = 1.0; + let mut has_unknown_conjunct = false; + for conjunct in rest { + match in_list_selectivity( + conjunct, + &input_stats.column_statistics, + &input_num_rows, + ) { + Some(selectivity) => unanalyzed_selectivity *= selectivity, + None => has_unknown_conjunct = true, + } + } + if has_unknown_conjunct { + unanalyzed_selectivity *= default_selectivity as f64 / 100.0; + } + + // Rebuilding the conjunction re-associates it, and interval + // propagation is sensitive to the shape of the tree it walks, so the + // predicate is passed through untouched unless something was actually + // split off it. It must never be passed through when something was: + // interval analysis rejects the parts that were split off, and asking + // it to walk them anyway is an error rather than a fallback. + let analyzable = if split_anything { + conjunction_opt(supported.into_iter().cloned()) + } else { + Some(Arc::clone(predicate)) + }; + if let Some(analyzable) = analyzable { let input_analysis_ctx = AnalysisContext::try_from_statistics( schema, &input_stats.column_statistics, )?; - let analysis_ctx = analyze(predicate, input_analysis_ctx, schema)?; - let selectivity = analysis_ctx.selectivity.unwrap_or(1.0); + let analysis_ctx = analyze(&analyzable, input_analysis_ctx, schema)?; + let selectivity = + analysis_ctx.selectivity.unwrap_or(1.0) * unanalyzed_selectivity; let filtered_num_rows = input_num_rows.with_estimated_selectivity(selectivity); - let cs = collect_new_statistics( + let mut cs = collect_new_statistics( schema, &input_stats.column_statistics, analysis_ctx.boundaries, @@ -376,12 +421,23 @@ impl FilterExec { &null_rejecting_columns, filtered_num_rows, ); + // A column pinned by an equality that was split off has one + // distinct value left, which interval analysis of the remaining + // conjuncts cannot know. + for idx in &eq_columns { + if let Some(col_stat) = cs.get_mut(*idx) + && col_stat.distinct_count != Precision::Exact(1) + { + col_stat.distinct_count = + distinct_count_for_singleton_domain(filtered_num_rows); + } + } (selectivity, filtered_num_rows, cs) } else { - // Without interval boundaries, use the default selectivity and - // apply the row-count constraints that still follow from the - // filter predicate. - let selectivity = default_selectivity as f64 / 100.0; + // Nothing to derive boundaries from, so keep the input's value + // statistics and apply only the row-count constraints that still + // follow from the filter predicate. + let selectivity = unanalyzed_selectivity; let filtered_num_rows = input_num_rows.with_estimated_selectivity(selectivity); let mut cs = input_stats.to_inexact().column_statistics; @@ -959,6 +1015,91 @@ impl EmbeddedProjection for FilterExec { /// /// Only AND conjunctions are traversed; OR is intentionally skipped /// since `a = 1 OR a = 2` does not pin NDV to 1. +/// Estimates the selectivity of `col IN (a, b, c)`, including the chain of +/// `OR`ed equalities the planner expands a short list into. +/// +/// Interval arithmetic cannot narrow a column from a disjunction, so without this +/// such a conjunct would contribute nothing but the default selectivity. Here the +/// fraction of values the list selects is `distinct literals / distinct values`, +/// the same reasoning `col = literal` gets from `1 / NDV`. +/// +/// Returns `None` when the conjunct is not a list of literals over one column. +fn in_list_selectivity( + conjunct: &Arc, + column_statistics: &[ColumnStatistics], + num_rows: &Precision, +) -> Option { + let mut column = None; + let mut values = HashSet::new(); + if let Some(in_list) = conjunct.downcast_ref::() { + if in_list.negated() { + // `NOT IN` selects the complement, which is usually most of the + // column, and is left to the default rather than guessed at. + return None; + } + column = Some(in_list.expr().downcast_ref::()?); + for value in in_list.list() { + values.insert(value.downcast_ref::()?.value().clone()); + } + } else { + // Only a disjunction, so that a plain `col = literal` keeps whatever + // estimate it gets today rather than being re-derived here. + let binary = conjunct.downcast_ref::()?; + if *binary.op() != Operator::Or { + return None; + } + collect_or_equalities(conjunct, &mut column, &mut values)?; + } + + let column = column?; + if values.len() < 2 { + return None; + } + let stats = column_statistics.get(column.index())?; + let distinct = *max_distinct_count(num_rows, stats).get_value()?; + if distinct == 0 { + return None; + } + Some((values.len() as f64 / distinct as f64).min(1.0)) +} + +/// Collects the literals of an `OR` chain of equalities over a single column. +/// +/// Returns `None` as soon as the expression is anything else, so a mixed +/// disjunction such as `a = 1 OR b = 2` is not mistaken for a list. +fn collect_or_equalities<'a>( + expr: &'a Arc, + column: &mut Option<&'a Column>, + values: &mut HashSet, +) -> Option<()> { + let binary = expr.downcast_ref::()?; + match binary.op() { + Operator::Or => { + collect_or_equalities(binary.left(), column, values)?; + collect_or_equalities(binary.right(), column, values) + } + Operator::Eq => { + let (found, literal) = match ( + binary.left().downcast_ref::(), + binary.right().downcast_ref::(), + ) { + (Some(col), None) => (col, binary.right()), + (None, Some(col)) => (col, binary.left()), + _ => return None, + }; + // Every equality has to constrain the same column, or the fraction + // below would not describe the conjunct. + if column.is_some_and(|current| current != found) { + return None; + } + *column = Some(found); + values.insert(literal.downcast_ref::()?.value().clone()); + Some(()) + } + _ => None, + } +} + fn collect_equality_columns(predicate: &Arc) -> (HashSet, bool) { let mut eq_values: HashMap = HashMap::new(); let mut infeasible = false; @@ -2747,9 +2888,10 @@ mod tests { Arc::new(Literal::new(ScalarValue::Utf8(Some("b".to_string())))), )), )), - // Input NDV is 50, but the 20% default selectivity on 100 rows - // estimates 20 output rows, so NDV is capped at 20. - vec![Precision::Inexact(20)], + // The two listed values are 2 of the column's 50, so 4 of the + // 100 input rows are expected and NDV is capped at 4. Still not + // collapsed to 1, which is what this case guards. + vec![Precision::Inexact(4)], ), ( "AND with mixed types (Utf8 + Int32)", diff --git a/datafusion/sqllogictest/test_files/join.slt.part b/datafusion/sqllogictest/test_files/join.slt.part index 00bea008fc2fc..958b556e9ced2 100644 --- a/datafusion/sqllogictest/test_files/join.slt.part +++ b/datafusion/sqllogictest/test_files/join.slt.part @@ -934,10 +934,12 @@ logical_plan 05)--SubqueryAlias: d 06)----TableScan: department projection=[dept_name] physical_plan -01)CrossJoinExec -02)--FilterExec: name@1 = Alice OR name@1 = Bob +01)ProjectionExec: expr=[emp_id@1 as emp_id, name@2 as name, dept_name@0 as dept_name] +02)--CrossJoinExec 03)----DataSourceExec: partitions=1, partition_sizes=[1] -04)--DataSourceExec: partitions=1, partition_sizes=[1] +04)----RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1 +05)------FilterExec: name@1 = Alice OR name@1 = Bob +06)--------DataSourceExec: partitions=1, partition_sizes=[1] # expect no row for Carol query ITT @@ -947,10 +949,10 @@ JOIN department AS d ON (e.name = 'Alice' OR e.name = 'Bob'); ---- 1 Alice HR -1 Alice Engineering -1 Alice Sales 2 Bob HR +1 Alice Engineering 2 Bob Engineering +1 Alice Sales 2 Bob Sales # OR conditions on Filter (not join filter) From 1040be5a323410f3cf2767c2a9f540c124e0f7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Tue, 18 Aug 2026 17:33:27 +0200 Subject: [PATCH 04/16] refactor: trim join enumeration comments, drop the greedy fallback Comments were carrying explanation that the code already states. Cut them back to the reasoning that is not evident from reading it: why reordering is sound, why one key per class per cut is enough, why the predicate is not re-associated before interval analysis. Drop `solve_greedy`. It was a second search strategy for graphs above the exhaustive limit that no benchmark reaches -- TPC-H and TPC-DS graphs are eight relations at most -- so it was untested surface. Graphs that large now keep the planner's order, which is what happened before enumeration existed. That also merges the two size bounds into one. Fold three end-to-end row-equality tests into one over four queries, and fix two lint failures: `HashSet` trips `mutable_key_type`, and two bitmask tests are `is_power_of_two`. Co-Authored-By: Claude Opus 5 --- datafusion/common/src/config.rs | 28 +- .../physical_optimizer/join_enumeration.rs | 153 +++---- .../src/join_enumeration.rs | 409 +++++------------- datafusion/physical-plan/src/filter.rs | 83 ++-- .../test_files/information_schema.slt | 6 +- docs/source/user-guide/configs.md | 6 +- 6 files changed, 224 insertions(+), 461 deletions(-) diff --git a/datafusion/common/src/config.rs b/datafusion/common/src/config.rs index c8903045a7c66..41d3fd7e819b7 100644 --- a/datafusion/common/src/config.rs +++ b/datafusion/common/src/config.rs @@ -1672,28 +1672,20 @@ config_namespace! { /// query is used. pub join_reordering: bool, default = true - /// When set to true, the physical plan optimizer enumerates alternative - /// join orders for connected subtrees of inner hash joins and picks the - /// cheapest one from cardinality estimates, considering bushy shapes as - /// well as left-deep ones. Subtrees whose inputs lack row count - /// statistics, and subtrees whose original order is already the cheapest, - /// are left untouched. + /// When set to true, the physical plan optimizer enumerates join orders for + /// subtrees of joins and picks the cheapest from cardinality estimates, + /// considering bushy shapes as well as left-deep ones. Subtrees whose inputs + /// lack row count statistics are left untouched. pub join_enumeration: bool, default = true - /// How much cheaper an enumerated join order must be, in percent, before - /// `join_enumeration` replaces the order the planner produced. - /// - /// Cardinality estimates are often unable to tell two orders apart, and - /// a plan swapped on an estimate that close is as likely to be slower as - /// faster. A margin keeps the planner's order unless the model is - /// confident, at the cost of missing genuinely small wins. + /// How much cheaper an enumerated join order must be, in percent, before it + /// replaces the order the planner produced. Estimates often cannot tell two + /// orders apart, and swapping on one that close is as likely to lose as win. pub join_enumeration_min_improvement: u8, default = 10 - /// Maximum number of inputs in a join subtree for which - /// `join_enumeration` runs its exhaustive dynamic programming search. - /// Larger subtrees fall back to a greedy search. The exhaustive search - /// costs `O(3^n)` in the number of inputs, so values above 16 are - /// clamped to 16. + /// Maximum inputs in a join subtree for which `join_enumeration` searches, + /// at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do + /// subtrees of over 16 inputs whatever this is set to. pub join_enumeration_limit: usize, default = 12 /// When set to true, the physical plan optimizer uses the pluggable diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index 784aa7a32cdf2..f3841f818777b 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -40,12 +40,8 @@ use insta::assert_snapshot; use crate::physical_optimizer::join_selection::StatisticsExec; -/// Statistics for a table of `rows` rows whose columns are described by -/// `(name, distinct_count)` pairs. -/// -/// Every column gets a `[0, distinct_count)` range so that the enumerator's -/// distinct-value estimate is well defined even without an explicit -/// `distinct_count`, matching what a real scan with min/max statistics offers. +/// A table of `rows` rows with `(name, distinct_count)` columns. Each gets a +/// `[0, distinct_count)` range, as a real scan with min/max statistics would. fn table(rows: usize, columns: &[(&str, usize)]) -> (Statistics, Schema) { let column_statistics = columns .iter() @@ -160,9 +156,9 @@ fn greater_than_filter( )) } -/// A three way join whose `FROM` order is the expensive one: joining the two -/// large tables first produces a million rows, while joining either of them with -/// the tiny table first cuts the input down to ten thousand. +/// A three way join in its expensive `FROM` order: the two large tables first +/// produce a million rows, where either with the tiny table first gives ten +/// thousand. fn late_reducer_plan() -> Result> { let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); @@ -195,9 +191,8 @@ async fn reorders_a_late_reducer() -> Result<()> { StatisticsExec: col_count=1, row_count=Inexact(10) "); - // Enumeration pulls the reducing join down so the two large tables are never - // joined directly, and the join projections keep the output columns in their - // original positions. + // The reducing join moves down so the large tables never join directly, and the + // projections keep the output columns in place. assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)], projection=[f_id@0, f_type@1, o_id@3, t_type@2] HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], projection=[f_id@1, f_type@2, t_type@0] @@ -213,9 +208,8 @@ async fn respects_the_config_flag() -> Result<()> { let mut config = ConfigOptions::new(); config.optimizer.join_enumeration = false; let optimized = optimize(late_reducer_plan()?, &config)?; - // Only the local build side and partition mode decisions are made: the tiny - // table becomes the build side of the join it already sat in, but the two - // large tables are still joined first, for a million row intermediate. + // Only build side and partition mode change: the large tables still join first, + // for a million row intermediate. assert_snapshot!(formatted(&optimized), @r" ProjectionExec: expr=[f_id@1 as f_id, f_type@2 as f_type, o_id@3 as o_id, t_type@0 as t_type] HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)] @@ -251,8 +245,7 @@ async fn leaves_plans_without_statistics_alone() -> Result<()> { #[tokio::test] async fn keeps_an_already_optimal_order() -> Result<()> { - // Same tables, but now written in the cheap order to begin with. The - // enumerator must not churn the plan when it cannot do better. + // Already in the cheap order, so the enumerator must not churn the plan. let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); let types = scan(10, &[("t_type", 10)]); @@ -269,7 +262,7 @@ async fn keeps_an_already_optimal_order() -> Result<()> { Ok(()) } -/// Builds a session over four in-memory tables shaped like a small star schema. +/// A session over four in-memory tables shaped like a small star schema. async fn star_schema_context(join_enumeration: bool) -> Result { let mut config = SessionConfig::new(); config.options_mut().optimizer.join_enumeration = join_enumeration; @@ -306,53 +299,53 @@ async fn star_schema_context(join_enumeration: bool) -> Result { Ok(ctx) } -const STAR_QUERY: &str = "select f_id, f_type, f_region \ - from fact, ids, types, regions \ - where f_id = o_id and f_type = t_type and f_region = r_region \ - order by f_id"; +/// Queries over `star_schema_context`, covering a plain join tree, `EXISTS`, +/// `NOT EXISTS` and a non-equi predicate. +const STAR_QUERIES: [&str; 4] = [ + "select f_id, f_type, f_region from fact, ids, types, regions \ + where f_id = o_id and f_type = t_type and f_region = r_region order by f_id", + "select f_id, f_type from fact where exists \ + (select 1 from types where t_type = f_type) \ + and f_id in (select o_id from ids) order by f_id", + "select f_id, f_type from fact where not exists \ + (select 1 from types where t_type = f_type) \ + and f_id in (select o_id from ids) order by f_id", + "select f_id, f_type, t_type from fact, ids, types \ + where f_id = o_id and f_type = t_type and f_id > t_type order by f_id", +]; #[tokio::test] -async fn reordered_join_returns_the_same_rows() -> Result<()> { +async fn reordering_returns_the_same_rows() -> Result<()> { let enumerated = star_schema_context(true).await?; let baseline = star_schema_context(false).await?; + let mut reordered_any = false; + for query in STAR_QUERIES { + let enumerated_plan = enumerated.sql(query).await?.create_physical_plan().await?; + let baseline_plan = baseline.sql(query).await?.create_physical_plan().await?; + reordered_any |= formatted(&enumerated_plan) != formatted(&baseline_plan); - let enumerated_plan = enumerated - .sql(STAR_QUERY) - .await? - .create_physical_plan() - .await?; - let baseline_plan = baseline - .sql(STAR_QUERY) - .await? - .create_physical_plan() - .await?; - // If enumeration made no difference here the comparison below proves - // nothing, so check that it did. - assert_ne!(formatted(&enumerated_plan), formatted(&baseline_plan)); - - let enumerated_rows = enumerated.sql(STAR_QUERY).await?.collect().await?; - let baseline_rows = baseline.sql(STAR_QUERY).await?.collect().await?; - assert_eq!( - pretty_format_batches(&enumerated_rows)?.to_string(), - pretty_format_batches(&baseline_rows)?.to_string(), - ); - // And that the query is not trivially empty. - assert_eq!( - enumerated_rows.iter().map(|b| b.num_rows()).sum::(), - 24 - ); + let enumerated_rows = enumerated.sql(query).await?.collect().await?; + let baseline_rows = baseline.sql(query).await?.collect().await?; + assert_eq!( + pretty_format_batches(&enumerated_rows)?.to_string(), + pretty_format_batches(&baseline_rows)?.to_string(), + "rows differ for: {query}" + ); + assert!(enumerated_rows.iter().map(|b| b.num_rows()).sum::() > 0); + } + // Rows matching would prove nothing if no plan had changed. + assert!(reordered_any); Ok(()) } -/// A semi join whose `EXISTS` side is highly selective, sitting above a join of -/// two large tables -- the shape TPC-H q18 has. +/// A selective semi join sitting above a join of two large tables, as TPC-H q18 +/// has it. fn late_semi_join_plan(anti: bool) -> Result> { let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); - // Sized so that either way round the reducer keeps one percent of the fact - // table: ten of its thousand types match for the semi join, and all but ten - // match for the anti join. A reducer that kept most of its input would not be - // worth moving, and the enumerator would rightly leave it alone. + // Sized so the reducer keeps one percent either way round: ten of the thousand + // types match for the semi join, all but ten for the anti join. A reducer that + // kept most of its input would not be worth moving. let wanted = if anti { scan(990, &[("w_type", 990)]) } else { @@ -379,8 +372,8 @@ async fn applies_a_selective_semi_join_first() -> Result<()> { StatisticsExec: col_count=1, row_count=Inexact(10) "); - // The semi join becomes a `RightSemi` that filters the fact table before it - // reaches the inner join, with the ten row `EXISTS` side on the build side. + // A `RightSemi` filtering the fact table before the inner join, with the ten + // row `EXISTS` side building. assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)] HashJoinExec: mode=CollectLeft, join_type=RightSemi, on=[(w_type@0, f_type@1)] @@ -405,34 +398,10 @@ async fn applies_an_anti_join_first() -> Result<()> { Ok(()) } -#[tokio::test] -async fn semi_join_returns_the_same_rows() -> Result<()> { - for query in [ - "select f_id, f_type from fact where exists \ - (select 1 from types where t_type = f_type) \ - and f_id in (select o_id from ids) order by f_id", - "select f_id, f_type from fact where not exists \ - (select 1 from types where t_type = f_type) \ - and f_id in (select o_id from ids) order by f_id", - ] { - let enumerated = star_schema_context(true).await?; - let baseline = star_schema_context(false).await?; - let enumerated_rows = enumerated.sql(query).await?.collect().await?; - let baseline_rows = baseline.sql(query).await?.collect().await?; - assert_eq!( - pretty_format_batches(&enumerated_rows)?.to_string(), - pretty_format_batches(&baseline_rows)?.to_string(), - "rows differ for: {query}" - ); - assert!(enumerated_rows.iter().map(|b| b.num_rows()).sum::() > 0); - } - Ok(()) -} - #[tokio::test] async fn moves_a_non_equi_filter_with_its_join() -> Result<()> { - // `f_type > t_type` rides on the join of fact and types, which enumeration - // moves below the join with the second large table. + // `f_type > t_type` rides on the fact/types join, which moves below the join + // with the second large table. let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); let types = scan(10, &[("t_type", 10)]); @@ -446,8 +415,8 @@ async fn moves_a_non_equi_filter_with_its_join() -> Result<()> { Some(greater_than_filter(("f_type", 1), ("t_type", 0))?), )?; - // The filter is re-attached to whichever join now brings its two columns - // together, with its column indices rewritten for that join's inputs. + // Re-attached to the join that now brings its two columns together, with column + // indices rewritten for that join's inputs. assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)], projection=[f_id@0, f_type@1, o_id@3, t_type@2] HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], filter=f_type@0 > t_type@1, projection=[f_id@1, f_type@2, t_type@0] @@ -457,19 +426,3 @@ async fn moves_a_non_equi_filter_with_its_join() -> Result<()> { "); Ok(()) } - -#[tokio::test] -async fn non_equi_filter_returns_the_same_rows() -> Result<()> { - let query = "select f_id, f_type, t_type from fact, ids, types \ - where f_id = o_id and f_type = t_type and f_id > t_type order by f_id"; - let enumerated = star_schema_context(true).await?; - let baseline = star_schema_context(false).await?; - let enumerated_rows = enumerated.sql(query).await?.collect().await?; - let baseline_rows = baseline.sql(query).await?.collect().await?; - assert_eq!( - pretty_format_batches(&enumerated_rows)?.to_string(), - pretty_format_batches(&baseline_rows)?.to_string(), - ); - assert!(enumerated_rows.iter().map(|b| b.num_rows()).sum::() > 0); - Ok(()) -} diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index 0e5f07a7de85f..f59d579ae42fd 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -15,51 +15,19 @@ // specific language governing permissions and limitations // under the License. -//! Cost-based join order enumeration for [`JoinSelection`]. +//! Cost-based join order enumeration for [`JoinSelection`], which on its own +//! only picks the build side and partition mode of one join at a time. //! -//! [`JoinSelection`] on its own only makes *local* decisions: for a single join -//! it picks the build side and the partition mode. The shape of the join tree is -//! whatever the logical planner produced, which for a query written as a flat -//! list of relations is a left-deep tree in `FROM`-clause order. That order is -//! frequently far from the cheapest one, because it ignores how much each join -//! reduces or inflates its inputs. +//! A subtree of reorderable joins is flattened into a [`JoinGraph`] of opaque +//! relations plus the predicates between them, [`solve_dp`] searches the orders +//! (bushy as well as left-deep) under the [`CostModel`], and [`Rebuilder`] +//! rebuilds the subtree if the winner is cheaper by a clear margin. //! -//! This module adds the missing global step. It -//! -//! 1. **extracts** a maximal connected subtree of reorderable joins into a -//! [`JoinGraph`] of opaque *relations* (the subtree's leaves) plus the -//! predicates between them, -//! 2. **enumerates** join trees over that graph with a dynamic programming -//! search ([`solve_dp`]) that considers bushy shapes as well as left-deep -//! ones, scoring each candidate with the cardinality model in [`CostModel`], -//! and falling back to a greedy search ([`solve_greedy`]) for graphs too -//! large to enumerate exhaustively, and -//! 3. **rebuilds** the subtree from the winning plan ([`build_tree`]), -//! re-deriving every join key and filter against the new schemas and -//! inserting join projections so intermediate results stay as narrow as they -//! were before. -//! -//! The rewrite only replaces the original subtree when the winning plan is -//! strictly cheaper than the shape the planner produced, so plans that are -//! already optimal are left untouched. -//! -//! # Why reordering is sound, and what a relation set means -//! -//! A tree of inner joins is equivalent to the cross product of its relations -//! filtered by the conjunction of all its predicates. So *any* tree that applies -//! every predicate exactly once, at a node where the columns that predicate needs -//! are available, computes the same rows. Three kinds of predicate take part: -//! -//! * **Equi-join edges** ([`Edge`]) connect two relations. Each is applied at the -//! one node whose two inputs separate its endpoints. -//! * **Non-equi join filters** ([`Filter`]) may reference any number of -//! relations. Each is applied at its lowest common ancestor: the deepest node -//! whose inputs together, but neither alone, cover everything it references. -//! * **Semi and anti joins** ([`Reducer`]) are *filters on their output side*: -//! they keep or drop rows of it and contribute no columns of their own. Their -//! quantified side therefore becomes a relation that may be applied at any node -//! covering the columns its keys reference, which is what lets a selective -//! `EXISTS` run before the joins it used to sit above. +//! Reordering is sound because a tree of inner joins equals the cross product of +//! its relations filtered by all its predicates: any tree applying every +//! predicate exactly once, where the columns it needs are available, computes the +//! same rows. Semi and anti joins join in as [`Reducer`]s because they filter +//! their output side rather than contributing columns of their own. //! //! [`JoinSelection`]: crate::join_selection::JoinSelection @@ -82,27 +50,13 @@ use datafusion_physical_plan::joins::{HashJoinExec, HashJoinExecBuilder, Partiti use datafusion_physical_plan::projection::{ProjectionExec, all_alias_free_columns}; use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties}; -/// Hard upper bound on the number of relations in one join graph. -/// -/// Relation sets are bitmasks in a `u64` and the greedy search is cubic in the -/// number of relations, so very large join graphs are left alone. -const MAX_RELATIONS: usize = 32; - -/// Hard upper bound on the relations handed to the exhaustive search, whatever -/// `join_enumeration_limit` says. -/// -/// [`solve_dp`] allocates `2^n` entries and visits `3^n` splits, so an unclamped -/// configuration value would ask for absurd amounts of memory and time. Graphs -/// above this bound use [`solve_greedy`] instead. -const MAX_DP_RELATIONS: usize = 16; - -/// Computes the statistics of a plan node. -/// -/// `JoinSelection` supplies this so the enumerator sees the same estimates as -/// the rest of the rule, including the pluggable [`StatisticsRegistry`] when that -/// is enabled. -/// -/// [`StatisticsRegistry`]: datafusion_physical_plan::operator_statistics::StatisticsRegistry +/// Hard upper bound on the relations in one join graph, and on the exhaustive +/// search, which allocates `2^n` and visits `3^n`. Larger graphs keep the +/// planner's order however high `join_enumeration_limit` is set. +const MAX_RELATIONS: usize = 16; + +/// Computes the statistics of a plan node, so the enumerator sees the same +/// estimates as the rest of the rule. pub(crate) type StatsFn<'a> = dyn FnMut(&dyn ExecutionPlan) -> Result> + 'a; @@ -125,10 +79,8 @@ fn covers(mask: RelSet, required: RelSet) -> bool { required & !mask == 0 } -/// Reference to one column of one relation of a [`JoinGraph`]. -/// -/// Column plumbing is done in terms of these rather than column indices, since -/// reordering the tree changes the index any given column sits at. +/// One column of one relation. Reordering moves columns, so plumbing is done in +/// these terms rather than in indices. #[derive(Clone, Copy, PartialEq, Eq, Debug)] struct ColRef { /// Index into [`JoinGraph::relations`]. @@ -142,8 +94,8 @@ struct ColRef { enum Role { /// An ordinary input: its rows and columns flow into the output. Output, - /// The quantified side of a semi or anti join: it filters other relations and - /// contributes no columns. + /// The quantified side of a semi or anti join, which filters instead of + /// contributing columns. Reducer(Reducer), } @@ -152,11 +104,9 @@ enum Role { struct Reducer { /// `true` for an anti join, which keeps the rows that do *not* match. anti: bool, - /// Join keys, as `(column of the filtered side, column index in this - /// relation)`. + /// Keys, as `(column of the filtered side, column index here)`. keys: Vec<(ColRef, usize)>, - /// The relations the keys reference. This reducer can only be applied to a - /// set of relations covering all of them. + /// Relations the keys reference; this reducer applies only to a set covering them. required: RelSet, } @@ -178,8 +128,7 @@ struct Edge { right: ColRef, } -/// A non-equi join predicate, carried along unchanged apart from having its -/// column references rewritten for wherever it ends up. +/// A non-equi join predicate, moved along with its column references rewritten. #[derive(Debug)] struct Filter { filter: JoinFilter, @@ -189,22 +138,18 @@ struct Filter { required: RelSet, } -/// A connected set of joins, flattened into relations plus the predicates -/// between them. +/// A connected set of joins as relations plus the predicates between them. #[derive(Debug)] struct JoinGraph { relations: Vec, edges: Vec, filters: Vec, - /// The columns the original subtree emitted, in order. The rebuilt subtree - /// reproduces exactly this list, so the plan above it stays valid. + /// Columns the original subtree emitted; the rebuilt one reproduces this exactly. output: Vec, - /// Null handling shared by every join in the subtree, taken from the first - /// join seen. A join that handles nulls differently becomes a relation - /// instead of part of the graph. + /// Null handling shared by the subtree's joins. A join that differs becomes a + /// relation instead. null_equality: Option, - /// Relation sets of the original tree's internal nodes, used to score the - /// shape the planner produced against the enumerated alternatives. + /// The original tree's internal nodes, for scoring it against the alternatives. original_nodes: Vec, /// The relations that are reducers rather than ordinary inputs. reducers: RelSet, @@ -234,13 +179,12 @@ impl JoinGraph { } } -/// A valid way of combining two relation sets into one node. +/// A valid way of combining two relation sets. #[derive(Clone, Copy, Debug)] enum Combine { /// An inner join of two sets that a predicate connects. Inner, - /// A semi or anti join applying the reducer relation `reducer` to the - /// opposite set. + /// A semi or anti join applying `reducer` to the opposite set. Reducer { reducer: usize }, } @@ -250,11 +194,9 @@ struct CostModel<'a> { /// Aggregated selectivity per connected relation pair, as /// `(rel_a, rel_b, selectivity)` with `rel_a < rel_b`. pair_selectivity: Vec<(usize, usize, f64)>, - /// Neighbours of each relation, as a bitmask. Reducers neighbour nothing: - /// they are applied, not joined. + /// Neighbours of each relation. Reducers neighbour nothing: they are applied. adjacency: Vec, - /// The fraction of its filtered side's rows each reducer keeps, indexed by - /// relation. `1.0` for relations that are not reducers. + /// Fraction of its filtered side each reducer keeps; `1.0` for non-reducers. reducer_selectivity: Vec, /// Selectivity of each non-equi filter, with the relations it needs. filter_selectivity: Vec<(RelSet, f64)>, @@ -262,12 +204,8 @@ struct CostModel<'a> { impl<'a> CostModel<'a> { fn new(graph: &'a JoinGraph, config: &ConfigOptions) -> Self { - // Aggregate the predicates of a relation pair the way - // `estimate_inner_join_cardinality` does: a multi-key join is estimated - // from its single most selective key rather than by multiplying the keys - // together. Keeping the two models consistent matters, because the - // statistics the rest of `JoinSelection` reads off the rebuilt plan come - // from that function. + // Denominate each relation pair by its most selective key, as + // `estimate_inner_join_cardinality` does, so the two models agree. let mut denominators: HashMap<(usize, usize), f64> = HashMap::new(); for edge in &graph.edges { let (a, b) = (edge.left.rel, edge.right.rel); @@ -287,14 +225,13 @@ impl<'a> CostModel<'a> { pair_selectivity.push((a, b, 1.0 / denominator)); } // `HashMap` iteration order is not deterministic, but plans must be. - pair_selectivity.sort_unstable_by(|l, r| (l.0, l.1).cmp(&(r.0, r.1))); + pair_selectivity.sort_unstable_by_key(|(a, b, _)| (*a, *b)); let reducer_selectivity = (0..graph.relations.len()) .map(|rel| match graph.reducer(rel) { None => 1.0, Some(reducer) => { - // The fraction of the filtered side's key values that the - // reducer covers, from its most selective key. + // Fraction of the filtered side's key values the reducer covers. let matched = reducer .keys .iter() @@ -308,8 +245,7 @@ impl<'a> CostModel<'a> { }) .collect(); - // A non-equi filter has no statistics to go on, so it gets the same - // default the rest of the optimizer uses for an opaque predicate. + // A non-equi filter has no statistics, so it takes the optimizer's default. let default_selectivity = f64::from(config.optimizer.default_filter_selectivity) / 100.0; let filter_selectivity = graph @@ -327,16 +263,13 @@ impl<'a> CostModel<'a> { } } - /// Estimated number of rows produced by joining every relation in `mask`. - /// - /// This is the textbook estimate: the product of the relation sizes scaled - /// down by the selectivity of every predicate that applies within `mask`. It - /// depends only on the *set* of relations and not on the shape of the tree - /// that joins them, which is what makes the dynamic program below valid. + /// Estimated rows from joining every relation in `mask`: the product of the + /// relation sizes scaled by the predicates that apply within it. Depending only + /// on the *set*, not the tree shape, is what makes the dynamic program valid. fn cardinality(&self, mask: RelSet) -> f64 { let mut rows = 1.0; for rel in iter_rels(mask) { - // A reducer contributes a selectivity rather than rows of its own. + // A reducer contributes a selectivity, not rows. rows *= match self.graph.reducer(rel) { Some(_) => self.reducer_selectivity[rel], None => self.graph.relations[rel].rows, @@ -363,28 +296,25 @@ impl<'a> CostModel<'a> { /// How `left` and `right` may be combined, if at all. fn combine(&self, left: RelSet, right: RelSet) -> Option { let reducers = self.graph.reducers; - // A side that is a lone reducer is applied to the other side, which must - // supply every column the reducer's keys reference. + // A lone reducer is applied to the other side, which must supply its keys. for (reducer_side, filtered) in [(right, left), (left, right)] { - if reducer_side.count_ones() == 1 && reducer_side & reducers != 0 { + if reducer_side.is_power_of_two() && reducer_side & reducers != 0 { let reducer = reducer_side.trailing_zeros() as usize; let required = self.graph.reducer(reducer)?.required; return covers(filtered, required) .then_some(Combine::Reducer { reducer }); } } - // Otherwise this is an inner join, so both sides must contribute columns - // and a predicate must connect them. Cross products are never introduced: - // a plan needing one is left in the planner's own order. + // Otherwise an inner join: both sides contribute columns and a predicate must + // connect them. Cross products are never introduced. if left & !reducers == 0 || right & !reducers == 0 { return None; } self.connected(left, right).then_some(Combine::Inner) } - /// Cost of a join tree: the sum of the cardinalities of its internal nodes - /// (`C_out`). Leaves are excluded because every candidate tree reads the same - /// relations, making their cost a constant. + /// `C_out`: the sum of the internal nodes' cardinalities. Leaves are excluded as + /// every candidate reads the same relations. fn tree_cost(&self, nodes: &[RelSet]) -> f64 { nodes .iter() @@ -394,26 +324,21 @@ impl<'a> CostModel<'a> { } } -/// The winning join tree: for every internal node, the relation set of one of -/// its two inputs. The other input is the rest of that node's relation set. +/// The winning tree: per internal node, the relation set of one of its inputs. struct Solution { splits: HashMap, cost: f64, } -/// Exhaustive dynamic programming over connected relation subsets. -/// -/// Every subset is costed once by trying all the ways of splitting it into two -/// halves, so bushy shapes are considered alongside left-deep ones. The search is -/// `O(3^n)` in the number of relations, which is why the caller bounds `n`. +/// Exhaustive dynamic programming over connected relation subsets, costing each +/// once by trying every split, so bushy shapes are considered too. `O(3^n)`. fn solve_dp(model: &CostModel) -> Option { let n = model.graph.relations.len(); let full: RelSet = model.graph.all(); let size = 1usize << n; - // `f64::INFINITY` marks a subset that cannot be built at all, either because - // it is disconnected or because it holds a reducer whose columns it does not - // cover. + // `INFINITY` marks a subset that cannot be built: disconnected, or holding a + // reducer whose columns it does not cover. let mut cost = vec![f64::INFINITY; size]; let mut split = vec![0 as RelSet; size]; for rel in 0..n { @@ -425,8 +350,7 @@ fn solve_dp(model: &CostModel) -> Option { continue; } let cardinality = model.cardinality(mask); - // Enumerate the subsets of `mask` containing its lowest set bit, so each - // unordered pair of halves is visited exactly once. + // Subsets containing the lowest set bit, so each pair of halves is seen once. let lowest = mask & mask.wrapping_neg(); let mut left = mask; let mut best = f64::INFINITY; @@ -463,7 +387,7 @@ fn solve_dp(model: &CostModel) -> Option { return None; } - // Walk the winning tree, keeping only the splits it actually uses. + // Keep only the splits the winning tree uses. let mut splits = HashMap::new(); let mut stack = vec![full]; while let Some(mask) = stack.pop() { @@ -482,67 +406,19 @@ fn solve_dp(model: &CostModel) -> Option { }) } -/// Greedy fallback for join graphs too large for [`solve_dp`]. -/// -/// Repeatedly combines the pair of subtrees whose result is smallest. Cubic in -/// the number of relations, and it can lose to the planner's original order -- -/// which the caller checks for. -fn solve_greedy(model: &CostModel) -> Option { - let n = model.graph.relations.len(); - // (relation set, accumulated cost of the subtree built for it) - let mut components: Vec<(RelSet, f64)> = (0..n).map(|rel| (bit(rel), 0.0)).collect(); - let mut splits = HashMap::new(); - - while components.len() > 1 { - let mut best: Option<(usize, usize, f64, f64)> = None; - for i in 0..components.len() { - for j in (i + 1)..components.len() { - let (left, left_cost) = components[i]; - let (right, right_cost) = components[j]; - if model.combine(left, right).is_none() { - continue; - } - let cardinality = model.cardinality(left | right); - let cost = left_cost + right_cost + cardinality; - if best.is_none_or(|(_, _, best_cardinality, _)| { - cardinality < best_cardinality - }) { - best = Some((i, j, cardinality, cost)); - } - } - } - // Nothing left to combine without a cross product; leave the graph alone. - let (i, j, _, cost) = best?; - let (left, _) = components[i]; - let (right, _) = components[j]; - splits.insert(left | right, left); - components[i] = (left | right, cost); - components.swap_remove(j); - } - - let (_, cost) = components[0]; - Some(Solution { splits, cost }) -} - /// How a join takes part in enumeration, if at all. #[derive(Clone, Copy, Debug)] enum JoinRole { /// An inner join: both inputs are part of the graph. Inner, - /// A semi or anti join: `output` names the side whose rows survive, and the - /// other side becomes a [`Reducer`]. + /// A semi or anti join; `output` names the side whose rows survive. Reducing { anti: bool, output: JoinSide }, } -/// Classifies a join for enumeration. -/// -/// Outer and mark joins are excluded: an outer join is not a filter on its inputs -/// and so cannot be moved past one, and a mark join adds a column that the column -/// plumbing here does not model. `null_aware` anti joins are excluded because they -/// carry `NOT IN` semantics that depend on the whole probe side, and joins with a -/// limit because the limit belongs to one particular tree shape. A semi or anti -/// join with a non-equi filter is excluded too: that filter is part of an -/// existential test, not a conjunct that can move on its own. +/// Classifies a join for enumeration. Outer and mark joins are excluded (not +/// filters on their inputs, or they add a column), as are `null_aware` anti joins, +/// joins with a limit, and semi joins with a filter that is part of their +/// existential test. fn join_role(join: &HashJoinExec) -> Option { if join.null_aware || join.fetch().is_some() || join.on().is_empty() { return None; @@ -581,37 +457,29 @@ fn position(columns: &[ColRef], col: ColRef) -> Option { columns.iter().position(|candidate| *candidate == col) } -/// Appends `col` unless it is already there. fn push_unique(columns: &mut Vec, col: ColRef) { if position(columns, col).is_none() { columns.push(col); } } -/// Appends the columns of `wanted` that belong to `side`, keeping their order and -/// dropping duplicates. +/// Appends the columns of `wanted` belonging to `side`, in order, without repeats. fn extend_required(columns: &mut Vec, wanted: &[ColRef], side: RelSet) { for col in wanted.iter().filter(|col| side & bit(col.rel) != 0) { push_unique(columns, *col); } } -/// Extracts the maximal reorderable join subtree rooted at `plan`. -/// -/// Returns `None` when `plan` does not root a subtree worth enumerating, which -/// covers every bail-out condition: a join feature the enumerator does not model, -/// a join key that is not a plain column, missing row count statistics, or too -/// few or too many relations. +/// Extracts the maximal reorderable join subtree rooted at `plan`. `None` covers +/// every bail-out: an unmodelled join feature, a key that is not a plain column, +/// missing row counts, too few or too many relations. fn extract( plan: &Arc, stats: &mut StatsFn, ) -> Result> { - // Start either at a join, or at the column pruning projection that usually - // sits directly above one. Rooting the graph at the projection lets its - // column list become the top join's own projection, instead of leaving a - // `ProjectionExec` stranded above a join emitting more columns than the query - // needs. Anything else is not a subtree root, and bailing out here keeps - // whole plans from being walked for nothing. + // Start at a join, or at the column pruning projection usually sitting above + // one: rooting there lets its column list become the top join's projection + // instead of stranding a `ProjectionExec` above a wider join. let is_root = match plan.downcast_ref::() { Some(join) => join_role(join).is_some(), None => plan @@ -654,12 +522,10 @@ impl<'a, 's> Extractor<'a, 's> { graph.output = output; if graph.relations.len() < 3 { - // A single join has nothing to reorder: `JoinSelection`'s build side - // swap already covers it. + // A single join has nothing to reorder. return Ok(None); } - // A filter referencing a single relation has no join to be applied at, - // since the deepest node covering it would be a leaf. + // A filter over one relation has no join to sit at; the node would be a leaf. if graph .filters .iter() @@ -670,10 +536,8 @@ impl<'a, 's> Extractor<'a, 's> { Ok(Some(graph)) } - /// Recursively flattens `plan`, returning the columns it emits and the set of - /// relations it covers. - /// - /// `None` means the subtree cannot be reordered and the caller must give up. + /// Flattens `plan` into the columns it emits and the relations it covers. `None` + /// means the caller must give up. fn visit( &mut self, plan: &Arc, @@ -697,8 +561,7 @@ impl<'a, 's> Extractor<'a, 's> { }; self.graph.original_nodes.push(mask); - // The join's projection selects from the columns it emits, which for a - // semi or anti join are only those of its output side. + // For a semi or anti join the projection selects from the output side alone. let columns = match &join.projection { Some(projection) => projection.iter().map(|idx| columns[*idx]).collect(), None => columns, @@ -707,12 +570,9 @@ impl<'a, 's> Extractor<'a, 's> { } else if let Some(projection) = plan.downcast_ref::() && all_alias_free_columns(projection.expr()) { - // A pure column pruning projection. Looking through these is what lets - // the enumerator see a whole join chain: at this point in the - // optimizer the planner has left one between every pair of joins, and - // `ProjectionPushdown`, which folds them into the joins, has not run - // yet. `all_alias_free_columns` also rules out renaming, so dropping - // the projection cannot change the subtree's output field names. + // Looking through pruning projections is what lets the enumerator see a whole + // chain, since `ProjectionPushdown` has not folded them into the joins yet. + // `all_alias_free_columns` rules out renaming, so dropping them is safe. let Some((child, mask)) = self.visit(projection.input())? else { return Ok(None); }; @@ -727,7 +587,7 @@ impl<'a, 's> Extractor<'a, 's> { .collect::>>(); Ok(columns.map(|columns| (columns, mask))) } else { - // A leaf: opaque to the enumerator, but it needs its statistics. + // A leaf: opaque, but its statistics are needed. let Some(rel) = self.push_relation(plan, Role::Output)? else { return Ok(None); }; @@ -740,8 +600,8 @@ impl<'a, 's> Extractor<'a, 's> { } } - /// Flattens an inner join: both sides join the graph, and its predicates - /// become edges and filters. + /// Flattens an inner join: both sides join the graph, predicates become edges + /// and filters. fn visit_inner( &mut self, join: &HashJoinExec, @@ -757,15 +617,14 @@ impl<'a, 's> Extractor<'a, 's> { let (Some(left_key), Some(right_key)) = (as_column(left_key), as_column(right_key)) else { - // A key such as `cast(a) = b` would have to be re-derived against - // a different schema; not worth the complexity. + // A key like `cast(a) = b` would need re-deriving against a different schema. return Ok(None); }; let edge = Edge { left: left[left_key], right: right[right_key], }; - // Duplicate predicates would be double counted by the cost model. + // Duplicates would be double counted by the cost model. if !self .graph .edges @@ -802,8 +661,8 @@ impl<'a, 's> Extractor<'a, 's> { Ok(Some((columns, left_mask | right_mask))) } - /// Flattens a semi or anti join: its output side joins the graph, and its - /// quantified side becomes a reducer relation. + /// Flattens a semi or anti join: its output side joins the graph, its quantified + /// side becomes a reducer. fn visit_reducing( &mut self, join: &HashJoinExec, @@ -820,8 +679,7 @@ impl<'a, 's> Extractor<'a, 's> { return Ok(None); }; - // The keys are resolved against the output side's columns, so they have to - // be collected before the reducer relation that owns them exists. + // Keys resolve against the output side, so they precede the reducer relation. let mut keys = Vec::with_capacity(join.on().len()); let mut required = 0; for (left_key, right_key) in join.on() { @@ -850,15 +708,14 @@ impl<'a, 's> Extractor<'a, 's> { Ok(Some((columns, mask | bit(rel)))) } - /// Adds `plan` to the graph as a relation, returning its index. + /// Adds `plan` as a relation, returning its index. fn push_relation( &mut self, plan: &Arc, role: Role, ) -> Result> { if plan.boundedness().is_unbounded() { - // Reordering could break the pipeline properties the other - // `JoinSelection` subrules establish. + // Reordering could break the pipeline properties the other subrules establish. return Ok(None); } if self.graph.relations.len() >= MAX_RELATIONS { @@ -900,22 +757,19 @@ struct Rebuilder<'a> { graph: &'a JoinGraph, model: &'a CostModel<'a>, solution: &'a Solution, - /// The plan of each relation, already rewritten if it held a join subtree of - /// its own. + /// Each relation's plan, already rewritten if it held a join subtree. relations: &'a [Arc], } impl Rebuilder<'_> { - /// Builds the node joining every relation in `mask`, emitting `required` in - /// that order. + /// Builds the node joining `mask`, emitting `required` in that order. fn node( &self, mask: RelSet, required: &[ColRef], ) -> Result<(Arc, Vec)> { - if mask.count_ones() == 1 { - // Relations are opaque, so they are emitted as they are. Narrowing - // them is `ProjectionPushdown`'s job and it runs later. + if mask.is_power_of_two() { + // A single relation, opaque here; narrowing is `ProjectionPushdown`'s job. let rel = mask.trailing_zeros() as usize; let plan = Arc::clone(&self.relations[rel]); let columns = (0..plan.schema().fields().len()) @@ -932,10 +786,7 @@ impl Rebuilder<'_> { None => internal_err!("join enumeration produced an invalid join"), Some(Combine::Reducer { reducer }) => self.reducing(mask, required, reducer), Some(Combine::Inner) => { - // Put the cheaper side on the left. `JoinSelection`'s build side - // swap runs after this and may revise the choice from the rebuilt - // plan's statistics, but starting from the smaller side keeps the - // two decisions consistent. + // Cheaper side on the left; `JoinSelection`'s swap may still revise it. let (left, right) = if self.model.cardinality(split) <= self.model.cardinality(other) { (split, other) @@ -954,9 +805,7 @@ impl Rebuilder<'_> { left_mask: RelSet, right_mask: RelSet, ) -> Result<(Arc, Vec)> { - // Every equi-join predicate crossing this cut is applied here, and only - // here: each edge crosses exactly one cut of the tree, at the lowest node - // whose relation set contains both of its endpoints. + // Each edge crosses exactly one cut, at the lowest node holding both endpoints. let mut keys: Vec<(ColRef, ColRef)> = vec![]; for edge in &self.graph.edges { let (left, right) = (edge.left, edge.right); @@ -970,8 +819,7 @@ impl Rebuilder<'_> { return internal_err!("join enumeration produced a cross product"); } - // Non-equi filters are applied at their lowest common ancestor: this node - // covers everything the filter references, and neither input does alone. + // Filters land at their lowest common ancestor: covered here, by neither input. let mask = left_mask | right_mask; let filters: Vec<&Filter> = self .graph @@ -984,8 +832,8 @@ impl Rebuilder<'_> { }) .collect(); - // Each side must emit this join's key columns, the columns its filters - // reference, and whatever the nodes above asked for. + // Each side emits this join's keys, its filters' columns, and what is asked + // for above. let child_required = |side: RelSet, take_left: bool| { let mut columns: Vec = vec![]; for (left, right) in &keys { @@ -1022,8 +870,7 @@ impl Rebuilder<'_> { let join = HashJoinExecBuilder::new(left_plan, right_plan, on, JoinType::Inner) .with_filter(filter) .with_null_equality(self.graph.null_equality()) - // The build side and the partition mode are picked by - // `statistical_join_selection_subrule`, which runs after enumeration. + // Build side and partition mode are picked by the statistical subrule after. .with_partition_mode(PartitionMode::Auto) .with_projection(projection) .build()?; @@ -1042,10 +889,8 @@ impl Rebuilder<'_> { }; let filtered_mask = mask & !bit(reducer); - // The filtered side must emit the columns the keys compare, plus whatever - // the nodes above asked for. Non-equi filters never land here: they only - // reference output relations, so the deepest node covering one is always - // inside the filtered side. + // Filters never land here: they reference output relations only, so their + // lowest covering node is always inside the filtered side. let mut filtered_required: Vec = vec![]; for (column, _) in &info.keys { push_unique(&mut filtered_required, *column); @@ -1056,9 +901,8 @@ impl Rebuilder<'_> { self.node(filtered_mask, &filtered_required)?; let reducer_plan = Arc::clone(&self.relations[reducer]); - // The reducer goes on the build side so the filtered side can be - // streamed: `RightSemi` and `RightAnti` emit rows of their right input, - // which is exactly the filtered side. + // Reducer on the build side so the filtered side streams: `RightSemi` and + // `RightAnti` emit rows of their right input. let reducer_schema = reducer_plan.schema(); let on = info .keys @@ -1075,8 +919,7 @@ impl Rebuilder<'_> { }) .collect::>>()?; - // A semi or anti join emits only its output side, so the projection - // selects from the filtered side's columns alone. + // A semi or anti join emits only its output side. let projection = projection_for(required, &filtered_columns)?; let join_type = if info.anti { JoinType::RightAnti @@ -1093,7 +936,7 @@ impl Rebuilder<'_> { } } -/// Builds a `Column` expression for `col`, given the columns a plan emits. +/// Builds a `Column` expression for `col` against the columns a plan emits. fn key_expr( columns: &[ColRef], plan: &Arc, @@ -1105,8 +948,7 @@ fn key_expr( Ok(Arc::new(Column::new(plan.schema().field(index).name(), index)) as _) } -/// The projection selecting `required` out of `emitted`, or `None` when the node -/// already emits exactly that. +/// The projection selecting `required` out of `emitted`, or `None` if identical. fn projection_for(required: &[ColRef], emitted: &[ColRef]) -> Result>> { let mut projection = Vec::with_capacity(required.len()); for col in required { @@ -1120,14 +962,10 @@ fn projection_for(required: &[ColRef], emitted: &[ColRef]) -> Result Result { if offset == 0 { return Ok(expression); @@ -1229,36 +1066,24 @@ pub(crate) fn enumerate_join_order( } } -/// Enumerates orders for one extracted graph, rebuilding it if a cheaper order -/// exists. +/// Enumerates orders for one graph, rebuilding it if a cheaper one exists. fn reorder( graph: &JoinGraph, config: &ConfigOptions, stats: &mut StatsFn, ) -> Result>> { let model = CostModel::new(graph, config); - let limit = config - .optimizer - .join_enumeration_limit - .min(MAX_DP_RELATIONS); - let solution = if graph.relations.len() <= limit { - solve_dp(&model) - } else { - solve_greedy(&model) - }; - let Some(solution) = solution else { + let limit = config.optimizer.join_enumeration_limit.min(MAX_RELATIONS); + if graph.relations.len() > limit { + return Ok(None); + } + let Some(solution) = solve_dp(&model) else { return Ok(None); }; - // Keep the planner's order unless the winner is cheaper by a clear margin. - // - // The dynamic program considers the original shape too, so its winner is - // never *more* expensive under this model -- but "cheaper by a hair" is not - // a reason to churn a plan. Where estimates cannot tell orders apart, every - // candidate looks about the same and the winner is picked essentially - // arbitrarily, which is how TPC-DS q6 lost 37%: a subquery-filtered - // `date_dim` is estimated at 14,610 rows against 31 real ones, so no join in - // the query appears to reduce anything and all orders tie. + // Keep the planner's order unless the winner is clearly cheaper. Where estimates + // cannot tell orders apart every candidate looks alike and the winner is + // arbitrary, which cost TPC-DS q6 37% on an estimated gain under 1%. let margin = f64::from(config.optimizer.join_enumeration_min_improvement) / 100.0; if solution.cost >= model.tree_cost(&graph.original_nodes) * (1.0 - margin) { return Ok(None); diff --git a/datafusion/physical-plan/src/filter.rs b/datafusion/physical-plan/src/filter.rs index 57bbe6aee691b..40c805ed9a146 100644 --- a/datafusion/physical-plan/src/filter.rs +++ b/datafusion/physical-plan/src/filter.rs @@ -360,21 +360,16 @@ impl FilterExec { } else { let null_rejecting_columns = collect_null_rejecting_columns(predicate); - // Estimate the predicate one top-level conjunct at a time. Interval - // analysis rejects a whole predicate if any part of it is out of - // reach, and an `IN` list is out of reach because the planner expands - // it into a chain of `OR`s. Splitting first means a query like - // `d_dom between 1 and 2 AND d_year IN (1999, 2000, 2001)` no longer - // falls back to the default selectivity for all of it: TPC-DS - // estimates that at 20% of `date_dim`, or 14,610 rows, where 72 - // survive. + // Estimate one top-level conjunct at a time: interval analysis rejects a whole + // predicate if any part is out of reach, and an `IN` list is, since the planner + // expands it into `OR`s. TPC-DS estimated `d_dom between 1 and 2 AND d_year IN + // (1999, 2000, 2001)` at 20% of `date_dim`, 14,610 rows, where 72 survive. let (supported, rest): (Vec<_>, Vec<_>) = split_conjunction(predicate) .into_iter() .partition(|conjunct| check_support(conjunct, schema)); let split_anything = !rest.is_empty(); - // Selectivity of the conjuncts interval analysis cannot see. An - // unrecognized one still contributes the default, once, as the whole + // An unrecognized conjunct still contributes the default, once, as the whole // predicate used to. let mut unanalyzed_selectivity = 1.0; let mut has_unknown_conjunct = false; @@ -392,12 +387,10 @@ impl FilterExec { unanalyzed_selectivity *= default_selectivity as f64 / 100.0; } - // Rebuilding the conjunction re-associates it, and interval - // propagation is sensitive to the shape of the tree it walks, so the - // predicate is passed through untouched unless something was actually - // split off it. It must never be passed through when something was: - // interval analysis rejects the parts that were split off, and asking - // it to walk them anyway is an error rather than a fallback. + // Rebuilding re-associates the conjunction and interval propagation is + // sensitive to the tree's shape, so pass the predicate through untouched when + // nothing was split off. Never pass it through when something was: analysis + // errors on the parts it rejected rather than falling back. let analyzable = if split_anything { conjunction_opt(supported.into_iter().cloned()) } else { @@ -421,9 +414,7 @@ impl FilterExec { &null_rejecting_columns, filtered_num_rows, ); - // A column pinned by an equality that was split off has one - // distinct value left, which interval analysis of the remaining - // conjuncts cannot know. + // An equality that was split off pins its column to one distinct value. for idx in &eq_columns { if let Some(col_stat) = cs.get_mut(*idx) && col_stat.distinct_count != Precision::Exact(1) @@ -434,9 +425,8 @@ impl FilterExec { } (selectivity, filtered_num_rows, cs) } else { - // Nothing to derive boundaries from, so keep the input's value - // statistics and apply only the row-count constraints that still - // follow from the filter predicate. + // No boundaries to derive, so keep the input's value statistics and apply only + // the row-count constraints that follow from the predicate. let selectivity = unanalyzed_selectivity; let filtered_num_rows = input_num_rows.with_estimated_selectivity(selectivity); @@ -1015,35 +1005,30 @@ impl EmbeddedProjection for FilterExec { /// /// Only AND conjunctions are traversed; OR is intentionally skipped /// since `a = 1 OR a = 2` does not pin NDV to 1. -/// Estimates the selectivity of `col IN (a, b, c)`, including the chain of -/// `OR`ed equalities the planner expands a short list into. +/// Estimates `col IN (a, b, c)`, including the `OR` chain a short list expands +/// into, as `distinct literals / distinct values` -- the reasoning `col = +/// literal` gets from `1 / NDV`. Interval arithmetic cannot narrow a column from +/// a disjunction, so such a conjunct would otherwise only take the default. /// -/// Interval arithmetic cannot narrow a column from a disjunction, so without this -/// such a conjunct would contribute nothing but the default selectivity. Here the -/// fraction of values the list selects is `distinct literals / distinct values`, -/// the same reasoning `col = literal` gets from `1 / NDV`. -/// -/// Returns `None` when the conjunct is not a list of literals over one column. +/// `None` when the conjunct is not a list of literals over one column. fn in_list_selectivity( conjunct: &Arc, column_statistics: &[ColumnStatistics], num_rows: &Precision, ) -> Option { let mut column = None; - let mut values = HashSet::new(); + let mut values: Vec = vec![]; if let Some(in_list) = conjunct.downcast_ref::() { if in_list.negated() { - // `NOT IN` selects the complement, which is usually most of the - // column, and is left to the default rather than guessed at. + // `NOT IN` selects most of the column; left to the default. return None; } column = Some(in_list.expr().downcast_ref::()?); for value in in_list.list() { - values.insert(value.downcast_ref::()?.value().clone()); + push_literal(&mut values, value)?; } } else { - // Only a disjunction, so that a plain `col = literal` keeps whatever - // estimate it gets today rather than being re-derived here. + // Disjunctions only, so a plain `col = literal` keeps the estimate it has. let binary = conjunct.downcast_ref::()?; if *binary.op() != Operator::Or { return None; @@ -1063,14 +1048,24 @@ fn in_list_selectivity( Some((values.len() as f64 / distinct as f64).min(1.0)) } -/// Collects the literals of an `OR` chain of equalities over a single column. -/// -/// Returns `None` as soon as the expression is anything else, so a mixed -/// disjunction such as `a = 1 OR b = 2` is not mistaken for a list. +/// Appends `expr`'s literal value, if it is one, keeping the list distinct. +fn push_literal( + values: &mut Vec, + expr: &Arc, +) -> Option<()> { + let value = expr.downcast_ref::()?.value(); + if !values.contains(value) { + values.push(value.clone()); + } + Some(()) +} + +/// Collects the literals of an `OR` chain of equalities over one column. `None` +/// for anything else, so `a = 1 OR b = 2` is not mistaken for a list. fn collect_or_equalities<'a>( expr: &'a Arc, column: &mut Option<&'a Column>, - values: &mut HashSet, + values: &mut Vec, ) -> Option<()> { let binary = expr.downcast_ref::()?; match binary.op() { @@ -1087,14 +1082,12 @@ fn collect_or_equalities<'a>( (None, Some(col)) => (col, binary.left()), _ => return None, }; - // Every equality has to constrain the same column, or the fraction - // below would not describe the conjunct. + // All equalities must constrain the same column. if column.is_some_and(|current| current != found) { return None; } *column = Some(found); - values.insert(literal.downcast_ref::()?.value().clone()); - Some(()) + push_literal(values, literal) } _ => None, } diff --git a/datafusion/sqllogictest/test_files/information_schema.slt b/datafusion/sqllogictest/test_files/information_schema.slt index 3af8e1b69aa8a..8449d0d75ed8c 100644 --- a/datafusion/sqllogictest/test_files/information_schema.slt +++ b/datafusion/sqllogictest/test_files/information_schema.slt @@ -489,9 +489,9 @@ datafusion.optimizer.hash_join_inlist_pushdown_max_distinct_values 150 Maximum n datafusion.optimizer.hash_join_inlist_pushdown_max_size 131072 Maximum size in bytes for the build side of a hash join to be pushed down as an InList expression for dynamic filtering. Build sides larger than this will use hash table lookups instead. Set to 0 to always use hash table lookups. InList pushdown can be more efficient for small build sides because it can result in better statistics pruning as well as use any bloom filters present on the scan side. InList expressions are also more transparent and easier to serialize over the network in distributed uses of DataFusion. On the other hand InList pushdown requires making a copy of the data and thus adds some overhead to the build side and uses more memory. This setting is per-partition, so we may end up using `hash_join_inlist_pushdown_max_size` * `target_partitions` memory. The default is 128kB per partition. This should allow point lookup joins (e.g. joining on a unique primary key) to use InList pushdown in most cases but avoids excessive memory usage or overhead for larger joins. datafusion.optimizer.hash_join_single_partition_threshold 1048576 The maximum estimated size in bytes for one input side of a HashJoin will be collected into a single partition datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 The maximum estimated size in rows for one input side of a HashJoin will be collected into a single partition -datafusion.optimizer.join_enumeration true When set to true, the physical plan optimizer enumerates alternative join orders for connected subtrees of inner hash joins and picks the cheapest one from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics, and subtrees whose original order is already the cheapest, are left untouched. -datafusion.optimizer.join_enumeration_limit 12 Maximum number of inputs in a join subtree for which `join_enumeration` runs its exhaustive dynamic programming search. Larger subtrees fall back to a greedy search. The exhaustive search costs `O(3^n)` in the number of inputs, so values above 16 are clamped to 16. -datafusion.optimizer.join_enumeration_min_improvement 10 How much cheaper an enumerated join order must be, in percent, before `join_enumeration` replaces the order the planner produced. Cardinality estimates are often unable to tell two orders apart, and a plan swapped on an estimate that close is as likely to be slower as faster. A margin keeps the planner's order unless the model is confident, at the cost of missing genuinely small wins. +datafusion.optimizer.join_enumeration true When set to true, the physical plan optimizer enumerates join orders for subtrees of joins and picks the cheapest from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics are left untouched. +datafusion.optimizer.join_enumeration_limit 12 Maximum inputs in a join subtree for which `join_enumeration` searches, at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of over 16 inputs whatever this is set to. +datafusion.optimizer.join_enumeration_min_improvement 10 How much cheaper an enumerated join order must be, in percent, before it replaces the order the planner produced. Estimates often cannot tell two orders apart, and swapping on one that close is as likely to lose as win. datafusion.optimizer.join_reordering true When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. datafusion.optimizer.max_passes 3 Number of times that the optimizer will attempt to optimize the plan datafusion.optimizer.prefer_existing_sort false When true, DataFusion will opportunistically remove sorts when the data is already sorted, (i.e. setting `preserve_order` to true on `RepartitionExec` and using `SortPreservingMergeExec`) When false, DataFusion will maximize plan parallelism using `RepartitionExec` even if this requires subsequently resorting data using a `SortExec`. diff --git a/docs/source/user-guide/configs.md b/docs/source/user-guide/configs.md index 0ee61ddef2534..9de15b15df8a2 100644 --- a/docs/source/user-guide/configs.md +++ b/docs/source/user-guide/configs.md @@ -170,9 +170,9 @@ The following configuration settings are available: | datafusion.optimizer.max_passes | 3 | Number of times that the optimizer will attempt to optimize the plan | | datafusion.optimizer.top_down_join_key_reordering | true | When set to true, the physical plan optimizer will run a top down process to reorder the join keys | | datafusion.optimizer.join_reordering | true | When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. | -| datafusion.optimizer.join_enumeration | true | When set to true, the physical plan optimizer enumerates alternative join orders for connected subtrees of inner hash joins and picks the cheapest one from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics, and subtrees whose original order is already the cheapest, are left untouched. | -| datafusion.optimizer.join_enumeration_min_improvement | 10 | How much cheaper an enumerated join order must be, in percent, before `join_enumeration` replaces the order the planner produced. Cardinality estimates are often unable to tell two orders apart, and a plan swapped on an estimate that close is as likely to be slower as faster. A margin keeps the planner's order unless the model is confident, at the cost of missing genuinely small wins. | -| datafusion.optimizer.join_enumeration_limit | 12 | Maximum number of inputs in a join subtree for which `join_enumeration` runs its exhaustive dynamic programming search. Larger subtrees fall back to a greedy search. The exhaustive search costs `O(3^n)` in the number of inputs, so values above 16 are clamped to 16. | +| datafusion.optimizer.join_enumeration | true | When set to true, the physical plan optimizer enumerates join orders for subtrees of joins and picks the cheapest from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics are left untouched. | +| datafusion.optimizer.join_enumeration_min_improvement | 10 | How much cheaper an enumerated join order must be, in percent, before it replaces the order the planner produced. Estimates often cannot tell two orders apart, and swapping on one that close is as likely to lose as win. | +| datafusion.optimizer.join_enumeration_limit | 12 | Maximum inputs in a join subtree for which `join_enumeration` searches, at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of over 16 inputs whatever this is set to. | | datafusion.optimizer.use_statistics_registry | false | When set to true, the physical plan optimizer uses the pluggable `StatisticsRegistry` for statistics propagation across operators. This enables more accurate cardinality estimates compared to each operator's built-in `partition_statistics`. | | datafusion.optimizer.prefer_hash_join | true | When set to true, the physical plan optimizer will prefer HashJoin over SortMergeJoin. HashJoin can work more efficiently than SortMergeJoin but consumes more memory | | datafusion.optimizer.enable_piecewise_merge_join | false | When set to true, piecewise merge join is enabled. PiecewiseMergeJoin is currently experimental. Physical planner will opt for PiecewiseMergeJoin when there is only one range filter. | From 1a3ab0c42ebe39941aeb1b37ac248596d840522c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Tue, 18 Aug 2026 20:17:43 +0200 Subject: [PATCH 05/16] fix: keep the enumeration module private and drop unused async from tests `cargo doc` rejects public documentation that links to private items, and every item in `join_enumeration` is private or crate-visible, so the module is made private too rather than dropping the links from its own docs. The plan-shape tests never await, which `clippy::unused_async` rejects. Co-Authored-By: Claude Opus 5 --- .../physical_optimizer/join_enumeration.rs | 34 +++++++++---------- .../physical-optimizer/src/join_selection.rs | 5 ++- datafusion/physical-optimizer/src/lib.rs | 2 +- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index f3841f818777b..fc2687b7aef9b 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -179,8 +179,8 @@ fn formatted(plan: &Arc) -> String { displayable(plan.as_ref()).indent(true).to_string() } -#[tokio::test] -async fn reorders_a_late_reducer() -> Result<()> { +#[test] +fn reorders_a_late_reducer() -> Result<()> { let plan = late_reducer_plan()?; // The planner's order: the two million row tables are joined first. assert_snapshot!(formatted(&plan), @r" @@ -203,8 +203,8 @@ async fn reorders_a_late_reducer() -> Result<()> { Ok(()) } -#[tokio::test] -async fn respects_the_config_flag() -> Result<()> { +#[test] +fn respects_the_config_flag() -> Result<()> { let mut config = ConfigOptions::new(); config.optimizer.join_enumeration = false; let optimized = optimize(late_reducer_plan()?, &config)?; @@ -221,8 +221,8 @@ async fn respects_the_config_flag() -> Result<()> { Ok(()) } -#[tokio::test] -async fn leaves_plans_without_statistics_alone() -> Result<()> { +#[test] +fn leaves_plans_without_statistics_alone() -> Result<()> { let fact = scan_without_statistics(&["f_id", "f_type"]); let other = scan_without_statistics(&["o_id"]); let types = scan_without_statistics(&["t_type"]); @@ -243,8 +243,8 @@ async fn leaves_plans_without_statistics_alone() -> Result<()> { Ok(()) } -#[tokio::test] -async fn keeps_an_already_optimal_order() -> Result<()> { +#[test] +fn keeps_an_already_optimal_order() -> Result<()> { // Already in the cheap order, so the enumerator must not churn the plan. let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); @@ -263,7 +263,7 @@ async fn keeps_an_already_optimal_order() -> Result<()> { } /// A session over four in-memory tables shaped like a small star schema. -async fn star_schema_context(join_enumeration: bool) -> Result { +fn star_schema_context(join_enumeration: bool) -> Result { let mut config = SessionConfig::new(); config.options_mut().optimizer.join_enumeration = join_enumeration; let ctx = SessionContext::new_with_config(config); @@ -316,8 +316,8 @@ const STAR_QUERIES: [&str; 4] = [ #[tokio::test] async fn reordering_returns_the_same_rows() -> Result<()> { - let enumerated = star_schema_context(true).await?; - let baseline = star_schema_context(false).await?; + let enumerated = star_schema_context(true)?; + let baseline = star_schema_context(false)?; let mut reordered_any = false; for query in STAR_QUERIES { let enumerated_plan = enumerated.sql(query).await?.create_physical_plan().await?; @@ -361,8 +361,8 @@ fn late_semi_join_plan(anti: bool) -> Result> { join_of_type(joined, wanted, &[("f_type", "w_type")], join_type, None) } -#[tokio::test] -async fn applies_a_selective_semi_join_first() -> Result<()> { +#[test] +fn applies_a_selective_semi_join_first() -> Result<()> { let plan = late_semi_join_plan(false)?; assert_snapshot!(formatted(&plan), @r" HashJoinExec: mode=Auto, join_type=LeftSemi, on=[(f_type@1, w_type@0)] @@ -384,8 +384,8 @@ async fn applies_a_selective_semi_join_first() -> Result<()> { Ok(()) } -#[tokio::test] -async fn applies_an_anti_join_first() -> Result<()> { +#[test] +fn applies_an_anti_join_first() -> Result<()> { let optimized = optimize(late_semi_join_plan(true)?, &ConfigOptions::new())?; // The anti join is pushed down the same way. assert_snapshot!(formatted(&optimized), @r" @@ -398,8 +398,8 @@ async fn applies_an_anti_join_first() -> Result<()> { Ok(()) } -#[tokio::test] -async fn moves_a_non_equi_filter_with_its_join() -> Result<()> { +#[test] +fn moves_a_non_equi_filter_with_its_join() -> Result<()> { // `f_type > t_type` rides on the fact/types join, which moves below the join // with the second large table. let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); diff --git a/datafusion/physical-optimizer/src/join_selection.rs b/datafusion/physical-optimizer/src/join_selection.rs index 87429a3caa4a5..dff0be81485d3 100644 --- a/datafusion/physical-optimizer/src/join_selection.rs +++ b/datafusion/physical-optimizer/src/join_selection.rs @@ -21,9 +21,8 @@ //! tries to transform a non-runnable query (with the given infinite sources) //! into a runnable query by replacing pipeline-breaking join operations with //! pipeline-friendly ones. To achieve the second goal, it enumerates alternative -//! join orders for subtrees of inner hash joins (see [`crate::join_enumeration`]) -//! and selects the proper `PartitionMode` and the build side using the available -//! statistics for hash joins. +//! join orders for subtrees of joins and selects the proper `PartitionMode` and +//! the build side using the available statistics for hash joins. use crate::PhysicalOptimizerRule; use crate::join_enumeration::enumerate_join_order; diff --git a/datafusion/physical-optimizer/src/lib.rs b/datafusion/physical-optimizer/src/lib.rs index 4d45f573685bd..465e12e953516 100644 --- a/datafusion/physical-optimizer/src/lib.rs +++ b/datafusion/physical-optimizer/src/lib.rs @@ -34,7 +34,7 @@ pub mod ensure_requirements; // modules keep their public paths. pub use ensure_requirements::{enforce_distribution, enforce_sorting}; pub mod filter_pushdown; -pub mod join_enumeration; +mod join_enumeration; pub mod join_selection; pub mod limit_pushdown; pub mod limit_pushdown_past_window; From 47f3a3942a07b787e09a99797e3a21164224bf7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Tue, 18 Aug 2026 20:36:13 +0200 Subject: [PATCH 06/16] fix: don't re-enumerate inside a subtree whose order was kept When a graph's best order failed the improvement margin, the walk carried on into the plan's children, which re-extracted a graph one relation smaller and searched subsets the dynamic program had already costed. Worse, each level measured its gain against its own subtree's cost, so a change rejected for the whole graph could be re-adopted lower down at a fraction of the impact. Descend into the graph's relations instead, which are the opaque subplans the enumerator never looked inside, substituting them by pointer identity so the kept shape stays byte identical. Co-Authored-By: Claude Opus 5 --- .../src/join_enumeration.rs | 62 +++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index f59d579ae42fd..125ab904ca03a 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -1031,16 +1031,70 @@ fn shift_columns(expression: PhysicalExprRef, offset: usize) -> Result, Arc); + +/// Substitutes rewritten relations into a subtree, leaving its shape untouched. +/// +/// The relations are the exact `Arc`s the extractor took from this subtree, so +/// pointer identity finds them, and a match is not descended into. +fn replace_relations( + plan: &Arc, + rewritten: &[RewrittenRelation], +) -> Result>> { + if rewritten.is_empty() { + return Ok(None); + } + if let Some((_, new)) = rewritten + .iter() + .find(|(original, _)| Arc::ptr_eq(original, plan)) + { + return Ok(Some(Arc::clone(new))); + } + let mut changed = false; + let children = plan + .children() + .into_iter() + .map(|child| match replace_relations(child, rewritten)? { + Some(new_child) => { + changed = true; + Ok(new_child) + } + None => Ok(Arc::clone(child)), + }) + .collect::>>()?; + if changed { + Ok(Some(replace_children_if_necessary( + Arc::clone(plan), + children, + )?)) + } else { + Ok(None) + } +} + /// Enumerates join orders throughout `plan`, returning `None` if nothing changed. pub(crate) fn enumerate_join_order( plan: &Arc, config: &ConfigOptions, stats: &mut StatsFn, ) -> Result>> { - if let Some(graph) = extract(plan, stats)? - && let Some(reordered) = reorder(&graph, config, stats)? - { - return Ok(Some(reordered)); + if let Some(graph) = extract(plan, stats)? { + if let Some(reordered) = reorder(&graph, config, stats)? { + return Ok(Some(reordered)); + } + // Rejected, so descend into the relations rather than into the children: + // re-extracting inside this subtree would search subsets the dynamic + // program already costed, and would measure any gain against a subtree's + // own cost instead of the whole graph's, letting a change the margin + // rejected back in. + let mut rewritten: Vec = vec![]; + for relation in &graph.relations { + if let Some(new) = enumerate_join_order(&relation.plan, config, stats)? { + rewritten.push((Arc::clone(&relation.plan), new)); + } + } + return replace_relations(plan, &rewritten); } // Not a subtree that could be reordered: recurse into the children. From 074cd43eb207aa354ac717cf9044268e499bf6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Tue, 18 Aug 2026 22:08:08 +0200 Subject: [PATCH 07/16] refactor: make join enumeration its own rule It was a pre-pass inside `JoinSelection`, which hid the fact that it is a separate traversal of the whole plan and buried the ordering constraint in a comment. As `JoinEnumeration` it sits immediately before `JoinSelection` in the rule list, where "shape first, then build side and partition mode" is visible, and it shows up in verbose `EXPLAIN` like any other rule. `JoinSelection` goes back to deciding only how each join runs. Also another pass over the comments, dropping those that restate the name or signature they sit above. Co-Authored-By: Claude Opus 5 --- .../physical_optimizer/join_enumeration.rs | 19 +- .../src/join_enumeration.rs | 168 +++++++++++------- .../physical-optimizer/src/join_selection.rs | 18 +- datafusion/physical-optimizer/src/lib.rs | 2 +- .../physical-optimizer/src/optimizer.rs | 4 + datafusion/physical-plan/src/filter.rs | 9 +- .../sqllogictest/test_files/explain.slt | 4 + 7 files changed, 123 insertions(+), 101 deletions(-) diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index fc2687b7aef9b..f657ac5ee157d 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -32,6 +32,7 @@ use datafusion_expr::Operator; use datafusion_physical_expr::expressions::BinaryExpr; use datafusion_physical_expr::expressions::Column; use datafusion_physical_optimizer::PhysicalOptimizerRule; +use datafusion_physical_optimizer::join_enumeration::JoinEnumeration; use datafusion_physical_optimizer::join_selection::JoinSelection; use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode}; @@ -73,7 +74,6 @@ fn scan(rows: usize, columns: &[(&str, usize)]) -> Arc { Arc::new(StatisticsExec::new(statistics, schema)) } -/// A scan with no statistics at all. fn scan_without_statistics(columns: &[&str]) -> Arc { let schema = Schema::new( columns @@ -168,10 +168,12 @@ fn late_reducer_plan() -> Result> { join(fact_other, types, &[("f_type", "t_type")]) } +/// Both rules in pipeline order: shape first, then build side and partition mode. fn optimize( plan: Arc, config: &ConfigOptions, ) -> Result> { + let plan = JoinEnumeration::new().optimize(plan, config)?; JoinSelection::new().optimize(plan, config) } @@ -191,8 +193,7 @@ fn reorders_a_late_reducer() -> Result<()> { StatisticsExec: col_count=1, row_count=Inexact(10) "); - // The reducing join moves down so the large tables never join directly, and the - // projections keep the output columns in place. + // The reducing join moves down so the large tables never join directly. assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)], projection=[f_id@0, f_type@1, o_id@3, t_type@2] HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], projection=[f_id@1, f_type@2, t_type@0] @@ -208,8 +209,7 @@ fn respects_the_config_flag() -> Result<()> { let mut config = ConfigOptions::new(); config.optimizer.join_enumeration = false; let optimized = optimize(late_reducer_plan()?, &config)?; - // Only build side and partition mode change: the large tables still join first, - // for a million row intermediate. + // Without enumeration the large tables still join first, for a million rows. assert_snapshot!(formatted(&optimized), @r" ProjectionExec: expr=[f_id@1 as f_id, f_type@2 as f_type, o_id@3 as o_id, t_type@0 as t_type] HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)] @@ -299,8 +299,7 @@ fn star_schema_context(join_enumeration: bool) -> Result { Ok(ctx) } -/// Queries over `star_schema_context`, covering a plain join tree, `EXISTS`, -/// `NOT EXISTS` and a non-equi predicate. +/// A plain join tree, `EXISTS`, `NOT EXISTS`, and a non-equi predicate. const STAR_QUERIES: [&str; 4] = [ "select f_id, f_type, f_region from fact, ids, types, regions \ where f_id = o_id and f_type = t_type and f_region = r_region order by f_id", @@ -372,8 +371,7 @@ fn applies_a_selective_semi_join_first() -> Result<()> { StatisticsExec: col_count=1, row_count=Inexact(10) "); - // A `RightSemi` filtering the fact table before the inner join, with the ten - // row `EXISTS` side building. + // A `RightSemi` filtering the fact table before the inner join. assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)] HashJoinExec: mode=CollectLeft, join_type=RightSemi, on=[(w_type@0, f_type@1)] @@ -415,8 +413,7 @@ fn moves_a_non_equi_filter_with_its_join() -> Result<()> { Some(greater_than_filter(("f_type", 1), ("t_type", 0))?), )?; - // Re-attached to the join that now brings its two columns together, with column - // indices rewritten for that join's inputs. + // Re-attached to the join that now brings its two columns together. assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)], projection=[f_id@0, f_type@1, o_id@3, t_type@2] HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], filter=f_type@0 > t_type@1, projection=[f_id@1, f_type@2, t_type@0] diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index 125ab904ca03a..8c571692df6cb 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -15,25 +15,26 @@ // specific language governing permissions and limitations // under the License. -//! Cost-based join order enumeration for [`JoinSelection`], which on its own -//! only picks the build side and partition mode of one join at a time. +//! Cost-based join order enumeration. //! -//! A subtree of reorderable joins is flattened into a [`JoinGraph`] of opaque -//! relations plus the predicates between them, [`solve_dp`] searches the orders -//! (bushy as well as left-deep) under the [`CostModel`], and [`Rebuilder`] -//! rebuilds the subtree if the winner is cheaper by a clear margin. +//! A subtree of joins is flattened into relations plus the predicates between +//! them, a dynamic program searches the orders (bushy as well as left-deep) under +//! a `C_out` cost model, and the subtree is rebuilt if the winner is cheaper by a +//! clear margin. `JoinSelection` then picks each join's build side and partition +//! mode against the shape chosen here. //! //! Reordering is sound because a tree of inner joins equals the cross product of //! its relations filtered by all its predicates: any tree applying every //! predicate exactly once, where the columns it needs are available, computes the -//! same rows. Semi and anti joins join in as [`Reducer`]s because they filter -//! their output side rather than contributing columns of their own. -//! -//! [`JoinSelection`]: crate::join_selection::JoinSelection +//! same rows. Semi and anti joins take part as reducers, since they filter their +//! output side rather than contributing columns of their own. use std::collections::HashMap; use std::sync::Arc; +use crate::PhysicalOptimizerRule; +use crate::optimizer::{ConfigOnlyContext, PhysicalOptimizerContext}; + use arrow::datatypes::{FieldRef, Schema}; use datafusion_common::config::ConfigOptions; use datafusion_common::error::Result; @@ -47,16 +48,80 @@ use datafusion_physical_plan::joins::utils::{ ColumnIndex, JoinFilter, max_distinct_count, }; use datafusion_physical_plan::joins::{HashJoinExec, HashJoinExecBuilder, PartitionMode}; +use datafusion_physical_plan::operator_statistics::StatisticsRegistry; use datafusion_physical_plan::projection::{ProjectionExec, all_alias_free_columns}; +use datafusion_physical_plan::statistics::{StatisticsArgs, StatisticsContext}; use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties}; +/// Chooses the shape of the join tree, before [`JoinSelection`] decides how each +/// join runs. +/// +/// [`JoinSelection`]: crate::join_selection::JoinSelection +#[derive(Default, Debug)] +pub struct JoinEnumeration {} + +impl JoinEnumeration { + #[expect(missing_docs)] + pub fn new() -> Self { + Self {} + } +} + +impl PhysicalOptimizerRule for JoinEnumeration { + fn optimize( + &self, + plan: Arc, + config: &ConfigOptions, + ) -> Result> { + self.optimize_with_context(plan, &ConfigOnlyContext::new(config)) + } + + fn optimize_with_context( + &self, + plan: Arc, + context: &dyn PhysicalOptimizerContext, + ) -> Result> { + let config = context.config_options(); + if !config.optimizer.join_enumeration { + return Ok(plan); + } + let mut default_registry = None; + let registry: Option<&StatisticsRegistry> = + if config.optimizer.use_statistics_registry { + Some(context.statistics_registry().unwrap_or_else(|| { + default_registry + .insert(StatisticsRegistry::default_with_builtin_providers()) + })) + } else { + None + }; + let mut stats = |plan: &dyn ExecutionPlan| { + if let Some(registry) = registry { + registry + .compute(plan) + .map(|s| Arc::::clone(s.base_arc())) + } else { + StatisticsContext::new().compute(plan, &StatisticsArgs::new()) + } + }; + Ok(enumerate_join_order(&plan, config, &mut stats)?.unwrap_or(plan)) + } + + fn name(&self) -> &str { + "join_enumeration" + } + + fn schema_check(&self) -> bool { + true + } +} + /// Hard upper bound on the relations in one join graph, and on the exhaustive /// search, which allocates `2^n` and visits `3^n`. Larger graphs keep the /// planner's order however high `join_enumeration_limit` is set. const MAX_RELATIONS: usize = 16; -/// Computes the statistics of a plan node, so the enumerator sees the same -/// estimates as the rest of the rule. +/// Computes a plan node's statistics, shared with the rest of `JoinSelection`. pub(crate) type StatsFn<'a> = dyn FnMut(&dyn ExecutionPlan) -> Result> + 'a; @@ -67,39 +132,33 @@ fn bit(rel: usize) -> RelSet { 1u64 << rel } -/// Iterates the relation indices contained in `mask`. fn iter_rels(mask: RelSet) -> impl Iterator { std::iter::successors(Some(mask), |m| Some(m & m.wrapping_sub(1))) .take_while(|m| *m != 0) .map(|m| m.trailing_zeros() as usize) } -/// Whether `mask` contains every relation in `required`. fn covers(mask: RelSet, required: RelSet) -> bool { required & !mask == 0 } -/// One column of one relation. Reordering moves columns, so plumbing is done in -/// these terms rather than in indices. +/// One column of one relation. Reordering moves columns, so plumbing uses these +/// rather than indices. #[derive(Clone, Copy, PartialEq, Eq, Debug)] struct ColRef { - /// Index into [`JoinGraph::relations`]. rel: usize, - /// Column index within that relation's output schema. col: usize, } /// What a relation contributes to the join. #[derive(Debug)] enum Role { - /// An ordinary input: its rows and columns flow into the output. Output, /// The quantified side of a semi or anti join, which filters instead of /// contributing columns. Reducer(Reducer), } -/// The quantified side of a semi or anti join. #[derive(Debug)] struct Reducer { /// `true` for an anti join, which keeps the rows that do *not* match. @@ -156,12 +215,10 @@ struct JoinGraph { } impl JoinGraph { - /// Distinct value estimate for a column. fn ndv(&self, col: ColRef) -> f64 { self.relations[col.rel].ndv[col.col] } - /// The set of all relations in the graph. fn all(&self) -> RelSet { (0..self.relations.len()).fold(0, |mask, rel| mask | bit(rel)) } @@ -182,10 +239,11 @@ impl JoinGraph { /// A valid way of combining two relation sets. #[derive(Clone, Copy, Debug)] enum Combine { - /// An inner join of two sets that a predicate connects. Inner, /// A semi or anti join applying `reducer` to the opposite set. - Reducer { reducer: usize }, + Reducer { + reducer: usize, + }, } /// Cardinality and cost estimates over the subsets of a [`JoinGraph`]. @@ -288,12 +346,10 @@ impl<'a> CostModel<'a> { rows.max(1.0) } - /// Whether at least one equi-join predicate connects `left` and `right`. fn connected(&self, left: RelSet, right: RelSet) -> bool { iter_rels(left).any(|rel| self.adjacency[rel] & right != 0) } - /// How `left` and `right` may be combined, if at all. fn combine(&self, left: RelSet, right: RelSet) -> Option { let reducers = self.graph.reducers; // A lone reducer is applied to the other side, which must supply its keys. @@ -324,7 +380,6 @@ impl<'a> CostModel<'a> { } } -/// The winning tree: per internal node, the relation set of one of its inputs. struct Solution { splits: HashMap, cost: f64, @@ -387,7 +442,6 @@ fn solve_dp(model: &CostModel) -> Option { return None; } - // Keep only the splits the winning tree uses. let mut splits = HashMap::new(); let mut stack = vec![full]; while let Some(mask) = stack.pop() { @@ -409,16 +463,17 @@ fn solve_dp(model: &CostModel) -> Option { /// How a join takes part in enumeration, if at all. #[derive(Clone, Copy, Debug)] enum JoinRole { - /// An inner join: both inputs are part of the graph. Inner, /// A semi or anti join; `output` names the side whose rows survive. - Reducing { anti: bool, output: JoinSide }, + Reducing { + anti: bool, + output: JoinSide, + }, } -/// Classifies a join for enumeration. Outer and mark joins are excluded (not -/// filters on their inputs, or they add a column), as are `null_aware` anti joins, -/// joins with a limit, and semi joins with a filter that is part of their -/// existential test. +/// Classifies a join. Outer and mark joins are excluded (not filters on their +/// inputs, or they add a column), as are `null_aware` anti joins, joins with a +/// limit, and semi joins whose filter is part of their existential test. fn join_role(join: &HashJoinExec) -> Option { if join.null_aware || join.fetch().is_some() || join.on().is_empty() { return None; @@ -463,15 +518,14 @@ fn push_unique(columns: &mut Vec, col: ColRef) { } } -/// Appends the columns of `wanted` belonging to `side`, in order, without repeats. fn extend_required(columns: &mut Vec, wanted: &[ColRef], side: RelSet) { for col in wanted.iter().filter(|col| side & bit(col.rel) != 0) { push_unique(columns, *col); } } -/// Extracts the maximal reorderable join subtree rooted at `plan`. `None` covers -/// every bail-out: an unmodelled join feature, a key that is not a plain column, +/// Extracts the maximal reorderable subtree at `plan`. `None` covers every +/// bail-out: an unmodelled join feature, a key that is not a plain column, /// missing row counts, too few or too many relations. fn extract( plan: &Arc, @@ -492,7 +546,6 @@ fn extract( Extractor::new(stats).extract(plan) } -/// Flattens a join subtree into a [`JoinGraph`]. struct Extractor<'a, 's> { graph: JoinGraph, stats: &'s mut StatsFn<'a>, @@ -522,7 +575,6 @@ impl<'a, 's> Extractor<'a, 's> { graph.output = output; if graph.relations.len() < 3 { - // A single join has nothing to reorder. return Ok(None); } // A filter over one relation has no join to sit at; the node would be a leaf. @@ -536,8 +588,6 @@ impl<'a, 's> Extractor<'a, 's> { Ok(Some(graph)) } - /// Flattens `plan` into the columns it emits and the relations it covers. `None` - /// means the caller must give up. fn visit( &mut self, plan: &Arc, @@ -587,7 +637,6 @@ impl<'a, 's> Extractor<'a, 's> { .collect::>>(); Ok(columns.map(|columns| (columns, mask))) } else { - // A leaf: opaque, but its statistics are needed. let Some(rel) = self.push_relation(plan, Role::Output)? else { return Ok(None); }; @@ -708,7 +757,6 @@ impl<'a, 's> Extractor<'a, 's> { Ok(Some((columns, mask | bit(rel)))) } - /// Adds `plan` as a relation, returning its index. fn push_relation( &mut self, plan: &Arc, @@ -723,7 +771,6 @@ impl<'a, 's> Extractor<'a, 's> { } let statistics = (self.stats)(plan.as_ref())?; let Some(rows) = statistics.num_rows.get_value().copied() else { - // Without a row count there is no basis for reordering anything. return Ok(None); }; let rows = (rows as f64).max(1.0); @@ -752,7 +799,6 @@ impl<'a, 's> Extractor<'a, 's> { } } -/// Rebuilds a join subtree from the tree a search picked. struct Rebuilder<'a> { graph: &'a JoinGraph, model: &'a CostModel<'a>, @@ -762,7 +808,6 @@ struct Rebuilder<'a> { } impl Rebuilder<'_> { - /// Builds the node joining `mask`, emitting `required` in that order. fn node( &self, mask: RelSet, @@ -798,7 +843,6 @@ impl Rebuilder<'_> { } } - /// Builds an inner join of `left_mask` and `right_mask`. fn inner( &self, required: &[ColRef], @@ -862,7 +906,6 @@ impl Rebuilder<'_> { .collect::>>()?; let filter = rebuild_filters(&filters, &left_columns, &right_columns)?; - // The join's natural output, before its projection. let mut joined = left_columns; joined.extend(right_columns); let projection = projection_for(required, &joined)?; @@ -877,7 +920,6 @@ impl Rebuilder<'_> { Ok((Arc::new(join), required.to_vec())) } - /// Builds the semi or anti join applying `reducer` to the rest of `mask`. fn reducing( &self, mask: RelSet, @@ -936,7 +978,6 @@ impl Rebuilder<'_> { } } -/// Builds a `Column` expression for `col` against the columns a plan emits. fn key_expr( columns: &[ColRef], plan: &Arc, @@ -948,7 +989,6 @@ fn key_expr( Ok(Arc::new(Column::new(plan.schema().field(index).name(), index)) as _) } -/// The projection selecting `required` out of `emitted`, or `None` if identical. fn projection_for(required: &[ColRef], emitted: &[ColRef]) -> Result>> { let mut projection = Vec::with_capacity(required.len()); for col in required { @@ -962,10 +1002,9 @@ fn projection_for(required: &[ColRef], emitted: &[ColRef]) -> Result Result { if offset == 0 { return Ok(expression); @@ -1031,13 +1069,11 @@ fn shift_columns(expression: PhysicalExprRef, offset: usize) -> Result, Arc); /// Substitutes rewritten relations into a subtree, leaving its shape untouched. -/// -/// The relations are the exact `Arc`s the extractor took from this subtree, so -/// pointer identity finds them, and a match is not descended into. +/// The relations are the exact `Arc`s taken from it, so pointer identity finds +/// them, and a match is not descended into. fn replace_relations( plan: &Arc, rewritten: &[RewrittenRelation], @@ -1073,7 +1109,6 @@ fn replace_relations( } } -/// Enumerates join orders throughout `plan`, returning `None` if nothing changed. pub(crate) fn enumerate_join_order( plan: &Arc, config: &ConfigOptions, @@ -1083,11 +1118,9 @@ pub(crate) fn enumerate_join_order( if let Some(reordered) = reorder(&graph, config, stats)? { return Ok(Some(reordered)); } - // Rejected, so descend into the relations rather than into the children: - // re-extracting inside this subtree would search subsets the dynamic - // program already costed, and would measure any gain against a subtree's - // own cost instead of the whole graph's, letting a change the margin - // rejected back in. + // Rejected, so descend into the relations, not the children: re-extracting here + // would re-search costed subsets, and would weigh a gain against a subtree's own + // cost rather than the whole graph's, readmitting what the margin rejected. let mut rewritten: Vec = vec![]; for relation in &graph.relations { if let Some(new) = enumerate_join_order(&relation.plan, config, stats)? { @@ -1097,7 +1130,6 @@ pub(crate) fn enumerate_join_order( return replace_relations(plan, &rewritten); } - // Not a subtree that could be reordered: recurse into the children. let mut changed = false; let children = plan .children() @@ -1120,7 +1152,6 @@ pub(crate) fn enumerate_join_order( } } -/// Enumerates orders for one graph, rebuilding it if a cheaper one exists. fn reorder( graph: &JoinGraph, config: &ConfigOptions, @@ -1143,7 +1174,6 @@ fn reorder( return Ok(None); } - // Reorder inside the relations before assembling them. let mut relations = Vec::with_capacity(graph.relations.len()); for relation in &graph.relations { relations.push( diff --git a/datafusion/physical-optimizer/src/join_selection.rs b/datafusion/physical-optimizer/src/join_selection.rs index dff0be81485d3..c2c872c79a706 100644 --- a/datafusion/physical-optimizer/src/join_selection.rs +++ b/datafusion/physical-optimizer/src/join_selection.rs @@ -20,12 +20,12 @@ //! is any) to obtain more performant plans. To achieve the first goal, it //! tries to transform a non-runnable query (with the given infinite sources) //! into a runnable query by replacing pipeline-breaking join operations with -//! pipeline-friendly ones. To achieve the second goal, it enumerates alternative -//! join orders for subtrees of joins and selects the proper `PartitionMode` and -//! the build side using the available statistics for hash joins. +//! pipeline-friendly ones. To achieve the second goal, it selects the proper +//! `PartitionMode` and the build side using the available statistics for hash +//! joins. The shape of the join tree is chosen before this, by +//! [`JoinEnumeration`](crate::join_enumeration::JoinEnumeration). use crate::PhysicalOptimizerRule; -use crate::join_enumeration::enumerate_join_order; use crate::optimizer::{ConfigOnlyContext, PhysicalOptimizerContext}; use datafusion_common::Statistics; use datafusion_common::config::ConfigOptions; @@ -165,16 +165,6 @@ impl PhysicalOptimizerRule for JoinSelection { } else { None }; - // Choose the shape of the join tree before making the per-join build - // side and partition mode decisions below, which are then made against - // the inputs the chosen shape actually produces. - let plan = if config.optimizer.join_enumeration { - let mut stats = |p: &dyn ExecutionPlan| get_stats(p, registry); - enumerate_join_order(&plan, config, &mut stats)?.unwrap_or(plan) - } else { - plan - }; - let subrules: Vec> = vec![ Box::new(hash_join_convert_symmetric_subrule), Box::new(hash_join_swap_subrule), diff --git a/datafusion/physical-optimizer/src/lib.rs b/datafusion/physical-optimizer/src/lib.rs index 465e12e953516..4d45f573685bd 100644 --- a/datafusion/physical-optimizer/src/lib.rs +++ b/datafusion/physical-optimizer/src/lib.rs @@ -34,7 +34,7 @@ pub mod ensure_requirements; // modules keep their public paths. pub use ensure_requirements::{enforce_distribution, enforce_sorting}; pub mod filter_pushdown; -mod join_enumeration; +pub mod join_enumeration; pub mod join_selection; pub mod limit_pushdown; pub mod limit_pushdown_past_window; diff --git a/datafusion/physical-optimizer/src/optimizer.rs b/datafusion/physical-optimizer/src/optimizer.rs index aed25546cd09b..460b4bad46d69 100644 --- a/datafusion/physical-optimizer/src/optimizer.rs +++ b/datafusion/physical-optimizer/src/optimizer.rs @@ -25,6 +25,7 @@ use crate::combine_partial_final_agg::CombinePartialFinalAggregate; use crate::ensure_coop::EnsureCooperative; use crate::ensure_requirements::EnsureRequirements; use crate::filter_pushdown::FilterPushdown; +use crate::join_enumeration::JoinEnumeration; use crate::join_selection::JoinSelection; use crate::limit_pushdown::LimitPushdown; use crate::limited_distinct_aggregation::LimitedDistinctAggregation; @@ -93,6 +94,9 @@ impl PhysicalOptimizer { // this information is not lost across different rules during optimization. Arc::new(OutputRequirements::new_add_mode()), Arc::new(AggregateStatistics::new()), + // Chooses the shape of the join tree, so it must run before JoinSelection + // decides each join's build side and partition mode. + Arc::new(JoinEnumeration::new()), // Statistics-based join selection will change the Auto mode to a real join implementation, // like collect left, or hash join, or future sort merge join, which will influence the // EnsureRequirements rule as it decides whether to add additional repartitioning and diff --git a/datafusion/physical-plan/src/filter.rs b/datafusion/physical-plan/src/filter.rs index 40c805ed9a146..dde4fb5d89c5e 100644 --- a/datafusion/physical-plan/src/filter.rs +++ b/datafusion/physical-plan/src/filter.rs @@ -369,8 +369,8 @@ impl FilterExec { .partition(|conjunct| check_support(conjunct, schema)); let split_anything = !rest.is_empty(); - // An unrecognized conjunct still contributes the default, once, as the whole - // predicate used to. + // An unrecognized conjunct contributes the default once, as the whole predicate + // used to. let mut unanalyzed_selectivity = 1.0; let mut has_unknown_conjunct = false; for conjunct in rest { @@ -425,8 +425,7 @@ impl FilterExec { } (selectivity, filtered_num_rows, cs) } else { - // No boundaries to derive, so keep the input's value statistics and apply only - // the row-count constraints that follow from the predicate. + // No boundaries to derive, so keep the input's value statistics. let selectivity = unanalyzed_selectivity; let filtered_num_rows = input_num_rows.with_estimated_selectivity(selectivity); @@ -1048,7 +1047,6 @@ fn in_list_selectivity( Some((values.len() as f64 / distinct as f64).min(1.0)) } -/// Appends `expr`'s literal value, if it is one, keeping the list distinct. fn push_literal( values: &mut Vec, expr: &Arc, @@ -1082,7 +1080,6 @@ fn collect_or_equalities<'a>( (None, Some(col)) => (col, binary.left()), _ => return None, }; - // All equalities must constrain the same column. if column.is_some_and(|current| current != found) { return None; } diff --git a/datafusion/sqllogictest/test_files/explain.slt b/datafusion/sqllogictest/test_files/explain.slt index b6837002086ad..ad989647b6213 100644 --- a/datafusion/sqllogictest/test_files/explain.slt +++ b/datafusion/sqllogictest/test_files/explain.slt @@ -234,6 +234,7 @@ physical_plan after OutputRequirements 01)OutputRequirementExec: order_by=[], dist_by=Unspecified 02)--DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/core/tests/data/example.csv]]}, projection=[a, b, c], file_type=csv, has_header=true physical_plan after aggregate_statistics SAME TEXT AS ABOVE +physical_plan after join_enumeration SAME TEXT AS ABOVE physical_plan after join_selection SAME TEXT AS ABOVE physical_plan after LimitedDistinctAggregation SAME TEXT AS ABOVE physical_plan after FilterPushdown SAME TEXT AS ABOVE @@ -314,6 +315,7 @@ physical_plan after OutputRequirements 02)--GlobalLimitExec: skip=0, fetch=10, statistics=[Rows=Exact(8), Bytes=Absent, [(Col[0]: ScanBytes=Exact(32)),(Col[1]: ScanBytes=Inexact(24)),(Col[2]: ScanBytes=Exact(32)),(Col[3]: ScanBytes=Exact(32)),(Col[4]: ScanBytes=Exact(32)),(Col[5]: ScanBytes=Exact(64)),(Col[6]: ScanBytes=Exact(32)),(Col[7]: ScanBytes=Exact(64)),(Col[8]: ScanBytes=Inexact(88)),(Col[9]: ScanBytes=Inexact(49)),(Col[10]: ScanBytes=Exact(64))]] 03)----DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/parquet-testing/data/alltypes_plain.parquet]]}, projection=[id, bool_col, tinyint_col, smallint_col, int_col, bigint_col, float_col, double_col, date_string_col, string_col, timestamp_col], limit=10, file_type=parquet, statistics=[Rows=Exact(8), Bytes=Absent, [(Col[0]: ScanBytes=Exact(32)),(Col[1]: ScanBytes=Inexact(24)),(Col[2]: ScanBytes=Exact(32)),(Col[3]: ScanBytes=Exact(32)),(Col[4]: ScanBytes=Exact(32)),(Col[5]: ScanBytes=Exact(64)),(Col[6]: ScanBytes=Exact(32)),(Col[7]: ScanBytes=Exact(64)),(Col[8]: ScanBytes=Inexact(88)),(Col[9]: ScanBytes=Inexact(49)),(Col[10]: ScanBytes=Exact(64))]] physical_plan after aggregate_statistics SAME TEXT AS ABOVE +physical_plan after join_enumeration SAME TEXT AS ABOVE physical_plan after join_selection SAME TEXT AS ABOVE physical_plan after LimitedDistinctAggregation SAME TEXT AS ABOVE physical_plan after FilterPushdown SAME TEXT AS ABOVE @@ -360,6 +362,7 @@ physical_plan after OutputRequirements 02)--GlobalLimitExec: skip=0, fetch=10 03)----DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/parquet-testing/data/alltypes_plain.parquet]]}, projection=[id, bool_col, tinyint_col, smallint_col, int_col, bigint_col, float_col, double_col, date_string_col, string_col, timestamp_col], limit=10, file_type=parquet physical_plan after aggregate_statistics SAME TEXT AS ABOVE +physical_plan after join_enumeration SAME TEXT AS ABOVE physical_plan after join_selection SAME TEXT AS ABOVE physical_plan after LimitedDistinctAggregation SAME TEXT AS ABOVE physical_plan after FilterPushdown SAME TEXT AS ABOVE @@ -613,6 +616,7 @@ physical_plan after OutputRequirements 01)OutputRequirementExec: order_by=[], dist_by=Unspecified 02)--DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/core/tests/data/example.csv]]}, projection=[a, b, c], file_type=csv, has_header=true physical_plan after aggregate_statistics SAME TEXT AS ABOVE +physical_plan after join_enumeration SAME TEXT AS ABOVE physical_plan after join_selection SAME TEXT AS ABOVE physical_plan after LimitedDistinctAggregation SAME TEXT AS ABOVE physical_plan after FilterPushdown SAME TEXT AS ABOVE From 53da44d280bea4390cff1822a80895e65384eacb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Wed, 19 Aug 2026 07:43:30 +0200 Subject: [PATCH 08/16] docs: add join_enumeration to the optimizer rule reference `physical_rules_match_documented_order` checks the rule list against this table, so the new rule has to appear in it, which renumbers the rules after it. Co-Authored-By: Claude Opus 5 --- .../core/src/optimizer_rule_reference.md | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/datafusion/core/src/optimizer_rule_reference.md b/datafusion/core/src/optimizer_rule_reference.md index 1367ed0843c59..e4bb2350958e3 100644 --- a/datafusion/core/src/optimizer_rule_reference.md +++ b/datafusion/core/src/optimizer_rule_reference.md @@ -72,22 +72,23 @@ in multiple phases. | ----- | ------------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | 1 | `OutputRequirements` | add phase | Adds helper nodes so output requirements survive later physical rewrites. | | 2 | `aggregate_statistics` | - | Uses exact source statistics to answer some aggregates without scanning data. | -| 3 | `join_selection` | - | Chooses join implementation, build side, and partition mode from statistics and stream properties. | -| 4 | `LimitedDistinctAggregation` | - | Pushes limit hints into grouped distinct-style aggregations when only a small result is needed. | -| 5 | `FilterPushdown` | pre-optimization phase | Pushes supported physical filters down toward data sources before distribution and sorting are enforced. | -| 6 | `WindowTopN` | - | Replaces eligible row-number window and filter patterns with per-partition TopK execution. | -| 7 | `EnsureRequirements` | - | Enforces both distribution and sorting requirements in a single idempotent rule. | -| 8 | `CombinePartialFinalAggregate` | - | Collapses adjacent partial and final aggregates when the distributed shape makes them redundant. | -| 9 | `OptimizeAggregateOrder` | - | Updates aggregate expressions to use the best ordering once sort requirements are known. | -| 10 | `ProjectionPushdown` | early pass | Pushes projections toward inputs before later physical rewrites add more limit and TopK structure. | -| 11 | `OutputRequirements` | remove phase | Removes the temporary output-requirement helper nodes after requirement-sensitive planning is done. | -| 12 | `LimitAggregation` | - | Passes a limit hint into eligible aggregations so they can keep fewer accumulator buckets. | -| 13 | `LimitPushPastWindows` | - | Pushes fetch limits through bounded window operators when doing so keeps the result correct. | -| 14 | `HashJoinBuffering` | - | Adds buffering on the probe side of hash joins so probing can start before build completion. | -| 15 | `LimitPushdown` | - | Moves physical limits into child operators or fetch-enabled variants to cut data early. | -| 16 | `TopKRepartition` | - | Pushes TopK below hash repartition when the partition key is a prefix of the sort key. | -| 17 | `ProjectionPushdown` | late pass | Runs projection pushdown again after limit and TopK rewrites expose new pruning opportunities. | -| 18 | `PushdownSort` | - | Pushes sort requirements into data sources that can already return sorted output. | -| 19 | `EnsureCooperative` | - | Wraps non-cooperative plan parts so long-running tasks yield fairly. | -| 20 | `FilterPushdown(Post)` | post-optimization phase | Pushes dynamic filters at the end of optimization, after plan references stop moving. | -| 21 | `SanityCheckPlan` | - | Validates that the final physical plan meets ordering, distribution, and infinite-input safety requirements. | +| 3 | `join_enumeration` | - | Chooses the join tree shape by costing alternative orders from statistics. | +| 4 | `join_selection` | - | Chooses join implementation, build side, and partition mode from statistics and stream properties. | +| 5 | `LimitedDistinctAggregation` | - | Pushes limit hints into grouped distinct-style aggregations when only a small result is needed. | +| 6 | `FilterPushdown` | pre-optimization phase | Pushes supported physical filters down toward data sources before distribution and sorting are enforced. | +| 7 | `WindowTopN` | - | Replaces eligible row-number window and filter patterns with per-partition TopK execution. | +| 8 | `EnsureRequirements` | - | Enforces both distribution and sorting requirements in a single idempotent rule. | +| 9 | `CombinePartialFinalAggregate` | - | Collapses adjacent partial and final aggregates when the distributed shape makes them redundant. | +| 10 | `OptimizeAggregateOrder` | - | Updates aggregate expressions to use the best ordering once sort requirements are known. | +| 11 | `ProjectionPushdown` | early pass | Pushes projections toward inputs before later physical rewrites add more limit and TopK structure. | +| 12 | `OutputRequirements` | remove phase | Removes the temporary output-requirement helper nodes after requirement-sensitive planning is done. | +| 13 | `LimitAggregation` | - | Passes a limit hint into eligible aggregations so they can keep fewer accumulator buckets. | +| 14 | `LimitPushPastWindows` | - | Pushes fetch limits through bounded window operators when doing so keeps the result correct. | +| 15 | `HashJoinBuffering` | - | Adds buffering on the probe side of hash joins so probing can start before build completion. | +| 16 | `LimitPushdown` | - | Moves physical limits into child operators or fetch-enabled variants to cut data early. | +| 17 | `TopKRepartition` | - | Pushes TopK below hash repartition when the partition key is a prefix of the sort key. | +| 18 | `ProjectionPushdown` | late pass | Runs projection pushdown again after limit and TopK rewrites expose new pruning opportunities. | +| 19 | `PushdownSort` | - | Pushes sort requirements into data sources that can already return sorted output. | +| 20 | `EnsureCooperative` | - | Wraps non-cooperative plan parts so long-running tasks yield fairly. | +| 21 | `FilterPushdown(Post)` | post-optimization phase | Pushes dynamic filters at the end of optimization, after plan references stop moving. | +| 22 | `SanityCheckPlan` | - | Validates that the final physical plan meets ordering, distribution, and infinite-input safety requirements. | From f37ebb5c443dcff2f94c329dace02432bc76e298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Wed, 19 Aug 2026 08:21:00 +0200 Subject: [PATCH 09/16] feat: enumerate sort merge join orders too Only `HashJoinExec` was recognized, so with `prefer_hash_join = false` the rule did nothing. Both operators are now read through one `JoinView` and rebuilt as whichever kind the subtree used; a graph mixing the two treats the odd join as a relation, as it already does for a differing null equality. A sort merge join has no built-in projection, so its nodes emit every column and one projection restores the subtree's output. This surfaced a correctness bug in filter re-attachment. A hash join reads a filter's intermediate columns positionally, but a sort merge join rebuilds that batch as all its left columns followed by all its right ones, so a filter whose sides were interleaved -- which is exactly what moving it to another join can do -- evaluated against the wrong columns. `f_id > t_type` became a comparison of the wrong pair and returned nothing. Intermediate columns are now ordered left side first, which a hash join cannot observe and a sort merge join needs. The end-to-end row-equality test runs every query under both join implementations, which is what caught it. Co-Authored-By: Claude Opus 5 --- .../physical_optimizer/join_enumeration.rs | 75 ++++- .../src/join_enumeration.rs | 289 ++++++++++++++---- 2 files changed, 302 insertions(+), 62 deletions(-) diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index f657ac5ee157d..a2b216a91999c 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use arrow::array::{Int32Array, RecordBatch}; +use arrow::compute::SortOptions; use arrow::datatypes::{DataType, Field, Schema}; use arrow::util::pretty::pretty_format_batches; use datafusion::prelude::{SessionConfig, SessionContext}; @@ -35,7 +36,7 @@ use datafusion_physical_optimizer::PhysicalOptimizerRule; use datafusion_physical_optimizer::join_enumeration::JoinEnumeration; use datafusion_physical_optimizer::join_selection::JoinSelection; use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; -use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode}; +use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode, SortMergeJoinExec}; use datafusion_physical_plan::{ExecutionPlan, displayable}; use insta::assert_snapshot; @@ -97,6 +98,41 @@ fn join( join_of_type(left, right, on, JoinType::Inner, None) } +/// The same shape as [`late_reducer_plan`], joined by sort merge instead of hash. +fn sort_merge_late_reducer_plan() -> Result> { + let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); + let other = scan(1_000_000, &[("o_id", 1_000_000)]); + let types = scan(10, &[("t_type", 10)]); + + let joined = sort_merge_join(fact, other, &[("f_id", "o_id")])?; + sort_merge_join(joined, types, &[("f_type", "t_type")]) +} + +fn sort_merge_join( + left: Arc, + right: Arc, + on: &[(&str, &str)], +) -> Result> { + let keys = on + .iter() + .map(|(left_key, right_key)| { + Ok(( + Arc::new(Column::new_with_schema(left_key, &left.schema())?) as _, + Arc::new(Column::new_with_schema(right_key, &right.schema())?) as _, + )) + }) + .collect::>>()?; + Ok(Arc::new(SortMergeJoinExec::try_new( + left, + right, + keys, + None, + JoinType::Inner, + vec![SortOptions::default(); on.len()], + NullEquality::NullEqualsNothing, + )?)) +} + fn join_of_type( left: Arc, right: Arc, @@ -263,9 +299,13 @@ fn keeps_an_already_optimal_order() -> Result<()> { } /// A session over four in-memory tables shaped like a small star schema. -fn star_schema_context(join_enumeration: bool) -> Result { +fn star_schema_context( + join_enumeration: bool, + prefer_hash_join: bool, +) -> Result { let mut config = SessionConfig::new(); config.options_mut().optimizer.join_enumeration = join_enumeration; + config.options_mut().optimizer.prefer_hash_join = prefer_hash_join; let ctx = SessionContext::new_with_config(config); let ints = |name: &str, values: Vec| -> Result { @@ -315,8 +355,15 @@ const STAR_QUERIES: [&str; 4] = [ #[tokio::test] async fn reordering_returns_the_same_rows() -> Result<()> { - let enumerated = star_schema_context(true)?; - let baseline = star_schema_context(false)?; + for prefer_hash_join in [true, false] { + reordering_returns_the_same_rows_with(prefer_hash_join).await?; + } + Ok(()) +} + +async fn reordering_returns_the_same_rows_with(prefer_hash_join: bool) -> Result<()> { + let enumerated = star_schema_context(true, prefer_hash_join)?; + let baseline = star_schema_context(false, prefer_hash_join)?; let mut reordered_any = false; for query in STAR_QUERIES { let enumerated_plan = enumerated.sql(query).await?.create_physical_plan().await?; @@ -416,10 +463,28 @@ fn moves_a_non_equi_filter_with_its_join() -> Result<()> { // Re-attached to the join that now brings its two columns together. assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(f_id@0, o_id@0)], projection=[f_id@0, f_type@1, o_id@3, t_type@2] - HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], filter=f_type@0 > t_type@1, projection=[f_id@1, f_type@2, t_type@0] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@0, f_type@1)], filter=f_type@1 > t_type@0, projection=[f_id@1, f_type@2, t_type@0] StatisticsExec: col_count=1, row_count=Inexact(10) StatisticsExec: col_count=2, row_count=Inexact(1000000) StatisticsExec: col_count=1, row_count=Inexact(1000000) "); Ok(()) } + +#[test] +fn reorders_sort_merge_joins() -> Result<()> { + // A sort merge join carries no projection, so the columns the subtree used to + // emit are restored by one projection above it. + assert_snapshot!( + formatted(&optimize(sort_merge_late_reducer_plan()?, &ConfigOptions::new())?), + @r" + ProjectionExec: expr=[f_id@1 as f_id, f_type@2 as f_type, o_id@3 as o_id, t_type@0 as t_type] + SortMergeJoinExec: join_type=Inner, on=[(f_id@1, o_id@0)] + SortMergeJoinExec: join_type=Inner, on=[(t_type@0, f_type@1)] + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=2, row_count=Inexact(1000000) + StatisticsExec: col_count=1, row_count=Inexact(1000000) + " + ); + Ok(()) +} diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index 8c571692df6cb..fb427ee3432ee 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -35,6 +35,7 @@ use std::sync::Arc; use crate::PhysicalOptimizerRule; use crate::optimizer::{ConfigOnlyContext, PhysicalOptimizerContext}; +use arrow::compute::SortOptions; use arrow::datatypes::{FieldRef, Schema}; use datafusion_common::config::ConfigOptions; use datafusion_common::error::Result; @@ -43,11 +44,14 @@ use datafusion_common::{JoinSide, JoinType, NullEquality, Statistics, internal_e use datafusion_expr_common::operator::Operator; use datafusion_physical_expr::PhysicalExprRef; use datafusion_physical_expr::expressions::{BinaryExpr, Column}; +use datafusion_physical_expr::projection::ProjectionExpr; use datafusion_physical_plan::execution_plan::replace_children_if_necessary; use datafusion_physical_plan::joins::utils::{ ColumnIndex, JoinFilter, max_distinct_count, }; -use datafusion_physical_plan::joins::{HashJoinExec, HashJoinExecBuilder, PartitionMode}; +use datafusion_physical_plan::joins::{ + HashJoinExec, HashJoinExecBuilder, PartitionMode, SortMergeJoinExec, +}; use datafusion_physical_plan::operator_statistics::StatisticsRegistry; use datafusion_physical_plan::projection::{ProjectionExec, all_alias_free_columns}; use datafusion_physical_plan::statistics::{StatisticsArgs, StatisticsContext}; @@ -212,6 +216,8 @@ struct JoinGraph { original_nodes: Vec, /// The relations that are reducers rather than ordinary inputs. reducers: RelSet, + /// Which join operator the subtree used, and which the rebuild emits. + kind: Option, } impl JoinGraph { @@ -230,6 +236,10 @@ impl JoinGraph { } } + fn kind(&self) -> JoinKind { + self.kind.unwrap_or(JoinKind::Hash) + } + fn null_equality(&self) -> NullEquality { self.null_equality .unwrap_or(NullEquality::NullEqualsNothing) @@ -460,6 +470,56 @@ fn solve_dp(model: &CostModel) -> Option { }) } +/// The join operators the rule can flatten and rebuild. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum JoinKind { + Hash, + /// Emits every column, having no built-in projection; the subtree gets one + /// projection on top instead. + SortMerge, +} + +/// One join, seen the same way whichever operator implements it. +struct JoinView<'a> { + kind: JoinKind, + role: JoinRole, + left: &'a Arc, + right: &'a Arc, + on: &'a [(PhysicalExprRef, PhysicalExprRef)], + filter: Option<&'a JoinFilter>, + null_equality: NullEquality, + projection: Option<&'a [usize]>, +} + +fn join_view(plan: &Arc) -> Option> { + if let Some(join) = plan.downcast_ref::() { + if join.null_aware || join.fetch().is_some() { + return None; + } + return Some(JoinView { + kind: JoinKind::Hash, + role: join_role(join.join_type(), join.filter().is_some(), join.on())?, + left: join.left(), + right: join.right(), + on: join.on(), + filter: join.filter(), + null_equality: join.null_equality, + projection: join.projection.as_deref(), + }); + } + let join = plan.downcast_ref::()?; + Some(JoinView { + kind: JoinKind::SortMerge, + role: join_role(&join.join_type(), join.filter().is_some(), join.on())?, + left: join.left(), + right: join.right(), + on: join.on(), + filter: join.filter().as_ref(), + null_equality: join.null_equality(), + projection: None, + }) +} + /// How a join takes part in enumeration, if at all. #[derive(Clone, Copy, Debug)] enum JoinRole { @@ -474,11 +534,15 @@ enum JoinRole { /// Classifies a join. Outer and mark joins are excluded (not filters on their /// inputs, or they add a column), as are `null_aware` anti joins, joins with a /// limit, and semi joins whose filter is part of their existential test. -fn join_role(join: &HashJoinExec) -> Option { - if join.null_aware || join.fetch().is_some() || join.on().is_empty() { +fn join_role( + join_type: &JoinType, + has_filter: bool, + on: &[(PhysicalExprRef, PhysicalExprRef)], +) -> Option { + if on.is_empty() { return None; } - let role = match join.join_type() { + let role = match join_type { JoinType::Inner => JoinRole::Inner, JoinType::LeftSemi => JoinRole::Reducing { anti: false, @@ -498,7 +562,7 @@ fn join_role(join: &HashJoinExec) -> Option { }, _ => return None, }; - if join.filter().is_some() && !matches!(role, JoinRole::Inner) { + if has_filter && !matches!(role, JoinRole::Inner) { return None; } Some(role) @@ -534,12 +598,10 @@ fn extract( // Start at a join, or at the column pruning projection usually sitting above // one: rooting there lets its column list become the top join's projection // instead of stranding a `ProjectionExec` above a wider join. - let is_root = match plan.downcast_ref::() { - Some(join) => join_role(join).is_some(), - None => plan + let is_root = join_view(plan).is_some() + || plan .downcast_ref::() - .is_some_and(|projection| all_alias_free_columns(projection.expr())), - }; + .is_some_and(|projection| all_alias_free_columns(projection.expr())); if !is_root { return Ok(None); } @@ -562,6 +624,7 @@ impl<'a, 's> Extractor<'a, 's> { null_equality: None, original_nodes: vec![], reducers: 0, + kind: None, }, stats, } @@ -592,18 +655,19 @@ impl<'a, 's> Extractor<'a, 's> { &mut self, plan: &Arc, ) -> Result, RelSet)>> { - if let Some(join) = plan.downcast_ref::() - && let Some(role) = join_role(join) + if let Some(view) = join_view(plan) && self .graph .null_equality - .is_none_or(|null_equality| null_equality == join.null_equality) + .is_none_or(|null_equality| null_equality == view.null_equality) + && self.graph.kind.is_none_or(|kind| kind == view.kind) { - self.graph.null_equality = Some(join.null_equality); - let visited = match role { - JoinRole::Inner => self.visit_inner(join)?, + self.graph.null_equality = Some(view.null_equality); + self.graph.kind = Some(view.kind); + let visited = match view.role { + JoinRole::Inner => self.visit_inner(&view)?, JoinRole::Reducing { anti, output } => { - self.visit_reducing(join, anti, output)? + self.visit_reducing(&view, anti, output)? } }; let Some((columns, mask)) = visited else { @@ -612,7 +676,7 @@ impl<'a, 's> Extractor<'a, 's> { self.graph.original_nodes.push(mask); // For a semi or anti join the projection selects from the output side alone. - let columns = match &join.projection { + let columns = match view.projection { Some(projection) => projection.iter().map(|idx| columns[*idx]).collect(), None => columns, }; @@ -651,18 +715,15 @@ impl<'a, 's> Extractor<'a, 's> { /// Flattens an inner join: both sides join the graph, predicates become edges /// and filters. - fn visit_inner( - &mut self, - join: &HashJoinExec, - ) -> Result, RelSet)>> { - let Some((left, left_mask)) = self.visit(join.left())? else { + fn visit_inner(&mut self, view: &JoinView) -> Result, RelSet)>> { + let Some((left, left_mask)) = self.visit(view.left)? else { return Ok(None); }; - let Some((right, right_mask)) = self.visit(join.right())? else { + let Some((right, right_mask)) = self.visit(view.right)? else { return Ok(None); }; - for (left_key, right_key) in join.on() { + for (left_key, right_key) in view.on { let (Some(left_key), Some(right_key)) = (as_column(left_key), as_column(right_key)) else { @@ -684,7 +745,7 @@ impl<'a, 's> Extractor<'a, 's> { } } - if let Some(filter) = join.filter() { + if let Some(filter) = view.filter { let columns = filter .column_indices() .iter() @@ -714,13 +775,13 @@ impl<'a, 's> Extractor<'a, 's> { /// side becomes a reducer. fn visit_reducing( &mut self, - join: &HashJoinExec, + view: &JoinView, anti: bool, output: JoinSide, ) -> Result, RelSet)>> { let (output_plan, reducer_plan) = match output { - JoinSide::Left => (join.left(), join.right()), - JoinSide::Right => (join.right(), join.left()), + JoinSide::Left => (view.left, view.right), + JoinSide::Right => (view.right, view.left), JoinSide::None => return internal_err!("semi join with no output side"), }; @@ -729,9 +790,9 @@ impl<'a, 's> Extractor<'a, 's> { }; // Keys resolve against the output side, so they precede the reducer relation. - let mut keys = Vec::with_capacity(join.on().len()); + let mut keys = Vec::with_capacity(view.on.len()); let mut required = 0; - for (left_key, right_key) in join.on() { + for (left_key, right_key) in view.on { let (output_key, reducer_key) = match output { JoinSide::Left => (left_key, right_key), _ => (right_key, left_key), @@ -843,6 +904,55 @@ impl Rebuilder<'_> { } } + /// Builds one join of the kind the subtree used. + fn build_join( + &self, + left: Built, + right: Built, + on: Vec<(PhysicalExprRef, PhysicalExprRef)>, + join_type: JoinType, + filter: Option, + required: &[ColRef], + ) -> Result<(Arc, Vec)> { + // A semi or anti join emits one side, and a reducer's own columns are never + // emitted, so its `Built` carries none. + let natural = match join_type { + JoinType::RightSemi | JoinType::RightAnti => right.columns.clone(), + JoinType::LeftSemi | JoinType::LeftAnti => left.columns.clone(), + _ => { + let mut natural = left.columns.clone(); + natural.extend(right.columns.clone()); + natural + } + }; + let keys = on.len(); + match self.graph.kind() { + JoinKind::Hash => { + let join = HashJoinExecBuilder::new(left.plan, right.plan, on, join_type) + .with_filter(filter) + .with_null_equality(self.graph.null_equality()) + // Build side and partition mode are picked by the + // statistical subrule after. + .with_partition_mode(PartitionMode::Auto) + .with_projection(projection_for(required, &natural)?) + .build()?; + Ok((Arc::new(join), required.to_vec())) + } + JoinKind::SortMerge => { + let join = SortMergeJoinExec::try_new( + left.plan, + right.plan, + on, + filter, + join_type, + vec![SortOptions::default(); keys], + self.graph.null_equality(), + )?; + Ok((Arc::new(join), natural)) + } + } + } + fn inner( &self, required: &[ColRef], @@ -906,18 +1016,20 @@ impl Rebuilder<'_> { .collect::>>()?; let filter = rebuild_filters(&filters, &left_columns, &right_columns)?; - let mut joined = left_columns; - joined.extend(right_columns); - let projection = projection_for(required, &joined)?; - - let join = HashJoinExecBuilder::new(left_plan, right_plan, on, JoinType::Inner) - .with_filter(filter) - .with_null_equality(self.graph.null_equality()) - // Build side and partition mode are picked by the statistical subrule after. - .with_partition_mode(PartitionMode::Auto) - .with_projection(projection) - .build()?; - Ok((Arc::new(join), required.to_vec())) + self.build_join( + Built { + plan: left_plan, + columns: left_columns, + }, + Built { + plan: right_plan, + columns: right_columns, + }, + on, + JoinType::Inner, + filter, + required, + ) } fn reducing( @@ -961,20 +1073,26 @@ impl Rebuilder<'_> { }) .collect::>>()?; - // A semi or anti join emits only its output side. - let projection = projection_for(required, &filtered_columns)?; let join_type = if info.anti { JoinType::RightAnti } else { JoinType::RightSemi }; - let join = HashJoinExecBuilder::new(reducer_plan, filtered_plan, on, join_type) - .with_null_equality(self.graph.null_equality()) - .with_partition_mode(PartitionMode::Auto) - .with_projection(projection) - .build()?; - Ok((Arc::new(join), required.to_vec())) + self.build_join( + Built { + plan: reducer_plan, + columns: vec![], + }, + Built { + plan: filtered_plan, + columns: filtered_columns, + }, + on, + join_type, + None, + required, + ) } } @@ -1002,6 +1120,12 @@ fn projection_for(required: &[ColRef], emitted: &[ColRef]) -> Result, + columns: Vec, +} + /// Rebuilds the non-equi filters applied at one join as one conjunction. A /// [`JoinFilter`] addresses an intermediate batch by index, so merging means /// concatenating those schemas and shifting all but the first filter's indices. @@ -1045,10 +1169,25 @@ fn rebuild_filters( }) .collect::>>()?; + // Intermediate columns go left side first: a hash join reads them positionally, + // but a sort merge join rebuilds the batch as all left then all right columns, + // so interleaved sides would evaluate against the wrong columns. + let mut order: Vec = (0..column_indices.len()).collect(); + order.sort_by_key(|i| column_indices[*i].side == JoinSide::Right); + let mut moved = vec![0; order.len()]; + for (to, from) in order.iter().enumerate() { + moved[*from] = to; + } + Ok(Some(JoinFilter::new( - expression, - column_indices, - Arc::new(Schema::new(fields)), + remap_columns(expression, &moved)?, + order.iter().map(|i| column_indices[*i].clone()).collect(), + Arc::new(Schema::new( + order + .iter() + .map(|i| Arc::clone(&fields[*i])) + .collect::>(), + )), ))) } @@ -1056,12 +1195,29 @@ fn shift_columns(expression: PhysicalExprRef, offset: usize) -> Result Result { + if moved.iter().enumerate().all(|(from, to)| from == *to) { + return Ok(expression); + } + rewrite_columns(expression, &|index| moved[index]) +} + +fn rewrite_columns( + expression: PhysicalExprRef, + index: &dyn Fn(usize) -> usize, +) -> Result { expression .transform(|expr| { Ok(match expr.downcast_ref::() { Some(column) => Transformed::yes(Arc::new(Column::new( column.name(), - column.index() + offset, + index(column.index()), )) as _), None => Transformed::no(expr), }) @@ -1188,6 +1344,25 @@ fn reorder( solution: &solution, relations: &relations, }; - let (plan, _) = rebuilder.node(graph.all(), &graph.output)?; - Ok(Some(plan)) + let (plan, columns) = rebuilder.node(graph.all(), &graph.output)?; + if columns == graph.output { + return Ok(Some(plan)); + } + // Only a sort merge subtree reaches here, having no projection of its own. + let schema = plan.schema(); + let exprs = graph + .output + .iter() + .map(|col| { + let Some(index) = position(&columns, *col) else { + return internal_err!("join enumeration lost column {col:?}"); + }; + let name = schema.field(index).name(); + Ok(ProjectionExpr { + expr: Arc::new(Column::new(name, index)), + alias: name.clone(), + }) + }) + .collect::>>()?; + Ok(Some(Arc::new(ProjectionExec::try_new(exprs, plan)?))) } From 636b45559c959d6b90c854c52f176a50a7695a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Wed, 19 Aug 2026 08:43:17 +0200 Subject: [PATCH 10/16] feat: enumerate cross and nested loop join orders too Both were leaf boundaries, so a non-equi join stopped enumeration dead and a cross join pinned whatever sat under it. They now take part, and which operator a node uses follows from the predicates crossing its cut: equi keys give a hash or sort merge join, a filter alone gives a nested loop join, and nothing gives a cross join. Nested loop semi and anti joins stay out, having no keys to model as a reducer. That means the search must allow an unconnected cut, which it does only between whole connected components -- any cut through a component is connected, and a graph holding a genuine cartesian product has no other way to be built. Components are computed over filters as well as keys, so a pair joined only by a non-equi predicate prunes like any other. Two of my own bugs found while testing this. The columns a node reports were inferred by comparing lengths, so a projection that merely permutes was reported as absent and the parent read its keys from the wrong positions -- the plan compared `b_k` against `a_t` where the predicate was `a_k = b_k`. And the first nested loop test asserted a plan that `JoinSelection`'s own input swap produces, not the enumerator's; it now drives the rule alone, over inputs where moving the join is genuinely cheaper. Co-Authored-By: Claude Opus 5 --- .../physical_optimizer/join_enumeration.rs | 55 +++++- .../src/join_enumeration.rs | 164 ++++++++++++++---- 2 files changed, 182 insertions(+), 37 deletions(-) diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index a2b216a91999c..b3d313e8fc1d8 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -36,7 +36,9 @@ use datafusion_physical_optimizer::PhysicalOptimizerRule; use datafusion_physical_optimizer::join_enumeration::JoinEnumeration; use datafusion_physical_optimizer::join_selection::JoinSelection; use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; -use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode, SortMergeJoinExec}; +use datafusion_physical_plan::joins::{ + CrossJoinExec, HashJoinExec, NestedLoopJoinExec, PartitionMode, SortMergeJoinExec, +}; use datafusion_physical_plan::{ExecutionPlan, displayable}; use insta::assert_snapshot; @@ -488,3 +490,54 @@ fn reorders_sort_merge_joins() -> Result<()> { ); Ok(()) } + +#[test] +fn reorders_around_a_cross_join() -> Result<()> { + // Only `types` has a predicate, so `other` can only join by cross product, + // which belongs on the small pair rather than under the reducing join. + let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); + let other = scan(10, &[("o_id", 10)]); + let types = scan(10, &[("t_type", 10)]); + + let crossed: Arc = Arc::new(CrossJoinExec::new(fact, other)); + let plan = join(crossed, types, &[("f_type", "t_type")])?; + + assert_snapshot!(formatted(&optimize(plan, &ConfigOptions::new())?), @r" + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t_type@1, f_type@1)], projection=[f_id@2, f_type@3, o_id@0, t_type@1] + CrossJoinExec + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=2, row_count=Inexact(1000000) + "); + Ok(()) +} + +#[test] +fn reorders_a_nested_loop_join() -> Result<()> { + // The equi join inflates its inputs a hundredfold, so the non-equi predicate is + // cheaper applied first, even at the default selectivity a filter gets. + let a = scan(1_000, &[("a_k", 10), ("a_t", 1_000)]); + let b = scan(1_000, &[("b_k", 10)]); + let t = scan(10, &[("t_t", 10)]); + + let joined = join(a, b, &[("a_k", "b_k")])?; + let plan: Arc = Arc::new(NestedLoopJoinExec::try_new( + joined, + t, + Some(greater_than_filter(("a_t", 1), ("t_t", 0))?), + &JoinType::Inner, + None, + )?); + + // Enumeration alone: `JoinSelection` swaps nested loop inputs itself, which + // would otherwise look like the reordering under test. + let enumerated = JoinEnumeration::new().optimize(plan, &ConfigOptions::new())?; + assert_snapshot!(formatted(&enumerated), @r" + HashJoinExec: mode=Auto, join_type=Inner, on=[(b_k@0, a_k@0)], projection=[a_k@1, a_t@2, b_k@0, t_t@3] + StatisticsExec: col_count=1, row_count=Inexact(1000) + NestedLoopJoinExec: join_type=Inner, filter=a_t@1 > t_t@0, projection=[a_k@1, a_t@2, t_t@0] + StatisticsExec: col_count=1, row_count=Inexact(10) + StatisticsExec: col_count=2, row_count=Inexact(1000) + "); + Ok(()) +} diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index fb427ee3432ee..5d0f360a2504b 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -50,7 +50,8 @@ use datafusion_physical_plan::joins::utils::{ ColumnIndex, JoinFilter, max_distinct_count, }; use datafusion_physical_plan::joins::{ - HashJoinExec, HashJoinExecBuilder, PartitionMode, SortMergeJoinExec, + CrossJoinExec, HashJoinExec, HashJoinExecBuilder, NestedLoopJoinExec, PartitionMode, + SortMergeJoinExec, }; use datafusion_physical_plan::operator_statistics::StatisticsRegistry; use datafusion_physical_plan::projection::{ProjectionExec, all_alias_free_columns}; @@ -262,8 +263,11 @@ struct CostModel<'a> { /// Aggregated selectivity per connected relation pair, as /// `(rel_a, rel_b, selectivity)` with `rel_a < rel_b`. pair_selectivity: Vec<(usize, usize, f64)>, - /// Neighbours of each relation. Reducers neighbour nothing: they are applied. + /// Neighbours of each relation, over equi-join keys and non-equi filters alike. + /// Reducers neighbour nothing: they are applied. adjacency: Vec, + /// The relations of each connected component of `adjacency`. + components: Vec, /// Fraction of its filtered side each reducer keeps; `1.0` for non-reducers. reducer_selectivity: Vec, /// Selectivity of each non-equi filter, with the relations it needs. @@ -322,10 +326,38 @@ impl<'a> CostModel<'a> { .map(|filter| (filter.required, default_selectivity)) .collect(); + // A filter links what it references, so a pair joined only by one counts as + // connected and its cuts prune like any other. + for filter in &graph.filters { + for rel in iter_rels(filter.required) { + adjacency[rel] |= filter.required & !bit(rel); + } + } + + let mut components: Vec = vec![]; + let mut seen: RelSet = 0; + for rel in 0..graph.relations.len() { + if seen & bit(rel) != 0 { + continue; + } + let mut component = bit(rel); + loop { + let grown = iter_rels(component) + .fold(component, |mask, rel| mask | adjacency[rel]); + if grown == component { + break; + } + component = grown; + } + seen |= component; + components.push(component); + } + Self { graph, pair_selectivity, adjacency, + components, reducer_selectivity, filter_selectivity, } @@ -371,12 +403,18 @@ impl<'a> CostModel<'a> { .then_some(Combine::Reducer { reducer }); } } - // Otherwise an inner join: both sides contribute columns and a predicate must - // connect them. Cross products are never introduced. + // Otherwise both sides must contribute columns, and the operator follows from + // the predicates crossing the cut. An unconnected cut is a cross product, + // allowed only between whole components: any cut through one is connected, and + // a disconnected graph has no other way to be built. if left & !reducers == 0 || right & !reducers == 0 { return None; } - self.connected(left, right).then_some(Combine::Inner) + let separates_components = self + .components + .iter() + .all(|component| component & left == 0 || component & right == 0); + (self.connected(left, right) || separates_components).then_some(Combine::Inner) } /// `C_out`: the sum of the internal nodes' cardinalities. Leaves are excluded as @@ -480,42 +518,76 @@ enum JoinKind { } /// One join, seen the same way whichever operator implements it. +/// +/// `kind` and `null_equality` are `None` for operators without equi-join keys, +/// which express no preference the rebuild must honour. struct JoinView<'a> { - kind: JoinKind, + kind: Option, role: JoinRole, left: &'a Arc, right: &'a Arc, on: &'a [(PhysicalExprRef, PhysicalExprRef)], filter: Option<&'a JoinFilter>, - null_equality: NullEquality, + null_equality: Option, projection: Option<&'a [usize]>, } fn join_view(plan: &Arc) -> Option> { if let Some(join) = plan.downcast_ref::() { - if join.null_aware || join.fetch().is_some() { + if join.null_aware || join.fetch().is_some() || join.on().is_empty() { return None; } return Some(JoinView { - kind: JoinKind::Hash, - role: join_role(join.join_type(), join.filter().is_some(), join.on())?, + kind: Some(JoinKind::Hash), + role: join_role(join.join_type(), join.filter().is_some())?, left: join.left(), right: join.right(), on: join.on(), filter: join.filter(), - null_equality: join.null_equality, + null_equality: Some(join.null_equality), projection: join.projection.as_deref(), }); } - let join = plan.downcast_ref::()?; + if let Some(join) = plan.downcast_ref::() { + if join.on().is_empty() { + return None; + } + return Some(JoinView { + kind: Some(JoinKind::SortMerge), + role: join_role(&join.join_type(), join.filter().is_some())?, + left: join.left(), + right: join.right(), + on: join.on(), + filter: join.filter().as_ref(), + null_equality: Some(join.null_equality()), + projection: None, + }); + } + if let Some(join) = plan.downcast_ref::() { + // A semi or anti variant has no keys to model as a reducer. + if *join.join_type() != JoinType::Inner { + return None; + } + return Some(JoinView { + kind: None, + role: JoinRole::Inner, + left: join.left(), + right: join.right(), + on: &[], + filter: join.filter(), + null_equality: None, + projection: join.projection().as_deref(), + }); + } + let join = plan.downcast_ref::()?; Some(JoinView { - kind: JoinKind::SortMerge, - role: join_role(&join.join_type(), join.filter().is_some(), join.on())?, + kind: None, + role: JoinRole::Inner, left: join.left(), right: join.right(), - on: join.on(), - filter: join.filter().as_ref(), - null_equality: join.null_equality(), + on: &[], + filter: None, + null_equality: None, projection: None, }) } @@ -534,14 +606,7 @@ enum JoinRole { /// Classifies a join. Outer and mark joins are excluded (not filters on their /// inputs, or they add a column), as are `null_aware` anti joins, joins with a /// limit, and semi joins whose filter is part of their existential test. -fn join_role( - join_type: &JoinType, - has_filter: bool, - on: &[(PhysicalExprRef, PhysicalExprRef)], -) -> Option { - if on.is_empty() { - return None; - } +fn join_role(join_type: &JoinType, has_filter: bool) -> Option { let role = match join_type { JoinType::Inner => JoinRole::Inner, JoinType::LeftSemi => JoinRole::Reducing { @@ -568,6 +633,14 @@ fn join_role( Some(role) } +/// Whether a graph-wide choice and one join's are compatible. +fn agree(graph: Option, join: Option) -> bool { + match (graph, join) { + (Some(graph), Some(join)) => graph == join, + _ => true, + } +} + fn as_column(expr: &PhysicalExprRef) -> Option { expr.downcast_ref::().map(|col| col.index()) } @@ -656,14 +729,11 @@ impl<'a, 's> Extractor<'a, 's> { plan: &Arc, ) -> Result, RelSet)>> { if let Some(view) = join_view(plan) - && self - .graph - .null_equality - .is_none_or(|null_equality| null_equality == view.null_equality) - && self.graph.kind.is_none_or(|kind| kind == view.kind) + && agree(self.graph.null_equality, view.null_equality) + && agree(self.graph.kind, view.kind) { - self.graph.null_equality = Some(view.null_equality); - self.graph.kind = Some(view.kind); + self.graph.null_equality = self.graph.null_equality.or(view.null_equality); + self.graph.kind = self.graph.kind.or(view.kind); let visited = match view.role { JoinRole::Inner => self.visit_inner(&view)?, JoinRole::Reducing { anti, output } => { @@ -926,6 +996,32 @@ impl Rebuilder<'_> { } }; let keys = on.len(); + if keys == 0 { + // No keys: a filter still restricts the pair, which is a nested loop join; + // without one it is a cross product. + let projection = projection_for(required, &natural)?; + return match filter { + Some(filter) => { + let join = NestedLoopJoinExec::try_new( + left.plan, + right.plan, + Some(filter), + &join_type, + projection.clone(), + )?; + // `projection_for` is `None` only for the identity. + let emitted = match projection { + Some(_) => required.to_vec(), + None => natural, + }; + Ok((Arc::new(join), emitted)) + } + // A cross join has no projection of its own. + None => { + Ok((Arc::new(CrossJoinExec::new(left.plan, right.plan)), natural)) + } + }; + } match self.graph.kind() { JoinKind::Hash => { let join = HashJoinExecBuilder::new(left.plan, right.plan, on, join_type) @@ -969,10 +1065,6 @@ impl Rebuilder<'_> { keys.push((right, left)); } } - if keys.is_empty() { - return internal_err!("join enumeration produced a cross product"); - } - // Filters land at their lowest common ancestor: covered here, by neither input. let mask = left_mask | right_mask; let filters: Vec<&Filter> = self From 4c344867b2cf5edb1ec55ccf6fa138009a7609a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Wed, 19 Aug 2026 14:05:50 +0200 Subject: [PATCH 11/16] Cost partitioning in join enumeration, raise the collect threshold The search costed cardinalities only, so it could not see that an order reusing a partitioning avoids a shuffle. Its DP state is now a subset and the partitioning that subset is clustered on, and each join is costed with the exchanges it needs, so a partitioning is paid for once and reused. The plan it emits now carries the `PartitionMode` it costed instead of `Auto`. Joins estimate their output width, so `hash_join_single_partition_threshold` compares bytes rather than falling back to counting rows, and the threshold moves to 4MB. Collecting a build side discards the join's hash partitioning, which an operator above may have needed: TPC-H q13's group-by fell back to a shuffle and a two-phase aggregation and lost 20%. `JoinSelection` now keeps such a join partitioned when the join emits at least as many rows as it probes with, so collecting would not have moved less. TPC-H SF10 0.90x, SF1 0.94x, TPC-DS SF1 0.67x over all 99 queries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012YiAABcW4WSqij31zz2P6c --- datafusion/common/src/config.rs | 2 +- .../physical_optimizer/join_enumeration.rs | 2 +- .../physical_optimizer/join_selection.rs | 38 +- .../partition_statistics.rs | 6 +- .../src/join_enumeration.rs | 342 ++++++++++++++---- .../physical-optimizer/src/join_selection.rs | 163 ++++++++- datafusion/physical-plan/src/joins/utils.rs | 30 +- .../test_files/information_schema.slt | 4 +- .../test_files/join_limit_pushdown.slt | 8 +- datafusion/sqllogictest/test_files/joins.slt | 26 +- .../test_files/statistics_registry.slt | 2 +- docs/source/user-guide/configs.md | 2 +- 12 files changed, 508 insertions(+), 117 deletions(-) diff --git a/datafusion/common/src/config.rs b/datafusion/common/src/config.rs index 41d3fd7e819b7..81c56f04a026d 100644 --- a/datafusion/common/src/config.rs +++ b/datafusion/common/src/config.rs @@ -1705,7 +1705,7 @@ config_namespace! { /// The maximum estimated size in bytes for one input side of a HashJoin /// will be collected into a single partition - pub hash_join_single_partition_threshold: usize, default = 1024 * 1024 + pub hash_join_single_partition_threshold: usize, default = 4 * 1024 * 1024 /// The maximum estimated size in rows for one input side of a HashJoin /// will be collected into a single partition diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index b3d313e8fc1d8..889ceea2d3671 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -533,7 +533,7 @@ fn reorders_a_nested_loop_join() -> Result<()> { // would otherwise look like the reordering under test. let enumerated = JoinEnumeration::new().optimize(plan, &ConfigOptions::new())?; assert_snapshot!(formatted(&enumerated), @r" - HashJoinExec: mode=Auto, join_type=Inner, on=[(b_k@0, a_k@0)], projection=[a_k@1, a_t@2, b_k@0, t_t@3] + HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(b_k@0, a_k@0)], projection=[a_k@1, a_t@2, b_k@0, t_t@3] StatisticsExec: col_count=1, row_count=Inexact(1000) NestedLoopJoinExec: join_type=Inner, filter=a_t@1 > t_t@0, projection=[a_k@1, a_t@2, t_t@0] StatisticsExec: col_count=1, row_count=Inexact(10) diff --git a/datafusion/core/tests/physical_optimizer/join_selection.rs b/datafusion/core/tests/physical_optimizer/join_selection.rs index 265279a8ca1e4..b0f070eda761b 100644 --- a/datafusion/core/tests/physical_optimizer/join_selection.rs +++ b/datafusion/core/tests/physical_optimizer/join_selection.rs @@ -75,6 +75,16 @@ fn get_thresholds() -> (usize, usize) { ) } +/// The byte size [`small_statistics`] derives from the configured threshold. +fn small_byte_size() -> usize { + get_thresholds().1 / 128 +} + +/// The byte size [`big_statistics`] derives from the configured threshold. +fn big_byte_size() -> usize { + get_thresholds().1 * 2 +} + /// Return statistics for small table fn small_statistics() -> Statistics { let (threshold_num_rows, threshold_byte_size) = get_thresholds(); @@ -258,14 +268,14 @@ async fn test_join_with_swap() { .compute(swapped_join.left().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(8192) + Precision::Inexact(small_byte_size()) ); assert_eq!( StatisticsContext::new() .compute(swapped_join.right().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(2097152) + Precision::Inexact(big_byte_size()) ); } @@ -382,14 +392,14 @@ async fn test_left_join_no_swap() { .compute(swapped_join.left().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(8192) + Precision::Inexact(small_byte_size()) ); assert_eq!( StatisticsContext::new() .compute(swapped_join.right().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(2097152) + Precision::Inexact(big_byte_size()) ); } @@ -431,14 +441,14 @@ async fn test_join_with_swap_semi() { .compute(swapped_join.left().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(8192) + Precision::Inexact(small_byte_size()) ); assert_eq!( StatisticsContext::new() .compute(swapped_join.right().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(2097152) + Precision::Inexact(big_byte_size()) ); assert_eq!(original_schema, swapped_join.schema()); } @@ -482,14 +492,14 @@ async fn test_join_with_swap_mark() { .compute(swapped_join.left().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(8192) + Precision::Inexact(small_byte_size()) ); assert_eq!( StatisticsContext::new() .compute(swapped_join.right().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(2097152) + Precision::Inexact(big_byte_size()) ); assert_eq!(original_schema, swapped_join.schema()); } @@ -608,14 +618,14 @@ async fn test_join_no_swap() { .compute(swapped_join.left().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(8192) + Precision::Inexact(small_byte_size()) ); assert_eq!( StatisticsContext::new() .compute(swapped_join.right().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(2097152) + Precision::Inexact(big_byte_size()) ); } @@ -681,14 +691,14 @@ async fn test_nl_join_with_swap(join_type: JoinType) { .compute(swapped_join.left().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(8192) + Precision::Inexact(small_byte_size()) ); assert_eq!( StatisticsContext::new() .compute(swapped_join.right().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(2097152) + Precision::Inexact(big_byte_size()) ); } @@ -752,14 +762,14 @@ async fn test_nl_join_with_swap_no_proj(join_type: JoinType) { .compute(swapped_join.left().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(8192) + Precision::Inexact(small_byte_size()) ); assert_eq!( StatisticsContext::new() .compute(swapped_join.right().as_ref(), &StatisticsArgs::new()) .unwrap() .total_byte_size, - Precision::Inexact(2097152) + Precision::Inexact(big_byte_size()) ); } diff --git a/datafusion/core/tests/physical_optimizer/partition_statistics.rs b/datafusion/core/tests/physical_optimizer/partition_statistics.rs index 6cabcdb710393..6decb7b9092f3 100644 --- a/datafusion/core/tests/physical_optimizer/partition_statistics.rs +++ b/datafusion/core/tests/physical_optimizer/partition_statistics.rs @@ -1596,7 +1596,7 @@ mod test { // For collect left mode, the min/max values are from the entire left table and the specific partition of the right table. let expected_p0_statistics = Statistics { num_rows: Precision::Inexact(2), - total_byte_size: Precision::Absent, + total_byte_size: Precision::Inexact(32), column_statistics: vec![ // Left id column: all partitions (id 1..4) ColumnStatistics { @@ -1677,7 +1677,7 @@ mod test { // For partitioned mode, the min/max values are from the specific partition for each side. let expected_p0_statistics = Statistics { num_rows: Precision::Inexact(2), - total_byte_size: Precision::Absent, + total_byte_size: Precision::Inexact(32), column_statistics: vec![ // Left id column: partition 0 only (id 3..4) ColumnStatistics { @@ -1756,7 +1756,7 @@ mod test { // For auto mode, the min/max values are from the entire left and right tables. let expected_p0_statistics = Statistics { num_rows: Precision::Inexact(4), - total_byte_size: Precision::Absent, + total_byte_size: Precision::Inexact(64), column_statistics: vec![ // Left id column: all partitions (id 1..4) ColumnStatistics { diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index 5d0f360a2504b..2bc068261b942 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -180,6 +180,8 @@ struct Relation { plan: Arc, /// Estimated row count, clamped to at least 1. rows: f64, + /// Estimated bytes per row, when the input reports a size. + width: Option, /// Per-column distinct value estimate, clamped to `[1, rows]`. ndv: Vec, role: Role, @@ -213,8 +215,9 @@ struct JoinGraph { /// Null handling shared by the subtree's joins. A join that differs becomes a /// relation instead. null_equality: Option, - /// The original tree's internal nodes, for scoring it against the alternatives. - original_nodes: Vec, + /// The original tree's internal nodes as `(node, one child)`, children first, so + /// the planner's shape can be scored under the same formula as the alternatives. + original_nodes: Vec<(RelSet, RelSet)>, /// The relations that are reducers rather than ordinary inputs. reducers: RelSet, /// Which join operator the subtree used, and which the rebuild emits. @@ -272,8 +275,19 @@ struct CostModel<'a> { reducer_selectivity: Vec, /// Selectivity of each non-equi filter, with the relations it needs. filter_selectivity: Vec<(RelSet, f64)>, + /// The key class of each edge, as a bit position. Joins on the same class can + /// reuse each other's hash partitioning. + edge_class: Vec, + /// The size a build side must stay under for `JoinSelection` to broadcast it. + broadcast_bytes: f64, + /// The row count it must stay under when no byte estimate is available. + broadcast_rows: f64, } +/// A hash partitioning, as the set of key classes it is partitioned on. Zero means +/// not hash partitioned, which is where every scan starts. +type PartSet = u32; + impl<'a> CostModel<'a> { fn new(graph: &'a JoinGraph, config: &ConfigOptions) -> Self { // Denominate each relation pair by its most selective key, as @@ -358,6 +372,10 @@ impl<'a> CostModel<'a> { pair_selectivity, adjacency, components, + edge_class: key_classes(&graph.edges), + broadcast_bytes: config.optimizer.hash_join_single_partition_threshold as f64, + broadcast_rows: config.optimizer.hash_join_single_partition_threshold_rows + as f64, reducer_selectivity, filter_selectivity, } @@ -419,33 +437,181 @@ impl<'a> CostModel<'a> { /// `C_out`: the sum of the internal nodes' cardinalities. Leaves are excluded as /// every candidate reads the same relations. - fn tree_cost(&self, nodes: &[RelSet]) -> f64 { - nodes + /// The key classes joining `left` to `right`, which is what a partitioned join + /// would hash both sides on. + fn crossing_classes(&self, left: RelSet, right: RelSet) -> PartSet { + let mut classes = 0; + for (index, edge) in self.graph.edges.iter().enumerate() { + let (a, b) = (bit(edge.left.rel), bit(edge.right.rel)); + if (left & a != 0 && right & b != 0) || (left & b != 0 && right & a != 0) { + classes |= 1 << self.edge_class[index]; + } + } + classes + } + + /// Whether `JoinSelection` will broadcast this side rather than partition it. It + /// compares bytes when an estimate exists and rows otherwise, so this mirrors it. + fn broadcasts(&self, side: RelSet) -> bool { + let mut width = 0.0; + for rel in iter_rels(side) { + if self.graph.reducer(rel).is_some() { + continue; + } + match self.graph.relations[rel].width { + Some(bytes) => width += bytes, + None => return self.cardinality(side) < self.broadcast_rows, + } + } + self.cardinality(side) * width < self.broadcast_bytes + } + + /// Cost of one join and the partitioning it leaves behind, for each way of + /// exchanging its inputs. Collecting a side moves that side; partitioning moves + /// whichever sides are not already hashed on the join key -- which is how a shape + /// that repartitions the largest relation twice becomes visibly worse than one + /// that repartitions it once. + fn exchanges( + &self, + left: RelSet, + right: RelSet, + left_part: PartSet, + right_part: PartSet, + collect_only: Option, + ) -> Vec<(f64, PartSet, RelSet, PartitionMode)> { + let classes = self.crossing_classes(left, right); + let mut options = vec![]; + for (build, probe_part) in [(left, right_part), (right, left_part)] { + if collect_only.is_some_and(|only| only != build) { + continue; + } + // With no key there is nothing to hash on, so a side must be collected + // whatever its size. + if classes == 0 || self.broadcasts(build) { + let cost = self.cardinality(build); + options.push((cost, probe_part, build, PartitionMode::CollectLeft)); + } + } + if classes != 0 { + let mut moved = 0.0; + if left_part != classes { + moved += self.cardinality(left); + } + if right_part != classes { + moved += self.cardinality(right); + } + let build = collect_only.unwrap_or_else(|| { + if self.cardinality(left) <= self.cardinality(right) { + left + } else { + right + } + }); + options.push((moved, classes, build, PartitionMode::Partitioned)); + } + options + } + + /// Cost of the shape the planner produced, scored the way the search scores its + /// own candidates, including the exchanges each of its joins would need. + fn tree_cost(&self, nodes: &[(RelSet, RelSet)]) -> f64 { + let mut parts: HashMap = HashMap::new(); + let mut total = 0.0; + for (mask, child) in nodes { + if mask.count_ones() < 2 { + continue; + } + let other = mask ^ child; + let (child_part, other_part) = ( + parts.get(child).copied().unwrap_or(0), + parts.get(&other).copied().unwrap_or(0), + ); + let collect_only = self.reducer_side(*child, other); + let best = self + .exchanges(*child, other, child_part, other_part, collect_only) + .into_iter() + .min_by(|a, b| a.0.total_cmp(&b.0)); + let (exchange, part) = + best.map_or((0.0, 0), |(cost, part, _, _)| (cost, part)); + total += self.cardinality(*mask) + exchange; + parts.insert(*mask, part); + } + total + } + + /// The side that must build, when one of them is a reducer. + fn reducer_side(&self, left: RelSet, right: RelSet) -> Option { + match self.combine(left, right) { + Some(Combine::Reducer { reducer }) => Some(bit(reducer)), + _ => None, + } + } +} + +/// Groups equi-join predicates that share a column into key classes, so two joins +/// hashing on the same class can reuse one partitioning. Returns each edge's class as +/// a bit position; classes beyond the bitmask's width collapse into the last, which +/// costs only precision. +fn key_classes(edges: &[Edge]) -> Vec { + let mut ids: Vec = (0..edges.len()).collect(); + let shares = |a: &Edge, b: &Edge| { + [a.left, a.right] .iter() - .filter(|mask| mask.count_ones() > 1) - .map(|mask| self.cardinality(*mask)) - .sum() + .any(|col| *col == b.left || *col == b.right) + }; + loop { + let mut merged = false; + for i in 0..edges.len() { + for j in (i + 1)..edges.len() { + if ids[i] != ids[j] && shares(&edges[i], &edges[j]) { + let (keep, drop) = (ids[i].min(ids[j]), ids[i].max(ids[j])); + ids.iter_mut() + .filter(|id| **id == drop) + .for_each(|id| *id = keep); + merged = true; + } + } + } + if !merged { + break; + } } + let mut compact = ids.clone(); + compact.sort_unstable(); + compact.dedup(); + ids.iter() + .map(|id| { + let position = compact.iter().position(|c| c == id).unwrap_or(0); + position.min(PartSet::BITS as usize - 1) as u32 + }) + .collect() } +/// The winning join tree: per internal node, which input goes on the left and how the +/// join exchanges its data. struct Solution { - splits: HashMap, + nodes: HashMap, cost: f64, } -/// Exhaustive dynamic programming over connected relation subsets, costing each -/// once by trying every split, so bushy shapes are considered too. `O(3^n)`. +/// Exhaustive dynamic programming over connected relation subsets, each paired with +/// the partitioning its plan leaves behind. +/// +/// Carrying the partitioning is what lets a later join reuse an earlier one's hash +/// exchange instead of paying for another, so the search can tell a shape that +/// repartitions the largest relation once from one that does it twice. Costing order +/// alone cannot see that difference, since the exchange belongs to the mode. fn solve_dp(model: &CostModel) -> Option { let n = model.graph.relations.len(); let full: RelSet = model.graph.all(); - let size = 1usize << n; - // `INFINITY` marks a subset that cannot be built: disconnected, or holding a - // reducer whose columns it does not cover. - let mut cost = vec![f64::INFINITY; size]; - let mut split = vec![0 as RelSet; size]; + // Per subset, the cheapest plan for each partitioning it can be left in, and the + // choice that got there. A scan arrives hash partitioned on nothing. + let mut best: Vec> = vec![HashMap::new(); 1usize << n]; + let mut choice: Vec> = + vec![HashMap::new(); 1usize << n]; for rel in 0..n { - cost[bit(rel) as usize] = 0.0; + best[bit(rel) as usize].insert(0, 0.0); } for mask in 1..=full { @@ -456,56 +622,63 @@ fn solve_dp(model: &CostModel) -> Option { // Subsets containing the lowest set bit, so each pair of halves is seen once. let lowest = mask & mask.wrapping_neg(); let mut left = mask; - let mut best = f64::INFINITY; - let mut best_left = 0; while left != 0 { left = (left - 1) & mask; if left & lowest == 0 { continue; } let right = mask ^ left; - if right == 0 { - continue; - } - let (left_cost, right_cost) = (cost[left as usize], cost[right as usize]); - if !left_cost.is_finite() - || !right_cost.is_finite() - || model.combine(left, right).is_none() - { + if right == 0 || model.combine(left, right).is_none() { continue; } - let candidate = left_cost + right_cost + cardinality; - if candidate < best { - best = candidate; - best_left = left; + let collect_only = model.reducer_side(left, right); + for (left_part, left_cost) in best[left as usize].clone() { + for (right_part, right_cost) in best[right as usize].clone() { + let below = left_cost + right_cost + cardinality; + for (exchange, part, build, mode) in + model.exchanges(left, right, left_part, right_part, collect_only) + { + let candidate = below + exchange; + let entry = best[mask as usize].entry(part).or_insert(f64::MAX); + if candidate < *entry { + *entry = candidate; + choice[mask as usize].insert(part, (left, build, mode)); + } + } + } } } - if best.is_finite() { - cost[mask as usize] = best; - split[mask as usize] = best_left; - } } - if !cost[full as usize].is_finite() { - return None; - } + let (&winning_part, &cost) = best[full as usize] + .iter() + .min_by(|a, b| a.1.total_cmp(b.1))?; - let mut splits = HashMap::new(); - let mut stack = vec![full]; - while let Some(mask) = stack.pop() { + // Walk the winning tree, keeping the left input and mode of each node it uses. + let mut nodes = HashMap::new(); + let mut stack = vec![(full, winning_part)]; + while let Some((mask, part)) = stack.pop() { if mask.count_ones() < 2 { continue; } - let left = split[mask as usize]; - splits.insert(mask, left); - stack.push(left); - stack.push(mask ^ left); + let Some(&(split, build, mode)) = choice[mask as usize].get(&part) else { + continue; + }; + let other = mask ^ split; + // The side that builds goes on the left, which is the side `CollectLeft` + // gathers and the side a semi or anti join probes from. + nodes.insert(mask, (build, mode)); + for child in [split, other] { + let child_part = best[child as usize] + .iter() + .min_by(|a, b| a.1.total_cmp(b.1)) + .map(|(part, _)| *part) + .unwrap_or(0); + stack.push((child, child_part)); + } } - Some(Solution { - splits, - cost: cost[full as usize], - }) + Some(Solution { nodes, cost }) } /// The join operators the rule can flatten and rebuild. @@ -744,7 +917,6 @@ impl<'a, 's> Extractor<'a, 's> { return Ok(None); }; - self.graph.original_nodes.push(mask); // For a semi or anti join the projection selects from the output side alone. let columns = match view.projection { Some(projection) => projection.iter().map(|idx| columns[*idx]).collect(), @@ -838,6 +1010,9 @@ impl<'a, 's> Extractor<'a, 's> { let mut columns = left; columns.extend(right); + self.graph + .original_nodes + .push((left_mask | right_mask, left_mask)); Ok(Some((columns, left_mask | right_mask))) } @@ -885,6 +1060,7 @@ impl<'a, 's> Extractor<'a, 's> { let Some(rel) = self.push_relation(reducer_plan, role)? else { return Ok(None); }; + self.graph.original_nodes.push((mask | bit(rel), bit(rel))); Ok(Some((columns, mask | bit(rel)))) } @@ -923,6 +1099,10 @@ impl<'a, 's> Extractor<'a, 's> { self.graph.relations.push(Relation { plan: Arc::clone(plan), rows, + width: statistics + .total_byte_size + .get_value() + .map(|bytes| *bytes as f64 / rows), ndv, role, }); @@ -954,23 +1134,19 @@ impl Rebuilder<'_> { return Ok((plan, columns)); } - let Some(split) = self.solution.splits.get(&mask).copied() else { + let Some(&(left, mode)) = self.solution.nodes.get(&mask) else { return internal_err!("join enumeration produced no split for {mask:b}"); }; - let other = mask ^ split; - match self.model.combine(split, other) { + let right = mask ^ left; + match self.model.combine(left, right) { None => internal_err!("join enumeration produced an invalid join"), - Some(Combine::Reducer { reducer }) => self.reducing(mask, required, reducer), - Some(Combine::Inner) => { - // Cheaper side on the left; `JoinSelection`'s swap may still revise it. - let (left, right) = - if self.model.cardinality(split) <= self.model.cardinality(other) { - (split, other) - } else { - (other, split) - }; - self.inner(required, left, right) + Some(Combine::Reducer { reducer }) => { + self.reducing(mask, required, reducer, mode) } + // The search put the building side on the left and chose the mode along + // with the order, so both are emitted as decided rather than left to + // `JoinSelection` to pick again. + Some(Combine::Inner) => self.inner(required, left, right, mode), } } @@ -979,11 +1155,15 @@ impl Rebuilder<'_> { &self, left: Built, right: Built, - on: Vec<(PhysicalExprRef, PhysicalExprRef)>, - join_type: JoinType, - filter: Option, + spec: JoinSpec, required: &[ColRef], ) -> Result<(Arc, Vec)> { + let JoinSpec { + on, + join_type, + filter, + mode, + } = spec; // A semi or anti join emits one side, and a reducer's own columns are never // emitted, so its `Built` carries none. let natural = match join_type { @@ -1027,9 +1207,7 @@ impl Rebuilder<'_> { let join = HashJoinExecBuilder::new(left.plan, right.plan, on, join_type) .with_filter(filter) .with_null_equality(self.graph.null_equality()) - // Build side and partition mode are picked by the - // statistical subrule after. - .with_partition_mode(PartitionMode::Auto) + .with_partition_mode(mode) .with_projection(projection_for(required, &natural)?) .build()?; Ok((Arc::new(join), required.to_vec())) @@ -1054,6 +1232,7 @@ impl Rebuilder<'_> { required: &[ColRef], left_mask: RelSet, right_mask: RelSet, + mode: PartitionMode, ) -> Result<(Arc, Vec)> { // Each edge crosses exactly one cut, at the lowest node holding both endpoints. let mut keys: Vec<(ColRef, ColRef)> = vec![]; @@ -1117,9 +1296,12 @@ impl Rebuilder<'_> { plan: right_plan, columns: right_columns, }, - on, - JoinType::Inner, - filter, + JoinSpec { + on, + join_type: JoinType::Inner, + filter, + mode, + }, required, ) } @@ -1129,6 +1311,7 @@ impl Rebuilder<'_> { mask: RelSet, required: &[ColRef], reducer: usize, + mode: PartitionMode, ) -> Result<(Arc, Vec)> { let Some(info) = self.graph.reducer(reducer) else { return internal_err!("relation {reducer} is not a reducer"); @@ -1180,9 +1363,12 @@ impl Rebuilder<'_> { plan: filtered_plan, columns: filtered_columns, }, - on, - join_type, - None, + JoinSpec { + on, + join_type, + filter: None, + mode, + }, required, ) } @@ -1218,6 +1404,14 @@ struct Built { columns: Vec, } +/// One join as the search decided it, including the mode it was costed with. +struct JoinSpec { + on: Vec<(PhysicalExprRef, PhysicalExprRef)>, + join_type: JoinType, + filter: Option, + mode: PartitionMode, +} + /// Rebuilds the non-equi filters applied at one join as one conjunction. A /// [`JoinFilter`] addresses an intermediate batch by index, so merging means /// concatenating those schemas and shifting all but the first filter's indices. diff --git a/datafusion/physical-optimizer/src/join_selection.rs b/datafusion/physical-optimizer/src/join_selection.rs index c2c872c79a706..ba6da56579c34 100644 --- a/datafusion/physical-optimizer/src/join_selection.rs +++ b/datafusion/physical-optimizer/src/join_selection.rs @@ -35,7 +35,12 @@ use datafusion_common::{JoinSide, JoinType, internal_err}; use datafusion_expr_common::sort_properties::SortProperties; use datafusion_physical_expr::LexOrdering; use datafusion_physical_expr::expressions::Column; +use datafusion_physical_expr_common::physical_expr::PhysicalExpr; +use datafusion_physical_plan::Distribution; use datafusion_physical_plan::execution_plan::EmissionType; +use datafusion_physical_plan::execution_plan::{ + ChildrenPropertiesMode, ReplaceChildrenOptions, +}; use datafusion_physical_plan::joins::utils::ColumnIndex; use datafusion_physical_plan::joins::{ CrossJoinExec, HashJoinExec, NestedLoopJoinExec, PartitionMode, @@ -172,10 +177,13 @@ impl PhysicalOptimizerRule for JoinSelection { let new_plan = plan .transform_up(|p| apply_subrules(p, &subrules, config)) .data()?; - new_plan + let new_plan = new_plan .transform_up(|plan| { statistical_join_selection_subrule(plan, config, registry) }) + .data()?; + new_plan + .transform_down(|plan| keep_partitioning_needed_above(plan, registry)) .data() } @@ -188,6 +196,159 @@ impl PhysicalOptimizerRule for JoinSelection { } } +/// How far above a join a hash requirement is still attributed to it, enough to reach +/// through the partial aggregate that a group-by is planned as. +const PARTITIONING_LOOKAHEAD: usize = 3; + +/// Restores a partitioned join that an operator above needs the partitioning of. +/// +/// Collecting the build side saves the join its exchanges but discards its hash +/// partitioning, which then has to be rebuilt above: TPC-H q13's group-by falls back to +/// a shuffle and a two-phase aggregation and loses 20%, more than collecting saved. +fn keep_partitioning_needed_above( + plan: Arc, + registry: Option<&StatisticsRegistry>, +) -> Result>> { + let mut children: Vec<_> = plan.children().into_iter().map(Arc::clone).collect(); + let mut changed = false; + let requirements = plan.input_distribution_requirements(); + for (idx, required) in requirements.per_child_distributions().enumerate() { + #[expect( + deprecated, + reason = "HashPartitioned is still planned during the KeyPartitioned migration" + )] + let required_exprs = match required { + Distribution::KeyPartitioned(exprs) + | Distribution::HashPartitioned(exprs) => exprs, + _ => continue, + }; + if let Some(rewritten) = + repartition_collected_join(&children[idx], required_exprs, registry)? + { + children[idx] = rewritten; + changed = true; + } + } + if changed { + Ok(Transformed::yes(plan.replace_children( + children, + ReplaceChildrenOptions::new(ChildrenPropertiesMode::Recompute), + )?)) + } else { + Ok(Transformed::no(plan)) + } +} + +/// Rebuilds the collected join under `plan` as partitioned when its keys are what +/// `required_exprs` asks for, looking through the operators that pass a partitioning up. +fn repartition_collected_join( + plan: &Arc, + required_exprs: &[Arc], + registry: Option<&StatisticsRegistry>, +) -> Result>> { + let Some(required) = column_names(required_exprs) else { + return Ok(None); + }; + let mut node = Arc::clone(plan); + for depth in 0..PARTITIONING_LOOKAHEAD { + if let Some(join) = node.downcast_ref::() { + if *join.partition_mode() != PartitionMode::CollectLeft + || join.on().is_empty() + { + return Ok(None); + } + // The join's output partitioning is not hash partitioned yet, since its + // inputs are still single partitions, so compare against the keys it would + // be partitioned on. Naming a column of the other side is possible and only + // costs the collect this would have done. + let keys: Vec<_> = + join.on().iter().map(|(left, _)| Arc::clone(left)).collect(); + if column_names(&keys).is_none_or(|keys| keys != required) { + return Ok(None); + } + if !worth_partitioning(join, registry)? { + return Ok(None); + } + let partitioned: Arc = Arc::new( + join.builder() + .with_partition_mode(PartitionMode::Partitioned) + .build()?, + ); + return Ok(Some(rebuild_above(plan, &node, partitioned, depth)?)); + } + // Only a single-input operator asking for nothing itself passes a partitioning + // up unchanged. + let children = node.children(); + let [child] = children.as_slice() else { + return Ok(None); + }; + if !matches!( + node.input_distribution_requirements().child_distribution(0), + Some(Distribution::UnspecifiedDistribution) + ) { + return Ok(None); + } + let child = Arc::clone(child); + drop(children); + node = child; + } + Ok(None) +} + +/// Whether partitioning the join moves no more rows than collecting it does. +/// +/// Both plans move the build side, one to partition it and the other to collect it. +/// Partitioning then moves the probe side, where collecting instead leaves the shuffle +/// to the operator above and moves the join's output. Partitioning also aggregates in +/// one pass rather than two, which settles the tie. +fn worth_partitioning( + join: &HashJoinExec, + registry: Option<&StatisticsRegistry>, +) -> Result { + let rows = |plan: &dyn ExecutionPlan| -> Result> { + Ok(get_stats(plan, registry)?.num_rows.get_value().copied()) + }; + let (Some(probe), Some(output)) = (rows(join.right().as_ref())?, rows(join)?) else { + return Ok(false); + }; + Ok(output >= probe) +} + +/// The names the expressions partition on, or `None` if any is not a column. +fn column_names(exprs: &[Arc]) -> Option> { + exprs + .iter() + .map(|expr| expr.downcast_ref::().map(Column::name)) + .collect() +} + +/// Puts `replacement` back under the `depth` single-input operators above it. +fn rebuild_above( + top: &Arc, + old: &Arc, + replacement: Arc, + depth: usize, +) -> Result> { + if depth == 0 { + return Ok(replacement); + } + let mut rebuilt = replacement; + let mut chain = vec![]; + let mut node = Arc::clone(top); + while !Arc::ptr_eq(&node, old) { + chain.push(Arc::clone(&node)); + let child = Arc::clone(node.children()[0]); + node = child; + } + for parent in chain.into_iter().rev() { + rebuilt = parent.replace_children( + vec![rebuilt], + ReplaceChildrenOptions::new(ChildrenPropertiesMode::Recompute), + )?; + } + Ok(rebuilt) +} + /// Tries to create a [`HashJoinExec`] in [`PartitionMode::CollectLeft`] when possible. /// /// This function will first consider the given join type and check whether the diff --git a/datafusion/physical-plan/src/joins/utils.rs b/datafusion/physical-plan/src/joins/utils.rs index b1d0eb61ddaae..05de56bc68bcd 100644 --- a/datafusion/physical-plan/src/joins/utils.rs +++ b/datafusion/physical-plan/src/joins/utils.rs @@ -466,12 +466,40 @@ pub(crate) fn estimate_join_statistics( join_type: &JoinType, schema: &Schema, ) -> Result { + // Width of one output row, from the sides this join emits. Without it a join + // reports no size, and `hash_join_single_partition_threshold` -- a byte threshold + // -- falls back to counting rows, blind to how wide they are. + let width = |stats: &Statistics| match ( + stats.total_byte_size.get_value(), + stats.num_rows.get_value(), + ) { + (Some(bytes), Some(rows)) if *rows > 0 => Some(*bytes as f64 / *rows as f64), + _ => None, + }; + let output_width = match join_type { + JoinType::LeftSemi | JoinType::LeftAnti => width(&left_stats), + JoinType::RightSemi | JoinType::RightAnti => width(&right_stats), + JoinType::Inner | JoinType::Left | JoinType::Right | JoinType::Full => { + width(&left_stats) + .zip(width(&right_stats)) + .map(|(l, r)| l + r) + } + // A mark join emits one side plus a boolean. + JoinType::LeftMark => width(&left_stats).map(|w| w + 1.0), + JoinType::RightMark => width(&right_stats).map(|w| w + 1.0), + }; + let join_stats = estimate_join_cardinality(join_type, left_stats, right_stats, on, null_equality); let (num_rows, total_byte_size, column_statistics) = match join_stats { Some(stats) => ( Precision::Inexact(stats.num_rows), - stats.total_byte_size, + match output_width { + Some(width) => { + Precision::Inexact((stats.num_rows as f64 * width) as usize) + } + None => stats.total_byte_size, + }, stats.column_statistics, ), None => ( diff --git a/datafusion/sqllogictest/test_files/information_schema.slt b/datafusion/sqllogictest/test_files/information_schema.slt index 8449d0d75ed8c..61f60d78e284a 100644 --- a/datafusion/sqllogictest/test_files/information_schema.slt +++ b/datafusion/sqllogictest/test_files/information_schema.slt @@ -324,7 +324,7 @@ datafusion.optimizer.expand_views_at_output false datafusion.optimizer.filter_null_join_keys false datafusion.optimizer.hash_join_inlist_pushdown_max_distinct_values 150 datafusion.optimizer.hash_join_inlist_pushdown_max_size 131072 -datafusion.optimizer.hash_join_single_partition_threshold 1048576 +datafusion.optimizer.hash_join_single_partition_threshold 4194304 datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 datafusion.optimizer.join_enumeration true datafusion.optimizer.join_enumeration_limit 12 @@ -487,7 +487,7 @@ datafusion.optimizer.expand_views_at_output false When set to true, if the retur datafusion.optimizer.filter_null_join_keys false When set to true, the optimizer will insert filters before a join between a nullable and non-nullable column to filter out nulls on the nullable side. This filter can add additional overhead when the file format does not fully support predicate push down. datafusion.optimizer.hash_join_inlist_pushdown_max_distinct_values 150 Maximum number of distinct values (rows) in the build side of a hash join to be pushed down as an InList expression for dynamic filtering. Build sides with more rows than this will use hash table lookups instead. Set to 0 to always use hash table lookups. This provides an additional limit beyond `hash_join_inlist_pushdown_max_size` to prevent very large IN lists that might not provide much benefit over hash table lookups. This uses the deduplicated row count once the build side has been evaluated. The default is 150 values per partition. This is inspired by Trino's `max-filter-keys-per-column` setting. See: datafusion.optimizer.hash_join_inlist_pushdown_max_size 131072 Maximum size in bytes for the build side of a hash join to be pushed down as an InList expression for dynamic filtering. Build sides larger than this will use hash table lookups instead. Set to 0 to always use hash table lookups. InList pushdown can be more efficient for small build sides because it can result in better statistics pruning as well as use any bloom filters present on the scan side. InList expressions are also more transparent and easier to serialize over the network in distributed uses of DataFusion. On the other hand InList pushdown requires making a copy of the data and thus adds some overhead to the build side and uses more memory. This setting is per-partition, so we may end up using `hash_join_inlist_pushdown_max_size` * `target_partitions` memory. The default is 128kB per partition. This should allow point lookup joins (e.g. joining on a unique primary key) to use InList pushdown in most cases but avoids excessive memory usage or overhead for larger joins. -datafusion.optimizer.hash_join_single_partition_threshold 1048576 The maximum estimated size in bytes for one input side of a HashJoin will be collected into a single partition +datafusion.optimizer.hash_join_single_partition_threshold 4194304 The maximum estimated size in bytes for one input side of a HashJoin will be collected into a single partition datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 The maximum estimated size in rows for one input side of a HashJoin will be collected into a single partition datafusion.optimizer.join_enumeration true When set to true, the physical plan optimizer enumerates join orders for subtrees of joins and picks the cheapest from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics are left untouched. datafusion.optimizer.join_enumeration_limit 12 Maximum inputs in a join subtree for which `join_enumeration` searches, at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of over 16 inputs whatever this is set to. diff --git a/datafusion/sqllogictest/test_files/join_limit_pushdown.slt b/datafusion/sqllogictest/test_files/join_limit_pushdown.slt index b30f2a34a4069..d0afb510dbc21 100644 --- a/datafusion/sqllogictest/test_files/join_limit_pushdown.slt +++ b/datafusion/sqllogictest/test_files/join_limit_pushdown.slt @@ -220,11 +220,11 @@ logical_plan 05)------TableScan: t2 projection=[x] 06)----TableScan: t3 projection=[p] physical_plan -01)HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(x@0, a@0)], projection=[a@2, x@0, p@1], fetch=2 -02)--HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(p@0, x@0)], projection=[x@1, p@0] -03)----DataSourceExec: partitions=1, partition_sizes=[1] +01)HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(a@0, x@0)], projection=[a@0, x@1, p@2], fetch=2 +02)--DataSourceExec: partitions=1, partition_sizes=[1] +03)--HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(p@0, x@0)], projection=[x@1, p@0] 04)----DataSourceExec: partitions=1, partition_sizes=[1] -05)--DataSourceExec: partitions=1, partition_sizes=[1] +05)----DataSourceExec: partitions=1, partition_sizes=[1] query III SELECT t1.a, t2.x, t3.p diff --git a/datafusion/sqllogictest/test_files/joins.slt b/datafusion/sqllogictest/test_files/joins.slt index 7a706836f44d6..703529ec2f7e9 100644 --- a/datafusion/sqllogictest/test_files/joins.slt +++ b/datafusion/sqllogictest/test_files/joins.slt @@ -1337,13 +1337,12 @@ logical_plan 03)----TableScan: join_t1 projection=[t1_id] 04)----TableScan: join_t2 projection=[t2_id] physical_plan -01)AggregateExec: mode=FinalPartitioned, gby=[t1_id@0 as t1_id], aggr=[] -02)--RepartitionExec: partitioning=Hash([t1_id@0], 2), input_partitions=2 -03)----AggregateExec: mode=Partial, gby=[t1_id@0 as t1_id], aggr=[] -04)------HashJoinExec: mode=CollectLeft, join_type=LeftSemi, on=[(t1_id@0, t2_id@0)] -05)--------DataSourceExec: partitions=1, partition_sizes=[1] -06)--------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1 -07)----------DataSourceExec: partitions=1, partition_sizes=[1] +01)AggregateExec: mode=SinglePartitioned, gby=[t1_id@0 as t1_id], aggr=[] +02)--HashJoinExec: mode=Partitioned, join_type=LeftSemi, on=[(t1_id@0, t2_id@0)] +03)----RepartitionExec: partitioning=Hash([t1_id@0], 2), input_partitions=1 +04)------DataSourceExec: partitions=1, partition_sizes=[1] +05)----RepartitionExec: partitioning=Hash([t2_id@0], 2), input_partitions=1 +06)------DataSourceExec: partitions=1, partition_sizes=[1] statement ok set datafusion.explain.logical_plan_only = true; @@ -1431,13 +1430,12 @@ logical_plan 06)--------TableScan: join_t2 projection=[t2_id] physical_plan 01)ProjectionExec: expr=[count(Int64(1))@1 as count(*)] -02)--AggregateExec: mode=FinalPartitioned, gby=[t1_id@0 as t1_id], aggr=[count(Int64(1))] -03)----RepartitionExec: partitioning=Hash([t1_id@0], 2), input_partitions=2 -04)------AggregateExec: mode=Partial, gby=[t1_id@0 as t1_id], aggr=[count(Int64(1))] -05)--------HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(t1_id@0, t2_id@0)], projection=[t1_id@0] -06)----------DataSourceExec: partitions=1, partition_sizes=[1] -07)----------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1 -08)------------DataSourceExec: partitions=1, partition_sizes=[1] +02)--AggregateExec: mode=SinglePartitioned, gby=[t1_id@0 as t1_id], aggr=[count(Int64(1))] +03)----HashJoinExec: mode=Partitioned, join_type=Inner, on=[(t1_id@0, t2_id@0)], projection=[t1_id@0] +04)------RepartitionExec: partitioning=Hash([t1_id@0], 2), input_partitions=1 +05)--------DataSourceExec: partitions=1, partition_sizes=[1] +06)------RepartitionExec: partitioning=Hash([t2_id@0], 2), input_partitions=1 +07)--------DataSourceExec: partitions=1, partition_sizes=[1] query TT EXPLAIN diff --git a/datafusion/sqllogictest/test_files/statistics_registry.slt b/datafusion/sqllogictest/test_files/statistics_registry.slt index 46cb4960fac3d..afb5ae12e5ae7 100644 --- a/datafusion/sqllogictest/test_files/statistics_registry.slt +++ b/datafusion/sqllogictest/test_files/statistics_registry.slt @@ -200,7 +200,7 @@ statement ok set datafusion.optimizer.use_statistics_registry = false; statement ok -set datafusion.optimizer.hash_join_single_partition_threshold = 1048576; +set datafusion.optimizer.hash_join_single_partition_threshold = 4194304; statement ok set datafusion.optimizer.hash_join_single_partition_threshold_rows = 131072; diff --git a/docs/source/user-guide/configs.md b/docs/source/user-guide/configs.md index 9de15b15df8a2..4664d889d3fa3 100644 --- a/docs/source/user-guide/configs.md +++ b/docs/source/user-guide/configs.md @@ -176,7 +176,7 @@ The following configuration settings are available: | datafusion.optimizer.use_statistics_registry | false | When set to true, the physical plan optimizer uses the pluggable `StatisticsRegistry` for statistics propagation across operators. This enables more accurate cardinality estimates compared to each operator's built-in `partition_statistics`. | | datafusion.optimizer.prefer_hash_join | true | When set to true, the physical plan optimizer will prefer HashJoin over SortMergeJoin. HashJoin can work more efficiently than SortMergeJoin but consumes more memory | | datafusion.optimizer.enable_piecewise_merge_join | false | When set to true, piecewise merge join is enabled. PiecewiseMergeJoin is currently experimental. Physical planner will opt for PiecewiseMergeJoin when there is only one range filter. | -| datafusion.optimizer.hash_join_single_partition_threshold | 1048576 | The maximum estimated size in bytes for one input side of a HashJoin will be collected into a single partition | +| datafusion.optimizer.hash_join_single_partition_threshold | 4194304 | The maximum estimated size in bytes for one input side of a HashJoin will be collected into a single partition | | datafusion.optimizer.hash_join_single_partition_threshold_rows | 131072 | The maximum estimated size in rows for one input side of a HashJoin will be collected into a single partition | | datafusion.optimizer.hash_join_inlist_pushdown_max_size | 131072 | Maximum size in bytes for the build side of a hash join to be pushed down as an InList expression for dynamic filtering. Build sides larger than this will use hash table lookups instead. Set to 0 to always use hash table lookups. InList pushdown can be more efficient for small build sides because it can result in better statistics pruning as well as use any bloom filters present on the scan side. InList expressions are also more transparent and easier to serialize over the network in distributed uses of DataFusion. On the other hand InList pushdown requires making a copy of the data and thus adds some overhead to the build side and uses more memory. This setting is per-partition, so we may end up using `hash_join_inlist_pushdown_max_size` \* `target_partitions` memory. The default is 128kB per partition. This should allow point lookup joins (e.g. joining on a unique primary key) to use InList pushdown in most cases but avoids excessive memory usage or overhead for larger joins. | | datafusion.optimizer.hash_join_inlist_pushdown_max_distinct_values | 150 | Maximum number of distinct values (rows) in the build side of a hash join to be pushed down as an InList expression for dynamic filtering. Build sides with more rows than this will use hash table lookups instead. Set to 0 to always use hash table lookups. This provides an additional limit beyond `hash_join_inlist_pushdown_max_size` to prevent very large IN lists that might not provide much benefit over hash table lookups. This uses the deduplicated row count once the build side has been evaluated. The default is 150 values per partition. This is inspired by Trino's `max-filter-keys-per-column` setting. See: | From cf466cab6fbd6e728914b060d932a8bebc6ec8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Wed, 19 Aug 2026 22:13:28 +0200 Subject: [PATCH 12/16] Fix two costs the search got wrong for sort merge and keyless joins A sort merge join has no mode that collects one side, but the model offered one and priced it as a free broadcast, so under `prefer_hash_join = false` the search chose orders it could not carry out. TPC-H q5 lost 31% at SF10 that way. It now broadcasts only for hash joins. A keyless join compares every pair, which the output cardinality the rest of the cost is built from does not say, so a disjunctive filter estimated to keep few rows looked cheap. TPC-DS q85 turned an equijoin into a nested loop join and ran 122x slower. Keyless joins are now charged for the pairs. With sort merge joins: TPC-H SF10 0.85x, SF1 0.91x, TPC-DS SF1 0.46x. The hash path is unchanged: TPC-H SF10 0.88x, TPC-DS SF1 0.64x. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012YiAABcW4WSqij31zz2P6c --- .../physical-optimizer/src/join_enumeration.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index 2bc068261b942..5f8412e23bb22 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -452,7 +452,13 @@ impl<'a> CostModel<'a> { /// Whether `JoinSelection` will broadcast this side rather than partition it. It /// compares bytes when an estimate exists and rows otherwise, so this mirrors it. + /// + /// A sort merge join has no mode that collects one side, so it never broadcasts and + /// costing an order as if it could would pick orders it cannot carry out. fn broadcasts(&self, side: RelSet) -> bool { + if self.graph.kind() == JoinKind::SortMerge { + return false; + } let mut width = 0.0; for rel in iter_rels(side) { if self.graph.reducer(rel).is_some() { @@ -480,6 +486,15 @@ impl<'a> CostModel<'a> { collect_only: Option, ) -> Vec<(f64, PartSet, RelSet, PartitionMode)> { let classes = self.crossing_classes(left, right); + // Without a key every pair has to be examined, and the output cardinality the + // rest of the cost is built from does not say that: a filter estimated to keep + // few rows still compares all of them. TPC-DS q85 read as cheap and ran 122x + // slower once a join became one. + let pairs = if classes == 0 { + self.cardinality(left) * self.cardinality(right) + } else { + 0.0 + }; let mut options = vec![]; for (build, probe_part) in [(left, right_part), (right, left_part)] { if collect_only.is_some_and(|only| only != build) { @@ -488,7 +503,7 @@ impl<'a> CostModel<'a> { // With no key there is nothing to hash on, so a side must be collected // whatever its size. if classes == 0 || self.broadcasts(build) { - let cost = self.cardinality(build); + let cost = pairs + self.cardinality(build); options.push((cost, probe_part, build, PartitionMode::CollectLeft)); } } From 3496d020559564eed78b9ef96f0399d80a8a4092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Thu, 20 Aug 2026 09:27:33 +0200 Subject: [PATCH 13/16] Trim the comments this PR added Most were longer than what they explain. Cut to one or two plain lines each, dropped the benchmark anecdotes, and removed a stale doc line left on `crossing_classes` from an earlier edit. Co-Authored-By: Claude Opus 5 --- datafusion/common/src/config.rs | 10 +- .../physical_optimizer/join_enumeration.rs | 10 +- .../src/join_enumeration.rs | 153 +++++++----------- .../physical-optimizer/src/join_selection.rs | 29 ++-- .../physical-optimizer/src/optimizer.rs | 4 +- datafusion/physical-plan/src/filter.rs | 20 +-- datafusion/physical-plan/src/joins/utils.rs | 5 +- .../test_files/information_schema.slt | 4 +- docs/source/user-guide/configs.md | 4 +- 9 files changed, 92 insertions(+), 147 deletions(-) diff --git a/datafusion/common/src/config.rs b/datafusion/common/src/config.rs index 81c56f04a026d..ea5db1dea9c82 100644 --- a/datafusion/common/src/config.rs +++ b/datafusion/common/src/config.rs @@ -1679,13 +1679,13 @@ config_namespace! { pub join_enumeration: bool, default = true /// How much cheaper an enumerated join order must be, in percent, before it - /// replaces the order the planner produced. Estimates often cannot tell two - /// orders apart, and swapping on one that close is as likely to lose as win. + /// replaces the order the planner produced. Estimates are often too close to tell + /// two orders apart, so a small gain is not worth acting on. pub join_enumeration_min_improvement: u8, default = 10 - /// Maximum inputs in a join subtree for which `join_enumeration` searches, - /// at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do - /// subtrees of over 16 inputs whatever this is set to. + /// Maximum inputs in a join subtree for which `join_enumeration` searches, at a + /// cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of + /// more than 16 inputs regardless of this setting. pub join_enumeration_limit: usize, default = 12 /// When set to true, the physical plan optimizer uses the pluggable diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index 889ceea2d3671..a65e7a2841cc6 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -194,9 +194,8 @@ fn greater_than_filter( )) } -/// A three way join in its expensive `FROM` order: the two large tables first -/// produce a million rows, where either with the tiny table first gives ten -/// thousand. +/// A three way join in its expensive `FROM` order: the two large tables first produce a +/// million rows, where either of them with the tiny table first gives ten thousand. fn late_reducer_plan() -> Result> { let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); @@ -391,9 +390,8 @@ async fn reordering_returns_the_same_rows_with(prefer_hash_join: bool) -> Result fn late_semi_join_plan(anti: bool) -> Result> { let fact = scan(1_000_000, &[("f_id", 1_000_000), ("f_type", 1_000)]); let other = scan(1_000_000, &[("o_id", 1_000_000)]); - // Sized so the reducer keeps one percent either way round: ten of the thousand - // types match for the semi join, all but ten for the anti join. A reducer that - // kept most of its input would not be worth moving. + // Sized so the reducer keeps one percent either way round: ten of the thousand types + // match for the semi join, all but ten for the anti join. let wanted = if anti { scan(990, &[("w_type", 990)]) } else { diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index 5f8412e23bb22..f28ba82b7d7ff 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -17,17 +17,13 @@ //! Cost-based join order enumeration. //! -//! A subtree of joins is flattened into relations plus the predicates between -//! them, a dynamic program searches the orders (bushy as well as left-deep) under -//! a `C_out` cost model, and the subtree is rebuilt if the winner is cheaper by a -//! clear margin. `JoinSelection` then picks each join's build side and partition -//! mode against the shape chosen here. +//! A subtree of joins is flattened into relations plus the predicates between them, a +//! dynamic program searches the orders (bushy as well as left-deep) under a `C_out` cost +//! model, and the subtree is rebuilt if the winner is clearly cheaper. //! -//! Reordering is sound because a tree of inner joins equals the cross product of -//! its relations filtered by all its predicates: any tree applying every -//! predicate exactly once, where the columns it needs are available, computes the -//! same rows. Semi and anti joins take part as reducers, since they filter their -//! output side rather than contributing columns of their own. +//! Reordering is sound because a tree of inner joins equals the cross product of its +//! relations filtered by all its predicates. Semi and anti joins take part as reducers: +//! they filter their output side rather than contributing columns. use std::collections::HashMap; use std::sync::Arc; @@ -121,9 +117,8 @@ impl PhysicalOptimizerRule for JoinEnumeration { } } -/// Hard upper bound on the relations in one join graph, and on the exhaustive -/// search, which allocates `2^n` and visits `3^n`. Larger graphs keep the -/// planner's order however high `join_enumeration_limit` is set. +/// Hard upper bound on the relations in one join graph. The search allocates `2^n` and +/// visits `3^n`, so larger graphs keep the planner's order regardless of the limit. const MAX_RELATIONS: usize = 16; /// Computes a plan node's statistics, shared with the rest of `JoinSelection`. @@ -147,8 +142,8 @@ fn covers(mask: RelSet, required: RelSet) -> bool { required & !mask == 0 } -/// One column of one relation. Reordering moves columns, so plumbing uses these -/// rather than indices. +/// One column of one relation, tracked instead of a plain index because reordering +/// moves columns to other positions. #[derive(Clone, Copy, PartialEq, Eq, Debug)] struct ColRef { rel: usize, @@ -381,9 +376,8 @@ impl<'a> CostModel<'a> { } } - /// Estimated rows from joining every relation in `mask`: the product of the - /// relation sizes scaled by the predicates that apply within it. Depending only - /// on the *set*, not the tree shape, is what makes the dynamic program valid. + /// Estimated rows from joining every relation in `mask`. Depending on the set alone, + /// not the tree shape, is what makes the dynamic program valid. fn cardinality(&self, mask: RelSet) -> f64 { let mut rows = 1.0; for rel in iter_rels(mask) { @@ -421,10 +415,9 @@ impl<'a> CostModel<'a> { .then_some(Combine::Reducer { reducer }); } } - // Otherwise both sides must contribute columns, and the operator follows from - // the predicates crossing the cut. An unconnected cut is a cross product, - // allowed only between whole components: any cut through one is connected, and - // a disconnected graph has no other way to be built. + // Otherwise both sides must contribute columns. An unconnected cut is a cross + // product, allowed only between whole components, which is the only way to join + // a disconnected graph. if left & !reducers == 0 || right & !reducers == 0 { return None; } @@ -435,10 +428,8 @@ impl<'a> CostModel<'a> { (self.connected(left, right) || separates_components).then_some(Combine::Inner) } - /// `C_out`: the sum of the internal nodes' cardinalities. Leaves are excluded as - /// every candidate reads the same relations. - /// The key classes joining `left` to `right`, which is what a partitioned join - /// would hash both sides on. + /// The key classes joining `left` to `right`, which a partitioned join hashes both + /// sides on. fn crossing_classes(&self, left: RelSet, right: RelSet) -> PartSet { let mut classes = 0; for (index, edge) in self.graph.edges.iter().enumerate() { @@ -450,11 +441,8 @@ impl<'a> CostModel<'a> { classes } - /// Whether `JoinSelection` will broadcast this side rather than partition it. It - /// compares bytes when an estimate exists and rows otherwise, so this mirrors it. - /// - /// A sort merge join has no mode that collects one side, so it never broadcasts and - /// costing an order as if it could would pick orders it cannot carry out. + /// Whether `JoinSelection` will broadcast this side rather than partition it, by the + /// same bytes-or-rows test it uses. A sort merge join has no mode that collects. fn broadcasts(&self, side: RelSet) -> bool { if self.graph.kind() == JoinKind::SortMerge { return false; @@ -472,11 +460,8 @@ impl<'a> CostModel<'a> { self.cardinality(side) * width < self.broadcast_bytes } - /// Cost of one join and the partitioning it leaves behind, for each way of - /// exchanging its inputs. Collecting a side moves that side; partitioning moves - /// whichever sides are not already hashed on the join key -- which is how a shape - /// that repartitions the largest relation twice becomes visibly worse than one - /// that repartitions it once. + /// Cost of one join and the partitioning it leaves behind, for each way of exchanging + /// its inputs. A side already hashed on the join key is not moved again. fn exchanges( &self, left: RelSet, @@ -486,10 +471,8 @@ impl<'a> CostModel<'a> { collect_only: Option, ) -> Vec<(f64, PartSet, RelSet, PartitionMode)> { let classes = self.crossing_classes(left, right); - // Without a key every pair has to be examined, and the output cardinality the - // rest of the cost is built from does not say that: a filter estimated to keep - // few rows still compares all of them. TPC-DS q85 read as cheap and ran 122x - // slower once a join became one. + // Without a key every pair is examined, which the output cardinality does not + // show: a filter estimated to keep few rows still compares all of them. let pairs = if classes == 0 { self.cardinality(left) * self.cardinality(right) } else { @@ -500,8 +483,7 @@ impl<'a> CostModel<'a> { if collect_only.is_some_and(|only| only != build) { continue; } - // With no key there is nothing to hash on, so a side must be collected - // whatever its size. + // With no key there is nothing to hash on, so a side must be collected. if classes == 0 || self.broadcasts(build) { let cost = pairs + self.cardinality(build); options.push((cost, probe_part, build, PartitionMode::CollectLeft)); @@ -563,10 +545,9 @@ impl<'a> CostModel<'a> { } } -/// Groups equi-join predicates that share a column into key classes, so two joins -/// hashing on the same class can reuse one partitioning. Returns each edge's class as -/// a bit position; classes beyond the bitmask's width collapse into the last, which -/// costs only precision. +/// Groups equi-join predicates that share a column into key classes, so two joins hashing +/// on the same class can reuse one partitioning. Returns each edge's class as a bit +/// position, with classes past the bitmask's width collapsed into the last. fn key_classes(edges: &[Edge]) -> Vec { let mut ids: Vec = (0..edges.len()).collect(); let shares = |a: &Edge, b: &Edge| { @@ -609,13 +590,9 @@ struct Solution { cost: f64, } -/// Exhaustive dynamic programming over connected relation subsets, each paired with -/// the partitioning its plan leaves behind. -/// -/// Carrying the partitioning is what lets a later join reuse an earlier one's hash -/// exchange instead of paying for another, so the search can tell a shape that -/// repartitions the largest relation once from one that does it twice. Costing order -/// alone cannot see that difference, since the exchange belongs to the mode. +/// Exhaustive dynamic programming over connected relation subsets, each paired with the +/// partitioning its plan leaves behind. Carrying the partitioning lets a later join reuse +/// an earlier one's exchange instead of paying for another. fn solve_dp(model: &CostModel) -> Option { let n = model.graph.relations.len(); let full: RelSet = model.graph.all(); @@ -680,8 +657,7 @@ fn solve_dp(model: &CostModel) -> Option { continue; }; let other = mask ^ split; - // The side that builds goes on the left, which is the side `CollectLeft` - // gathers and the side a semi or anti join probes from. + // The build side goes on the left: that is the side `CollectLeft` gathers. nodes.insert(mask, (build, mode)); for child in [split, other] { let child_part = best[child as usize] @@ -700,15 +676,12 @@ fn solve_dp(model: &CostModel) -> Option { #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum JoinKind { Hash, - /// Emits every column, having no built-in projection; the subtree gets one - /// projection on top instead. + /// Has no built-in projection, so the subtree gets one projection on top instead. SortMerge, } -/// One join, seen the same way whichever operator implements it. -/// -/// `kind` and `null_equality` are `None` for operators without equi-join keys, -/// which express no preference the rebuild must honour. +/// One join, seen the same way whichever operator implements it. `kind` and +/// `null_equality` are `None` for operators without equi-join keys. struct JoinView<'a> { kind: Option, role: JoinRole, @@ -791,9 +764,8 @@ enum JoinRole { }, } -/// Classifies a join. Outer and mark joins are excluded (not filters on their -/// inputs, or they add a column), as are `null_aware` anti joins, joins with a -/// limit, and semi joins whose filter is part of their existential test. +/// Classifies a join. Outer and mark joins are excluded because they do not just filter +/// their inputs, and semi and anti joins with a filter because it is part of their test. fn join_role(join_type: &JoinType, has_filter: bool) -> Option { let role = match join_type { JoinType::Inner => JoinRole::Inner, @@ -849,16 +821,14 @@ fn extend_required(columns: &mut Vec, wanted: &[ColRef], side: RelSet) { } } -/// Extracts the maximal reorderable subtree at `plan`. `None` covers every -/// bail-out: an unmodelled join feature, a key that is not a plain column, -/// missing row counts, too few or too many relations. +/// Extracts the maximal reorderable subtree at `plan`. `None` covers every bail-out: an +/// unmodelled join feature, a non-column key, missing row counts, too few or many inputs. fn extract( plan: &Arc, stats: &mut StatsFn, ) -> Result> { - // Start at a join, or at the column pruning projection usually sitting above - // one: rooting there lets its column list become the top join's projection - // instead of stranding a `ProjectionExec` above a wider join. + // Start at a join, or at the pruning projection usually above one, so its column list + // becomes the top join's projection instead of a `ProjectionExec` above a wider join. let is_root = join_view(plan).is_some() || plan .downcast_ref::() @@ -941,9 +911,8 @@ impl<'a, 's> Extractor<'a, 's> { } else if let Some(projection) = plan.downcast_ref::() && all_alias_free_columns(projection.expr()) { - // Looking through pruning projections is what lets the enumerator see a whole - // chain, since `ProjectionPushdown` has not folded them into the joins yet. - // `all_alias_free_columns` rules out renaming, so dropping them is safe. + // Looking through pruning projections lets the enumerator see a whole chain, + // since `ProjectionPushdown` has not folded them into the joins yet. let Some((child, mask)) = self.visit(projection.input())? else { return Ok(None); }; @@ -1158,9 +1127,8 @@ impl Rebuilder<'_> { Some(Combine::Reducer { reducer }) => { self.reducing(mask, required, reducer, mode) } - // The search put the building side on the left and chose the mode along - // with the order, so both are emitted as decided rather than left to - // `JoinSelection` to pick again. + // The search chose the build side and the mode along with the order, so both + // are emitted as decided rather than left to `JoinSelection`. Some(Combine::Inner) => self.inner(required, left, right, mode), } } @@ -1192,8 +1160,8 @@ impl Rebuilder<'_> { }; let keys = on.len(); if keys == 0 { - // No keys: a filter still restricts the pair, which is a nested loop join; - // without one it is a cross product. + // No keys: with a filter this is a nested loop join, without one a cross + // join. let projection = projection_for(required, &natural)?; return match filter { Some(filter) => { @@ -1272,8 +1240,8 @@ impl Rebuilder<'_> { }) .collect(); - // Each side emits this join's keys, its filters' columns, and what is asked - // for above. + // Each side emits this join's keys, its filters' columns, and what is needed + // above. let child_required = |side: RelSet, take_left: bool| { let mut columns: Vec = vec![]; for (left, right) in &keys { @@ -1427,9 +1395,8 @@ struct JoinSpec { mode: PartitionMode, } -/// Rebuilds the non-equi filters applied at one join as one conjunction. A -/// [`JoinFilter`] addresses an intermediate batch by index, so merging means -/// concatenating those schemas and shifting all but the first filter's indices. +/// Rebuilds the non-equi filters applied at one join as one conjunction. A [`JoinFilter`] +/// addresses its batch by index, so merging shifts all but the first filter's indices. fn rebuild_filters( filters: &[&Filter], left_columns: &[ColRef], @@ -1470,9 +1437,8 @@ fn rebuild_filters( }) .collect::>>()?; - // Intermediate columns go left side first: a hash join reads them positionally, - // but a sort merge join rebuilds the batch as all left then all right columns, - // so interleaved sides would evaluate against the wrong columns. + // Intermediate columns go left side first: a sort merge join rebuilds the batch as + // all left then all right columns, so interleaved sides would read wrong columns. let mut order: Vec = (0..column_indices.len()).collect(); order.sort_by_key(|i| column_indices[*i].side == JoinSide::Right); let mut moved = vec![0; order.len()]; @@ -1528,9 +1494,8 @@ fn rewrite_columns( type RewrittenRelation = (Arc, Arc); -/// Substitutes rewritten relations into a subtree, leaving its shape untouched. -/// The relations are the exact `Arc`s taken from it, so pointer identity finds -/// them, and a match is not descended into. +/// Substitutes rewritten relations into a subtree, leaving its shape untouched. The +/// relations are the exact `Arc`s taken from it, so pointer identity finds them. fn replace_relations( plan: &Arc, rewritten: &[RewrittenRelation], @@ -1575,9 +1540,8 @@ pub(crate) fn enumerate_join_order( if let Some(reordered) = reorder(&graph, config, stats)? { return Ok(Some(reordered)); } - // Rejected, so descend into the relations, not the children: re-extracting here - // would re-search costed subsets, and would weigh a gain against a subtree's own - // cost rather than the whole graph's, readmitting what the margin rejected. + // Rejected, so descend into the relations rather than the children: re-extracting + // here would re-search costed subsets and readmit what the margin turned down. let mut rewritten: Vec = vec![]; for relation in &graph.relations { if let Some(new) = enumerate_join_order(&relation.plan, config, stats)? { @@ -1623,9 +1587,8 @@ fn reorder( return Ok(None); }; - // Keep the planner's order unless the winner is clearly cheaper. Where estimates - // cannot tell orders apart every candidate looks alike and the winner is - // arbitrary, which cost TPC-DS q6 37% on an estimated gain under 1%. + // Keep the planner's order unless the winner is clearly cheaper: where estimates + // cannot tell orders apart, the winner is arbitrary. let margin = f64::from(config.optimizer.join_enumeration_min_improvement) / 100.0; if solution.cost >= model.tree_cost(&graph.original_nodes) * (1.0 - margin) { return Ok(None); diff --git a/datafusion/physical-optimizer/src/join_selection.rs b/datafusion/physical-optimizer/src/join_selection.rs index 1fac4902e2759..c935b1a2763b3 100644 --- a/datafusion/physical-optimizer/src/join_selection.rs +++ b/datafusion/physical-optimizer/src/join_selection.rs @@ -196,15 +196,13 @@ impl PhysicalOptimizerRule for JoinSelection { } } -/// How far above a join a hash requirement is still attributed to it, enough to reach -/// through the partial aggregate that a group-by is planned as. +/// How far above a join a hash requirement still counts as that join's, enough to reach +/// through the partial aggregate a group-by is planned as. const PARTITIONING_LOOKAHEAD: usize = 3; -/// Restores a partitioned join that an operator above needs the partitioning of. -/// -/// Collecting the build side saves the join its exchanges but discards its hash -/// partitioning, which then has to be rebuilt above: TPC-H q13's group-by falls back to -/// a shuffle and a two-phase aggregation and loses 20%, more than collecting saved. +/// Restores a partitioned join whose partitioning an operator above needs. Collecting the +/// build side saves its exchanges but discards the partitioning, which is then rebuilt +/// above, at more than collecting saved. fn keep_partitioning_needed_above( plan: Arc, registry: Option<&StatisticsRegistry>, @@ -257,10 +255,8 @@ fn repartition_collected_join( { return Ok(None); } - // The join's output partitioning is not hash partitioned yet, since its - // inputs are still single partitions, so compare against the keys it would - // be partitioned on. Naming a column of the other side is possible and only - // costs the collect this would have done. + // The join is not hash partitioned yet, its inputs still being single + // partitions, so compare against the keys it would be partitioned on. let keys: Vec<_> = join.on().iter().map(|(left, _)| Arc::clone(left)).collect(); if column_names(&keys).is_none_or(|keys| keys != required) { @@ -276,8 +272,7 @@ fn repartition_collected_join( ); return Ok(Some(rebuild_above(plan, &node, partitioned, depth)?)); } - // Only a single-input operator asking for nothing itself passes a partitioning - // up unchanged. + // Only a single-input operator with no requirement of its own passes one up. let children = node.children(); let [child] = children.as_slice() else { return Ok(None); @@ -295,12 +290,8 @@ fn repartition_collected_join( Ok(None) } -/// Whether partitioning the join moves no more rows than collecting it does. -/// -/// Both plans move the build side, one to partition it and the other to collect it. -/// Partitioning then moves the probe side, where collecting instead leaves the shuffle -/// to the operator above and moves the join's output. Partitioning also aggregates in -/// one pass rather than two, which settles the tie. +/// Whether partitioning the join moves no more rows than collecting it does. Both move +/// the build side; partitioning then moves the probe side, collecting the output above. fn worth_partitioning( join: &HashJoinExec, registry: Option<&StatisticsRegistry>, diff --git a/datafusion/physical-optimizer/src/optimizer.rs b/datafusion/physical-optimizer/src/optimizer.rs index 460b4bad46d69..21be83dc1e246 100644 --- a/datafusion/physical-optimizer/src/optimizer.rs +++ b/datafusion/physical-optimizer/src/optimizer.rs @@ -94,8 +94,8 @@ impl PhysicalOptimizer { // this information is not lost across different rules during optimization. Arc::new(OutputRequirements::new_add_mode()), Arc::new(AggregateStatistics::new()), - // Chooses the shape of the join tree, so it must run before JoinSelection - // decides each join's build side and partition mode. + // Must run before JoinSelection, which decides each join's build side and + // partition mode. Arc::new(JoinEnumeration::new()), // Statistics-based join selection will change the Auto mode to a real join implementation, // like collect left, or hash join, or future sort merge join, which will influence the diff --git a/datafusion/physical-plan/src/filter.rs b/datafusion/physical-plan/src/filter.rs index e905956e80efc..441237d11a91b 100644 --- a/datafusion/physical-plan/src/filter.rs +++ b/datafusion/physical-plan/src/filter.rs @@ -361,9 +361,7 @@ impl FilterExec { let null_rejecting_columns = collect_null_rejecting_columns(predicate); // Estimate one top-level conjunct at a time: interval analysis rejects a whole - // predicate if any part is out of reach, and an `IN` list is, since the planner - // expands it into `OR`s. TPC-DS estimated `d_dom between 1 and 2 AND d_year IN - // (1999, 2000, 2001)` at 20% of `date_dim`, 14,610 rows, where 72 survive. + // predicate if any part is out of reach, and an expanded `IN` list is. let (supported, rest): (Vec<_>, Vec<_>) = split_conjunction(predicate) .into_iter() .partition(|conjunct| check_support(conjunct, schema)); @@ -388,9 +386,7 @@ impl FilterExec { } // Rebuilding re-associates the conjunction and interval propagation is - // sensitive to the tree's shape, so pass the predicate through untouched when - // nothing was split off. Never pass it through when something was: analysis - // errors on the parts it rejected rather than falling back. + // shape sensitive, so pass the predicate through untouched if nothing split. let analyzable = if split_anything { conjunction_opt(supported.into_iter().cloned()) } else { @@ -1004,10 +1000,9 @@ impl EmbeddedProjection for FilterExec { /// /// Only AND conjunctions are traversed; OR is intentionally skipped /// since `a = 1 OR a = 2` does not pin NDV to 1. -/// Estimates `col IN (a, b, c)`, including the `OR` chain a short list expands -/// into, as `distinct literals / distinct values` -- the reasoning `col = -/// literal` gets from `1 / NDV`. Interval arithmetic cannot narrow a column from -/// a disjunction, so such a conjunct would otherwise only take the default. +/// Estimates `col IN (a, b, c)`, and the `OR` chain a short list expands into, as +/// `distinct literals / distinct values`. Interval arithmetic cannot narrow a column +/// from a disjunction, so such a conjunct would otherwise only take the default. /// /// `None` when the conjunct is not a list of literals over one column. fn in_list_selectivity( @@ -2870,9 +2865,8 @@ mod tests { Arc::new(Literal::new(ScalarValue::Utf8(Some("b".to_string())))), )), )), - // The two listed values are 2 of the column's 50, so 4 of the - // 100 input rows are expected and NDV is capped at 4. Still not - // collapsed to 1, which is what this case guards. + // The two listed values are 2 of the column's 50, so 4 of the 100 rows + // are expected and NDV is capped at 4, not collapsed to 1. vec![Precision::Inexact(4)], ), ( diff --git a/datafusion/physical-plan/src/joins/utils.rs b/datafusion/physical-plan/src/joins/utils.rs index b4248c3cd2d7d..f01350aaa0b35 100644 --- a/datafusion/physical-plan/src/joins/utils.rs +++ b/datafusion/physical-plan/src/joins/utils.rs @@ -466,9 +466,8 @@ pub(crate) fn estimate_join_statistics( join_type: &JoinType, schema: &Schema, ) -> Result { - // Width of one output row, from the sides this join emits. Without it a join - // reports no size, and `hash_join_single_partition_threshold` -- a byte threshold - // -- falls back to counting rows, blind to how wide they are. + // Width of one output row, from the sides this join emits. Without it a join reports + // no size and `hash_join_single_partition_threshold` falls back to counting rows. let width = |stats: &Statistics| match ( stats.total_byte_size.get_value(), stats.num_rows.get_value(), diff --git a/datafusion/sqllogictest/test_files/information_schema.slt b/datafusion/sqllogictest/test_files/information_schema.slt index 61f60d78e284a..68879996934b5 100644 --- a/datafusion/sqllogictest/test_files/information_schema.slt +++ b/datafusion/sqllogictest/test_files/information_schema.slt @@ -490,8 +490,8 @@ datafusion.optimizer.hash_join_inlist_pushdown_max_size 131072 Maximum size in b datafusion.optimizer.hash_join_single_partition_threshold 4194304 The maximum estimated size in bytes for one input side of a HashJoin will be collected into a single partition datafusion.optimizer.hash_join_single_partition_threshold_rows 131072 The maximum estimated size in rows for one input side of a HashJoin will be collected into a single partition datafusion.optimizer.join_enumeration true When set to true, the physical plan optimizer enumerates join orders for subtrees of joins and picks the cheapest from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics are left untouched. -datafusion.optimizer.join_enumeration_limit 12 Maximum inputs in a join subtree for which `join_enumeration` searches, at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of over 16 inputs whatever this is set to. -datafusion.optimizer.join_enumeration_min_improvement 10 How much cheaper an enumerated join order must be, in percent, before it replaces the order the planner produced. Estimates often cannot tell two orders apart, and swapping on one that close is as likely to lose as win. +datafusion.optimizer.join_enumeration_limit 12 Maximum inputs in a join subtree for which `join_enumeration` searches, at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of more than 16 inputs regardless of this setting. +datafusion.optimizer.join_enumeration_min_improvement 10 How much cheaper an enumerated join order must be, in percent, before it replaces the order the planner produced. Estimates are often too close to tell two orders apart, so a small gain is not worth acting on. datafusion.optimizer.join_reordering true When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. datafusion.optimizer.max_passes 3 Number of times that the optimizer will attempt to optimize the plan datafusion.optimizer.prefer_existing_sort false When true, DataFusion will opportunistically remove sorts when the data is already sorted, (i.e. setting `preserve_order` to true on `RepartitionExec` and using `SortPreservingMergeExec`) When false, DataFusion will maximize plan parallelism using `RepartitionExec` even if this requires subsequently resorting data using a `SortExec`. diff --git a/docs/source/user-guide/configs.md b/docs/source/user-guide/configs.md index 3a88e7e155301..d6b19a43878fb 100644 --- a/docs/source/user-guide/configs.md +++ b/docs/source/user-guide/configs.md @@ -171,8 +171,8 @@ The following configuration settings are available: | datafusion.optimizer.top_down_join_key_reordering | true | When set to true, the physical plan optimizer will run a top down process to reorder the join keys | | datafusion.optimizer.join_reordering | true | When set to true, the physical plan optimizer may swap join inputs based on statistics. When set to false, statistics-driven join input reordering is disabled and the original join order in the query is used. | | datafusion.optimizer.join_enumeration | true | When set to true, the physical plan optimizer enumerates join orders for subtrees of joins and picks the cheapest from cardinality estimates, considering bushy shapes as well as left-deep ones. Subtrees whose inputs lack row count statistics are left untouched. | -| datafusion.optimizer.join_enumeration_min_improvement | 10 | How much cheaper an enumerated join order must be, in percent, before it replaces the order the planner produced. Estimates often cannot tell two orders apart, and swapping on one that close is as likely to lose as win. | -| datafusion.optimizer.join_enumeration_limit | 12 | Maximum inputs in a join subtree for which `join_enumeration` searches, at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of over 16 inputs whatever this is set to. | +| datafusion.optimizer.join_enumeration_min_improvement | 10 | How much cheaper an enumerated join order must be, in percent, before it replaces the order the planner produced. Estimates are often too close to tell two orders apart, so a small gain is not worth acting on. | +| datafusion.optimizer.join_enumeration_limit | 12 | Maximum inputs in a join subtree for which `join_enumeration` searches, at a cost of `O(3^n)`. Larger subtrees keep the planner's order, as do subtrees of more than 16 inputs regardless of this setting. | | datafusion.optimizer.use_statistics_registry | false | When set to true, the physical plan optimizer uses the pluggable `StatisticsRegistry` for statistics propagation across operators. This enables more accurate cardinality estimates compared to each operator's built-in `partition_statistics`. | | datafusion.optimizer.prefer_hash_join | true | When set to true, the physical plan optimizer will prefer HashJoin over SortMergeJoin. HashJoin can work more efficiently than SortMergeJoin but consumes more memory | | datafusion.optimizer.enable_piecewise_merge_join | false | When set to true, piecewise merge join is enabled. PiecewiseMergeJoin is currently experimental. Physical planner will opt for PiecewiseMergeJoin when there is only one range filter. | From 29c224d86b1188a6341dedbdf8c7371241dfebc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Thu, 20 Aug 2026 12:00:03 +0200 Subject: [PATCH 14/16] Narrow each rebuilt sort merge join instead of once above the subtree A hash join carries its own projection, so the rebuilt tree derives one per node from what that node's parents need. A sort merge join has none, so the rule emitted every column at every node and projected once on top, leaving each join to sort columns nothing above it reads. `ProjectionPushdown` cannot repair it afterwards: it only pushes through a join whose columns stay left-then-right, and reordering interleaves them. Each rebuilt sort merge join now drops what nothing above needs. Reordering alone is still left to the parent, which addresses columns by position, so no projection is added where none narrows. Measured against emitting every column, same run, alternating order: TPC-DS SF1 0.98x over the 39 queries whose plans change, TPC-H SF10 0.97x. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012YiAABcW4WSqij31zz2P6c --- .../src/join_enumeration.rs | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index f28ba82b7d7ff..1e7fa2281e7ad 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -1196,7 +1196,7 @@ impl Rebuilder<'_> { Ok((Arc::new(join), required.to_vec())) } JoinKind::SortMerge => { - let join = SortMergeJoinExec::try_new( + let join: Arc = Arc::new(SortMergeJoinExec::try_new( left.plan, right.plan, on, @@ -1204,8 +1204,20 @@ impl Rebuilder<'_> { join_type, vec![SortOptions::default(); keys], self.graph.null_equality(), - )?; - Ok((Arc::new(join), natural)) + )?); + // A sort merge join emits every column, so drop the ones nothing above + // needs here rather than once above the whole subtree: what this join + // emits is what the next one sorts. `ProjectionPushdown` cannot do it + // afterwards, since it only pushes through a join whose columns stay + // left-then-right, and reordering interleaves them. Reordering alone is + // left to the parent, which addresses columns by position anyway. + if required.len() == natural.len() { + return Ok((join, natural)); + } + let Some(projection) = projection_for(required, &natural)? else { + return Ok((join, natural)); + }; + Ok((narrow(join, &projection)?, required.to_vec())) } } } @@ -1368,6 +1380,25 @@ fn key_expr( Ok(Arc::new(Column::new(plan.schema().field(index).name(), index)) as _) } +/// Wraps `plan` in a projection keeping only `indices`, in that order. +fn narrow( + plan: Arc, + indices: &[usize], +) -> Result> { + let schema = plan.schema(); + let exprs: Vec = indices + .iter() + .map(|&index| { + let name = schema.field(index).name(); + ProjectionExpr { + expr: Arc::new(Column::new(name, index)), + alias: name.clone(), + } + }) + .collect(); + Ok(Arc::new(ProjectionExec::try_new(exprs, plan)?)) +} + fn projection_for(required: &[ColRef], emitted: &[ColRef]) -> Result>> { let mut projection = Vec::with_capacity(required.len()); for col in required { @@ -1612,21 +1643,9 @@ fn reorder( if columns == graph.output { return Ok(Some(plan)); } - // Only a sort merge subtree reaches here, having no projection of its own. - let schema = plan.schema(); - let exprs = graph - .output - .iter() - .map(|col| { - let Some(index) = position(&columns, *col) else { - return internal_err!("join enumeration lost column {col:?}"); - }; - let name = schema.field(index).name(); - Ok(ProjectionExpr { - expr: Arc::new(Column::new(name, index)), - alias: name.clone(), - }) - }) - .collect::>>()?; - Ok(Some(Arc::new(ProjectionExec::try_new(exprs, plan)?))) + // Only a sort merge subtree reaches here, when its root emits more than the output. + let Some(projection) = projection_for(&graph.output, &columns)? else { + return Ok(Some(plan)); + }; + Ok(Some(narrow(plan, &projection)?)) } From 8126ad333f5a8c54fc4d3e994093d534d09a6d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Fri, 21 Aug 2026 10:36:32 +0200 Subject: [PATCH 15/16] Make the cost model a trait others can plug into The search now asks a `JoinCostModel` for cardinalities, which pairs may be combined, and what each exchange costs, so anyone with better statistics than the plan carries -- or a different cost function -- can search under them via `JoinEnumeration::with_cost_model`. `DefaultJoinCostModel` is what the rule uses otherwise, unchanged in what it estimates. `reducer_side` and `tree_cost` are trait defaults, since both follow from the three required methods. `exchanges` returns a named `Exchange` rather than a tuple, and the graph the model works over is public: an external model needs to see the relations to key its own statistics off them. Co-Authored-By: Claude Opus 5 --- .../physical_optimizer/join_enumeration.rs | 79 ++- .../src/join_enumeration.rs | 456 ++++++++++++------ 2 files changed, 380 insertions(+), 155 deletions(-) diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index a65e7a2841cc6..ab0eed45f33e0 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -33,7 +33,10 @@ use datafusion_expr::Operator; use datafusion_physical_expr::expressions::BinaryExpr; use datafusion_physical_expr::expressions::Column; use datafusion_physical_optimizer::PhysicalOptimizerRule; -use datafusion_physical_optimizer::join_enumeration::JoinEnumeration; +use datafusion_physical_optimizer::join_enumeration::{ + Combine, DefaultJoinCostModel, Exchange, JoinCostModel, JoinCostModelFactory, + JoinEnumeration, JoinGraph, PartSet, RelSet, iter_rels, +}; use datafusion_physical_optimizer::join_selection::JoinSelection; use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; use datafusion_physical_plan::joins::{ @@ -299,6 +302,80 @@ fn keeps_an_already_optimal_order() -> Result<()> { Ok(()) } +/// Row counts from outside the plan, keyed by the first column a relation emits. The +/// tiny dimension table is claimed to be the largest of the three. +fn external_rows(column: &str) -> f64 { + match column { + "t_type" => 1e9, + _ => 1e6, + } +} + +/// Hands out [`ExternalStatisticsCostModel`]. +#[derive(Debug)] +struct ExternalStatistics {} + +impl JoinCostModelFactory for ExternalStatistics { + fn create<'graph>( + &self, + graph: &'graph JoinGraph, + config: &ConfigOptions, + ) -> Result> { + Ok(Box::new(ExternalStatisticsCostModel { + graph, + inner: DefaultJoinCostModel::new(graph, config), + })) + } +} + +/// Counts rows from statistics the plan does not carry, and leaves the rest of the model +/// to the built-in one. +struct ExternalStatisticsCostModel<'a> { + graph: &'a JoinGraph, + inner: DefaultJoinCostModel<'a>, +} + +impl JoinCostModel for ExternalStatisticsCostModel<'_> { + fn cardinality(&self, mask: RelSet) -> f64 { + iter_rels(mask) + .map(|rel| { + let schema = self.graph.relations[rel].plan.schema(); + external_rows(schema.field(0).name()) + }) + .product::() + .max(1.0) + } + + fn combine(&self, left: RelSet, right: RelSet) -> Option { + self.inner.combine(left, right) + } + + fn exchanges( + &self, + left: RelSet, + right: RelSet, + left_part: PartSet, + right_part: PartSet, + collect_only: Option, + ) -> Vec { + self.inner + .exchanges(left, right, left_part, right_part, collect_only) + } +} + +#[test] +fn searches_under_a_plugged_in_cost_model() -> Result<()> { + let plan = late_reducer_plan()?; + // Under the plan's own statistics this order is the expensive one, and + // `reorders_a_late_reducer` shows the built-in model rewriting it. Told the dimension + // table is the largest of the three, the search keeps it. + let enumerated = JoinEnumeration::new() + .with_cost_model(Arc::new(ExternalStatistics {})) + .optimize(Arc::clone(&plan), &ConfigOptions::new())?; + assert_eq!(formatted(&enumerated), formatted(&plan)); + Ok(()) +} + /// A session over four in-memory tables shaped like a small star schema. fn star_schema_context( join_enumeration: bool, diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration.rs index 1e7fa2281e7ad..3af336ef983b2 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration.rs @@ -21,6 +21,9 @@ //! dynamic program searches the orders (bushy as well as left-deep) under a `C_out` cost //! model, and the subtree is rebuilt if the winner is clearly cheaper. //! +//! The estimates come from a [`JoinCostModel`], so a different one can be plugged in +//! with [`JoinEnumeration::with_cost_model`]. +//! //! Reordering is sound because a tree of inner joins equals the cross product of its //! relations filtered by all its predicates. Semi and anti joins take part as reducers: //! they filter their output side rather than contributing columns. @@ -58,13 +61,29 @@ use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties}; /// join runs. /// /// [`JoinSelection`]: crate::join_selection::JoinSelection -#[derive(Default, Debug)] -pub struct JoinEnumeration {} +#[derive(Debug)] +pub struct JoinEnumeration { + cost_model: Arc, +} impl JoinEnumeration { #[expect(missing_docs)] pub fn new() -> Self { - Self {} + Self { + cost_model: Arc::new(DefaultJoinCostModelFactory {}), + } + } + + /// Searches with `cost_model` in place of [`DefaultJoinCostModel`]. + pub fn with_cost_model(mut self, cost_model: Arc) -> Self { + self.cost_model = cost_model; + self + } +} + +impl Default for JoinEnumeration { + fn default() -> Self { + Self::new() } } @@ -105,7 +124,10 @@ impl PhysicalOptimizerRule for JoinEnumeration { StatisticsContext::new().compute(plan, &StatisticsArgs::new()) } }; - Ok(enumerate_join_order(&plan, config, &mut stats)?.unwrap_or(plan)) + Ok( + enumerate_join_order(&plan, config, &mut stats, self.cost_model.as_ref())? + .unwrap_or(plan), + ) } fn name(&self) -> &str { @@ -126,120 +148,144 @@ pub(crate) type StatsFn<'a> = dyn FnMut(&dyn ExecutionPlan) -> Result> + 'a; /// A bitmask over relation indices. -type RelSet = u64; +pub type RelSet = u64; -fn bit(rel: usize) -> RelSet { +/// The set holding `rel` alone. +pub fn bit(rel: usize) -> RelSet { 1u64 << rel } -fn iter_rels(mask: RelSet) -> impl Iterator { +/// The relations in `mask`, lowest index first. +pub fn iter_rels(mask: RelSet) -> impl Iterator { std::iter::successors(Some(mask), |m| Some(m & m.wrapping_sub(1))) .take_while(|m| *m != 0) .map(|m| m.trailing_zeros() as usize) } -fn covers(mask: RelSet, required: RelSet) -> bool { +/// A hash partitioning, as the set of key classes it is partitioned on. Zero means +/// not hash partitioned, which is where every scan starts. +pub type PartSet = u32; + +/// Whether `mask` holds every relation in `required`. +pub fn covers(mask: RelSet, required: RelSet) -> bool { required & !mask == 0 } /// One column of one relation, tracked instead of a plain index because reordering /// moves columns to other positions. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -struct ColRef { - rel: usize, - col: usize, +pub struct ColRef { + /// Index into [`JoinGraph::relations`]. + pub rel: usize, + /// Index into that relation's schema. + pub col: usize, } /// What a relation contributes to the join. #[derive(Debug)] -enum Role { +pub enum Role { + /// An ordinary input, contributing its columns. Output, /// The quantified side of a semi or anti join, which filters instead of /// contributing columns. Reducer(Reducer), } +/// The quantified side of a semi or anti join, as what it does to the side it filters. #[derive(Debug)] -struct Reducer { +pub struct Reducer { /// `true` for an anti join, which keeps the rows that do *not* match. - anti: bool, + pub anti: bool, /// Keys, as `(column of the filtered side, column index here)`. - keys: Vec<(ColRef, usize)>, + pub keys: Vec<(ColRef, usize)>, /// Relations the keys reference; this reducer applies only to a set covering them. - required: RelSet, + pub required: RelSet, } /// One leaf of the join graph: a subplan the enumerator does not look inside. #[derive(Debug)] -struct Relation { - plan: Arc, +pub struct Relation { + /// The subplan this relation stands for. + pub plan: Arc, /// Estimated row count, clamped to at least 1. - rows: f64, + pub rows: f64, /// Estimated bytes per row, when the input reports a size. - width: Option, + pub width: Option, /// Per-column distinct value estimate, clamped to `[1, rows]`. - ndv: Vec, - role: Role, + pub ndv: Vec, + /// What it contributes to the join. + pub role: Role, } /// An equi-join predicate `left = right` between two distinct relations. #[derive(Clone, Copy, Debug)] -struct Edge { - left: ColRef, - right: ColRef, +pub struct Edge { + /// The column on one side. + pub left: ColRef, + /// The column it is compared against. + pub right: ColRef, } /// A non-equi join predicate, moved along with its column references rewritten. #[derive(Debug)] -struct Filter { - filter: JoinFilter, +pub struct Filter { + /// The predicate itself. + pub filter: JoinFilter, /// The column each entry of the filter's intermediate schema comes from. - columns: Vec, + pub columns: Vec, /// The relations those columns belong to. - required: RelSet, + pub required: RelSet, } /// A connected set of joins as relations plus the predicates between them. #[derive(Debug)] -struct JoinGraph { - relations: Vec, - edges: Vec, - filters: Vec, +pub struct JoinGraph { + /// The leaves, addressed by index throughout. + pub relations: Vec, + /// The equi-join predicates between them. + pub edges: Vec, + /// The non-equi predicates between them. + pub filters: Vec, /// Columns the original subtree emitted; the rebuilt one reproduces this exactly. - output: Vec, + pub output: Vec, /// Null handling shared by the subtree's joins. A join that differs becomes a /// relation instead. - null_equality: Option, + pub null_equality: Option, /// The original tree's internal nodes as `(node, one child)`, children first, so /// the planner's shape can be scored under the same formula as the alternatives. - original_nodes: Vec<(RelSet, RelSet)>, + pub original_nodes: Vec<(RelSet, RelSet)>, /// The relations that are reducers rather than ordinary inputs. - reducers: RelSet, + pub reducers: RelSet, /// Which join operator the subtree used, and which the rebuild emits. - kind: Option, + pub kind: Option, } impl JoinGraph { - fn ndv(&self, col: ColRef) -> f64 { + /// Distinct values estimated for one column. + pub fn ndv(&self, col: ColRef) -> f64 { self.relations[col.rel].ndv[col.col] } - fn all(&self) -> RelSet { + /// Every relation in the graph. + pub fn all(&self) -> RelSet { (0..self.relations.len()).fold(0, |mask, rel| mask | bit(rel)) } - fn reducer(&self, rel: usize) -> Option<&Reducer> { + /// How `rel` reduces the side it filters, if it is a reducer at all. + pub fn reducer(&self, rel: usize) -> Option<&Reducer> { match &self.relations[rel].role { Role::Reducer(reducer) => Some(reducer), Role::Output => None, } } - fn kind(&self) -> JoinKind { + /// The operator the rebuild emits, defaulting to a hash join. + pub fn kind(&self) -> JoinKind { self.kind.unwrap_or(JoinKind::Hash) } - fn null_equality(&self) -> NullEquality { + /// The null handling the rebuild emits, defaulting to `NullEqualsNothing`. + pub fn null_equality(&self) -> NullEquality { self.null_equality .unwrap_or(NullEquality::NullEqualsNothing) } @@ -247,16 +293,132 @@ impl JoinGraph { /// A valid way of combining two relation sets. #[derive(Clone, Copy, Debug)] -enum Combine { +pub enum Combine { + /// An inner join, both sides contributing their columns. Inner, /// A semi or anti join applying `reducer` to the opposite set. Reducer { + /// The reducing relation, which is the whole of its side. reducer: usize, }, } -/// Cardinality and cost estimates over the subsets of a [`JoinGraph`]. -struct CostModel<'a> { +/// One way of exchanging a join's inputs: what the exchange costs, the partitioning it +/// leaves the output in, the side that builds, and the mode the join runs in. +#[derive(Clone, Copy, Debug)] +pub struct Exchange { + /// Cost of moving the inputs, in the unit [`JoinCostModel::cardinality`] counts in. + pub cost: f64, + /// The partitioning the join's output is left in, for a join above to reuse. + pub partitioning: PartSet, + /// The side that builds: what `CollectLeft` collects, or which side is hashed. + pub build: RelSet, + /// The mode the join is costed under, which is the one the rebuild emits. + pub mode: PartitionMode, +} + +/// Cardinality and cost estimates over the subsets of a [`JoinGraph`], which is all the +/// search knows about the data. +/// +/// [`DefaultJoinCostModel`] derives them from the statistics the subtree's inputs report. +/// Implement this trait, and hand it to [`JoinEnumeration::with_cost_model`], to search +/// under other estimates: statistics kept outside the plan, a different cost function, or +/// knowledge of the data the plan has no way to carry. +pub trait JoinCostModel { + /// Estimated rows from joining every relation in `mask`. + /// + /// Must depend on the set alone and not on the order its relations were joined in: + /// that is what makes the dynamic program valid. Should stay at or above `1.0`, since + /// the search multiplies cardinalities. + fn cardinality(&self, mask: RelSet) -> f64; + + /// Whether `left` and `right` can be combined, and how: as an inner join, or with one + /// side applying as a reducer. `None` prunes the pair from the search, which is what + /// keeps a cut no predicate spans, or a reducer whose keys the other side does not + /// supply, out of the plan. + fn combine(&self, left: RelSet, right: RelSet) -> Option; + + /// Cost of joining `left` (partitioned as `left_part`) with `right` (`right_part`), + /// one entry per way of exchanging the inputs. Returning several lets the search carry + /// each partitioning forward and keep whichever pays off at the joins above; an empty + /// result prunes the pair. + /// + /// `collect_only` is the side that must build, when the combination forces one. + fn exchanges( + &self, + left: RelSet, + right: RelSet, + left_part: PartSet, + right_part: PartSet, + collect_only: Option, + ) -> Vec; + + /// The side that must build, when one of them is a reducer: the reducer is hashed so + /// the side it filters can stream. + fn reducer_side(&self, left: RelSet, right: RelSet) -> Option { + match self.combine(left, right) { + Some(Combine::Reducer { reducer }) => Some(bit(reducer)), + _ => None, + } + } + + /// Cost of a whole tree, given its internal nodes as `(node, one child)`, children + /// first. The search scores its own candidates this way, so the shape the planner + /// produced can be compared against them. + fn tree_cost(&self, nodes: &[(RelSet, RelSet)]) -> f64 { + let mut parts: HashMap = HashMap::new(); + let mut total = 0.0; + for (mask, child) in nodes { + if mask.count_ones() < 2 { + continue; + } + let other = mask ^ child; + let (child_part, other_part) = ( + parts.get(child).copied().unwrap_or(0), + parts.get(&other).copied().unwrap_or(0), + ); + let collect_only = self.reducer_side(*child, other); + let best = self + .exchanges(*child, other, child_part, other_part, collect_only) + .into_iter() + .min_by(|a, b| a.cost.total_cmp(&b.cost)); + let (exchange, part) = + best.map_or((0.0, 0), |best| (best.cost, best.partitioning)); + total += self.cardinality(*mask) + exchange; + parts.insert(*mask, part); + } + total + } +} + +/// Builds the [`JoinCostModel`] for one join subtree. +pub trait JoinCostModelFactory: std::fmt::Debug + Send + Sync { + /// Creates a cost model over `graph`. + fn create<'graph>( + &self, + graph: &'graph JoinGraph, + config: &ConfigOptions, + ) -> Result>; +} + +/// Hands out [`DefaultJoinCostModel`], which the rule searches with unless it was given +/// another factory. +#[derive(Debug, Default)] +pub struct DefaultJoinCostModelFactory {} + +impl JoinCostModelFactory for DefaultJoinCostModelFactory { + fn create<'graph>( + &self, + graph: &'graph JoinGraph, + config: &ConfigOptions, + ) -> Result> { + Ok(Box::new(DefaultJoinCostModel::new(graph, config))) + } +} + +/// The built-in [`JoinCostModel`]: a `C_out` model over the row counts, distinct counts +/// and widths the subtree's inputs report. +pub struct DefaultJoinCostModel<'a> { graph: &'a JoinGraph, /// Aggregated selectivity per connected relation pair, as /// `(rel_a, rel_b, selectivity)` with `rel_a < rel_b`. @@ -279,12 +441,9 @@ struct CostModel<'a> { broadcast_rows: f64, } -/// A hash partitioning, as the set of key classes it is partitioned on. Zero means -/// not hash partitioned, which is where every scan starts. -type PartSet = u32; - -impl<'a> CostModel<'a> { - fn new(graph: &'a JoinGraph, config: &ConfigOptions) -> Self { +impl<'a> DefaultJoinCostModel<'a> { + /// Precomputes the selectivities and connectivity of `graph`. + pub fn new(graph: &'a JoinGraph, config: &ConfigOptions) -> Self { // Denominate each relation pair by its most selective key, as // `estimate_inner_join_cardinality` does, so the two models agree. let mut denominators: HashMap<(usize, usize), f64> = HashMap::new(); @@ -376,8 +535,46 @@ impl<'a> CostModel<'a> { } } - /// Estimated rows from joining every relation in `mask`. Depending on the set alone, - /// not the tree shape, is what makes the dynamic program valid. + fn connected(&self, left: RelSet, right: RelSet) -> bool { + iter_rels(left).any(|rel| self.adjacency[rel] & right != 0) + } + + /// The key classes joining `left` to `right`, which a partitioned join hashes both + /// sides on. + fn crossing_classes(&self, left: RelSet, right: RelSet) -> PartSet { + let mut classes = 0; + for (index, edge) in self.graph.edges.iter().enumerate() { + let (a, b) = (bit(edge.left.rel), bit(edge.right.rel)); + if (left & a != 0 && right & b != 0) || (left & b != 0 && right & a != 0) { + classes |= 1 << self.edge_class[index]; + } + } + classes + } + + /// Whether `JoinSelection` will broadcast this side rather than partition it, by the + /// same bytes-or-rows test it uses. A sort merge join has no mode that collects. + fn broadcasts(&self, side: RelSet) -> bool { + if self.graph.kind() == JoinKind::SortMerge { + return false; + } + let mut width = 0.0; + for rel in iter_rels(side) { + if self.graph.reducer(rel).is_some() { + continue; + } + match self.graph.relations[rel].width { + Some(bytes) => width += bytes, + None => return self.cardinality(side) < self.broadcast_rows, + } + } + self.cardinality(side) * width < self.broadcast_bytes + } +} + +impl JoinCostModel for DefaultJoinCostModel<'_> { + /// Rows are the product of the relations' row counts, cut by the selectivity of + /// every predicate the set closes over. fn cardinality(&self, mask: RelSet) -> f64 { let mut rows = 1.0; for rel in iter_rels(mask) { @@ -400,10 +597,6 @@ impl<'a> CostModel<'a> { rows.max(1.0) } - fn connected(&self, left: RelSet, right: RelSet) -> bool { - iter_rels(left).any(|rel| self.adjacency[rel] & right != 0) - } - fn combine(&self, left: RelSet, right: RelSet) -> Option { let reducers = self.graph.reducers; // A lone reducer is applied to the other side, which must supply its keys. @@ -428,40 +621,8 @@ impl<'a> CostModel<'a> { (self.connected(left, right) || separates_components).then_some(Combine::Inner) } - /// The key classes joining `left` to `right`, which a partitioned join hashes both - /// sides on. - fn crossing_classes(&self, left: RelSet, right: RelSet) -> PartSet { - let mut classes = 0; - for (index, edge) in self.graph.edges.iter().enumerate() { - let (a, b) = (bit(edge.left.rel), bit(edge.right.rel)); - if (left & a != 0 && right & b != 0) || (left & b != 0 && right & a != 0) { - classes |= 1 << self.edge_class[index]; - } - } - classes - } - - /// Whether `JoinSelection` will broadcast this side rather than partition it, by the - /// same bytes-or-rows test it uses. A sort merge join has no mode that collects. - fn broadcasts(&self, side: RelSet) -> bool { - if self.graph.kind() == JoinKind::SortMerge { - return false; - } - let mut width = 0.0; - for rel in iter_rels(side) { - if self.graph.reducer(rel).is_some() { - continue; - } - match self.graph.relations[rel].width { - Some(bytes) => width += bytes, - None => return self.cardinality(side) < self.broadcast_rows, - } - } - self.cardinality(side) * width < self.broadcast_bytes - } - - /// Cost of one join and the partitioning it leaves behind, for each way of exchanging - /// its inputs. A side already hashed on the join key is not moved again. + /// A side already hashed on the join key is not moved again, so the cost of an + /// exchange depends on what the joins below left behind. fn exchanges( &self, left: RelSet, @@ -469,7 +630,7 @@ impl<'a> CostModel<'a> { left_part: PartSet, right_part: PartSet, collect_only: Option, - ) -> Vec<(f64, PartSet, RelSet, PartitionMode)> { + ) -> Vec { let classes = self.crossing_classes(left, right); // Without a key every pair is examined, which the output cardinality does not // show: a filter estimated to keep few rows still compares all of them. @@ -485,8 +646,12 @@ impl<'a> CostModel<'a> { } // With no key there is nothing to hash on, so a side must be collected. if classes == 0 || self.broadcasts(build) { - let cost = pairs + self.cardinality(build); - options.push((cost, probe_part, build, PartitionMode::CollectLeft)); + options.push(Exchange { + cost: pairs + self.cardinality(build), + partitioning: probe_part, + build, + mode: PartitionMode::CollectLeft, + }); } } if classes != 0 { @@ -504,45 +669,15 @@ impl<'a> CostModel<'a> { right } }); - options.push((moved, classes, build, PartitionMode::Partitioned)); + options.push(Exchange { + cost: moved, + partitioning: classes, + build, + mode: PartitionMode::Partitioned, + }); } options } - - /// Cost of the shape the planner produced, scored the way the search scores its - /// own candidates, including the exchanges each of its joins would need. - fn tree_cost(&self, nodes: &[(RelSet, RelSet)]) -> f64 { - let mut parts: HashMap = HashMap::new(); - let mut total = 0.0; - for (mask, child) in nodes { - if mask.count_ones() < 2 { - continue; - } - let other = mask ^ child; - let (child_part, other_part) = ( - parts.get(child).copied().unwrap_or(0), - parts.get(&other).copied().unwrap_or(0), - ); - let collect_only = self.reducer_side(*child, other); - let best = self - .exchanges(*child, other, child_part, other_part, collect_only) - .into_iter() - .min_by(|a, b| a.0.total_cmp(&b.0)); - let (exchange, part) = - best.map_or((0.0, 0), |(cost, part, _, _)| (cost, part)); - total += self.cardinality(*mask) + exchange; - parts.insert(*mask, part); - } - total - } - - /// The side that must build, when one of them is a reducer. - fn reducer_side(&self, left: RelSet, right: RelSet) -> Option { - match self.combine(left, right) { - Some(Combine::Reducer { reducer }) => Some(bit(reducer)), - _ => None, - } - } } /// Groups equi-join predicates that share a column into key classes, so two joins hashing @@ -593,9 +728,9 @@ struct Solution { /// Exhaustive dynamic programming over connected relation subsets, each paired with the /// partitioning its plan leaves behind. Carrying the partitioning lets a later join reuse /// an earlier one's exchange instead of paying for another. -fn solve_dp(model: &CostModel) -> Option { - let n = model.graph.relations.len(); - let full: RelSet = model.graph.all(); +fn solve_dp(graph: &JoinGraph, model: &dyn JoinCostModel) -> Option { + let n = graph.relations.len(); + let full: RelSet = graph.all(); // Per subset, the cheapest plan for each partitioning it can be left in, and the // choice that got there. A scan arrives hash partitioned on nothing. @@ -627,14 +762,19 @@ fn solve_dp(model: &CostModel) -> Option { for (left_part, left_cost) in best[left as usize].clone() { for (right_part, right_cost) in best[right as usize].clone() { let below = left_cost + right_cost + cardinality; - for (exchange, part, build, mode) in + for exchange in model.exchanges(left, right, left_part, right_part, collect_only) { - let candidate = below + exchange; - let entry = best[mask as usize].entry(part).or_insert(f64::MAX); + let candidate = below + exchange.cost; + let entry = best[mask as usize] + .entry(exchange.partitioning) + .or_insert(f64::MAX); if candidate < *entry { *entry = candidate; - choice[mask as usize].insert(part, (left, build, mode)); + choice[mask as usize].insert( + exchange.partitioning, + (left, exchange.build, exchange.mode), + ); } } } @@ -674,9 +814,11 @@ fn solve_dp(model: &CostModel) -> Option { /// The join operators the rule can flatten and rebuild. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum JoinKind { +pub enum JoinKind { + /// Rebuilt as a [`HashJoinExec`]. Hash, - /// Has no built-in projection, so the subtree gets one projection on top instead. + /// Rebuilt as a [`SortMergeJoinExec`], which has no built-in projection, so the + /// subtree gets one projection on top instead. SortMerge, } @@ -1096,7 +1238,7 @@ impl<'a, 's> Extractor<'a, 's> { struct Rebuilder<'a> { graph: &'a JoinGraph, - model: &'a CostModel<'a>, + model: &'a dyn JoinCostModel, solution: &'a Solution, /// Each relation's plan, already rewritten if it held a join subtree. relations: &'a [Arc], @@ -1566,16 +1708,19 @@ pub(crate) fn enumerate_join_order( plan: &Arc, config: &ConfigOptions, stats: &mut StatsFn, + cost_model: &dyn JoinCostModelFactory, ) -> Result>> { if let Some(graph) = extract(plan, stats)? { - if let Some(reordered) = reorder(&graph, config, stats)? { + if let Some(reordered) = reorder(&graph, config, stats, cost_model)? { return Ok(Some(reordered)); } // Rejected, so descend into the relations rather than the children: re-extracting // here would re-search costed subsets and readmit what the margin turned down. let mut rewritten: Vec = vec![]; for relation in &graph.relations { - if let Some(new) = enumerate_join_order(&relation.plan, config, stats)? { + if let Some(new) = + enumerate_join_order(&relation.plan, config, stats, cost_model)? + { rewritten.push((Arc::clone(&relation.plan), new)); } } @@ -1586,13 +1731,15 @@ pub(crate) fn enumerate_join_order( let children = plan .children() .into_iter() - .map(|child| match enumerate_join_order(child, config, stats)? { - Some(new_child) => { - changed = true; - Ok(new_child) - } - None => Ok(Arc::clone(child)), - }) + .map( + |child| match enumerate_join_order(child, config, stats, cost_model)? { + Some(new_child) => { + changed = true; + Ok(new_child) + } + None => Ok(Arc::clone(child)), + }, + ) .collect::>>()?; if changed { Ok(Some(replace_children_if_necessary( @@ -1608,13 +1755,14 @@ fn reorder( graph: &JoinGraph, config: &ConfigOptions, stats: &mut StatsFn, + cost_model: &dyn JoinCostModelFactory, ) -> Result>> { - let model = CostModel::new(graph, config); let limit = config.optimizer.join_enumeration_limit.min(MAX_RELATIONS); if graph.relations.len() > limit { return Ok(None); } - let Some(solution) = solve_dp(&model) else { + let model = cost_model.create(graph, config)?; + let Some(solution) = solve_dp(graph, model.as_ref()) else { return Ok(None); }; @@ -1628,14 +1776,14 @@ fn reorder( let mut relations = Vec::with_capacity(graph.relations.len()); for relation in &graph.relations { relations.push( - enumerate_join_order(&relation.plan, config, stats)? + enumerate_join_order(&relation.plan, config, stats, cost_model)? .unwrap_or_else(|| Arc::clone(&relation.plan)), ); } let rebuilder = Rebuilder { graph, - model: &model, + model: model.as_ref(), solution: &solution, relations: &relations, }; From a61d01b56bc864007b22528fd77e76a81754a8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Fri, 21 Aug 2026 11:07:31 +0200 Subject: [PATCH 16/16] Move the join graph into its own module The graph, and the extraction that flattens a subtree into one, are what a plugged-in cost model has to read, so they now sit in `join_enumeration::graph` rather than among the rule's own internals. The rule, the cost model and the rebuild stay in `mod.rs`; the column-list helpers only the rebuild uses stay with it. Each type keeps a single public path, so `JoinGraph` and the set vocabulary are named through `graph` now. Co-Authored-By: Claude Opus 5 --- .../physical_optimizer/join_enumeration.rs | 5 +- .../src/join_enumeration/graph.rs | 593 ++++++++++++++++++ .../mod.rs} | 577 +---------------- 3 files changed, 611 insertions(+), 564 deletions(-) create mode 100644 datafusion/physical-optimizer/src/join_enumeration/graph.rs rename datafusion/physical-optimizer/src/{join_enumeration.rs => join_enumeration/mod.rs} (70%) diff --git a/datafusion/core/tests/physical_optimizer/join_enumeration.rs b/datafusion/core/tests/physical_optimizer/join_enumeration.rs index ab0eed45f33e0..231fd6b4ccd44 100644 --- a/datafusion/core/tests/physical_optimizer/join_enumeration.rs +++ b/datafusion/core/tests/physical_optimizer/join_enumeration.rs @@ -33,9 +33,12 @@ use datafusion_expr::Operator; use datafusion_physical_expr::expressions::BinaryExpr; use datafusion_physical_expr::expressions::Column; use datafusion_physical_optimizer::PhysicalOptimizerRule; +use datafusion_physical_optimizer::join_enumeration::graph::{ + JoinGraph, RelSet, iter_rels, +}; use datafusion_physical_optimizer::join_enumeration::{ Combine, DefaultJoinCostModel, Exchange, JoinCostModel, JoinCostModelFactory, - JoinEnumeration, JoinGraph, PartSet, RelSet, iter_rels, + JoinEnumeration, PartSet, }; use datafusion_physical_optimizer::join_selection::JoinSelection; use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; diff --git a/datafusion/physical-optimizer/src/join_enumeration/graph.rs b/datafusion/physical-optimizer/src/join_enumeration/graph.rs new file mode 100644 index 0000000000000..58793ce770d09 --- /dev/null +++ b/datafusion/physical-optimizer/src/join_enumeration/graph.rs @@ -0,0 +1,593 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! The join graph: a subtree of joins as relations plus the predicates between them. +//! +//! Extraction flattens a subtree into a [`JoinGraph`], which is what +//! [`JoinCostModel`](super::JoinCostModel) estimates over and what the rebuilt tree is +//! assembled from. + +use std::sync::Arc; + +use datafusion_common::error::Result; +use datafusion_common::{JoinSide, JoinType, NullEquality, Statistics, internal_err}; +use datafusion_physical_expr::PhysicalExprRef; +use datafusion_physical_expr::expressions::Column; +use datafusion_physical_plan::joins::utils::{ + ColumnIndex, JoinFilter, max_distinct_count, +}; +use datafusion_physical_plan::joins::{ + CrossJoinExec, HashJoinExec, NestedLoopJoinExec, SortMergeJoinExec, +}; +use datafusion_physical_plan::projection::{ProjectionExec, all_alias_free_columns}; +use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties}; + +/// Hard upper bound on the relations in one join graph. The search allocates `2^n` and +/// visits `3^n`, so larger graphs keep the planner's order regardless of the limit. +pub(crate) const MAX_RELATIONS: usize = 16; + +/// Computes a plan node's statistics, shared with the rest of `JoinSelection`. +pub(crate) type StatsFn<'a> = + dyn FnMut(&dyn ExecutionPlan) -> Result> + 'a; + +/// A bitmask over relation indices. +pub type RelSet = u64; + +/// The set holding `rel` alone. +pub fn bit(rel: usize) -> RelSet { + 1u64 << rel +} + +/// The relations in `mask`, lowest index first. +pub fn iter_rels(mask: RelSet) -> impl Iterator { + std::iter::successors(Some(mask), |m| Some(m & m.wrapping_sub(1))) + .take_while(|m| *m != 0) + .map(|m| m.trailing_zeros() as usize) +} + +/// Whether `mask` holds every relation in `required`. +pub fn covers(mask: RelSet, required: RelSet) -> bool { + required & !mask == 0 +} + +/// One column of one relation, tracked instead of a plain index because reordering +/// moves columns to other positions. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ColRef { + /// Index into [`JoinGraph::relations`]. + pub rel: usize, + /// Index into that relation's schema. + pub col: usize, +} + +/// What a relation contributes to the join. +#[derive(Debug)] +pub enum Role { + /// An ordinary input, contributing its columns. + Output, + /// The quantified side of a semi or anti join, which filters instead of + /// contributing columns. + Reducer(Reducer), +} + +/// The quantified side of a semi or anti join, as what it does to the side it filters. +#[derive(Debug)] +pub struct Reducer { + /// `true` for an anti join, which keeps the rows that do *not* match. + pub anti: bool, + /// Keys, as `(column of the filtered side, column index here)`. + pub keys: Vec<(ColRef, usize)>, + /// Relations the keys reference; this reducer applies only to a set covering them. + pub required: RelSet, +} + +/// One leaf of the join graph: a subplan the enumerator does not look inside. +#[derive(Debug)] +pub struct Relation { + /// The subplan this relation stands for. + pub plan: Arc, + /// Estimated row count, clamped to at least 1. + pub rows: f64, + /// Estimated bytes per row, when the input reports a size. + pub width: Option, + /// Per-column distinct value estimate, clamped to `[1, rows]`. + pub ndv: Vec, + /// What it contributes to the join. + pub role: Role, +} + +/// An equi-join predicate `left = right` between two distinct relations. +#[derive(Clone, Copy, Debug)] +pub struct Edge { + /// The column on one side. + pub left: ColRef, + /// The column it is compared against. + pub right: ColRef, +} + +/// A non-equi join predicate, moved along with its column references rewritten. +#[derive(Debug)] +pub struct Filter { + /// The predicate itself. + pub filter: JoinFilter, + /// The column each entry of the filter's intermediate schema comes from. + pub columns: Vec, + /// The relations those columns belong to. + pub required: RelSet, +} + +/// A connected set of joins as relations plus the predicates between them. +#[derive(Debug)] +pub struct JoinGraph { + /// The leaves, addressed by index throughout. + pub relations: Vec, + /// The equi-join predicates between them. + pub edges: Vec, + /// The non-equi predicates between them. + pub filters: Vec, + /// Columns the original subtree emitted; the rebuilt one reproduces this exactly. + pub output: Vec, + /// Null handling shared by the subtree's joins. A join that differs becomes a + /// relation instead. + pub null_equality: Option, + /// The original tree's internal nodes as `(node, one child)`, children first, so + /// the planner's shape can be scored under the same formula as the alternatives. + pub original_nodes: Vec<(RelSet, RelSet)>, + /// The relations that are reducers rather than ordinary inputs. + pub reducers: RelSet, + /// Which join operator the subtree used, and which the rebuild emits. + pub kind: Option, +} + +impl JoinGraph { + /// Distinct values estimated for one column. + pub fn ndv(&self, col: ColRef) -> f64 { + self.relations[col.rel].ndv[col.col] + } + + /// Every relation in the graph. + pub fn all(&self) -> RelSet { + (0..self.relations.len()).fold(0, |mask, rel| mask | bit(rel)) + } + + /// How `rel` reduces the side it filters, if it is a reducer at all. + pub fn reducer(&self, rel: usize) -> Option<&Reducer> { + match &self.relations[rel].role { + Role::Reducer(reducer) => Some(reducer), + Role::Output => None, + } + } + + /// The operator the rebuild emits, defaulting to a hash join. + pub fn kind(&self) -> JoinKind { + self.kind.unwrap_or(JoinKind::Hash) + } + + /// The null handling the rebuild emits, defaulting to `NullEqualsNothing`. + pub fn null_equality(&self) -> NullEquality { + self.null_equality + .unwrap_or(NullEquality::NullEqualsNothing) + } +} + +/// The join operators the rule can flatten and rebuild. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum JoinKind { + /// Rebuilt as a [`HashJoinExec`]. + Hash, + /// Rebuilt as a [`SortMergeJoinExec`], which has no built-in projection, so the + /// subtree gets one projection on top instead. + SortMerge, +} + +/// One join, seen the same way whichever operator implements it. `kind` and +/// `null_equality` are `None` for operators without equi-join keys. +struct JoinView<'a> { + kind: Option, + role: JoinRole, + left: &'a Arc, + right: &'a Arc, + on: &'a [(PhysicalExprRef, PhysicalExprRef)], + filter: Option<&'a JoinFilter>, + null_equality: Option, + projection: Option<&'a [usize]>, +} + +fn join_view(plan: &Arc) -> Option> { + if let Some(join) = plan.downcast_ref::() { + if join.null_aware || join.fetch().is_some() || join.on().is_empty() { + return None; + } + return Some(JoinView { + kind: Some(JoinKind::Hash), + role: join_role(join.join_type(), join.filter().is_some())?, + left: join.left(), + right: join.right(), + on: join.on(), + filter: join.filter(), + null_equality: Some(join.null_equality), + projection: join.projection.as_deref(), + }); + } + if let Some(join) = plan.downcast_ref::() { + if join.on().is_empty() { + return None; + } + return Some(JoinView { + kind: Some(JoinKind::SortMerge), + role: join_role(&join.join_type(), join.filter().is_some())?, + left: join.left(), + right: join.right(), + on: join.on(), + filter: join.filter().as_ref(), + null_equality: Some(join.null_equality()), + projection: None, + }); + } + if let Some(join) = plan.downcast_ref::() { + // A semi or anti variant has no keys to model as a reducer. + if *join.join_type() != JoinType::Inner { + return None; + } + return Some(JoinView { + kind: None, + role: JoinRole::Inner, + left: join.left(), + right: join.right(), + on: &[], + filter: join.filter(), + null_equality: None, + projection: join.projection().as_deref(), + }); + } + let join = plan.downcast_ref::()?; + Some(JoinView { + kind: None, + role: JoinRole::Inner, + left: join.left(), + right: join.right(), + on: &[], + filter: None, + null_equality: None, + projection: None, + }) +} + +/// How a join takes part in enumeration, if at all. +#[derive(Clone, Copy, Debug)] +enum JoinRole { + Inner, + /// A semi or anti join; `output` names the side whose rows survive. + Reducing { + anti: bool, + output: JoinSide, + }, +} + +/// Classifies a join. Outer and mark joins are excluded because they do not just filter +/// their inputs, and semi and anti joins with a filter because it is part of their test. +fn join_role(join_type: &JoinType, has_filter: bool) -> Option { + let role = match join_type { + JoinType::Inner => JoinRole::Inner, + JoinType::LeftSemi => JoinRole::Reducing { + anti: false, + output: JoinSide::Left, + }, + JoinType::RightSemi => JoinRole::Reducing { + anti: false, + output: JoinSide::Right, + }, + JoinType::LeftAnti => JoinRole::Reducing { + anti: true, + output: JoinSide::Left, + }, + JoinType::RightAnti => JoinRole::Reducing { + anti: true, + output: JoinSide::Right, + }, + _ => return None, + }; + if has_filter && !matches!(role, JoinRole::Inner) { + return None; + } + Some(role) +} + +/// Whether a graph-wide choice and one join's are compatible. +fn agree(graph: Option, join: Option) -> bool { + match (graph, join) { + (Some(graph), Some(join)) => graph == join, + _ => true, + } +} + +fn as_column(expr: &PhysicalExprRef) -> Option { + expr.downcast_ref::().map(|col| col.index()) +} + +/// Extracts the maximal reorderable subtree at `plan`. `None` covers every bail-out: an +/// unmodelled join feature, a non-column key, missing row counts, too few or many inputs. +pub(crate) fn extract( + plan: &Arc, + stats: &mut StatsFn, +) -> Result> { + // Start at a join, or at the pruning projection usually above one, so its column list + // becomes the top join's projection instead of a `ProjectionExec` above a wider join. + let is_root = join_view(plan).is_some() + || plan + .downcast_ref::() + .is_some_and(|projection| all_alias_free_columns(projection.expr())); + if !is_root { + return Ok(None); + } + Extractor::new(stats).extract(plan) +} + +struct Extractor<'a, 's> { + graph: JoinGraph, + stats: &'s mut StatsFn<'a>, +} + +impl<'a, 's> Extractor<'a, 's> { + fn new(stats: &'s mut StatsFn<'a>) -> Self { + Self { + graph: JoinGraph { + relations: vec![], + edges: vec![], + filters: vec![], + output: vec![], + null_equality: None, + original_nodes: vec![], + reducers: 0, + kind: None, + }, + stats, + } + } + + fn extract(mut self, plan: &Arc) -> Result> { + let Some((output, _)) = self.visit(plan)? else { + return Ok(None); + }; + let mut graph = self.graph; + graph.output = output; + + if graph.relations.len() < 3 { + return Ok(None); + } + // A filter over one relation has no join to sit at; the node would be a leaf. + if graph + .filters + .iter() + .any(|filter| filter.required.count_ones() < 2) + { + return Ok(None); + } + Ok(Some(graph)) + } + + fn visit( + &mut self, + plan: &Arc, + ) -> Result, RelSet)>> { + if let Some(view) = join_view(plan) + && agree(self.graph.null_equality, view.null_equality) + && agree(self.graph.kind, view.kind) + { + self.graph.null_equality = self.graph.null_equality.or(view.null_equality); + self.graph.kind = self.graph.kind.or(view.kind); + let visited = match view.role { + JoinRole::Inner => self.visit_inner(&view)?, + JoinRole::Reducing { anti, output } => { + self.visit_reducing(&view, anti, output)? + } + }; + let Some((columns, mask)) = visited else { + return Ok(None); + }; + + // For a semi or anti join the projection selects from the output side alone. + let columns = match view.projection { + Some(projection) => projection.iter().map(|idx| columns[*idx]).collect(), + None => columns, + }; + Ok(Some((columns, mask))) + } else if let Some(projection) = plan.downcast_ref::() + && all_alias_free_columns(projection.expr()) + { + // Looking through pruning projections lets the enumerator see a whole chain, + // since `ProjectionPushdown` has not folded them into the joins yet. + let Some((child, mask)) = self.visit(projection.input())? else { + return Ok(None); + }; + let columns = projection + .expr() + .iter() + .map(|proj| { + proj.expr + .downcast_ref::() + .map(|col| child[col.index()]) + }) + .collect::>>(); + Ok(columns.map(|columns| (columns, mask))) + } else { + let Some(rel) = self.push_relation(plan, Role::Output)? else { + return Ok(None); + }; + Ok(Some(( + (0..plan.schema().fields().len()) + .map(|col| ColRef { rel, col }) + .collect(), + bit(rel), + ))) + } + } + + /// Flattens an inner join: both sides join the graph, predicates become edges + /// and filters. + fn visit_inner(&mut self, view: &JoinView) -> Result, RelSet)>> { + let Some((left, left_mask)) = self.visit(view.left)? else { + return Ok(None); + }; + let Some((right, right_mask)) = self.visit(view.right)? else { + return Ok(None); + }; + + for (left_key, right_key) in view.on { + let (Some(left_key), Some(right_key)) = + (as_column(left_key), as_column(right_key)) + else { + // A key like `cast(a) = b` would need re-deriving against a different schema. + return Ok(None); + }; + let edge = Edge { + left: left[left_key], + right: right[right_key], + }; + // Duplicates would be double counted by the cost model. + if !self + .graph + .edges + .iter() + .any(|e| (e.left, e.right) == (edge.left, edge.right)) + { + self.graph.edges.push(edge); + } + } + + if let Some(filter) = view.filter { + let columns = filter + .column_indices() + .iter() + .map(|ColumnIndex { index, side }| match side { + JoinSide::Left => Some(left[*index]), + JoinSide::Right => Some(right[*index]), + JoinSide::None => None, + }) + .collect::>>(); + let Some(columns) = columns else { + return Ok(None); + }; + let required = columns.iter().fold(0, |mask, col| mask | bit(col.rel)); + self.graph.filters.push(Filter { + filter: filter.clone(), + columns, + required, + }); + } + + let mut columns = left; + columns.extend(right); + self.graph + .original_nodes + .push((left_mask | right_mask, left_mask)); + Ok(Some((columns, left_mask | right_mask))) + } + + /// Flattens a semi or anti join: its output side joins the graph, its quantified + /// side becomes a reducer. + fn visit_reducing( + &mut self, + view: &JoinView, + anti: bool, + output: JoinSide, + ) -> Result, RelSet)>> { + let (output_plan, reducer_plan) = match output { + JoinSide::Left => (view.left, view.right), + JoinSide::Right => (view.right, view.left), + JoinSide::None => return internal_err!("semi join with no output side"), + }; + + let Some((columns, mask)) = self.visit(output_plan)? else { + return Ok(None); + }; + + // Keys resolve against the output side, so they precede the reducer relation. + let mut keys = Vec::with_capacity(view.on.len()); + let mut required = 0; + for (left_key, right_key) in view.on { + let (output_key, reducer_key) = match output { + JoinSide::Left => (left_key, right_key), + _ => (right_key, left_key), + }; + let (Some(output_key), Some(reducer_key)) = + (as_column(output_key), as_column(reducer_key)) + else { + return Ok(None); + }; + let column = columns[output_key]; + required |= bit(column.rel); + keys.push((column, reducer_key)); + } + + let role = Role::Reducer(Reducer { + anti, + keys, + required, + }); + let Some(rel) = self.push_relation(reducer_plan, role)? else { + return Ok(None); + }; + self.graph.original_nodes.push((mask | bit(rel), bit(rel))); + Ok(Some((columns, mask | bit(rel)))) + } + + fn push_relation( + &mut self, + plan: &Arc, + role: Role, + ) -> Result> { + if plan.boundedness().is_unbounded() { + // Reordering could break the pipeline properties the other subrules establish. + return Ok(None); + } + if self.graph.relations.len() >= MAX_RELATIONS { + return Ok(None); + } + let statistics = (self.stats)(plan.as_ref())?; + let Some(rows) = statistics.num_rows.get_value().copied() else { + return Ok(None); + }; + let rows = (rows as f64).max(1.0); + let ndv = statistics + .column_statistics + .iter() + .map(|col| { + max_distinct_count(&statistics.num_rows, col) + .get_value() + .map(|ndv| (*ndv as f64).clamp(1.0, rows)) + .unwrap_or(rows) + }) + .collect(); + + let rel = self.graph.relations.len(); + if matches!(role, Role::Reducer(_)) { + self.graph.reducers |= bit(rel); + } + self.graph.relations.push(Relation { + plan: Arc::clone(plan), + rows, + width: statistics + .total_byte_size + .get_value() + .map(|bytes| *bytes as f64 / rows), + ndv, + role, + }); + Ok(Some(rel)) + } +} diff --git a/datafusion/physical-optimizer/src/join_enumeration.rs b/datafusion/physical-optimizer/src/join_enumeration/mod.rs similarity index 70% rename from datafusion/physical-optimizer/src/join_enumeration.rs rename to datafusion/physical-optimizer/src/join_enumeration/mod.rs index 3af336ef983b2..c670994e8b66f 100644 --- a/datafusion/physical-optimizer/src/join_enumeration.rs +++ b/datafusion/physical-optimizer/src/join_enumeration/mod.rs @@ -24,10 +24,14 @@ //! The estimates come from a [`JoinCostModel`], so a different one can be plugged in //! with [`JoinEnumeration::with_cost_model`]. //! +//! The graph itself, and how a subtree of joins is flattened into one, is in [`graph`]. +//! //! Reordering is sound because a tree of inner joins equals the cross product of its //! relations filtered by all its predicates. Semi and anti joins take part as reducers: //! they filter their output side rather than contributing columns. +pub mod graph; + use std::collections::HashMap; use std::sync::Arc; @@ -39,23 +43,26 @@ use arrow::datatypes::{FieldRef, Schema}; use datafusion_common::config::ConfigOptions; use datafusion_common::error::Result; use datafusion_common::tree_node::{Transformed, TransformedResult, TreeNode}; -use datafusion_common::{JoinSide, JoinType, NullEquality, Statistics, internal_err}; +use datafusion_common::{JoinSide, JoinType, Statistics, internal_err}; use datafusion_expr_common::operator::Operator; use datafusion_physical_expr::PhysicalExprRef; use datafusion_physical_expr::expressions::{BinaryExpr, Column}; use datafusion_physical_expr::projection::ProjectionExpr; +use datafusion_physical_plan::ExecutionPlan; use datafusion_physical_plan::execution_plan::replace_children_if_necessary; -use datafusion_physical_plan::joins::utils::{ - ColumnIndex, JoinFilter, max_distinct_count, -}; +use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; use datafusion_physical_plan::joins::{ - CrossJoinExec, HashJoinExec, HashJoinExecBuilder, NestedLoopJoinExec, PartitionMode, + CrossJoinExec, HashJoinExecBuilder, NestedLoopJoinExec, PartitionMode, SortMergeJoinExec, }; use datafusion_physical_plan::operator_statistics::StatisticsRegistry; -use datafusion_physical_plan::projection::{ProjectionExec, all_alias_free_columns}; +use datafusion_physical_plan::projection::ProjectionExec; use datafusion_physical_plan::statistics::{StatisticsArgs, StatisticsContext}; -use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties}; + +use graph::{ + ColRef, Edge, Filter, JoinGraph, JoinKind, MAX_RELATIONS, RelSet, StatsFn, bit, + covers, extract, iter_rels, +}; /// Chooses the shape of the join tree, before [`JoinSelection`] decides how each /// join runs. @@ -139,158 +146,10 @@ impl PhysicalOptimizerRule for JoinEnumeration { } } -/// Hard upper bound on the relations in one join graph. The search allocates `2^n` and -/// visits `3^n`, so larger graphs keep the planner's order regardless of the limit. -const MAX_RELATIONS: usize = 16; - -/// Computes a plan node's statistics, shared with the rest of `JoinSelection`. -pub(crate) type StatsFn<'a> = - dyn FnMut(&dyn ExecutionPlan) -> Result> + 'a; - -/// A bitmask over relation indices. -pub type RelSet = u64; - -/// The set holding `rel` alone. -pub fn bit(rel: usize) -> RelSet { - 1u64 << rel -} - -/// The relations in `mask`, lowest index first. -pub fn iter_rels(mask: RelSet) -> impl Iterator { - std::iter::successors(Some(mask), |m| Some(m & m.wrapping_sub(1))) - .take_while(|m| *m != 0) - .map(|m| m.trailing_zeros() as usize) -} - /// A hash partitioning, as the set of key classes it is partitioned on. Zero means /// not hash partitioned, which is where every scan starts. pub type PartSet = u32; -/// Whether `mask` holds every relation in `required`. -pub fn covers(mask: RelSet, required: RelSet) -> bool { - required & !mask == 0 -} - -/// One column of one relation, tracked instead of a plain index because reordering -/// moves columns to other positions. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub struct ColRef { - /// Index into [`JoinGraph::relations`]. - pub rel: usize, - /// Index into that relation's schema. - pub col: usize, -} - -/// What a relation contributes to the join. -#[derive(Debug)] -pub enum Role { - /// An ordinary input, contributing its columns. - Output, - /// The quantified side of a semi or anti join, which filters instead of - /// contributing columns. - Reducer(Reducer), -} - -/// The quantified side of a semi or anti join, as what it does to the side it filters. -#[derive(Debug)] -pub struct Reducer { - /// `true` for an anti join, which keeps the rows that do *not* match. - pub anti: bool, - /// Keys, as `(column of the filtered side, column index here)`. - pub keys: Vec<(ColRef, usize)>, - /// Relations the keys reference; this reducer applies only to a set covering them. - pub required: RelSet, -} - -/// One leaf of the join graph: a subplan the enumerator does not look inside. -#[derive(Debug)] -pub struct Relation { - /// The subplan this relation stands for. - pub plan: Arc, - /// Estimated row count, clamped to at least 1. - pub rows: f64, - /// Estimated bytes per row, when the input reports a size. - pub width: Option, - /// Per-column distinct value estimate, clamped to `[1, rows]`. - pub ndv: Vec, - /// What it contributes to the join. - pub role: Role, -} - -/// An equi-join predicate `left = right` between two distinct relations. -#[derive(Clone, Copy, Debug)] -pub struct Edge { - /// The column on one side. - pub left: ColRef, - /// The column it is compared against. - pub right: ColRef, -} - -/// A non-equi join predicate, moved along with its column references rewritten. -#[derive(Debug)] -pub struct Filter { - /// The predicate itself. - pub filter: JoinFilter, - /// The column each entry of the filter's intermediate schema comes from. - pub columns: Vec, - /// The relations those columns belong to. - pub required: RelSet, -} - -/// A connected set of joins as relations plus the predicates between them. -#[derive(Debug)] -pub struct JoinGraph { - /// The leaves, addressed by index throughout. - pub relations: Vec, - /// The equi-join predicates between them. - pub edges: Vec, - /// The non-equi predicates between them. - pub filters: Vec, - /// Columns the original subtree emitted; the rebuilt one reproduces this exactly. - pub output: Vec, - /// Null handling shared by the subtree's joins. A join that differs becomes a - /// relation instead. - pub null_equality: Option, - /// The original tree's internal nodes as `(node, one child)`, children first, so - /// the planner's shape can be scored under the same formula as the alternatives. - pub original_nodes: Vec<(RelSet, RelSet)>, - /// The relations that are reducers rather than ordinary inputs. - pub reducers: RelSet, - /// Which join operator the subtree used, and which the rebuild emits. - pub kind: Option, -} - -impl JoinGraph { - /// Distinct values estimated for one column. - pub fn ndv(&self, col: ColRef) -> f64 { - self.relations[col.rel].ndv[col.col] - } - - /// Every relation in the graph. - pub fn all(&self) -> RelSet { - (0..self.relations.len()).fold(0, |mask, rel| mask | bit(rel)) - } - - /// How `rel` reduces the side it filters, if it is a reducer at all. - pub fn reducer(&self, rel: usize) -> Option<&Reducer> { - match &self.relations[rel].role { - Role::Reducer(reducer) => Some(reducer), - Role::Output => None, - } - } - - /// The operator the rebuild emits, defaulting to a hash join. - pub fn kind(&self) -> JoinKind { - self.kind.unwrap_or(JoinKind::Hash) - } - - /// The null handling the rebuild emits, defaulting to `NullEqualsNothing`. - pub fn null_equality(&self) -> NullEquality { - self.null_equality - .unwrap_or(NullEquality::NullEqualsNothing) - } -} - /// A valid way of combining two relation sets. #[derive(Clone, Copy, Debug)] pub enum Combine { @@ -812,141 +671,6 @@ fn solve_dp(graph: &JoinGraph, model: &dyn JoinCostModel) -> Option { Some(Solution { nodes, cost }) } -/// The join operators the rule can flatten and rebuild. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum JoinKind { - /// Rebuilt as a [`HashJoinExec`]. - Hash, - /// Rebuilt as a [`SortMergeJoinExec`], which has no built-in projection, so the - /// subtree gets one projection on top instead. - SortMerge, -} - -/// One join, seen the same way whichever operator implements it. `kind` and -/// `null_equality` are `None` for operators without equi-join keys. -struct JoinView<'a> { - kind: Option, - role: JoinRole, - left: &'a Arc, - right: &'a Arc, - on: &'a [(PhysicalExprRef, PhysicalExprRef)], - filter: Option<&'a JoinFilter>, - null_equality: Option, - projection: Option<&'a [usize]>, -} - -fn join_view(plan: &Arc) -> Option> { - if let Some(join) = plan.downcast_ref::() { - if join.null_aware || join.fetch().is_some() || join.on().is_empty() { - return None; - } - return Some(JoinView { - kind: Some(JoinKind::Hash), - role: join_role(join.join_type(), join.filter().is_some())?, - left: join.left(), - right: join.right(), - on: join.on(), - filter: join.filter(), - null_equality: Some(join.null_equality), - projection: join.projection.as_deref(), - }); - } - if let Some(join) = plan.downcast_ref::() { - if join.on().is_empty() { - return None; - } - return Some(JoinView { - kind: Some(JoinKind::SortMerge), - role: join_role(&join.join_type(), join.filter().is_some())?, - left: join.left(), - right: join.right(), - on: join.on(), - filter: join.filter().as_ref(), - null_equality: Some(join.null_equality()), - projection: None, - }); - } - if let Some(join) = plan.downcast_ref::() { - // A semi or anti variant has no keys to model as a reducer. - if *join.join_type() != JoinType::Inner { - return None; - } - return Some(JoinView { - kind: None, - role: JoinRole::Inner, - left: join.left(), - right: join.right(), - on: &[], - filter: join.filter(), - null_equality: None, - projection: join.projection().as_deref(), - }); - } - let join = plan.downcast_ref::()?; - Some(JoinView { - kind: None, - role: JoinRole::Inner, - left: join.left(), - right: join.right(), - on: &[], - filter: None, - null_equality: None, - projection: None, - }) -} - -/// How a join takes part in enumeration, if at all. -#[derive(Clone, Copy, Debug)] -enum JoinRole { - Inner, - /// A semi or anti join; `output` names the side whose rows survive. - Reducing { - anti: bool, - output: JoinSide, - }, -} - -/// Classifies a join. Outer and mark joins are excluded because they do not just filter -/// their inputs, and semi and anti joins with a filter because it is part of their test. -fn join_role(join_type: &JoinType, has_filter: bool) -> Option { - let role = match join_type { - JoinType::Inner => JoinRole::Inner, - JoinType::LeftSemi => JoinRole::Reducing { - anti: false, - output: JoinSide::Left, - }, - JoinType::RightSemi => JoinRole::Reducing { - anti: false, - output: JoinSide::Right, - }, - JoinType::LeftAnti => JoinRole::Reducing { - anti: true, - output: JoinSide::Left, - }, - JoinType::RightAnti => JoinRole::Reducing { - anti: true, - output: JoinSide::Right, - }, - _ => return None, - }; - if has_filter && !matches!(role, JoinRole::Inner) { - return None; - } - Some(role) -} - -/// Whether a graph-wide choice and one join's are compatible. -fn agree(graph: Option, join: Option) -> bool { - match (graph, join) { - (Some(graph), Some(join)) => graph == join, - _ => true, - } -} - -fn as_column(expr: &PhysicalExprRef) -> Option { - expr.downcast_ref::().map(|col| col.index()) -} - fn position(columns: &[ColRef], col: ColRef) -> Option { columns.iter().position(|candidate| *candidate == col) } @@ -963,279 +687,6 @@ fn extend_required(columns: &mut Vec, wanted: &[ColRef], side: RelSet) { } } -/// Extracts the maximal reorderable subtree at `plan`. `None` covers every bail-out: an -/// unmodelled join feature, a non-column key, missing row counts, too few or many inputs. -fn extract( - plan: &Arc, - stats: &mut StatsFn, -) -> Result> { - // Start at a join, or at the pruning projection usually above one, so its column list - // becomes the top join's projection instead of a `ProjectionExec` above a wider join. - let is_root = join_view(plan).is_some() - || plan - .downcast_ref::() - .is_some_and(|projection| all_alias_free_columns(projection.expr())); - if !is_root { - return Ok(None); - } - Extractor::new(stats).extract(plan) -} - -struct Extractor<'a, 's> { - graph: JoinGraph, - stats: &'s mut StatsFn<'a>, -} - -impl<'a, 's> Extractor<'a, 's> { - fn new(stats: &'s mut StatsFn<'a>) -> Self { - Self { - graph: JoinGraph { - relations: vec![], - edges: vec![], - filters: vec![], - output: vec![], - null_equality: None, - original_nodes: vec![], - reducers: 0, - kind: None, - }, - stats, - } - } - - fn extract(mut self, plan: &Arc) -> Result> { - let Some((output, _)) = self.visit(plan)? else { - return Ok(None); - }; - let mut graph = self.graph; - graph.output = output; - - if graph.relations.len() < 3 { - return Ok(None); - } - // A filter over one relation has no join to sit at; the node would be a leaf. - if graph - .filters - .iter() - .any(|filter| filter.required.count_ones() < 2) - { - return Ok(None); - } - Ok(Some(graph)) - } - - fn visit( - &mut self, - plan: &Arc, - ) -> Result, RelSet)>> { - if let Some(view) = join_view(plan) - && agree(self.graph.null_equality, view.null_equality) - && agree(self.graph.kind, view.kind) - { - self.graph.null_equality = self.graph.null_equality.or(view.null_equality); - self.graph.kind = self.graph.kind.or(view.kind); - let visited = match view.role { - JoinRole::Inner => self.visit_inner(&view)?, - JoinRole::Reducing { anti, output } => { - self.visit_reducing(&view, anti, output)? - } - }; - let Some((columns, mask)) = visited else { - return Ok(None); - }; - - // For a semi or anti join the projection selects from the output side alone. - let columns = match view.projection { - Some(projection) => projection.iter().map(|idx| columns[*idx]).collect(), - None => columns, - }; - Ok(Some((columns, mask))) - } else if let Some(projection) = plan.downcast_ref::() - && all_alias_free_columns(projection.expr()) - { - // Looking through pruning projections lets the enumerator see a whole chain, - // since `ProjectionPushdown` has not folded them into the joins yet. - let Some((child, mask)) = self.visit(projection.input())? else { - return Ok(None); - }; - let columns = projection - .expr() - .iter() - .map(|proj| { - proj.expr - .downcast_ref::() - .map(|col| child[col.index()]) - }) - .collect::>>(); - Ok(columns.map(|columns| (columns, mask))) - } else { - let Some(rel) = self.push_relation(plan, Role::Output)? else { - return Ok(None); - }; - Ok(Some(( - (0..plan.schema().fields().len()) - .map(|col| ColRef { rel, col }) - .collect(), - bit(rel), - ))) - } - } - - /// Flattens an inner join: both sides join the graph, predicates become edges - /// and filters. - fn visit_inner(&mut self, view: &JoinView) -> Result, RelSet)>> { - let Some((left, left_mask)) = self.visit(view.left)? else { - return Ok(None); - }; - let Some((right, right_mask)) = self.visit(view.right)? else { - return Ok(None); - }; - - for (left_key, right_key) in view.on { - let (Some(left_key), Some(right_key)) = - (as_column(left_key), as_column(right_key)) - else { - // A key like `cast(a) = b` would need re-deriving against a different schema. - return Ok(None); - }; - let edge = Edge { - left: left[left_key], - right: right[right_key], - }; - // Duplicates would be double counted by the cost model. - if !self - .graph - .edges - .iter() - .any(|e| (e.left, e.right) == (edge.left, edge.right)) - { - self.graph.edges.push(edge); - } - } - - if let Some(filter) = view.filter { - let columns = filter - .column_indices() - .iter() - .map(|ColumnIndex { index, side }| match side { - JoinSide::Left => Some(left[*index]), - JoinSide::Right => Some(right[*index]), - JoinSide::None => None, - }) - .collect::>>(); - let Some(columns) = columns else { - return Ok(None); - }; - let required = columns.iter().fold(0, |mask, col| mask | bit(col.rel)); - self.graph.filters.push(Filter { - filter: filter.clone(), - columns, - required, - }); - } - - let mut columns = left; - columns.extend(right); - self.graph - .original_nodes - .push((left_mask | right_mask, left_mask)); - Ok(Some((columns, left_mask | right_mask))) - } - - /// Flattens a semi or anti join: its output side joins the graph, its quantified - /// side becomes a reducer. - fn visit_reducing( - &mut self, - view: &JoinView, - anti: bool, - output: JoinSide, - ) -> Result, RelSet)>> { - let (output_plan, reducer_plan) = match output { - JoinSide::Left => (view.left, view.right), - JoinSide::Right => (view.right, view.left), - JoinSide::None => return internal_err!("semi join with no output side"), - }; - - let Some((columns, mask)) = self.visit(output_plan)? else { - return Ok(None); - }; - - // Keys resolve against the output side, so they precede the reducer relation. - let mut keys = Vec::with_capacity(view.on.len()); - let mut required = 0; - for (left_key, right_key) in view.on { - let (output_key, reducer_key) = match output { - JoinSide::Left => (left_key, right_key), - _ => (right_key, left_key), - }; - let (Some(output_key), Some(reducer_key)) = - (as_column(output_key), as_column(reducer_key)) - else { - return Ok(None); - }; - let column = columns[output_key]; - required |= bit(column.rel); - keys.push((column, reducer_key)); - } - - let role = Role::Reducer(Reducer { - anti, - keys, - required, - }); - let Some(rel) = self.push_relation(reducer_plan, role)? else { - return Ok(None); - }; - self.graph.original_nodes.push((mask | bit(rel), bit(rel))); - Ok(Some((columns, mask | bit(rel)))) - } - - fn push_relation( - &mut self, - plan: &Arc, - role: Role, - ) -> Result> { - if plan.boundedness().is_unbounded() { - // Reordering could break the pipeline properties the other subrules establish. - return Ok(None); - } - if self.graph.relations.len() >= MAX_RELATIONS { - return Ok(None); - } - let statistics = (self.stats)(plan.as_ref())?; - let Some(rows) = statistics.num_rows.get_value().copied() else { - return Ok(None); - }; - let rows = (rows as f64).max(1.0); - let ndv = statistics - .column_statistics - .iter() - .map(|col| { - max_distinct_count(&statistics.num_rows, col) - .get_value() - .map(|ndv| (*ndv as f64).clamp(1.0, rows)) - .unwrap_or(rows) - }) - .collect(); - - let rel = self.graph.relations.len(); - if matches!(role, Role::Reducer(_)) { - self.graph.reducers |= bit(rel); - } - self.graph.relations.push(Relation { - plan: Arc::clone(plan), - rows, - width: statistics - .total_byte_size - .get_value() - .map(|bytes| *bytes as f64 / rows), - ndv, - role, - }); - Ok(Some(rel)) - } -} - struct Rebuilder<'a> { graph: &'a JoinGraph, model: &'a dyn JoinCostModel,