プログラミング
DuckDB – ラップトップのためのデータパワーツール、Clojure対応版が登場 (2023年)
DuckDB – Data power tools for your laptop, now in Clojure (2023) (techascent.com)
要約
この記事は、インメモリ列指向データ処理プラットフォームであるtech.ml.dataset (TMD) が、ラップトップで利用可能な強力なデータ処理ツールであるDuckDBとClojureで連携できるようになったことを紹介しています。これにより、メモリに収まらない大規模データセットに対しても、DuckDBのベクトル化されたSQL実行エンジンを活用し、効率的なデータ分析が可能になります。
全文翻訳
ブログホームお問い合わせ2023-09-02DuckDB - ラップトップのためのデータパワーツール、Clojure対応版が登場
必要性の確立
私たちのインメモリ列指向データ処理プラットフォーム、tech.ml.dataset (TMD) は、機能的データサイエンスの未来を牽引します。データがメモリに収まらないほど大きくなった場合、TMDはデータのサンプルを操作したり、作業環境の制約に合わせて関連するサブセットをフィルタリングしたりすることで継続できます。さらに、nippy、arrow、またはparquetを使用して、小規模から大規模までデータの永続化を実現できます。
データが大きくなり、例えば100GB程度の.csvファイルのセットで、それらにリレーショナルな側面がある場合、現在のツールでは扱いにくくなることがあります。非機能的なsparkyクラスターの混乱に関与したくなる誘惑に駆られます。もちろん、ある程度のトランザクションインタラクションとシンプルなディスクIOモデルを維持することは依然として非常に望ましいです。ローカルディスクは十分に大きく、ローカルチップは十分に高速なので、性急なことは何もする必要はありません。
リレーショナルデータベースは、メモリ外ストレージと高速なリレーショナルクエリにうまく適応していますが、機能プログラミングの利点とTMDの列指向処理モデルを諦めることなく、これをどのように活用できるでしょうか?JDBCは、Postgresと共に、この質問に対する良い最初の答えを提供しますが、JDBCを介してデータを取得しTMDに入れるために、非効率的でバッチ処理されないAPIを介した完全な行から列への変換を実行するのはイライラします。
新たな挑戦者が現れる
DuckDBは2021年5月のgithubイシューで登場し、tmduckenは同年12月までにそのCバインディングと最小限の統合が行われました。そのバージョンでは、すべてのクエリ結果が一度に返されたため、メモリに収まる必要がありました。また、DuckDBの初期の頃は、特定の高性能な追加または挿入システムがなかったため、IOがパフォーマンスの可能性を制限しており、PostgresはTMDの補助処理システムとして存続していました。それ以来、多くのことが変わりました。
過去2年間で、DuckDBは大きく改善されました。特に重要なのは、Cインターフェースが現在、挿入とクエリの両方に対してバッチシステムを提供しており、これにより非常に大きな結合の処理が可能になったことです(後述)。これらの改善された機能は、TMDを介してClojureで活用できるようになり、DuckDBの最先端のベクトル化SQL実行エンジンにアクセスできるようになりました。そしてそれは素晴らしいです。
実際の使用
前回の投稿に基づいて、3年間のトランザクションデータを記録した50ギガバイトの.csvファイルがあり、合計4億行あります:
$ ll -h data.csv
-rw-rw-r-- 1 harold harold 50G Aug 8 09:49 data.csv
これをDuckDBにロードするのは驚くほど簡単です - ただし、2分待つ必要があります:
$ time duckdb data.ddb 'CREATE TABLE data AS FROM "data.csv";'
100% ▕████████████████████████████████████████████████████████████▏
real 1m50.091s
user 21m42.693s
sys 0m57.887s
$ ll -h data.ddb
-rw-rw-r-- 1 harold harold 18G Sep 6 10:57 data.ddb
これにより、ファイルは18GBに削減されました。これには、DuckDBによって自動的に作成されたすべてのインデックス(!)が含まれています。
データはそこにあります:
$ duckdb data.ddb
v0.8.1 6536a77232
".help" で使用方法のヒントを入力してください。
D SELECT COUNT(*) AS n FROM data;
┌───────────┐
│ n │
│ int64 │
├───────────┤
│ 400000000 │
└───────────┘
D DESCRIBE TABLE data;
┌────────────────┬─────────────┬─────────┬─────────┬─────────┬─────────┐
│ column_name │ column_type │ null │ key │ default │ extra │
│ varchar │ varchar │ varchar │ varchar │ varchar │ varchar │
├────────────────┼─────────────┼─────────┼─────────┼─────────┼─────────┤
│ customer-id │ VARCHAR │ YES │ │ │ │
│ day │ BIGINT │ YES │ │ │ │
│ inst │ TIMESTAMP │ YES │ │ │ │
│ month │ BIGINT │ YES │ │ │ │
│ brand │ VARCHAR │ YES │ │ │ │
│ style │ VARCHAR │ YES │ │ │ │
│ sku │ VARCHAR │ YES │ │ │ │
│ year │ BIGINT │ YES │ │ │ │
│ transaction-id │ VARCHAR │ YES │ │ │ │
│ quantity │ BIGINT │ YES │ │ │ │
│ price │ DOUBLE │ YES │ │ │ │
└────────────────┴─────────────┴─────────┴─────────┴─────────┴─────────┘
│ 11 rows 6 columns │
└──────────────────────────────────────────────────────────────────────┘
これをClojureからTMDを介してアクセスするのも簡単です:
user> (require '[tmducken.duckdb :as duckdb])
nil
user> (require '[tech.v3.dataset :as ds])
nil
user> (duckdb/initialize!)
Sep 06, 2023 11:00:12 AM clojure.tools.logging$eval7454$fn__7457 invoke INFO: Attempting to load duckdb from "./binaries/libduckdb.so"
true
user> (def db (duckdb/open-db "data.ddb"))
#'user/db
user> (def conn (duckdb/connect db))
#'user/conn
user> (time (duckdb/sql->dataset conn "SELECT COUNT(*) AS n FROM data"))
"Elapsed time: 10.305756 msecs"
:_unnamed [1 1]:
| n |
|----------:|
| 400000000 |
さて、管理職から、各SKUの色情報をキャプチャする別のデータセットがデータベースにも必要であると通知されたと想像してください:
user> (-> (let [colors ["red" "green" "blue" "yellow" "purple" "black" "white"]] (->> (for [brand (range 100) style (range 10) item (range 10)] (let [sku (format "sku-%s-%s-%s" brand style item) n (rand-int 8)] (for [color (take n (shuffle colors))] {"sku" sku "color" color}))) (apply concat))) (ds/->dataset {:dataset-name "colors"}))
colors [35179 2]:
| sku | color |
|------------|--------|
| sku-0-0-0 | red |
| sku-0-0-0 | blue |
| sku-0-0-0 | white |
| sku-0-0-0 | yellow |
| sku-0-0-0 | black |
| sku-0-0-0 | green |
| sku-0-0-1 | black |
| sku-0-0-1 | yellow |
| sku-0-0-1 | blue |
| sku-0-0-1 | purple |
| ... | ... |
| sku-99-9-8 | yellow |
| sku-99-9-8 | purple |
| sku-99-9-8 | black |
| sku-99-9-8 | red |
| sku-99-9-8 | white |
| sku-99-9-8 | blue |
| sku-99-9-8 | green |
| sku-99-9-9 | purple |
| sku-99-9-9 | blue |
| sku-99-9-9 | black |
| sku-99-9-9 | green |
user> (duckdb/create-table! conn *1)
"colors"
user> (duckdb/insert-dataset! conn *2)
35179
どこに向かっているかはわかっています。SKUごとに約3.51色を持つこのデータと、SKUを持つ4億件のトランザクションを結合する必要があります。
幸いなことに、彼らの最初の要求は比較的単純です。「2021年3月に各色で何個の商品が販売されたか?」そして「いつわかるか?」と彼らは尋ねます。
;; まず、できることとして、ラップトップ上で14億行を2.5秒で結合します...
user> (time (duckdb/sql->dataset conn "SELECT COUNT(*) FROM data INNER JOIN colors ON data.sku = colors.sku"))
"Elapsed time: 2486.620275 msecs"
:_unnamed [1 1]:
| count_star() |
|-------------:|
| 1416737859 |
;; 次に、彼らの質問に答えます...
user> (time (duckdb/sql->dataset conn "SELECT color, COUNT(*) FROM data INNER JOIN colors ON data.sku = colors.sku WHERE data.year='2021' AND data.month='3' GROUP BY color"))
"Elapsed time: 1077.723309 msecs"
:_unnamed [7 2]:
| color | count_star() |
|--------|-------------:|
| red | 5714223 |
| yellow | 5652010 |
| black | 5720753 |
| blue | 5750846 |
| white | 5689916 |
| green | 5816652 |
| purple | 5671959 |
1秒後にはわかります。これが答えです。
おそらく次の質問はSQLにはあまり適しておらず、ClojureでTMDを使って処理する方が良いかもしれません。この例では、ボスのお気に入りのSKUのすべてのトランザクションを、時系列順(1秒ごと)にソートして削減しています:
user> (time (reduce (fn [eax ds] (conj eax (ds/row-count ds))) [] (duckdb/sql->datasets conn "SELECT * FROM data WHERE sku='sku-50-5-5' ORDER BY inst")))
"Elapsed time: 1067.480751 msecs"
[2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 733]
もちろん、この削減関数は些細なものですが、ポイントは証明されています - 実現されたデータセットは、メモリが尽きることがないメカニズムを通じて、削減によって任意の処理に利用可能です。
DuckDBはゼロコピークエリパスウェイもサポートしています。クエリ結果のどのチャンクも削減関数からエスケープする必要がない場合、マシンはより少ない作業で済む可能性があります。以下の例では、これは有効になっており、{:reduce-type :zero-copy-imm}オプションを渡すことでアクセスされます。
このように処理を表現できる場合、これは理論上利用可能な最もメモリ使用量の少ないパスウェイです。
user> (time (let [sql "SELECT * FROM data WHERE sku='sku-50-5-5' ORDER BY inst" options {:reduce-type :zero-copy-imm}] (reduce (fn [eax zc-ds] (conj eax (