Skip to content

Add pluggable backend dispatch system - #1151

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

Intron7 wants to merge 9 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.

selmanozleyen added a commit that referenced this pull request Sep 10, 2026
Suggestion on top of #1284. Three bug fixes, one behaviour revert, and a
restructuring that makes each niche flavor one call of a shared primitive.

Bug fixes
- The shell filter compared path *counts* against the visited set, so whether a
  pair was excluded depended on how many paths joined it -- grst's "not accounted
  for node degrees" in #1277. Worth noting the original CellCharter does not have
  this: its `_aggregate_neighbors` opens with `adj = adj.astype(bool)`, which makes
  the matmul a reachability test and `>` the logical and-not its comment claims.
  The port dropped that line. #1284's `frontier.data[:] = 1` fixes the unweighted
  case; on a weighted graph `1 > 0.5` still let a visited pair back in.
- `n_hop_weights` shorter than `distance` logged an error and then raised
  `UnboundLocalError` on the unassigned `weights`.
- The `calculate_niche` deprecation moved to `logg.warning`, which cannot be
  filtered, caught, or asserted on. Every other deprecation in squidpy uses
  `FutureWarning`, `_docs.py` documents that, and three test modules rely on it.

Reverted
- `_NhoodProfileEmbedder` was switched from matrix powers to disjoint shells while
  standardising on one hop helper. The two are not interchangeable: shells suit
  `concat`, where overlapping blocks would restate each other, and powers suit the
  weighted `sum`, where multiplicity *is* the distance weighting. `hop_mode` now
  carries both, defaulting per use, and `neighborhood` is bit-identical to main
  again at `distance > 1`.

Restructuring
- Hops now come from a numba breadth-first search. Each observation is reached once
  at its shortest distance, so the shells are disjoint by construction -- there is no
  visited set to subtract, and so no way for degree to leak in. 1.4-4.1x faster
  (1.06s -> 0.39s at 200k cells, 4 hops).
- `nhood_aggregate` is the primitive all three flavors call, verified bit-identical
  to the embedders it replaces across 8 configurations. Unexposed: not in
  `gr/__init__.py` or `docs/api.md`.
- Embedders are functions rather than classes; the `_NicheEmbedder` ABC is gone.
  A function is something backend dispatch can attach to, which a third-party
  subclass is not (#1151).
- Sparse features stay sparse, as upstream CellCharter does, so the concatenated
  matrix -- `hops` times the width of the features -- is not densified before PCA.
- `Array | CSBase` from fast_array_utils instead of `NDArrayA`, widening the
  embedding contract off numpy (#1245) while keeping the sparse half honest.

Tests go 21 -> 46 in test_niche.py; every fix has one that fails without it.
selmanozleyen added a commit that referenced this pull request Sep 10, 2026
Suggestion on top of #1284.

Fixes
- Shell exclusion compared path counts, not sets, so it depended on node degree
  (#1277). CellCharter does not have this: `_aggregate_neighbors` starts with
  `adj = adj.astype(bool)`, which makes `>` the logical and-not its comment claims,
  and the port dropped that line. `frontier.data[:] = 1` fixes only the unweighted
  case -- on a weighted graph `1 > 0.5` still readmits a visited pair.
- Short `n_hop_weights` raised `UnboundLocalError` instead of failing cleanly.
- The `calculate_niche` deprecation became `logg.warning`, which cannot be filtered,
  caught or asserted on; every other deprecation here uses `FutureWarning`.

Revert
- `neighborhood` was switched from matrix powers to disjoint shells. The two are not
  interchangeable: rings suit concat, where blocks must not restate each other, and
  powers suit the weighted sum, where multiplicity is the distance weighting. It is
  bit-identical to main again at distance > 1.

Restructure
- A numba breadth-first search reaches each cell once at its shortest distance, so
  the rings are disjoint by construction and there is no visited set to subtract.
  1.06s -> 0.39s at 200k cells, 4 hops.
- `nhood_aggregate`: one primitive behind all three flavors, bit-identical to the
  embedders it replaces across 8 configurations. Unexposed.
- Embedders are functions, not classes, and the ABC is gone. Dispatch attaches to
  functions the library owns, not to third-party subclasses (#1151).
- Sparse in, sparse out, as upstream does -- the concatenation, which is `hops` times
  the width of the features, is no longer densified before PCA.
- `Array | CSBase` from fast_array_utils instead of `NDArrayA` (#1245).

test_niche.py goes 21 -> 46; every fix has a test that fails without it.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants