Skip to content

ENH: support N-D arrays in np.searchsorted by searching along the last axis - #32346

Open
ikrommyd wants to merge 15 commits into
numpy:mainfrom
ikrommyd:searchsorted-axis
Open

ikrommyd wants to merge 15 commits into
numpy:mainfrom
ikrommyd:searchsorted-axis

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Aug 19, 2026 •

Copy link
Copy Markdown
Member

PR summary

Closes #4224.

np.searchsorted and ndarray.searchsorted no longer require the sorted array to be one-dimensional. Each 1-D slice along the last axis is searched independently. There is no axis keyword: to search along another axis, bring it to the end first, for instance with np.moveaxis.

Internally this is four private gufuncs, _searchsorted_{left,right} with signature (n),(m?)->(m?) and _searchsorted_{left,right}_sorter with (n),(m?),(n)->(m?). side picks the gufunc statically, so the kernels in npysort/binsearch.cpp are unchanged and still selected as get_binsearch_func(dtype, side) did.

(n),(m?)->(m?) is the signature and what array-api-extra implements, with the flexible m? so a scalar v keeps returning a scalar. Keys are v's last axis and leading axes broadcast against a's other dimensions. The searched dimension of the result lands last, which is the plain gufunc default, so no axes juggling is needed and the layout matches JAX and array-api-extra. Regarding Jake's question, there is no batch_dims equivalent, leading dims broadcast right-aligned, so the batched cases need a newaxis manually from the user. Against array-api-extra the results agree wherever both accept a call, and numpy additionally broadcasts in both directions, takes a sorter, promotes a and v by value, and covers every dtype with a comparison rather than real numbers only.

The gufunc is an internal layer rather than the Python entry point: dispatch stays in C, PyArray_SearchSorted is unchanged and serves as the fallback, and the legacy ArrFunc_Compare layer is untouched. The dtype slot is not here, but a registered loop is the only thing that grants a dtype N-D support, which is the seam a searchsorted_meth slot plugs into, so that follow-up is additive and would close the existing TODO in item_selection.c. Exposing axis later is additive in the same way.

N-D works for all builtin dtypes and for run-time registered ones via a promoter. StringDType stays 1-D for now and says so, rather than only reporting the depth of a.

AI Disclosure

AI has been used a lot in brainstorming about this PR about the signature and potential ways to implement the PR and to create quick rough sketches to try out myself which would have taken me many days to do manually. Eventually I iterated with AI to figure out how to logically do the dtype handling and for review.

@ikrommyd ikrommyd changed the title Searchsorted axis ENH: add axis support to np.searchsorted Aug 19, 2026
@ikrommyd ikrommyd added this to the 2.6.0 Release milestone Aug 19, 2026
@ngoldbaum

Copy link
Copy Markdown
Member

We talked about this during the triage review meeting. We think that maybe the simplest thing is to simply drop the axis keyword argument, keep the gufunc, and force people who would need to use axis to reshuffle.

@ikrommyd

Copy link
Copy Markdown
Member Author

In the last commit I've droppied axis and I'm only searching along axis=-1 by default internally. There are unrelated CI failures due to cython. I have not updated the PR description and title. I just pushed this change just to brainstorm what's best.

@MaanasArora

Copy link
Copy Markdown
Contributor

Thanks, this is looking nice, the implementation is coming along really well and many general improvements! Just a few high level comments for now, just opening them up for discussion really in any case anyone has any thoughts.

From a brief look my biggest concern is about having four gufuncs for the same operation. That seems like we are exposing implementation/selection details to the gufunc object itself. I think it is better to keep it to one gufunc and use the void *data argument (which seems to be currently perm) to select among implementations for sides and sorted. I think this is at least true for sides, which is very much a parameter. This could be implemented as a struct in the same way as for example PyArrayMethod_SortParameters. This getter-style architecture also lets us evolve arguments if we ever need to. FWIW, I think a similar discussion was actually had in #29737, but for algorithm choice.

The other point is that we are still wrapping the gufunc, with PyArray_SearchSorted_int? In that case could it not be better to redirect from PyArray_SearchSorted itself? It seems here we have something like PyArray_SearchSorted_int -> gufunc (if found) -> otherwise PyArray_SearchSorted, but PyArray_SearchSorted -> PyArray_SearchSorted_int (or directly use the gufunc) -> otherwise fallback seems more intuitive to me. Of course it's consistent with the sorts that way as well, but I guess I'm just wondering if the entrypoint here should be changed, to me it seems this adds a layer in between of the existing two layers, it's not really a change in the entrypoint in essence.

@ikrommyd

ikrommyd commented Aug 26, 2026 •

Copy link
Copy Markdown
Member Author

From a brief look my biggest concern is about having four gufuncs for the same operation. That seems like we are exposing implementation/selection details to the gufunc object itself. I think it is better to keep it to one gufunc and use the void *data argument (which seems to be currently perm) to select among implementations for sides and sorted. I think this is at least true for sides, which is very much a parameter. This could be implemented as a struct in the same way as for example PyArrayMethod_SortParameters. This getter-style architecture also lets us evolve arguments if we ever need to. FWIW, I think a similar discussion was actually had in #29737, but for algorithm choice.

I don't immediately see how sorter can be in the same gufunc loop. It's an entirely new array and changes the ufunc signature. It looks like we need at least two to me. Regarding the side it can definitely be a scalar (() in the signature) input to the loop however I thought more of it as static and not something I wanted to expose to the loop even with the void *data. The latter is possible though.

The other point is that we are still wrapping the gufunc, with PyArray_SearchSorted_int? In that case could it not be better to redirect from PyArray_SearchSorted itself? It seems here we have something like PyArray_SearchSorted_int -> gufunc (if found) -> otherwise PyArray_SearchSorted, but PyArray_SearchSorted -> PyArray_SearchSorted_int (or directly use the gufunc) -> otherwise fallback seems more intuitive to me. Of course it's consistent with the sorts that way as well, but I guess I'm just wondering if the entrypoint here should be changed, to me it seems this adds a layer in between of the existing two layers, it's not really a change in the entrypoint in essence.

PyArray_SearchSorted is public C api. It is not allowed to change so I had to keep it intact. This is why I resorted to a new PyArray_SearchSorted_int which is not exposed publicly. The point is that PyArray_SearchSorted should remain as is (only did internal refactoring inside the function but did not break api here). You are right that what you say seems more intuitive and it is usually how it works in numpy with functions that have an internal (_int) function too but here we can't do that cause It'd break the api.

@ikrommyd ikrommyd changed the title ENH: add axis support to np.searchsorted ENH: support N-D arrays in np.searchsorted by searching along the last axis Sep 2, 2026
@ikrommyd
ikrommyd marked this pull request as ready for review September 2, 2026 13:44
@ikrommyd

ikrommyd commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Since we decided we'd go with searching along the last axis only for now, I've updated the title and PR description and did a short fable review pass as well. If CI is happy now, this should be a ready for a first pass IMO.

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

Thanks, I took a deeper look, looking good overall! It's pretty nice we can vectorize this without being too invasive. A few larger general points, and inline, a bug that my AI found, with some refactoring suggestions / nits.

It would be really nice to somehow avoid using the ArrFunc_compare slot, but I understand that we're scoping the kernels and user-dtypes out of this PR. I'm generally happy to start here and then do the refactors later, such that we can get rid of it. But as you mentioned:

The dtype slot is not here, but a registered loop is the only thing that grants a dtype N-D support, which is the seam a searchsorted_meth slot plugs into, so that follow-up is additive

Will we be using a dtype slot to fill-in these four gufuncs internally? I'm wondering if that would be much more complex than, say, just having four gufuncs that can be used both internally and for user-dtypes (i.e., make ArrayMethods that can also be public). It's also consistent with the way we went for sorts. I guess this internal approach buys us a bit of flexibility in theory but I don't see a use case for it. But I could be wrong - if this lets us do that unification for user-dtypes down the line, or it isn't possible anyway, it should be fine. But I'm also interested in @mhvk's and @seberg's (sorry for the ping, no rush!) takes on this as we're early right now.

PyArray_SearchSorted is public C api. It is not allowed to change so I had to keep it intact.

That's right, thanks for clarifying! Maybe _int can be swapped for another name for clarity but that's minor. It's unfortunate, though I'm hoping we can rely on this gufunc completely later (on the Python end) when we merge in user-dtypes (then maybe a lot later, even deprecate). That should avoid this much of error-catching too. But that does imply we gradually move away from the legacy surface including ArrFunc_compare, so that ties back to my first point.

I don't immediately see how sorter can be in the same gufunc loop.

Indeed, we need at least two! I think this is fine for now, since they're private, thanks.

Comment thread numpy/_core/src/umath/searchsorted.cpp Outdated
Comment thread numpy/_core/src/umath/searchsorted.cpp Outdated
Comment thread numpy/_core/src/umath/searchsorted.cpp
Comment thread numpy/_core/src/umath/searchsorted.cpp
Comment thread numpy/_core/src/umath/searchsorted.cpp Outdated
spec.flags = NPY_METH_NO_FLOATINGPOINT_ERRORS;
if (generic) {
/* the legacy comparison function may call into Python */
spec.flags = (NPY_ARRAYMETHOD_FLAGS)(spec.flags

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.

Do we need the GIL for all types? Just ones with references, right, and these are built-in - so object?

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.

That was indeed a regression. There is now a get_loop that builds the carrier once per call into NpyAuxData and clears NPY_METH_REQUIRES_PYAPI per descriptor. Only a reference check is not enough though: np.dtype([('x','i4'),('y','f8')]) has hasobject=False but NEEDS_PYAPI=True, because VOID_compare reaches into Python even without object fields. So it uses PyDataType_FLAGCHK(descr, NPY_NEEDS_PYAPI) now, the same test NPY_BEGIN_THREADS_DESCR makes, and it has to be per descriptor rather than per dtype since one void dtype covers both cases.

I had no idea how to solve this btw so the idea and implementation are all due to my AI agent. Check 0a59156 and let me know what you think. I personally felt like this is a bit hacky but at the same time could not think of something better.

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.

Yeah, that's annoying! I'd say keep the dtypes separate: object obviously always needs the GIL, so we can scope this just to void. Also, don't string and unicode never need the GIL?

(Honestly, I'd be fine if we can even skip some (or all!) generic types here, and address them in follow-up. All the generic types except void have tag-level comparisons now, we're just not using them in binsearch.cpp.)

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.

Should have done what you wanted in de9e24e

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.

Well, this is very annoying. I am OK with the carrier hack, although if that is the only reason for auxdata, then there are a few places where I have been doing crazy things and just allocate a static array that is completely undefined except the dtype field.

The point is that there are exactly two reasons anyone could in theory use the array reasonably or how we use it:

  • To fetch the dtype
  • And in crazy rare cases for void/structured dtypes to know who owns the data.

I would be shocked if we need more than the first here. I am not proud of that hack and it's ugly, but so is allocating a full fresh array for nothing...

Alternatively, if that smells too much for you, we could skip it for builtin dtypes that aren't string/bytes/void.

Comment thread numpy/_core/src/umath/searchsorted.cpp Outdated
Comment thread numpy/_core/src/umath/searchsorted.cpp
if (!no_loop) {
/* Stash the error: the attribute lookups must not run under it. */
PyObject *exc = PyErr_GetRaisedException();
int declined_override = PyUFunc_HasOverride((PyObject *)op1)

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.

My AI agent noticed and I confirmed that an override that raises another TypeError will also get swallowed here, as we're checking if array_ufunc is used at all:

self = <test_multiarray.TestMethods object at 0x7cd03e32c2d0>

    def test_searchsorted_array_ufunc_override(self):
        # an operand overriding ufuncs is consulted, and one that declines
        # falls back to the ordinary implementation
        class Handles(np.ndarray):
            def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
                return "handled"
    
        class Declines(np.ndarray):
            def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
                return NotImplemented
    
        class Raises(np.ndarray):
            def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
                raise TypeError("boom")
    
        a = np.arange(10.)
        v = np.array([2.5, 7.5])
        assert a.searchsorted(v.view(Handles)) == "handled"
        assert a.view(Handles).searchsorted(v) == "handled"
        assert_equal(a.searchsorted(v.view(Declines)), [3, 8])
        assert_equal(a.view(Declines).searchsorted(v), [3, 8])
>       with pytest.raises(TypeError, match="boom"):
E       Failed: DID NOT RAISE <class 'TypeError'>

We should fix and add a test for this too!

@ikrommyd ikrommyd Sep 8, 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.

Indeed checking if any operand have has __array_ufunc__, which cannot tell apart a decline from a raise. It now tests whether the pending exception is the one the machinery raises when every override returned NotImplemented, so an override's own error propagates.

That also surfaced a second thing, __array_ufunc__ = None is an opt-out rather than an implementation, and searchsorted has always just converted such an operand. It started raising, so that is restored with a test too.

However, It's a bit hacky that the decline is identified by the message prefix from array_ufunc_errmsg_formatter, since there is no distinct exception type and PyUFunc_CheckOverride returns -1 for both cases. If you have a better idea of how to do this, please let me know.

See f1d2936

@MaanasArora MaanasArora Sep 8, 2026 •

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.

Right, I think this error-checking thing is difficult to do generally. Checking the message is definitely not the way to go though.

Could it be possible to check if __array_ufunc__ exists before you make the call to the gufunc?

Also: PyArray_SearchSorted is only for legacy dtypes? And we already have gufuncs for all of those. The only way a user-dtype can implement these gufuncs is by filling them in. So why do we need this fallback at all?

Edit: sorry, said it backwards. I meant, at least in the absence of a gufunc-registration mechanism, we can guard searchsorted_gufunc to only work with legacy dtypes. Still quite hacky, though.

@MaanasArora MaanasArora Sep 8, 2026 •

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.

Hmm, could searchsorted be overwritten by array ufunc before? I don't think so, right? In that case let's just not add that behavior! This is entirely private, and we'll provide ways to register anyway, after all.

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.

Have no time to look right in detail right now, but ideally we do allow __array_ufunc__ to override all ufuncs! Will try to come back to this soon, but it is first week of classes and I'm teaching a fair lot this term, plus I have a visitor, so may have to be next week.

@ikrommyd ikrommyd Sep 8, 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.

__array_ufunc__ never worked for searchsorted. It's not an exposed ufunc to the user. The internals are here being turned into 4 gufuncs so __array_ufunc__ can work on those if the user overrides them. But at the top level, searchsorted is not a (g)ufunc. I'd argue that it's not needed to support __array_ufunc__ here at all. I don't think a single person is going to use it. Nobody is going to override four ufuncs for this and since it never worked it's fine to not enable such a thing here.

__array_function__ keeps working fine as it always has for searchsorted.

@mhvk mhvk Sep 8, 2026 •

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.

The internals are here being turned into 4 gufuncs so __array_ufunc__ can work on those if the user overrides them.

That is all I meant; if that works, I'm OK, we definitely do not have to do more (it is a bit like the string functions, where one can override the uderlying ufuncs too but then has to deal oneself with the prerequisites).

EDIT: apologies for commenting while not actually having time to look at the code.

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 the problem is that because the top level entry point is in C and the gufuncs are being called from C, checking for the override and handling it here becomes complicated which is why I'm saying it's fine to not enable such a thing here.

@ikrommyd

ikrommyd commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Thanks Maanas for the review. I addressed most of the feedback and left the ones I didn't address as unresolved. I didn't know instantly about how to solve those. Regarding PyArray_SearchSorted_int, I just renamed it to npy_searchsorted. I'd like to defer getting rid of ArrFunc_compare to an additional additive PR if possible.

Comment thread numpy/__init__.pyi Outdated
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]>
Comment thread numpy/_core/src/umath/searchsorted.cpp Outdated
spec.flags = NPY_METH_NO_FLOATINGPOINT_ERRORS;
if (generic) {
/* the legacy comparison function may call into Python */
spec.flags = (NPY_ARRAYMETHOD_FLAGS)(spec.flags

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.

Yeah, that's annoying! I'd say keep the dtypes separate: object obviously always needs the GIL, so we can scope this just to void. Also, don't string and unicode never need the GIL?

(Honestly, I'd be fine if we can even skip some (or all!) generic types here, and address them in follow-up. All the generic types except void have tag-level comparisons now, we're just not using them in binsearch.cpp.)

if (!no_loop) {
/* Stash the error: the attribute lookups must not run under it. */
PyObject *exc = PyErr_GetRaisedException();
int declined_override = PyUFunc_HasOverride((PyObject *)op1)

@MaanasArora MaanasArora Sep 8, 2026 •

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.

Right, I think this error-checking thing is difficult to do generally. Checking the message is definitely not the way to go though.

Could it be possible to check if __array_ufunc__ exists before you make the call to the gufunc?

Also: PyArray_SearchSorted is only for legacy dtypes? And we already have gufuncs for all of those. The only way a user-dtype can implement these gufuncs is by filling them in. So why do we need this fallback at all?

Edit: sorry, said it backwards. I meant, at least in the absence of a gufunc-registration mechanism, we can guard searchsorted_gufunc to only work with legacy dtypes. Still quite hacky, though.

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

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

Thank you @ikrommyd, and sorry for doing the review in pieces, I think this is starting to settle! I really have only two major comments, about registering new non-legacy loops, and that maybe we should promote the types in the promoter instead of resolve_descriptors.

Edit: I also wonder if we can resolve generic just one level up the loop instead of threading something that can be inferred from the descriptor, but just an idea.

Comment on lines +321 to +322
if (generic && dt->type_num != NPY_STRING
&& dt->type_num != NPY_UNICODE) {

@MaanasArora MaanasArora Sep 10, 2026 •

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.

Suggested change
if (generic && dt->type_num != NPY_STRING
&& dt->type_num != NPY_UNICODE) {
if (dt->type_num == NPY_OBJECT || dt->type_num == NPY_VOID) {

* only in the sorter, so a duplicate is not an error. Dispatch reports
* it below if the loop really is missing.
*/
if (dt->type_num >= NPY_USERDEF

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.

Sorry, missed this: can user-defined types be legacy? If not, is this reachable? Either way, I don't think this should be done in the promoter, if at all!

new_op_dtypes[0] = (PyArray_DTypeMeta *)Py_NewRef(dt);
new_op_dtypes[1] = (PyArray_DTypeMeta *)Py_NewRef(dt);
for (int i = 2; i < ufunc_obj->nargs; i++) {
new_op_dtypes[i] = PyArray_DTypeFromTypeNum(NPY_INTP);

@MaanasArora MaanasArora Sep 10, 2026 •

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.

It seems this function is actually "legacy" and roundabout. We don't need it anyway:

Suggested change
new_op_dtypes[i] = PyArray_DTypeFromTypeNum(NPY_INTP);
new_op_dtypes[i] = &PyArray_IntpDType;
Py_INCREF(new_op_dtypes[i]);

(or Py_NewRef if you prefer)

Comment on lines +281 to +284
PyArray_DTypeMeta *intp_dt = PyArray_DTypeFromTypeNum(NPY_INTP);
if (intp_dt == NULL) {
return -1;
}

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.

Suggested change
PyArray_DTypeMeta *intp_dt = PyArray_DTypeFromTypeNum(NPY_INTP);
if (intp_dt == NULL) {
return -1;
}
PyArray_DTypeMeta *intp_dt = &PyArray_IntpDType;
Py_INCREF(intp_dt);

loop_descrs[0] = NPY_DT_CALL_ensure_canonical(given_descrs[0]);
}
else {
loop_descrs[0] = PyArray_PromoteTypes(given_descrs[0],

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.

In hindsight, shouldn't this be part of the promoter rather than resolve_descriptors?

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.

(Sorry if this feels like it adds weird comments, or if you already discussed it. Just let me know, the caveat means I am not 100% sure that doing this falls into place well enough, although I somewhat suspect so.)

There is an important point to this, but this looks right to me. We have two problems:

  1. np.searchsorted(int64_arr, float64_arr) currently works by using the common dtype of both. This is as of now still handled in the wrapper, though. The promoter assumes this is the case.
  2. If you have fixed-size string, bytes, or structured dtypes, then those actually do define a compare but need both sides to have the identical dtype instance.
    (The caller really already ensures this right now, but it's still right.)

So this is correct and necessary to resolve point 2 (i.e. while calling "PromoteTypes", this is the correct type-resolution step).

However, it loops back a bit to the other part and I think I now see what you meant yesterday with things not being sure about the design around using the compare functions.

This is a thought, one caveat that may make it only semi useful noted in-inline.

What I might prefer is if we can move things around a little bit so that:

  1. Change the promoter to effectively have two paths (yes, we'll call it twice typically!):
    • The current: If all DTypes are as expected, check if we can add a loop for the DTypes based on the compare function. (Promoter returns 1 if it successfully added the loop.) 1
    • If DTypes are not OK for the above, it calls CommonDType on the first two and sets the second to Intp.
      There is a real problem with this! It'll break np.searchsorted(np.array([1, 2, 3, 4], dtype="uint8"), 500). There are ways around this (see integer comparison ufuncs) but they are awkward... We could consider just converting incoming Python integers -- still changing promotion for floats subtly, but to the better...
    • It makes sense to have a "third" step and start with only modifying the last Intp operand if it isn't already. That will help with broadening things up to mixed DType loops.)
    • (Searchsorted is one of those functions where it feels usually wrong to promote the first array to the second, but ...)
  2. You are currently relying on the setup call to already return two identical arrays. I think it would be nice if we can not do that.

I think this (with subok=False) should allow us to remove the early array conversion (with caveat!) in the searchsorted wrapper function which additionally means we push promotion into the gufuncs.
Right now that would make things like searchsorted(uint8_arr, pyint) more awkward, but would also create a path for us to fix searchsorted(uint64_arr, int64_arr) to not incorrectly promote to float64.


Another small question: Is there a BC reason to not just change PyArray_SearchSorted() more drastically to call the gufunc as well? (Fine if there is, I'd prefer to deprecate many of these rather than trying hard to keep them aligned.)

Footnotes

  1. There was one additional thing, but we should defer this. Peeter's PR that rejects promoting numbers to strings would have to be dealt with in the promoter there. Nothing wrong with it, but probably "just one more thing". ↩

add_searchsorted_promoter(PyObject *ufunc)
{
PyUFuncObject *ufunc_obj = (PyUFuncObject *)ufunc;
PyObject *dtypes[NPY_MAXARGS];

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.

Is this to ensure we accommodate both types of ufuncs? Would *dtypes[4] not work? IIRC we avoid use of NPY_MAXARGS (it is so many anyway!)

}
if (PyArray_NDIM(op1) == 1 && PyArray_NDIM(sorter) == 1
&& PyArray_SIZE(sorter) != PyArray_SIZE(op1)) {
PyErr_SetString(PyExc_ValueError, "sorter.size must equal a.size");

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.

nit: maybe add a simple comment that this is for legacy compatibility?

of keys can be searched in every slice. ``sorter`` may now have the same
shape as ``a``. To search along another axis, bring it to the end first, for
instance with `numpy.moveaxis`. This feature is available for all built-in
dtypes except ``StringDType``, which remains one-dimensional.

@seberg seberg Sep 10, 2026 •

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.

Should maybe add a release note on the fact that we now may call the search functions with non-contiguous loops, I suspect.

(oops, sorry, wanted to just store it as a review for myself, because I think it drifted very subtly.)

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

Sorry for following up, I think I have found two more asks here:

  1. StringDType: You already have an explicit init_searchsorted_ufuncs does it really add serious churn/hurdle here to just special case StringDType (even as a template parameter to the inner loop seems perfectly OK).
  2. Especially if we do the above, I don't really love the thought that we are guessing at adding loops for dtypes that don't define compare/searchorted functions. I think I'd prefer to just skip this (plus making the promoter only add the loop if input dtypes already fit resolves the "add it duplicate thing" except maybe due to threads racing, not quite sure about that).

spec.nin = with_sorter ? 3 : 2;
spec.nout = 1;
/* What searchsorted_resolve_descriptors returns, promoting when needed. */
spec.casting = NPY_SAFE_CASTING;

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.

Suggested change
spec.casting = NPY_SAFE_CASTING;
spec.casting = NPY_NO_CASTING;

Effectively always!

spec.flags = NPY_METH_NO_FLOATINGPOINT_ERRORS;
if (generic) {
/* the legacy comparison function may call into Python */
spec.flags = (NPY_ARRAYMETHOD_FLAGS)(spec.flags

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.

Well, this is very annoying. I am OK with the carrier hack, although if that is the only reason for auxdata, then there are a few places where I have been doing crazy things and just allocate a static array that is completely undefined except the dtype field.

The point is that there are exactly two reasons anyone could in theory use the array reasonably or how we use it:

  • To fetch the dtype
  • And in crazy rare cases for void/structured dtypes to know who owns the data.

I would be shocked if we need more than the first here. I am not proud of that hack and it's ugly, but so is allocating a full fresh array for nothing...

Alternatively, if that smells too much for you, we could skip it for builtin dtypes that aren't string/bytes/void.

@mattip

mattip commented Sep 23, 2026

Copy link
Copy Markdown
Member

This now has some merge conflicts.

@ikrommyd

ikrommyd commented Sep 23, 2026 •

Copy link
Copy Markdown
Member Author

Yeah just by look at them here on "edit on the web", they are easy . I'll do it as soon as a come back to this. Have been a bit busy and the latest reviews were a bit beefy so I need to find some quiet time to think about this to refactor.

This branch has not been deployed

No deployments
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.

searchsorted should get a axis kwarg

7 participants