Skip to content

Commit 26cb664

Browse files
committed
perf_hooks: implement qrde analysis support in Histogram
Implements quantile-respectful density estimate calcuation on Histogram. Helps with tail-focused latency analysis without retaining raw samples. Baking this directly into Node.js, based on a 1 million sample, 1k bin workload, this impl is roughly 150-325x faster than performing the equivalent in Rscript. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode
1 parent 57860ef commit 26cb664

9 files changed

Lines changed: 1271 additions & 36 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
3+
const common = require('../common.js');
4+
const { createHistogram } = require('perf_hooks');
5+
6+
const bench = common.createBenchmark(main, {
7+
n: [5],
8+
bins: [100, 1000],
9+
samples: [1e6],
10+
unique: [100, 1000, 10000],
11+
dequantize: ['none', 'hdr', 'all'],
12+
}, {
13+
test: {
14+
n: 1,
15+
bins: 10,
16+
samples: 100,
17+
unique: 10,
18+
},
19+
});
20+
21+
async function main({ n, bins, samples, unique, dequantize }) {
22+
const histogram = createHistogram();
23+
const maximum = 1e12;
24+
25+
for (let i = 0; i < samples; i++) {
26+
const index = i % unique;
27+
const rank = unique === 1 ? 0 : index / (unique - 1);
28+
histogram.record(Math.max(1, Math.round(maximum ** rank)));
29+
}
30+
31+
await histogram.qrde({ bins, dequantize });
32+
bench.start();
33+
for (let i = 0; i < n; i++) {
34+
await histogram.qrde({ bins, dequantize });
35+
}
36+
bench.end(n);
37+
}

‎doc/api/perf_hooks.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2437,6 +2437,54 @@ Returns the values at the specified percentiles, computed in a single
24372437
efficient pass over the histogram data. More efficient than calling
24382438
`histogram.percentile()` multiple times.
24392439

2440+
### `histogram.qrde([options])`
2441+
2442+
<!-- YAML
2443+
added: REPLACEME
2444+
-->
2445+
2446+
* `options` {Object}
2447+
* `bins` {number} The number of equal-probability density bins to return.
2448+
Must be between 1 and 1000. Cannot be used with `probabilities`.
2449+
**Default:** `100`.
2450+
* `probabilities` {number\[]} Custom probability boundaries. The array must
2451+
contain between 2 and 1001 strictly increasing values, start with `0`, and
2452+
end with `1`. Cannot be used with `bins`.
2453+
* `dequantize` {string} Controls whether repeated bucket values are spread
2454+
deterministically over their equivalent-value ranges. May be `'none'`,
2455+
`'hdr'`, or `'all'`. **Default:** `'hdr'`.
2456+
* Returns: {Promise} Fulfills with an {Object} containing:
2457+
* `probabilities` {Float64Array} The probability boundaries used by the
2458+
estimate.
2459+
* `quantiles` {Float64Array} The quantiles at the probability boundaries.
2460+
* `densities` {Float64Array} The density within each quantile interval.
2461+
* `count` {bigint} The number of values in the histogram snapshot.
2462+
* `bucketCount` {number} The number of occupied HDR buckets.
2463+
* `corrections` {number} The number of non-monotonic floating-point results
2464+
that were clamped to the preceding quantile.
2465+
* `dequantize` {string} The selected dequantization mode.
2466+
2467+
Returns a quantile-respectful density estimate based on the Harrell-Davis
2468+
quantile estimator. By default, `bins` generates equal probability boundaries.
2469+
The `probabilities` option can instead focus the estimate on regions such as
2470+
p90, p99, p99.9, and p99.99. The density for interval `i` contains probability
2471+
mass `probabilities[i + 1] - probabilities[i]`. The histogram is snapshotted
2472+
when the method is called and the estimate is calculated in the libuv thread
2473+
pool. Highly concentrated beta weights use a second-order asymptotic
2474+
approximation to avoid numerical convergence loss at large sample counts.
2475+
2476+
HDR histograms aggregate observations into equivalent-value buckets. The
2477+
`'hdr'` dequantization mode models repeated values in buckets wider than one
2478+
unit as a continuous uniform distribution over the bucket resolution. This
2479+
reduces density artifacts introduced by HDR quantization while preserving
2480+
repeated unit-resolution values as point masses. The `'all'` mode also
2481+
dequantizes repeated unit-resolution values. Use `'none'` to calculate the
2482+
grouped Harrell-Davis estimator using bucket midpoints directly.
2483+
2484+
An empty histogram returns the requested `probabilities` but produces empty
2485+
`quantiles` and `densities` arrays. A non-dequantized interval whose quantile
2486+
boundaries are equal has an infinite density.
2487+
24402488
### `histogram.reset()`
24412489

