Skip to content

Generate the code of an initial assignment once per symbol - #1435

Open
matthiaskoenig wants to merge 1 commit into
developfrom
cache-initial-assignments
Open

matthiaskoenig wants to merge 1 commit into
developfrom
cache-initial-assignments

Conversation

@matthiaskoenig

@matthiaskoenig matthiaskoenig commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1436.

The initial value resolvers inline the math of every initial assignment (and of an assignment rule read at initialization) into the code of each reader. Unlike the model data resolvers they do not use the symbol cache, so a value which several initial assignments read is generated again for every read, and the code of a chain of initial assignments grows exponentially with its depth (the reproducer and the timings are in #1436).

This matters for physiologically based (PBPK) models, where organ masses, volumes, blood flows, partition coefficients and species starts are initial assignments over a few covariates (body weight, height, age, organ function), so that a changed covariate updates the whole physiology at a reset. Most of their load time is spent compiling these copies; with age-dependent reference values (a piecewise per organ) the compile became unusable.

Change

  • SBMLInitialValueSymbolResolver and ModelInitialValueSymbolResolver cache the value of an initial assignment and of an assignment rule per symbol with cacheValue, as the other resolvers do. piecewiseCodeGen already pushes a cache block per condition and branch, so a value generated inside a branch is not reused outside of it.
  • GetInitialValueCodeGenBase and SetInitialValueCodeGenBase generate one case block per symbol and now flush the cache at the start of each case, as GetValueCodeGenBase already does, so no case reads a value of another case.

Tests

  • New test/python/test_initial_assignment_cache.py: a chain of 40 initial assignments, each reading the one before twice and inside a piecewise, read by a species and an assignment rule; checks the values at load and after init(AGE) is changed and the model reset (the other branch). Without the cache the model does not load within minutes.
  • ctest: all tests pass (with -j 12 some ModelEditing, StateSaving and Auto2000 tests fail on shared files; they pass serially).
  • The 2975 tests of pkpdsim (PBPK models built with sbmlutils, simulated through sbmlsim) pass with this build.

Compile times (pkpdsim PBPK test model, CPU seconds of a fresh process)

model LLJIT before after
current physiology O0 2.07 0.53
current physiology O2 4.54 1.14
age-dependent physiology O0 24.6 2.35
age-dependent physiology O2 363 8.5

Reproducer of #1436 (chain of n initial assignments, each reading the one before twice)

n before after
10 0.74 0.40
20 15.5 0.50
22 58.6 0.53
40 - 0.87

The initial value resolvers inlined the math of every initial assignment
and assignment rule into the code of each reader, without the symbol
cache the model data resolvers use. A value which other initial
assignments read several times was generated again for every read, so
the code of a chain of initial assignments grew exponentially with its
depth; physiologically based models, in which the organ volumes, flows
and starts are initial assignments over a few covariates, spent most of
their load time compiling these copies.

SBMLInitialValueSymbolResolver and ModelInitialValueSymbolResolver now
cache the value of an initial assignment (and of an assignment rule) per
symbol with cacheValue, as the other resolvers do. The piecewise code
already pushes a cache block per branch, so a value generated in a
branch is not reused outside of it. The generated accessors of the
initial values (GetInitialValueCodeGenBase, SetInitialValueCodeGenBase)
generate one case block per symbol and now flush the cache at the start
of each case, as GetValueCodeGenBase does, so no case reads a value of
another case.

test_initial_assignment_cache.py loads a chain of 40 initial
assignments, each reading the one before twice and in a piecewise, and
checks the values before and after a changed initial value; without the
cache it does not load within minutes. A physiologically based model
of the pkpdsim test suite compiles in 0.53 s instead of 2.07 s (LLJIT
O0) and 1.14 s instead of 4.54 s (O2), its age dependent variant in
2.35 s instead of 24.6 s (O0) and 8.5 s instead of 363 s (O2), CPU
time of a fresh process. The ctest suite passes.
matthiaskoenig added a commit to matthiaskoenig/pkpdsim that referenced this pull request Nov 6, 2026
`PBPKDefinition(age=True)` makes every reference value and factor a curve of
the covariate `AGE`; without it (the default) the model is the reference
individual of 35 years, the factors of the age are the constant 1 and the
model has no `AGE`, so a change of it fails instead of being ignored. The
factors of the sex are the constants of the adult in such a build. The python
side (`reference_individual(sex, age)`, the age classes, the virtual
populations) is unchanged.

The reason is roadrunner: without the cache of the initial value resolvers
(sys-bio/roadrunner#1435) every initial assignment is inlined into each of its
readers, so the curves of the age are repeated through the chain of the
physiology. On the toy model of the tests this grows the model from 60,764 to
822,089 expanded nodes and its compile from 7.2 to 65.6 s at LLJIT O0 with
libroadrunner 2.11.0 (4.9 s with the cache), which no continuous integration
can carry. The tests which build a model over age carry the marker `over_age`,
which skips them below the first roadrunner with the cache; the equations of
the documentation are checked on toys over age, which build SBML only.

The stored reference SBML and the pinned counts are those of the default
build again.
matthiaskoenig added a commit to matthiaskoenig/pkpdsim that referenced this pull request Nov 6, 2026
…regnancy (#133, #137, #138, #131, #134) (#142)

* The physiology over age and sex, and virtual populations (#133, #137)

The physiology of the PBPK library is a function of the age from the newborn
to 100 years (#133): the reference children of ICRP 89 and the aging of the
adults (`pbpk/age.py`, curves written once as numbers and as SBML formulas in
`pbpk/curves.py`), the maturation of the GFR, the hematocrit, the albumin and
AGP over age (NHANES 2013-2018), the ontogeny of the enzymes and transporters,
the flow fractions over age, age classes, and virtual populations drawn from
the anthropometry of each sex over age (`pbpk/virtual.py`, NHANES population
files).

The female build gets the differences of the sexes beyond the reference
anatomy of ICRP 89 (#137, `pbpk/sex_differences.py`,
`database/tables/sex_human.toml`), factors relative to a man of the same age,
body weight and height, 1 in the children: the cardiac output (the same
cardiac index of the sexes, 0.914), the brain fraction of the cardiac output,
the albumin (the NHANES ratio), the transit of the stomach in the fed state
and of the large intestine, and CYP3A4, CYP1A2, UGT2B17, UGT2B15 and CYP2A6 of
the liver; the male build is unchanged. The evidence and the quantities
without a difference are documented in docs/pbpk/physiology.md (Sex).

The physiology over age needs the cache of the initial value resolvers of
roadrunner (sys-bio/roadrunner#1435), without which a model takes minutes to
compile.

* The code of the populations page in the format of ruff

* The factor of the sex on the cardiac output computed from the physiology at every age

* The physiologies of the rat, the mouse and the pig: PBPKDefinition.species (#138)

* Circadian rhythms of the physiology: the clock time and the factors over the day and the night (#131)

* The pregnancy: the maternal physiology over the gestational age and the fetal exposure (#134)

* The physiology over age is a choice of the build (#133)

`PBPKDefinition(age=True)` makes every reference value and factor a curve of
the covariate `AGE`; without it (the default) the model is the reference
individual of 35 years, the factors of the age are the constant 1 and the
model has no `AGE`, so a change of it fails instead of being ignored. The
factors of the sex are the constants of the adult in such a build. The python
side (`reference_individual(sex, age)`, the age classes, the virtual
populations) is unchanged.

The reason is roadrunner: without the cache of the initial value resolvers
(sys-bio/roadrunner#1435) every initial assignment is inlined into each of its
readers, so the curves of the age are repeated through the chain of the
physiology. On the toy model of the tests this grows the model from 60,764 to
822,089 expanded nodes and its compile from 7.2 to 65.6 s at LLJIT O0 with
libroadrunner 2.11.0 (4.9 s with the cache), which no continuous integration
can carry. The tests which build a model over age carry the marker `over_age`,
which skips them below the first roadrunner with the cache; the equations of
the documentation are checked on toys over age, which build SBML only.

The stored reference SBML and the pinned counts are those of the default
build again.

* Two guards of the tests which are at their boundary, not at their purpose

`test_load_time` guards the physiology as assignment rules, which took 28.8 s
to compile where the initial assignments took 2 s. Its limit of 15 s was the
10.6 s of CPU of a windows runner when it was written; the same model takes
15.1 to 15.7 s on the windows and macos runners of today and 20 s on a
developer machine, so the test failed on a model which differs in no byte
from the one of `develop`. The limit is 30 s, twice the slowest measurement
and far below the factor 14 it guards against.

The absorbed amount of an endogenous substance after an equilibration grows
by 4.6e-18 mmole over 60 min and 3.7e-16 over 6000 min: the difference of two
nearly equal fluxes, whose sign is that of the platform (-1.4e-17 on a macos
runner). The test asserts that the readout starts at exactly 0 and stays
there within that noise; that it grows at all is the assertion of the
simulation without the equilibration, over 1e5 min.

This branch has not been deployed

No deployments
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.

Load time grows exponentially with the depth of chains of initial assignments

1 participant