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.
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.
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.
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 |
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.
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.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.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.grainsize for
reduce_sum() (no gain), and moving the computation to
another language (the time is spent inside CmdStan).