Rust in Everything
基于 serde_json 1.0.154

浮点数往返后不相等

主线位置:两条主线各有一半。读回来差一位出在反序列化主线(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。
📝NOTE
冷知识:正确舍入曾经差一点成为默认

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> 能读,但分不出原值。

💬 关联讨论

发起讨论
加载中...