NEP 43 — 增强UFuncs的可扩展性#

标题:

增强UFuncs的可扩展性

作者:

Sebastian Berg

状态:

草案

类型:

标准

创建时间:

2020-06-20

注意

本NEP是系列中的第四篇

  • NEP 40 解释了NumPy的dtype实现中的缺陷。

  • NEP 41 概述了我们提议的替代方案。

  • NEP 42 描述了新设计中与数据类型相关的API。

  • NEP 43(本文档)描述了新设计中用于通用函数的API。

摘要#

先前的NEP 42提议创建可在NumPy外部由用户定义的新DTypes。NEP 42的实现将使用户能够创建具有自定义dtype和存储值的数组。本NEP概述了NumPy未来如何操作带有自定义dtypes的数组。操作NumPy数组的最重要函数是所谓的“通用函数”(ufunc),其中包括所有数学函数,例如np.addnp.multiply,甚至np.matmul。这些ufunc必须有效地操作具有不同数据类型的多个数组。

本NEP提议扩展ufunc的设计。它在可操作多种不同dtype(如浮点数或整数)的ufunc与定义特定dtype高效操作的新ArrayMethod之间建立了新的区分。

注意

内部和外部API的细节可能会根据用户评论和实现约束进行更改。但其基本原则和选择不应发生重大变化。

动机与范围#

本NEP的目标是扩展通用函数以支持NEP 41和42中详细说明的新DType系统。虽然主要动机是启用新的用户定义DTypes,但这还将显著简化NumPy字符串或结构化DTypes的通用函数定义。迄今为止,由于其参数性质(对比NEP 41和42),例如字符串长度,这些DTypes不受任何NumPy函数(如np.addnp.equal)的支持。

数组上的函数必须处理若干个不同的步骤,这些步骤在“UFunc调用涉及的步骤”一节中有更详细的描述。其中最重要的是:

  • 组织定义特定DTypes的ufunc调用所需的所有功能。这通常被称为“内循环”。

  • 处理找不到精确匹配实现的情况。例如,当int32float64相加时,int32会被转换为float64。这需要一个独特的“提升”步骤。

在组织和定义这些之后,我们需要:

  • 定义用户API以定制上述两点。

  • 允许方便地重用现有功能。例如,表示物理单位(如米)的DType应该能够回退到NumPy现有的数学实现。

本NEP详细说明了如何在NumPy中实现这些要求:

  • 目前作为ufunc定义一部分的所有特定于DType的功能都将作为新的ArrayMethod对象的一部分进行定义。此ArrayMethod对象将是描述任何操作数组的函数的新首选方式。

  • UFuncs将调度到ArrayMethod,并可能使用提升来找到要使用的正确ArrayMethod。这将在提升和调度部分进行描述。

每个部分都将概述一个新的C-API。未来的Python API预计会非常相似,并且为了可读性,C-API以Python代码的形式呈现。

本NEP提议对NumPy ufunc内部进行大规模但必要的重构。此次现代化不会直接影响最终用户,它不仅是新DTypes的必要步骤,其本身也是一项维护工作,预计将有助于未来对ufunc机制的改进。

尽管提议的最重要重构是新的ArrayMethod对象,但最大的长期考虑是提升和调度的API选择。

向后兼容性#

普遍的向后兼容性问题此前已在NEP 41中列出。

绝大多数用户除了NumPy版本发布中常见的变化外,不应看到任何其他变化。受提议变更影响的主要有三类用户或用例:

  1. Numba包直接访问NumPy C循环,并为了自身目的直接修改NumPy ufunc结构。

  2. Astropy使用其自己的“类型解析器”,这意味着从现有类型解析到新默认Promoter的默认切换需要谨慎处理。

  3. 目前可以为dtype实例注册循环。这在理论上对结构化dtypes有用,并且是此处提议的DType解析步骤之后的一个解析步骤。

本NEP将尽力保持向后兼容性。然而,这两个项目都已表示愿意适应破坏性更改。

NumPy能够提供向后兼容性的主要原因是:

  • 现有内循环可以被封装,为调用增加一层间接性,但保持完全向后兼容。在这种情况下,get_loop函数可以搜索现有的内循环函数(这些函数直接存储在ufunc上),以即使面对潜在的直接结构访问也能保持完全兼容性。

  • 遗留类型解析器可以作为回退调用(可能缓存结果)。解析器可能需要调用两次(一次用于DType解析,一次用于resolve_descriptor实现)。

  • 在大多数情况下,回退到遗留类型解析器应处理为此类结构化dtype实例定义的循环。这是因为如果没有其他np.Void实现,遗留回退至少在最初会保留旧的行为。

掩码类型解析器将明确不再受支持,但目前没有已知用户(包括NumPy本身,它只使用默认版本)。

此外,不会尝试为调用(而不是提供普通或掩码类型解析器)进行兼容性处理。因为NumPy只会将其用作回退。这种(未文档化的)可能性没有已知用户。

尽管上述更改可能破坏某些工作流,但我们相信长期的改进远远超过了这一点。此外,像Astropy和Numba这样的包能够适应,因此最终用户可能需要更新其库,而不是其代码。

用法与影响#

本NEP重构了NumPy数组操作在NumPy内部和对于外部实现者的定义方式。本NEP主要关注那些为自定义DTypes扩展ufuncs或创建自定义ufuncs的人。它不旨在最终确定所有潜在用例,而是重构NumPy以使其可扩展,并允许在出现新问题或功能请求时进行处理。

