Skip to content

Nested cross-validation fits many models, and the outer folds are independent of one another. nested_tune_grid() and its siblings run those folds on mirai daemons when a pool is connected, and serially otherwise. Nothing in the call changes between the two. This page starts a pool of two daemons, runs the getting-started guide’s loop on it, shows the result identical to the same run made serially, and then goes through what the parallel path checks before it dispatches, what it sends to each daemon, what to do while developing the package, and how a run stops.

?nested_tune_grid carries the reference version of each of those under its parallel section. This page shows them running.

The design and the workflow

These are the guide’s, unchanged: five outer folds of mtcars, five inner folds under each, a random forest with two parameters marked for tuning, and a grid of six candidates. vignette("nested-cv") walks through each of them.

set.seed(1)

folds <- nested_resamples(
  mtcars,
  outside = vfold_cv(v = 5),
  inside = vfold_cv(v = 5)
)

rf <- rand_forest(mtry = tune(), min_n = tune(), trees = 500) |>
  set_engine("ranger") |>
  set_mode("regression")

wf <- workflow(mpg ~ ., rf)

grid <- expand.grid(mtry = c(2L, 5L, 8L), min_n = c(2L, 10L))

Starting a pool

Parallelism is switched on by connecting daemons and off by disconnecting them. There is no argument on the loop functions for it. mirai::daemons() starts the pool, and mirai::status() reports how many daemons are connected. Two or more is the threshold, the same one tune uses.

mirai::daemons(2)

mirai::status()$connections
#> [1] 2

Each daemon is a separate R process. It does not inherit the options, library paths, or environment variables this session set after it was launched, and it loads nestedtune from an installed library rather than from this session. Both points matter below.

The pre-flight

Before any fold is dispatched, the call asks every connected daemon whether it can load nestedtune, whether its copy defines the same internal functions this session’s copy does, and whether it can load each package the workflow and the tuner need, here parsnip, workflows and ranger. A daemon that fails any of those stops the call with a message naming how many daemons are affected and what is missing, rather than letting the run go ahead and every fold come back as an opaque worker failure. A daemon that does not answer within the bound is reported as a non-response, which is a different message, so a slow daemon is never told to install a package it already has. The bound is thirty seconds by default and is an R option, nestedtune.preflight_timeout, in milliseconds.

That first round trip is also what makes each daemon load the tidymodels stack, so the first parallel call after starting a pool is the slow one. Later calls in the same session reuse what the daemons already loaded.

Running the loop

The call is the guide’s call. With a pool connected the outer folds are dispatched to the daemons, and each fold’s inner tuning runs serially on its daemon, because parallelism inside a fold on top of parallelism across folds would oversubscribe the cores.

set.seed(2)

par_res <- nested_tune_grid(wf, folds, grid = grid)

par_res
#> 
#> ── Nested cross-validation results ────────────────────────────────────
#> Outer resamples: 5-fold cross-validation
#> # A tibble: 5 × 9
#>   splits         id    .metrics .selected .inner_metrics    .notes  
#>   <list>         <chr> <list>   <list>    <list>            <list>  
#> 1 <split [25/7]> Fold1 <tibble> <tibble>  <tibble [12 × 8]> <tibble>
#> 2 <split [25/7]> Fold2 <tibble> <tibble>  <tibble [12 × 8]> <tibble>
#> 3 <split [26/6]> Fold3 <tibble> <tibble>  <tibble [12 × 8]> <tibble>
#> 4 <split [26/6]> Fold4 <tibble> <tibble>  <tibble [12 × 8]> <tibble>
#> 5 <split [26/6]> Fold5 <tibble> <tibble>  <tibble [12 × 8]> <tibble>
#> # ℹ 3 more variables: .completed <lgl>, .tuning_seed <int>,
#> #   .outer_fit_seed <int>
#>  Use `summary()` for what the run means: which folds failed, what
#>   each one selected, and the estimate across them.

The result carries no mark of how it was computed. That is deliberate, and the serial run below is why.

Stopping the pool

mirai::daemons(0) disconnects the daemons. After it the same call runs serially.

mirai::daemons(0)

mirai::status()$connections
#> [1] 0

The same run, serially

The seed is the same, so the serial run is the same procedure on the same resamples. Each outer fold draws its tuning seed from the session state at entry, by its position in the design, before anything is dispatched, and each daemon then pins the same generator kind the serial path uses. So the fold’s inner tuning sees the same random stream whichever process runs it.

set.seed(2)

ser_res <- nested_tune_grid(wf, folds, grid = grid)
same <- c(
  metrics = identical(par_res$.metrics, ser_res$.metrics),
  selected = identical(par_res$.selected, ser_res$.selected),
  tuning_seed = identical(par_res$.tuning_seed, ser_res$.tuning_seed)
)

same
#>     metrics    selected tuning_seed 
#>        TRUE        TRUE        TRUE

identical() on the outer scores, the selections and the tuning seeds returns TRUE for each. The two results are identical, and which one was computed on daemons is not recoverable from either. That is what lets a pool be a matter of the session rather than of the analysis: a script gives the same answer on a laptop with no daemons and on a workstation with many.

What crosses the wire

A fold’s payload is its outer split, its inner resamples and its two seeds; the workflow, the grid and the tuner’s settings travel beside it. Every split in a design indexes one shared copy of the data, but serializing does not preserve that sharing, so sending the splits as they are would send one copy of the data per split. The data is therefore stripped from each split before dispatch and refilled on the daemon, and what crosses is the fold’s row indices plus one copy of the data per fold. Two things this does not reach, both of them objects you supply rather than anything the package builds. A recipe keeps a copy of the data it was created with, and a formula carries the environment it was written in, so a workflow built inside a function that holds a large object sends that object with every fold. Building the workflow at the top level avoids the second.

Developing against daemons

Daemons load nestedtune from an installed library. Under devtools::load_all() the host session runs the source tree, and the daemons cannot see it. With no installed copy in the library, the pre-flight stops the call. When the library does hold one, the daemons run that copy, which may be older than the source the host is running. The pre-flight catches the case where the older copy is missing a function the host’s copy has, and asks you to reinstall and restart the pool. A running daemon keeps the namespace it already loaded, so reinstalling underneath it changes nothing until the pool is restarted.

For development, prime the daemons with the source tree instead:

mirai::everywhere(pkgload::load_all("path/to/nestedtune"))

Interrupting and cancelling

Pressing interrupt at the console while folds are outstanding unwinds the blocking wait, restores the random-number state, and asks the daemons to stop the folds they have not finished, so the pool goes idle rather than computing results nobody will read. Stopping is a request. A fold already inside a compiled fitting routine may not be interruptible, and one that has nearly finished may simply finish. Nothing about an interrupted run is recorded, because a fold that was never given a chance to run has not failed.

Cancelling needs mirai’s dispatcher, the process that hands tasks to daemons and can recall them. mirai::daemons(n) starts one by default. A pool started with dispatcher = FALSE has no dispatcher, so the request cannot reach the daemons and the outstanding folds run to completion. The results of such a pool are correct, and it is not refused. What it lacks is the ability to stop, and the call says so at dispatch with a warning of class nestedtune_pool_not_cancellable, once per run.

Here is that pool, with the warning caught by a handler and its class printed. The results it returns are the same as before.

mirai::daemons(2, dispatcher = FALSE)

caught <- NULL

set.seed(2)

nd_res <- withCallingHandlers(
  nested_tune_grid(wf, folds, grid = grid),
  nestedtune_pool_not_cancellable = function(w) {
    caught <<- w
    invokeRestart("muffleWarning")
  }
)

class(caught)
#> [1] "nestedtune_pool_not_cancellable" "rlang_warning"                  
#> [3] "warning"                         "condition"

identical(nd_res$.metrics, ser_res$.metrics)
#> [1] TRUE

mirai::daemons(0)

The message itself names the remedy: restart the pool with mirai::daemons(n), which starts a dispatcher by default.

These mirai daemons were started without a dispatcher, so this run
cannot be cancelled.
ℹ Interrupting it returns control to you, but the outer folds keep
  computing on the pool and their results are never read.
ℹ For a pool that stops when you do, restart it with
  `mirai::daemons(n)`, which starts a dispatcher by default.

One case the call cannot tell apart is documented rather than guessed at. Calling mirai::daemons(0) while folds are outstanding produces exactly what a daemon dying mid-fold produces, so tearing the pool down that way is recorded as fold failures rather than as a cancellation. The other folds keep their results.

What this example cannot show

mtcars has 32 rows and the grid has 6 points, so each fold takes a fraction of a second, and two daemons loading the tidymodels stack cost more than the folds save. This page shows the parallel path working and its result matching the serial one, not a speedup. The speedup arrives where the folds are expensive: larger data, a bigger grid, a slower engine, or preprocessing the loop has to redo in every fold. The arithmetic in vignette("nested-cv") says where the cost lives, and what is parallel here is the outer loop over that cost.