Skip to content

bench(parquet): cover DELTA_BYTE_ARRAY at sub-page-limit value sizes - #10550

Merged
etseidl merged 1 commit into
apache:mainfrom
pydantic:claude/parquet-bench-small-delta-values
Aug 4, 2026
Merged

bench(parquet): cover DELTA_BYTE_ARRAY at sub-page-limit value sizes#10550
etseidl merged 1 commit into
apache:mainfrom
pydantic:claude/parquet-bench-small-delta-values

Conversation

@adriangb

@adriangb adriangb commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

None. This is benchmark coverage split out of #10549 so that the performance change proposed there can be reviewed against benchmarks that already exist on main.

Rationale for this change

The existing DELTA_BYTE_ARRAY writer benchmarks (added in #10512) write 128 values of 2 MiB each against the default 1 MiB data_page_size_limit. Every value exceeds the limit, so each one is cut onto its own data page, and each page boundary clears the encoder's previous-value state. Every prefix length is therefore 0.

Measured on main, writing that benchmark's own large_string_shared_prefix data (256 MiB raw input):

encoding data_page_size_limit output
PLAIN default (1 MiB) 256.02 MiB
DELTA_BYTE_ARRAY default (1 MiB) 256.02 MiB
DELTA_BYTE_ARRAY 4 MiB 2.00 MiB

At the default limit the DELTA_BYTE_ARRAY output is byte-for-byte what PLAIN produces — the encoding is doing no work, so those benchmarks cannot measure anything about prefix scanning. That is the known regression #10489.

The new benchmarks use 1 KiB values, far below the page limit, so roughly 1000 values share a page and the previous-value state survives across them. That is the regime DELTA_BYTE_ARRAY is actually deployed in.

What changes are included in this PR?

Three new criterion benchmark groups in bench_small_delta_byte_array_writers, each writing 8192 rows of 1 KiB strings with both PLAIN (as a control/baseline) and DELTA_BYTE_ARRAY:

  • small_string_shared_prefix — values differ only in a trailing 8-byte counter, so each prefix scan covers nearly the whole value.
  • small_string_partial_prefix — values share their first 512 bytes and then diverge, the realistic sorted-column case (paths, URLs, keys). Uses a new create_string_partial_prefix_bench_batch helper.
  • small_string_distinct — values differ from byte 0, so prefix deduplication saves nothing.

No library code is touched.

Are these changes tested?

These are benchmarks. The benchmark binary compiles, and cargo fmt and cargo clippy -p parquet --benches --all-features -- -D warnings pass.

The benchmarks were also run, to confirm they resolve real differences rather than noise. They were validated by measuring an actual candidate change against them — the block-wise shared-prefix scan in #10549 — on an A/B/A schedule (baseline, branch, baseline again) so that machine drift is quantified rather than assumed. The plain rows act as controls, since PLAIN never calls the prefix scan. Times are means in ms, aarch64:

bench base (pre) candidate change base (post)
small_string_shared_prefix/plain (control) 0.683 0.694 0.699
small_string_shared_prefix/delta_byte_array 2.832 0.682 2.870
small_string_partial_prefix/plain (control) 0.603 0.853 0.639
small_string_partial_prefix/delta_byte_array 1.876 0.703 1.925
small_string_distinct/plain (control) 0.454 0.471 0.474
small_string_distinct/delta_byte_array 0.680 0.710 0.699

The shared-prefix case resolves a 4.2x difference and the partial-prefix case a 2.7x difference, both far above the largest control excursion. The distinct case is flat, which is the correct outcome — there is no prefix to scan there. One caveat: the small_string_partial_prefix/plain control had a single noisy reading (0.853 against baselines of 0.603 and 0.639), so that row's noise floor is wider than the others; the delta effect on that bench is still several times larger than that excursion.

The baseline columns also show that on main the shared-prefix case costs 2.83 ms with DELTA_BYTE_ARRAY versus 0.68 ms with PLAIN — the encoding is currently about 4x more expensive than PLAIN on exactly the data it exists for.

Are there any user-facing changes?

No. Benchmark-only change; no library code is touched.

The existing delta benches use 2 MiB values against the default 1 MiB page
limit, where every value is cut onto its own page and prefix state is reset
each time -- DELTA_BYTE_ARRAY output there is byte-identical to PLAIN, so the
shared-prefix scan never runs to depth. Add 1 KiB values, which sit far below
the limit and share a page, across full / partial / no shared prefix.
@github-actions github-actions Bot added the parquet Changes to the parquet crate label Aug 4, 2026
@adriangb
adriangb marked this pull request as ready for review August 4, 2026 18:58
@etseidl

etseidl commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Thanks @adriangb!

@etseidl
etseidl merged commit 2e81b05 into apache:main Aug 4, 2026
18 checks passed
etseidl added a commit that referenced this pull request Aug 4, 2026
#10549)

> **Stacked on #10550 This
branch is
> that PR's benchmark commit plus one commit of its own, so that the
numbers
> below are reproducible with `cargo bench` on this branch alone. Review
only
> the second commit here; the diff shrinks to
> `+139/-15` once #10550 merges and this rebases onto `main`.

# Which issue does this PR close?

None directly. Split out of
#10505 so that the correctness
fix there can be reviewed without an unrelated performance change
attached to it.

# Rationale for this change

`DELTA_BYTE_ARRAY` stores each value as the number of leading bytes it
shares with its predecessor plus the remaining suffix, so writing a
value runs a shared-prefix scan against the previous value. Both encoder
paths implement that scan as a byte-at-a-time loop:

- `DeltaByteArrayEncoder::put` in
`parquet/src/encodings/encoding/mod.rs` (the generic
`SerializedFileWriter` path)
- `FallbackEncoder::encode`'s `Delta` arm in
`parquet/src/arrow/arrow_writer/byte_array.rs` (the `ArrowWriter` path)

The scan runs once per value, and on exactly the data the encoding
exists for — near-identical consecutive values — it covers essentially
the whole value. That makes its throughput, not its per-call overhead,
the thing that matters, and a byte-at-a-time loop is the slowest way to
do it.

# What changes are included in this PR?

Extract the two duplicated loops into
`crate::util::prefix::common_prefix_length` and compare a 32-byte block
at a time instead of a byte at a time.

32 is the widest block that both aarch64 and x86-64 still expand inline;
at 64 bytes x86-64 drops to an out-of-line `bcmp` call, which costs more
than the extra width buys. Measured on aarch64, every width from 16 up
performs the same, so this sits in the middle of a flat optimum rather
than on a tuned peak.

No behavior change: the function returns the same prefix length the
byte-wise loops did, and no page layout, encoding, or file output
changes.

# Are these changes tested?

Existing coverage: the full `parquet` suite passes unmodified (1307
tests), including the `DELTA_BYTE_ARRAY` round-trip tests.

Those round trips are weaker evidence than they look, in two ways.
First, they never reach the new code path: `ByteArrayType::test` and
`FixedLenByteArrayType::test` feed random values, which share no prefix,
and the `ArrowWriter` cases write values a handful of bytes long — so
nothing in the suite writes two consecutive values sharing 32 bytes, and
the block loop never runs. Second, a round trip is structurally blind to
an under-counted prefix: the encoder just emits a correspondingly longer
suffix and the decoder reconstructs the same bytes either way. Only
over-counting shows up. `test_estimated_data_encoded_size` does assert
an exact encoded size, but on 2- and 3-byte values that can never enter
the block loop.

New coverage, unit level, in `parquet/src/util/prefix.rs`: the boundary
cases the block loop introduces — empty inputs, prefixes shorter than /
equal to / longer than one block, a mismatch in the first and last byte
of a block, and unequal lengths where one input is a strict prefix of
the other. Plus unequal lengths combined with a mismatch past a block
boundary (where truncating to the shorter length interacts with the
block loop), non-zero slice start offsets (callers pass sub-slices into
shared Arrow buffers, not freshly allocated `Vec`s), and a prefix ending
mid-UTF-8-codepoint — byte-level prefixes may split a multi-byte
character, which was true of the byte-wise scan too and is worth pinning
now that the scan is wider.

New coverage, end to end, for both call sites:
`test_delta_byte_array_long_shared_prefix{,_fixed_len}` in
`parquet/src/encodings/encoding/mod.rs` and
`delta_byte_array_long_shared_prefix` in
`parquet/src/arrow/arrow_writer/mod.rs`. Each writes values with a
1000-byte shared prefix — not a multiple of the 32-byte block, so the
scan has to resolve a partial block — and asserts on the prefix lengths
actually written, decoded back out of the page, rather than on a round
trip alone.

Those assertions were checked for power by mutation: dropping the
sub-block tail scan from `common_prefix_length` (so it under-counts by
up to 31 bytes) leaves all 156 `arrow::arrow_writer` tests passing on
`main`, and fails all three new tests.

Benchmarked with `parquet/benches/arrow_writer.rs`'s
`bench_delta_byte_array_writers`, added in #10512. Results in a comment
below.

# Are there any user-facing changes?

No API changes and no change to written output. `DELTA_BYTE_ARRAY`
writes get faster.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ed Seidl <etseidl@users.noreply.github.com>
@Jefffrey Jefffrey added the development-process Related to development process of arrow-rs label Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

development-process Related to development process of arrow-rs parquet Changes to the parquet crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants