Skip to content

The great restructuring, episode 15: the monorepo strikes back, single SciML version #1082

@ChrisRackauckas

Description

@ChrisRackauckas

As SciML has grown, so has its structural needs. Back in 2017, we realized that DifferentialEquations.jl was too large and so it needed to be split into component packages for OrdinaryDiffEq, StochasticDiffEq, etc. in order to allow for loading only a part of the system as DiffEq itself was too big. In 2021, we expanded SciML to have LinearSolve, NonlinearSolve, etc. as separate interfaces so they can be independently used, and as such independent packages. In 2024 we split many of the solver packages into independent packages like OrdinaryDiffEqTsit5, in order to accomodate users who only wanted lean dependencies to simple solvers. And now we have hundreds of packages across hundreds of repos, though many of the subpackages are in the same repo.

If we had started this system from scratch again, I don't think we'd have so many repos. With the subrepo infrastructure of modern Julia, keeping OrdinaryDiffEq and StochasticDiffEq together would have been nice. In fact, DelayDiffEq.jl touches many of the OrdinaryDiffEqCore internals, so it should be versioned together with OrdinaryDiffEq. This leads to issues like JuliaLang/julia#55516 being asked of the compiler team.

The core issue here is that the module structure does not necessarily reflect the public/private boundaries, but rather the module system is designed for separate loading, separate compilation, conditional dependencies, and ultimately handling startup/latency issues. This means that it really does not make sense for OrdinaryDiffEqTsit5 and OrdinaryDiffEqCore to have different versions, since they really should be handled in lock-step, and any versioning that isn't matching is always suspect. This leads to odd issues with bumping, and nasty resolution of manifests. The issue is that the semver system is designed around package boundaries being built around public interfaces with announcing public breakage, but this simply does not work out when the common breakage is non-public internals simply because the package boundary is at some internal function, again not because it's a generally good idea for the packages but instead because it's a requirement to achieve lower startup times.

So what can we do? The proposal is not so simple, but it can work. Instead of waiting on a fix for JuliaLang/julia#55516, we can do some clever tricks on the tools that we now have in Julia 2025 land in order to pull this off. It would look like this:

  • We can have a single basic repository that contains all of the core code. For example, OrdinaryDiffEq contains what is now the OrdinaryDiffEqCore code
  • We then make all of the add on code into extensions. For example, OrdinaryDiffEqTsit5 would effectively be an empty package, that only exists to have a Project.toml for declaring new dependencies, and an OrdinaryDiffEqOrdinaryDiffEqTsit5 extension to OrdinaryDiffEq would be created to trigger on using OrdinaryDiffEqTsit5 that actually loads the extension code.
  • Importantly, this means that the code lives in OrdinaryDiffEq.jl, and thus the solver code only has one version, the OrdinaryDiffEq version. All solver code is then single versioned and updated in lock step. The only reason to bump the version on the pseudo packages is to bump dependency versions. Now because the code does not actually live in OrdinaryDiffEqTsit5, the only way to do a code update is to first make a version of OrdinaryDiffEqTsit5 that is a major bump with the new dependency versions, and then update OrdinaryDiffEq.jl to allow the new OrdinaryDiffEqTsit5 major. This means that every release of the psuedo packages is a considered a breaking release, but the only times they need a release are for compat bumps.

With this, the user code would look like:

using OrdinaryDiffEq, OrdinaryDiffEqTsit5
function lorenz!(du, u, p, t)
    du[1] = 10.0(u[2] - u[1])
    du[2] = u[1] * (28.0 - u[3]) - u[2]
    du[3] = u[1] * u[2] - (8 / 3) * u[3]
end
u0 = [1.0; 0.0; 0.0]
tspan = (0.0, 100.0)
prob = ODEProblem(lorenz!, u0, tspan)
sol = solve(prob, Tsit5())
using Plots;
plot(sol, idxs = (1, 2, 3))

Note that using OrdinaryDiffEqTsit5 would be a requirement, as OrdinaryDiffEq would no longer trigger any solvers. If this isn't nice, we could have the single version package be OrdinaryDiffEqCore, with a higher level OrdinaryDiffEq that just uses a few solvers.

Small Questions

  1. The issue "Note that using OrdinaryDiffEqTsit5 would be a requirement, as OrdinaryDiffEq would no longer trigger any solvers. If this isn't nice, we could have the single version package be OrdinaryDiffEqCore, with a higher level OrdinaryDiffEq that just uses a few solvers. " - Is that to janky?
  2. Any potential issues not considered?

Big Question

If we go to monorepo, what's in and what's out? The advantage of having more things in a single repo is clear. Most of the issues around downstream testing, keeping package versions together, etc. are all gone. You know your code is good to go if it passes tests in this repo because that would have "everything".

However, what is everything? Should we have the following:

  • DifferentialEquations
  • NonlinearSolve
  • LinearSolve
  • ...

Or if we're going to do this, should we just have a single SciML?

using SciML, OrdinaryDiffEqTsit5
function lorenz!(du, u, p, t)
    du[1] = 10.0(u[2] - u[1])
    du[2] = u[1] * (28.0 - u[3]) - u[2]
    du[3] = u[1] * u[2] - (8 / 3) * u[3]
end
u0 = [1.0; 0.0; 0.0]
tspan = (0.0, 100.0)
prob = ODEProblem(lorenz!, u0, tspan)
sol = solve(prob, Tsit5())
using Plots;
plot(sol, idxs = (1, 2, 3))

Then you can just ask, "what version is your SciML?"

I think that would actually be really nice because splitting the interface between SciMLOperators, SciMLBase, DiffEqBase, NonlinearSolveBase, OptimizationBase, OrdinaryDiffEqCore, etc. are also somewhat arbitrary distinctions and moving code at the interface level there has always been a multi-repo mess due to the aritificiality of the split and release process w.r.t. semver at this level. Additionally, SciMLSensitivity is a package adding sensitivity analysis to all solvers, even NonlinearSolve.jl, so it would neatly fit as an extension to all of the packages. If that's the case, is the core package here just SciMLBase, and everything else is an extension to SciMLBase?

That said, how far do we go? Is ModelingToolkit in there? Is Catalyst in there? Symbolics.jl and SymbolicUtils.jl? Do we then restructure the whole SciML documentation as a single Vitepress doc? Or build many different Documenter docs from one repo?

Some CI questions

  1. Testing the subpackages: do we put the tests all in the main repo, or associate them with the psuedo packages? That way users could trigger subsets of tests?
  2. Could the tests know/understand the dependency graph, so say a change to SciMLBase itself triggers all tests, while changing something in OrdinaryDiffEqCore only triggers the OrdinaryDiffEq tests and other downstream of that? That would be pretty essential for managing the growth of the CI budget, since more tests is more costs.
  3. Are there downsides with precompilation, download sizes, and system image building to consider?
  4. How will the VS Code Language Server feel about this?

Conclusion

This is going to be a lot of work, so I'm looking for comments before commencing.

@devmotion @oscardssmith @isaacsas @TorkelE @thazhemadam @asinghvi17

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions