Performance of svTidy Functions

One goal of the {svTidy} package is to provide an interface that is similar to {dplyr} and {tidyr} to performant code (both for speed and memory use), possibly using the {data.table} or {collapse} packages under the hood.

In this document, we compare speed and memory use of {svTidy} with {dplyr} and {tidyr}. Keep in mind that most benchmarks (including the present ones) are artificial and do not necessarily reflect real use cases. Test with your own data. Also, do you really need faster or more memory efficient code? It depends on your particular context! Also take your hardware into account (number of CPU and amount of RAM).

We test {svTidy} function both in standard evaluation (SE) and non standard evaluation (NSE) modes: the later one usually requires more computing time, but it is more convenient to write and read and closer to {dplyr}/{tidyr} syntax.

Functions that are considered optimized

Preparation: using 3/4 of available cores for parallel code in {data.table} and {collapse}.

data.table::setDTthreads(percent = 75)
(.nthreads <- data.table::getDTthreads())
#> [1] 3
options(collapse_nthreads = .nthreads)
options(collapse_na.rm = FALSE)
options(collapse_mask = "all")

Small and medium data sets.

# Small one
data(mtcars)
mtcars <- as_tibble(mtcars, rownames = "model")
mtcars_dt <- as.data.table(mtcars)

# Medium one
data(babynames, package = 'babynames')
babynames <- as_tibble(babynames)
babynames_dt <- as.data.table(babynames)

Here is a couple of examples of fast and memory efficient {svTidy} functions.

filter_()

Small data set.

# Note: collapse::qDF() = quickly convert to a data.frame, for identical results
bench::mark(
  dplyr      = filter(mtcars, mpg > 20) |> qDF(),
  data.table = mtcars_dt[mpg > 20] |> qDF(), 
  svTidyNSE  = filter_(mtcars, ~mpg > 20) |> qDF(),
  svTidySE   = filter_(mtcars, mtcars$mpg > 20) |> qDF())
#> # A tibble: 4 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 dplyr       673.1µs  716.6µs     1347.    1.36MB     8.52
#> 2 data.table  108.6µs    115µs     8330.    1.82MB     6.28
#> 3 svTidyNSE    52.7µs     56µs    17225.  154.43KB    10.8 
#> 4 svTidySE     45.9µs   48.7µs    19842.   43.45KB    10.8

Medium data set.

bench::mark(
  dplyr      = filter(babynames, n > 1000) |> qDF(),
  data.table = babynames_dt[n > 1000] |> qDF(),
  svTidyNSE  = filter_(babynames, ~n > 1000) |> qDF(),
  svTidySE   = filter_(babynames, babynames$n > 1000) |> qDF())
#> # A tibble: 4 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 dplyr       10.74ms  10.97ms      76.4    31.4MB     43.0
#> 2 data.table   4.71ms   4.84ms     192.     16.7MB     47.2
#> 3 svTidyNSE    4.29ms   4.46ms     189.     16.7MB     37.8
#> 4 svTidySE     4.33ms   4.45ms     184.     16.7MB     29.8

arrange_()

bench::mark(
  dplyr      = arrange(mtcars, cyl, desc(vs)) |> qDF(),
  data.table = mtcars_dt[order(cyl, -vs)] |> qDF(),
  svTidyNSE  = arrange_(mtcars, ~cyl, ~ -vs) |> qDF(),
  svTidySE   = arrange_(mtcars, 'cyl', '-vs') |> qDF())
#> # A tibble: 4 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 dplyr        2.25ms   2.31ms      429.   591.4KB     8.71
#> 2 data.table 155.89µs 163.07µs     5928.    38.9KB     8.57
#> 3 svTidyNSE  100.44µs  105.1µs     9144.    92.9KB    10.9 
#> 4 svTidySE    60.58µs   64.1µs    14978.    85.1KB    10.8
bench::mark(
  dplyr      = arrange(babynames, sex, desc(n)) |> qDF(),
  data.table = babynames_dt[order(sex, -n)] |> qDF(),
  svTidyNSE  = arrange_(babynames, ~sex, ~ -n) |> qDF(),
  svTidySE   = arrange_(babynames, 'sex', '-n') |> qDF())
#> # A tibble: 4 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 dplyr        84.4ms   84.4ms      11.8   112.7MB     59.2
#> 2 data.table   59.5ms   66.1ms      15.1    73.4MB     37.8
#> 3 svTidyNSE    25.9ms   27.1ms      25.5    73.4MB     11.4
#> 4 svTidySE     25.7ms   26.1ms      35.9    73.4MB     16.3

Functions that could still be optimized

Not all {svTidy} functions are currently faster or more memory efficient than their {dplyr} or {tidyr} counterparts. Those still need refactoring to be optimized. Here are some examples.

bind_rows_()

df1 <- tibble(x = 1:2, y = letters[1:2])
df1_dt <- as.data.table(df1)

bench::mark(
  dplyr      = bind_rows(df1, df1) |> qDF(),
  base       = rbind(df1, df1) |> qDF(),
  data.table = rbindlist(list(df1_dt, df1_dt)) |> qDF(),
  svTidy     = bind_rows_(df1, df1) |> qDF(),
  svTidy2    = bind_rows_(list(df1, df1)) |> qDF())
#> # A tibble: 5 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 dplyr        90.1µs   94.4µs    10227.    96.4KB     8.68
#> 2 base           78µs   82.2µs    11724.    47.5KB    11.1 
#> 3 data.table  110.3µs  114.1µs     8461.   121.5KB     6.30
#> 4 svTidy      616.1µs  652.7µs     1525.   203.9KB    10.8 
#> 5 svTidy2     618.1µs    657µs     1514.    52.5KB     8.60
bench::mark(
  dplyr      = bind_rows(babynames, babynames) |> qDF(),
  base       = rbind(babynames, babynames) |> qDF(),
  data.table = rbindlist(list(babynames_dt, babynames_dt)) |> qDF(),
  svTidy     = bind_rows_(babynames, babynames) |> qDF(),
  svTidy2    = bind_rows_(list(babynames, babynames)) |> qDF())
#> Warning: Some expressions had a GC in every iteration; so filtering is
#> disabled.
#> # A tibble: 5 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 dplyr       74.22ms  75.57ms    10.7       132MB    3.57 
#> 2 base       157.51ms 180.73ms     5.39      256MB    3.59 
#> 3 data.table  44.57ms  46.07ms    17.1       132MB    5.71 
#> 4 svTidy        1.56s    1.56s     0.640     368MB    1.28 
#> 5 svTidy2       1.52s    1.52s     0.657     338MB    0.657

bind_cols_()

df1 <- tibble(x = 1:2, y = letters[1:2])
df2 <- tibble(z = 10:11, w = factor(5:6))
df1_dt <- as.data.table(df1)
df2_dt <- as.data.table(df2)

bench::mark(check = FALSE,
  dplyr      = bind_cols(df1, df2) |> qDF(),
  base       = cbind(df1, df2) |> qDF(),
  data.table = cbind(df1_dt, df2_dt) |> qDF(),
  svTidy     = bind_cols_(df1, df2) |> qDF())
#> # A tibble: 4 × 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 dplyr         144µs    152µs     6348.   32.87KB     2.10
#> 2 base          194µs    205µs     4733.    2.19KB     2.08
#> 3 data.table    162µs    169µs     5730.  187.33KB     2.09
#> 4 svTidy        152µs    158µs     6109.   13.88KB     4.26