libffi 的性能改进

Lobsters Hottest 工具

摘要

本文详细介绍了 libffi 中的一项性能改进:将参数放置缓存为扁平移动列表(即“计划”),从而消除了每次函数调用时的冗余重新分类,在不使用 JIT 编译的情况下实现了显著的加速。

<p><a href="https://lobste.rs/s/agw0rr/performance_improvements_libffi">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/22 01:29

# libffi 性能提升 来源:https://atgreen.github.io/repl-yell/posts/libffi-plan-cache/ libffi 是一个函数调用解释器。你在运行时向它提供函数签名的描述,它当场确定如何放置每个参数并进行调用。它解释调用约定的方式,就像字节码虚拟机解释指令一样。没有预先编译任何东西,因为关键就在于你事先并不知道签名。 需要速度时,你不会选择解释器。通常的答案是 JIT:为每个签名编译一个定制的调用桩,即原生代码,它将参数放入相应的寄存器并跳转,运行时无需再解释什么。速度更快,但代价是向既可写又可执行的内存写入新机器码——而这正是现代系统试图消除的。 所以 libffi 保持为解释器,这是有意为之。我着手要回答的问题是:通过复用已有信息而不是在运行时生成代码或将任何页面映射为可写可执行,解释器到底能快多少。 ## 浪费之处 当你通过 libffi 调用一个函数时,工作分在两个地方完成。`ffi_prep_cif` 对每个签名运行一次。它对整个签名进行分类,但只保留两个结果:调用所需栈帧的大小,以及返回值返回方式的一个小代码。帧大小必须在构建调用之前知道,因为任何不适合放入寄存器的参数都会溢出到栈上,而这部分空间是提前预留的。返回码是为之后用的,因为结果根据类型不同可能返回在 `rax`、`xmm0` 或内存中,需要知道从哪里读取。两者都很小且大小固定,因此存放在 `ffi_cif` 中。prep 丢弃的正是它花费大部分时间处理的部分:每个参数具体放在哪里。 因此,在*每一次* `ffi_call` 中,编排代码都会再次遍历参数列表,并从头开始重新推导参数放置,然后才将值复制到位。对于 x86-64 上的三个参数调用,这大约需要 650 条记账指令,而且每次产生完全相同的结果。 这些指令中的大部分并非在移动参数字节,而是在决定字节去哪里。System V AMD64 ABI 通过固定流程对每个参数进行分类,而对该流程的每步执行意味着:遍历参数的类型,递归进入结构体的字段并追踪其类型描述符中的指针,将每个 8 字节块分类为 INTEGER 或 SSE 寄存器类,并检查它是否还能放入剩余的寄存器或必须溢出到栈上。这是分支密集、指针追踪的工作,CPU 跑起来很慢,而它在每次调用时都重新运行,去计算一个从不改变的位置。 但函数参数的放置是签名的纯函数。我们可以计算一次,记住它,并在以后每次调用时跳过该工作。 ## 一种计划 修复方案是一个“计划”:将位置编译成一个扁平的移动列表,即针对一个签名的微型字节码。如果 `ffi_call` 每次调用都重新推导位置类似于每次重新遍历程序的语法树来解释它,那么计划就是编译后的字节码:树遍历只发生一次,而以后的每次调用只需运行扁平的列表。`build_plan` 遍历参数类型一次,按照 ABI 规则对每个参数进行分类,并为每一块生成一条移动指令:这个 8 字节单词放到 `rdi`,那个 32 位整数符号扩展后放到 `rsi`,这个 `double` 放到一个 SSE 槽,那个过大的东西溢出到栈上。有了计划,执行调用就只是运行这些移动指令。无需重新分类。 构建调用计划,然后运行它 操作码故意设计得很简单。`GP64` 将一个单词复制到通用寄存器;`SE8`/`SE16`/`SE32` 对窄整数进行符号扩展;`SSE64`/`SSE32` 移动浮点数;`STACK` 将溢出的参数用 memcpy 复制。一个三参数调用编译成三条或四条这样的指令。下面展示了两个真实签名转换后的样子: ``` long (void *, void *, void *) long (void *, int, void *) GP64 avalue[0] -> rdi GP64 avalue[0] -> rdi GP64 avalue[1] -> rsi SE32 avalue[1] -> rsi (符号扩展) GP64 avalue[2] -> rdx GP64 avalue[2] -> rdx => 全是 GP64:thunk => 包含 SE32:解释模式 ``` 当每个参数都是通用寄存器中的单个 64 位值时(大多数传递指针的代码都是如此),计划甚至不需要解释器。它被标记为“可 thunk”,并在 `.text` 中有一个手写的小 thunk,直接从参数数组加载值到参数寄存器并调用。它跳过了移动循环、中间的寄存器映像以及来回的复制。右侧的调用带有一个 `int`,因此需要符号扩展,所以它运行移动循环来代替。 运行移动指令有一个微妙之处。循环从未实际加载参数寄存器,因为 C 语言不提供将值放入 `rdi` 并在调用期间保持它的途径;编译器掌管寄存器。因此每一步移动都会写入一个普通的内存结构,该结构镜像 System V 寄存器文件:六个整数寄存器和八个 SSE 寄存器按顺序排列,只有当该映像构建完成后,一个简短的汇编蹦床才会一次性地将所有参数寄存器从中加载并跳转到目标。C 代码在内存中移动字节;寄存器则在 `.text` 中、紧挨着调用之前一次性获得最终值。那个蹦床正是 `ffi_call` 一直以来使用的同一个蹦床,因此计划改变的是位置计算的时间点,而不是寄存器的加载方式。 计划是纯数据,而 thunk 像任何其他函数一样位于二进制文件的只读文本段中。没有任何东西同时可写可执行,这与闭包已经通过静态蹦床获得的特性相同(https://blog.lazym.io/2021/07/29/Cast-a-Closure-to-a-Function-Pointer-How-libffi-closure-works/)。 ## 构建一次,多次调用 该计划以一个小型、可选加入的 API 形式暴露。你从准备好的 `ffi_cif` 构建一个计划,按需多次调用它,完成后释放它: ``` ffi_call_plan *plan = ffi_call_plan_alloc(&cif); /* 构建计划一次 */ ffi_call_plan_invoke(plan, fn, &rv, av); /* 调用它,无每次调用的 setup */ /* ... 再次调用,再再次调用 ... */ ffi_call_plan_free(plan); ``` `ffi_call` 本身未受影响。对于每个签名已经缓存了 `ffi_cif` 的绑定(大部分绑定都是如此),只需在计划旁也缓存一个,并通过 `ffi_call_plan_invoke` 调用。计划一旦构建便不可变,因此一个计划可以被任何线程共享和调用而无需加锁。处理不了快速路径的签名也没关系:`invoke` 会回退到 `ffi_call`。 ## 数据 这是公平比较:同一个 libffi,同一种函数,通过三种方式调用。普通的直接调用、通过 `ffi_call` 的调用、以及通过预构建计划的调用。相同的二进制文件、相同的机器(Core Ultra 7 255H)、相同的 `-O2`,因此 FFI 两行之间唯一的区别就是 API。定时循环就是简单的重复: ``` ffi_type *at[] = { &ffi_type_pointer, &ffi_type_pointer, &ffi_type_pointer }; ffi_cif cif; ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 3, &ffi_type_sint64, at); ffi_call_plan *plan = ffi_call_plan_alloc(&cif); /* 构建一次 */ void *av[] = { &a, &b, &c }; long rv; ffi_call_plan_invoke(plan, (void(*)(void))fn, &rv, av); /* <-- 这里是我们计时的部分 */ ``` ``` ptr(p,p,p) ns/调用 相对于普通调用的倍数 普通函数调用 1.9 1x ffi_call_plan_invoke 5.1 2.7x ffi_call 31.0 16x ``` 正常通过 `ffi_call` 调用该函数的开销大约是直接调用的 16 倍。通过预编译计划调用则不到 3 倍。计划比 `ffi_call` 快了大约 6 倍,而且由于是同一个库以两种方式调用,这个差距完全来自 API 本身。 计划移除的大部分工作是每次调用的重新分类:`ffi_call` 每次都重建位置,而 `invoke` 只是运行预建好的移动指令。在这种签名形状下,计划走的是 thunk 路径,因此它也跳过了寄存器映像,更接近普通调用:在 2 ns 的调用之上大约有 3 ns 的 FFI 开销,而 `ffi_call` 则是 29 ns。 混合整数和浮点数的签名不走 thunk 路径,因为一个 32 位 `int` 需要符号扩展,一个 `double` 需要 SSE 寄存器,因此它们运行移动循环,开销略高。但它们仍然跳过了重新分类。按值传递的结构体参数没有计划,因此 `invoke` 回退到 `ffi_call`,开销与之前完全相同。 ## 调用实际发生在何处 只有真正使用了该签名形状的程序,并且调用频率高到构建一次计划能回本时,6 倍的加速才有意义。因此我追踪了一个实例。 GNOME Shell 是一个很好的压力测试:整个桌面 UI 都是 JavaScript 通过 GObject Introspection 调用 C 代码,而 GObject Introspection 又通过 libffi 调用。我使用 Whistler(https://github.com/atgreen/whistler)将 eBPF 探针附加到 `ffi_call`,观察了一段时间。排名靠前的签名如下: ``` 21744 int (void *) 19139 void *(void *, unsigned long) 13083 void *(void *) 10116 void (void *, void *, void *, long, void *) 9918 void *(void *, void *) ``` 大约 90% 的调用是纯 64 位 GP(指针和长整型),也就是 thunk 路径。超过十万次调用中,没有出现一个按值传递的结构体参数。而且这些就是反复调用的同一小批签名,恰好是那种构建一次计划然后永远调用就能获益的形状。像 GObject Introspection 这样的绑定已经为每个签名持有一个 `ffi_cif`;计划只需紧挨着它放置即可。 这一切都位于 libffi git 树的 HEAD 上,尚未发布版本,还需要更多测试才能成为可依赖的基础。加速目前仅限 x86-64,但 API 是可移植的:在其他平台上 `ffi_call_plan_invoke` 只是调用 `ffi_call`,因此绑定可以无条件地为每个签名构建计划,并在存在加速路径的地方使用它,自身无需添加 `#ifdef`。对于其他 ABI 是否值得构建快速路径还不清楚:收益与要跳过的每次调用分类的工作量成正比,而不同调用约定之间差异很大。 代码在 GitHub 上:libffi(https://github.com/libffi/libffi)。 在 Hacker News 上讨论(https://news.ycombinator.com/item?id=48619207)。 --- *发布后为清晰起见进行了编辑:`6fed8af`(https://github.com/atgreen/repl-yell/commit/6fed8af311fca8256b23673089da42e5beaf0cee)。*

相似文章

让 Julia 达到 C++ 的速度(2019)

Hacker News Top

这是 BYU FLOW Lab 于 2019 年发布的一篇博客文章,以真实的空气动力学应用(涡粒子法)作为基准测试,探讨如何优化 Julia 代码以匹配 C++ 的性能。作者分享了在 Julia 中实现高性能计算的经验,涵盖类型声明、JIT 编译以及代码优化技巧。

Fil-C 优化调用约定

Hacker News Top

Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。

未充分利用的性能

Lobsters Hottest

一篇技术博客文章,演示了如何通过LLVM的配置文件引导优化(PGO)在标准-O3和LTO之外显著提升二进制性能,以SQLite作为基准测试。

当编译器让你惊喜

Lobsters Hottest

Matt Godbolt 探讨了编译器优化如何将 O(n) 求和循环转换为 O(1) 的闭式解,突出了 Clang 和 GCC 如何采用循环展开和数学简化等复杂技术来大幅提升代码性能。

Zig 构建速度正在提升

Mitchell Hashimoto

Zig 0.15 相比 0.14 在编译时性能有显著提升,构建脚本编译时间从约 7 秒降至约 1.7 秒,完整构建时间从 41 秒降至 32 秒,且仍使用 LLVM。本文重点介绍了自托管后端和增量编译方面的进展。