Skip to contents
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 / chunkSize arguments.

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.96772

chunkSize = 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 done

When 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 190

This 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.4938018

When 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:

tasks <- lapply(seq_along(reps), function(i) {
  mirai::mirai(
    {
      library(rxode2)
      rxSetSeed(seed)
      ev_i <- et(id = reps[[i]]) |> et(amt = 100, ii = 24, until = 72) |>
        et(seq(0, 72, by = 4))
      as.data.frame(rxSolve(pkModel, ev_i))
    },
    pkModel = pkModel, reps = reps, seed = base_seed + i, i = i
  )
})

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