FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

[SPARK-57399][CORE][SQL] Local repartition: in-process pipelined channel shuffle for a single executor by viirya · Pull Request #58097 · apache/spark · GitHub

/ spark Public

[SPARK-57399][CORE][SQL] Local repartition: in-process pipelined channel shuffle for a single executor - #58097

Open
viirya wants to merge 53 commits into
apache:masterfrom
viirya:local-repartition-v2-pr
Open

[SPARK-57399][CORE][SQL] Local repartition: in-process pipelined channel shuffle for a single executor#58097
viirya wants to merge 53 commits into
apache:masterfrom
viirya:local-repartition-v2-pr

Conversation

viirya commented Aug 19, 2026
edited
Loading

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

This PR adds an opt-in, in-process channel transport for pipelined shuffles and wires it
into the SQL layer, so a local-mode batch query can run its shuffle exchanges as pipelined
shuffles served entirely within one JVM -- producer and consumer stages co-scheduled by the
concurrent-stage scheduler, with records flowing through bounded in-memory queues instead of
shuffle files.

It builds on the already-merged pipelined-shuffle infrastructure -- PipelinedShuffleDependency
and dependency-type shuffle routing (SPARK-58185), concurrent-stage scheduling (SPARK-58263),
group-atomic failure and fail-fast rejection (SPARK-58398), and the MapOutputTracker decoupling
(SPARK-58454) -- and adds the pieces specific to a local, in-process transport.

New core transport (org.apache.spark.shuffle.local.pipelined):

  • ChannelShuffleRendezvous: a process-wide rendezvous holding one bounded LinkedBlockingQueue
    per (shuffleId, reducePartitionId). Every map task writing a reduce partition shares the
    queue with the single reduce task that drains it. Queue elements are BATCHES of records (an
    Array of pairs) or an end-of-stream marker, so the queue's per-operation lock cost is paid
    per batch, not per row.
  • ChannelShuffleWriter / ChannelShuffleReader: the ShuffleWriter/ShuffleReader for the
    transport. The writer batches records per reduce partition, pushes full batches onto the
    queues, and emits one end-of-stream marker per partition; the reader drains its partition's
    queue until it has seen numMaps end-of-stream markers. Records + read/write time are
    reported to the shuffle metrics (no byte metrics: an in-process transport serializes nothing,
    so there is no wire-byte count).
  • PipelinedChannelShuffleManager: a PipelinedShuffleManager that mints the writer/reader.
    It declares usesStreamingShuffleOutputTracker = false (the reader/writer find each other by
    (shuffleId, partition) in-JVM, so no writer-location directory is needed) and
    requiresDetachedRecords = true (records cross to a concurrent consumer thread, so the SQL
    layer must copy each row off the producer's reused buffer). It requires local mode in its
    constructor -- a cross-executor deployment would give each executor its own empty queue map
    and hang every reader, so it fails loud at startup instead.

SQL integration:

  • EnablePipelinedShuffle (non-AQE) and AQEEnablePipelinedShuffle (AQE) rewrite eligible
    ShuffleExchangeExec nodes to pipelined = true. Both gate on the opt-in flag, on local
    mode, and on the in-process channel manager actually being the configured pipelined manager;
    otherwise they leave the plan regular. Both leave a plan regular when it contains a reused
    exchange (a pipelined producer cannot fan out to more than one consumer), and both refuse to
    pipeline a shuffle read by a CoalesceExec (a coalesced reader would drain several reduce
    partitions per task, which the in-process channel transport cannot serve without deadlocking).
  • ShuffleExchangeExec gains a pipelined flag and, for the pipelined path, copies each row
    off the producer's reused buffer before it is handed across the channel.
  • Three registered configs: spark.sql.pipelinedShuffle.enabled (default false) turns the
    rewrite on; spark.shuffle.pipelined.channel.batchSize (default 1024) sets the per-partition
    batch size; spark.shuffle.pipelined.channel.queueCapacity (default 64) sets each queue's
    depth in batches (the backpressure bound and heap-residency knob).
    spark.shuffle.manager.incremental selects the channel manager.

Scheduler:

  • DAGScheduler's job-shape classification is relaxed to admit a MATERIALIZED-PREFIX MIXED job:
    a job may mix regular and pipelined shuffles when every regular boundary reachable from the
    final RDD is fully materialized and no pipelined shuffle sits below a regular one. This is the
    shape adaptive execution produces (prior map-stage jobs materialize the prefix; the final job
    runs the pipelined tail). An unmaterialized regular prefix, and a pipelined shuffle below a
    regular boundary, stay rejected fail-fast. The relaxation is a strict superset: previously
    rejected shapes now run, and every previously-valid job classifies and schedules identically
    (an all-pipelined job is unchanged, pinned by a test). The DAGScheduler also passes the
    result stage's live reduce partitions to the producer, so a partial-read job (LIMIT /
    executeTake reads a subset) does not fill and wedge the queues of partitions no consumer will
    drain.

Why are the changes needed?

Local repartition (a shuffle whose producer and consumer are co-located in one JVM) does not
need the durable, file-based, cross-executor machinery of a regular shuffle. Serving it through
an in-process channel -- records handed directly from writer to a concurrently running reader --
avoids the shuffle-file write/read, block-manager, and serialization/fetch startup costs, which
dominate for the small-to-medium shuffles typical of a single-executor deployment.

Crucially, this reuses Spark's own scheduling machinery (the already-merged concurrent-stage /
pipelined-shuffle infrastructure) rather than introducing a parallel mechanism outside the
shuffle framework: the two shuffle sides remain real, separately scheduled stages, so the shuffle
boundary stays visible in the UI and to AQE, and the same scheduler serves both regular and
pipelined shuffles routed by dependency type. Each transport serves the workload it was built
for -- the RPC streaming transport is latency-optimized for cross-executor streaming, while
the in-process channel is throughput-optimized for local batch (a three-way transport benchmark
in this PR shows the channel beating a regular shuffle while the RPC streaming transport loses to
it on batch shapes, which is why local batch needs its own transport rather than reusing the
streaming one).

Does this PR introduce any user-facing change?

No behavior change by default: the feature is off unless spark.sql.pipelinedShuffle.enabled is
set to true (default false) AND the in-process channel manager is configured via
spark.shuffle.manager.incremental, both in local mode. With those set, eligible shuffle
exchanges of a batch query run as in-process pipelined shuffles; results are unchanged, and the
shuffle appears in the UI as concurrently scheduled producer/consumer stages rather than a
materialized boundary. Three new configs are added (all documented,
spark.sql.pipelinedShuffle.enabled, spark.shuffle.pipelined.channel.batchSize, and
spark.shuffle.pipelined.channel.queueCapacity).

How was this patch tested?

New unit and end-to-end suites, all passing:

  • PipelinedChannelShuffleSuite (core): the channel transport loses/duplicates no rows and
    routes correctly; matches a regular shuffle's grouping; slot admission fits/rejects a group;
    the manager refuses to construct outside local mode; a materialized regular prefix runs
    end-to-end while an unmaterialized one is rejected; ContextCleaner frees the channel's queues
    for a tracker-less pipelined shuffle; the tracker-less cleanup arm is scoped to shuffles the
    manager actually holds; and deterministic unit tests for the abandon / end-of-stream-counting
    logic (abandon marks and drains; removeShuffle drops all epochs; a re-run's epoch isolates it
    from a prior run's leftover queue and marks; the reader stops after exactly numMaps markers).
  • PipelinedShuffleSqlSuite, AQEPipelinedShuffleSuite (sql): batch queries -- repartition,
    keyed groupBy, range partitioning, sort-merge join, single-partition aggregate, chains through
    SinglePartition -- run end-to-end through the channel and produce correct results against an
    independently computed ground truth, under both the non-AQE and AQE rules; over-wide plans fail
    loud at admission; cross-subquery reuse cannot create a shared pipelined exchange.
  • PipelinedLimitHangSuite (sql): a LIMIT over a pipelined shuffle completes and returns the
    correct rows in both AQE modes (an early-stopping reader must not wedge the writer).
  • DAGSchedulerSuite (core): the materialized-prefix relaxation accepts a fully-materialized
    mixed job and rejects unmaterialized-prefix / pipelined-below-regular; a FetchFailed on a
    pipelined group member (including one reading an external materialized prefix) aborts the whole
    group rather than resubmitting a lone stage; an all-pipelined job classifies identically
    under the relaxation (a previously-valid shape is unchanged).
  • PipelinedShuffleRoutingSuite, ContextCleanerSuite: routing by dependency type and cleanup.

A PipelinedShuffleBenchmark (on the standard SqlBasedBenchmark framework, with a checked-in
results file) compares the channel against the regular shuffle and against the RPC streaming
transport across batch shapes.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code

viirya added 30 commits August 5, 2026 13:08
…ffle prototype

Starting point for exploring an alternative local-repartition implementation that
reuses the concurrent-stage pipelined-shuffle machinery (merged for Real-Time Mode)
but with an in-process bounded-channel transport instead of the RPC streaming
shuffle. Proven end-to-end on local[8]: 1000 rows repartitioned through a
PipelinedShuffleDependency + a channel-backed ShuffleManager, no Netty / no
serialization / no MapOutputTracker.

This is a self-contained prototype (manager + rendezvous + writer + reader + test in
one file under test/). Next steps: refactor into production shape (main/ code + real
tests), and address the two known constraints (slot-check ceiling on single-machine
multi-stage queries; the StreamingShuffleManager coupling that a real transport plug-in
needs decoupled upstream). See plans/LOCAL_REPARTITION_PIPELINED_SHUFFLE_FEASIBILITY.md.

Co-authored-by: Claude Code
…huffle into production shape

Splits the throwaway single-file prototype into proper main/ + test/ code under
org.apache.spark.shuffle.local.pipelined:

  - ChannelShuffleRendezvous: JVM-static per-(shuffleId, reducePartitionId) bounded-queue
    registry with end-of-stream marker and per-shuffle cleanup.
  - ChannelShuffleWriter / ChannelShuffleReader: the in-process transport (map side pushes
    copied pairs onto the reduce partition's queue as produced; reduce side drains until
    every map task has signalled end-of-stream). No serialization / disk / network.
  - PipelinedChannelShuffleManager: subclasses StreamingShuffleManager (so SparkEnv creates
    the StreamingShuffleOutputTracker the pipelined DAGScheduler path requires) and
    overrides only getWriter/getReader to use the channel transport; unregisterShuffle
    drops the shuffle's queues.

Test suite (5 tests, all green): end-to-end repartition correctness + routing, parity with
a regular shuffle, an admissible fan-in, an explicit assertion that a group whose
whole-group demand exceeds the slots is rejected up front (the single-machine ceiling of
the pipelined model, pinned as a documented constraint), and the unregister cleanup
contract.

Still a v2 exploration, not for merge as-is: the StreamingShuffleManager coupling and the
slot-check ceiling remain open (see plans/LOCAL_REPARTITION_PIPELINED_SHUFFLE_FEASIBILITY.md).

Co-authored-by: Claude Code
…om the concrete streaming manager

The StreamingShuffleOutputTracker was created only when the pipelined shuffle manager
isInstanceOf[StreamingShuffleManager] (SparkEnv), and a PipelinedShuffleDependency
unconditionally demanded it (DAGScheduler). But the tracker is purely a transport
directory of writer host/port locations, read only by the RPC StreamingShuffleReader;
pipelined scheduling (co-scheduling, deferred completion, stage availability) runs off
ShuffleMapStage.pipelinedCompletedPartitions and never consults it. So an in-process
pipelined transport that finds its reader/writer pairs within one JVM needs no tracker,
yet the isInstanceOf coupling forced it to subclass the concrete RPC streaming manager
just to get the tracker created.

Decouple it by capability rather than concrete type:

  - PipelinedShuffleManager gains `usesStreamingShuffleOutputTracker` (default true, so the
    built-in StreamingShuffleManager is unaffected).
  - SparkEnv.initializeStreamingShuffleOutputTracker keys creation off that flag instead of
    isInstanceOf[StreamingShuffleManager].
  - DAGScheduler.outputTrackerMaster returns Option; a pipelined dep whose manager declares
    no tracker registers with none (createShuffleMapStage's registration becomes a no-op via
    Option.foreach). When a tracker IS expected but absent, it still fails loudly.

This lets PipelinedChannelShuffleManager implement the PipelinedShuffleManager trait
directly (usesStreamingShuffleOutputTracker = false) instead of subclassing
StreamingShuffleManager, removing the workaround the prototype needed.

Regression: RTM pipelined DAGScheduler tests 51/51, streaming shuffle + tracker suites
74/74, PipelinedChannelShuffleSuite 5/5 (now on the clean trait path). No behavior change
for the RPC streaming manager, which keeps the default `usesStreamingShuffleOutputTracker`.

Co-authored-by: Claude Code
… batch query

Adds the minimal SQL wiring to drive a batch query through the pipelined channel shuffle,
and fixes two bugs that only the real SQL (UnsafeRow) path exposes -- the core-RDD tests
missed them because boxed Integers are not buffer-reused.

SQL entry point:
  - EnablePipelinedShuffle: opt-in preparation rule (spark.sql.pipelinedShuffle.enabled,
    AQE off) that rewrites hash-partitioning ShuffleExchangeExec nodes to pipelined=true.
    Conservative all-or-nothing gate: rewrites only when every shuffle in the plan is
    HashPartitioning (a SinglePartition/Range shuffle is a regular ShuffleDependency, and
    mixing regular + pipelined in one job is rejected) and none is reused (fan-out is
    rejected). Runs last in preparations so it sees the final reuse decision.

Bug 1 -- reader ignored the reduce-partition range. ShuffledRowRDD's
CoalescedPartitionSpec can map a range [startPartition, endPartition) to one reduce task;
the reader drained only startPartition, stranding the rest. ChannelShuffleReader now drains
every queue in the range.

Bug 2 -- reused row buffers. SQL producers reuse their output UnsafeRow across iterations,
so enqueuing the row as-is made every pair alias the same buffer (groupBy collapsed to a
couple of keys with halved counts). A regular shuffle avoids this by serializing on write;
the channel skips serialization, so the row must be copied. The copy cannot be done in
core's ChannelShuffleWriter (core cannot reference InternalRow, and UnsafeRowSerializer has
no single-object serialize), so it is done in the SQL layer: createShuffleWriteProcessor
gains copyRows (true only for the pipelined path) and its write() copies each value row
before delegating. This mirrors v1's SQL-layer row.copy() and keeps the transport
zero-serialization.

End-to-end: PipelinedShuffleSqlSuite runs df.repartition($k) and groupBy($k).count() batch
on local[8] through rule -> pipelined dependency -> channel manager -> concurrent-stage
scheduler, asserting the exchange went pipelined and results match. Both green;
PipelinedChannelShuffleSuite still 5/5.

Co-authored-by: Claude Code
…onings, cover join

Relaxes EnablePipelinedShuffle from hash-only to rewriting EVERY ShuffleExchangeExec
(hash, single-partition, range) to pipelined. Rewriting all of them keeps the job
all-pipelined, which the DAGScheduler requires (a mix of pipelined and regular shuffles is
rejected); the previous hash-only gate left SinglePartition/Range exchanges regular and so
skipped any query with a final ORDER BY / LIMIT / global aggregate. The channel transport
does not care about the partitioning kind -- it routes by partitioner.getPartition(key),
and SinglePartition is just numPartitions == 1. The no-reuse skip stays (a pipelined
producer cannot fan out to more than one consumer).

Test coverage now spans all TPC-DS transport shapes, all end-to-end batch on local[16]
(high slot cap so the whole-group admission passes; correctness harness, not perf):
  - repartition (hash)
  - keyed groupBy (partial -> hash -> final)
  - groupBy + ORDER BY (hash + SinglePartition, both pipelined, mixed-job rejection avoided)
  - SortMergeJoin (both inputs hash-exchanged and pipelined concurrently)

The join test also confirms the no-reuse gate: an earlier version with structurally
identical join inputs triggered exchange reuse, which the rule correctly declined to
pipeline; distinct inputs exercise the two-sided pipelined path.

core PipelinedChannelShuffleSuite 5/5, sql PipelinedShuffleSqlSuite 4/4.

Co-authored-by: Claude Code
…add benchmark

Per-row queue hand-off was the transport's bottleneck: LinkedBlockingQueue costs a lock
acquisition per operation (~300ns under contention), paid PER ROW, which made an
unaggregated 20M-row repartition ~19x SLOWER than a regular shuffle (6.5s vs 0.35s).
Isolation confirmed the diagnosis: raising the queue capacity from 64 to 4M changed
nothing (not backpressure), and 6.2s / 20M rows = ~310ns/row matches the lock cost;
per-row copy is not the driver (v1's object-batch pays the same copy and wins).

Fix is the same lesson v1's object-batch transport learned: the writer accumulates rows
in a per-output-partition Array[AnyRef] (spark.shuffle.pipelined.channel.batchSize,
default 1024) and hands a full batch across the queue in ONE operation; partial batches
are trimmed and flushed before the end-of-stream markers. The reader drains batch by
batch and iterates rows out of each array. Lock traffic drops by ~batchSize (20M -> 20K
operations). Batch arrays are ownership-transferred (writer allocates a fresh one after
each put), so no cross-thread buffer reuse.

Benchmark (new PipelinedShuffleBenchmark, local[16] on 16 cores, group demand 14 <= 16
so no oversubscription; 20M rows, best of 6):
  repartition(k) + count   regular 326ms   pipelined 228ms   1.43x  (was 6537ms, 0.05x)
  groupBy(k).count         regular  67ms   pipelined  39ms   1.72x

core PipelinedChannelShuffleSuite 5/5, sql PipelinedShuffleSqlSuite 4/4.

Co-authored-by: Claude Code
…e; cover range/single

Testing RangePartitioning end-to-end (repartitionByRange via Dataset.rdd) exposed silent,
racy row loss (875/750 of 1000 rows, ~2/3 reproducible) that had nothing to do with range
itself: Dataset.rdd builds its RDD inside a SQL execution scope that ends BEFORE any job
runs, and spark.sql.classic.shuffleDependency.fileCleanup.enabled (default under testing)
removes the shuffle from every manager at scope end. That wiped the channel manager's
numMapsByShuffle registry entry between registration and execution; the reader then read
the missing entry as numMaps = 0 (null unboxed), stopped at the FIRST end-of-stream marker
instead of one per map task, and dropped whatever the other writer had not yet enqueued.
Diagnosed by instrumenting the channel (readers logged "seen=1/0") and stack-tracing
unregisterShuffle to SQLExecution's execution-end cleanup RPC.

Fix: keep no per-shuffle mutable state in the manager at all. The map-task count is stamped
into a ChannelShuffleHandle at registration; the handle travels with the dependency into
every task, a plain Int survives task serialization (deriving from handle.dependency.rdd
NPEs in a deserialized task -- the dependency's rdd reference is @transient), and no later
unregister can take it away. unregisterShuffle now only drops the rendezvous queues.

Coverage corrections and additions in PipelinedShuffleSqlSuite (7/7, the once-flaky
Dataset.rdd regression test now 5/5 stable):
  - The ORDER BY test's exchange is RangePartitioning, not SinglePartition as previously
    claimed; it now pins that (and exercises RangePartitioner's sample job running over a
    pipelined hash shuffle before the main job).
  - New repartitionByRange test: pure range transport, asserts non-overlapping key ranges.
  - New global-aggregate test: a true SinglePartition exchange (numPartitions == 1).
  - New Dataset.rdd regression test for the unregister-before-job scenario.

core PipelinedChannelShuffleSuite 5/5.

Co-authored-by: Claude Code
…'s sampling penalty

Adds two RangePartitioning cases to PipelinedShuffleBenchmark, separating the control from
the differential (viirya's hypothesis: the range sample job re-runs the child, which
regular shuffle amortizes via materialized map output but a single-shot channel cannot):

  repartitionByRange(k) + count   regular 414ms  pipelined 338ms  1.22x
  groupBy(k).count + orderBy(k)   regular  72ms  pipelined  73ms  0.99x

Range over a plain scan (control): no differential -- BOTH modes re-run the scan for the
sample job (nothing materialized below the range exchange for regular to reuse either);
pipelined still wins, with the margin narrowed by the shared sample-pass cost.

Range above a shuffle (differential): confirmed exactly. Pipelined pays one full extra
upstream pass -- its 73ms is 2x the groupBy-alone 37ms -- because the sample job consumes
the single-shot hash channels and completed-job stage cleanup makes the main job re-run
scan + partial agg + map side. Regular's main run reuses the hash map output (63ms -> 72ms
only). The transport's 1.7x per-pass advantage exactly cancels the extra pass here; a
heavier upstream would turn this into a net loss.

Co-authored-by: Claude Code
…4 queries verified

Adds TPCDSPipelinedRunner: builds SF=1 parquet from dsdgen .dat files (trailing-delimiter
handled), registers views, and runs selected TPC-DS queries twice in one session --
pipelined rule off (regular baseline) then on -- comparing full result sets. Correctness
harness only: local[128] clears the gang slot check by oversubscribing the cores
(spark.sql.files.maxPartitionBytes=512m keeps fact-scan producer partitions low; the first
attempt at local[64] was rejected at demand 68), so timing is meaningless by design.

Results, all rows identical to the regular-shuffle baseline:
  q3 q42 q52 q55 q7 q19    OK  1 pipelined hash exchange each (star join + agg;
                               ORDER BY+LIMIT becomes TakeOrderedAndProject, no range)
  q12 q34 q73 q79 q96 q20  OK  1-2 pipelined exchanges
  q98                      OK  3 pipelined exchanges: RangePartitioning + 2 hash --
                               a real query exercising the pipelined range path,
                               sampling included
  q68                      OK  reuse gate fired (ReusedExchange present): rule left the
                               plan regular, results still correct -- the fallback is safe

14/14 correct; 13 executed the v2 channel transport end-to-end.

Co-authored-by: Claude Code
…s timed fairly

Adds a `bench` mode to TPCDSPipelinedRunner: same off/on structure as `verify` but timed
(1 warm-up + best of 5) on local[N = physical cores], so the pipelined group does not
oversubscribe and the regular-vs-pipelined comparison is fair. Fitting the gang under 16
honest slots required squeezing scan parallelism for BOTH modes
(files.maxPartitionBytes=64m + minPartitionNum=1 -> ~5 store_sales scan partitions;
FilePartition planning otherwise targets defaultParallelism leaves and every query was
rejected at demand 17-26) -- reduced scan parallelism is itself the honest cost of the
slot ceiling on one box.

SF=1, 16 cores, best of 5 (geomean of the 12 that ran: ~1.10x):
  q3 1.44x  q42 1.21x  q52 1.20x  q20 1.18x  q55/q7/q12 1.13x  q19 1.09x
  q79 1.04x  q96 1.01x (SinglePartition)
  q34 0.94x, q73 0.85x -- the two RANGE-bearing plans, confirming the RangePartitioner
    sampling double-pass penalty on real queries (sample job re-runs the unmaterialized
    pipelined upstream; regular reuses its materialized map output)
  q98 REJECTED (demand 18 > 16): the one 3-stage group that still does not fit

Co-authored-by: Claude Code
…ibe instead of squeeze

Adds a third runner mode answering viirya's challenge to the bench methodology: instead of
squeezing scan parallelism until the gang fits the physical cores, keep near-natural scans
(32m -> ~13 store_sales partitions vs the box's natural 16) and raise local[n] to
2*cores so gang admission passes and the pipelined group oversubscribes the cores. The
regular baseline is unaffected by the larger n (its per-stage width still fits the
physical cores), so the contention cost lands only on the pipelined side -- measuring it
is the point.

Results (SF=1, local[32] on 16 cores, best of 5): all 13 queries ran, including q98
(0.86x -- previously rejected at demand 18; its loss matches v1's 0.85x on the same query,
again pinning the range-sampling penalty on the shared unmaterialized-transport property).
Geomean ~1.08x vs the squeezed bench's ~1.10x: mild oversubscription (demand <= 2x cores)
costs only a few percent and does not change the qualitative picture -- the earlier claim
that oversubscribed timing is meaningless holds for local[128], not for this regime.
Small-data wrinkle: at SF=1 BOTH modes ran slightly faster with the squeezed 5-partition
scans than with 13 (per-task overhead dominates tiny scans), so the "sacrificed" scan
parallelism cost nothing here; at larger scale factors that reverses.

Co-authored-by: Claude Code
…2 wins bigger

Adds v1's LocalRepartitionBenchmark "prototype workload" (1M rows, UNIQUE key per row,
uncached, wide column pruned -> pure transport overhead) to PipelinedShuffleBenchmark, in
two variants. v1's historical record on it was 4.1-4.5x over regular shuffle (AQE off,
local[32]); reproduced today on the v1 branch at 4.35x under the matching 32-map shape.

  prototype, 6 maps, local[16]:        regular  ~52ms   v1 33ms (1.55x)   v2 26ms (2.08x)
  prototype, 32 maps, local[48]:       regular ~136ms   v1 31ms (4.35x)   v2 25ms (5.48x)

v2 shows the same large-multiple behavior and beats v1 on its own flagship shape. The
mechanism the two variants isolate: going 6 -> 32 maps leaves both in-process transports
flat (25-33ms) while the REGULAR baseline degrades 52 -> 136ms -- tiny data multiplied by
many map tasks inflates the regular shuffle's fixed per-task/per-segment cost, which is
what the big multiple actually measures (and why real TPC-DS queries sit at 1.1-1.4x).
Caveat: the 32-map gang demand is 41, so v2's 5.48x requires local[48] oversubscription
(v1 was run at the same master for symmetry); at honest local[16] only the 6-map variant
is admissible.

Co-authored-by: Claude Code
…inst subquery reuse

The gate was plan.exists(_.isInstanceOf[ReusedExchangeExec]), which walks the operator
tree only -- reuse landing inside a subquery plan (embedded in an expression) was
formally invisible to it. Probing every SQL route to that shape showed none can currently
produce a reused PIPELINED exchange, each closed by a different layer:

  1. Same-tree reuse: the gate catches it (q68, join shapes).
  2. Main-vs-subquery reuse: never fires. Each subquery runs its own full preparation
     pass (PlanSubqueries -> prepareExecutedPlan, which includes EnablePipelinedShuffle),
     so its exchanges are already pipelined=true when the outer ReuseExchangeAndSubquery
     compares canonical forms against the outer, not-yet-pipelined exchange -- the
     `pipelined` field diverges and reuse does not match. Accidental, but load-bearing.
  3. Subquery-vs-subquery duplication: MergeScalarSubqueries / subquery-level reuse
     collapses the duplicates into ONE executed subquery before exchange reuse matters.

Mechanisms 2 and 3 are rule-ordering and optimizer accidents, so the gate now checks
collectWithSubqueries instead of relying on them. A regression test pins all observed
facts (no reused exchange materializes, subquery exchanges pipeline via their own
preparation, both probe queries return correct results) so a change in any layer
surfaces. Suite 8/8.

Co-authored-by: Claude Code
…ow a pipelined suffix

Relaxes the all-regular-or-all-pipelined job rule to admit the MATERIALIZED-PREFIX MIXED
shape: pipelined shuffles in the region reachable from the final RDD, where every regular
shuffle boundary at that region's edge is fully materialized (all MAP outputs registered
with the MapOutputTracker) and no pipelined shuffle sits below any regular boundary. The
prefix never re-runs, so the job executes exactly like an all-pipelined job whose leaves
read materialized shuffle data, and gang admission demand (final stage + suffix producers)
is unchanged. This is the shape adaptive execution produces -- prior map-stage jobs
materialize the prefix stages, the final job runs the pipelined tail -- and is the
scheduler-side prerequisite for running the v2 pipelined shuffle under AQE.

Still rejected, fail-fast with no partial scheduler state:
  - an UNMATERIALIZED regular boundary in a pipelined job: its stage would have to run
    while gang-admitted producers hold slots blocked on transport backpressure, which
    admission does not account for and can deadlock (sequencing the prefix before the gang
    is future work);
  - a pipelined shuffle BELOW a regular boundary.

classifyJobShuffleKinds is replaced by classifyJobShuffleShape: a suffix walk that stops
at regular boundaries, then a materialization check (against the producer RDD's partition
count -- the tracker counts MAP outputs; the first version compared the reducer-side
partitioner and the end-to-end test's asymmetric 2-map/3-reduce prefix caught it) and a
below-boundary pipelined scan per boundary.

Tests: DAGSchedulerSuite gains the accepted-shape test (asymmetric map/reduce counts to
pin the map-side materialization check); the two existing rejection tests keep passing
with comments updated to the refined rule; PipelinedChannelShuffleSuite gains end-to-end
materialized-prefix (runs, no row loss) and unmaterialized-prefix (still rejected) tests.
DAGSchedulerSuite 213/213, streaming 74/74, MultiShuffleManager 4/4, channel 7/7,
PipelinedShuffleSqlSuite 8/8.

Co-authored-by: Claude Code
…der AQE

Builds the AQE-side half on top of the materialized-prefix scheduler relaxation
(3958627), adapting v1's AQEReplaceWithLocalRepartition design to v2's mechanics:

1. AQEEnablePipelinedShuffle (queryStagePreparationRules, after skew handling): flips
   eligible exchanges to pipelined using v1's placement policy -- "free" candidates whose
   path to the root crosses nothing stats-sensitive, ShuffledJoin inputs only as symmetric
   pairs, duplicated canonical forms skipped (incl. materialized stages and subqueries).
   Unlike v1 (hash-only operator, SinglePartition treated as a transparent regular wall),
   a pipelined exchange supports every partitioning, so SinglePartition exchanges in free
   position are simply candidates. The walk stops below a flipped exchange: everything
   underneath stays regular and keeps full AQE treatment (coalescing, skew, join
   switching).

2. AdaptiveSparkPlanExec.createNonResultQueryStages: a pipelined exchange is never
   promoted to a query stage (a pipelined producer cannot materialize alone -- the
   scheduler rejects a map-stage job over a pipelined dep); it stays inline and the walk
   recurses through it, so stages below still materialize as the regular prefix. The final
   result job is then exactly the scheduler's admitted shape: fully-materialized prefix +
   pipelined suffix gang.

AQEPipelinedShuffleSuite (4/4): single-exchange aggregate (whole final job pipelined, no
stage materialized), groupBy + ORDER BY (the canonical shape: hash exchange materialized
as a ShuffleQueryStageExec prefix, range exchange pipelined on top, results correct),
shuffled-join pair flip, and an off/on baseline comparison. Plan assertions are made on
the SAME Dataset that executed (.as[...] creates a fresh QueryExecution; an unexecuted
sibling shows the initial isFinalPlan=false plan -- the first version asserted on that and
passed two tests vacuously). AdaptiveQueryExecSuite 129/129 (rule is opt-in and inert by
default), PipelinedShuffleSqlSuite 8/8, channel suite 7/7.

Co-authored-by: Claude Code
…h SinglePartition

First AQE-on benchmark exposed that the placement rule accelerated only the topmost
exchange: repartition(k)+count flips just the trivial SinglePartition agg exchange while
the 20M-row hash exchange below it materializes as a regular stage (1.09x vs v1's 1.83x
-- v1's AQE walk treats SinglePartition as transparent and replaces the hash below, its
historical 7e10df5 lesson). Adopt it for v2: a flipped SinglePartition candidate keeps the
walk going, flipping free candidates below into a pipelined CHAIN (v2's all-pipelined
constraint means the single exchange must flip along, where v1 left it regular).

Chain flipping surfaced a second bug: transformUp rebuilds children first, so the upper
candidate node reaching the pattern is a new instance whose flipped child no longer
matches the collected original structurally -- the upper flip silently dropped, leaving a
regular exchange above a pipelined one, which the scheduler correctly rejected as
pipelined-below-regular. Fixed by transformDown (candidates hit the pattern before their
subtrees are rebuilt). v1 never nests replacements, which is why its transformUp is safe.

AQE-on mirror benchmark (local[16], 20M rows / 1M prototype, best of 6), after the fix:
  repartition(k)+count   regular ~340ms   v1 1.83x   v2 1.09x -> 1.73x
  groupBy(k).count       regular  ~55ms   v1 1.10x   v2 1.08x -> 1.51x
  groupBy+orderBy        regular  ~68ms   v1 1.06x   v2 1.08x (range tail is trivial)
  prototype 1M uniq      regular  ~56ms   v1 1.62x   v2 1.17x -> 2.28x

AQEPipelinedShuffleSuite 4/4, AdaptiveQueryExecSuite 129/129.

Co-authored-by: Claude Code
…on head-to-head

Adds a `benchaqe` runner mode (bench configs + adaptive execution on; plan summaries via
AdaptiveSparkPlanHelper.collect and an executed probe -- TreeNode.collect sees an AQE plan
as exchange-less, and an unexecuted adaptive plan is the initial one).

AQE-on results (SF=1, local[16], best of 5; v1 numbers from its mirrored scratch runner):
v2 fires on 12/13 queries, geomean ~1.12x; v1 fires on 8/13, geomean ~1.09x. Speedups on
shared queries are comparable (v1 slightly ahead on q3/q12, v2 on q7/q19/q55). The
structural differences:
  - q98 RUNS at honest local[16] under AQE (1.15x; rejected at demand 18 AQE-off): gang
    demand now scopes to the pipelined tail over materialized stages.
  - The range-sampling tax vanishes under AQE for aggregate+ORDER BY shapes: q34 went
    0.94x (AQE off) -> 1.17x. The hash stage below the range exchange is materialized, so
    RangePartitioner's sample job re-runs only the cheap reduce side over materialized
    output instead of the whole upstream.
  - v1's AQE rule skips q34/q73/q98 (hash below a stats-sensitive range exchange) and q96
    (SinglePartition-only): shapes only v2's any-partitioning flip can serve.
  - q79 is skipped by both (its remaining exchange feeds a join; no symmetric pair).

Co-authored-by: Claude Code
…g RTM's hot path

Auditing our changes for RTM impact found one real (perf-only) regression: the pipelined
branch of prepareShuffleDependency passed copyRows = true unconditionally, and RTM's
real-time plans take the same branch -- its RPC streaming transport serializes records
promptly (serialization IS the detach), so the per-row InternalRow.copy() was pure added
overhead on RTM's hot path. Correctness was unaffected, which is why every suite stayed
green while the cost crept in.

Express the need as a manager capability, the same pattern as the tracker decoupling:
PipelinedShuffleManager.requiresDetachedRecords (default false, so the built-in streaming
manager keeps its no-copy behavior); the in-process channel manager overrides it to true
(records cross the channel as object references read by a concurrent consumer thread);
the SQL pipelined branch reads the capability off SparkEnv's pipelined manager (always
initialized; defaults to streaming).

ExchangeSuite 16/16, PipelinedShuffleSqlSuite 8/8, AQEPipelinedShuffleSuite 4/4, channel
7/7, streaming 74/74.

Co-authored-by: Claude Code
…his; v2 did not)

viirya asked whether v2 carries v1's local-mode restriction -- it did not, anywhere. The
channel rendezvous is JVM-local, so on a multi-executor deployment every reader would
block forever on data written in another JVM: a silent hang, the worst failure mode for a
misconfiguration. Three layers, mirroring v1's gates plus a hard floor v1 does not need:

  - PipelinedChannelShuffleManager now REQUIRES a local master at construction (fails the
    app loudly at startup with a clear message; unit-tested both ways).
  - EnablePipelinedShuffle and AQEEnablePipelinedShuffle return the plan untouched off
    local mode, matching v1's ReplaceWithLocalRepartition / AQEReplaceWithLocalRepartition
    gates. (The pipelined machinery itself is not local-only -- the RPC streaming
    transport is cross-executor -- but batch queries over it are unexplored, so the rules
    stay conservative.)

channel 8/8, PipelinedShuffleSqlSuite 8/8, AQEPipelinedShuffleSuite 4/4.

Co-authored-by: Claude Code
… concurrency limit

Second gap found by the v1-gate audit (after the local-mode gate): v1 refuses to replace
an exchange whose partition count exceeds SparkContext.defaultParallelism -- its
channel-deadlock precondition, and under AQE additionally an output cap -- degrading
gracefully to a regular run. v2 had no such gate and leaned on the scheduler's gang slot
check, which FAILS the query instead of skipping the optimization: with the DEFAULT
spark.sql.shuffle.partitions=200 on a 16-core box, enabling the flag would fail
essentially every query (our suites masked this by setting partitions=4).

Both rules now cap candidates at defaultParallelism: the non-AQE rule skips the WHOLE
plan if any exchange is wider (a partial flip would be a rejected mixed job); the AQE
rule skips per candidate (a skipped candidate materializes as a regular stage, which the
prefix shape supports). Regression tests pin the degrade-to-regular behavior at
shuffle.partitions=64 on local[16] in both suites.

Remaining audit deltas, accepted and documented rather than ported: v1's
maxInputPartitionNum soft cap and maxNum replacement budget (v2's whole-group slot check
subsumes their deadlock role; the failure-vs-skip asymmetry for INPUT-side width remains
a known gap -- the rules cannot see scan partition counts at plan time).

PipelinedShuffleSqlSuite 9/9, AQEPipelinedShuffleSuite 5/5, channel 8/8.

Co-authored-by: Claude Code
…ns (design decision)

