CANN AICPU 算子耗时分析及优化探索
摘要: 本文以 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 算子耗时的简单分析和优化方案探索,分析和优化思路较为粗糙,不当之处,还请华为专家不吝赐教,也希望能有机会和相关专家探讨交流优化方案。
版权声明: 本文为 InfoQ 作者【华为云开发者社区】的原创文章。
原文链接:【http://xie.infoq.cn/article/5c03d94c47119b6b5b1ab1def】。文章转载请联系作者。
评论