去年学习Go
语言时,有位同学说了一句让我至今仍深刻记忆的话:“我们有足够多的工具来进行性能分析,以找出性能问题的根源”。
后来我发现,Go
语言的性能分析工具确实非常强大。更重要的是,它被设计成可以直接在生产环境中采样线上数据。
然而,当我写Lua
代码时,我并没有自信能说出同样的话。尽管我之前曾多次实现Lua
性能分析器。
这些分析器的实现原理与gprof
类似,只是细节略有不同。在代码块进入时记录函数的进入时间,在退出时统计函数的执行时间和执行次数。
为了准确评估rpc:call
等函数的CPU时间,还添加了一个选项用于去除coroutine
的让出时间。
然而,这些性能分析器存在一些缺点:
首先,它们对宿主程序的性能影响很大。在以函数为区间进行耗时统计时,甚至可能达到1000%
的性能影响。因此,不能在线上环境中使用,只能在开发期进行自测。
其次,它们只能统计Lua
函数(包括C
编写的闭包和lightCFunction
),无法统计C
模块内部的C
函数开销。而使用其他C
性能分析工具时,也无法分析与Lua
函数相关的耗时。这在进行性能分析时会导致非常不连贯的感觉。
此外,当使用C
的性能分析器进行分析时,我们会失去上下文信息。由于Lua
是用C
语言编写的虚拟机,当我们发现某个C
函数的耗时很高时,无法确定是哪段Lua
代码导致的。例如,当发现tremove
函数的CPU使用率很高时,无法知道是哪段Lua
代码引起的。
最后,这些性能分析器是实现在宿主进程中的。如果宿主进程陷入死循环,将无法获取任何性能分析数据。
但当时我并没有找到解决以上问题的好办法,直到最近我开始研究eBPF
,我终于觉得自己可以解决这些缺点,并且实现一个和Go
语言类似的性能分析器。
现在回想起来,已经过去一年了。
新的性能分析器和Go
的性能分析器一样基于栈采样技术,这样可以做到对目标程序的性能影响最小。
和Go不同的是,我这次实现的Lua
性能分析器和linux
下的perf
一样,是一个独立的程序。
这样可以做到对目标程序无侵入,并且在目标程序死循环的情况下,依然可以正常运行。
按照最初的想法,这并不是一件太困难的事情。只需要在bpf
程序中获取C
的callstack
和Lua
的callstack
,然后在用户空间将它们合并。
最后,按照火焰图的格式进行输出并生成火焰图。
整个过程并不复杂。
然而,当我开始实际实现时,事情的发展远远超出了我的预期,整个过程触及了我知识的盲区。
我本以为eBPF
发展了近9年,在内核空间获取C
的callstack
应该只是一个API的事情。然而,现实却给了我一个沉重的打击。
现代编译器只要开启优化,默认情况下会抹去栈帧指针。而bpf
中的内置API
只能在栈帧指针保留的情况下轻易获取整个callstack
。
我面临两个选择:要求被性能分析的进程在编译时必须使用-fomit-frame-pointer
编译选项,或者我必须手动进行栈回溯。
我的目标是对目标程序进行无侵入性能分析,我认为强制要求目标程序使用-fomit-frame-pointer
也是一种形式的侵入。
目标程序需要不断忍受-fomit-frame-pointer
带来的性能损失。
而且,我无法要求像libc
等系统提供的so
文件必须保留栈帧指针。
于是,我只剩下一种方案,就是手动进行栈回溯。
手动进行栈回溯也有两种方案。
一种是在bpf
程序中将目标进程的完整栈数据复制到用户空间,然后使用libunwind
进行栈回溯。
另一种是直接在bpf
程序中进行栈回溯,并统计调用栈的出现次数,然后只将统计结果发送回用户空间。
很快,我否定了第一种方案。
bpf
最初的目的是用于过滤网络数据包,因此eBPF
程序应该基于此设计思路。
即在bpf
程序中处理和加工所需的数据,然后只将需要的数据传回用户空间。
而且,如果我们在用户空间进行栈回溯,由于ringbuffer
的异步性,我们无法及时采样到与收集到的栈数据相匹配的Lua
调用栈(因为我们需要先回溯完C
的callstack
才能获取L
指针,然后再对L
进行栈回溯,而这期间目标程序的Lua
调用栈早已经发生变化)。
对已经抹去栈帧指针的callstack
进行手动回溯,完全触及了我知识的盲区。
最初,我考虑仿照gdb
的方案,通过调试信息进行栈回溯。
但是,调试信息的数据量太大,不方便传送到内核。而且,解析调试信息这样的任务也不太适合由bpf
完成。
一个偶然的机会,我发现了elf
文件中的一个名为eh_frame的section
,全名为exception handling frame
。它被设计为在发生异常时为栈回溯提供完整的信息,这恰好符合我们的需求。
eh_frame
是由一系列CFI指令组成的,用于为栈回溯提供指导信息。这些CFI指令按函数顺序执行,即程序执行到某一行代码时,要回溯所有寄存器的状态,需要执行函数开始到该行代码之前的所有CFI指令。
幸运的是,在回溯时我们只需要获取caller
的EIP
和包含luaState *L
变量的寄存器的值,因此可以忽略大多数寄存器的回溯信息。
为了加快bpf
程序的执行速度,我们可以在将eh_frame
数据发送给bpf
之前进行预处理。
通过模拟CFI指令的执行,我们可以获得每行汇编对应的所有寄存器的回溯信息。
这样当我们在bpf
中获取到对应的EIP
时,可以使用二分查找快速获取所有寄存器的回溯规则。
为了更好地利用缓存,我们还可以生成一个类似于eh_frame_header
的数组,只包含EIP
,专门供bpf
程序进行二分查找。
一旦获取到索引,我们可以再查询真正的eh_frame
信息。
尽管上述方案没有问题,但它忽略了一个条件。
我们从elf
文件读取的是相对虚拟地址(PC
),而在bpf
程序中获取的是经过内核映射后的绝对虚拟地址(VA
)。
在对eh_frame
进行预处理时,我们需要将其中的PC
转换为VA
。这就需要使用到elf
文件的Program Header Table
信息。
Program Header Table
提供了整个elf
文件在进程空间中映射的分段情况。根据/proc/<pid>/smaps
中的映射信息,我们可以将PC
和VA
进行转换。
具体的转换逻辑是,当<program Header Table>.p_offset
与/proc/<pid>/smaps
中的offset
相同时,表示它们属于文件的同一映射区域。
一旦我们获得了eh_frame
中的PC
,只需计算其在ELF文件映射块中的偏移量,加上/proc/</pid><pid>/smaps
中的映射基地址,即可得到PC
在进程空间中的绝对虚拟地址(VA
)。
现在,我们终于可以在bpf
程序中进行C
的栈回溯了。
Lua
的调用栈相对简单,只需要遍历整个L->ci
链表即可。
但是有一个特别的问题,由于Lua
中的函数都是动态的,有可能某个函数在当前分析的时刻存在,但过一会就被垃圾回收(GC
)掉了。
因此,在回溯Lua
的调用栈时,我们需要保留当前的所有文件信息,否则稍后可能就无法获取它们了。
然而,直接在Lua
的调用栈中存储文件路径和行号会浪费大量空间。
简单计算一下,如果我们要支持的最大Lua
调用栈深度为128
,并且每个文件路径的最大长度为64
字节,那么每个调用栈就需要浪费128 * 64 + 4
个字节的存储空间。
这种存储量级是不可接受的,并且在对调用栈进行计数时,也会导致性能严重损失。
为了简化设计,我在bpf
程序中创建了一个字符串映射表strings
。
在回溯Lua调用栈时,我们通过strings
将字符串转换为uint32_t
类型的id
,然后使用id << 32 | line_num
来构建一个虚拟地址。
为了标记这是一个合成地址而不是真实的虚拟地址,需要将即最终结果修改为(uint64)id << 32 | line_num | (1 << 63)
。
这种方法的之所以有效,是因为在于用户空间,地址的bit63
永远为0
。
值得注意的是,strings
是一个哈希表,因此无法保证冲突永不发生。
当字符串冲突时,我们将旧字符串和对应的id
发送回用户空间,让用户空间进行存储,并为该槽位分配一个新的id
。
我们利用了一个事实,Lua
中的大部分函数都是常驻的,因此它们的源文件TString
指针很可能是相同的。
尽管冲突存在,但我们并不太关心它们。因此,我们可以将源文件TString
的指针视为该字符串的哈希值,当哈希值不同时,我们直接认为这是两个不同的字符串。
在bpf_helper
中,有一个辅助函数bpf_get_stackid
可将整个callstack映射为一个id。这对于生成火焰图非常有用。
由于我们正在手动进行栈回溯,我们需要自己实现该功能。然而,这也带来了一些好处。
通过与用户空间进行通信,我们可以利用用户空间的大内存来支持我们做一些bpf_get_stackid
做不到的事情。
首先,我们需要定义一个名为stacks
的哈希表。
当我们获取到一个栈回溯数据时,我们同时计算内核空间调用栈
、用户空间调用栈
和Lua调用栈
的哈希值。然后,根据哈希值来确定stacks
中对应的槽位。
如果槽位上已经有值,我们将比较它是否与当前的callstack相同,如果相同则数量加一。
如果不同,bpf_get_stackid
将选择要么丢弃当前槽位上的旧callstack
,要么丢弃新插入的callstack
。
由于我们可以与用户空间进行通信,我们可以选择将旧的callstack
发送回用户空间,并让新的callstack
占据槽位。
将Lua
调用栈和C
调用栈也不是一帆风顺的。
从Lua 5.4
版本开始,Lua
支持在C
函数中使用yield
功能。
这可能导致在L->ci
(Lua
调用信息链表)中出现某个C
函数或C
闭包,但在C
调用栈中并不存在相应的信息。
目前的解决方案是采用一种启发式的匹配策略。
当L->ci
链表中的C
函数与C
调用栈中的C
函数匹配时,我们认为从Lua
调用栈的栈顶到当前C
函数位置的部分是由当前C
调用栈中的C
函数产生的,并进行合并。
一些旁支末节。
在我最初学习eBPF
程序时,我听说内核有一个bpf
校验器,可以确保你编写的bpf
程序永远不会损坏内核数据。
我一直觉得这很神奇,当时我在思考如果将这种技术应用于应用程序的检查中,会不会无敌。
最后才了解到,原来bpf
校验器是采用宁可错杀一千也不可漏掉一人的方式进行检查的,各种报错会让人感到困惑和沮丧。
一个非常重要的知识点是,bpf
校验器是在编译之后才开始校验的。
如果你写了相应的if检查,但校验器仍然报告你没有进行检查,那可能是因为你的检查被编译器优化掉了,你需要采用各种非常归的方法来阻止编译器的优化(我在这个问题上花了整整一个周末的时间来解决)。
最后源码在这里
试用了一波,发现两个问题:
1. 读取elf的时候通过判断offset一致来确定一个段。实测的时候发现smaps中的offset和elf offset不完全一样,没法匹配到。readelf -l 查看elf中Align是4k,offset不是4k的整数倍,但是smaps中的offset变成了4k的整数倍。
2. 采样性能好像不高,100%cpu跑了很久也没跑到什么数据。不知道是否和问题1有关系(修改了源码,解决了4k对齐问题)。
测试环境是centos8 + gcc14.1 + lua5.4.3
你是连c栈都采不到,还是只是采不到lua的栈,这个问题能在github上使用issue来交流么,blog评论可能没有那么实时。