
DOP853 Dense Output: When and How to Use It
2026-05-23
Source:vignettes/articles/rxode2-dop853-dense.Rmd
rxode2-dop853-dense.RmdWhat 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 callsdop853()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 onedop853()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 spacingYou 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] 0The 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.06xTypical 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
#> 30dense_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:
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.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.
No
linCmt()calls. Dense output is not yet implemented for the analytical linear-compartment solver. If the model containslinCmt(), 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; settingdense = TRUEhas 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