OmoikaneのBoa移行前検証で、同じBoaとTest262をLinuxのx86_64とARM64で動かしたところ、結果が10ケースだけ違いました。

実行したのは50,595ケースです。

環境PassedFailedIgnored
Linux x86_6447,6069332,056
Linux ARM6447,6169232,056

差が出た10ケースは、すべてFloat16への変換に関係するテストでした。

同じソースと入力なのにCPUが変わるとJavaScriptの結果も変わるのは困ります。何が起きているのか調べました。

原因はfloat16 0.1.5のソフトウェア変換

先に結論を書くと、Rust本体やCPUの浮動小数点仕様の問題ではありませんでした。

Boaが依存していたfloat16 crateのバージョン0.1.5にある、f64からf16へのソフトウェア変換が原因です。

問題のあった0.1.5の実装は、f64の64ビット表現を取得した直後に32ビット右へシフトしていました。

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

f16の仮数部は10ビットしかないので、f64の下位ビットは最終結果にはそのまま残りません。

ただし、丸める前に捨ててよいわけではありません。丸め境界より上なのか、ちょうど中間なのかを判定するために、捨てる部分にも非ゼロのビットがあるかという情報が必要です。

0.1.5ではその情報まで先に消していたため、本当は中間値を少し超えている値を、ちょうど中間値だと判定する場合がありました。

一番小さい値で見る

f16で表現できる最小の正の非正規化数は2^-24です。

2^-24 = 5.960464477539063e-8

0との中間は2^-25になります。

2^-25 = 2.98023223876953125e-8

今回の再現値は、その中間よりほんの少しだけ大きい値です。

入力:     2.980232238769532e-8
f64 bits: 0x3e60000000000001

末尾の1があるため、正しく丸めると0ではなく、最小の正のf16である0x0001になります。

ところが下位32ビットを先に捨てると末尾の1が消えます。すると入力がちょうど中間に見えてしまい、最近接偶数丸めによって偶数側の0x0000が選ばれます。

結果に残らないビットでも、丸めの方向を決めるためには必要だったということです。

最小の検証コードを書いた

0.1.5と修正後の0.1.7を同じプログラムから呼び、3個の境界値を比較する検証コードを作りました。

0.1.5はIssueで確認したGit revisionを指定し、0.1.7はcrates.ioのリリースを固定しています。

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

確認している部分は次のようになっています。

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はCPU固有の命令を使わない経路です。そのためARM64上でも、x86_64で使われた0.1.5のソフトウェア変換を明示的に再現できます。

実行方法はこれだけです。

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

Apple SiliconのMacで実行した結果は次のとおりでした。

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

3ケースとも、0.1.5のソフトウェア変換だけが1段小さい値になりました。0.1.7では期待するビット列と一致しています。

なぜCPUによって結果が変わったのか

float16 0.1.5from_f64は、x86とx86_64ではソフトウェア版のfrom_f64_constを呼びます。

一方、ARM64でFP16を利用できる場合は、f64からf16へ直接変換するCPU固有の経路が使われます。

そのため、今回の環境では次のように処理が分かれました。

x86_64
  f64 -> 0.1.5のソフトウェア変換 -> 誤った丸め

ARM64
  f64 -> FP16を使った直接変換   -> 正しい丸め

CPUごとに異なる丸めが許されているわけではありません。同じ変換を別々に実装したうち、ソフトウェア側だけに不具合があったため結果が変わりました。

Test262で差が出た10ケースのうち9ケースは、共通のbyteConversionValues.jsにある境界値を使っていました。残る1ケースも同じ微小値と丸め境界を確認しています。

0.1.7では修正されている

float16 0.1.7の実装では、丸め処理まで64ビットの仮数を保持するように変更されています。

Issueの調査では、今回の3ケースとTest262の共通fixtureにある56入力を比較した範囲で、0.1.7のソフトウェア変換に不一致はありませんでした。

ただし、これだけで全体の修正確認が終わったわけではありません。Linux x86_64、Linux ARM64、macOS ARM64で全Test262を再実行し、10ケースが解消してほかのテストが退行しないことを確認する作業は残っています。

また、現行Omoikaneのルート依存ではすでに0.1.7が選ばれています。今回0.1.5だったのは、移行対象として取り込んだBoa側の固定されたCargo.lockです。

そのため、この結果だけを見て現行Omoikaneにも同じ不具合があるとは言えません。

まとめ

最初はCPUの浮動小数点命令か、Rust側の変換に何か違いがあるのかと思いました。

実際には、float16 0.1.5のソフトウェア変換が丸め前に情報を捨てていたことと、CPUによって変換経路が分かれていたことが重なっていました。

最終結果が16ビットだからといって、最初から不要そうなビットを捨てると丸めに必要な情報まで消えることがあります。

そしてCPUごとに最適化した経路を持つ処理は、同じテストを複数のアーキテクチャで動かす意味がかなりありそうです。