doc/contributing/ffi-fast-api-internals.md
This document describes the internal implementation of the node:ffi Fast API
path. It is intended for contributors working on the FFI implementation, not for
users of the public API.
The Fast API path is an optimization layer for FFI calls whose signatures can be represented by V8 Fast API metadata and a generated native trampoline. It does not replace the generic libffi path. Instead, Node.js creates the fastest available callable for each signature and keeps the generic path available for unsupported call shapes, deoptimized V8 calls, and validation behavior that must match the public FFI API.
The Fast API implementation is designed around these goals:
v8::FunctionCallbackInfo path.node:ffi behavior and error shape.lib/ffi.js composing
the two wrapper layers.The implementation is split across these files:
src/ffi/fast.h declares the Fast API type model, metadata containers,
detection query, and platform trampoline hooks.src/ffi/fast.cc maps public FFI type names to Fast API types, creates V8
CFunctionInfo metadata, checks JIT-memory support and signature eligibility,
and exposes buffer conversion helpers used by generated trampolines.src/ffi/jit_memory.{h,cc} performs the runtime executable-memory self-test
used to decide whether generated trampolines are allowed in this process.src/ffi/types.{h,cc} parses public FFI signatures and implements
IsFastCallEligible(), which rejects signatures that the current Fast API
trampolines cannot represent.src/ffi/platforms/*.cc contain the platform trampoline generators. These
files follow the contract exposed by node_ffi_create_fast_trampoline() and
release code with node_ffi_free_fast_trampoline().src/node_ffi.cc decides whether a function gets a Fast API callable,
SharedBuffer callable, or generic callable, and attaches hidden metadata used
by JavaScript wrappers.src/node_ffi.h stores FastFFIMetadata objects in FFIFunctionInfo so V8
metadata and generated executable code stay alive with the JavaScript function.lib/ffi.js is the public module wrapper. It patches DynamicLibrary methods
and composes SharedBuffer and Fast API wrappers.lib/internal/ffi/fast-api.js performs JavaScript-side pointer conversions
for Fast API calls.lib/internal/ffi-shared-buffer.js contains only SharedBuffer-specific
argument packing and result unpacking.DynamicLibrary::CreateFunction() creates one FFIFunctionInfo per generated
JavaScript function. That object owns the parsed FFIFunction, keeps the owning
DynamicLibrary object alive through an internal field, and owns any optimized
invocation metadata.
The creation flow is:
CreateFastFFIMetadata(*fn).FunctionTemplate that contains both the conventional callback and the Fast
API v8::CFunction.InvokeFunction path.Returning nullptr from CreateFastFFIMetadata() is not a public signature
error. It means only that the current Fast API implementation cannot optimize
that signature or cannot safely allocate executable trampoline memory. The caller
then falls back to another invocation strategy.
The Fast API path needs both a platform trampoline emitter and executable memory.
IsFastCallSupported() is the coarse process-level query for this. It returns
true only on supported architectures when IsJitMemorySupported() succeeds.
IsJitMemorySupported() runs a one-time self-test:
mprotect(PROT_READ | PROT_EXEC).std::call_once.The probe deliberately does not execute the generated instruction. Executing a
freshly written capability probe could terminate the process on systems that
block generated code. The real trampoline emitter performs the same writable to
executable transition when creating a callable trampoline and falls back when it
is rejected. Windows uses VirtualAlloc, VirtualProtect, and
FlushInstructionCache for the same probe.
IsFastCallEligible() rejects signatures before native code generation. This
keeps unsupported cases out of the trampoline emitters and lets
CreateFastFFIMetadata() return nullptr cleanly.
Eligibility requires:
void.void cannot be an argument.function typed argument or return value.fn.args and fn.arg_type_names lengths.AArch64 eligibility mirrors src/ffi/platforms/arm64.cc:
x0 is occupied by V8's receiver, so user GP arguments arrive in x1..x7.v0..v7.buffer and arraybuffer arguments use a v8::Local<v8::Value> plus
FastApiCallbackOptions, so they consume one additional GP slot.x86_64 SysV eligibility mirrors src/ffi/platforms/x64.cc:
rdi is occupied by V8's receiver, leaving rsi, rdx, rcx, r8, and
r9 for incoming GP arguments.xmm0..xmm7.Win64 x64 eligibility mirrors the conservative Windows emitter in
src/ffi/platforms/x64.cc:
PPC64LE eligibility mirrors src/ffi/platforms/ppc64.cc:
r3 is occupied by V8's receiver, so user GP arguments arrive in r4..r10.ctr, with the target address in r12 for ELFv2 global entry.LoongArch64 eligibility mirrors src/ffi/platforms/loong64.cc:
a0 is occupied by V8's receiver, so user GP arguments arrive in a1..a7.fa0..fa7 and are not shifted by the receiver slot.jirl.RISC-V 64 eligibility mirrors src/ffi/platforms/riscv64.cc:
a0 is occupied by V8's receiver, so user GP arguments arrive in a1..a7.fa0..fa7 and are not shifted by the receiver slot.jalr.s390x eligibility mirrors src/ffi/platforms/s390x.cc:
r2 is occupied by V8's receiver, so user GP arguments arrive in r3..r6.f0, f2, f4, and f6 and are not shifted by the receiver
slot.br.The native trampoline generator still repeats its own register checks. The eligibility function is the early, centralized rejection point; the generator checks are a defense against direct or future callers.
The internal FastFFIType enum is intentionally smaller than the public FFI type
surface. It models the ABI categories that the generated trampoline knows how to
marshal directly:
kVoidkBoolkFloat32kFloat64kPointerkBufferPublic aliases are normalized in FastScalarTypeFromName() and
FastArgTypeFromName().
pointer, ptr, string, str, buffer, and arraybuffer all represent
pointer-sized native values at the target ABI boundary. They differ in how
JavaScript values are accepted and converted before the target function is
called. function is pointer-sized for the generic FFI surface, but the current
Fast API eligibility check rejects it so it falls back.
CreateFastFFIMetadata() creates a FastFFIMetadata object. That object owns:
FastFFITrampoline trampoline, the executable bridge called by V8.std::vector<v8::CTypeInfo> arg_info, the V8 type list.std::unique_ptr<v8::CFunctionInfo> c_function_info, the V8 signature object.v8::CFunction c_function, the handle attached to the FunctionTemplate.The metadata object must own arg_info and c_function_info because V8 keeps
pointers into that storage. Destroying or moving that storage while the function
is alive would leave V8 with dangling pointers.
The first V8 argument is always the JavaScript receiver. For that reason,
CreateFastFFIMetadata() prepends a v8::CTypeInfo::Type::kV8Value entry before
the public FFI arguments.
If any argument or return value needs a 64-bit integer or pointer, the V8
CFunctionInfo is configured with BigInt representation. This avoids precision
loss for pointer-sized values and 64-bit integers.
Buffer-shaped arguments require one additional
CTypeInfo::kCallbackOptionsType entry so node_ffi_fast_buffer_data() can
throw through FastApiCallbackOptions when conversion fails.
The generated trampoline bridges V8's Fast API calling convention to the native ABI expected by the library symbol. Its responsibilities are:
kBuffer arguments from V8 values to backing-store pointers.The trampoline is generated per signature. It does not loop over arguments at runtime using metadata tables. The code generator may loop while emitting instructions, but the emitted code is a straight-line bridge specialized for the signature.
Executable memory is allocated writable, populated with instructions, flushed
from the instruction cache as required by the platform, and then marked
executable. FastFFIMetadata releases that memory through
node_ffi_free_fast_trampoline() when the JavaScript function is collected.
Cleanup is idempotent and safe for partially initialized metadata.
Fast API buffer arguments are represented internally as FastFFIType::kBuffer.
In V8 metadata they are described as v8::Local<v8::Value>, not as uint64_t.
This lets the generated trampoline receive the original JavaScript object and
call node_ffi_fast_buffer_data() immediately before the native target call.
node_ffi_fast_buffer_data() accepts:
Buffer and other ArrayBuffer viewsArrayBufferSharedArrayBufferFor views, the pointer is the backing store plus byteOffset. For ArrayBuffer
and SharedArrayBuffer, the pointer is the start of the backing store.
Invalid values cause the helper to throw through FastApiCallbackOptions and
return a sentinel value. The generated trampoline checks for that sentinel and
returns zero without calling the native target. This prevents native code from
observing an invalid pointer after JavaScript validation has failed.
Pointer-like public types include pointer, ptr, string, and str. They are
represented as raw unsigned pointer values in the scalar Fast API signature.
JavaScript wrappers in lib/internal/ffi/fast-api.js convert accepted non-BigInt
values before entering the scalar Fast API function:
null and undefined become 0n.Keeping these conversions in JavaScript preserves public FFI semantics and keeps temporary object lifetime explicit.
String conversion intentionally stays in JavaScript. A string accepted by a
string, str, or pointer-like parameter is encoded into a temporary Buffer
with a trailing NUL byte. The pointer to that Buffer is passed to the scalar Fast
API function.
Each owning DynamicLibrary keeps a hidden array of string conversion entries.
Each argument index gets one reusable entry. If the same string is passed again
at the same argument index, the wrapper reuses the existing encoded buffer and
pointer. This is a single-entry reuse strategy, not an unbounded cache. Buffers
grow when needed and are kept alive by the DynamicLibrary object.
This design avoids native lifetime ambiguity. The generated trampoline never allocates temporary string storage and never has to guess how long a pointer to a converted string must stay alive.
A single pointer-like function can be called efficiently in two different ways:
BigInt, null, or a string-converted
pointer.Buffer, a typed array, DataView,
ArrayBuffer, or SharedArrayBuffer.These two cases require different V8 Fast API representations. The scalar case
uses a uint64_t/BigInt-shaped argument. The memory-object case uses a
v8::Local<v8::Value> argument so the generated trampoline can extract the
backing-store pointer.
For monomorphic single-argument pointer-like signatures, native code may attach
a secondary function under the hidden kFastBufferInvoke symbol. This secondary
function uses a cloned signature where the pointer-like argument is described as
buffer for Fast API metadata purposes, while still calling the same native
target symbol.
The JavaScript wrapper dispatches to this secondary function only when the runtime argument is Buffer or ArrayBuffer-backed memory. Other pointer inputs use the primary scalar Fast API function.
This keeps both important call shapes fast. Replacing the primary scalar Fast API function with the buffer-shaped function would simplify the machinery, but it would force BigInt, null, and string-converted pointer calls onto a slower fallback path. Keeping both entrypoints preserves performance for both pointer representations.
Native code attaches internal metadata to raw generated functions using
per-isolate Symbols. These symbols are exported through internalBinding('ffi')
and are not part of the public API.
SharedBuffer metadata uses:
kSbSharedBufferkSbInvokeSlowkSbArgumentskSbReturnFast API metadata uses:
kFastArgumentskFastBufferInvokeThe two groups are intentionally separate. SharedBuffer wrappers should not need
Fast API metadata, and Fast API wrappers should not need SharedBuffer metadata.
lib/ffi.js is the composition layer that reads both groups and decides which
wrapper to apply.
lib/ffi.js patches the native DynamicLibrary methods that expose generated
functions:
DynamicLibrary.prototype.getFunctionDynamicLibrary.prototype.getFunctionsDynamicLibrary.prototype.functions accessorAll three routes call wrapFFIFunction() before returning functions to user
code.
wrapFFIFunction() applies wrappers in this order:
functions accessor.wrapWithSharedBuffer().wrapWithRawPointerConversions().This ordering keeps SharedBuffer-specific behavior inside
lib/internal/ffi-shared-buffer.js, Fast API pointer conversion behavior inside
lib/internal/ffi/fast-api.js, and public wrapper orchestration inside
lib/ffi.js.
Fast API raw pointer conversion wrappers specialize arity 1, 2, and 3 to avoid
rest-argument allocation on common call shapes. Larger signatures use a generic
ReflectApply() path after converting only the required argument indexes.
SharedBuffer remains a separate optimized path for signatures that are not using Fast API but can still avoid per-argument native conversion overhead. The SharedBuffer wrapper writes arguments into a fixed 8-byte slot layout, calls a raw native function with no JavaScript arguments, and reads the return value from slot zero.
Pointer signatures using SharedBuffer have a slow invoker attached under
kSbInvokeSlow. The wrapper uses the SharedBuffer path for BigInt and nullish
pointer values, and falls back to the slow invoker for values that require the
generic native conversion path.
Fast API and SharedBuffer are independent. A function uses either the Fast API
path, the SharedBuffer path, or the generic path as its primary native callable.
lib/ffi.js only composes the JavaScript wrappers needed to preserve public
argument behavior.
Every Fast API function is also bound with the conventional native callback. V8 can call that callback when a JavaScript call site is not eligible for the Fast API path. Node.js also uses the generic path directly when metadata creation rejects a signature.
The generic path remains responsible for complete validation and public error compatibility. Fast wrappers should match those errors for conversions they perform in JavaScript.
The Fast API path intentionally supports only a bounded set of signatures. This keeps V8 metadata, wrapper specialization, and generated trampolines simple and predictable. Signatures outside that bound fall back to SharedBuffer or the generic path.
Important limits are:
function argument or return type in the Fast API path.Linux x86 and armv7 are experimental Node.js platforms, but the current Fast FFI
trampoline model remains 64-bit only. They continue to use SharedBuffer or
generic libffi fallback paths. Linux s390x is a Tier 2 Node.js platform, but
bundled FFI is not currently enabled for that target; if built with
--shared-ffi, scalar register-only Fast API FFI can use the s390x emitter. AIX
PPC64BE is intentionally not covered by this implementation.
These are optimization boundaries, not public FFI signature boundaries. User code can still call supported public FFI signatures through fallback paths.
JavaScript wrappers preserve selected public function metadata:
namelengthpointerThe pointer property mirrors the raw function's pointer descriptor so user
code that reads or reassigns it continues to work through wrappers. Internal
Symbol-keyed metadata is not forwarded to wrappers.