Benchmarks: baseball data

The purpose of these benchmarks is to be as fair as possible, to help understand the relatively performance tradeoffs of the different approaches. If you think my implementation of base or data.table equivalents is suboptimal, please let me know better ways.

Also note that I consider any significant performance difference between dt and dt_raw to be a bug in dplyr: for individual operations there should be very little overhead to calling data.table via dplyr. However, data.table may be significantly faster when performing the same sequence of operations as dplyr. This is because currently dplyr uses an eager evaluation approach so the individual calls to [.data.table don't get as much information about the desired result as the single call to [.data.table would if you did it by hand.

Thanks go to Matt Dowle and Arun Srinivasan for their extensive feedback on these benchmarks.

Data setup

The following benchmarks explore the performance on a somewhat realistic example: the Batting dataset from the Lahman package. It contains 96600 records on the batting careers of 96600 players from 1871 to 2012.

The first code block defines two alternative backends for the Batting dataset. Grouping operations are performed inline in each benchmark. This represents the common scenario where you group the data and immediately use it.

batting_df <- tbl_df(Batting)
batting_dt <- tbl_dt(Batting)

Summarise

Compute the average number of at bats for each player:

microbenchmark(
  dplyr_df = batting_df %.% group_by(playerID) %.% summarise(ab = mean(AB)),
  dplyr_dt = batting_dt %.% group_by(playerID) %.% summarise(ab = mean(AB)),
  dt_raw =   batting_dt[, list(ab = mean(AB)), by = playerID],
  base =     tapply(batting_df$AB, batting_df$playerID, FUN = mean),
  times = 5
)
#> Unit: milliseconds
#>      expr   min    lq median    uq   max neval
#>  dplyr_df  17.5  18.4   18.4  19.3  20.3     5
#>  dplyr_dt  66.9  67.1   69.3  80.6  81.3     5
#>    dt_raw  19.4  19.8   20.7  22.1  25.6     5
#>      base 183.8 186.9  194.9 212.5 216.5     5

NB: base implementation captures computation but not output format, giving considerably less output.

However, this comparison is slightly unfair because both data.table and summarise() use tricks to find a more efficient implementation of mean(). Data table calls a C implementation of the mean (using.External(Cfastmean, B, FALSE)and thus avoiding the overhead of S3 method dispatch).dplyr::summarise uses a hybrid evaluation technique, where common functions are implemented purely in C++, avoiding R function call overhead.

mean_ <- function(x) .Internal(mean(x))
microbenchmark(
  dplyr_df = batting_df %.% group_by(playerID) %.% summarise(ab = mean_(AB)),
  dplyr_dt = batting_dt %.% group_by(playerID) %.% summarise(ab = mean_(AB)),
  dt_raw =   batting_dt[, list(ab = mean_(AB)), by = playerID],
  base =     tapply(batting_df$AB, batting_df$playerID, FUN = mean_),
  times = 5
)
#> Unit: milliseconds
#>      expr  min   lq median   uq  max neval
#>  dplyr_df 28.0 31.3   32.9 33.7 35.5     5
#>  dplyr_dt 33.0 41.6   72.0 73.5 76.4     5
#>    dt_raw 17.4 18.3   18.9 19.5 26.0     5
#>      base 81.9 82.0   82.4 83.0 84.9     5

Arrange

Arrange by year within each player:

microbenchmark(
  dplyr_df = batting_df %.% arrange(playerID, yearID),
  dplyr_dt = batting_dt %.% arrange(playerID, yearID),
  dt_raw =   setkey(copy(batting_dt), playerID, yearID),
  base   =   batting_dt[order(batting_df$playerID, batting_df$yearID), ],
  times = 2
)
#> Unit: milliseconds
#>      expr   min    lq median    uq   max neval
#>  dplyr_df  10.2  10.2   11.3  12.4  12.4     2
#>  dplyr_dt 187.1 187.1  203.4 219.8 219.8     2
#>    dt_raw  17.4  17.4   18.0  18.6  18.6     2
#>      base  69.7  69.7   85.9 102.1 102.1     2

Filter

Find the year for which each player played the most games:

microbenchmark(
  dplyr_df = batting_df %.% group_by(playerID) %.% filter(G == max(G)),
  dplyr_dt = batting_dt %.% group_by(playerID) %.% filter(G == max(G)),
  dt_raw   = batting_dt[batting_dt[, .I[G == max(G)], by = playerID]$V1],
  base   =   batting_df[ave(batting_df$G, batting_df$playerID, FUN = max) ==
    batting_df$G, ],
  times = 2
)
#> Unit: milliseconds
#>      expr   min    lq median    uq   max neval
#>  dplyr_df  45.2  45.2   47.0  48.8  48.8     2
#>  dplyr_dt  49.4  49.4   52.9  56.5  56.5     2
#>    dt_raw  37.4  37.4   38.4  39.4  39.4     2
#>      base 111.8 111.8  112.1 112.3 112.3     2

I'm not aware of a single line data table equivalent (see SO 16573995). Suggetions welcome. dplyr currently doesn't support hybrid evaluation for logical comparison, but it is scheduled for 0.2 (see #113), this should give an additional speed up.

Mutate

Rank years based on number of at bats:

microbenchmark(
  dplyr_df  = batting_df %.% group_by(playerID) %.% mutate(r = rank(desc(AB))),
  dplyr_dt  = batting_dt %.% group_by(playerID) %.% mutate(r = rank(desc(AB))),
  dt_raw =    copy(batting_dt)[, rank := rank(desc(AB)), by = playerID],
  times = 2
)
#> Unit: milliseconds
#>      expr min  lq median  uq max neval
#>  dplyr_df 631 631    645 659 659     2
#>  dplyr_dt 680 680    693 705 705     2
#>    dt_raw 601 601    602 603 603     2

(The dt_raw code needs to explicitly copy the data.table so the it doesn't modify in place, as is the data.table default. This is an example where it's difficult to compare data.table and dplyr directly because of different underlying philosophies.)

Compute year of career:

microbenchmark(
  dplyr_df = batting_df %.% group_by(playerID) %.%
    mutate(cyear = yearID - min(yearID) + 1),
  dplyr_dt = batting_dt %.% group_by(playerID) %.%
    mutate(cyear = yearID - min(yearID) + 1),
  dt_raw =   copy(batting_dt)[, cyear := yearID - min(yearID) + 1,
    by = playerID],
  times = 5
)
#> Unit: milliseconds
#>      expr  min   lq median   uq   max neval
#>  dplyr_df 48.3 49.5   50.5 51.0  51.7     5
#>  dplyr_dt 63.7 65.8   92.5 93.1 131.2     5
#>    dt_raw 23.7 26.3   29.0 63.4  66.2     5

Rank is a relatively expensive operation and min() is relatively cheap, showing the the relative performance overhead of the difference techniques.

dplyr currently has some support for hybrid evaluation of window functions. This yields substantial speed-ups where available:

min_rank_ <- min_rank
microbenchmark(
  hybrid  = batting_df %.% group_by(playerID) %.% mutate(r = min_rank(AB)),
  regular  = batting_df %.% group_by(playerID) %.% mutate(r = min_rank_(AB)),
  times = 2
)
#> Unit: milliseconds
#>     expr   min    lq median    uq   max neval
#>   hybrid  52.6  52.6   53.5  54.3  54.3     2
#>  regular 648.7 648.7  651.7 654.8 654.8     2

Joins

We conclude with some quick comparisons of joins. First we create two new datasets: master which contains demographic information on each player, and hall_of_fame which contains all players inducted into the hall of fame.

master_df <- tbl_df(Master) %.% select(playerID, hofID, birthYear)
hall_of_fame_df <- tbl_df(HallOfFame) %.% filter(inducted == "Y") %.%
  select(hofID, votedBy, category)

master_dt <- tbl_dt(Master) %.% select(playerID, hofID, birthYear)
hall_of_fame_dt <- tbl_dt(HallOfFame) %.% filter(inducted == "Y") %.%
  select(hofID, votedBy, category)
microbenchmark(
  dplyr_df = left_join(master_df, hall_of_fame_df, by = "hofID"),
  dplyr_dt = left_join(master_dt, hall_of_fame_dt, by = "hofID"),
  base     = merge(master_df, hall_of_fame_df, by = "hofID", all.x = TRUE),
  times = 10
)
#> Unit: milliseconds
#>      expr    min    lq median    uq   max neval
#>  dplyr_df  0.982  1.11   1.21  1.31  3.00    10
#>  dplyr_dt  3.025  3.25   3.29  3.35  4.06    10
#>      base 28.785 29.97  32.47 35.47 41.60    10

microbenchmark(
  dplyr_df = inner_join(master_df, hall_of_fame_df, by = "hofID"),
  dplyr_dt = inner_join(master_dt, hall_of_fame_dt, by = "hofID"),
  base     = merge(master_df, hall_of_fame_df, by = "hofID"),
  times = 10
)
#> Unit: milliseconds
#>      expr   min    lq median    uq  max neval
#>  dplyr_df 0.876 0.887  0.943 0.958 1.03    10
#>  dplyr_dt 2.063 2.106  2.240 2.423 3.12    10
#>      base 2.112 2.345  2.542 2.647 2.94    10

microbenchmark(
  dplyr_df = semi_join(master_df, hall_of_fame_df, by = "hofID"),
  dplyr_dt = semi_join(master_dt, hall_of_fame_dt, by = "hofID"),
  times = 10
)
#> Unit: milliseconds
#>      expr  min    lq median    uq  max neval
#>  dplyr_df 0.87 0.878  0.897 0.914 1.08    10
#>  dplyr_dt 1.33 1.344  1.423 1.481 1.64    10

microbenchmark(
  dplyr_df = anti_join(master_df, hall_of_fame_df, by = "hofID"),
  dplyr_dt = anti_join(master_dt, hall_of_fame_dt, by = "hofID"),
  times = 10
)
#> Unit: milliseconds
#>      expr  min   lq median   uq  max neval
#>  dplyr_df 1.21 1.39   1.43 1.48 1.52    10
#>  dplyr_dt 2.32 2.71   2.82 3.00 3.50    10