rguides

Parallel Processing with furrr

On a 4-core laptop, a 10,000-iteration bootstrap that takes 14 minutes with purrr::map() usually drops to about 4 minutes with furrr::future_map(). The win is not free. Workers add startup cost, global variables disappear from scope, and random number streams split across processes. Get those three things right and parallel processing in R is a one-line change to existing purrr code. Here is the smallest useful furrr program:

library(furrr)
plan(multisession, workers = 4)

1:10 |> future_map(~ .x^2)
#> [[1]]
#> [1] 1
#> [[2]]
#> [1] 4
#> [[3]]
#> [1] 9
#> [[4]]
#> [1] 16
#> [[5]]
#> [1] 25
#> [[6]]
#> [1] 36
#> [[7]]
#> [1] 49
#> [[8]]
#> [1] 64
#> [[9]]
#> [1] 81
#> [[10]]
#> [1] 100

furrr drops into the tidyverse iteration stack: every purrr::map*() has a future_map*() twin, and switching between the two is a one-line change once a parallel future::plan() is set. Where purrr is single-threaded, furrr distributes the work across R sessions or forked processes, then stitches the results back together in input order.

This guide zooms in on three pieces that are easy to get wrong: which plan() to pick, what furrr_options() actually controls, and how to keep RNG output reproducible when workers run in parallel. It is a focused companion to the broader furrr overview on rguides and assumes you already reach for purrr::map() by default. The official furrr reference is the source of truth for every signature shown below.

TL;DR

  • Call plan(multisession, workers = availableCores() - 1) once at the top of your script, then write future_map() instead of purrr::map().
  • Use multicore on Linux or macOS for speed, multisession everywhere else, and avoid multiprocess (deprecated since 2021).
  • Forward globals with furrr_options(globals = "X") or a character vector. The auto-detect default is correct but slow on large environments.
  • Combine set.seed() and RNGkind("L'Ecuyer-CMRG") in the parent with seed = TRUE for reproducible parallel RNG.
  • Skip .progress = TRUE. Use progressr instead, since .progress is on the way out.

Install and load

install.packages("furrr")

furrr ≥ 0.3.1 supports R ≥ 3.5. R 4.4+ is the smoothest path because you get a stable L’Ecuyer-CMRG RNG stream and access to parallelly::availableCores() for honest worker counts. Older R versions work fine, but the seed handling story is messier and you may need to install parallelly separately. The package is on CRAN, and binary builds exist for Windows, macOS, and the common Linux distributions.

Load furrr after purrr (or tidyverse) so the function names you are shadowing are the ones you actually want. If you only ever call furrr functions with the future_ prefix, the load order does not matter because there is no name collision in the first place.

library(purrr)
library(furrr)

Both packages share the same map*() surface, so code that already uses purrr::map() will usually run with furrr::future_map() once a plan is set. The two functions return the same shape of result, take the same .f shortcuts, and follow the same argument order. The only thing that changes is that workers run in fresh R sessions under multisession, which means the parent environment is not automatically visible from inside .f. Globals, packages, and RNG state have to be declared explicitly through furrr_options().

Parallel processing with furrr in two lines

Pick a strategy, then call future_map() exactly like purrr::map(). The two-line change is the whole point of the package:

library(furrr)
plan(multisession, workers = 4)

# Same call as purrr::map()
result <- 1:10 |> future_map(~ .x^2)

result
#> [[1]]
#> [1] 1
#> [[2]]
#> [1] 4
#> [[3]]
#> [1] 9
#> [[4]]
#> [1] 16
#> [[5]]
#> [1] 25
#> [[6]]
#> [1] 36
#> [[7]]
#> [1] 49
#> [[8]]
#> [1] 64
#> [[9]]
#> [1] 81
#> [[10]]
#> [1] 100

The return type, naming, and shortcut syntax for .f (formula, anonymous function, string, integer index) all match purrr. A switch from purrr to furrr is a search-and-replace of map( to future_map( after the plan() call. Output is the same list-of-results structure you would get from purrr::map(), with names and ordering preserved.

The list of variants is the same one you already know:

future_map(.x, .f, ..., .options = furrr_options(),
           .env_globals = parent.frame(), .progress = FALSE)

future_map_chr(.x, .f, ...)     # character vector
future_map_dbl(.x, .f, ...)     # numeric vector
future_map_int(.x, .f, ...)     # integer vector
future_map_lgl(.x, .f, ...)     # logical vector

future_map_dfr(.x, .f, ..., .id = NULL)   # row-bind into a data frame
future_map_dfc(.x, .f, ...)               # column-bind into a data frame
future_map_vec(.x, .f, ..., .ptype = NULL)

future_walk(.x, .f, ...)       # side effects, return .x invisibly

Two-input and many-input families exist too: future_map2(), future_map2_*(), future_pmap(), future_pmap_*(), future_walk2(), future_pwalk(). Signatures and argument order match purrr::map2() and purrr::pmap().

Which plan() should you pick?

furrr does not parallelise on its own. The strategy lives in future::plan(), and the choice changes which globals, packages, and RNG behaviour you can rely on. Four strategies matter in practice:

PlanPlatformHow it worksWhen to use it
sequentialallno workers; furrr behaves like purrrdebugging, or when furrr must be a no-op
multisessionallfresh R session per workerdefault cross-platform choice, with clean worker state
multicoreLinux, macOSforked R processeslowest startup overhead, with workers sharing parent memory
clusterallPSOCK cluster of hostsmulti-node work, not supported by furrr (use future.apply::future_lapply())

multiprocess still appears in older examples but is deprecated and now falls back to multisession. Don’t write new code against it.

A reasonable starting point:

plan(multisession, workers = parallelly::availableCores() - 1)

Reserve one core for the parent R process and for whatever else is running on the machine. Hyperthreading rarely helps for numeric R work, so the physical core count is what you actually want. On a shared server, drop to availableCores() - 2 if other users are running jobs.

The globals gotcha

The most common new-user failure looks like this:

plan(multisession)   # not multicore: forked workers share parent memory
x <- 1
y <- 2

future_map(1, ~ y)
#> Error in furrr::future_map(1, ~ y) :
#>   object 'y' not found

Under multisession each worker is a fresh R session. It cannot see y in the parent frame, so the lookup fails. Under multicore on Linux or macOS the same code works because forked workers inherit the parent’s memory, but that memory sharing is also why multicore is unsafe with parallel connections, GUI state, and other stateful objects.

The fix is to tell furrr which globals to forward. Pass them by name with furrr_options(globals = ...):

# explicit, fast, no auto-scan
future_map(1:3, ~ y + .x,
           .options = furrr_options(globals = "y"))
#> [[1]]
#> [1] 3
#> [[2]]
#> [1] 4
#> [[3]]
#> [1] 5

The globals argument accepts:

  • TRUE (default), which auto-detects by scanning the calling environment
  • a character vector of names, which only forwards those
  • a named list, which forwards those values as-is
  • NULL, which behaves the same as TRUE

Auto-detection works but it scans the parent frame, which is wasteful for large environments. Naming the globals explicitly is both faster and self-documenting, and it survives refactors where someone moves the call into a function with a busy enclosing scope.

How to tune furrr with furrr_options()

furrr_options() is the central switchboard for everything that furrr cannot infer from your call: which globals to forward, which packages to attach in workers, how to relay stdout and conditions, and how to keep RNG streams reproducible. It also exposes two performance knobs (scheduling and chunk_size) that decide how iterations are batched into futures. Most calls use the defaults; the few that don’t are usually about globals, packages, or seeds. The full signature:

furrr_options(
  ...,                # reserved, must be empty
  stdout = TRUE,            # relay print()/cat() from workers
  conditions = "condition", # condition classes to relay
  globals = TRUE,           # TRUE, NULL, char vec, or named list
  packages = NULL,          # packages to attach in workers
  seed = FALSE,             # FALSE, TRUE, NA, int(1), int(7), or list
  scheduling = 1,           # 0, 1, 2, ..., TRUE, FALSE, or Inf
  chunk_size = NULL,        # integer, Inf, or NULL
  prefix = NULL             # label, e.g. "bootstrap"
)

Chunking many small jobs

Spinning up one future per element is wasteful when each element is fast. Two knobs trade off overhead against granularity:

  • scheduling is the average number of futures per worker
  • chunk_size is the average number of elements per future
# 10,000 tiny jobs: 1 future per element is overhead-heavy
1:10000 |>
  future_map(~ .x * 2,
             .options = furrr_options(chunk_size = 100))

scheduling = Inf (or FALSE) is the “one future per element” mode. It is fine for heterogeneous or long tasks, and slow for thousands of short ones. When in doubt, profile both with bench::mark().

Attaching packages

If .f uses a non-base package, attach it explicitly so every worker has it:

future_map(models, ~ broom::tidy(.x),
           .options = furrr_options(packages = "broom"))

Calling library(broom) inside .f works, but it runs on every iteration and is a code smell. The explicit packages = declaration also documents the dependency for anyone reading the code later.

Quieting workers

Chatty third-party code that calls print() or message() can drown your logs. Suppress selectively:

furrr_options(
  stdout = FALSE,                              # silence print()/cat()
  conditions = structure("condition",          # relay warnings, not messages
                         exclude = "message")
)

Reproducible RNG

This is the part most parallel-R articles get wrong. Three facts from the furrr_options reference:

  1. seed = FALSE (the default) means workers do not inherit the parent’s RNG stream. Calling set.seed(42) in the parent does not give you the same numbers you would get from purrr::map().
  2. furrr uses L’Ecuyer-CMRG to pre-generate per-iteration seeds when seed is set. This is what makes chunked results reproducible across runs.
  3. The parent’s RNG state is forwarded one step after a furrr call returns, so chained furrr calls stay reproducible as long as each one passes seed = TRUE.

The safe incantation is set.seed() plus RNGkind("L'Ecuyer-CMRG") in the parent, plus seed = TRUE on every furrr call:

library(furrr)
plan(multisession, workers = 2)

set.seed(42)
RNGkind("L'Ecuyer-CMRG")
res1 <- 1:5 |>
  future_map(~ rnorm(1),
             .options = furrr_options(seed = TRUE))

set.seed(42)
RNGkind("L'Ecuyer-CMRG")
res2 <- 1:5 |>
  future_map(~ rnorm(1),
             .options = furrr_options(seed = TRUE))

identical(res1, res2)
#> [1] TRUE

Do not expect purrr::map() and furrr::future_map() to produce identical sequences even with aligned seeds. The default R RNG is Mersenne-Twister; the default furrr RNG is L’Ecuyer-CMRG. They are different streams, by design. Both are statistically sound, and neither is a bug.

You can also pass an explicit integer (length 1) or a full L’Ecuyer seed (length 7) to seed = for a fully deterministic stream, which is what the future package documentation recommends for long simulation studies.

Progress bars

The .progress = TRUE argument inside future_map() still works for multisession, multicore, and multiprocess plans, but the furrr reference warns it is on the way out. The replacement is progressr, which is what you should write new code against:

library(progressr)
plan(multisession, workers = 4)
handlers("progress")

with_progress({
  1:100 |>
    future_map(~ Sys.sleep(0.1) + .x,
               .options = furrr_options(scheduling = Inf))
})

progressr works across the futureverse, not just furrr, so the same setup will report progress for future.apply::future_lapply() and friends. It also handles nested map calls and parallel workers correctly, which the old .progress argument never did.

What are the most common furrr mistakes?

Most parallel-processing bugs in furrr come from three things: forgetting to forward globals under multisession, expecting set.seed() alone to make RNG output reproducible (it does not), and assuming furrr works with plan(cluster, ...) (it does not). The list below puts the loudest errors first because that is the order users actually meet them. Each item names the symptom you will see, the cause, and the smallest fix.

Here is what the typical first failure looks like in a real script, and the smallest fix that gets you unstuck:

plan(multisession)
threshold <- 0.95

# This errors under multisession because `threshold` lives in the parent
future_map(1:3, ~ rnorm(1) > threshold)

# The fix: declare the global explicitly
future_map(1:3, ~ rnorm(1) > threshold,
           .options = furrr_options(globals = "threshold"))
  • object 'X' not found under multisession: globals were not forwarded. Fix with furrr_options(globals = "X"), or switch to multicore if you understand the memory-sharing trade-off.
  • furrr_options(seed = TRUE) is not a set.seed() substitute. Call set.seed() and RNGkind("L'Ecuyer-CMRG") in the parent first, and pass seed = TRUE (or a fixed integer) to every furrr call.
  • Order is preserved for future_map_dfr, future_map_dfc, and future_map_vec. Furrr sorts results by index of .x before binding, even though workers finish out of order.
  • packages must be set explicitly for non-base functions used inside .f. Without it, multisession workers will not have the namespace loaded.
  • Cluster plans are not supported by furrr. For PSOCK clusters, use future.apply::future_lapply() from the same futureverse family.
  • Multisession startup is slow on Windows and inside RStudio, since each worker is a fresh R session. For interactive work on tiny tasks, the overhead can erase the speedup. Measure with bench::mark() before assuming furrr wins.
  • Don’t library() inside .f. Use furrr_options(packages = ...) instead. It runs once per worker rather than once per iteration, and is more honest about dependencies.
  • .progress is deprecated. Use progressr instead.

Frequently asked questions

How is furrr different from parallel::mclapply()?

Both distribute work across cores, but parallel::mclapply() is Unix-only and assumes a fork-based model. furrr sits on top of future, so you can switch between multisession, multicore, and cluster plans without changing your map calls. furrr also mirrors the full purrr API (variants, typed returns, helpers) rather than only lapply. A quick side-by-side:

# parallel::mclapply
parallel::mclapply(1:4, function(x) x^2, mc.cores = 2)

# furrr equivalent
plan(multisession, workers = 2)
future_map(1:4, ~ .x^2)

How many workers should you use?

parallelly::availableCores() - 1 is the standard starting point on a shared machine. On a dedicated node you can use availableCores(). Hyperthreading rarely helps for numeric R work, so the physical core count is what you want. If your iterations are memory-bound, fewer workers can actually be faster because you avoid cache thrashing. Measure with bench::mark() before locking in a worker count:

parallelly::availableCores()
#> [1] 8

Can furrr run on Windows?

Yes, but only with plan(multisession, ...). multicore uses fork() and is not available on Windows. The trade-off is higher startup cost per worker because each one is a fresh R session, so for very small jobs the overhead can erase the speedup.

Does furrr support foreach-style clusters?

No. furrr is designed for the multisession, multicore, and (deprecated) multiprocess strategies. For multi-host PSOCK clusters, use future.apply::future_lapply() from the same futureverse family. The future.batchtools package adds support for HPC schedulers like Slurm and SGE on top of the same framework.

Will purrr code run unchanged as furrr?

Mostly yes. The argument order, return types, and shortcut syntax for .f all match — see the purrr map reference for the full contract. The two things to watch for are global variables (which need to be declared via furrr_options(globals = ...)) and random number streams (which use L’Ecuyer-CMRG by default rather than Mersenne-Twister).

Conclusion

furrr is the path of least resistance for parallel processing in R: call plan() once, then write code that looks like purrr. The interesting decisions are which plan() to pick, how to declare your globals and packages via furrr_options(), and how to keep random output reproducible with L’Ecuyer-CMRG seeds. Get those three right and the rest is the same tidyverse code you would have written anyway, with parallel processing as a one-line switch from purrr.

Profile first, measure with bench::mark(), and only reach for furrr when each iteration costs more than the per-future overhead, usually a few milliseconds or more. The honest way to decide is to time both versions on a representative sample of your real workload before committing:

# A representative bench::mark call for an furrr migration
bench::mark(
  purrr = purrr::map(1:200, ~ simulate_one(.x)),
  furrr = furrr::future_map(1:200, ~ simulate_one(.x),
                            .options = furrr_options(packages = "myPkg")),
  check = TRUE, iterations = 5
)

If the median furrr time is at least the median purrr time divided by the number of physical cores, the migration pays for itself. If not, the workload is too cheap per iteration and the worker startup overhead is eating the win. Parallel processing with furrr is rarely the wrong tool, but it is the wrong tool for jobs that finish in microseconds.

See Also