Skip to content

ENH: add mechanism to register reduction loops to ufuncs for multi-output reductions - #31816

Merged
ngoldbaum merged 26 commits into
numpy:mainfrom
ikrommyd:reduction-loops
Jul 30, 2026
Merged

ngoldbaum merged 26 commits into
numpy:mainfrom
ikrommyd:reduction-loops

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Jun 30, 2026 •

Copy link
Copy Markdown
Member

PR summary

This is adding the ability to add reduction loops to ufuncs as it was being recently discussed in the numpy community meetings. The current reductions are implemented using the binary ufunc loops which are 2 -> 1 (2 inputs 1 output) and by setting the output pointer equal to the first input pointer which makes it the accumulation location and not moving it. We generalize this logic for ufuncs that return N outputs. However the normal ufunc loop cannot be used in a similar way in this case. The trick is not possible. So we add the ability to add reduction loops to ufuncs that are N + 1 -> N loops while their nominal/forward loops are 2 -> N.

This is done via a single new function pointer on the array method called get_reduction_loop similar to get_strided_loop. The whole reduction machinery is then generalized to support loops that are N + 1 -> N instead of hardcoding the assumption that the loop is 2 -> 1. In the nominal binary ufunc case, everything collapses down to the old 2 -> 1 assumption. This generalization was the "easy" part. I kept it to reduce right now to minimize the diff for comments but can done for reduceat and accumulate in a similar manner. The "difficult" algorithmically part for me was the reducelike_promote_and_resolve_multi function mainly because since the reduction loops live on the array method via the get_reduction_loop function pointer, we have to resolve the dtypes for the "forward pass" loop, get the array method from ufunc object's _loops and then get the reduction loop from it. This potentially needs some discussion about how it's best to do so. I generally like the fact that I have to touch only a few files to implement this whole machinery here.

An example of how this machinery can be used by numpy or an extension is here: https://gist.github.com/ikrommyd/51dddb52e54c6f99aa86637b16a11d22
The PR/design should be reviewed in alignment with that.
Here I have a minmax.cpp extension with a setup.py that implements a minmummaximum ufunc and registers reduction loops for it which would be minmax. I also include a test file test_minmax.py in the gist.

This is obviously not ready, I'm just opening it as a draft for comments about the core ideas. We definitely need to consolidate the design and add better checks and error messages in a few places. Currently on my macos and linux machines both the numpy testing suite and test_minmax.py pass so I'm curious to see what the full CI does.

I plan to make a PR on my fork soon (next couple of days) that targets the PR branch that implements minmax as part of numpy using the reduction loop mechanism instead of having it in a separate extension.

This is towards #9836

First time committer introduction

N/A

AI Disclosure

Claude Opus 4.8 via the Claude Code harness has been used for A) querying around the numpy codebase as a fancier grep B) reviewing for logic or reference counting bugs and C) to write comments that I was too lazy to write myself. The whole design and ideas are my own and most of the C code is my own. The AI has mainly fixed my bugs and wrote code to register the reduction loops on the ufunc in my minmax.cpp extension in the gist.

cc @ngoldbaum @seberg please tag anyone else who might be interested in taking a look at the design.

P.S. ignore the changes in numpy/_core/src/umath/loops_minmax.dispatch.c.src, they are just for benchmarking purposes because my minmax.cpp extension has no SIMD right now.

@ikrommyd ikrommyd changed the title Reduction loops ENH: add mechanism to register reduction loops to ufuncs for multi-output reductions Jun 30, 2026
@seberg

seberg commented Jul 1, 2026

Copy link
Copy Markdown
Member

CC @mhvk I would be very curious if you have some high-level thoughts on this?

@mhvk

mhvk commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@seberg - thanks for the ping!

@ikrommyd - this looks very nice indeed, and I like the general idea of having a new slot available to register reduction functions. Like you, I'm also pleased that in the end the changes are not very intrusive.

On the PR itself, one thing I think is important is to have something in numpy that exercises the new function; possibly, your minmax, maybe just for integers or floats, could serve that purpose? You'll have seen that there are several _core/src/umath/_*.c test functions, which you could add too.

@ikrommyd

ikrommyd commented Jul 1, 2026 •

Copy link
Copy Markdown
Member Author