Follows the revert of the concurrency-cap gates (viirya's call): the user opted into
pipelined execution explicitly, so a plan whose flipped exchanges cannot fit the local
task-concurrency limit should surface the scheduler's explicit
CONCURRENT_SCHEDULER_INSUFFICIENT_SLOT error -- actionable (raise local[n] or lower
shuffle.partitions) -- rather than silently degrade to a regular run as v1's gates do.
Two tests pin the fail-loud behavior at shuffle.partitions=64 on local[16], non-AQE and
AQE. The local-mode gates stay (a cluster misconfiguration is a silent HANG, not an
explicit error, so it still fails at startup / skips the rules).

PipelinedShuffleSqlSuite 9/9, AQEPipelinedShuffleSuite 5/5.

Co-authored-by: Claude Code
…a proper SQLConf

spark.sql.pipelinedShuffle.enabled existed only as an unregistered string key read via
getConfString in both rules: no type validation, no doc, invisible to SET -v. Register it
as an internal boolean SQLConf entry (default false, version 4.3.0; doc covers the
local-mode requirement, the manager routing, and the explicit insufficient-slot failure
semantics) and switch both rules to conf.pipelinedShuffleEnabled.

PipelinedShuffleSqlSuite 9/9, AQEPipelinedShuffleSuite 5/5.

Co-authored-by: Claude Code
…ke the flag public

Two conf-surface cleanups (viirya):
  - spark.shuffle.pipelined.channel.batchSize is now a registered core ConfigEntry
    (intConf, > 0, default 1024, version 4.3.0) next to spark.shuffle.manager.incremental,
    replacing the raw conf.getInt in PipelinedChannelShuffleManager.
  - spark.sql.pipelinedShuffle.enabled drops .internal(): the flag is the feature's
    public opt-in surface.

channel 8/8, PipelinedShuffleSqlSuite 9/9, AQEPipelinedShuffleSuite 5/5, SQLConfSuite
41/41.

Co-authored-by: Claude Code
Adds a regular vs streaming-pipelined vs channel-pipelined comparison over the shapes the
streaming transport survives (no range sampling: its tracker accumulates writer
registrations across the sample job's producer re-run and the reader dies on its
writer-count assertion). Same scheduling for the two pipelined modes -- all-pipelined
gang -- so the delta is purely the byte path. local[16], AQE off, best of 6:

  repartition(k)+count 20M  regular 346ms  streaming 1096ms (0.32x)  channel 204ms (1.70x)
  groupBy(k).count 20M      regular  59ms  streaming  124ms (0.48x)  channel  41ms (1.44x)
  join 10Mx10M uniq + count regular 622ms  streaming  608ms (1.02x)  channel 623ms (1.00x)
  prototype 1M uniq         regular  50ms  streaming  133ms (0.38x)  channel  26ms (1.92x)

The headline: RTM's RPC streaming transport LOSES to the regular materialized shuffle by
2-3x on shuffle-dominated batch shapes -- it is latency-optimized (records flow while the
producer runs, at per-record serialization + Netty loopback + tracker cost), not
throughput-optimized -- while the in-process channel beats regular by 1.4-1.9x and beats
streaming head-to-head by 3-5x. This is the quantified justification for v2 carrying its
own transport rather than reusing the streaming one in local mode. The join row is a
compute-bound control: all three transports tie when sort-merge work dominates.

Co-authored-by: Claude Code
…all shuffles not worth it"

Adds a `curve` mode sweeping repartition(k)+count over shrinking row counts (regular vs
channel, no aggregation so the transport stays on the hot path) to find the crossover where
the channel's fixed per-gang cost (extra concurrent-stage scheduling + queue setup) would
overtake its transport win -- the premise behind a size-based cost-aware placement gate.

There is NO crossover. Small shuffles are where the channel wins MOST (local[16], best of 6):

  20M rows  regular 326ms  channel 204ms  1.60x
  1M        regular  53ms  channel  25ms  2.12x
  100K      regular  37ms  channel  18ms  2.06x
  10K       regular  34ms  channel  15ms  2.27x
  1K        regular  32ms  channel  14ms  2.29x
  100       regular  32ms  channel  14ms  2.29x

The premise was wrong because the two sides are asymmetric: regular's absolute time floors
at ~32ms regardless of size (shuffle-file write, block manager, serializer/fetch framework
startup), while the channel floors at ~14ms (gang scheduling + queue setup is real but
cheaper than the spill framework). As data shrinks the comparison becomes floor-vs-floor
and the channel's lower floor makes the speedup RISE toward a ~2.3x asymptote. Small
shuffles are exactly where an in-process transport should win -- regular pays the most
per-byte spill overhead there. So a size-based skip does not belong in cost-aware
placement; if that gate has value at all, its predicate is not row count.

Co-authored-by: Claude Code
…ter bug (known bug)

Investigating the unregister lifecycle turned up a real, reachable, common-shape bug via a
LIMIT counterexample: an early-stopping reader over a pipelined channel shuffle hangs the
writer. The channel queue is bounded (64 batches); once a LIMIT's reduce task is satisfied
and stops draining while the map task is still producing, the writer blocks on a full
queue's put() with no drainer, forever. Verified to hang under a 90s deadline in BOTH AQE
modes.

This is unlike regular shuffle, which materializes to disk so the writer finishes
regardless of whether the reader drains everything -- LIMIT just skips reading the rest.
The pipelined transport's writer/reader concurrency + backpressure assumes the reader
stays; early stop (LIMIT, first/head/take, an early-terminating join build side) breaks
that assumption. Reachable and common, so it outranks the (unreachable) mid-job-unregister
hazard that this investigation started from.

Adds PipelinedLimitHangSuite as the reproduction / future acceptance test (both cases
`ignore` -- they hang to the deadline, so CI-running them would waste 90s each and, sharing
a process, a cancelled first corrupts the second; flip to `test` + assert rows==10 once the
fix lands). Also reverts the misguided sentinel attempt in ChannelShuffleRendezvous.remove-
Shuffle: a non-empty queue at unregister is NORMAL (LIMIT leaves undrained elements), so
queue occupancy is not a usable "unregistered mid-job" signal; comment records why.

Fix direction is an open design question (writer-side detect reader-gone and stop/discard,
vs a channel "reader abandoned" signal that makes put() return/throw) -- deliberately not
chosen here.

Co-authored-by: Claude Code
…riter hang

The pipelined channel deadlocks whenever a consumer does not drain every reduce partition
to the end: the single-threaded writer interleaves all partitions, fills an undrained
partition's bounded queue, and blocks -- so even the read partitions never get their data
or end-of-stream. Reachable and common (LIMIT / take / head / first / isEmpty / show, and
any multi-exchange plan). Diagnosed by instrumentation after several wrong guesses; the
fix has two halves plus two correctness refinements that later hangs forced out.

Half 1 -- drop writes to dead partitions. The DAGScheduler knows a job's live reduce
partitions (ResultStage.partitions, set before submitStage; executeTake's per-batch runJob
passes exactly the subset it reads). New job property
SPARK_PIPELINED_LIVE_REDUCE_PARTITIONS carries it to the producer's tasks;
ChannelShuffleWriter drops records (and emits no end-of-stream) for a partition not in the
set. Absent property = all live, so full-read jobs (collect/count) are unchanged.

Half 2 -- reader-departure signal for LIVE partitions that stop early. A live partition
whose reader quits (LIMIT pulled enough) still fills and wedges. The reader's
TaskCompletionListener marks its partitions abandoned; the writer uses a polling
offer(100ms) that bails when the partition becomes abandoned, and abandon() drains the
queue to release a parked put -- a cooperative unblock, no reliance on interrupt.

Refinements the SQL suite's later cases forced (each a real design bug, not test-only):
  - live set is per-SHUFFLE-EDGE, not per-job. Setting the result stage's partition subset
    on EVERY pipelined producer broke multi-exchange chains: a subquery's hash exchange
    (4 parts) below a single-partition agg was told live={0} and dropped the parts the agg
    still needed -> hang. Now the property is set only on the producer whose shuffle the
    result stage reads DIRECTLY (getShuffleDependenciesAndResourceProfiles); middle
    exchanges stay fully live.
  - abandonment is per-JOB, but a shuffleId is re-run within one query (RangePartitioner
    sampling job then main job; executeTake batches). A prior job's abandoned marks must
    not make the re-run's fresh writer think partitions are dead, so the writer clears its
    partitions' marks at the start of each attempt; only abandonment during that attempt
    counts. clearForTesting/removeShuffle also clear the abandoned set.

PipelinedLimitHangSuite's two cases flipped ignore->test and pass (AQE off + on), no longer
hang. Verified in a working shell (this sandbox's sbt could not start): channel 8/8, SQL
9/9, AQE 5/5.

Co-authored-by: Claude Code
…ss channel transport

PipelinedChannelShuffleManager was handed a ShuffleWriteMetricsReporter and a
ShuffleReadMetricsReporter in getWriter/getReader and discarded both, so the Spark
UI showed all-zero shuffle metrics for a pipelined (channel) shuffle even though
rows were flowing.

Wire the reporters through:

  - ChannelShuffleWriter takes the write reporter. On each successful hand-off of a
    non-empty batch to a partition's queue it records incRecordsWritten(batch rows)
    and incWriteTime(nanos since the put began -- so time parked on a full bounded
    queue counts as write time). End-of-stream markers and dropped/abandoned batches
    (records == 0, or a partition with no reader / a departed reader) record nothing,
    since those rows are never shuffled out.
  - ChannelShuffleReader takes the read reporter and records incRecordsRead(batch
    rows) as each batch is fetched from the queue.
  - The manager now passes both reporters into the writer/reader instead of dropping
    them.

Deliberately NOT reported:

  - Bytes (incBytesWritten / incLocalBytesRead). The channel hands off object batches
    with no serialization, so there is no wire-byte count; a fabricated figure would
    be misleading. Records + time are the honest metrics for this transport.
  - MapStatus stays the all-zero placeholder (a pipelined reducer never reads
    partition lengths), matching the RPC streaming writer.

Co-authored-by: Claude Code
…e decoupling relaxed

The usesStreamingShuffleOutputTracker decoupling (earlier in this stack) let an
in-process pipelined manager declare it needs no StreamingShuffleOutputTracker, so
SparkEnv builds none and DAGScheduler.outputTrackerMaster registers the shuffle with
no tracker instead of failing. This deliberately RELAXED the older invariant "a
pipelined dependency implies a streaming tracker."

PipelinedShuffleRoutingSuite (upstream, SPARK-58454) still carried
"createShuffleMapStage fails loud for a pipelined dependency when no streaming
tracker", which asserted that older invariant. It can no longer describe a reachable
state: SparkEnv's build-it decision and outputTrackerMaster's throw-if-absent
decision now read the SAME flag, so they are locked consistent -- flag true builds a
tracker (no throw), flag false needs none (no throw). No manager reports "needs a
tracker" while being trackerless. Remove the test rather than repurpose it (its
fail-loud subject is gone) or ignore it (nothing to revive).

The behavior the change actually introduced -- a channel-manager pipelined dependency
schedules and runs with no tracker and no throw -- is covered end to end by the
channel suites (PipelinedChannelShuffleSuite, PipelinedShuffleSqlSuite, ...), so it is
not left untested.

Also migrate the suite's IncrementalRecordingManager to override
usesStreamingShuffleOutputTracker = false. It is a recording mock, not the RPC
StreamingShuffleManager; under the old isInstanceOf check it was classified "no
tracker", but the flag's trait default is true, so without the override it now builds
a tracker and breaks the two "does / does not initialize the tracker" tests. The
override restores its intended non-streaming role.

Co-authored-by: Claude Code
…ner gating, and cleanups

From a second review pass. Two real problems, plus defense-in-depth, allocation, config,
comment, and test-coverage items.

- Reader wait was uninterruptible (resource leak). ChannelShuffleReader drained the queue with
  a blocking take(); if a producer map task threw before emitting end-of-stream, the DAGScheduler
  aborts the group and kills the reduce task, but the kill only sets the TaskContext interrupt
  flag (spark.job.interruptOnCancel defaults to false), so the thread parked forever and pinned
  its executor slot for the app's life. Poll with a 100ms timeout and call killTaskIfInterrupted
  each cycle -- the symmetric cooperative escape to the writer's putUnlessAbandoned. New test:
  a failing producer map task fails the job promptly instead of hanging.

- ContextCleaner behavior change reached feature-off users. The else arm of doCleanupShuffle was
  made to call removeShuffle unconditionally; that arm is also reached for an already-cleaned
  regular shuffle (RDD.cleanShuffleDependencies unregisters it from the MapOutputTracker eagerly,
  then its GC re-cleans it), so with the feature off it fired an extra cluster-wide RemoveShuffle
  RPC and shuffleCleaned callback -- contradicting "No behavior change by default". Gate the arm
  on a tracker-less pipelined manager being active, so it stays on the pipelined path only.

- Live-set identity guard (defense-in-depth). readsShuffleByIdentity now also requires the reader
  RDD's partition count to equal the shuffle's reduce partition count, so an offset
  CoalescedPartitionSpec (which passes the width-1 require but breaks index identity) degrades to
  "all partitions live" rather than dropping a partition and hanging. Unreachable today.

- Rendezvous outliving its SparkContext. PipelinedChannelShuffleManager.stop() (called from
  SparkEnv.stop()) now clears the process-wide ChannelShuffleRendezvous, so a new SparkContext in
  the same JVM cannot read rows/markers a previous context left in queues keyed by a reused
  shuffleId. clearForTesting is promoted to clear().

- Per-record allocation. The pipelined write processor no longer copies rows twice
  (copyRows = requiresDetachedRecords && !needToCopyObjectsBeforeShuffle), and ChannelShuffleWriter
  stores the incoming detached pair directly instead of re-wrapping it in a fresh Tuple2.

- Queue depth is now a conf (spark.shuffle.pipelined.channel.queueCapacity, default 64) rather
  than a hard-coded constant, with its doc stating the queueCapacity * batchSize * numPartitions
  heap-residency product; the channel manager sets the rendezvous capacity from it.

- Reader now reports incFetchWaitTime for time parked on the channel (the read-side backpressure
  signal), matching the "records + read/write time" the docs promise.

- Restored routing coverage: a pipelined shuffle with a no-tracker manager registers with neither
  tracker (asserted in PipelinedShuffleRoutingSuite), pinning the relaxed successor to the deleted
  "fails loud when no tracker" test.

- Reconciled the getReader comment with ChannelShuffleReader's width-1 require.

Co-authored-by: Claude Code
viirya force-pushed the local-repartition-v2-pr branch from 02ff163 to af04d5d Compare August 19, 2026 21:57

viirya commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Thanks again, @dongjoon-hyun -- another very sharp pass. All ten are addressed in a new commit; the two you flagged as real (the uninterruptible reader and the unscoped RemoveShuffle) plus the identity-guard and the cross-context rendezvous are the substantive ones, the rest are the allocation/config/comment/coverage cleanups. Replies inline.

On the not-behind-the-flag one specifically: you are right, thank you -- that was mine to catch and I missed it. The cleaner arm is now gated on a tracker-less pipelined manager being active, so a feature-off deployment sees no extra RemoveShuffle or shuffleCleaned.

CI is running on the new commit; I will confirm green when it finishes.

private def putUnlessAbandoned(pid: Int, batch: AnyRef, records: Int): Boolean = {
val q = ChannelShuffleRendezvous.queue(shuffleId, pid)
val start = System.nanoTime()
while (!ChannelShuffleRendezvous.isAbandoned(shuffleId, pid)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

putUnlessAbandoned has no kill/interrupt check, so this is the writer-side twin of the reader hang fixed in the last round.

The only exit from this loop is isAbandoned, and that mark is set exclusively by ChannelShuffleReader's task-completion listener -- i.e. only if the reduce task for pid actually started. If it never starts, the loop never terminates.

That is reachable on the opt-in path, because the producer is submitted before the consumer. In submitStage the recursion runs submitStage(parent) first and the consumer's submitMissingTasks afterwards, so the producer's map tasks can already be filling queues before the consumer TaskSet exists. Anything that aborts the group in that window -- submitMissingTasks throwing on the consumer (unserializable task), an early failure of another group member, a job cancel -- leaves the producer running with no reader for some or all partitions. The abort then goes through cancelTasks / killAllTaskAttempts with shouldInterruptTaskThread(job), which reads spark.job.interruptOnCancel and defaults to false, so the thread is never interrupted and this offer stays parked.

The consequence is the one you fixed on the reader: the task never dies and pins an executor slot for the life of the application. A partial abort is enough -- with 4 reduce partitions and only one reduce task launched before the abort, the writers for the other three park here.

The symmetric fix is the one already applied to takeItem: check the kill flag each cycle, e.g. Option(TaskContext.get()).foreach(_.killTaskIfInterrupted()) at the top of the loop body.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Fixed. putUnlessAbandoned now calls killTaskIfInterrupted() at the top of each loop cycle, so a writer whose reader never started wakes on the group abort interrupt flag instead of parking on offer forever -- symmetric to takeItem. Added a regression test: a producer map task that throws now fails the job promptly (under a deadline) rather than pinning the slot.

* submission, the one point with no live task of that run, removes the race by construction.)
* Queues are left intact -- only the marks are reset.
*/
def clearAbandonedForShuffle(shuffleId: Int): Unit = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Resetting the marks without the queues leaves a previous run's data readable by the next run of the same shuffleId.

The scaladoc is explicit that "Queues are left intact", and abandon clears a queue only for a partition whose reader actually departed. A partition whose reduce task never started therefore keeps whatever the writers pushed into it.

In classic mode a Dataset holds one executedPlan, so ShuffleExchangeExec.shuffleDependency -- and its shuffleId -- is reused across actions. Sequence:

  1. An action runs and the group aborts partway (a consumer task fails, or the job is cancelled). Partitions whose readers never started keep their batches, and -- if their writers got that far -- their EndOfStream markers too.
  2. The user re-runs the action. Same shuffleId, new producer stage; onPipelinedProducerStageSubmit clears the marks only.
  3. The new writers append to the same queues.
  4. The new reader drains the stale batches first and counts the stale EndOfStream markers toward numMaps, so it can stop before the new run's data arrives.

That is a silent wrong result (duplicated and/or dropped rows) rather than a hang.

Calling removeShuffle(shuffleId) here instead of clearAbandonedForShuffle would cover it, since the hook is documented to run before any map task of the run starts -- but only if a pipelined producer stage can never be resubmitted with partial progress, otherwise the clear would drop in-flight data. A sturdier option is to scope the rendezvous keys by a per-run epoch, (shuffleId, epoch, reducePartitionId). That also closes the related race the mark reset still has: right after step 2 clears the marks, a straggler writer from the aborted run that has not yet noticed its kill sees isAbandoned == false again and can push into the new run's queue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Fixed with per-run epoch isolation. The rendezvous is now keyed (shuffleId, epoch, reducePartitionId), where the epoch is the jobId: DAGScheduler.handleJobSubmitted stamps it into a pipelined job properties before any stage, and submitMissingTasks clones it per stage so the gang producer and consumer read the same value. A re-run of the same shuffleId is a different job, hence a different epoch and physically separate queues -- so it never drains a prior run leftover batches or counts its EndOfStream markers toward numMaps, and a straggler writer from the aborted run (still on the old epoch) cannot push into the new run queue. This removes the whole "clear the marks between runs" mechanism: clearAbandonedForShuffle and the onPipelinedProducerStageSubmit hook are both deleted, and removeShuffle(shuffleId) now drops every epoch of a shuffle. New regression test asserts the isolation.

viirya added 4 commits August 20, 2026 14:11
… + per-run epoch isolation

Two issues from the third review pass, both real hangs/correctness on the opt-in path.

A. Writer-side interrupt escape (the twin of the reader fix from the previous round).
   putUnlessAbandoned's offer loop exits only on the abandoned mark, which is set only by a
   reduce task that actually STARTED. If the group aborts while a producer is already filling
   queues but the reader for a partition never started (an unserializable consumer task, an
   early failure of another member, a job cancel), the mark never appears and the writer parked
   forever, pinning the executor slot (spark.job.interruptOnCancel defaults to false, so the
   kill does not interrupt the thread). Check killTaskIfInterrupted each cycle -- symmetric to
   the reader's takeItem. New regression test: a producer map task that throws fails the job
   promptly instead of hanging.

B. Per-run epoch so the process-wide rendezvous is physically isolated between runs of the same
   shuffleId, replacing the "clear the marks between runs" approach. Clearing marks left the
   queues intact, so a partition whose reader never started kept the previous run's batches and
   EndOfStream markers; a re-run of the same shuffleId (a Dataset re-executing its plan; a
   RangePartitioner sample job then main job) reused the same queues and could drain stale data
   and count stale markers toward numMaps -- a silent wrong result -- and a straggler writer from
   the aborted run could push into the new run's queue after the marks were cleared. The
   DAGScheduler now stamps a per-run epoch (the jobId) into a pipelined job's properties in
   handleJobSubmitted, before any stage; submitMissingTasks clones it per stage, so the gang's
   producer and consumer read one value. The rendezvous is keyed (shuffleId, epoch,
   reducePartitionId); a re-run is a different job, hence a different epoch and physically
   separate queues, so a straggler cannot cross runs and a re-run never sees leftover data.
   removeShuffle drops every epoch of a shuffleId (cleanup does not know the epoch). This
   removes the need for the Finding-2 onPipelinedProducerStageSubmit hook and
   clearAbandonedForShuffle, both deleted. New regression test asserts epoch isolation.

Co-authored-by: Claude Code
Remove exploratory benchmarks that answered our own design questions and do not belong
in the PR:

  - TPCDSPipelinedRunner.scala: a TPC-DS runner used to verify correctness and time
    queries across AQE modes during development. Its findings are captured; the runner
    itself is not product.
  - The fixed-cost curve in PipelinedShuffleBenchmark (compareChannel / fixedCostCurve
    and the `curve` main branch): it swept repartition+count over shrinking row counts to
    settle the "are small shuffles worth pipelining" question (answer: yes, no crossover),
    which is now decided and needs no standing benchmark.

KEPT in PipelinedShuffleBenchmark: the three-way transport comparison (regular vs RTM
RPC-streaming vs in-process channel), which answers the reviewer question "why not just
use the existing streaming manager?" (the channel beats streaming 3-5x on batch shapes),
plus the AQE / range / prototype compare rows.

sql/Test/compile clean.

Co-authored-by: Claude Code
…SqlBasedBenchmark framework

The benchmark was an ad-hoc object with a hand-rolled main / timing loop. Rework it onto
Spark's standard benchmark framework so it fits repo conventions and can emit a results file
(SPARK_GENERATE_BENCHMARK_FILES=1 -> benchmarks/PipelinedShuffleBenchmark-results.txt):

  - `extends SqlBasedBenchmark`, implements `runBenchmarkSuite`, groups cases under
    `runBenchmark(...)` blocks (three-way transport; regular-vs-channel AQE off; AQE on;
    32-map oversubscribed), using `new Benchmark(...)` + the framework's warm-up / best-of-N.
  - All original workloads preserved unchanged.

The one non-standard need: each transport (regular / RPC-streaming / channel) requires its OWN
SparkSession, because the shuffle manager and the pipelined flag are SparkContext-level and
fixed at startup -- they cannot be switched with setConf on a shared session. Handled with
`addTimerCase`: build the session before `startTiming()` and stop it after `stopTiming()`, so
session build/teardown (seconds of fixed cost) is EXCLUDED from the measured time while the
framework still runs its own iterations. Each case also stops any active/default session first,
so `getOrCreate` builds a fresh one rather than silently reusing the base trait's throwaway
session (which would ignore the case's master / manager config).

Verified by running: no session-reuse WARN, per-case stdev ~2-7% of best, and the headline
signal is clean (channel 1.7-7.2x over regular; RPC-streaming loses to regular on batch
shapes, e.g. 0.4x on repartition -- which is why the channel transport exists).

Co-authored-by: Claude Code
…k21)

Generated baseline for PipelinedShuffleBenchmark via
SPARK_GENERATE_BENCHMARK_FILES=1, per Spark convention (a *-results.txt under
sql/core/benchmarks/). Captured on JDK 21 / Apple M4 Max; the standard CI benchmark
workflow can regenerate on the reference environment.

Highlights: the in-process channel beats the regular shuffle 1.4-7.4x across the batch
shapes, while RTM's RPC-streaming transport loses to regular on the same shapes (0.4x on
repartition, 2.2x on groupBy) -- the throughput-vs-latency split that justifies the
channel carrying its own transport rather than reusing the streaming manager in local
mode.

Co-authored-by: Claude Code
viirya force-pushed the local-repartition-v2-pr branch from af04d5d to f4d950e Compare August 20, 2026 21:11

viirya commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Thanks, @dongjoon-hyun. Both fixed in a new commit.

The writer now checks killTaskIfInterrupted each cycle, same as the reader takeItem. And the rendezvous is now keyed by a per-run epoch (the jobId), so a re-run of the same shuffleId uses physically separate queues -- a straggler writer from an aborted run cannot cross into the next run, and a re-run never drains leftover batches or counts leftover end-of-stream markers. That let me delete the between-run mark reset (and the onPipelinedProducerStageSubmit hook) entirely, since there is nothing to clear once runs are isolated. Replies inline.

Copy link
Copy Markdown
Member

Thanks for the detailed write-up in the PR description -- the inline rationale (especially in ChannelShuffleRendezvous and the epoch design) made this much easier to follow. I reviewed the full diff and ran the new suites locally. One confirmed deadlock plus three smaller items below.

1. Deadlock: partial-read job whose result RDD reaches the pipelined shuffle through a non-identity chain

submitMissingTasks only stamps SPARK_PIPELINED_LIVE_REDUCE_PARTITIONS when readsShuffleByIdentity holds (DAGScheduler.scala:2736), i.e. the result RDD reaches the shuffle through a chain of single OneToOneDependency hops. When it does not hold, the property is left unset and the writer reads that as "every reduce partition is live".

The comment there argues this is safe because "that operator drains every reduce partition". That is true for a full read, but not for a partial one. executeTake/LIMIT runs its first job on partition 0 only, so the remaining reduce partitions have no reduce task at all -- not a reader that stopped early (which abandon covers), but one that never started, so the abandoned mark never appears and putUnlessAbandoned's q.offer(..., 100ms) loop has no exit.

A join is the easy case to hit: the result RDD is a ZippedPartitionsRDD2 with two dependencies, so the match falls through to case _ => false.

// local mode, spark.sql.pipelinedShuffle.enabled=true, channel manager, shuffle.partitions=4
left.join(right, $"k" === $"k2").limit(10).collect()   // hangs

I added this to PipelinedLimitHangSuite (purely additive; existing helpers untouched):

  private def joinLimitOverPipelinedCompletesWithin(seconds: Int, aqe: Boolean): Boolean = {
    val pool = Executors.newSingleThreadExecutor()
    val fut = pool.submit(new Runnable {
      override def run(): Unit = withSession(aqe) { spark =>
        import spark.implicits._
        // Force a shuffled join: a broadcast side would leave only one exchange, and the
        // AQE rule flips a join's inputs only as a symmetric pair.
        spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "-1")
        spark.conf.set("spark.sql.adaptive.autoBroadcastJoinThreshold", "-1")
        // Distinct row counts and column names so the two exchanges cannot canonicalize
        // alike: a ReusedExchangeExec would make EnablePipelinedShuffle leave the whole plan
        // regular and the query would pass for the wrong reason.
        val left = spark.range(0, 4000000L, 1, 4).withColumn("k", ($"id" % 100))
        val right = spark.range(0, 100000L, 1, 4).withColumn("k2", ($"id" % 100))
        val rows = left.join(right, $"k" === $"k2").limit(10).collect().length
        require(rows == 10, s"expected 10 rows, got $rows")
      }
    })
    try {
      fut.get(seconds.toLong, TimeUnit.SECONDS)
      true
    } catch {
      case _: java.util.concurrent.TimeoutException =>
        fut.cancel(true)
        false
    } finally {
      pool.shutdownNow()
    }
  }

  test("LIMIT over a pipelined shuffle read through a join completes (AQE off)") {
    assert(joinLimitOverPipelinedCompletesWithin(90, aqe = false),
      "LIMIT over a joined pipelined shuffle should complete, but the writer hung feeding " +
        "reduce partitions that executeTake never reads")
  }

  test("LIMIT over a pipelined shuffle read through a join completes (AQE on)") {
    assert(joinLimitOverPipelinedCompletesWithin(90, aqe = true),
      "LIMIT over a joined pipelined shuffle should complete, but the writer hung feeding " +
        "reduce partitions that executeTake never reads")
  }

Result -- the two existing tests pass, the two new ones burn the full 90s deadline in both AQE modes:

[info] - LIMIT over a pipelined shuffle completes (AQE off) (3 seconds, 136 milliseconds)
[info] - LIMIT over a pipelined shuffle completes (AQE on) (287 milliseconds)
[info] - LIMIT over a pipelined shuffle read through a join completes (AQE off) *** FAILED *** (1 minute, 30 seconds)
[info] - LIMIT over a pipelined shuffle read through a join completes (AQE on) *** FAILED *** (1 minute, 30 seconds)

Thread dump of the forked test JVM at the 45s mark confirms it is a deadlock, not slowness -- every live executor thread is parked, each having burned ~0.1s of CPU over 44.8s elapsed:

"Executor task launch worker for task 0.0 in stage 0.0 (TID 0)"  TIMED_WAITING (parking)
	at java.util.concurrent.LinkedBlockingQueue.offer(...)
	at ...ChannelShuffleWriter.putUnlessAbandoned(ChannelShuffleWriterReader.scala:116)
	at ...ChannelShuffleWriter.write(ChannelShuffleWriterReader.scala:164)

"Executor task launch worker for task 0.0 in stage 2.0 (TID 8)"  TIMED_WAITING (parking)
	at java.util.concurrent.LinkedBlockingQueue.poll(...)
	at ...ChannelShuffleReader$$anon$1.takeItem(ChannelShuffleWriterReader.scala:281)
	at ...ChannelShuffleReader$$anon$1.advance(ChannelShuffleWriterReader.scala:298)
