Skip to content

ENH: Fast path in ufuncs for exact arrays - #32499

Open
eendebakpt wants to merge 19 commits into
numpy:mainfrom
eendebakpt:ufunc_fastpath_core2
Open

eendebakpt wants to merge 19 commits into
numpy:mainfrom
eendebakpt:ufunc_fastpath_core2

Conversation

@eendebakpt

@eendebakpt eendebakpt commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

PR summary

This PR adds a fast path for unary ufunc calls ufunc(arr) where arr is an exact ndarray that is trivially iterable (0-d, 1-d, or C/F-contiguous) with a builtin non-parametric dtype, e.g. np.sin(x) or np.abs(x) for a float or integer array.

The approach for 0d arrays is similar to #32269.

Fixes #11232

Benchmarks:

call main this PR
np.sin(np.array(1.5)) 261 67
np.sin(np.arange(4.0)) 260 146
np.negative(np.arange(4)) 243 129
np.abs(np.arange(4.0)) 244 132
np.sin(np.arange(100.0)) 869 777
np.add(a4, a4) (binary, not affected) 267 266

Because the fast path no longer reference counts the input array and its DType class, scaling on free-threaded builds improves as well.

Benchmark script
import timeit
import numpy as np

a0 = np.array(1.5)
a4 = np.arange(4.0)
i4 = np.arange(4)
a100 = np.arange(100.0)
g = dict(np=np, a0=a0, a4=a4, i4=i4, a100=a100)
n = 200_000
for name, stmt in [
    ("sin 0-d", "np.sin(a0)"),
    ("sin 4", "np.sin(a4)"),
    ("negative int 4", "np.negative(i4)"),
    ("abs 4", "np.abs(a4)"),
    ("sin 100", "np.sin(a100)"),
    ("add 4", "np.add(a4, a4)"),
]:
    t = min(timeit.repeat(stmt, globals=g, number=n, repeat=7)) / n * 1e9
    print(f"{name:16s} {t:7.1f} ns")

AI Disclosure

Claude was used in generation and benchmarking of the code.

Makes the padding bytes of longdouble scalars deterministic.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
if (ndim == 0) {
/* 0-d returns a scalar: compute on the stack, skip the temp array. */
unary_scalar_storage storage;
memset(&storage, 0, sizeof(storage)); /* deterministic padding bytes */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not sure this is needed. For longdouble 10 bytes are used, but due to padding 16 are allocated. For all computations setting the storage to zero is not needed, but if afterwards the results is inspected with tobytes() the padding bytes are visible.

@mhvk mhvk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@eendebakpt - nice improvements! Two general comments:

  1. It would be nice to combine the numpy scalar and array scalar cases, as they are super similar. I worry that duplicated code will eventually lead to divergence (bug fixes in only one place, etc.).
  2. For my work on numpy scalars, I just used the knowledge that clongdouble was the longest possible scalar, which is guaranteed to be aligned properly for anything else. That would avoid the whole union and getting a scalar type out.

@ngoldbaum

Copy link
Copy Markdown
Member

For my work on numpy scalars, I just used the knowledge that clongdouble was the longest possible scalar, which is guaranteed to be aligned properly for anything else. That would avoid the whole union and getting a scalar type out.

I'd prefer avoiding that because it relies on platform-dependent behaviors. I asked for the union over in #32269.

@mhvk

mhvk commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

For my work on numpy scalars, I just used the knowledge that clongdouble was the longest possible scalar, which is guaranteed to be aligned properly for anything else. That would avoid the whole union and getting a scalar type out.

I'd prefer avoiding that because it relies on platform-dependent behaviors. I asked for the union over in #32269.

It certainly doesn't hurt to be safe. But maybe then another argument to if not combine, at least make the two fast paths consistent with each other.

@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.

I asked an AI to review this and it identified some issues, see below.

It also generated the following patch, which saves about 60 lines from the diff and fixes the issues I pointed out

Details
diff --git a/numpy/_core/src/umath/legacy_array_method.c b/numpy/_core/src/umath/legacy_array_method.c
index 36cf9990c0..9769a0362e 100644
--- a/numpy/_core/src/umath/legacy_array_method.c
+++ b/numpy/_core/src/umath/legacy_array_method.c
@@ -71,7 +71,7 @@ wrapped_legacy_resolve_descriptors(PyArrayMethodObject *NPY_UNUSED(self),
  * Much the same as the default type resolver, but tries a bit harder to
  * preserve metadata.
  */
-static NPY_CASTING
+NPY_NO_EXPORT NPY_CASTING
 simple_legacy_resolve_descriptors(
         PyArrayMethodObject *method,
         PyArray_DTypeMeta *const *dtypes,
diff --git a/numpy/_core/src/umath/legacy_array_method.h b/numpy/_core/src/umath/legacy_array_method.h
index 82eeb04a0a..d48dc2abb1 100644
--- a/numpy/_core/src/umath/legacy_array_method.h
+++ b/numpy/_core/src/umath/legacy_array_method.h
@@ -30,6 +30,12 @@ NPY_NO_EXPORT NPY_CASTING
 wrapped_legacy_resolve_descriptors(PyArrayMethodObject *,
         PyArray_DTypeMeta *const *, PyArray_Descr *const *, PyArray_Descr **, npy_intp *);
 
+NPY_NO_EXPORT NPY_CASTING
+simple_legacy_resolve_descriptors(
+        PyArrayMethodObject *method, PyArray_DTypeMeta *const *dtypes,
+        PyArray_Descr *const *given_descrs, PyArray_Descr **output_descrs,
+        npy_intp *view_offset);
+
 #ifdef __cplusplus
 }
 #endif
diff --git a/numpy/_core/src/umath/ufunc_object.c b/numpy/_core/src/umath/ufunc_object.c
index 834ba4cde6..8ea893e1e0 100644
--- a/numpy/_core/src/umath/ufunc_object.c
+++ b/numpy/_core/src/umath/ufunc_object.c
@@ -4731,6 +4731,49 @@ replace_with_wrapped_result_and_return(PyUFuncObject *ufunc,
     return NULL;
 }
 
+/*
+ * Run a resolved unary loop, including floating point error handling.
+ * Returns 0 on success and -1 with an error set.
+ */
+static inline int
+execute_unary_trivial_loop(
+        PyUFuncObject *ufunc, PyArrayMethod_Context *context,
+        char **data, npy_intp *strides, npy_intp count)
+{
+    NPY_BEGIN_THREADS_DEF;
+
+    PyArrayMethod_StridedLoop *strided_loop;
+    NpyAuxData *auxdata = NULL;
+    NPY_ARRAYMETHOD_FLAGS flags = 0;
+    if (context->method->get_strided_loop(context, 1, 0, strides,
+                                          &strided_loop, &auxdata, &flags) < 0) {
+        return -1;
+    }
+
+    if (!(flags & NPY_METH_NO_FLOATINGPOINT_ERRORS)) {
+        npy_clear_floatstatus();
+    }
+    if (!(flags & NPY_METH_REQUIRES_PYAPI)) {
+        NPY_BEGIN_THREADS_THRESHOLDED(count);
+    }
+    int ret = strided_loop(context, data, &count, strides, auxdata);
+    NPY_END_THREADS;
+    NPY_AUXDATA_FREE(auxdata);
+
+    if (ret == 0 && PyErr_Occurred()) {
+        ret = -1;
+    }
+    if (ret == 0 && !(flags & NPY_METH_NO_FLOATINGPOINT_ERRORS)) {
+        int fpe_errors = npy_get_floatstatus();
+        if (fpe_errors && PyUFunc_GiveFloatingpointErrors(
+                ufunc_get_name_cstr(ufunc), fpe_errors) < 0) {
+            ret = -1;
+        }
+    }
+    return ret;
+}
+
+
 /*
  * Check whether the input object is a known scalar and whether the ufunc has
  * a suitable inner loop for it, which takes and returns the data type of the
@@ -4821,43 +4864,11 @@ try_trivial_scalar_call(
     context.caller = (PyObject *)ufunc;
     context.method = method;
     npy_intp strides[2] = {0, 0};  // 0 ensures scalar math, not SIMD for half.
-    PyArrayMethod_StridedLoop *strided_loop;
-    NpyAuxData *auxdata = NULL;
-    NPY_ARRAYMETHOD_FLAGS flags = 0;
-    if (method->get_strided_loop(&context, 1, 0, strides,
-                                 &strided_loop, &auxdata, &flags) < 0) {
-        ret = -1;  // Should not happen, so raise error if it does anyway.
-        goto bail;
-    }
-    /*
-     * Call loop with single element, checking floating point errors.
-     */
-    if (!(flags & NPY_METH_NO_FLOATINGPOINT_ERRORS)) {
-        npy_clear_floatstatus();
-    }
-    npy_intp n = 1;
-    ret = strided_loop(&context, data, &n, strides, auxdata);
-    NPY_AUXDATA_FREE(auxdata);
+    ret = execute_unary_trivial_loop(ufunc, &context, data, strides, 1);
     if (ret == 0) {
-        if (PyErr_Occurred()) {
-            ret = -1;
-            goto bail;
-        }
-        if (!(flags & NPY_METH_NO_FLOATINGPOINT_ERRORS)) {
-            // Check for any unmasked floating point errors (note: faster
-            // than _check_ufunc_fperr as one doesn't need mask up front).
-            int fpe_errors = npy_get_floatstatus();
-            if (fpe_errors) {
-                if (PyUFunc_GiveFloatingpointErrors(
-                        ufunc_get_name_cstr(ufunc), fpe_errors) < 0) {
-                    ret = -1;  // Real error, falling back would not help.
-                    goto bail;
-                }
-            }
-        }
         *result = PyArray_Scalar(out, dt, NULL);
         if (*result == NULL) {
-            ret = -1;  // Real error (should never happen).
+            ret = -1;
         }
     }
   bail:
@@ -4891,77 +4902,6 @@ typedef union {
     npy_clongdouble clongdouble_;
 } unary_scalar_storage;
 
-/* Pointer to the union member for `type_num`, or NULL if unsupported. */
-static inline char *
-unary_scalar_storage_ptr(unary_scalar_storage *storage, int type_num)
-{
-    switch (type_num) {
-        case NPY_BOOL: return (char *)&storage->bool_;
-        case NPY_BYTE: return (char *)&storage->byte_;
-        case NPY_UBYTE: return (char *)&storage->ubyte_;
-        case NPY_SHORT: return (char *)&storage->short_;
-        case NPY_USHORT: return (char *)&storage->ushort_;
-        case NPY_INT: return (char *)&storage->int_;
-        case NPY_UINT: return (char *)&storage->uint_;
-        case NPY_LONG: return (char *)&storage->long_;
-        case NPY_ULONG: return (char *)&storage->ulong_;
-        case NPY_LONGLONG: return (char *)&storage->longlong_;
-        case NPY_ULONGLONG: return (char *)&storage->ulonglong_;
-        case NPY_HALF: return (char *)&storage->half_;
-        case NPY_FLOAT: return (char *)&storage->float_;
-        case NPY_DOUBLE: return (char *)&storage->double_;
-        case NPY_LONGDOUBLE: return (char *)&storage->longdouble_;
-        case NPY_CFLOAT: return (char *)&storage->cfloat_;
-        case NPY_CDOUBLE: return (char *)&storage->cdouble_;
-        case NPY_CLONGDOUBLE: return (char *)&storage->clongdouble_;
-        default: return NULL;
-    }
-}
-
-
-/*
- * Run a resolved unary loop, including floating point error handling.
- * Returns 0 on success and -1 with an error set.
- */
-static inline int
-execute_unary_trivial_loop(
-        PyUFuncObject *ufunc, PyArrayMethod_Context *context,
-        char **data, npy_intp *strides, npy_intp count)
-{
-    NPY_BEGIN_THREADS_DEF;
-
-    PyArrayMethod_StridedLoop *strided_loop;
-    NpyAuxData *auxdata = NULL;
-    NPY_ARRAYMETHOD_FLAGS flags = 0;
-    if (context->method->get_strided_loop(context, 1, 0, strides,
-                                          &strided_loop, &auxdata, &flags) < 0) {
-        return -1;
-    }
-
-    if (!(flags & NPY_METH_NO_FLOATINGPOINT_ERRORS)) {
-        npy_clear_floatstatus();
-    }
-    if (!(flags & NPY_METH_REQUIRES_PYAPI)) {
-        NPY_BEGIN_THREADS_THRESHOLDED(count);
-    }
-    int ret = strided_loop(context, data, &count, strides, auxdata);
-    NPY_END_THREADS;
-    NPY_AUXDATA_FREE(auxdata);
-
-    if (ret == 0 && PyErr_Occurred()) {
-        ret = -1;
-    }
-    if (ret == 0 && !(flags & NPY_METH_NO_FLOATINGPOINT_ERRORS)) {
-        int fpe_errors = npy_get_floatstatus();
-        if (fpe_errors && PyUFunc_GiveFloatingpointErrors(
-                ufunc_get_name_cstr(ufunc), fpe_errors) < 0) {
-            ret = -1;
-        }
-    }
-    return ret;
-}
-
-
 /*
  * Fast path for `ufunc(arr)` with an exact, trivially iterable ndarray
  * (0-d, 1-d, or C/F-contiguous) of a builtin non-parametric dtype.
@@ -4980,7 +4920,8 @@ try_unary_trivial_call(
 
     PyArray_Descr *in_descr = PyArray_DESCR(in);
     PyArray_DTypeMeta *in_DType = NPY_DTYPE(in_descr);
-    if (in_descr != in_DType->singleton || !PyArray_ISALIGNED(in)) {
+    if (!PyDataType_ISNUMBER(in_descr)
+            || in_descr != in_DType->singleton || !PyArray_ISALIGNED(in)) {
         return -2;
     }
     int ndim = PyArray_NDIM(in);
@@ -5007,7 +4948,8 @@ try_unary_trivial_call(
     PyArrayMethodObject *method =
             (PyArrayMethodObject *)PyTuple_GET_ITEM(info, 1);
     if (!PyObject_TypeCheck(method, &PyArrayMethod_Type)
-            || method->resolve_descriptors_with_scalars != NULL) {
+            || method->resolve_descriptors_with_scalars != NULL
+            || method->resolve_descriptors != &simple_legacy_resolve_descriptors) {
         return -2;
     }
     PyObject *all_dtypes = PyTuple_GET_ITEM(info, 0);
@@ -5018,12 +4960,8 @@ try_unary_trivial_call(
     PyArray_DTypeMeta *out_DType =
             (PyArray_DTypeMeta *)PyTuple_GET_ITEM(all_dtypes, 1);
     PyArray_Descr *out_descr = out_DType->singleton;
-    /* No parametric (datetime, strings) or refcounted dtypes, and no
-     * finalize_descr (the allocation could replace the descr). */
-    if (out_descr == NULL || PyDataType_REFCHK(in_descr)
-            || PyDataType_REFCHK(out_descr)
-            || NPY_DT_is_parametric(in_DType) || NPY_DT_is_parametric(out_DType)
-            || NPY_DT_has_finalize(out_DType)) {
+    /* Only builtin numeric/bool descriptors can bypass resolution. */
+    if (out_descr == NULL || !PyDataType_ISNUMBER(out_descr)) {
         return -2;
     }
 
@@ -5037,10 +4975,7 @@ try_unary_trivial_call(
         /* 0-d returns a scalar: compute on the stack, skip the temp array. */
         unary_scalar_storage storage;
         memset(&storage, 0, sizeof(storage));  /* deterministic padding bytes */
-        char *out_buf = unary_scalar_storage_ptr(&storage, out_descr->type_num);
-        if (out_buf == NULL) {
-            return -2;
-        }
+        char *out_buf = (char *)&storage;
         char *data[2] = {PyArray_BYTES(in), out_buf};
         npy_intp strides[2] = {0, 0};  // 0 ensures scalar math, not SIMD
         if (execute_unary_trivial_loop(ufunc, &context, data, strides, 1) < 0) {
@@ -5050,10 +4985,9 @@ try_unary_trivial_call(
         return *result == NULL ? -1 : 0;
     }
 
-    PyArrayObject *out = (PyArrayObject *)PyArray_NewFromDescr_int(
+    PyArrayObject *out = (PyArrayObject *)PyArray_NewFromDescr(
             &PyArray_Type, (PyArray_Descr *)Py_NewRef(out_descr),
-            ndim, PyArray_SHAPE(in), NULL, NULL, out_fortran,
-            NULL, NULL, _NPY_ARRAY_ENSURE_DTYPE_IDENTITY);
+            ndim, PyArray_SHAPE(in), NULL, NULL, out_fortran, NULL);
     if (out == NULL) {
         return -1;
     }

Comment thread numpy/_core/src/common/npy_hashtable.c Outdated
Comment on lines +125 to +132
if (memcmp(item+1, key, key_len * sizeof(PyObject *)) == 0) {
int match = 1;
for (int i = 0; i < key_len; i++) {
if (item[i + 1] != key[i]) {
match = 0;
break;
}
}
if (match) {

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 change looks unrelated to the other work. It also looks a bit dubious to me, memcmp is pretty fast!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Seems to be faster. Unrelated, so I will remove it and revisit later.

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
PyArrayMethodObject *method =
(PyArrayMethodObject *)PyTuple_GET_ITEM(info, 1);
if (!PyObject_TypeCheck(method, &PyArrayMethod_Type)
|| method->resolve_descriptors_with_scalars != NULL) {

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.

Consider the following patch on top of your PR, which adds a test ufunc that this check does not sufficiently capture, leading to a caching bug:

Details
diff --git a/numpy/_core/src/umath/_umath_tests.c.src b/numpy/_core/src/umath/_umath_tests.c.src
index ed8058eb8a..b887e10183 100644
--- a/numpy/_core/src/umath/_umath_tests.c.src
+++ b/numpy/_core/src/umath/_umath_tests.c.src
@@ -969,6 +969,62 @@ UMath_Tests_create_unary_ufunc(PyObject *NPY_UNUSED(dummy),
             "fresh unary float64 test ufunc (negation)", 0);
 }
 
+static NPY_CASTING
+unary_error_resolve_descriptors(
+        struct PyArrayMethodObject_tag *NPY_UNUSED(method),
+        PyArray_DTypeMeta *const *NPY_UNUSED(dtypes),
+        PyArray_Descr *const *NPY_UNUSED(given_descrs),
+        PyArray_Descr **NPY_UNUSED(loop_descrs),
+        npy_intp *NPY_UNUSED(view_offset))
+{
+    PyErr_SetString(PyExc_TypeError, "unary descriptor resolution failed");
+    return (NPY_CASTING)-1;
+}
+
+static int
+unary_unexpected_loop(PyArrayMethod_Context *NPY_UNUSED(context),
+        char *const *NPY_UNUSED(data),
+        const npy_intp *NPY_UNUSED(dimensions),
+        const npy_intp *NPY_UNUSED(strides), NpyAuxData *NPY_UNUSED(auxdata))
+{
+    PyErr_SetString(PyExc_AssertionError,
+            "unary loop executed without descriptor resolution");
+    return -1;
+}
+
+static PyObject *
+UMath_Tests_create_unary_ufunc_with_resolver_error(
+        PyObject *NPY_UNUSED(self), PyObject *NPY_UNUSED(args))
+{
+    PyObject *ufunc = PyUFunc_FromFuncAndData(
+            NULL, NULL, NULL, 0, 1, 1, PyUFunc_None,
+            "unary_resolver_error", NULL, 0);
+    if (ufunc == NULL) {
+        return NULL;
+    }
+    PyArray_DTypeMeta *dtypes[2] = {
+            &PyArray_DoubleDType, &PyArray_DoubleDType};
+    PyType_Slot slots[] = {
+        {NPY_METH_resolve_descriptors, &unary_error_resolve_descriptors},
+        {NPY_METH_strided_loop, &unary_unexpected_loop},
+        {0, NULL},
+    };
+    PyArrayMethod_Spec spec = {
+        .name = "unary_resolver_error",
+        .nin = 1,
+        .nout = 1,
+        .casting = NPY_NO_CASTING,
+        .flags = NPY_METH_REQUIRES_PYAPI,
+        .dtypes = dtypes,
+        .slots = slots,
+    };
+    if (PyUFunc_AddLoopFromSpec(ufunc, &spec) < 0) {
+        Py_DECREF(ufunc);
+        return NULL;
+    }
+    return ufunc;
+}
+
 static PyMethodDef UMath_TestsMethods[] = {
     {"test_signature",  UMath_Tests_test_signature, METH_VARARGS,
      "Test signature parsing of ufunc. \n"
@@ -986,6 +1042,9 @@ static PyMethodDef UMath_TestsMethods[] = {
     {"create_unary_ufunc", UMath_Tests_create_unary_ufunc, METH_NOARGS,
      "Create a fresh unary float64 ufunc (negation) with untouched "
      "dispatch state.\n"},
+    {"create_unary_ufunc_with_resolver_error",
+     UMath_Tests_create_unary_ufunc_with_resolver_error, METH_NOARGS,
+     "Create a fresh unary float64 ufunc whose descriptor resolver raises.\n"},
     {NULL, NULL, 0, NULL}        /* Sentinel */
 };
 
diff --git a/numpy/_core/tests/test_umath.py b/numpy/_core/tests/test_umath.py
index 1a9fdb6c72..09c985dfc8 100644
--- a/numpy/_core/tests/test_umath.py
+++ b/numpy/_core/tests/test_umath.py
@@ -3261,6 +3261,19 @@ class TestUnaryTrivialCall:
     # ``ufunc(arr)`` for an exact, trivially iterable ndarray with a builtin
     # dtype takes a fast path (``try_unary_trivial_call``).
 
+    @pytest.mark.parametrize("shape", [(), (2,)])
+    def test_cached_descriptor_resolver_error(self, shape):
+        ufunc = ncu_tests.create_unary_ufunc_with_resolver_error()
+        arr = np.ones(shape, dtype=np.float64)
+
+        # The first call caches the ArrayMethod before its resolver raises.
+        with pytest.raises(TypeError, match="unary descriptor resolution failed"):
+            ufunc(arr)
+
+        # A cache hit must still invoke the resolver, without running the loop.
+        with pytest.raises(TypeError, match="unary descriptor resolution failed"):
+            ufunc(arr)
+
     @pytest.mark.parametrize("dtype", ["?", "i1", "u8", "e", "d", "g", "G"])
     def test_0d_returns_scalar(self, dtype):
         arr = np.array([1, 0], dtype=dtype)

The fix is to also add a check that specifically the legacy resolve_descriptors implementation is running

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment on lines +5023 to +5026
if (out_descr == NULL || PyDataType_REFCHK(in_descr)
|| PyDataType_REFCHK(out_descr)
|| NPY_DT_is_parametric(in_DType) || NPY_DT_is_parametric(out_DType)
|| NPY_DT_has_finalize(out_DType)) {

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.

Couldn't you replace this with !(PyDataType_ISNUMBER(in_descr) || PyDataType_ISNUMBER(out_descr)? That includes all the types in the union, I think.

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
* Returns 0 on success and -1 with an error set.
*/
static inline int
execute_unary_trivial_loop(

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 should probably share code with the scalar shortcut

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated
Comment on lines +4898 to +4917
switch (type_num) {
case NPY_BOOL: return (char *)&storage->bool_;
case NPY_BYTE: return (char *)&storage->byte_;
case NPY_UBYTE: return (char *)&storage->ubyte_;
case NPY_SHORT: return (char *)&storage->short_;
case NPY_USHORT: return (char *)&storage->ushort_;
case NPY_INT: return (char *)&storage->int_;
case NPY_UINT: return (char *)&storage->uint_;
case NPY_LONG: return (char *)&storage->long_;
case NPY_ULONG: return (char *)&storage->ulong_;
case NPY_LONGLONG: return (char *)&storage->longlong_;
case NPY_ULONGLONG: return (char *)&storage->ulonglong_;
case NPY_HALF: return (char *)&storage->half_;
case NPY_FLOAT: return (char *)&storage->float_;
case NPY_DOUBLE: return (char *)&storage->double_;
case NPY_LONGDOUBLE: return (char *)&storage->longdouble_;
case NPY_CFLOAT: return (char *)&storage->cfloat_;
case NPY_CDOUBLE: return (char *)&storage->cdouble_;
case NPY_CLONGDOUBLE: return (char *)&storage->clongdouble_;
default: return NULL;

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.

Couldn't this whole function be replaced by (char *)&storage? That doesn't have type punning issues because any pointer type can be cast to char* and you're always just returning the address of the first byte above.

np.positive(np.array(['bar'], dtype=object))


class TestUnaryTrivialCall:

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.

Try to think about other possible corner cases around the use of caching in the implementation. I also don't see tests for unusual arrays with negative or zero strides or empty arrays.

eendebakpt and others added 7 commits September 4, 2026 23:40
A cached ArrayMethod with a custom resolve_descriptors must still have
it called; the fast paths (array and scalar) now require the legacy
simple_legacy_resolve_descriptors, whose result is the DType singleton.

Also, from review: restrict the array fast path to builtin numeric
dtypes (PyDataType_ISNUMBER), drop the union member switch, use the
public PyArray_NewFromDescr, and share the loop execution with the
scalar fast path.  Adds a test ufunc whose resolver raises.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
The existing scalar fast path does not do so either.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…fast paths

The Python-scalar shortcut and the 0-d array fast path now use the same
helpers to find the cached trivial loop and to run it into stack
storage; the scalar shortcut thereby also handles loops with a different
builtin output dtype (e.g. isnan).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
eendebakpt added a commit to eendebakpt/numpy that referenced this pull request Sep 5, 2026
Shared name with numpygh-32499, which uses the same union.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@ngoldbaum ngoldbaum added the triage review Issue/PR to be discussed at the next triage meeting label Sep 21, 2026
@ngoldbaum ngoldbaum added this to the 2.6.0 Release milestone Sep 21, 2026

@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.

There's a merge conflict but otherwise I think this looks good! I also spotted two minor simplification opportunities.

Because this is a decent-sized chunk of code, I've gone ahead and added the triage review label and the 2.6 milestone. Hopefully that attracts at least one more review pass from another maintainer.

Comment thread numpy/_core/src/umath/ufunc_object.c Outdated

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.

While we're here and now that we have a nice name for it, I think this is the same as:

        cin.cdouble_ = npy_cpack(oop.real, oop.imag)

Comment on lines +4953 to +4965
int ndim = PyArray_NDIM(in);
npy_intp in_stride = in_descr->elsize;
int out_fortran = 0;
if (ndim == 1) {
in_stride = PyArray_STRIDE(in, 0);
}
else if (ndim > 1 && !PyArray_IS_C_CONTIGUOUS(in)) {
if (!PyArray_IS_F_CONTIGUOUS(in)) {
/* Multiple memory segments need the full iterator. */
return -2;
}
out_fortran = 1;
}

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.

Suggested change
int ndim = PyArray_NDIM(in);
npy_intp in_stride = in_descr->elsize;
int out_fortran = 0;
if (ndim == 1) {
in_stride = PyArray_STRIDE(in, 0);
}
else if (ndim > 1 && !PyArray_IS_C_CONTIGUOUS(in)) {
if (!PyArray_IS_F_CONTIGUOUS(in)) {
/* Multiple memory segments need the full iterator. */
return -2;
}
out_fortran = 1;
}
int ndim = PyArray_NDIM(in);
if (!PyArray_TRIVIALLY_ITERABLE(in)) {
return -2;
}
npy_intp in_stride = num == 1 ? PyArray_STRIDE(in, 0) : in_descr->elsize;
int out_fortran = PyArray_ISFORTRAN(in)

I think this is equivalent to what you have above, please double-check

eendebakpt and others added 4 commits September 21, 2026 18:43
# Conflicts:
#	benchmarks/benchmarks/bench_ufunc.py
Use npy_cpack to fill the complex scalar input and
PyArray_TRIVIALLY_ITERABLE/PyArray_ISFORTRAN for the layout check.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

01 - Enhancement triage review Issue/PR to be discussed at the next triage meeting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ufunc calls on scalars are very slow

3 participants