Describe the bug / limitation
Reading a Parquet column of large, distinct, dictionary-encoded binary values
through the parquet crate costs roughly 2× the wall time of reading the same
file with parquet-cpp (pyarrow.parquet.read_table). Rewriting the same data with
use_dictionary=False inverts the result — the crate is then slightly faster — so
the gap is specific to the dictionary decode path.
We found one contributing cause and have a patch for it (#10690), but it accounts
for only about a fifth of the gap. Most of it we cannot explain, which is why we
are filing this rather than just sending the patch.
The file shape
One file, 1024 rows, a binary() column of 256 KiB incompressible random values
plus an int64 column, single row group, page index written, ~256 MiB total,
written with PyArrow defaults.
The load-bearing property is a writer default rather than anything contrived.
use_dictionary=True is on and the 1 MB dictionary_pagesize_limit is checked
lazily, so the writer never falls back to plain. The dictionary page ends up
holding the entire 268 MB column, every value distinct, every dictionary entry
referenced exactly once, and every data page is just RLE keys. Any
PyArrow-default pipeline writing unique large binary values produces this.
Measurements
parquet / arrow crate 59.1.0, read from Python over the C data interface
- pyarrow 24.0.0
- Linux, wall time at the read operation, 3 repeats per arm, medians
|
wall |
parquet-cpp (pq.read_table) |
0.6148 s |
parquet crate |
1.2167 s |
| ratio |
1.98 |
Caveats on those, stated up front because they bound how much they are worth:
n = 3 per arm with overlapping spreads, and the two arms come from runs whose
PyArrow baselines drifted structurally against each other, so we are quoting the
one uncontaminated baseline rather than an in-run normalizer. Treat 1.98 as "about
2×", not a precise figure.
Localization
These are macOS, and directional only — magnitudes from that machine are not
quotable for this shape (see the caveat at the bottom). They are what pointed us
at the dictionary path:
- A value-size matrix at equal total bytes loses at 256 KiB values but is at
parity at 64 B. So the cost scales with value size, not row count, and it is
not decompression.
- Rewriting the same data with
use_dictionary=False gives a ratio of 0.93 —
the crate is faster than parquet-cpp as soon as dictionary encoding is off.
- Profiling the decode (
sample, ~4,300 decode samples) puts 54% under
OffsetBuffer::extend_from_dictionary → memmove.
That last one points at extend_from_dictionary, which reserves offsets but
never values, so each gathered value is appended to an unreserved Vec<u8> and
amortized doubling re-copies roughly all the gathered data one extra time:
https://github.com/apache/arrow-rs/blob/main/parquet/src/arrow/buffer/offset_buffer.rs#L90-L110
Note that the 54% does not separate the reallocation copies from the gather
copy itself, which is unavoidable. Only the former is addressable — consistent
with the partial result below.
The partial fix, and the residual
#10690 pre-sizes values with an O(1) estimate. On the Linux box above:
- crate read wall median 1.2167 → 1.0700 s, min 1.0922 → 1.0261, rank-matched
3/3 not-worse. The honest band is −6% to −12%.
- ratio against parquet-cpp 1.98 → 1.74
So the mechanism is real but minor. We had predicted the reserve would take the
ratio to ≤1.0 and it moved about a fifth of that, which is the main reason for
this issue: most of the gap is something else in the dictionary path, and we do
not know what.
We deliberately did not use the exact-sum reserve proposed in #5250 — we
reproduced its rejection on 59.1.0 (+8–18% on the three
arrow_array_reader/StringArray/dictionary encoded cases), because the second
bounds-checked pass over the keys costs more than the copy it saves at ~19-byte
values. The O(1) estimate measures at parity on those cases.
Caveat on our macOS numbers
We are not quoting any macOS magnitude for this shape. A null control there —
byte-identical code re-benched against its own saved baseline — reported "+43.6%
regressed, p = 0.00", and one case read 24.6 / 37.1 / 30.9 / 35.4 ms across four
builds, two of which were provably identical. The regime is allocation and
page-fault dominated on that machine and swings ±45% build to build. The
small-string cases are stable there (null control −0.6%, p = 0.40); the
large-value ones are not.
Question
- Is there a known reason the dictionary gather path would be ~2× parquet-cpp on
large distinct values, beyond the missing reservation?
AI disclosure
This report was drafted with AI assistance. The measurements, the analysis and
the conclusions are mine, and I have reviewed every claim above against my own
run records.
Describe the bug / limitation
Reading a Parquet column of large, distinct, dictionary-encoded binary values
through the
parquetcrate costs roughly 2× the wall time of reading the samefile with parquet-cpp (
pyarrow.parquet.read_table). Rewriting the same data withuse_dictionary=Falseinverts the result — the crate is then slightly faster — sothe gap is specific to the dictionary decode path.
We found one contributing cause and have a patch for it (#10690), but it accounts
for only about a fifth of the gap. Most of it we cannot explain, which is why we
are filing this rather than just sending the patch.
The file shape
One file, 1024 rows, a
binary()column of 256 KiB incompressible random valuesplus an
int64column, single row group, page index written, ~256 MiB total,written with PyArrow defaults.
The load-bearing property is a writer default rather than anything contrived.
use_dictionary=Trueis on and the 1 MBdictionary_pagesize_limitis checkedlazily, so the writer never falls back to plain. The dictionary page ends up
holding the entire 268 MB column, every value distinct, every dictionary entry
referenced exactly once, and every data page is just RLE keys. Any
PyArrow-default pipeline writing unique large binary values produces this.
Measurements
parquet/arrowcrate 59.1.0, read from Python over the C data interfacepq.read_table)parquetcrateCaveats on those, stated up front because they bound how much they are worth:
n = 3 per arm with overlapping spreads, and the two arms come from runs whose
PyArrow baselines drifted structurally against each other, so we are quoting the
one uncontaminated baseline rather than an in-run normalizer. Treat 1.98 as "about
2×", not a precise figure.
Localization
These are macOS, and directional only — magnitudes from that machine are not
quotable for this shape (see the caveat at the bottom). They are what pointed us
at the dictionary path:
parity at 64 B. So the cost scales with value size, not row count, and it is
not decompression.
use_dictionary=Falsegives a ratio of 0.93 —the crate is faster than parquet-cpp as soon as dictionary encoding is off.
sample, ~4,300 decode samples) puts 54% underOffsetBuffer::extend_from_dictionary→memmove.That last one points at
extend_from_dictionary, which reservesoffsetsbutnever
values, so each gathered value is appended to an unreservedVec<u8>andamortized doubling re-copies roughly all the gathered data one extra time:
https://github.com/apache/arrow-rs/blob/main/parquet/src/arrow/buffer/offset_buffer.rs#L90-L110
Note that the 54% does not separate the reallocation copies from the gather
copy itself, which is unavoidable. Only the former is addressable — consistent
with the partial result below.
The partial fix, and the residual
#10690 pre-sizes
valueswith an O(1) estimate. On the Linux box above:3/3 not-worse. The honest band is −6% to −12%.
So the mechanism is real but minor. We had predicted the reserve would take the
ratio to ≤1.0 and it moved about a fifth of that, which is the main reason for
this issue: most of the gap is something else in the dictionary path, and we do
not know what.
We deliberately did not use the exact-sum reserve proposed in #5250 — we
reproduced its rejection on 59.1.0 (+8–18% on the three
arrow_array_reader/StringArray/dictionary encodedcases), because the secondbounds-checked pass over the keys costs more than the copy it saves at ~19-byte
values. The O(1) estimate measures at parity on those cases.
Caveat on our macOS numbers
We are not quoting any macOS magnitude for this shape. A null control there —
byte-identical code re-benched against its own saved baseline — reported "+43.6%
regressed, p = 0.00", and one case read 24.6 / 37.1 / 30.9 / 35.4 ms across four
builds, two of which were provably identical. The regime is allocation and
page-fault dominated on that machine and swings ±45% build to build. The
small-string cases are stable there (null control −0.6%, p = 0.40); the
large-value ones are not.
Question
large distinct values, beyond the missing reservation?
AI disclosure
This report was drafted with AI assistance. The measurements, the analysis and
the conclusions are mine, and I have reviewed every claim above against my own
run records.