Melih Birim

csvqlは8GBのファイルでDuckDBの約2.8倍の速度で生のCSVをクエリし、使用メモリは約6分の1、追加のディスク容量はゼロ — そのベンチマークにより、発見できなかった2つの実際のバグも明らかになった。

セットアップ:同じマシンで同じ生のCSVをクエリする

DuckDBは自社のNYC-taxi CSVベンチマークを公開しています。これが完璧な基準になります。自社データセットに自社のクエリを実行しているため、競合他社を誤設定したと非難されることはありません。

データセットはDuckDB自身のblobストレージ(blobs.duckdb.org/data/nyc-taxi-dataset)にあります。各ファイルは2000万行、51列、非圧縮で約8GBです。4つのクエリは定番の「Billion Taxi Rides」集計 — cab type、passenger count、year、丸められたtrip distanceに対するGROUP BYのカウントと平均です。

このようなベンチマークで成否を分ける唯一のルール:両エンジンは同じ作業を行わなければなりません。DuckDBの見出し数字には2つの種類があります — 「with storage」(CSVをDuckDBのネイティブなカラム形式にロードした後)と「without storage」(毎回生のCSVをクエリ)です。csvqlは常に生のCSVを直接クエリし、ネイティブストアはありません。公平な比較は両エンジンが毎回生のCSVをコールドで読み込むことです:

csvql:  csvql "SELECT ... FROM 'trips.csv' ..."
duckdb: SELECT ... FROM read_csv_auto('trips.csv') ...   -- NOT a preloaded table

Enter fullscreen mode Exit fullscreen mode

生CSV上のcsvqlと事前ロード済みのストア上のDuckDBを比較するのは不誠実であり、誰がスクリプトを開いてもそう言うでしょう。直接対直接のみが精査に耐えられます。

マシン:Apple Silicon、macOS。DuckDB:v1.4.2。方法:クエリごとにbest-of-5回実行、OSページキャッシュをウォーム(両エンジン同等)。


速度:8GBで約2.8倍

クエリ csvql DuckDB Speedup
Q01 COUNT(*) GROUP BY cab_type 1.29 s 3.55 s 2.8x
Q02 AVG(total_amount) GROUP BY passenger_count 1.41 s 3.81 s 2.7x
Q03 COUNT(*) GROUP BY passenger_count, year 1.36 s 4.01 s 2.9x
Q04 GROUP BY passenger_count, year, ROUND(distance) 1.38 s 3.99 s 2.9x

クエリレイテンシ(小さいほど良い):

        0s        1s        2s        3s        4s
Q01 csvql  ██████▌ 1.29
    duck   ██████████████████ 3.55
Q02 csvql  ███████ 1.41
    duck   ███████████████████ 3.81
Q03 csvql  ██████▊ 1.36
    duck   ████████████████████ 4.01
Q04 csvql  ██████▉ 1.38
    duck   ████████████████████ 3.99

Enter fullscreen mode Exit fullscreen mode

ここで反直感的な部分があります:ファイルが大きくなるほど差は縮まる。417MB / 100万行のサンプルではcsvqlが約10倍高速です。8GBでは約2.8倍です。理由は、小さいファイルではDuckDBのプロセス起動とCSVリーダー初期化がウォールクロックを支配し、csvqlの軽量起動が大きく勝つためです。大きいファイルでは実際のパースと集計スループットが支配的になり、DuckDBの並列CSVリーダーが追いつき、csvqlは安定して約2.8倍に落ち着きます。どちらの数字も正しく、誠実なのはファイルサイズを添付した曲線全体を公開することで、10xだけを恣意的に選ぶことではありません。


メモリ:約6分の1

同じ8GBファイルでのクエリあたりのピークメモリ使用量:

クエリ csvql DuckDB
Q01 29 MB 178 MB
Q02 30 MB 210 MB
Q03 34 MB 208 MB
Q04 38 MB 219 MB
Peak memory (MB), 8 GB file
csvql   █▍                          ~30 MB
duckdb  ██████████████████████████  ~200 MB

Enter fullscreen mode Exit fullscreen mode

csvqlはメモリマップスキャンでファイルをストリーミングし、グループバイハッシュテーブルだけをメモリに保持します — 数十グループのみです。8GBのうちごくわずかしか一度に保持しません。だから30MBのフットプリントで8GBのファイルを処理できるのです。


ストレージ:実際に重要な数字

ここで「生CSV、ストアなし」が公平性の注釈ではなく、本質になるのです。

8GBファイルに対するこれらのクエリに必要な追加ディスク:

csvql   0 bytes        — CSVをその場でクエリ、インジェスト不要
duckdb  2.1 GB + 21.7s — 高速パス用にネイティブストアを構築する必要あり

Enter fullscreen mode Exit fullscreen mode

DuckDBはこれらの生CSVの数字よりも高速になることがあります — ただしCSVを2.1GBのネイティブストアに最初にインジェストする「with storage」パス経由のみで、単一の8GBファイルに対して約22秒の一回限りのコスト(マルチファイルデータセット全体では数分)です。csvqlはその数字を達成するのに追加ディスクゼロ、インジェストゼロを必要としません。5分前にディスクに着いたCSVに対するアドホッククエリにとって、「インジェストなし」は「今すぐ答えが出る」か「コーヒーブレイク後に答えが出る」かの違いです。

正直なまとめ:同じ答え、約2.8倍高速、約6分の1のメモリ、追加ディスクゼロ、インジェストゼロ。


実行できなかったテスト — そしてそれが正直な部分である理由

csvqlの数字をDuckDBの公開済み数値(ブログではQ01–Q04をストレージなしで2.45 / 3.89 / 5.21 / 11.2 sと報告)と並べなかったことに気づくでしょう。csvqlの約1.3 sをQ04の11.2 sと並べると魅力的な8倍に見えますが、それは無意味です。

2つの理由があります:

  1. データセットサイズが異なる。DuckDBの公開クエリ数字は完全な約11億行のデータセット(65ファイルすべて、非圧縮CSVで約500GB)に対するものです。私の数字は2000万行の単一ファイルに対するものです。これは行数で55倍の差があります。同じテーブルに並べることは、2000万行上のcsvqlと11億行上のDuckDBを比較することになり、ベンチマークの八百長に相当します。(Q04の自社数字を見ればサイズ差がわかります:11億行で11.2 s、私のローカルDuckDBが2000万行で約4 s。)

  2. ハードウェアが異なる。彼らの数字はM1 Proから得られたもので、私のは別のApple Siliconマシンからです。他のシリコンで測定された競合他社の数字を借りて自分の数字で割るのはベンチマークではなく、願望です。

DuckDBの公開数字を合法的に引用するには、csvqlは同一の約11億行のデータセットを実行する必要があり、それはディスク上に非圧縮CSV約500GBを意味します。私のマシンには275GBの空きがあります。物理的に入りません。だからハードウェアとデータサイズをまたいだ数字を借りる代わりに、同じ2000万行を同じマシンで同じOSキャッシュ状態で両エンジンを実行しました。それにより、クロスハードウェア比較では制御できないすべてを制御できます。より小さく派手さのない結果ですが、はるかに信頼できるものです。

1TBのディスクを持つ誰かが完全な500GBを実行し、csvqlをDuckDBの正確な公開テーブルと比較して報告したい場合、スクリプトは1つのフラグで対応できます(bench_taxi.sh 65)。ぜひ見たいです。


どんでん返し:ベンチマークで自分のエンジンに2つのバグが見つかった

私は速度を測定するためにベンチマークを構築しました。結果として、それはcsvqlがこれまで持った最高のテストスイートになりました。なぜなら、実データに対する実クエリの実行が即座に2つを破壊したからです:

バグ #1 — GROUP BY ROUND(trip_distance)ColumnNotFoundでクラッシュした。文字列を生成する関数(STRFTIME)でグループ化するのは動作しましたが、数値を生成する関数(ROUND)でのグループ化は動作しませんでした — グループキーリゾルバーがそのケースを持っていなかったのです。Q04はケースを追加するまで全く実行できませんでした。

バグ #2 — ORDER BY COUNT(*) DESCが静かにソートされていない行を返した。エラーもクラッシュもありません — ただ間違った出力でした。GROUP BYコードパスは内部ハッシュキーで結果をソートし、ORDER BY句を完全に無視していました。これは最も恐ろしいクラスのバグです:自信たっぷりで間違った答えを返す種類です。タイミングだけをチェックするベンチマークはこれを見逃していたでしょう。出力をDuckDBと比較したことで、1回の差分で発見できました。

どちらも修正済みで、それぞれに回帰テストがあり、4つの定番クエリはDuckDBとバイト単位で同一の結果を生成します(11.0の浮動小数点フォーマットを除く)。教訓:信頼できるオラクルに対してチェックするベンチマークは、ストップウォッチを身に着けた正しさのテストです。csvqlを自分自身に対してしか実行していなければ、両方のバグはまだ残っていたでしょう。


自分で再現する

すべてがリポジトリにあります。500GBは必要ありません — サンプルモードは約417MBをプルし、数秒で実行します:

git clone https://github.com/melihbirim/csvql
cd csvql && zig build -Doptimize=ReleaseFast

# quick: 100万行サンプル(約417 MB)
./bench/bench_taxi.sh --sample

# full: 2000万行(DuckDBのblobsから約8 GBダウンロード)
./bench/bench_taxi.sh 1

# speedの代わりにmemory / CPU / storage
./bench/bench_taxi.sh --resources 1

Enter fullscreen mode Exit fullscreen mode

スクリプトはDuckDB自身のデータセットをダウンロードし、生CSV上で両エンジンを実行し、上記のテーブルを出力します。約130行のbashです — 信頼する前に読んでください。


まとめ

  • 直接対直接でベンチマークするか、しないか。この演習全体で最も重要な行は、生CSVのcsvqlを事前ロード済みのDuckDBと比較することを拒否したことでした。
  • 曲線全体を公開する。400MBで約10倍、8GBで約2.8倍、どちらも正しい。片方を隠して他方をヘッドラインにするのが、ベンチマークが悪評を獲得する方法です。
  • 興味深い数字は常に速度とは限らない。約6分の1のメモリとインジェストゼロは、2秒を削ることよりもアドホックなCSV作業にとって大きな問題です。
  • ベンチマークを信頼できるオラクルに向ける。それは書くのが面倒だった正しさのテストを兼ねます。出荷していたであろう2つのバグを発見しました。

csvqlはオープンソース(Zig、単一の静的バイナリ、github.com/melihbirim/csvql)です。完全なデータセットを実行したら、結果を教えてください。

数字:Apple Silicon M2Pro、macOS、DuckDB v1.4.2、best-of-5、ウォームキャッシュ。データセットとクエリ:DuckDBのNYC-taxiベンチマーク