Skip to content

Add pluggable backend dispatch system - #1151

Draft
Intron7 wants to merge 8 commits into
mainfrom
add-backend-to-squidpy
Draft

Add pluggable backend dispatch system#1151
Intron7 wants to merge 8 commits into
mainfrom
add-backend-to-squidpy

Conversation

@Intron7

@Intron7 Intron7 commented Apr 7, 2026

Copy link
Copy Markdown
Member

Summary

  • Adopt scverse-backends>=0.0.3 for Squidpy backend dispatch.
  • Align the trusted RAPIDS backend identity with Scanpy: canonical rapids-singlecell, with cuda, rapids, and rapids_singlecell aliases.
  • Dispatch the four Squidpy graph functions implemented by RAPIDS SingleCell: spatial_autocorr, co_occurrence, ligrec, and calculate_niche.
  • Preserve the historical joblib backend usage for spatial_autocorr, co_occurrence, and ligrec through explicit compatibility handling.
  • Use scverse_backends.testing.run_conformance with Squidpy-owned CPU-vs-backend checks.

Backend Contract

Backends register an adapter under the squidpy.backends entry-point group. The companion RAPIDS SingleCell change supplies that adapter; this host PR keeps the dispatcher configuration and aliases identical to the Scanpy integration.

Verification

  • uv run --group test pytest -q tests/test_backends.py
  • uv run --group test pytest -q tests/graph/test_ppatterns.py
  • uv run --group test pytest -q tests/graph/test_ligrec.py -k 'parallel_backend or legacy_parallel_backend'
  • uv run ruff check ...

@Intron7
Intron7 requested review from flying-sheep, selmanozleyen and timtreis and removed request for flying-sheep and selmanozleyen April 7, 2026 10:02
@codecov

codecov Bot commented Apr 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.58974% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.42%. Comparing base (ca07e64) to head (6549a80).

Files with missing lines Patch % Lines
src/squidpy/testing/backend_conformance.py 93.33% 1 Missing and 2 partials ⚠️
src/squidpy/_utils.py 90.47% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1151      +/-   ##
==========================================
+ Coverage   77.24%   77.42%   +0.18%     
==========================================
  Files          63       64       +1     
  Lines        9378     9455      +77     
  Branches     1579     1585       +6     
==========================================
+ Hits         7244     7321      +77     
  Misses       1532     1532              
  Partials      602      602              
Files with missing lines Coverage Δ
src/squidpy/gr/_ligrec.py 78.61% <100.00%> (+0.28%) ⬆️
src/squidpy/gr/_niche.py 37.88% <100.00%> (+0.34%) ⬆️
src/squidpy/gr/_ppatterns.py 81.25% <100.00%> (+1.25%) ⬆️
src/squidpy/_utils.py 66.80% <90.47%> (+3.67%) ⬆️
src/squidpy/testing/backend_conformance.py 93.33% <93.33%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/squidpy/_backends/_settings.py Outdated
# Trusted but not installed
if canonical in TRUSTED_BACKENDS and get_backend(canonical) is None:
package = TRUSTED_BACKENDS[canonical]["package"]
raise ValueError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should be ImportError

Comment thread src/squidpy/_backends/_registry.py Outdated
# conformance test suite (squidpy.testing.backend_conformance).
TRUSTED_BACKENDS: dict[str, dict[str, Any]] = {
"rapids_singlecell": {
"aliases": ["rapids-singlecell", "rsc", "cuda", "gpu"],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why have multiple? maybe just have a error message helper that recognizes them and does “backend 'gpu' does not exist, did you mean 'rapids-singlecell'?”

Comment thread src/squidpy/_backends/_dispatch.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

didn’t @selmanozleyen already build something like this? this looks like it’s similar code, so if the other version is merged, this should be unified with that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@selmanozleyen approach is similar but less extensible. It's still a draft PR like this one. This approach would immediately open the door for other backends. With 0 updates needed in squidpy.

Comment thread src/squidpy/_backends/_dispatch.py Outdated
return wrapper


def _update_signatures() -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This private function is not used in this file but it is exported. Therefore it shouldn't be private. The module _dispatch is already private so it is fine to remove the _ here

Comment thread src/squidpy/_backends/_dispatch.py Outdated
def _get_param_sets(
func: Callable,
adapter_method: Callable,
func_name: str,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is unused

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

to be more clear func_name is not being used here

@selmanozleyen

Copy link
Copy Markdown
Member

compared to #1093 I agree that renaming it to backend rather than device is way better.

However, currently there is a blocker because the backend argument is reserved for the parallelize backend. Which is also one more motivation to get rid of it. To fix it quickly we can basically rename it to parallelize_backend and give warnings until we hit a good enough version and introduce backend again which doesn't sound ideal but it can be ok with sq 2.0

Comment thread src/squidpy/_backends/_dispatch.py Outdated
Comment on lines +273 to +275
Called once automatically after backend discovery so that ``help()`` /
IDE tooltips show the full parameter list (CPU + GPU + backend) with
documentation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not sure about dynamically updating the signature and docstrings. I like the approach with just linking to the dispatched backend better in :#1093

@flying-sheep @ilan-gold wdyt? For example if I compiled the docs with gpu will it compile a mix of rsc and squidpy docs?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would ideally want to expose everything, even if dispatched to another function in the public function signature

Comment thread src/squidpy/_backends/_registry.py Outdated
_update_signatures()


def _check_trusted(name: str) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

again, should be check_trusted

@Zethson

Zethson commented Apr 8, 2026

Copy link
Copy Markdown
Member

I agree that renaming it to backend rather than device is way better.

I think device has the advantage that the actual (CUDA) device could be specified -> like a specific GPU number. This would be weird to pass to backend, right?

But I think the right answer is to do what the rest of the pydata ecosystem does and just follow these patterns. I don't see any reason for us to stick out.

Generally, I hope that a generalized version of this could be implemented into https://github.com/scverse/scverse-misc so that it would eventually be reused across all of our packages trivially.

Edit: I just read on zulip:

My plan is to longterm move this over into a dedicated scverse_backends package that every package can import

Yeah SGTM but maybe scverse-misc could also be the place.

@selmanozleyen

Copy link
Copy Markdown
Member

I think device has the advantage that the actual (CUDA) device could be specified -> like a specific GPU number. This would be weird to pass to backend, right?

But this way we can leave it to the user to either set the context with whatever backend of their choice. If we had jax backend for example we could do it either in cpu or gpu.

Comment thread src/squidpy/_backends/_dispatch.py Outdated
effective = local_backend or settings.backend

if effective == "cpu":
return func(*args, **kwargs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This has a subtle bug if we want to generalize. We only bind *args by order and don't ever check if the order and the names match.

For example:

CPU: func(a, b)
GPU: func(b, a)
and both a and b are integers:

func(5, 10, backend="gpu")
becomes:

GPU sees b=5, a=10
If the backend computes something like a / b, you silently get 10 / 5 instead of 5 / 10. No exception, just incorrect output.

or imagine this

if you have
CPU: threshold, n_perms
GPU: n_perms, threshold

these won't error at the level they should. Maybe it will say n_perms should be an integer inside the function but it shouldn't have been dispatchable to begin with.

@selmanozleyen

Copy link
Copy Markdown
Member

@Zethson We should also document the contract here. There are lots of assumptions hidden in this PR if we want to generalize.

For example currently the dispatch is position based for args, see the review I did, if it's not addressed and generalized it will introduce subtle bugs.

To expose these assumptions ideally we should have a dispatchable(f_base, f_backend_impl) that checks if can be dispatchable (not only true false but also with a state: throws warning during runtime). This doesn't have to be written in code but I think it's easier to formulate it this way for me.

For two functions f_base(*base_args, **base_kwargs), f_backend_impl(*backend_args, **backend_kwargs) to be dispatchable:

  • can have base_args that aren't in backend_args which we can call base_only_args. These are dropped:
    • silently if their defaults are equal
    • with warning if their defaults don't match
  • same for base_only_kwargs we have two options.
  • currently we can similarly have backend_only_args but we should think about this more, current behaviour is we can't document these in the base implementation therefore we silently update the signature of the base function for them to be included. This is a big redflag for me. I will write some of my suggestions to fix this below
  • the intersection by arg name of base_args and backend_args should be in same order i.e., (this isn't currently done but I assume it will be fixed)
shared = [name for name in base_args if name in backend_arg_names]
backend_shared = [name for name in backend_args if name in base_arg_names]
# we want
shared == backend_shared

These are the current terms for the contract. But instead of update-the-signature trick we can disallow backend_only_args to exist and only support backend_only_kwargs. Then we can have in the base signature backend_kwargs inf_base(...,backend_kwargs). We can update then documentation of backend_kwargs dynamically that might explain what these kwargs do but we won't be changing our function signatures this way.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants