Skip to content

TableFunction's cardinality also returns post-filter estimates - #28099

Open
myrrc wants to merge 3 commits into
duckdb:mainfrom
myrrc:myrrc/join-with-pushed-filters
Open

myrrc wants to merge 3 commits into
duckdb:mainfrom
myrrc:myrrc/join-with-pushed-filters

Conversation

@myrrc

@myrrc myrrc commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Readers like Parquet or Vortex can say they have pushed down a filter in pushdown_complex_filter by
removing the filter from the filters list. Readers
can also report estimated cardinality of the query with regards to pushed filters.

If the filters are not pushed, DuckDB either converts them to table filters or inserts as additional nodes
in the query plan. Then consumers like join planner use the plan to infer output cardinality.

However, join planner doesn't account for filters which have been pushed which overstates
relation's cardinality. It uses the minimum of function's cardinality as the denominator, thus if you account for a pushed filter in cardinality(), the join cardinality estimate grows.

Attached are examples of TPC-DS query 64 on scale factor 100. "default.log" is the state where
we don't report any filter as pushed and return cardinality before applying the filter,
"current.log" is the current implementation where we report filters as pushed and try to account
for this in cardinality. We don't have access to the relation manager from the reader, thus
one of join's cardinality estimates is 5 times the original, an extra join is inserted,
and query time slows ~100x.

This change adds a cardinality_postfilter callback which allows a TableFunction to specify a cardinality estimate after pushing down a filter or multiple filters.
This change also fixes join planner for such use cases.

current.log
default.log

@duckdb-draftbot
duckdb-draftbot marked this pull request as draft October 29, 2026 18:00
@myrrc
myrrc marked this pull request as ready for review October 29, 2026 18:00
@myrrc

myrrc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Ok, there's something strange going with Parquet (it never pushes any filters), I'll investigate

@myrrc

myrrc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Ok, it's hive partitioning. MultiFileReader does remove the filters on virtual columns yet doesn't account it on cardinality. Ideally pushdown_complex_filter should return enum with option "remove but account me in cardinality" and "remove and don't account in cardinality".

@myrrc

myrrc commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

I think this is a better solution - let implementations choose whether filters they've pushed are evaluated before the scan (and so cardinality already accounts for them) or after the scan (and so cardinality needs to be adjusted). To avoid breaking the API it's not a return type of pushdown_complex_filter but an output parameter on LogicalGet.

@duckdb-draftbot
duckdb-draftbot marked this pull request as draft October 30, 2026 14:13
@myrrc
myrrc marked this pull request as ready for review October 30, 2026 14:13
@Mytherin

Mytherin commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Thanks - but this seems like the wrong shape. Can't this be solved in the table function? The table function (1) is the one reporting its cardinality, and (2) is the one taking in the filters that are pushed down. Can't the table function itself take these filters into account when reporting the cardinality?

@lnkuiper

lnkuiper commented Nov 2, 2026

Copy link
Copy Markdown
Member

I let Codex look into this and it suggested that this can be fixed vortex-side:

diff --git a/vortex-duckdb/cpp/table_function.cpp b/vortex-duckdb/cpp/table_function.cpp
index 295d402..8c8590b 100644
--- a/vortex-duckdb/cpp/table_function.cpp
+++ b/vortex-duckdb/cpp/table_function.cpp
@@ -210,9 +210,6 @@ duckdb_state register_table_function(DatabaseInstance &db, LogicalType parameter
     fn.pushdown_expression = [](auto &, const auto &, Expression &expression) {
         return duckdb_table_function_pushdown_expression(reinterpret_cast<duckdb_vx_expr>(&expression));
     };
-    fn.pushdown_complex_filter = [](auto &, auto &, FunctionData *bind_data, FilterVec &filters) {
-        pushdown_complex_filter(*bind_data, filters);
-    };
     fn.projection_expression_pushdown = projection_expression_pushdown;
     fn.to_string = to_string;
 
diff --git a/vortex-duckdb/src/table_function.rs b/vortex-duckdb/src/table_function.rs
index ed751b1..0fb95c6 100644
--- a/vortex-duckdb/src/table_function.rs
+++ b/vortex-duckdb/src/table_function.rs
@@ -576,34 +576,19 @@ fn try_push_projection_aggregate(
     }))
 }
 
-/// Duckdb requires post-filter cardinality estimates, otherwise join planner
-/// may flip join sides which is a huge regression for some queries i.e. 1000x
-/// for tpcds 85.
-///
-/// See duckdb/src/optimizer/join_order/relation_statistics_helper.cpp
-///
-/// As we don't report distinct values (same as Parquet), the only heuristic
-/// duckdb uses is a 0.2 filter if there is any non-optional filter. We mimic it
-/// here.
-const DEFAULT_SELECTIVITY: f64 = 0.2;
+/// Estimate source rows; DuckDB estimates the selectivity of filters retained in the plan.
 pub fn cardinality(bind_data: &BindState, file_count: u64) -> Cardinality {
     // If we're doing an aggregate scan, we don't change output cardinality to
     // 1 as we want duckdb to do our aggregation in parallel. That may look
     // counterintuitive in the plan, though.
-    let has_non_optional_filter = bind_data.has_non_optional_filter.load(Ordering::Relaxed);
     let total = bind_data
         .first_file_row_count
         .saturating_mul(max(file_count, 1));
-    if !has_non_optional_filter {
-        return if file_count <= 1 {
-            Cardinality::Exact(total)
-        } else {
-            Cardinality::Estimate(total)
-        };
+    if file_count <= 1 {
+        Cardinality::Exact(total)
+    } else {
+        Cardinality::Estimate(total)
     }
-    let post_cardinality = total as f64 * DEFAULT_SELECTIVITY;
-    let post_cardinality: u64 = post_cardinality.as_();
-    Cardinality::Estimate(max(1, post_cardinality))
 }
 
 pub fn to_string(bind_data: &BindState, map: &mut DuckdbStringMapRef) {

@myrrc

myrrc commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@Mytherin

Can't this be solved in the table function?

No, because the issue is not in the cardinality estimation of the table function alone, but in the estimation in the join planner. You can push down a filter and then account for it in cardinality(). That's what we currently do, see Laurens's excerpt from our code.

However, when we're estimating cardinality for a join, cardinality()'s output becomes both base_table_cardinality and cardinality_after_filter, total_domain = base_table_cardinality.
For a join, resulting cardinality is the multiplication of sides cardinalities divided by the minimum of side' total_domains.

For an example join where left side has N rows, right has M rows, N > M,
each table function over a table has one filter

  • the default case (nothing is pushed), domain is M, multipler is 0.2 * 0.2 * N * M (each filter for both sides gives a flat 0.2 multiplier), output is 0.04 * N.
  • if you push a filter and multiply cardinality() with 0.2 inside (our current solution), multiplier is 0.2 * 0.2 * N * M (same as in first case), but domain (total_cardinality) is now 0.2 * M (since we return a smaller cardinality with accounted filter), output is 0.2 * N (5 times higher).
  • if you push a filter and use my solution, it's the same case as (1)

So the issue is in the join estimator, maybe I should've been more clear about this.

@lnkuiper Not pushing down table filters will result in 10-100x regressions since we won't be able to discard unused data. The issue I'm describing is not specific to Vortex. Every table function which can push down filters and reports cardinality is subject to this bug.

@Mytherin

Mytherin commented Nov 2, 2026

Copy link
Copy Markdown
Member

Maybe as a work-around we can just leave in a cheap filter?

@myrrc

myrrc commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

We don't know whether the filter is cheap or not without evaluating it. We may use heuristics like "if this is an equality over some column, this is cheap" but nothing guarantees it's cheap indeed. For this to be implemented, a TableFunction implementer like Vortex would also need to know/use DuckDB's heuristics for determining a filter's cost which shouldn't be the custody of the TableFunction.

This solution has yet another flaw if all filters are expensive by this heuristic, yet all can be pushed down (which we have also discovered in our tests). In this case you ultimately regress, either by making DuckDB reapply a filter or by having incorrect join estimates.

@Mytherin

Mytherin commented Nov 2, 2026

Copy link
Copy Markdown
Member

I agree it's not ideal. The ideal fix imo is to expand the statistics callback to allow it to give back both the pre and post filter statistics. Could we go down that path instead?

@Mytherin

Mytherin commented Nov 2, 2026

Copy link
Copy Markdown
Member

Of course that's an API change so that will only land in v2.1, as a work-around for v2.0.X leaving in the cheap filters could be done.

@myrrc

myrrc commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Could we go down that path instead?

No, because for multi-file scans statistics is not provided (i.e. by MultiFileReader, which is a resonable choice) yet we want to estimate number of rows for a multi-file scan. cardinality() is a good distinct feature for this purpose. Maybe cardinality() itself should return multiple estimates.

@Mytherin

Mytherin commented Nov 2, 2026

Copy link
Copy Markdown
Member

Yes, that's what I meant, we need to change / extend the API around what cardinality estimates table functions can provide

@myrrc

myrrc commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Ok, let's repurpose this PR to change cardinality() to return both pre- and post- estimates to 2.1, and we'll hack the implementation on our side in 2.0.

@duckdb-draftbot
duckdb-draftbot marked this pull request as draft November 3, 2026 11:41
@myrrc myrrc changed the title Join planner takes into account complex filters pushed in file readers cardinality_postfilter callback for TableFunction Nov 3, 2026
@myrrc
myrrc marked this pull request as ready for review November 3, 2026 11:42
@myrrc

myrrc commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

@Mytherin I think a new function is better for backwards compatibility, but let me know what you think.

@Mytherin

Mytherin commented Nov 3, 2026

Copy link
Copy Markdown
Member

I would prefer to change the existing callback, while we're at it we can also shape it more like the other newer callbacks (i.e. introduce a TableFunctionCardinalityInput struct)

myrrc added a commit to vortex-data/vortex that referenced this pull request Nov 3, 2026
DuckDB's JOIN planner overstates cardinality for table functions which
push down
filters (duckdb/duckdb#28099). Hack this until
upstream
PR is merged by reporting distinct count as MAX - MIN + 1 based on
min/max
statistics.

---------

Signed-off-by: Mikhail Kot <[email protected]>
@myrrc myrrc changed the title cardinality_postfilter callback for TableFunction TableFunction's cardinality also returns post-filter estimates Nov 3, 2026
@myrrc

myrrc commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

I would prefer to change the existing callback

The issue with existing callback is that it already reports two cardinalities (estimate and max), it's unclear whether post-filter should be another field in it or another entry unique_ptr<NodeStatistics> in cardinality's output.

@myrrc

myrrc commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

I'm off for a while, when I get back, I'll update the solution for cardinality

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DuckDB on Vortex fails the TPC-DS nightly since the tables were split into files

3 participants