Reuse aggregate-derived join domains as detail-side payloads - #23113
Reuse aggregate-derived join domains as detail-side payloads#23113pentschev wants to merge 2 commits into
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
Replace eligible detail-side join inputs with an existing aggregate-derived payload in join filter pushdown. This keeps the upstream semi-domain filtering from current main while removing the remaining raw detail scan from Q18-style plans, reducing memory pressure and improving runtime without depending on the separate reused semi-domain candidate branch.
01e286b to
fbc8ea5
Compare
|
I was trying to understand what this is doing, because the example rewrite in the PR description isn't valid on its own (the two query fragments are not equivalent). I think I understand what is going on, but I am not fully convinced that this is a set of changes we want to incorporate into the streaming plan optimisation. I think what is happening is the following. Suppose we have: What we notice is that because of the algebraic structure of Now, There are various conditions on In particular, in the example above, a requirement on the shape of the query is that in between the replaced right-side input to q3 and the final sum over Here's a test that demonstrates that we get the wrong answer with this optimisation as written: def test_aggregated_domain_reuse_ok(
tmp_path: pathlib.Path,
engine: SPMDEngine,
) -> None:
lineitem_path = tmp_path / "lineitem.parquet"
orders_path = tmp_path / "orders.parquet"
pl.DataFrame(
{
"k": [1, 1, 2, 2, 3, 3],
"v": [1, 2, 0, 0, 0, 0],
}
).write_parquet(lineitem_path)
pl.DataFrame({"k": [1, 2, 3]}).write_parquet(orders_path)
lineitem = pl.scan_parquet(lineitem_path)
orders = pl.scan_parquet(orders_path)
selected_keys = (
lineitem.group_by("k")
.agg(pl.col("v").sum().alias("sum_v"))
.filter(pl.col("sum_v") > 0)
.select("k")
)
q = (
orders.join(selected_keys, on="k", how="semi")
.join(lineitem, on="k")
.select("k", "v", bucket="v")
.group_by("bucket") # here we observe "v" with sum(v).
.agg(pl.col("v").sum())
)
assert_gpu_result_equal(q, engine=engine, check_row_order=False)It seems like a safer, but always correct, thing to do would be to deduce that the I'm also surprised that the filter pushdown doesn't find the first I suppose in this case, because we will almost certainly do things as a broadcast join, this optimisation is not to reduce the communication volume but to avoid reading (parts of) lineitem twice (or buffering the whole lot behind a fanout node?). Ah yeah, maybe it's this latter issue, the polars plan is (in part) for Q18: So we get cache reuse of the lineitem scan. But, because it's joined back, if we do this by broadcasting the |
| return ( | ||
| isinstance(left, Cache) | ||
| and isinstance(right, Cache) | ||
| and left.key == right.key | ||
| and left.refcount == right.refcount | ||
| and left.schema == right.schema | ||
| ) |
There was a problem hiding this comment.
I think that the rewrite optimize_with_stats removes Cache nodes, so we should never see Cache at this point.
But, if we need correct equality of Cache we should fix that on Cache, no?
| def _exact_column_bindings(root: IR, column: str) -> Iterable[tuple[IR, str]]: | ||
| """Yield direct output-to-input bindings for a column through a subplan.""" | ||
| node = root | ||
| while column in node.schema: | ||
| yield node, column | ||
| binding = _input_binding(node, column) | ||
| if binding is None: | ||
| return | ||
| node, column = binding |
There was a problem hiding this comment.
Why can't we use the utilities in column_domain.py for this?
| def _replace_on_aggregate_path( | ||
| root: IR, | ||
| column: str, | ||
| target: Join, | ||
| replacement: IR, | ||
| ) -> IR | None: | ||
| """Replace a join only on the direct lineage of an aggregate column.""" |
There was a problem hiding this comment.
Why can't we use replace_at_path?
| def _aggregate_reuse_candidates_for_join( | ||
| node: Join, | ||
| summed_column: str, | ||
| facts: PlanFacts, | ||
| ) -> Iterable[_AggregateReuseCandidate]: | ||
| """Yield aggregate replacements for one join on the sum lineage.""" |
There was a problem hiding this comment.
This thing only ever yields a single value (or no value at all). Does it need to be a generator at all?
This extends
join_filter_pushdownto recognize when an aggregate-derived join domain can be reused as the payload for a later detail-side join input.The motivating case is PDSH Q18. The query computes the set of
l_orderkeyvalues whosesum(l_quantity) > 300, and current planning can already use that result as a semi-join domain to filterorders. However, the finalordersxlineitemjoin still reads the rawlineitemdetail side again.With this change, when the aggregate-derived domain is known to contain the join key and the payload columns needed by the later join, the planner can reuse that derived domain to feed the detail side as well. The original full join is still preserved, so the rewrite is a row-reduction optimization rather than a semantic shortcut.
Q18 plan before, relevant subtree
Q18 plan after, relevant subtree
Materially, this reduces Q18 memory pressure and improves runtime by avoiding the final unfiltered detail-side
lineiteminput. In the latest standalone validation, the full SF30K Q1-Q22 workflow completed successfully on 6xNVL4 nodes with validation passing in 146s lukewarm/128s hot.