Skip to content

ENH: implement np.unwrap as a generalized ufunc - #31848

Merged
MaanasArora merged 34 commits into
numpy:mainfrom
ikrommyd:unwrap-gufunc
Jul 22, 2026
Merged

MaanasArora merged 34 commits into
numpy:mainfrom
ikrommyd:unwrap-gufunc

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Jul 3, 2026 •

Copy link
Copy Markdown
Member

PR summary

Closes #9959

This PR implements np.unwrap as a generalized ufunc. We have to preserve the python wrapper to keep the API intact.
While making this PR I also found that unwrap had a docstring and typing issue. It's period argument can be an integer too and when both the input array and period are integers, the output dtype is also an integer. This was already shown in the docstring examples but not reflected in the types and typing stubs. I fixed both here.

The core of unwrap is now a generalized ufunc in C++ (signature (n),(),()->(n)). The loop is implemented with discont argument always being a double as the function asks for it to be a python float.
As a result, unwrap now preserves the ndarray subclasses rather than always returning a base ndarray which is what the issue wanted at the time.

I chose not to add an object dtype C++ loop here as that is way too complicated for what it offers because I assume there is very little use of unwrap for object dtypes. We fall back to the old python implementation for object dtypes. I vibe coded an object dtype loop and it's 2x faster than the fallback on my machine but I think it's not worth having this complicated C++ code for this.

For integer/float dtypes, I see a nice 2x - 3x speed improvement on my machine from limited benchmarking and memory should be improved too as the fused loop avoids copies.

As unwrap had very limited testing, I added tests here that all pass on main except the test_subclass_preserved of course because until now unwrap did not preserve ndarray subclasses.

AI Disclosure

AI tools were used to help me translate the python implementation algorithm into the C++ loop. The documenting comments above the loop were also written by AI after I asked it (but I don't know if those are useful and can be removed).

ikrommyd added 5 commits July 2, 2026 16:15
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>
@ikrommyd ikrommyd changed the title ENH: implement np.unwrap as a gufunc ENH: implement np.unwrap as a generalized ufunc Jul 3, 2026
Comment thread numpy/lib/_function_base_impl.pyi Resolved
Comment thread numpy/lib/_function_base_impl.py Resolved
Signed-off-by: Iason Krommydas <[email protected]>
@ikrommyd

ikrommyd commented Jul 3, 2026 •

Copy link
Copy Markdown
Member Author

FYI this is what one gets with the current unwrap testing on main. Some error messages are different here versus main and naturally on main subclasses are not returned. But I'd argue for the cases that unwrap shouldn't work anyways, the error messages are a bit better now instead of overflow errors and TypeError: ufunc 'remainder' not supported

FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_subclass_preserved[float32] - AssertionError: assert False
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_subclass_preserved[float64] - AssertionError: assert False
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_subclass_preserved[int8] - AssertionError: assert False
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_subclass_preserved[int32] - AssertionError: assert False
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_subclass_preserved[int64] - AssertionError: assert False
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_subclass_preserved[bool] - AssertionError: assert False
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_subclass_preserved[object] - AssertionError: assert False
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_complex_raises - TypeError: ufunc 'remainder' not supported for the input types, and the inputs could not be safely coerced to any supported types according to the casting r...
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_unsigned_integer_period_raises[uint8] - OverflowError: Python integer -4 out of bounds for uint8
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_unsigned_integer_period_raises[uint32] - OverflowError: Python integer -4 out of bounds for uint32
FAILED numpy/lib/tests/test_function_base.py::TestUnwrap::test_unsigned_integer_period_raises[uint64] - OverflowError: Python integer -4 out of bounds for uint64

@ikrommyd
ikrommyd marked this pull request as ready for review July 6, 2026 18:51
@ikrommyd

ikrommyd commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

IMO this should be ready for a review pass

Signed-off-by: Iason Krommydas <[email protected]>
@ngoldbaum
ngoldbaum requested a review from MaanasArora July 8, 2026 19:15

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

I didn't look very deeply, but will do so very soon - this looks nice, thanks! Just one bug, and mostly doc comments otherwise for now.

Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
MaanasArora

This comment was marked as duplicate.

ikrommyd added 2 commits July 11, 2026 20:52
Signed-off-by: Iason Krommydas <[email protected]>
Signed-off-by: Iason Krommydas <[email protected]>

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

OK, first off, I'm very happy with the performance improvements. As I'm sure you know, I'm very happy with doing this sort of thing to improve performance of NumPy in ways that are natural in the codebase.

Now given that, I think we should probably check in with the numpy-discussion mailing list before actually merging the PR. While this is mostly not user-visible (except via the performance and memory improvements) this does add a new ufunc to numpy, which downstream libraries are going to have to deal with. @mhvk can check in with an astropy hat on probably but there are lots of other smaller projects that this will impact as well. IMO it's worth it but it's worth pinging the mailing list to check in first.

I also did a review pass and noticed some issues that'll need to be addressed. I may spot more stuff on a further pass but hopefully we'll converge quickly after this first pass is addressed.

Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
Comment thread numpy/lib/_function_base_impl.py Resolved
Comment thread numpy/lib/_function_base_impl.py Resolved
Comment thread numpy/lib/tests/test_function_base.py Resolved
Comment thread numpy/lib/tests/test_function_base.py Resolved
Comment thread numpy/lib/tests/test_function_base.py Resolved
@ikrommyd

Copy link
Copy Markdown
Member Author

Thanks for the review! @ngoldbaum I'll address everything in the coming week as best as I can.
In general I was mostly trying to preserve behavior with main as much as I could but couldn't fully preserve the exceptions indeed. I guess because unwrap didn't really care too much about errors from how I read the imlementation and was like "yeah whatever the other functions raise". You also pointed out some real bugs in the ufunc code which I will fix obviously.

@ikrommyd ikrommyd added this to the 2.6.0 Release milestone Jul 12, 2026
@ikrommyd

ikrommyd commented Jul 17, 2026 •

Copy link
Copy Markdown
Member Author

@mhvk what are your thoughts on 9a45e86 regarding the discont parameter? I asked claude to make it NEP-50 compartible and gave it the NEP.

@ikrommyd

ikrommyd commented Jul 17, 2026 •

Copy link
Copy Markdown
Member Author

Or I have one more idea. We get rid of the whole python promotion and passing signature in the ufunc and do the promotion with a promoter in c++. @ngoldbaum and @mhvk what do you think?

@MaanasArora

Copy link
Copy Markdown
Contributor

I agree with Marten it doesn't seem to make much sense to keep e.g. discont double if the input is float32; I don't think we're gaining any real precision as this is an elementwise comparison, and there is no error accumulation / propagation. I would simply cast it to the input array dtype (you could make an exception for integers to preserve boundary precision, though there could be better ways to handle the boundary). NEP 50 applies to promoting Python scalars especially when the dtype is "real" (ends up in a result for example) but this seems to be on the other end of a specific internal mechanic. In any case, I'm pretty sure this new loop registration is complicated and not right just to keep discont's type.

@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 spotted a couple minor issues with a claude review pass. I think C promoters are generally what you want instead of implementing promotion logic in the Python wrapper, but I'll defer to Maanas and Sebastian's opinions.

Comment thread numpy/lib/_function_base_impl.py Resolved
Comment thread numpy/_core/src/npysort/x86-simd-sort Resolved
Comment thread numpy/lib/tests/test_function_base.py Resolved

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

Just two fly-by nits... the discont handling still needs to be resolved of course.

For the promotion logic, I'm not sure if C is the right level. We only need to do what PyArray_ResultType does, but this is mostly just legacy behavior, not sure if it will be simpler enough as we handle non-builtins too. But maybe Sebastian has a better idea if it's worth it.

Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
@ikrommyd

ikrommyd commented Jul 18, 2026 •

Copy link
Copy Markdown
Member Author

Because I know very little about implementing promoters, I had claude generate me one to see the idea and pushed it in another branch. This is the commit: ikrommyd@c32bc51. Do you think we should go into that direction?

@MaanasArora

Copy link
Copy Markdown
Contributor

I still don't think we should care about specializing precision for discont, and that branch just moves the same logic into C. I think that should probably be resolved first. To be clear, I think a C promoter is worth it if it is more simply correct (for both legacy behavior and user dtypes), not to solve that problem.

@ikrommyd

Copy link
Copy Markdown
Member Author

Main's output dtype is result_type(p, period) only, never discont, so folding discont into that promotion would change the return dtype based on a threshold value, which is worse than what we're fixing. But discont's own comparison precision on main already follows NEP 50 (weak scalars downcast to p's dtype, explicit ones promote), so a gufunc that always compares discont at a fixed type (double, or always T) diverges from main in one direction or the other. For example if p=[0,3] with float16 dtype, and discont=3.0001, main gives [0,-1], while fixed-double gives [0,3]. That's why I think discont needs its own resolution, separate from T. Doing it in python or c++ does not matter much.

@MaanasArora

MaanasArora commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

I agree casting to T is technically a behavior change, just not sure if we shouldn't make it. I also don't think it's going to affect weak scalars (as tested), just explicit ones.

But even right now, an explicit longdouble discont would still lose precision. Then we would need at least 3 more loops to bring this to its logical completion!

@ikrommyd

Copy link
Copy Markdown
Member Author

I'd be fine with casting to T for floats but we can't do it for integer Ts

@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 think @MaanasArora and @ikrommyd have actually converged on a design, see my comment below. I think a one-liner is better than a C++ promoter.

Comment thread numpy/lib/_function_base_impl.py Resolved
Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
Comment thread numpy/lib/tests/test_function_base.py Resolved
Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
Comment thread numpy/lib/_function_base_impl.py Resolved
Comment thread numpy/_core/src/umath/unwrap.cpp Resolved
@ikrommyd

Copy link
Copy Markdown
Member Author

Is it a thing to do multiple release notes like an improvement + another one for behavior changes or should everything go into the same ‎doc/release/upcoming_changes/9959.improvement.rst?

@ngoldbaum

Copy link
Copy Markdown
Member

Two different release notes are a thing, you can add a second one for the changes.

ikrommyd added 3 commits July 21, 2026 13:00
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 @MaanasArora I believe I have addressed the comments and have added the converged dtype logic in 86a2566
The riscv CI failure is unrelated infrastructure problem.
Regarding Nathan's comments about UB with signed integers, I think these should probably all be fixed centrally in numpy at some point and not do anything here as no numpy additions/subtractions that I saw care about UB so far.

@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! I'd appreciate it if one of Maanas or Marten could look this over and hit the merge button if they approve.

Also maybe worth a ping to the mailing list as a head's up for downstream maintainers who try to be fully compatible with all NumPy ufuncs. That's pretty much just astropy (and Marten already knows) but it may be other people too.

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

Looks good to me! Thanks for the changes, I think the dtype resolution comes together quite nicely. I didn't look very closely at the tests but they seem thorough. In it goes... thanks @ikrommyd for the nice improvement!

@MaanasArora
MaanasArora merged commit dbe3531 into numpy:main Jul 22, 2026
94 of 95 checks passed
@ikrommyd
ikrommyd deleted the unwrap-gufunc branch July 22, 2026 12:57
@ikrommyd

Copy link
Copy Markdown
Member Author

Thank y'all for the reviews! Cheers!

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.

Make unwrap a ufunc

7 participants