perf: optimization of rank_genes_groups#4204
Conversation
|
@ilan-gold regarding these points, perhaps I open separate PR(s):
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4204 +/- ##
==========================================
+ Coverage 79.92% 79.94% +0.02%
==========================================
Files 121 121
Lines 12958 12987 +29
==========================================
+ Hits 10357 10383 +26
- Misses 2601 2604 +3
Flags with carried forward coverage won't be shown. Click here to find out more.
|
Benchmark changes
Comparison: https://github.com/scverse/scanpy/compare/c52da398956c8de9a5bc1945a7966ad86b8431be..e1a88630cd6cef277acedcda56b9e5a6ab22e500 More details: https://github.com/scverse/scanpy/pull/4204/checks?check_run_id=87850791862 |
| return expm1_func(x) | ||
|
|
||
|
|
||
| @numba.njit # noqa: TID251 (inner kernel called from _vars_rest's nopython loop) |
There was a problem hiding this comment.
I don't fully understand this - could you explain more? Why do we need to use numba.njit here?
There was a problem hiding this comment.
@ilan-gold in general njit is giving a significant speedup on the means/vars work for stats, and we use @numba.njit specifically because fau's njit is used on _vars_rest, and it's a python-based wrapper (https://github.com/scverse/fast-array-utils/blob/main/src/fast_array_utils/numba/__init__.py#L113), so when _chan_aggregate is called inside of that it can't reference the python wrapper code -- it has to call njit directly. @numba.njit is the same pattern used here elsewhere in scanpy, with the noqa exclusion
this is another thing I was curious to look closer at w/ fau / njit, as it means fairly common patterns of an njit function calling another njit function have to have this workaround w/ #noqa
There was a problem hiding this comment.
That makes total sense ok! I really think that fau njit function is mostly for dask but since so many of our kernels fall into that category....
|
P.S Any thought why the |
|
@ilan-gold FYI there is also a patch I'll need to make so we use illico's return_as_scanpy. We're currently doing without it and it adds quite a bit of time (it's like 80% of the runtime, with illico being ~10%). I am debating about whether that work belongs in this PR, there might be some overlap with the stats code. Will report back on that later today (this is an important change because it's still way faster to use illico directly. e.g. on reploglek562gwps it was something like 7 mins vs < 1 min). |
yes, so ovo is already fine. the issue in the stats code was vs_rest calculates things like means_rest, vars_rest, pts_rest, which are different for each group. That was done very inefficiently. And the performance impact was much bigger than it shows in the benchmark because those are small amounts of data and the runtime was ~quadratic. e.g. on real datasets see the tables here: #4118 . I'm rerunning a benchmark now and will share those results, as some things are unclear about the old benchmark I had run, and maybe incorrect. Will report back on that later today + share the script(s) with you |
|
Edited on 07/16/2026 to reflect latest PR state @ilan-gold so here are some more comprehensive benchmarks on small perturb seq datasets, which are still quite a bit larger than the benchmark data. This also runs more paths than we do in the built in benchmark, and I used cProfile to show a breakdown of where timing improvements come from. Note that most datasets I typically use take hours on the old code in the vs_rest cases, and that's now down to minutes on the new code. But I didn't time those out here b/c too slow
wessels23 (groups=184)
adamson16 (groups=90)
sunshine23 (groups=195)
norman19 (groups=238)
Component breakdown — wessels23, vs_rest (cProfile,
|
| component | cumtime | frame |
|---|---|---|
STATS _basic_stats |
0.05 | _rank_genes_groups.py:325 |
↳ aggregate / _stats_vs_rest |
0.04 / 0.05 | _aggregate_group_stats / _stats_vs_rest |
TEST t_test |
0.37 | _rank_genes_groups.py:460 (185 calls) |
XFORM p-value correction (multipletests) |
0.19 | multitest.py:63 |
XFORM _build_stats_dataframe |
0.84 | _rank_genes_groups.py:731 |
ASSEMBLE to_records |
0.10 | frame.py:2557 |
wilcoxon_illico (rest_i) — profiled total 2.11s
| component | cumtime | frame |
|---|---|---|
TEST illico asymptotic_wilcoxon |
0.69 | asymptotic_wilcoxon.py:231 |
STATS _basic_stats (+aggregate/vs_rest) |
0.03 | _rank_genes_groups.py:325 |
XFORM _illico_results_to_iter |
0.09 | _rank_genes_groups.py:53 |
XFORM p-value correction (multipletests) |
0.18 | multitest.py:63 |
XFORM _build_stats_dataframe |
0.46 | _rank_genes_groups.py:731 |
ASSEMBLE to_records |
0.10 | frame.py:2557 |
_vars_rest numba kernel — njit vs pure-Python
Isolated microbenchmark of the leave-one-out variance kernel; identical inputs, JIT toggled via NUMBA_DISABLE_JIT; result checksums identical.
| kernel size (k × genes) | njit | pure-Python | speedup |
|---|---|---|---|
| 184 × 1000 | 0.71 ms | 307 ms | 435× |
| 500 × 1000 | 1.86 ms | 836 ms | 449× |
| 1819 × 1000 | 2.84 ms | 3009 ms | 1060× |
| 1819 × 9623 | 34.6 ms | ~29,000 ms (extrap.) | ~840× |
| --> and this just clarifies the impact of njit here. It's negligible on small datasets but does move the needle on typical public perturb seq datasets, especially the ones from the past few years |
Contribution of each mechanism
Also, there are 3 distinct mechanisms in this PR that give performance improvements in different circumstances.
-
passing the number of threads to illico so it can multithread. previously it was just single threading.
-
the means/vars stats computation code, which impacts vs_rest scenarios.
-
build_stats_dataframe improvement, which significantly impacted any rank_genes_groups work on datasets with a few thousand+ groups.
Here's a vs_rest breakdown:
wilcoxon_illico
| phase | old | new | saved | mechanism |
|---|---|---|---|---|
_basic_stats (means/vars) |
30.13 | 0.03 | 30.1 | ① means/vars rewrite |
illico asymptotic_wilcoxon |
8.14 | 0.67 | 7.5 | ② illico threading |
| output build + correction + records | ~1.4 | ~1.4 | ~0 @184g | ③ dataframe construction (scale) |
| total | 39.7 | 2.1 | 37.6 |
t-test
| phase | old | new | saved | mechanism |
|---|---|---|---|---|
_basic_stats (means/vars) |
17.75 | 0.05 | 17.7 | ① means/vars rewrite |
| t-test + output build + correction | ~1.8 | ~1.5 | ~0 @184g | ③ dataframe construction (scale) |
| total | 19.5 | 1.6 | 17.9 |
_build_stats_dataframe time vs #groups
Because running bigger datasets on the old code takes a long time, and the build stats df issue mostly appears there, I've isolated that piece with a separate benchmark here. So you can see once we start getting into bigger group sizes this grows. It will be substantial on gwps, multiple minutes for something that can take a few seconds.
Synthetic (many groups, 4 cells/group so illico is ~instant), wilcoxon_illico vs_rest,
8,248 genes, n_genes=None, 16 threads. Timer wraps _build_stats_dataframe only
(includes the inherent per-group p-value correction).
| groups | OLD (per-column) | NEW (one-shot) | speedup | OLD growth / 2× groups |
|---|---|---|---|---|
| 250 | 384 ms | 299 ms | 1.3× | — |
| 500 | 961 ms | 585 ms | 1.6× | ×2.5 |
| 1,000 | 2,295 ms | 1,239 ms | 1.9× | ×2.4 |
| 2,000 | 6,730 ms | 2,666 ms | 2.5× | ×2.9 |
| 4,000 | 22,228 ms | 5,501 ms | 4.0× | ×3.3 |
Co-authored-by: Ilan Gold <ilanbassgold@gmail.com>
yea realistically this wouldn't happen so agree Co-authored-by: Ilan Gold <ilanbassgold@gmail.com>
@zboldyga Do you have a sense where the time difference is coming from? The |
| # illico can emit scanpy-formatted record arrays directly, skipping the | ||
| # per-group `.loc` reshuffle and the wide-DataFrame rebuild. It can't | ||
| # reproduce abs-value ranking or a non-natural log base, so fall back to | ||
| # the streamed iterator in those cases. `exp_post_agg` mirrors | ||
| # `mean_in_log_space` so illico's log-fold-changes match scanpy's. |
There was a problem hiding this comment.
This explanation is exactly why I preferred to rely on scanpy's machinery outside illico given the complexity - for me, the illico - scanpy gap is quite small i.e., calculating the stats isn't even that slow, it appears to be largely coming from our nonnegativity check, which I would argue we should just abandon in favor of "is this a unsigned integer" and throw an error otherwise. Would love to hear your feedback, but for my conversation with claude, this appeared to be the case.
@ilan-gold thb I should have approached this differently. I was initially thinking, illico is kind of getting absorbed into scanpy, may as well use this supported feature b/c it was already implemented. But that doesn't really make sense I'm glad you pushed on this. build_stats_dataframe was the issue that was giving longer runtimes on real datasets which using return_as_scanpy was inadvertently solving. But the build_stats_dataframe fix is actually relevant to any rank_genes_groups call with thousands+ groups: dataframe uses BlockManager internally, and setting properties like df[group, "scores"] I think rebuilds the whole BlockManager memory thus far, so it's super inefficient (basically O(groups^2)). So I've reverted to not using return_as_scanpy, tweaked the dataframe build (which also impacts the runtime of other de methods -- especially with bigger datasets), and re-run benchmarks to clarify. I updated above benchmarks to reflect latest code. will push momentarily |
…evant code tidied up
|
@ilan-gold see above message with my benchmark timing and breakdowns of the 3 optimization areas in this PR. all of this said, I do think expanding the benchmarks to capture some of these pieces would make sense. And we can do it on some isolated levels, e.g. build stats dataframe, other helpers. But perhaps we do that after this PR for two reasons:
|
Yesssssssss, great deep dive!
In general, like with testing, not a huge fan of benchmarking private APIs. But possibly the constituent steps become public (would not be unreasonable to e.g., request from a class-based RGG to rebuild the p-values with a different correction method etc.). For this, you will definitely want to talk to Phil as I think scanpy will expose a class-based 2.0 API but am not sure. |
ilan-gold
left a comment
There was a problem hiding this comment.
So in its current form, I think this is ready for the light-of-day. Thanks a million. Feel free to go ham in a follow-up PR updating RGG - if you want to run API stuff by someone, independent of whether I am around, Phil is probably best anyway.
|
Owee, I'm MrMeeseeks, Look at me. There seem to be a conflict, please backport manually. Here are approximate instructions:
And apply the correct labels and milestones. Congratulations — you did some good work! Hopefully your backport PR will be tested by the continuous integration and merged soon! Remember to remove the If these instructions are inaccurate, feel free to suggest an improvement. |
…_groups)" (#4223) Co-authored-by: Alberto Fabbri <alberto_fabbri.git@yahoo.com> Co-authored-by: Zach Boldyga <zboldyga@users.noreply.github.com>
@ilan-gold replayed changes of #4118 onto main
Edit on 07/15/2026: @ilan-gold I widened the scope of this PR to include two things:
I decided to combine these into one PR because they have some overlap, and I think it would be more cumbersome to have to come back again and do a separate PR review. Especially since rank_genes_groups overall is kinda obfuscated / not pythonic and I'm always losing track of what's going on in this file lol