perf: portable block-summed percentile scan (drops AVX2 dispatch) + single-pass hdr_value_at_percentiles - #137
Conversation
…_percentiles
Replace the runtime-dispatched AVX2 scan in get_value_from_idx_up_to_count
(the _scalar + _avx2 + __builtin_cpu_supports("avx2") dispatcher) with a single
portable block-summed scan: sum a fixed block of counts, then test the running
total against the target once per block, dropping the early-exit branch
frequency from one-per-element to one-per-block. Removes <immintrin.h>, the
__attribute__((target("avx2"))) function and the HDR_HAS_AVX2_DISPATCH machinery,
and is faster than the AVX2 path it replaces on every Intel CPU tested.
Rewrite hdr_value_at_percentiles to resolve all requested percentiles in a
single ascending pass over counts[] instead of one hdr_iter_next() walk per call
(~12-16x faster). Document the non-decreasing-percentiles requirement and correct
the @return doc (EINVAL, not ENOMEM). No public API change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rdening Three follow-ups from adversarial review: 1. CORRECTNESS REGRESSION on decoded histograms (concurrency reviewer). hdr_log_read / hdr_decode_compressed set h->normalizing_index_offset from the wire format (hdr_histogram_log.c:540,642). The previous hdr_value_at_percentiles walked buckets via hdr_iter_next -> counts_get_normalised, which is offset-correct. The direct counts[idx] read in the new fast scan returns the wrong cumulative count when offset != 0. Add an offset-aware fallback at the top of both get_value_from_idx_up_to_count and hdr_value_at_percentiles that uses counts_get_normalised for the (rare) decoded-histogram case. The fast block-summed path stays for offset==0, preserving the perf claim. 2. RESTORE uint64 hardening on the block sum (UB reviewer). The previous AVX2 scan explicitly cast lane sums through uint64_t with a comment "avoid signed-overflow UB if invariants are violated". The PR dropped that hardening when removing AVX2. Restore in the block sum and crossing-test, matching prior intent without changing valid-state behavior. 3. HDR_UNLIKELY on cold branches (portability reviewer). The file's existing convention (update_min_max, normalize_index) marks data-dependent cold paths with HDR_UNLIKELY. Apply to the block-crossing branch and the offset-aware fallback predicate for consistency. Local validation: 5/5 ctest suites pass, including hdr_histogram_log_test which exercises non-zero normalizing_index_offset via encode/decode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91fb98b to
c644509
Compare
filipecosta90
left a comment
There was a problem hiding this comment.
The force-push at 91fb98b3 drops a correctness fallback that hdr_value_at_percentiles had pre-PR. Flagging in case it's unintentional.
The regression
The old hdr_value_at_percentiles walked buckets via hdr_iter_next, which dereferences counts through counts_get_normalised (src/hdr_histogram.c:822) — i.e. honors h->normalizing_index_offset. The new single-pass code reads h->counts[idx] directly. When normalizing_index_offset != 0 the bucket layout is rotated in counts[], so the cumulative scan crosses the target at the wrong physical bucket and hdr_value_at_percentiles returns the wrong percentile values.
The single-percentile hdr_value_at_percentile already had this issue pre-PR (via get_value_from_idx_up_to_count), so that one is not a regression — it's pre-existing. The batched API is the one this PR newly breaks.
When it triggers
normalizing_index_offset is set non-zero only by the decode paths in hdr_histogram_log.c:540,642 (hdr_log_read / hdr_decode_compressed) when the wire format carries a non-zero offset. The C library encoder only emits what it sees (always 0 from hdr_init), so the trigger is realistic only for cross-language interop (e.g. decoding a histogram emitted by Java HdrHistogram after a shiftValuesLeft/Right) or other encoders. Corner-case in C-only deployments; load-bearing for anyone reading wire-format logs from polyglot pipelines.
Suggested fixes (any one)
- Add an offset-aware fallback at the top of both scan functions — what
c644509(force-pushed away on this branch) did. Fast path stays identical; theHDR_UNLIKELY(h->normalizing_index_offset != 0)branch walks viacounts_get_normalised. Preserves the perf claim. - Hoist
normalizing_index_offsetinto the inner loop viacounts_get_normalisedunconditionally. Slower across the board; the compiler may or may not LICM-hoist the offset==0 check out of the loop. - Document the precondition on
hdr_value_at_percentiles("undefined behavior on histograms with non-zeronormalizing_index_offset") and add anassert. Cheapest, but silently changes the contract.
Happy with any of these — option 1 is what I'd pick (preserves both correctness and perf). If you're OK with the change of contract, option 3 is fine too as long as it's documented in the header alongside the non-decreasing-percentiles note.
Side notes
- The
running += block_summade unconditional (with the new comment) is a nice tweak; compiler can schedule independently of the crossing-branch. - The
uint64_tcast hardening on the block sum (added during PR #134's review to guard signed-overflow UB on fuzzed/corrupted histograms) was also dropped in the force-push. Restoring is a one-liner — doesn't change valid-state behavior; just keeps the prior intent for fuzz robustness. HDR_UNLIKELYon the cold crossing branch matches the file's convention introduced by recent PRs. Not load-bearing.
|
Good catch, and correct on all counts — the
On the Cross-µarch numbers for the |
|
|
Summary
get_value_from_idx_up_to_count(the_scalar+_avx2+__builtin_cpu_supports("avx2")dispatcher) with a single portable block-summed scan: sum a fixed block ofBLK = 4counts, then test the running total against the target only once per block; the precise per-element walk runs only for the block that crosses the target. The win is the drop in early-exit branch frequency (one-per-block instead of one-per-element); the block sum is also a plain reduction the compiler can vectorize at the baseline ISA. This removes<immintrin.h>, the__attribute__((target("avx2")))function, theHDR_HAS_AVX2_DISPATCHmachinery and the per-call__builtin_cpu_supportscheck — and is faster than the AVX2 path it replaces on every Intel CPU tested.hdr_value_at_percentilesto resolve all requested percentiles in a single ascending pass overcounts[], replacing the per-callhdr_iter_next()walk (heavy per-bucket bookkeeping). Same block-summed shape; same non-decreasing-percentiles requirement as before (now documented in the header).-march=nativeadds nothing).Benchmark
test/hdr_histogram_benchmark, ns/op (lower is better), median of 8 reps on an isolated core. Base =main(0.11.10, runtime-dispatched AVX2). precision = 4; precision = 3 shows the same pattern. Cross-validated on three Intel microarchitectures.hdr_value_at_percentile(single percentile)hdr_value_at_percentiles(batched: p50/p95/p99/p99.9 in one call)† Granite Rapids test box has no CMake; measured with an equivalent standalone driver compiling the same translation unit (
-O3), base vs PR.Correctness
hdr_histogram_testsuite passes.hdr_value_at_percentilesis behaviorally equivalent to the previous iterator-based implementation. For percentiles in (0, 100] it also matcheshdr_value_at_percentilecalled once per percentile: across 2000 random histograms × random sorted percentile sets (6956 checks), plus the empty-histogram case, the outputs are identical — 0 mismatches.Steps to reproduce
🤖 Generated with Claude Code