← 返回信息流
AI 资讯Hacker News·2 天前

Libffi 性能提升

原标题:Performance Improvements in Libffi

速览

Libffi 是一个提供高级语言与 C 语言之间调用接口的开源库。此次更新带来了明显的性能改进,有助于提升依赖该库的软件运行效率。

AI 深度解读

Libffi 性能优化深度解读:从解释器到预编译计划

背景

libffi 是一个函数调用解释器(Function Call Interpreter)。它的核心机制是在运行时接收一个函数签名的描述,并即时计算出如何放置每个参数以及如何发起调用。它通过解释调用约定(Calling Convention)来工作,其工作方式类似于字节码虚拟机解释指令。由于在调用前完全不知道函数签名,因此无法进行预先编译(Ahead-of-Time Compilation)。

在追求极致速度的场景下,解释器通常不是首选方案。标准的优化路径是引入 JIT(即时编译):为每个签名编译一个定制的调用桩(Call Stub),生成原生机器码,将参数直接放入寄存器并跳转,从而在运行时无需任何解释。这种方式更快,但其代价是需要在内存中写入既可写又可执行(Writable and Executable, W+X)的新机器码。然而,现代操作系统和安全机制正在极力遏制这种 W+X 内存页的使用,以防范代码注入等安全风险。

因此,libffi 刻意保持为解释器架构。本文探讨的核心问题是:在不生成运行时代码、不映射 W+X 内存页的前提下,如何通过复用已有的知识来最大化提升其解释执行的速度。

核心内容

1. 性能瓶颈:被浪费的计算

当通过 libffi 调用函数时,工作分为两部分:

  1. ffi_prep_cif:针对每个签名仅运行一次。它对整个签名进行分类,但仅保留两个结果:调用所需的栈帧大小(Stack Frame Size)和返回值返回方式的代码。栈帧大小必须在构建调用前确定,因为无法放入寄存器的参数需要溢出到栈上,这部分空间需预先预留。返回值代码用于调用后,因为结果可能返回在 raxxmm0 或内存中,系统需要知道从哪里读取。这两个值都很小且固定,存储在 ffi_cif 结构中。
  2. 被丢弃的部分ffi_prep_cif 花费大部分时间处理的“每个参数具体放置位置”的信息被丢弃了。

在每次调用 ffi_call 时,编组代码(Marshalling Code)会再次遍历参数列表,从头重新推导参数的放置位置,然后复制值。以 x86-64 架构上的三参数调用为例,这涉及约 650 条指令的簿记工作,且每次产生的答案完全相同。

这些指令的大部分并非在移动参数字节,而是在决定字节该去哪里。根据 System V AMD64 ABI,每个参数都通过固定流程进行分类。运行该流程意味着遍历类型、递归进入结构体字段、追踪类型描述符中的指针,将每个 8 字节块分类为整数寄存器或 SSE 寄存器类,并检查剩余寄存器是否足够或需要溢出到栈上。这是一项高度依赖分支和指针追踪的工作,CPU 执行效率较低,且每次调用都重复执行以计算一个不会改变的位置。

2. 解决方案:预编译“计划”(Plan)

既然函数参数的放置位置是签名的纯函数(Pure Function),我们可以只计算一次,记住它,并在后续调用中跳过重复工作。

“计划”(Plan)的概念: 将放置位置编译成一个扁平的移动指令列表,即针对特定签名的微型字节码。如果 ffi_call 每次重新推导放置位置就像每次重新遍历语法树,那么“计划”就是编译后的字节码:遍历语法树只发生一次,后续调用只需运行扁平列表。

build_plan 函数遍历参数类型,按照 ABI 规则对每个参数进行分类,并为每个数据块生成一条移动指令:

  • 这个 8 字节字放入 rdi
  • 那个 32 位整数进行符号扩展后放入 rsi
  • 这个 double 放入 SSE 槽位
  • 那个过大的数据溢出到栈上

拥有“计划”后,发起调用只需执行这些移动指令,无需重新分类。

操作码设计: 操作码设计得非常简单直接:

  • GP64:将字复制到通用寄存器
  • SE8/SE16/SE32:窄整数符号扩展
  • SSE64/SSE32:移动浮点数
  • STACK:内存拷贝溢出的参数

例如,两个真实签名编译后的结果:

  • long (void *, void *, void *):全部为 GP64,标记为 thunk-eligible(适合使用 Thunk)。
  • long (void *, int, void *):包含 SE32(符号扩展),需要解释执行移动循环。

Thunk 优化: 当所有参数都是通用寄存器中的单个 64 位值(大多数指针传递代码的情况)时,“计划”甚至不需要解释器。它被标记为 thunk-eligible,并使用一段手写的小 Thunk 代码。该 Thunk 直接从参数数组加载值到参数寄存器并调用,跳过了移动循环、中间寄存器镜像以及来回复制的过程。如果签名包含需要符号扩展的 int,则需运行移动循环。

执行细节: 在运行移动指令时有一个微妙之处:循环从不直接加载实际的参数寄存器,因为 C 语言没有提供将值放入 rdi 并在调用期间保持的方法(编译器拥有寄存器所有权)。因此,每个移动操作写入一个镜像 System V 寄存器文件(六个整数寄存器和八个 SSE 寄存器按顺序排列)的普通内存结构体。只有在该镜像构建完成后,一段简短的汇编 Trampoline 才会一次性从镜像中加载所有参数寄存器,并跳转到目标地址。

C 代码在内存中移动字节;寄存器在 .text 段中,在调用前瞬间获得最终值。这个 Trampoline 与 ffi_call 一直使用的是同一个,因此“计划”仅改变位置计算的时机,而不改变寄存器加载的方式。

安全性: “计划”是纯数据,Thunk 位于二进制文件的只读文本段(Read-only Text)中,像其他函数一样。没有任何地方同时是可写和可执行的,这与闭包通过静态 Trampoline 获得的属性相同。

3. API 设计与基准测试

API 设计: “计划”作为一个小型的、可选的 API 暴露给用户:

  1. 从准备好的 ffi_cif 构建一个计划(ffi_call_plan_alloc)。
  2. 多次调用该计划(ffi_call_plan_invoke),无需每次设置。
  3. 完成后释放计划(ffi_call_plan_free)。

原有的 ffi_call 保持不变。大多数已经缓存 ffi_cif 的绑定库只需在旁边缓存一个“计划”,并通过 ffi_call_plan_invoke 调用。由于计划一旦构建即不可变,因此一个计划可以在不加锁的情况下被任何线程共享和调用。如果快速路径无法处理某些签名,invoke 会自动回退到 ffi_call

性能数据: 测试环境:Core Ultra 7 255H,-O2 优化,同一二进制文件。 测试场景:三次调用同一函数——直接调用、通过 ffi_call 调用、通过预构建计划调用。

| 调用方式 | 纳秒/次 (ns/call) | 相对倍数 | | :--- | :--- | :--- | | 常规函数直接调用 | 1.9 ns | 1x (基准) | | ffi_call_plan_invoke | 5.1 ns | 2.7x | | ffi_call (原版) | 31.0 ns | 16x |

结果分析

  • 通过 ffi_call 正常调用该函数的成本是直接调用的约 16 倍。
  • 通过预构建计划调用,成本降至直接调用的不到 3 倍。
  • “计划”比原版 ffi_call 快约 6 倍。
  • 对于简单的指针传递签名,计划使用了 Thunk,跳过了寄存器镜像,FFI 开销仅为 3 ns(加上 2 ns 的直接调用开销),而原版 ffi_call 为 29 ns。
  • 对于
查看原文 →atgreen.github.io