feat: experimental zero-copy row views for writes of complex-typed data [experiment] - #5626
feat: experimental zero-copy row views for writes of complex-typed data [experiment]#5626andygrove wants to merge 3 commits into
Conversation
Spark's file write path is typed on InternalRow throughout and never needs an UnsafeRow: OutputWriter.write, FileFormatDataWriter.write and WriteTaskStatsTracker.newRow all take InternalRow, ParquetWriteSupport reads fields through SpecializedGetters, and BasicWriteTaskStatsTracker.newRow ignores the row. So the UnsafeProjection in CometColumnarToRowExec builds a row that the writer immediately decodes again. Add CometColumnarToRowViewExec, which hands the writer ColumnarBatch.rowIterator() directly - a reused ColumnarBatchRow that is a zero-copy view over the Arrow buffers. It is deliberately not CodegenSupport, since whole-stage codegen would generate an UnsafeRowWriter loop and reintroduce the copy. The reused mutable row is only correct for a consumer that finishes with a row before pulling the next, so EliminateRedundantTransitions plants it only for unpartitioned, unbucketed writes through one of Spark's own FileFormats, and only when the schema contains a struct, array or map. Flat schemas are declined: there the projection is a generated fixed-width copy that measures inside the noise of a Parquet write. Off by default behind spark.comet.exec.write.rowView.enabled.
|
interesting idea, would it also work Iceberg writers? |
…import Spark's `withSQLConf` only became generic in 4.0; on 3.4 and 3.5 it returns Unit, so the polymorphic `withRowView[T]` helper failed to compile. Every call site passes a Unit expression, so pin the helper to Unit. Also remove the `testImplicits` import that scalafix flagged as unused.
|
Not as written, and I think that is the right default for now. The rule only matches Whether it could work is a more interesting question, and structurally the plain append path looks like it would:
But the safety argument would have to be redone against a much bigger writer set than Spark's, and it is a set we do not control the version of:
The part that bothers me is that for Spark's own writers the gate is at least argued against source that moves in lockstep with the Spark version we compile against. For Iceberg it would be argued against a dependency on its own release cadence, where a writer that starts retaining a row is a silent-corruption bug for us and a perfectly reasonable change for them. If this lands at all, that pushes me further toward caveat 1 in the description: an explicit allowlist of known-safe consumers rather than a package prefix, and Iceberg only added to it with its own round of round-trip tests. So: worth a follow-up issue rather than scope for this PR, and only after the core mechanism has convinced people it is worth having. |
Which issue does this PR close?
Closes #5625.
Experimental / RFC. Off by default, and I am not convinced the gain justifies the hazard yet -
see the caveats at the bottom. Opening as a draft to get the measurements and the safety argument
in front of people rather than because I think it is ready to land.
Rationale for this change
Comet has to do a columnar-to-row conversion before a write because native write support is still
experimental. That conversion materialises an
UnsafeRowper row, and nothing on Spark's writepath needs one:
OutputWriter.write(InternalRow),FileFormatDataWriter.write(InternalRow)andWriteTaskStatsTracker.newRow(String, InternalRow)are all typed onInternalRowParquetWriteSupport extends WriteSupport<InternalRow>and reads fields throughSpecializedGettersBasicWriteTaskStatsTracker.newRowignores the row entirely and just increments a counterMeanwhile
CometColumnarToRowExec.doExecutealready producesbatch.rowIterator()- a reusedColumnarBatchRowthat is a zero-copy view over the Arrow buffers - and then throws it away byapplying an
UnsafeProjection. For a write that copy is pure overhead: the writer decodes straightback out of the row it was just given.
This does not touch what actually makes a write expensive, which is parquet-mr encoding. Only the
native writer changes that. What it does do 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.
What changes are included in this PR?
CometColumnarToRowViewExecreturnsbatch.rowIterator()unprojected. It is deliberately notCodegenSupport: whole-stage codegen would generate anUnsafeRowWriterloop and reintroduceexactly the copy this exists to avoid.
EliminateRedundantTransitionsplants it underWriteFilesExec(planned writes) orDataWritingCommandExec(whenplannedWriteis off). The row it hands over is reused and mutable,so it is only correct for a consumer that finishes with a row before pulling the next one. Three
gates keep it there:
FileFormatWriterchooseSingleDirectoryDataWriter. The other writers do not qualify: the required ordering onpartition/bucket columns puts a
SortExecin between andUnsafeExternalSorterneedsUnsafeRow, andDynamicPartitionDataConcurrentWriterspills throughUnsafeKVExternalSorter.insertKV, which is typed onUnsafeRow.FileFormats, whoseOutputWriters encode each row on the spot (Parquetthrough
ParquetWriteSupport, ORC throughOrcSerializerinto aVectorizedRowBatch, the textformats directly). A third-party format is free to buffer the
InternalRowit is handed.fixed-width copy and the saving is inside the noise, which does not pay for the hazard above.
Behind
spark.comet.exec.write.rowView.enabled, default false.Also adds
CometParquetWriteBenchmark. Note it setsspark.shuffle.managerexplicitly - withoutit
isCometLoadeddisables Comet and every "Comet" arm silently measures Spark, which is #5624.Each arm asserts through a
QueryExecutionListenerthat it planned the transition its label names,and warns into the results file if not.
Measurements
M3 Max, 1M rows, Spark 4.1, release build, best-of-N, versus today's
CometColumnarToRowExec:The declined rows are both arms running the identical plan, so they double as a noise-floor
estimate: about 0-3%.
Two things worth noting. The gain comes from complex types being present, not from depth - one
level already captures it, and it flattens out after that. And the native C2R
(
spark.comet.exec.columnarToRow.native.enabled) is consistently slower than the JVM one onnested data here, 0.9X against Spark in several groups, which matches its documented per-batch JNI
cost.
How are these changes tested?
New
CometWriteRowViewSuite, 11 tests, registered in both PR build workflows. The bar for each isthat turning the config on changes nothing observable but the plan:
primitives/strings/nulls/nested, for fuzz-generated flat and nested schemas, and for a
deliberately deep four-level schema with nulls at every level
maxRecordsPerFileset and unsetschema, absent for partitioned and for bucketed writes, and present for a complex column sitting
alongside flat ones
Also ran
CometParquetWriterSuiteandCometNativeColumnarToRowSuitealongside it: 67 tests pass.Caveats I would want a second opinion on
argued from Spark's source rather than enforced by anything, and a future Spark change to a
writer that starts retaining rows would be silent data corruption rather than a test failure. A
defensive option would be to restrict it to
ParquetFileFormatonly.signal for "this
OutputWriterdoes not retain the row".