Repository navigation
Description
What is your issue?
I think that this is a longstanding problem. Sorry if I missed an existing github issue.
I was looking at an Dask-array-backed Xarray workload with @phofl and we were both concerned about some performance we were seeing with groupby-aggregations called with out-of-order indices. Here is a minimal example:
import xarray as xr
import dask.array as da
import numpy as np
import pandas as pd
lat = np.linspace(-89.5, 89.5, 100)
lon = np.linspace(-179.375, 179.375, 100)
time = pd.date_range(
start="1990-01-01", end="2000-12-31", freq="D",
)
arr = (
xr.DataArray(
da.random.random((100, 100, len(time)), chunks=(100, 100, 365)),
dims=["lat", "lon", "time"],
coords={"lat": lat, "lon": lon, "time": time},
name="arr"
)
.to_dataset()
)
arr["arr"].data
def f(x):
return x
result = arr.groupby("time.dayofyear").map(f)
result["arr"].data
Performance here is bad in a few ways:
- Output chunk sizes are very small (12 chunks turns into 4000 chunks)
- There are a lot of tasks
- There are a lot of layers (365 new layers)
We think that what is happening here looks like this:
- slice underlying array with a very out-of-order array to arrange groups to be close to each other
- Iterate through each group and apply function
- slice the underlying array with the inverse array to put everything back in the right place
For steps (1) and (3) above performance is bad in a way that we can reduce to a dask array performance issue. Here is a small reproducer for that:
x = da.random.random((100, 100, 10000))
x
idx = np.random.randint(0, x.shape[2], x.shape[2])
x[:, :, idx]
We think that we can make this better on our end, and can take that away as homework.
However for step (2) we think that this probably has to be a change in xarray. Ideally xarray would call something like map_blocks, rather than iterate through each group. This would be a special-case for dask-array. Is this ok?
Also, we think that this has a lot of impact throughout xarray, but are not sure. Is this also the code path taken in sum/max/etc..? (assuming that flox is not around). Mostly we're curious how much we all should prioritize this.
Asks
Some questions:
- Does our understanding of the situation sound right?
- Is avoiding iteration through groups for dask arrays doable?
- is anyone around to do this within xarray if we're also improving slicing on the dask array side?
Activity
mrocklin commentedon Jul 9, 2024
For Dask Array people, here is a tiny test:
My sense is that we want to embrace an
n^2solution here when that reduces the task count. The benefit here is that herenis really only in one dimension, and so will likely not be as large a blowup as we might otherwise expect. In the example at the top of this post it would blowup tasks by a factor of 10, but not by a factor of 400, which would be a considerable win. In some future we could also invoke the p2p machinery to do this better (but I'd be inclined to solve this with tasks short-term myself).fjetter commentedon Jul 10, 2024
Reducing the number of tasks is easy if we just merged adjacent slices. However, in that scenario neither task number not network is the limiting factor but memory.
If we truly care/need this kind of random access take, the best move would probably be to have a p2p version of this as well.
dcherian commentedon Jul 11, 2024
Yes. groupby-map is quite popular (sadly).
Yes. This is a high-impact problem to solve.
This assumes that you can shove a whole group into one block, which is not a good assumption.
I think the core problem is that we need Xarray to give dask/cubed/whatever all the information (indices that sort by group) in one call rather than a for-loop over groups.
Which suggests that the missing primitive is a
shuffle_by_keyorshuffle(Array, indices: Sequence[Sequence[int], ...], axis: int) -> Sequence(Array, ...)that yields one dask array per inner sequence of indices (note not necessarily one block).That way a user is free to rechunk to a single block if it makes sense and dask/whatever is free to optimize the indexing in one go. This
shufflewould also be useful for flox'method="cohorts"which loops over a groups-of-groups (https://github.com/xarray-contrib/flox/blob/22257d2ba8de666a94fc8954451905e9ebd361bb/flox/core.py#L1704-L1719).For an example of the indices see
It feels like you've already solved a much harder problem at some level... it "just" needs to be hooked up to slicing.
This is fine. The iteration is hidden behind an abstraction already.
Yes I can help, I've been refactoring all this code recently.
PS: Arkouda does something like this for groupby problems --- IIRC they always sort by key.
dcherian commentedon Jul 22, 2024
alternatively we need
np.take_along_axis(array, axis, chunks=...). This would help with #4555 and binary ops with groupby (this is usually indexing with repeated ints).TomNicholas commentedon Jul 22, 2024
Once array libraries define such a primitive, we now have a natural place for xarray to inteface with it - the
ChunkManagerEntryPoint(see https://docs.xarray.dev/en/stable/internals/chunked-arrays.html).dcherian commentedon Jul 29, 2024
Here's an array-only implementation
The
chunks_hintshould tell dask to always start a new chunk at those boundaries. The actual chunk sizes for the output. array can be treated as an implementation detail. That way you have at most one group per block in the output.What's not clear to me is whether just optimizing the
takewith the addedchunks_hintinfo is good enough for dask? (note i made in a mistake in mentioningtake_along_axisearlier).Or do we also need an API for the apply-combine step? If so, a natural API for groupby-apply on the shuffled array would be to mimic
ufunc.reduceat.phofl commentedon Jul 29, 2024
I think what you would want Is basically the shuffle (i.e. take) and then call map_blocks with the transformer / reducer. This is what DataFrames in Dask do as well.
I am a bit hesitant about
chunks_hint = group_sizes,,I'd rather have control about this in dask by default, creating a block per group might blow up the graph if we are unlucky with a high cardinality grouper. You can split the groups in map_blocks again if needed, hiding the cardinality of the grouper from the system
dcherian commentedon Jul 29, 2024
OK now I am finally understanding what you mean here. The UDF is expecting to receive only one group, we can still satisfy that interface by inserting a for-loop-over-groups in the map-blocks call. 👍
For one, without an extra arg, dask doesn't know where the group boundaries are. Another option is to pass:
A low cardinality grouper is also important to consider, particularly with small chunk sizes. For example.
^ Today, this is a tree-reduction reduce because we dispatch to
dask.array.mean. The shuffle/map_blocks solution would be a regression.phofl commentedon Jul 29, 2024
I definitely agree that the shuffle map_blocks solution shouldn't be the default for reducers. A tree reduction is totally fine if you have a low cardinality grouper and will most likely outperform every shuffle implementation we can come up with. We have to come up with a threshold starting from where the shuffle map_blocks approach makes sense for reducers.
Yeah we need some marker where the group boundaries are, I totally agree with you here. What I would want to avoid is that the group boundaries necessarily define the chunks and then you would do:
For context: in DataFrame land this works as follow:
df.groupby(...).transform(...)is translated into the following:We push the actual groupby operation into the map_partitions call. For arrays you would "re-do" the groupby in the map_blocks call to ensure that the UDF gets one group at a time, but we don't have to create a task per group with this approach, rather one per chunk which should keep the graph size smaller and still parallelises very well.
dcherian commentedon Aug 5, 2024
After thinking over it over the weekend, I think it'd be nice to have at least two APIs.
Array.shuffle(indices: list[list[int]], axis: int)that does guarantee an integer number of groups N >= 1 in the output chunks (analogous to dask.dataframe).shuffleshould rechunk the other axes automatically to make this feasible. This would be immediately useful inGroupBy.quantileandGroupBy.median. I believe this is implemented in Implement task-based array shuffle dask/dask#11262np.takethat does not bother about groups, and simply extracts/reorders the elements in the most efficient manner possible. Here I think it is preferable to preserve the chunking along the other axes as much as possible. We might consider achunks_hintargument, that suggests optional chunksizes (or chunk boundaries) as proposed in Groupby-map is slow with out of order indices #9220 (comment). This is more general, and I think Xarray would calltakefollowed by slicing to index out each group for the generalGroupBy.reduce.cc @phofl
phofl commentedon Aug 5, 2024
That sounds good to me.
So the current shuffle implementation doesn't do any rechunking, assuming you have a group that is very large you would expect us to change the length of the chunks across other dimensions correct?
re take: I have a pr dask/dask#11267 that hooks the shuffle approach into take, this means that we preserve the chunk size along the axis that we are applying the take indexer but it's a lot more efficient than an out of order take indexer (this is what I benchmarked here). This is something that would be helpful if we expose this toplevel I assume?
dcherian commentedon Aug 5, 2024
Yes you'd absolutely have to.
Isn't it already exposed as
take?How does this do with repeated indices? #2745
phofl commentedon Aug 5, 2024
This isn't yet implemented, I'll add this as a follow up.
good point, this should end up in the same place (I'll double check that to be sure though)
It places them in different chunks if you have too many of them. It looks at the average input chunk size along the dimension your are indexing and then uses this as the target chunk size for the output. It would preserve the 100, 100 chunks in the example in the other issue.
dcherian commentedon Aug 5, 2024
Perfect! We'll need this for groupby-binary-ops (
ds.groupby(...) - ds.groupby(...).mean())which has this total hack at the moment:xarray/xarray/core/groupby.py
Lines 655 to 668 in 1ac19c4
dcherian commentedon Aug 7, 2024
Some updates after a chat with @phofl
First off, amazing progress on reducing memory usage in dask 2024.08.0!
TODO:
GroupBy.shuffle()to allow users to manually shuffle the data so that all members of a group are guaranteed to be in one chunk. We may have more than one group in a single chunk.GroupBy.shuffle_to_chunks()#9320 .GroupBy.medianandGroupBy.quantileto make use of it.GroupBy.transformthat callsshuffleand then callsxarray.map_blockswith a wrapper function that applies another GroupBy with the UDF. This is similar to whatdask.dataframe.GroupBy.mapdoes.shufflekwarg toGroupBy.map#9706GroupBy.mapandGroupBy.reduceuse the new & improveddask.array.takealgorithm.Array.vindexwhich does its own thing.Array.vindexthroughtake.takealgo, this should be a massive improvement.headtr1ck commentedon Oct 12, 2026
Status update with the example from the top post (dask 2026.8.0, xarray main):
groupby("time.dayofyear").map(identity)shuffle_to_chunks()shuffle_to_chunks()+groupby(...).map(center)The dask side is in much better shape: the new
take/shufflealgorithm brings the output from ~4000 to ~400 chunks, and #9320 addedGroupBy.shuffle_to_chunks()(used automatically by flox'smethod="blockwise").What's left is step 2 from the top post:
GroupBy.mapstill loops over groups, giving ~one graph layer per group even after shuffling. #9706 (shufflekwarg forGroupBy.mapviamap_blocks) is a stale draft, and #9546 didn't settle on an API.How do we want to go forward here?
shufflekwarg toGroupBy.map#9706:groupby(...).map(func, shuffle=True)shuffles and applies the UDF per block viamap_blocks, re-grouping inside each block.GroupBy.map_shuffled/transform), keepingmapunchanged.shuffle_to_chunks()+xr.map_blocks(lambda b: b.groupby(...).map(func), ...)as the recommended pattern and close this issue, tracking the remaining pieces (auto-shuffle forquantile/median, nD groupers) separately.[This is Claude Code on behalf of Michael Niklas]