【R】Apache Arrowとduckdbを試してみる

これは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くらいあってダウンロードが終わらなさそうだったので軽いベンチマークのみやることにしました……）

### 使用ライブラリ

```R:R
library(dplyr)
library(arrow)
library(duckdb)

# ベンチマークで使用するのでインストールされていない場合microbenchmarkパッケージをインストールしておく
if (!require("microbenchmark")) {
  install.packages("microbenchmark")
}

# ベンチマーク結果のプロットに使用
library(ggplot2)
```

### データダウンロード

`lineitemsf1.snappy.parquet`をダウンロードします。（記事をR Markdownで書いている都合上、実行する度にファイルをDLしないよう`if`でくくっていますが気にしないでください）

```R:R
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として読み込みます。

```R:R
lineitem <- arrow::read_parquet("lineitemsf1.snappy.parquet", as_data_frame = FALSE)

lineitem |>
  class()
```

    ## [1] "Table"        "ArrowTabular" "ArrowObject"  "R6"

まずテーブルに何が含まれているのかを確認する必要がありますよね。`head()`でテーブルの一部を表示させてみます。

dplyrのノリで書けます。違うのは最後に`dplyr::collect()`を実行しているところだけです。

```R:R
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行を抽出するため、目に見えて速度は遅くなります。

```R:R
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万行以上含まれていると分かります。

```R:R
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のデータベースインスタンスを立ち上げてから処理を行います。

```R:R
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.

計算が終わったらデータベースはシャットダウンしましょう。

```R:R
DBI::dbDisconnect(con, shutdown = TRUE)
```

なおこの例の内容の場合、duckdbを使うまでもなく以下のようにarrowで計算を完結させることもできます（登場する全ての関数がarrowに対応済みなので）。
先ほどのコードに`arrow::to_duckdb()`を挟まない場合、arrowで処理されます。

```R:R
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パターン行います。

1.  Parquetファイルを記事のようにArrow Tableとして読み込んでduckdbで処理
2.  Parquetファイルをdata.frameとして読み込んでdplyrで処理（いつものやつ）
3.  Parquetファイルを記事のようにArrow Tableとして読み込んでarrowで処理

なお実行環境はRyzen2700X、RAM25GBのWSL2 Dockerコンテナです。

#### summarise

記事の「Projection Pushdown」の内容です。
16列ある内の2列だけを使った結果を出力させます。

```R:R
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
)
```

```R:R
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

```R:R
ggplot2::autoplot(bm)
```

![plot1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675583/086bd16e-43bb-6639-0e21-51b4dfa2d328.png)
ほぼ差はありませんでした。
dplyrでは全ての列を読み込むためとても遅くなるものと思っていたのですけども……。

#### filter + summarise

記事の「Filter Pushdown」の内容です。
`dplyr::filter()`の処理が追加されています。

```R:R

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
)
```

……実行時間が長すぎて繰り返し実行は躊躇われるレベルになりました。

結果を表示します。

```R:R
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

```R:R
ggplot2::autoplot(bm)
```

![plot2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675583/6ad6343d-2fab-cd95-1bf1-0e2049ed3892.png)
ざっくり50倍以上の差が付きました！
dplyrが桁違いに遅くなったのと対照的に、arrowとduckdbはむしろ実行時間は短くなっています。

## まとめ

ParquetとApache ArrowとDuckDBをどんどん使いましょう。

Enjoy!