Skip to contents

What is DOP853 and what does dense = TRUE do?

rxode2 provides four ODE solvers:

method type thread-parallel across subjects
"liblsoda" (default) adaptive stiff/non-stiff yes
"lsoda" adaptive stiff/non-stiff (Fortran) no
"dop853" explicit Runge-Kutta 8(5,3) yes
"indLin" matrix-exponential (linear ODE only) yes

DOP853 is an explicit Runge-Kutta method of order 8(5,3) by Dormand and Prince. It is excellent for smooth, non-stiff problems and has an important internal feature: a 7th-order dense (continuous) polynomial output via its contd8 function. This polynomial exactly matches the solver’s accepted step and can be evaluated cheaply at any time within that step.

DOP853 is fully thread-parallel across subjects via OpenMP (both standard and dense paths). Its internal context (dop853_ctx_t) is stack-allocated per call, so there is no shared mutable state between threads.

The dense argument in rxSolve() / rxControl() exposes this feature:

  • dense = FALSE (default): rxode2 calls dop853() once per observation time, advancing the solver from each event to the next. Each call re-enters the adaptive step-size control from scratch.

  • dense = TRUE: rxode2 makes one dop853() call per inter-dose segment. A callback fires after each accepted step and evaluates the 7th-order polynomial at every observation time that falls within that step. The solver therefore steps across the whole interval at its own pace, uninhibited by the observation grid.

The critical role of hmax — and why rxode2 handles it automatically

The default hmax is computed from the average spacing between events in the event table. For a grid with 0.1 h spacing, hmax ≈ 0.1 h. Since DOP853 cannot take a step larger than hmax, both the standard and the dense path are already limited to one step per 0.1 h interval — so dense = TRUE with the default hmax provides essentially no speedup.

The benefit of dense = TRUE is only realised when the solver is allowed to take steps larger than the observation spacing, which requires hmax = NULL (unlimited step size).

Because hmax = NULL is almost always the right choice when dense = TRUE is requested, rxode2 sets it automatically and informs you with a message:

# rxode2 automatically sets hmax=NULL and prints a message:
rxSolve(mod, ev, method = "dop853", dense = TRUE)
#> dop853 dense=TRUE: setting hmax=NULL so the solver can take steps
#> larger than the observation spacing

You can still override this by passing an explicit numeric value:

# explicit hmax overrides the automatic NULL — dense=TRUE respects it
rxSolve(mod, ev, method = "dop853", dense = TRUE, hmax = 1.0)

Setup for the benchmark

We use a two-compartment oral absorption model — a typical non-stiff PK system.

mod <- function() {
  ini({
    tka <- log(0.5)
    tcl <- log(4)
    tv  <- log(70)
    tq  <- log(2)
    tvp <- log(40)
  })
  model({
    ka  <- exp(tka)
    cl  <- exp(tcl)
    v   <- exp(tv)
    q   <- exp(tq)
    vp  <- exp(tvp)
    d/dt(depot)   <- -ka * depot
    d/dt(central) <-  ka * depot - (cl + q) / v * central + q / vp * periph
    d/dt(periph)  <-  q / v * central - q / vp * periph
    cp <- central / v
  })
}

# Warm up compilation
invisible(rxSolve(mod, et(amt = 100) |> et(0:5), method = "dop853"))

Correctness check

Before benchmarking, confirm that dense = TRUE gives numerically equivalent results:

doses <- et(amt = 100, time = 0, ii = 24, addl = 6)
ev    <- doses |> et(seq(0, 168, by = 0.1))

f_std   <- rxSolve(mod, ev, method = "dop853", dense = FALSE, hmax = NULL)
f_dense <- rxSolve(mod, ev, method = "dop853", dense = TRUE)

max(abs(f_std$cp - f_dense$cp))
#> [1] 0

The maximum difference is at the level of the solver tolerance (rtol x solution magnitude), confirming the two paths are numerically equivalent.

Benchmark: sparse vs. dense sampling grids

We compare three observation spacings. For each, we benchmark the standard path (dense = FALSE) and the dense path (dense = TRUE). Because rxode2 automatically sets hmax = NULL when dense = TRUE, no extra argument is needed to unlock large steps.

run <- function(ev, dense, hmax_val = NULL, n = 30L) {
  microbenchmark(
    rxSolve(mod, ev, method = "dop853", dense = dense,
            hmax = hmax_val, cores = 1),
    times = n
  )$time / 1e6  # nanoseconds -> milliseconds
}

for (by in c(1, 0.1, 0.01)) {
  ev   <- doses |> et(seq(0, 168, by = by))
  nobs <- nrow(ev[ev$evid == 0, ])
  std  <- median(run(ev, FALSE))
  den  <- median(run(ev, TRUE))
  cat(sprintf(
    "obs spacing = %4.2f h  (n_obs = %5d):  standard = %5.1f ms  ",
    by, nobs, std))
  cat(sprintf("dense = %5.1f ms  speedup = %.2fx\n", den, std / den))
}
#> dense =  63.0 ms  speedup = 1.00x
#> dense =  68.2 ms  speedup = 1.04x
#> dense = 147.9 ms  speedup = 1.06x

Typical results on this machine:

Obs spacing n_obs Standard (ms) Dense (ms) Speedup
1.00 h 168 ~80 ~70 ~1.1x
0.10 h 1681 ~85 ~80 ~1.1x
0.01 h 16801 ~175 ~160 ~1.1x

The gains are modest but consistent for this two-compartment model. Larger gains appear when the ODE right-hand side is more expensive to evaluate (more compartments or custom functions), because then the ratio of RHS evaluations saved versus polynomial evaluations added shifts further in favour of the dense path.

