Hengrui Hu created SPARK-60178:
----------------------------------

             Summary: Canonicalization re-associates floating-point 
Add/Multiply, producing wrong results
                 Key: SPARK-60178
                 URL: https://issues.apache.org/jira/browse/SPARK-60178
             Project: Spark
          Issue Type: Bug
          Components: SQL
    Affects Versions: 4.2.0
            Reporter: Hengrui Hu


{{Add.canonicalized}} and {{Multiply.canonicalized}} flatten and reorder 
adjacent operands
({{Expression.buildCanonicalizedPlan}} -> {{orderCommutative}} -> 
{{gatherCommutative}}) with no
exception for FLOAT/DOUBLE. Floating-point addition and multiplication are 
commutative but *not
associative*, so {{(a + b) + c}} and {{a + (b + c)}} canonicalize to the same 
expression while
evaluating to different values.

Every consumer of semantic equality is therefore allowed to substitute one for 
the other. Two of
them return wrong results today: the cached-plan lookup in {{CacheManager}} 
({{sameResult}}) and
subexpression elimination ({{EquivalentExpressions.ExpressionEquals}} -> 
{{semanticEquals}}).

h2. Reproduction

Measured on current master ({{5.0.0-SNAPSHOT}}), {{local[4]}}, AQE disabled. 
One Parquet row:

{code:sql}
-- a = 1.0E16, b = -1.0E16, c = 1.0, all DOUBLE
-- (a + b) + c = 1.0      a + (b + c) = 0.0
{code}

*1. Subexpression elimination, same query*

{code:sql}
SELECT (a + b) + c AS x, a + (b + c) AS y FROM t
{code}

{noformat}
cse=true  wholeStage=true  -> x=1.0 y=1.0     <-- y is wrong
cse=true  wholeStage=false -> x=1.0 y=1.0     <-- y is wrong
cse=false wholeStage=true  -> x=1.0 y=0.0
cse=false wholeStage=false -> x=1.0 y=0.0
{noformat}

The physical plan still contains both expressions; only the evaluation is 
shared. Setting
{{spark.sql.subexpressionElimination.enabled=false}} restores the correct 
answer, which isolates
the mechanism.

*2. Cached-plan reuse, across queries*

{code:scala}
val d1 = spark.sql("select (a+b)+c as v from t")
d1.cache(); d1.collect()                       // [1.0], correct
spark.sql("select a+(b+c) as v from t").collect()   // [1.0], expected [0.0]
d1.unpersist()
spark.sql("select a+(b+c) as v from t").collect()   // [0.0], correct again
{code}

The second query's plan is served from the cache entry built for the first:

{noformat}
== Physical Plan ==
InMemoryTableScan [v#49]
   +- InMemoryRelation [v#49], StorageLevel(disk, memory, deserialized, 1 
replicas)
         +- *(1) Project [((a#3 + b#4) + c#5) AS v#20]
{noformat}

*3. Directly, at the expression level*

{code:scala}
val a = AttributeReference("a", DoubleType)()
val b = AttributeReference("b", DoubleType)()
val c = AttributeReference("c", DoubleType)()
Add(Add(a, b), c).semanticEquals(Add(a, Add(b, c)))                // true
Multiply(Multiply(a, b), c).semanticEquals(Multiply(a, Multiply(b, c)))  // true
{code}

h2. Proposed fix

Floating-point {{+}} and {{*}} are commutative, so {{a + b}} and {{b + a}} may 
still be treated as
semantically equal; it is only the re-association across levels that is 
unsound. The narrow fix is
to stop flattening when the operation's {{dataType}} is {{FloatType}} or 
{{DoubleType}}, and
canonicalize just the two direct children, sorting them as today. That keeps 
commutative reuse and
rejects {{(a + b) + c}} vs {{a + (b + c)}}.

A more conservative variant is to return {{withCanonicalizedChildren}} for 
floating-point types,
mirroring the existing decimal fallback added in SPARK-40903 for a comparable 
reason (reordering
changing the result), at the cost of also losing the commutative case.

The same {{buildCanonicalizedPlan}} path produces {{MultiCommutativeOp}} above
{{spark.sql.analyzer.canonicalization.multiCommutativeOpMemoryOptThreshold}} 
operands, so the guard
belongs in {{canonicalized}} before the operands are gathered rather than in 
the reduce step.

Suggested coverage: an assertion pair in the canonicalization suite 
({{(a+b)+c}} not semantically
equal to {{a+(b+c)}} for DOUBLE, while {{a+b}} stays equal to {{b+a}}), plus 
the two end-to-end
cases above.

h2. Notes

Integer, boolean and bitwise operands are unaffected: reordering those changes 
only the order in
which ANSI errors may be raised, which Spark does not specify. For FLOAT/DOUBLE 
the returned value
itself changes.

Not a regression. {{Add.canonicalized}} / {{Multiply.canonicalized}} have the 
same shape on
branch-3.5 through master, so every maintained branch is affected; the numbers 
above were measured
on master.

Found while reviewing https://github.com/apache/spark/pull/58424, where an 
optimizer rule relies on
{{sameResult}} to prove two scans are equivalent. The gap is in the shared 
canonicalization, not in
that rule.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to