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() and begin_session() take the same argument.
Per bptk instance, through the configuration it is created with. Every call of that instance that is not given a backend - run_scenarios(), plot_scenarios(), begin_session() - uses it:
import BPTK_Py
bptk = BPTK_Py.bptk(configuration={"default_backend": "rust"})The value is read when the instance is created; changing bptk.config.configuration afterwards has no effect.
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 so is every /run. Clients need not send a backend field at all — though one they do send to /begin-session 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. A scenario manager registered as agent-based ("type": "abm") runs in Python and ignores backend, and so does the agent-based half of a hybrid model.
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 a few models cannot go that way - a model compiled from XMILE, for instance, or one with an elementwise=False function. Which ones, and why, is listed under Limitations. Arrayed models and user-defined functions are not among them.
Asking for an engine you cannot have
backend="rust" on such a model raises RustBackendError, and the message says which of the four it is:
from BPTK_Py import RustBackendError
try:
results = bptk.run_scenarios(
scenario_managers=["smSimple"], scenarios=["base"],
equations=["stock"], backend="rust")
except RustBackendError as error:
print(error)The run is not computed on the Python engine instead: a run meant to be fast, or meant to exercise the engine, would look the same as one that had used it. If you want the Python engine, ask for it — that is what backend="python" is for.
A session raises on the step where it happens. It does not finish in Python: the Python engine cannot see the rounds the Rust engine already played, and would rebuild that history from the settings current now, so anything carrying state — a stock, a delay — would come out wrong from that point on.
Part of a model can still be Python
A user-defined function runs in Python, on the engine’s request, once per node and timestep. The run says so:
[WARN] This model calls 1 user-defined Python function(s) ('market_wage'). Those nodes are
evaluated in Python while the rest of the model runs on the Rust engine.
Nothing is wrong there — the numbers are the same either way — but it is not a pure engine run, and it is why such a model can be slower than you expect. What a callback costs measures it.
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.
Asking for backend="rust" there raises RustBackendError rather than quietly using the Python engine. Code that has to work in both places either leaves the backend alone or catches the error.
What the choice is worth
How much the engine matters depends on the model, and on how often it runs. Three models are measured in full - the code, the numbers, and what the numbers do not say - under Benchmark Models:
- A scalar model over time - an SIR epidemic with a capacity feedback, from 400 to 400,000 timesteps.
- An arrayed model over its width - a workforce chain whose vector grows from 3 levels to 200, which is the axis a scalar model does not have.
- A model that calls back into Python - what a user-defined function costs: the crossing into Python, and the work it does there.
Troubleshooting
“I set backend='rust' and nothing got faster.” Read the log. Either the model calls user-defined functions, which are evaluated in Python and say so at [WARN], or it 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).