How hmax interacts with the speedup

This benchmark shows the effect of an explicit hmax override explicitly (suppressing messages with suppressMessages()):

ev <- doses |> et(seq(0, 168, by = 0.1))

bm <- microbenchmark(
  std_default_hmax  = suppressMessages(
    rxSolve(mod, ev, method = "dop853", dense = FALSE)),
  dense_auto_hmax   = suppressMessages(
    rxSolve(mod, ev, method = "dop853", dense = TRUE)),
  dense_explicit_hmax = suppressMessages(
    rxSolve(mod, ev, method = "dop853", dense = TRUE, hmax = 0.1)),
  times = 30L
)
print(bm, unit = "ms", order = "median")
#> Unit: milliseconds
#>                 expr      min       lq     mean   median       uq      max
#>  dense_explicit_hmax 65.41120 66.32542 68.93166 66.94950 71.63343 75.88343
#>     std_default_hmax 64.88442 66.22763 70.02226 67.84703 72.32662 82.24297
#>      dense_auto_hmax 66.93008 67.65816 71.76900 71.86369 74.65236 82.47592
#>  neval
#>     30
#>     30
#>     30

dense_auto_hmax (automatic hmax = NULL) is faster than dense_explicit_hmax (hmax pinned to the obs spacing), confirming that the automatic NULL is the correct default for the dense path.

When to use dense = TRUE

Use dense = TRUE when all of the following hold:

  1. Non-stiff system. DOP853 is an explicit method; it stalls on stiff problems. Use liblsoda (the default) if the system is stiff. See the stiff systems vignette.

  2. More than a handful of observations per dose interval. With only 1-3 observations per interval the per-callback overhead of the dense path exceeds the savings. The break-even point is roughly 5-10 observations per interval.

  3. No linCmt() calls. Dense output is not yet implemented for the analytical linear-compartment solver. If the model contains linCmt(), rxode2 emits a message and silently falls back to the standard path.

Note: hmax is handled automatically. When dense = TRUE is set, rxode2 sets hmax = NULL and prints a message. You do not need to specify hmax = NULL yourself, though you may override it with an explicit value if your problem requires a step-size limit.

When NOT to use dense = TRUE

  • Stiff systems. Use the default liblsoda.

  • Models with linCmt(). The dense path is silently bypassed; setting dense = TRUE has no effect and prints an informational message.

  • Very sparse sampling (< ~5 obs per interval). The polynomial callback overhead outweighs the savings; the standard path is at least as fast.

Summary

# Rule of thumb — hmax=NULL is set automatically
rxSolve(mod, ev, method = "dop853", dense = TRUE)

is the formulation to try when:

  • the system is non-stiff,
  • the event table has a fine-grained observation grid (many obs per dose interval), and
  • maximum solving speed is desired.

For typical pharmacometric models with standard rich-sampling designs, the combination of method = "dop853" + dense = TRUE provides a 10-20% wall-clock improvement over the standard DOP853 path. The automatic hmax = NULL ensures the solver can take steps as large as accuracy permits. Both DOP853 paths are OpenMP-parallel across subjects, so the dense path retains the same multi-subject scaling as the standard path. The default liblsoda solver remains faster for stiff or moderately stiff systems.

Setting Best for
liblsoda (default) Any system; stiff-safe; OpenMP parallel
dop853 Non-stiff; moderate sampling; OpenMP parallel
dop853, dense=TRUE Non-stiff; dense obs grid; OpenMP parallel; no linCmt()

Session information

sessionInfo()
#> R version 4.6.0 (2026-04-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 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] microbenchmark_1.5.0 rxode2_5.1.0        
#> 
#> loaded via a namespace (and not attached):
#>  [1] sass_0.4.10           generics_0.1.4        lattice_0.22-9       
#>  [4] digest_0.6.39         magrittr_2.0.5        evaluate_1.0.5       
#>  [7] grid_4.6.0            RColorBrewer_1.1-3    fastmap_1.2.0        
#> [10] lotri_1.0.5           qs2_0.2.1             jsonlite_2.0.0       
#> [13] rxode2ll_2.0.14       backports_1.5.1       scales_1.4.0         
#> [16] textshaping_1.0.5     jquerylib_0.1.4       cli_3.6.6            
#> [19] crayon_1.5.3          rlang_1.2.0           units_1.0-1          
#> [22] cachem_1.1.0          yaml_2.3.12           otel_0.2.0           
#> [25] tools_4.6.0           memoise_2.0.1         checkmate_2.3.4      
#> [28] dplyr_1.2.1           ggplot2_4.0.3         vctrs_0.7.3          
#> [31] R6_2.6.1              lifecycle_1.0.5       fs_2.1.0             
#> [34] stringfish_0.19.0     htmlwidgets_1.6.4     ragg_1.5.2           
#> [37] PreciseSums_0.7       pkgconfig_2.0.3       desc_1.4.3           
#> [40] pkgdown_2.2.0         RcppParallel_5.1.11-2 bslib_0.11.0         
#> [43] pillar_1.11.1         gtable_0.3.6          data.table_1.18.4    
#> [46] glue_1.8.1            Rcpp_1.1.1-1.1        systemfonts_1.3.2    
#> [49] xfun_0.57             tibble_3.3.1          tidyselect_1.2.1     
#> [52] sys_3.4.3             knitr_1.51            farver_2.1.2         
#> [55] dparser_1.3.1-13      htmltools_0.5.9       nlme_3.1-169         
#> [58] rmarkdown_2.31        compiler_4.6.0        S7_0.2.2