feat: drive Spark's file writers from Arrow batches, including partitioned and bucketed writes [experiment] - #5632
Draft
andygrove wants to merge 2 commits into
Draft
Conversation
…rites Spark's write path is typed on InternalRow throughout and its writers read each row and encode it before asking for the next one, so the UnsafeRow that the columnar-to-row transition materializes is a copy the writer only undoes again. A partitioned or bucketed write pays for it twice: BaseDynamicPartitionDataWriter projects every row a second time through getOutputRow purely to strip the partition and bucket columns. Replace WriteFilesExec with a Comet node that drives Spark's own OutputWriter from child.executeColumnar(). Pruning the partition columns off a ColumnarBatch costs nothing, so CometRowViewDynamicPartitionWriter overrides only writeRecord and inherits partition-change detection, writer renewal and maxRecordsPerFile unchanged. Everything above the per-task write stays with Spark, because the node extends WriteFilesExecBase and V1WritesUtils.getWriteFilesOpt finds it. Gated on Spark 4.0+, on maxConcurrentOutputFileWriters being 0 so that the concurrent writer's UnsafeRow spill path is never reached, on one of Spark's own FileFormats, and on a complex type among the data columns. Behind spark.comet.exec.write.rowView.enabled, default false. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmHrzzDLtwh1nLHZUxTVKm
… writes CometWriteRowViewBenchmark measures three arms (Spark, Comet writing via UnsafeRow, Comet writing via row view) across flat, nested and deeply nested schemas, unpartitioned and partitioned, at two compression settings. Each arm asserts through a QueryExecutionListener that it planned the write node its name claims and warns into the results file when it does not, so a silently declined arm cannot masquerade as a measurement. 1M rows, M3 Ultra, timing the whole scan -> write -> file pipeline. Gain is the row-view arm against the UnsafeRow arm, which differ only in the write node (uncompressed / snappy): flat 10-column unpartitioned 0.2% / 0.6% <- declined, noise floor nested unpartitioned 20.4% / 13.3% deeply nested unpartitioned 12.3% / 13.3% flat 10-column partitioned 3.7% / 4.5% nested partitioned 16.3% / 16.1% deeply nested partitioned 16.5% / 16.6% The flat partitioned row prompted a gate change. A partitioned or bucketed write removes two projections per row, the columnar-to-row transition and BaseDynamicPartitionDataWriter.getOutputRow, so it is worth doing whatever the schema; only an unpartitioned write, which removes just the transition, still needs a complex data column to clear the noise floor. Require the complex type only in that case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmHrzzDLtwh1nLHZUxTVKm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. #5626implements it that way. This PR is an alternative that lifts the restriction.
Draft / RFC. Off by default. Opening as a draft to get the approach and the writer-internals
coupling in front of people, not because anything is unfinished.
Rationale for this change
The restriction in #5625 is not arbitrary. A partitioned or bucketed write never reaches
SingleDirectoryDataWriter; it goes throughBaseDynamicPartitionDataWriter, which projectsevery row a second time before the
OutputWritersees it: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/UnsafeArrayDatain the columnar-to-rowtransition, followed by a cheap second projection that hits
GenerateUnsafeProjection'sinstanceof UnsafeRow/UnsafeArrayData/UnsafeMapDatabulk-copy branches(
GenerateUnsafeProjection.scala:72,193,227). Feeding the writer aColumnarBatchRowwould deletethe 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
ColumnarBatchthe same pruning isnew ColumnarBatch(subsetOfVectors, numRows), which copies nothing. Getting that requiresreplacing the writer, not the transition.
Spark 4.0 provides the seam.
V1WritesUtils.getWriteFilesOptmatches theWriteFilesExecBasetrait, so a Comet node extending it is driven through
FileFormatWriter.executeWrite->SparkPlan.executeWrite->doExecuteWrite, and everythingabove the per-task write stays with Spark: SaveMode, the commit protocol,
_SUCCESS, dynamicpartition 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).
Measurements
CometWriteRowViewBenchmark, added here. 1M rows, Spark 4.1, release build, M3 Ultra, timing thewhole
scan -> write -> filepipeline. The gain compares the two Comet arms, which run the sameplan and differ only in the write node (uncompressed / snappy):
The flat unpartitioned row is the noise floor: the rule declines it, so both Comet arms run the
identical plan there.
Two caveats on reading this. The unpartitioned nested case moved 15.2% -> 20.4% between two runs
while every other case held within a point, so treat it as "roughly 13-20%" rather than 20%; the
partitioned numbers reproduced tightly across both runs. And this does not touch what actually
makes a write expensive, which is parquet-mr encoding. Only the native writer changes that. What it
does is make the interim path cheaper while native writes remain experimental, and unlike the
native writer it stays Spark-compatible by construction because Spark's own writer still produces
the bytes.
Relationship to #5293 and #5626
refactor: hook native Parquet writes into Spark's WriteFilesExec seam) puts aCometWriteFilesExecat this seam that writes Parquet with the native Rust writer, currentlyunpartitioned only. This PR reuses that PR's
ShimCometWriteFilesExectraits verbatim, sowhichever lands second is a trivial rebase. The node here is named
CometRowViewWriteFilesExecto avoid a file-level collision.CometColumnarToRowViewExectransition plantedunder the write. If this PR is the direction people prefer, feat: experimental zero-copy row views for writes of complex-typed data [experiment] #5626 should be closed in favour of
it: this one subsumes it, covering the unpartitioned case too, with a stronger safety argument
and larger measured gains.
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 whoseonly consumer is Spark's
OutputWriter. ExtendingWriteFilesExecBaseis also what stops AQE fromre-inserting a second
WriteFilesExecabove the node, which is the hazard #5293 documents.What changes are included in this PR?
CometRowViewWriteFilesExecreplacesWriteFilesExecand drives Spark's own writers fromchild.executeColumnar(). ItsexecuteTaskis a direct port ofFileFormatWriter.executeTask,differing in two places:
CometRowViewDynamicPartitionWriter, a subclass ofDynamicPartitionDataSingleWriterthat overrides onlywriteRecordto substitute a pruned viewfor
getOutputRow(record). Partition-change detection, writer renewal andmaxRecordsPerFileare inherited unchanged and still see the full row.
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, sothe pair costs one object per batch rather than a copy per row.
The unpartitioned case needs no subclass:
SingleDirectoryDataWriterhands the row straight to theOutputWriter, so the batch row goes in as-is.EliminateRedundantTransitionsplants the node, gated on:getWriteFilesOptmatches the concreteWriteFilesExeccase class,so a replacement node is invisible and the write would silently take a path that calls
doExecuteon it. Same reasoning and same shim as refactor: hook native Parquet writes into Spark's WriteFilesExec seam #5293.spark.sql.maxConcurrentOutputFileWritersat its default of 0. Above 0,V1WritesUtils.getSortOrderplants no sort andFileFormatWriterpicksDynamicPartitionDataConcurrentWriter, which spills throughUnsafeKVExternalSorter. This gateis load-bearing in both directions: it keeps that writer out, and it is what guarantees
DynamicPartitionDataSingleWritergets the sorted input it requires.FileFormats, whoseOutputWriters encode each row on the spot. Athird-party format is free to buffer the
InternalRowit is handed.write removes. A partitioned or bucketed write removes two, the transition and
getOutputRow, soit qualifies at any schema: the flat partitioned row in the table above is 3.7-4.5% against a
0.2-0.6% noise floor. An unpartitioned write removes only the transition, and on a flat schema
that is a generated fixed-width copy which measures inside the noise, so it still needs a struct,
array or map to be worth putting a reused mutable row in front of Spark's writer. Partition and
bucket columns never count towards that test; they are stripped before the
OutputWriterseesthe row.
No check for an intervening
SortExecis needed. The rule only rewritesw.child, so a writewhose required ordering was satisfied by a Spark
SortExecrather than a Comet one simply does notmatch. This is worth stating because #5625 and #5626 both cite
UnsafeExternalSorteras a reasonto 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 onisSpark40Plus. The bar for each is that enabling the config changes nothing observable but theplan, with the baseline written by the same Comet plan with the config off:
a flat partitioned schema; deeply nested types (four levels, nulls at every level) with
partitions; null and empty-string partition values, which exercise
__HIVE_DEFAULT_PARTITION__and the
Empty2Nullprojection;maxRecordsPerFileset and unset across partition boundaries;ORC as well as Parquet
bucketed writes, absent by default, absent for a flat unpartitioned schema, and absent when
maxConcurrentOutputFileWritersis raisedRan alongside
CometParquetWriterSuite: 48 tests pass on Spark 4.1.CometExecSuitepasses as aregression sweep (144 tests). Compiles against Spark 3.4, 3.5, 4.0 and 4.1.
CometWriteRowViewBenchmarkasserts through aQueryExecutionListenerthat each arm planned thewrite node its label names, and warns into the results file when it did not, so a silently declined
arm cannot masquerade as a measurement. The runs behind the table above produced no such warnings.
Caveats I would want a second opinion on
writeRecord,currentWriter,statsTrackersandrecordsInFileareprotectedmembers of Spark's writer, and this depends on their current structure across4.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.
WriteTaskStatsTracker.newRownow receives a view ratherthan a materialized row. That is correct for
BasicWriteTaskStatsTracker, which ignores it, andstrictly better than refactor: hook native Parquet writes into Spark's WriteFilesExec seam #5293's native path, which passes
InternalRow.empty. A third-partytracker that retains rows would still be wrong. There is a format gate but no tracker gate;
statsTrackersonly exists at execution time.crude here. A defensive option would be to restrict it to Parquet and ORC by class.