Skip to content

Commit b9d7a50

Browse files
jasnelladuh95
authored andcommitted
perf_hooks: add statistical hypothesis testing to histogram
Welch's t-test, Mann-Whitney U test, Cohen's d, and Cliff's delta, and and handful of others These methods enable in-process benchmark comparison and regression detection without external dependencies. No new dependencies. Tests and docs created by the AI agent. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65416 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
1 parent feb0d57 commit b9d7a50

6 files changed

Lines changed: 1647 additions & 10 deletions

File tree

doc/api/perf_hooks.md

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,16 @@ added:
16211621
**Default:** `Number.MAX_SAFE_INTEGER`.
16221622
* `figures` {number} The number of accuracy digits. Must be a number between
16231623
`1` and `5`. **Default:** `3`.
1624+
* `halfLife` {number} The EWMA half-life in number of samples. When set to
1625+
a value greater than 0, the histogram tracks an exponentially weighted
1626+
moving average and standard deviation, accessible via
1627+
`histogram.ewmaMean` and `histogram.ewmaStddev`. After `halfLife`
1628+
recordings, a value's influence has decayed to 50%. **Default:** `0`
1629+
(disabled).
1630+
* `threshold` {number} An SLO threshold value. When set together with
1631+
`halfLife`, the histogram tracks a smoothed error rate for values
1632+
exceeding this threshold, accessible via `histogram.ewmaErrorRate` and
1633+
`histogram.burnRate()`. **Default:** `0` (disabled).
16241634
* Returns: {RecordableHistogram}
16251635

16261636
Returns a {RecordableHistogram}.
@@ -1885,6 +1895,36 @@ value, representing the probability that a recorded value will be less
18851895
than or equal to `value`. This is the inverse operation of
18861896
`histogram.percentile()`.
18871897

1898+
### `histogram.cliffsD(other)`
1899+
1900+
<!-- YAML
1901+
added: REPLACEME
1902+
-->
1903+
1904+
* `other` {Histogram} The histogram to compare against.
1905+
* Returns: {number} A value between -1.0 and 1.0.
1906+
1907+
Computes [Cliff's delta][], a non-parametric effect size measure. Returns
1908+
the probability that a random value from this histogram exceeds a random
1909+
value from `other`, minus the reverse probability. A value of 1 means every
1910+
value in this histogram exceeds every value in `other`; -1 means the
1911+
opposite; 0 means no tendency in either direction.
1912+
1913+
### `histogram.cohensD(other)`
1914+
1915+
<!-- YAML
1916+
added: REPLACEME
1917+
-->
1918+
1919+
* `other` {Histogram} The histogram to compare against.
1920+
* Returns: {number} The effect size.
1921+
1922+
Computes [Cohen's d][] effect size, the standardized difference between the
1923+
means of this histogram and `other`, using the pooled standard deviation.
1924+
Positive values indicate this histogram has a higher mean. By convention,
1925+
|d| < 0.2 is a small effect, 0.5 is medium, and 0.8 or greater is large.
1926+
Both histograms must have at least 2 recorded values; otherwise returns 0.
1927+
18881928
### `histogram.countAt(value)`
18891929

18901930
<!-- YAML
@@ -1921,6 +1961,74 @@ added:
19211961
The number of times the event loop delay exceeded the maximum 1 hour event
19221962
loop delay threshold.
19231963

1964+
### `histogram.ewmaMean`
1965+
1966+
<!-- YAML
1967+
added: REPLACEME
1968+
-->
1969+
1970+
* Type: {number}
1971+
1972+
The exponentially weighted moving average of recorded values. Only active
1973+
when the histogram was created with a `halfLife` option greater than 0.
1974+
Returns `0` when EWMA is disabled or no values have been recorded.
1975+
1976+
### `histogram.ewmaStddev`
1977+
1978+
<!-- YAML
1979+
added: REPLACEME
1980+
-->
1981+
1982+
* Type: {number}
1983+
1984+
The exponentially weighted moving standard deviation. Only active when the
1985+
histogram was created with a `halfLife` option greater than 0. Returns `0`
1986+
when EWMA is disabled or no values have been recorded.
1987+
1988+
### `histogram.ewmaErrorRate`
1989+
1990+
<!-- YAML
1991+
added: REPLACEME
1992+
-->
1993+
1994+
* Type: {number}
1995+
1996+
The EWMA-smoothed probability of a recorded value exceeding the configured
1997+
`threshold`. Only active when the histogram was created with both `halfLife`
1998+
and `threshold` options. Returns `0` when not enabled or no values have been
1999+
recorded.
2000+
2001+
### `histogram.burnRate(sloTarget)`
2002+
2003+
<!-- YAML
2004+
added: REPLACEME
2005+
-->
2006+
2007+
* `sloTarget` {number} The SLO target as a fraction between 0 and 1
2008+
(exclusive). For example, `0.999` for a 99.9% SLO.
2009+
* Returns: {number}
2010+
2011+
Returns the SLO burn rate: `ewmaErrorRate / (1 - sloTarget)`. A burn rate
2012+
of 1 means the error budget will be exactly exhausted over the SLO window.
2013+
A burn rate greater than 1 means it is being consumed faster than allowed.
2014+
Requires the histogram to have been created with both `halfLife` and
2015+
`threshold` options.
2016+
2017+
```js
2018+
const { createHistogram } = require('node:perf_hooks');
2019+
2020+
// Track latency with a 200ms SLO threshold, half-life of 100 samples
2021+
const h = createHistogram({ halfLife: 100, threshold: 200_000_000 });
2022+
2023+
// ... record latency values ...
2024+
2025+
// Check burn rate against a 99.9% SLO
2026+
const rate = h.burnRate(0.999);
2027+
if (rate > 1) {
2028+
console.log(`SLO burn rate: ${rate.toFixed(2)}x — error budget depleting`);
2029+
}
2030+
```
2031+
19242032
### `histogram.ksTest(other)`
19252033

19262034
<!-- YAML
@@ -1974,6 +2082,24 @@ Returns the histogram data rebucketed into logarithmically-spaced
19742082
intervals, where each bucket's width is multiplied by `base`.
19752083
Useful for visualization and export.
19762084

2085+
### `histogram.mannWhitneyTest(other)`
2086+
2087+
<!-- YAML
2088+
added: REPLACEME
2089+
-->
2090+
2091+
* `other` {Histogram} The histogram to compare against.
2092+
* Returns: {Object}
2093+
* `uStatistic` {number} The Mann-Whitney U statistic.
2094+
* `zScore` {number} The z-score (normal approximation).
2095+
* `pValue` {number} Two-tailed p-value.
2096+
2097+
Performs a [Mann-Whitney U test][] comparing whether this histogram tends to
2098+
produce larger or smaller values than `other`. Unlike `welchTest()`, this is a
2099+
non-parametric test that makes no assumptions about the shape of the
2100+
distributions. Uses the normal approximation with tie correction for the
2101+
p-value.
2102+
19772103
### `histogram.max`
19782104

19792105
<!-- YAML
@@ -2052,6 +2178,40 @@ added:
20522178

20532179
Returns the value at the given percentile.
20542180

2181+
### `histogram.percentileCI(percentile[, options])`
2182+
2183+
<!-- YAML
2184+
added: REPLACEME
2185+
-->
2186+
2187+
* `percentile` {number} A percentile value in the range (0, 100].
2188+
* `options` {Object}
2189+
* `confidence` {number} The confidence level for the interval, between
2190+
0 and 1 (exclusive). **Default:** `0.95`.
2191+
* Returns: {Object}
2192+
* `value` {number} The point estimate (same as `histogram.percentile()`).
2193+
* `lower` {number} The lower bound of the confidence interval.
2194+
* `upper` {number} The upper bound of the confidence interval.
2195+
2196+
Returns a confidence interval for the given percentile using the exact
2197+
binomial method. With fewer samples, the interval will be wider, reflecting
2198+
the greater uncertainty in the percentile estimate. Requires at least 2
2199+
recorded values; with fewer than 2, `lower` and `upper` will equal `value`.
2200+
2201+
```js
2202+
const { createHistogram } = require('node:perf_hooks');
2203+
2204+
const h = createHistogram();
2205+
for (let i = 0; i < 1000; i++) {
2206+
h.record(Math.floor(Math.random() * 100));
2207+
}
2208+
2209+
const ci = h.percentileCI(99);
2210+
console.log(ci.value); // The p99 point estimate
2211+
console.log(ci.lower); // The lower bound (95% confidence)
2212+
console.log(ci.upper); // The upper bound (95% confidence)
2213+
```
2214+
20552215
### `histogram.percentiles`
20562216

20572217
<!-- YAML
@@ -2119,6 +2279,31 @@ added: v11.10.0
21192279

21202280
The standard deviation of the recorded event loop delays.
21212281

2282+
### `histogram.welchTest(other[, options])`
2283+
2284+
<!-- YAML
2285+
added: REPLACEME
2286+
-->
2287+
2288+
* `other` {Histogram} The histogram to compare against.
2289+
* `options` {Object}
2290+
* `confidence` {number} Confidence level for the interval, between 0 and 1.
2291+
**Default:** `0.95`.
2292+
* Returns: {Object}
2293+
* `tStatistic` {number} The Welch t-statistic.
2294+
* `degreesOfFreedom` {number} Welch-Satterthwaite degrees of freedom.
2295+
* `pValue` {number} Two-tailed p-value.
2296+
* `confidenceInterval` {Object}
2297+
* `lower` {number} Lower bound of the confidence interval on the
2298+
difference of means.
2299+
* `upper` {number} Upper bound.
2300+
2301+
Performs [Welch's t-test][] comparing the means of this histogram and `other`.
2302+
The p-value indicates the probability of observing a difference at least this
2303+
extreme under the null hypothesis that the two distributions have the same
2304+
mean. Both histograms must have at least 2 recorded values; otherwise the
2305+
result has `pValue` 1 and `tStatistic` 0.
2306+
21222307
## Class: `ELDHistogram extends Histogram`
21232308

21242309
A `Histogram` that records event loop delay, returned by
@@ -2280,6 +2465,32 @@ const violating = latency.ccdf(500_000_000);
22802465
console.log(`${(violating * 100).toFixed(1)}% of requests violating SLO`);
22812466
```
22822467

2468+
### SLO burn rate monitoring
2469+
2470+
```js
2471+
const { createHistogram } = require('node:perf_hooks');
2472+
2473+
// Track latency with EWMA (half-life 100 samples) and a 200ms SLO threshold
2474+
const latency = createHistogram({
2475+
halfLife: 100,
2476+
threshold: 200_000_000, // 200ms in nanoseconds
2477+
});
2478+
2479+
// Record request latencies...
2480+
2481+
// Smoothed error rate: probability of exceeding the threshold
2482+
console.log(`Error rate: ${(latency.ewmaErrorRate * 100).toFixed(2)}%`);
2483+
2484+
// Burn rate against a 99.9% SLO
2485+
// >1 means the error budget is depleting faster than allowed
2486+
const rate = latency.burnRate(0.999);
2487+
console.log(`Burn rate: ${rate.toFixed(2)}x`);
2488+
2489+
// EWMA mean and stddev track the smoothed latency
2490+
console.log(`EWMA latency: ${latency.ewmaMean.toFixed(0)}ns`);
2491+
console.log(`EWMA stddev: ${latency.ewmaStddev.toFixed(0)}ns`);
2492+
```
2493+
22832494
### Regression detection with KS test
22842495

22852496
```js
@@ -2331,6 +2542,46 @@ newSnapshot.subtract(snapshot);
23312542
console.log('Recent p99:', newSnapshot.percentile(99));
23322543
```
23332544

2545+
### Benchmark comparison with Welch's t-test
2546+
2547+
```js
2548+
const { createHistogram } = require('node:perf_hooks');
2549+
2550+
const baseline = createHistogram();
2551+
const candidate = createHistogram();
2552+
2553+
// Record operation rates from the old and new builds...
2554+
2555+
const result = baseline.welchTest(candidate);
2556+
const improvement = ((candidate.mean - baseline.mean) / baseline.mean * 100);
2557+
2558+
console.log(`Improvement: ${improvement.toFixed(2)}%`);
2559+
console.log(`p-value: ${result.pValue.toFixed(6)}`);
2560+
console.log(`95% CI: [${result.confidenceInterval.lower.toFixed(2)}, ` +
2561+
`${result.confidenceInterval.upper.toFixed(2)}]`);
2562+
2563+
if (result.pValue < 0.05) {
2564+
const d = baseline.cohensD(candidate);
2565+
console.log(`Statistically significant (Cohen's d = ${d.toFixed(4)})`);
2566+
}
2567+
```
2568+
2569+
### Effect size with Cliff's delta
2570+
2571+
```js
2572+
const { createHistogram } = require('node:perf_hooks');
2573+
2574+
const before = createHistogram();
2575+
const after = createHistogram();
2576+
2577+
// Record latencies before and after a change...
2578+
2579+
const delta = before.cliffsD(after);
2580+
// A delta > 0: before tends to produce larger values (improvement)
2581+
// A delta < 0: after tends to produce larger values (regression)
2582+
console.log(`Cliff's delta: ${delta.toFixed(4)}`);
2583+
```
2584+
23342585
## Examples
23352586

23362587
### Measuring the duration of async operations
@@ -2585,13 +2836,17 @@ dns.promises.resolve('localhost');
25852836
```
25862837

25872838
[Async Hooks]: async_hooks.md
2839+
[Cliff's delta]: https://en.wikipedia.org/wiki/Effect_size#Cliff's_delta
2840+
[Cohen's d]: https://en.wikipedia.org/wiki/Effect_size#Cohen's_d
25882841
[Fetch Response Body Info]: https://fetch.spec.whatwg.org/#response-body-info
25892842
[Fetch Timing Info]: https://fetch.spec.whatwg.org/#fetch-timing-info
25902843
[High Resolution Time]: https://www.w3.org/TR/hr-time-2
2844+
[Mann-Whitney U test]: https://en.wikipedia.org/wiki/Mann%E2%80%93Whitney_U_test
25912845
[Performance Timeline]: https://w3c.github.io/performance-timeline/
25922846
[Resource Timing]: https://www.w3.org/TR/resource-timing-2/
25932847
[User Timing]: https://www.w3.org/TR/user-timing/
25942848
[Web Performance APIs]: https://w3c.github.io/perf-timing-primer/
2849+
[Welch's t-test]: https://en.wikipedia.org/wiki/Welch%27s_t-test
25952850
[Worker threads]: worker_threads.md#worker-threads
25962851
[`'exit'`]: process.md#event-exit
25972852
[`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options

0 commit comments

Comments
 (0)