Skip to content

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

Open
vbhanuchander-lang wants to merge 1 commit into
apache:mainfrom
vbhanuchander-lang:zorder-float-ordering
Open

Core: Fix Z-order byte encoding for floating point values#17628
vbhanuchander-lang wants to merge 1 commit 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.

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