为什么使用dplyr管道(%>%)比同等的非管道表达式慢,对于高基数分组?

时间:2016-03-11 05:55:50

标签: r performance dplyr magrittr cardinality

我认为一般来说使用%>%不会对速度产生明显影响。但在这种情况下,它运行速度慢了4倍。

library(dplyr)
library(microbenchmark)

set.seed(0)
dummy_data <- dplyr::data_frame(
  id=floor(runif(100000, 1, 100000))
  , label=floor(runif(100000, 1, 4))
)

microbenchmark(dummy_data %>% group_by(id) %>% summarise(list(unique(label))))
microbenchmark(dummy_data %>% group_by(id) %>% summarise(label %>% unique %>% list))

没有管道:

min       lq     mean   median       uq      max neval
1.691441 1.739436 1.841157 1.812778 1.880713 2.495853   100

使用烟斗:

min       lq     mean   median       uq      max neval
6.753999 6.969573 7.167802 7.052744 7.195204 8.833322   100

为什么%>%在这种情况下会这么慢?有没有更好的方法来写这个?

4 个答案:

答案 0 :(得分:30)

在编写与以前“可忽略的”时间相关的单行时,在现实世界的完整应用程序中可能产生的微不足道的影响变得不可忽视。我怀疑如果您对测试进行概要分析,那么大部分时间都在summarize子句中,所以让microbenchmark类似于:

> set.seed(99);z=sample(10000,4,TRUE)
> microbenchmark(z %>% unique %>% list, list(unique(z)))
Unit: microseconds
                  expr     min      lq      mean   median      uq     max neval
 z %>% unique %>% list 142.617 144.433 148.06515 145.0265 145.969 297.735   100
       list(unique(z))   9.289   9.988  10.85705  10.5820  11.804  12.642   100

这对你的代码做了一些不同的事情,但说明了这一点。管道比较慢。

因为管道需要将R的调用重构为函数评估正在使用的调用,然后对它们进行评估。所以它更慢。多少取决于功能的快速程度。在{R}中调用uniquelist非常快,因此这里的全部差异就是管道开销。

这样的分析表达式表明我大部分时间花在管道函数上:

                         total.time total.pct self.time self.pct
"microbenchmark"              16.84     98.71      1.22     7.15
"%>%"                         15.50     90.86      1.22     7.15
"eval"                         5.72     33.53      1.18     6.92
"split_chain"                  5.60     32.83      1.92    11.25
"lapply"                       5.00     29.31      0.62     3.63
"FUN"                          4.30     25.21      0.24     1.41
 ..... stuff .....

然后在大约第15位的某个地方完成了真正的工作:

"as.list"                      1.40      8.13      0.66     3.83
"unique"                       1.38      8.01      0.88     5.11
"rev"                          1.26      7.32      0.90     5.23

然而,如果您只是将这些函数称为Chambers,R直接指向它:

                         total.time total.pct self.time self.pct
"microbenchmark"               2.30     96.64      1.04    43.70
"unique"                       1.12     47.06      0.38    15.97
"unique.default"               0.74     31.09      0.64    26.89
"is.factor"                    0.10      4.20      0.10     4.20

因此经常引用的建议是,管道在你的大脑认为是连锁的命令行上是可以的,但不是在可能对时间至关重要的功能中。在实践中,这个开销可能会在一次调用glm时被几百个数据点消灭,但这是另一个故事....

答案 1 :(得分:3)

但这是我今天学到的东西。我使用的是R 3.5.0。

代码x = 100(1e2)

library(microbenchmark)
library(dplyr)

set.seed(99)
x <- 1e2
z <- sample(x, x / 2, TRUE)
timings <- microbenchmark(
  dp = z %>% unique %>% list, 
  bs = list(unique(z)))

print(timings)

Unit: microseconds
 expr    min      lq      mean   median       uq     max neval
   dp 99.055 101.025 112.84144 102.7890 109.2165 312.359   100
   bs  6.590   7.653   9.94989   8.1625   8.9850  63.790   100

虽然,如果x = 1e6

Unit: milliseconds
 expr      min       lq     mean   median       uq      max neval
   dp 27.77045 31.78353 35.09774 33.89216 38.26898  52.8760   100
   bs 27.85490 31.70471 36.55641 34.75976 39.12192 138.7977   100

答案 2 :(得分:2)

所以,我终于开始在OP的问题中运行表达式了:

set.seed(0)
dummy_data <- dplyr::data_frame(
  id=floor(runif(100000, 1, 100000))
  , label=floor(runif(100000, 1, 4))
)

microbenchmark(dummy_data %>% group_by(id) %>% summarise(list(unique(label))))
microbenchmark(dummy_data %>% group_by(id) %>% summarise(label %>% unique %>% list))

这花了很长时间以至于我以为我遇到了一个错误,强行打断了R。

再次尝试,重复次数减少,我得到以下时间:

microbenchmark(
    b=dummy_data %>% group_by(id) %>% summarise(list(unique(label))),
    d=dummy_data %>% group_by(id) %>% summarise(label %>% unique %>% list),
    times=2)

#Unit: seconds
# expr      min       lq     mean   median       uq      max neval
#    b 2.091957 2.091957 2.162222 2.162222 2.232486 2.232486     2
#    d 7.380610 7.380610 7.459041 7.459041 7.537471 7.537471     2

时间是几秒钟!这么多毫秒或微秒。难怪R似乎最初挂了,默认值为times=100

但为什么要这么久?首先,构建数据集的方式是id列包含大约63000个值:

length(unique(dummy_data$id))
#[1] 63052

其次,正在汇总的表达式依次包含多个管道,每组分组数据都会相对较小。

这实际上是管道表达式的最坏情况:它被调用很多次,并且每次都在非常小的输入上运行。这导致了大量的开销,并且没有太多的计算来分摊该开销。

相比之下,如果我们只是切换正在分组和汇总的变量:

microbenchmark(
    b=dummy_data %>% group_by(label) %>% summarise(list(unique(id))),
    d=dummy_data %>% group_by(label) %>% summarise(id %>% unique %>% list),
    times=2)

#Unit: milliseconds
# expr      min       lq     mean   median       uq      max neval
#    b 12.00079 12.00079 12.04227 12.04227 12.08375 12.08375     2
#    d 10.16612 10.16612 12.68642 12.68642 15.20672 15.20672     2

现在一切看起来都更加平等。

答案 3 :(得分:1)

magrittr 的管道围绕功能链的概念进行编码。

您可以通过以点. %>% head() %>% dim()开头来创建一个,这是一种编写函数的紧凑方式。

在使用标准管道调用(例如iris %>% head() %>% dim())时,仍将首先计算功能链. %>% head() %>% dim(),从而导致开销。

功能链有点奇怪:

(. %>% head()) %>% dim
#> NULL

当您查看呼叫. %>% head() %>% dim()时,它实际上解析为`%>%`( `%>%`(., head()), dim())。基本上,整理出来的东西需要一些操作,这需要一些时间。

需要花费一些时间的另一件事是处理rhs的不同情况,例如iris %>% headiris %>% head(.)iris %>% {head(.)}等,在出现错误时在正确的位置插入点相关。

您可以通过以下方式构建非常快的管道:

`%.%` <- function (lhs, rhs) {
    rhs_call <- substitute(rhs)
    eval(rhs_call, envir = list(. = lhs), enclos = parent.frame())
}

它比magrittr的管道快得多,并且在边缘情况下实际上会表现得更好,但是将需要显式的点并且显然不支持功能链。

library(magrittr)
`%.%` <- function (lhs, rhs) {
  rhs_call <- substitute(rhs)
  eval(rhs_call, envir = list(. = lhs), enclos = parent.frame())
}
bench::mark(relative = T,
  "%>%" =
    1 %>% identity %>% identity() %>% (identity) %>% {identity(.)},
  "%.%" = 
    1 %.% identity(.) %.% identity(.) %.% identity(.) %.% identity(.)
)
#> # A tibble: 2 x 6
#>   expression   min median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <dbl>  <dbl>     <dbl>     <dbl>    <dbl>
#> 1 %>%         15.9   13.3       1        4.75     1   
#> 2 %.%          1      1        17.0      1        1.60

reprex package(v0.3.0)于2019-10-05创建

这里的时钟速度是以前的13倍。

我将其包含在名为%>>%的实验性fastpipe程序包中。

现在,我们还可以通过简单的更改直接利用功能链的功能:

dummy_data %>% group_by(id) %>% summarise_at('label', . %>% unique %>% list)

这将更快,因为功能链仅被解析了一次,然后在内部它只是一个循环接一个地应用函数,非常接近您的基本解决方案。另一方面,由于对每个循环实例和每个管道都进行了评估/替换,因此我的快速管道仍会增加少量开销。

这里有一个基准,包括这两个新解决方案:

microbenchmark::microbenchmark(
  nopipe=dummy_data %>% group_by(id) %>% summarise(label = list(unique(label))),
  magrittr=dummy_data %>% group_by(id) %>% summarise(label = label %>% unique %>% list),
  functional_chain=dummy_data %>% group_by(id) %>% summarise_at('label', . %>% unique %>% list),
  fastpipe=dummy_data %.% group_by(., id) %.% summarise(., label =label %.% unique(.) %.% list(.)),
  times = 10
)

#> Unit: milliseconds
#>              expr      min       lq     mean    median       uq      max neval cld
#>            nopipe  42.2388  42.9189  58.0272  56.34325  66.1304  80.5491    10  a 
#>          magrittr 512.5352 571.9309 625.5392 616.60310 670.3800 811.1078    10   b
#>  functional_chain  64.3320  78.1957 101.0012  99.73850 126.6302 148.7871    10  a 
#>          fastpipe  66.0634  87.0410 101.9038  98.16985 112.7027 172.1843    10  a