From 31795671664d138fd37bb788dc3623ece8ff028d Mon Sep 17 00:00:00 2001 From: Qingyuan Wang <162230229@nuaa.edu.cn> Date: Sun, 25 Oct 2026 12:25:05 +0000 Subject: [PATCH] fix(java): round tiny float32 values up to the smallest float16 subnormal Float16.floatToFloat16Bits flushes a value to zero when the subnormal rounding shift reaches 24 bits, but the shift only becomes a guaranteed flush one bit later than that. At netShift == 24 the round and sticky bits still decide, and every float strictly between 2^-25 (the midpoint between zero and 2^-24) and 2^-24 is closer to 2^-24 than to zero, so it has to round up to MIN_VALUE. The guard as written drops all of them. An exhaustive sweep of every 2^32 float32 patterns against java.lang.Float.floatToFloat16 (JDK 20+) finds 16777214 disagreements, and every one of them is this case: the 2^23 - 1 patterns with a non-zero mantissa in the 2^-25 binade, for both signs. Each one encodes as a signed zero instead of 0x0001/0x8001, so a value that is representable in float16 is silently lost. The rounding rule that the conversion must follow is the one the docs already state for the JavaScript codec: round to nearest, ties to the even least-significant bit. Relaxing the guard to netShift > 24 makes the routine agree with the JDK conversion for all 2^32 patterns; the remaining 32766 differences are signaling-NaN payload canonicalisation, which is unchanged by this commit. Nothing else is affected. The flush that the guard still performs for netShift > 24 is what keeps the rounding path out of the shift-count masking traps (netShift - 1 == 32 would otherwise index roundBit and sticky with a masked shift), and the existing Float16Test suite never covered this interval because no float16 bit pattern decodes into it. Verification: - red: Float16Test 20 tests, 2 failures, sweep test reports expected [0] but found [16777214] - green: Float16Test 20/20 after the guard change - full fory-core suite: 2348 tests, 1 failure (ExpressionTest.testLiteralSourceRoundTrip, reproduces without this change) - exhaustive differential sweep against Float.floatToFloat16 over all 2^32 inputs: 0 mismatches; Float16.toFloat over all 2^16 inputs: 0 mismatches Signed-off-by: Qingyuan Wang <162230229@nuaa.edu.cn> --- .../java/org/apache/fory/type/Float16.java | 5 ++- .../org/apache/fory/type/Float16Test.java | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/java/fory-core/src/main/java/org/apache/fory/type/Float16.java b/java/fory-core/src/main/java/org/apache/fory/type/Float16.java index c76eaa362c..a3fa1a0b5d 100644 --- a/java/fory-core/src/main/java/org/apache/fory/type/Float16.java +++ b/java/fory-core/src/main/java/org/apache/fory/type/Float16.java @@ -123,7 +123,10 @@ private static short floatToFloat16Bits(float f32) { int shift = 1 - newExp; int netShift = 13 + shift; - if (netShift >= 24) { + // Shifting past the hidden bit is what actually flushes a value to zero. At netShift + // == 24 the round and sticky bits still decide: every float strictly between the + // 2^-25 mid-point and 2^-24 rounds up to the smallest subnormal. + if (netShift > 24) { outExp = 0; outMant = 0; } else { diff --git a/java/fory-core/src/test/java/org/apache/fory/type/Float16Test.java b/java/fory-core/src/test/java/org/apache/fory/type/Float16Test.java index 23f573e3dc..5853874daa 100644 --- a/java/fory-core/src/test/java/org/apache/fory/type/Float16Test.java +++ b/java/fory-core/src/test/java/org/apache/fory/type/Float16Test.java @@ -313,6 +313,44 @@ public void testConstants() { assertEquals((short) 0x0001, Float16.MIN_VALUE.toBits()); } + private static int encoded(float value) { + return Float16.toBits(value) & 0xFFFF; + } + + @Test + public void testRoundsUpToMinValueJustAboveTheSubnormalMidpoint() { + // The midpoint between zero and the smallest subnormal is 2^-25. The tie goes to the even + // encoding, which is zero, so 2^-25 must encode as a zero of the matching sign. + assertEquals(encoded(Float.intBitsToFloat(0x33000000)), 0x0000); + assertEquals(encoded(Float.intBitsToFloat(0xb3000000)), 0x8000); + // The largest float strictly below the midpoint is nearer to zero than to 2^-24. + assertEquals(encoded(Float.intBitsToFloat(0x32ffffff)), 0x0000); + + assertEquals(encoded(Float.intBitsToFloat(0x33000001)), 0x0001); + assertEquals(encoded(3.0e-8f), 0x0001); + assertEquals(encoded(4.4702e-8f), 0x0001); + assertEquals(encoded(Float.intBitsToFloat(0x337fffff)), 0x0001); + assertEquals(encoded(-3.0e-8f), 0x8001); + assertEquals(encoded((float) Math.pow(2, -24)), 0x0001); + assertEquals(Float16.parse("3e-8").toBits(), (short) 0x0001); + } + + @Test + public void testNothingBetweenTheSubnormalMidpointAndMinValueIsFlushedToZero() { + // Every one of the 2 * (2^23 - 1) floats strictly between 2^-25 and 2^-24 is closer to 2^-24 + // than to zero, so none of them may encode as a zero of either sign. + int flushed = 0; + for (int bits = 0x33000001; bits < 0x33800000; bits++) { + if (encoded(Float.intBitsToFloat(bits)) != 0x0001) { + flushed++; + } + if (encoded(Float.intBitsToFloat(bits | 0x80000000)) != 0x8001) { + flushed++; + } + } + assertEquals(flushed, 0, "floats in (2^-25, 2^-24) must round to MIN_VALUE"); + } + @Test public void testSizeConstants() { assertEquals(16, Float16.SIZE_BITS);