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...
There was a problem hiding this comment.
Also, the original loop is already chunked correctly.
There was a problem hiding this comment.
I'll revert this, since this will make this performance worse :-(
…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
|
Does this even make a difference in a benchmark? There are so many expensive things happening when reading or writing Parquet (such as compression or encoding) that I doubt the cost of |
|
@ursabot please benchmark lang=C++ |
|
Benchmark runs are scheduled for commit 7ce5324. 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. |
Generally speaking, no. Some optimization might benifit from same optimization in #55308 , write side previously counting the numbers toooo slow. It has only a few optimization during read =_= |
|
Hmm, there seem to be some small but real speedups on the benchmarks. |
| /// It is kept out of line so that the common path (a single Parquet page chunk, | ||
| /// always short enough for one accumulator) is a plain loop without any chunk | ||
| /// bookkeeping in front of it. |
There was a problem hiding this comment.
Was it measured to make a difference?
Was this AI-generated?
There was a problem hiding this comment.
Was it measured to make a difference?
Generally speaking yes. The asm and benchmark shows this could benefit the performance.
Was this AI-generated?
Yes, but all changes are verified and reviewed myself
| // Short inputs are counted first, before the wide loop and before the chunk | ||
| // clamp, because a handful of levels cannot amortize either. Running the | ||
| // small case first is also how folly::simd::detail::simdForEach orders its | ||
| // single steps before the unrolled ones, so that short inputs never pay for | ||
| // the unrolled set up. | ||
| // | ||
| // 32 is the widest per-iteration body this reduction gets: a packed int16_t | ||
| // register holds 8 levels with SSE2/NEON and 16 with AVX2, but clang runs up | ||
| // to 32 levels per iteration on NEON (four registers), so shorter inputs | ||
| // never reach a full iteration on the narrow targets. The bound also has to | ||
| // be a compile-time power of two, which is what turns the loop into an | ||
| // unrolled ladder of 8/16/24-wide compares instead of scalar code. |
There was a problem hiding this comment.
This lengthy comment is AI-generated, right?
There was a problem hiding this comment.
Yeah, I'll rewrite. it myself
| // 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.
What's the point of this if? It's exactly the same code as the other loop below.
There was a problem hiding this comment.
I found this nice for small benchmark, actuall when if (n < 32), directly loop is slower than std::count.
n < 32 will tell the compiler to unroll the loop for it. when n <= 8 it's slower, when n in (8, 32), this making the counting faster.
Without n < 32 ( or this small check ), it just fall into loop.
Detail see: https://godbolt.org/z/nj1vKvqPY
Performance on my MacOS:
| n | u16_guard | u16 | i64_guard | std_count | i64_loop |
|---|---|---|---|---|---|
| 1 | 2.38 | 1.53 | 1.93 | 1.43 | 1.14 |
| 4 | 3.16 | 2.29 | 3.15 | 1.89 | 2.06 |
| 7 | 4.12 | 3.82 | 4.00 | 3.45 | 3.83 |
| 8 | 1.76 | 2.35 | 2.00 | 2.29 | 2.65 |
| 13 | 3.73 | 3.14 | 4.13 | 3.78 | 3.18 |
| 16 | 2.00 | 3.43 | 2.80 | 3.52 | 3.81 |
| 24 | 1.99 | 4.02 | 4.21 | 4.51 | 4.41 |
| 28 | 3.81 | 4.28 | 5.00 | 5.12 | 5.02 |
| 31 | 4.69 | 5.87 | 5.87 | 6.17 | 6.15 |
| 32 | 2.05 | 2.35 | 6.76 | 6.63 | 6.56 |
| 256 | 5.58 | 5.72 | 37.60 | 38.70 | 37.81 |
| 2048 | 38.70 | 39.25 | 278.86 | 277.19 | 283.70 |
I can remove this if we think it's a benchmark hack
There was a problem hiding this comment.
Detail see: https://godbolt.org/z/nj1vKvqPY
Micro-benchmarks like this do not tell us about performance when called from Parquet C++ reader code. Inlining may be different, code placement may be different, etc.
There was a problem hiding this comment.
Yeah, the main difference is that it helps unroll n<32's vectorization, and tells the compiler the loop is for element >32 && <15536. But I can remove this, any way CountIf16 mainly benefit from Chunked counting.
|
|
||
| MinMax PARQUET_EXPORT FindMinMax(const int16_t* levels, int64_t num_levels); | ||
|
|
||
| /// Counts the levels that satisfy `pred`, in chunks small enough that a |
There was a problem hiding this comment.
Can you move these functions to a private/internal header?
There was a problem hiding this comment.
Emmm it's already parquet::internal in a internal header?
There was a problem hiding this comment.
Hmm, the header is not internal, but that can be a separate issue.
| /// `bool(int16_t)`. | ||
| /// \return The number of levels for which `pred` returns true. | ||
| template <typename Predicate> | ||
| inline int64_t CountIf16(const int16_t* levels, int64_t num_levels, Predicate pred) { |
| return count; | ||
| } | ||
| if (num_levels > kMaxCount) { | ||
| return CountIf16Chunked(levels, num_levels, pred); |
There was a problem hiding this comment.
Should use perfect forwarding?
| return CountIf16Chunked(levels, num_levels, pred); | |
| return CountIf16Chunked(levels, num_levels, std::forward<Predicate>(pred)); |
| // 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.
Also, the original loop is already chunked correctly.
|
It's a bit late in my tz, I'll resolve comments tomorrow night |
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: