Невелике введення в паралельне програмування на R

Давайте поговоримо про використання та переваги паралельних обчислень в R.

Причина, по якій варто про це задуматися: змушуючи комп'ютер працювати (виконувати багато розрахунків одночасно), ми менше часу чекаємо результатів наших експериментів і можемо виконати ще. Це особливо важливо для аналізу даних (R як платформа зазвичай використовується саме для цієї мети), оскільки часто потрібно повторити варіації одного і того ж підходу, щоб щось дізнатися, вивести значення параметрів, оцінити стабільність моделі.

Зазвичай, для того, щоб змусити комп'ютер працювати, спочатку потрібно попрацювати самому аналітику, програмісту або творцеві бібліотеки, щоб організувати обчислення у вигляді, зручному для паралелізації. У кращому разі хтось вже зробив це за вас:
  • Хороші паралельні бібліотеки, наприклад, багатопотокові BLAS/LAPACK, включені до Revolution R Open (RRO, зараз Microsoft R Open) (дивитися тут).
  • Спеціалізовані паралельні розширення, надають свої власні високопродуктивні реалізації важливих процедур, наприклад, методи rx від RevoScaleR або методи h2o від h2o.ai.
  • Фреймворки абстрактної паралелізації, наприклад, Thrust/Rth.
  • Використання прикладних бібліотек R, пов'язаних з параллелизацией (зокрема, dw, boot і vtreat). (Деякі з цих бібліотек не використовують паралельні операції, поки не задано оточення для паралельного виконання.)

В доповнення до завдання, підготовленої для паралелізації, потрібно обладнання, яке буде її підтримувати. Наприклад:

  • Ваш власний комп'ютер. Зазвичай навіть ноутбуки мають чотири і більше ядер. Потенційне перевагу в роботі алгоритму в чотири рази швидше — величезна.
  • Графічні процесори (GPU). Багато мащины володіють однією або декількома потужними відеокартами. Для деяких обчислювальних завдань ці процесори в 10-100 разів швидше, ніж центральний процесор (CPU), зазвичай використовується для обчислень (подробности).
  • Комп'ютерні кластери (наприклад, Amazon ec2, Hadoop-сервера та ін).
Очевидно, паралельні обчислення R — велика і специфічна тема. Може здатися, що неможливо швидко навчитися цієї магії — як зробити ваші обчислення більш швидкими.

У цій статті ми покажемо, як можна прискорити ваші обчислення, скориставшись базовими можливостями мови R.

Для початку, повинна бути завдання, піддається паралелізації. Найбільш очевидні завдання такого роду містять повторювані дії (інтуїтивно зрозумілий термін — «природно паралельні»):

  • Підгонка параметрів моделі шляхом повторного застосування моделей (як це робиться в пакеті caret).
  • Застосування перетворення до великої кількості різних змінних (як це робиться в пакеті vtreat).
  • Оцінка якості моделі через крос-валідацію, бутстрэп або інші техніки вибірки з повтореннями.
Будемо припускати, що у нас вже є завдання з великою кількістю простих повторень. Зверніть увагу: ця концепція не завжди легко досяжна, але такий крок необхідний, щоб можна було почати процес.

Ось завдання, яке ми будемо використовувати в якості прикладу застосування моделі з передбаченням для невеликого набору даних. Завантажимо набір даних і деякі визначення в робочу область:

d <- iris # хай "d" посилається на один з вбудованих в R наборів даних
vars <- c('Sepal.Length','Sepal.Width','Petal.Length')
yName <- 'Вид'
yLevels <- sort(unique(as.character(d[[yName]])))
print(yLevels)

## [1] "setosa" "versicolor" "virginica"

(Будемо використовувати конвенцію, що будь-який рядок, що починається з "##" — виведення результату попередньої команди R.)

Ми зіткнулися з невеликою проблемою моделювання: змінна, яку ми намагаємося прогнозувати, має три рівня. Техніка моделювання, яку ми збиралися використовувати (
glm(family='біном')
) не вміє передбачати "поліноміальні результати" (хоча є бібліотеки, призначені для цього). Ми вирішили підійти до вирішення цієї задачі за допомогою стратегії "один-проти решти" і побудувати набір класифікаторів: кожен буде відокремлювати одну цільову змінну від інших. Ця задача — очевидний кандидат на параллелизацию. Давайте для читання обернем в функцію побудову однієї вихідної моделі:

