NBEP 1: 整数类型推断的改变

作者

Antoine Pitrou

日期

2015年7月

状态

最终

当前语义

Numba 中整数的类型推断目前存在一些微妙之处和一些极端情况。简单的情况是,某个变量具有明确的 Numba 类型(例如,因为它是 Numpy 标量类型(如 np.int64)的构造函数调用的结果)。这种情况没有歧义。

不那么简单的情况是,变量不带有此类显式信息。这可能是因为它是从内置的 Python int 值推断出来的,或者是从两个整数之间的算术运算推断出来的,或者其他情况。然后 Numba 有许多规则来推断结果的 Numba 类型,特别是它的有符号性和位宽。

目前,通用情况可以概括为:从小开始,按需增长。具体而言:

  1. 每个常量或伪常量都使用能正确表示它的最小有符号整数类型进行推断(或者,对于 2**632**64 - 1 之间的正整数,可能是 uint64)。

  2. 运算结果的类型被推断为可确保在溢出和其他大小增加(例如,int32 + int32 将被推断为 int64)时安全表示。

  3. 作为一个例外,用作函数参数的 Python int 总是被推断为 intp,一个指针大小的整数。这是为了避免编译特化(specializations)的泛滥,因为否则输入参数中各种整数位宽可能会产生多个签名。

注意

上述第二条规则(“尊重大小增加”规则)重现了 Numpy 在标量值算术运算上的行为。然而,Numba 在实现和性能约束方面与 Numpy 标量不同。

顺便值得一提的是,Numpy 数组并未实现该规则(即 array(int32) + array(int32) 的类型是 array(int32),而不是 array(int64))。这可能是为了使性能更可控。

这有几个不明显的副作用

  1. 在经过几次操作后,很难预测函数内部值的精确类型。表达式树中的基本操作数可能是 int8,但最终结果可能是 int64。这是否可取是一个悬而未决的问题;它对正确性有利,但对性能可能不利。

  2. 在试图遵循正确性优先于可预测性规则时,一些值实际上可能会脱离整数范围。例如,int64 + uint64 的类型被推断为 float64,以避免大小损失(但附带地会在大整数值上损失精度…),这再次遵循了 Numpy 标量的语义。这通常不是用户所期望的。

  3. 更复杂的场景可能会在类型统一阶段产生意外错误。一个例子是 Github issue 1299,其要点在此重现:

    @jit(nopython=True)
    def f():
        variable = 0
        for i in range(1):
            variable = variable + 1
        return np.arange(variable)
    

    截至本文撰写时,这在 64 位系统上编译失败,出现错误:

    numba.errors.TypingError: Failed at nopython (nopython frontend)
    Can't unify types of variable '$48.4': $48.4 := {array(int32, 1d, C), array(int64, 1d, C)}
    

    熟悉 Numba 类型统一系统的专家可以理解原因。但用户却感到困惑。

提议:可预测的宽度保留类型推断

我们提议彻底改变当前的类型推断哲学。与其“从小开始,按需增长”,我们提议“从大开始,保持宽度不变”。

具体而言:

  1. 用作函数参数的 Python int 值的类型推断不变,因为它令人满意且不会让用户感到意外。

  2. 整数常量(和伪常量)的类型推断将更改为与整数参数的类型推断匹配。也就是说,每个未显式指定类型的整数常量都将推断为 intp,即指针大小的整数;除了在 32 位系统上需要 int64uint64 的少数情况。

  3. 整数运算会将位宽提升到 intp(如果当前位宽较小),否则不会提升。例如,在 32 位机器上,int8 + int8 的类型是 int32int32 + int32 也是。然而,int64 + int64 的类型仍为 int64

  4. 此外,有符号和无符号之间的混合运算将回退到有符号类型,同时遵循相同的位宽规则。例如,在 32 位机器上,int8 + uint16 的类型是 int32uint32 + int32 也是。

提案影响

语义

有了这个提案,语义变得更加清晰。无论函数的参数和常量是否显式类型化,函数中任何一点的各种表达式结果都具有易于预测的类型。

当使用内置的 Python int 时,用户将获得可接受的大小(32 或 64 位,取决于系统的位数),并且在所有计算中类型保持不变。

当显式使用较小的位宽时,中间结果不会因大小损失而受影响,因为它们的位宽会提升到 intp

如上所示,与类型统一系统相关的烦恼也可能减少。用户必须强制使用几种不同的类型才能遇到此类错误。

一个潜在的担忧是与 Numpy 标量语义的差异;但同时这也使 Numba 标量语义更接近数组语义(包括 Numba 和 Numpy 的),这似乎也是一个理想的结果。

值得指出的是,某些整数来源,例如内置的 range(),总是产生 32 位或更大的整数。此提案可能是一个机会,可以将其标准化为 intp

性能

除了在微不足道的情况下,整数常量的当前“最适合”行为似乎不太可能带来性能优势。毕竟,Numba 代码中的大多数整数要么存储在数组中(具有用户选择的已知类型),要么用作索引,其中 int8 的性能极不可能优于 intp(实际上,如果 LLVM 无法优化掉所需的符号扩展,性能可能会更差)。

另外,默认使用 intp 而不是 int64 确保了 32 位系统不会出现糟糕的算术性能。

实现

乐观地说,这个提案可能会简化 Numba 的一些内部结构。或者,至少,它不会使它们变得明显更复杂。

限制

此提案并未真正解决有符号整数和无符号整数的组合问题。它主要旨在解决位宽问题,这是用户常见的一个痛点。在 Numba 编译的代码中,无符号整数实际上非常罕见,除非明确要求,因此痛点要小得多。

在位宽方面,32 位系统仍然可能根据常量的不同值显示出差异:如果常量太大而无法容纳在 32 位中,则其类型将为 int64,这会传播到其他计算中。这将是当前行为的延续,但会更罕见且更受控。

长期展望

虽然我们相信此提案使 Numba 的行为更规范和可预测,但它也使其进一步偏离了与纯 Python 语义的通用兼容性,在纯 Python 语义中,用户可以假定任意精度整数而不会出现任何截断问题。