ulysses-you commented on code in PR #58818:
URL: https://github.com/apache/spark/pull/58818#discussion_r4022079888


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/util/SQLOpenHashSet.scala:
##########
@@ -110,19 +111,21 @@ object SQLOpenHashSet {
     }
   }
 
-  def withNaNCheckFunc(
+  def withNaNAndZeroCheckFunc(

Review Comment:
   Blocking: this rename (and `withNaNCheckCode` -> `withNaNAndZeroCheckCode` 
at :141) is a binary-incompatible change to public methods that shipped in 
4.0.0, and `./dev/mima` fails on it. `build/sbt 
'catalyst/mimaReportBinaryIssues'` reports 4 `DirectMissingMethodProblem`s 
against `spark-catalyst_2.13:4.0.0` (the object method and its static 
forwarder, for each of the two names). `@Private` does not exempt it: 
`dev/mima`'s generator only excludes symbols with a `private[X]` qualifier 
(`tools/.../GenerateMIMAIgnore.scala:38-70`), and `MimaExcludes.scala` has no 
entry for this class. Keeping the old names is the cheapest fix; otherwise add 
the two filters MiMa prints. Details in the review summary.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/collectionOperations.scala:
##########
@@ -4561,6 +4618,16 @@ trait ArraySetLike {
   @transient protected lazy val ordering: Ordering[Any] =
     TypeUtils.getInterpretedOrdering(et)
 
+  @transient private lazy val normalizeElement: Any => Any = et match {

Review Comment:
   Non-blocking perf: this projection is built per expression instance, and a 
fresh instance is created per task (the evaluator is instantiated per task with 
its own `references`, `WholeStageCodegenExec.scala:738-793`), so 
`UnsafeProjection.create` runs once per task instead of once per query as on 
master. Measured on this head: 0.4-0.95 ms per fresh instance vs 4.5-5.9 us 
steady-state eval for `array_distinct(array<array<double>>)`. This is not 
specific to one execution mode: for complex element types `doGenCode` delegates 
to `nullSafeEval` (:4869/:5072/:5310/:5528), so `wholeStage` on/off does not 
change it. Cheapest fix: drop the projection and evaluate the same normalized 
expression with `.eval(InternalRow(value))`, which also removes the 
`InternalRow.copyValue` deep copy (`:4627`); or hoist the projection per 
(thread, dataType).



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/NormalizeFloatingNumbers.scala:
##########
@@ -53,71 +53,52 @@ import org.apache.spark.util.ArrayImplicits._
  *  `genEqual` method of 
[[org.apache.spark.sql.catalyst.expressions.codegen.CodegenContext]].
  * Case 2 is handled during planning in the `Aggregation` and 
`StatefulAggregationStrategy` objects
  *  of [[org.apache.spark.sql.execution.SparkStrategies]].
- * Cases 3-5 are handled by this optimizer rule.
- *
- * This rule runs in two places:
- *   1. Early in `FinishAnalysis` (right after `ReplaceExpressions` and before 
`EvalInlineTables`)
- *      so that array set-like operations are wrapped before optimizer rules 
that pre-evaluate
- *      expressions (e.g. `ConstantFolding`, `ConvertToLocalRelation`, 
`EvalInlineTables`).
- *   2. As a late batch at the end of the optimizer, because rules like 
subquery rewrite and
- *      join reorder can create new joins or join conditions after 
`FinishAnalysis` that still
- *      need their keys to be normalized.
+ * Cases 3 and 4 are handled by this optimizer rule. Array set operations 
handle case 5 in their
+ * expression evaluation.
  *
  * Ideally we should do the normalization in the physical operators that 
compare the
  * binary `UnsafeRow` directly. We don't need this normalization if the Spark 
SQL execution engine
  * is not optimized to run on binary data. This rule is created to simplify 
the implementation, so
  * that we have a single place to do normalization, which is more maintainable.
  *
+ * Note that, this rule must be executed at the end of optimizer, because the 
optimizer may create
+ * new joins(the subquery rewrite) and new join conditions(the join reorder).
  */
 object NormalizeFloatingNumbers extends Rule[LogicalPlan] {
 
-  def apply(plan: LogicalPlan): LogicalPlan = {
-    plan
-      .transformWithPruning( _.containsAnyPattern(WINDOW, JOIN)) {
-        case w: Window if w.partitionSpec.exists(p => needNormalize(p)) =>
-          // Although the `windowExpressions` may refer to `partitionSpec` 
expressions,
-          // we don't need to normalize the `windowExpressions`, as they are 
executed
-          // per input row and should take the input row as it is.
-          w.copy(partitionSpec = w.partitionSpec.map(normalize))
-
-        // Only hash join and sort merge join need the normalization. Here we 
catch all Joins with
-        // join keys, assuming Joins with join keys are always planned as hash 
join or sort merge
-        // join. It's very unlikely that we will break this assumption in the 
near future.
-        case j @ ExtractEquiJoinKeys(_, leftKeys, rightKeys, condition, _, _, 
_, _)
-            // The analyzer guarantees left and right joins keys are of the 
same data type. Here we
-            // only need to check join keys of one side.
-            if leftKeys.exists(k => needNormalize(k)) =>
-          val newLeftJoinKeys = leftKeys.map(normalize)
-          val newRightJoinKeys = rightKeys.map(normalize)
-          val newConditions = newLeftJoinKeys.zip(newRightJoinKeys).map {
-            case (l, r) => EqualTo(l, r)
-          } ++ condition
-          j.copy(condition = Some(newConditions.reduce(And)))
-
-        // The specialized NAAJ is a hash join, but its OR condition is not an 
equi-join shape.
-        case j @ ExtractSingleColumnNullAwareAntiJoin(leftKeys, rightKeys)
-            if leftKeys.exists(needNormalize) =>
-          val equality = EqualTo(normalize(leftKeys.head), 
normalize(rightKeys.head))
-          j.copy(condition = Some(Or(equality, IsNull(equality))))
-
-        // TODO: ideally Aggregate should also be handled here, but its 
grouping expressions are
-        // mixed in its aggregate expressions. It's unreliable to change the 
grouping expressions
-        // here. For now we normalize grouping expressions during planning. 
See Case 2 in the
-        // Scaladoc just above.
-      }
-      .transformAllExpressionsWithPruning(_.containsAnyPattern(
-        ARRAY_DISTINCT, ARRAY_UNION, ARRAY_INTERSECT, ARRAY_EXCEPT, 
ARRAYS_OVERLAP)) {
-        case e: ArrayDistinct if needNormalize(e.child) =>
-          e.copy(child = normalize(e.child))
-        case e: ArrayUnion if needNormalize(e.left) =>
-          e.copy(left = normalize(e.left), right = normalize(e.right))
-        case e: ArrayIntersect if needNormalize(e.left) =>
-          e.copy(left = normalize(e.left), right = normalize(e.right))
-        case e: ArrayExcept if needNormalize(e.left) =>
-          e.copy(left = normalize(e.left), right = normalize(e.right))
-        case e: ArraysOverlap if needNormalize(e.left) =>
-          e.copy(left = normalize(e.left), right = normalize(e.right))
-      }
+  def apply(plan: LogicalPlan): LogicalPlan = plan match {

Review Comment:
   Leftover from the revert: the single-case `plan match` can go (restore `def 
apply(plan: LogicalPlan) = plan.transformWithPruning(...)`). The closing 
scaladoc line about "a single place to do normalization" is also stale now that 
case 5 lives in the expression evaluation.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/util/SQLOpenHashSet.scala:
##########
@@ -131,31 +134,38 @@ object SQLOpenHashSet {
           handleNaN(valueNaN)
         }
       } else {
-        handleNotNaN(value)
+        handleNotNaN(normalize(value))
       }
   }
 
-  def withNaNCheckCode(
+  def withNaNAndZeroCheckCode(
       dataType: DataType,
       valueName: String,
       hashSet: String,
       handleNotNaN: String,
       handleNaN: String => String): String = {
     val ret = dataType match {
       case DoubleType =>
-        Some((s"java.lang.Double.isNaN((double)$valueName)", 
"java.lang.Double.NaN"))
+        Some((
+          s"java.lang.Double.isNaN((double)$valueName)",
+          s"if ($valueName == 0.0d) $valueName = 0.0d;",

Review Comment:
   `withNaNAndZeroCheckCode` now assigns to `valueName`, so that parameter must 
be a writable local of the exact primitive type (same at :156 for Float). All 
six call sites comply today, but the contract is undocumented, and a future 
caller passing a getter would only fail at runtime codegen. Worth one scaladoc 
line.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/collectionOperations.scala:
##########
@@ -30,8 +30,14 @@ import 
org.apache.spark.sql.catalyst.expressions.KnownNotContainsNull
 import org.apache.spark.sql.catalyst.expressions.codegen._
 import org.apache.spark.sql.catalyst.expressions.codegen.Block._
 import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke
+import org.apache.spark.sql.catalyst.optimizer.NormalizeFloatingNumbers

Review Comment:
   Expressions now depend on the optimizer package, the reverse of the existing 
direction, and this pulls the rule's `internalError` fallthrough into runtime 
evaluation. A comment on which types `needNormalize` admits but `normalize` 
cannot handle would help. Relatedly, the map arm of `normalizeElement` is 
unreachable for these five expressions (the analyzer rejects non-orderable 
element types: `array<map<string,double>>` fails with `INVALID_ORDERING_TYPE`), 
so it deserves a one-line causality comment.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/collectionOperations.scala:
##########
@@ -4561,6 +4618,16 @@ trait ArraySetLike {
   @transient protected lazy val ordering: Ordering[Any] =
     TypeUtils.getInterpretedOrdering(et)
 
+  @transient private lazy val normalizeElement: Any => Any = et match {
+    case dt if NormalizeFloatingNumbers.needNormalize(dt) =>

Review Comment:
   `case dt` shadows the trait's own `dt` (the array type) while `et` is the 
element type, which is easy to misread in a trait that uses both names. Could 
be `if (NormalizeFloatingNumbers.needNormalize(et))` without the pattern 
binding, and the `private lazy val normalizeElement` + `protected def 
normalizedElement` pair could collapse into a single `protected lazy val`.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/CollectionExpressionsSuite.scala:
##########
@@ -3532,6 +3532,46 @@ class CollectionExpressionsSuite
       Literal.create(Seq(Float.NaN, null, 1f), ArrayType(FloatType))), true)
   }
 
+  test("SPARK-54918: array set operations normalize special floating-point 
values") {
+    val doubles = Literal.create(
+      Seq(-0.0d, 0.0d, Double.NaN, Double.NaN), ArrayType(DoubleType, false))
+    val doubleSet = Literal.create(Seq(0.0d, Double.NaN), 
ArrayType(DoubleType, false))
+    checkEvaluation(ArrayDistinct(doubles), Seq(0.0d, Double.NaN))
+    checkEvaluation(ArrayUnion(doubles, doubleSet), Seq(0.0d, Double.NaN))
+    checkEvaluation(ArrayIntersect(doubles, doubleSet), Seq(0.0d, Double.NaN))
+    checkEvaluation(ArrayExcept(doubles, doubleSet), Seq.empty[Double])
+    checkEvaluation(ArrayExcept(doubleSet, doubles), Seq.empty[Double])
+    checkEvaluation(ArraysOverlap(doubles, doubleSet), true)
+
+    val floats = Literal.create(
+      Seq(-0.0f, 0.0f, Float.NaN, Float.NaN), ArrayType(FloatType, false))
+    val floatSet = Literal.create(Seq(0.0f, Float.NaN), ArrayType(FloatType, 
false))
+    checkEvaluation(ArrayDistinct(floats), Seq(0.0f, Float.NaN))
+    checkEvaluation(ArrayUnion(floats, floatSet), Seq(0.0f, Float.NaN))
+    checkEvaluation(ArrayIntersect(floats, floatSet), Seq(0.0f, Float.NaN))
+    checkEvaluation(ArrayExcept(floats, floatSet), Seq.empty[Float])
+    checkEvaluation(ArrayExcept(floatSet, floats), Seq.empty[Float])
+    checkEvaluation(ArraysOverlap(floats, floatSet), true)
+  }
+
+  test("SPARK-54918: array set operations normalize nested floating-point 
values") {

Review Comment:
   These new tests are discriminating: they fail on the pre-change revision at 
the byte-exact comparison in `ExpressionEvalHelper.scala:319` 
(`8000000000000000` vs `0`), and replacing `normalizedElement` with `identity` 
reproduces exactly that failure. Two gaps though: the NaN data is canonical 
only, so NaN-payload canonicalization (which the projection does provide) is 
not pinned, and no test reaches the complex path from SQL/DataFrame. 
`checkInputDataTypes` is `TypeCheckSuccess` for `array<array<double>>` and 
`array<struct<d:double>>`, so one `DataFrameFunctionsSuite` case asserting 
`doubleToRawLongBits` on a nested/struct element (in the style of 
`isPositiveZero`) would close the last hop, and pinning the equality itself 
(`[[0.0],[-0.0]]` -> one element) would let the #53468 wrapper change compose 
safely.



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