--- title: "Performance" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Performance} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- This page collects what is known about how long crywolf takes, what makes it faster, and what was tried without success. All times were measured on one 18-core laptop (Apple M5 Pro) and will differ on other computers; the ratios are the useful part. The scripts and raw results are in `dev/profile/` of the source repository, and each change is described in `NEWS.md`. ## A large dataset over the versions The `curiango` data have 97,806 one-minute automated occasions in 1,753 site-nights. The model used for timing has three occupancy covariates, two detection covariates, a constant false-positive rate, and a recorder random effect, fitted with four chains of 1000 warm-up and 1000 sampling iterations and the default sampler settings. | Version | Change | Wall time | |---|---|---| | 1.0.0 | one thread per chain | 70 min | | 1.1.0 | likelihood summed over sites in parallel (`reduce_sum()`), four threads per chain | 16.5 min | | 1.3.0 | per-site totals for unoccupied sites, one GLM call per site for occupied ones | | | 1.5.0 | (same likelihood as 1.3.0), four threads per chain | 6.6 min | The 1.3.0 change was measured per gradient evaluation rather than per fit: on these data a gradient took 8.5 ms in 1.2.0 and 2.6 ms in 1.3.0 with one thread. In every version the posterior is the same; only the arithmetic is arranged differently, and the log density and its gradient agree up to floating-point rounding. ## What you can do **Use more threads per chain.** The likelihood is a sum over sites, which Stan can split among cores. With `threads_per_chain = "auto"`, crywolf divides the physical cores among the chains that run in parallel. On the `curiango` data, 150 iterations of one chain took: | Threads | 1 | 2 | 4 | 8 | |---|---|---|---|---| | Time | 55.1 s | 28.8 s | 16.1 s | 10.3 s | `"auto"` is the default since crywolf 2.0.0 (it was one thread before). With more than one thread the partial sums are added in an order that changes from run to run, so a fit is not reproduced exactly by its seed, even on the same computer, although its posterior is the same. Set `threads_per_chain = 1` when exact reproduction matters (published analyses, saved results). `fp_calibrate()`, `fp_calibrate_fit()` and `fp_sbc()` use one thread per chain unless told otherwise, because their fits run side by side in parallel workers. **Run chains in parallel.** `parallel_chains` defaults to the number of chains, so the four chains of a fit run at the same time when there are four free cores. **In calibrations, use fewer chains per fit.** `fp_calibrate()` and its relatives run many small fits in parallel workers, with the chains of each fit one after another. There the cost is proportional to the number of chains: two chains take half the time of four, and still gave an effective sample size of about 2000 per parameter in the default design. Keep one thread per chain in parallel calibrations, since the workers already use the cores. **Simplify the detection model when you can.** Occasions of a site with the same detection covariates are collapsed into one binomial row, so a model without occasion-level detection covariates evaluates far fewer terms. With covariates on every occasion, the per-site GLM call of 1.3.0 is what keeps the cost down. ## Small fits For simulated datasets of the sizes used in calibrations, the fixed cost of starting CmdStan and reading its output was small next to sampling (medians of three fits, chains in parallel): | Design | Chains | Wall time | Sampler time | Fixed cost | |---|---|---|---|---| | 100 sites, 6 occasions | 2 | 6.6 s | 6.2 s | 0.4 s | | 300 sites, 20 occasions | 2 | 21.2 s | 20.8 s | 0.5 s | | 1000 sites, 30 occasions | 2 | 66.1 s | 65.4 s | 0.7 s | | 100 sites, 6 occasions | 4 | 7.7 s | 7.1 s | 0.6 s | | 300 sites, 20 occasions | 4 | 24.4 s | 23.4 s | 1.0 s | | 1000 sites, 30 occasions | 4 | 71.3 s | 70.2 s | 1.1 s | ## Tried without success On the `curiango` data each sampling iteration takes about 100 leapfrog steps, and these were the attempts to reduce that number or the cost of each step. None gave a consistent gain, and run-to-run differences of up to 30% in effective sample size per second made smaller effects impossible to detect. * **A dense mass matrix** (`metric = "dense_e"`, which can be passed to `fp_occu()` through `...`) cut the wall time from 393 s to 161 s but produced divergent transitions and halved the effective sample size of the recorder standard deviation. It is not a safe default with a hierarchical recorder effect. * **Sampling the false-positive intercept with the gap.** With a recorder effect, the mean detection intercept is the uncertain quantity and its posterior correlation with the gap reached 0.99. Sampling the false-positive intercept instead was meant to avoid that correlation, but the number of leapfrog steps did not fall. * **A centred recorder effect**, which suits recorders with many occasions each, alone or with the previous change: no combination was best in both the `curiango` data and simulations with 5 to 30 recorders. * **`reduce_sum_static()`**, in the hope that a fixed partition of the sites would make draws with several threads reproducible. It did not. * **Initialization by Pathfinder** was not pursued, because warm-up is only about 40% of the time, and **a QR decomposition** of the design matrices was not, because no posterior correlation among covariate effects exceeded 0.5. * **Longer warm-up or a higher `adapt_delta`** with `random_fp = TRUE` took longer without a clear gain: 2 and 3 of 20 fits had an R-hat above 1.01, against 3 of 20 with the defaults. * Earlier: a larger `grainsize` for `reduce_sum()` (no gain), and moving the computation to another language (the time is spent inside CmdStan).