Why wispack hand-rolls its own forking
forking.Rmdwisp()’s bootstrap-resampling step
(bs_batch(), implemented in src/wspc.cpp and
src/fork_utils.h) distributes many independent per-resample
model fits – each an NLopt optimization over a Stan Math autodiff model
– across forked child processes, rather than using base R’s
parallel::mclapply(). A CRAN reviewer may reasonably ask
why a package forks its own processes at the C++ level instead of
calling a standard, already-reviewed base-R parallelism primitive. This
page is that justification, backed by a reproducible benchmark rather
than assertion.
Short answer: for the small models in this package’s
own tests and tutorials, mclapply() is actually a little
faster. For the large models this package is designed for in
practice (the kind of fit a user runs with 1,000+ parameters and many
bootstraps), the result reverses – mclapply() becomes
measurably slower, and uses measurably more peak memory,
because its long-lived worker processes accumulate
heap-allocator-retained memory across many sequential bootstrap fits.
The hand-rolled forking code avoids this because it forks a fresh batch
of short-lived children for every batch of resamples; each child’s
entire address space, including whatever it allocated, is reclaimed by
the OS the moment it exits. Memory safety at the scale this package
targets, not just raw speed, is the reason for the custom
implementation.
The comparison
Both the hand-rolled path and the mclapply() alternative
are variations on the same idea: fork worker processes, have each do
some share of the bootstrap resampling, and collect the results back in
the parent. They differ in process lifetime:
-
fork_utils.h’srun_forked_batches()forks a fresh batch of up tomax.forkchildren for every batch of resamples. A child does its one resample’s fit, writes its result back through a pipe, and calls_exit(). The next batch forks brand-new children from the parent’s own (bounded) memory state – not from whatever the previous batch’s children had grown their heaps to. -
parallel::mclapply(mc.preschedule = TRUE)forks exactlymc.coresworkers once, and each worker processes its entire assigned share of the task list sequentially, in-process, before exiting.
Two benchmarks compare them directly, holding everything else fixed:
same underlying cpp_model object (freshly initialized, not
yet fit), same bootstrap count, same number of workers, nothing else
running in either arm. The real bs_batch() production path
is one arm; mclapply() over the package’s internal
per-resample fit function is the other.
Result 1: a small, realistic model (276 parameters)
Using this package’s own liverradial tutorial/test
fixture (882,144 simulated count-data rows, 276 fitted parameters – see
tests/testthat/test-slow-liverradial.R and
benchmarks/RESULTS.md for the full methodology and raw
numbers):
| bootstraps | workers | bs_batch() |
mclapply() |
mclapply vs. bs_batch()
|
|---|---|---|---|---|
| 100 | 2 | 137.1s | 130.2s | 1.05x faster |
| 100 | 10 | 33.7s | 32.1s | 1.05x faster |
| 1000 | 20 | 189.0s | 157.1s | 1.20x faster |
At this scale, mclapply() is consistently 5-20% faster.
The reason is simple: each fork in fork_utils.h costs a
fork() syscall plus pipe setup/teardown (sub-millisecond to
low-single-digit milliseconds), and bs_batch() pays that
cost on every one of the 1,000 bootstraps, while mclapply()
pays its (slightly different) fixed per-worker cost only once per
worker. At ~0.15-0.3s of real optimization work per bootstrap, that
per-fork overhead is a real, if modest, fraction of total time.
Taken in isolation, this result would argue for switching to
mclapply(). It does not tell the whole story.
Result 2: a large model (4,788 parameters), the regime this package is actually built for
The package’s own author has, outside of testing, fit models with
1,000+ parameters that collectively use 50+ GB of RAM across concurrent
bootstrap fits – a regime the 276-parameter benchmark above does not
probe. To test whether the result above generalizes, the same benchmark
was repeated on a 4,788-parameter model built from this package’s real
multi-context tutorial data
(ACx_laminar_countdata_demo_with_celltype.csv.gz, keeping
12 of the data’s 31 real MapMyCells cell types instead of the tutorial’s
own simplifying collapse to 2 – no fabricated data; see
benchmarks/RESULTS_large_model.md for full
methodology):
| bootstraps | workers | bs_batch() |
mclapply() |
mclapply vs. bs_batch()
|
|---|---|---|---|---|
| 20 | 4 | 204.3s | 224.3s | 0.91x (9% slower) |
| 20 | 10 | 125.8s | 136.7s | 0.92x (8% slower) |
| 20 | 20 | 104.5s | 124.7s | 0.84x (16% slower) |
| 40 | 20 | 138.7s | 148.0s | 0.94x (6% slower) |
The result reverses completely. Every grid point
flips sign: bs_batch() is now consistently faster than
mclapply(), by 6-16%.
Peak combined memory (RSS, summed across all worker processes) shows why:
| bootstraps | workers |
bs_batch() peak RSS |
mclapply() peak RSS |
ratio |
|---|---|---|---|---|
| 20 | 4 | 15.5 GB | 23.8 GB | 1.54x |
| 20 | 10 | 37.3 GB | 41.0 GB | 1.10x |
| 20 | 20 | 46.7 GB* | 43.5 GB | 0.93x* |
| 40 | 20 | 57.7 GB | 89.4 GB | 1.55x |
*bs_batch()’s peak RSS at 20 bootstraps/20 workers
varied substantially between its two repetitions (46.7 GB and 93.3 GB);
the less noisy rep is shown. This is also the one grid point where
neither arm has any repeated sequential fits per worker to
accumulate memory from (a single batch of 20 forks vs. 20 single-task
mclapply workers), so it’s the one row not expected to
follow the pattern below. See
benchmarks/RESULTS_large_model.md for both raw values.
The clearest pattern: holding the number of workers fixed at 20 and
doubling the bootstrap count from 20 to 40 (so each
mclapply worker now does 2 sequential fits instead of 1
before exiting) makes mclapply’s peak RSS almost exactly
double (43.5 GB → 89.4 GB). bs_batch()’s peak RSS barely
moves under the same change (46.7 GB → 57.7 GB), because
fork_utils.h forks an entirely fresh batch of children
every batch regardless of how many total batches run.
Why: this is heap-allocator behavior, not a Stan memory leak
This is not a case of Stan’s autodiff arena silently growing across
calls within a worker, in either arm, though the two arms rule that out
differently. The benchmark’s mclapply arm explicitly calls
clear_stan_mem()
(stan::math::recover_memory()) after every individual
bootstrap fit (bs_fit(i, TRUE)), so Stan’s own memory is
actively recovered on every call its persistent workers make. The forked
bs_batch() path being measured here doesn’t do this – every
grid point above uses more than one worker, so it’s always the
forked branch (src/wspc.cpp,
bs_fit(..., false)) rather than the serial one, and that
branch skips the explicit Stan-memory recovery. It doesn’t need it: each
forked child does exactly one bootstrap fit and then calls
_exit() immediately (src/fork_utils.h), so the
OS reclaims that child’s entire address space in one shot,
Stan’s autodiff arena included – a strictly more complete cleanup than
an explicit recover_memory() call would have been, since
there’s no second fit in that process for an uncleared arena to ever
accumulate against.
The accumulation is instead ordinary process-memory behavior: a
long-lived process’s malloc implementation generally
retains freed pages, mapped for reuse, rather than returning them to the
OS immediately – especially for the large, variably-sized allocations a
multi-thousand- parameter NLopt fit makes on every bootstrap resample.
An mclapply worker that runs several such fits
sequentially, in the same process, can retain a high-water-mark of these
pages across its entire assigned sequence. A fork_utils.h
child, by contrast, is a distinct OS process for exactly one bootstrap
fit: when it calls _exit(), the OS reclaims its
entire address space in one shot, freed-but-retained allocator
pages included. The next batch’s freshly forked children start from the
parent’s own bounded memory state, never from whatever the previous
batch’s children grew their heaps to – regardless of how many total
bootstraps the model asks for.
At 276 parameters, this same mechanism is present
(mclapply’s RSS is higher there too, by 1.06-1.97x across
configurations) but the absolute memory at stake is small enough that it
costs nothing in wall-clock time; mclapply’s
one-time-fork-cost advantage dominates instead. At 4,788 parameters, the
same mechanism costs tens of gigabytes, which is large enough – more
TLB/cache pressure, slower allocator searches, more OS paging – to flip
the net timing result. For the even larger models (1,000+ parameters,
50+ GB combined across concurrent bootstraps) this package is actually
used for, this is the regime that matters.
Conclusion
The hand-rolled forking in src/fork_utils.h is not a
case of reinventing a wheel CRAN’s parallel package already
provides for free. It is a deliberate choice, made because this
package’s bootstrap-resampling workload – many repeated, memory-heavy
Stan/NLopt fits, at model sizes that routinely reach the
tens-of-gigabytes range in combined memory – sits in exactly the regime
where long-lived worker processes accumulate retained memory in a way
that short-lived, re-forked-every-batch children do not. The benchmark
scripts (benchmarks/mclapply_vs_fork_general.R,
benchmarks/configs/*.R) and raw results
(benchmarks/RESULTS.md,
benchmarks/RESULTS_large_model.md) are included in the
repository so this comparison is reproducible rather than taken on
faith.
This is not a criticism of parallel::mclapply(), which
remains a better fit for workloads made of many short, memory-light
tasks – precisely because it avoids fork_utils.h’s per-task
fork overhead in that regime, as Result 1 shows. It is a statement that
the right choice is workload-dependent, and this package’s own workload
lands on the side that favors re-forking short-lived children over
long-lived persistent workers.