Skip to content

perf(spark): append rows straight into the Arrow batch - #7

Merged
ChristopherWirt merged 3 commits into
developfrom
rasheed/spark-write-direct-arrow
Oct 27, 2026
Merged

ChristopherWirt merged 3 commits into
developfrom
rasheed/spark-write-direct-arrow

Conversation

@gitRasheed

Copy link
Copy Markdown
Collaborator

Summary

Fixes #3

VortexDataWriter copied every row into a list, because Spark reuses the row object, and converted the list to Arrow when the batch was full. Writing each row straight into the Arrow vectors removes the copy and the per-value type dispatch. Writer alone, 2M rows, release native build:

schema wall time allocation per row
5 longs -11% to -20% 116 B to 8 B
100 numeric columns -22% 1067 B to 122 B
long + 4 strings -23% to -33% 495 B to 9 B
struct, array, map -31% to -36% 761 B to 298 B

Changes

  • ArrowFieldWriter appends one field of a row to its Arrow vector, in the shape of Spark's ArrowWriter: typed once, struct and map children resolved once, strings copied once from the row into the Arrow buffer.
  • Vectors start at 64 slots, or the batch size if smaller, and grow on demand. Buffers are now allocated at a batch's first row, so a writer that sees a few rows would otherwise hold a full batch of direct memory. 200 open writers of one row each hold 25 MiB, against 2 MiB on develop.
  • The schema is exported once per writer and released on close, which frees the child structs every batch's export leaked.
  • A write() that fails part-way through a row marks the writer failed, so later write() and commit() calls throw instead of writing misaligned columns.

VortexDataWriterTest covers memory growing with rows rather than batches, and rows the caller reuses after write(): on-heap, off-heap, and a GenericInternalRow with a binary column whose byte[] is overwritten. Develop read the overwritten bytes back in the last case.

Half or more of the writer thread's time is now the native encoder, which runs on the Spark task thread. Write worker threads are a separate change. VortexOutputWriter in vortex-data#9658 still copies rows the same way, so this change applies there too.

Checked locally: spotlessApply then spotlessCheck, ./gradlew --parallel javadoc check with Docker running (213 vortex-spark tests pass on Spark 3.5.9 and 4.1.2, including VortexDataSourceS3MockTest), the same tests on Spark 4.2.0 with -PsparkTestVersion=4.2.0, and a write of 400k rows into 200 partition directories (1600 open writers, every column type, with nulls) that reads back identical to the same data written as Parquet.

I used AI tools to help write and review this change.

VortexDataWriter allocated every batch at Arrow's default capacity of a few
thousand values per vector, and the native writer keeps each batch until a row
block of 8192 rows is complete, so small vortex.write.batch.size values retained
hundreds of kilobytes of direct memory per batch and exhausted the executor's
direct memory. Set the initial capacity of each vector to the batch size.

Signed-off-by: gitRasheed <[email protected]>
VortexDataWriter copied every InternalRow into a list and converted the
list to Arrow vectors once a batch was full. The copy is only there
because Spark reuses the row object, and it costs an UnsafeRow plus its
bytes per row (about 1 KiB per row for 100 columns), while the batch
conversion dispatched on the Spark type once per value, looked vectors
and struct children up per value, and copied each string twice.

Append each row into the batch vectors as it arrives, through one
ArrowFieldWriter per field in the shape of Spark's own ArrowWriter: typed
once, with a per-writer position so list elements and map entries land in
the right slot, struct and map children resolved once, and strings copied
straight from the row into the Arrow buffer. The schema is exported once
per writer; the native side only reads it. Vectors are still allocated
fresh per batch, since the native writer imports the exported buffers
zero-copy and releases them from its own thread.

Because the buffers are now allocated at the first row of a batch rather
than when it is full, the vectors start at 64 slots instead of the batch
size: Arrow doubles on demand and keeps the grown capacity for the next
allocateNew(), so a writer that fills its batches reaches the batch size
after its first one, while a partition writer that sees a few rows holds
131 KiB rather than 3.3 MiB for 100 columns (200 open writers: 25 MiB
instead of 630 MiB). A write() that fails part-way through a row poisons
the writer, since the batch columns are misaligned from then on, so later
write() and commit() calls fail until it is aborted. The exported schema
is released before it is closed, which frees the child structs the export
allocated (the native side only borrows the schema and never releases
them).

Writer-only, 2M rows, release native build: nested -31% to -38% wall,
strings -35% to -39%, 100 numeric columns -24%, 5 longs within noise;
allocation per row 116 -> 8 B (5 longs), 1072 -> 122 B (100 columns),
495 -> 9 B (strings), 761 -> 298 B (nested).

Signed-off-by: gitRasheed <[email protected]>

@ChristopherWirt ChristopherWirt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One regression to fix before merge (on commit()). The rest are non-blocking.

I checked the append logic against Arrow 19 and the native writeBatch, and ran a round-trip of 10k rows at batch sizes 1/2048/65536 with nulls at every nesting level. It reads back identical, and the Spark 3.5 and 4.1 suites pass.

*/
@Override
public WriterCommitMessage commit() throws IOException {
checkNotFailed();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This throws before any cleanup, so a failed writer's native writer, allocator, vectors and exported schema all leak. Develop's commit() always cleaned up.

Abort doesn't rescue it: PartitionedVortexDataWriter.commit() sets closed = true even when a child throws, so its abort() returns early and never reaches the failed child.

Could a failed writer release its resources first, e.g. call abort() here, then throw?

// For unsupported types, set null
throw new IllegalArgumentException("Unsupported data type: " + dataType);
for (ArrowFieldWriter writer : fieldWriters) {
writer.reset();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: Arrow never shrinks the capacity it remembers. One batch with a 50 MB string makes every later batch allocate 50 MB at its first row. Develop behaves the same, but this PR's memory argument relies on that capacity. Worth re-applying setInitialCapacity here, or at least noting it in a comment?

Comment on lines +97 to +104
// Arrow's default capacity is a few thousand values per vector regardless of how many
// rows a batch holds, and the native writer keeps every batch it receives until a row
// block is complete, so the batch bounds the capacity. Start well below it, though:
// the buffers are allocated at the first row of every batch, and a partition writer
// that sees a handful of rows must not hold a whole batch of direct memory for its
// lifetime. Arrow doubles on demand and remembers the grown capacity for the next
// allocateNew(), so a writer that fills its batches reaches the batch size once.
int initialCapacity = Math.min(batchSize, INITIAL_VECTOR_CAPACITY);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this mostly repeats the commit message. Something like // Small start so writers that see few rows hold little memory; Arrow grows on demand. would do.


@Override
void setValue(SpecializedGetters row, int ordinal) {
decimals.setSafe(count, row.getDecimal(ordinal, precision, scale).toJavaBigDecimal());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this allocates a BigDecimal per value. For precision <= 18, row.getDecimal(...).toUnscaledLong() avoids the allocation. Only wider decimals need BigDecimal.


@Override
void setValue(SpecializedGetters row, int ordinal) {
binaries.setSafe(count, row.getBinary(ordinal));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: getBinary allocates a byte[] and setSafe copies it again. The StringWriter approach (setValueLengthSafe, then copy straight from the row) would avoid the per-value garbage here too.

…ls as longs

A commit after a failed write threw before any cleanup, and
PartitionedVortexDataWriter does not abort a child whose commit threw, so
the native writer, allocator, vectors and exported schema leaked. Abort
first, then throw.

Decimals of up to 18 digits are written from their unscaled long instead
of a BigDecimal per value. The capacity comment is shorter and notes that
Arrow keeps the largest capacity a batch needed.

Signed-off-by: gitRasheed <[email protected]>
@gitRasheed

Copy link
Copy Markdown
Collaborator Author

Fixed in 910abcc.

  • A failed writer's commit() now aborts before it throws. commitAfterFailedWriteReleasesTheWriter covers it.
  • Decimals of up to 18 digits are written from the unscaled long. decimalsRoundTrip covers 18 and 38 digits.
  • The comment is shorter and notes that the capacity never shrinks. Develop does the same, so I left the allocation alone.
  • Binary: I kept the copy. SpecializedGetters has no aliasing getter for binary, so avoiding it needs UnsafeRow-specific code, and Spark's ArrowWriter copies here too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Spark] Reduce intermediate row copies and Arrow allocation churn during writes

2 participants