jayzhan211 opened a new pull request, #26987:
URL: https://github.com/apache/datafusion/pull/26987

   ## Which issue does this PR close?
   
   - Part of #26711.
   
   ## Rationale for this change
   
   A spilled sort or aggregate fails with `ResourcesExhausted` when its rows 
are wide, even though each row fits the pool. Two examples, both failing on 
main (5db4b233ef) and passing with this PR:
   
   - The reproducer in #25851 (`array_agg` over 16 groups, `datafusion-cli -m 
128M`). Main fails with `Failed to allocate additional 179.9 MB for 
FinalHashAggregateStream[0]`. This PR returns 16 groups and 3,000,000 values.
   - The reproducer in #25910 (sort over 64 KiB rows, `-m 12m --mem-pool-type 
fair`). Main fails with `Failed to allocate additional 20.0 MB for 
ExternalSorterMerge[0]`. This PR finishes with 7 spills.
   
   Both have the same cause. Sorted spill runs are written in batches of 
`batch_size` rows, whatever their size in bytes, and the multi-level merge 
reserves twice the largest batch of each run. When two runs don't fit, the 
merge re-spills a run with every batch halved, tracks a smaller batch size for 
each re-spilled run, and scans for runs of single rows (#22945, #23286, 
#25383). Bounding the batches when the runs are written makes all of that 
unnecessary.
   
   #25910 and #25853 each bound spill batches for one operator, the sort and 
the aggregate respectively. This PR applies one bound to every sorted run. It 
takes the batch splitter from #25910 and the output cap approach from #25853, 
and removes the halving re-spill. The merge's admission policy is unchanged.
   
   ## What changes are included in this PR?
   
   The three commits can be reviewed separately.
   
   1. `SpillManager::with_max_batch_bytes`: every batch written through the 
manager is at most this many bytes. The exceptions are a batch of one row, and 
a batch with a column that cannot be split by rows: dictionaries or run-end 
encoded arrays below the top level, unions and list views. Larger batches are 
split with `SpillPieces` (from #25910), which here also splits top-level 
run-end encoded columns.
   2. The sort and both aggregate streams set the bound, and the multi-level 
merge writes its intermediate runs through the same manager:
      - For the sort, the bound is `sort_spill_reservation_bytes / 4` (at least 
64 KiB), so two runs with one batch read ahead fit the workspace the sort 
already retains. This is the formula from #25910.
      - The aggregate retains no workspace, so its bound is also at most an 
eighth of the state of its first spill. The smallest merge, plus as much again 
for the replay, then fits in what it held when memory first ran out. Later 
spills, such as the remainder spilled at the end of the input, don't lower it.
      - A merge of spilled runs ends an output batch at `batch_size` rows or at 
about the bound in bytes, estimated from the average row size of each input 
batch. Merges without a bound skip this estimate.
   3. The merge no longer re-spills runs. Removed: `split_spill_file_in_half`, 
`split_batch_in_half`, the per-run batch-size limit, the single-row scan with 
its retry flags, `MergeStep` and `SpillFilesToMerge`. If two runs don't fit 
with one batch read ahead, the merge returns `ResourcesExhausted`, with the 
size it needed in the message. The aggregate's replay headroom keeps one 
change: with one batch read ahead, the two runs a pass needs are admitted 
against the pool alone, which on main happened only after re-spilling failed. 
Also includes an upgrade guide entry.
   
   ## What is the testing strategy for this PR?
   
   - **`spill_batch_bound.slt`:** spilled sorts and aggregates over wide rows, 
checking results. On main, three of its five queries fail with 
`ResourcesExhausted`: a sort of 64 KiB rows at 16 MB, the #25851 query scaled 
to 4 groups at 12 MB, and a `string_agg` over 600-byte values at 4 MB. The 
other two pass on both: a run-end encoded sort at 32 MB, and the legacy 
aggregate stream at 4 MB.
   - **Unit tests:**
     - `SpillPieces`: row order and the size bound for strings, string views, 
lists, skewed rows, top-level dictionaries and run-end encoded columns; one 
oversized row; a nested dictionary written whole.
     - Merge tests of the removed re-spill are removed. The others are kept; 
those that called the re-spill directly now check the same outcomes without it.
     - New: `bounded_runs_merge_in_a_pool_smaller_than_two_whole_batches` and 
`read_ahead_needs_headroom`.
   - **Fuzz assertion:** in `spilling_fuzz_in_memory_constrained_env`, the 
output batch count of a spilled sort is now checked with `>=` instead of `==`. 
With `sort_spill_reservation_bytes = 1` the bound is 64 KiB, so the output 
comes in more, smaller batches.
   - **Local runs (all pass):**
     - physical-plan lib tests
     - core `memory_limit` tests
     - extended fuzz tests (spilling, sort, aggregate, 
`memory_limit_validation`)
     - the full sqllogictest suite
   - **Aggregate under small pools,** `datafusion-cli`, main vs this PR:
   
     | Values per group | Memory limit | main | This PR |
     |---|---|---|---|
     | 4 × 40 B | 3, 4, 5, 6, 8, 12 MB | ok | ok |
     | 4 × 200 B | 3 MB | `ResourcesExhausted` | ok |
     | 4 × 200 B | 4, 5, 6, 8, 12 MB | ok | ok |
     | 4 × 600 B | 3, 4, 5, 6, 8 MB | `ResourcesExhausted` | ok |
     | 4 × 600 B | 12 MB | ok | ok |
   
     <details><summary>Query</summary>
   
     ```sql
     SET datafusion.execution.target_partitions = 1;
     -- 4 × 200 B: 40000 groups, repeat('x', 200), 160000 rows
     SELECT count(*), sum(length(s)) FROM (
       SELECT k, string_agg(v, ',') AS s
       FROM (SELECT value % 40000 AS k, repeat('x', 200) AS v FROM 
generate_series(1, 160000))
       GROUP BY k
     );
     ```
   
     The 4 × 40 B row uses `value % 100000`, `repeat('x', 40)` and 400000 rows.
   
     </details>
   
     With `enable_migration_aggregate = false` (the legacy stream), each of 
these queries succeeds or fails exactly as on main.
   
   - **Columns that are hard to split:** a sort over 64 KiB rows held in a 
`Struct` of a `Dictionary` column, which `SpillPieces` writes whole, behaves as 
on main: both fail at 12 and 64 MB and finish without spilling at 96 MB. The 
same rows as run-end encoded strings succeed on both at 32, 48 and 64 MB.
   - **Not done yet:**
     - Benchmarks: `sort_tpch` and `external_aggr` under memory limits, 
`sort_preserving_merge`, `spill_io`.
     - Spilled sorts with union or list view columns.
   
   ## Are there any user-facing changes?
   
   Query results don't change, and in every comparison above, a query that 
succeeds on main also succeeds here. Memory behavior changes in two ways:
   
   - Spilled sorts and aggregates with wide rows, or with a small 
`sort_spill_reservation_bytes`, write and emit smaller batches.
   - New public `SpillManager::with_max_batch_bytes` and `max_batch_bytes`. 
Callers of `StreamingMergeBuilder::with_sorted_spill_files` that write their 
own runs no longer get the halving re-spill; they can set the bound instead 
(see the upgrade guide).
   


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