模拟垃圾减少 73%,没有改变任何一次回放
2026 年 6 月,我对 CandyRush 的模拟做了两轮性能优化,并定下一条不可商量的规则: 任何触及模拟的改动都必须逐位一致。不是“玩起来一样”,而是回放摘要必须完全匹配。
结果如下,全部来自同一台机器上的编辑器 batchmode 基准测试:
| 场景 | 之前 | 之后 | 变化 |
|---|---|---|---|
| 行走 60 秒,垃圾总量(第一轮,PR #300) | 119.3 MB | 32.7 MB | -73% |
| 行走,每 tick 平均(第一轮) | 33,156 B | 9,525 B | -71% |
| 空闲战斗,3,600 tick,每 tick 平均(第二轮,PR #302) | 8,976 B | 487 B | -95% |
| 敌人从 0 增至 246,每 tick 平均(第二轮) | 71,901 B | 2,927 B | -96% |
| 500 敌人压力测试,P95 帧时间(第二轮) | 9.21 ms | 7.23 ms | -22% |
两轮中没有任何回放 fixture 发生变化。途中,这项工作还发现了一个自 1 月起就存在于 工具包里的对象池 bug,以及一个专门写来预期这个 bug 的测试。
为什么垃圾在这里很重要
CandyRush 在 Unity 中运行固定 60 Hz 的模拟。垃圾在创建时代价很小;代价出现在回收器 运行时,表现为本来流畅的帧里的一次卡顿。屏幕上有几百个敌人时,玩家会注意到。
真正让这件事比普通的分配清理更难的是那条约束。在确定性模拟中,一次“无害”的列表 重排就可能改变哪个敌人先被击中,进而改变整局游戏。每项修复都必须保持相同的值和 相同的顺序。
第一轮:先测量,再找到 15 tick 的规律
PR #300 起初是对热路径的一次清理,也就是在 Simulate() 中每 tick 都会运行的代码。
一个 agent 在我的指导下完成了清理;我定下逐位一致的规则,并审查了每一轮。前期的
修复都是熟悉的那些:
- 每 tick 代码中不用 LINQ。 两个光环类技能每 tick 都执行
OrderBy(...).ToList(),即使附近没有敌人。现在它们复用排好序的缓冲区,并在为空时 提前返回。 - 填充缓冲区的查询。 “半径内的敌人”原本只有一个返回新列表的方法。现在新增了一个
重载,填充调用方持有的列表,与 Unity 的
Physics2D.GetContacts约定相同;原来会 分配的版本委托给它,所以扫描实现仍然只有一份。 - 共享比较器。 六处确定性排序各自传入一个 lambda,在 Mono 上每次调用都会分配一个
比较器包装对象。现在它们共享一个
DeterministicOrder类中的单例比较器,这个类也 因此成为实体排序方式的唯一权威。
最大的收获来自一个更好的测量工具。已有的基准测试从不移动玩家,所以这个 PR 的下一轮 加入了一个每 tick 分配探针,包含两个场景:有真实波次的空闲战斗,以及向东行走 60 秒 的“移动马拉松”。
一个注意事项:在 Unity 的 Boehm 回收器下,GC.GetAllocatedBytesForCurrentThread
读数为零,所以探针记录的是 GC.GetTotalMemory(false) 的每 tick 差值。这是对堆增长
的估计,因此结果只在相同场景、相同种子、相同机器之间比较。
行走时每 tick 的分配约为静止时的五倍,而且尖峰很有规律:每个约 370 KB,在 409 个 大尖峰之间的间隔中,有 184 个恰好是 15 tick。15 tick 正是流场的重算间隔。
原因是:引导敌人的流场是一个跟随相机的网格。相机窗口移动时,服务会分配一个全新的 网格,即使它的尺寸并没有变。玩家行走时,每次重算都会这样做。
修复放进了共享工具包,所有使用它的游戏都能受益:
// Moves the grid window to a new origin, reusing the existing cell array.
// The result is state-identical to constructing a fresh grid of the same
// dimensions at the new origin.
public void Recenter(FixedVector2 newOrigin)
{
Origin = newOrigin;
ResetAll(); // the same reset the constructor now calls
}
构造函数和 Recenter 共用一个重置方法,所以“与新建网格完全相同”是由结构保证的,
而不是靠小心地复制。在相同场景、相同种子、相同机器上重跑:
| 移动马拉松 | 每 tick 平均 | p99 | 32 KB 及以上的尖峰 | 每 60 秒总量 |
|---|---|---|---|---|
| 之前 | 33,156 B | 401,408 B | 409 | 119.3 MB |
| 之后 | 9,525 B | 40,960 B | 165 | 32.7 MB |
15 tick 的规律消失了。剩下的大尖峰与行走过程中的区块切换一一对应,这指向了下一轮。
第二轮:写下来的协议
第二轮开始前,在改动任何代码之前,我把协议写进了 issue(#301):
- 从没有已知测试失败的 dev 分支顶端开分支。
- 分支上的第一个提交就是“之前”的测量:在未改动的代码上把每个测量工具运行一次, 原始的每 tick CSV 归档到仓库中。
- 修复分层进行。第一层影响大、确定性风险为零。任何需要摘要验证的改动都会明确标注。
- “之后”的测量使用完全相同的场景、种子和用例顺序。
先提交“之前”的数据很重要:只写在拉取请求里的数字无法复查。这些可以。为了写这篇 文章,我从归档的 CSV 重新计算了空闲战斗和移动场景的平均值,与 PR 中的数字完全一致。
最大的单项发现是一个每 tick 都重建的空间索引。它的 Clear() 丢弃了所有桶列表,
重建时又为每个被占用的格子分配一个新列表:按 issue 中的估算,200 个敌人时每 tick
有数百个。修复方法是保留这些列表,并记录哪些正在使用:
// Reset only the occupied lists, in place: O(occupied), no allocation.
for (int i = 0; i < occupiedKeys.Count; i++)
{
buckets[occupiedKeys[i]].Clear();
}
occupiedKeys.Clear();
每个格子内的插入顺序保持不变,这很关键,因为碰撞推出逻辑会遍历这些列表。在这一轮 之前,即使是不带技能的基准场景,每 tick 也要分配 1,143 字节,PR 把这个均匀的底线 大部分归因于这次重建。之后,同一场景测得 64 字节。
第一层的其他改动,每一项都因为能保持输出完全相同而入选:
- 先走便宜的路径。 每个敌人都会先计算一个归一化的追击方向(一次定点数平方根和 两次除法),然后通常又用流场方向覆盖它。现在它先询问流场,只在需要时才计算后备 方向。输出位完全相同。
- 一次压缩遍历。 从模拟循环中移除已消失的实体时,原来对每一项调用
List.Remove, 当一次爆炸同时杀死 30 个敌人时就是二次复杂度。现在用一次读/写遍历,按原顺序保留 存活者。 - 变化时才写入。 两个光环类技能每帧都设置碰撞体半径。根据 issue 中的分析,设置 2D 圆形碰撞体的半径,哪怕是相同的值,也会让 Box2D 重建形状,所以现在只在尺寸变化 时才写入。
- 在 tick 之外扩充对象池。 敌人和掉落物的对象池在每个 Unity 帧补充一个对象,
位于
Simulate()之外。拿到池中的哪个对象不属于模拟状态,因为实体 ID 是在生成时 才盖上的。
对象池 bug,以及预期它的测试
第二轮扫描读到工具包的对象池构造函数,在全部四个构造函数重载中都发现了这一行:
for (int i = 0; i <= startingSize; i++)
游戏中的每个对象池都比要求的多创建了一个对象。这个循环可以追溯到 2026 年 1 月工具包 最早的几次提交。
更有启发的是那个测试。循环修复后,一个对象池测试失败了,因为它断言的正是这个 bug:
// startingSize=3 creates 4 entities (loop is <=)
Assert.AreEqual(4, childCount, ...);
某个时候,有人注意到了多出来的那个对象,并写了一个测试把它固定下来。这个测试已改为 断言精确的数量。当一个测试的注释在解释为什么显而易见的答案是错的时,值得再看一眼。
回放如何证明什么都没变
证明建立在 5 月的回放测试框架上:实体状态摘要,每个模拟秒一个哈希,与提交的 fixture 比对。
- 第一轮: 验证方法是先把 diff 暂存(stash),在未改动的 dev 顶端运行,再把新
摘要与分支上的比较。两者完全一致(例如第 60 tick 在两边的哈希都是
9D039073D0F5338A)。随后,流场修复又与在修复之前提交的 fixture 比对:仍然通过。 - 第二轮: 闸门更严格。多次运行一致、自身基线和对端基线测试,在实体和 props 两个 场景中,都必须针对分支起点的 fixture 通过。这个拉取请求没有改动任何 fixture 文件; 我从它的 diff 中确认了这一点。(所有 fixture 都是 Windows 的,因此对端测试在这里按 设计直接通过;真正起作用的是自身基线测试。)
这个闸门之所以可信,是因为就在第一轮之前落地的那项工作。PR #297 的测试套件重整清除了 套件中所有的抑制手段,并在先确认模拟在不同 Unity 进程之间是确定性的之后,修复了两个 过期的 PlayMode fixture。在它前后两次运行中,PlayMode 的墙钟时间也从 34.3 分钟降到 20.3 分钟,同时删除 1,703 行、新增 1,220 行。一个本来就是红的摘要闸门,告诉不了你 任何新东西。
第二轮的最终验证:EditMode 3,268 通过,0 失败;PlayMode 457 通过,0 失败。
刻意没有动的部分
- 可能改变模拟结果的第三层项目没有做,这样这一轮的摘要闸门就保持为纯粹的“什么都 没变”断言。每一项都需要单独的摘要验证。
- 第一轮的一项修复(在空闲帧补充敌人对象池)无法用快进基准测试体现,因为它在几帧之内 就跑完数千个 tick。PR 如实说明了这一点,而不是报一个数字。
关于帧时间:它们是编辑器 batchmode 下的测量,不是玩家构建。根据我的经验,部署后的 玩家构建帧率通常是编辑器的 2-3 倍,但我没有为这些运行测量过。
可以推广的经验
构建与玩家行为相符的测量工具。 最大的垃圾来源一直不可见,直到有一个基准测试真的 走了起来。之前的每个基准测试都站着不动。
提交“之前”的数据。 归档的原始数据把一个声称的改进变成任何人都能重算的改进。
让“完全相同”由结构保证。 构造函数和 Recenter 共用一个重置方法,复用的列表保持
插入顺序,这意味着摘要只是确认了设计本来就保证的东西。
仔细读那些编码了意外的测试。 一个写来预期 n + 1 的测试,记录的是 bug,而不是需求。