Skip to content

[WIP][Parquet] Add FastLanes packing and UTL delta to PFOR - #11061

Draft
sdf-jkl wants to merge 10 commits into
apache:mainfrom
sdf-jkl:pr-10977-fastlanes
Draft

sdf-jkl wants to merge 10 commits into
apache:mainfrom
sdf-jkl:pr-10977-fastlanes

Conversation

@sdf-jkl

@sdf-jkl sdf-jkl commented Sep 11, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

None. Experimental follow-up stacked on #10977, based on its head f6e19d07a3a574b4c83ff32559f2f91332fc6281. This draft includes that PR's commits.

Incremental commits: byte-oriented FastLanes packing and independent UTL delta chains.

Rationale for this change

The PFOR implementation in #10977 unpacks residuals sequentially, adds the frame in another pass, and reconstructs delta vectors with one serial prefix sum. This experiment adds two opt-in FastLanes modes and measures their costs, including returning decoded integers in original order.

What changes are included in this PR?

  • Mode 1: byte-oriented FastLanes packing with fused wrapping frame addition. It retains ordinary adjacent delta encoding and its serial reconstruction. Complete 1,024-value blocks use FastLanes; tails use sequential packing. Encoded sizes match mode 0.
  • Mode 2: FastLanes with independent UTL delta chains, enabled by with_fastlanes_delta_enabled(true). Each complete block has 32 chains of 32 consecutive values for i32, or 16 chains of 64 for i64. Each chain starts with a zero difference and carries its first value separately: 128 bytes of starts per full block. Tails retain scalar delta with one start.
  • Unpatched mode-2 blocks fuse unpacking, frame addition, and lane accumulation into a UTL buffer. A separate, fully unrolled 1,024-value permutation restores original output order, following the actual FastLanes and Vortex implementations. Patched blocks apply exceptions before the lane sums and use the same permutation. This replaces the earlier scatter-based output path.
  • The per-vector planner includes all lane-start bytes in its cost. Mode 2 may fall back to raw PFOR; delta remains optional. Its encoded size can differ from ordinary delta.
  • Kernels use unaligned little-endian byte loads/stores, adapted from the earlier local ALP experiment, with the UTL sum/output strategy taken from the FastLanes and Vortex references. Mode 2 retains its existing i32 lane numbering; the fully unrolled permutation accounts for that difference. fastlanes = 0.5.0 is a dev-only bitpacking oracle, not a runtime dependency.
  • Benchmark arms retain the original and mode-1 variants and add PFOR_FASTLANES+UTL_DELTA. Untimed checks compare every decoded integer with the original. Benchmark logs now report exact encoded sizes, actual delta-vector counts, UTL-block counts, and patch counts.

Are these changes tested?

Passed on the final implementation:

cargo test -p parquet --lib --offline encodings::  # 200 passed
cargo clippy -p parquet --features experimental --lib --tests --bench pfor --offline -- -D warnings
cargo fmt --all --check
rustfmt --edition 2024 --check <the five changed Rust files>
git diff --check

Tests include all bit widths for both integer types against independently packed oracle data, byte alignments 0–7, wrapping arithmetic, running sums and output permutation checked against the upstream Delta::undelta and Transpose::untranspose implementations, fused and patched lane reconstruction, literal wire data with a lane-local patch and a scalar tail, truncated start arrays, all supported vector sizes, and streaming reads/skips across lanes, blocks, vectors, and tails. Existing mode-0/mode-1 roundtrips remain covered. These are codec-level checks, not an end-to-end Arrow roundtrip with mode 2.

Native benchmark results

Measured implementation: 67bff48a94769dc24b939d7e8f85dff4e7375e0b (fully unrolled transpose). Main comparison is original PFOR + delta versus FastLanes + UTL delta. The transitional FastLanes + serial-delta arm is omitted from the reported comparison; its measurements remain in the raw logs for reference.

AMD Ryzen AI 9 HX PRO 470, pinned logical CPU 2, Rust 1.97.1 / LLVM 22.1.6, -C target-cpu=native. The comparison covers 33 synthetic i32 and four synthetic i64 datasets, each with 102,400 values: 74 encode/decode pairs. Criterion uses 60 samples, 0.3 s warmup, and 1 s target measurement per case. Throughput below is decimal GB/s of uncompressed output, with geometric means across datasets. Exact integer readback checks passed.

All workloads, delta allowed

Type Operation Datasets Original PFOR GB/s FastLanes UTL GB/s Speedup
int32 encode 33 0.96 1.07 1.11×
int32 decode 33 19.20 37.28 1.94×
int64 encode 4 1.33 1.48 1.12×
int64 decode 4 20.61 32.42 1.57×

Actual UTL-selected workloads

Type Operation Datasets Original PFOR GB/s FastLanes UTL GB/s Speedup
int32 encode 5 0.48 0.54 1.14×
int32 decode 5 12.05 12.84 1.07×
int64 encode 1 0.89 0.93 1.05×
int64 decode 1 15.55 19.64 1.26×

UTL-selected decode cases

Type Dataset Original GB/s UTL GB/s Speedup Original bytes UTL bytes
int32 CounterID 10.58 12.79 1.21× 27707 52307
int32 MonotoneRowId 15.42 13.35 0.87× 2107 26707
int32 NearSortedUnixTime 10.60 11.36 1.07× 65561 77247
int32 SortedKeyDups 12.53 13.28 1.06× 14307 26707
int32 SortedUnixTime 11.75 13.57 1.15× 39907 52307
int64 SortedUnixTime 15.55 19.64 1.26× 296707 308707

Most datasets do not select delta at all. The overall 1.94× i32 / 1.57× i64 decode improvements therefore include faster raw bitpacking and must not be described as delta-specific improvements. On the five i32 datasets that select UTL delta, the decode improvement is 1.07×; on the single selected i64 dataset it is 1.26×. MonotoneRowId regresses, and its extra lane starts/zero differences increase encoded size substantially. The current codec does not achieve a 2× improvement on these actual delta-selected workloads.

Independent larger-working-set check

A standalone wall-clock probe uses synthetic i32 positive steps with occasional large jumps. Every vector is asserted to select delta; full output equality is checked. Two rounds reverse implementation order. The latest probe was rebuilt against this implementation's native library.

Working set Original PFOR delta GB/s FastLanes UTL delta GB/s
One hot page 10.01–10.42 12.55–12.66
256 distinct pages (~119 MB original / ~125 MB UTL input+output) 10.16–10.26 10.70–10.76

These are codec measurements, not full Parquet scan or file-I/O throughput.

Machine conditions

Temperature and host counters were sampled each second, with 30 seconds of preflight telemetry. AC was connected throughout; powersave governor / balance_performance EPP were unchanged. Preflight temperature averaged 48.5°C. During Criterion it averaged 87.1°C and peaked at 95°C; CPU 2 reported frequency averaged 5.07 GHz. The SMT sibling averaged 6.1% busy with a brief one-second peak of 85.9%. This was a monitored desktop run, not an isolated host. Frequency snapshots do not establish absence of throttling, and small differences should be treated cautiously.

Reproduce the two-way comparison:

RUSTFLAGS='-C target-cpu=native' taskset -c 2 cargo bench \
  -p parquet --features experimental --bench pfor -- \
  'int(32|64)/(encode|decode)/(PFOR\+DELTA|PFOR_FASTLANES\+UTL_DELTA)/' \
  --sample-size 60 --warm-up-time 0.3 --measurement-time 1.0 --noplot

Reference implementations inspected: Vortex FastLanes transpose, Vortex delta decompression, and the local FastLanes generated rsum / untranspose_i kernels.

Are there any user-facing changes?

Mode 0 remains the default. The decoder reads modes 0, 1, and 2. Opt-in modes 1 and 2 require their respective decoder extensions; older decoders reject them. These are experimental PFOR wire-format extensions, not changes to existing standard Parquet encodings. UTL delta remains opt-in given its compression overhead and the mixed performance results above.

sfc-gh-pgaur and others added 9 commits September 3, 2026 17:19
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.
@github-actions github-actions Bot added the parquet Changes to the parquet crate label Sep 11, 2026
@sdf-jkl sdf-jkl changed the title [WIP][Parquet] Add byte-oriented FastLanes packing to PFOR [WIP][Parquet] Add FastLanes packing and UTL delta to PFOR Sep 11, 2026
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.

2 participants