游戏内性能与内存剖析
In-Game Profiling & Memory Tracking
游戏是实时系统,要保持 30 或 60 FPS,同时受目标硬件内存限制。第三方剖析工具有局限、在主机上未必可用,所以许多引擎自带游戏内剖析器(手动插桩的代码块计时、层级关系、调用次数、导出 CSV)和内存统计与泄漏检测工具。GEA 讲解它们的实现要点和常见限制。(卷 I 10.8–10.9 节)
游戏内剖析器(卷 I 10.8)
按第 2 章的 80/20 法则,大部分代码不需要优化,唯一知道哪里要优化的办法是测量。游戏内剖析器通常让程序员给要计时的代码块加注解并起可读的名字,用 CPU 高精度计时器(见 游戏时间线与时钟)测每块的执行时间存在内存中,再用 HUD 实时显示,常同时给出原始周期数、微秒耗时和占整帧的百分比。Naughty Dog 引擎提供可逐层展开具体函数调用的层级显示,以及时间线模式,显示一帧内各操作在 PS4 七个 CPU 核上的确切执行时刻(《神秘海域:失落的遗产》;作业层面的可视化见 作业系统)。
层级剖析(卷 I 10.8.1)
命令式程序天然是层级的:例如 a() 调 b()、c(),b() 调 d()、e()、f()。调试时的调用栈只是这棵树的一个快照:从当前执行函数到根的路径。C/C++ 中根通常是 main() 或 WinMain(),但严格说它们被 C 运行时 (CRT) 的启动函数调用,后者才是真正的根;在 e() 设断点时调用栈为 e() ← b() ← a() ← main() ← _crt_startup()。
包含时间与独占时间(10.8.1.1):测一个函数的时间会包含它所有子孙函数的时间,解读数据必须考虑调用层级。很多商业剖析器能自动插桩每个函数,测出包含时间 (inclusive)(含所有子函数)和独占时间 (exclusive)(仅函数自身):
有些还记录调用次数,以区分「自身很耗时」与「被调用太多次」的函数。
游戏内剖析器通常靠手动插桩。主循环结构足够简单时,可在粗粒度上测各大阶段而不必多想层级:
while (!quitGame) {
{ PROFILE(SID("Poll Joypad")); PollJoypad(); }
{ PROFILE(SID("Game Object Update")); UpdateGameObjects(); }
{ PROFILE(SID("Animation")); UpdateAllAnimations(); }
// ... Joint Post-Processing, Collision, Physics, Animation Finaling, Cameras ...
{ PROFILE(SID("Rendering")); RenderScene(); }
{ PROFILE(SID("Audio")); UpdateAudio(); }
}
PROFILE() 宏通常实现为一个 RAII 类:构造时读计时器开始计时,析构时停止并以给定名字记录样本,借助 C++ 对象进出作用域自动构造析构,只计时所在代码块:
struct AutoProfile {
AutoProfile(const char* name) { m_name = name; m_startTime = QueryPerformanceCounter(); }
~AutoProfile() {
std::int64_t endTime = QueryPerformanceCounter();
std::int64_t elapsedTime = endTime - m_startTime;
g_profileManager.storeSample(m_name, elapsedTime);
}
const char* m_name; std::int64_t m_startTime;
};
#define PROFILE(name) AutoProfile p(name)
(SID() 为字符串 id,见 字符串与唯一标识符。)
原书问题(示例代码):
- Win32 的
QueryPerformanceCounter签名是BOOL QueryPerformanceCounter(LARGE_INTEGER* lpPerformanceCount),计数通过输出参数返回,返回值只表示成功与否(MS Learn「QueryPerformanceCounter function (profileapi.h)」),书中m_startTime = QueryPerformanceCounter();不能编译,应写成LARGE_INTEGER t; QueryPerformanceCounter(&t); m_startTime = t.QuadPart;;所得为计数而非秒,换算需除以QueryPerformanceFrequency的结果。AutoProfile构造函数接受const char*,而用法是PROFILE(SID("..."))传入字符串 id,二者类型不一致;按书中用法参数应为StringId。- 宏
#define PROFILE(name) AutoProfile p(name)固定变量名p,同一作用域内写两次会重定义;实际实现常用__LINE__拼接生成唯一变量名(书中每个调用都包在单独的{}块里,所以示例本身可用)。
嵌套的问题与对策:
- 简单做法在更深层嵌套时失效:在
RenderScene()内再加PROFILE(),就必须理解调用层级才能正确解读。 - 对策之一是让插桩者声明样本间的层级关系,通常在引擎初始化时预先声明样本箱 (sample bin) 及其父箱,如
ProfilerDeclareSampleBin(SID("Visibility"), SID("Rendering"))、Shaders父为Rendering、Materials父为Shaders、Audio无父。 - 但这只在每个函数只有一个父函数时有效:同一函数可能在调用树中多次出现、父函数各不相同,静态声明的箱会把它的时间全算到某一个父箱里,而实际上应分摊到所有父箱,数据会误导。多数引擎不去解决,因为主要剖析的是只在一个位置被调用的粗粒度函数;用简单引擎内剖析器时要意识到这一限制。
- 也可以写能正确处理嵌套
AutoProfile的完整层级剖析器——是否值得投入工程时间,是引擎设计中众多取舍之一。
调用次数:某函数测得 2 ms,要知道是单次 2 ms 还是一帧调用了 1000 次共 2 ms。实现很简单:每收到一个样本就给计数器加一,每帧开始清零。
导出到 Excel(卷 I 10.8.2)
有的引擎允许把剖析数据导出为文本以便事后分析,作者认为 CSV 最好,可直接载入 Excel 任意处理。作者曾为《荣誉勋章:血战太平洋》引擎写过这样的导出器:每列对应一个注解代码块,每行是一帧的样本,第一列为帧号、第二列为以秒计的实际游戏时间;团队可以绘制性能随时间的变化、确定每帧实际耗时,并用简单公式算帧率和耗时百分比。
游戏内内存统计与泄漏检测(卷 I 10.9)
PC 有完善的虚拟内存管理器,受限最少,但仍受发行商承诺、包装上标明的最低配置 (min spec) 机器约束。所以多数引擎实现自定义内存追踪工具,查看各子系统用了多少内存、是否泄漏(分配了从不释放),以便有依据地削减内存以适配目标主机或 PC。
只包装 malloc()/free() 或 new/delete 远远不够,原因有三:
| 难点 | 说明 |
|---|---|
| 无法控制别人代码的分配行为 | 除非 OS、驱动和引擎全自己写,否则总会链接第三方库;好库提供内存分配钩子可替换分配器,但有的没有。仔细挑选第三方库通常还是能做到全面追踪 |
| 内存有不同种类 | PC 有主内存和显存(显卡上的内存,主要放几何与纹理);DirectX 等图形 API 对开发者隐藏了显存的分配和使用细节,几乎无法追踪。主机上反而容易些,因为往往要自己写显存管理器——比用 DirectX 难,但完全清楚发生了什么。注:这描述的是 D3D11 及更早的驱动托管模型;D3D12/Vulkan 等显式 API 由应用自己创建堆、子分配资源(如 ID3D12Device::CreateHeap、vkAllocateMemory),并可用 IDXGIAdapter3::QueryVideoMemoryInfo 查询显存预算与用量(MS Learn),PC 上的显存追踪已比书中所说容易得多 |
| 分配器有不同种类 | 如 Naughty Dog 引擎有:通用全局堆、游戏对象生成/销毁专用堆、游玩中流式载入数据的关卡加载堆、单帧分配的栈分配器(每帧自动清空)、显存分配器、仅用于发售版不需要的分配的调试堆。每个分配器启动时抓一大块内存自行管理,追踪 new/delete 只会看到这六个分配器各一次 new;要有用的信息必须追踪每个分配器内部的分配(分配器设计见 自定义内存分配器) |
专业团队会投入大量精力打造准确详细的内存追踪工具,输出多种形式:一段时间内所有分配的详细转储;每个分配器或每个游戏系统的高水位线 (high water mark),即所需物理内存的最大值;运行时的内存 HUD(表格或图形,书中图 10.15、10.16)。
内存不足时的提示:PC 开发机内存多于最低配置,主机开发套件内存多于零售机,所以技术上已超出零售限制时游戏仍能继续运行;此时引擎可显示「内存不足——此关卡无法在零售机上运行」之类的消息。其他让问题尽早、醒目暴露的例子:
- 模型加载失败时,在原本物体所在的世界位置悬浮一行亮红色 3D 文字;
- 纹理加载失败时用明显不属于最终游戏的难看粉色纹理绘制;
- 动画加载失败时让角色摆出表示缺动画的特殊(可能滑稽的)姿势,并在头顶显示缺失资产名。
好的内存分析工具的关键:(a) 信息准确;(b) 呈现方便、让问题一目了然;(c) 提供上下文信息帮助找到根因。
与其他来源的关系
- UE5 性能分析 记录 UE5 自带的剖析工具;帧时间预算与渲染性能 与 渲染瓶颈定位与性能度量 从渲染角度讨论预算与测量。GEA 本节侧重自研引擎内剖析器的实现方式与局限(手动插桩、静态样本箱的多父问题)。
- 第 2 章的第三方剖析工具与 80/20 法则见 构建配置与编译优化。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 10 章 10.8–10.9 节