In the pre‑migration verification of Boa for Omoikane (issue #655), we ran the same Boa and Test262 on Linux x86_64 and ARM64, and the results differed in only 10 cases.

A total of 50,595 cases were executed.

EnvironmentPassedFailedIgnored
Linux x86_6447,6069332,056
Linux ARM6447,6169232,056

The ten cases that differed were all tests related to the conversion to Float16.

It’s problematic that JavaScript results change when the CPU changes even though the source code and inputs are identical. We investigated what was happening.

The cause: software conversion in float16 0.1.5

To state the conclusion up front, it was not an issue with the Rust runtime itself or with the CPU’s floating‑point specifications.

The problem stems from the software conversion from f64 to f16 that is present in version 0.1.5 of the float16 crate (source) which Boa depended on.

The problematic implementation in 0.1.5 (source code) shifted the 64‑bit representation obtained for f64 right by 32 bits immediately after retrieving it.

let val: u64 = unsafe { mem::transmute::<f64, u64>(value) };
let x = (val >> 32) as u32;

Because the significand of f16 is only 10 bits, the lower bits of f64 do not remain in the final result.

However, those bits cannot be discarded outright before rounding. To determine whether the value lies above the rounding boundary or exactly at the midpoint, it is necessary to know whether the discarded portion contains any non‑zero bits.

In 0.1.5, that information was discarded in advance, so values that were actually just above the midpoint were sometimes judged to be exactly at the midpoint.

Looking at the smallest values

The smallest positive denormalized number representable by f16 is 2^-24.

2^-24 = 5.960464477539063e-8

The midpoint between that value and 0 is 2^-25.

2^-25 = 2.98023223876953125e-8

The reproduced value in this case is slightly larger than that midpoint.

Input:     2.980232238769532e-8
f64 bits: 0x3e60000000000001

Because the trailing 1 is present, correct rounding yields 0x0001—the smallest positive f16—rather than 0.

However, if the lower 32 bits are discarded first, the trailing 1 disappears. Consequently, the input appears to be exactly at the midpoint, and round‑to‑nearest‑even selects the even value 0x0000.

Thus, even bits that are ultimately discarded are needed to determine the direction of rounding.

Wrote a minimal verification program

We created a verification program (source) that calls both 0.1.5 and the corrected 0.1.7 from the same code and compares three boundary values.

For 0.1.5 we specify the Git revision identified in the issue, while for 0.1.7 we pin the crates.io release.

[dependencies]
float16_015 = { package = "float16", git = "https://github.com/Alexhuszagh/float16.git", rev = "2401a40bbdeac256070324ec954bf268bc8e7be7" }
float16_017 = { package = "float16", version = "=0.1.7" }

The portion being checked looks like this.

let old_native = float16_015::f16::from_f64(input).to_bits();
let old_software = float16_015::f16::from_f64_const(input).to_bits();
let fixed_software = float16_017::f16::from_f64_const(input).to_bits();

assert_eq!(old_software, old_software_bits);
assert_eq!(fixed_software, expected_bits);

from_f64_const is a path that does not use any CPU‑specific instructions. Therefore, even on ARM64 we can explicitly reproduce the software conversion of 0.1.5 that was used on x86_64.

That is all you need to do to run it.

cargo run --release --locked \
  --manifest-path examples/float16-rounding-repro/Cargo.toml

The results obtained when running on an Apple Silicon Mac were as follows.

architecture: aarch64
input=2.980232238769532e-8 f64_bits=0x3e60000000000001
  0.1.5 native   =0x0001
  0.1.5 software =0x0000
  0.1.7 software =0x0001
  expected       =0x0001
input=1.490116119384766e-7 f64_bits=0x3e84000000000001
  0.1.5 native   =0x0003
  0.1.5 software =0x0002
  0.1.7 software =0x0003
  expected       =0x0003
input=1.0004882812500002 f64_bits=0x3ff0020000000001
  0.1.5 native   =0x3c01
  0.1.5 software =0x3c00
  0.1.7 software =0x3c01
  expected       =0x3c01
all boundary cases reproduced

In all three cases, only the 0.1.5 software conversion produced a value one step smaller. With 0.1.7, the bit pattern matches the expected one.

Why the results differed by CPU

The from_f64 in float16 0.1.5 (source code) invokes the software version of from_f64_const on x86 and x86_64.

In contrast, when FP16 can be used on ARM64, a CPU‑specific path that directly converts from f64 to f16 is employed.

Consequently, in our environment the processing split as follows.

x86_64
  f64 → software conversion in version 0.1.5 → incorrect rounding

ARM64
  f64 → direct conversion using FP16 → correct rounding

This does not mean that different rounding rules are permitted per CPU. The discrepancy arose because, among the separate implementations of the same conversion, only the software version contained a bug, leading to differing results.

Of the ten Test262 cases that differed, nine used the boundary values defined in the common byteConversionValues.js. The remaining case also involves the same tiny value and rounding boundary.

Fixed in 0.1.7

The implementation in float16 0.1.7 (source code) was modified to retain the 64‑bit significand all the way through the rounding step.

During the issue investigation, when comparing the three cases in question with the 56 inputs in the shared Test262 fixture, no discrepancies were found in the 0.1.7 software conversion.

However, this alone does not complete the overall verification. We still need to rerun the entire Test262 suite on Linux x86_64, Linux ARM64, and macOS ARM64 to confirm that the ten problematic cases are resolved and that no other tests have regressed.

Also, the current Omoikane root dependency already selects version 0.1.7. The 0.1.5 version we observed corresponds to the fixed Cargo.lock in Boa, which was imported as part of the migration target.

Therefore, we cannot conclude from this result alone that the current Omoikane has the same defect.

Summary

At first we wondered whether the discrepancy was due to differences in the CPU’s floating‑point instructions or in Rust’s conversion implementation.

In fact, the issue stemmed from the software conversion in float16 0.1.5 discarding information before rounding, combined with the fact that the conversion path differed by CPU.

Even though the final result is only 16 bits, discarding bits that seem unnecessary from the start can eliminate the information needed for proper rounding.

Moreover, having CPU‑specific optimized paths for such operations makes it highly worthwhile to run the same tests across multiple architectures.