This is an automated email from the ASF dual-hosted git repository.

gengliangwang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new c9a910486056 [SPARK-57909][SQL] Extract ColumnarToRow batch-advance 
machinery into a compiled helper
c9a910486056 is described below

commit c9a910486056c3f267ec4f4f4340e7c800a25aa0
Author: Gengliang Wang <[email protected]>
AuthorDate: Mon Jul 6 09:25:04 2026 -0700

    [SPARK-57909][SQL] Extract ColumnarToRow batch-advance machinery into a 
compiled helper
    
    ### What changes were proposed in this pull request?
    
    Every `ColumnarToRowExec` stage re-emits the same type-independent 
batch-advance machinery into its
    generated `nextBatch` method -- release the current batch, fetch the next 
one from the input
    iterator, bump the `numInputBatches`/`numOutputRows` metrics -- plus 
release lines in the
    `processNext` loop epilogue.
    
    Before -- the fetch/metrics logic is inline in `nextBatch`, and the release 
is a separate pair of
    lines in the `processNext` loop:
    
    ```java
    private void nextBatch() throws java.io.IOException {
      if (input.hasNext()) {
        batch = (ColumnarBatch) input.next();
        numInputBatches.add(1);
        numOutputRows.add(batch.numRows());
        idx = 0;
        colInstance0 = (OnHeapColumnVector) batch.column(0);   // per-column 
casts (schema-specific)
        ...
      }
    }
    // ... inside processNext()'s loop epilogue:
    idx = numRows;
    batch.closeIfFreeable();
    batch = null;
    nextBatch();
    ```
    
    After -- the release/fetch/metrics move into a compiled
    `ColumnarToRowExec.advanceBatch(input, current, numInputBatches, 
numOutputRows)` helper; the
    generated `nextBatch` keeps only the per-column `ColumnVector` casts (the 
schema-specific part), and
    the two release lines drop out of the loop epilogue since `advanceBatch` 
now does the release:
    
    ```java
    private void nextBatch() throws java.io.IOException {
      batch = org.apache.spark.sql.execution.ColumnarToRowExec.advanceBatch(
        input, batch, numInputBatches, numOutputRows);
      if (batch != null) {
        idx = 0;
        colInstance0 = (OnHeapColumnVector) batch.column(0);   // per-column 
casts (schema-specific)
        ...
      }
    }
    // ... loop epilogue is now just:
    idx = numRows;
    nextBatch();
    ```
    
    This is a relocation: the batch-advance logic still exists as bytecode, but 
in one precompiled
    helper rather than re-emitted into every stage's `processNext`/`nextBatch`. 
Semantics are preserved
    point-by-point:
    
    - same release-before-fetch order (`closeIfFreeable` on the previous batch 
before fetching; no
      release on the first call when the batch is null);
    - exhaustion leaves the batch null, so the processing loop exits exactly as 
before;
    - mid-batch `shouldStop` re-entry is untouched (no advance happens on that 
path);
    - the limit-reached cleanup block is unchanged.
    
    Exception path: if the input iterator throws mid-advance (after the helper 
released the current
    batch), the generated batch field briefly keeps referencing the released 
batch. That state is
    unobservable -- the cleanup block is not in a `finally` so it is skipped on 
a throw, a failed task's
    iterator is never pumped again, and reader-owned batches are not freeable 
so `closeIfFreeable`
    cannot release them in the first place. This reasoning is documented in the 
emitter.
    
    ### Why are the changes needed?
    
    Part of [SPARK-56908](https://issues.apache.org/jira/browse/SPARK-56908) 
(reduce generated Java size
    in whole-stage codegen). On a TPC-DS codegen dump (150 queries, 1,572 
whole-stage-codegen subtrees):
    about **-2.2%** summed per-stage max method bytecode (the advance code sits 
in `processNext`,
    typically the largest generated method, which is the metric behind the 
HotSpot 8KB JIT-compilation
    threshold) and **-0.9%** total generated source, with every query shrinking.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Existing tests: `WholeStageCodegenSuite`, `ParquetV1QuerySuite`, and 
`ParquetV2QuerySuite` exercise
    execution through the parquet read paths (batch release, exhaustion, and 
re-entry).
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #56977 from gengliangwang/SPARK-57909-columnar-advance-batch.
    
    Authored-by: Gengliang Wang <[email protected]>
    Signed-off-by: Gengliang Wang <[email protected]>
---
 .../org/apache/spark/sql/execution/Columnar.scala  |  42 +++++++--
 .../sql/execution/ColumnarToRowExecSuite.scala     | 104 +++++++++++++++++++++
 2 files changed, 140 insertions(+), 6 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/Columnar.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/Columnar.scala
index 5c98e3685327..c6323575c9a8 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/Columnar.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/Columnar.scala
@@ -152,14 +152,19 @@ case class ColumnarToRowExec(child: SparkPlan)
         (name, s"$name = ($columnVectorClz) $batch.column($i);")
     }.unzip
 
+    // The type-independent advance machinery (releasing the current batch, 
fetching the next one
+    // and bumping the metrics) is a compiled helper; only the per-column 
vector casts are emitted.
+    // Exception-path note: if the helper throws after releasing the current 
batch (the input
+    // iterator failing mid-scan), the field briefly keeps referencing the 
released batch. That
+    // state is unobservable: the cleanup block below is not in a finally, a 
failed task's
+    // iterator is never pumped again, and reader-owned batches are not 
freeable to begin with.
     val nextBatch = ctx.freshName("nextBatch")
     val nextBatchFuncName = ctx.addNewFunction(nextBatch,
       s"""
          |private void $nextBatch() throws java.io.IOException {
-         |  if ($input.hasNext()) {
-         |    $batch = ($columnarBatchClz)$input.next();
-         |    $numInputBatches.add(1);
-         |    $numOutputRows.add($batch.numRows());
+         |  $batch = 
org.apache.spark.sql.execution.ColumnarToRowExec.advanceBatch(
+         |    $input, $batch, $numInputBatches, $numOutputRows);
+         |  if ($batch != null) {
          |    $idx = 0;
          |    ${columnAssigns.mkString("", "\n", "\n")}
          |  }
@@ -191,8 +196,6 @@ case class ColumnarToRowExec(child: SparkPlan)
        |    $shouldStop
        |  }
        |  $idx = $numRows;
-       |  $batch.closeIfFreeable();
-       |  $batch = null;
        |  $nextBatchFuncName();
        |}
        |// clean up resources
@@ -210,6 +213,33 @@ case class ColumnarToRowExec(child: SparkPlan)
     copy(child = newChild)
 }
 
+object ColumnarToRowExec {
+  /**
+   * Releases the current batch (if any) and fetches the next one from the 
input iterator,
+   * bumping the batch/row metrics. Returns null when the input is exhausted. 
This is called by
+   * the generated code of [[ColumnarToRowExec]] (through the static 
forwarder), so the
+   * type-independent per-batch bookkeeping is compiled once per JVM instead 
of being re-emitted
+   * into every stage's generated `nextBatch` method.
+   */
+  def advanceBatch(
+      input: Iterator[ColumnarBatch],
+      current: ColumnarBatch,
+      numInputBatches: SQLMetric,
+      numOutputRows: SQLMetric): ColumnarBatch = {
+    if (current != null) {
+      current.closeIfFreeable()
+    }
+    if (input.hasNext) {
+      val batch = input.next()
+      numInputBatches.add(1)
+      numOutputRows.add(batch.numRows())
+      batch
+    } else {
+      null
+    }
+  }
+}
+
 /**
  * Provides an optimized set of APIs to append row based data to an array of
  * [[WritableColumnVector]].
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/ColumnarToRowExecSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/ColumnarToRowExecSuite.scala
new file mode 100644
index 000000000000..0987c7cb02f6
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/ColumnarToRowExecSuite.scala
@@ -0,0 +1,104 @@
+/*
+ * 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.execution
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.types.{Decimal, IntegerType}
+import org.apache.spark.sql.vectorized.{ColumnarArray, ColumnarBatch, 
ColumnarMap, ColumnVector}
+import org.apache.spark.unsafe.types.UTF8String
+
+class ColumnarToRowExecSuite extends SparkFunSuite {
+
+  /** A minimal ColumnVector that records whether `closeIfFreeable`/`close` 
was called. */
+  private class RecordingColumnVector extends ColumnVector(IntegerType) {
+    var closeIfFreeableCalls = 0
+    var closeCalls = 0
+
+    override def closeIfFreeable(): Unit = closeIfFreeableCalls += 1
+    override def close(): Unit = closeCalls += 1
+
+    private def unused = throw new UnsupportedOperationException("not used by 
advanceBatch")
+    override def hasNull: Boolean = unused
+    override def numNulls(): Int = unused
+    override def isNullAt(rowId: Int): Boolean = unused
+    override def getBoolean(rowId: Int): Boolean = unused
+    override def getByte(rowId: Int): Byte = unused
+    override def getShort(rowId: Int): Short = unused
+    override def getInt(rowId: Int): Int = unused
+    override def getLong(rowId: Int): Long = unused
+    override def getFloat(rowId: Int): Float = unused
+    override def getDouble(rowId: Int): Double = unused
+    override def getArray(rowId: Int): ColumnarArray = unused
+    override def getMap(ordinal: Int): ColumnarMap = unused
+    override def getDecimal(rowId: Int, precision: Int, scale: Int): Decimal = 
unused
+    override def getUTF8String(rowId: Int): UTF8String = unused
+    override def getBinary(rowId: Int): Array[Byte] = unused
+    override def getChild(ordinal: Int): ColumnVector = unused
+  }
+
+  private def newBatch(numRows: Int): (ColumnarBatch, RecordingColumnVector) = 
{
+    val vector = new RecordingColumnVector
+    val batch = new ColumnarBatch(Array[ColumnVector](vector), numRows)
+    (batch, vector)
+  }
+
+  test("advanceBatch releases the current batch and fetches the next, bumping 
metrics") {
+    val numInputBatches = new SQLMetric("sum")
+    val numOutputRows = new SQLMetric("sum")
+    val (current, currentVector) = newBatch(3)
+    val (next, _) = newBatch(5)
+
+    val result = ColumnarToRowExec.advanceBatch(
+      Iterator(next), current, numInputBatches, numOutputRows)
+
+    assert(result eq next)
+    // The previous batch is released before the next is fetched.
+    assert(currentVector.closeIfFreeableCalls == 1)
+    assert(numInputBatches.value == 1)
+    assert(numOutputRows.value == 5)
+  }
+
+  test("advanceBatch does not release when the current batch is null (first 
call)") {
+    val numInputBatches = new SQLMetric("sum")
+    val numOutputRows = new SQLMetric("sum")
+    val (next, _) = newBatch(4)
+
+    val result = ColumnarToRowExec.advanceBatch(
+      Iterator(next), null, numInputBatches, numOutputRows)
+
+    assert(result eq next)
+    assert(numInputBatches.value == 1)
+    assert(numOutputRows.value == 4)
+  }
+
+  test("advanceBatch returns null and bumps no metrics when the input is 
exhausted") {
+    val numInputBatches = new SQLMetric("sum")
+    val numOutputRows = new SQLMetric("sum")
+    val (current, currentVector) = newBatch(3)
+
+    val result = ColumnarToRowExec.advanceBatch(
+      Iterator.empty, current, numInputBatches, numOutputRows)
+
+    assert(result == null)
+    // The current batch is still released even when there is no next batch.
+    assert(currentVector.closeIfFreeableCalls == 1)
+    assert(numInputBatches.value == 0)
+    assert(numOutputRows.value == 0)
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to