摘要:本文以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(Data Level 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编译选项后,编译器不仅进行了主动SIMD优化,还对循环进行了unroll操作,有利于升高循环开销,提供指令级并行,优化指令流水线的调度。
对于float16这一数据类型,通过浏览Eigen库3.3.9版本源码,能够看到当计算设施为CPU时,绝大多数计算(除operator/外)是将其转换为float后再进行计算,最初将计算结果转换为half数据类型。代码片段如下:
图12 Eigen库中half数据类型operator>=函数定义
这种实现形式波及到两次数据类型转换,且因为不是调用ARM原生数据类型,不能SIMD优化,且不利于循环展开,理论计算效率远低于其余原生数据类型。
通过查阅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更新的Eigen 3.4-rc1版本中,Eigen::half以ARM原生反对的__fp16实现,并且改良了所有后端的向量化反对和ARM在矩阵计算方面对NEON指令集的调度。
图14 Eigen更新日志
图15 Eigen3.4.0 Half.h当架构为ARM64时对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. 向量化的局限性
当然,开启向量化是完满的么?当然不是,向量化是有肯定的局限性的。
- 目前存在的SIMD扩大部件的向量寄存器长度都是固定的,如果向量寄存器长度过长而循环迭代次数或基本块内同构语句条数较少,则程序不能被向量化。
- SIMD对数据地址间断与否对执行效率有很大影响,当访存地址不在对齐的边界上时,则须要进行额定的移位和合并操作,能力失去满足要求的向量数据。非对齐访存构造不仅减少了额定的访存操作,而且减少了非凡的操作(例如移位和合并操作等),能力失去满足 SIMD 扩大部件要求的向量数据。因为Tensor的数据逻辑地址是对齐的,对于Element-wise类算子,这个问题并没有产生过大影响。
- 一些程序因为其迭代次数有余,或者基本块内向量并行的语句不够多,不足以为向量寄存器提供足够的并行,须要进行不充沛SIMD向量化。
- 通过在算子实现代码中内嵌手写的汇编代码或编译器提供的内函数来增加SIMD指令,实践上手工向量化可能实现最高水平的向量化,但因为不同处理器提供的SIMD扩大指令集各不相同,会导致代码的可移植性大幅降落,并难以进行持续优化。而主动向量化目前对代码的优化还有肯定局限性。
- 循环展开会造成肯定水平的代码收缩。
- ARM的NEON扩大的浮点数计算并没有齐全实现合乎IEEE 754规范的浮点运算,尤其是非正则化值会被当做0来解决,为保障计算精度,在编译选项不启用-funsafe-math-optimizations选项时,局部不平安浮点计算的NEON代码GCC编译器不会在主动向量化中实现,这也进一步限度了ARM的SIMD性能体现。
8. 总结与优化倡议
总结
- 依照目前canndev源码仓的编译选项,各种数据类型的性能在4K以上数据规模时均和TensorFlow有较大性能差距,且int8和uint8耗时异样,有可能依照16bit进行计算解决。对于Float16的解决canndev和TensorFlow均采纳了Eigen库的half,性能差距在所有数据类型中最小,然而差距比例还是高达1.3x。
- 目前canndev源码仓中的GreaterEqual算子未启用多核,且未对无需播送的状况进行特化解决,因而在无需播送的状况下性能远低于自研算子。而波及非单元素的播送操作时,因为Eigen库的播送性能优于canndev的Bcast,小数据量canndev源码仓中的GreaterEqual算子性能优于自研算子,但随着数据量增大,开启多核后,自研算子性能超过源码仓的算子。
- 自研算子参考源码仓中的Less算子进行设计,两个算子计算逻辑基本相同,但Less算子设计的并行阈值偏低,导致所有数据类型在8K数据规模时呈现一个显著的耗时波峰,后移并行阈值后状况改善。
- 目前canndev主仓的编译选项并未启用主动向量化,开启主动向量化后能被正确向量化的代码性能大幅提高,且在不启用-funsafe-math-optimizations编译选项时,计算精度未呈现显著变动。
- 从汇编指令的角度摸索了算子代码向量化状况,Eigen<3.4版本的half数据类型不是通过ARM原生反对的__fp16进行实现,因而无奈进行向量化优化,Eigen 3.4-rc1以及之后的版本底层通过__fp16实现,能够正确调用SIMD指令,性能大幅晋升。
优化倡议
- 优化Less算子并行阈值,使临界数据量并行减速比尽量靠近于1。
- 开启编译器主动向量化选项-ftree-vectorize,充沛进步CPU在一个时钟周期的计算效率。
- 降级Eigen版本至3.4以及之后的版本,在进行穿插编译时指定对应ARM架构,并且开启fp16反对,如-march=armv8.2+fp16,可实现fp16在ARM平台上的原生反对,由编译器进行SIMD优化和循环展开,无效晋升Eigen::half在ARM架构上的性能体现。
- 优化Bcast的实现逻辑,目前版本依赖算子开发人员进行手动判断是否须要播送操作,并提取三种非凡状况进行手动实现(无需Broadcast、X为一个元素、Y为一个元素),算子实现代码充斥大量冗余代码,应把例如判断是否须要播送的操作进行形象,通过对立接口对元素进行拜访。
- 优化Bcast需播送状况的获取元素索引办法的实现形式,目前仓库中的Bcast性能远低于TensorFlow,落后于Eigen库的broadcast,且目前GetBroadcastXIndex办法的实现对编译器优化不敌对。
9. 结语
本文仅为一位CANN算子开发者对AICPU算子耗时的简略剖析和优化计划摸索,剖析和优化思路较为毛糙,不当之处,还请华为专家不吝赐教,也心愿能有机会和相干专家探讨交换优化计划。
点击关注,第一工夫理解华为云陈腐技术~