Почему использование 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))

Без трубы:

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

person logworthy    schedule 11.03.2016    source источник
comment
Вы не должны отказываться от единиц. В этом случае вы, вероятно, говорите о миллисекундах или даже микросекундах.   -  person Hong Ooi    schedule 11.03.2016
comment
Если вы пытаетесь сравнить два фрагмента кода, запустите их оба в одном вызове microbenchmark: microbenchmark(code1 = { ...first snippet... }, code2 = { ...second snippet... }) (или без имен), чтобы вы могли напрямую сравнивать время.   -  person alistaire    schedule 11.03.2016
comment
Итак, этот комментарий о милли- или микросекундах был совершенно неуместен. Смотрите мой ответ ниже.   -  person Hong Ooi    schedule 02.05.2018


Ответы (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 в тот же самый, который используют оценки функций, а затем оценивать их. Поэтому он должен работать медленнее. Насколько сильно зависит от скорости работы функций. Вызовы 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 с несколькими сотнями точек данных, но это уже другая история...

person Spacedman    schedule 11.03.2016
comment
FWIW, library(pipeR); z %>>% unique %>>% list делает то же самое и примерно в 4 раза быстрее, чем версия magrittr, хотя все еще медленнее, чем чистая базовая версия. - person BrodieG; 11.03.2016
comment
Из функционального пакета Compose также быстрее library(functional); microbenchmark(mag = z %>% unique %>% list, base = list(unique(z)), fun = Compose(unique,list)(z)) (хотя все еще в 6 раз медленнее, чем базовый). - person Frank; 11.03.2016

Итак, я, наконец, дошел до запуска выражений в вопросе ОП:

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

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

person Hong Ooi    schedule 02.05.2018
comment
Но этот вопрос по-прежнему является хорошим уловом и вызывает обоснованную жалобу. Если причина в том, что конвейер работает медленнее, чем не-канал для переменных с очень высокой кардинальностью, то dplyr должен, по крайней мере, обнаружить и отметить это (постфактум)? Просто сравните n_distinct(id)/length(id) > threshold, скажем, 0,5, и предупредите, если это так. Ожидать, что пользователь потратит время на поиск другого категориального элемента не столь высокой кардинальности для группировки, кажется немного неразумным, не так ли? - person smci; 03.05.2018

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

Хотя, если х = 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
person RgrNormand    schedule 02.05.2018
comment
можете ли вы объяснить словами, что иллюстрирует ваш пример? Мне кажется, что вы узнали, что (как говорится в ответе @Spacedman) разница между конвейерной и неконвейерной связью исчезает, когда выполняемая вами операция занимает нетривиальное количество времени (во втором примере dp быстрее , но на мизерную сумму) - person Ben Bolker; 02.05.2018
comment
@BenBolker Фактический ответ на вопрос ОП немного тоньше; см. мой ответ. - person Hong Ooi; 02.05.2018
comment
@BenBolker Я хочу сказать, что конвейеры могут быть медленными для векторов / матриц / кадров данных с небольшим количеством элементов, но похожи / быстрее, чем базовый R, когда количество задействованных элементов велико. Я пробовал с разными кодами, и кажется, что при использовании каналов существует связь между количеством элементов и скоростью. - person RgrNormand; 02.05.2018

Канал magrittr основан на концепции функциональной цепочки.

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

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

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

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

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

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

Создано 05 октября 2019 г. с помощью пакета reprex (v0.3.0)

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

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

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

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

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

Вот тест, включающий эти 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
person Moody_Mudskipper    schedule 05.10.2019
comment
Этот пример кажется довольно далеким от исходного варианта использования в вопросе. Как бы вы адаптировали исходный пример, чтобы использовать ваш fastpipe? - person logworthy; 08.10.2019
comment
Это станет microbenchmark(dummy_data %.% group_by(., id) %.% summarise(., label %.% unique(.) %.% list(.)). Хорошо, я добавлю тест, включая его, когда у меня будет такая возможность! - person Moody_Mudskipper; 08.10.2019
comment
При повторном чтении также высока вероятность того, что использование summarize_at() наlabel с функциональной цепочкой . %>% unique %>% list значительно улучшит скорость. - person Moody_Mudskipper; 08.10.2019
comment
И то, и другое было конкурентоспособным! Я отредактировал вопрос, включив их в качестве ориентиров. - person logworthy; 08.10.2019
comment
Интересно, что magrittr все же заканчивается быстрее. Причина, по которой это работает, заключается в том, что функциональная цепочка анализируется только один раз, а затем внутри она просто применяет функции одну за другой в цикле, очень близком к вашему базовому решению. Мой быстрый канал добавляет небольшие накладные расходы из-за eval / replace, выполняемого для каждого экземпляра цикла и каждого канала. - person Moody_Mudskipper; 08.10.2019
comment
@logworthy Я улучшил свой ответ и добавил тесты, я думаю, что они здесь лучше, чем в вашем вопросе. - person Moody_Mudskipper; 08.10.2019