Skip to content

Commit f5e52e6

Browse files
committed
perf_hooks: implement SlidingWindowHistogram
The current recordable `Histogram` does not maintain any sense of time. Samples accumulate indefinitely as they are collected. This implements a relatively simple sliding-window mechanism that can be count or time based. The key benefit is that the window remains fixed/bounded while samples are recorded. The sliding window is chunk based. Internally, it maintains a ring buffer of a fixed number of individual histograms. When snapshot() is called, those are materialized into a single combined histogram. As the window slides, older chunks (and all of the samples they hold) are dropped from the window so the window drops however many samples happened to be in that chunk. This does mean that precision of the window is determined by the chunk size. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode
1 parent 0a744b8 commit f5e52e6

11 files changed

Lines changed: 818 additions & 9 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
const common = require('../common.js');
5+
const { createSlidingWindowHistogram } = require('perf_hooks');
6+
7+
const bench = common.createBenchmark(main, {
8+
n: [1e6],
9+
mode: ['count', 'time'],
10+
chunks: [6],
11+
});
12+
13+
function main({ n, mode, chunks }) {
14+
const options = mode === 'count' ?
15+
{ chunks, recordsPerChunk: 1000 } :
16+
{ chunks, chunkDuration: 1 };
17+
const histogram = createSlidingWindowHistogram(options);
18+
19+
bench.start();
20+
for (let i = 0; i < n; i++) histogram.record((i % 1000) + 1);
21+
bench.end(n);
22+
23+
assert.ok(histogram.snapshot().count > 0);
24+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
const common = require('../common.js');
5+
const { createSlidingWindowHistogram } = require('perf_hooks');
6+
7+
const bench = common.createBenchmark(main, {
8+
n: [100],
9+
chunks: [2, 8],
10+
recordsPerChunk: [1000],
11+
});
12+
13+
let snapshot;
14+
15+
function main({ n, chunks, recordsPerChunk }) {
16+
const histogram = createSlidingWindowHistogram({
17+
chunks,
18+
recordsPerChunk,
19+
});
20+
for (let i = 0; i < chunks * recordsPerChunk; i++) {
21+
histogram.record((i % 1000) + 1);
22+
}
23+
24+
bench.start();
25+
for (let i = 0; i < n; i++) snapshot = histogram.snapshot();
26+
bench.end(n);
27+
28+
assert.strictEqual(snapshot.count, chunks * recordsPerChunk);
29+
}

‎doc/api/perf_hooks.md‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,6 +1718,61 @@ added:
17181718

17191719
Returns a {RecordableHistogram}.
17201720

1721+
## `perf_hooks.createSlidingWindowHistogram(options)`
1722+
1723+
<!-- YAML
1724+
added: REPLACEME
1725+
-->
1726+
1727+
* `options` {Object}
1728+
* `chunks` {number} The number of histogram chunks retained. Must be an
1729+
integer between `1` and `1024`.
1730+
* `chunkDuration` {number} The duration of each chunk in milliseconds. Must
1731+
be an integer between `1` and `18_446_744_073_709`. Exactly one of
1732+
`chunkDuration` and `recordsPerChunk` must be specified.
1733+
* `recordsPerChunk` {number} The number of calls to `record()` assigned to
1734+
each chunk. Must be an integer between `1` and `Number.MAX_SAFE_INTEGER`.
1735+
Exactly one of `chunkDuration` and `recordsPerChunk` must be specified.
1736+
* `lowest` {number|bigint} The lowest discernible value. Must be an integer
1737+
value greater than `0`. **Default:** `1`.
1738+
* `highest` {number|bigint} The highest recordable value. Must be an integer
1739+
value that is equal to or greater than two times `lowest`.
1740+
**Default:** `Number.MAX_SAFE_INTEGER`.
1741+
* `figures` {number} The number of accuracy digits. Must be an integer between
1742+
`1` and `5`. **Default:** `3`.
1743+
* Returns: {SlidingWindowHistogram}
1744+
1745+
Creates a {SlidingWindowHistogram} that retains the latest `chunks` histogram
1746+
chunks. Rotation is lazy and does not create a timer. Time-based rotation is
1747+
evaluated when `record()` or `snapshot()` is called. Count-based rotation is
1748+
evaluated when `record()` is called.
1749+
1750+
One histogram chunk is allocated during construction. Additional chunks are
1751+
allocated lazily. The maximum native memory used by the window scales with
1752+
`chunks` and with the `lowest`, `highest`, and `figures` histogram options.
1753+
1754+
The window boundary has chunk-level precision. With `N` chunks of duration
1755+
`D`, a recorded value is retained for between `(N - 1) * D` and `N * D`
1756+
milliseconds. Once a count-based window is populated, it retains between
1757+
`(N - 1) * C + 1` and `N * C` recording attempts, where `C` is
1758+
`recordsPerChunk`. Recording attempts which exceed `highest` are included when
1759+
determining count-based rotation.
1760+
1761+
```js
1762+
const { createSlidingWindowHistogram } = require('node:perf_hooks');
1763+
1764+
const window = createSlidingWindowHistogram({
1765+
chunks: 6,
1766+
chunkDuration: 10_000,
1767+
});
1768+
1769+
window.record(20_000_000);
1770+
1771+
// Materialize the current window as an independent Histogram.
1772+
const snapshot = window.snapshot();
1773+
console.log(snapshot.percentile(99));
1774+
```
1775+
17211776
## `perf_hooks.importHistogram(data)`
17221777

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

2745+
## Class: `SlidingWindowHistogram`
2746+
2747+
<!-- YAML
2748+
added: REPLACEME
2749+
-->
2750+
2751+
Records values into a lazily rotated ring of histogram chunks. Instances are
2752+
created using [`perf_hooks.createSlidingWindowHistogram()`][] and cannot be
2753+
constructed directly. A `SlidingWindowHistogram` does not extend {Histogram};
2754+
call `snapshot()` to materialize the current window as a {Histogram}.
2755+
2756+
`SlidingWindowHistogram` instances cannot be cloned or transferred through a
2757+
{MessagePort}.
2758+
2759+
### `slidingWindowHistogram.record(val)`
2760+
2761+
<!-- YAML
2762+
added: REPLACEME
2763+
-->
2764+
2765+
* `val` {number|bigint} The amount to record.
2766+
2767+
Records `val` in the current chunk. For a count-based window, every call that
2768+
reaches the native histogram counts toward rotation, including values which
2769+
exceed the configured `highest` value.
2770+
2771+
### `slidingWindowHistogram.reset()`
2772+
2773+
<!-- YAML
2774+
added: REPLACEME
2775+
-->
2776+
2777+
Invalidates all chunks in the current window. Allocated chunks are reset
2778+
lazily when reused.
2779+
2780+
### `slidingWindowHistogram.snapshot()`
2781+
2782+
<!-- YAML
2783+
added: REPLACEME
2784+
-->
2785+
2786+
* Returns: {Histogram}
2787+
2788+
Materializes the current window as a new, independent {Histogram}. Values
2789+
recorded or expired after this method returns do not change the returned
2790+
histogram. Materialization allocates one histogram and merges every retained
2791+
chunk.
2792+
26902793
## Histogram analysis examples
26912794

26922795
The `Histogram` class provides statistical analysis methods useful for
@@ -3117,6 +3220,7 @@ dns.promises.resolve('localhost');
31173220
[`'exit'`]: process.md#event-exit
31183221
[`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options
31193222
[`histogram.export()`]: #histogramexport
3223+
[`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions
31203224
[`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2
31213225
[`perf_hooks.importHistogram()`]: #perf_hooksimporthistogramdata
31223226
[`perf_hooks.monitorEventLoopDelay()`]: #perf_hooksmonitoreventloopdelayoptions

‎lib/internal/histogram.js‎

Lines changed: 128 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const {
4+
BigInt,
45
Float64Array,
56
Map,
67
MapPrototypeEntries,
@@ -12,6 +13,7 @@ const {
1213

1314
const {
1415
Histogram: _Histogram,
16+
SlidingWindowHistogram: _SlidingWindowHistogram,
1517
} = internalBinding('performance');
1618

1719
const {
@@ -47,7 +49,11 @@ const {
4749
const kDestroy = Symbol('kDestroy');
4850
const kHandle = Symbol('kHandle');
4951
const kRecordable = Symbol('kRecordable');
52+
const kSlidingWindowHandle = Symbol('kSlidingWindowHandle');
5053
const kQrdeDequantizationModes = ['none', 'hdr', 'all'];
54+
const kMaxSlidingWindowHistogramChunks = 1024;
55+
const kMaxChunkDuration = 18_446_744_073_709;
56+
const kMaxInt64 = 9_223_372_036_854_775_807n;
5157

5258
const {
5359
kClone,
@@ -801,6 +807,48 @@ class RecordableHistogram extends Histogram {
801807
}
802808
}
803809

810+
class SlidingWindowHistogram {
811+
constructor(skipThrowSymbol = undefined) {
812+
if (skipThrowSymbol !== kSkipThrow) {
813+
throw new ERR_ILLEGAL_CONSTRUCTOR();
814+
}
815+
}
816+
817+
/**
818+
* @param {number|bigint} val
819+
* @returns {void}
820+
*/
821+
record(val) {
822+
if (this[kSlidingWindowHandle] === undefined)
823+
throw new ERR_INVALID_THIS('SlidingWindowHistogram');
824+
if (typeof val === 'bigint') {
825+
this[kSlidingWindowHandle].record(val);
826+
return;
827+
}
828+
829+
validateInteger(val, 'val', 1);
830+
this[kSlidingWindowHandle].record(val);
831+
}
832+
833+
/**
834+
* @returns {Histogram}
835+
*/
836+
snapshot() {
837+
if (this[kSlidingWindowHandle] === undefined)
838+
throw new ERR_INVALID_THIS('SlidingWindowHistogram');
839+
return new ClonedHistogram(this[kSlidingWindowHandle].snapshot());
840+
}
841+
842+
/**
843+
* @returns {void}
844+
*/
845+
reset() {
846+
if (this[kSlidingWindowHandle] === undefined)
847+
throw new ERR_INVALID_THIS('SlidingWindowHistogram');
848+
this[kSlidingWindowHandle].reset();
849+
}
850+
}
851+
804852
function ClonedHistogram(handle) {
805853
const histogram = new Histogram(kSkipThrow);
806854
markTransferMode(histogram, true, false);
@@ -827,6 +875,32 @@ function createRecordableHistogram(handle) {
827875
return new ClonedRecordableHistogram(handle);
828876
}
829877

878+
function validateHistogramOptions(lowest, highest, figures) {
879+
if (typeof lowest !== 'bigint') {
880+
validateInteger(lowest, 'options.lowest', 1, NumberMAX_SAFE_INTEGER);
881+
} else if (lowest < 1n || lowest > kMaxInt64) {
882+
throw new ERR_OUT_OF_RANGE(
883+
'options.lowest', `>= 1n && <= ${kMaxInt64}n`, lowest);
884+
}
885+
886+
if (typeof highest !== 'bigint') {
887+
validateInteger(highest, 'options.highest', 1, NumberMAX_SAFE_INTEGER);
888+
} else if (highest < 1n || highest > kMaxInt64) {
889+
throw new ERR_OUT_OF_RANGE(
890+
'options.highest', `>= 1n && <= ${kMaxInt64}n`, highest);
891+
}
892+
893+
const minimumHighest = 2n *
894+
(typeof lowest === 'bigint' ? lowest : BigInt(lowest));
895+
const highestBigInt = typeof highest === 'bigint' ?
896+
highest : BigInt(highest);
897+
if (highestBigInt < minimumHighest) {
898+
throw new ERR_OUT_OF_RANGE(
899+
'options.highest', `>= 2 * options.lowest (${minimumHighest}n)`, highest);
900+
}
901+
validateInteger(figures, 'options.figures', 1, 5);
902+
}
903+
830904
/**
831905
* @param {{
832906
* lowest? : number,
@@ -846,15 +920,7 @@ function createHistogram(options = kEmptyObject) {
846920
halfLife = 0,
847921
threshold = 0,
848922
} = options;
849-
if (typeof lowest !== 'bigint')
850-
validateInteger(lowest, 'options.lowest', 1, NumberMAX_SAFE_INTEGER);
851-
if (typeof highest !== 'bigint') {
852-
validateInteger(highest, 'options.highest',
853-
2 * lowest, NumberMAX_SAFE_INTEGER);
854-
} else if (highest < 2n * lowest) {
855-
throw new ERR_INVALID_ARG_VALUE.RangeError('options.highest', highest);
856-
}
857-
validateInteger(figures, 'options.figures', 1, 5);
923+
validateHistogramOptions(lowest, highest, figures);
858924
validateNumber(halfLife, 'options.halfLife');
859925
if (halfLife < 0)
860926
throw new ERR_OUT_OF_RANGE('options.halfLife', '>= 0', halfLife);
@@ -865,6 +931,57 @@ function createHistogram(options = kEmptyObject) {
865931
new _Histogram(lowest, highest, figures, halfLife, threshold));
866932
}
867933

934+
/**
935+
* @param {{
936+
* chunks: number,
937+
* chunkDuration? : number,
938+
* recordsPerChunk? : number,
939+
* lowest? : number|bigint,
940+
* highest? : number|bigint,
941+
* figures? : number,
942+
* }} options
943+
* @returns {SlidingWindowHistogram}
944+
*/
945+
function createSlidingWindowHistogram(options) {
946+
validateObject(options, 'options');
947+
const {
948+
chunks,
949+
chunkDuration,
950+
recordsPerChunk,
951+
lowest = 1,
952+
highest = NumberMAX_SAFE_INTEGER,
953+
figures = 3,
954+
} = options;
955+
956+
validateInteger(
957+
chunks, 'options.chunks', 1, kMaxSlidingWindowHistogramChunks);
958+
validateHistogramOptions(lowest, highest, figures);
959+
960+
const timeBased = chunkDuration !== undefined;
961+
if (timeBased === (recordsPerChunk !== undefined)) {
962+
throw new ERR_INVALID_ARG_VALUE(
963+
'options', options,
964+
'must specify exactly one of "chunkDuration" or "recordsPerChunk"');
965+
}
966+
967+
let rotateAt;
968+
if (timeBased) {
969+
validateInteger(
970+
chunkDuration, 'options.chunkDuration', 1, kMaxChunkDuration);
971+
rotateAt = BigInt(chunkDuration) * 1_000_000n;
972+
} else {
973+
validateInteger(
974+
recordsPerChunk, 'options.recordsPerChunk', 1, NumberMAX_SAFE_INTEGER);
975+
rotateAt = BigInt(recordsPerChunk);
976+
}
977+
978+
const histogram = new SlidingWindowHistogram(kSkipThrow);
979+
markTransferMode(histogram, false, false);
980+
histogram[kSlidingWindowHandle] = new _SlidingWindowHistogram(
981+
lowest, highest, figures, chunks, timeBased, rotateAt);
982+
return histogram;
983+
}
984+
868985
/**
869986
* Reconstructs a histogram from a CBOR-encoded Uint8Array previously
870987
* produced by `histogram.export()`.
@@ -880,12 +997,14 @@ function importHistogram(data) {
880997
module.exports = {
881998
Histogram,
882999
RecordableHistogram,
1000+
SlidingWindowHistogram,
8831001
ClonedHistogram,
8841002
ClonedRecordableHistogram,
8851003
isHistogram,
8861004
kDestroy,
8871005
kHandle,
8881006
kSkipThrow,
8891007
createHistogram,
1008+
createSlidingWindowHistogram,
8901009
importHistogram,
8911010
};

‎lib/perf_hooks.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const {
2525

2626
const {
2727
createHistogram,
28+
createSlidingWindowHistogram,
2829
importHistogram,
2930
} = require('internal/histogram');
3031

@@ -44,6 +45,7 @@ module.exports = {
4445
eventLoopUtilization,
4546
timerify,
4647
createHistogram,
48+
createSlidingWindowHistogram,
4749
importHistogram,
4850
performance,
4951
};

0 commit comments

Comments
 (0)