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
API: Ensure that casting does not affect ufunc loop #18880
API: Ensure that casting does not affect ufunc loop #18880
Conversation
This ensures that casting does not affect ufunc loops, which means that the following will give the same result: ``` >>> np.not_equal(None, False, dtype=bool) True >>> np.not_equal(None, False, dtype=bool, casting="unsafe") False ``` This is absolutely necessary to make new promotion and type resolution sane. In some cases, it causes problems with existing weirder dtype usage, the workaround is that we allow the promoter (in current terms the type resolver) to resolve more strictly (potentially ignoring input types). I.e. a promoter (after not finding match), can use the fixed (signature) dtypes to infer the loop. Because this makes things more strict, most importantly `nextafter` causes problems. Code like: ``` np.nextafter(0., np.inf, dtype=np.float32) ``` looks harmless enough, but requires such preferential treatment. NumPy has such capabilities currently for the homogeneous type resolver, so changing to that fixes it (SciPy uses the above quite a lot). SciPy also has code like (in the tests): ``` np.nextafter(0, 1) ``` which will now FAIL. However, this is for the better since the above code is actually buggy: It will return different values on windows and 32bit linux because it will find the float32 instead of the float64 loop. That is almost certainly not the expected result.
I forgot to explain what this code "actually does" so to speak. When the user specifies an exact DType for a loop, that dtype must be used and match exactly. So in that case, we do not need to check whether the (input) array can cast safely to pick the loop (the loop still has to match the casting safety, but that defaults to "same kind"!). However, here I enforce that any operand that has not been specified must match "safely" (for inputs, for picking a loop, outputs really don't/shouldn't matter...). |
Well, |
A maybe noteworthy example, that hopefully clarifies the behaviour rather than confusing it:
is not 0 currently because the first version downcasts the inputs, while the second downcasts the output. (Generally, I think downcasting the output would be better). This PR "refuses the temptation to guess" so to speak, i.e. if The other complexity is what (My problem there is that I really don't want to things, so I have to make a choice into what to convert |
I suppose we could special case nextafter for integers to always go to doubles if anyone thinks it helps with transitions. I just realized that |
I checked I have to think a bit more. But one solution could be to modify the meaning of That might even allow to undo the EDIT: There are ufuncs (in scipy) that have signatures like |
@seberg - I read through it and I'll try to look a the code later. But to understand better, the current documentation for dtype reads
Which seems quite explicit, and in the direction of being equivalent to |
Thanks. In the signature normalization PR, I actually had modified it to The old behaviour is roughly:
Right now I made it:
So my last thought was, to try both Maybe I thought about it wrong though. I prefer the "output" interpretation (and I think documentation or not, that is likely what most would guess). If I revisit the previous change, the main issue is that binary comparisons behave slightly different with ufuncs. If we special case them (for a bit) The one thing I don't really like would be to try to keep the special case that EDIT: Sorry for being so confusing, the fact that the old code paths very quite a bit from ufunc to ufunc is confusing to me also. One further note is that a reason I liked the change to |
OK, that helps at least get my thoughts a bit in order! But let me try to think through a bit more, and try to see what strictly following the docstring mentioning that it is the output dtype and calculation might do. For comparisons, the But with this in mind, I am confused by your example in #18880 (comment):
This seems expected: in one case the user explicitly asks for
I think the intent would be clear: I want my calculation done in float64 even if p.s. I think |
@mhvk Yeah, I have to think about it more and maybe map out the space of possibilities a bit clearer. One thing about the
If the user says In either case the (Not sure it helps, but the main issue is about cases where it seems like it should be obvious which loop to choose, but I am not sure how to choose it without using the fact that loops are currently ordered.) |
If only output dtypes are provided, we add the additional step in the ufunc type resolver of converting a signature of the format:: (None,) * nin + (DType,) * nout into:: (DType,) * (nin + nout) And try searching for a matching loop again. The advantage of this is that IF the ufunc has mixed-type inputs, we have a fighting chance of finding a good loop for `dtype=DType`. Further, future `promoters` could refine such logic, while at the same time, we do not need to use completely different code paths for `signature=...` and `dtype=...`. The rare downside is that we might pick a `dd->d` loop when a `dl->d` loop would do (SciPy has such ufuncs). The up-side is, that before, using `dtype=...` was not even possible for functions with non-homogenous loops like `np.ldexp`. The future will hopefully see more precise promotion logic, and some of the more surprising paths simply deprecated...
OK, I think I mostly converted to the solution of modifying the meaning of That means that if only output dtypes are provided in the signature, we add the additional step in the ufunc type resolver of converting a signature of the format::
into::
And try searching for a matching loop again. This falls back to the old version, unless it finds a loop, which presumably should return a correct result. (After all all inputs can safely cast to the loop.) The main advantage of this is that if the ufunc has mixed-type inputs, we have a fighting chance of finding a good loop for This is the contrast with the main alternative, which is translating There are some disadvantages, of course:
I can be convinced of going the |
Also, for now enforce *all* outputs. That is more conservative, and obviously won't work for certain functions in scipy that have multiple outputs of different types, but it seems a bit safer to me right now. Maybe at some point we have an idea of how to "modify" what the `dtype` means. I am not sure how! By allowing the ufunc itself to interpret how to translte it to a `signature`? By giving it a distinct meaning? Yes, this might narrow us down a bit... but...
I am sorry about adding a "slight" new change. I did now enforce all outputs for In theory, SciPy has some ufuncs which have multiple outputs of different DTypes in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having read through the discussion, I went through the changes to see how well I understood the intent and to what degree the documentation updates made that intent clear. I had a couple of questions/suggestions for sentences that could be polished, but generally I think the documentation describing the change is looks good!
Since NumPy special cases if only outputs (or ``dtype``) is provided, | ||
this should affect very few users. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I interpreted this to mean "The behavior change only affects partial signatures where at least one input type is provided, so the change should affect very few users". I'm not sure that my interpretation is correct though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that is what I meant, and I think it is probably true :). It definitely is the mental model I want users to use for now (which will immediately tell them, that practically nobody will notice).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 in that case it might be worth rewording this sentence since IMO it requires a bit of extra context to correctly interpret the "special casing" here. It could just be me though :)
@@ -553,6 +553,11 @@ PyUFunc_SimpleUniformOperationTypeResolver( | |||
* This is a fast-path, since all descriptors will be identical, mainly | |||
* when only a single descriptor was passed (which would set the out | |||
* one in the tuple), there is no need to check all loops. | |||
* Note that this also allows (None, None, float64) to resolve to | |||
* (float64, float64, float64), even when the inputs do not match, | |||
* i.e. fixing a single part of the signature can fix all of them. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this say "i.e. fixing the output of the signature can determine the dtype(s) for the rest of the signature"? Not that this would ever happen, but the current wording makes it seem like (None, float64, None)
would also fall back to (float64, float64, float64)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, you are right here. For the other changes, this is not what happens, but for the simpleuniform
one, that is actually what I coded... I don't mnind that behaviour, but there is also no real reason for it. Let me modify the code to align with the rest and only apply the "uniform" expansion to outputs.
Co-authored-by: Ross Barnowski <rossbar@berkeley.edu>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I looked again and think the code and description are clear now. Only some totally nitpicky comments about docstrings and code comments.
to no loop being found in some cases, NumPy will normally also search | ||
for the loop:: | ||
|
||
signature("float64", "float64, "float64") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing end double quote on second item.
doc/source/reference/ufuncs.rst
Outdated
|
||
This argument allows the user to specify exact DTypes to be used for the | ||
calculation. Casting will be used as necessary. The input DType | ||
is not considered unless ``signature`` is ``None`` for that input. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe "The actual DType of the input arguments is not considered ..." (otherwise, it is a bit unclear what "input DType" means)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adopted (replaced "argument" with "array" also).
operation. It does not specify the ``datetime64`` time-unit or the | ||
``float64`` byte-order. | ||
|
||
For backwards compatibility this argument can also be provided as *sig*, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of scope here, but time to remove sig
, I'd think!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately, we never deprecated it with a warning. But maybe that is about time...
out_dtype, types, NULL); | ||
out_dtype, orig_types, NULL); | ||
/* | ||
* In principle, we only need to validate the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean that in future we could simplify this? If so maybe add "TODO" in front?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My TODO here is "replace practically everything in this file and then delete it". It is a bit unfortunate, but to get there, I have to fix it first...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😄 Yes, better to evolve using small steps...
/* Error */ | ||
case -1: | ||
return -1; | ||
/* Found matching loop */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Align same as /* Error */
for (i = 0; i < self->ntypes; ++i) { | ||
char *orig_types = self->types + i*self->nargs; | ||
|
||
/* Check specified types and copy into an int array for matching */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe note this code appears twice and could be put into a small function?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will add a note that its duplicated. Its not quite trivial to refactor because orig_types
is char *
in one case and int *
.
types[j] = orig_types[j]; | ||
} | ||
set_ufunc_loop_data_types(self, op, out_dtype, types, NULL); | ||
/* In principle, we only need to validate the NPY_NOTYPE ones */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As above, is this meant to be a TODO?
The behaviour for ``signature=...`` and ``dtype=...`` can differ in | ||
some cases to the previous behaviour. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The behaviour for ``signature=...`` and ``dtype=...`` can differ in | |
some cases to the previous behaviour. | |
The behaviour for ``np.ufunc(1.0, 1.0, signature=...)`` and ``np.ufunc(1.0, 1.0, dtype=...)`` | |
can yield different loops in 1.21 than in 1.20 because of changes in promotion. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adopted as:
The behaviour for
np.ufunc(1.0, 1.0, signature=...)
or
np.ufunc(1.0, 1.0, dtype=...)
can now yield different loops in 1.21 compared
to 1.20 because of changes in promotion.
In that case, it is necessary to provide the complete signature | ||
to enforce casting the inputs. | ||
If ``dtype="float64"`` is used or only outputs are set (e.g. | ||
``signature=(None, None, "float64")`` the behaviour should remain |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
``signature=(None, None, "float64")`` the behaviour should remain | |
``signature=(None, None, "float64")`` the is |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
adopted, thanks
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few nits, but otherwise LGTM.
Co-authored-by: Matti Picus <matti.picus@gmail.com>
close/reopen |
Can we put this in so that I can do the ugly deed of squash merging gh-18905? |
Thanks @seberg |
This ensures that casting does not affect ufunc loops, which means
that the following will give the same result:
This is absolutely necessary to make new promotion and type resolution
sane.
In some cases, it causes problems with existing weirder dtype usage,
the workaround is that we allow the promoter (in current terms the
type resolver) to resolve more strictly (potentially ignoring input
types).
I.e. a promoter (after not finding match), can use the fixed (signature)
dtypes to infer the loop.
Because this makes things more strict, most importantly
nextafter
causes problems. Code like:
looks harmless enough, but requires such preferential treatment. NumPy
has such capabilities currently for the homogeneous type resolver, so
changing to that fixes it (SciPy uses the above quite a lot).
SciPy also has code like (in the tests):
which will now FAIL. However, this is for the better since the above code
is actually buggy: It will return different values on windows and 32bit linux
because it will find the float32 instead of the float64 loop. That is almost
certainly not the expected result.
This is a bit tricky, and on the other hand fairly straight forward in the sense that I think we have to give it a shot right now if we want sane future behaviour... I am planning to add a few tests (and release note), but I think it should be ready for review.