Если верить стереотипам, то язык R – это что-то узкоспециализированное для статистики и машинного обучения. Второй стереотип – что код на чистом R не очень быстрый: во-первых, потому что интерпретируемый, во-вторых, потому что исполняется последовательно. Безусловно, стереотипы имеют какую-то связь с реальностью, иначе бы их не существовало, но на то они и стереотипы, что дают экстремально упрощённую картину мира, в которой теряется много деталей. В частности, сегодня я хочу поделиться удивительно простым способом, как добавить параллельность в R и кратно ускорить выполнение уже имеющегося кода без необходимости внесения в него каких-либо серьёзных изменений. Всё это делается буквально за пару минут.


Допустим, у нас есть матрица или таблица данных, содержащая некоторое количество строк и столбцов, и мы хотим для каждой из строк выполнить какое-то однотипное вычисление. Например, посчитать сумму квадратов её значений. Логично вынести расчёты в функцию и вызывать её для каждой из строк.


Исходные данные:


a <- matrix(rnorm(500000, mean=0, sd=2), 100000, 50)

Функция:


sum.of.squares <- function(n) {
  n_sq <- sapply(n, function(x) x^2)
  sum(n_sq)
}

Можно просто перебрать циклом строки, и к каждой из строк применить эту функцию, но это не самый рекомендуемый способ для R. Вычисления для каждой из строк будут выполняться последовательно, все расчёты будут идти на одном ядре. Такой код действительно не очень производителен. На всякий случай запишем такой вариант и замерим время его выполнения:


b <- vector()
for(i in 1:dim(a)[1]) {
  b[i] <- sum.of.squares(a[i,])
}

Замеряем время выполнения:


b <- vector()
start_time <- Sys.time()
for(i in 1:dim(a)[1]) {
  b[i] <- sum.of.squares(a[i,])
}
timediff <- difftime(Sys.time(), start_time)
cat("Расчёт занял: ", timediff, units(timediff))

Получаем:


Расчёт занял:  4.474074 secs

Будем использовать это время как некоторую отправную точку для сравнения с другими способами.


Выполнение заданной операции над каждой из строк некоторого объекта можно записать в векторизованной форме. Для этого в R есть специальное семейство функций apply(). Для тех кто не знаком с ними, могу порекомендовать статьи по их использованию: 1, 2. Данные функции примечательны тем, что позволяют компактно описать однотипные действия без использования циклов. Даже в нашей функции расчёта суммы квадратов я для краткости использовал одну из таких функций – sapply(), которая выполняет некоторое заданное действие для каждого из элементов поступающего на вход набора значений. В нашем случае – вычисляет квадрат каждого из значений. Если же говорить про применение данной функции к матрице исходных данных, то с использованием функции apply() это можно переписать как:


b <- apply(a, 1, function(x) sum.of.squares(x))

Действительно, очень компактная запись. Но если замерить время её исполнения, то получим примерно то же, что и у цикла:


start_time <- Sys.time()
b <- apply(a, 1, function(x) sum.of.squares(x))
timediff <- difftime(Sys.time(),start_time)
cat("Расчёт занял: ", timediff, units(timediff))

Результат:


Расчёт занял: 4.484046 secs

К слову, уже первый знак после запятой может от запуска к запуску немного меняться. Но суть ясна: это примерно тот же результат, что и с циклом.


Казалось бы, мы возвращаемся к исходному положению, что R считает всё в одном потоке, и от этого значительная часть ресурсов компьютера простаивает без дела. В некотором смысле так и есть: в данный момент функции семейства apply(), хотя потенциально их и можно распараллелить, выполняются последовательно. Но если рассмотреть ситуацию немного шире, то уже сейчас идёт работа, чтобы сделать их параллельными. Более того, уже сейчас эти функции из будущего можно загрузить и использовать вместо обычных функций семейства apply(). Помимо непосредственно функции apply() распараллеливаются также функции by(), eapply(), lapply(), Map(), .mapply(), mapply(), replicate(), sapply(), tapply(), vapply(). Со временем параллельные реализации этих функций заменят текущие, а пока что для их использования необходимо поставить специальный пакет future_apply:


install.packages("future.apply") 

Буквально несколько секунд – и вот он установлен. После этого его надо подключить и указать, что расчёт пойдёт в многоядерном режиме:


library("future.apply")
plan(multiprocess)

Есть различные параметры запуска. Например, поддерживается расчёт на распределённом кластере. Текущие настройки параллельного расчёта можно посмотреть используя команду future::plan(). Чтобы пользоваться возможностями параллельного расчёта, код основной программы менять практически никак не нужно, достаточно дописать к функциям apply приставку "future_". Получим:


b <- future_apply(a, 1, function(x) sum.of.squares(x))

Запустим с замером времени:


start_time <- Sys.time()
b <- future_apply(a, 1, function(x) sum.of.squares(x))
timediff <- difftime(Sys.time(),start_time)
cat("Расчёт занял: ", timediff, units(timediff))

Получаем уже совсем другое время выполнения:


Расчёт занял:  1.283569 secs

В моём случае расчёт производился на Intel Core i7-8750H с 12 логическими ядрами. Полученное ускорение конечно не 12-кратное, но всё же довольно приличное.


В зависимости от решаемой задачи степень ускорения может существенно меняться. В некоторых случаях, например, на маленьких массивах, накладные расходы от распараллеливания могут превышать выигрыш от параллельного исполнения, и вычисления могут даже замедляться, так что везде без разбора бездумно использовать параллельные версии функций точно не стоит. Например, не стоит распараллеливать таким способом расчёт квадратов значений внутри вызываемой функции, используя future_sapply, это приведёт к сильному замедлению. Но для обработки массивов с большим числом строк подобное распараллеливание почти всегда даёт достаточно ощутимое ускорение. В данном случае – примерно четырёхкратное. Если производить аналогичные вычисления не для матрицы, а для таблицы данных, например, предварительно преобразовав в неё исходную матрицу (a <- data.frame(a)), то расчёт в целом в несколько раз замедлится, но разница между последовательным и параллельным выполнением будет уже примерно в 8 раз. Довольно ощутимая разница.


Ну вот, собственно, и всё. Способ достаточно простой. Для меня, когда я про него узнал, это была просто находка. Справедливо ли утверждение, что нынешний R не поддерживает параллельные вычисления? Зависит от точки зрения на этот вопрос, от строгости его постановки. Но в некотором смысле можно считать, что всё-таки поддерживает.