This is an automated email from the ASF dual-hosted git repository.
Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 4aee48acaf perf(parquet): splice buffered pages with `write_all`
instead of `io::copy` (adapts #10052) (#10353)
4aee48acaf is described below
commit 4aee48acaf4f37233db478650fd60aab238ae12b
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Sat Jul 18 21:19:50 2026 -0500
perf(parquet): splice buffered pages with `write_all` instead of `io::copy`
(adapts #10052) (#10353)
# Which issue does this PR close?
- Adapts #10052 (by @alamb) to the current `PageStore`-based writer.
That PR predates #10020 / #10142 and no longer applies cleanly; this PR
carries its idea forward. Closes #10052's use case.
- closes https://github.com/apache/arrow-rs/pull/10052
# Rationale for this change
When the `ArrowWriter` flushes a row group, each column chunk's buffered
pages are spliced into the output through `StreamingColumnChunkReader`
(a `Read`) driven by `std::io::copy`. Every byte is copied twice — once
into `io::copy`'s fixed 8 KiB buffer, then again into `TrackedWrite`'s
`BufWriter` — in ~`len / 8 KiB` write calls.
The page blobs are already contiguous owned `Bytes` sitting in the
`PageStore`; we can write each one straight to the sink with a single
`write_all` (large writes bypass the `BufWriter` buffer entirely).
#10052 did exactly this, but its `IntoIterator<Item = Bytes>` signature
can't express the fallible, one-page-at-a-time drain of a (possibly
spilling) `PageStore`, and it assumed the pre-#10020 `Vec<Bytes>` chunk
representation.
# What changes are included in this PR?
- `StreamingColumnChunkReader` (`Read` impl) becomes
`StreamingColumnChunkPages`, an `Iterator<Item = Result<Bytes>>` that
`take()`s each page out of the `PageStore` as it is consumed —
preserving the one-page-in-memory bound for spilling backends.
- New `pub(crate) SerializedRowGroupWriter::append_column_from_pages`,
which writes each page blob with a single `write_all`.
- The shared validation / offset-rewriting code is factored into
`begin_appended_column` / `finish_appended_column` (same refactor as
#10052), also used by the existing `Read`-based path that still backs
the public `append_column`.
# Are these changes tested?
Covered by existing tests. Also verified that the output files are
byte-for-byte identical to main for multi-row-group writes with int /
string / dictionary columns under both default and small-page
properties.
Local benchmark results (`arrow_writer` bench, Apple Silicon),
reproducing the wins from #10052's benchmark run without its
`large_string_non_null` regression:
| benchmark | main | this PR | change |
|---|---|---|---|
| `string_dictionary/default` | 36.9 ms | 27.9 ms | ~24% faster |
| `list_primitive/default` | 179.8 ms | 150.1 ms | ~17% faster |
| `large_string_non_null/default` | 31.4 ms | 30.1 ms | ~4% faster |
# Are there any user-facing changes?
No. `append_column_from_pages` is kept `pub(crate)` for now; it could be
made public in a follow-up if the `Result<Bytes>` iterator contract is
deemed a good public API (#10052 proposed a public variant).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Sd8JjF3qQMkn7htofu8bF3
---------
Co-authored-by: Claude Fable 5 <[email protected]>
---
parquet/src/arrow/arrow_writer/mod.rs | 37 +++++---------
parquet/src/file/writer.rs | 95 ++++++++++++++++++++++++++++++-----
2 files changed, 94 insertions(+), 38 deletions(-)
diff --git a/parquet/src/arrow/arrow_writer/mod.rs
b/parquet/src/arrow/arrow_writer/mod.rs
index 37d85cf7da..7dd7fb7789 100644
--- a/parquet/src/arrow/arrow_writer/mod.rs
+++ b/parquet/src/arrow/arrow_writer/mod.rs
@@ -20,7 +20,7 @@
use crate::column::chunker::ContentDefinedChunker;
use bytes::Bytes;
-use std::io::{Read, Write};
+use std::io::Write;
use std::slice::Iter;
use std::sync::{Arc, Mutex};
use std::vec::IntoIter;
@@ -769,24 +769,22 @@ impl ArrowColumnChunkData {
}
}
-/// A streaming [`Read`] over one column chunk's buffered pages, in final file
-/// order: the dictionary page (if any) first, then the data pages.
+/// A streaming iterator over one column chunk's buffered page blobs, in final
+/// file order: the dictionary page (if any) first, then the data pages.
///
/// Each blob is taken back out of the [`PageStore`] *as it is
/// consumed* and released immediately afterwards, so splicing a chunk into the
/// output file never materializes more than a single page in memory at a time.
/// This is what keeps the splice phase within the memory bound for a spilling
/// backend (an in-memory store already holds the bytes, so it is unaffected).
-struct StreamingColumnChunkReader {
+struct StreamingColumnChunkPages {
store: Box<dyn PageStore>,
/// Page handles in final file order: the dictionary page first (if any),
/// then the data pages.
keys: IntoIter<PageKey>,
- /// The blob currently being drained into the output; emptied as it is
read.
- current: Bytes,
}
-impl StreamingColumnChunkReader {
+impl StreamingColumnChunkPages {
fn new(data: ArrowColumnChunkData) -> Self {
// The dictionary page must be emitted first, ahead of the data pages,
// even though it was the last page produced.
@@ -801,27 +799,16 @@ impl StreamingColumnChunkReader {
Self {
store: data.store,
keys: keys.into_iter(),
- current: Bytes::new(),
}
}
}
-impl Read for StreamingColumnChunkReader {
- fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
- // Refill from the next blob whenever the current one is drained: the
- // dictionary page first, then each data page, all taken from the
store.
- while self.current.is_empty() {
- if let Some(key) = self.keys.next() {
- self.current =
self.store.take(key).map_err(std::io::Error::other)?;
- } else {
- return Ok(0);
- }
- }
+impl Iterator for StreamingColumnChunkPages {
+ type Item = Result<Bytes>;
- let len = self.current.len().min(out.len());
- let b = self.current.split_to(len);
- out[..len].copy_from_slice(&b);
- Ok(len)
+ fn next(&mut self) -> Option<Self::Item> {
+ let key = self.keys.next()?;
+ Some(self.store.take(key))
}
}
@@ -998,8 +985,8 @@ impl ArrowColumnChunk {
// it ahead of the data pages in the recorded offsets before the
splice.
let close = close.update_dictionary_location(data.dictionary_len)?;
- let reader = StreamingColumnChunkReader::new(data);
- writer.append_column_from_read(reader, close)
+ let pages = StreamingColumnChunkPages::new(data);
+ writer.append_column_from_pages(pages, close)
}
}
diff --git a/parquet/src/file/writer.rs b/parquet/src/file/writer.rs
index 8ec16ba367..b62d8886cd 100644
--- a/parquet/src/file/writer.rs
+++ b/parquet/src/file/writer.rs
@@ -22,6 +22,8 @@ use crate::file::metadata::thrift::PageHeader;
use crate::file::page_index::column_index::ColumnIndexMetaData;
use crate::file::page_index::offset_index::OffsetIndexMetaData;
use crate::parquet_thrift::{ThriftCompactOutputProtocol, WriteThrift};
+#[cfg(feature = "arrow")]
+use bytes::Bytes;
use std::fmt::Debug;
use std::io::{BufWriter, IoSlice, Read};
use std::{io::Write, sync::Arc};
@@ -708,14 +710,75 @@ impl<'a, W: Write + Send> SerializedRowGroupWriter<'a, W>
{
pub(crate) fn append_column_from_read<R: Read>(
&mut self,
read: R,
- mut close: ColumnCloseResult,
+ close: ColumnCloseResult,
) -> Result<()> {
+ let (src_offset, src_length, write_offset) =
self.begin_appended_column(&close)?;
+
+ let mut read = read.take(src_length as _);
+ let write_length = std::io::copy(&mut read, &mut self.buf)?;
+
+ if src_length as u64 != write_length {
+ return Err(general_err!(
+ "Failed to splice column data, expected {src_length} got
{write_length}"
+ ));
+ }
+
+ self.finish_appended_column(close, src_offset, write_offset)
+ }
+
+ /// Splice an already-encoded column chunk into the row group from an
+ /// in-order sequence of byte buffers (typically its serialized pages).
+ ///
+ /// This is a lower-overhead alternative to [`Self::append_column`] /
+ /// [`Self::append_column_from_read`] for callers that already hold the
+ /// chunk as owned [`Bytes`]: each buffer is written straight to the output
+ /// with a single `write_all`, skipping the intermediate copy through
+ /// [`std::io::copy`]'s fixed-size buffer.
+ ///
+ /// `pages` must yield the chunk's compressed bytes in final file order
+ /// (the dictionary page, if any, first) and together total exactly the
+ /// compressed size recorded in `close`.
+ #[cfg(feature = "arrow")]
+ pub(crate) fn append_column_from_pages<I>(
+ &mut self,
+ pages: I,
+ close: ColumnCloseResult,
+ ) -> Result<()>
+ where
+ I: IntoIterator<Item = Result<Bytes>>,
+ {
+ let (src_offset, src_length, write_offset) =
self.begin_appended_column(&close)?;
+
+ let mut write_length = 0u64;
+ for page in pages {
+ let page = page?;
+ self.buf.write_all(&page)?;
+ write_length += page.len() as u64;
+ }
+
+ if src_length as u64 != write_length {
+ return Err(general_err!(
+ "Failed to splice column data, expected {src_length} got
{write_length}"
+ ));
+ }
+
+ self.finish_appended_column(close, src_offset, write_offset)
+ }
+
+ /// [`Self::append_column_from_read`] / [`Self::append_column_from_pages`]
+ /// preamble: validates the writer state and that `close` matches the next
+ /// expected column.
+ ///
+ /// Returns `(src_offset, src_length, write_offset)`: the chunk's start
+ /// offset and length in the source buffer, and the offset at which it will
+ /// land in the output file.
+ fn begin_appended_column(&mut self, close: &ColumnCloseResult) ->
Result<(i64, i64, usize)> {
self.assert_previous_writer_closed()?;
let desc = self
.next_column_desc()
.ok_or_else(|| general_err!("exhausted columns in
SerializedRowGroupWriter"))?;
- let metadata = close.metadata;
+ let metadata = &close.metadata;
if metadata.column_descr() != desc.as_ref() {
return Err(general_err!(
@@ -725,20 +788,26 @@ impl<'a, W: Write + Send> SerializedRowGroupWriter<'a, W>
{
));
}
- let src_dictionary_offset = metadata.dictionary_page_offset();
- let src_data_offset = metadata.data_page_offset();
- let src_offset = src_dictionary_offset.unwrap_or(src_data_offset);
+ let src_offset = metadata
+ .dictionary_page_offset()
+ .unwrap_or_else(|| metadata.data_page_offset());
let src_length = metadata.compressed_size();
-
let write_offset = self.buf.bytes_written();
- let mut read = read.take(src_length as _);
- let write_length = std::io::copy(&mut read, &mut self.buf)?;
+ Ok((src_offset, src_length, write_offset))
+ }
- if src_length as u64 != write_length {
- return Err(general_err!(
- "Failed to splice column data, expected {read_length} got
{write_length}"
- ));
- }
+ /// [`Self::append_column_from_read`] / [`Self::append_column_from_pages`]
+ /// epilogue: rewrites the buffer-relative page offsets recorded in `close`
+ /// to their final positions in the output file and closes the column.
+ fn finish_appended_column(
+ &mut self,
+ mut close: ColumnCloseResult,
+ src_offset: i64,
+ write_offset: usize,
+ ) -> Result<()> {
+ let metadata = close.metadata;
+ let src_dictionary_offset = metadata.dictionary_page_offset();
+ let src_data_offset = metadata.data_page_offset();
let map_offset = |x| x - src_offset + write_offset as i64;
let mut builder =
ColumnChunkMetaData::builder(metadata.column_descr_ptr())