Repository navigation
perf: balance file groups by size for scans with an output ordering - #26458
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #26458 +/- ##
=======================================
Coverage 82.89% 82.90%
=======================================
Files 1154 1154
Lines 457656 457728 +72
Branches 457656 457728 +72
=======================================
+ Hits 379391 379464 +73
+ Misses 55261 55259 -2
- Partials 23004 23005 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working on this. The size-balanced repartitioning approach addresses the skew in ordered scans, but I found a correctness issue with nullable sort keys that needs to be fixed before merging. I've also left two suggestions to improve the test coverage and documentation.
| None, | ||
| self, | ||
| )? | ||
| && balanced.validated_output_ordering() == self.validated_output_ordering() |
There was a problem hiding this comment.
The ordering check can accept an invalid merge when sort keys contain NULLs, because the min/max validation ignores null counts. For example, with ASC NULLS LAST, combining [1, NULL] and [2, 3] can place NULL before non-null values while still passing validation. Please add a null-aware check that falls back when ordering cannot be proven, including unknown null counts, without rejecting valid singleton groups. Also add regression tests for both NULLS FIRST and NULLS LAST, including a SQL test that reads rows from both files and verifies the results.
There was a problem hiding this comment.
This is already reachable on main without this PR: with [1, NULL] and [2, 3] in two files declared WITH ORDER (a ASC NULLS LAST) and target_partitions = 1, both files are read in one partition, the sort is removed, and the query returns 1, NULL, 2, 3. #26280 (for #26278) fixes it in MinMaxStatistics, which is the check this PR relies on: it puts NULLs into the file bounds when a file may contain them, treats unknown null counts as possibly NULL, and leaves single-file groups alone. So I'd rather build on that than add a second check here.
I checked this PR on top of #26280. Once it merges I'll rebase and add a unit test where the balanced split would put a file with NULLs after another file under NULLS FIRST (it is rejected and the ordered split is used), plus sqllogictest cases with two files for NULLS FIRST and NULLS LAST that read the rows back, with the NULLs at the allowed end (ordering kept) and at the wrong end (sort added).
| 100 | ||
| 200 | ||
|
|
||
| # explain should not have any groups with more than one file |
There was a problem hiding this comment.
The nearby comment says EXPLAIN should never contain multi-file groups, but the updated plan intentionally includes one. Could you update it to explain that groups may span files as long as their concatenation preserves the declared ordering?
There was a problem hiding this comment.
While looking into this I found that the updated plan here was itself wrong. The table declares int over files that store bigint, and in that case the file statistics come back with NULL min/max values and no null count, so the check accepted [1.parquet (100, 200), 2.parquet (1..5)] as one sorted group. The results only looked right because 2.parquet:0..6 reads no rows. On top of #26280 that split is rejected and this plan goes back to what it is on main, so the comment is accurate again. I'll add a separate case whose files are in value order, which shows a group spanning two files and explains when that is allowed.
| let ordering = config.output_ordering.first().cloned(); | ||
| assert_eq!(config.validated_output_ordering().len(), 1); | ||
|
|
||
| assert!(DataSource::repartitioned(&config, 4, 0, ordering)?.is_none()); |
There was a problem hiding this comment.
The current rejection test only covers a case where both repartitioning strategies return None, so it doesn't verify that the ordered fallback can succeed. Could you add a mixed-group test using [[f1(8000, 0..9), f2(8000, 10..19)], [f3(1000, 5..15)]] with four target partitions, and verify that the balanced candidate is rejected while the ordered fallback returns Some with the first group intact, f3 split, and ordering preserved?
There was a problem hiding this comment.
Added repartition_falls_back_to_ordered_split_when_rebalancing_breaks_order with this layout and 4 target partitions. The balanced split is rejected, and the ordered split keeps [f1, f2] as the first group, splits f3 into the other three groups, and keeps the ordering.
…che#26434) ## Which issue does this PR close? - Closes apache#24272. - Makes apache#24215 moot (the limit nodes no longer carry an ordering to lose). ## Rationale for this change `ORDER BY ... LIMIT` over a parquet table declared `WITH ORDER` can return the wrong rows with default settings: ```sql -- 8 sorted files plus one backfill file holding the smallest values SET datafusion.execution.target_partitions = 8; CREATE EXTERNAL TABLE t STORED AS PARQUET WITH ORDER (a ASC) LOCATION '...'; SELECT a FROM t ORDER BY a LIMIT 3; -- returns 0, 1, 2; min(a) is -100000 ``` The plan is `SortPreservingMergeExec: fetch=3 -> DataSourceExec: output_ordering=[a ASC], limit=3`. `LimitPushdown` pushes the merge's fetch into the scan and, because its own `preserve_order` flag defaults to `false`, calls `with_preserve_order(false)`. That overwrites the `true` the scan was built with, which: - turns on work stealing: the partitions share one file queue, so a partition can read a higher file before a lower one and its stream is no longer sorted (the wrong rows above); - turns on parquet limit pruning: the scan may skip a partially matching row group for a later fully matching one (apache#24272). Whether a scan must keep its file order is passed through six places today: `EnforceSorting` sets `required_ordering` on limit nodes, `LimitPushdown` copies it into a bool, `ExecutionPlan::with_preserve_order` forwards it through projection/filter/coalesce/merge, `DataSource::with_preserve_order` sets `FileScanConfig::preserve_order`, and the file stream and parquet opener read it. Any place that drops it means "reorder freely" (apache#24173, apache#24215, apache#24272). The scan already knows the answer: it advertises its output ordering, and every parent that depends on order relies on exactly that. ## What changes are included in this PR? - `FileScanConfig::preserves_file_order()`: a scan with an output ordering always reads its files in order. Work stealing and parquet limit pruning use only this. - `LimitPushdown` no longer tracks or pushes order sensitivity. - Deprecated, no longer used: `ExecutionPlan::with_preserve_order`, `DataSource::with_preserve_order` (the 5 forwarding overrides are removed), and `GlobalLimitExec`/`LocalLimitExec::required_ordering` / `set_required_ordering` (the field is removed; the protobuf field is ignored on read and no longer written). - Upgrade guide entry. ## What is the testing strategy for this PR? - `limit_pruning.slt`: two new cases, both fail on `main` (`0..4` instead of `-10..-6`; `4, 5, 6` instead of `2, 3, 4`). - Unit test `output_ordering_keeps_files_in_order` in `file_scan_config`. - Updated tests that asserted the removed plumbing (core physical optimizer, proto). Benchmark: TPC-H SF10 `lineitem` declared `WITH ORDER (l_orderkey)`, release-nonlto, same commit with and without this change, median of 10 runs over two interleaved rounds (ms): | Query | pushdown_filters | main | this PR | |---|---|---|---| | Q1 `ORDER BY l_orderkey LIMIT 10` | off / on | 19.5 / 19.0 | 19.0 / 19.0 | | Q2 `WHERE l_quantity > 49 ORDER BY l_orderkey LIMIT 10` | off / on | 28.5 / 20.0 | 28.0 / 20.0 | | Q3 `WHERE l_shipdate > '1998-11-25' ORDER BY l_orderkey LIMIT 10` | off / on | 35.0 / 29.0 | 37.5 / 29.0 | | Q4 `WHERE l_quantity > 49 LIMIT 10` | off / on | 18.0 / 17.5 | 17.0 / 17.5 | | Q5 `WHERE l_shipdate > '1998-11-25' LIMIT 10` | off / on | 27.5 / 23.5 | 26.5 / 22.5 | A rerun of Q3 (off) with 15 interleaved runs each gave 37-42 ms on main and 36-41 ms with this PR. Scans with a declared ordering already read files in order whenever no limit is pushed into them (the builder sets `preserve_order`), so this PR only removes the reordering on the path where a limit is pushed in; tables without a declared ordering are unaffected. ## Are there any user-facing changes? - Wrong results fixed for `ORDER BY ... LIMIT` over declared-ordering file scans. - A plain `LIMIT` (no `ORDER BY`) over a declared-ordering table now keeps file order, so it no longer uses work stealing or limit pruning. Tables without a declared ordering are unchanged. With uneven file groups this costs time: 16 sorted files (4 x 8M rows, 12 x 0.5M), 8 partitions, `WHERE b > 0.999999 LIMIT 10` takes 12 -> 14 ms with `pushdown_filters` off and 4 -> 6 ms with it on (about 2x on @adriangb's machine). The same uneven groups already slow every non-`LIMIT` query over such tables on `main` (`sum(a + b) WHERE b > 0.5`: 233 ms vs 93 ms without a declared ordering). The cause is count-based file grouping for ordered scans, not missing work stealing; apache#26458 balances ordered file groups by size (228 -> 78 ms on `main`, and the `LIMIT` case 11 -> 8 ms). - API deprecations listed above (`api change` label).
d8d0eb3 to
dd563b2
Compare
kosiew
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. The size-balanced repartitioning approach is appropriate, and the additional tests cover the ordering correctness concerns raised in the previous review.
The architectural boundary is sound: ordered scans can adopt balanced file groups only when their validated output ordering is preserved. Otherwise, the existing ordering-aware splitter remains responsible for safe repartitioning. This improves workload distribution without weakening the scan's ordering contract.
The previous correctness blocker is resolved by the shared statistics fix in the rebased branch. The NULL-handling, statistics, and fallback regression coverage addresses the remaining concerns. I have no further blocking findings. Approved.
|
Thanks folks! |
Which issue does this PR close?
Rationale for this change
A scan with a declared ordering keeps the count-based file groups from
split_files, while an unordered scan is cut into size-balanced byte ranges. With uneven files, one partition of an ordered scan gets most of the data and every query over the table waits for it (see the issue: 233 ms ordered vs 93 ms unordered for the same full scan).What changes are included in this PR?
FileScanConfig::repartitioned: when the scan has an output ordering, first ask the file source for the split it would use without an ordering (the size-balanced split, which keeps files in group order and only moves the group boundaries). Use it only ifvalidated_output_ordering()is unchanged, that is, statistics prove every new group is still sorted. Otherwise fall back to the current order-preserving split.split_groups_by_statisticsinterleave value ranges, so the re-cut fails validation and they are kept as they are.Noneand keep today's behavior.What is the testing strategy for this PR?
repartition_balances_ordered_groups: groups[[8000 B, 8000 B], [1000 B, 1000 B]]become four 4500 B groups and keep the ordering.repartition_keeps_ordered_groups_when_rebalancing_breaks_order: overlapping files across the old boundary keep the old groups. Fails if the validation check is removed.repartition_falls_back_to_ordered_split_when_rebalancing_breaks_order:[[f1, f2], [f3]]withf3overlapping both; the balanced split is rejected and the ordered split keeps[f1, f2]and splitsf3.repartition_keeps_ordered_groups_when_rebalancing_misplaces_nulls: the balanced split would read a file with NULLs after another file; it is rejected.repartition_scan.slt: a newordered_filestable whose files are in value order gets size-balanced groups, one of them spanning both files, and returns the rows in order. The existing ordered plan is unchanged, because its statistics cannot prove the files are sorted.parquet_sorted_statistics.slt: two files with NULLs for NULLS FIRST and NULLS LAST, read in one partition. When the NULLs would be in the wrong place the plan keeps a sort, otherwise the ordering is used; both read the rows back.parallelization_multiple_filessnapshot updated: at 8 partitions each group is still one range of one file, only the group order changes; the 3-partition case keeps its old groups because the balanced split would put two files without statistics in one group.Benchmarks (release-nonlto, median of 5 interleaved runs, ms). 16 sorted files (4 x 8M rows, 12 x 0.5M),
WITH ORDER (a ASC),target_partitions = 8:SELECT sum(a + b) FROM t WHERE b > 0.5SELECT * FROM t WHERE b > 0.999999SELECT a, count(*) FROM t WHERE b > 0.99 GROUP BY a ORDER BY a LIMIT 5SELECT * FROM t WHERE b > 0.999999 LIMIT 10SELECT * FROM t WHERE b > 0.999 ORDER BY a LIMIT 10SELECT * FROM t ORDER BY a LIMIT 10TPC-H SF10
lineitem(12 even files) declaredWITH ORDER (l_orderkey), 5 queries x pushdown on/off, two interleaved rounds: all within noise (for exampleORDER BY l_orderkey LIMIT 10: 19-20 vs 18 ms;WHERE l_shipdate > '1998-11-25' ORDER BY l_orderkey LIMIT 10: 35-37 vs 35-36 ms).Worst case: when the old groups were already balanced, the new split moves the boundaries and can leave a small byte range of a file in a group (the new
repartition_scan.sltplan has1.parquet:516..521), as unordered scans already do.Are there any user-facing changes?
EXPLAIN output for ordered file scans shows byte-range file groups more often. No API changes.