dongjoon-hyun commented on code in PR #59933:
URL: https://github.com/apache/spark/pull/59933#discussion_r4418472608


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/Columnar.scala:
##########
@@ -589,6 +590,9 @@ case class ApplyColumnarRulesAndInsertTransitions(
         case write: DataWritingCommandExec
             if write.cmd.isInstanceOf[V1WriteCommand] && 
conf.plannedWriteEnabled =>
           write.child.supportsColumnar
+        // A V2 write whose data writers take columnar batches writes those of 
a columnar query.
+        case write: V2TableWriteExec if write.supportsColumnarInput =>
+          write.query.supportsColumnar

Review Comment:
   **1. [Medium] A cached DataFrame is now read as batches, which are kept 
until the end of the task**
   
   This case makes the query columnar whenever it `supportsColumnar`, also when 
it supports rows too. `InMemoryTableScanExec` supports both, and SPARK-37369 
made Spark read it as rows when its parent takes rows. Its batches are never 
Arrow vectors, so nothing is copied, and the columnar path only adds work and 
memory.
   
   For example, with numeric columns only, which 
`DefaultCachedBatchSerializer.supportsColumnarOutput` accepts, and a cached 
plan without a shuffle, so that adaptive query execution does not apply:
   
   ```scala
   val df = spark.read.parquet(path).select("id", "value").cache()
   df.count()
   df.write.format("native_sink").mode("append").save()
   ```
   
   The query of the write is the `InMemoryTableScanExec`, so `writeWithV2` 
calls `executeColumnar()`:
   
   - Before this PR, the scan ran as rows, through 
`convertCachedBatchToInternalRow`, without column vectors.
   - Now `convertCachedBatchToColumnarBatch` decompresses each cached batch 
into new `OnHeapColumnVector`s (`OffHeapColumnVector`s with 
`spark.sql.columnVector.offheap.enabled`), and registers a task completion 
listener that closes them. `closeIfFreeable` in `runColumnar` is a no-op for 
these vectors, so every decompressed batch of the partition stays reachable 
until the task ends.
   - `writeBatch` then converts these batches value by value, as it does for 
any batch that is not Arrow.
   
   Could this case keep rows for a query that also supports them 
(`write.query.supportsColumnar && !write.query.supportsRowBased`), with the 
same condition in `writeWithV2`? A test that writes a cached DataFrame would 
cover it.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/columnar/ColumnarWrite.scala:
##########
@@ -214,15 +232,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:
   **2. [Low] Small columnar batches are no longer merged into batches of 
`maxRecordsPerBatch` rows**
   
   A columnar batch with a copied column first writes the pending rows, and the 
batches cut from it end with it, as the doc at L205 says. Before this PR, 
`ColumnarToRowExec` and `ArrowWriter` merged the rows of small columnar batches 
into batches of up to `maxRecordsPerBatch` rows. So the size of the batches 
that a library gets now follows the batch size of the source:
   
   - A native source that returns batches of 500 rows, with the default 
`maxRecordsPerBatch` of 10000, now gives 20 times as many calls of `write`, 
each with its own export through the C data interface.
   - A micro-batch whose source returns batches of a few rows gives a call of 
`write` for every few rows.
   
   A sink that turns each batch into a unit, such as a row group, a page or an 
IPC record batch, then writes much smaller units. The header only promises an 
upper bound, so this stays within it, but the PR description says that the 
batches end at `maxRecordsPerBatch` rows or `maxBytesPerBatch` bytes "as the 
header promises", and the user doc does not mention smaller batches either. 
Could a small copied batch be appended to the pending batch, or be converted as 
rows are, until the batch reaches the limits? If the current behavior is 
intended, could the user doc say that a library may get smaller batches when 
Spark copies them?



##########
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)

Review Comment:
   **4. [Nit] The test depends on the defaults of adaptive query execution**
   
   This assertion holds only with `spark.sql.adaptive.enabled=true`, and the 
assertions that the plain copies are a `BatchScanExec` (L164, L169) only with 
`spark.sql.adaptive.forceApply=false`, but the `withSQLConf` at L161 sets only 
`maxRecordsPerBatch`. With adaptive query execution disabled, `shuffled` is a 
`ShuffleExchangeExec`, and with `forceApply`, the copies are adaptive plans. 
Setting both in the `withSQLConf` would make the test independent of the 
defaults:
   
   ```scala
       withSQLConf(
           SQLConf.ARROW_EXECUTION_MAX_RECORDS_PER_BATCH.key -> "3",
           SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
           SQLConf.ADAPTIVE_EXECUTION_FORCE_APPLY.key -> "false") {
   ```



##########
docs/sql-native-data-sources.md:
##########
@@ -37,7 +37,12 @@ package, and loads it when a query uses the data source. 
Spark adapts it to
 
 Data is exchanged only as columnar batches, through the
 [Arrow C data 
interface](https://arrow.apache.org/docs/format/CDataInterface.html), so Spark 
reads
-the data that a library returns without copying it.
+the data that a library returns without copying it. When a write gets the 
batches of a scan as
+they are, as in a plain copy from one native data source to another, Spark 
writes them without
+converting them to rows: it copies the buffers of their columns, since a 
library may keep the data
+that it writes, and converts value by value the columns of other Arrow types 
than those of the
+schema, such as `utf8_view` strings. With adaptive query execution, the data 
of a query with a
+shuffle or a subquery is still converted to rows.

Review Comment:
   **3. [Nit] A query with a shuffle is converted to rows with or without 
adaptive query execution**
   
   This is about the change for 
[7](https://github.com/apache/spark/pull/59933#discussion_r4417361040). The 
sentence reads as if adaptive query execution caused the conversion of a query 
with a shuffle. But `ShuffleExchangeExec` is not columnar, so Spark also 
inserts `ColumnarToRow` before the shuffle when adaptive query execution is 
disabled, and the write gets rows. What adaptive query execution adds is that 
the adaptive plan as a whole is not columnar. The PR description has the same 
framing ("Under adaptive query execution, ... the batches only reach the write 
when the query has no shuffle"). For example:
   
   ```suggestion
   schema, such as `utf8_view` strings. The data of a query in which Spark also 
filters, projects or
   shuffles the rows is still converted to rows, and with adaptive query 
execution, so is the data of
   a query with a subquery.
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/WriteToDataSourceV2Exec.scala:
##########
@@ -701,18 +729,49 @@ trait WritingSparkTask[W <: DataWriter[InternalRow]] 
extends Logging with Serial
       iter: Iterator[InternalRow],
       useCommitCoordinator: Boolean,
       customMetrics: Map[String, SQLMetric]): DataWritingSparkTaskResult = {
+    val dataWriter =
+      writerFactory.createWriter(context.partitionId(), 
context.taskAttemptId()).asInstanceOf[W]
+
+    val iterWithMetrics = IteratorWithMetrics(iter, dataWriter, customMetrics)
+
+    writeAndCommit(dataWriter, context, useCommitCoordinator, customMetrics) {
+      write(dataWriter, iterWithMetrics)
+      iterWithMetrics.count
+    }
+  }
+
+  /**
+   * Writes the columnar batches of a query, for a write whose data writers 
take them (see
+   * [[SupportsColumnarInput]]). Only the task that writes the rows as they 
are does.
+   */
+  def runColumnar(
+      writerFactory: DataWriterFactory,
+      context: TaskContext,
+      iter: Iterator[ColumnarBatch],
+      useCommitCoordinator: Boolean,
+      customMetrics: Map[String, SQLMetric]): DataWritingSparkTaskResult = {
+    throw SparkException.internalError(s"${getClass.getName} does not write 
columnar batches.")
+  }
+
+  /**
+   * Writes the data of the task with `write`, which returns the number of 
rows that it wrote, and
+   * commits the data writer, once the output commit coordinator authorizes it 
if
+   * `useCommitCoordinator` is true. Aborts the data writer if either fails, 
and closes it.
+   */
+  protected def writeAndCommit(
+      dataWriter: DataWriter[_],
+      context: TaskContext,
+      useCommitCoordinator: Boolean,
+      customMetrics: Map[String, SQLMetric])(write: => Long): 
DataWritingSparkTaskResult = {

Review Comment:
   **5. [Nit] The block of `writeAndCommit` has the name of the method `write`**
   
   The by-name parameter `write` has the name of the abstract method 
`write(writer, iter)` of this trait, so `val numRows = write` at L774 evaluates 
the block, while the block that `run` passes calls the method. It works, but a 
reader can take L774 for a call of the method, and an edit of `writeAndCommit` 
that means to call the method gets a confusing compile error. Another name, 
such as `writeData`, would avoid this, with `val numRows = writeData` at L774:
   
   ```suggestion
         customMetrics: Map[String, SQLMetric])(writeData: => Long): 
DataWritingSparkTaskResult = {
   ```



-- 
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]

Reply via email to