[WIP][POC][Parquet] Add PFOR encoding support - #10977
Draft
prtkgaur wants to merge 8 commits into
Draft
Conversation
PFOR stores integers as a per-vector frame of reference plus bit-packed residuals, with the values that do not fit the chosen width carried separately and patched back after unpacking. A vector may also be differenced first, which the encoder decides per vector. The wire format is the one in parquet-format#579, so this is a second reader and writer of one format rather than a second design: * a page header giving the packing mode, the vector size, the value byte width and the element count, then a uint32 offset per vector; * per vector, an info block holding the frame of reference, a width byte and a uint16 exception count, then the packed residuals, then the exception positions and their full-width values; * bit 7 of the width byte is the differencing flag, leaving seven bits of width so that 64 is expressible; when it is set, a full-width start value sits between the info block and the residuals, which is what makes each differenced vector decodable on its own. Three things are worth a reviewer's attention. The frame of reference is searched rather than taken as the minimum, so a cluster with outliers below it can be patched from both sides; the search is bucketed and costs a fixed amount of work per vector, and it is seeded with the minimum's exact cost so it cannot regress against it. The differencing decision is gated by a sampled estimate, so a vector the mode cannot help does not pay for it. And the decoder validates the whole offset chain before any of it steers a read, so a page whose offsets overlap or run backwards is rejected rather than decoded part-way. Encoding::PFOR is 11; 10 is left for the separately proposed ALP encoding. That hole is load-bearing: EncodingMask::ALLOWED_MASK has to carve out bit 10, or a file claiming encoding 10 passes try_new and then panics in i32_to_encoding. get_encoder gains a private::GetEncoder trait specialised per value type, mirroring the private::GetDecoder that decoding.rs already has, because a flat match cannot express an encoding that exists only for INT32 and INT64.
Add PFOR to the arrow_writer round-trip matrix for the integer types, and cover two things the matrix cannot: that PFOR is the encoding that actually lands in the column chunk metadata, and that a column of an unsupported physical type is refused.
The corpus is the one the C++ implementation is measured on: 33 integer columns from ClickBench, TPC-H, TPC-DS and the NYC taxi data, plus four sorted or near-sorted shapes that separate the delta schemes from each other. Each column is measured on encode and decode under both encodings, and the sizes are printed once before the timings. The generators draw from a different engine than the C++ ones, so the values are not identical -- the distribution and the seed of each column are what carry over. An i64 leg covers the wide paths, over four of the same shapes scaled until neither the values nor the frame of reference fit in 32 bits.
The benchmark compared PFOR with DELTA_BINARY_PACKED only, which answers what PFOR costs against the other delta scheme but not what it costs against the two ways an integer column is usually made smaller today: bit packing, and a general purpose compressor over plain values. Six arms now, on the same columns: the RLE/bit-packed hybrid at the column's own bit width, DELTA_BINARY_PACKED, PFOR with differencing forbidden, PFOR with differencing allowed, and plain values through lz4 and through zstd. Splitting PFOR in two separates what the frame-of-reference search buys from what the differencing mode buys, which the single arm could not show. The compressor arms time the codec alone over a buffer already in memory, which is what the C++ benchmark times, so their decode figure is bytes to bytes and does not include producing values. The RLE arm is handed its bit width out of band because the hybrid does not carry one, which is also how the C++ arm is set up. Neither is an encoding a writer could choose for an integer column; they are reference points. The size table is now one row per column and one ratio per arm, with the arm that produced the fewest bytes named at the end of the row.
Two CI gates fail on the sources added here, and neither shows up in the usual local commands. `cargo fmt -p parquet` never reaches these files. The `encodings` module is declared inside the `experimental!` macro, and rustfmt cannot descend into a module declaration produced by a macro, so every file under it is invisible to a plain format run. CI formats the crate by naming each file with `skip_children=true`, which does reach them; that is the command that flagged the twenty sites reflowed here. The docs job builds with `--document-private-items` and `-Dwarnings`, which makes a link from public documentation to a private item an error rather than a warning. `PforDecoder`'s type-level comment linked to its own private buffer field, so the sentence now names the buffer in prose instead.
Four comments buried their subject behind "there is" or "there are", and one opened with a bare "This". Each now names the thing it is about.
Drop `bits_required`, which duplicated `bit_util::num_required_bits` -- a function six other places in this crate already import -- along with a test that duplicated bit_util's own, assertion for assertion. Confine `encodings::pfor` to the crate, leaving public only the one trait a benchmark names, and replace the two glob imports of it with explicit lists. Both changes are about being told things: a glob hides where a name comes from, and a `pub` item inside a `doc(hidden)` module is still public, so `dead_code` could not report one going unused. Split `decode_vector` into the phases the format lays out. A `VectorLayout` now reads the info block and checks every wire-derived size once, where the offset arithmetic used to be recomputed further down; `unpack_residuals`, `patch_exceptions`, `accumulate` and `step_by` take slices rather than `&mut self`, which is what kept the phases in one 151-line body. Six new tests call them directly, two of which cover rejections that previously needed a hand-built malformed page.
Paired per column against the C++ implementation of this format, the Rust decoder runs at 0.37x of it on 29 shared int32 columns, while this crate's DELTA_BINARY_PACKED runs at 1.55x of the same C++ benchmark, which places the gap in the unpack loop rather than the harness. Record the two candidate causes next to the loop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Rationale for this change
What changes are included in this PR?
Are these changes tested?
Are there any user-facing changes?