thread state cpu / elapsed
stage 0.0 TID 0..3 (producer map tasks) putUnlessAbandoned -> offer 104-116ms / 44.81s
stage 2.0 TID 8 (result stage, partition 0) takeItem -> poll 128ms / 44.81s

Note there is exactly one reduce task for 4 shuffle partitions, which is the crux: partitions 1-3 never get a reader, so they never get abandoned, so the writers never reach their end-of-stream loop, so the partition-0 reader never counts numMaps markers.

The same reasoning applies to coalesce(...).limit(n) and union(...).limit(n).

On the fix: extending readsShuffleByIdentity to cover join/union/coalesce means re-deriving each operator's partition mapping, which seems fragile. It may be more robust to invert the fallback. Today "cannot determine the live set" degrades to "everything is live", but the safe degradation is the opposite: if the result stage does not read all of its partitions (rs.partitions.length != rs.rdd.partitions.length) and the live set cannot be determined exactly, reject the job fail-fast (or leave the plan regular) rather than risk a hang. Happy to be wrong here if you see a cheaper invariant.

2. AQEEnablePipelinedShuffle: toFlip matches structurally, not by identity

toFlip is a mutable.HashSet[ShuffleExchangeExec] (AQEEnablePipelinedShuffle.scala:79). TreeNode overrides hashCode but not equals, so the case-class structural equals applies and toFlip.contains(s) at line 91 flips every structurally equal exchange, not just the node the collector selected.

duplicatedShuffleForms is the only guard, and it is skipped entirely when spark.sql.exchange.reuse=false (val shared = if (conf.exchangeReuseEnabled) ... else Set.empty). In that configuration a twin on a blocked path -- one collectCandidates deliberately left regular -- gets flipped too. If it sits below a regular boundary, classifyJobShuffleShape then rejects the whole job with SparkException.

Keying on SparkPlan.id, or an IdentityHashMap-backed set, removes the ambiguity regardless of the reuse flag.

3. ContextCleaner: the new arm is gated on the manager, not on the shuffle

The else if (tracklessPipelinedManagerActive) arm (ContextCleaner.scala:264) fires for any shuffle found in neither tracker once the channel manager is configured -- including regular ones. The comment at 265-277 anticipates the double-clean problem and gates on the manager to avoid it for default deployments, but the gate does not distinguish a regular shuffle from a pipelined one.

That matters here precisely because this feature produces regular shuffles: the materialized prefix of a mixed job. A prefix shuffle unregistered early (RDD.cleanShuffleDependencies, or Dataset.rdd with spark.sql.classic.shuffleDependency.fileCleanup.enabled) will, on later GC, take the new arm and fire shuffleCleaned a second time plus an extra RemoveShuffle RPC. Tracking channel shuffle ids in the manager, or checking that the dependency was pipelined, would scope it correctly.

4. classifyJobShuffleShape adds per-boundary graph walks to every job submission

rddGraphHasPipelinedDependency(sd.rdd) (DAGScheduler.scala:1202) is called once per frontier regular boundary, and each call builds its own visited set. The previous classifyJobShuffleKinds visited each RDD exactly once.

For a job whose frontier boundaries share a large ancestor subgraph (a wide join or union over a common base), this is O(K x |graph|) on the single-threaded DAGScheduler event loop, before any stage is created -- and it runs for everyone, including users who never enable this feature, where the answer is always false. Carrying a belowRegular flag through the single existing traversal would restore the original cost.

Minor notes

  • The PR description says "Two registered configs" but there are three -- spark.shuffle.pipelined.channel.queueCapacity is missing from the user-facing-change section. The description also mentions a clearAbandoned test helper that does not exist in the code.
  • PipelinedChannelShuffleManager.stop()'s comment says the rendezvous is "keyed only by (shuffleId, reducePartitionId)", which is stale now that epoch is part of the key.

Nothing above touches the core design, which I think is sound -- routing by dependency type, the requiresDetachedRecords gate combined with the needToCopyObjectsBeforeShuffle de-duplication, the per-run epoch keying, and the cooperative killTaskIfInterrupted escapes on both sides all look right to me.

viirya added 4 commits August 21, 2026 00:27
…rents)

The pipelined producer must drop reduce partitions no consumer reads
(partial reads like LIMIT), or the writer wedges on a full bounded queue
whose reader has departed. The earlier code (readsShuffleByIdentity) only
handled a result RDD reading the shuffle DIRECTLY; a union or a join between
the shuffle and the result made the mapping wrong.

Replace it with liveReduceSet, which walks NarrowDependency.getParents from
the result RDD to the target shuffle, threading the read-partition subset
through the generic contract: OneToOne (identity), RangeDependency (union's
per-branch offset), and join fan-in (one narrow branch per side). When the
narrow chain reaches the shuffle but the mapping is uncomputable AND the read
is partial, fail fast via abortStage rather than hang; a middle exchange
(chain blocked by an intervening shuffle) and any full read fall through to
fully-live, never wrongly aborted.

Add union/join + LIMIT acceptance tests in both AQE modes.

Co-authored-by: Claude Code
A CoalesceExec (user .coalesce(n), a narrow no-shuffle partition reduction)
reading from a shuffle makes ONE reduce task drain SEVERAL reduce partitions
sequentially -- a core CoalescedRDD over the ShuffledRowRDD. The channel
transport cannot serve that: the map-side writer interleaves all partitions
on one thread and parks on a full bounded queue, so a reader draining
partition start to completion before touching start+1 deadlocks the parked
writer, with no timeout escape. coalesce's API contract is a narrow
dependency that merges adjacent partitions, so we cannot honor it by
re-hashing to n partitions either.

So refuse to pipeline any shuffle read by a coalesce:
- EnablePipelinedShuffle (non-AQE): if any shuffle is read by a CoalesceExec
  through a narrow chain, leave the WHOLE plan regular (like the reuse
  fallback). Leaving only that exchange regular would put a pipelined
  exchange below a regular boundary, which the scheduler rejects.
- AQEEnablePipelinedShuffle: treat CoalesceExec as stats-sensitive, so the
  candidate walk blocks below it and the shuffle it reads -- and everything
  deeper -- stays regular.

The query runs correctly, just not pipelined. The reader's width-1 require
stays as a fail-loud backstop; its class doc now notes the rule guarantee.
Add coalesce-fallback tests in both non-AQE and AQE suites, deadline-guarded
so a regression surfaces as a failure rather than a hung suite.

Co-authored-by: Claude Code
…he manager holds

ContextCleaner.doCleanupShuffle's tracker-less arm (freeing an in-process
pipelined shuffle's rendezvous queues) was gated only on the channel manager
being active, not on the shuffle actually being one of the manager's. That
arm is reached for an already-cleaned REGULAR shuffle too: RDD.cleanShuffle-
Dependencies unregisters it from the MapOutputTracker eagerly, then its later
GC re-cleans it and, finding it in neither tracker, lands here. With the
feature on -- a session that also runs regular shuffles, e.g. any plan the
rules leave regular -- that fired a duplicate cluster-wide RemoveShuffle RPC
and a shuffleCleaned callback per such shuffle: a behavior change beyond the
opt-in path.

Scope the arm to shuffles the manager actually holds. Add
PipelinedShuffleManager.holdsShuffle(id) (default false); the channel manager
tracks its registered shuffle ids in a set (added at registerShuffle, removed
at unregisterShuffle) and answers from it. The cleaner gates the arm on
manager != null && !usesStreamingShuffleOutputTracker && holdsShuffle(id), so
a regular shuffle -- never registered with the channel manager -- is a no-op
there. A channel shuffle's own GC-time re-clean is now also a clean no-op
(unregister already dropped it from the set), where before it double-fired.

Also fix two stale comments in PipelinedChannelShuffleManager: stop() said
the rendezvous key was (shuffleId, reducePartitionId) -- it now includes the
per-run epoch; and getReader's width-1 note now cites the rule guarantee
(the enable rules refuse to pipeline a coalesce-read shuffle) rather than
implying only AQE keeps it uncoalesced.

Co-authored-by: Claude Code
…elow-regular walk

Two independent scheduler/AQE robustness fixes.

AQEEnablePipelinedShuffle: `toFlip` was a HashSet[ShuffleExchangeExec].
TreeNode overrides hashCode but not equals, so the set matched
STRUCTURALLY, and transformDown's `toFlip.contains(s)` flipped EVERY
exchange structurally equal to a collected one -- including a twin the
collector deliberately left regular on a blocked path. With
spark.sql.exchange.reuse off (so duplicatedShuffleForms, the only other
guard, is empty), that twin below a regular boundary made
classifyJobShuffleShape reject the whole job. Key the set on SparkPlan.id
(unique per instance) so exactly the collected nodes flip. The flip step is
factored into a package-visible flipEligibleExchanges so it can be unit-tested
directly on a hand-built plan (a free exchange plus a structurally identical
blocked twin), bypassing apply's environment guards.

classifyJobShuffleShape: it called rddGraphHasPipelinedDependency once per
frontier regular boundary, each building its own visited set -- O(K x graph)
on shared ancestors, and it ran for every job (feature off included). Fold the
pipelined-below-regular detection into the single existing walk, carrying a
`belowRegular` flag. The walk descends through regular boundaries too and keys
its visited set on (RDD, belowRegular), NOT on the RDD alone: a node reachable
both above and below a boundary must be explored in both contexts, or a
pipelined dep reachable only below could be missed (a wrongly-accepted job).
A node is visited at most twice, restoring O(graph) cost. hasPipelined is set
only above a regular boundary, matching the old walk.

Co-authored-by: Claude Code

viirya commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough pass -- all four plus the minors are addressed. Pushed on top of the branch.

1. Partial-read deadlock through a non-identity chain. Your invert-the-fallback suggestion is what I went with, plus a bit more. submitMissingTasks now maps the live reduce set through NarrowDependency.getParents (liveReduceSet), threading the read-partition subset through OneToOne (identity), RangeDependency (union's offset), and join fan-in -- so join/union + LIMIT map correctly instead of falling back to all-live. When the narrow chain reaches the shuffle but the mapping is uncomputable and the read is partial, it fails fast (leaves it regular) rather than risking a hang, exactly as you proposed. Your coalesce(...).limit(n) case turned out to deadlock regardless of the live set (one reduce task drains several reduce partitions sequentially against a bounded queue), so a coalesce reading a shuffle is now left regular altogether. Added union/join + LIMIT and coalesce-fallback tests.

2. toFlip matches structurally, not by identity. Fixed -- toFlip is now keyed on SparkPlan.id. transformDown matches each original node before rebuilding it, so its id is the same instance id the collector recorded; a structural twin the collector left on a blocked path is no longer flipped, regardless of spark.sql.exchange.reuse. Added a unit test that builds a free exchange plus a structurally identical blocked twin and asserts only the collected one flips (it fails on the old structural key, passes on the id key).

3. Cleaner arm gated on the manager, not the shuffle. Fixed -- the channel manager now tracks the shuffle ids it holds, and the tracker-less arm is gated on !usesStreamingShuffleOutputTracker && holdsShuffle(id). A regular shuffle in a feature-on session (including the materialized prefix of a mixed job) is never held, so it stays a no-op there; a channel shuffle's own eager-then-GC re-clean is now a clean no-op too.

4. Per-boundary graph walks. Folded into the single existing traversal with a belowRegular flag, so the per-boundary re-walks are gone. One caveat worth flagging: a single belowRegular bool with RDD-only dedup is unsound under DAG sharing -- a node reachable both above and below a regular boundary would take the first-reached context and could miss a pipelined dep only reachable below (a wrongly-accepted job). So the walk keys its visited set on (RDD, belowRegular): a node is visited at most twice, keeping it O(graph) rather than O(K x graph) while staying correct.

Minor notes. The PR description now lists all three configs and drops the stale clearAbandoned reference; the two stale comments in the manager are corrected.

viirya commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

@dongjoon-hyun Thanks for your careful reviews! Could you take another look? Thanks!

Copy link
Copy Markdown
Member

Sorry for missing your ping here, @viirya . Let me review now.

Copy link
Copy Markdown
Member

BTW, is this new feature still aiming Apache Spark 4.3.0 whose RC1 is scheduled next week by @HeartSaVioR ?

viirya commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

BTW, is this new feature still aiming Apache Spark 4.3.0 whose RC1 is scheduled next week by @HeartSaVioR ?

Let's see if we can finish the review before that? If this can land in 4.3.0, it would be good.

dongjoon-hyun left a comment
edited
Loading

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

8 finder angles over the full diff, top correctness candidates independently re-verified against the PR head. 10 findings below as inline comments, ranked most severe first — 5 correctness (all in the opt-in feature path), 2 efficiency, 1 reuse, 1 conventions, 1 low-severity invariant note. The three most severe are all reachable within the feature's advertised envelope (flag on, local mode, channel manager): a Cartesian N-to-1 read that splits rows/markers across concurrent readers (wrong results, then a hang), a coalesce-over-union/join shape the non-AQE guard misses (silent deadlock), and hidden regular shuffles built by the limit operators' doExecute that make flipped plans hard-fail at job submission.

}

plan.transformUp {
case s: ShuffleExchangeExec if !s.pipelined => s.copy(pipelined = true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[correctness / CONFIRMED] The blanket flip has no guard for a shuffle consumed N-to-1 through CartesianProductExec, so N concurrent ChannelShuffleReaders drain the same (shuffleId, epoch, pid) queue.

With AQE off, the flag on, and broadcast not applying, df.repartition($"k").crossJoin(df2).collect() plans CartesianProductExec directly over the flipped exchange. UnsafeCartesianRDD's NarrowDependency (getParents = id / numPartitionsInRdd2) computes each left reduce partition once per right partition, and each compute mints a fresh reader on the SAME rendezvous queue (same job, same epoch). Rows and EndOfStream markers are then split nondeterministically between concurrent readers — wrong results — and a reader that received fewer than numMaps markers polls forever in takeItem; worse, the first reader to finish fires abandon(), which clears the queue and stops the writer, silently discarding the other readers' data. Nothing rejects the job: the DAGScheduler fan-out check counts distinct consumer RDDs (here 1, the single ShuffledRowRDD), and the width-1 require guards range width, not reader count.

Fix direction: bail out (leave the plan regular) when a shuffle is consumed through a CartesianProductExec — or, more generally, through any narrow dependency that maps several output partitions to one reduce partition.

def reachesShuffle(p: SparkPlan): Boolean = p match {
case _: ShuffleExchangeExec => true
case u: UnaryExecNode => reachesShuffle(u.child)
case _ => false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[correctness / CONFIRMED] reachesShuffle walks only UnaryExecNode chains, so a CoalesceExec above a UnionExec (or any BinaryExecNode) whose subtree contains shuffles is not detected, and those exchanges get flipped — producing exactly the coalesced multi-partition read the ChannelShuffleReader class doc says must never reach the transport.

Repro shape (AQE off, flag on, channel manager): df1.repartition($"k").union(df2.repartition($"k")).coalesce(2).collect() with >65K rows per reduce partition. c.child is UnionExec, not a UnaryExecNode, so the guard returns false and both exchanges flip. At runtime CoalescedRDD's one task drains reduce partition p0 to completion (it needs numMaps EndOfStream markers) before touching p1, while the single-threaded writer fills p1's bounded queue (64 batches) and parks in putUnlessAbandoned before ever emitting p0's markers. No abandon mark appears (the reader task never completes), the per-partition width-1 require never trips, and there is no timeout escape — the job hangs indefinitely. The AQE rule is safe (isStatsSensitive blocks at CoalesceExec); only this non-AQE guard has the gap, and the existing test covers only a unary chain (groupBy.count().coalesce(2)).

Fix direction: recurse into all children of any non-exchange node (case p => p.children.exists(reachesShuffle)), stopping only at exchanges.

* so no pipelined exchange ends up below the coalesce's regular boundary (which the
* scheduler would reject). See the non-AQE `EnablePipelinedShuffle` for the full rationale.
*/
private def isStatsSensitive(plan: SparkPlan): Boolean = plan match {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[correctness / CONFIRMED] Neither flip rule guards against operators that build hidden regular shuffles inside doExecute: CollectLimitExec, CollectTailExec, and TakeOrderedAndProjectExec each call ShuffleExchangeExec.prepareShuffleDependency with the default pipelined = false and no plan node, so the plan walk cannot see them. A flipped exchange below them puts a pipelined shuffle under an unmaterialized regular boundary — the exact shape classifyJobShuffleShape rejects (hasPipelinedBelowRegular) — so the query hard-fails with the mixing error instead of falling back to regular.

Repros (flag on, either AQE mode): df.groupBy("k").count().orderBy("cnt").limit(5).write.parquet(...) — SpecialLimits plans TakeOrderedAndProjectExec for the non-root sort+limit; it is a UnaryExecNode not matched by isStatsSensitive, so the exchange below it flips, then doExecute builds the hidden SinglePartition regular shuffle (child has >1 partition) and job submission throws. Likewise df.repartition($"k").limit(10).toLocalIterator() via CollectLimitExec.doExecute. The PR's tests only exercise limit(n).collect(), which takes executeTake and never hits doExecute — which is why this went unnoticed.

Fix direction: treat CollectLimitExec / CollectTailExec / TakeOrderedAndProjectExec as blocking in both rules (like CoalesceExec), or teach those operators to emit a pipelined-aware dependency.

}

/** Whether the RDD graph reachable through `dep` contains `targetShuffleId`. */
private def dependencyReachesShuffle(dep: Dependency[_], targetShuffleId: Int): Boolean =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[correctness / CONFIRMED] dependencyReachesShuffle recurses with no visited set, and liveReduceSet re-invokes it (a full subtree re-walk) for every dependency at every recursion level, itself also unmemoized: O(n²) on a deep narrow chain, exponential on narrow diamonds (shared ancestors via zip/nested unions), plus unbounded recursion depth — all on the single-threaded dag-scheduler-event-loop at pipelined producer-stage submission.

A pipelined producer under ~30 levels of shared narrow fan-in explores ~2^30 paths inside submitMissingTasks, freezing all scheduling in the application; a chain thousands of operators deep instead throws StackOverflowError on the event loop. Every other traversal in this file uses the deduped traverseRDDGraph for exactly this reason (the PR's own classifyJobShuffleShape comment cites eliminating O(K x graph) re-walks as motivation).

Fix direction: compute reachability once with a memoized HashMap[RDD, Boolean] (iteratively), and thread it through liveReduceSet instead of re-querying per branch.

new ChannelShuffleReader[K, C](h, startPartition, endPartition, h.numMaps, metrics)
}

override def unregisterShuffle(shuffleId: Int): Boolean = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[correctness / CONFIRMED] The unregister-before-run sequence this class's own scaladoc documents (Dataset.rdd under spark.sql.classic.shuffleDependency.fileCleanup.enabled ends the SQL scope and removes the shuffle from every manager before any job runs) leaves a cleanup hole: unregisterShuffle removes the id from registeredShuffleIds and nothing ever re-adds it (registerShuffle runs once, in the dependency constructor). When the job then runs, ChannelShuffleRendezvous.queue()'s computeIfAbsent recreates the queues — but holdsShuffle is now permanently false, so at GC time ContextCleaner.heldByTracklessPipelinedManager returns false, the tracker-less arm never fires (the shuffle is in neither tracker), and the recreated queues plus abandoned marks leak until SparkContext.stop(). The PR fixed the correctness half of this sequence (numMaps stamped into the handle) but not the cleanup half.

Impact is bounded — the leaked queues are empty (the reader's completion listener drains them), so it is ~numPartitions map entries + abandoned key tuples per run, growing unboundedly over a long-lived session.

Fix direction: re-add the id on first rendezvous access for the shuffle (or have the writer/reader re-register), or make the cleaner's tracker-less arm fire whenever the rendezvous holds state for the id (e.g. a ChannelShuffleRendezvous.holdsShuffle check) rather than relying on the manager's registry.

"amortize the queue's per-operation lock cost at the price of higher hand-off latency " +
"and per-partition buffering. Only used when spark.shuffle.manager.incremental is the " +
"in-process channel manager.")
.version("4.3.0")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[conventions / CONFIRMED] The three new configs — spark.shuffle.pipelined.channel.batchSize here, spark.shuffle.pipelined.channel.queueCapacity below (line 1877), and spark.sql.pipelinedShuffle.enabled in SQLConf — all declare .version("4.3.0"), but branch-4.3 is already cut (branch-4.x is at 4.4.0-SNAPSHOT, master at 5.0.0-SNAPSHOT). A config merged now cannot first ship in 4.3.0; the docs would claim these configs exist in a release that never had them. They should be 4.4.0 (or 5.0.0 if this lands master-only). The adjacent spark.shuffle.manager.incremental legitimately carries 4.3.0 because it already shipped there — likely where the copied value came from.

// and (2) if a FetchFailed did strip the prefix, handleTaskCompletion routes it to a
// WHOLE-GROUP abort (the failing stage is a pipelined group member), not a lone-stage
// resubmit into the held slots -- the job reruns from scratch rather than deadlocking.
if (mapOutputTracker.getNumAvailableOutputs(sd.shuffleId) != sd.rdd.partitions.length) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[efficiency / CONFIRMED] This materialization loop — and the regularBoundaries map feeding it — runs unconditionally on every job submission, but hasUnmaterializedRegularBoundary is only ever consumed when hasPipelined is true (isUnsupportedMix short-circuits otherwise). Every all-regular job on every Spark deployment (feature off, no pipelined manager configured) now pays K getNumAvailableOutputs lookups (shuffleStatuses access + read-locked count) plus the boundary HashMap and per-node (RDD, Boolean) tuple allocations, inside single-threaded handleJobSubmitted.

Fix direction: guard the boundary collection and this loop with hasPipelined (skip both entirely when the walk saw no pipelined dependency).

// and its leftovers are physically separate -- this run starts against empty per-epoch state.

// One in-progress batch per reduce partition, plus its fill count.
val batches = Array.fill(numPartitions)(new Array[AnyRef](batchSize))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[efficiency / CONFIRMED] This eagerly allocates numPartitions x batchSize object slots per map task, including partitions liveMask guarantees are never touched. A 2000-partition repartition allocates ~16MB of empty arrays per map task before the first record; a LIMIT partial read with 1 live partition of 200 allocates 199 arrays the liveMask(pid) gate guarantees are never written.

Fix direction: allocate a partition's batch lazily on its first record (one null-check branch on the hot path), or at minimum only for liveMask-true partitions.

*/
case class EnablePipelinedShuffle() extends Rule[SparkPlan] {

override def apply(plan: SparkPlan): SparkPlan = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[reuse / CONFIRMED] This three-gate preamble (flag check, local-mode check, PipelinedChannelShuffleManager instance check) is duplicated verbatim in AQEEnablePipelinedShuffle.apply, differing only in log wording. It is a correctness gate, not cosmetics: flipping under the RPC streaming manager would skip the row copy (requiresDetachedRecords = false) and silently corrupt rows — so the two copies must never drift, yet nothing ties them together. When eligibility changes (a second validated transport, relaxed locality), one rule gets updated and the other silently splits AQE vs non-AQE behavior.

Fix direction: extract one shared predicate (e.g. a small helper object in execution.exchange) called by both apply methods; each rule keeps only its own plan-shape logic.

// and its map-stage availability is tracked locally on the ShuffleMapStage. When a
// tracker IS expected (the RPC streaming manager) but absent, that is a real
// misconfiguration, so keep failing loudly.
sc.env.streamingShuffleOutputTracker match {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

[correctness / PLAUSIBLE, low severity] The Some arm never consults usesStreamingShuffleOutputTracker, and SparkEnv.initializeStreamingShuffleOutputTracker creates the tracker for blockingIsMulti too. So with the (deprecated) MultiShuffleManager as blocking manager plus the channel manager as incremental, the tracker exists and a channel-served pipelined shuffle IS registered in the StreamingShuffleOutputTrackerMaster — contradicting the invariant the comment below states ("SparkEnv then creates none; such a shuffle registers with no output tracker at all") and the one PipelinedShuffleRoutingSuite pins. The 'two decisions locked consistent / UNREACHABLE by construction' analysis omits the blockingIsMulti OR-clause.

Consequences are cosmetic on inspection: the registration is inert (the channel reader/writer never consult the tracker) and cleanup still routes through shuffleDriverComponents.removeShuffle in the streaming arm, so nothing leaks. Still, either the Some arm should consult the flag, or the comment/test should carry the caveat for this combination.

HeartSaVioR commented Aug 28, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

This is a major code change with 4000 lines of code diff - I'm not sure we could have a sufficient time duration to bake this in rather than failing the RC every time we realize an issue. What is the impact of deferring this by 3 months?

viirya commented Aug 28, 2026
edited
Loading

Copy link
Copy Markdown
Member Author

This is a major code change with 4000 lines of code diff - I'm not sure we could have a sufficient time duration to bake this in rather than failing the RC every time we realize an issue. What is the impact of deferring this by 3 months?

I said that it is good to have it in 4.3 because it is disabled by default. If we worry that any issues of this would fail RC, I'm okay to have this in 4.4 instead of 4.3.

Copy link
Copy Markdown
Member

Thank you. +1 for the decision to retarget for Apache Spark 4.4.0. Please update the config versions accordingly.

viirya added 4 commits August 28, 2026 10:41
…reads a shuffle

Three plan shapes could reach the channel transport in a way it cannot serve.
All three now leave the WHOLE plan regular (leaving only one exchange regular
would put a pipelined exchange below a regular boundary, which the scheduler
rejects, so it must be all-or-nothing).

- CartesianProductExec: UnsafeCartesianRDD reads each child partition once per
  right partition, so N reduce tasks mint N readers on the SAME rendezvous
  queue for one (shuffleId, epoch, pid). Rows and end-of-stream markers split
  nondeterministically (wrong results), a reader short of numMaps markers
  hangs, and the first to finish abandons the queue and discards the others'
  data. The fan-out check does not catch it (one consumer RDD, computed many
  times), nor does the reader's width-1 require (it guards range width, not
  reader count).
- coalesce over a UnionExec/join: the guard walk only recursed through
  UnaryExecNode, so a CoalesceExec above a BinaryExecNode whose subtree holds
  shuffles was missed and those exchanges flipped -- the coalesced
  multi-partition read then deadlocks the single-threaded writer. The walk now
  recurses through every child, stopping at the first exchange on each path.
- CollectLimitExec / CollectTailExec / TakeOrderedAndProjectExec: each builds a
  hidden regular (pipelined = false) shuffle inside doExecute via
  prepareShuffleDependency, invisible to any plan walk. A flipped exchange
  below one of them sits under an unmaterialized regular boundary and the job
  hard-fails at submission. Blocked in both rules (the AQE rule via
  isStatsSensitive). This is deliberately broad: whether such an operator will
  take executeCollect (safe) or doExecute (hazardous) depends on the action the
  user calls, which the rule cannot know -- the same plan, with the operator at
  the root, serves both .collect() and .write.

Also extract the shared environment gate (opt-in flag, single-executor local
mode, channel manager active) into PipelinedShuffleEligibility, so the two
rules cannot drift: flipping under the RPC streaming manager would skip the row
copy and silently corrupt rows.

Tests: cartesian, coalesce-over-union and limit-write fallbacks (each verified
to FAIL with the pre-fix guard). PipelinedLimitHangSuite now asserts the
fallback it can actually observe -- its LIMIT shapes are no longer pipelined, so
it could no longer exercise the early-stop path and would have passed
vacuously; that machinery moved to RDD-level tests in the core suite.

Co-authored-by: Claude Code
…off the default path

liveReduceSet / dependencyReachesShuffle re-walked the RDD subtree for every
dependency at every recursion level, unmemoized: O(n^2) on a deep narrow chain,
exponential on a narrow diamond (a shared ancestor reached through several
branches, e.g. a zip), plus unbounded recursion depth -- all on the
single-threaded dag-scheduler event loop, where a wide shared fan-in freezes
scheduling and a very deep chain throws StackOverflowError.

- Reachability is now computed once by rddReachesShuffle: memoized, and
  iterative (a two-phase post-order over an explicit stack) so a shared ancestor
  is visited once and depth costs no stack. It returns the memo, so the
  submitMissingTasks caller queries it instead of re-walking.
- liveReduceSet is now an explicit worklist. The work unit is
  (rdd -> the live subset of ITS partition indices); a node reached through
  several branches is processed with the UNION of them, which is equivalent to
  the old per-branch recursion plus final union because getParents distributes
  over union. A node is re-enqueued only when its accumulated set actually grew
  (set inequality, not size), and the sets grow monotonically inside a finite
  index domain, so it converges. The unmappable short-circuit keeps the old
  None semantics.

Two default-path costs this feature should not impose:
- classifyJobShuffleShape returns early when no pipelined shuffle manager is
  configured: without one no PipelinedShuffleDependency can exist, so the shape
  is trivially all-regular and every job on a default cluster now skips the walk
  (a boundary HashMap, a visited set of (RDD, Boolean) tuples, a tuple per node
  per context).
- the materialization loop (K getNumAvailableOutputs lookups, each a read-locked
  count) now runs only when the walk saw a pipelined dependency, since
  isUnsupportedMix reads hasUnmaterializedRegularBoundary only then.

outputTrackerMaster's Some arm now consults usesStreamingShuffleOutputTracker
rather than mere tracker presence: SparkEnv creates the tracker when the
incremental manager needs it OR when the blocking manager is a
MultiShuffleManager, so with MultiShuffleManager plus the in-process channel
manager a channel shuffle was registered in the streaming tracker -- inert, but
contradicting the invariant this method documents and PipelinedShuffleRoutingSuite
pins.

Co-authored-by: Claude Code
…ate writer batches lazily

The tracker-less cleanup arm was scoped by a per-manager registry of registered
shuffle ids. That registry is dropped at unregisterShuffle and never re-added,
so the unregister-before-run sequence this manager documents (Dataset.rdd under
spark.sql.classic.shuffleDependency.fileCleanup.enabled ends the SQL scope and
removes the shuffle from every manager before any job runs) left a hole: when the
job then ran, the rendezvous recreated its queues lazily, but holdsShuffle stayed
false forever, so the arm never fired and the recreated queues plus abandoned
marks leaked until SparkContext.stop().

Key the arm off ChannelShuffleRendezvous.holdsShuffle instead -- the rendezvous
is the thing that actually holds the state to free, so a re-run's recreated
queues are freed, while a regular shuffle (never present there) still gets no
duplicate cluster-wide RemoveShuffle RPC. The manager's registry and the
PipelinedShuffleManager.holdsShuffle trait method (private[spark], so no
compatibility concern) are removed as now-redundant.

ChannelShuffleWriter.write allocated numPartitions x batchSize object slots up
front, including for partitions liveMask guarantees are never written -- ~16MB of
empty arrays per map task on a 2000-partition shuffle, and 199 unused arrays for
a partial read with one live partition of 200. Each partition's batch is now
allocated on its first record, and a filled batch handed to the consumer leaves
null behind rather than eagerly allocating a successor the partition may never
use.

Tests: the cleanup-scoping test now drives the rendezvous predicate, plus a
regression for the unregister-before-run re-run. Three RDD-level partial-read
tests (identity, union offsets, zip fan-in) cover the live-reduce-set and
early-stop machinery that the SQL LIMIT suite can no longer reach; each was
verified to hang (and so fail) when liveReduceSet is deliberately broken.

Co-authored-by: Claude Code
branch-4.3 is already cut (branch-4.x is at 4.4.0-SNAPSHOT), so a config merged
now cannot first ship in 4.3.0 and the docs would claim these exist in a release
that never had them. spark.shuffle.pipelined.channel.batchSize,
spark.shuffle.pipelined.channel.queueCapacity and
spark.sql.pipelinedShuffle.enabled move to 4.4.0. The adjacent
spark.shuffle.manager.incremental legitimately keeps 4.3.0 -- it already shipped
there, which is likely where the copied value came from.

Co-authored-by: Claude Code

viirya commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Thanks -- this was a very useful pass. All ten are addressed; four commits pushed. Notes where my fix differs from the suggestion, or where the cost is worth calling out.

1. Cartesian N-to-1 read. Confirmed and fixed. UnsafeCartesianRDD computes each child partition once per right partition, so N reduce tasks mint N readers on one (shuffleId, epoch, pid) queue -- rows and markers split, a reader short of numMaps hangs, and the first to finish abandons the queue under the others. As you note neither the fan-out check (one consumer RDD, computed many times) nor the width-1 require catches it. A shuffle read through a CartesianProductExec now leaves the plan regular.

2. coalesce over a binary node. Confirmed and fixed -- the walk now recurses through every child and stops at the first exchange on each path. Worth recording how the regression test went: my first attempt used two identical repartition($"k") branches and passed even with the old guard, because exchange reuse collapsed them and the reuse gate bailed out first, and ~25K rows per reduce partition never filled the 64-batch queue. The committed test uses two structurally different shuffles (groupBy vs repartition) and enough rows to park the writer; it deadlocks with the old guard and passes with the new one.

3. Hidden shuffles in the limit operators. Confirmed and fixed by blocking all three in both rules -- but the block is broader than the hazard, and that costs real coverage: limit(n).collect() shapes are no longer pipelined even though executeCollect never builds the hidden shuffle. I tried to narrow it to "not at the plan root" and measured that this does not work: for groupBy.count().orderBy(c).limit(5), TakeOrderedAndProject sits at the root both for .collect() (safe) and for .write.parquet(...) (hazardous), and the write path re-plans to the same root shape -- the rule sees an identical plan and cannot know the action. So the broad block is deliberate.

Your alternative (emit a pipelined-aware dependency) does look feasible: prepareShuffleDependency's pipelined branch already installs the detaching write processor, the hidden shuffle is SinglePartition so the width-1 read is satisfied trivially, and doExecute already holds child.execute() so it could inspect the child RDD graph for a PipelinedShuffleDependency rather than guessing from the conf. I left it out of this PR because it changes three shared operators in limit.scala and introduces execution-time creation of pipelined dependencies -- a mechanism worth its own review. Happy to open a follow-up JIRA if you agree.

One consequence I should flag: PipelinedLimitHangSuite's shapes are now regular, so it could no longer exercise the early-stop path and would have passed vacuously. It now asserts the fallback instead, and the live-reduce-set/early-stop machinery moved to three RDD-level partial-read tests in PipelinedChannelShuffleSuite (identity, union offsets, zip fan-in). I verified each fails -- hangs to the deadline -- when liveReduceSet is deliberately broken.

4. Unmemoized reachability. Fixed. Reachability is computed once by a memoized, iterative (two-phase post-order) rddReachesShuffle that returns its memo, and liveReduceSet is now an explicit worklist keyed on (rdd -> live subset). Merging live sets at a shared node is equivalent to the old per-branch recursion plus union because getParents distributes over union; a node is re-enqueued only when its set actually grew (set inequality, not size), which terminates in a finite index domain.

5. Cleanup scoped to the manager, not the shuffle. Fixed, and thank you -- the registry I had was worse than you describe: it is dropped at unregisterShuffle and never re-added, so after the unregister-before-run sequence the recreated queues leaked permanently. The arm now keys off ChannelShuffleRendezvous.holdsShuffle, i.e. the thing that actually holds the state to free. The manager registry and the holdsShuffle trait method are gone.

6. Per-boundary materialization loop. Fixed -- it runs only when the walk saw a pipelined dependency. I also added the early-out you would probably have asked for next: classifyJobShuffleShape returns immediately when no pipelined manager is configured, so a default cluster no longer pays for the graph walk itself on every job submission.

7. Config versions. Fixed: the three new configs move to 4.4.0. Which also answers your RC1 question -- with branch-4.3 cut this cannot land in 4.3.0, so I have set them to the release it would actually first ship in.

8. Eager batch allocation. Fixed -- allocated per partition on first record, and a handed-off batch leaves null behind rather than eagerly allocating a successor.

9. Duplicated eligibility gate. Fixed -- extracted to PipelinedShuffleEligibility, used by both rules.

10. MultiShuffleManager + channel manager. Confirmed; the Some arm now consults usesStreamingShuffleOutputTracker rather than mere tracker presence, so the documented invariant and the routing-suite assertion now match the behavior. My earlier "unreachable by construction" comment missed the blockingIsMulti clause -- removed.

Tests: core 264 and the SQL pipelined suites 26, all green.

Scalastyle enforces alphabetical order inside an import selector: CoalesceExec
must precede CollectLimitExec / CollectTailExec.

Co-authored-by: Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants


Back | FazBrowse Home | New Git URL