duckplyr
覚醒

2024-06-08 第103回R勉強会@東京
@eitsupi

はじめに

自己紹介

前回までのあらすじ

dplyrバックエンドの速度比較をしたり(2022年)

DuckDBの紹介をしたりしてきました(2023年)

そして2024年……

今、DuckDBがアツい!!!

  • 4月2日:DuckDB BLOG上でduckplyr発表1
  • 4月10日:duckplyrが速すぎるという検証結果のブログが話題に2
  • 6月3日:DuckDB 1.0.0リリース3

1億行のCSVファイルから集計を行う”1 billion row challenge”4をRでやってみた結果DuckDBを使用した場合が高速だった報告がいくつかある56など、R界隈でもますます注目が集まっています。

DuckDB周辺のおさらい

Apache Parquet (1/2)

  • 2013年~7
  • Apache Hadoop用に作られた列指向ファイルフォーマット
    • 列方向に圧縮されるため大量のレコードを圧縮しやすい
    • 列単位でベクトル化した計算を行う分析処理と相性が良い
  • 速度・容量・型の豊富さから、大きなデータフレームの保存に向く
    • 「CSVをやめて人間を続けよう」8

Apache Parquet (2/2)

  • Parquetを読み書きできる主要なRパッケージ
    • Spark経由のsparkRsparklyr
    • arrow: 多機能だがビルド大変
    • duckdb: 多機能、factor型への特別対応なし
    • nanoparquet (New!): ビルド簡単、最低限の機能

duckdbの多機能化とnanoparquetの登場で、
Parquet読み書きのためだけにarrowを使用しなくても良い時代に

Apache Arrow

  • 2016年~9
  • A high-performance cross-system data layer for columnar in-memory analytics
  • 言語に寄らない列指向インメモリフォーマットの標準を目指しているプロジェクト
    • Arrowを介することで、ある列指向データから別の列指向データへの変換を個別に実装する必要はなくなる

クエリエンジン競争(※個人的見解)

Apache Arrow周辺の主要なクエリエンジン

  • Acero ← 速度は優先事項ではない10
  • DataFusion ← Rust向け(他言語バインディングは関心低)
  • DuckDB ← 多くの言語から利用可能、SQLiteから置換しやすい
  • Polars ← Python向け(Rustは優先事項ではない)

DuckDBは最速クラスな上に多くの言語から利用可能
(それに加えてSQLも最も充実している)

duckplyrとは

  • dplyrの高速バックエンドとして作られたRパッケージ
  • dplyrのクエリをDuckDBのリレーショナルAPIに変換する
  • Rのデータフレームへのクエリに特化(通常のdplyrと同じ)
    • 本来DuckDBの得意とするParquet等へのクエリへの対応は途中(後述)
      • 現時点ではデータフレーム以外にはdbplyr使う方が良い
  • 遅延評価を行う他のバックエンドと異なりdplyr::collect()なしで即座にデータフレームを返す(通常のdplyrと同じ)

duckplyr WASM

DuckDBなので当然webR上でも動きます

ベンチマーク

data.frameへのクエリ 1/2

ベンチマーク用関数
.gen_data <- \(n_group, n_row, n_col_value, .seed = 1) {
  groups <- seq_len(n_group) |>
    rep_len(n_row) |>
    as.character()

  set.seed(.seed)

  runif(n_row * n_col_value, min = 0, max = 100) |>
    round() |>
    matrix(ncol = n_col_value) |>
    tibble::as_tibble(
      .name_repair = \(x) paste0("col_value_", seq_len(n_col_value))
    ) |>
    dplyr::mutate(col_group = groups, .before = 1)
}

.use_dplyr <-
  function(.data) {
    .data |>
      dplyr::summarise(
        value = sum(col_value_1, na.rm = TRUE),
        .by = col_group
      ) |>
      dplyr::arrange(col_group)
  }

.use_dtplyr <-
  function(.data) {
    .data |>
      dtplyr::lazy_dt() |>
      dplyr::summarise(
        value = sum(col_value_1, na.rm = TRUE),
        .by = col_group
      ) |>
      dplyr::arrange(col_group) |>
      dplyr::collect()
  }

.use_acero <-
  function(.data) {
    .data |>
      arrow::as_arrow_table() |>
      dplyr::summarise(
        value = sum(col_value_1, na.rm = TRUE),
        .by = col_group
      ) |>
      dplyr::arrange(col_group) |>
      dplyr::collect()
  }

.use_duckplyr <-
  function(.data) {
    .data |>
      duckplyr::as_duckplyr_df() |>
      dplyr::summarise(
        # na.rm は非対応
        value = sum(col_value_1),
        .by = col_group
      ) |>
      dplyr::arrange(col_group)
  }

.use_polars <-
  function(.data) {
    polars::as_polars_lf(.data)$group_by("col_group")$agg(
      value = polars::pl$col("col_value_1")$sum()
    )$sort("col_group")$collect() |>
      tibble::as_tibble()
  }
res_sum <- bench::press(
  n_row = c(1e5, 1e6, 1e7),
  n_col_value = c(1),
  n_group = c(1e2, 1e3),
  {
    dat <- .gen_data(n_group, n_row, n_col_value)
    bench::mark(
      check = FALSE,
      min_iterations = 5,
      dplyr = .use_dplyr(dat),
      dtplyr = .use_dtplyr(dat),
      acero = .use_acero(dat),
      duckplyr = .use_duckplyr(dat),
      polars = .use_polars(dat)
    )
  }
)

data.frameへのクエリ 2/2

  • DuckDBはdata.frameをDB内にコピーしないため速い
  • DuckDBの計算速度が速い

Parquetへのクエリ 1/10

DuckDBのリポジトリに置かれているいつものParquet11を使用します。

curl::curl_download(
  "https://github.com/duckdb/duckdb-data/releases/download/v1.0/lineitemsf1.snappy.parquet",
  "lineitemsf1.snappy.parquet"
)

サクッと時間を計りたいのでtictocパッケージをロードします。

library(tictoc)

Parquetへのクエリ 2/10

nanoparquetで読み込みdplyrでクエリを実行する場合

tic()

nanoparquet::read_parquet("lineitemsf1.snappy.parquet") |>
  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))
   revenue
1 75207768
toc()
10.773 sec elapsed

Parquetへのクエリ 3/10

Acero (arrowパッケージ) の場合

tic()

arrow::open_dataset("lineitemsf1.snappy.parquet") |>
  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.
toc()
0.56 sec elapsed

Parquetへのクエリ 4/10

Polarsの場合

tic()

polars::pl$scan_parquet("lineitemsf1.snappy.parquet")$filter(
  polars::pl$col("l_shipdate") >= "1994-01-01",
  polars::pl$col("l_shipdate") < "1995-01-01",
  polars::pl$col("l_discount") >= 0.05, polars::pl$col("l_discount") < 0.07,
  polars::pl$col("l_quantity") < 24
)$select(
  revenue = (polars::pl$col("l_extendedprice") * polars::pl$col("l_discount"))$sum()
) |>
  as.data.frame()
   revenue
1 75207768
toc()
0.482 sec elapsed

Parquetへのクエリ 5/10

GlareDB(DataFusionに基づいた分析用RDBMS)の場合

tic()

glaredb::glaredb_sql("
SELECT
  sum(l_extendedprice * l_discount) AS revenue
FROM read_parquet('lineitemsf1.snappy.parquet')
WHERE
  l_shipdate >= '1994-01-01' AND l_shipdate < '1995-01-01'
  AND l_discount >= 0.05 AND l_discount < 0.07
  AND l_quantity < 24
") |>
  as.data.frame()
   revenue
1 75207768
toc()
0.963 sec elapsed

Parquetへのクエリ 6/10

duckplyrの場合

tic()

duckplyr::duckplyr_df_from_parquet("lineitemsf1.snappy.parquet") |>
  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))

toc()

Parquetへのクエリ 7/10

duckplyrの場合……は、どうやらプッシュダウンが上手く動作しておらず遅いようです12。今後に期待。

materializing:
---------------------
--- Relation Tree ---
---------------------
Filter [(>=(l_shipdate, '1994-01-01') AND <(l_shipdate, '1995-01-01') AND >=(l_discount, 0.05) AND <(l_discount, 0.07) AND <(l_quantity, 24.0))]
  read_parquet(lineitemsf1.snappy.parquet)

---------------------
-- Result Columns  --
---------------------
- l_orderkey (BIGINT)
- l_partkey (BIGINT)
- l_suppkey (BIGINT)
- l_linenumber (INTEGER)
- l_quantity (INTEGER)
- l_extendedprice (DOUBLE)
- l_discount (DOUBLE)
- l_tax (DOUBLE)
- l_returnflag (VARCHAR)
- l_linestatus (VARCHAR)
- l_shipdate (VARCHAR)
- l_commitdate (VARCHAR)
- l_receiptdate (VARCHAR)
- l_shipinstruct (VARCHAR)
- l_shipmode (VARCHAR)
- l_comment (VARCHAR)
   revenue
1 75207768
2.791 sec elapsed

Parquetへのクエリ 8/10

duckdbの場合

tic()

duckdb:::sql("
SELECT
  sum(l_extendedprice * l_discount) AS revenue
FROM read_parquet('lineitemsf1.snappy.parquet')
WHERE
  l_shipdate >= '1994-01-01' AND l_shipdate < '1995-01-01'
  AND l_discount >= 0.05 AND l_discount < 0.07
  AND l_quantity < 24
") |>
  as.data.frame()
   revenue
1 75207768
toc()
0.367 sec elapsed

Parquetへのクエリ 9/10

duckdbの場合(dbplyr経由)

tic()

dplyr::tbl(DBI::dbConnect(duckdb::duckdb()), "read_parquet('lineitemsf1.snappy.parquet')") |>
  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.
toc()
0.627 sec elapsed

Parquetへのクエリ 10/10

ベンチマーク
bnch <- bench::mark(
  check = FALSE,
  min_iterations = 10,
  acero = arrow::open_dataset("lineitemsf1.snappy.parquet") |>
    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(),
  polars = polars::pl$scan_parquet("lineitemsf1.snappy.parquet")$filter(
    polars::pl$col("l_shipdate") >= "1994-01-01",
    polars::pl$col("l_shipdate") < "1995-01-01",
    polars::pl$col("l_discount") >= 0.05, polars::pl$col("l_discount") < 0.07,
    polars::pl$col("l_quantity") < 24
  )$select(
    revenue = (polars::pl$col("l_extendedprice") * polars::pl$col("l_discount"))$sum()
  ) |>
    as.data.frame(),
  glaredb = glaredb::glaredb_sql("
SELECT
  sum(l_extendedprice * l_discount) AS revenue
FROM read_parquet('lineitemsf1.snappy.parquet')
WHERE
  l_shipdate >= '1994-01-01' AND l_shipdate < '1995-01-01'
  AND l_discount >= 0.05 AND l_discount < 0.07
  AND l_quantity < 24
") |>
    as.data.frame(),
  duckdb = duckdb:::sql("
SELECT
  sum(l_extendedprice * l_discount) AS revenue
FROM read_parquet('lineitemsf1.snappy.parquet')
WHERE
  l_shipdate >= '1994-01-01' AND l_shipdate < '1995-01-01'
  AND l_discount >= 0.05 AND l_discount < 0.07
  AND l_quantity < 24
") |>
    as.data.frame()
)

まとめ

  • どんどん速くなるDuckDB
  • duckplyrで手軽さUP

Enjoy!

バージョン情報

sessioninfo::session_info()
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.4.0 (2024-04-24)
 os       Ubuntu 22.04.4 LTS
 system   x86_64, linux-gnu
 ui       X11
 language (EN)
 collate  en_US.UTF-8
 ctype    en_US.UTF-8
 tz       Etc/UTC
 date     2024-06-07
 pandoc   3.1.13 @ /usr/bin/ (via rmarkdown)

─ Packages ───────────────────────────────────────────────────────────────────
 package     * version date (UTC) lib source
 arrow         16.1.0  2024-05-25 [1] RSPM
 assertthat    0.2.1   2019-03-21 [1] RSPM
 bench         1.1.3   2023-05-04 [1] RSPM
 bit           4.0.5   2022-11-15 [1] RSPM
 bit64         4.0.5   2020-08-30 [1] RSPM
 blob          1.2.4   2023-03-17 [1] RSPM
 cli           3.6.2   2023-12-11 [1] RSPM
 collections   0.3.7   2023-01-05 [1] RSPM
 colorspace    2.1-0   2023-01-23 [1] RSPM
 data.table    1.15.4  2024-03-30 [1] RSPM
 DBI           1.2.3   2024-06-02 [1] RSPM
 dbplyr        2.5.0   2024-03-19 [1] RSPM
 digest        0.6.35  2024-03-11 [1] RSPM
 dplyr         1.1.4   2023-11-17 [1] RSPM
 dtplyr        1.3.1   2023-03-22 [1] RSPM
 duckdb        0.10.2  2024-05-01 [1] RSPM
 duckplyr      0.4.0   2024-05-21 [1] RSPM
 evaluate      0.23    2023-11-01 [1] RSPM
 fansi         1.0.6   2023-12-08 [1] RSPM
 farver        2.1.2   2024-05-13 [1] RSPM
 fastmap       1.1.1   2023-02-24 [1] RSPM
 generics      0.1.3   2022-07-05 [1] RSPM
 ggplot2       3.5.1   2024-04-23 [1] RSPM
 glaredb       0.0.1   2024-06-06 [1] https://e~
 glue          1.7.0   2024-01-09 [1] RSPM
 gtable        0.3.5   2024-04-22 [1] RSPM
 htmltools     0.5.8.1 2024-04-04 [1] RSPM
 jsonlite      1.8.8   2023-12-04 [1] RSPM
 knitr         1.46    2024-04-06 [1] RSPM
 labeling      0.4.3   2023-08-29 [1] RSPM
 lifecycle     1.0.4   2023-11-07 [1] RSPM
 magrittr      2.0.3   2022-03-30 [1] RSPM
 munsell       0.5.1   2024-04-01 [1] RSPM
 nanoarrow     0.5.0.1 2024-05-31 [1] RSPM
 nanoparquet   0.2.0   2024-05-30 [1] RSPM
 pillar        1.9.0   2023-03-22 [1] RSPM
 pkgconfig     2.0.3   2019-09-22 [1] RSPM
 polars        0.17.0  2024-06-06 [1] https://r~
 profmem       0.6.0   2020-12-13 [1] RSPM
 purrr         1.0.2   2023-08-10 [1] RSPM
 R6            2.5.1   2021-08-19 [1] RSPM
 rlang         1.1.4   2024-06-04 [1] RSPM
 rmarkdown     2.26    2024-03-05 [1] RSPM
 scales        1.3.0   2023-11-28 [1] RSPM
 sessioninfo   1.2.2   2021-12-06 [1] RSPM
 tibble        3.2.1   2023-03-20 [1] RSPM
 tictoc      * 1.2.1   2024-03-18 [1] RSPM
 tidyr         1.3.1   2024-01-24 [1] RSPM
 tidyselect    1.2.1   2024-03-11 [1] RSPM
 utf8          1.2.4   2023-10-22 [1] RSPM
 vctrs         0.6.5   2023-12-01 [1] RSPM
 withr         3.0.0   2024-01-16 [1] RSPM
 xfun          0.43    2024-03-25 [1] RSPM
 yaml          2.3.8   2023-12-11 [1] RSPM

 [1] /usr/local/lib/R/site-library
 [2] /usr/local/lib/R/library

──────────────────────────────────────────────────────────────────────────────

脚注

  1. duckplyr: A dplyr backend for DuckDB

  2. The Truth About Tidy Wrappers

  3. Announcing DuckDB 1.0.0

  4. https://github.com/gunnarmorling/1brc

  5. https://github.com/jrosell/1br

  6. R One Billion Row Challenge: Is R Viable Option for Analyzing Huge Datasets?

  7. DuckDB quacks Arrow: A zero-copy data integration between Apache Arrow and DuckDB

  8. そろそろRユーザーもApache ArrowでParquetを使ってみませんか?

  9. The Apache® Software Foundation Announces Apache Arrow™ as a Top-Level Project

  10. [DISCUSS] Acero roadmap / philosophy

  11. DuckDB quacks Arrow: A zero-copy data integration between Apache Arrow and DuckDB

  12. https://github.com/duckdblabs/duckplyr/issues/172