GH-48701: [C++][Parquet] Add ALPpd encoding - #48345
Conversation
|
Thanks for opening a pull request! 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? or See also: |
1b78a5c to
d563ce0
Compare
|
Thanks @prtkgaur -- it is super exciting to see this movement. Unfortunately, I am not familiar with the C/C++ codebase to give this a realistic review. I started the CI checks on this PR and had some comments about the testing. |
| std::string tarball_path = std::string(__FILE__); | ||
| tarball_path = tarball_path.substr(0, tarball_path.find_last_of("/\\")); | ||
| tarball_path = tarball_path.substr(0, tarball_path.find_last_of("/\\")); | ||
| tarball_path += "/arrow/cpp/submodules/parquet-testing/data/floatingpoint_data.tar.gz"; |
There was a problem hiding this comment.
@Reviewer the data sits in the parquet-testing submodule
apache/parquet-testing#100
|
|
||
| // Unsafe resize without initialization - use only when you will immediately | ||
| // overwrite the memory (e.g., before memcpy). Only safe for POD types. | ||
| void UnsafeResize(size_t n) { |
There was a problem hiding this comment.
Using this over resize gave us around 2-3% performance improvement
0c035b7 to
1cb0852
Compare
|
Talked offline and wanted to capture notes on high-level changes:
|
35f1ad7 to
0908342
Compare
Thanks for the feedback @emkornfield. We have addressed
|
|
|
|
|
||
| // Slow path: partial read - decode to intermediate buffer | ||
| // ALP Bit unpacker needs batches of 64 | ||
| if (needs_decode_) { |
There was a problem hiding this comment.
TODO(prateek) : check with Antoine and other reviewers if there is a way to relax this constraint. Though this has negligible impact on performance.
There was a problem hiding this comment.
umm ideally this submodule shouldn't be attached with this commit.
Should revert the changes to this file.
There was a problem hiding this comment.
Please check cpp/src/arrow/util/alp/ALP_Encoding_Specification_terse.md for a more terse spec of the encoding.
There was a problem hiding this comment.
Also this file will be removed once the spec in parquet format repository is merged.
|
|
||
| ## 2. Data Layout | ||
|
|
||
| ALP encoding consists of a page-level header followed by one or more encoded vectors. Each vector contains up to 1024 elements. |
There was a problem hiding this comment.
Replace 1024 with the constant specified in AlpConstant file.
1b08599 to
f5f5011
Compare
Merges the duplicate float/double fixtures into one typed suite and replaces tolerant float comparisons with the bit-exact helper the file already defines.
|
Sorry for the long delay! I'll take a look. |
| auto file = test::get_data_file(file_name); | ||
| auto pool = ::arrow::default_memory_pool(); | ||
| std::unique_ptr<FileReader> parquet_reader; | ||
| ASSERT_OK(FileReader::Make(pool, ParquetFileReader::OpenFile(file, false), |
There was a problem hiding this comment.
This test does not compile any more if rebased.
There was a problem hiding this comment.
These tests have since moved to parquet/arrow/arrow_encoding_test.cc, per your other comment, so a rebase now picks up a different file than the one you were on. If you're still hitting a compile error there, paste it and I'll chase it — I'd rather not close this on the move having happened to fix it.
| int64_t EstimatedDataEncodedSize() override { return sink_.length(); } | ||
|
|
||
| std::shared_ptr<Buffer> FlushValues() override { | ||
| if (sink_.length() == 0) { |
There was a problem hiding this comment.
When an optional page is all null, PutSpaced adds zero values and sink_ stays empty. This path still writes a page because the writer counts levels, so returning a zero-byte payload violates the required 7-byte ALP header and the reader rejects it.
There was a problem hiding this comment.
Real bug, fixed. FlushValues no longer short-circuits on an empty sink — it runs the normal path with a zero count, producing a 7-byte header with no offset section. The reader accepts that: Open returns early when there are no vectors and num_values_ comes out 0.
Two tests write an all-null optional V2 page and read it back; the writer path had no coverage at all before. I checked they fail without the fix.
| const uint8_t* body = input + AlpHeader::kSize; | ||
| const int64_t body_size = input_size - static_cast<int64_t>(AlpHeader::kSize); | ||
|
|
||
| ARROW_RETURN_NOT_OK(DecodeAlp<TargetType>(num_elements, body, body_size, |
There was a problem hiding this comment.
DecodeAlp sizes its work from header.num_elements, but AlpDecoder returns the outer page count. If the header count is smaller (including zero), decoding can return OK after writing only part of the output. Please require the header count to match the expected non-null value count.
There was a problem hiding this comment.
Required now — Decode fails when the two disagree in either direction, since a smaller header count leaves part of output unwritten and a larger one overruns it.
AlpRobustnessTest.HeaderElementCountMismatch drives both sides, including the zero case you called out. The decoder passing the wrong count in the first place was the other half of this; that's your SetData comment below.
| " > capacity=", num_elements); | ||
| } | ||
|
|
||
| // Validate offset is within bounds and enough buffer remains for metadata |
There was a problem hiding this comment.
The ALP spec requires offset[0] = num_vectors * 4 and each later offset to be the previous offset plus the previous vector size. This code only checks each offset independently, so duplicate, backward, or gapped offsets are accepted and can decode the wrong bytes.
There was a problem hiding this comment.
Makes sense. The chain rule is checked now, exactly as you describe. VectorReader::Open checks the offsets in one pass against a running expected value, starting at num_vectors * sizeof(OffsetType) and adding each vector's size, and says which vector broke the chain. The end of the last vector is bound-checked against the buffer.
AlpRobustnessTest.CorruptedOffsetChain covers duplicate, backward, gapped and skipped offsets, all chosen to stay inside the buffer so only the chain rule can reject them.
| list(APPEND ARROW_UTIL_SRCS util/compression_zstd.cc) | ||
| endif() | ||
|
|
||
| # ALP (for Parquet encoder/decoder) |
There was a problem hiding this comment.
We need to add these to meson.build as well.
There was a problem hiding this comment.
Added to meson.build too. The CMake side had a second problem worth mentioning: the ALP sources were in ARROW_UTIL_SRCS and also listed in the alp-test target, so the test compiled them a second time instead of linking the library's copies. They build once now, and the test is registered in both build systems.
| // ---------------------------------------------------------------------- | ||
| // ALP encoder (Adaptive Lossless floating-Point) | ||
|
|
||
| // TODO: support incremental encoding. Today `Put` only appends raw input |
There was a problem hiding this comment.
Let's shorten these comments. They look like notes that taken by coding agents to talk to themselves.
There was a problem hiding this comment.
Fair, and trimmed — the block above AlpEncoder went from 33 lines to 7. What's left is the two TODOs and one fact each: Put buffers the page, so working memory scales with it, and the PLAIN fallback needs a ratio estimate the sampler computes but doesn't expose. I kept the paper's break-even numbers, since they're the evidence the fallback matters; the argument about where that decision belongs is down to one clause.
| #include "arrow/array/builder_dict.h" | ||
| #include "arrow/array/builder_primitive.h" | ||
| #include "arrow/type_traits.h" | ||
| #include "arrow/util/alp/alp.h" |
There was a problem hiding this comment.
We probably can optimize header structure to include only one alp header.
There was a problem hiding this comment.
decoder.cc is down to one, alp_codec_internal.h; it had been pulling in three. What removed the other two is that the decoder no longer derives anything itself — SetData opens a VectorReader and takes the page's value count from it, instead of computing it from AlpConstants and the header layout, so the codec header is the only one it needs. encoder.cc still has a second include, for AlpConstants::kAlpVectorSize, the writer's fixed vector size.
| using Base = TypedDecoderImpl<DType>; | ||
| using T = typename DType::c_type; | ||
|
|
||
| // TODO: support incremental decode. Partial reads currently decode the entire |
There was a problem hiding this comment.
I would argue that incremental decoding is worth doing in this PR.
There was a problem hiding this comment.
Agreed, and done. VectorReader validates the header and the whole offset chain once in Open and then decodes any single vector on demand, so Decode is Open plus a loop and both paths share one validator instead of having one each — a few lines longer than the whole-page version rather than shorter.
A vector entered at its first value and read to its end decodes straight into the caller's buffer; anything else goes through one vector of scratch. So the decoder holds 4 or 8 KB rather than a page, pool-backed and allocated on first use — which also answers your scratch-buffer comment. TestAlpEncoding.BatchedDecode covers six batch plans over a 2000-value page.
| private: | ||
| std::vector<T> decoded_buffer_; | ||
| size_t current_offset_; | ||
| bool needs_decode_; |
There was a problem hiding this comment.
needs_decode_, current_offset_, and inherited num_values_ all track the same page progress, while different branches update different subsets. Please consolidate this into one source of truth so the state cannot diverge.
There was a problem hiding this comment.
Consolidated to one: the inherited num_values_. needs_decode_ and current_offset_ are gone, along with a scratch_filled_ flag that my first attempt at this introduced. Position is derived as total_values_ - num_values_, with total_values_ set once by SetData and not touched again, and Decode and DecodeArrow each decrement num_values_ exactly once, so the two paths can't disagree.
|
|
||
| private: | ||
| ::arrow::BufferBuilder sink_; | ||
| int32_t vector_size_; |
There was a problem hiding this comment.
| int32_t vector_size_; | |
| const int32_t vector_size_; |
There was a problem hiding this comment.
The member is gone instead — it's a static constexpr int32_t kVectorSize on the encoder now, since nothing sets it per instance.
encoding_alp_benchmark.cc loaded 20 real-world floating-point CSVs from cpp/submodules/parquet-testing, which required pointing the submodule at a fork carrying a dataset tarball several times larger than the whole of parquet-testing. That data was never accepted upstream, so the benchmark cannot build for anyone else and the submodule bump has to travel with the branch. Remove the benchmark, its CMake entry, and the submodule bump. The synthetic ALP cases in encoding_benchmark.cc are unaffected and stay.
apache/parquet-testing now ships alp_extended.zstd.parquet, published for exactly this purpose. All eight of its columns hold the same 9032 values; float_plain and double_plain are PLAIN-encoded references, so a correct decoder reproduces them bit for bit and the test needs no hardcoded expected values. Its three vector sizes per type (1024, 4096, 32) force a reader to honour log_vector_size from the page header rather than assume the default, which nothing here covered before. This replaces the eight tests that read ALP files and expected-value CSVs from a fork of parquet-testing. That data was never accepted upstream, so those tests could not run against the submodule pointer this branch now carries. The conformance file covers strictly more: three distinct NaN bit patterns, +/-Inf, -0.0, subnormals, a value that cannot round-trip as a decimal, all-exception vectors, a constant vector (bit_width 0), and nulls. It also retires ~150 lines of hand-rolled CSV parsing. Comparison is on bit patterns rather than values, so NaN payloads are actually checked (NaN != NaN under ==) and -0.0 is not accepted in place of 0.0. A separate test asserts the reference column really does contain the corner cases, so the suite cannot pass vacuously if the file is ever regenerated as ordinary data.
ALP is a Preview feature in the Parquet format. The Preview note in Encodings.md asks writers to keep such an encoding behind an opt-in flag, because a reader that has not implemented it must fail rather than return wrong values, and the ecosystem has not caught up yet. Add WriterProperties::Builder::enable_alp_encoding(), with a per-column overload, defaulting to disabled. The flag grants permission; it does not select the encoding. Selecting Encoding::ALP for a column that has not been granted permission makes build() throw, rather than silently falling back to PLAIN, so a caller who asked for ALP never gets something else without being told. WriterProperties' constructor is private, so build() is the only way to construct one and the check cannot be bypassed. This mirrors parquet-java, which added withAlpEncoding(boolean) with the same default. The versioning discussion on dev@ settled that implementations agree on behaviour but each chooses its own API shape, and there is no format-level field to write yet, so this is writer-side only. Decoding stays unconditional: Arrow implements ALP, so it can always read what it is given.
Bit extraction in the conformance test picked a 64-bit holder for any type that was not four bytes wide, then copied only sizeof(CType) bytes into it, so instantiating the fixture on a half float would have compared six bytes of uninitialized stack. Move it into a helper with a static_assert on the width. ReferenceColumnsCoverCornerCases was plural but only looked at double_plain; check float_plain too. It also counted NaNs while claiming to check "three distinct NaN bit patterns" -- distinctness is the whole reason to compare bits, so collect the payloads in a set and assert on its size. Print bit patterns in hex when a comparison fails, since decimal is unreadable for a value like 0x7ff800deadbeef00. AlpRejectedWithoutOptIn matched only "enable_alp_encoding", which both branches of the check emit, so it did not pin down that the default column properties are what rejected the build. Match on both substrings. Add ALP to the encodings support table in docs/source/cpp/parquet.rst, which lists every other encoding Arrow reads and writes, with a note covering the Preview status, the opt-in flag and the FLOAT/DOUBLE restriction.
Refuse a count above INT32_MAX before it sizes the sampling span, require the page header's count to equal the caller's, and check each vector offset against where the previous vector ended rather than only against the buffer bounds.
The page header counts nulls, so AlpDecoder now reads its count from the ALP header, keeps one progress variable, expands nulls with SpacedExpandLeftward and holds its whole-page scratch in a pool-backed buffer.
An all-null optional page buffers no values, and the empty payload it used to emit has no header for the reader to load. Also drop the vector_size parameter nothing passes and trim the encoder's TODO comments.
Move them into the sorted ARROW_UTIL_SRCS list so alp-test links them instead of compiling them a second time, and add the same sources and test to meson.
Every multi-byte wire field now converts explicitly, so the big-endian static_assert is gone. The bit-packed values already needed no conversion.
It is only used by the header codec in alp_codec.cc, so it belongs in that file's anonymous namespace.
Each test was checked against the code with its validation removed, so a regression in any of the three shows up as a failure.
The batched test fails if the decoder's remaining count and its scratch index disagree, which is the divergence the old three-variable state allowed.
AlpCodec<T>::VectorReader validates the header and offset chain once, then decodes any single vector on demand, so a batched reader needs scratch for one vector rather than for a whole page.
arrow_reader_writer_test.cc is already long, so the encoding round trips move to arrow/arrow_encoding_test.cc, which later encodings can share.
The names now say what the install rules already do: these headers are not installed and carry no backward-compatibility obligation.
The test hand-duplicated a float block and a double block, which is what the type-parameterized suite is for. Also move the float decode benchmark's output buffer out of the loop and stop timing its SetData, matching the double one.
Incremental decode calls DecodeVector once per vector, and each call built a fresh view and a fresh vector of unpacked integers, so a page's worth of vectors meant a heap allocation per vector (three, when the vector had exceptions). Let the reader hold that scratch and reuse it: ResetDataOnly points an existing view at the next vector's data, and DecompressVectorView now unpacks into a caller-provided span instead of returning a vector. Decoding 32 vectors of doubles allocates 11 times for the first vector and nothing after it, where before it allocated on every one.
The ALP decode benchmarks were the only ones in the file hiding SetData behind PauseTiming, which left their numbers incomparable to their neighbours'.
parquet.thrift is a vendored copy of the format repo's file, and ALP has since been merged there. The comment written here says the same thing in different words, and points at Encodings.md#alp for the full spec, which now lives in AlpEncoding.md.
parquet/encoder.cc and decoder.cc call AlpCodec, whose out-of-line definitions live in libarrow, so the ten symbols they reference have to cross the library boundary. Every other arrow symbol libparquet imports carries ARROW_EXPORT; these did not, which breaks an MSVC shared build and any build that gives arrow hidden default visibility. Also fold the three-deep namespace into one, which is what clang-tidy's modernize-concat-nested-namespaces asks for.
Four of the five const members were copies of AlpConstants values and two of those were only ever read to derive the sampling interval, which is itself a constant. Reference the constants directly and keep the derived one as a static constexpr, which removes the constructor and 40 bytes from every sampler.
Co-authored-by: dhirhan17@gmail.com
Rationale for this change
ALP significantly improves on the compression ratio and decompression speed over of float/double columns over other encoding/compression techniques.
Spec
Spec
This PR also contains a terse version of the spec in the file cpp/src/arrow/util/alp/ALP_Encoding_Specification_terse.md which can go in the Encodings.md
Parquet Format PR
Dataset PR (parquet-testing)
apache/parquet-testing#100
What changes are included in this PR?
This PR
Introduces ALP (pseudo-decimal) encoding into c++ arrow code.
We also provide benchmarks and dataset to prove the effectiveness of the above algorithm.
Adding above needed us to add following classes.
Integration of the above code was done in
Are these changes tested?
Unit tests
Benchmark tests
Are there any user-facing changes?
DuckDB