Почему использование dplyr pipe (%>%) медленнее, чем эквивалентное нетрубное выражение, для группы с высокой производительностью?

Я думал, что, вообще говоря, использование %>% не окажет заметного влияния на скорость. Но в этом случае он работает в 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))

Без pipes:

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

С pipeкой:

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

Почему %>% намного медленнее в этой ситуации? Есть ли лучший способ написать это?

EDIT:

Я уменьшил размер фрейма данных и включил предложения 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

Ответ 1

То, что может быть незначительным эффектом в реальном приложении в полном объеме, становится пренебрежимо малым при написании однострочных, которые зависят от времени от ранее "незначительного". Я подозреваю, что если вы профиль тестов, то большая часть времени будет находиться в 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-вызов в тот же, который используют оценки функций, а затем оценивать их. Так что это должно быть медленнее. Насколько зависит от того, насколько быстрыми являются функции. Звонки на unique и list довольно быстрые в R, поэтому вся разница здесь в накладных расходах.

Профилирование таких выражений показало, что большую часть времени тратится на функции труб:

                         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

Если вы просто назовете функции как предназначенные для камеры, то 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 с несколькими сотнями точек данных, но это другая история....

Ответ 2

Но вот что я узнал сегодня. Я использую 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

Ответ 3

Итак, я наконец добрался до выражения выражений в вопросе 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))

Это заняло так много времени, что я думал, что наткнулся на ошибку, и принудительно прервал Р.

Повторяя, с сокращением числа повторений, я получил следующие моменты:

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

Теперь все выглядит намного более равным.

Ответ 4

Magrittr pipes закодированы вокруг концепции функциональной цепи.

Вы можете создать его, начав с точки: . %>% head() %>% dim(), это компактный способ написания функции.

При использовании стандартного конвейерного вызова, такого как iris %>% head() %>% dim(), функциональная цепочка . %>% head() %>% dim() будет по-прежнему вычисляться первой, вызывая издержки.

Функциональная цепочка немного странного животного:

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

Когда вы смотрите на вызов . %>% head() %>% dim(), он фактически анализируется как '%>%'( '%>%'(., head()), dim()). По сути, для сортировки вещей требуются некоторые манипуляции, которые занимают немного времени.

Еще одна вещь, которая занимает немного времени, - это обрабатывать различные случаи относительной влажности, такие как iris %>% head, iris %>% head(.), iris %>% {head(.)} и т.д., Чтобы вставить точку в нужном месте, когда это уместно.

Вы можете построить очень быструю pipeу следующим образом:

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

Он будет намного быстрее, чем pipe 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

Created on 2019-10-05 by the reprex package (v0.3.0)

Здесь это было в 13 раз быстрее.

Я включил его в свой экспериментальный пакет fastpipe, названный %>>%.

Теперь мы также можем напрямую использовать возможности функциональных цепочек, просто изменив свой вызов:

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

Это будет намного быстрее, потому что функциональная цепочка анализируется только один раз, а затем внутренне она просто применяет функции одну за другой в цикле, очень близко к вашему базовому решению. Моя быстрая pipe, с другой стороны, все еще добавляет небольшие издержки из-за eval/замены, сделанной для каждого экземпляра цикла и каждого канала.

Вот эталон, включающий эти 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