NoahKusaba opened a new issue, #2578:
URL: https://github.com/apache/datafusion-ballista/issues/2578

   **Is your feature request related to a problem or challenge? Please describe 
what you are trying to do.**
   
   `SortShuffleWriterExec` keeps every input batch and records a
   `(batch_idx, row_idx)` pair per row. Only when it spills or finishes does it
   build each output batch, gathering the rows with `interleave_record_batch`
   (#1598, #1432). This keeps memory flat however many partitions there are.
   
   On large shuffles that gather is slow. By the time it runs, the buffered
   batches are out of CPU cache, and each output batch picks its rows out of as
   many input batches as there are partitions, a few rows from each, so almost
   every row is a cache miss.
   
   Today (simplified from `buffer.rs` and `partitioned_batch_iterator.rs`):
   
   ```rust
   // 1. As each input batch arrives: keep it, and note where its rows go.
   for (p, rows) in per_partition_rows.iter().enumerate() {
       for &r in rows {
           indices[p].push((batch_idx, r)); // 8 bytes written per row
       }
   }
   batches.push(batch);
   
   // 2. At spill or end time, possibly hundreds of MB later: build each
   //    partition's output batches by gathering its rows, then write them.
   for p in 0..num_partitions {
       for chunk in indices[p].chunks(batch_size) {
           // rows scattered across ~num_partitions long-evicted input batches
           let out = interleave_record_batch(&batches, chunk)?;
           writers[p].write(&out)?; // IPC stream, or the spill file
       }
   }
   ```
   
   Proposed:
   
   ```rust
   // 1. As each input batch arrives, while it is still in cache: copy each
   //    partition's rows into that partition's coalescer, which fills
   //    `batch_size`-row batches and sets each one aside when full.
   for (p, rows) in per_partition_rows.iter().enumerate() {
       let taken = take_record_batch(&batch, &UInt32Array::from(rows.clone()))?;
       coalescers[p].push_batch(taken)?;
   }
   // `batch` is dropped here.
   
   // 2. At spill or end time: the batches are already built; just write them.
   for p in 0..num_partitions {
       coalescers[p].finish_buffered_batch()?; // the last, partly filled batch
       while let Some(out) = coalescers[p].next_completed_batch() {
           writers[p].write(&out)?;
       }
   }
   ```
   
   Both versions hold all the row data in memory until they spill or finish.
   What differs is how it is arranged, and when rows are sorted into partitions:
   
   | | Today | Proposed |
   |---|---|---|
   | Holds | Whole input batches, partitions mixed, plus an 8-byte index per 
row | Each partition's rows in its own batches; input batches are dropped |
   | Sorts rows into partitions | At spill or end time, by gathering from cold 
memory | As each batch arrives, while it is in cache |
   | Extra memory | The indices (8 bytes a row) | One partly filled batch per 
partition, however few rows it has |
   
   So the data takes about the same memory either way. The speedup comes from
   copying each row while its batch is still in cache rather than long after.
   
   Example (TPC-H SF10, one executor): shuffling 60M `lineitem` rows
   (`l_orderkey, l_quantity`) into 12 partitions takes 1.75 s, while the scan
   alone takes 0.31 s. The gather is the largest part of the shuffle write
   (~0.48 s per input partition), followed by encoding (~0.44 s) and recording
   the index pairs (~0.23 s).
   
   **How it got this way**
   
   - **Before #1598**, the writer split each input batch by partition as it
     arrived, using DataFusion's `BatchPartitioner`. With 200 partitions an
     8192-row batch became ~41-row pieces, each buffered, spilled and written as
     its own IPC batch: heavy per-batch overhead, poor compression and slow
     reads (#1432).
   - **#1598** fixed that by adopting DataFusion Comet's approach: keep whole
     batches plus row indices, and gather full `batch_size` batches only at
     spill or write time. Batches came out the right size, with less memory
     overhead as the partition count grows. The trade-off it didn't measure is
     the cold-memory gather above.
   - **#1626** then found that the gather kept every source string buffer alive
     after the DataFusion 52 upgrade (~90× slower on string-heavy data), and
     added compaction after each gather.
   
   This proposal splits on arrival again, but into a `BatchCoalescer` per
   partition, so output batches are still full-size (#1432's problem stays
   fixed). That trades #1598's flat memory for per-partition buffers, which is
   why it only switches when they fit.
   
   **Prior art**
   
   - **Comet's "immediate mode"** (apache/datafusion-comet#3845, #3855, both
     closed in April 2026): split each batch on arrival into reusable
     per-partition builders. It was opt-in 
(`spark.comet.exec.shuffle.nativeMode`,
     default `buffered`), with an automatic mode "based on the number of output
     partitions" planned but never built. It was set aside because the
     per-partition builders reserve memory before any data arrives, which does
     not scale to the thousands of partitions common in Spark (Comet #887). A
     sort-based follow-up (#3940) was 36% faster than buffered at 200 partitions
     but used 22 GiB, against 7.4 GiB, at 800. Comet's hash shuffle still
     buffers and gathers, like Ballista today.
   - **Gluten ships both** (apache/gluten#6475): a hash shuffle writer that
     scatters rows on arrival into pre-allocated, reused per-partition buffers
     (the default), and a buffered sort shuffle writer it switches to above a
     user-set partition-count or column-count threshold. The sort writer was
     1.33× faster at SF1000 with 40,000 partitions.
   
   This proposal is the automatic mode Comet planned, in the spirit of Gluten's
   hybrid. It answers Comet's memory concern three ways:
   
   - it starts in today's layout, so nothing is reserved per partition up front;
   - it switches only once a batch per partition's worth of rows has arrived,
     and only if the partly filled batches fit a memory budget (not a partition
     count);
   - it switches back if memory pressure appears later.
   
   Measured peak executor RSS was lower than today at 12 partitions (3.44 → 3.18
   GB on TPC-H q3, q9, q17, q18 and q21). The per-partition `take` still makes
   many small allocations; gathering straight into the coalescer would avoid 
that
   (apache/arrow-rs#8957).
   
   **Describe the solution you'd like**
   
   Copy each partition's rows out of an input batch right away, while the batch
   is still in cache, into a per-partition `BatchCoalescer`, then drop the
   batch. Spill and the final write then emit those batches as they are.
   
   The catch, and the reason #1598 avoided it: each partition holds a partly
   filled batch, so memory grows with `partitions × batch_size × row width`.
   So keep today's layout as the default, and switch an input over only when it
   is safe:
   
   1. it has seen enough rows to fill a batch per partition, and
   2. the partly filled batches fit a budget (the smallest of ¼ of the
      per-task spill budget, ¼ of the input's share of the memory pool, and
      64 MiB).
   
   If a switched input later spills mostly partly filled batches (memory
   pressure), it switches back for good.
   
   These defaults are reasoned, not tuned:
   
   - **A quarter** of the limit keeps most of the memory for full batches, so a
     spill writes mostly full batches, not near-empty ones.
   - **The pool share** stops one input from claiming more than its part of the
     pool. Without it, a 2-column shuffle into 200 partitions in a 256 MB pool
     spilled 7,390 times, against 88 today, and ran 1.9× slower.
   - **64 MiB** is a quarter of the default 256 MiB task budget, so turning the
     budget off behaves the same as leaving it at the default.
   - **Waiting for a batch's worth of rows per partition** means each partly
     filled batch is likely to fill up.
   
   They cause no regressions on TPC-H SF10 (12 and 200 partitions), on probes
   up to 1,000 partitions, on small pools or on `benches/sort_shuffle.rs`, but
   other values may do better.
   
   Shuffle stats (`num_bytes`) should also report bytes used rather than
   allocated capacity, since a partly filled batch would otherwise look full to
   AQE.
   
   Prototype results, single node, TPC-H SF10:
   
   - −8.5% total at 12 partitions; shuffle-heavy queries (q3, q4, q12, q17,
     q21, q22) −10 to −23%.
   - −22% on q1–q8 at 200 partitions.
   - No change on `benches/sort_shuffle.rs` (#1598's wide, 200-partition case),
     which keeps today's layout.
   - Same AQE join decisions on all 22 queries; all pass `--verify`.
   
   **Describe alternatives you've considered**
   
   - Always split: 3× slower on wide rows at 200 partitions (7,390 spills vs
     64).
   - Reorder each batch by partition on arrival, as Spark does: copies rows
     twice; 19–38% slower on `benches/sort_shuffle.rs`.
   - Write each partition to the file as soon as every input has encoded it: no
     gain when the CPU is already saturated.
   
   **Additional context**
   
   Planned as three PRs:
   
   1. Refactor, no behaviour change: `BufferedBatches` gets `drain()` and
      `memory_size()`. (#TBD)
   2. Shuffle stats report bytes used. (#TBD)
   3. The split-on-arrival layout and its switching rules. (#TBD)
   
   **Expected effect at SF100 / SF1000 (not measured):** smaller. Whether an
   input switches depends on the partition count and row width, not on data
   size, but larger runs usually use more partitions, so fewer shuffles fit the
   64 MiB cap. At 256 partitions the cut-off is about 32 bytes a row: a key and
   a decimal (~24 bytes) would still switch, while wider rows, or any column of
   long strings, would keep today's layout and run exactly as now. Where it
   does switch, the gain should hold or grow, since a bigger buffer makes
   today's gather colder.
   
   Open questions: should the 64 MiB cap and ¼ factor be configurable, perhaps
   scaling with executor memory, which would extend the gain to larger runs? All
   numbers are single-node SF10; a multi-node run at larger scale would help.
   


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