ffi: add experimental fast FFI call API #63068
ffi: add experimental fast FFI call API #63068ShogunPanda wants to merge 2 commits intonodejs:mainfrom
Conversation
Signed-off-by: Paolo Insogna <paolo@cowtech.it> Assisted-By: OpenAI:GPT-5.5 <openai/gpt-5.5>
Signed-off-by: Paolo Insogna <paolo@cowtech.it> Assisted-By: OpenAI:GPT-5.5 <openai/gpt-5.5>
|
Review requested:
|
|
At first glance, some observations:
|
| ffi_args_heap.resize(nargs); | ||
| values = values_heap.data(); | ||
| ffi_args = ffi_args_heap.data(); | ||
| } |
There was a problem hiding this comment.
This is exactly what MaybeStackBuffer is there for
| } | ||
|
|
||
| return true; | ||
| } |
There was a problem hiding this comment.
C++ style: This should return std::optional<std::pair<FastFFIType, CTypeInfo>>
| std::shared_ptr<void> fast_code; | ||
| std::vector<v8::CTypeInfo> fast_arg_info; | ||
| std::unique_ptr<v8::CFunctionInfo> fast_function_info; | ||
| std::unique_ptr<v8::CFunction> fast_c_function; |
There was a problem hiding this comment.
Feel free to leave a TODO for me to clean up the allocation management here, having 10+ separate heap allocations for each function seems like a lot
| @@ -129,6 +129,9 @@ class EnvironmentOptions : public Options { | |||
| bool experimental_addon_modules = EXPERIMENTALS_DEFAULT_VALUE; | |||
| bool experimental_eventsource = EXPERIMENTALS_DEFAULT_VALUE; | |||
| bool experimental_ffi = EXPERIMENTALS_DEFAULT_VALUE; | |||
| #if HAVE_FAST_FFI | |||
| bool experimental_fast_ffi = EXPERIMENTALS_DEFAULT_VALUE; | |||
| #endif | |||
There was a problem hiding this comment.
Just to echo what @bengl said – It seems like having the flag available unconditionally would not break anything and just make things easier (e.g. save you the file reexecution jumps you're hooping through in the tests).
There was a problem hiding this comment.
This is first-party Node.js core code, right? It probably shouldn't live in deps/ in the long run
| allocate a temporary UTF-8 copy. For performance-sensitive C string APIs, encode | ||
| the string before invoking the native function, for example with `TextEncoder`, | ||
| and declare the parameter as `buffer` or `arraybuffer`. Include the trailing | ||
| `\0` byte when the native API expects a NUL-terminated string. |
There was a problem hiding this comment.
... but that's also a temporary UTF-8 copy, just like passing a string directly would have been?
| kBuffer = 12, | ||
| }; | ||
|
|
||
| bool ToToFastFFIType(ffi_type* type, |
| }; | ||
|
|
||
| bool ToToFastFFIType(ffi_type* type, | ||
| const std::string& type_name, |
There was a problem hiding this comment.
| const std::string& type_name, | |
| std::string_view type_name, |
| #if HAVE_FAST_FFI | ||
| PrepareFastFunction(env, fn.get()); | ||
| const CFunction* fast_c_function = fn->fast_c_function.get(); | ||
| #endif |
There was a problem hiding this comment.
| #endif | |
| #else | |
| const CFunction* fast_c_function = nullptr; | |
| #endif |
that lets you get rid of the much larger conditional below here
Unfortunately that's not the case, as far as I understood this problem. V8 Fast API optimize JS -> C++ entry, Cranelift generates the native wrapper that performs the ABI-correct call to the FFI target. FFI signatures are declared at runtime, while V8 Fast API requires a concrete native signature for each fast callable. Cranelift is what turns the runtime FFI signature into such a concrete callable. A libffi-only Fast API path is possible, but only for a finite set of predefined C++ wrapper signatures, and it would still route through ffi_call(). That would not provide the universal fast path this PR is trying to introduce.
@addaleax Also concurred on this below. I'll remove it.
I'll attach some benchmarks tomorrow so we can compare.
As far as I understand, TCC is LGPL which is not usable in Node.js? Am I wrong? |
Does it? I haven't tried it out myself, but there are CFunction(const void* address, const CFunctionInfo* type_info);
CFunctionInfo(const CTypeInfo& return_info, unsigned int arg_count,
const CTypeInfo* arg_info,
Int64Representation repr = Int64Representation::kNumber);constructors available, which should allow constructing CFunction instances with runtime-supplied type information, no? |
|
I get a little confused here. I guess you're right, but what are they invoking? How are the target functions built? |
Review Guide: Optional Fast FFI
Summary
This change adds an optional fast call path for the experimental
node:ffimodule. The fast path uses V8 Fast API calls on the JavaScript/C++ boundary and
Cranelift-generated native trampolines on the C++/native boundary.
The feature remains opt-in at runtime with
--experimental-fast-ffiand is nowalso optional at build time with
--without-fast-ffi. Builds without fast FFI donot compile the fast C++ integration, do not enable the Cranelift Rust module,
and do not expose
--experimental-fast-ffiinprocess.allowedNodeEnvironmentFlags.Baseline
node:ffisupport remains controlled by--experimental-ffiand theexisting
--without-ffi/--shared-ffibuild options.TODO
Motivation
The existing FFI call path is general and compatibility-oriented. It supports
the full declared FFI surface, including slow conversions, but has substantial
per-call overhead for simple scalar and raw-memory signatures.
This change provides a faster path for a deliberately limited subset of valid
FFI signatures while preserving the existing compatibility path for everything
else.
User-Facing Behavior
Fast FFI is active only when all of these are true:
--experimental-ffi.--experimental-fast-ffi.If any condition is not met, the existing FFI path is used or the flag is
unavailable.
Build-Time Behavior
Default FFI build:
python3 configure.py --ninjaExpected config:
Fast FFI disabled:
python3 configure.py --ninja --without-fast-ffiExpected config:
In a
--without-fast-ffibuild, passing--experimental-fast-ffifails as anunknown option.
Runtime Flags
--experimental-ffiEnables the experimental
node:ffimodule when the binary was built with FFIsupport.
--experimental-fast-ffiEnables fast calls for supported FFI signatures when the binary was built with
fast FFI support. This flag requires
--experimental-ffi.Fast-Path Eligibility
Eligible signatures use scalar numeric values, pointers, registered callback
pointers, explicit buffers, and explicit array buffers.
Supported type families include:
voidi8,u8,i16,u16,i32,u32i64,u64f32,f64pointerbufferarraybufferfunctionas a registered callback pointer valueUnsupported signatures continue through the existing compatibility path.
Notably,
stringandstrsignatures intentionally remain on the compatibilitypath because they allocate temporary UTF-8 storage per call. Performance-sensitive
C strings should be pre-encoded by user code and passed as explicit
buffer/arraybuffervalues, including a trailing\0when required by the CAPI.
Correctness Constraints
Reviewers should pay particular attention to these invariants:
u64values preserve BigInt semantics.f32andf64values preserve special values such asNaN, infinities, and-0.pointeracceptsbigintandnullin the fast path.bufferorarraybuffersignatures.pointerremain compatibility-path behavior.pointerremain compatibility-pathbehavior.
path when the fast sentinel branch handles the value.
Safety Model
Fast FFI compiles a wrapper for a declared signature and native target slot. The
wrapper loads the native target from the dynamic function slot on each call, so a
closed library can be detected rather than calling a stale target.
Fast FFI assumes the user-provided signature accurately describes the native
function. Calling a native function with an incorrect signature is outside the
safety guarantees of
node:ffiand remains user responsibility.Build Integration
The build changes are intentionally split between baseline FFI and fast FFI:
node_use_fficontrols baselinenode:ffisupport.node_use_fast_fficontrols fast FFI support.HAVE_FFIguards baseline FFI code and options.HAVE_FAST_FFIguards fast-only C++ storage, declarations, and CLI optionregistration.
src/ffi/fast.ccis compiled only whennode_use_fast_ffi == "true".fast_ffifeature.
This keeps baseline bundled libffi builds independent from the Cranelift fast
path.
Rust Integration
The Rust crate under
deps/cratesremains the single Rust static library used byNode. Fast FFI adds a gated module:
Cranelift-related dependencies are optional and are enabled by:
cargo rustc --features fast_ffithrough
deps/crates/crates.gypwhennode_use_fast_ffi == "true".Important Files
Build and configure:
configure.pynode.gypnode.gypideps/crates/Cargo.tomldeps/crates/crates.gypdeps/crates/src/lib.rsdeps/crates/src/node_fast_ffi.rsC++ implementation:
src/node_ffi.ccsrc/node_ffi.hsrc/ffi/fast.ccsrc/node_options.ccsrc/node_options.hTests:
test/common/index.jstest/ffi/test-ffi-shared-buffer.jstest/ffi/test-ffi-fast.jstest/ffi/test-ffi-calls.jstest/ffi/test-ffi-dynamic-library.jstest/parallel/test-process-env-allowed-flags-are-documented.jsDocs:
doc/api/ffi.mddoc/api/cli.mddoc/node.1Review Checklist
Build system:
--without-fast-ffisetsnode_use_fast_ffitofalse.--without-ffistill disables all FFI support.--shared-ffidoes not accidentally enable fast FFI.src/ffi/fast.ccis absent from disabled fast FFI builds.C++ guards:
FFIFunctionare behindHAVE_FAST_FFI.PrepareFastFunction()is declared and called only underHAVE_FAST_FFI.--experimental-fast-ffiis registered only underHAVE_FAST_FFI.experimental_fast_ffi.Fast call behavior:
name,length, and pointer metadata.u64remains BigInt-facing.Tests and docs:
--experimental-fast-ffido not fail before skip logic can run.allowedNodeEnvironmentFlagstests account for optional fast FFI.Verification Commands
Default fast-enabled build:
Confirm enabled flag state:
out/Release/node --no-warnings -p "[process.config.variables.node_use_fast_ffi, process.allowedNodeEnvironmentFlags.has('--experimental-fast-ffi')].join(' ')"Expected output:
Fast-disabled build:
Confirm disabled flag state:
Expected behavior:
Whitespace check:
git diff --checkVerified Locally
Verified on macOS arm64:
allowedNodeEnvironmentFlagsdocumentation test passed.tools/test.py --mode=release ffi/test-ffi-fastpassed.--without-fast-fficonfigure completed successfully.--without-fast-ffibuild completed successfully.--experimental-fast-ffiwas absent fromallowedNodeEnvironmentFlagsindisabled build.
--experimental-fast-ffiwas rejected by the disabled build.git diff --checkpassed.Cross-Platform Notes
Only macOS arm64 was verified locally. Reviewers should pay special attention to:
deps/crates/cargo_build.py.signatures.
PR Footer