Parallel and Asynchronous Computing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. The core of this page is base R: the parallel package that ships with R (version 4.6.1, the same as R itself). Add-on packages are named with the versions they were checked against: future 1.80.0 (with parallelly 1.48.0, which it installs), future.apply 1.21.0, mirai 2.7.3, later 1.4.8, promises 1.5.0, callr 3.8.0 and data.table 1.18.6.1; purrr 1.2.2 and Shiny 1.14.0 are named where their parallel features come up, and the lintr 3.4.0 default linters were used only to check the scripts. Several packages named in the text were not available in the test image and are described, not run, wherever they appear: furrr, doParallel and foreach, RhpcBLASctl, progressr, and carrier (which purrr’s in_parallel() needs). R runs one stream of R code per process. Doing more than one thing at a time therefore means starting more processes, or calling libraries that use threads inside compiled code. This page explains the first, in the three flavours R offers today: copying the running process (forking), starting fresh R sessions that talk over sockets, and the frameworks future and mirai that wrap those ideas in a different interface. It explains how to give every worker reproducible random numbers, then how R code can wait for something without blocking (the later and promises packages, and a background R process from callr), and last how the threads of a multi-threaded BLAS and of data.table interact with all of it.
Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes. Every example uses two workers, the smallest number that is parallel. Timings depend on the machine, on the BLAS and on what else is running, so no example prints seconds: where speed matters the script prints a comparison that is robust to the machine (for example t_serial / t_forked > 1.5, with tasks made of Sys.sleep() so that the answer does not depend on the speed of the CPU). The number of cores of the machine that ran the examples is never printed either. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). The work that is parallelised is always the same: the share of overdue loans of each member of the shelf, and a bootstrap of the mean loan duration. Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale.
The page leans on The apply Family and Functionals and Vectorisation and Iteration for lapply() and vapply(), which every parallel function below imitates. It continues the story of Distributions and Random Numbers, which introduced the L’Ecuyer-CMRG generator and its streams but ran them on one process. The first thing to do before parallelising anything is to find out what is slow, which is the subject of Performance, Profiling and Benchmarking; the copying that makes a worker start with a copy of your data is described in Memory and Copy Semantics; and the compiled-code route to speed, which is often better than parallel R, is in Interfacing C, C++ and Fortran.
R, processes and threads
R evaluates one expression at a time in one thread. The vignette of the parallel package says why workers are processes and not threads: in principle the workers could be implemented by threads, but only in principle, because the R interpreter is not thread-safe (The parallel package vignette, footnote to the introduction). A loop in a plain R session keeps one core busy however many cores the machine has. What does use several cores inside one R process is compiled code that starts its own threads: a multi-threaded BLAS for matrix algebra, OpenMP loops in packages such as data.table, and a few packages with their own thread pools. These are the subject of the last section.
The parallel package was first included in R 2.14.0. It merged two older CRAN packages, multicore (forking) and snow (sockets, "simple network of workstations"), and added the random-number handling described below. Its vignette describes the model that every function on this page follows, whatever its name: start M worker processes and initialise them; send them the data; split the task into chunks and send each worker its chunk with the R code; wait for the results; repeat; shut the workers down. The tasks must be unrelated: parallel R of this kind is "coarse-grained", suited to running the same function on many sets of data, such as the bootstrap replicates, simulations and per-group fits that fill the rest of this page. The vignette also says that all methods of communication use serialize() and unserialize() to move R objects between processes, which has limits (typically hundreds of millions of elements) that a well-designed parallel algorithm should not approach (R documentation: serialize).
Three consequences follow, and they explain most of what goes wrong in practice.
-
Starting workers and moving data costs time, so a task has to be long enough to repay it. Parallelising a million tasks of a microsecond each makes the program slower.
-
A worker is a separate process with its own memory and its own copy of whatever it was sent. Changing a variable in a worker never changes it in the master, and a worker only sees what it was given or what it can load itself.
-
The workers compete for the machine. Two workers that each start a multi-threaded BLAS or a threaded data.table can ask for more threads than there are cores, and the result is slower than running serially.
The unit of work
All the examples use one function. overdue_rate() takes the loans of one member and returns the share of them that are overdue: a returned loan is overdue when it was kept more than days days, and a loan still out is measured against today. It takes today and days as arguments and uses no global variable, which is what makes it safe to send to another process. The script cleans the member names, drops the empty one, and splits loans into one data frame per member, so there is one task per member.
source("scenario.R")
# The unit of work: the share of one member's loans that are overdue.
# It takes everything it needs as arguments and uses no globals.
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
# Clean the member names, drop the empty one, and split loans by member.
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
length(by_member)
#> [1] 8
serial <- lapply(by_member, overdue_rate, today = shelf_today, days = loan_days)
# Percent of each member's loans that are overdue.
round(100 * unlist(serial))
#> ana ruiz ben okafor chloe martin dev patel eva lindqvist
#> 62 60 67 67 33
#> farid karimi grace liu hugo silva
#> 0 57 50
Source: R documentation: lapply
Every parallel version below should return exactly this list. The last example of the page, Choosing a tool, checks that four of them do.
Two ways to make a worker
There are two ways to get a worker process, and the choice decides what the worker knows when it starts. The vignette lists three and describes the third (message-passing systems such as MPI, and grid schedulers) as outside its scope; the first two cover a laptop or one server.
Forking is a feature of POSIX operating systems (Linux and macOS, not Windows). The fork() system call creates a child process that is a complete copy of the parent: the workspace, the attached packages, the options and the state of the random number generator. Because the operating system shares the memory pages between parent and child until one of them writes to a page ("copy on write"), forking is fast and costs little memory. The child needs nothing to be sent: the code and the data are simply there. Results come back through a pipe that the parent opened before forking. The price is that the child shares everything else too, including the graphical user interface, open graphics devices and open files, which the vignette says can cause havoc, and the threads of any multi-threaded library, because only the thread that called fork() survives in the child. This is mclapply(), pvec(), mcparallel() and makeForkCluster(), and it is what the future package calls a "multicore" backend.
Sockets start a new R session for each worker, using Rscript, and connect to it over a socket on localhost (or, for a real cluster, on another machine through ssh). The worker is a clean R session with the default packages and an empty workspace: whatever it needs has to be sent over the socket, with clusterExport() or as arguments, or loaded there with clusterEvalQ(). Starting the workers takes a fraction of a second each, and sending data costs serialisation. In return it works everywhere, including Windows, it is safe with GUIs and threads, and the same code can run on workers on other machines. This is makeCluster() (type "PSOCK", the default), and what future calls "multisession".
Source: R documentation: makeCluster
The left half is a fork: the children exist the moment fork() returns and already hold everything the parent held. The right half is a socket cluster: each worker is started from nothing, so every object it needs has to travel over a socket. The cost model follows. A fork is cheap to start and expensive to share a GUI or threads with; a socket worker is cheap to share (it shares nothing) and has to be fed. makeForkCluster() is the hybrid: a socket cluster whose workers are forks, so it has the speed of a fork and the parLapply() interface of a cluster. The parallel documentation states the rule for Windows in one line each: mclapply() relies on forking and is not available there unless mc.cores = 1, and makeForkCluster() is only a stub.
Forking: mclapply and its relatives
mclapply
mclapply(X, FUN, ..., mc.cores) is lapply() with the elements shared among forked children. Its help page says that it relies on forking and is therefore not available on Windows unless mc.cores = 1; on Windows it exists only as a stub that calls lapply() (the vignette says so). The default of mc.cores is getOption("mc.cores", 2L), and the option takes its initial value from the environment variable MC_CORES when the package is loaded, so a forked job uses two cores unless told otherwise (the vignette notes that this differs from the multicore package). Setting mc.cores to 1 does not fork at all: the function calls lapply(). The help page adds that parallelisation requires at least two cores, so mc.cores = 1 is the standard way to switch parallel execution off for debugging.
The script below checks the four facts that are easy to forget. The result is identical to lapply(): same list, same names. The work ran in child processes (their process ids differ from the parent’s). mc.cores = 1 gives the same answer without forking. And with tasks that last a quarter of a second, two cores make the job clearly faster than one; the script prints only whether the speed-up is more than 1.5, because the exact ratio depends on the machine and what else is running.
source("scenario.R")
library(parallel)
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
serial <- lapply(by_member, overdue_rate, today = shelf_today, days = loan_days)
# Forking only exists on Unix-alikes (Linux, macOS), not on Windows.
.Platform$OS.type
#> [1] "unix"
formals(mclapply)$mc.cores
#> getOption("mc.cores", 2L)
getOption("mc.cores")
#> NULL
forked <- mclapply(
by_member, overdue_rate,
today = shelf_today, days = loan_days, mc.cores = 2
)
identical(forked, serial)
#> [1] TRUE
# The work ran in child processes, not in this one.
pids <- unlist(mclapply(1:4, \(i) Sys.getpid(), mc.cores = 2))
length(unique(pids))
#> [1] 2
Sys.getpid() %in% pids
#> [1] FALSE
# mc.cores = 1 does not fork at all: it calls lapply().
one <- mclapply(
by_member, overdue_rate,
today = shelf_today, days = loan_days, mc.cores = 1
)
identical(one, serial)
#> [1] TRUE
# Does it pay off? Make each unit take about a quarter of a second.
slow_rate <- function(rows, today, days) {
Sys.sleep(0.25)
overdue_rate(rows, today, days)
}
t_serial <- system.time(
lapply(by_member, slow_rate, today = shelf_today, days = loan_days)
)[["elapsed"]]
t_forked <- system.time(
mclapply(by_member, slow_rate, today = shelf_today, days = loan_days,
mc.cores = 2)
)[["elapsed"]]
t_serial / t_forked > 1.5
#> [1] TRUE
Source: R documentation: mclapply
Arguments that change what happens
Four of the arguments of mclapply() decide how the work is shared and what happens when a task fails.
-
mc.preschedule(defaultTRUE) deals the values ofXout to the cores in turn, value 1 to core 1, value 2 to core 2, valuemc.cores + 1to core 1 again, and forks one process per core, each handling its share. WithFALSEone process is forked for each value, and the parent waits for a child to finish before it forks the next oncemc.coresare running. The help page recommends the first for short tasks or many values, and the second for tasks with a high variance of completion time and not too many values compared withmc.cores: it is a load-balancing mode, at the price of a fork per value. -
mc.set.seed(defaultTRUE) controls the random number generator in the children; see Reproducible streams with forking. -
mc.silentsuppresses the standard output of the children (standard error is not affected), andmc.cleanupkills the children when the call is interrupted (the default is to sendSIGTERM; a signal number can be given instead). -
mc.allow.recursive(defaultTRUE): if it is false, anmclapply()called inside a child uses that child and does not fork again. The vignette recalls a case ofmclapply()called recursively that generated 2n + n^2 processes on a machine estimated to have n = 16 cores.
The second script shows prescheduling, error handling and what a child cannot do. Each forked job runs inside try(..., silent = TRUE), so an error becomes a "try-error" value in the result and a warning, and because a job usually covers several values, all of them are marked, not only the one that failed (the help page says so). A child that is killed delivers nothing and its values are NULL; for that reason FUN should never return NULL, or the two cases cannot be told apart. And changes made in a child, here a counter incremented with <<-, never reach the parent: the parent’s copy was not touched.
library(parallel)
# mc.preschedule = TRUE (the default): the values are dealt out to the
# cores in turn, one forked process per core.
pids <- unlist(mclapply(1:6, \(i) Sys.getpid(), mc.cores = 2))
match(pids, unique(pids))
#> [1] 1 2 1 2 1 2
# mc.preschedule = FALSE: one forked process per value.
pids <- unlist(
mclapply(1:6, \(i) Sys.getpid(), mc.cores = 2, mc.preschedule = FALSE)
)
length(unique(pids))
#> [1] 6
# An error is caught per job, not per value: values 1 and 3 share a core,
# so both come back as "try-error" although only 3 failed.
warned <- character()
res <- withCallingHandlers(
mclapply(1:4, \(i) if (i == 3) stop("bad item ", i) else i, mc.cores = 2),
warning = \(w) {
warned <<- conditionMessage(w)
invokeRestart("muffleWarning")
}
)
warned
#> [1] "scheduled core 1 encountered error in user code, all values of the job will be affected"
vapply(res, inherits, logical(1), what = "try-error")
#> [1] TRUE FALSE TRUE FALSE
cat(res[[3]])
#> Error in FUN(X[[i]], ...) : bad item 3
conditionMessage(attr(res[[3]], "condition"))
#> [1] "bad item 3"
# With mc.preschedule = FALSE only the value that failed is affected.
res <- suppressWarnings(
mclapply(1:4, \(i) if (i == 3) stop("bad item ", i) else i,
mc.cores = 2, mc.preschedule = FALSE)
)
vapply(res, inherits, logical(1), what = "try-error")
#> [1] FALSE FALSE TRUE FALSE
# A child that dies delivers nothing: its value is NULL.
res <- withCallingHandlers(
mclapply(1:2, \(i) if (i == 2) tools::pskill(Sys.getpid(), 9) else i,
mc.cores = 2, mc.preschedule = FALSE),
warning = \(w) {
warned <<- conditionMessage(w)
invokeRestart("muffleWarning")
}
)
warned
#> [1] "1 parallel job did not deliver a result"
vapply(res, is.null, logical(1))
#> [1] FALSE TRUE
# Changes made in a child never reach the parent.
counter <- 0
invisible(mclapply(1:2, \(i) {
counter <<- counter + 1
counter
}, mc.cores = 2))
counter
#> [1] 0
# mcaffinity() is where the CPU numbers are (NULL where unsupported).
length(mcaffinity()) >= 1L
#> [1] TRUE
Source: R documentation: mclapply
With the default mc.preschedule = TRUE the values 1, 3 and 5 ran in the first child and 2, 4 and 6 in the second, which is the pattern 1 2 1 2 1 2. The error in value 3 therefore also took value 1 with it; with mc.preschedule = FALSE only value 3 failed. The text of the warning is the one the help page implies: scheduled core 1 encountered error in user code, all values of the job will be affected. The last check is mcaffinity(), which returns the CPUs the process may run on (or NULL where the system gives no access to them); the argument affinity.list of mclapply(), which needs mc.preschedule = FALSE, can pin the value i to the CPUs listed in element i. It is for tuning on a known machine and is not used further here.
pvec, mcparallel and mccollect
pvec(v, FUN, ...) is the variant for functions that are already vectorised. It splits the vector into as many nearly equal pieces as there are cores, calls FUN once on each piece in a forked child and joins the pieces. FUN must be a pure vectorised map: it takes a vector and returns a vector of exactly the same length, and the value for an element must not depend on which piece the element is in. is_valid_isbn13() from the scenario is such a function. The help page warns that side effects of FUN are not collected from the children, and it warns when FUN is not a vectorised map.
mcparallel(expr) is the low-level building block: it forks one child that evaluates the expression and returns at once with a "parallelJob" object; mccollect(jobs) waits for the results (all jobs if none are named) and returns a list named by the name given to mcparallel() or else by the process ids. With wait = FALSE, mccollect() only looks, for timeout seconds, and returns NULL if nothing is ready: it is the polling interface. The help page notes that expr must not return NULL, because mccollect() uses NULL to signal an error, and that raw vectors cannot be returned. mcmapply() and mcMap() are the forking versions of mapply() and Map(); since R 4.0.0 mcmapply() returns a list, and never simplifies, whenever it must hold "try-error" values.
source("scenario.R")
library(parallel)
# pvec() splits a vector, runs the function on each piece, and joins the
# pieces. The function must be a pure vectorised map.
valid <- pvec(loans$isbn, is_valid_isbn13, mc.cores = 2)
identical(valid, is_valid_isbn13(loans$isbn))
#> [1] TRUE
sum(valid)
#> [1] 57
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
# mcparallel() starts one job and returns at once; mccollect() waits.
job_a <- mcparallel(
overdue_rate(by_member[["ana ruiz"]], shelf_today, loan_days),
name = "ana"
)
job_b <- mcparallel(
overdue_rate(by_member[["ben okafor"]], shelf_today, loan_days),
name = "ben"
)
class(job_a)
#> [1] "parallelJob" "childProcess" "process"
res <- mccollect(list(job_a, job_b))
names(res)
#> [1] "ana" "ben"
round(unlist(res), 2)
#> ana ben
#> 0.62 0.60
# wait = FALSE only looks: NULL means "nothing is ready yet".
slow <- mcparallel({
Sys.sleep(0.5)
"done"
})
is.null(mccollect(slow, wait = FALSE))
#> [1] TRUE
mccollect(slow)[[1]]
#> [1] "done"
# mcmapply() and mcMap() are the forking versions of mapply() and Map().
mcmapply(
\(rows, label) paste(label, nrow(rows)),
by_member[1:3], names(by_member)[1:3],
mc.cores = 2
)
#> ana ruiz ben okafor chloe martin
#> "ana ruiz 14" "ben okafor 5" "chloe martin 9"
When not to fork
The help pages of mclapply(), mcparallel() and makeForkCluster() are explicit about the limits of forking. Using these functions in a GUI or an embedded environment is strongly discouraged: the parent and the children share the same GUI, which will likely cause chaos and possibly crashes, and children should never use on-screen graphics devices (the page adds that the Tcl/Tk package counts as a GUI because it runs an event loop). RStudio is the common case, and the future package disables forking there for the same reason (see Plans). Using forking with multi-threaded libraries or packages is strongly discouraged as well, and the help page suggests a non-fork cluster if in doubt; the page for the low-level mcfork() gives the details. Other limits: the operating system limits the number of open files and processes, so using more than about 100 cores can fail with the error unable to create a pipe unless the limit on open file descriptors is raised; children share the per-session temporary directory tempdir() with the parent (before R 2.14.1 two of them could even pick the same temporary file name); and the results come back serialised through a pipe, so returning very large objects from children is inefficient, and before R 3.4.0 on a 32-bit platform they were limited to 2^31 - 1 bytes.
Where none of that applies, forking is the least effort of the three flavours on this page: nothing has to be exported, because the children already hold the workspace. That is also its trap. Code that works with mclapply() because it silently relies on a global variable fails when it is moved to a socket cluster, a future plan or a Windows machine, where there is no copy of the global environment to rely on.
Socket clusters
makeCluster and stopCluster
makeCluster(spec, type) creates a cluster of one of the registered types. The default type, "PSOCK", calls makePSOCKcluster(), which runs Rscript for each worker and has the worker connect back to a socket on the master; spec is a number of workers on localhost or a character vector of host names. The result is an object of class c("SOCKcluster", "cluster"). Every function that takes a cluster takes it as its first argument, called cl.
The cluster is a pool of persistent workers: mclapply() sets up a pool just for one computation, whereas a cluster lives until stopCluster(cl), serving any number of parLapply() calls (the vignette’s distinction). Two consequences follow. The workers keep their workspaces between calls, so objects accumulate and packages stay attached; they are state, and the script below shows it with a counter. And a cluster you forget to stop stays alive as a set of idle processes holding sockets. The help page says the workers terminate themselves when the socket they listen on becomes unavailable, which should happen when the master session ends; that is a safety net, not a plan. Write on.exit(stopCluster(cl)) in the function that makes the cluster, as with_cluster() does at the end of the script.
source("scenario.R")
library(parallel)
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
serial <- lapply(by_member, overdue_rate, today = shelf_today, days = loan_days)
# makeCluster(2) starts two fresh copies of R (type "PSOCK") on this machine.
cl <- makeCluster(2)
cl
#> socket cluster with 2 nodes on host ‘localhost’
class(cl)
#> [1] "SOCKcluster" "cluster"
length(cl)
#> [1] 2
# Each worker is a separate process with its own workspace.
pids <- unlist(clusterCall(cl, Sys.getpid))
length(unique(pids))
#> [1] 2
Sys.getpid() %in% pids
#> [1] FALSE
# parLapply() splits X into one chunk per worker and sends each chunk over
# the socket together with the function.
par <- parLapply(
cl, by_member, overdue_rate, today = shelf_today, days = loan_days
)
identical(par, serial)
#> [1] TRUE
# The workers stay alive, and keep their workspaces, until you stop them.
invisible(clusterEvalQ(cl, counter <- 0))
invisible(clusterEvalQ(cl, counter <- counter + 1))
unlist(clusterEvalQ(cl, counter))
#> [1] 1 1
stopCluster(cl)
# A function that always cleans up after itself.
with_cluster <- function(n, code) {
cl <- makeCluster(n)
on.exit(stopCluster(cl))
code(cl)
}
with_cluster(2, \(cl) unlist(parLapply(cl, 1:4, \(i) i^2)))
#> [1] 1 4 9 16
Source: R documentation: makeCluster
clusterCall(cl, fun, ...) calls the function with the same arguments on every worker, clusterEvalQ(cl, expr) evaluates a literal expression on every worker (an evalq() for the cluster), and clusterApply(cl, x, fun, ...) sends element i of x to worker i, recycling the workers. All return a list with one element per worker (or per element of x), so a value you do not want is best wrapped in invisible() or ended with NULL, as in the script. parLapply(cl, X, fun, ...) is built on clusterApply(): it splits X into one consecutive chunk per worker, runs lapply() on each chunk and joins the results, so a call needs only one round trip per worker. If any worker produces an error, the master signals an error.
Getting code and data to the workers
A fresh worker has the base packages and an empty global environment. A function you send is serialised with its arguments, but the variables it refers to are not captured: R looks them up where the function is evaluated, and for a function defined at top level, that place is the worker’s own global environment. The error below is the most common one in parallel R, and the message names the first variable that is missing. It says "2 nodes produced errors" because every worker failed in the same way, and the call it comes from is checkForRemoteErrors(val), which tells you nothing about which of your functions it was.
There are three ways to fix it and they have different costs.
-
Pass it as an argument. Anything in
...is sent along with the function. It is explicit and works everywhere (sockets, forks, future, mirai); the data is serialised on every call, which is fine for a number and slow for a large object. This is the first choice. -
clusterExport(cl, varlist, envir)copies the named variables fromenvirto the global environment of every worker, once. Use it for something large that every call needs. The defaultenviris the global environment, so inside a function the variable is local andclusterExport()fails on the master withobject 'secret' not found; giveenvir = environment(). -
clusterEvalQ(cl, expr)runs code on every worker, which is how you load packages (library(stats)) or define helper functions there.
Do not forget that a function defined inside another function carries its enclosing environment when it is serialised. A task made by a factory function can drag along every local variable of the factory, even an argument the task never uses, and the whole object is sent to every worker. The last lines of the script measure that with serialize(), without timing anything: the closure that captured an 8 MB vector serialises to more than 8 MB, while a function defined at top level serialises to a few hundred bytes, because the global environment is sent as a reference and not as a copy.
source("scenario.R")
library(parallel)
cl <- makeCluster(2)
# A function defined at top level is sent to the workers, but its free
# variables are looked up in each worker's own global environment.
days_left <- function(i) loan_days - i # nolint: object_usage_linter.
tryCatch(
parLapply(cl, 1:2, days_left),
error = conditionMessage
)
#> [1] "2 nodes produced errors; first error: object 'loan_days' not found"
# Fix 1: pass what the function needs as an argument.
days_left_arg <- function(i, days) days - i
unlist(parLapply(cl, 1:2, days_left_arg, days = loan_days))
#> [1] 20 19
# Fix 2: copy the variable into every worker's global environment.
clusterExport(cl, "loan_days")
unlist(parLapply(cl, 1:2, days_left))
#> [1] 20 19
# Fix 3: run code on every worker, for example to load a package.
invisible(clusterEvalQ(cl, library(stats)))
unlist(clusterEvalQ(cl, "package:stats" %in% search()))
#> [1] TRUE TRUE
# clusterExport() looks in the global environment by default. Inside a
# function the variable is local, so say where it is with envir.
export_local <- function(cl) {
secret <- 42 # nolint: object_usage_linter.
clusterExport(cl, "secret")
}
tryCatch(export_local(cl), error = conditionMessage)
#> [1] "object 'secret' not found"
export_local_ok <- function(cl) {
secret <- 42 # nolint: object_usage_linter.
clusterExport(cl, "secret", envir = environment())
}
export_local_ok(cl)
unlist(clusterEvalQ(cl, secret))
#> [1] 42 42
# A function made inside another function drags its environment along.
big <- numeric(1e6)
make_task <- function(values) {
force(values)
\(i) i + 1
}
task_closure <- make_task(big)
task_global <- \(i) i + 1
length(serialize(task_closure, NULL)) > 8e6
#> [1] TRUE
length(serialize(task_global, NULL)) < 1e4
#> [1] TRUE
stopCluster(cl)
Source: R documentation: clusterApply
The apply family on a cluster
The functions of the parallel package for a cluster mirror the apply family, with cl first.
| Function | Counterpart | Note |
|---|---|---|
|
|
one chunk per worker by default; |
|
|
dynamic load balancing; since R 3.5.0 the default is twice as many chunks as workers |
|
|
simplifies like |
|
|
|
|
|
element |
|
|
the next element goes to whichever worker is free; which worker runs which element varies from run to run |
|
|
|
|
|
cuts |
Source: R documentation: clusterApply
The load-balancing versions are for tasks whose durations differ a lot: the worker that finishes first gets the next task. Two caveats come with them. The result of a computation that draws random numbers cannot be reproduced when the work is balanced dynamically, because which worker runs which task changes from run to run (the help pages and the vignette both say so), and more communication costs more time than one chunk per worker. The vignette suggests having substantially more tasks than workers, but not by a factor of 100.
The script shows each in turn. The static functions put element 1 on worker 1, element 2 on worker 2, then element 3 on worker 1 again (1 2 1 2 1 2). clusterSplit(cl, 1:7) shows the chunks parLapply() would use. All the versions return the same list as lapply(); the load-balanced clusterApplyLB() drops the names, as clusterApply() does, so its result is compared with unname(serial). clusterMap() is used with a function passed as a value and not by name, which matters: the function body of an anonymous function that calls overdue_rate() would look for it on the worker and not find it, for the reason given above. The last lines show a trap of the ... of all these functions: an extra argument you name is matched against the formals of the function first, with partial matching. parSapply(cl, 1:3, \(i, x) i + x, x = 10) fails because x also names a formal argument of the clusterApply() that parLapply() calls internally. The help page warns about it with the words "beware of partial matching to earlier arguments"; rename the argument (offset in the last line) or use a name that cannot match.
source("scenario.R")
library(parallel)
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
serial <- lapply(by_member, overdue_rate, today = shelf_today, days = loan_days)
cl <- makeCluster(2)
# clusterApply(): element i goes to node i, recycling the nodes.
nodes <- unlist(clusterApply(cl, 1:6, \(i) Sys.getpid()))
match(nodes, unique(nodes))
#> [1] 1 2 1 2 1 2
# clusterSplit() shows how parLapply() cuts a vector into one chunk per node.
clusterSplit(cl, 1:7)
#> [[1]]
#> [1] 1 2 3 4
#>
#> [[2]]
#> [1] 5 6 7
#>
# parLapply() is clusterApply() on those chunks, then lapply() in each.
par <- parLapply(
cl, by_member, overdue_rate, today = shelf_today, days = loan_days
)
identical(par, serial)
#> [1] TRUE
# The load-balancing versions hand out work as nodes become free. Which node
# runs which value then varies from run to run, but the result does not.
lb <- parLapplyLB(
cl, by_member, overdue_rate, today = shelf_today, days = loan_days
)
identical(lb, serial)
#> [1] TRUE
lb2 <- clusterApplyLB(
cl, by_member, overdue_rate, today = shelf_today, days = loan_days
)
identical(lb2, unname(serial))
#> [1] TRUE
# chunk.size = 1 makes every value its own unit of scheduling.
par1 <- parLapply(
cl, by_member, overdue_rate, today = shelf_today, days = loan_days,
chunk.size = 1
)
identical(par1, serial)
#> [1] TRUE
# clusterMap() is mapply() on the cluster.
clusterMap(cl, \(a, b) a * b, 1:3, 4:6)
#> [[1]]
#> [1] 4
#>
#> [[2]]
#> [1] 10
#>
#> [[3]]
#> [1] 18
#>
rates <- clusterMap(
cl, overdue_rate, by_member[1:3],
days = c(7, 14, 21), MoreArgs = list(today = shelf_today)
)
# Percent overdue, with 7, 14 and 21 days allowed.
round(100 * unlist(rates))
#> ana ruiz ben okafor chloe martin
#> 92 80 67
# parSapply() simplifies like sapply(): fine for a number per element, but
# the type of the result depends on the data. Prefer vapply() on parLapply().
checked <- vapply(par, identity, numeric(1))
all.equal(checked, unlist(serial))
#> [1] TRUE
# Extra arguments are matched by name to the formals of parLapply() first.
tryCatch(
parSapply(cl, 1:3, \(i, x) i + x, x = 10),
error = conditionMessage
)
#> [1] "formal argument \"x\" matched by multiple actual arguments"
unlist(parLapply(cl, 1:3, \(i, offset) i + offset, offset = 10))
#> [1] 11 12 13
stopCluster(cl)
Source: R documentation: clusterApply
Cluster types, defaults, logging and timeouts
registerClusterType() with no arguments lists the registered types. In R 4.6.1 they are "PSOCK", "FORK", "MIRAI", "MPI", "RPSOCK" and "SOCK"; the last four need the packages named in the help page (snow for "SOCK" and "MPI", parallelly for "RPSOCK", mirai for "MIRAI"). The NEWS file of R 4.x does not mention when registerClusterType() and the extra types were added, and the mirai documentation says that the "MIRAI" type is an official cluster type in R 4.5, so this page does not give an exact version; the type is shown in Mirai and the parallel package.
makeCluster(2, type = "FORK") is makeForkCluster(2): a socket cluster whose workers are forks of the master, so they inherit its workspace as it is at that moment (the script shows that a variable created after the fork does not exist on the workers). It is not available on Windows and shares the cautions about forking above.
setDefaultCluster(cl) registers a cluster as the default for the session, so the cl argument of parLapply() and friends can be NULL or omitted; getDefaultCluster() returns it, and stopping that cluster, or setDefaultCluster(NULL), removes it. The convenience has a cost: a hidden global is exactly what makes code hard to reuse, so it suits scripts better than packages.
The options of makeCluster() that matter in practice are these (help page makeCluster):
| Option | What it does |
|---|---|
|
where the standard output and standard error of the workers go; |
|
the longest time in seconds with no communication between master and worker before failing; the default is 30 days |
|
the longest time in seconds that a worker keeps trying to connect to the master; the default is 2 minutes, which is the time you wait for an error if the workers cannot start |
|
the port; the default comes from the environment variable |
|
the name or address the workers use to find the master, which on private networks may need to be a numeric address |
|
the |
|
|
|
|
|
the command (default |
|
|
Source: R documentation: makeCluster
Worker output deserves a note, because it surprises everybody once: the standard output and standard error of a PSOCK worker are discarded by default, so a print() or message() inside a task shows nothing, and a warning raised in a task is lost altogether, because only errors travel back to the master. Set outfile to a file to see the output; the script below filters out the line each worker writes when it starts, which contains the process id and the time. Neither of the two limits of the socket protocol is usually hit, but both exist: a cluster uses one connection per worker, and the maximum number of connections in an R session is 128 by default, with some already in use; and, on Windows and macOS, the first time the workers connect the firewall may ask whether R may accept incoming connections (the vignette says so).
library(parallel)
# The cluster types this R knows about.
registerClusterType()
#> [1] "PSOCK" "FORK" "MIRAI" "MPI" "RPSOCK" "SOCK"
# makeCluster(2, type = "FORK") is makeForkCluster(2): the workers are
# forks of this session, so they inherit its workspace as it is right now.
x_before <- 42
cl <- makeForkCluster(2)
x_after <- 1
unlist(clusterEvalQ(cl, exists("x_before")))
#> [1] TRUE TRUE
unlist(clusterEvalQ(cl, exists("x_after")))
#> [1] FALSE FALSE
stopCluster(cl)
# Register a default cluster so that the cl argument can be left out.
getDefaultCluster()
#> NULL
cl <- makeCluster(2)
setDefaultCluster(cl)
identical(getDefaultCluster(), cl)
#> [1] TRUE
unlist(parLapply(NULL, 1:3, \(i) i * 2))
#> [1] 2 4 6
stopCluster(cl)
getDefaultCluster()
#> NULL
tryCatch(parLapply(NULL, 1:3, \(i) i * 2), error = conditionMessage)
#> [1] "no cluster 'cl' supplied and none is registered"
# Worker output is discarded unless outfile says where to put it.
log_file <- tempfile(fileext = ".log")
cl <- makeCluster(2, outfile = log_file)
invisible(clusterEvalQ(cl, {
cat("hello from a worker\n")
message("a message from a worker")
}))
stopCluster(cl)
log_lines <- readLines(log_file)
sort(log_lines[!startsWith(log_lines, "starting worker")])
#> [1] "a message from a worker" "a message from a worker"
#> [3] "hello from a worker" "hello from a worker"
unlink(log_file)
# Worker warnings are lost too: only errors travel back.
cl <- makeCluster(2)
res <- withCallingHandlers(
parLapply(cl, 1:2, \(i) {
warning("careful")
i
}),
warning = \(w) cat("the master saw a warning\n")
)
unlist(res)
#> [1] 1 2
stopCluster(cl)
# Options of the PSOCK launcher: how long to wait while the workers start
# (setup_timeout), the longest silence on a connection (timeout), the port
# (port, or the environment variable R_PARALLEL_PORT).
cl <- makeCluster(2, setup_timeout = 60, timeout = 3600)
length(cl)
#> [1] 2
stopCluster(cl)
Source: R documentation: makeCluster
When things go wrong
An error on a worker reaches the master as one error whose message starts with the number of nodes that failed and then gives the first message. The call recorded in the condition is always checkForRemoteErrors(val): the traceback of the worker is lost. When debugging, run the same function with lapply() on a small input first. The messages worth recognising are in the script: a task calling a function that the worker does not have (could not find function), a package missing from the worker (there is no package called), a worker whose process died (error reading from connection), and a cluster that was already stopped (invalid connection). After a worker has died the other workers are still alive but the cluster object is damaged; stop it (try(stopCluster(cl))) and make a new one.
library(parallel)
# An error on a worker comes back to the master as one error.
cl <- makeCluster(2)
e <- tryCatch(parLapply(cl, 1:2, \(i) stop("no good ", i)), error = identity)
conditionMessage(e)
#> [1] "2 nodes produced errors; first error: no good 1"
conditionCall(e)
#> checkForRemoteErrors(val)
# A call to something the worker does not have.
tryCatch(
parLapply(cl, 1:2, \(i) not_a_function(i)),
error = conditionMessage
)
#> [1] "2 nodes produced errors; first error: could not find function \"not_a_function\""
tryCatch(clusterEvalQ(cl, library(notAPackage)), error = conditionMessage)
#> [1] "2 nodes produced errors; first error: there is no package called ‘notAPackage’"
# A worker that dies.
tryCatch(
clusterEvalQ(cl, quit(save = "no", status = 1, runLast = FALSE)),
error = conditionMessage
)
#> [1] "error reading from connection"
try(stopCluster(cl), silent = TRUE)
# A cluster that was stopped cannot be used again.
cl <- makeCluster(2)
stopCluster(cl)
tryCatch(parLapply(cl, 1:2, \(i) i), error = conditionMessage)
#> [1] "invalid connection"
Source: R documentation: clusterApply
How many workers?
Not detectCores(). Its help page says it is not suitable for use directly as mc.cores or as the number of workers of makeCluster(): it may return NA; it does not give the number of cores that this user or this job is allowed (on a shared server, or in a container, the machine has many more cores than the job may use); and on some machines it is not reasonable to use all the logical CPUs at once. What it counts is operating-system dependent: logical CPUs by default, and the argument logical = FALSE for physical cores is honoured only on macOS, Solaris and Windows. The parallelly package (a dependency of future) has availableCores(), which starts from the same number and then applies every limit it can find: the Linux control groups of a container, the mc.cores option, and the variables set by batch schedulers such as Slurm, PBS and SGE. It also accepts omit to leave cores free and max. Its predecessor in the future package was moved to parallelly in future 1.20.0 (November 2020). The rule of thumb that follows is one worker fewer than the cores you are allowed, and never more workers than tasks, and the script shows it as a small function. The examples of this page use two workers so that they run anywhere.
library(parallel)
# detectCores() counts the CPUs of the machine; it may be NA, and it does not
# know what this user or job is allowed to use.
n_machine <- detectCores()
is.na(n_machine)
#> [1] FALSE
n_machine >= detectCores(logical = FALSE)
#> [1] TRUE
# parallelly::availableCores() starts from the same number and then applies
# every limit it can find: cgroups, the mc.cores option, batch schedulers.
n_allowed <- parallelly::availableCores()
unname(n_allowed <= n_machine)
#> [1] TRUE
options(mc.cores = 2)
unname(parallelly::availableCores(methods = "mc.cores"))
#> [1] 2
unname(parallelly::availableCores() <= 2L)
#> [1] TRUE
options(mc.cores = NULL)
# A sensible default: leave one core for the main session, and never ask for
# more workers than there are tasks.
choose_workers <- function(tasks, cores = parallelly::availableCores()) {
max(1L, min(tasks, cores - 1L))
}
choose_workers(8L, cores = 4L)
#> [1] 3
choose_workers(2L, cores = 16L)
#> [1] 2
choose_workers(8L, cores = 1L)
#> [1] 1
Reproducible random numbers in parallel
Random numbers are where parallel code most often goes quietly wrong. A worker is a separate R process with its own .Random.seed, and three things can happen to it, none of them good by default. A forked child starts with a copy of the parent’s generator state, so if the parent has drawn any numbers, every child continues the same sequence and all of them draw the same numbers. A fresh PSOCK worker seeds itself from the clock and its process id, so the numbers are different in every worker and different in every run: independent with very high probability, but not reproducible. And seeding each worker with set.seed(i) for different i is the tempting repair, but it does not guarantee that the streams are independent: for the default Mersenne-Twister generator nothing says that streams started from different seeds do not overlap. The vignette of the parallel package describes the problem and the repair, which is to use one generator and place the streams far apart in its sequence.
The generator is L’Ecuyer-CMRG (RNGkind("L'Ecuyer-CMRG")), a combined multiple-recursive generator of L’Ecuyer (1999) with a period of about 2^191 and a seed of six integers (seven in .Random.seed, with the leading code). Unlike the Mersenne-Twister, it is simple to advance by a fixed number of steps. The package implements L’Ecuyer, Simard, Chen and Kelton (2002): the sequence is cut into streams of length 2^127, each cut again into substreams of length 2^76, and each parallel computation gets a stream (and may use substreams) so that the numbers never get in step. nextRNGStream(seed) and nextRNGSubStream(seed) take a seed of this generator and return the seed of the next stream or substream; the result can be assigned to .Random.seed. Distributions and Random Numbers showed the streams on one process; the three ways to use them in parallel follow.
-
clusterSetRNGStream(cl, iseed)selects L’Ecuyer-CMRG in the master, callsset.seed(iseed)(or, ifiseedisNULL, uses the current seed of the master) and gives each worker the next stream. -
mclapply()andmcparallel()withmc.set.seed = TRUEgive each forked child the next stream, provided the generator is L’Ecuyer-CMRG;mc.reset.stream()restarts the sequence of streams afterset.seed(), andmclapply()andpvec()call it themselves. -
Hand-made seeds, one per task, as in the last example of this section, which is the one whose result does not depend on the number of workers.
A socket cluster: clusterSetRNGStream
The script bootstraps the mean loan duration of the returned loans. It runs the same six replicates three times on a cluster: first without seeding, which differs between the two runs; then twice after clusterSetRNGStream(cl, 282), which gives identical results; then with a different seed, which differs. Two side effects are worth seeing. clusterSetRNGStream() also changed the generator of the master (the RNGkind() line shows "L'Ecuyer-CMRG"), so call it after any code in the master that depends on the default generator. And the result depends on the number of workers: with three workers the same six replicates are shared differently and the streams differ, so the numbers differ even with the same seed. A seed fixes the streams of the workers, not the stream of each task.
The argument that carries the data is called values and not x, for the partial-matching reason given above.
source("scenario.R")
library(parallel)
# The loan durations, in days, of the loans that were returned.
durations <- as.integer(loans$return_date - loans$loan_date)
durations <- durations[!is.na(durations) & durations >= 0L]
# One bootstrap replicate: the mean of a resample.
boot_mean <- function(i, values) mean(sample(values, replace = TRUE))
# Without seeding, every run draws different numbers.
cl <- makeCluster(2)
run_1 <- parSapply(cl, 1:6, boot_mean, values = durations)
run_2 <- parSapply(cl, 1:6, boot_mean, values = durations)
identical(run_1, run_2)
#> [1] FALSE
# clusterSetRNGStream() gives each worker its own L'Ecuyer-CMRG stream.
clusterSetRNGStream(cl, 282)
seeded_1 <- parSapply(cl, 1:6, boot_mean, values = durations)
clusterSetRNGStream(cl, 282)
seeded_2 <- parSapply(cl, 1:6, boot_mean, values = durations)
identical(seeded_1, seeded_2)
#> [1] TRUE
round(seeded_1, 2)
#> [1] 20.67 19.51 20.08 18.71 20.98 19.47
# A different seed gives different streams.
clusterSetRNGStream(cl, 283)
other <- parSapply(cl, 1:6, boot_mean, values = durations)
identical(other, seeded_1)
#> [1] FALSE
# It also switched the generator of the master process.
RNGkind()
#> [1] "L'Ecuyer-CMRG" "Inversion" "Rejection"
stopCluster(cl)
# The streams belong to the workers, so three workers draw differently.
cl <- makeCluster(3)
clusterSetRNGStream(cl, 282)
three <- parSapply(cl, 1:6, boot_mean, values = durations)
stopCluster(cl)
identical(three, seeded_1)
#> [1] FALSE
Source: R documentation: RngStream
Forking: mc.set.seed and mc.reset.stream
For forked children the rule is stricter, because the three cases of the help page of mcparallel() all show up in one script. With the generator set to L’Ecuyer-CMRG and a seed set, mclapply() gives the children the next streams and the result is reproducible, as long as the same tasks go to the first, second, … child: the first lines of the script show this, and also that three cores give different numbers from two. With the default Mersenne-Twister and mc.set.seed = TRUE, the children are seeded from the clock and process id: the code of the unexported function parallel:::mc.set.stream() removes .Random.seed in the child for any generator other than L’Ecuyer-CMRG, so the child draws a fresh seed when it first needs one. The children differ from each other and the run cannot be reproduced. (The help page of mcparallel() says the behaviour with mc.set.seed = TRUE is different only for L’Ecuyer-CMRG; the script shows what happens in practice.) And with mc.set.seed = FALSE the children inherit the state of the parent: once the parent has a generator state (here made by set.seed()), every child draws the same numbers, which is almost never what you want. The help page of mclapply() makes its example reproducible with set.seed(123, "L'Ecuyer"), which sets the kind and the seed in one call. mc.reset.stream() after setting the seed makes mcparallel() reproducible in the same way (the help page notes that it does not set the seed in the master, so it does not affect the fall-back to serial execution when mc.cores = 1).
source("scenario.R")
library(parallel)
durations <- as.integer(loans$return_date - loans$loan_date)
durations <- durations[!is.na(durations) & durations >= 0L]
boot_mean <- function(i, values) mean(sample(values, replace = TRUE))
# mclapply() gives each child the next L'Ecuyer-CMRG stream, but only when
# that generator is selected before the seed is set.
RNGkind("L'Ecuyer-CMRG")
set.seed(282)
fork_1 <- unlist(mclapply(1:6, boot_mean, values = durations, mc.cores = 2))
set.seed(282)
fork_2 <- unlist(mclapply(1:6, boot_mean, values = durations, mc.cores = 2))
identical(fork_1, fork_2)
#> [1] TRUE
round(fork_1, 2)
#> [1] 18.71 19.76 20.98 19.80 19.47 21.41
# The result depends on how many children there are.
set.seed(282)
fork_3 <- unlist(mclapply(1:6, boot_mean, values = durations, mc.cores = 3))
identical(fork_1, fork_3)
#> [1] FALSE
# With the default generator, children are seeded from the clock and the
# process id: different from each other and not reproducible.
RNGkind("Mersenne-Twister")
set.seed(282)
mt_1 <- mclapply(1:2, \(i) runif(1), mc.cores = 2)
set.seed(282)
mt_2 <- mclapply(1:2, \(i) runif(1), mc.cores = 2)
identical(mt_1[[1]], mt_1[[2]])
#> [1] FALSE
identical(mt_1, mt_2)
#> [1] FALSE
# mc.set.seed = FALSE: the children inherit the state of the parent, so once
# the parent has drawn numbers they all draw the same ones.
set.seed(282)
same <- mclapply(1:2, \(i) runif(1), mc.cores = 2, mc.set.seed = FALSE)
identical(same[[1]], same[[2]])
#> [1] TRUE
# mc.reset.stream() restarts the stream sequence for mcparallel().
RNGkind("L'Ecuyer-CMRG")
set.seed(282)
mc.reset.stream()
job_1 <- mccollect(mcparallel(runif(1)))[[1]]
set.seed(282)
mc.reset.stream()
job_2 <- mccollect(mcparallel(runif(1)))[[1]]
identical(job_1, job_2)
#> [1] TRUE
# nextRNGStream() and nextRNGSubStream() move a seed along the streams.
seed <- .Random.seed
length(seed)
#> [1] 7
identical(nextRNGStream(seed), seed)
#> [1] FALSE
identical(nextRNGStream(seed), nextRNGSubStream(seed))
#> [1] FALSE
One stream per task
Both results above depend on how many workers ran. For a result that is the same whatever ran the tasks, give each task its stream, not each worker. The master makes one seed per task with nextRNGStream() from a single set.seed(), passes the seed as an argument, and the task installs it as its .Random.seed before drawing. The script runs the six replicates in a loop, with mclapply(), on a cluster of two workers and on a cluster of three, and the four results are identical. RNGkind("L'Ecuyer-CMRG") appears inside the task because a fresh worker starts with the Mersenne-Twister and a .Random.seed of seven integers means something else there; assigning it first selects the right generator. This is also what the future and mirai frameworks do for you, as shown below.
source("scenario.R")
library(parallel)
durations <- as.integer(loans$return_date - loans$loan_date)
durations <- durations[!is.na(durations) & durations >= 0L]
# One seed per task, made in the master from a single set.seed().
make_streams <- function(n, seed) {
RNGkind("L'Ecuyer-CMRG")
set.seed(seed)
stream <- get(".Random.seed", envir = globalenv())
out <- vector("list", n)
for (i in seq_len(n)) {
out[[i]] <- stream
stream <- nextRNGStream(stream)
}
out
}
# The task installs its own stream, whatever process runs it.
boot_task <- function(stream, values) {
RNGkind("L'Ecuyer-CMRG")
assign(".Random.seed", # nolint: object_name_linter.
stream, envir = globalenv())
mean(sample(values, replace = TRUE))
}
streams <- make_streams(6L, 282)
in_loop <- vapply(streams, boot_task, numeric(1), values = durations)
forked <- unlist(mclapply(streams, boot_task, values = durations, mc.cores = 2))
cl2 <- makeCluster(2)
two <- parSapply(cl2, streams, boot_task, values = durations)
stopCluster(cl2)
cl3 <- makeCluster(3)
three <- unlist(clusterApply(cl3, streams, boot_task, values = durations))
stopCluster(cl3)
# The same numbers, whatever ran the tasks and however many workers there were.
same <- list(forked, two, three)
vapply(same, identical, logical(1), y = in_loop)
#> [1] TRUE TRUE TRUE
round(in_loop, 2)
#> [1] 20.67 18.71 19.76 21.90 21.10 22.31
Source: R documentation: RngStream
sample() and the generator settings of the workers
A fresh worker is a fresh R session, so it starts with R’s defaults: the Mersenne-Twister, Inversion for normal variates and Rejection for sample(). The last of these changed in R 3.6.0, which made the default method of drawing from a discrete uniform distribution (used by sample()) unbiased on large populations; the old method, "Rounding", can be selected with RNGkind(sample.kind = "Rounding") or RNGversion() to reproduce old results, and selecting it gives a warning. The script shows that the master’s setting is not inherited by workers (they still say "Rejection"), but that clusterSetRNGStream() builds the workers' streams from the master’s kind, so after it the workers sample the old way as well. In practice: if you reproduce a result obtained under R before 3.6.0, set the kind once in the master and seed with clusterSetRNGStream(); do not rely on a worker’s default.
library(parallel)
# A new worker is a new R session: it starts with R's defaults.
cl <- makeCluster(2)
unlist(clusterEvalQ(cl, RNGkind())[[1]])
#> [1] "Mersenne-Twister" "Inversion" "Rejection"
# R 3.6.0 changed the default of sample(); the old method needs a warning
# to be accepted.
withCallingHandlers(
RNGkind(sample.kind = "Rounding"),
warning = \(w) {
cat("warning:", conditionMessage(w), "\n")
invokeRestart("muffleWarning")
}
)
#> warning: non-uniform 'Rounding' sampler used
RNGkind()[3]
#> [1] "Rounding"
unlist(clusterEvalQ(cl, RNGkind())[[1]])[3]
#> [1] "Rejection"
# clusterSetRNGStream() builds the streams from the master's settings, so
# the workers now sample the old way as well.
clusterSetRNGStream(cl, 282)
unlist(clusterEvalQ(cl, RNGkind())[[1]])
#> [1] "L'Ecuyer-CMRG" "Inversion" "Rounding"
stopCluster(cl)
RNGkind(sample.kind = "Rejection")
Source: R documentation: Random
The R NEWS for 3.6.0 describes the change and the argument sample.kind (R NEWS: Changes in R 3.6.0).
The future package
The future package (version 1.80.0 here) puts one interface over all of the above. A future is a value that is computed somewhere else and fetched when needed. You write the code once, in terms of future() and value(), and the user says where it runs with plan(): in this process, in background R sessions, in forked processes, on a cluster. The help page of future() puts the point in one sentence: any R code using futures remains the same regardless of these settings, and there is no need to modify the code when switching from sequential to parallel processing. Sequential evaluation is the default, so code written with futures runs everywhere, in parallel only when somebody asks.
Plans, futures and values
The built-in plans (help page plan) are sequential (in this process), multisession (background R sessions on this machine, which are PSOCK workers), multicore (forked processes; not on Windows) and cluster (R sessions on one or more machines). Other backends live in other packages, among them future.callr, future.mirai and future.batchtools for job schedulers. The documentation tells package authors to leave the plan to the user: do not call plan() in a package, and if a function must change it, undo the change on exit. For multisession, workers defaults to availableCores(constraints = "connections-16"), which keeps 16 connection slots free on machines with very many cores (future 1.67.0).
future(expr) starts evaluating expr at once (lazy = FALSE is the default) and returns a future; resolved(f) asks without waiting whether it has finished; value(f) waits for it and returns the value. x %<-% expr is the future assignment: it creates a future and binds x to a promise that fetches the value when x is first used. nbrOfWorkers() is the number of workers of the current plan. plan() returns the current plan when called with no argument and the previous plan, invisibly, when it sets a new one. Printing a plan is not done in the script: in future 1.80.0 it starts the backend and prints the details of the machine, so the script asks inherits(plan(), "sequential") instead.
The behaviour the script shows is the part of the contract that matters. Globals are found by inspecting the code of the expression (globals = TRUE is the default) and are copied to the worker: the future g needed the function overdue_days() and, through it, loans and loan_days, and the script reads their names from g$globals. (This is the automatic export that clusterExport() does by hand; it also means a large global is copied every time.) Standard output and conditions (messages and warnings) raised on the worker are relayed when value() is called; the script shows cat() output arriving and a message handled by withCallingHandlers() in the master. Errors are re-signalled by value(). And the plan is put back with plan(sequential) at the end, which is the habit to keep: a plan set in a script stays set for the rest of the session.
source("scenario.R")
library(future)
# A plan says where futures are evaluated. The default is sequential: in
# this very R process.
inherits(plan(), "sequential")
#> [1] TRUE
nbrOfWorkers()
#> [1] 1
# multisession: background R sessions on this machine (PSOCK workers).
plan(multisession, workers = 2)
nbrOfWorkers()
#> [1] 2
# future() starts the work and returns at once; value() waits for it.
f <- future({
Sys.sleep(0.5)
Sys.getpid()
})
class(f)[1]
#> [1] "MultisessionFuture"
resolved(f)
#> [1] FALSE
value(f) == Sys.getpid()
#> [1] FALSE
resolved(f)
#> [1] TRUE
# A future assignment: x is a promise that is filled in when first used.
x %<-% {
Sys.sleep(0.2)
sum(seq_len(10))
}
x
#> [1] 55
# Globals are found by inspecting the code, then copied to the worker.
overdue_days <- function(loan_date, return_date) {
as.integer(return_date - loan_date) - loan_days # nolint: object_usage_linter.
}
g <- future(overdue_days(loans$loan_date[1], loans$return_date[1]))
names(g$globals)
#> [1] "overdue_days" "loans" "loan_days"
value(g)
#> [1] 0
# Output and conditions are relayed to the master when the value is read.
h <- future({
cat("printed on the worker\n")
message("a message from the worker")
1
})
res <- withCallingHandlers(
value(h),
message = \(m) {
cat("relayed:", conditionMessage(m))
invokeRestart("muffleMessage")
}
)
#> printed on the worker
#> relayed: a message from the worker
# An error is re-signalled by value().
e <- future(stop("failure on the worker"))
tryCatch(value(e), error = conditionMessage)
#> [1] "failure on the worker"
# Always put the plan back.
plan(sequential)
nbrOfWorkers()
#> [1] 1
Changing the plan safely
A plan should be set once, at the top of a script. When a block of code or a function must use a different plan temporarily, with(plan(multisession, workers = 2), { ... }) evaluates the block under the temporary plan and then restores the previous one, and with(plan(...), local = TRUE) inside a function lasts until the function exits (future 1.40.0, April 2025). The older idiom saves the old plan, old_plan <- plan(multisession), and restores it with plan(old_plan). The script also shows the other two plans in use. parallelly::supportsMulticore() says whether forking is supported and allowed: it is false on Windows and in RStudio, where future disables forking, and the help page of multicore quotes a member of R Core advising against forking in packages except as an option the user sets. On a system without support, plan(multicore) falls back to sequential, with a one-time warning in RStudio. plan(cluster, workers = cl) accepts a cluster you made yourself, here with parallel::makeCluster(), so it can be shared with code that does not use futures; value(f) %in% unlist(parallel::clusterCall(cl, Sys.getpid)) checks that the future ran on one of its workers. Finally plan(multiprocess), which older tutorials use, was deprecated in future 1.20.0 (2020), declared defunct in 1.32.0 and removed in 1.33.0 (2023): use multisession, or multicore where forking is allowed.
source("scenario.R")
library(future)
# Change the plan for one block of code only (future 1.40.0 and later).
with(plan(multisession, workers = 2), {
cat("inside:", nbrOfWorkers(), "workers\n")
})
#> inside: 2 workers
cat("after:", nbrOfWorkers(), "worker\n")
#> after: 1 worker
# Inside a function, local = TRUE restores the plan when the function exits.
count_workers <- function() {
with(plan(multisession, workers = 2), local = TRUE)
nbrOfWorkers()
}
count_workers()
#> [1] 2
nbrOfWorkers()
#> [1] 1
# The older idiom: plan() returns the previous plan, to put back later.
old_plan <- plan(multisession, workers = 2)
nbrOfWorkers()
#> [1] 2
plan(old_plan)
nbrOfWorkers()
#> [1] 1
# Forked workers: only where forking is available and safe.
can_fork <- parallelly::supportsMulticore()
can_fork
#> [1] TRUE
if (can_fork) {
plan(multicore, workers = 2)
f <- future(Sys.getpid())
cat("forked worker:", value(f) != Sys.getpid(), "\n")
plan(sequential)
}
#> forked worker: TRUE
# The cluster backend accepts a cluster you made yourself.
cl <- parallel::makeCluster(2)
plan(cluster, workers = cl)
nbrOfWorkers()
#> [1] 2
f <- future(Sys.getpid())
value(f) %in% unlist(parallel::clusterCall(cl, Sys.getpid))
#> [1] TRUE
plan(sequential)
parallel::stopCluster(cl)
# A sequential plan evaluates in this process, which makes debugging easy.
plan(sequential)
f <- future(Sys.getpid())
value(f) == Sys.getpid()
#> [1] TRUE
# multiprocess was removed in future 1.33.0 (2023); its replacement is
# multisession or multicore.
exists("multiprocess", envir = asNamespace("future"))
#> [1] FALSE
Random numbers in futures
The future package takes random numbers seriously, because they are the commonest silent error in parallel code. A future that draws random numbers without having declared it with seed = is detected, and the value comes with a warning: UNRELIABLE VALUE: Future (<unnamed-n>) unexpectedly generated random numbers without specifying argument 'seed'. followed by an explanation (the script shortens the label that contains a counter and removes the trailing session identifiers, which differ every run). The class of the warning is RngFutureWarning. The option future.rng.onMisuse controls the check: "warning" is the default, "ignore" disables it, and "error" makes it an error of class RngFutureError.
The remedy is the seed argument. seed = TRUE gives the future a parallel-safe L’Ecuyer-CMRG stream; seed = 282, a single integer, gives a fixed stream from which the result is reproducible; a full seed of seven integers can be given as well. This is the same generator and the same streams as in the previous section, assigned for you and per future, not per worker.
library(future)
plan(multisession, workers = 2)
# A future that draws random numbers without seed = TRUE is flagged.
f <- future(rnorm(1))
w <- tryCatch(value(f), warning = identity)
class(w)[1:2]
#> [1] "RngFutureWarning" "FutureWarning"
msg <- conditionMessage(w)
sub("\\(<unnamed-[0-9]+>\\)", "(<unnamed-n>)", sub(" \\[future .*$", "", msg))
#> [1] "UNRELIABLE VALUE: Future (<unnamed-n>) unexpectedly generated random numbers without specifying argument 'seed'. There is a risk that those random numbers are not statistically sound and the overall results might be invalid. To fix this, specify 'seed=TRUE'. This ensures that proper, parallel-safe random numbers are produced. To disable this check, use 'seed=NULL', or set option 'future.rng.onMisuse' to \"ignore\"."
# seed = TRUE gives each future a proper L'Ecuyer-CMRG stream. The numbers
# are random, so the stream is fixed by a seed given as an integer.
a <- value(future(rnorm(2), seed = 282))
b <- value(future(rnorm(2), seed = 282))
identical(a, b)
#> [1] TRUE
d <- value(future(rnorm(2), seed = 283))
identical(a, d)
#> [1] FALSE
# The warning can be turned off for good by declaring the use of RNG...
old <- options(future.rng.onMisuse = "ignore")
length(value(future(rnorm(1))))
#> [1] 1
options(old)
# ... or the check can be raised to an error.
options(future.rng.onMisuse = "error")
r <- tryCatch(value(future(rnorm(1))), error = \(e) class(e)[1])
r
#> [1] "RngFutureError"
options(future.rng.onMisuse = "warning")
plan(sequential)
future.apply
Writing future() and value() by hand is rarely the aim. The future.apply package (1.21.0) provides drop-in replacements for the apply family: future_lapply(), future_sapply(), future_vapply(), future_mapply(), future_Map(), future_replicate(), future_apply() and others. They split the input into chunks, one per worker by default (future.scheduling = 1; a larger number makes more, smaller chunks that balance the load, and future.chunk.size fixes the number of elements per chunk), evaluate them as futures, and combine the results with the semantics of the function they replace. The names of the arguments follow the pattern future.*: future.seed for random numbers, future.globals and future.packages to control what is exported, future.stdout and future.conditions for relaying output, future.label for naming. The globals of FUN are found automatically, so the script does not export overdue_rate(), shelf_today or loan_days.
Two properties set it apart from the parallel functions. The numbers from future.seed = 282 do not depend on the plan or the number of workers: the script computes the same six replicates under multisession with two workers, with three workers, and under sequential, and all three are identical, because the streams belong to the elements. (They are not the numbers of the previous section: future.seed uses its own way of deriving the stream of each element from the seed.) And when FUN draws random numbers without future.seed, a warning of class RngFutureWarning says which iteration did, as for a plain future. future_vapply() checks the type of the result like vapply().
source("scenario.R")
library(future)
library(future.apply)
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
serial <- lapply(by_member, overdue_rate, today = shelf_today, days = loan_days)
plan(multisession, workers = 2)
# future_lapply() keeps the names and the shape of lapply(); the globals
# overdue_rate, shelf_today and loan_days are found and exported for us.
par <- future_lapply(by_member, \(rows) {
overdue_rate(rows, shelf_today, loan_days)
})
identical(par, serial)
#> [1] TRUE
# future_vapply() checks the type of every result, like vapply().
rates <- future_vapply(
by_member, overdue_rate, numeric(1), today = shelf_today, days = loan_days
)
all.equal(rates, unlist(serial))
#> [1] TRUE
# future_mapply() walks several vectors together.
future_mapply(\(a, b) a + b, 1:3, 4:6)
#> [1] 5 7 9
# Chunking: one chunk per worker by default (future.scheduling = 1).
# future.chunk.size sets the number of elements per chunk.
par_chunks <- future_lapply(
by_member, overdue_rate, today = shelf_today, days = loan_days,
future.chunk.size = 2
)
identical(par_chunks, serial)
#> [1] TRUE
# Random numbers need future.seed. With it the result does not depend on
# the plan, nor on the number of workers.
durations <- as.integer(loans$return_date - loans$loan_date)
durations <- durations[!is.na(durations) & durations >= 0L]
boot_mean <- function(i) mean(sample(durations, replace = TRUE))
on_two <- future_sapply(1:6, boot_mean, future.seed = 282)
plan(multisession, workers = 3)
on_three <- future_sapply(1:6, boot_mean, future.seed = 282)
plan(sequential)
in_process <- future_sapply(1:6, boot_mean, future.seed = 282)
c(identical(on_two, on_three), identical(on_two, in_process))
#> [1] TRUE TRUE
round(on_two, 2)
#> [1] 18.98 20.73 20.82 19.31 18.08 21.63
# Without future.seed the same call warns.
plan(multisession, workers = 2)
w <- tryCatch(future_sapply(1:2, boot_mean), warning = identity)
class(w)[1]
#> [1] "RngFutureWarning"
msg <- sub(" \\[future .*$", "", conditionMessage(w))
sub("\\(‘future_sapply-[0-9]+’\\)", "(‘future_sapply-n’)", msg)
#> [1] "UNRELIABLE VALUE: One of the ‘future.apply’ iterations (‘future_sapply-n’) unexpectedly generated random numbers without declaring so. There is a risk that those random numbers are not statistically sound and the overall results might be invalid. To fix this, specify 'future.seed=TRUE'. This ensures that proper, parallel-safe random numbers are produced via a parallel RNG method. To disable this check, use 'future.seed = NULL', or set option 'future.rng.onMisuse' to \"ignore\"."
plan(sequential)
furrr
furrr (not installed in the test image, so nothing here was run) combines the purrr mapping functions with future: future_map(), future_map_dbl(), future_map2(), future_pmap(), future_walk(), future_imap() and their variants are near drop-in replacements for map() and its relatives, run according to the current plan(). Random numbers are handled with the .options argument: future_map(x, f, .options = furrr_options(seed = TRUE)), with an integer to fix the seed, and its documentation guarantees the same results whatever the type of future and scheduling (furrr package documentation: furrr_options). furrr shares the globals detection of future, and its documentation has an article on common gotchas (furrr package documentation). Newer code has the alternative of purrr::in_parallel(), described with mirai in the next section.
The mirai package
mirai (2.7.3; the word is Japanese for "future") is a newer framework for the same job, built for low latency and for scaling from a laptop to a scheduler. Its documentation describes the architecture: mirai() sends tasks to daemons, persistent R worker processes that dial in to the host; a dispatcher thread schedules the tasks first in, first out, and handles cancellation. The model differs from a future in three ways that show in the code. The code of a task runs in a clean environment, so everything it needs is named in the call. The result is read from the object, m$data, with an unresolved state that is a logical NA, or collected with m[], and there are no value() calls to forget. And errors and timeouts are values, not conditions: an error comes back as a miraiError and a timeout as the integer 5, so a batch of tasks never loses the tasks that succeeded.
Daemons and mirai()
daemons(n) starts n daemons on the local machine and returns invisibly TRUE (daemons(0) returns FALSE and resets). Calling daemons() again replaces the existing ones, and the daemons exit by themselves when the session ends. Without any daemons, mirai() still works but starts a new background R process for every request, which is slow; mirai_map() refuses to run and says so. mirai(.expr, ..., .args, .timeout) evaluates the expression in a daemon and returns at once. Objects named in ... are assigned to the global environment of the daemon, as if the code ran in your global environment, so they are the way to pass helper functions that call each other; objects in .args stay local to the evaluation. .timeout is in milliseconds: the task resolves to the integer 5 (printed 'errorValue' int 5 | Timed out) if it takes longer. unresolved(m) is TRUE while the task runs and is meant for while and if; call_mirai(m) waits; m[] waits and returns the value; is_mirai_error() and is_error_value() classify the result. The help page lists the other error value, 19 (connection reset), which a task gets if its daemon dies or if daemons(0) is called while it is unresolved.
mirai_map(.x, .f, ..., .args) maps over a vector, list, matrix or data frame (over the rows of the last two), one mirai per element, scheduled to minimise the total time. The arguments are not those of parLapply(): named arguments in ... are objects that .f references and that daemons must have (they go to the global environment of the daemons), while .args is a list of constant arguments passed to .f. Mixing them up is the mistake on the list in the script: y = 10 in ... is not an argument of the function, and the result is the error argument "y" is missing, with no default, returned for every element. The collection options are written in the brackets: x[] returns a list; x[.flat] flattens to a vector and fails on an error or a mixed type; x[.progress] shows a progress bar; x[.stop] stops at the first failure and cancels the rest. A failure in one element does not lose the others, as the script shows with list(1, "a", 3), so only the failures need to be rerun. everywhere() evaluates an expression on every daemon, to load a package or set an option.
Random numbers have their own argument. daemons(n, seed = NULL) gives each daemon a L’Ecuyer-CMRG stream, which is sound but not reproducible; an integer seed makes a stream per mirai, so that mirai_map() gives the same numbers whatever the number of daemons and whichever daemon runs which task. The script runs the same four draws with two daemons and with three and compares them. (The help page of daemons() documents this argument.)
source("scenario.R")
library(mirai)
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
serial <- lapply(by_member, overdue_rate, today = shelf_today, days = loan_days)
# daemons(2) starts two persistent background R processes.
daemons(2)
status()$connections
#> [1] 2
# mirai() returns at once; the code runs in a clean environment, so
# everything it needs is passed by name.
m <- mirai(
{
Sys.sleep(0.5)
overdue_rate(rows, today, days)
},
overdue_rate = overdue_rate, rows = by_member[[1]],
today = shelf_today, days = loan_days
)
class(m)
#> [1] "mirai" "recvAio"
unresolved(m)
#> [1] TRUE
m[]
#> [1] 0.6153846
round(m$data, 2)
#> [1] 0.62
unresolved(m)
#> [1] FALSE
# The value is read from $data; before the result arrives it is an
# "unresolved" NA, and call_mirai() waits for it.
m2 <- mirai({
Sys.sleep(0.3)
"late"
})
is.na(m2$data)
#> [1] TRUE
call_mirai(m2)$data
#> [1] "late"
# What is not passed is not there.
m3 <- mirai(overdue_rate(rows, today, days), rows = by_member[[1]],
today = shelf_today, days = loan_days)
m3[]
#> 'miraiError' chr Error in overdue_rate(rows, today, days): could not find function "overdue_rate"
# An error is a value, not an exception.
m4 <- mirai(stop("boom"))
call_mirai(m4)
is_mirai_error(m4$data)
#> [1] TRUE
is_error_value(m4$data)
#> [1] TRUE
class(m4$data)
#> [1] "miraiError" "errorValue" "try-error"
# A timeout is a value as well: the integer 5.
m5 <- mirai(Sys.sleep(5), .timeout = 200)
call_mirai(m5)$data
#> 'errorValue' int 5 | Timed out
is_error_value(m5$data)
#> [1] TRUE
# mirai_map() maps over a list, one mirai per element, and collects with [].
par <- mirai_map(
by_member, overdue_rate,
.args = list(today = shelf_today, days = loan_days)
)[]
identical(par, serial)
#> [1] TRUE
# Extra arguments in ... are objects for the daemons, not arguments of .f.
bad <- mirai_map(1:2, \(x, y) x + y, y = 10)[]
bad[[1]]
#> 'miraiError' chr Error in (function (x, y) : argument "y" is missing, with no default
ok <- mirai_map(1:2, \(x, y) x + y, .args = list(y = 10))[.flat]
ok
#> [1] 11 12
# Errors in a map are returned element by element, so only they can be re-run.
mixed <- mirai_map(list(1, "a", 3), \(x) x + 1)[]
vapply(mixed, is_mirai_error, logical(1))
#> [1] FALSE TRUE FALSE
# Run code on every daemon, for example to load a package.
invisible(everywhere(library(stats)))
# seed makes the random numbers of mirai_map() reproducible, whatever the
# number of daemons.
daemons(0)
daemons(2, seed = 282)
first <- mirai_map(1:4, \(i) rnorm(1))[.flat]
daemons(0)
daemons(3, seed = 282)
second <- mirai_map(1:4, \(i) rnorm(1))[.flat]
daemons(0)
identical(first, second)
#> [1] TRUE
# Without daemons, mirai() starts a fresh background process per request.
m6 <- mirai(Sys.getpid())
call_mirai(m6)$data == Sys.getpid()
#> [1] FALSE
tryCatch(mirai_map(1:2, identity), error = conditionMessage)
#> [1] "No daemons set.\nUse e.g. `mirai::daemons(6)` to set 6 local daemons."
The remaining arguments of daemons() are for production use. dispatcher = FALSE removes the scheduler (tasks go straight to daemons), memory caps the queued payload, url and remote (built with ssh_config(), cluster_config() for Slurm, SGE, PBS and LSF, http_config() or remote_config()) place daemons on other machines, tls secures the connections, .compute names independent sets of daemons (profiles), and sync = TRUE evaluates tasks in the current process, for debugging. The documentation notes that a package which uses a parallel map should never call daemons() itself, so that the user decides where the work runs; it is the same advice as for plan().
Mirai and the parallel package
mirai also plugs into the parallel API. The mirai documentation says that a miraiCluster is an official cluster type in R 4.5, created with parallel::makeCluster(type = "MIRAI"), which calls mirai::make_cluster(), and that it works with clusterApply(), parLapply(), parLapplyLB() and the other functions of the apply family on a cluster. The first lines of the script at the end of this section use the type with parLapply(). This makes it possible to run code written for the parallel package on mirai daemons, local or remote, without changing it.
purrr::in_parallel and Shiny ExtendedTask
purrr 1.1.0 (2025-07-10) added in_parallel(), a way to make purrr’s map() family parallel with mirai. Its help page: wrap the function passed to .f in in_parallel(), and set mirai::daemons(); without daemons the map falls back to sequential processing, and mirai::require_daemons() makes the lack of daemons an error. in_parallel() also asks for a self-contained function: package functions called with ::, data passed as named arguments in ..., helper functions supplied the same way. It is a thin wrapper around carrier::crate(), and purrr checks that the carrier package (version 0.3.0 or later) is installed: it was not in the test image, so the script shows only that the function exists and the message that the call gives. The fragment below, which was not run, is the shape of its use.
library(purrr)
mirai::daemons(2)
offset <- 10
map_dbl(1:3, in_parallel(\(x) x + offset, offset = offset))
mirai::daemons(0)
Shiny has its own use of the same machinery for an app that must not freeze while one user’s long computation runs: the ExtendedTask class, documented in Shiny 1.14.0, wraps a function that returns a promise (a mirai, or a future_promise()) so that the session stays responsive while the task proceeds in the background; each ExtendedTask object accepts one invocation at a time. Shiny reactives themselves run on a single R thread, so one slow reactive blocks every session of the process: that is what promises and ExtendedTask are for. The pages on Shiny apps are in Shiny, plumber and Web APIs; the class is described at Shiny documentation: ExtendedTask.
library(parallel)
library(mirai)
# R 4.6.1 knows a "MIRAI" cluster type: the parallel API on mirai daemons.
cl <- makeCluster(2, type = "MIRAI")
class(cl)
#> [1] "miraiCluster" "cluster"
unlist(parLapply(cl, 1:4, \(i) i^2))
#> [1] 1 4 9 16
stopCluster(cl)
# purrr 1.1.0 added in_parallel(); it needs the carrier package.
exists("in_parallel", asNamespace("purrr"))
#> [1] TRUE
tryCatch(purrr::in_parallel(\(x) x + 1), error = \(e) conditionMessage(e))
#> [1] "The package \"carrier\" (>= 0.3.0) is required for parallel map."
isTRUE(requireNamespace("carrier", quietly = TRUE))
#> [1] FALSE
# The adapter picks up the daemons you set; without them it runs serially.
exists("ExtendedTask", asNamespace("shiny"))
#> [1] TRUE
daemons(0)
Asynchronous without parallel: later, promises and callr
"Asynchronous" and "parallel" are different words, and R needs both. Parallel means several computations progress at the same time on several cores. Asynchronous means that you ask for something, carry on, and are told when the answer arrives; it can be built on one thread. The parallel, future and mirai packages above are asynchronous when you use mcparallel(), future() or mirai() and fetch later. The packages in this section are asynchronous in the second sense, for code that must stay responsive: a Shiny session, a plumber API, an interactive console.
later and promises
The later package (1.4.8) is an event loop for R. later(func, delay) schedules a function to run after a delay in seconds, like JavaScript’s setTimeout; R is single-threaded, so there is no promise that the function runs exactly on time, only that at least the delay has passed. By default scheduled work runs only when no other R code is on the stack, which means when R is sitting at the console prompt; run_now() lets a script, which never reaches the prompt, run the work that is due, and loop_empty() says whether anything is left. The value returned by later() is a function that cancels the callback. The script shows that the function does not run until run_now().
The promises package (1.5.0) represents a value that is not there yet. promise(\(resolve, reject) ...) creates a promise from a function that starts the work and calls resolve(value) or reject(reason) when it finishes; the help page says it is mainly for package authors. You consume it with then(promise, onFulfilled, onRejected), catch() and finally(), which return new promises and so chain with the native pipe: promise |> then(f) |> catch(g). The older promise pipes, %...>% for success and %...!% for errors, are marked superseded in the help page of promises 1.5.0, because the native pipe and the function shorthand made then() and catch() as short. The key point of the script is the order of output: the line after then() prints before the value, because then() only registers what to do and returns. A mirai is a promise through as.promise() (mirai needs the event loop to deliver it, so the script runs the loop until the task has resolved), and future_promise() is a promise-friendly future() that returns a promise and keeps the event loop free. The mirai package has an article on Shiny and plumber (mirai package documentation: Promises (Shiny and Plumber)).
library(later)
library(promises)
# later() schedules a function; nothing runs until the event loop gets
# control. In a script that is run_now(); at the console it is the prompt.
log <- "before"
later(\() log <<- c(log, "ran later"), delay = 0.1)
log
#> [1] "before"
run_now(timeoutSecs = 1)
log
#> [1] "before" "ran later"
# The value returned by later() cancels the callback.
stop_it <- later(\() cat("never printed\n"), delay = 5)
stop_it()
loop_empty()
#> [1] TRUE
# A promise stands for a value that is not there yet.
slow_value <- promise(\(resolve, reject) {
later(\() resolve(42), delay = 0.1)
})
class(slow_value)
#> [1] "promise"
slow_value |> then(\(v) cat("got", v, "\n"))
cat("this line comes first\n")
#> this line comes first
while (!loop_empty()) run_now(timeoutSecs = 0.5)
#> got 42
# then() chains, catch() handles a rejection.
failing <- promise(\(resolve, reject) {
later(\() reject("failed upstream"), delay = 0.1)
})
failing |>
then(\(v) cat("not reached\n")) |>
catch(\(e) cat("caught:", conditionMessage(e), "\n"))
while (!loop_empty()) run_now(timeoutSecs = 0.5)
#> caught: failed upstream
# The same with the older promise pipe, which is superseded by then().
slow_value %...>% (\(v) cat("piped", v, "\n"))
while (!loop_empty()) run_now(timeoutSecs = 0.5)
#> piped 42
# A mirai is a promise: as.promise() lets the event loop deliver it.
library(mirai)
daemons(2)
m <- mirai({
Sys.sleep(0.3)
"mirai finished"
})
as.promise(m) |> then(\(v) cat(v, "\n"))
while (unresolved(m)) run_now(timeoutSecs = 0.1)
run_now()
daemons(0)
# So is a future, through future_promise() (a promise-friendly future()).
library(future)
plan(multisession, workers = 2)
fp <- future_promise({
Sys.sleep(0.3)
"future finished"
})
fp |> then(\(v) cat(v, "\n"))
while (!loop_empty()) run_now(timeoutSecs = 0.5)
#> mirai finished
#> future finished
plan(sequential)
callr: a background R process
callr::r_bg(func, args) runs a function in a fresh background R session and returns a process object at once, with is_alive(), wait(), get_result(), get_exit_status(), read_all_output() and kill(); callr::r() is the blocking version. It is the simplest tool for "run this slow script while I keep working", and the way the page on HTTP and Web APIs starts a local server for a test and stops it afterwards. The function runs in a clean session, so what it needs goes in args, and an error in the child is signalled by get_result() as an error of class callr_status_error with the child’s message.
library(callr)
# r_bg() runs a function in a fresh background R process and returns.
bg <- r_bg(function(n) {
Sys.sleep(0.3)
sum(seq_len(n))
}, args = list(10L))
class(bg)[1]
#> [1] "r_process"
bg$is_alive()
#> [1] TRUE
invisible(bg$wait())
bg$is_alive()
#> [1] FALSE
bg$get_result()
#> [1] 55
bg$get_exit_status()
#> [1] 0
# The function runs in a clean session: pass what it needs in args.
x <- 1
bad <- r_bg(function() x + 1)
invisible(bad$wait())
tryCatch(bad$get_result(), error = conditionMessage)
#> [1] "! in callr subprocess.\nCaused by error in `(function () ...`:\n! object 'x' not found"
good <- r_bg(function(x) x + 1, args = list(x = x))
invisible(good$wait())
good$get_result()
#> [1] 2
# Errors in the child are signalled by get_result().
failing <- r_bg(function() stop("child failed"))
invisible(failing$wait())
err <- tryCatch(failing$get_result(), error = identity)
class(err)[1]
#> [1] "callr_status_error"
conditionMessage(err)
#> [1] "! in callr subprocess.\nCaused by error in `(function () ...`:\n! child failed"
# Output can be piped back, and a long job can be killed.
talker <- r_bg(function() {
cat("hello from the child\n")
1
}, stdout = "|")
invisible(talker$wait())
talker$read_all_output()
#> [1] "hello from the child\n"
sleeper <- r_bg(function() Sys.sleep(30))
sleeper$is_alive()
#> [1] TRUE
invisible(sleeper$kill())
invisible(sleeper$wait())
sleeper$is_alive()
#> [1] FALSE
# r() is the blocking version.
callr::r(function() Sys.getpid()) == Sys.getpid()
#> [1] FALSE
Threads inside a process: BLAS and data.table
A single R process can use several cores without any of the machinery above, when the compiled code it calls is multi-threaded. Two cases matter, and both interact with the workers you start.
BLAS and LAPACK. R does not implement matrix algebra itself; %*%, crossprod(), solve(), chol(), eigen() and svd() end up in the BLAS and LAPACK libraries, which the Installation and Administration manual says can be replaced by external, usually tuned, libraries (R Installation and Administration: Linear algebra). Several of those, among them OpenBLAS, Intel MKL and Apple’s Accelerate, use multiple threads. The test image has an OpenBLAS build. The manual warns about three consequences: R’s profiling (which uses the SIGPROF signal) may cause problems with a multi-threaded BLAS; a multi-threaded BLAS can take more CPU time, and even more elapsed time, than a single-threaded one, especially on a machine running other tasks; and BLAS calls inside threaded code, such as OpenMP sections in packages like mgcv, may be unsafe with some external libraries. The number of threads is set by environment variables read when the library is loaded: OPENBLAS_NUM_THREADS or OMP_NUM_THREADS for OpenBLAS (depending on the build, as the manual explains for the Fedora packages), and MKL_NUM_THREADS or OMP_NUM_THREADS for MKL (R Installation and Administration: OpenBLAS and BLIS). Changing the variable inside a running session is too late for the library. The RhpcBLASctl package (not installed in the test image, so not run) exists to change the BLAS thread count at run time, with functions such as blas_set_num_threads() and omp_set_num_threads(); setting the variable before the worker starts, as below, needs no package.
data.table. data.table (1.18.6.1) parallelises fread(), fwrite(), grouping, ordering and other operations with OpenMP. Its help page for setDTthreads() says the number of threads is initialised when data.table is loaded, from environment variables, to 50% of the logical CPUs by default; getDTthreads() reports it; setDTthreads(threads) changes it for the session (0 means all logical CPUs); setDTthreads(NULL) reads the environment variables again; and the variables R_DATATABLE_NUM_THREADS (a fixed number), R_DATATABLE_NUM_PROCS_PERCENT and OMP_THREAD_LIMIT (which caps everything) control the initial value. The help page also says that data.table automatically switches to single-threaded mode in a fork, as the one made by mclapply() (and by the foreach package), because nested parallelism would overload the CPUs, and that by default it is multi-threaded again afterwards (restore_after_fork). A PSOCK worker is not a fork, it loads data.table fresh and gets the full default, which is why two workers can oversubscribe a machine.
How many cores a job keeps busy can be measured without timing anything: it is the CPU time (user plus system) divided by the elapsed time. The first part of the script does that in three fresh R processes started with callr, which can be given a different environment: with the default environment the matrix products keep more than two cores busy; with OPENBLAS_NUM_THREADS=1 they keep one; with 2 they keep two. The ratios are rounded, and the first only compared with 2, because it depends on the machine and the BLAS. The rest shows data.table: the thread count in the master, in two forked children (one thread each), and in two PSOCK workers that were started after the environment variables had been set (also one: the variables are read when each worker loads data.table). The master loaded data.table before the variables were set, so it needed setDTthreads(threads = NULL) to read them. The usual rule of thumb is that workers multiplied by threads per worker should not exceed the cores available: with n workers, give each cores / n threads, or one.
library(parallel)
suppressPackageStartupMessages(library(data.table))
# Threads inside one R process: a multi-threaded BLAS is one example. How
# many cores a job keeps busy is CPU time divided by elapsed time.
busy <- function() {
set.seed(1)
m <- matrix(rnorm(1500 * 1500), 1500)
t <- system.time(for (i in 1:3) crossprod(m))
unname((t[["user.self"]] + t[["sys.self"]]) / t[["elapsed"]])
}
# A fresh R process for each measurement, with the BLAS thread count set in
# its environment before it starts. The numbers depend on the machine and
# its BLAS; the shape does not.
default <- callr::r(busy)
one <- callr::r(
busy, env = c(callr::rcmd_safe_env(), OPENBLAS_NUM_THREADS = "1")
)
two <- callr::r(
busy, env = c(callr::rcmd_safe_env(), OPENBLAS_NUM_THREADS = "2")
)
default > 2
#> [1] TRUE
round(one)
#> [1] 1
round(two)
#> [1] 2
# data.table is multi-threaded too (OpenMP), with its own controls.
getDTthreads() >= 2L
#> [1] TRUE
# Inside a forked child it drops to one thread, to avoid nested parallelism.
unlist(mclapply(1:2, \(i) getDTthreads(), mc.cores = 2))
#> [1] 1 1
# A PSOCK worker is a new session: it starts with the full thread count, so
# two workers can oversubscribe the machine. Limit it before it starts.
Sys.setenv(
R_DATATABLE_NUM_THREADS = "1",
OMP_NUM_THREADS = "1",
OPENBLAS_NUM_THREADS = "1"
)
cl <- makeCluster(2)
unlist(clusterEvalQ(cl, Sys.getenv("OPENBLAS_NUM_THREADS")))
#> [1] "1" "1"
unlist(clusterEvalQ(cl, {
library(data.table)
getDTthreads()
}))
#> [1] 1 1
stopCluster(cl)
# data.table reads its environment variable when it is loaded, so this
# session needs setDTthreads() to read it again.
getDTthreads() >= 2L
#> [1] TRUE
invisible(setDTthreads(threads = NULL))
getDTthreads()
#> [1] 1
Choosing a tool
The five ways to parallelise overdue_rate() over the members all return exactly the same list. That is what to check first when moving a computation to workers: the parallel result must be identical to the serial one, apart from random numbers. The script runs the four that were not compared earlier together (forked, PSOCK, future.apply and mirai) and checks each with identical() against the serial result.
source("scenario.R")
library(parallel)
library(future)
library(future.apply)
library(mirai)
overdue_rate <- function(rows, today, days) {
end <- rows$return_date
end[is.na(end)] <- today
mean(as.integer(end - rows$loan_date) > days, na.rm = TRUE)
}
members <- tolower(trimws(loans$member))
keep <- nzchar(members)
by_member <- split(loans[keep, ], members[keep])
serial <- lapply(by_member, overdue_rate, today = shelf_today, days = loan_days)
forked <- mclapply(
by_member, overdue_rate,
today = shelf_today, days = loan_days, mc.cores = 2
)
cl <- makeCluster(2)
socket <- parLapply(
cl, by_member, overdue_rate, today = shelf_today, days = loan_days
)
stopCluster(cl)
plan(multisession, workers = 2)
futures <- future_lapply(
by_member, overdue_rate, today = shelf_today, days = loan_days
)
plan(sequential)
daemons(2)
mirais <- mirai_map(
by_member, overdue_rate,
.args = list(today = shelf_today, days = loan_days)
)[]
daemons(0)
results <- list(
fork = forked, psock = socket, future = futures, mirai = mirais
)
vapply(results, identical, logical(1), y = serial)
#> fork psock future mirai
#> TRUE TRUE TRUE TRUE
Source: R documentation: identical
The table summarises where each tool fits.
| Tool | Works on | Starts workers by | Use it when |
|---|---|---|---|
|
Linux and macOS (Windows runs serially) |
forking |
a quick speed-up on one Unix machine, outside RStudio and GUIs, with no multi-threaded library in the way |
|
everywhere |
|
you want base R only, one pool for many calls, workers on other machines, and full control of what is exported |
|
everywhere, forking where allowed |
chosen by |
code that must run unchanged on a laptop, on a server and on a scheduler; reproducible random numbers with |
|
everywhere |
persistent daemons, local or remote |
low-latency tasks, many small jobs, error values instead of exceptions, remote daemons, Shiny and plumber; |
|
everywhere |
a fresh background R process |
one long job you want to leave running, a test server, a process you must be able to kill |
|
everywhere |
no new process: an event loop |
code that waits for something (a task, a timer, a request) and must stay responsive |
Source: R documentation: mclapply
Pitfalls at a glance
-
Parallelising before measuring. Find the slow part first (Performance, Profiling and Benchmarking); a vectorised or compiled version of the same step is often faster than any number of workers.
-
Tasks that are too small. Dispatch and serialisation cost more than the work. Make fewer, larger chunks (
chunk.size,future.chunk.size, a function that handles a whole group). -
Globals. A worker has none of your variables. Pass them as arguments, export them once with
clusterExport(), or use a framework that finds them (future) or asks for them by name (mirai). -
Random numbers. Use L’Ecuyer-CMRG streams, one per task if the result must not depend on the number of workers; never
set.seed(i)in each worker. -
Too many workers. More workers than cores, or workers times threads above the cores, makes everything slower. Use
parallelly::availableCores(), leave a core free, and limit BLAS and data.table threads in the workers. -
Forking where it is unsafe. In RStudio and other GUIs, with graphics devices open, and with multi-threaded libraries; use a PSOCK cluster or
plan(multisession). -
Forgotten clusters and plans.
on.exit(stopCluster(cl)),plan(sequential)at the end of a script,daemons(0)when done. -
Order and errors. The results come back in the order of the input, but a worker’s error is reported once, with a useless call; reproduce a failure with
lapply()on a small input. -
Large results. Everything returned is serialised through a pipe or a socket; reduce on the worker and return the summary.
Version notes
The entries come from the NEWS files of R (R NEWS; the entries before R 4.0.0 are in R NEWS: Changes in R 3.x), the parallel vignette and the NEWS pages of the packages.
| Version | Change |
|---|---|
R 2.14.0 |
The |
R 3.3.0 |
|
R 3.4.0 |
The low-level functions that pass serialised objects to and from forked children support long vectors on 64-bit platforms. Loading the |
R 3.5.0 |
|
R 3.5.2 |
Fork clusters could hang because of a race condition in the initialisation of |
R 3.6.0 |
The default method of generating from a discrete uniform distribution, used by |
R 4.0.0 |
Starting a homogeneous PSOCK cluster on |
R 4.0.1 |
|
R 4.1.0 |
|
R 4.1.2 |
Selecting the Marsaglia-Multicarry generator warns in more places. |
R 4.2.0 |
|
R 4.5.0 |
The mirai documentation names |
R 4.6.1 |
The |
future 1.20.0 |
|
future 1.32.0, 1.33.0 |
|
future 1.40.0 |
|
future 1.67.0 |
|
purrr 1.1.0 |
|
promises 1.5.0 |
The promise pipes ( |
Where older tutorials differ
| Older tutorial writes | Current practice | Why | Since |
|---|---|---|---|
|
|
the two packages were merged into |
R 2.14.0 |
|
|
|
always |
|
a PSOCK cluster, or |
doMC is forking only, so it fails on Windows and in RStudio |
always |
|
|
different seeds do not guarantee independent streams |
always |
|
|
|
always |
|
|
|
always |
|
a socket cluster or |
forking is not available on Windows and discouraged in GUIs by the help pages |
always |
a cluster started and never stopped |
|
idle workers keep their sockets and memory until the session ends |
always |
|
|
removed from future |
future 1.33.0 |
|
|
the function moved to parallelly |
future 1.20.0 |
|
|
the promise pipes are superseded |
promises 1.5.0 |
|
|
purrr runs the map on mirai daemons itself |
purrr 1.1.0 |
Source: R documentation: mclapply
Where to go next
-
Performance, Profiling and Benchmarking to find what is worth parallelising and to measure whether it was.
-
Memory and Copy Semantics for the copy-on-modify that makes a forked child cheap, and what a worker costs in memory.
-
Distributions and Random Numbers for the generators,
set.seed()andsample(), and the simulations (replicate()) that are the usual parallel workload. -
Interfacing C, C++ and Fortran for moving the hot loop to compiled code, including OpenMP, instead of adding workers.
-
The apply Family and Functionals and Vectorisation and Iteration for the serial versions of everything above.
-
data.table for its internal threading,
setDTthreads()and the operations it parallelises. -
Matrices and Arrays for the BLAS and LAPACK libraries behind multi-threaded matrix algebra.
-
HTTP and Web APIs and Shiny, plumber and Web APIs for background processes and non-blocking apps.
-
Conditions and Error Handling for
tryCatch()andwithCallingHandlers(), used throughout this page. -
Concurrency and Async in Python for the same ideas (processes, threads, event loops) in another language, and Python Reference.