Skip to content

ENH: implement np.minmax - #32231

Merged
seberg merged 33 commits into
numpy:mainfrom
ikrommyd:np-minmax
Sep 10, 2026
Merged

seberg merged 33 commits into
numpy:mainfrom
ikrommyd:np-minmax

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

PR summary

Closes #9836

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

First time committer introduction

N/A

AI Disclosure

AI has been used for writing most of the boilerplate such as import locations and the almost entirely copy-pasted doscstrings from min/max. AI has also been used to review the loops which are mostly copy-pasted from the minimum/maximum loops and find better SIMD optimizations on them that are specific to the minmax case. I'd really appreciate the opinion of a SIMD person here as the only thing I can argue about is SIMD is just trying out and seeing if it's faster or slower. My knowledge stops there.

@ikrommyd ikrommyd added this to the 2.6.0 Release milestone Aug 9, 2026
@jorenham
jorenham self-requested a review August 9, 2026 15:22
@github-actions

This comment has been minimized.

1 similar comment
@github-actions

This comment has been minimized.

@ikrommyd

ikrommyd commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

I don't know what the above message is.

@jorenham

jorenham commented Aug 9, 2026

Copy link
Copy Markdown
Member

I don't know what the above message is.

This seems to be a mypy bug that's related to #32215 and only occurs for certain mypy configurations in there's a assignment to a variable called _. I'll open a mypy issue for this.

TLDR; it's unrelated

@github-actions

This comment has been minimized.

@ikrommyd
ikrommyd marked this pull request as ready for review August 10, 2026 17:03
@ikrommyd
ikrommyd requested a review from ngoldbaum August 10, 2026 17:04
@ikrommyd

Copy link
Copy Markdown
Member Author

@ngoldbaum I think this should be ready for a quick pass as well.

@ngoldbaum

Copy link
Copy Markdown
Member

@seiko2plus do you have any thoughts on the SIMD here and extending our current .c.src templating?

Maybe @mhvk has an opinion?

IMO this is fine and the simplest way to do this. It does make it marginally more complicated for someone else to eventually move away from .c.src templating but it also doesn't leave us in a weird half-converted state either.

@ikrommyd

Copy link
Copy Markdown
Member Author

I'd argue that this was the simplest way to do this. We want np.minmax to have identical behavior to return np.min(...), np.max(...) in all cases so adding the loops right below where the minimum/maximum loops are defined using the same optimizations as them in .src files, creating the ufunc using the legacy way to have it implicitly get the legacy dtype promotion too, and finally using only a small c++ file just to add the reduction loops to the already created ufunc seemed like the simplest and shortest way to get this to me.

@mhvk mhvk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Had a relatively quick look, which led to some small comments.

On the question of code organization, this does seem the most sensible approach, keeping very similar loops together.

Comment thread numpy/_core/src/umath/loops.c.src
Comment thread numpy/_core/src/umath/minmax.cpp
Comment thread numpy/_core/code_generators/ufunc_docstrings.py
Comment thread numpy/_core/src/umath/loops.c.src
Comment thread numpy/_core/fromnumeric.py Outdated
@github-actions

This comment has been minimized.

4 similar comments
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Comment thread numpy/_core/src/umath/minmax.cpp

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

My AI model points out that np.minmax is silently incorrect for masked arrays:

ma = np.ma.masked_array([1.0, 100.0, 2.0], mask=[0, 1, 0])
np.min(ma), np.max(ma)   # (1.0, 2.0)
np.minmax(ma)            # (1.0, 100.0)

I think we can add a simple minmax method on the MaskedArray class to fix this:

def minmax(self, axis=None, out=None, fill_value=None, keepdims=np._NoValue):
    outs = (None, None) if out is None else out
    return (self.min(axis=axis, out=outs[0], fill_value=fill_value, keepdims=keepdims),
            self.max(axis=axis, out=outs[1], fill_value=fill_value, keepdims=keepdims))
np.ma.MaskedArray.minmax = minmax

IMO this is worth adding to paper over the issue, even if it is adding a (minor) new feature to MaskedArray. Of course I know people don't like the attribute-forwarding this relies on and may not want to do more of it. A np.ma.minmax is certainly more principled and I'm happy to defer until that if you think the method isn't a good idea.

Also it looks like StringDType isn't supported:

>>> np.minmax(arr)
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    np.minmax(arr)
    ~~~~~~~~~^^^^^
numpy._core._exceptions._UFuncNoLoopError: ufunc 'minimummaximum' did not contain a loop with signature matching types (<class 'numpy.dtypes.StringDType'>, <class 'numpy.dtypes.StringDType'>) -> (None, None)

What do you think about implementing StringDType minimummaximum loops here like you do for the other dtypes, following the existing minimum and maximum implementations? It also looks like np.min and np.max are completely untested in test_strings.py - seems worth adding tests for those along with minmax to me.

Comment thread numpy/_core/fromnumeric.py Outdated
Comment thread numpy/_core/tests/test_multiarray.py
Comment thread numpy/_core/src/umath/minmax.cpp
@ikrommyd

ikrommyd commented Aug 18, 2026 •

Copy link
Copy Markdown
Member Author

@ngoldbaum - On the string dtype (and potentially for other user dtypes) how about we add a generic fallback to the minmax python wrapper that just does return np.min(...), np.max(...) if the minmax loops raises such a type error? Such a thing would also cover other user dtypes that implement min and max but not minmax. Then I can do string dtype loops in an immediate follow-up PR and increase coverage in test_strings.py for string dtype in particular.

For masked arrays, I'd argue to not add a minmax method, but we can simply add np.ma.minmax that just does return np.ma.min(...), np.ma.max(...) unless you are worried about the fact that np.min now works properly for masked arrays (because it uses the ndarray method) and similarly want np.minmax to work properly for masked arrays but I'd argue that this is an asymmetry because ndarrays have no minmax method here so it would probably be best to not have one for masked arrays too. If you definitely want np.minmax to properly dispatch to the masked array one for masked arrays, I'd say we add minmax as a method on the ndarray then but we did not want such a thing originally and that automatic dispatching unfortunately only works for things that have corresponding ndarray methods. In any case, I don't think we should ever write minmax mask-aware loops for masked arrays.

@ngoldbaum

Copy link
Copy Markdown
Member

A generic fallback for stringdtype and user dtypes would be nice!

Fair enough on masked arrays.

@mhvk

mhvk commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Agreed with @ikrommyd to just add a np.ma.minmax function - that is how it works for much of the rest of the numpy functions, and thus leaves things in a consistent state (until someone gets around to implement MaskedArray.__array_ufunc__ and __array_function__...)

@jorenham

Copy link
Copy Markdown
Member

For masked arrays, I'd argue to not add a minmax method, but we can simply add np.ma.minmax that just does return np.ma.min(...), np.ma.max(...)

Should we really be adding new np.ma functionality though 😅?

@ikrommyd

ikrommyd commented Aug 18, 2026 •

Copy link
Copy Markdown
Member Author

For masked arrays, I'd argue to not add a minmax method, but we can simply add np.ma.minmax that just does return np.ma.min(...), np.ma.max(...)

Should we really be adding new np.ma functionality though 😅?

It's good for consistency and it's literally a one-liner function. The docstring what we're adding effectively to the review.

@jorenham

jorenham commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

For masked arrays, I'd argue to not add a minmax method, but we can simply add np.ma.minmax that just does return np.ma.min(...), np.ma.max(...)

Should we really be adding new np.ma functionality though 😅?

It's good for consistency and it's literally a one-liner function. The docstring what we're adding effectively to the review.

I'm more worried that when we put it in the relnotes, it might lead to people thinking that it's a good idea to use np.ma 😛

@ikrommyd

ikrommyd commented Aug 18, 2026 •

Copy link
Copy Markdown
Member Author

We can sneak it in the single release note for numpy.minmax like I have it now lol. Unfortunately people do use masked arrays regardless. We are not giving them any benefits here apart from the API anyways.

I believe I have addressed this round of review comments from everyone.

@shoumikchakravarty-dev

Copy link
Copy Markdown
Contributor

Hi @ikrommyd - On the top-level API question - np.minmax would definitely be helpful. Bounding boxes and plot axis limits both need min and max on the same array and currently that's two separate calls walking the data twice. Even more useful on large datasets. A nanminmax version alongside np.minmax would be useful too. If minmax ships without it, users will hit the gap immediately and end up writing data[~np.isnan(data)] every time.

Signed-off-by: Iason Krommydas <[email protected]>
@ikrommyd

Copy link
Copy Markdown
Member Author

Thanks @seberg! I think I did what you wanted in 76eefc1
I also rebased to be on top of main and found a build failure that's now fixed.

@seberg

seberg commented Sep 10, 2026

Copy link
Copy Markdown
Member

Yap, thanks, fly-by fixes also looks right. Let's merge when CI is done and then follow-up if necessary :).

@seberg
seberg merged commit 7f16df0 into numpy:main Sep 10, 2026
93 checks passed
@ikrommyd
ikrommyd deleted the np-minmax branch September 10, 2026 11:44
eendebakpt added a commit to eendebakpt/numpy that referenced this pull request Sep 11, 2026
Conflicts came from numpygh-32231 (np.minmax), which extended the hardcoded
reduction fast path this branch replaces with the generic tuple-spec and
forward= machinery.

* arrayfunction_override.c: keep this branch's forwarding fast path.
* fromnumeric.py: max/prod keep their tuple-spec dispatchers; minmax keeps
  the callable _minmax_dispatcher, since its out= may be a tuple of two
  arrays which a tuple-spec cannot unpack for the override check.  It
  therefore has no C fast path for now and relies on the Python
  _UFuncNoLoopError fallback as before.
* overrides._ReductionKind and the matching C enum stay removed.
@GniLudio

Copy link
Copy Markdown

Did anybody benchmark on how much of a difference this makes?

@ngoldbaum

Copy link
Copy Markdown
Member

Did anybody benchmark on how much of a difference this makes?

@ikrommyd did but I can't find a public link offhand.

However it's pretty easy to find cases that are wins:

In [1]: arr = np.random.random(100_000_000)

In [2]: %timeit (np.min(arr), np.max(arr))
17.7 ms ± 133 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

In [3]: %timeit np.minmax(arr)
9.15 ms ± 56.2 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)

In [4]: (np.min(arr), np.max(arr))
Out[4]: (np.float64(2.454591385703253e-08), np.float64(0.9999999596284977))

In [5]: np.minmax(arr)
Out[5]: (np.float64(2.454591385703253e-08), np.float64(0.9999999596284977))

In [6]: arr = np.random.random(12)

In [7]: %timeit (np.min(arr), np.max(arr))
457 ns ± 1.33 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

In [8]: %timeit np.minmax(arr)
299 ns ± 4.5 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

In fact, I think we checked at one point and couldn't find any cases where it isn't a win to use minmax if you really do need both min and max.

@ikrommyd

ikrommyd commented Sep 11, 2026 •

Copy link
Copy Markdown
Member Author

@GniLudio - Yeah I had done some benchmarks. it's 1.25x to 2x better usually and never worse. Think about it this way, your core computation does not change, you still need to accumulate the min and max. What changes is how often you pass over the array and read it's data. If your operation is bottleneck is reading from memory (you are reducing along a contiguous axis), then the boost is around 2x cause you reduced the reading by half. If your bottleneck is actually calculating the min and max, then the speed up is much smaller at around 25%. These are usually platform dependent too and dependent on what instructions your cpu can run. If you find a case that it isn't faster, it's almost surely a bug that we should fix.

@GniLudio

Copy link
Copy Markdown

@GniLudio - Yeah I had done some benchmarks. it's 1.25x to 2x better usually and never worse. Think about it this way, your core computation does not change, you still need to accumulate the min and max.

I was just surprised to not see any mentions of benchmarking results. It's always easy to assume that something is faster, just to find out that it's not. Glad to see that you actually tested it and got the expected speedups.

@jakirkham

Copy link
Copy Markdown
Contributor

Very cool! Thank you for working on this Iason. Also thank you everyone who helped review 🙏

@andyfaff

Copy link
Copy Markdown
Member

Sorry for commenting on a merged PR. Is it usual to add a .. versionadded:: to the docstring for additions like this? End users often need to know in what version of the package functions were added.

@ikrommyd

ikrommyd commented Sep 14, 2026 •

Copy link
Copy Markdown
Member Author

I thought it usually is for features on an existing function. Will make a PR.

Edit: #32607

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.

ENH: minmax function