Skip to content

feat(parquet): RowSelection can be backed by a BooleanBuffer#10141

Open
haohuaijin wants to merge 31 commits into
apache:mainfrom
haohuaijin:export-mask
Open

feat(parquet): RowSelection can be backed by a BooleanBuffer#10141
haohuaijin wants to merge 31 commits into
apache:mainfrom
haohuaijin:export-mask

Conversation

@haohuaijin

@haohuaijin haohuaijin commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

RowSelection currently stores selections as Vec<RowSelector> (16 bytes per selector). This is compact for long runs, but expensive for scattered matches. With ~35% isolated single-row hits, it uses about 11.2 bytes per input row. A BooleanBuffer uses 1 bit per input row, about 90x less memory.

The reader can also choose the Mask strategy, which converts selectors back into a bitmap. When the caller already had a bitmap, this conversion round-trip is unnecessary.

This PR lets RowSelection preserve a caller-provided bitmap and pass it directly to mask execution.

This is not intended to claim broad DataFusion / TPC-DS / ClickBench speedups. Current common DataFusion SQL paths generally do not naturally produce bitmap-backed RowSelections. The practical benefit is for integrations that already have a row-level bitmap and need Parquet to consume it without materializing a large selector list.

What changes are included in this PR?

RowSelection can now be backed by either Vec<RowSelector> or BooleanBuffer. New public construction:

pub fn RowSelection::from_boolean_buffer(mask: BooleanBuffer) -> Self;
impl From<BooleanBuffer> for RowSelection;

Methods that can work directly on the bitmap now do so:

  • iter() still returns &RowSelector (non-breaking); mask-backed selections lazily materialize a selector cache on first call, while internal hot paths bypass it and use the BooleanBuffer / MaskRunIter directly
  • row_count / skipped_row_count use a cached popcount
  • selects_any uses set_indices().next()
  • trim preserves mask backing via BooleanBuffer::slice
  • intersection / union on Mask+Mask use BitAnd / BitOr
  • split_off on a mask uses BooleanBuffer::slice (O(1), both halves stay mask-backed)
  • limit slices at the selected-row boundary via find_nth_set_bit_position, staying mask-backed
  • offset finds the first selected row to keep via find_nth_set_bit_position and rebuilds only the mask buffer, avoiding selector materialization
  • and_then applies the inner selection over the mask's set positions, returning a mask-backed result
  • FromIterator<RowSelection> concatenates BooleanBuffers when every input is mask-backed

Mixed inputs, and existing selector-backed inputs, still use the existing selector helpers. Existing callers keep the same behavior.

The reader (ReadPlanBuilder::build) passes a mask-backed selection straight to RowSelectionCursor::new_mask_from_buffer, so it skips rebuilding the bitmap from selectors.

Auto resolution works directly on the bitmap (early-exit run counting), without converting the backing.

Integrates with #10288: both mask cursor constructors carry LoadedRowRanges, so a caller-provided bitmap stays within loaded pages when page pruning skips pages.

Also adds MaskRunIter + RowSelection::as_mask for zero-allocation RLE iteration over a mask, the row_selector_boolean_buffer benchmark, and read_auto / mask-backed input modes in row_selection_cursor.

Are these changes tested?

Yes. This PR extends the existing RowSelection unit tests with coverage for:

  • constructing from BooleanBuffer, including empty and all-unset masks
  • From<BooleanBuffer>
  • preserving mask backing across clone, split_off, limit, offset, and_then, and all-mask FromIterator<RowSelection>
  • falling back to selector backing for mixed-backed concatenation
  • equality between equivalent selector-backed and mask-backed selections
  • mask-backed intersection / union, including uneven-length inputs
  • fuzz-style equivalence between mask-backed selections and the existing from_filters selector path
  • sparse-page regression tests from a non-byte-aligned BooleanBuffer::slice(...), under both Mask and Auto policies (fix(parquet): support mask filtering across skipped pages #10288 integration seam)
  • mask chunks stop at LoadedRowRanges boundaries
  • fuzz tests for boolean_mask_from_selectors and trim_mask (including non-zero offsets)
  • remaining branches checked with cargo llvm-cov

Are there any user-facing changes?

No breaking API changes. New public APIs: RowSelection::from_boolean_buffer, From<BooleanBuffer>, RowSelection::as_mask, MaskRunIter.

@github-actions github-actions Bot added the parquet Changes to the parquet crate label Jun 14, 2026
@alamb

alamb commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

@haohuaijin

Copy link
Copy Markdown
Contributor Author

Thanks @alamb for the pointers. I looked at #6624, #7454, and the work that landed in #8733.

My understanding is:

This does not replace selector-backed selections. Selectors are still better for clustered/page-index-style selections.

The case this helps is when the caller already has a row-level bitmap, such as from an external index, FTS index, or bitmap index. Today that bitmap has to go through BooleanBuffer -> Vec<RowSelector> -> mask in some paths. Keeping the original BooleanBuffer avoids that extra conversion and avoids building a large selector list for fragmented masks.

So I see this as a small representation-layer improvement that complements #8733. #8733 added mask execution; this PR adds a direct bitmap-backed input path for it. It does not try to implement the broader adaptive predicate-pushdown/page-cache design from #7454.

cc @hhhizzz @XiangpengHao @zhuqi-lucas

@alamb

alamb commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

run benchmark arrow_reader arrow_reader_row_filter

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c4783293361-635-hxxkf 6.12.68+ #1 SMP Sat May 2 07:49:07 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
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:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
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

Comparing export-mask (1720537) to 2e035fd (merge-base) diff
BENCH_NAME=arrow_reader
BENCH_COMMAND=cargo bench --features=arrow,async,test_common,experimental,object_store --bench arrow_reader
BENCH_FILTER=
Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c4783293361-636-bs95k 6.12.68+ #1 SMP Sat May 2 07:49:07 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
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:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
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

Comparing export-mask (1720537) to 2e035fd (merge-base) diff
BENCH_NAME=arrow_reader_row_filter
BENCH_COMMAND=cargo bench --features=arrow,async,test_common,experimental,object_store --bench arrow_reader_row_filter
BENCH_FILTER=
Results will be posted here when complete


File an issue against this benchmark runner

@alamb alamb added performance api-change Changes to the arrow API labels Jun 23, 2026
@alamb

alamb commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

run benchmark arrow_reader_clickbench

@alamb

alamb commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Merging up to fix CI

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c4783400488-637-cprmq 6.12.68+ #1 SMP Sat May 2 07:49:07 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
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:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
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

Comparing export-mask (7b102f4) to 6ba533d (merge-base) diff
BENCH_NAME=arrow_reader_clickbench
BENCH_COMMAND=cargo bench --features=arrow,async,test_common,experimental,object_store --bench arrow_reader_clickbench
BENCH_FILTER=
Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
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:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
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
Details

group                                                                                              export-mask                            main
-----                                                                                              -----------                            ----
arrow_reader_row_filter/float64 <= 99.0/all_columns/async                                          1.02      5.4±0.03ms        ? ?/sec    1.00      5.3±0.03ms        ? ?/sec
arrow_reader_row_filter/float64 <= 99.0/all_columns/sync                                           1.01      5.2±0.04ms        ? ?/sec    1.00      5.2±0.06ms        ? ?/sec
arrow_reader_row_filter/float64 <= 99.0/exclude_filter_column/async                                1.01      4.6±0.04ms        ? ?/sec    1.00      4.6±0.03ms        ? ?/sec
arrow_reader_row_filter/float64 <= 99.0/exclude_filter_column/sync                                 1.00      4.5±0.04ms        ? ?/sec    1.00      4.5±0.05ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0 AND ts >= 9000/all_columns/async                            1.01      4.2±0.04ms        ? ?/sec    1.00      4.2±0.04ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0 AND ts >= 9000/all_columns/sync                             1.01      4.5±0.04ms        ? ?/sec    1.00      4.5±0.04ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0 AND ts >= 9000/exclude_filter_column/async                  1.02      3.7±0.03ms        ? ?/sec    1.00      3.7±0.01ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0 AND ts >= 9000/exclude_filter_column/sync                   1.01      3.7±0.04ms        ? ?/sec    1.00      3.7±0.03ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0/all_columns/async                                           1.01      5.4±0.03ms        ? ?/sec    1.00      5.3±0.03ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0/all_columns/sync                                            1.01      5.2±0.05ms        ? ?/sec    1.00      5.2±0.05ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0/exclude_filter_column/async                                 1.00      4.6±0.03ms        ? ?/sec    1.00      4.6±0.03ms        ? ?/sec
arrow_reader_row_filter/float64 > 99.0/exclude_filter_column/sync                                  1.00      4.5±0.03ms        ? ?/sec    1.00      4.5±0.04ms        ? ?/sec
arrow_reader_row_filter/int64 == 9999/all_columns/async                                            1.02    897.9±2.45µs        ? ?/sec    1.00    878.6±2.32µs        ? ?/sec
arrow_reader_row_filter/int64 == 9999/all_columns/sync                                             1.00      2.2±0.02ms        ? ?/sec    1.00      2.2±0.02ms        ? ?/sec
arrow_reader_row_filter/int64 == 9999/exclude_filter_column/async                                  1.03    822.5±2.61µs        ? ?/sec    1.00    798.2±2.02µs        ? ?/sec
arrow_reader_row_filter/int64 == 9999/exclude_filter_column/sync                                   1.00      2.1±0.02ms        ? ?/sec    1.00      2.1±0.02ms        ? ?/sec
arrow_reader_row_filter/int64 > 90/all_columns/async                                               1.09      8.3±0.12ms        ? ?/sec    1.00      7.6±0.05ms        ? ?/sec
arrow_reader_row_filter/int64 > 90/all_columns/sync                                                1.01      6.9±0.06ms        ? ?/sec    1.00      6.8±0.04ms        ? ?/sec
arrow_reader_row_filter/int64 > 90/exclude_filter_column/async                                     1.08      7.7±0.12ms        ? ?/sec    1.00      7.2±0.05ms        ? ?/sec
arrow_reader_row_filter/int64 > 90/exclude_filter_column/sync                                      1.01      6.4±0.05ms        ? ?/sec    1.00      6.3±0.04ms        ? ?/sec
arrow_reader_row_filter/ts < 9000/all_columns/async                                                1.01      5.0±0.04ms        ? ?/sec    1.00      5.0±0.04ms        ? ?/sec
arrow_reader_row_filter/ts < 9000/all_columns/sync                                                 1.00      5.3±0.04ms        ? ?/sec    1.00      5.3±0.04ms        ? ?/sec
arrow_reader_row_filter/ts < 9000/exclude_filter_column/async                                      1.00      4.5±0.04ms        ? ?/sec    1.01      4.6±0.03ms        ? ?/sec
arrow_reader_row_filter/ts < 9000/exclude_filter_column/sync                                       1.01      4.5±0.04ms        ? ?/sec    1.00      4.5±0.04ms        ? ?/sec
arrow_reader_row_filter/ts >= 9000/all_columns/async                                               1.00      3.4±0.03ms        ? ?/sec    1.00      3.4±0.03ms        ? ?/sec
arrow_reader_row_filter/ts >= 9000/all_columns/sync                                                1.00      3.6±0.03ms        ? ?/sec    1.01      3.6±0.02ms        ? ?/sec
arrow_reader_row_filter/ts >= 9000/exclude_filter_column/async                                     1.00      3.1±0.02ms        ? ?/sec    1.02      3.1±0.03ms        ? ?/sec
arrow_reader_row_filter/ts >= 9000/exclude_filter_column/sync                                      1.00      3.2±0.03ms        ? ?/sec    1.00      3.2±0.03ms        ? ?/sec
arrow_reader_row_filter/utf8View <> ''/all_columns/async                                           1.06      9.1±0.08ms        ? ?/sec    1.00      8.6±0.06ms        ? ?/sec
arrow_reader_row_filter/utf8View <> ''/all_columns/sync                                            1.01      9.4±0.06ms        ? ?/sec    1.00      9.3±0.06ms        ? ?/sec
arrow_reader_row_filter/utf8View <> ''/exclude_filter_column/async                                 1.06      7.6±0.07ms        ? ?/sec    1.00      7.2±0.04ms        ? ?/sec
arrow_reader_row_filter/utf8View <> ''/exclude_filter_column/sync                                  1.00      6.4±0.03ms        ? ?/sec    1.00      6.4±0.04ms        ? ?/sec
arrow_reader_row_filter_limit/float64 <= 99.0/all_columns/limit10/async                            1.03    347.8±1.64µs        ? ?/sec    1.00    336.2±0.81µs        ? ?/sec
arrow_reader_row_filter_limit/float64 <= 99.0/exclude_filter_column/limit10/async                  1.05    344.0±1.71µs        ? ?/sec    1.00    326.9±0.57µs        ? ?/sec
arrow_reader_row_filter_limit/float64 > 99.0 AND ts >= 9000/all_columns/limit10/async              1.03    384.5±1.68µs        ? ?/sec    1.00    374.1±1.44µs        ? ?/sec
arrow_reader_row_filter_limit/float64 > 99.0 AND ts >= 9000/exclude_filter_column/limit10/async    1.04    371.2±2.32µs        ? ?/sec    1.00    356.9±1.02µs        ? ?/sec
arrow_reader_row_filter_limit/float64 > 99.0/all_columns/limit10/async                             1.03    348.1±1.56µs        ? ?/sec    1.00    337.2±1.29µs        ? ?/sec
arrow_reader_row_filter_limit/float64 > 99.0/exclude_filter_column/limit10/async                   1.05    345.4±1.73µs        ? ?/sec    1.00    327.9±0.97µs        ? ?/sec
arrow_reader_row_filter_limit/int64 > 90/all_columns/limit10/async                                 1.03    341.7±2.09µs        ? ?/sec    1.00    330.5±1.30µs        ? ?/sec
arrow_reader_row_filter_limit/int64 > 90/exclude_filter_column/limit10/async                       1.04    336.2±1.81µs        ? ?/sec    1.00    324.6±1.56µs        ? ?/sec
arrow_reader_row_filter_limit/ts < 9000/all_columns/limit10/async                                  1.04    338.6±1.61µs        ? ?/sec    1.00    326.5±1.17µs        ? ?/sec
arrow_reader_row_filter_limit/ts < 9000/exclude_filter_column/limit10/async                        1.05    335.8±1.76µs        ? ?/sec    1.00    321.1±1.14µs        ? ?/sec
arrow_reader_row_filter_limit/ts >= 9000/all_columns/limit10/async                                 1.03    354.8±1.67µs        ? ?/sec    1.00    344.6±1.16µs        ? ?/sec
arrow_reader_row_filter_limit/ts >= 9000/exclude_filter_column/limit10/async                       1.05    349.7±1.31µs        ? ?/sec    1.00    334.1±0.97µs        ? ?/sec
arrow_reader_row_filter_limit/utf8View <> ''/all_columns/limit10/async                             1.06    373.9±1.52µs        ? ?/sec    1.00    353.8±0.99µs        ? ?/sec
arrow_reader_row_filter_limit/utf8View <> ''/exclude_filter_column/limit10/async                   1.03    353.8±1.27µs        ? ?/sec    1.00    343.5±0.93µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 480.1s
Peak memory 45.0 MiB
Avg memory 30.9 MiB
CPU user 473.1s
CPU sys 0.1s
Peak spill 0 B

branch

Metric Value
Wall time 470.1s
Peak memory 44.8 MiB
Avg memory 31.1 MiB
CPU user 467.5s
CPU sys 0.0s
Peak spill 0 B

File an issue against this benchmark runner

@alamb

alamb commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

run benchmark row_selector row_selection_cursor

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

Thank you @haohuaijin . I think this is totally the right direction and I really like where this is going

As long as this doesn't cause a performance regression I think it is good to go.

I have actually tried this myself (see #6624) as did @XiangpengHao in #6624.

Some other thoughts

  1. We need to make sure it doesn't cause a performance regression (I can help here)
  2. It currently has a public API change, so we can't merge it until main opens for the next major release (likely in mid-late July). If we can figure out how to keep the API the same we could potentially release it sooner

I haven' tmade it through the entire PR yet, but I did fire off some benchmarks


#[inline]
fn next(&mut self) -> Option<RowSelector> {
match self {

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.

In general I think this will add a branch in the main loops - every chunk of bits will require matching -- it might make sense to figure out how to have iterators for the two different types, so that code paths that want to iterate over the selection can match outside

match iter { 
  RowSelectionIter::Selectors(iter) => {  for i in iter ... }, // special loop for selector backed
  RowSelectionIter::Mask(iter) => {  for i in iter ... }, // special loop for mask backed
}

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.

updated in fff3ab3

if self.finished {
return None;
}
match self.slices.next() {

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.

Much of this code appears untested

I ran code coverage like

  cargo llvm-cov --html test -p parquet --features arrow
Image

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.

added in fff3ab3

impl Iterator for MaskRunIter<'_> {
type Item = RowSelector;

fn next(&mut self) -> Option<RowSelector> {

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 is the main API that is not compatible -- it need to return &RowSelector -- if we could change that we can merge this PR before the next major release (with breaking API changes)

@haohuaijin haohuaijin Jun 24, 2026

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 updated RowSelection::iter() to keep returning Iterator<Item = &RowSelector>.

  • For selector-backed selections this still borrows from the internal Vec<RowSelector>.
  • For mask-backed selections I added a lazy selector cache, because the returned &RowSelector values need stable storage inside RowSelection; otherwise they would point into a temporary Vec.

The internal hot paths still avoid this cache and use the BooleanBuffer or MaskRunIter directly.

the struct is below

pub struct RowSelection {
    inner: RowSelectionInner,
}

pub(crate) enum RowSelectionInner {
    Selectors(Vec<RowSelector>),
    Mask(Box<MaskSelection>),
}

pub(crate) struct MaskSelection {
    mask: BooleanBuffer,
    selectors: OnceLock<Vec<RowSelector>>,
}

I am not fully sure this is the best approach, so suggestions and feedback are welcome.

if let (Some(l), Some(r)) = (self.as_mask(), other.as_mask()) {
return Self::from_boolean_buffer(intersect_masks(l, r));
}
let l = self.materialize_for_combine();

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 is a clever idea -- as a follow on we could potentially implement specialized versions of each of these implementations

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.

for example, I think and_then can use some of the fancy BMI instructions that @devanbenz is investigating in #10136

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.

agree

// Verify that the size of RowGroupDecoderState does not grow too large
fn test_structure_size() {
assert_eq!(std::mem::size_of::<RowGroupDecoderState>(), 232);
assert_eq!(std::mem::size_of::<RowGroupDecoderState>(), 256);

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 is bigger because a RowSelection is now bigger.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c4783482356-640-s7mgp 6.12.68+ #1 SMP Sat May 2 07:49:07 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
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:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
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

Comparing export-mask (7b102f4) to 6ba533d (merge-base) diff
BENCH_NAME=row_selection_cursor
BENCH_COMMAND=cargo bench --features=arrow,async,test_common,experimental,object_store --bench row_selection_cursor
BENCH_FILTER=
Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c4783482356-639-b2k9m 6.12.68+ #1 SMP Sat May 2 07:49:07 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
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:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
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

Comparing export-mask (7b102f4) to 6ba533d (merge-base) diff
BENCH_NAME=row_selector
BENCH_COMMAND=cargo bench --features=arrow,async,test_common,experimental,object_store --bench row_selector
BENCH_FILTER=
Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
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:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
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
Details

group           export-mask                            main
-----           -----------                            ----
and_then        1.48   862.5±15.70µs        ? ?/sec    1.00    581.9±2.03µs        ? ?/sec
from_filters    1.00    370.2±1.96µs        ? ?/sec    1.01    372.2±5.17µs        ? ?/sec
intersection    2.03      2.7±0.02ms        ? ?/sec    1.00   1338.6±2.46µs        ? ?/sec
union           2.38      3.1±0.03ms        ? ?/sec    1.00   1294.3±3.77µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 50.0s
Peak memory 14.0 MiB
Avg memory 10.4 MiB
CPU user 44.6s
CPU sys 0.0s
Peak spill 0 B

branch

Metric Value
Wall time 45.0s
Peak memory 13.5 MiB
Avg memory 10.8 MiB
CPU user 30.9s
CPU sys 9.1s
Peak spill 0 B

File an issue against this benchmark runner

@alamb

alamb commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

🤔 looks like arrow_reader_row_filter is slightly slower. Rerunning to try an reproduce

@alamb

alamb commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

run benchmark arrow_reader_row_filter

@hhhizzz

hhhizzz commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

I reran one representative point from row_selection_cursor on the current
head (8c217d8) against the merge-base (ee30b61), using five alternating
current/base runs on a pinned CPU.

For uniform50/L32:

  • forced Selectors: +7.67% paired geometric mean
  • forced Mask: +2.80%

All five pairs moved in the same direction and Criterion produced no warnings.

However, I want to be precise about the interpretation: this benchmark forces
the two policies in order to map their crossover; it does not exercise Auto.
Therefore these numbers are a signal that the underlying forced paths became
slower at L32, not evidence that the Auto decision or threshold is wrong. A
single point is also not enough to establish a broad regression.

Could we rerun the complete L4–L40 curve in row_selection_cursor on the current head and compare:

  1. the Selector/Mask crossover against the merge-base;
  2. the strategy selected by Auto at each point; and
  3. Auto's runtime against the faster forced strategy?

Since this PR adds a second input representation, it would be particularly
useful to run the matrix for both selector-backed and mask-backed
RowSelections. That would show whether Auto should use a different threshold
or cost model when choosing a strategy requires first converting the backing
representation.

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

The direct bitmap path looks valuable, especially for fragmented selections,
but I found several performance issues that seem worth addressing:

  1. a reproducible regression in existing selector/mask cursor paths; (Addressed in last comment)
  2. dense-mask trimming enumerating every selected bit;
  3. repeated full popcounts across row groups;
  4. cached selectors being materialized a second time;
  5. loss of slice-iterator optimizations; and
  6. mixed representations eagerly expanding fragmented masks back to RLE.

The first three look blocking to me; the remaining ones should at least be
resolved or explicitly scoped as follow-ups with benchmark coverage.

Comment thread parquet/src/arrow/arrow_reader/selection/boolean.rs
Comment thread parquet/src/arrow/arrow_reader/selection/mod.rs
self.iter().copied().collect()
}

fn into_selectors_vec(self) -> Vec<RowSelector> {

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.

Could we consume the lazy selector cache here instead of always materializing
the mask again?

RowSelection::iter() populates MaskSelection::selectors, but
into_selectors_vec() ignores that cache and calls mask_to_selectors() a
second time. Current DataFusion does exactly this sequence in
ParquetAccessPlan::into_overall_row_selection: it first calls iter() for
validation and then consumes the same selection with selection.into().

For an alternating 3M-row mask, each selector Vec has a 64 MiB capacity. This
path therefore performs a second full scan and can temporarily retain about
128 MiB of selector storage.

Could MaskSelection provide an owning into_selectors() that uses
OnceLock::into_inner() when initialized, and share it with the cursor
conversion path?

type Item = &'a RowSelector;

#[inline]
fn next(&mut self) -> Option<Self::Item> {

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.

Could RowSelectionIter preserve the underlying slice iterator's optimized
methods?

The previous iter() implementation delegated directly to slice::Iter, so
its exact size_hint and specialized nth / count implementations were used.
This wrapper only forwards next(), which falls back to (0, None) for
size_hint; consequently, existing selector-backed code such as
selection.iter().copied().collect::<Vec<_>>() now repeatedly grows and copies
the Vec, while count() and nth() become element-by-element walks.

Since both backing types expose a slice iterator after any lazy materialization,
could this delegate size_hint, nth, count, and last, and ideally
implement ExactSizeIterator (plus DoubleEndedIterator / FusedIterator)?

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 also came up with claude code in my review

I think it would basically look like

impl<'a> Iterator for RowSelectionIter<'a> {
    type Item = &'a RowSelector;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        self.0.next()
    }

    #[inline]
    fn size_hint(&self) -> (usize, Option<usize>) {
        self.0.size_hint()
    }
}

impl ExactSizeIterator for RowSelectionIter<'_> {}

// once it returns None, it will continue returning None
impl std::iter::FusedIterator for RowSelectionIter<'_> {}

(RowSelectionInner::Selectors(l), RowSelectionInner::Selectors(r)) => {
intersect_row_selections(l, r)
}
(RowSelectionInner::Selectors(l), RowSelectionInner::Mask(r)) => {

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.

Could the mixed-representation path avoid eagerly converting the entire mask
with mask_to_selectors? The same issue also applies to the mixed union arms
below.

A natural target use case is an externally supplied fragmented bitmap composed
with selector-backed page-index pruning. DataFusion's
ParquetAccessPlan::scan_selection performs exactly this kind of
intersection. An alternating 3M-row mask can therefore materialize up to 3M
RowSelectors before reading begins, reintroducing the memory cost this PR is
intended to avoid.

Could this either stream the mixed operation, or choose which side to convert
based on the two representations' shapes and preserve mask backing when that is
cheaper? A mixed bitmap + page-selection benchmark would also protect this
case.

- rewrite boolean_mask_from_selectors to fill bitmap bytes directly,
  fixing a 3-11% read regression caused by append_n no longer being
  inlined on this hot path
- trim_mask: fast-path a set final bit and reverse-scan bytes for the
  last set bit instead of walking every selected bit
- cache the selected-row count in MaskSelection and propagate it
  through split_off/trim/offset/limit so the row-group frontier no
  longer rescans the shrinking bitmap tail per row group
Runs every matrix point with RowSelectionPolicy::default() (read_auto)
in addition to the two forced policies, and repeats all modes with a
mask-backed RowSelection as input (*_mask_backed groups). Selector-backed
groups keep their historical names so existing baselines stay comparable.
@haohuaijin

haohuaijin commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @hhhizzz — I verified all six findings and they are accurate. The three blocking items are fixed in 496d7cb; 325d774 extends the row_selection_cursor bench with a read_auto mode and mask-backed inputs.

1. Regression — fixed

Profiling showed the regression was entirely in the selector→bitmap conversion: after the module split, LLVM stopped inlining BooleanBufferBuilder::append_n there (self time 3.21% → 5.43%, matching the ~2% regression); the decode loop profiles identically on both sides. boolean_mask_from_selectors now fills the bitmap bytes directly instead of calling append_n per selector, with a fuzz test asserting bit-exact equivalence.

L4–L40 matrix, 10141-auto 325d774 vs main ee30b61 (medians, Apple M2 Max):

image

Both forced paths are now faster than the main at every point, and the Selectors/Mask crossover (between L12 and L16 on this machine) is unchanged by this PR.

2. Dense-mask trimming — fixed

trim_mask no longer popcounts or walks set bits: it fast-paths a set final bit, otherwise reverse-scans bytes for the last set bit. Near-dense masks are now O(1) to trim. Fuzz-tested including sliced (non-zero offset) masks.

3. Repeated popcounts — fixed

MaskSelection caches the selected count in a OnceLock<usize>. split_off popcounts only the head and derives the tail by subtraction, so the row-group frontier is O(bitmap) total instead of O(row_groups × bitmap); trim/offset/limit propagate the cache, and Clone keeps it so peek_next_row_group's frontier clone answers row_count() for free. Unit tests cover the propagation.

4–6

These are valid findings as well. To keep this PR from growing too large I'd like to address them in follow-up PRs, and I'll file issues after this pr merge for them so they don't get lost. While doing that I also think we should split up selection/mod.rs — it has grown past 2000 lines and mixes the selection type, the set-algebra helpers, and the cursor machinery.

@haohuaijin

Copy link
Copy Markdown
Contributor Author

Could we rerun the complete L4–L40 curve in row_selection_cursor on the current head and compare:

  1. the Selector/Mask crossover against the merge-base;
  2. the strategy selected by Auto at each point; and
  3. Auto's runtime against the faster forced strategy?

Since this PR adds a second input representation, it would be particularly
useful to run the matrix for both selector-backed and mask-backed
RowSelections. That would show whether Auto should use a different threshold
or cost model when choosing a strategy requires first converting the backing
representation.

the command for below figure benchmark that 325d774 added the bitmap input representation into row_selection_cursor

cargo bench -p parquet --bench row_selection_cursor --features arrow,test_common -- "len-uniform50-uniform50" --save-baseline 10141-auto

# selector-backed input
critcmp 10141-auto -g '.*?/(.*)' -f "read_(auto|selector|mask)/len-uniform50-uniform50"

# mask-backed input
critcmp 10141-auto -g '.*?/(.*)' -f "mask_backed/len-uniform50-uniform50"
image

@hhhizzz

hhhizzz commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

I reran the forced-Selectors benchmark on an AMD EPYC 9254 using the exact same historical 2-mode benchmark source for both cc83f7e and 6c98404 (5 alternating pairs, CPU pinned). The regression still reproduces: L32 is +2.49% and L40 is +5.58%.

I then split L40 into selection clone, reader creation, read-plan build, and reader drain. Clone (-3.06%), try_new (-2.99%), and build (-0.70%) do not regress; the difference is entirely in drain, at roughly +5% after excluding one noisy round.

However, this does not appear to be additional work introduced by the selector cursor. The selector hot path is unchanged, and the normalized ParquetRecordBatchReader::next machine-code stream is identical on both revisions (732 instructions). The result is also highly codegen-sensitive: a debug/frame-pointer build reverses the result, and with codegen-units=1 the stable gap disappears. Forcing function alignment changes it again. I also tried adding #[inline] to the affected read_records helper; that restored the base-like symbol layout but made performance another 2.25% worse.

My conclusion is that the residual forced-Selectors difference is a default multi-CGU inlining/code-layout artifact rather than a cursor algorithm regression. I would recommend not change SelectorsCursor, the L4–L40 sweep, or the Auto threshold based on this result. The mask-backed Auto decision cost from fully enumerating mask_run_count, on the other hand, remains independently reproducible within the same binary and is still worth optimizing.

@hhhizzz

hhhizzz commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

#10288 is about to land and changes Mask execution across page-pruned data. When rebasing this PR, could we preserve its LoadedRowRanges-aware Mask path and thread loaded_row_ranges into both cursor constructors, especially:

new_mask_from_buffer(mask, loaded_row_ranges)

#10288 only has new_mask_from_selectors, while this PR introduces the direct BooleanBuffer -> MaskCursor path. If the ranges are not propagated there, RowSelection::from_boolean_buffer(...) can still cross an unloaded page and reproduce the sparse-page offset failure.

Could we also add a sparse-page regression test starting from a direct, preferably non-byte-aligned, BooleanBuffer::slice(...)? The existing #10288 tests all start from Vec<RowSelector>, so they do not cover this integration seam.

The old should_force_selectors / override_selector_strategy_if_needed fallback should be removed after the rebase, since #10288 makes Auto remain Mask safely across skipped pages.

@haohuaijin

haohuaijin commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

The mask-backed Auto decision cost from fully enumerating mask_run_count, on the other hand, remains independently reproducible within the same binary and is still worth optimizing.

optimized this part in 3f89ed0

image

#10288 is about to land and changes Mask execution across page-pruned data. When rebasing this PR, could we preserve its LoadedRowRanges-aware Mask path and thread loaded_row_ranges into both cursor constructors, especially:

new_mask_from_buffer(mask, loaded_row_ranges)

#10288 only has new_mask_from_selectors, while this PR introduces the direct BooleanBuffer -> MaskCursor path. If the ranges are not propagated there, RowSelection::from_boolean_buffer(...) can still cross an unloaded page and reproduce the sparse-page offset failure.

Could we also add a sparse-page regression test starting from a direct, preferably non-byte-aligned, BooleanBuffer::slice(...)? The existing #10288 tests all start from Vec<RowSelector>, so they do not cover this integration seam.

The old should_force_selectors / override_selector_strategy_if_needed fallback should be removed after the rebase, since #10288 makes Auto remain Mask safely across skipped pages.

i will add those test case after #10288 merge.

@alamb

alamb commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

i will add those test case after #10288 merge.

I just merged

Resolves conflicts with apache#10288 (mask filtering across skipped pages):
- Port LoadedRowRanges-aware MaskCursor (next_chunk / next_mask_chunk_non_empty)
  into selection/boolean.rs
- Thread loaded_row_ranges into both RowSelectionCursor::new_mask_from_buffer
  and new_mask_from_selectors
- Keep LoadedRowRanges in selection/mod.rs
- mask_backed_plan_respects_loaded_row_ranges: unit test that a Mask
  cursor built via new_mask_from_buffer from a non-byte-aligned
  BooleanBuffer::slice stops decoded chunks at loaded-range boundaries
- test_mask_from_boolean_buffer_slice_with_sparse_pages: end-to-end
  test that RowSelection::from_boolean_buffer crossing pruned pages
  reads correctly under both Mask and Auto policies
Coverage-guided additions (cargo llvm-cov):
- selectors_policy_lowers_mask_backed_selection: the Selectors policy x
  Mask backing arm of build_cursor, the only untested strategy/backing
  combination
- intersect_masks with a longer right operand (tail pass-through)
- and_then where the inner selection selects nothing
- mixed mask/selector PartialEq inequality paths (length mismatch,
  set bit inside a skip run, misaligned select run)
- zero-length RowSelector filtering and offset(0)/zero-batch-size
  identity paths
@haohuaijin

haohuaijin commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

resolved conflict and added test case in d4fa2be, also added some test case found by cargo llvm-cov in 9052c57

@haohuaijin
haohuaijin requested review from alamb and hhhizzz July 21, 2026 12:37
@hhhizzz

hhhizzz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Thanks for the updates. I re-ran the row_selection_cursor benchmark on the latest head with three alternating rounds against the main baseline containing #10288.

The new early-exit run-count logic substantially reduces mask-backed Auto overhead (for example, L4 drops from +24.5% to +2.8%), and the measured crossover remains between L28 and L32, so the current threshold of 32 still looks appropriate. The residual selector variation is codegen/layout-sensitive rather than additional cursor work.

The sparse-page integration and added coverage also look good.

LGTM from both correctness and performance perspectives.

@haohuaijin

Copy link
Copy Markdown
Contributor Author

Thanks for your reviews @hhhizzz

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

Thank you @haohuaijin and @hhhizzz -- I found this PR very easy to read and understand; It is also super well tested, and I think is a foundational piece for more efficient filter evaluation in parquet

cc @etseidl for your interest in all things parquet
cc @devanbenz as maybe some of your BMI work is relevant / could be used here
cc @Dandandan as we have discussed this before in the context of pushdown filter work (when the manipulation of RowSelections was expensive)
cc @adriangb as I think you are also interested in this

#[derive(Default, Clone)]
pub struct RowSelection {
selectors: Vec<RowSelector>,
inner: RowSelectionInner,

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.

🧑‍🍳 👌

/// RowSelection::from_consecutive_ranges(ranges.into_iter(), 20);
/// let actual: Vec<RowSelector> = selection.into();
/// assert_eq!(actual, expected);
///

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 worth updating the descriptio of this struct (we can do it in a follow on PR too)

The current one talks about skipping/selecting

/// [`RowSelection`] allows selecting or skipping a provided number of rows
/// when scanning the parquet file.
///
/// This is applied prior to reading column data, and can therefore
/// be used to skip IO to fetch data into memory

Maybe we could focus on the selection aspect part

/// [`RowSelection`] represents selecting a subset of rows 
/// when scanning a parquet file. 
///
/// This is applied prior to reading column data, and can therefore
/// be used to skip IO to fetch data into memory. 

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.

And then we could add a note somewhere like

/// 
/// Depending on the pattern or rows to be selected, [`RowSelection`] has 
/// either a bitmap or an RLE ([`RowSelector`]) based implementation.

type Item = &'a RowSelector;

#[inline]
fn next(&mut self) -> Option<Self::Item> {

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 also came up with claude code in my review

I think it would basically look like

impl<'a> Iterator for RowSelectionIter<'a> {
    type Item = &'a RowSelector;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        self.0.next()
    }

    #[inline]
    fn size_hint(&self) -> (usize, Option<usize>) {
        self.0.size_hint()
    }
}

impl ExactSizeIterator for RowSelectionIter<'_> {}

// once it returns None, it will continue returning None
impl std::iter::FusedIterator for RowSelectionIter<'_> {}

/// assert_eq!(selection.row_count(), 3);
/// ```
///
/// A [`RowSelection`] maintains the following invariants:

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.

The note on invariants I think only apply to RLE/Selector backed one -- it would be good to update this documentation

}

impl RowSelection {
fn from_selectors(selectors: Vec<RowSelector>) -> Self {

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.

Should this be pub for parity with the other constructors like from_boolean_buffer? I think there is a From<Vec<RowSelector>> impl, but it is strange that the named method is not also pub

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.

Claude says "no because this does no validation" -- so maybe a small comment saying that would help

/// Returns the underlying mask if this selection is mask-backed.
///
/// Public so that engines composing selections (e.g. DataFusion's
/// `ParquetAccessPlan::into_overall_row_selection`) can concatenate

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.

if we are going to refer to this, it probably would make sense to make it hyperlink rather than a name

} else {
RowSelectionStrategy::Selectors
}
selection.auto_selection_strategy(threshold)

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.

Well that is certainly nicer - I like how it is now much more encapsulated

if l.len() == r.len() {
return l | r;
}
let common = l.len().min(r.len());

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.

you might be able to do this more efficiently by copying the longer one and then using &= -- maybe in some follow on

Comment on lines +27 to +29
pub use selection::{
MaskRunIter, RowSelection, RowSelectionCursor, RowSelectionPolicy, RowSelector,
};

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.

Since it is pub I (and claude) suggest also adding RowSelectionIter here

Suggested change
pub use selection::{
MaskRunIter, RowSelection, RowSelectionCursor, RowSelectionPolicy, RowSelector,
};
pub use selection::{
MaskRunIter, RowSelection, RowSelectionCursor, RowSelectionIter, RowSelectionPolicy,
RowSelector,
};

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

Labels

api-change Changes to the arrow API parquet Changes to the parquet crate performance

Projects

None yet

5 participants