对于高基数分组,为什么使用 dplyr 管道 (%>%) 比等效的非管道表达式慢?
Why is using dplyr pipe (%>%) slower than an equivalent non-pipe expression, for high-cardinality group-by?
我认为一般来说使用 %>%
不会对速度产生明显影响。但在这种情况下,它的运行速度慢了 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
当编写与以前 "negligible" 时间相关的单行代码时,在真实世界的完整应用程序中可能可以忽略不计的影响变得不可忽略。我怀疑如果你分析你的测试,那么大部分时间都会在 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 中调用 unique
和 list
非常快,所以这里的全部区别在于管道开销。
像这样分析表达式表明我大部分时间都花在了管道函数上:
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
时消除,但这是另一回事....
但这是我今天学到的东西。我正在使用 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
所以,我终于抽出时间运行调整 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
现在一切看起来都平等多了。
magrittr 的管道是围绕功能链的概念进行编码的。
您可以创建一个以点开头的函数:. %>% head() %>% dim()
,这是一种编写函数的紧凑方式。
当使用 iris %>% head() %>% dim()
等标准管道调用时,函数链 . %>% head() %>% dim()
仍将首先计算,导致开销。
功能链有点奇怪:
(. %>% head()) %>% dim
#> NULL
当您查看调用 . %>% head() %>% dim()
时,它实际上解析为 `%>%`( `%>%`(., head()), dim())
。基本上,整理东西需要一些操作,需要一些时间。
另一件需要一点时间的事情是处理rhs的不同情况,例如在iris %>% head
、iris %>% 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)
它会快得多,因为函数链只被解析一次,然后在内部它只是在一个循环中一个接一个地应用函数,非常接近你的基本解决方案。另一方面,由于对每个循环实例和每个管道进行的评估/替换,我的快速管道仍然会增加一些开销。
这是一个包含这 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
我认为一般来说使用 %>%
不会对速度产生明显影响。但在这种情况下,它的运行速度慢了 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
当编写与以前 "negligible" 时间相关的单行代码时,在真实世界的完整应用程序中可能可以忽略不计的影响变得不可忽略。我怀疑如果你分析你的测试,那么大部分时间都会在 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 中调用 unique
和 list
非常快,所以这里的全部区别在于管道开销。
像这样分析表达式表明我大部分时间都花在了管道函数上:
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
时消除,但这是另一回事....
但这是我今天学到的东西。我正在使用 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
所以,我终于抽出时间运行调整 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
现在一切看起来都平等多了。
magrittr 的管道是围绕功能链的概念进行编码的。
您可以创建一个以点开头的函数:. %>% head() %>% dim()
,这是一种编写函数的紧凑方式。
当使用 iris %>% head() %>% dim()
等标准管道调用时,函数链 . %>% head() %>% dim()
仍将首先计算,导致开销。
功能链有点奇怪:
(. %>% head()) %>% dim
#> NULL
当您查看调用 . %>% head() %>% dim()
时,它实际上解析为 `%>%`( `%>%`(., head()), dim())
。基本上,整理东西需要一些操作,需要一些时间。
另一件需要一点时间的事情是处理rhs的不同情况,例如在iris %>% head
、iris %>% 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)
它会快得多,因为函数链只被解析一次,然后在内部它只是在一个循环中一个接一个地应用函数,非常接近你的基本解决方案。另一方面,由于对每个循环实例和每个管道进行的评估/替换,我的快速管道仍然会增加一些开销。
这是一个包含这 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