diff options
author | Matti Picus <matti.picus@gmail.com> | 2023-01-21 17:42:19 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-21 17:42:19 +0200 |
commit | 747823cc7e77d1e13705856d10a6ca2a46f4c47d (patch) | |
tree | a8054fac7af6986077772c4f0870a89ec1508450 | |
parent | df3751b03d789ee04cac3a9a8a7f7f3aba58735c (diff) | |
parent | 52e13840c621764ae855f1a275e2e8ff6d863cfe (diff) | |
download | numpy-747823cc7e77d1e13705856d10a6ca2a46f4c47d.tar.gz |
Merge pull request #20970 from seberg/reduce-identity-array-meth
ENH: Move identity to the ArrayMethod to allow customization
-rw-r--r-- | numpy/core/include/numpy/experimental_dtype_api.h | 46 | ||||
-rw-r--r-- | numpy/core/src/multiarray/array_method.c | 7 | ||||
-rw-r--r-- | numpy/core/src/multiarray/array_method.h | 51 | ||||
-rw-r--r-- | numpy/core/src/multiarray/experimental_public_dtype_api.c | 2 | ||||
-rw-r--r-- | numpy/core/src/umath/_scaled_float_dtype.c | 107 | ||||
-rw-r--r-- | numpy/core/src/umath/legacy_array_method.c | 118 | ||||
-rw-r--r-- | numpy/core/src/umath/reduction.c | 132 | ||||
-rw-r--r-- | numpy/core/src/umath/reduction.h | 6 | ||||
-rw-r--r-- | numpy/core/src/umath/ufunc_object.c | 68 | ||||
-rw-r--r-- | numpy/core/src/umath/ufunc_object.h | 3 | ||||
-rw-r--r-- | numpy/core/src/umath/wrapping_array_method.c | 35 | ||||
-rw-r--r-- | numpy/core/tests/test_custom_dtypes.py | 16 | ||||
-rw-r--r-- | numpy/core/tests/test_ufunc.py | 46 |
13 files changed, 508 insertions, 129 deletions
diff --git a/numpy/core/include/numpy/experimental_dtype_api.h b/numpy/core/include/numpy/experimental_dtype_api.h index 5f5f24317..86ad15f37 100644 --- a/numpy/core/include/numpy/experimental_dtype_api.h +++ b/numpy/core/include/numpy/experimental_dtype_api.h @@ -182,16 +182,21 @@ typedef struct { */ typedef enum { /* Flag for whether the GIL is required */ - NPY_METH_REQUIRES_PYAPI = 1 << 1, + NPY_METH_REQUIRES_PYAPI = 1 << 0, /* * Some functions cannot set floating point error flags, this flag * gives us the option (not requirement) to skip floating point error * setup/check. No function should set error flags and ignore them * since it would interfere with chaining operations (e.g. casting). */ - NPY_METH_NO_FLOATINGPOINT_ERRORS = 1 << 2, + NPY_METH_NO_FLOATINGPOINT_ERRORS = 1 << 1, /* Whether the method supports unaligned access (not runtime) */ - NPY_METH_SUPPORTS_UNALIGNED = 1 << 3, + NPY_METH_SUPPORTS_UNALIGNED = 1 << 2, + /* + * Used for reductions to allow reordering the operation. At this point + * assume that if set, it also applies to normal operations though! + */ + NPY_METH_IS_REORDERABLE = 1 << 3, /* All flags which can change at runtime */ NPY_METH_RUNTIME_FLAGS = ( @@ -200,6 +205,7 @@ typedef enum { } NPY_ARRAYMETHOD_FLAGS; + /* * The main object for creating a new ArrayMethod. We use the typical `slots` * mechanism used by the Python limited API (see below for the slot defs). @@ -301,10 +307,10 @@ typedef NPY_CASTING (resolve_descriptors_function)( * * NOTE: As of now, NumPy will NOT use unaligned loops in ufuncs! */ -#define NPY_METH_strided_loop 3 -#define NPY_METH_contiguous_loop 4 -#define NPY_METH_unaligned_strided_loop 5 -#define NPY_METH_unaligned_contiguous_loop 6 +#define NPY_METH_strided_loop 4 +#define NPY_METH_contiguous_loop 5 +#define NPY_METH_unaligned_strided_loop 6 +#define NPY_METH_unaligned_contiguous_loop 7 typedef struct { @@ -321,6 +327,30 @@ typedef int (PyArrayMethod_StridedLoop)(PyArrayMethod_Context *context, NpyAuxData *transferdata); +/** + * Query an ArrayMethod for the initial value for use in reduction. + * + * @param context The arraymethod context, mainly to access the descriptors. + * @param reduction_is_empty Whether the reduction is empty. When it is, the + * value returned may differ. In this case it is a "default" value that + * may differ from the "identity" value normally used. For example: + * - `0.0` is the default for `sum([])`. But `-0.0` is the correct + * identity otherwise as it preserves the sign for `sum([-0.0])`. + * - We use no identity for object, but return the default of `0` and `1` + * for the empty `sum([], dtype=object)` and `prod([], dtype=object)`. + * This allows `np.sum(np.array(["a", "b"], dtype=object))` to work. + * - `-inf` or `INT_MIN` for `max` is an identity, but at least `INT_MIN` + * not a good *default* when there are no items. + * @param initial Pointer to initial data to be filled (if possible) + * + * @returns -1, 0, or 1 indicating error, no initial value, and initial being + * successfully filled. Errors must not be given where 0 is correct, NumPy + * may call this even when not strictly necessary. + */ +#define NPY_METH_get_reduction_initial 3 +typedef int (get_reduction_initial_function)( + PyArrayMethod_Context *context, npy_bool reduction_is_empty, + char *initial); /* * **************************** @@ -458,7 +488,7 @@ PyArray_GetDefaultDescr(PyArray_DTypeMeta *DType) */ #if !defined(NO_IMPORT) && !defined(NO_IMPORT_ARRAY) -#define __EXPERIMENTAL_DTYPE_VERSION 5 +#define __EXPERIMENTAL_DTYPE_VERSION 6 static int import_experimental_dtype_api(int version) diff --git a/numpy/core/src/multiarray/array_method.c b/numpy/core/src/multiarray/array_method.c index bdbd60dbb..eeebf2d9b 100644 --- a/numpy/core/src/multiarray/array_method.c +++ b/numpy/core/src/multiarray/array_method.c @@ -27,15 +27,18 @@ * weak reference to the input DTypes. */ #define NPY_NO_DEPRECATED_API NPY_API_VERSION +#define _UMATHMODULE #define _MULTIARRAYMODULE #include <npy_pycompat.h> #include "arrayobject.h" +#include "array_coercion.h" #include "array_method.h" #include "dtypemeta.h" #include "common_dtype.h" #include "convert_datatype.h" #include "common.h" +#include "numpy/ufuncobject.h" /* @@ -248,6 +251,7 @@ fill_arraymethod_from_slots( /* Set the defaults */ meth->get_strided_loop = &npy_default_get_strided_loop; meth->resolve_descriptors = &default_resolve_descriptors; + meth->get_reduction_initial = NULL; /* no initial/identity by default */ /* Fill in the slots passed by the user */ /* @@ -284,6 +288,9 @@ fill_arraymethod_from_slots( case NPY_METH_unaligned_contiguous_loop: meth->unaligned_contiguous_loop = slot->pfunc; continue; + case NPY_METH_get_reduction_initial: + meth->get_reduction_initial = slot->pfunc; + continue; default: break; } diff --git a/numpy/core/src/multiarray/array_method.h b/numpy/core/src/multiarray/array_method.h index c9ec8903d..ef4648443 100644 --- a/numpy/core/src/multiarray/array_method.h +++ b/numpy/core/src/multiarray/array_method.h @@ -13,21 +13,21 @@ extern "C" { typedef enum { /* Flag for whether the GIL is required */ - NPY_METH_REQUIRES_PYAPI = 1 << 1, + NPY_METH_REQUIRES_PYAPI = 1 << 0, /* * Some functions cannot set floating point error flags, this flag * gives us the option (not requirement) to skip floating point error * setup/check. No function should set error flags and ignore them * since it would interfere with chaining operations (e.g. casting). */ + NPY_METH_NO_FLOATINGPOINT_ERRORS = 1 << 1, + /* Whether the method supports unaligned access (not runtime) */ + NPY_METH_SUPPORTS_UNALIGNED = 1 << 2, /* - * TODO: Change this into a positive flag? That would make "combing" - * multiple methods easier. OTOH, if we add more flags, the default - * would be 0 just like it is here. + * Used for reductions to allow reordering the operation. At this point + * assume that if set, it also applies to normal operations though! */ - NPY_METH_NO_FLOATINGPOINT_ERRORS = 1 << 2, - /* Whether the method supports unaligned access (not runtime) */ - NPY_METH_SUPPORTS_UNALIGNED = 1 << 3, + NPY_METH_IS_REORDERABLE = 1 << 3, /* * Private flag for now for *logic* functions. The logical functions * `logical_or` and `logical_and` can always cast the inputs to booleans @@ -104,6 +104,30 @@ typedef int (get_loop_function)( NPY_ARRAYMETHOD_FLAGS *flags); +/** + * Query an ArrayMethod for the initial value for use in reduction. + * + * @param context The arraymethod context, mainly to access the descriptors. + * @param reduction_is_empty Whether the reduction is empty. When it is, the + * value returned may differ. In this case it is a "default" value that + * may differ from the "identity" value normally used. For example: + * - `0.0` is the default for `sum([])`. But `-0.0` is the correct + * identity otherwise as it preserves the sign for `sum([-0.0])`. + * - We use no identity for object, but return the default of `0` and `1` + * for the empty `sum([], dtype=object)` and `prod([], dtype=object)`. + * This allows `np.sum(np.array(["a", "b"], dtype=object))` to work. + * - `-inf` or `INT_MIN` for `max` is an identity, but at least `INT_MIN` + * not a good *default* when there are no items. + * @param initial Pointer to initial data to be filled (if possible) + * + * @returns -1, 0, or 1 indicating error, no initial value, and initial being + * successfully filled. Errors must not be given where 0 is correct, NumPy + * may call this even when not strictly necessary. + */ +typedef int (get_reduction_initial_function)( + PyArrayMethod_Context *context, npy_bool reduction_is_empty, + char *initial); + /* * The following functions are only used be the wrapping array method defined * in umath/wrapping_array_method.c @@ -193,6 +217,7 @@ typedef struct PyArrayMethodObject_tag { NPY_ARRAYMETHOD_FLAGS flags; resolve_descriptors_function *resolve_descriptors; get_loop_function *get_strided_loop; + get_reduction_initial_function *get_reduction_initial; /* Typical loop functions (contiguous ones are used in current casts) */ PyArrayMethod_StridedLoop *strided_loop; PyArrayMethod_StridedLoop *contiguous_loop; @@ -203,6 +228,8 @@ typedef struct PyArrayMethodObject_tag { PyArray_DTypeMeta **wrapped_dtypes; translate_given_descrs_func *translate_given_descrs; translate_loop_descrs_func *translate_loop_descrs; + /* Chunk reserved for use by the legacy fallback arraymethod */ + char legacy_initial[sizeof(npy_clongdouble)]; /* initial value storage */ } PyArrayMethodObject; @@ -233,10 +260,12 @@ extern NPY_NO_EXPORT PyTypeObject PyBoundArrayMethod_Type; */ #define NPY_METH_resolve_descriptors 1 #define NPY_METH_get_loop 2 -#define NPY_METH_strided_loop 3 -#define NPY_METH_contiguous_loop 4 -#define NPY_METH_unaligned_strided_loop 5 -#define NPY_METH_unaligned_contiguous_loop 6 +#define NPY_METH_get_reduction_initial 3 +/* specific loops for constructions/default get_loop: */ +#define NPY_METH_strided_loop 4 +#define NPY_METH_contiguous_loop 5 +#define NPY_METH_unaligned_strided_loop 6 +#define NPY_METH_unaligned_contiguous_loop 7 /* diff --git a/numpy/core/src/multiarray/experimental_public_dtype_api.c b/numpy/core/src/multiarray/experimental_public_dtype_api.c index 84507b481..734955ac4 100644 --- a/numpy/core/src/multiarray/experimental_public_dtype_api.c +++ b/numpy/core/src/multiarray/experimental_public_dtype_api.c @@ -16,7 +16,7 @@ #include "common_dtype.h" -#define EXPERIMENTAL_DTYPE_API_VERSION 5 +#define EXPERIMENTAL_DTYPE_API_VERSION 6 typedef struct{ diff --git a/numpy/core/src/umath/_scaled_float_dtype.c b/numpy/core/src/umath/_scaled_float_dtype.c index 2d6ba3574..9bcac8114 100644 --- a/numpy/core/src/umath/_scaled_float_dtype.c +++ b/numpy/core/src/umath/_scaled_float_dtype.c @@ -26,6 +26,14 @@ #include "dispatching.h" +/* TODO: from wrapping_array_method.c, use proper public header eventually */ +NPY_NO_EXPORT int +PyUFunc_AddWrappingLoop(PyObject *ufunc_obj, + PyArray_DTypeMeta *new_dtypes[], PyArray_DTypeMeta *wrapped_dtypes[], + translate_given_descrs_func *translate_given_descrs, + translate_loop_descrs_func *translate_loop_descrs); + + typedef struct { PyArray_Descr base; double scaling; @@ -439,7 +447,7 @@ sfloat_to_bool_resolve_descriptors( static int -init_casts(void) +sfloat_init_casts(void) { PyArray_DTypeMeta *dtypes[2] = {&PyArray_SFloatDType, &PyArray_SFloatDType}; PyType_Slot slots[4] = {{0, NULL}}; @@ -645,13 +653,55 @@ add_sfloats_resolve_descriptors( } +/* + * We define the hypot loop using the "PyUFunc_AddWrappingLoop" API. + * We use this very narrowly for mapping to the double hypot loop currently. + */ static int -add_loop(const char *ufunc_name, - PyArray_DTypeMeta *dtypes[3], PyObject *meth_or_promoter) +translate_given_descrs_to_double( + int nin, int nout, PyArray_DTypeMeta *wrapped_dtypes[], + PyArray_Descr *given_descrs[], PyArray_Descr *new_descrs[]) +{ + assert(nin == 2 && nout == 1); + for (int i = 0; i < 3; i++) { + if (given_descrs[i] == NULL) { + new_descrs[i] = NULL; + } + else { + new_descrs[i] = PyArray_DescrFromType(NPY_DOUBLE); + } + } + return 0; +} + + +static int +translate_loop_descrs( + int nin, int nout, PyArray_DTypeMeta *new_dtypes[], + PyArray_Descr *given_descrs[], + PyArray_Descr *NPY_UNUSED(original_descrs[]), + PyArray_Descr *loop_descrs[]) +{ + assert(nin == 2 && nout == 1); + loop_descrs[0] = sfloat_common_instance( + given_descrs[0], given_descrs[1]); + if (loop_descrs[0] == 0) { + return -1; + } + Py_INCREF(loop_descrs[0]); + loop_descrs[1] = loop_descrs[0]; + Py_INCREF(loop_descrs[0]); + loop_descrs[2] = loop_descrs[0]; + return 0; +} + + +static PyObject * +sfloat_get_ufunc(const char *ufunc_name) { PyObject *mod = PyImport_ImportModule("numpy"); if (mod == NULL) { - return -1; + return NULL; } PyObject *ufunc = PyObject_GetAttrString(mod, ufunc_name); Py_DECREF(mod); @@ -659,6 +709,18 @@ add_loop(const char *ufunc_name, Py_DECREF(ufunc); PyErr_Format(PyExc_TypeError, "numpy.%s was not a ufunc!", ufunc_name); + return NULL; + } + return ufunc; +} + + +static int +sfloat_add_loop(const char *ufunc_name, + PyArray_DTypeMeta *dtypes[3], PyObject *meth_or_promoter) +{ + PyObject *ufunc = sfloat_get_ufunc(ufunc_name); + if (ufunc == NULL) { return -1; } PyObject *dtype_tup = PyArray_TupleFromItems(3, (PyObject **)dtypes, 1); @@ -679,6 +741,24 @@ add_loop(const char *ufunc_name, } +static int +sfloat_add_wrapping_loop(const char *ufunc_name, PyArray_DTypeMeta *dtypes[3]) +{ + PyObject *ufunc = sfloat_get_ufunc(ufunc_name); + if (ufunc == NULL) { + return -1; + } + PyArray_DTypeMeta *double_dt = PyArray_DTypeFromTypeNum(NPY_DOUBLE); + PyArray_DTypeMeta *wrapped_dtypes[3] = {double_dt, double_dt, double_dt}; + int res = PyUFunc_AddWrappingLoop( + ufunc, dtypes, wrapped_dtypes, &translate_given_descrs_to_double, + &translate_loop_descrs); + Py_DECREF(ufunc); + Py_DECREF(double_dt); + + return res; +} + /* * We add some very basic promoters to allow multiplying normal and scaled @@ -706,7 +786,7 @@ promote_to_sfloat(PyUFuncObject *NPY_UNUSED(ufunc), * get less so with the introduction of public API). */ static int -init_ufuncs(void) { +sfloat_init_ufuncs(void) { PyArray_DTypeMeta *dtypes[3] = { &PyArray_SFloatDType, &PyArray_SFloatDType, &PyArray_SFloatDType}; PyType_Slot slots[3] = {{0, NULL}}; @@ -727,7 +807,7 @@ init_ufuncs(void) { if (bmeth == NULL) { return -1; } - int res = add_loop("multiply", + int res = sfloat_add_loop("multiply", bmeth->dtypes, (PyObject *)bmeth->method); Py_DECREF(bmeth); if (res < 0) { @@ -745,13 +825,18 @@ init_ufuncs(void) { if (bmeth == NULL) { return -1; } - res = add_loop("add", + res = sfloat_add_loop("add", bmeth->dtypes, (PyObject *)bmeth->method); Py_DECREF(bmeth); if (res < 0) { return -1; } + /* N.B.: Wrapping isn't actually correct if scaling can be negative */ + if (sfloat_add_wrapping_loop("hypot", dtypes) < 0) { + return -1; + } + /* * Add a promoter for both directions of multiply with double. */ @@ -766,14 +851,14 @@ init_ufuncs(void) { if (promoter == NULL) { return -1; } - res = add_loop("multiply", promoter_dtypes, promoter); + res = sfloat_add_loop("multiply", promoter_dtypes, promoter); if (res < 0) { Py_DECREF(promoter); return -1; } promoter_dtypes[0] = double_DType; promoter_dtypes[1] = &PyArray_SFloatDType; - res = add_loop("multiply", promoter_dtypes, promoter); + res = sfloat_add_loop("multiply", promoter_dtypes, promoter); Py_DECREF(promoter); if (res < 0) { return -1; @@ -814,11 +899,11 @@ get_sfloat_dtype(PyObject *NPY_UNUSED(mod), PyObject *NPY_UNUSED(args)) return NULL; } - if (init_casts() < 0) { + if (sfloat_init_casts() < 0) { return NULL; } - if (init_ufuncs() < 0) { + if (sfloat_init_ufuncs() < 0) { return NULL; } diff --git a/numpy/core/src/umath/legacy_array_method.c b/numpy/core/src/umath/legacy_array_method.c index a8627d55c..39b66a0ec 100644 --- a/numpy/core/src/umath/legacy_array_method.c +++ b/numpy/core/src/umath/legacy_array_method.c @@ -13,10 +13,13 @@ #include "convert_datatype.h" #include "array_method.h" +#include "array_coercion.h" #include "dtype_transfer.h" #include "legacy_array_method.h" #include "dtypemeta.h" +#include "ufunc_object.h" + typedef struct { NpyAuxData base; @@ -233,6 +236,97 @@ get_wrapped_legacy_ufunc_loop(PyArrayMethod_Context *context, } + +/* + * We can shave off a bit of time by just caching the initial and this is + * trivial for all internal numeric types. (Wrapped ufuncs never use + * byte-swapping.) + */ +static int +copy_cached_initial( + PyArrayMethod_Context *context, npy_bool NPY_UNUSED(reduction_is_empty), + char *initial) +{ + memcpy(initial, context->method->legacy_initial, + context->descriptors[0]->elsize); + return 1; +} + + +/* + * The default `get_reduction_initial` attempts to look up the identity + * from the calling ufunc. This might fail, so we only call it when necessary. + * + * For internal number dtypes, we can easily cache it, so do so after the + * first call by overriding the function with `copy_cache_initial`. + * This path is not publically available. That could be added, and for a + * custom initial getter it should be static/compile time data anyway. + */ +static int +get_initial_from_ufunc( + PyArrayMethod_Context *context, npy_bool reduction_is_empty, + char *initial) +{ + if (context->caller == NULL + || !PyObject_TypeCheck(context->caller, &PyUFunc_Type)) { + /* Impossible in NumPy 1.24; guard in case it becomes possible. */ + PyErr_SetString(PyExc_ValueError, + "getting initial failed because it can only done for legacy " + "ufunc loops when the ufunc is provided."); + return -1; + } + npy_bool reorderable; + PyObject *identity_obj = PyUFunc_GetDefaultIdentity( + (PyUFuncObject *)context->caller, &reorderable); + if (identity_obj == NULL) { + return -1; + } + if (identity_obj == Py_None) { + /* UFunc has no idenity (should not happen) */ + Py_DECREF(identity_obj); + return 0; + } + if (PyTypeNum_ISUNSIGNED(context->descriptors[1]->type_num) + && PyLong_CheckExact(identity_obj)) { + /* + * This is a bit of a hack until we have truly loop specific + * identities. Python -1 cannot be cast to unsigned so convert + * it to a NumPy scalar, but we use -1 for bitwise functions to + * signal all 1s. + * (A builtin identity would not overflow here, although we may + * unnecessary convert 0 and 1.) + */ + Py_SETREF(identity_obj, PyObject_CallFunctionObjArgs( + (PyObject *)&PyLongArrType_Type, identity_obj, NULL)); + if (identity_obj == NULL) { + return -1; + } + } + else if (context->descriptors[0]->type_num == NPY_OBJECT + && !reduction_is_empty) { + /* Allows `sum([object()])` to work, but use 0 when empty. */ + Py_DECREF(identity_obj); + return 0; + } + + int res = PyArray_Pack(context->descriptors[0], initial, identity_obj); + Py_DECREF(identity_obj); + if (res < 0) { + return -1; + } + + if (PyTypeNum_ISNUMBER(context->descriptors[0]->type_num)) { + /* For numbers we can cache to avoid going via Python ints */ + memcpy(context->method->legacy_initial, initial, + context->descriptors[0]->elsize); + context->method->get_reduction_initial = ©_cached_initial; + } + + /* Reduction can use the initial value */ + return 1; +} + + /* * Get the unbound ArrayMethod which wraps the instances of the ufunc. * Note that this function stores the result on the ufunc and then only @@ -272,6 +366,27 @@ PyArray_NewLegacyWrappingArrayMethod(PyUFuncObject *ufunc, flags = _NPY_METH_FORCE_CAST_INPUTS; } + get_reduction_initial_function *get_reduction_intial = NULL; + if (ufunc->nin == 2 && ufunc->nout == 1) { + npy_bool reorderable = NPY_FALSE; + PyObject *identity_obj = PyUFunc_GetDefaultIdentity( + ufunc, &reorderable); + if (identity_obj == NULL) { + return NULL; + } + /* + * TODO: For object, "reorderable" is needed(?), because otherwise + * we disable multi-axis reductions `arr.sum(0, 1)`. But for + * `arr = array([["a", "b"], ["c", "d"]], dtype="object")` + * it isn't actually reorderable (order changes result). + */ + if (reorderable) { + flags |= NPY_METH_IS_REORDERABLE; + } + if (identity_obj != Py_None) { + get_reduction_intial = &get_initial_from_ufunc; + } + } for (int i = 0; i < ufunc->nin+ufunc->nout; i++) { if (signature[i]->singleton->flags & ( NPY_ITEM_REFCOUNT | NPY_ITEM_IS_POINTER | NPY_NEEDS_PYAPI)) { @@ -282,9 +397,10 @@ PyArray_NewLegacyWrappingArrayMethod(PyUFuncObject *ufunc, } } - PyType_Slot slots[3] = { + PyType_Slot slots[4] = { {NPY_METH_get_loop, &get_wrapped_legacy_ufunc_loop}, {NPY_METH_resolve_descriptors, &simple_legacy_resolve_descriptors}, + {NPY_METH_get_reduction_initial, get_reduction_intial}, {0, NULL}, }; if (any_output_flexible) { diff --git a/numpy/core/src/umath/reduction.c b/numpy/core/src/umath/reduction.c index 817f99a04..9416e9a29 100644 --- a/numpy/core/src/umath/reduction.c +++ b/numpy/core/src/umath/reduction.c @@ -17,6 +17,9 @@ #include "numpy/arrayobject.h" #include "npy_pycompat.h" +#include "array_assign.h" +#include "array_coercion.h" +#include "array_method.h" #include "ctors.h" #include "numpy/ufuncobject.h" @@ -148,24 +151,15 @@ PyArray_CopyInitialReduceValues( * context : The ArrayMethod context (with ufunc, method, and descriptors). * operand : The array to be reduced. * out : NULL, or the array into which to place the result. - * wheremask : NOT YET SUPPORTED, but this parameter is placed here - * so that support can be added in the future without breaking - * API compatibility. Pass in NULL. + * wheremask : Reduction mask of valid values used for `where=`. * axis_flags : Flags indicating the reduction axes of 'operand'. - * reorderable : If True, the reduction being done is reorderable, which - * means specifying multiple axes of reduction at once is ok, - * and the reduction code may calculate the reduction in an - * arbitrary order. The calculation may be reordered because - * of cache behavior or multithreading requirements. * keepdims : If true, leaves the reduction dimensions in the result * with size one. * subok : If true, the result uses the subclass of operand, otherwise * it is always a base class ndarray. - * identity : If Py_None, PyArray_CopyInitialReduceValues is used, otherwise - * this value is used to initialize the result to - * the reduction's unit. + * initial : Initial value, if NULL the default is fetched from the + * ArrayMethod (typically as the default from the ufunc). * loop : `reduce_loop` from `ufunc_object.c`. TODO: Refactor - * data : Data which is passed to the inner loop. * buffersize : Buffer size for the iterator. For the default, pass in 0. * funcname : The name of the reduction function, for error messages. * errormask : forwarded from _get_bufsize_errmask @@ -182,9 +176,9 @@ PyArray_CopyInitialReduceValues( NPY_NO_EXPORT PyArrayObject * PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, PyArrayObject *operand, PyArrayObject *out, PyArrayObject *wheremask, - npy_bool *axis_flags, int reorderable, int keepdims, - PyObject *identity, PyArray_ReduceLoopFunc *loop, - void *data, npy_intp buffersize, const char *funcname, int errormask) + npy_bool *axis_flags, int keepdims, + PyObject *initial, PyArray_ReduceLoopFunc *loop, + npy_intp buffersize, const char *funcname, int errormask) { assert(loop != NULL); PyArrayObject *result = NULL; @@ -198,38 +192,35 @@ PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, /* Loop auxdata (must be freed on error) */ NpyAuxData *auxdata = NULL; - /* More than one axis means multiple orders are possible */ - if (!reorderable && count_axes(PyArray_NDIM(operand), axis_flags) > 1) { - PyErr_Format(PyExc_ValueError, - "reduction operation '%s' is not reorderable, " - "so at most one axis may be specified", - funcname); - return NULL; - } - /* Can only use where with an initial ( from identity or argument) */ - if (wheremask != NULL && identity == Py_None) { - PyErr_Format(PyExc_ValueError, - "reduction operation '%s' does not have an identity, " - "so to use a where mask one has to specify 'initial'", - funcname); - return NULL; - } - - /* Set up the iterator */ op[0] = out; op[1] = operand; op_dtypes[0] = context->descriptors[0]; op_dtypes[1] = context->descriptors[1]; + /* Buffer to use when we need an initial value */ + char *initial_buf = NULL; + + /* More than one axis means multiple orders are possible */ + if (!(context->method->flags & NPY_METH_IS_REORDERABLE) + && count_axes(PyArray_NDIM(operand), axis_flags) > 1) { + PyErr_Format(PyExc_ValueError, + "reduction operation '%s' is not reorderable, " + "so at most one axis may be specified", + funcname); + goto fail; + } + it_flags = NPY_ITER_BUFFERED | NPY_ITER_EXTERNAL_LOOP | NPY_ITER_GROWINNER | - NPY_ITER_DONT_NEGATE_STRIDES | NPY_ITER_ZEROSIZE_OK | NPY_ITER_REFS_OK | NPY_ITER_DELAY_BUFALLOC | NPY_ITER_COPY_IF_OVERLAP; + if (!(context->method->flags & NPY_METH_IS_REORDERABLE)) { + it_flags |= NPY_ITER_DONT_NEGATE_STRIDES; + } op_flags[0] = NPY_ITER_READWRITE | NPY_ITER_ALIGNED | NPY_ITER_ALLOCATE | @@ -297,8 +288,52 @@ PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, goto fail; } + npy_bool empty_iteration = NpyIter_GetIterSize(iter) == 0; result = NpyIter_GetOperandArray(iter)[0]; + /* + * Get the initial value (if it exists). If the iteration is empty + * then we assume the reduction is also empty. The reason is that when + * the outer iteration is empty we just won't use the initial value + * in any case. (`np.sum(np.zeros((0, 3)), axis=0)` is a length 3 + * reduction but has an empty result.) + */ + if ((initial == NULL && context->method->get_reduction_initial == NULL) + || initial == Py_None) { + /* There is no initial value, or initial value was explicitly unset */ + } + else { + /* Not all functions will need initialization, but init always: */ + initial_buf = PyMem_Calloc(1, op_dtypes[0]->elsize); + if (initial_buf == NULL) { + PyErr_NoMemory(); + goto fail; + } + if (initial != NULL) { + /* must use user provided initial value */ + if (PyArray_Pack(op_dtypes[0], initial_buf, initial) < 0) { + goto fail; + } + } + else { + /* + * Fetch initial from ArrayMethod, we pretend the reduction is + * empty when the iteration is. This may be wrong, but when it is, + * we will not need the identity as the result is also empty. + */ + int has_initial = context->method->get_reduction_initial( + context, empty_iteration, initial_buf); + if (has_initial < 0) { + goto fail; + } + if (!has_initial) { + /* We have no initial value available, free buffer to indicate */ + PyMem_FREE(initial_buf); + initial_buf = NULL; + } + } + } + PyArrayMethod_StridedLoop *strided_loop; NPY_ARRAYMETHOD_FLAGS flags = 0; @@ -313,12 +348,27 @@ PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, * Initialize the result to the reduction unit if possible, * otherwise copy the initial values and get a view to the rest. */ - if (identity != Py_None) { - if (PyArray_FillWithScalar(result, identity) < 0) { + if (initial_buf != NULL) { + /* Loop provided an identity or default value, assign to result. */ + int ret = raw_array_assign_scalar( + PyArray_NDIM(result), PyArray_DIMS(result), + PyArray_DESCR(result), + PyArray_BYTES(result), PyArray_STRIDES(result), + op_dtypes[0], initial_buf); + if (ret < 0) { goto fail; } } else { + /* Can only use where with an initial (from identity or argument) */ + if (wheremask != NULL) { + PyErr_Format(PyExc_ValueError, + "reduction operation '%s' does not have an identity, " + "so to use a where mask one has to specify 'initial'", + funcname); + return NULL; + } + /* * For 1-D skip_first_count could be optimized to 0, but no-identity * reductions are not super common. @@ -354,7 +404,7 @@ PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, } } - if (NpyIter_GetIterSize(iter) != 0) { + if (!empty_iteration) { NpyIter_IterNextFunc *iternext; char **dataptr; npy_intp *strideptr; @@ -387,6 +437,10 @@ PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, } Py_INCREF(result); + if (initial_buf != NULL && PyDataType_REFCHK(PyArray_DESCR(result))) { + PyArray_Item_XDECREF(initial_buf, PyArray_DESCR(result)); + } + PyMem_FREE(initial_buf); NPY_AUXDATA_FREE(auxdata); if (!NpyIter_Deallocate(iter)) { Py_DECREF(result); @@ -395,6 +449,10 @@ PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, return result; fail: + if (initial_buf != NULL && PyDataType_REFCHK(op_dtypes[0])) { + PyArray_Item_XDECREF(initial_buf, op_dtypes[0]); + } + PyMem_FREE(initial_buf); NPY_AUXDATA_FREE(auxdata); if (iter != NULL) { NpyIter_Deallocate(iter); diff --git a/numpy/core/src/umath/reduction.h b/numpy/core/src/umath/reduction.h index 2170e27a7..d2cbe4849 100644 --- a/numpy/core/src/umath/reduction.h +++ b/numpy/core/src/umath/reduction.h @@ -64,8 +64,8 @@ typedef int (PyArray_ReduceLoopFunc)(PyArrayMethod_Context *context, NPY_NO_EXPORT PyArrayObject * PyUFunc_ReduceWrapper(PyArrayMethod_Context *context, PyArrayObject *operand, PyArrayObject *out, PyArrayObject *wheremask, - npy_bool *axis_flags, int reorderable, int keepdims, - PyObject *identity, PyArray_ReduceLoopFunc *loop, - void *data, npy_intp buffersize, const char *funcname, int errormask); + npy_bool *axis_flags, int keepdims, + PyObject *initial, PyArray_ReduceLoopFunc *loop, + npy_intp buffersize, const char *funcname, int errormask); #endif diff --git a/numpy/core/src/umath/ufunc_object.c b/numpy/core/src/umath/ufunc_object.c index 62e06f281..eac09389e 100644 --- a/numpy/core/src/umath/ufunc_object.c +++ b/numpy/core/src/umath/ufunc_object.c @@ -2085,12 +2085,16 @@ _get_coredim_sizes(PyUFuncObject *ufunc, PyArrayObject **op, } /* - * Returns a new reference + * Returns a new reference to the ufunc identity. Note that this identity + * is only a default identity value stored on the ufunc, since the invidiual + * ufunc loop (ArrayMethod) is queried for the actual identity. + * * TODO: store a reference in the ufunc object itself, rather than * constructing one each time */ -static PyObject * -_get_identity(PyUFuncObject *ufunc, npy_bool *reorderable) { +NPY_NO_EXPORT PyObject * +PyUFunc_GetDefaultIdentity(PyUFuncObject *ufunc, npy_bool *reorderable) +{ switch(ufunc->identity) { case PyUFunc_One: *reorderable = 1; @@ -3006,10 +3010,8 @@ PyUFunc_Reduce(PyUFuncObject *ufunc, PyObject *initial, PyArrayObject *wheremask) { int iaxes, ndim; - npy_bool reorderable; npy_bool axis_flags[NPY_MAXDIMS]; - PyArrayObject *result = NULL; - PyObject *identity; + const char *ufunc_name = ufunc_get_name_cstr(ufunc); /* These parameters come from a TLS global */ int buffersize = 0, errormask = 0; @@ -3034,9 +3036,6 @@ PyUFunc_Reduce(PyUFuncObject *ufunc, return NULL; } - /* - * Promote and fetch ufuncimpl (currently needed to fix up the identity). - */ PyArray_Descr *descrs[3]; PyArrayMethodObject *ufuncimpl = reducelike_promote_and_resolve(ufunc, arr, out, signature, NPY_FALSE, descrs, NPY_UNSAFE_CASTING, "reduce"); @@ -3044,62 +3043,19 @@ PyUFunc_Reduce(PyUFuncObject *ufunc, return NULL; } - /* Get the identity */ - /* TODO: Both of these should be provided by the ArrayMethod! */ - identity = _get_identity(ufunc, &reorderable); - if (identity == NULL) { - goto finish; - } - - /* Get the initial value */ - if (initial == NULL) { - initial = identity; - - /* - * The identity for a dynamic dtype like - * object arrays can't be used in general - */ - if (initial != Py_None && PyArray_ISOBJECT(arr) && PyArray_SIZE(arr) != 0) { - Py_DECREF(initial); - initial = Py_None; - Py_INCREF(initial); - } - else if (PyTypeNum_ISUNSIGNED(descrs[2]->type_num) - && PyLong_CheckExact(initial)) { - /* - * This is a bit of a hack until we have truly loop specific - * identities. Python -1 cannot be cast to unsigned so convert - * it to a NumPy scalar, but we use -1 for bitwise functions to - * signal all 1s. - * (A builtin identity would not overflow here, although we may - * unnecessary convert 0 and 1.) - */ - Py_SETREF(initial, PyObject_CallFunctionObjArgs( - (PyObject *)&PyLongArrType_Type, initial, NULL)); - if (initial == NULL) { - goto finish; - } - } - } else { - Py_DECREF(identity); - Py_INCREF(initial); /* match the reference count in the if above */ - } - PyArrayMethod_Context context = { .caller = (PyObject *)ufunc, .method = ufuncimpl, .descriptors = descrs, }; - result = PyUFunc_ReduceWrapper(&context, - arr, out, wheremask, axis_flags, reorderable, keepdims, - initial, reduce_loop, ufunc, buffersize, ufunc_name, errormask); + PyArrayObject *result = PyUFunc_ReduceWrapper(&context, + arr, out, wheremask, axis_flags, keepdims, + initial, reduce_loop, buffersize, ufunc_name, errormask); - finish: for (int i = 0; i < 3; i++) { Py_DECREF(descrs[i]); } - Py_XDECREF(initial); return result; } @@ -7018,7 +6974,7 @@ static PyObject * ufunc_get_identity(PyUFuncObject *ufunc, void *NPY_UNUSED(ignored)) { npy_bool reorderable; - return _get_identity(ufunc, &reorderable); + return PyUFunc_GetDefaultIdentity(ufunc, &reorderable); } static PyObject * diff --git a/numpy/core/src/umath/ufunc_object.h b/numpy/core/src/umath/ufunc_object.h index 32af6c58e..45444dac4 100644 --- a/numpy/core/src/umath/ufunc_object.h +++ b/numpy/core/src/umath/ufunc_object.h @@ -12,6 +12,9 @@ ufunc_seterr(PyObject *NPY_UNUSED(dummy), PyObject *args); NPY_NO_EXPORT const char* ufunc_get_name_cstr(PyUFuncObject *ufunc); +NPY_NO_EXPORT PyObject * +PyUFunc_GetDefaultIdentity(PyUFuncObject *ufunc, npy_bool *reorderable); + /* strings from umathmodule.c that are interned on umath import */ NPY_VISIBILITY_HIDDEN extern PyObject *npy_um_str_array_ufunc; NPY_VISIBILITY_HIDDEN extern PyObject *npy_um_str_array_prepare; diff --git a/numpy/core/src/umath/wrapping_array_method.c b/numpy/core/src/umath/wrapping_array_method.c index 9f8f036e8..c32b5c714 100644 --- a/numpy/core/src/umath/wrapping_array_method.c +++ b/numpy/core/src/umath/wrapping_array_method.c @@ -177,6 +177,39 @@ wrapping_method_get_loop( } +/* + * Wraps the original identity function, needs to translate the descriptors + * back to the original ones and provide an "original" context (identically to + * `get_loop`). + * We assume again that translating the descriptors is quick. + */ +static int +wrapping_method_get_identity_function( + PyArrayMethod_Context *context, npy_bool reduction_is_empty, + char *item) +{ + /* Copy the context, and replace descriptors: */ + PyArrayMethod_Context orig_context = *context; + PyArray_Descr *orig_descrs[NPY_MAXARGS]; + orig_context.descriptors = orig_descrs; + orig_context.method = context->method->wrapped_meth; + + int nin = context->method->nin, nout = context->method->nout; + PyArray_DTypeMeta **dtypes = context->method->wrapped_dtypes; + + if (context->method->translate_given_descrs( + nin, nout, dtypes, context->descriptors, orig_descrs) < 0) { + return -1; + } + int res = context->method->wrapped_meth->get_reduction_initial( + &orig_context, reduction_is_empty, item); + for (int i = 0; i < nin + nout; i++) { + Py_DECREF(orig_descrs); + } + return res; +} + + /** * Allows creating of a fairly lightweight wrapper around an existing ufunc * loop. The idea is mainly for units, as this is currently slightly limited @@ -243,6 +276,8 @@ PyUFunc_AddWrappingLoop(PyObject *ufunc_obj, PyType_Slot slots[] = { {NPY_METH_resolve_descriptors, &wrapping_method_resolve_descriptors}, {NPY_METH_get_loop, &wrapping_method_get_loop}, + {NPY_METH_get_reduction_initial, + &wrapping_method_get_identity_function}, {0, NULL} }; diff --git a/numpy/core/tests/test_custom_dtypes.py b/numpy/core/tests/test_custom_dtypes.py index 185404f09..33f9a996f 100644 --- a/numpy/core/tests/test_custom_dtypes.py +++ b/numpy/core/tests/test_custom_dtypes.py @@ -202,3 +202,19 @@ class TestSFloat: # The output casting does not match the bool, bool -> bool loop: with pytest.raises(TypeError): ufunc(a, a, out=np.empty(a.shape, dtype=int), casting="equiv") + + def test_wrapped_and_wrapped_reductions(self): + a = self._get_array(2.) + float_equiv = a.astype(float) + + expected = np.hypot(float_equiv, float_equiv) + res = np.hypot(a, a) + assert res.dtype == a.dtype + res_float = res.view(np.float64) * 2 + assert_array_equal(res_float, expected) + + # Also check reduction (keepdims, due to incorrect getitem) + res = np.hypot.reduce(a, keepdims=True) + assert res.dtype == a.dtype + expected = np.hypot.reduce(float_equiv, keepdims=True) + assert res.view(np.float64) * 2 == expected diff --git a/numpy/core/tests/test_ufunc.py b/numpy/core/tests/test_ufunc.py index 34cdd88c6..55122d697 100644 --- a/numpy/core/tests/test_ufunc.py +++ b/numpy/core/tests/test_ufunc.py @@ -1650,6 +1650,19 @@ class TestUfunc: a = a[1:, 1:, 1:] self.check_identityless_reduction(a) + def test_reduce_identity_depends_on_loop(self): + """ + The type of the result should always depend on the selected loop, not + necessarily the output (only relevant for object arrays). + """ + # For an object loop, the default value 0 with type int is used: + assert type(np.add.reduce([], dtype=object)) is int + out = np.array(None, dtype=object) + # When the loop is float64 but `out` is object this does not happen, + # the result is float64 cast to object (which gives Python `float`). + np.add.reduce([], out=out, dtype=np.float64) + assert type(out[()]) is float + def test_initial_reduction(self): # np.minimum.reduce is an identityless reduction @@ -1670,6 +1683,9 @@ class TestUfunc: # Check initial=None raises ValueError for both types of ufunc reductions assert_raises(ValueError, np.minimum.reduce, [], initial=None) assert_raises(ValueError, np.add.reduce, [], initial=None) + # Also in the somewhat special object case: + with pytest.raises(ValueError): + np.add.reduce([], initial=None, dtype=object) # Check that np._NoValue gives default behavior. assert_equal(np.add.reduce([], initial=np._NoValue), 0) @@ -1679,6 +1695,23 @@ class TestUfunc: res = np.add.reduce(a, initial=5) assert_equal(res, 15) + def test_empty_reduction_and_idenity(self): + arr = np.zeros((0, 5)) + # OK, since the reduction itself is *not* empty, the result is + assert np.true_divide.reduce(arr, axis=1).shape == (0,) + # Not OK, the reduction itself is empty and we have no idenity + with pytest.raises(ValueError): + np.true_divide.reduce(arr, axis=0) + + # Test that an empty reduction fails also if the result is empty + arr = np.zeros((0, 0, 5)) + with pytest.raises(ValueError): + np.true_divide.reduce(arr, axis=1) + + # Division reduction makes sense with `initial=1` (empty or not): + res = np.true_divide.reduce(arr, axis=1, initial=1) + assert_array_equal(res, np.ones((0, 5))) + @pytest.mark.parametrize('axis', (0, 1, None)) @pytest.mark.parametrize('where', (np.array([False, True, True]), np.array([[True], [False], [True]]), @@ -2617,7 +2650,9 @@ def test_reduce_casterrors(offset): with pytest.raises(ValueError, match="invalid literal"): # This is an unsafe cast, but we currently always allow that. # Note that the double loop is picked, but the cast fails. - np.add.reduce(arr, dtype=np.intp, out=out) + # `initial=None` disables the use of an identity here to test failures + # while copying the first values path (not used when identity exists). + np.add.reduce(arr, dtype=np.intp, out=out, initial=None) assert count == sys.getrefcount(value) # If an error occurred during casting, the operation is done at most until # the error occurs (the result of which would be `value * offset`) and -1 @@ -2626,6 +2661,15 @@ def test_reduce_casterrors(offset): assert out[()] < value * offset +def test_object_reduce_cleanup_on_failure(): + # Test cleanup, including of the initial value (manually provided or not) + with pytest.raises(TypeError): + np.add.reduce([1, 2, None], initial=4) + + with pytest.raises(TypeError): + np.add.reduce([1, 2, None]) + + @pytest.mark.skipif(IS_WASM, reason="fp errors don't work in wasm") @pytest.mark.parametrize("method", [np.add.accumulate, np.add.reduce, |