jayzhan211 commented on issue #26481:
URL: https://github.com/apache/datafusion/issues/26481#issuecomment-6405070779
@alamb thanks for going through it. Yes, it can be delivered incrementally.
Each step lands on its own and is useful by itself, so stopping after any of
them still leaves the code better than today. The proposed order at the end is
the next few steps.
I also have a new finding. I moved each rule that is tied to its position
around `EnsureRequirements` (enforcement) to the other side of it, and ran
every sqllogictest file:
- **Correctness depends on two positions today.** `join_selection` and the
`OutputRequirements` marker must run before enforcement. Every other rule gives
the same results on either side; moving it only changes the plan.
- **`WindowTopN` and `join_selection` would get slower if moved after
enforcement.** Both run before it today. Moved after it, they replace an
operator that enforcement has already placed repartitions or sorts for, and
those stay behind. I think we should fix these two before moving rules into the
optimizer phase or running the optimizer rules in a loop (#25572).
- **`EliminateConstantJoin` should stay where it is**, before enforcement
(re zhuqi-lucas's comment above, which suggested moving it to the optimizer).
Moved after enforcement, it loses the scan predicate in all 3 of its plans,
because `FilterPushdown`, which pushes its new filter into the scan, runs
before enforcement.
The two diagrams below are the main read; the evidence for each rule is in
collapsed sections, and the method is at the end.
### Today
```text
why it is here moved
across enforcement
planner joins start as PartitionMode::Auto
PhysicalAnalyzer
OutputRequirements (add) holds the query's ORDER BY INSERT
ORDER BY loses its order
aggregate_statistics EliminateConstantJoin needs it 3 plans
keep a join
EliminateConstantJoin FilterPushdown needs it 3 plans
lose a scan predicate
join_selection enforcement needs the join mode 767
SanityCheckPlan errors
LimitedDistinctAggregation matches only adjacent aggregates 6 plans
lose lim=[N]
FilterPushdown enforcement reads its row estimates 58 plans
change
WindowTopN replaces the window, not its sort 25 plans
keep a full sort
EnsureRequirements
════════════════════════════ the plan is valid from here
════════════════════════════
PhysicalOptimizer
CombinePartialFinalAggregate, OptimizeAggregateOrder, ProjectionPushdown
OutputRequirements (remove) removes the marker 5 plans
change
LimitAggregation ... PushdownSort
EnsureCooperative nothing found no change
FilterPushdown(Post) later rules rebuild the TopK 27
dynamic filters stay empty
SanityCheckPlan a check written as a rule
```
Only the `OutputRequirements (add)` and `join_selection` rows affect
correctness. The others only change plans.
### After the fixes
This is what I have found so far, and it may change as I find more.
```diff
planner
- joins start as PartitionMode::Auto
+ creates each join in a mode that can execute
+ records the query's output requirement (ORDER BY, one output partition)
PhysicalAnalyzer
- OutputRequirements (add) a marker node at the root holds the output
requirement
aggregate_statistics
EliminateConstantJoin
- join_selection resolves Auto, then chooses by statistics
+ join_selection chooses by statistics only
- LimitedDistinctAggregation matches only adjacent aggregates
+ LimitedDistinctAggregation also matches through exchanges
- FilterPushdown enforcement depends on where the filter
ends up
+ FilterPushdown same estimates either way, leftover filters
stay up
- WindowTopN leaves the window's sort behind
+ WindowTopN also drops the window's sort
- EnsureRequirements reads the output requirement from the marker
+ EnsureRequirements gets it from the planner, also inserts
CooperativeExec
═══════════════════════ the plan is valid from here
═══════════════════════
PhysicalOptimizer
CombinePartialFinalAggregate, OptimizeAggregateOrder, ProjectionPushdown
- OutputRequirements (remove) removes the marker
LimitAggregation ... PushdownSort
- EnsureCooperative
- FilterPushdown(Post) dynamic-filter links break if the plan
changes later
+ FilterPushdown(Post) nodes holding a dynamic filter guard its
links
- SanityCheckPlan
+ planner final check all requirements and the output requirement
```
`EnsureCooperative`, the final check (#26461) and the dynamic-filter guard
come from my earlier proposal. The moves only show that nothing ties them to
their current place.
### Why `OutputRequirements` goes
Today the query's output requirement (its `ORDER BY`, and one output
partition) lives in an `OutputRequirementExec` node: one rule adds it at the
root, another removes it. The node:
- must be added before enforcement. Added after it, `INSERT ... ORDER BY`
lost its order in 3 plans, and a file scan was no longer split into byte ranges
(`repartition_scan.slt:337`).
- changes what other rules do. When it is removed earlier, 5 window plans
place projections differently.
- leaks into the final plan when a custom optimizer list leaves out the
remove pass, and proto serialization then fails.
- is removed with 9 optimizer rules still to run, so no check sees whether
they keep the top-level sort.
The replacement is not another node. The planner keeps the requirement,
gives it to `EnsureRequirements` as the requirement on the root, and checks the
final plan against it. It has to keep today's scan splitting.
### `WindowTopN`
`WindowTopN` is opt-in today (`datafusion.optimizer.enable_window_topn`,
default `false`).
**Problem.** Moved after enforcement, the rule still replaces the window
with `PartitionedTopKExec`. But the full sort that enforcement placed for the
window stays underneath, in all 25 changed plans. `PartitionedTopKExec` only
needs its input partitioned on the partition keys, so the sort is wasted, and
avoiding it is the reason the rule exists.
**Fix.** When the rule replaces the window, it also drops the sort directly
below it, if that sort has no fetch and keeps its input's partitioning. With
the fix, no plan changes where the rule is today. After enforcement, 3 plans
change, all in where a repartition goes. In `window_topn.slt:1516` the top-K
runs on one partition instead of five, because enforcement placed the
repartition for the window.
For the top 3 rows per key over 20M rows, the query takes 0.034 s today,
0.435 s with the rule after enforcement, and 0.035 s after enforcement with the
fix.
<details>
<summary>Timing setup</summary>
Top 3 rows per key, 20M in-memory rows, 100 keys:
| `WindowTopN` | Time |
|---|---|
| before enforcement (today) | 0.034 s |
| after enforcement | 0.435 s |
| after enforcement, with the fix | 0.035 s |
</details>
### `join_selection`
It makes four decisions, and they need different fixes:
| Decision | Kind | Proposal |
|---|---|---|
| 1. Use `SymmetricHashJoinExec` when both inputs are unbounded | runs or
not | decide separately, with 2010YOUY01's suggestion to move it out of the
default pipeline (#24265) |
| 2. Swap so an unbounded input is never the build side | runs or not | same
|
| 3. Give each hash join an executable mode (resolve `Auto`) | correctness:
`Auto` can't execute | the planner creates joins in a mode that can execute |
| 4. Choose `CollectLeft` or `Partitioned`, and the build side, from
statistics | performance | stays in the rule |
Moved after enforcement as it is, the rule gives 767 `SanityCheckPlan`
errors:
- 604 hash joins whose mode was chosen after enforcement saw `Auto`, which
declares no requirement.
- 152 nested-loop and cross joins whose sides were swapped after enforcement
had placed a single-partition requirement on the other side.
To test decision 4 alone, I resolved `Auto` to `Partitioned` before
enforcement, made the statistics-based choice after it, and then repaired the
plan. Every repair keeps the plans valid, with no wrong rows. But inserting
what is missing, or also dropping the repartitions a `CollectLeft` join no
longer needs, changes 53 to 66 plans and makes a probe-heavy query up to 1.46x
slower. Only running `EnsureRequirements` again keeps today's plans, and it is
the most expensive physical rule.
<details>
<summary>Repair variants and timings</summary>
| Repair after the choice | Errors / wrong rows | Plans changed |
|---|---|---|
| insert what is missing (`satisfy_requirements`) | 0 / 0 | 66 |
| drop the hash repartitions a `CollectLeft` join no longer needs, then
insert | 0 / 0 | 53 |
| run `EnsureRequirements` again | 0 / 0 | 13, mostly from running it twice
(#25585); in 2 the build side flips |
- **Insert:** a join that becomes `CollectLeft` keeps the hash repartitions
placed for `Partitioned`, plus a coalesce on top.
- **Drop, then insert:** the probe side loses the round-robin repartition
that today's plan adds. All 43 that disappear had a single input partition, so
this only happens when the probe input arrives in one partition.
- **Run again:** keeps today's join plans apart from the 2 build-side flips,
but `EnsureRequirements` is the most expensive physical rule (about 28 of 75 ms
of physical optimization for all TPC-DS queries).
A 100K-row build side against a 20M-row Parquet file read as one partition
(`datafusion.optimizer.repartition_file_scans = false`), then a `GROUP BY`:
| | Time |
|---|---|
| today | 0.196 s |
| insert what is missing | 0.252 s |
| drop unneeded hash repartitions, then insert | 0.287 s |
</details>
### Proposed order
1. `WindowTopN`: drop the window's sort when it replaces the window.
2. Joins: the planner creates each join in a mode that can execute
(`Partitioned`, or `CollectLeft` where it is required), and `HashJoinExec`
rejects `Auto` as not executable. `join_selection` keeps only decision 4.
3. The planner holds the query's output requirement and `OutputRequirements`
goes (step 4 of my earlier order).
4. Open question: when `join_selection` runs after enforcement, for example
with statistics known only at runtime, how does the probe side get its
round-robin repartition back without a second enforcement pass?
These come before moving any rule into the optimizer phase.
Does this order make sense? If so, I'll open issues for 1 and 2. 4 needs a
design first.
<details>
<summary>How I tested</summary>
- Branch: #25688 rebased onto main, plus the fixes from my review of it and
`satisfy_requirements`, the insert-only part of `EnsureRequirements` (step 3 of
my earlier comment).
- Moves: analyzer rules to the start of the optimizer phase; late optimizer
rules to right after enforcement. Every sqllogictest file, including the TPC-H
plans, with the runner's 10-errors-per-file limit raised.
- Counts: records whose expected output changed, leaving out the 4 records
in explain.slt that only show rule order. Almost all are `EXPLAIN` plans. TPC-H
plans changed only when `join_selection` moved as it is.
- No query returned wrong rows. The only result differences: two
`WindowTopN` queries numbered two tied `NULL` rows the other way round; one
query without `ORDER BY` returned its rows in another order; and with the
`OutputRequirements` marker added after enforcement, one CSV query that should
fail succeeded, because its file was no longer split into byte ranges.
- Timings: `datafusion-cli` release builds, 12 partitions, median of 7 runs
on my laptop.
- I can share the patches I used for the moves.
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]