andygrove commented on code in PR #5734:
URL: https://github.com/apache/datafusion-comet/pull/5734#discussion_r3959422667


##########
spark/src/test/scala/org/apache/spark/sql/benchmark/CometInMemoryCacheBenchmark.scala:
##########
@@ -73,6 +73,13 @@ object CometInMemoryCacheBenchmark extends 
CometBenchmarkBase {
           "concat('str_c_', cast(id as string)) AS s3")
         .createOrReplaceTempView(sourceTable)
 
+      val buildBenchmark =
+        new Benchmark("in-memory cache materialization", numRows, output = 
output)
+      buildBenchmark.addCase("Comet cache writer") { _ =>
+        withCachedTable {}
+      }
+      buildBenchmark.run()

Review Comment:
   Two things about this case. `withCachedTable`'s `finally` block runs inside 
the timed lambda, so `uncacheTable` and `clearCache` are part of the 
measurement along with row generation, Arrow conversion and LZ4. And the thing 
this PR speeds up is a small share of that, which matches your own 1.926 s 
against 1.935 s.
   
   I measured `gatherColumnStats` on its own over an `OnHeapColumnVector` batch 
of 10000 rows and got 505-522 ms down to 134-138 ms with 1505 MB down to 4.8 MB 
allocated, so the win is real and bigger than you claimed, but nothing 
committed here would catch it regressing. Would you consider a case that times 
statistics collection alone, or at least moving the teardown out of the timed 
region?
   
   A one-line comment saying this case has no comparison arm on purpose would 
help too, since every other case in the file has two and the explanation for 
why a `DefaultCachedBatch` baseline is impossible lives down in 
`withCachedTable`.



##########
spark/src/test/scala/org/apache/comet/exec/CometInMemoryCacheSuite.scala:
##########
@@ -351,6 +352,82 @@ class CometInMemoryCacheSuite extends CometTestBase {
     }
   }
 
+  test("Comet in-memory cache statistics preserve typed bounds and null 
counts") {
+    withSQLConf(
+      CometConf.COMET_ENABLED.key -> "false",
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val types = Seq(
+        "boolean",
+        "tinyint",
+        "smallint",
+        "int",
+        "bigint",
+        "float",
+        "double",
+        "decimal(10,2)",
+        "decimal(38,2)",
+        "string",
+        "date",
+        "timestamp",
+        "timestamp_ntz",
+        "binary")
+      val expressions = types.zipWithIndex.map { case (dt, i) =>
+        val value = dt match {
+          case "date" => "date_add(DATE '2000-01-01', cast(v AS INT))"
+          case "timestamp" | "timestamp_ntz" =>
+            s"cast(date_add(DATE '2000-01-01', cast(v AS INT)) AS $dt)"
+          case "string" | "binary" => s"cast(concat('字', cast(v AS STRING)) AS 
$dt)"
+          case _ => s"cast(v AS $dt)"
+        }
+        s"$value AS c$i"
+      }
+      // Leading nulls, updates in both directions, duplicate values, all-null 
and single-value
+      // columns exercise initialization as well as the primitive and 
reference bounds loops.
+      Seq("(NULL), (2), (-3), (0), (1), (2), (NULL)", "(NULL), (NULL)", 
"(NULL), (1)").foreach {

Review Comment:
   These fixtures stay inside `[-3, 2]`, so the new `FloatType` and 
`DoubleType` arms never see a value where `Float.compare` and `<` disagree. One 
more fixture with `NaN`, `-0.0`, the infinities and the integral extremes would 
cover them.
   
   Worth knowing before you add it: the bounds stored here use `Float.compare` 
total ordering, where `-0.0 < 0.0` and NaN is greatest, while Spark's `min` and 
`max` treat `-0.0` and `0.0` as equal. A fixture holding both would fail the 
`expected` comparison because `java.lang.Double.equals` is bit-wise. Those two 
bounds are better asserted directly than against the aggregate.



##########
spark/src/test/scala/org/apache/comet/exec/CometInMemoryCacheSuite.scala:
##########
@@ -351,6 +352,82 @@ class CometInMemoryCacheSuite extends CometTestBase {
     }
   }
 
+  test("Comet in-memory cache statistics preserve typed bounds and null 
counts") {
+    withSQLConf(
+      CometConf.COMET_ENABLED.key -> "false",

Review Comment:
   Setting `COMET_ENABLED=false` here means the cached plan is row-based, so 
the batches reaching `gatherColumnStats` are Arrow-backed `CometVector`s from 
`rowToArrowBatchIter`. The numbers you measured came from Spark's 
`OnHeapColumnVector` going through `convertColumnarBatchToCachedBatch` instead, 
and that is also the path where the string `.copy()` is load bearing, since 
`getUTF8String` there returns a view into the vector's own `byteData` rather 
than an owned copy.
   
   The suite already has `withSparkColumnarCache` for exactly this shape. Would 
you mind running the same assertions through it as well?



##########
spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowCachedBatchSerializer.scala:
##########
@@ -92,20 +95,169 @@ class ArrowCachedBatchSerializer extends 
SimpleMetricsCachedBatchSerializer {
       val dt = attrs(c).dataType
       val col = batch.column(c)
       var r = 0
-      while (r < numRows) {
-        if (col.isNullAt(r)) {
-          nulls(c) += 1
-        } else if (tracksBounds(dt)) {
-          val value = readValue(col, dt, r)
-          if (lower(c) == null || compare(dt, value, lower(c)) < 0) {
-            lower(c) = value
+      var nullCount = 0
+      // Dispatch once per column and box only the final bounds. r == 
nullCount identifies
+      // the first non-null value, including when a column starts with nulls.
+      dt match {
+        case BooleanType =>
+          var min = false
+          var max = false
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getBoolean(r)
+              if (r == nullCount || JBoolean.compare(value, min) < 0) min = 
value
+              if (r == nullCount || JBoolean.compare(value, max) > 0) max = 
value
+            }
+            r += 1
           }
-          if (upper(c) == null || compare(dt, value, upper(c)) > 0) {
-            upper(c) = value
+          if (nullCount < numRows) {
+            lower(c) = min
+            upper(c) = max
+          }
+        case ByteType =>
+          var min = 0.toByte
+          var max = 0.toByte
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getByte(r)
+              if (r == nullCount || JByte.compare(value, min) < 0) min = value
+              if (r == nullCount || JByte.compare(value, max) > 0) max = value
+            }
+            r += 1
+          }
+          if (nullCount < numRows) {
+            lower(c) = min
+            upper(c) = max
+          }
+        case ShortType =>
+          var min = 0.toShort
+          var max = 0.toShort
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getShort(r)
+              if (r == nullCount || JShort.compare(value, min) < 0) min = value
+              if (r == nullCount || JShort.compare(value, max) > 0) max = value
+            }
+            r += 1
+          }
+          if (nullCount < numRows) {
+            lower(c) = min
+            upper(c) = max
+          }
+        case IntegerType | DateType =>
+          var min = 0
+          var max = 0
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getInt(r)
+              if (r == nullCount || JInteger.compare(value, min) < 0) min = 
value
+              if (r == nullCount || JInteger.compare(value, max) > 0) max = 
value
+            }
+            r += 1
+          }
+          if (nullCount < numRows) {
+            lower(c) = min
+            upper(c) = max
+          }
+        case LongType | TimestampType | TimestampNTZType =>
+          var min = 0L
+          var max = 0L
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getLong(r)
+              if (r == nullCount || JLong.compare(value, min) < 0) min = value
+              if (r == nullCount || JLong.compare(value, max) > 0) max = value
+            }
+            r += 1
+          }
+          if (nullCount < numRows) {
+            lower(c) = min
+            upper(c) = max
+          }
+        case FloatType =>
+          var min = 0.0f
+          var max = 0.0f
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getFloat(r)
+              if (r == nullCount || JFloat.compare(value, min) < 0) min = value
+              if (r == nullCount || JFloat.compare(value, max) > 0) max = value
+            }
+            r += 1
+          }
+          if (nullCount < numRows) {
+            lower(c) = min
+            upper(c) = max
+          }
+        case DoubleType =>
+          var min = 0.0d
+          var max = 0.0d
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getDouble(r)
+              if (r == nullCount || JDouble.compare(value, min) < 0) min = 
value
+              if (r == nullCount || JDouble.compare(value, max) > 0) max = 
value
+            }
+            r += 1
+          }
+          if (nullCount < numRows) {
+            lower(c) = min
+            upper(c) = max
+          }
+        case d: DecimalType =>
+          var min: Decimal = null
+          var max: Decimal = null
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getDecimal(r, d.precision, d.scale)
+              if (min == null || value.compare(min) < 0) min = value
+              if (max == null || value.compare(max) > 0) max = value
+            }
+            r += 1
+          }
+          lower(c) = min
+          upper(c) = max
+        case StringType =>
+          val ordering = TypeUtils.getInterpretedOrdering(dt)
+          var min: UTF8String = null
+          var max: UTF8String = null
+          while (r < numRows) {
+            if (col.isNullAt(r)) {
+              nullCount += 1
+            } else {
+              val value = col.getUTF8String(r)
+              // Compare the UTF-8 bytes directly, without allocating getBytes 
arrays.
+              // Borrow the value for comparison, but retain owned copies of 
the bounds.
+              if (min == null || ordering.compare(value, min) < 0) min = 
value.copy()
+              if (max == null || ordering.compare(value, max) > 0) max = 
value.copy()
+            }
+            r += 1
+          }
+          lower(c) = min
+          upper(c) = max
+        case _ =>

Review Comment:
   `tracksBounds` decides what `buildFilter` pushes down, and the match above 
decides what actually gets bounds. Nothing links the two lists, and they are 
two hundred lines apart. If someone adds a type to `tracksBounds` without 
adding an arm here, the pushed predicate compares against null bounds and 
prunes every batch, which drops rows silently rather than failing.
   
   Your new test pins that binary bounds stay unset, which is today's snapshot 
rather than the implication. Would an `assert(!tracksBounds(other))` in this 
arm, or a test that walks the types `tracksBounds` accepts and requires 
non-null bounds for each, be worth adding?



##########
spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowCachedBatchSerializer.scala:
##########
@@ -92,20 +95,169 @@ class ArrowCachedBatchSerializer extends 
SimpleMetricsCachedBatchSerializer {
       val dt = attrs(c).dataType
       val col = batch.column(c)
       var r = 0
-      while (r < numRows) {
-        if (col.isNullAt(r)) {
-          nulls(c) += 1
-        } else if (tracksBounds(dt)) {
-          val value = readValue(col, dt, r)
-          if (lower(c) == null || compare(dt, value, lower(c)) < 0) {
-            lower(c) = value
+      var nullCount = 0
+      // Dispatch once per column and box only the final bounds. r == 
nullCount identifies
+      // the first non-null value, including when a column starts with nulls.
+      dt match {
+        case BooleanType =>

Review Comment:
   The repetition is the right call here, since pushing these arms back through 
`Any` callbacks would reintroduce the boxing you are removing. A lighter shape 
would be one small private method per type returning `(min, max, nullCount)`, 
which leaves `gatherColumnStats` as a readable dispatch, drops the shared `var 
r` and `var nullCount`, and removes the seven copies of the `if (nullCount < 
numRows)` epilogue. That trades two boxes per row for three per column per 
batch.
   
   Three smaller things either way. The primitive arms use `nullCount < 
numRows` to mean "no values" while the decimal and string arms use a null 
sentinel, so it would read better to pick one. `Array.fill[Int](numCols)(0)` 
above is now dead initialization, since every arm assigns `nulls(c)` 
unconditionally at the end of the loop. And `// Compare the UTF-8 bytes 
directly, without allocating getBytes arrays` in the string arm describes the 
code this replaces rather than the code that is there; the borrow-and-copy line 
under it is the part a future reader actually needs.



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