Poireau:一个采样分配调试器

2020-05-19 16:59:10

libpoireau库截取一小部分toalloc/calloc/等调用,以生成具有统计代表性的应用程序堆占用概览。虽然拦截器目前只跟踪长期分配(例如,泄漏),但我们也计划按照“电子围栏”的精神实现保护页面。

与LeakSanitizeror Valgrind不同,采样方法使得在生产中使用此库对性能的影响最小(请参阅性能开销一节),并且不需要对代码生成进行任何更改。

程序库的实现策略将大部分复杂性卸载到内核或外部分析脚本,并且对于少数采样的分配只覆盖系统内存分配器(或任何其他已覆盖系统错误锁的分配器),这意味着插装不太可能从根本上改变一个程序的行为。(#**$${##**$$}。预加载libpoireau.so比插入tcmalloconly(因为要调试分配)侵入性小得多。代码库也更小,在将新的库投入生产之前更容易审核。

最后,poireau.py分析脚本只报告旧的分配,而不是扫描堆以查找引用。对于期望在启动后快速进入稳定状态的应用程序服务器和其他工作负载,这比只报告无法访问的对象更有用:堆占用的缓慢增长是一个问题,即使罪魁祸首是可以访问的,例如,在应该清除的时候没有清除的列表中。

libpoireau目前的目标是在4KB页面的64位平台上运行Linux4.8+(用于静态定义的跟踪点支持)。执行make.sh在当前目录中创建libpoireau.so;代码需要与GCC兼容的C11实现。

在使用libpoireau之前,我们必须用Linux perf注册它的静态探测点;这可以在使用LD_PRELOAD启动程序之前完成,也可以在启动之后完成,这并不重要。

我们现在可以在libpoireau覆盖stdlib调用时启用跟踪点来生成perf事件。

这足以让Linux perf报告这些事件,例如,在perf top中。然而,这是很多信息,不一定有用。

执行scripts/poireau.sh$pid以启动对该PID性能跟踪,并将输出提供给分配跟踪脚本。每隔10分钟,该脚本将转储当前活动的旧(>;5分钟)采样分配列表。向poireau.py发送HUP信号,以获取所有实时采样分配的列表。旧分配最终将用已知的泄漏或启动分配来填充;通过向poireau.py发送USR1信号,从未来的报告中删除所有当前的旧分配。

在过程之外进行分析的一个关键优势是,我们仍然可以在崩溃后提供信息。向poireau.py发送一个USR2信号,列出最近对free或realloc的一些调用,希望它能帮助调试释放后使用。

perf通常需要sudo访问权限,但是以root身份运行all of poireau.py没有意义;poireau.sh只使用sudo执行Perfect。为了覆盖sudo下的perf二进制文件,请使用PERF=Which perf scripts/poireau.sh.。

您还可以通过不带任何参数调用poireau.sh来启用系统范围的跟踪。如果atime中只有一个进程将LD_PRELOAD libpoireau.so:poireau.py中的分析代码当前不能区分进程何时匹配分配和释放(编辑poireau.py中的全局通信模式,以便只从与特定正则表达式匹配的程序中摄取事件),这将非常有用。系统范围的跟踪使跟踪程序启动时立即发生的事件变得更容易。

如果您必须在执行程序之前编辑init脚本以插入LD_PRELOAD变量,那么撤消编辑并尽快重新启动插入指令的程序是有意义的。

当LD_PRELOAD时,libpoireau截取对malloc/calloc/realloc/free的每个调用,并快速将绝大多数调用转发给实际实现,如果libpoireau不存在,这些调用将被使用。

只有那些标记为采样的分配才会被转移,在malloc和calloc的情况下,当调用被转移的分配时,free会被覆盖。最后,出于采样目的,realloc被视为一对malloc和free。

采样逻辑模拟以相等概率对每个分配的字节进行采样的过程。(硬编码)采样率的目标是平均每32MB采样一次分配;例如,我们对100字节的分配请求以相同的概率成为样本的一部分,就好像我们以1/(32*1024*1024)的概率抛出了100次落在";头上的偏向硬币,并决定如果这些硬币中有任何抛到头上,则将该请求作为样本的一部分。

这种无内存采样策略使得即使在对抗工作负载的情况下,也可以推导出堆分配调用的形状的统计界限。然而,简单的实现速度很慢,我们不是为每个分配的字节翻转偏向硬币,而是通过从指数分布生成值来生成连续的尾数。

每当选择对malloc、calloc或realloc的调用进行采样时,libpoireau都会执行使用USDT(用户静态定义的跟踪)探测进行检测的代码。Linux Perf可以注释代码以生成事件(对于链接共享库每个进程,这是一个系统范围的开关);我们使用这些事件让内核捕获每个采样调用的调用堆栈。

此外,这些分配请求被转移到内部跟踪分配器。这使我们能够识别对跟踪的分配进行释放和重新分配的调用,这对于生成配对的USDT事件(释放或重新分配此分配)至关重要;它还确保我们将这些分配传递回备份跟踪分配器,而不是系统malloc。

对性能敏感的程序倾向于避免在热点区域进行动态内存分配。也就是说,通过在单个线程中重复调用一对malloc和free(大多数内存分配器的最佳情况),这里有两个微基准测试和上限限制libpoire.so中LD_PRELOAD的开销。下面的结果是在运行Linux 5.3.11和glibc 2.27的卸载AMD EPYC 7601上计时的。

基线(Glibc Malloc):0.092 us/malloc-free(0.047用户,0.046系统)预加载,无探测:0.153 us/malloc-free(0.058用户,0.094系统)预加载,探测器:0.236 us/malloc-free(0.067用户,0.169系统)预加载,跟踪:0.271 us/malloc free(0.069用户,0.203系统)。

这几乎是我们最糟糕的情况:我们预计会非常频繁地触发allocationtracking,每32个分配一次,而且我们的trackingallocator比普通的mmap/munmap稍微复杂一些(这一点我们仍然需要改进)。

基线(Glibc Malloc):0.042 us/malloc-free(0.041用户,0.001系统)预加载,无探测:0.044 us/malloc-free(0.043用户,0.001系统)预加载,探测器:0.046 us/malloc-free(0.042用户,0.004系统)预加载,跟踪:0.054 us/malloc free(0.042用户,0.012系统)。

在这种不太合理的大小下,将采样分配转移到跟踪分配器的开销不到5%。我们还可以观察到,尽管每当我们执行竞赛点时都会触发中断,但与生成回溯所需的时间相比,服务中断所花费的时间相对较少(<;20%)。这并不令人惊讶,因为我们使用的内核部分与分析性能问题时使用的内核部分相同。

基线(Glibc Malloc):0.017 us/malloc-free(0.017用户,0.000系统)预加载,无探测:0.020 us/malloc-free(0.020用户,0.000系统)预加载,探测器:0.020 us/malloc-free(0.020用户,0.000系统)预加载,跟踪:0.020 us/malloc free(0.020用户,0.000系统)。

在这里,所有的减速都是通过从我们的拦截器malloc到基础系统malloc的蹦床来实现的。

TL;DR:在分配微基准中,对于小型或中型分配,libpoireaustrumentation的开销约为5-20%,而对于非常大的分配,则高达~70%。

启用分配跟踪将为小型或中型分配再增加0-20%,为超大型分配再增加约130%。

对于一个什么都不做的程序来说,这些都是最坏的数字,但是循环中的malloc和free都是空闲的。在实践中,对性能敏感的程序在内存管理上花费的时间希望不到10%(而且比在大分配中花费的时间要少得多),这意味着libpoireau和捕获堆栈跟踪带来的总开销可能接近1-5%。

libpoireau包括派生自xoshio 256+1.0的代码,该代码由David Blackman和Sebastiano Vigna([email protected])于2018年编写,专用于公共领域。