andygrove opened a new pull request, #2782:
URL: https://github.com/apache/datafusion-ballista/pull/2782

   # Which issue does this PR close?
   
   Closes #2756.
   
   # Rationale for this change
   
   With `ballista.planner.coalesce.enabled` on, `CoalescePartitionsRule` skips 
the rewrite whenever the bin-pack produces one group. A stage whose whole input 
fits in `ballista.planner.coalesce.target_partition_bytes` then still runs one 
partition per upstream partition, most of them reading next to nothing. Those 
are exactly the stages coalescing exists for: final aggregates, small joins and 
the sorts below an `ORDER BY` merge. Spark's `CoalesceShufflePartitions` goes 
all the way down to one partition.
   
   The issue asked four questions. Here is what I found.
   
   **Why was `K <= 1` excluded?** It was a conservative default rather than a 
guard for an operator requirement. #1684 calls the one-group case degenerate 
and keeps such a stage at full parallelism. None of the operators the rule 
allows (`tolerates_partition_count_change`) need more than one partition.
   
   The guard was hiding two bugs, though. Both already fire on main when a 
shuffle packs into more than one but fewer than all of its partitions (`1 < K < 
M`):
   
   - **Sorted shuffles.** The adapter reads an exchange whose input is sorted 
with `RangeShuffleReaderExec`, one k-way merge per partition. Once coalesced it 
builds a plain `ShuffleReaderExec` instead, which concatenates a group's files. 
The `SortPreservingMergeExec` above then merges unsorted streams, and `ORDER 
BY` results come back out of order. With one group allowed and nothing else 
changed, every small `ORDER BY` over more than one partition would come back 
unsorted.
   - **Passthrough stage widths.** A stage below a `CoalescePartitionsExec` or 
`SortPreservingMergeExec` writes one partition per partition it reads. Since 
#2714 its output is resolved to the width its writer produced, so after 
coalescing it declares `M` partitions but resolves `K` location lists. If the 
stage reading it doesn't coalesce too, its reader still exposes `M` partitions 
and indexes past the end. On main with coalescing on, TPC-H q17 at SF100 fails 
this way (`index out of bounds: the len is 8 but the index is 10`).
   
   **Can `K = 1` be expressed with the existing `CoalescePlan`?** Yes. One 
group spanning all `M` upstream partitions gives a one-partition reader. The 
stage needs no new shape: `CoalescePartitionsExec` and 
`SortPreservingMergeExec` pass a single partition straight through, and 
Ballista turns off round-robin repartitioning, so there is no `RepartitionExec` 
to drop.
   
   **How much does it matter?** I ran TPC-H and TPC-DS with coalescing on, on 
main and on this PR, with the benchmark runners' `--verify` checking every 
answer against single-process DataFusion. The setup was one machine with 32 
cores, a scheduler and two executors with 8 vcores and a 16 GiB pool each, 16 
target partitions, and Parquet data. Each stage's tasks and partitions come 
from the scheduler's REST API.
   
   | | TPC-H SF10 | TPC-H SF100 | TPC-DS SF10 |
   |---|---:|---:|---:|
   | Queries | 22 | 21 | 98 |
   | Stages | 124 | 130 | 1,102 |
   | Stages coalesced, main / this PR | 11 / 51 | 9 / 27 | 26 / 441 |
   | ... to one partition, main / this PR | 0 / 40 | 0 / 18 | 0 / 417 |
   | Partitions run, main / this PR | 1,535 / 1,175 (-23%) | 1,682 / 1,457 
(-13%) | 16,072 / 12,949 (-19%) |
   | Tasks launched, main / this PR | 217 / 192 (-12%) | 233 / 216 (-7%) | 
2,164 / 2,040 (-6%) |
   
   TPC-H SF100 leaves out q17, which fails on main as described above. TPC-DS 
leaves out q23, which hangs on main with coalescing on or off. Every other 
query returned the right answer on both builds, and so did q17 at SF100 with 
this PR.
   
   In the stages this PR reads as one partition, partitions fall from 569 to 
220 at TPC-H SF10, from 243 to 18 at SF100 and from 6,331 to 3,265 at TPC-DS 
SF10.
   
   Ballista already packs a stage's partitions into tasks by free vcores 
(`ballista.scheduler.max_partitions_per_task`), so the task count falls less 
than the partition count. The partitions are what a single group removes: each 
one holds a vcore, runs the stage's operators and writes its own shuffle 
output, which the next stage then fetches. When a stage reads a shuffle through 
a gather, such as a `CollectLeft` build side or the final merge, its partition 
count comes from elsewhere, and coalescing only cuts the number of streams the 
gather reads.
   
   Wall time is flat. Over three alternating runs per build at TPC-H SF100, the 
per-query medians add up to 177.6 s on main and 176.1 s here. Most queries are 
within 3%. q11 is the exception, 0.53 s to 0.71 s: its 3.1M-row join stage now 
runs as one task taking 219 ms, where 16 partitions took 36 ms. That is the 
parallelism a 64 MiB target trades away, the same trade every coalesced 
partition already makes. Spark keeps more of it by default with 
`parallelismFirst`, which caps the target at the shuffle's size divided by the 
cluster's parallelism. That could be a follow-up if latency matters more than 
the saved partitions. The SF10 runs showed the same totals within noise.
   
   **Are there alternatives?** Packing partitions into tasks, described above, 
is the closest, and it doesn't remove the partitions. Running small stages on 
the scheduler would put the data path (shuffle reads, memory, object stores) in 
the scheduler, where one slow stage could hold up planning for every job. 
Fusing a small stage into its consumer removes a shuffle, but that has to be 
decided before the upstream stage runs, from estimates, which is the 
static-planning problem AQE is there to avoid. Broadcast joins already cover 
the common small-side case. Coalescing to one partition is a small change to a 
rule that already exists.
   
   # What changes are included in this PR?
   
   Two commits, so the fixes can be reviewed, or split out, on their own:
   
   1. **`fix(aqe): keep sorted shuffles and passthrough widths intact when 
coalescing`**
      - `CoalescePartitionsRule` leaves a stage alone when any leaf's input 
declares an ordering, like the broadcast and range-repartition cases. Such a 
stage is usually the final merge of an `ORDER BY`, which runs as one task 
anyway. The same check keeps the prefix-window rewrite's final stage 
uncoalesced: `PrefixMergeExec` keys its state by partition, and the shuffle it 
reads is sorted.
      - `AdaptivePlanner::take_stage_output_partitions` resolves every 
partition a stage's boundary declares, and more if the writer produced more (a 
skew-split final stage), padding with empty location lists.
   2. **`feat(aqe): coalesce shuffle partitions down to a single partition`**
      - The rule keeps `K = 1` and skips only `K >= M`.
      - The tuning guide says coalescing can go down to a single task.
   
   Tests:
   
   - `state::aqe::test::coalesce_rule` gains a one-partition aggregate, both 
legs of a join coalesced to one partition together, a sorted leaf at `K = 1` 
and `K = 2`, and a coalesced passthrough stage that still resolves all 8 of its 
declared partitions. All four fail on main.
   - `ballista/client/tests/coalesce.rs` runs four query shapes (a sorted 
`GROUP BY`, a sorted join, a global aggregate over a join and a sorted scan) on 
a standalone cluster with coalescing on, at five targets and with broadcast 
joins on and off, and compares each result with single-process DataFusion, row 
by row. On main 16 of the 40 cases fail: the global aggregate panics and the 
sorted results come back out of order. All pass here.
   - I also ran `ballista/client/tests/prefix_window.rs` with coalescing on at 
targets from 128 bytes to 64 MiB. On main it hangs at 2048 bytes and returns 
wrong running sums at 1024. Here it passes at every target.
   
   # Are there any user-facing changes?
   
   Only with `ballista.planner.coalesce.enabled=true`, which is off by default. 
A shuffle that fits in the coalesce target is now read as a single partition, 
so its stage runs as one task. The out-of-order `ORDER BY` results and the 
passthrough panic described above are fixed. There are no API or configuration 
changes.
   


-- 
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]

Reply via email to