Execution Backends
rust, execution engine, backend, performance, system dynamics, bptk, bptk-py, python, business simulation
Execution Backends
BPTK evaluates System Dynamics models on one of two engines. They compute the same results; they differ in how fast they get there and in what they can express.
The Python engine is the default. It is the reference implementation, it runs everywhere Python runs — including in a browser — and it handles every model BPTK can build.
The Rust engine evaluates the same model in compiled code. It is worth reaching for once a model has grown large, or once you run the same scenario hundreds of times: a parameter sweep, a Monte Carlo run, a reinforcement learning loop.
There is nothing to install. The engine ships pre-compiled inside the wheel, so pip install BPTK-Py already has it — no Rust toolchain, no build step, no configuration.
Choosing an engine
The choice can be made at three levels, and the more specific one always wins.
Per run, with the backend argument:
bptk.run_scenarios(
scenarios="base",
scenario_managers="smPopulation",
equations=["population"],
backend="rust",
)plot_scenarios() takes the same argument.
Per bptk instance, through its configuration:
import BPTK_Py
bptk = BPTK_Py.bptk()
bptk.config.configuration["default_backend"] = "rust"Per serving process, through the factory the server builds its instances with. The bptk_factory of BptkServer is called once per instance, so the configuration has to be passed there rather than set afterwards:
from BPTK_Py.server.bptkServer import BptkServer
from BPTK_Py.bptk import bptk
def make_factory(configuration=None):
def build():
instance = bptk(configuration=configuration)
# register the scenario managers this server should serve
return instance
return build
app = BptkServer(
__name__,
bptk_factory=make_factory(configuration={"default_backend": "rust"}),
)Every session on that server is then Rust-backed and clients need not send a backend field at all — though one they do send still wins, which is what makes an A/B comparison between the engines possible without restarting anything.
An explicit backend= on a call always beats the instance default, and the instance default beats the process default. When nothing says otherwise, the answer is "python".
It applies to System Dynamics scenarios only. Agent-based models and the agent-based half of a hybrid model always run in Python.
Sessions keep the engine they started on
A step-by-step session — begin_session(), then run_step() — is bound to the engine it began with, for its whole life. If the serving process is later reconfigured, or the session resumes in a process that defaults to the other engine, the session keeps its original one.
This is deliberate, and the reason is worth knowing: the two engines carry their simulation state in different places. Half a session on one engine and half on the other would not be a slower run, it would be a different one.
For the same reason, a stochastic model that has to resume identically after a restart — a session externalised to Postgres or Redis — needs an explicit seed:
bptk.begin_session(
scenarios=["base"],
scenario_managers=["sm"],
backend="rust",
seed=42,
)Deterministic models have no random numbers to pin, so they resume exactly regardless.
A Rust session that comes back from external state
The two combine, but not by saving the engine: a live Rust engine is a compiled object and is not part of the serialised session state. When a Rust-backed session is read back from Postgres, Redis or a file — after a restart, or on another process behind a load balancer — the engine is rebuilt before the next step is computed. There are two ways it can happen, and which one you get is a matter of what was persisted:
- Import. The session usually carries an exported memo grid, and rebuilding from it costs the same whether the session is at round three or round three hundred. The per-step settings are folded together and re-applied so later steps use the right equations, but the rounds already computed are not recomputed.
- Replay. Without such a grid, the recorded per-step settings are replayed one round at a time until the cursor reaches the current step. Always correct, and the cost grows with the number of rounds.
For a deterministic model the two are indistinguishable in their results. For a stochastic one they are not: the import path does not restore the generator’s mid-stream position, so the numbers drawn after the resume differ from the ones an uninterrupted run would have drawn — the values already computed stay exactly as they were. Replay reproduces even those bit-identically. This is the other reason to pass an explicit seed to a stochastic session that has to survive a restart.
When the Rust engine cannot take a model
Running on the Rust engine means serialising the model, and two things cannot be expressed that way:
- User-defined functions — a function you wrote in Python has no counterpart in the engine.
- Arrayed aggregations — arrays are a Python-level concept, and the engine sees only flat scalar elements.
When BPTK meets one of these, nothing fails. The scenario continues on the Python engine and the reason goes to the log. You lose the speed, never the result.
Two properties of that fallback matter in practice:
- It is per scenario, and it lasts for the session. Once a scenario has fallen back, it stays on the Python engine until you start a new one. Other scenarios in the same run are unaffected.
- It is only visible in the log. No exception is raised. So when a run you expected to be fast is not, read the log before you reach for a profiler — the answer is usually a line there saying which scenario fell back and why.
A fallback that happens after a step-by-step session has already advanced is logged at [ERROR] rather than [WARN], because the Python engine cannot see the rounds the Rust engine already played and recomputes them with the settings of the current step.
In a browser
There is no Rust engine in a browser. The compiled extension has no place to run under Emscripten, so every page of this documentation — and any notebook you serve the same way — uses the Python engine, whatever the configuration says.
The two engines side by side
Everything above says how to choose an engine. This chapter says what the choice is worth, with a model small enough to print in full — copy the four blocks into a file and you have the benchmark.
The model
An SIR epidemic with vaccination and a hospital-capacity feedback. People move from susceptible to infected, from infected to recovered or deceased, and from susceptible to vaccinated; the recovered slowly lose their immunity and return. Mortality is not a constant: it rises once the infected outnumber the hospital beds. That feedback, plus the waning immunity, is what makes the model oscillate rather than settle after one wave — and what makes it worth simulating rather than solving.
Five stocks hold the population:
from BPTK_Py import Model
from BPTK_Py.sddsl import functions as sd
def build_sir_model(stoptime=100, dt=0.25):
model = Model(starttime=0, stoptime=stoptime, dt=dt, name="sir")
susceptible = model.stock("susceptible")
infected = model.stock("infected")
recovered = model.stock("recovered")
vaccinated = model.stock("vaccinated")
deceased = model.stock("deceased")
susceptible.initial_value = 9990.0
infected.initial_value = 10.0
recovered.initial_value = 0.0
vaccinated.initial_value = 0.0
deceased.initial_value = 0.0Five flows move people between them. Each stock equation is simply the flows in minus the flows out — the arithmetic operators build the equation graph, they do not compute anything yet:
infection = model.flow("infection")
recovery = model.flow("recovery")
vaccination = model.flow("vaccination")
death = model.flow("death")
waning_immunity = model.flow("waning_immunity")
susceptible.equation = -infection - vaccination + waning_immunity
infected.equation = infection - recovery - death
recovered.equation = recovery - waning_immunity
vaccinated.equation = vaccination
deceased.equation = deathSeven constants parameterise it:
contact_rate = model.constant("contact_rate")
transmission_prob = model.constant("transmission_prob")
recovery_time = model.constant("recovery_time")
mortality_rate = model.constant("mortality_rate")
vaccination_rate = model.constant("vaccination_rate")
immunity_duration = model.constant("immunity_duration")
hospital_capacity = model.constant("hospital_capacity")
contact_rate.equation = 8.0
transmission_prob.equation = 0.03
recovery_time.equation = 14.0
mortality_rate.equation = 0.001
vaccination_rate.equation = 0.01
immunity_duration.equation = 180.0
hospital_capacity.equation = 200.0And three converters carry the parts that are neither a level nor a rate. capacity_pressure is the feedback: at or below capacity it is 1 and mortality is the base rate; above it, it grows with the number of infected and takes effective_mortality with it.
total_population = model.converter("total_population")
capacity_pressure = model.converter("capacity_pressure")
effective_mortality = model.converter("effective_mortality")
total_population.equation = susceptible + infected + recovered + vaccinated
infection.equation = sd.max(
0,
contact_rate * transmission_prob * susceptible * infected / total_population,
)
recovery.equation = sd.max(0, infected / recovery_time)
vaccination.equation = sd.If(
sd.time() > 10,
sd.min(susceptible * vaccination_rate, susceptible / model.dt),
0,
)
capacity_pressure.equation = sd.max(1, infected / hospital_capacity)
effective_mortality.equation = mortality_rate * capacity_pressure
death.equation = sd.max(0, infected * effective_mortality)
waning_immunity.equation = sd.max(0, recovered / immunity_duration)
return modelTwenty elements in total, thirteen of them tracked in the runs below. It is deliberately a small model: what changes from row to row is only how many timesteps it is run for, from 400 to 400,000, because that is what a parameter sweep, a Monte Carlo run or a long horizon actually does to you.
Timing both engines
Both runs go through the ordinary API. Only the backend argument differs:
import time
import BPTK_Py
EQUATIONS = [
"susceptible", "infected", "recovered", "vaccinated", "deceased",
"infection", "recovery", "vaccination", "death", "waning_immunity",
"total_population", "capacity_pressure", "effective_mortality",
]
def measure(stoptime, dt=0.25, runs=3):
"""Median wall-clock time of one full run, on each engine."""
model = build_sir_model(stoptime=stoptime, dt=dt)
bptk = BPTK_Py.bptk()
bptk.register_scenario_manager({"bench": {"model": model}})
bptk.register_scenarios(scenarios={"base": {}}, scenario_manager="bench")
times = {"python": [], "rust": []}
results = {}
for _ in range(runs):
for backend in ("python", "rust"):
model.reset_cache() # without this the second run reads the first one's memo
start = time.perf_counter()
results[backend] = bptk.run_scenarios(
scenario_managers=["bench"], scenarios=["base"],
equations=EQUATIONS, backend=backend,
)
times[backend].append(time.perf_counter() - start)
median = {b: sorted(v)[runs // 2] for b, v in times.items()}
largest_difference = (results["python"] - results["rust"]).abs().max().max()
return median["python"], median["rust"], largest_difference
for stoptime, steps in [(100, "400"), (1_000, "4,000"),
(10_000, "40,000"), (100_000, "400,000")]:
python_s, rust_s, difference = measure(stoptime)
print(f"{steps:>9} steps python {python_s * 1000:9.1f} ms"
f" rust {rust_s * 1000:8.1f} ms {python_s / rust_s:5.1f}x"
f" largest difference {difference:.1e}")reset_cache() is not decoration. Without it the second engine reads the values the first one memoised and reports a speed that belongs to neither.
Results
Median of three runs on an Apple M3, Python 3.13:
| Timesteps | Python engine | Rust engine | Speedup | Time saved |
|---|---|---|---|---|
| 400 | 26.0 ms | 3.9 ms | 6.7× | 22 ms |
| 4,000 | 257.5 ms | 24.3 ms | 10.6× | 0.2 s |
| 40,000 | 2,502.5 ms | 260.5 ms | 9.6× | 2.2 s |
| 400,000 | 27,548.4 ms | 4,398.0 ms | 6.3× | 23.2 s |
The two engines agree exactly: the largest absolute difference across all thirteen equations, in every row, is zero.
Your own numbers will differ — the ratio is what to compare, not the milliseconds. One caveat if you build the engine from source: maturin develop produces a debug build, roughly five times slower than the release build that ships in the wheel. Benchmark against maturin develop --release, or against an installed pip install BPTK-Py.
Reading the table
The ratio is not the interesting column; the last one is. At 400 timesteps the Rust engine saves 22 milliseconds, which nobody notices. At 400,000 it saves 23 seconds — the difference between a run you wait for and a run you switch away from. Put that inside a hundred-iteration sweep and it is the difference between forty minutes and six.
The ratio drops at the very top, and the engine is not the reason. Simulating 400,000 steps costs the Rust engine about 1.9 s; turning the result into a pandas DataFrame costs about 2.6 s — and that conversion is the same Python code on both paths. The simulation itself stays 15–20× faster throughout; what you measure end-to-end is diluted by the part neither engine can speed up. Serialising the model to JSON and parsing it in Rust, by contrast, cost about 0.1 ms each and never matter at any size.
Which is why the advice under Troubleshooting is what it is: the Rust engine pays off with size and with repetition. A small model run once is dominated by setting it up.
Troubleshooting
“I set backend='rust' and nothing got faster.” Read the log. Either the scenario fell back (see above), or the model is small enough that the run is dominated by setting it up rather than by evaluating it. The Rust engine pays off with size and with repetition.
“The results changed when I switched engines.” They should not. Both engines are covered by the same test suite, and a difference is a bug worth reporting — with one exception: a stochastic model draws different random numbers on the two engines unless you pin a seed.
“It worked in my script and fails on the server.” Check whether the session was started on the other engine; sessions keep theirs (see above).