汇编视角下的性能优化深水区
大多数程序都无法充分发挥 CPU 的性能,也难以把硬件资源利用到极致。原因在于 CPU 微结构非常复杂,软件层面的执行效率往往很难自然逼近硬件上限。
因此,看到 CPU 占用高,并不意味着 CPU 一直在高效执行有效指令。很多情况下,CPU 只是处于繁忙状态,但每个时钟周期实际完成的指令数并不高,即 IPC(Instructions Per Cycle)偏低。
通常可以将 CPU 周期大致分为两类:
- 执行停顿周期(Stalled Cycle) 此时 CPU 主要在等待资源,例如等待指令或数据到达。常见原因包括 iTLB miss、Cache miss、内存访问延迟等。针对这类问题,优化重点通常在于改善访存局部性、降低缺失率,从而减少等待时间,释放 CPU 执行能力。
- 非停顿周期(Non-Stalled Cycle) 此时 CPU 正在实际执行指令。针对这部分开销,优化重点通常在于减少需要执行的指令总量,例如通过更合理的架构设计、算法和数据结构优化来降低负载,同时减少错误分支预测、使用向量指令替代标量处理等手段,提高执行效率。
下面记录几个业务场景中遇到的案例,并用伪代码做简要说明。
1 案例一
在性能分析中,高频热点函数通常不难找到,但真正困难的是: 当函数本身没有明显的算法优化空间时,应该如何继续压缩其执行开销。
typedef struct {
uint32_t uiIpAddr;
uint16_t usFvrfIndex;
uint8_t ucMaskLen;
uint8_t ucResv;
} Case_001_Keys;
int32_t Case_001_Func(void *pKey1, void *pkey2)
{
Case_001_Keys *pstKey1 = (Case_001_Keys *) pKey1;
Case_001_Keys *pstKey2 = (Case_001_Keys *) pkey2;
if (pstKey1->uiIpAddr > pstKey2->uiIpAddr) {
return 1;
} else if (pstKey1->uiIpAddr < pstKey2->uiIpAddr) {
return -1;
} else {
if (pstKey1->usFvrfIndex > pstKey2->usFvrfIndex) {
return 1;
} else if (pstKey1->usFvrfIndex < pstKey2->usFvrfIndex) {
return -1;
} else {
if (pstKey1->ucMaskLen > pstKey2->ucMaskLen) {
return 1;
} else if (pstKey1->ucMaskLen < pstKey2->ucMaskLen) {
return -1;
} else {
return 0;
}
}
}
}
该函数属于高频执行的分支密集型小函数,优化难点在于其逻辑固定且缺乏明显的算法替代空间。
对于这类函数,常规能想到的优化方式通常包括:
-
使用 inline 减少函数调用开销
但本案例中的比较函数是挂载在通用数据结构上的函数指针,例如
pstTree->Case_001_Func,因此内联优化在当前场景下基本无法生效。 -
根据业务命中规律调整判断顺序
将更容易命中的分支放在前面,以减少错误分支预测带来的 Bad Speculation。 但这种方式通常依赖明确的业务分布特征,适用性有限。
1.1 巧思一
利用结构体固定长度,将多级比较压缩为整数比较
观察该结构体可以发现,其总大小恰好为 8 字节。 如果业务允许按整体数值大小比较,那么原本三层嵌套的字段比较,可以压缩为一次整数比较:
int64_t Case_001_Func_Fix(void *pKey1, void *pkey2)
{
uint64_t v1 = *(uint64_t *)pKey1;
uint64_t v2 = *(uint64_t *)pKey2;
if (v1 > v2) {
return 1;
} else if (v1 < v2) {
return -1;
}
return 0;
}
这种改写的核心收益在于:
- 减少多层字段访问与条件判断
- 将原本多级分支比较收敛为一次整数比较
- 更有利于编译器生成更紧凑的指令序列
1.2 巧思二
需要注意的是,这种改写成立的前提是:结构体内存布局固定,且整体整数比较语义与原字段比较顺序保持一致。
当然,如果外部逻辑并不强制依赖 1、0、-1 这类特定返回值,只需要根据返回值的正负判断大小关系,那么还可以进一步简化为直接做减法:
int64_t Case_001_Func_Fix(void *pKey1, void *pkey2)
{
return *((int64_t*)pKey1) - *((int64_t*)pkey2);
}
相比原始版本,这种写法进一步减少了分支判断,也更容易生成非常短的指令路径。
通过 perf annotate --stdio -s Case_001_Func_Fix 可以看到优化后的热点汇编及其采样占比:
: 10 int64_t CaseFunc_001_Fix(void *pKey1, void *pkey2)
: 11 {
4.56 : 1320: endbr64
: 13 return *((int64_t*)pKey1) - *((int64_t*)pkey2);
76.35 : 1324: mov (%rdi),%rax
6.27 : 1327: sub (%rsi),%rax
: 16 }
12.82 : 132a: ret
相比原始版本中多层 if/else 带来的多次比较和跳转,优化后不仅指令数量明显减少,也避免了频繁分支判断可能带来的分支预测失败和流水线回滚。
优化结果:函数开销从 9.68% 降低到 0.2%,端到端性能提升 5%
对于这类没有算法空间、但位于高频路径上的小函数,适当结合数据布局和返回值语义进行改写,往往能够获得超出预期的收益。
2 案例二
在高频函数中,反复读取全局控制变量做分支判断,容易形成额外性能开销。这类开销不只是一次简单取值,从汇编上看,往往需要经过:
① 定位全局符号地址 -> ② 加载全局对象指针 -> ③ 再加载控制字段 -> ④ 最后参与条件跳转
如果这些访问位于热点路径中,被高频重复执行,开销就会不断累积放大。 此外,若全局对象分散在不同内存区域,还可能带来额外的 Cache / TLB 压力。
尤其当热点链路较长、工作集较大、相关数据会在处理中被其他访存冲掉时,这类开销更容易体现出来。
受篇幅限制,下文仅截取关键反汇编片段进行说明。
2.1 问题示意
业务中常见类似写法,用于线程控制、条件判断等:
if (g_workerCtrl->enableFastPath) {
...
}
我们截取一段典型的热点汇编:
4e450: adrp x1, g_workerCtrl ; 定位全局符号所在页
4e454: ldr x1, [x1, #offset] ; 取出全局对象指针
4e458: ldr w0, [x1, #2232] ; 读取控制字段,如 enableFastPath
4e45c: cbnz w0, 4e46c ; 根据控制值做条件跳转
这几条指令组合起来,才完成了一次源码层面的“读取全局控制变量并做分支判断”。
这类问题不在于单条指令执行得有多慢,而在于在高频热点路径中,“全局寻址 + 字段加载 + 条件跳转”的开销会不断累积。若相关全局对象分散在不同页中,除了 Cache 局部性变差外,还可能带来额外的 TLB Miss,使访存链路进一步变长,最终造成明显的性能下降。
2.2 优化思路
如果这些全局控制变量在一次函数调用期间允许按快照语义使用,可以在函数入口先读入局部变量:
int enableFastPath = g_workerCtrl->enableFastPath;
// 或将这些控制变量,放到热点的内存页中
这样在后续热点路径中,条件判断直接依赖局部变量或寄存器值,从而避免重复的全局对象访问,对应的汇编变化通常类似:
4e420: adrp x1, g_workerCtrl
4e424: ldr x1, [x1, #offset]
4e428: ldr w20, [x1, #2232] ; 函数前部读取一次
... 热点循环开始 ...
4e460: cbnz w20, 4e470
优化后, 全局对象及其控制字段的加载被前移到函数前部,热点路径中直接使用寄存器中的局部值,不再反复执行 adrp + ldr + ldr 这一整条全局访存链,从而缩短了热点路径并降低了访存成本。
2.2.1 注意事项
需要注意的是,并非所有场景都适合直接提取临时变量。只有当该控制变量在一次函数执行期间允许按“快照值”使用时,这种优化才是安全的。
当局部变量提取不适用时,优化重点就不再是“减少读取次数”,而是“改善热点控制数据的布局方式”。 此时可以考虑将热点路径中经常一起访问的控制字段聚合到同一个更紧凑的热点控制结构中,并将其作为独立的热点对象维护,例如:
if (g_hotCtrlA->enableFastPath) { ... }
if (g_hotCtrlB->routeMode == 1) { ... }
if (len > g_hotCtrlC->threshold) { ... }
typedef struct {
int enableFastPath;
int routeMode;
int threshold;
} HotCtrlPage; // 把原本分散在不同全局对象中的热点字段,重新组织成一个单独维护的热点控制结构
HotCtrlPage *g_hotCtrl;
如果这些字段在热点路径中经常一起使用,那么相比于分散在多个全局对象中,将其聚合到同一个热点控制结构里,往往更有利于提高数据局部性。这样即使不做局部变量提取,也能尽量减少多个分散全局对象之间的访存跳转,降低 Cache 和 TLB 压力。