Commit c905bedc901 for nodejs
commit c905bedc9019166231dd371964ffb9ff0c78261a
Author: James M Snell <jasnell@gmail.com>
Date: Sun Oct 11 17:59:17 2026 +0000
lib: avoid dead code elimination with 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 8118a928d0f..31f4c2e22ad 100644
--- a/doc/api/bench.md
+++ b/doc/api/bench.md
@@ -124,8 +124,9 @@ specialize it more narrowly than the workload being modeled. Framework and loop
overhead can also dominate operations that are too short. To reduce these risks:
* Make values produced by measured work observable outside the measured
- interval, for example by validating an aggregate derived from every result.
- Passing them only through unused local computations is insufficient.
+ interval, for example by validating an aggregate derived from every result
+ or by passing each result to [`doNotOptimize()`][]. Passing them only through
+ unused local computations is insufficient.
* Perform enough operations in each sample to amortize fixed timer reads and
calls to `context.start()` and `context.end()`. If loop bookkeeping is material
relative to one operation, batch multiple operations per iteration and report
@@ -138,7 +139,10 @@ overhead can also dominate operations that are too short. To reduce these risks:
`node:bench` does not force a particular optimization state or infer whether an
engine eliminated work. Such controls and diagnostics are runtime-specific and
-heuristic, and do not replace validating the benchmark workload.
+heuristic, and do not replace validating the benchmark workload. The `spec`
+reporter flags rates that are implausibly fast for real work, which often
+indicates eliminated work, but the absence of that warning does not prove the
+work was measured.
### Dynamic sampling and variable batches
@@ -311,8 +315,11 @@ are started from the same executable, but their `execArgv` can differ.
The `spec` reporter buffers results and outputs a summary of the environment
followed by a concise table containing the sample count, mean rate, 95%
confidence interval for the mean, median rate, and warnings. A coefficient of
-variation above 5% is reported as `noisy`, and an absolute skewness above 1 is
-reported as `skewed`. The exact human-readable format is subject to change.
+variation above 5% is reported as `noisy`, an absolute skewness above 1 is
+reported as `skewed`, and a median rate of at least one operation per
+nanosecond is reported as `implausible`, as it usually indicates that the
+measured work was optimized away. The exact human-readable format is subject to
+change.
The `json` reporter emits a `'bench:environment'` record containing the
environment snapshot before the first lifecycle record, and then emits every
@@ -490,6 +497,61 @@ added: v26.9.0
Alias for `suite()`.
+## `doNotOptimize(value)`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `value` {any} A value produced by measured work.
+* Returns: {any} `value`.
+
+Stores `value` where the optimizing compiler cannot prove it is unused, so the
+computation that produced it is not eliminated as dead code. Returning `value`
+allows the call to wrap an expression.
+
+```mjs
+import { bench, doNotOptimize } from 'node:bench';
+
+bench('Math.sqrt', (b) => {
+ const n = 1_000_000;
+ b.start();
+ for (let i = 0; i < n; i++) {
+ doNotOptimize(Math.sqrt(i));
+ }
+ b.end(n);
+});
+```
+
+```cjs
+const { bench, doNotOptimize } = require('node:bench');
+
+bench('Math.sqrt', (b) => {
+ const n = 1_000_000;
+ b.start();
+ for (let i = 0; i < n; i++) {
+ doNotOptimize(Math.sqrt(i));
+ }
+ b.end(n);
+});
+```
+
+The call adds a small, constant cost to every invocation, so it is best suited
+to work that takes longer than a few nanoseconds per call. For cheaper
+operations, accumulate results into a value that is passed to
+`doNotOptimize()` once after the measured region.
+
+`doNotOptimize()` only prevents the result from being discarded. It does not
+prevent the compiler from hoisting loop-invariant work or folding constant
+inputs; vary inputs between iterations when the work would otherwise be
+invariant.
+
+`doNotOptimize()` is intended to be called only from benchmark functions while
+a runner is executing them. Non-number values are retained until the current
+sample finishes, after which the runner releases them. When called outside a
+benchmark function, the most recent non-number value remains reachable until
+`doNotOptimize()` is called again.
+
## `before(fn)`
<!-- YAML
@@ -918,6 +980,7 @@ A completed benchmark result contains:
[`--bench-gc`]: cli.md#--bench-gcmode
[`context.record()`]: #contextrecordsample
[`createRunner()`]: #createrunneroptions
+[`doNotOptimize()`]: #donotoptimizevalue
[`os`]: os.md
[`run()`]: #runoptions
[benchmark reporters]: #benchmark-reporters
diff --git a/lib/bench.js b/lib/bench.js
index 96e199773d9..9cee01524a6 100644
--- a/lib/bench.js
+++ b/lib/bench.js
@@ -18,6 +18,7 @@ const {
run,
runFile,
} = require('internal/bench_runner/runner');
+const { doNotOptimize } = require('internal/bench_runner/benchmark');
if (process.env.NODE_BENCH_CONTEXT !== 'child' ||
typeof process.send !== 'function') {
@@ -33,6 +34,7 @@ ObjectAssign(module.exports, {
bench,
createRunner,
describe: suite,
+ doNotOptimize,
run,
runFile,
suite,
diff --git a/lib/internal/bench_runner/benchmark.js b/lib/internal/bench_runner/benchmark.js
index f332b523f4b..114feb38817 100644
--- a/lib/internal/bench_runner/benchmark.js
+++ b/lib/internal/bench_runner/benchmark.js
@@ -7,6 +7,7 @@ const {
ArrayPrototypeReverse,
ArrayPrototypeSlice,
ArrayPrototypeSort,
+ Float64Array,
JSONStringify,
MathFloor,
MathMax,
@@ -57,6 +58,25 @@ const kEmptyNamePath = ObjectFreeze([]);
const kEmptyParams = ObjectFreeze({ __proto__: null });
const kEmptyTags = ObjectFreeze([]);
+// doNotOptimize() stores values in reachable storage so that the optimizing
+// compiler cannot prove the computation that produced them is unused. Numbers
+// use a typed array so that storing a double does not allocate a HeapNumber.
+const numberSink = new Float64Array(1);
+const valueSink = [undefined];
+
+function doNotOptimize(value) {
+ if (typeof value === 'number') {
+ numberSink[0] = value;
+ } else {
+ valueSink[0] = value;
+ }
+ return value;
+}
+
+function clearSink() {
+ valueSink[0] = undefined;
+}
+
function validateSkip(skip) {
if (skip !== undefined && typeof skip !== 'boolean' &&
typeof skip !== 'string') {
@@ -578,6 +598,8 @@ module.exports = {
Bench,
BenchContext,
Suite,
+ clearSink,
+ doNotOptimize,
kGcModes,
normalizeArgs,
summarizeSamples,
diff --git a/lib/internal/bench_runner/harness.js b/lib/internal/bench_runner/harness.js
index 8d649b22a42..e9afe90ce50 100644
--- a/lib/internal/bench_runner/harness.js
+++ b/lib/internal/bench_runner/harness.js
@@ -57,6 +57,7 @@ const {
Bench,
BenchContext,
Suite,
+ clearSink,
kGcModes,
normalizeArgs,
summarizeSamples,
@@ -974,6 +975,7 @@ class Harness {
}
const outcome = await this.#runSample(
benchmark, controller.signal, phase, index);
+ clearSink();
if (!outcome.failed) {
checkDeadline();
if (controller.signal.aborted) {
diff --git a/lib/internal/bench_runner/reporter/spec.js b/lib/internal/bench_runner/reporter/spec.js
index 6663360f592..cf3ba088548 100644
--- a/lib/internal/bench_runner/reporter/spec.js
+++ b/lib/internal/bench_runner/reporter/spec.js
@@ -17,6 +17,10 @@ const {
collectEnvironment,
} = require('internal/bench_runner/environment');
+// One operation per nanosecond is faster than almost any real operation can
+// run, which usually means the optimizing compiler removed the measured work.
+const kImplausibleRate = 1e9;
+
const kHeader =
'benchmark | samples | mean rate | 95% CI | median rate | warning\n';
@@ -118,6 +122,10 @@ function formatWarning(summary) {
if (NumberIsFinite(summary.skewness) && MathAbs(summary.skewness) > 1) {
ArrayPrototypePush(warnings, 'skewed');
}
+ if (NumberIsFinite(summary.median) &&
+ summary.median >= kImplausibleRate) {
+ ArrayPrototypePush(warnings, 'implausible');
+ }
return ArrayPrototypeJoin(warnings, ', ');
}
diff --git a/test/parallel/test-bench-do-not-optimize.js b/test/parallel/test-bench-do-not-optimize.js
new file mode 100644
index 00000000000..015fa123aa0
--- /dev/null
+++ b/test/parallel/test-bench-do-not-optimize.js
@@ -0,0 +1,60 @@
+// Flags: --expose-gc --experimental-bench --no-warnings
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { Readable } = require('stream');
+const { createRunner, doNotOptimize } = require('node:bench');
+const { spec } = require('node:bench/reporters');
+
+// The value is returned unchanged.
+for (const value of [
+ 0, -0, 1.5, NaN, Infinity, 1n, 'string', true, null, undefined,
+ Symbol('symbol'), {}, [], () => {},
+]) {
+ assert.ok(Object.is(doNotOptimize(value), value));
+}
+
+function result(name, median) {
+ return {
+ type: 'bench:complete',
+ data: {
+ name,
+ params: {},
+ samples: [{}],
+ summary: {
+ mean: median,
+ median,
+ coefficientOfVariation: 0,
+ confidenceInterval: { lower: median, upper: median },
+ skewness: 0,
+ },
+ },
+ };
+}
+
+(async () => {
+ // Values are released after the sample that produced them.
+ const runner = createRunner({ yieldBetweenSamples: false });
+ let ref;
+ runner.bench('retained', { samples: 1, warmup: 0 }, common.mustCall((b) => {
+ const value = {};
+ ref = new WeakRef(value);
+ b.start();
+ doNotOptimize(value);
+ b.end();
+ }));
+ await runner.run().toArray();
+ await new Promise(setImmediate);
+ globalThis.gc();
+ assert.strictEqual(ref.deref(), undefined);
+
+ // The spec reporter flags median rates of at least one operation per
+ // nanosecond as implausible.
+ const output = (await Readable.from([
+ result('plausible', 999_999_999),
+ result('implausible', 1e9),
+ ]).compose(spec).toArray()).join('');
+ assert.match(output, /^plausible \|.* \| $/m);
+ assert.match(output, /^implausible \|.* \| implausible$/m);
+})().then(common.mustCall());