Fedora 38 的帧指针

Fedora 项目最近打算更改编译选项,给几乎所有程序在加上帧指针(Frame Pointer)。我个人反对这一提案,不过当我知道这个提案的时候,这个提案已经通过了。我写这篇文章是为了让社区的朋友们理解 Fedora 作出这一决定的理由,并告诉读者为什么我不赞成这一提案。

有啥用

这个提案在页面里面如此介绍其好处:

实施此更改将提供分析工具,可以轻松访问已安装库和可执行文件的堆栈跟踪,这通常会导致更准确的分析数据。 这反过来又可用于对核心库和可执行文件进行优化,从而提高Fedora本身和更广泛的Linux生态系统的整体性能。
各种调试工具也可以使用帧指针来访问当前堆栈跟踪,尽管像gdb这样的工具已经可以通过嵌入式dwarf调试信息在某种程度上做到这一点。

要理解这一点好处,就要理解二进制程序的执行过程。

栈帧与帧指针

在执行函数的时候,需要一块内存来放置该函数相关的数据,比如传入的参数、局部变量与返回上一个函数的地址,这一块内存区域称为栈帧。(英文是 Stack frame。 Make Stack Frame, Not War )而帧指针,顾名思义,就是指向一个栈帧开始地址的指针。在启用帧指针的情况下,每个栈帧都有指向上一个栈帧的指针,这个指针就是帧指针。

假设有如下伪代码构成的程序,我们给他加上一个断点:

fn bar(){
    ;  // break point here!
}
fn foo(){
    a = 123; // pretend there's some complex code use many variable 
    bar();
}
fn main(){
    foo();
}

在断点的地方,其栈上的内存大概是这样:

StackAddress
Frame of bar0x030
Frame of foo0x020
Frame of main0x000

这个时候将栈内存保存下来(或者把当前进程给冻结),就可以把栈“展开”(Unwind),也就是进行分析,看出当前函数的调用。

问题是,栈帧的大小是不确定的,一个参数与变量多的函数自然会拥有更大的栈帧,反之,栈帧会更小。在上面的伪代码例子中,函数 foo 因为有更多的局部变量,其栈帧就更大。而这就导致在没有额外信息的情况下,栈帧的分析比较困难。

显然,编译器是知道栈的大小与位置的,编译器生成的汇编指令操作的内存地址就是 该函数栈帧的偏移量 + 内存在栈帧内的位置(额,严谨地说法比较复杂,因为不是所有 CPU 都有直接操作栈的指令,编译器生成的汇编也未必会直接在栈上操作。x86 是可以直接操作栈,但 RISC 系的 CPU 似乎就不能了,得先复制栈上面的到寄存器里面,谁叫寄存器他快呢……)。所以生成的二进制其实不需要记录某个栈帧的大小以及位置,也就不需要帧指针。编译器省略帧指针是完全合理的做法。

调试与性能分析

在调试的时候,栈展开是非常重要的功能。调试器需要展开栈,才能知道栈的内容,获取指向可执行代码的虚拟地址列表。

正如上面所说的,如果你用户拥有一个程序完整的源代码,那么就可以把这些代码喂给编译器,让编译器告诉调试器栈帧的位置。如果没有代码,编译器也可以生成调试信息(这个调试信息的格式叫 DWARF),包含了栈帧的信息以及函数的名称等内容,调试器可以根据这些信息来分析。

没有这些信息,调试器就只能靠算法去猜测,这也就是 KDE 的 DrKonqi 会在没有调试信息的时候抱怨生成的回溯没有用——不仅仅是因为找不到函数的名字,更因为这些回溯结果可能完全错误。

如果在编译的时候加入栈指针,展开栈就会更加容易。前面说过,这个时候,每个栈帧都有一个指向前一个栈的指针——这样栈的展开就完全变成了查找链表的操作,比利用 DWARF 展开要快速,有些时候速度确实很重要。

对一个程序进行性能分析其实和调试很类似。

性能分析的方法之一是对程序的栈进行多次取样,获得不同时间下函数的调用情况,从而计算出某个函数执行的时间。对于 Linux 的 perf 工具而言,默认采样频率是每秒 1000 个样本。

这个方法非常直接,缺点也很明显,因为是采样,所以采样的速度越快,结果据越精确(某个函数可能在两个采样的间隔执行并返回,这个函数在结果里面就是看不到的)。当然,这种方法也有好处,那就是可以对正在运行的程序进行采样,不影响现有程序的执行。只需要 sudo perf record -p pid 即可。对于生产环境中测试性能的工程师而言,这种方法很有用,而这也对速度提出了要求。

perf 可以使用 DWARF 展开,但根据帧指针的提案页面所诉,其开销很高,拖累了分析的速度。如果有了帧指针,perf 就能更加快速有效的展开栈。在实践中,使用 perf 可以既没有 DWARF 也没有帧指针,perf 不会抱怨什么,但是记录的数据很可能是错误的。

还有一种方法可以用于性能分析,那就是使用 Valgrind 这类 DBI (Dynamic Binary Instrumentation) 工具。Valgrind 在运行客户程序时,会把程序本身的寄存器和内存几乎复制一遍,同时记录程序的所有操作。为了实现这些功能。Valgrind 会把机器码解析到 VEX 中间语言,然后插入所需的代码。从操作系统的角度看,Valgrind 以及对应的客户程序都在同一进程内,他们的地址空间也是共享的。某种意义上 Valgrind 做的工作和 Qemu-static-user 这种转译器非常相似,Valgrind 执行的是自己加工过的客户程序。

当然,Valgrind 会带来性能损失,而且不能分析运行中的程序。要使用 Valgrind ,必须搭建一个测试环境,然后根据需要让 Valgrind 来运行程序。对于一些复杂的应用,搭建环境这一过程可能非常耗时。

性能损失

在编译时使用帧指针会在运行时带来开销。

首先,使用帧指针要求程序在进入函数与返回函数时运行额外的指令来保存与处理帧指针,本身就有一定的性能开销。在一些特定场景下,比如递归调用,这一开销可能会比较高。

其次,帧指针会占用一个寄存器。访问寄存器比直接访问栈要快,编译器会把变量或者参数尽可能放在寄存器上面(这么说可能不完全对,但我不是编译器或者 CPU 专家,让我们跳过这些细节罢)。被帧指针占用一个寄存器之后,一些编译器优化的效果可能会下降。

一些争议

帧指针作用与缺点

读者看到这里,帧指针的作用已经很清楚了。对于直接在生产环境中进行性能分析,帧指针具有不可替代的作用。

而有部分人认为,使用帧指针能有益于开源软件的调试与优化,假以时日,这些优化带来的增益能覆盖性能损失。但是,我觉得这逻辑行不通。显然,不会有个红帽或者网飞的工程师在普通 Fedora 用户报告性能问题后,SSH 到用户的电脑上(也就是生产环境)跑个 perf 看看 Gnome 卡在哪儿,而在工程师们的开发环境中,他们可以慢慢复现 Bug,让 Valgrind 这类工具自由驰骋。

那些大公司的工程师是帧指针受益最大的一方。大公司有能力与资源为整个系统进行优化,而开源项目就没有那么强的能力。因此帧指针的受益者非常有限。再者,大公司需要一个开启帧指针的发行版也非常简单,他们完全可以在内部编译一个 Fedora 衍生版或者直接用 Gentoo,没必要让所有 Fedora 用户承担性能损失。如果网飞和脸书想弄这么一个子发行版,我完全赞成。

这值得吗

前面提到过,帧指针会造成性能损失——关于损失程度,我收集了一些量化后的数据。

