Skip to content

Commit 1f2d39d

Browse files
committed
feat(examples): add StatisticsRegistry examples (override + custom stats)
Two runnable examples under datafusion-examples/examples/statistics, each showing a registry-driven plan flip via before/after EXPLAIN: - override: a provider corrects an intermediate join's row-count estimate with plain Statistics, flipping the outer join's build side. - custom_stats: a custom NDV sketch is attached at scans, merged across a join through the ExtendedStatistics extension map, and consumed to flip the plan - something a scalar distinct_count cannot express.
1 parent e2a1f11 commit 1f2d39d

4 files changed

Lines changed: 549 additions & 0 deletions

File tree

datafusion-examples/README.md

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

216+
## Statistics Examples
217+
218+
### Group: `statistics`
219+
220+
#### Category: Single Process
221+
222+
| Subcommand | File Path | Description |
223+
| ------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
224+
| custom_stats | [`statistics/statistics_registry_custom_stats.rs`](examples/statistics/statistics_registry_custom_stats.rs) | Propagate a custom NDV sketch through the extension map and consume it at joins |
225+
| override | [`statistics/statistics_registry_override.rs`](examples/statistics/statistics_registry_override.rs) | Correct a built-in operator's row-count estimate with plain Statistics to flip a join order |
226+
216227
## UDF Examples
217228

218229
### Group: `udf`
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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|override|custom_stats]
27+
//! ```
28+
//!
29+
//! Each subcommand runs a corresponding example:
30+
//! - `all`: run all examples included in this module
31+
//!
32+
//! - `custom_stats`
33+
//! (file: statistics_registry_custom_stats.rs, desc: Propagate a custom NDV sketch through the extension map and consume it at joins)
34+
//!
35+
//! - `override`
36+
//! (file: statistics_registry_override.rs, desc: Correct a built-in operator's row-count estimate with plain Statistics to flip a join order)
37+
38+
mod statistics_registry_custom_stats;
39+
mod statistics_registry_override;
40+
41+
use datafusion::error::{DataFusionError, Result};
42+
use strum::{IntoEnumIterator, VariantNames};
43+
use strum_macros::{Display, EnumIter, EnumString, VariantNames};
44+
45+
#[derive(EnumIter, EnumString, Display, VariantNames)]
46+
#[strum(serialize_all = "snake_case")]
47+
enum ExampleKind {
48+
All,
49+
Override,
50+
CustomStats,
51+
}
52+
53+
impl ExampleKind {
54+
const EXAMPLE_NAME: &str = "statistics";
55+
56+
fn runnable() -> impl Iterator<Item = ExampleKind> {
57+
ExampleKind::iter().filter(|v| !matches!(v, ExampleKind::All))
58+
}
59+
60+
async fn run(&self) -> Result<()> {
61+
match self {
62+
ExampleKind::All => {
63+
for example in ExampleKind::runnable() {
64+
println!("Running example: {example}");
65+
Box::pin(example.run()).await?;
66+
}
67+
Ok(())
68+
}
69+
ExampleKind::Override => {
70+
statistics_registry_override::statistics_registry_override().await
71+
}
72+
ExampleKind::CustomStats => {
73+
statistics_registry_custom_stats::statistics_registry_custom_stats().await
74+
}
75+
}
76+
}
77+
}
78+
79+
#[tokio::main]
80+
async fn main() -> Result<()> {
81+
let usage = format!(
82+
"Usage: cargo run --example {} -- [{}]",
83+
ExampleKind::EXAMPLE_NAME,
84+
ExampleKind::VARIANTS.join("|")
85+
);
86+
87+
let example: ExampleKind = std::env::args()
88+
.nth(1)
89+
.unwrap_or_else(|| ExampleKind::All.to_string())
90+
.parse()
91+
.map_err(|_| DataFusionError::Execution(format!("Unknown example. {usage}")))?;
92+
93+
example.run().await
94+
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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+
//! Propagate a custom NDV sketch through the extension map and consume it at joins.
19+
//!
20+
//! Core [`Statistics`] carries a *scalar* `distinct_count` at leaves but cannot
21+
//! correctly combine NDV across a join (that needs the underlying sketches to be
22+
//! unioned/intersected), so it cannot propagate accurate NDV up a join chain. The
23+
//! [`ExtendedStatistics`] extension map can carry the sketch itself, which a
24+
//! provider merges as it walks.
25+
//!
26+
//! `(a ⋈ b ON k) ⋈ c ON k`. A producer provider attaches each scan's key sketch;
27+
//! a join provider intersects the two child sketches for the join key, emits the
28+
//! merged sketch, and estimates cardinality from its NDV. With no sketches the
29+
//! all-distinct fallback underestimates `a ⋈ b` (50 rows), so it stays on the
30+
//! build side; the sketch yields the true NDV and estimates 1000 rows, flipping
31+
//! `c` onto the build side. The merged `a ⋈ b` sketch is what the `⋈ c` join then
32+
//! consumes.
33+
//!
34+
//! Extensions are not forwarded by the walk itself, so `PassthroughStatisticsProvider`
35+
//! is registered to carry the scan sketches through the hash repartitions into the
36+
//! join; without it (or a custom passthrough) they would stop at the first operator
37+
//! that does not produce them.
38+
//!
39+
//! For clarity the "sketch" is an exact set of key values; a real deployment would
40+
//! use a bounded-space HLL/theta sketch with the same merge semantics.
41+
42+
use std::collections::BTreeSet;
43+
use std::sync::Arc;
44+
45+
use datafusion::arrow::array::Int64Array;
46+
use datafusion::arrow::datatypes::{DataType, Field, Schema};
47+
use datafusion::arrow::record_batch::RecordBatch;
48+
use datafusion::arrow::util::pretty::pretty_format_batches;
49+
use datafusion::catalog::MemTable;
50+
use datafusion::common::stats::Precision;
51+
use datafusion::common::{Result, Statistics};
52+
use datafusion::execution::SessionStateBuilder;
53+
use datafusion::physical_plan::ExecutionPlan;
54+
use datafusion::physical_plan::joins::HashJoinExec;
55+
use datafusion::physical_plan::operator_statistics::{
56+
ExtendedStatistics, PassthroughStatisticsProvider, StatisticsProvider,
57+
StatisticsRegistry, StatisticsResult,
58+
};
59+
use datafusion::prelude::*;
60+
61+
/// A custom statistic: the set of distinct join-key values. Stand-in for an
62+
/// HLL/theta sketch (same `ndv`/`intersect` semantics, bounded space).
63+
#[derive(Clone, Debug)]
64+
struct KeySketch {
65+
values: BTreeSet<i64>,
66+
}
67+
68+
impl KeySketch {
69+
fn of(values: impl IntoIterator<Item = i64>) -> Self {
70+
Self {
71+
values: values.into_iter().collect(),
72+
}
73+
}
74+
fn ndv(&self) -> usize {
75+
self.values.len()
76+
}
77+
/// Merge for an equi-join key: only matched values survive.
78+
fn intersect(&self, other: &Self) -> Self {
79+
Self {
80+
values: self.values.intersection(&other.values).copied().collect(),
81+
}
82+
}
83+
}
84+
85+
/// Attaches each base table's key sketch (and row count) to its scan, as an
86+
/// external catalog would. Matched by a table's marker column.
87+
#[derive(Debug)]
88+
struct CatalogSketches {
89+
tables: Vec<(&'static str, usize, KeySketch)>,
90+
}
91+
92+
impl StatisticsProvider for CatalogSketches {
93+
fn compute_statistics(
94+
&self,
95+
plan: &dyn ExecutionPlan,
96+
child_stats: &[ExtendedStatistics],
97+
) -> Result<StatisticsResult> {
98+
// Only leaf scans.
99+
if !child_stats.is_empty() {
100+
return Ok(StatisticsResult::Delegate);
101+
}
102+
let schema = plan.schema();
103+
let Some((_, rows, sketch)) = self
104+
.tables
105+
.iter()
106+
.find(|(marker, _, _)| schema.field_with_name(marker).is_ok())
107+
else {
108+
return Ok(StatisticsResult::Delegate);
109+
};
110+
let mut base = Statistics::new_unknown(&schema);
111+
base.num_rows = Precision::Exact(*rows);
112+
let mut ext = ExtendedStatistics::new(base);
113+
ext.set_extension(sketch.clone());
114+
Ok(StatisticsResult::Computed(ext))
115+
}
116+
}
117+
118+
/// Estimates an equi-join's cardinality from the child key sketches and emits the
119+
/// merged (intersected) sketch so the next join can consume it.
120+
#[derive(Debug)]
121+
struct SketchJoinCardinality;
122+
123+
impl StatisticsProvider for SketchJoinCardinality {
124+
fn compute_statistics(
125+
&self,
126+
plan: &dyn ExecutionPlan,
127+
child_stats: &[ExtendedStatistics],
128+
) -> Result<StatisticsResult> {
129+
if plan.downcast_ref::<HashJoinExec>().is_none() || child_stats.len() != 2 {
130+
return Ok(StatisticsResult::Delegate);
131+
}
132+
let (Some(left), Some(right)) = (
133+
child_stats[0].get_extension::<KeySketch>(),
134+
child_stats[1].get_extension::<KeySketch>(),
135+
) else {
136+
return Ok(StatisticsResult::Delegate);
137+
};
138+
let (Some(&l_rows), Some(&r_rows)) = (
139+
child_stats[0].base().num_rows.get_value(),
140+
child_stats[1].base().num_rows.get_value(),
141+
) else {
142+
return Ok(StatisticsResult::Delegate);
143+
};
144+
let merged = left.intersect(right);
145+
let ndv = left.ndv().max(right.ndv()).max(1);
146+
let mut base = Statistics::new_unknown(&plan.schema());
147+
base.num_rows = Precision::Inexact(l_rows.saturating_mul(r_rows) / ndv);
148+
let mut ext = ExtendedStatistics::new(base);
149+
ext.set_extension(merged);
150+
Ok(StatisticsResult::Computed(ext))
151+
}
152+
}
153+
154+
fn mem_table(cols: &[(&str, Vec<i64>)]) -> Result<Arc<MemTable>> {
155+
let schema = Arc::new(Schema::new(
156+
cols.iter()
157+
.map(|(name, _)| Field::new(*name, DataType::Int64, false))
158+
.collect::<Vec<_>>(),
159+
));
160+
let arrays = cols
161+
.iter()
162+
.map(|(_, v)| Arc::new(Int64Array::from(v.clone())) as _)
163+
.collect();
164+
let batch = RecordBatch::try_new(Arc::clone(&schema), arrays)?;
165+
Ok(Arc::new(MemTable::try_new(schema, vec![vec![batch]])?))
166+
}
167+
168+
async fn build_ctx(with_registry: bool) -> Result<SessionContext> {
169+
let config = SessionConfig::new()
170+
.with_target_partitions(4)
171+
.set_bool("datafusion.explain.physical_plan_only", true)
172+
.set_usize(
173+
"datafusion.optimizer.hash_join_single_partition_threshold",
174+
1,
175+
)
176+
.set_usize(
177+
"datafusion.optimizer.hash_join_single_partition_threshold_rows",
178+
1,
179+
);
180+
181+
let mut builder = SessionStateBuilder::new()
182+
.with_config(config)
183+
.with_default_features();
184+
185+
if with_registry {
186+
let registry = StatisticsRegistry::with_providers(vec![
187+
Arc::new(SketchJoinCardinality),
188+
// Carries the scan sketches through the hash RepartitionExecs into the
189+
// join (extensions are not forwarded by the walk itself).
190+
Arc::new(PassthroughStatisticsProvider),
191+
Arc::new(CatalogSketches {
192+
tables: vec![
193+
("va", 50, KeySketch::of(0..50)),
194+
("vb", 1000, KeySketch::of(0..50)),
195+
("vc", 500, KeySketch::of(0..500)),
196+
],
197+
}),
198+
]);
199+
builder = builder.with_statistics_registry(registry);
200+
}
201+
202+
let ctx = SessionContext::new_with_state(builder.build());
203+
ctx.register_table(
204+
"a",
205+
mem_table(&[("k", (0..50).collect()), ("va", (0..50).collect())])?,
206+
)?;
207+
ctx.register_table(
208+
"b",
209+
mem_table(&[
210+
("k", (0..1000).map(|i| i % 50).collect()),
211+
("vb", (0..1000).collect()),
212+
])?,
213+
)?;
214+
ctx.register_table(
215+
"c",
216+
mem_table(&[("k", (0..500).collect()), ("vc", (0..500).collect())])?,
217+
)?;
218+
Ok(ctx)
219+
}
220+
221+
const QUERY: &str = "SELECT a.va, b.vb, c.vc \
222+
FROM a \
223+
JOIN b ON a.k = b.k \
224+
JOIN c ON b.k = c.k";
225+
226+
async fn explain(ctx: &SessionContext) -> Result<String> {
227+
let batches = ctx
228+
.sql(&format!("EXPLAIN {QUERY}"))
229+
.await?
230+
.collect()
231+
.await?;
232+
Ok(pretty_format_batches(&batches)?.to_string())
233+
}
234+
235+
pub async fn statistics_registry_custom_stats() -> Result<()> {
236+
println!(
237+
"Watch the build (first) input of the OUTER join (on `c`). Without sketches the\n\
238+
all-distinct fallback underestimates `a ⋈ b` (50 rows) and keeps it on the build\n\
239+
side; the key sketch gives the true NDV (a ⋈ b = 1000 rows), moving `c` to build.\n"
240+
);
241+
242+
println!("-- Without the sketch providers (core has no distinct_count) --");
243+
println!("{}\n", explain(&build_ctx(false).await?).await?);
244+
245+
println!(
246+
"-- With CatalogSketches + SketchJoinCardinality (sketch merged a ⋈ b -> ⋈ c) --"
247+
);
248+
println!("{}", explain(&build_ctx(true).await?).await?);
249+
250+
Ok(())
251+
}

0 commit comments

Comments
 (0)