Skip to content

Commit 1e04f7b

Browse files
authored
dask.distributed: do not rechunk (#72)
1 parent 51a47f6 commit 1e04f7b

3 files changed

Lines changed: 185 additions & 33 deletions

File tree

recursive_diff/core.py

Lines changed: 178 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
from __future__ import annotations
88

9+
import itertools
910
import math
11+
import operator
1012
import re
1113
import typing
1214
from collections.abc import Callable, Collection, Generator, Hashable
@@ -422,10 +424,15 @@ def _diff_dataarrays(
422424
lhs, rhs = xarray.align(lhs, rhs, join="inner")
423425

424426
is_dask = lhs.chunks is not None or rhs.chunks is not None
425-
if is_dask and lhs.chunks is None:
426-
lhs = lhs.chunk(dict(zip(rhs.dims, rhs.chunks))) # type: ignore[arg-type]
427-
elif is_dask and rhs.chunks is None:
428-
rhs = rhs.chunk(dict(zip(lhs.dims, lhs.chunks))) # type: ignore[arg-type]
427+
if is_dask:
428+
import dask.array as da
429+
430+
# Ensure that both lhs and rhs are Dask arrays and that they
431+
# have aligned chunks
432+
lhs_data, rhs_data = da.broadcast_arrays(lhs.data, rhs.data)
433+
lhs = lhs.copy(deep=False, data=lhs_data)
434+
rhs = rhs.copy(deep=False, data=rhs_data)
435+
assert lhs.chunks == rhs.chunks
429436

430437
# Generate a bit-mask of the differences
431438
# For Dask-backed arrays, this operation is delayed.
@@ -477,33 +484,37 @@ def _diff_dataarrays(
477484
# non-brief dim, with potentially repeated indices
478485
# All of the arrays will have the same size, which is the number of differences.
479486
# For Dask-backed arrays, this whole operation is delayed.
487+
diffs_idx: tuple[np.ndarray | Array, ...]
480488

481489
if brief_axes:
482490
diffs_count = mask.astype(int).sum(axis=tuple(brief_axes))
483491
mask = diffs_count > 0
484-
if mask.ndim:
485-
diffs_count = diffs_count[mask]
492+
if is_dask:
493+
assert isinstance(mask, Array)
494+
# a[mask] is very slow in Dask for 2+ dimensional arrays because it needs to
495+
# preserve the order of the returned elements, so it involves rechunking. Under
496+
# the assumption that the number of differences is << the number of total
497+
# elements, filter each chunk independently and then full-sort the results by
498+
# index.
499+
diffs_idx, sort_indices = _fast_dask_nonzero(mask)
500+
if brief_axes:
501+
if mask.ndim:
502+
diffs_count = _fast_dask_mask(diffs_count, mask, sort_indices)
503+
else:
504+
diffs_lhs = _fast_dask_mask(lhs.data, mask, sort_indices)
505+
diffs_rhs = _fast_dask_mask(rhs.data, mask, sort_indices)
486506
else:
487-
diffs_lhs = lhs.data[mask]
488-
diffs_rhs = rhs.data[mask]
489-
490-
diffs_idx = []
491-
for axis, size in enumerate(mask.shape):
492-
idx_shape = (1,) * axis + (-1,) + (1,) * (mask.ndim - axis - 1)
493-
if is_dask:
494-
import dask.array as da
495-
496-
assert isinstance(mask, da.Array)
497-
idx = da.arange(size, chunks=mask.chunks[axis])
498-
idx = idx.reshape(idx_shape)
499-
idx = da.broadcast_to(idx, mask.shape, chunks=mask.chunks)
507+
assert isinstance(mask, (np.ndarray, np.generic))
508+
if brief_axes:
509+
if mask.ndim:
510+
diffs_idx = np.nonzero(mask)
511+
diffs_count = diffs_count[mask]
512+
else:
513+
diffs_idx = ()
500514
else:
501-
idx = np.arange(size)
502-
idx = idx.reshape(idx_shape)
503-
idx = np.broadcast_to(idx, mask.shape)
504-
505-
idx = idx[mask]
506-
diffs_idx.append(idx)
515+
diffs_idx = np.nonzero(mask)
516+
diffs_lhs = lhs.data[mask]
517+
diffs_rhs = rhs.data[mask]
507518

508519
msg_prefix = "".join(f"[{elem}]" for elem in path)
509520

@@ -536,7 +547,7 @@ def _diff_dataarrays(
536547

537548
rel_delta = da.map_blocks(_rel_delta, diffs_lhs, diffs_rhs, dtype=float)
538549
else:
539-
rel_delta = _rel_delta(diffs_lhs, diffs_rhs)
550+
rel_delta = _rel_delta(diffs_lhs, diffs_rhs) # type: ignore[arg-type]
540551
args = (diffs_lhs, diffs_rhs, abs_delta, rel_delta, *diffs_coords)
541552
build_df = partial(
542553
_build_dataframe,
@@ -575,6 +586,147 @@ def _diff_dataarrays(
575586
yield from pp_func(*args)
576587

577588

589+
def _fast_dask_nonzero(mask: Array) -> tuple[tuple[Array, ...], Array]:
590+
"""Variant of da.nonzero(mask), which is much faster when the number of
591+
nonzero elements is much smaller than the total.
592+
593+
Returns:
594+
595+
- tuple of arrays of shape (nan, ), one array per axis, one point per nonzero
596+
element, just like da.nonzero(mask)
597+
- matching array of shape (nan, ) which is to be used by _fast_dask_mask to reorder
598+
the output.
599+
"""
600+
from dask.base import tokenize
601+
from dask.core import flatten
602+
from dask.highlevelgraph import HighLevelGraph
603+
604+
try:
605+
from dask.base import List, Task, TaskRef
606+
except ImportError:
607+
List = lambda x: x # type: ignore[misc,assignment] # noqa: E731
608+
Task = lambda _, f, *args: (f, *args) # type: ignore[misc,assignment] # noqa: E731
609+
TaskRef = lambda x: x # type: ignore[misc,assignment] # noqa: E731
610+
611+
# 1. Apply np.nonzero() to each chunk independently and add the
612+
# coordinates of the top-left corner of the chunk to the output
613+
chunk_offsets: list[list[int]] = [
614+
[0, *np.cumsum(c[:-1]).tolist()] for c in mask.chunks
615+
]
616+
617+
tok = tokenize(mask)
618+
name1 = f"fast_nonzero-{tok}"
619+
layer1 = {}
620+
for i, (key, offset) in enumerate(
621+
zip(flatten(mask.__dask_keys__()), itertools.product(*chunk_offsets))
622+
):
623+
layer1[name1, 0, i] = Task(
624+
(name1, 0, i), _fast_dask_nonzero_chunk, TaskRef(key), offset
625+
)
626+
hlg1 = HighLevelGraph.from_collections(name1, layer1, dependencies=[mask]) # type: ignore[list-item]
627+
628+
# 2. Rechunk to a single chunk
629+
name2 = f"fast_nonzero_rechunk-{tok}"
630+
layer2 = {
631+
(name2, 0, 0): Task(
632+
(name2, 0, 0),
633+
partial(np.concatenate, axis=1),
634+
List([TaskRef(key) for key in layer1]),
635+
)
636+
}
637+
hlg2 = HighLevelGraph(
638+
{**hlg1.layers, name2: layer2},
639+
{**hlg1.dependencies, name2: {name1}},
640+
)
641+
642+
nz = Array(
643+
dask=hlg2,
644+
name=name2,
645+
shape=(mask.ndim, math.nan),
646+
chunks=((mask.ndim,), (math.nan,)),
647+
dtype=int,
648+
meta=np.array([[]], dtype=int),
649+
)
650+
651+
# 3. Get the order in which np.nonzero() would have returned the output
652+
sort_indices = nz[::-1, :].map_blocks(
653+
np.lexsort,
654+
dtype=int,
655+
meta=np.array([], dtype=int),
656+
drop_axis=0,
657+
)
658+
# 4. Reorder
659+
nz_sorted = nz.T.map_blocks(
660+
operator.getitem,
661+
sort_indices,
662+
dtype=int,
663+
meta=np.array([[]], dtype=int),
664+
).T
665+
return tuple(nz_sorted), sort_indices
666+
667+
668+
def _fast_dask_nonzero_chunk(
669+
mask_chunk: np.ndarray, offset: tuple[int, ...]
670+
) -> np.ndarray:
671+
nz_indices = np.stack(np.nonzero(mask_chunk))
672+
return nz_indices + np.array(offset)[:, None]
673+
674+
675+
def _fast_dask_mask(a: Array, mask: Array, sort_indices: Array) -> Array:
676+
"""Variant of a[mask], which is much faster when the number of
677+
True points in the mask is much smaller than the total.
678+
Applying this function to multiple identically shaped **and chunked**
679+
arrays with the same mask will return objects in the same order.
680+
"""
681+
682+
from dask.base import tokenize
683+
from dask.core import flatten
684+
from dask.highlevelgraph import HighLevelGraph
685+
686+
try:
687+
from dask.base import List, Task, TaskRef
688+
except ImportError:
689+
List = lambda x: x # type: ignore[misc,assignment] # noqa: E731
690+
Task = lambda _, f, *args: (f, *args) # type: ignore[misc,assignment] # noqa: E731
691+
TaskRef = lambda x: x # type: ignore[misc,assignment] # noqa: E731
692+
693+
# 1. Apply a[mask] to each chunk independelty
694+
# The reported shape and chunks are bogus here!
695+
masked = a.map_blocks(operator.getitem, mask, dtype=a.dtype, meta=a._meta) # type: ignore[arg-type]
696+
697+
# 2. rechunk to a single chunk
698+
# 3. Sort the results to match a[mask]
699+
tok = tokenize(masked)
700+
name = f"fast_mask_merge-{tok}"
701+
layer = {
702+
(name, 0): Task(
703+
(name, 0),
704+
_fast_dask_mask_chunk,
705+
List([TaskRef(key) for key in flatten(masked.__dask_keys__())]),
706+
TaskRef(sort_indices.__dask_keys__()[0]),
707+
)
708+
}
709+
hlg = HighLevelGraph.from_collections(
710+
name,
711+
layer,
712+
dependencies=[masked, sort_indices], # type: ignore[list-item]
713+
)
714+
715+
return Array(
716+
dask=hlg,
717+
name=name,
718+
shape=(math.nan,),
719+
chunks=((math.nan,),),
720+
dtype=a.dtype,
721+
meta=np.array([], dtype=a.dtype),
722+
)
723+
724+
725+
def _fast_dask_mask_chunk(chunks: list[np.ndarray], order: np.ndarray) -> np.ndarray:
726+
masked = np.concatenate(chunks)
727+
return masked[order]
728+
729+
578730
def _build_dataframe(
579731
column_names: list[str], index_names: list[str], *args: np.ndarray
580732
) -> pd.DataFrame:

recursive_diff/tests/test_diff_arrays.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def test_diff_arrays(chunk):
5252
"abs_delta": [1.0],
5353
"rel_delta": [0.25],
5454
"x": ["b"],
55-
"y": np.asarray([1], dtype=np.int_),
55+
"y": np.asarray([1], dtype=np.int64),
5656
}
5757
).set_index(["x", "y"]),
5858
)
@@ -61,7 +61,7 @@ def test_diff_arrays(chunk):
6161
arrays["[3][data]"],
6262
pd.DataFrame(
6363
{"lhs": ["bar"], "rhs": ["baz"]},
64-
index=pd.Index(np.asarray([1], dtype=np.int_), name="dim_0"),
64+
index=pd.Index(np.asarray([1], dtype=np.int64), name="dim_0"),
6565
),
6666
)
6767

@@ -116,7 +116,7 @@ def test_brief_dims(chunk):
116116
arrays["[data]"],
117117
pd.DataFrame(
118118
{"diffs_count": np.asarray([1])},
119-
index=pd.Index(np.asarray([1]), name="y"),
119+
index=pd.Index(np.asarray([1], dtype=np.int64), name="y"),
120120
),
121121
)
122122

recursive_diff/tests/test_recursive_diff.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,10 +1367,10 @@ def test_lazy_datasets_without_dask(tmp_path):
13671367
[
13681368
# Different OSes and dependency versions have different peak RAM usages.
13691369
# These are the worst case scenarios across all combinations.
1370-
("netcdf", None, 100), # Uses more RAM on MacOS
1371-
("netcdf", {}, 50),
1372-
("zarr", None, 100),
1373-
("zarr", {}, 25), # ~5 MiB on Linux, up to 25 MiB on Windows
1370+
("netcdf", None, 110), # Uses more RAM on MacOS
1371+
("netcdf", {}, 60),
1372+
("zarr", None, 110),
1373+
("zarr", {}, 30), # ~5 MiB on Linux, up to 30 MiB on Windows
13741374
],
13751375
)
13761376
def test_lazy_datasets_huge(tmp_path, chunks, max_peak, format):

0 commit comments

Comments
 (0)