Skip to content

Hybrid numba - #777

Closed
Ziaeemehr wants to merge 36 commits into
the-virtual-brain:hybrid-numbafrom
Ziaeemehr:hybrid-numba
Closed

Hybrid numba#777
Ziaeemehr wants to merge 36 commits into
the-virtual-brain:hybrid-numbafrom
Ziaeemehr:hybrid-numba

Conversation

@Ziaeemehr

@Ziaeemehr Ziaeemehr commented Apr 20, 2026

Copy link
Copy Markdown

Added two example for benchmarking and visualization of corresponding python and numba backends of multiple models.

=== Hybrid Simulator vs Numba Benchmark ===
TVB_USER_HOME = /tmp/tvb-user
dt            = 0.1 ms
nstep         = 1000

Chunk Size = 1 (Raw Output)
Case                           |  Python(s) |  Compile(s) |   Numba(s) |  Speedup
------------------------------ | ---------- | ----------- | ---------- | --------
MontbrioPazoRoxin              |     0.1349 |      4.2562 |     0.0107 |   12.63x
JansenRit                      |     0.0754 |      0.1057 |     0.0126 |    6.01x
Epileptor                      |     0.0843 |      0.1055 |     0.0112 |    7.52x
WilsonCowan                    |     0.1020 |      0.1049 |     0.0122 |    8.39x
ReducedWongWang                |     0.0929 |      0.1056 |     0.0114 |    8.14x
LarterBreakspear               |     0.2849 |      0.1052 |     0.0151 |   18.90x
Generic2dOscillator            |     0.0694 |      0.1054 |     0.0108 |    6.42x
ZerlautAdaptationFirstOrder    |     0.4729 |      0.1149 |     0.0156 |   30.41x
CoupledMPR                     |     0.3238 |      0.0960 |     0.0194 |   16.71x

Chunk Size = 20 (Temporally Averaged - Single Networks Only)
Case                           |  Python(s) |  Compile(s) |   Numba(s) |  Speedup
------------------------------ | ---------- | ----------- | ---------- | --------
MontbrioPazoRoxin              |     0.1351 |      0.0828 |     0.0008 |  173.60x
JansenRit                      |     0.0669 |      0.0800 |     0.0022 |   30.17x
Epileptor                      |     0.0741 |      0.0807 |     0.0010 |   72.80x
WilsonCowan                    |     0.0889 |      0.0785 |     0.0017 |   51.82x
ReducedWongWang                |     0.0853 |      0.0811 |     0.0013 |   65.47x
LarterBreakspear               |     0.2491 |      0.0795 |     0.0044 |   57.22x
Generic2dOscillator            |     0.0606 |      0.1579 |     0.0009 |   70.45x
ZerlautAdaptationFirstOrder    |     0.4599 |      0.1021 |     0.0068 |   67.52x

NOTE: Coupled networks require chunk_size <= minimum delay (1 step)
Compile time includes code generation plus first JIT warmup.
Numba(s) is the cached compiled-kernel run time.

we can also just add to tests but I think it's good demonstration of model usage.
Feel free to modify or remove.

DeLaVlag and others added 30 commits April 10, 2026 06:37
* added numba dfun for model
* signature for elementwise if/else, njit for helper funcs
* tuple unpack svars
* test: fix and test numba kionex
* fix: notebook fixes
* fix: pickle load error
* fix(kionex): shape error in notebook
* fix(jansen-rit notebook): numpy 2.4 scalar assignment compat

In numpy 2.4+, assigning a non-scalar ndarray to a scalar index
raises ValueError instead of a deprecation warning. The expression
phi_n_scaling = (jrm.a * jrm.A * ...) yields a (1,) array, so
sigma[3] = phi_n_scaling now fails. Fix by adding .item() to
extract the scalar value.
* added numba dfun for model
* signature for elementwise if/else, njit for helper funcs
* feat(kionex): extend numba backend and implement kionex
* fix(kionex): fix dfun call
…n 2 pickled gpickle files

pandas.read_pickle defaults to ASCII encoding which fails on binary numpy
data pickled with Python 2. The gpickle format is just a pickled networkx
graph, so use stdlib pickle.load with encoding='latin1' instead.
- Rebase hybrid-numba onto origin/master (picks up KIonEx numba backend
  commits: Add numba dfun for model KIonEx + Enable numba backend)
- Extend NbHybridBackend._check_compatibility to accept KIonEx alongside
  MontbrioPazoRoxin
- Add sin/cos/exp/log shorthands to nb-hybrid-sim.py.mako template header
  (required by KIonEx dfun_helpers which reference exp/log by name)
- Extend nb-hybrid-sim.py.mako to emit per-subnet dfun_constants,
  dfun_helpers, and dfun_intermediates when the model provides them
  (KIonEx-style models with ionic current helpers)
- New test: tvb/tests/library/simulator/hybrid/test_mpr_kionex.py
  Pure-Python Simulator.run() smoke test: MPR + KIonEx two-subnet hybrid
  (output shape, NaN-free at safe ICs, bidirectional inter-projections)
- New test class: TestNbHybridMprKIonEx in test_nb_hybrid.py
  NbHybridBackend compiled kernel: acceptance, shape correctness, and
  Python-vs-Numba numerical consistency for MPR + KIonEx
Replace log(K_o/K_i) and log(Na_o/Na_i) with log(K_o)-log(K_i) and
log(Na_o)-log(Na_i) in all four representation sites:

  - dfun_helpers strings (used by nb-hybrid-sim codegen)
  - _numpy_dfun inner helper lambdas
  - standalone @njit I_K_form / I_Na_form (two duplicate copies)

In float32 the ratio K_o/K_i can underflow to zero before the log is
taken, yielding -inf.  Computing log(a)-log(b) avoids the intermediate
ratio and keeps both operands in the normal float32 range as long as each
concentration is individually representable.

Note: log(Cl_o0/Cl_i0) is left as-is because both values are compile-time
constants that are always positive and finite.

Also document in TestNbHybridMprKIonEx.test_output_shapes that NaN-freedom
for KIonEx in float32 is a separate open item (K_o can go negative under
extreme states), while the Python float64 path is already checked in
test_mpr_kionex.py.
Add Sigmoidal and SigmoidalJansenRit coupling functions to the numba
hybrid backend, and support the JansenRit model as a second model target
alongside MontbrioPazoRoxin and KIonEx.

Backend changes (nb_hybrid.py):
- _cfun_type(): recognise Sigmoidal -> 'sigmoidal', SigmoidalJR -> 'sigmoidal_jr'
- _cfun_params(): return float32[5] array (replaces (cfun_a, cfun_b) 2-tuple)
  Layout: [a, sigma, midpoint, cmin, cmax] for Sigmoidal;
          [a, e0, r, v0, 0] for SigmoidalJansenRit
- _check_compatibility(): accept JansenRit alongside MPR and KIonEx
- _run_compiled(): pass cfun_params array (single arg per projection)

Template changes (nb-hybrid-sim.py.mako):
- cfun_a/cfun_b scalar args -> cfun_params float32[5] array throughout
- Pre-cfun hook inside CSR inner loop for sigmoidal_jr (pre-synaptic
  sigmoid on source state values before weighted-sum accumulation)
- Post-cfun dispatch: sigmoidal branch added; linear/scaling use cfun_params[0/1]
- Both mono_src (n_modes==1) and general paths updated

Model changes (jansen_rit.py):
- Add coupling_terms, parameter_names, dfun_helpers (sigm_jr helper),
  dfun_intermediates, state_variable_dfuns for numba codegen
- coupling enters y4 equation only as Coupling_Term
- 13 scalar parameters baked at codegen time

Tests (test_nb_hybrid.py):
- TestNbHybridSigmoidalCfun: 6 tests (Sigmoidal + SigmoidalJansenRit,
  shape + finite + matches-python)
- TestNbHybridJansenRit: 4 tests (acceptance, shape, finite, NB vs Python)
- TestNbHybridMultiMode: 3 tests (MPR with n_modes=2, shape + matches-python)
- Total: 260 tests passing (was 28)
Replace exec()-into-dict with _build_as_module(): renders generated source
to a real .py file under $TMPDIR/tvb_nb_hybrid_cache/, registers it in
sys.modules, then imports it. This gives Numba a real co_filename so that
cache=True on @nb.njit writes .nbi/.nbc native-code files next to the .py.

