Skip to content

showcase: rust execution engine - #5732

Open
BradyPlanden wants to merge 1 commit into
mainfrom
showcase/rust-core-complete
Open

showcase: rust execution engine#5732
BradyPlanden wants to merge 1 commit into
mainfrom
showcase/rust-core-complete

Conversation

@BradyPlanden

Copy link
Copy Markdown
Member

This is a showcase PR, not intended to be merged.

This PR adds a Rust execution engine as discussed in #5667. This engine compiles a discretised model's expression trees: RHS, algebraic residuals, Jacobians, events, output variables, into a flat instruction tape evaluated entirely in Rust. It updates the IDAKLU solver with an FFI to integrate with this engine without crossing the Python layer, and adds a pure-Rust diffsol solver implementation.

The engine is opt-in through BaseModel.convert_to_format:

model = pybamm.lithium_ion.DFN()
model.convert_to_format = "rust" # the entire opt-in
sol = pybamm.Simulation(model).solve([0, 3600])

This engine lands as an optional backend for PyBaMM, but it offers the structure to replace CasADi as the default once it stabilises. This branch moves the default to 'rust', showcasing full test suite coverage.

GitHub offers an interactive VS Code interface for reviewing PRs, which I would recommend as a good way to poke around (or even better, clone locally). This is enabled by typing a full stop . on this PR page.

Mental model

convert_to_format already selected the expression backend ("casadi", "jax", "python"); this adds "rust". Four components sit behind it:

What Where
Compute core Expression DAG → typed IR → flat tape. Includes forward-tangent + adjoint AD, with DSATUR colouring, and simplifier. Knows nothing about PyBaMM. pybamm-rust/pybamm-core
Rust-Python bindings Bindings between the pure-Rust core and PyBaMM, with stubs provided for type-hinting. pybamm-rust/pybamm-python
IDAKLU swappable backend The same SUNDIALS IDA + KLU solver, RustFunctions substituted for CasadiFunctions. pybammsolvers/.../Expressions/Rust
New solver pybamm.DiffsolSolver: pure-Rust BDF (diffsol + faer). No SUNDIALS, no CasADi in the path. solvers/diffsol_solver.py

Performance has two axes

  • Integration: kernel cost in the stepping loop. For IDAKLU, both backends call into preallocated C++ buffers, so it's a pure arithmetic comparison.
  • Observation: extracting variables and sensitivities. ProcessedVariable drives CasADi with a per-timepoint Python loop; the Rust path runs one native tangent sweep per parameter across the whole trajectory, GIL released. Scales with output points.

Performance

The baseline is origin/main. Every CasADi number below was measured in a second checkout of origin/main (abfdff33f) with its own replicated environment. The Rust numbers come from this branch.

Top-level

Rust IDAKLU speedup against the CasADi default, every lane by model by output-point count

rust_idaklu beats main's CasADi default in all 72 cells, from 1.22× to 12.36×. rust_diffsol wins 69 of 72, see Gradients.

1C discharge

Wall-clock against output points, 1C constant-current discharge

Triangle-wave drive cycle

Wall-clock against output points, triangle-wave drive cycle

Output variables

Passing output_variables=["Voltage [V]"] moves observation inside the solve and skips storing the full state.

Wall-clock against output points, output_variables solve, 1C discharge

Repeated evaluation

One solve per parameter draw, read through the interpolating call interface, representing the inner loop of a fitting or inference run. With output_variables= set the rust_idaklu win rises to 1.35–6.98×.

Model 1C discharge drive cycle
SPM 1.81–1.96× 2.59–5.26×
SPMe 2.59–2.91× 2.18–3.49×
DFN 1.23–1.45× 1.26–1.52×

Gradients

Forward sensitivities. The largest ratios in the set, and the ones needing the most care to read.

DFN sensitivity vs CasADi on main 10 100 1,000 10,000
rust_idaklu, 1C discharge 1.39× 1.69× 3.87× 7.87×
rust_idaklu, drive cycle 1.78× 1.92× 3.64× 8.08×
casadi_idaklu_aot, 1C discharge 1.53× 1.40× 1.11× 1.01×

AOT-compiling the CasADi kernels is worth ~1.5× when integration dominates and decays to nothing once observation does. The Rust path moves the opposite way and grows to 8.08×.

Two caveats. main's IDAKLU never calls IDASetSensParams, so it runs at IDAS's pbar = 1 default; this PR passes pbar_i = |p_i|, which moves step selection for both IDAKLU backends. rust_diffsol error-controls the scaled sensitivities while neither IDAKLU backend does (IDASetSensErrCon is never called), so it integrates strictly more state under error control than the rows it is timed against, which is why it is slower than main's CasADi.

Setting output_variables= shrinks the gradient win to 1.13–3.88×, because the observation saving is already taken by the restriction.

The experiment path

pybamm.Experiment with four steps, 10 s period against main's CasADi, as rust_idaklu / rust_diffsol. Runnable by using --protocols experiment in the benchmark suite.

lane SPM SPMe DFN
solver 1.49× / 1.49× 1.81× / 2.04× 1.47× / 1.67×
inference 1.16× / 1.23× 1.89× / 2.15× 1.53× / 1.69×
sensitivity 4.61× / 4.53× 2.92× / 2.82× 2.09× / 1.03×

All 9 cells win. This protocol sits outside the sweep above, as its step period fixes the output grid.

output_variables= plus calculate_sensitivities is not supported across a step boundary, on any backend or either branch: the handoff needs d(state)/dp at the boundary and an outputs-only solve does not store it. This PR makes that fail uniformly with a named SolverError. On main the same combination silently returns wrong gradients (off by ~110–120× tolerance), which is how it was found.

Where the time goes

DFN sensitivities, 1C discharge, at 10,000 output points (ms):

integration observation end-to-end
CasADi default (main) 127.85 1,560.46 1,705.20
CasADi AOT-compiled (main) 106.42 1,573.71 1,695.95
Rust · IDAKLU 109.15 93.11 216.69

Rust's integration lands level with AOT-compiled CasADi (109 ms against 106 ms) with no compile step in front of it, and the end-to-end 7.87× comes mostly from the other axis: 1,560 ms of observation becomes 93 ms.

Setup cost

The fair "fast CasADi" comparator is options={"compile": True}, which shells out to gcc -O3 -march=native. Rust's "compile" is an in-memory IR build:

Model CasADi AOT prepare Rust prepare Ratio
SPM 672.3 ms 5.0 ms 135×
SPMe 1,691.8 ms 10.8 ms 157×
DFN 15,163.3 ms 5.5 ms 2,734×

(first-solve setup, 1C discharge at 1,000 points; CasADi measured on main, cache isolated per case, each row verified as a fresh compile followed by a fresh-process disk reload. The non-AOT casadi_idaklu prepare is 14.6 / 36.2 / 40.3 ms.)

Numerical parity and accuracy

Correctness of the Rust backend is established in the test suite, where the kernels (RHS, Jacobians, events, outputs, tangents) are checked against CasADi expression-for-expression at near machine precision (the shared parity fixture asserts rtol 1e-10, atol 1e-14). rust_idaklu feeds those kernels to the same SUNDIALS integrator, so its trajectories are the same computation. The tables below confirm that parity holds end-to-end through the FFI as rust_idaklu's worst errors match casadi_idaklu's to the digit in every cell.

We can use the benchmark to measure the solution accuracy of rust_diffsol in reference to IDAKLU/CasADi. This is done below, where every row is scored against a converged reference of atol = rtol = 1e-10 (falling back to 1e-9 where that will not converge) with no solution interpolation. Rows outside tolerance, by lane and output-point count:

lane 10 100 1,000 10,000
solver 0/48 0/48 1/48 1/48
sensitivity 0/48 0/48 0/48 0/48
inference 46/48 43/48 0/48 0/48

