← Back to blog

Porting the simulation engine into Rust

A measured comparison of Modeloop's Rust and historical Python simulation runtimes: MIL, SIL, hierarchical models, and the cost of recording signals.

TL;DR

After moving Modeloop's simulation engine from Python to Rust, execution was, on average, 8.6× faster when running models and 16.4× faster when running their generated C code across the engineering models we tested. The recorded results matched between the two engines.

These measurements come from tests on a single machine. The improvement varies with the model and how much data it records.

A control algorithm can be inexpensive to compute and still be expensive to simulate. At every tick, the runtime must decide which callbacks execute, move values between them, update state, evaluate inputs, and record the signals an engineer wants to inspect. Repeating that work thousands of times makes the surrounding machinery matter.

Python helped us move quickly during Modeloop’s early development. We could change the engine and run it immediately, without a separate native compilation step. That short feedback loop made a substantial difference while we were building the first versions and refining how the system should work.

As models grew, the trade-off became more visible. Some of the time we saved during development became time users spent waiting for simulations. We optimized the Python runtime, but its performance did not scale with the workloads we needed to support. Running more callbacks, moving larger signals, and recording more measurements increased the cost of work repeated at every tick.

Why we chose Rust

Rust offered several properties that mattered for this engine. It compiles to native machine code, letting us move scheduling and data handling out of the Python interpreter. It also gives us direct control over memory layout and allocation, without requiring a garbage collector. That makes it possible to reduce the overhead of storing state, moving signals, and collecting results.

Performance was one part of the decision. Rust’s type system and ownership rules check how data is used and how long resources remain valid. Those checks are useful in an engine that coordinates model state, shared signal storage, and native libraries. Its concurrency checks also provide a foundation for safe parallel work, although the benchmarks in this post measure sequential execution.

Rust also fits the environments where Modeloop runs. It can call generated C controllers through native interfaces and compile to WebAssembly for the browser. This lets us maintain a shared simulation core across desktop and web.

Our goal is a responsive, fast product as models and projects grow. Meeting that goal led us to a complete engine rewrite in Rust. The work kept us occupied for several months, including rebuilding execution paths and checking that they preserved the behavior of existing models. The measurements below show the performance gains that effort delivered.

To understand what changed in simulation specifically, we compared the two runtimes on controlled workloads, engineering models, and nested controllers with multiple callback rates. We measured execution separately from process startup and result writing, and checked the recorded signals before admitting each configuration to timing.

Inside a simulation tick

One step of the model clock
  1. Sample inputsRead the scenario
  2. Release callbacksSelect the work due
  3. Execute & updateCompute and retain state
  4. Exchange signalsPass values between callbacks
  5. Record valuesCapture selected signals
Repeated across the simulation
Conceptual activities within a tick. Their execution order follows the model's dependencies; the spacing does not represent elapsed time.

Across six engineering models, the Rust runtime delivered a geometric-mean execution speedup of 8.60× for model-in-the-loop (MIL) and 16.42× for software-in-the-loop (SIL) on the test host.

Together, these results explain where the migration helped and where further work remains.

Comparing the runtime without changing the compiler

To isolate the simulation runtime, we compared the historical Python execution implementation from Modeloop 1.6.0 with the Rust runtime, using the same compiler to prepare both sides. Compilation happened before timing.

The two simulation targets required different comparisons:

  • MIL: the historical runtime executed generated Python classes; the Rust runtime executed the compiled model. For the engineering models, both came from the same authored model and compiler configuration.
  • SIL: both runtimes loaded exactly the same precompiled C shared-library bytes. The comparison therefore captured differences in scheduling, calling the generated code, passing signals, and recording results while holding the generated C implementation constant.

This distinction matters. A SIL speedup can come from reducing the cost of invoking and observing a controller even when the controller’s C code stays identical. In MIL, the two runtimes also executed the model in different forms, so the model’s own computation can cost differently too.

Measuring the work around the controller

The timed execution interval included initialization, input-profile evaluation, scheduling, state updates, communication, recording selected signals, collecting results, and termination. We measured setup and MDF4 result writing separately. An external timer also measured fresh-process completion, including startup and teardown.

