HN 日本語サマリー

← 一覧へ戻る
プログラミング

DuckDBでParquetファイルをページングする:file_row_numberかOffsetか?

Paging Through a Parquet File in DuckDB: File_row_number or Offset? (rusty.today)

33 pointsby rustyconover3 コメント

要約

この記事では、DuckDBを使用して巨大なParquetファイルを効率的にページングする方法について、LIMIT/OFFSET句とfile_row_numberオプションを比較検討しています。APIレスポンスサイズ制限やクライアント側のメモリ制約に対応するため、ページングは不可欠ですが、OFFSETはパフォーマンスのボトルネックになり得ます。DuckDBのfile_row_numberオプションは、行グループのスキップにより大幅な高速化を実現し、特に多数の行グループを持つファイルで有効であることが示されています。

全文翻訳

私は巨大なParquetファイルと、その内容を返す必要のあるサービスを持っていました。2000万行を返すことは、APIレスポンスの最大サイズを簡単に超えてしまい、デプロイされている環境にも上限があります。Lambdaは同期リクエストまたはレスポンスに6MB、Cloud Runはストリーミングしない限りHTTP/1レスポンスを32MiBに制限します。プラットフォームの制限がなくても、クライアントは送信されたデータを保持する必要があります。そのため、内容は一度に1ページずつ返され、呼び出し元はすべてを取得するまで要求を続けます。他のすべてを形作るのが、各リクエストが独立していなければならないという点です。ロードバランサーの後ろに複数のワーカーがいる場合、再開できるサーバー側の位置がないため、「次のページ」は毎回、どのワーカーによってもリクエスト自体から再構築可能でなければなりません。それを書く明白な方法はLIMITとOFFSETですが、懸念されるのはOFFSET 19000000が1900万行を数えてページを見つけなければならないことで、これはファイル全体を二次関数的に通過することになるでしょう。DuckDBのread_parquetにはfile_row_numberオプションがあり、各行の物理的な位置を返してくれるため、行の範囲でフィルタリングできます。クライアントは依然として小さなものを返し、サーバーはそれからページを再構築するという契約は同じですが、何も数えられません。OFFSETがあなたのページの前にすべてを再読み込みすると証明することを期待していました。それはしません、そして問題となったのはスピードでは全くありませんでした。短いバージョン2000万行のファイルと163個の行グループで、行範囲バージョンはファイル全体でOFFSETよりも2.53倍速く完了しました。これは37回の実行中37回で確認されました。 -- LIMIT $n OFFSET $offset の代わりに SELECT id, k, name, category, value, ts FROM read_parquet($path, file_row_number => true) WHERE file_row_number >= $lo AND file_row_number < $hi この行範囲は特定のことを行っています。Parquetファイルは行グループと呼ばれるブロックのシーケンスとして保存されており、DuckDBはファイルのフッターから、指定された行番号の範囲がどのブロックに含まれるかを把握できます。あなたのページの前にあるすべては、解凍されることなくスキップされます。 必要な行グループに直接スキップ WHERE file_row_number >= $lo AND file_row_number < $hi は、前のブロックに触れることなく、あなたの行が含まれるブロックに到達します。だからこそ、ページのコストはページを深く掘り下げるにつれて増加しないのです。 ただし、この数値に基づいて作業を進める前に、2つの注意点があります。それは、ファイルに多くの行グループがあるかどうかに完全に依存します。スピードも、2つの議論のうち弱い方です。より強い議論はパフォーマンスの問題よりも悪いです。あなたのファイルにはいくつの行グループがありますか?DuckDBは一度に1つの行グループずつ作業をスキップします。単一の巨大な行グループとして書き込まれたParquetファイルにはスキップするものが何もないため、これのどれも役に立ちません。計画を立てる前に、parquet_metadataを使用して確認してください。 SELECT count(DISTINCT row_group_id) AS row_groups, min(row_group_num_rows) AS smallest, max(row_group_num_rows) AS largest FROM parquet_metadata('yourfile.parquet'); 1が返ってきたら、読むのをやめても構いません。これがどれほど重要かを見るために、同じ200万行を2回書き込みました。スキーマとデータは同一で、ROW_GROUP_SIZEだけを変更しました。 より多くの行グループ、より大きな勝利 同じ200万行が2つの方法で書き込まれ、参考のために大きなファイルも含まれています。点線は同点です。 1行グループ 2M行 1.25倍 17行グループ 2M行 1.75倍 163行グループ 20M行 2.53倍 1対17のペアは正直な比較です。同一のデータで、ROW_GROUP_SIZEのみが変更されています。163のバーはより大きなファイルからのもので、それを「傾向は続いている」と読み取ってください。1行グループでは勝利は1.25倍に低下します。それは消えませんが、DuckDBは行グループ内で2,048行のバッチも破棄するため、狭いウィンドウでもグループ全体よりも少なく読み取ります。あなたは利点のほとんどを失うだけです。そのチャートでより興味深い数値は、比率ではなくクロックです。同じ作業が0.49秒から2.12秒に移行し、両方の方法で3〜4倍遅くなります。書き込みを制御できる場合は、他のものを最適化する前にそれを修正してください。DuckDB自身のライターはデフォルトで1グループあたり122,880行ですが、これは問題ありません。他の多くのツールはそうではなく、DuckDBのファイル形式パフォーマンスガイドには、サイズ選択に関する独自の注記があります。1つの巨大な行グループは、どのクエリを書いても悪い考えです。 DuckDBがOFFSETで実際に行っていること ここで私の二次関数の仮定が崩れました。ページングクエリでEXPLAINを実行すると、予期しない結果が得られます。 HASH_JOIN (SEMI) on file_index = file_index and file_row_number = file_row_number ├─ READ_PARQUET id, k, name, ... └─ STREAMING_LIMIT └─ READ_PARQUET DuckDBはOFFSETを行番号ルックアップに書き換え、データをそれと半結合します。それはあなたの代わりにfile_row_numberにアクセスし、手書きバージョンと同じように行グループをスキップします。OFFSETによるページングは二次関数的ではありません。あなたは余分なパスの代償を払いますが、それはあなたが要求した行番号がどれであるかを把握するためのもので、そのパスはページングが深くなるにつれてより高価になります。あなたがそれをタイプしたかどうかにかかわらず、あなたはすでにfile_row_numberを使用しています。唯一の問題は、あなたがそれを制御するかどうかです。この測定の私の最初の試みは二次関数のモデルを仮定し、40ページを時間計測し、残りを外挿するために傾きを適合させました。傾きは負になりました。負の傾きはモデルが壊れていることを示しており、機械ではないので、外挿を捨てて163ページすべてを直接測定しました。書き換えには崖があります。それはLIMITが1,000,000行以下の場合にのみ発生します。それはファイルの割合ではなく、フラットな行数であり、500k行のファイルと2000万行のファイルで同一です。1,000,000行を要求すると書き換えが得られます。1,000,001行を要求すると、それはなくなり、ページの前にあるすべてを解凍することになります。WHERE句を追加すると、これも無効になります。100万行のページでは、2つのアプローチ間のギャップは約5〜6倍に広がりました。その数値はオプティマイザーの定数LIMIT_MAX_VALですが、それがすべてではありません。late_materialization_max_rowsという設定もあり、実効的なカットオフは、2つのうち大きい方です。 late_materialization_max_rows 書き換えは最大 50 (デフォルト) 1,000,000行 200,000 1,000,000行 2,000,000 2,000,000行 したがって、100万未満にそれを上げても効果はなく、100万を超えて上げると崖が移動します。もしあなたが本当に100万ページ以上を必要とし、OFFSETを使い続けたいのであれば、それがノブです。私はまだ行範囲を書き、クエリテキストからは見えないオプティマイザーの書き換えに依存しない方が良いでしょう。また、ストップウォッチから推測するのではなく、行グループのスキップを証明したかったので、行グループ0の中央に128バイトのゴミを書き込んでデコード不能にしました。ファイル全体のスキャンは失敗し、行グループ0内のページも失敗しました。行グループ100を越えるページはバイト単位で正確に返されました。壊れたバイトには触れませんでした。 心配すべき部分は、LIMIT/OFFSETにはORDER BYがないため、どの行を取得するかについて何の約束もしていません。DuckDBはデフォルトで挿入順序を保持するため、今日は機能しています。それは文書化されたパフォーマンスノブであり、人々はそれをオフにします。そこで、私はそれをオフにし、複数のスレッドを実行し、ファイル全体をページングしました。両方の実行は正確に20,000,000行を返しました。 実行 行数不足 行数重複 最大コピー数 16,131,712 4,943,872 526,408,192 5,221,632 5 一部の行は決して現れず、他の行は5回現れ、毎回異なった結果になります。エラーは発生しません。行数は完璧です。約30%のデータがありません。したがって、行数を数えてエクスポートを検証しないでください。カウントはこれを見ることができません。ドロップと重複が相殺されるためです。600万行が行方不明になり、500万行が2回送信され、合計は正確に20,000,000になります。代わりにハッシュを使用してください。crypto拡張機能にはcrypto_hash_aggがあり、結果セット全体を1つの値にダイジェストします。 INSTALL crypto FROM community; LOAD crypto; SELECT crypto_hash_agg( 'blake3', hash((id, k, name, ts)) ORDER BY hash((id, k, name, ts))) FROM read_