概述和最终用户API#

为了概述本NEP如何构建ufuncs,以下描述了所提议重构对最终用户的潜在影响。

当考虑只有一个输入的ufunc时,通用函数非常类似于在数组的DType上定义的Python方法:

res = np.positive(arr)

可以(概念上)实现为:

positive_impl = arr.dtype.positive
res = positive_impl(arr)

然而,与方法不同,positive_impl并不存储在dtype本身上。它更像是np.positive针对特定DType的实现。当前的NumPy通过通用函数中的dtype(或更准确地说是signature)属性部分地暴露了这种“实现选择”,尽管这些属性很少被使用。

np.positive(arr, dtype=np.float64)

强制NumPy使用专门为Float64 DType编写的positive_impl

本NEP通过创建一个新对象来表示positive_impl,从而使这种区别更加明确。

positive_impl = np.positive.resolve_impl((type(arr.dtype), None))
# The `None` represents the output DType which is automatically chosen.

尽管positive_impl对象的创建和resolve_impl方法是本NEP的一部分,但以下代码:

res = positive_impl(arr)

最初可能不会实现,并且不是重新设计的核心。

通常,NumPy通用函数可以接受多个输入。这需要通过考虑所有输入来查找实现,并使ufuncs成为关于输入DTypes的“多方法”。

add_impl = np.add.resolve_impl((type(arr1.dtype), type(arr2.dtype), None))

本NEP定义了positive_impladd_impl如何表示为新的ArrayMethod,该ArrayMethod可以在NumPy外部实现。此外,它定义了resolve_impl将如何实现和解决调度和提升。

查看“UFunc调用涉及的步骤”部分后,这种拆分的原因可能会更加清晰。

定义新的ufunc实现#

以下是一个模拟示例,展示了如何将一个新实现(在本例中是定义字符串相等性)添加到ufunc中。

class StringEquality(BoundArrayMethod):
    nin = 1
    nout = 1
    # DTypes are stored on the BoundArrayMethod and not on the internal
    # ArrayMethod, to reference cyles.
    DTypes = (String, String, Bool)

    def resolve_descriptors(self: ArrayMethod, DTypes, given_descrs):
        """The strided loop supports all input string dtype instances
        and always returns a boolean. (String is always native byte order.)

        Defining this function is not necessary, since NumPy can provide
        it by default.

        The `self` argument here refers to the unbound array method, so
        that DTypes are passed in explicitly.
        """
        assert isinstance(given_descrs[0], DTypes[0])
        assert isinstance(given_descrs[1], DTypes[1])
        assert given_descrs[2] is None or isinstance(given_descrs[2], DTypes[2])

        out_descr = given_descrs[2]  # preserve input (e.g. metadata)
        if given_descrs[2] is None:
            out_descr = DTypes[2]()

        # The operation is always "no" casting (most ufuncs are)
        return (given_descrs[0], given_descrs[1], out_descr), "no"

    def strided_loop(context, dimensions, data, strides, innerloop_data):
        """The 1-D strided loop, similar to those used in current ufuncs"""
        # dimensions: Number of loop items and core dimensions
        # data: Pointers to the array data.
        # strides: strides to iterate all elements
        n = dimensions[0]  # number of items to loop over
        num_chars1 = context.descriptors[0].itemsize
        num_chars2 = context.descriptors[1].itemsize

        # C code using the above information to compare the strings in
        # both arrays.  In particular, this loop requires the `num_chars1`
        # and `num_chars2`.  Information which is currently not easily
        # available.

np.equal.register_impl(StringEquality)
del StringEquality  # may be deleted.

这种定义足以创建一个新循环,并且其结构允许未来扩展;这在NumPy内部实现类型转换时已经是必需的。我们在此处使用BoundArrayMethod和一个context结构。这些将在后面详细描述和阐述其动机。简言之:

  • context是Python传递给其方法的self的泛化。

  • BoundArrayMethod等同于Python中class.method是方法,而class().method返回“绑定”方法的区别。

定制调度和提升#

当调用np.positive.resolve_impl()时找到正确的实现,在很大程度上是一个实现细节。但是,在某些情况下,当没有实现与请求的DTypes完全匹配时,可能需要影响此过程:

np.multiple.resolve_impl((Timedelta64, Int8, None))

将不会有精确匹配,因为NumPy只有Timedelta64Int64相乘的实现。在简单情况下,NumPy将使用默认的提升步骤尝试找到正确的实现,但要实现上述步骤,我们将允许以下操作:

def promote_timedelta_integer(ufunc, dtypes):
    new_dtypes = (Timdelta64, Int64, dtypes[-1])
    # Resolve again, using Int64:
    return ufunc.resolve_impl(new_dtypes)

np.multiple.register_promoter(
    (Timedelta64, Integer, None), promote_timedelta_integer)

其中Integer是一个抽象DType(对比NEP 42)。

UFunc调用涉及的步骤#

在深入探讨更详细的API选择之前,回顾NumPy中通用函数调用所涉及的步骤会很有帮助。

UFunc调用分为以下步骤:

  1. 处理__array_ufunc__协议

  2. 提升和调度

    • 给定所有输入的DTypes,找到正确的实现。例如,float64int64或用户定义DType的实现。

    • 当不存在精确实现时,必须执行提升。例如,float32float64相加是通过首先将float32转换为float64来实现的。

  3. 参数化dtype解析

    • 通常,只要输出DType是参数化的,就必须找到(解析)其参数。

    • 例如,如果一个循环将两个字符串相加,则需要定义正确的输出(以及可能的输入)dtypes。S5 + S4 -> S9,而upper函数的签名为S5 -> S5

    • 当它们不是参数化时,会提供一个默认实现,该实现填充默认的dtype实例(例如,确保本机字节顺序)。

  4. 准备迭代

    • 此步骤主要由NpyIter(迭代器)内部处理。

    • 分配执行类型转换所需的所有输出和临时缓冲区。这需要步骤3中解析的dtypes。

    • 找到最佳迭代顺序,其中包括有效实现广播的信息。例如,将单个值添加到数组中会重复相同的值。

  5. 设置并获取C级函数

    • 如果需要,分配临时工作空间。

    • 找到C语言实现的轻量级内循环函数。找到内循环函数可以在未来实现专用化。例如,类型转换目前会优化连续转换,而规约则具有目前在内循环函数本身内部处理的优化。

    • 指示内循环是否需要Python API,或者GIL是否可以释放(以允许线程化)。

    • 清除浮点异常标志。

  6. 执行实际计算

    • 运行DType特定的内循环函数。

    • 内循环可能需要访问额外的数据,例如dtypes或上一步中设置的额外数据。

    • 内循环函数可能被调用不确定次数。

  7. 终结

    • 释放步骤5中分配的任何临时工作空间。

    • 检查浮点异常标志。

    • 返回结果。

The ArrayMethod提供了一个概念,用于将步骤3至6以及部分步骤7进行分组。然而,新的ufunc或ArrayMethod的实现者通常不需要自定义NumPy可以且确实提供的步骤4或6中的行为。对于ArrayMethod实现者来说,要自定义的核心步骤是步骤3和步骤5。这些步骤提供了自定义内循环函数和潜在的内循环特定设置。未来扩展中也可能并预期进行进一步的自定义。

步骤2是提升和调度,并将使用新的API进行重构,以便在必要时允许定制该过程。

步骤1为了完整性列出,不受本NEP影响。

以下草图概述了步骤2至6,并强调了dtypes的处理方式以及哪些部分可定制(“已注册”),哪些部分由NumPy处理。

_images/nep43-sketch.svg

ArrayMethod#

本NEP的一个核心提议是创建ArrayMethod,作为一个描述给定DTypes集合的每个特定实现的对象。我们使用class语法来描述创建新ArrayMethod对象所需的信息。

class ArrayMethod:
    name: str  # Name, mainly useful for debugging

    # Casting safety information (almost always "safe", necessary to
    # unify casting and universal functions)
    casting: Casting = "no"

    # More general flags:
    flags: int

    def resolve_descriptors(self,
            Tuple[DTypeMeta], Tuple[DType|None]: given_descrs) -> Casting, Tuple[DType]:
        """Returns the safety of the operation (casting safety) and the
        """
        # A default implementation can be provided for non-parametric
        # output dtypes.
        raise NotImplementedError

    @staticmethod
    def get_loop(Context : context, strides, ...) -> strided_loop_function, flags:
        """Returns the low-level C (strided inner-loop) function which
        performs the actual operation.

        This method may initially private, users will be able to provide
        a set of optimized inner-loop functions instead:

        * `strided_inner_loop`
        * `contiguous_inner_loop`
        * `unaligned_strided_loop`
        * ...
        """
        raise NotImplementedError

    @staticmethod
    def strided_inner_loop(
            Context : context, data, dimensions, strides, innerloop_data):
        """The inner-loop (equivalent to the current ufunc loop)
        which is returned by the default `get_loop()` implementation."""
        raise NotImplementedError

Context主要提供关于函数调用的静态信息。

class Context:
    # The ArrayMethod object itself:
    ArrayMethod : method

    # Information about the caller, e.g. the ufunc, such as `np.add`:
    callable : caller = None
    # The number of input arguments:
    int : nin = 1
    # The number of output arguments:
    int : nout = 1
    # The actual dtypes instances the inner-loop operates on:
    Tuple[DType] : descriptors

    # Any additional information required. In the future, this will
    # generalize or duplicate things currently stored on the ufunc:
    #  - The ufunc signature of generalized ufuncs
    #  - The identity used for reductions

并且flags存储属性,用于指示是否:

  • ArrayMethod支持未对齐的输入和输出数组

  • 内循环函数需要Python API(GIL)

  • NumPy必须检查浮点错误CPU标志。

注意:预计将根据需要添加更多信息。

调用Context#

“上下文”(context)对象类似于Python中传递给所有方法的self。要理解为什么“上下文”对象是必要的及其内部结构,回顾Python方法可以按以下方式编写(另请参阅__get__的文档)会很有帮助:

class BoundMethod:
    def __init__(self, instance, method):
        self.instance = instance
        self.method = method

    def __call__(self, *args, **kwargs):
        return self.method.function(self.instance, *args, **kwargs)


class Method:
    def __init__(self, function):
        self.function = function

    def __get__(self, instance, owner=None):
        assert instance is not None  # unsupported here
        return BoundMethod(instance, self)

以下的method1method2行为相同。

def function(self):
    print(self)

class MyClass:
    def method1(self):
        print(self)

    method2 = Method(function)

两者都将打印相同的结果。

>>> myinstance = MyClass()
>>> myinstance.method1()
<__main__.MyClass object at 0x7eff65436d00>
>>> myinstance.method2()
<__main__.MyClass object at 0x7eff65436d00>

在这里,self.instance将是Context传递的所有信息。Context是一个泛化,必须传递额外的信息。

  • 与操作单个类实例的方法不同,ArrayMethod操作多个输入数组,因此涉及多个dtypes。

  • 上述BoundMethod__call__只包含对一个函数的单个调用。但是ArrayMethod必须调用resolve_descriptors,然后将该信息传递给内循环函数。

  • Python函数除了由其外部作用域定义的状态外,没有其他状态。在C语言中,Context能够在必要时提供额外状态。

正如Python需要区分方法和绑定方法一样,NumPy也将拥有一个BoundArrayMethod。这存储了Context中所有的常量信息,例如:

幸运的是,大多数用户甚至ufunc实现者都不必担心这些内部细节;就像很少有Python用户需要了解__get__双下划线方法一样。Context对象或C结构为快速C函数提供了所有必要数据,并且NumPy API根据需要创建新的ArrayMethodBoundArrayMethod

ArrayMethod规范#

这些规范提供了一个最小的初始C-API,未来将进行扩展,例如允许专门的内循环。

简而言之,NumPy目前依赖于跨步内循环,这最初将是定义ufunc的唯一允许方法。我们预计未来会添加setup函数或公开get_loop

UFuncs需要与类型转换相同的信息,给出以下定义(另请参阅NEP 42中的CastingImpl)。

  • 将传递给解析函数和内循环的新结构:

    typedef struct {
        PyObject *caller;  /* The ufunc object */
        PyArrayMethodObject *method;
    
        int nin, nout;
    
        PyArray_DTypeMeta **dtypes;
        /* Operand descriptors, filled in by resolve_desciptors */
        PyArray_Descr **descriptors;
    
        void *reserved;  // For Potential in threading (Interpreter state)
    } PyArrayMethod_Context
    

    此结构可能会在NumPy的未来版本中添加额外信息,并包含所有常量循环元数据。

    我们可以对这个结构进行版本控制,尽管对ArrayMethod本身进行版本控制可能更简单。

  • 类似于类型转换,ufuncs可能需要找到正确的循环dtype,或者指示某个循环仅能处理所涉及DTypes的特定实例(例如,仅本机字节顺序)。这由resolve_descriptors函数处理(与CastingImplresolve_descriptors相同)。

    NPY_CASTING
    resolve_descriptors(
            PyArrayMethodObject *self,
            PyArray_DTypeMeta *dtypes,
            PyArray_Descr *given_dtypes[nin+nout],
            PyArray_Descr *loop_dtypes[nin+nout]);
    

    该函数根据给定的given_dtypes填充loop_dtypes。这需要填充输出的描述符。通常还需要找到输入描述符,例如,当内循环需要本机字节顺序时确保。

    在大多数情况下,ArrayMethod将具有非参数化输出DTypes,以便可以提供默认实现。

  • 一个额外的void *user_data通常会被类型化以扩展现有的NpyAuxData *结构。

    struct {
        NpyAuxData_FreeFunc *free;
        NpyAuxData_CloneFunc *clone;
        /* To allow for a bit of expansion without breaking the ABI */
       void *reserved[2];
    } NpyAuxData;
    

    该结构目前主要用于NumPy内部的类型转换机制,并且目前必须提供freeclone,尽管这可以放宽。

    与NumPy类型转换不同,绝大多数ufuncs目前不需要这个额外的临时空间,但可能需要简单的标记功能,例如用于实现警告(参见下面的“错误和警告处理”)。为了简化这一点,当user_data未设置时,NumPy将传递一个初始化为零的npy_intp *请注意,也可以将其作为Context的一部分进行传递。

  • 可选的get_loop函数最初不会公开,以避免最终确定需要与类型转换相关的设计选择的API。

    innerloop *
    get_loop(
        PyArrayMethod_Context *context,
        int aligned, int move_references,
        npy_intp *strides,
        PyArray_StridedUnaryOp **out_loop,
        NpyAuxData **innerloop_data,
        NPY_ARRAYMETHOD_FLAGS *flags);
    

    NPY_ARRAYMETHOD_FLAGS可以指示是否需要Python API以及是否必须检查浮点错误。move_references目前在NumPy类型转换中内部使用。

  • 内循环函数

    int inner_loop(PyArrayMethod_Context *context, ..., void *innerloop_data);
    

    将具有与当前内循环相同的签名,并进行以下更改:

    • 返回值为-1而不是0时表示错误。返回-1时,必须设置一个Python错误。

    • 新的第一个参数PyArrayMethod_Context *用于以方便的方式传入关于ufunc或描述符的潜在所需信息。

    • void *innerloop_data将是get_loop设置的NpyAuxData **innerloop_data。如果get_loop未设置innerloop_data,则会传递一个npy_intp *(请参阅下面的“错误处理”以了解其动机)。

    注意:由于get_loop预计是私有的,innerloop_data的具体实现可以在最终公开之前进行修改。

创建新的BoundArrayMethod将使用PyArrayMethod_FromSpec()函数。一个简写方式将允许使用PyUFunc_AddImplementationFromSpec()直接注册到ufunc。该规范预计包含以下内容(未来可能会扩展):

typedef struct {
    const char *name;  /* Generic name, mainly for debugging */
    int nin, nout;
    NPY_CASTING casting;
    NPY_ARRAYMETHOD_FLAGS flags;
    PyArray_DTypeMeta **dtypes;
    PyType_Slot *slots;
} PyArrayMethod_Spec;

讨论与替代方案#

上述将ArrayMethodContext进行拆分以及对BoundArrayMethod的额外要求,是模仿Python中方法和绑定方法实现方式的必要拆分。

这种要求的一个原因是,在许多情况下,它允许存储ArrayMethod对象而不持有对DTypes的引用,这在DTypes动态创建(和删除)时可能很重要。(这是一个复杂的主题,在当前的Python中没有完整的解决方案,但这种方法解决了与类型转换相关的问题。)

似乎没有这种结构的其他替代方案。将DType特定步骤与通用ufunc调度和提升分离是绝对必要的,以允许未来扩展和灵活性。此外,它还允许统一类型转换和ufuncs。

由于ArrayMethodBoundArrayMethod的结构将是不透明且可扩展的,除了选择将它们作为Python对象之外,长期设计影响很小。

resolve_descriptors#

resolve_descriptors方法可能是本NEP的主要创新,它也是NEP 42中实现类型转换的核心。

通过确保每个ArrayMethod都提供resolve_descriptors,我们为“UFunc调用涉及的步骤”中的步骤3定义了一个统一、清晰的API。此步骤是分配输出数组所必需的,并且必须在准备类型转换之前发生。

尽管对于通用函数,返回的类型转换安全性(NPY_CASTING)几乎总是“否”,但包含它有两个主要优点:

  • -1表示发生错误。如果设置了Python错误,则会引发。如果未设置Python错误,这将视为“不可能”的类型转换,并将设置自定义错误。(这种区分对于np.can_cast()函数很重要,它在第一种情况下应引发错误,在第二种情况下应返回False,对于典型的ufuncs则不值得注意。)这一点正在考虑中,我们可能会使用-1表示一般错误,并为不可能的类型转换使用不同的返回值。

  • 返回类型转换安全性是NEP 42中类型转换的核心,并允许在那里不经修改地使用ArrayMethod

  • 未来可能希望实现快速但不安全的实现。例如,int64 + int64 -> int32从类型转换角度来看是不安全的。目前,这将使用int64 + int64 -> int64,然后转换为int32。跳过类型转换的实现必须表明它有效地包含了“同类”类型转换,因此不被视为“否”。

get_loop方法#

目前,NumPy ufunc通常只提供一个跨步循环,因此get_loop方法可能看起来没有必要。出于这个原因,我们计划get_loop最初是一个私有函数。

然而,get_loop在类型转换中是必需的,即使是超出跨步和连续循环的专门循环也会用到它。因此,get_loop函数必须完全替代内部的PyArray_GetDTypeTransferFunction

未来,get_loop可能会被公开,或者暴露出一个新的setup函数以允许更多控制,例如允许分配工作内存。此外,我们可以扩展get_loop,并允许ArrayMethod实现者也能控制外部迭代,而不仅仅是1-D内循环。

扩展内循环签名#

扩展内循环签名是本NEP另一个核心且必要的部分。

传入Context

传入Context可能允许通过向上下文结构添加新字段来未来扩展签名。此外,它还提供了对内循环操作的dtype实例的直接访问。这对于参数化dtypes是必要信息,例如比较两个字符串需要知道两个字符串的长度。Context还可以保存潜在有用的信息,例如原始的ufunc,这在报告错误时会很有帮助。

原则上,传入Context并非必要,因为所有信息都可以包含在innerloop_data中并在get_loop函数中设置。在本NEP中,我们提议传入该结构体以简化参数化DTypes循环的创建。

传入用户数据

当前的类型转换实现使用现有的NpyAuxData *来传递get_loop定义的额外数据。当然有这种结构的其他替代方案,但它提供了一个简单的解决方案,并且已经在NumPy和公共API中使用。

NpyAyxData *是一个轻量级的、已分配的结构体,由于它已经存在于NumPy中用于此目的,因此似乎是一个自然的选择。为了简化某些用例(参见下面的“错误处理”),当未提供innerloop_data时,我们将传递一个npy_intp *innerloop_data = 0

注意:由于get_loop预计最初是私有的,我们可以在将其作为公共API公开之前积累innerloop_data的经验。

返回值

返回值指示错误是NumPy中一个重要但目前缺失的功能。CPU发出浮点错误信号的方式使错误处理进一步复杂化。这两点将在下一节讨论。

错误处理#

我们预期未来的内循环通常会在发现错误时立即设置Python错误。当内循环在不锁定GIL的情况下运行时,这会变得复杂。在这种情况下,函数将不得不锁定GIL,设置Python错误并返回-1以指示发生错误:

int
inner_loop(PyArrayMethod_Context *context, ..., void *innerloop_data)
{
    NPY_ALLOW_C_API_DEF

    for (npy_intp i = 0; i < N; i++) {
        /* calculation */

        if (error_occurred) {
            NPY_ALLOW_C_API;
            PyErr_SetString(PyExc_ValueError,
                "Error occurred inside inner_loop.");
            NPY_DISABLE_C_API
            return -1;
        }
    }
    return 0;
}

