性能优化不仅要看运行时收益
性能优化不仅要看运行时收益
1. 结论
本文采用以下工程判断原则:
性能优化不仅要评估运行时收益, 还要评估由此增加的状态复杂度、生命周期管理和维护成本.
因此, 不能仅通过:
1
2
Before: 3ms
After: 0.5ms/frame
判断一个优化方案是否更好。
应该同时比较:
1
2
3
4
5
6
7
8
9
10
11
12
13
性能收益
+
Frame Budget 改善
+
新增状态
+
生命周期复杂度
+
异常与取消处理
+
测试成本
+
维护成本
对于 Unity 中的分帧、异步、Job、后台构建等方案尤其如此。
2. Unity 中的实际判断方式
以后遇到一个性能问题, 可以先按照下面的顺序判断:
1
2
3
4
5
6
7
8
9
10
11
12
13
这项工作必须做吗?
↓
必须每帧做吗?
↓
必须这一帧全部完成吗?
↓
多次工作能否合并?
↓
是否存在优先级?
↓
必须 Main Thread 做吗?
↓
最后再考虑算法和代码层面的优化
这里分别对应:
1
2
3
4
5
6
7
Skip / Cache
Reduce Frequency
Time Slicing
Batch / Merge
Prioritize
Job / Worker / GPU
Algorithm Optimization
这种思路与浏览器 Main Thread 优化非常相似。《The Browser’s Main Thread Is Expensive》将相关策略总结为 Splitting、Batching、Prioritizing、Deferring, 并进一步讨论把工作转移到 Worker 或直接消除工作。
Unity 中可以采用相同的系统级思考方式, 只是具体执行机制不同。
3. 分帧为什么不是免费的
假设一个任务原本需要 3ms:
1
2
3
Frame N
└─ Process
└─ 3ms
分帧后可能变成:
1
2
3
4
Frame N 0.5ms
Frame N+1 0.5ms
Frame N+2 0.5ms
...
这显著降低了单帧峰值。
但是原来的任务模型:
1
2
3
4
5
Input
↓
Process
↓
Result
也随之变成:
1
2
3
4
5
6
7
8
9
10
11
Input
↓
Running
↓
Partial
↓
Partial
↓
Complete
↓
Result
原本只有:
1
2
Before
After
现在增加了:
1
2
3
4
5
6
7
Idle
Running
Partially Completed
Completed
Invalid
Cancelled
Failed
所以:
分帧降低的是单帧执行压力, 但通常会增加时间维度上的状态复杂度.
4. 为什么这些额外状态必须处理
例如:
1
2
3
4
5
6
7
8
Frame 100
基于 Input A 开始任务
Frame 101
完成 30%
Frame 102
Input 已经变成 B
此时系统必须明确:
1
2
3
4
旧任务继续执行还是取消?
中间结果是否仍然有效?
是否重新开始?
旧结果还能否继续使用?
此外还可能发生:
1
2
3
4
5
6
对象 Destroy
Component Disable
Scene 切换
资源释放
新任务覆盖旧任务
系统 Shutdown
所以分帧并不仅意味着“下一帧继续计算”。
它实际上引入了一个任务生命周期。
可靠实现通常需要考虑:
1
2
3
4
5
6
7
8
Input Snapshot
Working State
Task Lifetime
Generation / Version
Cancellation
Commit / Swap
Cleanup
Failure Handling
这些系统并不是附加优化, 而是分帧方案本身的一部分。
5. 正式状态与 Working State 应该分离
一个重要原则是:
不要让正式系统读取半完成结果.
应尽量避免:
1
2
3
A = New
B = Old
C = Old
更可靠的模型是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌── Current Result
│ 正式系统继续使用
│
Input ───────┤
│
└── Working Result
↓
分帧构建
↓
Complete
↓
Commit / Swap
↓
New Current Result
如果任务中途失效:
1
2
3
Working Result
↓
Discard
而不是:
1
2
3
4
5
修改正式状态
↓
发现失败
↓
尝试 Rollback
这可以显著减少中间态泄漏到正式系统的风险。
6. 更核心的问题是 State Complexity
分帧只是一个例子。
类似问题还存在于:
1
2
3
4
5
6
7
异步加载
Job System
多线程
后台资源构建
渐进式 Bake
网络请求
GPU Async Compute
这些技术经常以性能优化为目的, 但同时都会改变系统的时间关系和状态关系。
2006 年的论文《Out of the Tar Pit》把 State 和 Control 视为大型软件复杂度的重要来源, 并特别指出性能需求可能迫使系统引入额外的 accidental complexity, 即并非业务问题本身要求、而是实现方式带来的复杂度。论文主张尽可能避免这种复杂度, 无法避免时则应将其隔离。
从这个角度看:
1
2
3
4
5
6
7
同步任务
→ 简单控制流
为了性能进行分帧
→ 新增进度状态
→ 新增时间关系
→ 新增取消与失效关系
因此性能提升本身可能成为新的系统复杂度来源。
这并不意味着不应该优化。
而是意味着:
性能收益必须足够大, 才值得购买这部分复杂度.
7. 为什么任务生命周期尤其重要
并发和异步系统的另一个典型问题是任务生命周期失去边界。
例如一个任务已经启动, 但创建它的对象已经失效:
1
2
3
4
5
6
7
Owner
↓
Start Task
↓
Owner Destroy
↓
Task ?
如果任务没有明确的生命周期关系, 后续就需要处理大量:
1
2
3
4
5
谁负责取消?
谁负责等待?
结果交给谁?
异常交给谁?
资源由谁释放?
Structured Concurrency, 即“结构化并发”的相关研究和工程实践正是在处理这一类问题: 让子任务生命周期受明确作用域约束, 从而重新获得可以推理的任务边界。Nathaniel J. Smith 在关于 Structured Concurrency 的文章中重点讨论了无结构并发导致任务生命周期难以推理的问题。
即使 Unity 中不直接采用某个 Structured Concurrency 框架, 这个思想仍然值得借鉴:
1
2
3
4
任务必须有明确 Owner
任务必须有明确开始
任务必须有明确结束
任务必须有明确取消规则
8. Job System 也没有消除这些问题
将工作从 Unity Main Thread 转移到 Worker Thread 可以显著改善 CPU 利用率。
Unity Job System 就是为了把适合的工作调度到 Worker Threads 执行。Unity 同时提供 Job Dependency 和 Safety System, 用于约束执行顺序和减少数据竞争风险。
但 Job System 解决的是其中一部分问题:
1
2
3
CPU 并行执行
依赖关系
线程安全
它不会自动回答业务层的问题:
1
2
3
4
5
这个结果是否已经过期?
输入中途变化怎么办?
Owner 被销毁怎么办?
旧任务是否应该取消?
完成结果什么时候可以提交?
所以:
“放到 Job”与“完整地设计异步任务生命周期”不是同一件事.
9. 如何判断一个优化是否值得
以后评估类似方案时, 可以同时记录两组信息。
性能收益
1
2
3
4
5
CPU/GPU 时间减少多少?
单帧峰值降低多少?
是否改善 Worst Frame?
是否改善持续 Frame Budget?
是否影响延迟?
工程成本
1
2
3
4
5
6
7
8
新增多少状态?
是否需要 Snapshot?
是否需要 Double Buffer?
是否需要 Cancellation?
是否存在过期结果?
资源生命周期是否变复杂?
测试路径增加多少?
故障是否更难复现?
最终判断的不是:
1
3ms → 0.5ms
而是:
1
2
这部分性能收益
是否值得新增的系统复杂度?
例如:
1
2
3
4
5
偶尔执行一次的 Editor 3ms Task
→ 通常不值得建立完整分帧系统
Runtime 持续产生的 3ms Stall
→ 很可能值得
具体阈值取决于系统的 Frame Budget 和业务要求。
10. 最终原则
性能优化可以首先尝试减少工作本身:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
不做
↓
少做
↓
合并做
↓
延后做
↓
分开做
↓
并行做
↓
GPU 做
↓
最后再优化单次计算本身
但每向后引入一种更复杂的调度方式, 都应该同时检查:
1
2
3
4
5
6
State
Lifetime
Ownership
Cancellation
Commit
Cleanup
因此本文最终采用的工程原则是:
在满足性能预算的前提下, 使用尽可能低的系统复杂度获得足够的性能收益.
性能优化的目标不是让 Profiler 中的数字无限降低。
目标是得到一个:
1
2
3
4
5
6
7
8
9
性能足够
+
状态可控
+
行为可预测
+
能够测试
+
能够长期维护
的系统。
11. 参考资料
The Browser’s Main Thread Is Expensive
本文最直接的启发来源。
重点关注:
1
2
3
4
5
6
Splitting
Batching
Prioritizing
Deferring
Worker
Eliminating Work
它讨论的是浏览器, 但核心是如何管理实时系统中的稀缺执行预算。
Out of the Tar Pit
Ben Moseley, Peter Marks, 2006.
适合进一步理解:
1
2
3
4
State 为什么增加复杂度
Control 为什么增加复杂度
Performance 为什么可能引入 accidental complexity
为什么应该隔离非必要复杂度
Notes on Structured Concurrency
Nathaniel J. Smith, 2018.
适合进一步理解:
1
2
3
4
异步任务生命周期
任务 Ownership
Cancellation
为什么没有边界的异步任务难以推理
Unity Job System Documentation
用于了解 Unity 对以下问题的官方解决方式:
1
2
3
4
5
Worker Threads
Job Dependency
Safety System
NativeContainer
Parallel Jobs
需要注意, 这些机制主要解决执行和数据安全问题, 不能替代业务层面的任务生命周期设计。