24422490
<!-- YAML

‎lib/internal/histogram.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const {
3636
validateInteger,
3737
validateNumber,
3838
validateObject,
39+
validateOneOf,
3940
} = require('internal/validators');
4041

4142
const {
@@ -45,6 +46,7 @@ const {
4546
const kDestroy = Symbol('kDestroy');
4647
const kHandle = Symbol('kHandle');
4748
const kRecordable = Symbol('kRecordable');
49+
const kQrdeDequantizationModes = ['none', 'hdr', 'all'];
4850

4951
const {
5052
kClone,
@@ -594,6 +596,72 @@ class Histogram {
594596
return map;
595597
}
596598

599+
/**
600+
* Builds a quantile-respectful density estimate using Harrell-Davis
601+
* quantiles. Values can be spread deterministically within their HDR
602+
* equivalent-value ranges to reduce quantization artifacts.
603+
* @param {{ bins?: number, probabilities?: number[],
604+
* dequantize?: 'none'|'hdr'|'all' }} [options]
605+
* @returns {Promise<{
606+
* probabilities: Float64Array,
607+
* quantiles: Float64Array,
608+
* densities: Float64Array,
609+
* count: bigint,
610+
* bucketCount: number,
611+
* corrections: number,
612+
* dequantize: 'none'|'hdr'|'all',
613+
* }>}
614+
*/
615+
qrde(options = kEmptyObject) {
616+
if (!isHistogram(this))
617+
throw new ERR_INVALID_THIS('Histogram');
618+
validateObject(options, 'options');
619+
const { bins, probabilities, dequantize = 'hdr' } = options;
620+
if (bins !== undefined && probabilities !== undefined) {
621+
throw new ERR_INVALID_ARG_VALUE(
622+
'options', options, '"bins" and "probabilities" are mutually exclusive');
623+
}
624+
625+
let boundaries;
626+
if (probabilities === undefined) {
627+
const binCount = bins ?? 100;
628+
validateInteger(binCount, 'options.bins', 1, 1000);
629+
boundaries = new Float64Array(binCount + 1);
630+
for (let i = 0; i <= binCount; i++) boundaries[i] = i / binCount;
631+
} else {
632+
validateArray(probabilities, 'options.probabilities', 2);
633+
const length = probabilities.length;
634+
if (length > 1001) {
635+
throw new ERR_OUT_OF_RANGE(
636+
'options.probabilities.length', '>= 2 && <= 1001', length);
637+
}
638+
639+
boundaries = new Float64Array(length);
640+
let previous = -1;
641+
for (let i = 0; i < length; i++) {
642+
const probability = probabilities[i];
643+
validateNumber(probability, `options.probabilities[${i}]`, 0, 1);
644+
if (probability <= previous) {
645+
throw new ERR_INVALID_ARG_VALUE(
646+
'options.probabilities', probabilities, 'must be strictly increasing');
647+
}
648+
boundaries[i] = probability;
649+
previous = probability;
650+
}
651+
if (boundaries[0] !== 0 || boundaries[length - 1] !== 1) {
652+
throw new ERR_INVALID_ARG_VALUE(
653+
'options.probabilities', probabilities, 'must start with 0 and end with 1');
654+
}
655+
}
656+
657+
validateOneOf(dequantize,
658+
'options.dequantize', kQrdeDequantizationModes);
659+
let mode = 0;
660+
if (dequantize === 'hdr') mode = 1;
661+
else if (dequantize === 'all') mode = 2;
662+
return this[kHandle]?.qrde(boundaries, mode);
663+
}
664+
597665
/**
598666
* @returns {void}
599667
*/

0 commit comments

Comments
 (0)