Skip to content

perf: semi-join small filtered dimensions below the joins in between - #2799

Merged
andygrove merged 2 commits into
apache:mainfrom
andygrove:perf-semi-join-reduction
Nov 10, 2026
Merged

andygrove merged 2 commits into
apache:mainfrom
andygrove:perf-semi-join-reduction

Conversation

@andygrove

@andygrove andygrove commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

No single issue. Split out of #2792. Related: #2520 (performance review tracking), #1375 (dynamic filters).

Depends on the semi-join pushdown PR (#2793). It is built on that PR's commit, so review only the last commit; this PR will be rebased once the first one merges.

Rationale for this change

DataFusion applies a selective dimension filter (such as nation) only at the join that names it. TPC-H q7 joins and shuffles 182M lineitem rows before the nation filter drops 92% of them. Spark's runtime filters avoid this.

What changes are included in this PR?

A rule, SemiJoinReduction, controlled by ballista.optimizer.semi_join_reduction (default true). For an inner join against a dimension filtered by =, IN or LIKE on literals (an OR needs one of these in each branch), it adds a semi join against a copy of the dimension on the table the key comes from, below the joins in between. In q7, supplier and customer are reduced to the two filtered nations before they join lineitem.

Logical plans have no statistics, so the scheduler runs the rule through apply_semi_join_reduction in TaskManager::submit_job before physical planning:

  • Candidates are found by shape on the optimized plan, then planned on their own (concurrently) to read their row estimate. Only dimensions estimated at no more than ballista.optimizer.broadcast_join_threshold_rows are used; 0 disables the reduction.
  • A semi join that lands on a scan under 100K estimated rows (nation reduced by region in q2/q5/q8) only adds a join, so it is removed again.
  • The semi joins sink through the shared step helper in SemiJoinPushdown, but, unlike that rule, also through inner joins with a filtered input, since the semi join is the selective side there.
  • It is an optimization, so a failure while sizing (a table that cannot be planned) is logged and the plan is submitted unchanged. Plans without a join, and EXPLAIN, DDL and DML plans, are left alone.

Tests cover the shape checks and run apply_semi_join_reduction end to end over in-memory tables: a semi join is added where it lands on a large table, removed where it lands on a small one, and not added when disabled.

With every change in #2792 together, on one machine at TPC-H SF100 (2 executors x 8 cores, 16 partitions), q7 goes from 20.1 s to 5.6 s. All 22 queries pass --verify against DataFusion at SF10 on that branch.

Known limits, so reviewers can weigh them:

  • This is a static, estimate-based stand-in for Spark's runtime filters. Runtime or dynamic filters in the AQE planner (Investigate use of DynamicFilters in ballista #1375) would use measured sizes and need no size heuristic, but would only apply to AQE jobs. This rule applies to logical submissions in every execution mode.
  • Physical-plan submissions are not rewritten.
  • Sizing plans each candidate once on the scheduler, which can list files, in addition to the real planning later. The optimizer also runs once more here than before for plans with joins.
  • A landing input that is a join subtree is not sized, so a tiny one is not removed.
  • The size of a dimension is an estimate (DataFusion's default selectivity for a filter on a table without statistics), and the shape check does not measure selectivity.

Are there any user-facing changes?

One new config key, ballista.optimizer.semi_join_reduction, enabled by default. Plans for queries with filtered dimension joins change. docs/source/user-guide/configs.md is regenerated.

DataFusion decorrelates IN/EXISTS/NOT EXISTS subqueries into semi and
anti joins placed above the FROM clause's join tree, so the subquery
filters the joined result. Add a logical rule, like Spark's
PushDownLeftSemiAntiJoin, that moves them below passthrough projections,
inner joins and the preserved side of outer joins, onto the input that
holds their keys. TPC-H q18 then filters orders down to the qualifying
orders before joining lineitem.

The rule stops at two places where the move is sound but slower:

- A Filter. The semi join should see the filter's output: TPC-H q4's
  EXISTS probes 5.7M filtered orders instead of all 150M (3.3 s to
  9.6 s at SF100 when it did not).
- A join whose other input is filtered. That join can remove most rows
  itself, so a semi join below it probes rows it would have dropped.
  TPC-H q21's EXISTS and NOT EXISTS moved onto all 379M l1 rows at
  SF100, doubling its run time (32 s to 65 s).

Controlled by ballista.optimizer.semi_join_pushdown (default true).
For an inner join against a small dimension filtered by =, IN or LIKE on
literals (a nation, region or part type), also semi-join the dimension
with the table the join key comes from, below the joins in between, so
the filter removes rows before they are joined and shuffled. This is
the effect of Spark's runtime filters. In TPC-H q7 supplier and customer
are reduced to the two filtered nations before joining lineitem.

Logical plans carry no statistics, so the scheduler runs the rule before
physical planning: candidates are found by shape, planned on their own
to read their row estimate, and used only under
ballista.optimizer.broadcast_join_threshold_rows. A semi join that lands
on a tiny input (under 100K estimated rows, such as nation reduced by
region in q2/q5/q8) only adds a join, so it is removed again.

Unlike SemiJoinPushdown, the reduction also sinks below joins with a
filtered input: the semi join is the selective side there.

Controlled by ballista.optimizer.semi_join_reduction (default true).

@milenkovicm milenkovicm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

thanks @andygrove

@andygrove
andygrove marked this pull request as ready for review November 10, 2026 00:18
@andygrove
andygrove added this pull request to the merge queue Nov 10, 2026
Merged via the queue into apache:main with commit 153c5f9 Nov 10, 2026
25 checks passed
@andygrove
andygrove deleted the perf-semi-join-reduction branch November 10, 2026 12:59
andygrove added a commit to andygrove/datafusion-ballista that referenced this pull request Nov 10, 2026
apache#2799 landed this branch's first commit (the SemiJoinPushdown rule, its
config key and docs) as part of the stacked semi-join reduction work, and
refactored the rule's traversal so SemiJoinReduction can share it. That is
what conflicted in config.rs, optimizer.rs, semi_join_pushdown.rs (added on
both sides) and configs.md.

Each conflicted file takes main's version, and the second commit's changes
are re-applied on top of it: the volatile key and filter guard in
is_pushable_semi_join with its test, the whole-plan assertion in the session
test, and the results test with the rule on and off. Against main the branch
now differs only in optimizer.rs and semi_join_pushdown.rs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants