游戏引擎学习第254天:重新启用性能分析

运行游戏并尝试让性能分析系统恢复部分功能

我们现在的调试系统这几天基本整理得差不多了,因此我们打算开始清理一些功能,比如目前虽然已经在收集性能分析数据,但我们没有办法查看或有效利用这些信息。今天的计划可能会围绕这方面展开:要么开始真正构建性能分析器,要么尝试一些更实际的整合工作,看看它将如何在实际使用中运行。

我们也可能会处理一下绘制界面方面的问题,因为当前绘制逻辑还比较凌乱,布局也比较混乱,显示效果还不理想,像边框和控件都没画好。

现在我们运行游戏,能看到基础的窗口系统已经可以工作,也可以调整窗口大小。但是我们目前并没有展示任何性能分析数据,而这是我们接下来的重点。

我们希望展示两类性能分析信息:一是内存占用情况,二是耗时分析。其中耗时是最重要的,我们希望能够在调试系统中显示性能分析信息。

我们想要的性能信息主要分为两种:

  1. 高层次的性能概览:这是一个宏观视角,用来展示每帧大致耗时,包括渲染、模拟、输入处理等各个部分所占用的时间比例。这样可以帮助我们确保每部分运行时间大致正常,不会出现某一部分突然耗时激增的情况。

  2. 详细的代码性能分析:这是用于代码优化的详细信息。比如当我们发现某段代码运行缓慢时,需要能插入某种标记或调用,以便具体查看那段代码的性能表现。这种分析是为了帮助我们理解并优化特定代码段。

通常这两种信息不会同时需要。我们希望能长期在屏幕上保留一份高层次的性能概览,而当需要优化具体代码时再调用更详细的分析工具。

所以现在我们要做的,大概就是继续构建一个"概览型性能分析器",类似于传统性能分析器那种列出函数列表、显示函数自身耗时及其子函数耗时、调用次数等信息的方式。这种结构化的展示方式可以让我们快速看出哪部分代码占用时间最多,是一个合理的起点。

此外,传统分析器通常只支持单线程分析,而我们现在的程序是多线程的,因此可能也要考虑用条形图的方式展示不同线程中的事件重叠情况,以便更直观地看到多线程任务的时间关系。

同时我们也注意到当前的 UI 交互还有很多粗糙的地方,比如窗口可以被拖动,但交互手感不好,内容很难看清,缺乏背景遮挡或分隔,使得一些文字在浅色背景下根本看不清。因此也需要加些界面上的遮罩或暗化处理,让调试界面更容易使用。

总的来说,我们现在要解决的是两个问题:一是性能分析系统的构建与可视化,二是界面交互与可读性的提升。从当前情况来看,性能分析是最迫切的问题,所以我们会优先处理这一块,接着再清理其他部分。我们需要回顾之前已经写好的部分,决定接下来要怎样把这套分析系统做好。

game_debug.cpp:重新熟悉 CollateDebugRecords 函数

当前的调试系统中,我们在 CollateDebugRecords 的代码部分可以看到性能分析的残留结构。在那里,我们记录了每一帧的标记(Frame Marker),并且记录了与这些标记关联的 wall-clock 时间(实际经过的时间),这为后续的性能分析提供了基础数据。

我们还保留了 BEGIN_BLOCKEND_BLOCK 这样的事件,它们之间可以配对,以确定代码块的执行时间。系统会查找这些配对的开始与结束事件,并将其构造成时间段(Span)用于分析。不过现在的处理方式还有些问题:

  • 目前并没有很好地处理那些跨帧的 block(即在一帧开始,在另一帧结束的代码块),这些情况被忽略了,未来需要加以修复。
  • 对于匹配的 block,我们现在只记录了部分特定名称的 block,这是临时做法,不够通用。

在整理这些 debug 记录时,每帧结束都会遍历一次所有事件,然后尝试将它们整理成我们需要的格式。这个整理过程让我们思考是否有更好的方式来构建性能分析结构。

目前对事件的处理方式比较繁琐,尤其是对 BEGIN_BLOCKEND_BLOCK 的特殊处理。我们考虑是否可以换个思路,把这些 block 也当作一种"事件"来处理,就像对待普通的数据块一样,而不是单独去匹配成一个 span 区间。也就是说,可以把 begin 和 end block 处理成统一结构,每次记录一个事件,而不是在整理阶段再去做匹配和转换。

这种做法看起来更简单,更统一。每个 block 的记录都会变成一个事件,并关联到某个调试元素(debug element),不再需要在事后分析中单独处理。只要在记录的时候,能明确知道这个事件属于哪个元素,就可以建立起完整的事件流。

这种结构的好处在于:

  • 所有事件都可以走同一套系统,降低逻辑复杂度;
  • 不需要在事后再花大量逻辑去匹配 BEGIN_BLOCKEND_BLOCK
  • 可以更灵活地记录事件,不再依赖于是否恰好有匹配的 begin 和 end。

当然,这种方式会加快调试数据的占用,我们会更快地耗尽调试数据的存储空间。但调试阶段并不在意内存消耗,我们完全可以为调试系统分配更大的存储空间,比如 16GB 的内存,足够记录几千帧的详细信息。如果只需要回看几帧,也不会有任何问题。

因此,更倾向于采用这种事件式的统一处理方式,让所有事件都走一样的路径进行存储和回放,把调试系统变得更通用和高效。我们将对 BEGIN_BLOCKEND_BLOCK 进行重构,统一到一个记录事件的系统中。

我们目前认为,把 BEGIN_BLOCKEND_BLOCK 统一为通用事件来处理是一个不错的想法。虽然还不知道实际实施时是否会暴露出问题,但我们决定先按这个思路尝试一下,后续如果发现问题再做调整。

接下来准备开始清理 StoreEvent 部分的代码,着手进行调整。不过在这个过程中突然遇到一个奇怪的情况------无法切换到编辑模式,表现为按键操作不正常。

具体来说,发现 Shift 键像是被锁定了一样,导致输入状态异常。这种情况以前从未遇到过,无论是在这台机器上还是其他机器上。初步怀疑可能是键盘的问题。

当前使用的是较老的 Das Keyboard 3 型号,以前也曾在另一块 Das Keyboard 上遇到过类似"按键卡住"的问题。而其他地方都已经更换成 Razer 键盘,只是还没给这台开发机配一把新的。考虑到以往的经历,这次的问题可能也是这块旧键盘本身存在物理故障或者是接触问题。

虽然这是个小插曲,但还是提醒了我们旧设备可能带来的问题,后续有必要更换这块键盘以避免干扰正常开发流程。

game_debug.cpp:简化 CollateDebugRecords 并修改传递给 StoreEvent 的内容

我们决定将 BEGIN_BLOCKEND_BLOCK 事件直接存储到对应的元素中,不再进行之前复杂的匹配和处理。每当遇到一个 BEGIN_BLOCKEND_BLOCK,就把这个事件存储到其对应的 debug 元素中。由于这些事件在创建元素时就关联在一起,因此可以简化处理逻辑,不再关心 frame index 是否匹配,只要配对关系成立,就进行存储。

这样,每个元素中就会有一个以"进入-退出-进入-退出"的模式组织的事件链表,代表了该代码块在运行过程中的每一次进入与退出。这个结构能够完整记录每次调用的时机和持续时间。

不过,这种方式虽然能记录"某段代码被调用了多少次,每次持续了多久",但仍存在一个问题:我们并不知道这些调用是被谁触发的。换句话说,缺少了调用关系的信息。要知道调用关系,我们只能依赖事件的时间顺序进行推断,比如通过分析时间戳判断哪个事件嵌套在另一个事件内部,但这种方式不够直接,也容易出错。

为了解决这个问题,我们考虑在存储事件的时候,加入一个额外字段,用来记录"当前事件的父级元素",即这个代码块是被哪个上层代码块调用的。这样就能在分析调用关系时更加明确,能够清晰地追踪每个调用链上的父子层级。

简而言之,我们的思路是:

  1. 简化事件处理流程,遇到 BEGIN_BLOCKEND_BLOCK 就立即记录,不再依赖复杂匹配。
  2. 每个元素维护一组"进入-退出"的事件序列,完整还原每次执行时长。
  3. 为每个事件记录其"父级调用者",明确调用关系,方便之后构建调用树或进行性能分析。
  4. 删除原有多余的逻辑和临时代码,进一步简化结构。

这样处理之后,性能分析数据将更加结构化、可视化效果也更容易做出明确的父子层级展示,同时提升了系统的灵活性和可维护性。

黑板:调用归因(Call Attribution)

我们设计了一个用于调试的结构体(debug element),这个结构体记录了每一次函数调用的信息。我们能够掌握的信息包括:

  • 每个函数被调用的次数;
  • 每次调用持续的时间;
  • 每次调用发生在哪一帧(frame)上。

这些数据都存储在一个类似链表的结构中,我们已经具备了相应的采样记录能力。

不过,目前仍有一个重要的信息缺失:调用者是谁 。例如,函数 foo 可能既被函数 bar 调用,也可能被函数 boz 调用。如果我们在调试过程中发现 foo 被调用了多次,并且我们已经记录了调用的时间和帧位置,但我们无法区分 哪一次调用是来自 bar,哪一次是来自 boz。这对于后续分析调用关系、定位性能瓶颈会造成不便。

为了能够获得更精细的调用上下文分析结果,比如"函数 bar 导致了 foo 花费了多少时间"、"函数 boz 又占用了多少",我们希望在调试信息中增加一个额外的指针或引用,用来指向调用该函数的上层函数对应的 debug element。这样我们就能从 foo 的调用记录中追溯到具体的调用来源。

因此,建议在事件存储结构中,加入一个额外的指针字段,用来标记"谁"调用了这个函数调用。这种方式在实际使用中会提供更高的灵活性和更丰富的分析能力,比如可以选择性地启用或禁用这部分引用信息,视具体分析需求而定。

这类优化将在调试和性能分析工具(如 game profiling 系统)中提供更高质量的追踪数据。我们可以进一步查看事件存储结构的定义(如 game 的 StoredEvent)来确认该指针的可添加位置。

game_debug.h:记下将调用归因数据存储到 debug_stored_event 的计划

在当前的事件记录机制中,我们保存了事件的帧索引和事件的类型。但如果能额外存储一个信息,例如事件的"父级调用者",会对后续分析非常有帮助。

虽然我们暂时不需要立即添加这个信息,但我们意识到这是一个重要的点,因为我们只在当前的相关性分析过程中才能知道每个事件的调用者是谁,也就是说,只有在执行事件匹配遍历(correlation pass)的时候,才能知道一个事件是被谁调用的。

这种信息之所以只能在特定阶段获得,是因为我们是通过遍历事件数组并按照顺序配对"打开"和"关闭"事件,推断出哪个事件是谁的父级。在这个阶段我们掌握了所有调用关系。但这个数组在分析完成之后就会被释放,也就是说,绘制图形或回放调试信息的时候,我们再也无法获得这些调用链信息。因为原始事件流不再保留,我们也没有记录每个事件的父级信息。

因此,如果我们希望后期还原调用关系,就需要在事件存储结构中预先记录好父级指针,否则信息就会丢失。

也考虑过一种替代方案:直接写入一个持续增长的事件流,不做任何中间结构保存,而是通过向后查找的方式追踪调用者。但这种方式可能效率较低,尤其是在处理大体量事件时会产生很高的性能开销。因此,引入中间结构并定期进行处理,是为了在性能和可用性之间取得平衡。

总体来说,当前的设计思路是:仅处理当前帧的新事件,避免每次绘制或分析都去遍历整个事件数组。这样既节省计算资源,又可以保留需要的重要数据。最终目标是减少处理开销,同时保留必要的调试信息。我们所做的权衡,是在保留调试所需信息的前提下,尽量减少无效数据扫描和处理的成本。

game_debug.cpp:实现调用归因

我们开始重新编译代码,并准备进入分析和实现性能分析(profiling)相关功能的阶段。接下来的目标是让绘制调试菜单中的性能分析部分(profile UI)正常工作。

在完成事件归并(collation)之后,绘制完成各个调试元素(debug elements)之后,我们会进入主调试菜单的绘制过程。在这一阶段,我们会调用 DebugDrawMainMenu 相关的函数,并在内部进一步处理性能分析的绘制工作。关键点在于:我们开始进入实际将性能分析信息可视化的流程。

调试系统中包含多种类型的元素,其中之一是 CounterThreadList,这是我们想要首先处理的部分。我们开始分析它的作用和使用方式。

接下来查看了与 CounterThreadList 相关的结构定义和调用位置。我们注意到在某些位置它被用于打印信息,但目前我们还不完全理解这些结构的具体意义。因此,暂时将相关部分注释掉,以便聚焦于理解其实际作用和分析在哪些位置被使用。

当前已知的是:

  • CounterThreadListCounterFunctionList 都是性能分析中用于统计信息的数据结构;
  • 它们可能被用于绘制与线程或函数调用相关的性能图表或统计视图;
  • 在实际绘制逻辑中,它们作为调试元素的一部分存在,在调试菜单中被绘制出来;
  • 我们将逐步理清它们的结构、用途以及如何通过它们来实现可视化功能。

目前的策略是:从分析调用路径和绘制流程开始,逐步实现或修复 CounterThreadList 等数据的可视化输出,为后续的性能分析提供可靠的展示工具。通过理解其用途和代码流转方式,为调试系统构建更完善的分析界面奠定基础。

game_debug.cpp:将 DEBUGDrawElementDEBUGDrawEvent 合并为一个函数

目前代码中存在两个看起来类似的函数:DebugDrawElementDebugDrawEvent。回顾后发现,它们实际上没有明显区别,其存在只是由于早期对如何划分状态存储方式还存在疑问而做出的结构安排。

随着代码演进,这种分离已不再必要,因此决定将两个函数合并成一个统一的函数,即只保留 DebugDrawElement。原来的 DebugDrawEvent 中的逻辑和代码已经与 DebugDrawElement 几乎完全一致,没有保留的意义。

接下来,移除了多余的函数定义,并将可能遗留的逻辑暂时整合到当前保留的函数中,以防后续还需要参考。

在新保留的 DebugDrawElement 函数中,实际上只依赖一个内容:当前正在绘制的 StoredEvent(也就是存储的调试事件)。原来还传递了一个 view 参数用于视图计算,但从逻辑看出,这个参数并未真正使用,因此也一并省略,简化了函数签名和使用方式。

最后,调整函数中处理 element 的调用方式,使逻辑更加直观一致。将原来分散的事件绘制逻辑提取、合并,保留核心操作,并确认调用的是最近一个事件的数据作为绘制目标。

总体调整结果如下:

  • 合并 DebugDrawElementDebugDrawEvent,只保留前者;
  • 移除冗余代码和无效参数(如视图);
  • 明确绘制依赖的数据结构为最近的 StoredEvent
  • 统一命名和调用方式,减少重复,简化整体结构。

这次整理让绘制调试元素的流程更加清晰,也为后续功能拓展或维护降低了复杂度。

黑板:草拟概览性能分析器

当前的任务是规划我们希望构建的性能分析(profile)视图类型。我们决定从最基本的一种开始------概览视图(overview),这也是之前已经实现过的一种形式。

这类概览视图的目标是以线程为单位,展示在一段时间内各线程的运行活动。可视化方式是以"线程"作为纵向的分类(垂直方向每一行代表一个线程),而以"时间"作为横向的刻度(从左到右表示时间流逝)。在每个线程的时间线上,绘制不同函数的执行区间,通过方块或条形的方式展示。

具体形式如下:

  • 纵向表示不同线程(lanes)
  • 横向表示时间流逝(clock ticks)
  • 在每个线程时间线上,绘制函数调用的执行区间,显示函数名
  • 基于事件中的 open/close 块数据来确定函数的执行时间段,并以此绘制可视化块

目前我们在数据结构上还没有一个完全的"层次结构"(hierarchy)来呈现调用关系,这使得要构建一个明确的调用图(flow graph)仍有一定难度。因此,在开始绘制前,我们意识到也许可以在可视化过程中临时构建某种形式的调用结构或数据索引,以便更有效地布局和展示各个函数调用。

此外,随着实际实现推进,我们也会逐步明确更多细节,包括:

  • 如何更清晰地区分线程和函数的显示区域;
  • 是否需要对函数调用深度做视觉上的嵌套处理;
  • 如何提升渲染性能,避免处理庞大的事件数组时出现效率问题。

这套概览视图的实现将作为性能分析系统的基础,为之后更复杂的视图(比如调用图、按函数分类统计等)提供出发点。我们计划从这个最直接、最易理解的展示方式入手,一步步建立起完整的性能可视化工具体系。

game_debug_interface.h:开始实现 ThreadIntervalGraph

当前的目标是为性能分析系统实现一种新的可视化类型,我们将其命名为 线程区间图(Thread Interval Graph),它专注于展示各线程在一段时间内执行函数的区间分布。

我们定义这种视图类型为一种"更深层"的视图,用于更细致地查看线程中的函数执行情况。设计的核心思路如下:


线程区间图的基本理念

  • 每个线程有一条独立的时间线;
  • 在线上绘制函数调用区间(使用开始时间和结束时间);
  • 显示函数名称;
  • 横轴为时间刻度;
  • 纵轴为线程分布;
  • 支持筛选,仅显示特定函数的调用区间(如果传入特定函数名);
  • 如果传入空字符串,则显示所有函数。

实现规划

我们在现有的 debug_profile 模块中加入这个新的视图类型:

  • 定义新的视图类型名为 DebugView_ThreadIntervalGraph
  • 注册该类型,使其能够在调试界面中被调用;
  • 提供函数名参数:作为过滤器,决定是否只显示某一个函数;
  • 在调试菜单中,默认只显示 game_updaterender 等游戏逻辑代码,而非平台层逻辑;
  • 将这个视图作为一个基础结构添加进调试系统,使得用户可以选择它并查看每帧中不同线程的函数执行情况;
  • 可以考虑在视图中显示每个执行块的句柄信息或 open block 数据,便于追踪来源。

关于部分旧代码的处理

之前存在一些与函数列表、计数器等有关的调试逻辑,但这些代码目前不符合新的需求,也不再适用,因此可以忽略或清理。同时,对于这部分线程区间图的核心绘制逻辑,目前还未开始正式实现,但已经完成了结构的初步搭建,为后续图形绘制和逻辑遍历做好了准备。


当前阶段目标总结

  • 明确了我们要实现一种新的可视化图;
  • 将其纳入已有的调试系统框架中;
  • 提供了参数支持(函数名过滤);
  • 定位其适用范围(主要关注游戏逻辑线程);
  • 清理了无关代码,为后续实现留出干净入口。

接下来的工作将聚焦于如何利用记录的函数调用事件,绘制出清晰的、可交互的线程区间图形界面。

(void) 是消除一些clangd 的警告

运行游戏并看到 END_BLOCK 被打印出来β

段错误吗?

当前我们已经移除了冗余的函数,使代码结构变得更清晰,这是一个积极的进展。接下来我们观察到了调试系统中一些异常行为,并对其进行了初步分析。


当前调试行为及异常现象

  • 我们注意到调试信息中正在打印 endblock 数据块,这引起了我们的注意;
  • 初步判断这可能是由于我们在调用 store_event 的时候自动触发的行为;
  • 然而目前这块数据的打印看起来十分奇怪,有些抖动或跳变现象;
  • 在调试暂停状态下,系统本不应该更新,但依然发生了刷新行为,这非常异常,推测可能是某处逻辑未正确检测暂停状态;
  • 这一行为非常可疑,需要后续进一步调查其根本原因。

内存区域耗尽测试

  • 此外,我们有意识地让内存分配区域(arena)耗尽以观察系统的反应;
  • 系统提示大约还有 30 秒内存空间,随后逐步减少至 5MB、4MB......最终耗尽;
  • 在耗尽时我们特别留意系统是否能正常处理该情况;
  • 最终在内存耗尽时出现了某些"奇怪的数据"或行为(可能是图像/调试数据显示异常),这同样是值得关注的问题;
  • 除此之外,系统大体上在其他方面保持了正常运行。

总结与下一步方向

  • 成功移除了不必要的函数,提高了整体代码可读性;
  • 发现 store_event 可能引发了不希望发生的数据输出行为;
  • 注意到调试系统在暂停状态下依然有"跳变"或"闪动",违反预期;
  • 内存区域耗尽时,系统虽未崩溃,但出现了可疑的数据图像,应进一步观察内存管理与调试渲染逻辑;
  • 下一步需要集中精力定位调试系统在暂停状态下仍更新的问题,并进一步分析 arena 耗尽后的容错机制是否健壮。

这些观察结果为调试系统的健壮性提供了宝贵信息,有助于后续优化和稳定性提升。

game_debug.cpp:注释掉 StoreEvent 的调用,以确认 32MiB 的内存不足

我们对当前的调试系统进行了一些内存相关的测试,并得出了几个关键结论和后续改进方向:


内存占用测试及现象分析

  • 使用 begin_blockend_block 进行测试,初始情况下不存储事件时系统运行良好;
  • 只存储其中一个事件时,内存占用减半,依然表现正常;
  • 同时存储两个事件后内存开始迅速耗尽;
  • 初始设置的 32MB 调试存储空间完全不足以容纳当前记录的事件数量,内存被快速"吃掉";
  • 当只存储一个事件时运行正常,说明系统逻辑本身没有问题,只是事件数量太多;
  • 临时将调试存储空间设置为一个更大的值后,系统能够稳定记录完整的一帧数据;
  • 说明只要内存充足,系统的事件记录机制是有效的;

对事件量的初步判断与优化方向

  • 当前调试系统在每帧记录的事件数量过多,意味着某些地方设置了过多的区域(zone)或嵌套;
  • 尽管增大内存可以缓解问题,但并不能从根本上解决事件过多的问题;
  • 可能需要对记录策略进行优化,例如合并重复的事件、限制最大嵌套层级、仅记录关键事件等;
  • 系统虽然能记录下完整帧,但每次刷新内存的速度极快,依旧不够理想,后续需做进一步内存优化设计;

调试层级显示的修正需求

  • 当前的调试层级(hierarchy)显示中出现了一些不应当出现在其中的元素;
  • 推测是某些调试元素(debug elements)被错误地加入到了层级结构中;
  • 为了更好地组织视图,需要将这类不应出现在主层级中的元素排除出去;
  • 后续需要调整处理逻辑,区分哪些是用于显示层级的核心事件,哪些是辅助性事件或不应显示的内部记录项;
  • 这将改善调试视图的清晰度和结构逻辑,使调试工具更易于阅读和分析。

总结

  • 当前调试系统基本运行稳定,但内存使用率极高;
  • 临时扩展内存缓解问题,但需从源头优化事件记录策略;
  • 调试显示结构存在错误归类问题,需修正层级渲染逻辑;
  • 整体机制可用,后续工作重心在于压缩事件数据、提升可读性、控制内存消耗。

奇怪

为什么只显示这一行

game_debug.cpp:修改 GetElementFromEvent,加入参数 b32 CreateHierarchy,以便根据条件调用 GetGroupForHierarchicalName

我们当前遇到的问题是在处理调试事件时,有一类特殊的元素(Hierarchy类型的 debug element)不应当参与正常的层级结构分组处理。它们应被统一归入一个固定的分组中,而不是通过一般的层级规则进行归类。为了解决这个问题,我们需要对事件解析和分组逻辑进行调整。


当前问题描述

  • 有一批特定的调试事件不应参与层级结构的归类;
  • 这类事件应始终被归入一个固定的父组,而不是根据名称层级进行分组;
  • 当前 get_element_from_event 函数会默认通过 get_group_for_hierarchical_name 来查找或创建层级分组;
  • 这就导致了我们不希望出现的自动归类行为;

解决思路与设计调整

我们可以在 get_element_from_event 函数中引入一个额外的参数或机制来控制是否跳过层级结构的查找过程:

新增标志控制行为
  • 引入一个 add_directly_to_parent 的布尔标志,用于控制是否直接将该事件归入指定父组;
  • 当该标志为 true 时,跳过 get_group_for_hierarchical_name 的调用,直接使用提供的 parent_group
  • 否则,执行原有的层级结构归类逻辑;
函数内部逻辑变更示例
c 复制代码
if (add_directly_to_parent) {
    // 直接使用传入的 parent,不进行层级归类
    assign_to_group(parent_group);
} else {
    // 按照名称层级查找或创建分组
    group = get_group_for_hierarchical_name(name);
    assign_to_group(group);
}

理由与好处

  • 避免了非层级元素被错误地纳入分组结构;
  • 允许更灵活地控制调试元素的归类行为;
  • 保留现有层级分组逻辑的完整性;
  • 简化了这些特殊元素的调试显示逻辑;

未来可优化方向

  • 可以进一步将事件分为普通层级事件与特殊事件两个通路处理;
  • 根据事件类型自动决定是否走"扁平化分组"逻辑;
  • 让调试系统本身具有更清晰的分类能力,以减少每次手动判断与传参的负担;

小结

我们现在通过引入一个控制标志 add_directly_to_parent,成功区分了哪些事件需要参与层级结构,哪些事件应统一归为某一组。这样一来,调试显示逻辑就更加清晰且可控,避免不必要的混乱结构,同时为后续调试系统的维护和优化打下了良好的基础。

game_debug.cpp:引入 ProfileGroup 的概念

我们现在的目标是让某些调试事件(例如 Hierarchy类型的调试元素)不被加入层级结构中,而是直接存入一个专门的"分析块容器"中,以便更好地管理和展示这些不需要参与常规层级结构的调试数据。


当前处理逻辑概述

  1. 在调用 get_element_from_event 函数时,我们决定通过传入参数控制是否允许该元素进入层级结构;
  2. 对于某些不希望层级化的事件(如 Hierarchy类型),我们在调用时将 create_hierarchy 参数设置为 false
  3. 这类元素会被统一放入一个专门的容器,例如 profile_group,这个容器位于顶层节点 profile_root 之下;
  4. profile_group 是一个我们新增的变量,需要将其加入调试状态结构中(debug_state);
  5. 目前因为函数 get_element_from_event 被多重声明(重载),而 create_hierarchy 参数只出现在其中一个定义中,导致调用时不明确,编译器报出调用歧义错误;
  6. 进一步发现问题是因为我们在头文件中对 get_element_from_event 的声明不完整,缺少了新的参数,需要更新声明以匹配实现;

操作细节与调整

  • get_element_from_event 函数增加的新参数 create_hierarchy 正确声明在头文件中,消除重载歧义;
  • 确保只有一处函数声明,并与实际实现一致;
  • debug_state 中添加新的 profile_group 字段,用于保存这类特殊事件的容器;
  • 在创建变量组时(create_variable_group),指定对应的大小为 78,并绑定到新的容器中;
  • 编译后检查 get_element_from_event 调用是否正确解析,是否成功地将事件放入非层级化结构中;

整体架构演变意义

  • 实现了调试系统中"层级事件"和"非层级事件"的并存机制;
  • 提高事件展示与管理的灵活性;
  • 通过简单标志参数控制是否参与层级化归类,逻辑清晰、易于维护;
  • 建立了专属的 profile_group 容器,未来可以扩展更多仅限于该组的展示和处理方式;

小结

通过对 get_element_from_event 函数添加控制参数并修正声明,同时引入新的分析容器 profile_group,我们成功实现了将特定调试事件排除在默认层级结构之外的逻辑。这一改进提升了调试工具对不同类型事件的表达能力,为后续的可视化和调试体验打下良好基础。

运行游戏并查看调试可视化效果

我们现在的处理逻辑是:虽然仍然在记录所有调试信息,但这些信息已经被存储到了一个不可见的"后台区域"中------这是我们想要的效果。


当前目标与思路

我们想要从这个专门的调试组中,将数据提取出来并以图形方式绘制出来。为此,我们可以:

  1. 利用已有的 profile 数据结构,其中包含线程相关的事件信息;
  2. 通过访问这个结构内的组成员,遍历出所有我们感兴趣的事件;
  3. 将这些事件绘制出来,构建可视化的线程时间图(thread interval graph);
  4. 虽然当前系统已经具备这些数据,但还没有实际绘图,我们现在准备实现这一步;

技术细节分析

  • thread interval graph 是我们用来可视化线程运行区段的模块;
  • 每个线程拥有一组事件,我们通过遍历 element group 中的成员来获取这些事件;
  • 需要知道当前的帧索引,以便绘制"最新的一帧";
  • 推测中,我们应该在调试存储结构中已经存储了最新帧的索引,例如通过 most_recent_frame 这样的字段;
  • 可以通过 most_recent_frame.index 之类的方式获取正确的时间轴基准;
  • 遇到了一个变量 frame_bar_scale,似乎是早期移植代码时残留的,可能原意是计算显示缩放比例或线程总数,但现在看起来已经无效,因此可以忽略或清除;

小结

  • 所有调试信息现在已经按照预期进入专门的组中;
  • 下一步可以在 thread interval graph 中遍历这些事件并开始绘图;
  • 需要从调试状态中提取"当前帧"的索引,用于绘制最近一次的线程活动;
  • 遇到的 frame_bar_scale 被识别为废弃变量,不再具有实际意义;
  • 接下来我们将基于已组织好的调试数据,实现更直观的线程区段可视化图形。

这意味着整体框架已经打通,后续重点放在如何渲染可视化图形上。

game_debug.cpp:让 DrawProfileIn 绘制一帧

我们当前的任务是将性能分析系统中记录的数据以图形方式绘制出来,重点是以帧为单位进行绘制。目前我们采取的是从最新的帧中提取时间块数据进行可视化的初步尝试。以下是详细总结:


目标与思路梳理

  • 当前的渲染逻辑中,已经实现了性能分析数据的记录和存储,但绘制部分仍未完成;
  • 我们决定只绘制最新的一帧,而不是遍历所有帧,因为绘制全部帧过于耗资源,屏幕空间也不足;
  • 将通过"存储事件"中的"打开块(begin block)"和"关闭块(end block)"来计算时间区间,进行绘制;
  • 时间区间对应屏幕上的 X 轴坐标,线程对应 Y 轴坐标(每个线程对应一条"lane")。

数据结构与绘图逻辑

帧选择与时间跨度计算
  • 使用"最新帧"进行绘制,具体是 most_recent_frame

  • 确认"帧标记"是在调试数据绘制后执行,因此 most_recent_frame已完成记录的一帧;

  • 获取该帧的时间跨度为:

    复制代码
    frame_span = frame_end_clock - frame_begin_clock
  • 计算屏幕绘图的缩放比例(frame_scale):

    复制代码
    frame_scale = pixel_span / frame_span

    即将时间范围标准化后映射到绘图区域的宽度上。


线程信息处理与图形绘制

线程区间绘制
  • profile_group 中遍历所有事件(stored_event);

  • 判断每个事件是否属于当前帧(根据其帧索引判断);

  • 对于成对出现的 begin_blockend_block

    • 记录下 begin_block,当遇到对应的 end_block 时使用两者之间的时间跨度绘制矩形;

    • 位置计算如下:

      复制代码
      min_x = profile_rect.min_x + scale * (open_event.clock - frame.begin_clock)
      max_x = profile_rect.min_x + scale * (close_event.clock - frame.begin_clock)
  • Y 轴位置根据线程在 lane 中的位置进行分配,使用:

    复制代码
    lane_height = profile_rect.height / lane_count

    然后:

    复制代码
    min_y = profile_rect.max_y - (lane_index + 1) * lane_height
    max_y = profile_rect.max_y - lane_index * lane_height
问题点与后续计划
  • 当前存储事件是单向链表(从 oldest 到 most_recent),无法从尾部逆向遍历;
  • 可能需要改成双向链表,或者以后考虑用树形结构来优化事件组织;
  • 目前线程是用系统 ThreadID 表示的,不方便进行稳定排序和索引,需要将其替换为内部维护的"序号化 ID";
  • 图形绘制采用 push_rect 的方式将矩形压入渲染组,使用的是 no_transform
  • 目前省略了文本绘制(如函数名标签),后续再实现;
  • 无用的变量(如 frame_bar_scale)已清理,保持绘图逻辑简洁清晰;

总体结构现状与后续工作

  • 当前绘制逻辑已经可运行,并且可以从最新帧中准确提取和渲染函数块时间;

  • 线程图将以条形区域方式直观显示每个线程的执行片段;

  • 后续工作包括:

    • 优化线程 ID 管理;
    • 改进事件链表结构;
    • 添加文本信息(函数名称等);
    • 支持多帧回顾或缩放;
    • 提高绘制性能和美观性。

整个系统已经完成从数据记录到图形初步绘制的闭环,未来将聚焦在可用性提升与功能细化上。

奇怪怎么没有呢

屏幕打开profile 没进来

DebugType_EndBlock 中StoreEvent被屏蔽掉了

再次运行

world 里面显示会有bug,

为什么上面树折叠对下面有影响

运行游戏并查看正确的调试可视化ε

现在的效果非常理想,甚至可以说出乎意料地正确。

虽然目前还没有任何机制确保绘制的顺序是准确的,但初步结果已经达到了可接受的状态。接下来仍然有许多工作要做,尤其是排序顺序的问题还没有解决。当前的绘制过程缺乏对调用层级或先后关系的掌握,导致无法准确堆叠调用块,显示出正确的嵌套或包含关系。

这也体现了性能分析器中处理调用关系的一大难点:没有现成的信息说明哪个函数是谁的调用者或被调用者。因此,要正确堆叠这些时间块并在图表中呈现函数嵌套结构,是一个比较复杂的问题。

尽管如此,整个绘制系统的基础框架已经逐步搭建完成,进展令人满意。我们正在一步步接近理想状态。后续将重点解决排序与堆叠的逻辑,确保最终展示的数据既准确又具有层次感。整体来看,性能分析的可视化已经迈出了关键的一步。

Q&A

game_debug.cpp:将 PointerToUint32 转换为 CloseEvent->GUID

我们考虑到,如果拿到一个指针后,希望将其转换成可以直接使用的形式,比如某种更简化、更适合处理的类型(例如一个索引或偏移量),这一步操作必须在某个明确的位置进行,也就是说,它必须在某个阶段完成这个转换过程。

这是在处理数据结构或事件记录时非常关键的一步,尤其是在可视化或者调试系统中,当我们遍历和渲染各种数据时,如果依赖于指针本身,那么在渲染或比较时将非常不方便。因此,必须将指针或引用的信息提取出可以计算和比较的值(比如将其转换为编号、序号、数组索引等)。

