Repository navigation
fix: bound parallel batch sorting in SortExec - #26242
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #26242 +/- ##
========================================
Coverage 82.91% 82.91%
========================================
Files 1155 1155
Lines 458091 458301 +210
Branches 458091 458301 +210
========================================
+ Hits 379823 380008 +185
Misses 55229 55229
- Partials 23039 23064 +25 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
e2e4c5e to
9d0897a
Compare
67093a0 to
1eacb12
Compare
| .map_err(|e| internal_datafusion_err!("Sort concurrency closed: {e}"))?; | ||
| SpawnedTask::spawn(async move { | ||
| // Match the buffered producer's first-batch handoff so spill | ||
| // workspace can be reused while the remaining runs are sorted. |
There was a problem hiding this comment.
I don't think this comment adds much value.
There was a problem hiding this comment.
indeed, removed.
| // current batch, returning workspace before the merge | ||
| // reserves memory for that batch and its cursor. | ||
| let next = stream.next().await; | ||
| Some((batch, (next, stream))) |
There was a problem hiding this comment.
Haven't read the consumer side of this stream yet but here's one observation -
Since the batch is unaccounted, why not re-reserve the memory here in self.merge_pool?
- In total across all permits, there's
batch_size * semaphore size, so that's a lot of unaccounted memory right? - The consumed stream returns all the reserved memory (to the workspace or otherwise) before yielding a batch
There was a problem hiding this comment.
Good catch. Preparation now keeps all sorted output reserved until merging starts. During merging, the one-batch lookahead still temporarily releases the prefetched batch’s reservation so the merge can reuse that workspace.
| #[derive(Debug)] | ||
| struct SortConcurrency { | ||
| /// Bounds preparation futures buffered by each partition. | ||
| limit: usize, |
There was a problem hiding this comment.
Hmm I guess you need this bc there's no API for total semaphore size?
There was a problem hiding this comment.
Yes, you can only easily get the still available permits, but we want the total size. Renamed the field to per_partition_limit to clarify its purpose.
| .prepare_sort_runs(runs, concurrency) | ||
| .with_elapsed_compute(elapsed_compute.clone()) | ||
| .await?; | ||
| timer.restart(); |
There was a problem hiding this comment.
So pausing the timer actually fails to capture the time spent in the sort_batch_stream stream which calls sort_batch_chunked, which is elapsed compute we want to capture. Maybe pass the timer as a param? Or something better, a timer param feels like a smell.
There was a problem hiding this comment.
Fixed: sort_run now measures sorting and memory accounting on the task doing the work, for both inline and spawned execution. Scheduling waits remain excluded.
There was a problem hiding this comment.
Oh neat, with_elapsed_compute is quite handy
| .sum::<usize>() | ||
| - input_bytes; | ||
| let first_bytes = get_record_batch_memory_size(&sorted_batches[0]); | ||
| assert!(loan > first_bytes); |
There was a problem hiding this comment.
Can you comment why it's bigger? I guess it's because some 4096 size buffers are re-used but not filled?
There was a problem hiding this comment.
Each sorted StringView chunk retains the shared string buffers, and summing the chunks' memory sizes counts those buffers repeatedly. I’ve added a comment explaining this.
Related: #25820 fixes shared-buffer counting for sort inputs, but explicitly leaves StringView accounting for follow-up work (I ran into a conflict with that PR when rebasing over the latest main.)
| assert!(alive <= 1, "{alive} sort tasks remain before merging"); | ||
| assert_eq!( | ||
| sorter.merge_pool.borrow(usize::MAX).size(), | ||
| workspace - 2 * (loan - first_bytes) |
There was a problem hiding this comment.
Adding comments around the math would make it easier to reason about
There was a problem hiding this comment.
Should hopefully be a tiny bit clearer now!
4ba6d18 to
96f3f8a
Compare
96f3f8a to
d1a59c1
Compare
|
run benchmark sort_tpch sort_tpch10 |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/bound-sort-preparation (d1a59c1) to e732212 (merge-base) diff Run configurationrun benchmark sort_tpchResults will be posted here when complete File an issue against this benchmark runner |
@comphead Thanks for your very pertinent suggestions! I tested both on my fork (thank you Codex!), then investigated where the scheduling gains came from:
My proposed path is to keep bounded per-run preparation in this PR, then pursue coalescing and cooperative merge initialization as separate improvements in two new follow-up PRs. I also corrected the ordering comments and description: restoring run order keeps output independent of task completion order. |
Share a worker-sized permit budget across sort partitions and acquire permits before spawning. Each job sorts and accounts for one run, keeping its output reserved until the merge consumes it. Preserve run order, cancellation, and merge-time workspace reuse.
Check permit ownership, cancellation, errors, shared-limit wiring and reset. Verify inline and spawned sorting record compute before output consumption. Keep prepared output reserved against a competing consumer and exercise tight-workspace merging and unread-output cleanup. Preserve spill coverage on both current-thread and multithreaded runtimes.
Use the smaller of the Tokio worker count and DataFusion's available parallelism estimate for the shared sort-preparation budget. Explain why CPU-bound jobs need both limits and update the SortExec documentation.
Return per-run and shared input sizes from sort_run_sizes, and create reservations at both call sites. Preserve sizing, lazy run construction, and shared-input lifetimes.
Document the independent-input and common-input cases explicitly, including which budget covers private input buffers and when input charges are released.
Keep the randomized sort configurations and compare each non-TopK query against forced inline and bounded parallel sorting, preserving small input batches in both arms. Compare complete results in row order after concatenating batches. Every projected column is an ORDER BY key, so equal keys have identical output. Retain the current-thread determinism test. This changes internal test coverage only; no public documentation update is needed.
8d5b96d to
aba15cd
Compare
There was a problem hiding this comment.
Thanks for testing both alternatives on the fork and for the numbers. Keeping the per-run design here makes sense to me given the 6 to 13% slowdown from chunking. The ordering comments now match what the merge guarantees, and the tpch, tpcds and sort_tpch runs show nothing beyond noise.
I think the PR is good to go
comphead
left a comment
There was a problem hiding this comment.
Thanks @geoffreyclaude lets give it a try
|
🚀 |
Thanks @comphead for your extremely useful reviews! I'll open the follow ups once this is merged and tag you. |
| check_equality_of_batches(expected, &results).unwrap(); | ||
| // Every projected column is an ORDER BY key, so tied | ||
| // rows have identical output and can be compared in order. | ||
| assert_eq!(expected, &results); |
There was a problem hiding this comment.
This line change causes false positives for this test, it's getting fixed in
Just want to double check it's only for cleanup here, and there is no other reason requires assert_eq
Which issue does this PR close?
Addresses the unbounded local-sort task population discussed in #23174.
Rationale for this change
SortExecsorts pieces of buffered input into independent runs, then merges them. On multithreaded Tokio runtimes, it currently creates one buffered producer task per run.A one-batch channel limits each producer's output, but the number of tasks still grows with the number of runs. With many input batches, this task buildup can stall unrelated queries on the same runtime. Large inputs can trigger this buildup even with normal-sized batches.
What changes are included in this PR?
Use bounded preparation for both final in-memory sorting and sorting before a spill:
SortExecshare a budget capped by both the runtime's worker count and DataFusion's estimate of available CPU parallelism. Each partition also bounds its pending preparations.Memory handoff: during merging, one-batch lookahead returns workspace for the merge's batches and cursors. Each run may hold one prefetched batch whose sort-side reservation has already been released, preserving the previous buffering bound.
Cancellation aborts outstanding jobs. Resetting execution state gives the new plan a fresh concurrency limit.
Single-batch and concatenate-and-sort paths continue inline, as do current-thread runtimes and execution outside Tokio. TopK is unchanged.
SortExecrustdoc describes the limit and its lifetime.What is the testing strategy for this PR?
416c2d6, the benchmark bot classified all 11 queries in each of sort_tpch (SF1) and sort_tpch10 (SF10) as "no change". These Parquet-based suites use the default 8,192-row batch target.A local TPC-H SF1 test uses 32-row batches to stress task growth with a modest dataset. With 4 Tokio workers and 4 partitions, two runs per revision gave:
SELECT 1probeAre there any user-facing changes?
Sorts with many runs have a bounded population of batch-sort jobs across partitions. Public configuration and API signatures are unchanged.
SortExec'selapsed_computenow includes local batch sorting and its memory accounting on both inline and spawned paths, while excluding waits for a permit or another task.EXPLAIN ANALYZEcan therefore report larger values.Durations from concurrent sort jobs are added together and can exceed the query's wall-clock duration.
This PR was prepared with AI assistance.