LuciferYang commented on code in PR #58419:
URL: https://github.com/apache/spark/pull/58419#discussion_r3931513453


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -1023,13 +1026,53 @@ case class UnionExec(children: Seq[SparkPlan]) extends 
SparkPlan with CodegenSup
     }
   }
 
-  // True when the codegen path applies: `outputPartitioning` is 
`UnknownPartitioning`,
-  // and `unionedInputRDD` matches the semantics of `sparkContext.union(...)` 
in `doExecute`.
-  // A `KeyedPartitioning` union also uses `sparkContext.union(...)` in 
`doExecute`, but
-  // codegen is disabled for it (`supportCodegenFailureReason` reports 
"partitioning-aware"):
-  // the per-partition key descriptor is consumed by a downstream 
`GroupPartitionsExec`, and
-  // keeping these unions out of whole-stage codegen matches the 
`HashPartitioning` union case.
-  private[sql] def isPlainUnion: Boolean = 
outputPartitioning.isInstanceOf[UnknownPartitioning]
+  // 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()
+
+  /**
+   * True when this union behaves as a plain concatenation, so 
`unionedInputRDD` matches
+   * `sparkContext.union(...)` in `doExecute` and the codegen path applies. A 
`KeyedPartitioning`
+   * union also concatenates, but codegen stays off for it: 
`supportCodegenFailureReason` reports
+   * "partitioning-aware", because a downstream `GroupPartitionsExec` consumes 
its key descriptor.
+   *
+   * Latched, because the answer moves under its consumers.
+   * `InMemoryTableScanExec.outputPartitioning` reports `UnknownPartitioning` 
while its inner
+   * `AdaptiveSparkPlanExec` has no final plan, so a union can look plain when
+   * `CollapseCodegenStages` gates on it and partitioning-aware by the time 
the stage runs. The
+   * shell that gate builds wraps a `withNewChildren` copy, and a copy that 
re-derives comes back
+   * with empty `metrics` while `doProduce` asks `metricTerm` for 
`numOutputRows`. A `TreeNodeTag`
+   * survives that rebuild where a field would not, since `withNewChildren` 
ends in `copyTagsFrom`.
+   *
+   * `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 = {

Review 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.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -1023,13 +1026,53 @@ case class UnionExec(children: Seq[SparkPlan]) extends 
SparkPlan with CodegenSup
     }
   }
 
-  // True when the codegen path applies: `outputPartitioning` is 
`UnknownPartitioning`,
-  // and `unionedInputRDD` matches the semantics of `sparkContext.union(...)` 
in `doExecute`.
-  // A `KeyedPartitioning` union also uses `sparkContext.union(...)` in 
`doExecute`, but
-  // codegen is disabled for it (`supportCodegenFailureReason` reports 
"partitioning-aware"):
-  // the per-partition key descriptor is consumed by a downstream 
`GroupPartitionsExec`, and
-  // keeping these unions out of whole-stage codegen matches the 
`HashPartitioning` union case.
-  private[sql] def isPlainUnion: Boolean = 
outputPartitioning.isInstanceOf[UnknownPartitioning]
+  // 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()
+
+  /**
+   * True when this union behaves as a plain concatenation, so 
`unionedInputRDD` matches
+   * `sparkContext.union(...)` in `doExecute` and the codegen path applies. A 
`KeyedPartitioning`
+   * union also concatenates, but codegen stays off for it: 
`supportCodegenFailureReason` reports
+   * "partitioning-aware", because a downstream `GroupPartitionsExec` consumes 
its key descriptor.
+   *
+   * Latched, because the answer moves under its consumers.
+   * `InMemoryTableScanExec.outputPartitioning` reports `UnknownPartitioning` 
while its inner
+   * `AdaptiveSparkPlanExec` has no final plan, so a union can look plain when
+   * `CollapseCodegenStages` gates on it and partitioning-aware by the time 
the stage runs. The
+   * shell that gate builds wraps a `withNewChildren` copy, and a copy that 
re-derives comes back
+   * with empty `metrics` while `doProduce` asks `metricTerm` for 
`numOutputRows`. A `TreeNodeTag`
+   * survives that rebuild where a field would not, since `withNewChildren` 
ends in `copyTagsFrom`.
+   *
+   * `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 = {
+    
decisionLock.synchronized(getTagValue(UnionExec.PLAIN_UNION_DECISION)).getOrElse
 {
+      val plain = !conf.getConf(SQLConf.UNION_OUTPUT_PARTITIONING) ||
+        rawPartitioning.isInstanceOf[UnknownPartitioning]
+      decisionLock.synchronized {
+        getTagValue(UnionExec.PLAIN_UNION_DECISION).getOrElse {
+          setTagValue(UnionExec.PLAIN_UNION_DECISION, plain)

Review 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?
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -1023,13 +1026,53 @@ case class UnionExec(children: Seq[SparkPlan]) extends 
SparkPlan with CodegenSup
     }
   }
 
-  // True when the codegen path applies: `outputPartitioning` is 
`UnknownPartitioning`,
-  // and `unionedInputRDD` matches the semantics of `sparkContext.union(...)` 
in `doExecute`.
-  // A `KeyedPartitioning` union also uses `sparkContext.union(...)` in 
`doExecute`, but
-  // codegen is disabled for it (`supportCodegenFailureReason` reports 
"partitioning-aware"):
-  // the per-partition key descriptor is consumed by a downstream 
`GroupPartitionsExec`, and
-  // keeping these unions out of whole-stage codegen matches the 
`HashPartitioning` union case.
-  private[sql] def isPlainUnion: Boolean = 
outputPartitioning.isInstanceOf[UnknownPartitioning]
+  // 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()

Review 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.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -1267,6 +1313,9 @@ case class UnionExec(children: Seq[SparkPlan]) extends 
SparkPlan with CodegenSup
 }
 
 object UnionExec {
+  /** The latched "is this a plain concatenation" decision. See 
`isPlainUnion`. */
+  private val PLAIN_UNION_DECISION = TreeNodeTag[Boolean]("plainUnionDecision")

Review Comment:
   Reasoning on record, and it is not a strong preference.
   
   The tag does one thing a field does not: it survives the `withNewChildren` 
copy that `insertInputAdapter` puts inside the codegen shell without each 
future rebuild path having to remember to forward it. A field forwarded in 
`withNewChildrenInternal` covers that path, and that override is the only copy 
between `CollapseCodegenStages` and execution today, so it would work. It moves 
the invariant from "tags are copied" to "this override forwards it", and 
`EnsureRequirements` already has two `g.copy(...)` sites that call 
`copyTagsFrom` by hand, which is the same kind of thing being forgotten.
   
   The conf as a planner-set constructor field I like independently of the 
rest, since it removes a live read at execution.
   
   The part I do not think works is deciding the partitioning half in 
`SparkStrategies`: the children there are `PlanLater` placeholders, so the 
answer comes out plain for everything, including unions that are co-partitioned 
without an exchange. If you want the decision at a defined point rather than on 
first read, the place that has real children and still runs before execution is 
a rule after `EnsureRequirements`, which is what I proposed on the `sparkPlan` 
thread.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to