浮点数往返后不相等
主线位置:两条主线各有一半。读回来差一位出在反序列化主线(
parse_decimal之后的f64_from_parts);NaN 变null出在序列化主线(serialize_f64)。
flowchart TB f["f64 值"] -->|"to_string"| t["最短十进制串"] t -->|"from_str 默认"| d["两次舍入"] d --> bad["约 10% 差一个 ULP"] t -->|"float_roundtrip"| ok["正确舍入,逐位相同"] n["NaN 或无穷"] -->|"to_string"| nul[null] nul -->|"from_str f64"| err["invalid type: null"]
数字怎么解析、f64_from_parts 的两个版本,在 反序列化一侧 的数字一节讲过;NaN 为什么写成 null、作为键为什么报错,在 序列化一侧。这一页只讲现象、修法和检查。
现象
缓存、签名、去重时常写这样的断言:from_str(&to_string(&x)?)? == x。对 f64,它在默认 feature 下会时不时失败。src/bin/floats.rs 拿一百万个 [0, 1) 里的随机 f64 试,顺便测一下解析一个一百万元素数组要多久:
use std::time::Instant;
// Writes many f64 with to_string (shortest repr), reads them back with from_str.
fn check(label: &str, mut make: impl FnMut(u64) -> f64) {
let mut x: u64 = 0x2545F4914F6CDD1D;
let (mut total, mut differ) = (0, 0);
let mut example = None;
for _ in 0..1_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
let f = make(x);
let text = serde_json::to_string(&f).unwrap();
let parsed: f64 = serde_json::from_str(&text).unwrap();
// The text itself is fine: std's parser reads every one back exactly.
assert_eq!(text.parse::<f64>().unwrap(), f);
total += 1;
if parsed != f {
differ += 1;
example.get_or_insert((text, f.to_bits(), parsed.to_bits()));
}
}
println!("{label}: {total} round trips, {differ} not identical");
if let Some((text, a, b)) = example {
println!(" e.g. {text}: bits {a:#x} -> {b:#x}");
}
}
fn main() {
check("[0, 1)", |x| (x >> 11) as f64 / (1u64 << 53) as f64);
// Parse speed: one JSON array of 1_000_000 floats in [0, 1).
let mut x: u64 = 0x2545F4914F6CDD1D;
let floats: Vec<f64> = (0..1_000_000)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
(x >> 11) as f64 / (1u64 << 53) as f64
})
.collect();
let text = serde_json::to_string(&floats).unwrap();
let mut best = u128::MAX;
for _ in 0..5 {
let t = Instant::now();
let back: Vec<f64> = serde_json::from_str(&text).unwrap();
best = best.min(t.elapsed().as_millis());
assert_eq!(back.len(), floats.len());
}
println!("parse {} bytes of floats: best of 5 = {best} ms", text.len());
}
M2 Max、macOS、rustc 1.99.0,--release,serde_json = "=1.0.154"(默认 feature),连跑两次输出相同:
[0, 1): 1000000 round trips, 105294 not identical
e.g. 0.38595771669529844: bits 0x3fd8b387feb616c6 -> 0x3fd8b387feb616c5
parse 19270909 bytes of floats: best of 5 = 19 ms
assert_eq! 一次也没触发:to_string 写出的是能读回原值的最短串,标准库的 str::parse 每个都读对了。错在 serde_json 读的那一侧,十个里大约一个差最后一位(位模式只差 1)。
从哪里来
默认构建下的 f64_from_parts 拿着累加好的十进制有效数字和指数,这样算:
#[cfg(not(feature = "float_roundtrip"))]
fn f64_from_parts(
&mut self,
positive: bool,
significand: u64,
mut exponent: i32,
) -> Result<f64> {
let mut f = significand as f64;
loop {
match POW10.get(exponent.wrapping_abs() as usize) {
Some(&pow) => {
if exponent >= 0 {
f *= pow;
// ...
} else {
f /= pow;
}
break;
}
// ...
}
}
Ok(if positive { f } else { -f })
}
0.38595771669529844 的有效数字是 17 位的 38595771669529844,超过 2^53,significand as f64 先舍入一次;再除以 1e17 又舍入一次。两次舍入叠起来,就可能落到相邻的那个 f64 上。有效数字不超过 2^53、指数不超过 22 时,significand as f64 和 10^k 都是精确的,只剩乘除这一次舍入,结果就是正确舍入的值(比如两位小数的金额),反序列化一侧 的实验里「分」一百万个零差异。
修法:float_roundtrip
开 feature:
serde_json = { version = "=1.0.154", features = ["float_roundtrip"] }
开了之后 f64_from_parts 换成 lexical::parse_concise_float,正确舍入。同一个 floats.rs 放进只开了这一个 feature 的 crate,连跑两次:
[0, 1): 1000000 round trips, 0 not identical
parse 19270909 bytes of floats: best of 5 = 29 ms
第二次是 30 ms。代价写在 Cargo.toml 里:
# Use sufficient precision when parsing fixed precision floats from JSON to
# ensure that they maintain accuracy when round-tripped through JSON. This comes
# at an approximately 2x performance cost for parsing floats compared to the
# default best-effort precision.
#
# Unlike arbitrary_precision, this feature makes f64 -> JSON -> f64 produce
# output identical to the input.
float_roundtrip = []
「approximately 2x」说的是解析浮点数这一步。上面测的是解析整个数组(还有逗号、Vec 的增长),19 ms 变成 29–30 ms,慢了大约一半。
两点要知道:
- 注释最后一句把它和
arbitrary_precision分开:float_roundtrip保证f64 → JSON → f64不变;arbitrary_precision保证JSON → Number → JSON不变(Value)。要的是前者,就别开后者。 - feature 在整个依赖图里合并:依赖树里任何一个 crate 开了它,所有用 serde_json 的地方都变成正确舍入,也都付这份代价;反过来,你没开、依赖也没开,就是默认的 best-effort。
PR #671「tweak f64 deserialization to make float roundtrips work」在 2020-06-07 合并,说明是「This is a new attempt at fixing issue #536 using the latest version of ... minimal-lexical library for deserialization of f64 values. The goal is to make float roundtrip work without severely impacting compile times.」(issue #536 标题就是「Perfect accuracy float parsing」。)合并时它删掉了原来用 POW10 表乘除的那条路径。
第二天发版之前,主分支上接连几个提交把它改成可选:「Introduce float_roundtrip cargo cfg」「Call out performance cost of float_roundtrip」「Restore best-effort float parse codepath」。这些和 #671 一起首次发布于 1.0.54,发布说明写的是「Add float_roundtrip feature to enable a slower but higher precision float parser」。所以从来没有哪个发布版默认正确舍入。上面那段 cfg(not(feature = "float_roundtrip")) 的 POW10 路径,就是那次恢复回来的。
另一种不相等:NaN 和无穷
JSON 没有 NaN 和无穷的写法。src/bin/nan.rs:
// NaN and infinities have no JSON spelling: what comes out, and can it be read back?
fn main() {
let v = vec![1.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
let text = serde_json::to_string(&v).unwrap();
println!("to_string {text}");
println!("to_value {:?}", serde_json::to_value(&v).unwrap());
println!("from_str Vec<f64> {:?}", serde_json::from_str::<Vec<f64>>(&text).map_err(|e| e.to_string()));
println!("from_str Vec<Option<f64>> {:?}", serde_json::from_str::<Vec<Option<f64>>>(&text).map_err(|e| e.to_string()));
println!("from_str 1e400 {:?}", serde_json::from_str::<f64>("1e400").map_err(|e| e.to_string()));
}
to_string [1.5,null,null,null]
to_value Array [Number(1.5), Null, Null, Null]
from_str Vec<f64> Err("invalid type: null, expected f64 at line 1 column 9")
from_str Vec<Option<f64>> Ok([Some(1.5), None, None, None])
from_str 1e400 Err("number out of range at line 1 column 5")
- 写出时不报错:
serialize_f64看到FpCategory::Nan | FpCategory::Infinite就改调write_null,to_value同样得到Null。 - 读回
f64失败,因为null不是数字。读成Option<f64>能成功,但三种值都成了None,分不出是 NaN 还是哪个方向的无穷。 - 读的一侧也不会产生无穷:
1e400上溢报number out of range。
需要保留它们,就得自己约定表示(比如用字符串 "NaN"、"inf",配 serialize_with / deserialize_with),serde_json 不替你做。
怎么确认没中招
有没有开:
cargo tree -e features -i serde_json,看有没有serde_json feature "float_roundtrip"这一行。只开了这一个 feature 的实验 crate:serde_json v1.0.154 ├── serde_json feature "default" │ └── exp-serde-pit4-roundtrip v0.1.0 (<scratchpad>/exp-serde-pit4/roundtrip) │ └── exp-serde-pit4-roundtrip feature "default" (command-line) ├── serde_json feature "float_roundtrip" │ └── exp-serde-pit4-roundtrip v0.1.0 (<scratchpad>/exp-serde-pit4/roundtrip) (*) └── serde_json feature "std" └── serde_json feature "default" (*)依赖逐位相等的地方,写一条往返测试,输入用随机位模式或
[0, 1)的随机数,不要只用0.5、1.25这种短小数:它们在默认 feature 下也总是对的。数据里可能出现 NaN 或无穷时,确认读的一侧用的是
Option<f64>或自定义表示,否则写得出、读不回。
小结
- 默认的浮点解析是 best-effort:
significand as f64再乘除10^k,两次舍入,[0, 1)的随机数约 10% 读回来差一个 ULP。写出的一侧没问题。 - 开
float_roundtrip换成正确舍入的 lexical 算法,实验里零差异;文档说解析浮点慢约 2 倍,整个数组的解析实测从 19 ms 到 29–30 ms。 - 1.0.54 引入这个 feature 时它就是可选的,正确舍入从来不是默认。
- NaN 和无穷写成
null,读回f64报invalid type: null, expected f64;Option<f64>能读,但分不出原值。