Skip to content

Push down topk through join#21621

Open
SubhamSinghal wants to merge 20 commits intoapache:mainfrom
SubhamSinghal:push-down-topk-through-join
Open

Push down topk through join#21621
SubhamSinghal wants to merge 20 commits intoapache:mainfrom
SubhamSinghal:push-down-topk-through-join

Conversation

@SubhamSinghal
Copy link
Copy Markdown
Contributor

Which issue does this PR close?

#11900

Rationale for this change

When a query has ORDER BY <cols> LIMIT N on top of an outer join and all sort columns come from the preserved side,
DataFusion currently runs the full join first, then sorts and limits. We can push a copy of the Sort(fetch=N) to the preserved input, reducing the number of rows entering the join.

Before:

Sort: t1.b ASC, fetch=3
   Left Join: t1.a = t2.a
     Scan: t1     ← scans ALL rows
     Scan: t2

After:

  Sort: t1.b ASC, fetch=3
    Left Join: t1.a = t2.a
      Sort: t1.b ASC, fetch=3  ← pushed down
        Scan: t1               ← only top-3 rows enter join
      Scan: t2

What changes are included in this PR?

A new logical optimizer rule PushDownTopKThroughJoin that:

  1. Matches Sort with fetch = Some(N) (TopK)
  2. Looks through an optional Projection to find a Join
  3. Checks join type is LEFT or RIGHT with no non-equijoin filter
  4. Verifies all sort expression columns come from the preserved side
  5. Inserts a copy of the Sort(fetch=N) on the preserved child
  6. Keeps the top-level sort for correctness

Are these changes tested?

Yes through UT

Are there any user-facing changes?

No API changes.

@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Apr 14, 2026
} else {
&join.right
};
if matches!(preserved_child.as_ref(), LogicalPlan::Sort(_)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This condition looks a bit broad.
If the child has no fetch limit or a larger fetch limit than the current one then pushing down the current Sort with its fetch limit would be beneficial, no ?
The optimization should be skipped only if the Sort expr is different or its fetch limit is non-zero but smaller than the current one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed.

Comment thread datafusion/sqllogictest/test_files/push_down_topk_through_join.slt Outdated
Comment thread datafusion/sqllogictest/test_files/push_down_topk_through_join.slt
gene-bordegaray

This comment was marked as duplicate.

Copy link
Copy Markdown
Contributor

@gene-bordegaray gene-bordegaray left a comment

Choose a reason for hiding this comment

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

The idea behind the optimizer rule itself makes sense. I think there is some room to add more regression testing and ensure we uphold correctness standards for all cases. Let me know what you think :)


// Create the new Sort(fetch) on the preserved child
let new_child_sort = Arc::new(LogicalPlan::Sort(SortPlan {
expr: sort.expr.clone(),
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.

I don't think we shoudl just clone sort.expr here. Since the Sort can sit on top of a Projection, in this case the ORDER BY clause of the query is interpreted against the projection output columns, not directly on the join's child.

When we push down the Sort(fetch) rather than cloning the Sort columns we need to push down the columns that were projected.

I believe behavior for this right now would work like this:

Sort: b, fetch=1
  Projection: -t1.b AS b
    Join

The optimizer rewrites it into:

Sort: b, fetch=1
  Projection: -t1.b AS b
    Join
      Sort: b, fetch=1 -> This is using the post-projected value!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

Comment thread datafusion/optimizer/src/push_down_topk_through_join.rs Outdated
Comment on lines +166 to +184
# Child has larger fetch: push our tighter limit
# The inner Sort(fetch=5) has a larger limit than our outer Sort(fetch=2),
# so pushing fetch=2 to the preserved child reduces data further.
query TT
EXPLAIN SELECT * FROM (
SELECT t1.a, t1.b, t2.x
FROM (SELECT * FROM t1 ORDER BY b ASC LIMIT 5) t1
LEFT JOIN t2 ON t1.a = t2.x
) sub
ORDER BY b ASC LIMIT 2;
----
logical_plan
01)Sort: sub.b ASC NULLS LAST, fetch=2
02)--SubqueryAlias: sub
03)----Left Join: t1.a = t2.x
04)------SubqueryAlias: t1
05)--------Sort: t1.b ASC NULLS LAST, fetch=5
06)----------TableScan: t1 projection=[a, b]
07)------TableScan: t2 projection=[x]
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.

Seems like we don't actually push down the fetch=2 tighter limit into the nested Sort here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is being blocked by subqueryAlias between sort and join. I think I need to update the comment.

Copy link
Copy Markdown
Contributor

@neilconway neilconway Apr 17, 2026

Choose a reason for hiding this comment

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

Right; couldn't we push the topk down despite the alias? This seems like a fairly common query structure that it would be nice to support.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

refactored code to handle SubqueryAlias

Comment on lines +127 to +129
if join.filter.is_some() {
return Ok(Transformed::no(plan));
}
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.

Might not be necessary for this PR, but would be pretty easy to check if the filter only references non-preserved-side columns, in which case I think we can still do the pushdown?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

removed filter. added UT to verify results are correct.

Comment thread datafusion/optimizer/src/push_down_topk_through_join.rs Outdated
Comment thread datafusion/optimizer/src/push_down_topk_through_join.rs
/// (`Option<TableReference>`) structurally. A `Bare("t1")` and
/// `Full { catalog, schema, table: "t1" }` are NOT equal even though they
/// refer to the same column. After resolving through SubqueryAlias the
/// variant may differ, so we compare by display string instead.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How Expr::to_string() helps with the missing TableReference on one of the sides ?
I don't understand how this is better than Column::eq().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated comment and removed to_string()

Copy link
Copy Markdown
Contributor

@gene-bordegaray gene-bordegaray left a comment

Choose a reason for hiding this comment

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

this is looking good 👍

/// Input sort exprs: [neg_b ASC]
/// Output sort exprs: [(- t1.b) ASC]
/// ```
fn resolve_sort_exprs_through_projection(
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.

may be worth adding unit tests for this guy and resolve_sort_exprs_through_subquery_alias rather than just in slt files to make behavior expectations very clear

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added UT

@SubhamSinghal SubhamSinghal requested a review from martin-g April 22, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants