Skip to content

ENH: support reduction loops in ufunc.accumulate - #32213

Merged
ikrommyd merged 14 commits into
numpy:mainfrom
ikrommyd:accumulate-reduction-loops
Nov 4, 2026
Merged

ikrommyd merged 14 commits into
numpy:mainfrom
ikrommyd:accumulate-reduction-loops

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Aug 6, 2026

Copy link
Copy Markdown
Member

PR summary

Expands on top of #31816 and allows ufunc.accumulate to use the reduction loops registered on the ufunc

First time committer introduction

N/A

AI Disclosure

AI has been used for review to find bugs and apply fixes

@ikrommyd

ikrommyd commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

This is a sister PR to #32212 so depending on the merge order, a little bit of buffering is required between them to consider the review comments of the one to the other and rebase whilst fixing merge conflicts.

@ikrommyd
ikrommyd force-pushed the accumulate-reduction-loops branch from b13b565 to 29b425a Compare August 9, 2026 10:59
@ikrommyd
ikrommyd marked this pull request as ready for review August 10, 2026 15:34
@ikrommyd
ikrommyd requested a review from ngoldbaum August 10, 2026 17:04
@ikrommyd
ikrommyd force-pushed the accumulate-reduction-loops branch from f14f365 to faec809 Compare August 12, 2026 22:12
@ikrommyd
ikrommyd force-pushed the accumulate-reduction-loops branch from faec809 to 897a462 Compare September 24, 2026 02:42
@ikrommyd
ikrommyd marked this pull request as draft September 24, 2026 02:42
@ikrommyd

Copy link
Copy Markdown
Member Author

The branch is now on top of #32212 and I have converted it to draft. The review will continue in that one, and once that one has converged, all the findings from that review will be applied here and once its merged on main the diff will shorten significantly. Now the branch holds 2 PRs in the diff.

@ikrommyd
ikrommyd force-pushed the accumulate-reduction-loops branch from fbfe52e to 54e6813 Compare September 24, 2026 03:07

