Parallel Computing in R Exercises: 20 Practice Problems
Twenty practice problems on parallel computing in R covering the parallel package, the future framework, furrr, foreach with doParallel, reproducible random number streams, and real bootstrap and simulation workflows. Solutions are hidden behind reveal blocks so you can try first.
library(parallel)
library(future)
library(future.apply)
library(furrr)
library(foreach)
library(doParallel)
library(doRNG)
library(dplyr)
library(purrr)
library(tibble)
library(ggplot2)
library(microbenchmark)
Section 1. The parallel package: cores, forks, and clusters (4 problems)
Exercise 1.1: Detect available cores and reserve one for the OS
Task: Before launching any parallel job, a reporting analyst wants to know how many cores are available on the current machine and how many should be used as workers (always leave one free for the operating system). Use parallel::detectCores() to find the total, subtract one to get the worker count, and save both as a named integer vector to ex_1_1.
Expected result:
#> total workers
#> 8 7
Difficulty: Beginner
The machine reports a total core count, and you want that total alongside a worker count that holds one core back for the operating system.
Get the total with detectCores(), then assemble a named integer vector with c(total = ..., workers = ... - 1L).
ex_1_1 <- # your code here
ex_1_1
Click to reveal solution
total_cores <- parallel::detectCores()
ex_1_1 <- c(total = total_cores, workers = total_cores - 1L)
ex_1_1
#> total workers
#> 8 7
Explanation: detectCores() returns the number of logical CPUs reported by the OS, which on modern Intel and AMD chips includes hyperthreads. The minus-one idiom keeps the machine responsive: if you grab every core, the OS thread that scheduled R itself competes with your workers and overall throughput often drops. Use detectCores(logical = FALSE) if you want physical cores only, which can help when the workload is CPU-bound rather than IO-bound.
Exercise 1.2: Parallel apply over mtcars columns with mclapply
Task: A junior analyst is auditing the mtcars dataset and wants the mean of every column computed in parallel using parallel::mclapply() with two cores. On Windows mc.cores is silently forced to 1, but the call still works. Save the result to ex_1_2 as a named list of length 11.
Expected result:
#> $mpg
#> [1] 20.09062
#>
#> $cyl
#> [1] 6.1875
#>
#> $disp
#> [1] 230.7219
#>
#> ... (8 more elements: hp, drat, wt, qsec, vs, am, gear, carb)
Difficulty: Beginner
A data frame is a list of columns, so a parallel apply can walk every column and reduce each one to its average.
Call mclapply(mtcars, mean, mc.cores = 2).
ex_1_2 <- # your code here
ex_1_2
Click to reveal solution
ex_1_2 <- parallel::mclapply(mtcars, mean, mc.cores = 2)
ex_1_2[1:2]
#> $mpg
#> [1] 20.09062
#>
#> $cyl
#> [1] 6.1875
Explanation: mclapply() is a drop-in parallel replacement for lapply() on Linux and macOS, using fork() to copy the parent process. Forking is fast (copy-on-write memory) but does not exist on Windows, where the function falls back to sequential execution with a warning suppressed. For cross-platform parallel apply, prefer future.apply::future_lapply() or parLapply() with an explicit cluster.
Exercise 1.3: Build and tear down a PSOCK cluster cleanly
Task: The ops team requires that every parallel job releases its workers, even if the job throws an error. Build a two-worker PSOCK cluster with parallel::makeCluster(), run parLapply() to square the numbers 1 through 6, and stop the cluster inside on.exit() so workers always shut down. Save the squared values to ex_1_3 as a numeric vector.
Expected result:
#> [1] 1 4 9 16 25 36
Difficulty: Intermediate
Spin up workers, hand them a squaring job over six numbers, and guarantee they shut down even if the job errors.
Inside a function, build the cluster with makeCluster(2), guard teardown with on.exit(stopCluster(cl)), then unlist(parLapply(cl, seq_len(n), function(x) x^2)).
ex_1_3 <- # your code here
ex_1_3
Click to reveal solution
run_squared <- function(n) {
cl <- parallel::makeCluster(2)
on.exit(parallel::stopCluster(cl))
unlist(parallel::parLapply(cl, seq_len(n), function(x) x^2))
}
ex_1_3 <- run_squared(6)
ex_1_3
#> [1] 1 4 9 16 25 36
Explanation: PSOCK clusters spawn fresh R sessions and communicate over sockets, so they work on every platform including Windows. The on.exit() guard is the single most important pattern in cluster code: without it, a thrown error inside parLapply() leaves orphaned R processes that lock files, hold ports, and bleed memory. Wrapping the cluster lifecycle in a function gives the call site a single tidy entry point.
Exercise 1.4: Export variables and packages to PSOCK workers
Task: Unlike fork-based parallelism, PSOCK workers start as empty R sessions and cannot see variables or packages from the parent. Build a two-worker cluster, define multiplier <- 10 and load dplyr on each worker using clusterExport() and clusterEvalQ(), then compute multiplier * x for x in 1 to 5 using parSapply(). Save the result to ex_1_4.
Expected result:
#> [1] 10 20 30 40 50
Difficulty: Intermediate
PSOCK workers start as empty sessions, so the variable and the package they need must both be shipped over before the compute call.
Use clusterExport(cl, "multiplier") and clusterEvalQ(cl, library(dplyr)), then parSapply(cl, 1:5, function(x) multiplier * x).
ex_1_4 <- # your code here
ex_1_4
Click to reveal solution
cl <- parallel::makeCluster(2)
on.exit(parallel::stopCluster(cl))
multiplier <- 10
parallel::clusterExport(cl, "multiplier")
invisible(parallel::clusterEvalQ(cl, library(dplyr)))
ex_1_4 <- parallel::parSapply(cl, 1:5, function(x) multiplier * x)
parallel::stopCluster(cl)
ex_1_4
#> [1] 10 20 30 40 50
Explanation: clusterExport() copies named objects from the parent environment into each worker, and clusterEvalQ() runs an arbitrary expression on each worker (commonly library() calls). Forgetting either is the most common bug: the parent works but workers throw "object not found" or "could not find function". The future framework removes this friction by detecting needed globals automatically, which is why most modern code uses future and furrr instead of raw PSOCK.
Section 2. The future framework: plans, values, and futures (4 problems)
Exercise 2.1: Set a sequential plan and resolve a single future
Task: A code reviewer wants to demonstrate that future::future() is lazy: nothing actually runs until you call value(). Set plan(sequential) (no actual parallelism, but the API behaves identically), create a future that sleeps for a fraction of a second and returns the square root of 144, then resolve it. Save the resolved value to ex_2_1.
Expected result:
#> [1] 12
#> class(ex_2_1)
#> [1] "numeric"
Difficulty: Beginner
A future only schedules work; nothing computes until you explicitly ask for the result.
Set the plan with plan(sequential), wrap the work in future({ Sys.sleep(0.1); sqrt(144) }), and resolve it with value().
ex_2_1 <- # your code here
ex_2_1
Click to reveal solution
future::plan(future::sequential)
f <- future::future({ Sys.sleep(0.1); sqrt(144) })
ex_2_1 <- future::value(f)
ex_2_1
#> [1] 12
Explanation: A future is a placeholder for a value that may be computed elsewhere or later. future() schedules the work and returns immediately; value() blocks until the result is ready. With plan(sequential) everything runs in the current process so timings are not faster, but the API is identical to multisession or multicore plans, which means you can prototype on sequential, then change one line to scale out without touching the rest of your code.
Exercise 2.2: Use future_lapply for a cross-platform parallel apply
Task: A statistician wants a square-root computation parallelized in a way that works on Windows, macOS, and Linux without if (.Platform$OS.type) branches. Switch the plan to multisession with two workers, then use future.apply::future_lapply() to compute sqrt() of 1 through 8. Save the unlisted result to ex_2_2 as a numeric vector.
Expected result:
#> [1] 1.000000 1.414214 1.732051 2.000000 2.236068 2.449490 2.645751 2.828427
Difficulty: Intermediate
Pick a plan that behaves identically on every operating system, then run a parallel apply of the square root over eight integers.
Set plan(multisession, workers = 2) and call unlist(future_lapply(1:8, sqrt)).
ex_2_2 <- # your code here
ex_2_2
Click to reveal solution
future::plan(future::multisession, workers = 2)
ex_2_2 <- unlist(future.apply::future_lapply(1:8, sqrt))
ex_2_2
#> [1] 1.000000 1.414214 1.732051 2.000000 2.236068 2.449490 2.645751 2.828427
future::plan(future::sequential)
Explanation: multisession spawns persistent background R sessions (built on PSOCK) and reuses them across calls, so the per-call overhead is paid once. future_lapply() is the drop-in replacement for lapply() and handles globals automatically: it scans the function body and ships needed variables to workers without an explicit clusterExport(). Always reset to plan(sequential) at the end of an interactive script so background workers do not linger.
Exercise 2.3: Choose multicore vs multisession by platform
Task: A package author writing portable code wants to pick the fastest available plan: multicore (fork-based, no global serialization) on Linux and macOS, falling back to multisession on Windows. Use supportsMulticore() from the parallelly package (re-exported through future) to branch, set the plan with 2 workers, and capture the chosen plan class name as a single string. Save to ex_2_3.
Expected result:
#> [1] "multisession" # on Windows
#> [1] "multicore" # on Linux/macOS
Difficulty: Intermediate
Fork-based parallelism is faster but unavailable on some platforms, so probe support first and fall back when it is missing.
Branch on supportsMulticore() between plan(multicore, workers = 2) and plan(multisession, workers = 2), then read class(plan())[1].
ex_2_3 <- # your code here
ex_2_3
Click to reveal solution
if (future::supportsMulticore()) {
future::plan(future::multicore, workers = 2)
} else {
future::plan(future::multisession, workers = 2)
}
ex_2_3 <- class(future::plan())[1]
ex_2_3
#> [1] "multisession"
future::plan(future::sequential)
Explanation: Fork-based parallelism is faster to start up (no R session spawn, no global serialization) but is unsafe inside RStudio and absent on Windows. supportsMulticore() is a single-line portability check that returns FALSE on Windows or inside the RStudio GUI, so the same code can ship to a teammate on a different OS without breaking. The chosen plan class is returned by class(plan()) and is useful for logging which mode actually ran.
Exercise 2.4: Inspect future resolution state without blocking
Task: A platform engineer building a dashboard wants to launch three slow futures, then check which ones have finished without waiting for the slow ones. Set plan(multisession, workers = 3), create three futures (Sys.sleep(0.5), Sys.sleep(0.05), Sys.sleep(0.5) returning 1, 2, 3 respectively), wait long enough for only the fast one to finish, and call resolved() on each. Save the logical vector to ex_2_4.
Expected result:
#> [1] FALSE TRUE FALSE # the middle (fast) future is resolved
Difficulty: Advanced
Launch several jobs of differing length, pause briefly, then ask each one whether it has finished without waiting on the slow ones.
Store the three future({ Sys.sleep(...); ... }) calls in a list and poll each with vapply(fs, resolved, logical(1)).
ex_2_4 <- # your code here
ex_2_4
Click to reveal solution
future::plan(future::multisession, workers = 3)
fs <- list(
future::future({ Sys.sleep(0.5); 1 }),
future::future({ Sys.sleep(0.05); 2 }),
future::future({ Sys.sleep(0.5); 3 })
)
Sys.sleep(0.2)
ex_2_4 <- vapply(fs, future::resolved, logical(1))
ex_2_4
#> [1] FALSE TRUE FALSE
future::plan(future::sequential)
Explanation: resolved() is non-blocking: it asks "is this future done yet?" and returns immediately, unlike value() which waits. This is the key building block for progress bars (progressr), throttled queues, and dashboards that need to poll multiple long-running jobs. The pattern generalizes: launch many futures, store them in a list, then either vapply(fs, resolved, ...) to poll or future::value(fs) to block on all of them.
Section 3. furrr: purrr-style parallel maps (4 problems)
Exercise 3.1: Square integers with future_map_dbl
Task: A junior analyst onboarding to furrr wants the typed parallel equivalent of purrr::map_dbl(). Set plan(multisession, workers = 2), then use furrr::future_map_dbl() to square the integers 1 through 6. Save the numeric vector to ex_3_1.
Expected result:
#> [1] 1 4 9 16 25 36
Difficulty: Beginner
You want the parallel counterpart of a typed map that guarantees a numeric vector as its output.
Under plan(multisession, workers = 2), call future_map_dbl(1:6, function(x) x^2).
ex_3_1 <- # your code here
ex_3_1
Click to reveal solution
future::plan(future::multisession, workers = 2)
ex_3_1 <- furrr::future_map_dbl(1:6, function(x) x^2)
ex_3_1
#> [1] 1 4 9 16 25 36
future::plan(future::sequential)
Explanation: The future_map_*() family mirrors purrr::map_*(): future_map() returns a list, future_map_dbl() enforces a numeric vector, future_map_chr() a character vector, and so on. The type-stable variants fail loudly if a worker returns the wrong type, which catches bugs (NA propagation, NULL returns) faster than a permissive future_map() plus a manual unlist(). Whenever you know the output type, prefer the typed variant.
Exercise 3.2: Row-bind worker results with future_map_dfr
Task: A performance reviewer wants per-group summaries computed in parallel and stacked into one tibble. Split mtcars by cyl, then for each chunk compute a one-row tibble with mean mpg and mean hp using furrr::future_map_dfr(). The .id = "cyl" argument labels each row with the input name. Save the result to ex_3_2.
Expected result:
#> # A tibble: 3 x 3
#> cyl mean_mpg mean_hp
#> <chr> <dbl> <dbl>
#> 1 4 26.7 82.6
#> 2 6 19.7 122.
#> 3 8 15.1 209.
Difficulty: Intermediate
Break the data into per-group chunks, summarise each chunk into a single row, and stack the rows into one table.
Use future_map_dfr(split(mtcars, mtcars$cyl), function(d) tibble(mean_mpg = mean(d$mpg), mean_hp = mean(d$hp)), .id = "cyl").
ex_3_2 <- # your code here
ex_3_2
Click to reveal solution
future::plan(future::multisession, workers = 2)
ex_3_2 <- furrr::future_map_dfr(
split(mtcars, mtcars$cyl),
function(d) tibble::tibble(mean_mpg = mean(d$mpg), mean_hp = mean(d$hp)),
.id = "cyl"
)
ex_3_2
#> # A tibble: 3 x 3
#> cyl mean_mpg mean_hp
#> <chr> <dbl> <dbl>
#> 1 4 26.7 82.6
#> 2 6 19.7 122.
#> 3 8 15.1 209.
future::plan(future::sequential)
Explanation: future_map_dfr() is the parallel equivalent of purrr::map_dfr() and is the right pattern for split-apply-combine where each group fits comfortably in a worker. The .id argument turns the names of the input list into a column, which keeps the group key in the result. For very fine-grained groups (thousands of small chunks), the per-chunk serialization overhead can eat the parallel gains; profile first.
Exercise 3.3: Reproducible random draws with future.seed = TRUE
Task: A statistician needs three parallel Monte Carlo draws of 100 standard-normal values and wants exactly the same numbers every run on every machine, regardless of how many workers are used. Use furrr::future_map() with .options = furrr::furrr_options(seed = 123) to draw the three samples, then keep only the means as a length-3 numeric vector. Save to ex_3_3.
Expected result:
#> [1] -0.05768771 0.07730672 -0.03862729 # exact values reproducible
Difficulty: Advanced
Parallel random draws are only reproducible when each iteration receives its own deterministic random stream.
Pass .options = furrr_options(seed = 123) to future_map(), then reduce each draw with vapply(draws, mean, numeric(1)).
ex_3_3 <- # your code here
ex_3_3
Click to reveal solution
future::plan(future::multisession, workers = 2)
draws <- furrr::future_map(
1:3,
function(i) rnorm(100),
.options = furrr::furrr_options(seed = 123)
)
ex_3_3 <- vapply(draws, mean, numeric(1))
ex_3_3
#> [1] -0.05768771 0.07730672 -0.03862729
future::plan(future::sequential)
Explanation: Naive set.seed() does NOT make parallel code reproducible: each worker has its own RNG state, and which iteration lands on which worker depends on load balancing. furrr_options(seed = 123) builds a parallel-safe L'Ecuyer-CMRG stream so iteration i always gets the same sub-stream regardless of worker count. Forgetting this option is the most common reproducibility bug in parallel R code; the gate seed = TRUE (without an integer) auto-generates a stream you can record for later replay.
Exercise 3.4: Parallel pmap across two argument vectors
Task: A take-home interviewer wants a parallel multi-argument map: pair x = 1:4 with y = c(10, 20, 30, 40) and compute x * y for each pair using furrr::future_pmap_dbl(). Use a list-input of length-2 named list elements (or a tibble). Save the numeric result to ex_3_4.
Expected result:
#> [1] 10 40 90 160
Difficulty: Intermediate
Pair up two equal-length inputs row by row and multiply each pair in parallel.
Build a tibble(x = 1:4, y = c(10, 20, 30, 40)) and pass it to future_pmap_dbl(args_df, function(x, y) x * y).
ex_3_4 <- # your code here
ex_3_4
Click to reveal solution
future::plan(future::multisession, workers = 2)
args_df <- tibble::tibble(x = 1:4, y = c(10, 20, 30, 40))
ex_3_4 <- furrr::future_pmap_dbl(args_df, function(x, y) x * y)
ex_3_4
#> [1] 10 40 90 160
future::plan(future::sequential)
Explanation: future_pmap_dbl() walks over a list of equal-length vectors (or a tibble's rows) and calls the supplied function with each row's columns as named arguments. It is the parallel equivalent of purrr::pmap_dbl() and is the cleanest way to parameter-sweep across two or more inputs without manually building Map() or nested loops. The argument names of the function must match the column names of the tibble.
Section 4. foreach and doParallel (3 problems)
Exercise 4.1: Sequential foreach loop returning a list
Task: A junior analyst wants to see foreach running in its sequential mode first, before adding parallel backends. Use foreach::foreach() with %do% (sequential operator) to square 1 through 5 and let the default .combine = NULL return a list. Save to ex_4_1.
Expected result:
#> [[1]]
#> [1] 1
#>
#> [[2]]
#> [1] 4
#> ... (3 more elements: 9, 16, 25)
Difficulty: Beginner
Run the loop replacement in its sequential mode first, letting the default combiner collect the results into a list.
Use foreach(i = 1:5) %do% { i^2 }.
ex_4_1 <- # your code here
ex_4_1
Click to reveal solution
ex_4_1 <- foreach::foreach(i = 1:5) %do% { i^2 }
ex_4_1[1:2]
#> [[1]]
#> [1] 1
#>
#> [[2]]
#> [1] 4
Explanation: foreach is a for-loop replacement that always returns its values rather than relying on side effects. %do% runs sequentially; %dopar% runs in parallel against whatever backend is registered. The default combiner returns a list, but .combine = c, .combine = "+", or .combine = rbind are common. Getting the sequential version right first means the parallel switch is a one-character change.
Exercise 4.2: Parallel foreach with doParallel and combine = c
Task: The audit team needs the parallel sum of squares 1^2 + 2^2 + ... + 10^2 = 385, computed with foreach over a doParallel backend on two workers, using .combine = "+" to sum inside the foreach call directly. Register the backend, run the loop, stop the cluster, and save the integer sum to ex_4_2.
Expected result:
#> [1] 385
Difficulty: Intermediate
Register a parallel backend, then let the loop itself add the partial squares together as they return.
After registerDoParallel(makeCluster(2)), run foreach(i = 1:10, .combine = "+") %dopar% { i^2 }.
ex_4_2 <- # your code here
ex_4_2
Click to reveal solution
cl <- parallel::makeCluster(2)
doParallel::registerDoParallel(cl)
on.exit(parallel::stopCluster(cl))
ex_4_2 <- foreach::foreach(i = 1:10, .combine = "+") %dopar% {
i^2
}
parallel::stopCluster(cl)
ex_4_2
#> [1] 385
Explanation: .combine = "+" calls Reduce("+", results) so the workers can each compute partial squares and the reducer adds them, which avoids materializing a length-10 vector in the parent. Common combiners: c for a flat vector, cbind/rbind for matrices, data.frame for tabular output (slow on big data, prefer dplyr::bind_rows() afterward). The registerDoParallel() plus stopCluster() pair is the boilerplate; the modern alternative is doFuture::registerDoFuture() to drive foreach with whatever future plan is active.
Exercise 4.3: Reproducible parallel foreach with doRNG
Task: A biostatistician needs five parallel rnorm(3) draws that are bit-identical across runs and across worker counts. Register doParallel over two workers, use the %dorng% operator from the doRNG package with a seed of 42, and bind the rows of the five three-element vectors into a 5x3 matrix. Save to ex_4_3.
Expected result:
#> [,1] [,2] [,3]
#> [1,] 0.8597694 1.3286145 -0.8919004
#> [2,] -0.4796100 0.2173026 -0.0556542
#> [3,] 0.1571086 -0.9685743 0.4022046
#> [4,] -0.2773792 1.1471551 1.4060958
#> [5,] 1.4669218 -0.1232078 0.2842646
Difficulty: Advanced
Plain parallel loops are not reproducible because worker hand-off timing varies, so you need an operator backed by a parallel-safe random stream.
Replace %dopar% with %dorng% and set .options.RNG = 42 inside foreach(i = 1:5, .combine = rbind).
ex_4_3 <- # your code here
ex_4_3
Click to reveal solution
cl <- parallel::makeCluster(2)
doParallel::registerDoParallel(cl)
on.exit(parallel::stopCluster(cl))
ex_4_3 <- foreach::foreach(i = 1:5, .combine = rbind,
.options.RNG = 42) %dorng% {
rnorm(3)
}
attr(ex_4_3, "rng") <- NULL # drop the rng attribute for printing
rownames(ex_4_3) <- NULL
parallel::stopCluster(cl)
ex_4_3
Explanation: %dorng% from the doRNG package replaces %dopar% and threads a parallel-safe L'Ecuyer-CMRG stream through every iteration. The .options.RNG = 42 argument seeds that stream so the same seed produces the same numbers regardless of how many workers handle each iteration. Plain %dopar% plus set.seed() is NOT reproducible because the timing of worker hand-offs is non-deterministic; reach for %dorng% whenever results need to match across runs.
Section 5. Real-world parallel workflows (5 problems)
Exercise 5.1: Parallel bootstrap of the mtcars mpg median
Task: A statistician wants a parallel bootstrap of the median mpg in mtcars using 1,000 resamples with a reproducible seed. Set plan(multisession, workers = 2) and use furrr::future_map_dbl() with furrr_options(seed = 7) to return the median of each resampled vector. Save the length-1000 numeric vector to ex_5_1 and print its summary().
Expected result:
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 15.00 18.50 19.20 19.27 20.10 24.40
Difficulty: Advanced
A bootstrap is many independent resamples, each summarised down to one statistic, so it parallelizes cleanly with a reproducible seed.
Use future_map_dbl(seq_len(1000), function(i) median(sample(mtcars$mpg, replace = TRUE)), .options = furrr_options(seed = 7)).
ex_5_1 <- # your code here
summary(ex_5_1)
Click to reveal solution
future::plan(future::multisession, workers = 2)
ex_5_1 <- furrr::future_map_dbl(
seq_len(1000),
function(i) median(sample(mtcars$mpg, replace = TRUE)),
.options = furrr::furrr_options(seed = 7)
)
summary(ex_5_1)
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 15.00 18.50 19.20 19.27 20.10 24.40
future::plan(future::sequential)
Explanation: Bootstrap is the textbook parallel workload: 1,000 independent resamples with no cross-iteration state. The seed option threads a reproducible RNG stream so a 5-worker run and a 16-worker run produce the same 1,000 medians in the same positions. For real production bootstraps, 1,000 iterations is a starting point; precision of the standard error scales with sqrt(B), so 10,000 is a common target when CIs need a third significant digit.
Exercise 5.2: Parallel cross-validation MSE across 5 folds
Task: An ML engineer is timing 5-fold linear-regression cross-validation in parallel on mtcars. Split row indices into 5 folds, fit mpg ~ wt + hp on the training rows, predict on the held-out fold, and return the fold's mean squared error. Use future_map_dbl() with furrr_options(seed = 1). Save the length-5 vector to ex_5_2.
Expected result:
#> [1] 5.06 9.32 4.81 7.55 3.18 # 5 fold MSEs; mean ~ 5.98
Difficulty: Advanced
Each cross-validation fold trains on its complement and scores on itself, and the folds never interact.
Build folds with split(sample(seq_len(nrow(mtcars))), rep(1:5, length.out = nrow(mtcars))), then map them with future_map_dbl(folds, function(test_idx) { ... }, .options = furrr_options(seed = 1)) returning mean((predict(fit, test) - test$mpg)^2).
ex_5_2 <- # your code here
ex_5_2
Click to reveal solution
future::plan(future::multisession, workers = 2)
set.seed(1)
folds <- split(sample(seq_len(nrow(mtcars))), rep(1:5, length.out = nrow(mtcars)))
ex_5_2 <- furrr::future_map_dbl(
folds,
function(test_idx) {
train <- mtcars[-test_idx, , drop = FALSE]
test <- mtcars[ test_idx, , drop = FALSE]
fit <- lm(mpg ~ wt + hp, data = train)
mean((predict(fit, test) - test$mpg)^2)
},
.options = furrr::furrr_options(seed = 1)
)
ex_5_2
future::plan(future::sequential)
Explanation: Each fold is independent so cross-validation parallelizes perfectly. Two seed touch points matter: set.seed(1) before sample() controls how rows are assigned to folds (a serial operation), and furrr_options(seed = 1) controls any random calls inside the worker function. For pure least-squares regression there are no random calls in the fit, but the option is still wise habit so the same code stays reproducible when you swap in random forest or boosting.
Exercise 5.3: Embarrassingly parallel Monte Carlo pi estimation
Task: A take-home interviewer asks for a parallel Monte Carlo estimate of pi using 4 batches of 100,000 draws each (400,000 total). Inside each batch, draw uniform x and y in [-1, 1] and count the fraction inside the unit circle; multiply by 4 to estimate pi. Use future_map_dbl() with furrr_options(seed = 99), then average the four estimates. Save the scalar estimate to ex_5_3.
Expected result:
#> [1] 3.141758 # close to pi = 3.14159; exact value reproducible
Difficulty: Intermediate
Estimate pi from the fraction of random points landing inside the unit circle, run several independent batches, and average them.
Define a batch function returning 4 * mean(x^2 + y^2 <= 1), map it with future_map_dbl(rep(1e5, 4), batch_pi, .options = furrr_options(seed = 99)), then take the mean().
ex_5_3 <- # your code here
ex_5_3
Click to reveal solution
future::plan(future::multisession, workers = 2)
batch_pi <- function(n) {
x <- runif(n, -1, 1)
y <- runif(n, -1, 1)
4 * mean(x^2 + y^2 <= 1)
}
estimates <- furrr::future_map_dbl(
rep(1e5, 4),
batch_pi,
.options = furrr::furrr_options(seed = 99)
)
ex_5_3 <- mean(estimates)
ex_5_3
#> [1] 3.141758
future::plan(future::sequential)
Explanation: Monte Carlo with independent batches is the canonical "embarrassingly parallel" workload: zero cross-batch communication, perfect linear speedup until disk or RAM bottlenecks kick in. Computing one mean of the per-batch estimates is mathematically equivalent to one large estimate when batch sizes are equal, and it gives you a free per-batch standard error: sd(estimates) / sqrt(length(estimates)) quantifies how much pi is wandering between batches.
Exercise 5.4: Parallel grouped summarise across diamonds carat bins
Task: A jeweller wants the median price per carat bin for the ggplot2::diamonds data, computed in parallel across the 6 bins. Cut carat into c(0, 0.5, 1, 1.5, 2, 3, 6), split the data into bin-keyed groups, and use future_map_dfr(.id = "carat_bin") to return one row per bin with the bin label and median price. Save the tibble to ex_5_4.
Expected result:
#> # A tibble: 6 x 2
#> carat_bin median_price
#> <chr> <dbl>
#> 1 (0,0.5] 718
#> 2 (0.5,1] 2245
#> 3 (1,1.5] 6011
#> 4 (1.5,2] 11791
#> 5 (2,3] 14749.
#> 6 (3,6] 16238
Difficulty: Intermediate
Bucket a continuous column into ranges, split the data by bucket, and summarise each bucket to one row in parallel.
Use cut(d$carat, breaks = c(0, 0.5, 1, 1.5, 2, 3, 6)), then future_map_dfr(split(d, d$carat_bin), function(chunk) tibble(median_price = median(chunk$price)), .id = "carat_bin").
ex_5_4 <- # your code here
ex_5_4
Click to reveal solution
future::plan(future::multisession, workers = 2)
d <- ggplot2::diamonds
d$carat_bin <- cut(d$carat, breaks = c(0, 0.5, 1, 1.5, 2, 3, 6))
ex_5_4 <- furrr::future_map_dfr(
split(d, d$carat_bin),
function(chunk) tibble::tibble(median_price = median(chunk$price)),
.id = "carat_bin"
)
ex_5_4
future::plan(future::sequential)
Explanation: Splitting before mapping is fine for a handful of groups, but for hundreds of groups the serialization cost (each chunk is shipped to a worker) dominates. The rule of thumb: each chunk should keep a worker busy for at least 50 to 100 milliseconds, otherwise stay sequential or batch groups together. For very many tiny groups, plain dplyr::summarise() or data.table is faster than any parallel approach because it stays single-threaded but vectorized.
Exercise 5.5: Parallel hyperparameter grid sweep
Task: A scout wants the AIC of lm(mpg ~ ., data = mtcars) evaluated across a grid of three subset sizes (4, 6, 8 predictors taken in order) in parallel. For each k, fit lm using only the first k predictor columns (cols 2 to k+1 of mtcars) and return AIC. Use future_map_dfr() to build a tibble with columns k and aic. Save to ex_5_5.
Expected result:
#> # A tibble: 3 x 2
#> k aic
#> <int> <dbl>
#> 1 4 163.
#> 2 6 161.
#> 3 8 163.
Difficulty: Advanced
Each grid point fits an independent model and reports one score, so the sweep is just a parallel map over the grid of subset sizes.
Map over c(4, 6, 8) with future_map_dfr(), subsetting mtcars[, c(1, 2:(k + 1))], fitting lm(mpg ~ ., data = sub), and returning tibble(k = k, aic = AIC(fit)).
ex_5_5 <- # your code here
ex_5_5
Click to reveal solution
future::plan(future::multisession, workers = 2)
ex_5_5 <- furrr::future_map_dfr(
c(4, 6, 8),
function(k) {
sub <- mtcars[, c(1, 2:(k + 1))]
fit <- lm(mpg ~ ., data = sub)
tibble::tibble(k = k, aic = AIC(fit))
}
)
ex_5_5
future::plan(future::sequential)
Explanation: Hyperparameter sweeps are the second canonical parallel workload after Monte Carlo: each configuration is independent and fits a complete model. For a real sweep with thousands of configurations, prefer tune (tidymodels) or mlr3 which add resume-on-failure, progress bars, and intelligent search; for a quick custom sweep with under a thousand cells, future_map_dfr() plus a small parameter grid is faster to write and good enough.
Section 6. Reliability, benchmarking, and shutdown (3 problems)
Exercise 6.1: Catch worker errors with safely
Task: A platform engineer wants a parallel job that does NOT crash on a single failed worker: input c(4, -1, 9, -2, 16) with sqrt() should produce results for the non-negative values and capture errors for the negative ones (using base R, sqrt(-1) returns NaN with a warning, not an error, so use a guard that throws an error on negative input). Wrap with purrr::safely() and run via future_map(). Save the list-of-result-error pairs to ex_6_1 and extract just the successful results.
Expected result:
#> $`1`
#> [1] 2
#>
#> $`2`
#> NULL # negative input, captured as error
#>
#> $`3`
#> [1] 3
#> ... (2 more elements)
Difficulty: Advanced
You want a parallel job where one bad input is captured as an error rather than aborting the whole run.
Wrap the worker with purrr::safely(), run it through future_map(c(4, -1, 9, -2, 16), guarded_sqrt), and pull successes with map(ex_6_1, "result").
ex_6_1 <- # your code here
purrr::map(ex_6_1, "result")
Click to reveal solution
future::plan(future::multisession, workers = 2)
guarded_sqrt <- purrr::safely(function(x) {
if (x < 0) stop("negative input")
sqrt(x)
})
ex_6_1 <- furrr::future_map(c(4, -1, 9, -2, 16), guarded_sqrt)
purrr::map(ex_6_1, "result")
#> $`1`
#> [1] 2
#>
#> $`2`
#> NULL
#>
#> $`3`
#> [1] 3
future::plan(future::sequential)
Explanation: safely() wraps a function so it returns a list with two slots: result (or NULL on error) and error (or NULL on success). In parallel code this is the difference between a job that finishes with partial output and a job that crashes on iteration 47 of 10,000. The sibling purrr::possibly(f, otherwise = NA) is simpler when you just want a sentinel value, while safely() keeps the error messages for post-mortem inspection.
Exercise 6.2: Benchmark sequential vs parallel apply
Task: A performance reviewer wants concrete evidence that parallelism helps only when the per-iteration work is expensive enough. Use microbenchmark::microbenchmark() to compare lapply() versus future.apply::future_lapply() (under plan(multisession, workers = 2)) on a workload that sleeps 0.1 seconds and returns x^2 for x in 1:4 (40 calls reduced into 4 here). Save the benchmark object's median times in milliseconds as a named numeric vector to ex_6_2.
Expected result:
#> sequential parallel
#> ~ 400 ~ 220 # ms; parallel ~2x faster on 2 workers
Difficulty: Intermediate
Measure the wall time of the same heavy workload run serially versus across workers, then extract the typical times.
Compare lapply and future_lapply inside microbenchmark(..., times = 3), then aggregate(time ~ expr, ..., FUN = median) and divide by 1e6.
ex_6_2 <- # your code here
ex_6_2
Click to reveal solution
future::plan(future::multisession, workers = 2)
work <- function(x) { Sys.sleep(0.1); x^2 }
bm <- microbenchmark::microbenchmark(
sequential = lapply(1:4, work),
parallel = future.apply::future_lapply(1:4, work),
times = 3
)
medians <- aggregate(time ~ expr, data = bm, FUN = median)
ex_6_2 <- setNames(medians$time / 1e6, medians$expr)
ex_6_2
future::plan(future::sequential)
Explanation: Parallel speedup is bounded by Amdahl's law: only the iterated portion benefits, while serialization (shipping inputs to workers, receiving outputs) is fixed overhead. The 0.1-second sleep makes the iteration heavy enough that two workers roughly halve the wall time. If you halve the sleep to 0.001 seconds, the overhead dominates and multisession is SLOWER than lapply(). The "should I parallelize?" question is empirical: benchmark first, then decide.
Exercise 6.3: Clean shutdown of multisession workers
Task: The SRE oncall is responding to an alert about R sessions consuming memory after a long script finished. Show the right shutdown idiom: start plan(multisession, workers = 2), run a trivial future_map(1:2, sqrt) job, then explicitly tear down the workers by setting plan(sequential). Capture the post-shutdown plan class name. Save it to ex_6_3.
Expected result:
#> [1] "sequential"
Difficulty: Beginner
Background workers linger after a job unless you explicitly return the session to its non-parallel state.
After the job, call plan(sequential) and capture class(plan())[1].
ex_6_3 <- # your code here
ex_6_3
Click to reveal solution
future::plan(future::multisession, workers = 2)
invisible(furrr::future_map(1:2, sqrt))
future::plan(future::sequential)
ex_6_3 <- class(future::plan())[1]
ex_6_3
#> [1] "sequential"
Explanation: multisession workers persist across calls so subsequent jobs avoid the spawn overhead, but they keep R processes (and their memory) alive in the background until the script ends or you explicitly reset the plan. Setting plan(sequential) is the documented teardown idiom; it sends a shutdown signal to the worker pool. Leaving stale workers around is a common cause of "my laptop is hot for no reason" tickets and shows up as orphaned Rscript processes in top.
What to do next
- Read Parallel Computing in R for the conceptual overview of cores, processes, and the cost model behind the choices in these exercises.
- Try Apply Family Exercises in R for the sequential
lapply,sapply,vapplypatterns these problems parallelize. - Work through Functional Programming in R Exercises for the purrr
mapfamily thatfurrrmirrors. - For grouped-data workflows where parallel maps shine, see dplyr Exercises in R.
r-statistics.co · Verifiable credential · Public URL
This document certifies mastery of
Parallel Computing Mastery
Every certificate has a public verification URL that proves the holder passed the assessment. Anyone with the link can confirm the recipient and date.
118 learners have earned this certificate