[SPARK-59122][SQL] Take UnionExec's plain-union decision once instead of re-deriving it per caller - #58419
[SPARK-59122][SQL] Take UnionExec's plain-union decision once instead of re-deriving it per caller#58419LuciferYang wants to merge 8 commits into
Conversation
… of re-deriving it per caller Fused UnionExec threw "key not found: numOutputRows" because the copy insertInputAdapter puts inside the codegen shell re-derived the gate after the cache stages had finalised. Latch the decision in a TreeNodeTag, which withNewChildren copies, and derive outputPartitioning from it.
- publish the latch under a dedicated `decisionLock`, with the derivation outside it and the first writer winning - read `outputPartitioning` once in `doExecute` so the branch and `numPartitions` come from the same answer - correct four comments whose stated mechanisms did not match the code - reuse `AdaptiveSparkPlanHelper.collect` instead of a hand-written traversal, narrow the test cache helper to this view's own plan, and make `PLAIN_UNION_DECISION` private
…decision `rawPartitioning` read `spark.sql.unionOutputPartitioning` on every call and `SparkPlan.conf` is the live session conf, so a plan made with the conf on could execute with it off: the parent aggregate had already lost its exchange because the union reported a concrete `HashPartitioning`, and the union then concatenated, putting one group in two partitions and reporting it twice. Read the conf where the plain-union decision is latched instead, so what the node reports and how it executes cannot be split by a conf change. The other half -- the children answering differently later -- stays as it was. AQE skew splitting through a union does that routinely, and it is safe there only because those parents require no distribution; `doExecute` cannot tell whether anything consumed the reported partitioning, so guarding on the latch alone rejects those plans too.
Comment-only. Several rounds of review each added a clause, and the blocks ended up restating the same tag-propagation mechanism three times. Keep the pointers a reader needs to navigate -- `InMemoryTableScanExec.outputPartitioning`, `CollapseCodegenStages`, `withNewChildren`/`copyTagsFrom`, `metricTerm` -- plus why `UNION_OUTPUT_PARTITIONING` is read in the latch and why `doExecute` does not guard on the latch alone. Drop the rest.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Left inline comments. The main ones:
doExecuteColumnarstill concatenates while a non-plain union advertisesHashPartitioning, which gives wrong results with bucketed columnar scans. Pre-existing from SPARK-52921, but the newoutputPartitioningdoc states the invariant it breaks.- The latch changes
CoalesceShufflePartitionsbehavior after a skew split through a union (both children lose coalescing). - The
numOutputRowscrash still reproduces through the codegen confs, since only theisPlainUnioninput is latched.
The rest are smaller (lock shape, @transient val, test cleanups).
| /** | ||
| * A node latched plain reports `UnknownPartitioning` even once its children agree on a concrete | ||
| * one: a fused union concatenates, and claiming their partitioning would let a parent skip an | ||
| * exchange it needs. The cost is SPARK-52921's exchange elimination for such a union. |
There was a problem hiding this comment.
This invariant only holds on the row path. doExecuteColumnar (L1307) still does sparkContext.union(children.map(_.executeColumnar())) regardless of outputPartitioning, so a non-plain union that stays columnar concatenates while advertising its children's HashPartitioning.
I reproduced wrong results on master with two bucketed Parquet tables (bucketBy(4, "k")):
SELECT k, count(*) FROM (SELECT * FROM t1 UNION ALL SELECT * FROM t2) GROUP BY kplans as HashAggregate <- ColumnarToRow <- Union(FileScan bucketed, FileScan bucketed) with no exchange and returns 10 rows instead of 5 (spark.sql.unionOutputPartitioning=false gives 5). This predates this PR (SPARK-52921), but since the doc here states the invariant, could we either mirror doExecute in doExecuteColumnar or make supportsColumnar false when !isPlainUnion? A separate JIRA is fine too.
There was a problem hiding this comment.
Your reproduction matched mine exactly, down to the ten rows of four. This became SPARK-59141 (#58445) and landed on master, so it arrives here through the merge in 32a2593d7ba, with doExecute and doExecuteColumnar sharing one unionRDDs helper. The bug is reachable on branch-4.2 and branch-4.1 as well, so I have backport PRs open for both (#58511, #58512).
| * `UNION_OUTPUT_PARTITIONING` is read here rather than in `rawPartitioning` so it is latched too: | ||
| * `conf` is live, and re-reading it let a plan made with the conf on execute with it off. | ||
| */ | ||
| private[sql] def isPlainUnion: Boolean = { |
There was a problem hiding this comment.
One consumer of isPlainUnion whose behavior this latch changes is CoalesceShufflePartitions.childrenNeedCompatiblePartitioning (L189).
With AQE on, SparkPlanInfo.fromSparkPlan / EnsureRequirements latch the union non-plain while both children are identical rebalance exchanges. If OptimizeSkewInRebalancePartitions later splits only one child, that child reports UnknownPartitioning, but the tag still says non-plain, so both children land in one coalesce group with mixed specs and coalescePartitionsWithSkew bails out for both (Could not apply partition coalescing ...). Before this PR the re-derived answer was plain and each child was coalesced independently.
e.g. df1.hint("rebalance", "k").union(df2.hint("rebalance", "k")).count() with skew only in df1. Results are still correct, but this is a behavior change that isn't mentioned or tested.
There was a problem hiding this comment.
This one is still open after the latest push. The CoalesceShufflePartitions consumer at L189 is a behavior change this PR introduces (both children lose coalescing after a skew split through the union), so I'd like to hear your take before moving on: is it acceptable as is, should the consumer read rawPartitioning instead of the latch, or should it be mentioned in the PR description and covered by a test?
There was a problem hiding this comment.
Keeping the latch, and I would rather not have the consumer read rawPartitioning.
The reason is narrower than "the latch is better". unionRDDs branches on the latched decision through outputPartitioning, so if childrenNeedCompatiblePartitioning keyed off a freshly derived value, the grouping decision and the arm the union actually takes could come from two different reads. Today they cannot. There is also a functional difference: reading isPlainUnion latches an unlatched union at the earliest AQE consumer, and reading rawPartitioning would not.
I could not construct a wrong result from the fresh read, for what it is worth. Wherever a parent relies on the union's partitioning, rawPartitioning has stayed concrete and the two values agree; where they diverge, nothing relies on it and both arms produce the same rows in a different layout. So this is about keeping the decision locally evident rather than contingent on that analysis.
On the behavior change itself you are right that it is one, and that it was neither documented nor tested. I have added it to the description: after a skew split under a non-plain union both children lose coalescing, where before the re-derived answer was plain and each child was coalesced independently.
One correction to the repro. df1.hint("rebalance", "k").union(df2.hint("rebalance", "k")).count() does not reach it: the ProjectExec that count() puts above the union drops k, so the union latches plain and takes the independent-group path. It needs an aggregate that keeps the key, for example groupBy("k").max("v").
On a test, the cheapest shape I found pins the timing rather than the skew path: cache a child so the union latches while the inner AQE plan is non-final, turn coalescing on, and assert that the two children's read specs may differ. I can add that if you want it, though it does not cover the skew axis, and building a stable one-side-skewed rebalance pair looked more expensive than this behavior change warrants. Your call.
| // and `metrics` see one reason on one instance; `conf` is live, so re-deriving | ||
| // could answer differently. Agreeing with the `withNewChildren` copy is the | ||
| // `isPlainUnion` tag's job, not this memo's. | ||
| @transient private lazy val supportCodegenFailureReason: Option[String] = { |
There was a problem hiding this comment.
The comment above says agreeing with the withNewChildren copy is the tag's job, but the tag only covers the isPlainUnion input. The copy that insertInputAdapter puts inside the shell still evaluates this lazy val for the first time at execution, so WHOLESTAGE_UNION_CODEGEN_ENABLED / WHOLESTAGE_UNION_MAX_CHILDREN flipped between executedPlan and collect() still gives empty metrics and the same key not found: numOutputRows from doProduce (AQE off, children with an exchange so the copy is fresh).
That's pre-existing, but it suggests the cleaner fix is registering numOutputRows unconditionally like the other CodegenSupport operators, rather than tying metrics to this memo.
There was a problem hiding this comment.
Good news: latching the whole reason worked, so I did not need the unconditional-registration fallback. Fixed in 543684188d1.
The reason now lives in a CODEGEN_FAILURE_REASON tag under the same decisionLock, so metrics and doProduce see one answer by construction, and the metric still stays off plans that fall back to doExecute. The trade-off is the one I mentioned: spark.sql.codegen.wholeStage.union.enabled becomes sticky per plan, the same semantics this PR already gives spark.sql.unionOutputPartitioning. Glad to switch to the unconditional registration if you would rather have that.
Your parenthetical about the exchange turned out to be the key part, and I am glad you wrote it. With spark.range(...).union(spark.range(...)) nothing fails, because withNewChildren returns this when the children compare equal, so there is no second instance to re-derive anything; the child has to not be CodegenSupport for insertInputAdapter to produce a real copy. My first repro attempt missed that and came back green. The new test puts a repartition(2) on each side, and reverting the latch makes it fail with the same key not found: numOutputRows.
The PR description now covers both routes.
| rawPartitioning.isInstanceOf[UnknownPartitioning] | ||
| decisionLock.synchronized { | ||
| getTagValue(UnionExec.PLAIN_UNION_DECISION).getOrElse { | ||
| setTagValue(UnionExec.PLAIN_UNION_DECISION, plain) |
There was a problem hiding this comment.
Since this write happens on first read, reading outputPartitioning on the un-prepared queryExecution.sparkPlan now latches plain (children haven't got their exchanges yet), and executedPlan inherits it through sparkPlan.clone() -> makeCopy -> copyTagsFrom (QueryExecution.scala:395). After EnsureRequirements inserts matching HashPartitioning under both children, the union still reports UnknownPartitioning and the parent's exchange elimination is lost.
QueryTest.checkAnswer(_, planFunction, _) and PlannerSuite inspect sparkPlan this way, and listeners/extensions can too. Before the PR outputPartitioning was side-effect free.
There was a problem hiding this comment.
Still open. Could you reply on whether this side effect of reading outputPartitioning on the un-prepared sparkPlan is acceptable, or whether the latch should only be taken once the plan is prepared?
There was a problem hiding this comment.
Not acceptable as is, agreed. I do not think "latch only once the plan is prepared" can be expressed from inside the node either, since it has no way to know whether it is being read before or after preparation.
What I would do instead is stamp the decision from a rule appended after EnsureRequirements, in both QueryExecution.preparations and AQE's stage-prep list. That turns "whoever reads first" into a defined point and makes outputPartitioning side-effect free again.
That also answers half of your design comment below: the conf can be a planner-set field, but the partitioning half cannot be decided in SparkStrategies, because the children there are PlanLater placeholders. A decision taken at that point comes out plain for every union, including ones whose children are co-partitioned without any exchange.
Would you rather I do that in this PR, or land the current read-on-first-use and follow up?
| // Serializes the latch below so concurrent first readers agree on one answer. Not this node's own | ||
| // monitor, which `unionedInputRDD`'s `lazy val` holds while it drives `child.execute()`. | ||
| // Driver-only, hence `@transient`. | ||
| @transient private val decisionLock = new Object() |
There was a problem hiding this comment.
This is null after deserialization, so isPlainUnion / outputPartitioning / doExecute NPE on a deserialized UnionExec (e.g. inside a ScalarSubquery / InSubqueryExec plan captured in a task closure), where the previous outputPartitioning still worked. @transient private lazy val keeps it driver-only and survives the round trip, as SPARK-23731 did for FileSourceScanExec.
There was a problem hiding this comment.
Still a val after the latest push. Is there a reason not to make it @transient private lazy val? If you'd rather keep it, please say so here.
There was a problem hiding this comment.
I would rather keep the val, because lazy val trades this NPE for a lock-ordering problem.
A lazy val's initializer takes the enclosing instance's monitor in Scala 2.13, and that is the same monitor unionedInputRDD's lazy val holds while it builds the children's RDDs. I checked the bytecode on this branch rather than trusting the reference: unionedInputRDD$lzycompute does monitorenter on this and runs children.map(...) and new UnionRDD(...) inside it. supportCodegenFailureReason is another lazy val that calls isPlainUnion from inside its own initializer. So a lazy lock puts its own initialization behind the monitor that the separate lock exists to stay out of, and CoalesceShufflePartitions reads isPlainUnion while holding the AQE lock.
On the NPE I read it as parity with what SparkPlan already does rather than as unreachable. SparkPlan has @transient private val prepareLock = new Object(), taken in prepare() and waitForSubqueries(), which are on the path of every execute*, and @transient val session ... orNull is on the path of conf, sparkContext and metrics. A deserialized plan that anyone uses as a plan has been failing on those long before this, so decisionLock adds no exposure that was not already there. I would not claim more than that: I did not sweep every path that can put a plan in a closure.
If you would still rather not add another one, the shape that avoids the monitor without the NPE is an explicit field forwarded in withNewChildrenInternal, which is your next comment.
| * `conf` is live, and re-reading it let a plan made with the conf on execute with it off. | ||
| */ | ||
| private[sql] def isPlainUnion: Boolean = { | ||
| decisionLock.synchronized(getTagValue(UnionExec.PLAIN_UNION_DECISION)).getOrElse { |
There was a problem hiding this comment.
rawPartitioning only walks the children (no execute, no lock another thread could hold while waiting on this one), so this can be a single decisionLock.synchronized { getTagValue(...).getOrElse { ...; setTagValue(...); plain } }, like SparkPlan.prepare(). Concurrent first readers then wait behind one derivation instead of each deriving and discarding.
There was a problem hiding this comment.
Done in d60079ebca4. It reads much better as one block, and prepare() was the right precedent to point me at.
| * | ||
| * The other branch is derived per call and can come back `UnknownPartitioning` later -- AQE skew | ||
| * splitting through a union leaves the children's partition counts divergent. Failing there was | ||
| * tried and reverted: nothing in those plans required the reported partitioning, and this node |
There was a problem hiding this comment.
nit: this paragraph (and "not this memo's job" in the memo comment below) reads as PR history rather than a description of the code. I'd keep the first paragraph plus a one-liner that the non-plain branch may become UnknownPartitioning after AQE skew splitting and is tolerated, and drop the tried-and-reverted sentence.
There was a problem hiding this comment.
Good catch, and done in d60079ebca4. Two later passes tightened the same paragraph again after I found it was still claiming more than the code shows: it now states the mechanism and names what reconciles a change, with no tried-and-reverted history left in it.
| } | ||
| } | ||
|
|
||
| test("SPARK-59122: a fused union keeps numOutputRows when a child's partitioning firms up") { |
There was a problem hiding this comment.
This and the next test have identical setup and assert two halves of the same decision. One test asserting both metrics.contains("numOutputRows") and outputPartitioning.isInstanceOf[UnknownPartitioning] would do.
There was a problem hiding this comment.
Agreed, merged in 9da6b20394e. One test asserts both halves now, and a later pass also pinned spark.sql.unionOutputPartitioning inside it, so it cannot pass vacuously if that default ever flips.
| .createOrReplaceTempView(view) | ||
| // Both callers need the cache unmaterialized, and `CacheManager` no-ops on an already-cached | ||
| // plan, so drop whatever an earlier test left for this one. `isCached` matches by plan. | ||
| if (spark.catalog.isCached(view)) spark.catalog.uncacheTable(view) |
There was a problem hiding this comment.
This guard is unreachable: both callers are inside withTempView("v"), which already uncaches via Catalog.dropTempView -> uncacheView at the end of each test.
There was a problem hiding this comment.
You are right, and it is gone in 9da6b20394e. The helper's doc comment now just says what withTempView does instead of promising more than dropTempView actually gives.
| def build(): DataFrame = | ||
| left.repartition(4, col("k")).union(right.repartition(4, col("k"))).groupBy("k").count() | ||
|
|
||
| val expected = withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") { |
There was a problem hiding this comment.
The answer is fully determined here (4 ids per k from each range), so checkAnswer(planned, (0L until 5L).map(k => Row(k, 8L))) is stronger and avoids a second plan/execute. The parity oracle would still pass if both paths regressed to 10 rows of 4.
There was a problem hiding this comment.
Done in 9da6b20394e: it asserts (0L until 5L).map(k => Row(k, 8L)) now and skips the second execution. Your point about both paths regressing together is exactly why, and I made the same change to the SPARK-59141 test for the same reason.
Kept the latch; dropped this branch's doExecute single-read hunk, which SPARK-59141's shared unionRDDs helper now covers.
|
Is this ready back for reviews, @LuciferYang ? |
|
Summary of my review comments so far, for tracking. Addressed (thanks for the quick turnaround)
Still open, waiting for a reply
Item 1 is the one I'd like settled before this merges. The Kafka failure in the latest CI run is unrelated ( |
Not really… I forgot yesterday that this PR wasn’t fully fixed yet. |
Thanks for your review. Let me take another look. @dongjoon-hyun |
b6cea20 to
7cdb29c
Compare
supportCodegenFailureReason read live confs, so the copy insertInputAdapter puts inside the codegen shell could answer differently than the gate did and leave metrics empty under generated code that asks metricTerm for numOutputRows. Store the reason in a TreeNodeTag under decisionLock, the same way isPlainUnion is latched.
What changes were proposed in this pull request?
UnionExecreads its children'soutputPartitioningto decide three separate things: whether whole-stage codegen fusion applies, whethernumOutputRowsis registered, and which RDDunionRDDsbuilds. Those reads can disagree, because the children's answer moves while the plan is prepared and executed. This PR makes the decision once per node, stores it in aTreeNodeTag, and derivesoutputPartitioningfrom the stored value.outputPartitioningbecomes a privaterawPartitioning, still derived from the children;isPlainUnionevaluates it on first use and latches the answer inUnionExec.PLAIN_UNION_DECISION, publishing it under a dedicated lock so two concurrent first readers cannot latch different answers;outputPartitioningreturnssuper.outputPartitioningfor a node that latched plain, andrawPartitioningotherwise;supportCodegenFailureReasonlatches the same way, inUnionExec.CODEGEN_FAILURE_REASON, so the fusion gate andmetricscannot answer differently either;spark.sql.unionOutputPartitioning,spark.sql.codegen.wholeStage.union.enabled,spark.sql.codegen.wholeStage.union.maxChildren) are read where the decision is latched rather than on every call.TreeNode.withNewChildrenends incopyTagsFrom, which only writes into a tagless node, so the copy thatCollapseCodegenStagesplaces inside the codegen shell inherits the decision instead of making its own. A field would not survive that rebuild, which is why the decision lives on a tag rather than in alazy val.Why are the changes needed?
Two things go wrong on default configuration, both because those reads disagree.
The first is a crash: a fused union loses the metric its generated code increments.
InMemoryTableScanExec.outputPartitioningreportsUnknownPartitioningwhile its innerAdaptiveSparkPlanExechas no final plan. The row-basedProjectExecon one branch keeps the union out of the columnar path, so the union looks plain at the momentCollapseCodegenStagesgates on it and is fused.insertInputAdapterthen rebuilds the node and puts the copy in the shell, and that copy evaluates the gate for the first time after the cache stages have finalised: both children now report the sameHashPartitioning, the gate answerspartitioning-aware,metricscomes back empty, anddoProducethrows while askingmetricTermfornumOutputRows.The same crash has a second route that needs no cache, because the codegen reason was derived from live confs as well. Plan the query with
spark.sql.codegen.wholeStage.union.enabledon so the union is fused, set it to false, then collect: the copy inside the shell evaluates the reason for the first time at execution, answersunion-codegen-disabled, andmetricscomes back empty for a stage whose code is being generated on the assumption that it is fused. This route needs a child that is notCodegenSupport, an exchange for instance, sincewithNewChildrenreturnsthiswhen the children compare equal and then no second instance exists to re-derive anything. Latching the whole reason closes it: the gate evaluates it on the original beforeinsertInputAdaptertakes the copy.Registering the metric unconditionally would stop both crashes, but it leaves the disagreement that produces the wrong answer below in place, since a fused union concatenates its children's partitions and a node that went on claiming their
HashPartitioningcould let a parent skip an exchange it needs. It would also put a 0-valued row count on every union that falls back todoExecute.The second is a wrong answer with no crash.
rawPartitioningused to readspark.sql.unionOutputPartitioningon every call,SparkPlan.confis the live session conf, andQueryExecution.executedPlanis memoized on first read:Five groups of eight come back as ten rows of four. Reading the conf where the decision is latched fixes this: once a node is planned, flipping the conf cannot change what it reports or how it executes.
One half of that gap stays open. A union latched non-plain still derives
rawPartitioningfrom its children, and AQE skew splitting through a union leaves the children's partition counts divergent, so such a union can concatenate at execution after reporting something concrete at planning. Adding a check for that turned out to be wrong: nothing in those plans required the reported partitioning, and the node cannot tell at execution time whether anything did, so the check also rejected parents that ask forUnspecifiedDistribution.This affects the fusion added in SPARK-56482, so branch-4.2 onward carries it.
Does this PR introduce any user-facing change?
Yes. A query of the first shape above fails on 4.2.0 and returns rows after this change; a query of the second shape returns duplicated groups and now returns the correct ones.
In the other direction, a union whose children only agree on partitioning after planning now keeps reporting
UnknownPartitioning, so SPARK-52921's exchange elimination no longer applies to that shape. The alternative is a node that concatenates partitions while advertising a partitioning it does not have.Both decisions are now taken once per node, so the three confs above no longer apply to a plan that has already been prepared. Setting one between
explain()andcollect()used to change how that plan executed, which is what the second crash route and the wrong answer above both are. A plan built after the change picks up the new value as before.One AQE-side behavior change comes with the latch.
CoalesceShufflePartitionsreads the decision to tell whether a union's children have to coalesce compatibly, so a union that latched non-plain keeps its children in one coalesce group; ifOptimizeSkewInRebalancePartitionsthen splits only one of them, the group's partition counts stop matching and both children lose coalescing. Before this change the answer was re-derived, came out plain at that point, and each child was coalesced independently. Results are the same either way.How was this patch tested?
Three new cases in
UnionCodegenSuite. The first covers both halves of the fused-union failure: the union keepsnumOutputRows, and it reportsUnknownPartitioningrather than its children'sHashPartitioning. The second plans a union withspark.sql.unionOutputPartitioningon, flips the conf off, and then collects, so the union still executes by the partitioning it reported. The third does the same withspark.sql.codegen.wholeStage.union.enabled, over a union whose children are exchanges so that the shell really holds a copy. Each fails without the corresponding change: the first and the third with the exception above, the second with ten rows where five are expected.Locally:
UnionCodegenSuite,DataFrameSetOperationsSuite,CoalesceShufflePartitionsSuite,AdaptiveQueryExecSuiteandKeyGroupedPartitioningSuiterun 395 cases, all passing;sql/scalastyleandsql/Test/scalastyleare clean. The existing caseSPARK-56482: partitioning-aware union falls back to non-codegencovers children that agree up front, so it pins that the latch does not disable the partitioning-aware path.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5