Version
main
Platform
Subsystem
ffi
What steps will reproduce the bug?
repro.c
#include <stdint.h>
// Fast-eligible: 2 register arguments, scalar return. Resolving this signature
// makes CreateFastFFIMetadata() emit a trampoline through AllocateCodeNear().
int32_t add2(int32_t a, int32_t b) {
return a + b;
}
// Fast-ineligible baseline: 9 arguments exceeds V8's fast-call cap, so no
// trampoline is emitted and AllocateCodeNear() is never reached.
int32_t add9(int32_t a, int32_t b, int32_t c, int32_t d, int32_t e, int32_t f,
int32_t g, int32_t h, int32_t i) {
return a + b + c + d + e + f + g + h + i;
}
repro.js
import { DynamicLibrary, suffix } from 'node:ffi';
import { stat } from 'node:fs/promises';
const libPath = new URL(`./repro.${suffix}`, import.meta.url).pathname;
await stat(libPath);
const fastEligible = { arguments: ['i32', 'i32'], return: 'i32' };
const fastIneligible = { arguments: Array(9).fill('i32'), return: 'i32' };
const lib = new DynamicLibrary(libPath);
function timeResolve(name, signature) {
const start = performance.now();
lib.getFunction(name, signature);
return (performance.now() - start) * 1000;
}
// Warm up one-time initialization (libffi setup, executable-memory self-test)
// so it is not attributed to the first measured resolution.
lib.getFunction('add9', fastIneligible);
lib.getFunction('add2', fastEligible);
// Same symbol resolution work, but no trampoline is allocated.
console.log(`add9, no trampoline: ${timeResolve('add9', fastIneligible).toFixed(1)} us`);
console.log(`add2, first probe: ${timeResolve('add2', fastEligible).toFixed(1)} us`);
// Every fast-eligible resolution allocates another trampoline page next to the
// library, so the free pages inside AllocateCodeNear()'s +/- 1024 page probe
// window are steadily consumed. Once they are gone, each allocation walks the
// whole window, fails every MAP_FIXED_NOREPLACE probe, and takes the plain-mmap
// fallback it could have taken immediately.
const samples = [];
for (let i = 0; i < 1200; i++) samples.push(timeResolve('add2', fastEligible));
for (let i = 0; i < samples.length; i += 200) {
const window = samples.slice(i, i + 200);
const mean = window.reduce((a, b) => a + b, 0) / window.length;
console.log(`add2, resolves ${i + 1}-${i + window.length}: mean ${mean.toFixed(1)} us`);
}
lib.close();
Commands to run:
$ cc -shared -fPIC -O2 -o repro.so repro.c
$ node --experimental-ffi repro.js
How often does it reproduce? Is there a required condition?
Always
What is the expected behavior? Why is that the expected behavior?
Resolving a fast-eligible signature costs about as much as a fast-ineligible one, since the extra work is a single page allocation.
What do you see instead?
add9, no trampoline: 29.0 us
add2, first probe: 31.1 us
add2, resolves 1-200: mean 517.5 us
add2, resolves 201-400: mean 657.0 us
add2, resolves 401-600: mean 728.0 us
add2, resolves 601-800: mean 751.0 us
add2, resolves 801-1000: mean 753.1 us
add2, resolves 1001-1200: mean 755.4 us
Once the pages near the library are used up, each fast-eligible resolution costs ~28x a fast-ineligible one, spent entirely on ~2048 mmap calls that are all expected to fail.
Additional information
No response
Version
main
Platform
Subsystem
ffi
What steps will reproduce the bug?
repro.crepro.jsCommands to run:
How often does it reproduce? Is there a required condition?
Always
What is the expected behavior? Why is that the expected behavior?
Resolving a fast-eligible signature costs about as much as a fast-ineligible one, since the extra work is a single page allocation.
What do you see instead?
Once the pages near the library are used up, each fast-eligible resolution costs ~28x a fast-ineligible one, spent entirely on ~2048
mmapcalls that are all expected to fail.Additional information
No response