← 返回全部文章

500 个敌人:在确定性模拟中从 27 FPS 提升到 42 FPS

发布于:
candyrushperformancedeterminismunityevidence

10 月 26 日,距离 CandyRush 在 Steam 发售还有五天,一次在 Unity 编辑器里对第 3 章(arc 3)深度 5 的试玩几乎没法玩:15 到 30 FPS。当天结束时,同一关卡的峰值测得 42.4 FPS,而且无论新代码运行在 1 个还是 23 个工作线程上,回放摘要(digest)都完全 相同。

下面所有数据都来自 arc 3 的 "Horde" 段,深度 5,敌人数达到 500 的上限时:

之前之后
峰值 FPS(中位数)27.442.4
帧时间36.4 ms23.5 ms
每帧模拟 tick 数2.141.42
每 tick 敌人邻居计算约 3.0 ms0.86 ms
每 tick 危险道具1.80 ms0.57 ms

危险道具这一行来自对最终代码的一次后续采集,那次峰值为 41.4 FPS,与 42.4 的差距在 多次运行之间的噪声范围内。

这些都是 Unity 编辑器里的采集结果,机器是 Ryzen 9 7900X 加 RTX 4090,Game 视图 为 1599x894,"之后" 一行在编辑器里开启了 Burst 编译。按我的经验,部署出去的玩家构建 通常比编辑器高 2-3 倍 FPS;这是我的经验,不是本文测得的结果。同一场景多次运行之间的 噪声大约是每帧正负 1.5 ms(三次运行分别为 41.4、42.4 和 43.1 FPS)。

这项工作以两个在 10 月 26 日合并的拉取请求交付:先是 UTools #179,然后是 CandyRush #1079。我指挥一个 AI 编码代理完成了它:我设定约束和要回答的问题,并在每 一次改动之前审阅上一次的测量结果。本文讲的是这些决策是怎么做出来的。

约束

CandyRush 是一个确定性的固定时间步长模拟。每一个周排行榜成绩都要在服务器上重放玩家 录下的输入来验证,而这只有在同样的输入在 Windows、macOS、Linux 和网页上逐位产生 同样状态时才成立。正因为如此,模拟使用定点数学(Fixed64),而不是浮点数。

这就排除了常见的快速优化。浮点快速路径早就试过:敌人分离(separation)一直用浮点 数学,直到 8 月 9 日因为网页版和桌面版回放出现分歧而被关闭。我提出过一个选项:在每周 排行榜那一局之外放宽确定性。书面分析建议不要做双模式模拟,最终上线的方案保留一个 确定性模拟,只放宽表现层。

第一步:让问题在 90 秒内可复现

一次 15 分钟的试玩不是测量。第一个交付物是一个 PlayMode 采集测试 SegmentDensityCaptureTests:它用发售的关卡段数据,在指定深度构建一局真实的游戏, 并每秒记录一行:帧时间、模拟所占份额、每帧 tick 数、存活敌人数和人群重叠情况。它还 读取 Unity 自己的 profiler 标记来拆分引擎开销,并支持 A/B 变体(关闭物理、关闭 Animator、关闭音频等)。

测试复现了反馈的问题:深度 5 的 Horde 大约 35 秒就填满 500 个敌人的上限,然后一直 停在 25 到 29 FPS。一次运行大约 90 秒,而不是 15 分钟。它被标记为 [Explicit], 所以永远不会在 CI 中运行。

基线也指明了该看哪里:

峰值时,深度 5敌人数FPS帧 ms
Horde50027.436.4
Combat30960.916.4
Horde,深度 025474.913.3

在 500 个敌人时,模拟大约占一帧的 54%:每帧 2.14 个 tick,每个 9.1 ms。这是追赶 螺旋(catch-up spiral)的开端:慢的一帧意味着下一帧要补更多固定 tick,于是那一帧 更慢。降低每个 tick 的开销,也就减少了 tick 的数量。

采集还发现了值得清除的噪声。一个 8 月留下的临时调试探针,每局 arc 3 仍在写大约 6,400 行带堆栈的日志,记录敌人死亡、魔杖射击和进入关门的事件。它被删掉了。

第二步:保持确定性的 Burst 内核

敌人分离(柔和地避开邻居)和重叠推出(硬性解除穿透)是每个 tick 里最大的开销。 这次改动把两者都移进了 Burst 编译的内核:

  • EnemyNeighbourGrid 是每个敌人位置、半径和 id 的快照,在 tick 开始时生成,并用 计数排序分桶。
  • SeparationForceJob 在一个并行 job 中计算整个人群的转向力。
  • 重叠推出以 Burst 直接调用的方式运行。

有两个细节让它保持确定性:

用 Jacobi,而不是 Gauss-Seidel。 每个敌人都从快照读取邻居,绝不读取其他敌人在 本 tick 已经更新过的位置。结果不再依赖处理顺序,所以 1 个工作线程和 23 个工作线程的 结果相同。第一版用移动后的自身去和快照中的邻居比较,结果两个完全重叠堆在一起的敌人 永远分不开。现有的 EnemySeparationIntegrationTests 抓住了这个问题,一个新的内核 测试把修复固定了下来。

Burst 不能依赖静态构造函数里的 double 运算。 Fixed64 用 FromDouble(Math.PI ...) 构造它的角度常量。UTools #179 把它们换成了那个表达式本来 就会产生的原始 64 位数值,并有一个测试断言每个字面量都等于原来的来源。这个改动没有 让任何摘要发生变化。

500 个敌人时每个 tick 的测量结果:

开销之前(ms)之后(ms)
分离1.260.15
重叠1.420.48
网格重建0.340.23

有一个发现根本不是代码:开发机上的编辑器关闭了 Burst 编译,在这种设置下同样的内核 测得 30.9 FPS,而不是 42.4。采集必须记录这个设置。

第三步:一次错误的猜测,经过测量后撤回

profile 上的下一项是 HazardPropBehavior:危险区域这类范围道具每个 tick 花 1.80 ms。代码用线性查找在成员列表里找每个敌人,所以显而易见的猜测是查找本身。代理把 它换成了 HashSet。

没有任何变化。于是它没有再猜一次,而是加上临时计时器,覆盖了 135,045 次范围道具 tick:

每个道具、每个 tick耗时
整个成员扫描19.0 µs
遍历全部约 500 个敌人17.2 µs
形状重建1.2 µs
施加效果0.45 µs

区域里平均只有 0.2 个敌人。开销不在于查找成员,而在于每个道具、每个 tick 都要访问 全部 500 个敌人,结果几乎一个都找不到。HashSet 的改动连同计时器一起被撤回。

真正的修复复用了快照网格:范围道具现在向网格查询自己边界内的敌人。 HazardPropBehavior 从每 tick 1.80 ms 降到 0.57 ms,峰值时完整 tick 从 6.65 ms 降到 5.73 ms。那次运行的峰值 FPS 是 41.4,与之前的 42.4 相差在噪声范围内。

人群行为还正常吗?

改用 tick 开始时的快照会改变模拟结果。所以人群行为是通过测量检查的,而不是靠肉眼:

Horde 深度 5,峰值时每个敌人的重叠对数最深重叠
新的快照路径0.4650.36
旧的实时读取路径0.5680.81

人群并没有挤得更紧;重叠对更少,最坏情况也更浅。对比行使用的是浮点数学选项,因为 旧的定点实时读取路径已经不存在,无法再运行。

确定性:重新认定基线,再加以证明

因为结果变了,回放摘要也随之改变。这不是一次 "什么都没变" 的优化,拉取请求里也明确 写了这一点。流程如下:

  1. 先确认这台机器能复现它自己的摘要(同操作系统的多次运行测试为绿)。
  2. 重新生成发生变化的两个 PlayMode 回放基线,并重新盖章两个 canary 回放。 EditMode、道具和回旋镖的基线没有变化。
  3. 全部重跑:PlayMode 摘要 18/18,EditMode 摘要 9/9,两个 canary,以及多段和六个 Boss 的矩阵测试。
  4. 证明新代码与顺序无关:无论 1 个还是 23 个 job 工作线程,无论 Burst 开或关, 摘要都完全相同。

只有 Windows 有回放测试数据,所以基线是在 Windows 上重新生成的。模拟版本保持为 1, 符合项目对发售前模拟改动的规定。

表现层:敌人不再走物理引擎

A/B 变体带来了两项表现层的改动:

  • 敌人离开了 Box2D。 它们的 Rigidbody2D 不再参与模拟。这首先需要审查每一个 trigger 和 collision 回调,确认每个回调要么因为确定性扫描已经处理了而提前返回, 要么只关心玩家。A/B:在上限时 Box2D 每帧约花 2.6 ms,开和关的击杀数完全相同。
  • 屏幕外的普通敌人停止播放动画。 Boss 骨骼保留其设定的模式。

一次把敌人物理和 Animator 都完全关闭的 A/B 测得 61.1 FPS。最终上线的方案略有不同 (Animator 在屏幕外被剔除,而不是被移除),我没有重新测量这个确切组合,所以我认可的 数字是 42.4。

评审

两次独立的对抗性评审,一次针对确定性,一次针对原生/引擎代码,在合并前发现了七个 问题。其中包括:在快照之后被传送的敌人使用了过期的槽位;位于网格边缘之外的范围道具 可能漏掉敌人(现在会回退到旧的扫描);以及快照重建时如果抛出异常会让模拟冻结。七个 问题都在同一个拉取请求中修复了。

还剩什么

  • 流场采样和障碍物推出,在 500 个敌人时约 0.7 ms,仍然是逐个敌人的托管代码。
  • 多次运行之间的噪声约为正负 1.5 ms。有了采集测试,在一台安静的机器上重新测量只需 一行命令。

可推广的经验

先复现,再优化。 一个能在 90 秒内重现问题的测试,比任何单个修复都更有价值,因为 之后的每一个结论都要拿它来检验。

当猜测没有让数字变化时,去测量,而不是再猜一次。 HashSet 是一个合理的想法, 只是针对了错误的开销。计时器在一次运行中就找到了真正的开销。

确定性是要在其中设计的约束,而不是停下来的理由。 从快照做 Jacobi 式读取,既更 容易并行化,又与顺序无关。证据就是 1 个和 23 个工作线程下摘要完全一致。