关于测试:CANN-AICPU算子耗时分析及优化探索

270次阅读

共计 7225 个字符,预计需要花费 19 分钟才能阅读完成。

摘要: 本文以 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. 向量化的局限性

当然,开启向量化是完满的么?当然不是,向量化是有肯定的局限性的。

  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 进行实现,因而无奈进行向量化优化,Eigen 3.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 算子耗时的简略剖析和优化计划摸索,剖析和优化思路较为毛糙,不当之处,还请华为专家不吝赐教,也心愿能有机会和相干专家探讨交换优化计划。

点击关注,第一工夫理解华为云陈腐技术~

正文完
 0