Skip to content

perf(parquet): pre-size values in OffsetBuffer::extend_from_dictionary - #10690

Open
AarryaSaraf wants to merge 1 commit into
apache:mainfrom
AarryaSaraf:dict-extend-reserve-values
Open

perf(parquet): pre-size values in OffsetBuffer::extend_from_dictionary#10690
AarryaSaraf wants to merge 1 commit into
apache:mainfrom
AarryaSaraf:dict-extend-reserve-values

Conversation

@AarryaSaraf

@AarryaSaraf AarryaSaraf commented Aug 14, 2026

Copy link
Copy Markdown

Which issue does this PR close?

Part of #10694, but does not close it. That issue reports a ~2x gap against
parquet-cpp on large distinct dictionary-encoded values; this addresses one
contributing cause and moves about a fifth of it. The residual stays open there.

Rationale for this change

OffsetBuffer::extend_from_dictionary reserves offsets but not values, so
every gathered dictionary value is appended to an unreserved Vec<u8> and
amortized doubling re-copies roughly all gathered data one extra time. For a
column of large dictionary-encoded values that cost is measurable; for the small
values the crate's benchmarks cover, it is not.

#5250 raised the same allocation profile and proposed an exact-sum reserve —
a second pass over the keys summing each referenced value's length. It was
withdrawn after regressing the small-string dictionary benchmarks by ~10-15%. We
reproduced that on 59.1.0: +8-18% across the three
arrow_array_reader/StringArray/dictionary encoded cases. At ~19 byte values the
second bounds-checked pass over the keys costs more than the copy it saves.

This PR uses an O(1) estimate instead: keys.len() × (dict_values.len() / dict_entry_count). No per-key pass, exact when value lengths are uniform, and an
ordinary reservation hint when they are not.

Note that OffsetBuffer::with_capacity deliberately does not pre-size values
("its size is unpredictable"). That remains true in general; this change is
scoped to the dictionary path, where the dictionary page gives a cheap size
signal.

What changes are included in this PR?

A reservation hint at the top of extend_from_dictionary. No behavior change: it
is skipped for an empty dictionary, and it only pre-sizes the same Vec the loop
was already growing.

The reservation uses try_reserve rather than reserve. A skewed dictionary — a
few very large entries with most keys selecting small ones — can inflate an
average-based estimate far above the true output size, and the dictionary comes
from an untrusted file, so a failed reserve would abort the process. With
try_reserve that case degrades to the current growth behavior instead.

Are these changes tested?

A unit test covers the empty-dictionary guard and a skewed large/small dictionary
whose estimate over-shoots, asserting the gathered output is unaffected. The
change is a capacity hint and cannot alter output otherwise.

Performance, Linux, parquet 59.1.0 read from
Python over the C data interface, against pyarrow 24.0.0. The file is 256 MiB,
1024 rows, a binary() column of 256 KiB distinct values written with PyArrow
defaults, so dictionary encoded end to end. Wall time at the read operation,
3 repeats per arm:

metric before after
read wall, median 1.2167 s 1.0700 s
read wall, min 1.0922 s 1.0261 s
ratio vs parquet-cpp (pq.read_table) 1.98 1.74

3 of 3 runs rank-matched not-worse; the honest band on the improvement is
−6% to −12%. Two caveats: n = 3 with overlapping spreads, and the parquet-cpp
baseline comes from a different run than the patched arm, because the in-run
baselines drifted structurally against each other. So this is a direction plus a
rough size, not a precise figure.

This recovers part of the gap against parquet-cpp on this shape and not all of
it — we had predicted the reserve would close it, and it moved about a fifth of
the way. The rest is tracked in #10694.

The three existing small-string dictionary cases measure at parity — the property
the exact-sum variant in #5250 failed. Patched vs baseline means: 267.2 vs
257.7 µs, 261.9 vs 260.6 µs, 248.4 vs 252.4 µs, i.e. scattered around zero, with
a null control on the same machine at −0.6% (p = 0.40).

The large-value benchmark added in #10691 does not separate the two on my
Apple hardware. Interleaving stock and patched runs against a common baseline,
7 stock and 5 patched:

median min mean
stock 6.512 ms 6.364 ms 6.673 ms
patched 6.431 ms 6.281 ms 6.520 ms

The distributions overlap almost entirely, and a null control on the same box —
stock re-run against its own saved baseline, byte-identical code — reports
−9.3% (p = 0.00). A single-digit effect is not resolvable there, which is why the
table above comes from Linux and our own harness rather than from this benchmark.

That is the main thing I would value from review: a run of #10691's case on your
reference hardware. It is the measurement I cannot produce.

For the same reason we are not quoting macOS magnitudes for the large-value case
at all. An earlier null control there reported "+43.6% regressed, p = 0.00" on
byte-identical code, and one case read 24.6 / 37.1 / 30.9 / 35.4 ms across four
builds, two of which were provably identical. The small-string cases quoted above
are stable on that machine; the large-value ones are not.

Are there any user-facing changes?

No.

AI disclosure

The patch and this description were drafted with AI assistance and reviewed by me
line by line. The measurements are my own runs. I verified the reservation cannot
change decode output — it only affects Vec capacity — and worked through the
skewed-dictionary over-estimate case by hand, which is why try_reserve is used.

@github-actions github-actions Bot added the parquet Changes to the parquet crate label Aug 14, 2026
extend_from_dictionary reserves the offsets buffer but not values, so
every gathered dictionary value lands in an unreserved Vec and amortized
doubling re-copies roughly all gathered data one extra time. For large
dictionary values (e.g. binary image columns, which common writers
dictionary-encode because the dictionary-size limit is checked lazily)
that extra copy dominates the decode.

An exact-sum reserve was proposed and withdrawn in apache#5250: the second
bounds-checked pass over the keys regresses small-value dictionaries by
10-15% on the crate's own benchmarks (reproduced at 8-18% on 59.1.0).
This uses an O(1) estimate instead - keys.len() times the dictionary's
average value length - which is exact for uniform value lengths and an
ordinary reservation hint otherwise, with no per-key pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant