alphaFeedback

WorksComputer Science

Parallel Sparse Identification of Nonlinear Dynamics in Python: Task- and Data-Parallel SINDy with Numba, OpenMP, Threads and Multiprocessing for the Lorenz and Rössler Systems

AI contributions
Not recorded for this paper
View details
Ancillary data
1 code link
View

Abstract

The sparse identification of nonlinear dynamics (SINDy) recovers interpretable governing equations from time-series data by sparse regression over a library of candidate functions. As data sets, candidate libraries and the number of repeated fits grow, the cost of SINDy becomes dominated by the evaluation of the library matrix and its least-squares problems. We present an open-source Python implementation of SINDy in which a single set of loop-based kernels is just-in-time compiled with Numba and executed on a shared-memory multicore node by four interchangeable back ends: Numba prange, OpenMP directives through PyOMP, Python threads that release the global interpreter lock, and multiprocessing with shared memory. The normal equations are accumulated in one cache-blocked pass over the data, with an automatic fallback to a tall-skinny QR reduction for ill-conditioned problems. Two decompositions are studied on the Lorenz and Rössler systems: an ensemble (task-parallel) workload of 960 independent identifications and a library (data-parallel) workload of one trajectory with 2×1072\times10^7 samples and a fifth-order library of 56 candidate terms. All back ends recover the exact sparsity pattern in every identification, with coefficient errors identical to PySINDy. On a dual-socket Intel Xeon E5-2650 v4 node of the University of Houston-Downtown HPC cluster (24 cores, 48 hardware threads), the compiled serial code is 4.3-5.6x faster than PySINDy, the parallel back ends reach speed-ups of 14.8-20.3 on 24 cores (parallel efficiency 0.61-0.84), and the overall time to solution is reduced by up to 117x with respect to PySINDy. We analyse the differences between the back ends, the effect of the two sockets and of hyper-threading, and give practical recommendations.

Introduction

Discovering the governing equations of a dynamical system directly from measurements is a central problem in data-driven science [9]. The sparse identification of nonlinear dynamics (SINDy) algorithm of Brunton, Proctor and Kutz [1] addresses it by assuming that the right-hand side of the dynamics is a sparse combination of a few candidate functions, so that a sparse regression over a library of such functions yields models that are both accurate and interpretable. SINDy has since been extended to partial differential equations [5], latent coordinates [6], implicit and rational dynamics [7], weak formulations [8] and ensemble/bootstrap variants for noisy data [4], with a recent tutorial perspective for engineering applications [11] and robust autoencoder variants [12]. The open-source PySINDy package [2, 3, 10] made these methods widely accessible.

Practical SINDy studies rarely consist of a single fit. Model selection requires sweeps over the sparsity threshold and the library; robustness is assessed by bootstrapping [4]; and long or high-resolution experiments produce millions of samples while high-order libraries contain tens to hundreds of terms. In this regime the computation is dominated by the evaluation of the library matrix Θ(X)\Theta(\mathbf{X}) and by least-squares solves, and interpreter overhead and serial execution become the limiting factors.

Python offers several mature routes to multicore execution: Numba's just-in-time (JIT) compilation with prange loops [15]; OpenMP [19] directives through PyOMP [16, 17] or pure-Python implementations such as OMP4Py [18]; ordinary threads, which run in parallel when compiled code releases the global interpreter lock (GIL) [21]; and process-based parallelism with multiprocessing. Their relative merits for equation-discovery workloads have not been systematically compared on a modern multicore node.

This paper makes three contributions. (i) We formulate SINDy so that its expensive part is a single cache-blocked pass that accumulates the normal equations, safeguarded by a tall-skinny QR (TSQR) fallback [27] for illconditioned data, and implement it as one set of compiled kernels shared by all back ends. (ii) We describe task-parallel (ensemble) and data-parallel (library) decompositions and their implementation with prange, PyOMP, GIL-free threads and multiprocessing. (iii) We report a strong-scaling study from 1 to 48 hardware threads on a compute node of the University of Houston–Downtown (UHD) HPC cluster for the Lorenz [13] and Rössler [14] systems, validated against PySINDy. All code and benchmark scripts are released as open source.

Sparse identification of nonlinear dynamics

We consider autonomous systems x˙(t)=f(x(t))\dot{x}(t)=f(x(t)) with state x∈Rn\mathbf{x}\in\mathbb{R}^{n}. Sampling the state at times t1,…,tNt_{1},\ldots,t_{N} gives the data matrix X∈RN×n\mathbf{X}\in\mathbb{R}^{N\times n} and, by numerical differentiation, X˙∈RN×n\dot{\mathbf{X}}\in\mathbb{R}^{N\times n}. SINDy postulates

X˙≈Θ(X)Ξ,(1)\dot{\mathbf{X}}\approx\Theta(\mathbf{X})\Xi, \tag*{(1)}

where Θ(X)∈RN×m\Theta(\mathbf{X})\in\mathbb{R}^{N\times m} collects mm candidate functions evaluated on the data and Ξ=[ξ1,…,ξn]∈Rm×n\Xi=[\boldsymbol{\xi}_{1},\ldots,\boldsymbol{\xi}_{n}]\in\mathbb{R}^{m\times n} is a sparse coefficient matrix whose column ξk\boldsymbol{\xi}_{k} defines the kk-th equation x˙k=Θ(x)ξk\dot{x}_{k}=\Theta(\mathbf{x})\boldsymbol{\xi}_{k} [1]. Θ\Theta contains all monomials of the state variables up to degree dd, ordered as 11, xx, yy, zz, x2x^{2}, xyxy, xzxz, y2y^{2}, yzyz, z2z^{2}, x3x^{3}, …\ldots (as in PySINDy), so that m=(n+dd)m=\binom{n+d}{d}: m=20m=20 for d=3d=3 and m=56m=56 for d=5d=5 with n=3n=3.

Numerical differentiation

Derivatives are obtained with second-order central differences, x˙i≈(xi+1−xi−1)/(2Δt)\dot{x}_{i}\approx(x_{i+1}-x_{i-1})/(2\Delta t), and second-order one-sided formulas at the two end points, matching pysindy.FiniteDifference. The truncation error is O(Δt2)O(\Delta t^{2}) and, for the noise-free data used here, is the dominant source of the coefficient errors reported in § 5.

Sequentially thresholded least squares

The sparse coefficients are computed with sequentially thresholded least squares (STLSQ) [1], whose convergence was analysed in [26]. Starting from the full support, each iteration solves a ridge-regularised least-squares problem on the active columns and zeroes every coefficient whose magnitude falls below a threshold λ\lambda:

ξk←argmin⁡ξ ∥ΘSξ−x˙k∥22+α∥ξ∥22,S←{j:∣ξk,j∣≥λ},(2)\boldsymbol{\xi}_{k}\leftarrow\underset{\boldsymbol{\xi}}{\operatorname{argmin}}\ \lVert\Theta_{S}\boldsymbol{\xi}-\dot{x}_{k}\rVert_{2}^{2}+\alpha\lVert\boldsymbol{\xi}\rVert_{2}^{2},\qquad S\leftarrow\{j:|\xi_{k,j}|\geq\lambda\}, \tag*{(2)}

until the support SS no longer changes. We use λ=0.1\lambda=0.1 for Lorenz, λ=0.05\lambda=0.05 for Rössler and α=10−10\alpha=10^{-10}. Columns are scaled to unit norm for the solve, while the ridge term and the threshold are applied in the original (unscaled) units, as in PySINDy.

Normal-equation formulation

Every least-squares problem in STLSQ involves sub-matrices of Θ\Theta only through the Gram matrix and the projected derivatives

G=ΘTΘ=∑i=1NθiθiT,R=ΘTX˙=∑i=1Nθix˙iT,(3)G=\Theta^{\mathsf{T}}\Theta=\sum_{i=1}^{N}\theta_{i}\theta_{i}^{\mathsf{T}},\qquad R=\Theta^{\mathsf{T}}\dot{X}=\sum_{i=1}^{N}\theta_{i}\dot{x}_{i}^{\mathsf{T}}, \tag*{(3)}

where θiT\theta_{i}^{\mathsf{T}} is the ii-th row of Θ\Theta. The restricted problem for support SS is (GSS+αI)ξS=RS,k(G_{SS}+\alpha I)\xi_{S}=R_{S,k}, an m×mm\times m system independent of NN. A single pass over the data therefore costs O(Nm2)O(Nm^{2}) operations and O(m2)O(m^{2}) memory, after which all STLSQ iterations cost O(m3)O(m^{3}). The N×mN\times m matrix Θ\Theta is never stored: rows are generated in blocks of 4096 that fit in cache. Because GG and RR are sums over rows, the formulation is naturally parallel (§ 4). Since forming GG squares the condition number of Θ\Theta [28], the code also checks the condition number of the small column-scaled Gram matrix and, above 101010^{10}, recomputes the fit from the RR factor of a blocked tall-skinny QR factorisation of [Θ∣X˙][\Theta\mid\dot{X}] [27], which can be distributed in the same way; this happens only for nearly degenerate data.

Test systems and identified models

We use the two classical chaotic benchmarks of the SINDy literature [1]. The Lorenz system [13]

x˙=σ(y−x),y˙=x(ρ−z)−y,z˙=xy−βz,(4)\dot{x}=\sigma(y-x),\qquad\dot{y}=x(\rho-z)-y,\qquad\dot{z}=xy-\beta z, \tag*{(4)}

with σ=10\sigma=10, ρ=28\rho=28, β=8/3\beta=8/3, and the Rössler system [14]

x˙=−y−z,y˙=x+ay,z˙=b+z(x−c),(5)\dot{x}=-y-z,\qquad\dot{y}=x+ay,\qquad\dot{z}=b+z(x-c), \tag*{(5)}

with a=b=0.2a=b=0.2, c=5.7c=5.7. Data are generated with a fourth-order Runge–Kutta (RK4) integrator with Δt=0.002\Delta t=0.002 (Lorenz) and Δt=0.005\Delta t=0.005 (Rössler) from random initial conditions in [−15,15]×[−20,20]×[5,40][-15,15]\times[-20,20]\times[5,40] and [−8,8]×[0,2][-8,8]\times[0,2], respectively. The data are noise-free, so that differences between back ends can only come from floating-point summation order.

Table 1 lists the coefficients identified in the library workload, from one trajectory of N=2×107N = 2 \times10^{7} samples with the fifth-order library (56 candidate terms per equation, 168 in total). In both systems SINDy retains exactly the seven true terms and sets the other 161 coefficients to zero; the largest relative error is 0.16 %. Figure 1 compares simulations of the identified and true models from the same initial condition: the Rössler trajectories are indistinguishable, and the Lorenz trajectories coincide for about seven time units before separating, as expected from the positive Lyapunov exponent of the Lorenz attractor, while both remain on the same attractor.

SystemEq.TermTrueIdentifiedRel. error
Lorenzx˙\dot{x}xx−10.0000−9.99910.009 %
x˙\dot{x}yy10.00009.99910.009 %
y˙\dot{y}xx28.000027.99200.029 %
y˙\dot{y}yy−1.0000−0.99840.156 %
y˙\dot{y}xzxz−1.0000−0.99980.022 %
z˙\dot{z}zz−2.6667−2.66640.012 %
z˙\dot{z}xyxy1.00000.99990.012 %
Rösslerx˙\dot{x}yy−1.0000−1.00000.000 %
x˙\dot{x}zz−1.0000−1.00000.004 %
y˙\dot{y}xx1.00001.00000.001 %
y˙\dot{y}yy0.20000.20000.001 %
z˙\dot{z}10.20000.20000.013 %
z˙\dot{z}zz−5.7000−5.69930.013 %
z˙\dot{z}xzxz1.00000.99990.013 %

Table 1. Table 1. True and identified coefficients (library workload, N=2×107N = 2 \times10^{7}, degree-5 library). All other 161 coefficients are exactly zero in both systems. All parallel back ends give the same values to the digits shown.

Identified versus true dynamics: Lorenz and Rössler attractors and time series

Figure 1. Identified versus true dynamics. (a, b) Attractors of the true system (solid black) and of the SINDy model (dashed red) integrated from the same initial condition. (c, d) Time series of x(t)x(t). For Lorenz the trajectories separate after t≈7t \approx7 because of sensitive dependence on initial conditions, although the identified model reproduces the attractor; for Rössler they overlap for the whole window.

Implementation and parallelization strategies

Single-source compiled kernels

All numerical steps (the RK4 integrator, finite differences, the library row θi\theta_i, accumulation of GG and RR, a pivoted Gaussian-elimination solver, the conditioning check, the QR fallback and STLSQ) are written once as plain loops in a factory build_kernels(jit) that receives the decorator to use. The same source is compiled with numba.njit(nogil=True) for the serial, prange, thread and process back ends and with PyOMP's numba.openmp.njit for OpenMP, so that every back end executes the same arithmetic and differences in run time are due to the parallelization strategy alone. Inner products are written as loops rather than BLAS calls, so that no hidden library threads distort the core count.

