- Published on
Parquet 2.13.0 的 nan_count 与 IEEE 754 total order — float 统计用 3 年 3 个月才修好的故事
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 统计本该用来跳过的,但在 float 上却做不到
- 为什么 float 统计格外棘手
- 这不是纸上谈兵 — 在 DataFusion 中至今仍能复现
- PARQUET-2249 — ColumnIndex 面对的不可能要求
- 3 年 3 个月 — 两次流产,最终达成共识
- 2.13.0 在规范里究竟改了什么
- total order 的实现出人意料地只有一行
- 当前实现状态 — 规范已发布,引擎还没跟上
- 诚实的代价 — 这次修复并不免费
- 那么,现在应该做什么
- 结语
- 参考资料
引言 — 统计本该用来跳过的,但在 float 上却做不到
Parquet 文件之所以快,很大一部分要归功于统计信息。每个 row group、每个列块(column chunk)、每个页面,都写下了 min、max、null_count 这样的摘要,查询引擎只看这份摘要,就判断"这一块里没有答案",从而整块跳过。谓词下推和页面裁剪都建立在这个基础之上。列式存储的全貌,我已经在Columnar Storage 完全指南 2025:Parquet/ORC/Arrow 的整体结构一文里整理过了。
对整数或字符串来说,这份契约很简单。writer 记下真实的最小值和最大值,reader 按这个范围判断就行。但在 FLOAT、DOUBLE 列上,这个简单的模型会崩溃。而且不是"稍微变慢一点"这种崩法,而是以查询找不到明明存在的行这种方式崩溃。
2026 年 6 月 13 日发布的 parquet-format 2.13.0,在规范层面堵上了这个漏洞。本文要看的是,这次改动究竟是什么、为什么花了 3 年多、以及现在是否真的能用。
为什么 float 统计格外棘手
原因有两个。Parquet 社区发布的官方博客文章(Jan Finis、Gang Wu,2026-05-29)把这两点都梳理清楚了。
第一,带符号的零。 -0.0 和 +0.0 在普通运算中被视为相等,但比特模式并不相同。如果不规定顺序,不同的库会在同一份数据上给出不同的统计结果。Parquet 一直以来采用的办法是绕过问题 — 强制规定:只要最小值是 0.0,无论实际符号如何都写成 -0.0;只要最大值是 0.0,就写成 +0.0。然后警告 reader,"即便 min 是 +0.0,也可能存在 -0.0"。这是防御,不是排序。
第二,NaN。 这才是真正的问题所在。在 IEEE 754 中,NaN 根本没有顺序。x < NaN、x > NaN、x == NaN 全部为 false。如果 writer 直接把 NaN 塞进 min/max 的计算,算出来的边界毫无用处。于是 Parquet 选择了相反的方向 — 把 NaN 排除在统计之外。
事故就出在这里。照搬博客原文的例子来说:假设某页面的 max 统计是 0.0,而这个页面里恰好有一个 NaN。一个把 NaN 视为大于所有值的查询引擎,抛出了类似 x > 1.0 的谓词。引擎一看统计信息,max 是 0.0,于是即便实际上存在满足条件的行,也可能错误地把这个页面整个跳过。用原文的说法,在不知道 NaN 是否存在的情况下,引擎根本无法对浮点数列安全地执行这种页面裁剪。
也就是说,统计信息其实是"去掉 NaN 之后的范围",但 reader 根本没有办法知道这一点 — 因为当时还没有 nan_count。
这不是纸上谈兵 — 在 DataFusion 中至今仍能复现
如果这听起来太抽象,那就来看真实的复现案例。DataFusion 的 issue #15812「Pruning of floating point Parquet columns is incorrect when NaN is present」 于 2025 年 4 月 22 日提出,直到今天(2026-07-16)依然处于打开状态。
issue 正文里的例子是这样的。如果这一列是 [1.0, 0.0, -1.0, NaN, -2.0],max 会被记录为 1,min 记录为 -2(排除 NaN)。但如果执行 select * from ... where x > 2,由于没有任何 chunk 的 max > 2,结果就是 0 行。而 DataFusion 认为 NaN 大于 2,所以正确答案应该是 NaN 所在的那一行。
issue 中附带的复现步骤,用的是 parquet-testing 仓库里的真实文件。
> select * from 'parquet-testing/data/float16_nonzeros_and_nans.parquet' where x > arrow_cast(2.0, 'Float16');
+---+
| x |
+---+
+---+
0 row(s) fetched.
期望的结果是包含 NaN 的一行。而那个文件如今仍然在仓库里。
让问题变得更棘手的是,各引擎在 NaN 比较上根本没有共识。看 alamb 在 arrow-rs 追踪 issue #8156 中贴出的执行结果:同一份数据上,DataFusion 判定 NaN > 34 为 true、-NaN > 34 为 false(因为 arrow-rs 在浮点数比较中使用了 total order),而 PostgreSQL 对两者都返回 true。在同一个讨论串里,tustvold 甚至说 — 各实现中带 bug 的太多了(按他的说法,也包括 arrow-rs 自己过去的版本),DataFusion 干脆就不该下推浮点谓词,如果现在正在这么做,那就是一个需要修的 bug。
这就是必须修规范的原因。指望每个引擎各自做好,是解决不了这个问题的。
PARQUET-2249 — ColumnIndex 面对的不可能要求
这个问题有名字,叫 PARQUET-2249。标题是 "Parquet spec (parquet.thrift) is inconsistent w.r.t. ColumnIndex + NaNs",由 Jan Finis 于 2023 年 2 月 19 日提出。
核心是这样的。假设某个数据页面全部都是 NaN。这个页面并不是"null 页面" — 数据是物理存在的,而 NaN 不同于缺失(null)。可是现行指南要求在计算 min/max 时排除 NaN。与此同时,ColumnIndex 对任何非 null 的页面都强制要求(required)给出有效的 min_values 和 max_values。明明一个可用的 non-NaN 值都没有,却必须写 — 规范内部的矛盾无法闭合。
在 Statistics 这一侧,可以组合 num_values、null_count、nan_count 推导出"non-null 全部是 NaN"这件事。但 ColumnIndex 里没有 num_values 字段。直接读 2.13.0 的 parquet.thrift 就能看到,ColumnIndex 的字段只有 null_pages、min_values、max_values、boundary_order、null_counts、几个层级直方图,以及新加入的 nan_counts。这里根本做不了那个算术。
3 年 3 个月 — 两次流产,最终达成共识
- 2023-02-19 — PARQUET-2249 提出(Jan Finis)。
- 2023-03-22 — PR #196「Add nan_count to statistics」。提议在统计信息里加一个
nan_count。它能明确告知 NaN 是否存在,而且是可选字段,是一条不会破坏旧 reader 的安全迁移路径。留下 99 条评论(31 条一般评论 + 68 条 review)后,未合并就被关闭了 — 因为它没能解决ColumnIndex的两难。 - 2023-11-22 — PR #221「Introduce IEEE 754 total order」。干脆引入一种全新列排序方式的方案。虽然给带符号的零和 NaN 的比特模式赋予了确定的位置,却有一个致命弱点 — 由于 NaN 被放在 total order 的两端,哪怕只有一个 NaN,也会污染 min 或 max,让普通的数值谓词下推完全失效。留下 52 条评论后,同样未能合并。
- 2025-04-22 — DataFusion #15812 提出。在规范讨论还在打转的时候,它以真实引擎给出错误结果的形式浮现了出来。
- 2025-08-09 — PR #514「PARQUET-2249: Introduce IEEE 754 total order & NaN-counts」。把两者合并到一起。
- 2026-05-26 — PR #514 合并。开放约 9 个半月,评论 67 条(17 条一般 + 50 条 review),净变更是 3 个文件、新增 98 行 / 删除 6 行。
- 2026-06-13 — parquet-format 2.13.0 发布。
从 issue 提出到规范合并,一共花了 3 年 3 个月。考虑到最终改动只有 98 行,很明显这个项目难的不是代码,而是达成共识。
达成共识的逻辑是这样的。新的 total order 严格定义了边界要如何比较,nan_count 则明确标出 NaN 是否存在。而决定性的一点是 — 由于现有的 writer 里没有一个用新排序方式的,规范就可以放心地规定:一旦使用 IEEE_754_TOTAL_ORDER,就必须写 nan_count,同时依然宽容地处理旧文件。这相当于重新造了一张干净的白纸。
2.13.0 在规范里究竟改了什么
与其说明,不如直接看 IDL 更快。2.12.0 的 parquet.thrift 里,nan_count 和 IEEE754TotalOrder 都不存在(字符串搜索结果为 0)。2.13.0 新增了三样东西。
第一,Statistics 加上了第 9 号字段。
/**
* Count of NaN values in the column; only present if physical type is FLOAT
* or DOUBLE, or logical type is FLOAT16.
* If this field is not present, readers MUST assume NaNs may be present
* (i.e. MUST assume nan_count > 0 and MAY NOT assume nan_count == 0).
*/
9: optional i64 nan_count;
这里重要的是最后一句。字段"不存在"和字段"值为 0"是两回事。不存在意味着"不知道",reader 必须假设可能存在 NaN。没有这条规则,所有旧文件都会被误读成"没有 NaN"。
第二,ColumnIndex 加上了按页面列出的列表。
/**
* A list containing the number of NaN values for each page. Only present
* for columns of physical type FLOAT or DOUBLE, or logical type FLOAT16.
* If this field is not present, readers MUST assume that there might be
* NaN values in any page.
*/
8: optional list<i64> nan_counts
第三,ColumnOrder union 多了一个新的变体。
/** Empty struct to signal IEEE 754 total order for floating point types */
struct IEEE754TotalOrder {}
union ColumnOrder {
1: TypeDefinedOrder TYPE_ORDER;
2: IEEE754TotalOrder IEEE_754_TOTAL_ORDER;
}
规范里是这样描述新排序方式的含义的 — 它遵循 IEEE-754(2008 修订版)第 5.10 节的 totalOrder 谓词,只有 FLOAT、DOUBLE,或逻辑类型为 FLOAT16 的列才能使用这种排序。直观地说,就是把浮点数按数学顺序排列,同时把 -0 排在 +0 之前,把 -NaN 排在所有值之前,把 +NaN 排在所有值之后,并且在同一个值的不同比特表示之间也定义顺序。
而当使用 IEEE_754_TOTAL_ORDER 时,记录 nan_count 就变成"强制"。min/max 保存的是 non-NaN 值中的最小/最大值,但如果所有 non-null 值都是 NaN,就回退为 total order 意义下的最小/最大 NaN — 这正是解开 PARQUET-2249 那个不可能要求的地方。由于 ColumnIndex 没有 num_values,min_values 或 max_values 中出现 NaN 这件事本身,就成了"这个页面全部是 NaN"的信号。
既有的 TYPE_ORDER 也没有被直接丢弃。2.13.0 针对 TYPE_ORDER 新写明:"float 类型对 NaN 与 -0/+0 的处理欠规范(underspecified)、存在歧义,因此建议 writer 使用 IEEE_754_TOTAL_ORDER",而如果仍要使用它,就必须写出 nan_count(即便是 0),并且 min/max 只能用 non-NaN 值计算。2.12.0 中原本是 "should" 的不少地方,被收紧成了 "must"。
total order 的实现出人意料地只有一行
名字听起来很唬人,好像需要一个很重的比较引擎,但其实不用。官方博客给出的 Rust 示意代码是这样的。
pub fn totalOrder(x: f64, y: f64) -> bool {
let mut x_int = x.to_bits() as i64;
let mut y_int = y.to_bits() as i64;
x_int ^= (((x_int >> 63) as u64) >> 1) as i64;
y_int ^= (((y_int >> 63) as u64) >> 1) as i64;
return x_int <= y_int;
}
这是一个技巧:保留 IEEE 的比特模式,只翻转负数,让整数表示按正确顺序排列,然后直接当整数来比较。32 位的 float 也是同样的做法。
而实际的实现也确实是这么做的。打开 parquet-java 的 PrimitiveComparator 一看,用的正是同一个技巧。
public int compare(double d1, double d2) {
long d1Long = Double.doubleToRawLongBits(d1);
long d2Long = Double.doubleToRawLongBits(d2);
d1Long ^= ((d1Long >> 63) >>> 1);
d2Long ^= ((d2Long >> 63) >>> 1);
return Long.compare(d1Long, d2Long);
}
关键点在于用的是 doubleToRawLongBits,而不是 doubleToLongBits。前者会把 NaN 规范化掉,后者则会原样保留 NaN 的符号和 payload 比特。total order 要做到"在同一个值的不同比特表示之间也定义顺序",就必须是 raw 的。
当前实现状态 — 规范已发布,引擎还没跟上
这里是本文最需要诚实的部分。规范发布了,不代表你的流水线就修好了。以下是截至 2026 年 7 月 16 日,我直接核对各个实现得到的结果。
parquet-java — 已实现,但尚未发布。 PR #3393「PARQUET-2249: Add IEEE-754 total order and nan count for floating types」 由 wgtmac(官方博客的共同作者 Gang Wu)于 2026 年 2 月 12 日提出,于 2026 年 6 月 25 日合并。规模是 40 个文件,新增 4,857 行 / 删除 229 行。里面包含 IEEE754FloatStatistics、IEEE754DoubleStatistics、IEEE754Float16Statistics 这样的实际类,以及 TestIeee754TotalOrderE2E 这样的端到端测试,pom.xml 里的 parquet.format.version 已经是 2.13.0。不过 parquet-java 最新的发布版本是 1.17.1(2026-05-12) — 早于这次合并。也就是说,现在从 Maven 拉到的版本里还没有这个功能。
Arrow C++ — 只同步了 thrift。 2026 年 6 月 29 日的一次提交"GH-50265: [C++][Parquet] Update parquet.thrift to sync with 2.13.0",让 cpp/src/parquet/parquet.thrift 跟上了 2.13.0,生成的 parquet_types.h 等文件里也带上了新字段。但真正计算统计信息的 cpp/src/parquet/statistics.cc 里,对 nan_count 的提及是零。也就是说,线上格式的定义有了,但填充或读取它的逻辑还没有。
arrow-rs / DataFusion — 仍是 draft。 PR #9619「Implement PARQUET-2249: Introduce IEEE 754 total order」 由 etseidl 于 2026 年 3 月 26 日提出,至今仍是 draft(17 个文件,新增 1,545 行 / 删除 252 行,评论 106 条,最后更新于 2026-07-14 — 目前仍在积极推进)。此前的尝试 — Xuanwo 的 #8158 已于 2026 年 7 月 4 日在未合并的情况下关闭,作为 PoC 的 #7408 和 #9669 也都没有合并。搜索当前 main 分支手写的 thrift 解码器(parquet/src/file/metadata/thrift/mod.rs),nan_count 是 0 条命中,basic.rs 里的 ColumnOrder 枚举所知道的只有 TYPE_DEFINED_ORDER、UNDEFINED、UNKNOWN。也就是说,Rust 的 parquet crate 最新发布版(59.1.0,2026-07-07)里还没有这个功能。
总结一下 — 规范在 6 月 13 日发布,一个月过去的现在,任何一个主流实现的发布版本里都还没有这个功能。 parquet-java 走得最靠前,也最有可能出现在下一个版本里,但目前也还没发布。
诚实的代价 — 这次修复并不免费
"老 bug 被修好了"这种事,一般听起来是纯粹的好消息。这次不是。
它是可选启用(opt-in)的。 parquet-java PR #3393 的正文里写得很明确 — "The existing type-defined order remains the default."(现有的 type-defined order 仍然是默认值。)看代码也能确认这一点:PrimitiveType 在没有指定 columnOrder 时,会落到 ColumnOrder.typeDefined()。必须按列在 schema 里手动开启。什么都不做,就什么都不会变。
裁剪反而会变得更保守。 照搬同一份 PR 正文里的话,"Filtering around NaN values may be more conservative to avoid dropping data that can still match."(为了不丢弃仍可能匹配的数据,围绕 NaN 的过滤可能会变得更保守。)这就是这个权衡的本质 — 迄今为止的"快",建立在一部分错误答案之上;一旦保证结果正确,某些查询能跳过的就变少了。如果 nan_count 不是 0,像 col > 34 这样的谓词就无法只靠统计信息下推了。这正是 tustvold 在 arrow-rs 讨论串里指出的那一点。没有任何来源公开过这会拖慢多少的具体测量数据,所以这里我不会编造数字。
波及范围远比规范里的 98 行要大得多。 看看 parquet-java 那边改动的 40 个文件里都有什么,就能感觉到这一点 — 除了统计信息和列索引之外,还动到了 DictionaryValuesWriter、ByteStreamSplitValuesWriter、BloomFilterImpl、DictionaryFilter、StatisticsFilter,甚至 LittleEndianDataOutputStream。根据 PR 正文,这是为了把 NaN 的符号位和 payload 比特,原样保留成应用程序传入时的样子。反过来说,这也意味着parquet-java 此前一直没有保留 NaN 的比特模式。
旧 reader 的兼容性,理论和实际是两回事。 规范写明"如果 reader 不支持这个 union 的某个取值,就必须忽略该列的 min/max 统计",而 arrow-rs 目前的 main 分支也确实这么做 — 把不认识的 union 变体归入 ColumnOrder::UNKNOWN,并忽略统计,完全按规范来。但 arrow-rs 的 issue #8156 里,emkornfield 在 2026 年 5 月 19 日提出了一个担忧。旧版本(用他的话说,"I think prior to arrow 57")用的是生成出来的 thrift 代码,读取新文件时解析器可能会 panic,是不是需要回补(backport)?etseidl 的回答是,问题不在 crate 代码本身,而在 Rust 的 thrift 生成器里;alamb 则回复"等真的有人需要的证据出现了,再来回补"。不管怎样 — 即便规范通过可选字段和 union 扩展被设计成"安全"的,新增一个字段,和在排序 union 里新增一个变体,二者的兼容性性质并不相同。前者,旧 reader 忽略掉就好;后者则完全取决于旧 reader 怎么处理它。
那么,现在应该做什么
现在就该做的事:检查你的 float 谓词是不是已经出错了。 这不是未来的问题,是现在的问题。如果你正在对一个可能出现 NaN 的 float 列执行 > / < 谓词,而引擎又在做基于统计信息的裁剪,那你就有和 DataFusion #15812 同类的错误结果风险。判断标准很简单 — 那一列实际上是否可能出现 NaN。传感器数据、用 NaN 表示缺失值的科学计算流水线、会有除以零结果流入的聚合 — 这些都是候选场景。如果一个列原理上根本不可能出现 NaN,那这整篇文章都跟你无关。
不该开启的时候:现在的大多数情况。 第一个理由是它压根开不了 — 任何发布版本里都还没有它。第二个理由是互操作性。用新列排序方式写出的文件,只有当 reader 理解这种排序方式时才能利用其中的统计信息,而现在几乎没有这样的 reader。现在开启它得到的是"将来才会生效的元数据",同时要付出的代价是与旧 reader 之间未经验证的交互。这正是为什么 parquet-testing 仓库里加入了互操作测试文件,parquet-java 也附上了 TestInterOpReadFloatingPointNanCount 这样的测试 — 这个问题不是靠某一个实现做得好就能解决的。
值得开启的时刻。 等 parquet-java 下一个版本发布、arrow-rs #9619 脱离 draft 状态并合并、Arrow C++ 在 thrift 之外补上实际逻辑之后 — 那时候,从真正会出现 NaN 的列开始,按列逐个开启才是对的做法。不是一次性全面切换,而是按列可选启用,这个设计本身就是在告诉你该这么用。
在那之前,安全的默认做法是保守一点。 tustvold 的建议最清楚 — 既然各实现现在是这个状态,float 谓词干脆就不下推才是对的。慢是可以修的,错却是看不见的。
结语
总结一下。Parquet 的 min/max 统计这份契约,在 NaN(一个没有顺序的值)和 -0.0/+0.0(同一个值的两种比特模式)面前不成立了。把 NaN 排除在统计之外的做法,没有给 reader 任何办法知道这件事,结果就是产生了找不到实际存在的行这类错误答案。ColumnIndex 更是直接卡在规范内部的矛盾里。
2.13.0 的解法是两块拼在一起 — 用 nan_count 明确标出 NaN 是否存在,用 IEEE_754_TOTAL_ORDER 把比较方式钉死成确定性的,再规定使用新排序方式时必须写 nan_count,从而造出一个干净的信号:"新排序方式 = 可信的统计信息"。之所以花了 3 年 3 个月、经历了两次流产的 PR,是因为这两个方案各自都只是半个答案,而承认这一点花了不少时间。
如果这篇文章只能带走一件事,那就是这个。规范发布了,和问题解决了,是两码事。 就在此刻,规范已经完结,可复现的错误结果仍然是一个悬而未决的 issue,任何主流实现的发布版本里都还没有这个修复。与其读完格式规范就下结论说"修好了",不如去确认一下,你实际使用的库版本里,它是不是真的能用。这一次,答案依然是"不能"。
参考资料
- parquet-format 2.13.0 发布 (2026-06-13)
- Taming Floating-Point Statistics in Apache Parquet: IEEE 754 Total Order and NaN Counts (Jan Finis, Gang Wu, 2026-05-29)
- parquet.thrift @ 2.13.0 —
Statistics.nan_count、ColumnIndex.nan_counts、ColumnOrderunion 原文 - PARQUET-2249 — Parquet spec is inconsistent w.r.t. ColumnIndex + NaNs(2023-02-19 提出)
- parquet-format PR #514 — 合并 total order 与 NaN 计数 (2025-08-09 → 2026-05-26 合并)
- parquet-format PR #196 — 单独的 nan_count 方案(流产)
- parquet-format PR #221 — 单独的 IEEE 754 total order 方案(流产)
- DataFusion #15812 — 存在 NaN 时 float 列裁剪不正确(仍然打开)
- parquet-java PR #3393 — 实现(2026-06-25 合并,40 个文件)
- arrow-rs PR #9619 — Rust 实现(draft)
- arrow-rs #8156 — 追踪 issue 与各引擎 NaN 比较的讨论
- Columnar Storage 完全指南 2025:Parquet/ORC/Arrow 的整体结构(相关文章)