Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
deps: patch v8 fast-call to support no-receiver mode
Adds CFunctionInfo::HasReceiver = kNo. When set, V8's TurboFan and
Turboshaft fast-call lowering omits the JS receiver from the C call
— the C function pointer is invoked with user args only, no receiver
in the first parameter register.

For Node's FFI fast-call path this means the receiver-strip JIT stub
is no longer needed: dlsym'd target functions can be registered
directly with V8 as the C address. Eliminates ~7 instructions per
call (AArch64) plus V8's own receiver-into-arg0 setup. Yields +3-24%
over the prior validators+stub path on FFI microbenchmarks (largest
gains on many-args and pointer-bigint), and beats the pre-fix
silent-truncation thin wrapper on every numeric and pointer benchmark
while preserving strict validation.

The change is gated by an enum on CFunctionInfo (default kYes,
backward-compatible). Existing fast-call users (DOM bindings, V8
internals) are unaffected. Patches in deps/v8 cover the API header,
the constructor, the GetFastApiCallTarget overload-matching, and the
js-call-reducer input-layout setup; the simplified-lowering and
turboshaft graph-builder loops already iterate from input 0 over
ArgumentCount() inputs and pick up the new layout automatically.

Signed-off-by: Bryan English <bryan@bryanenglish.com>
  • Loading branch information
bengl committed May 6, 2026
commit 56ffab4b406e08be5cb73e97ef3b5b36a4c8f14b
19 changes: 18 additions & 1 deletion deps/v8/include/v8-fast-api-calls.h
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,28 @@ class V8_EXPORT CFunctionInfo {
kBigInt = 1, // Use BigInts to represent 64 bit integers.
};

// Whether the C function takes a JS receiver as its first argument.
// Most fast-call C functions do (matching how V8 wires up FunctionTemplate
// callbacks). Embedders that want to register a plain C function pointer
// — e.g. an FFI dispatcher that has no use for the receiver — can set this
// to kNo. In that mode V8 omits the receiver from the C call: arg_info[0]
// is the first user argument, ArgumentCount() returns the user-arg count,
// and the JS receiver value is discarded by the lowering instead of being
// passed in the first parameter register.
enum class HasReceiver : uint8_t {
kYes = 0,
kNo = 1,
};

// Construct a struct to hold a CFunction's type information.
// |return_info| describes the function's return type.
// |arg_info| is an array of |arg_count| CTypeInfos describing the
// arguments. Only the last argument may be of the special type
// CTypeInfo::kCallbackOptionsType.
CFunctionInfo(const CTypeInfo& return_info, unsigned int arg_count,
const CTypeInfo* arg_info,
Int64Representation repr = Int64Representation::kNumber);
Int64Representation repr = Int64Representation::kNumber,
HasReceiver has_receiver = HasReceiver::kYes);

const CTypeInfo& ReturnInfo() const { return return_info_; }

Expand All @@ -327,6 +341,8 @@ class V8_EXPORT CFunctionInfo {

Int64Representation GetInt64Representation() const { return repr_; }

bool HasReceiverArg() const { return has_receiver_ == HasReceiver::kYes; }

// |index| must be less than ArgumentCount().
// Note: if the last argument passed on construction of CFunctionInfo
// has type CTypeInfo::kCallbackOptionsType, it is not included in
Expand All @@ -342,6 +358,7 @@ class V8_EXPORT CFunctionInfo {
private:
const CTypeInfo return_info_;
const Int64Representation repr_;
const HasReceiver has_receiver_;
const unsigned int arg_count_;
const CTypeInfo* arg_info_;
};
Expand Down
4 changes: 3 additions & 1 deletion deps/v8/src/api/api.cc
Original file line number Diff line number Diff line change
Expand Up @@ -11992,9 +11992,11 @@ CFunction::CFunction(const void* address, const CFunctionInfo* type_info)

CFunctionInfo::CFunctionInfo(const CTypeInfo& return_info,
unsigned int arg_count, const CTypeInfo* arg_info,
Int64Representation repr)
Int64Representation repr,
HasReceiver has_receiver)
: return_info_(return_info),
repr_(repr),
has_receiver_(has_receiver),
arg_count_(arg_count),
arg_info_(arg_info) {
DCHECK(repr == Int64Representation::kNumber ||
Expand Down
8 changes: 6 additions & 2 deletions deps/v8/src/compiler/fast-api-calls.cc
Original file line number Diff line number Diff line change
Expand Up @@ -385,10 +385,14 @@ FastApiCallFunction GetFastApiCallTarget(
function_template_info.c_signatures(broker);
const size_t overloads_count = signatures.size();

// Only considers entries whose type list length matches arg_count.
// Only considers entries whose type list length matches arg_count. For
// signatures registered with HasReceiver=kNo, the C-side ArgumentCount
// already excludes the receiver, so we don't subtract it here.
for (size_t i = 0; i < overloads_count; i++) {
const CFunctionInfo* c_signature = signatures[i];
const size_t len = c_signature->ArgumentCount() - kReceiver;
const size_t len =
c_signature->ArgumentCount() -
(c_signature->HasReceiverArg() ? kReceiver : 0);
bool optimize_to_fast_call =
(len == arg_count) &&
fast_api_call::CanOptimizeFastSignature(c_signature);
Expand Down
13 changes: 10 additions & 3 deletions deps/v8/src/compiler/js-call-reducer.cc
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,9 @@ class FastApiCallReducerAssembler : public JSCallReducerAssembler {
// arguments, so extract c_argument_count from the first function.
const int c_argument_count =
static_cast<int>(c_function_.signature->ArgumentCount());
CHECK_GE(c_argument_count, kReceiver);
if (c_function_.signature->HasReceiverArg()) {
CHECK_GE(c_argument_count, kReceiver);
}

const int slow_arg_count =
// Arguments for CallApiCallbackOptimizedXXX builtin including
Expand All @@ -677,11 +679,16 @@ class FastApiCallReducerAssembler : public JSCallReducerAssembler {
base::SmallVector<Node*, kInlineSize> inputs(value_input_count +
kEffectAndControl);
int cursor = 0;
inputs[cursor++] = n.receiver();
const bool has_receiver_arg =
c_function_.signature->HasReceiverArg();
if (has_receiver_arg) {
inputs[cursor++] = n.receiver();
}

// TODO(turbofan): Consider refactoring CFunctionInfo to distinguish
// between receiver and arguments, simplifying this (and related) spots.
int js_args_count = c_argument_count - kReceiver;
int js_args_count =
c_argument_count - (has_receiver_arg ? kReceiver : 0);
for (int i = 0; i < js_args_count; ++i) {
if (i < n.ArgumentCount()) {
inputs[cursor++] = n.Argument(i);
Expand Down
36 changes: 16 additions & 20 deletions src/ffi/fastcall/cfunction_info.cc
Original file line number Diff line number Diff line change
Expand Up @@ -91,30 +91,25 @@ CFunctionInfoBundle& CFunctionInfoBundle::operator=(
}

CFunctionInfoBundle BuildCFunctionInfo(const FFIFunction& fn) {
using T = v8::CTypeInfo::Type;
static_assert(std::is_trivially_destructible_v<v8::CTypeInfo>,
"CTypeInfo must be trivially destructible for placement-new "
"array without explicit element destruction");
CFunctionInfoBundle b;

// V8 wants the receiver as arg[0]. Total CTypeInfo entries = args + 1.
// CTypeInfo has no default constructor, so we allocate raw storage and
// placement-new each element individually.
size_t n = fn.args.size() + 1;
void* raw = ::operator new[](n * sizeof(v8::CTypeInfo));
b.arg_types = static_cast<v8::CTypeInfo*>(raw);
// Placement-new the receiver slot.
new (&b.arg_types[0]) v8::CTypeInfo(T::kV8Value);
// Placement-new the arg slots with a placeholder; overwritten below.
for (size_t i = 1; i < n; ++i) {
new (&b.arg_types[i]) v8::CTypeInfo(T::kVoid);
}

b.arg_classes.reserve(fn.args.size());
for (size_t i = 0; i < fn.args.size(); ++i) {
auto m = MapArgType(fn.args[i]);
b.arg_types[i + 1] = v8::CTypeInfo(m.ctype);
b.arg_classes.push_back(m.cls);
// We register the C function with HasReceiver=kNo, so V8 does not pass a
// JS receiver in the first parameter register. The CTypeInfo[] holds only
// user-arg types — no receiver slot. CTypeInfo has no default constructor,
// so we allocate raw storage and placement-new each element.
size_t n = fn.args.size();
if (n > 0) {
void* raw = ::operator new[](n * sizeof(v8::CTypeInfo));
b.arg_types = static_cast<v8::CTypeInfo*>(raw);
b.arg_classes.reserve(n);
for (size_t i = 0; i < n; ++i) {
auto m = MapArgType(fn.args[i]);
new (&b.arg_types[i]) v8::CTypeInfo(m.ctype);
b.arg_classes.push_back(m.cls);
}
}

auto rm = MapResultType(fn.return_type);
Expand All @@ -125,7 +120,8 @@ CFunctionInfoBundle BuildCFunctionInfo(const FFIFunction& fn) {
return_info,
static_cast<unsigned int>(n),
b.arg_types,
v8::CFunctionInfo::Int64Representation::kBigInt);
v8::CFunctionInfo::Int64Representation::kBigInt,
v8::CFunctionInfo::HasReceiver::kNo);

return b;
}
Expand Down
185 changes: 76 additions & 109 deletions src/node_ffi.cc
Original file line number Diff line number Diff line change
Expand Up @@ -319,118 +319,85 @@ MaybeLocal<Function> DynamicLibrary::CreateFunction(
if (fc_ok) {
auto bundle = node::ffi::fastcall::BuildCFunctionInfo(*fn);
{
auto stub = node::ffi::fastcall::EmitForwarder(
isolate, fn->ptr, bundle.arg_classes, bundle.result_class);
if (!stub.has_value()) {
// Warn at most once per process: a single FFI app may register hundreds
// of functions, and the failure cause (mprotect / SELinux / hardened
// runtime) is the same for every call. Repeated warnings would flood
// logs without adding signal.
static std::atomic<bool> warned{false};
if (!warned.exchange(true, std::memory_order_acq_rel)) {
if (ProcessEmitWarning(env,
"FFI fast-call stub emission failed for an eligible "
"signature; falling back to libffi for this and possibly "
"future calls. This usually indicates a JIT memory or "
"permission issue (mprotect/SELinux/hardened runtime).")
.IsNothing()) {
return MaybeLocal<Function>();
}
// Register the dlsym'd target function pointer directly with V8 as the
// C function. With CFunctionInfo built using HasReceiver=kNo, V8's fast-
// call lowering omits the JS receiver from the C call — so no receiver-
// strip stub is needed. The target's plain C signature matches the
// CFunctionInfo exactly.
v8::CFunction cfun(fn->ptr, bundle.info);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be broken when the v8 14.8 upgrade lands, which includes https://chromium-review.googlesource.com/c/v8/v8/+/7828135 that requires CFunction to outlive the FunctionTemplate

v8::Local<v8::FunctionTemplate> tmpl = v8::FunctionTemplate::New(
isolate,
DynamicLibrary::InvokeFunction,
data,
v8::Local<v8::Signature>(),
static_cast<int>(fn->args.size()),
v8::ConstructorBehavior::kThrow,
v8::SideEffectType::kHasSideEffect,
&cfun);
v8::Local<v8::Function> fc_fn;
if (tmpl->GetFunction(context).ToLocal(&fc_fn)) {
bool metadata_ok = true;

v8::Local<v8::ArrayBuffer> alive_ab = AliveBuffer(isolate);
if (!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_alive_symbol(),
alive_ab, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}
// fall through to libffi path
} else {
v8::CFunction cfun(stub->entry, bundle.info);
v8::Local<v8::FunctionTemplate> tmpl = v8::FunctionTemplate::New(
isolate,
DynamicLibrary::InvokeFunction,
data,
v8::Local<v8::Signature>(),
static_cast<int>(fn->args.size()),
v8::ConstructorBehavior::kThrow,
v8::SideEffectType::kHasSideEffect,
&cfun);
v8::Local<v8::Function> fc_fn;
if (tmpl->GetFunction(context).ToLocal(&fc_fn)) {
// Build all V8 values before touching `info` fields, so that on
// any failure we can free the stub and return cleanly without
// leaking JIT memory.
bool metadata_ok = true;

// Alive ArrayBuffer.
v8::Local<v8::ArrayBuffer> alive_ab = AliveBuffer(isolate);
if (!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_alive_symbol(),
alive_ab, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}

// Build the slow-invoke v8::Function for the wrapper's pointer
// fallback path.
Local<Function> slow_fn;
if (metadata_ok &&
!Function::New(context, DynamicLibrary::InvokeFunction, data)
.ToLocal(&slow_fn)) {
metadata_ok = false;
}
if (metadata_ok &&
!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_invoke_slow_symbol(),
slow_fn, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}

// Attach params and result type names.
Local<Value> params_arr;
if (metadata_ok &&
!ToV8Value(context, fn->arg_type_names, isolate)
.ToLocal(&params_arr)) {
metadata_ok = false;
}
if (metadata_ok &&
!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_params_symbol(),
params_arr, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}
Local<Value> result_str;
if (metadata_ok &&
!ToV8Value(context, fn->return_type_name, isolate)
.ToLocal(&result_str)) {
metadata_ok = false;
}
if (metadata_ok &&
!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_result_symbol(),
result_str, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}

if (!metadata_ok) {
// Free the stub to avoid a JIT memory leak.
node::ffi::fastcall::JitMemory::Get(isolate)
->Free(*stub);
return MaybeLocal<Function>();
}

// All metadata attached successfully. Populate fast-call state.
info->fast = std::make_unique<FastCallState>(
isolate, slow_fn, stub->entry, stub->alloc_size,
std::make_unique<node::ffi::fastcall::CFunctionInfoBundle>(
std::move(bundle)));
ret = fc_fn;
} else {
// Template-to-function failed (V8 has a pending exception). Free the
// stub since we won't use it; mirror the metadata-fail path and
// propagate the empty MaybeLocal so the caller surfaces the V8
// exception.
node::ffi::fastcall::JitMemory::Get(isolate)
->Free(*stub);

Local<Function> slow_fn;
if (metadata_ok &&
!Function::New(context, DynamicLibrary::InvokeFunction, data)
.ToLocal(&slow_fn)) {
metadata_ok = false;
}
if (metadata_ok &&
!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_invoke_slow_symbol(),
slow_fn, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}

Local<Value> params_arr;
if (metadata_ok &&
!ToV8Value(context, fn->arg_type_names, isolate)
.ToLocal(&params_arr)) {
metadata_ok = false;
}
if (metadata_ok &&
!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_params_symbol(),
params_arr, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}
Local<Value> result_str;
if (metadata_ok &&
!ToV8Value(context, fn->return_type_name, isolate)
.ToLocal(&result_str)) {
metadata_ok = false;
}
if (metadata_ok &&
!fc_fn->DefineOwnProperty(
context, env->ffi_fastcall_result_symbol(),
result_str, internal_attrs)
.FromMaybe(false)) {
metadata_ok = false;
}

if (!metadata_ok) {
return MaybeLocal<Function>();
}

info->fast = std::make_unique<FastCallState>(
isolate, slow_fn, /*stub=*/nullptr, /*alloc_size=*/0,
std::make_unique<node::ffi::fastcall::CFunctionInfoBundle>(
std::move(bundle)));
ret = fc_fn;
} else {
return MaybeLocal<Function>();
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion test/cctest/test_ffi_fastcall_cfunction.cc
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ TEST(FFIFastCallCFunction, NumericSignature) {
{"i32", "i32"});
auto bundle = BuildCFunctionInfo(fn);
// Receiver + 2 args = 3 CTypeInfo entries.
EXPECT_EQ(bundle.info->ArgumentCount(), 3u);
// No-receiver mode: ArgumentCount counts user args only (no leading
// v8::Value receiver slot).
EXPECT_EQ(bundle.info->ArgumentCount(), 2u);
EXPECT_EQ(bundle.arg_classes.size(), 2u);
EXPECT_EQ(bundle.arg_classes[0], ArgClass::kGP);
EXPECT_EQ(bundle.arg_classes[1], ArgClass::kGP);
Expand Down