Repository navigation
feat: Add experimental pipelined execution mode (all tasks placed up front, streaming exchange) - #2657
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:
|
Add ballista.standalone.task_scheduling_policy (default pull-staged). With push-staged, the standalone scheduler pushes tasks and the standalone executor registers on a free gRPC port to receive them. The standalone scheduler now binds its listener before it is built and advertises its real address. Push-staged executors report task status to the endpoint each launch names, which was hardcoded to localhost:50050.
|
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
Pass the bound listener to a new crate-private executor_server::startup_with_incoming instead of releasing the port and binding it again, which let another port-0 bind in the process take it. Correct the keep-alive comment about the stop channel, and make the two-executor test check the scheduler rejects poll_work, so it fails if the push-staged setting is ignored.
# Conflicts: # ballista/core/src/execution_plans/distributed_query.rs # ballista/executor/src/executor.rs # ballista/executor/src/flight_service.rs # ballista/flight-sql/src/backend.rs # ballista/flight-sql/src/service.rs # ballista/flight-sql/tests/service.rs # ballista/scheduler/src/flight_sql.rs # ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs # ballista/scheduler/src/state/execution_graph.rs # ballista/scheduler/src/state/execution_stage.rs # ballista/scheduler/src/state/mod.rs # ballista/scheduler/src/state/pipelined_join_selection.rs # ballista/scheduler/src/state/task_manager.rs
# Conflicts: # ballista/core/src/config.rs
Stack on apache#2669. Its standalone push-staged change replaces this branch's copy, and pipelined execution now overrides ballista.standalone.task_scheduling_policy to push-staged.
# Conflicts: # ballista/core/src/config.rs # ballista/executor/src/standalone.rs # ballista/scheduler/src/standalone.rs # docs/source/user-guide/configs.md
There was a problem hiding this comment.
Reviewed against the DataFrame::cache() design. One blocker (1), plus fixes so a retry rebuilds lost cache data (3, 4). Should these go into this PR now, or come in a follow-up right after it merges?
Tests: nothing runs both modes on one cluster or session, though #2713 documents SET. Suggest a same-context blocking-then-pipelined client test, plus a cached-frame variant.
cc @andygrove @milenkovicm
| )); | ||
| } | ||
|
|
||
| self.task_manager |
There was a problem hiding this comment.
(1) Blocker. Cache builds inherit the session's mode, so a pipelined session builds them as pipelined jobs, which leave nothing to cache (see 2). Suggest: if self.cache_linker.key_of(job_id) is set, submit with a copy of the session that has ballista.execution.mode = blocking, made with session_manager.create_or_update_session so the session id is kept. Read jobs keep the user's mode; reading a blocking-built cache from a pipelined job already works.
There was a problem hiding this comment.
Fixed in 81ff2e3. Cache builds now run blocking whatever the session mode. Rather than going through create_or_update_session, I override ballista.execution.mode on a copy of the job's SessionState, which keeps the session id and leaves the session manager out of it. Read jobs keep the user's mode.
|
|
||
| /// A pipelined job has no shuffle files to read; its final stage streams | ||
| /// from the locations in the running status. | ||
| fn output_locations(&self) -> Vec<PartitionLocation> { |
There was a problem hiding this comment.
(2) This empty list is what a pipelined cache build records: the cache ends up empty and reads return no rows without an error (reproduced in the scheduler test harness). A cache larger than its queue fails after registration_timeout_seconds instead (from reading the code). With 1 this can't happen, but record_materialized_cache could also refuse a pipelined graph as a guard.
There was a problem hiding this comment.
Added the guard too, so record_materialized_cache now refuses a pipelined graph.
| for pairs in self.active_job_cache.iter() { | ||
| let (job_id, job_info) = pairs.pair(); | ||
| let mut graph = job_info.execution_graph.write().await; | ||
| let reason = if let Some(pipelined) = graph.as_pipelined_mut() { |
There was a problem hiding this comment.
(3) This branch skips reads_external_data_on for pipelined jobs. Check both, and use CACHE_DATA_LOST when cache data was lost, so the lost build is dropped.
There was a problem hiding this comment.
Fixed. executor_lost now checks reads_external_data_on before the pipelined branch, so a pipelined job that loses cache data fails with CACHE_DATA_LOST and the build is dropped.
| stage.update_task_metrics(task_id, metrics)?; | ||
| } | ||
| Some(task_status::Status::Failed(failed)) => { | ||
| failure.get_or_insert_with(|| { |
There was a problem hiding this comment.
(4) A FetchPartitionError on cache data (files gone, executor alive) should carry CACHE_DATA_LOST, detected with is_external_fetch_failure as in the static graph. Otherwise every later pipelined query on that cache fails.
There was a problem hiding this comment.
Fixed. A FetchPartitionError on cache data, detected with is_external_fetch_failure, now fails the pipelined job with CACHE_DATA_LOST, so a retry rebuilds the cache.
…e data A pipelined job streams its output and leaves no shuffle files, so a cache built from a pipelined session recorded an empty entry. Cache builds now run blocking whatever the session's mode; reads keep it. A pipelined job that loses cache data, to a lost executor or a failed fetch from a live one, now fails with CACHE_DATA_LOST so the build is dropped and a retry rebuilds it.
|
Thanks for the careful review against the cache design, @akshaychitneni! I put all four fixes into this PR in 81ff2e3 rather than a follow-up (replies inline). On tests, there are new scheduler tests for (1), (3) and (4), plus the client tests you suggested. One switches a single session between blocking and pipelined with |
mbutrovich
left a comment
There was a problem hiding this comment.
Thanks @andygrove! Splitting the exchange, executor, client and join selection out into their own PRs made the scheduler side easy to follow, and the reading guide helped.
Losing an executor now drops its share of a pipelined job's reservation, so an executor that registers again under the same id does not get those vcores on top of its fresh capacity. succeed_job now releases a pipelined job's reservation before persisting its terminal state, as abort_job does, so a failed save cannot strand the vcores. Also drop the configs.md link to the pipelined execution guide, which lands in a later PR.
milenkovicm
left a comment
There was a problem hiding this comment.
Thanks @andygrove
Let's merge this as experimental and iterate on it
Thank you @milenkovicm |
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.
Most of the feature is already on
main. Those parts were split out of this PR and merged on their own, and they do nothing until the scheduler accepts pipelined jobs:This PR adds the scheduler side and turns the mode on:
state/pipelined_graph.rs).PipelinedExecutionGraphplans the job statically, with the join selection from feat: add size-aware join selection for pipelined jobs #2685, and places all of its tasks at once, or waits until it can.submit_jobno longer refusesballista.execution.mode = pipelined.ballista.standalone.task_scheduling_policytopush-staged, so a standalone session only has to setballista.execution.mode = pipelined.pipelinedleg.Reading guide
About 1,000 of the added lines are tests. The core is
ballista/scheduler/src/state/pipelined_graph.rs. A suggested order:plan_placement(top ofpipelined_graph.rs). A pure function: given each stage's width and the free vcores per executor, can the whole job fit right now, and where does each task go? Its unit tests show the cases: spreading, collapse stages, capped task size, waiting, and a stage wider than the cluster.PipelinedExecutionGraph::newandplace.newplans the job statically into stages.placecommits a placement: it resolves every reader to its producers' executors (resolve_pipelined_readers) and hands out every task at once.SchedulerState::bind_pipelined_jobs(state/mod.rs). Runs before normal binding on each offer round. It plans under the graph read lock, reserves the vcores atomically (ClusterState::reserve_vcores), then commits under the write lock.task_manager.rs,query_stage_scheduler.rs).succeed_jobandabort_jobtake the job's reservation (take_reservation) and release it.executor_lostfails a pipelined job instead of rolling it back.submit_job(task_manager.rs). Builds aPipelinedExecutionGraphwhen the session asks for pipelined execution, and no longer rejects it.Changes on the path blocking jobs use are small:
holds_vcores). No blocking task is affected.executor_lostreturns anExecutorLostOutcomestruct. Jobs that lostDataFrame::cache()data are failed through itsfailed_jobs, with the same message as before.ReviveOffers, and after an executor is lost theReviveOffersis posted after the job failures instead of before them.Design notes
These are the scheduler decisions in this PR, each with its reason. The design notes for the exchange, executor and client are in #2670, #2695 and #2696. The answers to #2652's open questions are linked above.
Scheduling and placement
vcores_consumed = 0and pipelined tasks are left out of the per-task reservation ledger, so the normal per-task refunds never hand those vcores back early.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.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?
Yes.
ballista.execution.mode = pipelinednow runs jobs instead of being rejected, and in a standalone cluster it implies push-staged scheduling. The setting and its companions (ballista.pipelined.queue_capacity,ballista.pipelined.registration_timeout_secondsandballista.pipelined.stream_window_bytes) are already onmainfrom #2670. The mode is experimental and off by default, and blocking behavior is unchanged.