Skip to content

Finish repairing the deferred KxQ rebuild, and pin the estimator constants - #522

Open
leerho wants to merge 2 commits into
masterfrom
hll-kxq-rebuild-parity
Open

Finish repairing the deferred KxQ rebuild, and pin the estimator constants#522
leerho wants to merge 2 commits into
masterfrom
hll-kxq-rebuild-parity

Conversation

@leerho

@leerho leerho commented Sep 5, 2026

Copy link
Copy Markdown
Member

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 touch
HllUtil.hpp and HllArray-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 in HllArray-internal.hpp: curMin is always 0 and numAtCurMin is the number of zero registers, decremented by Hll8Array::internalCouponUpdate() as registers fill.

When the merged array has no zero registers the rebuild leaves curMin > 0, and from that point numAtCurMin -= (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.

A = lg_k 12 HLL_4 keys [20000,30364)
B = lg_k 10 HLL_8 keys [5000,14699)
C = lg_k 17 HLL_4 keys [70000,72598)     // stays in SET mode
hll_union u(7)

order A,B,C -> curMin=6 numAtCurMin=11
order B,C,A -> curMin=6 numAtCurMin=8
registers identical, estimates identical (23040.86465)
recomputed from the registers: curMin=6 numAtCurMin=8   <- A,B,C stores a wrong value 4.0.1, before #364: both orders -> curMin=0 numAtCurMin=0, byte-identical

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's numNonZeros and getHllBitMapEstimate's numUnhitBuckets) branch on curMin == 0. So this is a serialization-determinism defect, not an accuracy one.

check_rebuild_kxq_cur_min() is also missing the curMode == HLL && tgtHllType == HLL_8 guard that datasketches-java has. On an Hll4Array the rebuild would overwrite curMin without re-encoding the nibbles, which are stored relative to it. That is unreachable today — only Hll8Array::mergeHll sets the flag — but setRebuildKxqCurminFlag() and check_rebuild_kxq_cur_min() are both public.

P2. The relative-error constants are rounded literals

HLL_HIP_RSE_FACTOR     = 0.8325546   // sqrt(ln 2)          exact: 0.8325546111576977
HLL_NON_HIP_RSE_FACTOR = 1.03896     // sqrt(3 ln 2 - 1)    exact: 1.0389617614136892

datasketches-java computes these rather than rounding them. For lg_k > 12 the 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 above lg_k 12.

P3. log() comes from the platform libm

HarmonicNumbers::harmonicNumber and HllArray::getHllBitMapEstimate call std::log. getBitMapEstimate computes K * (H(K) - H(K - numHit)), and the cancellation between two nearby harmonic numbers amplifies a 1 ULP difference in log() 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.log is specified to be fdlibm's __ieee754_log, and on the tested JVM Math.log is 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 runtime sqrt(log(2.0)) so the value cannot vary with the platform libm.

  • common/include/fdlibm_log.hpp (new): FDLIBM 5.3 __ieee754_log, the function StrictMath.log is specified to be. Copyright (C) 1993 Sun Microsystems, freely distributable with the notice preserved; the notice is in the header. Used by harmonicNumber and getHllBitMapEstimate.

    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 a volatile round-trip that the standard requires the compiler to honour.

    Verified bit-identical to Java under -O2, -O3, -ffp-contract=on and -ffp-contract=fast. -ffast-math breaks it, as it breaks IEEE semantics generally.

Compatibility

Unlike PR#364 , this PR changes serialized bytes — that is its purpose.

  • Union results: stored curMin / numAtCurMin change (to the canonical form 4.0.1 produced).
  • Estimates in the linear-counting range shift by ~1e-15 relative, from the log change.
  • Bounds at lg_k > 12 shift 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/numAtCurMin branch on curMin == 0, so old and new images yield identical estimates and bounds.

Part 3 — Tests

New hll/test/HllKxqRebuildTest.cpp — 5 cases, 37 assertions:

  • a union result is byte-identical across all six permutations of three inputs, one of which stays in SET mode so the coupon-update path into a gadget with a pending rebuild is exercised;
  • reading an estimate mid-stream does not change a later get_result() image;
  • a union result's stored curMin/numAtCurMin match a recount over its own registers;
  • get_rel_err() matches numStdDev * sqrt(ln 2) / sqrt(K) and the non-HIP form at lg_k 13/16/21 for 1..3 standard deviations;
  • fdlibm::log matches 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 internalCouponUpdate fires 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_k 4..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:

before:  428 of 1041 records differ
after:     0 of 1041 records differ

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.

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>
@coveralls

coveralls commented Sep 5, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34001458664

Coverage increased (+0.03%) to 82.344%

Details

  • Coverage increased (+0.03%) from the base build.
  • Patch coverage: 9 uncovered changes across 2 files (75 of 84 lines covered, 89.29%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
common/include/fdlibm_log.hpp 46 40 86.96%
hll/include/HllArray-internal.hpp 12 9 75.0%
Total (11 files) 84 75 89.29%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 21523
Covered Lines: 17723
Line Coverage: 82.34%
Coverage Strength: 1365970.91 hits per line

💛 - Coveralls

@leerho
leerho force-pushed the hll-kxq-rebuild-parity branch from 00f3ab2 to 8635a41 Compare September 5, 2026 20:54
@leerho
leerho requested review from proost and tisonkun September 5, 2026 21:09
…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
leerho force-pushed the hll-kxq-rebuild-parity branch from 8635a41 to 25583e9 Compare September 6, 2026 00:30
@leerho
leerho marked this pull request as ready for review September 7, 2026 23:24

@proost proost left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for fix.

Comment thread hll/include/HllUtil.hpp
// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Like previous PR, how do you think introducing new const and deprecation instead of renaming?

after next release, remove it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants