8000 API: Allow comparisons with and between any python integers by seberg · Pull Request #24915 · numpy/numpy · GitHub
[go: up one dir, main page]

Skip to content

API: Allow comparisons with and between any python integers #24915

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Oct 19, 2023

Conversation

seberg
Copy link
Member
@seberg seberg commented Oct 12, 2023

This implements comparisons between NumPy integer arrays and arbitrary valued
Python integers when weak promotion is enabled.

To achieve this:

  • I allow abstract DTypes (with small bug fixes) to register as loops (ArrayMethods).
    This is fine, you just need to take more care.
    It does muddy the waters between promotion and not a bit if the
    result DType would also be abstract.
    (For the specific case it doesn't, but in general it does.)
  • A new resolve_descriptors_raw function, which does the same job as
    resolve_descriptors but I pass it this scalar argument
    (can be expanded, but starting small).
    • This only happens when available, so there are some niche paths were this cannot
      be used (ufunc.at and the explicit resolution function right now),
      we can deal with those by keeping the previous rules (things will just raise
      trying to convert).
    • The function also gets the actual arrays.dtype instances while I normally ensure that
      we pass in dtypes already cast to the correct DType class.
      (The reason is that we don't define how to cast the abstract DTypes as of now,
      and even if we did, it would not be what we need unless the dtype instance actually had
      the value information.)
  • There are new loops added (for combinations!), which:
    • Use the new resolve_descriptors_raw (a single function dealing with everything)
    • Return the current legacy loop when that makes sense.
    • Return an always true/false loop when that makes sense.
    • To achieve this, they employ a hack/trick: get_loop() needs to know the value,
      but only resolve_descriptors_raw() does right now, so this is encoded on whether we use
      the np.dtype("object") singleton or a fresh instance!
      (Yes, probably ugly, but avoids channeling things to more places.)

Additionally, there is a promoter to say that Python integer comparisons can just use
object dtype (in theory weird if the input then wasn't a Python int,
but that is breaking promises).


This is the split out from gh-23912, to solve the biggest pain point/problem. Comparisons with Python integers that are out-of-bounds.

May need a bit of cleanup, but the main discussion should be if the appraoch is really what we want (but we really need something).

Copy link
Contributor
@mhvk mhvk left a comment

Choose a reason for hiding this comment

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

This looks very nice! Quite a few inline comments, but most are small (partially about removing some strange references to "string" ufuncs).

Larger is whether the "raw" version should not just get to deal with everything - just pass in all information and let it deal with it as it pleases (possibly leaving the casting check, but not worrying about interpreting the inputs, setting original descriptors, etc.).

p.s. Thanks for splitting this of, it is much easier to wrap one's head around smaller pieces...

int overflow;
long long val = PyLong_AsLongLongAndOverflow(value, &overflow);
if (val == -1 && overflow == 0 && PyErr_Occurred()) {
return (NPY_CASTING)-1;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is confusing - are you recasting? To what? Would seem worth a comment!

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, I added the -1 for "error" to the casting enum at some point, but always with an underscore and just returned -1 everywhere for error. Turns out C++ dosn't like just returning -1, so I need to either write _NPY_ERROR_OCCURRED_IN_CAST or add the cast...

Copy link
Contributor

Choose a reason for hiding this comment

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

I think _NPY_ERROR_OCCURRED_IN_CAST is clear; might as well use it for new code.

int arr_idx = 0;
int scalar_idx = 1;

if (dtypes[0] == &PyArray_PyIntAbstractDType) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit, but I'd write

npy_bool first_is_pyint = dtypes[0] == &PyArray_PyIntAbstractDType;
int arr_idx = first_is_pyint? 1: 0;
int scalar_idx = first_is_pyint? 0: 1;   # or 1-arr_idx
PyObject *scalar = input_scalars[scalar_idx];

and then use first_is_pyint and scalar below.

if (input_scalars[scalar_idx] != NULL
&& PyLong_CheckExact(input_scalars[scalar_idx])) {
value_range = get_value_range(input_scalars[scalar_idx], arr_dtype->type_num);
if (value_range == -2) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is -2 just the same as (NPY_CASTING)-1? If so, I'd made that explicit, i.e., if (value_range == (NPY_CASTING)-2)

Copy link
Member Author

Choose a reason for hiding this comment

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

I will change it to if (get_value_range(..., &value_range) < 0) {return...}, they are not the same, just translating one error to another, but its OK to be explicit (and it should get inlined anyway).

* function supports *both* directions for all types.
*/
static NPY_CASTING
resolve_descriptors_raw(
Copy link
Contributor

Choose a reason for hiding this comment

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

This name is rather bad. Just resolve_descriptors_pyscalars?

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, never liked it either! just didn't have a good idea. Maybe just _scalars or even _with_scalars? Because I think it may be nice to allow that in some future.

if (value_range == -2) {
return (NPY_CASTING)-1;
}
if (arr_idx == 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This would be if (first_is_pyint)

/* NOTE: This should receive global symbols? */
PyArray_DTypeMeta *Bool = PyArray_DTypeFromTypeNum(NPY_BOOL);

/* We start with the string loops: */
Copy link
Contributor

Choose a reason for hiding this comment

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

Which string loops?

promoter = PyCapsule_New(
(void *)&pyint_comparison_promoter, "numpy._ufunc_promoter", NULL);
if (promoter == NULL) {
Py_DECREF(promoter);
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't decref NULL - dtype_tuple?

goto finish;
}

/* All String loops */
Copy link
Contributor

Choose a reason for hiding this comment

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

Again string???

Copy link
Member Author

Choose a reason for hiding this comment

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

Sorry, I had started with a string ufuncs as they are also templated in C++ for all comparisons, and wasn't careful enough with removing all th leftovers. Will look through more carefully.

@@ -4475,14 +4476,60 @@ _get_fixed_signature(PyUFuncObject *ufunc,
* need to "cast" to string first).
*/
static int
resolve_descriptors(int nop,
resolve_descriptors(int nop, int nin,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it useful to pass in nin? I though that one could just get that with ufunc->nin? (Similarly, is nop necessary?)

Copy link
Contributor

Choose a reason for hiding this comment

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

More generally, I wonder if it wouldn't make more sense to just pass through everything one could possibly want to the _raw version. If a ufunc wants raw, perhaps it should deal with the nitty-gritty (including even check_safety, or set that up differently so one is not stuck with original_dtypes). In that case, the name is more appropriate too...

The question then is if there is anything missing from the current argument list above that could possibly be useful? Logically, I guess it is just the outputs -- which could be easily attached by passing through full_args rather than just full_args.in.

If so, I'd suggest to remove nin and add ufunc_full_args full_args

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes we don't have to pass nin and nout, it is on the ufunc and ufuncimpl. I guess for a single nop it seemed easier, with both it is getting unclear.

Right, I would be happy to add outputs in principle. Right now I did this because I limited to true scalars and those are simply never outputs (i.e. the i < nout is only a sanity check).
One reason was that it isn't necessary trivial to distinguish a scalar from a 0-D object and the original 0-D object may need to be converted to an array to do anything with it so at that point I would have to pass both if you were to actually use that information reasonably. One thing that could work is checking for whether it is a scalar based on the DType, even when not abstract, and otherwise pass the array.)

I havne't thought of integrating the cast-safety check. Right now we have a "disable cast safety" special path for logical ufuncs loop (casting to bool is always OK).
I would be happy with passing the cast-safety and either allowing or forcing you to take care of it. I.e. we can remove the flag or if you want to deal with it you use set the flag so NumPy doesn't check again.
(I am not aware of where it would be useful to side-step the check except for that logical bool, bool -> bool loop.)

I would agree that there might also be a good reason to allow a "let me do everything" path where we let the user do as much as reasonable (e.g. also the full iteration). I think we should have that although finding the right API for it might not be trivial (unless we launch back into/through effectively python?!).
It isn't super easy to get feature parity, but would allow a lot of weirder stuff and speed experimentation that might be cool. (Also just making allowing more things to look like a ufunc and use the protocol.)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, it is tricky. And indeed, if one wants more control, it would probably also be necessary to know not just the ufunc but also whether it is a call, at, reduce, etc. At which point it is no longer obvious this is the right place. Still, my tendency would be to add full_args instead of the inputs and just pass everything on, i.e., let the implementation deal with input_scalars (and original_types). But perhaps keep the check on cast safety, since that is what the function has to return anyway.

Copy link
Member Author

Choose a reason for hiding this comment

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

Well, I prefer passing everything on as PyObject *const * (I will add the const) rather than two tuples. Just because I think that is the more convenient API usually, and I also think changing the way we do this in the ufunc code itself would be an improvement probably (create the tuple when we need it, we don't need it often).

Passing the method additionally, would seem OK to me. I am not sure it is useful/needed, but I could imagine it can be (if just for printing a nicer custom error message).

My uncertainty is how implementations should deal with weird types (non-arrays but also not obvious scalars), so I am not quite sure what is the best thing to pass on there if that is what they want.
Thus, my maybe silly inclination to punt on it, but maybe rename the argument. We could pass all arguments as it is, we currently just keep them NULL when it isn't quite clear what is best.

Copy link
Contributor

Choose a reason for hiding this comment

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

OK, true, and I don't want to make too big a deal of it.

But I think my suggestion, passing on the outputs as well, remains easier to explain and work with. And while you are right that passing in the pre-conversion-to-array input may be quite useless from an ndarray perspective, who knows, maybe it is really handy from the perspective of a more random (sub)class that overrides __array_ufunc__ and needs some strange dtype resolution as well. (For quantities, like @ngoldbaum, we had this nice procedure of the final unit of an operation only depending on the units of the arguments, which works for all but np.power. For that case, the scalar value is all that is needed, but maybe for other cases one needs the class shape or so.)

Or, to turn the argument around, how does passing on all inputs and outputs hurt the current use cases?

Copy link
Member Author

Choose a reason for hiding this comment

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

🤷 Yes. I am fine if you wish to say that I largely don't like it because I don't want you to do things like np.emath (inspect values to guess result dtype). But even after you might convince me that there are actual use-cases where this would be useful – I am sure there are –, I am confident that doing it this way will still be a bad protocol for reasons beyond being brittle for alternate array-objects and conceptually not really extending to non-arrays at all.

One extension, we could also consider is pivoting to is passing char *const *scalar_vals which would hold the raw (possibly unaligned?) original dtype data pointer (whatever that is) or the Python objects for the current Python scalars.
If we talk about shapes, what I think you actually want is passing the core dimensions (reduction size/shape), not original objects (you can't really infer much from there), this is even more interesting in the get_loop() (where we have the data available in principle).

(In a sense, a lot of this to me comes back to: Yes, we probably want a "let me do everything", or even an alternative pivot where we allow more ufunc-like objects to dispatch via __array_ufunc__ to embrace a split of "ufunc as an implementation" and "ufunc as a protocol" a bit.)

Copy link
Contributor

Choose a reason for hiding this comment

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

My problem remains that the current approach feels super directed just at the particular use case. If that was done privately, without opening API for it, that would be perfectly fine. But if one adds new API, it would seem nice to be not so directed at one particular use case.

But maybe your idea for the extension is a way to resolve this. Though would one perhaps generalize it slightly? Either the original dtype or the object if it had no dtype (i.e., getattr(obj, "dtype", obj)). Or maybe just anything that did not have a dtype, and NULL otherwise? (I'm not quite sure any more what processing has happened to the dtype before we get to this stage; maybe passing the original dtype is best if it can be different.)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, it is directed at the string multiplication/unit power use-case, because that is the biggest hole I am aware off and there is some overlap in the solution.
Maybe the main problem is that it isn't quite clear yet what the correct pattern is of enabling that use case.

I can live with getting it wrong (i.e. deprecating it later). But we really need a solution for NEP 50's sake more than anything. So I am OK either way:

  1. Just pass all arguments and make it public. I dislike it, so I would add a scary warning to it (maybe an underscore), but in the end we are all grown-ups here after all.
  2. Just make it private for now.
  3. We iterate once and maybe land on the char ** solution (may make this use-case slightly more complex, but generalize the string mul/unit power one maybe).

Just FYI... Probably clear, but let me summarize what happens right now:

  • __array_ufunc__, this is the only place where you can avoid any form of array conversion.
  • Array coercion. This is a two step process:
    • Finding shape and dtype. Which includes calling __array__() when necessary.
    • If necessary, creating a new array and filling it in.
      Especially for scalars, we don't have to do the second step in principle. But for most array-like objects, we currently don't allow delaying the full conversion anyway.
  • Promotion based on the DType classes.
    • This find the ArrayMethod
  • The resolution step (this one). We clearly have the "original" dtype instances available here. (For Python integers, this right now means its could be an int64, uint64 or object dtype).
    • Normally, we will convert/cast these to the actual loop DType class so that the user doesn't have to worry about that. (This is normally a de-facto no-op, because most dtypes are non-parametric)

My problem remains that the current approach feels super directed just at the particular use case.

Yes, it clearly is directly at solving exactly two things. First, side-stepping the preparation because in its current form it fails for abstract DTypes and, second, trying to create/prepare a path forward for unit power and string multiply. (maybe it isn't particularly good at that, because you may want to support 3-4 things: unit**4, unit**np.uint8(4) and unit**np.array([4]); where the last IMO should include array-likes if you allow it).

Copy link
Member Author

Choose a reason for hiding this comment

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

I can't think of a clear solution to this... So, I made the slot raise an error when used as public API (effectively making it private).

@@ -266,17 +266,17 @@ def test_half_correctness(self):
if len(a32_fail) != 0:
bad_index = a32_fail[0]
assert_equal(self.finite_f32, a_manual,
"First non-equal is half value %x -> %g != %g" %
(self.finite_f16[bad_index],
"First non-equal is half value 0x%x -> %g != %g" %
Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated?

Copy link
Member Author
@seberg seberg Oct 13, 2023

Choose a reason for hiding this comment

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

Uhh, yeah, the test had failed on me when I had a buggy version which caused this to fail cryptically...

EDIT: Left it for now, because its so small...

This implements comparisons between NumPy integer arrays and arbitrary valued
Python integers when weak promotion is enabled.

To achieve this:
* I allow abstract DTypes (with small bug fixes) to register as loops (`ArrayMethods`).
  This is fine, you just need to take more care.
  It does muddy the waters between promotion and not a bit if the
  result DType would also be abstract.
  (For the specific case it doesn't, but in general it does.)
* A new `resolve_descriptors_raw` function, which does the same job as
  `resolve_descriptors` but I pass it this scalar argument
  (can be expanded, but starting small).
  * This only happens *when available*, so there are some niche paths were this cannot
    be used (`ufunc.at` and the explicit resolution function right now),
    we can deal with those by keeping the previous rules (things will just raise
    trying to convert).
  * The function also gets the actual `arrays.dtype` instances while I normally ensure that
    we pass in dtypes already cast to the correct DType class.
    (The reason is that we don't define how to cast the abstract DTypes as of now,
    and even if we did, it would not be what we need unless the dtype instance actually had
    the value information.)
* There are new loops added (for combinations!), which:
  * Use the new `resolve_descriptors_raw` (a single function dealing with everything)
  * Return the current legacy loop when that makes sense.
  * Return an always true/false loop when that makes sense.
  * To achieve this, they employ a hack/trick: `get_loop()` needs to know the value,
    but only `resolve_descriptors_raw()` does right now, so this is encoded on whether we use
    the `np.dtype("object")` singleton or a fresh instance!
    (Yes, probably ugly, but avoids channeling things to more places.)

Additionally, there is a promoter to say that Python integer comparisons can just use
`object` dtype (in theory weird if the input then wasn't a Python int,
but that is breaking promises).
* Smaller cleanups/fixes
* Rename the function to `resolve_descriptors_with_scalars` for now
* Rename the file to `special_integer_comparisons` since that is clearer
Not sure when this got lost, but should be easy to deal with
any conflicts
@seberg seberg force-pushed the pyint-comparisons branch from cdfa82c to df219d0 Compare October 18, 2023 11:02
Makign private by raising an error.  Maybe a bit odd, but OTOH,
it does advertise that we can make it public quite easily if we want
to.
/* We may want to adapt the `get_loop` signature a bit: */
#define _NPY_METH_get_loop 2
#define NPY_METH_get_reduction_initial 3
#define _NPY_METH_get_loop 4
Copy link
Member

Choose a reason for hiding this comment

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

Did you skip 3 on purpose?

"slot private due to uncertainty about the right "
"signature (see gh-24915)");
return -1;
}
Copy link
Member

Choose a reason for hiding this comment

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

I'm fine with coming back to this when someone has a real need for it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, fine to come back to this.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not adding, I can't see a comment that does more than repeat the error message. Added a leading underscore to the slot ID, though, so it is less awkward to keep it in a public header.

Copy link
Member
@ngoldbaum ngoldbaum left a comment

Choose a reason for hiding this comment

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

Other than the question about the change to the dtype slot constants this looks fine to me. @mhvk are you ok with keeping this undecided and private until someone needs it for a unit dtype or a new fixed-width text dtype?

Copy link
Contributor
@mhvk mhvk left a comment

Choose a reason for hiding this comment

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

Yes, fine with keeping it private and just getting in the fix! A few inline comments. Most important is to add a comment to the .h file, since that is what people may read.

I also suggest not passing in nin to keep the changes minimal.

/*
* Rarely needed, slightly more powerful version of `resolve_descriptors`.
* See also `resolve_descriptors_function` for details on shared arguments.
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

Would be good to add a comment here that this one is private for now given uncertainties about the signature.

"slot private due to uncertainty about the right "
"signature (see gh-24915)");
return -1;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, fine to come back to this.

@@ -115,10 +115,11 @@ static PyObject *
prepare_input_arguments_for_outer(PyObject *args, PyUFuncObject *ufunc);

static int
resolve_descriptors(int nop,
resolve_descriptors(int nop, int nin,
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, I think I'd still remove this extra addition of nin, since you only need it for the private case: just use ufunc->nin there. (Of course, no choice for inputs_tup!)

@@ -2803,8 +2804,8 @@ reducelike_promote_and_resolve(PyUFuncObject *ufunc,
* casting safety could in principle be set to the default same-kind.
* (although this should possibly happen through a deprecation)
*/
if (resolve_descriptors(3, ufunc, ufuncimpl,
ops, out_descrs, signature, casting) < 0) {
if (resolve_descriptors(3, 2, ufunc, ufuncimpl,
Copy link
Contributor

Choose a reason for hiding this comment

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

So, remove 2, here.

@@ -4475,14 +4476,60 @@ _get_fixed_signature(PyUFuncObject *ufunc,
* need to "cast" to string first).
*/
static int
resolve_descriptors(int nop,
resolve_descriptors(int nop, int nin,
Copy link
Contributor

Choose a reason for hiding this comment

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

original_dtypes[i] = PyArray_DTYPE(operands[i]);
Py_INCREF(original_dtypes[i]);
}
if (i < nin
Copy link
Contributor

Choose a reason for hiding this comment

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

And change to ufunc->nin here.

@@ -4856,8 +4905,8 @@ ufunc_generic_fastcall(PyUFuncObject *ufunc,
}

/* Find the correct descriptors for the operation */
if (resolve_descriptors(nop, ufunc, ufuncimpl,
operands, operation_descrs, signature, casting) < 0) {
if (resolve_descriptors(nop, nin, ufunc, ufuncimpl,
Copy link
Contributor

Choose a reason for hiding this comment

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

Remove addition of nin.

@@ -6228,8 +6277,8 @@ ufunc_at(PyUFuncObject *ufunc, PyObject *args)
}

/* Find the correct operation_descrs for the operation */
int resolve_result = resolve_descriptors(nop, ufunc, ufuncimpl,
tmp_operands, operation_descrs, signature, NPY_UNSAFE_CASTING);
int resolve_result = resolve_descriptors(nop, ufunc->nin, ufunc, ufuncimpl,
Copy link
Contributor

Choose a reason for hiding this comment

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

Remove ufunc->nin

@@ -6557,8 +6606,9 @@ py_resolve_dtypes_generic(PyUFuncObject *ufunc, npy_bool return_context,
}

/* Find the correct descriptors for the operation */
if (resolve_descriptors(ufunc->nargs, ufunc, ufuncimpl,
dummy_arrays, operation_descrs, signature, casting) < 0) {
if (resolve_descriptors(ufunc->nargs, ufunc->nin, ufunc, ufuncimpl,
Copy link
Contributor

Choose a reason for hiding this comment

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

Here too

@seberg seberg force-pushed the pyint-comparisons branch from 9ce67f6 to 7c70ad2 Compare October 19, 2023 08:40
@ngoldbaum
Copy link
Member
ngoldbaum commented Oct 19, 2023

Thanks @seberg! Let's pull this in to unblock NEP 50 progress.

@ngoldbaum ngoldbaum merged commit 7566783 into numpy:main Oct 19, 2023
@mhvk
Copy link
Contributor
mhvk commented Oct 19, 2023

Thanks, both, this is really nice, and good that comparisons now work sensibly!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants
0