- はじめに — 統計はスキップするためにあるのに、floatではそれができなかった
- なぜfloat統計だけが特に難しいのか
- これは机上の話ではない — DataFusionでは今も再現する
- PARQUET-2249 — ColumnIndexが抱えた不可能な要求
- 3年3か月 — 2度の頓挫、そして合意
- 2.13.0がスペックで正確に変えたこと
- total orderの実装は意外にも1行
- 現在の実装状況 — スペックは出たが、エンジンはまだ
- 正直なコスト — この修正はタダではない
- で、今何をすべきか
- おわりに
- 参考資料
はじめに — 統計はスキップするためにあるのに、floatではそれができなかった
Parquetファイルが速い理由の大部分は統計にあります。ロウグループごと、カラムチャンクごと、ページごとにmin、max、null_countのような要約を書いておき、クエリエンジンはその要約だけを見て「このかたまりに答えはない」と判断して丸ごとスキップします。プレディケートプッシュダウンとページプルーニングはこの上に成り立っています。カラム指向ストレージの全体像はColumnar ストレージ完全ガイド 2025: Parquet, ORC, Apache Arrow, Dremel — 分析 DB が 10,000 倍速い理由編にまとめてあります。
整数や文字列では、この契約はシンプルです。writerが本当の最小値と最大値を記録し、readerがその範囲で判断すればよいだけです。ところがFLOAT、DOUBLEカラムでは、このシンプルなモデルが崩れます。それも「少し遅くなる」形ではなく、クエリが存在する行を見つけられない形で崩れます。
2026年6月13日にリリースされたparquet-format 2.13.0は、この穴をスペックのレベルで塞ぎました。本稿では、その変更が正確に何なのか、なぜ3年以上かかったのか、そして今実際に使える状態なのかを見ていきます。
なぜfloat統計だけが特に難しいのか
理由は2つあります。Parquetコミュニティが出した公式ブログ記事(Jan Finis、Gang Wu、2026-05-29)が、この2つを整理しています。
第一に、符号付きゼロ。 -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が1つ入っているとします。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を投げると、max > 2のチャンクが存在しないため結果は0行になります。DataFusionはNaNを2より大きいと見なすので、正解はNaNの1行であるはずです。
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を含む1行です。そしてそのファイルは今もリポジトリに存在します。
問題をさらに難しくしているのは、エンジン同士がNaNの比較で合意していないという点です。arrow-rsの追跡issue #8156にalambが貼った実行結果を見ると、DataFusionは同じデータでNaN > 34をtrueと、-NaN > 34をfalseと判定するのに対し(arrow-rsが浮動小数点比較にtotal orderを使っているためです)、PostgreSQLはどちらもtrueを返します。同じスレッドでtustvoldはここまで言い切ります — 実装にバグがありすぎるので(彼の表現ではarrow-rs自身の過去バージョンも含めて)、DataFusionは浮動小数点プレディケートをそもそもプッシュダウンすべきではなく、もし今やっているならそれは直すべきバグだと。
これがスペックを直さなければならなかった理由です。各エンジンが個別にうまくやるだけでは解決しない問題でした。
PARQUET-2249 — ColumnIndexが抱えた不可能な要求
問題には名前があります。PARQUET-2249です。タイトルは"Parquet spec (parquet.thrift) is inconsistent w.r.t. ColumnIndex + NaNs"で、2023年2月19日にJan Finisが登録しました。
核心はこうです。あるデータページがすべてNaNだとします。このページは「nullページ」ではありません — データは物理的にそこにあり、NaNは欠測(null)とは違います。ところが既存の指針はmin/maxの計算からNaNを除けと言います。一方ColumnIndexは、nullでないページであれば有効なmin_valuesとmax_valuesをrequiredとして要求します。使えるnon-NaN値が1つもないのに必ず書かなければならない状況 — スペックの内部で矛盾が閉じません。
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か月 — 2度の頓挫、そして合意
- 2023-02-19 — PARQUET-2249 登録(Jan Finis)。
- 2023-03-22 — PR #196「Add nan_count to statistics」。統計に
nan_countを追加しようという案。NaNの存在を明示的に伝えられ、オプショナルフィールドなので旧readerを壊さない安全な移行経路でした。コメント99件(一般31+レビュー68)を残し、マージされないままクローズされました。ColumnIndexのジレンマを解けなかったためです。 - 2023-11-22 — PR #221「Introduce IEEE 754 total order」。まったく新しいカラム順序を導入する案。符号付きゼロとNaNのビットパターンに決定論的な位置を与える代わりに、致命的な弱点がありました — NaNがtotal orderの両端に置かれるため、NaNが1つあるだけで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)、正味の変更は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で新たに生まれたのは3つです。
第一に、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に新しいvariantが加わりました。
/** 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の実装は意外にも1行
名前が大げさで、重たい比較エンジンが必要そうに聞こえますが、そうではありません。公式ブログが示した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);
}
ポイントはdoubleToLongBitsではなくdoubleToRawLongBitsだということです。前者はNaNを正規化してしまい、後者はNaNの符号とペイロードのビットをそのまま保存します。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のようなE2Eテストが含まれており、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への言及が0件です。ワイヤフォーマットの定義はあるのに、それを埋めたり読んだりするロジックはない状態です。
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 enumが知っているのはTYPE_DEFINED_ORDER、UNDEFINED、UNKNOWNだけです。つまりRustのparquetクレートの最新リリース(59.1.0、2026-07-07)には入っていません。
まとめると — スペックは6月13日に出て、1か月経った今も、どのメジャー実装のリリースにもこの機能は入っていません。 parquet-javaが最も先行しており次のリリースに載る可能性が高いですが、それもまだ出ていません。
正直なコスト — この修正はタダではない
「古いバグが直った」という話は、普通は純粋な良いニュースに聞こえます。今回はそうではありません。
オプトインである。 parquet-java PR #3393の本文がこう明記しています — "The existing type-defined order remains the default."(既存のtype-defined orderが引き続きデフォルトです。) コードを見ても、PrimitiveTypeはcolumnOrderが指定されなければColumnOrder.typeDefined()に落ちます。カラム単位でスキーマから有効化する必要があります。何もしなければ何も変わりません。
プルーニングがむしろ保守的になる。 同じPR本文の文をそのまま訳せば、"Filtering around NaN values may be more conservative to avoid dropping data that can still match."(マッチしうるデータを落とさないために、NaN周辺のフィルタリングがより保守的に動作することがあります。) これがこのトレードオフの本質です — これまでの「速さ」は一部の誤答の上に成り立っていて、正答を保証した瞬間、一部のクエリはスキップされにくくなります。nan_countが0でなければ、col > 34のようなプレディケートは統計だけではプッシュダウンできません。arrow-rsのスレッドでtustvoldが指摘した通りです。どれだけ遅くなるかの測定値はどのソースにも公開されていないので、ここで数字を作り出すことはしません。
ブラスト半径はスペックの98行よりはるかに大きい。 parquet-java側の40ファイルに何が含まれているかを見ると感覚がつかめます — 統計とカラムインデックスだけでなく、DictionaryValuesWriter、ByteStreamSplitValuesWriter、BloomFilterImpl、DictionaryFilter、StatisticsFilter、さらにはLittleEndianDataOutputStreamにまで手が入っています。PR本文によれば、これはNaNの符号・ペイロードビットをアプリケーションが渡したとおりに保存するためです。裏を返せば、これは「parquet-javaがこれまでNaNのビットパターンを保存していなかった」という意味でもあります。
旧readerとの互換性は理屈と実際で違う。 スペックは「readerがこのunionの値をサポートしない場合、該当カラムのmin/max統計は無視しなければならない」と書いており、arrow-rsの現在のmainも実際にそうなっています — 未知のunion variantをColumnOrder::UNKNOWNに落として統計を無視します、スペックどおりです。ところがarrow-rsのissue #8156で2026年5月19日にemkornfieldが提起した懸念はこうです。旧リリース(彼の表現では"I think prior to arrow 57")は生成されたthriftコードを使っているので、新しいファイルを読むときにパーサーがパニックする可能性があり、バックポートが必要ではないか、というものです。etseidlの答えは、問題はクレートのコードではなくRust thriftジェネレーターにあるというもので、alambは「誰かが実際に必要だという証拠が出たら、そのときバックポートしよう」と答えました。どちらにせよ — スペックがオプショナルフィールドとunion拡張で「安全に」設計されていても、フィールドを追加することと順序unionにvariantを追加することでは、互換性の性質が異なります。前者は旧readerが無視すればそれで済みますが、後者は旧readerがそれをどう扱うかに懸かっています。
で、今何をすべきか
今すぐやるべきこと: あなたのfloatプレディケートがすでに誤っていないか確認する。 これは未来の問題ではなく現在の問題です。NaNが入りうるfloatカラムに>/<プレディケートを投げていて、エンジンが統計ベースのプルーニングを行っているなら、DataFusion #15812と同種の誤答リスクがあります。判断基準はシンプルです — そのカラムに実際にNaNが入りうるか。センサーデータ、欠測をNaNで表す科学計算パイプライン、0除算の結果が流れ込む集計 — こういった箇所が候補です。原理的にNaNが入り得ないカラムなら、この記事全体があなたとは無関係です。
有効化すべきでないとき: 今のところほとんどの場合。 第一の理由は、どのリリースにも入っていないので有効化しようがないことです。第二は相互運用性です。新しいカラム順序で書かれたファイルは、その順序を理解するreaderがなければ統計を活用できず、そんなreaderはまだほとんどありません。今これを有効化して得られるのは「将来有効になるメタデータ」であり、その間に払う代償は旧readerとの未検証な相互作用です。だからこそparquet-testingリポジトリに相互運用テストファイルが入り、parquet-javaがTestInterOpReadFloatingPointNanCountのようなテストを付けたのです — この問題は実装が1つうまくやるだけでは解決しません。
有効化する価値が出てくる時点。 parquet-javaの次のリリースが出て、arrow-rs #9619がdraftを脱してマージされ、Arrow C++がthriftを超えて実際のロジックを付ければ — そのときNaNが実際に入るカラムからカラム単位で有効化するのが正しいやり方です。全面切り替えではなくカラム単位のオプトインという設計そのものが、そう使えという意味です。
それまでの安全なデフォルトは、保守的にいくことです。 tustvoldの提案がいちばん明快です — 実装の状態がこうである以上、floatプレディケートはそもそもプッシュダウンしないのが正しい。遅いのは直せますが、間違っているのは目に見えません。
おわりに
まとめるとこうです。Parquetのmin/max統計という契約は、順序のないNaNという値と、同じ値に対する2つのビットパターンである-0.0/+0.0の前で成り立たなくなっていました。統計からNaNを除くという方便は、readerがその事実を知る手段を与えず、結果として存在する行を見つけられない誤答が生まれていました。ColumnIndexはそもそもスペック内部の矛盾に引っかかっていました。
2.13.0の解法は2つの要素の組み合わせです — nan_countでNaNの存在を明示し、IEEE_754_TOTAL_ORDERで比較を決定論的に固定し、新しい順序を使うならnan_countを義務化することで「新順序=信頼できる統計」というクリーンな信号を作ったのです。3年3か月と2度の頓挫した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 ストレージ完全ガイド 2025: Parquet, ORC, Apache Arrow, Dremel — 分析 DB が 10,000 倍速い理由(関連記事)
현재 단락 (1/106)
Parquetファイルが速い理由の大部分は統計にあります。ロウグループごと、カラムチャンクごと、ページごとに`min`、`max`、`null_count`のような要約を書いておき、クエリエンジンはそ...