On second process startup with the same network topology the SHA-256 key
matches the existing .py, Numba loads the native cache directly (~50 ms vs
~5 s cold JIT). The in-process _COMPILED_FN_CACHE dict is retained as an
additional first-level fast path.

Changes:
- _build_as_module() helper with atomic os.replace write
- _build() delegates to _build_as_module instead of exec()
- NbHybridBackend.clear_cache() classmethod (in-process + disk)
- NbHybridBackend.get_cache_dir() staticmethod
- Template: cache=True added to all @nb.njit decorators (inline + plain)
- TestNbHybridDiskCache: 3 tests (dir created, in-process hit, clear)
Mark as done: Sigmoidal cfun, SigmoidalJansenRit cfun, JansenRit model,
n_modes>1 test, disk-persistent JIT cache. Update test count to 44 (263
total across backend + hybrid suites). Update §6 supported configuration
table and §7/§8 to reflect 2026-04-10 work.
… strip CSR zeros

Model codegen attributes added (coupling_terms, parameter_names,
dfun_intermediates, state_variable_dfuns):
- ReducedWongWang (wong_wang.py): 1 svar S, 8 params, 2 intermediates
  (x_ww, H_ww); S clipped to [0,1] via existing boundaries
- Epileptor (epileptor.py): 6 svars, 16 params, 4 ternary intermediates
  (f1, zterm, h, f2); modification=False only
- WilsonCowan (wilson_cowan.py): 2 svars E/I, 22 params, 4 intermediates
  (x_e, x_i, s_e, s_i); shifted sigmoid (shift_sigmoid=True only)
- Generic2dOscillator (oscillator.py): already added in prior session

Backend (nb_hybrid.py):
- _check_compatibility: accept RWW, Epileptor, WilsonCowan (7 models total)
- Per-model constraint checks: rejects Epileptor(modification=True) and
  WilsonCowan(shift_sigmoid=False) with NotImplementedError
- _build_projection_info: strip structural epsilon zeros via
  eliminate_zeros() on a copy of p.weights before extracting
  .data/.indices/.indptr; idelays aligned with stripped structure

Tests (test_nb_hybrid.py):
- TestNbHybridReducedWongWang: 4 tests (accepted, shape, finite, py-match)
- TestNbHybridEpileptor: 4 tests
- TestNbHybridWilsonCowan: 4 tests (uses source_cvar=[0] for E-only coupling)
- test_rejects_unsupported_model: updated to use SupHopf (WilsonCowan now accepted)
- 283 tests passing total (was 52)
…/8.9/8.10)

§8.8 Resumable runs / snapshot API:
- CompiledNetworkFn.run(return_snapshot=True) -> (outputs, snapshot)
  snapshot = {'states': [...], 'buffers': {...}}; captured from in-place
  Numba writes, zero kernel changes required
- CompiledNetworkFn.resume(snapshot, nstep) restores state+buffers and
  continues; numerically identical to a single longer run
- _run_compiled gains _initial_buffers=None parameter

§8.9 Testing gaps:
- TestNbHybridModeMap (4 tests): non-identity mode_map on 2-mode MPR nets;
  verifies accepted, shape, finite, and that mixing actually differs from identity
- TestNbHybridLargeNScaling (2 tests): N=100 no-error smoke test; N=50
  speedup regression (cached Numba vs Python loop with generous bound)

§8.10 Code quality:
- __all__ added to nb_hybrid.py (NbHybridBackend, CompiledNetworkFn,
  NetworkAnalysis, SubnetworkInfo, ProjectionInfo)
- backend/__init__.py: lazy __getattr__ for NbHybridBackend + CompiledNetworkFn
  (direct import caused circular dep via integrators -> equations -> backend)
- Plan doc: §8.3 marked DONE, §6 table extended, Phase C2 added to §7

Tests: 74 passing (was 68)
…§8.4/8.10)

§8.10 Code quality:
- compile(debug_nojit=True) / TVB_HYBRID_NO_JIT=1 env var: replaces @nb.njit
  decorators with no-ops for fast debugging; hash naturally differs (rendered
  source changes) so no cache collisions
