negative/positive NaNを意識して、RustのsortとJSのsortの挙動を合わせる
- publishedAt:
- 2025/05/31
- updatedAt:
- 2025/05/31
Intro
少し前ですが、NovaにTypedArray.prototype.sortメソッドを追加しました。
今回はその中で、普段はあまり意識することのないnegative/positive NaNの違いによるsortのバグがあったので、その話をしようと思います。
問題
NaN
や-NaN
のビットパターンをBigIntリテラルで表現して、それをTypedArrayで扱うためにBigUint64Array(64ビット符号なし整数)に代入し、それをArrayBuffer経由でFloat64Arrayを使い、sortするようなJavaScriptの処理を想定します。
具体的には、以下のようなコードです。
const nanPatterns = [
0x7ff8000000000001n,
0x3ff0000000000000n,
0x7ff0000000000000n,
0xfff0000000000000n,
0x0000000000000000n,
0xfff8000000000000n,
0xbff0000000000000n,
];
const arr = new Float64Array(nanPatterns.length);
const u64 = new BigUint64Array(arr.buffer);
nanPatterns.forEach((bits, i) => {
u64[i] = bits;
});
arr.sort();
そしてこれを、Rustでは以下のようなsort処理を行うものとします。
slice.sort_by(|a, b| a.total_cmp(b));
結果は、それぞれ以下のようになります。
// Nova: [NaN, -inf, -1.0, 0.0, 1.0, inf, NaN]
// Other: [-Infinity, -1, 0, 1, Infinity, NaN, NaN]
Novaの場合は、NaNが先頭に来ています。正しくはOther(v8など)のようになるべきです。ただもちろん、これは実装当初の話なので、レビューを通して修正しました。
今回は、なぜこのようなことが起こるのか、どうやって治したのか。について書きます。
なぜ起きるのか?
JavaScriptでの挙動
JavaScriptにおいて、negative NaN(-NaN
)と、positive NaN(NaN
)は、基本的には同じNaNだと扱われます。
NaN自体の比較は全てfalseになる(NaN === NaN // false
)ので、isNaNを使ってNaNかどうかのチェックを行います。
このとき、positive/negative関係なくNaNはtrueになります。
> Number.isNaN(NaN)
true
> Number.isNaN(-NaN)
true
仕様書も見てみましょう。
globalのisNaNです。ecma262では以下のように定義されています。
ToNumberで変換された変数numが、NaNだったらtrueを返すように定義しています。
1. Let num be ? ToNumber(number).
2. If num is NaN, return true.
3. Otherwise, return false.
このとき、NovaではisNaNの引数で受け取ったnumberからすでにNaNとされているので、parse時点でNaNになっていそうです(たぶん)
$ cargo run eval index.js
// positive NaNを渡した
Number: SmallF64(NaN)
Number: NaN
// negative NaNを渡した
Number: SmallF64(NaN)
Number: NaN
Rustでの挙動
根本的な原因としては、参考実装時に使用したRustで使えるtotal_cmp
というメソッドが、AとBの比較を行う際にAとBをto_bitsして比較します。
negative NaNをビット値で扱おうと0x7ff8000000000001n
になります。これをto_bitsすると、0x7ff8000000000001n
()が返ってきます。
positive NaNは0xfff8000000000000n
で同じような処理になります。
そしてこれらの値を比較したとき、rustでは0x7ff8000000000001n
は先頭に来るようにsortするので、jsとは異なる挙動になってしまうということです。
どうやって治したのか
Novaではこのような問題に対応するため、total_cmpを使わず、partial_cmpとOrderingを組み合わせたecmascript_cmp(命名はとりあえずこれで、みたいな感じで作ったので変わると思う)というのものを用意しています。
インターフェースとコードは以下のようになっています。
pub trait ECMAScriptOrd {
fn ecmascript_cmp(&self, other: &Self) -> std::cmp::Ordering;
}
impl ECMAScriptOrd for f64 {
fn ecmascript_cmp(&self, other: &Self) -> std::cmp::Ordering {
if self.is_nan() {
if other.is_nan() {
return std::cmp::Ordering::Equal;
}
return std::cmp::Ordering::Greater;
}
if other.is_nan() {
return std::cmp::Ordering::Less;
}
if *self == 0.0 && *other == 0.0 {
if self.is_sign_negative() && other.is_sign_positive() {
return std::cmp::Ordering::Less;
}
if self.is_sign_positive() && other.is_sign_negative() {
return std::cmp::Ordering::Greater;
}
return std::cmp::Ordering::Equal;
}
self.partial_cmp(other).unwrap()
}
}
return時に出てくるOrderingは、以下のようなenumになっています。
pub enum Ordering {
Less = -1,
Equal = 0,
Greater = 1,
}
これを組み合わせて、AとBの比較時に以下のパターンのことを行なっています。
- AがNaNで、BがNaNじゃなかったらAをひとつ前に
- [NaN, “foo”] → [“foo”, NaN]
- AとBが両方ともNaNだったらそのまま
- [NaN, NaN] → [NaN, NaN]
- AがNaNではなくBがNaNだったら、Aをひとつ下げる
- [“foo”, NaN] → [“foo”, NaN]
- AとBが両方とも0である前提
- Aが-0、Bが+0だったらAを一つ下げる
- [-0, 0] → [0, -0]
- Aが+0、Bが-0だったらAを一つあげる
- [0, -0] → [0, -0]
- 上記二つのどちらでもなかったらそのまま
- [”foo”, “bar”] → [“foo”, ”bar”]
- Aが-0、Bが+0だったらAを一つ下げる
上記以外のsortに関してはpartial_cmpに任せています。
partial_cmpは[Option](https://doc.rust-lang.org/std/option/enum.Option.html)<[Ordering](https://doc.rust-lang.org/std/cmp/enum.Ordering.html)>
となっていて、 NaNが渡れば本来Noneを返しますが、ここにたどり着いた時点でNaNが渡ることはないので、unwrapするようになっています。
この処理自体は、Rustのsliceとして書いて渡してあげれば挙動をチェックできるので、もし気になったらRust Playgroudでチェックしてみてください
まとめ
何はともあれ、今は正常に動くようになったはずです。あと多分それならいに早いとも思います。Rustパワー。
あと確かこれに関してはtest262にはなくて、レビューで言われて初めて気づいた挙動でした。(SpiderMonkeyのnon262にはあるのかな...??)
negative/positive NaNを意識して、RustのsortとJSのsortの挙動を合わせる
- publishedAt:
- 2025/05/31
- updatedAt:
- 2025/05/31
0