NEP 38 — 使用SIMD优化指令提升性能#
- 作者:
Sayed Adel, Matti Picus, Ralf Gommers
- 状态:
最终
- 类型:
标准
- 创建日期:
2019-11-25
- 决议:
摘要#
尽管编译器在利用硬件特定例程优化代码方面越来越好,但有时它们并不能产生最佳结果。此外,我们希望能够在具有相同基础架构(x86、ARM或PowerPC)但功能不同的机器之间复制二进制优化过的C扩展模块,而无需重新编译。
我们在ufunc机制中有一个机制,可以根据CPU功能名称索引来构建替代循环。在导入时(在InitOperators
中),会从候选函数中选择与运行时CPU信息匹配的循环函数。本NEP提出了一个在此基础上构建更多功能和架构的机制。提议的步骤是:
建立一套定义明确、与架构无关的通用内部函数,以捕获跨架构可用的功能。
将这些通用内部函数封装在一组C宏中,并使用这些宏为从基线到该架构上可用最大功能集的功能集合构建代码路径。将这些作为有限数量的已编译替代代码路径提供。
在运行时,发现可用的CPU功能,并相应地从可能的代码路径中进行选择。
动机和范围#
传统上,NumPy依赖编译器为目标架构生成最佳代码。然而,如今很少有用户在其机器上本地编译NumPy。大多数用户使用二进制包,这些包必须为最低通用CPU架构提供运行时支持。因此,NumPy无法利用其CPU处理器更高级的功能,因为这些功能可能并非在所有用户系统上都可用。
传统上,CPU功能通过内部函数(intrinsics)暴露,这些内部函数是编译器特定的指令,直接映射到汇编指令。最近,关于添加更多内部函数(例如,用于浮点数AVX优化的gh-11113)的有效性进行了讨论。过去,为了在各种ufunc中实现快速avx512例程,NumPy中添加了架构特定代码,并使用上述机制选择最佳循环。然而,该代码不通用,也无法推广到其他架构。
最近,OpenCV转向在硬件抽象层(HAL)中使用通用内部函数,这为常见的共享单指令多数据(SIMD)结构提供了很好的抽象。本NEP为NumPy提出了一个类似的机制。使用该机制分为三个阶段:
代码中提供了抽象内部函数的基础设施。ufunc机制将通过使用这些抽象内部函数集进行扩展,使得单个ufunc将表示为一组循环,从最小可用内部函数集到最大可能可用内部函数集。
在编译时,编译器宏和CPU检测用于将抽象内部函数转换为具体的内部函数调用。平台上不可用的任何内部函数,无论是由于CPU不支持它们(因此无法测试)还是因为抽象内部函数在该平台上没有对应的具体内部函数,都不会报错;相反,将不会生成相应的循环并将其添加到可能性集合中。
在运行时,CPU检测代码将进一步限制可用循环集,并为ufunc选择最优循环。
当前的NEP仅提议将运行时功能检测和最优循环选择机制用于ufunc。未来的NEP可能会提出该解决方案的其他用途。
ufunc机制已经具备在运行时为特定可用CPU功能选择最佳循环的能力,目前用于avx2
、fma
和avx512f
循环(在生成的__umath_generated.c
文件中);通用内部函数将扩展生成的代码以包含更多循环变体。
用法和影响#
最终用户将能够获取其平台和编译器可用的内部函数列表。此外,用户可以选择在运行时使用哪个可用循环,也许通过环境变量来启用对不同循环影响的基准测试。对于不熟悉技术的最终用户而言,应该没有直接影响,所有循环的结果在少量(1-3?)ULP之内应保持一致。另一方面,拥有更强大机器的用户应该会注意到显著的性能提升。
二进制版本 - PyPI上的wheels和conda包#
通过此过程发布的二进制文件将更大,因为它们包含该架构所有可能的循环。一些打包者可能更倾向于限制循环的数量以限制二进制文件的大小,我们希望他们仍然能支持广泛的架构系列。请注意,英特尔MKL产品中已存在此问题,其二进制包包含大量用于各种CPU替代方案的替代共享对象(DLL)。
源码构建#
请参阅下面的“详细描述”。理论上,如果打包者了解目标机器的详细信息,通过命令行参数选择仅编译目标所需的循环,源码构建可以生成更小的二进制文件。
如何运行基准测试以评估性能优势#
添加更多使用内部函数的代码将增加代码维护难度。因此,只有在能带来显著性能提升的情况下才应添加此类代码。评估这种性能提升可能并非易事。为了提供帮助,本NEP的实现将添加一种通过环境变量在运行时选择使用哪些指令集的方法。(名称待定)。此能力对于CI代码验证至关重要。
诊断#
Python中将提供一个新的字典__cpu_features__
。键是可用的功能,值是表示该功能是否可用的布尔值。内部将使用各种新的私有C函数来查询可用功能。这些功能可能会通过特定的C扩展模块暴露出来,用于测试。
添加新的CPU架构特定优化的工作流程#
对于任何可能是SIMD向量化候选的代码,NumPy将始终有一个基线C实现。如果贡献者希望为某个架构(通常是他们最感兴趣的架构)添加SIMD支持,此注释是关于如何操作的教程的开头:numpy/numpy#13516
截至目前,NumPy对许多ufuncs具有多个avx512f
、avx2
和fma
SIMD循环。这些很可能是首批移植到通用内部函数的候选。预期新的实现可能会导致基准测试中的性能下降,但不会增加二进制文件的大小。如果性能下降不是最小的,我们可能会选择为该平台保留X86特定代码,并为其他平台使用通用内部函数代码。
任何使用内部函数实现ufuncs的新PR都将被期望使用通用内部函数。如果可以证明使用通用内部函数过于笨拙或性能不足,也可以接受平台特定代码。在极少数情况下,只针对单一平台的PR可能会被接受,但这必须在优先考虑使用通用内部函数解决方案的框架内进行审查。
接受新循环的主观标准是:
正确性:新代码的精度下降不得超过1-3个ULP,即使在算法的边缘点也是如此。
代码膨胀:包括源代码大小,尤其是编译后的wheel的二进制文件大小。
可维护性:代码的可读性如何
性能:基准测试必须显示出显著的性能提升
添加新的内部函数#
如果贡献者想使用一个尚未被支持为通用内部函数的平台特定SIMD指令,那么
它应该作为所有平台的通用内部函数添加
如果它在其他平台上没有等效指令(例如
AVX512
中的_mm512_mask_i32gather_ps
),则不应添加通用内部函数,而应编写一个平台特定的ufunc
或一个简短的辅助函数。如果使用此类辅助函数,它必须用功能宏包装,并提供一个合理的非内部函数回退作为默认使用。
我们预计(2)将是例外情况。贡献者和维护者应考虑与使用最佳可用通用内部函数实现相比,该单平台内部函数是否值得。
其他项目复用#
如果通用内部函数能被SciPy或Astropy等也构建ufunc的库使用,那将是很好的,但这并非本NEP首次实现的明确目标。
向后兼容性#
不应影响向后兼容性。
详细描述#
CPU特定指令映射到通用内部函数,这些通用内部函数在所有x86 SIMD变体、ARM SIMD变体等之间都是相似的。例如,NumPy通用内部函数npyv_load_u32
映射到:
基于ARM NEON的
vld1q_u32
基于x86 AVX2的
_mm256_loadu_si256
基于x86 AVX-512的
_mm512_loadu_si512
任何编写SIMD循环的人都将使用npyv_load_u32
宏,而不是架构特定的内部函数。代码还提供了用于编译和运行时的守卫宏,以便可以选择适当的循环。
runtests.py
和setup.py
有两个新的构建选项:--cpu-baseline
和--cpu-dispatch
。编译所需的绝对最低功能由--cpu-baseline
定义。例如,在x86_64
上,这默认为SSE3
。如果编译器支持,则会启用最低功能。可以通过--cpu-dispatch
设置可检测并用作调度要求集附加内部函数。例如,在x86_64
上,这默认为[SSSE3, SSE41, POPCNT, SSE42, AVX, F16C, XOP, FMA4, FMA3, AVX2, AVX512F, AVX512CD, AVX512_KNL, AVX512_KNM, AVX512_SKX, AVX512_CLX, AVX512_CNL, AVX512_ICL]
。所有这些功能都映射到C级别的布尔数组npy__cpu_have
,C级别的便捷函数npy_cpu_have(int feature_id)
查询此数组,结果在运行时存储在__cpu_features__
中。
导入ufunc时,可用的已编译循环所需功能与发现的功能进行匹配。最匹配的循环被标记为由ufunc调用。
实现#
当前PR
编译时和运行时代码基础设施由第一个PR提供。第二个PR增加了基础设施在循环中使用的演示。一旦NEP获得批准,还需要更多工作来使用NEP提供的机制编写循环。
替代方案#
gh-13516中提出的一个替代方案是为每个CPU架构手动单独实现循环,而不是试图抽象SIMD内部函数中的常见模式(例如,有loops.avx512.c.src、loops.avx2.c.src、loops.sse.c.src、loops.vsx.c.src、loops.neon.c.src等)。这更类似于PIXMAX的做法。然而,这里存在大量重复,并且手动代码重复需要一位致力于实现和维护该平台循环代码的倡导者。
讨论#
大部分讨论发生在接受本NEP的PR gh-15228上。邮件列表上的讨论提到了VOLK,该内容已添加到相关工作部分。可维护性问题也在邮件列表和gh-15228中提出,并解决如下:
参考文献和脚注#
版权#
本文档已置于公共领域。[1]