Skip to content

Comet cache decode ignores column projection, making narrow reads of wide cached relations slower than Spark #5484

Description

@andygrove

What is the problem the feature request solves?

#5051 adds a Comet cache serializer that stores each cached batch as a single compressed Arrow IPC stream covering every cached column (CometCachedBatch). Column projection happens after the stream is decoded: convertCachedBatchToColumnarBatch calls Utils.decodeBatches(cb.bytes, "CometCache") and then projectBatch(batch, indices).

The consequence is that read cost is flat in the width of the projection. A scan that needs one column of a wide cached relation decompresses and Arrow-decodes all of them. Spark's DefaultCachedBatch stores each column separately, so its cost falls away as the projection narrows.

Measured on a 5M-row, 6-column relation (1 long key, 2 longs, 3 strings), cached and then read repeatedly by Spark operators, comparing spark.sql.cache.serializer set to Comet's serializer against Spark's default:

read shape Spark cache Comet cache ratio
1 of 6 columns 353 ms 1175 ms 3.3x slower
3 of 6 columns 1078 ms 1859 ms 1.7x slower
6 of 6 columns 1227 ms 1728 ms 1.4x slower
materialize cache 5050 ms 2468 ms 2.0x faster

Materialization is faster, so a cache-once/read-many workload that projects a subset of a wide relation can be a net regression even though the write path improved. This is the shape reported against #5051 in #5051 (comment), where a pipeline with many fallbacks to Spark operators went from under 10 minutes to over 15.

Note that the gap does not close entirely at full projection, so decode and row conversion cost is a second, smaller contributor on top of the missing pruning.

Describe the potential solution

Make decode cost proportional to the projection. Options, roughly in increasing order of work:

  1. Write one Arrow IPC stream per column (or per column group) into CometCachedBatch and decode only the streams the scan selects. This keeps the payload Arrow and leaves the stats and pruning logic untouched, at the cost of more, smaller buffers per batch.
  2. Keep a single stream but record per-column byte ranges in CometCachedBatch, so a selected subset can be decoded without inflating the rest. Whether this is workable depends on the compression codec being applied per column rather than across the whole stream, which today it is not: Utils.serializeBatches wraps the entire ArrowStreamWriter in one codec.compressedOutputStream.
  3. Push projection into the decode itself so unselected columns are skipped while reading the stream.

Option 1 is the most direct and reuses the existing serialization path per column.

Whichever route is taken, the benchmark needs a genuine baseline to measure against. CometInMemoryCacheBenchmark currently cannot provide one: spark.sql.cache.serializer is a static conf, so both of its cases read a Comet-written cache and "Comet cache disabled" means Spark execution over CometCachedBatch, not Spark's own cache format. A cross-serializer comparison needs a second SparkSession, and InMemoryRelation memoizes the resolved serializer in a JVM-static field, so InMemoryRelation.clearSerializer() has to be called between sessions or every phase after the first silently reuses the first phase's serializer and the comparison is vacuous.

Additional context

Follow-up from review and testing of #5051. The cache path is off by default (spark.comet.exec.inMemoryCache.enabled=false) and the limitation is documented on that config, so this is a performance gap in an experimental opt-in feature rather than a regression in a shipped path.

Related: #4781 (cache performance follow-ups), #5245 (AQE test coverage).

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions