多态分派
使用 jit()
或 vectorize()
编译的函数是开放式的:它们可以接受许多不同类型的输入,并且必须选择(可能在运行时编译)正确的底层特化。我们在此解释这个机制是如何实现的。
要求
JIT 编译的函数可以接受多个参数,并且在选择特化时会考虑每个参数。因此,这是一种多重分派的形式,比单一分派更为复杂。
每个参数在选择中根据其 Numba 类型 进行权衡。Numba 类型通常比 Python 类型更细粒度:例如,Numba 根据数组的维度和布局(C-连续等)对 Numpy 数组进行不同处理。
一旦为每个参数推断出 Numba 类型,就必须在可用的类型中选择一个特化;或者,如果没有找到合适的特化,则必须编译一个新的特化。这不是一个简单的决定:对于给定的具体签名,可能存在多个兼容的特化(例如,假设一个双参数函数已经为 (float64, float64)
和 (complex64, complex64)
编译了特化,并且它被调用时使用了 (float32, float32)
)。
因此,调度机制中有两个关键步骤:
推断具体参数的 Numba 类型
为推断出的 Numba 类型选择最佳可用的专业化(或选择编译一个新的)
编译时 vs. 运行时
本文档讨论了在运行时进行调度的情况,即当一个JIT编译的函数从纯Python代码中调用时。在这种情况下,性能是重要的。为了保持在Python中正常函数调用的开销范围内,调度的开销应保持在微秒以下。当然,越快越好…
当一个JIT编译的函数从另一个JIT编译的函数(在 nopython模式 下)调用时,多态性在编译时解决,使用一个不影响性能的机制,不会产生任何运行时性能开销。
备注
在实践中,这里描述的性能关键部分是用C语言编写的。
类型解析
因此,第一步是在调用时推断出函数每个具体参数的 Numba 类型。由于 Numba 类型的粒度比 Python 类型更细,因此不能简单地查找对象的类并用它作为字典的键来获取相应的 Numba 类型。
相反,存在一种机制来检查对象,并基于其 Python 类型查询各种属性,以推断适当的 Numba 类型。这可以是或多或少复杂的:例如,Python int
参数将始终推断为 Numba intp``(指针大小的整数),但 Python ``tuple
参数可以推断为多种 Numba 类型(取决于元组的大小及其每个元素的具体类型)。
Numba 的类型系统是高级的,并且是用纯 Python 编写的;有一个基于泛型函数的纯 Python 机制,用于进行上述推断(在 numba.typing.typeof
中)。该机制用于编译时的推断,例如常量。不幸的是,它对于基于运行时值的分派来说太慢了。它仅用作很少使用(或难以推断)类型的后备,并且表现出多个微秒的开销。
类型代码
Numba 类型系统实际上过于高级,无法从 C 代码中高效地进行操作。因此,C 调度层使用另一种基于整数类型代码的表示。每个 Numba 类型在构造时都会获得一个唯一的整数类型代码;此外,一个内部系统确保不会创建两个相同类型的实例。因此,调度层能够通过使用简单的整数类型代码来避免 Numba 类型系统的开销,这些代码适用于众所周知的优化(快速哈希表等)。
类型解析步骤的目标变为:为函数的每个具体参数推断一个 Numba 类型代码。理想情况下,它不再处理 Numba 类型…
硬编码的快速路径
虽然避免了类型系统的抽象和面向对象的开销,但整数类型代码仍然具有相同的概念复杂性。因此,加速推理的一个重要技术是首先对最重要的类型进行检查,并为每种类型硬编码一个快速解析。
几种类型从这种优化中受益,特别是:
基本的 Python 标量 (
bool
,int
,float
,complex
);基本的 Numpy 标量(各种整数、浮点数、复数类型);
特定维度和基本元素类型的 Numpy 数组。
每条快速路径理想情况下在经过几个简单检查后,使用硬编码的结果值或直接查表。
然而,我们不能将这种技术应用于所有参数类型;这将导致临时内部缓存的爆炸性增长,并且维护起来会变得困难。此外,硬编码的快速路径的递归应用不一定能组合成低开销(例如,在嵌套元组的情况下)。
基于指纹的类型代码缓存
对于非平凡类型(例如元组或 Numpy 的 datetime64
数组),硬编码的快速路径不匹配。然后,另一种更通用的机制开始起作用。
这里的基本原则是检查每个参数值,就像纯 Python 机制那样,并明确描述其 Numba 类型。不同的是,我们实际上并不计算一个 Numba 类型。相反,我们计算一个简单的字节串,这是该 Numba 类型的低级可能表示:一个 指纹。指纹格式设计得简短且非常容易从 C 代码中计算(实际上,它具有类似字节码的格式)。
一旦指纹被计算出来,它会在一个将指纹映射到类型代码的缓存中进行查找。该缓存是一个哈希表,由于指纹通常非常短(很少超过20字节),查找速度非常快。
如果缓存查找失败,必须首先使用缓慢的纯 Python 机制来计算类型代码。幸运的是,这种情况只会发生一次:在后续调用中,将为给定的指纹返回缓存的类型代码。
在极少数情况下,无法高效计算指纹。对于某些类型来说,这是无法从C中轻易检查的情况:例如 cffi
函数指针。在这种情况下,每次调用带有此类参数的函数时,都会调用缓慢的纯Python机制。
备注
两个指纹可能表示一个 Numba 类型。这并不会使机制出错;它只是创建了更多的缓存条目。
摘要
函数参数的类型解析涉及以下机制,按顺序进行:
尝试一些硬编码的快速路径,针对常见的简单类型。
如果上述操作失败,计算参数的指纹并在缓存中查找其类型代码。
如果上述所有方法都失败了,调用纯Python机制,它将为参数确定一个Numba类型(并查找其类型代码)。
专业化选择
在上一步中,已经为JIT编译函数的每个具体参数确定了整数类型代码。现在需要将该具体签名与函数的每个可用特化进行匹配。可能会有三种结果:
存在一个令人满意的最佳匹配:然后调用相应的特化(它将处理参数解包和其他细节)。
有两个或更多“最佳匹配”:会引发异常,拒绝解决歧义。
没有令人满意的匹配:会为推断出的具体参数类型编译一个新的特化版本。
选择过程通过遍历所有可用的特化,并计算每个具体参数类型与特化预期签名中相应类型的兼容性来实现。具体来说,我们感兴趣的是:
具体参数类型是否允许隐式转换为特化参数类型;
如果是这样,转换会带来什么样的语义(用户可见)成本。
隐式转换规则
从源类型到目标类型有五种可能的隐式转换(注意这是一种非对称关系):
精确匹配:这两种类型完全相同;这是理想的情况,因为特化将完全按照预期行为进行;
同类型提升: 这两种类型属于同一种“类型”(例如
int32
和int64
是两种整数类型),并且源类型可以无损地转换为目标类型(例如从int32
到int64
,但不能反向转换);安全转换:这两种类型属于不同的种类,但源类型可以合理地转换为目标类型(例如从
int32
到float64
,但不能反过来);不安全的转换: 从源类型到目标类型的转换是可用的,但它可能会失去精度、大小或其他理想的品质。
无转换: 在这两种类型之间没有正确或合理有效的方法进行转换(例如在
int64
和datetime64
之间,或在一个 C 连续数组和一个 Fortran 连续数组之间)。
当检查一个特化时,后两种情况会将其从最终选择中排除:即当至少有一个参数*没有转换*或仅有一个*不安全的转换*到签名的参数类型时。
备注
然而,如果函数在 jit()
调用中使用显式签名编译(因此不允许编译新的特化),则允许 不安全的转换。
候选者和最佳匹配
如果一个特化没有被上述规则消除,它将进入 候选者 列表,以供最终选择。这些候选者根据一个有序的4元整数组进行排序:``(不安全转换的数量, 安全转换的数量, 同类型提升的数量, 精确匹配的数量)``(注意元组元素的总和等于参数的数量)。最佳匹配是按升序排序后的第1个结果,从而优先选择精确匹配,其次是提升,再次是安全转换,最后是不安全转换。
实现
上述机制适用于整数类型代码,而不是Numba类型。它使用一个内部哈希表来存储每对兼容类型的可能转换种类。内部哈希表部分在启动时构建(对于内置的简单类型,如``int32``、``int64``等),部分动态填充(对于任意复杂的类型,如数组类型:例如,允许在函数期望非连续二维数组的地方使用C连续的二维数组)。
摘要
选择合适的专业涉及以下步骤:
检查每个可用的特化,并将其与具体的参数类型匹配。
消除任何至少有一个参数不提供足够兼容性的特化。
如果有剩余的候选者,根据保留类型语义的标准选择最佳的一个。
杂项
一些 调度性能的基准测试 存在于 Numba基准测试 仓库中。
机器的某些特定方面的单元测试可以在 numba.tests.test_typeinfer
和 numba.tests.test_typeof
中找到。更高级别的调度测试在 numba.tests.test_dispatcher
中。