- run_network() also accepts debug_nojit kwarg
- Type annotations added: _run_compiled, _analyse, _check_compatibility,
  _build_projection_info
- Module docstring references nb_hybrid_plan.md
- TestNbHybridDebugNojit: 2 tests (runs without error, matches JIT output)

§8.4 Lazy stimulus infrastructure (stub):
- _STIM_LAZY_THRESHOLD_MB = 64 (overridable via TVB_HYBRID_LAZY_STIM_MB)
- _stim_estimate_mb(sn_info, nstep): projected stim array size in MiB
- _compute_stimulus_lazy stub: windowed (step_start, step_end) computation
  with TODO explaining template change needed (network_chunk must use t_local
  indexing instead of global t-1 before lazy path can be wired to run_network)
- TestStimulusMemoryEstimate: 2 tests (small <64MB, large >64MB)

Plan doc (nb_hybrid_plan.md):
- Status line updated; §6 table extended (8 new rows); Phase D + E added
- §8.2 checkboxes all ticked; §8.6 FHN note clarified; §8.7 marked DONE
- §8.8 marked DONE; §8.9 table fully checked; §8.10 checkboxes all ticked

Tests: 78 passing (was 74)
nb_hybrid_next.md: plan for bulk model codegen (17 scalar models via
Ralph loop), monitor support (M1 Python dispatch, M2 in-kernel subsample,
M3 projection monitors), combined-mode dfun generation for
ReducedSetFitzHughNagumo/HindmarshRose (unrolled matrix ops at codegen time).

ralph_add_model_codegen.sh: bash script that iterates over 17 model files,
calling opencode run with zai/glm-5.1 to add coupling_terms, parameter_names,
dfun_intermediates, state_variable_dfuns. Includes validation (attr existence,
key matching, coupling_term usage, parameter attribute check) and 3-retry
with revert on failure.

nb_hybrid_plan.md: updated status line, reclassified ReducedSetFHN from
'permanently deferred' to 'deferred to next phase (combined-mode)', updated
file summary table.
…emplate (Phase F)

Phase F — Bulk Model Codegen (Ralph Loop):
- 15 scalar models via AI-assisted Ralph loop: SupHopf, Kuramoto,
  Epileptor2D, Hopfield, LarterBreakspear, EpileptorRestingState,
  EpileptorCodim3, EpileptorCodim3SlowMod, ZetterbergJansen,
  ReducedWongWangExcInh, CoombesByrne, CoombesByrne2D,
  GastSchmidtKnosche_SD, GastSchmidtKnosche_SF, DumontGutkin
- Each model gets coupling_terms, parameter_names, dfun_intermediates,
  state_variable_dfuns codegen attributes
- 2 Zerlaut models via custom nb-zerlaut-dfun.py.mako template:
  ZerlautAdaptationFirstOrder (5 svars, erfc transfer function pipeline)
  ZerlautAdaptationSecondOrder (8 svars, numerical-derivative covariance)

Combined-mode dfun (nb_hybrid_next.md §3 / G4):
- ReducedSetFitzHughNagumo (4 svars × 3 modes): dfun_mode='combined'
- ReducedSetHindmarshRose (6 svars × 3 modes): dfun_mode='combined'
- Template gains is_combined branch in nb-hybrid-sim.py.mako
- Inter-mode matrix products (Aik, Bik, Cik) unrolled at codegen time
- derived_matrix_names/ops model attributes for combined-mode metadata

Gaps G1–G3:
- G1: Multiplicative noise raises NotImplementedError (was silent wrong result)
- G2: chunk_size > min_horizon raises ValueError (was silent wrong result)
- G3: Monitor M1 Python-side dispatch: monitors= kwarg on run_network()
  supports TemporalAverage, Raw, SubSample, GlobalAverage, AfferentCoupling

Test coverage: 78 → 145 tests (+67), all passing across 28 test classes
Models supported: 8 → 27 (19 new via codegen)
_ralph_models parametrized smoke tests (accepted + shape + finite)
Zerlaut dedicated tests (shape + finite + python-match)
Monitor dispatch tests (TestNbHybridMonitors)
…G1/G2/G3)

