游戏引擎中的 C++ 内存管理:谁吃掉了帧?
在帧内调用 new 会在分析器中产生约 13 毫秒的性能尖峰;通过引入帧竞技场和对象池,将峰值降低到 7.9 毫秒,并显著平滑了帧时间波动。以下是我们是如何发现并解决该问题的完整过程,包括定位问题、分析分配模式、实现 arena 分配器以及验证性能改进。
分析器中的三毫秒尖峰
去年冬天后期,我在自研引擎中对战斗场景进行性能分析。帧时间平均约为 9 毫秒,但每隔几秒就会出现一次 13 毫秒的帧。屏幕上并未出现明显卡顿,但在 60 FPS 的目标下,这些尖峰是可以感知的。我们首先查看渲染,因为大家通常先看渲染,结果发现渲染时间几乎在每帧之间保持恒定。
罪魁祸首是一行位于 DamageNumberSystem 中的代码:对每一次击中执行一次 new DamageLabel()。在一次每秒 400 次击中的波次中,这意味着每帧会有六七次堆分配。大多数分配耗时 60 ns,但偶尔会出现一次超过 40 µs 的分配,导致整帧性能崩溃。平均值的图表永远显示不出这种情况,因为 400 次分配中有 399 次是廉价的。
并非所有此类尖峰都来源于分配,所以第一步是把原因区分开来。我们在帧内的每一次 operator new 调用上加了计数器,计数器峰值出现的帧与帧时间峰值的帧一一对应。此后就没有争议可言。
这正是游戏引擎中 C++ 内存管理成为独立话题的原因:平均成本不重要,最坏情况才决定成败。在 16.6 ms 的预算内,一个尾部延迟就会错过帧,玩家会在手感上感受到,而你可能在数据上还未发现。
为什么在帧内使用 new/delete 危险
通用分配器并非确定性的。malloc 会遍历空闲链表寻找合适块,获取锁,并且有时会向操作系统请求新页面。在后者情况下,成本会从纳秒级跳升到微秒级,且阅读代码也无法预测会在何帧发生。我们在 Windows 上测得的大多数尖峰恰好对应页面错误。
第二个问题是碎片化。长时间会话中,成千上万的短生命周期、大小不一的分配会在地址空间中留下洞,久而久之,同等大小的请求所需时间会比之前更长。在一次 40 分钟的测试中,同一代码路径的耗时是最初一分钟的两倍。主机平台更糟,因为没有同等的虚拟内存余量。
第三点,在多线程引擎中每个分配点都是隐藏的同步点。当四个 WorkerThread 实例同时分配时,分配器内部的锁会将它们串行化。你在分析器里看不到单一的明显卡顿块,只会看到遍布各处的细小延迟,这也是它常被发现得很晚的原因。
第四点是测量本身。通用分配器的成本从不集中在一个地方,而是分散在数百个调用点上,单个点看起来都不足以引起注意。这就是为什么内存管理问题在你测量整体时才会暴露,而不是盯着某个子系统时出现。
帧竞技场:每帧一次重置
竞技场分配器,也称线性分配器,思路很简单:在启动时预留一大块内存,每次分配时把光标向前移动,且不单独释放。帧结束时将光标复位为零即可。我们的 FrameArena 初始大小为 8 MB,单次分配只需一次对齐加一次加法。我们将其大小设为测得峰值的两倍,并在每帧结束时把已使用字节数写入遥测。
规则是只能把生命周期不超过帧的对象放入竞技场:可见性列表、临时的 DrawCommand 数组、物理宽相位的配对列表、UI 为该帧生成的文字几何体。任何跨帧边界的指针若来源于竞技场,下一帧就会被覆盖,而这种错误是沉默的。
Reset() 不会调用析构函数。因此我们只在竞技场中放入平凡可析构的类型。其他类型需要单独的析构列表,这会抵消一部分速度优势;实践中我们从未走这条路。分配点处的 static_assert 在编译期强制执行该规则。
每个工作线程拥有自己的竞技场。这样甚至把最后的原子操作也从分配路径中移除,线程之间的隐藏争用彻底消失。永不共享的竞技场也不需要锁。四个竞技场共计 32 MB,相比我们在帧时间上获得的提升,这几乎是微不足道的代价。
固定大小对象的对象池
Arena 对于跨帧存活的对象毫无用处。弹丸、音频源和粒子发射器的生命周期为数秒,并以任意顺序死亡。对于这些我们使用池分配器:一个等大小块的数组加上指向空块的空闲链表。由于块大小固定,碎片化根本不再是问题。
内存池的好处在于空闲链表可以直接存放在块内部。空闲块未被使用,因此可以把下一个空闲块的地址写入其前八个字节;无需额外的数据结构,也无需额外的分配。这使得Acquire()和Release()的时间复杂度为常数,几乎没有分支。
我们从不向外部提供原始指针。每个弹丸都有一个 32 位索引加上代数计数器;当弹丸死亡时代数递增,任何其他地方持有的陈旧句柄会自行失效。这把 use‑after‑free 从崩溃转变为可以在造成损害前断言的静默失败。
对于ProjectilePool我们选择了 4096 的容量;在我们测得的最密集波次中峰值使用量为 2,870。当池满时我们不扩容,而是回收最旧的弹丸。帧中扩容会抵消我们引入 arena 和池的初衷。我们在每次发布时重新评估容量,使用峰值数据进行报告。
缓存局部性、对齐和伪共享
更换分配器真正带来的收益不是分配时间,而是对象在内存中相互靠近。池中的 2,870 个弹丸是连续的,更新循环按顺序读取 64 字节的缓存行,硬件预取器随之启动。当我们使用new分散同等数量的弹丸时,缓存未命中率提升了三倍。
保持热点数据小是另一关键。将Projectile结构体从 96 字节缩减到 48 字节后,每行缓存可容纳两个弹丸,更新循环时间从 0.9 ms 降至 0.6 ms。把 mesh 指针和 sound id 等冷数据移到平行数组即可。唯一改变的是字段布局,数学计算保持不变。
伪共享在未测量前是不可见的。我们四个工作线程各自递增自己的计数器,但这些计数器位于同一缓存行上,导致每次写入都会使其他核心的该行失效。使用alignas(64)将它们分离后,在该系统上节省了 1.2 ms,只需一行代码。
在 SIMD 方面,对齐不是可选项。Arena 的Allocate函数接受对齐参数;我们为保存__m128值的缓冲区传入 16,为 AVX 路径传入 32。即使在不崩溃的非对齐访问平台上,我们也测得其运行明显更慢。由于 arena 已经返回对齐块,这一检查只出现在唯一的地方。
std::pmr 足够,还是自定义分配器?
测量摘要:在战斗场景中,平均帧时间从 9.2 ms 降至 7.1 ms,但真正的差异体现在第 99 百分位。峰值从 13.4 ms 降至 7.9 ms,分析器中的周期性峰值完全消失。我们在两台不同机器上重复相同测量,比例保持一致。整个工作用了三周,代码仅两份头文件。
大部分工作可以用std::pmr完成。std::pmr::monotonic_buffer_resource本身就是 arena,unsynchronized_pool_resource本身就是池。如果你使用标准容器,先测量再在其下层插入内存资源即可覆盖大多数需求,几乎无需维护成本。并且它是团队成员都熟悉的标准接口。
必须自行编写自定义分配器的情况很少:热点循环中不想产生虚函数调用、系统中使用 32 位索引而非指针、主机硬件上的特殊内存区域、以及需要自行上报帧预算的遥测。如果这四种情况都不适用,继续使用pmr即可。
如果你只能做一件事,先统计帧内的分配次数。给引擎加一个计数器并打印每帧的分配数量只需半小时工作量,得到的数字通常是团队中没人能猜到的。这个数字越接近零,后续的所有工作就越容易。