本文主要针对C++的性能优化方法展开讨论。虽然这些方法也适用于一些其他语言,但由于C++经常用于底层操作,提供了更多的优化空间;相比之下,诸如Python、Kotlin等高级语言由于其抽象程度更高,优化空间较少。
性能优化原理
要实现性能优化,需要从硬件和软件层面了解优化的实现原理,尤其是围绕运算和存储两个方面。
CPU三级缓存机制
首先来看CPU的三级缓存机制,这是了解性能优化的关键概念。下图展示了CPU三级缓存的结构:
从图中可以看出,越靠近CPU的内存,存储速度越快但空间越小。这涉及到一个关键概念——缓存命中率。CPU对内存的调用逻辑是首先查看L1数据高速缓存是否命中数据,如果没有,再查看L2高速缓存,最后是L3级调用(此时命中率最低)。因此,如何提高CPU的缓存命中率非常重要。
提高缓存命中率
为了提高缓存命中率,可以考虑以下几种方法:
- 数据局部性:尽量让数据使用集中在一小块内存区域内。
- 批量处理:一次处理尽可能多的数据以减少缓存的更新频率。
- 避免跨行访问:尽量避免数据结构导致跨行访问。
CPU对于不同运算的运算速度
不同的运算在CPU上的速度是不同的:
- 逻辑运算(如AND, OR, NOT, XOR):这些运算由专用电路实现,速度最快。
- 比较运算:使用专门的数字比较器,速度较快。
- 赋值运算:由于处理器频繁调用,也被高度优化。
- 算术运算:
- 加(+)、减(-)、移位操作(<<, >>):速度较快,接近逻辑运算速度。
- 乘法(*):相对较慢。
- 除法(/):最慢。
- 浮点运算:速度比整数运算慢得多。
CPU流水线
在大多数CPU中,命令的执行依赖于CPU流水线(Pipeline)。下图展示了一个4级流水线,缓存行(Cache Line,一般是L1,一级缓存)上存放等待执行的指令:
- Clock 1:取出绿色指令放入流水线。
- Clock 2:取出紫色指令,解码绿色指令。
- Clock 3:取出蓝色指令,解码紫色指令,执行绿色指令。
- Clock 4:取出红色指令,解码蓝色指令,执行紫色指令,回写绿色指令。
- Clock 5:流水线空出一级,继续执行类似的流程。
CPU会尝试预测接下来的操作并将其置入流水线中。如果操作非线性,且包含频繁的逻辑判断,可能会中断流水线,使所有操作失效,从而降低处理速度。
利用GPU和NPU进行加速
GPU(图形处理单元)擅长处理大量简单的并行任务,适合图像处理等操作。
NPU(神经网络处理器)则是为加速深度学习算法而构建的,可以在AI工作负载中提供比CPU和GPU更高的性能。
代码处优化
优化代码的方法包括:
- 减少循环开销:精简循环体,减少不必要的操作。
- 减少函数调用:使用内联函数(
inline
)减少函数调用开销。 - 避免不必要的内存分配:尽量减少动态内存分配,使用栈内存或预分配内存。
编译时优化
修改编译时优化级别也能提升性能:
- -O0:调试版本,生成的汇编代码与实际代码更好对应,附加调试信息。
- -O2:发布版本,启用内联、循环展开、指令顺序优化、常量替换等技术优化代码性能。
- -O3:激进优化,过度循环展开、内联等,可能导致可执行文件变大,甚至栈溢出。
对于ARM内核,使用armcc
编译器通常比gcc
有更好的性能优化。
性能优化方法
基于上述性能优化原理,可以采用以下优化方法:
1. 结合代码处性能优化和CPU三级缓存中提高命中率的方法
以矩阵计算为例,常见的边界问题会用到判断(如if
),但if
判断是CPU流水线的敌人,会中断CPU的分支预测,拖慢处理速度。以下是一个优化示例:
原始代码
#include <stdio.h>
#define ROWS 10
#define COLS 10
void matrix_calc(int matrix[ROWS][COLS]) {
for (int i = 0; i < COLS; ++i) {
for (int j = 0; j < ROWS; ++j) {
if (i == 0 || i == ROWS - 1 || j == 0 || j == COLS - 1) {
matrix[i][j]--;
} else {
matrix[i][j]++;
}
}
}
}
优化后代码
#include <stdio.h>
#include <immintrin.h>
#define ROWS 16
#define COLS 16
void matrix_calc_simd(int matrix[ROWS][COLS]) {
__m128i mask = _mm_set1_epi32(0x0000FFFF); // 用于判断是否为边界
__m128i inc = _mm_set1_epi32(1);
__m128i dec = _mm_set1_epi32(-1);
for (int i = 0; i < ROWS; i += 4) {
for (int j = 0; j < COLS; j += 4) {
__m128i data = _mm_loadu_si128((__m128i*)&matrix[i][j]);
__m128i row_mask = _mm_set1_epi32(i == 0 || i == ROWS - 1 ? 0xFFFFFFFF : 0);
__m128i col_mask = _mm_set1_epi32(j == 0 || j == COLS - 1 ? 0xFFFFFFFF : 0);
__m128i boundary_mask = _mm_or_si128(row_mask, col_mask);
__m128i result = _mm_blendv_epi8(data, _mm_add_epi32(data, inc), boundary_mask);
result = _mm_blendv_epi8(result, _mm_sub_epi32(data, dec), boundary_mask);
_mm_storeu_si128((__m128i*)&matrix[i][j], result);
}
}
}
在优化后的代码中,利用了SIMD指令集一次处理多个数据,提高了计算效率。SIMD指令集(单指令多数据)是性能优化的一个好办法。更多信息可以参考相关文章。
如果去掉SIMD操作后,代码如下:
#include <stdio.h>
#define ROWS 10
#define COLS 10
void matrix_calc(int matrix[ROWS][COLS]) {
for (int i = 0; i < ROWS; i++) {
matrix[i][0]--;
matrix[i][COLS-1]--;
matrix[0][i]--;
matrix[ROWS-1][i]--;
for (int j = 1; j < COLS - 1; j += 2) {
matrix[i][j]++;
matrix[i][j
+1]++;
}
}
}
该代码的优化在于:
- 首先,将边界值的计算放在循环外面处理,这样就不必在每次迭代中检查是否在边界上,减少了判断次数。
- 然后,对于非边界上的元素,采用批量增加的方式,每次处理两个数据,加快了计算速度。
2. 滑动窗口
有聪明的小伙伴就发现了,这个词好像在我们计算机网络这门课听说过,但是听的迷迷糊糊的,我们可以理解为一个一直移动的框,举个例子,我们如果要计算一个很长的无序数组的某n个数和,比如说一个数组a内容为{1,7,3,2,4,5,8,0,2,9,1,3,5,7,8,1......}
而我们需要计算每5个数的和。
正常而言,我们需要计算第一个数就是1+7+3+2+4
,第二个数就是7+3+2+4+5
,第三个数就是3+2+4+5+8
…大家发现了什么,其实每次计算哦独有一部分是重复的!这一部分就是滑动窗口。
于是乎我们可以先做一次计算,比如第一个数是1+7+3+2+4 = 17
,那么第二个数是多少呢?就是第一个数-a[0]+a[5]
,也就是17-1+5 =21
,第三个数是第二个数-a[1]+a[6]=22
,我们会惊奇的发现,通过这种方法我们省下了3个运算!
给个例子,比如在计算无重复字符的最长子串问题中,原代码如下
问题描述: 给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。
#include <iostream>
#include <unordered_set>
#include <string>
using namespace std;
int lengthOfLongestSubstring(string s) {
int n = s.size();
unordered_set<char> set;
int ans = 0, i = 0, j = 0;
while (j < n) {
if (set.count(s[j]) == 0) {
set.insert(s[j++]);
ans = max(ans, j - i);
} else {
set.erase(s[i++]);
}
}
return ans;
}
int main() {
string s = "abcabcbb";
cout << lengthOfLongestSubstring(s) << endl; // 输出 3
return 0;
}
我们完全可以利用滑动窗口优化,每次滑动窗口就是最长子串
#include <iostream>
#include <unordered_map>
#include <vector>
#include <string>
using namespace std;
vector<int> findAnagrams(string s, string p) {
vector<int> res;
if (s.size() < p.size()) return res;
unordered_map<char, int> need, window;
for (char c : p) need[c]++;
int left = 0, right = 0, valid = 0;
while (right < s.size()) {
char c = s[right];
right++;
// 窗口内字符数量变化
if (need.count(c)) {
window[c]++;
if (window[c] == need[c]) valid++;
}
// 判断窗口是否有效
while (right - left == p.size()) {
if (valid == need.size()) res.push_back(left);
// 左指针向右移动
char d = s[left];
left++;
if (need.count(d)) {
if (window[d] == need[d]) valid--;
window[d]--;
}
}
}
return res;
}
3. 映射表
这也是一种常见的优化手段,多用于图像处理,比如,我想让RGB图像的绿色值都加1,封顶是绿色为256
传统方法下操作如下
void adjustBrightness(uint8_t* imageData, int width, int height, int brightness) {
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
for (int c = 0; c < 3; ++c) { // RGB 三个通道
int pixel = imageData[(y * width + x) * 3 + c];
pixel = std::min(std::max(pixel + brightness, 0), 255);
imageData[(y * width + x) * 3 + c] = pixel;
}
}
}
}
可以看到,再循环中每次循环都进行了比较和运算操作,虽然单次的运算操作速度很快,但是架不住数量多啊!
这时候我们可以将已知值进行与计算(计算256次)
vector<uint8_t> createGreenLUT() {
vector<uint8_t> lut(256);
for (int i = 0; i < 256; ++i) {
lut[i] = min(i + 1, 255);
}
return lut;
}
// 假设 imageData 是一个 uint8_t 的数组,存储了图像的像素数据
void applyGreenLUT(uint8_t* imageData, int width, int height, vector<uint8_t>& lut) {
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int index = (y * width + x) * 3 + 1; // 假设 RGB 格式,绿色通道在第二个位置
imageData[index] = lut[imageData[index]];
}
}
}
此时我们就大大降低了计算数量!
4. 量化数据
前面提到,浮点数运算会大大拖慢运算速度,因此我们可以考虑对浮点数据进行量化,将其量化为更小的数据类型。
比如我们已知浮点数的范围为0.1-0.8,那么我们就可以将其量化为8位无符号整数,取值范围为0-255
代码如下
#include <iostream>
int quantize(float value) {
// 假设浮点数范围为0.1-0.8,量化为8位无符号整数
float min_val = 0.1;
float max_val = 0.8;
int quant_level = 255;
int quantized_value = round((value - min_val) / (max_val - min_val) * quant_level);
return quantized_value;
}
int main() {
float f = 0.5;
int q = quantize(f);
std::cout << "Quantized value: " << q << std::endl;
return 0;
}
此时对循环读取等操作都变得更加友好了,这种方法被广泛用于诸如ai模型优化等方面,可以将一个浮点数40mb的ai模型给优化成10mb!但是在使用时需要将其反量化为40mb的大小,这就涉及一个点——精度损失。
float dequantize(int quantized_value, float min_val, float max_val, int quant_level) {
// 反量化公式:
// dequantized_value = quantized_value * (max_val - min_val) / quant_level + min_val
return quantized_value * (max_val - min_val) / quant_level + min_val;
}
我们将其中的数字进行反量化后发现原本的0.1-0.8经过这一过程后变为了
Original: 0.1, Quantized: 0, Dequantized: 0.1, Error: 0
Original: 0.2, Quantized: 36, Dequantized: 0.198824, Error: 0.00117648
Original: 0.3, Quantized: 73, Dequantized: 0.300392, Error: 0.000392139
Original: 0.4, Quantized: 109, Dequantized: 0.399216, Error: 0.000784338
Original: 0.5, Quantized: 146, Dequantized: 0.500784, Error: 0.000784338
Original: 0.6, Quantized: 182, Dequantized: 0.599608, Error: 0.000392139
Original: 0.7, Quantized: 219, Dequantized: 0.701177, Error: 0.00117648
可以看到或多或少都出现了误差,但是误差范围都在小数点后三位以后,对精度要求不是很高的情况下可以使用该方法。
5. 其他优化策略
可以多用内敛函数inline减少函数调用,多用引用少用指针提高内存利用率,在做运算时用乘法移位代替除法,用加法移位代替乘法等等
6. 最后的手段:多线程
现代计算机的处理器大都支持多线程操作,可以在同一时间处理多个任务,这也能大大提高处理效率。
7. 硬件层面的,善用GPU和NPU加速
利用OpenGL等硬件加速手段来辅助加快运行速度
8. 编译器编译时参数优化
优化选项
-O1, -O2, -O3
GCC编译器的-O1, -O2, -O3
优化选项可以自动进行一些优化操作,例如内联函数、循环展开、指令调度等。你可以根据实际需求选择合适的优化级别。一般来说,-O3
优化选项提供最高级别的优化,但同时可能增加代码的编译时间和执行文件的体积。
-flto
链接时优化(Link Time Optimization, -flto
)可以在链接阶段进一步优化代码,特别是当多个源文件链接在一起时。这个选项可以在编译时使用-flto
参数,然后在链接时也需要使用这个选项。例如:
g++ -O3 -flto -o myprogram main.cpp module1.cpp module2.cpp
-march=native
-march=native
选项会让编译器针对当前机器的硬件架构生成优化的代码。这个选项可以启用处理器支持的所有优化指令,例如SIMD指令集。使用示例:
g++ -O3 -march=native -o myprogram main.cpp
-ffast-math
-ffast-math
选项启用对浮点运算的激进优化。这个选项会让编译器忽略一些浮点运算的标准规则,以提高代码执行速度。但需要注意,使用这个选项可能导致结果精度下降或不符合IEEE 754标准。
结论
性能优化是一个复杂的过程,需要综合考虑硬件架构、软件算法和编译器优化。不同的应用场景需要不同的优化策略。通过结合数据局部性、批量处理、编译器优化选项以及使用SIMD指令等方法,可以大大提高程序的运行效率。这次只是浅探一下,更深入的内容还需要再进行探索。