これはR Advent Calendar 2021の24日目の記事です(ということにさっきしました)(遅刻)。
https://qiita.com/advent-calendar/2021/rlang
Apache ArrowとDuckDB
arrow
パッケージ
Rのarrowパッケージ(バージョン6.0.0)に遂にjoin系の機能やdplyr::group_by()
への対応が入りいよいよ日常的に使っていきたいと思ったのでどのくらい早いのか計ってみようと思います。
私はApache Arrowを最近知り、凄いプロジェクトがあるものだと衝撃を受けたクチです。
https://github.com/apache/arrow
Apache Arrowについてはクリアコード社のサイトにて積極的に日本語の情報発信されているのでまずそちらをご覧いただくのも良いと思います。
- Apache Arrowのご紹介
https://www.clear-code.com/services/apache-arrow.html
Rユーザーがarrowを使うメリットについては湯谷さん(@yutannihilation)下記の記事がわかりやすいです。
- そろそろRユーザーもApache ArrowでParquetを使ってみませんか?
https://notchained.hatenablog.com/entry/2019/12/17/213356
arrowパッケージバージョン6.0.0の威力についてはApache Arrow公式ブログでも力を入れて解説されています。
- Apache Arrow R 6.0.0 Release
https://arrow.apache.org/blog/2021/11/08/r-6.0.0/
duckdb
パッケージ
上記のApache Arrow公式ブログにも書かれている通り、arrowパッケージバージョン6の目玉機能の一つが、duckdbパッケージとの統合です。
DuckDBは、これまた私はごく最近知ったのですけれど、「SQLite for Analytics」と銘打たれている、SQLiteのように使える分析用のDBMSです。
ちなみに先日のJapan.R 2021で「データベース触る機会がない」みたいな感想をいくつか見かけたのですけれども、duckdbは後述するように手軽に使えますので、日々の分析用途に組み込んでいけるのではないかと。
オランダの国立研究所のメンバーが中心になって開発されているそうです。
https://github.com/duckdb/duckdb
上記リポジトリ内にいくつかの言語用のライブラリがあり、Rのduckdbパッケージのソースコードもそこに入っています。
Rから使う場合はdbplyrを利用できます。
arrowとduckdbを動かしてみる
rocker/tidyverse
には最初からarrowとduckdbがインストールされているので、Docker環境さえあればすぐに試せると思います。
Apache Arrow公式ブログとDuckDB公式ブログのクロスポスト記事である以下のブログ投稿の、Pythonで書かれたベンチマークをやってみましょう。
- DuckDB quacks Arrow: A zero-copy data integration between Apache
Arrow and DuckDB
https://arrow.apache.org/blog/2021/12/03/arrow-duckdb/
(ちなみにベンチマークとして最後に載っているニューヨークのタクシーデータを用いるやつをやろうとしたらファイルサイズが30GBくらいあってダウンロードが終わらなさそうだったので軽いベンチマークのみやることにしました……)
使用ライブラリ
library(dplyr)
library(arrow)
library(duckdb)
# ベンチマークで使用するのでインストールされていない場合microbenchmarkパッケージをインストールしておく
if (!require("microbenchmark")) {
install.packages("microbenchmark")
}
# ベンチマーク結果のプロットに使用
library(ggplot2)
データダウンロード
lineitemsf1.snappy.parquet
をダウンロードします。(記事をR Markdownで書いている都合上、実行する度にファイルをDLしないようif
でくくっていますが気にしないでください)
if (!fs::file_exists("lineitemsf1.snappy.parquet")) {
curl::curl_download(
"http://github.com/cwida/duckdb-data/releases/download/v1.0/lineitemsf1.snappy.parquet",
"lineitemsf1.snappy.parquet"
)
}
arrow::read_parquet()
関数でParquetファイルを読み込みます。デフォルトではオプションas_data_frame = TRUE
なのでdata.frameとして読み込まれます。今回はArrow Tableとして読み込みます。
lineitem <- arrow::read_parquet("lineitemsf1.snappy.parquet", as_data_frame = FALSE)
lineitem |>
class()
## [1] "Table" "ArrowTabular" "ArrowObject" "R6"
まずテーブルに何が含まれているのかを確認する必要がありますよね。head()
でテーブルの一部を表示させてみます。
dplyrのノリで書けます。違うのは最後にdplyr::collect()
を実行しているところだけです。
lineitem |>
head() |>
dplyr::collect()
## # A tibble: 6 × 16
## l_orderkey l_partkey l_suppkey l_linenumber l_quantity l_extendedprice
## <int> <int> <int> <int> <int> <dbl>
## 1 1 155190 7706 1 17 21168.
## 2 1 67310 7311 2 36 45983.
## 3 1 63700 3701 3 8 13310.
## 4 1 2132 4633 4 28 28956.
## 5 1 24027 1534 5 24 22824.
## 6 1 15635 638 6 32 49620.
## # … with 10 more variables: l_discount <dbl>, l_tax <dbl>, l_returnflag <chr>,
## # l_linestatus <chr>, l_shipdate <chr>, l_commitdate <chr>,
## # l_receiptdate <chr>, l_shipinstruct <chr>, l_shipmode <chr>,
## # l_comment <chr>
Arrow Tableとして読み込んだため、dplyr::collect()
でdata.frameに変換するまで遅延評価されます。
リリースノートによると、arrow 6.0.0でhead()
は遅延評価されるようになったそうです。全ての行を読み込まず必要な6行のみを処理しているわけです。
dplyr::collect()
とhead()
の順番を逆にすると全ての行をdata.frameに変換したあとで6行を抽出するため、目に見えて速度は遅くなります。
lineitem |>
dplyr::collect() |>
head()
## # A tibble: 6 × 16
## l_orderkey l_partkey l_suppkey l_linenumber l_quantity l_extendedprice
## <int> <int> <int> <int> <int> <dbl>
## 1 1 155190 7706 1 17 21168.
## 2 1 67310 7311 2 36 45983.
## 3 1 63700 3701 3 8 13310.
## 4 1 2132 4633 4 28 28956.
## 5 1 24027 1534 5 24 22824.
## 6 1 15635 638 6 32 49620.
## # … with 10 more variables: l_discount <dbl>, l_tax <dbl>, l_returnflag <chr>,
## # l_linestatus <chr>, l_shipdate <chr>, l_commitdate <chr>,
## # l_receiptdate <chr>, l_shipinstruct <chr>, l_shipmode <chr>,
## # l_comment <chr>
Arrow Tableを直接print()
すると、このテーブルには600万行以上含まれていると分かります。
lineitem
## Table
## 6001215 rows x 16 columns
## $l_orderkey <int64>
## $l_partkey <int64>
## $l_suppkey <int64>
## $l_linenumber <int32>
## $l_quantity <int32>
## $l_extendedprice <double>
## $l_discount <double>
## $l_tax <double>
## $l_returnflag <string>
## $l_linestatus <string>
## $l_shipdate <string>
## $l_commitdate <string>
## $l_receiptdate <string>
## $l_shipinstruct <string>
## $l_shipmode <string>
## $l_comment <string>
##
## See $metadata for additional Schema metadata
duckdbを試す
記事通りに、Arrow Tableとして読み込んだデータをDuckDBに渡して計算させてみましょう。
duckdb::duckdb()
でDuckDBのデータベースインスタンスを立ち上げてから処理を行います。
con <- DBI::dbConnect(duckdb::duckdb())
lineitem |>
arrow::to_duckdb(table_name = "lineitem", con = con) |>
dplyr::filter(
l_shipdate >= "1994-01-01",
l_shipdate < "1995-01-01",
l_discount >= 0.05,
l_discount < 0.07,
l_quantity < 24
) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
dplyr::collect()
## # A tibble: 1 × 1
## revenue
## <dbl>
## 1 75207768.
計算が終わったらデータベースはシャットダウンしましょう。
DBI::dbDisconnect(con, shutdown = TRUE)
なおこの例の内容の場合、duckdbを使うまでもなく以下のようにarrowで計算を完結させることもできます(登場する全ての関数がarrowに対応済みなので)。
先ほどのコードにarrow::to_duckdb()
を挟まない場合、arrowで処理されます。
lineitem |>
dplyr::filter(
l_shipdate >= "1994-01-01",
l_shipdate < "1995-01-01",
l_discount >= 0.05,
l_discount < 0.07,
l_quantity < 24
) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
dplyr::collect()
## # A tibble: 1 × 1
## revenue
## <dbl>
## 1 75207768.
duckdbへの変換が力を発揮するのは、まだarrowは対応していないがduckdbなら対応しているwindow関数等を使いたい場合や、SQLで直接クエリを書きたい場合だと思います。
arrowにせよduckdb(dbplyr)にせよ使える関数に制限がかかるので、どの関数は対応していてどの関数は対応していないのか必要に応じて調べていきましょう。
ベンチマーク
arrowやduckdbを使った処理が動くことを確認したので、ベンチマークをとっていきます。
以下の3パターン行います。
- Parquetファイルを記事のようにArrow Tableとして読み込んでduckdbで処理
- Parquetファイルをdata.frameとして読み込んでdplyrで処理(いつものやつ)
- Parquetファイルを記事のようにArrow Tableとして読み込んでarrowで処理
なお実行環境はRyzen2700X、RAM25GBのWSL2 Dockerコンテナです。
summarise
記事の「Projection Pushdown」の内容です。
16列ある内の2列だけを使った結果を出力させます。
bm <- microbenchmark::microbenchmark(
# Arrow Tableとして読み込んでduckdbで処理
"arrow_to_duckdb" = {
con <- DBI::dbConnect(duckdb::duckdb())
revenue <- read_parquet("lineitemsf1.snappy.parquet", as_data_frame = FALSE) |>
arrow::to_duckdb(table_name = "lineitem", con = con) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
collect() |>
as.numeric()
DBI::dbDisconnect(con, shutdown = TRUE)
revenue
},
# data.frameとして読み込んでdplyrで処理
"data.frame" = {
read_parquet("lineitemsf1.snappy.parquet", as_data_frame = TRUE) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
as.numeric()
},
# Arrow Tableとして読み込んでarrowで処理
"arrow" = {
read_parquet("lineitemsf1.snappy.parquet", as_data_frame = FALSE) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
collect() |>
as.numeric()
},
# 全て同じ結果になることを確認
check = "equal",
# 実行回数
times = 5
)
bm
## Unit: milliseconds
## expr min lq mean median uq max neval
## arrow_to_duckdb 1018.1135 2282.773 3449.078 2975.282 4075.097 6894.124 5
## data.frame 3662.9379 3759.154 7821.678 4033.930 4101.284 23551.085 5
## arrow 719.2391 2441.858 3254.103 4116.576 4455.113 4537.728 5
ggplot2::autoplot(bm)
ほぼ差はありませんでした。
dplyrでは全ての列を読み込むためとても遅くなるものと思っていたのですけども……。
filter + summarise
記事の「Filter Pushdown」の内容です。
dplyr::filter()
の処理が追加されています。
bm <- microbenchmark::microbenchmark(
# Arrow Tableとして読み込んでduckdbで処理
"arrow_to_duckdb" = {
con <- DBI::dbConnect(duckdb::duckdb())
revenue <- read_parquet("lineitemsf1.snappy.parquet", as_data_frame = FALSE) |>
arrow::to_duckdb(table_name = "lineitem", con = con) |>
dplyr::filter(
l_shipdate >= "1994-01-01",
l_shipdate < "1995-01-01",
l_discount >= 0.05,
l_discount < 0.07,
l_quantity < 24
) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
collect() |>
as.numeric()
DBI::dbDisconnect(con, shutdown = TRUE)
revenue
},
# data.frameとして読み込んでdplyrで処理
"data.frame" = {
read_parquet("lineitemsf1.snappy.parquet", as_data_frame = TRUE) |>
dplyr::filter(
l_shipdate >= "1994-01-01",
l_shipdate < "1995-01-01",
l_discount >= 0.05,
l_discount < 0.07,
l_quantity < 24
) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
as.numeric()
},
# Arrow Tableとして読み込んでarrowで処理
"arrow" = {
read_parquet("lineitemsf1.snappy.parquet", as_data_frame = FALSE) |>
dplyr::filter(
l_shipdate >= "1994-01-01",
l_shipdate < "1995-01-01",
l_discount >= 0.05,
l_discount < 0.07,
l_quantity < 24
) |>
dplyr::summarise(revenue = sum(l_extendedprice * l_discount, na.rm = TRUE)) |>
collect() |>
as.numeric()
},
# 全て同じ結果になることを確認
check = "equal",
# 実行回数
times = 5
)
……実行時間が長すぎて繰り返し実行は躊躇われるレベルになりました。
結果を表示します。
bm
## Unit: milliseconds
## expr min lq mean median uq max
## arrow_to_duckdb 1211.3752 1247.4634 1264.067 1275.5271 1292.183 1293.787
## data.frame 56016.6693 56985.9214 57810.239 57471.0483 57956.082 60621.472
## arrow 854.6861 941.0318 1022.523 989.5133 1008.687 1318.695
## neval
## 5
## 5
## 5
ggplot2::autoplot(bm)
ざっくり50倍以上の差が付きました!
dplyrが桁違いに遅くなったのと対照的に、arrowとduckdbはむしろ実行時間は短くなっています。
まとめ
ParquetとApache ArrowとDuckDBをどんどん使いましょう。
Enjoy!