Repository navigation
[SPARK-60178][SQL] Do not re-associate floating-point Add/Multiply in canonicalization - #59437
Vivek1106-04 wants to merge 3 commits into
Conversation
734cdb2 to
594c940
Compare
|
@cloud-fan @peter-toth could you take a look at this canonicalization fix when you have time? Thanks! |
HyukjinKwon
left a comment
There was a problem hiding this comment.
Review summary
The canonicalization change is correct at its natural owner. The float/double branch only reorders a single operator's operands, the non-float path is moved unchanged, and the new tests fail without the fix in each consumer they target. One minor gap: the rewritten CombineApproximatePercentilesSuite tests no longer reach two guards that the rule still relies on. The percentage-evaluation check and the evaluated-accuracy keying can still be reached through decimal re-association with precision loss, so a decimal-based scenario would restore their coverage. Separately, the GROUP BY analysis behavior change below is raised as a question for the author, not as a defect.
Findings
1 total: 0 P0, 0 P1, 0 P2, 1 P3.
Nit (P3)
- Percentile fusion guards lost their only regression tests —
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/CombineApproximatePercentilesSuite.scala:252— see inline.
Verification
- Decimal re-association with precision loss still produces canonically equal but differently evaluated expressions: DECIMAL(38,10) (a + b) + c = 1E-8 vs a + (b + c) = 0E-8, and DECIMAL(38,7) 3.000000 vs 2.999999, checked against adjustPrecisionScale and ROUND_HALF_UP semantics from the pinned source.
PR description suggestions
- The sentence 'Integer, decimal, boolean and bitwise operators are unaffected: re-associating those does not change the returned value' overstates the guarantee. Decimal Add/Multiply under the default spark.sql.decimalOperations.allowPrecisionLoss round intermediate results, and ANSI integral addExact/multiplyExact can overflow depending on association, so those types can also be association-sensitive. Consider saying that other types are intentionally left on the existing flattening path, out of scope for this PR, rather than claiming re-association is value-preserving for them.
Decision challenges
GROUP BY over differently associated float expressions now fails analysis
Not flattening float chains also changes analysis outcomes, not only CSE and cache reuse. ExprUtils.assertValidAggregation accepts a SELECT/HAVING/ORDER BY expression only if it is semanticEquals to a grouping expression. On DOUBLE columns, SELECT a + b + c FROM t GROUP BY c + b + a (or SELECT price * qty * rate ... GROUP BY rate * qty * price) used to pass analysis, because both sides flattened to the same operand list, and it returned the grouping value. With this PR it fails with MISSING_AGGREGATION. Identical text and single-operator commutations such as a + b vs b + a still match. The new behavior is arguably the correct one, since the selected value can differ from the grouping value in floating point. Still, previously working queries (and persisted views re-analyzed after an upgrade) now fail. The PR's user-facing-change section doesn't mention this, and docs/sql-migration-guide.md usually records correctness-driven behavior changes like it. Is this break intended? If so, could you call it out in the migration guide and the PR description? Or do you think it needs a legacy switch?
Generated by Omnigent on Databricks.
| assert(secondAccuracy.eval() == 4) | ||
| assert(firstAccuracy.canonicalized == secondAccuracy.canonicalized) | ||
| assertNotCombined(result) | ||
| assert(firstAccuracy.canonicalized != secondAccuracy.canonicalized) |
There was a problem hiding this comment.
Nit (P3): These two tests used to be the only coverage for the rule's 'canonically equal but evaluated differently' guards. Now that float re-association is canonically distinct, they assert fusion instead, so no test reaches hasSafePhysicalFusion's percentages.forall(_ == first) rejection or the evaluated-accuracy split of a shared PhysicalCompatibilityKey. The 'require structural equality for inputs and filters' test still covers sizeCompare(1) through the input dimension only.
Both guards are still reachable, because decimal Add/Multiply keep flattening. With the default allowPrecisionLoss=true, three DECIMAL(38,10) literals 0.0000000004, 0.0000000001, 0.0000000040 give (a + b) + c = 1E-8 but a + (b + c) = 0E-8. Both have result type DECIMAL(38,8), so they canonicalize to the same tree; cast to DOUBLE they are canonically equal percentages that evaluate differently. DECIMAL(38,7) literals 1.9999994, 0.0000001, 1.0000000 give 3.000000 vs 2.999999, so cast to INT they are canonically equal accuracies 3 and 2. Could you keep the new float expectations and add decimal-based variants of the old assertNotCombined scenarios? Assert the canonical-equality and evaluation preconditions first, so a future canonicalization change fails loudly instead of silently dropping the coverage again.
Verification:
- Regression: Removing hasSafePhysicalFusion's per-canonical-group percentage-evaluation check makes the restored percentage scenario fail.
- Regression: Keying CompatibilityKey by the canonical accuracy expression instead of its evaluated value makes the restored accuracy scenario fail.
- Behavior: The restored scenarios assert that their decimal-derived inputs are canonically equal and evaluate differently. A future canonicalization change that makes them distinct then fails loudly instead of silently dropping guard coverage.
There was a problem hiding this comment.
Thanks, good catch. Added two decimal-based tests that bring back the old assertNotCombined scenarios, using your DECIMAL(38,10) and DECIMAL(38,7) literals. Each test first asserts that the two sides are canonically equal and evaluate differently (1E-8 vs 0.0, and 3 vs 2). I checked them by mutation: dropping the percentages.forall(_ == first) check fails the percentage test, and dropping the shared-PhysicalCompatibilityKey sizeCompare(1) check fails the accuracy test.
|
@HyukjinKwon thanks for the review. Addressed in f05fce0:
|
f05fce0 to
ceff75b
Compare
|
Hi @Vivek1106-04 @HyukjinKwon @sunchao , thanks for working on this fix! This PR is currently blocking progress on #58424, as we depend on the floating-point canonicalization fix to move forward. Are there any remaining concerns or changes needed before this PR can move forward? Thanks for your help! cc @wangyum |
… canonicalization Add.canonicalized and Multiply.canonicalized flatten adjacent operators and reorder the operands with no exception for FLOAT/DOUBLE. Floating-point addition and multiplication are commutative but not associative, so (a + b) + c and a + (b + c) canonicalized to the same expression while evaluating to different values. Subexpression elimination and the CacheManager lookup then substituted one for the other and returned wrong results. For FLOAT/DOUBLE, only reorder the two direct children of Add/Multiply instead of flattening the operator tree. a + b and b + a stay semantically equal; re-associated expressions no longer do. Other types are unchanged.
…associative floating-point canonicalization Two tests asserted that differently associated floating-point Add trees canonicalize equally and that the rule therefore refuses to fuse them. With floating-point Add no longer re-associated, those expressions are canonically distinct, so fusion is now safe and keeps each evaluated accuracy and percentage. Update the tests to assert the correct fusion.
…ent GROUP BY change Add decimal-based CombineApproximatePercentilesSuite tests that reach the percentage-evaluation and evaluated-accuracy guards, which the float tests no longer reach. Pin the GROUP BY analysis change in SQLQuerySuite and record it in the SQL migration guide.
ceff75b to
dd1e524
Compare
|
Thanks @hhr293. No open items on my side: @HyukjinKwon's review was addressed in my Oct 16 update (guard coverage restored with decimal-based tests, GROUP BY behavior change documented in the migration guide). I just rebased onto master to resolve conflicts with recent migration-guide and |
spark-test
left a comment
There was a problem hiding this comment.
Review summary
The floating-point fix is correct where it applies. Resolved FLOAT and DOUBLE Add and Multiply now reorder only their two operands, so commuted forms still match while re-associated chains no longer share subexpression results, cached plans, or percentile-fusion keys, and the new tests exercise each of those consumers. The fix stops short of decimal chains whose steps reach 38 digits: they still re-associate in canonicalization even though they round or overflow depending on association, so the same wrong results remain for them. Separately, the central canonicalization scaladocs still describe flattening for floating-point Add and Multiply.
The earlier review's percentile-guard coverage nit is addressed: two decimal-based CombineApproximatePercentilesSuite tests restore the guard scenarios and assert their preconditions. That review used decimal re-association only to keep guard coverage and suggested describing other types as out of scope. It did not consider that the same re-association still produces wrong results for 38-digit decimal chains, which is why this review raises it now. A question for the author about the GROUP BY break follows.
Cross-cutting observations
-
Whatever is decided for the floating-point GROUP BY break should apply in the same way to the 38-digit decimal chains that the decimal correction also stops matching, so that both share one migration note and one error message.
The decimal correction is needed however the GROUP BY question is settled; only its migration wording should follow that decision.
Findings
2 total: 0 P0, 0 P1, 1 P2, 1 P3.
Non-blocking (P2)
- Decimal Add and Multiply still re-associate in canonicalization and can return the other association's value —
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/arithmetic.scala:483— see inline.
Nit (P3)
- Canonicalization scaladocs still describe flattening for FLOAT/DOUBLE Add and Multiply —
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/Expression.scala:1437— see inline.
Re-review status
Prior AI findings: 1 no longer raised, 0 still raised; additional unresolved findings in this review: 2.
New attribution: 0 newly introduced, 2 late catch, 0 previously raised, 0 unattributed.
Decision challenges
GROUP BY over a differently associated FLOAT/DOUBLE expression now fails analysis with no restore setting
Following up on the earlier discussion of this break: this review confirmed that the fixed-point and single-pass analyzers both reject these queries through the shared ExprUtils.assertValidAggregation. So SELECT a + b + c FROM t GROUP BY c + b + a on DOUBLE columns fails with MISSING_AGGREGATION, while a + b versus b + a and integral chains still match. On the base branch the same query passed analysis and returned the grouping expression's value, which can differ from the selected expression's value. Two gaps remain in how the break reaches users. The migration note covers queries but not persisted views. Those are parsed again from their stored SQL text when read, so a view of this shape created on 4.3 fails on 4.4 until its definition is rewritten. The MISSING_AGGREGATION error also gives no hint that the expression matches a grouping expression except for floating-point association. This review does not recommend a setting that restores the old matching, because it would bring back a grouping value that can differ from the selected value. What remains open is whether the break should ship as documented, or whether the change should also name persisted views in the migration note, make the error point at the association mismatch, or both.
This follows up on existing discussions 1, 2.
Generated by Omnigent on Databricks
| ) | ||
| if (resolved && reorderResult.resolved && reorderResult.dataType == dataType) { | ||
| reorderResult | ||
| if (resolved && (dataType == FloatType || dataType == DoubleType)) { |
There was a problem hiding this comment.
Non-blocking (P2): This condition sends only FLOAT and DOUBLE results to the per-operator path, so decimal Add and Multiply still flatten and reorder every operand. A decimal step whose result type is capped at 38 digits rounds (with the default spark.sql.decimalOperations.allowPrecisionLoss=true) or can overflow, so its value depends on association just as a floating-point value does. With DECIMAL(38,7) columns a = 1.9999994, b = 0.0000001 and c = 1.0000000, (a + b) + c is 3.000000 and a + (b + c) is 2.999999, yet both canonicalize to the same MultiCommutativeOp and pass the SPARK-40903 type check, so SELECT (a + b) + c, a + (b + c) FROM t with subexpression elimination returns 3.000000 for both. The same equality lets a cached (a + b) + c answer a + (b + c), and lets GROUP BY match the two forms. Multiply behaves the same way and has no type check. Sums of three DECIMAL(38,18) values, the default type of Scala and Java BigDecimal fields, are one common case. Decimal chains that stay below 38 digits are exact and can keep flattening. The PR description lists decimals as out of scope, but these inputs reach the same wrong result the PR fixes for floating point. The new decimal tests in CombineApproximatePercentilesSuite assert this remaining equality, so extending the fix also means rebuilding those fixtures.
This extends an existing discussion.
Recommended change: Extend the non-associative branch of Add.canonicalized and Multiply.canonicalized: a resolved decimal result whose precision equals the 38-digit maximum should also be canonicalized with orderBinaryCommutative instead of being flattened, while decimal chains below 38 digits keep the existing flattening path. Add decimal cases to the canonicalization, subexpression elimination, cached-table and GROUP BY tests. The decimal-based percentile-fusion guard fixtures rely on the old decimal equality, and after this change decimal and floating-point literal chains no longer give canonically equal inputs that evaluate differently, so rebuild those fixtures on a test-only foldable expression whose canonicalized form stays the same while the value it returns differs. Extend the migration-guide entry to cover 38-digit decimal chains.
Why this works: A decimal step whose result type is capped at 38 digits rounds its result (with spark.sql.decimalOperations.allowPrecisionLoss) or can overflow, so moving operands across that step can change the value. Ordering only the two direct children keeps commutation, which is exact for decimals and keeps the result type, and never moves operands between levels. Below 38 digits every step is exact, and decimal precision only grows toward the root, so flattening such a chain still preserves its value.
Scope: Make 38-digit decimal Add and Multiply chains association-aware in canonicalization, and update the tests, guard fixtures, and migration note that depend on decimal re-association.
Compatibility: Decimal chains whose result precision stays below 38 digits, and all integral and interval chains, keep their current canonical forms, plans, and GROUP BY matching.
Risks: GROUP BY queries and persisted views whose 38-digit decimal select and grouping expressions differ only in association will fail with MISSING_AGGREGATION, the same trade-off this PR already makes for FLOAT/DOUBLE, and the change cannot be rolled back without adding a setting. 38-digit decimal chains no longer build a MultiCommutativeOp, so their canonical forms use one binary node per operator; memory stays linear in the chain length. Chains whose 38-digit result is still exact, such as a sum of three DECIMAL(36,0) columns, also stop matching across associations, so they lose shared subexpression results and cached-plan reuse between associations even though their values never differ; like the rest of this change, this has no setting to roll it back.
Constraints: Apply the condition at every node, so a 38-digit root never flattens its operands even when the MultiCommutativeOp threshold would otherwise apply. Keep each canonical node's result type equal to the original node's type; commuting two decimal operands already preserves it.
Exclusions:
- Integral Add and Multiply chains under ANSI or TRY mode stay on the flattening path, because Spark's optimizer already re-associates integral chains under the same eval modes. Interval Add chains stay as well, because their association can only decide whether an overflow error is raised.
Success: Differently associated decimal Add or Multiply chains whose result precision reaches 38 digits are no longer semantically equal, so subexpression elimination, cached-plan reuse, and GROUP BY matching stop substituting one for another. Swapping the two operands of a single decimal operator still canonicalizes to the same form. Decimal chains below 38 digits keep re-association equality, the MultiCommutativeOp optimization, and the SPARK-40903 fallback. The migration-guide entry describes the GROUP BY consequence for 38-digit decimal chains as well as for FLOAT/DOUBLE. The percentile-fusion guards keep regression fixtures that are canonically equal but evaluate differently.
Verification:
- Regression: For decimal Add and Multiply chains whose 38-digit steps round or overflow depending on association, a query that selects two associations returns each one's own result, including null for the overflowing side with ANSI mode off, with subexpression elimination on and off and with whole-stage codegen on and off. For example, with DECIMAL(38,7) columns a = 1.9999994, b = 0.0000001 and c = 1.0000000, (a + b) + c and a + (b + c) return 3.000000 and 2.999999.
- Regression: After (a + b) + c over those columns is cached, a query for a + (b + c) does not match the cache entry and returns its own value.
- Behavior: For 38-digit decimal Add and Multiply chains, re-associated forms are not semantically equal while commuted forms are, at both a low and a high MultiCommutativeOp threshold.
- Compatibility: Decimal chains below 38 digits, such as small-precision decimal literals, still re-associate to equal forms and still build a MultiCommutativeOp once the operand count reaches the threshold.
- Behavior: Removing either percentile-fusion guard, the per-group percentage-evaluation check or the shared physical-key check, still makes a regression test fail.
| * adjacent operators. Used when the operator is commutative but not associative, e.g. | ||
| * floating-point addition and multiplication. | ||
| */ | ||
| protected def orderBinaryCommutative( |
There was a problem hiding this comment.
Nit (P3): This helper introduces a per-operator path, but the central contract text was not updated. The Expression.canonicalized scaladoc (Expression.scala:311-319) still says that adjacent same-class CommutativeExpressions are gathered and reordered from the root and that canonicalized is computed only on that root, and the MultiCommutativeOp scaladoc (Expression.scala:1471-1473) lists Add and Multiply without exception. Resolved FLOAT/DOUBLE Add and Multiply now never gather, never build a MultiCommutativeOp, and compute canonicalized at every node, so a maintainer relying on those docs could reintroduce the flattening this PR removes. Both scaladocs could name the exception and point to orderBinaryCommutative.
Verification:
- Inspection: The Expression.canonicalized and MultiCommutativeOp scaladocs name the Add and Multiply result types that use per-operator ordering, and that set matches the condition implemented in Add.canonicalized and Multiply.canonicalized.
What changes were proposed in this pull request?
Add.canonicalizedandMultiply.canonicalizedflatten adjacent operators and reorder all operands (buildCanonicalizedPlan->orderCommutative->gatherCommutative), with no exception forFLOAT/DOUBLE.This PR changes canonicalization for floating-point
Add/Multiplyto reorder only the two direct children, without flattening the operator tree:CommutativeExpression.orderBinaryCommutative, which canonicalizes and sorts the two children of a binary commutative operator.AddandMultiplyuse it when the (resolved) result type isFloatTypeorDoubleType. All other types keep the existing flattening path, including theMultiCommutativeOpmemory optimization and the SPARK-40903 decimal fallback.So
a + bandb + astay semantically equal, while(a + b) + canda + (b + c)no longer are.Why are the changes needed?
Floating-point
+and*are commutative but not associative, so(a + b) + canda + (b + c)canonicalized to the same expression while evaluating to different values. Every consumer of semantic equality may then substitute one for the other, and two of them return wrong results today.With
a = 1.0E16,b = -1.0E16,c = 1.0((a + b) + c = 1.0,a + (b + c) = 0.0):CacheManager/sameResult):Other types (integral, decimal, and so on) intentionally stay on the existing flattening path and are out of scope for this PR. Note that re-association is not always value-preserving for them either: decimal
+/*round intermediate results whenspark.sql.decimalOperations.allowPrecisionLossis true, and ANSI integral arithmetic can overflow depending on association.Does this PR introduce any user-facing change?
Yes, a correctness fix. Queries containing floating-point
+/*expressions that differ only in association no longer share subexpression-elimination results or cached plans, so they return the correct values instead of the value of the other expression. Expressions that differ only in operand order of a single operator (a + bvsb + a) are still deduplicated.This also changes analysis: a query that selects a floating-point expression grouped by a differently associated form of it, e.g.
SELECT a + b + c FROM t GROUP BY c + b + aonDOUBLEcolumns, now fails withMISSING_AGGREGATION. Previously it passed analysis and returned the grouping expression's value, which can differ from the selected expression's value. This is recorded indocs/sql-migration-guide.md.How was this patch tested?
New tests:
CanonicalizeSuite: forFloatType/DoubleType,Add/Multiply, andMULTI_COMMUTATIVE_OP_OPT_THRESHOLDof 1 and 100, commutative forms are semantically equal and re-associated forms are not.SQLQuerySuite: end-to-end subexpression elimination for+(precision loss) and*(overflow to Infinity), across CSE on/off and whole-stage codegen on/off.CachedTableSuite: a re-associated query does not hit the cache entry of the original and returns the correct value.SQLQuerySuite: GROUP BY over a re-associatedDOUBLEexpression fails withMISSING_AGGREGATION, while single-operator commutation and integral re-association still match.CombineApproximatePercentilesSuite: the existing float tests now expect fusion; new decimal-based tests keep coverage of the percentage-evaluation and evaluated-accuracy guards, and assert their canonical-equality and evaluation preconditions.All three fail without the fix and pass with it. Also ran
CanonicalizeSuite,SubexpressionEliminationSuite,ReorderAssociativeOperatorSuite,ArithmeticExpressionSuite, and all*PlanStability*Suite(322 tests, no plan changes).Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5.5)