994 words
5 minutes
优化程序性能
背景
这篇笔记更偏“程序层性能优化”的基础认知,重点不是 Unity 某个具体模块,而是理解:为什么有些优化编译器能自动做,有些必须靠我们改数据结构和写法。
5.1 优化编译器的能力和局限性
编译器必须很小心地对程序使用安全的优化。对于程序可能遇到的所有输入和执行路径,优化后的程序都必须和未优化版本保持等价的可观察行为。
编译器必须假设不同的指针可能指向同一个内存位置,这就是常说的 memory aliasing 问题。一旦无法证明两个引用互不影响,很多看起来“显然可以改”的优化都会被保守放弃。
编译器擅长做什么
- 常量折叠和常量传播
- 死代码删除
- 公共子表达式消除
- 循环不变量外提
- 内联、向量化、寄存器分配等
【重点】这些优化的前提都是“编译器能够证明安全”。能不能证明,往往取决于代码的写法是否清晰。
为什么 memory aliasing 会卡住优化
看一个简化例子:
void foo(int *a, int *b) { *a = *a + 1; *b = *b + 1;}如果 a 和 b 指向不同地址,编译器有空间把读取、写回和寄存器复用做得更激进;但如果它们可能其实是同一块内存,重排顺序就可能改变结果。
【例子】当我们调用 foo(&x, &x) 时,程序语义立刻变得敏感:两次写入互相影响,很多“看上去合理”的合并优化都不能随便做。
编译器不容易替你解决的事
1. 算法复杂度选错
O(n^2) 改成 O(n log n) 的收益,通常远大于微调几条指令。
2. 数据布局不友好
缓存命中率、内存局部性、对象分散分配等问题,往往需要程序员改数据结构,编译器很难替你重构。
3. 分支与间接调用过多
虚函数、函数指针、复杂条件跳转、不可预测分支,会让编译器更难做内联和向量化。
4. 有副作用的代码
I/O、原子操作、锁、异常边界、对全局状态的修改,都会限制可重排空间。
写代码时如何帮助编译器
- 让数据依赖关系更清晰,减少“这两个引用会不会互相影响”的歧义
- 优先使用连续内存和简单循环,给缓存与向量化创造条件
- 把热路径里的无关工作提前或挪出循环
- 减少不必要的对象分配、虚调用和跨层封装
- 用 profiling 先确认热路径,再做针对性微优化
【注意】“写得像汇编一样”不等于更快。过度手工展开、过早内联、到处堆临时 micro-opt,反而可能让代码更难维护,也让编译器更难理解。
一个更实用的优化顺序
- 先测量,确认热点函数和真实瓶颈。
- 先改算法与数据结构。
- 再看内存访问模式、分支预测、分配行为。
- 最后才是编译器友好的写法和指令级微调。
回到工程实践
在 Unity、游戏引擎或通用服务端里,程序性能优化通常都遵循同一个顺序:
- 先确认瓶颈是在 CPU 算法、内存、同步还是渲染提交
- 再决定是改架构、改数据组织,还是只修一段热路径
- 不要把“编译器会帮我优化”当作省略设计的理由
【技巧】如果一个优化点很难解释它为什么会快,或者只在极少数数据上成立,先把它放回 profiler 验证。没有测量支撑的微优化,大概率只是错觉。