浮点错误是特殊的,因为它们需要检查硬件状态,如果在内循环函数本身内部完成,则成本过高。因此,如果ArrayMethod标记,NumPy将处理这些错误。如果ArrayMethod标记不应检查浮点错误标志,则它不应导致这些标志被设置。这可能会在调用多个函数时产生干扰;特别是在需要类型转换时。

另一种解决方案是只允许在NumPy也将检查浮点错误标志的后续终结步骤中设置错误。

我们目前决定不采用这种模式。它看起来更复杂,并且通常不必要。尽管在循环中安全地获取GIL可能需要在未来传入额外的PyThreadStatePyInterpreterState(用于子解释器支持),但这是可接受且可预期的。在稍后设置错误会增加复杂性:例如,如果操作暂停(这目前特别容易发生在类型转换中),每次发生时都需要显式运行错误检查。

我们预期立即设置错误是最简单和最方便的解决方案,而更复杂的解决方案可能是未来的扩展。

处理警告稍微复杂一些:对于每个函数调用(即对于整个数组),警告应该只发出一次,即使天真地看它可能会发出多次。为了简化这种用例,我们默认会传入npy_intp *innerloop_data = 0,它可以用来存储标志(或其他简单的持久数据)。例如,我们可以想象一个整数乘法循环,在发生溢出时发出警告:

int
integer_multiply(PyArrayMethod_Context *context, ..., npy_intp *innerloop_data)
{
    int overflow;
    NPY_ALLOW_C_API_DEF

    for (npy_intp i = 0; i < N; i++) {
        *out = multiply_integers(*in1, *in2, &overflow);

        if (overflow && !*innerloop_data) {
            NPY_ALLOW_C_API;
            if (PyErr_Warn(PyExc_UserWarning,
                    "Integer overflow detected.") < 0) {
                NPY_DISABLE_C_API
                return -1;
            }
            *innerloop_data = 1;
            NPY_DISABLE_C_API
    }
    return 0;
}

TODO: 当未设置innerloop_data时传递一个npy_intp临时空间的想法似乎很方便,但我对此不确定,因为我不知道有任何类似的先例。原则上,这个“临时空间”也可以是context的一部分。

重用现有循环/实现#

对于许多DTypes,上述添加C级循环的定义将是足够的,并且只需要一个跨步循环实现;如果循环与参数化DTypes一起工作,则还必须提供resolve_descriptors函数。

然而,在某些用例中,希望能够回调现有实现。在Python中,这可以通过简单地调用原始ufunc来实现。

为了在C语言中获得更好的性能,以及处理大型数组,期望尽可能直接地重用现有ArrayMethod,以便其内循环函数可以直接使用而无需额外开销。因此,我们将允许从现有ArrayMethod创建一个新的、封装的ArrayMethod

这个封装的ArrayMethod将有两个额外的方法:

  • view_inputs(Tuple[DType]: input_descr) -> Tuple[DType]用与封装循环匹配的描述符替换用户输入描述符。必须能够将输入视为输出。例如,对于Unit[Float64]("m") + Unit[Float32]("km"),这将返回float64 + int32。原始的resolve_descriptors会将其转换为float64 + float64

  • wrap_outputs(Tuple[DType]: input_descr) -> Tuple[DType]用所需的实际循环描述符替换已解析的描述符。原始的resolve_descriptors函数将在这两个调用之间被调用,因此输出描述符可能不会在第一次调用中设置。在上述示例中,它将使用返回的float64(其字节顺序可能已更改),并进一步解析物理单位,从而形成最终签名。

    Unit[Float64]("m") + Unit[Float64]("m") -> Unit[Float64]("m")
    

    ufunc机制将负责将“km”输入转换为“m”。

view_inputs方法允许将正确的输入传递给原始的resolve_descriptors函数,而wrap_outputs确保为输出分配和输入缓冲类型转换使用正确的描述符。

一个重要的用例是抽象的Unit DType,它具有每个数值dtype的子类(可以动态创建)。

Unit[Float64]("m")
# with Unit[Float64] being the concrete DType:
isinstance(Unit[Float64], Unit)  # is True

这样的Unit[Float64]("m")实例在类型提升方面具有明确定义的签名。`Unit` DType的作者可以通过封装现有数学函数并使用上述两个额外方法来实现大多数必要的逻辑。使用提升步骤,这将允许为抽象的Unit DType向ufunc注册一个单独的推广器。然后,该推广器可以在提升时动态添加封装的具体ArrayMethod,并且NumPy可以在第一次调用后缓存(或存储)它。

替代用例

另一个用例是Unit(float64, "m") DType,其中数值类型是DType参数的一部分。这种方法是可行的,但需要一个封装现有循环的自定义ArrayMethod。它也必须始终需要两个调度步骤(一个针对Unit DType,另一个针对数值类型)。

此外,高效实现将需要从另一个ArrayMethod获取并重用内循环函数的能力。(这对于像Numba这样的用户来说可能是必需的,但尚不确定它是否应该成为一个通用模式,并且它无法从Python本身访问。)

提升与调度#

NumPy ufuncs在某种意义上是多方法,因为它们同时操作(或使用)多个DTypes。虽然输入(和输出)dtypes附加到NumPy数组,但ndarray类型本身不携带要将哪个函数应用于数据的信息。

例如,给定输入:

int_arr = np.array([1, 2, 3], dtype=np.int64)
float_arr = np.array([1, 2, 3], dtype=np.float64)
np.add(int_arr, float_arr)

必须找到正确的ArrayMethod来执行操作。理想情况下,会定义一个精确匹配,例如,对于np.add(int_arr, int_arr)ArrayMethod[Int64, Int64, out=Int64]完全匹配并可以使用。然而,对于np.add(int_arr, float_arr),没有直接匹配,需要一个提升步骤。

提升和调度过程#

通常,通过搜索所有输入DTypes的精确匹配来找到ArrayMethod。输出dtypes不应影响计算,但如果多个已注册的ArrayMethods精确匹配,则将使用输出DType来找到更好的匹配。这将允许当前对np.equal循环的区分,该循环同时定义了Object, Object -> Bool(默认)和Object, Object -> Object

最初,ArrayMethod将仅为具体DTypes定义,并且由于它们不能被子类化,因此保证精确匹配。未来我们预计ArrayMethod也可以为抽象DTypes定义。在这种情况下,将按照下文详细说明的方式找到最佳匹配。

提升

默认情况下,任何UFunc都具有一个提升机制,该机制使用所有输入的公共DType并进行第二次调度。这对于大多数数学函数是明确定义的,但如果需要,可以禁用或自定义。例如,int32 + float64会尝试使用float64 + float64(这是公共DType)再次执行。

  • 用户可以注册新的Promoters,就像他们可以注册新的ArrayMethod一样。这些Promoters将使用抽象DTypes来匹配各种签名。提升函数的返回值应该是一个新的ArrayMethodNotImplemented。它必须在多次使用相同输入的调用中保持一致,以允许缓存结果。

  • 提升函数的签名将是:

请注意,DTypes可能包含输出的DType,但是,通常输出DType不会影响选择哪个ArrayMethod

promoter(np.ufunc: ufunc, Tuple[DTypeMeta]: DTypes): -> Union[ArrayMethod, NotImplemented]

在大多数情况下,不需要添加自定义提升函数。需要此功能的例子是与单位的乘法:在NumPy中,timedelta64可以与大多数整数相乘,但NumPy只定义了timedelta64 * int64的循环(ArrayMethod),因此与int32相乘会失败。

为了允许这种情况,可以为(Timedelta64, Integral, None)注册以下推广器:

在这种情况下,就像Timedelta64 * int64int64 * timedelta64ArrayMethod是必需的一样,还需要注册第二个推广器来处理整数首先传入的情况。

def promote(ufunc, DTypes):
    res = list(DTypes)
    try:
        res[1] = np.common_dtype(DTypes[1], Int64)
    except TypeError:
        return NotImplemented

    # Could check that res[1] is actually Int64
    return ufunc.resolve_impl(tuple(res))

ArrayMethod和Promoters的调度规则

Promoter和ArrayMethod是通过查找DType类层次结构定义的最佳匹配来发现的。最佳匹配的定义条件是:

签名与所有输入DTypes匹配,因此issubclass(input_DType, registered_DType)返回真。

  • 没有其他promoter或ArrayMethod在任何输入中更精确:issubclass(other_DType, this_DType)为真(这可能包括两者完全相同的情况)。

  • 该promoter或ArrayMethod在至少一个输入或输出DType中更精确。

  • 如果返回NotImplemented或两个promoter对输入的匹配程度相同,则会出错。当现有promoter对于新功能不够精确时,必须添加新的promoter。为了确保此promoter具有优先权,可能需要定义新的抽象DTypes作为现有DTypes的更精确子类。

上述规则在提供了输出或指定了完整循环的情况下,允许进行专门化。这通常不是必需的,但它允许解析np.logic_or等,这些函数同时具有Object, Object -> BoolObject, Object -> Object循环(默认使用第一个)。

讨论与替代方案#

除了解析并返回新的实现之外,我们还可以返回一组新的DTypes用于调度。这可行,但缺点是无法调度到在不同ufunc上定义的循环,也无法动态创建新的ArrayMethod

驳回的替代方案

在上述方案中,promoter使用多重调度风格的类型解析,而当前的UFunc机制在有序层次结构中使用第一个“安全”循环(另请参阅NEP 40)。

尽管“安全”类型转换规则不够严格,但我们可以设想使用新的“提升”类型转换规则,或通过必要时向上转换输入来使用通用DType逻辑查找最佳匹配循环。

这种方法的一个缺点是仅向上转换可能导致结果向上转换超出用户预期:目前(作为回退仍受支持)任何仅定义float64循环的ufunc,通过向上转换,也适用于float16和float32。

结果为float32。在不改变后续代码结果的情况下,不可能将erf函数更改为返回float16结果。总的来说,我们认为在可以定义精度较低的循环的情况下,不应发生自动向上转换,除非ufunc作者通过提升有意为之。

>>> from scipy.special import erf
>>> erf(np.array([4.], dtype=np.float16))  # float16
array([1.], dtype=float32)

这种考虑意味着向上转换必须通过一些额外的方法来限制。

替代方案1

假设不打算进行通用向上转换,则必须定义一个规则来限制输入从float16 -> float32的向上转换,这可以通过DTypes上的通用逻辑或UFunc本身(或两者结合)来实现。UFunc本身无法轻易做到这一点,因为它无法知道所有可能注册循环的DTypes。考虑以下两个例子:

第一个(应被驳回):

输入:float16 * float16

  • 现有循环:float32 * float32

  • 第二个(应被接受):

输入:timedelta64 * int32

  • 现有循环:timedelta64 * int16

  • 这需要:

timedelta64以某种方式发出信号,表明如果它参与操作,则int64向上转换始终受支持。

  1. float32 * float32循环拒绝向上转换。

  2. 实现第一种方法需要发出信号,表明在特定上下文中向上转换是可接受的。这将需要额外的钩子,对于复杂的DTypes可能不简单。

对于第二种方法,在大多数情况下,一个简单的np.common_dtype规则将在初始调度中发挥作用,然而,即使这样也仅在同质循环中是明确的。此选项将需要为每个循环单独添加一个函数来检查输入是否是有效的向上转换,这似乎有问题。在许多情况下,可以提供一个默认值(同质签名)。

替代方案2

另一种“提升”步骤是:在首先找到正确的输出DType后,确保输出DType与循环匹配。如果输出DTypes已知,则找到一个安全循环变得容易。在大多数情况下,这都有效,正确的输出dtype只是:

或某些固定DType(例如,逻辑函数的Bool)。

np.common_dtype(*input_DTypes)

然而,例如在上述timedelta64 * int32的情况下,它会失败,因为事先无法知道此输出的“预期”结果类型确实是timedelta64np.common_dtype(Datetime64, Int32)会失败)。这需要对timedelta64精度为int64有一些额外了解。由于ufunc可以有任意数量的(相关)输入,因此它至少需要Datetime64(和所有DTypes)上额外的__promoted_dtypes__方法。

掩码DTypes显示了另一个限制。当涉及掩码时,逻辑函数没有布尔结果,因此这将要求原始ufunc作者在此方案中预先考虑掩码DTypes。类似地,一些为复数值定义的函数将返回实数,而另一些则返回复数。如果原始作者没有预料到复数,那么对于后来添加的复数循环,提升可能是不正确的。

我们认为Promoters,尽管允许巨大的理论复杂性,却是最佳解决方案:

提升允许动态添加新循环。例如,可以定义一个抽象的Unit DType,它动态创建类来封装其他现有DTypes。使用单个promoter,这个DType可以动态封装现有的ArrayMethod,使其在一次查找中而不是两次查找中找到正确的循环。

  1. 提升逻辑通常会倾向于安全方面:除非也添加了一个promoter,否则新添加的循环不会被误用。

  2. 它们将仔细思考逻辑是否正确的负担放在了向UFunc添加新循环的程序员身上。(与替代方案2相比)

  3. 如果现有提升不正确,则可以编写一个promoter来限制或完善通用规则。通常,提升规则不应返回不正确的提升,但如果现有提升逻辑失败或对新添加的循环不正确,则循环可以添加新的promoter来完善逻辑。

  4. 让每个循环验证没有发生向上转换的选项可能是最佳替代方案,但它不包括动态添加新循环的能力。

通用推广器的主要缺点是它们可能导致非常大的复杂性。第三方库可能会向NumPy添加不正确的推广,然而,通过添加新的不正确循环,这已经是可能的。总的来说,我们相信我们可以依赖下游项目谨慎负责地使用这种能力和复杂性。

用户指南#

通常,向UFunc添加推广器必须非常谨慎。推广器不应影响其他数据类型可以合理定义的循环。定义一个假设的erf(UnitFloat16)循环绝不能导致erf(float16)。通常,推广器应满足以下要求:

在定义新的提升规则时要保守。不正确的结果比意外错误危险得多。

  • 添加的(抽象)DTypes之一通常应与您的项目定义的DType(或DTypes家族)具体匹配。永远不要添加超出正常通用DType规则的提升规则!如果您编写了一个int24 dtype,那么添加int16 + uint16 -> int24的循环是不合理的。此操作的结果之前已定义为int32,并将在此假设下使用。

  • 推广器(或循环)绝不应影响现有循环的结果。这包括添加更快但精度较低的循环/推广器来替换现有循环。

  • 尝试在所有提升(和类型转换)相关逻辑中保持清晰的线性层次结构。NumPy本身针对整数和浮点数打破了这种逻辑(它们并非严格线性,因为int64不能提升为float32)。

  • 循环和推广器可以由任何项目添加,这可能是:

  • 定义ufunc的项目

    • 定义DType的项目

    • 第三方项目

    • 尝试找出哪个项目最适合添加循环。如果定义ufunc的项目和定义DType的项目都没有添加循环,则可能会出现多重定义(被拒绝)的问题,并且应注意循环行为始终比错误更可取。

    在某些情况下,这些规则的例外可能是有意义的,但是,总的来说,我们要求您务必谨慎行事,如有疑问,请创建新的UFunc。这会明确告知用户规则差异。如有疑问,请在NumPy邮件列表或问题跟踪器上提问!

实现#

本NEP的实施将涉及对当前ufunc机制(以及类型转换)进行大规模重构和重组。

不幸的是,该实现将需要对UFunc机制进行大量维护,因为实际的UFunc循环调用以及初始调度步骤都必须进行修改。

通常,正确的ArrayMethod,包括推广器返回的那些,将被缓存(或存储)在哈希表中以进行高效查找。

讨论#

有许多可能的实现方式,在不同地方进行了大量讨论,以及初步的想法和设计文档。为简洁起见,这些已在NEP 40的讨论中列出,此处不再重复。

涉及其中许多点并指向类似解决方案的长期讨论可以在github问题12518“ufunc内循环签名调用约定应是什么?”中找到。

参考文献#

请参阅NEP 40和41了解更多讨论和参考文献。

版权#