摘要: 本文以GreaterEqual作为测试算子,该算子计算逻辑较为简单(output = input1 >=input2),旨在尽可能降低计算耗时,使得算子耗时尽可能以数据操作和算子调度作为主体。
本文分享自华为云社区《CANN AICPU算子耗时分析及优化探索》,作者:DavilSu。
1. 分析目的
在实际开发CANN算子的过程中,常常出现算子功能正常,但性能远低于TensorFlow对标算子的情况。针对这个问题,本文以GreaterEqual作为测试算子,该算子计算逻辑较为简单(output =input1 >= input2),旨在尽可能降低计算耗时,使得算子耗时尽可能以数据操作和算子调度作为主体。
2. 测试代码与平台介绍
本次测试平台为OpenLab提供的Ascend服务器,搭载Ascend910A,CANN Toolkit版本号为5.0.2alpha005。
自研测试代码参考cac625f243dfe7b04dbb2a82059cd0e4349f77d1这一commit进行修改,该commit针对广播操作性能进行了优化。自研设置并行阈值:含广播操作计算为8K,不含广播操作计算为32K。
GreaterEqual的TensorFlow对标算子为TensorFlow1.15版本算子,canndev对标算子commit为d660e086717b94b8cfb3f35a8e08046ca0461772,该版本算子尝试利用Eigen库的broadcast操作规避canndev源码仓Bcast性能不足的问题,但未启用并行计算进行加速。
测试数据我设置了涉及广播操作和不涉及广播操作的两批数据,涉及广播操作的测试数据又分为需广播Tensor的元素个数为1和元素个数不为1两种,测试了int8、int16、int32、int64、uint8、float16、float32、float64共8种TensorFlow对标算子支持的数据类型,每种数据类型分别设置了128B、256B、1K、2K、4K、8K、16K、32K、64K、128K、256K、1M、2M、8M共14个数据规模梯度,详细数据规模与shape对应关系如下:
3. 单线程性能分析
这一部分旨在测试单线程处理数据CANN算子与TensorFlow算子性能差距。为避免广播操作对测试结果产生影响,本次测试数据采用不涉及广播操作的数据批次。
图1 单线程耗时比例
可以看出,对于数据量低于2K的小型数据规模,CANN算子相比于TensorFlow有一定性能优势,但随着数据量的增加,CANN算子性能出现显著性能劣化,尤其是uint8这一数据类型,劣化程度十分严重,性能劣化高达6.57倍。对于非C++标准的float16这一数据类型,二者均采用Eigen库中的half数据类型进行代替,测试结果性能较为接近。
图2 计算1K数据耗时
我还测试了CANN和TF单核计算16K-8M数据量时,计算1K数据所消耗的时间。
可以看出,TensorFlow随着数据类型占用空间的增大,耗时也成比例的相应增加。而奇怪的是,CANN的int8、uint8耗时与int16相近,这一特点同样体现在耗时比例int8和uint8的性能劣化程度远高于其他数据类型,猜测有可能是因为int8和uint8是扩展至16位再进行计算。CANN在float32和float64这两个数据类型的表现也十分奇怪,随着数据量的增加,耗时发生了较大波动。具体情况在向量化代码与性能分析部分尝试进行了分析优化。
4. 自研算子与主仓已实现算子性能对比
Canndev主仓GreaterEqual算子,尝试利用Eigen库的broadcast操作规避canndev源码仓广播性能不足的问题,但未启用并行计算进行加速。自研算子使用canndev仓中的Bcast类进行广播,对是否需要广播的情况进行细化与特殊化,针对不同数据规模设置并行阈值。
本部分分别测试了涉及广播操作和不涉及广播操作的两批数据,旨在测试canndev提供的方法和Eigen提供的broadcast操作性能优劣,及自研算子的性能优势。
图3 不含广播操作耗时比例
图4 含广播操作耗时比例
从结果可以看出,当不开启广播操作时,自研算子性能全面优于已有算子,小数据量时由于直接操作指针,并未同已有算子通过Eigen的broadcast方法检查后再进行处理,性能有一定优势,大数据量由于开启多线程,性能远优于已有算子。
但是开启广播操作后,由于并行阈值设定在8K,小数据量均同为单线程处理数据,可见目前CANN的Bcast性能劣于Eigen实现的broadcast,数据量大于8K后,由于多线程的并行处理优势,自研算子性能远超已有算子。
TensorFlow实现的广播操作相比于Eigen实现的broadcast和CANN实现的Bcast均有较大的性能优势,同为单线程领先Eigen实现的broadcast 8-26倍,领先CANN则更多。
5. 并行阈值对比
由于参考算子为广播优化后的Less算子,我设置了一个对照组,阈值与Less算子的阈值相同(含广播操作计算为2K,不含广播操作计算为7K),以验证其并行阈值是否合理。为避免广播操作对测试结果产生影响,本次测试数据采用不涉及广播操作的数据批次。
测试结果如下:
图5 Less算子阈值和自研算子阈值耗时比例阈值
可见Less算子的并行阈值设置并不合理,在8K数据规模时出现了一个明显的耗时突增,耗时主体为并行通讯耗时而非计算,自研算子相对平缓,该阈值由二分法循环测试得出,临界点并行加速比接近1。
6. 向量化代码与性能分析
在进行单线程性能分析时,我注意到一个很奇怪的现象,int8与int16耗时十分接近(如图2),这引起了我的注意,处理器在处理数据时,耗时会与处理的数据为定点数还是浮点数、数据的位宽、处理数据调用的指令等等因素相关,在处理相同数量的int8与int16数据时,理应int16耗时高于int8。观察TensorFlow算子执行时间,int8和uint8耗时也小于int16耗时。
现代处理器往往支持SIMD(单指令流多数据流),通过将数据打包在一个向量寄存器中,一个运算指令内执行多个数据的计算,从而实现DLP(DataLevel Parallelism),达到加速数据密集型运算的效果。而GreaterEqual算子计算过程不包含分支选择结构,计算逻辑简单重复,适合利用SIMD进行加速。
查阅资料发现Ascend910处理器中的AICPU为16个核心的TaiShan核心,通过系统查询,支持AArch64指令集,其中也包含了NEON指令集。
我尝试在C++实现代码中嵌入汇编代码来实现手动向量化,性能的确大幅提升。虽然理论上手工向量化能够实现最高程度的向量化,但由于不同处理器提供的SIMD 扩展指令集各不相同,不同应用程序特征也复杂多变,SIMD 向量化代码的可读性较差,可移植程度较低,并难以进行继续优化。考虑到未来算子代码可能需要迁移到x86-64、ARM等不同架构的CPU上,最终选择编译器自动生成针对目标处理器SIMD 扩展的向量程序。自动向量化程序员无需关心底层提供的SIMD 扩展部件结构和指令集等问题,只需要把程序中存在的并行性表达清楚,很大程度上解决了高性能代码可移植性低的问题。
查询canndev主仓代码内容,向量化优化相关关键词仅在TFPlugin中出现,检查CmakeLists.txt的编译选项仅进行了O2优化。由于编译AICPU代码的编译器为GCC,通过查阅GCC文档,O2包含的编译选项除包含了O1的优化选项外,还包含了以下选项:
可以看到表3中并未包含向量化优化的编译选项,因此我们通过向CmakeLists.txt中添加-ftree-vectorize(包含-ftree-loop-vectorize和-ftree-slp-vectorize)这一编译选项来开启自动向量化,优化结果如下:
图6 单线程向量化计算1K数据耗时
观察图6结果,可以看到单线程进行向量化优化的代码性能大幅提升。同时我们还可以观察到,相同符号类型的定点数或浮点数的计算耗时随着数据位宽的翻倍而成比例的增加,这也对应着SIMD扩展部件的向量寄存器长度是固定的,NEON的向量寄存器长度为128bit,因此我们设置并行阈值不应该按照元素个数进行设计,而应该按照元素数据总大小来确定。
图7 FP16开辟临时变量与否耗时比例
尝试将Tensor内的half数据转换为float后存入临时开辟的float数组,性能反而劣化,分析原因为逐元素进行数据类型转换后赋值的开销远大于向量化带来的性能提升。
图8 单线程向量化与否耗时比例
图9 多线程向量化与否对比耗时比例
由图9可知,经过向量化后,所有C++原生数据类型的性能均已优于TensorFlow算子。
观察图10,进行向量化优化后,算子性能得到有效提升,但我们可以看到某些数据类型在数据量为128K时性能反而不如未进行优化,这里是因为向量化优化版代码并行阈值是按照数据大小进行设定的,这里可以针对不同数据类型进行更细粒度的并行阈值设定。
图10 向量化与否含广播操作(需广播Tensor的元素个数为1)耗时比例
我还测试了向量化优化后,单元素做广播操作的特殊情况,可以看到由于没有调用广播操作,而是直接对单个元素指针解引用,编译器能正确对这种情况实现向量化优化,因此性能也得到了显著提高。
但遗憾的是,由于需要进行广播操作时,访问Tensor中的元素需要调用Bcast类的GetBroadcastXIndex和GetBroadcastYIndex方法来计算广播操作后的地址偏移量,包含了较为复杂的计算,编译器并不能对其进行向量化优化,而开辟临时空间并赋值的开销远大于向量化带来的性能提升,因此如何优化这个过程还有待研究。
图11 开启-ftree-vectorize前后反汇编代码对比
由图11可知,开启-ftree-vectorize编译选项后,编译器不仅进行了自动SIMD优化,还对循环进行了unroll操作,有利于降低循环开销,提供指令级并行,优化指令流水线的调度。
对于float16这一数据类型,通过阅读Eigen库3.3.9版本源码,可以看到当计算设备为CPU时,绝大多数计算(除operator/外)是将其转换为float后再进行计算,最后将计算结果转换为half数据类型。代码片段如下:
图12 Eigen库中half数据类型operator>=函数定义
这种实现方式涉及到两次数据类型转换,且由于不是调用ARM原生数据类型,不能SIMD优化,且不利于循环展开,实际计算效率远低于其他原生数据类型。
图13 反汇编代码,左为GCC11.1,右为Clang9.0.0
通过查阅ARM架构官方文档,我发现Armv8.2-A中包括了半精度浮点指令,这避免了与单精度浮点之间的转换的需要,因此产生了更高性能的代码。也就说明AICPU完全可以调用数据类型__fp16来实现原生支持半精度浮点数计算。当然,GCC编译器目前对FP16的支持劣于Clang,目前只能优化类似Add这类操作基本和指令集指令相近的算子,对于GreaterEqual算子,GCC<=11.1是转成float再进行比较,而Clang>=9.0.0可以生成对应的半精度浮点数的SIMD指令集代码。
但__fp16是 Arm C语言扩展,在x86-64平台上,对于FP16,只支持原生存储,计算都需要将其转换为float,GCC7.3无法编译,Clang可以进行编译。为保证代码的可移植性,并不建议使用这个数据类型。
有没有高可移植性、高性能的实现方案呢?我在翻阅Eigen更新日志的时候,发现在2021/04/19更新的Eigen3.4-rc1版本中,Eigen::half以ARM原生支持的__fp16实现,并且改进了所有后端的向量化支持和ARM在矩阵计算方面对NEON指令集的调度。
图14 Eigen更新日志
图15 Eigen3.4.0 Half.h当架构为ARM64时对Eigen::half的定义
图16 Add算子反汇编代码(左为__fp16,中为3.4.0版本Eigen::half,右为3.3.9版本Eigen::half)
通过观察图16反汇编代码,可以看出编译器已成功调用fp16的SIMD指令集指令,Eigen::half生成的代码基本和__fp16无异,相较于未调用SIMD指令集、未启用原生fp16的代码更高效,不仅免去了两次类型转换,还提升了一次循环内的计算数据量(SIMD一次计算8个fp16数据,未启用SIMD指令即便是进行了循环展开,只能在一次循环内计算4个数据,且指令量远大于优化版本)。
由于个人对友商源码熟悉程度PyTorch高于TensorFlow,因此对比对象选定为PyTorch,他们对SIMD进行了部分手动优化,例如在目录aten/src/ATen/cpu/vec下,封装了Vectorized类和一系列常用计算函数,一定程度上避免了实现文件中嵌入SIMD函数导致代码可读性降低,同时通过一系列环境宏定义判断目标CPU架构,启用对应架构的SIMD函数,在自动向量化的基础上进一步优化实际向量化表现。
图17 PyTorch aten/src/ATen/cpu/vec/vec256目录下文件
7. 向量化的局限性
当然,开启向量化是完美的么?当然不是,向量化是有一定的局限性的。
1. 目前存在的SIMD扩展部件的向量寄存器长度都是固定的,如果向量寄存器长度过长而循环迭代次数或基本块内同构语句条数较少,则程序不能被向量化。
2. SIMD对数据地址连续与否对执行效率有很大影响,当访存地址不在对齐的边界上时,则需要进行额外的移位和合并操作,才能得到满足要求的向量数据。非对齐访存结构不仅增加了额外的访存操作,而且增加了特殊的操作(例如移位和合并操作等),才能得到满足 SIMD 扩展部件要求的向量数据。由于Tensor的数据逻辑地址是对齐的,对于Element-wise类算子,这个问题并没有产生过大影响。
3. 一些程序由于其迭代次数不足,或者基本块内向量并行的语句不够多,不足以为向量寄存器提供足够的并行,需要进行不充分SIMD向量化。
4. 通过在算子实现代码中内嵌手写的汇编代码或编译器提供的内函数来添加SIMD指令,理论上手工向量化能够实现最高程度的向量化,但由于不同处理器提供的SIMD扩展指令集各不相同,会导致代码的可移植性大幅下降,并难以进行继续优化。而自动向量化目前对代码的优化还有一定局限性。
5. 循环展开会造成一定程度的代码膨胀。
6. ARM的NEON扩展的浮点数计算并没有完全实现符合IEEE 754标准的浮点运算,尤其是非正则化值会被当做0来处理,为保证计算精度,在编译选项不启用-funsafe-math-optimizations选项时,部分不安全浮点计算的NEON代码GCC编译器不会在自动向量化中实现,这也进一步限制了ARM的SIMD性能表现。
8. 总结与优化建议
总结
1. 按照目前canndev源码仓的编译选项,各种数据类型的性能在4K以上数据规模时均和TensorFlow有较大性能差距,且int8和uint8耗时异常,有可能按照16bit进行计算处理。对于Float16的处理canndev和TensorFlow均采用了Eigen库的half,性能差距在所有数据类型中最小,但是差距比例还是高达1.3x。
2. 目前canndev源码仓中的GreaterEqual算子未启用多核,且未对无需广播的情况进行特化处理,因此在无需广播的情况下性能远低于自研算子。而涉及非单元素的广播操作时,由于Eigen库的广播性能优于canndev的Bcast,小数据量canndev源码仓中的GreaterEqual算子性能优于自研算子,但随着数据量增大,开启多核后,自研算子性能超过源码仓的算子。
3. 自研算子参考源码仓中的Less算子进行设计,两个算子计算逻辑基本相同,但Less算子设计的并行阈值偏低,导致所有数据类型在8K数据规模时出现一个明显的耗时波峰,后移并行阈值后情况改善。
4. 目前canndev主仓的编译选项并未启用自动向量化,开启自动向量化后能被正确向量化的代码性能大幅提高,且在不启用-funsafe-math-optimizations编译选项时,计算精度未出现明显变化。
5. 从汇编指令的角度探索了算子代码向量化情况,Eigen<3.4版本的half数据类型不是通过ARM原生支持的__fp16进行实现,因此无法进行向量化优化,Eigen3.4-rc1以及之后的版本底层通过__fp16实现,可以正确调用SIMD指令,性能大幅提升。
优化建议
1. 优化Less算子并行阈值,使临界数据量并行加速比尽量接近于1。
2. 开启编译器自动向量化选项-ftree-vectorize,充分提高CPU在一个时钟周期的计算效率。
3. 升级Eigen版本至3.4以及之后的版本,在进行交叉编译时指定对应ARM架构,并且开启fp16支持,如-march=armv8.2+fp16,可实现fp16在ARM平台上的原生支持,由编译器进行SIMD优化和循环展开,有效提升Eigen::half在ARM架构上的性能表现。
4. 优化Bcast的实现逻辑,目前版本依赖算子开发人员进行手动判断是否需要广播操作,并提取三种特殊情况进行手动实现(无需Broadcast、X为一个元素、Y为一个元素),算子实现代码充斥大量冗余代码,应把例如判断是否需要广播的操作进行抽象,通过统一接口对元素进行访问。
5. 优化Bcast需广播情况的获取元素索引方法的实现方式,目前仓库中的Bcast性能远低于TensorFlow,落后于Eigen库的broadcast,且目前GetBroadcastXIndex方法的实现对编译器优化不友好。
9. 结语
本文仅为一位CANN算子开发者对AICPU算子耗时的简单分析和优化方案探索,分析和优化思路较为粗糙,不当之处,还请华为专家不吝赐教,也希望能有机会和相关专家探讨交流优化方案。