Skip to content

persist: decode fetched parts in bounded arrow batches - #39587

Merged
antiguru merged 3 commits into
MaterializeInc:mainfrom
antiguru:moritz/cpu-296-batched-part-decode
Oct 8, 2026
Merged

antiguru merged 3 commits into
MaterializeInc:mainfrom
antiguru:moritz/cpu-296-batched-part-decode

Conversation

@antiguru

@antiguru antiguru commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

FetchedPart decodes a hollow part's single parquet row group into one arrow batch and keeps it alive until the last row is emitted. During hydration, each worker therefore holds a whole decoded part (persist targets 128 MiB encoded parts), and more than one when parts queue in persist_source.

With the new dyncfg persist_part_decode_batch_rows set, readers that go through FetchedPart keep the encoded bytes and decode, validate, and normalize at most that many rows at a time. These readers are persist_source (via FetchedBlob::parse), Listen, and snapshot_and_stream. Consolidation through the peek stash carries across batch boundaries. Inline parts and EncodedPart consumers (compaction, consolidating iteration, inspect) still decode whole parts. The flag defaults to unset, which keeps today's behavior, and CI runs with 16384 (plus unset and 7 as variants).

Whole-part decoding now goes through the same BlobTraceBatchPartReader with a batch size of the part's row count, so both paths share one format dispatch. As a result, a part with no rows decodes to empty updates instead of failing.

Two new tests, fetch::tests::batched_part_decode and fetch::tests::batched_part_decode_ts_rewrite, read one 10-row hollow part unbatched and with batch sizes 0, 1, 3, 4, 10, and 16384 through both read paths. They cover duplicates that merge across a batch boundary and a pair that cancels across one. The second test reads a part whose description was rewritten, so every batch is truncated and has its timestamps rewritten.

Measured on a TPC-H SF100 lineitem index hydrating on an 8 GiB, 8-worker replica (r8gd.8xlarge, file-backed buffer pool, lgalloc off, fetch semaphore at 0.1): whole-part decode was OOM-killed, and 16384-row batches hydrated in 303 s. In heap profiles, the roughly 1.1 GB of parquet decode output no longer appears. Details are in CPU-296.

CPU-296

🤖 Generated with Claude Code

@antiguru
antiguru marked this pull request as ready for review October 6, 2026 16:08
@antiguru
antiguru requested review from a team as code owners October 6, 2026 16:08

@mtabebe mtabebe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall this looks good to me. Thank you for the measurement details in the linear ticket.

I had a few nits and a clarification question for my understanding.

/// Incremental decoder over a parquet-encoded [`BlobTraceBatchPart`].
///
/// Decodes at most `batch_rows` rows per call to [`Self::next_updates`], so
/// only the encoded bytes and one decoded batch are resident at a time. Each

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might be helpful to explain why we want this property in the comment. That is:

(1) we want to unpack data as we go so that we don't need to unpack the whole part at once and keep all that in memory, thereby reducing OOM when holding multiple parts

(2) we output in order and are contiguous, which matters for merging adjacent tuples. Out of order would mean under merging rows? I believe the invariant comes from this comment here:
// We enforce an invariant that we have a single RowGroup.
But it isn't clear that the caller can rely on that behaviour

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added a paragraph to the BlobTraceBatchPartReader doc: successive batches cover the part's rows in encoded order without gaps or overlap, so a caller can carry its pending update across a boundary and merge the same updates as on the whole part. The reader walks row groups in order, so this holds independently of the single-row-group invariant. The memory motivation is in the first paragraph. Ordering affects only how much adjacent consolidation happens, not correctness, since FetchedPart consolidation is best-effort.

(Reply by Claude Code.)

Comment thread src/persist-client/src/fetch.rs Outdated
0,
"\
Maximum number of rows of a fetched blob part that a reader decodes at \
once. 0 decodes the whole part at once.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit, but 0 as the whole part at once feels weird to me. I know this is a standard way of doing things but it always gives me the ick ... Can we make it an Config<Option> where None defaults to the old behaviour?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done, it's now Config<Option<usize>> with None (empty string in SQL) as whole-part decoding. Some(0) behaves like 1, and the test covers it.

(Reply by Claude Code.)

@@ -1123,21 +1344,31 @@ where
let mut consolidated = self.peek_stash.take();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just so I make sure I understand: this is what is holding the state of the row being read: either we accumulate into this, or we create a new row. And this is why we need the ordering property

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes. consolidated holds the owned, decoded update being accumulated, and peek_stash holds the first update that didn't merge, which seeds the next call. Both are owned values, so they survive the decoded batch being replaced. Consolidation only merges adjacent equal (kv, t) pairs, so it relies on rows arriving in order and contiguously. If they arrived out of order, it would merge less, but the result would still be correct.

(Reply by Claude Code.)

/// Reads the shard's single hollow part as of `as_of`, with and without
/// batched decoding, through both `snapshot_and_stream` and the
/// `BatchFetcher`/`FetchedBlob` path used by `persist_source`.
async fn assert_batched_reads(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice 👍

antiguru and others added 3 commits October 8, 2026 12:58
FetchedPart decoded a hollow part's single parquet row group into one
arrow batch and kept it alive until the last row was emitted. With
`persist_part_decode_batch_rows` set, readers that go through
FetchedPart (persist_source via FetchedBlob::parse, Listen,
snapshot_and_stream) keep the encoded bytes and decode, validate, and
normalize at most that many rows at a time, so one decoded batch per
part is resident. Consolidation via the peek stash carries across batch
boundaries.

Inline parts and EncodedPart consumers (compaction, consolidating
iteration, inspect) still decode whole parts. The flag defaults to 0,
which keeps whole-part decoding, and CI runs with 16384.

CPU-296

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Decode whole parts through `BlobTraceBatchPartReader`, so the format
dispatch exists once. Pick whole or batched decoding of a hollow part in
`PartSource::from_hollow_blob`, keep a `FetchedPart`'s current batch and
its cursor in one `DecodedRows`, and read all `BatchFetcherConfig` values
at fetch time. Add a test for batched decoding of a part with a
rewritten description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Unset keeps whole-part decoding, which replaces the 0 sentinel. Document
that reader batches cover a part's rows in order without gaps, which lets
callers carry consolidation across batch boundaries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@antiguru
antiguru force-pushed the moritz/cpu-296-batched-part-decode branch from e23c16c to 99c8d23 Compare October 8, 2026 11:02
@antiguru
antiguru merged commit 9894a2d into MaterializeInc:main Oct 8, 2026
89 checks passed
@antiguru
antiguru deleted the moritz/cpu-296-batched-part-decode branch October 8, 2026 11:33
@antiguru

antiguru commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review!

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.

2 participants