Skip to content

Core: Fix Z-order byte encoding for floating point values - #17628

Closed
vbhanuchander-lang wants to merge 3 commits into
apache:mainfrom
vbhanuchander-lang:zorder-float-ordering
Closed

Core: Fix Z-order byte encoding for floating point values#17628
vbhanuchander-lang wants to merge 3 commits into
apache:mainfrom
vbhanuchander-lang:zorder-float-ordering

Conversation

@vbhanuchander-lang

Copy link
Copy Markdown

Closes #17070.

Credit to @eye-gu, who reported this and identified the one-character fix. They ticked that they
could contribute it independently, so if a PR is already in flight I am happy to close this in
favour of it — six weeks had passed with no linked PR, so I verified the report and wrote it up
with tests.

The bug

ZOrderByteUtils.floatingPointOrderedBytes builds its sign mask by shifting a long by
Integer.SIZE - 1 (31) rather than Long.SIZE - 1 (63):

long lval = Double.doubleToLongBits(val);
lval ^= ((lval >> (Integer.SIZE - 1)) | Long.MIN_VALUE);

For a positive value the shift is supposed to yield 0, so that | Long.MIN_VALUE flips only the
sign bit. Shifting by 31 instead leaves exponent bits sitting in the low 32 bits of the mask, and
those corrupt the low bytes of the output. For 1.0d:

value
doubleToLongBits(1.0) 0x3ff0000000000000
lval >> 31 0x000000007fe00000 ← leaks exponent bits into the low word
mask actually used 0x800000007fe00000
lval >> 63 0x0000000000000000
mask intended 0x8000000000000000 ← sign-bit flip only

Why it has gone unnoticed

The high bytes are still encoded correctly, so ordering is only wrong when two values agree on
their top 31 bits and the corrupted low bytes are what decide the comparison. That is rare for
arbitrary values but systematic for values that differ only in low mantissa bits:

  • all 2016 pairs drawn from 1.0d + i * 0x1.0p-30 (i = 0..63) encode in reverse order
  • 48 of the 496 pairs among 32 consecutive float bit patterns above 1.0f are inverted, the
    smallest being 1.0f and Math.nextUp(1.0f):
    1.0f0xbff000007fe00000 vs 1.0000001f0xbff000005fe00000

testFloatOrdering and testDoubleOrdering draw from random.nextFloat()/nextDouble(), whose
values essentially never agree on their high bits. I replayed the exact Random(42) sequence those
tests use: none of the 100 000 pairs in either test share bits 63..33, so neither test ever
reaches the corrupted low bytes. That is why 17 tests pass on main today.

Tests

Three deterministic cases that target the region the random tests cannot reach — values differing
only in low mantissa bits:

Test Fails before fix
testFloatOrderingForConsecutiveMantissaValues — 64 consecutive float bit patterns from 1.0f
testDoubleOrderingForValuesDifferingInLowMantissaBits1.0d + i * 0x1.0p-30
testNegativeDoubleOrderingForValuesDifferingInLowMantissaBits ❌ (passes either way)

The negative case passes before and after — for negative values the leaked bits flip the whole low
region and happen to preserve order. I kept it because the guarantee is worth pinning against a
future change to the mask, and I would rather state that it is not a regression witness than imply
all three are.

./gradlew :iceberg-core:test --tests "*TestZOrderByteUtil" → 17 tests, 2 failed before the
change, all pass after. spotlessApply, checkstyleMain and checkstyleTest are clean.

Compatibility

None to worry about: the encoding is never persisted. Its only production consumer is
SparkZOrderUDF via SparkZOrderFileRewriteRunner, which adds the bytes as the temporary
ICEZVALUE column, sorts on it, and drops it again before writing. So this changes only the
clustering produced by future z-order rewrites — no existing file or manifest encodes these bytes,
and no reader path parses them.

I also grepped core, api, spark and flink for the same Integer.SIZE - 1 shift pattern;
this is the only occurrence.

@github-actions github-actions Bot added the core label Aug 13, 2026
floatingPointOrderedBytes shifts a long by Integer.SIZE - 1 (31) instead
of Long.SIZE - 1 (63) when building the sign mask. For a positive value
the shift is meant to produce 0 so that only the sign bit is flipped, but
shifting by 31 leaves exponent bits in the low 32 bits of the mask, which
then corrupt the low bytes of the encoding.

Ordering is still decided correctly by the high bytes, so the bug only
surfaces when two values agree on their top 31 bits and the corrupted low
bytes decide the comparison. There the order is inverted: every one of
the 2016 pairs drawn from 1.0d + i * 2^-30 encodes backwards, as do 48 of
the 496 pairs among 32 consecutive float bit patterns above 1.0f, the
smallest being 1.0f and Math.nextUp(1.0f).

The existing random ordering tests draw from nextFloat()/nextDouble(),
whose values essentially never agree on their high bits: with the fixed
seed of 42, none of the 100000 pairs in either test share bits 63..33, so
neither test reaches the corrupted low bytes.

The encoding is not persisted -- SparkZOrderFileRewriteRunner adds it as
the temporary ICEZVALUE column, sorts on it and drops it -- so this only
changes the clustering produced by future z-order rewrites.

The diagnosis and the one-character fix are eye-gu's, from issue apache#17070.
The boundary pairs in testDoubleOrderingForBoundaryPairs are taken from
their PR apache#17071, which was closed by the stale bot without review.
@vbhanuchander-lang

Copy link
Copy Markdown
Author

Correction to my own description above, and full prior art. I wrote that "six weeks had
passed with no linked PR". That was wrong, and I should have caught it before opening this — I
apologise. There are two earlier PRs against #17070:

My Gate-A check missed both: I searched for #17070 and #17130 references the issue by full URL
rather than by number, while #17071 was already closed.

So the credit is @eye-gu's — the diagnosis and the fix are theirs, and I have said so in the commit
message. I have also taken their seven boundary pairs into
testDoubleOrderingForBoundaryPairs
with attribution, because they cover things my sweeps did
not: -Double.MIN_VALUE vs 0.0, 0.0 vs Double.MIN_VALUE, and ±Double.MAX_VALUE. With those
included, 3 of the 4 new tests fail without the production change (18 tests, 3 failed) and all
18 pass with it.

How I would like to see this resolved, in order of preference:

  1. Revive Core: Fix Z-order byte encoding for floating-point values #17071 and merge that — it is @eye-gu's work and it was first. I will happily close
    this PR. If it helps, my three sweep tests and the note on why the existing random tests miss
    the bug can be moved over there, or dropped entirely; the one-line production fix is what
    matters and it is identical in all three PRs.
  2. Merge this one, which is the union of both test approaches, and close [Auto-fix] Core: Z-order byte encoding for floating-point values is not order-preserving #17130.
  3. Merge [Auto-fix] Core: Z-order byte encoding for floating-point values is not order-preserving #17130 for the fix alone — though I would push back gently on that, since it lands the
    change with no test, and the reason this bug survived for so long is precisely that the existing
    tests cannot reach it.

Whichever a committer prefers, the substantive point is that the same one-character fix has now
been independently derived three times and has sat unreviewed for six weeks. It would be good to
land one of them.

.isNegative();
}
}

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.

Please note an IEEE-754 edge-coverage gap here. No test pins {-0.0d, +0.0d} or {Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY}. The corrected mask orders all of them correctly (-0.0d -> 0x7FFFFFFFFFFFFFFF, +0.0d -> 0x8000000000000000; -Inf -> 0x000FFFFFFFFFFFFF, +Inf -> 0xFFF0000000000000), but for a change whose whole purpose is to close a systematic ordering gap, pinning these edges would complete coverage. Also, it would be nice to add some NaN tests too.

Adds the edge coverage uros-b asked for on the PR: the full Double.compare
ladder for both double and float (signed zeros distinguished, infinities
bounding the finite range, NaN sorting above everything), plus a test that
distinct NaN payloads encode identically.

The NaN case pins a determinism invariant rather than an ordering one. The
encoding goes through Double.doubleToLongBits, which collapses every NaN to
the canonical quiet NaN, so switching it to doubleToRawLongBits would make
the z-order key depend on the incoming NaN payload.

These special values order correctly under both the old and the new mask, so
they complete coverage rather than witness the regression; the mantissa and
boundary cases remain the tests that fail without the fix.
@vbhanuchander-lang

Copy link
Copy Markdown
Author

Thanks — added in 1831d1c. I checked your four encodings against the corrected mask before writing
the tests and they are exactly right:

value doubleToLongBits encoded
-0.0d 0x8000000000000000 0x7fffffffffffffff
+0.0d 0x0000000000000000 0x8000000000000000
-Inf 0xfff0000000000000 0x000fffffffffffff
+Inf 0x7ff0000000000000 0xfff0000000000000

What I added:

  • testDoubleOrderingAcrossSpecialValues — the full Double.compare ladder in one sequence:
    -Inf, -MAX_VALUE, -1.0, -MIN_VALUE, -0.0, +0.0, MIN_VALUE, 1.0, MAX_VALUE, +Inf, NaN. This covers
    both of your pairs and also pins that NaN sorts above +Inf, which is where Double.compare puts
    it (encoded 0xfff8000000000000 vs 0xfff0000000000000).
  • testFloatOrderingAcrossSpecialValues — the same ladder for float, since
    floatToOrderedBytes delegates to the same method and the widening is worth pinning.
  • testDoubleOrderedBytesCanonicalizeNaN — on your NaN suggestion. This one pins a
    determinism invariant rather than an ordering one: the encoding goes through
    Double.doubleToLongBits, which collapses every NaN to the canonical quiet NaN, so four distinct
    raw payloads (non-zero payload, all payload bits set, sign bit set, and a signalling NaN) must all
    encode identically. If someone swapped it for doubleToRawLongBits the z-order key would start
    depending on the incoming NaN payload, and nothing would currently catch that.

21 tests in the class, all passing; checkstyleTest clean.

One caveat I would rather state than let you discover: as you noted, the corrected mask already
orders all of these correctly — and so does the old mask. I verified that the whole ladder has
zero ordering violations under both >> 31 and >> 63, so these three tests complete coverage but
do not witness the regression. The tests that fail without the production change remain
testFloatOrderingForConsecutiveMantissaValues,
testDoubleOrderingForValuesDifferingInLowMantissaBits and testDoubleOrderingForBoundaryPairs
(3 of 7 new tests).

Also, while you are here — please see my earlier
comment
on prior art. #17071
is @eye-gu's PR with the same fix, opened the day after they reported #17070; it got no human review
and the stale bot closed it two days ago. Their work came first and I would genuinely rather it were
revived and merged than this one — I have credited them in the commit message and taken their
boundary pairs. There is also #17130, still open, which lands the production fix with no test.
Happy to follow whichever route you prefer.

Replaces the nested-lambda encode(Function<ByteBuffer, ByteBuffer>) plumbing
with named encodeDouble/encodeFloat helpers used as method references, drops a
redundant array clone (allocatePrimitiveBuffer already returns a fresh
buffer), and moves the private helpers below the test methods.

No change to what is asserted.
@vbhanuchander-lang

Copy link
Copy Markdown
Author

Correcting myself on the coverage claim above. I said the special-value ladder "has zero
ordering violations under both >> 31 and >> 63", so those tests complete coverage without
witnessing the regression. That is wrong for the double ladder, and your instinct was better than my
analysis.

testDoubleOrderingAcrossSpecialValues does fail without the production fix, on one adjacent
pair:

-Double.MIN_VALUE < -0.0d
  buggy  (>> 31):  0x7fffffff00000001  >=  0x7fffffff00000000   inverted
  fixed  (>> 63):  0x8000000000000000  <   0x7fffffffffffffff … ordered

The smallest-magnitude negative subnormal and negative zero share all their high bits, so they land
squarely in the corrupted low region — the same failure mode as my mantissa sweeps, just reached from
the zero boundary instead.

Why I got it wrong: the throwaway ladder I checked before writing the test omitted
±Double.MIN_VALUE, while the test I actually committed includes them. So I validated a weaker input
set than the one in the code and then reported the weaker result. My mistake, and worth flagging
because it changes the value of what you asked for: the signed-zero edge is not just completeness, it
catches a real inversion my sweeps missed.

Corrected tally, verified by reverting the one-character change and re-running:

4 of 7 new tests fail without the fix (21 tests in the class, 4 failed):

  • testFloatOrderingForConsecutiveMantissaValues
  • testDoubleOrderingForValuesDifferingInLowMantissaBits
  • testDoubleOrderingForBoundaryPairs
  • testDoubleOrderingAcrossSpecialValues ← the one I misreported

3 pass either way, which I have now actually confirmed rather than assumed:

  • testFloatOrderingAcrossSpecialValues — the float ladder has no violating adjacent pair under
    either mask, because widening a float to double leaves the low 29 bits zero, so
    -Float.MIN_VALUE and -0.0f do not collide in the corrupted region the way the double
    subnormal does
  • testNegativeDoubleOrderingForValuesDifferingInLowMantissaBits
  • testDoubleOrderedBytesCanonicalizesNaN — a determinism invariant, not an ordering one

Also pushed ef70925, a readability pass on the tests only, no change to what is asserted: named
encodeDouble/encodeFloat helpers used as method references instead of the nested
encode(Function<ByteBuffer, ByteBuffer>) plumbing, a redundant array clone removed since
allocatePrimitiveBuffer() already returns a fresh buffer, and the private helpers moved below the
tests. 21 tests pass; spotlessCheck, checkstyleMain and checkstyleTest are clean.

@vbhanuchander-lang

Copy link
Copy Markdown
Author

Closing in favour of #17071 — that was the right call. @eye-gu reported the bug and had the fix up
the next day; it deserved to be the one that merged, and I'm glad the revival worked.

Fair point on the testing, @RussellSpitzer — noted for next time.

For the record, nothing is lost by closing this: I checked that the boundary pairs in the merged
test do catch the regression, so main has a genuine witness for it and not just the one-character
change.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core: Z-order byte encoding for floating-point values is not order-preserving

2 participants