This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new 67e7036f4 fix(javascript): round float16 values to nearest even (#4007)
67e7036f4 is described below
commit 67e7036f417f798c440d46623a10f99fe9406c29
Author: Shawn Yang <[email protected]>
AuthorDate: Mon Aug 31 11:03:23 2026 +0800
fix(javascript): round float16 values to nearest even (#4007)
## Why?
## What does this PR do?
## Related issues
## AI Contribution Checklist
- [ ] Substantial AI assistance was used in this PR: `yes` / `no`
- [ ] If `yes`, I included a completed [AI Contribution
Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
in this PR description and the required `AI Usage Disclosure`.
- [ ] If `yes`, my PR description includes the required `ai_review`
summary and screenshot evidence or equivalent persisted links of the
final clean AI review results from both fresh reviewers described in
`AI_POLICY.md`, the Fory-guided reviewer and the independent general
reviewer, on the current PR diff or current HEAD after the latest code
changes.
## Does this PR introduce any user-facing change?
- [ ] Does this PR introduce any public API change?
- [ ] Does this PR introduce any binary protocol compatibility change?
## Benchmark
---
.../javascript/supported-types.md | 2 +
javascript/packages/core/lib/types/float16.ts | 49 ++++++++++++++--------
javascript/test/array.test.ts | 15 +++++++
javascript/test/number.test.ts | 44 ++++++++++++++++++-
4 files changed, 92 insertions(+), 18 deletions(-)
diff --git a/docs/object-serialization/javascript/supported-types.md
b/docs/object-serialization/javascript/supported-types.md
index 57ca8eb01..7949aeb1a 100644
--- a/docs/object-serialization/javascript/supported-types.md
+++ b/docs/object-serialization/javascript/supported-types.md
@@ -70,6 +70,8 @@ Type.bfloat16();
`float16` and `bfloat16` are useful when interoperating with languages or
payloads that use reduced-precision numeric formats.
+`Type.float16()` rounds numbers to the nearest half-precision value, choosing
the value with an even least-significant bit when the input is exactly halfway
between two values. Float16 array conversion uses the same rounding rule.
Signed zero, infinities, and NaN are preserved; values can underflow to signed
zero or overflow to signed infinity.
+
## Arrays and Typed Arrays
### Lists
diff --git a/javascript/packages/core/lib/types/float16.ts
b/javascript/packages/core/lib/types/float16.ts
index 20ffcd332..ad6681a80 100644
--- a/javascript/packages/core/lib/types/float16.ts
+++ b/javascript/packages/core/lib/types/float16.ts
@@ -17,36 +17,51 @@
* under the License.
*/
-const float32View = new Float32Array(1);
-const int32View = new Int32Array(float32View.buffer);
+const float64View = new DataView(new ArrayBuffer(8));
export function toFloat16Bits(value: number) {
- float32View[0] = value;
- const floatValue = int32View[0];
- const sign = (floatValue >>> 16) & 0x8000;
- const exponent = ((floatValue >>> 23) & 0xff) - 127;
- const significand = floatValue & 0x7fffff;
+ // Round directly from binary64: narrowing to binary32 first can move a value
+ // onto a binary16 midpoint and change the ties-to-even result.
+ float64View.setFloat64(0, value);
+ const high = float64View.getUint32(0);
+ const sign = (high >>> 16) & 0x8000;
+ const exponent = ((high >>> 20) & 0x7ff) - 1023;
+ let significand = high & 0xfffff;
- if (exponent === 128) {
- return sign | 0x7c00 | (significand !== 0 ? 0x0200 : 0);
+ if (exponent === 1024) {
+ return sign | 0x7c00 | (significand !== 0 || float64View.getUint32(4) !==
0 ? 0x0200 : 0);
}
if (exponent > 15) {
return sign | 0x7c00;
}
- if (exponent < -24) {
- // Too small for a float16 subnormal. Larger shifts below would wrap
- // (JS masks shift counts with & 31) and leave garbage bits, so
- // underflow to signed zero.
+ if (exponent < -25) {
+ // Below half the smallest subnormal, round to signed zero before the
+ // shift count can reach 32 and wrap in JavaScript.
return sign;
}
+ let shift = 10;
+ let bits = (exponent + 15) << 10;
if (exponent < -14) {
- return sign | ((significand | 0x800000) >> (13 - 14 - exponent));
- }
-
- return sign | ((exponent + 15) << 10) | (significand >> 13);
+ shift = -exponent - 4;
+ significand |= 0x100000;
+ bits = 0;
+ }
+ bits |= significand >>> shift;
+ const remainder = significand & ((1 << shift) - 1);
+ const halfway = 1 << (shift - 1);
+ // The low binary64 word distinguishes an exact tie from a value above it.
+ // Increment the complete encoding so rounding can carry into the exponent.
+ if (
+ remainder > halfway ||
+ (remainder === halfway && (float64View.getUint32(4) !== 0 || (bits & 1)
!== 0))
+ ) {
+ bits++;
+ }
+
+ return sign | bits;
}
export function fromFloat16Bits(bits: number): number {
diff --git a/javascript/test/array.test.ts b/javascript/test/array.test.ts
index d6b66a4b0..989bbf7f8 100644
--- a/javascript/test/array.test.ts
+++ b/javascript/test/array.test.ts
@@ -215,6 +215,21 @@ describe("array", () => {
expect(Array.from(result.a6 as Iterable<number>)[2]).toBeCloseTo(-4.5, 1);
});
+ test("rounds float16 arrays to nearest even", () => {
+ const values = [4e-8, 1 + 2 ** -11, 1 + 3 * 2 ** -11, 1 + 2 ** -11 + 2 **
-52, -65520];
+ const expected = [2 ** -24, 1, 1 + 2 ** -9, 1 + 2 ** -10, -Infinity];
+ const array = new ForyFloat16Array(values);
+ expect(Array.from(array)).toEqual(expected);
+
+ const fory = new Fory({ compatible: false, ref: true });
+ const { serialize, deserialize } = fory.register(
+ Type.struct({ typeName: "example.f16round" }, { values:
Type.float16Array() }),
+ );
+ for (const input of [values, array]) {
+ expect(Array.from(deserialize(serialize({ values: input
})).values)).toEqual(expected);
+ }
+ });
+
test("should bfloat16Array work", () => {
const typeinfo = Type.struct(
{
diff --git a/javascript/test/number.test.ts b/javascript/test/number.test.ts
index 7da913422..006b633e7 100644
--- a/javascript/test/number.test.ts
+++ b/javascript/test/number.test.ts
@@ -19,6 +19,7 @@
import Fory, { Type } from "../packages/core/index";
import { describe, expect, test } from "@jest/globals";
+import { toFloat16Bits } from "../packages/core/lib/types/float16";
describe("number", () => {
test("should i8 work", () => {
@@ -155,7 +156,7 @@ describe("number", () => {
});
test("should float16 underflow tiny magnitudes to signed zero", () => {
- // Magnitudes below the smallest float16 subnormal must encode as zero;
+ // Magnitudes below half the smallest float16 subnormal encode as zero;
// shift counts of 32 or more wrapped (JS masks them with & 31) and left
// garbage bits in the half.
const fory = new Fory({ compatible: false, ref: true });
@@ -168,6 +169,47 @@ describe("number", () => {
expect(deserialize(serialize({ a: -1e-10 })).a).toBe(-0);
});
+ test.each([
+ [0, 0x0000],
+ [Number.MIN_VALUE, 0x0000],
+ [2 ** -25 - 2 ** -78, 0x0000],
+ [2 ** -25, 0x0000],
+ [2 ** -25 + 2 ** -77, 0x0001],
+ [4e-8, 0x0001],
+ [2 ** -24, 0x0001],
+ [3 * 2 ** -25 - 2 ** -76, 0x0001],
+ [3 * 2 ** -25, 0x0002],
+ [2 ** -14 - 2 ** -25 - 2 ** -67, 0x03ff],
+ [2 ** -14 - 2 ** -25, 0x0400],
+ [2 ** -14, 0x0400],
+ [1 + 2 ** -11, 0x3c00],
+ [1 + 2 ** -11 + 2 ** -52, 0x3c01],
+ [1.0008, 0x3c01],
+ [1 + 3 * 2 ** -11 - 2 ** -52, 0x3c01],
+ [1 + 3 * 2 ** -11, 0x3c02],
+ [2 - 2 ** -11 - 2 ** -52, 0x3fff],
+ [2 - 2 ** -11, 0x4000],
+ [65504, 0x7bff],
+ [65520 - 2 ** -37, 0x7bff],
+ [65520, 0x7c00],
+ [Number.MAX_VALUE, 0x7c00],
+ [Infinity, 0x7c00],
+ ])("rounds float16 %s to bits %s", (value, bits) => {
+ expect(toFloat16Bits(value)).toBe(bits);
+ expect(toFloat16Bits(-value)).toBe(bits | 0x8000);
+ });
+
+ test("rounds float16 fields to nearest even", () => {
+ const fory = new Fory({ compatible: false, ref: true });
+ const { serialize, deserialize } = fory.register(
+ Type.struct({ typeName: "example.f16round" }, { a: Type.float16() }),
+ );
+ expect(deserialize(serialize({ a: 4e-8 })).a).toBe(2 ** -24);
+ expect(deserialize(serialize({ a: 1 + 3 * 2 ** -11 })).a).toBe(1 + 2 **
-9);
+ expect(deserialize(serialize({ a: 1 + 2 ** -11 + 2 ** -52 })).a).toBe(1 +
2 ** -10);
+ expect(deserialize(serialize({ a: -65520 })).a).toBe(-Infinity);
+ });
+
test("should float16 Infinity work", () => {
const fory = new Fory({ compatible: false, ref: true });
const serializer = fory.register(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]