Repository navigation
perf(batch-ssa): compile the model once for the whole CPU pool - #58
Merged
Merged
Conversation
added 2 commits
September 30, 2026 22:53
Measurement instrument for CpuBatchSsaSimulator (cpp/engine/BatchSsa.cpp), added before any change to it and replayed onto origin/main (1c78a72). The pool is reachable in production only through the Python binding simulate_batch_ssa_cpu, so there is no CLI entry point to drive it; this driver links the same engine library and calls the pool directly. - --mode bench: timed repetitions, one CSV row each (trajectories/sec, events/sec) - --mode dump: one run writing every output array as raw little-endian bytes, so two binaries can be compared with cmp for BIT identity of final states and of the observable mean/std trajectories - --mode fanout: spawns N no-op std::async tasks and joins them, to price the pool's per-batch fan-out independently of any simulation - parse_dump.py: per-chunk event balance from a dump, a load-independent measure of how evenly the static partitioning balances work - run_under_test.py: drops the editable-install meta_path finder and asserts the extension resolved under bionetgen._bionetgen_cpp, so a pytest run cannot silently test another worktree's tree build_driver.sh reuses CMake's own flags.make/link.txt for bng_cpp so that cpp/CMakeLists.txt is not edited. run_ab.sh alternates two binaries within one session and reports min+spread.
simulateMultiCore built one OdeIntegrator per worker plus one on the calling thread, so every batch paid numThreads+1 full model compiles (16 on a 15-CPU host) and held numThreads copies of the compiled reaction network alive at once. Sharing a single integrator removes both. Safe because the SSA path is re-entrant on this class: integrate() dispatches to integrateSSA(), which reads only the compiled reaction/group tables and writes exclusively to its own locals and the OdeResult it returns. Every helper it reaches (outputTimes, parseStopIf, updateGroups, stopConditionMet, computePropensity) is const and takes its output through an out-parameter. The one mutable member, groupValues_, is derivs() scratch, which the SSA path never calls. Measured as instructions retired (deterministic, /usr/bin/time -l), 3 reps per process, 15 threads, batch <= 20000: egfr_net batch=60 202.76e9 -> 129.01e9 -36.4% egfr_net batch=500 1048.45e9 -> 974.66e9 -7.0% gene_expr_simple batch=20000 2.713e9 -> 2.596e9 -4.3% isomerization batch=20000 3.983e9 -> 3.914e9 -2.0% Within-arm spread <= 0.11%, so the arms separate far outside it. Peak RSS on egfr_net batch=500 falls from 57-68 MB to 26-29 MB, because 15 compiled networks are no longer resident at once. Bit-identity against the pre-change binary: cmp of full output dumps EMPTY on seven configurations (isomerization b20000/15t, gene_expr_simple b20000/15t and b5000/8t, toy-jim b5000/15t, egfr_net b500/15t, performance_test_models/ egfr_net b400/15t, egfr_net b800/1t), covering final species, final observables, per-trajectory event counts, and the float observable mean/std trajectories. Gates: ctest -j4 -> 100% tests passed out of 461. tests/python -> 862 passed, 28 skipped, run through benchmarks/batch_ssa/run_under_test.py so the editable-install meta_path finder cannot silently substitute another tree. tests/test_batch_ssa_statistical_parity.py -> ALL STATISTICAL PARITY TESTS PASSED SUCCESSFULLY, including the Metal GPU arm, on macOS. No changes to cpp/engine/OdeIntegrator.* or cpp/bindings/. Verification tokens for a merge that touches this file: grep -c 'Independent OdeIntegrator per thread' cpp/engine/BatchSsa.cpp -> 0 grep -c 'OdeIntegrator integrator(model_, network_)' cpp/engine/BatchSsa.cpp -> 2
akutuva21
force-pushed
the
agent/perf-batch
branch
from
October 1, 2026 02:53
880bd93 to
e37bcf0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
perf(batch-ssa): compile the model once for the whole CPU pool
CppBatchSsaSimulator::simulateMultiCoreincpp/engine/BatchSsa.cppbuilt oneOdeIntegratorper worker plus one on the calling thread. Every batch call paidnumThreads + 1full model compiles (16 on a 15-CPU host) and heldnumThreadscopies of the compiled reaction network alive simultaneously. This change shares a
single integrator across the pool:
numThreads + 1compiles becomes1.Scope: one file, 18 insertions, 8 deletions.
cpp/engine/BatchSsa.cpponly.No change to
cpp/engine/OdeIntegrator.*(three operators plus perfEngine holdregions of it) and none to
cpp/bindings/(perfTooling's lane).Why it is safe
The previous code carried the comment
// Independent OdeIntegrator per thread for thread safety. Sharing one is sound because the SSA path is re-entrant onOdeIntegrator:integrate()dispatchesmethod == "ssa"tointegrateSSA()(
cpp/engine/OdeIntegrator.cpp:1541).integrateSSA()(cpp/engine/OdeIntegrator.cpp:2360) reads only the compiledreaction/group tables and writes exclusively to its own locals and the
OdeResultit returns.constand returns its output through anout-parameter:
outputTimes(:1351),parseStopIf(:1391),updateGroups(:1340),stopConditionMet(:1404),computePropensity(
:2320).mutablemember,groupValues_, is scratch forderivs()(
cpp/engine/OdeIntegrator.hpp:119), which the SSA path never calls.memoryUsageBytesis corrected to match: it previously reportednumThreads * sizeof(OdeIntegrator).Measured — deterministic metric (EXEMPT CLASS)
Instructions retired, from
/usr/bin/time -l. This is a counter, not aduration: it cannot move when nothing changed. 3 batch reps per process, 2-3
process runs per arm, 15 threads,
batch_size <= 20000.models/egfr_net.bnglbatch=60, t_end=0.02models/egfr_net.bnglbatch=500, t_end=0.02models/gene_expr_simple.bnglbatch=20000, t_end=2000models/isomerization.bnglbatch=20000, t_end=20Within-arm spread across process runs: 0.11% or less on every row. The arms
separate far outside that.
Peak RSS,
/usr/bin/time -l,egfr_netbatch=500:before 60,617,280 / 63,832,064 / 68,173,824 B; after 27,901,952 / 29,442,048 /
28,408,656 B — roughly halved, because 15 compiled networks are no longer
resident at once.
The effect scales as expected: it is a fixed per-batch cost, so it is largest
when the batch is small relative to the compile, and vanishes as the batch grows.
Measured — wall clock (NOT EXEMPT, reported with full context)
Interleaved A/B in one session, 12 alternations x 2 reps per arm = 24 samples
per arm, via
/tmp/bng-bench-lock/benchlock(slot 0, held).calibration of loadavg as non-responsive on this host).
ps -axo pid,etime,command, including my own):perfOracle
verify/identity_check.py+ itsbng_cpp_changed model.bnglchildren; perfNetGen
bench/netgen_bench.py --full; twopytest tests/validation; shared-mainbng_cpp isingspin_localfcn.bngl;correctness
bng_cpp hybrid_test.bngl; swarmCache/tmp/cachebench/bng_cpp_base big_1000.bngl; perfNetGenbuild/cpp/bng_cpp .../e6_gen.bngl; perfEngineheld benchlock slot 0 for the NFsim baseline.
egfr_netbatch=200, 15 threadsisomerizationbatch=20000, 15 threadsI am claiming the wall-clock result as CORROBORATION, not as the result. With
45-138% within-arm spread the minima are the only usable statistic and they
overlap the other arm's range. The instruction counts are the number to rely on:
they are deterministic and reproduce to 0.11%.
Thread scaling,
isomerizationbatch=20000,sim_msminimum over 4 reps:1 thread 115.5 ms, 2 threads 64.0, 4 threads 35.2, 8 threads 23.7, 15 threads
19.4. Efficiency collapses past 4 threads (90% / 82% / 61% / 40%). This is not
load imbalance in the partition — see candidate D below — and it is not fan-out
(candidate B). It is host contention plus allocator pressure inside
integrateSSA, which is not my file.Candidate population, including the losers
OdeIntegratoracross all workersstd::asyncper batchstd::asynctasks + join (--mode fanout, 8 reps). That is 0.4% of a 24 ms batch. Nothing to amortise.std::thread+ condvar instead ofstd::asyncstd::asyncwithlaunch::asyncalready costs one thread per task; candidate B's measurement covers it.benchmarks/batch_ssa/parse_dump.pyon the per-trajectory event counts: heaviest chunk / mean chunk = 1.0047x, so static partitioning costs 0.47% onisomerizationbatch=20000/15 threads. Separately, the observable means are summed per worker and reduced in worker order, so ANY change to the partition count or boundaries changes the floating-point summation order and breaks the bit-identity gate. Load balancing and bit-identity are mutually exclusive here; 0.47% is not worth trading it for.trajectoryEventCounts/finalSpeciesbranges, so only the 14 chunk-boundary cache lines ofbatchSize/16lines are ever shared (14 of 1250 at batch=20000).Profiled bottleneck (
sample <pid> 6, macOS, top-of-stack), which is why thepool layer was the right place to look at all — and where the ceiling is:
egfr_netbatch=500:integrateSSA50,644 of ~42,000 aggregatedtop-of-stack samples dominates; main thread blocked in
future::getis6,140;
OdeIntegrator::OdeIntegrator(the per-worker compile) is 85 samples,1.95% of main-thread samples.
isomerizationbatch=20000:integrateSSA20,419; allocator(
_xzm_free+_xzm_xzone_malloc*+operator new) ~4,699; the pool lambdabody outside
integrateSSA484.Where the remaining time goes, and why it is not mine — reported, not edited:
integrateSSAbuilds and discards, for every one of the 20,000 trajectories, afull
numPoints x numSpeciesconcentration history(
appendScheduledThroughdoesresult.concentrations.push_back(y)atcpp/engine/OdeIntegrator.cpp:2393and:2401) plusoutputTimes()(
:2362) andparseStopIf()(:2363) per call.CpuBatchSsaSimulatorreadsonly
res.concentrations.back()andres.observables. An option that suppressesthe history would be the next large win, and it lives in
cpp/engine/OdeIntegrator.*, which is perfEngine's / swarmCrossover's /swarmCache's / swarmSerial's. Flagging it rather than touching it.
Correctness gate
Bit-identity against the pre-change binary — two binaries, separate processes,
same
base_seed,--mode dumpwrites every output array as raw little-endianbytes, compared with
cmp:Each dump covers
finalSpecies(int32),finalObservables(float),trajectoryEventCounts(uint32),observableMeans/observableStdDevs(float),and
meanSpecies/stdSpecies/meanObservables/stdObservables(double) —so final states AND observable means/stds are both proven bit-identical, at 15
threads, 8 threads, and 1 thread.
Gates, all from this worktree's own build (
Release -j4,BUILD_TESTS=ON,bindings ON):
The parity gate covers conservation identities, seed reproducibility, and
threads=0vsthreads=1equality; on this Mac it also ran the Metal GPU arm.My evidence is macOS-local only — the parity gate's CPU reference arm is
hardware-independent, but I have no Linux run, so I say nothing about the
cpp/CMakeLists.txtextension-linking change in flight (PR #30).benchmarks/batch_ssa/run_under_test.pyexists becausebionetgenis installedhere as a scikit-build editable whose meta_path finder beats
sys.path: withoutit,
pytest tests/pythonin this worktree silently tests the shared maintree. The two
UNDER TESTlines above are the proof of which tree ran.Build numbers,
/usr/bin/time -l: configure 302 MB / 23.3 s; fullRelease
-j4build (bindings ON, tests ON) 471 MB / 157.2 s; incremental relinkof
BatchSsa.cpp463 MB / 23.5 s. All far under the 4 GiB cap. Every batch runabove is
batch_size <= 20000; peak RSS 634 MB is the largest single figure andbelongs to
ctest, not to a batch.Base and provenance
Base commit
6889fba. Branchagent/perf-batch, two commits: the benchmarkharness first (
5d2c038), then the change (f49406e) — the harness predates thechange so the baseline is reproducible from the branch itself.
Both A/B binaries were linked in this worktree from its own
build/tree, onebefore the edit and one after, via
benchmarks/batch_ssa/build_driver.sh, whichreuses CMake's own
flags.make/link.txtforbng_cpprather than adding atarget to
cpp/CMakeLists.txt(perfTooling's file).git stashwas never used. The baseline binary was produced by copying thebuilt binary aside, per Main's standing rule.
Reproducing
benchmarks/batch_ssa/bench_batch_ssa.cpp
benchmarks/batch_ssa/build_driver.sh
benchmarks/batch_ssa/parse_dump.py
benchmarks/batch_ssa/run_ab.sh
benchmarks/batch_ssa/run_under_test.py
cpp/engine/BatchSsa.cpp
e37bcf0 perf(batch-ssa): compile the model once for the whole CPU pool
f7b393d bench(batch-ssa): CPU pool throughput + bit-identity harness
$ grep -c 'Independent OdeIntegrator per thread' cpp/engine/BatchSsa.cpp
0 # pre-change value: 1
$ grep -c 'OdeIntegrator integrator(model_, network_)' cpp/engine/BatchSsa.cpp
2 # pre-change value: 1
IDENTICAL isomerization_20_20000_15 (481,481 B)
IDENTICAL gene_expr_simple_500_20000_15 (401,211 B)
IDENTICAL gene_expr_simple_2000_5000_8 (101,211 B)
IDENTICAL toy-jim_50_5000_15 (703,519 B)
IDENTICAL egfr_net_0.02_500_15 (754,492 B)
IDENTICAL performance_test_models/egfr_net_0.02_400_15 (601,926 B)
IDENTICAL egfr_net_0.02_800_1 (1,200,892 B)
$ ctest --test-dir build -j4
100% tests passed out of 473 [was 461 at 6889fba]
$ python3 benchmarks/batch_ssa/run_under_test.py tests/python -q
UNDER TEST pkg: /Users/akutuva/Documents/BioNetGen/BNG3-batch/python/bionetgen/init.py
UNDER TEST ext (submodule): /Users/akutuva/Documents/BioNetGen/BNG3-batch/build/cpp/_bionetgen_cpp.cpython-314-darwin.so
958 passed, 28 skipped, 1380 warnings in 23.10s [was 862 passed, 28 skipped at 6889fba]
$ python3 tests/test_batch_ssa_statistical_parity.py
ALL STATISTICAL PARITY TESTS PASSED SUCCESSFULLY!