Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: Track accumulated selectivity in logical plan to improve probe side decisions #3734

Merged

Conversation

desmondcheongzx
Copy link
Contributor

@desmondcheongzx desmondcheongzx commented Jan 29, 2025

Today, the cardinality of a join is estimated by taking the max of the cardinality of the two sides because we assume primary key + foreign key joins (pk-fk joins).

This is fine in the case where there is no filtering on the left or right side of the join. However, when there is filtering, we overestimate the cardinality of the join.

What's happening is that filtering one side of a pk-fk join should reduce the cardinality of the join by the same proportion. However, in the case where the unfiltered side has a larger cardinality, we currently take its cardinality as the cardinality of the join, meaning that the cardinality of the join is not scaled down by the proportion of the filtered side.

This PR fixes this by tracking the accumulated selectivity of all logical operations. When it comes time to estimate the cardinality of a join, we scale down the estimated cardinality of the join by the accumulated selectivity of the unchosen side (we don't need to scale down by the selectivity of the chosen side because the cardinality has already been scaled down prior to the join).

For a more concrete example, consider the following query tree:

                       InnerJoin
                     /           \
Filter(selectivity = 0.5)    Filter(selectivity = 0.9)
                   /                \
Scan(cardinality = 10)          Scan(cardinality = 10000)

For this inner join, the left side has a cardinality of 5 (10 * 0.5) and the right side has a cardinality of 9000 (10000 * 0.9). Assuming a pk-fk join, and ignoring filters, we expect the cardinality of the join to be 10000 (taking the max of the two sides to be the foreign key side).

However, if we take the filters into account, 50% of the primary keys are filtered out and 90% of the foreign keys are filtered out. Assuming that the filters are independent, we expect the cardinality of the join to be 10000 * 0.5 * 0.9 = 4500. To make this estimate, we take the larger cardinality of the two sides (9000) and scale it down by the selectivity of the smaller side (0.5) which gives us the expected cardinality of the join (4500).

This is an approximation, and it might well be the case that the selectivities are not independent. This could be handled in the future by using histograms, or we could also penalize multiplication of the selectivity by some tuned penalty.

Results with TPCH

These changes speed up TPC-H queries 8, 9, and 10, which were suffering from incorrect probe side decisions.

Q5 saw some regressions because the cardinalities for two sides were similar enough that these changes caused the probe side to flip incorrectly. However, we currently have an incorrect join order for Q5, so once join reordering is turned on this may no longer be an issue.

Other queries were not affected.

@github-actions github-actions bot added the perf label Jan 29, 2025
Copy link

codspeed-hq bot commented Jan 29, 2025

CodSpeed Performance Report

Merging #3734 will degrade performances by 18.74%

Comparing desmondcheongzx:add-selectivity-to-plans (5735c67) with main (78beff4)

Summary

⚡ 3 improvements
❌ 1 regressions
✅ 23 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Benchmarks breakdown

Benchmark BASE HEAD Change
test_iter_rows_first_row[100 Small Files] 153.1 ms 188.4 ms -18.74%
test_tpch[1-in-memory-native-8] 192.3 ms 172.4 ms +11.53%
test_tpch[1-in-memory-native-9] 469.6 ms 374.9 ms +25.23%
test_tpch_sql[1-in-memory-native-9] 472.8 ms 401.6 ms +17.75%

Copy link

codecov bot commented Jan 29, 2025

Codecov Report

Attention: Patch coverage is 72.91667% with 26 lines in your changes missing coverage. Please review.

Project coverage is 77.65%. Comparing base (fec399e) to head (5735c67).
Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
src/daft-physical-plan/src/plan.rs 60.97% 16 Missing ⚠️
src/daft-logical-plan/src/ops/join.rs 69.23% 4 Missing ⚠️
src/daft-logical-plan/src/stats.rs 76.92% 3 Missing ⚠️
src/daft-logical-plan/src/ops/distinct.rs 80.00% 1 Missing ⚠️
src/daft-logical-plan/src/ops/explode.rs 80.00% 1 Missing ⚠️
src/daft-logical-plan/src/ops/limit.rs 83.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3734      +/-   ##
==========================================
+ Coverage   77.58%   77.65%   +0.06%     
==========================================
  Files         729      731       +2     
  Lines       92010    92329     +319     
==========================================
+ Hits        71389    71697     +308     
- Misses      20621    20632      +11     
Files with missing lines Coverage Δ
src/daft-dsl/src/expr/mod.rs 75.65% <100.00%> (+1.36%) ⬆️
src/daft-logical-plan/src/ops/agg.rs 66.12% <100.00%> (+3.62%) ⬆️
src/daft-logical-plan/src/ops/filter.rs 85.00% <100.00%> (+0.38%) ⬆️
src/daft-logical-plan/src/ops/source.rs 73.62% <100.00%> (+1.21%) ⬆️
src/daft-logical-plan/src/ops/unpivot.rs 75.60% <100.00%> (+0.30%) ⬆️
src/daft-logical-plan/src/ops/distinct.rs 93.75% <80.00%> (-2.55%) ⬇️
src/daft-logical-plan/src/ops/explode.rs 76.36% <80.00%> (+0.36%) ⬆️
src/daft-logical-plan/src/ops/limit.rs 94.28% <83.33%> (-2.15%) ⬇️
src/daft-logical-plan/src/stats.rs 71.01% <76.92%> (+2.59%) ⬆️
src/daft-logical-plan/src/ops/join.rs 78.13% <69.23%> (+0.14%) ⬆️
... and 1 more

... and 40 files with indirect coverage changes

Comment on lines +1422 to +1424
// String contains
Expr::ScalarFunction(ScalarFunction { udf, .. }) if udf.name() == "contains" => 0.1,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@colin-ho Btw I almost forgot that I had put this in here while I was prototyping. We can leave it in or remove it - I don't think it makes too much of a difference either way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good

#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f64 does not impl Eq because of NaNs, but it seems like we didn't need Eq for ApproxStats anyway ¯_(ツ)_/¯

Comment on lines 71 to 89
ScanState::Tasks(scan_tasks) => {
let mut approx_stats = ApproxStats::empty();
let mut prefiltered_num_rows = 0.0;
for st in scan_tasks.iter() {
if let Some(num_rows) = st.num_rows() {
approx_stats.num_rows += num_rows;
prefiltered_num_rows += num_rows as f64
/ st.pushdowns().estimated_selectivity(st.schema().as_ref());
} else if let Some(approx_num_rows) = st.approx_num_rows(None) {
approx_stats.num_rows += approx_num_rows as usize;
prefiltered_num_rows += approx_num_rows
/ st.pushdowns().estimated_selectivity(st.schema().as_ref());
}
approx_stats.size_bytes +=
st.estimate_in_memory_size_bytes(None).unwrap_or(0);
}
approx_stats.acc_selectivity =
approx_stats.num_rows as f64 / prefiltered_num_rows;
approx_stats
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe physical_scan_info has the pushdowns, and they should be the same as the pushdowns in all the scan tasks. Given this, is physical_scan_info.pushdowns.estimated_selectivity() essentially the same as acc_selectivity, and we don't need to do the prefiltered_num_rows calculations?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, changed this to physical_scan_info.pushdowns.estimated_selectivity(self.output_schema.as_ref());

Comment on lines +1422 to +1424
// String contains
Expr::ScalarFunction(ScalarFunction { udf, .. }) if udf.name() == "contains" => 0.1,

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good

@@ -58,13 +58,17 @@ impl Aggregate {
ApproxStats {
num_rows: 1,
size_bytes: est_bytes_per_row,
acc_selectivity: input_stats.approx_stats.acc_selectivity
/ input_stats.approx_stats.num_rows as f64,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder what the behaviour is if input_stats.approx_stats.num_rows is 0. I guess this will become NaN? Which might be dangerous if it is propagated into somewhere that casts it to usize.

Lets add some safety checks for divide by 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, added checks. Tempted to add unlikely hints to the 0 num_rows case but doesn't seem like we do this in the rest of the code base so I'm holding off on it

@desmondcheongzx desmondcheongzx merged commit 684505b into Eventual-Inc:main Jan 30, 2025
39 of 41 checks passed
@desmondcheongzx desmondcheongzx deleted the add-selectivity-to-plans branch January 30, 2025 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants