Skip to content

perf: numbafy spatial_autocorr permutations - #1303

Open
selmanozleyen wants to merge 54 commits into
scverse:mainfrom
selmanozleyen:perf/numbafy-spatial-autocorr
Open

selmanozleyen wants to merge 54 commits into
scverse:mainfrom
selmanozleyen:perf/numbafy-spatial-autocorr

Conversation

@selmanozleyen

@selmanozleyen selmanozleyen commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

my notes:

  • added humanfriendly bc annbatch also uses it
  • now we have working_memory which will also be used in other functions later
  • this remove test_parallelize bc the only consumer is the soon to be deprecated ImageContainer

disclaimer AI report:

spatial_autocorr's permutations run in one numba kernel (njit(nogil=True), with features across a thread pool) instead of a loky pool. Both statistics sum, over the rows of g[perm, :], a per-row quantity the permutation only reindexes, so it is accumulated once and each permutation costs O(nnz in the feature) instead of O(nnz).

  • 85-91x end to end on real data (table below)
  • pval_sim and the FDR columns are bit-identical; var_sim and pval_z_sim agree to ~1e-11 relative on Moran's I, whose expectation sits near zero (float reordering)
  • the output no longer depends on n_jobs, which now sizes the thread pool; backend is deprecated like in co_occurrence and nhood_enrichment
  • sparse vals are converted to CSR once, since row slicing CSC is O(nnz)
  • both statistics also split into a term touching only the feature's non-zero cells and a term the permutation leaves invariant, so a feature is never densified and Geary's wx2 term leaves the loop entirely
  • the permutation scores are only ever reduced along the permutation axis, so a tally and shifted mean/variance replace the (n_perms, n_features) matrix

Follow up:

  • once fast-array-utils exposes some of these metrics as inlinable functions, we can dedup against it (also nogil=true)
  • after this chain, deprecating ImageContainer removes the last use of parallelize

Benchmarks

Real datasets, full sq.gr.spatial_autocorr, exclusive HMGU nodes, main vs this branch. pval_sim is bit-identical in every row.

dataset cells genes density n_perms stat main this PR speedup
Xenium breast rep1 167,780 313 20.2% 100 Geary 97.7s 1.1s 86.6x
Xenium breast rep1 167,780 313 20.2% 100 Moran 100.5s 1.1s 90.9x
MERFISH 73,655 161 39.3% 100 Geary 25.5s 0.3s 85x
MERFISH 73,655 161 39.3% 100 Moran 21.8s 0.2s 109x
MERFISH 73,655 161 39.3% 1000 Geary 69.9s 1.9s 37x
MERFISH 73,655 161 39.3% 1000 Moran 69.7s 1.3s 54x

Xenium on 32 cores, MERFISH on 90. At 1000 permutations this branch still finishes ~13x faster than main does at 100, so finer FDR resolution is affordable.

Scaling, on MERFISH tiled by offsetting copies (so the graph stays a real neighbourhood graph), against the first two commits of this PR rather than main:

cells Geary Moran
167,780 3.33x 3.31x
671,120 3.70x 3.04x
1,510,020 5.35x 3.26x

Permutations are drawn in blocks so memory does not track n_perms; splitting costs ~3% at 1M cells and ~10% at 10M, and at two blocks it measured faster than a single buffer. 100M cells x 161 genes runs in ~96 GB.

@selmanozleyen
selmanozleyen force-pushed the perf/numbafy-spatial-autocorr branch 3 times, most recently from 4667f60 to bca5e96 Compare October 1, 2026 12:15
Ruff's PLR0917 (preview, selected explicitly) now flags any function with
more than five positional arguments.

- Public functions take only their data object positionally; everything
  after it is keyword-only. Released functions keep accepting the old
  positional calls through legacy-api-wrap, with a FutureWarning naming
  the arguments. Experimental functions don't: that API promises no
  stability.
- Private functions change only where the rule flags them: arguments with a
  default become keyword-only, and helpers whose arguments are all required
  keep only their first argument positional. Numba kernels are exempted,
  since numba needs positional arguments.
- Left as on main: the deprecated calculate_niche and its validator (removed
  in v1.9.0), and ImageContainer until it is deprecated.
- Internal and test callers pass the moved arguments by name. Tests are
  exempted from the rule, since pytest passes fixtures by name.
Preview mode also caught an undefined apply_stain_normalization in
squidpy.experimental.im.__all__, removed here.

Also documents these rules and the rng conventions in the contribution
guide, and drops the unused docs/release/notes-dev.md: release notes come
from the PR changelog.
@selmanozleyen
selmanozleyen force-pushed the perf/numbafy-spatial-autocorr branch from bca5e96 to 02ab58a Compare October 1, 2026 12:22
One njit(parallel=True, nogil=True) kernel over the permutations replaces the
loky pool. Both statistics sum, over the rows of g[perm, :], a per-row quantity
the permutation only reindexes, so accumulating it once leaves O(n_cells) per
permutation instead of O(nnz): 21-34x end to end. pval_sim, var_sim and the FDR
columns are bit-identical; the continuous columns agree to <=5e-10 relative,
which is the float reordering scanpy's own kernels already have across thread
counts. Reductions are explicit serial loops so the output no longer depends on
n_jobs. Sparse vals arrive as CSC, whose row slicing is O(nnz); one tocsr saves
7.9s of a 19s call at 40k cells x 2000 genes.

backend is deprecated as co_occurrence and nhood_enrichment deprecate it, and
n_jobs now caps the numba thread pool.
@selmanozleyen
selmanozleyen force-pushed the perf/numbafy-spatial-autocorr branch from 02ab58a to b97a1f3 Compare October 5, 2026 12:26
@timtreis
timtreis deleted the branch scverse:main October 6, 2026 01:47
@timtreis timtreis closed this Oct 6, 2026
@timtreis timtreis reopened this Oct 6, 2026
@timtreis
timtreis changed the base branch from chore/kwonly-args to main October 6, 2026 12:11
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 61.90476% with 64 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.78%. Comparing base (dc82c73) to head (5ffa64a).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
src/squidpy/gr/_ppatterns.py 58.70% 63 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1303      +/-   ##
==========================================
- Coverage   78.95%   78.78%   -0.18%     
==========================================
  Files          63       63              
  Lines        9439     9574     +135     
  Branches     1574     1596      +22     
==========================================
+ Hits         7453     7543      +90     
- Misses       1469     1521      +52     
+ Partials      517      510       -7     
Files with missing lines Coverage Δ
src/squidpy/_docs.py 95.50% <100.00%> (+0.05%) ⬆️
src/squidpy/_utils.py 71.48% <100.00%> (+1.36%) ⬆️
src/squidpy/gr/_ppatterns.py 70.55% <58.70%> (-10.96%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

selmanozleyen and others added 3 commits October 9, 2026 17:58
The permutation kernel indexes indptr/indices/data as CSR, and
sklearn's row-normalize silently no-ops on a CSC graph. tocsr(copy=True)
keeps the copy normalize(copy=False) needs.
Each k owns its output slot and sums its own row in index order, so
prange does not reorder any accumulation. Bit-identical; worth 1.4-1.7x
once the permutations are blocked and the serial precompute repeats.
@selmanozleyen
selmanozleyen marked this pull request as draft October 9, 2026 16:42
Both statistics split into a term that only touches a feature's non-zero
cells and a term the permutation leaves invariant, so a permutation costs
O(nnz_feature) rather than O(n_cells) and Geary's wx2 term leaves the loop
entirely. The precompute scatters g's columns for the same reason, which
also means a feature is never densified.

`w_sum` and `col_sum` do not depend on the feature, so they move out of the
kernel; the O(n_cells) running totals become O(nnz) through `col_sum`.

Features are independent and the kernel is nogil, so parallelism moves up
to a thread pool over features -- measured faster than an in-kernel prange
even when there are fewer features than workers. Permutation draws fan out
the same way.

The permutation scores are only ever reduced along the permutation axis, so
`_score_perms` keeps a tally and a running mean/M2 per feature instead of
the (n_perms, n_features) matrix.

pval_sim and the FDR columns stay bit-identical; var_sim and pval_z_sim
move by ~1e-11 relative on Moran's I, whose expectation sits near zero.
The kernel is compiled serial now, the graph transpose is unconditional,
and the block cap is 2**28, so the notes describing the previous shapes
were contradicting the code beside them. Inline the one-use source
function into its decorator and drop the kernel alias.
The permuted scores cluster around the observed one, so shifting by it
turns the mean and variance into plain sums: they merge across blocks with
`+=` instead of a Chan combine, and `s2/n - (s1/n)**2` has nothing to
cancel because `s1/n` is the small residual.

Also more accurate than the combine it replaces -- 2e-15 against 2e-09
relative on tightly clustered scores, where the naive `E[x^2] - E[x]^2`
is wrong outright.
@selmanozleyen
selmanozleyen marked this pull request as draft October 12, 2026 09:51
The observed score now comes out of the same kernel as the permuted ones,
so ties are bit-exact. A sparse gene whose non-zero cells touch no other
non-zero cell sits at the floor of its null and ties most permutations;
folding sims >= obs alone then gives every such gene the minimum p-value
(1/(n_perms+1)). Before, float noise between scanpy's observed and permuted
scores split those ties at random. Take the smaller of the two tails with
ties counted in both, which equals the old fold when nothing ties.
Instead of a fixed 2**28-entry block, the buffer is working_memory worth of
int32, as in scikit-learn: a number of MiB or a string like '16GB'. The
default is an eighth of the memory the process may use (cgroup-aware, so
SLURM and container limits count), never below the old block size. Every
feature is revisited once per block, so at 100M cells the fixed block meant
a pass per two permutations.
A fixed default, the old block size, instead of an eighth of free memory:
predictable, and no cgroup probing. The buffer is still clipped to
n_perms x n_cells, so small inputs allocate only what they need.
Observed score from the same kernel pass as the permutations; NaN pval_sim for
constant features; canonical CSR features; one byte constant for the default
permutation buffer; drop the dead numba thread cap, the explicit full-gene-list
fast path and duplicated tests.
@selmanozleyen
selmanozleyen marked this pull request as ready for review October 30, 2026 10:32
@selmanozleyen selmanozleyen self-assigned this Oct 30, 2026

@flying-sheep flying-sheep left a comment

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.

Can’t really review the algorithm as I don’t know it, but the memory budged is awesome.

Only question os why the p_norm tests are gone, but there’s probably a good reason.

Comment thread tests/graph/test_ppatterns.py Resolved
Comment thread tests/graph/test_ppatterns.py Outdated
# depend on how the features were split across workers
df_parallel = df_parallel.loc[df.index] # align in case the stat-based sort ties differently
for col in ("pval_sim", "pval_z_sim", "var_sim"):
np.testing.assert_allclose(df[col].values, df_parallel[col].values, atol=1e-12)

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.

use the built-in subtests fixture here to see what actually failed (if something does)

Comment thread src/squidpy/_utils.py
import numba
import numpy as np
import xarray as xr
from humanfriendly import InvalidSize, parse_size

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.

nice API!

Comment thread src/squidpy/gr/_ppatterns.py Outdated
def _p_value_calc(
score: NDArrayA,
sims: NDArrayA | None,
sims: dict[str, Any] | 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.

just use a private dataclass, it’s nicer to use for autocomplete

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.

3 participants