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

dongjoon-hyun 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 e3f4817f1ad4 [SPARK-60395][SQL] Fix the batch size computation and 
limit check of maxBytesPerOutputBatch in sliced Arrow output
e3f4817f1ad4 is described below

commit e3f4817f1ad4f879793cf1093e0c6a3e4cd3a644
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Thu Oct 29 06:56:44 2026 -0700

    [SPARK-60395][SQL] Fix the batch size computation and limit check of 
maxBytesPerOutputBatch in sliced Arrow output
    
    ### What changes were proposed in this pull request?
    
    This PR fixes how `SliceBytesArrowOutputProcessorImpl`, used when the 
internal config `spark.sql.execution.arrow.maxBytesPerOutputBatch` is set, 
measures an output batch and compares it with the limit. It addresses the six 
items in SPARK-60395, raised in the review of #59616:
    
    1. Add `SliceBytesArrowOutputProcessorImpl.bufferSize(vector): Long` and 
use it to measure batches. `ValueVector.getBufferSize` computes the size in 
int, so it wraps around over 2 GiB: a struct vector sums its children and 
returns `(int) buffer`, and fixed- and variable-width vectors compute 
`valueCount * typeWidth` and their offsets in int. Large variable-width vectors 
cap it at `Int.MaxValue`, which a limit of `Int.MaxValue` would accept. The new 
function computes fixed-width and (l [...]
    2. Both checks now use `<=`. This matches the config doc ("equal or less 
than this value") and the records processor.
    3. The shrink search no longer restarts from all the remaining rows for 
every output batch. When the whole batch does not fit, the first search starts 
from the number of rows that would fit at the root's bytes per row, at most 
`0.9 * rowCount`, which skips the `slice(0, rowCount)` probe that always 
measured the same as the root. After a slice fits, the next search starts from 
the number of rows that would fit at that slice's bytes per row, capped by the 
remaining rows. This is never b [...]
    4. A row larger than the limit is output as the one-row slice that the 
search measured last, rather than a slice rebuilt after closing it, without the 
always-true `if (sliceBatchCount == 0)` and the unused initial `rootSlice`. 
Only a one-row batch that does not fit still builds `root.slice(currentRowIdx, 
1)`.
    5. `SQLConf.arrowMaxBytesPerOutputBatch` now returns `math.max(value, 0L)`, 
still as a Long, before `PythonArrowOutput` narrows it with `.toInt`. Before, a 
value below `Int.MinValue` could wrap around to a positive limit.
    6. Drop the redundant `rowCount > 0 &&` in 
`BaseSliceArrowOutputProcessor.loadBatch`.
    7. Output a batch whole when Arrow cannot slice it. Arrow 19 computes some 
byte positions of a slice in int: 
`BaseFixedWidthVector.splitAndTransferValueBuffer` computes `startIndex * 
typeWidth` and `length * typeWidth`, `ListVector` (and so `MapVector`) the 
positions of its offsets, and the view vectors `startIndex * 16`. So 
`root.slice` throws for a fixed-width vector of 2 GiB or more, a list vector 
with 2^29 - 1 rows or more or a view vector with 2^27 rows or more, at any 
level of t [...]
    
    ### Why are the changes needed?
    
    - The outputs of mapInArrow, mapInPandas and applyInPandas are one 
top-level struct vector. A batch over 2 GiB could measure negative and be 
output without slicing. For example, three int64 columns of 2^27 rows measure 
as -1,006,632,960 bytes.
    - A batch whose size equals the limit was sliced, although the doc says it 
is allowed.
    - A probe usually copies its offset buffers: string and binary columns when 
it does not start at row 0, and array and map columns always. Restarting the 
search from all the remaining rows made splitting one batch of R rows into 
chunks of c rows copy O(R^2/c) offsets.
    - A negative value, which the doc describes as no limit, could wrap around 
to a positive limit, e.g. `-3g` became 1 GiB and `-4294967295` became 1 byte. 
If `maxRecordsPerOutputBatch` was also set, the check that both limits are not 
set at once then threw.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Only for the internal config 
`spark.sql.execution.arrow.maxBytesPerOutputBatch`:
    - Output batches over 2 GiB are now sliced, unless Arrow cannot slice them 
(item 7), in which case they are output whole as before.
    - A batch whose size equals the limit is no longer sliced.
    - Values below `Int.MinValue` now mean no limit, as the doc says.
    
    ### How was this patch tested?
    
    New tests in `PythonArrowOutputSuite`:
    - A batch whose size equals the limit is not sliced, and one byte less 
slices it.
    - The number of size measurements stays linear in the number of slices. 
Splitting 2000 rows into about 50 slices took 1513 measurements before this 
change.
    - The exact row counts of the slices, with a modeled size per row: uniform 
rows, including a slice that fits the limit exactly; one row larger than the 
limit followed by small rows; a few large rows followed by small rows; and rows 
that are all larger than the limit.
    - A run of rows larger than the limit takes about one measurement per row, 
after earlier slices have fit.
    - A batch that Arrow cannot slice is output whole by the bytes slicing, and 
`canSlice` rejects a struct vector that holds an int64 vector of 2^28 values. 
SPARK-60627 already tests `canSlice` at the boundaries of flat vectors.
    - `bufferSize` matches `getBufferSize` for nested and leaf vectors 
(boolean, decimal, binary, array of struct, map of array) on roots and slices 
below 2 GiB.
    - `bufferSize` and `batchBytes` do not wrap around for a struct vector 
whose children report 3 GiB in total and include an int64 vector of 2^28 
values, which the struct measures only if it measures its children in Long; 
`bufferSize` does not wrap around for a fixed-width vector of 2^28 int64 
values, a variable-width vector whose data is close to 2 GiB, and a large 
variable-width vector of 3 GiB. These vectors report a large size or end 
offset, so nothing over 2 GiB is allocated.
    - `maxBytesPerOutputBatch` values of `-3g`, `-4294967295`, `-1` and `0` 
mean no limit.
    
    The tests for the equal-limit batch, the measurement counts, the slice row 
counts, the batch output whole, the struct over 2 GiB and the negative values 
fail without the fix. Also ran `pyspark.sql.tests.arrow.test_arrow_map`, 
`pyspark.sql.tests.pandas.test_pandas_grouped_map_with_state`, 
`pyspark.sql.tests.arrow.test_arrow_python_udf_cached` and 
`pyspark.sql.tests.test_udtf`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5.5)
    
    This pull request and its description were written by Isaac.
    
    Closes #59786 from viirya/SPARK-60395.
    
    Authored-by: Liang-Chi Hsieh <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../org/apache/spark/sql/internal/SQLConf.scala    |  11 +-
 .../sql/execution/python/PythonArrowOutput.scala   | 142 +++++++++----
 .../execution/python/PythonArrowOutputSuite.scala  | 228 ++++++++++++++++++++-
 3 files changed, 336 insertions(+), 45 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 550536cf2a95..4a16f2618f5a 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -5582,8 +5582,10 @@ object SQLConf {
         "the columnar-based operator implemented by third-party libraries. 
Spark will try to " +
         "create batches with the size equal or less than this value. Normally 
it should not " +
         "happen, but if in extreme case that even one record is still larger 
than this value, " +
-        "Spark will create a batch with one record. applyInPandasWithState, 
Arrow Python " +
-        "UDTFs and Arrow Python UDFs that take columnar input directly ignore 
this config.")
+        "Spark will create a batch with one record. A batch that Arrow cannot 
slice, e.g. one " +
+        "with a fixed-width column of 2 GiB or more, is sent as a whole. " +
+        "applyInPandasWithState, Arrow Python UDTFs and Arrow Python UDFs that 
" +
+        "take columnar input directly ignore this config.")
       .version("4.1.0")
       .internal()
       .bytesConf(ByteUnit.BYTE)
@@ -10114,7 +10116,10 @@ class SQLConf extends Serializable with Logging with 
SqlApiConf {
 
   def arrowMaxRecordsPerOutputBatch: Int = 
getConf(ARROW_EXECUTION_MAX_RECORDS_PER_OUTPUT_BATCH)
 
-  def arrowMaxBytesPerOutputBatch: Long = 
getConf(ARROW_EXECUTION_MAX_BYTES_PER_OUTPUT_BATCH)
+  // Zero or negative means no limit; clamp it so that a value below 
Int.MinValue does not wrap
+  // around to a positive limit when narrowed to an int.
+  def arrowMaxBytesPerOutputBatch: Long =
+    math.max(getConf(ARROW_EXECUTION_MAX_BYTES_PER_OUTPUT_BATCH), 0L)
 
   def arrowMaxBytesPerBatch: Long = 
getConf(ARROW_EXECUTION_MAX_BYTES_PER_BATCH)
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
index 07a0939736e9..aa6c0f2a9bf9 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonArrowOutput.scala
@@ -21,7 +21,7 @@ import java.util.concurrent.atomic.AtomicBoolean
 
 import scala.jdk.CollectionConverters._
 
-import org.apache.arrow.vector.{BaseFixedWidthVector, 
BaseVariableWidthViewVector, FieldVector, VectorSchemaRoot}
+import org.apache.arrow.vector.{BaseFixedWidthVector, 
BaseLargeVariableWidthVector, BaseVariableWidthVector, 
BaseVariableWidthViewVector, BitVectorHelper, FieldVector, ValueVector, 
VectorSchemaRoot}
 import org.apache.arrow.vector.complex.{BaseRepeatedValueVector, ListVector}
 import org.apache.arrow.vector.ipc.ArrowStreamReader
 
@@ -224,7 +224,7 @@ abstract class BaseSliceArrowOutputProcessor(
   protected var prevRoot: VectorSchemaRoot = root
 
   override def loadBatch(): Boolean = {
-    if (rowCount > 0 && currentRowIdx < rowCount) {
+    if (currentRowIdx < rowCount) {
       true
     } else {
       val loaded = super.loadBatch()
@@ -303,47 +303,115 @@ class SliceBytesArrowOutputProcessorImpl(
     arrowMaxBytesPerOutputBatch: Int)
   extends BaseSliceArrowOutputProcessor(reader, pythonMetrics) {
 
+  // The number of rows from which the search for the next slice of the 
current batch starts.
+  private var sliceRowCount = 0
+  // Whether the last output batch is one row larger than the limit.
+  private var lastRowTooLarge = false
+
   protected override def getRoot: VectorSchemaRoot = {
-    // Output the whole root only if no row of this batch has been output yet.
-    if (currentRowIdx == 0 && getBatchBytes(root) < 
arrowMaxBytesPerOutputBatch) {
-      currentRowIdx = rowCount
-      root
-    } else {
-      // We iteratively slice the batch by 90% until we reach a batch with 
required byte size.
-      // If we cannot have it even with one single row, we will output a batch 
with one row.
-      var rootSlice = root
-      val stepRatio = 0.9
-      var sliceBatchCount = rowCount - currentRowIdx
-
-      while (sliceBatchCount > 0) {
-        rootSlice = root.slice(currentRowIdx, sliceBatchCount)
-        val batchBytes = getBatchBytes(rootSlice)
-        if (batchBytes < arrowMaxBytesPerOutputBatch) {
-          currentRowIdx = currentRowIdx + rootSlice.getRowCount
-
-          return rootSlice
-        } else {
-          rootSlice.close()
-          sliceBatchCount = (sliceBatchCount * stepRatio).toInt
-        }
+    if (currentRowIdx == 0) {
+      lastRowTooLarge = false
+      // Output the whole root if it fits, which happens only if no row of it 
has been output yet,
+      // or if Arrow cannot slice it.
+      val rootBytes = batchBytes(root)
+      if (rootBytes <= arrowMaxBytesPerOutputBatch || !canSlice(root)) {
+        currentRowIdx = rowCount
+        return root
       }
-      // If the batch size is still too large, we need to slice it down to a 
single row.
-      // Normally, this should not happen, but it is possible if the data is 
very large.
-      if (sliceBatchCount == 0) {
-        sliceBatchCount = 1
+      // The first search starts from the rows that would fit at the bytes per 
row of the root.
+      val estimate = math.max(rowCount.toLong * arrowMaxBytesPerOutputBatch / 
rootBytes, 1L)
+      sliceRowCount = math.min(
+        estimate, (rowCount * 
SliceBytesArrowOutputProcessorImpl.stepRatio).toLong).toInt
+    }
+    if (lastRowTooLarge) {
+      // Probe the row after a row larger than the limit alone, and output it 
if it is larger than
+      // the limit too, so that a run of such rows does not search from 
`sliceRowCount` every row.
+      val rootSlice = root.slice(currentRowIdx, 1)
+      if (batchBytes(rootSlice) > arrowMaxBytesPerOutputBatch) {
+        currentRowIdx += 1
+        return rootSlice
       }
-      rootSlice = root.slice(currentRowIdx, sliceBatchCount)
-      currentRowIdx = currentRowIdx + rootSlice.getRowCount
-      rootSlice
+      rootSlice.close()
+      lastRowTooLarge = false
     }
+    // We iteratively shrink the slice by 10% until it fits. Each later search 
starts from the
+    // number of rows that would fit at the bytes per row of the last slice 
that fit, or of the
+    // root while none has, rather than from all the remaining rows, since a 
slice usually copies
+    // its offset buffers, and restarting from all the remaining rows would 
copy them again and
+    // again.
+    var numRows = math.min(sliceRowCount, rowCount - currentRowIdx)
+    while (numRows > 0) {
+      val rootSlice = root.slice(currentRowIdx, numRows)
+      val bytes = batchBytes(rootSlice)
+      if (bytes <= arrowMaxBytesPerOutputBatch) {
+        // Never below `numRows`, so the next slice can grow again after a run 
of large rows.
+        val estimate = numRows.toLong * arrowMaxBytesPerOutputBatch / 
math.max(bytes, 1L)
+        sliceRowCount = math.min(estimate, Int.MaxValue).toInt
+        currentRowIdx += numRows
+        return rootSlice
+      }
+      if (numRows == 1) {
+        // Even a single row is larger than the limit, so we output a batch 
with that one row.
+        // Normally, this should not happen, but it is possible if the data is 
very large. The
+        // next search starts from where this one did, since the next rows may 
be much smaller.
+        lastRowTooLarge = true
+        currentRowIdx += 1
+        return rootSlice
+      }
+      rootSlice.close()
+      numRows = (numRows * SliceBytesArrowOutputProcessorImpl.stepRatio).toInt
+    }
+    // A batch of one row that is larger than the limit, where the search does 
not run.
+    val rootSlice = root.slice(currentRowIdx, 1)
+    currentRowIdx += 1
+    rootSlice
   }
 
-  private def getBatchBytes(root: VectorSchemaRoot): Long = {
-    var batchBytes = 0L
-    root.getFieldVectors.asScala.foreach { vector =>
-      batchBytes += vector.getBufferSize
-    }
-    batchBytes
+  private[python] def batchBytes(root: VectorSchemaRoot): Long =
+    SliceBytesArrowOutputProcessorImpl.batchBytes(root)
+}
+
+object SliceBytesArrowOutputProcessorImpl {
+  private val stepRatio = 0.9
+
+  /** Returns the size in bytes of the buffers of `root`, as the sum of 
[[bufferSize]]. */
+  private[python] def batchBytes(root: VectorSchemaRoot): Long =
+    root.getFieldVectors.asScala.map(bufferSize).sum
+
+  /**
+   * Returns the size in bytes of the buffers of `vector`, like 
`ValueVector.getBufferSize` but as
+   * a Long. `getBufferSize` computes the size in int, so it wraps around for 
a vector over 2 GiB,
+   * e.g. a struct vector, which adds up the sizes of its children, such as 
the one top-level
+   * struct vector that holds the output of mapInArrow, mapInPandas and 
applyInPandas. A large
+   * variable-width vector computes it in Long but caps it at Int.MaxValue 
instead.
+   *
+   * Fixed-width and (large) variable-width vectors are measured in Long. A 
nested vector adds up
+   * its children in Long, but its own buffers come from its int 
`getBufferSize`, so they are
+   * still wrong when they alone reach 2 GiB, e.g. the offsets of a list 
vector with 2^29 - 1 rows
+   * or more. Other vectors, e.g. the view vectors, fall back to 
`getBufferSize`.
+   */
+  private[python] def bufferSize(vector: ValueVector): Long = vector match {
+    case v: BaseFixedWidthVector if v.getTypeWidth > 0 && v.getValueCount > 0 
=>
+      BitVectorHelper.getValidityBufferSizeFromCount(v.getValueCount) +
+        v.getValueCount.toLong * v.getTypeWidth
+    case v: BaseVariableWidthVector if v.getValueCount > 0 =>
+      val offsetWidth = BaseVariableWidthVector.OFFSET_WIDTH
+      BitVectorHelper.getValidityBufferSizeFromCount(v.getValueCount) +
+        (v.getValueCount + 1L) * offsetWidth +
+        v.getOffsetBuffer.getInt(v.getValueCount.toLong * offsetWidth)
+    case v: BaseLargeVariableWidthVector if v.getValueCount > 0 =>
+      val offsetWidth = BaseLargeVariableWidthVector.OFFSET_WIDTH
+      BitVectorHelper.getValidityBufferSizeFromCount(v.getValueCount) +
+        (v.getValueCount + 1L) * offsetWidth +
+        v.getOffsetBuffer.getLong(v.getValueCount.toLong * offsetWidth)
+    case v: FieldVector if !v.getChildrenFromFields.isEmpty =>
+      val children = v.getChildrenFromFields.asScala.toSeq
+      // The size of the buffers of the nested vector itself, e.g. the 
validity and offsets. Its
+      // `getBufferSize` adds them to the sizes of its children in int, so 
subtracting the sizes
+      // of the children in int cancels out any wrap-around of the sum.
+      val ownBytes = v.getBufferSize - children.map(_.getBufferSize).sum
+      ownBytes + children.map(bufferSize).sum
+    case v => v.getBufferSize
   }
 }
 
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
index 735b5b8f50ed..bceec11ca8b6 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonArrowOutputSuite.scala
@@ -22,15 +22,17 @@ import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
 import scala.collection.mutable.ArrayBuffer
 import scala.jdk.CollectionConverters._
 
-import org.apache.arrow.vector.{BigIntVector, FieldVector, 
FixedSizeBinaryVector, VectorSchemaRoot}
+import org.apache.arrow.vector.{BigIntVector, FieldVector, 
FixedSizeBinaryVector, LargeVarCharVector, NullVector, VarCharVector, 
VectorSchemaRoot}
 import org.apache.arrow.vector.complex.{ListVector, StructVector}
 import org.apache.arrow.vector.ipc.{ArrowStreamReader, ArrowStreamWriter}
 import org.apache.arrow.vector.types.pojo.{ArrowType, FieldType}
 
 import org.apache.spark.SparkFunSuite
 import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, GenericArrayData}
 import org.apache.spark.sql.execution.arrow.ArrowWriter
 import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types._
 import org.apache.spark.sql.util.ArrowUtils
 import org.apache.spark.sql.vectorized.ArrowColumnVector
@@ -79,8 +81,9 @@ class PythonArrowOutputSuite extends SparkFunSuite {
 
   /**
    * Drives `processor` the way `PythonArrowOutput.newReaderIterator.read()` 
does, and returns
-   * the rows and the buffer size in bytes of every output batch. Rows are 
copied before the
-   * next batch is produced, since the processor releases the previous output 
batch then.
+   * the rows and the buffer size in bytes, as 
`SliceBytesArrowOutputProcessorImpl` measures it,
+   * of every output batch. Rows are copied before the next batch is produced, 
since the
+   * processor releases the previous output batch then.
    */
   private def readAll(
       batchSizes: Seq[Int],
@@ -96,7 +99,8 @@ class PythonArrowOutputSuite extends SparkFunSuite {
     while (processor.loadBatch()) {
       val batch = processor.produceBatch()
       val bytes = (0 until batch.numCols()).map { i =>
-        
batch.column(i).asInstanceOf[ArrowColumnVector].getValueVector.getBufferSize.toLong
+        SliceBytesArrowOutputProcessorImpl.bufferSize(
+          batch.column(i).asInstanceOf[ArrowColumnVector].getValueVector)
       }.sum
       output += ((batch.rowIterator().asScala.map(_.copy()).toSeq, bytes))
     }
@@ -216,7 +220,221 @@ class PythonArrowOutputSuite extends SparkFunSuite {
       checkAllRowsOnceInOrder(batchSizes, output)
       // Guards the choice of cases: each has a batch that does not fit, which 
must be sliced.
       assert(output.size > batchSizes.size)
-      assert(output.forall { case (rows, bytes) => rows.size <= 1 || bytes < 
maxBytes })
+      assert(output.forall { case (rows, bytes) => rows.size <= 1 || bytes <= 
maxBytes })
     }
   }
+
+  test("SPARK-60395: slice by bytes: a batch whose size equals the limit is 
not sliced") {
+    val batchSizes = Seq(50, 50)
+    val batchBytes = readAll(batchSizes, new ArrowOutputProcessorImpl(_, 
_)).map(_._2)
+    assert(batchBytes.distinct.size === 2)
+    val maxBytes = batchBytes.max.toInt
+    val output = readAll(batchSizes, new SliceBytesArrowOutputProcessorImpl(_, 
_, maxBytes))
+    checkAllRowsOnceInOrder(batchSizes, output)
+    assert(output.map(_._1.size) === batchSizes)
+    // One byte less, the larger batch is sliced.
+    val sliced = readAll(batchSizes, new SliceBytesArrowOutputProcessorImpl(_, 
_, maxBytes - 1))
+    checkAllRowsOnceInOrder(batchSizes, sliced)
+    assert(sliced.size > batchSizes.size)
+    assert(sliced.forall(_._2 <= maxBytes - 1))
+  }
+
+  test("SPARK-60395: slice by bytes: each search does not restart from all 
remaining rows") {
+    val batchSizes = Seq(2000)
+    val maxBytes = (readAll(batchSizes, new ArrowOutputProcessorImpl(_, 
_)).head._2 / 50).toInt
+    var measured = 0
+    val output = readAll(batchSizes, (reader, metrics) =>
+      new SliceBytesArrowOutputProcessorImpl(reader, metrics, maxBytes) {
+        override private[python] def batchBytes(root: VectorSchemaRoot): Long 
= {
+          measured += 1
+          super.batchBytes(root)
+        }
+      })
+    checkAllRowsOnceInOrder(batchSizes, output)
+    assert(output.forall { case (rows, bytes) => rows.size <= 1 || bytes <= 
maxBytes })
+    assert(output.size >= 50)
+    // The whole batch and the first search take a few measurements, and each 
later slice one,
+    // or a few more where its rows are larger. Restarting every search from 
all the remaining
+    // rows would take hundreds.
+    assert(measured <= 2 * output.size, s"$measured measurements for 
${output.size} slices")
+  }
+
+  /**
+   * Slices `batchSizes` by bytes, where a row whose `id` is i takes 
`rowBytes(i)` bytes, and
+   * returns the number of rows of every output batch and the number of size 
measurements.
+   */
+  private def sliceRowCountsAndMeasurements(batchSizes: Seq[Int], maxBytes: 
Int)(
+      rowBytes: Long => Long): (Seq[Int], Int) = {
+    var measured = 0
+    val output = readAll(batchSizes, (reader, metrics) =>
+      new SliceBytesArrowOutputProcessorImpl(reader, metrics, maxBytes) {
+        override private[python] def batchBytes(root: VectorSchemaRoot): Long 
= {
+          measured += 1
+          val ids = root.getVector("id").asInstanceOf[BigIntVector]
+          (0 until root.getRowCount).map(i => rowBytes(ids.get(i))).sum
+        }
+      })
+    checkAllRowsOnceInOrder(batchSizes, output)
+    (output.map(_._1.size), measured)
+  }
+
+  private def sliceRowCounts(batchSizes: Seq[Int], maxBytes: Int)(rowBytes: 
Long => Long)
+    : Seq[Int] = sliceRowCountsAndMeasurements(batchSizes, 
maxBytes)(rowBytes)._1
+
+  test("SPARK-60395: slice by bytes: number of rows of each slice") {
+    // The first search starts from the rows that would fit at the bytes per 
row of the root, and
+    // later searches from those of the last slice, here 10 rows, which fit 
exactly.
+    assert(sliceRowCounts(Seq(50), 1000)(_ => 100) === Seq.fill(5)(10))
+    // Row 0 alone is larger than the limit, which does not keep the other 
slices at one row. The
+    // first search goes 66, 59, 53, ..., 2, 1 rows, and the next search 
starts from 66 rows.
+    assert(sliceRowCounts(Seq(1000), 1000)(i => if (i == 0) 5000 else 10) ===
+      Seq(1, 66) ++ Seq.fill(9)(100) ++ Seq(33))
+    // Slices grow again after the large rows.
+    assert(sliceRowCounts(Seq(1000), 1000)(i => if (i < 10) 500 else 10) ===
+      Seq.fill(6)(2) ++ Seq.fill(9)(100) ++ Seq(88))
+    // Every row is larger than the limit.
+    assert(sliceRowCounts(Seq(3, 0, 2), 1000)(_ => 2000) === Seq(1, 1, 1, 0, 
1, 1))
+  }
+
+  test("SPARK-60395: slice by bytes: rows larger than the limit are measured 
once each") {
+    // 100 small rows make the search start from about 100 rows, then 900 rows 
are each larger
+    // than the limit. Searching from there for each of them would take about 
45 measurements.
+    val (rowCounts, measured) = sliceRowCountsAndMeasurements(Seq(1000), 1000) 
{ i =>
+      if (i < 100) 10 else 2000
+    }
+    assert(rowCounts.takeRight(900) === Seq.fill(900)(1))
+    assert(measured <= 2 * rowCounts.size, s"$measured measurements for 
$rowCounts")
+  }
+
+  test("SPARK-60395: slice by bytes: a batch that Arrow cannot slice is output 
whole") {
+    val batchSizes = Seq(10, 20)
+    val output = readAll(batchSizes, (reader, metrics) =>
+      new SliceBytesArrowOutputProcessorImpl(reader, metrics, 1) {
+        override private[python] def canSlice(root: VectorSchemaRoot): Boolean 
= false
+      })
+    checkAllRowsOnceInOrder(batchSizes, output)
+    assert(output.map(_._1.size) === batchSizes)
+  }
+
+  test("SPARK-60395: bufferSize matches getBufferSize of vectors below 2 GiB") 
{
+    val nestedSchema = new StructType()
+      .add("b", BooleanType)
+      .add("d", DecimalType(20, 2))
+      .add("bin", BinaryType)
+      .add("arr", ArrayType(new StructType().add("x", LongType).add("y", 
StringType)))
+      .add("m", MapType(StringType, ArrayType(IntegerType)))
+    val writer = ArrowWriter.create(nestedSchema, "UTC")
+    try {
+      (0 until 20).foreach { i =>
+        val arr = new GenericArrayData((0 until i % 4).map { j =>
+          InternalRow(j.toLong, if (j % 2 == 0) null else 
UTF8String.fromString(s"y_$j"))
+        })
+        val m = ArrayBasedMapData(
+          Array[Any](UTF8String.fromString(s"k_$i")),
+          Array[Any](new GenericArrayData((0 until i % 3).toArray[Any])))
+        writer.write(InternalRow(i % 2 == 0, Decimal(i * 100L, 20, 2),
+          Array.fill[Byte](i)(1), if (i % 5 == 0) null else arr, m))
+      }
+      writer.finish()
+      val root = writer.root
+      Seq((0, 20), (3, 7), (5, 1), (19, 1), (0, 13)).foreach { case (start, 
length) =>
+        val slice = root.slice(start, length)
+        try {
+          (slice.getFieldVectors.asScala ++ 
root.getFieldVectors.asScala).foreach { v =>
+            assert(SliceBytesArrowOutputProcessorImpl.bufferSize(v) === 
v.getBufferSize,
+              s"${v.getField} in slice ($start, $length)")
+          }
+        } finally {
+          slice.close()
+        }
+      }
+    } finally {
+      writer.root.close()
+    }
+  }
+
+  test("SPARK-60395: bufferSize does not wrap around for a struct vector over 
2 GiB") {
+    val allocator = ArrowUtils.rootAllocator.newChildAllocator("struct", 0, 
Long.MaxValue)
+    class TestStructVector extends StructVector(
+        "st", allocator, FieldType.nullable(ArrowType.Struct.INSTANCE), null) {
+      def addChild(name: String, child: FieldVector): Unit = putChild(name, 
child)
+    }
+    val struct = new TestStructVector
+    try {
+      // Children that report 1 GiB each, like three int64 columns of 2^27 
rows.
+      (0 until 3).foreach { i =>
+        struct.addChild(s"c$i", new NullVector(s"c$i") {
+          override def getBufferSize: Int = 1 << 30
+        })
+      }
+      // An int64 column of 2^28 rows, without allocating it, which the struct 
vector measures
+      // only if it measures its children in Long too.
+      struct.addChild("c3", new FixedSizeBinaryVector("c3", allocator, 8) {
+        override def getValueCount: Int = 1 << 28
+        override def setValueCount(valueCount: Int): Unit = {}
+      })
+      struct.setValueCount(1)
+      // `StructVector.getBufferSize` wraps around to a negative size.
+      assert(struct.getBufferSize < 0)
+      val expected = 3L * (1 << 30) + (1L << 31) + (1 << 25) + 1
+      assert(SliceBytesArrowOutputProcessorImpl.bufferSize(struct) === 
expected)
+      val root = new VectorSchemaRoot(Seq[FieldVector](struct).asJava)
+      assert(SliceBytesArrowOutputProcessorImpl.batchBytes(root) === expected)
+      // Arrow cannot slice the int64 child of 2 GiB, which only the check of 
the children sees.
+      assert(!BaseSliceArrowOutputProcessor.canSlice(struct))
+    } finally {
+      struct.close()
+      allocator.close()
+    }
+  }
+
+  test("SPARK-60395: bufferSize does not wrap around for flat vectors over 2 
GiB") {
+    val allocator = ArrowUtils.rootAllocator.newChildAllocator("flat", 0, 
Long.MaxValue)
+    // An int64 column of 2^28 rows, without allocating it.
+    val fixed = new FixedSizeBinaryVector("f", allocator, 8) {
+      override def getValueCount: Int = 1 << 28
+    }
+    val varChar = new VarCharVector("v", allocator)
+    val largeVarChar = new LargeVarCharVector("l", allocator)
+    try {
+      assert(SliceBytesArrowOutputProcessorImpl.bufferSize(fixed) === (1L << 
31) + (1 << 25))
+
+      // Variable-width vectors of 3 values whose end offset is then set to a 
large value.
+      varChar.allocateNew()
+      largeVarChar.allocateNew()
+      Seq("a", "bb", "ccc").zipWithIndex.foreach { case (str, i) =>
+        varChar.setSafe(i, str.getBytes("UTF-8"))
+        largeVarChar.setSafe(i, str.getBytes("UTF-8"))
+      }
+      Seq(varChar, largeVarChar).foreach { v =>
+        v.setValueCount(3)
+        assert(SliceBytesArrowOutputProcessorImpl.bufferSize(v) === 
v.getBufferSize)
+      }
+      varChar.getOffsetBuffer.setInt(3 * 4, Int.MaxValue - 10)
+      // `getBufferSize` wraps around to a negative size.
+      assert(varChar.getBufferSize < 0)
+      assert(SliceBytesArrowOutputProcessorImpl.bufferSize(varChar) ===
+        1 + 4 * 4 + (Int.MaxValue - 10L))
+      largeVarChar.getOffsetBuffer.setLong(3 * 8, 3L << 30)
+      // `getBufferSize` caps the size at Int.MaxValue.
+      assert(largeVarChar.getBufferSize === Int.MaxValue)
+      assert(SliceBytesArrowOutputProcessorImpl.bufferSize(largeVarChar) ===
+        1 + 4 * 8 + (3L << 30))
+    } finally {
+      fixed.close()
+      varChar.close()
+      largeVarChar.close()
+      allocator.close()
+    }
+  }
+
+  test("SPARK-60395: maxBytesPerOutputBatch below Int.MinValue means no 
limit") {
+    val conf = new SQLConf
+    Seq("-3g", "-4294967295", "-1", "0").foreach { value =>
+      
conf.setConfString(SQLConf.ARROW_EXECUTION_MAX_BYTES_PER_OUTPUT_BATCH.key, 
value)
+      assert(conf.arrowMaxBytesPerOutputBatch === 0, value)
+    }
+    conf.setConfString(SQLConf.ARROW_EXECUTION_MAX_BYTES_PER_OUTPUT_BATCH.key, 
"256m")
+    assert(conf.arrowMaxBytesPerOutputBatch === 256 * 1024 * 1024)
+  }
 }


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

Reply via email to