Repository navigation
feat: Add experimental pipelined execution mode (all tasks placed up front, streaming exchange) - #2657
feat: Add experimental pipelined execution mode (all tasks placed up front, streaming exchange)#2657andygrove wants to merge 7 commits into
Conversation
9fc6f31 to
83edc87
Compare
TPC-H SF1000: pipelined vs blockingI ran TPC-H SF1000 with Setup: 2 executors on 2 nodes, each with 8 vCPU, a 56 GiB pod limit, Caveat: the blocking column is an earlier run on the same cluster, built from the #2659 branch rather than this PR's image, so treat the comparison as approximate. An earlier pipelined run, on this branch before the rebase, failed Q5, Q8, Q9, Q17, Q18 and Q21. Tasks exceeded DataFusion's default 100 GB spill limit, which executors could not raise before #2659. Pipelined mode holds every stage's state at the same time, so it spills more than blocking does. That makes
17 of 22 queries run at 0.59–0.98x of blocking. Q8 and Q9 alone add about 715 s, so they are the ones to look at next. I have scheduler event logs for every query in this run. |
Why the slower SF1000 queries regressThis follows up on the SF1000 numbers above, using the scheduler event logs from the blocking and pipelined runs. Root cause: with no AQE, every join is a sort-merge join. Pipelined jobs are always planned with the static planner ( Q8 is the clearest case:
Q9, Q17, Q2 and Q11 have the same shape. Q17 sort-merge-joins about 12B rows, where blocking mode has no sort-merge join at all. Experiment: pipelined with
Implication: a global hash-join or sort-merge-join switch won't fix this. Pipelined mode needs size-aware join selection at static planning time: estimate build-side sizes from Parquet and row-count statistics, and apply the existing broadcast thresholds, roughly what AQE decides at runtime. Broadcasting small filtered build sides into the large scan looks like the biggest win (Q8, Q9, Q17), with partitioned hash joins for medium inputs and sort-merge joins kept for very large ones. Setup: same cluster as above, at 83edc87. |
SF1000 results with size-aware join selection (34a1151)Following up on the slow-query analysis above: pipelined jobs now plan with hash joins and Same setup as before: 2 executors x 8 vCPU, 48 GB pool, 1 TB spill cap, 16 partitions, local data, one iteration. All 22 queries pass. Total 2450s, down from 3795s for pipelined before this change, and 0.73x the 3374s blocking baseline.
Notes:
|
|
I'm splitting this into smaller PRs for review, and keeping this one open as a draft for reference (full design notes, end-to-end tests, SF1000 results). Each split PR is cut from this branch's final state.
I'll link the rest here as they're opened. |
|
Benchmark of the pipelined mode at TPC-H SF1000: 15 of 22 queries run and are much faster, 7 fail with I ran TPC-H at SF1000 (Parquet, zstd, Hive-partitioned, S3) on Kubernetes at SpeedPipelined, for the queries that completed (mean of 2 iterations, seconds):
q6 is a 2 s query, so I'd read that as noise. The other 14 are 14 to 74% faster. Failuresq2, q5, q8, q11, q16, q20 and q21 failed. Each failed job reports a task failing because a The failing stage, task and partition differ from job to job, so it is not one bad partition. The failing queries tend to be the ones with the most joins and stages (q5, q8, q21). What the executor logs show for q2 (job
What I don't know
Suggestions
Logs and the result JSON are available if useful. |
Add PipelinedShuffleWriterExec, PipelinedShuffleReaderExec and the per-executor PipelinedRegistry that connects them, with their protobuf messages, serde, the FetchPipelinedPartition Flight action and the ballista.execution.mode and ballista.pipelined.* settings. Nothing plans these operators yet. The scheduler rejects jobs with ballista.execution.mode = pipelined, and executors answer pipelined fetches with unimplemented, until later changes wire them in.
…pers - Lease pooled connections for the life of a remote pipelined stream, and build unpooled clients with new_ballista_client, as the shuffle reader does. Encode Flight tickets with encode_protobuf. - Compute the writer's routing once, drop unused builder and getter methods, and let JoinSet's drop abort the remaining inputs. - Track unclaimed queues with a counter instead of rescanning every partition on each open, share one bounded set for closed jobs and stages, and stop cloning per batch in the queue stream. - Keep a broadcast reader whole when restricting partitions, forget a consumer's waiting slot when it times out, and document the minimums of the pipelined settings.
c3cf10b to
5b57a9e
Compare
SF1000 results after stacking on #2670 (5b57a9e)This run is on the current head, which includes #2670's review fixes:
Setup is the same as before: 2 executors x 8 vCPU, 48 GB pool, 1 TB spill cap, 16 partitions, local data, one iteration. All 22 queries pass in 2475s. That is 1.01x the previous pipelined run (2450s) and 0.73x blocking mode (3374s).
Notes:
One gap showed up while running this. If a client process is killed outright, as opposed to dropping its result stream, its pipelined job keeps running on the scheduler and holds its vcores until it is cancelled through |
|
Follow-up: the I reran q2 at
So within about 1.6 s of the job starting, each executor was asked to accept thousands of connections and its listen queue overflowed. A dropped final ACK leaves the client believing the connection is open, and the server then answers its first data with an RST. That produces the The timing matches. The scheduler placed the job at 13:44:13.36, the first I think the cause is the number of connections: each reader drains all producers concurrently, so at 256 partitions the job opens about 73k connections across 32 executors, compared with the 31 that one HTTP/2 connection per executor pair would need. The executor binds its gRPC listener with Things that might help, in the order I'd try them:
This explains the 7 failing queries of 22 in my earlier comment. It would also explain why they are the ones with the most stages and the widest fan-in. I can run any of these changes against the same cluster if that helps. |
Each remote stream checked out its own connection, so a wide pipelined job opened one per producer and consumer task pair and overflowed the executors' listen queues. A reader partition now multiplexes its streams to an executor onto one connection, capped at 64 streams per connection.
5b57a9e to
d0572fc
Compare
|
Update: confirmed in the code. The reader checked a pooled connection out for every stream and held it until the stream ended, so every producer and consumer task pair got its own connection. e573d27 in #2670 makes each reader partition share one connection per producer executor, with at most 64 streams on a connection. It stops short of one connection per executor pair. With the 64 MB connection window and 16 MB stream window, a few stalled streams (say a join's probe side while it builds) would fill the connection and starve the build side's streams on it. Within one reader partition all the streams feed the same output, so that can't happen. This should cut q2 from about 73k connections to roughly 8k. #2657 is rebased on it. Next step is rerunning q2 at d0572fc. If it still resets, I'll add the retry before the first batch and the backlog setting. |
|
Follow-up: I rebuilt at
So the shared connection did not fix the failures, and q2's single passing run was luck. I also need to correct my previous comment, where I said accept-queue overflow explains the resets. The kernel counters I read from the 32 executors after a failing q2 on
The queue overflowed in the run that passed too, and the number of accepted connections didn't fall, so I can't say overflow is the cause, or that most of the 73k connections are the reader's streams. These counters cover each pod's whole life, so they include startup and other traffic. I'm going to snapshot them before the job and sample them during the run, so I can line counter jumps up with the failures. What I do know is the first error in each failing run is a TCP reset seen by a reader ( One thing that might be useful regardless: a stream open that gets reset before any batch is consumed could be retried. All 8 failures are of that kind, and pipelined jobs don't retry tasks. |
tonic's `serve` binds through the standard library, which listens with a backlog of 128. A pipelined job connects from every reader task on every executor when it starts, so an executor can get thousands of connections within a second and overflow both its accept and SYN queues. Overflowed connections wait for a SYN retransmit, and once the kernel falls back to SYN cookies, a client whose first segments were dropped is reset as soon as it sends more. The reader then sees `ConnectionReset` on a stream it never opened, with nothing logged on either side. Bind the Flight server through `create_grpc_server_incoming`, which the executor's gRPC server already uses, and have that helper listen with `GrpcServerConfig::listen_backlog`, 4096 by default. The kernel caps the value at `net.core.somaxconn`, which is 4096 on Linux 5.4 and later.
… server answered A wide pipelined job opens its remote streams in one burst, and a connection the producer's executor could not accept in time can be reset by the kernel as the reader starts sending. The reader saw that as a `ConnectionReset` on a stream it had not opened, and since pipelined jobs do not retry tasks, one such reset failed the job. `fetch_pipelined_partition` now reports a failure of the connection itself, before the server answered, as `GrpcConnectionError`, and keeps `GrpcActionError` for anything the server answered. The reader retries the former on a new connection, `io_retries_times` times with `io_retry_wait_time_ms` between attempts, the settings the blocking shuffle reader already uses. A failed connection is dropped from its slot, so the other streams of the partition that share it connect again rather than fail on it. An error the server answered, including the producer's own failure, is still final: the producer may already have handed the partition to the failed stream. Tests cover a stream open that is reset on its first connection and served on its second, and a server-side failure that is not retried.
d0572fc to
667fb67
Compare
|
Two speculative fixes for the |
A pipelined reader opened a connection per reader partition to each producer executor, so a job opened executors x tasks x stage boundaries connections to every executor at once, enough to overflow its listen queue however large the queue is. The executor's client pool now keeps one shared connection per (host, port, config), which the streams of every task on the executor multiplex over, so an executor holds one connection to each other executor. A connection that fails before the server answers is discarded from the pool, and the open is retried on a new one. Sharing is safe because of how the windows are set. A stream nobody is reading holds up to its stream window of data on the connection, and if stalled streams could use up the connection window they would starve the streams something is waiting on. The shared connection therefore advertises the largest connection window HTTP/2 allows and a small stream window, `ballista.pipelined.stream_window_bytes` (512 KiB by default), so stalled streams hold at most one window each and a connection carries over 4000 streams before any could starve the rest. The executor's server is explicitly left without a limit on concurrent streams, since a stream the client had to queue behind a limit would leave its producer blocked on a full queue. `BallistaClientPool` gains a `shared` method with a default implementation, so existing pools keep compiling.
Wire the pipelined exchange operators into the executor, scheduler, client and Flight SQL, and remove the guards that rejected pipelined jobs until now. - Executor: own a PipelinedRegistry, serve FetchPipelinedPartition from do_get, run PipelinedShuffleWriterExec stage roots, share one memory pool among a job's tasks, and drop a job's registry entries on cleanup. - Scheduler: plan pipelined jobs as a PipelinedExecutionGraph and place all of their tasks at once, reserving the widest stage's vcores on each executor. Choose join strategies by estimated build size. - Client: stream every final-stage partition while the job runs, cancel the job when its results are dropped, and report its failure. - Flight SQL: serve a pipelined result through one merged endpoint. - Standalone: run push-staged when the session asks for pipelined execution. - Tests, a TPC-H SF10 pipelined CI leg, and a user guide page.
667fb67 to
cb49f30
Compare
|
Edited: the blocking numbers are now from a back-to-back run on the same image ( I reran the full suite at All 22 queries completed, where TimeMean of 2 iterations, in seconds:
The total is 278.8 s against 517.0 s (0.54x). Every query is faster, from 7% (q1) to 80% (q14). Blocking runs on this cluster vary between runs (q2 took 29.7 s and 36.3 s, q10 37.4 s and 45.1 s in two blocking runs today), so the per-query ratios are rough, but the gap is much larger than that variation. ConnectionsI read each executor's kernel TCP counters (
The earlier builds overflowed the executors' accept queues by tens of thousands in every run, passing ones included. With the reworked connection sharing there are about 16 times fewer accepted connections and no overflows. I can't tell which of the three fixes (backlog, retry, shared connections) matters most in this run, and I don't need to. ResultsThe 22 queries return the same number of rows in both modes (for example q1 4, q3 10, q9 175, q16 27,840, q18 100, q20 112,013, q22 7). q11 returns 0 rows and q13 returns 30 in both modes, so that is not specific to pipelined execution. I compared row counts only, not the contents. CaveatsThis is one run of each mode with 2 iterations. |
Which issue does this PR close?
Closes #2652.
Rationale for this change
#2652 proposes an opt-in pipelined execution mode. In this mode the scheduler places every task of a job up front, and stages stream record batches to each other over Arrow Flight instead of running stage by stage through shuffle files. The goal is lower latency for interactive queries. The blocking shuffle stays the default, and nothing changes unless a session sets
ballista.execution.mode = pipelined.What changes are included in this PR?
The feature works end to end and is ready for review. The answers to the open questions in #2652 are in this comment.
ballista.execution.mode = blocking | pipelined, plusballista.pipelined.queue_capacityandballista.pipelined.registration_timeout_seconds.ballista/core/src/execution_plans/pipelined/).PipelinedShuffleWriterExecroutes each output partition into a bounded queue in a per-executorPipelinedRegistry.PipelinedShuffleReaderExecdrains all of its producers concurrently, either locally or over a newFetchPipelinedPartitionFlight ticket.do_get. All of a job's tasks on one executor share one memory pool, sized by the job's reservation there. Job cleanup drops the job's registry entries.state/pipelined_graph.rs).PipelinedExecutionGraphplans the job statically and places all of its tasks at once, or waits until it can.state/pipelined_join_selection.rs). Without AQE, pipelined jobs are planned with hash joins. A partitioned hash join is demoted to a sort-merge join when its estimated build size per partition exceedshash_join_max_build_partition_bytesor is unknown, since every hash table in a pipelined job is resident at once and none can spill.user-guide/pipelined-execution.md) covers when to use the mode, how a job runs, memory and settings.Design notes
These are the decisions made while implementing, each with its reason. The answers to #2652's open questions are linked above.
Scheduling and placement
vcores_consumed = 0so the normal per-task refunds stay correct.UNION ALLis as wide as all of its inputs together, so rejecting wide stages would fail ordinary queries. The cost is that a wide job waits until the whole cluster is free.bind_schedulable_tasksuses.Exchange
mpscqueue, so a slow consumer stalls its producer. In DataFusion Distributed, by contrast, data for a slow consumer piles up in the producer. Bounded queues can't deadlock for tree-shaped plans as long as every reader drains all of its producers concurrently, whichPipelinedShuffleReaderExecdoes. The hash writer finishes its partitions concurrently, so one full queue doesn't hold back the end markers of the others.Message::Endcame from a producer that failed or was cancelled. The consumer gets the producer's error, never a silently truncated partition.LIMIT, the writer stops reading its input.broadcast_join_threshold_bytes. Any output that a stage of several tasks reads in full is replayed the same way, such as a cross join's left side. Nothing bounds those, but blocking mode reads that data once per task too.ballista.pipelined.registration_timeout_secondslimits how long a consumer waits for its producer to register, and how long a producer waits for someone to open a full queue. A slow consumer that has already opened its queue is ordinary backpressure and isn't timed out. The producer waits on a queue reservation rather than a send, so a timeout loses no batch.do_getreturns at once and waits for the producer inside the stream body, so a consumer that arrives early isn't cut off by its client's request timeout.Executor
SessionConfigextension. Plans aren't rewritten, so custom execution engines that leave the plan alone still work.ballista.pipelined.stream_window_bytes, 512 KiB) is small and the connection window is the largest HTTP/2 allows, so stalled streams cannot starve the ones something is waiting on. A stream open whose connection fails before the other executor answers is retried; a stream that has started is not. The executors' listeners take a 4096 backlog for the burst the first job after a restart opens.ballista.shuffle.force_remote_readforces the Flight path for tests.Client and Flight SQL
SuccessfulJobor read the partitions one after another, as it does in blocking mode.How are these changes tested?
ballista/client/tests/pipelined.rscompares queries against blocking mode in three setups: a standalone cluster, the same with forced remote reads, and a scheduler with two executors. It also coversLIMIT, a divide-by-zero reaching the client, a pull-staged cluster rejecting the job, and a dropped result stream freeing the cluster.ballista/scheduler/tests/flight_sql.rsruns a pipelined job through Flight SQL.pipelinedleg that runs SF10 with--verifyagainst DataFusion.cb49f30f(2 iterations per query). All 22 queries pass in 278.8 s, against 517.0 s for blocking mode on the same image and cluster (0.54x). Every query is faster, from 7% (q1) to 80% (q14). The 22 queries return the same row counts in both modes; I compared counts only, not contents. q11 returns 0 rows and q13 returns 30 in both modes. Per-query numbers are in this comment.ListenOverflows0 andPassiveOpens3,590. A single query (q7, two iterations) ond0572fc7, before the shared connection per executor pair, gave 37,779 and 56,230. Earlier builds failed 7 to 8 of the 22 queries at this size withConnectionReset.Per-query times on the 32-executor cluster, mean of 2 iterations, in seconds, from the image at
cb49f30f:Total: 278.8 s pipelined against 517.0 s blocking (0.54x).
Known limitations:
partside once it knows its size, but the static estimate is too large to broadcast. On the 32-executor cluster q8 is faster than blocking (14.1 s against 21.6 s).Are there any user-facing changes?
There are four new session settings:
ballista.execution.mode,ballista.pipelined.queue_capacity,ballista.pipelined.registration_timeout_secondsandballista.pipelined.stream_window_bytes. The mode is experimental and off by default, and blocking behavior is unchanged.