cloud-fan commented on code in PR #58419:
URL: https://github.com/apache/spark/pull/58419#discussion_r4023166749


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala:
##########
@@ -134,7 +134,15 @@ case class AdaptiveSparkPlanExec(
     Seq(
       CoalesceBucketsInJoin,
       RemoveRedundantProjects,
+      // Must run before `ensureRequirements`, which asks a `UnionExec` what 
it reports: it
+      // records the conf that answer depends on, so the following 
`StampUnionDecisions` freezes the
+      // decision under the same value the exchanges were planned against.
+      SnapshotUnionOutputPartitioningConf,

Review Comment:
   **Non-blocking (P2):** The deterministic snapshot-ordering regression 
currently drives only `QueryExecution.preparations`. AQE maintains this 
separate private sequence, while the new extension tests exercise the later 
post-preparation and post-stage barriers with a stable initial setting. 
Removing this snapshot, or moving it after AQE's `EnsureRequirements`, 
therefore leaves those tests green and can reopen the race where requirements 
elides an exchange under one value but the union later stamps a different 
layout. Could we add an AQE-enabled discriminator that fails specifically when 
this initial snapshot is absent or late, and cannot be satisfied by the later 
stamping barriers?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -2938,7 +2938,9 @@ object SQLConf {
       .doc("When both this conf and `spark.sql.codegen.wholeStage` are true, " 
+
         "UnionExec participates in whole-stage codegen on its " +
         "non-partitioning-aware path: the parent and all children fuse into " +
-        "a single WholeStageCodegenExec stage.")
+        "a single WholeStageCodegenExec stage. The value is read when a 
UnionExec's " +
+        "decision is fixed during physical preparation, so a change does not 
reach a " +

Review Comment:
   **Non-blocking (P2):** This preparation-scoped lifecycle makes the 
cached-child parity case in `UnionCodegenSuite` vacuous for its disabled half: 
that test first collects one DataFrame with union codegen enabled, then changes 
this setting and collects the same DataFrame again. The first collect has 
already memoized an executed plan with the enabled decision stamped, so the 
second collect never prepares or executes the disabled UnionExec fallback. 
Please construct and physically prepare a fresh cached-child union under each 
flag value, with a plan-path assertion that proves the two preparations 
observed different decisions before comparing their fixed results.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/UnionCodegenSuite.scala:
##########
@@ -628,6 +659,290 @@ class UnionCodegenSuite extends SharedSparkSession {
     }
   }
 
+  test("SPARK-59122: a fused union keeps numOutputRows and reports 
UnknownPartitioning") {
+    // The children's partitioning is not stable while the plan is being 
prepared:
+    // `InMemoryTableScanExec.cachedPlan` unwraps the inner 
`AdaptiveSparkPlanExec` only once
+    // `isFinalPlan` is true, and reports `UnknownPartitioning(0)` until then, 
so the union looks
+    // plain and is fused. The projection is what makes that reachable: 
`supportsColumnar` is
+    // `children.forall`, so one row-based `ProjectExec` over the columnar 
scan is enough to make
+    // it false, and without one `supportCodegenFailureReason` reports 
`columnar` and nothing
+    // fuses. `SELECT *` or a plain alias collapses the projection away and 
does not reproduce
+    // this. Once the cache stages finalise, both children report the same 
concrete layout, and
+    // re-deriving the decision at that point left `metrics` empty while 
`doProduce` asked
+    // `metricTerm` for `numOutputRows`.
+    //
+    // Both halves of the decision are asserted here. Registering the metric 
unconditionally would
+    // fix the crash and leave the other half broken: a fused union 
concatenates its children's
+    // partitions, so claiming their partitioning would let a parent satisfy a 
clustered
+    // distribution from an RDD that does not have it.
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+        SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+      withTempView("v") {
+        cacheAggregateView("v")
+        val df = spark.sql("SELECT k, abs(s) AS s FROM v UNION ALL SELECT k, s 
FROM v")
+        // Execute this DataFrame rather than a count over it: the plan being 
inspected has to be
+        // the one that ran, and an AQE plan that never ran has no final plan 
to inspect.
+        assert(df.collect().length == 20)
+        val fused = fusedUnions(df)
+        assert(fused.nonEmpty,
+          "this shape must actually fuse, or the test is not exercising the 
defect")
+        fused.foreach { u =>
+          // Part of the premise, not the whole of it: the children expose a 
concrete layout by now,
+          // so this node is not reporting `UnknownPartitioning` merely for 
want of anything to
+          // derive from. `rawPartitioning` also falls back when the 
children's remapped
+          // partitionings do not compare equal, and that cannot be asserted 
here: each side carries
+          // its own exprIds, and they line up only after the private 
`prepareOutputPartitioning`.
+          val childPartitionings = u.children.map(_.outputPartitioning)
+          
assert(childPartitionings.forall(_.isInstanceOf[HashPartitioningLike]),
+            s"premise: got $childPartitionings")
+          assert(childPartitionings.map(_.numPartitions).distinct.size == 1,
+            s"premise: got $childPartitionings")
+          assert(u.metrics.contains("numOutputRows"),
+            "a fused union must register the metric its generated code 
increments")
+          assert(u.outputPartitioning.isInstanceOf[UnknownPartitioning],
+            s"a fused union must not claim a concrete partitioning, got 
${u.outputPartitioning}")
+        }
+      }
+    }
+  }
+
+  test("SPARK-59122: a partitioning-aware union keeps its layout when the conf 
changes between " +
+    "planning and execution") {
+    // `spark.sql.unionOutputPartitioning` is read where the plain-union 
decision is stamped, not on
+    // every `outputPartitioning` call, so a plan executes by the partitioning 
it was planned
+    // against. Reading it per call let the parent aggregate lose its exchange 
at planning and get a
+    // plain concatenation at execution, reporting each group twice. The 
`checkAnswer` below stays
+    // outside the block that planned the DataFrame on purpose: the plan is 
forced inside that
+    // block and `executedPlan` is memoized, so the two phases see different 
confs. Asserting
+    // inside it, or dropping the second `withSQLConf`, makes the test pass 
without testing this.
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val left = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS k")
+      val right = spark.range(20, 40, 1, 2).selectExpr("id % 5 AS k")
+
+      val planned = withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> 
"true") {
+        val df = left.repartition(4, col("k"))
+          .union(right.repartition(4, col("k"))).groupBy("k").count()
+        val plan = df.queryExecution.executedPlan
+        val unions = plan.collect { case u: UnionExec => u }
+        assert(unions.size == 1)
+        // Asserted through the exchanges rather than through `isPlainUnion`, 
so that the check
+        // does not depend on how the decision is stored: only the two 
repartitions may shuffle, so
+        // the aggregate's exchange was elided, which it could only be if the 
union reported a
+        // concrete partitioning.
+        val shuffles = plan.collect { case s: ShuffleExchangeExec => s }
+        assert(shuffles.size == 2)
+        assert(shuffles.forall(_.shuffleOrigin == REPARTITION_BY_NUM),
+          s"expected only the two repartitions, got 
${shuffles.map(_.shuffleOrigin)}")
+        df
+      }
+
+      withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
+        // Each side contributes four ids per `k`, so the answer is fixed. 
Comparing against the
+        // same query run with the conf off would also pass if both paths 
regressed to ten rows.
+        checkAnswer(planned, (0L until 5L).map(k => Row(k, 8L)))
+      }
+    }
+  }
+
+  test("SPARK-59122: a fused union keeps numOutputRows when the codegen conf 
changes between " +
+    "planning and execution") {
+    // `supportCodegenFailureReason` used to read 
`WHOLESTAGE_UNION_CODEGEN_ENABLED` live, and the
+    // copy that `insertInputAdapter` puts inside the codegen shell evaluated 
it for the first time
+    // at execution. Planned with the conf on the union is fused, so the 
generated code increments
+    // `numOutputRows`; if the copy re-derives the reason with the conf off, 
`metrics` comes back
+    // empty and `doProduce` throws `key not found: numOutputRows`.
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val planned = withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key 
-> "true") {
+        // Each child is an exchange, which is not `CodegenSupport`, so 
`insertInputAdapter` wraps
+        // it and the union is rebuilt through `withNewChildren`, the copy 
this test needs. Children
+        // that do support codegen can still produce one, since 
`insertInputAdapter` recurses into
+        // their descendants; exchanges just make it certain.
+        val df = rangeDF(100).repartition(2).union(rangeDF(100).repartition(2))
+        // `fusedUnions` requires the union to be the stage root; 
`unionInsideWSCG` would also
+        // match a union that an `InputAdapter` left inside the stage unfused, 
which is exactly
+        // the degradation this guard has to catch.
+        assert(fusedUnions(df).size == 1, "this shape must fuse, or the test 
exercises nothing")
+        df
+      }
+      withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
+        assert(planned.collect().length == 200)
+        // The row count alone does not discriminate, since the shell was 
installed at planning and
+        // keeps emitting; registering `numOutputRows` unconditionally and 
reading the conf per call
+        // passes it. This assertion is what fails there, because nothing 
forces the copy's reason
+        // before it. It has to sit after the flip, as it does here: taken 
while the conf was still
+        // on, it would warm a memoizing implementation with the answer this 
test needs it not to
+        // have.
+        val copy = fusedUnions(planned)
+        assert(copy.size == 1)
+        assert(copy.head.supportCodegen,
+          "the copy in the shell must keep the decision it was planned with")
+      }
+    }
+  }
+
+  test("SPARK-59122: a fused union keeps numOutputRows when the child cap 
drops between " +
+    "planning and execution") {
+    // `WHOLESTAGE_UNION_MAX_CHILDREN` is on the same snapshot as the enable 
flag, so the same shape
+    // has to hold for it: prepared under a cap this union meets, it stays 
fused even if the cap is
+    // lowered under it. Reading the cap live would give the shell's copy 
`max-children-exceeded`,
+    // empty `metrics`, and `doProduce` failing at `metricTerm`. Three 
children against a cap of
+    // two, since the conf refuses anything below two.
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val planned = withSQLConf(
+          SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
+          SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "3") {
+        // Exchange children again, so the shell really holds a 
`withNewChildren` copy.
+        val df = rangeDF(100).repartition(2)
+          .union(rangeDF(100).repartition(2))
+          .union(rangeDF(100).repartition(2))
+        val fused = fusedUnions(df)
+        assert(fused.size == 1 && fused.head.children.size == 3,
+          s"this shape must fuse as one three-child union, got 
${fused.map(_.children.size)}")
+        df
+      }
+      withSQLConf(SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "2") {
+        assert(planned.collect().length == 300)
+        val copy = fusedUnions(planned)
+        assert(copy.size == 1)
+        assert(copy.head.supportCodegen,
+          "the copy in the shell must keep the cap it was planned with")
+        // Not `metrics.contains`, which `collect()` above already proves: an 
empty `metrics` would
+        // have thrown at `metricTerm`. The count is what says the fused code 
ran and counted.
+        assert(copy.head.metrics("numOutputRows").value == 300)
+      }
+    }
+  }
+
+  test("SPARK-59122: the codegen gate re-derives when a rule replaces the 
children") {
+    // The gate's children-dependent terms must not outlive the children they 
were taken from.
+    // `SQLExecution` builds a `SparkPlanInfo` before execution, which reads 
`metrics` on every
+    // node; a decision carried from there onto a node whose children a rule 
then replaced would
+    // fuse a topology that the gate rejects.
+    withSQLConf(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true") {
+      val df = rangeDF(100).union(rangeDF(100))
+      val unions = fusedUnions(df)
+      assert(unions.size == 1, "this shape must fuse, or the test exercises 
nothing")
+      val union = unions.head
+      // What the plan update does, and what decides the gate for this 
instance.
+      assert(union.metrics.contains("numOutputRows"))
+      assert(union.supportCodegen)
+
+      // A nested union is one of the topologies the gate rejects, and 
`withNewChildren` is the path
+      // a rule takes when it rewrites children in place. A rule returning an 
arbitrary replacement
+      // node is a different path, and one `copyTagsFrom` need not carry the 
tags along.
+      val nested = UnionExec(Seq(union.children.head, union.children.head))
+      val rebuilt = union.withNewChildren(Seq(nested, 
union.children.last)).asInstanceOf[UnionExec]
+      assert(!rebuilt.supportCodegen, "the rebuilt union must answer against 
its own children")
+      assert(rebuilt.metrics.isEmpty)
+    }
+  }
+
+  test("SPARK-59122: reading the unprepared plan does not decide the prepared 
one") {
+    // `QueryExecution.executedPlan` is 
`prepareForExecution(sparkPlan.clone())`, and `clone` ends
+    // in `makeCopy`, which calls `copyTagsFrom`. A decision written while 
answering a read on
+    // `sparkPlan` would therefore ride into the prepared plan. Here the two 
answers differ: each
+    // child is an aggregate whose exchange `EnsureRequirements` has yet to 
insert, so the union
+    // passes nothing through before preparation and both children's 
`HashPartitioning` after it.
+    // Reads before `StampUnionDecisions` answer without writing, so only 
preparation decides.
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+        SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+      val left = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS 
k").groupBy("k").count()
+      val right = spark.range(20, 40, 1, 2).selectExpr("id % 5 AS 
k").groupBy("k").count()
+      val df = left.union(right)
+
+      val unprepared = df.queryExecution.sparkPlan.collect { case u: UnionExec 
=> u }
+      assert(unprepared.size == 1)
+      
assert(unprepared.head.outputPartitioning.isInstanceOf[UnknownPartitioning],
+        "the aggregates have no exchange under them yet, so there is nothing 
to pass through")
+
+      val prepared = df.queryExecution.executedPlan.collect { case u: 
UnionExec => u }
+      assert(prepared.size == 1)
+      
assert(!prepared.head.outputPartitioning.isInstanceOf[UnknownPartitioning],
+        "the read above must not have decided for the prepared plan, got " +
+          s"${prepared.head.outputPartitioning}")
+      checkAnswer(df, (0L until 5L).flatMap(k => Seq(Row(k, 4L), Row(k, 4L))))
+    }
+  }
+
+  test("SPARK-59122: a prepared union keeps its layout when nothing read it 
during preparation") {
+    // With whole-stage codegen off, no gate consults the union while the plan 
is prepared, and a
+    // root union has no parent to ask for its partitioning either. First-read 
initialization would
+    // then decide at execution, under whatever the conf says by then; 
`StampUnionDecisions` decides
+    // during preparation instead.
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+        SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "false") {
+      val plan = withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+        spark.range(0, 20, 1, 2).selectExpr("id % 5 AS k").repartition(4, 
col("k"))
+          .union(spark.range(20, 40, 1, 2).selectExpr("id % 5 AS 
k").repartition(4, col("k")))
+          .queryExecution.executedPlan
+      }
+      withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
+        // Co-partitioned children pass their four partitions through; a plain 
concatenation would
+        // report eight.
+        assert(plan.execute().getNumPartitions == 4,
+          "a prepared union must execute by the layout it was prepared with")
+      }
+    }
+  }
+
+  test("SPARK-59122: a later stamping pass fills in a fresh union and keeps 
stamped ones") {

Review Comment:
   Confirmed: the query-stage-preparation case now injects 
`ObserveUnionPartitioning` as a query-stage optimizer. It reads the added union 
before `postStageCreationRules`, so removing the post-prep stamp makes the test 
observe `UnknownPartitioning`. Resolved.
   
   <!-- SPARK_DEV_REVIEW_REPLY 
{"feedback_id":"inline:3999365350","thread_id":"inline:3999365350","verdict_sha256":"7514e8bcaed415fcda0a875cc6616cec7552e86140802ad2771cc5c0b5b1392a"}
 -->



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