← 返回日报
精读 预计 6 分钟

Performance improvements in libffi

摘要

libffi(libffi)本质是一个运行时函数调用解释器,每次调用都会重新根据 ABI 规则推导 “参数该放在哪个寄存器/栈位置”,这部分计算成本很高且每次都重复。 文章提出的优化是引入 “plan”:在 ffi_cif(签名描述)基础上,把参数布局一次性编译成一组简单的 move 操作(类似字节码),之后每次调用只执行这组固定指令,不再重复做类型分类与寄存器分配。 对于最常见的纯寄存器传参(如 64-bit pointer/整数),还能走 thunk 快路径,直接在寄存器间搬运,进一步减少开销;混合 int + float 或按值 struct 则仍需执行部分 move 逻辑或回退原 ffi_call。 基准测试显示,在 Core Ultra 7 255H 上,该方案将 ffi_call 的开销从约 16x direct call 降到约 3x,且相比 ffi_call 本身约提升 6 倍。 在 GNOME Shell(GNOME Shell)+ GObject Introspection(GObject Introspection)的实际调用跟踪中,约 90% 调用属于纯 64-bit 参数路径,理论上非常适合 plan 复用;调用分布高度重复,使一次构建、多次复用具备现实收益。 该实现目前仅在 x86-64 Linux 上的 libffi git HEAD 中实验性存在,尚未进入正式 release,也未验证其他 ABI 的收益情况。

荐读理由

直接调用 ffi_call_plan_invoke 从 prebuilt plan 跑 ffi_call 就能让纯 64-bit-GP 签名(GNOME Shell 里 90% 的指针/长调用)把 overhead 从 29ns 压到 3ns,迁移到 AI 工程项目里就能跳过每次 ffi_prep_cif + re-classification 的 650 条指令开销

原文

Performance improvements in libffi

libffi is a function call interpreter. You hand it a description of a function’s signature at runtime, and it works out, on the spot, how to place each argument and make the call. It interprets the calling convention the way a bytecode VM interprets instructions. Nothing is compiled ahead of time, because the whole point is that you don’t know the signature ahead of time.

An interpreter is not what you reach for when you want speed. That is what JIT compilation is for, and some systems choose it instead.

A runtime can JIT-compile a bespoke call stub for each signature, native code that drops the arguments into registers and jumps, with no interpretation left at runtime. It’s quicker, but it works by generating code at runtime into memory that’s both writable and executable, which is exactly what modern systems are trying to stamp out.

So libffi stays an interpreter, on purpose. The question I set out to answer was how much faster it could get that way, by making better use of what it already knows instead of generating code at runtime or mapping any page writable and executable.

The waste#

When you call a function through libffi, the work splits across two places. ffi_prep_cif runs once per signature. It classifies the whole thing, but it keeps only two results: the size of the stack frame the call will need, and a small code for how the return value comes back. The frame size has to be known before the call is built, because any argument that doesn’t fit in a register spills to the stack, and that space is reserved up front. The return code is for afterward, because the result comes back in rax, or xmm0, or memory depending on the type, and something has to know where to read it from. Both are small and fixed-size, so they live in the ffi_cif. What prep throws away is the part it spent most of its time on: where each individual argument goes.

So on every ffi_call, the marshalling code walks the argument list again and re-derives that placement from scratch before copying the values into place. For a three-argument call on x86-64 that’s around 650 instructions of bookkeeping, and it produces the identical answer every single time.

Most of those instructions aren’t moving argument bytes. They’re deciding where the bytes go. The x86-64 calling convention has genuine rules, and applying them to a single argument means walking its type, recursing into a struct’s fields and chasing the pointers in its type descriptor, sorting each 8-byte chunk into an integer or floating-point register class, and checking whether it still fits in the registers that are left or has to spill to the stack. That is branch-heavy, pointer-chasing work, the sort a CPU runs slowly, and it reruns on every call to compute a placement that never changes.

But function argument placement is a pure function of the signature. We can compute it once, remember it, and skip the work on every later call.

A plan#

The fix is a “plan”: the placement compiled into a flat list of moves, a tiny bytecode for one signature. If ffi_call re-deriving the placement on every call is like interpreting a program by re-walking its syntax tree each time, the plan is the compiled bytecode: the tree-walk happens once, and every later call just runs the flat list. build_plan walks the argument types once, classifies each one the way the ABI rules say, and emits a move per piece: this 8-byte word goes in rdi, that 32-bit int gets sign-extended into rsi, this double lands in an SSE slot, that oversized thing spills to the stack. With the plan in hand, making the call is just running the moves. No re-classification.

Building a call plan, then running it

