Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions benchmark/perf_hooks/histogram-qrde.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';

const common = require('../common.js');
const { createHistogram } = require('perf_hooks');

const bench = common.createBenchmark(main, {
n: [5],
bins: [100, 1000],
samples: [1e6],
unique: [100, 1000, 10000],
dequantize: ['none', 'hdr', 'all'],
}, {
test: {
n: 1,
bins: 10,
samples: 100,
unique: 10,
},
});

async function main({ n, bins, samples, unique, dequantize }) {
const histogram = createHistogram();
const maximum = 1e12;

for (let i = 0; i < samples; i++) {
const index = i % unique;
const rank = unique === 1 ? 0 : index / (unique - 1);
histogram.record(Math.max(1, Math.round(maximum ** rank)));
}

await histogram.qrde({ bins, dequantize });
bench.start();
for (let i = 0; i < n; i++) {
await histogram.qrde({ bins, dequantize });
}
bench.end(n);
}
24 changes: 24 additions & 0 deletions benchmark/perf_hooks/histogram-sliding-window-record.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

const assert = require('assert');
const common = require('../common.js');
const { createSlidingWindowHistogram } = require('perf_hooks');

const bench = common.createBenchmark(main, {
n: [1e6],
mode: ['count', 'time'],
chunks: [6],
});

function main({ n, mode, chunks }) {
const options = mode === 'count' ?
{ chunks, recordsPerChunk: 1000 } :
{ chunks, chunkDuration: 1 };
const histogram = createSlidingWindowHistogram(options);

bench.start();
for (let i = 0; i < n; i++) histogram.record((i % 1000) + 1);
bench.end(n);

assert.ok(histogram.snapshot().count > 0);
}
29 changes: 29 additions & 0 deletions benchmark/perf_hooks/histogram-sliding-window-snapshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';

const assert = require('assert');
const common = require('../common.js');
const { createSlidingWindowHistogram } = require('perf_hooks');

const bench = common.createBenchmark(main, {
n: [100],
chunks: [2, 8],
recordsPerChunk: [1000],
});

let snapshot;

function main({ n, chunks, recordsPerChunk }) {
const histogram = createSlidingWindowHistogram({
chunks,
recordsPerChunk,
});
for (let i = 0; i < chunks * recordsPerChunk; i++) {
histogram.record((i % 1000) + 1);
}

bench.start();
for (let i = 0; i < n; i++) snapshot = histogram.snapshot();
bench.end(n);

assert.strictEqual(snapshot.count, chunks * recordsPerChunk);
}
180 changes: 180 additions & 0 deletions doc/api/perf_hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -1718,6 +1718,61 @@ added:

Returns a {RecordableHistogram}.

## `perf_hooks.createSlidingWindowHistogram(options)`

<!-- YAML
added: REPLACEME
-->

* `options` {Object}
* `chunks` {number} The number of histogram chunks retained. Must be an
integer between `1` and `1024`.
* `chunkDuration` {number} The duration of each chunk in milliseconds. Must
be an integer between `1` and `18_446_744_073_709`. Exactly one of
`chunkDuration` and `recordsPerChunk` must be specified.
* `recordsPerChunk` {number} The number of calls to `record()` assigned to
each chunk. Must be an integer between `1` and `Number.MAX_SAFE_INTEGER`.
Exactly one of `chunkDuration` and `recordsPerChunk` must be specified.
* `lowest` {number|bigint} The lowest discernible value. Must be an integer
value greater than `0`. **Default:** `1`.
* `highest` {number|bigint} The highest recordable value. Must be an integer
value that is equal to or greater than two times `lowest`.
**Default:** `Number.MAX_SAFE_INTEGER`.
* `figures` {number} The number of accuracy digits. Must be an integer between
`1` and `5`. **Default:** `3`.
* Returns: {SlidingWindowHistogram}

Creates a {SlidingWindowHistogram} that retains the latest `chunks` histogram
chunks. Rotation is lazy and does not create a timer. Time-based rotation is
evaluated when `record()` or `snapshot()` is called. Count-based rotation is
evaluated when `record()` is called.

One histogram chunk is allocated during construction. Additional chunks are
allocated lazily. The maximum native memory used by the window scales with
`chunks` and with the `lowest`, `highest`, and `figures` histogram options.

The window boundary has chunk-level precision. With `N` chunks of duration
`D`, a recorded value is retained for between `(N - 1) * D` and `N * D`
milliseconds. Once a count-based window is populated, it retains between
`(N - 1) * C + 1` and `N * C` recording attempts, where `C` is
`recordsPerChunk`. Recording attempts which exceed `highest` are included when
determining count-based rotation.

```js
const { createSlidingWindowHistogram } = require('node:perf_hooks');

const window = createSlidingWindowHistogram({
chunks: 6,
chunkDuration: 10_000,
});

window.record(20_000_000);

// Materialize the current window as an independent Histogram.
const snapshot = window.snapshot();
console.log(snapshot.percentile(99));
```

## `perf_hooks.importHistogram(data)`

<!-- YAML
Expand Down Expand Up @@ -2437,6 +2492,82 @@ Returns the values at the specified percentiles, computed in a single
efficient pass over the histogram data. More efficient than calling
`histogram.percentile()` multiple times.

### `histogram.qrde([options])`

<!-- YAML
added: REPLACEME
-->

* `options` {Object}
* `bins` {number} The number of equal-probability density bins to return.
Must be between 1 and 1000. Cannot be used with `probabilities`.
**Default:** `100`.
* `probabilities` {number\[]} Custom probability boundaries. The array must
contain between 2 and 1001 strictly increasing values, start with `0`, and
end with `1`. Cannot be used with `bins`.
* `dequantize` {string} Controls whether repeated bucket values are spread
deterministically over their equivalent-value ranges. May be `'none'`,
`'hdr'`, or `'all'`. **Default:** `'hdr'`.
* `cache` {boolean} When `true`, retains the expanded histogram snapshot for
reuse by subsequent calls with `cache: true`. The snapshot is invalidated
when the histogram is modified. **Default:** `false`.
* Returns: {Promise} Fulfills with an {Object} containing:
* `probabilities` {Float64Array} The probability boundaries used by the
estimate.
* `quantiles` {Float64Array} The quantiles at the probability boundaries.
* `densities` {Float64Array} The density within each quantile interval.
* `count` {bigint} The number of values in the histogram snapshot.
* `bucketCount` {number} The number of occupied HDR buckets.
* `corrections` {number} The number of non-monotonic floating-point results
that were clamped to the preceding quantile.
* `dequantize` {string} The selected dequantization mode.

Returns a quantile-respectful density estimate based on the Harrell-Davis
quantile estimator. By default, `bins` generates equal probability boundaries.
The `probabilities` option can instead focus the estimate on regions such as
p90, p99, p99.9, and p99.99. The density for interval `i` contains probability
mass `probabilities[i + 1] - probabilities[i]`. The histogram is snapshotted
when the method is called. Snapshot expansion and the estimate are calculated
in the libuv thread pool. Highly concentrated beta weights use a second-order
asymptotic approximation to avoid numerical convergence loss at large sample
counts.

Setting `cache` to `true` avoids repeating snapshot capture and expansion when
several estimates are requested from an unchanged histogram. The retained
snapshot uses memory proportional to the number of occupied HDR buckets and is
released when the histogram is next modified.

QRDE temporarily uses approximately one additional HDR count array plus 32
bytes per occupied bucket. With `cache: true`, the expanded 32-byte-per-bucket
snapshot remains allocated. The following estimates use `lowest: 1` and
`highest: Number.MAX_SAFE_INTEGER` and exclude allocator and JavaScript object
overhead:

| `figures` | Histogram | Maximum expanded snapshot | Peak cache-miss QRDE |
| --------- | --------: | ------------------------: | -------------------: |
| 1 | 6.3 KiB | 25 KiB | 31 KiB |
| 2 | 47 KiB | 188 KiB | 235 KiB |
| 3 | 352 KiB | 1.4 MiB | 1.7 MiB |
| 4 | 5.0 MiB | 20 MiB | 25 MiB |
| 5 | 37 MiB | 148 MiB | 185 MiB |

The maximum snapshot column assumes every representable bucket is occupied.
Lower `highest` values reduce histogram and temporary copy sizes. Concurrent
calls that miss the cache each require their own temporary copy and expanded
snapshot.

HDR histograms aggregate observations into equivalent-value buckets. The
`'hdr'` dequantization mode models repeated values in buckets wider than one
unit as a continuous uniform distribution over the bucket resolution. This
reduces density artifacts introduced by HDR quantization while preserving
repeated unit-resolution values as point masses. The `'all'` mode also
dequantizes repeated unit-resolution values. Use `'none'` to calculate the
grouped Harrell-Davis estimator using bucket midpoints directly.

An empty histogram returns the requested `probabilities` but produces empty
`quantiles` and `densities` arrays. A non-dequantized interval whose quantile
boundaries are equal has an infinite density.

### `histogram.reset()`

<!-- YAML
Expand Down Expand Up @@ -2611,6 +2742,54 @@ Subtracts the values of `other` from this histogram. Both histograms should
have compatible configurations. Bucket counts that would become negative
are clamped to zero.

## Class: `SlidingWindowHistogram`

<!-- YAML
added: REPLACEME
-->

Records values into a lazily rotated ring of histogram chunks. Instances are
created using [`perf_hooks.createSlidingWindowHistogram()`][] and cannot be
constructed directly. A `SlidingWindowHistogram` does not extend {Histogram};
call `snapshot()` to materialize the current window as a {Histogram}.

`SlidingWindowHistogram` instances cannot be cloned or transferred through a
{MessagePort}.

### `slidingWindowHistogram.record(val)`

<!-- YAML
added: REPLACEME
-->

* `val` {number|bigint} The amount to record.

Records `val` in the current chunk. For a count-based window, every call that
reaches the native histogram counts toward rotation, including values which
exceed the configured `highest` value.

### `slidingWindowHistogram.reset()`

<!-- YAML
added: REPLACEME
-->

Invalidates all chunks in the current window. Allocated chunks are reset
lazily when reused.

### `slidingWindowHistogram.snapshot()`

<!-- YAML
added: REPLACEME
-->

* Returns: {Histogram}

Materializes the current window as a new, independent {Histogram}. Values
recorded or expired after this method returns do not change the returned
histogram. Materialization allocates one histogram and merges every retained
chunk.

## Histogram analysis examples

The `Histogram` class provides statistical analysis methods useful for
Expand Down Expand Up @@ -3041,6 +3220,7 @@ dns.promises.resolve('localhost');
[`'exit'`]: process.md#event-exit
[`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options
[`histogram.export()`]: #histogramexport
[`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions
[`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2
[`perf_hooks.importHistogram()`]: #perf_hooksimporthistogramdata
[`perf_hooks.monitorEventLoopDelay()`]: #perf_hooksmonitoreventloopdelayoptions
Expand Down
Loading