Skip to content

perf: faster staged execution (semi-join placement, shuffle spill accounting, scheduler latency) - #2792

Closed
andygrove wants to merge 18 commits into
apache:mainfrom
andygrove:perf/staged-vs-comet
Closed

andygrove wants to merge 18 commits into
apache:mainfrom
andygrove:perf/staged-vs-comet

Conversation

@andygrove

@andygrove andygrove commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

No single issue. This is a working branch for making staged Ballista faster than Spark + Comet on TPC-H, to be split into separate PRs once it settles. Related: #2662 (q21 at SF1000), #2520 (performance review tracking), #1375 (dynamic filters).

Rationale for this change

On a single machine (TPC-H SF100, 2 executors x 8 cores, both engines running the queries in benchmarks/queries), main and Spark 4.1 + Comet 1.2.0 take about the same time (197 s and 204 s). On a two-node cluster at SF1000 (2 x 16 cores, 32 partitions), main is 1.14x slower than Comet over the queries both finish, and q5 and q18 fail. Comparing plans, stage timelines and metrics against Spark's event logs found these causes:

  • Join placement. DataFusion leaves IN/EXISTS semi joins above the whole FROM join tree, and applies selective dimension filters (such as nation) only at the join that names them. q18 joins 600M rows before its subquery keeps 6,400 orders; q7 joins and shuffles 182M lineitem rows before the nation filter drops 92% of them. Spark handles these with PushDownLeftSemiAntiJoin and runtime filters.
  • Spilling. The sort shuffle writer charged every zero-copy slice of an aggregate's output with the whole shared buffer, so q18's partial aggregate stage spilled 1,145 times per input partition. At SF1000 the spills of q5 and q18 exceed DataFusion's 100 GB default spill cap and fail.
  • Per-task latency. The scheduler serves gRPC through axum, which leaves Nagle's algorithm on, so each pull-mode poll waited about 40 ms for a delayed ACK. That is added to every task wave and dominates at high partition counts.
  • High partition counts break. At 128 partitions the executor hits a 1024 open-file soft limit, and a poll response over the gRPC limit leaves its bound tasks running forever, which hangs the job.

What changes are included in this PR?

This PR collects the benchmark results. Every change on this branch has now merged into main through its own PR: #2793, #2794, #2795 (poll response fit), #2796 (shared buffers in shuffle buffering), #2797, #2798 (Nagle), #2799, #2805 (zstd as the default shuffle codec at level 1, with #2802's spill writers closed after each spill), #2806 (undecided join partitioning) and #2807 (broadcast read cache). The branch is merged with main as of 3f300da, and its only remaining diff against main is the BufferExec half of (5): #2796 changed the sort shuffle buffer only, so BufferExec's dam mode still charges every zero-copy slice with the whole shared buffer. That will go in a follow-up PR. This PR stays a draft to collect benchmark results.

  1. perf: push semi and anti joins below inner joins: logical rule SemiJoinPushdown (ballista.optimizer.semi_join_pushdown). It moves semi/anti joins below passthrough projections and inner joins (and the preserved side of outer joins), but not below filters or joins whose other input is filtered, where the other operator is the cheaper filter.
  2. perf: semi-join small filtered dimensions below the joins in between: SemiJoinReduction (ballista.optimizer.semi_join_reduction). For a join against a dimension filtered by =/IN/LIKE on literals, it adds a semi join against a copy of the dimension on the table the key comes from, below the joins in between. Logical plans have no statistics, so the scheduler runs it before physical planning and keeps only dimensions whose estimated rows are under broadcast_join_threshold_rows, dropping any that land on tables under 100K rows.
  3. fix: raise the executor's open file limit to the hard limit at startup.
  4. fix: reschedule tasks that do not fit in a poll response: tasks that would push a PollWorkResult past the encoding limit are reported back as killed (re-queued, no failure counted). A single oversized task fails its job with an error naming the setting. Merged in fix: reschedule tasks that do not fit in a poll response #2795.
  5. fix: count buffers shared by sliced batches once in shuffle buffering: uses DataFusion's RecordBatchMemoryCounter in the sort shuffle buffer and BufferExec. Merged in fix: count buffers shared by sliced batches once in shuffle buffering #2796 for the sort shuffle buffer; the BufferExec part is still on this branch.
  6. Two follow-ups to (1) and (2) that keep semi joins above filters and filtered joins (TPC-H q4 and q21 regressed without them) and skip reductions onto tiny tables.
  7. fix: let executor spills use the work dir's free space by default, when --max-temp-directory-size is unset.
  8. perf: disable Nagle's algorithm on scheduler connections. Merged in perf: disable Nagle's algorithm on scheduler connections #2798.

Results on one machine, TPC-H SF100, 2 executors x 8 cores, 16 partitions, one iteration, both engines on the same query text:

Total (s)
Spark 4.1 + Comet 1.2.0 203.5
Ballista main 197.5
This branch 140.9

The biggest changes are q7 20.1 s to 5.6 s, q18 42.2 s to 16.0 s and q21 31.9 s to 15.6 s. No query is more than noise slower than on main. At 128 partitions the branch takes 148 s against 204 s on main, mostly from the Nagle fix. All 22 queries pass --verify against DataFusion at SF10, at 16 and at 128 partitions.

Two-node cluster, TPC-H SF1000 on node-local disk, 2 executors x 16 cores, 32 partitions, one iteration, benchmarks/cluster/run.py for every engine:

Completed Total (s)
Spark 4.1 + Comet 1.2.0 22 / 22 2291
Ballista main 20 / 22 (q5 and q18 exceed the 100 GB spill cap) 2127 for the other 20 (Comet: 1870)
This branch, 32 partitions 22 / 22 2091
This branch, 256 partitions 22 / 22 1744
+ broadcast read cache and aggregate fix, 256 partitions 22 / 22 1702
+ zstd shuffle compression, 256 partitions 22 / 22 1318

Notable queries (main / branch / Comet, seconds): q7 342 / 68 / 132, q21 713 / 279 / 243, q11 48 / 4.7 / 44, q19 36 / 18 / 18. q3 is slower in this run (128 / 165 / 83) with the same plan, spill counts and CPU time, so the difference is in how fast the 120 GB of spill files were written and read. At 32 partitions at this scale, Ballista still spills heavily in sorts and shuffles (q3, q5, q10). With 256 partitions the branch completes all 22 queries in 1744 s, 1.31x faster than Comet: spill-heavy queries improve (q3 165 to 103 s, q5 439 to 266 s, q10 134 to 109 s, q18 231 to 185 s, q21 279 to 223 s), while short and broadcast-heavy ones get slower (q11 4.7 to 14.7 s, q8 33 to 48 s).

Two later commits address that. e3be5d583 shares each broadcast read between the tasks on an executor (q2's broadcast stage at 128 partitions: 3.0 s to 0.7 s of task time, 16,384 to 2,048 fetches), which is worth about 1% at SF1000. c0913e62f (merged in #2806) fixes undecided AQE joins reporting one output partition, which let DataFusion merge q11's scalar sum into a Single aggregate that every task then ran over all 32M input rows (also on main); q11 goes from 14.7 s to 5.4 s. With both, the branch completes SF1000 in 1702 s, 1.35x faster than Comet.

The remaining large stages at SF1000 were bound by shuffle bytes: the lineitem join in q5 pulls half of lineitem's shuffle across the network, and the map side spends much of its time writing and copying spill files. lz4 IPC stores lineitem's shuffled columns at about 16 bytes per row (Comet's shuffle: 14.8), zstd at about 8.2. Making zstd the default (b83da19ea, merged in #2805) brings SF1000 to 1318 s, 1.74x faster than Comet: q5 274 to 170 s, q3 113 to 76 s, q9 156 to 117 s, q18 183 to 145 s, q21 199 to 161 s. It also helps on one machine with no network in between (SF100, 138.8 s to 132.3 s). Before that, c072080da stops the sort shuffle spill manager from keeping a writer open per spilled output partition until the task ends; with zstd each open writer holds its own compression context outside the memory pool, which at 256 partitions came to thousands of open files and several GB per executor.

TPC-DS SF100 on the single machine (2 x 8 cores, 16 partitions, 98 queries, q72 skipped): main 510.4 s, this branch 362.6 s (1.41x). Every query returns the same row count on both. The largest gains are q50 10.9 s to 1.1 s, q25 20.9 s to 2.7 s, q29 13.3 s to 1.8 s, q17 12.6 s to 2.3 s and q4 41.8 s to 20.2 s. The only queries more than 15% slower are q34 (+0.18 s) and q60 (+0.37 s).

Still open: coarse tasks leave vcores idle behind their slowest partition and during the task's single-threaded shuffle file write (parallel writes to the one file do not help, since buffered writes to a file serialize in the kernel); q2, q4, q10, q13, q16, q19 and q22 remain 3-60% slower than Comet at SF1000. Storing string offsets as lengths before zstd, as Comet does, would shrink string-heavy shuffles by a further 20-55% (#2804).

Are there any user-facing changes?

Yes:

  • Two new config keys, both enabled by default: ballista.optimizer.semi_join_pushdown and ballista.optimizer.semi_join_reduction. Plans for queries with subqueries or filtered dimension joins change.
  • When --max-temp-directory-size is not set, the executor's spill limit is the work dir's free space at startup instead of 100 GB.
  • The executor raises its open-file soft limit at startup.

andygrove and others added 9 commits November 7, 2026 07:23
DataFusion decorrelates IN/EXISTS/NOT EXISTS subqueries into semi and
anti joins placed above the FROM clause's join tree, so the subquery
filters the joined result. Add a logical rule, like Spark's
PushDownLeftSemiAntiJoin, that moves them below passthrough projections,
filters, inner joins and the preserved side of outer joins, onto the
input that holds their keys. TPC-H q18 then filters orders down to the
qualifying orders before joining lineitem.

Controlled by ballista.optimizer.semi_join_pushdown (default true).
A task holds a file per shuffle partition it reads or writes plus its
spill files, so stages with a few hundred partitions need thousands of
descriptors. With the common 1024 soft limit, TPC-H at 128+ partitions
fails with "Too many open files". The JVM raises its soft limit at
startup, so Spark executors are unaffected; do the same.
For an inner join against a small dimension filtered by =, IN or LIKE on
literals (a nation, region or part type), also semi-join the dimension
with the table the join key comes from, below the joins in between, so
the filter removes rows before they are joined and shuffled. This is
the effect of Spark's runtime filters. In TPC-H q7 supplier and customer
are reduced to the two filtered nations before joining lineitem.

Logical plans carry no statistics, so the scheduler runs the rule before
physical planning: candidates are found by shape, planned on their own
to read their row estimate, and used only under
ballista.optimizer.broadcast_join_threshold_rows. Controlled by
ballista.optimizer.semi_join_reduction (default true).
Under pull scheduling the scheduler binds tasks, marking them running,
before tonic encodes the PollWorkResult. When the response exceeds the
gRPC encoding limit (many tasks with large plans, such as broadcast
readers listing every upstream location at 128 partitions) the executor
gets an error, the tasks are never run, and the job hangs.

Send only the tasks that fit and report the rest as killed, which
re-queues their partitions without counting a failure. A single task
larger than the limit fails its job with an error naming the setting to
raise.
Aggregates emit their output as one large array sliced into batch-size
pieces. The sort shuffle writer summed get_array_memory_size per
buffered batch, which counts the whole shared buffer for every slice,
so after the first slice the 256 MiB per-task budget looked exceeded
and the writer spilled on almost every batch: TPC-H q18's partial
aggregate stage spilled 1,145 times per input partition at SF100.

Use DataFusion's RecordBatchMemoryCounter, which counts each buffer
once across batches, in the sort shuffle buffer and in BufferExec's dam.
Pushing a semi join below a Filter is sound but makes it probe the
unfiltered input: TPC-H q4's EXISTS probed all orders instead of the
5.7M in its date range (3.3 s to 9.6 s at SF100). Stop at filters.

A dimension semi join that lands on a tiny table, such as nation
reduced by region in q2/q5/q8, only adds a join. Estimate the input it
lands on and remove it below 100K rows.
A join whose other input is filtered can remove most rows itself, so a
semi or anti join pushed below it probes rows that join would have
dropped. TPC-H q21's EXISTS and NOT EXISTS moved below the orders
(o_orderstatus = 'F') and nation joins onto all 379M l1 rows at SF100,
doubling its run time (32 s to 65 s). Keep subquery semi joins above
such joins; dimension reductions still sink through them, since there
the semi join is the selective side.
DataFusion caps spill files at 100 GB per runtime. At TPC-H SF1000 on
two executors, q5 and q18 spill more than that and fail, although the
executor's work dir is a 1 TB volume. When --max-temp-directory-size is
not set, use the space free on the work dir's filesystem at startup,
never less than DataFusion's default.
The scheduler serves gRPC through axum, which, unlike tonic's own
server, leaves Nagle's algorithm on for accepted connections. A poll
response is several small HTTP/2 frames, so the later ones wait for the
executor's delayed ACK: about 40 ms per poll on Linux. Under pull
scheduling that is added to every task launch, so a stage of N waves
pays N x 40 ms; TPC-H q2 at 128 partitions spent about 2 s of its 5.6 s
waiting. Set TCP_NODELAY on every accepted connection.
sim added 6 commits November 7, 2026 10:34
The build side of a broadcast hash join is a broadcast ShuffleReaderExec
that reads every partition of the upstream stage. Each task runs its own
plan, so every task on an executor fetched and decoded the same build
side: at 128 partitions a 16-task broadcast stage made 16,384 fetch
requests for one 318K-row table.

Add an executor-wide BroadcastReadCache. The first task to run a
broadcast read fetches it; tasks that ask meanwhile wait for that fetch,
and later ones reuse the decoded batches. Entries are keyed by job,
stage and the exact locations read, expire after 30 s unused, are not
kept above 1 GiB, and are dropped with the job's data. A failed fetch is
not cached, so each task reports its own fetch error as before.

Controlled by ballista.shuffle.cache_broadcast_reads (default true).
An undecided AQE join (DynamicJoinSelectionExec) reported the
partitioning of the join it was planned as. A CollectLeft join passes
its right input's partitioning through, so a join planned with a
one-partition table such as nation on the right reported one partition,
although the join it resolves into may build nation and output the
other side's partitions. Meanwhile DataFusion's CombinePartialFinal-
Aggregate saw an aggregate over one partition and merged it into a
Single aggregate, which later replans cannot split. Once the join
resolved, the aggregate's input was coalesced and exchanged below it,
so every task of the consuming stage read and aggregated the whole
input: TPC-H q11's scalar sum read all 32M rows in each of 16 tasks at
SF1000 with 256 partitions.

Report at least as many partitions as the widest input until the join
is decided.
with_new_children is deprecated in DataFusion 55, and clippy rejects the
call under -D warnings.
The existing multi-input test never spilled, so nothing checked that each
output partition's range stitches every input's spill and in-memory bytes
together in order. Run it again with a tiny buffer budget so every input
spills several times and keeps an in-memory remainder.
The spill manager kept one IPC writer open per spilled output partition
until the task finished, each holding a file descriptor and its own
compression context outside the memory pool. With hundreds of output
partitions that is hundreds of open files per input partition and, with
zstd, hundreds of megabytes of compressor state. Write each spill event as
one complete stream appended to the partition's file and close it; readers
already cross concatenated streams.
zstd writes shuffle data at roughly half the size of lz4 (about 8 bytes
per row against 16 for TPC-H lineitem's shuffled columns) for little
extra CPU. Shuffles that cross the network get correspondingly faster,
and smaller files also cut local disk and page-cache traffic. lz4 stays
available through ballista.shuffle.compression.codec.
# Conflicts:
#	ballista/core/src/config.rs
#	ballista/core/src/execution_plans/sort_shuffle/spill.rs
#	docs/source/user-guide/configs.md
# Conflicts:
#	ballista/core/src/config.rs
#	ballista/core/src/execution_plans/broadcast_cache.rs
#	ballista/core/src/execution_plans/shuffle_reader.rs
#	ballista/core/src/optimizer.rs
#	ballista/core/src/optimizer/semi_join_pushdown.rs
#	ballista/core/src/optimizer/semi_join_reduction.rs
#	ballista/executor/src/config.rs
#	ballista/executor/src/executor_process.rs
#	docs/source/user-guide/configs.md
@andygrove

Copy link
Copy Markdown
Member Author

Closing: every change from this branch has merged into main through its own PR. The last remaining piece, the BufferExec half of #2796, is now in #2845.

@andygrove andygrove closed this Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant