Skip to content

[WIP][POC][BenchmarkingOnly] PFOR interleaved and fastlanes numbers - #51296

Draft
prtkgaur wants to merge 106 commits into
apache:mainfrom
prtkgaur:pgaur_interleavedPlusFastLanesDelta
Draft

prtkgaur wants to merge 106 commits into
apache:mainfrom
prtkgaur:pgaur_interleavedPlusFastLanesDelta

Conversation

@prtkgaur

@prtkgaur prtkgaur commented Sep 10, 2026 •

Copy link
Copy Markdown

Rationale for this change

Parquet uses a continuous bit-packed stream, while FastLanes uses a
lane-interleaved grid. This PR measures whether the grid's cheaper unpacking
can offset the cost of returning values in Parquet file order.

The answer depends on packed width, output element width, decode call shape,
memory footprint, instruction set, and the permutation back to file order.
This branch keeps those variables separate instead of reducing the comparison
to one throughput number.

What changes are included in this PR?

This benchmarking-only proof of concept adds:

  • Portable lane-interleaved and FastLanes-style bit-packing kernels.
  • AVX2 and NEON paths that perform the file-order permutation in registers.
  • Runtime dispatch for the interleaved kernels.
  • PFOR experiments using interleaved, lane-delta, and transposed-delta layouts.
  • Benchmarks for packed width, output width, corpus shape, and memory footprint.
  • Controls for call shape, output address, bytes moved, lane order, and stores.
  • Reproducible build and analysis scripts for x86-64 and AArch64.
  • Checked-in benchmark results and documentation of their limitations.

Run ./layouts in fl5_corpus without arguments to see the measurement index.
Each study names the variable it changes and the assumptions required to
interpret its output.

Are these changes tested?

Yes. The codecs and experimental layouts are checked for bit-exact round trips
before they are timed. Unit tests cover packing widths, output order, delta
paths, tails, exceptions, and boundary values.

The benchmark harness includes timing controls and rejects results that do not
meet its validity checks. Results are recorded for multiple compilers,
instruction sets, and working-set sizes.

Are there any user-facing changes?

No production encoding or default writer behavior is proposed by this PR. It
is a benchmarking and research branch used to evaluate layout choices.

The experimental APIs and on-disk layouts in this branch should not be treated
as stable or interoperable formats.

@github-actions

Copy link
Copy Markdown

Thanks for opening a pull request!

This pull request has been automatically converted to a draft because its title doesn't match Arrow's required format.

If this is not a minor PR. Could you open an issue for this pull request on GitHub? https://github.com/apache/arrow/issues/new/choose

Opening GitHub issues ahead of time contributes to the Openness of the Apache Arrow project.

Then could you also rename the pull request title in the following format?

GH-${GITHUB_ISSUE_ID}: [${COMPONENT}] ${SUMMARY}

or

MINOR: [${COMPONENT}] ${SUMMARY}

After updating the title, you can mark the pull request as ready for review.

See also:

@prtkgaur prtkgaur changed the title [WIP][POC][BenchmarkingOnly] PFOR interleaved and flanes numbers [WIP][POC][BenchmarkingOnly] PFOR interleaved and fastlanes numbers Sep 11, 2026
Implements the PFOR (Patched Frame of Reference) integer compression
algorithm as a standalone utility library in arrow/util/pfor/. Includes:
- Cost model for optimal bit width selection (histogram-based)
- Vector-level encode/decode with FOR + bit-packing + exceptions
- Page-level wrapper with header, offset array, and multi-vector layout
- Round-trip tests for int32 and int64 at the vector and page level,
  covering outliers, all-identical and all-zero runs, negative values,
  the int min/max edges, a single element, and a page whose length is
  an exact multiple of the vector size
Adds PFOR = 11 to the Encoding enum and wires it into the parquet
read/write pipeline:
- PforEncoder<DType> in encoder.cc (buffers values, calls PforWrapper::Encode)
- PforDecoder<DType> in decoder.cc (decodes all values on first access)
- PFOR case in column_reader.cc InitializeDataDecoder
- Encoding string mapping in types.cc

Supports INT32 and INT64 column types.
Benchmarks encode/decode throughput for int32/int64 across 10 data
distributions inspired by Snowflake's NumericComprBenchmark: constant,
sequential, small range, high-base-small-range (timestamps), with
outliers (exception path), random, TPC-DS date/store/item/quantity keys.

Each distribution runs at 1K/10K/100K/1M elements. Reports bytes/s,
items/s, and compression ratio.
Defaults to kPforVectorSize, so existing callers are unaffected.
Load() now returns Result<PforVectorInfo> after the Status/Result
refactoring. Use ASSERT_OK_AND_ASSIGN to properly unwrap the result
in tests.
@prtkgaur
prtkgaur force-pushed the pgaur_interleavedPlusFastLanesDelta branch from e737aaf to da4ffbd Compare October 2, 2026 15:18
@prtkgaur
prtkgaur force-pushed the pgaur_interleavedPlusFastLanesDelta branch 3 times, most recently from b15f1e6 to 633ba32 Compare October 2, 2026 22:46
Make LoadHeader fallible, use SafeLoadAs and SafeStore for header and offset
fields, and validate the packing mode, element width, vector size, and element
count at the page boundary.
Use int64_t for encoded sizes, uint8_t for byte buffers, and fully qualified
PFOR names at Parquet call sites. Remove the pointer casts made unnecessary by
the consistent buffer type.
Convert the vector metadata and encoded-vector types to classes with explicit
accessors. Validate widths and exception counts while loading metadata, and
construct metadata atomically on encode.
Compiles the PFOR sources into the parquet target and adds the
parquet-pfor-comparison-benchmark executable.
Replaces hand-rolled power-of-two and ceiling-division arithmetic with
bit_util::IsPowerOf2 and bit_util::CeilDiv, and records where
incremental encode and decode would hook in.
Commit 00b6318 introduced ARROW_DCHECK(bit_util::IsPowerOf2(vector_size))
in PforWrapper<T>::Encode, but vector_size is int32_t and bit_util has
overloads only for int64_t and uint64_t -- the call is ambiguous and the
file no longer compiles.

Cast to int64_t to disambiguate. CeilDiv calls in the same file already
promote to int64_t implicitly via its int64_t-only signature.
Use the validated page-level packing mode to distinguish sequential and
lane-interleaved payloads. Apply interleaving only to full 1024-value int32
vectors; encode unsupported widths and tail vectors sequentially.
Every bit width round-trips, framed and unframed, and the framed cases go
through the unpack that folds the frame into its own store. The widths are
built from a delta mask rather than a range so the cost model cannot land
on a narrower width and leave the loop testing one kernel thirty-one
times; the test reads back the width the encoder settled on and checks it.

One test exists only to keep the others honest. Because the two layouts
write the same number of bytes, a mode request that quietly fell back to
sequential on both the encode and the decode side would pass every round
trip above. So one test asserts the payloads really are different bytes at
widths 1 through 31 -- and identical at 32, where the two layouts coincide
and the kernel is a memcpy.
The layout is not in the Parquet specification, so a file written with it
can only be read by an implementation that knows it. It is therefore off
unless a writer asks:
WriterProperties::Builder::enable_pfor_interleaved_bit_packing. A reader
that does not know the layout rejects the page rather than misreading it,
because the mode is recorded in a header byte the reader already
validates.

The flag is a request. It reaches only 4-byte columns, and within those
only the full vectors of a page, so a file whose columns are not all
eligible can still be written with one setting: everything the layout
cannot cover is written the way PFOR has always written it.

MakeEncoder grows a defaulted parameter rather than taking the properties,
which would put the whole of the writer's configuration behind a factory
that needs one bit of it.
Because both layouts write the same number of bytes, a flag that never
reached the encoder would still round-trip a table. So the test writes the
same table with the flag and without it and compares the two files: same
length, different bytes is what shows the flag arrived.

The int64 case asserts the opposite -- the two files are identical, byte
for byte -- because the layout cannot apply there and the request is
dropped rather than refused.
The benchmark only ever encoded with the default packing mode, so nothing in
the tree measured the layout this branch adds. Thread the mode through the
two benchmark bodies and register a paired int32 variant for each of the ten
distributions.

The encode variants report CompRatio%, which comes out identical to the
sequential variant's for every distribution: the two layouts write the same
number of bits, so the layout is a decode-speed choice and not a size trade-
off. There is no int64 variant because only 32-bit values reach the
interleaved kernels.
Every existing size is an exact multiple of the 1024-value vector, so the
tail path that falls back to sequential packing was never timed. The
Parquet writer caps a data page at max_rows_per_page = 20,000 rows, which
for int32 always binds before the 1 MB size target, so 20,000 values is
the destination a reader actually decodes into. Add it.

It also has a partial last vector (19 x 1024 + 544), which is what makes
it worth measuring separately from 10240 and 102400.
Name the sequential unpacker in the shared read-bound comment and document
where the interleaved layout comparison is meaningful. Comments only.
lane_delta.h gives lane l the container rows l, 32+l, ..., so its in-lane
predecessor is 32 positions back in file order and its differences are wider
than DELTA_BINARY_PACKED's. The paper assigns lane l the contiguous run
[32l, 32l+32) instead, which makes the in-lane predecessor the immediately
preceding value and the stored differences exactly the format's, at the price
of 32 entry points per block and a transpose back to file order.

transposed_delta.h implements that, with the bases stored either raw or
delta-encoded across lanes, and the benchmark adds four decode variants so
the base stream and the transpose can be priced separately from the
container.
Transpose each lane-parallel delta block while computing its prefix sums,
avoiding a separate repair pass. Preserve the running value across blocks and
verify that the fused path matches the original layout.
The layout had only ever been called as a kernel, from a benchmark that
handed it a payload and a value count. That skips the page header, the
decoder's dispatch and the level handling, so dividing a kernel rate by a
full decoder's rate overstates what a reader would gain.

Give it page framing and register it as an encoding, so the same encoder
and decoder interfaces every other encoding is reached through also reach
this one. The framing adds a little-endian value count ahead of the
payload: the payload's own header does not carry one, decode needs it to
locate every section, and a page's level count cannot stand in because it
counts nulls too.

INT32 only, because the packing kernel works on 32-bit lanes. The payload
still stores its packed words and per-block minimums in host byte order,
which a format specification would have to pin down before a file written
this way could be read anywhere else.
Compare the lane-parallel delta path with Arrow's existing
DELTA_BINARY_PACKED decoder over the same generated inputs.
The encoding is registered for INT32, so the fuzzer reaches it with pages
it has mutated. Four kinds of damage now raise instead of reading past the
buffer: a page shorter than the value count it declares, a page too short
to hold a count at all, a declared count larger than the page header's
level count allows, and a block claiming a bit width no 32-bit lane can
hold.
The 32 entry points of a block are bit-packed, and reading them with one
unaligned 64-bit load per value reads up to 7 bytes past the stream. That
was covered by slack in the kernel's own size bound, but a page is sized to
its contents: when the last block's differences are all equal its payload is
empty, the entry points end the page, and the load runs off the buffer.

Staging the stream -- at most 128 bytes, once per 1024 values -- keeps the
loop free of both a bounds test and short loads, and costs 3% of decode.
Splitting the loop instead, so that only the last values take a short load,
costs 8%: the split needs a division to find where to stop, and the fast
half loses its compile-time trip count.

A sanitizer harness reports an 8-byte read one byte past a 478-byte page
against the old code and nothing against this one, and a sweep of all 33
widths against a reference unpack agrees value for value.
The kernel encodes a whole array and is told its length; a page has to say
how long it is, and a decoder has to be able to tell a truncated or mutated
page from a valid one before it reads a byte of payload.

The framing is a 4-byte value count followed by the kernel's output, and
validation walks the blocks: each block's payload width and entry-point
width are bounded, and every block's contribution to the size is added up
and checked against the page. Decode reads in place when the payload is
4-byte aligned, which it is for a page written by this encoder, and copies
only when a caller hands it something else.
Store the value count, lane entry points, and packed delta blocks needed to
decode a page independently. Validate truncation, block counts, and alignment,
and cover constant and partial final blocks.
InterleavedBitPackingLayout and InterleavedRequestIgnoredForInt64 built
WriterProperties directly and never called enable_pfor_encoding(), so
both threw the preview-feature guard instead of exercising the layout.
Compare sequential and lane-interleaved PFOR using the same encoded column
shapes while holding delta selection fixed.
Add forced raw and delta benchmark variants so layout comparisons do not also
measure a change in representation. Use the production encoder paths for both
variants.
Exclude the generated AVX-512 unpacker from runtime dispatch because it builds
vectors from scalar loads and uses out-of-line calls. Retain AVX2 as the
highest dispatched target until the AVX-512 kernels use vector loads directly.
Build each transpose tile directly from unpacked registers and store it in file
order, avoiding the intermediate 4 KiB grid. Apply the same structure to the
lane-parallel delta path so unpacking, prefix sums, and permutation share one
pass.
Run the four layout variants over five source and destination footprints, and
add a store-only reference to distinguish compute effects from output-bandwidth
limits.
Record the corpus and instruction-set constraints required to interpret the
layout benchmarks, and identify which variants have compatible work and output
order.
Compile baseline and vectorized kernel tables in separate translation units and
select between them with Arrow's runtime dispatch. Parameterize the kernels by
architecture so separately compiled instances remain distinct at link time.
Replace boxed headings and informal benchmark terminology, clarify the validity
criteria, and centralize the shared benchmark corpus. No codec behavior changes.
Add a four-lane NEON transpose that writes file order directly instead of using
the portable scratch-grid fallback. Verify every packed width with and without
a frame bias.
Remove convenience constructors that prevented designated initialization and
caused force_delta to be dropped at layout call sites. Pass complete option
objects through the wrapper and benchmarks.
The ZSTD and LZ4 benchmarks dereferenced Codec::Create directly. For a codec
the build did not enable that Result holds NotImplemented, and dereferencing it
aborts the process, so one unbuilt compression dependency takes down every
other benchmark registered in the same binary -- including all the PFOR ones,
which need no compressor at all. With ARROW_WITH_BROTLI=OFF, Codec::Create
returns "NotImplemented: Support for codec 'brotli' not built"; the same shape
reaches ZSTD and LZ4 wherever those are off.

The four call sites now go through a helper that reports the status through
SkipWithError and returns, which is how arrow/ipc/read_write_benchmark.cc
handles a facility it cannot get.

Also drops BM_DbpGeom128x4. It was a second definition of the body already
registered as BM_DbpAbFull, was never registered itself, and the comment above
it now names BM_DbpAbFull as the 128/4 control instead.
Store the page's packing mode in VectorReader and pass it to each vector decode.
Add a regression test covering multiple interleaved vectors and a sequential
tail through the vector-at-a-time path.
Replace the informal term "ladder" with "shuffle sequence" in comments. No
code changes.
@prtkgaur
prtkgaur force-pushed the pgaur_interleavedPlusFastLanesDelta branch from 633ba32 to 199407c Compare October 3, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants