Skip to content

GH-50703: [C++][Parquet] Reserve dictionary byte array data before decoding - #50710

Draft
Punisheroot wants to merge 5 commits into
apache:mainfrom
Punisheroot:perf/js/reserve-parquet-dictionary-byte-arrays
Draft

GH-50703: [C++][Parquet] Reserve dictionary byte array data before decoding#50710
Punisheroot wants to merge 5 commits into
apache:mainfrom
Punisheroot:perf/js/reserve-parquet-dictionary-byte-arrays

Conversation

@Punisheroot

@Punisheroot Punisheroot commented Jul 29, 2026

Copy link
Copy Markdown

What changed

This changes dictionary-encoded BYTE_ARRAY decoding for Binary, String,
LargeBinary, and LargeString builders to:

  • decode dictionary indices up front into the decoder's reusable scratch buffer;
  • validate the decoded index count and dictionary bounds before appending;
  • compute the exact decoded value-data length;
  • reserve that data capacity once and use the builder's unsafe append path.

BinaryView and StringView builders keep the existing bounded streaming path,
since short view values are stored inline and do not benefit from reserving the
dictionary values' total byte length.

The patch also adds dictionary decode benchmarks with 10% and 50% null density
and a regression test for out-of-range and truncated dictionary-index streams.

Why

The dictionary path previously could not derive a useful data-size estimate
from page metadata, so every value used the checked append path and the value
buffer grew incrementally. Predecoding the indices provides the exact payload
size while hoisting the bounds checks out of the append loop.

The trade-off is one reusable int32_t scratch entry per decoded non-null value
and an additional pass over the indices. The scratch allocation is retained for
reuse across decode calls.

This addresses the dictionary-path exact-reservation proposal in GH-50703.

Benchmarks

Windows MSVC Release build, 65,536 values, baseline/optimized/optimized/baseline
order, five randomized repetitions per run:

Null density Binary time improvement BinaryView control Improvement normalized to control
0% 21.9% -1.2% 23.4%
10% 25.5% -3.9% 30.6%
50% 17.2% -0.5% 17.7%

BinaryView is the unchanged-path control. These are local decoder
microbenchmarks rather than an end-to-end scan of the reporter's 6.5 GB file.

Validation

  • parquet-encoding-test.exe --gtest_filter=DictEncodingAdHoc.DenseDecodeRejectsInvalidOrTruncatedIndices: passed
  • complete parquet-encoding-test.exe suite: 136/136 passed
  • optimized dictionary decode benchmark smoke run: passed
  • git diff --check: passed

AI assistance disclosure

AI assistance was used for code exploration, implementation drafting, and
test/benchmark iteration. I reviewed and validated the resulting changes and
can explain and take ownership of them.

@Punisheroot
Punisheroot marked this pull request as ready for review July 29, 2026 12:54
@Punisheroot
Punisheroot requested a review from wgtmac as a code owner July 29, 2026 12:54
Copilot AI review requested due to automatic review settings July 29, 2026 12:54
@Punisheroot
Punisheroot requested a review from pitrou as a code owner July 29, 2026 12:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes Parquet dictionary-encoded BYTE_ARRAY decoding into Arrow binary/string builders by predecoding dictionary indices to compute the exact decoded payload size, reserving once, and then appending via unsafe fast paths. It also adds a regression test for invalid/truncated index streams and extends microbenchmarks to include null-density scenarios.

Changes:

  • Predecode dictionary indices for Binary/String/LargeBinary/LargeString builders, validate bounds up front, compute exact decoded byte length, and reserve once before appending.
  • Add a regression test ensuring dense dictionary decode rejects out-of-range and truncated index streams.
  • Extend dictionary decode benchmarks with 10% and 50% null-density configurations.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
cpp/src/parquet/decoder.cc Predecode indices + exact reservation path for dictionary BYTE_ARRAY decoding into non-view Arrow builders.
cpp/src/parquet/encoding_test.cc Adds regression coverage for invalid/truncated dictionary-index streams.
cpp/src/parquet/encoding_benchmark.cc Adds benchmark variants exercising dense decode with configurable null density.

Comment thread cpp/src/parquet/decoder.cc
Comment on lines +1343 to +1347
} else {
for (int64_t i = 0; i < length; ++i) {
helper->UnsafeAppendNull();
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I benchmarked this exact change by replacing the per-value loop with helper->AppendNulls(length) in an ABBA comparison using the same Windows MSVC Release build. It improved the 10% randomly distributed null case by approximately 2.4–2.6%, but regressed the 50% null case by approximately 10–12%. The latter produces many short null runs, for which the bulk call is not consistently beneficial.
A separate microbenchmark showed clear benefits only for sufficiently long runs, so an adaptive run-length threshold may be worth exploring separately. For this PR, I’d prefer to retain the existing per-value path and keep the change focused on exact dictionary payload reservation.

@pitrou

pitrou commented Jul 29, 2026

Copy link
Copy Markdown
Member

@ursabot please benchmark lang=C++

@rok

rok commented Jul 29, 2026

Copy link
Copy Markdown
Member

Benchmark runs are scheduled for commit 74acebb. Watch https://buildkite.com/apache-arrow and https://conbench.arrow-dev.org for updates. A comment will be posted here when the runs are complete.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 29, 2026 13:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 29, 2026 13:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@conbench-apache-arrow

Copy link
Copy Markdown

Thanks for your patience. Conbench analyzed the 4 benchmarking runs that have been run so far on PR commit 74acebb.

There were 15 benchmark results indicating a performance regression:

The full Conbench report has more details.

Copilot AI review requested due to automatic review settings July 29, 2026 21:35
@Punisheroot

Copy link
Copy Markdown
Author

Thanks for the Conbench report. I investigated all 15 possible regressions
reported for PR commit 74acebb .

The follow-up fix described below is included in commit 59e8845.

Root cause

Six regressions affected BM_ArrowBinaryPlain on the
amd64-c6a-4xlarge-linux runner:

  • DecodeArrow_Dense: 4,096, 32,768, and 65,536 values
  • DecodeArrowNonNull_Dense: 4,096, 32,768, and 65,536 values

These regressions were real but indirect. The Plain decoding algorithm was not
changed. Instead, the additional dictionary decoding code changed GCC 14's
inlining decision for decoder.cc. GCC stopped inlining the existing
ArrowBinaryHelper::AppendValue implementation into the Plain decode loop,
introducing an out-of-line call for every decoded value.

I confirmed this by comparing the generated GCC 14 code for the baseline, the
original PR, and the follow-up fix.

Classification of the other results

Seven alerts belong to separate Arrow benchmark executables: the two temporal
cases, the ARM allocator case, and the four bit/cast/unpacking cases on the m5
runner. These executables do not link libparquet, and none of the three files
changed by this PR contributes code to them. Their measured deltas are genuine
observations from those runs, but there is no source or binary dependency through
which this PR could change their benchmark code.

The Boolean RLE and FLBA cases require a more cautious classification because
they are part of parquet-encoding-benchmark and therefore share the executable
or libparquet affected by the PR. Neither executes the modified dictionary
path:

  • the GCC 14 hot function body for Boolean RLE is byte-identical between the
    baseline, original PR, and follow-up;
  • FLBA calls arrow::util::internal::ByteStreamSplitDecode directly rather than
    the modified Parquet decoder.

Paired GCC 14 controls did not reproduce either regression. With the complete
baseline/PR/follow-up executables and libraries, the median changes versus the
baseline were:

  • Boolean RLE: original PR -0.59%, follow-up -0.80%;
  • FLBA: original PR +0.49%, follow-up -0.05%.

A second experiment using the same executable for every comparison and changing
only libparquet also remained within approximately 0.5%. These local results
strongly suggest that the Conbench RLE and FLBA alerts were run- or
machine-specific. However, because code layout differs in the linked c6a binary,
a new Apache-runner result remains the authoritative confirmation.

Follow-up fix

The follow-up adds a narrowly scoped overload for the known-size BinaryType
append path:

  • the common per-value path is explicitly kept inline;
  • the rare chunk-rollover path is moved to a no-inline helper;
  • the existing optional-size path remains unchanged for other callers;
  • the dictionary predecode, validation, exact reservation, and reusable scratch
    buffer remain unchanged.

This restores the intended generated code without increasing GCC's global
inline budget. The final GCC 14 decoder.cc object contains 277 fewer bytes of
text than the original PR object.

Performance validation

I could not reproduce the exact Apache c6a host locally, so these measurements
do not replace a new Conbench run. I used GCC 14.2, -O2, the c6a optimization
flags, CPU pinning, rotated execution order, and paired comparisons.

For the six originally regressed Plain cases:

  • follow-up versus baseline: 25.3% to 51.6% faster;
  • follow-up versus the original PR: 38.8% to 66.6% faster.

The dictionary optimization remains intact:

  • follow-up versus baseline: 29.0% to 63.9% faster;
  • follow-up versus the original PR: median differences from -0.56% to
    +0.92%, with no systematic loss.

All 76 registered BM_ArrowBinary* cases were screened in six paired rounds.
No repeatable regression remained. The only short-run outlier was +2.70%;
twelve longer pairs for that exact case produced a -0.05% median with six
positive and six negative results.

Correctness validation

The exact final source passed:

  • parquet-encoding-test: 136/136;
  • parquet-reader-test: 149 passed, 5 skipped;
  • parquet-arrow-reader-writer-test: 823 passed, 9 skipped;
  • total: 1,108 passed, 14 skipped, 0 failed;
  • 16 focused ASan/UBSan Plain and Dictionary cases, with no findings.

A disposable diagnostic build also forced the normally rare chunk rollover at
4 KiB and verified every emitted chunk against the expected array slices for
Plain and Dictionary decoding, with and without nulls.

Relation to the original PR benchmarks

The original Windows/MSVC measurements remain valid for the dictionary cases
they measured: they showed a 17.2%–25.5% improvement at 65,536 values. However,
they did not exercise the Linux/GCC Plain-path code-generation side effect later
exposed by Conbench.

The follow-up therefore supplements the original measurements with the missing
Linux/GCC controls. It preserves the dictionary improvement while removing the
indirect Plain regression.

A new Conbench run on the Apache runners is still required for final validation.

Thank you!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

cpp/src/parquet/encoding_benchmark.cc:1335

  • DecodeArrowWithNullDenseBenchmark regenerates inputs and re-encodes before the decode loop without pausing timing, so the benchmark measures setup+encode work in addition to decode. Pause timing around the reinitialization so results reflect decode cost (consistent with the other Decode* benchmarks which rely on SetUp()).
  void DecodeArrowWithNullDenseBenchmark(benchmark::State& state) {
    null_probability_ = static_cast<double>(state.range(1)) / 10000;
    InitDataInputs();
    DoEncodeArrow();

Comment on lines +1353 to +1371
if (valid) {
for (int64_t i = 0; i < length; ++i) {
const auto& val = dict_values[decoded_indices[pos_indices++]];
RETURN_NOT_OK(helper->AppendValue(
val.ptr, static_cast<int32_t>(val.len), remaining_data_length));
remaining_data_length -= val.len;
}
values_decoded += static_cast<int>(length);
} else {
for (int64_t i = 0; i < length; ++i) {
helper->UnsafeAppendNull();
}
}
return Status::OK();
}));
DCHECK_EQ(pos_indices, values_to_decode);
DCHECK_EQ(remaining_data_length, 0);
*out_num_values = values_decoded;
return Status::OK();

@Punisheroot Punisheroot Jul 29, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The defensive validity-bitmap fix is included in commit d9e9f6e.

Copilot AI review requested due to automatic review settings July 29, 2026 22:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

cpp/src/parquet/decoder.cc:2497

  • The validity-bitmap validation error doesn’t report the expected vs actual non-null counts, which makes diagnosing upstream bitmap/null_count mismatches harder. Including both counts would align with the more detailed index-stream validation message above.
  if (ARROW_PREDICT_FALSE(actual_non_null != num_values - null_count)) {
    throw ParquetException(
        "Invalid validity bitmap: null_count does not match the number of non-null "
        "values");
  }

Comment on lines +2479 to +2483
RETURN_NOT_OK(PushChunk());
RETURN_NOT_OK(ReserveInitialChunkData(estimated_remaining_data_length));
chunk_space_remaining_ -= length;
--entries_remaining_;
builder_->UnsafeAppend(data, length);
@Punisheroot
Punisheroot marked this pull request as draft July 30, 2026 00:14
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.

4 participants