NEP 11 — 推迟 UFunc 评估#

作者:

Mark Wiebe <mwwiebe@gmail.com>

Content-Type:

text/x-rst

Created:

2010 年 11 月 30 日

Status:

推迟

摘要#

此 NEP 描述了向 NumPy 的 UFunc 添加推迟评估的建议。这将允许像“a[:] = b + c + d + e”这样的 Python 表达式一次性地对所有变量进行评估,而无需临时数组。由此产生的性能可能与 `numexpr` 库相当,但具有更自然的语法。

这个想法与 UFunc 错误处理和 UPDATEIFCOPY 标志有一些交互作用,影响设计和实现,但结果允许在 Python 用户的角度来看,以最小的努力使用推迟评估。

动机#

NumPy 的 UFunc 执行方式会导致大型表达式的性能不佳,因为会分配多个临时变量,并且多次遍历输入。`numexpr` 库可以针对此类大型表达式优于 NumPy,因为它会在小缓存友好块中执行,并按元素评估整个表达式。这导致对每个输入进行一次扫描,这对于缓存来说要好得多。

为了了解如何在不更改 Python 代码的情况下在 NumPy 中获得此类行为,请考虑 C++ 中的表达式模板技术。这些技术可用于使用向量或其他数据结构任意地重新排列表达式,例如

A = B + C + D;

可以转换为等效于

for(i = 0; i < A.size; ++i) {
    A[i] = B[i] + C[i] + D[i];
}

这是通过返回一个代理对象来完成的,该对象知道如何计算结果,而不是返回实际对象。使用现代 C++ 优化编译器,生成的机器代码通常与手工编写的循环相同。有关此示例,请参阅 Blitz++ 库。用于帮助编写表达式模板的更近期的库是 Boost Proto

通过在 Python 中使用相同的返回代理对象的想法,我们可以动态地实现相同的功能。返回对象是一个没有分配缓冲区的 ndarray,并且具有计算自身所需的信息。当最终评估“推迟数组”时,我们可以使用由所有操作数推迟数组组成的表达式树,从而有效地创建单个新的 UFunc 在飞行中进行评估。

示例 Python 代码#

以下是它在 NumPy 中的用法示例。

# a, b, c are large ndarrays

with np.deferredstate(True):

    d = a + b + c
    # Now d is a 'deferred array,' a, b, and c are marked READONLY
    # similar to the existing UPDATEIFCOPY mechanism.

    print d
    # Since the value of d was required, it is evaluated so d becomes
    # a regular ndarray and gets printed.

    d[:] = a*b*c
    # Here, the automatically combined "ufunc" that computes
    # a*b*c effectively gets an out= parameter, so no temporary
    # arrays are needed whatsoever.

    e = a+b+c*d
    # Now e is a 'deferred array,' a, b, c, and d are marked READONLY

    d[:] = a
    # d was marked readonly, but the assignment could see that
    # this was due to it being a deferred expression operand.
    # This triggered the deferred evaluation so it could assign
    # the value of a to d.

尽管如此,可能会有某些令人惊讶的行为。

with np.deferredstate(True):

    d = a + b + c
    # d is deferred

    e[:] = d
    f[:] = d
    g[:] = d
    # d is still deferred, and its deferred expression
    # was evaluated three times, once for each assignment.
    # This could be detected, with d being converted to
    # a regular ndarray the second time it is evaluated.

我相信在文档中建议的用法是将推迟状态保留为默认值,除非评估大型表达式可以从中受益。

# calculations

with np.deferredstate(True):
    x = <big expression>

# more calculations

这将避免由于始终保持推迟使用 True 而导致的意外情况,例如在使用推迟表达式时在意外时间出现的浮点数警告或异常。希望通过推荐这种方法来避免诸如“为什么我的打印语句会引发除以零错误?”之类的问题。

建议的推迟评估 API#

为了使推迟评估起作用,C API 需要意识到它的存在,并且能够在需要时触发评估。ndarray 将获得两个新的标志。

NPY_ISDEFERRED

指示此 ndarray 实例的表达式评估已推迟。

NPY_DEFERRED_WASWRITEABLE

仅在 `PyArray_GetDeferredUsageCount(arr) > 0` 时设置。它指示当 `arr` 首次在推迟表达式中使用时,它是一个可写数组。如果设置了此标志,则调用 `PyArray_CalculateAllDeferred()` 将使 `arr` 再次可写。

注意

问题

NPY_DEFERRED 和 NPY_DEFERRED_WASWRITEABLE 应否对 Python 可见,或者从 Python 访问标志是否应触发必要的 PyArray_CalculateAllDeferred?

API 将扩展为包含许多函数。

int PyArray_CalculateAllDeferred()

此函数强制所有当前推迟的计算发生。

例如,如果错误状态设置为忽略所有内容,并且 `np.seterr({all=’raise’})`,这将更改对已推迟表达式的处理方式。因此,在更改错误状态之前,应评估所有现有推迟数组。

int PyArray_CalculateDeferred(PyArrayObject* arr)

如果 'arr' 是推迟数组,则为其分配内存并评估推迟表达式。如果 'arr' 不是推迟数组,则简单地返回成功。返回 NPY_SUCCESS 或 NPY_FAILURE。

int PyArray_CalculateDeferredAssignment(PyArrayObject* arr, PyArrayObject* out)

如果 'arr' 是推迟数组,则将推迟表达式评估到 'out' 中,并且 'arr' 仍然是推迟数组。如果 'arr' 不是推迟数组,则将其值复制到 out。返回 NPY_SUCCESS 或 NPY_FAILURE。

int PyArray_GetDeferredUsageCount(PyArrayObject* arr)

返回此数组作为操作数使用的推迟表达式的数量。

Python API 将按如下方式扩展。

numpy.setdeferred(state)

启用或禁用推迟评估。True 表示始终使用推迟评估。False 表示永远不使用推迟评估。None 表示仅当错误处理状态设置为忽略所有内容时才使用推迟评估。在 NumPy 初始化时,推迟状态为 None。

返回以前的推迟状态。

numpy.getdeferred()

返回当前的推迟状态。

numpy.deferredstate(state)

用于处理推迟状态的上下文管理器,类似于 `numpy.errstate`。

错误处理#

错误处理是推迟评估的一个棘手问题。如果 NumPy 错误状态为 {all=’ignore’},则将推迟评估作为默认值引入可能是合理的,但是如果 UFunc 可以引发错误,则稍后的“打印”语句引发异常而不是实际导致错误的操作将是非常奇怪的。

一种好的方法可能是默认情况下仅当错误状态设置为忽略所有内容时才启用推迟评估,但允许使用“setdeferred”和“getdeferred”函数进行用户控制。True 表示始终使用推迟评估,False 表示永远不使用它,而 None 表示仅在安全时使用它(即错误状态设置为忽略所有内容)。

与 UPDATEIFCOPY 的交互#

`NPY_UPDATEIFCOPY` 文档指出

数据区域表示一个(良好行为的)副本,其信息应在删除此数组时传输回原始数组。

这是一个特殊标志,如果此数组表示由于用户在 `PyArray_FromAny` 中需要某些标志而创建的副本,并且必须对某些其他数组进行副本(并且用户要求在这种情况中设置此标志),则会设置此标志。base 属性然后指向“行为不端”的数组(设置为只读)。当删除设置了此标志的数组时,它将将其内容复制回“行为不端”的数组(如有必要则进行转换),并将“行为不端”的数组重置为 NPY_WRITEABLE。如果“行为不端”的数组最初不是 NPY_WRITEABLE,那么 `PyArray_FromAny` 将返回一个错误,因为 NPY_UPDATEIFCOPY 将不可能。

当前 UPDATEIFCOPY 的实现假设它是在以这种方式修改可写标志的唯一机制。这些机制必须相互了解才能正常工作。以下是如何出错的一个示例:

  1. 使用 UPDATEIFCOPY 创建 'arr' 的临时副本('arr' 变成只读)

  2. 在推迟表达式中使用 'arr'(推迟使用计数变为 1,未设置 NPY_DEFERRED_WASWRITEABLE,因为 'arr' 是只读的)

  3. 销毁临时副本,导致 'arr' 变成可写

  4. 写入 'arr' 会破坏推迟表达式的值

为了解决这个问题,我们使这两个状态互斥。

  • UPDATEIFCOPY 的使用会检查 `NPY_DEFERRED_WASWRITEABLE` 标志,如果它已设置,则调用 `PyArray_CalculateAllDeferred` 来在继续之前刷新所有推迟计算。

  • ndarray 获得一个新的标志 `NPY_UPDATEIFCOPY_TARGET`,指示数组将在将来某个时候更新并变为可写。如果推迟评估机制在任何操作数中看到此标志,它将触发立即评估。

其他实现细节#

创建推迟数组时,它会获取 UFunc 所有操作数的引用以及 UFunc 本身。每个操作数的“推迟使用计数”都会增加,并在计算推迟表达式或销毁推迟数组时减少。

跟踪所有推迟数组的全局弱引用列表,按创建顺序排列。当调用 `PyArray_CalculateAllDeferred` 时,首先计算最新的推迟数组。这可能会释放对包含在推迟表达式树中的其他推迟数组的引用,这些引用则永远不必计算。

进一步优化#

与其在任何错误未设置为“忽略”时保守地禁用推迟评估,不如让每个 UFunc 提供它生成的可能错误集。然后,如果所有这些错误都设置为“忽略”,即使其他错误未设置为“忽略”,也可以使用推迟评估。

一旦显式存储表达式树,就可以对其进行转换。例如,`add(add(a,b),c)` 可以转换为 `add3(a,b,c)`,或者 `add(multiply(a,b),c)` 可以使用可用的 CPU 融合乘加指令转换为 `fma(a,b,c)`。

虽然我将推迟评估框架为仅适用于 UFunc,但它可以扩展到其他函数,例如 `dot()`。例如,可以重新排序链接矩阵乘法以最大限度地减少中间结果的大小,或者窥孔式优化器传递可以搜索匹配优化 BLAS/其他高性能库调用的模式。

对于真正大型数组的操作,将 JIT(如 LLVM)集成到此系统中可能会有很大好处。UFunc 和其他操作将提供位码,这些位码可以一起内联并由 LLVM 优化器优化,然后执行。事实上,迭代器本身也可以用位码表示,允许 LLVM 在进行优化时考虑整个迭代。