Repository navigation
ENH: Support the dot family for user-dtypes - #31574
SwayamInSync wants to merge 27 commits into
Conversation
dot family for new-style user DTypesdot family for user-dtypes
| n = a.shape[axis_a] | ||
| m = math.prod(a.shape[ax] for ax in notin_a) | ||
| p = math.prod(b.shape[ax] for ax in notin_b) | ||
| at = ascontiguousarray(a.transpose(notin_a + [axis_a]).reshape(m, n)) |
There was a problem hiding this comment.
np.dot must make out= as C-style array, so I think doing ascontiguousarray might be OK?
Ideally the matmul/BLAS should handle any layout (for perfomance cases we can revert back to let dtype's BLAS handle this)
If we revert then, this will require new release from numpy-quaddtype (PR numpy/numpy-quaddtype#108 is already landed)
There was a problem hiding this comment.
By default, any ufunc produces a C-contiguous array, so I don't think you'd need to do anything.
|
Test failures seems independent from this PR |
mhvk
left a comment
There was a problem hiding this comment.
Some comments in-line. Functionally, the one about complex conjugation for correlate is the most important, but the others should help simplify the implementation. In particular, please check carefully whether all those ascontiguousarray calls are in fact necessary (by having tests with non-contigous arrays).
| res = np.zeros(res_shape, dtype=res_dtype) | ||
| elif a.ndim == 1 and b.ndim == 1: | ||
| # 1-D x 1-D: inner product | ||
| res = np.matmul(ascontiguousarray(a), ascontiguousarray(b)) |
There was a problem hiding this comment.
Here, you could just do return np.matmul(a, b, out=out) - by default, output is contiguous.
| n = a.shape[axis_a] | ||
| m = math.prod(a.shape[ax] for ax in notin_a) | ||
| p = math.prod(b.shape[ax] for ax in notin_b) | ||
| at = ascontiguousarray(a.transpose(notin_a + [axis_a]).reshape(m, n)) |
There was a problem hiding this comment.
By default, any ufunc produces a C-contiguous array, so I don't think you'd need to do anything.
| p = math.prod(b.shape[ax] for ax in notin_b) | ||
| at = ascontiguousarray(a.transpose(notin_a + [axis_a]).reshape(m, n)) | ||
| bt = ascontiguousarray(b.transpose([axis_b] + notin_b).reshape(n, p)) | ||
| res = np.matmul(at, bt) |
There was a problem hiding this comment.
Are you sure the detailed handling of the axes is necessary? Remember that with np.matmul, like with all gufuncs, you have the option of explicitly passing in the axes that are contracted; see https://numpy.org/doc/stable/reference/ufuncs.html#optional-keyword-arguments
There was a problem hiding this comment.
Unfortunately, for ndim > 2 dot has a funny behavior that the result shape is a.shape + b.shape minus the reduction axes.
I.e. it doesn't actually do a proper broadcasting/stacked operation, it is the same as tensordot(..., axes=[-1, -2]).
However, tensordot actually does this precise dance already. So we could consider being a bit annoying and just rejecting it for this path (we should deprecate it on the other path I guess) with a note of "please use tensordot instead".
There is another good argument for this, the code has:
#if defined(HAVE_CBLAS)
if (PyArray_NDIM(ap1) <= 2 && PyArray_NDIM(ap2) <= 2 &&
so we don't even use BLAS for this case, which is ridiculous and unless arrays are small users are better of to use the clearer tensordot() anyway.
(If we do that this might be simple enough to just move it to C.)
There was a problem hiding this comment.
fallback's _dot_contract is the tensordot-style reshape=>matmul, faster than legacy N-D dot, so rejecting it isn't a perf win, it's purely about not carrying the deprecation-worthy N-D dot semantics. I'm fine either way.
There was a problem hiding this comment.
Not sure I am following precisely, but I am leaning to just reject it with a "use tensordot" message. Let's ping @mhvk, though.
(That would mean we should aim for a deprecation as a tendency, I guess.)
| ``_pyarray_correlate`` already handles empty checks, complex conjugation | ||
| and the ``len(a) < len(v)`` swap/reverse, so ``a`` is the longer operand. | ||
| """ | ||
| a = ascontiguousarray(np.asarray(a).ravel()) |
There was a problem hiding this comment.
Did you check all these ascontiguousarray calls are in fact necessary? I rather doubt it, since matmul produces a contiguous array.
| pad = zeros(n2 - 1, dtype=res_dtype) | ||
| apad = concatenate([pad, a, pad]) | ||
| win = apad[arange(n1 + n2 - 1)[:, None] + arange(n2)] | ||
| full = np.matmul(win, v) |
There was a problem hiding this comment.
I think you need vecdot here to ensure complex numbers are properly conjugated (do add at test! also for dot, where complex conjugation is not done [incorrectly, but the fallback needs to mimic that mistake...])
@mhvk the element-wise ones does not, they preserve the order. So the case for (0-D x n-D) (just a multiply) will not go to C-contig order. For BLAS ops yes it will always, I kept this earlier because in quaddtype the PR hasn't merged yet and in case any dtype did not conform with this. |
| ref = np.dot(a, b) | ||
| got = _dot_fallback(a, b) | ||
| assert_array_equal(got, ref) | ||
| assert got.shape == np.shape(ref) |
There was a problem hiding this comment.
You can get the shape check by setting strict=True in assert_array_equal.
Also, I'm actually not 100% sure we should care about contiguity, but if we do, we should test it to ensure we don't regress, by adding something like assert got.strides == ref.strides (same in other tests).
|
In quaddtype the I will try to fast-forward the work there to get the support merge nicely, and maybe should we add quaddtype's test dependecy to direct fetch and build from repo? @seberg |
|
Also I tried, |
I already have one or two tests with quaddtype, but I am not sure it is installed yet. The problem is that that even importing quaddtype can break some other tests. So you need a bit of a tedious skipping for the quaddtype version (without actually importing it). But, I think that's fine... we can factor that out into a little private helper. (I think you should be able to find that hack if you search, quickly.) |
Done, added in this PR as it wasn't that disturbing the context |
|
This is ready for 2nd round of review. |
| except PackageNotFoundError: | ||
| pytest.skip("numpy_quaddtype is not installed") | ||
| if _pep440.Version(installed) < _pep440.Version(MIN_VERSION): | ||
| pytest.skip(f"numpy_quaddtype >= {MIN_VERSION} is required") |
There was a problem hiding this comment.
Did you mean to put MIN_VERSION to 1.1 since I think we are at 1.0 (and we need to reject it).
I guess we can make it customizable if needed. The current min-version is vital because even importing is wrong, but after 1.1 is out that isn't the case anymore.
There was a problem hiding this comment.
Yeah that also works, just weird habit of having even jumps for sub-versions :)
There was a problem hiding this comment.
I think this comment still needs to be addressed...
There was a problem hiding this comment.
I guess if the next release is 1.2 then it doesn't matter. Although, I don't really see why numpy-quaddtype should use a different version scheme (distinguising odd from even versions) than NumPy.
| n1, n2 = a.size, v.size | ||
| pad = zeros(n2 - 1, dtype=np.result_type(a.dtype, v.dtype)) | ||
| apad = concatenate([pad, a, pad]) | ||
| win = apad[arange(n1 + n2 - 1)[:, None] + arange(n2)] |
There was a problem hiding this comment.
This can be a sliding_window_view I think. I think matmul will be OK with core dimensions that have self overlap (if not, we could pass axes to make it a non-core dimension). Although that is of course not BLAS compatible (for our code, I think it might even go back into einsum).
Otherwise, you would bloat memory usage like crazy here. (I suspect we can do this, but I am slightly unsure and i tmay make sense to split the PR into two, since I think dot is a bit clearer.)
There was a problem hiding this comment.
I am too concerned about the memory bloat here + sliding_window_view user-dtypes don't support.
Right to split this, I will do another PR for correlate and with the current and we can make further changes there
There was a problem hiding this comment.
sliding_window_viewuser-dtypes don't support.
Ah, that is true and annoying :/. Could possibly not go via __array_interface__ there to make it work, but I am not sure that is better than trying a fix for __array_interface__ (heck even adding a dtype field).
|
We talked a little bit about this at the triage meeting and I'm in favor of not implementing the weird behavior of |
|
I see, so I think we are set to deprecate the last case with warning "use tensordot"? Then we also need to do this for legacy path too right. |
Probably but it doesn't need to happen here. |
Address review feedback: do not reproduce np.dot's non-broadcasting outer-product semantics for stacked arrays (ndim > 2) in the user-dtype fallback; raise a ValueError pointing users to numpy.tensordot instead. The unambiguous N-D . 1-D reduction is kept. Also stop forcing C order in the 0-D scalar*array branch so the fallback reproduces np.dot's memory layout exactly (matches the got.strides == ref.strides invariant), and add a release note.
With the stacked (ndim>2) case now rejected, np.dot coincides exactly with matmul for every remaining case (matrix/vector products, the N-D . 1-D reduction and empty contractions). Remove the _dot_contract transpose/reshape helper (which only existed to express the rejected outer-product semantics) and call matmul/multiply directly, keeping the _dot_check_out validation since those ufuncs are more permissive with out= than np.dot.
It was only used once; fold the out= validation directly into the fallback and drop the now-redundant comments.
|
Apologies for the delay, the weird behaviour of np.dot with |
…test np.dot routes ndim<=2 float/double/cfloat/cdouble through CBLAS, which returns a C-contiguous result even for the 0-D scalar*array case; without CBLAS it uses an order-preserving elementwise multiply. A user DType never hits the CBLAS path, so the fallback (and the legacy path it mimics) uses the order-preserving multiply. Assert only the stable property -- that a matrix product via matmul is C-contiguous -- instead of comparing strides against the BLAS-dependent np.dot oracle for the scalar case.
|
IMO, at this point, going through Python adds as much complexity as it resolves, but if others don't mind... |
Make sense, I will wait for the final thoughts then. |
mhvk
left a comment
There was a problem hiding this comment.
I had another look and think it is fine, though I somewhat agree with @seberg that the python overrides are sufficiently short one could consider just doing it in C. On the other hand, it is easier for people to understand where things go wrong if they can just look at the python code...
Anyway, I'm fine either way, just don't forget to update MIN_VERSION!
| except PackageNotFoundError: | ||
| pytest.skip("numpy_quaddtype is not installed") | ||
| if _pep440.Version(installed) < _pep440.Version(MIN_VERSION): | ||
| pytest.skip(f"numpy_quaddtype >= {MIN_VERSION} is required") |
There was a problem hiding this comment.
I think this comment still needs to be addressed...
On the C vs Python thing: I tried porting both fallbacks to C to see how it actually looks. The catch is the tests. Nothing in numpy's own test suite can reach the fallback through
So either way I need a private helper for the tests to call, in Python it is |
Conflict in numpy/_core/src/common/npy_import.h: numpygh-32544 now generates the members of npy_runtime_imports_struct from NPY_RUNTIME_IMPORTS_FIELDS via NPY_DECLARE_PYOBJECT_FIELDS, so the explicit member list is gone. Resolved by taking main's generated declaration and dropping the two explicit `_dot_fallback`/`_vdot_fallback` members this branch had added; they are now generated from the F() entries already present in module_state_fields.h.
`dot` has always handled a 0-D operand with `multiply`, for every dtype,
after casting both operands to their common dtype. Diverting DTypes
without a dotfunc into the fallback skipped that cast, which exposed
`multiply`'s heterogeneous loops where `dot` never allowed them:
np.dot(np.array('ab'), 3, out=np.empty((), 'U6'))
returned 'ababab' instead of raising, and the same for bytes. Move the
dotfunc check below the existing 0-D block so those operands stay on
dot's own code path, and let the fallback handle only the matmul case.
Checked against a build with the fallback compiled out over 8752
dot/vdot/inner cases (dtype x shape grids, mixed dtypes, out=,
subclasses, Fortran and strided layouts, empty arrays): no case where
one succeeds and the other raises, and no differing results.
|
merge conflict and found a regression on stringdtype (since we prevented the casting to common type). |
`multiply` is no longer part of the fallback: 0-D operands stay on dot's own path. `vdot` goes through `vecdot`, so name that instead.
|
Pushed: main merged (conflict cleared) and the regression fixed. The dotfunc check sat above dot's 0-D block, so DTypes without a dotfunc skipped the cast to the common dtype and reached multiply's string×int loop Moved it below the 0-D block (e45f588), so 0-D stays on dot's own path. That left the multiply branch in |
|
An old bug on main is exposed by the new tests added. In I reproduced it locally: Fixed it in 631a826 Other CI failure is unrelated |
ngoldbaum
left a comment
There was a problem hiding this comment.
I asked an AI model to look this over and it spotted some corner cases and one issue with the tests, see below. I also have a suggestion for the release note, although it's already getting pretty long so if you can come up with a one-sentence version that's even better.
|
|
||
| from numpy._utils import _pep440 | ||
|
|
||
| MIN_VERSION = "1.1" |
There was a problem hiding this comment.
I guess we have a bit of a chicken-egg problem in that this release doesn't exist yet. Is there any chance you can make the test work (with xfails maybe?) against 1.0 so can be pretty confident it works? Right now this whole test is dead code.
There was a problem hiding this comment.
Right now v1.0 of numpy-quaddtype has the bug of registering broad promoter/loops and importing it can interfere with other unrelated tests (those issues are fixed upstream and planned to be out in v1.1) so technically we cannot even import it in the current released version.
So this guard is technically saving us from multiple issues (indeed a bad practice) hence I will make sure that next release of quaddtype rightly solves such problems
There was a problem hiding this comment.
OK, then what do you think about deleting these dead tests in this PR and then re-adding them after you do a numpy-quaddtype release and bump the pin here?
There was a problem hiding this comment.
I dunno, I honestly prefer putting this in with the dead tests (that work and were tested locally), then with a bunch of unit-tests for the _dot_fallback().
(I don't like such unit-tests if it's avoidable. For one they don't age well if we delete _dot_fallback() and then the person who does that work has to now do the double work of also fixing all tests.)
Of course that relies on actually getting that quaddtype release out very soon (and this bug feels like almost enough of a reason to do so?)
| "use 'numpy.tensordot' instead." | ||
| ) | ||
| try: | ||
| return np.matmul(a, b, out=out) |
There was a problem hiding this comment.
should this specify order="C"? Otherwise stuff like this happens:
import numpy as np
from numpy._core.numeric import _dot_fallback
a = np.ones((2, 3, 4, 5)).swapaxes(0, 1)
b = np.ones((5, 6))
expected = np.dot(a, b)
actual = _dot_fallback(a, b)
print(expected.flags.c_contiguous, actual.flags.c_contiguous)
# True False
There was a problem hiding this comment.
Matmul and the ufunc machinery have different out compatibility rules. I'm not sure whether this is worth worrying about, but there's also this incosistency:
import numpy as np
from numpy._core.numeric import _dot_fallback
a, b = np.ones((2, 3)), np.ones((3, 4))
outputs = {
"extra batch": np.empty((5, 2, 4)),
"Fortran": np.empty((2, 4), order="F"),
"float32": np.empty((2, 4), dtype=np.float32),
"strided": np.empty((2, 8))[:, ::2],
}
for label, out in outputs.items():
for fn in (np.dot, _dot_fallback):
try:
result = fn(a, b, out=out)
print(label, fn.__name__, "accepted", result.shape, result.dtype)
except ValueError:
print(label, fn.__name__, "ValueError")
On this PR I get:
extra batch dot ValueError
extra batch _dot_fallback accepted (5, 2, 4) float64
Fortran dot ValueError
Fortran _dot_fallback accepted (2, 4) float64
float32 dot ValueError
float32 _dot_fallback accepted (2, 4) float32
strided dot ValueError
strided _dot_fallback accepted (2, 4) float64
e.g. the "real" dot implementation rejects all these out shapes, dtypes, C/F order, and strides
There was a problem hiding this comment.
Fixed, the fallback now passes order="C".
dot c_contiguous True | fallback c_contiguous True
and regarding dot's out= contract, earlier silently depends on the dtype. The fallback now validates out itself (ndim, C-contiguity, dtype) and reuses dot's own error message, so all four of your cases match:
extra batch dot=ValueError fallback=ValueError
Fortran dot=ValueError fallback=ValueError
float32 dot=ValueError fallback=ValueError
strided dot=ValueError fallback=ValueError
I checked this with other possible permutation/combinations and all matches with the usual dot behaviour
| `numpy.vecdot` for `numpy.vdot`, so the operations work as long as the DType | ||
| implements those. | ||
|
|
||
| The stacked-array behaviour of `numpy.dot`, which for ``a.ndim >= 2`` and |
There was a problem hiding this comment.
It's not just np.dot though. Lots of other code paths go through MatrixProduct2. Maybe you could say "behaviour of numpy.dot, numpy.inner, numpy.tensordot, and other code paths like numpy.cov that rely on dot products"
# Conflicts: # numpy/_core/tests/test_multiarray.py
| if (out.ndim != expected_ndim or not out.flags.c_contiguous | ||
| or out.dtype != np.result_type(a, b)): |
There was a problem hiding this comment.
This still isn't quite right: it should also flag an incorrect output shape with the same number of dimensions and an un-aligned but C-contiguous output.
Rather than duplicating the rules in Python, can you instead extract the output validation from new_array_for_sum into a private C helper that this function can call?
There was a problem hiding this comment.
Do you have an opinion on this?
I feel like we either give up and pass out as allocated and checked by dot(). Or we don't worry about any of this and just use the gufunc machinery as is (as it is the "gold standard" and what dot could transition to as well).
Here, we actually check that out.dtype is identical to the result_type, but we don't enforce it in the ufunc when out=None, where it isn't strictly guaranteed. At that point we are almost at casting="no" into the ufunc, but also that I would prefer not to.
(I feel like all of this is churn for fairly weird corner cases -- yes, I don't like the divergence much either, but...)
There was a problem hiding this comment.
FWIW, when it comes to the order="C" that seems reasonable, because it is also a reasonable idea if we want to rewire things (unfortunately!).
In general, I don't want us to start to guarantee order, but maybe dot is special enough (to at least not rush changing it).
|
I think this is getting very close! I added a milestone and the triage review label to hopefully attract one more careful code review pass from a maintainer. |
seberg
left a comment
There was a problem hiding this comment.
I think it's very close, but now that we have all of those additional check, I am honestly a bit confused.
I don't care a whole lot if the new path is slightly more relaxed here (heck, I could ignore the order="C" part).
About the tests... Serious question: Can we maybe just get that release out? The fix for this issue alone feels like it may well warrent it? :) (no idea how much work it is, but also Python 3.15 support, etc. is upcoming so it would be timely.)
| if (out.ndim != expected_ndim or not out.flags.c_contiguous | ||
| or out.dtype != np.result_type(a, b)): |
There was a problem hiding this comment.
Do you have an opinion on this?
I feel like we either give up and pass out as allocated and checked by dot(). Or we don't worry about any of this and just use the gufunc machinery as is (as it is the "gold standard" and what dot could transition to as well).
Here, we actually check that out.dtype is identical to the result_type, but we don't enforce it in the ufunc when out=None, where it isn't strictly guaranteed. At that point we are almost at casting="no" into the ufunc, but also that I would prefer not to.
(I feel like all of this is churn for fairly weird corner cases -- yes, I don't like the divergence much either, but...)
|
|
||
| from numpy._utils import _pep440 | ||
|
|
||
| MIN_VERSION = "1.1" |
There was a problem hiding this comment.
I dunno, I honestly prefer putting this in with the dead tests (that work and were tested locally), then with a bunch of unit-tests for the _dot_fallback().
(I don't like such unit-tests if it's avoidable. For one they don't age well if we delete _dot_fallback() and then the person who does that work has to now do the double work of also fixing all tests.)
Of course that relies on actually getting that quaddtype release out very soon (and this bug feels like almost enough of a reason to do so?)
| if (out.ndim != expected_ndim or not out.flags.c_contiguous | ||
| or out.dtype != np.result_type(a, b)): |
There was a problem hiding this comment.
FWIW, when it comes to the order="C" that seems reasonable, because it is also a reasonable idea if we want to rewire things (unfortunately!).
In general, I don't want us to start to guarantee order, but maybe dot is special enough (to at least not rush changing it).
|
Hi sorry for the delay here, I will come back to it quick after doing the v1.1 release from the quaddtype side, releasing all the blockers for this and other PRs (won't take long) |
PR summary
This PR adds the
dotandcorrelatefallback methods for the user-dtypes. It uses themultiplyandmatmuloperations to delegate the operations as per the input shape.closes #30793
AI Disclosure
None