ARM 处理器代码优化技术

本文梳理 ARM Cortex-A 系列代码优化技术,按三个层级递进展开:编译器选项、内存子系统优化、源码级微调。

1. 引言

优化不只有”更快”——在嵌入式系统上,省电和减少内存占用同样重要。手册第 18 章开头即说明了优化目标的多样性:更少的执行周期意味着处理器可以更早进入休眠,从而延长电池寿命。这几者常常是相互促进的:代码跑得更快 → 更少活跃时间 → 更长休眠时间 → 更省电。

在 Cortex-A 处理器上,你几乎不能手动预测一段代码的执行时间。你不能再像 ARM7TDMI 时代那样打开 TRM 查到每条指令的 cycle count 然后逐条累加——流水线中的指令运动依赖于周围指令的进度,并且会受到内存系统活动的显著影响。一条待定的 Load 或指令预取在 Cache 中未命中可能会使代码停顿数十个周期。一条 LDR 的实际延迟取决于它是否命中 L1 Cache——命中 1-3 个周期,未命中 20-100 个周期。这个差异远超任何指令级优化的收益。

因此,手册中给出的所有优化技术自然形成了三层递进优先级:

1
2
3
Tier 1: 编译器优化 —— 开一个 flag 即可,零代码改动
Tier 2: 内存系统优化 —— 决定 80% 性能,关键在数据 locality
Tier 3: 源码级微调 —— 细粒度改造 C 代码,榨出最后几个周期

2. 编译器优化

编译器是最容易利用的优化工具——不需要修改一行源代码,只改变命令行 flags 就能影响代码生成质量。

2.1 函数内联

内联的收益本质是用代码膨胀换取三个具体收益:消除函数调用开销(BL、返回地址保存、寄存器压栈、返回时的分支跳转);更重要地,扩大编译器能看见的代码窗口,内联后编译器可以在更大的上下文中做值域传播和死代码消除——这往往是远超调用开销本身的收益;副作用是过度内联增大 Code Size,降低 I-Cache 利用率,可能反而变慢。

实例:内联一个小函数的汇编级对比

1
2
3
4
5
6
7
// 原始代码 —— tiny_func 没有被内联
int tiny_func(int x) { return x * 2 + 1; }
int caller() {
int a = 10;
int b = tiny_func(a);
return b + a;
}
1
2
3
4
5
6
7
8
9
10
11
12
// 未内联时 caller() 的汇编 (ARM): 需要 BL + 参数准备 + 返回值使用
caller:
MOV R0, #10 ; a = 10
BL tiny_func ; 分支跳转,LR保存返回地址
ADD R0, R0, #10 ; b + a (tiny_func 的返回值在 R0)
BX LR

tiny_func:
ADD R0, R0, R0 ; R0 = x + x = x * 2
ADD R0, R0, #1 ; R0 = x * 2 + 1
BX LR
// 总计: 5 条指令,2 次分支 (BL + BX LR)
1
2
3
4
5
6
7
8
// 编译器内联后 (GCC -O2):消除 BL 和 BX LR
caller:
MOV R0, #10 ; a = 10 → R0
ADD R0, R0, R0 ; tiny_func 内联第1条: x * 2
ADD R0, R0, #1 ; tiny_func 内联第2条: x * 2 + 1
ADD R0, R0, #10 ; b + a
BX LR
// 总计: 4 条指令,0 次函数调用分支

GCC 默认只在单个编译单元内做内联。跨文件内联需要 inline 关键字配合 static / extern

2.2 公共子表达式消除

编译器在 -O1 及以上默认执行此优化,前提是相关变量不是 volatile

1
2
3
4
5
6
7
8
// 原始代码
i = a * b + c;
j = a * b * d;

// 编译器优化后等价于
tmp = a * b;
i = tmp + c;
j = tmp * d;

这个优化同时降低了指令数和执行周期数,属于零代价优化。

2.3 循环展开

循环展开用代码膨胀消除每次迭代的分支和比较开销。一个简单的例子:

1
2
3
4
// 原始:每次迭代都要 CMP + Bcc
for (i = 0; i < 10; i++) {
x[i] = i;
}
1
2
3
// 展开后:无分支,10 条连续赋值
x[0] = 0; x[1] = 1; x[2] = 2; x[3] = 3; x[4] = 4;
x[5] = 5; x[6] = 6; x[7] = 7; x[8] = 8; x[9] = 9;

每次循环迭代可以省去一次比较和一次条件跳转,从而减少循环控制开销。

不过,循环展开并非总能提升性能。展开后代码体积会增大,可能降低 I-Cache 命中率;另一方面,现代 Cortex-A 处理器具有较高准确率的分支预测机制,对于这种规律性的循环跳转通常能够正确预测,因此循环分支本身的开销已经很低。对于迭代次数较少或循环体较小的场景,循环展开带来的收益可能不足以抵消代码膨胀带来的影响。

因此,循环展开并不是 Cortex-A 上的通用优化手段。建议结合 Profiling 工具分析热点代码,再决定是否启用 -funroll-loops 或手动展开循环。

2.4 GCC 优化级别全景

优化级别 主要特点 调试可见性
-O0 不进行优化,尽量保持源码与汇编的一一对应关系,编译速度最快。 ★★★★★
-O1 启用基础优化,如常量传播、公共子表达式消除(CSE)、死代码消除等,在编译时间和运行效率之间取得平衡。 ★★★★☆
-O2 -O1 基础上启用更多优化,如指令调度、循环优化、寄存器分配优化等,是大多数项目的默认发布优化级别。 ★★★☆☆
-O3 -O2 基础上进一步启用更激进的优化,例如更积极的函数内联、循环展开以及自动向量化(-ftree-vectorize),以追求最高运行性能。 ★★☆☆☆
-Os 以减小代码体积为目标,在保持大部分 -O2 优化的同时,避免增加代码尺寸的优化。 ★★★☆☆
-Og 面向调试的优化级别,在保留较好调试体验的同时提供一定优化效果。 ★★★★★
-funroll-loops 独立于 -O 的循环展开选项,可与其他优化级别组合使用。

说明: 实际启用哪些优化选项会随 GCC 版本变化,可通过 gcc -Q --help=optimizers 查看当前版本各优化级别对应的具体选项。

推荐使用方式

  • 开发调试:推荐使用 **-Og -g**,既能保持较好的调试体验,又能发现部分优化相关问题。
  • 性能测试或发布版本:通常使用 **-O2**,它在性能、代码体积和编译时间之间取得了较好的平衡,也是大多数项目的默认选择。
  • 极致性能优化:当 Profiling 证明热点代码仍有优化空间时,再考虑使用 **-O3**,并评估其带来的代码体积增长是否值得。
  • Flash 或 ROM 空间受限:优先考虑 **-Os**。

2.5 平台指定参数

参数 作用 示例
-march=armv7-a 指定架构版本,决定可用指令集 启用对应架构支持的指令(如 Thumb-2、SDIV/UDIV、Barrier 指令等)
-mcpu=cortex-a9 为特定处理器调度指令 指定目标 CPU,同时选择对应架构并针对该 CPU 的流水线、Cache 和执行单元进行优化
-mtune=cortex-a8 为特定处理器优化但不改变指令集 可配合 -march=armv5te 在旧指令集上做新处理器调度
-mfpu=neon 启用 NEON SIMD 与 VFP 浮点指令 Cortex-A5 示例
-mfloat-abi=hard 使用硬件 FP 寄存器传浮点参数 softfp 减少参数拷贝开销

向编译器提供尽可能详细的目标平台信息,是获得最优代码的关键。如果目标平台支持 ARMv7-A ,指定 -march=armv7-a 后,编译器可以使用 ARMv7-A 支持的更多指令,例如硬件除法(SDIV/UDIV)、Barrier 指令等;同时也可以生成 Thumb-2 指令,以在性能和代码体积之间取得更好的平衡。

至此,你已经让编译器尽可能生成高效的指令。但如果 Profiling 仍然显示热点集中在某个函数上,不必急于手写汇编,更应该先检查这段代码的内存访问模式。编译器优化解决的是”如何更快地执行指令”,而内存优化解决的是”如何更快地拿到数据”。对于 Cortex-A 而言,一次 Cache Miss 带来的等待时间,往往远高于执行几条算术或逻辑指令的开销,因此数据访问模式通常比单纯优化指令更值得关注。


3. 内存系统优化

在大多数 Cortex-A 系列处理器中,Cache 命中与未命中的访问延迟相差很大。L1 Cache 命中通常只需几个处理器周期,而一次 Cache Miss 若需要访问更低层 Cache 甚至主存,则可能需要数十甚至上百个周期。因此,对大多数算法而言,尽可能减少 Cache Miss 往往是最重要的性能优化目标,其中 L1 Cache 的优化收益通常最为明显。

3.1 数据局部性:空间与时间

数据 Cache 优化的核心原则是数据局部性(Data Locality):尽量让已经加载到 Cache 中的数据被重复利用,减少对更低层存储器的访问。

  • 空间局部性(Spatial Locality):一个 Cache Line 通常为 32 或 64 字节。访问地址 0x1000 时,处理器会将包含该地址的整条 Cache Line 一并加载到 Cache 中,因此紧接着访问 0x10040x1008 等位于同一 Cache Line 内的数据,通常都能直接命中 Cache,几乎无需额外的访存开销。
  • 时间局部性(Temporal Locality):刚访问过的数据,如果在较短时间内再次被访问,大概率仍然保留在 Cache 中,因此能够快速命中,而无需重新从更低层存储器读取。

绝大多数 Cache 优化技巧,本质上都是围绕这两个原则展开:按顺序访问数据,提高空间局部性;尽快重复使用数据,提高时间局部性。

3.2 Loop Tiling:用分块提升 Cache 利用率

Loop Tiling(又称 Loop Blocking)的核心思想是:将大规模循环拆分为多个能够放入 Cache 的小块,使数据在被替换之前得到充分复用,从而减少 Cache Miss。

矩阵乘法是最经典的例子:

1
2
3
4
5
// 原始代码
for (i = 0; i < 1024; i++)
for (j = 0; j < 1024; j++)
for (k = 0; k < 1024; k++)
result[i][j] += a[i][k] * b[k][j];

性能瓶颈主要出现在 b[k][j] 的访问模式。

由于 C 语言二维数组采用行优先(Row-major)存储,固定 j、递增 k 时,每次访问都会跳到下一行:

1
2
3
4
b[0][j]
b[1][j]
b[2][j]
...

假设矩阵每行有 1024 个 int,每个 int 占 4 字节,则相邻两次访问的地址相差:

1
1024 × 4 = 4096 Bytes

这远大于一个 Cache Line(通常为 32 或 64 字节),因此几乎每次访问都会落到新的 Cache Line。虽然每次都会把整条 Cache Line 加载到 Cache 中,但其中只有一个元素被立即使用,其余数据很可能在再次访问前就已经被替换掉,导致 Cache 利用率极低。

Loop Tiling 的做法是把矩阵划分为多个较小的子块,使参与一次计算的数据能够尽可能驻留在 Cache 中:

1
2
3
4
5
6
7
8
9
10
11
12
// Loop Tiling(8×8×8 分块)
for (io = 0; io < 1024; io += 8)
for (jo = 0; jo < 1024; jo += 8)
for (ko = 0; ko < 1024; ko += 8)
for (ii = 0, rresult = &result[io][jo], ra = &a[io][ko];
ii < 8;
ii++, rresult += 1024, ra += 1024)
for (ki = 0, rb = &b[ko][jo];
ki < 8;
ki++, rb += 1024)
for (ji = 0; ji < 8; ji++)
rresult[ji] += ra[ki] * rb[ji];

采用分块后,每次只处理一个 8×8×8 的子问题。这样,同一个子块中的 abresult 数据能够在 L1 Cache 中被反复访问,而不是频繁重新加载,大幅提高了 Cache 命中率。

说明: 分块大小并不是固定为 8。实际选择通常需要根据 L1 Cache 容量、Cache Line 大小、数据类型以及算法特点综合确定,8×8×8 只是一个便于说明原理的示例。

ARM 优化手册还建议交换 kiji 的循环顺序。原因是 ra[ki] 在一次内层循环中只使用一次,而 rb[ji]rresult[ji] 会在整个内层循环中反复访问。将引用频率更高的数据放在最内层循环,可以减少地址计算和寄存器更新,提高流水线利用率,并为编译器优化创造更好的条件。

3.3 Loop Interchange:换一下循环顺序

Loop Interchange(循环交换)是指交换嵌套循环的执行顺序,使内层循环尽可能按连续地址访问数据,从而提高 Cache 命中率。

例如:

1
2
3
4
// 按列访问(Cache 不友好)
for (int j = 0; j < N; j++)
for (int i = 0; i < M; i++)
sum += a[i][j];

交换循环顺序后:

1
2
3
4
// 按行访问(Cache 更友好)
for (int i = 0; i < M; i++)
for (int j = 0; j < N; j++)
sum += a[i][j];

由于 C 语言采用行优先(Row-major)存储,第二种写法按连续地址访问数组,更容易命中 Cache。因此,Loop Interchange 的原则不是让迭代次数最多的循环放在最内层,而是让内层循环尽可能连续访问内存。

3.4 结构体布局优化

结构体布局优化

数据在内存中的布局会直接影响 Cache 利用率。优化的目标是:让一次 Cache Line 加载尽可能包含更多真正会被访问的数据,同时避免额外的访存开销。

例如,一个 64 字节结构体中只有一个字段是热点数据:

1
2
3
4
5
6
7
// 修复前:一次 Cache Line 填充只有少量有效数据
struct big {
int hot_field;
char cold_data[60];
};

struct big obj[N];

如果热点代码只访问:

1
sum += obj[i].hot_field;

那么 CPU 每次都需要把包含整个结构体的 Cache Line 加载到 Cache,而其中绝大多数数据并不会被使用,造成 Cache 带宽浪费。

一种常见优化方法是将热点字段拆分出来,单独存放:

1
2
3
// 修复后:一次 Cache Line 包含更多热点数据
int hot_array[N];
char cold_array[N][60];

这样一条 Cache Line 可以容纳更多连续的 hot_field 数据,提高 Cache 利用率。

此外,数据对齐(Alignment)也属于数据布局优化的一部分。虽然现代 Cortex-A 支持非对齐访问,但相比按自然边界对齐的访问,非对齐访问可能需要额外的访存操作,增加访问延迟。因此,在性能敏感的代码中,仍应尽量保证数据按自然边界对齐,避免不必要的非对齐访问。

3.5 Cache 关联性效应

ARM L1 Cache 通常采用 4-way 组相联(Set Associative) 结构,L2 Cache 则常见为 8-way 或 16-way。每个 Cache Set 最多只能同时保存有限数量的 Cache Line,当多个数据映射到同一个 Cache Set 时,即使其他 Cache Set 仍有空闲空间,也可能发生频繁替换,导致 Cache Miss,这种现象称为 Conflict Miss(冲突失效)

一个典型场景是多个数据块恰好相隔 Cache 每 Way 的容量。以 16KB、4-way、32B Cache Line 的 L1 Cache 为例,每 Way 容量为 4KB,因此地址相差 4KB 的数据会映射到同一个 Cache Set。当同时访问超过 4 个这样的数据块时,就会不断发生 Cache Line 替换,即使整个 Cache 仍有大量空闲空间。

因此,在设计数据结构或选择分块大小时,应尽量避免大量热点数据集中映射到同一个 Cache Set,以减少冲突失效,提高 Cache 利用率。

3.6 I-Cache 与内联的微妙权衡

函数内联能够消除函数调用开销,并为编译器创造更多优化机会,但并非越多越好。对于调用点很多的小函数,过度内联会导致代码体积迅速膨胀,占用更多 I-Cache 空间,反而降低性能。