At 1,000 and 10,000 output points 286 of 288 rows pass. The two misses are a narrow state-accuracy gap for a DFN drive cycle at 1.16–1.17× tolerance.

The inference-lane block at 10 and 100 points is a property of the output grid, where that lane reads strictly between stored nodes, and a 10-point grid cannot resolve a discharge between its nodes however the trajectory was computed. main misses in the same proportion. Where the interpolants differ the Rust diffsol path is the most accurate of the three, because it supplies derivatives for Hermite interpolation where both IDAKLU backends fall back to a linear chord:

worst normalized error, inference 10 pts 100 pts 1,000 pts 10,000 pts
casadi_idaklu (main) 342.9 16.4 0.87 0.02
rust_idaklu 342.9 16.4 0.87 0.02
rust_diffsol 130.6 5.2 0.51 0.05

The identical casadi_idaklu / rust_idaklu rows are the end-to-end form of the parity the shared-integrator design was meant to buy.

Binary size

Shipped binary footprint, Rust core against the CasADi wheel

This is mostly because CasADi's wheel carries IPOPT, HiGHS, Cbc/Clp, qpOASES, MUMPS and a bundled gfortran that we never use and can't decline. The Rust improvement is shown in the dependency footprint below where diffsol, faer and the whole CAS/AD stack are statically linked into one object.

$ otool -L pybamm/rust/_core.abi3.so
    /usr/lib/libc++.1.dylib
    /usr/lib/libiconv.2.dylib
    /usr/lib/libSystem.B.dylib

Size Overview

+73,938 / −923 against origin/main, with the majority coming from tests:

Bucket Lines
Python src/ +4,825 / −580 the entire user-visible surface
C++ (pybammsolvers) +1,390 / −82 backend integration, pbar + IDASetSensParams
Rust src/ +20,305 new, self-contained, behind an FFI boundary
Rust + Python tests +38,769 / −69 Rust: 24,886 / Python: 13,883
Benchmarks +4,678 the observability suite above
Docs / CI / lockfiles / build glue +3,971 / −192 incl. uv.lock and the Rust Cargo.lock

Reproducing

From the root directory, run the below command. Additional flags are documented in the benchmark suite.

uv run python benchmarks/run_rust_observability.py \
    --lane solver --models SPM SPMe DFN \
    --protocols cc_discharge drive_cycle \
    --warmup 5 --aot all --output-points 1000 --json solver_1000.json

Every JSON payload embeds its own provenance (git revision, dirty flag, platform, package versions).

@BradyPlanden
BradyPlanden requested a review from a team as a code owner August 20, 2026 18:03
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@BradyPlanden
BradyPlanden force-pushed the showcase/rust-core-complete branch from b6c76d8 to 107f313 Compare August 20, 2026 18:27
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.90%. Comparing base (7c9f5bd) to head (107f313).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5732      +/-   ##
==========================================
- Coverage   98.11%   97.90%   -0.22%     
==========================================
  Files         340      346       +6     
  Lines       32743    33965    +1222     
==========================================
+ Hits        32127    33252    +1125     
- Misses        616      713      +97     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@martinjrobins

Copy link
Copy Markdown
Contributor

awesome @BradyPlanden :) I'll have a more detailed look at the code, and I'll look into the diffsol slowdowns in comparison with idaklu, some possible sources are:

  • as you mentioned, you might be error controlling the sensitivity equations whereas sundials does not do this be default. Note that recently I changed the diffsol defaults to match sundials, so this depends on the version you are using
  • the faer cpu backend is slower than the nalgebra backend for small state sizes
  • the faer lu solver can be slower than suitesparse's klu, I also provide a ffi interface to klu, so you could try that as well.
  • any number of solver configuration differences between sundials and diffsol. I mostly provide similar configuration options now, so you should be able to match the config to what we use for idaklu

MarcBerliner added a commit that referenced this pull request Aug 21, 2026
Three defects, none of which depend on the API shape:

The cache scratch vector lived on the solver object, so two threads evaluating
one Function shared it. Moved onto BrentMemory, which CasADi hands out per
concurrent evaluation.

The bracket test read `fa * fb <= 0`, and two residuals near the underflow
limit multiply to zero, which reads as a sign change. Now tested by sign, with
NaN handled explicitly, so a bracket holding no root is reported as one.

The NumPy path returned NaN where the plugin raises: a blanket
`except (ValueError, RuntimeError)` around brentq swallowed both an empty
bracket and a failure to converge along with the tree probes it was meant to
absorb. Those two now raise SolverError, and only a probe returns NaN.

`Brent` and `BrentUnknown` also lose their public names, becoming `_Brent` and
`_BrentUnknown` alongside `_BaseAverage`, and the docs page goes. The node has
no in-tree consumer yet, and lifting the residual into a closed sub-expression
-- which is where this is headed, and what the Rust tape backend in #5732 wants
too -- will change the constructor. Cheaper to withdraw the commitment now than
to break it later. The changelog bullet goes with it: nothing user-facing is
left to announce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@martinjrobins martinjrobins left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @BradyPlanden, I've had a first pass through, I skimmed a lot of it cause it is kinda big :) so I may have misunderstood some things, but these are my initial comments. I'll keep on having a look when I can

///
/// `Arc` marks the state shared with the `PreparedSolver` across solves, which
/// is `Send + Sync`; everything built per solve is owned outright.
pub struct Equations {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you already have a ModelEvaluator struct, so this struct seems to be duplicating this
functionality

pub(crate) compiled: Arc<CompiledModel>,
/// Shared with the caller, which evaluates output tapes against the same
/// scratch between steps on the output-variable path.
pub(crate) ws: Rc<RefCell<Workspace>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the Rc? should only need a RefCell, then either share a reference to your
ops, or better yet use a ModelEvaluator struct containing a RefCell and share a referfence
to that.

//! `Equations::reset()` always returns `None` and `ResetOp` is never
//! constructed. It exists solely to satisfy the `Reset` associated-type bounds
//! of `OdeEquationsImplicitSens`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't need this, use diffsol's UnitCallable

//! `Equations::reset()` always returns `None` and `ResetOp` is never
//! constructed. It exists solely to satisfy the `Reset` associated-type bounds
//! of `OdeEquationsImplicitSens`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you use pybamm's unified experiment mode, this will give you the reset and you can then
get proper gradients through the reset.

/// diffsol mints one of these per callback invocation, so it holds nothing of
/// its own: every field is a reference into the equations, making a mint a
/// handful of pointer copies.
pub struct RhsOp<'a> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

second paragraph of this docstring is redundent

pub name: Option<String>,
}

impl FunctionSignature {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filename should be function_signature.rs

pub name: Option<String>,
}

impl FunctionSignature {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spent a long time trying to find out where this was used in the ffi, but I don't think it
is. perhaps it should be called FunctionMetadata.

)


class ObservationBackend(ABC):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation sounds like a data measurement, perhaps SolutionBackend?



class ObservationBackend(ABC):
"""How a Solution lowers a variable name into evaluable leaves.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unclear what a leaf is?
I read this a couple of times but can't figure it out. what is the key
in backend[key]? is it a variable name? a segment index? a slice of segments?
Looking further down, it looks like the backends use interior mutability,
so you might want to say they provide an immutable api.

def __getitem__(self, key: slice) -> ObservationBackend:
"""This backend restricted to a slice of the Solution's segments."""

@abstractmethod

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing return type on signature

@BradyPlanden

Copy link
Copy Markdown
Member Author

Thanks for the initial review @martinjrobins! I'm now on hols now for a few weeks so once I'm back I'll take a deeper look. From an initial glance, I think you've correctly found some of the duplications / sloppy abstracts that have crept into the implementation over the few months of building. I'm working towards tightening these at the moment and will update this branch with the corrections. That said, I'm not very keen on supporting this branch long-term, so I will start splitting this into smaller PRs pretty quickly :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants