Skip to content

Commit ca6a869

Browse files
committed
[SPARK-58918][SQL] Push column pruning into the Arrow cache read path
### What changes were proposed in this pull request? When reading a projection of an Arrow-cached relation, `ArrowCachedBatchSerializer`'s read path deserialized **every** cached column -- decompressing and loading all of them off-heap -- and then discarded the unselected ones. This change reads only the selected columns out of the cached bytes. A new helper `ArrowCachedBatchSerializer.readProjectedRecordBatch` reads the encapsulated IPC RecordBatch message's metadata (a small flatbuffer that lists every buffer's offset and length within the body) and copies just the byte ranges belonging to the selected columns straight out of the in-memory cached `Array[Byte]`, so the unselected columns are never copied off-heap, loaded, or decompressed. The selected buffers become windows into a single off-heap allocation, mirroring how the standard IPC reader slices one body buffer, so ownership stays a single allocation freed once. It is wired into both read paths -- `convertCachedBatchToColumnarBatch` (columnar) and `convertCachedBatchToInternalRow` (row). A projection whose selected attribute is absent from the cache schema (index `-1`) falls back to reading the full batch. ### Why are the changes needed? The wasted work is proportional to the pruned columns and dominates wide-relation scans, especially with compression. An e2e SQL benchmark (`sum(col0)` over a cached relation, cache materialized outside the timed region, 1M rows x 50 long columns) shows: | | before | after | |---|---|---| | Arrow cache -- uncompressed | 31 ms | 16 ms (~1.9x) | | Arrow cache -- zstd level 1 | 326 ms | 22 ms (~15x) | The compressed case gains most, since the 49 pruned columns are no longer decompressed. ### Does this PR introduce _any_ user-facing change? No. The Arrow cache serializer (SPARK-57268) is unreleased, and this is a read-path performance improvement with identical results. ### How was this patch tested? - A new `ArrowCachedBatchSerializerSuite` test covers projection shapes (reordering, single column at each position, complex-after-var-width, duplicate selection, full projection) on both read paths, and is negative-validated (an off-by-one in the buffer-span arithmetic fails it). - Full `ArrowCachedBatchSerializerSuite` and `ArrowCachedBatchKryoRegistrationSuite` pass (77 tests). - A new `columnPruningWideTable` case in `ArrowCacheBenchmark` measures the read path with the cache built outside the timed region; the committed benchmark result files are regenerated by the benchmark CI job. ### Was this patch authored or co-authored using generative AI tooling? Yes, this pull request and its description were written by Claude Code. Closes #58177 from viirya/arrow-cache-projection-pushdown. Authored-by: Liang-Chi Hsieh <viirya@gmail.com> Signed-off-by: Liang-Chi Hsieh <viirya@gmail.com>
1 parent e55ba12 commit ca6a869

3 files changed

Lines changed: 295 additions & 16 deletions

File tree

sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ArrowCachedBatchSerializer.scala

Lines changed: 199 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ import java.nio.channels.Channels
2323
import scala.jdk.CollectionConverters._
2424

2525
import 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}
2729
import org.apache.arrow.vector.compression.{CompressionCodec, NoCompressionCodec}
2830
import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel}
31+
import org.apache.arrow.vector.ipc.message.{ArrowBodyCompression, ArrowFieldNode}
2932
import org.apache.arrow.vector.ipc.message.{ArrowRecordBatch, MessageSerializer}
33+
import org.apache.arrow.vector.types.pojo.Field
3034

3135
import org.apache.spark.{SparkException, TaskContext}
3236
import 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

sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/ArrowCacheBenchmark.scala

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,13 +793,69 @@ object ArrowCacheBenchmark extends SqlBasedBenchmark {
793793
}
794794
}
795795

796+
private def columnPruningWideTable(): Unit = {
797+
// 1M rows x 50 long columns is a ~400MB uncompressed cache -- wide enough that pruning 49 of
798+
// 50 columns matters, but small enough to sit comfortably in the benchmark heap so the timing
799+
// reflects the read, not GC pressure from a cache that nearly fills the heap.
800+
val numRows = 1000000
801+
val numCols = 50
802+
val cols = (0 until numCols).map(i => s"id + $i as col$i")
803+
804+
// Measure only the read: summing one column while pruning the other 49. Each case builds and
805+
// fully materializes its cache before starting the timer (via addTimerCase), so the numbers
806+
// reflect the cached-scan read path -- which column pruning speeds up -- not the one-time cache
807+
// materialization. An aggregate is used rather than a projection to a noop sink because the
808+
// latter does not pull the selected column's data through the read path. The cache serializer
809+
// is a JVM-wide static, so each case creates its own fresh session (excluded from timing),
810+
// which also lets the Default and Arrow serializers share one comparison table.
811+
val default = "org.apache.spark.sql.execution.columnar.DefaultCachedBatchSerializer"
812+
val arrow = "org.apache.spark.sql.execution.columnar.ArrowCachedBatchSerializer"
813+
runBenchmark(s"Cache with column pruning (sum 1 of $numCols columns)") {
814+
val benchmark = new Benchmark(
815+
s"Sum 1 of $numCols columns, $numRows rows", numRows, output = output)
816+
817+
def addPruningCase(name: String, serializer: String)(
818+
configure: SparkSession => Unit): Unit = {
819+
benchmark.addTimerCase(name) { timer =>
820+
val spark = createFreshSession(serializer)
821+
try {
822+
configure(spark)
823+
val df = spark.range(numRows).selectExpr(cols: _*)
824+
df.cache()
825+
df.count() // materialize the cache before timing
826+
timer.startTiming()
827+
df.selectExpr("sum(col0)").collect()
828+
timer.stopTiming()
829+
df.unpersist(blocking = true)
830+
} finally {
831+
spark.stop()
832+
}
833+
}
834+
}
835+
836+
addPruningCase("Default cache", default)(_ => ())
837+
addPruningCase("Default cache (uncompressed)", default) { spark =>
838+
spark.conf.set("spark.sql.inMemoryColumnarStorage.compressed", "false")
839+
}
840+
addPruningCase("Arrow cache", arrow)(_ => ())
841+
Seq("-1", "1", "3").foreach { level =>
842+
addPruningCase(s"Arrow cache (zstd level $level)", arrow) { spark =>
843+
spark.conf.set(SQLConf.ARROW_EXECUTION_COMPRESSION_CODEC.key, "zstd")
844+
spark.conf.set(SQLConf.ARROW_EXECUTION_ZSTD_COMPRESSION_LEVEL.key, level)
845+
}
846+
}
847+
benchmark.run()
848+
}
849+
}
850+
796851
override def runBenchmarkSuite(mainArgs: Array[String]): Unit = {
797852
runBenchmark("Arrow Cache vs Default Cache") {
798853
cachePrimitiveTypes()
799854
cacheWithFilters()
800855
cacheColumnarInput()
801856
recacheArrowData()
802857
columnPruning()
858+
columnPruningWideTable()
803859
}
804860
}
805861
}

0 commit comments

Comments
 (0)