Conversation
…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.
comphead
left a comment
There was a problem hiding this comment.
Thanks @andygrove should we also test/benchmark nested data?
…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.
|
Good call — this turned out to be worth doing, because the nested coverage that existed was in the one shape that can't fail. Nested columns were already round-tripped, but only under There are now two tests over a six-column relation whose middle four are a struct, an array, a map and a struct wrapping an array. The first gives each column a turn as the sole projection with the other five corrupted, so a run computed short or long by a buffer gets caught reaching into a corrupted neighbour. The second compares values against the uncached query across single-column, paired and out-of-order projections — row counts come from the record batch header, so they can't catch a window that is misaligned but still decompresses, and an out-of-order projection is the case a full one genuinely 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 subtree. Both new tests fail if The format itself needed no changes, so this is coverage rather than a fix. On the benchmark, I added the three projection widths over a relation of struct columns:
The gap is wider than the flat relation's at every width, which is what you'd expect — the conversion the left column pays scales with values per row, not with columns. I left arrays and maps out of the benchmark on purpose, and said so in the docs. The left column needs Spark's cache scan to bridge into Comet operators, and One thing your question shook out that I should flag: while adding the nested cases I made |
…ection-projection
…ection-projection
comphead
left a comment
There was a problem hiding this comment.
Reviewed for Spark semantics, the Arrow/JVM boundary, memory ownership, and scope.
The format change reads as sound. I checked the reference counting in load and decompressed against arrow-java 18.3.0: the 6-arg ArrowRecordBatch constructor is (.., variadicBufferCounts, alignBuffers) with retainBuffers defaulting to true, extractUncompressedBuffer returns a non-retained slice without closing its input, and ZstdCompressionCodec.doDecompress closes its allocation on both error paths. The retain-before-decompress ordering is correct for all four cases (compressed, stored-verbatim, zero-length, no codec). The collated-string change also matches Spark: TypeUtils.getInterpretedOrdering resolves through CollationFactory on 4.x, which is the same ordering the generated partition filter uses, so bounds and predicate agree with no shim.
One finding worth acting on before merge. The rest are cleanups.
Major. The read path derives every buffer window from Utils.toArrowSchema(cacheAttributes), but nothing checks the writer produced that layout, and isArrowBacked admits at least one vector whose buffer count differs. Details inline on CachedBatchIpc.scala. The fix is a length require in load using prefix sums selectedRange already computes.
Scope. Appropriately scoped. The getInterpretedOrdering switch replaces a hand-rolled per-type comparison rather than adding one, so it is not a drive-by.
Tests. The corruption-based projection tests are the right assertion and do distinguish the new behaviour from the old. Two gaps: no coverage of a BinaryType column backed by a fixed-size vector (see the Major), and the new tests cannot move to sql-tests/ because the serializer comes from a suite-level SparkConf and spark.sql.cache.serializer is static.
Performance. Benchmark evidence is present, and the verifyPlan projection-width assertion is a real improvement over the previously published numbers.
Two nits not worth their own thread. Projection(arrowFields: Seq[Field], ...) is indexed positionally and both call sites already pass .toIndexedSeq, so IndexedSeq[Field] would make that a compile-time guarantee. hydrateDictionaries reads oddly beside a scaladoc and a PR description that both say "decode".
| // top-level column owns a contiguous run of it; field nodes and variadic buffer counts run in | ||
| // the same order. | ||
| private val nodeIndices = selectedRange(arrowFields, selectedIndices, fieldNodeCount) | ||
| private val bufferIndices = selectedRange(arrowFields, selectedIndices, fieldBufferCount) |
There was a problem hiding this comment.
Major: the read path trusts an unchecked layout invariant.
Projection derives every node and buffer window from Utils.toArrowSchema(cacheAttributes), but nothing verifies the writer produced that layout. encodeBatches takes the Utils.isArrowBacked(batch) fast path without converting, and Utils.isSupportedFieldVector admits FixedSizeBinaryVector, which Utils.fromArrowType maps to Spark BinaryType while Utils.toArrowType(BinaryType) is ArrowType.Binary. TypeLayout.getTypeBufferCount is 2 for the former and 3 for the latter.
So a cached BinaryType column backed by a fixed-size vector (mapInArrow output, an Iceberg fixed[N] read) is stored one buffer short. Every buffer index from that column onward shifts, and batch.buffers(j) is an unchecked flatbuffer accessor, so the result is wrong values or an ArrayIndexOutOfBoundsException inside setBytes rather than an error naming the cause. The previous per-column streams were self-describing, so they could not drift this way.
selectedRange already computes the full prefix sum. Keeping starts.last for each of the three sequences and checking it in load is O(1) per batch:
require(
batch.nodesLength() == totalNodes && batch.buffersLength() == totalBuffers,
s"cached batch layout does not match the cached schema: ...")Dropping FixedSizeBinaryVector from the isArrowBacked fast path, the way LargeVarCharVector already is, would close the known case too.
There was a problem hiding this comment.
You're right, and the FixedSizeBinaryVector case is real: toArrowType(BinaryType) is Binary at three buffers against that vector's two, so every buffer index from such a column on shifts.
The length check is in, built the way you suggested. selectedRange now returns starts.last alongside the indices, and load compares both totals against nodesLength() and buffersLength() before it touches batch.buffers(j). I mutated the check away to confirm it earns its place: the new test for it then passes with no exception at all, which is exactly the silent-wrong-answer failure you described.
I went wider than dropping FixedSizeBinaryVector from isArrowBacked, because that only closes the top-level case. isArrowBacked answers for the top-level vector and never looks at children, so a struct whose child is a LargeVarCharVector passes it today and is stored with 64-bit offsets and read back with 32-bit. The length check can't catch that one either, since LargeUtf8 and Utf8 are both three buffers. So the write path now asks the direct question: do this batch's vectors already carry the Arrow types the reader will rebuild, recursively? That's CachedBatchIpc.matchesReaderLayout, and a batch that disagrees takes the conversion path it was already taking for non-Arrow input rather than being written unreadable. Names, nullability and a timestamp's timezone are excluded from the comparison — the last because a Comet scan labels with the session zone where the reader rebuilds UTC, and that's a label rather than a layout, so comparing it would send every timestamp column down the conversion path for nothing.
One wrinkle: Arrow-Java puts the index type on a dictionary-encoded vector's own field, so the field to compare is the dictionary's, resolved through the same Utils.lookupDictionary the writer uses.
On coverage, a unit test builds a FixedSizeBinaryVector-backed batch and asserts isArrowBacked accepts it while the write path declines it, with a VarBinaryVector beside it as the control so the predicate can't pass by refusing everything. The reader's check gets its own test that hands the reader one more attribute than the writer stored.
| * Only Utf8View and BinaryView carry one. Comet's cache never writes view vectors today, but | ||
| * the span arithmetic above has to stay correct if that changes. | ||
| */ | ||
| private def fieldVariadicCount(field: Field): Int = { |
There was a problem hiding this comment.
fieldVariadicCount has no effect today, and the stated reason for keeping it ("the span arithmetic above has to stay correct if that changes") does not hold. fieldBufferCount goes through TypeLayout.getTypeBufferCount, which returns 2 for Utf8View and BinaryView and ignores the variadic data buffers entirely, so if view vectors ever reach here the buffer spans misalign whether or not the variadic counts are tracked.
Suggest dropping this and the variadicCounts plumbing in load, and letting the length check above fail loudly instead.
There was a problem hiding this comment.
Agreed, dropped — both fieldVariadicCount and the variadicCounts plumbing in load. Your reasoning holds: getTypeBufferCount is 2 for Utf8View and ignores the data buffers entirely, so the spans misalign whether or not the counts are tracked. With the length check in place a view vector now fails there loudly instead, which is the right outcome given nothing on the write path can produce one.
| * those buffers' recorded lengths. With one payload per batch these are the only per-column | ||
| * sizes available -- there is no separate stream to measure -- and they are exact. | ||
| */ | ||
| private def columnSizes(fields: Seq[Field], recordBatch: ArrowRecordBatch): Array[Long] = { |
There was a problem hiding this comment.
fieldBufferCount(fields(i)) re-walks the column's subtree even though scanLeft already accumulated it. starts(i) until starts(i + 1) is the same range. This runs per batch on the write path, and the .map(...).sum allocates an intermediate per column, so a while over buffers between the two bounds does the whole thing in one pass.
There was a problem hiding this comment.
Done — a single while over buffers between starts(i) and starts(i + 1), so no re-walk and no intermediate collection per column.
| * | ||
| * Compression is applied by Arrow per buffer rather than by wrapping the whole payload in a Spark | ||
| * `CompressionCodec`. That is what makes projection cheap: the message metadata records every | ||
| * buffer's offset and length within the body, so [[readProjected]] can copy out only the buffers |
There was a problem hiding this comment.
readProjected does not exist. The method is Projection.load, so the [[readProjected]] link will not resolve. Same name appears in the alignBuffers comment at line 131 and in the CometCachedBatch scaladoc in ArrowCachedBatchSerializer.scala.
There was a problem hiding this comment.
Fixed, all three: the class scaladoc and the alignBuffers comment now say Projection.load, and so does the CometCachedBatch scaladoc in ArrowCachedBatchSerializer.scala.
| // Not load bearing for memory: the plan that produced the batch releases its vectors | ||
| // either way, and dropping this line leaks nothing. It is here because serializeBatches | ||
| // does the same, so both writers leave a batch they were handed in the same state. | ||
| root.clear() |
There was a problem hiding this comment.
Five lines of comment to say the line does nothing ("dropping this line leaks nothing"). Either drop both, or keep one line noting it matches what serializeBatches leaves behind.
Same pattern elsewhere in this file. decompressed, Projection and load each carry multi-paragraph rationale where the invariant is a sentence. The reference-counting notes earn their space. The reconstructions of what the code does do not.
There was a problem hiding this comment.
Fair. That one is three lines now and says only what it's for. I also cut the first paragraph of load's doc, which restated the class doc, and tightened Projection's. I left decompressed alone — both of its paragraphs are the reference-counting rationale you said earns its space.
| .toMap | ||
|
|
||
| /** The decompressor for a body-compression byte, or None when the batch is stored plain. */ | ||
| private def readCodec(compressionType: Byte): Option[CompressionCodec] = |
There was a problem hiding this comment.
Question: CompressionUtil.CodecType.fromCompressionType falls back to NO_COMPRESSION for an unrecognized byte rather than throwing, so this returns None and the payload is read as plain bytes. Is that intended? A batch written by a codec this build does not know would decode to garbage rather than fail.
There was a problem hiding this comment.
Not intended, no. fromCompressionType falls through to NO_COMPRESSION for anything outside its enum, so an unrecognized byte read the body as plain bytes and returned garbage rather than failing. readCodec now checks NoCompressionCodec.COMPRESSION_TYPE directly for the genuine uncompressed case and throws for anything else.
The realistic way to reach it is a corrupt payload rather than a writer from a newer build, since a cache never outlives the application that wrote it. Either way it's the same class of thing as the Major above: better to fail than to answer wrongly.
| // reason. | ||
| // Built once per partition: resolving the Arrow schema and the projection's buffer layout | ||
| // walks every field of the cached relation, which would otherwise be paid per batch. | ||
| val projection = new CachedBatchIpc.Projection( |
There was a problem hiding this comment.
For a SELECT count(*) read, indices is empty and projection is never used, but a wide relation still pays toArrowSchema plus three full field-tree walks per partition. Hoisting the indices.isEmpty branch above this, or making projection a lazy val, keeps the cheapest read cheap. Worth it given this is exactly the wide-relation case the format targets.
There was a problem hiding this comment.
Made it a lazy val. The indices.isEmpty branch below already short-circuits the decode, so that alone is enough to keep a count(*) read from paying toArrowSchema plus the three field-tree walks.
| * closure ships to the executors, where `CometConf` would resolve against whatever `SQLConf` | ||
| * happens to be current on that thread rather than against this session's. | ||
| */ | ||
| private def codecSettings(conf: SQLConf): (String, Int) = |
There was a problem hiding this comment.
A bare (String, Int) read back as codecSetting._1 and ._2 in encodeBatches. Two parameters, or a small case class, costs nothing and is equally serializable into the closure.
There was a problem hiding this comment.
Replaced with a CacheCodecSettings(name, zstdLevel) case class, with the "resolved on the driver" note moved onto it. Doing that shook out something else: the comment block above codecSettings actually describes encodeBatches and had been orphaned when that scaladoc went in, so I've moved it back and corrected its last sentence, which still named Utils.serializeBatches as the writer.
| // because a nested column's run of buffers is as long as its subtree rather than a fixed two or | ||
| // three: a run computed short or long shifts every column after it, so which column is selected | ||
| // decides whether the misalignment reaches into a corrupted neighbour. | ||
| nestedProjectionColumns.indices.foreach { selectedIdx => |
There was a problem hiding this comment.
withNestedProjectionCache is inside the loop, so this materializes the whole relation six times. Caching once and restoring each batch's bytes from a clone() between iterations does the same work in a sixth of the time.
Also, the flat "decodes only the projected columns" above is the selectedIdx = 1 special case of this loop. One per-column loop parameterized over both relations would drop a test without dropping coverage.
There was a problem hiding this comment.
Both done. The fixture is out of the loop, with each batch's payload snapshotted once up front and restored between turns — the nested case runs in 213 ms where it was materializing the relation six times. And the flat test is folded into the same loop, so there's now one test parameterized over both relations rather than a general case and its special case.
| // is handed. The columns were decoded from separate streams, so they arrive carrying separate | ||
| // providers: passing any one of them cannot resolve the others. | ||
| withDictionaryCache { (relation, batches) => | ||
| test("Comet in-memory cache releases its vectors when a column fails after a partial decode") { |
There was a problem hiding this comment.
This and "releases its vectors when a column fails to decode" assert the same thing at two corruption points, and the comment here says this is "the one that actually catches a leak". Worth merging into one test with two corruption modes rather than two fixtures and two allocator snapshots.
There was a problem hiding this comment.
Merged — one test, two corruption points, one fixture and one allocator snapshot per point. The commentary about what each point catches moved into the test's own comment.
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.
|
Thanks @comphead — the Major is real and the fix is in, along with everything else. Replies are on the individual threads; the two nits from your summary are done too ( Two things worth pulling up here rather than leaving buried in a thread. The first is that I went wider than dropping The second is that the length check does carry its weight. I mutated it away and re-ran the test written for it: it fails with "no exception was thrown", which is exactly the silent-wrong-answer mode rather than a crash. So the read path really was returning an answer from a layout it had never checked. Both checks also make the failure modes distinct in a way that should help later: a writer that produces an unexpected layout is now converted at cache time, and a payload that somehow still disagrees at read time fails with an error naming the mismatch rather than an Also changed while I was in here: an unrecognized body-compression byte is now rejected rather than read as plain bytes — Verified on the default profile: |
sunchao
left a comment
There was a problem hiding this comment.
Reviewed 3e74e9dcde021a043a9ad6ccd0322ade2279151c across the IPC/vector boundary, Spark semantics, memory ownership, compatibility, and tests. The recursive writer-layout check and reader length guard address the earlier layout concern. I found two remaining issues, detailed inline: a write-side Arrow memory leak on recoverable compression failure, and an activation example that configures the cache after the serializer has already been selected.
Validation: JDK 17 root-reactor JVM test compilation passed. Exact-head IPC component checks passed 73,800 row comparisons across 19 types and 24 projections, including nested arrays/maps/structs, reordered/repeated/empty projections, nulls, timestamps, and uncompressed/zstd payloads. Normal round trips returned allocator usage to zero. I separately reproduced the compression-failure leak and checked the documentation example with actual Spark 4.1.3 contexts.
Limits: These were component and configuration probes, not full native-dependent Spark suites. Local native compilation was blocked because the configured dependency mirror lacks locked DataFusion 55.1.0. Benchmark performance was not remeasured. The previous-write-path comparison mentioned inline uses its single-column method sequence with an explicit SparkConf, not a full base-revision integration run.
Current CI: Required Checks is failing. Five lint checks failed; inspected logs request removing the redundant s prefix from the error string at CachedBatchIpc.scala:295. The Rust test check and Spark 4.1/JDK 17 build passed, while native-build and downstream jobs were cancelled and several optional jobs were skipped. CI's merge commit ca15f7da281b5a301e7498e9295d942537ec3d17 contains unrelated changes relative to this head, so those results are not exact-head validation.
| val unloader = new VectorUnloader(root, true, codec, true) | ||
| val recordBatch = unloader.getRecordBatch |
There was a problem hiding this comment.
[P2] Release partial Arrow allocations when compression fails
VectorUnloader.getRecordBatch retains each input buffer and accumulates compressed buffers without cleaning them up if a later compression fails. That failure happens before this method's recordBatch.close() finally becomes active. Closing the input batch afterwards does not release those extra references or the earlier compressed buffers, so a failed cache materialization can leave batch-sized off-heap allocations behind.
I reproduced this against the current CachedBatchIpc.serialize and Arrow 18.3.0 with 4,194,304 integer values, an unlimited RootAllocator, and valid zstd level 22. After warming the child JVM, limiting its virtual address space to its current size plus 64 MiB caused zstd's native workspace allocation to fail with an ordinary RuntimeException: Error compressing: Allocation error : not enough memory. After closing the input batch, 18,350,080 bytes (17.5 MiB) remained allocated. The JVM remained usable. Under equivalent pressure, a component using the previous single-column stream-writing sequence threw ZstdIOException and returned Arrow allocation to zero after input close.
Please make write-side compression explicitly own and release partial buffers on failure, similar to the guarded decompression path, and add a failure-path regression test. Merely closing the input/root in a finally will not undo the unloader's retained references.
There was a problem hiding this comment.
You're right, and the repro made this quick to confirm. appendNodes retains each input buffer and then does buffers.add(codec.compress(...)) into a list that lives inside getRecordBatch, so when a compress throws, that retain is stranded and every buffer compressed before it is unreachable from anywhere a caller can get to. Closing the input batch afterwards releases the input's own reference, not the extra retain, and it never sees the compressed buffers at all.
Fixed by not handing the codec to the unloader. serialize now unloads with NoCompressionCodec, which retains and hands each buffer straight back without allocating, and a new CachedBatchIpc.compressed does the compression in the same shape decompressed already uses on the read side: retain, compress inside a try that releases on a throw, and an outer catch that closes whatever is already in the list. Peak memory is unchanged, since the unloader was doing the same thing one buffer at a time.
The retain is worth a note because it earns its keep differently in the two cases. A codec that allocates consumes the retained reference and returns a buffer of its own; NoCompressionCodec returns the input, and the retain is then the reference the result batch ends up owning. Either way the input buffer is back at its original count once the result is closed.
There is a regression test, and I mutated the fix away to check it isn't passing for free: it then fails with 2,176 bytes still allocated after the input is closed, at 256 rows, which is the same thing you measured at 17.5 MiB with a real workload. The codec it uses is a real zstd codec that throws on the third buffer, so the int column's two are genuine allocations by the time it does. Failing at the first buffer would have passed with no cleanup at all.
| This feature is **experimental and disabled by default**. | ||
|
|
||
| ```scala | ||
| spark.conf.set("spark.comet.exec.inMemoryCache.enabled", "true") |
There was a problem hiding this comment.
[P3] Configure the cache before SparkContext startup
Starting from the documented default of false, this runtime setting cannot install Comet's cache serializer. CometDriverPlugin chooses spark.sql.cache.serializer once during SparkContext initialization, so subsequent cached relations still use Spark's default format and cannot take the native cache path.
I checked this with the current plugin on Spark 4.1.3: startup false followed by this runtime setting kept DefaultCachedBatchSerializer; startup true installed ArrowCachedBatchSerializer.
Please replace this activation example with --conf spark.comet.exec.inMemoryCache.enabled=true on startup, or builder configuration before creating a fresh SparkContext.
There was a problem hiding this comment.
Right, and the page contradicted itself: the paragraph immediately below the example said the config is read at startup. It is now a startup --conf on spark-shell, with a sentence saying why -- the driver plugin picks spark.sql.cache.serializer while the SparkContext is initializing, so a session that started with the default goes on using Spark's format however the config is set afterwards. The "is read at startup" sentence in the next section came out, since it was then saying the same thing twice.
…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.
|
Thanks @sunchao — both are fixed in b978e54, with the details on the individual threads. Two things worth pulling up here. The leak is real, and the fix is not local to the failure point: The lint failure was the redundant Verified on the default profile: |
Which issue does this PR close?
Part of #5487. Ticks these boxes:
Not addressed here: typed readers for the row path (#5485 has no established cause yet, so this would be optimising ahead of a diagnosis) and background prefetch.
Rationale for this change
#5051 stores each cached column as its own compressed Arrow IPC stream, so a scan decodes only the columns it projects. That works, but it pays 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.
Spark's
ArrowCachedBatchSerializer(SPARK-57268) reaches the same projection-proportional decode with no per-column framing at all. It keeps one RecordBatch per cached batch and parses the IPC message flatbuffer, which lists every buffer's offset and length within the body, to copy out only the byte ranges belonging to the selected columns. It depends on Arrow's native per-buffer compression rather than wrapping the whole payload in a SparkCompressionCodec.That approach dominates the current design on footprint while keeping the projection win, so this PR adopts it.
Separately,
tracksBoundsmatchescase StringType, which a collatedStringTypedoes not equal. Collated columns therefore get null bounds andbuildFilterdeclines to push predicates on them. That is correct but loses pruning that Spark manages.What changes are included in this PR?
Cached batch format.
CometCachedBatch.columns: Array[ChunkedByteBuffer]becomesbytes: Array[Byte]: one encapsulated Arrow IPC record batch message and its body, with no Schema message and no end-of-stream marker. The reader rebuilds the schema from the cached relation's attributes viaUtils.toArrowSchema, so a wide relation no longer repeats the same schema bytes once per cached batch. NewCachedBatchIpcowns the format — the field-node, buffer-span and variadic-count arithmetic, and the projected read that copies only the selected columns' buffers into a single off-heap allocation.Compression moves from a whole-payload Spark codec to Arrow's per-buffer IPC compression, which is what lets a projected read decompress only what it selected. New
spark.comet.exec.inMemoryCache.compression.codec(zstddefault,noneavailable) and...compression.zstd.level.Arrow's lz4 is deliberately not offered. It 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:zstdnonelz4lz4 is dominated on both speed and size, so nothing prefers it. zstd also beats storing batches uncompressed on both axes, because the bytes it saves cost more to copy and store than compressing them costs. Reads still accept any codec a batch records.
Dictionary-encoded columns are decoded before being stored. A payload with no schema message has nowhere to record either the index type or the dictionary. Comet's native scans do produce such columns, so this is a real path rather than a defensive one.
Decompression is done in
CachedBatchIpcrather than left toVectorLoader. arrow-java 18.3.0 leaks on the failure path:VectorLoader.loadBufferscollects a field's decompressed buffers into a local list and releases them only after the whole field has loaded, so a buffer that fails to decompress strands every buffer of that field decompressed before it. A string column reaches this — its offsets buffer decompresses, then its data buffer throws — so a single corrupt cached batch leaks off-heap for the life of the executor.Collated string pruning.
tracksBoundswidens to anyStringType, and bounds are compared withTypeUtils.getInterpretedOrdering(dataType)— Spark's own interpreted ordering for the type, which on Spark 4 resolves aStringTypethroughCollationFactory.fetchCollation(collationId).comparator. So bounds are recorded with the same ordering the partition filter Spark generates over that column uses, the collation awareness comes from Spark at every supported version with no shim, and the ordering is resolved once per column instead of re-dispatching on theDataTypeper row. This also replaces the hand-rolled per-typecompare.Reading builds one
CachedBatchIpc.Projectionper partition, holding the projected schema and the node/buffer/variadic index layout. That arithmetic walks every field of the cached relation, so computing it per batch would make the bookkeeping O(total columns) against O(selected columns) of useful work — worst in exactly the wide-relation, narrow-projection case this format exists for.Dependency. Adds
org.apache.arrow:arrow-compression. Already covered by the existingorg.apache.arrow:*shade include, so it relocates with the rest of Arrow; itscommons-compressandzstd-jniare excluded and come from Spark, which ships both on every supported version.How are these changes tested?
CometInMemoryCacheSuitekeeps its existing coverage, with the format-dependent tests rewritten against the new layout:nonetakes a different path on read and was broken until this test was written.BinaryType.VectorLoaderbehaviour above. Both assert the decode error surfaces as itself rather than as an Arrow reference-count error, which is what catches a cleanup path releasing the shared body twice.CometCachedBatchHelperre-derives the IPC buffer arithmetic independently rather than calling intoCachedBatchIpc, so the assertions built on it cannot pass by inheriting a bug from the code under test.Nested columns get their own projection coverage, over a six-column relation whose middle four are a struct, an array, a map and a struct wrapping an array. They were previously round-tripped only under
SELECT *, which cannot exercise the span arithmetic: 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 a full projection covers the whole buffer sequence however it is partitioned. So one test gives each column a turn as the sole projection with the other five corrupted, and another compares values against the uncached query across single-column, paired and out-of-order projections — a row count comes from the record batch header, so it cannot catch a window that is misaligned but still decompresses. The per-column statistics test runs over the nested relation too. Both new tests fail iffieldNodeCountstops recursing into children.Also run:
CometInMemoryCacheKryoSuite,CometExecSuite,UtilsSuite. Compiles clean against Spark 3.5, 4.0 and 4.1. The shaded jar was checked to confirm arrow-compression relocates and that commons-compress and zstd-jni are not bundled.CometInMemoryCacheBenchmark(Apple M3 Max, JDK 17, Spark 4.1, release build), over a 5M-row relation of six flat columns:CometInMemoryTableScanAnd over a 1M-row relation of six columns whose middle three are structs, one nested two levels deep:
CometInMemoryTableScanverifyPlannow asserts the projection width each case claims, which is what corrected the flat table above: the previousfull projection (6 of 6 columns)row was reading three columns, becausecount()over a non-nullable column is rewritten tocount(1)byNullPropagationand the column is then pruned out of the scan. Onlyk,s1ands2were nullable, and only incidentally, becauseRemaindercan divide by zero. Every column of both relations is nullable now socount(c)genuinely readsc. A real six-column read is 1.9x, not the 2.2x previously published for a three-column one.Arrays and maps are deliberately not in the benchmark. The baseline arm needs Spark's cache scan to bridge into Comet operators, and
CometSparkToColumnarExecdeclinesArrayTypeandMapType, so for a query projecting one of those that arm does not exist and the two cases stop measuring the same boundary. They are covered in the suite instead.As with #5051, both columns read the same Comet-written
CometCachedBatchand Comet execution is on in both, so this measures keeping the cached scan native against falling back to a Spark cache scan and converting — not Comet against Spark execution, and not a comparison with Spark's own cache format.Notes for reviewers
spark.comet.exec.inMemoryCache.enabledstaysfalse, as it has been since feat: add experimental native support for in-memory cache, disabled by default #5051, so none of this reaches a default configuration. Everything below the config, including the change of cached format, only affects users who have opted in to the experimental native cache. The config is static: its value at startup is what decides whether Comet's cache serializer is installed at all.spark/benchmarksis in.gitignore, so Comet does not currently commit results files the way Spark does. Doing it properly needs that directory un-ignored plus a workflow to regenerate them, or the numbers rot — worth its own decision rather than being slipped in here. The measured tables live in the new docs page instead.