dtplyr 鬼はええ!
このままdplyrパイプライン
全部data.tableで計算しようぜ!

2022-07-23 第100回R勉強会@東京
@eitsupi

はじめに

自己紹介

  • @eitsupi
  • 製造業勤務
    • Excelが嫌になりRを触り初めて3年
  • Dockerイメージrocker/r-ver他のメンテナー

前回のあらすじ

巨大なデータを扱うときにはCSVではなくParquetを使うと便利です。

前回発表スライド

今日の話

  • dplyrバックエンドの速度比較をやってみた。
  • dtplyrの日本語の情報が少ないので共有したい。

対象かも

  • dplyr
  • dbplyrdtplyrを試す機会のなかった方

対象外かも

  • data.table

結論

Q. data.tableって速いの?

A. dtplyrですぐに試せるのでやってみましょう!
tidyverseパッケージインストール時にインストールされてます!)

dtplyr, arrow, duckdb

dplyrバックエンド達

dplyrで記述したデータ操作をdplyr外で実行するパッケージ。

  • tydyverse1
  • その他
    • sparklyr : SparkにSQLを送信して計算実行
    • arrow : Arrow C++ライブラリの計算エンジンAceroで計算実行

どれが速いか気になる!

  • dtplyr, arrow, dbplyr+duckdbは同じような目的(ローカル実行で集約計算などを速くしたい)で使用されます。
    • 前回の発表arrowと共にduckdbを紹介したところ、
      duckdbの使いどころが分からない」との感想をいただいた。
  • 有名なH2Oベンチマークは最後の実行が2021年7月、 まだarrowdplyr::summariseを実行できなかった頃の結果。

ベンチマーク

対象パッケージ

library(dplyr)
library(dtplyr)
library(arrow)
library(duckdb)

これらで、以下のようなグループ毎の集約計算を行います。

mtcars |>
  dplyr::group_by(cyl) |>
  dplyr::summarise(sum = sum(wt, na.rm = TRUE), .groups = "drop")
# A tibble: 3 × 2
    cyl   sum
  <dbl> <dbl>
1     4  25.1
2     6  21.8
3     8  56.0

環境

R version 4.2.1 (2022-06-23)
Platform: x86_64-pc-linux-gnu (64-bit)
Running under: Ubuntu 20.04.4 LTS

Matrix products: default
BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/liblapack.so.3

locale:
 [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
 [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
 [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
 [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
 [9] LC_ADDRESS=C               LC_TELEPHONE=C            
[11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] duckdb_0.4.0 DBI_1.1.3    arrow_8.0.0  dtplyr_1.2.1 dplyr_1.0.9 

loaded via a namespace (and not attached):
 [1] knitr_1.39        magrittr_2.0.3    bit_4.0.4         tidyselect_1.1.2 
 [5] R6_2.5.1          rlang_1.0.3       fastmap_1.1.0     fansi_1.0.3      
 [9] stringr_1.4.0     tools_4.2.1       data.table_1.14.2 xfun_0.31        
[13] utf8_1.2.2        cli_3.3.0         htmltools_0.5.2   ellipsis_0.3.2   
[17] bit64_4.0.5       assertthat_0.2.1  yaml_2.3.5        digest_0.6.29    
[21] tibble_3.1.7      lifecycle_1.0.1   crayon_1.5.1      purrr_0.3.4      
[25] codetools_0.2-18  vctrs_0.4.1       glue_1.6.2        evaluate_0.15    
[29] rmarkdown_2.14    stringi_1.7.6     compiler_4.2.1    pillar_1.7.0     
[33] generics_0.1.3    jsonlite_1.8.0    pkgconfig_2.0.3  

準備 1/3

行数、列数、グループ数を変えてベンチマークするために、
データフレームを以下のような関数で作れるようにしておきます。

.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)
}

準備 2/3

各パッケージによる計算も関数化しておきます。

dplyr

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

dtplyr

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

二つの関数が同じ結果を返すことを確認します。

df <- .gen_data(3, 300, 10)
dplyr::all_equal(.use_dtplyr(df), .use_dplyr(df))
[1] TRUE

準備 3/3

各パッケージによる計算も関数化しておきます。

arrow

.use_arrow <-
  function(.data, .fn = dplyr::summarise) {
  .data |>
    arrow::arrow_table() |>
    dplyr::group_by(col_group) |>
    .fn(
      value = sum(col_value_1, na.rm = TRUE),
    ) |>
    dplyr::collect()
  }

duckdb

.use_duckdb <-
  function(.data, .fn = dplyr::summarise) {
  .data |>
    arrow::to_duckdb() |>
    dplyr::group_by(col_group) |>
    .fn(
      value = sum(col_value_1, na.rm = TRUE),
    ) |>
    dplyr::collect()
  }
dplyr::all_equal(.use_arrow(df), .use_dplyr(df))
[1] TRUE
dplyr::all_equal(.use_duckdb(df), .use_dplyr(df))
[1] TRUE

ベンチマーク (summarise)

行数3条件、グループ数3条件の組み合わせ全9条件で、
benchパッケージによるベンチマークを取ります。

res_sum <- bench::press(
  fn = c("dplyr::summarise"),
  n_row = c(1e6, 1e7, 1e8),
  n_col_value = c(1),
  n_group = c(1e2, 1e3, 1e4),
  {
    dat <- .gen_data(n_group, n_row, n_col_value)
    fn <- eval(parse(text = fn))
    bench::mark(
      check = dplyr::all_equal,
      min_iterations = 5,
      dplyr = .use_dplyr(dat, fn),
      dtplyr = .use_dtplyr(dat, fn),
      arrow = .use_arrow(dat, fn),
      duckdb = .use_duckdb(dat, fn)
    )
  }
)

ベンチマーク結果 (summarise)

res_sum |> ggplot2::autoplot("violin")

dtplyr速い!

ベンチマーク結果 (mutate)

arrowはgroupに対するmutate非対応、
duckdbgroupに対するmutateは現状とても遅くベンチマークが終わらなかったので省略。

dplyrdtplyrほぼ互角。

ベンチマーク (summarise + across)

以下のような、acrossで複数列を対象にする集約計算について、
列数を変えながらベンチマークを取ってみましょう。

.use_across_dplyr <-
  function(.data, .fn = dplyr::summarise) {
  .data |>
    dplyr::group_by(col_group) |>
    .fn(
      dplyr::across(
        tidyselect::starts_with("col_value"),
        .fns = ~ sum(.x, na.rm = TRUE)
      )
    )
  }

arrowは現状across未対応なので使えません。

ベンチマーク結果 (summarise + across)

更に行数を増やすとduckdbが最速になりそうに見えますが、私のマシン(RAM16GB割り当て)ではこれ以上のサイズでの実行を完了できず……。

その他

まとめきれなかったもののベンチマーク色々回してて気付いた結果。

  • dtplyrdplyr::summarise()は列(計算対象外)が増えるだけで遅くなる事象を確認しました。不要な列はあらかじめdplyr::select()で削除する方が良いかも知れません。

  • tidyr::pivot_longer()dplyrが速かったです。

  • ↓みたいなこともできるので組み合わせて使いましょう!

    mtcars |>
      arrow::arrow_table() |>
      arrow::to_duckdb() |>
      dtplyr::lazy_dt()
    • 資料作成中arrow::Tabledtplyr::lazy_dt()に渡せないバグを見つけたので修正しました。

まとめ

  • data.tableはマジで速い!
  • dtplyrで敷居も低い!

Enjoy!

脚注

  1. dplyr backends: multidplyr 0.1.0, dtplyr 1.1.0, dbplyr 2.1.0