The experiment retained 912 timed invocations across 38 admitted target–workload configurations, using twelve randomized paired rounds for each configuration. All runs used one Intel i7-8750H WSL2 host, with each invocation starting in a fresh process pinned to the same guest logical CPU.

The table below reports execution speedups. A value of 8.60× means the Python runtime took 8.60 times as long as Rust within that execution interval.

Model suite MIL execution speedup SIL execution speedup
Six engineering models 8.60× [8.48, 8.74] 16.42× [16.24, 16.63]
Three hierarchical actuator-bank models 5.84× [5.70, 5.95] 41.82× [41.17, 42.43]

Each entry is an equal-model geometric mean of Python/Rust mean-time ratios. Brackets show pointwise paired-bootstrap 95% confidence intervals, conditional on this host and these runs. MIL and SIL were measured in separate blocks and remain separate summaries.

The six engineering models covered coupled hydraulic tanks, quarter-car suspension, a permanent-magnet synchronous motor, a thermal reactor, observer feedback, and tensor feedback. Their individual results varied substantially: MIL execution speedups ranged from 5.98× for the motor model to 24.21× for tensor feedback.

In absolute terms, each engineering run simulated two seconds of model time: 2,000 ticks of one millisecond. Rust executed those two seconds in 45–121 ms in MIL and 12–22 ms in SIL, against 393–1,080 ms and 162–1,001 ms in Python. Expressed as a real-time factor, simulated time divided by execution time, Rust ran the models 16–45× faster than real time in MIL and 90–168× in SIL. Python reached 1.9–5.1× and 2.0–12.4×. This is batch throughput: it does not show that every callback meets its deadline.

The interactive chart shows the speedup for each model. Hover over or focus a row to see each runtime’s mean time per tick.

Model-in-the-loopSoftware-in-the-loop
Coupled tanks
7.80×
13.55×
Observer feedback
7.34×
13.24×
CSTR thermal
7.85×
13.95×
PMSM dq
5.98×
11.59×
Quarter car
6.23×
12.56×
Tensor feedback
24.21×
53.87×
Whiskers: pointwise 95% confidence intervals. Dashed marker: equal time (1×).

Startup also mattered. Fresh-process completion for that suite improved by 19.15× in MIL and 47.61× in SIL. These numbers describe the benchmark process, including its startup and teardown. They do not measure the time from pressing Run to seeing plots in the application, or repeated runs in an already open application.

Exercising hierarchy and multiple rates

Small arithmetic fixtures help isolate costs, but a runtime also has to preserve behavior across nested state, conditional execution, and communication between callbacks.

We built three actuator-bank models with 3, 16, and 64 axes. Each combines a 10-ms supervisory callback with a 1-ms control callback. The supervisor filters a measurement and runs an enum-based safety state machine. Its mode passes through a connection between the two callbacks to the fast controller, which executes nested sensor filters, vector PI control, and normal, limited, or safe actuator behavior.

The models expose one tensor input and one tensor output. The safety mode is observed internally. The largest contains 165 blocks across 27 levels, reaching a nesting depth of 13, with no level exceeding 13 blocks.

Two rates, one controller

One tensor input · one tensor output
Safety supervisorEvery 10 ms
Measurement filterSelected input element
Safety state machineNORMAL · LIMITED · SAFE
Operating modeRetained between supervisor updates
Actuator controllerEvery 1 ms
Tensor inputTensor output
Sensor filtersNested, stateful stages
Vector PI controlOne control value per axis
Safety envelopeNormal, limited or safe
Measured model sizes
3 axes16 axes64 axes
Simplified structure of the hierarchical benchmark models. The supervisor updates the mode every ten simulation ticks; the fast controller uses the retained mode at each tick. Periods describe simulated time, not measured execution time.

A tick is one step of the simulation clock. Here, each step advances simulated time by one millisecond. To make the execution cost easier to compare, we divide the measured run time by its number of ticks and report the mean time per tick, in microseconds (µs).

For the 16-axis model, that mean fell from 417.28 to 72.75 µs per tick when running the model, and from 312.73 to 8.34 µs per tick when running its generated C code. Each run covered two simulated seconds, or 2,000 ticks.

This is the average cost of running a simulation step, including its share of initialization and result collection. It does not measure individual callback latency or guarantee that every step finishes within that time.

PythonRustMean tick time · µs · log scale
Model-in-the-loop
MIL: mean tick time by actuator axes3 axes: Python 238.35 µs per tick, Rust 19.88 µs per tick. 16 axes: Python 417.28 µs per tick, Rust 72.75 µs per tick. 64 axes: Python 979.90 µs per tick, Rust 339.15 µs per tick.Both targets use the same logarithmic scale.1101001,00031664
Actuator axes
Software-in-the-loop
SIL: mean tick time by actuator axes3 axes: Python 170.57 µs per tick, Rust 2.48 µs per tick. 16 axes: Python 312.73 µs per tick, Rust 8.34 µs per tick. 64 axes: Python 815.14 µs per tick, Rust 28.76 µs per tick.Both targets use the same logarithmic scale.1101001,00031664
Actuator axes

Both width and nesting depth increase. Lines connect the three measured models; they do not predict intermediate sizes. MIL and SIL share a logarithmic scale: equal vertical distances represent equal ratios.

As model width and nesting increased together, the relative speedup decreased. The 64-axis model still improved by 2.89× in MIL and 28.34× in SIL, but this sequence cannot tell us how much of the change came from tensor width versus hierarchy depth. It exercises both at once.

The margin to real time shrank as well. Rust took 678 ms to simulate the 64-axis model’s two seconds in MIL, 2.9× faster than real time; Python needed 1.96 s, barely ahead of real time. With the generated C code, Rust reached 35× real time against 1.2× in Python.

Checking the signals before comparing the clocks

A faster simulation is useful only if engineers can trust the behavior it records.

Before timing, an independent MDF4 reader checked channel names, storage types, timestamps, and sample values from both runtimes. Across the 38 admitted configurations, it compared 2,609,100 scalar recorded values, plus their timestamps. The maximum observed cross-runtime absolute numerical difference was zero. Integer and Boolean comparisons were exact; floating comparisons used a tolerance declared before timing.

Controlled fixtures also had independent expected answers. These included unsigned 64-bit values above the exact-integer range of binary64, helping check that the exercised path preserved integer values. The actuator scenarios exercised all three safety modes and checked bounded outputs and zero actuation in SAFE mode.

These checks establish agreement for the recorded scenarios. They do not exhaust every state transition or prove equivalence for arbitrary models.

How the workload changes the result

The controlled workload with 128 affine stages took 213.66 ms in Python and 287.33 ms in Rust: a Python/Rust ratio of 0.74×, or approximately 34.5% longer execution in Rust.

That fixture deliberately bypassed the compiler’s optimizations, leaving many individual operations to execute at every tick. We attribute the slowdown to the cost of executing those operations rather than to the work around them. Reducing runtime overhead does not guarantee a win for every operation mix.

Recording has a cost

Execution, startup, recording, and numerical work each contribute to simulation cost. In one controlled 64-element tensor case, selecting a single element for logging reduced Rust execution from 422.99 ms to 336.61 ms while preserving the numerical workload. Observation choices have a measurable cost of their own.

The cost of recording

+25.7%execution time when recording
all 64 elements instead of one
Compare both runtimes
Mean execution · 10,000 ticks · same 64-element model
RuntimeOne element (ms)All 64 (ms)Extra (ms)
Rust336.61422.99+86.38
Python3,626.947,271.52+3,644.58

What the rewrite changes

Python’s short development cycle helped us build and refine Modeloop. As the engine matured, we needed a foundation that could support growing models without passing an increasing execution cost on to users. Rewriting the engine in Rust took several months. These measurements give us concrete evidence of what that work achieved: average execution speedups of 8.6× for models and 16.4× for their generated C code across the six engineering models, with matching recorded results.

The gains vary with the workload. They come mainly from removing most of the fixed cost the Python runtime paid at every tick. What remains in Rust grows with the model: from 3 to 64 axes, the mean time per tick grew about 17× in MIL and 12× in SIL, compared with about 4× and 5× in Python. This is why the relative speedup shrinks as models get larger.

Our objective remains a responsive, fast product as those projects grow.

Rust gives us more control over the scheduling, memory, and data movement behind those results, alongside a shared core for desktop and browser execution. That control matters as we continue developing Modeloop and supporting more complex projects.