Thanks @mhvk! Yeah I was planning to open a separate PR targetting this PR's branch that implements minmax in numpy but it wouldn't be much different than what the gist already has. I mean one just registers the reduction loops using the slot and PyUFunc_AddLoopFromSpec which is how normally one adds ufunc loops in the new-style array method API. I'm just working on something else too the last couple of days so I got caught with that. Let me know if you have any other suggestions about what you'd like to see. I was also planning to do this in an external extension like user dtypes or quaddtype to show how such extensions would interact with this API. I was also considering writing a short NEP about this too once the design is consolidated just for documentation purposes.

@ngoldbaum

Copy link
Copy Markdown
Member

If Marten's OK with a minmax reduction landing as part of this PR, I'm fine with it too.

IMO a NEP isn't necessary to merge this, so it would be purely for documentation purposes. It would also add some process overhead that you might not want to deal with.

@ikrommyd

ikrommyd commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Yeah I wanted to keep the diff minimal for easier first look here on the design. We also need to touch reduceat and accumulate in a similar manner to reduce so that all 3 reduction-like ops work with this. Happy to add minmax + reduceat + accumulate changes here. I generally find smaller PRs easier to work with from the reviewers' perspective and then I would apply all the comments made here for reduce to accumulate and reduceat so what I originally had in mind was to have these and minmax as separate PRs but happy to adjust according to your liking 😃

@mhvk

mhvk commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

All sounds good. I agree with the sense to stick to just reduce here, but include an example of some kind. I think your current minmax is perhaps more suitable as a simple test case, since it is not vectorized---I find it also really helpful to have those test examples... But just suggestions, do follow what works best for you, this is a nice contribution as is! One thing, as you were mentioning documentation, that definitely would need to be added, at least in the C API text.

Comment thread doc/source/user/basics.ufuncs.rst Outdated
Traceback (most recent call last):
...
ValueError: reduce only supported for functions returning a single value
ValueError: divmod.reduce is not supported: the resolved loop does not register a reduction loop

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.

just wanted to make the docs ci pass, this whole section needs refactoring now that multi-output reductions are allowed

@ikrommyd

ikrommyd commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Okay since I have not seen any opposing feedback on the design here, I plan to turn this into a more proper PR this week with testing and documentation. As Marten suggested, I will add the minmax toy example as a C test.
Then people can properly review and the feedback will be considered in the separate PRs that will touch accumulate and reduceat in a similar manner.

@ikrommyd

ikrommyd commented Jul 9, 2026 •

Copy link
Copy Markdown
Member Author

I've added documentation updates and I took my minmax and turned it into a C test by adding a double loop only + promoter for python scalars. I didn't want to add full dtype support as that will be done in the actual minmax implementation that will follow. The linting failure is unrelated and is on a file I'm not touching here.

IMO this is ready for the first review pass. There may be some doc displaying issues cause I didn't check the website preview but will do that later. Gonna do a rebase to main too.

@ikrommyd ikrommyd added this to the 2.6.0 Release milestone Jul 9, 2026
@ikrommyd
ikrommyd requested review from mhvk, ngoldbaum and seberg July 9, 2026 05:25
@ikrommyd
ikrommyd marked this pull request as ready for review July 9, 2026 05:25
@ikrommyd
ikrommyd force-pushed the reduction-loops branch 2 times, most recently from 0ddc006 to c9f76ae Compare July 9, 2026 17:16

@seberg seberg 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.

Overall, very nice, I have only some small comments. I think that with the current minmax + tests using initial= we should have a fairly reasonable test coverage, which is also good (the no "identity" and identity logic part).
One thing we could make sure to test is fortran order (it shouldn't matter but that way we have strides != 0 for the reduced array), object dtype could be nice, but I don't want to bloat this too much (i.e. the initial buffer bug).

keeping their own dtype. A multi-dtype ufunc would instead register a
loop per dtype and pick the common one, as in the built-in
:func:`numpy.minimum`/:func:`numpy.maximum`.
- It has no :c:macro:`NPY_METH_get_reduction_initial`, so

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.

And I guess unfortunately that would need a char **initial or so (I don't have a thought beyond adding that and maybe considering to actually do a deprecation of the current one).
But yeah, we can follow up...

Comment thread numpy/_core/src/umath/reduction.c Resolved
Comment thread numpy/_core/src/umath/reduction.c Resolved
Comment thread numpy/_core/src/umath/reduction.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
* cross-operand constraint is the structural one that `acc_i` aliases `out_i`.
*/
static PyArrayMethodObject *
reducelike_promote_and_resolve_multi(PyUFuncObject *ufunc,

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 should re-read this once more eventually, but it looks not all that scary! Hopefully we don't find weird corner cases :).

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.

Yeah if you have any ideas on the algorithm, please let me know. This is the the algorithm I came up with but I don't know much about dtype resolution edge cases.

@ikrommyd

ikrommyd commented Jul 15, 2026 •

Copy link
Copy Markdown
Member Author

Merge conflict is simple. It's just the new member in the array method struct from the loop caching PR. Will fix on the next rebase. Just don't wanna ruin the state right now.

@ikrommyd

ikrommyd commented Jul 18, 2026 •

Copy link
Copy Markdown
Member Author

I have implemented one more small get_multi_reduction_initials method on the array method to be able to set initial values to multi output reductions. Potentially we can deprecate the old get_reduction_initial and use this one which can definitely do the single output reductions too as Sebastian suggested considering earlier.

@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.

Meaty review for a meaty PR!

I think this is well on its way but there are still a bunch of issues my AI-assisted review found, see below.

Comment thread doc/source/reference/c-api/array.rst Resolved
Comment thread doc/source/reference/c-api/array.rst Resolved
Comment thread doc/source/reference/c-api/array.rst Resolved
Comment thread doc/source/reference/c-api/array.rst Resolved
Comment thread doc/source/user/basics.ufuncs.rst Resolved
Comment thread doc/source/reference/c-api/array.rst Resolved
Comment thread numpy/_core/src/umath/reduction.c Resolved
Comment thread numpy/_core/src/umath/reduction.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
@ikrommyd
ikrommyd requested review from ngoldbaum and seberg July 22, 2026 20:16
@ikrommyd

ikrommyd commented Jul 22, 2026 •

Copy link
Copy Markdown
Member Author

Whew that took a while to get done haha. I feel like I've addressed all your review comments @ngoldbaum!

@ngoldbaum

Copy link
Copy Markdown
Member

@ikrommyd OK, I think this is ready now. You'll need to clear the merge conflict and then I think I'll go ahead and merge.

While you're doing that, I also think this test would add some coverage for the object forward loop:

def test_object_forward(self):
    a = np.array([3, -7, 12], dtype=object)
    b = np.array([1, 9, -2], dtype=object)
    got_min, got_max = mm(a, b)
    assert got_min.tolist() == [1, -7, -2]
    assert got_max.tolist() == [3, 9, 12]

ikrommyd added 17 commits July 29, 2026 16:47
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
@ikrommyd

Copy link
Copy Markdown
Member Author

@ngoldbaum - rebased, fixed the conflict and added the test you suggested.

@ngoldbaum

Copy link
Copy Markdown
Member

OK, no one else weighed in since I suggested merging this yesterday, so in it goes!

If anyone would like to register any comments or suggestions, just ping Iason and he'll get to it on a second pass.

@ngoldbaum
ngoldbaum merged commit 10613a8 into numpy:main Jul 30, 2026
91 checks passed
@ikrommyd
ikrommyd deleted the reduction-loops branch July 30, 2026 18:59
seberg pushed a commit that referenced this pull request Sep 10, 2026
Implements np.minmax using the new ability to register reduction loops to ufuncs implemented in #31816

The loops are implemented in the existing .c.src files with the np.minimun/maximum loops and using exactly the same optimizations and SIMD as them. Two new files minmax.cpp and minmax.h are created just to create the ufunc and initialize the array methods.
I found this to be by far the easiest setup to do now. The other option would be porting the whole loops_minmax.dispatch.c.src to highway and using only c++ but that is a more complicated change with a way more difficult review so I did not do that.

minmax is not a method on the ndarray like min and max are. The only thing exposed in the top-level API is the new np.minmax function. The minimummaximum ufunc is not exposed as np.minimummaximum like np.minimum and np.maximum are as I do not believe there's much use in that. If we ever need it, it's a very simple change.

We should also consider adding a nanminmax in a follow-up PR for completeness and also adding a masked array aware implementation (either by making minmax a method on the ndarray or only by a np.ma.minmax).

Signed-off-by: Iason Krommydas <[email protected]>
ngoldbaum added a commit to ngoldbaum/numpy that referenced this pull request Nov 3, 2026
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.

4 participants