Commit e256fcb78be for nodejs

commit e256fcb78be109bec04284105de2508087ba7599
Author: James M Snell <jasnell@gmail.com>
Date:   Sun Oct 11 17:38:31 2026 +0000

    lib: add gc options to node:bench

    Adds option post-warmup and pre-sample gc options
    to node:bench

    Signed-off-by: James M Snell <jasnell@gmail.com>
    Assisted-by: Opencode
    PR-URL: https://github.com/nodejs/node/pull/66664
    Reviewed-By: Matteo Collina <matteo.collina@gmail.com>

diff --git a/doc/api/bench.md b/doc/api/bench.md
index b457cff50c4..8118a928d0f 100644
--- a/doc/api/bench.md
+++ b/doc/api/bench.md
@@ -90,6 +90,32 @@ system load can all affect results. Keep raw samples when comparing results and
 investigate noisy or skewed distributions rather than treating a confidence
 interval as a pass/fail threshold.

+### Garbage collection
+
+Warmup invocations can leave garbage on the heap that is collected during the
+first measured samples, charging warmup allocations to measured work. The `gc`
+option forces a full garbage collection outside the measured region:
+
+* `'none'`: No garbage collection is forced. This is the default.
+* `'once'`: A full garbage collection runs after warmup, before the first
+  measured sample. Garbage produced by measured samples is still collected
+  during measurement, so its cost remains part of the results.
+* `'sample'`: A full garbage collection runs before every measured sample. This
+  reduces variance between samples, but samples that are too short to trigger a
+  collection on their own exclude the cost of collecting their garbage, which
+  can make allocation-heavy code appear faster than it is in practice.
+
+Forced collection runs after `beforeEach` hooks and any event loop turn between
+samples, and is excluded from benchmark timeout accounting. The effective mode
+is reported as `gc` in `'bench:plan'` events. The mode can be overridden for
+every benchmark through `createRunner()`, [`run()`][], or [`--bench-gc`][].
+
+Forcing garbage collection requires the `--expose-gc` flag, which also makes
+`globalThis.gc()` available to benchmark code. A benchmark whose effective mode
+is `'once'` or `'sample'` fails when the flag is not set. When `--bench-gc`
+selects one of these modes, the command-line runner adds `--expose-gc` to
+benchmark child processes automatically.
+
 ### Measurement integrity

 A statistically consistent result does not prove that a benchmark measured the
@@ -221,9 +247,10 @@ arbitrary functions or closures to move them between isolates, because closures
 cannot be reconstructed with their original lexical environment.

 Benchmark files passed to `--bench` should declare benchmarks but must not call
-`run()`. The CLI supports `--bench-name-pattern`, `--bench-samples`,
-`--bench-warmup`, `--bench-reporter`, and `--bench-reporter-destination`. See
-the [command-line options documentation][] for details.
+`run()`. The CLI supports `--bench-gc`, `--bench-name-pattern`,
+`--bench-samples`, `--bench-warmup`, `--bench-reporter`, and
+`--bench-reporter-destination`. See the [command-line options documentation][]
+for details.

 Preload modules passed through `--require` or `--import` should not declare
 benchmarks. Such declarations are not associated with an entry file and have
@@ -321,6 +348,9 @@ added: v26.9.0
 -->

 * `options` {Object}
+  * `gc` {string} Overrides the [garbage collection][] mode for every
+    benchmark declared through this runner. One of `'none'`, `'once'`, or
+    `'sample'`.
   * `yieldBetweenSamples` {boolean} Schedule an event loop turn between sample
     callbacks. Disabling this also prevents timer-based abort signals from
     firing between synchronous callbacks. Benchmark timeouts continue to be
@@ -334,8 +364,8 @@ the module-level functions. Call the returned `run()` function to start the
 runner and obtain its {BenchmarksStream}.

 Each runner can be started once. Its `run()` function accepts the same options
-as the module-level [`run()`][]. `run({ yieldBetweenSamples })` overrides the
-value passed to `createRunner()`. Unlike the module-level runner, a failed
+as the module-level [`run()`][]. `run({ gc })` and `run({ yieldBetweenSamples })`
+override the values passed to `createRunner()`. Unlike the module-level runner, a failed
 benchmark in an explicit runner does not change the process exit code.

 ## `bench([name][, options], fn)`
@@ -350,6 +380,8 @@ added: v26.9.0
   * `diagnosticChannels` {Array} String diagnostics channel names, deduplicated
     and inherited from containing suites by union. Symbol values in the array
     are silently ignored. **Default:** `[]`.
+  * `gc` {string} The [garbage collection][] mode. One of `'none'`, `'once'`,
+    or `'sample'`. **Default:** `'none'`.
   * `only` {boolean} When any benchmark or containing suite has `only` set,
     benchmarks without `only` in their hierarchy are skipped. **Default:**
     `false`.
@@ -511,6 +543,9 @@ added: v26.9.0
 -->

 * `options` {Object}
+  * `gc` {string} Overrides the [garbage collection][] mode for every
+    benchmark. One of `'none'`, `'once'`, or `'sample'`. **Default:** the value
+    passed to `createRunner()` for an explicit runner.
   * `namePattern` {string|RegExp} Only runs benchmarks whose full hierarchical
     name matches the pattern. String values are interpreted as JavaScript
     regular expressions.
@@ -803,6 +838,8 @@ isolation, all files share one runner and their plans are emitted before any
 benchmark executes. Plan data contains the benchmark-scoped identity, location,
 tags, and parameters described in [benchmark result][], together with:

+* `gc` {string} The effective [garbage collection][] mode after run-level
+  overrides.
 * `diagnosticChannels` {string\[]} The inherited string channel names
   subscribed to during each callback.
 * `samples` {number} The effective maximum number of measured callback
@@ -878,6 +915,7 @@ A completed benchmark result contains:
     interval for the median rate, with `lower` and `upper` properties.
   * `skewness` {number} The skewness of the scaled rate histogram.

+[`--bench-gc`]: cli.md#--bench-gcmode
 [`context.record()`]: #contextrecordsample
 [`createRunner()`]: #createrunneroptions
 [`os`]: os.md
@@ -885,3 +923,4 @@ A completed benchmark result contains:
 [benchmark reporters]: #benchmark-reporters
 [benchmark result]: #benchmark-result
 [command-line options documentation]: cli.md#--bench
+[garbage collection]: #garbage-collection
diff --git a/doc/api/cli.md b/doc/api/cli.md
index 5afcc6edaa5..f534aa96e77 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -554,6 +554,25 @@ details.
 This flag cannot be combined with `--test`, `--watch`, `--watch-path`,
 `--check`, `--eval`, or `--interactive`.

+### `--bench-gc=mode`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+> Stability: 1 - Experimental
+
+Overrides the garbage collection mode for every selected benchmark. When `mode`
+is `'once'`, a full garbage collection runs before the first measured sample.
+When `mode` is `'sample'`, a full garbage collection runs before every measured
+sample. When `mode` is `'none'`, no garbage collection is forced. Without this
+option, each benchmark uses its own `gc` value, which defaults to `'none'`.
+
+Forcing garbage collection requires the `--expose-gc` flag. With process
+isolation, `--expose-gc` is added to benchmark child processes automatically
+when `mode` is `'once'` or `'sample'`. With `--bench-isolation=none`,
+`--expose-gc` must be passed explicitly.
+
 ### `--bench-isolation=mode`

 <!-- YAML
@@ -4289,6 +4308,7 @@ one is included in the list below.
 * `--allow-openssl-store`
 * `--allow-wasi`
 * `--allow-worker`
+* `--bench-gc`
 * `--bench-isolation`
 * `--bench-name-pattern`
 * `--bench-reporter-destination`
diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json
index 0d2cb83b517..9b9c0f835d0 100644
--- a/doc/node-config-schema.json
+++ b/doc/node-config-schema.json
@@ -54,6 +54,10 @@
               "type": "boolean",
               "description": "launch benchmark runner on startup"
             },
+            "bench-gc": {
+              "type": "string",
+              "description": "run garbage collection before measured benchmark samples"
+            },
             "bench-isolation": {
               "type": "string",
               "description": "configures the type of benchmark isolation used in the benchmark runner"
@@ -191,6 +195,10 @@
               "type": "boolean",
               "description": "allow worker threads when any permissions are set"
             },
+            "bench-gc": {
+              "type": "string",
+              "description": "run garbage collection before measured benchmark samples"
+            },
             "bench-isolation": {
               "type": "string",
               "description": "configures the type of benchmark isolation used in the benchmark runner"
diff --git a/doc/node.1 b/doc/node.1
index 32fd0290582..af10d324353 100644
--- a/doc/node.1
+++ b/doc/node.1
@@ -353,6 +353,17 @@ details.
 This flag cannot be combined with \fB--test\fR, \fB--watch\fR, \fB--watch-path\fR,
 \fB--check\fR, \fB--eval\fR, or \fB--interactive\fR.
 .
+.It Fl -bench-gc Ns = Ns Ar mode
+Overrides the garbage collection mode for every selected benchmark. When \fBmode\fR
+is \fB'once'\fR, a full garbage collection runs before the first measured sample.
+When \fBmode\fR is \fB'sample'\fR, a full garbage collection runs before every measured
+sample. When \fBmode\fR is \fB'none'\fR, no garbage collection is forced. Without this
+option, each benchmark uses its own \fBgc\fR value, which defaults to \fB'none'\fR.
+Forcing garbage collection requires the \fB--expose-gc\fR flag. With process
+isolation, \fB--expose-gc\fR is added to benchmark child processes automatically
+when \fBmode\fR is \fB'once'\fR or \fB'sample'\fR. With \fB--bench-isolation=none\fR,
+\fB--expose-gc\fR must be passed explicitly.
+.
 .It Fl -bench-isolation Ns = Ns Ar mode
 Configures benchmark file isolation. When \fBmode\fR is \fB'process'\fR, each matching
 file runs in a separate child process. This is the default. Files are still run
@@ -2215,6 +2226,8 @@ one is included in the list below.
 .It
 \fB--allow-worker\fR
 .It
+\fB--bench-gc\fR
+.It
 \fB--bench-isolation\fR
 .It
 \fB--bench-name-pattern\fR
diff --git a/lib/internal/bench_runner/benchmark.js b/lib/internal/bench_runner/benchmark.js
index ea47696489a..f332b523f4b 100644
--- a/lib/internal/bench_runner/benchmark.js
+++ b/lib/internal/bench_runner/benchmark.js
@@ -42,6 +42,7 @@ const {
   validateInteger,
   validateNumber,
   validateObject,
+  validateOneOf,
   validateString,
   validateUint32,
 } = require('internal/validators');
@@ -50,6 +51,7 @@ const { structuredClone } = require('internal/worker/js_transferable');
 const { bigint: hrtime } = process.hrtime;
 const kDefaultSamples = 30;
 const kDefaultWarmup = 10;
+const kGcModes = ObjectFreeze(['none', 'once', 'sample']);
 const kEmptyDiagnosticChannels = ObjectFreeze([]);
 const kEmptyNamePath = ObjectFreeze([]);
 const kEmptyParams = ObjectFreeze({ __proto__: null });
@@ -234,6 +236,7 @@ class Bench extends AsyncResource {
     const validated = validateNodeOptions(
       options, parent.tags, parent.diagnosticChannels);
     const {
+      gc = 'none',
       params,
       samples = kDefaultSamples,
       signal,
@@ -241,6 +244,7 @@ class Bench extends AsyncResource {
       warmup = kDefaultWarmup,
     } = options;

+    validateOneOf(gc, 'options.gc', kGcModes);
     validateUint32(samples, 'options.samples', true);
     validateUint32(warmup, 'options.warmup');
     validateAbortSignal(signal, 'options.signal');
@@ -258,6 +262,7 @@ class Bench extends AsyncResource {
     this.skip = validated.skip;
     this.tags = validated.tags;
     this.params = canonicalizeParams(params);
+    this.gc = gc;
     this.samples = samples;
     this.warmup = warmup;
     this.timeout = timeout;
@@ -573,6 +578,7 @@ module.exports = {
   Bench,
   BenchContext,
   Suite,
+  kGcModes,
   normalizeArgs,
   summarizeSamples,
 };
diff --git a/lib/internal/bench_runner/cli.js b/lib/internal/bench_runner/cli.js
index 5884046e146..4993b44face 100644
--- a/lib/internal/bench_runner/cli.js
+++ b/lib/internal/bench_runner/cli.js
@@ -44,6 +44,9 @@ const { spawn } = require('child_process');
 const { createWriteStream, statSync } = require('fs');
 const { Glob } = require('internal/fs/glob');
 const { getValidatedPath } = require('internal/fs/utils');
+const {
+  kGcModes,
+} = require('internal/bench_runner/benchmark');
 const {
   BenchmarksStream,
 } = require('internal/bench_runner/benchmarks_stream');
@@ -112,6 +115,7 @@ const kFilterArgs = [
   '--experimental-default-config-file',
 ];
 const kFilterArgValues = [
+  '--bench-gc',
   '--bench-isolation',
   '--bench-name-pattern',
   '--bench-reporter',
@@ -265,6 +269,7 @@ function parseCommandLine() {
     destinations,
     fileRunId: isChild && process.env.NODE_BENCH_FILE_RUN_ID ?
       process.env.NODE_BENCH_FILE_RUN_ID : undefined,
+    gc: getOptionValue('--bench-gc') || undefined,
     isChild,
     isolation: getOptionValue('--bench-isolation'),
     namePattern: parseNamePattern(getOptionValue('--bench-name-pattern')),
@@ -438,6 +443,7 @@ function validateRecord(record) {
   }
   if (record.type === 'bench:plan') {
     const {
+      gc,
       samples,
       selected,
       skip,
@@ -458,6 +464,7 @@ function validateRecord(record) {
         ArrayPrototypeSome(ObjectValues(record.data.params), (value) =>
           typeof value !== 'string' && typeof value !== 'boolean' &&
           (typeof value !== 'number' || !NumberIsFinite(value))) ||
+        !ArrayPrototypeIncludes(kGcModes, gc) ||
         !NumberIsSafeInteger(samples) || samples <= 0 ||
         samples > 0xFFFFFFFF ||
         !NumberIsSafeInteger(warmup) || warmup < 0 || warmup > 0xFFFFFFFF ||
@@ -604,6 +611,7 @@ async function loadBenchmarkFiles(files, options, modules, onRecord) {

   const stream = runBenchmarks({
     __proto__: null,
+    gc: options.gc,
     namePattern: options.namePattern,
     samples: options.samples,
     warmup: options.warmup,
@@ -749,6 +757,10 @@ function getChildArgs(path, options) {
     getInheritedChildArgs() : ArrayPrototypeSlice(options.execArgv);
   ArrayPrototypePush(
     args, '--experimental-bench', '--bench', '--bench-isolation=none');
+  if (options.gc !== undefined) {
+    ArrayPrototypePush(args, `--bench-gc=${options.gc}`);
+    if (options.gc !== 'none') ArrayPrototypePush(args, '--expose-gc');
+  }
   if (options.namePatternSource.length > 0) {
     ArrayPrototypePush(
       args, `--bench-name-pattern=${options.namePatternSource}`);
diff --git a/lib/internal/bench_runner/harness.js b/lib/internal/bench_runner/harness.js
index 46d35a0a235..8d649b22a42 100644
--- a/lib/internal/bench_runner/harness.js
+++ b/lib/internal/bench_runner/harness.js
@@ -47,6 +47,7 @@ const {
   validateBoolean,
   validateFunction,
   validateObject,
+  validateOneOf,
   validateUint32,
 } = require('internal/validators');
 const { queueMicrotask } = require('internal/process/task_queues');
@@ -56,6 +57,7 @@ const {
   Bench,
   BenchContext,
   Suite,
+  kGcModes,
   normalizeArgs,
   summarizeSamples,
 } = require('internal/bench_runner/benchmark');
@@ -73,6 +75,23 @@ function createRunId() {
   return `${process.pid}:${String(hrtime())}:${nextRunId++}`;
 }

+let garbageCollector;
+
+// The gc() function installed by --expose-gc is resolved from a new context
+// so that it reflects the V8 flag rather than a user-replaceable global.
+function getGarbageCollector(mode) {
+  if (garbageCollector === undefined) {
+    const { runInNewContext } = require('vm');
+    const gc = runInNewContext('globalThis.gc');
+    if (typeof gc !== 'function') {
+      throw new ERR_INVALID_STATE(
+        `the '${mode}' gc mode requires the --expose-gc flag`);
+    }
+    garbageCollector = () => { gc(); };
+  }
+  return garbageCollector;
+}
+
 function eventLoopTurn() {
   return new Promise((resolve) => setImmediate(resolve));
 }
@@ -119,9 +138,11 @@ class Harness {
     validateObject(options, 'options');
     const {
       autoRun = true,
+      gc,
       yieldBetweenSamples = true,
     } = options;
     validateBoolean(autoRun, 'options.autoRun');
+    if (gc !== undefined) validateOneOf(gc, 'options.gc', kGcModes);
     validateBoolean(
       yieldBetweenSamples, 'options.yieldBetweenSamples');
     this.#autoRun = autoRun;
@@ -131,6 +152,7 @@ class Harness {
     this.entryFile = process.argv?.[1] ?? null;
     this.stream = new BenchmarksStream();
     this.state = 'collecting';
+    this.gc = gc;
     this.namePattern = null;
     this.outerSignal = undefined;
     this.samples = undefined;
@@ -261,16 +283,22 @@ class Harness {
     validateObject(options, 'options');

     const {
+      gc,
       namePattern,
       samples,
       signal,
       warmup,
       yieldBetweenSamples,
     } = options;
+    let nextGc = this.gc;
     let nextNamePattern = this.namePattern;
     let nextSamples = this.samples;
     let nextWarmup = this.warmup;
     let nextYieldBetweenSamples = this.#yieldBetweenSamples;
+    if (gc !== undefined) {
+      validateOneOf(gc, 'options.gc', kGcModes);
+      nextGc = gc;
+    }
     if (namePattern !== undefined) {
       if (typeof namePattern === 'string') {
         nextNamePattern = new RegExp(namePattern);
@@ -296,6 +324,7 @@ class Harness {
       nextYieldBetweenSamples = yieldBetweenSamples;
     }

+    this.gc = nextGc;
     this.namePattern = nextNamePattern;
     this.samples = nextSamples;
     this.outerSignal = signal;
@@ -434,6 +463,7 @@ class Harness {
         column: benchmark.loc.column,
         tags: ArrayPrototypeSlice(benchmark.tags),
         params: benchmark.params,
+        gc: this.gc ?? benchmark.gc,
         samples: this.samples ?? benchmark.samples,
         warmup: this.warmup ?? benchmark.warmup,
         timeout: benchmark.timeout === Infinity ? null : benchmark.timeout,
@@ -922,6 +952,8 @@ class Harness {
     try {
       await this.#runWithStop(benchmark, controller, async (pause) => {
         try {
+          const gcMode = this.gc ?? benchmark.gc;
+          const gc = gcMode === 'none' ? null : getGarbageCollector(gcMode);
           await this.#runBenchHooks(
             benchmark, 'beforeEach', hookContext);
           checkDeadline();
@@ -933,6 +965,13 @@ class Harness {
             }
             const phase = i < warmup ? 'warmup' : 'measurement';
             const index = phase === 'warmup' ? i : i - warmup;
+            if (gc !== null && i >= warmup &&
+                (gcMode === 'sample' || i === warmup)) {
+              await pause(gc);
+              if (controller.signal.aborted) {
+                throw controller.signal.reason;
+              }
+            }
             const outcome = await this.#runSample(
               benchmark, controller.signal, phase, index);
             if (!outcome.failed) {
@@ -1096,6 +1135,7 @@ function createRunner(options = kEmptyObject) {
   const harness = new Harness({
     __proto__: null,
     autoRun: false,
+    gc: options.gc,
     yieldBetweenSamples: options.yieldBetweenSamples === undefined ?
       true : options.yieldBetweenSamples,
   });
diff --git a/src/node_options.cc b/src/node_options.cc
index 8c82c0a9f65..3fac3275752 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -384,6 +384,10 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,

       debug_options_.allow_attaching_debugger = false;
     }
+    if (!bench_gc.empty() && bench_gc != "none" && bench_gc != "once" &&
+        bench_gc != "sample") {
+      errors->push_back("invalid value for --bench-gc");
+    }
     if (has_bench_samples && bench_samples == 0) {
       errors->push_back("--bench-samples must be greater than 0");
     }
@@ -1149,6 +1153,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
             false,
             OptionNamespaces::kBenchRunnerNamespace);
   Implies("--bench", "[has_bench_options]");
+  AddOption("--bench-gc",
+            "run garbage collection before measured benchmark samples",
+            &EnvironmentOptions::bench_gc,
+            kAllowedInEnvvar,
+            OptionNamespaces::kBenchRunnerNamespace);
+  Implies("--bench-gc", "[has_bench_options]");
   AddOption("--bench-isolation",
             "configures the type of benchmark isolation used in the benchmark "
             "runner",
diff --git a/src/node_options.h b/src/node_options.h
index d4d4dfc97a5..4d3585bb8cf 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -147,6 +147,7 @@ class EnvironmentOptions : public Options {
   std::string heap_snapshot_signal;
   std::string redirect_warnings;
   std::string diagnostic_dir;
+  std::string bench_gc;
   std::string bench_isolation = "process";
   std::string bench_name_pattern;
   std::string test_rerun_failures_path;
diff --git a/test/fixtures/bench-runner/gc.cjs b/test/fixtures/bench-runner/gc.cjs
new file mode 100644
index 00000000000..f94688707e5
--- /dev/null
+++ b/test/fixtures/bench-runner/gc.cjs
@@ -0,0 +1,10 @@
+'use strict';
+
+const { bench } = require('node:bench');
+
+const options = { samples: 3, warmup: 2 };
+if (process.env.BENCH_GC_OPTION) options.gc = process.env.BENCH_GC_OPTION;
+
+bench('gc', options, (b) => {
+  b.record({ detail: typeof globalThis.gc, duration_ns: 1n });
+});
diff --git a/test/parallel/test-bench-gc.js b/test/parallel/test-bench-gc.js
new file mode 100644
index 00000000000..5c01cca8659
--- /dev/null
+++ b/test/parallel/test-bench-gc.js
@@ -0,0 +1,126 @@
+// Flags: --expose-gc --experimental-bench --no-warnings
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+const fixtures = require('../common/fixtures');
+const { bench, createRunner } = require('node:bench');
+
+const spawnTimeout = common.platformTimeout(30_000);
+const fixture = fixtures.path('bench-runner/gc.cjs');
+
+function spawnNode(args, env = undefined) {
+  const result = spawnSync(process.execPath, args, {
+    __proto__: null,
+    encoding: 'utf8',
+    env: { ...process.env, ...env },
+    timeout: spawnTimeout,
+  });
+  assert.ifError(result.error);
+  assert.strictEqual(result.signal, null);
+  return result;
+}
+
+function parseRecords(output) {
+  return output.trim().split('\n').map((line) => JSON.parse(line));
+}
+
+function countForcedCollections(output) {
+  return output.split('\n').filter((line) => /^\[\d+:.*testing/.test(line))
+    .length;
+}
+
+// Invalid modes are rejected when declared or configured.
+for (const gc of ['always', true, null]) {
+  assert.throws(() => bench('invalid', { gc }, () => {}),
+                { code: 'ERR_INVALID_ARG_VALUE' });
+  assert.throws(() => createRunner({ gc }),
+                { code: 'ERR_INVALID_ARG_VALUE' });
+  assert.throws(() => createRunner().run({ gc }),
+                { code: 'ERR_INVALID_ARG_VALUE' });
+}
+
+(async () => {
+  // The effective mode is reported in the plan. run() overrides
+  // createRunner(), which overrides the benchmark option.
+  for (const { runner, run: runGc, expected } of [
+    { expected: ['none', 'once', 'sample'] },
+    { runner: 'sample', expected: ['sample', 'sample', 'sample'] },
+    { runner: 'sample', run: 'none', expected: ['none', 'none', 'none'] },
+  ]) {
+    const instance = createRunner({ gc: runner, yieldBetweenSamples: false });
+    for (const gc of [undefined, 'once', 'sample']) {
+      instance.bench(`gc ${gc}`, { gc, samples: 2, warmup: 1 }, (b) => {
+        b.record({ duration_ns: 1n });
+      });
+    }
+    const records = await instance.run({ gc: runGc }).toArray();
+    const plans = records.filter(({ type }) => type === 'bench:plan');
+    assert.deepStrictEqual(plans.map(({ data }) => data.gc), expected);
+    const completions = records.filter(({ type }) => type === 'bench:complete');
+    assert.deepStrictEqual(
+      completions.map(({ data }) => [data.error, data.samples.length]),
+      [[undefined, 2], [undefined, 2], [undefined, 2]]);
+  }
+
+  // A forced collection runs once before the first measured sample for
+  // 'once', and before every measured sample for 'sample'.
+  for (const { args = [], env, expected } of [
+    { expected: 0 },
+    { env: { BENCH_GC_OPTION: 'once' }, expected: 1 },
+    { env: { BENCH_GC_OPTION: 'sample' }, expected: 3 },
+    { args: ['--bench-gc=once'], expected: 1 },
+    { args: ['--bench-gc=none'], env: { BENCH_GC_OPTION: 'sample' },
+      expected: 0 },
+  ]) {
+    const result = spawnNode([
+      '--expose-gc', '--trace-gc', '--no-warnings', '--experimental-bench',
+      '--bench', '--bench-isolation=none', '--bench-reporter=json',
+      '--bench-reporter-destination=stderr', ...args, fixture,
+    ], env);
+    assert.strictEqual(result.status, 0, result.stderr);
+    assert.strictEqual(countForcedCollections(result.stdout), expected);
+  }
+
+  // Without --expose-gc, benchmarks that request collection fail.
+  {
+    const result = spawnNode([
+      '--no-warnings', '--experimental-bench', '--bench',
+      '--bench-isolation=none', '--bench-reporter=json',
+      '--bench-reporter-destination=stdout', fixture,
+    ], { BENCH_GC_OPTION: 'once' });
+    assert.strictEqual(result.status, 1);
+    const complete = parseRecords(result.stdout)
+      .find(({ type }) => type === 'bench:complete');
+    assert.strictEqual(complete.data.error.code, 'ERR_INVALID_STATE');
+    assert.match(complete.data.error.message,
+                 /'once' gc mode requires the --expose-gc flag/);
+  }
+
+  // With process isolation, --bench-gc exposes gc to the child process.
+  {
+    const result = spawnNode([
+      '--no-warnings', '--experimental-bench', '--bench', '--bench-gc=sample',
+      '--bench-reporter=json', '--bench-reporter-destination=stdout', fixture,
+    ]);
+    assert.strictEqual(result.status, 0, result.stderr);
+    const records = parseRecords(result.stdout);
+    const plan = records.find(({ type }) => type === 'bench:plan');
+    assert.strictEqual(plan.data.gc, 'sample');
+    const complete = records.find(({ type }) => type === 'bench:complete');
+    assert.strictEqual(complete.data.error, undefined);
+    assert.deepStrictEqual(
+      complete.data.samples.map(({ detail }) => detail),
+      ['function', 'function', 'function']);
+  }
+
+  // Invalid --bench-gc values are rejected at startup.
+  {
+    const result = spawnNode([
+      '--experimental-bench', '--bench', '--bench-gc=always', fixture,
+    ]);
+    assert.strictEqual(result.status, 9);
+    assert.match(result.stderr, /invalid value for --bench-gc/);
+  }
+})().then(common.mustCall());