Repository navigation
fix(core): reserve the sort-shuffle writer's encoded output - #2803
NoahKusaba wants to merge 4 commits into
Conversation
db14f68 to
70d855a
Compare
4b97cab to
84d660b
Compare
At the end of input the writer encodes its buffered rows into one IPC buffer per output partition. The buffered batches stay alive until the drain ends, so they and the encoded bytes are held at once, but only the batches were reserved, and the reservation kept their size until the shuffle file was written. With 200 output partitions and 2M rows of strings, the writer held 82 MiB more than it reserved. Grow the reservation by each partition's encoded bytes once that partition is encoded. When the pool refuses them, append the encoded stream to the partition's spill file instead, with the new SpillManager::spill_stream. write_ipc_stream produces the same bytes for both, so this is exactly what spilling the rows would have written. Only the partition just encoded is ever held beyond what the pool grants, and afterwards the reservation holds just the encoded bytes.
84d660b to
037fe55
Compare
andygrove
left a comment
There was a problem hiding this comment.
Thanks for this, it's a really clear write-up and the per-partition handling is a nice touch. I traced the reservation through the drain, the spill path and write_task_consolidated, and the rows, stats and pool accounting all line up. The byte-equality test for spill_stream is a great way to pin the format.
Claude's review recommends approving, with just the two small nits below. I want to go through this one manually first to make sure I fully understand it, so I'm holding off on the approval for now.
The red Labeler trigger check looks like a workflow quoting issue unrelated to this change. A rebase should clear it.
| let (buf, stat) = write_ipc_stream(Vec::new(), schema, opts, batches)?; | ||
| encoded[partition] = buf; | ||
| stats[partition] = stat; | ||
| if reservation.try_grow(buf.capacity()).is_ok() { |
There was a problem hiding this comment.
Nice that this reserves the real allocation rather than len(). One thought: buf grows by doubling inside write_ipc_stream, so its capacity can be close to twice the bytes written. That means a partition that would fit can still get spilled. Would a buf.shrink_to_fit() before the try_grow be worth it? It costs a copy, but the writer would then hold less memory for the rest of the task and ask the pool for less. Happy either way.
There was a problem hiding this comment.
Left it out: under mimalloc, realloc keeps the same block when shrinking to at least half its size, so shrink_to_fit frees nothing but lowers capacity(), and we'd under-reserve. Reserving capacity() over-counts slightly, the safe side. Added a comment.
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn spills_when_the_pool_cannot_hold_the_encoded_output() -> Result<()> { |
There was a problem hiding this comment.
This test depends on the buffered rows coming to just under 193 KiB. If the BufferedBatches accounting ever grows a little, the input loop spills instead. The test would still pass but would no longer cover the encode path. encoding_spills_the_partitions_the_pool_refuses covers that path directly, so this is minor. Could the test also check that the spill came from encoding, for example that the input loop recorded no spill event? Or a comment saying the margin is tight would help the next person who changes the accounting.
There was a problem hiding this comment.
Both spill paths write identical bytes, so the test now asserts its precondition: the buffered bytes, computed with the writer's own accounting, fit the pool. If the accounting grows, the test fails instead of silently covering the input loop.
spills_when_the_pool_cannot_hold_the_encoded_output now asserts that the buffered rows, computed with the writer's own accounting, fit the pool, so an accounting change fails the test instead of moving the spill into the input loop. Note why the encoded output reserves capacity() without shrink_to_fit, and hoist the test module's inline imports.
Which issue does this PR close?
No issue. Found while measuring the sort-shuffle writer's memory accounting
for #2774.
Rationale for this change
At the end of input, the sort-shuffle writer encodes its buffered rows into one
IPC buffer per output partition. The buffered batches stay alive until the last
partition is gathered, so the writer holds the batches and the encoded output
at the same time. Only the batches were reserved in the memory pool, so the
pool could grant the encoded output's memory to other consumers.
Measured with a counting allocator, 200 output partitions and 2M rows with a
string column: the writer held 287 MiB at its peak but had reserved 205 MiB,
82 MiB short. With this change it reserves 263 MiB at its peak. Most of the
remaining gap is allocator size-class rounding, which no reservation counts.
What changes are included in this PR?
encode_buffered_partitionsreserves each partition's encoded bytes as soon asthat partition is encoded:
spill file instead, with a new
SpillManager::spill_stream. Since perf: default the shuffle compression codec to zstd #2805, thein-memory encode and
spill_batchesboth write throughwrite_ipc_stream,so this is byte for byte what spilling the rows would have written.
consolidated write consumes them.
So only the partition just encoded is ever held beyond what the pool grants,
and only until the pool's answer. Every partition is handled the same way, so a
small partition after a refused one can still stay in memory.
A spill during encoding counts as one memory-pool spill event, with its own
debug log line, and is timed in
spill_time.spill_batchesand the newspill_streamshare opening the spill file and updating the counts.Alternatives considered
encoding is the buffered size. With compression that is several times the
encoded bytes, so the writer would spill whole buffers that would have fit.
it. Simpler, but it leaves one partition's encoded bytes over the pool's
limit until the shuffle file is written.
Are there any user-facing changes?
No API or configuration changes.
spill_streamispub(super). Two behaviourchanges:
did.
spill_countand disk I/O can therefore go up for jobs that run shortof pool memory.
size, which is usually smaller with compression. Other inputs of the same
task can use that memory sooner.
Without memory pressure the write path is unchanged apart from one pool call per
output partition.
How was this tested?
encoding_spills_the_partitions_the_pool_refuses: encodes 4 partitions of1000, 2000, 100 and 900 rows with room for partitions 0 and 2 only. It checks
which partitions stay in memory and which spill, each partition's encoded
bytes, and the bytes left reserved. So the small partition 2 stays in memory
after partition 1 is refused. An unlimited pool keeps every partition in
memory.
spills_when_the_pool_cannot_hold_the_encoded_output: runs the writerthrough the existing
run_round_triphelper with a 193 KiB pool. That poolholds the 192 KiB of buffered rows but not the encoded output as well, so
only encoding spills. The helper checks that the writer spills, that every
row comes back, and that the pool ends empty.
spill_stream_writes_what_spill_batches_would: two spills each throughspill_batchesand throughspill_streamproduce the same spill file bytesand the same counts.
resize, spilling every partitionafter the first refusal, or skipping
spill_stream's write or its countingeach makes at least one of these tests fail.
cargo fmt,cargo clippy --all-targets --workspace --exclude ballista-cli --all-features -- -D warnings,cargo doc --document-private-itemswith-D warnings, andcargo test -p ballista-core --lib(490 passed).TPC-H
SF10 parquet, one local scheduler and one executor (12 vcores,
--memory-pool-size 4GB),--partitions 12 --iterations 3, release builds ofmainat 04455a1 and this PR. Average of 3 iterations, in seconds:The per-query differences are noise. Q2 to Q4 look faster only because
mainran first and its first iterations were slower (Q2: 1.140 s, then 0.648 s). Q12 differed by +24% in that run. Rerun alternating main, PR, main, PR it averaged 1.219, 1.158, 1.162 and 1.169 s, so that was noise. Both builds logged the same 36 sort-shuffle spills (one memory-pool spill event per task, with matching byte counts), so on this workload encoding never spilled.