
Parallel solving with mirai
Source:vignettes/articles/rxode2-mirai-parallel.Rmd
rxode2-mirai-parallel.Rmd
library(rxode2)
#> rxode2 5.1.8.9000 using 2 threads (see ?getRxThreads)
#> no cache: create with `rxCreateCache()`This vignette builds on Out-of-memory solving with rxode2, which introduces chunked solving in detail. Read that vignette first if you are new to the
file/chunkSizearguments.
Two parallelism layers in rxode2
rxode2 has two independent parallelism mechanisms:
| Layer | Mechanism | Granularity | When active |
|---|---|---|---|
| 1 | OpenMP threads | Subject-level, within one rxSolve() call |
Always; controlled by rxControl(cores=)
|
| 2 | mirai daemons | Chunk-level, across separate processes | When parallel > 0
|
Layer 1 is always on and works well for most use
cases. Layer 2 adds process-level parallelism on top:
each chunk is an independent rxSolve() call dispatched to a
separate background process (daemon).
When mirai parallelism helps
mirai chunk-level parallelism is most beneficial when:
- Each chunk requires enough RAM that the solve is I/O-bound on a single core.
- You have more physical cores than OpenMP threads per solve (e.g., a 48-core machine solving 4-thread chunks gives 12 chunks in parallel).
- You want to distribute chunks across compute nodes on a cluster or HPC.
- You want true memory isolation between chunks so one chunk’s peak usage does not interfere with another.
If your population fits comfortably in RAM and OpenMP is already saturating your cores, Layer 1 alone is usually sufficient.
Model and event table
We reuse the same one-compartment oral PK model from the OOM vignette, defined with
ini() and model() blocks.
pkModel <- function() {
ini({
tka <- log(0.5); label("Typical log Ka (1/h)")
tcl <- log(4); label("Typical log CL (L/h)")
tv <- log(70); label("Typical log V (L)")
eta.ka ~ 0.09
eta.cl ~ 0.04
prop.err <- 0.15
})
model({
ka <- exp(tka + eta.ka)
cl <- exp(tcl + eta.cl)
v <- exp(tv)
d/dt(depot) <- -ka * depot
d/dt(center) <- ka * depot - cl / v * center
cp <- center / v
cp ~ prop(prop.err)
})
}
ev <- et(amt = 100, ii = 24, until = 72) |>
et(seq(0, 72, by = 4)) |>
et(id = 1:10)Parallel chunked solving with rxSolve()
Add parallel = n alongside file and
(optionally) chunkSize. rxode2 calls
mirai::daemons(n) internally, dispatches one chunk per
daemon, and shuts the daemons down when done.
res <- rxSolve(pkModel, ev,
file = tempfile("rxPar"),
chunkSize = 3,
parallel = 2)
#> ℹ parameter labels from comments are typically ignored in non-interactive mode
#> ℹ Need to run with the source intact to parse comments
res
#> ── Solved rxode2 object ──
#> ── Parameters (x$params): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpncPPQF/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 10 × 7
#> id tka tcl tv prop.err eta.ka eta.cl
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 1 -0.693 1.39 4.25 0.15 0.0448 -0.309
#> 2 2 -0.693 1.39 4.25 0.15 -0.166 -0.179
#> 3 3 -0.693 1.39 4.25 0.15 0.230 0.343
#> 4 4 -0.693 1.39 4.25 0.15 0.0225 0.180
#> 5 5 -0.693 1.39 4.25 0.15 -0.392 0.0824
#> 6 6 -0.693 1.39 4.25 0.15 -0.0601 0.0137
#> 7 7 -0.693 1.39 4.25 0.15 0.536 0.129
#> 8 8 -0.693 1.39 4.25 0.15 -0.0798 0.256
#> 9 9 -0.693 1.39 4.25 0.15 0.225 0.0241
#> 10 10 -0.693 1.39 4.25 0.15 0.0533 0.491
#> ── Initial Conditions (x$inits): ──
#> depot center
#> 0 0
#> ── First part of data (object): ──
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpncPPQF/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
#> # A tibble: 6 × 10
#> id time ka cl v cp ipredSim sim depot center
#> <int> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 1 0 0.523 2.94 70 0 0 0 100 0
#> 2 1 4 0.523 2.94 70 1.12 1.12 1.02 12.3 78.5
#> 3 1 8 0.523 2.94 70 1.09 1.09 0.963 1.52 76.1
#> 4 1 12 0.523 2.94 70 0.936 0.936 0.969 0.188 65.5
#> 5 1 16 0.523 2.94 70 0.793 0.793 0.689 0.0233 55.5
#> 6 1 20 0.523 2.94 70 0.671 0.671 0.603 0.00287 47.0
#> <rxSolveOom: 4 chunks, 190 total rows, prefix='/tmp/RtmpncPPQF/rxPar5d2f2f7264fe' [DuckDB/Arrow-backed]>
head(as.data.frame(res))
#> id time ka cl v cp ipredSim sim depot
#> 1 1 0 0.5229038 2.937553 70 0.0000000 0.0000000 0.0000000 1.000000e+02
#> 2 1 4 0.5229038 2.937553 70 1.1214032 1.1214032 1.0203791 1.234877e+01
#> 3 1 8 0.5229038 2.937553 70 1.0865946 1.0865946 0.9625345 1.524917e+00
#> 4 1 12 0.5229038 2.937553 70 0.9357858 0.9357858 0.9692756 1.883077e-01
#> 5 1 16 0.5229038 2.937553 70 0.7932925 0.7932925 0.6890626 2.325360e-02
#> 6 1 20 0.5229038 2.937553 70 0.6709674 0.6709674 0.6030925 2.871524e-03
#> center
#> 1 0.00000
#> 2 78.49822
#> 3 76.06162
#> 4 65.50501
#> 5 55.53047
#> 6 46.96772chunkSize = 3 gives four chunks of up to 3 subjects;
parallel = 2 means two daemons solve chunks concurrently
(chunks 1 and 2 run together, then 3 and 4).
Persistent daemons
When you call rxSolve() with
parallel > 0, rxode2 creates and
destroys daemons around the solve. If you run many consecutive
chunked solves, spinning daemons up and down for each one adds overhead.
Instead, start them yourself before the first solve:
mirai::daemons(2)
res1 <- rxSolve(pkModel, ev,
file = tempfile("rxPar1"),
chunkSize = 3,
parallel = 2)
res2 <- rxSolve(pkModel, ev,
file = tempfile("rxPar2"),
chunkSize = 3,
parallel = 2)
mirai::daemons(0) # shut down when doneWhen daemons are already running rxode2 reuses them rather than creating new ones.
Custom mirai workflows
Not every parallel use case fits the file pattern. You
can dispatch arbitrary rxSolve() calls across mirai daemons
for independent scenarios such as sensitivity analyses or dose-response
simulations.
doses <- c(50, 100, 200)
mirai::daemons(3)
tasks <- lapply(doses, function(d) {
mirai::mirai(
{
library(rxode2)
ev_d <- et(amt = d, ii = 24, until = 72) |>
et(seq(0, 72, by = 4)) |>
et(id = 1:10)
as.data.frame(rxSolve(pkModel, ev_d))
},
pkModel = pkModel,
d = d
)
})
results <- lapply(tasks, `[`) # block until each task finishes (m[] collects)
mirai::daemons(0)
# Each element of results is a data frame for one dose level
sapply(results, nrow)
#> [1] 190 190 190This pattern is useful for:
- Sensitivity analysis – vary one parameter at a time across daemons.
- Dose-response – simulate many dose levels simultaneously.
- Bootstrap replicates – each daemon processes an independent replicate data set.
Reproducibility with seeds
rxode2’s OOM path uses its own internal seed engine
(rxSetSeed() / rxGetSeed()), which is separate
from R’s set.seed(). To make a chunked parallel solve
reproducible, call rxSetSeed() before
rxSolve():
rxSetSeed(42)
res_seed <- rxSolve(pkModel, ev,
file = tempfile("rxSeed"),
chunkSize = 3,
parallel = 2)
head(as.data.frame(res_seed)[, c("id", "time", "cp")])
#> id time cp
#> 1 1 0 0.0000000
#> 2 1 4 1.1300983
#> 3 1 8 0.9788477
#> 4 1 12 0.7829709
#> 5 1 16 0.6219919
#> 6 1 20 0.4938018When parallel is set, rxode2 pre-draws
all subjects’ etas in one pass before splitting into
chunks. This means the eta draws match those of an equivalent
single-call rxSolve() at the same rxode2 seed – chunk
boundaries do not affect the random-number sequence.
For custom mirai workflows, seed each task independently:
Performance tips
| Guideline | Reasoning |
|---|---|
| Chunk size ~ total_subjects / (2 x ndaemons) | Keeps daemons busy without under-utilizing them |
Start with parallel = 4 for 4-core machines |
Match physical cores; OpenMP within each chunk uses 1 thread by default in daemon processes |
Avoid chunkSize = 1
|
Per-chunk mirai overhead outweighs any parallelism gain |
| Profile before tuning |
system.time(rxSolve(...)) is the fastest way to find
the sweet spot |
For cluster / HPC use, see mirai::daemons() with the
url and remote arguments; the rxode2
parallel path works unchanged with remote daemons.
Session information
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
#> [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
#> [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
#> [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] rxode2_5.1.8.9000
#>
#> loaded via a namespace (and not attached):
#> [1] utf8_1.2.6 sass_0.4.10 generics_0.1.4
#> [4] digest_0.6.39 magrittr_2.0.5 evaluate_1.0.5
#> [7] grid_4.6.1 RColorBrewer_1.1-3 fastmap_1.2.0
#> [10] lotri_1.0.5 jsonlite_2.0.0 rxode2ll_2.0.18
#> [13] backports_1.5.1 DBI_1.3.0 purrr_1.2.2
#> [16] scales_1.4.0 textshaping_1.0.5 jquerylib_0.1.4
#> [19] duckdb_2.0.0 cli_3.6.6 rlang_1.3.0
#> [22] crayon_1.5.3 bit64_4.8.6 cachem_1.1.0
#> [25] yaml_2.3.12 otel_0.2.0 tools_4.6.1
#> [28] memoise_2.0.1 checkmate_2.3.4 dplyr_1.2.1
#> [31] nanonext_1.10.3 ggplot2_4.0.3 rxode2lincmt_0.1.1
#> [34] assertthat_0.2.1 vctrs_0.7.3 R6_2.6.1
#> [37] lifecycle_1.0.5 bit_4.6.0 fs_2.1.0
#> [40] htmlwidgets_1.6.4 arrow_26.0.0 ragg_1.5.2
#> [43] PreciseSums_0.7 pkgconfig_2.0.3 desc_1.4.3
#> [46] pkgdown_2.2.1 bslib_0.12.0 pillar_1.11.1
#> [49] gtable_0.3.6 data.table_1.18.6.1 glue_1.8.1
#> [52] Rcpp_1.1.2 systemfonts_1.3.2 xfun_0.62
#> [55] tibble_3.3.1 tidyselect_1.2.1 sys_3.4.3
#> [58] knitr_1.52 farver_2.1.2 dparser_1.3.1-14
#> [61] htmltools_0.5.9 mirai_2.7.3 rmarkdown_2.32
#> [64] compiler_4.6.1 S7_0.2.2