Skip to content

Commit a38bb10

Browse files
authored
feat: make StatisticsRegistry the default operator-statistics path (#23651)
## Which issue does this PR close? - Closes #21443. - Part of #8227. ## Rationale for this change #23051 unified statistics into one `StatisticsContext` walk. This folds the previously opt-in `StatisticsRegistry` (#21483) into that walk as the default, so providers are consulted inline and `use_statistics_registry` is no longer needed. Consumed today by join ordering and EXPLAIN; more CBO rules are follow-up. ## What changes are included in this PR? - Consult the provider chain in the `StatisticsContext` walk (first `Computed` wins, else `statistics_from_inputs`); used by `join_selection`. - Deprecate `use_statistics_registry` (ignored, warns) and `DefaultStatisticsProvider`; add partition-aware `StatisticsProvider::compute_statistics_with_args`. - Propagate provider `Extensions` through the walk via a side cache (no cost without providers); add `StatisticsContext::compute_extended`, deprecate `StatisticsRegistry::compute`/`compute_base`. - EXPLAIN and EXPLAIN ANALYZE `show_statistics` now reflect the session registry. - Add the `join_reorder` example. ## Are these changes tested? Unit tests, `statistics_registry.slt`, the example, the full extended suite, and `compute_statistics` at benchmark parity. ## Are there any user-facing changes? - `use_statistics_registry` deprecated and ignored (default behavior unchanged). - `StatisticsRegistry::compute`/`compute_base` deprecated (55.0.0) for `StatisticsContext`; `compute_extended` added; `DefaultStatisticsProvider` deprecated. Nothing removed. See the 55.0.0 upgrade guide. Disclaimer: I used AI to assist in the code generation, I have manually reviewed the output and it matches my intention and understanding.
1 parent 9ad1217 commit a38bb10

17 files changed

Lines changed: 1603 additions & 439 deletions

File tree

‎benchmarks/src/statistics.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use datafusion::error::{DataFusionError, Result};
2828
use datafusion::logical_expr::LogicalPlan;
2929
use datafusion::physical_plan::metrics::MetricValue;
3030
use datafusion::physical_plan::operator_statistics::StatisticsRegistry;
31+
use datafusion::physical_plan::statistics::{StatisticsArgs, StatisticsContext};
3132
use datafusion::physical_plan::{ExecutionPlan, collect};
3233
use datafusion::prelude::{ParquetReadOptions, SessionConfig, SessionContext};
3334
use datafusion::sql::parser::{DFParserBuilder, Statement};
@@ -246,7 +247,8 @@ fn capture_statistics_inner(
246247
path: &str,
247248
result: &mut Vec<CapturedStatistics>,
248249
) -> Result<()> {
249-
let statistics = statistics_context.compute_base(plan)?;
250+
let statistics = StatisticsContext::new_with_registry(statistics_context.clone())
251+
.compute(plan, &StatisticsArgs::new())?;
250252
result.push(CapturedStatistics {
251253
path: path.to_string(),
252254
name: plan.name().to_string(),

‎datafusion-examples/README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,16 @@ cargo run --example dataframe -- dataframe
214214
| frontend | [`sql_ops/frontend.rs`](examples/sql_ops/frontend.rs) | Build LogicalPlans from SQL |
215215
| query | [`sql_ops/query.rs`](examples/sql_ops/query.rs) | Query data using SQL |
216216

217+
## Statistics Examples
218+
219+
### Group: `statistics`
220+
221+
#### Category: Single Process
222+
223+
| Subcommand | File Path | Description |
224+
| ------------ | ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
225+
| join_reorder | [`statistics/join_reorder.rs`](examples/statistics/join_reorder.rs) | Supply and refine column statistics via a provider to flip a join order |
226+
217227
## UDF Examples
218228

219229
### Group: `udf`
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Plug refined cardinality estimation into the optimizer via the `StatisticsRegistry`.
19+
//!
20+
//! DataFusion's built-in statistics are intentionally simple defaults; the registry
21+
//! is the seam for plugging in more refined estimation (formulas or heuristics
22+
//! suited to your system or data) without changing the core.
23+
//!
24+
//! `(SELECT user_id FROM events WHERE amount < 50 GROUP BY user_id) JOIN dims`: a
25+
//! provider supplies the column stats a catalog knows (`amount` range, `user_id`
26+
//! distinct count); the built-in `FilterStatisticsProvider` then refines the
27+
//! post-filter distinct count with the survival formula
28+
//! `NDV * (1 - (1 - selectivity)^(rows / NDV))` (Yao/Cardenas) to ~32, below `dims`
29+
//! (48), flipping the join build side. Core's simpler `min(NDV, rows)` cap would
30+
//! give 50 (> 48) and keep the other order; the refinement is the point. The
31+
//! ground-truth query prints the true surviving distinct count (below 48),
32+
//! confirming the flip.
33+
34+
use std::sync::Arc;
35+
36+
use datafusion::arrow::array::Int32Array;
37+
use datafusion::arrow::datatypes::{DataType, Field, Schema};
38+
use datafusion::arrow::record_batch::RecordBatch;
39+
use datafusion::arrow::util::pretty::pretty_format_batches;
40+
use datafusion::catalog::MemTable;
41+
use datafusion::common::Result;
42+
use datafusion::common::ScalarValue;
43+
use datafusion::common::stats::Precision;
44+
use datafusion::execution::SessionStateBuilder;
45+
use datafusion::physical_plan::ExecutionPlan;
46+
use datafusion::physical_plan::operator_statistics::{
47+
ClosureStatisticsProvider, ExtendedStatistics, StatisticsRegistry, StatisticsResult,
48+
};
49+
use datafusion::physical_plan::statistics::StatisticsArgs;
50+
use datafusion::prelude::*;
51+
use rand::{Rng, SeedableRng, rngs::StdRng};
52+
53+
/// Matches the base `events` scan: a leaf carrying both `user_id` and `amount`.
54+
fn catalog_matches(plan: &dyn ExecutionPlan) -> bool {
55+
let schema = plan.schema();
56+
plan.children().is_empty()
57+
&& schema.index_of("user_id").is_ok()
58+
&& schema.index_of("amount").is_ok()
59+
}
60+
61+
/// Injects the catalog-known `amount` range (for filter selectivity) and
62+
/// `user_id` distinct count (for post-filter NDV) the in-memory table lacks.
63+
fn catalog_stats(
64+
plan: &dyn ExecutionPlan,
65+
_child_stats: &[ExtendedStatistics],
66+
) -> Result<StatisticsResult> {
67+
let schema = plan.schema();
68+
let user_id = schema.index_of("user_id")?;
69+
let amount = schema.index_of("amount")?;
70+
let mut stats = (*plan.statistics_from_inputs(&[], &StatisticsArgs::new())?).clone();
71+
stats.column_statistics[amount].min_value =
72+
Precision::Inexact(ScalarValue::Int32(Some(0)));
73+
stats.column_statistics[amount].max_value =
74+
Precision::Inexact(ScalarValue::Int32(Some(999)));
75+
stats.column_statistics[user_id].distinct_count = Precision::Inexact(100);
76+
Ok(StatisticsResult::Computed(ExtendedStatistics::new(stats)))
77+
}
78+
79+
fn int_col(values: &[i32]) -> Arc<Int32Array> {
80+
Arc::new(Int32Array::from_iter_values(values.iter().copied()))
81+
}
82+
83+
fn mem_table(fields: &[(&str, Arc<Int32Array>)]) -> Result<Arc<MemTable>> {
84+
let schema = Arc::new(Schema::new(
85+
fields
86+
.iter()
87+
.map(|(name, _)| Field::new(*name, DataType::Int32, false))
88+
.collect::<Vec<_>>(),
89+
));
90+
let cols = fields.iter().map(|(_, col)| Arc::clone(col) as _).collect();
91+
let batch = RecordBatch::try_new(Arc::clone(&schema), cols)?;
92+
Ok(Arc::new(MemTable::try_new(schema, vec![vec![batch]])?))
93+
}
94+
95+
fn build_ctx(with_registry: bool) -> Result<SessionContext> {
96+
let config = SessionConfig::new()
97+
.with_target_partitions(4)
98+
.set_bool("datafusion.explain.physical_plan_only", true)
99+
.set_bool("datafusion.explain.show_statistics", true)
100+
// Force Partitioned hash joins so statistics alone drive the build side.
101+
.set_usize(
102+
"datafusion.optimizer.hash_join_single_partition_threshold",
103+
1,
104+
)
105+
.set_usize(
106+
"datafusion.optimizer.hash_join_single_partition_threshold_rows",
107+
1,
108+
);
109+
110+
let mut builder = SessionStateBuilder::new()
111+
.with_config(config)
112+
.with_default_features();
113+
if with_registry {
114+
let mut registry = StatisticsRegistry::default_with_builtin_providers();
115+
registry.register(Arc::new(ClosureStatisticsProvider::with_matches(
116+
catalog_matches,
117+
catalog_stats,
118+
)));
119+
builder = builder.with_statistics_registry(registry);
120+
}
121+
let ctx = SessionContext::new_with_state(builder.build());
122+
123+
let n = 1000i32;
124+
let user_ids: Vec<i32> = (0..n).map(|v| v % 100).collect();
125+
// `amount` independent of `user_id` so the filter keeps a representative sample.
126+
let mut rng = StdRng::seed_from_u64(2024);
127+
let amounts: Vec<i32> = (0..n).map(|_| rng.random_range(0..1000)).collect();
128+
ctx.register_table(
129+
"events",
130+
mem_table(&[
131+
("user_id", int_col(&user_ids)),
132+
("amount", int_col(&amounts)),
133+
])?,
134+
)?;
135+
ctx.register_table(
136+
"dims",
137+
mem_table(&[
138+
("user_id", int_col(&(0..48).collect::<Vec<_>>())),
139+
("label", int_col(&(0..48).collect::<Vec<_>>())),
140+
])?,
141+
)?;
142+
Ok(ctx)
143+
}
144+
145+
const QUERY: &str = "SELECT e.user_id, d.label \
146+
FROM (SELECT user_id FROM events WHERE amount < 50 GROUP BY user_id) e \
147+
JOIN dims d ON e.user_id = d.user_id";
148+
149+
async fn explain(ctx: &SessionContext) -> Result<String> {
150+
let batches = ctx
151+
.sql(&format!("EXPLAIN {QUERY}"))
152+
.await?
153+
.collect()
154+
.await?;
155+
Ok(pretty_format_batches(&batches)?.to_string())
156+
}
157+
158+
pub async fn join_reorder() -> Result<()> {
159+
let truth_query = "SELECT count(DISTINCT user_id) AS true_distinct_users \
160+
FROM events WHERE amount < 50";
161+
println!("-- Ground truth --\n{truth_query}\n");
162+
let truth = build_ctx(false)?.sql(truth_query).await?.collect().await?;
163+
println!("{}\n", pretty_format_batches(&truth)?);
164+
165+
println!("-- Query --\n{QUERY}\n");
166+
println!(
167+
"A hash join builds its in-memory hash table from one input and probes with\n\
168+
the other, so the smaller input should be the build side. Default estimation\n\
169+
sizes the grouped `events` at 1000 rows and builds from `dims`; the\n\
170+
registry's refined ~32 estimate is below `dims` (48 rows), so it flips the\n\
171+
build side to `events`. The ground-truth count above (also below 48)\n\
172+
confirms `events` really is the smaller, cheaper side.\n"
173+
);
174+
println!("-- Without the registry (default estimation) --");
175+
println!("{}\n", explain(&build_ctx(false)?).await?);
176+
println!("-- With the registry (built-in refinement) --");
177+
println!("{}", explain(&build_ctx(true)?).await?);
178+
Ok(())
179+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! # Pluggable operator statistics (`StatisticsRegistry`)
19+
//!
20+
//! These examples show how a `StatisticsProvider` registered on the session can
21+
//! change a physical plan by supplying statistics the built-in estimators
22+
//! cannot express.
23+
//!
24+
//! ## Usage
25+
//! ```bash
26+
//! cargo run --example statistics -- [all|join_reorder]
27+
//! ```
28+
//!
29+
//! Each subcommand runs a corresponding example:
30+
//! - `all`: run all examples included in this module
31+
//!
32+
//! - `join_reorder`
33+
//! (file: join_reorder.rs, desc: Supply and refine column statistics via a provider to flip a join order)
34+
35+
mod join_reorder;
36+
37+
use datafusion::error::{DataFusionError, Result};
38+
use strum::{IntoEnumIterator, VariantNames};
39+
use strum_macros::{Display, EnumIter, EnumString, VariantNames};
40+
41+
#[derive(EnumIter, EnumString, Display, VariantNames)]
42+
#[strum(serialize_all = "snake_case")]
43+
enum ExampleKind {
44+
All,
45+
JoinReorder,
46+
}
47+
48+
impl ExampleKind {
49+
const EXAMPLE_NAME: &str = "statistics";
50+
51+
fn runnable() -> impl Iterator<Item = ExampleKind> {
52+
ExampleKind::iter().filter(|v| !matches!(v, ExampleKind::All))
53+
}
54+
55+
async fn run(&self) -> Result<()> {
56+
match self {
57+
ExampleKind::All => {
58+
for example in ExampleKind::runnable() {
59+
println!("Running example: {example}");
60+
Box::pin(example.run()).await?;
61+
}
62+
Ok(())
63+
}
64+
ExampleKind::JoinReorder => join_reorder::join_reorder().await,
65+
}
66+
}
67+
}
68+
69+
#[tokio::main]
70+
async fn main() -> Result<()> {
71+
let usage = format!(
72+
"Usage: cargo run --example {} -- [{}]",
73+
ExampleKind::EXAMPLE_NAME,
74+
ExampleKind::VARIANTS.join("|")
75+
);
76+
77+
let example: ExampleKind = std::env::args()
78+
.nth(1)
79+
.unwrap_or_else(|| ExampleKind::All.to_string())
80+
.parse()
81+
.map_err(|_| DataFusionError::Execution(format!("Unknown example. {usage}")))?;
82+
83+
example.run().await
84+
}

‎datafusion/common/src/config.rs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,11 +1760,10 @@ config_namespace! {
17601760
/// query is used.
17611761
pub join_reordering: bool, default = true
17621762

1763-
/// When set to true, the physical plan optimizer uses the pluggable
1764-
/// `StatisticsRegistry` for statistics propagation across operators.
1765-
/// This enables more accurate cardinality estimates compared to each
1766-
/// operator's built-in `partition_statistics`.
1767-
pub use_statistics_registry: bool, default = false
1763+
/// (Deprecated) Ignored: the physical plan optimizer always consults the
1764+
/// session's pluggable `StatisticsRegistry` (register providers on the
1765+
/// `SessionState`; with none it is a no-op).
1766+
pub use_statistics_registry: bool, warn = "`use_statistics_registry` is deprecated and ignored; the StatisticsRegistry is always consulted, register providers on the SessionState", default = false
17681767

17691768
/// When set to true, the physical plan optimizer will prefer HashJoin over SortMergeJoin.
17701769
/// HashJoin can work more efficiently than SortMergeJoin but consumes more memory

0 commit comments

Comments
 (0)