dongjoon-hyun commented on code in PR #59933:
URL: https://github.com/apache/spark/pull/59933#discussion_r4417897708
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -185,17 +193,26 @@ object ArrowBatchingOptions {
}
/**
- * Converts the rows of a write task to Arrow-backed [[ColumnarBatch]]es for
the [[DataWriter]]
- * of a [[ColumnarDataSource]]. A new Arrow vector schema root is created for
each batch, so a
- * native writer that took ownership of the data of a batch can keep it after
the call.
+ * Converts the data of a write task to Arrow-backed [[ColumnarBatch]]es for
the [[DataWriter]]
+ * of a [[ColumnarDataSource]]: its rows, or the columnar batches of a
columnar query. Each batch
+ * gets new Arrow vectors, so a native writer that took ownership of the data
of a batch can keep
+ * it after the call, while the producer of a columnar batch may reuse its
vectors once the call
+ * returns.
+ *
+ * Each column of a columnar batch that is an Arrow vector in the layout of
the Arrow schema of the
+ * write is copied buffer by buffer, and the other columns are converted value
by value. The
+ * batches end as those of rows do, though a batch with copied columns also
ends with the columnar
+ * batch. A columnar batch without columns to copy is converted as rows are.
*/
class ArrowBatchingDataWriter(delegate: DataWriter[ColumnarBatch], options:
ArrowBatchingOptions)
- extends DataWriter[InternalRow] {
+ extends ColumnarInputDataWriter {
+ import ArrowBatchingDataWriter.WindowColumn
private val arrowSchema = ArrowUtils.toArrowSchema(
options.schema, options.timeZoneId, errorOnDuplicatedFieldNames = true,
largeVarTypes = false)
private val allocator =
ArrowUtils.rootAllocator.newChildAllocator("columnar data source writer",
0, Long.MaxValue)
Review Comment:
**1. [Medium] Data kept past the close of a data writer is still charged to
the allocator that `close()` closes**
The new contract says that a data writer may take ownership of the buffers
of a batch to keep its data, and `spark_datasource.h` lets a library move an
input array to take ownership, with no bound on how long it keeps it. But every
buffer that the data writer gets, whether a copy, a converted vector or a batch
of rows, is allocated from this child allocator, which `close()` (L451-L458)
closes right after `delegate.close()`.
So a library that keeps the arrays past `close_data_writer`, for example an
in-memory sink that appends them to a table that outlives the task, leaves
buffers outstanding when the allocator closes:
- `allocator.close()` throws `IllegalStateException` ("Memory was leaked by
query"), which `Utils.tryLogNonFatalError` logs as an error, for every task.
- When the library later releases the array, each release of these buffers
starts with `allocator.assertOpen()` (`BufferLedger.decrement`, and
`BaseAllocator.dissociateLedger` for the last one; I checked this in the Arrow
19.0.0 classes). With Java assertions enabled, as in the Spark tests, that
throws `IllegalStateException` inside the release callback.
The test data writers release what they keep in `commit` or `close`, so the
suites do not hit this. Could the contract and the header say that the data
must be released by the time the data writer is closed? Or, if a library should
be able to keep it longer, could these buffers come from an allocator that
outlives the task, such as `ArrowUtils.rootAllocator`, so that closing the data
writer does not depend on them?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
+ val converted = copied.indices.map { i =>
+ if (copied(i).isEmpty) Some(convertColumn(batch, i, windowStart,
windowEnd)) else None
+ }
Review Comment:
**2. [Low] The converted vectors leak when a later column fails to convert**
`converted` is built by `copied.indices.map` before the `tryWithSafeFinally`
that closes it. So if `convertColumn` throws for a column, the vectors already
converted for the columns before it are never closed: `convertColumn` closes
only its own vector. It can throw on the data, for example
`ArrowColumnVector.getDecimal` when a value exceeds the declared precision, or
with an `OutOfMemoryException` from the allocation. `close()` then logs a leak,
and the memory stays charged to `ArrowUtils.rootAllocator`.
Building the columns inside the `tryWithSafeFinally`, and closing what was
converted so far, would avoid it, for example:
```scala
val converted = ArrayBuffer.empty[FieldVector]
Utils.tryWithSafeFinally {
val columns = copied.indices.map { i =>
copied(i) match {
case Some(vector) => WindowColumn(vector, 0, copied = true)
case None =>
val vector = convertColumn(batch, i, windowStart, windowEnd)
converted += vector
WindowColumn(vector, windowStart, copied = false)
}
}
// ... the loop over the batches of the window, as now
} {
converted.foreach(_.close())
}
```
This also removes the `internalError` case of L257-L258 (see item 8).
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
+ val converted = copied.indices.map { i =>
+ if (copied(i).isEmpty) Some(convertColumn(batch, i, windowStart,
windowEnd)) else None
+ }
+ Utils.tryWithSafeFinally {
+ val columns = copied.zip(converted).map {
+ case (Some(vector), _) => WindowColumn(vector, 0, copied = true)
+ case (_, Some(vector)) => WindowColumn(vector, windowStart, copied
= false)
+ case _ =>
+ throw SparkException.internalError("A column is neither copied
nor converted.")
+ }
+ var start = windowStart
+ while (start < windowEnd) {
+ val length = batchLength(columns, start, windowEnd)
+ writeWindow(columns, start, length)
+ start += length
+ }
+ } {
+ converted.flatten.foreach(_.close())
+ }
+ windowStart = windowEnd
+ }
+ }
+ }
+
+ /**
+ * Returns, for each column of a batch, its Arrow vector if it can be copied
buffer by buffer: an
+ * Arrow vector with as many values as the batch has rows, whose field is
physically congruent
+ * with the one of `arrowSchema`, though it may differ in the names,
nullability and metadata of
+ * its fields, and in the time zones of timestamps, which the copy keeps.
The other columns are
+ * converted value by value.
+ */
+ private def copiedVectors(batch: ColumnarBatch): Seq[Option[FieldVector]] = {
+ val fields = arrowSchema.getFields.asScala
+ if (batch.numCols() != fields.length) {
+ Seq.fill(fields.length)(None)
+ } else {
+ fields.indices.map { i =>
+ batch.column(i) match {
+ case column: ArrowColumnVector => column.getValueVector match {
+ case vector: FieldVector if vector.getValueCount ==
batch.numRows() &&
+ ArrowUtils.isCompatibleWithDeclaredField(vector.getField,
fields(i)) =>
+ Some(vector)
+ case _ => None
+ }
+ case _ => None
+ }
+ }
+ }
+ }
+
+ /** Converts the values of the rows [start, end) of a column into a vector
of `arrowSchema`. */
+ private def convertColumn(
+ batch: ColumnarBatch,
+ ordinal: Int,
+ start: Int,
+ end: Int): FieldVector = {
+ val vector = arrowSchema.getFields.get(ordinal).createVector(allocator)
+ try {
+ vector.setInitialCapacity(end - start)
+ vector.allocateNew()
+ val fieldWriter = ArrowWriter.createFieldWriter(vector)
+ var i = start
+ while (i < end) {
+ fieldWriter.write(batch.getRow(i), ordinal)
+ i += 1
+ }
+ fieldWriter.finish()
+ vector
+ } catch {
+ case e: Throwable =>
+ vector.close()
+ throw e
+ }
+ }
+
+ /**
+ * Returns the number of rows of the batch that starts at the row `start` of
the columns: up to
+ * `end`, or as a batch of rows ends, up to the first row with which it
holds `maxBytesPerBatch`
+ * bytes, by the measure of `getBufferSizeFor`, which `rangeSize` computes
without slicing.
+ *
+ */
+ private def batchLength(columns: Seq[WindowColumn], start: Int, end: Int):
Int = {
+ // The size is not computed when the limit is the largest possible one, as
for rows.
+ if (options.maxBytesPerBatch >= Int.MaxValue) {
+ end - start
+ } else {
+ def sizeInBytes(rows: Int): Long = columns.map { column =>
+ ArrowBatchingDataWriter.rangeSize(
+ column.vector, start - column.first, start - column.first + rows)
+ }.sum
+ // The size grows with the number of rows.
+ var lo = 1
+ var hi = end - start
+ while (lo < hi) {
+ val mid = (lo + hi) >>> 1
+ if (sizeInBytes(mid) >= options.maxBytesPerBatch) hi = mid else lo =
mid + 1
+ }
+ lo
+ }
+ }
+
+ /**
+ * Writes the rows [start, start + length) of the columns. A copied column
is sliced, which shares
+ * the buffers of the batch, and its buffers copied once each at their size.
Arrow Java reads the
+ * offsets of a vector of variable width, or of a list, as if they started
at 0, which those of a
+ * slice do. A converted column, whose vector nothing else writes to, is
only sliced.
+ */
+ private def writeWindow(columns: Seq[WindowColumn], start: Int, length:
Int): Unit = {
+ val copiedColumns = columns.filter(_.copied).map(_.vector)
+ // The vectors of the batch, which this root does not own: their producer
releases them.
+ val batchRoot = new VectorSchemaRoot(copiedColumns.map(_.getField).asJava,
+ copiedColumns.asJava,
copiedColumns.headOption.map(_.getValueCount).getOrElse(0))
+ val written = ArrayBuffer.empty[AutoCloseable]
+ Utils.tryWithSafeFinally {
+ val copies = if (copiedColumns.isEmpty) {
+ Iterator.empty
+ } else {
+ val slice = batchRoot.slice(start, length)
+ val copy = Utils.tryWithSafeFinally(copyRoot(slice))(slice.close())
+ written += copy
+ copy.getFieldVectors.asScala.iterator
+ }
+ val vectors = columns.map { column =>
+ if (column.copied) {
+ copies.next()
+ } else {
+ val pair = column.vector.getTransferPair(column.vector.getAllocator)
+ pair.splitAndTransfer(start - column.first, length)
+ val slice = pair.getTo.asInstanceOf[FieldVector]
+ written += slice
+ slice
+ }
+ }
+ delegate.write(new ColumnarBatch(
+ vectors.map(new ArrowColumnVector(_): ColumnVector).toArray, length))
+ } {
+ written.foreach(_.close())
+ }
+ }
+
+ /** Copies the buffers of the vectors of a root into new vectors, once each,
at their size. */
+ private def copyRoot(source: VectorSchemaRoot): VectorSchemaRoot = {
+ val copy = VectorSchemaRoot.create(source.getSchema, allocator)
+ try {
+ val unloaded = new VectorUnloader(source).getRecordBatch
try {
- delegate.write(new ColumnarBatch(columns.toArray, root.getRowCount))
+ val buffers = unloaded.getBuffers.asScala.map { buffer =>
+ val size = buffer.readableBytes()
+ val copied = allocator.buffer(size)
+ copied.setBytes(0, buffer, buffer.readerIndex(), size)
+ copied.writerIndex(size)
+ copied
+ }
Review Comment:
**3. [Low] `copyRoot` leaks the buffers already copied when an allocation
fails**
The `map` that copies the buffers runs before the `try` whose `finally`
closes `buffers`. If `allocator.buffer(size)` throws for one buffer, for
example with an `OutOfMemoryException` while copying a wide batch, `buffers` is
never assigned and the copies made before it stay allocated in the child
allocator, which `close()` then reports as leaked.
Collecting the copies into a buffer inside the `try` would release them on
any failure:
```scala
val buffers = ArrayBuffer.empty[ArrowBuf]
try {
unloaded.getBuffers.asScala.foreach { buffer =>
val size = buffer.readableBytes()
val copied = allocator.buffer(size)
buffers += copied
copied.setBytes(0, buffer, buffer.readerIndex(), size)
copied.writerIndex(size)
}
// ... the ArrowRecordBatch and the VectorLoader, as now
} finally {
buffers.foreach(_.close())
}
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
Review Comment:
**4. [Low] The ends of the windows also end the batches cut by the byte
limit**
The windows are fixed at multiples of `maxRecordsPerBatch` from the start of
the columnar batch. When `maxBytesPerBatch` ends a batch inside a window, the
next batch can only go to the end of that window, not to `start +
maxRecordsPerBatch` as a batch of rows can. For example, with
`maxRecordsPerBatch` at 3 and rows that reach `maxBytesPerBatch` every 2 rows:
- rows give batches of 2, 2, 2, ...
- a copied columnar batch gives 2, 1, 2, 1, ..., since the windows [0, 3)
and [3, 6) are written as [0, 2), [2, 3), [3, 5) and [5, 6).
That is half as many batches again, and half of them are half the size. The
class doc (L204-L205) says that the batches end as those of rows do, except at
the end of the columnar batch. Could a window start where the previous batch
ended, or could the doc say that the windows end the batches too?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
+ val converted = copied.indices.map { i =>
+ if (copied(i).isEmpty) Some(convertColumn(batch, i, windowStart,
windowEnd)) else None
+ }
+ Utils.tryWithSafeFinally {
+ val columns = copied.zip(converted).map {
+ case (Some(vector), _) => WindowColumn(vector, 0, copied = true)
+ case (_, Some(vector)) => WindowColumn(vector, windowStart, copied
= false)
+ case _ =>
+ throw SparkException.internalError("A column is neither copied
nor converted.")
+ }
+ var start = windowStart
+ while (start < windowEnd) {
+ val length = batchLength(columns, start, windowEnd)
+ writeWindow(columns, start, length)
+ start += length
+ }
+ } {
+ converted.flatten.foreach(_.close())
+ }
+ windowStart = windowEnd
+ }
+ }
+ }
+
+ /**
+ * Returns, for each column of a batch, its Arrow vector if it can be copied
buffer by buffer: an
+ * Arrow vector with as many values as the batch has rows, whose field is
physically congruent
+ * with the one of `arrowSchema`, though it may differ in the names,
nullability and metadata of
+ * its fields, and in the time zones of timestamps, which the copy keeps.
The other columns are
+ * converted value by value.
+ */
+ private def copiedVectors(batch: ColumnarBatch): Seq[Option[FieldVector]] = {
+ val fields = arrowSchema.getFields.asScala
+ if (batch.numCols() != fields.length) {
+ Seq.fill(fields.length)(None)
+ } else {
+ fields.indices.map { i =>
+ batch.column(i) match {
+ case column: ArrowColumnVector => column.getValueVector match {
+ case vector: FieldVector if vector.getValueCount ==
batch.numRows() &&
+ ArrowUtils.isCompatibleWithDeclaredField(vector.getField,
fields(i)) =>
+ Some(vector)
+ case _ => None
+ }
+ case _ => None
+ }
+ }
+ }
+ }
+
+ /** Converts the values of the rows [start, end) of a column into a vector
of `arrowSchema`. */
+ private def convertColumn(
+ batch: ColumnarBatch,
+ ordinal: Int,
+ start: Int,
+ end: Int): FieldVector = {
+ val vector = arrowSchema.getFields.get(ordinal).createVector(allocator)
+ try {
+ vector.setInitialCapacity(end - start)
+ vector.allocateNew()
+ val fieldWriter = ArrowWriter.createFieldWriter(vector)
+ var i = start
+ while (i < end) {
+ fieldWriter.write(batch.getRow(i), ordinal)
+ i += 1
+ }
+ fieldWriter.finish()
+ vector
+ } catch {
+ case e: Throwable =>
+ vector.close()
+ throw e
+ }
+ }
+
+ /**
+ * Returns the number of rows of the batch that starts at the row `start` of
the columns: up to
+ * `end`, or as a batch of rows ends, up to the first row with which it
holds `maxBytesPerBatch`
+ * bytes, by the measure of `getBufferSizeFor`, which `rangeSize` computes
without slicing.
+ *
+ */
+ private def batchLength(columns: Seq[WindowColumn], start: Int, end: Int):
Int = {
+ // The size is not computed when the limit is the largest possible one, as
for rows.
+ if (options.maxBytesPerBatch >= Int.MaxValue) {
+ end - start
+ } else {
+ def sizeInBytes(rows: Int): Long = columns.map { column =>
+ ArrowBatchingDataWriter.rangeSize(
+ column.vector, start - column.first, start - column.first + rows)
+ }.sum
+ // The size grows with the number of rows.
+ var lo = 1
+ var hi = end - start
+ while (lo < hi) {
+ val mid = (lo + hi) >>> 1
+ if (sizeInBytes(mid) >= options.maxBytesPerBatch) hi = mid else lo =
mid + 1
+ }
+ lo
+ }
+ }
+
+ /**
+ * Writes the rows [start, start + length) of the columns. A copied column
is sliced, which shares
+ * the buffers of the batch, and its buffers copied once each at their size.
Arrow Java reads the
+ * offsets of a vector of variable width, or of a list, as if they started
at 0, which those of a
+ * slice do. A converted column, whose vector nothing else writes to, is
only sliced.
+ */
+ private def writeWindow(columns: Seq[WindowColumn], start: Int, length:
Int): Unit = {
+ val copiedColumns = columns.filter(_.copied).map(_.vector)
+ // The vectors of the batch, which this root does not own: their producer
releases them.
+ val batchRoot = new VectorSchemaRoot(copiedColumns.map(_.getField).asJava,
+ copiedColumns.asJava,
copiedColumns.headOption.map(_.getValueCount).getOrElse(0))
+ val written = ArrayBuffer.empty[AutoCloseable]
+ Utils.tryWithSafeFinally {
+ val copies = if (copiedColumns.isEmpty) {
+ Iterator.empty
+ } else {
+ val slice = batchRoot.slice(start, length)
+ val copy = Utils.tryWithSafeFinally(copyRoot(slice))(slice.close())
Review Comment:
**10. [Nit] The slice allocates some buffers that the copy then copies
again**
This is about the change for
[5](https://github.com/apache/spark/pull/59933#discussion_r4417361029).
`batchRoot.slice(start, length)` shares most buffers, but it allocates and
writes new offsets for every list, and for every string or binary column whose
first offset is not 0, and new validity bits when `start` is not a multiple of
8. `copyRoot` then copies these new buffers again, and it also copies the
validity buffers that `VectorLoader` replaces when a column has no nulls or
only nulls. So the comment at L352-L353, that the buffers are copied once each,
holds only for the buffers that the slice shares.
The cost is small next to the data, so updating the comment may be enough.
Otherwise, the buffers that the slice allocated could be moved into the copy
instead of copied.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
Review Comment:
**7. [Nit] An empty batch whose columns could be copied ends the pending
batch of rows**
`flush()` runs even when `numBatchRows` is 0, so a batch without rows, whose
columns are congruent Arrow vectors, writes the pending rows as a smaller batch
although nothing is copied after them. For example, on-heap batches of 2 rows
with an empty Arrow batch between them give batches of 2 rows instead of up to
`maxRecordsPerBatch`. Returning early from `writeBatch` when `numBatchRows` is
0 would avoid it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
+ val converted = copied.indices.map { i =>
+ if (copied(i).isEmpty) Some(convertColumn(batch, i, windowStart,
windowEnd)) else None
+ }
+ Utils.tryWithSafeFinally {
+ val columns = copied.zip(converted).map {
+ case (Some(vector), _) => WindowColumn(vector, 0, copied = true)
+ case (_, Some(vector)) => WindowColumn(vector, windowStart, copied
= false)
+ case _ =>
+ throw SparkException.internalError("A column is neither copied
nor converted.")
+ }
+ var start = windowStart
+ while (start < windowEnd) {
+ val length = batchLength(columns, start, windowEnd)
+ writeWindow(columns, start, length)
+ start += length
+ }
+ } {
+ converted.flatten.foreach(_.close())
+ }
+ windowStart = windowEnd
+ }
+ }
+ }
+
+ /**
+ * Returns, for each column of a batch, its Arrow vector if it can be copied
buffer by buffer: an
+ * Arrow vector with as many values as the batch has rows, whose field is
physically congruent
+ * with the one of `arrowSchema`, though it may differ in the names,
nullability and metadata of
+ * its fields, and in the time zones of timestamps, which the copy keeps.
The other columns are
+ * converted value by value.
+ */
+ private def copiedVectors(batch: ColumnarBatch): Seq[Option[FieldVector]] = {
+ val fields = arrowSchema.getFields.asScala
+ if (batch.numCols() != fields.length) {
+ Seq.fill(fields.length)(None)
+ } else {
+ fields.indices.map { i =>
+ batch.column(i) match {
+ case column: ArrowColumnVector => column.getValueVector match {
+ case vector: FieldVector if vector.getValueCount ==
batch.numRows() &&
+ ArrowUtils.isCompatibleWithDeclaredField(vector.getField,
fields(i)) =>
+ Some(vector)
Review Comment:
**5. [Low] A copied column skips the checks that a converted value goes
through**
A copied column forwards the buffers as they are, while the same values on
the conversion path go through `ArrowColumnVector` and `ArrowWriter`, which
check their domain: `getDecimal` throws when a value exceeds the declared
precision, `DecimalWriter` writes a null when `changePrecision` fails, and the
nanosecond timestamps check `DATETIME_OVERFLOW`. Spark does not check these
values in the arrays that a library returns.
So for the same data, whether a write fails depends on the plan. If a native
reader returns a `DECIMAL(10, 2)` array that holds 10^12, `INSERT INTO sink
SELECT * FROM source` copies it into the sink, while the same query with a
`.repartition(2)` (an adaptive plan, so rows) fails with a precision error. The
doc of `ArrowUtils.isCompatibleWithDeclaredField` also says that the row-based
path is where these checks are reinstated.
If this is intended, could the contract say that the values of copied
columns are not checked? Otherwise, the decimal columns could be checked, or
converted.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarDataSourceSuite.scala:
##########
@@ -404,31 +765,95 @@ object TestColumnarSink {
case class TestCommitMessage(rows: Seq[(Long, String)], batchSizes: Seq[Int])
extends WriterCommitMessage
-class TestDataWriter(fail: Boolean) extends DataWriter[ColumnarBatch] {
+/**
+ * @param keep whether to keep the data of each batch until the commit, as a
native data writer
+ * can, by taking ownership of the Arrow buffers of the batch
+ */
+class TestDataWriter(fail: Boolean, keep: Boolean = false) extends
DataWriter[ColumnarBatch] {
private val rows = ArrayBuffer.empty[(Long, String)]
private val batchSizes = ArrayBuffer.empty[Int]
+ private val kept = ArrayBuffer.empty[TestArrowExport]
override def write(batch: ColumnarBatch): Unit = {
if (fail) {
throw new IllegalStateException("injected failure")
}
assert((0 until
batch.numCols()).forall(batch.column(_).isInstanceOf[ArrowColumnVector]))
batchSizes += batch.numRows()
+ if (keep) {
+ kept += TestArrowExport(batch)
+ } else {
+ read(batch)
+ }
+ }
+
+ private def read(batch: ColumnarBatch): Unit = {
(0 until batch.numRows()).foreach { i =>
rows += ((batch.column(0).getLong(i),
batch.column(1).getUTF8String(i).toString))
}
}
- override def commit(): WriterCommitMessage = TestCommitMessage(rows.toSeq,
batchSizes.toSeq)
+ override def commit(): WriterCommitMessage = {
+ kept.foreach(_.read(read))
+ kept.clear()
+ TestCommitMessage(rows.toSeq, batchSizes.toSeq)
+ }
override def abort(): Unit = {}
- override def close(): Unit = {}
+ override def close(): Unit = {
+ kept.foreach(_.close())
+ kept.clear()
+ }
+}
+
+/** A batch exported to the Arrow C data interface, which keeps its data until
it is read. */
+class TestArrowExport(array: ArrowArray, schema: ArrowSchema) {
+ private var closed = false
+
+ /** Imports the batch, and passes it to `f`. */
+ def read[T](f: ColumnarBatch => T): T = {
+ val root = Data.importVectorSchemaRoot(ArrowUtils.rootAllocator, array,
schema, null)
+ // The import moved the data to the root, and closed the structs.
+ closed = true
Review Comment:
**11. [Nit] After a failed import, `close()` throws and hides the error of
the import**
This is about the change for
[10](https://github.com/apache/spark/pull/59933#discussion_r4417361062).
`Data.importVectorSchemaRoot` releases and closes the `ArrowSchema` in a
`finally` (`Data.importField`), and then moves and closes the `ArrowArray`
before it imports its buffers. So when the import throws, one or both structs
are already closed, but `closed` stays false, and `close()` calls `release()`
on a closed struct, which throws "ArrowSchema is already closed" (or the same
for the `ArrowArray`). Since `close()` runs from the `close()` of the data
writer in a `finally`, that exception replaces the error of the import in the
test report.
Importing the schema and the array in two steps, `Data.importSchema` and
then `Data.importIntoVectorSchemaRoot`, and marking each struct as closed once
the step that consumes it is called, would let `close()` release only the
struct that is still open, and keep the original error.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarDataSourceSuite.scala:
##########
@@ -128,6 +140,244 @@ class ColumnarDataSourceSuite extends QueryTest with
SharedSparkSession {
}
}
+ test("write the columnar batches of a query") {
+ // Runs a write, and returns the plan of the query that it writes.
+ def writtenPlan(write: => Unit): SparkPlan = {
+ QueryTest.withQueryExecutionsCaptured(spark)(write).flatMap { qe =>
+ qe.executedPlan.collectFirst { case writeExec: V2TableWriteExec =>
writeExec.query }
+ }.head
+ }
+ // Writes the ids [0, 10) and their names, read in Arrow batches of
`arrowBatchSize` rows, or in
+ // a batch of on-heap vectors for each of the two partitions of the scan.
+ def copy(table: String, arrowBatchSize: Int = 0, keep: Boolean = false):
SparkPlan = {
+ writtenPlan {
+ spark.read.format(format).option("end", "10").option("arrowBatchSize",
arrowBatchSize)
+ .load()
+ .write.format(format).option("table", table).option("keep",
keep).mode("append").save()
+ }
+ }
+ val expected = (0L until 10L).map(id => (id, s"name$id"))
+
+ withSQLConf(SQLConf.ARROW_EXECUTION_MAX_RECORDS_PER_BATCH.key -> "3") {
+ // The data writers get the batches of the scan, without a transition to
rows. On-heap
+ // vectors are converted to Arrow value by value, into batches that end
as those of rows do.
+ assert(copy("columnar_write").isInstanceOf[BatchScanExec])
+ assert(TestColumnarSink.rows("columnar_write") == expected)
+ assert(TestColumnarSink.batchSizes("columnar_write") == Seq(3, 2, 3, 2))
+
+ // Arrow batches are copied buffer by buffer, each in batches of at most
3 rows.
+ assert(copy("arrow_write", arrowBatchSize =
2).isInstanceOf[BatchScanExec])
+ assert(TestColumnarSink.rows("arrow_write") == expected)
+ assert(TestColumnarSink.batchSizes("arrow_write") == Seq(2, 2, 1, 2, 2,
1))
+ copy("large_arrow_write", arrowBatchSize = 5)
+ assert(TestColumnarSink.rows("large_arrow_write") == expected)
+ assert(TestColumnarSink.batchSizes("large_arrow_write") == Seq(3, 2, 3,
2))
+
+ // A data writer may keep the data of the batches that it gets, though
the scan overwrites
+ // its Arrow vectors with each batch.
+ copy("kept_arrow_write", arrowBatchSize = 2, keep = true)
+ assert(TestColumnarSink.rows("kept_arrow_write") == expected)
+
+ // A query that does not produce columnar batches is written as rows.
+ val plan = writtenPlan {
+ spark.read.format(format).option("end", "10").load()
+ .selectExpr("id", "upper(name) AS name")
+ .write.format(format).option("table",
"row_write").mode("append").save()
+ }
+ assert(plan.exists(_.isInstanceOf[ColumnarToRowExec]), plan)
+
+ // With adaptive query execution, a query with a shuffle is an adaptive
plan, which is not
+ // columnar, so its batches are converted to rows.
+ val shuffled = writtenPlan {
+ spark.read.format(format).option("end", "10").option("arrowBatchSize",
2).load()
+ .repartition(2)
+ .write.format(format).option("table",
"shuffled_write").mode("append").save()
+ }
+ assert(shuffled.isInstanceOf[AdaptiveSparkPlanExec], shuffled)
+ assert(find(shuffled)(_.isInstanceOf[ColumnarToRowExec]).isDefined,
shuffled)
+ assert(TestColumnarSink.rows("shuffled_write").sorted == expected)
+ assert(TestColumnarSink.rows("row_write") == expected.map { case (id,
name) =>
+ (id, name.toUpperCase(Locale.ROOT))
+ })
+ }
+ }
+
+ test("copy the Arrow columns of every type, or convert the columns of a
batch") {
+ val nullable = Seq(
+ "id % 2 = 0", "CAST(id AS TINYINT)", "CAST(id AS SMALLINT)", "CAST(id AS
INT)", "id",
+ "CAST(id AS FLOAT)", "CAST(id AS DOUBLE)", "CAST(id AS DECIMAL(10, 2))",
+ "CAST(id AS DECIMAL(38, 10))", "CAST(id AS STRING)", "CAST(CAST(id AS
STRING) AS BINARY)",
+ "DATE_ADD(DATE'2020-01-01', CAST(id AS INT))", "TIMESTAMP_SECONDS(id)",
+ "CAST(TIMESTAMP_SECONDS(id) AS TIMESTAMP_NTZ)",
"MAKE_YM_INTERVAL(CAST(id AS INT), 1)",
+ "MAKE_DT_INTERVAL(CAST(id AS INT))", "MAKE_INTERVAL(0, 1, 0, CAST(id AS
INT))",
+ "PARSE_JSON(CAST(id AS STRING))", "ARRAY(id, NULL)", "ARRAY(CAST(id AS
STRING))",
+ "MAP(CAST(id AS STRING), id)", "MAP(id, ARRAY(CAST(id AS STRING)))",
+ "NAMED_STRUCT('a', id, 'b', CAST(id AS STRING))",
"ARRAY(NAMED_STRUCT('x', id))")
+ // c0 is not nullable, c1 has the null type, and the other columns have
nulls.
+ val df = spark.range(7).selectExpr(
+ Seq("id AS c0", "NULL AS c1") ++ nullable.zipWithIndex.map { case (expr,
i) =>
+ s"IF(id % 3 = 1, NULL, $expr) AS c${i + 2}"
+ }: _*)
+ val schema = df.schema
+ val rows = df.queryExecution.toRdd.map(_.copy()).collect().toSeq
+ val allocator = ArrowUtils.rootAllocator.newChildAllocator("test", 0,
Long.MaxValue)
+ try {
+ // Writes a batch with the session time zone America/Los_Angeles, and
returns the sizes of
+ // the batches that the data writer got, the time zone of their
timestamp column c14, which
+ // a copy keeps, the Arrow types of their string column c11, and their
rows. The data writer
+ // keeps the data of the batches, and reads it once `overwrite`
overwrote the batch.
+ def writeAndRead(
+ batch: ColumnarBatch,
+ overwrite: () => Unit,
+ schema: StructType = schema,
+ maxRecordsPerBatch: Int = 10,
+ maxBytesPerBatch: Long = Int.MaxValue)
+ : (Seq[Int], Seq[String], Seq[ArrowType], Seq[InternalRow]) = {
+ val sizes = ArrayBuffer.empty[Int]
+ val timeZones = ArrayBuffer.empty[String]
+ val stringTypes = ArrayBuffer.empty[ArrowType]
+ val kept = ArrayBuffer.empty[TestArrowExport]
+ def field(batch: ColumnarBatch, name: String) =
batch.column(schema.fieldIndex(name))
+ .asInstanceOf[ArrowColumnVector].getValueVector.getField
+ val dataWriter = new DataWriter[ColumnarBatch] {
+ override def write(batch: ColumnarBatch): Unit = {
+ sizes += batch.numRows()
+ timeZones += field(batch,
"c14").getType.asInstanceOf[ArrowType.Timestamp].getTimezone
+ stringTypes += field(batch, "c11").getType
+ kept += TestArrowExport(batch)
+ }
+ override def commit(): WriterCommitMessage = null
+ override def abort(): Unit = {}
+ override def close(): Unit = kept.foreach(_.close())
+ }
+ val writer = new ArrowBatchingDataWriter(dataWriter,
ArrowBatchingOptions(
+ schema, "America/Los_Angeles", maxRecordsPerBatch, maxBytesPerBatch))
+ try {
+ writer.writeBatch(batch)
+ writer.commit()
+ overwrite()
+ val toUnsafe = UnsafeProjection.create(schema)
+ val written = kept.toSeq.flatMap { exported =>
+
exported.read(_.rowIterator().asScala.map(toUnsafe(_).copy()).toSeq)
+ }
+ kept.clear()
+ (sizes.toSeq, timeZones.toSeq, stringTypes.toSeq, written)
+ } finally {
+ writer.close()
+ }
+ }
+
+ // Runs `f` with a batch of Arrow vectors with the time zone UTC, and a
function that
+ // overwrites the vectors with the rows in reverse order.
+ def withArrowBatch(largeVarTypes: Boolean)(f: (ColumnarBatch, () =>
Unit) => Unit): Unit = {
+ val root = VectorSchemaRoot.create(
+ ArrowUtils.toArrowSchema(
+ schema, "UTC", errorOnDuplicatedFieldNames = true, largeVarTypes =
largeVarTypes),
+ allocator)
+ try {
+ val arrowWriter = ArrowWriter.create(root)
+ rows.foreach(arrowWriter.write)
+ arrowWriter.finish()
+ val batch = new ColumnarBatch(
+ root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector).toArray,
+ rows.length)
+ f(batch, () => {
+ arrowWriter.reset()
+ rows.reverse.foreach(arrowWriter.write)
+ arrowWriter.finish()
+ })
+ } finally {
+ root.close()
+ }
+ }
+
+ val expected = rows.map(UnsafeProjection.create(schema)(_).copy())
+ val utf8 = ArrowType.Utf8.INSTANCE
+ Seq(false, true).foreach { largeVarTypes =>
+ withArrowBatch(largeVarTypes) { (batch, _) =>
+ // The size of a range of rows, which the batches of a copy end at,
is the size of a slice
+ // of these rows.
+ (0 until batch.numCols()).foreach { i =>
+ val vector =
batch.column(i).asInstanceOf[ArrowColumnVector].getValueVector
+ Seq((0, 7), (1, 4), (3, 7), (6, 7), (2, 2)).foreach { case (start,
end) =>
+ val pair = vector.getTransferPair(vector.getAllocator)
+ pair.splitAndTransfer(start, end - start)
+ val sliceSize =
+ try pair.getTo.getBufferSizeFor(end - start) finally
pair.getTo.close()
+ assert(ArrowBatchingDataWriter.rangeSize(vector, start, end) ==
sliceSize,
+ s"${vector.getField} [$start, $end)")
+ }
+ }
+ }
+ }
+ withArrowBatch(largeVarTypes = false) { (batch, overwrite) =>
+ // The columns are Arrow vectors with the types of the schema, so they
are copied.
+ assert(writeAndRead(batch, overwrite) == ((Seq(7), Seq("UTC"),
Seq(utf8), expected)))
+ }
+ withArrowBatch(largeVarTypes = false) { (batch, overwrite) =>
+ // In slices of at most 3 rows, which start in the middle of the bytes
of validity.
+ assert(writeAndRead(batch, overwrite, maxRecordsPerBatch = 3) ==
+ ((Seq(3, 3, 1), Seq.fill(3)("UTC"), Seq.fill(3)(utf8), expected)))
+ }
+ withArrowBatch(largeVarTypes = false) { (batch, overwrite) =>
+ // A copied batch also ends after the first row with which it holds
the maximum size.
+ assert(writeAndRead(batch, overwrite, maxBytesPerBatch = 1) ==
+ ((Seq.fill(7)(1), Seq.fill(7)("UTC"), Seq.fill(7)(utf8), expected)))
Review Comment:
**6. [Low] The byte limit of a copied batch is only tested at 1 byte**
With `maxBytesPerBatch = 1`, every search of `batchLength` returns 1, since
`lo` starts at 1. So the tests do not check where a batch ends when the limit
is reached in the middle of a window, nor that it ends where a batch of rows
would. For example, comparing with `>` instead of `>=` at L345 of
`ColumnarWrite.scala` still passes every test.
A case whose limit is the size of a few rows, which `rangeSize` can compute
for the columns of the batch, compared with the batch sizes that the same rows
get on the row path (an on-heap batch, as at the end of this test), would cover
it. With a `maxRecordsPerBatch` that is not a multiple of that number of rows,
it would also show the difference of item 4.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
+ val converted = copied.indices.map { i =>
+ if (copied(i).isEmpty) Some(convertColumn(batch, i, windowStart,
windowEnd)) else None
+ }
+ Utils.tryWithSafeFinally {
+ val columns = copied.zip(converted).map {
+ case (Some(vector), _) => WindowColumn(vector, 0, copied = true)
+ case (_, Some(vector)) => WindowColumn(vector, windowStart, copied
= false)
+ case _ =>
+ throw SparkException.internalError("A column is neither copied
nor converted.")
+ }
+ var start = windowStart
+ while (start < windowEnd) {
+ val length = batchLength(columns, start, windowEnd)
+ writeWindow(columns, start, length)
+ start += length
+ }
+ } {
+ converted.flatten.foreach(_.close())
+ }
+ windowStart = windowEnd
+ }
+ }
+ }
+
+ /**
+ * Returns, for each column of a batch, its Arrow vector if it can be copied
buffer by buffer: an
+ * Arrow vector with as many values as the batch has rows, whose field is
physically congruent
+ * with the one of `arrowSchema`, though it may differ in the names,
nullability and metadata of
+ * its fields, and in the time zones of timestamps, which the copy keeps.
The other columns are
+ * converted value by value.
+ */
+ private def copiedVectors(batch: ColumnarBatch): Seq[Option[FieldVector]] = {
+ val fields = arrowSchema.getFields.asScala
+ if (batch.numCols() != fields.length) {
+ Seq.fill(fields.length)(None)
+ } else {
+ fields.indices.map { i =>
+ batch.column(i) match {
+ case column: ArrowColumnVector => column.getValueVector match {
+ case vector: FieldVector if vector.getValueCount ==
batch.numRows() &&
+ ArrowUtils.isCompatibleWithDeclaredField(vector.getField,
fields(i)) =>
+ Some(vector)
+ case _ => None
+ }
+ case _ => None
+ }
+ }
+ }
+ }
+
+ /** Converts the values of the rows [start, end) of a column into a vector
of `arrowSchema`. */
+ private def convertColumn(
+ batch: ColumnarBatch,
+ ordinal: Int,
+ start: Int,
+ end: Int): FieldVector = {
+ val vector = arrowSchema.getFields.get(ordinal).createVector(allocator)
+ try {
+ vector.setInitialCapacity(end - start)
+ vector.allocateNew()
+ val fieldWriter = ArrowWriter.createFieldWriter(vector)
+ var i = start
+ while (i < end) {
+ fieldWriter.write(batch.getRow(i), ordinal)
+ i += 1
+ }
+ fieldWriter.finish()
+ vector
+ } catch {
+ case e: Throwable =>
+ vector.close()
+ throw e
+ }
+ }
+
+ /**
+ * Returns the number of rows of the batch that starts at the row `start` of
the columns: up to
+ * `end`, or as a batch of rows ends, up to the first row with which it
holds `maxBytesPerBatch`
+ * bytes, by the measure of `getBufferSizeFor`, which `rangeSize` computes
without slicing.
+ *
+ */
+ private def batchLength(columns: Seq[WindowColumn], start: Int, end: Int):
Int = {
+ // The size is not computed when the limit is the largest possible one, as
for rows.
+ if (options.maxBytesPerBatch >= Int.MaxValue) {
+ end - start
+ } else {
+ def sizeInBytes(rows: Int): Long = columns.map { column =>
+ ArrowBatchingDataWriter.rangeSize(
+ column.vector, start - column.first, start - column.first + rows)
+ }.sum
+ // The size grows with the number of rows.
+ var lo = 1
+ var hi = end - start
+ while (lo < hi) {
+ val mid = (lo + hi) >>> 1
+ if (sizeInBytes(mid) >= options.maxBytesPerBatch) hi = mid else lo =
mid + 1
+ }
+ lo
Review Comment:
**9. [Nit] The search runs even when the whole window fits**
With the default `maxBytesPerBatch` of 64MB, most windows are smaller than
the limit, yet each batch runs the whole binary search, about 14 probes over
all the columns for the 10000 rows of the default `maxRecordsPerBatch`, before
it returns `end - start`. Checking the whole range first would take one probe
in that case:
```scala
if (sizeInBytes(end - start) < options.maxBytesPerBatch) {
end - start
} else {
// the search, as now
}
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
+ val converted = copied.indices.map { i =>
+ if (copied(i).isEmpty) Some(convertColumn(batch, i, windowStart,
windowEnd)) else None
+ }
+ Utils.tryWithSafeFinally {
+ val columns = copied.zip(converted).map {
+ case (Some(vector), _) => WindowColumn(vector, 0, copied = true)
+ case (_, Some(vector)) => WindowColumn(vector, windowStart, copied
= false)
+ case _ =>
+ throw SparkException.internalError("A column is neither copied
nor converted.")
+ }
+ var start = windowStart
+ while (start < windowEnd) {
+ val length = batchLength(columns, start, windowEnd)
+ writeWindow(columns, start, length)
+ start += length
+ }
+ } {
+ converted.flatten.foreach(_.close())
+ }
+ windowStart = windowEnd
+ }
+ }
+ }
+
+ /**
+ * Returns, for each column of a batch, its Arrow vector if it can be copied
buffer by buffer: an
+ * Arrow vector with as many values as the batch has rows, whose field is
physically congruent
+ * with the one of `arrowSchema`, though it may differ in the names,
nullability and metadata of
+ * its fields, and in the time zones of timestamps, which the copy keeps.
The other columns are
+ * converted value by value.
+ */
+ private def copiedVectors(batch: ColumnarBatch): Seq[Option[FieldVector]] = {
+ val fields = arrowSchema.getFields.asScala
+ if (batch.numCols() != fields.length) {
+ Seq.fill(fields.length)(None)
+ } else {
+ fields.indices.map { i =>
+ batch.column(i) match {
+ case column: ArrowColumnVector => column.getValueVector match {
+ case vector: FieldVector if vector.getValueCount ==
batch.numRows() &&
+ ArrowUtils.isCompatibleWithDeclaredField(vector.getField,
fields(i)) =>
+ Some(vector)
+ case _ => None
+ }
+ case _ => None
+ }
+ }
+ }
+ }
+
+ /** Converts the values of the rows [start, end) of a column into a vector
of `arrowSchema`. */
+ private def convertColumn(
+ batch: ColumnarBatch,
+ ordinal: Int,
+ start: Int,
+ end: Int): FieldVector = {
+ val vector = arrowSchema.getFields.get(ordinal).createVector(allocator)
+ try {
+ vector.setInitialCapacity(end - start)
+ vector.allocateNew()
+ val fieldWriter = ArrowWriter.createFieldWriter(vector)
+ var i = start
+ while (i < end) {
+ fieldWriter.write(batch.getRow(i), ordinal)
+ i += 1
+ }
+ fieldWriter.finish()
+ vector
+ } catch {
+ case e: Throwable =>
+ vector.close()
+ throw e
+ }
+ }
+
+ /**
+ * Returns the number of rows of the batch that starts at the row `start` of
the columns: up to
+ * `end`, or as a batch of rows ends, up to the first row with which it
holds `maxBytesPerBatch`
+ * bytes, by the measure of `getBufferSizeFor`, which `rangeSize` computes
without slicing.
+ *
+ */
+ private def batchLength(columns: Seq[WindowColumn], start: Int, end: Int):
Int = {
+ // The size is not computed when the limit is the largest possible one, as
for rows.
+ if (options.maxBytesPerBatch >= Int.MaxValue) {
+ end - start
+ } else {
+ def sizeInBytes(rows: Int): Long = columns.map { column =>
+ ArrowBatchingDataWriter.rangeSize(
+ column.vector, start - column.first, start - column.first + rows)
+ }.sum
+ // The size grows with the number of rows.
+ var lo = 1
+ var hi = end - start
+ while (lo < hi) {
+ val mid = (lo + hi) >>> 1
+ if (sizeInBytes(mid) >= options.maxBytesPerBatch) hi = mid else lo =
mid + 1
+ }
+ lo
+ }
+ }
+
+ /**
+ * Writes the rows [start, start + length) of the columns. A copied column
is sliced, which shares
+ * the buffers of the batch, and its buffers copied once each at their size.
Arrow Java reads the
+ * offsets of a vector of variable width, or of a list, as if they started
at 0, which those of a
+ * slice do. A converted column, whose vector nothing else writes to, is
only sliced.
+ */
+ private def writeWindow(columns: Seq[WindowColumn], start: Int, length:
Int): Unit = {
+ val copiedColumns = columns.filter(_.copied).map(_.vector)
+ // The vectors of the batch, which this root does not own: their producer
releases them.
+ val batchRoot = new VectorSchemaRoot(copiedColumns.map(_.getField).asJava,
+ copiedColumns.asJava,
copiedColumns.headOption.map(_.getValueCount).getOrElse(0))
+ val written = ArrayBuffer.empty[AutoCloseable]
+ Utils.tryWithSafeFinally {
+ val copies = if (copiedColumns.isEmpty) {
+ Iterator.empty
+ } else {
Review Comment:
**8. [Nit] The branch without copied columns, and the column that is neither
copied nor converted, cannot happen**
`writeBatch` calls `writeWindow` only when at least one column is copied, so
`copiedColumns` is never empty here, and this branch with `Iterator.empty`
never runs. Similarly, `copied` and `converted` are complementary, so the
`internalError` case of L257-L258 is unreachable. Building the columns in one
pass, as in the example of item 2, would remove the second, and the first could
become a plain `val copy = ...`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +231,201 @@ class ArrowBatchingDataWriter(delegate:
DataWriter[ColumnarBatch], options: Arro
}
}
- private def flush(): Unit = {
- if (root != null) {
- arrowWriter.finish()
- val columns = root.getFieldVectors.asScala.map(new ArrowColumnVector(_):
ColumnVector)
+ override def writeBatch(batch: ColumnarBatch): Unit = {
+ val numBatchRows = batch.numRows()
+ val copied = copiedVectors(batch)
+ if (copied.forall(_.isEmpty)) {
+ // Without columns to copy, the values are converted as rows are.
+ var i = 0
+ while (i < numBatchRows) {
+ write(batch.getRow(i))
+ i += 1
+ }
+ } else {
+ flush()
+ // A window of the rows of the batch, whose columns that are not copied
are converted at once.
+ var windowStart = 0
+ while (windowStart < numBatchRows) {
+ val windowEnd = math.min(numBatchRows, windowStart +
options.maxRecordsPerBatch)
+ val converted = copied.indices.map { i =>
+ if (copied(i).isEmpty) Some(convertColumn(batch, i, windowStart,
windowEnd)) else None
+ }
+ Utils.tryWithSafeFinally {
+ val columns = copied.zip(converted).map {
+ case (Some(vector), _) => WindowColumn(vector, 0, copied = true)
+ case (_, Some(vector)) => WindowColumn(vector, windowStart, copied
= false)
+ case _ =>
+ throw SparkException.internalError("A column is neither copied
nor converted.")
+ }
+ var start = windowStart
+ while (start < windowEnd) {
+ val length = batchLength(columns, start, windowEnd)
+ writeWindow(columns, start, length)
+ start += length
+ }
+ } {
+ converted.flatten.foreach(_.close())
+ }
+ windowStart = windowEnd
+ }
+ }
+ }
+
+ /**
+ * Returns, for each column of a batch, its Arrow vector if it can be copied
buffer by buffer: an
+ * Arrow vector with as many values as the batch has rows, whose field is
physically congruent
+ * with the one of `arrowSchema`, though it may differ in the names,
nullability and metadata of
+ * its fields, and in the time zones of timestamps, which the copy keeps.
The other columns are
+ * converted value by value.
+ */
+ private def copiedVectors(batch: ColumnarBatch): Seq[Option[FieldVector]] = {
+ val fields = arrowSchema.getFields.asScala
+ if (batch.numCols() != fields.length) {
+ Seq.fill(fields.length)(None)
+ } else {
+ fields.indices.map { i =>
+ batch.column(i) match {
+ case column: ArrowColumnVector => column.getValueVector match {
+ case vector: FieldVector if vector.getValueCount ==
batch.numRows() &&
+ ArrowUtils.isCompatibleWithDeclaredField(vector.getField,
fields(i)) =>
+ Some(vector)
+ case _ => None
+ }
+ case _ => None
+ }
+ }
+ }
+ }
+
+ /** Converts the values of the rows [start, end) of a column into a vector
of `arrowSchema`. */
+ private def convertColumn(
+ batch: ColumnarBatch,
+ ordinal: Int,
+ start: Int,
+ end: Int): FieldVector = {
+ val vector = arrowSchema.getFields.get(ordinal).createVector(allocator)
+ try {
+ vector.setInitialCapacity(end - start)
+ vector.allocateNew()
+ val fieldWriter = ArrowWriter.createFieldWriter(vector)
+ var i = start
+ while (i < end) {
+ fieldWriter.write(batch.getRow(i), ordinal)
+ i += 1
+ }
+ fieldWriter.finish()
+ vector
+ } catch {
+ case e: Throwable =>
+ vector.close()
+ throw e
+ }
+ }
+
+ /**
+ * Returns the number of rows of the batch that starts at the row `start` of
the columns: up to
+ * `end`, or as a batch of rows ends, up to the first row with which it
holds `maxBytesPerBatch`
+ * bytes, by the measure of `getBufferSizeFor`, which `rangeSize` computes
without slicing.
+ *
+ */
Review Comment:
**12. [Nit] An empty line ends the doc of `batchLength`**
```suggestion
*/
```
--
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]