例如,一个很小的函数在 20 个位置被调用。如果保持独立实现,它只需在 I-Cache 中保存一份代码,所有调用点共享;而内联后,函数体会被复制 20 次,增加 I-Cache 占用,更容易发生 I-Cache Miss,并挤占其他热点代码。

因此,对于调用点很多但函数体较小的场景,函数调用带来的几个周期开销,往往比 I-Cache Miss 的代价低得多。现代 Cortex-A 处理器还配备了高效的分支预测器和 Return Stack Buffer(RSB),能够很好地预测函数调用和返回,因此不应为了消除 BL/RET 而盲目内联。

类似地,对于明显偏向某一分支的条件判断(例如 error == 0 的概率超过 99%),应尽量让高频执行路径保持线性,低频路径放到条件分支中。这样不仅有利于分支预测,也能避免异常处理等冷代码进入 I-Cache,提高指令 Cache 的利用率。

3.7 TLB 优化

TLB(Translation Lookaside Buffer)用于缓存虚拟地址到物理地址的映射。当程序访问的页面数量超过 TLB 容量时,会发生 TLB Miss,需要重新进行页表遍历,增加访存延迟。

对于应用开发者而言,能够直接干预 TLB 的手段并不多。优化的核心是减少活跃页面数量:将热点代码和热点数据尽可能集中,避免频繁跨页访问。在裸机或操作系统允许的场景下,还可以使用 Section(1MB)Supersection(16MB) 映射代替 4KB 小页,使一个 TLB 条目覆盖更大的地址空间,进一步降低 TLB Miss 的概率。

3.8 Data Abort 优化

在支持虚拟内存的操作系统(如 Linux)中,访问尚未建立映射的页面会触发 Data Abort(Page Fault),由内核完成缺页处理,例如分配物理页面、建立页表映射等。对于采用 Copy-on-Write(COW) 的页面,首次写入共享页面时也会触发异常,由内核分配新的物理页并完成复制。这些异常处理都会带来较高的性能开销。

优化思路与 TLB 优化类似:减少页面数量和随机跨页访问。热点代码和数据越集中,不仅能够降低 TLB Miss 的概率,也有助于减少首次访问页面时的缺页处理开销。

3.9 预取:PLD 和 PLI

PLD(Preload Data)用于提示处理器提前将数据加载到 D-Cache;PLI(Preload Instruction)用于提前将指令加载到 I-Cache。它们都是预取提示(Hint),处理器可以选择执行,也可以忽略。

现代 Cortex-A 处理器通常还具备硬件自动预取能力。当检测到连续或规律的内存访问模式时,处理器会自动预取后续 Cache Line,因此大多数场景无需手动插入 PLDPLI。只有在 Profiling 证明内存访问延迟仍然是主要瓶颈时,才值得尝试手动预取,并根据实际测试结果评估收益。

至此,我们已经介绍了编译器优化和内存系统优化,它们覆盖了大多数程序的性能瓶颈。只有当 Profiling 仍然显示热点集中在少数循环或函数时,才值得进一步从 C 源码甚至汇编层面进行针对性的优化。此时,开发者需要理解 ARM 微架构的执行代价,将热点代码改写为更适合处理器执行的形式,从而获得最后一部分性能收益。


4. 源码级微调

编译器做完代码生成后,ARM 汇编层面的规则仍然影响着你写的 C 代码。这里的每条技巧都基于一条特定的 ARM 汇编特性——循环倒计数利用 SUBS 免费更新条件标志位,restrict 解除编译器被迫的保守假设,int 类型避免窄类型的符号/零扩展指令。

4.1 循环倒计数

ARM 汇编知识可以直接指导 C 代码优化:让循环计数器倒数到 0 而非正数到 N。这是因为 SUBS(带标志更新的减法)会让与零的比较”免费”——Z flag 自动更新,省掉一条显式 CMP 指令。

1
2
3
4
5
// 每次迭代需要 CMP 指令
for (i = 1; i <= total; i++)

// SUB 自动更新 Z flag,省掉 CMP
for (i = total; i != 0; i--)

成本对比:正数版本每条迭代是 ADD + CMP + B(cc),三条指令;倒数版本每条迭代是 SUBS + BNE,两条指令。如果循环执行 100,000 次,就省了 100,000 条 CMP。

此外,循环计数器应使用 int (32-bit) 类型。ARM 是原生 32-bit 机器,使用 shortchar 作为循环计数器会导致编译器插入额外的溢出检查指令(如 SXTHBIC 等)。

4.2 Loop Fusion:合并独立循环

1
2
3
4
5
6
// 合并前 — 两个独立的循环
for (i = 0; i < 10; i++) { x[i] = 1; }
for (j = 0; j < 10; j++) { y[j] = j; }

// 合并后 — 一个循环完成两个数组的初始化
for (i = 0; i < 10; i++) { x[i] = 1; y[i] = i; }

收益是减少了循环控制开销(比较 + 分支)的一半。但需注意:如果 xy 恰好在 Cache 中被映射到同一个 Set(因为地址相差 Cache Way 大小的倍数),合并后两个数组会互相逐出对方的 Line,产生 Cache thrashing,合并反而更慢。

4.3 减少栈和堆的使用

ARM 处理器的寄存器数量有限。当所有寄存器都被当前活跃的变量占用时,额外的变量会被溢出到栈上,每次访问都需要内存操作,增加额外的执行周期。关键原则是:尽量减少同时活跃的变量数量。

一个直接的影响是函数参数数量。ARM AAPCS 规定前 4 个参数通过寄存器(R0-R3)传递,第 5 个及以后的参数通过栈传递。因此,4 个或更少的参数比 5 个以上的参数高效得多。64-bit 变量(如 doublelong long)占用两个寄存器槽位。C++ 的非静态成员函数还会额外消耗一个槽位给 this 指针。递归函数通常会进一步恶化寄存器利用效率。

4.4 变量选择:int 是 ARM 上的最优类型

1
2
3
4
5
6
7
8
9
10
// unsigned int i, j, k;  →  i = j + k;
ADD R0, R1, R2 // 1 条指令

// signed short i, j, k; → i = j + k;
ADD R0, R1, R2
SXTH R0, R0 // 额外指令:符号扩展确保不溢出 16-bit

// unsigned short i, j, k;
ADD R0, R1, R2
BIC R0, R0, #0x10000 // 额外指令:位清除确保不溢出 16-bit

原因:ARM 的 ALU 指令对 32-bit 操作数做全宽度计算。当变量声明为更窄的类型时,编译器必须插入裁剪指令来模拟窄类型的溢出行为。虽然编译器有时可以处理不正确的类型指定,但最好从一开始就使用正确的类型。

4.5 restrict:解绑指针假别名

指针别名是编译器优化的最大障碍。C 语言中任何指针都可能与其他指针重叠,编译器必须保守地假设通过指针访问的内存区域可能重叠,这阻止了许多可能的优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 无 restrict — 编译器被迫保守:每次都要从内存重读 *i
void foo(unsigned int *ptr1, unsigned int *ptr2, unsigned int *i) {
*ptr1 += *i;
*ptr2 += *i; // 必须再读一次 *i,编译器不知道 *ptr1 是否改写了 *i
}

// 有 restrict — 编译器知道三个指针不重叠,可以只读一次 *i 然后复用
void foo(unsigned int *restrict ptr1,
unsigned int *restrict ptr2,
unsigned int *restrict i) {
*ptr1 += *i; // *i 读一次
*ptr2 += *i; // 复用同一个值
}

restrict (C99) 或 __restrict (GCC 扩展) 告诉编译器这个指针指向的内存不会被其他任何指针触及。这对于需要同时读写多个数组的循环体(如矩阵运算、信号处理)效果尤其显著。

4.6 除法与取模

并非所有 ARM 处理器都有硬件除法支持。对于没有硬件除法的处理器,C 除法通常会调用一个库例程,对 32 位整数除法需要数十个周期。即使有硬件除法器,除法仍然比乘法慢得多。

