1、profiling工具
性能分析是性能调优的前提,能帮助发现当前程序的性能瓶颈,指导性能优化的方向。
cpu-profiling工具:valgrind、linux perf、brpc-view(CPU-profiler),可用于分析性能瓶颈、热点函数、热点开销、耗时分布等。
mem-profiling工具:brpc-view(heap-profiler),可用于分析常驻内存消耗、内存申请热点、内存泄漏等问题。
2、性能热点函数中慎用虚函数
虚函数调用会引入额外性能开销,原因如下:
- 间接调用:虚函数是通过虚函数表(vtable)来实现的,调用时需要查虚函数表找到真正的函数地址,这是一个间接调用的过程。
- 内存访问:vtable通常存储在内存中,访问vtable需要从内存中读取数据,这比直接从CPU缓存中读取数据要慢。
- cache miss:普通函数指令通常在函数地址附近,容易命中cache。vtable和object在内存中分离存储,大概率导致cache miss,导致性能退化
- 无法编译优化:因为虚函数在编译时无法确定具体调用哪个函数实现,只能在运行时动态决定,无法享受编译优化带来的性能提升。
优化方法: - 使用函数指针替代虚函数
- 使用模板编程替代虚函数
- 对不被继承的子类用final关键字修饰(final修饰后,编译器可进行优化,线下demo测试耗时可优化约1.5%)
3、循环展开
循环展开是指通过增加每次循环迭代中处理的数据数量,来减少循环次数。
循环展开对提升性能的帮助:
- 减少分支预测失败的次数
- 增加循环体内指令并行的可能性(需要依赖指令间不存在数据相关)
#include <iostream>
#include <sys/time.h>
float loop_v0() {
float ret = 1.0f;
for (uint32_t i = 1; i < 10000001; ++i) {
ret *= i;
}
return ret;
}
float loop_v1() {
float ret = 1.0f;
float ret1 = 1.0f;
float ret2 = 1.0f;
for (uint32_t i = 1; i < 10000001; i+=2) {
ret1 *= i;
ret2 *= i+1;
}
ret = ret1 * ret2;
return ret;
}
uint32_t timediff(struct timeval& first, struct timeval& sec) {
return (sec.tv_sec - first.tv_sec) * 1000000 + (sec.tv_usec - first.tv_usec);
}
int main() {
struct timeval tv0;
gettimeofday(&tv0, NULL);
float ret0 = loop_v0();
struct timeval tv1;
gettimeofday(&tv1, NULL);
float ret1 = loop_v1();
struct timeval tv2;
gettimeofday(&tv2, NULL);
std::cout << "tm0=" << timediff(tv0, tv1) << ",ret0=" << ret0 << std::endl; // 打印ret0是为了避免O3编译优化下,编译器判定loop_v0函数为dead code而不执行
std::cout << "tm1=" << timediff(tv1, tv2) << ",ret1=" << ret1 << std::endl;
return 0;
}
上面的demo,即使开O3编译优化,循环展开版本相比非循环展开版本也能有约50%的性能优化:
4、内存对齐
尽管内存是以字节为单位的,但是cpu一般不是按字节块从内存取数据,而是按照双字节、四字节、八字节等为单位来存取内存,有些特殊指令甚至是按128位、256位来存取内存(例如avx128、avx256 load命令)。
如果未做内存对齐,cpu在取数据时可能需要做多次内存访问,且可能需要做额外的位移、合并操作。做内存对齐后只需要一次内存访问,访存效率得到提升。
使用#pragma pack (n),编译器将按照n个字节对齐
使用#pragma pack (),取消自定义字节对齐方式
5、cache aware
cpu访问各部件的延迟差别很大,下面是2009年Jeff Dean给的一组数字:
(最新数据见:https://colin-scott.github.io/personal_website/research/interactive_latency.html)
对于互联网公司线上常见的Haswell CPU架构,CPU访问cache、RAM所需的始终周期数约:
(详细数据可参考:https://www.7-cpu.com/cpu/Haswell.html)
L1 | L2 | L3 | RAM | |
---|---|---|---|---|
Latency(cycles) | 4-5 | 11-13 | 25-40 | 107-226 |
Size | 32KB | 256KB | 2-3MB x nb cores | - |
Cache-aware指在程序设计时应该充分利用cache系统的特征,尽可能利用低延迟cache部件,以提升程序性能。常见方法有:
- 保证数据局部性,尽可能让数据在空间和访问时间上连续,从而提升cache命中率
- 程序中需要频繁使用的数据应尽可能load到三级cache中,例如需要查表的程序中可以考虑对表进行拆分或压缩,尽可能将表常驻高速缓存中
- 使用prefetch指令,提前将数据加载到缓存。prefetch是异步操作,可以指定将一个cache line(一般是64字节)的数据加载到指定cache level。prefetch的时机对性能影响非常大,prefetch最佳时机跟程序行为紧密相关,建议通过perf工具来分析和调整。
可以使用cachegrind来分析程序的cache miss情况,可以看到函数级的cache miss。
6、NUMA亲和性与绑核
了解底层系统的实现对程序性能优化至关重要。
在传统的统一内存访问架构(UMA,Uniform Memory Access)下,所有处理器共享同一块内存,导致内存带宽成为瓶颈。
在现代多处理器系统中,大多采用非统一内存访问架构(NUMA, Non-Uniform Memory Access),在NUMA架构下,多个CPU被封装在一起,这种封装被称为CPU Socket(插槽),每个CPU Socket拥有自己的本地内存。
CPU访问本地内存的速度比访问其他Socket的远端内存更快。
假如一个本来在CPU Socket 0,它需要的数据被load到CPU Socket 0的本地内存,后来任务被调度到CPU Socket 1中,那CPU需要通过互连技术(QPI或IF)跨Socket访问远端内存,这样会导致访问耗时增加。
在NUMA架构下,我们期望的访存行为是尽可能访问本地内存,避免访问远端内存。
通常通过将进程或者线程绑定到特定CPU核来达到这个目的,常见的绑定方法有:
- 配置numa亲和性(通常由paas平台配置),系统会从多个维度(如socket(插槽)、die(硅片)、CCX(缓存控制器))将所有CPU核划分成多个numa node,paas平台在创建实例时将实例绑定在特定的numa node上。
- OpenMP程序通过OMP_PLACES、OMP_PROC_BIND绑定
- C/C++程序可通过sched_setaffinity(2)、pthread_getaffinity_np(3)绑定
目前线上很多服务已经开启numa亲和性配置了,这种情况下绑核仍然是有意义的:
- 有些cpu开销大的服务,paas分配的numa node可能会跨socket
- 绑核可以减少cpu核切换的开销(如系统调度、cpu上下文切换、寄存器刷新、缓存失效等)
绑核的时候需要注意绑定方式,避免cpu过载。
附:AMD Zen3架构图