Skip to content

Fast and exact multidimensional cubic/quintic interp - #11708

Open
headtr1ck wants to merge 5 commits into
pydata:mainfrom
headtr1ck:interp-cubic-separable
Open

headtr1ck wants to merge 5 commits into
pydata:mainfrom
headtr1ck:interp-cubic-separable

Conversation

@headtr1ck

@headtr1ck headtr1ck commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Multidimensional interp with method="cubic" / "quintic" used scipy.interpolate.interpn, which builds the collocation matrix of the whole grid and solves it with an iterative sparse solver (gcrotmk, atol=1e-6). That is slow, needs a lot of memory and is only accurate to ~1e-6 (~1e-5 for quintic).

AI Disclosure

  • This PR contains AI-generated content.
    • I have tested any AI-generated content in my PR.
    • I take responsibility for any AI-generated content in my PR.
    • Tools: Claude Code with Opus 5.5

🤖 Generated with Claude Code

headtr1ck and others added 3 commits October 11, 2026 21:13
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
@headtr1ck headtr1ck added the run-benchmark Run the ASV benchmark workflow label Oct 11, 2026
@headtr1ck

Copy link
Copy Markdown
Collaborator Author

Some background on why this works and why scipy doesn't do it already:

Why solving one dimension at a time is exact. On a regular grid, the values are interpolated at every combination of grid points. So the collocation matrix of the n-D spline is the Kronecker product of the 1-D collocation matrices, A = A_t ⊗ A_y ⊗ A_x, and solving A c = v is the same as solving with each 1-D matrix along its own axis. That is what calling make_interp_spline once per dimension does. Each 1-D matrix is small and banded, so this is fast, needs only a few copies of the data, and is exact. The result matches scipy with a direct solver (RegularGridInterpolator(..., solver=spsolve)) to ~1e-15.

What scipy does instead. RegularGridInterpolator / interpn build the spline with make_ndbspl (since scipy 1.13). It lists all grid points with itertools.product, builds the full sparse collocation matrix (64 nonzeros per grid point for 3-D cubic, which is where the ~2 GB in #11342 comes from), and solves it with gcrotmk at atol=1e-6. That is also why its results are only accurate to ~1e-6 (~1e-5 for quintic), and why test_interpolate_nd now needs an exact reference. make_ndbspl just treats this as a general sparse problem and doesn't take advantage of the Kronecker structure.

Downsides. None that I can see for interp:

  • It needs the data on a full grid, which is always the case for interpn and interp.
  • The boundary conditions are the same (not-a-knot), so the spline is identical.
  • NaNs in the data still raise, like before.
  • bounds_error, fill_value and NaN destinations behave like interpn.
  • The only thing given up is passing a custom solver to scipy, which interpn doesn't accept anyway, so it was never possible through interp.

This could be fixed in scipy too, which would help other RegularGridInterpolator users.

@headtr1ck
headtr1ck marked this pull request as ready for review October 11, 2026 19:24
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

🏎️ New personal best! Some benchmarks got better.

Change Before [78199ec] After [454712d] Ratio Benchmark (Parameter)
🚀 7.33±0.01ms 2.66±0.02ms 0.36 dataset_io.IOReadSingleFile.time_read_dataset('scipy', None)
🚀 262±3ms 12.2±0.08ms 0.05 interp.InterpolationSplineND.time_interpolation_spline_nd('cubic', 'grid')
🚀 245±20ms 7.54±0.02ms 0.03 interp.InterpolationSplineND.time_interpolation_spline_nd('cubic', 'point')
🚀 251±10ms 7.69±0.1ms 0.03 interp.InterpolationSplineND.time_interpolation_spline_nd('cubic', 'pointwise')
🚀 2.92±0.02s 18.6±0.03ms 0.01 interp.InterpolationSplineND.time_interpolation_spline_nd('quintic', 'grid')
🚀 2.91±0s 7.75±0.02ms 0 interp.InterpolationSplineND.time_interpolation_spline_nd('quintic', 'point')
🚀 2.86±0.01s 8.71±0.8ms 0 interp.InterpolationSplineND.time_interpolation_spline_nd('quintic', 'pointwise')
🚀 244±20ms 19.0±1ms 0.08 interp.InterpolationSplineNDDask.time_interpolation_spline_nd('cubic', 'grid')
🚀 240±20ms 13.9±0.9ms 0.06 interp.InterpolationSplineNDDask.time_interpolation_spline_nd('cubic', 'point')
🚀 242±20ms 14.3±0.9ms 0.06 interp.InterpolationSplineNDDask.time_interpolation_spline_nd('cubic', 'pointwise')
🚀 2.87±0.03s 25.4±0.8ms 0.01 interp.InterpolationSplineNDDask.time_interpolation_spline_nd('quintic', 'grid')
🚀 2.85±0.03s 14.3±1ms 0.01 interp.InterpolationSplineNDDask.time_interpolation_spline_nd('quintic', 'point')
🚀 2.85±0.04s 14.6±1ms 0.01 interp.InterpolationSplineNDDask.time_interpolation_spline_nd('quintic', 'pointwise')

🚀 better · 🐌 worse · 💥 now failing · 🩹 fixed · 🤷 not comparable

Comparing 454712dd (this PR merged into the base branch) with 78199ecb (base branch).
Only changes by more than a factor of 1.5 are shown, see the workflow run for all results.

headtr1ck and others added 2 commits October 12, 2026 09:31
spsolve on the 100x30x10 test grid takes minutes for quintic and timed
out on CI; gcrotmk with rtol=1e-12 is accurate to ~1e-13 in under a second.

Co-authored-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scipy-stubs types the RegularGridInterpolator solver as returning an array
and solver_args as a tuple, but scipy calls iterative solvers as
x, info = solver(A, b, **solver_args). Pass them via a dict[str, Any].

Co-authored-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@headtr1ck

Copy link
Copy Markdown
Collaborator Author

I opened an upstream issue for that. Maybe it's better to fix this at the source instead of ugly custom builds here. Thoughts?

@dcherian dcherian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the whole, I think this is worth it. We maintain vendored functions for efficiency and backcompat already. It would definitely be good to upstream. In the meantime, we could stick these functions in scipy_compat.py for example, following current code conventions.

I'll leave it to your judgement.

If you'd like to merge, please add some property tests comparing the output to scipy for random inputs. Another property could be the interp of a 1D vector shoul dmatch the interp of that vector broadcasted out to nD.

@headtr1ck

Copy link
Copy Markdown
Collaborator Author

It seems that scipy is very interested in getting this patch. I might work on that, so maybe this PR is not necessary. I don't think we really need a compatiblity layer in that case. If you want it faster, update your scipy.

@dcherian

Copy link
Copy Markdown
Contributor

Great. As an aside, I loved that thread. Great example of how LLMs can help out OSS!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-benchmark Run the ASV benchmark workflow topic-performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

xarray interp with method="cubic" fails for time/lon/lat interpolation

2 participants