Skip to content

perf: numbafy spatial_autocorr permutations - #1303

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

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

Conversation

@selmanozleyen

@selmanozleyen selmanozleyen commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

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 60.56338% with 56 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.58%. Comparing base (f030690) to head (00be7be).

Files with missing lines Patch % Lines
src/squidpy/gr/_ppatterns.py 60.56% 54 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1303      +/-   ##
==========================================
- Coverage   78.88%   78.58%   -0.31%     
==========================================
  Files          65       65              
  Lines        9611     9722     +111     
  Branches     1591     1612      +21     
==========================================
+ Hits         7582     7640      +58     
- Misses       1497     1550      +53     
  Partials      532      532              
Files with missing lines Coverage Δ
src/squidpy/gr/_ppatterns.py 72.00% <60.56%> (-9.18%) ⬇️
🚀 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 and others added 9 commits October 10, 2026 03:24
`extract_X` skips the subset when the selection is every gene in
var_names order. Without the order check a reordered full-length list
takes that path too, returning X in var order against the caller's
labels. Nothing caught that; this does.
The constant said what it was, not why that value. It exists to keep memory
off `n_perms` -- 1000 permutations over 100M cells would want 372 GB
unbatched -- and 2**28 comes from measuring the cost of splitting, not from
any cache size: ~1.03x at 1M cells, ~1.10x at 10M, and 0.86x at 10M with
two blocks, where splitting is the faster arm. Also corrects the reduction
note: the kernel is serial, so there is no parallel reduction to avoid, and
independence from `n_jobs` comes from the RNG binding and per-feature
ownership instead.
The ratios are what transfer; the byte counts were specific to the machine
and dataset they were measured on.
Seventeen lines of justification for one constant was more than it needs.
`_build` and `_sepal` already map over a thread pool through this helper;
`_score_perms` was managing its own. Measured equal or slightly faster
interleaved, so the pool it builds per call costs nothing here.

Also notes that the v1.8.3 positional test goes when the `backend` shim
does, since the signature it pins only exists for that shim.
The `_score_perms` docstring still described a running mean and M2, which
the shifted sums replaced. The `extract_X` note claimed `genes is None`
means every gene, which is false when `highly_variable` is set. `nogil`
was credited with avoiding the `parallel=True` abort, which is what
`parallel=False` does. `arr.sum()` materializes nothing, so that argument
only applied to the centred square. The fold in `_p_value_calc` was called
a swap, and a test docstring still referred to numba threads.
@selmanozleyen
selmanozleyen marked this pull request as ready for review October 10, 2026 09:23
Shifting the variance by the observed score makes it exactly zero when
every permutation scores alike, where the old two-pass form left a
denormal residual. `z_sim` is then NaN, and neither mask in `_p_value_calc`
covers NaN, so `np.empty` left those entries at whatever memory held --
often 0.0, the most significant p-value there is. A gene expressed in one
cell hits this, which is routine in raw counts.

Both p-value arrays start as NaN now. `multipletests` spreads a NaN over
its whole input, so the correction runs over the defined p-values alone
and leaves the rest NaN.

Also drops the n_jobs coverage duplicated across `seq_par`, `perm_blocks`
and a separate invariance test, and the `pval_norm`/`var_norm` assertions
that `assert_frame_equal` already makes.
`s1/n` is the observed score's distance from the permutation mean, so it
is largest for the features with the most autocorrelation, not a small
residual. The cancellation it causes is real, about eps*z**2.
The note claimed splitting only gets expensive past ~8 blocks, which held
at 10M and not at 100M: the block there is two permutations, so the scatter
is re-paid every two and the measured cost is 1.3-1.6x at three to five
blocks. Block size drives it, not block count.
@selmanozleyen
selmanozleyen marked this pull request as draft October 12, 2026 09:51

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.

2 participants