为什么在高基数分组时使用dplyr管道(%>%)比等效的非管道表达式慢?

20

我本以为一般情况下使用%>%不会对速度产生明显影响。但在这种情况下,它运行得慢了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

为什么在这种情况下%>%很慢?有更好的写法吗?

编辑:

我缩小了数据框并将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

7
不应该省略单位。在这种情况下,您可能在谈论毫秒甚至微秒。 - Hong Ooi
5
如果您想比较两个代码片段,请在同一个 microbenchmark 调用中运行它们:microbenchmark(code1 = { ...第一个代码片段... }, code2 = { ...第二个代码片段... })(或者不使用名称),这样您就可以直接比较时间。 - alistaire
1
所以,有关毫秒或微秒的评论完全是错误的。请看下面我的答案。 - Hong Ooi
4个回答

34

在实际的应用程序中可能是可以忽略不计的影响,在编写依赖于以前“可忽略”项的时间相关的单行代码时,这种影响变得非常重要。我怀疑如果您对测试进行分析,大部分时间将用于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的调用中,这种开销很可能会被消除,但这是另一回事...

7
这句话的意思是,library(pipeR); z %>>% unique %>>% listmagrittr版本相比,执行相同的操作速度快大约4倍,但仍比纯基础版本慢。请注意,此处不提供解释或其他内容。 - BrodieG
5
从功能包中看,Compose也更快 library(functional); microbenchmark(mag = z %>% unique %>% list, base = list(unique(z)), fun = Compose(unique,list)(z))(尽管仍比基础库慢6倍)。 - Frank

4

所以,我最终开始运行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))

这个过程花费的时间太长了,我以为遇到了一个bug,所以强制中断了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

时间单位为秒!毫秒或微秒都不够用了。难怪在默认的times=100情况下,R一开始看起来像是挂了。

但为什么会这么长时间呢?首先,数据集构建的方式,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) > threshold,例如0.5,并在此情况下发出警告。期望用户花费时间寻找另一个不太高基数的分类变量进行分组似乎有点不合理,不是吗? - smci

3

今天我学到了一些有关IT技术的知识。我正在使用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
你能用语言解释一下你的例子说明了什么吗?在我看来,你发现的是当你运行的操作需要较长时间时(如@Spacedman的答案所说),管道和非管道之间的差异消失了(在你的第二个例子中,dp更快,但只是微不足道的快)。 - Ben Bolker
@BenBolker,对于OP的问题,实际答案比那个要微妙一些;请看我的回答。 - Hong Ooi
1
@BenBolker 我的观点是,对于元素数量较少的向量/矩阵/数据框,管道可能会比较慢,但当涉及的元素数量很大时,它们与基本R相似或更快。我已经尝试了不同的代码,似乎在使用管道时元素数量和速度之间存在关系。 - RgrNormand

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包 (v0.3.0)于2019年10月05日创建的

在这里,它被计时为13倍速。

我将其包含在我的实验性fastpipe包中,命名为%>>%

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

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

这将会更快,因为函数链只被解析一次,然后在循环中依次应用函数,非常接近于基础解决方案。而我快速管道则由于每个循环实例和每个管道都需要进行eval/substitute操作,仍然会增加一些小的开销。

下面是包括这两个新解决方案的基准测试:

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? - logworthy
1
当执行microbenchmark(dummy_data %.% group_by(., id) %.% summarise(., label %.% unique(.) %.% list(.))时,它会变得更加高效。很好的建议,我会在有机会时添加一个基准测试! - moodymudskipper
1
再次阅读此内容,使用带有函数链“.%>% unique%>% list”的summarize_at()label上也有很高的可能性可以大大提高速度。 - moodymudskipper
这两个都是有竞争力的!我已经编辑了问题,将它们作为基准。 - logworthy
有趣的是,magrittr 仍然更快。它能够运作的原因是函数链只被解析一次,然后在一个循环中内部依次应用函数,非常接近于您的基本解决方案。我的快速管道由于每个循环实例和每个管道所执行的 eval/substitute 而增加了一些小的开销。 - moodymudskipper
@logworthy 我改进了我的回答并添加了基准测试,我认为它们在这里比你的问题更好。 - moodymudskipper

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接