G1: Multiplicative noise raises NotImplementedError (was silent wrong result)
G2: chunk_size > min_horizon raises ValueError (was silent wrong result)
G3: Monitor M1 Python-side dispatch via monitors= kwarg
    supports TemporalAverage, Raw, SubSample, GlobalAverage, AfferentCoupling
…igmoidal cfuns

All 9 hybrid coupling functions now supported:
- Linear, Scaling, Sigmoidal, SigmoidalJansenRit (existing)
- Kuramoto: a * sin(x) post-hook
- Difference: a * x (maps to scaling internally)
- HyperbolicTangent: a * (1 + tanh((x-midpoint)/sigma)) pre-hook
- PreSigmoidal: H * (Q + tanh(G*(P*x - theta))) pre-hook

Template fix: pre-cfun transforms moved outside CSR accumulation loop
to match Python pipeline (weighted sum -> pre -> scale -> post).
This also fixes sigmoidal_jr correctness for heterogeneous source states.

_cfun_params array expanded from 5 to 8 elements for new parameter layouts.
6 new tests in TestNbHybridCfunExtended (all python-vs-numba comparisons).
151 tests total, all passing.
…match tests for all models

Template bug fix: tavg accumulation now uses actual variables_of_interest
indices instead of state[0..n_voi-1]. This was wrong for any model where
voi aren't the first N svars (e.g., ZetterbergJansen voi=v6,v7,v2-v5,
Epileptor voi=x2-x1,z). Derived voi expressions (e.g., 'x2 - x1') are
now parsed and generated as arithmetic in the template.

_ic_from_range() fixed to produce correct multi-mode ICs for ReducedSet
models (n_modes=3).

Added parametrized test_ralph_model_matches_python for all 17 Ralph-loop
models — compares full Numba backend output against Python reference loop.
Epileptor match test updated for derived voi.

168 tests total, all passing.
…onitors (M2-M5)

All monitors implemented as Python-side post-processing in _apply_monitors():

M2 - SubSample: period enforcement via time-point masking, selecting chunks
     whose mid-time falls within dt/2 of a period boundary.

M3 - SpatialAverage: node grouping via spatial_mean matrix (n_areas, n_nodes)
     applied with einsum to reduce node dimension.

M4 - Projection (EEG/MEG/iEEG): gain-matrix projection from node space to
     sensor space. Handles mode summation and adds singleton mode dim.

M5 - Bold: HRF convolution via rolling stock buffer with interim accumulation.
     Stateful across multiple run_network() calls via _nb_ prefixed attrs.
     Supports FirstOrderVolterra k1/V0 scaling.

Tests: 7 new monitor tests covering shape, values, period spacing, and
statefulness. Updated test_unsupported_monitor_raises to use ProgressLogger.

175 tests total, all passing.
…n tests

Each test runs the same simulation through both the Python hybrid
step-loop and the Numba backend, applies the same monitor logic,
and asserts numerical equivalence at rtol=1e-2:

- TemporalAverage (chunk_size=1 and chunk_size=5)
- SubSample (step-based period masking, matching Python istep semantics)
- GlobalAverage (mean across nodes)
- SpatialAverage (spatial_mean matrix grouping)
- Projection/EEG (gain-matrix sensor projection)
- Bold (HRF convolution with FirstOrderVolterra scaling)

Bug fix: SubSample mask changed from float time-point rounding to
step-based modular arithmetic to avoid float32 precision artifacts.

182 tests total, all passing.
@maedoc

maedoc commented Apr 27, 2026

Copy link
Copy Markdown
Member

hi @Ziaeemehr (and @i-Zaak ) we could plan a sync this week or next, since I've now got hybrid simulation mostly covered by a new backend in #775

@Ziaeemehr

Copy link
Copy Markdown
Author

works for me. I am available.

@i-Zaak

i-Zaak commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Sure!

@Ziaeemehr
Ziaeemehr deleted the branch the-virtual-brain:hybrid-numba June 17, 2026 13:12
@Ziaeemehr Ziaeemehr closed this Jun 17, 2026
@Ziaeemehr
Ziaeemehr deleted the hybrid-numba branch June 17, 2026 13:12
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.

4 participants