Skip to content

Commit 71c5e42

Browse files
Backport PR #4446 on branch 1.12.x (fix: avoid loading whole dask arrays in axis_nnz and check_nonnegative_integers) (#4478)
Co-authored-by: Yangqi Su <yangqi.su@som.umaryland.edu>
1 parent 1e18775 commit 71c5e42

3 files changed

Lines changed: 76 additions & 4 deletions

File tree

‎docs/release-notes/4446.fix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix {func}`~scanpy.pp.highly_variable_genes` (`flavor='seurat_v3'`), {func}`~scanpy.pp.calculate_qc_metrics`, and {func}`~scanpy.tl.rank_genes_groups` loading entire row-chunked {class}`dask.array.Array`s into memory when checking for count data or counting nonzero cells per gene {smaller}`Y Su`

‎src/scanpy/_utils/__init__.py‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -748,14 +748,32 @@ def _(x: CSBase, /, axis: Literal[0, 1]) -> np.ndarray:
748748

749749
@axis_nnz.register(DaskArray)
750750
def _(x: DaskArray, /, axis: Literal[0, 1]) -> DaskArray:
751-
return x.map_blocks(
752-
partial(axis_nnz, axis=axis),
751+
import dask.array as da
752+
753+
# Count per block, then sum the counts across blocks.
754+
# (`map_blocks(..., drop_axis=axis)` would concatenate all blocks along `axis`
755+
# before counting, i.e. load all of a row-chunked array into memory for `axis=0`.)
756+
return da.reduction(
757+
x,
758+
chunk=_axis_nnz_block,
759+
# keep the in-memory result’s dtype (int32 for scipy sparse), `np.sum` upcasts
760+
aggregate=lambda counts, axis, keepdims: counts.sum(
761+
axis=axis, keepdims=keepdims, dtype=counts.dtype
762+
),
763+
axis=axis,
753764
dtype=np.int64,
754765
meta=np.array([], dtype=np.int64),
755-
drop_axis=axis,
756766
)
757767

758768

769+
def _axis_nnz_block(
770+
x: _MemoryArray, /, *, axis: tuple[Literal[0, 1]], keepdims: bool
771+
) -> np.ndarray:
772+
# `da.reduction` passes `axis` as a tuple and expects the reduced axis to be kept
773+
(ax,) = axis
774+
return np.expand_dims(axis_nnz(x, axis=ax), ax)
775+
776+
759777
@singledispatch
760778
def check_nonnegative_integers(x: _SupportedArray, /) -> bool | DaskArray:
761779
"""Check values of X to ensure it is count data."""
@@ -779,7 +797,18 @@ def _check_nonnegative_integers_in_mem(x: _MemoryArray, /) -> bool:
779797

780798
@check_nonnegative_integers.register(DaskArray)
781799
def _check_nonnegative_integers_dask(x: DaskArray, /) -> DaskArray:
782-
return x.map_blocks(check_nonnegative_integers, dtype=bool, drop_axis=(0, 1))
800+
import dask.array as da
801+
802+
# One result per block, combined with `all` (see `axis_nnz` for why not `drop_axis`)
803+
return da.reduction(
804+
x,
805+
chunk=lambda block, axis, keepdims: np.array([
806+
[check_nonnegative_integers(block)]
807+
]),
808+
aggregate=np.all,
809+
dtype=bool,
810+
meta=np.array([], dtype=bool),
811+
)
783812

784813

785814
def dematrix[SA: _SupportedArray](x: SA | np.matrix) -> SA:

‎tests/test_utils.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
from anndata.tests.helpers import asarray
1414
from scipy import sparse
1515

16+
import scanpy._utils
1617
from scanpy._compat import CSBase, DaskArray
1718
from scanpy._utils import (
1819
_numba_thread_limit,
1920
axis_mul_or_truediv,
21+
axis_nnz,
2022
check_nonnegative_integers,
2123
descend_classes_and_funcs,
2224
)
@@ -26,6 +28,7 @@
2628
random_k_tuples,
2729
random_str,
2830
)
31+
from testing.scanpy._pytest.marks import needs
2932
from testing.scanpy._pytest.params import (
3033
ARRAY_TYPES,
3134
ARRAY_TYPES_DASK,
@@ -182,6 +185,45 @@ def test_check_nonnegative_integers(array_type, array_value, expected):
182185
assert received is expected
183186

184187

188+
@needs.dask
189+
@pytest.mark.parametrize(
190+
("func", "kwargs"),
191+
[
192+
pytest.param(check_nonnegative_integers, {}, id="check_nonnegative_integers"),
193+
pytest.param(axis_nnz, dict(axis=0), id="axis_nnz-0"),
194+
pytest.param(axis_nnz, dict(axis=1), id="axis_nnz-1"),
195+
],
196+
)
197+
@pytest.mark.parametrize(
198+
"block_type",
199+
[np.asarray, sparse.csr_matrix], # noqa: TID251
200+
ids=["dense_dask", "csr_dask"],
201+
)
202+
def test_dask_reductions_blockwise(
203+
monkeypatch: pytest.MonkeyPatch, func: Callable, kwargs: dict, block_type
204+
):
205+
"""Reductions over dask arrays must see one block at a time, not all blocks concatenated."""
206+
import dask.array as da
207+
208+
x_np = np.random.default_rng(0).poisson(size=(100, 20)).astype(np.float32)
209+
x = da.from_array(block_type(x_np), chunks=(10, 20), asarray=False)
210+
211+
# the dask implementation calls the module-level function on each block
212+
seen_shapes = []
213+
214+
def spy(block, **kw):
215+
seen_shapes.append(block.shape)
216+
return func(block, **kw)
217+
218+
monkeypatch.setattr(scanpy._utils, func.__name__, spy)
219+
lazy = func(x, **kwargs)
220+
seen_shapes.clear() # drop dask’s metadata probe, made while building the graph
221+
received = lazy.compute()
222+
223+
assert seen_shapes == [(10, 20)] * 10
224+
np.testing.assert_array_equal(received, func(x_np, **kwargs))
225+
226+
185227
@pytest.mark.parametrize("seed", [0, 1, 1256712675])
186228
@pytest.mark.parametrize("pass_seed", [True, False], ids=["pass_seed", "set_seed"])
187229
@pytest.mark.parametrize("func", ["choice"])

0 commit comments

Comments
 (0)