grorge123 commented on code in PR #5526:
URL: https://github.com/apache/datafusion-comet/pull/5526#discussion_r4195997002


##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -73,6 +82,167 @@ object CometExecRule {
     
scan.relation.cacheBuilder.serializer.isInstanceOf[ArrowCachedBatchSerializer] 
&&
       ArrowCachedBatchSerializer.supportsSchema(scan.relation.output)
 
+  val nondeterministicBesideNullTypeReason: String =
+    "a non-deterministic expression that does not run entirely in the codegen 
dispatcher, in " +
+      "an operator whose native plan also runs a dispatcher kernel with a 
NullType result: " +
+      "native evaluation computes every argument over the whole batch and can 
evaluate an " +
+      "argument twice, while Spark skips arguments that other arguments 
decide, so a stateful " +
+      "expression advances on rows Spark does not evaluate"
+
+  /**
+   * True when `op` evaluates a non-deterministic expression outside the 
codegen dispatcher, and
+   * `op`'s native plan runs a dispatcher kernel whose result type contains 
NullType.
+   *
+   * Spark's generated code skips an argument when another one decides the 
result (a NULL operand,
+   * a NULL divisor), while native evaluation computes every argument over the 
whole batch, and
+   * some serdes serialize an argument twice (`ln`'s domain check), so a 
stateful expression
+   * (`monotonically_increasing_id`, `rand`) advances on rows Spark does not 
evaluate. That
+   * happens on main for any native operator. But the dispatcher used to 
refuse NullType results,
+   * and an operator with one fell back to Spark together with the native 
operators above it up to
+   * a shuffle. Every operator this check refuses therefore ran in Spark on 
main, so refusing all
+   * of them costs nothing against main and does not depend on modelling each 
expression's
+   * evaluation order. A top-level expression the dispatcher runs as a whole 
is exempt: its kernel
+   * evaluates Spark's own generated code, with its own state per occurrence. 
The exemption reads
+   * the dispatcher's tag on `op`'s expressions, which a rebuilt tree (a 
decimal expression
+   * wrapped in `CheckOverflow`) does not carry; that only makes the operator 
fall back.
+   *
+   * The kernels are read from the serialized plans rather than from `op`'s 
expressions, since
+   * serialization can dispatch a tree it built itself (a folded map literal 
expanded into
+   * `CreateMap`, a decimal addition wrapped in `CheckOverflow`).
+   */
+  private[rules] def nondeterministicBesideNullTypeKernel(
+      op: SparkPlan,
+      nativeOp: OperatorOuterClass.Operator): Boolean =
+    op.expressions.exists(evaluatesNondeterministicNatively) &&
+      (runsNullTypeKernel(nativeOp) || projectionRunsNullTypeKernel(op) ||
+        op.children.exists(nullTypeKernelBelow))
+
+  private def evaluatesNondeterministicNatively(expr: Expression): Boolean = 
expr match {
+    case e if !e.exists(isStateful) => false
+    case e if e.getTagValue(CometExplainInfo.DISPATCHED_SELF).isDefined => 
false
+    case alias: Alias => evaluatesNondeterministicNatively(alias.child)
+    case _ => true
+  }
+
+  /**
+   * A node whose value depends on how many rows were evaluated before it: a 
Catalyst
+   * `Nondeterministic` node or a user function that keeps state, whatever its 
arguments. The
+   * partition id and the input file only depend on the partition, however 
many rows Spark skips.
+   */
+  private def isStateful(e: Expression): Boolean = e match {
+    case _: SparkPartitionID | _: InputFileName | _: InputFileBlockStart |
+        _: InputFileBlockLength =>
+      false
+    case _: Nondeterministic => true
+    case other => DispatchOccurrence.holdsSharedState(other)
+  }
+
+  /**
+   * `TakeOrderedAndProjectExec` serializes as a sink, a leaf scan, and builds 
its native sort and
+   * projection only when it executes, so those expressions are serialized 
again here.
+   */
+  private def projectionRunsNullTypeKernel(op: SparkPlan): Boolean = op match {
+    case top: TakeOrderedAndProjectExec =>
+      deferredRunsNullTypeKernel(top.sortOrder ++ top.projectList, 
top.child.output)
+    case _ => false
+  }
+
+  private def deferredRunsNullTypeKernel(
+      exprs: Seq[Expression],
+      inputs: Seq[Attribute]): Boolean =
+    exprs.exists(e => QueryPlanSerde.exprToProto(e, 
inputs).exists(runsNullTypeKernel))
+
+  /**
+   * The native plans below `op` up to a shuffle. A sink is a leaf scan of the 
serialized plan
+   * above it, and a broadcast, union or coalesce sink exists only when the 
plan below it is
+   * native, so on main a NullType kernel there kept the operators above it in 
Spark as well; a
+   * shuffle took a Spark child, so only its own partitioning expressions 
count.
+   */
+  private def nullTypeKernelBelow(plan: SparkPlan): Boolean = plan match {
+    // A Comet shuffle's partitioning expressions are serialized when it runs. 
Main refused a
+    // shuffle keyed on a NullType kernel, and then the operators above it ran 
in Spark too.
+    case shuffle: CometShuffleExchangeExec =>
+      shuffle.outputPartitioning match {
+        case partitioning: Expression =>
+          deferredRunsNullTypeKernel(partitioning.children, 
shuffle.child.output)
+        case _ => false
+      }
+    case stage: ShuffleQueryStageExec => nullTypeKernelBelow(stage.plan)
+    case read: AQEShuffleReadExec => nullTypeKernelBelow(read.child)
+    case _: ShuffleExchangeLike => false
+    case native: CometNativeExec =>
+      runsNullTypeKernel(native.nativeOp) || 
native.children.exists(nullTypeKernelBelow)
+    case stage: BroadcastQueryStageExec => nullTypeKernelBelow(stage.plan)
+    case reused: ReusedExchangeExec => nullTypeKernelBelow(reused.child)
+    case top: CometTakeOrderedAndProjectExec =>
+      deferredRunsNullTypeKernel(top.sortOrder ++ top.projectList, 
top.child.output) ||
+      nullTypeKernelBelow(top.child)
+    case comet: CometPlan => comet.children.exists(nullTypeKernelBelow)
+    case _ => false
+  }
+
+  private val dispatcherClassName = classOf[CometScalaUDFCodegen].getName
+
+  /**
+   * Walks a serialized plan or expression for a dispatcher kernel that 
computes a NullType value:
+   * as its result, or anywhere in the tree it runs (a whole `coalesce` 
dispatched with an INT
+   * result can hold a `transform` with an `array<void>` result, which main 
refused).
+   */
+  private def runsNullTypeKernel(message: Message): Boolean = message match {
+    case udf: ExprOuterClass.JvmScalarUdf
+        if udf.getClassName == dispatcherClassName &&
+          (protoContainsNullType(udf.getReturnType) || 
kernelComputesNullType(udf)) =>
+      true
+    case _ =>
+      message.getAllFields.asScala.exists {
+        case (field, value) if field.getJavaType == 
FieldDescriptor.JavaType.MESSAGE =>
+          value match {
+            case values: java.util.List[_] =>
+              values.asScala.exists(v => 
runsNullTypeKernel(v.asInstanceOf[Message]))
+            case child: Message => runsNullTypeKernel(child)
+            case _ => false
+          }
+        case _ => false
+      }
+  }
+
+  /**
+   * The kernel's first argument is its closure-serialized expression (see
+   * `CometScalaUDF.emitJvmCodegenDispatch`). One that cannot be read counts 
as computing
+   * NullType.
+   */
+  private def kernelComputesNullType(udf: ExprOuterClass.JvmScalarUdf): 
Boolean =
+    udf.getArgsCount == 0 || !udf.getArgs(0).hasLiteral || {
+      val bytes = udf.getArgs(0).getLiteral.getBytesVal.toByteArray
+      try {
+        val loader = Option(Thread.currentThread().getContextClassLoader)
+          .getOrElse(classOf[Expression].getClassLoader)
+        SparkEnv.get.closureSerializer
+          .newInstance()
+          .deserialize[Expression](ByteBuffer.wrap(bytes), loader)
+          .exists(e => containsNullType(e.dataType))
+      } catch {
+        case NonFatal(_) => true
+      }
+    }
+
+  private def containsNullType(dt: DataType): Boolean = dt match {

Review Comment:
   Good idea. I'll record whether a kernel computes a `NullType` value while 
`emitJvmCodegenDispatch` serializes it, and carry that up to the operator, so 
the rule no longer deserializes closures or walks every message field. It still 
has to see expressions the serializer creates, TakeOrderedAndProject's deferred 
sort keys and projection, and kernels below broadcast, union and coalesce sinks 
and in shuffle keys, so I'll keep those covered rather than narrow the gate.
   
   On the issue: it's related to #6006 but not the same bug. #6006 tracks 
expressions that raise an error on rows Spark skips. Here a stateful operand 
advances on those rows and the answer is wrong without any error, e.g. `IF(id = 
0, CAST(NULL AS BIGINT), id) + monotonically_increasing_id()`. I'll confirm 
that on `main`, file it as its own issue linked to #6006, and reference it from 
the scaladoc. The rule only shields the shapes this PR newly admits; it doesn't 
fix the typed case.
   



##########
spark/src/test/scala/org/apache/comet/CometNullTypeCompositionSuite.scala:
##########
@@ -0,0 +1,919 @@
+/*
+ * 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.comet
+
+import scala.collection.mutable
+import scala.collection.mutable.ArrayBuffer
+import scala.util.{Failure, Success, Try}
+
+import org.apache.spark.sql.CometTestBase
+import org.apache.spark.sql.catalyst.expressions.{ArrayAppend, ArrayExcept, 
ArrayIntersect, ArrayRepeat, ArraysZip, ArrayUnion, Coalesce, CreateArray, 
Expression, JsonToStructs, MapFromArrays, Murmur3Hash, RuntimeReplaceable, 
Sequence, Size, StringToMap, XxHash64}
+import org.apache.spark.sql.catalyst.expressions.aggregate._
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.comet.CometProjectExec
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec, 
AdaptiveSparkPlanHelper}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.NullType
+
+import org.apache.comet.serde.{CodegenDispatchFallback, 
CometAggregateExpressionSerde, CometExpressionSerde, Compatible, 
QueryPlanSerde, SupportLevel, Unsupported}
+
+/**
+ * Cross-product sweep of the `NullType` shapes the JVM codegen dispatcher 
admits against the
+ * expressions that consume them.
+ *
+ * Three sweeps share one driver, `sweep`: producers under consumers, 
producers under operators,
+ * and producers nested in a container and then put under a serializing 
operator.
+ */
+class CometNullTypeCompositionSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {

Review Comment:
   Makes sense. The suite is out of this PR as of 267b5ce. In the follow-up 
I'll move the sweep into the fuzz suites, reuse `CometFuzzTestBase`'s matrix 
and add only the two-row batches, the spilling sort-based writer and AQE. 
Dispatch decisions will stay in the `expect_dispatch` fixtures, and the checks 
that each serde publishes its reasons will move to focused serde tests, since 
the fixtures don't cover those.
   



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