Parallel simulations
Simulation studies β parameter recovery, scenario sweeps, prior sensitivity β are loops of independent fits. Written as a plain for loop they run one at a time, so a single core works while the rest of the machine sits idle.
run_simulations spreads that loop across worker processes. Each worker is a separate Python process with its own JAX backend, so every simulation gets its own bf() instance, its own share of the cores and its own seed.
Crucially, the scenario settings travel with the results: every row of the returned table carries the parameters that produced it, so comparing true against estimated values needs no bookkeeping of your own.
1. The pattern
Write a function that runs one simulation and returns the numbers you care about. run_simulations calls it once per scenario, in parallel, and stacks the results into a pandas DataFrame.
from BayesForge import bf, grid, run_simulations
def one_sim(<your scenario parameters>, seed, **_):
m = bf(rand_seed=seed, print_devices_found=False) # one bf per simulation
data = <simulate a dataset using the scenario parameters>
m.fit(<model>, obs=data, progress_bar=False)
return {"estimate": ..., "truth": ...} # -> one row of the result table
scenarios = grid(<param>=[...], <param>=[...], reps=<n>) # or just an int
res = run_simulations(one_sim, scenarios) # -> DataFrameThree things the runner injects into every call, on top of your own parameters:
| injected | meaning |
|---|---|
sim |
global index of the simulation |
rep |
replicate index within its scenario cell |
seed |
per-simulation seed, derived deterministically from the base seed |
Pass seed into bf(rand_seed=seed). Without it every worker draws the same data, because bf() seeds the global NumPy and Python RNGs from that value.
Always give one_sim a **_ catch-all so extra injected keys cannot break it.
.py script, use a main guard
Workers are spawned, which re-imports the calling script. Without the guard the module level runs again in every worker:
if __name__ == "__main__":
res = run_simulations(one_sim, scenarios)Notebooks and the REPL need no guard.
A fitted bf object cannot cross a process boundary β m.diag holds closures over m, and m.dist is an unpicklable proxy. Return m.summary(), m.sampler_stats(), or a plain dict. Returning m raises an explicit error.
2. A concrete example
Recovering the slope of a linear regression across two sample sizes and three true slopes, four replicates each β 24 simulations.
import numpy as np
from BayesForge import bf, grid, run_simulations
def one_sim(N, b_true, seed, **_):
m = bf(rand_seed=seed, print_devices_found=False)
# --- simulate one dataset --------------------------------------
x = np.asarray(m.dist.normal(0.0, 1.0, sample=True, shape=(N,)))
y = np.asarray(m.dist.normal(1.5 + b_true * x, 1.0, sample=True))
# --- the model -------------------------------------------------
def linreg(x=None, y=None):
a = m.dist.normal(0.0, 5.0, name="a")
b = m.dist.normal(0.0, 5.0, name="b")
s = m.dist.exponential(1.0, name="s")
m.dist.normal(a + b * x, s, name="y", obs=y)
# --- fit, then report truth against estimate -------------------
m.fit(linreg, obs=dict(x=x, y=y),
num_warmup=500, num_samples=500, num_chains=1,
progress_bar=False)
est = m.summary()
return {"b_hat": float(est.loc["b", "mean"]),
"b_true": b_true,
"r_hat": float(est.loc["b", "r_hat"])}
if __name__ == "__main__":
scenarios = grid(N=[100, 500], b_true=[-0.8, 0.0, 0.8], reps=4) # 24 sims
res = run_simulations(one_sim, scenarios)
print(res[["sim", "N", "b_true", "b_hat", "elapsed_s"]].head())res is a tidy DataFrame β the grid columns N and b_true are joined onto every row automatically:
sim N b_true b_hat elapsed_s
0 0 100 -0.8 -0.64 8.952
1 1 100 -0.8 -0.80 9.099
2 2 100 -0.8 -0.93 8.598
3 3 100 -0.8 -0.62 8.620
4 4 100 0.0 -0.13 8.184
So the analysis is a groupby, with nothing to join by hand:
(res.b_hat - res.b_true).abs().groupby([res.N, res.b_true]).mean()N b_true
100 -0.8 0.118
0.0 0.073
0.8 0.055
500 -0.8 0.015
0.0 0.025
0.8 0.018
Alongside your own columns, every row carries sim, rep, seed, worker, elapsed_s and error.
3. Chains, workers and cores
Chains and simulations compete for the same cores, so num_chains, workers and the fitβs chain_method must be chosen together. There are three sensible setups. All produce the same number of genuine chains and equivalent ESS β they differ in speed and in how many simulations are in flight at once.
fitβs chain_method |
pass num_chains= |
workers | cores/worker | sims at once |
|---|---|---|---|---|
| (single chain) | omit | n_cpu |
1 | n_cpu |
'parallel' |
N |
n_cpu // N |
N |
n_cpu // N |
'vectorized' |
omit | n_cpu |
1 | n_cpu |
Single-chain fits are the default and the quickest way through a large batch when you do not need a per-fit R-hat.
chain_method='parallel' puts one chain on one JAX device, so it needs a device per chain. Pass num_chains to the runner and take it back in your function, so the core budget and the fit can never disagree:
def one_sim(N, seed, num_chains, **_):
...
m.fit(model, obs=data, num_chains=num_chains,
chain_method="parallel", progress_bar=False)
res = run_simulations(one_sim, scenarios, num_chains=4)On a 24-core machine num_chains=4 gives each worker 4 cores and 4 JAX devices, pins it to its own block of CPUs, and runs 6 simulations at a time instead of 24 β simulation concurrency traded for chain concurrency.
chain_method='vectorized' vmaps the chains inside a single device, so no cores are reserved for them and every core stays free for a different simulation. Do not pass num_chains for this one: it would reserve cores that vectorized chains never use.
Measured on 24 simulations over 24 cores, each drawing 4 chains at equal ESS:
| setup | wall |
|---|---|
workers=24, cores_per_worker=1, vectorized |
17.5 s |
num_chains=4 (6 workers Γ 4 cores), parallel |
21.9 s |
workers=6, cores_per_worker=4, vectorized |
29.1 s |
Asking a fit for num_chains=4 with chain_method='parallel' without telling the runner leaves the worker with one device. NumPyro then draws the chains sequentially β correct results, needlessly slow, with a warning per fit. Passing num_chains to run_simulations prevents it.
4. Useful options
| argument | what it does |
|---|---|
workers |
How many simulations run at once. Defaults to one per core, divided by cores_per_worker. |
fn_kwargs |
Constants passed to every call. Serialised once per worker rather than once per scenario β put large shared arrays here. |
seed |
Base seed. Per-simulation seeds derive from it deterministically, so a rerun reproduces regardless of worker count or completion order. |
on_error |
'record' (default) puts the traceback in the rowβs error column and keeps going; 'raise' aborts the run. |
backend |
'serial' runs in-process for debugging, so pdb and full tracebacks work. |
Inspecting failures without losing the rest of the batch:
res = run_simulations(one_sim, scenarios, on_error="record")
res[res.error.notna()][["sim", "N", "error"]]Each worker JIT-compiles the model on its first fit. When the model is built fresh inside one_sim (a closure, as in the example above), every simulation pays full compilation because the JIT cache key changes each time. Defining it at module level lets a worker reuse its compiled executable across all the simulations it handles β often the single largest speedup available.