Execution Backends

The Python and Rust execution engines for System Dynamics models in BPTK-Py: how to choose one, how session stickiness works and what falls back.
Keywords

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.

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).