作者:Arm 工程部产品管理总监 Paul Black
Arm KleidiAI 是一款具有突破性意义的软件库,专为提升 Arm CPU 上的人工智能 (AI) 性能而设计。在此前发布的《Arm KleidiAI 助力 AI 框架性能提升》一文中,对 KleidiAI 进行了简要概述,并附有相关指南链接,其中详细说明了在 Linux 环境中运行 KleidiAI 矩阵乘法 (matmul) 微内核的分步操作,这份指南内容详实且极易上手。而本篇内容则将探索如何在裸机环境中运行 KleidiAI 内核,并通过测试多款 C/C 编译器,以确定如何能更高效地生成代码。
本文将介绍如何在裸机环境中运行 KleidiAI 微内核,并针对不同编译器在不同优化级别下的表现进行基础基准测试。文中会用到 Arm Development Studio 的相关组件,包括固定虚拟平台 (FVP),以及 Arm Compiler for Embedded (AC6) 的授权许可。与此同时,还提供了有关如何查看编译器已采用(或未采用)的优化的相关信息。
设置裸机项目
本文将评估的三个编译器分别是:
Arm Compiler for Embedded,更为人熟知名称的是 AC6
Arm GNU 工具链,即 GCC
新一代 Arm 嵌入式编译器 Arm Toolchain for Embedded (ATfE)。撰写本文时,该工具链还处于 Beta 测试阶段
为了在裸机项目中运行 KleidiAI 内核,可参考 Kleidi 指南中的说明。本文以 Arm Development Studio 中的 C 示例项目为基础进行开发:startup_Armv8-Ax1_AC6_CPP是 AC6 版本,startup_Armv8-Ax1_GCC_CPP是 GCC 版本,而 ATfE 的移植版本则包含在 ATfE 测试版下载包中。这三个编译器对应的项目功能相同,但需要对 Makefile 和链接脚本进行必要的修改。
各工具链的修复和更改
在粘贴 Kleidi 指南中提供的代码后,需对这三个项目进行以下简单修改以确保正常运行:
包含 float.h 头文件以定义 FLT_MAX
添加 KleidiAI 头文件的 include 路径
将架构更改为armv8.2-a dotprod i8mm
要运行此代码,需要一个具备 i8mm 扩展的 Arm 核心。此扩展在 Armv8.2-A 至 Armv8.5-A 架构中为可选功能,而在后续支持高级 SIMD 指令的核心中则为必选,因此 Arm Neoverse V1 是个不错的选择。Arm Development Studio 提供了 Neoverse V1 固定虚拟平台 (FVP),此处所选用的是-C cluster0.NUM_CORES=1 -C bp.secure_memory=false -C cache_state_modelled=0
启动代码中存在一段用于设置 SMPEN 的读-改-写序列,但这在 Neoverse V1 FVP 上会引发问题。由于是复用 Arm Cortex-A 的启动代码来适配 Neoverse 核心,因此需要进行一些修改,而在本场景中,移除该序列即可解决问题。理想情况下,应根据 Neoverse 核心的要求重新审阅启动代码,但就本次研究而言,确保代码正常运行便已足够。
添加了一些代码,用于向矩阵中填充随机数据。这一步可能并非必需,因为内存中原本就已填充了重复的非零模式。
此外,还需要对各个项目单独做一些修改。示例项目主要是实现处理器的启动,并未考虑在启动后运行较为复杂的负载任务:
在 ATfE 项目中,RAM 大小被设为 0x80000,这个容量过小,会导致堆与栈发生冲突。不过此问题很容易解决,因为即便是 FVP 的默认配置,其提供的 RAM 也远大于该数值。因此,我们可以在链接脚本中设置更大的 RAM 大小。
在 GCC 项目中,.init_array 段被分配到 0x80100000 地址,该地址过低,会与 .eh_frame 段产生冲突。移除这一地址设置即可解决问题。
至此就能成功在裸机环境中使用三款不同的工具链运行 KleidiAI 内核。接下来便可开展性能测试。
基准测试方法和结果
本次研究中使用了 FVP 的周期计数器来作为性能衡量指标。虽然它并非完美,但对于本次研究而言已经足够。由于三款编译器运行的是相同的工作负载,因此即便存在测量误差,其误差程度和分布位置也会保持一致。所以,作为一种性能参考指标,FVP 的周期计数完全能满足本次研究的需求。接着,分别在 -O0、-O1、-O2 和 -O3 这四个优化级别下,对三款编译器的周期计数进行了测量,以启动处理器核心、设置矩阵以及执行 KleidiAI 内核:
这里有两个值得关注的现象。首先,大部分优化效果在 -O1 级别就已显现。在 -O2 和 -O3 级别下虽有小幅提升(其中 GCC 的提升相对更明显),但提升幅度远不及 -O1 级别。这并不令人惊讶,因为 KleidiAI 内核本身已通过大量手工编写的汇编指令进行了优化,而在 Kleidi 内核外添加的代码既简短又简单。本文后续会深入分析所使用的优化手段。
其次,ATfE 的表现似乎明显快于 AC6 和 GCC。新一代 Arm 嵌入式编译器能在与 AC6 的对比中展现出如此优势,固然令人欣喜,但这一性能差距也促使我进行更深入的探究。
AC6 和 ATfE 的汇编器、编译器及 C 库组件均基于 LLVM 构建,两款工具链的主要差异体现在链接器和 C 库上(AC6 采用专有版本,ATfE 则使用开源版本)。因此,两者之间约 20% 的性能差距让我颇为好奇。我需要确保所有性能数据和基准测试结果都能适用于实际项目,所以必须进一步厘清 ATfE 的速度提升究竟源于何处。
深入分析
在这一部分对性能测试进行了简化,但同时也提升了复杂度。通过只关注 -O1 优化等级,以此简化了测试,因为大部分优化效果都体现在这一级别。与此同时,通过将代码分为三个部分来提高分析的粒度:
启动:所有启动代码,直至进入 main ()
准备:为矩阵分配内存,向矩阵填充随机数据
执行:运行 Kleidi 内核
周期计数如下:
从 KleidiAI 内核的执行耗时来看,三款编译器的表现十分接近,ATfE 略领先于 AC6(约 1%),而 GCC 则稍显落后。在 -O2 和 -O3 级别下重新运行了该测试,如前文所述,随着优化级别的提高,GCC 在 -O3 级别时小幅反超,这正是高级别优化带来的提升效果之一。
在准备阶段,ATfE 与 AC6 的表现依然接近,GCC 则仍然落后。同样在 -O2 和 -O3 级别下重新测试后发现,在这些优化级别下,GCC 缩小了部分差距。这似乎表明,不同编译器会在不同优化级别中纳入特定的优化过程。
然而,ATfE 之所以能实现整体耗时的大幅缩短,关键提速点其实在启动阶段。我猜测,这可能是因为 ATfE 所使用的 Picolibc 在 C 库设置环节,比 AC6 采用的 ArmCLib 或 GCC 采用的 newlib 更轻量化。由于 ATfE 的主要提速点在于此,而测试项目本身的代码量较少,这就导致初始的性能对比结果存在偏差:如果增大工作负载,启动代码在整体运行时间中的占比就不会如此之高了。
分析编译器优化
若要了解 ATfE 采用(或未采用)哪些优化过程,可借助编译器选项-Rpass(或-Rpass-missed)。这两个选项后可接=.*(表示所有优化过程)或= (特定优化过程)。例如,使用-Rpass=inline可查看哪些函数调用已被内联,而使用-Rpass-missed=inline则能了解哪些调用未被内联。-Rpass-missed 选项对于 开发者而言颇具价值,它能揭示 C/C 代码可如何调整,从而让编译器更易于优化。
快速查看了 ATfE 在 -O0、-O1、-O2 和 -O3 下级别下的优化过程,其结果如下:
即便在 -O0 级别,编译器仍会对部分 Arm C 语言扩展 (ACLE) 内联函数进行内联处理,例如 vaddq_s16(向量加法)。这一点是合理的,因为这类调用仅对应单条指令,因此在性能(得益于消除函数调用开销)与代码体积增加(因代码复制导致)之间不存在权衡问题。
在 -O1 级别,编译器进行了大量的函数内联,尤其是对小型函数(如随机数生成器实现)。此外,若循环中某些指令或表达式无需在每次迭代时重新计算,编译器会将它们提升 (hoist) 到循环外部。
在 -O2 级别,编译器开始进行循环向量化,但部分向量化操作会推迟到 -O3 级别。编译器采用启发式算法来权衡每项优化的收益与成本。如同内联优化,在同一优化级别下,不同循环可能会采用不同的向量化策略,这一点值得关注。
在 - O3 级别,编译器还会对部分循环进行展开。
提升 (hoisting) 机制值得深入探究。以 KleidiAI 源文件中一段大幅简化的代码为例:
for (size_t dst_row_idx = 0; dst_row_idx < dst_num_rows; dst_row_idx) {
for (size_t dst_byte_idx = 0; dst_byte_idx < dst_num_bytes_per_row; dst_byte_idx) {
const size_t block_idx = dst_byte_idx / block_length_in_bytes;
const size_t nr_idx = block_idx % nr;
const size_t n0_idx = dst_row_idx * nr nr_idx;
编译器注意到,在计算 n0_idx 时,其中的乘法部分无需放在内层循环中,因为在内层循环中,dst_row_idx 和 nr 均为常量:
src/kai_rhs_pack_nxk_qsi4cxp_qs4cxs1s0.c47: remark: hoisting mul [-Rpass=licm]
96 | const size_t n0_idx = dst_row_idx * nr nr_idx;
| ^
编译器会将该乘法操作从内层循环提升 (hoist) 到外层循环,大致如下:
for (size_t dst_row_idx = 0; dst_row_idx < dst_num_rows; dst_row_idx) {
const size_t hoist_temp = dst_row_idx * nr;
for (size_t dst_byte_idx = 0; dst_byte_idx < dst_num_bytes_per_row; dst_byte_idx) {
const size_t block_idx = dst_byte_idx / block_length_in_bytes;
const size_t nr_idx = block_idx % nr;
const size_t n0_idx = hoist_temp nr_idx;
开发者也可手动进行此类优化,但这可能会使代码变得不够简洁、清晰,难以理解和维护。编译器会考虑这些因素,从而让开发者能够专注于代码功能、清晰度和可维护性。
ATfE 的 -Rpass 选项输出包含大量信息,既涉及已应用的优化过程,也涉及未应用的过程。这些信息对于开发者而言非常有帮助,能让开发者了解编译器如何优化代码,并指导开发者对代码进行调整,以更好地配合编译器优化。这是一个庞大的主题,我将在后续博客中深入探讨。
结论
Arm Development Studio 提供了一套适用于裸机环境下 KleidiAI 内核实验的工具,包括便于快速上手的示例项目、用于测试的 FVP,以及 AC6 的授权(之后还将包含 ATfEP 的授权)。与所有软件开发工作一样,在评估编译器性能等指标时,需要考虑采集所有相关数据。在本案例中,很容易轻易得出“用 ATfE 构建的项目比用 AC6 构建的项目快约 20%”的结论。ATfE 会基于每项潜在优化的成本与收益做出启发式优化决策,并提供实用选项来查看已采用和未采用的优化。通过这些选项获取的信息,可用于调整代码,使编译器能够实现更多优化。
关注
134文章
9382浏览量
379224免责声明:本文为转载,非本网原创内容,不代表本网观点。其原创性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
如有疑问请发送邮件至:bangqikeconnect@gmail.com