comphead commented on code in PR #5543: URL: https://github.com/apache/datafusion-comet/pull/5543#discussion_r4017711812
########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/CachedBatchIpc.scala: ########## @@ -0,0 +1,456 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.spark.sql.comet.execution.arrow + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.nio.channels.Channels + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.control.NonFatal + +import org.apache.arrow.compression.{CommonsCompressionFactory, ZstdCompressionCodec} +import org.apache.arrow.flatbuf.{RecordBatch => FlatBufRecordBatch} +import org.apache.arrow.memory.{ArrowBuf, BufferAllocator} +import org.apache.arrow.vector.{FieldVector, TypeLayout, ValueVector, VectorLoader, VectorSchemaRoot, VectorUnloader} +import org.apache.arrow.vector.compression.{CompressionCodec, CompressionUtil, NoCompressionCodec} +import org.apache.arrow.vector.dictionary.DictionaryEncoder +import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel} +import org.apache.arrow.vector.ipc.message.{ArrowBodyCompression, ArrowFieldNode, ArrowRecordBatch, MessageSerializer} +import org.apache.arrow.vector.types.pojo.{ArrowType, Field, Schema} +import org.apache.arrow.vector.util.DataSizeRoundingUtil +import org.apache.spark.SparkException +import org.apache.spark.sql.comet.util.Utils +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** + * The on-disk shape of a `CometCachedBatch` payload, and the two operations over it. + * + * A cached batch is one encapsulated Arrow IPC RecordBatch message followed by its body, with no + * Schema message and no end-of-stream marker. The schema is not stored because the reader already + * has it: `InMemoryRelation` knows the cached relation's attributes, and `Utils.toArrowSchema` + * maps them to exactly the fields the writer unloaded. Leaving it out saves a schema message per + * cached batch, which for a wide relation cached in many batches is a large share of the payload + * that is not data. + * + * 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 + * of the columns a scan selected and let `VectorLoader` decompress just those. A whole-payload + * codec would have to inflate everything before any column could be read. + */ +private[comet] object CachedBatchIpc { + + /** + * The Arrow compression codec named by `spark.comet.exec.inMemoryCache.compression.codec`. + * + * Only the write path consults the config. A batch records which codec compressed it, so the + * read path looks the codec up from the batch itself and keeps reading data cached before the + * config changed. + */ + def compressionCodec(codecName: String, zstdLevel: Int): CompressionCodec = codecName match { + case "none" => NoCompressionCodec.INSTANCE + // Constructed directly rather than through CompressionCodec.Factory, which ignores the level + // and always builds a codec at zstd's default. + case "zstd" => new ZstdCompressionCodec(zstdLevel) + // Arrow's other codec, LZ4_FRAME, is not offered. It is commons-compress's pure-Java LZ4 -- + // no relation to the JNI-accelerated lz4-java behind spark.io.compression.codec -- and + // measures three orders of magnitude slower to write than zstd while also producing larger + // output, so nothing prefers it. Reads still accept it, since the factory the read path uses + // handles whatever codec a batch records. + case other => + throw new SparkException( + s"Unsupported Arrow compression codec for Comet's cache: $other. " + + "Supported values: none, zstd") + } + + // Room for the encapsulated metadata message that precedes the body. The message is a small + // flatbuffer whose size grows with the field count, not the data, so this is a starting size for + // the output buffer rather than a bound -- it grows if a very wide schema needs more. + private val METADATA_SIZE_HINT = 8 * 1024 + + // Decompressors are stateless and shared. Resolving one per cached batch would allocate a codec + // per batch on every scan, and the enum lookup walks the CodecType values each time. + private val readCodecs: Map[CompressionUtil.CodecType, CompressionCodec] = + CompressionUtil.CodecType + .values() + .filter(_ != CompressionUtil.CodecType.NO_COMPRESSION) + .map(t => t -> CommonsCompressionFactory.INSTANCE.createCodec(t)) + .toMap + + /** The decompressor for a body-compression byte, or None when the batch is stored plain. */ + private def readCodec(compressionType: Byte): Option[CompressionCodec] = + readCodecs.get(CompressionUtil.CodecType.fromCompressionType(compressionType)) + + /** + * Serialize `batch` into one encapsulated IPC RecordBatch message. + * + * Returns the message bytes and the on-body compressed size of each top-level column, which the + * caller records in the statistics row. The sizes come from the message's own buffer layout, so + * they are the real stored sizes rather than an estimate. + * + * Dictionary-encoded columns are decoded to their plain form first. A payload with no Schema + * message cannot describe a dictionary encoding, and the schema the reader rebuilds from Spark + * attributes never carries one, so a dictionary-encoded column has nowhere to record either its + * index type or the dictionary itself. Comet's native scans do produce such columns, so this is + * a real path, not a defensive one. + * + * As in `Utils.serializeBatches`, `batch`'s vectors are cleared once written, so callers gather + * anything they need from the batch (statistics, for instance) before calling this. + */ + def serialize( + batch: ColumnarBatch, + codec: CompressionCodec, + allocator: BufferAllocator): (Array[Byte], Array[Long]) = { + val (vectors, hydrated) = hydrateDictionaries(batch, allocator) + try { + val root = new VectorSchemaRoot(vectors.asJava) + // A batch of zero columns carries only a row count, which a VectorSchemaRoot cannot infer + // without vectors to measure. + if (vectors.isEmpty) { + root.setRowCount(batch.numRows()) + } + + // alignBuffers=true matches the 8-byte buffer alignment readProjected reproduces when it + // repacks the selected buffers. + val unloader = new VectorUnloader(root, true, codec, true) + val recordBatch = unloader.getRecordBatch + try { + val fields = vectors.map(_.getField) + // Serializing consumes the batch, as it does in Utils.serializeBatches. The record batch + // holds its own buffers by now -- compressed copies, or retained references when the codec + // is none -- so releasing the vectors here does not touch it. getField still answers + // afterwards: clearing releases buffers, not the schema. + // + // 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() + + // Sized up front from the body length the record batch already knows, plus room for the + // metadata message. An unsized ByteArrayOutputStream starts at 32 bytes and doubles, so a + // multi-MiB payload would be reallocated and recopied a dozen-odd times per batch. + val sizeHint = recordBatch.computeBodyLength() + METADATA_SIZE_HINT + val out = new ByteArrayOutputStream( + math.min(math.max(sizeHint, METADATA_SIZE_HINT), Int.MaxValue.toLong).toInt) + val channel = new WriteChannel(Channels.newChannel(out)) + MessageSerializer.serialize(channel, recordBatch) + (out.toByteArray, columnSizes(fields, recordBatch)) + } finally { + recordBatch.close() + } + } finally { + // Only the vectors this method allocated. The rest belong to the input batch. + hydrated.foreach(v => + try v.close() + catch { case NonFatal(_) => () }) + } + } + + /** + * Everything about reading one projection of this format that does not change between batches. + * + * The index arithmetic here is a pure function of the cached schema and the selected columns, + * both fixed for the life of a scan, but it walks every field of the whole relation rather than + * just the projected ones. Recomputing it per batch would make the bookkeeping O(total columns) + * while the useful work is O(selected columns) -- worst in exactly the wide-relation, + * narrow-projection case this format exists for. A scan builds one of these per partition. + * + * Holding the projected `Schema` here too is what keeps it consistent with the buffers: + * [[load]] packs field nodes and buffers by walking `selectedIndices` in order, and the schema + * is built from the same walk, so the two cannot drift apart. + */ + final class Projection(arrowFields: Seq[Field], selectedIndices: Array[Int]) { + + private val schema = new Schema(selectedIndices.map(arrowFields).toSeq.asJava) + + // A record batch body is a flat, depth-first sequence of buffers in schema order, so each + // 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) + private val variadicIndices = selectedRange(arrowFields, selectedIndices, fieldVariadicCount) + + /** + * Decode the projected columns of one cached payload into a fresh root the caller owns. + * + * Only the selected buffers are ever materialized off-heap or decompressed. The message + * metadata records every buffer's offset and length within the body, so the selected columns' + * bytes are copied into a single allocation -- each 8-byte aligned exactly as Arrow's IPC + * body lays them out -- and the columns that were not selected are never read, let alone + * inflated. + * + * A buffer's recorded (offset, length) covers its on-body bytes including the + * uncompressed-length prefix, so a copied window is exactly what the writer emitted. The + * windows are then decompressed in one pass; see [[decompressed]] for why that is not left to + * `VectorLoader`. + */ + def load(data: Array[Byte], allocator: BufferAllocator): VectorSchemaRoot = { + val readChannel = new ReadChannel(Channels.newChannel(new ByteArrayInputStream(data))) + // Reads the message metadata only. The body stays in `data` and is copied selectively. + val metadata = MessageSerializer.readMessage(readChannel) + if (metadata == null) { + throw new SparkException("Unexpected end of input reading a Comet cached batch") + } + val batch = + metadata.getMessage.header(new FlatBufRecordBatch()).asInstanceOf[FlatBufRecordBatch] + // serialize writes exactly [encapsulated message][body] and nothing after it, so the body is + // the tail of `data`. + val bodyStart = data.length - metadata.getMessageBodyLength.toInt + + val compression = + if (batch.compression() == null) NoCompressionCodec.DEFAULT_BODY_COMPRESSION + else new ArrowBodyCompression(batch.compression().codec(), batch.compression().method()) + + val nodes = new java.util.ArrayList[ArrowFieldNode](nodeIndices.length) + nodeIndices.foreach { j => + val node = batch.nodes(j) + nodes.add(new ArrowFieldNode(node.length(), node.nullCount())) + } + val variadicCounts = new java.util.ArrayList[java.lang.Long](variadicIndices.length) + if (batch.variadicBufferCountsLength() > 0) { + variadicIndices.foreach(j => variadicCounts.add(batch.variadicBufferCounts(j))) + } + + val offsets = new Array[Long](bufferIndices.length) + val lengths = new Array[Long](bufferIndices.length) + var total = 0L + var k = 0 + while (k < bufferIndices.length) { + val buffer = batch.buffers(bufferIndices(k)) + offsets(k) = buffer.offset() + lengths(k) = buffer.length() + total += DataSizeRoundingUtil.roundUpTo8Multiple(lengths(k)) + k += 1 + } + + // allocator.buffer(0) is legal but yields a buffer no window can be sliced from, and an + // all-empty projection (every selected column a NullVector, say) would ask for exactly that. + val body = allocator.buffer(math.max(total, 1L)) + val compressedBatch = + try { + val buffers = new java.util.ArrayList[ArrowBuf](bufferIndices.length) + var position = 0L + var i = 0 + while (i < bufferIndices.length) { + val length = lengths(i) + if (length > 0) { + body.setBytes(position, data, bodyStart + offsets(i).toInt, length.toInt) + } + val window = body.slice(position, length) + window.writerIndex(length) + buffers.add(window) + position += DataSizeRoundingUtil.roundUpTo8Multiple(length) + i += 1 + } + new ArrowRecordBatch( + batch.length().toInt, + nodes, + buffers, + compression, + variadicCounts, + false) + } catch { + case NonFatal(e) => + body.close() + throw e + } + + // The constructor retained each window; slice() alone does not. Dropping `body`'s own + // reference leaves the batch as sole owner of the one allocation, so closing the batch is + // what frees it -- and closing `body` again would drive its reference count negative. + body.close() + val plainBatch = + try decompressed(compressedBatch, allocator) + finally compressedBatch.close() + + // The loader needs no compression factory: every buffer is decompressed by this point. + val root = VectorSchemaRoot.create(schema, allocator) + try { + new VectorLoader(root).load(plainBatch) + root + } catch { + case NonFatal(e) => + try root.close() + catch { case NonFatal(closeError) => e.addSuppressed(closeError) } + throw e + } finally { + plainBatch.close() + } + } + } + + /** + * The indices, within a record batch's flat depth-first sequence, that the selected columns + * own. + * + * `count` gives how many entries of the sequence a field occupies including its descendants, so + * a running total over every field turns a column index into its run within the sequence. + */ + private def selectedRange( + arrowFields: Seq[Field], + selectedIndices: Array[Int], + count: Field => Int): Array[Int] = { + val starts = arrowFields.scanLeft(0)(_ + count(_)).toArray + selectedIndices.flatMap(i => starts(i) until starts(i + 1)) + } + + /** + * The same record batch with every buffer decompressed, as a new batch the caller owns. + * + * `VectorLoader` would do this itself, but arrow-java 18.3.0 leaks on the failure path: + * `VectorLoader.loadBuffers` decompresses a field's buffers into a local list and only releases + * them after the whole field has loaded, so if one buffer of a field fails to decompress, every + * buffer of that field decompressed before it is unreachable and never freed. A string column + * is enough to reach it -- its offsets buffer decompresses, then its data buffer throws -- so a + * single corrupt cached batch leaks off-heap for the life of the executor. Doing the + * decompression here keeps every allocation reachable from this method's own error path. + * + * Buffers are retained before decompressing rather than after, which is the other half of the + * difference. `decompress` consumes a reference to its input on the paths where it allocates, + * so retaining afterwards leaves the reference stranded if it throws -- and, when a batch has a + * single buffer, drops the shared body to zero references and frees it before the retain that + * was meant to protect it. + */ + private def decompressed( + batch: ArrowRecordBatch, + allocator: BufferAllocator): ArrowRecordBatch = { + // getCodec is the raw IPC byte; the factory keys off the enum. Both sides of the comparison + // in readCodec have to be CodecType: NoCompressionCodec.COMPRESSION_TYPE is the byte -1, and + // Scala compares a CodecType against it by universal equality, which is quietly always + // unequal. + val codec = readCodec(batch.getBodyCompression.getCodec) + + val buffers = new java.util.ArrayList[ArrowBuf]() + try { + batch.getBuffers.asScala.foreach { buffer => + buffer.getReferenceManager.retain() + val plain = + try { + // An empty buffer carries no compressed length prefix to read. + codec match { + case Some(c) if buffer.writerIndex() > 0 => c.decompress(allocator, buffer) + case _ => buffer + } + } catch { + case NonFatal(e) => + buffer.getReferenceManager.release() + throw e + } + buffers.add(plain) + } + + val result = new ArrowRecordBatch( + batch.getLength, + batch.getNodes, + buffers, + NoCompressionCodec.DEFAULT_BODY_COMPRESSION, + batch.getVariadicBufferCounts, + false) + // The constructor retained each buffer, so drop the references held here. + buffers.asScala.foreach(_.close()) + result + } catch { + case NonFatal(e) => + buffers.asScala.foreach { buffer => + try buffer.close() + catch { case NonFatal(closeError) => e.addSuppressed(closeError) } + } + throw e + } + } + + /** + * The on-body compressed size of each top-level column. + * + * Each column owns the run of buffers its subtree occupies, so its stored size is the sum of + * 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] = { + val buffers = recordBatch.getBuffersLayout + val starts = fields.scanLeft(0)(_ + fieldBufferCount(_)).toArray + fields.indices.map { i => + (starts(i) until starts(i) + fieldBufferCount(fields(i))) + .map(j => buffers.get(j).getSize) + .sum + }.toArray + } + + /** + * Replace every dictionary-encoded column of `batch` with its decoded form. + * + * Returns the vectors to write and, separately, the ones allocated here so the caller can close + * exactly those. Columns that needed no decoding are returned as they are and stay owned by + * `batch`. + */ + private def hydrateDictionaries( + batch: ColumnarBatch, + allocator: BufferAllocator): (Seq[FieldVector], Seq[ValueVector]) = { + val hydrated = mutable.ArrayBuffer.empty[ValueVector] + try { + val vectors = + Utils.getBatchFieldVectorsWithProviders(batch).map { case (vector, providerOpt) => + if (vector.getField.getDictionary == null) { + vector + } else { + val dictionary = Utils.lookupDictionary(vector, providerOpt) + val decoded = DictionaryEncoder.decode(vector, dictionary, allocator) + hydrated += decoded + decoded.asInstanceOf[FieldVector] + } + } + (vectors, hydrated.toSeq) + } catch { + case NonFatal(e) => + hydrated.foreach(v => + try v.close() + catch { case NonFatal(closeError) => e.addSuppressed(closeError) }) + throw e + } + } + + /** + * Number of Arrow buffers a field occupies in a RecordBatch body, including every descendant, + * in the depth-first order `VectorLoader` consumes them. The type's own count covers its + * validity and offset/data buffers; each child contributes its whole subtree. + */ + private def fieldBufferCount(field: Field): Int = + TypeLayout.getTypeBufferCount(field.getType) + + field.getChildren.asScala.map(fieldBufferCount).sum + + /** Number of field nodes a field occupies: itself plus every descendant. */ + private def fieldNodeCount(field: Field): Int = + 1 + field.getChildren.asScala.map(fieldNodeCount).sum + + /** + * Number of variadic buffer counts a field contributes, one per view-type buffer, recursively. + * + * 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 = { Review 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. ########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/CachedBatchIpc.scala: ########## @@ -0,0 +1,456 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.spark.sql.comet.execution.arrow + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.nio.channels.Channels + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.control.NonFatal + +import org.apache.arrow.compression.{CommonsCompressionFactory, ZstdCompressionCodec} +import org.apache.arrow.flatbuf.{RecordBatch => FlatBufRecordBatch} +import org.apache.arrow.memory.{ArrowBuf, BufferAllocator} +import org.apache.arrow.vector.{FieldVector, TypeLayout, ValueVector, VectorLoader, VectorSchemaRoot, VectorUnloader} +import org.apache.arrow.vector.compression.{CompressionCodec, CompressionUtil, NoCompressionCodec} +import org.apache.arrow.vector.dictionary.DictionaryEncoder +import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel} +import org.apache.arrow.vector.ipc.message.{ArrowBodyCompression, ArrowFieldNode, ArrowRecordBatch, MessageSerializer} +import org.apache.arrow.vector.types.pojo.{ArrowType, Field, Schema} +import org.apache.arrow.vector.util.DataSizeRoundingUtil +import org.apache.spark.SparkException +import org.apache.spark.sql.comet.util.Utils +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** + * The on-disk shape of a `CometCachedBatch` payload, and the two operations over it. + * + * A cached batch is one encapsulated Arrow IPC RecordBatch message followed by its body, with no + * Schema message and no end-of-stream marker. The schema is not stored because the reader already + * has it: `InMemoryRelation` knows the cached relation's attributes, and `Utils.toArrowSchema` + * maps them to exactly the fields the writer unloaded. Leaving it out saves a schema message per + * cached batch, which for a wide relation cached in many batches is a large share of the payload + * that is not data. + * + * 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 Review 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`. ########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/CachedBatchIpc.scala: ########## @@ -0,0 +1,456 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.spark.sql.comet.execution.arrow + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.nio.channels.Channels + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.control.NonFatal + +import org.apache.arrow.compression.{CommonsCompressionFactory, ZstdCompressionCodec} +import org.apache.arrow.flatbuf.{RecordBatch => FlatBufRecordBatch} +import org.apache.arrow.memory.{ArrowBuf, BufferAllocator} +import org.apache.arrow.vector.{FieldVector, TypeLayout, ValueVector, VectorLoader, VectorSchemaRoot, VectorUnloader} +import org.apache.arrow.vector.compression.{CompressionCodec, CompressionUtil, NoCompressionCodec} +import org.apache.arrow.vector.dictionary.DictionaryEncoder +import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel} +import org.apache.arrow.vector.ipc.message.{ArrowBodyCompression, ArrowFieldNode, ArrowRecordBatch, MessageSerializer} +import org.apache.arrow.vector.types.pojo.{ArrowType, Field, Schema} +import org.apache.arrow.vector.util.DataSizeRoundingUtil +import org.apache.spark.SparkException +import org.apache.spark.sql.comet.util.Utils +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** + * The on-disk shape of a `CometCachedBatch` payload, and the two operations over it. + * + * A cached batch is one encapsulated Arrow IPC RecordBatch message followed by its body, with no + * Schema message and no end-of-stream marker. The schema is not stored because the reader already + * has it: `InMemoryRelation` knows the cached relation's attributes, and `Utils.toArrowSchema` + * maps them to exactly the fields the writer unloaded. Leaving it out saves a schema message per + * cached batch, which for a wide relation cached in many batches is a large share of the payload + * that is not data. + * + * 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 + * of the columns a scan selected and let `VectorLoader` decompress just those. A whole-payload + * codec would have to inflate everything before any column could be read. + */ +private[comet] object CachedBatchIpc { + + /** + * The Arrow compression codec named by `spark.comet.exec.inMemoryCache.compression.codec`. + * + * Only the write path consults the config. A batch records which codec compressed it, so the + * read path looks the codec up from the batch itself and keeps reading data cached before the + * config changed. + */ + def compressionCodec(codecName: String, zstdLevel: Int): CompressionCodec = codecName match { + case "none" => NoCompressionCodec.INSTANCE + // Constructed directly rather than through CompressionCodec.Factory, which ignores the level + // and always builds a codec at zstd's default. + case "zstd" => new ZstdCompressionCodec(zstdLevel) + // Arrow's other codec, LZ4_FRAME, is not offered. It is commons-compress's pure-Java LZ4 -- + // no relation to the JNI-accelerated lz4-java behind spark.io.compression.codec -- and + // measures three orders of magnitude slower to write than zstd while also producing larger + // output, so nothing prefers it. Reads still accept it, since the factory the read path uses + // handles whatever codec a batch records. + case other => + throw new SparkException( + s"Unsupported Arrow compression codec for Comet's cache: $other. " + + "Supported values: none, zstd") + } + + // Room for the encapsulated metadata message that precedes the body. The message is a small + // flatbuffer whose size grows with the field count, not the data, so this is a starting size for + // the output buffer rather than a bound -- it grows if a very wide schema needs more. + private val METADATA_SIZE_HINT = 8 * 1024 + + // Decompressors are stateless and shared. Resolving one per cached batch would allocate a codec + // per batch on every scan, and the enum lookup walks the CodecType values each time. + private val readCodecs: Map[CompressionUtil.CodecType, CompressionCodec] = + CompressionUtil.CodecType + .values() + .filter(_ != CompressionUtil.CodecType.NO_COMPRESSION) + .map(t => t -> CommonsCompressionFactory.INSTANCE.createCodec(t)) + .toMap + + /** The decompressor for a body-compression byte, or None when the batch is stored plain. */ + private def readCodec(compressionType: Byte): Option[CompressionCodec] = + readCodecs.get(CompressionUtil.CodecType.fromCompressionType(compressionType)) + + /** + * Serialize `batch` into one encapsulated IPC RecordBatch message. + * + * Returns the message bytes and the on-body compressed size of each top-level column, which the + * caller records in the statistics row. The sizes come from the message's own buffer layout, so + * they are the real stored sizes rather than an estimate. + * + * Dictionary-encoded columns are decoded to their plain form first. A payload with no Schema + * message cannot describe a dictionary encoding, and the schema the reader rebuilds from Spark + * attributes never carries one, so a dictionary-encoded column has nowhere to record either its + * index type or the dictionary itself. Comet's native scans do produce such columns, so this is + * a real path, not a defensive one. + * + * As in `Utils.serializeBatches`, `batch`'s vectors are cleared once written, so callers gather + * anything they need from the batch (statistics, for instance) before calling this. + */ + def serialize( + batch: ColumnarBatch, + codec: CompressionCodec, + allocator: BufferAllocator): (Array[Byte], Array[Long]) = { + val (vectors, hydrated) = hydrateDictionaries(batch, allocator) + try { + val root = new VectorSchemaRoot(vectors.asJava) + // A batch of zero columns carries only a row count, which a VectorSchemaRoot cannot infer + // without vectors to measure. + if (vectors.isEmpty) { + root.setRowCount(batch.numRows()) + } + + // alignBuffers=true matches the 8-byte buffer alignment readProjected reproduces when it + // repacks the selected buffers. + val unloader = new VectorUnloader(root, true, codec, true) + val recordBatch = unloader.getRecordBatch + try { + val fields = vectors.map(_.getField) + // Serializing consumes the batch, as it does in Utils.serializeBatches. The record batch + // holds its own buffers by now -- compressed copies, or retained references when the codec + // is none -- so releasing the vectors here does not touch it. getField still answers + // afterwards: clearing releases buffers, not the schema. + // + // 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() + + // Sized up front from the body length the record batch already knows, plus room for the + // metadata message. An unsized ByteArrayOutputStream starts at 32 bytes and doubles, so a + // multi-MiB payload would be reallocated and recopied a dozen-odd times per batch. + val sizeHint = recordBatch.computeBodyLength() + METADATA_SIZE_HINT + val out = new ByteArrayOutputStream( + math.min(math.max(sizeHint, METADATA_SIZE_HINT), Int.MaxValue.toLong).toInt) + val channel = new WriteChannel(Channels.newChannel(out)) + MessageSerializer.serialize(channel, recordBatch) + (out.toByteArray, columnSizes(fields, recordBatch)) + } finally { + recordBatch.close() + } + } finally { + // Only the vectors this method allocated. The rest belong to the input batch. + hydrated.foreach(v => + try v.close() + catch { case NonFatal(_) => () }) + } + } + + /** + * Everything about reading one projection of this format that does not change between batches. + * + * The index arithmetic here is a pure function of the cached schema and the selected columns, + * both fixed for the life of a scan, but it walks every field of the whole relation rather than + * just the projected ones. Recomputing it per batch would make the bookkeeping O(total columns) + * while the useful work is O(selected columns) -- worst in exactly the wide-relation, + * narrow-projection case this format exists for. A scan builds one of these per partition. + * + * Holding the projected `Schema` here too is what keeps it consistent with the buffers: + * [[load]] packs field nodes and buffers by walking `selectedIndices` in order, and the schema + * is built from the same walk, so the two cannot drift apart. + */ + final class Projection(arrowFields: Seq[Field], selectedIndices: Array[Int]) { + + private val schema = new Schema(selectedIndices.map(arrowFields).toSeq.asJava) + + // A record batch body is a flat, depth-first sequence of buffers in schema order, so each + // 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) Review 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: ```scala 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. ########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowCachedBatchSerializer.scala: ########## @@ -462,24 +509,36 @@ class ArrowCachedBatchSerializer extends SimpleMetricsCachedBatchSerializer { } val indices = selectedIndices(cacheAttributes, selectedAttributes) + // Captured as a StructType rather than the attributes themselves: this closure ships to the + // executors, and the Arrow schema is rebuilt there from the same mapping the writer used. + val cacheSchema = Utils.fromAttributes(cacheAttributes) input.mapPartitions { it => - // A ColumnReaders closes its readers (releasing the vectors they are holding) only when the - // batch it produced has been consumed. A consumer that stops early -- LIMIT, take(), or a - // cancelled task -- leaves the readers for the batch in flight open, so close them on task - // completion. Spark's own ArrowCachedBatchSerializer registers a listener for the same - // 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( Review 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. ########## spark/src/test/scala/org/apache/comet/exec/CometInMemoryCacheSuite.scala: ########## @@ -1584,52 +1838,64 @@ class CometInMemoryCacheSuite extends CometTestBase { .cachedRepresentation try { - f(relation, relation.cacheBuilder.cachedColumnBuffers.collect()) + f(relation) } finally { spark.catalog.clearCache() } } } - test("Comet in-memory cache broadcasts a batch whose columns have separate dictionaries") { - // A broadcast of a cache scan re-serializes each decoded batch as one stream covering every - // column, and the writer resolves all of their dictionary IDs against the single provider it - // 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") { Review 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. ########## spark/src/test/scala/org/apache/comet/exec/CometInMemoryCacheSuite.scala: ########## @@ -1315,70 +1394,241 @@ class CometInMemoryCacheSuite extends CometTestBase { .sum } - test("Comet in-memory cache stores one stream per column") { + /** + * Run `f`, require it to fail, and require the failure to be the decode error itself. + * + * The read path allocates an off-heap body, hands it to a record batch that takes its own + * references, and drops its own. A cleanup path that then releases the body a second time + * drives its reference count negative, and the reference-count error replaces the decode + * failure that caused it -- leaving a plain `intercept[Exception]` green while the user sees a + * error that says nothing about their corrupt cache. + */ + private def interceptDecodeFailure(f: => Unit): Throwable = { + val thrown = intercept[Exception](f) + val chain = + Iterator.iterate(thrown: Throwable)(_.getCause).takeWhile(_ != null).take(20).toSeq + assert( + !chain.exists { t => + t.getClass.getName.contains("IllegalReferenceCount") || + Option(t.getMessage).exists(m => m.contains("RefCnt") || m.contains("refCnt")) + }, + s"the decode failure must surface as itself, not as a reference-count error: $thrown") + thrown + } + + test("Comet in-memory cache round-trips under every compression codec") { + // Every codec the config accepts, not just the default. `none` takes a different path on read + // -- the payload records no codec, so nothing is decompressed -- and shipped broken for a + // while because the only tests that ran were on the default codec. + Seq("none", "zstd").foreach { codec => + withSQLConf( + SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false", + SQLConf.CACHE_VECTORIZED_READER_ENABLED.key -> "true", + CometConf.COMET_EXEC_IN_MEMORY_CACHE_ENABLED.key -> "true", + CometConf.COMET_EXEC_IN_MEMORY_CACHE_COMPRESSION_CODEC.key -> codec, + "spark.comet.sparkToColumnar.enabled" -> "true") { + + spark.catalog.clearCache() + val view = s"codec_cache_$codec" + spark + .range(0, 4000, 1, 2) + .selectExpr( + "id", + "cast(id as double) / 3 AS d", + "concat('s_', cast(id as string)) AS s", + "cast(id % 2 = 0 as boolean) AS flag") + .createOrReplaceTempView(view) + spark.catalog.cacheTable(view) + + assert( + cachedBatchTypes(view).sameElements( + Array("org.apache.spark.sql.comet.execution.arrow.CometCachedBatch")), + s"codec $codec should still store CometCachedBatch") + + // A full read, a projected read (the buffer-selection path), and a row count that decodes + // nothing -- the three shapes the read path distinguishes. + checkSparkAnswer(spark.sql(s"SELECT * FROM $view")) + checkSparkAnswer(spark.sql(s"SELECT s FROM $view WHERE id >= 3990")) + assert(spark.sql(s"SELECT count(*) FROM $view").collect()(0).getLong(0) == 4000) + // Pruning reads the statistics rather than the payload, so exercise it too. + assert(spark.sql(s"SELECT id FROM $view WHERE id >= 3990").collect().length == 10) + + spark.catalog.clearCache() + } + } + } + + test("Comet in-memory cache stores no schema message per cached batch") { + // The reader rebuilds the schema from the cached relation's attributes, so storing one in + // every batch would repeat the same bytes for as many batches as the relation was cached in. withProjectionCache { (relation, batches) => assert(batches.nonEmpty) + val cacheSchema = Utils.fromAttributes(relation.output) batches.foreach { batch => assert( - CometCachedBatchHelper.numColumnStreams(batch) == relation.output.length, - "a cached batch must hold one independently decodable stream per cached column") + !CometCachedBatchHelper.hasSchemaMessage(batch), + "a cached batch must begin with its record batch, not a schema message") + val sizes = CometCachedBatchHelper.columnSizes(batch, cacheSchema) assert( - CometCachedBatchHelper.columnStreamSizes(batch).forall(_ > 0), - "every column stream must carry data") + sizes.length == relation.output.length, + "every cached column must own a run of buffers in the payload") + assert(sizes.forall(_ > 0), "every cached column must carry data") } } } test("Comet in-memory cache decodes only the projected columns") { - // Timings would be a weak assertion here, so this corrupts the streams the read must not - // touch. Reading still has to succeed, which it only can if those streams were never - // inflated. The second half checks the corruption is detectable at all, so that the first - // half cannot pass just because the bad bytes decode silently to nothing. + // Timings would be a weak assertion here, so this scrambles the compressed bytes of the + // columns the read must not touch, leaving every other byte of the payload identical. + // Reading still has to succeed, which it only can if those columns' buffers were never copied + // out of the payload and handed to the decompressor. The second half checks the corruption is + // detectable at all, so the first half cannot pass just because the bad bytes decode silently + // to nothing. withProjectionCache { (relation, batches) => + val cacheSchema = Utils.fromAttributes(relation.output) val selectedIdx = 1 val selected = Seq(relation.output(selectedIdx)) + relation.output.indices.foreach { i => + assert( + batches.forall(b => CometCachedBatchHelper.columnIsCompressed(b, cacheSchema, i)), + s"column $i is not stored compressed, so corrupting it would prove nothing") + } + relation.output.indices.filter(_ != selectedIdx).foreach { i => - batches.foreach(b => CometCachedBatchHelper.corruptColumnStream(b, i)) + batches.foreach(b => CometCachedBatchHelper.corruptColumn(b, cacheSchema, i)) } assert( - decodedRowCount(relation, batches, selected) == 500, - "reading one column must not decode the other five") + decodedRowCount(relation, batches, selected) == projectionCacheRows, + "reading one column must not decompress the other five") - batches.foreach(b => CometCachedBatchHelper.corruptColumnStream(b, selectedIdx)) - intercept[Exception] { + batches.foreach(b => CometCachedBatchHelper.corruptColumn(b, cacheSchema, selectedIdx)) + interceptDecodeFailure { decodedRowCount(relation, batches, selected) } } } + test("Comet in-memory cache decodes only the projected columns of a nested relation") { + // The flat case above pins one column and corrupts the rest. Here every column takes its turn, + // 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 => Review 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. ########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/CachedBatchIpc.scala: ########## @@ -0,0 +1,456 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.spark.sql.comet.execution.arrow + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.nio.channels.Channels + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.control.NonFatal + +import org.apache.arrow.compression.{CommonsCompressionFactory, ZstdCompressionCodec} +import org.apache.arrow.flatbuf.{RecordBatch => FlatBufRecordBatch} +import org.apache.arrow.memory.{ArrowBuf, BufferAllocator} +import org.apache.arrow.vector.{FieldVector, TypeLayout, ValueVector, VectorLoader, VectorSchemaRoot, VectorUnloader} +import org.apache.arrow.vector.compression.{CompressionCodec, CompressionUtil, NoCompressionCodec} +import org.apache.arrow.vector.dictionary.DictionaryEncoder +import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel} +import org.apache.arrow.vector.ipc.message.{ArrowBodyCompression, ArrowFieldNode, ArrowRecordBatch, MessageSerializer} +import org.apache.arrow.vector.types.pojo.{ArrowType, Field, Schema} +import org.apache.arrow.vector.util.DataSizeRoundingUtil +import org.apache.spark.SparkException +import org.apache.spark.sql.comet.util.Utils +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** + * The on-disk shape of a `CometCachedBatch` payload, and the two operations over it. + * + * A cached batch is one encapsulated Arrow IPC RecordBatch message followed by its body, with no + * Schema message and no end-of-stream marker. The schema is not stored because the reader already + * has it: `InMemoryRelation` knows the cached relation's attributes, and `Utils.toArrowSchema` + * maps them to exactly the fields the writer unloaded. Leaving it out saves a schema message per + * cached batch, which for a wide relation cached in many batches is a large share of the payload + * that is not data. + * + * 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 + * of the columns a scan selected and let `VectorLoader` decompress just those. A whole-payload + * codec would have to inflate everything before any column could be read. + */ +private[comet] object CachedBatchIpc { + + /** + * The Arrow compression codec named by `spark.comet.exec.inMemoryCache.compression.codec`. + * + * Only the write path consults the config. A batch records which codec compressed it, so the + * read path looks the codec up from the batch itself and keeps reading data cached before the + * config changed. + */ + def compressionCodec(codecName: String, zstdLevel: Int): CompressionCodec = codecName match { + case "none" => NoCompressionCodec.INSTANCE + // Constructed directly rather than through CompressionCodec.Factory, which ignores the level + // and always builds a codec at zstd's default. + case "zstd" => new ZstdCompressionCodec(zstdLevel) + // Arrow's other codec, LZ4_FRAME, is not offered. It is commons-compress's pure-Java LZ4 -- + // no relation to the JNI-accelerated lz4-java behind spark.io.compression.codec -- and + // measures three orders of magnitude slower to write than zstd while also producing larger + // output, so nothing prefers it. Reads still accept it, since the factory the read path uses + // handles whatever codec a batch records. + case other => + throw new SparkException( + s"Unsupported Arrow compression codec for Comet's cache: $other. " + + "Supported values: none, zstd") + } + + // Room for the encapsulated metadata message that precedes the body. The message is a small + // flatbuffer whose size grows with the field count, not the data, so this is a starting size for + // the output buffer rather than a bound -- it grows if a very wide schema needs more. + private val METADATA_SIZE_HINT = 8 * 1024 + + // Decompressors are stateless and shared. Resolving one per cached batch would allocate a codec + // per batch on every scan, and the enum lookup walks the CodecType values each time. + private val readCodecs: Map[CompressionUtil.CodecType, CompressionCodec] = + CompressionUtil.CodecType + .values() + .filter(_ != CompressionUtil.CodecType.NO_COMPRESSION) + .map(t => t -> CommonsCompressionFactory.INSTANCE.createCodec(t)) + .toMap + + /** The decompressor for a body-compression byte, or None when the batch is stored plain. */ + private def readCodec(compressionType: Byte): Option[CompressionCodec] = + readCodecs.get(CompressionUtil.CodecType.fromCompressionType(compressionType)) + + /** + * Serialize `batch` into one encapsulated IPC RecordBatch message. + * + * Returns the message bytes and the on-body compressed size of each top-level column, which the + * caller records in the statistics row. The sizes come from the message's own buffer layout, so + * they are the real stored sizes rather than an estimate. + * + * Dictionary-encoded columns are decoded to their plain form first. A payload with no Schema + * message cannot describe a dictionary encoding, and the schema the reader rebuilds from Spark + * attributes never carries one, so a dictionary-encoded column has nowhere to record either its + * index type or the dictionary itself. Comet's native scans do produce such columns, so this is + * a real path, not a defensive one. + * + * As in `Utils.serializeBatches`, `batch`'s vectors are cleared once written, so callers gather + * anything they need from the batch (statistics, for instance) before calling this. + */ + def serialize( + batch: ColumnarBatch, + codec: CompressionCodec, + allocator: BufferAllocator): (Array[Byte], Array[Long]) = { + val (vectors, hydrated) = hydrateDictionaries(batch, allocator) + try { + val root = new VectorSchemaRoot(vectors.asJava) + // A batch of zero columns carries only a row count, which a VectorSchemaRoot cannot infer + // without vectors to measure. + if (vectors.isEmpty) { + root.setRowCount(batch.numRows()) + } + + // alignBuffers=true matches the 8-byte buffer alignment readProjected reproduces when it + // repacks the selected buffers. + val unloader = new VectorUnloader(root, true, codec, true) + val recordBatch = unloader.getRecordBatch + try { + val fields = vectors.map(_.getField) + // Serializing consumes the batch, as it does in Utils.serializeBatches. The record batch + // holds its own buffers by now -- compressed copies, or retained references when the codec + // is none -- so releasing the vectors here does not touch it. getField still answers + // afterwards: clearing releases buffers, not the schema. + // + // 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() Review 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. ########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/CachedBatchIpc.scala: ########## @@ -0,0 +1,456 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.spark.sql.comet.execution.arrow + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.nio.channels.Channels + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.control.NonFatal + +import org.apache.arrow.compression.{CommonsCompressionFactory, ZstdCompressionCodec} +import org.apache.arrow.flatbuf.{RecordBatch => FlatBufRecordBatch} +import org.apache.arrow.memory.{ArrowBuf, BufferAllocator} +import org.apache.arrow.vector.{FieldVector, TypeLayout, ValueVector, VectorLoader, VectorSchemaRoot, VectorUnloader} +import org.apache.arrow.vector.compression.{CompressionCodec, CompressionUtil, NoCompressionCodec} +import org.apache.arrow.vector.dictionary.DictionaryEncoder +import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel} +import org.apache.arrow.vector.ipc.message.{ArrowBodyCompression, ArrowFieldNode, ArrowRecordBatch, MessageSerializer} +import org.apache.arrow.vector.types.pojo.{ArrowType, Field, Schema} +import org.apache.arrow.vector.util.DataSizeRoundingUtil +import org.apache.spark.SparkException +import org.apache.spark.sql.comet.util.Utils +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** + * The on-disk shape of a `CometCachedBatch` payload, and the two operations over it. + * + * A cached batch is one encapsulated Arrow IPC RecordBatch message followed by its body, with no + * Schema message and no end-of-stream marker. The schema is not stored because the reader already + * has it: `InMemoryRelation` knows the cached relation's attributes, and `Utils.toArrowSchema` + * maps them to exactly the fields the writer unloaded. Leaving it out saves a schema message per + * cached batch, which for a wide relation cached in many batches is a large share of the payload + * that is not data. + * + * 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 + * of the columns a scan selected and let `VectorLoader` decompress just those. A whole-payload + * codec would have to inflate everything before any column could be read. + */ +private[comet] object CachedBatchIpc { + + /** + * The Arrow compression codec named by `spark.comet.exec.inMemoryCache.compression.codec`. + * + * Only the write path consults the config. A batch records which codec compressed it, so the + * read path looks the codec up from the batch itself and keeps reading data cached before the + * config changed. + */ + def compressionCodec(codecName: String, zstdLevel: Int): CompressionCodec = codecName match { + case "none" => NoCompressionCodec.INSTANCE + // Constructed directly rather than through CompressionCodec.Factory, which ignores the level + // and always builds a codec at zstd's default. + case "zstd" => new ZstdCompressionCodec(zstdLevel) + // Arrow's other codec, LZ4_FRAME, is not offered. It is commons-compress's pure-Java LZ4 -- + // no relation to the JNI-accelerated lz4-java behind spark.io.compression.codec -- and + // measures three orders of magnitude slower to write than zstd while also producing larger + // output, so nothing prefers it. Reads still accept it, since the factory the read path uses + // handles whatever codec a batch records. + case other => + throw new SparkException( + s"Unsupported Arrow compression codec for Comet's cache: $other. " + + "Supported values: none, zstd") + } + + // Room for the encapsulated metadata message that precedes the body. The message is a small + // flatbuffer whose size grows with the field count, not the data, so this is a starting size for + // the output buffer rather than a bound -- it grows if a very wide schema needs more. + private val METADATA_SIZE_HINT = 8 * 1024 + + // Decompressors are stateless and shared. Resolving one per cached batch would allocate a codec + // per batch on every scan, and the enum lookup walks the CodecType values each time. + private val readCodecs: Map[CompressionUtil.CodecType, CompressionCodec] = + CompressionUtil.CodecType + .values() + .filter(_ != CompressionUtil.CodecType.NO_COMPRESSION) + .map(t => t -> CommonsCompressionFactory.INSTANCE.createCodec(t)) + .toMap + + /** The decompressor for a body-compression byte, or None when the batch is stored plain. */ + private def readCodec(compressionType: Byte): Option[CompressionCodec] = + readCodecs.get(CompressionUtil.CodecType.fromCompressionType(compressionType)) + + /** + * Serialize `batch` into one encapsulated IPC RecordBatch message. + * + * Returns the message bytes and the on-body compressed size of each top-level column, which the + * caller records in the statistics row. The sizes come from the message's own buffer layout, so + * they are the real stored sizes rather than an estimate. + * + * Dictionary-encoded columns are decoded to their plain form first. A payload with no Schema + * message cannot describe a dictionary encoding, and the schema the reader rebuilds from Spark + * attributes never carries one, so a dictionary-encoded column has nowhere to record either its + * index type or the dictionary itself. Comet's native scans do produce such columns, so this is + * a real path, not a defensive one. + * + * As in `Utils.serializeBatches`, `batch`'s vectors are cleared once written, so callers gather + * anything they need from the batch (statistics, for instance) before calling this. + */ + def serialize( + batch: ColumnarBatch, + codec: CompressionCodec, + allocator: BufferAllocator): (Array[Byte], Array[Long]) = { + val (vectors, hydrated) = hydrateDictionaries(batch, allocator) + try { + val root = new VectorSchemaRoot(vectors.asJava) + // A batch of zero columns carries only a row count, which a VectorSchemaRoot cannot infer + // without vectors to measure. + if (vectors.isEmpty) { + root.setRowCount(batch.numRows()) + } + + // alignBuffers=true matches the 8-byte buffer alignment readProjected reproduces when it + // repacks the selected buffers. + val unloader = new VectorUnloader(root, true, codec, true) + val recordBatch = unloader.getRecordBatch + try { + val fields = vectors.map(_.getField) + // Serializing consumes the batch, as it does in Utils.serializeBatches. The record batch + // holds its own buffers by now -- compressed copies, or retained references when the codec + // is none -- so releasing the vectors here does not touch it. getField still answers + // afterwards: clearing releases buffers, not the schema. + // + // 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() + + // Sized up front from the body length the record batch already knows, plus room for the + // metadata message. An unsized ByteArrayOutputStream starts at 32 bytes and doubles, so a + // multi-MiB payload would be reallocated and recopied a dozen-odd times per batch. + val sizeHint = recordBatch.computeBodyLength() + METADATA_SIZE_HINT + val out = new ByteArrayOutputStream( + math.min(math.max(sizeHint, METADATA_SIZE_HINT), Int.MaxValue.toLong).toInt) + val channel = new WriteChannel(Channels.newChannel(out)) + MessageSerializer.serialize(channel, recordBatch) + (out.toByteArray, columnSizes(fields, recordBatch)) + } finally { + recordBatch.close() + } + } finally { + // Only the vectors this method allocated. The rest belong to the input batch. + hydrated.foreach(v => + try v.close() + catch { case NonFatal(_) => () }) + } + } + + /** + * Everything about reading one projection of this format that does not change between batches. + * + * The index arithmetic here is a pure function of the cached schema and the selected columns, + * both fixed for the life of a scan, but it walks every field of the whole relation rather than + * just the projected ones. Recomputing it per batch would make the bookkeeping O(total columns) + * while the useful work is O(selected columns) -- worst in exactly the wide-relation, + * narrow-projection case this format exists for. A scan builds one of these per partition. + * + * Holding the projected `Schema` here too is what keeps it consistent with the buffers: + * [[load]] packs field nodes and buffers by walking `selectedIndices` in order, and the schema + * is built from the same walk, so the two cannot drift apart. + */ + final class Projection(arrowFields: Seq[Field], selectedIndices: Array[Int]) { + + private val schema = new Schema(selectedIndices.map(arrowFields).toSeq.asJava) + + // A record batch body is a flat, depth-first sequence of buffers in schema order, so each + // 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) + private val variadicIndices = selectedRange(arrowFields, selectedIndices, fieldVariadicCount) + + /** + * Decode the projected columns of one cached payload into a fresh root the caller owns. + * + * Only the selected buffers are ever materialized off-heap or decompressed. The message + * metadata records every buffer's offset and length within the body, so the selected columns' + * bytes are copied into a single allocation -- each 8-byte aligned exactly as Arrow's IPC + * body lays them out -- and the columns that were not selected are never read, let alone + * inflated. + * + * A buffer's recorded (offset, length) covers its on-body bytes including the + * uncompressed-length prefix, so a copied window is exactly what the writer emitted. The + * windows are then decompressed in one pass; see [[decompressed]] for why that is not left to + * `VectorLoader`. + */ + def load(data: Array[Byte], allocator: BufferAllocator): VectorSchemaRoot = { + val readChannel = new ReadChannel(Channels.newChannel(new ByteArrayInputStream(data))) + // Reads the message metadata only. The body stays in `data` and is copied selectively. + val metadata = MessageSerializer.readMessage(readChannel) + if (metadata == null) { + throw new SparkException("Unexpected end of input reading a Comet cached batch") + } + val batch = + metadata.getMessage.header(new FlatBufRecordBatch()).asInstanceOf[FlatBufRecordBatch] + // serialize writes exactly [encapsulated message][body] and nothing after it, so the body is + // the tail of `data`. + val bodyStart = data.length - metadata.getMessageBodyLength.toInt + + val compression = + if (batch.compression() == null) NoCompressionCodec.DEFAULT_BODY_COMPRESSION + else new ArrowBodyCompression(batch.compression().codec(), batch.compression().method()) + + val nodes = new java.util.ArrayList[ArrowFieldNode](nodeIndices.length) + nodeIndices.foreach { j => + val node = batch.nodes(j) + nodes.add(new ArrowFieldNode(node.length(), node.nullCount())) + } + val variadicCounts = new java.util.ArrayList[java.lang.Long](variadicIndices.length) + if (batch.variadicBufferCountsLength() > 0) { + variadicIndices.foreach(j => variadicCounts.add(batch.variadicBufferCounts(j))) + } + + val offsets = new Array[Long](bufferIndices.length) + val lengths = new Array[Long](bufferIndices.length) + var total = 0L + var k = 0 + while (k < bufferIndices.length) { + val buffer = batch.buffers(bufferIndices(k)) + offsets(k) = buffer.offset() + lengths(k) = buffer.length() + total += DataSizeRoundingUtil.roundUpTo8Multiple(lengths(k)) + k += 1 + } + + // allocator.buffer(0) is legal but yields a buffer no window can be sliced from, and an + // all-empty projection (every selected column a NullVector, say) would ask for exactly that. + val body = allocator.buffer(math.max(total, 1L)) + val compressedBatch = + try { + val buffers = new java.util.ArrayList[ArrowBuf](bufferIndices.length) + var position = 0L + var i = 0 + while (i < bufferIndices.length) { + val length = lengths(i) + if (length > 0) { + body.setBytes(position, data, bodyStart + offsets(i).toInt, length.toInt) + } + val window = body.slice(position, length) + window.writerIndex(length) + buffers.add(window) + position += DataSizeRoundingUtil.roundUpTo8Multiple(length) + i += 1 + } + new ArrowRecordBatch( + batch.length().toInt, + nodes, + buffers, + compression, + variadicCounts, + false) + } catch { + case NonFatal(e) => + body.close() + throw e + } + + // The constructor retained each window; slice() alone does not. Dropping `body`'s own + // reference leaves the batch as sole owner of the one allocation, so closing the batch is + // what frees it -- and closing `body` again would drive its reference count negative. + body.close() + val plainBatch = + try decompressed(compressedBatch, allocator) + finally compressedBatch.close() + + // The loader needs no compression factory: every buffer is decompressed by this point. + val root = VectorSchemaRoot.create(schema, allocator) + try { + new VectorLoader(root).load(plainBatch) + root + } catch { + case NonFatal(e) => + try root.close() + catch { case NonFatal(closeError) => e.addSuppressed(closeError) } + throw e + } finally { + plainBatch.close() + } + } + } + + /** + * The indices, within a record batch's flat depth-first sequence, that the selected columns + * own. + * + * `count` gives how many entries of the sequence a field occupies including its descendants, so + * a running total over every field turns a column index into its run within the sequence. + */ + private def selectedRange( + arrowFields: Seq[Field], + selectedIndices: Array[Int], + count: Field => Int): Array[Int] = { + val starts = arrowFields.scanLeft(0)(_ + count(_)).toArray + selectedIndices.flatMap(i => starts(i) until starts(i + 1)) + } + + /** + * The same record batch with every buffer decompressed, as a new batch the caller owns. + * + * `VectorLoader` would do this itself, but arrow-java 18.3.0 leaks on the failure path: + * `VectorLoader.loadBuffers` decompresses a field's buffers into a local list and only releases + * them after the whole field has loaded, so if one buffer of a field fails to decompress, every + * buffer of that field decompressed before it is unreachable and never freed. A string column + * is enough to reach it -- its offsets buffer decompresses, then its data buffer throws -- so a + * single corrupt cached batch leaks off-heap for the life of the executor. Doing the + * decompression here keeps every allocation reachable from this method's own error path. + * + * Buffers are retained before decompressing rather than after, which is the other half of the + * difference. `decompress` consumes a reference to its input on the paths where it allocates, + * so retaining afterwards leaves the reference stranded if it throws -- and, when a batch has a + * single buffer, drops the shared body to zero references and frees it before the retain that + * was meant to protect it. + */ + private def decompressed( + batch: ArrowRecordBatch, + allocator: BufferAllocator): ArrowRecordBatch = { + // getCodec is the raw IPC byte; the factory keys off the enum. Both sides of the comparison + // in readCodec have to be CodecType: NoCompressionCodec.COMPRESSION_TYPE is the byte -1, and + // Scala compares a CodecType against it by universal equality, which is quietly always + // unequal. + val codec = readCodec(batch.getBodyCompression.getCodec) + + val buffers = new java.util.ArrayList[ArrowBuf]() + try { + batch.getBuffers.asScala.foreach { buffer => + buffer.getReferenceManager.retain() + val plain = + try { + // An empty buffer carries no compressed length prefix to read. + codec match { + case Some(c) if buffer.writerIndex() > 0 => c.decompress(allocator, buffer) + case _ => buffer + } + } catch { + case NonFatal(e) => + buffer.getReferenceManager.release() + throw e + } + buffers.add(plain) + } + + val result = new ArrowRecordBatch( + batch.getLength, + batch.getNodes, + buffers, + NoCompressionCodec.DEFAULT_BODY_COMPRESSION, + batch.getVariadicBufferCounts, + false) + // The constructor retained each buffer, so drop the references held here. + buffers.asScala.foreach(_.close()) + result + } catch { + case NonFatal(e) => + buffers.asScala.foreach { buffer => + try buffer.close() + catch { case NonFatal(closeError) => e.addSuppressed(closeError) } + } + throw e + } + } + + /** + * The on-body compressed size of each top-level column. + * + * Each column owns the run of buffers its subtree occupies, so its stored size is the sum of + * 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] = { Review 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. ########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ArrowCachedBatchSerializer.scala: ########## @@ -339,34 +370,47 @@ class ArrowCachedBatchSerializer extends SimpleMetricsCachedBatchSerializer { // A columnar input batch is not guaranteed to be Arrow-backed; see supportsColumnarInput for // why. Batches that are not get copied into Arrow first, since Utils.serializeBatches only // writes CometVector columns. + /** + * The configured write codec, read on the driver. + * + * Both write paths resolve this here rather than inside their `mapPartitions` closure: the + * 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) = Review 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. ########## spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/CachedBatchIpc.scala: ########## @@ -0,0 +1,456 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.spark.sql.comet.execution.arrow + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.nio.channels.Channels + +import scala.collection.mutable +import scala.jdk.CollectionConverters._ +import scala.util.control.NonFatal + +import org.apache.arrow.compression.{CommonsCompressionFactory, ZstdCompressionCodec} +import org.apache.arrow.flatbuf.{RecordBatch => FlatBufRecordBatch} +import org.apache.arrow.memory.{ArrowBuf, BufferAllocator} +import org.apache.arrow.vector.{FieldVector, TypeLayout, ValueVector, VectorLoader, VectorSchemaRoot, VectorUnloader} +import org.apache.arrow.vector.compression.{CompressionCodec, CompressionUtil, NoCompressionCodec} +import org.apache.arrow.vector.dictionary.DictionaryEncoder +import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel} +import org.apache.arrow.vector.ipc.message.{ArrowBodyCompression, ArrowFieldNode, ArrowRecordBatch, MessageSerializer} +import org.apache.arrow.vector.types.pojo.{ArrowType, Field, Schema} +import org.apache.arrow.vector.util.DataSizeRoundingUtil +import org.apache.spark.SparkException +import org.apache.spark.sql.comet.util.Utils +import org.apache.spark.sql.vectorized.ColumnarBatch + +/** + * The on-disk shape of a `CometCachedBatch` payload, and the two operations over it. + * + * A cached batch is one encapsulated Arrow IPC RecordBatch message followed by its body, with no + * Schema message and no end-of-stream marker. The schema is not stored because the reader already + * has it: `InMemoryRelation` knows the cached relation's attributes, and `Utils.toArrowSchema` + * maps them to exactly the fields the writer unloaded. Leaving it out saves a schema message per + * cached batch, which for a wide relation cached in many batches is a large share of the payload + * that is not data. + * + * 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 + * of the columns a scan selected and let `VectorLoader` decompress just those. A whole-payload + * codec would have to inflate everything before any column could be read. + */ +private[comet] object CachedBatchIpc { + + /** + * The Arrow compression codec named by `spark.comet.exec.inMemoryCache.compression.codec`. + * + * Only the write path consults the config. A batch records which codec compressed it, so the + * read path looks the codec up from the batch itself and keeps reading data cached before the + * config changed. + */ + def compressionCodec(codecName: String, zstdLevel: Int): CompressionCodec = codecName match { + case "none" => NoCompressionCodec.INSTANCE + // Constructed directly rather than through CompressionCodec.Factory, which ignores the level + // and always builds a codec at zstd's default. + case "zstd" => new ZstdCompressionCodec(zstdLevel) + // Arrow's other codec, LZ4_FRAME, is not offered. It is commons-compress's pure-Java LZ4 -- + // no relation to the JNI-accelerated lz4-java behind spark.io.compression.codec -- and + // measures three orders of magnitude slower to write than zstd while also producing larger + // output, so nothing prefers it. Reads still accept it, since the factory the read path uses + // handles whatever codec a batch records. + case other => + throw new SparkException( + s"Unsupported Arrow compression codec for Comet's cache: $other. " + + "Supported values: none, zstd") + } + + // Room for the encapsulated metadata message that precedes the body. The message is a small + // flatbuffer whose size grows with the field count, not the data, so this is a starting size for + // the output buffer rather than a bound -- it grows if a very wide schema needs more. + private val METADATA_SIZE_HINT = 8 * 1024 + + // Decompressors are stateless and shared. Resolving one per cached batch would allocate a codec + // per batch on every scan, and the enum lookup walks the CodecType values each time. + private val readCodecs: Map[CompressionUtil.CodecType, CompressionCodec] = + CompressionUtil.CodecType + .values() + .filter(_ != CompressionUtil.CodecType.NO_COMPRESSION) + .map(t => t -> CommonsCompressionFactory.INSTANCE.createCodec(t)) + .toMap + + /** The decompressor for a body-compression byte, or None when the batch is stored plain. */ + private def readCodec(compressionType: Byte): Option[CompressionCodec] = Review 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. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
