我们的性能计数器是廉价的、方便的并且线程安全
让我们回顾一下当前的问题所在。
首先,展示的是我们的性能计数器,它们有一些非常好的优点。首先,它们是完全线程安全的,几乎没有任何性能开销,实际上对程序性能的影响极其小。所以,可以把它们随意地插入到代码中,它们几乎是“免费”的,几乎不会对程序的执行造成负担。
然而,唯一的代价是,在最终绘制时会有一些开销,因为那时会进行大量的渲染工作,但是这部分是隔离的,发生在帧处理完毕之后。因此,我们知道,这些性能计数器在某种程度上不会对实际的渲染过程造成影响。
实际上,它们对缓存几乎没有影响,执行的指令也非常少,通常只需要执行两到三条指令,如rdtsc
等。所以从性能计数器的角度来看,这种开销是尽可能低的。
并且,它们在当前状态下是完全线程安全的,可以在多个地方同时调用,而不会产生冲突。它们不需要上锁,也不需要检查是否有其他线程正在访问,可以完全并行地工作。这点非常好,因为这意味着可以将它们随意插入到任何地方,而且完全不需要担心性能问题。
这些优点是我们希望保持的,也是我们希望继续使用的功能。
它们报告一个操作花费的时间,但不报告它发生的时刻
目前的问题是,无法准确了解每个操作发生的具体时间,并且没有办法将这些信息以可视化的方式显示出来。之前展示的图表中使用了颜色编码,这样可以帮助辨识不同部分所占用的时间。例如,蓝色代表占用时间最长的部分,绿色代表占用时间最少的部分,红色和黄色分别代表中等时间占用。通过这种方式,可以直观地看到程序的哪个区域消耗了最多时间。比如在图表中,蓝色部分显示的是程序中消耗时间最多的区域,而这部分正是游戏相关的代码调用部分。
为了改进我们的性能分析视图,我们需要收集更多的数据
目前的问题是,虽然有一些性能数据可以显示,但是这些数据缺乏足够的上下文信息,无法准确地展示每个操作的具体时间。例如,现在的图表中通过颜色来表示不同部分的时间消耗,蓝色代表时间最长的部分,绿色代表时间最短的部分,但这些颜色并不能直接告诉我们每个部分具体代表什么。虽然可以通过鼠标悬停来查看具体的部分,理想情况下,应该能实现点击某个部分后进一步深入,查看更详细的信息。这将使得性能分析界面更加友好和交互性更强。
然而,当前面临的挑战是数据的不足。现有的数据仅包含一些平台层代码的时间戳,和程序中分布的几个定时器,虽然这些可以提供一些时间消耗的视角,但无法清晰地揭示具体哪些部分消耗了时间,以及它们是如何关联的。也就是说,目前没有办法精确了解不同定时器之间的关系,也不知道它们是如何相互交织的。
举个例子,虽然知道game update
和render
的时间范围,但调试系统并不能识别这些关系,因为它没有这种上下文信息。问题的根源之一在于,当前系统为了保持低开销,无法存储更多的上下文数据。
收集数据的过程应该是轻量的,任何实际的工作应该推迟到帧结束时再做
如果我们想保持当前系统的大部分优势,关键在于避免在定时器激活时进行大量工作。我们不希望在定时器处于活动状态时花费太多时间,因为这样会影响性能。我们可以在帧结束时花费任意的时间来处理数据,但这可能会导致程序的性能有所偏差。例如,帧结束时所做的额外工作可能会影响程序的缓存效应,这会导致每一帧之间存在一些性能波动,甚至可能会让程序的性能产生不一致的情况。
然而,只要我们将采样操作本身保持高效,尽量减少在定时器运行时的工作量,这样在帧结束时做大量的工作来分析和记录程序的性能数据是可以接受的。最重要的是,必须确保定时器的采样过程不影响程序的正常性能。
如果我们选择一种方法,例如采用基于日志的系统,尽量减少在定时器激活时的工作量,从而避免影响程序的性能。这是目前解决方案中最需要注意的部分,特别是避免在定时器运行时引入过多的工作量,这样可以确保程序的稳定性和性能。
基于日志的系统可能适合我们的需求
我们可以采用一种系统,在程序运行时写入日志条目,然后在之后收集这些日志条目。这种方法有一个很好的特点,就是我们可以以更高效的方式记录日志。我们可以在程序执行时记录一个日志条目,并在其中记录下当时的时间戳(比如 tsc
定时器的值),甚至可能还包括当时运行程序的处理器信息。
通过这种方式,我们可以获取大量有关程序在特定时刻所做事情的信息。这样的方法能够帮助我们更高效地采集数据,同时保持低开销,不会影响程序的运行性能。这是目前在性能分析和调试方面的一种有效策略。
记录的事件数量会不会太大?
问题在于,我们预计要记录的日志条目数量其实相当大。如果开始记录像“获取渲染”“实体渲染”“基础绘制”之类的操作,这些操作每帧可能会有五千次调用。对于一个屏幕上有很多实体的情况来说,这个数量并不算过高,五千到一万次也很正常,尤其是当屏幕上有大量对象时,比如树木、敌人、子弹等,每个都可能是一个实体。这样,每一帧的记录就会非常庞大。
假设每次记录需要大约16字节,而每帧有五千次记录,那么记录的数据量大约为80KB到128KB。虽然这看起来像是很多数据,但是仔细想想,这个数据量其实不算特别庞大。带宽上不会造成很大压力,也不太可能污染缓存,因为这些数据一旦写入就不会再被读取,甚至可以通过非临时存储方式来写入数据,这样可以避免污染缓存。
因此,尽管记录的数据量可能有点多,但从实际性能角度来看,这些数据不会对程序性能造成显著影响。最终的解决方案可能是通过一个更适合的库系统来收集这些日志事件,事后分析这些日志,从而更加有效地了解程序的实际运行情况。
我们来尝试一下基于日志的方法
由于没有非常明确的方案,当前对日志记录的想法还不够清晰。考虑到目前的代码状态已经保存在文件中,如果需要的话,可以随时回滚。接下来可能会尝试一下日志记录的方式,之前提到过,可能在某些情况下引入日志记录是正确的做法。计划先尝试一下日志系统,看看实际效果如何,然后再决定是否继续使用这种方式。
我们可以调用 rdtscp 吗?
想要检查系统是否支持 rdtscp
指令,之前可能测试过,但不确定是否支持,因此打算再次进行测试。rdtscp
是一个返回处理器时间戳计数器并可能返回处理器核心的信息的指令,测试时会调用这个指令,看是否能够正常执行。如果系统不支持该指令,程序会崩溃,否则将继续执行并根据测试结果决定后续的处理方式。
__rdtscp
是一个编译器内建函数,它封装了 rdtscp
汇编指令,用于读取处理器的时间戳计数器(TSC),并可选择性地返回执行该指令的处理器核心的 ID。以下是详细的解释:
函数签名:
unsigned __int64 __rdtscp(unsigned int *AUX);
参数:
AUX
:这是一个指向unsigned int
的指针,用来存储处理器核心的 ID。rdtscp
指令不仅会读取时间戳计数器,还会将当前执行该指令的处理器核心 ID 存储到AUX
指向的内存位置。这样可以帮助了解哪个 CPU 核心执行了指令,特别是在多核系统中。
返回值:
函数返回一个
__int64
类型的值,这是 时间戳计数器(TSC) 的值。它是一个 64 位的值,表示自处理器重置以来的时钟周期数。每当时钟周期增加时,TSC 的值也随之增加,提供了一个高精度的时间源。- TSC 的 低 32 位 存储在返回值的 低 32 位 中。
- TSC 的 高 32 位 存储在返回值的 高 32 位 中。
核心 ID(存储在
AUX
中)表示执行该指令的 CPU 核心 ID。在多核系统中,这个信息非常有用,可以帮助分析性能问题。
工作原理:
__rdtscp
函数会读取处理器的 TSC 寄存器,并返回当前的时钟周期数。- 同时,它还会检索执行该指令的处理器核心 ID,并将其存储到
AUX
参数指向的内存位置。 - 函数返回 64 位的时间戳计数器值,这可以用来进行精确的时间测量、性能分析等。
示例用法:
#include <iostream>
unsigned int coreID;
unsigned __int64 timestamp = __rdtscp(&coreID);
std::cout << "Timestamp: " << timestamp << std::endl;
std::cout << "Core ID: " << coreID << std::endl;
在这个示例中:
timestamp
会存储 64 位的 TSC 值。coreID
会存储执行__rdtscp
指令的处理器核心的 ID。
总结:
__rdtscp
提供了 高精度的时间戳计数器(TSC) 和 处理器核心 ID。- 时间戳计数器(TSC) 提供了精确的低开销时间测量,适用于性能分析、基准测试等。
- 核心 ID 有助于在多核系统中识别处理该指令的 CPU 核心,特别在性能分析和调试中非常有用。
该内建函数使开发者能够收集精细的时间测量数据,尤其适用于性能分析、基准测试以及低级别的时间测量。
rdtsc 告诉我们经过的处理器时间
我们所讨论的是“已读取时间戳计数器”(RTS)或“时间戳计数器”(TSC),它提供了一种测量程序执行过程中时间流逝的方式。当我们调用它时,实际上是从一个寄存器或计数器中读取一个值,可以理解为读取处理器的时间。随着程序指令的执行,这个时间会不断增长。
通过在程序的不同位置调用它,我们可以测量在两个调用之间,处理器花费了多少时间。它的一个重要特点是,时间计数总是单调递增的,即时间不会倒退。虽然不能通过它直接推断处理器内部发生了什么,但是它可以提供一个合理的、细粒度的时间估算,帮助我们了解在两个程序位置之间实际执行了多少工作。
总结来说,TSC(时间戳计数器)是一种非常有用的工具,能够准确且高效地测量程序执行的时间流逝,尤其是在评估程序性能时,它提供了一个非常好的方式来估算处理器在不同阶段的工作量。
https://www.felixcloutier.com/x86/rdtscp
rdtscp 应该还会报告哪个处理器/核心在运行我们的代码
我们原本预期RDTSCP
指令可以执行两个功能。首先,RDTSCP
确实会返回时间戳计数器(TSC)的值,这个功能我们已经知道,它会提供一个处理器时间戳计数器的当前值。这个部分是我们已经获取到的,没什么新意。
然而,原本希望RDTSCP
能够返回的第二个信息是关于当前运行在哪个处理器核心上的指示。还能得知当前执行的线程在哪个核心上运行。这是之前的期望,但看起来RDTSCP
并没有提供这个功能。
在查阅了一些文档后,我发现实际返回的确实是处理器ID(CPUID
),而这个ID存储在ECX寄存器中。这个ID代表的是当前执行线程所在的处理器核心。
我们将使用简化版的 debug_record 来记录调试条目
我们现在讨论的是如何搭建一个更加面向日志记录的系统,而不是之前那种只适用于锁定竞争的系统。首先,有一些调试记录我不希望更改,所以暂时称它们为调试事件(debug_events)。这样做的原因是,调试记录中包含一些信息,比如函数名称和行号,我们并不希望每次都存储这些信息,因为没有必要。我们希望调试事件的数据量不要太大,因此我们打算在存储这些信息时稍微保守一些,以便能够更灵活地处理。
对于调试事件,计划是它们将包含几个关键的内容:时钟(clock),RDTSCP
返回的值以及一些状态信息。除此之外,我们只需要存储一个指向调试记录的索引。这个调试记录的索引可以让我们定位到具体的调试记录。
具体来说,我们可以通过存储一个指向调试记录的指针来实现这一点。之所以这么做,是因为我们面临着双重编译单元的问题,即我们无法确定当前应该查询的是哪个数组。在这种情况下,存储指针似乎是一个不错的选择。
不过,也有一个问题,我并不完全喜欢这种方式。因为如果我们能够使RDTSCP
指令正常工作,我们可能会需要存储CPU编号(Core ID)。如果能存储CPU编号,那会很有帮助。所以,我们希望为此预留一些额外的空间,以便存储CPU编号。
现在,我在思考是否有任何更有效的方式来编码这些信息,既能保存需要的数据,又不会浪费太多的存储空间。
在 debug_event 内区分线程、核心、debug_records 和 debug_record_arrays
考虑到存储空间的限制,我们可能可以采取以下策略来设计调试事件的存储结构。首先,可以为每个调试事件分配一个16位的线程索引或核心索引,具体取决于需求。因为我们预期这些索引的数量不会太多,因此使用16位应该能够有效地压缩数据到合理的范围。
此外,我们还可以为每个调试事件存储一个调试记录的索引,这个索引用于指向具体的调试记录。考虑到数据结构的设计,我们还可能需要存储与调试记录相关的其他信息,例如调试记录的数组索引。所有这些信息最终构成了调试事件的核心内容。
从存储需求来看,每个调试事件将包含128位的存储空间,其中包括16位的线程索引或核心索引、调试记录索引以及其他相关数据。每个调试事件的大小将大致是16字节左右,这样可以有效地存储每个事件的信息。尽管我们目前还没有所有相关数据,但这是我们目前考虑的设计思路。
struct debug_event {
uint64 Clock;
uint16 ThreadIndex;
uint16 CoreIndex;
uint16 DebugRecordIndex;
uint16 DebugRecordArrayIndex;
};
我们只需要一个大的 DebugEventArray
如果我们想开始将这些调试事件写入日志,可以考虑使用一个全局变量。在构建过程中,我们将采用类似之前多重翻译单元(multiple translation unit)方法的方式,创建两个不同的日志记录器(loggers)。但是,后来意识到实际上不需要这样做。
这些调试事件不需要唯一的索引,所以我们可以简单地创建一个调试事件数组(debug event array),所有线程都可以向这个数组写入数据,这样完全可行。因此,我们只需要定义一个调试事件数组,并将其声明为全局变量。
接下来,在程序中,我们将把调试事件数组放在一个适当的位置,然后就可以开始写入数据。我们希望这个数组足够大,虽然不确定数组能做多大,因为如果过大,编译器可能会开始报错,或者程序可能会因为占用了过多的静态内存而运行不正常,因此在设置数组的大小时需要小心。
总的来说,开始时我们会让这个调试事件数组尽量大一点,但仍需要确保它不会导致编译或运行时的问题。
跟踪在调试事件数组中的位置
接下来,另一个需要做的事情是创建一个计数器,用来追踪调试事件数组的索引。这个计数器将帮助我们知道当前正在向调试事件数组的哪个位置写入数据。简单来说,它就是用来标记我们在数组中的写入位置,确保每次写入时可以正确地定位到合适的地方。
因此,我们需要一些额外的处理来管理这个索引,并且根据需要更新它。通过这个计数器,我们能够在调试事件数组中持续地添加新数据,而不会覆盖或丢失之前的记录。
对调试事件数组进行双缓冲
为了确保这个系统相对线程安全,我们需要考虑如何在数据收集的过程中避免数据冲突。具体来说,问题在于在数据被收集的同时,其他线程仍然可能会向数据写入。为了解决这个问题,可以采用类似“乒乓缓冲区”的策略,即有两个缓冲区,一个用于写入,另一个用于收集数据。我们可以通过交换这两个缓冲区,确保在收集数据时没有其他线程向正在收集的缓冲区写入数据。
因此,可以设置多个调试事件数组(debug_event_arrays),它们的作用是作为存储空间。在这个设计中,调试事件数组实际上只是指向这两个缓冲区中的一个。具体实现时,我们并不关心当前写入的是哪个缓冲区,而是依赖于系统在背后自动切换它们,这样每次数据写入都会发生在不同的缓冲区上。
接下来,需要确保代码能够正确编译,以验证这个设计是否没有错误。在编译时检查并确认系统能够正常工作,是非常重要的一步。
填充调试事件记录
在调试事件数组中,接下来在时间块中进行的操作是,当我们执行原子加操作时,不再直接执行原子加和计数的操作,而是采取其他方式来处理。虽然目前我们会保留之前的代码以备参考,但最终会将其简化到最小化的操作,因为在这些计时器的操作中,我们不希望执行过多的处理。
具体来说,首先我们会调用 rdtsc
来获取时间戳计数器的值,然后将这些值写入调试事件数组中。具体需要写入的内容包括:时钟、线程索引(假设有的话)、核心索引(假设有的话)、以及调试记录的索引。对于调试记录索引,由于我们知道代码中的配置不会超过 65,000 个分析点,因此可以放心地将其强制转换为合适的数据类型来存储。
这整个过程的目的是确保数据能够正确地存储到调试事件数组中,并且在执行时保持效率和最小化开销。
数组索引将由 build.bat 中定义的预处理符号决定
接下来,我们会写入数组索引。关于数组索引,实际上目前我们还不知道它的具体值,因为它还没有被定义得很清楚。然而,我们可以很容易地在构建过程中定义它。我们可以在构建时定义一个类似 CDebugRecordArrayIndex
的值,然后将其设置为 0 或 1,或者将其优化为 1 或 0,这样就可以根据编译的路径来确定是使用哪一个。这种方式能够确保我们在不同的程序路径中获得正确的定义。
至于写入位置,问题就出在如何将数据存入合适的地方。这时就需要引入原子操作。如果查看当前的实现,我们会看到在内建函数库中,已经有了 atomic add
,我们可以利用这个现有的原子加操作,而不需要引入新的操作。为了确保能够处理大范围的地址,虽然实际上我们不一定需要 64 位的地址,但我们暂时将它作为 64 位处理。因此,接下来我们就使用 atomic add
来处理这些写入操作。
定义 AtomicAddU32
为了简化操作,决定先不使用64位的原子操作,而采用32位的原子操作(atomic add
)。通过这种方式,可以在对DebugEventIndex
进行原子增量操作时,确保每个线程都会得到一个唯一的索引值。由于原子操作保证了所有核心以相同的顺序看到并执行增量操作,避免了多个线程获取相同的索引值。
首先,使用原子增量操作来更新DebugEventIndex
,这样可以确保每个事件的索引是唯一的。通过这种方式,每个线程在获取到当前索引后,都会将其递增1,保证了每个线程的索引不重复。接着,将返回的索引值存储到事件数组中,并在调试模式下进行验证,确保事件索引没有超过预定的最大值。为了确保不写入非法数据,我们通过断言来检查索引值是否在合法范围内。
在代码中,我们将为事件数量定义一个最大值(MAX_DEBUG_EVENT_COUNT
),这将限制我们可以记录的事件数量,以避免内存溢出或非法写入。这个值将是一个固定的常量,因为我们希望从程序开始时就能分配好足够的内存,而不进行动态分配。
随后,对于每个时间块,当事件记录完成时,我们可以通过获取当前的事件数组索引,记录该事件的相关信息(例如,时钟值等)。这部分操作与之前的流程是相同的,因此整个过程实际上只需要通过记录调试事件来完成,而每个事件的处理过程也是一致的。
简而言之,流程分为:使用原子操作确保事件索引的唯一性,存储事件信息,并确保事件索引不超出预定的最大值。最终,整个调试事件的处理过程将保持一致性和线程安全。
使用条目类型区分定时块的开始和结束
为了确定某个操作的耗时,可以通过查看它在日志中被标记的时间戳来实现。具体来说,可以记录事件发生的时间戳,并在相应的地方再次记录时间戳。通过比较这两个时间戳的差值,就能得到操作执行的时长。
为了在记录事件时更好地管理信息,决定在每个调试事件中增加额外的空间,用于存储事件类型。例如,事件类型可以标记为“调试事件开始块(DebugEvent_BeginBlock)”或“调试事件结束块(DebugEvent_EndBlock)”。这种做法不仅可以清晰地标识每个事件的起始和结束,还能够提供足够的灵活性来处理不同类型的调试事件。
在日志中,可以为每种事件类型指定一个明确的标识符,使得每个事件的意义更加明确。通过这种方式,能够更加系统化地记录和分析事件,特别是当需要计算某些操作的持续时间时,这种做法将特别有用。
总之,通过为每个调试事件增加类型信息和时间戳,可以更加高效地管理和分析程序执行过程中的各类调试信息,同时确保日志中每个事件的上下文更加清晰、准确。
将重复的事件记录代码整合到 RecordDebugEvent 宏中
为了记录和处理调试事件,计划采用一种宏方法,通过宏来简化和标准化记录调试事件的操作。具体来说,设计了一个宏record_debug_event
,这个宏将用于标记和记录事件的类型(例如“开始块”或“结束块”)。这种方式使得代码更加简洁和易于维护,因为可以通过一次调用宏来处理所有调试事件的记录工作。
在实现上,调试事件将被存储在一个全局数组中,数组的每个元素都对应一个调试事件的记录。事件的类型将通过宏传入,确保每个事件在记录时都带有明确的类型标识。通过这种方式,能够有效区分不同类型的调试事件,从而提供更精确的日志数据。
此外,宏的实现还会处理一些特定的操作,例如确保所有的调试事件都能够按照预期的方式进行记录。最终,整个过程中的一些变量和函数会被声明为全局变量,以便在程序的不同部分都能访问和使用这些记录事件的功能。
尽管这种方法实现了调试事件的记录,但由于某些语言特性缺失,实际上需要做一些额外的工作来确保事件类型的识别和记录。这是因为,如果编译器语言本身支持更好的特性,理想情况下这部分工作可以在编译时完成,而不需要运行时去处理。总之,这种方法虽然有些繁琐,但仍然能够提供足够的功能和灵活性。
在遇到类型不匹配或类型不明确的情况下,也会进行相应的修复和调整。例如,如果发现宏中使用的类型不匹配,可能需要调整为适当的数据类型,确保指针和长整型数据的正确处理。
总的来说,使用宏来处理调试事件的记录,是为了简化代码、提高可维护性和确保日志记录的准确性,同时在实现过程中需要处理一些语言本身的限制和兼容性问题。
我们不能同步交换指针并同时清除事件索引…
在实现过程中,遇到了一个问题:当尝试交换指针并清除事件索引时,不能同时进行这两个操作。尽管可以同步交换指针,但无法同时清除事件索引,这样会导致一些问题。具体来说,这个问题是在进行指针交换的同时清除事件索引时,可能会出现意外的行为。
虽然这是一个调试系统,不会影响程序的核心功能,且由于多线程的调试事件是基于时序问题显示的,因此不会直接影响程序的运行。但为了确保系统的清晰和可靠,最好能够尽量减少这种潜在的调试错误。
因此,问题在于如何在交换指针的同时处理事件索引的清理工作,如何确保这两者之间的操作不会发生冲突或错误。这提示我们在实现时需要更加小心,尽量确保在多线程环境下操作的原子性和正确性。
…除非我们将位置和事件索引打包到一个 64 位变量中
这个内容描述了如何通过将两个变量合并成一个来简化调试事件的记录过程。具体做法是,使用一个全局的调试事件数组,并将事件数组的索引与事件索引打包到一个变量中。这样,通过执行原子操作(如原子加法)时,就可以将这两个值组合在一起,而不需要分别处理它们。下面是详细的步骤和想法:
合并两个变量:我们将
DebugEventArrayIndex
和DebugEventIndex
这两个变量合并成一个单一的变量。这样做的好处是可以在一个原子操作中完成对这两个值的增量操作,简化了多线程情况下的事件记录。使用全局调试事件数组:定义一个全局的调试事件数组,这个数组会存储所有的调试事件。然后使用某种方式来提取该数组的前32位作为事件数组索引,后32位作为事件索引。例如,可以通过按位与运算(bitwise operations)来获取这两个部分。
执行原子操作:在记录调试事件时,我们会执行一个原子加法操作(atomic add),来更新合并后的索引。这确保了在多线程环境中,多个线程不会出现竞态条件(race condition),从而确保每个线程都能独立地获取一个唯一的事件索引。
简化代码:通过这种方式,可以消除一些复杂的逻辑,简化代码结构,并减少对每个单独事件索引的处理,从而使整个系统更加高效。
线程安全性:通过原子操作和将两个索引打包到一个变量中,可以确保即使在多线程环境下,记录的调试事件依然是安全的,不会发生冲突。这有助于提高调试系统在高并发情况下的可靠性。
调试和断言:为了确保逻辑的正确性,可以添加断言(assert)来检查事件索引的有效性,确保它没有超过预定义的最大值。
代码的未来改进:虽然目前的解决方案已经简化了代码,但它也表明目前的语言和工具并没有直接提供解决这个问题的简便方法。如果语言或工具提供了更强的原生支持,这个过程本来可以更轻松地实现。
未解决的问题:在实现过程中,可能会遇到一些无法即时解决的问题。例如,如何确保在交换指针和清除事件索引时不会发生冲突等问题。虽然这些问题对于调试系统来说并不致命,但仍然需要考虑并尽可能清理代码以提高效率。
最终的目的是为了实现一个高效且线程安全的调试系统,尽量简化逻辑并减少复杂度,同时保证多线程环境下的正确性。
在帧结束时交换调试事件数组
在进行数据收集时,需要交换全局调试事件索引的值,以确保新的事件可以正确记录,同时保证线程安全性。具体做法如下:
进行数据收集(gather)
在进行数据收集的过程中,需要处理Global_DebugEventArrayIndex_DebugEventIndex
,将其拆分为DebugEventArrayIndex
和DebugEventIndex
,以便正确访问调试事件数组。执行同步交换(AtomicExchangeUInt64)
由于涉及多个线程并发访问,我们需要执行一个同步交换操作,以确保多个线程不会同时修改同一个索引值。使用原子交换(AtomicExchangeUInt64)
采用AtomicExchangeUInt64
操作,使当前的Global_DebugEventArrayIndex_DebugEventIndex
变量的值被安全地替换,并返回之前的值。这意味着:- 线程在获取旧值的同时,将索引值重置为
0
,保证新事件可以从头开始写入。 - 避免多个线程竞争修改索引,确保数据不会错乱。
- 线程在获取旧值的同时,将索引值重置为
确保新事件正确写入
通过上述同步交换操作,确保新的调试事件能够正确写入数组,同时保证旧数据可以被正确读取和处理。实现简单且高效
这个方法的好处是:- 使用原子操作,确保线程安全。
- 避免复杂的锁机制,提高性能。
- 保证调试事件数据的一致性。
最终,通过这个方法,我们可以保证多个线程在记录调试事件时不会相互干扰,同时确保数据的准确性和完整性。
临时将宏转换为内联函数,以便于调试
当前的目标已经基本实现,但仍然存在一些问题需要解决。
代码未能正常工作
目前,系统仍然存在错误,需要进一步排查问题的根源,以确保调试事件系统能够正确运行。改进调试体验
- 由于宏的调试难度较大,因此考虑将相关逻辑封装成 内联函数,以便更方便地进行调试。
- 目前的调试方式在某些情况下可能会遇到困难,尤其是宏展开后难以追踪具体的执行逻辑。
宏的调试问题
- 由于宏的性质,无法直接逐步跟踪宏内部的执行逻辑,这对调试来说非常不便。
- Visual Studio 在较新的版本(如 2015 及以后)可能已经提供了一些改进,但依然不是最理想的调试方式。
调整
RecordDebugEvent
的实现- 现有的
RecordDebugEvent
宏需要改进,考虑改为 可调用的函数,以提高可读性和可调试性。 - 具体方法是将
debug_event_type
和RecordIndex
作为参数,封装为一个内联函数,替代原有的宏定义。
- 现有的
优化换行处理
- 由于宏在编写时需要换行,因此在语法上需要使用
\
进行换行,以保证编译时的正确性。 - 在新的函数版本中,将不再依赖换行符,从而避免这些格式问题带来的潜在错误。
- 由于宏在编写时需要换行,因此在语法上需要使用
下一步计划
- 先进行基础调试,确保
RecordDebugEvent
能够正确执行并返回预期结果。 - 观察当前代码在调试模式下的行为,检查是否仍然存在数据竞争或线程安全问题。
- 若问题依旧,可能需要进一步修改同步机制或调整原子操作的实现方式,以提高可靠性。
- 先进行基础调试,确保
综上,主要目标是通过转换 宏 为 内联函数,来提高可调试性,并进一步完善整个调试事件系统的稳定性。
我们的调试数组不够大,无法记录我们正在记录的条目数
当前的调试事件系统似乎在某些情况下产生了异常的大量记录,需要进一步检查其合理性。
异常的调试事件数量
- 目前已经写入了 65,000 个调试事件,这个数量是否合理存在疑问。
- 由于每帧都会清除调试事件,因此按照正常逻辑,不应该积累如此之多的记录。
可能的原因
- 记录频率过高:某个逻辑可能在极短时间内不断地触发
record_debug_event
,导致事件数量迅速增长。 - 事件清理未生效:虽然理论上每帧都会清除事件,但可能某些条件下没有正确执行清理操作,导致事件不断累积。
- 多线程并发问题:如果多个线程同时写入调试事件数组,可能会出现非预期的数据累积。
- 记录频率过高:某个逻辑可能在极短时间内不断地触发
验证和调试方案
- 添加日志或断点,检查
record_debug_event
的调用频率,确认是否真的被调用了 65,000 次。 - 检查事件清理逻辑,确保
Global_DebugEventArrayIndex_DebugEventIndex
以及相关数据在每帧结束时被正确重置。 - 确认多线程同步,如果多个线程同时写入调试事件数组,可能会导致数据异常累积,需检查原子操作是否正确同步。
- 添加日志或断点,检查
下一步行动
- 插入断点:在
record_debug_event
内部插入调试断点,观察调用栈,确认触发频率是否正常。 - 打印调试日志:在
Global_DebugEventArrayIndex_DebugEventIndex
清除时添加日志,验证是否按预期执行。 - 检查多线程环境:如果系统是多线程的,确保调试事件系统正确处理了并发访问。
- 插入断点:在
总之,需要进一步确认 record_debug_event
的调用逻辑,并检查调试事件数组是否正确清理,以避免非预期的事件积累。
临时增加这个数字,看看这是否真的在发生γ
临时增加了调试事件的上限,以确认是否真的会产生大量的调试事件。结果表明,确实存在大量的调试事件被记录,数据量远超预期。
确认大量事件的存在
- 通过调整上限,观察到调试事件数量确实迅速增长,验证了问题的真实性。
- 事件数据流量很大,虽然不会造成严重的性能问题,但仍然值得进一步优化。
可能的原因
- 可能存在某些逻辑触发了过多的调试事件,导致数据量激增。
- 事件记录的条件可能没有合理过滤,导致一些不必要的事件也被写入。
- 事件清理机制可能未正确执行,导致历史数据持续累积。
优化方向
- 添加事件类型筛选,减少不必要的调试信息记录。
- 优化事件触发逻辑,确保只有关键调试信息被记录,而不是所有可能的事件。
- 检查清理机制,确保每帧正确清除过期的调试事件,防止数据堆积。
虽然大量调试事件不会直接影响性能,但如果长期不优化,可能会影响调试效率,因此需要进一步优化事件记录逻辑。
我们记录的信息将允许更深入的分析操作
目前已经成功记录了完整的调试事件日志,虽然还没有对这些日志进行实际处理,但已经可以完整追踪所有事件的发生时间。
现状分析
调试事件的完整记录
- 事件日志已经包含了所有发生的调试事件,能够全面追踪程序的执行流程。
- 目前仅限于记录,还没有对这些数据进行进一步分析或可视化处理。
嵌套关系的识别
- 由于事件记录方式的改进,现在可以识别嵌套事件的层级关系。
- 这意味着可以实现更复杂的事件追踪,例如计算不同任务的执行时间,或者分析程序的并发行为。
下一步计划
实现日志解析和分析
- 设计数据结构,以便快速检索和关联不同的调试事件。
- 可能需要对事件日志进行分组,例如按照时间、线程或事件类型进行分类。
优化事件交换逻辑
- 需要设计一种机制,使事件记录在每一帧结束时能够正确切换并重置,而不会影响数据的完整性。
- 可能涉及到原子操作或锁机制,以确保多线程环境下的数据一致性。
尝试可视化调试数据
- 如果要进行深入分析,可以考虑将日志转换为可视化图表,例如时间轴或瀑布流图,以更直观地查看程序执行情况。
目前的工作重点是确保数据交换和日志解析的正确性,一旦这些基础部分完善,就可以开始进行更高级的数据分析和优化。
回顾一下数组和事件索引的打包
目前的实现方式是将事件索引(Event Index)和调试数组索引(Debug Array Index)捆绑在一起,以便能够原子性地同时交换它们。这么做的原因主要有以下几点:
为什么要捆绑它们?
调试数组的双缓冲机制
- 由于调试数组会在多个缓冲区之间切换(flip-flop),需要一种方式确保在切换时数据的完整性。
- 通过捆绑索引,可以确保切换时同时更新缓冲区索引和事件索引,避免线程在错误的缓冲区写入数据。
原子性交换,避免数据竞争
- 由于多个线程会同时写入调试缓冲区,需要保证切换时不会发生写入冲突。
- 通过原子交换(Atomic Exchange),可以保证:
- 在读取缓冲区内容时,不会有其他线程继续往该缓冲区写入新数据。
- 线程可以安全地切换到新的缓冲区,并从事件索引0开始写入,而不会影响之前已经收集的调试信息。
防止线程意外写入已切换的缓冲区
- 线程在写入事件数据时,需要知道当前正确的调试数组索引。
- 如果没有原子性交换,可能会出现某些线程继续往旧缓冲区写入数据的问题,从而导致数据错误或丢失。
如何实现捆绑?
使用一个变量存储两个索引信息
- 采用高32位存储调试数组索引,低32位存储事件索引。
- 这样可以通过一次原子操作同时交换两个值,而不需要单独处理它们。
原子性交换逻辑
- 线程在写入数据前,先从该变量提取正确的缓冲区索引和事件索引。
- 当需要切换缓冲区时,使用Atomic Exchange重置事件索引,并指向新的调试数组。
总结
- 这种方法的核心目标是在多线程环境下,保证调试事件的正确记录。
- 通过原子操作,确保在读取调试数据的同时,其他线程不会意外写入旧缓冲区。
- 这样可以有效减少数据竞争,确保调试信息的完整性,同时提升系统的稳定性。
正确切换事件数组索引
当前的目标是在双缓冲调试数组之间正确切换,确保调试事件数据的完整性,同时避免多线程竞争问题。具体的做法如下:
1. 需要额外的全局变量
为了控制缓冲区的切换,需要一个额外的全局变量来跟踪当前正在使用的调试数组索引:
- 变量名:
GlobalCurrentEventArrayIndex
- 作用:指示当前线程应该写入的调试数组,值在
0
和1
之间交替。 - 初始值:
0
(需要显式初始化,防止未定义行为)
2. 切换逻辑
切换调试数组索引
- 每次处理调试事件后,需要切换到另一个缓冲区,这样线程可以继续往新缓冲区写入,而不会干扰当前正在读取的数据。
- 通过
GlobalCurrentEventArrayIndex = !GlobalCurrentEventArrayIndex;
在0
和1
之间交替。
更新调试数组索引
ArrayIndex_EventIndex
的值需要右移 32 位,然后存入 高 32 位,用于标识当前调试缓冲区索引:uint32 EventArrayIndex = ArrayIndex_EventIndex >> 32LL;
- 这样可以确保在原子交换时,同时更新调试数组索引和事件索引。
计算事件索引
- 事件索引仍然存储在低 32 位,可以通过 与掩码(bitwise AND) 提取:
uint32 EventCount = ArrayIndex_EventIndex & 0xFFFF'FFFF;
- 这个
EventCount
值表示写入的下一个事件索引,因此实际的事件总数 = EventCount - 1。
- 事件索引仍然存储在低 32 位,可以通过 与掩码(bitwise AND) 提取:
3. 数据处理
- 现在,我们能够知道:
- 当前应该写入的调试数组索引
- 当前缓冲区中存储了多少个事件
- 已经完成的缓冲区数据可以安全读取
- 这样就可以确保:
- 一个缓冲区用于写入,另一个缓冲区用于读取,防止冲突。
- 多线程写入不会导致数据损坏,因为缓冲区切换时保证了原子性。
4. 关键点总结
- 额外的全局变量
GlobalCurrentEventArrayIndex
记录当前写入的缓冲区 - 每次切换时,
GlobalCurrentEventArrayIndex = !GlobalCurrentEventArrayIndex; 交替取
0和
1` - 通过
>> 32
和& 0xFFFFFFFF
,拆分高 32 位(缓冲区索引)和低 32 位(事件索引) - 使用原子操作 (
Atomic Exchange
) 确保多个线程不会同时写入同一缓冲区
这种方式确保了多线程环境下的安全写入,同时避免了数据竞争问题,使调试信息的记录更加准确可靠。
CollateRecords() 可以重现我们旧的非基于日志的事件系统的行为
当前的目标是实现一个新的函数 CollateDebugRecords
,用于整理和分析收集到的调试事件数据。这个函数的主要作用是:
- 接收调试事件数据(已确定的全局调试数组和事件数量)。
- 遍历调试事件,提取关键信息。
- 验证整理后的数据是否与之前的计数器数据一致,确保正确性。
- 为后续高级调试分析奠定基础(如事件嵌套关系分析)。
1. CollateDebugRecords
需要的参数
- 事件数量 (
EventCount
):表示当前调试缓冲区中存储的调试事件数量。 - 全局调试事件数组 (
Events
):提供所有已记录的调试事件。 - 调试状态 (
DebugState
):用于存储整理后的调试数据。
2. 代码逻辑
清除旧的调试快照数据
- 只需要清空当前要写入的快照数据,不必清空整个
debug_state
结构。 - 避免不必要的内存清空,提高效率。
- 只需要清空当前要写入的快照数据,不必清空整个
遍历所有调试事件
- 通过
EventIndex
遍历global_event_array
,逐条读取调试事件。 - 提取时间戳(clock value):
- 复用之前的方法提取时间戳信息。
- 可能需要类似
Event->Clock
这样的字段(具体结构待确定)。
- 通过
校验整理后的数据
- 通过
CollateDebugRecords
计算得到的统计数据,对比之前的计数器数据,确保结果一致。 - 如果结果一致,说明整理方法正确,可以继续进行更复杂的分析。
- 通过
扩展到高级调试分析
- 整理后的数据不仅可以用于统计事件数量,还可以用于:
- 分析事件嵌套关系(例如函数调用层次)。
- 记录不同线程的调试信息(便于分析多线程调试)。
- 创建更详细的调试报告,提供更丰富的调试信息。
- 整理后的数据不仅可以用于统计事件数量,还可以用于:
3. 关键点总结
CollateDebugRecords
负责整理调试事件数据,确保数据可用性和正确性。- 先清空旧快照数据,避免多余清空操作影响性能。
- 遍历所有事件,提取时间戳和关键信息,验证其正确性。
- 与已有计数器数据比对,确保整理方法正确。
- 为更高级的调试分析做准备,如事件嵌套、线程分析等。
这一步完成后,我们将不仅能获取与旧计数器相同的数据,还可以得到额外的调试信息,从而构建更完整的调试系统。
我们有两个事件数组(每个编译单元一个),这使得代码比我们拥有更好的工具时更显得复杂
我们发现了一个小问题,虽然这与我们刚刚编写的代码无关,而是与存储方式有关。目前,我们仍然有两个独立的事件数组,这导致线性化数据的过程变得有些麻烦。
我们需要一种更智能的方法来处理这个问题,我们需要知道每个数组中实际存储了多少数据。为此,我们调整了一些代码结构,使其更易于管理。虽然这个问题并不严重,但它确实让整个处理流程显得有些笨拙。
这种复杂性主要源于尝试为数据分配唯一的计数器,而这又是因为我们使用了两个不同的编译单元。如果编译器能够更好地优化特定函数,我们可能根本不会遇到这种情况。然而,由于 Visual C++ 编译器的某些限制,我们不得不采用这种方式进行处理。
总的来说,这并不是一个无法解决的问题,但它确实让代码的组织方式比理想情况更加繁琐。我们接下来需要改进存储和访问方式,使其更加高效。
线性化对 debug_record 数组的访问
通过查看当前的代码,能够清楚地知道我们有多少数据,计算总数时,我们可以直接将两个数组的大小相加,因为我们已经清楚这两个数组分别有多少元素。因此,我们可以通过调用计数器索引来确定总共有多少数据。
接下来,我们清除了所有的数据,确保状态已经重置。然后,我们需要知道当前是正在查看两个数组中的哪个,这时我们引入了一个概念——计数器状态数组。这个数组会有两个部分,一个指向主数组的部分DebugRecords_Main,另一个指向优化后的数组部分DebugRecords_Optimized。这样,两个数组就被线性化成了一个大数组的两段,每一段对应一个特定的数组部分。
在这个过程中,当我们需要获取某个事件对应的计数时,只需使用 DebugRecordArrayIndex
来索引我们需要的数据。获取到计数器后,基于事件的 DebugRecordIndex
,我们就能知道应该增加多少,这样就得到了事件的计数。
然后,我们根据计数值判断该事件是否为开始块(DebugEvent_BeginBlock),如果是的话,则执行特定的操作。如果不是,就按常规操作处理。处理完计数器后,我们会进行事件快照的写入。在这个过程中,我们会通过增量更新快照的计数,并记录当前的时间周期(cycle count)。为了准确性,我们会减去上次记录的时钟值,再加上当前的时钟值。
尽管目前无法100%确认所有的细节是否完美,但这些就是我们处理逻辑的核心步骤。最终,假设所有过程都能按预期顺利进行,事件处理将会在两个数组之间切换,并准确记录计数信息,确保所有数据都能正确反映每个事件的发生时机。
修复编译错误
在调试过程中,遇到了一些重定义问题。具体来说,出现了对某个变量的重新定义,可能是由于在代码中不小心将 x = 0
赋值给了某个变量,导致了这个错误。对此进行了修正。
此外,在处理64位与32位之间的转换时,发现有些地方可能还没有正确进行转换,导致了相关错误。为了应急,决定暂时将相关变量改为64位处理,虽然这并不是最终的解决方案,最终还是需要将其调整为32位。
在处理过程中,还遇到了一些未声明的变量问题,这显然是由于代码中的小错误所导致的。通过逐步修正这些问题,可以确保后续的代码能顺利运行。
总的来说,当前主要问题是类型转换和变量声明,正在逐步进行修复。
访问调试记录的文件名、函数名和行号
目前还有一个问题需要解决,那就是在调试过程中,并没有填充文件名等信息,因此这些信息不会显示出来。为了解决这个问题,可以在关联数据时,看到每个条目时,直接提取正确的值并填充进去。
具体的做法是,在处理调试记录时,可以从相应的地方抓取文件名等信息,并将其写入。这需要知道每个条目的计数索引以及计数数组的基础数组,尤其是需要知道调试记录索引和计数器数组。
为了解决这个问题,可以引入“调试记录数组”的概念,使用类似的技巧,通过索引来访问正确的记录,从而获取相应的计数器数组。可以通过已知的主数组和优化后的数组来访问这些数据。只需要将这些信息插入到代码中,就能正确地进行填充。
然而,在实际运行时,发现只有一个条目能够正确写入,并且出现了反向的问题。看起来调试记录中的一些信息还没有正确设置。时间有限,因此无法进一步调试和完善,可能需要明天继续进行。
另外,还注意到在增加计数时应该使用++
,即递增方式来处理,而不是一次性加一,确保计数的准确性。
基于日志的系统似乎正在工作
目前的进展看起来离成功还差一些,虽然还没有完全做好,但是值看起来和之前的结果差不多,像是计数值也接近预期。虽然还没有完全完成,但至少目前看来,调试事件日志已经基本能够正常工作,符合某种合理的调试事件定义。
虽然还有一些问题需要解决,但总体上,进展是顺利的,现在接近能够实现预期的功能。接下来,可能需要更多的调试和完善工作,明天可以继续深入分析。现在,先进行一个简短的暂停。
你提到你使用已知的基址进行内存管理。能详细讲一下吗?这意味着我可以通过从那个地址偏移来找到东西吗?如果我 fwrite 这个整个块,是否意味着我实际上是在 fwrite 整个游戏?
这意味着可以通过已知的基地址来定位内存中的内容。具体来说,你可以通过从基地址开始偏移来找到任何东西。你不需要每次都重新查找,而是通过相对于基地址的偏移量来确定目标位置。
通过这种方式,如果你写出整个内存块,理论上可以覆盖整个游戏,因为这些指针直接指向基地址,这样就可以通过跟踪偏移量来访问不同的内存区域。基本上,内存中的数据结构就是从这个基地址开始,以某种方式映射到你需要的位置,这样你就可以轻松管理这些内存块了。
总之,这种做法使得通过基地址的偏移量来查找和操作内存变得非常简便,类似于一种地址映射机制,尤其在游戏开发中非常常见。
你可以通过在命令行启用优化并用 #pragma optimize(“”, off) 和 #pragma optimize(“”, on) 来指定哪些函数需要优化,哪些代码不希望被优化
我们尝试过使用命令行和代码中的pragma指令来指定要优化的函数,但结果并不如预期。我们测试过了,发现通过这种方式并不能有效地启用或禁用特定函数的优化。我们尝试了在程序开始时关闭所有优化,然后仅为一个函数开启优化,再关闭优化,结果也没有成功。
如果有人能够验证并成功使其工作,并且证明它确实对该函数进行了优化,欢迎分享具体的方法和步骤。因为我们的尝试没有得到理想的效果,而且我们怀疑问题可能出在优化没有真正生效上。我们可以回过头去再看看这个问题,可能是某些细节没有处理好。
你们有没有使用 size_t,还是直接使用 u32、u64 等?
我们确实有使用过 size_t
类型,尽管我并不太喜欢这个名字。我们把它称为“内存索引”,这是为了处理一些需要适应指针大小的情况。在某些时候,需要一个能够容纳指针大小的类型,这时候 size_t
就非常合适,因为它能适应不同平台的内存地址大小。我们使用这种类型来确保在内存操作中能够适应不同的系统架构,确保代码的跨平台兼容性。
你们在添加/移除字段时会考虑结构体的填充吗?还是这对你们来说不是重点,字段的顺序不太重要?
在添加两个字段时,我们确实会考虑结构体对齐问题。这是一个习惯,虽然有时可能不需要过多讨论,但它确实影响了我们在编写代码时的选择。结构体字段的顺序确实很重要,因为它直接关系到内存对齐的优化,能够影响程序的性能。因此,每当我们设计结构体时,都会有意识地安排字段的顺序,确保内存布局是最优化的。
此外,调试信息中的核心编号有什么意义?看起来线程 ID 更重要
核心号在调试信息中的作用是帮助追踪任务是否在不同的核心之间移动。虽然线程ID通常更重要,但核心号能提供额外的信息,尤其是在查看调试视图时。如果任务在执行过程中从一个核心迁移到另一个核心,这个信息可以帮助我们了解程序的行为。此外,核心号也有助于分析缓存一致性问题,特别是在多个核心共享数据时,可以通过查看核心号来判断是否发生了缓存不一致的情况。因此,记录核心号对于调试和性能分析非常有用。
期待明天
好了,这一切进展得还不错,并不算太难。接下来,明天将会更有趣,看看它的性能如何,以及它是否会对程序的运行产生太大影响。总体来说,进展还是挺顺利的。现在完成了这些,接下来很期待明天能通过更多的调试信息来进行探索,看看能不能获得一些有趣的调试视图。这将会很有意思,至少这是我目前的想法。
我们如何避免收集或显示调试渲染代码的统计信息?
为了避免收集或展示调试渲染代码的统计信息,可以通过一种方法来实现,这在之前可能是做不到的。这个方法可能是通过某种日志功能来处理的,具体的实现将会在明天或这周的几天内进一步探索和完成。
性能分析器和调试器一样糟糕吗?
有时是的,简单的配置或者基础的性能分析工具就像开发者一样,存在一些问题。
你怎么看待检查异常(checked exceptions)?
对检查异常(checked exceptions)这个概念不太了解。通常来说,对异常的处理并不喜欢,简单来说就是不喜欢使用异常。可能我了解检查异常的具体内容,但不常用这个术语。