The opcodes are deliberately dumb. GP64 copies a word into a general register; SE8/SE16/SE32 sign-extend a narrow int; SSE64/SSE32 move a float; STACK memcpys a spilled argument. A three-argument call compiles to three or four of them. Here’s what two real signatures turn into:

long (void *, void *, void *)    long (void *, int, void *)
  GP64  avalue[0] -> rdi           GP64  avalue[0] -> rdi
  GP64  avalue[1] -> rsi           SE32  avalue[1] -> rsi   (sign-extend)
  GP64  avalue[2] -> rdx           GP64  avalue[2] -> rdx
  => all GP64: thunk               => has an SE32: interpret

When every argument is a single 64-bit value in a general register, which is most pointer-passing code, the plan doesn’t even need the interpreter. It’s marked thunk-eligible, and a small hand-written thunk in .text loads the values straight from the argument array into the argument registers and calls. It skips the move loop, the intermediate register image, and the copying back and forth entirely. The call on the right keeps an int, so it needs the sign-extend, so it runs the move loop instead.

The plan is plain data, and the thunk ships in the binary’s read-only text like any other function. Nothing is ever both writable and executable, the same property closures already get from static trampolines.

Build it once, invoke it many times#

The plan is exposed as a small, opt-in API. You build a plan from a prepared ffi_cif, invoke it as many times as you like, and free it when you’re done:

ffi_call_plan *plan = ffi_call_plan_alloc(&cif);   /* build the plan once */

ffi_call_plan_invoke(plan, fn, &rv, av);           /* invoke it, no per-call setup */
/* ... invoke it again, and again ... */

ffi_call_plan_free(plan);

ffi_call itself is untouched. A binding that already caches an ffi_cif per signature, which is most of them, caches a plan beside it and calls through ffi_call_plan_invoke. The plan is immutable once built, so one plan can be shared and invoked from any thread without a lock. A signature the fast path can’t handle is still fine: invoke falls back to ffi_call for it.

The numbers#

This is the fair comparison: one libffi, the same function, reached three ways. A plain direct call to it, the same call through ffi_call, and the same call through a prebuilt plan. Same binary, same machine (a Core Ultra 7 255H), same -O2, so the only thing that differs between the two FFI rows is the API. The timed loop is just this, over and over:

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);          /* built once */

void *av[] = { &a, &b, &c };
long rv;
ffi_call_plan_invoke(plan, (void(*)(void))fn, &rv, av);   /* <-- this is what we time */
ptr(p,p,p)                ns/call   vs a regular call
regular function call        1.9       1x
ffi_call_plan_invoke         5.1       2.7x
ffi_call                    31.0       16x

There’s the headline. Calling that function the normal way through ffi_call costs about 16 times what a direct call to it costs. Through a prebuilt plan it’s under 3 times. The plan is about 6x faster than ffi_call, and since they are the same library that is the API and nothing else.

Most of what the plan removes is the per-call re-classification: ffi_call rebuilds the placement every time, while invoke just runs the prebuilt moves. On this shape the plan takes the thunk, so it skips the register image too and lands close to a plain call: about 3 ns of FFI overhead on top of a 2 ns call, against 29 ns for ffi_call.

Mixed integer and floating-point signatures don’t take the thunk, because a 32-bit int needs sign extension and a double needs an SSE register, so they run the move loop and land a little higher. They still skip the re-classification. A struct-by-value argument has no plan, so invoke falls back to ffi_call and costs exactly what it did before.

Where the calls actually go#

A 5x number on one shape only matters if real programs use that shape, and call it often enough that building a plan once pays off. So I traced one.

GNOME Shell is a good stress test: the entire desktop UI is JavaScript calling into C through GObject Introspection, which calls through libffi. I attached an eBPF uprobe to ffi_call with Whistler and watched for a while. The top signatures looked like this:

21744  int   (void *)
19139  void *(void *, unsigned long)
13083  void *(void *)
10116  void  (void *, void *, void *, long, void *)
 9918  void *(void *, void *)

Around 90% of the calls are pure 64-bit-GP, pointers and longs, which is the thunk path. Not a single by-value struct argument showed up in over a hundred thousand calls. And these are the same handful of signatures called over and over, exactly the shape that rewards building a plan once and invoking it forever. A binding like GObject Introspection already holds an ffi_cif per signature; a plan slots in right beside it.

This all lives on the HEAD of the libffi git tree, not in any release, and it needs more testing before it’s something to build on. Today it’s x86-64 Linux only. Whether it’s worth doing for other ABIs isn’t clear: the payoff is proportional to how much per-call classification there is to skip, and that varies a lot between calling conventions.

The code is on GitHub: libffi.

Lobsters · 1 赞 · 0 评 讨论 → 阅读原文 →

这条对你有帮助吗?