Finish repairing the deferred KxQ rebuild, and pin the estimator constants - #522
Open
leerho wants to merge 2 commits into
Open
Finish repairing the deferred KxQ rebuild, and pin the estimator constants#522leerho wants to merge 2 commits into
leerho wants to merge 2 commits into
Conversation
Serialization flags bit 32 (0x20) was written as FULL_SIZE_FLAG_MASK here and is read as REBUILD_CURMIN_NUM_KXQ_MASK by datasketches-java, so the two implementations disagree about what a sketch image means. A C++ sketch built with start_full_size=true serializes flag FULL_SIZE, which Java heapifies with its REBUILD_CURMIN_NUM_KXQ_MASK (union rebuild) flag set (observed for HLL_4 and HLL_6; for HLL_8 Java's heapify calls checkRebuildCurMinNumKxQ immediately and clears it). Going the other way, a Java union-gadget image is read here as full size. This is a serious cross-language bug. In C++, the persisted bit exists for one purpose: so that reset() on a deserialized sketch returns to a full-size array rather than to LIST. It also leaked into unions, because union_impl replaces the gadget via copyAs()/copy() and the rvalue update() overload moves a sketch in wholesale, both of which carry the startFullSize_ across. A union's reset behavior therefore depended on which sketches had been merged into it. This is a bug internal to C++. A user that happened to merge in a sketch that was configured full-size would permanently configure his sketch to full-size mode without his knowledge. Fixing this collision between Java and C++ involves minimizing the impact surface to both Java backwards compatibility and C++ backwards compatibility. On the C++ side this Full-Size capability was never fully implemented and never tested, and it contained a bug in the union implementation as well. Fix Strategy: Make full-size a property of the call that creates the state instead. This means if the user wants this feature it must be requested when the sketch is created and requested upon reset (if reset is required). This feature is a runtime dynamic and never persisted, which removes the requirement to have the bit for C++. This fixes this bug going forward, there is not much we can do about historical C++ sketch images. - add hll_sketch_alloc::reset(bool full_size = false); reset() returns to coupon collection mode as Java does, reset(true) to an empty full-size HLL array - remove startFullSize_, isStartFullSize(), and the bool parameter on the HllArray/Hll4Array/Hll6Array/Hll8Array constructors and newHll() - stop writing and reading bit 32; rename the constant RESERVED_FLAG_MASK_32 and record the collision, noting bits 64 and 128 are free - hll_union_alloc::reset() resets its gadget to LIST explicitly Removing the state fixes the union leak by construction. Reading is unaffected: older images still deserialize and the bit is ignored. Output is byte-identical to master except for sketches created with start_full_size=true, verified over a 1047-record corpus spanning lg_k 4..21, all three target types, 17 sizes across LIST/SET/HLL, round trips and 80 union scenarios. One deliberate behavior change: a full-size sketch that has been serialized and deserialized now resets to LIST; call reset(true) for the previous effect. Adds HllFullSizeTest.cpp. The five assertions that also compile against the previous headers fail there and pass here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage Report for CI Build 34001458664Coverage increased (+0.03%) to 82.344%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
leerho
force-pushed
the
hll-kxq-rebuild-parity
branch
from
September 5, 2026 20:54
00f3ab2 to
8635a41
Compare
…tants Three independent defects in the HLL estimator state. 1. The deferred KxQ rebuild from #364 still leaves curMin/numAtCurMin merge order dependent. #512 repaired the visible damage, but not the representation the rebuild writes: check_rebuild_kxq_cur_min() stores the true minimum register value and the count at that minimum, while the rest of the HLL_8 code maintains curMin == 0 with numAtCurMin counting the zero registers. When the merged array has no zero register the rebuild leaves curMin > 0, and from then on numAtCurMin -= (curVal == 0) never fires, so the stored pair freezes and drifts away from the registers. Its value depends on when the rebuild fired, hence on merge order. Over 4800 randomized merge-order comparisons 92 differed only in these two fields; the same sweep before #364 gives 0. Estimates and bounds are unaffected because both consumers branch on curMin == 0, so this is a serialization determinism defect. Emit the canonical form instead, and add the HLL_8 / HLL-mode guard that datasketches-java has, so the rebuild can never rewrite curMin on an HLL_4 array whose nibbles are stored relative to it. 2. HLL_HIP_RSE_FACTOR and HLL_NON_HIP_RSE_FACTOR were rounded to seven digits. For lg_k > 12 the bounds use the closed form rather than the interpolation table, so this is observable: the HIP bounds differ from Java by ~4.3e-11 relative and the non-HIP factor is off by 1.7e-6 relative, skewing the bounds of every union result above lg_k 12. Use full precision literals taken from Java's Double.toString output, rather than computing sqrt(log(2.0)) at runtime, so the value cannot vary with the platform libm. 3. harmonicNumber() and getHllBitMapEstimate() called the platform std::log. getBitMapEstimate computes K * (H(K) - H(K - numHit)), whose cancellation amplifies a 1 ULP log difference by about 17x. Apple libm differs from fdlibm on 0.62% of a 400000 sample sweep. Java's StrictMath.log is specified to be fdlibm, so add common/include/fdlibm_log.hpp (FDLIBM 5.3 __ieee754_log, Sun notice preserved) and use it in both places. fdlibm needs strict IEEE evaluation, and the clang pragma is overridden by an explicit -ffp-contract=fast, so the contraction sensitive expressions go through a volatile round trip; verified bit-identical to Java under -O2, -O3, -ffp-contract=on and =fast. LICENSE gains an FDLIBM entry alongside the existing xxhash64, MurmurHash3 and bithacks entries. This changes serialized bytes, unlike the preceding PR. Union results carry the canonical curMin/numAtCurMin, linear-counting estimates shift ~1e-15, and bounds above lg_k 12 shift as described. Reading is unaffected: older images still deserialize and yield identical estimates and bounds. Adds HllKxqRebuildTest.cpp. Three of the four assertions that also compile against the parent branch fail there and pass here; the fourth already held and is kept as a guard. With the companion datasketches-java change, a 1041 record corpus spanning lg_k 4..21, all three target types, 17 sizes across LIST/SET/HLL, round trips and 80 union scenarios goes from 428 differing records to 0, comparing every serialized byte, estimate, composite estimate and bound as raw IEEE bits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
leerho
force-pushed
the
hll-kxq-rebuild-parity
branch
from
September 6, 2026 00:30
8635a41 to
25583e9
Compare
leerho
marked this pull request as ready for review
September 7, 2026 23:24
proost
reviewed
Sep 8, 2026
| // REBUILD_CURMIN_NUM_KXQ_MASK for its union gadget. The two meanings collided across | ||
| // implementations, so this side no longer writes or reads it. Do not reuse: bits 64 and 128 | ||
| // are free. | ||
| static const uint8_t RESERVED_FLAG_MASK_32 = 32; |
Member
There was a problem hiding this comment.
Like previous PR, how do you think introducing new const and deprecation instead of renaming?
after next release, remove it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finish repairing the deferred KxQ rebuild, and pin the estimator constants
Depends on PR#521 (
hll-full-size-reset); this branch is cut from it because both touchHllUtil.hppandHllArray-internal.hpp. Held as a draft until PR#521 merges.Three independent defects, all in the HLL estimator state.
Part 1 — The problems
P1. The deferred KxQ rebuild still leaves curMin/numAtCurMin merge-order dependent
The lazy rebuild introduced in PR#364 defers recomputing KxQ and curMin after a merge. PR#512 repaired the visible damage — an empty-looking gadget discarding accumulated data, a corrupted HIP accumulator, order-dependent estimates — but not the representation the rebuild writes.
check_rebuild_kxq_cur_min()stores the true minimum register value and the count at that minimum. The rest of the HLL_8 code maintains a different representation, the one documented inHllArray-internal.hpp:curMinis always 0 andnumAtCurMinis the number of zero registers, decremented byHll8Array::internalCouponUpdate()as registers fill.When the merged array has no zero registers the rebuild leaves
curMin > 0, and from that pointnumAtCurMin -= (curVal == 0)never fires again. The stored pair freezes at whatever the rebuild computed and drifts away from the registers, so its value depends on when the rebuild fired — that is, on merge order.Across 4800 randomized merge-order comparisons, 92 differed only in these two fields; the same sweep on the pre-#364 baseline gives 0. Estimates and bounds are unaffected, because both consumers (
getLowerBound'snumNonZerosandgetHllBitMapEstimate'snumUnhitBuckets) branch oncurMin == 0. So this is a serialization-determinism defect, not an accuracy one.check_rebuild_kxq_cur_min()is also missing thecurMode == HLL && tgtHllType == HLL_8guard that datasketches-java has. On anHll4Arraythe rebuild would overwritecurMinwithout re-encoding the nibbles, which are stored relative to it. That is unreachable today — onlyHll8Array::mergeHllsets the flag — butsetRebuildKxqCurminFlag()andcheck_rebuild_kxq_cur_min()are both public.P2. The relative-error constants are rounded literals
datasketches-java computes these rather than rounding them. For
lg_k > 12the bounds take the closed-form branch rather than the interpolation table, so the truncation is directly observable: the HIP bounds differ from Java by ~4.3e-11 relative, and the non-HIP factor is off by 1.7e-6 relative, which skews the bounds of every union result abovelg_k12.P3. log() comes from the platform libm
HarmonicNumbers::harmonicNumberandHllArray::getHllBitMapEstimatecallstd::log.getBitMapEstimatecomputesK * (H(K) - H(K - numHit)), and the cancellation between two nearby harmonic numbers amplifies a 1 ULP difference inlog()by roughly 17x. Apple libm differs from fdlibm on 0.62% of a 400,000-sample sweep spanning subnormals to 1e300, always by 1 ULP.datasketches-java's
StrictMath.logis specified to be fdlibm's__ieee754_log, and on the tested JVMMath.logis bit-identical to it over 3,000,000 assorted doubles. So Java is pinned and C++ is not — and C++ is not bit-stable against itself across platforms either.Part 2 — The fix
check_rebuild_kxq_cur_min()emits the canonical HLL_8 representation —curMin = 0,numAtCurMin= number of zero registers — so the rebuilt state is indistinguishable from the incrementally-maintained state and the timing of the rebuild is not observable. Adds the HLL_8 / HLL-mode guard.Full-precision RSE literals, taken from Java's own
Double.toString()output, with the defining expression in the comment. Literals rather than a runtimesqrt(log(2.0))so the value cannot vary with the platform libm.common/include/fdlibm_log.hpp(new): FDLIBM 5.3__ieee754_log, the functionStrictMath.logis specified to be. Copyright (C) 1993 Sun Microsystems, freely distributable with the notice preserved; the notice is in the header. Used byharmonicNumberandgetHllBitMapEstimate.fdlibm depends on strict IEEE-754 evaluation — a fused multiply-add in the polynomial changes the result.
#pragma clang fp contract(off)is honoured under default flags but is overridden by an explicit-ffp-contract=fast, which is GCC's default, so the contraction-sensitive expressions are routed through avolatileround-trip that the standard requires the compiler to honour.Verified bit-identical to Java under
-O2,-O3,-ffp-contract=onand-ffp-contract=fast.-ffast-mathbreaks it, as it breaks IEEE semantics generally.Compatibility
Unlike PR#364 , this PR changes serialized bytes — that is its purpose.
curMin/numAtCurMinchange (to the canonical form 4.0.1 produced).logchange.lg_k > 12shift by ~4.3e-11 (HIP) and ~1.7e-6 (non-HIP), from the constants.Golden-image tests and any test asserting exact double equality against previously recorded C++ values will need updating. Reading is unaffected: images from every earlier version still deserialize, and both readers of
curMin/numAtCurMinbranch oncurMin == 0, so old and new images yield identical estimates and bounds.Part 3 — Tests
New
hll/test/HllKxqRebuildTest.cpp— 5 cases, 37 assertions:get_result()image;curMin/numAtCurMinmatch a recount over its own registers;get_rel_err()matchesnumStdDev * sqrt(ln 2) / sqrt(K)and the non-HIP form atlg_k13/16/21 for 1..3 standard deviations;fdlibm::logmatches a nine-entry reference table on inputs where the platform libm diverges.The expected bit patterns were taken from Java's
StrictMath.log.Of the four assertions that also compile against the parent branch, three fail there and pass here. The fourth — that reading an estimate does not change a later result — already held in C++, because PR#512's eager rebuild inside
internalCouponUpdatefires in both the peeked and unpeeked orderings. It is kept as a guard, not claimed as a regression; that observer effect is the datasketches-java defect addressed in the companion PR.Full suite: 64 cases / 7758 assertions pass.
Cross-language result
With the companion datasketches-java PR applied, over 1041 records —
lg_k4..21 x {HLL_4, HLL_6, HLL_8} x 17 sizes spanning LIST, SET and HLL modes, plus heapify round-trips and 80 deterministic pseudo-random union scenarios:Zero differences in any field: both serialized forms, estimate, composite estimate, and lower and upper bounds at 1 and 2 standard deviations, compared as raw IEEE-754 bit patterns.