Skip to content

ENH: support reduction loops in ufunc.reduceat - #32212

Merged
ngoldbaum merged 13 commits into
numpy:mainfrom
ikrommyd:reduceat-reduction-loops
Oct 28, 2026
Merged

ngoldbaum merged 13 commits into
numpy:mainfrom
ikrommyd:reduceat-reduction-loops

Conversation

@ikrommyd

@ikrommyd ikrommyd commented Aug 6, 2026 •

Copy link
Copy Markdown
Member

PR summary

Expands on top of #31816 and allows ufunc.reduceat to use the reduction loops registered on the ufunc

First time committer introduction

N/A

AI Disclosure

AI has been used for review to find bugs and apply fixes

@ikrommyd ikrommyd added this to the 2.6.0 Release milestone Aug 6, 2026
@ikrommyd
ikrommyd force-pushed the reduceat-reduction-loops branch from b421964 to b21372b Compare August 9, 2026 10:21
@ikrommyd
ikrommyd marked this pull request as ready for review August 10, 2026 15:34
@ikrommyd
ikrommyd requested a review from ngoldbaum August 10, 2026 17:03

@ngoldbaum ngoldbaum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The NPY_METH_get_reduction_loop docstrings still say that it's used by ufunc.reduce only, which is no longer true here.

StringDType is currently rejected but I think the cast path you added here would allow you to support it. It's rejected because object arrays have a special case which would need to be replicated for StringDType but your general cast path handles both object and StringDType and (as I point out below) also allows you to delete the object special cases.

Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment thread numpy/_core/src/umath/ufunc_object.c Resolved
Comment on lines +3442 to +3447
/*
* Descriptor layout produced by the resolver (2*nout+1 entries):
* [acc_0 .. acc_{nout-1}, stream, out_0 .. out_{nout-1}]
* with acc_i == out_i (same descriptor). For a single-output ufunc this
* collapses to the classic [acc, stream, out] triplet.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Broader suggestion, probably for a shared prep patch rather than this PR: the
[acc_0.., x, out_0..] index arithmetic (nout, nout + 1 + i,
2*nout + 1) is now hand-written at every site — here, the seed loops below,
reduce_loop:

do {
/* Expand N+1 iterator operands into the (N+1)->N loop args */
for (int i = 0; i < nout; ++i) {
dataptrs_copy[i] = dataptrs[i];
strides_copy[i] = strides[i];
dataptrs_copy[nout + 1 + i] = dataptrs[i];
strides_copy[nout + 1 + i] = strides[i];
}
dataptrs_copy[nout] = dataptrs[nout];
strides_copy[nout] = strides[nout];
if (masked) {
dataptrs_copy[2 * nout + 1] = dataptrs[nout + 1];
strides_copy[2 * nout + 1] = strides[nout + 1];
}
retval = strided_loop(context,
dataptrs_copy, countptr, strides_copy, auxdata);
if (retval < 0) {
goto finish_loop;
}
} while (iternext(iter));

