994 words
5 minutes
优化程序性能

背景#

这篇笔记更偏“程序层性能优化”的基础认知,重点不是 Unity 某个具体模块,而是理解:为什么有些优化编译器能自动做,有些必须靠我们改数据结构和写法。

5.1 优化编译器的能力和局限性#

编译器必须很小心地对程序使用安全的优化。对于程序可能遇到的所有输入和执行路径,优化后的程序都必须和未优化版本保持等价的可观察行为。

编译器必须假设不同的指针可能指向同一个内存位置,这就是常说的 memory aliasing 问题。一旦无法证明两个引用互不影响,很多看起来“显然可以改”的优化都会被保守放弃。

编译器擅长做什么#

  • 常量折叠和常量传播
  • 死代码删除
  • 公共子表达式消除
  • 循环不变量外提
  • 内联、向量化、寄存器分配等

【重点】这些优化的前提都是“编译器能够证明安全”。能不能证明,往往取决于代码的写法是否清晰。

为什么 memory aliasing 会卡住优化#

看一个简化例子:

void foo(int *a, int *b) {
*a = *a + 1;
*b = *b + 1;
}

如果 ab 指向不同地址,编译器有空间把读取、写回和寄存器复用做得更激进;但如果它们可能其实是同一块内存,重排顺序就可能改变结果。

【例子】当我们调用 foo(&x, &x) 时,程序语义立刻变得敏感:两次写入互相影响,很多“看上去合理”的合并优化都不能随便做。

编译器不容易替你解决的事#

1. 算法复杂度选错#

O(n^2) 改成 O(n log n) 的收益,通常远大于微调几条指令。

2. 数据布局不友好#

缓存命中率、内存局部性、对象分散分配等问题,往往需要程序员改数据结构,编译器很难替你重构。

3. 分支与间接调用过多#

虚函数、函数指针、复杂条件跳转、不可预测分支,会让编译器更难做内联和向量化。

4. 有副作用的代码#

I/O、原子操作、锁、异常边界、对全局状态的修改,都会限制可重排空间。

写代码时如何帮助编译器#

  • 让数据依赖关系更清晰,减少“这两个引用会不会互相影响”的歧义
  • 优先使用连续内存和简单循环,给缓存与向量化创造条件
  • 把热路径里的无关工作提前或挪出循环
  • 减少不必要的对象分配、虚调用和跨层封装
  • 用 profiling 先确认热路径,再做针对性微优化

【注意】“写得像汇编一样”不等于更快。过度手工展开、过早内联、到处堆临时 micro-opt,反而可能让代码更难维护,也让编译器更难理解。

一个更实用的优化顺序#

  1. 先测量,确认热点函数和真实瓶颈。
  2. 先改算法与数据结构。
  3. 再看内存访问模式、分支预测、分配行为。
  4. 最后才是编译器友好的写法和指令级微调。

回到工程实践#

在 Unity、游戏引擎或通用服务端里,程序性能优化通常都遵循同一个顺序:

  • 先确认瓶颈是在 CPU 算法、内存、同步还是渲染提交
  • 再决定是改架构、改数据组织,还是只修一段热路径
  • 不要把“编译器会帮我优化”当作省略设计的理由

【技巧】如果一个优化点很难解释它为什么会快,或者只在极少数数据上成立,先把它放回 profiler 验证。没有测量支撑的微优化,大概率只是错觉。

优化程序性能
https://fuwari.vercel.app/posts/performance/optimize-program-performance/
Author
Qingswe
Published at
2025-02-14
License
CC BY-NC-SA 4.0