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

Posted

技术标签:

【中文标题】对于高基数分组,为啥使用 dplyr 管道 (%>%) 比等效的非管道表达式慢?【英文标题】:Why is using dplyr pipe (%>%) slower than an equivalent non-pipe expression, for high-cardinality group-by?对于高基数分组,为什么使用 dplyr 管道 (%>%) 比等效的非管道表达式慢? 【发布时间】:2016-06-26 06:54:55 【问题描述】:

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

library(dplyr)
library(microbenchmark)

set.seed(0)
dummy_data <- dplyr::data_frame(
  id=floor(runif(10000, 1, 10000))
  , label=floor(runif(10000, 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

为什么%&gt;% 在这种情况下会慢很多?有没有更好的写法?

编辑:

我使数据框更小,并将 Moody_Mudskipper 的建议纳入基准测试。

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

Unit: milliseconds
      expr       min        lq      mean    median        uq      max neval
    nopipe  59.91252  70.26554  78.10511  72.79398  79.29025 214.9245   100
  magrittr 469.09573 525.80084 568.28918 558.05634 590.48409 767.4647   100
 magrittr2  84.06716  95.20952 106.28494 100.32370 110.92373 241.1296   100
  fastpipe  93.57549 103.36926 109.94614 107.55218 111.90049 162.7763   100

【问题讨论】:

你不应该离开单位。在这种情况下,您可能在谈论毫秒甚至微秒。 如果您要比较两个 sn-ps,请在同一 microbenchmark 调用中运行它们:microbenchmark(code1 = ...first snippet... , code2 = ...second snippet... )(或不带名称),以便您可以直接比较时间。跨度> 所以,关于毫秒或微秒的评论完全不合时宜。请参阅下面的答案。 【参考方案1】:

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

您可以从一个点开始创建一个:. %&gt;% head() %&gt;% dim(),这是一种编写函数的紧凑方式。

当使用iris %&gt;% head() %&gt;% dim() 等标准管道调用时,函数链. %&gt;% head() %&gt;% dim() 仍将首先计算,导致开销。

功能链有点奇怪:

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

当您查看调用 . %&gt;% head() %&gt;% dim() 时,它实际上解析为 `%&gt;%`( `%&gt;%`(., head()), dim())。基本上,整理东西需要一些需要一些时间的操作。

另一件需要一点时间的事情是处理不同的 rhs 情况,例如iris %&gt;% headiris %&gt;% head(.)iris %&gt;% 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 月 5 日创建

这里的时钟速度是 13. 倍。

我将它包含在我的实验性fastpipe 包中,命名为%&gt;&gt;%

现在,我们还可以通过对您的调用进行简单更改来直接利用函数链的强大功能:

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

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

这是一个包含这 2 个新解决方案的基准:

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

【讨论】:

这个例子似乎与问题中的原始用例完全脱节。您将如何调整原始示例以使用您的 fastpipe? 它会变成microbenchmark(dummy_data %.% group_by(., id) %.% summarise(., label %.% unique(.) %.% list(.))。好点,当我有机会时,我会添加一个包含它的基准! 再读一遍也很有可能使用summarize_at() onlabel 和函数链. %&gt;% unique %&gt;% list 会大大提高速度。 两者都具有竞争力!我已编辑问题以将其作为基准。 有趣的是,magrittr 最终还是更快。它起作用的原因是函数链只被解析一次,然后在内部它只是在一个循环中一个接一个地应用函数,非常接近你的基本解决方案。由于对每个循环实例和每个管道都进行了评估/替换,我的快速管道增加了一点开销。【参考方案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

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

【讨论】:

但是这个问题仍然是一个很好的问题并且提出了有效的投诉。如果原因是对于非常高的基数变量,管道比非管道慢,那么 dplyr 至少应该检测并标记(事后)?只需比较n_distinct(id)/length(id) &gt; threshold,比如 0.5,如果是,则发出警告。期望用户花时间四处寻找另一个属于 group-by 的不那么高的基数似乎有点不合理,不是吗?【参考方案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

【讨论】:

你能用文字解释一下你的例子说明了什么吗?在我看来,您发现的是(正如@Spacedman 的回答所说)当您运行的操作花费大量时间时(在您的第二个示例中dp),管道和非管道之间的差异消失了是“更快”,但微不足道) @BenBolker OP 问题的实际答案比这更微妙一些;看我的回答。 @BenBolker 我的观点是,当涉及的元素数量很大时,管道对于具有少量元素的向量/矩阵/数据帧可能会很慢,但与基本 R 相似/更快。我尝试过不同的代码,使用管道时似乎元素数量和速度之间存在关系。【参考方案4】:

在编写与以前“可忽略”的时间相关的单行代码时,在现实世界的完整应用程序中可能可以忽略不计的影响变得不可忽略。我怀疑如果你分析你的测试,那么大部分时间将在summarize 子句中,所以让微基准测试类似的东西:

> 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 时用几百个数据点消除,但那是另一回事了....

【讨论】:

FWIW, library(pipeR); z %&gt;&gt;% unique %&gt;&gt;% list 做同样的事情,比 magrittr 版本快大约 4 倍,但仍然比纯基础版本慢。 从功能包来看,Compose 也更快 library(functional); microbenchmark(mag = z %&gt;% unique %&gt;% list, base = list(unique(z)), fun = Compose(unique,list)(z))(不过仍然是 base 的 6 倍)。

以上是关于对于高基数分组,为啥使用 dplyr 管道 (%>%) 比等效的非管道表达式慢?的主要内容,如果未能解决你的问题,请参考以下文章

使用 dplyr 管道更改列值

dplyr 管道 - 如何更改原始数据框

使用dplyr计数和分组

dplyr 管道中嵌套函数的执行顺序

R语言dplyr包使用dplyr函数使用group_by函数summarise函数和mutate函数计算分组占比实战

使用管道运算符时将 dplyr 重命名应用于所有列