28、指针与性能优化:缓存友好访问、指针别名与restrict、循环展开与指针
性能优化这事儿,说白了就是跟硬件打交道。你写的那几行C代码,最终都要变成CPU指令,在内存和缓存之间来回倒腾。我做了十几年嵌入式,见过太多“看起来漂亮”的代码,跑起来却慢得像蜗牛。问题出在哪?很多时候就是指针用得不够聪明。
今天咱们聊三个硬核话题:缓存友好访问、指针别名与restrict、循环展开与指针。这些不是花架子,是真正能让你代码“飞起来”的技巧。
28.1 缓存友好访问:别让CPU等你
现代CPU比内存快好几个数量级。你想想看,CPU执行一条指令可能只要0.3纳秒,但从内存读数据却要几十纳秒。这中间的差距,全靠缓存来填补。
缓存的工作原理很简单:它假设你刚访问过的地址,附近的数据你很快也会用到。这叫“空间局部性”。
我举个例子。假设你有一个二维数组,要遍历所有元素:
// 缓存友好版本:按行遍历
int sum_row_major(int (*arr)[1024], int rows) {
int total = 0;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 1024; j++) {
total += arr[i][j]; // 连续内存访问
}
}
return total;
}
// 缓存不友好版本:按列遍历
int sum_col_major(int (*arr)[1024], int rows) {
int total = 0;
for (int j = 0; j < 1024; j++) {
for (int i = 0; i < rows; i++) {
total += arr[i][j]; // 跳跃式访问
}
}
return total;
}
我在项目中遇到过类似的情况。有一次做视频处理,一帧图像数据是1920x1080的数组。同事用列优先方式遍历像素,结果处理一帧要35毫秒。改成行优先后,直接降到8毫秒。差距就是这么大。
核心原则:用指针遍历数组时,尽量让访问模式跟内存布局一致。C语言是行优先存储,所以内层循环应该遍历列。
用指针写缓存友好代码,可以这样:
void process_buffer(int *buf, size_t len) {
int *end = buf + len;
for (int *p = buf; p < end; p++) {
*p = (*p >> 1); // 连续递增,缓存友好
}
}
这种写法,每次p++都跳到下一个int,地址连续。缓存预取机制会提前把后面的数据拉进缓存,CPU几乎不用等。
小技巧:如果数据结构比较复杂,比如链表,可以考虑用“内存池”+“数组索引”代替指针链。这样能大幅提升缓存命中率。
28.2 指针别名与restrict:给编译器松绑
指针别名,说白了就是两个指针指向同一块内存。编译器看到这种情况,会变得非常保守。
为什么?因为它不敢乱优化。比如:
void vec_add(int *a, int *b, int *c, int n) {
for (int i = 0; i < n; i++) {
a[i] = b[i] + c[i];
}
}
编译器心里会想:“万一a和b指向同一个数组呢?那每次写a[i]都可能改变b[i+1]的值,我还怎么优化?”于是它只能老老实实每次从内存读,写完再存回去。
这时候restrict就派上用场了。它是C99引入的关键字,告诉编译器:“我保证这些指针不会重叠,你放心优化吧。”
void vec_add_restrict(int *restrict a, int *restrict b, int *restrict c, int n) {
for (int i = 0; i < n; i++) {
a[i] = b[i] + c[i];
}
}
加了restrict之后,编译器可能会生成SIMD指令,一次处理4个甚至8个int。性能提升非常明显。
警告:如果你加了restrict,但实际传入了重叠的指针,结果是未定义行为。编译器不会帮你检查,程序可能崩溃或算出错误结果。
我曾经在一个音频处理库中吃过这个亏。两个音频缓冲区有重叠,我加了restrict,结果出来的声音全是噪声。排查了两天才发现是别名问题。从那以后,我每次用restrict都会再三确认指针不会重叠。
用指针写的时候,restrict尤其重要:
void copy_data(int *restrict dst, const int *restrict src, size_t n) {
int *d = dst;
const int *s = src;
const int *end = src + n;
while (s < end) {
*d++ = *s++; // 编译器可以大胆用批量拷贝指令
}
}
记住:restrict不是语法糖,它是你跟编译器之间的契约。用好了,性能翻倍;用错了,bug难查。
28.3 循环展开与指针:减少开销,榨干性能
循环是有开销的。每次迭代都要判断条件、更新计数器、跳转。如果循环体本身很小,那循环开销占比就很大。
循环展开,就是手动减少循环次数,把多个迭代合并到一起。配合指针操作,效果更好。
看个例子:
// 原始版本
void scale_vector(float *data, float factor, size_t n) {
for (size_t i = 0; i < n; i++) {
data[i] *= factor;
}
}
// 循环展开 + 指针版本
void scale_vector_unrolled(float *data, float factor, size_t n) {
float *p = data;
float *end = data + n;
// 处理4的倍数部分
while (p + 4 <= end) {
p[0] *= factor;
p[1] *= factor;
p[2] *= factor;
p[3] *= factor;
p += 4;
}
// 处理剩余部分
while (p < end) {
*p++ *= factor;
}
}
展开4次之后,循环判断次数减少了75%。而且编译器看到连续的4次乘法,可能会生成SIMD指令,一次搞定。
我个人习惯展开4次或8次。展开太多反而不好,会让代码体积变大,指令缓存可能装不下。
经验值:对于嵌入式平台,展开2~4次通常收益最大。在ARM Cortex-M上,我试过展开8次反而变慢,因为代码膨胀导致指令缓存频繁miss。
指针在这里的优势很明显:p += 4比i += 4然后data[i]少一次索引计算。别小看这一丁点,在循环里累积起来就很可观。
再复杂一点,处理结构体数组:
typedef struct {
int x, y, z;
} Point3D;
void translate_points(Point3D *pts, int dx, int dy, int dz, size_t n) {
Point3D *p = pts;
Point3D *end = pts + n;
while (p + 2 <= end) {
p[0].x += dx; p[0].y += dy; p[0].z += dz;
p[1].x += dx; p[1].y += dy; p[1].z += dz;
p += 2;
}
while (p < end) {
p->x += dx; p->y += dy; p->z += dz;
p++;
}
}
这里展开2次,因为结构体比较大,展开太多会让寄存器压力增大。嗯,这个度需要根据实际情况调整。
总结一下:循环展开配合指针递增,能同时减少循环开销和索引计算。但要注意平衡代码大小和性能收益。
28.4 三者结合:一个实战案例
最后,我把三个技巧揉在一起,给你看一个完整的例子。假设我们要做图像灰度化:
// RGB888 转灰度,优化版本
void rgb_to_gray_optimized(
uint8_t *restrict gray,
const uint8_t *restrict rgb,
size_t pixel_count
) {
// 指针方式,缓存友好
uint8_t *g = gray;
const uint8_t *s = rgb;
const uint8_t *end = rgb + pixel_count * 3;
// 每次处理2个像素(6字节),循环展开
while (s + 6 <= end) {
// 像素1
*g++ = (uint8_t)((s[0]*77 + s[1]*150 + s[2]*29) >> 8);
// 像素2
*g++ = (uint8_t)((s[3]*77 + s[4]*150 + s[5]*29) >> 8);
s += 6;
}
// 处理剩余像素
while (s < end) {
*g++ = (uint8_t)((s[0]*77 + s[1]*150 + s[2]*29) >> 8);
s += 3;
}
}
这个函数里:
- restrict 告诉编译器gray和rgb不重叠
- 指针递增 保证连续访问,缓存友好
- 循环展开 减少分支开销
我在一个摄像头项目中用过类似的代码。原始版本用下标访问,处理640x480的图像要12毫秒。优化后降到4毫秒,刚好满足30fps的要求。
嗯,性能优化就是这样,有时候就差那么几毫秒。而指针,就是你手里最锋利的工具。
公众号:蓝海资料掘金营,微信deep3321