Repository navigation
Conversation
…ounting
### Rationale for this change
Counting how many of a run of int16_t levels match a predicate (e.g. how
many definition levels equal the max definition level, or how many
repetition levels are 0) is a hot loop in the Parquet reader and writer.
It is currently open-coded as a scalar loop or a std::count() call.
`std::count` accumulates into a 64-bit integer, which forces the compiler
to widen every 16-bit compare result before accumulating and prevents
efficient vectorization. Accumulating into a `uint16_t` instead lets the
compiler keep the whole reduction in 16-bit lanes (e.g. `cmeq.8h` +
`sub.8h` + `addv.8h` on Neon, 32 levels per iteration), and the chunked
loop keeps that accumulator from overflowing: at most `0xFFFF` levels are
counted before the accumulator is folded into the 64-bit total.
The chunk bookkeeping (clamping the chunk length, folding the partial
count, advancing the pointer) is a fixed cost per call, and for inputs
shorter than one vector iteration it dominates: counting 7 levels that
way is measurably slower than `std::count`, even though both end up
running the same scalar loop. Such short inputs are therefore counted
directly, without going through the chunk loop.
On an Apple M-series machine, counting 2048 int16 levels where every
tenth is missing drops from ~232 ns to ~32 ns (about 7x), while short
inputs are at least as fast as `std::count`.
### What changes are included in this PR?
- Add `parquet::internal::CountIf16` next to the other level scanning
primitives in `parquet/level_comparison.h`. It is a header-only inline
template, since the level conversion translation unit is compiled twice
(standard and BMI2).
- Add unit tests covering empty input, a mixed predicate, short inputs
around the vectorization threshold, and the `0xFFFF` chunk boundaries.
- Add `BM_CountPresentLevels{StdCount,CountIf16}` to the level conversion
benchmark to track the improvement.
### Are these changes tested?
New `CountIf16.*` tests in `level_conversion_test.cc`, run as part of
`parquet-reader-test`.
### Are there any user-facing changes?
No.
|
|
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues are reported for the shared Parquet level-counting helper and its integrations.
No open issues 🎉
What changed in this PR
This PR addresses GH-55399 by introducing a vectorization-friendly helper for counting Parquet definition and repetition levels, then applying it across reading, writing, and size statistics.
Changes:
- Add
CountIf16with 16-bit accumulators, overflow-safe chunking, and a short-input path. - Replace existing counting loops and
std::countcalls with the shared helper. - Add tests for predicates, short inputs, and chunk boundaries.
| File | Description |
|---|---|
cpp/src/parquet/size_statistics.cc |
Use the helper for binary-level histogram counts. |
cpp/src/parquet/level_conversion_test.cc |
Test counting correctness and chunk boundaries. |
cpp/src/parquet/level_comparison.h |
Implement CountIf16 and its chunked fallback. |
cpp/src/parquet/file_reader.cc |
Use the helper to count rows during file scans. |
cpp/src/parquet/column_writer.cc |
Replace value and row counting loops with the helper. |
cpp/src/parquet/column_reader.cc |
Apply the helper to value counting, record delimiting, and skipping. |
Review effort: Balanced
|
It looks like an interesting change. It might be worth submitting to LLVM to optimize the implementation of |
|
There're existing discussion about this, see: llvm/llvm-project#204897 But anyway if you want to benefit from this, you need at least |
| // unrolled ladder of 8/16/24-wide compares instead of scalar code. | ||
| constexpr int64_t kSmallLevels = 32; | ||
| constexpr int64_t kMaxCount = std::numeric_limits<uint16_t>::max(); | ||
| if (num_levels < kSmallLevels) { |
There was a problem hiding this comment.
Does it make sense to keep this if branch, rather than letting LLVM automatically generate optimized code for short loops?
There was a problem hiding this comment.
I don't know, it would making the very short loops (9-31 elements?) a bit slower ( like 1.5ns -> 3ns ) on my Mac M4 Pro. If this doesn't matter just counting by chunk is ok.
| // Counting the 1s directly gives us the number of 1s, and the number of 0s | ||
| // follows. CountIf16 accumulates in the int16_t space in chunks, which the | ||
| // compiler vectorizes efficiently and which cannot overflow. | ||
| const int64_t hist1 = internal::CountIf16(levels.data(), num_levels, |
There was a problem hiding this comment.
My LLM pointed out a performance regression here, and I think it’s right. There’s a logical constraint—each level can only be 1 or 0, so a summation could replace std::count. However, the compiler can’t infer this, and the new code ends up performing a check (level == 1 ? 1 : 0), which actually makes it slower.
There was a problem hiding this comment.
aha, nice catch. I would try whether an ASSUME helps
There was a problem hiding this comment.
I think similar optimizations may be applied elsewhere in this PR. Add a branch for descr_->max_repetition_level() == 1 and calcuate the result via num_levels - std::accumulate instead of std::count.
There was a problem hiding this comment.
I've tried it, but the optimization wouldn't be many. And I just found only that user case. Seems user can currently just hand-written accumulate here.🤔
There was a problem hiding this comment.
I’ve found that the ASSUME macro can easily resolve this issue https://godbolt.org/z/Mf1EzdPP1. However, the better approach here might be to leave the original code unmodified.
There was a problem hiding this comment.
Yes, I think the problem is that there is only one case here, and for gcc < 13, it doesn't have assume 🤔 So maybe leaving it here is ok...
…ader and writer ### Rationale for this change Follow-up to the `CountIf16` helper: use it for the level counting loops in the Parquet column reader/writer and in the file scanner, replacing open-coded scalar loops and `std::count` calls. Converted sites: - `TypedColumnReaderImpl::ReadLevelsInCurrentPage`: count non-null values. - `TypedRecordReader::SkipRecordsInBufferNonRepeated`: count values to skip. - `TypedRecordReader::DelimitRecords`: count records (rep level == 0) and values seen (def level == max). - `TypedRecordReader::ReadDenseForOptionalInBuffer`: count values to read. - `TypedColumnWriterImpl::WriteLevels`: count values to write (both overloads) and records written (rep level == 0). - `TypedColumnWriterImpl::MaybeCalculateValidityBits`: count values to write and spaced values to write. - `ScanFileContents`: count rows (rep level == 0). Sites are left as is where counting is not the bottleneck: the per-element bitmap loops in `DefRepLevelsToListInfo` also build validity bitmaps and write list offsets for every level, `page_index.cc` counts pages rather than levels, and the non-nullable validation loop in `WriteArrow` needs to `break` early on the first mismatch. `UpdateLevelHistogram`'s `max_level == 1` fast path is also left as is: its levels are known to be 0 or 1, so it sums them with `std::accumulate` in the `int16_t` space, which is a single 16-bit add per vector, while counting the 1s costs a compare and a subtract. ### What changes are included in this PR? Use `CountIf16` for the level counting call sites above. ### Are these changes tested? Covered by the existing Parquet reader, writer and Arrow reader/writer tests. ### Are there any user-facing changes? No.
e1291b9 to
7ce5324
Compare
Rationale for this change
std::countwith int16_t boolean is hard to optimize. So, adding a CountIf16 here.Before x86:
After x86:
Before ARM:
After arm:
Some notes:
What changes are included in this PR?
Adding a CounfIf16 to optimize level counting.
Are these changes tested?
Yes and covered by existing.
Are there any user-facing changes?
No
Was AI used for this PR?
PR code and description written by:
Reviewed before submission by: