sunchao commented on code in PR #57576:
URL: https://github.com/apache/spark/pull/57576#discussion_r3705824055


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -255,6 +255,10 @@ abstract class Optimizer(catalogManager: CatalogManager)
     Batch("Eliminate Sorts", Once,
       EliminateSorts,
       RemoveRedundantSorts),
+    // Run after operator optimization normally folds accuracy expressions and 
before
+    // RewriteDistinctAggregates so fused distinct percentiles are rewritten 
correctly.
+    Batch("Combine Approximate Percentiles", Once,

Review Comment:
   Fixed in `03ca69ee`. The `SparkOptimizer` batch now runs `MergeSubplans`, 
then `CombineApproximatePercentiles`, then `RewriteDistinctAggregates`. I added 
your two-scalar-subquery shape as an end-to-end regression; it checks the 
result and traverses the executed subqueries to verify that the merged 
aggregate has one percentile digest rather than two.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/CombineApproximatePercentiles.scala:
##########
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.optimizer
+
+import scala.collection.mutable
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference, 
Expression, ExprId, GetArrayItem, LeafExpression, Literal, NamedExpression}
+import 
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression, 
AggregateMode, ApproximatePercentile}
+import org.apache.spark.sql.catalyst.expressions.codegen.CodegenFallback
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, LogicalPlan}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreePattern.AGGREGATE
+import org.apache.spark.sql.catalyst.util.GenericArrayData
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{ArrayType, DoubleType}
+
+private[optimizer] case class PercentileFusionIdentity(
+    aggregateFunctions: Seq[Expression],
+    mode: AggregateMode,
+    isDistinct: Boolean,
+    filter: Option[Expression],
+    percentageBits: Seq[Long])
+
+/**
+ * Foldable percentage array that retains the original scalar aggregate 
structures in equality.
+ *
+ * Fusion removes those structures from the physical aggregate. Keeping them 
here prevents
+ * subquery or exchange reuse from equating plans that were distinct before 
fusion.
+ */
+private[optimizer] case class PercentileFusionArray(identity: 
PercentileFusionIdentity)
+    extends LeafExpression with CodegenFallback {
+  override def foldable: Boolean = true
+  override def nullable: Boolean = false
+  override def dataType: ArrayType = ArrayType(DoubleType, containsNull = 
false)
+
+  private lazy val value = new GenericArrayData(
+    identity.percentageBits.map(java.lang.Double.longBitsToDouble))
+  private lazy val literal = Literal(value, dataType)
+
+  override def eval(input: InternalRow): Any = value
+  override def toString: String = literal.toString
+  override def sql: String = literal.sql
+}
+
+/**
+ * Combines scalar approximate percentiles that can share the same percentile 
digest.
+ *
+ * An approximate percentile digest depends on its input, accuracy, filter, 
distinctness, and
+ * aggregate mode, but not on the percentile requested from the completed 
digest. Consequently,
+ * compatible scalar percentiles can be calculated by one array-valued 
aggregate and projected
+ * back to their original scalar outputs.
+ *
+ * Inputs and filters must retain their original expression structure so that 
floating-point
+ * evaluation and ANSI overflow behavior are preserved. Streaming aggregates 
are left unchanged
+ * to preserve the value schemas of existing checkpoints.
+ */
+object CombineApproximatePercentiles extends Rule[LogicalPlan] {
+
+  private case class CompatibilityKey(
+      child: Expression,
+      accuracy: Long,
+      mode: AggregateMode,
+      isDistinct: Boolean,
+      filter: Option[Expression])
+
+  private case class PhysicalCompatibilityKey(

Review Comment:
   Fixed in `03ca69ee`. `PhysicalCompatibilityKey` now includes the 
canonicalized percentage expression, so the guard matches the actual aggregate 
identity used by `PhysicalAggregation`. Catalyst and SQL regressions cover `(a 
+ b)` at p50/p90 and `(b + a)` at p25/p75, verify unchanged results, and assert 
that the four scalars become two independently fused digests. Existing 
overlapping-percentage and opposite-`DISTINCT` collision safeguards still pass.



##########
sql/core/src/test/scala/org/apache/spark/sql/ApproximatePercentileQuerySuite.scala:
##########
@@ -35,7 +42,403 @@ import org.apache.spark.tags.SlowSQLTest
 class ApproximatePercentileQuerySuite extends SharedSparkSession {
   import testImplicits._
 
+  override protected def sparkConf: SparkConf =
+    super.sparkConf.set(SQLConf.COMBINE_APPROXIMATE_PERCENTILES_ENABLED.key, 
"true")
+
   private val table = "percentile_approx"
+  private val constantFoldingRule =
+    "org.apache.spark.sql.catalyst.optimizer.ConstantFolding"
+
+  private def excludedRules: Seq[String] = {
+    spark.sessionState.conf.optimizerExcludedRules.toSeq
+      .flatMap(_.split(","))
+      .map(_.trim)
+      .filter(_.nonEmpty)
+  }
+
+  private def assertPercentileDigestCount(query: DataFrame, expected: Int): 
Unit = {
+    val counts = query.queryExecution.sparkPlan.collect {
+      case aggregate: ObjectHashAggregateExec =>
+        aggregate.aggregateExpressions.count(
+          _.aggregateFunction.isInstanceOf[ApproximatePercentile])
+    }
+    assert(counts.nonEmpty)
+    assert(counts.forall(_ == expected), counts)
+  }
+
+  private def checkMatchesUnfusedBaseline(sql: String, expectedDigests: Int): 
Unit = {
+    val withoutConstantFolding = (excludedRules :+ 
constantFoldingRule).distinct
+    val baseline = withSQLConf(
+      SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> 
withoutConstantFolding.mkString(","),
+      SQLConf.COMBINE_APPROXIMATE_PERCENTILES_ENABLED.key -> "false") {
+      spark.sql(sql).collect().toSeq
+    }
+
+    withSQLConf(
+      SQLConf.OPTIMIZER_EXCLUDED_RULES.key -> 
withoutConstantFolding.mkString(",")) {
+      val query = spark.sql(sql)
+      checkAnswer(query, baseline)
+      assertPercentileDigestCount(query, expectedDigests)
+    }
+  }
+
+  test("approximate percentile fusion can be disabled") {
+    withSQLConf(SQLConf.COMBINE_APPROXIMATE_PERCENTILES_ENABLED.key -> 
"false") {
+      val query = spark.sql(
+        "SELECT percentile_approx(id, 0.5D), percentile_approx(id, 0.9D) FROM 
range(10)")
+      checkAnswer(query, Row(4L, 8L))
+      assertPercentileDigestCount(query, 2)
+    }
+  }
+
+  test("compatible scalar percentiles share one physical percentile digest") {
+    withTempView(table) {
+      (1 to 1000).toDF("col").createOrReplaceTempView(table)
+      val query = spark.sql(
+        s"""SELECT
+           |  approx_percentile(col, 0.5, 10000),
+           |  approx_percentile(col, 0.9, 10000),
+           |  approx_percentile(col, 0.95, 10000)
+           |FROM $table
+           |""".stripMargin)
+
+      checkAnswer(query, Row(500, 900, 950))
+      assertPercentileDigestCount(query, 1)
+      val optimizedPercentiles = 
query.queryExecution.optimizedPlan.expressions.flatMap {
+        _.collect { case percentile: ApproximatePercentile => percentile }
+      }
+      assert(optimizedPercentiles.nonEmpty)
+      assert(optimizedPercentiles.forall(_.prettyName == "approx_percentile"))
+      val modes = query.queryExecution.sparkPlan.collect {
+        case aggregate: ObjectHashAggregateExec =>
+          aggregate.aggregateExpressions.collect {
+            case expression @ AggregateExpression(_: ApproximatePercentile, _, 
_, _, _) =>
+              expression.mode
+          }
+      }.flatten.toSet
+      assert(modes == Set(Partial, Final))
+    }
+  }
+
+  test("do not fuse duplicate percentages already shared by physical 
planning") {
+    withTempView(table) {
+      (1 to 1000).toDF("col").createOrReplaceTempView(table)
+      val query = spark.sql(
+        s"""SELECT
+           |  percentile_approx(col, 0.5D),
+           |  percentile_approx(col, 0.25D + 0.25D)
+           |FROM $table
+           |""".stripMargin)
+
+      checkAnswer(query, Row(500, 500))
+      val percentiles = query.queryExecution.sparkPlan.collect {
+        case aggregate: ObjectHashAggregateExec =>
+          aggregate.aggregateExpressions.collect {
+            case AggregateExpression(
+                percentile: ApproximatePercentile, _, _, _, _) => percentile
+          }
+      }.flatten
+      assert(percentiles.nonEmpty)
+      assert(percentiles.forall(_.percentageExpression.dataType == DoubleType))
+    }
+  }
+
+  test("preserve structural input and filter evaluation") {
+    checkAnswer(
+      spark.sql(
+        """SELECT
+          |  percentile_approx((a + b) + c, 0.5D),
+          |  percentile_approx(a + (b + c), 0.9D)
+          |FROM VALUES (
+          |  CAST(10000000000000000 AS DOUBLE),
+          |  CAST(-10000000000000000 AS DOUBLE),
+          |  CAST(1 AS DOUBLE)
+          |) AS t(a, b, c)
+          |""".stripMargin),
+      Row(1.0d, 0.0d))
+
+    checkAnswer(
+      spark.sql(
+        """SELECT
+          |  percentile_approx(v, 0.5D)
+          |    FILTER (WHERE (a + b) + c = 1D),
+          |  percentile_approx(v, 0.9D)
+          |    FILTER (WHERE a + (b + c) = 1D)
+          |FROM VALUES (
+          |  7,
+          |  CAST(10000000000000000 AS DOUBLE),
+          |  CAST(-10000000000000000 AS DOUBLE),
+          |  CAST(1 AS DOUBLE)
+          |) AS t(v, a, b, c)
+          |""".stripMargin),
+      Row(7, null))
+
+    withSQLConf(SQLConf.ANSI_ENABLED.key -> "true") {
+      val exception = intercept[SparkArithmeticException] {
+        spark.sql(
+          """SELECT
+            |  percentile_approx(a + (b + c), 0.5D),
+            |  percentile_approx((a + b) + c, 0.9D)
+            |FROM VALUES (
+            |  CAST(2147483647 AS INT),
+            |  CAST(1 AS INT),
+            |  CAST(-1 AS INT)
+            |) AS t(a, b, c)
+            |""".stripMargin).collect()
+      }
+      assert(exception.getCondition == "ARITHMETIC_OVERFLOW")
+    }
+  }
+
+  test("do not fuse canonical input or filter collisions") {
+    checkAnswer(
+      spark.sql(
+        """SELECT
+          |  percentile_approx(a + (b + c), 0.5D),
+          |  percentile_approx((a + b) + c, 0.5D),
+          |  percentile_approx((a + b) + c, 0.9D),
+          |  percentile_approx(a + (b + c), 0.9D)
+          |FROM VALUES (
+          |  CAST(10000000000000000 AS DOUBLE),
+          |  CAST(-10000000000000000 AS DOUBLE),
+          |  CAST(1 AS DOUBLE)
+          |) AS t(a, b, c)
+          |""".stripMargin),
+      Row(0.0d, 0.0d, 1.0d, 1.0d))
+
+    val crossDistinctCollision =
+      """SELECT
+        |  percentile_approx(DISTINCT a + (b + c), 0.5D),
+        |  percentile_approx(DISTINCT a + (b + c), 0.9D),
+        |  percentile_approx((a + b) + c, 0.5D),
+        |  percentile_approx((a + b) + c, 0.9D)
+        |FROM VALUES (
+        |  CAST(10000000000000000 AS DOUBLE),
+        |  CAST(-10000000000000000 AS DOUBLE),
+        |  CAST(1 AS DOUBLE)
+        |) AS t(a, b, c)
+        |""".stripMargin
+    val unfusedBaseline = withSQLConf(
+      SQLConf.COMBINE_APPROXIMATE_PERCENTILES_ENABLED.key -> "false") {
+      spark.sql(crossDistinctCollision).collect().toSeq
+    }
+    checkAnswer(spark.sql(crossDistinctCollision), unfusedBaseline)
+
+    checkAnswer(

Review Comment:
   Fixed in `03ca69ee`. Both result-only regressions now also assert the 
physical digest count: the filtered native-array case expects two digests, and 
the native-array/DISTINCT-removal case expects three. That makes the tests 
sensitive to the intended aggregate layout even when nulls or a single input 
row make the result values identical. I also changed the canonical-input 
collision case to compare against a dynamically collected fusion-disabled 
baseline rather than hard-coding Spark's existing incorrect result.



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