Understanding jump model preparation cost

I am using Catalyst.jl to make JumpProblems of around 160k species and a fairly sparse interaction structure that includes what JumpProcesses.jl calls “constant-rate jump” (CRJ) reactions. This works, but JumpProblem construction is quite slow (and so is integrator initialization). I would like to understand why that is and what I could do about it.

As an (admittedly contrived) example, consider:

using Catalyst
using Graphs
using JumpProcesses

t0 = @elapsed begin
	g = static_scale_free(n, 2n, 3.0, 3.0, seed = n)
	t = default_t()
	xs = map(vertices(g)) do i
		name = Symbol("node_$i")
		only(@species $name(t))
	end
	rate(i) = minimum(
		[hill(xs[j], 1.0, 1 / j, 1.0) for j in neighbors(g, i)],
		init = 1.0,
	)
	reactions = mapreduce(vcat, vertices(g)) do i
		[
			Reaction(rate(i), nothing, [xs[i]])
			Reaction(1.0, [xs[i]], nothing)
		]
	end
	system = complete(ReactionSystem(reactions, name = :name))
end
t1 = @timed JumpProblem(system, [s => 0 for s in unknowns(system)], (0.0, 1.0))

This builds a directed graph with n nodes, 2n edges and a power-law degree distribution, and then translates it into a reaction network such that the inbound edges are aggregated for each species (node) by a CRJ function (here map hill and reduce by minimum). Timing this construction for various sizes (on a smaller 2019 Intel laptop, no swap) gives:


(I have attached a Pluto notebook (113.9 KB) timing the JumpProblem call, but not the integrator initialization. Apparently I am not allowed to upload the CSV file with the timings?)

Although this isn’t really a clean benchmark, it still shows what I observed in my actual application: Constructing the model specification is negligibly fast, but the JumpProblem call is very expensive. Without having a good mental model of what exactly it does internally, my expectation would be that it should take time and memory proportionally to the number of links (i.e. here, O(n)). But beyond a certain size, both time spent and memory allocated grow faster than the link count:

Whenever I send SIGUSR1, it is currently doing assemble_crj/compile_equational_affect (for JumpProblem construction) or concretize_affects! (for integrator initialization). Since the timings show that most of that is Julia compiling stuff, I suspect that model preparation actually compiles these many rate update functions one-by-one. (Edit: “affect” actually refers to the state updates, right?)

In my application (which is ~160k species, ~64k CRJ-rate reactions, and ~300k purely mass-action reactions, run on a passively cooled Ryzen 9 HX 370, so effectively laptop hardware) JumpProblem construction takes ~36h, and the first integrator intitialization ~14½h. But each subsequent integrator initialization, despite being produced from a deepcopyd JumpProblem (!), is fast enough that I am unsure whether the delay might actually just have been faster reaction propensities early on. And the whole process takes ~18GiB peak resident memory, most of which seems to be for SciML, let’s call it 16GiB.

Now my questions are:

  • Am I using this incorrectly? It feels like I’m missing something here. (Why does creating an integrator mutate the JumpProblem? Why is JumpProblem mutable in the first place, isn’t the whole point of having an integrator object to encapsulate the iteration and execution context?)
  • Can I reduce the memory footprint somehow? ~100KiB per species (resp. ~50KiB per specified graph edge) seems quite high since I’m not saving trajectories.
  • Why is preparation so slow in absolute terms? Are these rate update functions all individually optimized or something like that? (Edit: “affect” = updates)
  • Why is preparation time super-linear in the number of species + links (which determine the complexity and size of the rate update functions)? (Edit: I guess the same consideration holds for the updates, i.e. the complexity/size of the state updater would depend on the number of outbound edges?)
  • Given that subsequent initializations are faster despite the preceding JumpProblem deepcopy: What part of the execution state here is global? Do the rate functions all enter Julia’s method table?
  • If so, will they be evicted at some point, or do I need to avoid processing a sequence of large SciML models in the same Julia process?
  • Are there easy ways to accelerate model preparation? E.g. by predefining/registering the rate functions somewhere, perhaps once for each arity, since they are so similar? (Edit: That probably does not make sense for the affects…) Otherwise, could their compilation be multi-threaded? (At that point I guess they would be independent of each other, right?)

Each Hill function reaction generates a new ConstantRateJump, which in turn requires two Julia functions to be built from the symbolic expressions (in the JumpProblem call) and compiled (I’m not sure where this happens currently, for ODEs I think it used to happen the first time the function was called). The translation from Catalyst to ModelingToolkit can be split from the function building if you use jump_model to do the conversion. This should be fast. Calling JumpProblem on that is where MTK then builds the Julia ConstantRateJump functions from the symbolic ConstantRateJumps, which is going to be slow with so many jumps. I don’t think these functions actually compile though, I’d have thought compilation occurs when they are first called in a Gillespie method (or maybe they compile when they are wrapped with FunctionWrappers during JumpProcess initialization).

I know the Julia compiler has issues with very large generated functions and a non-linear compilation time scaling there. It seems you are running into a similar issue with generating and compiling many many small functions. Unfortunately I think this is a fundamental ModelingToolkit limitation, but perhaps @ChrisRackauckas can say more.

We have discussed adding a custom HillJump type to JumpProcesses, which would serve as a aggregator for a collection of multiple Hill rates and potentially solve this issue for you, but I don’t think this is something I can promise in the next few months unfortunately.

JumpProblems store the underlying aggregator that samples the next reaction and its time, and hence have internal state (i.e. the current jump intensities for example). This is a consequence of the design of JumpProcesses to use callbacks over the aggregation for providing next event times (so one can easily mix jumps with ODEs/SDEs). It is messy, but can’t really be changed without having a way to have a cache of the aggregator’s needed data that persists between simulations and is instead accessible via the integrator (which is not possible now as far as I am aware). Hence it has always been cached in the JumpProblem, the only other globally accessible simulation component.

The memory use you are seeing likely all just comes from compilation. If you are setting save_positions = (false,false) then the only memory being used should be the current state saving and the caching of current propensities (depending on the method you are using).

It’s not fundamental just hard. We are doing some weird stuff like:

mixed with FunctionWrappersWrappers.jl in order to despecialize callback generations while not losing performance. We’re continuing to improve the compile time performance but actually just in a meeting today we noted that large numbers of callbacks is still one area where we trip up, and that’s the jump process one. So all sorts of, function splitting, callback despecialization, function reuse caching, etc. is going on.

Thank you both for the info, am I then understanding correctly that:

  • I am not using this incorrectly, and there is no easy way for me to improve this type of application right now?
  • All of these many compiled entities will outlive the JumpProblem instance, so if I need to run multiple large models I need to do so in separate Julia processes?
  • Tightly coupling these compiled entities with simulation state is an architectural decision in some underlying SciML package that cannot easily be changed? But this is mostly unrelated to my performance issue?

(Regarding the last point, I have found this JumpProcesses.jl issue that provides some context, but I still don’t understand why the “next reaction” identities and times have to be stored alongside the compiled stuff that might be expensive to recompute on a subsequent solve call but would not change across models /JumpProblems. I guess I am asking why “callbacks” are more than a reference to executable code, also in non-jump problems. Isn’t that state only ever needed in the context of an integrator invocation? An independent solve call would need to reinitialize them anyway, no?)

It seems like some part (around ⅔) of the compilation happens in JumpProblem construction, and the rest during the first integrator invocation, so maybe when they are first called. But either way, (assuming that persistent memory usage is primarily due to the compiled CRJ callbacks) how are they so large, at dozens of kilobytes each? Wouldn’t “despecialization, function reuse caching etc.” reduce their final size?

I am not sure if this is related, but after my most recent upgrade, JumpProblem memory usage has increased by at least an order of magnitude, and the process is now oom-killed after ~2min (at ~40GiB where previously at that time point it was at ~4GiB). I don’t yet know which package/version caused it, but had to downgrade from new-Manifest.toml (82.6 KB, as of 2026-07-27) to old-Manifest.toml (82.9 KB, as of 2026-05-26) for now.

@cryptic.ax check for a regression?

What value of n were you using for the model in the root of this thread? Or some other model entirely? We had a performance regression in a recent release, but I triggered registration for a new version with a fix a little while back.

For that example I am running for n in 2 .^ (2:15) to produce the figures as per the attached Pluto notebook.

So the used versions are:

Package old-Manifest.toml Pluto notebook new-Manifest.toml
ModelingToolkitBase 1.40.0 1.54.1 1.57.0
JumpProcesses 9.29.0 9.29.2 9.29.2
Catalyst 16.1.1 16.2.2 16.2.3

Which version (of ModelingToolkitBase I presume) has the fix? (I am unsure now if the regression was already present in the Pluto notebook because there I don’t go as big as my actual model.)

Thanks for looking into it.

The most current versions I get after running ]up right now are:

  • ModelingToolkitBase: v1.58.1
  • SciMLBase: v3.39.1 (blocked from upgrading by DiffEqBase)
  • DiffEqBase v7.9.0 (blocked from upgrading, unsatisfiable requirements for ImplicitDiscreteSolve, restricted by compatibility requirements with OrdinaryDiffEqCore: “no versions left”)

The excessive memory usage is still present, what version(s) do I need?

Those versions should be good. Can you share what you’re running to get this excessive memory usage?

Apologies for the delay, I had some trouble reducing this to a small reproducing example. The issue seems to be mixing integer states with float parameters. Maybe I’m also using this incorrectly, but as I said this previously ran fine (albeit very slowly like described above).

Run timeit.jl (980 Bytes) in environment Project.toml (12.3 KB), Manifest.toml (82.0 KB), Julia v1.12.6: as soon as it starts building the JumpProblem it keeps grabbing more memory until it runs out (on my machine).

Edit: I also find it surprising that the number of parameters reported changes from run to run, but it is always lower than what I would expect: 32768.

Have you tried timing the JumpProblem call with much simpler rate functions (like constants or linear expressions) to see if that compilation hypothesis holds up? If the slowdown is really baked into compiling individual CRJ affects, even a small test case with a few thousand species and complex rates should show the pattern. That might also tell you whether it’s the number of functions that matters or their complexity or what.

Sorry for the delayed reply; I was on vacation and am just now back at work.

I am not using this incorrectly, and there is no easy way for me to improve this type of application right now?

Not via Catalyst right now, no. This requires changes across SciML. If we wanted to avoid the memory / compilation issues that arise from having many many ConstantRateJump functions we would need to extend JumpProcesses to have a HillFunctionJump type or such, that works similar to MassActionJump in aggregating all such jumps into one entity. That is something that is doable in theory, but would require some work to integrate throughout the package and then propagate back up into ModelingToolkit and Catalyst. That said, @ChrisRackauckas has pointed out he has places this would be useful too, so it is probably something we should look into in the near future.

All of these many compiled entities will outlive the JumpProblem instance, so if I need to run multiple large models I need to do so in separate Julia processes?

No, they shouldn’t outlive a given problem instance. They will outlive a given solution instance since they are cached in the problem.

(Regarding the last point, I have found this JumpProcesses.jl issue that provides some context, but I still don’t understand why the “next reaction” identities and times have to be stored alongside the compiled stuff that might be expensive to recompute on a subsequent solve call but would not change across models /JumpProblems. I guess I am asking why “callbacks” are more than a reference to executable code, also in non-jump problems. Isn’t that state only ever needed in the context of an integrator invocation? An independent solve call would need to reinitialize them anyway, no?)

Tightly coupling these compiled entities with simulation state is an architectural decision in some underlying SciML package that cannot easily be changed? But this is mostly unrelated to my performance issue?

What you don’t want to do is reallocate all the aggregator’s (i.e. Gillespie method’s) cache data structures each time you call solve. That can be more expensive than the simulation itself, and will lead to massive memory allocations when running many independent samples. Hence such structures / arrays are only allocated once during JumpProblem construction, and then reused each time solve is called (i.e. reinitialized but not reallocated). But none of this should have anything to do with the issues you are encountering as far as I am aware (which are more related to having many many many functions that are generated via MTK and then need to be complied).

It seems like some part (around ⅔) of the compilation happens in JumpProblem construction, and the rest during the first integrator invocation, so maybe when they are first called. But either way, (assuming that persistent memory usage is primarily due to the compiled CRJ callbacks) how are they so large, at dozens of kilobytes each? Wouldn’t “despecialization, function reuse caching etc.” reduce their final size?

This I can’t answer, but it seems like maybe there is a MTK issue here with the generated code. These should be very small functions that we subsequently wrap with a FunctionWrapper.

Have you tried timing the JumpProblem call with much simpler rate functions (like constants or linear expressions) to see if that compilation hypothesis holds up? If the slowdown is really baked into compiling individual CRJ affects, even a small test case with a few thousand species and complex rates should show the pattern. That might also tell you whether it’s the number of functions that matters or their complexity or what.

This won’t work if your simpler examples are mass action, since they will generate a MassActionJump that handles all such reactions collectively. So one needs to have a custom rate law, or something beyond mass action to ensure one gets a ConstantRateJump. But a simple test would be just to hand code a ConstantRateJump for a Hill function and generate one via Catalyst, and then compare the function sizes after compilation (to be honest I’m not sure how to do this, but at one point one could get MTK to also return the generated function’s code to look over what it created, so perhaps that could help here).