这类转换通常要保证:

  1. 统一性:所有指针都应该以相同规则转换成可处理形式;
  2. 安全性:转换之后不应丢失必要的语义信息,例如不能转换得太粗糙以至于无法区分不同的元素;
  3. 效率:转换过程应尽量靠近数据采集或数据初次记录的位置完成,避免每次使用时重复处理。

因此,这种"指针向可用形式转换"的操作必须出现在一个明确而恰当的阶段,以保证后续渲染、比较、排序等处理逻辑可以顺利进行。

cpp 复制代码
#define PointerToUint32(Pointer) ((uint32)(uintptr_t)(Pointer))

uintptr_t 能保存一个指针
#define PointerToUint32(Pointer) ((uint32)(uintptr_t)(Pointer))

这段宏定义的作用是将一个指针 类型安全地转换为一个 uint32 类型的整数。


为什么要用 uintptr_t

uintptr_t 是 C/C++ 标准库 <stdint.h><cstdint> 中定义的一个 无符号整数类型,它的关键特性是:

它保证足够大,能够安全地存储任意指针的值。

换句话说,它是专门用来存放指针数值(即地址)的整数类型。


分析这段宏的转换流程:

c 复制代码
(uint32)(uintptr_t)(Pointer)
  1. (uintptr_t)(Pointer)

    这一步把 Pointer(原本是一个指针类型,比如 void*)转换成 uintptr_t,也就是把指针地址转换成一个整数值。这样可以安全获得指针的"地址数值"。

  2. (uint32)

    然后再将这个整数地址强制转换为 uint32,也就是取这个地址的低 32 位。


为什么不能直接 (uint32)(Pointer)

直接这么写是不安全的,原因有:

  • 在某些平台(尤其是 64 位系统)上,指针是 64 位的 ,而 uint32 只有 32 位。直接强制转换会丢掉高 32 位,编译器可能会发出警告或报错
  • 不同架构对指针到整数的直接转换有不同的要求。使用 uintptr_t 是标准定义的方式,可以确保转换的安全性和跨平台兼容性。

总结

使用 uintptr_t 的原因是为了:

  • 安全性:明确表示我们是要把指针的地址转成整数。
  • 可移植性:保证在不同平台上(如 32 位/64 位系统)都能正确处理指针到整数的转换。
  • 避免编译警告或错误:防止类型系统因为不安全的强转报错。

如果你在做类似 ID 映射、调试可视化、缓存索引等,需要把指针"压缩"成一个整型值的情形,这种做法是标准且推荐的方式。需要注意的是:最终的 uint32 可能会导致截断,如果系统是 64 位的,多个不同的指针可能映射成相同的 uint32 ,这在设计上需要考虑。

越界了

问:你觉得用"调用树"来追踪多线程之间的依赖关系怎么样?

我们在考虑使用调用树来追踪多线程中的依赖关系时,其实面临一些困难。如果我们以性能分析器为背景来讨论,会发现传统的调用树并不能真正帮到我们。

我们真正需要了解的是------每一组调用是如何与其"父级调用"关联的。这一点非常复杂,因为普通调用树或调用图只能提供"函数A调用函数B"这样的信息,但它不能告诉我们:在特定的一次调用过程中,这一组调用链是如何形成的,尤其在多线程环境下更难追踪。

我们可能需要引入更精细的记录方式,比如为每一次函数调用都记录它的"父调用"的返回信息,甚至为每个调用都维护一条具体的路径。这种方式虽然看起来像调用树,但其实远远超过了传统调用树的能力。

传统的调用图仅仅表示"函数A有时会调用函数B",但这种结构并不能区分不同线程或不同时间片中,哪一次调用具体属于哪个调用上下文。我们要做的更像是一种实时的调用链追踪,记录每一个函数调用的完整来源和上下文,而不是静态的、抽象的函数依赖关系图。

我们考虑引入某种机制,以精确地记录每一个函数调用的发生过程和其所属的父调用,这样才能在多线程场景下真正还原调用链路、分析依赖,并用于性能分析或调试。

问:你用什么命令从过场切换到游戏?

我们在游戏中切换操作模式时,使用的是空格键(Space Bar)。当需要从"切割"模式切换出来或进入其他状态时,就按下空格键来实现这个转换。这个按键被用作模式切换的快捷方式,方便我们在不同操作之间快速过渡。整个过程依赖键盘输入,通过监听空格键来触发对应的状态切换逻辑,从而改变当前的交互方式或行为。

问:你怎么处理在多线程中运行但计算时间超过一帧的任务?比如我听说新《极限竞速》的后视镜更新频率只有主画面的一半

我们在处理运行于独立线程的任务时,即使它们的执行时间超过一个帧的计算周期,也可以很好地应对。这种情况完全没有问题。当前我们暂时不会对这些任务进行绘制,但一旦整体流程整理完毕,就能轻松地添加这部分功能。

具体处理方式也不复杂。我们可以通过事件的开启与关闭来判断跨帧任务的存在。例如,如果当前帧中没有检测到"开启事件",但却发现了"关闭事件",这说明该任务其实是从前一帧就已经开始运行了。在这种情况下,我们只需从当前帧的起点开始绘制即可。

为此,我们可以将其绘图的时间偏移量直接设置为零(即表示从该帧的起始位置开始),这样就能正确反映出任务的持续时间,即便其开始时间早于当前帧。这个处理策略可以确保跨帧任务也能被准确地可视化,保证数据的完整性与时间线的连续性。

问:为什么没人告诉我游戏里有克兰普斯?

我们一开始居然没有被告知这个游戏里居然有克拉帕斯(Krampus),实在令人惊讶。其实很早以前我们就已经收到过克拉帕斯的图片,甚至可能在他首次出现在直播前,我们的邮箱里就已经有相关内容了。也许有人没注意到,但我们记得自己确实收到了关于克拉帕斯的信息,他早就已经在游戏中了。

更重要的是,克拉帕斯不仅仅是游戏中的一个角色,他几乎是整个游戏存在的核心动因。因为圣诞老人本质上只是个自负又冷漠的家伙,完全不能理解一个没有手的小男孩怎么可能满足于每年只收到几顶帽子。他需要的是能在森林中冒险、行动的能力,而不是装饰品。

而克拉帕斯出现之后,情况完全改变了。他理解这个孩子的真正需求,并且作为那个众所周知的"会肢解小孩"的存在,克拉帕斯自然在他的袋子里带了很多手。于是他将一只手送给了小男孩,让他重新拥有行动能力。从这个角度看,克拉帕斯拯救了一切。

这也构成了整个故事的主旨:克拉帕斯并不是恐怖的怪物,而是真正帮助孩子的英雄。我们从这个设定中得出结论,他在游戏中所扮演的角色不仅有趣,甚至是富有象征意义的核心存在。

问:一边写代码一边和观众聊天对你有帮助吗?

一边编码一边说话其实并不会带来什么帮助,反而会让编程变得非常困难。我们经常发现,在尝试处理复杂内容的时候,同时进行语言表达会干扰思路,导致效率下降。这种情况下,我们很难专注于逻辑推理或者细致的实现细节。

尽管如此,我们还是尽力而为,在保证思维不被完全打断的情况下去表达正在进行的操作或者解释思路。但总体而言,同时说话和编码确实是种挑战,需要在精力分配上不断调整。

问:有没有考虑用火焰图来可视化性能数据?

我们确实没有考虑使用 flame graph(火焰图)来进行时间可视化,原因有很多。首先,目前我们刚刚开始进行性能分析的可视化,像 flame graph 这样相对复杂的工具还需要编写大量的额外代码,特别是对于一个内部的分析系统来说,这种投入并不划算。

虽然以后理论上可以实现 flame graph,但我们并没有太大意愿去做,因为我们并不真正需要它。我们已经在调试系统上投入了不少时间,更希望把精力放在系统架构本身,让整个设计能清晰地展现出权衡和决策的过程,而不是把时间花在制作一些"花哨"的图表上。

flame graph 本身在信息传达上其实并没有太多独特优势,很多时候从更简单的图形中就能获取我们需要的信息。除非是在某种特别混乱、结构极其复杂的代码库中,我们才可能真的需要 flame graph 来协助理解代码执行路径。但在我们的情况下,它并不属于一个高优先级的工具。

因此,我们倾向于使用更直接、更轻量的可视化方式来辅助性能分析,避免过度投入在看起来好看但实用性不高的工具上。总体而言,flame graph 并非必须。

问:这是不是软件渲染?帧时间太高了

当前帧时间变卡顿的原因并不是运算逻辑本身在运行,而是我们正在处理数量非常庞大的调试元素。由于我们记录了任意数量的帧,每一帧中包含大量的事件数据,现在每次都必须遍历所有帧、每一个事件进行处理,这种做法代价极高。

目前的系统中,已经积累了成百上千甚至上百万条调试记录,而我们仍在使用一个简单的线性遍历方式去查询和处理它们。每一帧都需要重新完整地遍历所有数据,这种方式显然在数据量较大时无法承受,会导致性能急剧下降,尤其是在持续记录的场景下尤为严重。

因此,现有方式已经不适合继续使用,我们必须寻找一种更高效的查询机制来获取所需信息。必须优化这部分逻辑,例如通过构建更高效的数据结构,缓存机制或索引来快速定位目标数据,减少不必要的重复计算。换句话说,我们需要一种性能更高、响应更快的方式来支撑分析和绘制工作。

game_debug.cpp:停止每次都循环遍历所有事件

目前系统的性能瓶颈基本上出现在一个特定的 if 判断中。如果我们将这个判断逻辑去掉,性能问题就会得到缓解,看起来一切就能正常运行。然而,代码并没有正确地重新加载,原因在于有一段代码被标记为"无法到达",这导致编译器并未真正构建新的版本。

在我们手动绕过这部分判断之后,程序确实恢复了正常运行,但这也揭示了一个潜在问题 ------ 我们当前对字符串的处理存在严重缺陷。虽然逻辑上是将字符串压入某个容器或栈中,但实际上某些字符串并没有成功地被推入进去。

这一现象在代码热重载时表现得尤为明显。本应被加载的字符串在热重载后丢失了,说明它们在数据结构中的插入操作未能正确执行。推测问题发生在某个阶段字符串没有被加入,导致热重载后的状态不完整,这是一个明显的 bug。

相关推荐
共享家952744 分钟前
C++模板知识
c++
阿沁QWQ1 小时前
友元函数和友元类
开发语言·c++
achene_ql2 小时前
缓存置换:用c++实现最近最少使用(LRU)算法
开发语言·c++·算法·缓存
帅得不敢出门3 小时前
Android Framework学习二:Activity创建及View绘制流程
android·java·学习·framework·安卓·activity·window
Charlotte's diary3 小时前
虚拟局域网(VLAN)实验(Cisco Packet Tracer)-路由器、交换机的基本配置
经验分享·学习·计算机网络
mahuifa3 小时前
(35)VTK C++开发示例 ---将图片映射到平面2
c++·vtk·cmake·3d开发
帅云毅4 小时前
文件操作--文件包含漏洞
学习·web安全·php·xss·印象笔记
向風而行4 小时前
HarmonyOS NEXT第一课——HarmonyOS介绍
学习·华为·harmonyos
一匹电信狗4 小时前
【数据结构】堆的完整实现
c语言·数据结构·c++·算法·leetcode·排序算法·visual studio
胖大和尚5 小时前
Linux C++ xercesc xml 怎么判断路径下有没有对应的节点
xml·linux·c++