andygrove opened a new pull request, #6715:
URL: https://github.com/apache/datafusion-comet/pull/6715

   ## Which issue does this PR close?
   
   Closes #6704.
   
   ## Rationale for this change
   
   Spark's `HandleNullInputsForUDF` rule wraps a Scala UDF that has a primitive 
parameter over a nullable input as `if (isnull(a) or ...) null else 
f(knownnotnull(a), ...)`. The serde sent only the `ScalaUDF` to the codegen 
dispatcher and ran the `If` natively, as a `CASE` that filters the batch for 
each branch and merges the results. @mbutrovich measured on #6697 that this 
guard is most of the gap between a dispatched UDF and a vectorized one.
   
   ## What changes are included in this PR?
   
   - `CometIf` offers each `If` to a new `CometScalaUDF.emitNullGuardDispatch` 
first. It matches only the shape the rule builds: the predicate has to equal 
the `Or` of `IsNull` checks rebuilt from the UDF's `KnownNotNull` arguments at 
primitive positions, and the true branch has to be `null`, or `false`, which 
`ReplaceNullWithFalseInPredicate` puts there in filters, join conditions and 
the predicates of conditionals. The whole `If` then goes to the dispatcher, and 
the null check becomes a branch in the kernel's loop. The predicate only tests 
the UDF's own arguments, so the kernel runs nothing it would not run for the 
UDF alone apart from the guard's `if`, `or` and `isnull`, and those run as 
Spark's own code.
   - A nondeterministic argument does not match, because `semanticEquals` is 
false for it, so that guard stays native.
   - `emitJvmCodegenDispatch` is split into a private `tryJvmCodegenDispatch` 
that returns the reason it cannot dispatch instead of recording it. When the 
dispatcher refuses the whole guard, `CometIf` converts it natively as before 
and the UDF is dispatched on its own. A refused guard therefore neither takes 
the operator to Spark nor records an `if:` fallback reason for an expression 
the query never wrote. The one case I found where the guard is refused and the 
UDF alone is not is `spark.sql.codegen.maxFields`, because `canHandle` counts 
the guarded column twice.
   
   Some guards Spark produces still run natively, and could be follow-ups: the 
outer guard of nested primitive UDFs, whose predicate the optimizer rewrites to 
`isnull(a)`; `f(c) + 1`, where the optimizer pushes `+ 1` into the branch; and 
guards users write around UDFs with boxed parameters.
   
   I measured `SELECT max(f(c))` and `SELECT count(*) ... WHERE g(c)` over 4M 
rows of a Parquet `bigint` column with a tenth of the rows null, with `f = (x: 
Long) => x + 1` and `g = (x: Long) => x % 3 == 0`. The numbers are milliseconds 
above `SELECT max(c)` in the same run, each the best of at least 10 iterations, 
averaged over two interleaved runs of each build (before, after, after, before):
   
   | Batch size | `max(f(c))` before | after | `WHERE g(c)` before | after |
   | --- | --- | --- | --- | --- |
   | 1024 | 93.5 | 72.5 | 85.5 | 72 |
   | 8192 | 42 | 26.5 | 34 | 20.5 |
   | 65536 | 32 | 18 | 25.5 | 10.5 |
   
   That is Spark 4.1 with a release build on an Apple M3 Max. The benchmark is 
not part of this PR; its source is below.
   
   <details>
   <summary>Benchmark source</summary>
   
   ```scala
   package org.apache.spark.sql.benchmark
   
   import org.apache.spark.benchmark.Benchmark
   
   import org.apache.comet.{CometConf, ExtendedExplainInfo}
   
   object UdfNullGuardBenchmark extends CometBenchmarkBase {
   
     private val Rows = 4 * 1024 * 1024
   
     private val cases = Seq(
       "max(c), no function" -> "SELECT max(c) FROM parquetV1Table",
       "max(c + 1), native" -> "SELECT max(c + 1) FROM parquetV1Table",
       "max(prim_add_one(c)), dispatched" -> "SELECT max(prim_add_one(c)) FROM 
parquetV1Table",
       "count where prim_mult3(c), dispatched" ->
         "SELECT count(*) FROM parquetV1Table WHERE prim_mult3(c)")
   
     override def runCometBenchmark(mainArgs: Array[String]): Unit = {
       spark.udf.register("prim_add_one", (x: Long) => x + 1)
       spark.udf.register("prim_mult3", (x: Long) => x % 3 == 0)
       withTempPath { dir =>
         withTempTable(tbl) {
           spark.range(Rows).createOrReplaceTempView(tbl)
           prepareTable(dir, spark.sql(s"SELECT IF(id % 10 = 0, NULL, id * 
7919) AS c FROM $tbl"))
           cases.foreach { case (label, q) =>
             withSQLConf(configs(8192): _*) {
               val df = spark.sql(q)
               val result = df.collect().head
               val plan = df.queryExecution.executedPlan
               val info = new ExtendedExplainInfo()
               // scalastyle:off println
               println(
                 s"$label: result=$result 
native=${info.getNativeExpressions(plan)} " +
                   s"dispatched=${info.getCodegenDispatchExpressions(plan)} " +
                   s"fallback=${info.getFallbackReasons(plan)}")
               // scalastyle:on println
             }
           }
           Seq(1024, 8192, 65536).foreach { batchSize =>
             val benchmark = new Benchmark(
               s"null guard, batch size $batchSize",
               Rows,
               minNumIters = 10,
               output = output)
             cases.foreach { case (label, q) =>
               benchmark.addCase(label) { _ =>
                 withSQLConf(configs(batchSize): _*) {
                   spark.sql(q).noop()
                 }
               }
             }
             benchmark.run()
           }
         }
       }
     }
   
     private def configs(batchSize: Int): Seq[(String, String)] = Seq(
       CometConf.COMET_ENABLED.key -> "true",
       CometConf.COMET_EXEC_ENABLED.key -> "true",
       CometConf.COMET_BATCH_SIZE.key -> batchSize.toString,
       CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "true")
   }
   ```
   
   </details>
   
   ## How are these changes tested?
   
   New tests in `CometCodegenSuite`:
   
   - Single-argument, multi-argument (`or`), expression-argument and 
aggregate-input guards run in the kernel, checked with 
`checkSparkAnswerAndImpl` so that `if` and `isnull` must not be native.
   - The `false` form, in a filter and in the predicate of an `IF` the query 
wrote.
   - A nondeterministic UDF that counts its calls is called only for non-null 
rows, matching Spark.
   - With `maxFields=2` the guard stays a native `if` around the dispatched UDF 
and the projection stays in Comet. With the dispatcher off, the fallback reason 
names the UDF and none is recorded for `if`.
   - Shapes that do not match stay native: a predicate on another column, an 
extra check, a non-null branch, and a nondeterministic argument.
   
   Many existing dispatcher tests use primitive UDFs over nullable columns and 
now take this path too.
   
   I checked that the tests fail without the change, and against two simpler 
designs. Dispatching the guard through the tagging `emitJvmCodegenDispatch` 
with no native retry makes the projection fall back under `maxFields=2`. 
Keeping the retry but recording the failed attempt adds an `if: ...=false` 
reason with the dispatcher off. Dropping the `false` form, or comparing with 
`==` instead of `semanticEquals`, also fails a test.
   
   Local runs:
   
   - `CometCodegenSuite` passes on Spark 3.4, 3.5 and 4.1.
   - `CometCodegenFuzzSuite`, `CometCodegenHOFSuite`, 
`CometCodegenSourceSuite`, `CometScalaUDFClassLoaderSuite`, 
`CometUdfBridgeSuite`, `CometGenerateExecSuite`, 
`CometNativePositionalRoundRobinSuite` and 
`RevertNativeForTransitionHeavyStagesSuite` pass on 4.1.
   - Semantic scalafix, scalastyle and spotless on 3.5 are clean.
   
   I did not run the 4.0 and 4.2 profiles, `CometNativeUdfSuite`, which needs a 
library that wasn't built locally, or Spark's SQL 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]

Reply via email to