Skip to content

solver_stats: count the RHS evaluations a difference-quotient Jacobian costs (#1333) - #1403

Merged
wshlavacek merged 4 commits into
lanl:mainfrom
Montana:perf/1333-fd-step-and-counts
Nov 1, 2026
Merged

wshlavacek merged 4 commits into
lanl:mainfrom
Montana:perf/1333-fd-step-and-counts

Conversation

@Montana

@Montana Montana commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Addresses #1333, its second part: counting what #1302 costs. The first part, the step of the steady-state solvers' own quotients (Step 4), is a corpus measurement this PR does not make.

What was missing

solver_stats["n_rhs_evals"] is CVODE's count of the integration's own right-hand-side evaluations. Where the Jacobian is formed by difference quotients (jacobian="fd", or a closed form declined), bngsim forms it itself, through #1302's dense quotient and #1254's colored one. Those evaluations went to no counter. #1302's jump test added one to three per tested column, and the nightly's deterministic work counters could not see any of it.

The change

  • n_jac_rhs_evals counts the right-hand-side evaluations spent forming Jacobians:

    • the dense quotient's columns;
    • its jump test's side points (dq_side_point);
    • the colored quotient's evaluations, and its base where the run is compiled;
    • CVODE's own quotient's, where it forms one (CVodeGetNumLinRhsEvals, banked across restarts with the other segment counters).

    A counter on Impl, reset with closed_segments on both run paths, is reached through CvodeUserData::jac_rhs_evals. The count is 0 where every Jacobian is closed-form.

  • n_jac_formations (CVodeGetNumJacEvals) is the number of Jacobians actually formed. n_jac_evals counts the linear solver's setups, and a setup can reuse the last Jacobian, so the existing counter could not say what a formation costs.

  • The nightly (verdicts.py, _core/work.py, kept in lockstep) carries n_jac_rhs_evals and alerts on it (floor +50). A baseline that predates it has no value to compare, so it is skipped until a baseline is re-recorded.

  • The docs: Result.solver_stats and solvers.md say what each count is.

Measured

jacobian="fd", codegen=False, t_span=(0, 100), the ODE perf suite #1302 measured:

model species n_rhs_evals n_jac_rhs_evals ratio per formation
Repressilator 15 114 60 0.53 30
erk_activation 34 347 307 0.88 61
tcr_signaling 37 435 509 1.17 73
egfr_net_red 40 595 795 1.34 80
prion_aggregation 104 1,334 3,264 2.45 181
SHP2_base_model 149 178 750 4.21 250
fceri_ji 354 286 3,486 12.19 697
egfr_net 356 481 5,484 11.40 686

About two evaluations per species per Jacobian formed: its column, and #1302's point past it. On the large models, the right-hand-side work of an FD run was understated by up to 13×.

A traced 10-species chain checks the count exactly: 5 Jacobians formed at 15, 18, 20, 20 and 20 evaluations, which sum to the 93 reported.

Tests (test_jac_rhs_evals.py, 7)

  • a closed-form Jacobian costs none;
  • the dense quotient: more than n_species per formation (its columns, then the jump test), and at most four times that;
  • the colored quotient (KLU) is counted;
  • the count spans a run its events restart;
  • a second run on one Simulator counts its own, on the warm path and on the cold one;
  • the nightly carries and compares it.

Mutations (C++, each rebuilt)

mutant fails
M1: the dense quotient's columns not counted the dense test
M2: its jump test's side points not counted the dense test
M3: the colored quotient not counted the colored test
M4a / M4b: no reset on the warm / cold run path the second-run test, on that path
M5: the count not reported 4
M6: formations not banked across restarts 2

Checked

  • Full suite: 16,845 passed, 139 skipped. parity_checks/tests: 958 passed and 1 failed, test_bngsim_provenance, which fails the same way without this change. pre-commit run --all-files and sphinx -W are clean.

Not in this PR

Montana and others added 4 commits October 29, 2026 20:21
…n costs (lanl#1333)

n_rhs_evals is CVODE's count of the integration's own evaluations, so the
ones bngsim's dense and colored quotients spend forming a Jacobian, lanl#1302's
jump test included, showed in no counter. n_jac_rhs_evals counts them, and
CVODE's own quotient's; n_jac_formations is the number of Jacobians formed,
beside n_jac_evals, which counts setups that can reuse one. The nightly
carries and compares n_jac_rhs_evals. On jacobian="fd" runs of the ODE perf
suite they are 0.5-12x n_rhs_evals.
lanl#1333)

CVODE zeroes most of its counters at CVodeReInit, but the linear solver's
(CVodeGetNumJacEvals, CVodeGetNumLinRhsEvals) only at the first CVode call
after it (cvLsInitialize, from cvInitialSetup). A segment that a re-init
opens and nothing steps in still holds the segment before, which
reinit_cvode had already banked, so record_solver_stats read it twice.

The 10-species chain of test_jac_rhs_evals.py with
`E1: at (time > 10): X0 = 10`, run over (0, 10) with jacobian="fd", forms
4 dense quotients (20, 15, 18 and 20 evaluations, traced in the callback)
and reported n_jac_formations = 8: 9.1 evaluations a formation for a
quotient that takes a column per species, 10, and lanl#1302's test past them.
Over (0, 20) the event's restart has steps after it and the two agree.

A segment with no f call yet has had no CVode call, so its linear-solver
counts are taken as 0. n_jac_evals, the linear-solver setups, is cv_nsetups
and CVodeReInit zeroes it; its comment in result.hpp said "Jacobian
evaluations", which n_jac_formations now is.

The tests: the dense quotient's evaluations per formation are held in
(n, 4n] on the restarting run and on the one re-initialized at its end
(fails without this: 9.125); the colored count must exceed the formations,
since a compiled run's base evaluation alone made it equal to them.
…n step across restarts (lanl#1333)

Found by a blind review of the branch.

CVODE counts a Jacobian (nje) when it sets jcur and calls the Jacobian
function, before it checks the return (cvodes_ls.c cvLsLinSys, cvLsSetup),
so n_jac_formations counts the Jacobians it asked for, one a failed f call
cut short included, not the ones "actually formed". The docs, the
docstring, result.hpp and the changelog say so now.

CVODE's own quotient never runs: every path installs a callback of
bngsim's before the linear solver is set (setup_linsol_and_jac), so
CVodeGetNumLinRhsEvals is 0. The user-facing text no longer lists it as a
source of n_jac_rhs_evals; the code still reads it, for a path that one
day leaves CVODE to its own quotient.

The per-formation bound was 4n, and the restarting run reads 38 a
formation for n = 10 with the closed segments' formations dropped from
SegmentCounters::operator+=, so that mutant passed it. The chain's law is
smooth, so the jump test takes one point past each column and no more:
3n bounds it, and the mutant now fails four tests.
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