Skip to content

[EPIC] In-memory cache audit: correctness and performance follow-ups for InMemoryTableScanExec support #6834

Open

Description

@andygrove

What is the problem the feature request solves?

Comet's support for InMemoryTableScanExec was audited on main (bfacc43, the day #5634 made spark.comet.exec.inMemoryCache.enabled default to true on Spark 3.5+). This epic collects the correctness and performance findings so they can be worked and tracked in one place. Items are listed by priority.

Correctness

Verified sound during the audit, with no action needed: canonicalization and exchange reuse across scans; statistics bounds (all-null batches, NaN, collated strings, the StartsWith exclusion); the write-side layout check and read-side node/buffer validation; dictionary decoding; the zero-column count(*) path; Kryo registrations and the plugin's install gate; delegation of unsupported types; observed metrics; leak handling on the compress and decompress failure paths. Predicates that contain subqueries sit inside the kept Spark scan, out of reach of subquery planning and reuse (#6577 covers the subqueries exposure), but the stats filter only extracts literals, so they are never evaluated there.

Performance

  • Spark 3.4 and 3.5 build the cache through rows whenever AQE is on. Spark marks a cached AdaptiveSparkPlanExec columnar only from 4.0 (CachedBatchSerializer.convertToColumnarPlanIfPossible), so on 3.x InMemoryRelation takes the row path: native plan, columnar-to-row, UnsafeRow, then back to Arrow before compression. The materialization table in docs/source/user-guide/latest/in-memory-cache.md (944 ms against Spark's 3285 ms) was measured on 4.1 and does not apply to 3.x. Comet cannot change this without a Spark hook; document it under Limitations.

  • Statistics gathering is a per-cell JVM loop. ArrowCachedBatchSerializer.gatherColumnStats calls isNullAt and a typed getter per row per column through the ColumnVector interface, including for nested columns that only record a null count, where an Arrow-backed vector answers from its validity bitmap. The benchmark has a case for the collector but the docs record no number for it. Take null counts from getNullCount when Arrow-backed as a first step.

  • The in-memory cache stores small input batches as they arrive, so a cached repartition over many map tasks holds thousands of small batches #6950: a cache built from a shuffle read keeps one small batch per map output block. The columnar write stores each input batch as it arrives. Native filters already emit full 8192-row batches, but the shuffle reader returns each map task's block as its own batch, so repartition(50, a).cache() over 5M rows read from 200 map tasks holds 10000 batches of about 500 rows instead of 633 full ones: 34% more payload, 56% more memory and about 60% longer to decode. Coalesce small batches in encodeBatches up to spark.sql.inMemoryColumnarStorage.batchSize, where the row path cuts them.

  • Every cached batch allocates a 1 MiB heap chunk, however small its payload #6951: every cached batch allocates a 1 MiB heap chunk, however small its payload. CachedBatchIpc.serialize writes into a ChunkedByteBufferOutputStream with 1 MiB chunks, which allocates a whole zeroed chunk on the first write and then copies the payload into an exactly sized buffer. Batches of 100 to 2000 rows take 13% to 57% longer to write, and the 10000-batch cache in The in-memory cache stores small input batches as they arrive, so a cached repartition over many map tasks holds thousands of small batches #6950 allocates about 10 GiB of chunks to hold 37 MiB. Size the chunk from the payload, capped at 1 MiB.

  • On Spark 4.0+, an adaptive cached plan that ends in a Spark row operator is converted into Spark column vectors and then copied into Arrow #6952: on Spark 4.0+, an adaptive cached plan that ends in a Spark row operator is copied twice. AQE plans the final stage for columnar output, so Spark puts a RowToColumnarExec at its root and encodeBatches copies the resulting OnHeapColumnVectors into Arrow, where the row path writes Arrow once. This happens even when Comet runs none of the query, and the build takes 11% longer than with AQE off. Replace that conversion with CometSparkToColumnarExec.

  • A cached aggregate stores each output slice with the bytes of the slices before it, inflating its size statistics and memory #6995: a cached aggregate stores each output slice with the bytes of the slices before it. CachedBatchIpc.serialize writes a sliced batch's data buffers from byte 0, so a partition whose aggregate emits K slices stores and reports about (K + 1) / 2 times its string data. 10M distinct keys at Spark's defaults report 505 MiB against 144 MiB, and a 200K-key cache in one partition reports 28.7 MiB against 2.9 MiB, which turns its broadcast join into a sort-merge join. Rebase the offsets before unloading, as fix: keep the values of sliced batches when coalescing a broadcast #6900 does for broadcasts.

  • Decode and encode are JVM-only. A read copies the body from heap to off-heap, inflates zstd via zstd-jni, runs VectorLoader, wraps CometVectors and only then exports over FFI. Handing the IPC bytes to native and decoding with arrow-rs would remove that JVM work and allocation; the same holds for statistics and compression on the write side. Fits alongside the prefetch item in Adopt techniques from Spark's ArrowCachedBatchSerializer (SPARK-57268) in Comet's cache format #5487.

  • Dictionary-encoded columns are stored plain, so low-cardinality strings pay full inflate and decode cost on every read. Storing dictionaries needs a per-batch schema or dictionary message.

  • The serializer returns no vectorTypes, so Spark's generated columnar-to-row code reads through the ColumnVector interface rather than a concrete class.

  • Cached reads that feed Spark operators are slower than Spark's own cache format #5485: Spark consumers of Comet's format are slower than Spark's own format, up to 1.6x and 2.5x on wide relations. Open and accurately documented.

Related

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions