cloud-fan commented on code in PR #59402:
URL: https://github.com/apache/spark/pull/59402#discussion_r4256199476
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteDistinctAggregates.scala:
##########
@@ -223,8 +227,9 @@ object RewriteDistinctAggregates extends Rule[LogicalPlan] {
case a: Aggregate if mayNeedtoRewrite(a) => rewrite(a)
}
- def rewrite(a: Aggregate): Aggregate = {
+ def rewrite(origAgg: Aggregate): Aggregate = {
+ val a = normalizeCountDistinctConditional(origAgg)
Review Comment:
**Non-blocking (P2):** `normalizeCountDistinctConditional` runs on every
aggregate that reaches `rewrite()` and rewrites each matching COUNT on its own.
Nothing checks that the rewrite reduces the number of distinct groups, and two
shapes get worse than before:
- `COUNT(DISTINCT IF(c, x, NULL))` next to `SUM(DISTINCT IF(c, x, NULL))`.
Before, both shared one unfiltered group, and `rewrite()` returned the
aggregate unchanged (single-distinct planning, no Expand). Now only the COUNT
is normalized, so the groups become `{x}` and `{IF(c, x, NULL)}` and the plan
gets a 2-projection Expand.
- A repeated `COUNT(DISTINCT IF(c, x, NULL))`, e.g. once as a value and once
in a ratio over `COUNT(*)`. Before, this was one unfiltered group with no
Expand. Now the FILTER makes `mustRewrite` true, so the plan gets an Expand
with a regular branch and a distinct branch.
Results are the same, but every input row is now duplicated where it wasn't
before. Counts over different bases also pick up flag columns and `max` buffers
with no fan-out reduction. Could we keep the normalized aggregate only when it
has strictly fewer distinct groups than `origAgg` (using the same
`ExpressionSet` keying), and otherwise continue with `origAgg`?
This extends an [existing
discussion](https://github.com/apache/spark/pull/59402#issuecomment-6124805791).
**Verification:**
- **Regression:** COUNT(DISTINCT IF(c, x, NULL)) together with SUM(DISTINCT
IF(c, x, NULL)), and a repeated COUNT(DISTINCT IF(c, x, NULL)) next to
COUNT(*), produce no Expand, matching the merge-base plan shape.
- **Behavior:** Two or more conditional distinct counts over one base still
produce a single distinct Expand projection with the guarded key, and the
results match the config-off run.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteDistinctAggregates.scala:
##########
@@ -419,6 +446,63 @@ object RewriteDistinctAggregates extends Rule[LogicalPlan]
{
}
}
+ /**
+ * Canonicalizes COUNT(DISTINCT IF(cond, base, NULL)) and
+ * COUNT(DISTINCT CASE WHEN cond THEN base END) to COUNT(DISTINCT base)
FILTER (WHERE cond).
+ * This reduces the number of distinct groups: multiple conditional counts
on the same base
+ * column collapse into one group, shrinking the Expand fan-out from Nx to
1x.
+ *
+ * Note that the rewrite moves `base` out of the protective conditional
branch: after the
+ * rewrite the Expand operator evaluates the distinct child for every input
row, while
Review Comment:
**Nit (P3):** This describes the pre-guard plan. With the union-of-filters
guard, an all-filtered group projects `IF(Or(conds), base, NULL)`. The base is
then evaluated only on rows matching some member's condition, which are the
same rows the per-condition `IF`s evaluated it on before. Only a group that
also has an unfiltered aggregate on the same base projects the raw base for
every row, and the uncollapsed plan already did that too. Could you reword this
to describe the evaluation domain the guard actually produces, and say why
`canEvaluateUnconditionally` is still required under it? As written, it gives
the wrong reason for the restriction.
**Verification:**
- **Inspection:** The scaladoc's account of base evaluation matches the
guard's applicability conditions and the normalization's admission rules.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteDistinctAggregatesSuite.scala:
##########
@@ -125,4 +126,418 @@ class RewriteDistinctAggregatesSuite extends PlanTest {
fail(s"Plan is not as expected:\n$rewrite")
}
}
+
+ //
---------------------------------------------------------------------------
+ // COUNT(DISTINCT IF/CASE) canonicalization (SPARK-56898)
+ //
---------------------------------------------------------------------------
+
+ val conditionalTestRelation = LocalRelation(
+ Symbol("a").int, Symbol("b").int, Symbol("c").int, Symbol("d").string)
+
+ private def countDistinctIf(cond: Expression, base: Expression): Expression
= {
+ Count(If(cond, base, Literal(null))).toAggregateExpression(isDistinct =
true)
+ }
+
+ private def countDistinctCaseWhen(cond: Expression, base: Expression):
Expression = {
+ val caseWhen = CaseWhen(
+ Seq((cond, base)),
+ None)
+ Count(caseWhen).toAggregateExpression(isDistinct = true)
+ }
+
+ private def countDistinctCaseWhenElseNull(cond: Expression, base:
Expression): Expression = {
+ val caseWhen = CaseWhen(
+ Seq((cond, base)),
+ Some(Literal(null)))
+ Count(caseWhen).toAggregateExpression(isDistinct = true)
+ }
+
+ private def collectAggregateExpressions(plan: LogicalPlan):
Seq[AggregateExpression] = {
+ plan.collect { case a: Aggregate => a.aggregateExpressions }
+ .flatten
+ .flatMap(_.collect { case ae: AggregateExpression => ae })
+ }
+
+ /**
+ * Asserts that the optimized plan has exactly one Expand node with one
projection,
+ * that the shared distinct key is guarded by the union of the member
filters,
+ * and that the outer aggregate has moved the conditions into FILTER clauses.
+ */
+ private def assertSingleDistinctGroupExpand(
+ optimized: LogicalPlan,
+ baseColName: String,
+ conds: Seq[Expression]): Unit = {
+ assert(collectAggregateExpressions(optimized).exists(_.filter.isDefined),
Review Comment:
**Nit (P3):** `rewrite()` gives every distinct aggregate an `EqualTo(gid,
id)` filter, so `exists(_.filter.isDefined)` is true for any rewritten plan.
The doc says this checks that the conditions were moved into FILTER clauses.
But if each aggregate's own `max_cond` were dropped from its final FILTER, all
four tests that use this helper would still pass. `assertCanonicalized` below
already excludes gid-only filters (`_.references.exists(_.name != "gid")`).
Doing the same here, or matching each condition's flag attribute, would let
this check actually fail.
**Verification:**
- **Regression:** Collapsed conditional counts each keep a final FILTER that
combines gid routing with their own condition's max flag, and a plan with only
gid routing filters fails the helper.
##########
sql/core/src/test/scala/org/apache/spark/sql/RewriteDistinctAggregatesConditionalQuerySuite.scala:
##########
@@ -0,0 +1,412 @@
+/*
+ * 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
+
+import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, LogicalPlan}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+class RewriteDistinctAggregatesConditionalQuerySuite extends QueryTest with
SharedSparkSession {
+
+ private def checkRewriteAndResult(
+ conditionalSql: String,
+ filterSql: String): Unit = {
+ val expectedRows = spark.sql(filterSql).collect()
+
+ // Verify the rewrite produces the same result as the explicit FILTER form.
+ withSQLConf(
+ SQLConf.REWRITE_COUNT_DISTINCT_CONDITIONAL_ENABLED.key -> "true") {
+ checkAnswer(spark.sql(conditionalSql), expectedRows)
+ }
+
+ // Verify the non-rewritten form also matches the explicit FILTER form.
+ withSQLConf(
+ SQLConf.REWRITE_COUNT_DISTINCT_CONDITIONAL_ENABLED.key -> "false") {
+ checkAnswer(spark.sql(conditionalSql), expectedRows)
+ }
+ }
+
+ private def hasUserConditionFilter(plan: LogicalPlan): Boolean = {
+ val aggregateExpressions = plan.collect {
+ case a: Aggregate => a.aggregateExpressions
+ }.flatten
+ val aggregateExprs = aggregateExpressions.flatMap {
+ _.collect { case ae: AggregateExpression => ae }
+ }
+ // The Expand rewrite adds gid-routing filters to every distinct
aggregate; we only
+ // care about user-condition filters introduced by the IF/CASE
canonicalization.
+ aggregateExprs.flatMap(_.filter).exists(_.references.exists(_.name !=
"gid"))
+ }
+
+ test("rewrite COUNT(DISTINCT IF(cond, col, NULL)) correctness") {
+ withTempView("t") {
+ spark.range(7)
+ .selectExpr(
+ "cast(id % 3 + 1 as int) as key",
+ "cast(id * 10 as int) as col1",
+ "case when id % 4 = 0 then null else cast(id * 100 as int) end as
col2")
+ .createOrReplaceTempView("t")
+
+ // Two conditional distinct counts on the same base so the rewrite fires
(size > 1).
+ checkRewriteAndResult(
+ """SELECT key,
+ | COUNT(DISTINCT IF(col1 > 10, col2, NULL)),
+ | COUNT(DISTINCT IF(col1 > 20, col2, NULL))
+ |FROM t GROUP BY key""".stripMargin,
+ """SELECT key,
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 10),
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 20)
+ |FROM t GROUP BY key""".stripMargin)
+ }
+ }
+
+ test("rewrite COUNT(DISTINCT CASE WHEN cond THEN col END) correctness") {
+ withTempView("t") {
+ spark.range(7)
+ .selectExpr(
+ "cast(id % 3 + 1 as int) as key",
+ "cast(id * 10 as int) as col1",
+ "case when id % 4 = 0 then null else cast(id * 100 as string) end as
col2")
+ .createOrReplaceTempView("t")
+
+ // Two CASE WHEN conditional distinct counts on the same base so the
rewrite fires.
+ checkRewriteAndResult(
+ """SELECT key,
+ | COUNT(DISTINCT CASE WHEN col1 > 10 THEN col2 END),
+ | COUNT(DISTINCT CASE WHEN col1 > 20 THEN col2 END)
+ |FROM t GROUP BY key""".stripMargin,
+ """SELECT key,
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 10),
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 20)
+ |FROM t GROUP BY key""".stripMargin)
+ }
+ }
+
+ test("rewrite COUNT(DISTINCT CASE WHEN cond THEN col ELSE NULL END)
correctness") {
+ withTempView("t") {
+ spark.range(6)
+ .selectExpr(
+ "cast(id % 2 + 1 as int) as key",
+ "cast(id * 10 as int) as col1",
+ "case when id % 4 = 0 then null else cast(id * 1.0 as double) end as
col2")
+ .createOrReplaceTempView("t")
+
+ // Two CASE WHEN ... ELSE NULL counts on the same base so the rewrite
fires.
+ checkRewriteAndResult(
+ """SELECT key,
+ | COUNT(DISTINCT CASE WHEN col1 > 10 THEN col2 ELSE NULL END),
+ | COUNT(DISTINCT CASE WHEN col1 > 20 THEN col2 ELSE NULL END)
+ |FROM t GROUP BY key""".stripMargin,
+ """SELECT key,
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 10),
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 20)
+ |FROM t GROUP BY key""".stripMargin)
+ }
+ }
+
+ test("rewrite with no GROUP BY") {
+ withTempView("t") {
+ spark.range(5)
+ .selectExpr(
+ "cast(id * 10 as int) as col1",
+ "case when id % 3 = 0 then null else cast(id * 100 as int) end as
col2")
+ .createOrReplaceTempView("t")
+
+ // Two counts so the rewrite fires without GROUP BY.
+ checkRewriteAndResult(
+ """SELECT
+ | COUNT(DISTINCT IF(col1 > 10, col2, NULL)),
+ | COUNT(DISTINCT IF(col1 > 20, col2, NULL))
+ |FROM t""".stripMargin,
+ """SELECT
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 10),
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 20)
+ |FROM t""".stripMargin)
+ }
+ }
+
+ test("rewrite with all NULLs in conditional branch") {
+ withTempView("t") {
+ spark.range(3)
+ .selectExpr(
+ "cast(id % 2 + 1 as int) as key",
+ "cast(id * 5 as int) as col1",
+ "cast(id * 100 as int) as col2")
+ .createOrReplaceTempView("t")
+
+ // col1 values are 0, 5, 10 - both thresholds (> 10 and > 20) yield zero
matches,
+ // so both counts return 0. Two counts so the rewrite fires.
+ checkRewriteAndResult(
+ """SELECT key,
+ | COUNT(DISTINCT IF(col1 > 10, col2, NULL)),
+ | COUNT(DISTINCT IF(col1 > 20, col2, NULL))
+ |FROM t GROUP BY key""".stripMargin,
+ """SELECT key,
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 10),
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 20)
+ |FROM t GROUP BY key""".stripMargin)
+ }
+ }
+
+ test("rewrite with duplicates in base column") {
+ withTempView("t") {
+ spark.range(6)
+ .selectExpr(
+ "cast(id % 2 + 1 as int) as key",
+ "cast(id * 10 as int) as col1",
+ "case when id % 3 = 0 then 100 when id % 3 = 1 then 100 else 200 end
as col2")
+ .createOrReplaceTempView("t")
+
+ // Two counts on the same base (with duplicates) so the rewrite fires.
+ checkRewriteAndResult(
+ """SELECT key,
+ | COUNT(DISTINCT IF(col1 > 10, col2, NULL)),
+ | COUNT(DISTINCT IF(col1 > 20, col2, NULL))
+ |FROM t GROUP BY key""".stripMargin,
+ """SELECT key,
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 10),
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 20)
+ |FROM t GROUP BY key""".stripMargin)
+ }
+ }
+
+ test("multiple conditional distinct counts collapse and produce correct
results") {
+ withTempView("t") {
+ spark.range(5)
+ .selectExpr(
+ "cast(id % 2 + 1 as int) as key",
+ "cast(id * 10 as int) as col1",
+ "case when id % 3 = 0 then null else cast(id * 100 as int) end as
col2",
+ "case when id % 4 = 0 then null else cast(id * 10 as string) end as
col3")
+ .createOrReplaceTempView("t")
+
+ val conditionalSql =
+ """SELECT key,
+ | COUNT(DISTINCT IF(col1 > 10, col2, NULL)) as cnt1,
+ | COUNT(DISTINCT IF(col1 > 5, col3, NULL)) as cnt2
+ |FROM t GROUP BY key""".stripMargin
+
+ val filterSql =
+ """SELECT key,
+ | COUNT(DISTINCT col2) FILTER (WHERE col1 > 10) as cnt1,
+ | COUNT(DISTINCT col3) FILTER (WHERE col1 > 5) as cnt2
+ |FROM t GROUP BY key""".stripMargin
+
+ checkRewriteAndResult(conditionalSql, filterSql)
+ }
+ }
+
+ test("rewrite does not affect COUNT(DISTINCT IF(cond, col, non_null))") {
+ withTempView("t") {
+ spark.range(3)
+ .selectExpr(
+ "cast(id % 2 + 1 as int) as key",
+ "cast(id * 10 as int) as col1",
+ "cast(id * 100 as int) as col2")
+ .createOrReplaceTempView("t")
+
+ // Two counts so mayNeedtoRewrite fires and the non-null-else guard is
exercised.
+ val sqlText =
+ """SELECT key,
+ | COUNT(DISTINCT IF(col1 > 10, col2, 0)),
+ | COUNT(DISTINCT IF(col1 > 20, col2, 0))
+ |FROM t GROUP BY key""".stripMargin
+
+ // Collect the conf-off result as the semantic baseline; the conf-on
query must match it.
+ val withoutRewriteRows = withSQLConf(
+ SQLConf.REWRITE_COUNT_DISTINCT_CONDITIONAL_ENABLED.key -> "false") {
+ spark.sql(sqlText).collect()
+ }
+ withSQLConf(
+ SQLConf.REWRITE_COUNT_DISTINCT_CONDITIONAL_ENABLED.key -> "true") {
+ checkAnswer(spark.sql(sqlText), withoutRewriteRows)
+ }
+
+ // When the switch is enabled, the non-null ELSE branch must not be
canonicalized
+ // into a user-condition FILTER; only the internal gid-routing filters
introduced
+ // by the Expand rewrite may be present.
+ val hasFilter = withSQLConf(
+ SQLConf.REWRITE_COUNT_DISTINCT_CONDITIONAL_ENABLED.key -> "true") {
+ hasUserConditionFilter(spark.sql(sqlText).queryExecution.optimizedPlan)
+ }
+ assert(!hasFilter,
+ "Non-null ELSE branch should not produce a user-condition FILTER
clause")
+ }
+ }
+
+ test("rewrite is not applied to error-prone base expressions (ANSI divide by
zero)") {
+ withTempView("t") {
+ // Rows with x = 0 make both conditions false, so the IF branches
short-circuit
+ // and 10 / x is never evaluated for them.
+ spark.range(5)
+ .selectExpr(
+ "cast(id % 2 + 1 as int) as key",
+ "cast(id % 2 as int) as x")
+ .createOrReplaceTempView("t")
+
+ // Two conditional distinct counts on the same error-prone base so that
rewrite()
+ // is reached; the canonicalization must NOT fire for such a base.
+ val sqlText =
+ """SELECT key,
+ | COUNT(DISTINCT IF(x <> 0, 10 / x, NULL)),
+ | COUNT(DISTINCT IF(x > 0, 10 / x, NULL))
+ |FROM t GROUP BY key""".stripMargin
+
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> "true",
+ SQLConf.REWRITE_COUNT_DISTINCT_CONDITIONAL_ENABLED.key -> "true") {
+ // Must not raise DIVIDE_BY_ZERO: the canonicalization must leave the
IFs intact
+ // so that their branch short-circuiting is preserved.
+ checkAnswer(spark.sql(sqlText), Seq(Row(1, 0L, 0L), Row(2, 1L, 1L)))
+
assert(!hasUserConditionFilter(spark.sql(sqlText).queryExecution.optimizedPlan),
+ "error-prone base should not produce a user-condition FILTER clause")
+ }
+ }
+ }
+
+ test("guard: high-cardinality base with low-selectivity disjoint
predicates") {
+ withTempView("t") {
+ // ord is unique (high-cardinality base); each predicate matches ~1% of
rows and
+ // they are disjoint. This is the workload shape that regressed before
the
+ // union-of-filters guard: the guarded key must stay bounded by the
pre-rewrite
+ // plan's cardinality while the Expand stays collapsed.
+ spark.range(1000)
+ .selectExpr(
+ "cast(id % 7 as int) as key",
+ "cast(id as bigint) as ord",
+ "cast(id % 100 as int) as bucket")
+ .createOrReplaceTempView("t")
+
+ checkRewriteAndResult(
+ """SELECT key,
+ | COUNT(DISTINCT IF(bucket = 0, ord, NULL)),
+ | COUNT(DISTINCT IF(bucket = 3, ord, NULL))
+ |FROM t GROUP BY key""".stripMargin,
+ """SELECT key,
+ | COUNT(DISTINCT ord) FILTER (WHERE bucket = 0),
+ | COUNT(DISTINCT ord) FILTER (WHERE bucket = 3)
+ |FROM t GROUP BY key""".stripMargin)
+ }
+ }
+
+ test("guard: mixed overlapping windows and disjoint flags") {
+ withTempView("t") {
+ spark.range(1000)
+ .selectExpr(
+ "cast(id % 7 as int) as key",
+ "cast(id as bigint) as ord",
+ "cast(id % 30 as int) as day",
+ "cast(id % 100 as int) as bucket")
+ .createOrReplaceTempView("t")
+
+ // Overlapping windows (day < 10 is a subset of day < 30) plus disjoint
selective flags in
+ // the same query: the union guard must keep every count correct.
+ checkRewriteAndResult(
+ """SELECT key,
+ | COUNT(DISTINCT IF(day < 10, ord, NULL)),
+ | COUNT(DISTINCT IF(day < 30, ord, NULL)),
+ | COUNT(DISTINCT IF(bucket = 0, ord, NULL)),
+ | COUNT(DISTINCT IF(bucket = 7, ord, NULL))
+ |FROM t GROUP BY key""".stripMargin,
+ """SELECT key,
+ | COUNT(DISTINCT ord) FILTER (WHERE day < 10),
+ | COUNT(DISTINCT ord) FILTER (WHERE day < 30),
+ | COUNT(DISTINCT ord) FILTER (WHERE bucket = 0),
+ | COUNT(DISTINCT ord) FILTER (WHERE bucket = 7)
+ |FROM t GROUP BY key""".stripMargin)
+ }
+ }
+
+ test("guard: NULL condition keeps the count empty") {
+ withTempView("t") {
+ spark.range(50)
+ .selectExpr(
+ "cast(id % 7 as int) as key",
+ "cast(id as bigint) as ord",
+ "cast(id % 100 as int) as bucket")
+ .createOrReplaceTempView("t")
+
+ // A NULL condition is never true; with the union guard its rows may
share the
+ // Expand key of the other count, but must never be counted. The FILTER
syntax
+ // cannot express a NULL condition (non-boolean type is rejected), so
compare
+ // the rewritten plan against the conf-off result as the semantic
baseline.
+ val sqlText =
+ """SELECT key,
+ | COUNT(DISTINCT IF(NULL, ord, NULL)),
Review Comment:
**Nit (P3):** `IF(NULL, ord, NULL)` is gone before this rule runs.
`SimplifyConditionals` folds `If(null, _, f)` to `f` during operator
optimization, which comes before the Distinct Aggregate Rewrite batch. When
`RewriteDistinctAggregates` runs, this aggregate is already `COUNT(DISTINCT
CAST(NULL AS BIGINT))`, a separate foldable group, so the guarded group only
contains `bucket = 0`. The test passes however the guard handles NULL
predicates, and every condition column in the suite is non-null. A predicate
over a nullable column that is NULL for some rows, e.g. `IF(nullable_col > 0,
ord, NULL)`, would actually reach `Or(...)` and `max(flag)`. Asserting the
guarded key in the optimized plan would also stop the case from being folded
away again.
**Verification:**
- **Regression:** A conditional distinct count whose condition is NULL for
some rows, collapsed with another conditional count on the same base, returns
the config-off result, and the optimized plan shows the guarded key over both
conditions.
--
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]