Pin DDSketch's value-to-bucket mapping across architectures - #136
Merged
Conversation
A DDSketch payload records bucket indices, not the mapping that produced them, so the function from value to index is part of the format with nothing written down to pin it. It is also the only place in the library where a whole number reaching a payload comes from Math.Log, which is not required to be correctly rounded and may differ in its last bit between platforms. CI runs x64 Linux, x64 Windows and arm64 macOS, so this makes agreement across architectures checked rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gamma follows from the accuracy by correctly-rounded arithmetic, so it is the same number everywhere. Its logarithm is not, and it is the one value where a last-bit difference does not stay small: every index divides by it, so an ulp shifts every quotient at once, amplified by the quotient's own magnitude. That is roughly one value in 10^13 bucketed differently, five orders of magnitude likelier than a per-value difference in Math.Log, and correlated across the whole sketch. Pinning values cannot catch that. The pinned bits are the correctly rounded logarithms rather than this machine's output, so a platform whose libm is a bit out fails rather than being enshrined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The library is managed IL with no intrinsics and no unsafe code, so nothing in it is architecture-specific by intent. Two things are worth running rather than believing: payloads are little-endian by construction, and DDSketch derives a persisted bucket index from Math.Log, which is not required to be correctly rounded and may differ between one platform's libm and another's. That makes it both architectures on Linux and Windows, plus arm64 macOS. The arm64 labels pin an image version because there is no -latest form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Pins the bucket a value falls in, for 28 values across two accuracies, read straight out of the payload.
Why this and not the ILogB change
The ILogB idea I floated doesn't survive contact.
value = m × 2^egiveslog2(value) = e + log2(m), butlog2(m)is still a libm call and still isn't correctly rounded — it improves the conditioning and delivers no determinism. Getting determinism needs the mantissa's log computed from IEEE-mandated operations only, i.e. a polynomial mapping.That's a bigger deal than it sounds, because bucket indices are persisted:
The mapping isn't stored, but it's a format contract. Change it and every existing payload's indices mean something new, with nothing in the file to say so.
Mergehas the same exposure across mixed-architecture nodes. So a deterministic mapping costs a format version and a dual-read path.What the exposure actually is
Measured rather than argued:
Over 2M log-uniform values spanning 1µs–100s, the closest any got to a boundary was ~10⁵ ulps. And perturbing
Math.Logby a full ulp — more than a libm difference plausibly is — moves none of the 28 pinned values. The sole exception is1.0, and only becauselog(1.0)is exactly0.0, so a nudge shifts it off a boundary it sits exactly on; IEEE mandates that zero on every platform, so it doesn't move in reality either.Conclusion: not worth a format version. The mapping stays as it is.
What this adds instead
TestBucketIndicesHaveNotMovedAtOnePercent— 21 values, 1µs to 10sTestBucketIndicesHaveNotMovedAtATenthOfAPercent— 7 values where|q|is largest and drift has the most roomTestTheNumberBeingReadIsTheBucketIndex— anchors needing no logarithm (1.0→0,1.01→1,0.97→−1), each well inside its bucket, so they hold whatever the last bit does. Also covers the signedu32round trip, since indices go negative below one.Expected indices were derived from the paper's definition rather than copied from this library's output, so a wrong rounding would surface instead of being enshrined. That was done on one machine and proves nothing about others by itself — what checks architectures is this file running on x64 Linux, x64 Windows and arm64 macOS (
macos-latestismacos-26-arm64).Mutation
Ceiling→Floor: all 3 killed.Math.Log(value)perturbed by +1 ulp: all 3 killed, but only via the1.0case — recorded in the file, since it's the honest limit of what this pins. It guards the mapping against being changed, not against drifting; drift that small demonstrably doesn't reach these values.929 tests pass, clean
-warnaserrorbuild from scratch.Added: pinning the divisor
Working out the actual odds exposed a gap in the above. The value corpus guards the wrong channel.
Channel A — per-value. Two libms disagree on
log(v). Measured against a 60-digit correctly-rounded reference: 0.009% of arguments differ, always by exactly 1 ulp. Propagated into the quotient that's 1 in 2.5 × 10¹⁸ (a=0.01). Zero bucket flips in 300k values.Channel B — the divisor.
gammais(1+a)/(1-a): correctly-rounded arithmetic, identical everywhere.log(gamma)is not. Every index divides by that one number, so an ulp shifts every quotient at once, amplified by|q|(mean 288 at a=0.01, 2,880 at a=0.001) — 1 in 2 × 10¹³, five orders of magnitude worse, and correlated across the whole sketch instead of scattered.TestTheDivisorIsTheSameNumberOnEveryPlatformpinslogGammato the bit for seven accuracies. The pinned values are the correctly rounded logarithms, computed to sixty digits and rounded once — not this machine's output — so a platform whose libm is a bit out fails rather than having its answer enshrined.Mutation (
logGamma += 1 ulp) shows why it was needed:TestTheDivisorIsTheSameNumberOnEveryPlatformWhich is the predicted behaviour: 28 values at 5×10⁻¹⁴ each notice a divisor divergence about one time in 10¹². Without this, the corpus would pass happily on a platform where every sketch was quietly bucketing differently.
930 tests pass, clean
-warnaserrorbuild from scratch.