@@ -23,10 +23,14 @@ import java.nio.channels.Channels
2323import scala .jdk .CollectionConverters ._
2424
2525import org .apache .arrow .compression .{Lz4CompressionCodec , ZstdCompressionCodec }
26- import org .apache .arrow .vector .{VectorLoader , VectorSchemaRoot , VectorUnloader }
26+ import org .apache .arrow .flatbuf .{RecordBatch => FlatBufRecordBatch }
27+ import org .apache .arrow .memory .BufferAllocator
28+ import org .apache .arrow .vector .{TypeLayout , VectorLoader , VectorSchemaRoot , VectorUnloader }
2729import org .apache .arrow .vector .compression .{CompressionCodec , NoCompressionCodec }
2830import org .apache .arrow .vector .ipc .{ReadChannel , WriteChannel }
31+ import org .apache .arrow .vector .ipc .message .{ArrowBodyCompression , ArrowFieldNode }
2932import org .apache .arrow .vector .ipc .message .{ArrowRecordBatch , MessageSerializer }
33+ import org .apache .arrow .vector .types .pojo .Field
3034
3135import org .apache .spark .{SparkException , TaskContext }
3236import org .apache .spark .rdd .RDD
@@ -285,6 +289,135 @@ private object ArrowCachedBatchSerializer {
285289 out.toByteArray
286290 }
287291
292+ /**
293+ * Number of Arrow buffers a field occupies in a RecordBatch body, including all of its
294+ * descendants, in the depth-first order `VectorLoader` consumes them. The type's own buffer
295+ * count comes from `TypeLayout` (validity + offset/data buffers), then each child contributes
296+ * its whole subtree recursively. Used to map each top-level column to its run of buffers.
297+ */
298+ private def fieldBufferCount (field : Field ): Int =
299+ TypeLayout .getTypeBufferCount(field.getType) +
300+ field.getChildren.asScala.map(fieldBufferCount).sum
301+
302+ /** Number of Arrow field nodes a field occupies (itself plus every descendant). */
303+ private def fieldNodeCount (field : Field ): Int =
304+ 1 + field.getChildren.asScala.map(fieldNodeCount).sum
305+
306+ /** Number of variadic buffer counts a field contributes (one per view-type buffer, recursive). */
307+ private def fieldVariadicCount (field : Field ): Int = {
308+ val own = field.getType match {
309+ // View types (Utf8View/BinaryView) carry a variadic-buffer count in the RecordBatch;
310+ // no other type does. The cache never writes view vectors today, but account for them so
311+ // the span arithmetic stays correct if that changes.
312+ case _ : org.apache.arrow.vector.types.pojo.ArrowType .Utf8View |
313+ _ : org.apache.arrow.vector.types.pojo.ArrowType .BinaryView => 1
314+ case _ => 0
315+ }
316+ own + field.getChildren.asScala.map(fieldVariadicCount).sum
317+ }
318+
319+ /**
320+ * Read an encapsulated IPC RecordBatch message from `data`, materializing off-heap only the
321+ * buffers of the requested top-level columns. This is the projection-pushdown read path: the
322+ * message metadata (a small flatbuffer) lists every buffer's (offset, length) within the body,
323+ * so we copy just the byte ranges belonging to the selected columns straight out of the
324+ * in-memory `data` array, never touching (or allocating off-heap for) the unselected columns.
325+ *
326+ * The body is a flat, depth-first sequence of buffers in schema order, so each top-level column
327+ * owns a contiguous run of buffers whose span is `fieldBufferCount`; field nodes and variadic
328+ * counts run in the same order. The selected columns' bytes are copied into one off-heap buffer
329+ * (each buffer 8-byte aligned, matching Arrow's IPC body layout) and the returned batch's
330+ * buffers are windows into it, exactly like the standard reader slices one body buffer -- so the
331+ * batch has a single underlying allocation and no per-buffer bookkeeping. The returned batch
332+ * owns its buffers (the constructor retains each), so the caller closes it as usual.
333+ *
334+ * Compression is preserved unchanged: buffer (offset, length) spans cover the on-body bytes
335+ * including any per-buffer uncompressed-length prefix, so the copied windows are still compressed
336+ * as written; `VectorLoader.load` decompresses only the selected ones later.
337+ */
338+ def readProjectedRecordBatch (
339+ data : Array [Byte ],
340+ schemaFields : Seq [Field ],
341+ selectedIndices : Array [Int ],
342+ allocator : BufferAllocator ): ArrowRecordBatch = {
343+ val in = new ByteArrayInputStream (data)
344+ val readChannel = new ReadChannel (Channels .newChannel(in))
345+ // Read only the message metadata; the body bytes stay in `data` and are copied selectively.
346+ val metadata = MessageSerializer .readMessage(readChannel)
347+ require(metadata != null , " Unexpected end of input reading cached batch message" )
348+ val batch =
349+ metadata.getMessage.header(new FlatBufRecordBatch ()).asInstanceOf [FlatBufRecordBatch ]
350+ // serializeBatch writes exactly [encapsulated message][body] with no end-of-stream marker, so
351+ // the body is the tail of `data`: it starts at data.length minus the declared body length.
352+ val bodyStart = data.length - metadata.getMessageBodyLength().toInt
353+
354+ val compression : ArrowBodyCompression =
355+ if (batch.compression() == null ) NoCompressionCodec .DEFAULT_BODY_COMPRESSION
356+ else new ArrowBodyCompression (batch.compression().codec(), batch.compression().method())
357+
358+ val nodeStarts = schemaFields.scanLeft(0 )(_ + fieldNodeCount(_)).toArray
359+ val bufferStarts = schemaFields.scanLeft(0 )(_ + fieldBufferCount(_)).toArray
360+ val variadicStarts = schemaFields.scanLeft(0 )(_ + fieldVariadicCount(_)).toArray
361+ val hasVariadic = batch.variadicBufferCountsLength() > 0
362+
363+ // Enumerate the selected columns' nodes, buffer indices and variadic counts, in output order.
364+ val selectedNodes = new java.util.ArrayList [ArrowFieldNode ]()
365+ val selectedBufferIdx = new scala.collection.mutable.ArrayBuffer [Int ]()
366+ val selectedVariadic = new java.util.ArrayList [java.lang.Long ]()
367+ selectedIndices.foreach { i =>
368+ val field = schemaFields(i)
369+ val nStart = nodeStarts(i)
370+ (nStart until nStart + fieldNodeCount(field)).foreach { j =>
371+ val node = batch.nodes(j)
372+ selectedNodes.add(new ArrowFieldNode (node.length(), node.nullCount()))
373+ }
374+ val bStart = bufferStarts(i)
375+ (bStart until bStart + fieldBufferCount(field)).foreach(selectedBufferIdx += _)
376+ if (hasVariadic) {
377+ val vStart = variadicStarts(i)
378+ (vStart until vStart + fieldVariadicCount(field)).foreach(j =>
379+ selectedVariadic.add(batch.variadicBufferCounts(j)))
380+ }
381+ }
382+
383+ val layout = selectedBufferIdx.map { j =>
384+ val buf = batch.buffers(j)
385+ (buf.offset(), buf.length())
386+ }
387+ val alignedSizes = layout.map { case (_, len) => ((len + 7 ) / 8 ) * 8 }
388+ val body = allocator.buffer(math.max(alignedSizes.sum, 1 ))
389+ try {
390+ val selectedBuffers = new java.util.ArrayList [org.apache.arrow.memory.ArrowBuf ]()
391+ var pos = 0L
392+ layout.indices.foreach { k =>
393+ val (srcOffset, len) = layout(k)
394+ if (len > 0 ) {
395+ body.setBytes(pos, data, bodyStart + srcOffset.toInt, len.toInt)
396+ }
397+ val window = body.slice(pos, len)
398+ window.writerIndex(len)
399+ selectedBuffers.add(window)
400+ pos += alignedSizes(k)
401+ }
402+ val recordBatch = new ArrowRecordBatch (
403+ batch.length().toInt,
404+ selectedNodes,
405+ selectedBuffers,
406+ compression,
407+ selectedVariadic,
408+ false )
409+ // The constructor retained each window (slice() itself does not), so the batch now holds one
410+ // reference per window into `body`. Drop `body`'s original allocation reference; the batch is
411+ // then the sole owner and the caller's recordBatch.close() frees the single allocation.
412+ body.close()
413+ recordBatch
414+ } catch {
415+ case t : Throwable =>
416+ body.close()
417+ throw t
418+ }
419+ }
420+
288421 /**
289422 * Byte offset of the unscaled low-order word within a 16-byte Arrow Decimal128 slot, for the
290423 * given native byte order. Arrow Java writes decimal values in native byte order
@@ -1143,6 +1276,21 @@ private class ArrowCachedBatchToColumnarBatchIterator(
11431276 private val arrowSchema = ArrowUtils .toArrowSchema(
11441277 cacheSchema, timeZoneId, false , false , losslessInternalTypes = true )
11451278
1279+ // Projection pushdown: the cached batch stores all cache columns, but only the selected ones
1280+ // are needed. When every selected column maps to a distinct cached column, read a batch holding
1281+ // only the selected columns' buffers (in columnIndices order) so unselected columns are never
1282+ // copied off-heap, loaded, or decompressed. The projected schema's field order matches
1283+ // columnIndices, so the loaded root's vectors are already in output order. If any selected
1284+ // attribute is absent from the cache schema (index -1), fall back to reading the full batch.
1285+ private val cacheFields = arrowSchema.getFields.asScala.toSeq
1286+ private val canProjectOnLoad = columnIndices.forall(_ >= 0 )
1287+ private val projectedSchema =
1288+ if (canProjectOnLoad) {
1289+ new org.apache.arrow.vector.types.pojo.Schema (columnIndices.map(cacheFields).toList.asJava)
1290+ } else {
1291+ arrowSchema
1292+ }
1293+
11461294 // Track only the previous root to close it when next batch is produced
11471295 private var previousRoot : VectorSchemaRoot = null
11481296
@@ -1202,11 +1350,16 @@ private class ArrowCachedBatchToColumnarBatchIterator(
12021350
12031351 previousRoot = root
12041352
1205- // Wrap vectors in ArrowColumnVector and project to selected columns.
1206- val allColumns = root.getFieldVectors.asScala.map { vector =>
1207- new ArrowColumnVector (vector)
1208- }.toArray[ColumnVector ]
1209- val selectedColumns = columnIndices.map(allColumns(_))
1353+ // When projected on load, the root already holds only the selected columns in output order,
1354+ // so wrap its vectors directly. Otherwise it holds all cache columns and must be selected.
1355+ val selectedColumns = if (canProjectOnLoad) {
1356+ root.getFieldVectors.asScala.map(v => new ArrowColumnVector (v)).toArray[ColumnVector ]
1357+ } else {
1358+ val allColumns = root.getFieldVectors.asScala.map { vector =>
1359+ new ArrowColumnVector (vector)
1360+ }.toArray[ColumnVector ]
1361+ columnIndices.map(allColumns(_))
1362+ }
12101363 val batch = new ColumnarBatch (selectedColumns, root.getRowCount)
12111364
12121365 // Start prefetching the next batch while this one is being consumed.
@@ -1217,11 +1370,18 @@ private class ArrowCachedBatchToColumnarBatchIterator(
12171370
12181371 /** Deserialize a cached batch into its own freshly-created root. Does not touch other roots. */
12191372 private def deserializeToRoot (cachedBatch : ArrowCachedBatch ): VectorSchemaRoot = {
1220- val in = new ByteArrayInputStream (cachedBatch.arrowData)
1221- val readChannel = new ReadChannel (Channels .newChannel(in))
1222- val recordBatch = MessageSerializer .deserializeRecordBatch(readChannel, allocator)
1373+ // Projection pushdown: read only the selected columns' buffers out of the cached bytes, so
1374+ // unselected columns are never copied off-heap, loaded, or decompressed.
1375+ val recordBatch = if (canProjectOnLoad) {
1376+ ArrowCachedBatchSerializer .readProjectedRecordBatch(
1377+ cachedBatch.arrowData, cacheFields, columnIndices, allocator)
1378+ } else {
1379+ val in = new ByteArrayInputStream (cachedBatch.arrowData)
1380+ val readChannel = new ReadChannel (Channels .newChannel(in))
1381+ MessageSerializer .deserializeRecordBatch(readChannel, allocator)
1382+ }
12231383 Utils .tryWithSafeFinally {
1224- val root = VectorSchemaRoot .create(arrowSchema , allocator)
1384+ val root = VectorSchemaRoot .create(projectedSchema , allocator)
12251385 // VectorLoader.load fills vectors incrementally, so a failure (malformed data, decompression
12261386 // error, OOM) can occur after earlier vectors have allocated buffers. Close the partially
12271387 // loaded root on failure, otherwise it becomes unreachable and the later allocator.close()
@@ -1424,6 +1584,20 @@ private class ArrowCachedBatchToInternalRowIterator(
14241584 private val arrowSchema = ArrowUtils .toArrowSchema(
14251585 cacheSchema, timeZoneId, false , false , losslessInternalTypes = true )
14261586
1587+ // Projection pushdown: see ArrowCachedBatchToColumnarBatchIterator. When every selected column
1588+ // maps to a distinct cached column, read a batch holding only the selected columns' buffers so
1589+ // unselected columns are never copied off-heap, loaded, or decompressed, and readers bind
1590+ // positionally. If any selected attribute is absent from the cache (index -1), fall back to the
1591+ // full batch and bind readers via columnIndices.
1592+ private val cacheFields = arrowSchema.getFields.asScala.toSeq
1593+ private val canProjectOnLoad = columnIndices.forall(_ >= 0 )
1594+ private val projectedSchema =
1595+ if (canProjectOnLoad) {
1596+ new org.apache.arrow.vector.types.pojo.Schema (columnIndices.map(cacheFields).toList.asJava)
1597+ } else {
1598+ arrowSchema
1599+ }
1600+
14271601 // Pre-build typed readers per column at init time -- no per-row pattern match
14281602 private val columnReaders : Array [ArrowColumnReader ] =
14291603 selectedSchema.fields.map(f => ArrowColumnReader .create(f.dataType))
@@ -1504,11 +1678,17 @@ private class ArrowCachedBatchToInternalRowIterator(
15041678
15051679 /** Deserialize a cached batch into a VectorSchemaRoot. */
15061680 private def deserializeBatch (cachedBatch : ArrowCachedBatch ): VectorSchemaRoot = {
1507- val in = new ByteArrayInputStream (cachedBatch.arrowData)
1508- val readChannel = new ReadChannel (Channels .newChannel(in))
1509- val recordBatch = MessageSerializer .deserializeRecordBatch(readChannel, allocator)
1681+ // Projection pushdown: read only the selected columns' buffers out of the cached bytes.
1682+ val recordBatch = if (canProjectOnLoad) {
1683+ ArrowCachedBatchSerializer .readProjectedRecordBatch(
1684+ cachedBatch.arrowData, cacheFields, columnIndices, allocator)
1685+ } else {
1686+ val in = new ByteArrayInputStream (cachedBatch.arrowData)
1687+ val readChannel = new ReadChannel (Channels .newChannel(in))
1688+ MessageSerializer .deserializeRecordBatch(readChannel, allocator)
1689+ }
15101690 try {
1511- val root = VectorSchemaRoot .create(arrowSchema , allocator)
1691+ val root = VectorSchemaRoot .create(projectedSchema , allocator)
15121692 // VectorLoader.load fills vectors incrementally, so a failure (malformed data, decompression
15131693 // error, OOM) can occur after earlier vectors have allocated buffers. Close the partially
15141694 // loaded root on failure, otherwise it becomes unreachable and the later allocator.close()
@@ -1560,10 +1740,13 @@ private class ArrowCachedBatchToInternalRowIterator(
15601740
15611741 currentRoot = root
15621742
1563- // Update pre-built readers with new vectors
1743+ // Update pre-built readers with new vectors. When projected on load, the root holds the
1744+ // selected columns positionally; otherwise it holds all cache columns, selected via
1745+ // columnIndices.
15641746 var i = 0
15651747 while (i < numFields) {
1566- columnReaders(i).setVector(root.getVector(columnIndices(i)))
1748+ val vectorIndex = if (canProjectOnLoad) i else columnIndices(i)
1749+ columnReaders(i).setVector(root.getVector(vectorIndex))
15671750 i += 1
15681751 }
15691752
0 commit comments