Skip to content

feat: add experimental native support for in-memory cache, disabled by default - #5051

Open
andygrove wants to merge 24 commits into
apache:mainfrom
andygrove:feat/native-in-memory-cache
Open

feat: add experimental native support for in-memory cache, disabled by default#5051
andygrove wants to merge 24 commits into
apache:mainfrom
andygrove:feat/native-in-memory-cache

Conversation

@andygrove

@andygrove andygrove commented Jul 27, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #2391.

Rationale for this change

This PR continues the work started by @pchintar in #4591, who is no longer able to work on it. All credit for the original implementation goes to them. This branch is based on their branch with latest apache/main merged in, and picks up the remaining review feedback and CI failures.

Comet currently has limited support for Spark's in-memory cache.

When a table is cached and later read, the cached data cannot be consumed directly by Comet operators. Instead, the execution plan falls back to Spark's cache scan path and introduces an additional CometSparkColumnarToColumnar conversion before execution can continue in Comet.

This extra conversion adds overhead to cached table scans and prevents cached data from remaining on a native Comet execution path.

What changes are included in this PR?

A native cache path for in-memory cached tables behind a new configuration:

spark.comet.exec.inMemoryCache.enabled

When enabled:

  • Cached data is stored using a Comet-specific cache serializer, one compressed Arrow IPC stream per column, so a scan decodes only the columns it projects.
  • Cached data is represented as CometCachedBatch.
  • Cached tables are scanned using CometInMemoryTableScanExec.
  • Cached data can be consumed directly by Comet operators without introducing a CometSparkColumnarToColumnar conversion.
  • Per-batch column statistics (lower bound, upper bound, null count, row count) are written in the format expected by SimpleMetricsCachedBatchSerializer, so Spark's buildFilter can prune cached batches before they are decoded.

When disabled:

  • Spark's existing cache serializer continues to be used.
  • Existing cache scan behavior is preserved.

How are these changes tested?

CometInMemoryCacheSuite covers:

  • Comet-native cache scan over CometCachedBatch
  • Fallback behavior when native cache support is disabled
  • Multi-partition cached tables
  • Empty cached tables
  • Projection-only cache reads
  • Shuffle execution after cached table scans
  • Stats-based batch pruning
  • Empty projection scans (SELECT count(*))
  • Floating-point pruning with NaN values
  • Fallback read path for Spark DefaultCachedBatch
  • One stream stored per cached column, each carrying data
  • A projected read decoding only the projected columns
  • A row-count-only read decoding no columns at all
  • The scan asking for one cheap column, not the whole schema, when a query needs only the row count
  • Per-column sizes reported in the statistics row

The two projection tests corrupt the streams a read must not touch, so they assert on what was decoded rather than on timings, and each is checked to fail against the previous single-stream format.

CometInMemoryCacheKryoSuite covers spark.kryo.registrationRequired=true, which makes Kryo reject any unregistered class. It runs with KryoSerializer and spark.kryo.registrator=org.apache.comet.CometKryoRegistrator and asserts:

  • A CometCachedBatch round-trips at DISK_ONLY and MEMORY_AND_DISK_SER, over a relation carrying every type whose bounds the statistics row records, read back through a predicate so the bounds themselves go through Kryo
  • A native broadcast survives, which fails on main today independently of this feature: CometBroadcastExchangeExec broadcasts an Array[ChunkedByteBuffer], and Spark registers ChunkedByteBuffer but not an array of them
  • The delegated DefaultCachedBatch path survives, which needs Comet to register that class on Spark 3.4, 3.5 and 4.0 because Spark only registers it itself from 4.1

spark.kryo.registrator is read when SparkEnv builds the serializer, which is before CometDriverPlugin runs, so Comet cannot set it for the user the way it sets spark.sql.cache.serializer. It is documented on spark.comet.exec.inMemoryCache.enabled and CometDriverPlugin warns at startup when Kryo, registrationRequired, and a missing registrator are combined.

CometInMemoryCacheBenchmark compares the native cache scan against the fallback read path over the same Comet-written cache, for a repeated full scan, a selective filter exercising cache pruning, and row-count-only (0 of 6 columns), narrow (1 of 6) and full (6 of 6) projections.

Note what this benchmark does and does not isolate. Comet execution is on in both cases, so the aggregation runs on Comet either way and only the cache-scan boundary moves: disabled, Spark's InMemoryTableScanExec feeds those same Comet operators through a CometSparkColumnarToColumnar bridge; enabled, CometInMemoryTableScan feeds them directly. So these numbers measure "keep the cached scan native" against "fall back to a Spark cache scan and convert", not Comet execution against Spark execution.

Neither case is a baseline for Spark's own cache format either. spark.sql.cache.serializer is a static config, so a single session cannot also materialize a DefaultCachedBatch to compare against; both cases read the same Comet-written CometCachedBatch. Earlier revisions of this description presented these numbers as a speedup over Spark's cache, which they are not, and then described the disabled case as "Spark execution over CometCachedBatch", which reads as a Spark-execution baseline it also never was.

Benchmark results (release build, Apple M3 Ultra, JDK 17, Spark 3.5 profile, 5M-row cached table):

Repeated full scan (SELECT sum(id), sum(k), sum(v))

Case Best (ms) Avg (ms) Relative
Spark cache scan + CometSparkColumnarToColumnar 180 201 1.0x
CometInMemoryTableScan 121 128 1.5x

Selective filter (WHERE id >= 4500000 AND id < 4750000)

Case Best (ms) Avg (ms) Relative
Spark cache scan + CometSparkColumnarToColumnar 46 53 1.0x
CometInMemoryTableScan 42 48 1.1x

Comparison against Spark's cache format

Measured outside the benchmark harness, using two sessions so that one materializes a DefaultCachedBatch and the other a CometCachedBatch. 5M rows, 6 columns (3 longs, 3 strings), min of 5 runs after a discarded warm-up:

Read shape Spark cache Comet cache Ratio
count(*) (0 of 6 columns) 62 ms 77 ms 1.2x slower
1 of 6 columns 105 ms 57 ms 1.8x faster
3 of 6 columns 339 ms 204 ms 1.7x faster
6 of 6 columns 375 ms 448 ms 1.2x slower
Materialize cache 3497 ms 1563 ms 2.2x faster
Cache footprint 309 MiB 61 MiB 5x smaller

The first revision of this PR stored each cached batch as a single Arrow IPC stream covering every column, which had to be inflated in full before any projection could be applied. Narrow reads were then several times slower than Spark's cache, which stores columns separately. Reported in #5051 (comment) and tracked as #5484, now fixed here:

Read shape Single stream Per column
count(*) 241 ms 77 ms
1 of 6 columns 592 ms 57 ms
3 of 6 columns 655 ms 204 ms
6 of 6 columns 448 ms 448 ms

Per-column framing costs an Arrow schema block and compression framing per column per batch, and gives up cross-column compression. Footprint grows 2.5% at 6 columns and 32% at 60, where the cached relation is still 22x smaller than Spark's format (107 MiB versus 2369 MiB).

Remaining gap

Reads that feed Spark operators rather than Comet ones are still slower than Spark's cache, by roughly 1.7x to 2.5x depending on projection width. Those pay a row conversion that Spark's cache format avoids with a codegen'd path over its own layout. That is a separate cost from the projection issue fixed here and is not addressed in this PR. It is why the feature stays off by default and why the limitation is documented on spark.comet.exec.inMemoryCache.enabled.

Performance follow-ups are tracked in #4781. AQE test coverage is tracked in #5245.

InMemoryRelation resolves spark.sql.cache.serializer once per JVM and
memoizes the instance in a static field. Test suites share a forked JVM,
so whichever suite caches a table first pins the serializer for every
suite that follows. CometInMemoryCacheSuite runs after CometExecSuite in
the exec group, so its configured serializer was ignored, every cached
batch was a DefaultCachedBatch, and 9 of its 11 tests failed on all Spark
profiles.

Reset the memoized serializer around the suite, via a test-only shim for
the private[columnar] clearSerializer.

Also address review feedback:

- Fall through to the SparkToColumnar path when the native cache is
  enabled but the relation was cached by a foreign serializer, so
  enabling the feature is never worse for a scan than leaving it off.
  Covered by a new CometExecSuite test.
- Explain on CometInMemoryTableScanExec.output why the declared output
  can be narrower than the emitted batch width for an empty projection.
- Drop the import made redundant by the org.apache.spark.sql.comet._
  wildcard.
@andygrove
andygrove marked this pull request as ready for review July 27, 2026 15:03
@andygrove andygrove changed the title feat: add native support for in-memory cache feat: add experimental native support for in-memory cache, disabled by default Jul 27, 2026
@andygrove

Copy link
Copy Markdown
Member Author

I found some issues with data type coverage and will push some fixes soon.

…serializer

Three defects found while reviewing type coverage, each with a regression
test that fails without the corresponding fix.

Pruning dropped every batch for columns without bounds. `tracksBounds`
only computes bounds for the types it lists, so a collated `StringType`
on Spark 4.x left them null. Spark still builds a partition filter for
such a column because a collated string literal is an `AtomicType`, and
comparing against null bounds yields null, which the generated predicate
treats as false. A filtered query over a collated string column returned
zero rows instead of the matching ones, with no error. `buildFilter` now
drops predicates over columns that have no bounds, keeping `IsNull` and
`IsNotNull` which only read null and row counts.

Interval columns broke `cache()`. `Utils.getFieldVector` has no case for
`DurationVector` or `IntervalYearVector`, so materializing a cached
relation containing a `DayTimeIntervalType` or `YearMonthIntervalType`
column threw `Unsupported Arrow Vector for serialize`. The serializer
now reports the schemas its Arrow writer supports and delegates the rest
to Spark's default cache serializer.

Reading a `DefaultCachedBatch` failed for any non-primitive column.
`supportsColumnarOutput` returned true unconditionally while
`decodeDefaultCachedBatch` decoded through `ColumnAccessor.decompress`,
whose `PassThrough` decoder only handles the seven primitive physical
types. Spark's own serializer gates `supportsColumnarOutput` on exactly
those types to avoid this. Reading a cached string column threw
`scala.MatchError: PhysicalStringType`. The cached format is now decided
by schema alone rather than by a runtime config, so a relation is either
entirely Comet format or entirely delegated to Spark, and the hand-rolled
`DefaultCachedBatch` decoder is gone. `spark.sql.cache.serializer` is a
static conf, so a format that could flip mid-session was never readable
back reliably. `CometExecRule` checks the schema before choosing the
native scan.

Also add coverage for all supported types, the row read path over
`CometCachedBatch`, and a reordered full-width projection.
@andygrove

Copy link
Copy Markdown
Member Author

@0lai0 @manuzhang @sandugood @peterxcli could you help with reviews on this PR?

@sandugood

Copy link
Copy Markdown
Contributor

lgtm👍

@sandugood

sandugood commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Tried out on a pipeline that relies heavily on caching and got an error:

org.apache.spark.SparkException: Comet execution only takes Arrow Arrays, but got class org.apache.spark.sql.execution.vectorized.OffHeapColumnVector. This typically happens when a Comet scan falls back to Spark due to unsupported data types (e.g., complex types like structs, arrays, or maps). To resolve this, you can: (1) enable spark.comet.scan.allowIncompatible=true to use a compatible native scan variant, or (2) enable spark.comet.convert.parquet.enabled=true to convert Spark Parquet data to Arrow format automatically.
	at org.apache.spark.sql.comet.util.Utils$.$anonfun$getBatchFieldVectors$1(Utils.scala:417)
	at org.apache.spark.sql.comet.util.Utils$.$anonfun$getBatchFieldVectors$1$adapted(Utils.scala:403)
	at scala.collection.immutable.Range.map(Range.scala:60)
	at org.apache.spark.sql.comet.util.Utils$.getBatchFieldVectors(Utils.scala:403)
	at org.apache.spark.sql.comet.util.Utils$.$anonfun$serializeBatches$1(Utils.scala:248)

Note that I've tried to enable the suggested config insertions, but it didn't work either.
For context: pipeline has lots of fallbacks to default Spark operators. Also using Iceberg V2 as datasource with MOR tables.

cc @andygrove

@andygrove

Copy link
Copy Markdown
Member Author

Tried out on a pipeline that relies heavily on caching and got an error:

org.apache.spark.SparkException: Comet execution only takes Arrow Arrays, but got class org.apache.spark.sql.execution.vectorized.OffHeapColumnVector. This typically happens when a Comet scan falls back to Spark due to unsupported data types (e.g., complex types like structs, arrays, or maps). To resolve this, you can: (1) enable spark.comet.scan.allowIncompatible=true to use a compatible native scan variant, or (2) enable spark.comet.convert.parquet.enabled=true to convert Spark Parquet data to Arrow format automatically.
	at org.apache.spark.sql.comet.util.Utils$.$anonfun$getBatchFieldVectors$1(Utils.scala:417)
	at org.apache.spark.sql.comet.util.Utils$.$anonfun$getBatchFieldVectors$1$adapted(Utils.scala:403)
	at scala.collection.immutable.Range.map(Range.scala:60)
	at org.apache.spark.sql.comet.util.Utils$.getBatchFieldVectors(Utils.scala:403)
	at org.apache.spark.sql.comet.util.Utils$.$anonfun$serializeBatches$1(Utils.scala:248)

Note that I've tried to enable the suggested config insertions, but it didn't work either. For context: pipeline has lots of fallbacks to default Spark operators. Also using Iceberg V2 as datasource with MOR tables.

cc @andygrove

Thanks @sandugood. I'm looking into it now.

// Comet's Arrow writer only handles the types listed in supportsSchema. Reporting false here
// sends the relation down the row path, where it is delegated to Spark's default serializer,
// instead of failing at cache materialization inside Utils.serializeBatches.
override def supportsColumnarInput(schema: Seq[Attribute]): Boolean = supportsSchema(schema)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

supportsColumnarInput promises support for any columnar producer whose schema is supported, but convertColumnarBatchToCachedBatch eventually calls Utils.getBatchFieldVectors, which only accepts CometVector. Spark can expose vectorized Parquet/ORC plans here, causing otherwise supported caches to receive OnHeapColumnVector/OffHeapColumnVector and throw. Please either return false here and use the already-working row-to-Arrow path—the smallest safe fix—or add generic Spark-vector conversion. A vectorized Parquet/ORC cache-materialization regression with Comet execution disabled would cover this.

Assist with LLM

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably the cause of #5051 (comment)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During initial cache materialization, Spark calls convertColumnarBatchToCachedBatch with the cached plan’s original columnar output. If that plan is a Spark vectorized Parquet/ORC scan rather than a Comet scan, its vectors are not CometVector, so Utils.getBatchFieldVectors throws before any CometCachedBatch is created. Please either force the row-input path or convert generic Spark column vectors to Arrow, with a vectorized Parquet cache-fill regression.

Assist with LLM

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably the cause of #5051 (comment)

looks like its directly related

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and thanks for catching this — your diagnosis is exactly right, including that it is the cause of the report in #5051 (comment).

I reproduced it: with spark.comet.scan.enabled=false and spark.comet.sparkToColumnar.enabled=false, caching a Parquet table throws the same exception from CachedRDDBuilder$$anon$2.next(InMemoryRelation.scala:277). The mechanism is slightly worse than "Spark can expose vectorized plans here": because supportsColumnarInput returns true, InMemoryRelation.apply actively calls convertToColumnarIfPossible and strips the ColumnarToRow above the cached plan (InMemoryRelation.scala:324-328), so any columnar leaf underneath becomes the serializer input.

Of your two options I took the second (generic Spark-vector conversion) rather than returning false. Returning false is safe but gives up the columnar fast path for Comet-native cached plans, which is most of the point of the feature — the row path would force columnar -> row -> Arrow. Instead encodeBatches now checks Utils.isArrowBacked per batch and copies only foreign vectors into Arrow.

The regression you asked for is in 9dce0d6, and there is a second one covering nullable array/struct/map/binary through the nested vectorized reader. I also documented the contract at supportsColumnarInput itself so the conversion does not read as deletable defensive code.

`ArrowCachedBatchSerializer.supportsColumnarInput` decides the cache input
format from the schema alone, which is all Spark gives it. Returning true makes
`InMemoryRelation` strip the `ColumnarToRow` above the cached plan and feed
`cachedPlan.executeColumnar()` directly to
`convertColumnarBatchToCachedBatch`. Any Spark or third-party columnar leaf
under that transition (Spark's vectorized Parquet/ORC reader, a connector's own
vectors) therefore reaches the serializer with `On/OffHeapColumnVector`-style
columns, and `Utils.serializeBatches` fails with "Comet execution only takes
Arrow Arrays".

The serializer cannot detect this from the schema, so copy non-Arrow batches
into Arrow at write time. Comet-native cached plans keep the existing
zero-copy path.
* it. Values are copied element-wise, since Spark's `ColumnVector` implementations do not
* expose Arrow buffers.
*/
def columnarBatchToArrowBatch(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/**
* `ArrowReader` over an iterator of Spark-side `ColumnarBatch`es (not Arrow-backed). Slices up to
* `maxRecordsPerBatch` rows per `loadNextBatch` from the current Spark batch into the reader's
* stable VSR via `ArrowWriter.writeCol`. Spark's `ColumnVector` implementations aren't Arrow
* buffers, so this reader necessarily copies element values into Arrow format.
*/
private[comet] class SparkColumnarArrowReader(
allocator: BufferAllocator,
arrowSchema: Schema,
source: Iterator[ColumnarBatch],
maxRecordsPerBatch: Int,
onConversionNs: Long => Unit = _ => ())
extends ArrowReader(allocator) {
private var current: ColumnarBatch = _
private var rowsConsumedInCurrent: Int = 0
override protected def readSchema(): Schema = arrowSchema
override def bytesRead(): Long = 0L
override protected def closeReadSource(): Unit = ()
private def advanceToNonEmptyBatch(): Boolean = {
while (current == null || rowsConsumedInCurrent >= current.numRows()) {
if (current != null) {
// We don't own Spark ColumnarBatches; just drop the reference.
current = null
rowsConsumedInCurrent = 0
}
if (!source.hasNext) {
return false
}
current = source.next()
rowsConsumedInCurrent = 0
}
true
}
override def loadNextBatch(): Boolean = {
prepareLoadNextBatch()
if (!advanceToNonEmptyBatch()) {
return false
}
val startNs = System.nanoTime()
val rowsRemaining = current.numRows() - rowsConsumedInCurrent
val rowsToProduce =
if (maxRecordsPerBatch <= 0) rowsRemaining
else math.min(maxRecordsPerBatch, rowsRemaining)
val writer = ArrowWriter.create(getVectorSchemaRoot)
var col = 0
while (col < current.numCols()) {
val column = current.column(col)
val columnArray = new ColumnarArray(column, rowsConsumedInCurrent, rowsToProduce)
if (column.hasNull) {
writer.writeCol(columnArray, col)
} else {
writer.writeColNoNull(columnArray, col)
}
col += 1
}
rowsConsumedInCurrent += rowsToProduce
writer.finish()
// ArrowWriter derives the root row count from its per-column writes, so a zero-column
// input batch (Spark's count-from-metadata scan: numRows > 0, numCols == 0) would otherwise
// produce a root with rowCount == 0 and silently drop the rows. Set rowCount explicitly so
// downstream aggregations (e.g. df.count()) see the correct value.
getVectorSchemaRoot.setRowCount(rowsToProduce)
onConversionNs(System.nanoTime() - startNs)
true
}

I think we already have one?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checking which type of spark column vector does the SparkColumnarArrowReader support...

@peterxcli peterxcli Jul 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ColumnarBatch is a container:

class ColumnarBatch {
  ColumnVector[] columns;
}

Spark 4.1.2’s relevant ColumnVector implementations are:

Producer Vectors inside ColumnarBatch
Vectorized Parquet OnHeapColumnVector, OffHeapColumnVector, ConstantColumnVector
Vectorized ORC OrcAtomicColumnVector, OrcArrayColumnVector, OrcMapColumnVector, OrcStructColumnVector; sometimes heap/off-heap or constant vectors
Spark Arrow/Python paths ArrowColumnVector
Comet CometVector
External columnar sources Any implementation extending Spark’s ColumnVector

SparkColumnarArrowReader does not inspect those concrete classes. It wraps each column in ColumnarArray, and ArrowWriter reads through Spark’s standard accessors such as getLong, getUTF8String, getArray, and getStruct. Therefore, it supports any implementation that correctly follows the spark's ColumnVector

ColumnVector contract for the declared data type. The cache writer could reuse it approximately like this:

override def convertColumnarBatchToCachedBatch(
    input: RDD[ColumnarBatch],
    schema: Seq[Attribute],
    storageLevel: StorageLevel,
    conf: SQLConf): RDD[CachedBatch] = {

  val sparkSchema = toStructType(schema)
  val batchSize = conf.columnBatchSize
  val timeZoneId = conf.sessionLocalTimeZone

  input.mapPartitions { batches =>;
    // Arrow Schema is not serializable, so construct it inside the task.
    val arrowSchema = Utils.toArrowSchema(sparkSchema, timeZoneId)

    val arrowBatches = CometArrowStream.readerBatchIter(
      "CometCacheWrite",
      allocator =>;
        new SparkColumnarArrowReader(
          allocator,
          arrowSchema,
          batches,
          batchSize))

    encodeBatches(arrowBatches, schema)
  }
}

That gives this path:

Spark ColumnarBatch
  -> SparkColumnarArrowReader
  -> Arrow-backed ColumnarBatch containing CometVector
  -> existing encodeBatches/getBatchFieldVectors
  -> CometCachedBatch</code>

Assist with LLM

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right on both counts, and your analysis of the ColumnVector contract matches what I found — ColumnarArray + the standard accessors means it works for any correctly-implemented ColumnVector, which is why the fix also covers Iceberg and other external columnar sources without naming them.

The duplication is fixed in c7bc49e: the copy loop is now CometArrowConverters.writeColumns(root, batch, startRow, numRows), and SparkColumnarArrowReader.loadNextBatch calls it too. That also collapses the two copies of the zero-column setRowCount workaround back into one.

On your readerBatchIter sketch — I went a slightly different way and want to flag why, since it is a real tradeoff:

  • It converts unconditionally, so a Comet-native cached plan (the primary case for this feature) would get a full element-wise copy through the generic accessors instead of the current zero-copy Arrow IPC write. That is the regression I most wanted to avoid, so encodeBatches keeps a per-batch Utils.isArrowBacked check and only converts foreign vectors.
  • The reader wraps the whole partition iterator, so the Arrow-vs-not decision becomes per-partition rather than per-batch. In practice a partition is homogeneous, but making it per-batch costs nothing.

Your version does have one genuine advantage mine gives up: SparkColumnarArrowReader reuses one stable VSR across batches, whereas columnarBatchToArrowBatch allocates a fresh root per batch. If that shows up in cache-materialization profiles it would be worth revisiting — happy to file a follow-up.

One thing your sketch surfaced that I did not change: your code uses conf.sessionLocalTimeZone for the Arrow schema, which matches the pre-existing row path (convertInternalRowToCachedBatch), while my columnar path uses CometArrowStream.NATIVE_TIMEZONE (UTC) to match CometSparkToColumnarExec. So the same CometCachedBatch format is currently written with two timezone conventions depending on the input path. Timestamps are UTC micros either way so this is metadata-only today, but it is inconsistent and I would rather fix it deliberately. Do you have a preference for which one the cache format should standardise on?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a preference for which one the cache format should standardise on?

Good catch. I checked Spark’s cache implementations. Released Spark’s default cache does not store timezone metadata: both TimestampType and TimestampNTZType are stored as raw longs, with LTZ versus NTZ preserved by the relation schema.

Current Spark's Arrow cache constructs TimestampType using conf.sessionLocalTimeZone and TimestampNTZType with timezone=null, but its cached RecordBatch is deliberately schema-less. The timezone label is therefore not persisted and is reconstructed on read.

CometCachedBatch differs because it stores a complete IPC stream including the schema. I would therefore standardize its physical format on CometArrowStream.NATIVE_TIMEZONE (UTC) for TimestampType, while keeping TimestampNTZType timezone-free. This avoids embedding the write session’s mutable timezone, matches Comet’s native schema, and avoids an unnecessary native-boundary cast. The row path can simply pass NATIVE_TIMEZONE; no timestamp values are converted.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into Spark's implementations — that answers it, and I have taken your recommendation in c25c7b3.

Your framing of the difference is the key point and I had missed it: Spark's Arrow cache can construct TimestampType from conf.sessionLocalTimeZone safely because its RecordBatch is schema-less, so the label is never persisted and gets reconstructed on read. CometCachedBatch stores a full IPC stream including the schema, so for us that label really is written down — which makes using a mutable session value materially worse than it looks by analogy with Spark.

So the row path now passes CometArrowStream.NATIVE_TIMEZONE, matching the columnar path, which already did. Confirmed the two things you predicted:

  • No values move. Spark's internal representation is micros since the Unix epoch regardless of session timezone, so this is a label-only change. The test checks both the values and their CAST(ts AS STRING) rendering against Spark under two non-UTC session timezones.
  • TimestampNTZType needs nothing. Utils.toArrowType maps it to Timestamp(MICROSECOND, null) whatever timezone is passed, so it was already timezone-free.

A small bonus: the closure no longer needs the session timezone, so it no longer captures anything derived from conf at all. Worth noting because the original had to hoist val sessionTz outside mapPartitions — when I moved the read inside while testing, it NPEd in ConfigEntry.readString on the executor. That hazard is now gone.

The test caches a row-based plan (local Seq, so convertInternalRowToCachedBatch rather than the columnar path) under America/Los_Angeles and Asia/Kolkata, decodes the cached batches back through the serializer, and asserts the Arrow field's timezone. I checked it is not vacuous: reverting the one-line change fails it with got [America/Los_Angeles].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and that is what I ended up doing — SparkColumnarArrowReader.loadNextBatch and columnarBatchToArrowBatch now share CometArrowConverters.writeColumns (c7bc49e), so there is one copy loop rather than two. Marking this thread as settled; the remaining timezone question from it is answered on the sibling thread.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up here because merging latest apache/main changed the outcome of this thread.

The shared copy loop we settled on, CometArrowConverters.writeColumns, is gone. #5046 and #5442 landed on main in the meantime and moved that responsibility onto ArrowWriter itself as ArrowWriter.writeColumns(input, startRow, numRows), with a bulk-copy path for fixed-width columns rather than the element-wise loop this PR had. So the deduplication you asked for now lives upstream of both callers, and I resolved the conflict by deleting this PR's version rather than keeping a second one: SparkColumnarArrowReader.loadNextBatch takes main's version wholesale, and columnarBatchToArrowBatch now calls ArrowWriter.create(root, numRows), writeColumns, finish().

One detail worth recording, since it was a deliberate deletion rather than an oversight. This PR's writeColumns ended with an explicit root.setRowCount(numRows), because ArrowWriter derived the root row count from per-column writes and a zero-column batch (Spark's count-from-metadata scan, numRows > 0 with numCols == 0) would otherwise have produced rowCount == 0 and silently dropped the rows. Main's writeColumns sets count = numRows directly, so finish() gets it right with no columns present and the workaround is no longer needed. CometInMemoryCacheSuite has a test for that exact case ("supports empty projection scan"), and it still passes, so this is covered rather than taken on trust.

The leak guard from the other thread is unaffected and still wraps both call sites. All 21 tests in the suite pass on the merged branch.

@andygrove

Copy link
Copy Markdown
Member Author

I pushed a potential fix at the same time @peterxcli was reviewing.

Extract the element-wise Spark `ColumnVector` -> Arrow copy loop that
`columnarBatchToArrowBatch` had duplicated from `SparkColumnarArrowReader` into
a shared `CometArrowConverters.writeColumns`, so the zero-column rowCount
workaround lives in one place.

Also:
- hoist the Arrow schema out of the per-batch path
- replace the `(batch, ownsBatch)` tuple and `.toList` with a `serializeBatch`
  helper that takes the single element eagerly
- move `isArrowBacked` to `Utils`, next to the `getBatchFieldVectors`
  precondition it describes
- drop `toStructType` in favour of the existing `Utils.fromAttributes`
- document at `supportsColumnarInput` why its schema-only answer means the
  conversion in `encodeBatches` is load bearing
- route the new tests through the existing `withNativeCache` helper and assert
  both of them really cached in Comet's format
@andygrove

Copy link
Copy Markdown
Member Author

Thanks for the report @sandugood, and sorry for the bad error message — it sent you chasing scan configs when the failure was actually in the new cache serializer. @peterxcli diagnosed it correctly in the review thread above.

What happened: the Comet cache serializer decided it could accept columnar input based on the schema alone. That makes Spark strip the ColumnarToRow above the cached plan and hand the serializer whatever that plan produces. When the cached plan falls back to Spark (or is an external columnar source like Iceberg), those are On/OffHeapColumnVector, not Arrow vectors, so cache materialization threw. Your "pipeline has lots of fallbacks to default Spark operators" was the trigger — and neither spark.comet.scan.allowIncompatible nor spark.comet.convert.parquet.enabled could help, because the failure is at cache write time, not in the scan.

Workaround on current main: spark.comet.exec.inMemoryCache.enabled=false (the default), which keeps Spark's own cache serializer.

Fixed in 9dce0d6 + c7bc49e on this branch: non-Arrow columnar batches are now copied into Arrow at cache write time, while Comet-native cached plans keep the existing zero-copy path. Two regression tests cover it — primitives/string/decimal/date/timestamp, and nullable array/struct/map/binary.

Since Iceberg MOR is the one input I could not reproduce locally, it would be really helpful if you could retest your pipeline against the branch. Iceberg's vectors go through the same generic ColumnVector accessors as Spark's, so it should be covered, but confirmation from a real workload would be worth a lot here.

* (e.g. Comet's cache serializer, which Spark hands the cached plan's columnar output) use this
* to convert foreign vectors to Arrow instead of tripping the exception below.
*/
def isArrowBacked(batch: ColumnarBatch): Boolean =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this possible to be false?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — and this is the case the PR exists to handle.

isArrowBacked is false whenever Comet is handed a columnar batch from a plan it did not build. The concrete reproduction is the one from the report on this PR: with spark.comet.scan.enabled=false, Spark's own vectorized Parquet reader produces OnHeapColumnVectors, supportsColumnarInput returns true so InMemoryRelation.apply strips the ColumnarToRow above that scan, and the cache serializer receives non-Comet vectors. Before the converter path those batches hit the exception in getBatchFieldVectors.

It is also false for any external columnar source — an Iceberg or other connector's ColumnVector implementation — which is why the conversion is written against the ColumnVector contract rather than against specific classes.

The two tests at the end of CometInMemoryCacheSuite ("cache a Spark columnar plan whose vectors are not Arrow-backed" and the complex-types variant) cover exactly this, so the false branch is exercised.

* it. Values are copied element-wise, since Spark's `ColumnVector` implementations do not
* expose Arrow buffers.
*/
def columnarBatchToArrowBatch(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a preference for which one the cache format should standardise on?

Good catch. I checked Spark’s cache implementations. Released Spark’s default cache does not store timezone metadata: both TimestampType and TimestampNTZType are stored as raw longs, with LTZ versus NTZ preserved by the relation schema.

Current Spark's Arrow cache constructs TimestampType using conf.sessionLocalTimeZone and TimestampNTZType with timezone=null, but its cached RecordBatch is deliberately schema-less. The timezone label is therefore not persisted and is reconstructed on read.

CometCachedBatch differs because it stores a complete IPC stream including the schema. I would therefore standardize its physical format on CometArrowStream.NATIVE_TIMEZONE (UTC) for TimestampType, while keeping TimestampNTZType timezone-free. This avoids embedding the write session’s mutable timezone, matches Comet’s native schema, and avoids an unnecessary native-boundary cast. The row path can simply pass NATIVE_TIMEZONE; no timestamp values are converted.

@sandugood

sandugood commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

When the PR is sound I could test it again. Is it ready to be tested @andygrove?

Last time I've built it, got an error while writing to an iceberg table:
org.apache.comet.CometNativeException: Invalid argument error: column types must match schema types, expected List(Struct("col_1": Utf8, "col_2": Boolean, "col_3": Boolean)) but found List(Struct("col_1": Utf8, "col_2": non-null Boolean, "col_3": Boolean)) at column index 5

(note that I obfuscated column names, but kept their order)

@peterxcli peterxcli left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andygrove Thanks for the update on vector converting!

I have four new comments on this, but they all can be address in followup.

Other LGTM!

// Apply Spark's cache batch filter before decoding. Spark's InMemoryTableScanExec does this in
// filteredCachedBatches(), but that method is private. Reusing the serializer's buildFilter here
// keeps Comet on the same stats-based pruning path instead of decoding every cached batch.
override def doExecuteColumnar(): RDD[ColumnarBatch] = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pruning configuration ignored: still prunes when spark.sql.inMemoryColumnarStorage.partitionPruning=false.

not sure enable partitionPruning is always better than disable? if true, then nvm.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real bug, fixed in 1367665 — thanks. Spark's InMemoryTableScanExec.filteredCachedBatches gates on conf.inMemoryPartitionPruning and Comet was applying the filter unconditionally, so the config was silently ignored.

To your "not sure enable partitionPruning is always better than disable": pruning is normally the win, but that is not really the point — the config exists so a user can turn it off, typically to rule out a stats bug when results look wrong. Ignoring it means the one knob they are reaching for does nothing, and Comet diverges from Spark precisely when someone is debugging. So honoring it is right regardless of which default is faster.

Covered by a new test that observes the scan's numOutputRows, which counts rows in the batches actually decoded: 100 with pruning on (a single 100-row batch) and 1000 with it off (every batch decoded). One wrinkle worth noting for anyone writing a similar test: the metric has to be read after forcing that exact df to run, because checkSparkAnswer executes its own copies and leaves the collected plan instance at zero — my first version passed vacuously with 0 on both sides.

* Reads of `CometCachedBatch` keep working when the native scan is disabled, because Spark then
* reads the same cached data through the SparkToColumnar fallback path.
*/
class ArrowCachedBatchSerializer extends SimpleMetricsCachedBatchSerializer {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add early termination handling: eg. LIMIT, take, or any other cancellation

ref: spark's TaskCompletionListener of ArrowCachedBatchSerializer

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real leak, fixed in 1367665. Confirmed the mechanism: ArrowReaderIterator.close() is only reached from hasNext when the stream is exhausted, so LIMIT, take(), or a cancelled task leaves the reader it was part-way through open.

convertCachedBatchToColumnarBatch now registers a TaskCompletionListener, following the Spark implementation you linked. One simplification: flatMap consumes each inner iterator fully before constructing the next, so at most one reader is open at a time and tracking the current one is enough rather than a collection. close() is already idempotent and synchronized, so closing one that exhausted itself is a no-op and the listener is safe to run from another thread.

* `relationOutput` is the full schema stored in the cache. `scanOutput` is the subset requested
* by this scan after pruning.
*/
case class CometInMemoryTableScanExec(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add AQE test:

  • SPARK-42101: leaves a cached join cold, first-touches it through an AQE aggregation, and checks cold/warm materialization and plan rewrites.
  • Table-cache stage in an AQE join: verifies TableCacheQueryStageExec and shuffle behavior.
  • SPARK-37742: verifies AQE does not choose joins using invalid cache runtime statistics.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not done, and I would rather flag that than half-do it. Porting the three AQE scenarios you linked (SPARK-42101 cold/warm materialization, the TableCacheQueryStageExec join, SPARK-37742) is a meaningful chunk of work and touches AQE plan-shape assertions rather than the cache format this PR changes, so I have left it out of this round rather than rushing it alongside the four correctness fixes.

You said your comments could be follow-ups, so unless you would rather block on it, my suggestion is a tracking issue for AQE coverage of CometInMemoryTableScanExec. I have not filed one yet — say the word and I will, or feel free to open it yourself if you have a preference for how it should be scoped.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #5245, scoped to the three scenarios you linked, run with spark.comet.exec.inMemoryCache.enabled=true and asserting the cached scan is CometInMemoryTableScanExec.

I called out SPARK-37742 as the one to weight most: the Comet serializer reports its own sizes for CometCachedBatch, so the runtime statistics AQE reads when picking a join strategy come from Comet code rather than Spark's DefaultCachedBatch accounting. That is the scenario where a divergence would be silent and would change plans rather than results.

override val numRows: Int,
override val sizeInBytes: Long,
override val stats: InternalRow,
bytes: ChunkedByteBuffer)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChunkedByteBuffer is Externalizable, so BlockManager can serialize it to DiskStore. so both storage level (DISK_ONLY and MEMORY_AND_DISK) should already supported? Could we add a StorageLevel.DISK_ONLY regression test?

A deterministic DISK_ONLY test should:

  • Materialize a CometCachedBatch using StorageLevel.DISK_ONLY.
  • Assert memSize == 0, diskSize > 0, and all partitions cached.
  • Run a second query and verify the answer plus CometInMemoryTableScan, proving disk deserialization and decoding work.

Spark uses the same size assertions in its BlockManager regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 1367665, following your spec: "supports DISK_ONLY storage level" materializes the cache at StorageLevel.DISK_ONLY and asserts memSize == 0, diskSize > 0, numCachedPartitions == numPartitions, that the payload is still CometCachedBatch, and that a second query returns the right answer through CometInMemoryTableScan.

Your reasoning was right — ChunkedByteBuffer being Externalizable means BlockManager spills it like any other block, and it worked first time with no production change needed. Worth having the regression test precisely because nothing in Comet's code makes that true, so it could break without notice.

batch: ColumnarBatch,
arrowSchema: Schema,
allocator: BufferAllocator): ColumnarBatch = {
val root = VectorSchemaRoot.create(arrowSchema, allocator)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor Nit: columnarBatchToArrowBatch allocates root before NativeUtil.rootAsBatch wraps it.
If writeColumns or the wrap throws, root leaks, since the caller only closes the returned batch.
Consider guarding the body with `try { ... } catch { case NonFatal(e) => root.close(); throw e }.
But normal path will not lead to

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 1367665. Guarded with try/catch NonFatal releasing the root before rethrowing, as you suggested.

You are right that the normal path cannot hit it, but writeColumns walks arbitrary ColumnVector implementations, so a badly-behaved external vector is exactly the case that would throw here — and that is the path this PR added.

rowToArrowBatchIter just above has the same shape (root allocated, then writer.write(rowIter.next()) before rootAsBatch takes ownership), so I gave it the same guard rather than leaving one of the two fixed.

Three review fixes on the cache path, plus tests for two of them.

Honor spark.sql.inMemoryColumnarStorage.partitionPruning. CometInMemoryTableScanExec
applied the serializer's stats filter unconditionally, so setting the config to
false still pruned. Spark's InMemoryTableScanExec.filteredCachedBatches gates on
it, and a user reaching for that knob -- to rule out a stats bug, say -- is
specifically trying to stop pruning, so silently ignoring it makes Comet diverge
on the one thing they are controlling.

Close Arrow readers when a consumer stops early. ArrowReaderIterator closes its
reader only on exhaustion, so LIMIT, take() or a cancelled task left the reader
it was part-way through open. convertCachedBatchToColumnarBatch now registers a
TaskCompletionListener, as Spark's own ArrowCachedBatchSerializer does. flatMap
consumes each inner iterator fully before building the next, so at most one
reader is open at a time and tracking the current one suffices; close() is
already idempotent and synchronized, so closing an exhausted one is a no-op.

Release the VectorSchemaRoot if conversion throws. columnarBatchToArrowBatch
allocated a root before NativeUtil.rootAsBatch wrapped it, and the caller only
owns the returned batch, so a throw from writeColumns leaked the allocation.
rowToArrowBatchIter had the same shape and gets the same guard.

Tests:

- "honors inMemoryColumnarStorage.partitionPruning=false" observes the scan's
  numOutputRows, which counts rows in the batches actually decoded: 100 with
  pruning on (one batch), 1000 with it off. Note the metric has to be read after
  forcing this df to run, since checkSparkAnswer executes its own copies and
  leaves the collected plan instance at zero.
- "supports DISK_ONLY storage level" asserts memSize == 0, diskSize > 0, all
  partitions cached, the payload is still CometCachedBatch, and the cache reads
  back through CometInMemoryTableScan. CometCachedBatch holds a ChunkedByteBuffer,
  which is Externalizable, so BlockManager can spill it like any other block.

All 19 tests in CometInMemoryCacheSuite pass on spark-3.5.
@andygrove

Copy link
Copy Markdown
Member Author

@peterxcli @0lai0 thanks both — five of the six comments are addressed in 1367665, and two were real bugs.

Pruning config was silently ignored (@peterxcli). CometInMemoryTableScanExec applied the stats filter unconditionally, so spark.sql.inMemoryColumnarStorage.partitionPruning=false did nothing. Now gated the same way Spark's filteredCachedBatches is. On your "not sure enabling pruning is always better" — pruning is normally the win, but the point is that the config exists so a user can turn it off, usually to rule out a stats bug when results look wrong; ignoring it breaks the one knob they are reaching for.

Readers leaked on early termination (@peterxcli). Confirmed: ArrowReaderIterator.close() is only reached on exhaustion, so LIMIT/take()/cancellation left a reader open. Now registers a TaskCompletionListener as Spark's implementation does. Since flatMap consumes inner iterators sequentially, tracking the current reader is enough, and close() is already idempotent and synchronized.

Root leaked if conversion threw (@0lai0). Fixed with the try/NonFatal guard you suggested. You are right the normal path cannot reach it, but writeColumns walks arbitrary ColumnVector implementations — the new path in this PR — so a misbehaving external vector is exactly the case. rowToArrowBatchIter had the same shape and got the same guard.

DISK_ONLY test added to your spec: memSize == 0, diskSize > 0, all partitions cached, payload still CometCachedBatch, second query correct through the native scan. Your Externalizable reasoning held — it worked with no production change, which is exactly why the regression test is worth having.

isArrowBacked can be false — that is the case this PR exists for: with spark.comet.scan.enabled=false, Spark's vectorized Parquet reader hands us OnHeapColumnVectors, and any external connector's ColumnVector does the same. The two "not Arrow-backed" tests at the end of the suite cover it.

AQE tests: not done. Porting the three scenarios you linked is a real chunk of work on AQE plan-shape assertions rather than the cache format this PR changes, so I left it rather than rushing it. Since you said these could be follow-ups, I suggest a tracking issue for AQE coverage of CometInMemoryTableScanExec — I have not filed one, happy to if you want it.

One thing worth passing on from writing the pruning test: reading a metric off df.queryExecution.executedPlan after checkSparkAnswer(df) gives zero, because checkSparkAnswer executes its own copies. My first version passed vacuously with 0 on both sides of the comparison; it needs the df forced explicitly. The real numbers are 100 rows decoded with pruning on versus 1000 with it off.

All 19 tests in CometInMemoryCacheSuite pass on spark-3.5 (1 canceled, the pre-existing isSpark40Plus gate).

The row write path passed conf.sessionLocalTimeZone into rowToArrowBatchIter
while the columnar path already encoded with CometArrowStream.NATIVE_TIMEZONE, so
the same logical cache had two physical formats depending on which path filled
it, and the row one persisted the writing session's timezone into cached data.

Unlike Spark's Arrow cache, whose RecordBatch is deliberately schema-less and
reconstructs the timezone on read, CometCachedBatch stores a full IPC stream
including the schema, so that label really is written down. Standardise on
NATIVE_TIMEZONE ("UTC") for TimestampType, per the analysis on the review thread.

This is a label only. Spark's internal timestamp representation is micros since
the Unix epoch regardless of session timezone, so no values are converted, and
matching Comet's native schema also avoids a cast at the native boundary.
TimestampNTZType already had no timezone: Utils.toArrowType maps it to
Timestamp(MICROSECOND, null) whatever is passed in.

The closure no longer needs the session timezone at all, so it no longer captures
anything derived from `conf`.

Test: "stores timestamps with a UTC schema label" caches a row-based (local Seq)
plan under two non-UTC session timezones, decodes the cached batches back through
the serializer, and asserts the Arrow field carries "UTC" -- then checks the
values and their string rendering still match Spark. Reverting the one-line change
fails it with `got [America/Los_Angeles]`, so it pins the format rather than
passing vacuously.

All 20 tests in CometInMemoryCacheSuite pass on spark-3.5.
@andygrove

Copy link
Copy Markdown
Member Author

@peterxcli the timezone question is settled in c25c7b3 — thanks for checking Spark's implementations, your recommendation was the right call and for a reason I had missed.

The distinction that matters: Spark's Arrow cache can build TimestampType from conf.sessionLocalTimeZone safely because its RecordBatch is deliberately schema-less, so the label is never persisted and is reconstructed on read. CometCachedBatch stores a full IPC stream including the schema, so for us that label really does get written down — which makes a mutable session value materially worse here than the analogy with Spark suggests.

So the row write path now passes CometArrowStream.NATIVE_TIMEZONE, matching the columnar path, which already did. Both of your predictions held:

  • No values move — Spark stores timestamps as micros since the Unix epoch regardless of session timezone. The test checks values and their CAST(ts AS STRING) rendering against Spark under two non-UTC session timezones.
  • TimestampNTZType needed nothingUtils.toArrowType maps it to Timestamp(MICROSECOND, null) whatever is passed in.

Small bonus: the closure no longer needs the session timezone, so it captures nothing derived from conf. That removes a live footgun — the original had to hoist val sessionTz outside mapPartitions, and when I moved the read inside while testing, it NPEd in ConfigEntry.readString on the executor.

The new test caches a row-based plan (local Seq, so it goes through convertInternalRowToCachedBatch) under America/Los_Angeles and Asia/Kolkata, decodes the cached batches back through the serializer, and asserts the Arrow field timezone. I verified it is not vacuous: reverting the one-line change fails it with got [America/Los_Angeles].

All 20 tests in CometInMemoryCacheSuite pass on spark-3.5 (1 canceled, the pre-existing isSpark40Plus gate).

That clears every inline thread on this PR except the AQE test coverage, which we agreed can be a follow-up.

…hat they project

A CometCachedBatch held one compressed Arrow IPC stream covering every
cached column, so convertCachedBatchToColumnarBatch inflated all of them
and projected afterwards. Read cost was flat in the width of the
projection, where Spark's per-column DefaultCachedBatch falls away as it
narrows, and a narrow read of a wide cached relation was several times
slower than Spark's cache despite materializing faster.

Store one stream per column and decode only the selected ones. An empty
selection now stays empty rather than expanding to every column, and the
scan asks for a single cheap column instead of the whole schema when a
query needs only the row count, since the native plan still requires a
non-empty scan schema. Per-column sizes are now known, so the statistics
field Spark reserves for them holds the real value.

Measured on 5M rows and 6 columns, against Spark's cache format:

  read shape   before      after      Spark
  count(*)     241 ms       77 ms      62 ms
  1 of 6        59 ms       57 ms     105 ms
  3 of 6       205 ms      204 ms     339 ms
  6 of 6       448 ms      448 ms     375 ms

Framing costs a schema block and compression framing per column per
batch, and loses cross-column compression: footprint grows 2.5% at 6
columns and 32% at 60, where the cached relation is still 22x smaller
than Spark's format.
@andygrove

Copy link
Copy Markdown
Member Author

Fixed #5484 here rather than leaving it as a follow-up. A cache format that loses to Spark's on the reads people actually do is not worth shipping, even off by default, so it seemed better to fix it than to document it.

What changed. A CometCachedBatch now stores one compressed Arrow IPC stream per column instead of one stream covering all of them, and convertCachedBatchToColumnarBatch decodes only the columns it was asked for. Two related cases fell out of the same change: an empty selection now stays empty rather than expanding to every column, and the scan asks for a single cheap column rather than the whole cache schema when a query needs only the row count. That last one is why count(*) was the worst case rather than the best: CometInMemoryTableScanExec widened an empty projection to the full schema, because the native plan needs a non-empty scan schema, and once the serializer decodes exactly what it is asked for that widening meant decoding everything. One column satisfies the native planner just as well.

Per-column sizes are also known now, so the fifth statistics field Spark reserves for them holds the real value instead of zero.

Results, 5M rows and 6 columns, min of 5 runs after a discarded warm-up, against a real DefaultCachedBatch baseline in a separate session:

Read shape Spark cache Comet before Comet after
count(*) 62 ms 241 ms 77 ms
1 of 6 columns 105 ms 592 ms 57 ms
3 of 6 columns 339 ms 655 ms 204 ms
6 of 6 columns 375 ms 448 ms 448 ms
Materialize 3497 ms 1563 ms 1563 ms
Footprint 309 MiB 60 MiB 61 MiB

Narrow and medium projections now beat Spark's cache by 1.7x to 1.8x rather than losing to it. count(*) is at parity. A full-width projection is still 1.2x behind, which is decode and conversion cost rather than anything to do with projection.

The cost, since I said I would measure it rather than assume it. Per-column framing adds a schema block and compression framing per column per batch, and gives up cross-column compression. Footprint grows 2.5% at 6 columns and 32% at 60 columns. At 60 columns the cached relation is still 107 MiB against Spark's 2369 MiB, so I took the tradeoff, but it is a real one and it scales with width, so it is worth knowing about.

What is still slower. Reads that feed Spark operators rather than Comet ones remain 1.7x to 2.5x behind Spark's cache. Those pay a row conversion that Spark's format avoids with a codegen'd path over its own layout. That is a different cost from the projection problem and I have not touched it here.

@sandugood this should help your pipeline on the read side, but that fallback gap is exactly the shape you were running, so I would not promise it closes the whole 10-to-15-minute difference. If you do retest, the two things worth separating are cache materialization and the repeated reads, since they now move in opposite directions.

Testing. Five new tests in CometInMemoryCacheSuite. The two that matter corrupt the column streams a read must not touch, so they assert on what was actually decoded rather than on timings; I checked both fail against the previous single-stream format rather than passing for free. All 26 tests in the suite pass, along with UtilsSuite, CometNativeColumnarToRowSuite and NativeUtilSuite.

@peterxcli this changes the cached payload and the read path, so your approval no longer covers it. Sorry to move the target after you had signed off. The lifetime handling around the readers is the part I would most want a second pair of eyes on: a cached batch is now several independent Arrow streams, so the TaskCompletionListener you asked for has to release all the readers for the batch in flight rather than one.

@andygrove

Copy link
Copy Markdown
Member Author

@comphead @sunchao Could I get a review? This is an experimental feature, disabled by default, but I think this PR is already large and complex, and I think it would be helpful to merge it and then continue work in future PRs. WDYT?

scalafix RemoveUnused rejects the named relation parameter, which this
test does not use. Spotless does not catch it, so it only surfaced in
the Lint Java jobs.
@sunchao

sunchao commented Aug 26, 2026

Copy link
Copy Markdown
Member

Will take a look! It looks similar to apache/spark#56334 too although seems in Comet we store compressed IPC stream and can decode only selected columns. cc @viirya too

@andygrove

Copy link
Copy Markdown
Member Author

It looks similar to apache/spark#56334 too although seems in Comet we store compressed IPC stream and can decode only selected columns.

Thanks @sunchao, that is a useful pointer and I had not seen it. I went through it and filed #5487 with the comparison so it does not get lost. A few things worth saying here.

On the version. It is in branch-4.3 and master only, not 4.0, 4.1 or 4.2, so it is not available in any Spark version Comet supports today. It does not remove the need for Comet's serializer, but it is a more mature implementation of the same idea and several of its decisions are better than mine.

On the difference you spotted, which is real but slightly the other way round from how it sounds. Both formats decode only the selected columns. Comet gets there by splitting the payload into one compressed stream per column, which is the change I pushed a few commits ago. Spark keeps a single RecordBatch and instead parses the IPC message flatbuffer, which lists every buffer's offset and length within the body, copying out only the byte ranges belonging to the selected columns and letting VectorLoader.load decompress just those. That leans on Arrow's native per-buffer compression rather than wrapping the whole stream in a Spark CompressionCodec as Comet does.

Spark's is the better design point. Per-column streams pay framing per column per batch and give up cross-column compression: measured against the previous single-stream format, footprint grows 2.5% at 6 columns and 32% at 60. Spark's approach gets the same projection-proportional decode with none of that. Part of the gap is simply that their ArrowCachedBatch carries no Schema message at all, reconstructing it from the relation's attributes on read, where Comet writes a full stream schema per column per batch, roughly 30,000 of them in that 60-column test. I have not changed course here, since the buffer-span reader is about 120 lines of fairly intricate code and belongs in its own PR rather than bolted onto this one, but it is written up as the first thing to reconsider.

Two smaller things from their implementation, both in #5487: they register ArrowCachedBatch for Kryo, and Comet has no Kryo registrator at all, so CometCachedBatch would fail under spark.kryo.registrationRequired=true. And their row read path builds typed column readers once and writes straight into an UnsafeRowWriter instead of going through a generic row iterator, which is a concrete candidate for #5485, where I have measurements but no established cause.

One point of convergence worth noting, since it suggests neither of us is off in the weeds: they landed the same empty-projection optimization I did, emitting row counts without touching the payload so count(*) stops decoding everything, and they landed it as a follow-up in SPARK-58390 about a week after the main PR. Same gap, found in the same order.

@viirya if you have a moment, the question I would most value your view on is whether the buffer-span projection reader was worth its complexity in practice, or whether you would take the simpler split-by-column route knowing what you know now.

I also checked our collation handling against yours and there is no bug on our side, but you do prune collated string columns where we decline to: a collated StringType does not match Comet's case StringType in the bounds check, so those columns get null bounds and no predicate pushdown. Filed as part of #5487 rather than fixed here.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed commit 86a91ea7dd0fbc9765ea74340bc460c5e74ccfc4. Five inline findings are attached: one incorrect-result issue and four additional correctness/resource/execution issues.

Verification: Spark 4.1.3 with JDK 17, freshly compiled JVM classes, and a verified native build from the identical native source tree. All 58 existing tests passed across CometInMemoryCacheSuite, UtilsSuite, NativeUtilSuite, and CometNativeColumnarToRowSuite; separate targeted probes reproduced the reported failures. The large-offset Arrow finding was reproduced at the serializer boundary; the Python producer path was source-traced only. Other Spark versions were not tested.

Review assisted by Codex.

Comment on lines +153 to +154
} else {
Seq(op.relation.output.minBy(a => decodeCostRank(a.dataType)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve zero-column output for joins

An empty-output cache scan can feed a join, not only a count-style aggregate. Adding this column changes the native join's column positions while output still declares the original empty schema. With native caching enabled and AQE disabled, I reproduced:

val left = spark.range(10L, 13L).cache()
left.collect()
left.createOrReplaceTempView("cached_left")
spark.sql("""
  SELECT /*+ BROADCAST(r) */ sum(r.id)
  FROM cached_left l JOIN range(2) r ON true
""").collect()

The result is 66, versus the correct 3 when native cache scanning is disabled. The native join receives the hidden left column before r.id, so the aggregate's bound ordinal 0 reads the wrong values. Selecting r.id directly also fails with Output column count mismatch: expected 1, got 2. Please preserve the zero-column scan schema/batches instead of introducing an unreported column. A genuinely zero-column cached relation already returns the correct count on this head.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in cb07053. I reproduced your case exactly: 66 with native caching on, 3 with it off, and the direct r.id projection failing with the column count mismatch.

The premise the code rested on turned out to be false. The comment claimed the widening was needed because "the native plan still needs a non-empty scan schema", and I carried that forward when I narrowed the fallback from the whole cache schema to a single cheap column. It isn't true: passing op.output straight through, empty and all, plans and executes fine. So the fix is to delete the fallback rather than to make the extra column agree with output.

Worth noting for the record that this predates my change. The previous code fell back to relation.output, and since spark.range has exactly one column, your repro produced an identical one-column scan before and after. My change altered how wide the leak was on wider relations, not whether it existed.

Two things improve as a result. The join is correct, and count(*) now decodes nothing at all rather than one placeholder column, which is strictly better than what I had: selectedIndices keeps an empty selection empty, so the serializer short-circuits on row count alone.

Regression test added as "joins correctly over an empty-output cache scan", covering both the aggregate and the direct projection, and the existing count-only test now asserts scanOutput.isEmpty rather than a placeholder column.

Comment on lines +291 to +294
val root = new VectorSchemaRoot(Seq(fieldVector).asJava)
val writer = new ArrowStreamWriter(root, provider, Channels.newChannel(out))
writer.start()
writer.writeBatch()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use each column's dictionary provider when serializing it

The new decoder opens one Arrow reader per column, so its dictionary-backed output columns have independent providers. getBatchFieldVectors returns only the first dictionary column's provider, which is then passed to every writer here. Re-encoding a decoded cache batch cannot resolve the later columns' dictionary IDs.

I reproduced this through normal Spark operations: with Comet caching and spark.comet.shuffle.mode=jvm, cache a repartitioned DataFrame with two low-cardinality string columns. Then disable spark.comet.exec.enabled and spark.comet.exec.inMemoryCache.enabled and run first.union(first).cache().count(). Spark's columnar Union passes the decoded cached batches back to this serializer, and the second cache fails with IllegalArgumentException: Could not find dictionary with ID 1. The original cache reads correctly and its columns have separate provider namespaces. Please obtain the provider associated with each column rather than reusing the first one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and this one is squarely mine: it became reachable when I split the payload into one stream per column. Before that every column came from a single reader and therefore shared a provider, so collapsing to the first one was harmless. With independent readers the ID namespaces collide.

Fixed in cb07053. Utils gains getBatchFieldVectorsWithProviders, which pairs each field vector with the provider its own column was decoded with, and serializeBatchColumns uses that per column. getBatchFieldVectors keeps its existing signature and collapses to the first provider, so the shuffle and broadcast callers are unchanged.

Regression test added, using your reproduction shape: cache a repartitioned frame with two low-cardinality string columns, then with spark.comet.exec.enabled=false and the native cache scan disabled, first.union(first).cache().count(), so Spark's columnar Union hands the decoded batches back to the serializer. I verified it fails without the fix, with the Could not find dictionary with ID 1 you described.

Comment on lines +369 to +370
private val readers: Array[Iterator[ColumnarBatch]] =
buffers.map(Utils.decodeBatches(_, "CometCache"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Close previously opened readers when initialization fails

decodeBatches opens and decodes a column eagerly. If opening a later column throws, the readers already created by this map are lost. The task-completion listener cannot release them because the new ColumnReaders instance is assigned to current only after this constructor returns.

I reproduced this with valid cached data containing two integer columns and an allocator limit that permits the first column but rejects the second allocation. After task completion, the first column's 512 bytes remain allocated; three failed tasks leave 512, 1024, and 1536 bytes respectively, starting from zero. This turns allocation failures into persistent off-heap leaks and worsens subsequent memory pressure. Please close every successfully opened reader if constructing the remaining readers fails.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in cb07053. Your reading of the mechanism is exactly right: current is assigned only after the constructor returns, so the listener has nothing to close, and decodeBatches allocates eagerly because ArrowReaderIterator decodes its first batch in its constructor.

The readers are now opened in a loop guarded by try/NonFatal that closes everything already opened before rethrowing, with a failing close attached via addSuppressed rather than replacing the original failure, matching the guard used in CometArrowConverters.

I tested it without needing an allocator limit, since CometArrowAllocator exposes its accounting: corrupt the second of two selected column streams, record getAllocatedMemory, attempt the read, and assert the allocation returns to the baseline. Without the fix that leaks 2048 bytes per attempt, which matches the growth you saw across repeated tasks.

Comment on lines +129 to +130
relation.cacheBuilder.serializer,
relation.cacheBuilder.cachedColumnBuffers,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Defer cache RDD construction until execution

Accessing cachedColumnBuffers here is not a passive metadata lookup: Spark's cache builder constructs it by calling cachedPlan.execute/executeColumnar. If that plan is adaptive, this can execute shuffle stages while Comet is still planning the outer query.

With AQE and native caching enabled, I reproduced:

val cached = spark.range(100).repartition(2).cache()
cached.createOrReplaceTempView("cached_adaptive")
spark.sql("SELECT * FROM cached_adaptive").explain()

explain() starts one Spark job and changes the cached adaptive plan to isFinalPlan=true; with native cache scanning disabled and the same cache serializer, it starts zero jobs and the cached plan stays unexecuted. Planning/EXPLAIN can therefore perform expensive computation or fail on execution errors. Please retain the relation/cache builder and obtain its RDD inside doExecuteColumnar().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cb07053. CometInMemoryTableScanExec now holds the CachedRDDBuilder instead of an RDD[CachedBatch], and resolves cachedColumnBuffers inside doExecuteColumnar().

Regression test added. One note on how it asserts, because my first attempt was wrong in a way worth flagging: isCachedColumnBuffersLoaded is not the right signal, since it also requires the blocks to be populated, so it stayed false either way and the test passed with the fix reverted. The test now asserts on AQE finalization instead, which is the effect you actually described: the cached plan must still report isFinalPlan=false after explain(), and the query must still materialize normally when run. That version fails without the fix.

This one predates my change as well, since the original constructor took the RDD too, but it is a real problem and worth fixing here.

Comment on lines +212 to +218
val columns = if (Utils.isArrowBacked(batch)) {
Utils.serializeBatchColumns(batch)
} else {
val arrowBatch =
CometArrowConverters.columnarBatchToArrowBatch(batch, arrowSchema, CometArrowAllocator)
try Utils.serializeBatchColumns(arrowBatch)
finally arrowBatch.close()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle large-offset Arrow vectors before taking the direct write path

Utils.isArrowBacked accepts any CometVector, including a CometPlainVector wrapping LargeVarCharVector or LargeVarBinaryVector. However, serializeBatchColumns calls Utils.getFieldVector, which rejects both representations. Thus supportsColumnarInput accepts the Spark StringType/BinaryType schema, but materializing the cache fails.

At the serializer boundary, I reproduced both large-vector cases with just hello and world: Spark's default cache round-trips them, while this serializer throws Unsupported Arrow Vector for serialize. Ordinary VarCharVector succeeds on both paths. Accelerated mapInArrow is a relevant producer because its existing runner preserves returned Arrow vectors, including pa.large_string()/pa.large_binary(). That Python integration path was source-traced, not executed end to end; the serializer failure and default-cache comparison were run. Please normalize these physical representations through the conversion path or support them in the direct writer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cb07053. The mismatch is exactly as you describe: isArrowBacked asked only whether every column is a CometVector, while getFieldVector accepts a specific vector list that excludes both large-offset representations, so supportsColumnarInput accepted the schema and materialization then threw.

Utils now has isSupportedFieldVector, which answers the same question getFieldVector does without throwing to find out, and both use it: getFieldVector for its check, isArrowBacked to report false for a CometVector wrapping something it cannot write. Such batches take the conversion path, which rebuilds them against the Arrow schema derived from the Spark schema and so normalizes large offsets to VarChar/VarBinary.

Regression test added in UtilsSuite at the level the fix lives, covering both LargeVarCharVector and LargeVarBinaryVector. I did not attempt the mapInArrow end-to-end path, matching the scope you tested.

This is pre-existing rather than new here, since the previous single-stream writer went through the same getBatchFieldVectors call, but it is cheap to fix and the fix is contained.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed the durable part of this as #5488.

To be precise about what does and does not outlive this PR, since I called it pre-existing above and that was ambiguous. CometInMemoryTableScanExec and ArrowCachedBatchSerializer are both new files in #5051, so the two findings in the scan node cannot survive it being dropped. This one is different: getFieldVector and both Utils.serializeBatches consumers, getByteArrayRdd in operators.scala and CometBroadcastExchangeExec, are on main today.

And the fix here does not close it. Making isArrowBacked reject those vectors only reroutes the cache write path to conversion; the broadcast and collect paths have no conversion fallback and still throw. isSupportedFieldVector is a useful building block for a real fix, but it goes away with this PR too. #5488 has the producer trail through CometMapInBatchExec, and notes that neither of us ran the mapInArrow path end to end.

@viirya

viirya commented Aug 26, 2026

Copy link
Copy Markdown
Member

Will take a look! It looks similar to apache/spark#56334 too although seems in Comet we store compressed IPC stream and can decode only selected columns. cc @viirya too

I don't look at the details of this yet. I can do it later this week.

In Spark, the Arrow cache also stores IPC format data and can decode/decompress the selected columns only.

@viirya

viirya commented Aug 26, 2026

Copy link
Copy Markdown
Member

@viirya if you have a moment, the question I would most value your view on is whether the buffer-span projection reader was worth its complexity in practice, or whether you would take the simpler split-by-column route knowing what you know now.

"the buffer-span projection reader" is what you implemented in this PR?

So your question is about the column pruning (i.e., selection of columns) in the cache?

I did the column pruning in Arrow cache by this Spark PR apache/spark#58177.

Five issues from review of 86a91ea.

Emit zero-column batches for an empty-output cache scan. The scan
widened an empty projection to a placeholder column so the native plan
would have a non-empty scan schema, but that claim was untrue: a
zero-column scan plans and runs. Meanwhile the emitted batches
disagreed with the declared output, so a join over a cached relation
read the wrong column and silently returned a wrong sum rather than
failing. Removing the widening fixes the join and makes count(*) decode
nothing at all.

Serialize each column with the dictionary provider it was decoded with.
Columns decoded from separate streams have independent dictionary ID
namespaces, so re-encoding a decoded batch with the first column's
provider could not resolve the rest.

Release readers opened before a later column fails to decode. The
holder is published to the task-completion listener only after its
constructor returns, so a partial failure leaked off-heap for the life
of the executor.

Build the cached RDD in doExecuteColumnar rather than at planning time.
CachedRDDBuilder.cachedColumnBuffers executes the cached plan, so
planning a query over an adaptively cached relation ran a job and
finalized that plan during EXPLAIN.

Report large-offset Arrow vectors as not directly writable.
isArrowBacked accepted any CometVector, including one wrapping
LargeVarCharVector or LargeVarBinaryVector, which getFieldVector
rejects; such batches now take the conversion path instead of failing
at cache materialization.
@andygrove

Copy link
Copy Markdown
Member Author

@sunchao thanks, that is a genuinely valuable review and all five findings were real. Fixed in cb07053, with a regression test for each. Replies are on the individual threads; the short version:

Finding Status Introduced by this PR?
Join over empty-output cache scan returns wrong results Fixed No, predates it
Dictionary provider reused across columns Fixed Yes
Readers leak when a later column fails to open Fixed Yes
Cache RDD built during planning Fixed No, predates it
Large-offset Arrow vectors accepted then rejected Fixed No, predates it

The P1 fix is better than a repair. The code widened an empty projection to a placeholder column because a comment asserted the native plan needs a non-empty scan schema. That assertion is simply false: passing op.output through unchanged, empty and all, plans and runs fine. So the widening is deleted rather than reconciled with output, which fixes your join and makes count(*) decode nothing at all, which is better than the placeholder column I had introduced a commit earlier.

Two of the five are mine, both consequences of splitting the payload into one stream per column: independent readers mean independent dictionary namespaces, and a partial failure mid-construction has no owner yet. Worth saying plainly, since the split is a change I made deliberately and these are its costs.

One process note in case it is useful to you. My first version of the planning regression test asserted on isCachedColumnBuffersLoaded, which also requires the blocks to be populated, so it stayed false either way and passed with the fix reverted. I only caught it because I revert each fix and re-run before claiming a test works. The version on the branch asserts the cached plan still reports isFinalPlan=false after explain(), which is the effect you actually described, and it does fail without the fix.

All 30 tests in CometInMemoryCacheSuite pass, plus UtilsSuite, CometNativeColumnarToRowSuite and NativeUtilSuite. Scalafix is clean on the 3.4, 3.5 and 4.0 profiles.

Separately, I went through the Spark work you linked and filed #5487 with the comparison, including a couple of things worth taking from it that are unrelated to these findings.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed commit cb07053e30ba084dd63787bf65ecb88a194f414a. All five original reproductions now pass. Three remaining P2 findings are attached: dictionary handling during native broadcast, cleanup when a reader's own initialization fails, and cache-scan canonicalization/exchange reuse.

Verification: Spark 4.1.3 with JDK 17, freshly compiled JVM classes, and a verified native build from the identical native source tree. All 63 tests passed across CometInMemoryCacheSuite, UtilsSuite, NativeUtilSuite, and CometNativeColumnarToRowSuite. Another 222 correctness checks, 20 type/conversion reads, and 64 configuration checks passed. Each inline finding was independently reproduced. Older Spark versions received source inspection only.

Review assisted by Codex.

Comment on lines +458 to +459
val columns = getBatchFieldVectorsWithProviders(batch)
(columns.map(_._1), columns.flatMap(_._2).headOption)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve each column's dictionary when broadcasting cached batches

The per-column cache write fix works, but this whole-batch helper still discards every provider except the first. A native broadcast can consume CometInMemoryTableScanExec directly after the wrapper is removed, so CometBroadcastExchangeExec.getByteArrayRdd passes a batch with independent providers to Utils.serializeBatches.

I reproduced this with Comet caching, JVM shuffle and AQE disabled: cache a repartitioned DataFrame with two low-cardinality string columns, then run SELECT /*+ BROADCAST(c) */ c.a, c.b FROM range(1) r JOIN dictionary_cache c ON true. It fails with Could not find dictionary with ID 1. Disabling only spark.comet.exec.inMemoryCache.enabled lets the same query over the same cache return all 10,000 rows. Please normalize or safely combine the dictionaries for whole-batch serialization as well as the per-column writer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 70f046a. getBatchFieldVectors now combines the providers instead of taking the first: each dictionary-encoded column's dictionary is looked up under the provider that column was decoded with, and the result is one MapDictionaryProvider covering the whole batch.

Your repro is the regression test, Comet in-memory cache broadcasts a batch whose columns have separate dictionaries. With the fix reverted it fails with exactly IllegalArgumentException: Could not find dictionary with ID 1.

On normalize versus combine: combine. Renumbering would mean rewriting each vector's Field dictionary ID, which is not settable without copying the vector, so a same-ID/different-dictionary clash now raises rather than resolving one column against another's dictionary. That case is unreachable today, since every provider reaching this path descends either from a single NativeUtil importer or from a stream whose IDs that importer wrote, but silently decoding one column with the wrong dictionary was the worse of the two failure modes to leave available.

var i = 0
try {
while (i < buffers.length) {
opened(i) = Utils.decodeBatches(buffers(i), "CometCache")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Close the reader whose own initialization fails

The new guard correctly closes previously returned readers, but decodeBatches can allocate a dictionary and then throw while loading that same column's first record batch. In that case it never returns a reader to opened(i), and neither this catch nor the task-completion listener can close it.

With one valid dictionary-encoded cached string column, I limited the allocator to allow its dictionary message but reject the following indices buffer. After three completed failed tasks, retained Arrow allocations grew from zero to 64, 128 and 192 bytes. The original two-plain-column reproduction now returns to zero. Please make reader initialization exception-safe inside ArrowReaderIterator/the reader factory too, so a failed first batch releases that reader's own allocations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 70f046a. ArrowReaderIterator now closes the reader if its eager first decode throws, so a column that loads its dictionary and then fails on the record batch releases it. I also guarded StreamReader's own getVectorSchemaRoot, which allocates the root's vectors under the same condition: nothing holds the reader until the constructor returns, so nothing else can close it.

Regression test: Comet in-memory cache releases a reader whose own first batch fails to decode. Instead of an allocator limit it truncates the decoded Arrow stream, dropping the end-of-stream marker plus part of the record batch body, so the dictionary loads and the read that follows runs out of input. Reverted, it leaks 64 bytes per attempt, matching the growth you measured; with the fix the allocator returns to baseline.

The existing corruptColumnStream helper was not usable for this. A small column compresses to a single LZ4 block, so truncating the compressed bytes fails the decompressor before Arrow reads anything at all, which is the case that was already covered. The new helper cuts the decoded bytes and re-compresses.

Comment on lines +48 to +51
case class CometInMemoryTableScanExec(
originalPlan: InMemoryTableScanExec,
serializer: CachedBatchSerializer,
cacheBuilder: CachedRDDBuilder,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Canonicalize the embedded scan to preserve exchange reuse

This leaf extends CometExec, not CometNativeExec, so it does not get the latter's canonicalization of embedded plan fields. originalPlan is not a child and retains its expression IDs even after the Comet scan is canonicalized. Equivalent references to the same cache therefore fail sameResult.

With AQE disabled, a UNION of two identical grouped aggregates over one cache produces one shuffle exchange plus ReusedExchange when native cache scanning is disabled, but two independent shuffle exchanges when enabled. Repeating a broadcast join has the same one-versus-two behavior. In the canonical scans, cacheBuilder, relationOutput and scanOutput compare equal; only originalPlan differs, and canonicalizing that field restores equality. Please canonicalize the wrapped scan while preserving its predicates and source semantics to avoid repeating scans, aggregation, shuffle and broadcast work.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 70f046a. CometInMemoryTableScanExec now overrides doCanonicalize and canonicalizes originalPlan by deferring to InMemoryTableScanExec, which normalizes its own attributes and predicates against the relation's output. relationOutput and scanOutput were already normalized by QueryPlan, and cacheBuilder and serializer are the same instances, so that one field was the whole difference.

Regression test: Comet in-memory cache scans of one cache canonicalize equal, so exchanges are reused, using your UNION-of-two-aggregates shape. It asserts one exchange plus one ReusedExchangeExec; reverted, it finds two exchanges and no reuse.

It also asserts that two scans differing only in their pushed predicates still canonicalize unequal. That is the reason for deferring rather than nulling the field the way CometNativeExec.canonicalizePlans does for embedded plans, which would have equated those as well.

Three findings from review, each with a regression test that fails without
the fix:

- A broadcast of a cache scan re-serializes each decoded batch as one stream
  covering every column, but `getBatchFieldVectors` handed the writer only the
  first column's dictionary provider. The columns are decoded from separate
  streams, so they arrive with separate providers and the write failed with
  "Could not find dictionary with ID 1". Combine the providers instead, and
  refuse a genuine ID clash rather than resolve one column against another's
  dictionary.

- Opening an Arrow reader decodes the column's first batch, and a dictionary
  encoded column loads its dictionary before the record batch that indexes into
  it. A failure in between left that dictionary owned by a reader nobody holds:
  the constructor never returned. Close the reader on the way out, in
  `ArrowReaderIterator` and in `StreamReader`'s own schema read.

- `CometInMemoryTableScanExec` carries the wrapped Spark scan as a plan-typed
  field rather than a child, so canonicalization walked past it and left its
  expression IDs in place. Two scans of one cache compared unequal, and a UNION
  of two identical aggregates over it ran two shuffles where Spark's cache scan
  runs one and reuses it. Canonicalize the wrapped scan, which keeps scans with
  different pushed predicates distinct.

Also fix the Spark 3.4 failure in "does not build the cached RDD while
planning": 3.4 defaults `canChangeCachedPlanOutputPartitioning` to false, which
force-disables AQE inside the cached plan, so there is no finalization for the
test to observe. Set the conf so the relation stays adaptive on every version.
@viirya

viirya commented Aug 27, 2026

Copy link
Copy Markdown
Member

Reviewed the current HEAD (70f046abf). This is in good shape — the design boundary is clean (the serializer owns only the cached payload; Spark's cache manager keeps lifecycle, storage, and eviction), the fallback path is careful (enabled && cometCacheFormat, with distinct fallback reasons, and the guarantee that turning it on is never worse than off), and the comments explaining the non-obvious decisions — the empty-output/count(*) handling, doCanonicalize, deferring cachedColumnBuffers to execution, the fixed NATIVE_TIMEZONE label, the pruning-safety restriction in buildFilter — are genuinely helpful and clearly the residue of the earlier review rounds. I re-checked the fixes for @sunchao's findings (zero-column join output, per-column dictionary providers across the cache-write / broadcast / shuffle paths, reader-leak-on-init, canonicalization) and they hold; I didn't find new sibling instances of those bug-classes.

One thing I'd like to see addressed before merge, and one nit.

spark.kryo.registrationRequired=true — the blast radius is wider than "DISK_ONLY".

This is the Kryo-registration gap you already recorded in #5487 ("caching fails outright under spark.kryo.registrationRequired=true"), so the finding itself isn't in dispute — but I think it's under-scoped there, and that changes whether it should be a pre-merge item.

#5487 (and the natural reading of it) frames this as only affecting explicitly serialized storage levels, with DISK_ONLY as the path that exercises it. But a CachedBatch goes through the configured serializer in more cases than that:

  • the *_SER levels (MEMORY_ONLY_SER, MEMORY_AND_DISK_SER),
  • replicated levels (_2) and any cross-executor block fetch,
  • and — the one that matters — the disk-spill portion of the default MEMORY_AND_DISK that a plain df.cache() uses. The in-memory copy is deserialized, but once a partition spills, it's serialized.

So under spark.kryo.registrationRequired=true, an ordinary .cache() that spills is enough to hit this; the user doesn't have to reach for DISK_ONLY. And because CometCachedBatch is unregistered, the failure surfaces as Kryo's "Class is not registered" rather than anything pointing back at this feature, so it's hard to attribute.

The existing DISK_ONLY test doesn't cover it — the suite never sets a Kryo serializer, so that test runs on the default Java serializer.

Suggestion, in order of preference:

  1. Register CometCachedBatch (and the classes it carries) in a Comet Kryo registrator — Spark does exactly this for its own ArrowCachedBatch, so there's a direct precedent — plus a KryoSerializer + registrationRequired=true DISK_ONLY regression test. Small, and it closes a hard-failure hole.
  2. If you'd rather keep it a follow-up: at minimum document it on spark.comet.exec.inMemoryCache.enabled. That doc already lists several limitations but not this one, and the near-zero-cost win is turning a silent hard failure into a documented one.

I'd lean against leaving it purely as #5487 given that plain .cache() + spill can trip it, not just DISK_ONLY.

Nit: the enabled config conflates two things — whether CometDriverPlugin installs the serializer (decided at startup, since spark.sql.cache.serializer is static) and whether scans run natively. The doc explains this honestly, so this is just a "confirm it's intended": once the feature has been on at startup, every supported relation is cached in Arrow format for the life of the application even if the flag is later flipped off (that only reroutes scans back to Spark). That's a reasonable consequence of the static conf, not a bug — flagging it only because a reader turning the flag off at runtime might expect the cache footprint to revert too.

Overall I think this is mergeable as an experimental, default-off feature along the "merge and iterate" lines you proposed, with the Kryo item as the one I'd want handled (fix or document) first. The AQE gap (#5245) — especially the SPARK-37742 stats-divergence you called out, where Comet's own size accounting feeds AQE's join-strategy choice — is the follow-up I'd weight most after this lands.

@viirya

viirya commented Aug 27, 2026

Copy link
Copy Markdown
Member

One small accuracy point on the benchmark, separate from the above.

CometInMemoryCacheBenchmark compares "Comet cache disabled" against "Comet cache enabled", and the withCachedTable comment describes the disabled case as "Spark execution over CometCachedBatch, NOT Spark's own cache format". The second half is the important clarification and it's right — both cases read a Comet-written CometCachedBatch, so this is not a comparison against Spark's cache format. But "Spark execution" reads as if the disabled case runs the query on Spark operators, and from cacheConf that doesn't look right: both cases set COMET_ENABLED / COMET_EXEC_ENABLED = true and spark.comet.sparkToColumnar.enabled = true, and the only flag that flips is COMET_EXEC_IN_MEMORY_CACHE_ENABLED.

So unless I'm misreading the plan, the two cases differ only at the cache-scan boundary, not in the execution engine above it:

  • enabledCometInMemoryTableScan feeds the Comet operators directly (and verifyPlan asserts no CometSparkColumnarToColumnar);
  • disabled → Spark's InMemoryTableScanExec feeds the same Comet operators through a CometSparkColumnarToColumnar bridge — which is a Spark-columnar→Arrow transition whose existence implies the operators above it are still Comet.

The sum(...) aggregation runs on Comet in both cases; what the benchmark isolates is the cost of that scan-boundary conversion, which is exactly the overhead the PR sets out to remove. That makes the 1.5x / 1.1x a measure of "keep the cached scan native vs fall back to a Spark cache scan + convert", not "Comet vs Spark execution" — a narrower and, I think, more accurate framing than the comment's wording suggests.

Worth tightening the withCachedTable comment (and the same "Spark execution over CometCachedBatch" phrasing in the PR description) so a reader doesn't take the disabled case as a Spark-execution baseline. If I've misread and the upper operators do fall back to Spark in the disabled case, I'd be glad to be corrected — the plan for the disabled run would settle it.

…cache benchmark framing

Under spark.kryo.registrationRequired=true, Kryo rejects any class it has not
been told about. Two Comet payloads reach it:

* CometCachedBatch, whenever Spark serializes a cached block. That is not only
  DISK_ONLY: the _SER levels, replication, cross-executor fetches, and the disk
  half of the default MEMORY_AND_DISK all serialize, so a plain df.cache() that
  spills is enough.
* The Array[ChunkedByteBuffer] a native broadcast broadcasts. Spark registers
  ChunkedByteBuffer but not an array of them, so CometBroadcastExchangeExec
  fails here regardless of which Comet features are enabled. That one predates
  the cache work.

Spark registers its own ArrowCachedBatch in KryoSerializer.loadableSparkClasses;
Comet cannot add to that list, and spark.kryo.registrator is read when SparkEnv
builds the serializer, before any plugin runs, so Comet cannot set it either.
So: provide CometKryoRegistrator, document it on the cache config, and have
CometDriverPlugin warn at startup when the combination is unsafe.

DefaultCachedBatch is registered too, because a schema this serializer cannot
store is delegated to Spark's, and Spark only registers that class itself from
4.1 onwards.

Separately, correct what CometInMemoryCacheBenchmark claims to measure. Both
cases run the aggregation on Comet; only the cache-scan boundary moves. The
case labels and comment said "Spark execution over CometCachedBatch", which
reads as a Spark-execution baseline it never was. verifyPlan now asserts the
CometSparkColumnarToColumnar bridge in the disabled case so the framing is
pinned by the benchmark itself.
@andygrove

Copy link
Copy Markdown
Member Author

@viirya thanks — you were right on both counts, and the Kryo one turned out to be wider than either of us had it. Fixed in 7e19dff.

On the blast radius. Agreed, and your framing is what made me go looking. #5487 framed it as a DISK_ONLY concern and that was under-scoped: the _SER levels, replication, cross-executor fetches and the disk half of the default MEMORY_AND_DISK all serialize the CachedBatch, so a plain .cache() that spills is enough. Took your option 1.

But it is not only the cache. While writing the test I checked whether the registrator needed anything beyond the cached batch, and CometBroadcastExchangeExec broadcasts an Array[ChunkedByteBuffer] from Utils.serializeBatches. Spark registers ChunkedByteBuffer in KryoSerializer.toRegister but not an array of them, so a native broadcast join fails under registrationRequired=true on main today, with no cache involved:

java.lang.IllegalArgumentException: Class is not registered: org.apache.spark.util.io.ChunkedByteBuffer[]
  at org.apache.spark.sql.comet.CometBroadcastExchangeExec.doExecuteBroadcast(CometBroadcastExchangeExec.scala:232)

Same registration fixes it, since the cache write path hands back the same type, so I fixed it here rather than filing it — but it is a pre-existing bug rather than one this PR introduces, so say the word if you would rather it were split out.

One thing I could not do, and it changes the shape of the fix. Comet cannot install this for the user. KryoSerializer reads spark.kryo.registrator into a val in its constructor, and SparkEnv.create builds it at SparkContext.scala:478, whereas PluginContainer is constructed at line 574. So unlike spark.sql.cache.serializer — a StaticSQLConf read lazily from the session, which is exactly why maybeSetCacheSerializer can inject it — this one is already captured by the time any plugin runs. Setting it from the driver plugin would reach executors but not the driver's own SparkEnv, so it would work in cluster mode and fail in local mode, which is worse than a documented requirement.

So the fix is three parts rather than one:

  • org.apache.comet.CometKryoRegistrator, with the class list split between Utils.arrowBytesKryoClasses (the Arrow-bytes carriers, shared by broadcast and cache) and ArrowCachedBatchSerializer.kryoClasses (the cached batch and its statistics row).
  • Documented on spark.comet.exec.inMemoryCache.enabled, as your option 2, since option 1 does not remove the need to tell people about it.
  • CometDriverPlugin warns at startup when spark.serializer is Kryo, registrationRequired is true, and the registrator is absent, naming the conf to add. That is the part that addresses "hard to attribute" — the warning fires before anything is cached or broadcast.

Two findings worth recording from building the list.

DefaultCachedBatch had to be registered too. A schema Comet cannot store is delegated to Spark's serializer, and Spark registers that class itself only from 4.1 — 3.4, 3.5 and 4.0 do not, along with GenericInternalRow. So the delegated path fails on those versions without Comet registering it, and some entries that look redundant on 4.1 are load-bearing on 3.5.

java.math.MathContext cannot be registered at all on JDK 17 — kryo.register builds a FieldSerializer eagerly and gets InaccessibleObjectException: module java.base does not "opens java.math". Decimal bounds above Long precision survive anyway because Chill's scala.math.BigDecimal serializer writes the java.math.BigDecimal inside as a class-and-object rather than reflecting over the MathContext, which is why the list registers the java.math types and not the Scala one. Worth knowing before anyone tries to extend this list by intuition.

Testing. CometInMemoryCacheKryoSuite, four tests, all reverted-and-rechecked rather than assumed: cache round trip at DISK_ONLY and MEMORY_AND_DISK_SER over a relation carrying every type whose bounds the statistics row records, read back through a predicate so the bounds go through Kryo too; the native broadcast; and the delegated DefaultCachedBatch path. Without the registrator they fail with exactly the Class is not registered you predicted. Green on the 3.4, 3.5, 4.0 and 4.1 profiles, along with CometInMemoryCacheSuite, UtilsSuite, CometArrowStreamSuite, NativeUtilSuite and CometPluginsSuite.

I did not test the MEMORY_AND_DISK spill case itself. Both levels in the suite serialize deterministically on put; forcing a spill is not cheap to arrange, so the case that motivated your comment is covered by argument rather than by a test.

On the nit — confirmed, that is intended and not a bug. Because spark.sql.cache.serializer is static, the format is an application-lifetime decision made at startup; flipping spark.comet.exec.inMemoryCache.enabled off later only routes scans back to Spark's execution path and the footprint stays Arrow. The config doc says that, and I would rather keep the honest explanation than split the flag, since a relation whose cached format could change mid-session could not be read back reliably.

Agreed on AQE (#5245) being the follow-up to weight most, and SPARK-37742 within it for the reason you and @peterxcli both landed on.

@andygrove

Copy link
Copy Markdown
Member Author

@viirya you read it correctly and I had it wrong. Fixed in 7e19dff.

Here is the plan for the disabled run, which settles it:

*(1) CometColumnarToRow
+- CometHashAggregate [sum#265L, sum#266L, sum#267L], [Final], [sum(id#0L), sum(k#1L), sum(v#2L)]
   +- CometExchange SinglePartition, ENSURE_REQUIREMENTS, CometNativeShuffle
      +- CometHashAggregate [id#0L, k#1L, v#2L], [Partial], [partial_sum(id#0L), ...]
         +- CometSparkColumnarToColumnar
            +- Scan In-memory table bench_cache [id#0L, k#1L, v#2L]
                  +- InMemoryRelation [...], StorageLevel(disk, memory, deserialized, 1 replicas)

The aggregation is CometHashAggregate on both sides, over CometExchange, and the only difference is that CometSparkColumnarToColumnar + Scan In-memory table becomes CometInMemoryTableScan. So your reading of cacheConf was right and the "Spark execution" half of that comment was simply wrong — the correct framing is the narrower one you gave: keep the cached scan native versus fall back to a Spark cache scan and convert.

What changed:

  • The case labels are now Spark cache scan + CometSparkColumnarToColumnar and CometInMemoryTableScan, so the comparison is legible from the benchmark output alone rather than depending on a comment.
  • The withCachedTable comment states which flag moves and which does not, and keeps the separate point that neither case is a baseline for Spark's own cache format.
  • The PR description is corrected the same way. I had already fixed one wrong framing there — presenting the numbers as a speedup over Spark's cache — and replaced it with a second wrong one, so the description now says both explicitly.

One addition beyond what you asked for: verifyPlan now asserts CometSparkColumnarToColumnar is present in the disabled case, not just that CometInMemoryTableScan is absent. The labels claim a plan shape, so the benchmark should fail rather than mislabel itself if that shape ever changes. I verified the assertion holds for all five of the benchmark's queries, including the count-only and full-width ones where the scan output differs.

I could not re-run the benchmark itself in my current environment — exec:java cannot bind a socket there, unrelated to this change — so the numbers in the description are the previous run's. Only labels and comments moved, so they still describe the same measurement, but flagging it rather than implying I re-measured.

@andygrove

Copy link
Copy Markdown
Member Author

Filed the broadcast half of the Kryo work as #5510 so it can be split out if reviewers prefer.

To be clear about what is and is not in this PR's scope: CometBroadcastExchangeExec broadcasts an Array[ChunkedByteBuffer], which Spark does not register, so a native broadcast join fails under spark.kryo.registrationRequired=true on main today with no cache involved. 7e19dff fixes it here because the registrator this PR had to add anyway covers the same type — but it is a pre-existing bug, not one this PR introduces, and it should not have to wait on an experimental default-off feature.

The two are entangled only through the shared class list (Utils.arrowBytesKryoClasses), so splitting is straightforward: that list plus the registrator plus the warning land first, and this PR keeps only ArrowCachedBatchSerializer.kryoClasses and the cache tests. #5510 spells out what a standalone fix needs, including the reason Comet cannot install spark.kryo.registrator itself.

@viirya @sunchao happy either way — say which you would rather review and I will restructure, or leave it as is if folding it in here is fine.

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both addressed in 7e19dff20, and I went through the fix rather than just the replies — it's complete, and in the Kryo case more thorough than my comment asked for.

Kryo. The registration is transitively complete, which is the part that actually matters under registrationRequired=true — registering CometCachedBatch alone would still fail. arrowBytesKryoClasses covers ChunkedByteBuffer, Array[ChunkedByteBuffer], and the on-heap chunk types inside it (Array[ByteBuffer] and the concrete ByteBuffer class); kryoClasses covers the stats row's reachable types (GenericInternalRow, Array[Any], UTF8String, Decimal, and the BigDecimal/BigInteger a high-precision Decimal carries), which is exactly where I'd expected a gap and there isn't one. Registering DefaultCachedBatch for the pre-4.1 delegated path is a nice catch too. The new suite exercises DISK_ONLY and MEMORY_AND_DISK_SER with a column of each stats-tracked type, so the round-trip is actually covered rather than asserted.

Good find on the broadcast half (#5510). That's the sibling instance the finding pointed at without my naming it: CometBroadcastExchangeExec hands the same Array[ChunkedByteBuffer] to Spark's serializer, so a native broadcast join already fails under registrationRequired=true on main today, cache or no cache. Fixing it here because the registrator covers the same type is reasonable; splitting it out to keep it independent of an experimental feature is also reasonable — either way it shouldn't wait on this PR.

The one thing that stays a caveat is that spark.kryo.registrator can't be auto-set the way spark.sql.cache.serializer can, since it's read before the plugin runs. The startup warning on the unsafe combination plus the doc on the config is the right handling given that ordering — a user can't have it set silently for them, so being told at startup is the next best thing.

Benchmark. Pinning the framing with a verifyPlan assertion on the CometSparkColumnarToColumnar bridge in the disabled case is better than the comment fix I suggested — it keeps the "both cases run on Comet; only the scan boundary moves" claim honest against future edits rather than in prose that can drift. Thanks for the disabled-run plan; it settles it.

Both of my pre-merge items are resolved from my side. The remaining things I'd track are all post-merge and non-blocking: the AQE coverage in #5245 — SPARK-37742 most of all, since Comet's own size accounting feeds AQE's join-strategy choice and a divergence there would change plans silently — and the per-column framing follow-ups in #5487. This looks mergeable to me as an experimental, default-off feature.

@sunchao

sunchao commented Aug 28, 2026

Copy link
Copy Markdown
Member

@viirya @sunchao happy either way — say which you would rather review and I will restructure, or leave it as is if folding it in here is fine.

Thanks @andygrove , that makes sense. I'm in favor of splitting the broadcast fix into its own PR under #5510, since it affects existing native broadcast joins without the cache feature. The shared registrations, registrator, startup warning, and a broadcast regression test can land there first, with #5051 keeping the cache-specific registrations and tests. That lets us fix the existing bug independently and keeps this PR focused.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed commit 7e19dff202288330c74bd777ae7e63dd02db0ae7. The earlier cleanup and exchange-reuse regressions pass. Targeted probes reproduced two remaining native cache execution failures and unnecessary AQE shuffles on cold-cache reads. Five inline findings cover those issues, the missing CI suite registration, and the benchmark projection.

Verification: fresh native and JVM builds on Spark 4.1.3/JDK 17; all 260 selected tests across nine suites and 40 strict-Kryo component checks passed. Separate Spark/native probes reproduce the failures, with fallback and isolated code controls confirming both execution regressions. Older Spark versions received source inspection only.

Review assisted by Codex.

builder: OperatorOuterClass.Operator.Builder,
childOp: Operator*): Option[Operator] = {

val scanTypes = op.output.flatMap(attr => serializeDataType(attr.dataType))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Widen nested nullability in the cache scan schema

This serializes containsNull=false/valueContainsNull=false into the native scan schema, reintroducing the nested-type failures already fixed for CometLocalTableScanExec. On Spark 4.1.3, caching Seq(Seq(1, 2, 3), Seq(4, 5)) and evaluating slice(x, 2, 2) fails because spark_array_slice returns List(non-null Int32) while its declared result is List(Int32). map_entries over a cached Map[Int, Int] similarly panics on the value field's nullability. Both work uncached and with native cache scanning disabled over the same payload. An isolated change to serializeDataType(attr.dataType.asNullable) fixes both. Please preserve that normalization at this scan boundary and add cache regressions.

Comment on lines +508 to +510
case Some(existing) if existing.getVector ne dictionary.getVector =>
throw new SparkException(
s"Columns of the same batch carry different dictionaries under ID $id")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle equivalent dictionaries from repeated cached columns

Selecting one cached dictionary column twice opens two readers for the same IPC stream. Their dictionary IDs and values match, but their vector objects differ, so this identity check rejects valid data. With a dictionary-encoded cache produced through JVM shuffle and AQE disabled, spark.range(1).join(broadcast(cached.select(cached("s1"), cached("s1"))), lit(true), "inner").collect() fails with 'Columns of the same batch carry different dictionaries under ID 0'. Both plans use CometBroadcastExchange; disabling only native cache scanning returns all 2,000 rows correctly. Please support equivalent dictionaries or normalize IDs instead of requiring object identity.

Comment on lines +54 to +55
extends CometExec
with LeafExecNode {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve AQE cache materialization and reoptimization

Replacing Spark's InMemoryTableScanLike with an ordinary leaf during AQE preparation prevents TableCacheQueryStageExec creation. On Spark 4.1.3, first reading an adaptive cached join through a grouped aggregate executes an extra outer shuffle with native caching enabled: native disabled materializes the cache and removes that shuffle, while enabled retains it. A join against a cold cache already partitioned by its join key similarly runs two outer shuffles instead of one. Warm-cache queries avoid the difference. Results are correct, but the cold path unnecessarily reshuffles data. Please preserve materialization and reoptimization at the cache boundary. This is an executed failure of the scenarios requested in #5245, not just a coverage concern.

* This needs its own suite because `spark.serializer` and `spark.kryo.registrator` are read when
* `SparkEnv` builds the serializer, so they cannot be changed per test.
*/
class CometInMemoryCacheKryoSuite extends CometTestBase {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Add the new Kryo suite to both CI test matrices

This new suite is absent from the suite lists in both .github/workflows/pr_build_linux.yml and .github/workflows/pr_build_macos.yml. python3 dev/ci/check-suites.py exits 255 with 'Suite not found in workflow ... CometInMemoryCacheKryoSuite'; the current GitHub Preflight job fails for exactly this reason and the platform/Spark test jobs are skipped. Add the suite to both matrices so CI can validate the PR and keep exercising the new Kryo regressions.

Comment on lines +99 to +101
runCacheBenchmark(
"in-memory cache full projection (6 of 6 columns)",
s"SELECT count(id), count(k), count(v), count(s1), count(s2), count(s3) FROM $cacheTable")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Make the full projection benchmark consume all six columns

The Range-derived id, v, and s3 columns are non-nullable, so Catalyst rewrites count(id), count(v), and count(s3) to count(1). Both the native and fallback benchmark plans therefore scan only k, s1, and s2, despite this case being labeled 6 of 6 columns. This also omits the highest-cardinality string column s3. Use expressions that consume all six columns and assert the actual cache-scan projection, otherwise the benchmark does not measure the full-width per-column decoding cost it claims.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore options for accelerating InMemoryTableScanExec

9 participants