惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

O
OpenAI News
GbyAI
GbyAI
人人都是产品经理
人人都是产品经理
Last Week in AI
Last Week in AI
F
Fortinet All Blogs
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
罗磊的独立博客
爱范儿
爱范儿
B
Blog
C
Check Point Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
博客园_首页
A
About on SuperTechFans
Engineering at Meta
Engineering at Meta
V
Visual Studio Blog
P
Proofpoint News Feed
小众软件
小众软件
Google DeepMind News
Google DeepMind News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
G
Google Developers Blog
Y
Y Combinator Blog
Recorded Future
Recorded Future
博客园 - 聂微东
WordPress大学
WordPress大学
博客园 - 【当耐特】
腾讯CDC
T
Tailwind CSS Blog
The Register - Security
The Register - Security
V
V2EX
S
SegmentFault 最新的问题
IT之家
IT之家
D
Docker
I
InfoQ
大猫的无限游戏
大猫的无限游戏
云风的 BLOG
云风的 BLOG
Microsoft Azure Blog
Microsoft Azure Blog
月光博客
月光博客
Stack Overflow Blog
Stack Overflow Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Jina AI
Jina AI
The Cloudflare Blog
量子位
Microsoft Security Blog
Microsoft Security Blog
aimingoo的专栏
aimingoo的专栏
博客园 - 叶小钗
H
Help Net Security
MongoDB | Blog
MongoDB | Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
美团技术团队
B
Blog RSS Feed

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器中RPC模块的设计 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 再谈分布式服务架构 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 一次关于Cache的性能分析 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构 关于网络协议封装的一些新想法
再次实现了一个Lua性能分析器
重归混沌 · 2023-10-01 · via 重归混沌的BLOG

去年学习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程序中获取CcallstackLuacallstack,然后在用户空间将它们合并。

最后,按照火焰图的格式进行输出并生成火焰图。

整个过程并不复杂。

然而,当我开始实际实现时,事情的发展远远超出了我的预期,整个过程触及了我知识的盲区。

我本以为eBPF发展了近9年,在内核空间获取Ccallstack应该只是一个API的事情。然而,现实却给了我一个沉重的打击。

现代编译器只要开启优化,默认情况下会抹去栈帧指针。而bpf中的内置API只能在栈帧指针保留的情况下轻易获取整个callstack

我面临两个选择:要求被性能分析的进程在编译时必须使用-fomit-frame-pointer编译选项,或者我必须手动进行栈回溯。

我的目标是对目标程序进行无侵入性能分析,我认为强制要求目标程序使用-fomit-frame-pointer也是一种形式的侵入。

目标程序需要不断忍受-fomit-frame-pointer带来的性能损失。

而且,我无法要求像libc等系统提供的so文件必须保留栈帧指针。

于是,我只剩下一种方案,就是手动进行栈回溯。

手动进行栈回溯也有两种方案。

一种是在bpf程序中将目标进程的完整栈数据复制到用户空间,然后使用libunwind进行栈回溯。

另一种是直接在bpf程序中进行栈回溯,并统计调用栈的出现次数,然后只将统计结果发送回用户空间。

很快,我否定了第一种方案。

bpf最初的目的是用于过滤网络数据包,因此eBPF程序应该基于此设计思路。

即在bpf程序中处理和加工所需的数据,然后只将需要的数据传回用户空间。

而且,如果我们在用户空间进行栈回溯,由于ringbuffer的异步性,我们无法及时采样到与收集到的栈数据相匹配的Lua调用栈(因为我们需要先回溯完Ccallstack才能获取L指针,然后再对L进行栈回溯,而这期间目标程序的Lua调用栈早已经发生变化)。

对已经抹去栈帧指针的callstack进行手动回溯,完全触及了我知识的盲区。

最初,我考虑仿照gdb的方案,通过调试信息进行栈回溯。

但是,调试信息的数据量太大,不方便传送到内核。而且,解析调试信息这样的任务也不太适合由bpf完成。

一个偶然的机会,我发现了elf文件中的一个名为eh_framesection,全名为exception handling frame。它被设计为在发生异常时为栈回溯提供完整的信息,这恰好符合我们的需求。

eh_frame是由一系列CFI指令组成的,用于为栈回溯提供指导信息。这些CFI指令按函数顺序执行,即程序执行到某一行代码时,要回溯所有寄存器的状态,需要执行函数开始到该行代码之前的所有CFI指令。

幸运的是,在回溯时我们只需要获取callerEIP和包含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中的映射信息,我们可以将PCVA进行转换。

具体的转换逻辑是,当<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->ciLua调用信息链表)中出现某个C函数或C闭包,但在C调用栈中并不存在相应的信息。

目前的解决方案是采用一种启发式的匹配策略。

L->ci链表中的C函数与C调用栈中的C函数匹配时,我们认为从Lua调用栈的栈顶到当前C函数位置的部分是由当前C调用栈中的C函数产生的,并进行合并。


一些旁支末节。

在我最初学习eBPF程序时,我听说内核有一个bpf校验器,可以确保你编写的bpf程序永远不会损坏内核数据。

我一直觉得这很神奇,当时我在思考如果将这种技术应用于应用程序的检查中,会不会无敌。

最后才了解到,原来bpf校验器是采用宁可错杀一千也不可漏掉一人的方式进行检查的,各种报错会让人感到困惑和沮丧。

一个非常重要的知识点是,bpf校验器是在编译之后才开始校验的。

如果你写了相应的if检查,但校验器仍然报告你没有进行检查,那可能是因为你的检查被编译器优化掉了,你需要采用各种非常归的方法来阻止编译器的优化(我在这个问题上花了整整一个周末的时间来解决)。

最后源码在这里