andygrove opened a new pull request, #5632:
URL: https://github.com/apache/datafusion-comet/pull/5632
## Which issue does this PR close?
Part of #5625. That issue proposes handing Spark's writers a zero-copy row
view instead of a
materialized `UnsafeRow`, and restricts the idea to unpartitioned,
unbucketed writes. #5626
implements it that way. This PR is an alternative that lifts the restriction.
**Draft / RFC.** Off by default. The implementation is complete and tested,
but there are no
measurements yet, and the measurements are what should decide whether this
lands at all. See
"What is still owed" at the bottom.
## Rationale for this change
The restriction in #5625 is not arbitrary. A partitioned or bucketed write
never reaches
`SingleDirectoryDataWriter`; it goes through
`BaseDynamicPartitionDataWriter`, which projects
every row a second time before the `OutputWriter` sees it:
```scala
protected val getOutputRow =
UnsafeProjection.create(description.dataColumns, description.allColumns)
// :266
protected def writeRecord(record: InternalRow): Unit = {
val outputRow = getOutputRow(record) // strips partition / bucket columns
currentWriter.write(outputRow)
...
}
```
So simply planting a row view under the write does not help a partitioned
write. Today the cost is
a slow field-by-field build of nested `UnsafeRow` / `UnsafeArrayData` in the
columnar-to-row
transition, followed by a cheap second projection that hits
`GenerateUnsafeProjection`'s
`instanceof UnsafeRow` / `UnsafeArrayData` / `UnsafeMapData` bulk-copy
branches
(`GenerateUnsafeProjection.scala:72,193,227`). Feeding the writer a
`ColumnarBatchRow` would delete
the cheap pass and leave the expensive one, because those branches stop
matching.
The projection exists for one reason: to drop the partition and bucket
columns. Spark needs a full
row copy to do that because it is row-at-a-time. On a `ColumnarBatch` the
same pruning is
`new ColumnarBatch(subsetOfVectors, numRows)`, which copies nothing. Getting
that requires
replacing the writer, not the transition.
Spark 4.0 provides the seam. `V1WritesUtils.getWriteFilesOpt` matches the
`WriteFilesExecBase`
trait, so a Comet node extending it is driven through
`FileFormatWriter.executeWrite` -> `SparkPlan.executeWrite` ->
`doExecuteWrite`, and everything
above the per-task write stays with Spark: SaveMode, the commit protocol,
`_SUCCESS`, dynamic
partition overwrite, stats tracker aggregation, catalog updates. This is the
same seam #5293 opens
for native writes, and the two are consistent by design (see below).
## Relationship to #5293 and #5626
- **#5293** (`refactor: hook native Parquet writes into Spark's
WriteFilesExec seam`) puts a
`CometWriteFilesExec` at this seam that writes Parquet with the **native
Rust** writer, currently
unpartitioned only. This PR reuses that PR's `ShimCometWriteFilesExec`
traits **verbatim**, so
whichever lands second is a trivial rebase. The node here is named
`CometRowViewWriteFilesExec` to avoid a file-level collision.
- **#5626** implements #5625 the other way, as a
`CometColumnarToRowViewExec` transition planted
under the write. If this PR is the direction people prefer, #5626 should
be closed in favour of
it, because this one subsumes it: it covers the unpartitioned case too,
and with a stronger
safety argument.
On that argument: #5626's main caveat is that a reused mutable row becomes
an operator's output
and could reach a consumer that retains it. Here the reused rows never leave
`CometRowViewWriteFilesExec.doExecuteWrite`; they are created and consumed
inside one method whose
only consumer is Spark's `OutputWriter`. Extending `WriteFilesExecBase` is
also what stops AQE from
re-inserting a second `WriteFilesExec` above the node, which is the hazard
#5293 documents.
## What changes are included in this PR?
`CometRowViewWriteFilesExec` replaces `WriteFilesExec` and drives Spark's
own writers from
`child.executeColumnar()`. Its `executeTask` is a direct port of
`FileFormatWriter.executeTask`,
differing in two places:
- the partitioned and bucketed case builds a
`CometRowViewDynamicPartitionWriter`, a subclass of
`DynamicPartitionDataSingleWriter` that overrides only `writeRecord` to
substitute a pruned view
for `getOutputRow(record)`. Partition-change detection, writer renewal and
`maxRecordsPerFile`
are inherited unchanged and still see the full row.
- rows come from two iterators advanced in lockstep over the same batch: one
over all columns,
which Spark's writer uses to compute partition values and bucket ids, and
one over the pruned
batch, which is what reaches the `OutputWriter`. Both are views over the
same Arrow vectors, so
the pair costs one object per batch rather than a copy per row.
The unpartitioned case needs no subclass: `SingleDirectoryDataWriter` hands
the row straight to the
`OutputWriter`, so the batch row goes in as-is.
`EliminateRedundantTransitions` plants the node, gated on:
- **Spark 4.0+.** On 3.4 / 3.5 `getWriteFilesOpt` matches the concrete
`WriteFilesExec` case class,
so a replacement node is invisible and the write would silently take a
path that calls
`doExecute` on it. Same reasoning and same shim as #5293.
- **`spark.sql.maxConcurrentOutputFileWriters` at its default of 0.** Above
0,
`V1WritesUtils.getSortOrder` plants no sort and `FileFormatWriter` picks
`DynamicPartitionDataConcurrentWriter`, which spills through
`UnsafeKVExternalSorter`. This gate
is load-bearing in both directions: it keeps that writer out, and it is
what guarantees
`DynamicPartitionDataSingleWriter` gets the sorted input it requires.
- **one of Spark's own `FileFormat`s**, whose `OutputWriter`s encode each
row on the spot. A
third-party format is free to buffer the `InternalRow` it is handed.
- **a complex type among the data columns.** Partition and bucket columns do
not count; they never
reach the `OutputWriter`. On flat schemas the projection this removes is a
generated fixed-width
copy that #5625 measured inside the noise floor.
No check for an intervening `SortExec` is needed. The rule only rewrites
`w.child`, so a write
whose required ordering was satisfied by a Spark `SortExec` rather than a
Comet one simply does not
match. This is worth stating because #5625 and #5626 both cite
`UnsafeExternalSorter` as a reason
to exclude partitioned writes; that exclusion was already enforced
structurally.
Behind `spark.comet.exec.write.rowView.enabled`, default false.
## How are these changes tested?
New `CometWriteRowViewSuite`, 15 tests, registered in both PR build
workflows and gated on
`isSpark40Plus`. The bar for each is that enabling the config changes
nothing observable but the
plan, with the baseline written by the same Comet plan with the config off:
- same data and row count for: unpartitioned; one dynamic partition column;
two partition columns;
deeply nested types (four levels, nulls at every level) with partitions;
null and empty-string
partition values, which exercise `__HIVE_DEFAULT_PARTITION__` and the
`Empty2Null` projection;
`maxRecordsPerFile` set and unset across partition boundaries; ORC as well
as Parquet
- identical partition directory layout
- the node is present exactly once for unpartitioned, dynamically
partitioned and bucketed writes,
absent by default, absent for a flat schema, absent when the only complex
column is the partition
column, and absent when `maxConcurrentOutputFileWriters` is raised
Ran alongside `CometParquetWriterSuite`: 48 tests pass on Spark 4.1.
`CometExecSuite` passes as a
regression sweep. Compiles against Spark 3.4, 3.5, 4.0 and 4.1.
## What is still owed
1. **Measurements.** There are none yet, and they are the whole question.
The reasoning above says
a partitioned write should now save two projections per row rather than
the one #5626 saves, but
reasoning is not a number. A partitioned arm in
`CometParquetWriteBenchmark` (complex schema,
low-cardinality partition column, pre-sorted input) is the next step, and
this should not land
without it. It is also possible that the complex-type gate should be
relaxed for partitioned
writes, since `getOutputRow` is removed there even on flat schemas.
2. **The coupling.** `writeRecord`, `currentWriter`, `statsTrackers` and
`recordsInFile` are
`protected` members of Spark's writer, and this depends on their current
structure across
4.0 / 4.1 / 4.2. A future refactor there would be a compile error rather
than silent corruption,
which is the good case, but the dependency is real and worth a second
opinion.
3. `WriteTaskStatsTracker.newRow` now receives a reused row rather than a
materialized one. That is
correct for `BasicWriteTaskStatsTracker`, which ignores it, and it is
strictly better than
#5293's native path, which passes `InternalRow.empty`. A third-party
tracker that retains rows
would still be wrong. There is a format gate but no tracker gate;
`statsTrackers` only exists at
execution time.
--
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]