大学时我总以为写代码就是告诉电脑一步步该怎么做。等到真正开始做底层开发,才意识到现代编译器简直就是个有魔法的黑盒子。
调试一个性能问题时,我发现编译器把我写的20多行代码优化成了只有5条汇编指令!好家伙,它偷偷替我做了这么多工作:
还记得我第一次看反汇编结果时的震惊 - 我代码里的width * 800 + height * 600
直接变成了一个硬编码的数字。编译器在编译期间自己算好了!
实际工作中,我经常会看到新人写这样的代码:
float calculateArea() {
return 3.14159265358979 * radius * radius;
}
每次调用都要算一遍圆周率?编译器在默默帮你优化,但如果你写成这样会更好:
const float PI = 3.14159265358979;
float calculateArea() {
return PI * radius * radius;
}
有次我的同事写了这样的代码处理图像变换:
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
result[i][j] = source[i * width + j] * (width * height / 255);
alpha[i][j] = source[i * width + j] * (width * height / 127);
}
}
开启O2优化后,编译器立刻发现width * height
和i * width + j
在循环中被重复计算,主动提取出来只算一次。但这种优化并不总是可靠,特别是当表达式变得复杂或者跨越多个函数时。
我现在习惯手动提取复杂表达式:
for (int i = 0; i < height; i++) {
int rowOffset = i * width;
float sizeNorm = width * height; // 只计算一次
for (int j = 0; j < width; j++) {
int pixelIndex = rowOffset + j;
float sourceVal = source[pixelIndex];
result[i][j] = sourceVal * (sizeNorm / 255);
alpha[i][j] = sourceVal * (sizeNorm / 127);
}
}
记得有次我在调试LLVM优化问题,不得不深入研究它的中间表示(IR)。当时我对同事说:"这就像研究外星人的语言一样"。
不同的编译器有不同的中间表示:
我曾经为了实现一个自定义语言,手写了一个AST解析器。简单来说,AST就像是把你的代码按语法结构画成一棵树。
比如这段简单的代码:
if (x > 5) {
return x * 2;
} else {
return x + 1;
}
在编译器内部大概变成这样的树结构:
IfStatement
/ | \
Condition Then Else
(x>5) | |
Return Return
| |
Multiply Add
/ \ / \
x 2 x 1
你写的每一个表达式、每一个语句,都会变成这样的树节点。编译器后续的所有优化都是在这棵"树"上进行手术。
第一次接触SSA形式时,我的脑袋都大了。它的核心理念是:每个变量只被赋值一次。
这什么意思呢?正常人写的代码是这样的:
int x = 1;
x = x + 2;
x = x * 3;
转换成SSA形式后变成这样:
x1 = 1
x2 = x1 + 2
x3 = x2 * 3
看起来很蠢对吧?但这对编译器来说简直是天赐福音 - 因为它可以很容易地追踪每个变量的来源和去向,进行更激进的优化。
这部分是我最痴迷的领域,因为它直接和硬件打交道。现代CPU都是流水线设计,可以同时执行多条指令的不同阶段。
举个例子,我曾经手动重排过这样的代码:
优化前:
result = a * b;
temp = c + d;
output = result / temp;
flag = (output > threshold);
优化后:
// 启动乘法(耗时长)
result = a * b;
// 同时进行加法(不依赖乘法结果)
temp = c + d;
// 现在乘法可能完成了,进行除法
output = result / temp;
// 最后进行比较
flag = (output > threshold);
编译器通常会自动做这种优化,但在关键循环中,我有时会手动调整指令顺序,特别是当我知道某些操作特别耗时(比如除法和取模)时。
经过这么多年和编译器打交道,我总结了几个实用技巧:
有次我写了超复杂的嵌套三元表达式,想着"这样编译器能优化得更好"。结果呢?代码难读,bug一堆,而且优化效果还不如写清晰的if-else。
我的项目通常会使用:
-O3 -march=native -ffast-math
但要小心,-ffast-math
会牺牲一些IEEE浮点精度换取速度。出过几次bug后,我学会了在科学计算代码里避免使用它。
回到开头说的那次性能优化。我们的渲染引擎有个着色器编译环节特别慢。通过分析中间代码,我发现问题出在大量的纹理采样上。
原代码大概是这样:
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// 每个像素采样16次周围的纹素进行模糊
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
result += sampleTexture(x + i - 2, y + j - 2);
}
}
}
}
经过研究CPU流水线和缓存机制,我做了这些修改:
最终代码变得更长了,但性能提升了30%。有趣的是,如果我只是简单地开启-O3
优化,只能获得约10%的提升。这说明有时候了解底层原理,确实能比编译器做得更好。
编译器是个聪明但不全知全能的工具。它能做的优化比我们想象的多,但也有盲点。理解它的工作原理,能帮助你:
如果有一天你发现自己在研究汇编代码,不要害怕 - 欢迎来到优化的兔子洞!这里有时令人沮丧,但更多时候充满乐趣。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。