编译器可以将除以编译时常量的运算替换为移位乘法对——x = a / 60 会被优化为 (a × magic_constant) >> shift,等价于一次 32×32 乘法加移位,远快于库函数除法。但对于两个变量相除(a / b),编译器无法优化——必须避免出现在热路径中。

取模运算同样会使用除法库例程。一个典型案例:

1
2
3
4
5
// 慢:取模运算调用除法库函数
minutes = (minutes + 1) % 60;

// 快:直接比较 + 条件赋值(2 cycles vs 数十 cycles)
if (++minutes == 60) minutes = 0;

从库函数调用缩减为 2 个周期的 ADD+CMP 组合。

4.7 其他技巧一览

技巧 原理 适用场景
extern 数据放 struct 多个 extern 变量共享同一个基指针,省去多次加载 GOT 条目的开销 全局变量较多时
内联汇编当最后手段 先用 profiling + 算法优化 + 编译器优化,都用完了再考虑手写汇编 确认的极端热点
避免复杂寻址模式 带移位偏移的 LDR 不能让 CPU 双发射,拆分为独立 MOV+LDR 可能更快 循环体内的 Load/Store
对齐访问 非对齐 LDR 有额外 1 周期惩罚,跨 Cache Line 的非对齐更严重 数据结构的 padding 设计
链接时优化 LTO 跨编译单元做内联和死代码消除 大型项目,-flto
减少函数参数数量 ARM 的前 4 个参数走寄存器,第 5 个开始走栈 高频调用函数

5. 优化决策路径

优化决策遵循”先廉价后昂贵、先通用后专用”的原则。起点永远是 profiling——只有测量才能告诉你真正的热点在哪里。确定了热点之后,按照三个层级逐级递进:

第一层是编译器开关——零代码改动。打开 -O2 -mcpu=<target> -mfloat-abi=hard。如果 profiling 确认性能达标,停止优化。

第二层是内存系统优化——修改数据访问模式。如果 Tier 1 不够,瓶颈通常在内存子系统。检查数据局部性:是否可以用 Loop Tiling 把工作集限制在 Cache 内?是否需要交换循环顺序?结构体是否可以拆分让热点字段独立成数组?Cache miss 的代价 (20-100 cycles) 远大于任何指令级优化。

第三层是源码级微调——修改 C 代码细节。如果前两层都不够,热路径的每一行 C 都要仔细审视。循环能否倒计数?指针是否有假别名?热路径中是否有除法或取模?函数参数是否超过 4 个?这些微调每次只省 1-2 个周期,但累积效果显著。

核心原则:永远从编译器开始,永远用 profiling 验证每一步的收益,永远不要猜。

6. 关键要点

以下是 Cortex-A 性能优化中需要牢记的十个关键点:

  1. Profiling 先行——不要猜测瓶颈,先测量再优化。
  2. 优先利用编译器优化——-O2-mcpu=<target>-mfloat-abi=hard 等选项通常能以最低成本获得可观收益。
  3. Cache Miss 往往是最大的性能瓶颈——一次访问主存的延迟可能远高于执行几十条普通指令。
  4. 优化数据局部性——合理使用 Loop Tiling、Loop Interchange 等技术,提高 Cache 命中率。
  5. 避免盲目展开循环和内联函数——代码膨胀会增加 I-Cache 压力,应以 Profiling 结果为依据。
  6. 关注 I-Cache 与分支预测——保持热点代码紧凑、连续,通常比消除一次函数调用更重要。
  7. 充分发挥编译器优化能力——合理使用 restrictconst 等关键字,减少不必要的别名分析和优化障碍。
  8. 减少热路径中的高开销指令——避免频繁使用除法、取模等高延迟操作,必要时可采用等价的低成本实现。
  9. 关注地址转换开销——减少活跃页面数量,提高 TLB 命中率,降低缺页和页表遍历带来的性能损失。
  10. 优化目标不只有速度——降低功耗、减小代码体积和内存占用,同样是性能优化的重要目标。