viirya opened a new pull request, #12046:
URL: https://github.com/apache/arrow-rs/pull/12046
# Which issue does this PR close?
- Closes #12045
# Rationale for this change
`OffsetBuffer::extend_from_dictionary` turns dictionary keys into the values
and offsets of a `StringArray` / `BinaryArray`. It calls `extend_from_slice`
and `push` per value, so each value pays for a `memcpy` call with a data
dependent length and two capacity checks. Dictionary encoded strings are
usually short, so this overhead dominates. See the issue for details.
# What changes are included in this PR?
`extend_from_dictionary` now writes values and offsets through raw pointers
in a single pass:
- Offsets: `keys.len()` slots are reserved up front (as before), written
with `ptr::write`, and committed with one `set_len` at the end.
- Values: before each copy it checks that `self.values` has room for the
value plus 16 bytes of slack, and reserves when it doesn't. Since
`Vec::reserve` grows geometrically, this branch is rarely taken.
- A new `copy_value` helper copies values of up to 64 bytes as one to four
16-byte chunks with fixed-size `copy_nonoverlapping`. Those compile to plain
vector loads and stores. Anything else uses `memcpy`. The chunks are explicitly
unrolled because LLVM turns the equivalent loop back into a `memcpy` call.
Key and offset validation and the offset overflow check are the same as
before, all done before anything is written for that value. A dictionary offset
outside `dict_values` used to panic when slicing; it now returns an error.
Today's callers always pass a valid dictionary.
Safety argument:
- **Source reads:** a value is only copied in chunks if the source has
enough bytes from `start`: at least 16 for values of up to 16 bytes, and at
least `len.next_multiple_of(16)` for values of 17 to 64 bytes. Otherwise it
falls back to an exact-length `memcpy`. A zero-length value at the very end of
`dict_values` therefore copies 0 bytes. The `len > 16` condition on the
multi-chunk branch is what stops a zero-length value from going down that
branch.
- **Destination writes:** chunked copies write at most `len + 16` bytes, and
the capacity check guarantees `capacity >= len_so_far + value_len + 16`. When
the buffer has to grow, `set_len(len_so_far)` runs first so the reallocation
keeps every byte written so far, and the destination pointer is reloaded
afterwards.
- **`set_len`:** values and offsets are only ever written contiguously, so
the final `set_len` calls only cover initialized memory. The final `set_len`
also runs on the error paths, so an early return leaves the buffer consistent:
every value before the bad key is kept with its offset, just as before this
change.
- **Overflow:** the offset for each value is checked with `I::from_usize(len
+ value_len)` before the copy, as before. `len + value_len` can't wrap, since
both terms are at most `isize::MAX`.
# Are these changes tested?
Yes. Two new unit tests:
- `test_offset_buffer_extend_from_dictionary_lengths` covers value lengths
on both sides of each multiple of 16 up to 100 bytes, including zero-length and
short values at the end of the dictionary. It runs over several calls, so some
calls start with little spare capacity. The dictionary buffer is exactly sized,
so a read past its end is detected under AddressSanitizer.
- `test_offset_buffer_extend_from_dictionary_err` checks that a bad key or
bad dictionary offset leaves the earlier values in place and the buffer valid.
I ran the tests under AddressSanitizer (`RUSTFLAGS=-Zsanitizer=address cargo
+nightly test -p parquet --lib --target aarch64-apple-darwin offset_buffer`).
Each of these mutations makes them fail with a heap or global buffer overflow:
dropping the source length check on the single-chunk path, dropping the `len >
16` guard on the multi-chunk path, and dropping the 16 byte destination slack.
Benchmarks: Apple M4 Max, `main` and this PR run back to back. The upstream
`arrow_reader` numbers are the mean of two rounds.
`cargo bench -p parquet --features "arrow test_common experimental" --bench
arrow_reader -- 'dictionary encoded'` (dictionary values are 18-19 bytes)
| benchmark | main | this PR | change |
|---|---|---|---|
| StringArray, mandatory, no NULLs | 300.8 µs | 154.5 µs | −48.6% |
| StringArray, optional, no NULLs | 302.7 µs | 156.6 µs | −48.3% |
| StringArray, optional, half NULLs | 291.2 µs | 211.6 µs | −27.3% |
| BinaryArray, mandatory, no NULLs | 311.4 µs | 145.8 µs | −53.2% |
| BinaryArray, optional, no NULLs | 313.3 µs | 147.7 µs | −52.9% |
| BinaryArray, optional, half NULLs | 288.6 µs | 219.1 µs | −24.1% |
| BinaryArray, mandatory, no NULLs, large values (64 KiB) | 5.75 ms | 5.61
ms | −2.5% (noise) |
To check other value lengths, I temporarily changed the benchmark's
dictionary values to `format!("{x:0>N}")` (N bytes; this change isn't part of
the PR). StringArray results:
| value length | mandatory, no NULLs | optional, no NULLs | optional, half
NULLs |
|---|---|---|---|
| 8 B | 216.7 → 107.8 µs (−50%) | 218.3 → 110.1 µs (−50%) | 244.2 → 186.2 µs
(−24%) |
| 19 B | 254.9 → 149.3 µs (−41%) | 256.2 → 154.4 µs (−40%) | 263.4 → 214.3
µs (−19%) |
| 48 B | 343.0 → 205.3 µs (−40%) | 344.3 → 209.6 µs (−39%) | 296.4 → 223.4
µs (−25%) |
| 64 B | 249.3 → 233.0 µs (−7%) | 251.7 → 235.1 µs (−7%) | 257.8 → 252.1 µs
(−2%) |
| 100 B | 288.4 → 264.3 µs (−8%) | 284.6 → 268.9 µs (−6%) | 303.4 → 298.5 µs
(−2%) |
| 128 B | 288.6 → 277.5 µs (−4%) | 285.7 → 281.9 µs (−1%) | 323.5 → 313.8 µs
(−3%) |
# Are there any significant user-facing changes?
No.
This pull request and its description were written by Isaac.
--
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]