@ngoldbaum ngoldbaum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a review pass here and spotted a couple issues on top of what I already commented about in #32212, which also shows up here but will presumably go away once #32212 is updated.

Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
got_min, got_max = mm.reduce(a, initial=(7, -7))
assert got_min == np.minimum.reduce(a, initial=7)
assert got_max == np.maximum.reduce(a, initial=-7)
def test_accumulate_identity_ignored(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this test is actually testing something useful? The mmi ufunc uses +infinity and -infinity as its default identity, which makes this trivially pass. If you defined a version mmi that raises if the initial value callback is called would allow you to verify that does not in fact ever happen.

@ikrommyd ikrommyd Oct 17, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed this test in #32212 actually cause indeed it wasn't testing anything. If you want me to add yet another ufunc in _reduction_loop_tests.cto do the test you suggested here, let me know.

@ikrommyd
ikrommyd force-pushed the accumulate-reduction-loops branch 2 times, most recently from fb1395e to 9c59abb Compare October 17, 2026 14:55
@ikrommyd
ikrommyd force-pushed the accumulate-reduction-loops branch from 0a07ad5 to c88841d Compare October 28, 2026 22:57
@ikrommyd

Copy link
Copy Markdown
Member Author

Okay this is now rebased on top of main since #32212 has been merged and the feedback from that PR has been considered here. This should be ready for a review pass now @ngoldbaum

@ikrommyd
ikrommyd requested a review from ngoldbaum October 28, 2026 23:02
@ikrommyd
ikrommyd marked this pull request as ready for review October 28, 2026 23:09
@ngoldbaum

Copy link
Copy Markdown
Member

I only found one issue during this review pass. Consider this IPython script:

import numpy as np

a = np.ones(200_000, dtype=object)
indices = np.arange(a.size)
out = np.empty_like(a)
rows = a.reshape(-1, 2)
%timeit np.add.reduceat(a, indices, out=out)
%timeit np.add.accumulate(rows, axis=1)

rows = np.ones((100_000, 2))
%timeit np.add.accumulate(rows, axis=1)

On 47f8d7a (just before #32212 was merged), this prints on my ARM macbook:

286 μs ± 1.34 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
872 μs ± 7.27 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
362 μs ± 9.35 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

And on this PR it prints:

556 μs ± 959 ns per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
1 ms ± 2.25 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
519 μs ± 609 ns per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

Object reductions show the slowdown more clearly for both operations but I'm only able to reproduce it for accumulate using float arrays.

I think to get the performance back, you need to restore the old specialized single-output loops with something like:

if (nout == 1) {
    /* Specialized single-output loop. */
}
else {
    /* Generalized multi-output loop. */
}

That said, I'm not sure how load-bearing these code paths are for performance or if it's worth hyper-optimizing.

@ikrommyd

ikrommyd commented Oct 29, 2026 •

Copy link
Copy Markdown
Member Author

I'll do an investigation. I did not see this when we had done reduce only and I also don't understand how the code changes could cause that. I mean for very small arrays just because we're handling an array of size 1 of things instead of just the thing now I would say it's fine but your array is kinda large here.

@ikrommyd

ikrommyd commented Oct 29, 2026 •

Copy link
Copy Markdown
Member Author

Oh I see what you did in the benchmark, you are calling reductions along small axes....yeah and the inner loop is called once per row and now each inner loop has more fixed setup overhead I guess which is comparable to the actual loop call. I originally thought you had found some O(N) regression but yeah...it's a small dimension problem. Got scared a bit for my code 🤣

@ikrommyd

Copy link
Copy Markdown
Member Author

Oh the main issue is not that we are dealing with size 1 arrays now to cover the multi output reductions....It's the refactoring to deduplicate code that introduced more functions and function calls and helpers on the hot path of calling the loop for these small dimension reductions....

@ngoldbaum

Copy link
Copy Markdown
Member

It's the refactoring to deduplicate code that introduced more functions and function calls and helpers on the hot path of calling the loop for these small dimension reductions....

Hmm maybe we can give hints to the compiler to encourage inlining

@ikrommyd

ikrommyd commented Oct 29, 2026 •

Copy link
Copy Markdown
Member Author

It's the refactoring to deduplicate code that introduced more functions and function calls and helpers on the hot path of calling the loop for these small dimension reductions....

Hmm maybe we can give hints to the compiler to encourage inlining

Yeah I'm trying different things out accros macos and linux to see if I can do anything. There's NPY_INLINE but functions calls are not 100% of the regression. The hammer is as you said the if (nout == 1) {...} else {...} pattern which the branch predictor will get very quickly on the hot path so if my attempts fail, we got that.
I'm basically a bit scared of tryng to make the compiler do something since numpy has to support all these architectures and all these different compilers so even if I find something on my machines, it's hard to make a general statement I guess.

@ikrommyd

Copy link
Copy Markdown
Member Author

@ngoldbaum see cb5224f for my attempt.

@ngoldbaum

Copy link
Copy Markdown
Member

Looks nice! Just a thought: why not specialize two-output reductions too?

@ikrommyd

ikrommyd commented Oct 31, 2026 •

Copy link
Copy Markdown
Member Author

Looks nice! Just a thought: why not specialize two-output reductions too?

Hmm that's interesting, do you wanna move this to a follow-up PR perhaps? Worth an investigation for sure but as long as we're not doing any bad regressions here I'd want to get accumulate out of the to-do list.

@ngoldbaum

Copy link
Copy Markdown
Member

@ikrommyd I was thinking of something like this: https://gist.github.com/ngoldbaum/37a0d444df0f2b9b48822ea0b476476e

On my Mac using Clang, the patch makes the minmax call timed in this IPython script about 10% faster:

import numpy as np

a = np.ones((10_000, 15))
%timeit np.minmax(a, axis=1)

(103 vs 92.5 microseconds).

Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
…ns on the nominal path

Signed-off-by: Iason Krommydas <[email protected]>
@ikrommyd
ikrommyd force-pushed the accumulate-reduction-loops branch from cb5224f to 14056e3 Compare November 3, 2026 18:54
@ikrommyd

ikrommyd commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

I added an nout=2 specialization too @ngoldbaum.
Had to rebase as well due to conflicts. Should be ready for a last check.

@ngoldbaum ngoldbaum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Feel free to merge when CI finishes. My last round of review found one small cleanup for a pre-existing issue on main that can come in a followup or right here if you wish. Currently the complex minimummaximum ufunc loop fails to clear the floating point error status which leads to issues like this:

In [1]: import numpy as np

In [2]: a = np.array([[1, np.nan]], dtype=np.complex128)

In [3]: with np.errstate(invalid="raise"):
   ...:     print(np.minimum.reduceat(a, [0], axis=1))
   ...:     print(np.maximum.reduceat(a, [0], axis=1))
   ...:
[[nan+0.j]]
[[nan+0.j]]

In [4]: with np.errstate(invalid="raise"):
   ...:     np._core.umath.minimummaximum.reduceat(a, [0], axis=1)
   ...:
FloatingPointError: invalid value encountered in reduceat

Don't feel like you need to wait for me to re-review if you apply this patch to fix the problem and add a test:

diff --git a/numpy/_core/src/umath/loops.c.src b/numpy/_core/src/umath/loops.c.src
--- a/numpy/_core/src/umath/loops.c.src
+++ b/numpy/_core/src/umath/loops.c.src
@@ -2547,6 +2547,7 @@
         acc_min += strides[0]; acc_max += strides[1]; x += strides[2];
         out_min += strides[3]; out_max += strides[4];
     }
+    npy_clear_floatstatus_barrier((char*)dimensions);
     return 0;
 }
 
diff --git a/numpy/_core/tests/test_umath.py b/numpy/_core/tests/test_umath.py
--- a/numpy/_core/tests/test_umath.py
+++ b/numpy/_core/tests/test_umath.py
@@ -2800,6 +2800,8 @@
             arg1 = np.array([0, cnan, cnan], dtype=complex)
             arg2 = np.array([cnan, 0, cnan], dtype=complex)
             self.check(arg1, arg2)
+            with np.errstate(invalid="raise"):
+                self.check_accumulate(arg1)
 
     def test_object_array(self):
         arg1 = np.arange(5, dtype=object)

@ikrommyd

ikrommyd commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

Thanks Nathan! Folded the patch in here. Will merge once CI is happy one last time. Cheers!

@ikrommyd
ikrommyd merged commit c486840 into numpy:main Nov 4, 2026
91 checks passed
@ikrommyd
ikrommyd deleted the accumulate-reduction-loops branch November 4, 2026 06:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants