Skip to content

perf(parquet): reuse MaskSelection's cached selectors when converting to selectors - #10443

Open
haohuaijin wants to merge 3 commits into
apache:mainfrom
haohuaijin:reuse-mask-selector-cache
Open

perf(parquet): reuse MaskSelection's cached selectors when converting to selectors#10443
haohuaijin wants to merge 3 commits into
apache:mainfrom
haohuaijin:reuse-mask-selector-cache

Conversation

@haohuaijin

@haohuaijin haohuaijin commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Follow up to #10141. RowSelection::iter() caches the RLE form of a mask-backed selection, but the conversions to RowSelectors ignored that cache and re-encoded the bitmap, so a caller that iterates a selection and then consumes it encodes the whole bitmap twice.

What changes are included in this PR?

  • MaskSelection::into_selectors takes the cached Vec when it was populated and otherwise falls back to mask_to_selectors. Used by RowSelection::into_selectors_vec (which backs From<RowSelection> for Vec<RowSelector> and VecDeque<RowSelector>) and by build_cursor.
  • MaskSelection::borrowed_selectors does the same for intersection and union, returning Cow::Borrowed when the cache is populated and converting into a temporary when it is not, rather than populating the cache of a selection the caller still owns.
  • mask_to_selectors has no callers outside the selection module now, so its re-export is dropped.

Are these changes tested?

Four new unit tests in arrow_reader::selection::boolean cover the cache being moved out rather than re-encoded (asserted on the Vec's pointer), the cold-cache fallback, borrowed_selectors in both states, and intersection/union agreeing either way. cargo test -p parquet --all-features passes.

row_selector gains two scenarios over a mask-backed selection of 300k rows: mask_iterate_then_consume iterates and then converts, as ParquetAccessPlan::into_overall_row_selection does, and mask_consume converts with a cold cache.

cargo bench -p parquet --bench row_selector -- mask_
benchmark main this PR change
mask_iterate_then_consume/run01 2.237 ms 1.202 ms -46.5%
mask_iterate_then_consume/run04 272.5 µs 132.4 µs -51.4%
mask_iterate_then_consume/run16 73.7 µs 32.5 µs -55.9%
mask_iterate_then_consume/run64 26.0 µs 13.6 µs -47.8%
mask_iterate_then_consume/random 784.1 µs 404.1 µs -48.7%

mask_consume is unchanged: its medians over three runs per side land within 1.5% of main, inside the run-to-run spread main shows on its own.

Are there any user-facing changes?

No, the added methods are internal.

…ng to selectors

`RowSelection::iter()` lazily materializes and caches the RLE form of a
mask-backed selection, but every path that converted such a selection into
`RowSelector`s ignored that cache and re-encoded the bitmap.

Add `MaskSelection::into_selectors`, which takes the `OnceLock` value when it
was populated and otherwise falls back to `mask_to_selectors`, and use it from
`RowSelection::into_selectors_vec` (backing `From<RowSelection>` for both
`Vec<RowSelector>` and `VecDeque<RowSelector>`) and from `build_cursor` when
lowering a mask-backed selection to the selector cursor.

`intersection`/`union` reuse the cache too, via `borrowed_selectors`, which
borrows it when populated and otherwise converts into a temporary rather than
populating the cache of a selection the caller still owns.

`mask_to_selectors` no longer has callers outside the `selection` module, so
drop its `pub(crate)` re-export.

Benchmarked with the new `mask_iterate_then_consume` /`mask_consume` cases in
`row_selector`, which cover a caller that iterates a selection before consuming
it, as `ParquetAccessPlan::into_overall_row_selection` does. Over 300k rows the
iterate-then-consume conversion drops 46-56% across run lengths, and the
cold-cache conversion is unchanged.
@github-actions github-actions Bot added the parquet Changes to the parquet crate label Jul 27, 2026
@haohuaijin

Copy link
Copy Markdown
Contributor Author

cc @alamb @hhhizzz

@hhhizzz

hhhizzz commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

cc @alamb @hhhizzz

I'll take a look later today.

@hhhizzz

hhhizzz commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Thanks for working on this optimization. I independently reproduced the substantial warm-cache improvement, and the cache-reuse approach looks effective for the intended iterate-then-consume path. 🚀

I did, however, find a clear and reproducible regression in the cold-cache mask_consume/run64 case: the PR is approximately 11.4% slower than the base on my test machine.

The absolute difference is small—about 1.6 µs per conversion of a 300k-row mask—so this may not be a serious real-world regression, and it does not invalidate the main improvement. However, the result is stable and contradicts the PR's statement that all cold-cache cases remain within 1.5% of main. I think it is worth understanding the cause before concluding that the cold path is unchanged.

Could you please try reproducing this case in your environment?

Details

Reproduction setup

  • base: 8e7043bf937b60e3be8586ceb3cd00349b989e1e
  • PR head: 20eb12415e62f86cf31883cc637c09ed304a8896
  • x86_64 Linux
  • Rust 1.96.1
  • CPU pinned to one core
  • separate Cargo target directories for base and head
  • copied only the PR's parquet/benches/row_selector.rs to the base worktree, so both sides used the same benchmark harness
  • no implementation code was copied
  • alternating base/head execution order
  • no Criterion sample-time warnings

First, I ran the complete mask_ group three times per side:

cargo bench -p parquet --bench row_selector -- mask_

Criterion settings:

warm-up time: 1s
measurement time: 2s
sample size: 20

The cold-cache geometric means were:

benchmark base PR change
mask_consume/run01 638.630 µs 639.072 µs +0.07%
mask_consume/run04 167.478 µs 167.519 µs +0.02%
mask_consume/run16 48.721 µs 48.971 µs +0.51%
mask_consume/run64 14.391 µs 16.071 µs +11.67%
mask_consume/random 288.669 µs 289.050 µs +0.13%

I then isolated mask_consume/run64 and ran five alternating repetitions per side with a longer measurement time:

cargo bench -p parquet --bench row_selector -- 'mask_consume/run64'

Focused Criterion settings:

warm-up time: 1s
measurement time: 3s
sample size: 30

Focused result:

  • base geometric mean: 14.427 µs
  • PR geometric mean: 16.070 µs
  • change: +11.39%

The values were stable across all five repetitions. As a small diagnostic, adding #[inline] to MaskSelection::into_selectors did not remove the regression, so it does not appear to be caused solely by an additional non-inlined function call.

The main warm-cache improvement was reproduced and remains substantial. My concern is specifically the unexpected cold run64 behavior. It may be useful to test neighboring run lengths such as 32, 48, 96, and 128 to determine whether this is related to vector capacity, allocation behavior, or another boundary effect.

@haohuaijin

haohuaijin commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. I reran the comparison three times on Apple M4; the results are attached. run64 remained between approximately -0.6% and +2.5%. run01 showed a larger regression in one run, but it was not reproduced in the other two. Since the cold path still uses the same mask_to_selectors implementation, the variation may be related to system noise, CPU frequency changes, or compiler code-layout differences.

image

@hhhizzz

hhhizzz commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Thanks. I reran the comparison three times on Apple M4; the results are attached. run64 remained between approximately -0.6% and +2.5%. run01 showed a larger regression in one run, but it was not reproduced in the other two. Since the cold path still uses the same mask_to_selectors implementation, the variation may be related to system noise, CPU frequency changes, or compiler code-layout differences.

Thanks for checking this on Mac. I profiled the regression on an AMD EPYC 9254 (Zen 4) and reran every input as a separately compiled single-case benchmark.

With three alternating rounds:

  • mask_iterate_then_consume: all 9 cases improved by 49%–86%.
  • Cold mask_consume: no repeatable regression; run32/64/96/128 improved by 2%–7%, while the others were effectively unchanged.

The original run128 regression appears to be an x86_64 code-layout/alignment artifact. mask_to_selectors has identical machine code and retired instruction counts, but its address relative to a 32-byte boundary changes when the benchmark case list changes. Changing the layout also flips the performance result.

So I don’t think the previously reported cold-path regression is caused by this PR or should block it. The cold microbenchmark is simply quite sensitive to binary layout on this Zen 4 machine.

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

Looks good to me. Thank you!

hhhizzz

This comment was marked as duplicate.

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

Labels

parquet Changes to the parquet crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reuse MaskSelection's cached selectors when converting a mask-backed RowSelection to selectors

2 participants