From f4d2a526c1ac0bd681551521163be288d9be3477 Mon Sep 17 00:00:00 2001 From: umuoy1 Date: Sat, 1 Aug 2026 21:52:23 +0800 Subject: [PATCH 1/2] ffi: reuse libffi call plans Precompute a libffi call plan for each fixed signature on x86-64 System V and reuse it from the generic and SharedBuffer invokers. This avoids repeating argument-placement work for every call. Continue to use ffi_call() with libffi older than 3.7, on other ABIs, and when plan allocation fails. Signed-off-by: umuoy1 --- src/ffi/fast.cc | 24 +++++++++++++++++++++--- src/node_ffi.cc | 37 +++++++++++++++++++++++++++---------- src/node_ffi.h | 29 +++++++++++++++++++++++++---- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/ffi/fast.cc b/src/ffi/fast.cc index ea98bf8aa4f1..4ee5ca56ba10 100644 --- a/src/ffi/fast.cc +++ b/src/ffi/fast.cc @@ -192,13 +192,31 @@ bool SignatureNeedsFastBufferInvoke(const FFIFunction& fn) { IsBufferTypeName(fn.arg_type_names[0])); } +namespace { + +std::shared_ptr CloneForFastMetadata( + const std::shared_ptr& fn) { + // Fast metadata only needs the native target and signature. In particular, + // its temporary clone must not borrow the original function's cif or plan. + auto clone = std::make_shared(); + clone->closed = fn->closed; + clone->ptr = fn->ptr; + clone->args = fn->args; + clone->return_type = fn->return_type; + clone->arg_type_names = fn->arg_type_names; + clone->return_type_name = fn->return_type_name; + return clone; +} + +} // namespace + std::shared_ptr CloneWithRawPointerArgNames( const std::shared_ptr& fn) { // The primary Fast API entrypoint receives pointer-compatible values as // BigInts after the JS wrapper has converted strings, nullish values, and // memory-backed objects. A secondary entrypoint handles the monomorphic // memory-backed case without extracting the pointer in JS. - auto clone = std::make_shared(*fn); + auto clone = CloneForFastMetadata(fn); for (std::string& name : clone->arg_type_names) { if (IsBufferTypeName(name)) { name = "pointer"; @@ -209,10 +227,10 @@ std::shared_ptr CloneWithRawPointerArgNames( std::shared_ptr CloneWithFastBufferArgNames( const std::shared_ptr& fn) { - // Reuse the same native target and libffi metadata, but describe the JS + // Reuse the same native target and signature metadata, but describe the JS // argument as `buffer` so CreateFastFFIMetadata() emits a trampoline that // receives a V8 value and calls node_ffi_fast_buffer_data(). - auto clone = std::make_shared(*fn); + auto clone = CloneForFastMetadata(fn); for (std::string& name : clone->arg_type_names) { if (IsPointerTypeName(name)) { name = "buffer"; diff --git a/src/node_ffi.cc b/src/node_ffi.cc index b05d09270126..58cca944f40a 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -42,6 +42,17 @@ using v8::Value; namespace ffi { +void FFIFunction::Invoke(void* result, void** values) { +#if defined(NODE_FFI_HAS_FAST_CALL_PLAN) + if (call_plan != nullptr) { + ffi_call_plan_invoke(call_plan.get(), FFI_FN(ptr), result, values); + return; + } +#endif + + ffi_call(&cif, FFI_FN(ptr), result, values); +} + void FFIFunctionInfo::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("sb_backing", sb_backing); } @@ -146,14 +157,12 @@ Maybe DynamicLibrary::PrepareFunction( should_cache_symbol = symbols_.find(name) == symbols_.end(); - fn = std::make_shared( - FFIFunction{.closed = false, - .ptr = ptr, - .cif = {}, - .args = args, - .return_type = return_type, - .arg_type_names = std::move(arg_type_names), - .return_type_name = std::move(return_type_name)}); + fn = std::make_shared(); + fn->ptr = ptr; + fn->args = std::move(args); + fn->return_type = return_type; + fn->arg_type_names = std::move(arg_type_names); + fn->return_type_name = std::move(return_type_name); ffi_status status = ffi_prep_cif(&fn->cif, FFI_DEFAULT_ABI, @@ -178,6 +187,14 @@ Maybe DynamicLibrary::PrepareFunction( return {}; } +#if defined(NODE_FFI_HAS_FAST_CALL_PLAN) + // Allocation failure is non-fatal. Invoke() falls back to ffi_call(). + ffi_call_plan* call_plan = ffi_call_plan_alloc(&fn->cif); + if (call_plan != nullptr) { + fn->call_plan.reset(call_plan); + } +#endif + should_cache_function = true; } else { fn = existing->second; @@ -550,7 +567,7 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo& args) { result = Malloc(GetFFIReturnValueStorageSize(fn->return_type)); } - ffi_call(&fn->cif, FFI_FN(fn->ptr), result, ffi_args.data()); + fn->Invoke(result, ffi_args.data()); // Return result back to Javascript ToJSReturnValue(env, args, fn->return_type, result); @@ -609,7 +626,7 @@ void DynamicLibrary::InvokeFunctionSB(const FunctionCallbackInfo& args) { alignas(8) uint8_t result_storage[kSBResultStorageSize] = {0}; void* result = (fn->return_type != &ffi_type_void) ? result_storage : nullptr; - ffi_call(&fn->cif, FFI_FN(fn->ptr), result, ffi_args.data()); + fn->Invoke(result, ffi_args.data()); if (result != nullptr) { WriteFFIReturnToBuffer(fn->return_type, result, buffer, 0); diff --git a/src/node_ffi.h b/src/node_ffi.h index a55cb74fc619..07bd0163db75 100644 --- a/src/node_ffi.h +++ b/src/node_ffi.h @@ -14,20 +14,41 @@ #include #include +// libffi only accelerates reusable call plans on x86-64 System V. Other +// targets implement the API by calling ffi_call(), which adds no benefit. +#if defined(FFI_VERSION_NUMBER) && FFI_VERSION_NUMBER >= 30700 && \ + defined(__x86_64__) && !defined(__ILP32__) && !defined(X86_WIN64) && \ + !defined(_WIN32) +#define NODE_FFI_HAS_FAST_CALL_PLAN 1 +#endif + namespace node::ffi { class DynamicLibrary; struct FFIFunction; struct FFIFunction { - bool closed; + FFIFunction() = default; + FFIFunction(const FFIFunction&) = delete; + FFIFunction& operator=(const FFIFunction&) = delete; + FFIFunction(FFIFunction&&) = delete; + FFIFunction& operator=(FFIFunction&&) = delete; - void* ptr; - ffi_cif cif; + bool closed = false; + + void* ptr = nullptr; + ffi_cif cif = {}; std::vector args; - ffi_type* return_type; + ffi_type* return_type = nullptr; std::vector arg_type_names; std::string return_type_name; +#if defined(NODE_FFI_HAS_FAST_CALL_PLAN) + // The plan borrows cif, so it must remain uniquely owned by this instance. + std::unique_ptr call_plan{ + nullptr, ffi_call_plan_free}; +#endif + + void Invoke(void* result, void** values); }; class FFIFunctionInfo final : public BaseObject { From 249d72973ab2ada0457520a299ba8f472108deae Mon Sep 17 00:00:00 2001 From: umuoy1 Date: Sun, 2 Aug 2026 19:05:10 +0800 Subject: [PATCH 2/2] benchmark: add FFI invocation benchmark Measure the call path for signatures that bypass the V8 Fast API and reach FFIFunction::Invoke(), covering a register-only and a stack-spilled libffi call plan on x86-64 System V. The per-call delta is the decision-relevant metric for reusable call plans: plan allocation is a one-time cost that amortizes within a few calls. Signed-off-by: umuoy1 --- benchmark/ffi/invoke-function.js | 79 ++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 benchmark/ffi/invoke-function.js diff --git a/benchmark/ffi/invoke-function.js b/benchmark/ffi/invoke-function.js new file mode 100644 index 000000000000..ae8d5b2ef795 --- /dev/null +++ b/benchmark/ffi/invoke-function.js @@ -0,0 +1,79 @@ +'use strict'; + +const assert = require('node:assert'); +const common = require('../common.js'); +const { libraryPath, ensureFixtureLibrary } = require('./common.js'); + +// Measure the invocation (call) path for signatures that bypass V8 Fast API +// and use libffi through FFIFunction::Invoke(). On x86-64 System V with +// libffi >= 3.7, Invoke() reuses a precomputed call plan that avoids repeating +// argument-placement work on every call. This benchmark quantifies the +// per-call benefit. +// +// Signatures chosen to bypass both V8 Fast API and keep native work minimal: +// - call_int_callback (null): 'function' type forces the generic path; null +// pointer triggers the early return in C so native computation is negligible. +// From libffi's perspective this is a register-only plan (2 pointer-sized +// args both fit in GP registers on x86-64 System V). +// - sum_8_i32: 8 GP args exceed the x86-64 Fast API register cap (6), forcing +// the generic path. From libffi's perspective 6 args go in registers and 2 +// spill to the stack, exercising a stack-spilled plan. + +const bench = common.createBenchmark(main, { + n: [1e7], + symbol: ['call_int_callback', 'sum_8_i32'], +}, { + flags: ['--experimental-ffi', '--no-warnings'], +}); + +ensureFixtureLibrary(); + +function main({ n, symbol }) { + const ffi = require('node:ffi'); + + if (symbol === 'call_int_callback') { + // 'function' type bypasses Fast API (IsFastCallEligible rejects it). + // Pass 0n (null function pointer) so the native function returns -1 + // immediately without invoking any callback, keeping per-call overhead + // dominated by the FFI call machinery itself. + const { lib, functions } = ffi.dlopen(libraryPath, { + call_int_callback: { return: 'i32', arguments: ['function', 'i32'] }, + }); + + try { + // Verify the null-pointer early return. + assert.strictEqual(functions.call_int_callback(0n, 7), -1); + + bench.start(); + for (let i = 0; i < n; ++i) + functions.call_int_callback(0n, 21); + bench.end(n); + } finally { + lib.close(); + } + } else { + // 8 integer args exceed the x86-64 SysV GP register cap (6), which makes + // CreateFastFFIMetadata reject the signature. Calls go through the + // SharedBuffer or generic invoker into FFIFunction::Invoke(). + const { lib, functions } = ffi.dlopen(libraryPath, { + sum_8_i32: { + return: 'i32', + arguments: [ + 'i32', 'i32', 'i32', 'i32', + 'i32', 'i32', 'i32', 'i32', + ], + }, + }); + + const fn = functions.sum_8_i32; + + assert.strictEqual(fn(1, 2, 3, 4, 5, 6, 7, 8), 36); + + bench.start(); + for (let i = 0; i < n; ++i) + fn(1, 2, 3, 4, 5, 6, 7, 14); + bench.end(n); + + lib.close(); + } +}