|
| 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