In optimisation and integration the Python callback often dominates runtime, and knowing the internals lets you remove it.
SciPy's heavy lifting runs in compiled Fortran, C and C++ (LAPACK, QUADPACK, HiGHS, PROPACK and others), but routines like quad, minimize and solve_ivp call your Python function repeatedly. With cheap integrands the interpreter overhead of those calls dominates.
Ways to cut it:
- Vectorise callbacks:
solve_ivp(..., vectorized=True),differential_evolution(..., vectorized=True),stats.bootstrapwith anaxis-aware statistic,quad_vecinstead of looping overquad. One call then handles many points. - Parallel workers:
differential_evolution,scipy.fft,cKDTree.queryand others takeworkers=(processes or threads, or a map-like callable). - LowLevelCallable:
quad,ndimage.generic_filterand a few others accept a compiled C function (from ctypes, cffi or Numbacfunc) wrapped inscipy.LowLevelCallable, skipping Python entirely. - Jacobians: analytic
jacforminimize/root, and a sparsejac_sparsityforleast_squaresand stiffsolve_ivpmethods, reduce function calls dramatically on large problems.
For linear algebra, scipy.linalg.get_blas_funcs and get_lapack_funcs return the raw routine for a dtype, and options like overwrite_a=True and check_finite=False skip copies and checks in hot loops (only when the inputs are known to be clean).
Thread counts of the underlying BLAS are controlled by environment variables or threadpoolctl. Many SciPy functions also support the Python array API standard, accepting CuPy or PyTorch arrays when SCIPY_ARRAY_API=1 is set.
Example
import numpy as np
from scipy import integrate, linalg
a = np.linspace(1, 5, 200)
calls = 0
def f(x, a):
global calls
calls += 1
return np.exp(-a * x ** 2)
loop = [integrate.quad(f, 0, 1, args=(ai,))[0] for ai in a] # 200 separate integrals
print(calls) # 4200 Python calls
calls = 0
vec, err = integrate.quad_vec(lambda x: f(x, a), 0, 1) # one vector-valued integral
print(calls, np.allclose(loop, vec)) # 63 True
A = np.random.default_rng(0).normal(size=(200, 200))
gesv, = linalg.get_lapack_funcs(("gesv",), (A,))
lu, piv, x, info = gesv(A, np.ones(200)) # raw LAPACK call
print(info, np.allclose(A @ x, 1)) # 0 TrueRemember
- Python callbacks often dominate; vectorise them or use LowLevelCallable
- Use
workers=and analytic or sparse Jacobians on large problems check_finite=Falseandoverwrite_a=Truecut overhead in hot loops
Watch out for
- Parallelising with many processes while BLAS also uses all cores, causing oversubscription.