Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions benchmark/ffi/invoke-function.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
24 changes: 21 additions & 3 deletions src/ffi/fast.cc
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,31 @@ bool SignatureNeedsFastBufferInvoke(const FFIFunction& fn) {
IsBufferTypeName(fn.arg_type_names[0]));
}

namespace {

std::shared_ptr<FFIFunction> CloneForFastMetadata(
const std::shared_ptr<FFIFunction>& 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<FFIFunction>();
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<FFIFunction> CloneWithRawPointerArgNames(
const std::shared_ptr<FFIFunction>& 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<FFIFunction>(*fn);
auto clone = CloneForFastMetadata(fn);
for (std::string& name : clone->arg_type_names) {
if (IsBufferTypeName(name)) {
name = "pointer";
Expand All @@ -209,10 +227,10 @@ std::shared_ptr<FFIFunction> CloneWithRawPointerArgNames(

std::shared_ptr<FFIFunction> CloneWithFastBufferArgNames(
const std::shared_ptr<FFIFunction>& 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<FFIFunction>(*fn);
auto clone = CloneForFastMetadata(fn);
for (std::string& name : clone->arg_type_names) {
if (IsPointerTypeName(name)) {
name = "buffer";
Expand Down
37 changes: 27 additions & 10 deletions src/node_ffi.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -146,14 +157,12 @@ Maybe<DynamicLibrary::PreparedFunction> DynamicLibrary::PrepareFunction(

should_cache_symbol = symbols_.find(name) == symbols_.end();

fn = std::make_shared<FFIFunction>(
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<FFIFunction>();
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,
Expand All @@ -178,6 +187,14 @@ Maybe<DynamicLibrary::PreparedFunction> 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;
Expand Down Expand Up @@ -550,7 +567,7 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo<Value>& 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);
Expand Down Expand Up @@ -609,7 +626,7 @@ void DynamicLibrary::InvokeFunctionSB(const FunctionCallbackInfo<Value>& 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);
Expand Down
29 changes: 25 additions & 4 deletions src/node_ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,41 @@
#include <unordered_map>
#include <vector>

// 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<ffi_type*> args;
ffi_type* return_type;
ffi_type* return_type = nullptr;
std::vector<std::string> 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<ffi_call_plan, decltype(&ffi_call_plan_free)> call_plan{
nullptr, ffi_call_plan_free};
#endif

void Invoke(void* result, void** values);
};

class FFIFunctionInfo final : public BaseObject {
Expand Down
Loading