Two parallel decompositions

Figure 2 summarises the pipeline and the two decompositions.

SINDy pipeline, ensemble task parallelism, and library data parallelism

Figure 2. (a) SINDy pipeline in the normal-equation formulation; the orange steps dominate the cost. (b) Ensemble workload: whole identification problems are distributed among workers. (c) Library workload: row blocks of one long trajectory are distributed, private partial normal equations are reduced, and STLSQ is solved once.

  • Ensemble (task parallelism). M=960M = 960 trajectories, each with n=20 000n = 20\,000 samples, are generated from different initial conditions and identified independently with a third-order library (m=20m = 20). The work items are complete SINDy problems (integration, differentiation, library, STLSQ); workers do not communicate, and the result is MM coefficient matrices. This pattern covers parameter sweeps, threshold searches, bootstrapping (ensemble-SINDy [4]) and multi-experiment studies.

  • Library (data parallelism). A single trajectory with N=2×107N = 2 \times10^7 samples is identified with the fifth-order library (m=56m = 56). Row blocks are distributed among pp workers; worker qq accumulates private partial sums GqG_q, RqR_q over its rows, the partial sums are reduced, G=∑qGqG = \sum_q G_q, and STLSQ is solved once. Only O(pm2)O(pm^2) values are combined, independently of NN.

Back ends

Numba prange. A driver compiled with @njit(parallel=True) replaces range by prange, and Numba distributes the iterations over a pool of pp native threads. In the library workload each iteration owns one slice of the private arrays Gp[t], avoiding races without atomic operations:

@njit(parallel=True)
def ensemble_prange(system, p, x0s, dt, n, expo, thr):
    out = np.empty((x0s.shape[0], expo.shape[0], 3))
    for t in prange(x0s.shape[0]):          # one SINDy fit per iteration
        out[t] = sindy_one(system, p, x0s[t], dt, n, expo, thr)
    return out

@njit(parallel=True)
def library_prange_fit(X, dX, expo, thr, nparts):
    Gp = np.zeros((nparts, m, m)); Rp = np.zeros((nparts, m, 3))
    for t in prange(nparts):                # private partial sums
        gram_blocks(X, dX, expo, b0(t), b1(t), BLOCK, Gp[t], Rp[t])
    G = Gp.sum(axis=0); R = Rp.sum(axis=0)  # reduction
    return solve_checked(G, R, X, dX, expo, thr)  # STLSQ (+QR fallback)

OpenMP (PyOMP). PyOMP [16, 17] lowers with openmp(...) blocks to calls into the OpenMP runtime, giving Python code the semantics of C/Fortran #pragma omp directives, including explicit scheduling, thread identifiers and private data:

from numba.openmp import njit, openmp_context as openmp, omp_get_thread_num
@njit
def library_omp_fit(X, dX, expo, thr, nthreads):
    Gp = np.zeros((nthreads, m, m)); Rp = np.zeros((nthreads, m, 3))
    with openmp("parallel num_threads(nthreads)"):
        tid = omp_get_thread_num()
        with openmp("for schedule(static)"):
            for b in range(nblocks):
                library_gram_chunk(X, dX, expo, i0(b), i1(b), Gp[tid], Rp[tid])
    G = Gp.sum(axis=0); R = Rp.sum(axis=0)
    return solve_checked(G, R, X, dX, expo, thr)

The ensemble driver is a single with openmp("parallel for schedule(static)") loop over trajectories.

Python threads. Kernels compiled with nogil=True release the GIL for their whole duration, so a concurrent.futures.ThreadPoolExecutor with pp threads achieves true parallelism while the driver remains ordinary Python code. The work is split into pp contiguous chunks, so the GIL is acquired only once per chunk, and threads share all arrays.

multiprocessing. A multiprocessing.Pool of pp worker processes, each with its own interpreter, executes the serial kernel on its chunk. In the ensemble only the initial conditions are sent and the M×m×3M \times m \times3 coefficients returned. In the library workload XX and X˙\dot{X} (0.96 GB) are copied once into multiprocessing.shared_memory blocks to which the workers attach at start-up, so each task transfers two block indices and returns an m×mm \times m and an m×3m \times3 matrix.

Performance evaluation

Experimental setup

All measurements were taken on one compute node of the High-Performance Computing Center of the College of Sciences and Technology at UHD, a cluster of 32 compute nodes connected by Intel Omni-Path. The node has two Intel Xeon E5-2650 v4 processors (Broadwell, 2.2 GHz), each with 12 cores, for 24 physical cores and 48 hardware threads with Intel Hyper-Threading, and 256 GB of memory. The node was allocated exclusively through the Slurm scheduler. The software was Python 3.12.16 with NumPy 2.5.4, Numba 0.67.0, PyOMP and PySINDy 2.1.0 from conda-forge. Each configuration runs in a fresh interpreter in which the thread-pool size is fixed before Numba is imported and BLAS libraries are pinned to one thread. Each timing is the best of three repetitions after an untimed warm-up that triggers JIT compilation and starts the thread or process pools. The PySINDy reference uses the same data and settings (STLSQ(threshold=λ, alpha=1e-10), PolynomialLibrary(degree=d), FiniteDifference()) and is timed once. The worker counts p=1,2,4,8,12,16,24p = 1, 2, 4, 8, 12, 16, 24 cover one socket (p≤12p \le12) and both sockets (p=24p = 24) with one thread per physical core, and p=48p = 48 uses all hardware threads.

We report the speed-up and parallel efficiency with respect to the compiled serial code,

S(p)=Tserial/T(p),E(p)=S(p)/p,(6)S(p) = T_{\mathrm{serial}} / T(p), \qquad E(p) = S(p) / p, \tag*{(6)}

so that the gain due to compilation, reported separately, does not inflate the parallel speed-up.

Accuracy

All back ends identify the same models. In the ensemble workloads all 960 Lorenz and all 960 Rössler identifications have the correct support, with maximum coefficient errors of 8.4×10−38.4 \times10^{-3} and 2.1×10−32.1 \times10^{-3} (medians 7.9×10−37.9 \times10^{-3} and 7.2×10−47.2 \times10^{-4}); in the library workloads the maximum errors are 8.0×10−38.0 \times10^{-3} and 7.2×10−47.2 \times10^{-4}. These values agree with those of PySINDy on the same data to at least four significant digits, confirming that the normal-equation formulation with the conditioning safeguard is as accurate as the reference implementation for these problems.

Run time and scaling

Table 2 lists the timings, and Figure 3 shows the speed-up and the parallel efficiency.

Back endpL-ens TSL-lib TSR-ens TSR-lib TS
PySINDy (serial)142.410.23235.290.2142.420.23278.410.18
Numba serial19.761.0049.801.009.711.0050.001.00
Numba prange19.7461.0049.561.009.7131.0049.781.00
42.5463.8314.173.512.5103.8714.423.47
120.93710.415.6318.840.93410.405.5059.08
240.48919.962.82217.650.49319.682.85017.54
480.45521.432.59919.160.45221.472.52519.80

Table 2. Wall time TT [s] (best of three) and speed-up S=Tserial/TS = T_{\mathrm{serial}}/T on the UHD compute node (2 × Xeon E5-2650 v4, 24 cores, 48 hardware threads). Ensemble: M=960M = 960, n=20 000n = 20\,000, degree 3; library: N=2×107N = 2 \times10^{7}, degree 5. For PySINDy the SS column is Tserial/TPySINDyT_{\mathrm{serial}}/T_{\mathrm{PySINDy}}. Rows with p=24p = 24 (all physical cores) are shaded green, rows with p=48p = 48 (hyper-threading) grey. Results for p=2,8p = 2, 8 and 16 appear in Figure 3 and in the repository.

Strong scaling plots for Lorenz and Rössler ensemble and library workloads

Figure 3. Strong scaling of the four back ends on the UHD node. Top: speed-up S(p)=Tserial/T(p)S(p) = T_{\mathrm{serial}}/T(p), with ideal linear scaling dashed. Bottom: parallel efficiency E(p)=S(p)/pE(p) = S(p)/p. The worker axis is logarithmic; the shaded region (p>24p > 24) uses hyper-threads.

Back endppL-ens TSL-lib TSR-ens TSR-lib TS
PyOMP (OpenMP)110.140.9650.440.9910.130.9650.390.99
42.6573.6714.673.402.6643.6414.863.36
120.97510.015.8498.510.9769.955.6948.78
240.50919.183.10916.020.51019.053.12516.00
480.47020.772.75918.050.47120.622.74518.22
Threads (nogil)19.7531.0049.541.019.7191.0049.911.00
42.5433.8412.803.892.5163.8612.853.89
120.9829.944.83510.300.97010.014.85510.30
240.57616.932.46920.180.52618.452.46920.25
480.48020.312.37720.950.46920.732.38221.00
multiprocessing19.7741.0049.571.009.7830.9949.531.01
42.6703.6512.843.882.5853.7612.593.97
120.9809.964.88710.191.0319.424.93210.14
240.66114.752.50819.860.58516.592.50319.98
480.48220.252.38420.890.47920.272.40820.77

Table 2 (PDF p. 7).

Compilation and formulation gain. For identical results, the compiled serial code is 4.3× (Lorenz) and 4.4× (Rössler) faster than PySINDy in the ensemble workload and 4.7× and 5.6× faster in the library workload, where PySINDy takes 235 s and 278 s. In the library case the gain is partly algorithmic: PySINDy materialises the 2×107×562 \times10^{7} \times56 matrix (about 9 GB) and solves a tall least-squares problem in every STLSQ iteration, whereas the normal-equation formulation reads the data once and iterates on a 56×5656 \times56 system.

Scaling on physical cores. All back ends scale well up to the 24 physical cores. At p=12p = 12, one fully used socket, the efficiencies lie between 0.71 and 0.87; extending to the second socket at p=24p = 24 costs little further efficiency for the best back end of each workload, which shows that the cache-blocked kernels are compute-bound rather than limited by memory bandwidth or NUMA effects. On 24 cores, Numba prange reaches S=19.96S = 19.96 and 19.68 on the Lorenz and Rössler ensembles, and Python threads reach S=20.18S = 20.18 and 20.2520.25 on the library workloads. Combined with compilation, the best 24-core runs are 87×87\times and 86×86\times faster than PySINDy for the Lorenz and Rössler ensembles, and 95×95\times and 113×113\times faster for the library workloads, turning a 4.6-minute PySINDy fit into 2.5 s.

Differences between back ends. In the ensemble workload, the two compiled drivers (prange and PyOMP) perform best at p=24p = 24 (S≈19S \approx19–2020), whereas threads (S=16.93S = 16.93 and 18.4518.45) and multiprocessing (S=14.75S = 14.75 and 16.5916.59) fall behind. With a parallel run time of about 0.5 s, the fixed cost of dispatching work from Python to a thread or process pool and, for processes, of returning results through inter-process communication becomes a visible fraction of the total. In the library workload the order is reversed: threads and multiprocessing reach E(24)≈0.83E(24) \approx0.83–0.840.84, while prange and PyOMP reach 0.74 and 0.67 for Lorenz. The gap is already present at p=2p = 2 (S=1.75S = 1.75 for prange against 2.00 for threads), so it is a per-thread overhead of the runtime-managed thread pools in this kernel rather than a scalability limit. A likely cause is the allocation of the per-block Θ\Theta buffer inside the parallel region; preallocating one buffer per thread is a simple remedy that we leave for a future version. The two compiled drivers, prange and PyOMP, behave almost identically throughout, which indicates that the choice between them can be made on grounds of programming style.

Hyper-threading. Doubling the workers from 24 to 48 hardware threads improves the run time only by a factor 1.04–1.37 (S(48)=18.1S(48) = 18.1–21.521.5), and the efficiency drops to 0.38–0.45. This is not explained by Amdahl's law [22]: fitting S(24)=19.96S(24) = 19.96 gives a serial fraction f≈0.0088f \approx0.0088, which would predict S(48)≈34S(48) \approx34. Instead, the two hardware threads of a core share its floating-point units, which the dense, compute-bound kernels already keep busy. The largest hyper-threading gains occur for the back ends with the most idle time at p=24p = 24 (threads and processes in the ensemble), where the second hardware thread hides dispatch latency. For production runs on this node, 24 workers, one per physical core, are therefore the recommended setting.

Discussion and practical recommendations

  • Reformulate before parallelizing. Accumulating ΘTΘ\Theta^{\mathsf{T}}\Theta and ΘTX˙\Theta^{\mathsf{T}}\dot{X} in a single cache-blocked pass reduces memory from O(Nm)O(Nm) to O(m2)O(m^2), makes STLSQ independent of NN, exposes a simple reduction for data parallelism, and is responsible for a large part of the gain over PySINDy. The squared condition number is handled by checking the condition of the small Gram matrix and falling back to a TSQR reduction, which keeps the same parallel structure.

  • Choose the back end by task granularity. Compiled drivers (prange, PyOMP) are best for many fine-grained tasks, because they dispatch work without returning to the interpreter. Python threads with nogil kernels and multiprocessing with shared memory are equally good or better for coarse tasks with little communication, and allow the parallel driver to remain ordinary Python code. multiprocessing additionally isolates memory and is the natural stepping stone to distributed execution.

  • Use physical cores. On this node all back ends reach most of their attainable speed-up at 24 workers; hyper-threads add at most 37 % and usually much less.

  • OpenMP when explicit control matters. PyOMP exposes scheduling, data-sharing clauses and thread identifiers with familiar OpenMP semantics and performed on par with prange, which eases porting from C/Fortran. The free-threaded CPython builds enabled by PEP 703 [21] may further simplify thread-based pipelines.

Limitations and future work. The experiments use noise-free data, polynomial libraries and a single shared-memory node. Noisy data require smoothing or weak formulations [8] and ensemble statistics [4], which increase the number of fits and therefore benefit directly from the ensemble strategy. Because both decompositions reduce to independent tasks or to a sum of small matrices, they extend naturally to distributed memory with MPI (a reduction of m×mm \times m Gram matrices or R^\hat{R} factors across nodes), which we plan to evaluate across the 32 nodes of the UHD cluster, together with GPU back ends.

Conclusions

We presented a parallel implementation of SINDy in Python in which a single set of compiled kernels is executed by Numba prange, PyOMP, GIL-free threads and multiprocessing. For the Lorenz and Rössler systems all back ends recover the exact governing equations in every one of the 960 ensemble identifications and in the 2×1072 \times10^{7}-sample library fits, with errors identical to PySINDy. On a 24-core compute node of the UHD HPC cluster, the normal-equation formulation and JIT compilation make the serial code 4.34.3–5.6×5.6 \times faster than PySINDy, and parallel execution adds speed-ups of up to 20.3 on the physical cores, for an overall reduction of the time to solution of up to 117×117 \times. Task-parallel ensembles and data-parallel library construction thus make SINDy studies with large data sets, rich libraries and many repeated fits practical on a single multicore node.

Acknowledgments

The author gratefully acknowledges the High-Performance Computing Center of the College of Sciences and Technology at the University of Houston–Downtown, operated by UHD Information Technology, for providing the computational resources used in this work. The UHD HPC cluster was established with support from a grant of the U.S. Department of Defense.

Code availability

The source code (kernels, back ends, benchmark driver, Slurm job script, raw results in JSON format and the scripts that generate every figure and table of this paper) is available at https://github.com/guillenrondonp/parallel-sindy under an open-source license. The benchmark is reproduced with python benchmark.py --cores 1 2 4 8 12 16 24 48 --M 960 --N 20000000.

References

References

  1. [1]S. L. Brunton, J. L. Proctor, J. N. Kutz, “Discovering governing equations from data by sparse identification of nonlinear dynamical systems,” Proc. Natl. Acad. Sci. USA, 113(15):3932–3937, 2016. doi:10.1073/pnas.1517384113. arXiv:1509.03580.DOI
  2. [2]B. M. de Silva, K. Champion, M. Quade, J.-C. Loiseau, J. N. Kutz, S. L. Brunton, “PySINDy: A Python package for the sparse identification of nonlinear dynamical systems from data,” J. Open Source Softw., 5(49):2104, 2020. doi:10.21105/joss.02104.
  3. [3]A. A. Kaptanoglu, B. M. de Silva, U. Fasel, K. Kaheman, A. J. Goldschmidt, J. L. Callaham, C. B. Delahunt, Z. G. Nicolaou, K. Champion, J.-C. Loiseau, J. N. Kutz, S. L. Brunton, “PySINDy: A comprehensive Python package for robust sparse system identification,” J. Open Source Softw., 7(69):3994, 2022. doi:10.21105/joss.03994.DOI
  4. [4]U. Fasel, J. N. Kutz, B. W. Brunton, S. L. Brunton, “Ensemble-SINDy: Robust sparse model discovery in the low-data, high-noise limit, with active learning and control,” Proc. R. Soc. A, 478:20210904, 2022. doi:10.1098/rspa.2021.0904.DOI
  5. [5]S. H. Rudy, S. L. Brunton, J. L. Proctor, J. N. Kutz, “Data-driven discovery of partial differential equations,” Sci. Adv., 3(4):e1602614, 2017.
  6. [6]K. Champion, B. Lusch, J. N. Kutz, S. L. Brunton, “Data-driven discovery of coordinates and governing equations,” Proc. Natl. Acad. Sci. USA, 116(45):22445–22451, 2019.DOI
  7. [7]K. Kaheman, J. N. Kutz, S. L. Brunton, “SINDy-PI: A robust algorithm for parallel implicit sparse identification of nonlinear dynamics,” Proc. R. Soc. A, 476:20200279, 2020.
  8. [8]D. A. Messenger, D. M. Bortz, “Weak SINDy: Galerkin-based data-driven model selection,” Multiscale Model. Simul., 19(3):1474–1497, 2021.DOI
  9. [9]S. L. Brunton, J. N. Kutz, Data-Driven Science and Engineering: Machine Learning, Dynamical Systems, and Control, 2nd ed., Cambridge University Press, 2022.
  10. [10]J. Stevens-Haas, Open-Source Dynamical Systems Research, with a Side of (Francis) Bacon, Ph.D. thesis, University of Washington, 2025.
  11. [11]Y. C. Li, A. Larrañaga, S. L. Brunton, U. Fasel, “An introduction to sparse identification of nonlinear dynamics for engineering applications,” arXiv:2607.15077, 2026.arxiv.org/abs/2607.15077
  12. [12]K. Ding, “A robust SINDy autoencoder for noisy dynamical system identification,” arXiv:2604.04829, 2026.arxiv.org/abs/2604.04829
  13. [13]E. N. Lorenz, “Deterministic nonperiodic flow,” J. Atmos. Sci., 20(2):130–141, 1963.
  14. [14]O. E. Rössler, “An equation for continuous chaos,” J. Phys. Lett. A, 57(5):397–398, 1976.DOI
  15. [15]S. K. Lam, A. Pitrou, S. Seibert, “Numba: A LLVM-based Python JIT compiler,” in Proc. 2nd Workshop on the LLVM Compiler Infrastructure in HPC, ACM, 2015. doi:10.1145/2833157.2833162.DOI
  16. [16]T. Anderson, T. Mattson, “Multithreaded parallel Python through OpenMP support in Numba,” in Proc. 20th Python in Science Conf. (SciPy 2021), pp. 140–147, 2021. doi:10.25080/majora-1b6fd038-012.DOI
  17. [17]G. Georgakoudis, T. A. Anderson, S. Archibald, B. de Supinski, T. G. Mattson, “PyOMP: Parallel programming for CPUs and GPUs with OpenMP and Python,” SC24 Workshop on High Performance Python for Science at Scale (HPPSS), 2024.DOI
  18. [18]C. A. Piñeiro Pomar, J. C. Pichel Campos, “OMP4Py: A pure Python implementation of OpenMP,” Future Gener. Comput. Syst., 175:108035, 2025. doi:10.1016/j.future.2025.108035.
  19. [19]L. Dagum, R. Menon, “OpenMP: An industry standard API for shared-memory programming,” IEEE Comput. Sci. Eng., 5(1):46–55, 1998.DOI
  20. [21]S. Gross, “PEP 703 – Making the Global Interpreter Lock optional in CPython,” Python Enhancement Proposals, 2023.
  21. [22]G. M. Amdahl, “Validity of the single processor approach to achieving large scale computing capabilities,” in AFIPS Conf. Proc., 30:483–485, 1967.DOI
  22. [26]L. Zhang, H. Schaeffer, “On the convergence of the SINDy algorithm,” Multiscale Model. Simul., 17(3):948–972, 2019.arxiv.org/abs/1805.06445
  23. [27]J. Demmel, L. Grigori, M. Hoemmen, J. Langou, “Communication-optimal parallel and sequential QR and LU factorizations,” SIAM J. Sci. Comput., 34(1):A206–A239, 2012.
  24. [28]G. H. Golub, C. F. Van Loan, Matrix Computations, 4th ed., Johns Hopkins University Press, 2013.

Paper details

vlnc:W_5mrbqvhb
v1 · latestHistory (1)

published · Fresh — added to the Hub within the last 30 days

added ·

Usage

views · 0

downloads · 0

A view counts once per browser per day, a download each time the PDF is requested.

CC BY 4.0

Original source

  • Computer Science

Not recorded for this paper · View statement →

Looking for related papers…
Contents