Skip to content

feat: enable Comet's in-memory cache by default - #5634

Draft
andygrove wants to merge 21 commits into
apache:mainfrom
andygrove:feat/cache-enabled-by-default
Draft

andygrove wants to merge 21 commits into
apache:mainfrom
andygrove:feat/cache-enabled-by-default

Conversation

@andygrove

@andygrove andygrove commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Part of #5487.

Rationale for this change

spark.comet.exec.inMemoryCache.enabled has been off by default since #5051, so the native cache path only ever runs under CometInMemoryCacheSuite and CometInMemoryCacheKryoSuite, which exercise it deliberately. Nothing tells us how it behaves under the rest of the suite: the Spark SQL test diffs, the fuzz suites, the Iceberg and Delta jobs, and any test that calls cache()/persist() incidentally.

This PR flips the default so a full CI run exercises the cache format everywhere caching happens. It is opened as a draft to collect that signal, not as a proposal to ship the feature on by default. A clean run is evidence the format is ready for that conversation; a red one is the list of things to fix.

What changes are included in this PR?

  • spark.comet.exec.inMemoryCache.enabled defaults to true.
  • CometDriverPlugin.maybeSetCacheSerializer read the config out of SparkConf with a hardcoded false fallback, so flipping the ConfigEntry alone would have left the cache serializer uninstalled for anyone who did not set the key explicitly. It now falls back to the entry's own default, the same way the plugin already reads spark.comet.metrics.enabled.
  • The in-memory cache user guide records the new default and shows how to turn the feature off.

The feature is still described as experimental.

How are these changes tested?

The point of the PR is the CI run itself. Every job now builds cached tables in Comet's Arrow format wherever a test caches anything, rather than only in the two suites that opt in.

The existing cache suites are unaffected: they set the config explicitly, including the two cases that set it to false. CometInMemoryCacheSuite's driver-plugin test passes an explicit true, so it still covers the install path rather than relying on the new default.

…trings

Follow-up to apache#5051, applying items from apache#5487.

Replace the per-column Arrow IPC stream layout of `CometCachedBatch` with a
single encapsulated IPC record batch message per cached batch, carrying no
Schema message and no end-of-stream marker. The reader rebuilds the schema
from the cached relation's attributes, so a wide relation no longer repeats
the same schema bytes once per cached batch.

Compression moves from a whole-payload Spark codec to Arrow's per-buffer IPC
compression. That is what makes projection cheap: the message metadata records
every buffer's offset and length in the body, so `CachedBatchIpc.readProjected`
copies out only the byte ranges of the columns a scan selected and decompresses
just those. This subsumes the separate "drop the schema message" item, since
there is no longer a per-column stream to frame.

Dictionary-encoded columns are decoded before being stored: a payload with no
schema message cannot describe a dictionary encoding.

The codec defaults to zstd, and lz4 is deliberately not offered. Arrow's lz4 is
commons-compress's pure-Java implementation, unrelated to the JNI-accelerated
lz4-java behind `spark.io.compression.codec`. Over a 200k-row six-column
relation it measured 205s to write against 347ms for zstd, while also producing
larger output, so no workload prefers it. zstd also beats storing batches
uncompressed on both axes (347ms and 2 MiB against 1743ms and 13 MiB), because
the bytes it saves cost more to copy and store than compressing them costs.

Decompression is done here rather than left to `VectorLoader`, which leaks:
`VectorLoader.loadBuffers` collects a field's decompressed buffers into a local
list and releases them only after the whole field loads, so a buffer that fails
to decompress strands every buffer of that field decompressed before it. A
string column reaches this, its offsets buffer decompressing before its data
buffer throws.

Also track statistics bounds for collated string columns, comparing with the
collation's own ordering through a new `CometTypeShim.compareStrings`. Matching
the bare `StringType` object excluded collated columns, which then got null
bounds and no pruning.

Benchmark over a 5M-row six-column relation, keeping the cached scan native
against falling back to a Spark cache scan and converting: 1.3x on a repeated
scan, 1.3x on a narrow projection and 2.3x on a full projection.
…on layout

Cleanup pass over the cache format change. No behaviour change.

Drop the `compareStrings` shim in favour of `TypeUtils.getInterpretedOrdering`.
That method is public with the same signature on every supported Spark version,
and on Spark 4 it resolves a `StringType` through
`CollationFactory.fetchCollation(collationId).comparator` -- the comparison the
shim was reaching for. So the collation awareness comes from Spark itself and
the shim, its Spark 3.x stub and the hand-rolled per-type `compare` all go.
The ordering is now resolved once per column per partition rather than being
re-dispatched on the `DataType` twice per row.

Build the projection's index layout once per partition instead of per batch.
The node, buffer and variadic index arithmetic is a pure function of the cached
schema and the selected columns, but it walks every field of the relation, so
recomputing it per batch made the bookkeeping O(total columns) against O(selected
columns) of useful work -- worst in the wide-relation, narrow-projection case the
format exists for. `CachedBatchIpc.Projection` now holds that layout and the
projected schema, and owns the whole decode; `ProjectedBatch` is left with
ownership only. This also puts the projected schema next to the code that packs
buffers in the same order, an invariant that previously spanned two files
unstated.

Smaller cleanups: use Arrow's `DataSizeRoundingUtil.roundUpTo8Multiple` rather
than open-coding IPC body alignment; size the serialization buffer from the
record batch's known body length instead of growing from 32 bytes; resolve
decompressors once instead of per batch; share the dictionary lookup guard
between `Utils.combineDictionaryProviders` and the cache writer; read the codec
config through one helper carrying the driver-vs-executor rationale; and collapse
the duplicated compressed-buffer predicate and scramble loop in the test helper.

Corrects two `Utils` scaladocs that still described the per-column stream format
this change replaced. Benchmark and codec figures in the docs re-measured against
the current code.
arrow-compression ships
META-INF/services/org.apache.arrow.vector.compression.CompressionCodec$Factory.
The shade plugin copies it verbatim without a ServicesResourceTransformer, so
the jar declared a provider for Spark's own unshaded Arrow interface while
naming a class that exists here only under the relocated package. Every
ServiceLoader lookup Spark's Arrow made then failed with a
ServiceConfigurationError, which took CompressionCodec.Factory's static
initializer down with it and broke unrelated Arrow IPC reads, including
mapInArrow.

Add ServicesResourceTransformer so the service file name and its contents are
both relocated. arrow-compression is the only bundled artifact that ships one.

Also drop an unused NonFatal import that scalafix flagged.
"releases its vectors when a column fails part way through" zeroed the last
16 bytes of a compressed buffer and required the read to fail. Whether that
fails is a property of the zstd runtime, not of Comet: the cached payload is
byte-identical across Spark versions, but Comet takes zstd-jni from Spark
rather than from arrow-compression, and 1.5.5 (Spark 3.4, 3.5) decodes that
frame while 1.5.7 (Spark 4.x) reports it corrupt. So the test passed on 4.x
and failed on 3.4 and 3.5.

The scenario it claimed to cover is also unreachable: CachedBatchIpc
decompresses every selected buffer before VectorLoader runs, so no content
corruption can fail part way through the load. The two remaining leak tests
corrupt a frame from its header onwards, which every zstd release rejects,
and already cover a failure at a column's first buffer and a failure after an
earlier buffer of the same column decoded.

Records the constraint on scramble so a future test does not reach for a
tail-only corruption again, and drops the now unused truncateColumn helper
and the dictionary fixture's payload argument.
Flip spark.comet.exec.inMemoryCache.enabled to true so cached tables are
stored and scanned in Comet's Arrow format without an opt-in.

CometDriverPlugin.maybeSetCacheSerializer read the config out of SparkConf
with a hardcoded false default, so flipping the ConfigEntry alone would have
left the serializer uninstalled unless the user set the key explicitly. It
now falls back to the entry's own default, matching how the plugin reads
spark.comet.metrics.enabled.

Stacked on apache#5543.
@andygrove andygrove changed the title feat: enable Comet's in-memory cache by default feat: enable Comet's in-memory cache by default [WIP] Sep 2, 2026
…enchmark

Addresses review feedback asking whether nested data should be tested and
benchmarked.

Nested columns were already round-tripped, but only under a full projection,
which cannot see the part of the format that is nontrivial for them. A flat
column always owns one field node and two or three buffers; a nested one owns a
run as long as its subtree, and selecting every column covers the whole
sequence however it is partitioned. So the buffer-span arithmetic was only
exercised in the one shape where getting it wrong does not show.

Adds two tests over a six-column relation whose middle four columns are a
struct, an array, a map and a struct wrapping an array:

- Each column takes its turn as the sole projection while the other five are
  corrupted, so a run computed short or long is caught by reaching into a
  corrupted neighbour.
- Values are compared against the uncached query across single-column,
  paired and out-of-order projections. Row counts cannot catch a window that
  is misaligned but still decompresses, and out-of-order is the case a full
  projection cannot stand in for.

The per-column statistics test now runs over the nested relation too, since a
nested column's recorded size is the sum of its whole subtree.

Both new tests fail if fieldNodeCount stops recursing into children.

In the benchmark, adds the three projection widths over a relation of struct
columns, and asserts the width each case claims. That assertion caught the
existing "full projection (6 of 6 columns)" case reading three: count() over a
non-nullable column is rewritten to count(1) by NullPropagation, which prunes
the column out of the scan, and only k, s1 and s2 were nullable -- and those
only incidentally, because Remainder can divide by zero. Every column of both
relations is now nullable so count(c) genuinely reads c, and the documented
numbers are regenerated.

Array and map columns are left out of the benchmark deliberately: the baseline
arm needs Spark's cache scan to bridge into Comet operators, and
CometSparkToColumnarExec declines ArrayType and MapType, so for those the arm
does not exist and the two cases stop measuring the same boundary. The docs say
so rather than leaving it to be rediscovered.
@andygrove andygrove added the enhancement New feature or request label Sep 6, 2026
andygrove and others added 8 commits September 7, 2026 08:18
Reader-side: a cached payload carries no schema, so `Projection` derived
every node and buffer window from `Utils.toArrowSchema(cacheAttributes)`
with nothing checking the writer had produced that layout. `load` now
compares `nodesLength()`/`buffersLength()` against the totals
`selectedRange` already computes, before any unchecked `batch.buffers(j)`.

Writer-side: `isArrowBacked` accepts a `FixedSizeBinaryVector` for a
`BinaryType` column, which is two buffers where the reader rebuilds three,
and it answers for the top-level vector only -- so a struct of large
strings passes it and is stored with 64-bit offsets. `matchesReaderLayout`
compares the batch's Arrow types against the reader's recursively, and a
batch that disagrees takes the conversion path instead. A dictionary
column's field carries the index type, so the dictionary's field is what
is compared.

Also: an unrecognized body-compression byte is rejected rather than read
as plain bytes, `fieldVariadicCount` and the variadic plumbing are gone
(the length check covers view vectors, which the counts would not have),
`columnSizes` no longer re-walks each column's subtree, the write codec is
a case class rather than a bare tuple, the per-partition `Projection` is
lazy so a row-count-only read never builds it, `hydrateDictionaries` is
`decodeDictionaries`, `Projection` takes an `IndexedSeq`, and the stale
`readProjected` links and some over-long comments are fixed.

Tests: the two projection tests become one parameterized over both
relations, caching once and restoring the payload between columns instead
of re-caching; the two leak tests become one with two corruption points.
New tests cover the reader's layout check and the writer declining a
fixed-size-binary batch.
…ample

Compressing through VectorUnloader leaks on the failure path: appendNodes
retains each input buffer and accumulates the compressed ones into a list
local to getRecordBatch, so a buffer that fails to compress strands that
retain and leaves every buffer compressed before it reachable from nothing.
Closing the input batch afterwards undoes neither. Unload plain and compress
in CachedBatchIpc.compressed instead, mirroring what decompressed already
does on the read side, so every allocation stays reachable from an error
path that owns it.

The docs enabled the cache with spark.conf.set, which cannot work: the
driver plugin picks spark.sql.cache.serializer while the SparkContext is
initializing. Show it as a startup --conf.

Also drops a redundant s interpolator that the scalafix lint rejected.
…ection' into feat/cache-enabled-by-default

# Conflicts:
#	docs/source/user-guide/latest/in-memory-cache.md
…-default

# Conflicts:
#	spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowCachedBatchSerializer.scala
ArrowWriter.writeColumns drove its loop from the input ColumnarBatch's width while indexing the writer's fields, which come from the schema the batch is written under. That assumed every producer hands over a batch exactly as wide as the schema.

Iceberg's vectorized reader does not. BatchDeleteFilter.filterBatch reads with the delete filter's requiredSchema, which carries _pos after the projected columns when a data file has position deletes, and trims the extras back only when the file also has equality deletes. A merge-on-read UPDATE writes position deletes and no equality deletes, so the extra column survives into the batch, and caching such a relation failed with ArrayIndexOutOfBoundsException inside the write loop.

Drive the loop from the writer's fields instead, which writes exactly the columns the schema describes: the extras are trailing, the same prefix Iceberg keeps when it does trim. A batch narrower than the schema is a genuine contract violation and is now refused with a message naming both widths.

Closes apache#6087.
@andygrove andygrove added the run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue label Sep 21, 2026
@andygrove
andygrove marked this pull request as ready for review September 22, 2026 14:27
@andygrove andygrove changed the title feat: enable Comet's in-memory cache by default [WIP] feat: enable Comet's in-memory cache by default Sep 22, 2026
@andygrove andygrove added this to the 1.1.0 milestone Sep 22, 2026
@andygrove
andygrove marked this pull request as draft September 22, 2026 14:29
@andygrove
andygrove marked this pull request as ready for review September 24, 2026 09:50
@andygrove
andygrove marked this pull request as draft September 24, 2026 09:50
@andygrove
andygrove marked this pull request as ready for review September 24, 2026 14:10
…-default

apache#5543, which this branch carried at an earlier revision, landed on main as
e8eedcb. Its files take main's version, with this PR's change re-applied on
top: spark.comet.exec.inMemoryCache.enabled defaults to true, and the
in-memory cache guide says so.

@mbutrovich mbutrovich left a comment

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.

First pass, thanks @andygrove!

Comment on lines +107 to +109
if (conf.getBoolean(
CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key,
CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.defaultValue.get)) {

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.

With the default flipped, this installs Comet's serializer for every application that loads CometPlugin, including ones that start with spark.comet.enabled=false or spark.comet.exec.enabled=false. Those applications can never plan CometInMemoryTableScan, so every cached read goes through Spark operators on top of Comet's format. That is the 1.5x to 5.2x slower case in the Limitations table. Keeping the plugin in spark.plugins cluster-wide and switching Comet off with spark.comet.enabled=false is a common setup, and before this PR it left the cache format alone.

#5485 already lists this check as sound, and only calls it narrow because anyone who opted in would have execution enabled. That premise no longer holds once the feature is on by default. Could the plugin also require both configs at startup? This object already has a getBooleanConf helper that falls back to the entry's default, so the new read can use it too:

Suggested change
if (conf.getBoolean(
CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key,
CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.defaultValue.get)) {
if (getBooleanConf(conf, CometConf.COMET_ENABLED) &&
getBooleanConf(conf, CometConf.COMET_EXEC_ENABLED) &&
getBooleanConf(conf, CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED)) {

A session that starts with execution off and turns it on later would then keep Spark's format, and CometExecRule already records a fallback reason for that case. That seems like the right trade, since the serializer is fixed for the application.

The driver-plugin test sets the key to true explicitly, so nothing exercises the fallback to the default, which is the code change in this file. Could you add a case where the key is unset (serializer installed), plus cases for spark.comet.enabled=false and spark.comet.exec.enabled=false (serializer not installed)?

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. With the default on, an application that can never plan the native scan shouldn't get Comet's format. I'll gate the install on spark.comet.enabled and spark.comet.exec.enabled through getBooleanConf, and add the three plugin cases, before this comes out of draft. It won't avoid the slow path on its own, though: with both on, AQE still puts Spark operators above the cache scan, which is #6202.

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.

#6202 is fixed by #6208, which is now merged into this branch in 299d381. With both configs on, AQE keeps the operators above the cache scan native after the re-plan, so the caveat at the end of my reply no longer applies. The gate and the three plugin cases are still to come.

"the disk half of the default MEMORY_AND_DISK storage level.")
.booleanConf
.createWithDefault(false)
.createWithDefault(true)

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.

#5485 treats the slower Spark-operator reads as acceptable because "the cache path is off by default ... rather than a regression in a shipped path." This PR makes it a shipped path, and the format is fixed when the relation materializes, so a user can't avoid it for one query.

With the plugin gated as suggested above, the remaining exposure is a query where the cached scan runs natively and a Spark operator above it reads through a columnar-to-row transition, and a session that turns Comet off at runtime after caching. Is there a benchmark number for the first case against Spark's own cache format? The published numbers compare against Comet off entirely. For the second case, option 1 in #5485 (a fallback reason when Spark operators read a relation stored in Comet's format) is what would tell a user to turn the feature off. Could that land before or with this PR?

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.

The audit turned up part of the answer. Under AQE the first case is the normal outcome, not an edge case. Once the table-cache stage materializes, the re-plan leaves the operators above it on Spark, so a plain aggregate or join over a cached table reads Comet's format through a CometColumnarToRow (#6202). CometInMemoryCacheBenchmark runs with AQE off, so the published numbers don't show it. I'll fix #6202 first and then benchmark with AQE on against Spark's own format, so the number measures the path users will actually get.

Yes to option 1 from #5485 landing with the default flip. It will need to cover the #6202 path too, since nothing records a fallback reason there today.

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.

#6202 is fixed by #6208, which is now merged into this branch in 299d381. Under AQE, an aggregate or join over a cached table now stays native once the table-cache stage materializes, so the first case is no longer the normal outcome: it takes an operator Comet does not support above the cached scan. Option 1 no longer has a #6202 path to cover either, since the operators above the stage now convert, or record their own fallback reason, like any other operator. The benchmark with AQE on against Spark's own format is next.

Comment on lines +27 to +28
This feature is **experimental and enabled by default**. To turn it off, set the config at startup,
alongside the rest of Comet's configuration:

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.

This key did not exist in 1.0.0, so a user upgrading from 1.0.0 goes from Spark's cache format to Comet's without setting anything. With spark.kryo.registrationRequired=true and no CometKryoRegistrator, a df.cache() that spills to disk now fails with "Class is not registered" where it did not before. The plugin only logs a warning for that.

The versioning policy counts a new error under the same explicit configuration as a behavior change. Could you add an entry to the upgrade guide under the next release that covers the format change and the Kryo requirement? The policy asks for a spark.comet.legacy.* key, but spark.comet.exec.inMemoryCache.enabled=false already restores the old behavior, so naming that key in the entry seems enough. If you read the policy differently, it would be good to settle that here, since this is one of the first behavior changes since 1.0.0.

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 on the upgrade guide entry, covering both the format change and the Kryo requirement. Moving the flip past 1.1.0 changes one premise, though. 1.1.0 ships this key with a default of false, so turning it on in the next release is a change to an existing key's default, which is the first case the policy lists. Let's settle the legacy-key question when this comes out of draft.

@andygrove

Copy link
Copy Markdown
Member Author

I audited the in-memory cache and filed what came out of it.

#6202 is the one that bears most on this PR. With AQE on, once the table-cache stage materializes, the re-plan leaves the operators directly above it on Spark, so a plain GROUP BY or join over a cached table reads Comet's format through a CometColumnarToRow into Spark operators. That is the case @mbutrovich asks about in the review, and under AQE it is the common case rather than an edge case. The numbers in the cache guide come from a benchmark that turns AQE off, so they don't show it. It happens with the feature off too, but then the Spark operators read Spark's own format.

#6203 is about the signal this PR collects. 23 of the 53 cache test definitions check a cached query with checkSparkAnswer, and both sides of that comparison read the same cached bytes, so a write-path or pruning bug passes. With two bugs injected on purpose, 54 of the 57 cache tests still passed. A differential check against uncached ground truth caught both, and on unmodified main it matched on every write path, so the format itself looks sound.

I also added a cached-relation repro to #3079. A relation cached while Comet is on keeps native-shuffled partitions on a wide-decimal key, and a later join with Comet off returns 57 of 10000 rows. That doesn't depend on the cache format or on this PR, but caching is what carries it into queries that have Comet off.

@andygrove
andygrove marked this pull request as draft September 24, 2026 16:39
@andygrove

Copy link
Copy Markdown
Member Author

Moving this back to draft. We'll ship the in-memory cache in 1.1.0 the way it is on main, experimental and off by default, and fix #6202, #6203 and the points from this review before turning it on by default in the next release. #3079 doesn't depend on the cache format and is being fixed separately in #6005.

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

Labels

enhancement New feature or request run-iceberg-tests run-spark-4.1-tests Run the Spark 4.1 SQL tests on this pull request instead of waiting for the merge queue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants