Repository navigation
Prototype: exact Jacobian from Enzyme and the sparse KLU solver for CVODE (#1412, #1409) - #1418
matthiaskoenig wants to merge 12 commits into
Conversation
…ested with MCJit and LLJit Enzyme's activity and type analysis grow faster than linearly with the size of the differentiated function: 8.6 s for the 827 reactions of Chen_MSB2009 in one function. evalReactionRates is therefore differentiated in copies that each keep only the stores of a block of reaction rates (checked on the IR: the function writes only the reaction rates and never reads them), which cuts the Enzyme pass to a third. The copies are deleted after the pass. RR_ENZYME_BLOCK_SIZE, RR_ENZYME_OPTIONS and RR_ENZYME_DUMP_IR are knobs for experiments. Every Enzyme and CVODE test now runs with both JITs, and the load log measures the compilation up to the lookup of the functions, where LLJit compiles.
validate.py, benchmark.py and enzyme_jacobian_timing take the JIT from RR_LLVM_BACKEND and RR_LLJIT_OPT. LLJit keeps the compiled object of a model for the whole process (sys-bio#1420), so every load is measured in a fresh process and every backend in a process of its own (benchmark.py --backends, jit.csv). blocks.py measures the load time against the block size. The validation accepts entries at the rounding noise of the finite differences and reports the error only where the finite differences agree with each other.
Update: measured with MCJit and LLJit, reaction rates differentiated in blocksThe first version was measured only with MCJit. I have now measured everything with both JITs, which changed the picture of the load time. Two new commits:
The description above is updated as well. The Enzyme pass was the load cost, not the JITLLJit compiles the derivative code of Chen_MSB2009 (500 states, 827 reactions) in about 1 s, but the Enzyme pass itself took 8.6 s. Sampling with gdb put the time into Enzyme's activity and type analysis. That analysis grows faster than linearly with the size of the differentiated function:
Enzyme's command line options did not help.
The table shows Chen_MSB2009 for a range of block sizes, as medians of 3 loads in fresh processes:
64 reactions per block is now the default. Smaller blocks cost more in copying and per-call overhead than they save. The time of one Jacobian does not depend on the block size. Benchmark with both JITsThree configurations:
Settings and method:
What changed compared with the first version:
Validation with both JITsAll three configurations give the same result: the 4 benchmark models and 1417 SBML test suite cases pass, and none fail.
Two findings about the JITs, opened as issues
|
|
@luciansmith This looks super promising! I see speedups in integration time with Enzyme Jacobian and KLU throughout all models with ~4-5x speedup in integration for larger models (~500 states) with speedup increasing with model size. Our models will have more states 2000 - 50000 so I expect major speedups 10x - 20x here. When Jitting with LLJit the times are reasonable even for the larger models with a few seconds overhead for calculation of the Jacobian once (which can be cached). Also this should not be too complicated to implement (see the prototype in this pull request). This can be an alpha/beta feature which can be activated on demand (with default being no Jacobian and dense matrices). |
This draft is a working prototype of option 3 of #1412: CVODE gets an exact Jacobian, which Enzyme computes from roadrunner's generated LLVM IR, and the sparse direct solver KLU (#1409) uses it. It also partly covers #1411: the CVODE counters of a simulation are available from Python.
It is a draft for discussion, not for merging as it is. Everything is opt in, behind two CMake options that are off by default and one load option that is off by default. The questions it answers:
develop.Everything was measured with both JITs. Measuring with LLJit showed that most of the load time went into the Enzyme pass, not into compiling the derivative code. Differentiating the reaction rates in blocks of 64 reactions cuts the pass to a third; Chen_MSB2009 took 38 s to load in the first MCJit version. The full write-up, with the tables, is
prototype/enzyme_jacobian/RESULTS.md.Background
Why a Jacobian. CVODE's stiff solver (BDF with Newton iterations) needs the Jacobian
J = ∂f/∂yof the right-hand side. roadrunner does not provide one, so CVODE estimates it with a dense difference quotient (cvLsDenseDQJac). That costs one evaluation of the model per state, and it has a finite difference error. In the profiles of #1409,cvLsDenseDQJactakes 10 to 15 % of the instructions of a simulation. More importantly, every sparse linear solver in SUNDIALS needs a sparse Jacobian from the user, so without a Jacobian from roadrunner there is no KLU. The Jacobian would also servegetFullJacobian, the steady-state solvers and forward sensitivities (see #1412).Why Enzyme. #1412 lists the options: symbolic derivatives of the SBML math with libsbml, generated derivative code, or automatic differentiation. Enzyme is an LLVM plugin that differentiates LLVM IR at compile time. roadrunner already turns the model into LLVM IR, so Enzyme can differentiate exactly the code CVODE evaluates:
evalReactionRatesandevalRateRuleRatesare differentiated as they are.Enzyme's forward mode (
__enzyme_fwddiff) computes a Jacobian-vector productJ·vin about the cost of one evaluation of the model. With column coloring, one such pass gives a whole group of columns. Here that is 2 to 7 passes per Jacobian instead of n model evaluations.The go/no-go cutoff we agreed on for the prototype: it was viable only if Enzyme ran against roadrunner's prebuilt LLVM without rebuilding LLVM. It does. The tagged Enzyme release
v0.0.303works against roadrunner's LLVM 23.1.4 (static, RTTI, no clang) without patches, built as the static librarylibEnzymeStatic-23.aand run in the new pass manager inside roadrunner's JIT.How it works
Build.
-DRR_USE_ENZYME=ON -DRR_ENZYME_LIBRARY=.../libEnzymeStatic-23.alinks Enzyme.-DRR_USE_KLU=ON -DRR_SUITESPARSE_PREFIX=...links KLU from a SuiteSparse install and needs SUNDIALS built with KLU. Both options are off by default.Load.
Config.LLVM_ENZYME_JACOBIAN(load optionENZYME_JACOBIAN) turns the feature on for a model. After code generation,EnzymeJacobianCodeGen::prepare:JacobianSparsity). It walks the ASTs through assignment rules, function definitions and all branches of piecewise, and handles local parameters, compartments of species and rate rules on concentration species;evalReactionRatesTangentsandevalRateRuleRatesTangents, which call__enzyme_fwddiffon the generated functions with a shadowLLVMModelData;evalReactionRatesinto blocks of 64 reactions. Each block is a copy that keeps only the stores of its reaction rates, with the code they no longer need removed; Enzyme differentiates each copy, and the copies are deleted after the pass. The split requires that the function writes only the reaction rates and never reads them back, which holds because a reaction id in the math is computed again from its kinetic law. This is checked on the IR, and otherwise the function is differentiated whole;The model cache key includes the option.
Fallback. If a model cannot be differentiated, the model loads without the Enzyme Jacobian, gives the reason through
getStateVectorJacobianUnavailableReason(), and CVODE keeps the difference quotient. Enzyme reports failures as LLVM error diagnostics, which terminate the process by default. A customDiagnosticHandlerturns them into a fallback instead.Evaluation.
LLVMExecutableModel::getStateVectorJacobian(t, y, values):dv/dyandd(rate rules)/dy;J = cf · N · dv/dy, plus the rows of the rate rules, directly in CSC order.CVODE. Two new integrator settings,
jacobian("difference_quotient"by default, or"enzyme") andlinear_solver("dense"by default, or"klu").CVodeSetJacFngets one callback, which fills the dense matrix or copies the pattern and values into aSUNSparseMatrixforSUNLinSol_KLU. If a combination cannot be served (a model without the Jacobian, or a build without KLU), CVODE falls back to dense with the difference quotient and logs a warning. This also coversloadState, copies and conserved moiety analysis. The one error isjacobian = "enzyme"on a model loaded without the option.Generated helpers that Enzyme cannot see into (
rr_min,rr_max,rr_logd,rr_rootd,arccot,rr_arccot_negzero) get IR bodies in the module.API
C++:
ExecutableModel::hasStateVectorJacobian(),getStateVectorJacobianUnavailableReason(),getStateVectorJacobianPattern()(rr::CscPattern) andgetStateVectorJacobian(time, y, values), plusCVODEIntegrator::getSolverStatistics(). The state vector is the one CVODE integrates: the rate rule values, then the amounts of the independent floating species.getSolverStatistics()reports the counters of #1411 under shorter keys and without the totals over reinitializations that #1411 asks for. It was needed to measure this prototype, and it is not a proposal for the final names.JIT backends
roadrunner has two JITs, and I measured three configurations:
mcjit;lljit-none, LLJit as it is configured today; andlljit-aggressive, the codegen level #1215 apparently meant. Three findings about the JITs shaped the measurements:OPTIMIZE_*passes, so with LLJit the codegen level is the only optimization of the generated code, Enzyme's included.LOADSBMLOPTIONS_RECOMPILEand of the optimization level (LLJit reuses the compiled object of a model in a process despite LOADSBMLOPTIONS_RECOMPILE and a changed optimization level #1420). The benchmark therefore loads every model in a fresh process and runs every backend in a process of its own.Results
Four models:
hctz_bodyandglimepiride_body: whole-body PBPK models, the flat SBML of hctz-model and glimepiride-model.Smith_BMCSystBiol2013andChen_MSB2009: from the PEtab benchmark collection, at their nominal parameters.Settings:
rtol = atol = 1e-10,simulate(0, end, 201). Each backend runs in its own process, and the backends take turns for three rounds. Every time is the median over the rounds of 10 simulations per configuration (the configurations take turns) and 3 loads, each in a fresh process.The machine was shared with other jobs (load average 12 to 22 on 8 performance and 4 efficiency cores), and a process on an efficiency core runs 2 to 3 times slower. Single times therefore scatter, for example the
lljit-noneJacobian of hctz_body. The ratios within a backend are stable within about 10 %.What the numbers show:
I - γJ, and the gain grows with the size of the model, as the profiles in Sparse linear solver (KLU) and a sparse Jacobian for CVODE #1409 predicted.nonecompiles the derivative code of Chen_MSB2009 in about 1 s, while the Enzyme pass on its 827 reactions took 8.6 s. Sampling with gdb put the time into Enzyme's activity and type analysis, which grow faster than linearly with the size of the function (0.14 s for 31 reactions, 1.1 s for 367, 8.6 s for 827). In blocks of 64 reactions the pass takes 2.6 s. MCJit benefits too: its compilation of the smaller derivative functions drops from 10.7 s to 4.9 s.blocks.csvhas the sweep over block sizes. Enzyme's command line options did not help.nonemakes the derivative code slower (Chen_MSB2009: 64 µs per Jacobian against 52 to 53 µs), which the simulations hardly notice. Enzyme's vector mode (several colors per pass) barely speeds up the Jacobian but multiplies the compile time, so the default width is 1.Validation
min/max, with MCJit and with LLJit atnoneandaggressive:rateOfthroughcsr_matrix_get_nz,factorial,sec). 242 are skipped: roadrunner cannot simulate 208 of them (algebraic rules, delays, fast reactions), and 34 have no timecourse.aggressive), an entry that is zero by cancellation is 2e-16 in the Enzyme Jacobian and up to 9e-11 in the finite differences, because fused multiply-adds keep the rate from cancelling exactly. The validation accepts such an entry when the finite differences of different steps disagree with each other by more than they differ from the Enzyme value, and lists it.rtol = 1e-12,atol = 1e-14.EnzymeJacobianTests,JacobianSparsityTests,CvodeEnzymeJacobianTests); the 21 Enzyme and CVODE tests run with both JITs, 50 test runs in all. They cover the gate on a real model, the full Jacobian against finite differences, the helper derivatives, every fallback reason, setValue between Jacobians, the cache key, degenerate models, the independence of the Jacobian from the block size, and the CVODE settings in every order, across loads, copies, conserved moiety analysis, events and reset. The tests on the benchmark models skip whenRR_ENZYME_MODELSdoes not point to the model files.create()when a setting could not be served, so the next simulation crashed.Not covered, and open questions for upstream
Load time. A few seconds remain for a model of 800 reactions. Possible next steps:
Which way would you prefer?
Dependencies. Enzyme must match roadrunner's LLVM version exactly, so every LLVM update needs an Enzyme release that supports it. KLU needs SuiteSparse, and a SUNDIALS built with it, in libroadrunner-deps. Both would have to go into the dependency builds and the wheels for all platforms. The prototype has only run on Linux (Ubuntu 24.04, gcc 13.3).
Fallbacks that could be removed. These currently fall back to the difference quotient:
rateOf(csr_matrix_get_nzcould be marked inactive);factorial, thedistribfunctions and the reciprocal trigonometric helpers (IR bodies likerr_min/rr_max);saveState/loadState.Not used yet.
getFullJacobian,getReducedJacobian, NLEQ/KINSOL and forward sensitivities could use the same Jacobian.Not measured. A sparse difference quotient with the same coloring would also feed KLU, without Enzyme: one model evaluation per color, plus the finite difference error. It might be the cheaper route to most of the KLU speedup, if exactness turns out not to matter.
API.
jacobianandlinear_solverexist in every build, so they appear ingetSettings()and in saved states.Smaller points left for a real implementation. The helper bodies are not removed again when the Enzyme pass fails. There is no null check after looking up the tangent functions. The fallback is decided by comparing reason strings. The list of
LLVMModelDatafields that the shadow rebases has nostatic_assert.Issues found along the way
These are outside the prototype, and I opened them separately:
RoadRunnermethod (e.g.reset, in 18 test suite cases) replaces that method for every instance and removes it for the rest of the process.python_tests_RunStochasticTestSuitefinds the test suite only at fixed relative paths.ModelEditingTests/StateSavingTestsfail underctest -jbecause they share one output directory.LOADSBMLOPTIONS_RECOMPILEand a changed optimization level.Layout and how to reproduce
source/llvm/EnzymeJacobianCodeGen.*,EnzymeShadow.*andJacobianSparsity.*hold the code generation, the shadow model data and the sparsity. The Jacobian is evaluated inLLVMExecutableModel, and CVODE uses it inCVODEIntegrator.prototype/enzyme_jacobian/has the rest:BUILD.md: building Enzyme, SuiteSparse, SUNDIALS with KLU, and roadrunner;validate.py, with its output invalidation.md;benchmark.pyand the C++ timing tooljacobian_timing.cpp, with their output injit.csv,baseline.csvandwidth.csv;blocks.py, with its output inblocks.csv. They take the JIT fromRR_LLVM_BACKENDandRR_LLJIT_OPT;RESULTS.md.This directory would not go upstream as it is.
The commits follow the steps: build option, gate, sparsity, full Jacobian, CVODE, KLU, validation, benchmark, review fixes, blocks and the measurement with both JITs.
Builds.
BUILD_PYTHON=ON), all new tests pass with both JITs, and so do the other 3315 tests ofctest. Not counted are the tests that already fail ondevelop(python_tests_RunStochasticTestSuite finds the stochastic test suite only at fixed relative paths #1416, ModelEditingTests and StateSavingTests fail under ctest -j because they share one output directory #1417).Config.LLVM_ENZYME_JACOBIANfalls back with the reason "roadrunner was built without Enzyme".