游戏引擎学习第187天

看起来观众解决了上次的bug

昨天遇到了一个相对困难的bug,可以说它相当棘手。刚开始的时候,没有立刻想到什么合适的解决办法,所以今天得从头开始,逐步验证之前的假设,收集足够的信息,逐一排查可能的原因,最终找到罪魁祸首。

然而,与其说是自己独自解决问题,不如说是通过观众帮助找到了bug。虽然我自己并不知道他们找到的具体问题是什么,但从论坛上看到有人发布了关于bug可能原因的猜测,他们的分析让我觉得很有道理。看完后,我认为他们的猜测很可能接近问题的核心。

这种情况就像是,如果是自己一个人在办公室里工作,会把问题拖延几个小时,可能还没找到解决办法。而现在就像是在办公室里有一群程序员可以一起讨论,大家都在看问题,并且有人很快就找到了线索。

总结一下,昨天的问题让人觉得很棘手,但今天我开始从观众的反馈中获得了新的线索,可能这就是意外的好处所在。

描述这个bug的影响

现在我们看到的是一个看起来相当有效的性能分析,刚开始时,我们确实看到了合理的性能数据增长,但是不幸的是,在初始的一波之后,我们就没有看到任何数据了。这个现象很有趣,因为现在我意识到,仍然什么都看不见。

最初的一些数据大概可以看到,可能是因为第一次运行时,缓存和加载的过程比较慢,导致延迟,这些延迟反而帮助了我们。因此,昨天我们追踪到的bug其实我们已经知道是什么问题了,只是不清楚它的原因。

具体的bug是这样的:每次我们通过线程处理时,我们都会在打开一个计时块时进行记录,并且会寻找对应的关闭块。当我们打开块时,栈会增加,而关闭块时栈会减少。然而,我们发现这个过程本应该没有问题,但在某些时刻,我们收到了大量的调试记录,里面有一些打开的块没有对应的关闭块,这让我们感到很困惑。

这是一种常见的情况,就是在追踪调试记录时,出现了意外的行为,特别是在栈的管理上。

即使有很多经验,难以解决的bug仍然会发生

就像之前说的,我编程已经有三十年了。在调试问题时,有个普遍的规律就是,由于我编程时间久了,通常我能在十到二十分钟内调试好一个问题,即使是比较复杂的bug,因为我知道问题可能出在哪里,我去找找看,最终就会解决,这种情况在也经常发生。

但是偶尔,即便是编程这么多年,至少像我这样的人,还是会遇到让人完全摸不着头脑的bug。这种bug确实会把人难住,真的不知道问题出在哪。有时候解决这样的bug可能需要几个小时,甚至几天。有时这些bug就是真的非常难以捉摸。

有过这样的经历,编译器输出错误代码,追踪这种bug真的非常费时间;也有过操作系统本身的bug,导致问题出现。遇到这种情况,根本问题本身有缺陷,找起问题来确实很花时间,可能需要几天才能解决,确实是那种非常棘手的难题。

这个bug并不复杂,但它的来源超出了我们认为可能的原因范围

有时候,bug并不复杂,甚至是一个非常简单的bug。那么问题来了,为什么这个简单的bug找起来这么费劲,而其他一些看似简单的bug却能很快解决?

原因就在于直觉。由于多年的编程经验,会对代码的某些部分形成一定的假设。在调试时,通常会围绕这些假设去寻找问题所在。大多数时候,bug确实就出现在这些预设的范围内,因此调试过程通常是通过验证一些假设来缩小可能出错的范围。

但问题在于,通常在调试的过程中,我们并没有检查程序中的所有部分。因为有些部分可能也出错,但显然不可能去检查所有的地方。你会在某些地方做出假设,认为它们是对的,其他地方则认为不需要验证。问题就出在当你没有正确划定这个界限时,导致遗漏了某些地方,而这些地方恰恰可能就是bug所在。

这就像在现实生活中找东西一样。如果你丢了钥匙,找了很久也没有找到,最后发现它在一个非常合理的地方。问题是,你当时并没有想到它会在那里,你根本没检查到那个地方。因此,当你找钥匙很顺利时,通常是因为你在一开始就猜对了几个可能的地方,最终找到了它。

而如果钥匙在一个特别难找的地方,可能就是你当时就经过了那个地方,甚至就站在钥匙旁边,但你完全没意识到。直到很久之后才会想起来原来钥匙就在那个地方,而这时候已经过去了很长时间,可能还会错过几天才能找到。

这次遇到的bug就是这种情况。

这个bug可能与不正确的翻译单元索引有关

在论坛上,大家提出了一个可能的原因,认为我们遇到的问题是因为翻译单元索引出了问题。我们使用了一种新的方法来追踪每个调试位置的唯一标识符,这个方法之前没尝试过,但它正是因为我们只有很少的翻译单元才能够实现。通常情况下,由于翻译单元数量很多,这种方法是不可行的,但我们只有三个翻译单元,因此可以尝试这种方法。

为了实现这种方法,我们需要一个翻译单元的编号来确认每个调试记录对应的位置。调试记录的索引帮助我们唯一地标识出是哪一个翻译单元的记录。如果翻译单元索引不正确,就无法知道该调试记录到底属于哪个翻译单元,是否属于主代码、优化代码还是平台层代码。

这个方法是为了尝试用计数器系统来追踪调试位置而做出的妥协。然而,事实证明,最初我们是做对的,我原本将RecordDebugEvent(或者类似的代码)做成了宏,这是最终希望实现的方式。
在这里插入图片描述

如果编译器没有内联RecordDebugEvent,可能会生成错误的翻译单元索引

在某个阶段,我们将 RecordDebugEvent 改为了内联函数,以便更容易调试和观察。但是,这样做引发了一个问题。如果这个函数是内联的,但编译器没有在每个地方都内联它,即使我们尝试控制,也不能保证每次都内联。编译器完全有可能不内联这个函数。如果编译器不内联,那么它就会在每个翻译单元中生成这个函数的一个版本。

我们的程序有三个翻译单元,每个翻译单元都调用了 RecordDebugEvent,这就意味着每个翻译单元中都会有一个 RecordDebugEvent 的版本。然而,在链接时,链接器只会使用其中一个版本。这是链接器的工作方式,通常链接器不会试图保持这些版本分开,至少根据我所了解,链接器不会这么做。虽然可能在某些情况下,链接器应该确保每个翻译单元都有一个独立的版本,但这方面的规则我并不十分清楚,也可能我理解的有误。

在论坛上,大家提出了一个假设,经过检查后似乎是正确的。假设是,编译器在每个翻译单元中生成了不同的 RecordDebugEvent 函数,并将正确的翻译单元信息放入其中。然而,链接时,链接器实际上丢弃了其中一个版本,这意味着无论哪个翻译单元调用 RecordDebugEvent,它最终使用的都是同一个翻译单元的版本。

这种情况导致了调试记录的索引发生冲突,从而使得调试记录的开闭操作看起来像是错误的,实际上并没有按预期的方式发生。
在这里插入图片描述

检查这个理论

这是他们的假设,听起来确实是一个非常合理的猜测。实际上,检查这个问题有一个相对简单的方法。我们可以检查一下翻译单元的索引,看看它是否有可能不等于零或者二。如果程序中的优化翻译单元和主翻译单元是一起编译的,那么在调试事件中应该会看到翻译单元的索引为零和一。

我打算查看是否存在错误的函数调用,具体是看我们在调试时是否能看到这两个翻译单元的索引。如果我们能看到零和一,那么就意味着问题可能不出在这里,尽管还可能有其他原因导致问题。如果我们从未看到过翻译单元的索引为一,那么这就证明我们只得到了一个 RecordDebugEvent 的版本,导致了调试记录的错误。

因此,我会查看在运行过程中,当我们遍历这些事件时,是否能断言事件的翻译单元索引不等于零。这个断言应该会立即触发,因为我们预计调试事件中会有翻译单元零和一。果然,事件中的翻译单元零和一都存在。

接下来,我还将检查翻译单元二的情况,假设这意味着所有三个翻译单元都在运行。

目前来看,问题的根源还不完全明确,因为虽然翻译单元一(优化过的翻译单元)存在,但我们仍然不确定从优化单元出发的所有路径是否都能得到正确的翻译单元。因此,我希望进一步检查优化过的部分。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_optimized中的某个时间函数是否工作不正常?

我想看看时间函数到底是怎么工作的。实际上有两个TIMED_FUNCTION。所以可能其中一个或者两个函数并没有正确地记录信息。我打算进入这两个函数,逐步调试,看看它们的行为。同时,为了让调试过程更方便,我还会采取一些额外的措施来帮助我们更好地理解问题所在。

禁用优化

打算查看在完全关闭优化模式下,程序的运行情况。换句话说,如果我们关闭所有路径的优化,不进行优化编译,那么会发生什么呢?通过这个测试,发现很有意思的现象,似乎能支持之前的假设。因为如果调试信息在没有优化的情况下能正常显示,那么可能确实是在优化模式下出现了问题。不过这只是其中一个线索,仍然需要进一步找出问题的根本原因。虽然目前这样做有些不太理想,但接下来会考虑一个更干净的方式来存储数据。

在调试模式下,bug没有复现

在调试模式下,似乎没有正常显示回调信息,这开始暗示可能是因为翻译单元的问题,也就是说在优化过程中,可能有一个翻译单元被优化进了代码中,而另一个则没有。这可能导致了一些调试信息只有部分显示,虽然我们看到有一些“1”和“0”的输出,但目前仍不完全确定到底是怎么回事,可能是由于优化速度的问题导致的。现在还不清楚最终原因,只是在尝试调查这一现象,看是否有道理。从目前的情况来看,似乎必须将优化设置为更高的级别,或者至少高于低优化模式,才能正确地捕获到相关信息。虽然问题显然存在,无法否认,但目前还没有完全确认它的根本原因。

单步调试优化后的代码

为了深入排查问题,首先设置了一个断点,在 RecordDebugEvent 函数中,并进行了调试运行。接着,冻结了所有其他线程,只专注于当前的线程,以便能更好地观察调试过程。在调试时,检查了全局调试表格,尝试查看正在记录的信息。观察到当前记录的调试数据接近之前的一些数据,从而确认了当前代码的执行状态。此外,还尝试查看 RecordDebugEvent 的行为,检查是否能够获取更多关于执行过程的线索。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

RecordDebugEvent没有被内联

在调试过程中,观察到的现象与预期的情况基本一致,表明可能确实是之前提到的那个bug。当前执行的函数并没有被内联,这很可能导致相关的调试记录没有被正确地更新,甚至可能是因为这些记录被优化掉了。
在这里插入图片描述

接下来,尝试查看翻译单元(Translation Unit)的具体内容,看看是否能观察到调试记录的写入情况,但目前并没有看到相关更新。可能存在偏差,无法准确确定需要查看哪些数据,或者所期望的调试记录索引并没有正确写入。

在此基础上,考虑查看汇编代码,分析实际执行的指令,以便确认程序运行时到底执行了什么。
在这里插入图片描述

在这里插入图片描述

是的,翻译单元错了,论坛说得对

在这里插入图片描述

通过分析,发现程序在执行过程中确实选择了错误的翻译单元,导致了问题的发生。调试记录的翻译单元被错误地抛弃,这正是问题所在。这一发现与论坛上某些用户的分析完全一致,说明他们的推测是正确的。正是因为选择了错误的翻译单元,导致调试记录被处理错误,最终导致了预期外的行为。

将RecordDebugEvent改为宏修复了问题,但…

解决这个问题的方法其实非常简单,就是将相关的函数转换成宏。这样,宏会在每次调用时展开,这样就可以确保它在正确的地方被处理。通过强制内联,问题就能够得到解决。经过修改后,问题确实得到了修复。这个问题的根源在于翻译单元的处理,转换成宏后,问题就不再出现了。这是一个非常直接的修复方案。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

…我们应该去除与翻译单元索引相关的代码,反正它增加了复杂性,容易引入微妙的bug

在解决了这个问题之后,有一个重要的思考,就是使用每个翻译单元的方式可能已经到了该结束的时候。虽然这次尝试是一次实验,虽然这个方法可能有一些有用的方面,但我觉得这个问题很微妙,主要是因为我们在使用计数器时引入了额外的复杂性。而我现在感觉不太舒服的是,依赖这样的方式会增加不必要的复杂性,这样的 bug 很难察觉。

虽然引入一些不完美的东西(比如 Jenkins)并不会直接导致灾难,但这次的 bug 就是一个很好的例子,提示我们这种方式并不可靠。我觉得这是时候放弃这种方式了,毕竟尝试新方法是对的,但通过这次的经验,我感觉这并没有带来足够的好处,反而增加了复杂性,而这种复杂性带来了代价。对于我们的目标来说,最终这个复杂度似乎并不值得。

我们暂时不会删除翻译单元索引

目前虽然问题得到了修复,但我认为还是应该考虑放弃翻译单元索引的方式,转而采用更标准的单次哈希表。这种做法的复杂性已经带来了经典的问题,虽然现在修复了这个 bug,但我对于这种方式还是有所担忧,觉得它可能并不是最佳的方案。

尽管现在能够正常工作,但如果以后再出现类似的 bug,我会建议直接替换这种实现,而不是继续调试。毕竟,目前对这种方式的信任度不高,也没有足够的信心保证它在更复杂的情境下能稳定运行。换句话说,如果未来出现问题,我宁愿换掉它而不是再继续调试。

总的来说,这个 bug 解决了,现在的情况应该已经比较稳定。但接下来还有一堆工作需要处理,我们需要专注于生产一些有用的可视化结果,而不是再在复杂的实现上浪费太多时间。

回到可视化。让我们避免生成在调试图表中看不见的区域

首先,我们不希望在区域无法真正显示时还生成这些区域。为了避免这种情况,我们可以在添加区域时,先检查一下所记录的时间差是否足够大,能够实际反映出一个可见的区域。为了实现这一点,可以为每个区域设置最小和最大时间(min 和 max),并判断这些时间差是否足够大,以决定是否记录该区域。

具体来说,如果最大时间和最小时间的差值(max - min)小于某个预设的阈值,那么就可以忽略这个区域。这个阈值(例如,我们可以假设时间条被分为100份,小于其中1%的区域就不值得记录)将帮助过滤掉那些微小的、不可见的区域。这样就可以避免生成那些无用的、看不见的、非常短的时间片段。

通过这种方式,可以让记录的区域更加有意义,避免了那些只有极短时间跨度的区域,避免了生成大量微小且不易察觉的区域。
在这里插入图片描述

引入选项来编译掉分析代码

为了提高运行效率,首先需要确保调试信息能够被编译掉。调试信息会导致运行变慢,因此需要能够在不同的场景中开启和关闭调试功能,确保在调试时能控制它的开关。

目前,解决方案是在代码中使用条件编译来控制调试信息的插入。具体来说,可以通过设置编译器的标志来启用或禁用调试信息。例如,在启用游戏分析(profile)模式时,才会插入调试相关的代码。如果没有启用分析,调试相关的代码将被完全剔除,不会进入编译的最终代码中,这样就避免了不必要的性能损耗。

通过这种方式,代码在没有启用分析时,会保持快速运行,调试信息完全不存在,不会影响到性能和图表绘制。这种方法保证了调试信息只有在需要时才会出现,优化了性能,同时也保留了必要的调试功能。

总之,调试信息的插入是可以根据需求进行灵活控制的,可以根据不同的构建设置决定是否包括这些调试信息,以此来提高性能,确保调试与正常运行的平衡。
在这里插入图片描述

在这里插入图片描述

根据调试显示,我们应该运行在更高的帧率上

目前,程序的运行速度明显变慢,帧率看起来远低于预期。即使从分析的数据显示,程序的运行时间和帧率看起来是正常的,然而实际的体验却感觉非常缓慢。这让人怀疑可能是某些地方的绘制方式出了问题,或者是某些地方的性能被低估了。

具体来说,虽然从图表上看,程序的运行时间与理论上的帧率(比如每秒30帧)差距不大,但实际帧率却远低于预期,甚至像是每秒只有两到五帧,而不是应该达到的30帧。这种差距让人困惑,可能存在一些我们没注意到的问题。

为了解决这个问题,可以通过添加一个帧率计数器,使用类似“查询性能计数器”的方法来进一步诊断和分析帧率的实际情况。通过这样的方式,可以准确捕捉帧率,并检查是否真的存在性能瓶颈,帮助找出程序中可能的性能问题。
在这里插入图片描述

在这里插入图片描述

验证rdtsc测量与墙钟时间的对比

目前,可以考虑通过改进时间的显示和测量方式来更好地调试性能问题。首先,可以利用已有的“翻转时钟”(flip wall clock)和计时器(encounter)来优化调试系统。通过将这些时钟信息传递给调试系统,可以更精确地验证时间戳计数(TSC)测量结果与墙上时钟时间之间的关系,确保它们至少在某种程度上具有一致性。这样做可以让调试过程更加清晰和准确。

另外,可以通过记录整个代码块的 RTT(Round Trip Time)值来进一步验证时钟时间的精度。例如,可以在帧标记处插入墙上时钟的时间,这样就能在执行过程中实时记录下每一帧的时间和对应的墙上时钟时间。通过对比已知的墙上时钟时间和程序内的周期计时,可以确认它们之间是否存在合理的关联。

为了实现这一点,可以考虑将帧标记放到重置点(renault)处,这样在最初的地方就可以插入墙上时钟时间,从而捕捉到开始时的时间数据。虽然实现起来可能具有一定的挑战,但这一过程对于精确跟踪每帧的执行情况非常有帮助,有助于在调试时发现潜在的性能瓶颈。

在FRAME_MARKER调用时记录墙钟时间

为了进一步优化调试记录并提高精确度,可以考虑在调试系统中增加墙上时钟(wall clock)时间的记录。在目前的设计中,可以通过整合线程ID、核心索引和帧标记等信息,将它们存储在一个结构体中,这样可以在记录调试信息时更准确地跟踪每个操作的具体时刻。

对于墙上时钟时间的获取,可以通过调用系统的时钟函数,然后将返回的值转换为秒数,以便与程序内的周期计时进行对比。为了实现这一点,首先需要获取64位的时钟计数器,然后通过将其除以系统的时钟频率来得到一个秒数值。这样就能在每个帧标记处插入墙上时钟时间,并确保能够与程序中的计时信息进行对比。

考虑到精度问题,如果选择按照这种方法实现,可能会损失一定的精度,因为直接将时钟频率除以返回的计数器值可能不够精确。为此,也可以考虑在时钟时间的计算中引入更高精度的算法,或者使用已有的计时器来记录程序执行的具体时间。

此外,在帧标记的设计上,考虑到程序的启动和第一帧之间的时间差,可能需要做额外的标记和调整。虽然这会增加一些复杂度,但它能为进一步分析提供更完整的时间序列信息。总的来说,这些改进旨在提升调试的可操作性,使调试记录能够准确反映程序运行时的详细时间情况,从而更好地定位性能瓶颈和潜在问题。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

我们的显示与帧率不一致,因为没有考虑到整理调试记录所需的时间

问题的根本原因在于调试记录的收集时间没有被纳入性能分析中,这可能是导致性能配置文件(profile)与实际表现不匹配的一个关键因素。调试记录的收集时间通常会占用相当多的资源,因此它的计算需要被考虑进来。

为了解决这个问题,可以通过记录和计算调试事件的时间开销来改善性能分析。具体来说,可以在帧标记的过程中加入一个新的计时器,用于计算每次帧处理的时间差。首先,获取上一帧的计时器值(即last counter),然后与当前计时器值进行对比,计算出实际的秒数差。这个差值即为当前帧的时间(seconds elapsed)。

通过将这些计算集成到调试记录中,每次记录调试事件时,除了标准的调试信息外,还需要添加帧时间。这样就可以确保性能分析更加准确,能够反映出包括调试事件收集在内的所有时间消耗,从而帮助更好地理解程序的运行效率和性能瓶颈。
在这里插入图片描述

在这里插入图片描述

有选择地设置SecondsElapsed而不是ThreadId和核心索引

在调试事件中,需要根据特定条件适当设置事件参数。为了实现这一点,可以定义一个宏,该宏在记录调试事件时自动处理常见的操作,例如设置事件索引和类型。这样,框架标记的调试事件就能在记录时,按照不同的条件来设置秒数。

首先,应该定义一个调试事件的宏,它包含常见的调试操作。在框架标记中,可以使用这个宏来记录调试事件,但同时为每个事件设置不同的参数。这样做的目的是使得记录的每个事件都包含正确的时间戳信息,以便更准确地分析性能。

在实际实现过程中,可能需要调整事件的记录方式。尤其是当框架标记发生在结束时,而不是开始时,处理方式需要有所不同。例如,记录开始时的时钟值,而不是结束时的时钟值。尽管这增加了一些复杂性,但不影响框架的正常工作,只是意味着在遇到框架标记时,所有关于该帧的信息已经被收集完毕。

每次框架标记都会记录当前的帧数据,并在此基础上创建一个新的帧,用于记录随后的事件。这些事件会有新的开始时钟值,而不是前一帧的结束时钟。由于当前帧的信息已经被完全收集,所以下一帧的时间会基于新的时钟值进行记录。

这种做法的一个挑战是,无法在当前帧中即时获得秒数差(wall clock seconds elapsed),因此需要在后续的框架中进行处理。尽管这个过程略显繁琐,但仍然能够准确地记录每个调试事件的时间戳,从而帮助性能分析。

总体而言,这种方法有助于提高调试数据的准确性,并使得性能分析更加精确,能够反映调试事件对程序运行时性能的实际影响。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

测试今天的新增内容。奇怪的是,FrameWait和FrameDisplay的时间增加了

现在可以看到这一条线变长了,比之前要长。然而,奇怪的是,并没有真正修改这部分的信息。虽然移动了框架标记的位置,但这部分数据并没有被计算进去。因此,这种变化显得有些异常,说明可能在某个地方出现了错误。

目前运行的代码与之前相同,因此理论上不应该导致不同的形状。然而,现在的情况是,这条曲线出现了较长的尾部,特别是在**帧等待(frame wait)帧显示(frame display)**这两个部分。这两个部分的时间变长了,但原因尚不明确。这让人困惑,因为它们不应该变大,毕竟代码逻辑没有进行相应的修改。

从结果来看,这可能是某些细微的错误导致的。例如,某些未被注意到的修改影响了时间测量的方式,或者是数据收集的过程出现了意外的偏差。此外,虽然目前还没有将新的时间计算方式纳入统计,但似乎已经对最终的曲线产生了影响。

为了进一步验证,会尝试真正计算这部分数据,并看看最终结果是否与预期一致。这将有助于确定当前代码的执行情况,并找出导致曲线变化的具体原因。

添加DebugCollation计数器

现在来看一下,目前的**周期计数(cycle count)已经不再需要单独计算了。因为现在的代码已经在多个地方获取了周期计数,所以实际上没有必要再在平台层(platform layer)**执行这项工作。

之前在平台层进行周期计数的逻辑,现在已经变得多余。因此,这部分代码可以被移除,而不会影响整体功能。这样可以减少不必要的计算,使代码更加简洁高效。同时,这也避免了重复获取周期计数可能带来的额外开销或潜在错误。

接下来,可以检查是否有其他类似的冗余逻辑需要优化,确保整个代码逻辑更加合理、高效。

DebugCollation花费了很多时间

现在至少可以看到,整体表现更加符合**帧率(frame rate)的情况,这点是好的。然而,仍然存在一些疑问,比如这些条形图(bars)**的大小为何发生了变化。接下来需要继续深入分析,找出问题的根本原因。

此外,现在也可以明显看出**调试数据整理(debug collation)占用了大量时间。这是可以理解的,因为事件数量过于庞大,导致处理时间过长。例如,在第一帧(first frame)时,事件数量达到了五十万(500,000)个,而后续帧虽然有所减少,但仍然有数万(tens of thousands)**个事件需要遍历。这么多数据的处理方式可能并不是最优的,导致了额外的性能消耗。

目前,可以看到一个更真实的性能情况(realistic picture),但仍然有很多可以优化的地方,比如如何更加高效地处理这些调试数据,以减少不必要的计算开销。

使用墙钟时间打印绘制一帧所需的时间

现在,希望能够利用之前获取的**“墙钟时间”(wall clock time)来进一步可视化帧时间(frame time)。一旦知道了每帧的实际耗时(seconds elapsed per frame),就可以将其绘制到调试界面中,以更直观地看到每帧的毫秒耗时(milliseconds per frame)**。

具体来说,在调试覆盖(debug overlay)部分,可以添加一些关于帧时间的信息。由于已经有了字体渲染相关的功能,因此可以直接在调试界面上绘制文本。例如,在绘制帧信息的地方,可以添加一行调试文本(debug text line),用于显示最新帧的时间信息。

目前时间有限,因此暂时不对每个帧单独绘制时间,而是先在**底部(bottom)**显示最近一帧的时间,后续可以再进行更完善的调整。

实现步骤如下:

  1. 访问调试状态(debug state),获取帧信息。
  2. 计算最新帧的墙钟时间差(wall seconds elapsed),得到该帧的耗时(单位:秒)。
  3. 将该值转换为毫秒(milliseconds),即秒数 × 1000
  4. 在调试界面上绘制该值,以便可视化当前的帧耗时情况。

目前的实现可以正确显示帧时间(frame time),但默认取的是**第一帧(first frame)的时间,后续可能需要调整逻辑,以确保显示的是上一帧(last frame)**的时间。这一功能有助于更准确地了解帧率波动情况,为进一步优化提供数据支持。
在这里插入图片描述

在这里插入图片描述

很奇怪
在这里插入图片描述

不绘制调试矩形时,它降到91毫秒

如果不进行调试绘制(debug draw),仅仅绘制调试文本(debug text line),那么帧时间(frame time)是否会有所不同?当前的主要目的是观察帧时间(frame time),但可能应该显示的是**上一帧(last frame)**的时间,而不是默认的第一帧时间。

即使不进行绘制,**调试数据的整理(collation)**依然消耗了大量时间。调试整理(collation)的过程本身就非常昂贵,即使不渲染最终的结果,单单整理数据的过程也占用了相当多的计算资源。

如果关闭调试整理(collation),就会明显减少计算开销,但目前并不能完全确定是**调试整理(collation)本身导致的性能问题。可能的另一个影响因素是记录调试事件(recording debug events)**的开销。

为了进一步确认影响因素,可以尝试单独关闭调试整理(collation),以便观察性能变化。这有助于判断性能瓶颈究竟是在整理调试数据的过程中,还是在记录调试事件时产生的开销。

目前的问题是,如果不借助墙钟时间(wall clock time),就很难直观地判断调试整理的具体耗时。因此,下一步可以考虑使用墙钟时间来测量不同步骤的时间消耗,以找出性能优化的方向。
在这里插入图片描述

在这里插入图片描述

禁用调试事件记录,恢复到原来的性能

在**平台代码(platform code)**中,可以通过让 record_debug_event 不执行任何操作来测试其对性能的影响。具体方法是使用 #ifdef 预处理指令,将 timed_blocktimed_function 相关的代码屏蔽,使其不再执行任何逻辑。

这样,帧标记(frame marker) 仍然可以正常工作,因为没有对其进行修改,而所有其他的**调试事件(debug events)都不会被记录。这种方式允许观察仅仅禁用调试事件记录(debug event recording)**后,程序性能的变化情况。

游戏分析(game profiling)代码中,已经有相应的控制开关,可以直接关闭性能分析(profiling),但仍然保留帧标记(frame markers)。不过,是否要长期保持这种方式仍需进一步讨论。

关闭 record_debug_event 后,仍然无法直接得知**调试数据整理(collation)**的具体耗时,只能确认它是否有影响。因此,需要一种额外的测量方法,例如在 do_game_update() 过程中直接记录并打印帧时间(frame time)。

当前的主要问题是,如果不遍历所有调试记录(debug records),就无法得知帧时间,而帧时间的计算依赖于这些记录。因此,很难精准定位性能瓶颈,即究竟是**调试记录(debug recording)还是调试数据整理(collation)**占用了过多资源。

可能的解决方案包括:

  1. 在不遍历调试记录的情况下测量帧时间,比如在 do_game_update() 直接记录 wall_clock_time 并输出到调试信息中。
  2. 基于帧计数(frame count)来控制分析代码的执行,比如只在 state.frame_count == 1 时执行某些性能测量,以减少干扰。
  3. do_game_update() 或其他适当位置增加额外的时间测量逻辑,以便更准确地对比**调试记录(debug recording)调试数据整理(collation)**的开销。

下一步需要尝试不同的测试方式,以明确性能开销的具体来源,从而针对性地优化调试系统,提高整体运行效率。

我们整理调试记录的过程非常耗时

目前可以明显看出,调试数据整理(correlation) 确实是导致性能问题的主要原因。尽管尚未明确打印出具体的时间开销,但当前的帧率非常稳定,说明记录调试信息(recording debug info) 并不会直接导致性能下降,而整理这些信息(correlating debug info) 才是主要的性能瓶颈。

这意味着,当前的数据整理方式存在优化空间。虽然初次尝试时可能无法精准判断某种方法是否合适,但经过实践后,能够更清楚地了解问题的核心。例如,翻译单元(translation unit) 相关的复杂性在一定程度上增加了调试的难度,不过通过实践积累经验,可以更好地规避类似问题。

目前,调试信息的传输已经趋于稳定,接下来的重点是:

  1. 优化调试数据的整理方式,减少不必要的计算开销,提高执行效率。
  2. 改进 UI 交互体验,让数据的可视化更加直观,使其更方便分析和导航。
  3. 深入挖掘调试数据,找出关键的性能瓶颈,以便更精准地优化整体系统性能。

虽然这个优化过程经历了一些波折,甚至浪费了一定的时间,但最终仍然控制在合理范围内,这也得益于团队协作外部反馈,使得问题的解决速度加快。接下来,可以集中精力在优化数据整理方式改进 UI 交互上,以进一步提升调试工具的实用性和性能。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

你最喜欢的bug是什么?

在谈及最喜欢的程序错误(bug) 时,虽然可能确实存在某个特别有趣或印象深刻的 bug,但由于没有记录下来,因此难以回忆起具体的内容。尽管希望能回想起来并分享,但一时间却无法确定哪个是最值得一提的 bug。

在调试时,我常常不得不阻止自己不由自主地随机修改代码,因为懒惰,想着“希望是一个偶数个符号错误”。你有没有这种冲动?如果有,随着经验的增加,它是否有所减弱?

在调试时,经常会有一种随意更改代码的冲动,希望能通过随机调整来快速找到问题的根源。然而,随着编程经验的增加,这种冲动会逐渐减少,原因在于盲目修改代码通常会带来更多的问题,最终还是需要回头重新排查,甚至可能让问题变得更加复杂。

如果对软件质量有较高要求,就会认识到这种做法往往是得不偿失的。许多情况下,随意更改某个地方可能会暂时掩盖问题,但并没有真正解决它。

随着经验的积累,思维方式也会有所转变:

  • 实验性修改仍然是调试过程中非常重要的一部分,但不能停留在“改动后问题似乎消失了”这个层面。
  • 需要深入分析:“为什么这个改动能够修复问题?
  • 通过进一步验证,确保改动不是简单地掩盖了 bug,而是真正解决了其根本原因。

曾经有一次,在解决某个 bug 时,初步修改后看似问题已经解决,但始终觉得有些不对劲。在休假期间,这种疑虑一直存在,回到工作后进一步排查,最终发现真正的 bug 其实是另一个隐藏的问题,而之前的改动只是掩盖了真正的错误

所以,编程过程中,直觉式调试并不是坏事,但不能让它成为问题排查的终点。找到一个有效的修改方案只是第一步,关键是要深入分析修改的原因,并通过实验验证其正确性,只有这样才能真正提高代码质量,并避免日后出现更多潜在问题。

在游戏需要调试之前,提前做这样的调试器,这与“按需编写代码”的哲学是否相违背?

当前的性能分析和调试工具并不违背**“按需编写代码”的理念。实际上,我们早已在渲染系统中编写了性能计数器,但目前仍然对游戏的时间消耗情况缺乏清晰的认知**。如果没有这种调试工具,我们的状态就像在被遮挡的挡风玻璃后开车,完全缺乏对系统运行状况的感知

因此,性能分析和调试工具不是未来才会有用的东西,而是现在就必须具备的功能。我们的理念并非简单地“按需编写代码”,更准确的描述应该是**“在明确需求时编写代码”**。当确切知道需要某个功能时,就应该立即实现,而不是等到出现紧急情况才匆忙补充。

尽管这个工具当前可能不会直接影响游戏功能,但它的长期价值毋庸置疑。如果未来一定要编写调试工具,那么越早实现就能越早受益,而不是等到问题积累得难以处理时才手忙脚乱地添加。

调试工具的投入能够在开发的每个阶段持续提供价值,因此现在编写它不仅可以立即帮助分析性能问题,还能在整个开发过程中持续发挥作用。相比之下,如果拖到最后才实现,就浪费了前期所有可以利用的机会,同时也不得不花费时间去补足这部分功能。所以,从效率和开发体验的角度来看,尽早实现调试工具是更合理的选择。

有没有办法保持对旧代码的熟悉,还是频繁工作是唯一的方式?

对于如何保持对旧代码的熟悉,确实存在一定的挑战,尤其是当编写大量代码时,很难记住每一段代码的细节。在实际工作中,一天可能会写上千行代码,这意味着很难完全记住自己曾经写过的每一行代码。即便曾经很了解,随着时间的推移,很多细节都会被遗忘。因此,当需要重新回到这些旧代码时,通常会遇到一定的困惑和低效,可能会花费一两天的时间来理解曾经编写的复杂部分,这期间的产出会大幅减少。

为了尽量减少这种回归学习的时间,可以采取一些策略来使代码更容易理解。例如,在编写代码时,应该保持程序结构清晰,命名合理,尽量避免让代码变得复杂和混乱。如果某个函数的结构不够清晰,或者有许多特殊情况,应该花些时间在代码离开之前进行清理,使其更加简洁易懂。这样一来,回到这些代码时会更容易理解。

此外,编程中往往存在权衡取舍的情况。有时候,虽然当前代码看起来足够用,但如果知道自己可以做得更好,花些时间将代码写得更清晰、质量更高,可能在将来再次处理时会节省更多时间。因此,尽管短期内可能要花费一些额外的时间,但从长远来看,这种“提前做好”有时会带来更多的便利。通过不断的实践和经验积累,能够在这些权衡中做出更合适的选择。

然而,也要注意,如果过度优化或者过度设计代码,可能会造成不必要的浪费。为了避免这种情况,需要根据具体的需求判断是否值得投入额外的时间去优化代码。真正的难点在于,什么时候做得足够好,什么时候需要进一步提高,这往往需要经验的积累才能做出合适的决策。在编程过程中,这种权衡是常见的挑战,经验的积累会帮助做出更加精准的判断。

Mok实际上发现了bug,而AndreasK通过查看汇编代码弄清楚为什么会发生这个问题。编译器决定不在构造函数中内联调用,而是在析构函数中内联调用,导致起始标记错误,结束标记正确

在调试过程中,发现了一个 bug,问题的根本原因是编译器在处理构造函数和析构函数时,没有进行正确的内联操作。具体来说,编译器选择在构造函数中不进行内联,而在析构函数中却进行了内联。这导致了启动标记(start marker)不正确,而结束标记(end marker)则正确。通过检查汇编代码,找到了这个问题的根源。

当时,编译器并没有为构造函数和析构函数创建不同的例程,而是尝试共享相同的例程,这就引发了问题。理论上,如果函数被标记为静态函数,可能会解决这个问题,因为静态函数不会共享相同的例程。但目前还不确定为什么编译器会合并这两个不同的函数,导致出现这样的行为,这看起来像是一个奇怪的现象。甚至怀疑这是否可能是编译器的 bug,因为编译器允许将两个不同的函数合并成一个例程,这是不应该发生的。

总之,经过深入的分析,找出了导致问题的原因,这本来可能需要很长时间才能发现。

看起来那个FRAME_MARKER越过了这个TODO:“// TODO(casey):将这个移动到全局变量,以便在其下方可以有计时器?”

在调试过程中,发现一个问题,代码框架没有按预期移动,导致某些部分不在预定的范围内。这个问题可以通过使用哈希表来解决,这样就不会再出现这种情况。尽管问题本身不难修复,但这依然提醒了在设计时应避免依赖当前的结构,而应该考虑更合适的解决方案。

win32_game.cpp:将那个TODO移动并检查GlobalDebugTable是否存在

可以将某个逻辑放到代码的下方,并通过检查一个全局变量来处理。这种做法看起来是可行的。通过调整代码结构,将其移到合适的位置后,可以保证该逻辑正常工作。

你真的关心编译单元还是线程?哈希线程ID

目前并不关心复杂的单元或者线程的哈希值。编译时并不关注这些,我们只是在使用它们作为唯一的标识符。接下来打算做的是移除这些复杂的部分,直接使用文件名和行号来标识。这样做会更简单且符合实际需求,因此决定直接根据文件和行号来处理,而不是继续使用之前的复杂结构。

你提到过开发日志:你认为写开发日志对作者、读者还是对两者都有好处?

关于死锁日志的问题,目前并没有深入思考过它对写者或读者哪个更有利。没有特别的想法。

听起来调试器不仅仅是一个发现问题的工具,还能检测到可能会出问题的地方。这是一个正确的假设吗?我从未想到过调试器能保持“情境意识”。这个主意听起来不错

确实,调试代码不仅仅是用来找出问题发生的地方,还是为了在问题发生时能够及时检测到。这是调试代码的两个主要目的:一是帮助发现那些难以找到的错误,二是让我们能够知道当问题发生时,能够识别出问题所在。特别是性能问题,往往很难察觉,通常无法知道它们在哪里或者是什么原因,缺乏足够的情境意识。而内存问题也是类似的,很多时候可能并不知道游戏有什么问题,感觉可能只是因为帧率降低了,或者其他一些原因,实际上并非如此。因此,目标是通过合适的代码仪表化,让我们能够随时使用这些信息,虽然还需要一些时间才能达到这个目的,但我们在不断努力,最终希望能够让这些工具更加可靠地发挥作用。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/74980.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【入门初级篇】布局类组件的使用(1)

【入门初级篇】布局类组件的使用(1) 视频要点 (1)章节大纲介绍 (2)布局类组件类型介绍:行布局、列布局、标题 (3)实操演示:列表统计查询布局模型 点击访问my…

对内核fork进程中写时复制的理解记录

前言 文章写于学习Redis时对aof后台重写中写时复制的疑问 一、感到不理解的歧义 在部分技术文档中(以小林的文章为例),对写时复制后的内存权限存在如歧义: ! 二、正确技术表述 根据Linux内核实现(5.15版本&#x…

Ditto-Talkinghead:阿里巴巴数字人技术新突破 [特殊字符]️

Ditto-Talkinghead:阿里巴巴数字人技术新突破 🗣️ 阿里巴巴推出了一项新的数字人技术,名为 Ditto-Talkinghead。这项技术主要用于生成由音频驱动的说话头,也就是我们常说的“数字人”。不过,现有的基于扩散模型的同类…

.NET开发基础知识1-10

1. 依赖注入(Dependency Injection) 技术知识:依赖注入是一种设计模式,它允许将对象的依赖关系从对象本身中分离出来,通过构造函数、属性或方法参数等方式注入到对象中。这样可以提高代码的可测试性、可维护性和可扩展…

每日一题 MySQL基础知识----(三)

数据库常用基础知识:代码讲解和实验 1.创建数据库student 02,创建一个名为student02的数据库 CREATE DATABASE student02; 2.在student02中创建一张 students表,并且具有学生的编号id,姓名name,年龄age,生…

MySQL多表查询实验

1.数据准备 -- 以下语句用于创建 students 表,该表存储学生的基本信息 -- 定义表名为 students CREATE TABLE students (-- 定义学生的唯一标识符,类型为整数,作为主键,且支持自动递增student_id INT PRIMARY KEY AUTO_INCREMENT…

windows第二十章 单文档应用程序

文章目录 单文档定义新建一个单文档应用程序单文档应用程序组成:APP应用程序类框架类(窗口类)视图类(窗口类,属于框架的子窗口)文档类(对数据进行保存读取操作) 直接用向导创建单文档…

C++ 初阶总复习 (16~30)

C 初阶总复习 (16~30) 目的16. 2009. volatile关键字的作用17. 2010.什么是多态 简单介绍下C的多态18. 2011. 什么是虚函数 介绍下C中虚函数的原理19. 2012 构造函数可以是虚函数嘛20. 2013.析构函数一定要是虚函数嘛?21. 2015. 什么是C中的虚…

第一天 Linux驱动程序简介

目录 一、驱动的作用 二、裸机驱动 VS linux驱动 1、裸机驱动 2、linux驱动 三、linux驱动位于哪里? 四、应用编程 VS 内核编程 1、共同点 2、不同点 五、linux驱动分类 1、字符设备 2、块设备 3、网络设备 六、Linux驱动学习难点与误区 1、学习难点 …

PaddleX产线集成功能的使用整理

一、环境搭建 1.1 安装paddle-gpu 需要根据安装机器的cuda的版本,选择合适的版本进行安装 #安装paddle-gpu 官网链接 https://www.paddlepaddle.org.cn/install/quick?docurl/documentation/docs/zh/install/pip/linux-pip.html python -m pip install paddle…

docker-compese 启动mysql8.0.36与phpmyadmin,并使用web连接数据库

1、找一个文件夹,比如 E:\zqy\file\mysql,cd到这个目录下创建文件docker-compose.yml 2、将下面的代码块复制到docker-compose.yml文件中 version: 3.3 services:mysql:image: mysql:8.0.36container_name: mysqlrestart: alwaysports:- 3306:3306netw…

解决 Gradle 构建错误:Could not get unknown property ‘withoutJclOverSlf4J’

解决 Gradle 构建错误:Could not get unknown property ‘withoutJclOverSlf4J’ 在构建 Spring 源码或其他基于 Gradle 的项目时,可能会遇到如下错误: Could not get unknown property withoutJclOverSlf4J for object of type org.gradle…

mcp 接freecad画齿轮

from mcp.server.fastmcp import FastMCP import freecad.gears.commands import os from freecad import app from freecad import part mcp FastMCP("Demo")mcp.tool() def create_gear(num_teeth20,height10,double_helix True):"""创建一个渐开线…

【大前端系列19】JavaScript核心:Promise异步编程与async/await实践

JavaScript核心:Promise异步编程与async/await实践 系列: 「全栈进化:大前端开发完全指南」系列第19篇 核心: 深入理解Promise机制与async/await语法,掌握现代异步编程技术 📌 引言 在JavaScript的世界中,异步编程是无…

如何排查java程序的宕机和oom?如何解决宕机和oom?

排查oom 用jmap生成我们的堆空间的快照Heap Dump(堆转储文件),来分析我们的内存占用 用可视化工具,例如java中的jhat分析Heap Dump文件 ,它分析完会通过一个浏览器打开一个可视化页面展示分析结果 根据oom的类型来调…

什么是 OLAP 数据库?企业如何选择适合自己的分析工具

引言:为什么企业需要 OLAP 数据库? 你是否曾经经历过这样的场景: 市场部门急需一份用户行为分析报告,数据团队告诉你:“数据太大了,报表要跑 4 个小时”;业务负责人在会议中提出一个临时性分析…

测试:认识Bug

目录 一、软件测试的生命周期 二、bug 一、软件测试的生命周期 软件测试贯穿于软件的生命周期。 需求分析: ⽤⼾⻆度:软件需求是否合理 技术⻆度:技术上是否可⾏,是否还有优化空间 测试⻆度:是否存在业务逻辑错误、…

综合实验2

1、sw1和sw2之间互为备份 [sw1]interface Eth-Trunk 0 (创建聚合接口) [sw1-Eth-Trunk0]trunkport g0/0/1 (将物理接口划入到聚合接口中) [sw1-Eth-Trunk0]trunkport g0/0/2 [sw2]interface Eth-Trunk 0 [sw2-Eth-T…

【ArcGIS】ArcGIS10.6彻底卸载和ArcGIS10.2安装全过程

卸载python3后,解决了ArcGIS与python3冲突问题后,软件可以正常打开使用了 但是还是出现了问题 用ArcGIS 进行空间分析时,中间操作没有任何报错和问题,但是就是没有运行结果 在别人的软件上操作一遍可以出现运行结果 关闭确有这个,但真的不是我给它的运行时间不够,反反复复试…

Django之旅:第五节--Mysql数据库操作(一)

Django开发操作数据库更简单,内部提供了ORM框架 一、安装第三方模块 pip install mysqlclient注:最新的django框架需要使用mysqlclient模块,之前pymysql模块与django框架有编码兼容问题。 二、ORM 1、ORM可以帮助我们做两件事:…