Skip to content

bngsim SBML bridge reloads the model (re-derives the analytical Jacobian) on every objective evaluation #415

Description

@wshlavacek

Summary

The bngsim SBML bridge (pybnf/bngsim_sbml_model.py) fully reloads the bngsim
model — libSBML parse → interpretation → analytical-Jacobian derivation → codegen —
inside execute(), which runs once per objective evaluation. None of these
per-model costs amortize across a fit; in particular the symbolic Jacobian (SymPy
sp.diff) is re-derived from scratch on every evaluation, even though it depends only
on the rate-law structure and is identical across parameter sets.

The .net/BNGL bridge already avoids this (loads the model once, applies parameter
values in place via set_param), so the fix is to bring the SBML path in line.

Where (v1.4.0, 50bc026)

pybnf/bngsim_sbml_model.py, execute() — both the TimeCourse (L397) and
ParamScan (L422) branches:

doc = self._build_sbml_doc(mut=mut)
engine_model = self._load_bngsim_model_from_text(_sbml_doc_to_text(doc))  # full load, every eval
result = self._run_simulation(engine_model, ...)

_load_bngsim_model_from_text (L171) calls bngsim.Model.from_sbml_string /
from_sbml — the full loader (parse + interpret + analytical Functional Jacobian +
auto-codegen) — with no caching. execute() is invoked once per parameter set during
fitting/sampling, so the entire pipeline reruns each evaluation.

Cost

The dominant re-paid cost is the analytical-Jacobian derivation (SymPy). Measured with
bngsim 0.9.36:

model analytical-Jacobian derivation (re-paid per eval)
BIOMD0000000001 (12 species) ~27 ms
BIOMD0000000051 ~785 ms

It scales with model size. Parse + interpretation add a further ~10–40 ms re-paid per
eval, and the first evaluation in each worker process additionally pays the one-time
SymPy import (~0.3 s). Because the symbolic Jacobian is independent of parameter
values, every one of these re-derivations after the first is redundant work. As an
illustration, a 100k-evaluation fit of a BIOMD51-class model spends on the order of
~22 h re-deriving the identical Jacobian.

The .net bridge already does the right thing

pybnf/bngsim_model/net_model.py, execute():

model = self._engine_model          # loaded once (Model.from_net), cached on the instance
model.set_param(pname, value)       # apply new parameter values in place
model.reset()

Parameter values don't change the symbolic Jacobian, so set_param keeps the derived
Jacobian valid and the load cost is paid once for the whole fit.

Suggested fix

In the SBML path, load the bngsim model once (cache it on the instance, as the
.net path does) and apply per-evaluation parameter values via
model.set_param(...) + model.reset() instead of rebuilding the SBML document and
reloading. Reserve a reload for cases that genuinely change model structure (e.g.
mutants), not parameter-value changes.

Secondary: integration runs cold on every evaluation (both bridges)

Both bridges construct a fresh bngsim.Simulator(model, ...) per execute()
(bngsim_sbml_model.py:335; bngsim_model/net_model.py:267), so each integration
pays the one-time CVODE workspace / linear-solver setup (cold) instead of reusing a
warm solver. Measured on the same models, the cold→warm gap is ~2.5× on a fast model
(0.49 → 0.20 ms) and negligible on a slow/stiff one. Reusing the Simulator across
evaluations (with reset() to restore initial conditions) would let integration run
warm — a smaller win than the reload fix above, but the same idea of amortizing
per-model setup across a fit.

Note

This is a bridge issue, not a bngsim engine issue: bngsim reuses its solver workspace
correctly when a Simulator is reused, and supports set_param for in-place
parameter updates.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions