| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
|
Review requested:
|
Sorry, something went wrong.
|
At first glance, some observations:
|
Sorry, something went wrong.
| 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
Sorry, something went wrong.
| } | ||
|
|
||
| return true; | ||
| } |
There was a problem hiding this comment.
C++ style: This should return std::optional<std::pair<FastFFIType, CTypeInfo>>
Sorry, something went wrong.
| 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
Sorry, something went wrong.
| @@ -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).
Sorry, something went wrong.
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
Sorry, something went wrong.
| 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?
Sorry, something went wrong.
| kBuffer = 12, | ||
| }; | ||
|
|
||
| bool ToToFastFFIType(ffi_type* type, |
There was a problem hiding this comment.
Is the double To intentional?
Sorry, something went wrong.
| }; | ||
|
|
||
| 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, |
Sorry, something went wrong.
| #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
Sorry, something went wrong.
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? |
Sorry, something went 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? |
Sorry, something went wrong.
|
I get a little confused here. I guess you're right, but what are they invoking? How are the target functions built? |
Sorry, something went wrong.
|
@ShogunPanda Yeah, so, looking at the code in fast.cc, we're already using those as I would expect ... I guess my question is, do we think the complexity introduced by the cranelift wrapper is justified, given that we can already easily cover a fairly broad range directly through V8's own fast API support? Like @bengl said, the wrapper logic and its (massive) scaffolding is fairly independent from the core V8 fast call integration, and making these separate PRs (and separate decisions) seems wise. |
Sorry, something went wrong.
As for your questions – they are invoking native functions living in the process's memory, and they are typically built with a compiler. But these don't seem like actual answers to your questions, so I'm not sure I understand what you're saying here |
Sorry, something went wrong.
|
After a brainstorm sessing with @bengl I finally got a confirmation of my interpretation of your request and run a local spike. I checked the direct CFunction(address, CFunctionInfo*) path. It does not work for plain FFI symbols because V8 Fast API signatures include the JS receiver as the first C argument. A native FFI symbol such as int32_t(int32_t) therefore does not match a JS call with one argument; V8 expects a fast callback shaped like int32_t(Local receiver, int32_t). So direct V8 Fast API can use runtime type info, but it still requires an embedder-compatible wrapper. For runtime FFI signatures that means either a finite set of predefined wrappers or generated trampolines. Since I want to have a the "most universal solution" possible, I don't want to introduce predefined wrappers. I've evaluated other possible solutions but so far Cranelift seems to be the only viable. Do you concur on this or am I missing anything? |
Sorry, something went wrong.
Is that requirement made explicit or documented anywhere? I did try manually to remove the receiver argument from some of the Node.js built-in fast API call functions, and it didn't seem to make a difference (obviously this only works if the second argument isn't also a Local<>).
I think it's still worth thinking about ways in which to remove restrictions on the V8 side (which, yes, that has annoying implications around timelines because it's non-trivial upstream work), but it seems like something that would be significantly cleaner in the medium term |
Sorry, something went wrong.
|
@addaleax Can you point me where you successfully removed it? I can try something similar. |
Sorry, something went wrong.
|
@ShogunPanda Hm, it looks like this just worked "silently" because without Local<Value> receiver as the first parameter, the C++ source would compile fine and V8 would accept the signature, but it would not actually invoke the fast call variant. You're right that "shifting" away the first argument cannot really be done without some runtime/JIT compilation. I don't know if Cranelift is worth the overhead, since we're using it for a very very specific use case, and it would be possible to implement this for x64/arm64 ourselves, but I also see how an actual compiler library is something worth thinking about in that case. I think I'd still have a mild preference for seeing if there are ways to achieve the same goals by collaborating with the V8 team. Removing the need for a receiver argument, for example, should not be too complex (other than the requirement to modify V8 for this). |
Sorry, something went wrong.
|
I think given all that, for now, the best move is still to start with a PR that adds V8 Fast Calls alone, with no other changes, and then hold off on the rest until the modifying V8 is explored. @ShogunPanda SGTY? |
Sorry, something went wrong.
|
@bengl @addaleax I'm currently exploring using MIR instead of Cranelift, which is WAY smaller. Adding V8 Fast API now it would be useless unless we only enable a very narrow set of specialized and handwritten helpers. Which is not something I would like to do. |
Sorry, something went wrong.
|
Benchmarks ony my machine (Apple M2 Max on MacOS 26): ffi/add-64.js n=10000000 *** 3055.59 % ±24.62% ±33.18% ±44.05% ffi/add-f32.js n=10000000 *** 3294.28 % ±20.39% ±27.48% ±36.48% ffi/add-i16.js n=10000000 *** 2695.44 % ±25.29% ±34.08% ±45.25% ffi/add-i32.js n=10000000 *** 3064.68 % ±21.53% ±29.02% ±38.52% ffi/add-i64.js n=10000000 *** 3302.07 % ±47.30% ±63.75% ±84.62% ffi/add-i8.js n=10000000 *** 2615.40 % ±41.19% ±55.51% ±73.70% ffi/add-u16.js n=10000000 *** 2652.67 % ±96.77% ±130.42% ±173.15% ffi/add-u64.js n=10000000 *** 3520.18 % ±25.17% ±33.92% ±45.03% ffi/add-u8.js n=10000000 *** 2566.88 % ±88.23% ±118.91% ±157.87% ffi/buffer-first-byte-direct.js n=10000000 *** 866.43 % ±7.53% ±10.13% ±13.39% ffi/buffer-first-byte.js n=10000000 *** 795.50 % ±4.54% ±6.11% ±8.10% ffi/buffer-sum-direct.js n=10000000 *** 789.80 % ±7.46% ±10.04% ±13.30% ffi/buffer-sum.js n=10000000 *** 802.62 % ±5.58% ±7.52% ±9.98% ffi/getpid.js n=10000000 *** 1351.43 % ±44.12% ±59.45% ±78.92% ffi/identity-i32.js n=10000000 *** 2420.69 % ±16.71% ±22.50% ±29.84% ffi/many-args.js n=10000000 *** 4880.80 % ±35.29% ±47.56% ±63.14% ffi/noop-void.js n=10000000 *** 790.98 % ±9.68% ±13.04% ±17.31% ffi/pointer-bigint.js n=10000000 *** 1082.11 % ±6.52% ±8.78% ±11.64% ffi/pointer-buffer-direct.js n=10000000 *** 882.63 % ±2.52% ±3.39% ±4.49% ffi/pointer-buffer.js n=10000000 *** 858.21 % ±6.12% ±8.25% ±10.93% ffi/pointer-null.js n=10000000 *** 1991.44 % ±6.58% ±8.87% ±11.76% ffi/string-equals-hello-buffer-direct.js n=10000000 *** 680.82 % ±2.55% ±3.44% ±4.55% ffi/string-equals-hello-buffer.js n=10000000 *** 699.65 % ±3.37% ±4.52% ±5.94% ffi/string-first-char-buffer-direct.js n=10000000 *** 861.77 % ±4.74% ±6.37% ±8.44% ffi/string-first-char-buffer.js n=10000000 *** 790.11 % ±3.91% ±5.26% ±6.98% ffi/string-length-buffer-direct.js n=10000000 *** 808.66 % ±2.40% ±3.23% ±4.27% ffi/string-length-buffer.js n=10000000 *** 871.50 % ±4.70% ±6.33% ±8.38% ffi/string-length-string-direct.js n=10000000 *** 809.19 % ±1.14% ±1.52% ±1.98% ffi/string-length-string.js n=10000000 *** 1184.05 % ±14.64% ±19.73% ±26.19% ffi/sum-3-i32.js n=10000000 *** 3732.15 % ±24.49% ±33.01% ±43.82% ffi/sum-5-i32.js n=10000000 *** 4582.52 % ±24.00% ±32.34% ±42.93% ffi/sum-8-i32.js n=10000000 -0.06 % ±0.37% ±0.49% ±0.64% |
Sorry, something went wrong.
| } | ||
|
|
||
| #else | ||
| #elif !defined(__x86_64__) |
There was a problem hiding this comment.
This part probably shouldn’t live in arm64.cc
Sorry, something went wrong.
There was a problem hiding this comment.
lgtm
Sorry, something went wrong.
Add fast API trampolines for AArch64 and x86_64. IsJitMemorySupported() maps an RW page, writes a ret instruction (0xD65F03C0 AArch64 / 0xC3 x86_64), and mprotects it to RX. A successful RX transition is treated as the support signal. The page is deliberately not executed: this check may run during normal operation (when an FFI function is first created), and executing freshly written code from a capability probe could SIGSEGV/SIGKILL the process on systems that block executable memory. The real trampoline emitter performs the same mprotect at creation time and falls back to libffi when it is rejected. The result is computed once via std::call_once and cached for the lifetime of the process, so concurrent callers never observe a provisional value. Wired into CreateFastFFIMetadata() for early nullptr bail-out when JIT memory is unavailable. Windows stub returns false (no trampolines yet on this branch). IsFastCallEligible() validates at parse time whether a signature can use the fast-call path, covering: - Return and argument type eligibility (numeric, pointer; no structs) - Argument count cap (8, matching V8 fast-call limit) - Per-ABI register pressure limits that mirror the trampoline emitters (AArch64 and x86_64 SysV). Platforms without an emitter, including Win64, are reported ineligible. - Buffer and float args cannot coexist, and buffer args additionally consume an extra GP register slot on both supported ABIs. The arg/arg-name lengths are checked before the per-arg loop so a malformed signature cannot index out of bounds. Returns nullptr from CreateFastFFIMetadata() for ineligible signatures, falling back to libffi. Co-authored-by: Bryan English <bryan@bryanenglish.com> Signed-off-by: Paolo Insogna <paolo@cowtech.it> Signed-off-by: Bryan English <bryan@bryanenglish.com> Assisted-By: OpenAI:GPT-5.5 <openai/gpt-5.5>
|
@addaleax @bengl @mcollina I've update the PR. This is finally ready to land now. I've update the review guide. Here's the latest benchmarks: confidence improvement accuracy (*) (**) (***) ffi/add-64.js n=10000000 *** 3278.01 % ±25.33% ±34.14% ±45.32% ffi/add-f32.js n=10000000 *** 3510.40 % ±63.34% ±85.37% ±113.33% ffi/add-f64.js n=10000000 *** 3496.22 % ±16.64% ±22.42% ±29.76% ffi/add-i16.js n=10000000 *** 2930.89 % ±23.34% ±31.44% ±41.72% ffi/add-i32.js n=10000000 *** 3189.01 % ±112.64% ±151.81% ±201.54% ffi/add-i64.js n=10000000 *** 3683.74 % ±33.52% ±45.17% ±59.96% ffi/add-i8.js n=10000000 *** 2836.50 % ±23.02% ±31.02% ±41.18% ffi/add-u16.js n=10000000 *** 2905.98 % ±28.77% ±38.78% ±51.48% ffi/add-u64.js n=10000000 *** 3786.23 % ±35.41% ±47.72% ±63.35% ffi/add-u8.js n=10000000 *** 2803.08 % ±18.03% ±24.30% ±32.26% ffi/buffer-first-byte-direct.js n=10000000 *** 933.43 % ±3.06% ±4.13% ±5.48% ffi/buffer-first-byte.js n=10000000 *** 869.76 % ±5.69% ±7.66% ±10.14% ffi/buffer-sum-direct.js n=10000000 *** 855.34 % ±19.51% ±26.29% ±34.89% ffi/buffer-sum.js n=10000000 *** 860.10 % ±16.46% ±22.18% ±29.43% ffi/getpid.js n=10000000 *** 1407.47 % ±7.45% ±9.92% ±12.94% ffi/identity-i32.js n=10000000 *** 2601.42 % ±12.32% ±16.60% ±22.03% ffi/many-args.js n=10000000 *** 4963.37 % ±37.10% ±50.00% ±66.38% ffi/noop-void.js n=10000000 *** 894.44 % ±27.90% ±37.60% ±49.91% ffi/pointer-bigint.js n=10000000 *** 1176.11 % ±5.30% ±7.13% ±9.45% ffi/pointer-buffer-direct.js n=10000000 *** 958.23 % ±43.64% ±58.80% ±78.04% ffi/pointer-buffer.js n=10000000 *** 914.58 % ±16.19% ±21.82% ±28.96% ffi/pointer-null.js n=10000000 *** 2198.21 % ±9.59% ±12.92% ±17.14% ffi/string-equals-hello-buffer-direct.js n=10000000 *** 737.46 % ±2.24% ±3.00% ±3.96% ffi/string-equals-hello-buffer.js n=10000000 *** 825.09 % ±5.14% ±6.89% ±9.05% ffi/string-first-char-buffer-direct.js n=10000000 *** 953.08 % ±13.21% ±17.79% ±23.60% ffi/string-first-char-buffer.js n=10000000 *** 859.31 % ±13.21% ±17.80% ±23.62% ffi/string-length-buffer-direct.js n=10000000 *** 870.62 % ±14.66% ±19.75% ±26.21% ffi/string-length-buffer.js n=10000000 *** 964.00 % ±3.54% ±4.72% ±6.17% ffi/string-length-string-direct.js n=10000000 *** 879.73 % ±16.57% ±22.33% ±29.64% ffi/string-length-string.js n=10000000 *** 1216.76 % ±33.08% ±44.58% ±59.18% ffi/sum-3-i32.js n=10000000 *** 3861.01 % ±35.38% ±47.68% ±63.30% ffi/sum-5-i32.js n=10000000 *** 4669.63 % ±27.60% ±37.20% ±49.39% ffi/sum-8-i32.js n=10000000 -0.63 % ±0.64% ±0.85% ±1.11% ffi/sum-buffer.js n=1000000 size=1024 *** 113.83 % ±2.97% ±3.95% ±5.14% ffi/sum-buffer.js n=1000000 size=16384 *** 7.98 % ±0.77% ±1.03% ±1.34% ffi/sum-buffer.js n=1000000 size=64 *** 496.73 % ±7.52% ±10.11% ±13.37% Be aware that when doing many comparisons the risk of a false-positive result increases. In this case, there are 36 comparisons, you can thus expect the following amount of false-positive results: 1.80 false positives, when considering a 5% risk acceptance (*, **, ***), 0.36 false positives, when considering a 1% risk acceptance (**, ***), 0.04 false positives, when considering a 0.1% risk acceptance (***) (this was on my Apple MacBookPro M2 Max. Note that ffi/sum-8-i32.js is not optimizable on arm64) |
Sorry, something went wrong.
There was a problem hiding this comment.
lgtm
Sorry, something went wrong.
Sorry, something went wrong.
Sorry, something went wrong.
There was a problem hiding this comment.
lgtm
Sorry, something went wrong.
Sorry, something went wrong.
Sorry, something went wrong.
Sorry, something went wrong.
Add fast API trampolines for AArch64 and x86_64. IsJitMemorySupported() maps an RW page, writes a ret instruction (0xD65F03C0 AArch64 / 0xC3 x86_64), and mprotects it to RX. A successful RX transition is treated as the support signal. The page is deliberately not executed: this check may run during normal operation (when an FFI function is first created), and executing freshly written code from a capability probe could SIGSEGV/SIGKILL the process on systems that block executable memory. The real trampoline emitter performs the same mprotect at creation time and falls back to libffi when it is rejected. The result is computed once via std::call_once and cached for the lifetime of the process, so concurrent callers never observe a provisional value. Wired into CreateFastFFIMetadata() for early nullptr bail-out when JIT memory is unavailable. Windows stub returns false (no trampolines yet on this branch). IsFastCallEligible() validates at parse time whether a signature can use the fast-call path, covering: - Return and argument type eligibility (numeric, pointer; no structs) - Argument count cap (8, matching V8 fast-call limit) - Per-ABI register pressure limits that mirror the trampoline emitters (AArch64 and x86_64 SysV). Platforms without an emitter, including Win64, are reported ineligible. - Buffer and float args cannot coexist, and buffer args additionally consume an extra GP register slot on both supported ABIs. The arg/arg-name lengths are checked before the per-arg loop so a malformed signature cannot index out of bounds. Returns nullptr from CreateFastFFIMetadata() for ineligible signatures, falling back to libffi. Co-authored-by: Bryan English <bryan@bryanenglish.com> Signed-off-by: Paolo Insogna <paolo@cowtech.it> Signed-off-by: Bryan English <bryan@bryanenglish.com> Assisted-By: OpenAI:GPT-5.5 <openai/gpt-5.5> PR-URL: #63068 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Bryan English <bryan@bryanenglish.com>
| Back | FazBrowse Home | New Git URL |
Review Guide: Fast FFI
This guide is for reviewing the Fast API path added to the experimental
node:ffi implementation. It focuses on correctness, fallback behavior, and the
places where ABI or V8 Fast API assumptions can affect runtime safety.
Summary
Fast FFI is an optimization layer for eligible FFI signatures. It does not
replace the generic libffi path. For each generated FFI function, Node.js tries
to create the fastest safe callable and falls back when the signature, platform,
or process environment cannot support generated Fast API trampolines.
The main review question is whether every optimized path preserves the public FFI
semantics while avoiding unsupported ABI shapes before native code generation.
What Changed
The implementation adds a Fast API callable path with generated native
trampolines, JavaScript wrapper composition, and runtime support checks.
Important changes include:
signatures.
Architecture Overview
Function creation starts in src/node_ffi.cc. For each FFI function, native code
tries the Fast API path first. If Fast API metadata cannot be created, it tries
the SharedBuffer path. If neither optimized path applies, it creates the generic
libffi-backed callable.
The public wrapper layer is in lib/ffi.js. It patches the DynamicLibrary
entry points that expose functions and routes each raw function through
wrapFFIFunction() before user code receives it.
Wrapper responsibilities are intentionally split:
nullish conversions.
unpacking, and slow fallback dispatch.
Hidden native metadata is grouped by optimization path:
Key Design Points
Fast API support is opt-in per function. Returning nullptr from
CreateFastFFIMetadata() is not a public API error; it means the function must
use another invocation path.
IsFastCallSupported() is the coarse process-level check. It requires a
supported platform emitter and successful IsJitMemorySupported() result.
IsJitMemorySupported() maps a writable page, writes a minimal return
instruction, flushes the instruction cache, changes the page to RX, unmaps it,
and caches the result with std::call_once. The probe deliberately does not
execute the generated instruction.
IsFastCallEligible() rejects unsupported signatures before code generation. It
checks public argument count, return and argument types, function exclusion,
platform support, and ABI register pressure.
The generated trampoline still repeats register checks. Treat those as a defense
against future direct callers; reviewers should still expect the centralized
eligibility function to reject unsupported signatures first.
Reviewer Focus
Check that optimized functions and fallback functions stay behaviorally
equivalent for supported public FFI signatures.
Focus on these areas:
generic native path.
Correctness Checklist
Use this checklist while reviewing the implementation:
NUL-terminated and rejected when they contain embedded NUL bytes.
correct byte offset.
pointer.
conversion.
Platform / ABI Checklist
AArch64 support should match the trampoline generator contract:
x86_64 SysV support should match the trampoline generator contract:
stack into the target ABI's sixth GP register.
Win64 is currently excluded. IsJitMemorySupported() returns false on Windows
because this branch has no Win64 trampoline emitter or VirtualAlloc-based JIT
memory support.
Fallback Behavior
Fallback is part of the design, not an error condition.
Fast API metadata creation can fail because of unsupported types, unsupported
platforms, excessive argument count, excessive register pressure, missing JIT
memory support, or trampoline allocation failure. In all of those cases, function
creation should continue through SharedBuffer or generic libffi where possible.
V8 can also call the conventional callback attached to a Fast API function when a
call site is not eligible for the Fast API path. That callback must remain the
source of complete validation and public error compatibility.
Testing
Relevant focused tests:
Additional useful checks:
typed array, DataView, ArrayBuffer, and SharedArrayBuffer.
fallback paths.
Benchmark Notes
Fast API improvements should be largest for hot scalar calls where generic
callback overhead dominates. Pointer and string benchmarks depend on the
JavaScript wrapper path being applied, because the raw Fast API function expects
already-converted values for scalar pointer-like signatures.
ffi/sum-8-i32.js is intentionally not expected to improve in the current
implementation. It exceeds current register and argument constraints and should
fall back.
ffi/sum-buffer.js improvements shrink for large buffer sizes. At size=16384,
the native target scans enough memory that O(n) native work dominates the fixed
FFI call overhead.
When comparing binaries, verify that node-old and node-new are distinct
executables. Symlinking both names to the same out/Release/node binary makes
benchmark comparisons meaningless.
Known Limits
Current Fast API limits are optimization boundaries, not public FFI API limits.
Known limits include:
trampoline.
Review Map
Start with these files:
SharedBuffer versus generic callable selection.
buffer helper, metadata creation.
fallback dispatch.
Disclaimer
This guide is review material, not user-facing API documentation. The node:ffi
module is still experimental, and the Fast API path is an internal optimization
that must remain safe to disable or bypass through fallback paths.
Assisted-By: OpenAI:GPT-5.5 <openai/gpt-5.5>