Code-generated ODE RHS¶
Code-generated ODE RHS¶
For ODE simulations, BNGsim can compile model rate laws into native C code,
which is then loaded via dlopen(). This eliminates ExprTk bytecode
interpretation overhead for large models. The public API has two codegen
routes:
BioNetGen .net models use the .net codegen path. If the model was
loaded with Model.from_net(...), BNGsim already remembers the .net path:
net_model = bngsim.Model.from_net("model.net")
sim = bngsim.Simulator(
net_model,
method="ode",
codegen=True,
)
result = sim.run(t_span=(0, 100), n_points=101)
You may still pass net_path="model.net" explicitly for .net models, but
net_path means exactly this: a BioNetGen .net file.
SBML and Antimony models use model-based codegen. Do not pass the SBML
XML file as net_path; just enable codegen on the loaded model:
sbml_model = bngsim.Model.from_sbml("model.xml")
sim = bngsim.Simulator(
sbml_model,
method="ode",
codegen=True,
)
result = sim.run(t_span=(0, 100), n_points=101)
Passing an SBML XML file as net_path is rejected because the .net parser
cannot interpret SBML. This prevents accidental compilation of an empty RHS.
Codegen is enabled by default in PyBNF’s BngsimModel (set
BNGSIM_NO_CODEGEN=1 to disable). Compiled .so files are cached in
~/.cache/bngsim/codegen/ by SHA-256 hash — recompilation only happens when
the .net content or model-generated code changes. Compilation builds to a
process-unique temp file and os.replace()s it into the cache, so concurrent
Dask workers compiling the same model never observe a partial .so. Set
BNGSIM_CODEGEN_CACHE_DIR to relocate the cache — point it at fast node-local
scratch, or at a directory of artifacts pre-warmed on a login node so worker
jobs never compile (see Scheduler-free cluster evaluation).
HPC / cluster note. The codegen path shells out to a C compiler (
cc) atSimulatorconstruction. On many HPC systems compute nodes have no compiler onPATHby default even when the login node does — codegen then stalls or fails on the compute node despite working interactively. Ensure a compiler is available inside the batch/allocation environment (e.g.module load gcc) before running, or disable codegen withBNGSIM_NO_CODEGEN=1.
A few env vars tune the compile step for large reaction networks, whose flat RHS source can be several MB:
Variable |
Default |
Effect |
|---|---|---|
|
|
Directory for the content-addressed compiled-artifact cache. Redirect it to node-local scratch, or to a read-only dir of pre-warmed artifacts, so cluster jobs reuse one |
|
|
Seconds before the |
|
size-based |
Optimization level: an integer |
|
|
Reaction count at/above which the RHS (and analytical sensitivity) body is split into many small |
|
|
Reactions per chunked helper function. Smaller blocks compile a little faster at a slight call-overhead cost. |
|
allocation-aware |
Parallel compiler processes for a chunked source — see below. |
|
|
Estimated peak RAM per parallel compiler, in MB. The job count is capped at |
Large-model chunking. A flat code-generated RHS over N reactions is one
enormous basic block, and the C optimizer’s per-function passes are superlinear
in function size — so without chunking a ~100k-reaction model can take hours to
compile (a synthetic mass-action RHS scales ≈ O(N^2.5) at -O1). At/above
BNGSIM_CODEGEN_CHUNK reactions BNGsim splits the body into small noinline
blocks, which keeps compile time roughly linear and lets the source compile at
-O2 at any size (≈ minutes for 100k reactions). The split preserves reaction
order, so the chunked .so is bit-identical to the flat one; below the
threshold the emitted C is byte-identical to prior versions.
Chunking covers not just the RHS but the whole code-generated translation unit:
the analytical Jacobian’s per-reaction scatter, the output evaluator, and the
observable/function computation each of them recomputes are all split into
noinline blocks too. Otherwise these would pile up in the single non-sharded
driver function and become the serial compile wall at genome scale even after
the RHS itself was chunked — a 113k-reaction / 18k-function model’s driver was
~38 MB of C and timed out the compile budget; sharding it shrinks the driver
~10× so no single translation unit dominates.
Parallel shard compilation. A chunked source is still one .c file compiled
by a single serial cc, which dominates Simulator construction at genome
scale (a 113k-reaction model spent ~52 min almost entirely in that one compile).
Because the noinline blocks are already independent functions, BNGsim compiles
them as separate translation units in parallel (cc -c × N) and links the
.o files into the .so — the classic make -j. Wall-clock drops to roughly
(slowest unit + link) wherever multiple cores are available (the same
113k-reaction model with the compiled sparse Jacobian now builds in well under a
minute on 16 cores).
The pool is allocation-aware: it is sized from the CPUs the process may
actually run on (os.sched_getaffinity / SLURM_CPUS_PER_TASK), never
os.cpu_count(), so a job confined to a small slice of a shared node does not
spawn one compiler per node core. It is also memory-bounded (see
BNGSIM_CODEGEN_MEM_PER_JOB) so N parallel compilers cannot OOM the allocation.
The source partition is independent of the job count, so the linked .so is
byte-identical regardless of how many compilers run, and the SHA-256 codegen
cache keys on source content only — parallelism never changes the cached
artifact. A 1-core allocation (or BNGSIM_CODEGEN_JOBS=1) takes the unchanged
serial path, so there is no regression where there are no spare cores.
The speed-up requires cores. On a laptop every core is available, so this is automatic. On HPC it engages only if the batch allocation requests them — with
--cpus-per-task=1there is nothing to parallelize across. Request several CPUs per task (and enough memory for them) to unlock it.
Example: Slurm batch script for a large-model fit¶
Request multiple CPUs per task so codegen shards across them, and make sure a
compiler is on PATH inside the allocation (compute nodes often lack one by
default). Memory should cover the parallel compilers — --mem ÷
BNGSIM_CODEGEN_MEM_PER_JOB is roughly the most compilers that will run.
#!/usr/bin/env bash
#SBATCH --job-name=bngsim-codegen
#SBATCH --nodes=1
#SBATCH --ntasks=1
#SBATCH --cpus-per-task=16 # ← codegen shards across these 16 cores
#SBATCH --mem=32G # ≥ cpus-per-task × per-compiler peak RAM
#SBATCH --time=02:00:00
# A C compiler must be reachable on the COMPUTE node (not just the login node).
module load gcc # site-specific; provides `cc`/`gcc` on PATH
# Size the codegen pool from the Slurm allocation. `auto` already reads
# SLURM_CPUS_PER_TASK / the cgroup cpuset; setting it explicitly is equivalent
# and self-documenting.
export BNGSIM_CODEGEN_JOBS="${SLURM_CPUS_PER_TASK:-auto}"
# Optional: tune the per-compiler RAM estimate that bounds the pool. With
# --mem=32G and 2 GB/compiler the pool is capped near 16 (= cpus-per-task here).
export BNGSIM_CODEGEN_MEM_PER_JOB=2048 # MB
# Keep BLAS/OpenMP from oversubscribing the same cores at run time.
export OMP_NUM_THREADS="${SLURM_CPUS_PER_TASK:-1}"
srun python fit_large_model.py # builds Simulator(..., codegen=True) inside
With --cpus-per-task=1 the same script still runs correctly — codegen just
falls back to the serial compile (no speed-up, no error). Bump --cpus-per-task
(and --mem to match) to shard the compile across more cores.