viirya commented on code in PR #7225:
URL: https://github.com/apache/datafusion-comet/pull/7225#discussion_r4372554728


##########
spark/src/test/scala/org/apache/comet/exec/CometInMemoryCacheSuite.scala:
##########
@@ -2758,6 +2761,222 @@ class CometInMemoryCacheSuite extends CometTestBase {
     assert(zstdSize == plainSize, s"the relation size should not depend on the 
codec: $sizes")
   }
 
+  test("Comet in-memory cache drops the rows before a sliced aggregate 
output") {

Review Comment:
   "drops the rows before a sliced aggregate output" reads as if the cache 
loses rows. What the test checks is that the bytes of the earlier rows are not 
stored. How about "does not store the bytes before a sliced aggregate output"?



##########
docs/source/contributor-guide/ffi.md:
##########
@@ -247,9 +247,10 @@ rows before the slice still at the front of the data 
buffer, or of the child vec
 through the offsets is correct, but Arrow Java 18.3's 
`VectorSchemaRootAppender` assumes offsets that start at zero and
 folds those rows into the first value it appends, and `VectorUnloader` writes 
them into an IPC stream. So
 `Utils.serializeBatches` rebases a broadcast batch's offsets with 
`VectorSchemaRoot.slice` before
-`coalesceBroadcastBatches` appends it 
([#6779](https://github.com/apache/datafusion-comet/issues/6779)), and code that
-appends imported vectors has to rebase them too. The in-memory cache 
serializer still writes the earlier rows
-([#6995](https://github.com/apache/datafusion-comet/issues/6995)).
+`coalesceBroadcastBatches` appends it 
([#6779](https://github.com/apache/datafusion-comet/issues/6779)), and
+`CachedBatchIpc.serialize` does the same before the in-memory cache writes a 
batch, so that a cached slice neither
+stores nor reports the rows before it 
([#6995](https://github.com/apache/datafusion-comet/issues/6995)). Code that
+appends or writes imported vectors has to rebase them too.

Review Comment:
   There is one more writer that doesn't follow this yet. In 
`CometArrowPythonRunnerBase.withInputBatchRange`, the `offset == 0 && length == 
numRows` branch passes the batch's own vectors to `serializeBatch`, which 
unloads them with `VectorUnloader`. So when a sliced aggregate output feeds 
`mapInArrow` or `mapInPandas`, the bytes of the earlier slices go to the Python 
worker too. The values still come out right because pyarrow honors the offsets, 
but it is the same inflation this PR fixes for the cache. The partial-range 
branch is fine, since `column.slice` goes through `TransferPair` and rebases.
   
   Could that branch use `withRebasedOffsets` as well? It would need wider 
visibility than `private[comet]`, because the runner is in 
`org.apache.spark.sql.execution.python`. If you'd rather keep this PR to the 
cache, could you file an issue and link it here?



##########
spark/src/test/scala/org/apache/comet/exec/CometInMemoryCacheSuite.scala:
##########
@@ -2758,6 +2761,222 @@ class CometInMemoryCacheSuite extends CometTestBase {
     assert(zstdSize == plainSize, s"the relation size should not depend on the 
codec: $sizes")
   }
 
+  test("Comet in-memory cache drops the rows before a sliced aggregate 
output") {
+    // A grouped aggregate emits its groups as batch-size slices of one batch, 
and a slice of a
+    // string array reaches the JVM with offsets that start past zero and the 
rows before it still
+    // at the front of its data buffer. Written as it stands, the k-th slice 
is cached with the
+    // bytes of the k - 1 slices before it, and the relation reports them in 
its size.
+    val rows = 1000L
+    withTempPath { dir =>
+      withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+        SQLConf.SHUFFLE_PARTITIONS.key -> "1",
+        CometConf.COMET_BATCH_SIZE.key -> "100",
+        CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key -> "true") {
+        spark.catalog.clearCache()
+        spark
+          .range(0, rows, 1, 1)
+          .selectExpr("concat('key-', lpad(cast(id AS string), 12, '0')) AS k")
+          .write
+          .parquet(dir.getCanonicalPath)
+
+        def cachedSize(name: String, df: DataFrame): (Long, Long) = {
+          df.createOrReplaceTempView(name)
+          spark.catalog.cacheTable(name)
+          assert(spark.table(name).count() == rows)
+          val relation = spark.sharedState.cacheManager
+            .lookupCachedData(spark.table(name))
+            .get
+            .cachedRepresentation
+          val batches = relation.cacheBuilder.cachedColumnBuffers
+          val classes = 
batches.map(_.getClass.getSimpleName).distinct().collect()
+          assert(classes.sameElements(Array("CometCachedBatch")))
+          (relation.computeStats().sizeInBytes.toLong, batches.count())
+        }
+
+        try {
+          val scanned = spark.read.parquet(dir.getCanonicalPath)
+          val distinct = scanned.distinct()
+          assert(
+            
distinct.queryExecution.executedPlan.exists(_.isInstanceOf[CometHashAggregateExec]))
+
+          // The aggregate is cached first, so that it reads the Parquet scan 
and not a cached copy
+          // of it. It has to emit more than one slice for the bytes before a 
slice to matter.
+          val (distinctSize, distinctBatches) = cachedSize("distinct_cache", 
distinct)
+          assert(distinctBatches > 1, s"the aggregate emitted $distinctBatches 
batches")
+          val (scannedSize, _) = cachedSize("scanned_cache", scanned)
+          // The same rows, so the same bytes of string data. A slice that 
carried the bytes before
+          // it would make this about 5 times larger, as the 10 slices average 
5.5 slices of data.
+          assert(
+            distinctSize < scannedSize * 1.1,
+            s"the aggregate's cache is $distinctSize bytes, a scan's of the 
same rows $scannedSize")
+          checkAnswer(spark.table("distinct_cache"), scanned)
+        } finally {
+          spark.catalog.clearCache()
+        }
+      }
+    }
+  }
+
+  /** The bytes the cache writer stores for a batch of `vector` alone. */
+  private def payloadSize(vector: FieldVector, codec: CompressionCodec): Long 
= {
+    val column = CometVector.getVector(vector, new CDataDictionaryProvider)
+    val batch = new ColumnarBatch(Array[ColumnVector](column), 
vector.getValueCount)
+    CometCachedBatchHelper.payloadSize(
+      CometCachedBatchHelper.cachedBatch(
+        CometCachedBatchHelper.serialize(batch, codec, CometArrowAllocator),
+        batch.numRows()))
+  }
+
+  /**
+   * The field node, validity and offset buffers of the rows of `full` from 
`skip` on, which the C
+   * Data import hands the JVM for a native slice: the offsets start past 
zero, over the buffers
+   * of all of `full`'s rows.
+   */
+  private def nativeSlice(
+      full: FieldVector,
+      skip: Int,
+      nulls: Int): (ArrowFieldNode, Seq[ArrowBuf]) = {
+    val length = full.getValueCount - skip
+    (
+      new ArrowFieldNode(length.toLong, nulls.toLong),
+      Seq(
+        full.getValidityBuffer.slice(skip / 8L, (length + 7) / 8L),
+        full.getOffsetBuffer.slice(skip * 4L, (length + 1) * 4L)))
+  }
+
+  private def assertSliceWritesAsOwnValues(sliced: FieldVector, fresh: 
FieldVector): Unit = {
+    assert(sliced.getOffsetBuffer.getInt(0) > 0, "the test needs offsets past 
zero")
+    val codec = CometCachedBatchHelper.compressionCodec("none")
+    assert(payloadSize(sliced, codec) == payloadSize(fresh, codec))
+  }
+
+  private val sliceSkip = 8
+
+  private def stringVector(allocator: BufferAllocator, values: Seq[String]): 
VarCharVector = {
+    val vector = new VarCharVector("s", allocator)
+    vector.allocateNew()
+    values.zipWithIndex.foreach {
+      case (null, i) => vector.setNull(i)
+      case (v, i) => vector.setSafe(i, v.getBytes(StandardCharsets.UTF_8))
+    }
+    vector.setValueCount(values.length)
+    vector
+  }
+
+  private def slicedStringVector(
+      allocator: BufferAllocator,
+      values: Seq[String]): VarCharVector = {
+    val full = stringVector(allocator, values)
+    try {
+      val (node, buffers) = nativeSlice(full, sliceSkip, 
values.drop(sliceSkip).count(_ == null))
+      val vector = new VarCharVector("s", allocator)
+      vector.loadFieldBuffers(node, (buffers :+ full.getDataBuffer).asJava)
+      vector
+    } finally {
+      full.close()
+    }
+  }
+
+  test("Comet in-memory cache writes a sliced list of strings as its own 
values") {
+    // A list slice has offsets past zero in two places: its own, and its 
child's once the child
+    // is sliced too. Only the list's start past zero here, over a child that 
holds every element,
+    // which is how a native slice of a list reaches the JVM.
+    val values: Seq[Seq[String]] = (0 until 24).map { i =>
+      if (i % 5 == 0) null else (0 until i % 4).map(j => s"element-$i-$j")
+    }
+
+    def lists(allocator: BufferAllocator, vs: Seq[Seq[String]]): ListVector = {
+      val list = ListVector.empty("l", allocator)
+      val child = list
+        
.addOrGetVector[VarCharVector](FieldType.nullable(ArrowType.Utf8.INSTANCE))
+        .getVector
+      child.allocateNew()
+      var position = 0
+      vs.zipWithIndex.foreach { case (elements, i) =>
+        if (elements == null) {
+          list.setNull(i)
+        } else {
+          list.startNewValue(i)
+          elements.foreach { e =>
+            child.setSafe(position, e.getBytes(StandardCharsets.UTF_8))
+            position += 1
+          }
+          list.endValue(i, elements.length)
+        }
+      }
+      child.setValueCount(position)
+      list.setValueCount(vs.length)
+      list
+    }
+
+    def sliced(allocator: BufferAllocator): ListVector = {
+      val full = lists(allocator, values)
+      try {
+        val (node, buffers) =
+          nativeSlice(full, sliceSkip, values.drop(sliceSkip).count(_ == null))
+        val list =
+          new ListVector("l", allocator, 
FieldType.nullable(ArrowType.List.INSTANCE), null)
+        list.initializeChildrenFromFields(full.getField.getChildren)
+        list.loadFieldBuffers(node, buffers.asJava)
+        val fullChild = full.getDataVector.asInstanceOf[VarCharVector]
+        list.getDataVector.loadFieldBuffers(
+          new ArrowFieldNode(fullChild.getValueCount.toLong, 0L),
+          Seq(
+            fullChild.getValidityBuffer,
+            fullChild.getOffsetBuffer,
+            fullChild.getDataBuffer).asJava)
+        list
+      } finally {
+        full.close()
+      }
+    }
+
+    val allocator = CometArrowAllocator.newChildAllocator("sliced-list-write", 
0, Long.MaxValue)
+    try {
+      val vector = sliced(allocator)
+      val fresh = lists(allocator, values.drop(sliceSkip))
+      try assertSliceWritesAsOwnValues(vector, fresh)
+      finally {
+        fresh.close()
+        vector.close()
+      }
+    } finally {
+      allocator.close()
+    }
+  }
+
+  test("Comet in-memory cache writes a sliced string vector as its own values 
and releases it") {

Review Comment:
   I think this test can go. The success half is covered end to end by the 
aggregate test above. The failure half exercises the `finally` in 
`withRebasedOffsets`, which is now shared and already covered by `UtilsSuite`'s 
"serializeBatches closes the slice it writes when the write fails". Removing 
it, plus moving the list test, would also let `nativeSlice`, 
`slicedStringVector`, `stringVector` and `assertSliceWritesAsOwnValues` go, 
which removes about half of the new test code.



##########
spark/src/test/scala/org/apache/comet/exec/CometInMemoryCacheSuite.scala:
##########
@@ -2758,6 +2761,222 @@ class CometInMemoryCacheSuite extends CometTestBase {
     assert(zstdSize == plainSize, s"the relation size should not depend on the 
codec: $sizes")
   }
 
+  test("Comet in-memory cache drops the rows before a sliced aggregate 
output") {
+    // A grouped aggregate emits its groups as batch-size slices of one batch, 
and a slice of a
+    // string array reaches the JVM with offsets that start past zero and the 
rows before it still
+    // at the front of its data buffer. Written as it stands, the k-th slice 
is cached with the
+    // bytes of the k - 1 slices before it, and the relation reports them in 
its size.
+    val rows = 1000L
+    withTempPath { dir =>
+      withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+        SQLConf.SHUFFLE_PARTITIONS.key -> "1",
+        CometConf.COMET_BATCH_SIZE.key -> "100",
+        CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key -> "true") {
+        spark.catalog.clearCache()
+        spark
+          .range(0, rows, 1, 1)
+          .selectExpr("concat('key-', lpad(cast(id AS string), 12, '0')) AS k")
+          .write
+          .parquet(dir.getCanonicalPath)
+
+        def cachedSize(name: String, df: DataFrame): (Long, Long) = {
+          df.createOrReplaceTempView(name)
+          spark.catalog.cacheTable(name)
+          assert(spark.table(name).count() == rows)
+          val relation = spark.sharedState.cacheManager
+            .lookupCachedData(spark.table(name))
+            .get
+            .cachedRepresentation
+          val batches = relation.cacheBuilder.cachedColumnBuffers
+          val classes = 
batches.map(_.getClass.getSimpleName).distinct().collect()
+          assert(classes.sameElements(Array("CometCachedBatch")))
+          (relation.computeStats().sizeInBytes.toLong, batches.count())
+        }
+
+        try {
+          val scanned = spark.read.parquet(dir.getCanonicalPath)
+          val distinct = scanned.distinct()
+          assert(
+            
distinct.queryExecution.executedPlan.exists(_.isInstanceOf[CometHashAggregateExec]))
+
+          // The aggregate is cached first, so that it reads the Parquet scan 
and not a cached copy
+          // of it. It has to emit more than one slice for the bytes before a 
slice to matter.
+          val (distinctSize, distinctBatches) = cachedSize("distinct_cache", 
distinct)
+          assert(distinctBatches > 1, s"the aggregate emitted $distinctBatches 
batches")
+          val (scannedSize, _) = cachedSize("scanned_cache", scanned)
+          // The same rows, so the same bytes of string data. A slice that 
carried the bytes before
+          // it would make this about 5 times larger, as the 10 slices average 
5.5 slices of data.
+          assert(
+            distinctSize < scannedSize * 1.1,
+            s"the aggregate's cache is $distinctSize bytes, a scan's of the 
same rows $scannedSize")
+          checkAnswer(spark.table("distinct_cache"), scanned)
+        } finally {
+          spark.catalog.clearCache()
+        }
+      }
+    }
+  }
+
+  /** The bytes the cache writer stores for a batch of `vector` alone. */
+  private def payloadSize(vector: FieldVector, codec: CompressionCodec): Long 
= {
+    val column = CometVector.getVector(vector, new CDataDictionaryProvider)
+    val batch = new ColumnarBatch(Array[ColumnVector](column), 
vector.getValueCount)
+    CometCachedBatchHelper.payloadSize(
+      CometCachedBatchHelper.cachedBatch(
+        CometCachedBatchHelper.serialize(batch, codec, CometArrowAllocator),
+        batch.numRows()))
+  }
+
+  /**
+   * The field node, validity and offset buffers of the rows of `full` from 
`skip` on, which the C
+   * Data import hands the JVM for a native slice: the offsets start past 
zero, over the buffers
+   * of all of `full`'s rows.
+   */
+  private def nativeSlice(
+      full: FieldVector,
+      skip: Int,
+      nulls: Int): (ArrowFieldNode, Seq[ArrowBuf]) = {
+    val length = full.getValueCount - skip
+    (
+      new ArrowFieldNode(length.toLong, nulls.toLong),
+      Seq(
+        full.getValidityBuffer.slice(skip / 8L, (length + 7) / 8L),
+        full.getOffsetBuffer.slice(skip * 4L, (length + 1) * 4L)))
+  }
+
+  private def assertSliceWritesAsOwnValues(sliced: FieldVector, fresh: 
FieldVector): Unit = {
+    assert(sliced.getOffsetBuffer.getInt(0) > 0, "the test needs offsets past 
zero")
+    val codec = CometCachedBatchHelper.compressionCodec("none")
+    assert(payloadSize(sliced, codec) == payloadSize(fresh, codec))
+  }
+
+  private val sliceSkip = 8
+
+  private def stringVector(allocator: BufferAllocator, values: Seq[String]): 
VarCharVector = {
+    val vector = new VarCharVector("s", allocator)
+    vector.allocateNew()
+    values.zipWithIndex.foreach {
+      case (null, i) => vector.setNull(i)
+      case (v, i) => vector.setSafe(i, v.getBytes(StandardCharsets.UTF_8))
+    }
+    vector.setValueCount(values.length)
+    vector
+  }
+
+  private def slicedStringVector(
+      allocator: BufferAllocator,
+      values: Seq[String]): VarCharVector = {
+    val full = stringVector(allocator, values)
+    try {
+      val (node, buffers) = nativeSlice(full, sliceSkip, 
values.drop(sliceSkip).count(_ == null))
+      val vector = new VarCharVector("s", allocator)
+      vector.loadFieldBuffers(node, (buffers :+ full.getDataBuffer).asJava)
+      vector
+    } finally {
+      full.close()
+    }
+  }
+
+  test("Comet in-memory cache writes a sliced list of strings as its own 
values") {

Review Comment:
   This is the test that adds new coverage, the rebase recursing into the 
list's child. That is a property of `withRebasedOffsets` rather than of the 
cache, though, so it would fit better in `UtilsSuite`, tested against the 
helper directly. That would cover the broadcast path too, and `slicedStrings` 
already lives there, so `nativeSlice` wouldn't need to become a third copy of 
the native-slice construction.
   
   Also, the comment on L2883 seems to be missing a word. I think it means 
"Only the list's offsets start past zero here".



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