Repository navigation
Conversation
|
We talked about this during the triage review meeting. We think that maybe the simplest thing is to simply drop the |
|
In the last commit I've droppied |
86e2167 to
69c93bf
Compare
|
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 The other point is that we are still wrapping the gufunc, with |
I don't immediately see how
|
np.searchsorted np.searchsorted by searching along the last axis
|
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. |
There was a problem hiding this comment.
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.
| spec.flags = NPY_METH_NO_FLOATINGPOINT_ERRORS; | ||
| if (generic) { | ||
| /* the legacy comparison function may call into Python */ | ||
| spec.flags = (NPY_ARRAYMETHOD_FLAGS)(spec.flags |
There was a problem hiding this comment.
Do we need the GIL for all types? Just ones with references, right, and these are built-in - so object?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
__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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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]>
Signed-off-by: Iason Krommydas <[email protected]>
5134580 to
96207ee
Compare
|
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 |
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]>
| spec.flags = NPY_METH_NO_FLOATINGPOINT_ERRORS; | ||
| if (generic) { | ||
| /* the legacy comparison function may call into Python */ | ||
| spec.flags = (NPY_ARRAYMETHOD_FLAGS)(spec.flags |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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]>
There was a problem hiding this comment.
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.
| if (generic && dt->type_num != NPY_STRING | ||
| && dt->type_num != NPY_UNICODE) { |
There was a problem hiding this comment.
| 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 |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
It seems this function is actually "legacy" and roundabout. We don't need it anyway:
| 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)
| PyArray_DTypeMeta *intp_dt = PyArray_DTypeFromTypeNum(NPY_INTP); | ||
| if (intp_dt == NULL) { | ||
| return -1; | ||
| } |
There was a problem hiding this comment.
| 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], |
There was a problem hiding this comment.
In hindsight, shouldn't this be part of the promoter rather than resolve_descriptors?
There was a problem hiding this comment.
(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:
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.- 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:
- 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
CommonDTypeon the first two and sets the second toIntp.
There is a real problem with this! It'll breaknp.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
Intpoperand 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 ...)
- 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
-
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]; |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Sorry for following up, I think I have found two more asks here:
- StringDType: You already have an explicit
init_searchsorted_ufuncsdoes it really add serious churn/hurdle here to just special case StringDType (even as a template parameter to the inner loop seems perfectly OK). - 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; |
There was a problem hiding this comment.
| 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 |
There was a problem hiding this comment.
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.
|
This now has some merge conflicts. |
|
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. |
PR summary
Closes #4224.
np.searchsortedandndarray.searchsortedno longer require the sorted array to be one-dimensional. Each 1-D slice along the last axis is searched independently. There is noaxiskeyword: to search along another axis, bring it to the end first, for instance withnp.moveaxis.Internally this is four private gufuncs,
_searchsorted_{left,right}with signature(n),(m?)->(m?)and_searchsorted_{left,right}_sorterwith(n),(m?),(n)->(m?).sidepicks the gufunc statically, so the kernels innpysort/binsearch.cppare unchanged and still selected asget_binsearch_func(dtype, side)did.(n),(m?)->(m?)is the signature and what array-api-extra implements, with the flexiblem?so a scalarvkeeps returning a scalar. Keys arev's last axis and leading axes broadcast againsta's other dimensions. The searched dimension of the result lands last, which is the plain gufunc default, so noaxesjuggling is needed and the layout matches JAX and array-api-extra. Regarding Jake's question, there is nobatch_dimsequivalent, leading dims broadcast right-aligned, so the batched cases need anewaxismanually from the user. Against array-api-extra the results agree wherever both accept a call, and numpy additionally broadcasts in both directions, takes asorter, promotesaandvby 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_SearchSortedis unchanged and serves as the fallback, and the legacyArrFunc_Comparelayer 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 asearchsorted_methslot plugs into, so that follow-up is additive and would close the existingTODOinitem_selection.c. Exposingaxislater 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.