Skip to content

perf: spill byte arrays as lengths and coalesce spill writes - #26960

Open
comphead wants to merge 5 commits into
apache:mainfrom
comphead:spill-byte-array-lengths
Open

comphead wants to merge 5 commits into
apache:mainfrom
comphead:spill-byte-array-lengths

Conversation

@comphead

@comphead comphead commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Compressed spill files are mostly string offsets, which zstd and lz4 barely compress, see #26627. Spilling is also slower than it needs to be: a batch of the table in #26627 takes about 35 write calls, one per IPC buffer.

This replaces #26698, which needed spill reads to skip validation (#26688).

What changes are included in this PR?

  • With lz4_frame or zstd, each top level Utf8, LargeUtf8, Binary and LargeBinary column is spilled as a struct of its value lengths and of its value bytes as one run-end encoded value. Both are valid arrays, so the IPC reader decodes them as usual before the spill reader rebuilds and validates the original arrays. Files whose first batch has fewer than 128 rows keep their offsets, since the struct and run-end metadata would cost more than the lengths save. Uncompressed spills keep their layout.
  • The small IPC buffers of a batch are copied into one write, so a spilled batch takes a few writes instead of one per buffer.

Benchmarks

Apple M5 Max, interleaved runs against main.

The reproducer from #26627 (datafusion-cli -m 100m, median of 7):

codec main this PR
uncompressed 210 ms, 171.7 MB spilled 205 ms, 171.7 MB spilled
lz4_frame 252 ms, 54.0 MB spilled 240 ms, 17.1 MB spilled
zstd 306 ms, 32.0 MB spilled 259 ms, 8.9 MB spilled

The same query with small batches (batch_size 64, 128 and 256, median of 3), spilled bytes and time:

rows per batch lz4_frame, main → this PR zstd, main → this PR
64 83.9 → 83.9 MB, 11.8 → 10.0 s 62.1 → 62.1 MB, 11.8 → 11.1 s
128 70.3 → 52.5 MB, 3.35 → 2.92 s 44.9 → 44.3 MB, 3.67 → 3.06 s
256 63.3 → 36.6 MB, 1.40 → 1.18 s 37.6 → 27.9 MB, 1.70 → 1.28 s

sort_tpch queries at SF1 on Utf8 columns (one partition, -m 200m, median of 5), spilled bytes and time against main:

query uncompressed lz4_frame zstd
Q3 -0.2% 92.4 → 78.1 MB, +0.1% 61.4 → 50.5 MB, +1.6%
Q7 -4.0% 306.3 → 230.2 MB, -1.5% 171.3 → 122.6 MB, -3.5%
Q10 -3.5% 398.9 → 309.7 MB, -1.2% 228.2 → 169.2 MB, -2.8%

In the spill_io benchmark, writing and reading spill files is 2-14% faster uncompressed, 4-8% faster with zstd and unchanged to 10% faster with lz4, and the string datasets spill 27-41% smaller.

spill_io results and setup

cargo bench -p datafusion-physical-plan --bench spill_io, median of three alternating runs per build.

benchmark main this PR spilled, main → this PR
q2/uncompressed 3.81 ms 3.73 ms (-2.1%) unchanged
q2/zstd 17.42 ms 16.70 ms (-4.1%) unchanged
q2/lz4_frame 11.60 ms 10.64 ms (-8.3%) unchanged
q16/uncompressed 9.68 ms 8.80 ms (-9.1%) unchanged
q16/zstd 41.03 ms 37.84 ms (-7.8%) 4.4 MB → 2.6 MB
q16/lz4_frame 23.44 ms 23.43 ms (-0.1%) 7.8 MB → 5.2 MB
q20/uncompressed 5.68 ms 5.46 ms (-3.8%) unchanged
q20/zstd 26.82 ms 25.58 ms (-4.6%) unchanged
q20/lz4_frame 16.84 ms 15.23 ms (-9.6%) unchanged
wide/uncompressed 26.19 ms 22.60 ms (-13.7%) unchanged
wide/zstd 102.29 ms 95.98 ms (-6.2%) 11.6 MB → 8.2 MB
wide/lz4_frame 57.86 ms 56.12 ms (-3.0%) 19.9 MB → 14.6 MB

The sort_tpch runs set:

SET datafusion.execution.target_partitions = 1;
SET datafusion.execution.parquet.schema_force_view_types = false;
SET datafusion.execution.spill_compression = 'zstd'; -- or 'lz4_frame', 'uncompressed'

What is the testing strategy for this PR?

  • aggregate_memory_spill.slt spills Utf8, LargeUtf8, Binary and LargeBinary group keys with nulls under zstd and lz4_frame and checks their values.
  • test_spill_lengths_round_trip covers sliced and empty batches, test_spill_lengths_compress checks that a zstd spill of similar strings is under a quarter of plain zstd IPC, and test_spill_lengths_skipped_for_small_batches checks the 128-row threshold.
  • test_async_ipc_stream_writer checks that each batch takes one write.

Are there any user-facing changes?

Smaller compressed spill files and faster spilling. No configuration or API changes.

`StreamEncoder` returns every buffer of a batch and its padding as a
separate `Buffer`, and the spill writers wrote them one by one. For file
backed spills that is one `write` system call per buffer, about 35 for a
batch of six string and integer columns. Copy runs of buffers under 64 KB
into one, so a spilled batch typically takes a single write.
Closes apache#26627.

Offsets are a running total, so zstd keeps about 2.5 of every 4 bytes and
lz4 all of them, while the lengths of similar values repeat. Compressed
spill files now store each top level `Utf8`, `LargeUtf8`, `Binary` and
`LargeBinary` column as a struct of its lengths and of its value bytes as a
single run. Both are valid arrays, so the IPC reader decodes them as usual
and the spill reader rebuilds the offsets with a running sum.
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Oct 30, 2026
comphead

This comment was marked as resolved.

- `FileSpillWriter` writes through a 256 KB `BufWriter` instead of the IPC
  encoder copying small buffers together. One bounded buffer is reused, large
  buffers are not copied, and uncompressed spills benefit too.
- Byte arrays read back from compressed spill files are rebuilt with
  `OffsetBuffer::try_from_lengths` and `GenericByteArray::try_new`, so a
  corrupted file fails instead of producing invalid arrays.
- `IPCStreamEncoder` keeps the encoded schema and reuses its struct fields
  for every batch.
- `aggregate_memory_spill.slt` spills byte array keys with zstd and lz4.
@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) execution Related to the execution crate labels Oct 31, 2026
@comphead comphead changed the title perf: spill byte arrays as lengths and coalesce spill writes perf: spill byte arrays as lengths and buffer spill file writes Oct 31, 2026
@comphead
comphead marked this pull request as ready for review November 2, 2026 16:41

@jayzhan211 jayzhan211 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@comphead thanks for working on this, the size wins at normal batch sizes are great. Two things to address before merging, details inline: the per-file write buffer is memory the pool can't see, and small batches now spill larger than on main.

.map_err(DataFusionError::IoError)?;
Ok(Box::new(FileSpillWriter {
file,
file: BufWriter::with_capacity(SPILL_WRITE_BUFFER_SIZE, file),

@jayzhan211 jayzhan211 Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@comphead thanks for this! with_capacity allocates the 256 KB up front, and every open spill writer keeps it until it finishes, without the memory pool knowing. A preserve-order RepartitionExec keeps one spill file open per (input, output) pair, so this grows quadratically with partitions. With 2 inputs × 128 outputs and a 512 KB pool, peak heap went from 2.35 MB on main to 69.5 MB with this PR, and aggregate_memory_spill.slt now reports about twice the peak-memory drift. Your first commit's per-batch coalescing avoids this, since its copy only lives for one write. I ran it in place of the BufWriter: heap is back to main's level, the spill tests pass, and writes match or beat the BufWriter in most 8192-row cases.

--- a/datafusion/execution/src/disk_manager.rs
+++ b/datafusion/execution/src/disk_manager.rs
-            file: BufWriter::with_capacity(SPILL_WRITE_BUFFER_SIZE, file),
+            file,
--- a/datafusion/physical-plan/src/spill/mod.rs
+++ b/datafusion/physical-plan/src/spill/mod.rs
-        Ok((batch.num_rows(), bytes, buffers))
+        Ok((batch.num_rows(), bytes, coalesce_buffers(buffers)))
 ...
-        Ok((bytes, buffers))
+        Ok((bytes, coalesce_buffers(buffers)))
+
+/// Buffers at least this large are written on their own, see [`coalesce_buffers`]
+const COALESCE_BUFFERS_BELOW: usize = 64 * 1024;
+
+/// Copies runs of small buffers into one, so that writing a batch takes a few
+/// writes instead of one per IPC buffer and padding. The copy lives only for
+/// the write, unlike a buffer kept by every open spill file.
+fn coalesce_buffers(buffers: Vec<Buffer>) -> Vec<Buffer> {
+    let mut coalesced = Vec::new();
+    let mut small: Vec<u8> = Vec::new();
+    for buffer in buffers {
+        if buffer.len() < COALESCE_BUFFERS_BELOW {
+            small.extend_from_slice(buffer.as_slice());
+            continue;
+        }
+        if !small.is_empty() {
+            coalesced.push(Buffer::from_vec(std::mem::take(&mut small)));
+        }
+        coalesced.push(buffer);
+    }
+    if !small.is_empty() {
+        coalesced.push(Buffer::from_vec(small));
+    }
+    coalesced
+}

(Also revert the finish() flush and the BufWriter import in disk_manager.rs.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, agreed. Restored the per-batch coalescing from your diff in 03a6c93, so disk_manager.rs is unchanged now. aggregate_memory_spill.slt reports a 2.2 MB peak-memory drift with it, against 5.6 MB with the BufWriter.

Comment on lines +703 to +705
// Lengths take as much space as offsets when uncompressed
let lengths_schema =
compression_type.and_then(|_| lengths::encode_schema(schema));

@jayzhan211 jayzhan211 Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The struct and run-end wrapper costs about 200 bytes per batch for each byte column, so small batches now spill larger than on main. A preserve-order hash repartition into 128 partitions (about 64 rows per batch) spills 13.5% more with zstd. Files roughly double at 1 row per batch and grow 44–77% at 8 rows. Gating on the file's first batch fixed it in my run: 128 partitions spill the same as main, and the 64-partition case still wins (lz4 14.0 → 12.9 bytes/row). Could you add this along with the boundary test?

+/// Fewer rows per batch than this and the struct and run-end metadata of a
+/// lengths column outweighs what the lengths save, see [`lengths`]
+const MIN_LENGTHS_ROWS: usize = 128;
 ...
-    fn new(schema: &Schema, spill_compression: SpillCompression) -> Result<Self> {
+    fn new(
+        schema: &Schema,
+        spill_compression: SpillCompression,
+        first_batch_rows: usize,
+    ) -> Result<Self> {
 ...
-        let lengths_schema =
-            compression_type.and_then(|_| lengths::encode_schema(schema));
+        let lengths_schema = compression_type
+            .filter(|_| first_batch_rows >= MIN_LENGTHS_ROWS)
+            .and_then(|_| lengths::encode_schema(schema));

Pass gc_batch.num_rows() from both append_batch and append_batch_async in in_progress_spill_file.rs, and usize::MAX from the tests. Boundary test, so the encoding is off at 127 rows and on at 128:

#[test]
fn test_spill_lengths_skipped_for_small_batches() -> Result<()> {
    let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Utf8, false)]));
    for (rows, encoded) in [(127, false), (128, true)] {
        let values = (0..rows).map(|i| format!("item-{}", i / 64));
        let batch = RecordBatch::try_new(
            Arc::clone(&schema),
            vec![Arc::new(StringArray::from_iter_values(values))],
        )?;
        let spill_manager = spill_manager(Arc::clone(&schema), SpillCompression::Zstd);
        let spill_file = spill_manager
            .spill_record_batch_and_finish(std::slice::from_ref(&batch), "Test")?
            .unwrap();
        let file = std::fs::File::open(spill_file.path().unwrap())?;
        let reader = arrow_ipc::reader::StreamReader::try_new(file, None)?;
        let spilled_type = reader.schema().field(0).data_type().clone();
        assert_eq!(spilled_type != DataType::Utf8, encoded, "{rows} rows");
    }
    Ok(())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added MIN_LENGTHS_ROWS and the boundary test as you wrote them, in 03a6c93. The #26627 reproducer with batch_size = 64 now spills the same bytes as main with both codecs, and at 128 rows it still saves 25% with lz4 (70.3 to 52.5 MB) and 1% with zstd. I also set batch_size = 8192 for the byte key case in aggregate_memory_spill.slt, since its 128-row batches sat exactly at the threshold.

- Copy the small IPC buffers of each batch into one write again, instead of
  keeping a 256 KB `BufWriter` per spill file. Those buffers were outside the
  memory pool, and a preserve-order `RepartitionExec` keeps one spill file
  open per input and output partition.
- Write lengths only when the first batch of a file has at least 128 rows.
  Below that, the struct and run-end metadata cost more than the lengths save.
@github-actions github-actions Bot removed the execution Related to the execution crate label Nov 3, 2026
@comphead comphead changed the title perf: spill byte arrays as lengths and buffer spill file writes perf: spill byte arrays as lengths and coalesce spill writes Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spill files are mostly Utf8 offsets, which zstd and lz4 barely compress

2 participants