Skip to content

ffi: AllocateCodeNear() probes up to 2048 pages with a failing mmap each, costing ~790 µs per trampoline #64968

Description

@trivikr

Version

main

Platform

Linux

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

Metadata

Metadata

Assignees

Labels

ffiIssues and PRs related to experimental Foreign Function Interface support.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions