Skip to content

fix(parquet): release the bytes of a row group when the push decoder is done with it - #11235

Merged
alamb merged 7 commits into
apache:mainfrom
pydantic:claude/push-buffers-release-coalesced
Oct 8, 2026
Merged

alamb merged 7 commits into
apache:mainfrom
pydantic:claude/push-buffers-release-coalesced

Conversation

@adriangb

@adriangb adriangb commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

ParquetPushDecoder does not release a pushed buffer that is larger than a requested range. A caller that merges nearby ranges into one object storage request thus holds all the bytes of the scan until the scan ends.

After row group buffered_bytes() before this PR With this PR
0 195,128 0
1 390,256 0
2 585,384 0
3 780,512 0

The reproduction is in #11233.

What changes are included in this PR?

  1. When the decoder is done with a row group, it releases the pushed bytes of all column chunks of that row group.
  2. into_builder().build() releases the bytes that the rebuilt decoder does not read, for example the bytes of row groups that a dynamic filter pruned.

The inline comments show where to start and which parts of the diff are mechanical.

Details: bytes that stay buffered, internals, performance commits

These bytes stay buffered, as before, until clear_all_ranges:

  • bytes that are not in a column chunk (for example, the footer in a push of the full file), unless the decoder is rebuilt;
  • bytes of row groups that the decoder skips during the scan (no selected rows, or after the limit);
  • bytes of a row group that the scan reads again later.

buffered_bytes() counts the bytes that the decoder holds. The kept parts are zero-copy slices, so the allocator frees a pushed Bytes only after all of its slices are dropped.

Internals: PushBuffers::release_ranges trims or splits pushed buffers. retain_ranges calls release_ranges with the complement. #11234 uses them again.

The commits after the fix make the bookkeeping cheaper (from the review by @HippoBaro):

  • the decoder counts queued row groups, and does not scan the queue for each finished row group;
  • buffered_bytes() is O(1);
  • release_ranges trims buffers in place.

This PR includes #11236, which changed the same code.

Are these changes tested?

Yes. test_decoder_releases_coalesced_push is the reproduction from the issue, and it fails without the change.

All new and changed tests
Test Case
test_decoder_releases_coalesced_push the reproduction from the issue
test_decoder_releases_unrequested_bytes_of_row_group the pushed buffer also holds a column that the scan does not read
test_decoder_releases_coalesced_push_with_predicate a RowFilter, which makes two requests per row group
test_decoder_clear_all_ranges (changed) a push of the full file: the bytes of each finished row group are released
test_decoder_keeps_bytes_of_a_row_group_that_is_read_again a row group that is read two times keeps its bytes
test_into_builder_releases_bytes_of_skipped_row_groups read ahead the full file, rebuild to read only row group 0
test_into_builder_preserves_buffered_bytes (changed) the rebuild keeps the bytes of the next row group and releases the rest of the file
test_into_builder_keeps_bytes_of_predicate_columns a rebuild keeps the bytes of a predicate column that is not in the output

There are also unit tests for release_ranges (one compares it with a rebuild of all buffers) and retain_ranges.

Are there any user-facing changes?

There is no API change. There are two behavior changes:

  • buffered_bytes() decreases after each row group when the caller pushes buffers that are larger than the requested ranges.
  • After into_builder().build() (or build() with with_buffers), the decoder holds only the bytes that it reads. A caller that narrows the projection and then widens it again must push the other columns again.

🤖 Generated with Claude Code

@adriangb

Copy link
Copy Markdown
Contributor Author

@alamb @HippoBaro this is ready for review if one of you can take a look

Comment on lines +126 to +133
fn contains(&self, row_group_idx: usize) -> bool {
match self {
Self::Global { row_groups, .. } => row_groups.contains(&row_group_idx),
Self::PerRowGroup(row_groups) => row_groups
.iter()
.any(|row_group| row_group.row_group_index == row_group_idx),
}
}

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.

This searches the remaining queue after every completed row group. For a normal scan of N distinct groups, this mean quadratic aggregate work. Could we maintain remaining occurrence counts as the queue advances, rather than repeatedly scanning it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, sent in 4613731. 88c197c also makes PushBuffers::buffered_bytes O(1), which the memory accounting calls per batch.

@adriangb
adriangb force-pushed the claude/push-buffers-release-coalesced branch from b859f9f to 49e7d0e Compare October 1, 2026 13:50

@HippoBaro HippoBaro 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.

LGTM!

@adriangb

adriangb commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing @HippoBaro !

@alamb Hippo reviewed this PR already, do you think you could lend a second set of eyes? Thanks!

@adriangb
adriangb force-pushed the claude/push-buffers-release-coalesced branch from 332fb86 to e5d880d Compare October 6, 2026 17:28
@alamb

alamb commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

I am sorry -- your pR descriptions are better than most, but they are still very long and I think overly detailed. It would help me to review the pRs much more if you could cut the description down to a paragraph or two (or maybe hide the unecessary detail with <details>)

Then perhaps you can call out any important code / strutural changes with inline comments

@adriangb

adriangb commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

I'll see what I can do!

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reading guide for reviewers: these comments show where to start and which parts of the diff are mechanical.

RowGroupBuildResult::Finished { remaining_budget } => {
self.frontier
.update_budget_after_row_group(remaining_budget);
self.release_row_group(row_group_idx);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Start here. This is the fix: when a row group is done, release all of its column chunks. Before, the decoder released a buffer only if its range was equal to a requested range.

/// Release the buffered bytes of a row group that is done, unless the
/// queue reads it again. The reader of the row group holds its own
/// copies of the bytes that it reads.
fn release_row_group(&mut self, row_group_idx: Option<usize>) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

A row group can be queued more than one time, so its bytes are kept while it is still queued.

/// buffers with the same start is not specified. If no buffer overlaps
/// `ranges`, the buffers do not change.
#[cfg(feature = "arrow")]
pub(crate) fn release_ranges(&mut self, ranges: &[Range<u64>]) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The new primitive. It trims or splits pushed buffers into zero-copy slices. release_ranges_matches_rebuild compares it with a simple rebuild of all buffers.

/// reader builder.
#[derive(Debug, Clone)]
enum QueuedRowGroups {
struct QueuedRowGroups {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This diff looks larger than it is. The old QueuedRowGroups enum is renamed to Queue, and the only addition to it is row_group_indices. The new struct adds occurrence counts so that is_queued does not scan the queue (from the review by @HippoBaro).

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.

Thank you @adriangb 🙇

// The buffers can hold bytes that this decoder does not read. For
// example, the buffers of a rebuilt decoder can hold the bytes of row
// groups that the new configuration skips.
remaining_row_groups.release_unread_bytes();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The second change, and the one behavior change for callers. With a new builder the buffers are empty, so this returns early. It only has an effect with with_buffers or into_builder.

/// Remove the buffered bytes outside the read column chunks of
/// `row_groups`. A column chunk is read if the output or a predicate
/// reads its column. Indexes that are not in the file are ignored.
pub(crate) fn release_unread_bytes(&mut self, row_groups: impl IntoIterator<Item = usize>) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept bytes = the column chunks of the output projection and of the predicate columns, for the queued row groups.

@adriangb

adriangb commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @alamb, I tried to shorten the description while keeping it relevant and posted some guiding comments.

To make the PR itself smaller: if you want we can move the into_builder change (the last commit) to a follow-up PR so that the rest is more like a bug fix.

@alamb

alamb commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

run benchmark push_decoder

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark starting (GKE) | trigger

Target: push_decoder

Sharding factor: 1 (1 workers).

Comparing claude/push-buffers-release-coalesced (e5d880d) to 6b34163 (merge-base) diff

Run configuration
run benchmark push_decoder
shards: 1

Results will be posted when all workers finish.


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark completed (GKE) | trigger

Target: push_decoder

Comparing claude/push-buffers-release-coalesced (e5d880d) to 6b34163 (merge-base) diff

Run configuration
run benchmark push_decoder
shards: 1
Details
group                             base                                   changed
-----                             ----                                   -------
push_decoder/1buf/100000ranges    1.00     49.8±0.69ms        ? ?/sec    1.04     51.8±1.07ms        ? ?/sec
push_decoder/1buf/10000ranges     1.00      4.7±0.01ms        ? ?/sec    1.06      5.0±0.08ms        ? ?/sec
push_decoder/1buf/1000ranges      1.00    467.1±0.99µs        ? ?/sec    1.04    483.6±1.86µs        ? ?/sec
push_decoder/1buf/500000ranges    1.00    263.1±2.24ms        ? ?/sec    1.07    281.2±2.48ms        ? ?/sec
push_decoder/Nbuf/100000ranges    1.00   816.9±49.99ms        ? ?/sec    1.00   820.8±48.76ms        ? ?/sec
push_decoder/Nbuf/10000ranges     1.00     12.4±0.02ms        ? ?/sec    1.02     12.6±0.21ms        ? ?/sec
push_decoder/Nbuf/1000ranges      1.00    581.4±0.69µs        ? ?/sec    1.02    595.5±1.41µs        ? ?/sec

Per-runner information
push_decoder — shard 1/1

Node: gk3-benchmark-cluster-nap-1qxrz5np-3edda623-k5pw

Instance: c4a-highcpu-48 (12 vCPU / 65 GiB)

uname:

Linux bench-c6045619838-3152-cr2c2 6.12.94+ #1 SMP Fri Aug 21 08:00:16 UTC 2026 aarch64 GNU/Linux

BENCH_COMMAND:

cargo bench --features=arrow,async,test_common,experimental,object_store --bench push_decoder
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  48
On-line CPU(s) list:                     0-47
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     48
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               3 MiB (48 instances)
L1i cache:                               3 MiB (48 instances)
L2 cache:                                96 MiB (48 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-47
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Resource Usage

base (merge-base)

Metric Value
Wall time 185.0s
Peak memory 3.0 GiB
Avg memory 2.3 GiB
CPU user 181.0s
CPU sys 1.0s
Peak spill 0 B

branch

Metric Value
Wall time 185.0s
Peak memory 3.0 GiB
Avg memory 2.3 GiB
CPU user 178.1s
CPU sys 1.1s
Peak spill 0 B

File an issue against this benchmark runner

@alamb alamb 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.

All in all this seems like an important step towards better memory usage in the parquet decoder -- thank you @adriangb and @HippoBaro

I left a bunch of small wording changes, that I think would help but are not required

Also, I think @HippoBaro filed another ticket about this (and a proposed fix) back in april. I updated the PR description to reflect this

/// Provide a preexisting [`PushBuffers`] for the built decoder to read
/// from, so bytes already fetched are not requested again.
///
/// [`Self::build`] releases the bytes that the decoder does not read:

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.

I think this makes sense -- to put the cleaning as part of the builder

Comment thread parquet/src/arrow/push_decoder/mod.rs Resolved
Comment thread parquet/src/arrow/push_decoder/mod.rs Resolved
Comment thread parquet/src/arrow/push_decoder/mod.rs Resolved
Comment thread parquet/src/arrow/push_decoder/mod.rs Resolved
Comment thread parquet/src/util/push_buffers.rs Resolved
Comment thread parquet/src/util/push_buffers.rs Resolved
/// buffers with the same start is not specified. If no buffer overlaps
/// `ranges`, the buffers do not change.
#[cfg(feature = "arrow")]
pub(crate) fn release_ranges(&mut self, ranges: &[Range<u64>]) {

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.

Can this code be simplified once we merge the change to keep the ranges sorted in

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think much simpler, but we could make it faster using binary search. I can do that in a followup PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Filed #11458 for the binary search follow-up.

Comment thread parquet/src/util/push_buffers.rs Resolved
Comment thread parquet/src/util/push_buffers.rs Resolved
adriangb added a commit to pydantic/arrow-rs that referenced this pull request Oct 8, 2026
Address review comments on apache#11235

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@adriangb

adriangb commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @alamb. I will try to address the remaining feedback today.

Rich-T-kid pushed a commit to Rich-T-kid/arrow-rs that referenced this pull request Oct 8, 2026
# Which issue does this PR close?

- Part of apache#11234.

# Rationale for this change

`ParquetPushDecoder` keeps the pushed buffers in a `PushBuffers` list.
Each lookup scans the full list. If a caller pushes one buffer per
requested range (one per column chunk, or one per page), the work for
one row group grows with the square of the number of buffers. The
existing `push_decoder` benchmark shows this for wide files:

| Benchmark (`benches/push_decoder.rs`) | `main` | This PR | Speedup,
run 1 / run 2 | Control (`main` vs `main`) |
|---|---:|---:|---:|---:|
| `Nbuf/100000ranges` | 893 ms | 58 ms | 15.4× / 14.6× | 1.03 |
| `Nbuf/10000ranges` | 13.0 ms | 5.5 ms | 2.4× / 2.3× | 1.02 |
| `Nbuf/1000ranges` | 581 µs | 541 µs | 1.07× / 1.05× | 1.01 |
| `1buf/*` (one pushed buffer) | | | no change (±2%) | ±6% |

(`Nbuf`: one pushed buffer per column chunk. Benchmark bot runs: [run
1](apache#11237 (comment)),
[run
2](apache#11237 (comment)),
[control](apache#11237 (comment)),
which compares `main` with itself to show the noise.)

The batch mode of apache#11234 pushes
one buffer per page, which makes the list much longer.

# What changes are included in this PR?

`PushBuffers` keeps its buffers sorted by start offset, so a lookup is a
binary search plus a short backward scan. Pushed buffers can still
overlap. Removing buffers keeps the maximum buffer length exact, so a
large buffer that was removed does not slow down later lookups.

This PR is independent of apache#11235,
which also changes `push_buffers.rs`. The PR that merges second gets a
small rebase. A `max_len` that is too large is still correct (lookups
only scan further), so the rebase updates `max_len` in `release_ranges`
for performance, not for correctness.

# Are these changes tested?

Yes. Unit tests for out-of-order pushes, overlapping pushes, and
`clear_ranges`, and a randomized test (200 seeds) that compares pushes,
clears and lookups with a full scan and checks the sort order and the
maximum length.

# Are there any user-facing changes?

No.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
@alamb

alamb commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Thanks @alamb. I will try to address the remaining feedback today.

Thank you -- this is a major step forward. I can't wait to get it all integrated into the scans more deeply

adriangb and others added 5 commits October 8, 2026 12:02
…is done with it

The decoder released a pushed buffer only if its range was equal to a
requested range. A caller that fetched the requested ranges with one
larger request kept all bytes until the end of the scan.

Add PushBuffers::release_ranges, which trims or splits buffers into
zero-copy slices, and release all column chunks of a row group when the
decoder is done with it, unless the scan reads the row group again.

Closes apache#11233.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
RemainingRowGroups checked whether a finished row group is queued again
by scanning the whole queue after each row group, which is quadratic in
the number of row groups. QueuedRowGroups now keeps the number of times
each row group is queued, and updates it as the queue advances.

Claude-Session: https://claude.ai/code/session_01XvkYF2BB52ejLkuZy5WnLW

Co-Authored-By: Claude Opus 5.5 <[email protected]>
buffered_bytes summed every buffered range on each call, and callers
query it per batch for memory accounting. Keep a running total instead.

release_ranges also returns early when no buffer overlaps the released
ranges, and skips the sort when the kept parts are already sorted.

Claude-Session: https://claude.ai/code/session_01XvkYF2BB52ejLkuZy5WnLW

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…_ranges

release_ranges rebuilt both Vecs and cloned every buffer on each call,
although most calls trim or remove one buffer. Now it trims the
overlapping buffers in place, removes the released ones, and does not
touch the others.

Claude-Session: https://claude.ai/code/session_01XvkYF2BB52ejLkuZy5WnLW

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… read

`ParquetPushDecoderBuilder::build` now releases the buffered bytes outside
the read column chunks of the queued row groups. A column chunk is read if
the output projection or a RowFilter predicate reads its column. Thus a
decoder rebuilt via `into_builder` does not keep the bytes of row groups or
columns that the new configuration skips.

`PushBuffers::retain_ranges` is implemented on top of `release_ranges`, and
`column_chunk_range` is the one mapping from a column chunk to its bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
adriangb and others added 2 commits October 8, 2026 12:02
Address review comments on apache#11235

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…variant

- Explain in the docs of `buffered_bytes` and `release_ranges` that the
  kept slices record the bytes the decoder can still read, not the
  allocated memory.
- The buffers are now always sorted (a lookup invariant), so the tests
  call `assert_invariants` and check lookups, not the internal order.
- Do not underflow on an inverted range in `update_max_len`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@adriangb

adriangb commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@alamb I think I've addressed the review and filed followups. Ready to merge from my perspective!

@alamb

alamb commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Looks good -- let's do it

@alamb
alamb enabled auto-merge October 8, 2026 17:37
@alamb

alamb commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Thank you @adriangb

@alamb
alamb added this pull request to the merge queue Oct 8, 2026
Merged via the queue into apache:main with commit 890df9d Oct 8, 2026
25 checks passed
@adriangb
adriangb deleted the claude/push-buffers-release-coalesced branch October 8, 2026 19:10
@Jefffrey Jefffrey added the bug label Oct 9, 2026
adriangb added a commit to pydantic/arrow-rs that referenced this pull request Oct 15, 2026
…of the buffer count (apache#11459)

# Which issue does this PR close?

- Closes apache#11458.

Follow-up to apache#11235, which is
merged. This PR has four commits:

1.
apache@8087cef
(new `push_decoder/prefetch` benchmark)
2.
apache@c0857e7
(binary search in `release_ranges`)
3.
apache@04faa64
(binary search in `clear_ranges`)
4.
apache@8b98d70
(`VecDeque` and `max_len_count`)

# Rationale for this change

When a caller pushes many buffers before the decoder reads them (for
example, read-ahead for many row groups), the push decoder pays
`O(buffers)` for each row group. Thus, the full scan is quadratic.

The new `push_decoder/prefetch` benchmark shows this. It pushes one
buffer for each column chunk of the file, then builds all row group
readers. Time for each iteration on the GKE runner:

| Buffers | Before | After | Change |
|--|--|--|--|
| 1,000 | 1.12 ms | 0.60 ms | 1.9x faster |
| 10,000 | 56.6 ms | 6.3 ms | 9x faster |
| 30,000 | 478 ms | 19.2 ms | 25x faster |

After this PR, the time is linear in the number of buffers.

The cost: the `VecDeque` commit makes the existing `push_decoder/1buf`
and `push_decoder/Nbuf` cases 1% to 6% slower, because an index into a
`VecDeque` costs more than an index into a `Vec`. Those cases hold few
buffers at a time, so they get no gain from this PR.

# What changes are included in this PR?

For each row group, the decoder calls `clear_ranges` and then
`release_ranges`. Each call visited all buffers:

```diff
 for each row group
   clear_ranges(requested ranges)
-    rebuild all buffers, keep those that are not requested
-    scan all buffers for max_len
+    binary search for the buffers that start at each requested range
+    remove them, move no other buffer (VecDeque)
+    scan for max_len only if no buffer of that length remains
   release_ranges(column chunk ranges)
-    scan all buffers for an overlap
+    binary search for the buffers that can overlap
```

- **`release_ranges` (commit 2):** finds the buffers that can overlap a
released range with a binary search, as `PushBuffers::find` does. The
trim and split logic does not change.
- **`clear_ranges` (commit 3):** finds the buffers to remove with a
binary search. Before, it built new buffer lists on each call. This
commit gives most of the gain.
- **`VecDeque` and `max_len_count` (commit 4):** `PushBuffers` stores
the buffers in a `VecDeque`, so a removal at the start or at the end
moves no other buffer. A count of the buffers with length `max_len` lets
a removal skip the scan that updates `max_len` while other buffers of
that length remain.

<details>
<summary>Benchmark numbers for each commit, and edge cases</summary>

`push_decoder/prefetch` on the GKE runner. Each column adds one commit:

| Buffers | Benchmark only | + `release_ranges` | + `clear_ranges` | +
`VecDeque` |
|--|--|--|--|--|
| 1,000 | 1.12 ms | 1.09 ms | 0.64 ms | 0.60 ms |
| 10,000 | 56.6 ms | 53.1 ms | 11.9 ms | 6.3 ms |
| 30,000 | 478 ms | 447 ms | 73.4 ms | 19.2 ms |

Change of the existing `1buf` and `Nbuf` cases for each commit:

| Step | Change |
|--|--|
| Control (same code on both sides) | −3% to +2% |
| + `release_ranges` | −1% to +2% |
| + `clear_ranges` | −2% to +2% |
| + `VecDeque` | +1% to +6% (slower in all seven cases) |

Runs:

- Benchmark only compared with `release_ranges`:
apache#11459 (comment)
- `release_ranges` compared with `clear_ranges`:
apache#11459 (comment)
- `clear_ranges` compared with `VecDeque`:
apache#11459 (comment)
- Control:
apache#11459 (comment)

These runs used the commits before the rebase onto `main`. The rebase
changed only the benchmark file.

Time for each `release_ranges` call, from temporary timing tests that
are not part of this PR (release build, Apple silicon):

| Buffers | Call | Before | After commit 2 |
|--|--|--|--|
| 1,000 | releases nothing | 495 ns | 55 ns |
| 10,000 | releases nothing | 3.5 µs | 67 ns |
| 100,000 | releases nothing | 37 µs | 61 ns |
| 1,000 | trims one buffer | 1.5 µs | 82 ns |
| 10,000 | trims one buffer | 8.5 µs | 91 ns |
| 100,000 | trims one buffer | 86 µs | 81 ns |

Edge cases:

- Removal in the middle: `VecDeque::drain` moves the buffers on the
shorter side of the removed buffers. Thus, only a removal at the start
or at the end moves none.
- `max_len`: it stays exact. It is computed again from all buffers only
when the last buffer of length `max_len` is removed or trimmed.
- Empty buffers: `push_range` accepts empty and inverted ranges, and
`release_ranges` removes all of them when it releases bytes. To keep
this behavior without a visit of all buffers, `PushBuffers` counts the
empty buffers. `release_ranges` looks at all buffers only if the count
is not zero.
- Sort order: a trim moves the start of a buffer only past released
bytes, so the trimmed buffers stay in order. Only split parts can be out
of order, and the sort runs only after a split.
- Buffers with the same start: they are adjacent but not sorted by end.
`clear_ranges` checks each of them for an exact match.

</details>

# Are these changes tested?

Yes. The existing tests cover this, in particular
`fuzz_release_ranges_matches_rebuild`, which compares `release_ranges`
with a full rebuild, and `fuzz_matches_a_linear_scan`, which includes
random calls to `clear_ranges`. The new test
`clear_ranges_with_buffers_of_the_same_start` covers buffers that share
a start and ranges that are given more than once. `assert_invariants`
now also checks the count of empty buffers and the count of buffers with
length `max_len`.

# Are there any user-facing changes?

No. All changes are in `pub(crate)` functions and private fields, so a
revert is safe if a regression shows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug parquet Changes to the parquet crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ParquetPushDecoder does not release pushed buffers that are larger than the requested ranges PushBuffers::clear_ranges is quadratic and leaks

5 participants