プログラミング
DuckDBにおける非同期I/O:処理、スレッド、処理
Asynchronous I/O in DuckDB: Work, Thread, Work (duckdb.org)
要約
DuckDB v2.0(2026年秋リリース予定)では、ParquetおよびCSVファイルの非同期読み込みがサポートされます。これにより、特にEC2/S3のようなコンピューティングとストレージが分離された環境で、同期I/Oが帯域幅を飽和させない場合にクエリ速度が大幅に向上する可能性があります。非同期I/Oは、ワーカー・スレッドをブロックせずにI/O操作を開始できるようにすることで、リモートストレージからのデータ取得におけるレイテンシを隠蔽し、CPU利用率を高めます。この機能は、Parquetおよび未圧縮のUTF-8 CSVファイルで実装されており、将来的には他のフォーマットにも拡張される予定です。
全文翻訳
非同期I/O in DuckDB: Work, Thread, Work
Pedro Holanda
2026-07-31 · 21 min
TL;DR: v2.0(2026年秋リリース予定)から、DuckDBはParquetおよびCSVファイルの非同期読み込みをサポートします。これは、同期I/Oが利用可能な帯域幅を飽和させない場合に、クエリを大幅に高速化できます。これは、EC2/S3のコンピューティング・ストレージ設定で典型的な状況です。
データベースシステムでクエリ演算子がどれほど速くても、データを素早く取り込めなければ意味がありません。DuckDBの歴史のほとんどにおいて、この問題は早期にデータをプルーニングすることで回避されてきました。フィルタとプロジェクションをプッシュダウンすることで、実際に必要なものだけを読み込むことを保証できました。
これは、DuckDBが主にローカルで実行され、主なユースケースがマシンのSSDから直接データをクエリするためのクイックドロー・データベースエンジンであったため、特にうまく機能しました。データを複数のパーティション(Parquetファイルの場合は行グループ、CSVファイルの場合は固定サイズのバッファなど)に分割し、低レイテンシと高帯域幅でロードできました。
その結果、主なボトルネックは他の場所にありました:サブクエリ、結合、集計などです。同期アクセスはこのユースケースに完全に適合していたため、実際のデータアクセスパスはあまり注目されていませんでした。
いつものように、状況は変化しました。DuckDBのアーキテクチャが、リモートに保存された大規模データセット(データレイクなど、DuckLakeなど)をクエリするのに適していることに気づきました。
今年5月からは、Quackプロトコルを使用してDuckDBをサーバーとして実行することさえ可能です。
したがって、ローカルSSD上にあるデータファイルという元の期待は、常に当てはまるわけではなくなりました。
これらの変更の実際的な意味は、現在の多くのDuckDBセットアップでは、ファイルをリモートストレージから実際に処理するマシンに転送する必要があるということです。
例えば、データレイクの場合、典型的なセットアップは、データをS3のようなブロブストレージに保存し、同じリージョン内のEC2マシンで処理することです。
このセットアップでは、レイテンシと帯域幅がはるかに重要な役割を果たします。
利用可能なネットワーク帯域幅を使用するのに十分な同時リクエストを発行できない場合、パフォーマンスは劇的に低下する可能性があり、スレッドはデータを処理する代わりにリモート読み取りを待って多くの時間を費やすことになります。
例として、リモートParquetファイルに対する単純なクエリを考えてみましょう。
簡単にするために、単一のスレッドが実行されていると仮定します。
FROM read_parquet('s3://bucket/file.parquet');
Parquetスキャンは行グループベースのジョブに分割されており、各ジョブにはバイト範囲リクエストを発行する複数のフェッチタスクが含まれています。
同期I/Oでは、ワーカー・スレッドは、デコード、集計など、実際の作業を実行する前に、データがマシンに到着するのを待ってブロックされます。
以下の図の視覚的な描写を見ると、読み取りが完了するのを待っている間、スレッドはどの作業も実行できないことがわかります。
同期読み取り
これを解決するために、DuckDBで非同期I/Oパイプラインを実装してきました。
これらは現在、Parquetおよび未圧縮の、シーク可能なUTF-8 CSVファイルで実装されており、DuckDBネイティブフォーマットやJSONなどの他のフォーマットのサポートはまだこれからです。
このブログ投稿の残りの部分では、DuckDBで非同期I/Oがどのように実装されているかを簡単に説明し、ParquetファイルとCSVファイルの両方のベンチマークを提供します。
非同期I/Oを今すぐ試したい場合は、DuckDBのv2.0.0-devプレビュービルドを使用できます。
非同期I/Oは、次のメジャーバージョンであるv2.0(秋リリース)からデフォルトで使用されます。
非同期I/O
非同期I/Oの概念的なアイデアは非常にシンプルです:要求したワーカー・スレッドをブロックせずにI/O操作を開始できる必要があります。
私たちのParquetの例に適用すると、同じ図は次のようになります。
非同期読み取り
この例では、2つのASYNCスレッドと1つの通常のワーカー・スレッドがあります。
ASYNCスレッドはフェッチタスクをインフライト状態に保ち、ワーカー・スレッドはデータをデコードします。
初期ウォームアップ中、スキャンタスクは一時停止し、ワーカー・スレッドは他のパイプラインタスクを実行するために解放されます。
最初のジョブが準備できたら、フェッチとデコードをオーバーラップさせることができます。
DuckDBでは、同様のものを実装しました。
2つの異なるスレッドプールがあります。
REGULAR – このプールにはワーカー・スレッド(デフォルト:利用可能なCPUスレッドごとに1つ)が含まれています。
これらは、デコード、結合、集計などの実際の作業を行うスレッドです。
これらは通常の作業を優先しますが、アイドル時にはI/Oタスクを実行することもできます。
ASYNC – 非同期タスク、主にブロッキングI/Oを目的としたスレッドのプール。
これらの2つの異なるプールがある主な理由は、リモートI/Oの場合、これらのスレッドはHTTP応答などを待って、ほとんどすべての時間をブロックされた状態で費やす可能性があり、CPU使用率が非常に低くなるためです。
そのため、システムスレッドよりもはるかに多くのASYNCワーカーがあり、デフォルト設定はシステムスレッドの4倍で、合計は256に制限されています。
できるだけ多くのASYNCスレッドを常にビジー状態に保つことが非常に重要です。
それを確実にするために、オンデマンドで読み取るのではなく、リードヘッド戦略を実装しています。
これは、通常のワーカー・スレッドが現在必要としているよりもさらに先の作業のためのフェッチタスクをスケジュールすることを意味します。
リードヘッドはメモリを保持することでスループットを購入します。
デコードが遅く、ネットワークが速い場合、プリフェッチされたデータが蓄積し、メモリ不足の問題を引き起こす可能性があります。
これを軽減するために、非同期メモリガバナンスも実装しました。
リードヘッドとメモリガバナンスの両方について、次のセクションで詳しく説明します。
リードヘッドキュー
リードヘッドのアイデアも同様に簡単です。
通常のワーカーがデータを必要とする正確な瞬間に読み取りを開始するのではなく、さらに先の作業のためのフェッチタスクをスケジュールします。
通常のワーカーが現在のジョブをデコードしている間、ASYNCスレッドはすでに次のジョブのデータを取得しています。
目標は、リモートストレージのレイテンシを隠蔽するのに十分なフェッチタスクをインフライト状態に保つことです。
ジョブは独立してスケジュールおよび処理できる作業単位であり、基盤となるファイルフォーマットによって異なる場合があります。
Parquetファイルの場合、ジョブは1つのファイルの1つの行グループです。
CSVファイルの場合、ジョブはファイル内の固定バイト範囲を一般的にカバーするスキャン境界です。
Parquetジョブは、クエリプロジェクション、フィルタプッシュダウン、物理列の場所、および近くのバイト範囲を結合できるかどうかに応じて、複数のフェッチタスクに分解される場合があります。
以下の図の2つのフェッチタスクは例示的なものであり、その正確なグループ化とサイズはファイルとクエリに依存します。
CSVファイルの場合、Parquetファイルほど詳細な情報は得られません。
ジョブのフェッチタスクは、開始バッファがメモリにない場合はそれをロードし、スキャン境界がそのバッファの終わりに達したときに(例えば、2つのバッファにまたがる行を処理するため)、次のバッファもロードします。
ジョブ
キューを埋めるのに専用のプロデューサースレッドは必要ありません。
スキャン作業を探しに来た通常のワーカーは、まず許可される範囲でキューを補充します。
制限は、ユーザー指定のスロット数か、メモリ予算のいずれかによって決まります。
スペースがあれば、ジョブとそのフェッチタスクが作成されます。
フェッチタスクはすぐにASYNCプールにスケジュールされ、ジョブはバッチ順でリードヘッドキューに入力されます。
ASYNCスレッドは、ジョブキューのクレーム順序とは独立して個々のフェッチタスクを実行します。
同じジョブのフェッチタスクは並行して実行できますが、特定のASYNCスレッドへの割り当ては保証されません。
すべてのフェッチタスクはジョブを共有し、カウントダウンがあり、カウントダウンをゼロにするフェッチタスクがジョブのI/Oを完了させます。
ワーカー・スレッドはキュー内の最も古いジョブをクレームし、カウントダウンをチェックします。
I/Oが完了していれば、ワーカーはジョブのデコードを開始します。
そうでない場合は、スキャンタスクを一時停止し、他のパイプラインタスクを実行するために解放されます。
最後のフェッチタスクがスキャンタスクのブロックを解除し、それは任意の通常のワーカーで再開される可能性があります。
ジョブをクレームすると、キューのスロットがすぐに解放され、スキャン作業を探している通常のワーカーがレプリケーションを生成できるようになります。