AarryaSaraf opened a new issue, #10694:
URL: https://github.com/apache/arrow-rs/issues/10694

   **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**
   
   1. 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.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to