[SPARK-57638][SQL] Avoid busy-waiting in Declarative Pipelines flow resolution - #56700
[SPARK-57638][SQL] Avoid busy-waiting in Declarative Pipelines flow resolution#56700LuciferYang wants to merge 4 commits into
Conversation
…esolution DataflowGraphTransformer.transformDownNodes drove flow resolution from a loop that polled the in-flight futures with the non-blocking Future.isDone. When all slots were busy (or only the last futures remained) and none had finished, the loop reaped and scheduled nothing yet immediately looped again, pinning a CPU core for the whole resolution. Drive it with an ExecutorCompletionService: drain finished tasks with poll(), and when nothing can be scheduled but tasks are still running, block on take() instead of spinning. Same flows, same order, same exception propagation.
| } else if (outstanding > 0) { | ||
| // Nothing finished and nothing could be scheduled, but tasks are still running: | ||
| // block until the next one finishes instead of busy-spinning on Future.isDone. | ||
| reap(completionService.take()) |
There was a problem hiding this comment.
Nit: the else if (outstanding > 0) guard is effectively always true when reached. Control only reaches it when the if is false, i.e. outstanding >= batchSize || queue.isEmpty. Since batchSize >= 1, the first disjunct implies outstanding > 0; and if the queue is empty the loop invariant guarantees outstanding > 0. So it could just be a plain else?
There was a problem hiding this comment.
Good question, but the guard is needed - a plain else would deadlock the last iteration. The reasoning misses that the poll() drain loop runs after the loop-condition check and mutates outstanding. The invariant outstanding > 0 || queue.nonEmpty only holds at the top of the loop; by the time we reach this branch, the drain may have reaped the last in-flight tasks and taken outstanding to 0 while the queue is already empty. In that case the if is false (empty queue) and there is nothing left to wait for - take() would block forever. The outstanding > 0 guard lets the loop fall through so the next condition check (0 > 0 || empty) exits cleanly. I added a comment spelling this out.
…outstanding > 0 The poll() drain loop runs after the loop-condition check and can take outstanding to 0 with an empty queue, so the guard is not redundant: a plain else would call take() with nothing left to complete and block forever.
|
LGTM, non-blocking suggestion on test coverage. Clean fix : the Suggestion (non-blocking): a deterministic test for the >parallelism regime. The PR notes a test is omitted because asserting the absence of a busy-wait needs flaky CPU/timing measurement -- that's fair for the perf property. But there's a separate, non-timing property that this rewrite changes and that no existing suite covers: correct resolution when the number of flows exceeds What I verified:
|
…hen flow count exceeds parallelism Per review on apache#56700: the busy-wait rewrite changes the scheduling loop so that, once `parallelism` (10) slots are full, the driver blocks on a finished task via `ExecutorCompletionService.take()` instead of spinning on `Future.isDone`. The existing resolution suites only build small graphs, so the blocking branch is never exercised deterministically. Add two tests to `ConnectValidPipelineSuite`: - 25 independent flows: fills all slots repeatedly and forces the blocking `take()` path; asserts every flow resolves and the call returns. - one source view + 20 consumers reading from it: exercises the blocking path together with the `TransformNodeRetryableException` re-queue (a consumer scheduled before `src` resolves is pushed back onto the deque and must re-drive the loop). Both assert outcomes only (all flows resolved, the call terminates), so there is no timing dependence and no flakiness; a regression that deadlocked would hang until the suite times out.
… tighten test comments Follow-up to the review on apache#56700: - In the wide fan-out test, register the consumers before their source view so the consumers are scheduled first and the first batch deterministically observes an unresolved `src`, throwing TransformNodeRetryableException. The previous order (source first) let `src` resolve before the consumers ran, so the retry re-queue path was only opportunistically exercised. Also reword the comment to match the actual mechanism: a retryable consumer is parked as a dependent of `src` and re-queued only once `src` resolves, not pushed back onto the deque immediately. - Soften the independent-flows test comment so it no longer claims the scheduler blocks on `take()` on every iteration; it blocks once `parallelism` tasks are outstanding. No behavior change; tests only.
|
Thanks for the careful review @yadavay-amzn, and good catch on the coverage gap — the Added two deterministic tests in
|
|
@anew @jose-torres do you want to take a look as well? |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
+1, LGTM. BTW, shall we remove the following sentence, A dedicated test is not included ..., because this PR added two new test cases, @LuciferYang ?
How was this patch tested?
A dedicated test is not included because asserting the absence of a busy-wait reliably requires CPU-time or timing measurements that are flaky in CI.
Thank you @dongjoon-hyun . I have updated the |
…esolution ### What changes were proposed in this pull request? `DataflowGraphTransformer.transformDownNodes` resolves flows on a bounded thread pool and drives them from a `while` loop that, each pass, partitioned the in-flight futures with the non-blocking `future.isDone`, reaped the completed ones, and scheduled a new flow if a slot was free. When all slots were in flight (or the queue was drained and only the last futures remained) and none had completed, the pass reaped nothing and scheduled nothing, then looped again immediately - busy-spinning on `isDone` and pinning a core for the duration of resolution. This drives the loop with an `ExecutorCompletionService` instead: completed tasks are drained with the non-blocking `poll()`, and when nothing can be scheduled but tasks are still running, the loop blocks on `take()` until the next one finishes rather than spinning. Behavior is otherwise unchanged - the same flows are scheduled in the same order, exceptions are still propagated via `Future.get()`, and an `outstanding` counter replaces the `ArrayBuffer[Future]` for slot bookkeeping. ### Why are the changes needed? Resolving a graph with more flows than the parallelism (10) kept one CPU core busy at 100% doing no useful work for the whole resolution, which is wasteful and shows up as unexplained driver CPU. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? Two new cases in `ConnectValidPipelineSuite` cover the regime this PR changes - more flows than `parallelism` (10), so the slots fill and the loop reaches the blocking `take()` branch that replaces the busy-wait. The small graphs in the existing suites never get there. - `resolution terminates and resolves all flows when flow count exceeds parallelism` - 25 independent flows. - `resolution re-queues retryable flows under load when consumers exceed parallelism` - 20 consumers registered before their source `src`, so the first batch throws `TransformNodeRetryableException`, parks as dependents of `src`, and is re-queued once `src` resolves; this exercises the retryable re-queue path together with the blocking branch. Both assert only the outcome (every flow resolves and the call returns), so they are deterministic and have no timing dependence - a regression that deadlocked would hang until the suite times out. Asserting the absence of a busy-wait directly is not included, since that requires CPU-time or timing measurements that are flaky in CI. Existing graph-resolution suites (`ConnectValidPipelineSuite`, `ConnectInvalidPipelineSuite`, `SqlPipelineSuite`, `TriggeredGraphExecutionSuite`, `MaterializeTablesSuite`) still pass; the change only affects how the loop waits, not what it resolves. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) Closes #56700 from LuciferYang/sdp-resolution-busy-wait. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com> (cherry picked from commit 0747e28) Signed-off-by: yangjie01 <yangjie01@baidu.com>
…esolution ### What changes were proposed in this pull request? `DataflowGraphTransformer.transformDownNodes` resolves flows on a bounded thread pool and drives them from a `while` loop that, each pass, partitioned the in-flight futures with the non-blocking `future.isDone`, reaped the completed ones, and scheduled a new flow if a slot was free. When all slots were in flight (or the queue was drained and only the last futures remained) and none had completed, the pass reaped nothing and scheduled nothing, then looped again immediately - busy-spinning on `isDone` and pinning a core for the duration of resolution. This drives the loop with an `ExecutorCompletionService` instead: completed tasks are drained with the non-blocking `poll()`, and when nothing can be scheduled but tasks are still running, the loop blocks on `take()` until the next one finishes rather than spinning. Behavior is otherwise unchanged - the same flows are scheduled in the same order, exceptions are still propagated via `Future.get()`, and an `outstanding` counter replaces the `ArrayBuffer[Future]` for slot bookkeeping. ### Why are the changes needed? Resolving a graph with more flows than the parallelism (10) kept one CPU core busy at 100% doing no useful work for the whole resolution, which is wasteful and shows up as unexplained driver CPU. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? Two new cases in `ConnectValidPipelineSuite` cover the regime this PR changes - more flows than `parallelism` (10), so the slots fill and the loop reaches the blocking `take()` branch that replaces the busy-wait. The small graphs in the existing suites never get there. - `resolution terminates and resolves all flows when flow count exceeds parallelism` - 25 independent flows. - `resolution re-queues retryable flows under load when consumers exceed parallelism` - 20 consumers registered before their source `src`, so the first batch throws `TransformNodeRetryableException`, parks as dependents of `src`, and is re-queued once `src` resolves; this exercises the retryable re-queue path together with the blocking branch. Both assert only the outcome (every flow resolves and the call returns), so they are deterministic and have no timing dependence - a regression that deadlocked would hang until the suite times out. Asserting the absence of a busy-wait directly is not included, since that requires CPU-time or timing measurements that are flaky in CI. Existing graph-resolution suites (`ConnectValidPipelineSuite`, `ConnectInvalidPipelineSuite`, `SqlPipelineSuite`, `TriggeredGraphExecutionSuite`, `MaterializeTablesSuite`) still pass; the change only affects how the loop waits, not what it resolves. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) Closes #56700 from LuciferYang/sdp-resolution-busy-wait. Authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com> (cherry picked from commit 0747e28) Signed-off-by: yangjie01 <yangjie01@baidu.com>
What changes were proposed in this pull request?
DataflowGraphTransformer.transformDownNodesresolves flows on a bounded thread pool and drives them from awhileloop that, each pass, partitioned the in-flight futures with the non-blockingfuture.isDone, reaped the completed ones, and scheduled a new flow if a slot was free. When all slots were in flight (or the queue was drained and only the last futures remained) and none had completed, the pass reaped nothing and scheduled nothing, then looped again immediately - busy-spinning onisDoneand pinning a core for the duration of resolution.This drives the loop with an
ExecutorCompletionServiceinstead: completed tasks are drained with the non-blockingpoll(), and when nothing can be scheduled but tasks are still running, the loop blocks ontake()until the next one finishes rather than spinning. Behavior is otherwise unchanged - the same flows are scheduled in the same order, exceptions are still propagated viaFuture.get(), and anoutstandingcounter replaces theArrayBuffer[Future]for slot bookkeeping.Why are the changes needed?
Resolving a graph with more flows than the parallelism (10) kept one CPU core busy at 100% doing no useful work for the whole resolution, which is wasteful and shows up as unexplained driver CPU.
Does this PR introduce any user-facing change?
No.
How was this patch tested?
Two new cases in
ConnectValidPipelineSuitecover the regime this PR changes - more flows thanparallelism(10), so the slots fill and the loop reaches the blockingtake()branch that replaces the busy-wait. The small graphs in the existing suites never get there.resolution terminates and resolves all flows when flow count exceeds parallelism- 25 independent flows.resolution re-queues retryable flows under load when consumers exceed parallelism- 20 consumers registered before their sourcesrc, so the first batch throwsTransformNodeRetryableException, parks as dependents ofsrc, and is re-queued oncesrcresolves; this exercises the retryable re-queue path together with the blocking branch.Both assert only the outcome (every flow resolves and the call returns), so they are deterministic and have no timing dependence - a regression that deadlocked would hang until the suite times out. Asserting the absence of a busy-wait directly is not included, since that requires CPU-time or timing measurements that are flaky in CI.
Existing graph-resolution suites (
ConnectValidPipelineSuite,ConnectInvalidPipelineSuite,SqlPipelineSuite,TriggeredGraphExecutionSuite,MaterializeTablesSuite) still pass; the change only affects how the loop waits, not what it resolves.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)