中EN
概念页

游戏内性能与内存剖析

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)(仅函数自身):

texclusive(f)=tinclusive(f)−∑c ∈ 直接子函数(f)tinclusive(c)t_{\text{exclusive}}(f) = t_{\text{inclusive}}(f) - \sum_{c\,\in\,\text{直接子函数}(f)} t_{\text{inclusive}}(c)

有些还记录调用次数,以区分「自身很耗时」与「被调用太多次」的函数。

游戏内剖析器通常靠手动插桩。主循环结构足够简单时,可在粗粒度上测各大阶段而不必多想层级:

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__ 拼接生成唯一变量名(书中每个调用都包在单独的 {} 块里,所以示例本身可用)。

嵌套的问题与对策:

  1. 简单做法在更深层嵌套时失效:在 RenderScene() 内再加 PROFILE(),就必须理解调用层级才能正确解读。
  2. 对策之一是让插桩者声明样本间的层级关系,通常在引擎初始化时预先声明样本箱 (sample bin) 及其父箱,如 ProfilerDeclareSampleBin(SID("Visibility"), SID("Rendering"))、Shaders 父为 Rendering、Materials 父为 Shaders、Audio 无父。
  3. 但这只在每个函数只有一个父函数时有效:同一函数可能在调用树中多次出现、父函数各不相同,静态声明的箱会把它的时间全算到某一个父箱里,而实际上应分摊到所有父箱,数据会误导。多数引擎不去解决,因为主要剖析的是只在一个位置被调用的粗粒度函数;用简单引擎内剖析器时要意识到这一限制。
  4. 也可以写能正确处理嵌套 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) 提供上下文信息帮助找到根因。

与其他来源的关系

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 10 章 10.8–10.9 节

相关页面

游戏开发日志与调试绘制Logging & Debug Drawing几乎所有专业团队都会为引擎打造一套调试与开发工具。本页记录其中最基础的两类:日志与追踪(格式化调试输出、详细级别、通道、镜像到文件、崩溃报告)和调试绘制(在游戏世界或屏幕上画线、形状和文字以可视化数学计算)。(卷 I 第 10 章 引言、10.1–10.2 节)游戏开发游戏内调试工具In-Game Debugging Tools各子系统(渲染、动画、碰撞、物理、音频、网络、玩家机制、AI……)都有大量配置项,能在游戏运行时调整而无需改代码、重新编译链接再运行,可大幅节省调试和搭建关卡/玩法的时间。GEA 介绍支撑这一点的几类游戏内工具:游戏内菜单(及 Dear ImGui 等第三方 GUI)、游戏内控制台、调试摄像机与暂停/慢动作、作弊功能、截图与录像。(卷 I 10.3–10.7 节)游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)游戏开发游戏时间线与时钟Game Timelines & Clocks游戏引擎要同时处理多种「时间」:真实时间、游戏时间、动画片段的局部时间、CPU 周期等。GEA 用抽象时间线 (abstract timeline) 统一看待它们,并讨论帧时间 \Delta t 的测量方法、高精度计时器、时间单位与时钟变量类型的取舍,以及断点恢复后的异常 \Delta t 处理。(卷 I 第 8 章 引言、8.4–8.5 节)游戏开发 · UE5UE5 性能分析UE5 ProfilingUE5 性能分析的基本流程是:先用 stat unit 判断瓶颈在游戏线程、渲染线程还是 GPU,再用对应的 stat 组、profilegpu/GPU 统计或 Unreal Insights 的 Timing/Memory 等视图向下钻取;渲染特性(RDG、VSM、TSR)各自提供作用域和可视化。测量时要避开会扭曲数据的设置(调试构建、异步计算、可视化模式)。综合帧时间预算与渲染性能Frame Budget & Rendering Performance本页把 wiki 中分散在 RTR4 第 18~23 章的性能知识串成一条线:一帧的时间预算由最慢的管线阶段决定,先用毫秒帧时间度量并定位瓶颈,再按瓶颈所在位置——CPU/驱动、几何、像素、带宽/内存——选用对应手段;最后从 GPU 硬件机制(占用率、HiZ/early-z、压缩、分块架构)理解这些手段为何有效。各结论的细节与出处见所链接的概念页。图形学渲染瓶颈定位与性能度量Bottleneck Location & Performance Measurement渲染管线中最慢的阶段(瓶颈)决定整体吞吐量。优化就是"找到瓶颈 → 优化到瓶颈转移 → 重复",并让非瓶颈阶段做到与瓶颈同样多的工作以换取画质。定位靠逐阶段增减负载的实验和性能工具;度量要用毫秒帧时间而不是 FPS。两条箴言:了解你的架构 (KNOW YOUR ARCHITECTURE);测量、测量、再测量。游戏开发构建配置与编译优化Build Configurations & Compiler OptimizationC++ 游戏代码从源文件到可执行文件要经过预处理、编译、链接。游戏项目通常不止 Debug/Release 两种构建配置 (build configuration),而是 Debug、Development、Ship,有时再加混合构建;配置越多测试越难。编译器优化分为局部优化和全局优化(链接时优化 LTO、剖析引导优化 PGO),它们让程序更快,但也让调试器“说谎”。所以要学会调试优化过的构建,并用统计式或插桩式性能分析器 (profiler) 找出最耗时的代码。图形学渲染开发与 GPU 调试工具Rendering Development & GPU Debugging Tools渲染引擎的运行时部分只是拼图的一块,还需要一批离线工具来制作内容、优化性能、诊断问题。GEA 介绍了四类:数字内容创作 (DCC) 工具、材质编辑器、资产调理管线,以及 PIX、Razor GPU 这样的 GPU 帧捕获、检查和回放工具(GEA4 卷 II 12.9)。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。游戏开发字符串与唯一标识符Strings & Unique Identifiers字符串在 C/C++ 中只是字符数组,变长意味着要么硬编码长度上限、要么动态分配;比较和复制都是 O(n)。引擎内部大量用字符串做资源名和对象 ID,处理不当会严重拖慢游戏。GEA 的建议:运行时代码尽量避免字符串类,若用则了解其成本并按引用传递;路径类是值得专门做的例外;对象与资产的唯一标识用哈希字符串 ID(string id),兼得字符串的可读性与整数的比较速度。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原