From eb8bd6ae640068082daebcaa2c6407f66c268766 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 18 Sep 2026 02:11:33 +0000 Subject: [PATCH 1/3] perf_hooks: add histogram.snapshot() Cloning a histogram with `structuredClone()` or `postMessage()` shares the native histogram instead of copying it. Capturing the state of a histogram at a point in time, while other code keeps recording into it, requires serializing it with `export()` and parsing the result with `importHistogram()`. Add `histogram.snapshot()`, which returns a new, independent `Histogram` containing a copy of the histogram's configuration, recorded values, `exceeds` count, and EWMA state, without the serialization round trip. Values cannot be recorded into the returned histogram. The method is available on all histograms, including `RecordableHistogram` and `ELDHistogram` instances. Signed-off-by: James M Snell Assisted-by: OpenCode --- benchmark/perf_hooks/histogram-snapshot.js | 24 +++ doc/api/perf_hooks.md | 32 ++++ lib/internal/histogram.js | 12 ++ src/histogram.cc | 69 +++++++-- src/histogram.h | 5 + .../test-perf-hooks-histogram-snapshot.js | 138 ++++++++++++++++++ typings/internalBinding/performance.d.ts | 1 + 7 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 benchmark/perf_hooks/histogram-snapshot.js create mode 100644 test/parallel/test-perf-hooks-histogram-snapshot.js diff --git a/benchmark/perf_hooks/histogram-snapshot.js b/benchmark/perf_hooks/histogram-snapshot.js new file mode 100644 index 000000000000..79b13fecb0f6 --- /dev/null +++ b/benchmark/perf_hooks/histogram-snapshot.js @@ -0,0 +1,24 @@ +'use strict'; + +const assert = require('assert'); +const common = require('../common.js'); +const { createHistogram } = require('perf_hooks'); + +const bench = common.createBenchmark(main, { + n: [1e3], + highest: [1e6, Number.MAX_SAFE_INTEGER], + figures: [2, 3], +}); + +let snapshot; + +function main({ n, highest, figures }) { + const histogram = createHistogram({ highest, figures }); + for (let i = 1; i <= 1e4; i++) histogram.record(i); + + bench.start(); + for (let i = 0; i < n; i++) snapshot = histogram.snapshot(); + bench.end(n); + + assert.strictEqual(snapshot.count, 1e4); +} diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md index 69a4d6613b9b..239569f8b272 100644 --- a/doc/api/perf_hooks.md +++ b/doc/api/perf_hooks.md @@ -2621,6 +2621,38 @@ distribution. A positive value indicates a right-skewed distribution (longer right tail, common for latency data); a negative value indicates a left-skewed distribution. +### `histogram.snapshot()` + + + +* Returns: {Histogram} + +Returns a new, independent {Histogram} containing a copy of this histogram's +current state: its configuration, recorded values, `exceeds` count, and EWMA +state. Values recorded into this histogram after this method returns, and later +calls to `reset()`, do not change the returned histogram. This provides a stable +view of a histogram that is still recording, such as an enabled {ELDHistogram}. + +Values cannot be recorded into the returned histogram. Taking a snapshot copies +every bucket, so both its time and memory cost depend on the histogram's +`lowest`, `highest`, and `figures` configuration rather than on the number of +recorded values. + +```js +const { monitorEventLoopDelay } = require('node:perf_hooks'); + +const histogram = monitorEventLoopDelay(); +histogram.enable(); + +setTimeout(() => { + const snapshot = histogram.snapshot(); + console.log(snapshot.percentile(99)); + histogram.disable(); +}, 1000); +``` + ### `histogram.stddev` + +* `other` {Histogram} An earlier snapshot of this histogram. +* Returns: {Histogram} + +Returns a new {Histogram} containing the values recorded in this histogram after +`other` was taken. Neither histogram is changed. To get the values recorded +during each interval without calling `reset()`, compute each difference from a +snapshot and keep that snapshot as the baseline for the next interval: + +```js +const { monitorEventLoopDelay } = require('node:perf_hooks'); + +const histogram = monitorEventLoopDelay(); +histogram.enable(); +let previous = histogram.snapshot(); + +setInterval(() => { + const current = histogram.snapshot(); + // After a reset, use everything recorded since the reset. + const delta = current.resetCount === previous.resetCount ? + current.diff(previous) : current; + console.log(delta.percentile(99)); + previous = current; +}, 10_000); +``` + +The `count`, `exceeds`, and bucket counts of the returned histogram are the +differences between the two histograms. Its `min` and `max` are computed from +the buckets of the difference, it has no EWMA state, and its `resetCount` is +`0`. + +This method throws: + +* `ERR_INVALID_ARG_VALUE` if `other` has a different `lowest`, `highest`, or + `figures` configuration. +* `ERR_INVALID_STATE` if values have been removed from this histogram since + `other` was taken, which is the case when the `resetCount` of the two + histograms differs. +* `ERR_INVALID_ARG_VALUE` if `other` contains values that are not in this + histogram, for example because the histograms were passed in the wrong order. + ### `histogram.exceeds` -Resets the collected histogram data. +Resets the collected histogram data and increments `histogram.resetCount`. + +### `histogram.resetCount` + + + +* Type: {number} + +The number of times values have been removed from this histogram by `reset()` +or, for a {RecordableHistogram}, `subtract()`. A snapshot has the `resetCount` +of its source at the time it was taken, so comparing the `resetCount` of two +snapshots shows whether the source was reset between them. See +[`histogram.diff()`][]. ### `histogram.skewness` @@ -2810,7 +2870,7 @@ added: Subtracts the values of `other` from this histogram. Both histograms should have compatible configurations. Bucket counts that would become negative -are clamped to zero. +are clamped to zero. Increments `histogram.resetCount`. ## Class: `SlidingWindowHistogram` @@ -3289,6 +3349,7 @@ dns.promises.resolve('localhost'); [Worker threads]: worker_threads.md#worker-threads [`'exit'`]: process.md#event-exit [`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options +[`histogram.diff()`]: #histogramdiffother [`histogram.export()`]: #histogramexport [`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions [`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2 diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js index 8983289e0753..12c888c54217 100644 --- a/lib/internal/histogram.js +++ b/lib/internal/histogram.js @@ -679,6 +679,18 @@ class Histogram { this[kHandle]?.reset(); } + /** + * The number of times values have been removed from the histogram by + * `reset()` or `subtract()`. + * @readonly + * @type {number} + */ + get resetCount() { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + return this[kHandle]?.resetCount(); + } + /** * Returns a new, independent histogram containing a copy of this * histogram's current state. Values cannot be recorded into the returned @@ -691,6 +703,20 @@ class Histogram { return new ClonedHistogram(this[kHandle].snapshot()); } + /** + * Returns a new histogram containing the values recorded in this histogram + * after `other`, an earlier snapshot of it, was taken. + * @param {Histogram} other + * @returns {Histogram} + */ + diff(other) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + if (!isHistogram(other)) + throw new ERR_INVALID_ARG_TYPE('other', 'Histogram', other); + return new ClonedHistogram(this[kHandle].diff(other[kHandle])); + } + [kClone]() { const handle = this[kHandle]; return { diff --git a/src/histogram-inl.h b/src/histogram-inl.h index e2704b499f31..87f92c492efb 100644 --- a/src/histogram-inl.h +++ b/src/histogram-inl.h @@ -42,6 +42,7 @@ void Histogram::Reset() { RwLock::ScopedWriteLock lock(mutex_); hdr_reset(histogram_.get()); InvalidateRecordedSnapshot(); + reset_count_++; exceeds_ = 0; prev_ = 0; ewma_mean_ = 0; @@ -90,6 +91,11 @@ size_t Histogram::Exceeds() const { return exceeds_; } +uint64_t Histogram::ResetCount() const { + RwLock::ScopedReadLock lock(mutex_); + return reset_count_; +} + int64_t Histogram::Min() const { RwLock::ScopedReadLock lock(mutex_); return hdr_min(histogram_.get()); diff --git a/src/histogram.cc b/src/histogram.cc index 8c747729c482..568987382798 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -93,17 +93,22 @@ void CopyRecordedData(hdr_histogram* target, const hdr_histogram* source) { } } // namespace -std::shared_ptr Histogram::Clone() const { - // The layout is fixed when the histogram is created, so the copy can be - // allocated without holding the lock. - hdr_histogram* copy; +std::shared_ptr Histogram::CreateWithSameLayout() const { + // The layout is fixed when the histogram is created, so it can be read + // without holding the lock. + hdr_histogram* histogram; if (hdr_init(histogram_->lowest_discernible_value, histogram_->highest_trackable_value, histogram_->significant_figures, - ©) != 0) { + &histogram) != 0) { return {}; } - auto clone = std::make_shared(HistogramPointer(copy), Options{}); + return std::make_shared(HistogramPointer(histogram), Options{}); +} + +std::shared_ptr Histogram::Clone() const { + std::shared_ptr clone = CreateWithSameLayout(); + if (!clone) return {}; // Every member that holds recorded or statistical state must be copied // here. The recorded snapshot cache is not copied; the clone builds its own @@ -112,6 +117,7 @@ std::shared_ptr Histogram::Clone() const { CopyRecordedData(clone->histogram_.get(), histogram_.get()); clone->prev_ = prev_; clone->exceeds_ = exceeds_; + clone->reset_count_ = reset_count_; clone->ewma_alpha_ = ewma_alpha_; clone->ewma_mean_ = ewma_mean_; clone->ewma_variance_ = ewma_variance_; @@ -121,6 +127,59 @@ std::shared_ptr Histogram::Clone() const { return clone; } +std::shared_ptr Histogram::Diff(const Histogram& other, + DiffError* error) const { + // Counts are subtracted index by index, so both histograms must map values + // to the same indexes. None of these fields change after creation. + if (!IsCompatible(other) || histogram_->normalizing_index_offset != + other.histogram_->normalizing_index_offset) { + *error = DiffError::kIncompatible; + return {}; + } + + std::shared_ptr diff = CreateWithSameLayout(); + if (!diff) { + *error = DiffError::kOutOfMemory; + return {}; + } + + // Only the recorded values and the exceeds count carry over. EWMA and timing + // state cannot be subtracted. + uint64_t reset_count; + { + RwLock::ScopedReadLock lock(mutex_); + CopyRecordedData(diff->histogram_.get(), histogram_.get()); + diff->exceeds_ = exceeds_; + reset_count = reset_count_; + } + + // `diff` is not shared yet, so only the lock of `other` is needed from here + // on. Never holding both locks at once avoids lock ordering issues. + RwLock::ScopedReadLock lock(other.mutex_); + if (reset_count != other.reset_count_) { + *error = DiffError::kReset; + return {}; + } + if (diff->exceeds_ < other.exceeds_) { + *error = DiffError::kNotEarlier; + return {}; + } + + hdr_histogram* target = diff->histogram_.get(); + const hdr_histogram* source = other.histogram_.get(); + for (int32_t i = 0; i < target->counts_len; i++) { + if (target->counts[i] < source->counts[i]) { + *error = DiffError::kNotEarlier; + return {}; + } + target->counts[i] -= source->counts[i]; + } + diff->exceeds_ -= other.exceeds_; + hdr_reset_internal_counters(target); + *error = DiffError::kNone; + return diff; +} + void Histogram::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackFieldWithSize("histogram", GetMemorySize()); tracker->TrackFieldWithSize("qrde_snapshot", @@ -313,6 +372,7 @@ double Histogram::Subtract(const Histogram& other) { } hdr_reset_internal_counters(histogram_.get()); InvalidateRecordedSnapshot(); + reset_count_++; exceeds_ = (exceeds_ > other.exceeds_) ? exceeds_ - other.exceeds_ : 0; return static_cast(dropped); }; @@ -1844,6 +1904,8 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local tmpl) { &fast_get_ewma_error_rate_); SetProtoMethodNoSideEffect(isolate, tmpl, "export", DoExport); SetProtoMethodNoSideEffect(isolate, tmpl, "snapshot", DoSnapshot); + SetProtoMethodNoSideEffect(isolate, tmpl, "diff", DoDiff); + SetProtoMethodNoSideEffect(isolate, tmpl, "resetCount", GetResetCount); SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_); } @@ -1894,6 +1956,8 @@ void HistogramImpl::RegisterExternalReferences( registry->Register(GetEwmaErrorRate); registry->Register(DoExport); registry->Register(DoSnapshot); + registry->Register(DoDiff); + registry->Register(GetResetCount); registry->Register(fast_get_ewma_mean_); registry->Register(fast_get_ewma_stddev_); registry->Register(fast_get_ewma_error_rate_); @@ -3054,6 +3118,39 @@ void HistogramImpl::DoSnapshot(const FunctionCallbackInfo& args) { if (result) args.GetReturnValue().Set(result->object()); } +void HistogramImpl::DoDiff(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + HistogramImpl* other = HistogramImpl::FromJSObject(args[0]); + Histogram::DiffError error; + std::shared_ptr diff = + (*histogram)->Diff(*(other->histogram()), &error); + switch (error) { + case Histogram::DiffError::kNone: + break; + case Histogram::DiffError::kOutOfMemory: + return THROW_ERR_MEMORY_ALLOCATION_FAILED(env); + case Histogram::DiffError::kIncompatible: + return THROW_ERR_INVALID_ARG_VALUE( + env, "other must have the same configuration as the histogram"); + case Histogram::DiffError::kReset: + return THROW_ERR_INVALID_STATE( + env, "Values were removed from the histogram after other was taken"); + case Histogram::DiffError::kNotEarlier: + return THROW_ERR_INVALID_ARG_VALUE( + env, "other contains values that are not in the histogram"); + } + + BaseObjectPtr result = + HistogramBase::Create(env, std::move(diff)); + if (result) args.GetReturnValue().Set(result->object()); +} + +void HistogramImpl::GetResetCount(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + args.GetReturnValue().Set(static_cast((*histogram)->ResetCount())); +} + void HistogramImpl::GetPercentilesAt(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); diff --git a/src/histogram.h b/src/histogram.h index 473ebf97136b..93fe2b267d46 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -65,6 +65,23 @@ class Histogram : public MemoryRetainer { // if the copy cannot be allocated. std::shared_ptr Clone() const; + enum class DiffError { + kNone, + kOutOfMemory, + // `other` has a different layout. + kIncompatible, + // Values were removed from this histogram after `other` was taken. + kReset, + // `other` contains values that this histogram does not. + kNotEarlier, + }; + + // Returns a new histogram containing the values recorded in this histogram + // after `other`, an earlier copy of it, was taken. Returns nullptr and sets + // `error` if the difference cannot be computed. + std::shared_ptr Diff(const Histogram& other, + DiffError* error) const; + Histogram(HistogramPointer histogram, const Options& options); virtual ~Histogram() = default; @@ -80,6 +97,7 @@ class Histogram : public MemoryRetainer { inline int64_t Percentile(double percentile) const; inline size_t Exceeds() const; inline size_t Count() const; + inline uint64_t ResetCount() const; inline uint64_t RecordDelta(); @@ -165,10 +183,13 @@ class Histogram : public MemoryRetainer { inline void UpdateEwma(double value); inline void InvalidateRecordedSnapshot(); size_t GetCachedRecordedSnapshotMemorySize() const; + std::shared_ptr CreateWithSameLayout() const; HistogramPointer histogram_; uint64_t prev_ = 0; size_t exceeds_ = 0; + // Incremented whenever recorded values are removed by Reset() or Subtract(). + uint64_t reset_count_ = 0; // EWMA state (active when ewma_alpha_ > 0) double ewma_alpha_ = 0; @@ -242,6 +263,8 @@ class HistogramImpl { static void DoExport(const v8::FunctionCallbackInfo& args); static void DoImport(const v8::FunctionCallbackInfo& args); static void DoSnapshot(const v8::FunctionCallbackInfo& args); + static void DoDiff(const v8::FunctionCallbackInfo& args); + static void GetResetCount(const v8::FunctionCallbackInfo& args); static void FastReset(v8::Local receiver); static double FastGetCount(v8::Local receiver); diff --git a/test/parallel/test-perf-hooks-histogram-diff.js b/test/parallel/test-perf-hooks-histogram-diff.js new file mode 100644 index 000000000000..45dc375d2d69 --- /dev/null +++ b/test/parallel/test-perf-hooks-histogram-diff.js @@ -0,0 +1,169 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { setTimeout: delay } = require('timers/promises'); +const { + createHistogram, + createSlidingWindowHistogram, + importHistogram, + monitorEventLoopDelay, +} = require('perf_hooks'); + +{ + const histogram = createHistogram({ highest: 1000, halfLife: 4, threshold: 50 }); + for (let i = 1; i <= 10; i++) histogram.record(i); + histogram.record(2000); + const previous = histogram.snapshot(); + for (let i = 101; i <= 120; i++) histogram.record(i); + histogram.record(3000); + histogram.record(4000); + const current = histogram.snapshot(); + const exportedPrevious = previous.export(); + const exportedCurrent = current.export(); + + const delta = current.diff(previous); + assert.strictEqual(delta.constructor.name, 'Histogram'); + assert.strictEqual(delta.record, undefined); + assert.strictEqual(delta.count, 20); + assert.strictEqual(delta.exceeds, 2); + assert.strictEqual(delta.min, 101); + assert.strictEqual(delta.max, 120); + assert.strictEqual(delta.resetCount, 0); + + // The difference has the same distribution as a histogram of the values + // recorded between the snapshots, and no EWMA state. + const expected = createHistogram({ highest: 1000 }); + for (let i = 101; i <= 120; i++) expected.record(i); + assert.deepStrictEqual(delta.percentiles, expected.percentiles); + assert.strictEqual(delta.ewmaMean, 0); + assert.strictEqual(delta.ewmaStddev, 0); + assert.strictEqual(delta.ewmaErrorRate, 0); + + // Neither histogram is changed. + assert.deepStrictEqual(previous.export(), exportedPrevious); + assert.deepStrictEqual(current.export(), exportedCurrent); + + assert.strictEqual(histogram.diff(previous).count, 20); + assert.strictEqual(histogram.diff(histogram).count, 0); + assert.strictEqual(current.diff(current).count, 0); + + // Reversed arguments. + assert.throws(() => previous.diff(current), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} + +{ + // Consumers with different intervals each keep their own previous snapshot. + const histogram = createHistogram(); + const consumers = [3, 10].map((interval) => ({ + interval, + previous: histogram.snapshot(), + pending: 0, + total: 0, + })); + for (let i = 1; i <= 100; i++) { + histogram.record(i); + for (const consumer of consumers) { + consumer.pending++; + if (i % consumer.interval !== 0) continue; + const current = histogram.snapshot(); + const delta = current.diff(consumer.previous); + assert.strictEqual(delta.count, consumer.pending); + assert.strictEqual(delta.min, i - consumer.pending + 1); + assert.strictEqual(delta.max, i); + consumer.total += delta.count; + consumer.previous = current; + consumer.pending = 0; + } + } + assert.strictEqual(consumers[0].total, 99); + assert.strictEqual(consumers[1].total, 100); +} + +{ + const histogram = createHistogram(); + assert.strictEqual(histogram.resetCount, 0); + histogram.record(1); + histogram.recordCorrected(100, 10); + histogram.add(createHistogram()); + assert.strictEqual(histogram.resetCount, 0); + + histogram.reset(); + assert.strictEqual(histogram.resetCount, 1); + histogram.record(1); + const previous = histogram.snapshot(); + histogram.reset(); + assert.strictEqual(histogram.resetCount, 2); + assert.strictEqual(previous.resetCount, 1); + + // A reset is detected even when every count has grown past its previous + // value since. + for (let i = 0; i < 10; i++) histogram.record(1); + assert.throws(() => histogram.diff(previous), { + code: 'ERR_INVALID_STATE', + }); + + // subtract() also removes values. Subtracting an empty histogram leaves + // every count unchanged. + const snapshot = histogram.snapshot(); + assert.strictEqual(snapshot.resetCount, 2); + histogram.subtract(createHistogram()); + assert.strictEqual(histogram.resetCount, 3); + assert.throws(() => histogram.diff(snapshot), { + code: 'ERR_INVALID_STATE', + }); +} + +{ + const histogram = createHistogram(); + for (const options of [{ lowest: 2 }, { highest: 1000 }, { figures: 2 }]) { + assert.throws(() => histogram.diff(createHistogram(options)), { + code: 'ERR_INVALID_ARG_VALUE', + }); + } + + // Histograms with a different normalizing index offset map values to + // different indexes. + const data = createHistogram().export(); + const offset = Buffer.from(data).indexOf(Buffer.from([0x06, 0x00, 0x07, 0x00])); + assert.notStrictEqual(offset, -1); + data[offset + 3] = 1; + assert.throws(() => histogram.diff(importHistogram(data)), { + code: 'ERR_INVALID_ARG_VALUE', + }); + + assert.throws(() => histogram.diff.call({}, histogram), { + code: 'ERR_INVALID_THIS', + }); + const { get } = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(histogram.snapshot()), 'resetCount'); + assert.throws(() => get.call({}), { code: 'ERR_INVALID_THIS' }); + const window = createSlidingWindowHistogram({ chunks: 1, recordsPerChunk: 1 }); + for (const other of [undefined, null, {}, 1, window]) { + assert.throws(() => histogram.diff(other), { + code: 'ERR_INVALID_ARG_TYPE', + }); + } +} + +(async () => { + const histogram = monitorEventLoopDelay({ samplePerIteration: true }); + histogram.enable(); + while (histogram.count < 2) await delay(1); + const previous = histogram.snapshot(); + while (histogram.count < previous.count + 3) await delay(1); + + // Samples are only recorded while the event loop is running. + const current = histogram.snapshot(); + assert.strictEqual(current.diff(previous).count, + current.count - previous.count); + + histogram.disable(); + histogram.reset(); + assert.strictEqual(histogram.resetCount, 1); + assert.throws(() => histogram.diff(previous), { + code: 'ERR_INVALID_STATE', + }); +})().then(common.mustCall()); diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts index adba289c81a9..dc30eca95310 100644 --- a/typings/internalBinding/performance.d.ts +++ b/typings/internalBinding/performance.d.ts @@ -42,6 +42,8 @@ declare namespace InternalPerformanceBinding { ewmaStddev(): number; ewmaErrorRate(): number; snapshot(): Histogram; + diff(other: HistogramBase): Histogram; + resetCount(): number; } interface ELDHistogram extends HistogramBase {