NEP 11 — 推迟 UFunc 评估#

作者

马克·维贝< mwwiebe @ gmail com >

内容类型

文本/x-rst

创建

2010 年 11 月 30 日

地位

延期

抽象的

此 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 而导致的意外情况,例如稍后使用延迟表达式时在令人惊讶的时间出现浮点警告或异常。用户提出诸如“为什么我的 print 语句会抛出被零除错误?”之类的问题。通过推荐这种方法有望避免这种情况。

提议的延迟评估 API #

为了使延迟评估发挥作用,C API 需要知道它的存在,并能够在必要时触发评估。 ndarray 将获得两个新标志。

NPY_ISDEFERRED

指示此 ndarray 实例的表达式计算已被推迟。

NPY_DEFERRED_WASWRITEABLE

只能在 时设置。它表明当第一次在延迟表达式中使用时,它是一个可写数组。如果设置了此标志,调用将再次使其 可写。PyArray_GetDeferredUsageCount(arr) > 0arrPyArray_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 可以引发错误,则后面的 'print' 语句抛出异常而不是导致错误的实际操作。

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

与 UPDATEIFCOPY 交互#

文档NPY_UPDATEIFCOPY指出:

数据区域代表一个(表现良好的)副本,当删除该数组时,其信息应传输回原始副本。

这是一个特殊标志,如果该数组表示一个副本,则设置该标志,因为用户需要 PyArray_FromAny 中的某些标志,并且必须由其他数组制作副本(并且用户要求在这种情况下设置此标志) 。然后,基本属性指向“行为不当”数组(设置为只读)。当设置了此标志的数组被释放时,它会将其内容复制回“行为不当”数组(如有必要,则进行转换)并将“行为不当”数组重置为 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 本身的引用。 “DeferredUsageCount”针对每个操作数递增,然后在计算延迟表达式或销毁延迟数组时递减。

按创建顺序跟踪对所有延迟数组的弱引用的全局列表。当PyArray_CalculateAllDeferred 被调用时,首先计算最新的延迟数组。这可能会释放对延迟表达式树中包含的其他延迟数组的引用,这样就永远不需要计算这些数组了。

进一步优化#

每个 UFunc 可以给出一组它生成的可能错误,而不是在任何错误未设置为“忽略”时保守地禁用延迟评估。然后,如果所有这些错误都设置为“忽略”,则即使其他错误未设置为忽略,也可以使用延迟评估。

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

虽然我将延迟评估仅适用于 UFunc,但它可以扩展到其他函数,例如 dot()。例如,链式矩阵乘法可以重新排序以最小化中间体的大小,或者窥孔式优化器遍可以搜索与优化的 BLAS/其他高性能库调用相匹配的模式。

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