Repository navigation
Description
Is your feature request related to a problem?
I've been working on this problem for a while after @ianhi encouraged me to have a crack at it at Scipy back in July.
The TLDR; is that my Claude assisted prototype (https://github.com/charles-turner-1/xrexpr) has gotten pretty far along (heads up for anyone looking at that - I've been thrashing the code out with Claude. I haven't let it decide any of the design but I have delegated pretty much all the code writing to it).
Anyhow, after running into flox last week and a bit of playing around over the weekend, I think I've reached a bit of a fork in the road where it might be worth publicly asking about this prototype and what's the best direction to go with it.
My prototype basically uses an accessor to capture attribute access on a dataset/dataarray and turn it into an IR which it then reasons about - it does things like predicate/projection pushdown, removes unnecessary rechunks, etc, only allowing provably safe rewrites. It then just replays these back out and lets xarray reconstruct the outcome of the chain, delegating everything to the existing machinery.
Under this way of doing things, we can only speed expression chains up as far as we could get from a 'perfectly written' chain, but no more.
As a simple canonical example:
ds.mean(dim="lat").mean(dim="lon").isel(time=0)becomes
ds.plan.mean(dim="lat").mean(dim="lon").isel(time=0)would get rewritten to
ds.isel(time=0).mean(dim=["lat","lon"])If we have a time dim of len 100, this is going to give us an ~99% runtime reduction when we do compute, as we're removing the 99% of unnecessary work.
Anyhow, the fork in the road that I alluded to was (long & boring detailed exploratory notebook full of claude-isms here charles-turner-1/xrexpr#184) that after prodding around a bit in how flox works, I realised that this expression rewriting approach would let us do a generic map-reduction, which can generate a speedup, even for 'perfectly written' expressions.
Something like (ds['temperature'] * 1.8 + 32).mean() is basically a map-reduction. My current understanding is we can't really generically turn this into a map reduction the way it's currently implemented, because - in a loose sense- the *1.8 + 32 and .mean() parts of the computation don't really know about each other. Flox can, because it's working on eg. DatasetGroupBy, so the two parts of the computation do know about each other.
Anyhow, with an Intermediate Representation that has these nodes, we can so something like:
(ds['temperature'] * 1.8 + 32).mean()becomes[Projection(args=('temperature'),ElementWise(op='__mul__',val=1.8),ElementWise(op='__add__',val=32),Reduce(op='mean',dims='all')]
which we can rewrite to[Projection(args=('temperature'),ElementWiseAffineMap(add=32,mul=1.8),Reduce(op='mean',dims='all')]and finally[Projection(args=('temperature'),MapReduce(op=Affine(1.8,32),dims='all')]
This lets us push the computation straight into the dask blocks, flox style (as I've understood it. I guess if I've misunderstood I've discovered a different optimisation?), which gets a ~2x speedup in my benchmark in the marimo notebook tests I linked - but has to graft a new dask graph back on in a fairly hacky way.
Anyhow, this kind of optimisation is getting out of the scope of the prototype I'd produced, as it's the sort of thing that requires doing more than just replaying operations in a different order and/or merging them. I figured I'd open an issue up to get some feedback on the idea - I'm sure there are plenty of smart people in here who have thought about these sort of ideas before.
(Obviously there's been a lot of aggressive AI usage to get this prototype to the state is it at the speed I have. The whole thing is pretty verbose so I'm happy to summarise any relevant parts for anyone interested - I have actually read and verified - as well as I'm able to 😅 - all of the code)
Describe the solution you'd like
No response
Describe alternatives you've considered
No response
Additional context
No response
Activity
dcherian commentedon Aug 24, 2026
Have you seen https://github.com/mrocklin/dask-array?
The main optimization available in
ds['temperature'] * 1.8 + 32is going to be in-place rewriting.which dask could totally do.
charles-turner-1 commentedon Aug 25, 2026
Can't believe I'd somehow missed dask_array.
I had a look at profiling your example against the other things I've tried:
I think the in-place rewriting is doing roughly what the map-reduction approach is, right? It basically pushes f(x) = 1.8 * x + 32 into the dask blocks? It looks from the benchmark above that they give roughly the same speedup. Chunk local is the 'flox-y' mapreduce idea I was playing with above & inplace/block kernel is your snippet in the graph & the table below.
I found out yesterday from @Kai-Striega that numpy should be able to elide doing operations in place - if I've understood correctly. So I think on a plain numpy array, this should work in place (for a sufficiently large array):
I had a look at benchmarking that too but it gives the exact same results as the naive version, so I guess maybe the numpy arrays in each individual chunk are small enough that numpy doesn't bother?
(BTW - algebraic here is just doing the C -> F conversion on the single output number, ie.
(ds['temp'].mean()) *1.8 + 32)Kai-Striega commentedon Aug 25, 2026
I haven't had the time to look at the entire PR, but I'm going to respond to the specific section that I was pinged for. I'll take a look at the remaining PR later on this week.
a * 1.8 + 32.0does elide, even without casting theFloats to NumPy types.You can see this using the tracemalloc.get_traced_memory function. For example this can be seen by:
This means that we allocated a total of ~32 MiB during the entire operation. If we allocated twice this would be the size of two arrays, not one (roughly 64 MiB).
charles-turner-1 commentedon Aug 27, 2026
Okay, I've been quite a long way down the rabbit hole with this today, including trying these approaches with regular dask.array versus dask-array:It turns out all of this is wrong, see comment below. Still interesting though (at least to me). The TLDR; is that dask is recording individual ops in the tasks, eg.
[mul, add, mean], which adds a reference & stops numpy elision.In hindsight, using a benchmark which was trying to discriminate between eliding two buffers to one or cutting the amount of data moving between dask chunks by ~50% was a bad choice.
Details
Details
There's a heap going on there, but my interpretation is:1. Block local approaches are the fastest at ~2x. I figure the explanation is that the majority of the run time is just bussing the data from one dask block to another, and the block local approaches are ~halving the amount of data bussed out. It doesn't seem to matter matter if that's written as
arr * 1.8 + 32orarr += 1.8; arr += 32, so long as the computation gets pushed into the chunk~~2. Using a regular dask.array after registering dask-array is a bit of a footgun and slows things down here.3. dask-array isn't pushing the reduction into the blocks, or this line:| after dask-array | inplace (Deepak Version)| 0.076630 | 0.0813624249771 | 0.0826737 | 32.9 | 1.05x |would be sitting around the 2* speedup too. It seems like trying to makedask-arraydo that would be a good idea to me, I'll dig into that.4~~. I think the numpy elision might have been a bit of a red herring here (Sorry Kai! ). I suspect what's happening is probably that dask is holding a bunch of references to the numpy arrays in each block - so what looks like it should elide in a purely numpy array gets blocked by dask. I did a very cursory check (used codex to write the script below):
This is all wrong
Details
Array: 400^3 float64 = 512.0 MB
tracemalloc starts after the input array and dask graph are built; each case runs in a fresh process.
I haven't audited it a great deal of depth, but it aligns with what I would expect, so I'm satisfied. Also, pinning to python 3.14 stops numpy eliding in pure numpy and also pushes the block local dask version up to 3x memory usage - presumably there's some meaningful link there (threading or something) that I might look into when I get the chance.All of that's a lot of work to say that I think it'd definitely be worth trying to figure out how to push the reductions into chunks in every possible instance. I think a generic roughly 50% runtime decrease across any kind of map-reduction problem is something that would be worthwhile pursuing?Everything above this is wrong
I wasn't super convinced by the answer I came to so I did some more testing. This time, taking a 3d array and then reducing over either 1,2 or 3 dimensions after the conversion - the logic being that if this were about the full block being written out before being reduced then doing partial reductions should show that up, since it'll vary the number of bytes crossing the block boundary from one buffer to another (no idea if this is the right way to state this but this is how I've been visualising it).
Which turns out
If this was about the number of bytes coming out of each block computation and being written into an intermediate buffer, I would expect the 3 axis reduction to be the fastest, but there's no clear difference.
Anyway, then I realised it was a bit weird these all sat around ~1.3, not two. It looks like that's because DataArray.mean() is doing a nanmean - so extra work. (N.B the benchmarks I'm doing in this bit are just reducing as sums, not means - just a bit simpler to do).
It looks like what I thought might have been a bit of low hanging fruit with this optimisation might not be quite so worthwhile. Maybe trying to combine the elementwise operations would bump performance by up to ~30% in this sort of case. With that said, I think it would also potentially halve the peak memory footprint, which is potentially well worthwhile...