fitOneTargetModel <- function(yName,yLevel,vars,data) {
formula <- paste('(',yName,'=="',yLevel,'") ~ ',
paste(vars,collapse=' + '),sep=")
glm(as.formula(formula),family=біном,data=data)
}

Тоді звичайний «серійний» спосіб побудувати всі моделі буде виглядати так:

for(yLevel in yLevels) {
print("*****")
print(yLevel)
print(fitOneTargetModel(yName,yLevel,vars,d))
}

Чи можна обернути нашу процедуру функцію однієї змінної (цей патерн називається карринг) і застосувати елегантну позначення R —
lapply()
:

worker <- function(yLevel) {
fitOneTargetModel(yName,yLevel,vars,d)
}
models <- lapply(yLevels,worker)
names(models) <- yLevels
print(models)

Перевага нотації
lapply()
полягає в тому, що вона підкреслює незалежність кожного обчислення, саме той вид ізоляції, який потрібен для паралелізації наших обчислень. Подумайте про циклі for в тому сенсі, що він дуже точно визначає обчислення, задаючи непотрібний порядок або послідовність операцій.

Реорганізація обчислення функціонально підготувала нас до застосування паралельної бібліотеки та здійснення обчислення паралельно. Спочатку розгорнемо паралельний кластер:

# Запуск паралельного кластера
parallelCluster <- parallel::makeCluster(parallel::detectCores())
print(parallelCluster)

## socket cluster with 4 nodes on host 'localhost'

Зверніть увагу, ми створили «кластер сокетів». Кластер сокетів — це дивно гнучкий «паралельно розподілений» кластер у першому наближенні. Кластер сокетів — грубе наближення в тому сенсі, що працює він порівняно повільно (робота буде розподілятися «неточно»), але він дуже гнучкий реалізації: багато ядер на одній машині, багато ядер на декількох машинах в одній мережі, поверх інших систем, наприклад кластера MPI (message passing interface — протокол передачі повідомлень).

На цьому місці припускаємо, що код нижче буде працювати (тут подробиці
tryCatch
).

tryCatch(
models <- parallel::parLapply(parallelCluster,
yLevels,worker),
error = function(e) print(e)
)

## <simpleError in checkForRemoteErrors(val):
## 3 nodes produced errors; first error: 
## could not find function "fitOneTargetModel">

Замість результатів отримали помилку "
could not find function "fitOneTargetModel">.
"

Проблема: у кластері сокетів аргументи
parallel::parLapply
копіюються в кожен вузол обробки з сокету зв'язку. Однак, цілісність поточного оточення (в нашому випадку так званого «глобального оточення») не копіюється (повертаються тільки значення). Тому наша функція
worker()
при перенесенні на паралельні вузли повинна мати інше контакти (оскільки вона не може вказувати на наше оточення виконання), і виявляється, що нове замикання більше не містить посилань на необхідні значення
yName
,
vars
,
d
та
fitOneTargetModel
. Це сумно, але має сенс. R використовує всі оточення для реалізації концепції замикань, і R не може знати, які значення в даному оточенні фактично будуть потрібні даної функції.

Отже, ми знаємо, що не так. Як це виправити? Ми виправимо використанням оточення, відмінного від глобального, щоб перенести туди потрібні нам значення. Найлегший спосіб зробити це — використовувати своє власне замикання. Щоб цього домогтися, обернем весь процес в функцію (і будемо запускати її у контрольованому оточенні). Код нижче працює:

# побудова функції однієї змінної, яку ми будемо параллелизировать
mkWorker <- function(yName,vars,d) {
# переконаємося, що всі три потрібні змінні 
# доступні в цьому середовищі
force(yName)
force(vars)
force(d)
# визначимо кожну функцію, яка потрібна нашій 
# функції worker в цьому оточенні
fitOneTargetModel <- function(yName,yLevel,vars,data) {
formula <- paste('(',yName,'=="',yLevel,'") ~ ',
paste(vars,collapse=' + '),sep=")
glm(as.formula(formula),family=біном,data=data)
}
# Нарешті: визначити і повернути нашу функцію worker.
# "Замикання" функції worker 
# (де вона шукає незв'язані змінні) - 
# оточення активації/виконання mkWorker,
# а не глобальне оточення, як зазвичай.
# Паралельна бібліотека перемістить 
# це оточення (чого вона не робить
# з глобальним оточенням).
worker <- function(yLevel) {
fitOneTargetModel(yName,yLevel,vars,d)
}
return(worker)
}

models <- parallel::parLapply(parallelCluster,yLevels,
mkWorker(yName,vars,d))
names(models) <- yLevels
print(models)

Код вище працює, тому що ми перемістили потрібні нам значення в нове оточення виконання і визначили функцію, яку зібралися використовувати, безпосередньо в цьому оточенні. Очевидно, долати кожну функцію, коли вона нам потрібна — громіздко і витратно (хоча ми могли передати її в обгортку, як це робилося з іншими значеннями). Більш гнучкий патерн такий: використовувати допоміжну функцію "
bindToEnv
" для виконання деякої частини роботи. З
bindToEnv
код виглядає так.

source('bindToEnv.R') # Завантажити з: http://winvector.github.io/Parallel/bindToEnv.R
# побудова функції однієї змінної, яку ми будемо параллелизировать
mkWorker <- function() {
bindToEnv(objNames=c('yName','vars','d','fitOneTargetModel'))
function(yLevel) {
fitOneTargetModel(yName,yLevel,vars,d)
}
}

models <- parallel::parLapply(parallelCluster,yLevels,
mkWorker())
names(models) <- yLevels
print(models)

Патерн вище лаконічний і добре працює. Кілька застережень, які варто тримати в голові:

  • Пам'ятайте, кожен паралельний worker — віддалене оточення. Переконайтеся, що потрібні бібліотеки на кожній віддаленій машині.
  • Неосновні бібліотеки, завантажені в початкове оточення, необов'язково завантажені в видалені. Має сенс використовувати позначення з пакетами, наприклад,
    stats::glm()
    при виклику функцій з бібліотек (виклик
    library(...)
    на кожному віддаленому вузлі надлишковий).
  • Наша функція
    bindToEnv
    сама по собі безпосередньо змінює оточення переданих їй функцій (щоб вони могли звертатися до значень, які ми переносимо). Це може викликати додаткові проблеми з тими окружениями, до яких застосовувався карринг. Тут можна знайти деякі способи обійти цю проблему.
Про це варто задуматися. Однак, думаю, ви вирішите, що додавання восьми рядків оберточного/стереотипного коду в повній мірі гідно чотириразового або більше прискорення.

Також: по завершенні роботи не забудьте прибрати посилання на кластер:

# Акуратне вимикання кластера
if(!is.null(parallelCluster)) {
parallel::stopCluster(parallelCluster)
parallelCluster <- c()
}

На цьому ми закінчимо. В цій статті мова піде про те, як побудувати кластери сокетів на декількох машинах і на Amazon ec2.

Сама по собі функція
bindToEnv
досить проста:

#' Копіювання аргументів в оточення і перепривязка замикання будь-якої функції до bindTargetEnv.
#' 
#' http://winvector.github.io/Parallel/PExample.html - приклад використання.
#' 
#' 
#' Використовується для передачі даних з функцією в ситуаціях, подібних до паралельного виконання 
#' (коли глобальне оточення буде недоступне). Зазвичай викликається всередині 
#' функції, що задає функцію-worker для передачі паралельним процесам
#' (щоб у нас було замикання, з яким можна працювати).
#' 
#' @param bindTargetEnv - оточення, до якого здійснюється приєднання
#' @param objNames - додаткові імена, які потрібно знайти в батьківському оточенні і прив'язати
#' @param doNotRebind - імена функцій, замикання яких НЕ потрібно прив'язувати
bindToEnv <- function(bindTargetEnv=parent.frame(),objNames,doNotRebind=c()) {
# Прив'язка значень до оточення
# і передача всіх функцій в це оточення
for(var in objNames) {
val <- get(var,envir=parent.frame())
if(is.function(val) && (!(var %in% doNotRebind))) {
# заміна замикання функції в цьому оточенні на цільове (НЕБЕЗПЕЧНО)
environment(val) <- bindTargetEnv
}
# прив'язка об'єкта до цільового оточенню, тільки після можливої зміни
assign(var,val,envir=bindTargetEnv)
}
}

Її також можна завантажити звідси.

Один з недоліків використання паралелізації таким чином — завжди може знадобитися ще одна функція або дане. Один із способів обійти це — використовувати команду R
ls()
, щоб побудувати список імен, які потрібно передати. Особливо ефективно зберігати результати
ls()
відразу після вихідних файлів з функціями і важливими глобальними змінними. Без будь-якої стратегії тут додавання елементів в списки — біль.

Для великих масштабів: не особливо докладні інструкції по запуску декількох машин-R-серверів на ec2 можна знайти на тут.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.