性能优化流程
-
找到性能热点代码
在进行性能优化之前,首先需要确定哪些部分是代码的性能热点。可以使用性能分析工具(如 simpleperf)生成火焰图,识别程序中的瓶颈。
-
确定性能问题、原因和优化方案
通过分析火焰图,确定性能问题的具体位置和原因。性能问题可能来自算法设计不合理、代码流程不够高效,或者资源(如计算资源或IO资源)未被充分利用。根据问题的具体情况,提出相应的优化方案。
-
解决问题
根据确定的优化方案,对代码进行修改和优化,以提高性能。
遵循优化规则
- 使用更低比特的位宽
在适当的情况下,可以考虑使用更低比特的数据类型来节省内存空间和提高计算效率,但要确保不会损失精度。
cpp
// 示例:使用 uint8_t 代替 int,节省内存空间
uint8_t num = 10;
- Cache 友好的内存存取方式
尽量利用 Cache 以提高内存访问效率,可以通过顺序访问数组、结构体内存布局优化等方式实现。
cpp
// 示例:优化数组遍历顺序以提高 Cache 命中率
for (int i = 0; i < size; ++i) {
// 顺序访问数组元素
}
两个关键概念:
时间局部性:指程序中的某个数据项在一段时间内可能被多次使用。例如,在一个循环中反复访问相同的数组元素。
空间局部性:指程序中的某个数据项在一段时间内可能被多次使用其附近的数据。例如,数组元素的访问往往是顺序的或近乎顺序的。
考虑以下两种访问二维数组的方式:
cpp
// 不友好的访问方式
int array[N][N];
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
// 访问数组元素
array[i][j] = i + j;
}
}
// 友好的访问方式
int array[N][N];
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
// 访问数组元素
array[j][i] = i + j;
}
}
在第一个例子中,内层循环的访问是按行进行的,这可能导致不同行之间的数据不连续存储,降低了 Cache 的命中率。而在第二个例子中,内层循环的访问是按列进行的,这有利于提高空间局部性,使得 Cache 能够更好地工作,提高了访问效率。
- 循环嵌套遵循外小内大原则
在嵌套循环中,外层循环应该尽量简单,内层循环应该尽量复杂,以减少循环次数和提高 Cache 命中率。
cpp
//rows>cols
// 不友好的循环嵌套方式
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
// 复杂的内层循环操作
}
}
// 友好的循环嵌套方式
for (int j = 0; j < cols; ++j) {
for (int i = 0; i < rows; ++i) {
// 复杂的内层循环操作
}
}
- 循环体内尽量避免分支、函数调用、重复计算、内存申请等操作
在循环体内,尽量减少分支语句、函数调用、重复计算和内存申请等耗时操作,以提高循环执行效率。
避免分支:尽量减少使用分支语句,特别是在内层循环中,因为分支语句可能导致流水线的中断,影响性能。
cpp
// 不友好的分支使用
if (condition) {
// 分支内操作
} else {
// 分支内操作
}
// 友好的分支使用
result = condition ? value1 : value2;
避免函数调用:尽量减少在循环内部的函数调用,因为函数调用涉及栈操作和跳转,会增加额外的开销。
cpp
// 不友好的函数调用
for (int i = 0; i < size; ++i) {
result += func(array[i]);
}
// 友好的函数调用
int temp;
for (int i = 0; i < size; ++i) {
temp = array[i];
result += temp * temp; // 重复计算问题
}
避免重复计算:在循环内部尽量避免重复计算相同的值,可以使用临时变量存储中间结果,以提高执行效率。
- 循环展开
循环展开可以减少循环控制开销,提高指令级并行度,以及利用向量指令优化循环执行效率。
循环展开可以通过手动展开或者编译器指令展开来优化循环执行效率。
手动展开:
cpp
// 手动展开循环
for (int i = 0; i < size; i += 5) {
result += array[i] + array[i+1] + array[i+2] + array[i+3] + array[i+4];
}
编译器指令展开:
cpp
// 使用编译器指令展开循环
#pragma unroll(5)
for (int i = 0; i < size; ++i) {
result += array[i];
}
- 复合语句中将复杂表达式放后面
在复合语句中,将复杂表达式放在后面,以避免不必要的计算。
表达式优化可以通过重排计算顺序、利用短路求值等方式提高程序执行效率。
短路求值示例:
cpp
// 不友好的表达式
if (a == 0 || b / a > c) {
// 表达式操作
}
// 友好的表达式
if (a != 0 && b / a > c) {
// 表达式操作
}
- 函数设计上参数数量小于 4
在函数设计时,尽量保持参数数量的少量,以减少函数调用的开销和提高代码可读性。
函数参数的数量限制通常是由硬件平台和编译器规定的。在 x86-64 架构中,前 6 个参数通过寄存器传递,之后的参数通过栈传递。因此,通常建议将参数数量限制在 4 个以内,以避免过多参数的传递开销。
友好的函数参数示例:
cpp
// 不友好的函数参数
void processData(int a, int b, int c, int d, int e) {
// 函数操作
}
// 友好的函数参数
struct Parameters {
int a;
int b;
int c;
int d;
};
void processData(const Parameters& params, int e) {
// 使用结构体传递参数
}
在这个示例中,将参数封装为一个结构体 Parameters,通过引用或指针传递,避免了过多参数的传递开销。此外,结构体参数的方式也更具可读性和可维护性
- 使用多线程
在合适的情况下,可以使用多线程并行执行任务,以充分利用多核处理器的计算资源。
cpp
// 示例:使用多线程并行执行任务
std::thread t1(task1);
std::thread t2(task2);
t1.join();
t2.join();
- 合理使用 STL
在使用 STL(标准模板库)时,注意选择适当的数据结构和算法,并合理利用其提供的功能,如使用 reserve 预分配内存来避免动态内存分配的开销。
cpp
// 示例:使用 reserve 预分配内存
std::vector<int> vec;
vec.reserve(1000); // 预分配内存
- 大文件存取使用 C 库函数
对于大文件的读写操作,使用 C 库函数(如 fopen、fread、fwrite 等)而不是 C++ 的 fstream,以提高 IO 效率。
cpp
// 示例:使用 C 库函数进行大文件读写操作
FILE* file = fopen("large_file.bin", "rb");
// 读取文件内容
fclose(file);
- 数据对齐
数据对齐是指数据存储在内存中时按照一定的规则进行排列,以便于提高访问效率。在许多计算机体系结构中,访问未对齐的数据会导致额外的开销和性能损失。
友好的数据对齐示例:
cpp
struct Data {
int a;
float b;
char c;
};
// 友好的数据对齐
struct DataAligned {
int a;
char padding1[4]; // 补齐到 8 字节
float b;
char c;
};
在这个示例中,结构体 DataAligned 中的成员 a 和 b 分别是 4 字节和 4 字节对齐的,因此不需要额外的补齐;而 c 则是 1 字节对齐的。通过合理地安排成员的顺序和增加填充字节,可以使整个结构体的大小为 12 字节,保证了数据对齐,提高了访问效率。