Phoronix 为此做了一个评测,这个测试的结果有好几页,总结起来很简单:几乎所有应用在添加帧指针之后都会变慢。影响比较轻微的,如 Zstd 解压,变慢了2%左右(Zstd 压缩是 4%);影响严重的会直接打对折,还有一些就只有四分之一或者五分之一了,比如 Redis 和 Botan。

Fedora 自己也做了一些基准测试:

  • GCC 在编译内核的时候变慢 2.4%
  • CPython 的基准测试会受到 1-10% 的影响,具体情况随测试项目而变化
  • Blender 在特定情况下渲染帧的时间会延长 2%
  • Openssl/Botan/Zstd 没有显著影响
  • Redis 基准测试也没有显著影响

值得注意的是,Fedora 的结果与 Phoronix 的评测的部分项目有非常大的差距。Phoronix 测得 Botan 和 Redis 的性能严重下降,而 Fedora 的结果则是没有显著影响。Phoronix 的 Zstd 测得 2-4% 的性能下降,Fedora 这边依然宣称没有显著影响。

这种情况确实非常考验判断能力。所以我自己设计了一个简单的评测:

我用 C 语言写了一个计算斐波拉契数列前 4194304 项的程序,然后用 Bash 循环执行 10 次。(虽然由于 int 长度限制,肯定会溢出,但是不影响测试结果)结论是这个选项给我的测试样例带来了 0.5% 的损失。这个结果比起 Phoronix 的结果来讲要小得多,因为我的小程序只有一个函数,那就是 main 函数,变量数目也不多。

然后,我又实现了一个用递归的斐波拉契数列计算。与上次不同,这次递归计算前 48 项,也是 Bash 循环执行 10 次,减少项数是因为太长会 Stack Overflow。这次测得的损失是 12%。

现实情况

这一修改对于最终用户的影响大吗?这取决于实际的工作负载。

在游戏方面,性能缩减可能并不大。因为大部分游戏并不靠发行版来编译,也不使用发行版自带的库。Flatpak 内的应用收到的影响也微乎其微。Flatpak 应用和宿主共享的基本上只有内核,而内核不受此次影响。Podman 等容器应用也是一样的道理。如果 Wayland 合成器和 Xorg 服务器变慢,容器或者Flatpak内的图形应用会受到影响,但影响程度很难估计。

受帧指针影响的只是发行版源里面的软件,也就是说桌面环境、Bash 等系统组件必然受到影响,但是目前没人做 Bash 的基准测试,而估计桌面环境受到的影响程度也很困难。

最后的话

我的立场很简单:帧指针只方便了用 Linux perf 子系统进行性能分析的用户。“用 Linux perf 子系统进行性能分析”是一个非常小众的需求,据我所知,Valgrind 在性能分析中的应用比 perf 要丰富。 因此,为了小众需求牺牲所有用户的性能本来就不合理。

帧指针这个问题讨论了大半年,两次被提上日程。而现在讨论会与投票都结束了,开发者已经通过基本符合社区行为规范的流程,而根据社区的意见得出了最终结论。而我,作为用户,只能尊重他们。

Fedora 编译器的维护者也不支持该提案,然后那些支持者的回应是“我们不再相信编译器维护者能对实践中的性能有合适的考量,他们太注重基准测试了,而基准测试不能反映现实”。

另外,有人提出批评说在第一次投票被拒绝后(从提出到拒绝提案花了五个月),第二次投票的时间太短(只有一个月),选的时间点也不合适(在圣诞节期间)。所以就批评这次投票有被操纵的嫌疑。

但这毕竟不是我要操心的问题,我又不是 Fedora 的长期贡献者,一个普通用户而已。🤣

提案人与 FESCo 将在 Fedora Linux 40 的时候评估这次改动,决定是否在 Fedora 的后续版本保留帧指针(真成小白鼠了,哈哈)。也许到时候应该会有一些更可靠的数据——而不是像现在这样出现一些相互矛盾的结果。

参考资料

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注