快速的 uprobe¶
标题:Fast uprobes
日期:2024/07/10
作者:Jiri Olsa
链接:https://www.youtube.com/watch?v=WGH8Ycly9UU
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
好的,是的,还是我,还在思科(Cisco)。所以这个演讲是关于更新uprobe加速情况的,包括加速的目标是什么以及实际的加速效果看起来如何。我将首先从高层描述uprobe实际上是什么样子,安装过程是怎样的,然后我会实际展示我正在尝试做些什么来加速它。
所以uprobe,用户空间探针(user space probe),基本上你有一个文件,接口是你拥有一个inode和一个文件偏移量,你把探针放在那里,当应用程序实际运行并经过那个点时,它会命中探针,并执行任何附加到探针上的东西,在我们的例子中是BPF程序。这也是实现用户空间跟踪点(USDTs)的方式。当你附加到USDT时,你实际上也是在用uprobe。这都非常依赖于架构。本次演讲只涉及x86。特此声明。
那么,当你想要安装uprobe时,实际情况是怎样的呢?正如我所说,你有inode和偏移量。所以你实际上需要用断点指令覆盖该指令。当然,你不是在文件本身中替换它,而是当你安装uprobe时,你会在所有进程中查找该地址被映射的所有实例。然后你进行安装,就在内存中。uprobe也有回调机制,当你实际为文件进行内存映射时。当你映射文件中实际包含断点的那个部分时,它会触发uprobe的安装,并安装断点指令。所以现在它在内存中了,当应用程序实际执行时,在x86上是int3
断点指令。这实际上会触发内核中的断点处理程序。这基本上就是uprobe的执行过程。那时,它将运行BPF程序,或者运行称为“消费者”uprobe的通用层。对于uprobe,其中一个消费者是BPF、uprobe multi-link或者任何存在的perf处理程序。它将执行BPF程序。
一旦执行完成,它不能直接跳回去,因为你用断点覆盖了某个指令,这个指令需要在恢复用户空间应用程序执行之前被执行。所以此时有两种方法可以做到。你可以模拟uprobe该指令。这意味着我们仍在断点处理程序中,并且可以访问用户空间的寄存器。因此,有些指令你可以通过操作这些寄存器来实际模拟。你也可以访问用户空间堆栈。所以如果有像push
这样的指令,你可以在那里实际模拟它。这基本上就是模拟uprobe所做的。目前支持模拟某些指令。一些jump
、push
指令,当然还有nop
。当你这样做时,你可以实际返回到用户空间继续执行。
有些指令你无法模拟。在那种情况下,你需要触发单步执行uprobe,这实际上意味着,当然,你保留了原始指令。你需要把它放到一个特殊的区域,该区域被映射到那个进程。你可以在maps
目录下的uprobes
、uprobes_name
下找到它。所以它实际上会获得一个缓冲区位置。它会把指令放在那里,并且会让断点处理程序指向那个缓冲区。它将执行该指令,并设置调试标志uprobe,这意味着在执行该指令后,它会通过另一个陷阱(trap)回到内核。所以在那个时候,你实际执行了原始指令,然后就可以回去执行应用程序的其余部分了。
所以,我猜很明显,模拟比单步执行快得多,因为你需要触发这个额外的探针来执行原始指令。所以那是entry probe,就像你希望立即执行的那样。
顺便说一下,内核还支持return uprobe,其思想是你在用户空间有一个函数,当该函数实际返回时,你会被执行。为了真正做到这一点,你需要在函数的开头安装entry uprobe。这里的假设是,当这个entry uprobe实际被命中时,用户栈顶需要是返回地址。假设实际上有另一个函数调用了那个函数,返回地址就在栈顶。这个entry uprobe实际要做的是,它会安装返回跳板(return trampoline)。所以它会实际将原始的返回地址替换为用户空间应用程序中缓冲区的一个地址。当ret
指令实际执行时,它会跳转到那个地方。而这个地方实际上只包含一个断点指令(int3
),这将把我们带到内核。这就是我们执行处理程序的地方。它将执行附加到其上的任何BPF程序。不需要额外的清理工作。我们不需要。这里没有我们需要执行的原始指令。我们直接跳回去。
问题。所以如果用户空间应用程序有一些技巧,比如,你知道,Naemyang在哪儿?他在这儿吗?哦,他在那儿。就像你的用户空间ftrace东西,它有自己的影子栈并对返回做了一些技巧。那么如果你在那里放了一个探针,那会不会造成严重破坏?我认为会。所以有些像Go应用程序,它们维护自己的栈。一旦你往Go栈上写了东西,它实际上会发现并导致应用程序崩溃。所以是的。好的。我只是想,我只是对此感到好奇。
所以这就是uprobe和return probe的安装方式。在我实际进入加速部分之前。这是我们如何测量的。有一个trigger_bench
应用程序。trigger_bench
基本上在附加的探针上循环。而探针本身执行一个BPF程序,该程序计算探针被执行的次数。就像,如果你运行这个脚本,你基本上会得到每秒的执行次数(per second number of executions per second)。所以如你所见,有不同的数字。我们为在nop
指令、push
指令、ret
指令上添加uprobe准备了这个trigger_bench
,正如我之前展示的,这些指令触发了不同的行为。所以,这就是我们实际测量的方式。有,有问题吗?
没什么大问题。我只是想指出为什么我们有nop
、push
和ret
。因为那是三种常见情况,对吧?nop
通常用于USDT。push
就像正常的函数调用。而ret
是未被优化的基本块?所以那是最坏情况。这是相同的单步执行uprobe。是的。单步执行正在发生。push
通常会在函数的开头。所以那是常见情况。是的。nop
是USDT的常见用例。是的。没错。并且是每秒数百万次,对吧?是的。是的。
所以实际上最近有一些修复,用于加速。Andre做了一些uprobe加速的工作,最终加速了uprobe的所有情况。还有一个来自Jonathan Haslam的补丁,他改变了锁。我想他把自旋锁换成了读写锁,像是uprobe核心树的某种核心树(core tree)。基本上,如果你在多线程(too many threads)中使用uprobe,你实际上会看到差别。所以这就是uprobe最近的加速点。它甚至比那更糟。因为它是每个uprobe或每个用户探针实例(uprobe instance)的一个全局自旋锁。所以如果你在多个线程上使用同一个uprobe,或者在不同的线程上使用多个uprobe,它们都会争用同一个自旋锁。而且这只是其中一个锁。通常在每个uprobe执行时大约有三个锁被获取,这是我们计划要解决的。好的。所以更多的加速即将到来。
那么,我实际上在这里一直在做什么呢?所以当我们提议加速的想法时,简而言之,整个想法,无论是对于return probe还是entry probe,就是用syscall
指令替换断点指令(int3
)的执行。正如Andre建议的,最容易做到的地方是return uprobe,因为它不涉及其他处理。而且跳板已经在那里了。
所以总结一下,基本上就像我刚才解释return probe如何工作的那样,你在函数的开头安装uprobe。那个uprobe改变了栈顶,也就是返回地址(return address),当函数返回时,这个地址最终会被执行。而目前加速基本上是用新的syscall
指令替换那个入口指令,它实际上完成了陷阱处理程序(trap handler)所做的一切。但它是syscall
指令,结果证明它实际上更快。事情并非那么简单。仅仅调用syscall
指令是不够的,还需要一些其他指令,比如保存寄存器,使其与当前运行的应用程序协同工作。但基本上它只是执行syscall
,运行处理程序,然后返回。
加速效果是可见的。我们实际在最新的英特尔笔记本电脑上获得了大约30%的加速。在某个不太新的AMD上,我能看到较少的加速。我猜加速效果很大程度上取决于架构(architecture),微架构(micro architecture),比如syscall
指令实际比执行断点指令(int3
)快多少。
我在想加速的差异是否是由于缓解措施(mitigations)造成的。我的意思是,我想知道原因,因为我相信AMD不需要核心引导(heart boot)?或者是什么,熔断(Meltdown)缓解措施?而Intel确实需要,或者至少…所以我肯定,这个加速是在启用缓解措施的情况下测量的。是的,但我想说的是,在启动时它会检测,哦,我们不需要这个缓解措施。它就把它关掉了。所以我在想,我们运行的原因,为什么Intel看到更大的变化,可能是因为它有缓解措施。由于我记得熔断只影响Intel。它不影响AMD。嗯。好的。可能是。这可能不是原因,但我在想这是否是你可以研究的东西。是的,我没有检查那个。好观点。好观点。
所以那是return probe的加速。它已经被发布了。它遇到了一些关于Intel上实际安全影子栈的问题。所以这个问题正在解决中。希望很快能解决,这个补丁实际能合入。
好的。那么更困难的部分,基本上还没有太多工作完成。只是一个想法,就是实际的uprobe加速,比如入口加速(entry speed up),入口点(entry point),entry uprobe加速。这里的想法再次是用跳转(jump)或调用(call)到跳板的指令替换入口断点指令(int3
),跳板会执行syscall
指令。然后我们需要以某种方式执行原始指令并跳回应用程序。这有很多问题。
问题稍微少一点的是USDT的加速。USDT,用户空间跟踪点(user space trace points),是通过uprobe实现的。目前,我们使用nop
指令,systemtap probe
宏(systemtap probe
macro)来实际安装应用程序中的跟踪点。结果就是放置了nop
指令,这就是你实际附加uprobe的地方。并且,它会被执行用于那个探针。
这个想法,我认为是在讨论中由Alexei提出的,我们也许可以改变那个宏,让它发出一个五字节的nop
(nop five
)。在那个地方,我们可以很容易地把那个五字节的nop
改成对跳板的调用(call),跳板会执行syscall
指令,该指令会负责uprobe的执行并返回。仍然有问题,但你会消除运行原始指令(original instructions)的问题。
那么,问题是什么?所以目前,我卡住的地方,以及我实际上试图弄清楚的是用户空间指令的安全更新。这基本上是关于如何安全地从执行五字节nop
(nop five
)过渡到执行跳板。如果你有多个线程(multiple threads)或者另一个线程正要去你试图更新的那个地方,那就是个问题。应用程序会崩溃。所以,我猜这实际上来自于,目前我们只安装一个单字节断点(single breakpoint)。所以如果你实际改变超过一个字节,添加一个超过一个字节的指令,这不是问题。但如果你改变超过一个字节,那就是个问题。
另一个问题是原始指令的执行。并非所有你覆盖的指令都可以在线外(out of line)执行。比如,有些指令可能有其他依赖,这取决于指令在哪里被执行。所以这是另一个问题。
我能想到的最后一个问题是,如果我们实际使用五字节的跳转(call
或jump
),你有四个字节用于偏移量。而用户空间比那大得多(比如46位甚至56位),所以你无法用四个字节在整个用户空间范围内跳转。这意味着无论何时你安装uprobe,你都需要在附近有一个跳板可以轻松访问。
实际上,我能试着忽略一个问题吗?你能回到幻灯片吗?关于NOB 5
(五字节nop
),对吧?我只想指出,在Intel CPU上,在x86上,nop
(NOB
)有整整一个家族(zoo),对吧?根据我测试过的,NOB 5
(五字节nop
)没有被优化。所以,如果我们在USDT中只用NOB 5
(五字节nop
),在老内核上会是一个很大的性能倒退。这就是为什么我提议实际用NOB 1
(一字节nop
)和NOB 4
(四字节nop
)替换它。然后内核可以检测这种模式,并像处理一个东西一样替换它们。这样在老内核上性能上仍然是向后兼容的。仍然给你五字节,但是作为两个nop
。
是的,我实际上,如果你忽略所有这些,我想忽略并尝试。所以加速实际上是预期的。就像,从之前的情况我们看到,通过使用syscall
而不是uprobe,我们获得了2到3.3倍的加速。这确实是某种成果。如果所有的问题都能解决,那将是结果。那将是uprobe或USDT的加速。
是的,我实际上和一些内存管理人员讨论过。是的。指令的更新是棘手的。当前的想法是也许int3
在Intel上很特殊。它可能对缓存刷新有一些影响。也许当你执行断点指令(int3
)时,指令缓存(iCache)会被刷新。这就是为什么它不会崩溃。如果你实际把它替换成call
指令,也许方式就不同了。
其中一个想法是,也许我们可以使用当前在更新调用(calls)时所用的方法。基本思路是,在你写入call
指令或jump
指令之前,你首先写入一个int3
。这样,任何其他到达那个地方的线程实际上会进入调试处理程序。调试处理程序知道正在进行更新,并且知道应该做什么。我们应该跳到哪里?我们应该只是跳到下一条指令,还是应该跳到某个其他地方。同时,更新线程将实际在int3
后面写入偏移量。作为最后一步,它将写入指令的操作码(code of the instruction)。所以这基本上就是安全更新的想法,可能也适用于用户空间。
是的。所以,当…时就变得棘手了,我知道Masami有专门的代码(special code)来处理优化内核探针(optimized kprobes)时的这种情况。所以你想看看优化内核探针(optimized kprobe)的做法,因为用这种方式可能甚至不安全。例如,嗯…syscall
操作码有多大?我的意思是,有多少字节?syscall
指令好像是两个字节?是两个字节吗?所以,嗯…假设你有一个单字节的东西,你最终会落在上面,或者…还有另一条指令在第二个字节上。如果那里有两条指令需要修改,那就变得棘手了。我实际上不是在那里放syscall
指令,我放的是跳转(jump
)。对,跳转,嗯…或者syscall
,你还需要至少准备一个寄存器。所以它甚至更复杂。是的,是的,是的。所以它会是一个跳转到一段代码。是的,是的,是的。或者其他更…好的。
所以,让我回头说,Intel唯一说过可以安全写入(safe to write once)的东西就是int3
。因为它是单字节的,你把它换掉,砰,它就是为调试目的设计的。现在,如果你想修改代码的其余部分,你得看看…这变得复杂得多。你真的需要和Masami谈谈这个,因为他为优化的kprobes做过这个。有些情况,如果你修改超过一个字节,并且有东西跳入那段代码,或者有很多奇怪的事情可能发生在你注入的任意代码上。你需要查看整个函数。我的意思是,优化的kprobes如果它判断“嘿,修改超过一个字节不安全”,就不会进行优化。你可能实际上需要做和…相同的代码… 在这个案例中,它是用户代码(user code),所以…
是用户代码没错,但仍然,你不知道如果你修改了某些东西会怎样,因为它还在运行,对吧?哦,我是说它甚至更难,因为它是用户代码,他们可以随便跳到任何地方。是的,完全正确。你不知道它是什么。就像你甚至不知道那个函数,所以它可能会…这就是为什么USDT是一个更容易的案例,因为我们确实控制着指令的布局,对吧?在宏里。我们可以放5字节的,或者1字节的,或者4字节的,但是…并且基本假设没有人应该在中间跳转。对吧?因为那是由…控制的。就像宏…把nop
放在那里,所以它不应该像…从另一个地方可以访问。我的意思是,它是可访问的,但是…没人应该跳到那里。嗯,你永远不知道它是不是…嗯,它可能跳过去。你必须放一堆单字节中断(single interrupts),我猜。那可能有点用,我猜,如果你为每个字节都放单字节中断的话。
uprobe的一般做法是,如果用户空间做了些花哨的或错误的事情,我们就给它发SIGILL信号。嗯,就像…例如,如果他们使用了uretprobe,然后他们改变了栈指针,例如,我们无法匹配…是的。…在函数返回时,我们就直接杀死(kill)那个进程。是的。所以,你知道,像在这个UDT(USDT)的案例中,我们可以直接说没人应该在两个nop
之间跳转,然后…是的,那倒是真的。有一点是,如果它真的崩溃了,它会杀死进程。所以最坏的情况就是有人会说,嘿,让我们在这个代码里注入一些uprobe,然后那东西就突然崩溃了。所以,是的,它确实发生过。所以,是的。可能没有安全的方法来做这件事。
是的,另一个问题是,是的,如果你打算使用五字节的跳转,这实际上可能是最好的替代方案,正如我所说,我们有这个四字节的偏移量,而用户空间比那大得多。所以我们最终可能需要安装多个用户空间跳板(multiple user space trampolines),以便从我们实际想放跳转的地方可以轻松访问。所以…
是的,还有其他问题,但是是的,一次解决一个。所以当我们到达那里时,我们会看到。你们有什么问题吗?
只是一个蠢问题。有没有可能,比如说,不用nop 1
和nop 4
,而是用nop 1
和nop 8
?或者…nop 8
?我不知道这个是否存在,但像更大的nop
,这样你就能有更大的偏移量,或者…嗯…所以,我的意思是,那真的能帮到你有一个更大的空操作(no-op)吗?除非你是在谈论在代码本身注入(injecting)…哦,那会解决四字节偏移量的问题。是的,是的,是的。我们可以用更大的跳转,如果指令集里有更大的跳转指令。这就是为什么我们必须知道它只有四字节偏移量。是的,是的,是USDT。哦,好的。所以,我的意思是,如果你必须要有…因为它不能解决问题,如果代码…如果它不是…是的,你还是有这个问题,你必须修改一些东西。所以…是的。
实际上有一个想法,也许如果我们有足够大的nop
,我们实际上可以把syscall
的执行放进去,但syscall
不仅仅是执行syscall
指令,你需要准备AX寄存器,甚至可能需要push
,以及push
像syscall
指令本身,还要改变其他寄存器。所以你需要push
和pop
所有东西,你无法塞进任何…最长的nop
指令里,那可能12字节左右?我现在不确定了,大概15或16字节。我们可能能用。是的,是的。我检查过我们现在那里有什么,放不下,但是…
但是,有没有偏移量超过4字节的跳转(jump
)或调用(call
)指令?有。指令有多长?问题是,你需要为它准备寄存器。我不认为你能…哦,没有像立即数跳转那样的指令,它的立即数是64位的。它需要,我认为它需要在内存中。是的。这就是为什么它是一个悬而未决的问题,我没有进一步探索。我只是发现有这样的跳转指令,但你需要准备一些寄存器,或者把地址放到某个内存位置。所以这都增加了新的复杂性…目前看来这个五字节的nop
跳转(nop
job)是最好的。所以…
我想有一个间接跳转(indirect jump)指令,你可以把地址直接放在指令后面。字节。所以…哦,在指令后面存储那个…是的,是的。哦,好的。我会查一下的。然后你可以用零偏移量…那个指令有多大?我不记得了。但你可以用8字节的偏移量(offset),8字节的地址。好的。哦,好的。是的。所以我对USDT不太了解,但是…
所以如果你想从1字节的nop
改成5字节的nop
,不管用什么方式,但你必须更改系统标签头(system tab header),对吧?是的。那会不会破坏任何USDT合约(USDT contract)之类的东西?嗯,我简要地和Frank Eichler讨论过这个。是的,他担心可能会影响某些基准测试(benchmarks),比如对现有应用程序使用这个可能意味着什么。所以如果你真的走这条路,对现有应用程序进行基准测试并评估其影响,这肯定是需要做的。
所以我就想说感谢Jiri, Andre和John的工作。非常令人兴奋。但我也想提一下Unoma的BPF time,那是用户空间的BPF东西,他们说,并且可能有道理,可以让uprobe快10倍,因为我们根本不用进内核。我能想到一些情况我需要这个(内核方案)而不是那个(用户空间方案),因为我有些程序同时做内核和用户空间的事情。而其他程序可以完全是用户空间的。我们的运行时,像BPF trace,可以做出决定,拿一个工具说,哦,它只有uprobe。我就用BPF time代替。但如果混合的,我就去用这个(内核方案)。我只是想知道你是否能想到其他想法,以便我们清楚地理解我们要走向何方,为什么我会使用这个内核的东西,而不是比如说一个用户空间的实现?同时,如果你正在改变我们动态检测uprobe的方式,也许有…如果你实际有BPF time sourcing(BPF时间来源)并且你在看它,也许在支持两者方面有一些重叠。如果你在做这个改变,可能有机会同时改进两种类型的uprobe。你熟悉LibSide吗?不,LibSide。什么是LibSide?LibSide。是Matthew DeNois(或类似发音)正在做的项目。LibSide是L-I-B-S-I-D-E之类的。我们实际上正在研究如何与用户事件(user events)连接,但它实际上是,他把它用于他的用户空间跟踪(user-space tracing)和LTTNG,这些都是用户空间的。它不进内核。这是一种方式,我们实际上可以放入…像内部的跟踪事件(trace events)。你可以用他的东西放入跟踪事件(trace events)或任何东西,然后基本上任何注册到它的东西,BPF可以注册它,任何东西,都可以注册。所以基本上是他正在做的一个项目,我知道微软的人打算把它用于用户事件(user events)。LibSide,S-I-D。是的,我想是S-I-D-E吧。是Mathieu(Matthieu)吗?Mathieu DeNois(或类似发音)。好的。是的。好的,听起来…但你明白我的意思,你正在改变写汇编的代码。也许这些不同的机制有办法向内核注册自己,并说,嘿,把我写进用户空间,或者写内核的那个(方案)。当然,是的,这绝对值得检查一下。是的。
还有其他人(有问题)吗?好的。非常感谢。谢谢。谢谢。谢谢。谢谢。好的,非常感谢。