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 <jasnell@gmail.com>
Assisted-by: OpenCode
PR-URL: https://github.com/nodejs/node/pull/66099
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
James M Snell committed 2026-09-25 03:31:51 +00:00
1 parent 5af276ec30
commit b9e257f5cb
7 files changed
+271 -10

No files matched your search

@@ -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);
}
+32
View File
@@ -2689,6 +2689,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()`
<!-- YAML
added: REPLACEME
-->
* 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`
<!-- YAML
+12
View File
@@ -679,6 +679,18 @@ class Histogram {
this[kHandle]?.reset();
}
/**
* Returns a new, independent histogram containing a copy of this
* histogram's current state. Values cannot be recorded into the returned
* histogram.
* @returns {Histogram}
*/
snapshot() {
if (!isHistogram(this))
throw new ERR_INVALID_THIS('Histogram');
return new ClonedHistogram(this[kHandle].snapshot());
}
[kClone]() {
const handle = this[kHandle];
return {
+59 -10
View File
@@ -76,6 +76,51 @@ std::shared_ptr<Histogram> Histogram::Create(const Options& options) {
return std::make_shared<Histogram>(HistogramPointer(histogram), options);
}
namespace {
// Copies the recorded data of `source` into `target`, which must have been
// initialized with the same layout. The caller must hold a lock that prevents
// `source` from being modified during the copy.
void CopyRecordedData(hdr_histogram* target, const hdr_histogram* source) {
CHECK_EQ(target->counts_len, source->counts_len);
target->min_value = source->min_value;
target->max_value = source->max_value;
target->normalizing_index_offset = source->normalizing_index_offset;
target->conversion_ratio = source->conversion_ratio;
target->total_count = source->total_count;
std::memcpy(target->counts,
source->counts,
source->counts_len * sizeof(*source->counts));
}
} // namespace
std::shared_ptr<Histogram> 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;
if (hdr_init(histogram_->lowest_discernible_value,
histogram_->highest_trackable_value,
histogram_->significant_figures,
&copy) != 0) {
return {};
}
auto clone = std::make_shared<Histogram>(HistogramPointer(copy), Options{});
// Every member that holds recorded or statistical state must be copied
// here. The recorded snapshot cache is not copied; the clone builds its own
// on demand.
RwLock::ScopedReadLock lock(mutex_);
CopyRecordedData(clone->histogram_.get(), histogram_.get());
clone->prev_ = prev_;
clone->exceeds_ = exceeds_;
clone->ewma_alpha_ = ewma_alpha_;
clone->ewma_mean_ = ewma_mean_;
clone->ewma_variance_ = ewma_variance_;
clone->ewma_initialized_ = ewma_initialized_;
clone->threshold_ = threshold_;
clone->ewma_error_rate_ = ewma_error_rate_;
return clone;
}
void Histogram::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackFieldWithSize("histogram", GetMemorySize());
tracker->TrackFieldWithSize("qrde_snapshot",
@@ -158,16 +203,7 @@ Histogram::RecordedSnapshotSource Histogram::GetRecordedSnapshotSource(
return source;
}
CHECK_EQ(source.histogram->counts_len, histogram_->counts_len);
source.histogram->min_value = histogram_->min_value;
source.histogram->max_value = histogram_->max_value;
source.histogram->normalizing_index_offset =
histogram_->normalizing_index_offset;
source.histogram->conversion_ratio = histogram_->conversion_ratio;
source.histogram->total_count = histogram_->total_count;
std::memcpy(source.histogram->counts,
histogram_->counts,
histogram_->counts_len * sizeof(*histogram_->counts));
CopyRecordedData(source.histogram.get(), histogram_.get());
return source;
}
@@ -1926,6 +1962,7 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local<FunctionTemplate> tmpl) {
GetEwmaErrorRate,
&fast_get_ewma_error_rate_);
SetProtoMethodNoSideEffect(isolate, tmpl, "export", DoExport);
SetProtoMethodNoSideEffect(isolate, tmpl, "snapshot", DoSnapshot);
SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_);
}
@@ -1975,6 +2012,7 @@ void HistogramImpl::RegisterExternalReferences(
registry->Register(GetEwmaStddev);
registry->Register(GetEwmaErrorRate);
registry->Register(DoExport);
registry->Register(DoSnapshot);
registry->Register(fast_get_ewma_mean_);
registry->Register(fast_get_ewma_stddev_);
registry->Register(fast_get_ewma_error_rate_);
@@ -3108,6 +3146,17 @@ void HistogramImpl::DoImport(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(obj);
}
void HistogramImpl::DoSnapshot(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
std::shared_ptr<Histogram> snapshot = (*histogram)->Clone();
if (!snapshot) return THROW_ERR_MEMORY_ALLOCATION_FAILED(env);
BaseObjectPtr<HistogramBase> result =
HistogramBase::Create(env, std::move(snapshot));
if (result) args.GetReturnValue().Set(result->object());
}
void HistogramImpl::GetPercentilesAt(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
+5
View File
@@ -61,6 +61,10 @@ class Histogram : public MemoryRetainer {
// Factory method that returns nullptr on hdr_init failure.
static std::shared_ptr<Histogram> Create(const Options& options);
// Returns an independent copy of this histogram's current state, or nullptr
// if the copy cannot be allocated.
std::shared_ptr<Histogram> Clone() const;
Histogram(HistogramPointer histogram, const Options& options);
virtual ~Histogram() = default;
@@ -237,6 +241,7 @@ class HistogramImpl {
static void GetEwmaErrorRate(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DoExport(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DoImport(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DoSnapshot(const v8::FunctionCallbackInfo<v8::Value>& args);
static void FastReset(v8::Local<v8::Value> receiver);
static double FastGetCount(v8::Local<v8::Value> receiver);
@@ -0,0 +1,138 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const { setTimeout: delay } = require('timers/promises');
const { inspect } = require('util');
const {
createHistogram,
monitorEventLoopDelay,
} = require('perf_hooks');
function assertSameState(actual, expected) {
assert.strictEqual(actual.countBigInt, expected.countBigInt);
assert.strictEqual(actual.minBigInt, expected.minBigInt);
assert.strictEqual(actual.maxBigInt, expected.maxBigInt);
assert.strictEqual(actual.exceedsBigInt, expected.exceedsBigInt);
assert.strictEqual(actual.mean, expected.mean);
assert.strictEqual(actual.stddev, expected.stddev);
assert.strictEqual(actual.ewmaMean, expected.ewmaMean);
assert.strictEqual(actual.ewmaStddev, expected.ewmaStddev);
assert.strictEqual(actual.ewmaErrorRate, expected.ewmaErrorRate);
assert.deepStrictEqual(actual.percentilesBigInt, expected.percentilesBigInt);
// The export format also covers the layout, the raw min and max values,
// every bucket count, and the EWMA configuration.
assert.deepStrictEqual(actual.export(), expected.export());
}
function assertReadOnly(snapshot) {
assert.strictEqual(snapshot.constructor.name, 'Histogram');
assert.strictEqual(inspect(snapshot, { depth: -1 }), '[Histogram]');
for (const name of [
'record',
'recordDelta',
'recordCorrected',
'add',
'subtract',
'enable',
'disable',
]) {
assert.strictEqual(snapshot[name], undefined);
}
}
{
const histogram = createHistogram({
lowest: 1,
highest: 1000,
figures: 2,
halfLife: 8,
threshold: 50,
});
for (let i = 1; i <= 100; i++) histogram.record(i * 7);
histogram.record(1e6);
assert.strictEqual(histogram.exceeds, 1);
assert.ok(histogram.ewmaErrorRate > 0);
const snapshot = histogram.snapshot();
assert.notStrictEqual(snapshot, histogram);
assertReadOnly(snapshot);
assertSameState(snapshot, histogram);
assert.strictEqual(histogram.ksTest(snapshot), 0);
// A snapshot can be snapshotted and cloned.
const nested = snapshot.snapshot();
assertReadOnly(nested);
assertSameState(nested, snapshot);
assertSameState(structuredClone(snapshot), snapshot);
// Changes to the source histogram do not change the snapshot.
const exported = snapshot.export();
histogram.record(3);
histogram.recordCorrected(900, 100);
histogram.record(1e6);
assert.deepStrictEqual(snapshot.export(), exported);
assert.strictEqual(snapshot.count, 100);
assert.strictEqual(snapshot.exceeds, 1);
histogram.reset();
assert.strictEqual(histogram.count, 0);
assert.deepStrictEqual(snapshot.export(), exported);
// Resetting a snapshot does not change the source histogram or other
// snapshots.
histogram.record(5);
const current = histogram.snapshot();
snapshot.reset();
assert.strictEqual(snapshot.count, 0);
assert.strictEqual(histogram.count, 1);
assert.strictEqual(current.count, 1);
assert.deepStrictEqual(nested.export(), exported);
}
{
// Empty histograms, with and without EWMA state.
for (const options of [undefined, { halfLife: 10, threshold: 1 }]) {
const histogram = createHistogram(options);
const snapshot = histogram.snapshot();
assertReadOnly(snapshot);
assert.strictEqual(snapshot.count, 0);
assert.strictEqual(snapshot.minBigInt, 9223372036854775807n);
assert.strictEqual(snapshot.maxBigInt, 0n);
assertSameState(snapshot, histogram);
}
}
{
const histogram = createHistogram();
assert.throws(() => histogram.snapshot.call({}), {
code: 'ERR_INVALID_THIS',
});
}
async function testEventLoopDelay(options) {
const histogram = monitorEventLoopDelay(options);
histogram.enable();
while (histogram.count < 3) await delay(1);
// Samples are only recorded while the event loop is running, so none can
// be added while this code runs synchronously.
const snapshot = histogram.snapshot();
assertReadOnly(snapshot);
assertSameState(snapshot, histogram);
const exported = snapshot.export();
const count = histogram.count;
while (histogram.count === count) await delay(1);
assert.deepStrictEqual(snapshot.export(), exported);
histogram.disable();
histogram.reset();
assert.strictEqual(histogram.count, 0);
assert.deepStrictEqual(snapshot.export(), exported);
}
(async () => {
await testEventLoopDelay({ resolution: 1 });
await testEventLoopDelay({ samplePerIteration: true });
})().then(common.mustCall());
+1
View File
@@ -41,6 +41,7 @@ declare namespace InternalPerformanceBinding {
ewmaMean(): number;
ewmaStddev(): number;
ewmaErrorRate(): number;
snapshot(): Histogram;
}
interface ELDHistogram extends HistogramBase {