and reduction.c:
npy_intp *strideptr = NpyIter_GetInnerStrideArray(iter);
/* Expand the iterator strides into the loop layout (see reduce_loop) */
npy_intp fixed_strides[NPY_MAXARGS];
for (int i = 0; i < nout; i++) {
fixed_strides[i] = strideptr[i];
fixed_strides[nout + 1 + i] = strideptr[i];
}
fixed_strides[nout] = strideptr[nout];
if (wheremask != NULL) {
fixed_strides[2 * nout + 1] = strideptr[nout + 1];
if (PyArrayMethod_GetMaskedReductionLoop(context,

Small named helpers in reduction.h (e.g. REDUCTIONLOOP_X(nout),
REDUCTIONLOOP_OUT(nout, i), REDUCTIONLOOP_NARGS(nout)) would make the layout greppable
and make off-by-layout mistakes visible at the call site.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Can I do this later? I don't feel super confident I can extract the right macros before seeing how the reduceat and accumulate use them? There is potential to replace loops with macros but I don't easily see the right design right now which is why I would prefer it to do it after. I understand that we are repeating these loops but I don't think they will diverge cause they are trivial in what they do.

@ikrommyd

Copy link
Copy Markdown
Member Author

@ngoldbaum Thanks for the detailed review! I believe I did respond to all the comments and implemented the ones I have marked as resolved. The size increased because of the additional loops/tests regarding mixed dtypes.

@ngoldbaum ngoldbaum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry once again for taking so long to give this a look.

I think this is substantially improved since the last time I looked this over. My only concrete suggestion is to give this one more pass to simplify the tests.

Err on the side of expanding existing tests rather than adding new ones and also try to use existing test idioms and helpers rather than adding re-implementations. AI models typically prefer to re-invent the wheel when writing tests to save on ingesting a bunch of context, but that tends to lead to overly long and repetitive tests.

I would also suggest asking it to make sure that all new added tests have real testing power by intentionally introducing bugs and checking if the tests fail.


for (npy_intp i = 0; i < n; i++) {
double val = *(double *)x;
npy_intp ival = (npy_intp)val;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is UB for NaN, infinity, and values outside the representable range of intp. This is an internal test helper but it's at minimum bad style to intentionally add UB in C code like this. In principle compilers could also note that UB is happening here and generate nasal demons.

We don't have a C helper for this right now so you could either add one or add NPY_METH_REQUIRES_PYAPI and use e.g. PyLong_FromDouble and then PyArray_PyIntAsIntP to do this conversion. My AI model identified a few other places in the numpy implementation that have fishy casts like this. IMO that probably argues for landing a C helper and fixing these cases with the helper in a PR and then rebase this PR on top of the PR with the fix.

There is an _arange_safe_ceil_to_intp helper but it's buggy so some of these are slipping through that:

  1. arange’s length conversion — ctors.c:3089. Its check permits ivalue <= (double)NPY_MAX_INTP. With 64-bit intp, that upper bound rounds to 2⁶³, which is already outside the integer range. The subsequent cast therefore has UB at that boundary. This corrects my earlier characterization: _arange_safe_ceil_to_intp has checks, but they are incomplete.

  2. casting="same_value" validation — lowlevel_strided_loops.c.src:989. The integer maximum rounds upward during comparison here too, allowing an invalid cast inside the round-trip check. This produces an observable bug on this machine:

a = np.array([float(2**63)])
a.astype(np.int64, casting="same_value")
# array([9223372036854775807]), with RuntimeWarning

The input is exactly 9223372036854775808. It should reject the conversion. Converting the incorrect result back to double rounds upward again, concealing the change from the round-trip check. The analogous uint64 boundary also passes incorrectly.

  1. Timedelta multiplication and division by doubles — loops.c.src:1136, also lines 1157 and 1178. These check npy_isfinite(result), but finite values can still exceed the int64 range. Examples include:
np.timedelta64(1, "ns") * 1e20
np.timedelta64(1, "ns") / 1e-20

Both reach an out-of-range cast. Here they produce the maximum timedelta with a warning; that result is not guaranteed by C.

I confirmed these casts with Clang’s float-cast-overflow sanitizer using isolated functions extracted from the source, alongside the Python reproductions above.

So there is a concrete reuse case for a small, allocation-free C helper that returns conversion status, letting each caller choose its error handling. An intp variant would cover arange and the PR fixture; timedelta needs an int64 variant even on 32-bit platforms. The casting machinery would need additional destination types. Centralizing the boundary calculation would avoid repeating the deceptively unsafe “compare against integer maximum converted to double” pattern.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Would something like this suffice here or do you want to go for the separate PR with a more general helper?

diff --git a/numpy/_core/src/umath/_reduction_loop_tests.c b/numpy/_core/src/umath/_reduction_loop_tests.c
index 33228b7430..8c7bbce279 100644
--- a/numpy/_core/src/umath/_reduction_loop_tests.c
+++ b/numpy/_core/src/umath/_reduction_loop_tests.c
@@ -226,6 +226,21 @@ object_minimummaximum_get_reduction_loop(
  * The second output is the maximum converted to its dtype, so reduce and
  * reduceat seed it by casting the first float64 element of the reduction.
  */
+/* Convert to intp, or set a ValueError if `val` is NaN or out of range. */
+static int
+double_to_intp(double val, npy_intp *out)
+{
+    /* Both bounds are powers of two, so they are exact as doubles. */
+    if (!(val >= (double)NPY_MIN_INTP && val < -(double)NPY_MIN_INTP)) {
+        PyErr_SetString(PyExc_ValueError,
+                        "maximum is NaN or out of the intp range");
+        return -1;
+    }
+    *out = (npy_intp)val;
+    return 0;
+}
+
+
 static int
 double_minimum_intp_maximum_loop(PyArrayMethod_Context *NPY_UNUSED(context),
         char *const data[], npy_intp const dimensions[],
@@ -238,8 +253,10 @@ double_minimum_intp_maximum_loop(PyArrayMethod_Context *NPY_UNUSED(context),
     for (npy_intp i = 0; i < n; i++) {
         double a = *(double *)in1;
         double b = *(double *)in2;
+        if (double_to_intp(nan_max(a, b), (npy_intp *)out2) < 0) {
+            return -1;
+        }
         *(double *)out1 = nan_min(a, b);
-        *(npy_intp *)out2 = (npy_intp)nan_max(a, b);
         in1 += strides[0]; in2 += strides[1];
         out1 += strides[2]; out2 += strides[3];
     }
@@ -259,7 +276,10 @@ double_minimum_intp_maximum_reduce_loop(

     for (npy_intp i = 0; i < n; i++) {
         double val = *(double *)x;
-        npy_intp ival = (npy_intp)val;
+        npy_intp ival;
+        if (double_to_intp(val, &ival) < 0) {
+            return -1;
+        }
         npy_intp cur_max = *(npy_intp *)acc_max;
         *(double *)out_min = nan_min(*(double *)acc_min, val);
         *(npy_intp *)out_max = cur_max >= ival ? cur_max : ival;
@@ -281,7 +301,7 @@ minimum_intp_maximum_get_reduction_loop(
 {
     *out_loop = &double_minimum_intp_maximum_reduce_loop;
     *out_transferdata = NULL;
-    *flags = NPY_METH_NO_FLOATINGPOINT_ERRORS;
+    *flags = NPY_METH_REQUIRES_PYAPI;
     return 0;
 }

@@ -560,7 +580,7 @@ _reduction_loop_tests_exec(PyObject *m)
     if (add_mixed_minimummaximum(m, "minimum_intp_maximum", NPY_INTP,
             &double_minimum_intp_maximum_loop,
             &minimum_intp_maximum_get_reduction_loop,
-            NPY_METH_NO_FLOATINGPOINT_ERRORS) < 0) {
+            NPY_METH_REQUIRES_PYAPI) < 0) {
         return -1;
     }
     if (add_mixed_minimummaximum(m, "minimum_object_maximum", NPY_OBJECT,
@@ -584,9 +604,10 @@ static struct PyModuleDef_Slot _reduction_loop_tests_slots[] = {
 #endif
 #if PY_VERSION_HEX >= 0x030d00f0  // Python 3.13+
     /*
-     * No GIL is needed: the inner loops never touch Python objects,
-     * apart from the object ones, which declare NPY_METH_REQUIRES_PYAPI
-     * so the iterator does not release the GIL around them.
+     * No GIL is needed: the inner loops never touch the Python API,
+     * apart from the object and intp ones, which declare
+     * NPY_METH_REQUIRES_PYAPI so the iterator does not release the GIL
+     * around them.
      */
     {Py_mod_gil, Py_MOD_GIL_NOT_USED},
 #endif

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's fix this in a followup.

@ikrommyd

ikrommyd commented Oct 12, 2026 •

Copy link
Copy Markdown
Member Author

Thanks Nathan. I also see some conflicts now which look from a very brief look in the web interface like someone changed the code I'm refactoring into the helpers here so it should only be a matter of updating the helpers I think, so not difficult.

@ikrommyd
ikrommyd force-pushed the reduceat-reduction-loops branch from 6460337 to cbc052b Compare October 17, 2026 13:29
@ikrommyd

Copy link
Copy Markdown
Member Author

Okay I think I've fixed your testing comments and also did a tests refactoring with AI for simplifications and testing power (also taking into consideration the accumulate PR). I've not done anything for the UB the you mentioned yet.

@ngoldbaum

Copy link
Copy Markdown
Member

Thanks for your patience on this @ikrommyd!

@ngoldbaum
ngoldbaum merged commit a74be27 into numpy:main Oct 28, 2026
91 checks passed
@ikrommyd
ikrommyd deleted the reduceat-reduction-loops branch October 28, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants