HyukjinKwon commented on code in PR #59403:
URL: https://github.com/apache/spark/pull/59403#discussion_r4275695866
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2796,6 +2800,65 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
case _ => false
}
+ // Rewrites conjuncts pushed below `aggregate` in terms of the aggregate's
child and ANDs them.
+ // Expressions above the aggregate see its float/double grouping keys
normalized (see
+ // NormalizeFloatingNumbers: -0.0 becomes 0.0 and every NaN becomes one
canonical NaN), so the
+ // pushed conjuncts read them normalized too, wherever the raw value could
change the result.
+ // NormalizeFloatingNumbers.outputsNormalizedKeys says when the keys come
out normalized, whether
+ // the aggregate is planned or a later rule replaces it with a Project.
Aggregates of grouped-agg
+ // pandas UDFs output the raw keys, so conjuncts pushed below them read the
raw keys.
+ private[optimizer] def pushBelowAggregate(
+ conds: Seq[Expression],
+ aggregate: Aggregate): Expression = {
+ val aliasMap = getAliasMap(aggregate)
+ val normalizedGrouping = if
(NormalizeFloatingNumbers.outputsNormalizedKeys(aggregate)) {
Review Comment:
**Non-blocking (P2):** `outputsNormalizedKeys(aggregate)` is evaluated here,
when the filter is pushed. The Aggregate's function set is not final at that
point. In the operator-optimization rule set `PushDownPredicates`
(Optimizer.scala:108) runs before `ColumnPruning` (Optimizer.scala:115), and
ColumnPruning's `Project(_, a: Aggregate)` case drops aggregate expressions the
parent does not use. `SparkStrategies.Aggregation` plans an all-`PythonUDAF`
Aggregate as `ArrowAggregatePythonExec` on the raw keys and only rejects
mixtures at planning.
Example with rows `(x=-0.0, v=1.0), (x=-0.0, v=2.0)` and a grouped-agg
pandas UDF `pudf`: `df.groupBy("x").agg(pudf(col("v")).as("p"),
count("*").as("c")).where(col("x").cast("string") === "-0.0").select("x",
"p")`. When the filter is pushed the functions are mixed, so it becomes
`cast(knownfloatingpointnormalized(normalizenanandzero(x)) AS STRING) =
'-0.0'`, which is false for both rows. Then `c` is pruned and the Aggregate
runs on the raw key `-0.0`, which does satisfy the predicate. The query returns
no rows, while master returns the `-0.0` group. The new FilterPushdownSuite and
PythonUDFSuite pandas tests only use Aggregates that are all-`PythonUDAF` from
the start. The opposite shape, an all-`PythonUDAF` Aggregate whose UDAF output
is pruned so that it becomes group-only and normalizing, still gets a raw
filter, as on master.
**Recommended change:** For an Aggregate whose aggregate functions include
any PythonUDAF, push below it only the conjuncts that
normalizeGroupingExpressions leaves unchanged, and keep the conjuncts that
would read a float/double key in normalized form in the Filter above the
Aggregate. Update the outputsNormalizedKeys/pushBelowAggregate comments to
describe this. Add optimizer plan tests and an end-to-end grouped-agg pandas
UDF test, including a mixed pandas plus SQL aggregate whose SQL aggregate is
projected away.
**Why this works:** A conjunct evaluated above the Aggregate reads whatever
key form the finally planned operator outputs, so it no longer depends on the
function set at push time. Conjuncts that normalization would not change give
the same result on raw and normalized keys, so they can still be pushed below
either branch.
**Scope:** Make filter push-down below Aggregates that contain grouped-agg
pandas/Arrow UDFs independent of later column pruning.
**Compatibility:** Built-in and PythonAggregate aggregates keep receiving
normalized pushed filters, and plain comparisons keep reaching data sources.
**Risks:** The CTE removal mirror must keep translating pushed conjuncts the
same way and still find conjuncts that stay above the Aggregate.
**Constraints:** Plans for Aggregates without any PythonUDAF must not
change. Plain comparisons on float/double keys must still be pushed below
PythonUDAF aggregates.
**Success:** A mixed pandas UDF plus SQL aggregate whose SQL aggregate is
pruned, filtered with a sign-sensitive predicate on a -0.0 key, returns the
same rows as master. An all-PythonUDAF Aggregate whose pandas output is pruned,
filtered with a sign-sensitive predicate, returns only rows that satisfy the
predicate. Plain comparisons on float/double keys are still pushed below
PythonUDAF aggregates, and filters below built-in aggregates are normalized as
before.
**Verification:**
- **Regression:** A grouped-agg pandas UDF aggregation mixed with a SQL
aggregate that is projected away, over a -0.0 key and filtered with a
sign-sensitive predicate, returns the -0.0 group.
- **Regression:** A grouped-agg pandas UDF aggregation whose pandas output
is projected away, filtered with a sign-sensitive predicate, returns only rows
consistent with the predicate.
- **Behavior:** The optimized plan keeps a normalization-sensitive conjunct
above an Aggregate that contains a PythonUDAF and still pushes plain
comparisons below it.
- **Compatibility:** Pushed-filter plans for Aggregates without PythonUDAF
functions are unchanged.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2796,6 +2800,65 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
case _ => false
}
+ // Rewrites conjuncts pushed below `aggregate` in terms of the aggregate's
child and ANDs them.
+ // Expressions above the aggregate see its float/double grouping keys
normalized (see
+ // NormalizeFloatingNumbers: -0.0 becomes 0.0 and every NaN becomes one
canonical NaN), so the
+ // pushed conjuncts read them normalized too, wherever the raw value could
change the result.
+ // NormalizeFloatingNumbers.outputsNormalizedKeys says when the keys come
out normalized, whether
+ // the aggregate is planned or a later rule replaces it with a Project.
Aggregates of grouped-agg
+ // pandas UDFs output the raw keys, so conjuncts pushed below them read the
raw keys.
+ private[optimizer] def pushBelowAggregate(
+ conds: Seq[Expression],
+ aggregate: Aggregate): Expression = {
+ val aliasMap = getAliasMap(aggregate)
+ val normalizedGrouping = if
(NormalizeFloatingNumbers.outputsNormalizedKeys(aggregate)) {
+ aggregate.groupingExpressions
+ .filter(e => NormalizeFloatingNumbers.needNormalize(e.dataType))
+ .map(trimAliases)
+ } else {
+ Nil
+ }
+ conds.map { cond =>
+ val replaced = replaceAlias(cond, aliasMap)
+ if (normalizedGrouping.isEmpty) {
+ replaced
+ } else {
+ normalizeGroupingExpressions(replaced, normalizedGrouping)
+ }
+ }.reduce(And)
+ }
+
+ // Rewrites `e` so that it gives the same result on the raw values of
`grouping` as `e` gives on
+ // their normalized values (see NormalizeFloatingNumbers). Each occurrence
of a grouping
+ // expression is wrapped in its normalized form, with one exception: a
float/double attribute
+ // that is a direct operand of one of the comparisons matched below is left
as is, and so is a
+ // float attribute widened to double (as when a FLOAT column is compared
with a DOUBLE or
+ // DECIMAL value), since widening keeps -0.0 and NaN. Those comparisons
already treat -0.0 and
+ // 0.0 as equal and all NaNs as equal (see SQLOrderingUtil.compareDoubles and
+ // CodegenContext.genEqual), and IsNull, IsNotNull and IsNaN cannot tell
normalized values apart
+ // either, so wrapping would not change the result. Leaving them as is keeps
plain comparisons
+ // usable by data sources and partition pruning.
+ private[optimizer] def normalizeGroupingExpressions(
+ e: Expression,
+ grouping: Seq[Expression]): Expression = e match {
+ // A grouping key already normalized when the filter was pushed through an
Aggregate above on
+ // the same key. Any other normalized expression may be computed from a
grouping key (when the
+ // upper Aggregate groups by an expression of this one's key), so the key
inside it still has
+ // to be normalized. `normalize` builds new lambda variables for array and
map keys, so those
Review Comment:
**Nit (P3):** This sentence doesn't match the code. The guard below matches
with `semanticEquals`, which compares canonicalized trees, and
`HigherOrderFunction.canonicalized` (higherOrderFunctions.scala:245-267)
renumbers lambda variables. So `normalize(g)` for an array or map key is
semantically equal to the earlier normalization even though its
`NamedLambdaVariable`s are fresh, and the case returns `e` unchanged. Array and
map keys are kept like any other key, not normalized again. The results are the
same either way, but the comment, and the matching sentence in the PR
discussion, give the wrong model. No test covers an array or map key on this
path.
**Verification:**
- **Inspection:** The comment matches the short-circuit's semanticEquals
behavior for array and map keys.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RemoveRedundantAggregatesSuite.scala:
##########
@@ -180,11 +182,9 @@ class RemoveRedundantAggregatesSuite extends PlanTest {
.groupBy($"a")($"a", ($"a" + rand(0)) as "c")
.groupBy($"a", $"c")($"a", $"c")
.analyze
- val expected = relation
- .groupBy($"a")($"a", ($"a" + rand(0)) as "c")
- .analyze
+ // The upper aggregate is not removed either, since it normalizes the
double key c.
val optimized = Optimize.execute(query)
- comparePlans(optimized, expected)
+ comparePlans(optimized, query)
Review Comment:
**Nit (P3):** With `c = a + rand(0)` as a DOUBLE, the new guard keeps the
upper Aggregate, so this test now only asserts that nothing changes. Nothing in
the suite still checks the original scenario, a group-only upper Aggregate
removed over a lower one kept for its non-deterministic output. A regression
there would pass, and the title still says "Remove". The sibling test above
kept its coverage by using `(rand(0) * 10).cast(IntegerType)`. Doing the same
here, plus a separately named test for the double-key case if you want it
pinned, would restore the signal.
**Verification:**
- **Regression:** The group-only upper Aggregate over a lower Aggregate kept
for its non-deterministic non-grouping output is replaced by a Project for a
non-float key.
- **Inspection:** Each test's title describes its asserted plan.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/LimitPushdownSuite.scala:
##########
@@ -328,6 +328,22 @@ class LimitPushdownSuite extends PlanTest {
Optimize.execute(x.groupBy("x.a".attr)("x.a".attr,
count("x.a".attr)).limit(1).analyze),
x.groupBy("x.a".attr)("x.a".attr, count("x.a".attr)).limit(1).analyze)
}
+ test("SPARK-60150: No limit 1 push down through a group only Aggregate on a
float key") {
Review Comment:
**Nit (P3):** Nit: this test starts right after the previous test's closing
brace, and three blank lines follow it (345-347). OptimizeOneRowPlanSuite has
the same layout (79-80 and 92-94), and RemoveRedundantAggregatesSuite has two
blank lines after the second new test (281-282). One blank line between tests
would match the rest of these suites.
**Verification:**
- **Inspection:** No new test directly follows a closing brace and no run of
more than one blank line remains around the new tests.
--
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]